基于Python和OpenCV的疲劳驾驶检测系统:从误报到可复现方案

发布时间:2026/10/11 21:20:22
基于Python和OpenCV的疲劳驾驶检测系统:从误报到可复现方案 简介这是一套面向高校计算机、人工智能及相关专业学生的毕业设计完整项目主题为基于Python与OpenCV的疲劳驾驶检测系统适合需要完成课程设计、毕业设计或想入门计算机视觉实战的开发者参考。资源包共22个文件约93.53MB涵盖Python源码、模型数据文件、XML配置、PNG与JPG图像素材、HTML页面及说明文档等源码与数据齐备可直接运行调试。项目围绕疲劳驾驶检测这一典型场景涉及人脸与眼部特征识别、状态判定等核心模块目录结构清晰便于按功能模块阅读与二次开发。该资源已获导师认可并高分通过代码经过实际测试运行成功后才上传功能完整可靠。目前已有1231人学习下载适合希望快速获取可运行项目、对照学习OpenCV视觉检测流程与工程组织方式的读者使用。1. 疲劳驾驶检测到底在检测什么从一次误报到一套可复现方案很多人第一次做疲劳驾驶检测脑子里想的是装个摄像头看到闭眼就报警。真跑起来才发现白天逆光、戴眼镜、副驾乘客入镜、司机低头看导航全都能让系统疯狂误报。我见过最离谱的一次某开发者把阈值设成连续闭眼 0.5 秒报警结果司机每次正常眨眼都被判疲劳一路响个不停最后只能把喇叭线拔了。这套基于 Python 和 OpenCV 的疲劳驾驶检测系统核心要解决的就是把眨眼、打哈欠、长时间闭眼、低头这几种状态区分开并且只在真正危险时报警。它适合两类人一类是正在做毕业设计、需要一套能跑通、能讲清楚原理、能改参数的完整方案另一类是刚接触计算机视觉、想找一个端到端小项目练手的工程师。整条链路不复杂——人脸检测、关键点定位、眼睛和嘴巴状态计算、时序判定、报警输出但每一环都有参数要调、有坑要踩。下面我按自己实际搭过的顺序把选型理由、代码、参数和翻车点一次讲透。2. 环境搭建与依赖选型为什么我不用最新版2.1 Python 与 OpenCV 版本怎么选新手最容易在第一步翻车pip 装了个最新版 OpenCV结果 dlib 编译不过或者 mediapipe 和 numpy 版本打架。我的血泪经验是疲劳检测这类项目对版本不敏感但对能装上极其敏感。稳妥组合是 Python 3.83.10OpenCV 用 4.5.x 到 4.8.x 之间的版本numpy 锁在 1.23 以下。原因很简单dlib 和 mediapipe 的预编译包大多针对这个区间越往上越容易触发源码编译Windows 上没装 CMake 和 Visual Studio 构建工具就直接卡死。# 建议用虚拟环境隔离避免污染全局 python -m venv fatigue_env # Windows 激活 fatigue_env\Scripts\activate # Linux / macOS 激活 source fatigue_env/bin/activate # 核心依赖版本区间是踩坑后总结的稳妥值 pip install opencv-python4.5,4.9 pip install numpy1.24 pip install dlib19.24.0 pip install imutils pip install scipy这段命令的逻辑是先隔离环境再按区间装依赖。opencv-python负责读摄像头、画框、做图像预处理dlib提供 68 点人脸关键点模型imutils是常用图像操作的小工具集scipy用来算欧氏距离比手写省事。参数上唯一要盯的是 dlib 版本19.24.0 有 Windows 预编译 wheel装不上就退到 19.22。如果 dlib 死活装不上别硬刚直接换 mediapipe 的 FaceMesh它自带 468 个关键点精度更高代价是模型稍大。2.2 关键点方案对比dlib 68 点 vs mediapipe 468 点选哪个不是看谁点数多而是看你的算力预算和精度要求。dlib 的 68 点里眼睛是第 3641 和 4247 点嘴巴是 4867 点结构清晰CPU 上单帧几毫秒适合低配笔记本。mediapipe 的 468 点覆盖更密眼睛和嘴巴的轮廓点更细但模型加载慢一点CPU 占用高一些。对比项dlib 68 点mediapipe 468 点关键点数量68468CPU 单帧耗时约 515ms约 1540ms安装难度中易编译失败低pip 直装眼睛/嘴巴精度够用更细适合场景低配、离线精度优先我的建议是毕业设计用 dlib因为 68 点好讲原理答辩时能画图说明 EAR 怎么算实际产品化倾向 mediapipe鲁棒性更好。下面代码以 dlib 为主线mediapipe 的替换点我会标出来。2.3 摄像头采集与帧率控制摄像头不是越清晰越好。1080p 30 帧听着爽但每帧都要做人脸检测CPU 直接拉满帧率掉到 10 以下时序判定就失真了。我一般把分辨率压到 640×480帧率锁 30够用且流畅。import cv2 # 打开默认摄像头0 是设备索引 cap cv2.VideoCapture(0) # 设置采集分辨率降低算力压力 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) # 目标帧率实际以硬件为准 cap.set(cv2.CAP_PROP_FPS, 30) if not cap.isOpened(): raise RuntimeError(摄像头打开失败检查设备索引或被其他程序占用) while True: ret, frame cap.read() if not ret: break # 水平翻转符合照镜子习惯避免左右颠倒 frame cv2.flip(frame, 1) cv2.imshow(frame, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()逻辑说明VideoCapture(0)打开设备set设分辨率和帧率flip做镜像。参数上CAP_PROP_FRAME_WIDTH/HEIGHT是请求值摄像头可能不支持读回来要校验。waitKey(1)里的 1 是毫秒太小 CPU 空转太大画面卡顿30 帧场景用 1 合适。失败时先看isOpened()再确认有没有别的程序占着摄像头——这是最常见的黑匣子式故障。3. 眼睛和嘴巴状态计算EAR 与 MAR 的公式和阈值3.1 EAR 眼睛纵横比公式拆解与实现EAREye Aspect Ratio是疲劳检测的基石。原理很直白睁眼时眼睛是圆的上下距离大闭眼时上下距离趋近于零。用眼睛六个关键点算垂直方向两组距离之和除以水平方向距离的两倍就得到一个跟人脸远近无关的比值。from scipy.spatial import distance as dist def eye_aspect_ratio(eye): # eye 是 6 个点的坐标列表顺序为左角、上左、上右、右角、下右、下左 # 垂直距离上左-下左上右-下右 A dist.euclidean(eye[1], eye[5]) B dist.euclidean(eye[2], eye[4]) # 水平距离左角-右角 C dist.euclidean(eye[0], eye[3]) # 防止除零C 极小说明关键点异常 if C 1e-6: return 0.0 ear (A B) / (2.0 * C) return ear逻辑说明A和B是两组上下眼睑距离C是眼角水平距离(AB)/(2C)就是 EAR。参数上睁眼时 EAR 通常在 0.250.35闭眼时掉到 0.15 以下。这个区间因人而异戴眼镜、眯眼、单眼皮都会偏移所以阈值不能一刀切。我一般先用 0.25 做初值再让使用者做一次睁眼-闭眼校准动态取中值。3.2 MAR 嘴巴纵横比打哈欠怎么判打哈欠和说话都会张嘴区别在持续时间和张开幅度。MARMouth Aspect Ratio用嘴巴上下距离除以左右宽度打哈欠时能到 0.6 以上正常说话大多在 0.3 以下。def mouth_aspect_ratio(mouth): # mouth 取内唇 8 个点顺序为左角、上唇三点、右角、下唇三点 # 垂直距离取三组比单组更稳 A dist.euclidean(mouth[1], mouth[7]) B dist.euclidean(mouth[2], mouth[6]) C dist.euclidean(mouth[3], mouth[5]) # 水平距离左右嘴角 D dist.euclidean(mouth[0], mouth[4]) if D 1e-6: return 0.0 mar (A B C) / (3.0 * D) return mar逻辑说明用三组垂直距离求平均比单组抗噪。参数上MAR 阈值我一般设 0.50.6且要求持续超过 1.5 秒才算哈欠否则说话、笑都会误触发。这里有个坑dlib 的 68 点里嘴巴外轮廓是 4859内轮廓是 6067算 MAR 要用内轮廓用外轮廓会把嘴唇厚度算进去数值偏大。3.3 阈值标定别用别人的数字网上教程给的 EAR 0.3、MAR 0.6 只能当起点。每个人的眼型、摄像头角度、光照都不同直接抄必然翻车。我的做法是加一个 5 秒校准阶段让使用者正常睁眼 3 秒记录 EAR 均值ear_open再闭眼 2 秒记录ear_closed阈值取两者中值。def calibrate(cap, detector, predictor, seconds5): ear_samples [] start cv2.getTickCount() freq cv2.getTickFrequency() while (cv2.getTickCount() - start) / freq seconds: ret, frame cap.read() if not ret: continue gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces detector(gray, 0) for face in faces: shape predictor(gray, face) left [(shape.part(i).x, shape.part(i).y) for i in range(36, 42)] right [(shape.part(i).x, shape.part(i).y) for i in range(42, 48)] ear (eye_aspect_ratio(left) eye_aspect_ratio(right)) / 2.0 ear_samples.append(ear) if not ear_samples: return 0.25 # 没采到脸退回默认值 ear_samples.sort() # 取中位数作为睁眼基准抗异常帧 return ear_samples[len(ear_samples) // 2] * 0.75逻辑说明采集一段时间内的 EAR排序取中位数再乘 0.75 作为闭眼阈值。乘 0.75 是经验值因为中位数代表睁眼状态闭眼大约是其 70%80%。参数上校准时长 5 秒够用太短样本不足太长用户不耐烦。失败时看ear_samples是否为空空说明没检测到人脸检查光照和摄像头位置。4. 时序判定与报警逻辑单帧判断为什么不可靠4.1 计数器与计时器两种疲劳模式单帧 EAR 低不代表疲劳正常眨眼也会瞬间变低。真正要抓的是两种模式一是连续闭眼超过 N 帧对应打瞌睡二是单位时间内闭眼频率过高对应频繁眨眼。前者用计数器后者用滑动窗口。from collections import deque # 连续闭眼帧数阈值30帧约1秒 EYE_CLOSED_FRAMES 20 # 滑动窗口记录最近若干帧的闭眼状态 window deque(maxlen90) # 哈欠持续计时 yawn_start None YAWN_DURATION 1.5 # 秒 def judge(ear, mar, fps30): global yawn_start alerts [] # 模式一连续闭眼 if ear EAR_THRESHOLD: window.append(1) else: window.append(0) # 统计窗口内闭眼比例 if len(window) window.maxlen: ratio sum(window) / window.maxlen if ratio 0.4: alerts.append(频繁眨眼注意休息) # 模式二哈欠 if mar MAR_THRESHOLD: if yawn_start is None: yawn_start cv2.getTickCount() else: elapsed (cv2.getTickCount() - yawn_start) / cv2.getTickFrequency() if elapsed YAWN_DURATION: alerts.append(检测到哈欠) yawn_start None else: yawn_start None return alerts逻辑说明window是长度 90 的滑动窗口约 3 秒闭眼比例超 0.4 判频繁眨眼。哈欠用起始时间戳持续超 1.5 秒才报。参数上EYE_CLOSED_FRAMES和窗口长度要按实际帧率换算帧率不稳时用时间而非帧数更准。这里有个细节deque满了自动丢最旧的不用手动维护省心。4.2 连续闭眼帧数怎么定1 秒还是 2 秒这个参数直接决定误报率。设太短眨眼就报设太长真睡着了才报失去意义。生理上正常眨眼 0.10.4 秒微睡眠 0.53 秒。我一般取 1 秒作为警告2 秒作为严重报警分两级。按 30 帧算就是 30 帧和 60 帧。如果帧率掉到 15同样的秒数要翻倍帧数所以代码里最好用时间戳而不是帧计数。# 用时间戳替代帧计数帧率波动时更稳 closed_start None WARN_SECONDS 1.0 ALARM_SECONDS 2.0 def time_based_judge(ear): global closed_start now cv2.getTickCount() / cv2.getTickFrequency() if ear EAR_THRESHOLD: if closed_start is None: closed_start now duration now - closed_start if duration ALARM_SECONDS: return 严重疲劳立即停车休息 elif duration WARN_SECONDS: return 疑似疲劳请集中注意力 else: closed_start None return None逻辑说明用getTickCount换算秒数闭眼开始记时间超过阈值分级报警。参数上WARN_SECONDS和ALARM_SECONDS按场景调高速场景可以更敏感。失败时看closed_start是否被正确重置——如果睁眼分支没重置会一直累加导致误报这是新手常犯的错。4.3 报警输出声音、画面、日志三件套报警不能只响一声要让人无法忽略。我的做法是三重输出画面变红加文字、蜂鸣器响、写日志。日志很重要事后能复盘是误报还是真疲劳。import time def alarm(frame, message, log_pathfatigue_log.txt): # 画面叠加红色警告 cv2.putText(frame, message, (30, 60), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 3) # 写日志带时间戳 with open(log_path, a, encodingutf-8) as f: f.write(f{time.strftime(%Y-%m-%d %H:%M:%S)} {message}\n) # 蜂鸣Windows 用 winsound跨平台可用 print(\a) print(\a) return frame逻辑说明putText在画面左上角画警告open追加写日志\a触发终端响铃。参数上字体大小 1.0、线宽 3 在 640×480 下清晰可见。日志路径建议带日期分文件否则长期跑会撑大。注意\a在部分终端不响产品化要接真实蜂鸣器或音频文件。5. 避坑与排查那些让我熬夜的误报5.1 戴眼镜反光导致关键点漂移现象戴眼镜的用户 EAR 忽高忽低明明睁着眼却报疲劳。原因是镜片反光让 dlib 关键点定位偏移眼睛轮廓点跑到镜框上。解决一是加偏振片或调整光源角度减少正面直射二是对关键点做平滑用最近 5 帧的均值替代单帧值。from collections import deque ear_history deque(maxlen5) def smooth_ear(ear): ear_history.append(ear) return sum(ear_history) / len(ear_history)平滑窗口 5 帧是折中太大延迟高太小没效果。如果反光严重直接换 mediapipe它对眼镜的鲁棒性明显更好。5.2 光照突变让阈值失效现象进隧道或对面远光灯一照EAR 整体偏移误报或漏报。原因是灰度图对比度剧变关键点定位不稳。解决加直方图均衡化或者用自适应阈值。更稳的做法是检测画面亮度突变时暂停判定 1 秒等稳定再恢复。def is_lighting_stable(frame, prev_mean, threshold30): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) mean gray.mean() if prev_mean is not None and abs(mean - prev_mean) threshold: return False, mean return True, mean亮度均值变化超 30 就判为突变。这个阈值按场景调室内可以小一点室外大一点。5.3 多人入镜时检测错人现象副驾或后排乘客的脸被当成司机判定全乱。原因是没做身份绑定。解决只取画面中面积最大的人脸或者用位置约束——司机脸通常在画面中下部。更严谨的做法是加人脸跟踪锁定第一帧检测到的主脸。def pick_driver_face(faces): if not faces: return None # 取面积最大的脸假设司机离摄像头最近 return max(faces, keylambda r: r.width() * r.height())面积最大是简化假设实际可能翻车比如乘客凑近。产品化要加跟踪算法比如质心跟踪或 KCF。5.4 帧率不稳导致计时失真现象用帧计数判疲劳电脑一卡30 帧变 10 帧1 秒的阈值实际变成 3 秒。原因是帧计数和时间不成正比。解决全部改用时间戳前面 4.2 已经给了代码。另外可以动态测帧率低于 15 就降分辨率或跳帧检测。5.5 阈值写死导致换人失效现象A 用着好好的B 一坐上去就狂报。原因是 EAR 基准因人而异。解决每次启动做校准或者维护一个用户配置文件按人加载阈值。校准代码在 3.3 已经给了实际用的时候把结果存成 json下次直接读。6. 从能跑到好用几个让方案更稳的进阶技巧把基础版跑通只是起点真正决定这套疲劳驾驶检测能不能拿得出手的是细节打磨。我踩过一圈之后总结出三个最值钱的改进方向。第一个是多特征融合。单看 EAR 容易被眼镜和光照骗单看 MAR 容易被说话骗。把 EAR、MAR、头部姿态低头、偏头三个特征加权投票误报率能降一大截。头部姿态可以用 solvePnP 从关键点估算低头超过 20 度且持续 2 秒就算不看眼睛也能判疲劳。权重我一般设 EAR 0.5、MAR 0.3、姿态 0.2按场景微调。第二个是自适应阈值。与其每次手动校准不如让系统在线学习用最近 60 秒的 EAR 分布取 20 分位数作为动态闭眼阈值。这样用户从亮处走到暗处、从精神到困倦阈值跟着变不用人工干预。import numpy as np from collections import deque ear_buffer deque(maxlen1800) # 约60秒30fps def adaptive_threshold(ear): ear_buffer.append(ear) if len(ear_buffer) 300: # 样本不足用默认 return 0.25 arr np.array(ear_buffer) # 20分位数作为闭眼阈值随状态漂移 return float(np.percentile(arr, 20))逻辑说明缓存最近 60 秒 EAR取 20 分位数。参数上窗口 1800 帧对应 30 帧 60 秒样本不足 300 帧时退回默认。这个方法的边界是如果用户长时间闭眼分布会整体下移阈值跟着降可能漏报所以要和固定阈值取较小值兜底。第三个是日志回放与验证。报警到底准不准光靠感觉不行。我习惯把每次报警前后 5 秒的 EAR、MAR 序列存下来事后画曲线看。真疲劳的曲线是 EAR 持续低位误报往往是单帧毛刺。用 matplotlib 画出来一眼就能分辨。import matplotlib.pyplot as plt def plot_log(ear_list, mar_list, save_pathdebug.png): plt.figure(figsize(12, 4)) plt.plot(ear_list, labelEAR) plt.plot(mar_list, labelMAR) plt.axhline(y0.25, colorr, linestyle--, labelEAR threshold) plt.legend() plt.savefig(save_path) plt.close()验证方法上我一般录几段已知状态的视频——正常驾驶、打哈欠、闭眼——跑一遍看报警是否对得上。准确率、误报率、漏报率三个指标都记下来改参数时对比。这套流程走下来方案才算从能跑变成敢用。最后说个我自己的习惯任何阈值改动都先在录好的视频上回放验证再上真摄像头。真摄像头调试成本高一次误报可能让用户直接放弃。录视频回放是后悔药能省下大量反复。希望帮到你。本文还有配套的精品资源点击获取