ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于MediaPipe的驾驶员疲劳检测:眨眼哈欠与PERCLOS状态机

基于MediaPipe的驾驶员疲劳检测:眨眼哈欠与PERCLOS状态机 简介计算机视觉中的人脸关键点检测是解锁智能交互与安全监控的基础技术。通过定位眼部与嘴部的特征点可进一步计算眼睛纵横比EAR与嘴巴纵横比MAR将“困倦”这一主观感受转化为客观数据。行业公认的PERCLOS算法则基于闭眼时间占比评估疲劳程度并在车载驾驶员监控系统DMS中落地为预警方案。本文以Python和OpenCV为工具结合MediaPipe实现人脸关键点实时提取详解EAR、MAR的计算原理以及基于迟滞比较的状态机如何避免误报。从技术选型对比到环境搭建踩坑再到阈值自适应调优完整呈现一套可演示、可答辩的疲劳检测系统原型为高校毕设及工程实践提供清晰路径。1. 疲劳检测入门从“驾驶员”三个字看这个毕业设计的真正难点做驾驶员疲劳检测第一反应都是“人脸检测 眼睛睁闭”。等你真正动手会发现人脸检测只是地基把摄像头怼到脸上OpenCV 的 Haar 级联都能干真正的难点在于“疲劳”这两个字怎么用程序定义出来——一个人困不困不能靠感觉得靠可计算、可复现的指标比如眨眼频率、眼睛闭合时间占比、打哈欠次数。这个基于驾驶员面部特征的疲劳检测系统做的就是这件事读摄像头视频流用面部关键点提取眼睛和嘴巴的状态算出疲劳指标再按阈值触发报警。它既能当毕业设计交差也能作为车载 DMS驾驶员监控系统的原型。适合想用 Python 做视觉方向的毕业生也适合想快速搭一套可演示系统的开发者。往下读之前先记住一句话这个项目的天花板不在模型在阈值和判定逻辑的工程化程度。2. 面部特征靠什么抓MediaPipe 与 dlib 的选型对比2.1 为什么用面部关键点而不是整张图分类常见做法是先检测人脸再取关键点而不是端到端训一个“困不困”的分类器。原因有三第一疲劳本身没有严格标注的数据集公开数据集大多只给了闭眼、打哈欠的帧级标签想端到端训分类器数据准备阶段就能耗掉大半项目周期第二关键点方案可解释性强答辩时你能明确说出“这个 EAR 值低于 0.2 持续了 3 秒判定为闭眼”评委听得懂也信得过第三关键点计算量小纯 CPU 也能跑实时不用纠结 GPU。这个项目里面部特征等价于 468 个或 68 个关键点的坐标。眼睛的六个关键点算一个数值嘴巴的关键点算一个数值剩下的疲劳判定全在这两个数值上做文章。选哪套关键点方案决定了你后面安装依赖时哭不哭。2.2 MediaPipe 与 dlib 的实测对比准确率、安装难度、帧率我给这个方向做过几轮选型两条路都跑通过。dlib 的 68 点方案是经典老路离线可用模型文件约 96MBPython 调用稳定但安装是硬伤在 Windows 上必须配好 VS Build Tools 和 CMake一不留神就编译半小时然后报错。MediaPipe 的 Face Mesh 是 Google 的方案473 个关键点含虹膜pip 直接装模型随包走CPU 上单张脸大约 5-10ms精度在普通摄像头下足够用。对毕业设计来说我强烈建议走 MediaPipe省下的环境时间够你把判定逻辑多调三轮。对比项MediaPipe Face Meshdlib 68 点安装成本pip 一步到位需编译Windows 上极易翻车关键点数468 面部 5 虹膜68实时性CPU 实时CPU 实时遮挡鲁棒性较好一般模型体积随 pip 包分发需额外下载约 96MB 模型2.3 开搞前的环境准备Python、OpenCV、MediaPipe 安装环境这块最容易在开头就劝退一批人。我的建议是直接用 Python 3.9-3.11别追最新版。MediaPipe 对 Python 版本有要求太新的版本可能没预编译轮子。装依赖用下面这组命令python -m venv venv # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate pip install mediapipe opencv-python numpy安装完成后验证一下能跑通再往下写代码。用 PyCharm 的话记得在 Settings 里把 Project Interpreter 指到刚才建的 venv新手 90% 的“import 报错”都是解释器没切对。用 VSCode 也一样CtrlShiftP 打开 Command Palette选 Python: Select Interpreter指向 venv 路径。这一步和装 sklearn、装其他库一个套路问题是它太隐蔽报错时你不会怀疑到解释器头上。提示如果 pip 安装 mediapipe 时提示找不到匹配版本先执行 python --version 看版本3.12 以上建议换 3.10 重装。3. 眨眼与打哈欠两个最可靠的疲劳特征怎么算3.1 眼睛纵横比 EAR每一帧都在算的“睁眼程度”疲劳检测最经典的指标是眼睛纵横比Eye Aspect RatioEAR。它不直接量眼睛像素宽度而是用关键点之间的欧氏距离比抵消人脸远近和尺寸的影响。MediaPipe 的左眼关键点编号是 [362, 385, 387, 263, 373, 380]右眼是 [33, 160, 158, 133, 153, 144]每只眼六个点。EAR 的计算公式是两组垂直距离的平均值除以水平距离睁眼时约 0.3闭眼时掉到 0.1 以下。下面这段是核心计算代码import cv2 import mediapipe as mp import numpy as np mp_face_mesh mp.solutions.face_mesh LEFT_EYE [362, 385, 387, 263, 373, 380] RIGHT_EYE [33, 160, 158, 133, 153, 144] def _euclidean(p1, p2): return np.linalg.norm(np.array([p1.x, p1.y]) - np.array([p2.x, p2.y])) def eye_aspect_ratio(landmarks, eye_idx): # 六点取垂直距离均值 / 水平距离 vertical (_euclidean(landmarks[eye_idx[1]], landmarks[eye_idx[5]]) _euclidean(landmarks[eye_idx[2]], landmarks[eye_idx[4]])) / 2.0 horizontal _euclidean(landmarks[eye_idx[0]], landmarks[eye_idx[3]]) return vertical / horizontal这段代码里的_euclidean接收的是 MediaPipe 的 NormalizedLandmark 对象坐标已经归一化到 0-1所以不需要关心图像分辨率。eye_aspect_ratio每帧调用两次把左右眼的 EAR 平均得到这一帧的ear_value。有一个细节容易漏MediaPipe 的 landmarks 顺序是固定的但你从results.multi_face_landmarks取到的对象是列表嵌套结构写代码时先打印一下索引对应的坐标确认不是镜像翻转后的顺序。3.2 嘴巴纵横比 MAR从打哈欠到持续张嘴嘴巴状态用嘴巴纵横比MAR刻画。MediaPipe 中上嘴唇上缘取 13 号点下嘴唇下缘取 14 号点这两个点的归一化距离就是开合程度。更稳的做法是取上唇两组点、下唇两组点求平均避免单点抖动。打哈欠时 MAR 值会明显超过平静状态的 2-3 倍持续 1 秒以上才算一次哈欠。MOUTH_UPPER [13, 312] MOUTH_LOWER [14, 87] def mouth_aspect_ratio(landmarks): # 取两个纵向点对的平均距离 vertical_1 _euclidean(landmarks[MOUTH_UPPER[0]], landmarks[MOUTH_LOWER[0]]) vertical_2 _euclidean(landmarks[MOUTH_UPPER[1]], landmarks[MOUTH_LOWER[1]]) return (vertical_1 vertical_2) / 2.0这段代码的返回值不是标准化的比值而是归一化坐标下的绝对距离。它的优点是计算简单缺点是对人脸距离略敏感——脸离摄像头近值会整体偏大。所以后面判定哈欠时不能只看单帧 MAR要看它在短时间窗口内的相对增量。实测中平静时 MAR 大约 0.02-0.04打哈欠时能冲到 0.08-0.15。如果你的摄像头距离固定这个范围可以直接用如果人脸忽远忽近就得加一个自适应基线见第 6 章。3.3 用帧差法把“眨眼事件”从连续 EAR 曲线里挑出来单帧的 EAR 只能告诉你“这一刻眼睛睁着还是闭着”眨眼是一个过程EAR 从正常值快速下降到闭眼阈值以下再快速回升。所以检测眨眼要在时间序列上做帧差判断。我一般维护一个长度为 3 的滑动状态前一帧正常、当前帧闭合、下一帧恢复满足这个模式眨眼计数加一。下面是带状态机的眨眼计数器class BlinkCounter: def __init__(self, eye_closed_thresh0.2): self.thresh eye_closed_thresh self.prev_closed False self.cur_closed False self.count 0 def update(self, ear_value): # 更新前后两帧的闭合状态 self.prev_closed self.cur_closed self.cur_closed ear_value self.thresh if self.prev_closed and not self.cur_closed: self.count 1 # 从闭合回到睁开算一次完整眨眼 return self.count这个实现里最关键的是“从闭合回到睁开”才计数而不是“睁眼变闭眼”就计数。如果按后者低头看屏幕、闭眼瞬间都会被误统计成眨眼。阈值eye_closed_thresh默认 0.2实际要根据人眼大小微调后面避坑章节会细说。4. 疲劳判定不只看单帧PERCLOS 与状态机4.1 PERCLOS 算法闭眼时间占比为什么是行业标准PERCLOSPercentage of Eye Closure是交通领域公认的疲劳指标核心思想是统计一个时间窗口内眼睛闭合时间所占的百分比。它比眨眼频率更抗噪眨眼频率高不一定困但闭眼时间占比持续升高基本可以断定疲劳。行业常用阈值是 PERCLOS 超过 0.15 判定为疲劳超过 0.25 判定为严重疲劳。实现上用一个固定长度的滑动窗口窗口内闭眼帧数除以总帧数from collections import deque class PerclosCalculator: def __init__(self, window_size150, closed_thresh0.2): self.window deque(maxlenwindow_size) # 存每帧的 ear 值 self.closed_thresh closed_thresh def add_frame(self, ear_value): self.window.append(ear_value) def get_perclos(self): if len(self.window) self.window.maxlen: return 0.0 closed_frames sum(1 for v in self.window if v self.closed_thresh) return closed_frames / len(self.window)window_size150对应 30fps 下 5 秒的窗口。窗口太短偶尔一次闭眼就会误报窗口太长疲劳反应滞后等报警时人已经快睡着了。这个 150 是经验值我建议在 120-180 之间调配合下面第 4.2 节的状态机做两档输出比单阈值实用得多。注意deque(maxlen...)的语义满了之后自动弹出最老的帧所以len(self.window)永远等于窗口容量代码里判断maxlen就是判断缓冲区是否攒够了数据。4.2 疲劳状态机分为清醒、轻度疲劳、重度疲劳三档单一 PERCLOS 阈值有个毛病卡在阈值附近时系统会在报警和不报警之间疯狂抖动屏幕上警报图标一闪一闪体验极差。解法是引入迟滞比较的状态机三个状态清醒0、轻度疲劳1、重度疲劳2。轻度疲劳时只提醒“注意休息”重度疲劳才触发声音报警。状态迁移规则写在下面STATE_AWAKE 0 STATE_MILD 1 STATE_SEVERE 2 class FatigueStateMachine: def __init__(self, mild_thresh0.15, severe_thresh0.25): self.mild_thresh mild_thresh self.severe_thresh severe_thresh self.state STATE_AWAKE def update(self, perclos): if self.state STATE_AWAKE: if perclos self.severe_thresh: self.state STATE_SEVERE # 直接跳到重度 elif perclos self.mild_thresh: self.state STATE_MILD elif self.state STATE_MILD: if perclos self.severe_thresh: self.state STATE_SEVERE elif perclos self.mild_thresh * 0.7: # 降到阈值的70%才回清醒 self.state STATE_AWAKE elif self.state STATE_SEVERE: if perclos self.severe_thresh * 0.6: # 重度回轻度需要更大的回落 self.state STATE_MILD return self.state这套状态机的精髓在回退阈值从轻度回到清醒要求 PERCLOS 降到 0.105 而不是 0.15从重度回到轻度要求降到 0.15 而不是 0.25。这个 0.7 和 0.6 的回退系数让状态切换有了“惯性”消除抖动。答辩时评委会问为什么阈值不写死你就把这段贴出来解释迟滞比较原理能加不少分。4.3 串成完整流程录像回放与实时摄像头双模式完整的主循环要兼顾两个输入源摄像头实时画面和视频文件。这在大作业里很常见评测试时不方便现场演示摄像头得能读 mp4。主循环框架如下def run_detection(source0): cap cv2.VideoCapture(source) # source0 为摄像头也可传视频文件路径 blink_counter BlinkCounter() perclos PerclosCalculator() fsm FatigueStateMachine() with mp_face_mesh.FaceMesh(max_num_faces1, refine_landmarksTrue) as mesh: while cap.isOpened(): ok, frame cap.read() if not ok: break frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results mesh.process(frame_rgb) if results.multi_face_landmarks: lm results.multi_face_landmarks[0].landmark ear_value (eye_aspect_ratio(lm, LEFT_EYE) eye_aspect_ratio(lm, RIGHT_EYE)) / 2.0 mar_value mouth_aspect_ratio(lm) blink_counter.update(ear_value) perclos.add_frame(ear_value) state fsm.update(perclos.get_perclos()) else: # 没检测到脸不更新任何指标避免误判 state fsm.state # 把状态和数值画到帧上供显示 cv2.putText(frame, fEAR: {ear_value:.3f}, (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.putText(frame, fState: {state}, (30, 70), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) cv2.imshow(Fatigue Detection, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码里三个细节值得注意第一refine_landmarksTrue必须开否则拿不到 468 点全量关键点上面用的 362、13 这些索引会越界第二没检测到人脸时直接跳过指标更新否则上一帧的人脸数据被错误延续闭眼数据会被稀释放大第三cv2.waitKey(1)的 1 毫秒延迟控制了处理帧率视频文件播放速度和实时基本一致。blink_counter和perclos在这里是独立工作的眨眼频率是短时指标PERCLOS 是长时指标两者互相印证。5. 避坑一个接一个的经典炸点与排查办法5.1 摄像头画面倒转、90度旋转与 MediaPipe 坐标系现象用笔记本内置摄像头检测框是正常的但报警状态和实际动作对不上或者人脸坐标明显偏移比如眨眼时嘴唇的 MAR 在跳。 原因摄像头采集的原始帧是镜像的而 MediaPipe 处理的是 RGB 帧没有做镜像翻转时关键点坐标在水平方向是错的。另外有些手机竖屏视频自带旋转 90 度的 EXIF 信息OpenCV 读出来是横着的。 解决统一在送入 FaceMesh 前处理帧。实时摄像头用cv2.flip(frame, 1)做水平镜像视频文件先检查宽高比宽小于高说明是竖屏用cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)纠正。顺序必须是先旋转/翻转再转 RGB、再送入 mesh.process。5.2 戴眼镜、光线差时 EAR 基线漂移现象戴黑框眼镜的同学睁眼 EAR 只有 0.16另一位不戴眼镜睁眼 EAR 是 0.28。同一套阈值下前者被持续判定为闭眼疲劳误报率拉满。 原因眼镜边框和镜片反光会改变关键点定位精度尤其深色粗框会把眼睑边缘“吸”过去导致垂直距离被压缩。 解决把阈值做成动态的。程序启动前 30 帧采集睁眼 EAR 的均值作为基线 base_ear然后判定阈值取 base_ear * 0.6而不是固定 0.2。这样基线自动适配眼镜、单双眼皮、摄像头距离。注意启动时要提示用户“目视前方保持睁眼 3 秒”否则初始基线是闭眼状态整套判定就反了。5.3 一眨眼就算疲劳判定参数调优的翻车现场现象对着摄像头正常阅读系统每分钟报警 3-4 次看日志发现 PERCLOS 经常飙到 0.2 以上。 原因正常阅读时眨眼频率本来就高每分钟 15-20 次每次闭眼约 100-150ms。如果 PERCLOS 窗口设得太短比如 30 帧 1 秒一次自然眨眼占 3-5 帧占比直接超过 0.15。 解决两步走。第一步拉长窗口到 5 秒以上150 帧30fps让单次眨眼被平均掉第二步给闭眼加最短时长限制连续闭眼超过 300ms 才算“有效闭眼”300ms 以下的可能是闪烁、光线抖动、眨眼本身。实现时维护一个闭眼起始帧号在PerclosCalculator.add_frame里同时记录连续闭眼帧数小于 9 帧的直接按睁眼计算。5.4 face 检测丢失后特征值跳变怎么办现象驾驶员低头看导航人脸短暂出画重新入画那一瞬间 EAR 骤降到 0.05系统立刻报重度疲劳。 原因else分支里如果沿用上一帧的 landmarks 继续算特征很容易出现越界或错误值如果没处理算法会把丢失帧当睁眼帧拉低 PERCLOS。 解决上面主循环代码已经做了一层处理——丢帧时保持原状态不更新指标。但还有一层重新检测到人脸的第一帧特征是“从无到有”的跳变EAR 可能还没稳定。我习惯在重新检测到脸后跳过前 5 帧再开始统计代码里用一个warmup_frame计数器即可。这个小细节不写进论文也能跑但写进去答辩时就是“工程鲁棒性”的加分点。5.5 环境搭建翻车Python 版本、OpenCV 和 MediaPipe 版本冲突现象pip install 很顺利但import mediapipe时直接报错提示 DLL load failed 或者 undefined symbol。 原因最常见的是 Python 3.12 没有对应的预编译 wheelpip 去源码编译然后失败其次是 OpenCV 和 MediaPipe 内部依赖的原生库冲突比如 numpy 版本过新。 解决用 Python 3.10 建虚拟环境固定安装顺序先装 numpy1.24.3再装 opencv-python最后装 mediapipe。不要把 requirements.txt 一次性装完分步装能定位到底哪一步坏的。如果已经装乱了不用重装 Python直接python -m venv --clear venv重建干净环境。6. 验证与答辩用你自己的数据把阈值“焊死”系统能跑起来只是及格关键是你要有底气说“我的阈值是合理的”。我的做法是录三段 60 秒视频一段正常驾驶状态、一段频繁眨眼但没闭眼、一段有明显犯困动作连续闭眼 2 秒以上、多次哈欠。离线跑一遍统计三种状态下的 PERCLOS、眨眼频率、MAR 均值。你会发现清醒段 PERCLOS 几乎稳定在 0.05 以下犯困段会冲到 0.3 以上——这两个数字就是你论文里的论据。进阶一点的做法是把 EAR 基线做成自适应更新的。每 10 秒取一次 EAR 的 90 分位值作为参考基线低于基线 40% 判闭眼。这样换人戴眼镜都不用重新调参。再往外扩展还能加语音报警pyttsx3 或 winsound、把疲劳事件和时间戳存到 CSV 里甚至通过串口发一个高电平模拟“控制方向盘震动”。这些扩展在答辩时是“系统完整性”的体现而且每一块代码量不大风险可控。我自己的习惯是写完主逻辑后先录一段自己的翻车视频故意闭眼、打哈欠、转头把阈值调到误报和漏报的平衡点。这个“用自己数据验证”的过程比任何理论推导都让你心里有底。希望帮到你。本文还有配套的精品资源点击获取
返回列表