dlib 68点关键点与EAR/MAR指标:疲劳检测系统从原理到落地

dlib 68点关键点与EAR/MAR指标:疲劳检测系统从原理到落地 简介基于dlib与深度学习的疲劳检测系统资源包面向计算机视觉学习者、驾驶员状态监测开发者以及需要快速搭建疲劳预警原型的研究人员解决人脸关键点定位、眨眼与打哈欠识别等核心问题。资源共19个文件以Python脚本、配置文件、Jupyter交互式笔记、预训练人脸关键点模型、测试视频和说明文档为主压缩包约72.58MB目录结构清晰。系统采用dlib的人脸检测与形状预测器提取眼睛、嘴巴等面部关键点结合深度学习思路计算闭眼时间比例与哈欠频率从而判断驾驶或作业状态是否疲劳。资源中已集成简易图形界面、眼部检测、嘴部检测、关键点检测等脚本以及模型文件和测试视频可帮助读者快速运行完整流程并进一步拓展为实时预警工具。已有215人学习下载适合具备一定Python基础、希望以实际工程方式复现疲劳检测系统的开发者使用。1. 用 dlib 搭建疲劳检测系统为什么这仍然是最值得走的路线疲劳检测这几年从驾驶监控一路延伸到课堂状态分析、值班室值守、远程面试专注度评估技术路线也翻了好几轮。早期方案用 OpenCV 自带的 Haar 级联器定位眼睛区域再靠眼白像素分割判断睁闭眼这套做法在正脸、光线均匀的环境下勉强能跑但人一歪头、戴上眼镜或者处于逆光环境分割结果就崩了误报率高到没法用。dlib 给出的 68 点人脸关键点定位则绕开了这个脆弱的环节眼睛、嘴巴、眉毛的轮廓坐标一次性输出疲劳判据直接建立在关键点之间的几何距离比值上对头部姿态和机位远近有天然容忍度。整个系统的计算量在普通四核 CPU 上就能跑满 25 帧以上不依赖 GPU这是它至今仍是工业落地首选的根本原因。2. dlib 68 点模型解析坐标索引与 EAR/MAR 指标的原理2.1 68 点坐标索引分布眼部、嘴部区域怎么划分dlib 的 shape_predictor 输出 68 个关键点索引遵循 iBUG 300-W 数据集的标注规范。0 到 16 是下颌轮廓17 到 21 是左眉22 到 26 是右眉27 到 35 是鼻梁鼻尖36 到 47 是双眼轮廓48 到 67 是嘴部。疲劳检测真正用到的只有两段36 到 41 是左眼42 到 47 是右眼60 到 67 是嘴部内唇。这几个索引段的排列顺序是固定的而且每个点都按顺时针围绕器官轮廓分布。左眼的 36 号点落在内眼角39 号点落在外眼角38 和 40 分别是上下眼睑的顶点。右眼完全对称42 在内眼角45 在外眼角。嘴部内唇的 60 到 67 八个点同样按轮廓排列61 和 65 是左右嘴角位置63 和 67 是上下唇的顶点。开发时最容易犯的错是索引记混比如把 42 到 47 当成左眼。建议第一次接入时把 68 个点的编号全部画在帧上核对一遍这个步骤省掉后面所有阈值标定都可能是无效劳动。2.2 EAR 与 MAR几何比值为什么比绝对距离更稳EAR即 Eye Aspect Ratio是这套系统的核心指标。它的算式是取眼睛轮廓上两个垂直方向的距离之和除以水平方向的距离具体到 dlib 索引EAR (|p38 - p40| |p37 - p41|) / (2 * |p36 - p39|)分子是上眼睑到下眼睑两条垂直距离分母是内眼角到外眼角的水平宽度。眼睛睁开时这个比值稳定在 0.25 到 0.35 之间闭眼时上下眼睑距离趋近于零EAR 会断崖式跌到 0.15 以下。选用比值而不是像素距离的根本原因在于尺度不变性摄像头分辨率不同、人脸距镜头远近不同分子分母会同步缩放比值几乎不受影响。这意味着同一套阈值可以跨机位、跨分辨率使用这是 dlib 方案相比 Haar 加像素统计方案最本质的优势。MAR即 Mouth Aspect Ratio构造方式完全对称MAR (|p62 - p68| |p63 - p67| |p64 - p66|) / (2 * |p61 - p65|)分母是嘴角宽度分子取三组上下唇垂直距离。平时 MAR 在 0.2 上下浮动打哈欠时会冲到 0.6 以上。嘴巴这个信号的问题在于干扰源太多说话、咀嚼、大笑都会拉高数值所以 MAR 只能作为辅助判据与眼睛闭合状态联合决策不能单独触发疲劳告警。2.3 最小可用实现加载 dlib 检测器与预测器dlib 的调用链路分成两步检测器负责在整帧图中找出人脸矩形框预测器负责在框内定位 68 个关键点。检测器基于 HOG 特征加线性 SVM640x480 灰度图上单帧检测耗时大约 5 到 15 毫秒预测器每张脸 1 到 3 毫秒两者叠加可以跑满 25 帧以上的实时处理。import dlib import cv2 # HOG 线性 SVM 人脸检测器CPU 实时可用 detector dlib.get_frontal_face_detector() # 68 点关键点预测器模型文件需要单独下载约 100MB predictor dlib.shape_predictor(shape_predictor_68_face_landmarks.dat) img cv2.imread(face.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 第二个参数 1 表示图像金字塔上采样一次能找回更小的人脸 faces detector(gray, 1) for face in faces: shape predictor(gray, face) # 在检测到的人脸框内定位关键点 left_eye [(shape.part(i).x, shape.part(i).y) for i in range(36, 42)] right_eye [(shape.part(i).x, shape.part(i).y) for i in range(42, 48)] print(left eye points:, left_eye)模型文件 shape_predictor_68_face_landmarks.dat 需要从 dlib 官方发布页单独下载不在 pip 包内。detector(gray, 1)中的数字是 upsample_num表示对输入图像做几次金字塔放大后再检测。设为 1 能捕捉更小尺寸的人脸适合坐姿离摄像头较远的场景但耗时也会相应增加。如果发现远处的人脸检测不到优先调这个参数而不是换神经网络模型。predictor(gray, face)的第二个参数是检测器返回的矩形框对象传入时不需要转换成 tuple直接用原始返回值即可。提示HOG 检测器要求输入灰度图彩色图虽然不报错但检测效果和速度都会下降。Pipeline 里务必在送入 detector 之前完成cv2.cvtColor。3. 完整检测流水线从视频帧到疲劳状态输出3.1 视频帧读取与人脸检测调度策略接入摄像头或视频文件用 OpenCV 的 VideoCapture每帧完成人脸检测、关键点定位、指标计算后直接把结果绘制到帧上。这里有一个取舍如果每帧都做全图人脸检测CPU 占用率会顶到接近 100%四核机器上直接吃掉一整个核心。常见做法是隔帧检测每两帧做一次全图检测中间帧沿用上一帧的人脸框坐标。相邻帧之间头部位移很小沿用旧框喂给预测器的精度损失可以忽略CPU 占用却能下降约三分之一。import cv2 import dlib detector dlib.get_frontal_face_detector() predictor dlib.shape_predictor(shape_predictor_68_face_landmarks.dat) cap cv2.VideoCapture(0) frame_idx 0 prev_box None lost_frames 0 while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if frame_idx % 2 0 or prev_box is None or lost_frames 10: faces detector(gray, 1) prev_box faces[0] if len(faces) 0 else None lost_frames 0 if prev_box is not None else lost_frames 1 else: lost_frames 1 if prev_box is not None: shape predictor(gray, prev_box) # 此处在后续小节接入 EAR、MAR 计算与绘制 frame_idx 1 if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段调度逻辑里lost_frames变量承担了人脸丢失后的兜底职责。连续 10 帧没有检测到人脸时即使上一帧还有旧的prev_box也要强制做一次全新检测否则拿着过期的矩形框去喂预测器关键点会发散到完全错误的位置。cap.read()返回的帧默认是 BGR 色彩空间灰度转换务必放在送入 dlib 之前检测器对灰度图的要求是硬性的。3.2 眨眼判断与 PERCLOS 统计窗口的实现单帧的 EAR 只能回答这一刻眼睛闭着没有真正用于疲劳判定的是 PERCLOS 指标。PERCLOS 的定义是统计窗口内眼睛处于闭合状态的时间百分比行业里常说的 P80 标准指眼裂闭合超过 80% 计为闭合状态。工程实现上不直接测时间而是统计闭合帧数占窗口总帧数的比例因为帧率已知时帧数比例和时间比例是等价的。import numpy as np def eye_aspect_ratio(eye_points): # eye_points 按索引顺序传入 6 个点坐标 p1, p2, p3, p4, p5, p6 eye_points vertical_1 np.linalg.norm(np.array(p2) - np.array(p6)) vertical_2 np.linalg.norm(np.array(p3) - np.array(p5)) horizontal np.linalg.norm(np.array(p1) - np.array(p4)) return (vertical_1 vertical_2) / (2.0 * horizontal)左眼传入[36..41]六个点的坐标列表右眼传入[42..47]分别算出 EAR 后取平均值作为当前帧的眼部状态。np.linalg.norm计算的是两个坐标点之间的欧氏距离vertical_1 对应眼角到眼角的对角距离之所以取两条垂直方向的平均值是为了平滑单点抖动带来的误差。次像素级别的关键点抖动是 dlib 预测器的正常输出特性单条距离可能跳 2 到 3 个像素取平均后误差能被抑制到可接受范围内。PERCLOS 的统计窗口我一般设固定秒数而不是固定帧数比如过去 30 秒。这是因为不同摄像头帧率差异很大固定帧数会导致窗口实际时长漂移。实现上用 collections.deque 存指标和时间戳超过窗口时间就弹出旧数据。工程上的判定阈值通常取 0.35 到 0.4即窗口内闭合时间的比例超过 35% 判为疲劳这个值比医学 P80 标准更激进是因为驾驶类应用对预警时效性要求更高宁可早报不可漏报。3.3 打哈欠检测MAR 门限与持续时间双重约束MAR 的实时计算与 EAR 类似但判定逻辑要复杂一点。我的经验值是 MAR 大于 0.6 且持续 10 帧以上才记一次有效哈欠只超过 0.6 而持续时间低于 5 帧的多半是说话或者快速张嘴的动作直接丢弃。def mouth_aspect_ratio(inner_lip_points): # inner_lip_points 传入 [60..67] 八个点 p1, p2, p3, p4, p5, p6, p7, p8 inner_lip_points vertical_1 np.linalg.norm(np.array(p2) - np.array(p8)) vertical_2 np.linalg.norm(np.array(p3) - np.array(p7)) vertical_3 np.linalg.norm(np.array(p4) - np.array(p6)) horizontal np.linalg.norm(np.array(p1) - np.array(p5)) return (vertical_1 vertical_2 vertical_3) / (3.0 * horizontal)三个垂直距离取平均而不是两个是因为嘴部轮廓相比眼眶更不规则多点平均能降低唇部关键点横向滑动带来的干扰。哈欠信号要与眨眼信号做交叉验证而不是简单叠加只有 PERCLOS 超过阈值且 30 秒内哈欠次数大于 2 次才触发强疲劳告警。单独的哈欠频繁但眼睛指标正常多半是说话或者环境干燥导致的张口习惯不应计入疲劳。3.4 疲劳状态机多信号融合与迟滞输出单帧指标抖动剧烈直接按帧输出会有大量状态闪烁。解决办法是引入状态机定义 normal、warning、fatigue 三个状态状态迁移必须满足持续帧数或持续时间的条件同时加入迟滞逻辑防止临界区来回跳变。class FatigueStateMachine: def __init__(self, fps30): self.fps fps self.eye_closed_frames 0 self.yawn_streak 0 self.yawn_count 0 self.window_frames fps * 30 # 30 秒滑动窗口 self.closed_history [] # 记录窗口内每帧是否闭眼 self.state normal # normal / warning / fatigue def update(self, ear, mar): is_closed ear 0.2 self.closed_history.append(1 if is_closed else 0) if len(self.closed_history) self.window_frames: self.closed_history.pop(0) if is_closed: self.eye_closed_frames 1 else: self.eye_closed_frames 0 if mar 0.6: self.yawn_streak 1 else: if self.yawn_streak self.fps * 0.3: self.yawn_count 1 self.yawn_streak 0 perclos sum(self.closed_history) / len(self.closed_history) # 状态迁移warning 需要 PERCLOS 0.35 # fatigue 需要 PERCLOS 0.5 且哈欠数 2 return perclos, self.yawn_count, self.state迁移规则设计成阶梯式PERCLOS 超过 0.35 从 normal 进入 warningPERCLOS 超过 0.5 且窗口内哈欠大于 2 次才进入 fatigue。从 fatigue 回到 normal 要求 PERCLOS 连续 5 秒低于 0.2这个恢复门槛比触发门槛严格得多。迟滞设计是为了让告警输出干净利落避免系统在 0.35 阈值附近来回抖动导致告警声反复响起。yawn_streak的复位逻辑放在 MAR 回落之后保证一次连贯的哈欠动作只计一次数。4. 阈值标定与参数调优dlib 疲劳检测准确率的关键4.1 EAR、MAR、PERCLOS 的推荐初始阈值指标正常区间疲劳判定阈值判定条件EAR0.25 - 0.35小于 0.2记为闭眼帧连续闭眼帧数0 - 2 帧大于等于 3 帧确认一次完整眨眼MAR0.15 - 0.30大于 0.6需持续 10 帧以上PERCLOS小于 0.2大于 0.3530 秒窗口内闭眼帧占比这张表给的是通用起步值不是金标准。不同使用者的眼裂大小差异明显有人正常睁眼时 EAR 只有 0.22有人轻松到 0.4一套固定阈值必然产生误报或漏报。稳妥做法是采集使用者前 5 分钟清醒状态下的数据做个性化标定计算 EAR 均值与标准差把闭眼阈值设在均值减去两倍标准差的位置。单次校准成本换来的是误报率数量级的下降在单人长期使用的场景里性价比极高。4.2 帧率对阈值的影响与滑动窗口的时长语义帧率与阈值参数强相关。同样一次 0.5 秒的闭眼动作30fps 下对应 15 帧15fps 下只有 7 帧。如果把判定条件写成EAR 小于 0.2 持续 3 帧30fps 下能捕捉到约 0.1 秒的瞬态闭合10fps 下则可能漏掉整个快速眨眼过程。因此连续闭眼帧数的阈值必须按帧率等比缩放帧率出现变化时整套参数要重新核对。滑动窗口也应定义成秒数而非帧数。用deque存储时间戳和闭眼标记每帧追加并弹出窗口之外的数据无论摄像头是 15fps 还是 30fps窗口语义都保持一致。固定数组实现会在帧率波动时产生窗口时长漂移时间戳方案天然免疫这个问题。4.3 光照、遮挡与侧脸的边界条件处理dlib 的 HOG 检测器在正脸和光照均匀场景下表现最好但存在明确边界。逆光时面部对比度下降HOG 特征响应变弱常见的补救是在送入检测器前做直方图均衡化用cv2.equalizeHist提升局部对比度。口罩等下半脸遮挡会让嘴部关键点置信度骤降MAR 指标直接失真此时要主动从判定逻辑中移除嘴部信号只保留眼睛和头部姿态维度。侧脸超过 45 度时68 点模型的部分关键点会贴到人脸轮廓边缘坐标抖动剧烈EAR 数值不可信。检测时发现两个单眼 EAR 与双眼均值偏差超过 30%说明存在明显侧转该帧应标记为低置信度不参与 PERCLOS 统计。宁可少算一秒也不能让异常值污染整个滑动窗口的统计结果。4.4 误报抑制确认制与冷却时间双保险误报来源集中在两类使用者的正常小动作例如揉眼睛、低头看手机以及检测器自身的关键点抖动。针对小动作使用确认制疲劳信号必须连续触发 3 到 5 帧才输出事件单帧的瞬时低 EAR 直接忽略。针对检测器抖动使用冷却时间机制一次疲劳告警触发后 5 秒内不再重复告警给状态机足够的时间完成恢复迁移。两个机制叠加之后系统输出从频繁闪断变成稀疏而稳定在实际部署中这个体验差异比准确率数字更能说明问题。5. 落地前的验证技巧关键点可视化与性能测量5.1 关键点索引可视化核对参数调了半天发现指标不对八成是坐标索引写错了位置。验证手段简单直接把 68 个点的序号和坐标画到原始帧上肉眼核对编号与器官位置的对应关系。for i in range(68): x, y shape.part(i).x, shape.part(i).y cv2.circle(frame, (x, y), 2, (0, 255, 0), -1) cv2.putText(frame, str(i), (x 4, y - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (0, 0, 255), 1)输出画面上36 号点应落在左眼内眼角39 号点在外眼角60 到 67 号点围成内唇一圈。任何一个编号落点与预期不符后续所有的 EAR、MAR 计算都是在错误几何上做运算。这个验证步骤要放在调参之前执行一次到位不要边调边猜。5.2 性能测量与分辨率锁定部署前用一段 60 秒测试视频测量两个数据平均处理帧率和 CPU 占用率。测量时关闭所有可视化窗口因为cv2.imshow的渲染本身会吃掉大量 CPU 时间测出来的数据是虚高的。处理帧率低于 20fps 时优先把输入图像缩小一半再送入检测器320x240 上的 HOG 检测速度比 640x480 快约四倍而关键点定位精度受影响很小EAR 比值对图像尺寸不敏感的特性在这里发挥了作用。还有一个容易被忽略的部署细节摄像头分辨率的默认值。不少笔记本摄像头默认以 1280x720 输出检测耗时相比 640x480 几乎翻倍。用cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)和cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)主动锁定输出分辨率再配合隔帧检测大多数四核 CPU 机器能稳定跑满 30 帧。验证时观察一段时间内 CPU 占用是否出现明显峰值波动波动剧烈通常是垃圾回收或帧读取阻塞导致必要时给 VideoCapture 加上缓冲队列做生产消费解耦。本文还有配套的精品资源点击获取