☰
Python+OpenCV疲劳检测:EAR标定与PERCLOS滑动窗口
2026/10/25 13:13:23 网站建设 项目流程

简介:面向计算机与软件工程专业毕业设计、课程设计场景,这份 docx 文档围绕 Python+Django+OpenCV 的疲劳检测系统展开,重点解决人脸与眼动信号识别、眼睛闭合程度判定、眨眼频次统计及疲劳状态预警等问题,适合需要参考完整论文结构和技术路线的高校学生。资源包共 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(clipLimit=2.0, tileGridSize=(8, 8)) def preprocess_with_roi(bgr): gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) # scaleFactor=1.1 每次金字塔缩 10%;minNeighbors 越大越严;minSize 防止把小纹理当脸 faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, 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, faces

detectMultiScale返回的是(x, y, w, h)列表,minSize=(80,80)这个下限很关键:设小了,脸上的纹理块会被误判成人脸;设大了,人一靠近屏幕反而检测不到。参数不是玄学,是漏检率和误检率之间的一条曲线,只能在自己的场景里扫一遍。

2.2 EAR 的分子分母:一个比值如何抵消距离与尺度

眼睛开合度最直接的度量是像素高度,但它一换摄像头就废。EAR(Eye 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, ratio=0.7, min_samples=30): 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, fps=25, window_sec=60, ear_th=0.21, # 闭眼阈值 frame_th=20, # 连续闭眼帧数,25fps 下约 0.8 秒 perclos_th=0.15): # 窗口内闭眼占比阈值 self.window = deque(maxlen=int(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(maxlen=N)的好处是窗口满了自动丢弃最旧的样本,不用手动维护头尾指针,复杂度是 O(1)。frame_th=20对应 25fps 下 0.8 秒;perclos_th=0.15是文献里常用的判定线。窗口长度是响应速度和稳定性的直接交易:5 秒窗口会被一次正常眨眼污染,3 分钟窗口则要等太久才报警,30 到 60 秒是常见折中。

这里有个被论文系统掩盖掉的真问题:这套系统是「拍照或上传单张图片」做分析的,没有固定帧率,严格意义上的 PERCLOS 根本算不出来。可行的近似是把同一会话里的多张图片按addtime排序,把相邻两张之间的时间间隔当作采样间隔做零阶保持,再按时间加权而不是按张数平均。

def time_weighted_perclos(samples, window_sec=60): """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(), dtype=np.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( picname=upload.name, eyes=result["eyes"], haqian=result["haqian"], pilaov=result["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_to='photos/%Y/%m/',避免单目录堆几十万个文件导致ls都卡。

真正的取舍点在于一致性:数据库记录写成功、文件写失败,或者反过来,都会留下孤儿数据。低成本的做法是给记录加一个状态位,文件写完再置为可用,配合定时任务清理超时未完成的记录。这套系统规模不大,不做也能跑,但一旦要做批量导入,这个坑一定会踩到。

4.3 结果表的字段设计:varchar(2)

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询