简介面向计算机与软件工程专业毕业设计、课程设计场景这份 docx 文档围绕 PythonDjangoOpenCV 的疲劳检测系统展开重点解决人脸与眼动信号识别、眼睛闭合程度判定、眨眼频次统计及疲劳状态预警等问题适合需要参考完整论文结构和技术路线的高校学生。资源包共 1 个 docx 文件约 1.01MB内容以学士学位论文为主体涵盖中英文摘要、目录、绪论、开发背景、国内外研究现状以及 OpenCV 图像处理、人脸识别、Python 编程和 MySQL 数据库在系统中的应用。文中还涉及图像识别、图片分析、照片管理等模块的设计思路并讨论疲劳检测在交通安全、医疗保健和工作效率等场景的应用前景。目前已有 366 人学习可用于梳理选题框架、对照功能模块与数据库设计并借鉴论文写作与系统实现的组织方式。1. 疲劳检测的真正难点为什么「闭眼就报警」的系统上线第一天就会被关掉很多人第一次做基于 Python 和 OpenCV 的疲劳检测套路都一样调人脸检测、算眼睛开合度、闭眼就报警。半天写完跑起来对着屏幕正常眨三次眼警报响两次低头喝口水又响一次。换个房间就翻车白天好用晚上失灵。毛病不在 OpenCV而在把疲劳当成了瞬时状态——它本质上是随时间累积的量单帧图像只给出某一瞬间的观测值拿瞬时观测下状态结论系统从设计上就站不住。真正要拆的是三个问题眼睛闭到什么程度算闭、闭多久算疲劳、在什么时间窗里统计才不被单帧噪声带偏。这套 Python Django MySQL 的疲劳检测系统把三个问题分别压进 EAR、PERCLOS 和滑动窗口三处实现链路不长每一环都有取舍。搞懂这条链路比背下任何库的 API 都值。2. 眼睛闭合程度的量化EAR 为什么能把六个坐标点压成一个稳定标量从一张 RGB 图片到「这个人疲劳了」这句结论中间隔着五层转换像素、预处理、人脸框、关键点、标量指标。先把前半段走通——让眼睛的开合程度变成一个可以跨设备、跨距离比较的数字。这一步做不稳后面所有的时间窗统计都是在噪声上做积分。2.1 预处理顺序灰度、均衡化、高斯平滑为什么不能随便换论文里提到的cvSmooth和cvEqualizeHist是 OpenCV 1.x 时代的 C 风格接口在现在的 Python 绑定里根本调不到照着写会直接AttributeError。对应的现代写法是cv2.GaussianBlur和cv2.equalizeHist顺序也不是随手排的而是有依赖关系。import cv2 def preprocess(bgr): # 1) 转灰度equalizeHist 只接受单通道对三通道分别均衡会破坏通道比例肤色漂移 gray cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) # 2) 直方图均衡化把挤在窄区间的灰度拉开逆光/室内灯下更稳 gray cv2.equalizeHist(gray) # 3) 高斯平滑压传感器噪声(5, 5) 是细节保留与稳定性的折中 gray cv2.GaussianBlur(gray, (5, 5), 0) return gray均衡化必须排在平滑之前。它的作用是把集中在窄灰度区间的像素拉开提升局部对比度如果先平滑弱边缘已经被抹掉再均衡化只是把剩下的噪声一起拉伸放大。平滑放在最后是为了抑制 CMOS 噪声和视频压缩伪影减少关键点抖动。核大小ksize必须是奇数(5,5)以上就会把眼角这种细结构糊掉而眼角定位误差会一毫米不差地进入 EAR 的分子。更要命的是equalizeHist是全局统计人脸只占画面一小块时背景里的大片高光或窗户会主导直方图人脸区域的对比度反而被压下去。常见的改法是先用 Haar 级联拿到人脸框再只在人脸 ROI 上做 CLAHE。face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) def preprocess_with_roi(bgr): gray cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) # scaleFactor1.1 每次金字塔缩 10%minNeighbors 越大越严minSize 防止把小纹理当脸 faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(80, 80)) for (x, y, w, h) in faces: roi gray[y:y h, x:x w] # clipLimit 控制对比度增强上限调太高会把噪声当边缘放大 gray[y:y h, x:x w] clahe.apply(roi) return gray, facesdetectMultiScale返回的是(x, y, w, h)列表minSize(80,80)这个下限很关键设小了脸上的纹理块会被误判成人脸设大了人一靠近屏幕反而检测不到。参数不是玄学是漏检率和误检率之间的一条曲线只能在自己的场景里扫一遍。2.2 EAR 的分子分母一个比值如何抵消距离与尺度眼睛开合度最直接的度量是像素高度但它一换摄像头就废。EAREye Aspect Ratio换成比值就解决了这个问题。以 68 点人脸模型为例左眼是 36–41 号点右眼是 42–47 号点每个眼六点顺序是「左眼角、上眼睑左、上眼睑右、右眼角、下眼睑右、下眼睑左」。import numpy as np def eye_aspect_ratio(eye_points): # eye_points: (6, 2) 数组顺序 [左角, 上左, 上右, 右角, 下右, 下左] vertical_a np.linalg.norm(eye_points[1] - eye_points[5]) vertical_b np.linalg.norm(eye_points[2] - eye_points[4]) horizontal np.linalg.norm(eye_points[0] - eye_points[3]) # 分母乘 2两条竖线之和对比一条基线除以 2 后开眼时约落在 0.25~0.35 return (vertical_a vertical_b) / (2.0 * horizontal)为什么是这六个点。两个眼角给出水平基准线在头部左右偏转roll±15° 以内时这段距离变化很小上下眼睑各取两点组成两条竖直线取和而不是取平均是为了让任意一条竖线被镜框或反光干扰时信号不至于完全丢失。关键在比值本身。人脸后退一米分子分母同时按比例缩小商不变换个 720p 摄像头换成 1080p同样不变。这是它能跨设备工作的根本原因。反过来如果直接对像素高度设阈值换个分辨率就得重调一遍这种代码是没法交付的。EAR 的局限也很清楚左右转头yaw超过 30°一只眼被鼻梁挡住关键点开始乱飘低头pitch时眼睑在图像平面上的投影变短EAR 假性下降——这既是「点头」能被当作疲劳信号的原因也是误报的主要来源。状态EAR 参考范围工程含义自然睁眼0.25–0.35个体差异极大仅作初值半闭0.18–0.25判定灰区不能单独下结论闭眼低于 0.15需叠加时间条件才有意义2.3 阈值不该是常数个体差异下的两种标定思路固定 0.21 这个值的代价是做一次小范围测试就能看到的眼裂小的人自然睁眼只有 0.22全天误报眼窝深的人闭眼还剩 0.18全天漏报。同一个阈值不可能同时适配两类人这跟算法优劣无关是生理差异。第一种思路是静态标定启动时采集三到五秒睁眼样本取中位数乘一个比例系数作为闭眼阈值。用中位数而不是均值是因为标定期间只要有人眨一次眼均值就被拉下去了。用乘法而不是减法是因为 EAR 是比值型指标个体差异更接近乘性关系。class EarCalibrator: 用一小段睁眼样本把闭眼阈值定在这个人的尺度上 def __init__(self, ratio0.7, min_samples30): self.ratio ratio # 0.6~0.75 之间调越小越保守 self.min_samples min_samples # 25fps 下约 1.2 秒 self.samples [] self.threshold 0.21 # 未标定前的经验默认值 def feed(self, ear): self.samples.append(ear) if len(self.samples) self.min_samples: base float(np.median(self.samples)) self.threshold round(base * self.ratio, 3) return self.threshold第二种思路是相对标定干脆不设绝对阈值取最近 N 帧 EAR 的 20 分位数作为动态下界让阈值跟着人和光照一起漂。好处是全自动代价是长时间闭眼会让分位数本身往下走出现「越困越不报警」的自适应失效通常要加一个最小帧数保护并且只在判定为睁眼的帧上更新分位数。有个血泪经验标定界面如果写着「请看镜头」很多人会下意识瞪大眼睛阈值被抬高之后正常看屏幕就被判成闭眼。标定提示语必须写成「请像平时一样看屏幕」这行字比调十个参数都管用。3. PERCLOS 的滑动窗口疲劳判定为什么必须自带「记忆」有了逐帧的 EAR只解决了「这一帧眼睛闭上了」这个观测问题。疲劳是状态问题状态必须带记忆。把人眼闭合的时间比例作为疲劳指标是这类系统里最经得起验证的一条路线而它的工程价值全在窗口怎么滑、口径怎么定。3.1 三种统计口径P70、P80 与 EM 到底差在哪PERCLOS 有三条常见口径P70 指眼睑覆盖瞳孔面积超过 70% 的时间占比P80 是 80%EM 则是闭眼时长占统计总时长的比例。学术界偏爱 P80因为它对「半闭」更敏感能更早发现困意P70 更宽松误报少。工程上的麻烦在于普通摄像头根本算不出瞳孔被遮挡的面积百分比。所以实际实现几乎都用 EAR 阈值来替代EAR 阈值记一次闭眼再统计占比。这意味着 EAR 阈值和 PERCLOS 口径绑在一起了——阈值调松等价于把口径从 P80 挪向 P70两个参数不能独立调。这一点在很多实现里没写清楚导致调参时两边互相打架。3.2 双判据并行连续帧计数与窗口占比各管一段只靠占比会漏掉一种最危险的情况一次深闭眼持续一秒如果统计窗口是一分钟占比只有 1.7%远低于阈值。但一秒的闭眼在高速上意味着几十米的盲开。所以必须再加一条连续帧计数专门抓微睡眠。from collections import deque class FatigueJudge: def __init__(self, fps25, window_sec60, ear_th0.21, # 闭眼阈值 frame_th20, # 连续闭眼帧数25fps 下约 0.8 秒 perclos_th0.15): # 窗口内闭眼占比阈值 self.window deque(maxlenint(fps * window_sec)) self.ear_th ear_th self.frame_th frame_th self.perclos_th perclos_th self.closed_frames 0 self.yawn_frames 0 def update(self, ear, mar): closed ear self.ear_th # 判据一连续帧计数抓瞬时深闭眼 self.closed_frames self.closed_frames 1 if closed else 0 # 判据二滑动窗口抓累积疲劳 self.window.append(1 if closed else 0) perclos sum(self.window) / len(self.window) if mar 0.6: self.yawn_frames 1 if self.closed_frames self.frame_th: return fatigue, perclos, closed_frames # 窗口未填满不判避免启动阶段误报这个细节最容易漏 if len(self.window) self.window.maxlen and perclos self.perclos_th: return fatigue, perclos, perclos return normal, perclos, deque(maxlenN)的好处是窗口满了自动丢弃最旧的样本不用手动维护头尾指针复杂度是 O(1)。frame_th20对应 25fps 下 0.8 秒perclos_th0.15是文献里常用的判定线。窗口长度是响应速度和稳定性的直接交易5 秒窗口会被一次正常眨眼污染3 分钟窗口则要等太久才报警30 到 60 秒是常见折中。这里有个被论文系统掩盖掉的真问题这套系统是「拍照或上传单张图片」做分析的没有固定帧率严格意义上的 PERCLOS 根本算不出来。可行的近似是把同一会话里的多张图片按addtime排序把相邻两张之间的时间间隔当作采样间隔做零阶保持再按时间加权而不是按张数平均。def time_weighted_perclos(samples, window_sec60): samples: [(时间戳秒, 是否闭眼), ...] 按时间升序 图片式系统采样不均匀占比必须按时间加权按张数平均会系统性偏差 if len(samples) 2: return 0.0 end samples[-1][0] start max(samples[0][0], end - window_sec) closed total 0.0 for i in range(len(samples) - 1): t0, c0 samples[i] t1, _ samples[i 1] if t1 start: continue t0 max(t0, start) dt t1 - t0 total dt if c0: closed dt return closed / total if total 0 else 0.0采样越稀这种「状态保持到下一张图」的近似误差越大。如果用户两张照片间隔十分钟中间发生了什么谁也不知道算出来的数字只能当趋势参考。这个限制不是代码能解决的只能靠使用约束说明白。3.3 特征融合眨眼频次、打哈欠与头部姿态怎么加权单靠眼睛会误判所以这类系统普遍再加两路信号。打哈欠用嘴部关键点算 MAR嘴部纵横比68 点模型里内唇是 48–67 号取上下唇垂直距离除以嘴角水平距离大于 0.5 且持续十几帧记为一次哈欠。眨眼频率是另一种信号正常 15 到 20 次每分钟疲劳早期会代偿性增多后期反而下降。头部姿态则用solvePnP拿六个 2D 到 3D 的对应点求旋转向量低头 pitch 超过 15° 持续两秒计一次。特征计算方式触发条件类型连续闭眼帧EAR 低于阈值的连续帧数达到 20 帧硬判据PERCLOS窗口内闭眼时间占比超过 0.15硬判据哈欠频次MAR 大于 0.5 且持续 15 帧每分钟 3 次以上软判据眨眼频次单位时间内闭眼到睁眼次数低于 10 或高于 30软判据低头时长pitch 超过 15°每分钟累计 2 秒软判据融合策略上轻量系统用规则串联比加权打分更靠谱任一硬判据触发就报警软判据只累加计分不单独触发。原因很现实——加权系数没有数据支撑就是拍脑袋而规则触发之后至少能告诉用户「是因为连续闭眼 1.2 秒」这种可解释性在调试阶段价值极高。4. Django 侧的落地一次图片上传从 HTTP 到 MySQL 记录走了哪几步算法跑通只是半个系统。回到这套工程本身它的形态是浏览器上传图片、后端算完返回结果、前端用图表展示所以真正决定体验的是链路上那几个不起眼的选择文件怎么解码、图片存哪、结果字段怎么设计。4.1 视图函数的三段式解码、判定、落库一次上传的完整路径是浏览器FormData→ WSGI 请求对象 →request.FILES→ 视图函数 → OpenCV 解码 → 判定 → ORM 写入 → JSON 返回 → 前端图表渲染。中间最容易出事的是第二步和第三步的交接。import cv2 import numpy as np from django.http import JsonResponse from .models import PhotoInfo from .detector import detect_fatigue def upload_and_detect(request): if request.method ! POST: return JsonResponse({code: 405, msg: method not allowed}) upload request.FILES.get(image) if upload is None: return JsonResponse({code: 400, msg: no file}) # 关键不要先写临时文件再 cv2.imread走内存解码可绕过路径编码问题 raw np.frombuffer(upload.read(), dtypenp.uint8) frame cv2.imdecode(raw, cv2.IMREAD_COLOR) if frame is None: return JsonResponse({code: 415, msg: decode failed}) result detect_fatigue(frame) # 返回 eyes / haqian / pilaov 三个语义字段 PhotoInfo.objects.create( picnameupload.name, eyesresult[eyes], haqianresult[haqian], pilaovresult[pilaov], ) return JsonResponse({code: 200, data: result})np.frombuffer把上传流的字节直接映射成数组cv2.imdecode在内存里解码全程不落磁盘。这样既绕开了 Windows 下中文路径打不开图的问题也少了一次写文件和一次读文件的 IO。要注意 Django 的上传处理有个分界小于FILE_UPLOAD_MAX_MEMORY_SIZE默认 2.5MB的文件留在内存里超过就落到临时文件此时upload.read()依然可用但已经多了一次磁盘往返。手机拍的照片动辄四五兆这个阈值要么调大要么在前端先压缩。detect_fatigue这层封装值得单独拆出来不要和视图函数搅在一起。原因是判定逻辑要能脱离 HTTP 独立测试——给一张固定图片输出必须是确定的三个字段这样才能做回归。视图函数只负责协议和持久化算法层只负责数字边界清楚出问题时才能定位到底是解码坏了还是阈值飘了。4.2 图片存路径还是存 BLOB两种存储策略的代价图片本身放哪是个经常被忽略但会持续产生代价的决定。方案优点代价文件系统 数据库存路径数据库轻、可接 CDN、备份用文件同步记录与文件可能不一致、迁移麻烦直接存 BLOB事务一致、单点备份单表迅速膨胀、查询默认拉全量二进制选 BLOB 的人通常是想「省事」但 MySQL 有max_allowed_packet上限几十万张图片之后单表几个 G任何一次全表扫描都会拖垮实例而且 Django 查这个模型时默认把二进制字段一起拉出来列表页只想要文件名却拉了几兆图片白白浪费带宽。更常见的做法是用ImageField存相对路径文件落到MEDIA_ROOT下按日期分目录upload_tophotos/%Y/%m/避免单目录堆几十万个文件导致ls都卡。真正的取舍点在于一致性数据库记录写成功、文件写失败或者反过来都会留下孤儿数据。低成本的做法是给记录加一个状态位文件写完再置为可用配合定时任务清理超时未完成的记录。这套系统规模不大不做也能跑但一旦要做批量导入这个坑一定会踩到。4.3 结果表的字段设计varchar(2)本文还有配套的精品资源点击获取