简介:一套基于Python与OpenCV的疲劳检测实战项目,面向计算机视觉初学者和需要实时安全监控的开发者,可应用于驾驶员状态监测、工地安全值守等场景。资源包内含3个文件:Python源码detect_blinks.py实现核心检测流程,mp4示例视频用于效果演示,dat文件为68点人脸关键点模型,支撑眼睛、嘴巴等部位的精准定位。压缩包整体73.37MB,结构紧凑、注释详细,适合直接运行与二次开发。目前已有183人学习下载。项目代码覆盖图像采集、预处理、人脸检测、特征点定位、疲劳状态分析等关键环节,通过眼睛闭合频率与打哈欠动作判断疲劳程度,并给出实时视频处理与特征提取的完整参考实现,既是入门OpenCV项目实战的优质范例,也为进阶开发者提供了一套可复用的疲劳检测算法框架。
1. 疲劳检测不是玄学:先看这个项目的核心套路
凌晨三点开长途,眼皮开始打架,等你反应过来去踩刹车,可能已经撞上了护栏。市面上主流的疲劳检测方案动不动就上深度学习模型、红外传感器,但张小龙不是靠这些做出来的。这个项目只靠一个普通 RGB 摄像头、Python 和 OpenCV,就能把「困了」这个模糊的状态翻译成两个数字:眼睛闭合持续帧数、嘴巴张开幅度。核心不是机器学习,而是几何测量——用 dlib 的 68 点人脸关键点定位出眼睛和嘴巴的轮廓,再算纵横比,当眼睛闭合超过连续三帧、嘴巴张得过大,就判定疲劳。它不需要 GPU,不需要训练数据,一个笔记本 CPU 跑 30 帧毫无压力。这套思路在驾驶员状态监控、在线考试防作弊、工位疲劳提醒里都能直接改来用。源码包里有检测脚本、测试视频和预训练模型文件,从摄像头取帧到报警触发一条链路完整,适合刚入门 OpenCV 的人看懂工程怎么落地。
2. 视频流里先找到脸:OpenCV 人脸检测与 dlib 68 点特征点定位
疲劳检测的第一步,不是检测疲劳,而是先准确地找到脸、再找到眼睛和嘴巴。这个顺序不能乱。人脸检测(Face Detection)给出的是人脸外接矩形框,告诉你脸在画面哪里;人脸关键点定位(Face Landmark Alignment)则是在这个框里精确定位眉毛、眼睛、鼻子、嘴巴等部位的点坐标。项目里既要“看”到人,又要精确测量眼睛闭合程度,所以两步都要做。
2.1 为什么选 dlib 的 frontal_face_detector 而不是 OpenCV 的 Haar Cascade
OpenCV 自带的CascadeClassifier也能检测人脸,速度很快,但它返回的只是矩形框,而且对小尺寸、侧脸、遮挡的鲁棒性一般。dlib 的get_frontal_face_detector()基于 HOG + 线性分类器,召回率和稳定性比 Haar Cascade 好不少,尤其是对光照变化的容忍度更高。这个项目里最终需要的是每个关键点的精确像素坐标,dlib 的shape_predictor直接和frontal_face_detector配合,训练好的模型输出 68 个点,正好覆盖眼睛、嘴巴的轮廓。
shape_predictor_68_face_landmarks.dat是 dlib 官方提供的预训练模型,约 100MB,基于 iBUG 300-W 数据集训练。它输入一张人脸矩形框和对应的灰度图,输出 68 个点坐标。文件大是因为内部是树形回归器组合(Ensemble of Regression Trees),每棵树保存了大量像素对比规则。用的时候直接加载,不需要再训练。
2.2 加载模型并逐帧检测:上采样参数会直接影响检测距离
拿到源码包,先把模型文件和视频准备好。detect_blinks.py里第一步就是实例化检测器和预测器:
import dlib import cv2 detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") cap = cv2.VideoCapture("test.mp4") # 换成 0 就是读取摄像头 while True: ret, frame = cap.read() if not ret: break # 灰度图:dlib 检测器只接受单通道输入,同时减少计算量 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 第二个参数 1 表示对输入图像做一次金字塔上采样 faces = detector(gray, 1) for face in faces: shape = predictor(gray, face) # 36-41 是右眼关键点,42-47 是左眼关键点 for i in range(36, 48): x, y = shape.part(i).x, shape.part(i).y cv2.circle(frame, (x, y), 2, (0, 0, 255), -1) cv2.imshow("Frame", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()关键参数是detector(gray, 1)中的1。这个参数表示对图像做几次上采样再检测。设置为1时,dlib 会把原图放大一倍,这样摄像头里稍微远一点的小脸也能被检测到,代价是每帧多花约 20~40ms。如果你监控的是近景人脸,可以改成0,速度能明显提升。如果人物距离超过两米还经常丢脸,可以改成2,但 CPU 时间会翻倍。
shape.part(i)返回的是 dlib 定义的point对象,自带.x和.y。循环里取了 36 到 47 的点来标注,因为这些点正好是双眼轮廓。测试视频test.mp4里的人脸靠近镜头,帧率也不高,跑这段代码你能直观看到红点跟随眼睛移动。
2.3 68 点坐标的索引分布:眼睛、嘴巴分别对应哪几个点
拿到 68 个点后,必须清楚每个点的索引对应哪个部位,否则后续计算会取错坐标。dlib 的标准模型里,索引是固定的。下面这张表是我平时查得最勤的:
| 点索引范围 | 对应部位 | 在疲劳检测里的用途 |
|---|---|---|
| 0–16 | 脸部轮廓 | 一般不用,头部姿态估计时才会引用 |
| 17–21 | 左眉毛 | 不用 |
| 22–26 | 右眉毛 | 不用 |
| 27–35 | 鼻梁与鼻翼 | 辅助判断面朝方向 |
| 36–41 | 右眼轮廓(六点) | 计算右眼 EAR |
| 42–47 | 左眼轮廓(六点) | 计算左眼 EAR |
| 48–59 | 嘴巴外轮廓(十二点) | 上下唇开合距离 |
| 60–67 | 嘴巴内轮廓 | 张嘴程度,哈欠检测辅助 |
注意,predictor输出的坐标系对应的是灰度图里检测到的人脸区域,如果后续要把关键点绘制到彩色原图,直接使用shape.part(i).x和.y即可,因为检测和预测都在同一坐标空间下。实际操作中,35 点(鼻尖)和 8 点(下巴)之间的几何关系还可以用来做简单的头部低头检测,这是另一个功能,后面会提到。
2.4 从视频到帧:循环里的时间窗口和跳帧策略
while cap.read()是逐帧阻塞读取。test.mp4的视频没有声音,所以不需要音视频同步逻辑。对于 30fps 的输入,如果单帧处理耗时超过约 33ms,视频就会开始卡顿,检测结果也会滞后。cv2.waitKey(1)里的参数控制着imshow的画面刷新等待毫秒数,换成cv2.waitKey(30)会让处理慢下来,但不会影响cap.read()已经取到的帧。
如果接入实时摄像头,我一般会加一个跳帧策略:每两帧处理一次,把上一帧的检测结果直接用于显示。这样既能保证脸不丢,又不会让 CPU 打满。detect_blinks.py源码里没有做跳帧,但你在实际部署时完全可以在循环开头加一个frame_id += 1,然后用if frame_id % 2 != 0: continue跳过奇数帧。处理速度会快近一倍,疲劳检测本身不是逐帧敏感的应用,少处理一两帧不影响判断。
3. 用 EAR 和 MAR 量化「困了」:眼部纵横比与嘴巴开合度的计算
找到眼睛和嘴巴的轮廓点之后,下一步是把这些点坐标换算成能反映“闭合与否”的标量。直接用上下眼皮点之间的像素距离有一个致命问题:人脸离摄像头远近不同,同样的闭合状态会得出不同的像素值。比如靠近镜头时,睁眼距离可能是 20 像素,离远时变成 10 像素,这样固定阈值就失效了。所以项目采用纵横比这个归一化指标。
3.1 EAR 公式:为什么它能做到与距离无关
眼睛纵横比(Eye Aspect Ratio, EAR)最早由 Soukupová 和 Čech 在 2016 年的论文中提出,针对 dlib 的 36–41(右眼)和 42–47(左眼)六个点计算。公式如下:
EAR = (‖p2 − p6‖ + ‖p3 − p5‖) / (2‖p1 − p4‖)
对应到 dlib 索引:p1 是眼角最外点(36 或 42),p4 是眼角最内点(39 或 45),p2、p3 是上眼皮中间两点,p5、p6 是下眼皮中间两点。竖着取两个垂直距离的均值,横着取一个水平距离,两者相除后,近大远小的尺度因素就被约掉了。
看一眼代码实现:
from scipy.spatial import distance as dist def eye_aspect_ratio(eye): """ eye: 包含六个 (x, y) 坐标的列表,按 dlib 索引顺序排列 返回 EAR 值,正常睁眼约 0.28~0.34,闭合时趋近于 0 """ # 计算垂直方向的两组欧氏距离 vertical_1 = dist.euclidean(eye[1], eye[5]) vertical_2 = dist.euclidean(eye[2], eye[4]) # 计算水平方向的欧氏距离 horizontal = dist.euclidean(eye[0], eye[3]) ear = (vertical_1 + vertical_2) / (2.0 * horizontal) return earscipy.spatial.distance.euclidean接受两个元组或列表,返回欧氏距离。用(vertical_1 + vertical_2) / (2.0 * horizontal)分子分母的单位都是像素,比例无量纲。当眼睛闭合,上下眼皮距离接近 0,EAR 值会降到 0.1 以下。单独算一只眼睛容易受眨眼、眯眼干扰,所以实际使用时取左右眼平均值:
left_eye = [(shape.part(i).x, shape.part(i).y) for i in range(42, 48)] right_eye = [(shape.part(i).x, shape.part(i).y) for i in range(36, 42)] left_ear = eye_aspect_ratio(left_eye) right_ear = eye_aspect_ratio(right_eye) ear = (left_ear + right_ear) / 2.0注意索引顺序:range(36, 42)取的是 36、37、38、39、40、41,这六个点正好按顺时针从外眼角开始环绕右眼。range(42, 48)同理。顺序不能乱,否则eye[1]和eye[5]就不是对应的上下眼皮点。
3.2 MAR 公式:嘴部开合度与打哈欠的捕捉
嘴巴使用嘴部纵横比(Mouth Aspect Ratio, MAR)。dlib 的 48–67 点包含嘴唇内外轮廓,我用外轮廓的 48–59 点来计算开合度。取 48 为左嘴角,54 为右嘴角,50 和 52 为上嘴唇外侧点,56 和 58 为下嘴唇外侧点。公式形式上和 EAR 对称:
MAR = (‖p2 − p8‖ + ‖p3 − p7‖ + ‖p4 − p6‖) / (2‖p1 − p5‖)
其中 p2/p8、p3/p7、p4/p6 是上下唇对应的三组垂直点。**用了三组垂直距离是因为嘴唇张开时不是平整的椭圆,中间比两边变化更大,取平均值更稳定。**代码如下:
def mouth_aspect_ratio(mouth): """ mouth: 从 dlib 48-59 点中提取的 (x, y) 坐标列表 正常说话时约 0.2~0.5,大张嘴打哈欠时通常超过 0.65 """ vertical_1 = dist.euclidean(mouth[2], mouth[10]) # 50-58 vertical_2 = dist.euclidean(mouth[4], mouth[8]) # 52-56 vertical_3 = dist.euclidean(mouth[0], mouth[6]) # 48-54 horizontal = dist.euclidean(mouth[0], mouth[6]) # 48-54,嘴角宽度 mar = (vertical_1 + vertical_2 + vertical_3) / (3.0 * horizontal) return mar这个函数里要注意索引用对了:mouth[0]和mouth[6]是左右嘴角(48 和 54),mouth[2]是 50 点,mouth[10]是 58 点,mouth[4]是 52,mouth[8]是 56。垂直距离用3.0 * horizontal做归一化,与人脸到摄像头距离无关。MAR 对说话也很敏感,所以疲劳检测里不会单靠一帧 MAR 超阈值就行,而是跟眼睛闭合计数组合判断。
3.3 阈值与连续帧:单帧判断不可靠
疲劳的视觉表征不是“某一帧闭眼了”,而是“闭眼持续了一段时间”。正常眨眼时,EAR 会瞬间掉到 0.1 以下,但通常只在 100~400ms 内恢复,对应视频里大约 3~12 帧。真正的疲劳闭眼,EAR 低于阈值的时间会拉长到 500ms 以上。
项目代码里的阈值设定参考如下:
| 指标 | 常见初始值 | 依据 |
|---|---|---|
EYE_AR_THRESH | 0.25 | 睁眼 0.3 左右,闭眼低于 0.2,取中间偏上能容忍轻微眯眼 |
EYE_AR_CONSEC_FRAMES | 3 | 连续 3 帧低于阈值,约 100ms,排除快速眨眼 |
MOUTH_AR_THRESH | 0.65 | 哈欠时 MAR 普遍超过 0.6~0.7,普通说话时很少到 0.65 |
MOUTH_CONSEC_FRAMES | 5 | 哈欠至少要持续 5 帧以上 |
代码里的判断逻辑是维护一个计数器,只有连续达标的帧数超过阈值才触发报警:
EYE_AR_THRESH = 0.25 EYE_AR_CONSEC_FRAMES = 3 MOUTH_AR_THRESH = 0.65 eye_counter = 0 mouth_counter = 0 alarm = False # 主循环内的判断 if ear < EYE_AR_THRESH: eye_counter += 1 if eye_counter >= EYE_AR_CONSEC_FRAMES: alarm = True else: eye_counter = 0 if mar > MOUTH_AR_THRESH: mouth_counter += 1 if mouth_counter >= 5: alarm = True else: mouth_counter = 0计数器的作用是「延迟触发」。如果单帧 EAR 小于阈值就触发,那日常眨眼一定会让系统疯了。阈值本身不是一个绝对精确的物理量,它受分辨率、摄像头角度、是否戴眼镜影响,所以更合理的是在系统长时间运行时记录正常状态下的 EAR 均值,再在均值基础上下调 20%~30% 作为阈值。源码给的经验值 0.25 是在近距离、正脸、无眼镜场景下的一个起点,不是银弹。
4. detect_blinks.py 完整流程拆解:从视频帧到报警触发
接下来看资源包里的detect_blinks.py是怎么把前面这些零件拼成完整项目的。这个文件名是「检测眨眼」,实际上实现的是疲劳判断——通过连续闭眼帧数和哈欠帧数来输出报警信号。整体流程并不复杂,但每一段都有实际工程上的取舍。
4.1 初始化阶段:模型路径、参数、统计变量
打开源码,最早定义的是参数区。除了 Threshold 常量,还会有VIDEO_PATH、SHAPE_PREDICTOR_PATH这类路径配置。我建议你拿到源码后,第一件事就是确认路径和文件位置:
. ├── detect_blinks.py ├── shape_predictor_68_face_landmarks.dat ├── test.mp4 └── output.mp4 # 运行后生成的标注视频如果detect_blinks.py和模型文件不在同一个目录,运行时会报RuntimeError: Unable to open。看到这个错,先检查当前工作目录里有没有.dat文件。代码里加载模型用的相对路径,我从终端执行python detect_blinks.py时,工作目录就是终端当前目录,所以要么把文件放在一起,要么改成绝对路径。
4.2 主循环核心:一组眼睛点提取的封装
源码在拿到face后,会调用一个函数把shape对象转换成能直接用于 EAR 计算的坐标列表。手动写[(shape.part(i).x, shape.part(i).y) for i in range(...)]容易出错,封装一下更好:
def shape_to_np(shape, dtype="int"): """将 dlib shape 对象转成 (68, 2) 的 numpy 数组""" coords = np.zeros((68, 2), dtype=dtype) for i in range(0, 68): coords[i] = (shape.part(i).x, shape.part(i).y) return coords这样就不需要反复写part(i).x了。拿到的coords是 (68, 2) 数组,后续取眼睛点写作:
left_eye = coords[42:48] right_eye = coords[36:42] mouth = coords[48:60]用numpy数组切片代替列表推导式,代码更紧凑,计算欧氏距离时dist.euclidean(left_eye[1], left_eye[5])可以直接接受数组参数,返回np.float64。源码里的注释也很清楚,每个模块对应一段功能,我用红框标注注释的位置,你会发现它和流程图的顺序完全一致:取帧、灰度化、检测、关键点、EAR/MAR、计数器、可视化。
4.3 报警逻辑:画面标注还是声音提醒
项目里报警的可视化部分是在帧上画一个高亮矩形和文字提示。公开源码里最常见的是这样:
if eye_counter >= EYE_AR_CONSEC_FRAMES: cv2.putText(frame, "FATIGUE - EYES CLOSED!", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) if mouth_counter >= 5: cv2.putText(frame, "YAWN DETECTED!", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)cv2.putText的参数分别是图像、文本、左下角坐标、字体、缩放系数、颜色(BGR)、线宽。红色(0, 0, 255)用于报警提示。imshow显示窗口的标题可以改成当前 EAR/MAR 数值,这样调试时能看到实时的量化变化。
实际做驾驶员监控时,画面里不能只靠文字,因为司机不会盯着屏幕看。常见做法是联动蜂鸣器或输出串口信号。源码没有硬编码音频,因为 OpenCV 本身不带音频播放,但很容易扩展:在报警条件里加一行os.system("start alarm.wav")可以用系统默认播放器播放音频。注意 Windows 下start会阻塞,更可靠的是用playsound模块:
pip install playsound然后:
from playsound import playsound # 触发报警时 playsound("alarm.wav", block=False)我个人不建议在检测脚本里直接播放音乐,因为音频线程会和视频循环打架。可以单独写一个报警进程,检测到疲劳时用subprocess.Popen拉起播放,这样视频帧率不会掉。
4.4 依赖环境与运行命令:dlib 是最容易卡壳的环节
依赖项如下:
pip install opencv-python dlib imutils scipy numpyimutils是一个工具库,里面提供resize等便捷函数,源码里可能用到也可能没有,装上无害。最容易翻车的是dlib。在 Windows 上,pip install dlib经常因为缺少CMake和 C++ 编译环境而报错,尤其是 Python 3.9 以上更容易踩坑。我常用的两个解决方案:
| 方案 | 命令 | 使用场景 |
|---|---|---|
| 预编译 wheel | pip install dlib==19.24.2 | Windows + Python 3.8/3.9,wheel 能找到就最快 |
| conda 安装 | conda install -c conda-forge dlib | conda 环境里最省心,自动带 CMake 编译依赖 |
跑起来后在终端运行:
python detect_blinks.py如果脚本里没有写-v参数控制视频路径,那要先改代码里的cv2.VideoCapture("test.mp4")。test.mp4是打包人用摄像头自录的测试素材,内容是模拟打哈欠和闭眼的动作。你可以先用它验证环境,再换成摄像头cv2.VideoCapture(0),注意 0 表示默认摄像头,笔记本内置通常就是 0,外接摄像头可能是 1 或 2。
运行时会看到实时窗口,左上角显示 EAR 和 MAR 数值。EAR 低于阈值时,你会看到计数从 0 跳到 1、2、3,到 3 时屏幕上出现红色疲劳提示。整个脚本占用的 CPU 在笔记本 i5 上大约是百分之十几,内存约 200MB 左右。
5. 进阶:疲劳检测的边界条件与调试技巧
项目跑到能识别闭眼只是第一步。真正放在驾驶室或者教室里,摄像头角度、戴眼镜、逆光、看了个侧脸都会让检测失效。这章的技巧是让你从「能跑」走向「能扛」。
5.1 摄像头距离与阈值联动:用睁眼基线动态调整 EAR 阈值
固定 0.25 的阈值有个明显 bug:如果摄像头架在仪表台上,人脸距离比桌面近得多,睁眼 EAR 可能只有 0.19,闭眼 0.12,用 0.25 阈值等于永远报警。反过来,距离拉远后睁眼 EAR 变成 0.32,用 0.25 又太迟钝。解决方法是在启动后前 50 帧记录ear的均值baseline_ear,然后设定:
EYE_AR_THRESH = baseline_ear * 0.75只在连续 50 帧都检测到人脸时计算基线,避免把闭眼帧算进去。我通常在初始化前加一个状态机的逻辑:前 50 帧只要ear > 0.2就累积,否则不参与平均。这样不同装设位置都能自校准。
5.2 侧脸与遮挡:丢失特征点时如何降级
疲劳检测在头部转向一侧时,单只眼睛的轮廓会被鼻梁遮挡,dlib 给出的点会漂移,EAR 可能变得很低,造成误报。改进方案是计算左右眼各自 EAR,并检测两眼 EAR 的差值,如果差值大于 0.15,说明很大概率是侧脸,此时只看另一只眼的值。还可以用鼻子区域和脸部轮廓相对位置估算偏航角,超过 30 度时停止输出报警,避免误报。有人脸的置信度分数来辅助,face.confidence在 dlib 新版本里可以拿到,低于 0.8 就跳过这一帧。
5.3 用眨眼频率做二次确认,降低误报率
疲劳的实质是一串异常信号叠加:闭眼时间变长、眨眼频率降低、哈欠增多。检测到一次连续闭眼时,先别急着报警。可以统计过去 60 秒内的眨眼次数blink_count,正常人清醒时每分钟眨眼 15~20 次,疲劳时降到 5 次以下。源码里维护一个deque来记录眨眼时间戳,眨眼定义为从 EAR 低于 0.2 到重新高于 0.2 的一个完整过程,并且在 400ms 内恢复。当eye_counter >= 3且blink_count显著偏低时,才触发最终报警。这个二次确认逻辑能让误报率下降一个量级。
5.4 性能优化:把关键点定位限制在 ROI 区域
当有一张人脸时,下一帧的人脸位置和上一帧不会相差太远。与其每帧全图检测,不如用上一步的人脸框扩大 20% 作为 ROI,把检测直接作用在 ROI 上。dlib 的predictor本来就是在人脸框内预测的,所以真正费时间的frontal_face_detector可以只在每 3 帧调用一次,中间帧直接用上一帧的检测框加平移补偿。这样在树莓派或老式笔记本上,帧率可以从 15fps 提升到 28fps。另外一个技巧是把cap.read()到的帧先缩小到宽度 480 再送检测器,EAR 是比例值,缩小后依然有效,但检测和关键点定位的时间会大幅下降。
shape_predictor_68_face_landmarks.dat这个模型在 CPU 上单次推理约 1~3ms,瓶颈从来不在模型,而在图像解码和整帧全图检测。用以上几个技巧组合起来,能把这个 Demo 级别的项目改造成可以长时间运行的轻量级监控服务。最后提醒一句,源码中的output.mp4建议用cv2.VideoWriter_fourcc(*'mp4v')编码,否则常见播放器解不了。
本文还有配套的精品资源,点击获取