
简介一套基于 Python、Django 与 OpenCV 的疲劳检测系统毕业设计论文文档适用于计算机、软件工程等相关专业学生完成课程设计或毕业论文撰写。论文围绕眼动信号与人脸判断展开借助 OpenCV 图像处理库完成眼睛闭合程度检测并结合面部表情、眨眼频次对疲劳状态进行量化分析同时给出基于 Python 编程语言和 MySQL 数据库实现图像识别、图片分析及照片管理等模块的设计思路为读者梳理了从图像采集、特征提取到疲劳判定的完整流程也能帮助理解系统整体架构、核心算法与实际编码实现之间的对应关系。资源包内为 1 个 docx 格式文档大小约 1.01MB包含中英文摘要、目录、绪论、相关技术介绍、系统设计、功能模块说明及参考文献等完整章节既可作为论文写作的格式模板也可作为疲劳检测算法与 Django 项目开发的参考资料。已有 365 人学习下载对正在开展相关课题或准备毕业答辩的学生具有较高参考价值。1. 疲劳检测系统不是模型比赛先把“判疲”写成可量化状态机基于PythonDjangoOpenCV的疲劳检测系统听起来像是一套“人脸框一画、Django一跑、论文一交”的毕设套餐但真正自己动手做过的都会承认最难的从来不是把Django建起来也不是让OpenCV弹出人脸框而是让“疲劳”这个词在代码里变成一个能落库、能回放、能解释的量化指标。这个方向能做的事很多驾驶员疲劳预警、网课专注度分析、危险作业岗前状态监控甚至是工厂的工时状态统计。这篇文章就按“采集—判疲—上报”的链路把OpenCV检测、Django接口和数据库设计串起来给你一份能直接照着搭的最小实现以及那些论文里不会写的坑。适合正在做毕设、做小型安防Demo或者想从单机算法转Web化交付的开发者。2. 技术选型与参数基线DjangoOpenCV各自扛住哪一段2.1 OpenCV只负责“看得见”Django只负责“记得住”很多项目从一开始就错了想在Django里直接调用OpenCV做实时推理于是把所有逻辑都堆在view函数里结果一个请求进来视频流卡住页面也卡住。这个系统的正确拆法是OpenCV负责摄像头采集、人脸检测、关键点提取和疲劳判定Django只负责接收检测结果、写入数据库、对外提供查询接口。两者之间用消息队列或者直接HTTP上报解耦吞吐能力完全不同。我自己写这套系统时会把整个进程拆成三块采集检测进程、消息队列、Django Web服务。采集进程是唯一能碰摄像头的进程拿到单帧后跑OpenCV的dnn或dlib关键点模型算出EAR、MAR这些指标然后把结果推到队列Django这边起一个消费线程把队列里的数据批量落库。好处是哪怕Web服务重启、数据库暂时不可用检测进程也不会崩帧数据还能留在队列里补录。为什么不把检测直接做成Django的一个依赖因为OpenCV的VideoCapture在部分驱动下会和Django的autoreload、多线程模型打架而且CPU推理本身会阻塞event loop。如果坚持同步接口常见做法是把检测结果缓存到RedisTTL设3秒接口直接读Redis返回这样至少不会因为一次推理就把请求线程全占住。还有一个常见误用把cv2.VideoCapture(0)直接写在Django模块顶部。dev server的autoreload会加载两遍模块第二个进程再去open摄像头就会报Device or resource busy。如果你看到这个报错先别怀疑OpenCV去检查是不是有两个Python进程同时打开了摄像头。这也是我把采集进程独立出来的另一个原因。2.2 先定疲劳指标再谈算法模型疲劳检测的“疲劳”不能靠感觉定。目前从业界到论文里被复用最多的三个指标是EAR眼部纵横比、MAR嘴部纵横比和PERCLOS眼睛闭合时间占比。EAR用来度量眼睛闭合程度正常睁眼时稳定在0.3以上闭眼时会掉到0.15以下MAR用来度量嘴巴张开程度打哈欠时嘴部纵横比会显著拉高PERCLOS则是统计一段时间内闭眼帧数占窗口总帧数的比例比单次眨眼更能代表持续的疲劳趋势。这三个指标的阈值选取很有讲究。很多人直接抄论文里的固定值比如EAR取0.25一旦换摄像头、换分辨率、换关键点模型检测就翻车。我更建议把阈值当初始值部署前用一段人工标注视频做标定。具体怎么标定最后一章会给出脚本。下面这张表是常见的经验基线注意它是起点不是终点指标计算方式经验阈值说明EAR眼高/眼宽比值 0.25判闭眼68点/6点模型都适用需标定眨眼闭合帧数连续闭眼帧数2~3帧判一次眨眼太长会把眨眼漏掉MAR嘴高/嘴宽比值 0.6判张嘴与打哈欠的嘴型相关哈欠持续帧数连续张嘴帧数10~15帧判一次哈欠短张嘴不算PERCLOS闭眼帧数/窗口帧数 0.4判疲劳窗口通常取30~60秒从表格里能看出疲劳判断其实是“事件统计”不是“单帧分类”。这决定了后续的代码里一定要出现计数器、滑动窗口和状态机而不是只画一个人脸框。2.3 版本组合先按这套环境拉依赖Python版本、Django版本和OpenCV版本三者必须一起定不能各自最新。当前较稳的组合是Python 3.8或3.10、Django 3.2 LTS或4.2 LTS、opencv-python 4.5.x到4.8.x。OpenCV 4.4曾经在Windows上出现源码编译报错很多人卡在pip install opencv-python那一步就是版本和Python环境不匹配。后面避坑章节会专门说这个问题。先按这套组合初始化环境比较省事python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install django4.2.9 opencv-python4.8.1.78 numpy1.24.4Django侧还要选好数据库。默认SQLite在单机毕设里完全够用能直接跑通如果要做并发上报和多终端查询建议换成MySQL并给疲劳记录表加上personnel_id和created_at的联合索引。ORM在写入频率高的时候要记得用bulk_create或事务批量提交避免一帧一条INSERT把数据库拖垮。版本之外还有一对容易打架的包opencv-python和opencv-contrib-python。前者只含主模块后者额外带contrib算法两个包不能同时装在同一环境否则site-packages里会出现cv2的重复符号import时随机报错。很多人的做法是只装opencv-contrib-python因为里面包含了SIFT、xfeatures2d等算法如果项目里只用dnn和人脸检测装opencv-python就够了。确认环境的命令是pip list | grep opencv一旦发现两个包都在就pip uninstall掉其中一个再重装。3. OpenCV疲劳检测的四个可复现模块人脸框、EAR阈值、哈欠判定与状态机3.1 人脸检测优先用OpenCV DNN模型而不是Haar特征Haar级联在嵌入式或老机器上很经典但到了低光照、侧脸、戴眼镜这些真实场景误检漏检都偏高。我现在更习惯用OpenCV自带的dnn人脸检测器res10 SSD模型它在CPU上的单帧耗时大约20到40毫秒比Haar慢一点但稳定很多。模型文件是caffemodel不需要额外装深度学习框架OpenCV的dnn模块直接读。import cv2 import numpy as np prototxt deploy.prototxt caffemodel res10_300x300_ssd_iter_140000.caffemodel net cv2.dnn.readNetFromCaffe(prototxt, caffemodel) def detect_face(frame, conf_threshold0.7): h, w frame.shape[:2] # 模型输入固定为300x300RGB均值做减均值预处理 blob cv2.dnn.blobFromImage( cv2.resize(frame, (300, 300)), 1.0, (300, 300), (104.0, 177.0, 123.0) ) net.setInput(blob) detections net.forward() faces [] for i in range(detections.shape[2]): conf detections[0, 0, i, 2] if conf conf_threshold: box detections[0, 0, i, 3:7] * np.array([w, h, w, h]) x1, y1, x2, y2 box.astype(int) x1, y1 max(0, x1), max(0, y1) x2, y2 min(w, x2), min(h, y2) faces.append((x1, y1, x2, y2)) return faces这里有个细节blobFromImage里传入的均值是(104.0, 177.0, 123.0)这是SSD训练时的BGR均值不能随意改成(0,0,0)或者(127.5,127.5,127.5)否则检测精度会明显下降。conf_threshold我习惯设0.7人脸太小的时候降到0.5但会有更多误检。拿到人脸框后下一步要把框放大1.1倍再送入关键点模型因为原框往往切得太紧会裁掉半边眉毛。3.2 眨眼检测EAR和连续帧计数眨眼判定的核心是EAR。它利用眼周关键点计算眼部高度和宽度的比值睁眼时垂直距离大闭眼时垂直距离趋近于零。这个算子比直接量瞳距稳定得多因为它是归一化的对摄像头距离不敏感。import numpy as np def eye_aspect_ratio(eye): # 传入单眼6个关键点顺序是右眼角顺时针 a np.linalg.norm(eye[1] - eye[5]) b np.linalg.norm(eye[2] - eye[4]) c np.linalg.norm(eye[0] - eye[3]) ear (a b) / (2.0 * c) return ear EYE_AR_THRESH 0.25 BLINK_CONSEC_FRAMES 2 left_eye_index list(range(42, 48)) right_eye_index list(range(36, 42)) left_ear eye_aspect_ratio(landmarks[left_eye_index]) right_ear eye_aspect_ratio(landmarks[right_eye_index]) ear (left_ear right_ear) / 2.0 if ear EYE_AR_THRESH: blink_counter 1 else: if blink_counter BLINK_CONSEC_FRAMES: blink_total 1 blink_counter 0这里最容易被忽略的是左右眼的索引顺序。dlib的68点模型中36到41是右眼42到47是左眼但关键点顺序是“从眼角开始顺时针”如果按数组切片直接传给EAR函数大概率会把眼角当上下眼睑算出一个永远不变的异常值。我建议第一次跑通后先把每只眼的6个点可视化打印出来确认坐标顺序再继续。BLINK_CONSEC_FRAMES设2表示连续2帧EAR低于阈值才算一次眨眼。如果视频只有15帧每秒这个值可以放大到3防止眨眼被误拆成两次。3.3 哈欠检测MAR配合嘴型宽高比哈欠检测的公式和EAR几乎一样只是把目标从眼睛换成嘴巴。嘴部8个关键点取纵向距离和横向宽度的比值张嘴时MAR上升闭嘴时回落。和眨眼不同的是哈欠的持续时间更长所以判哈欠时要单独再设一个持续帧数阈值。def mouth_aspect_ratio(mouth): # mouth为8个关键点61, 62, 63在上唇65, 66, 67在下唇 a np.linalg.norm(mouth[2] - mouth[9]) b np.linalg.norm(mouth[4] - mouth[7]) c np.linalg.norm(mouth[0] - mouth[6]) mar (a b) / (2.0 * c) return mar MAR_THRESH 0.6 YAWN_CONSEC_FRAMES 12 mar mouth_aspect_ratio(landmarks[list(range(60, 68))]) if mar MAR_THRESH: yawn_counter 1 else: if yawn_counter YAWN_CONSEC_FRAMES: yawn_total 1 yawn_counter 0需要说明MAR到0.6这个经验值会受模型影响。dlib的68点模型本身是拿人脸对齐任务训练的嘴型宽度在高分辨率上识别得准但换到低分辨率摄像头时MAR会整体偏低。我遇到过一个案例同一张嘴在720p摄像头下MAR能到0.8换到480p工业相机后只有0.5阈值不变的话哈欠就永远触发不了。这时候要么降低MAR阈值要么把嘴部区域放大后再算。3.4 疲劳状态机用计数器把“单帧异常”变成“持续疲劳”单帧的EAR小于阈值只是闭了一下眼睛不代表疲劳。疲劳一定是个持续状态所以需要一个状态机来聚合眨眼频率、哈欠频率和PERCLOS。这个状态机如果写在每一帧的逻辑里代码会很快变得不可读我一般单独抽一个类出来。class FatigueStatus: def __init__(self, perclos_window60, perclos_thresh0.4, min_yawns2): self.eye_close_frames 0 self.total_frames 0 self.yawn_count 0 self.status normal self.window perclos_window self.perclos_thresh perclos_thresh self.min_yawns min_yawns def update(self, is_eye_closed, is_yawn): self.total_frames 1 if is_eye_closed: self.eye_close_frames 1 if is_yawn: self.yawn_count 1 if self.total_frames self.window: perclos self.eye_close_frames / self.total_frames if perclos self.perclos_thresh or self.yawn_count self.min_yawns: self.status fatigue else: self.status normal # 滑动窗口只保留最近N帧的统计否则旧数据一直占着内存 self.eye_close_frames 0 self.total_frames 0 self.yawn_count 0 return self.status注意这个简化版的滑动窗口是“整段清零”严格说法是每N帧滚动一次。实际工程里更常用的是deque双端队列窗口内只保留最近60帧的布尔值这样PERCLOS是真正滑动的不会出现“59秒内一直打哈欠最后一秒清零”的假疲劳。窗口长度建议取30到60秒太短会把一次低头误判成疲劳太长又反应迟钝。4. Django接入方案数据表、REST接口与MJPEG推流页面4.1 数据表设计疲劳事件与检测记录分开建从零搭Django侧时我一般先执行django-admin startproject fatigue_web python manage.py startapp detector接着在settings.py里注册app最后才是建模型和写接口。疲劳检测系统的数据库不只是存一张表至少需要人员/设备表、检测记录表和疲劳事件表。检测记录表存每一帧或每几帧的EAR、MAR、状态疲劳事件表只在状态从正常切到疲劳时插入一条事件记录发生时间、持续时长、触发原因。这样既方便论文出图也方便事后排查。from django.db import models class Personnel(models.Model): name models.CharField(max_length32) personnel_id models.CharField(max_length32, uniqueTrue) create_time models.DateTimeField(auto_now_addTrue) class DetectionRecord(models.Model): personnel models.ForeignKey(Personnel, on_deletemodels.CASCADE) frame_time models.DateTimeField(auto_now_addTrue) ear models.FloatField(default0) mar models.FloatField(default0) blink_rate models.FloatField(default0) status models.CharField(max_length16, defaultnormal) class Meta: indexes [ models.Index(fields[personnel, -frame_time]), ] class FatigueEvent(models.Model): personnel models.ForeignKey(Personnel, on_deletemodels.CASCADE) started_at models.DateTimeField(auto_now_addTrue) ended_at models.DateTimeField(nullTrue, blankTrue) reason models.CharField(max_length32)这里把DetectionRecord和FatigueEvent分开的原因很实际检测记录量很大按帧存的话一分钟就有1800条如果全塞给前端拉列表接口迟早被打爆事件表一天只有几十条适合做报表。论文里的准确率统计也应该以事件表为准而不是直接把帧记录导出来。外键和联合索引能保证按人员查历史记录时不会全表扫描。4.2 REST接口让检测进程把结果“报”上来Django侧先提供一个最朴素的POST接口让检测进程把算好的指标传过来。不用上DRF也能跑通但生产环境建议换成DRF加Serializer。这里为了让你最小成本复现我用JsonResponse直接返回。import json from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from .models import DetectionRecord, FatigueEvent, Personnel csrf_exempt def report_detection(request): if request.method ! POST: return JsonResponse({code: 405, msg: only POST allowed}) try: data json.loads(request.body) personnel Personnel.objects.get(personnel_iddata[personnel_id]) except (json.JSONDecodeError, KeyError, Personnel.DoesNotExist): return JsonResponse({code: 400, msg: bad request}) record DetectionRecord.objects.create( personnelpersonnel, eardata.get(ear, 0), mardata.get(mar, 0), blink_ratedata.get(blink_rate, 0), statusdata.get(status, normal), ) # 状态切到疲劳时写一条事件 if data.get(status) fatigue: FatigueEvent.objects.get_or_create( personnelpersonnel, ended_at__isnullTrue, defaults{reason: data.get(reason, perclos)}, ) return JsonResponse({code: 0, record_id: record.id})为什么用get_or_create而不是create因为检测进程每个窗口期都可能上报疲劳状态如果不加约束一次疲劳会生成几十条相同事件。让事件表以“ended_at为空”作为未结束的标记下一次上报疲劳时直接复用当前未结束的事件等到正常状态后再把ended_at写上。不这么做的话论文里的疲劳次数统计会虚高好几倍。上报频率也需要设计。检测进程可以每5帧算一次平均EAR/MAR再上报不要每个单帧都打一个POST请求。这样一分钟的上报量从1800条降到360条数据库压力小一个数量级。如果还嫌多就在Django消费端做bulk_create每10秒批量刷一次接口只负责接收并暂存在内存里。4.3 实时监控页MJPEG推流的成本最低Django里做实时视频预览最省事的方案是MJPEG推流。原理是HTTP响应保持不关闭帧以multipart/x-mixed-replace格式持续下发浏览器里的img标签就能直接播放。它比WebSocket简单也不需要额外装channels缺点是单路连接占带宽只适合本地或内网预览。import cv2 from django.http import StreamingHttpResponse def gen_frames(): cap cv2.VideoCapture(0) while True: ok, frame cap.read() if not ok: break # 这里可以复用第3章的检测函数把EAR/MAR画到帧上 ret, jpeg cv2.imencode(.jpg, frame) if not ret: continue frame_bytes jpeg.tobytes() yield (b--frame\r\n bContent-Type: image/jpeg\r\n\r\n frame_bytes b\r\n) def video_feed(request): return StreamingHttpResponse(gen_frames(), content_typemultipart/x-mixed-replace; boundaryframe)这个推流视图有个致命问题是它会一直占用摄像头的VideoCapture对象。如果你是边推流边检测同一个摄像头设备不能被两个进程重复open。常见解法是采集进程只做一个生产者把OpenCV的帧放进一个带锁的全局队列推流视图和检测模块都从队列里取帧而不是各自open摄像头。另一个坑是Django的dev server默认单线程推流会占掉一个worker导致其他接口卡死上线后用uwsgi或gunicorn多worker部署才能缓解。这个“推流和检测抢摄像头”的坑我放到下一章细说。5. 疲劳检测落地避坑光照、OpenCV版本与跨线程数据库5.1 白天准、晚上全挂问题不在算法在补光现象同一套EAR阈值和关键点模型白天检测正常到了晚上或者逆光场景闭眼误判率从5%涨到40%眨眼频率直接翻倍。原因普通RGB摄像头在低照度下眼部和皮肤对比度下降关键点模型输出的坐标开始抖动EAR的垂直距离忽大忽小尤其是下眼睑的2个点经常被识别到眼睛外面去。解决优先换红外或双光摄像头红外图像不受可见光影响这是很多商用疲劳驾驶方案的做法。如果手里只有普通摄像头就在预处理阶段加CLAHE自适应直方图均衡代码里只有一行cv2.createCLAHE(clipLimit2.0, tileGridSize(8,8)).apply(gray)。同时把人脸检测的置信度从0.7降到0.5防止关键点模型拿到一个模糊的小脸硬算。实测下来CLAHE能把夜间EAR抖动的方差缩小约30%但并不能彻底解决所以还要配合头部姿态过滤。如果画面里出现低头或转头关键点会被遮挡EAR同样会掉到阈值以下。判断是不是真闭眼可以用cv2.solvePnP配合人脸关键点做头部姿态估计算出头部的俯仰角和偏航角头部低头超过30度时不参与闭眼统计。这是很多商用系统的做法但需要先知道摄像头的内参矩阵否则算出来的欧拉角只有相对意义。5.2 OpenCV装不上或cv2.error先查版本组合现象pip install opencv-python报错或者在import cv2时报cv2.error: OpenCV(4.4.0)...pip-req-build还有更常见的ModuleNotFoundError: No module named cv2。原因OpenCV的wheel包对Python版本和系统架构有严格限制。Python 3.9以上装opencv-python 4.4.0会因为没有对应wheel而尝试本地编译编译时缺少CMake或MSVC工具链就直接炸掉。报错信息里出现的pip-req-build路径就是正在源码编译而不是在下载wheel。解决优先用pip安装指定小版本的预编译包比如pip install opencv-python4.6.0.66或者直接用更高版本pip install opencv-python -U。Windows用户要注意Python是64位还是32位32位环境下很多新版本OpenCV已经不出wheel了。如果公司内网禁pip那就用conda创建独立环境conda install opencv会走conda镜像依赖问题少很多。不要把时间浪费在源码编译上除非你确实要改OpenCV底层源码。5.3 眨眼次数统计翻倍EAR阈值不能一刀切现象一个人的正常眨眼频率从统计上的每分钟15次变成了30次甚至连续眨眼被拆成两三次单独事件。原因EAR阈值设得太高比如默认0.25在某个摄像头下人脸偏小睁眼时的EAR本来就只有0.22于是代码把“睁眼”当成“闭眼”还有一种情况是检测帧率不稳定同一帧被处理两次眨眼计数器被重复累加。解决先做离线标定录一段10秒正常睁眼和10秒闭眼的视频分别统计EAR分布把阈值取在两类分布的中间位置。这比抄论文参数可靠得多。处理上再做两层过滤第一层对EAR序列做3帧滑动平均抑制单帧抖动第二层记录每次眨眼事件的最小闭合帧数小于2帧的丢弃。如果摄像头存在丢帧要给每帧带上时间戳按时间窗口统计不要按帧数统计。5.4 Django收不到检测数据别着急加线程现象检测进程是单独的Python脚本往Django接口POST数据时偶尔成功偶尔超时重启Django后能恢复一阵然后又不行。原因最典型的是检测进程里有人在子线程中直接用了Django ORM。Django的数据库连接是基于thread-local的子进程或threading线程拿到的连接是父进程复制出来的脏连接写入时MySQL会报“commands out of sync”或直接卡死。常见表现就是前几次写入成功后面越积越慢。解决检测进程只把结果推给Redis队列Django里单独起一个management command做消费端从Redis取数据后统一落库。这样Django的ORM只在它自己的进程里使用不跨线程。另一个更简单但够用的方案是检测进程只用requests.post调第4章写的接口把ORM彻底留在Django侧。加线程解决不了本质问题只会把并发问题变成连接池问题。6. 从“能跑”到“能用”离线回放调参和阈值自标定脚本疲劳检测系统交到用户手里被吐槽最多的一句话是“它乱报警”。乱报警的来源通常不是算法不够新而是阈值是抄来的。我这两年养成的习惯是任何疲劳检测项目上线前必须先录一段真实场景视频做离线回放把EAR、MAR逐帧打点再和人工标注对比用脚本搜出当前场景的最优阈值。这个步骤在论文里还可以变成一张数据表比空谈准确率更能说服人。离线标定的思路很直接录三段视频一段正常睁眼、一段频繁眨眼、一段打哈欠人工把疲劳起止时间标出来然后对候选阈值做网格搜索跑一遍视频算出检测结果和人工标注计算IoU或F1分数取最高分对应的阈值。import cv2 import numpy as np def search_ear_threshold(video_path, gt_events, candidates): best None best_score -1 for thr in candidates: pred run_detector(video_path, ear_thresholdthr) score event_iou(pred, gt_events) if score best_score: best_score score best thr return best, best_score # candidates: 从0.15到0.35每隔0.01取一个 best_thr, score search_ear_threshold( normal.mp4, gt_events, np.arange(0.15, 0.35, 0.01) )这段代码只是给了网格搜索的骨架实际跑的时候要先把run_detector封装成接收阈值参数的纯函数不要让它去读全局变量。candidates的步长不要小于0.01否则标定时间翻倍但精度提升有限。除了阈值还可以把关键点模型本身加入搜索范围对比dlib和OpenCV自带的人脸关键点模型在同一段视频上的表现这也是一种常见的模型选型验证手段。另一个能用的小技巧是给Django后台加一个“回放页面”上传一段视频页面上能拖动进度条看到每一帧的EAR曲线和疲劳状态标记。这样验收时直接拉着用户看曲线比看密密麻麻的报警记录直观得多。这套做法帮我把误报率从最初抄阈值的每小时七八次压到了一两次也让我养成了一个习惯以后再做疲劳检测第一件事永远先问对方要一段真实场景视频不要拿着实验室的标清录像去估阈值。希望这份从选型到避坑的记录能帮你少走点弯路。如果你也打算在这个方向做毕设或产品原型建议把上面这些模块先拆开跑通再合到一起最后补上标定和回放这套链路的抗风险能力会高很多。希望帮到你。本文还有配套的精品资源点击获取