做驾驶疲劳检测这几年,听得最多的需求就是“能不能在司机闭眼超过一秒时给我一个提醒”。这个需求用现在的深度学习框架做并不难,但如果只想快速落地、在普通机器上也能稳定跑,最稳的一条路反而不是堆模型,而是那个看起来有点“老派”的组合:OpenCV 做图像处理,dlib 做人脸关键点定位,再用眼睛纵横比(EAR)判断眼睛状态。
这套方案有意思的地方在于,核心算法只有一行数学公式,剩下的都是工程细节。真正决定检测准不准的,不是网络结构,而是阈值怎么定、连续帧怎么算、光照变化怎么扛。这篇文章我会把 EAR 的原理、环境搭建、完整代码、参数调优、实测翻车场景一次性讲透,适合刚接触 OpenCV 想做个完整小项目的读者,也适合准备拿疲劳检测做毕设或工程 Demo 的同学参考。文章里所有代码我都按实际能跑通的方式组织过,你可以直接复制改参数用。
1. 眼睛纵横比(EAR)为什么是疲劳检测的首选信号
1.1 眨眼检测的几种传统思路,为什么都不省心
先说一个反直觉的事实:人眼检测这个事,看起来简单,做起来全是坑。早期做疲劳检测的人尝试过很多办法。
第一种思路是肤色分割加模板匹配。先框出人脸区域,在眼白和瞳孔区域找模板。问题是肤色分割对光照极其敏感,稍微逆光或者偏黄灯光,肤色范围就崩了,模板匹配也跟着失效。
第二种思路是检测虹膜。通过霍夫圆变换找眼珠位置,只要检测不到虹膜就认为眼睛闭着。这个思路在实验室里效果不错,但驾驶员一戴眼镜、一有反光,系统就开始乱报。
第三种思路是直接测量上下眼睑的像素距离。看起来最直观,实际最坑——这个距离和摄像头到人脸的物理距离强相关。人往前靠一点,距离变大;往后靠一点,距离变小。你根本没法用一个固定像素阈值去判断“闭眼”。
这也是我后来坚定选择 EAR 的原因:它不依赖绝对像素值,而是依赖关键点之间的比值关系。摄像头远近、人脸在画面里的大小变化,对它的影响都很小。
1.2 EAR 出自一个非常巧妙的归一化设计
EAR 全称是Eye Aspect Ratio,直译就是眼睛纵横比。它的原理很简单:在 dlib 输出的 68 个面部关键点里,每只眼睛周围有 6 个点,编号是固定的。把这 6 个点拿出来,用下面这个公式计算:
EAR = (||p2 - p6|| + ||p3 - p5||) / (2 * ||p1 - p4||)这里 p1 到 p6 是顺时针排列的 6 个点,分别对应:p1 是外眼角,p4 是内眼角,p2、p3 是上眼睑边缘,p5、p6 是下眼睑边缘。分子是上眼皮到对应下眼皮的两段垂直距离之和,分母是内外眼角之间的水平距离乘以 2。
可以把它想象成一条门缝:门的宽度基本固定,相当于两个眼角之间的距离;门缝的高低变化相当于上下眼睑之间的距离。门缝越窄,这个比值越小;门彻底关上时,分子趋近于 0,比值也变得很小。
我自己实测的经验数值大概是这样的:正常情况下睁着眼正视屏幕,EAR 大约在 0.28 到 0.35 之间;自然眨眼时最低能到 0.08 左右;如果人开始犯困、眼皮明显耷拉下来,EAR 会持续停留在 0.15 以下的区间。这也是后面设置报警阈值的基础。
1.3 为什么配合 dlib 而不是直接训练神经网络
现在很多人一听到“人脸关键点”就想到深度学习。但疲劳检测这个场景有个特殊性:它需要长时间运行,对延迟敏感,而且大部分运行环境没有独立显卡。dlib 的 68 点检测模型基于梯度提升树和 HOG 特征,在 CPU 上单帧推理也就几毫秒到十几毫秒,计算开销比小型卷积神经网络还要低一个量级。
另一个原因是工程成本。训练一个自己的眼睛关键点模型,需要准备几千上万张标注图片,还要处理数据清洗、过拟合、模型转换、推理部署这一整套流程。而 dlib 的shape_predictor_68_face_landmarks.dat是现成的预训练模型,对正脸、微侧脸的稳定点定位效果很好,直接拿来用就行。对绝大多数中小型项目来说,在这上面自己造轮子,性价比很低。
2. 环境准备:dlib 安装和模型文件是最大的两道坎
2.1 依赖清单与版本建议
这一步看起来简单,实际卡住过不少人。我整理了一份按实际使用排过坑的依赖清单:
| 依赖库 | 建议版本 | 用途 |
|---|---|---|
| Python | 3.9 或 3.10 | 版本太新可能没有对应 dlib 预编译包 |
| opencv-python | 4.8 及以上 | 图像读取、缩放、绘图显示 |
| dlib | 19.24 以上 | 人脸检测与 68 点关键点定位 |
| numpy | 1.26 或兼容版本 | 关键点数组和欧氏距离计算 |
安装命令只有几行:
pip install opencv-python pip install numpy pip install dlib如果你只是想快速验证代码,建议直接pip install dlib,没有报错就是命中了预编译包,能省掉编译那一大堆折腾。如果报错,会在后面的小节展开说解决办法。
2.2 dlib 安装失败的两种典型情况
我遇到的 dlib 安装失败基本分两类。
第一类是缺少 CMake。dlib 的 Python 绑定需要先编译 C++ 库,Windows 上会要求你先装 Visual Studio 的 C++ 生成工具,以及 CMake。这时候不要硬杠,直接去对应平台装好这两个前置依赖再重试,成功率会高很多。
第二类是 Python 版本太新。dlib 的预编译包往往滞后于 Python 版本,如果你用的是最新版 Python,大概率找不到对应的 wheel 文件,就会尝试源码编译。源码编译不是不行,只是慢,动辄十几分钟,而且中途报错很难一眼看出问题。我的建议是:如果卡在编译,优先考虑换一个低一两个小版本的 Python 环境,而不是跟编译报错较劲。
2.3 模型文件必须下载到本地
dlib 本身不自带人脸关键点模型,工程目录里必须放一个shape_predictor_68_face_landmarks.dat文件,大约 99MB。这个文件来自 dlib 的官方模型库,下载后放在项目根目录下的models文件夹里,比如这样:
project/ ├── models/ │ └── shape_predictor_68_face_landmarks.dat └── fatigue_detect.py装完环境之后,先用一小段代码验证模型能正常加载:
import dlib detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("models/shape_predictor_68_face_landmarks.dat") print("dlib 模型加载成功")能打印出这行字,环境就基本没问题了。后面所有代码都基于这个detector和predictor展开。
3. 手写整套检测流程:从摄像头画面到疲劳判定
3.1 主流程拆解
整个疲劳检测程序的逻辑线并不复杂,可以分成五步:
- 从摄像头读取一帧彩色图像,缩放到合适尺寸并转灰度。
- 用
detector在灰度图上做的人脸检测,拿到人脸矩形区域。 - 用
predictor在人脸区域内定位 68 个关键点。 - 取出左右眼各自 6 个关键点坐标,分别计算 EAR,再取平均值。
- 根据 EAR 是否低于阈值,结合“连续帧统计”,判断是眨眼还是闭眼疲劳。
我说的“取平均值”,是指把左眼 EAR 和右眼 EAR 相加除以二。原因很简单:当人脸稍微侧倾时,左眼和右眼的单侧 EAR 会一边偏高、一边偏低,平均之后能抵消一部分角度带来的波动,整体更稳定。
3.2 EAR 函数这样写,清晰又不容易出错
先说一下关键点的索引。dlib 的 68 点模型里,左眼的 6 个关键点索引是 36 到 41,右眼是 42 到 47。拿到shape对象后,我习惯先把它转成一个 numpy 数组,再切片取眼睛区域,这样后面的坐标计算代码会非常简洁。
用 numpy 写 EAR 函数,不需要额外引 scipy:
import numpy as np def eye_aspect_ratio(eye): # 垂直方向两段距离 A = np.linalg.norm(eye[1] - eye[5]) B = np.linalg.norm(eye[2] - eye[4]) # 水平方向距离 C = np.linalg.norm(eye[0] - eye[3]) # 防止分母接近 0 导致除零异常 if C < 1e-6: return 0.0 return (A + B) / (2.0 * C)这里有个容易被忽略的小坑:如果人脸检测框异常导致眼睛点全部重合,C会变成 0,直接除会抛异常或者返回 inf。虽然这种极端情况不常见,但加一个保护判断能让长时间运行的程序更稳。
3.3 完整主程序:人脸框、眼睛轮廓和 EAR 实时显示
下面是一份可以直接运行的主程序。考虑到兼容性和最少依赖,我直接用cv2.VideoCapture读取摄像头,没有引入额外的视频流库。
import cv2 import dlib import numpy as np # 模型初始化 detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("models/shape_predictor_68_face_landmarks.dat") # 关键点索引 LEFT_EYE = list(range(36, 42)) RIGHT_EYE = list(range(42, 48)) # 参数:阈值和连续帧数 EAR_THRESH = 0.25 CONSEC_FRAMES = 3 # 连续低于阈值多少帧记为一次眨眼 ALARM_FRAMES = 40 # 连续低于阈值多少帧触发疲劳报警 def eye_aspect_ratio(eye): A = np.linalg.norm(eye[1] - eye[5]) B = np.linalg.norm(eye[2] - eye[4]) C = np.linalg.norm(eye[0] - eye[3]) if C < 1e-6: return 0.0 return (A + B) / (2.0 * C) def get_eye_coords(shape, idxs): return np.array([(shape.part(i).x, shape.part(i).y) for i in idxs], dtype=int) cap = cv2.VideoCapture(0) frame_w = 720 blink_counter = 0 total_blinks = 0 low_ear_frame = 0 fatigue = False while True: ret, frame = cap.read() if not ret: break frame = cv2.resize(frame, (frame_w, int(frame.shape[0] * frame_w / frame.shape[1]))) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = detector(gray, 0) for face in faces: shape = predictor(gray, face) left_eye = get_eye_coords(shape, LEFT_EYE) right_eye = get_eye_coords(shape, RIGHT_EYE) left_ear = eye_aspect_ratio(left_eye) right_ear = eye_aspect_ratio(right_eye) ear = (left_ear + right_ear) / 2.0 # 绘制眼睛轮廓 left_hull = cv2.convexHull(left_eye) right_hull = cv2.convexHull(right_eye) cv2.drawContours(frame, [left_hull], -1, (0, 255, 0), 1) cv2.drawContours(frame, [right_hull], -1, (0, 255, 0), 1) # 眨眼计数逻辑 if ear < EAR_THRESH: blink_counter += 1 low_ear_frame += 1 else: if blink_counter >= CONSEC_FRAMES: total_blinks += 1 blink_counter = 0 # 疲劳判定 if low_ear_frame >= ALARM_FRAMES: fatigue = True else: fatigue = False # 画面文字信息 cv2.putText(frame, f"EAR: {ear:.2f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.putText(frame, f"Blinks: {total_blinks}", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.putText(frame, f"LowEarFrames: {low_ear_frame}", (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 0, 0), 2) if fatigue: cv2.putText(frame, "FATIGUE!", (200, 200), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 255), 4) cv2.imshow("Fatigue Detection", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()这段代码里有一个细节值得展开:detector(gray, 0)的第二个参数0表示不做金字塔上采样。默认值如果不写,dlib 会尝试放大图像去检测更小的脸,检测率更高,但速度会慢不少。在距离摄像头比较固定的驾驶场景中,人脸不会太小,用0可以换来接近成倍的提速。
另一个细节是cv2.convexHull。直接用cv2.polylines把 6 个关键点连起来其实也可以,但容易出现眼睛轮廓不够圆滑的问题,尤其在上下眼睑两个点比较靠近时,画出来的形状会很奇怪。凸包能保证轮廓闭合且形状稳定,显示效果也接近真实眼皮走势。
3.4 疲劳判定的计数器设计
这部分是容易写乱的地方。很多人把“眨眼计数”和“疲劳判定”混在一起,结果就是系统分不清“正常眨眼”和“眼睛持续闭着”。
我的做法是拆成两个独立状态:
blink_counter用来统计单次低于阈值持续了多少帧,当 EAR 回升到阈值之上时,如果该数值已经达到CONSEC_FRAMES,就认为完成了一次眨眼,total_blinks加一。low_ear_frame用来记录本次低于阈值的总时长,不随眨眼结束而清零,只有在 EAR 恢复到阈值以上时才清零。当它超过ALARM_FRAMES,判定为疲劳。
区分这两个状态的意义在于:自然眨眼是很正常的事情,单次眨眼就算眼皮闭合了,也不代表这个人疲劳;但眼睛闭着超过一两秒不睁开,就非常有理由怀疑犯困了。用两个不同的时长阈值去做逻辑判断,会比单一阈值可靠得多。
4. 阈值和连续帧数:决定这套系统准不准的两个数字
4.1 EAR 阈值 0.25 只是一个起点,不是标准答案
很多教程写EAR_THRESH = 0.25,然后用它跑完全程。从经验值来看,这个数字确实在大多数场景下能区分睁眼和闭眼,但它绝对不是普适的。
原因在于 EAR 是 6 个关键点之间距离的比值,虽然对摄像头距离不敏感,但对摄像头高度、人脸角度、眼睛大小还是有一定依赖。比如摄像头放在偏下方的位置仰拍,睁眼时的 EAR 可能整体被压低到 0.22;反过来,如果摄像头偏高俯拍,EAR 可能整体偏高。个体之间也有差异,有些人眼睛本来就大,眼皮轮廓舒展,睁眼 EAR 能到 0.38,另一些人耷拉眼皮,睁眼可能只有 0.24。
所以我的建议是:先不要急着定阈值,把代码里显示 EAR 的那一行保留,对着摄像头正常睁眼坐十秒,记录一下睁眼时 EAR 的区间;然后再刻意闭眼几次,记录一下闭眼瞬间 EAR 的最低值。取“睁眼最低值”和“闭眼最高值”中间的位置,大致就是比较合适的阈值。一般来说这个中间位置在 0.18 到 0.25 之间比较常见。
4.2 连续帧数要换算成物理时间,而不是凭空拍脑袋
CONSEC_FRAMES = 3这个值的含义是“连续 3 帧 EAR 低于阈值,才算一次眨眼”。为什么要连续多帧而不是单帧就判定?因为单帧受噪声干扰很大,关键点偶发抖动、眨眼过程中的中间状态都会导致瞬间低于阈值,如果只看一帧,会把很多非眨眼状态也算进去。
连续帧数的选择要结合帧率。假设摄像头是 30 FPS,那么:
1 帧 ≈ 33 毫秒 3 帧 ≈ 100 毫秒 40 帧 ≈ 1.33 秒正常情况下,一次自然眨眼的闭合过程大约是 100 到 200 毫秒,对应 30 FPS 下就是 3 到 6 帧。所以CONSEC_FRAMES = 3可以认为是最低门槛:低于这个值的话,连正常自然眨眼都可能被漏掉;大于 6 的话,部分快速眨眼又会被误判成未眨眼。至于ALARM_FRAMES = 40,意思就是眼睛连续低 EAR 超过 1.3 秒就报警,这也是一个比较接近“明显困倦”的设定。
如果你的摄像头实际帧率不是 30 FPS,这两个数字必须跟着换算。比如帧率掉到 15 FPS,同样要 100 毫秒的眨眼,连续帧数 3 帧就只覆盖了 200 毫秒,明显太宽松了。这也是我建议在主循环里加一个 FPS 显示的原因。实际做法可以简单一点,用time.time()每隔一定帧数计算一次平均帧率,然后动态把“期望的毫秒时长”换算成帧数阈值,效果会好很多。
4.3 疲劳报警策略:眨眼频率和单次闭眼时长是两回事
这里补充一个很容易被忽略的点:疲劳检测严格来说有两个维度,一个是眨眼频率变快,另一个是单次闭眼时长变长。两者都有参考价值,而且疲劳时往往同时出现。
- 单次闭眼时长:用上面代码里的
low_ear_frame判断,超过一定时间就报警。这是最直接的疲劳信号。 - 眨眼频率:用
total_blinks统计一段时间内的眨眼次数。正常环境下人平均每分钟眨眼 15 到 20 次,疲劳时频率会波动,注意力高度集中时也可能骤降到很低。这个维度适合做长时间统计,作为辅助参考。
我做项目时一般先只做单次闭眼时长报警,把频率统计和趋势曲线留到后续再扩展。原因是频率统计依赖平滑算法,阈值不好定,误报率在短期测试中往往偏高。
4.4 一组实测调参数据,给你一个参照
下面这组数据来自我本地摄像头(30 FPS,正对屏幕,摄像头略高于视平线)的实测,可以作为调参参照,不代表所有人适用:
| 状态 | EAR 实测区间 | 建议判定 |
|---|---|---|
| 正常睁眼平视 | 0.28 - 0.34 | 不触发 |
| 眨眼中途 | 0.10 - 0.22 | 结合连续帧判定眨眼 |
| 闭眼 | 0.08 以下 | 结合连续帧判定闭眼 |
| 眼睛半眯(犯困) | 0.15 - 0.20 | 风险状态,可提前预警 |
如果真的想做一个比较可靠的系统,我的做法是给“风险状态”和“疲劳状态”分两级:当low_ear_frame超过 20 帧时提示“注意”,超过 40 帧时才真正报警。这样既不会错过逐渐明显的犯困过程,又能减少突发噪声引起的误报。
5. 实测中容易翻车的场景和针对性优化
5.1 光照突变是最大的敌人,加一个 CLAHE 立竿见影
夜间开车、隧道、逆光,这些场景下的脸部光照可能不断变化。dlib 的人脸检测器对光照有一定容忍度,但detector一旦找不到人脸,后面所有关键点计算都会跳过,画面直接没有任何反馈。
实测下来,对灰度图做 CLAHE(自适应直方图均衡)能有效提升检测稳定性。在gray之后加一行:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray = clahe.apply(gray)这一行基本不增加耗时,但对逆光、偏暗画面的改善非常明显。这里要提醒的是,clipLimit不要调太大,超过 4.0 会让图像产生明显噪点,反而干扰关键点定位。
5.2 侧脸和低头:别把“检测不到”当成“闭眼”
dlib 的正脸检测器对接近正脸的人脸效果最好,一旦侧脸超过 45 度、低头幅度较大,检测框就会丢失。这种情况下系统很容易“静默”——既不说疲劳,也不说正常。
这里最重要的一个工程原则是:检测不到人脸时,什么都不做,不要尝试用上一帧的数据去猜测。很多人为了让报警连续,会在丢失人脸时沿用上一帧的 EAR 值,这个做法在快速摇头、低头捡东西时会产生大量误报。因为上一帧的 EAR 可能刚好处于低值区间,沿用一步就会让low_ear_frame继续累加,最终触发假疲劳报警。
如果你需要更强的侧脸检测能力,可以考虑把detector换成基于深度学习的人脸检测器,比如 OpenCV 自带的深度学习人脸检测模块或者轻量级的推理模型。但代价是 CPU 占用增大,帧率可能从 30 FPS 掉到十几 FPS。在驾驶场景里,我更愿意接受侧脸时暂时无输出,然后靠时间维度做容错处理。
5.3 眼镜反光和镜框遮挡:这个坑很难完全绕开
戴眼镜是疲劳检测的经典难题。镜框直接覆盖上眼睑区域时,关键点容易被拉到镜框边缘;镜片反光会让关键点在反光点上跳动,EAR 数值随之剧烈波动。
我的处理经验有几个层次。第一层是数据层面:做参数校准时,最好戴着实际工作时的眼镜录一段视频,用真实数据标定阈值。第二层是逻辑层面:如果观察到 EAR 频繁在短时间内大幅跳变,说明关键点已经不稳,这时候应该降低眨眼计数灵敏度,即把CONSEC_FRAMES调大一些,宁可少报几次眨眼,也不能被抖动干扰到报警。第三层是物理层面:调整摄像头位置,比如把镜头稍微侧移一点,通常能明显减少镜片反光对眼睛区域的正面反射。
5.4 帧率不稳定时,把帧计数改成时间计数
如果你的运行平台性能不太稳定,主循环里实际上不会严格保持在 30 FPS。帧率下降一半时,原先的 40 帧报警就变成了接近 2.7 秒,报警反应明显变慢。反过来帧率突然升高,报警又会变快。
改进方式不复杂,把帧计数换算成时间累积。在进入低 EAR 状态时记录time.time(),EAR 高于阈值时清零;每次进入低 EAR 状态前先判断累计时间是否超过报警阈值。伪代码思路如下:
import time low_ear_start = None if ear < EAR_THRESH: if low_ear_start is None: low_ear_start = time.time() elapsed = time.time() - low_ear_start if elapsed >= 1.5: fatigue = True else: low_ear_start = None这样即使帧率波动,报警时间也能保持稳定。如果你的摄像头稳定在 30 FPS,用帧计数也没问题,省一个时间判断的开销;但如果跑在树莓派或低功耗工控机上,建议直接改用时间计数。
5.5 这套方案后续还能往哪些方向扩展
把基础版跑通之后,扩展方向其实很丰富,我列几个实用且改造量可控的方向供参考。
- 头部姿态估计:dlib 的 68 点不止包含眼睛,还有鼻子、下巴、眉骨,这些点配合标准三维人脸模型,可以估算头部的俯仰角和偏转角。驾驶员长时间低头看手机、歪头,都可以用这套逻辑检测出来,和眼睛状态一起形成更完整的疲劳画像。
- PERCLOS 标准:疲劳检测领域有一个经典评价指标叫 PERCLOS,即单位时间内眼睛闭合时间占比。在 dlib 基础上统计到足够长时间窗口的数据后,用这个指标做疲劳分级会比单一的“持续闭眼”更平滑。
- 多目标支持:对车辆后排乘客或者多个值班人员,在外层遍历
faces列表,为每一张脸分别维护一套计数状态即可。代码结构上稍微改造,从原来的单脸逻辑变成按人脸框 ID 区分即可。 - 报警联动:识别到疲劳后,不只是画个红字,可以接蜂鸣器、语音提示,甚至通过串口或网络信号联动外部设备。这种联动功能在实际项目中往往是买方最看重的部分。
最后再分享一个我个人的建议:调试疲劳检测时,别对着摄像头一遍遍眨眼去测,效率太低,效果也不稳定。正确的做法是先录几段短视频,分别包含正常睁眼、频繁眨眼、长时间闭眼三种状态,然后离线跑代码调参数。参数定了以后,再切换到实时摄像头验证。这样你能快速对比不同阈值、不同连续帧数下的检测结果,也方便把每一次调参的效果记录下来。我自己前几次调整阈值时,就是靠这个方法二十分钟内完成了几十组参数对比,比之前对着摄像头干试快了不知道多少倍。