简介:本资源是一套基于深度学习的智能疲劳驾驶检测系统完整源码实现,面向计算机视觉初学者、智能交通方向研究者及Python深度学习实践者,旨在解决真实场景下驾驶员疲劳状态实时识别与预警这一关键安全问题。压缩包共84个文件,总计105.27MB,涵盖34个Python源码(含数据预处理、MediaPipe面部关键点提取、眼动特征建模、YOLO/ONNX模型推理等核心模块)、11个文本配置与说明文件(含readme、参数配置、日志记录)、7个JPG/JPEG图像样本、4个XML与4个PNG/WEBP界面资源,以及.pt模型权重和.gitignore等工程必需文件。已有105人学习下载,资源结构清晰,按「基础知识→基础功能→疲劳检测→语音响应→系统界面」分层组织,内置OpenCV、NumPy、MediaPipe等实战环境依赖说明,并提供可直接运行的端到端检测脚本与可视化界面,便于快速复现、调试与二次开发。 我盯着刚跑通的检测画面看了十分钟,心里其实没底。程序里那个“疲劳预警”的红色框倒是跳得挺欢,但仔细一看,它只是在驾驶员打哈欠的时候弹了个提示,遇到正常眯眼、眨眼、低头看导航这些动作,要么漏报,要么误报。这就是大多数疲劳驾驶检测项目的真实状态——人脸检测做得很热闹,真正的“疲劳判定”却没接上。
后来我重新梳理了这个“基于深度学习的智能疲劳驾驶检测系统设计源码”项目,把整个链路拆开重做,才把系统从“能识别脸”推进到“能判断疲劳”。这套系统做下来,其实核心就三件事:人脸在哪、眼睛和嘴巴什么状态、这些状态在时间上怎么累积成疲劳结论。本文会把这套系统的完整设计思路、源码模块、训练过程和踩坑记录都摊开讲,适合正在做毕业设计、课程设计,或者在研究DMS(驾驶员监控系统)的同学参考。
1. 疲劳驾驶检测这件事,技术选型为什么绕不开深度学习
1.1 传统视觉方案的三个死穴
很多人刚接触这个题目时,第一反应是用传统图像处理来解:肤色分割找脸、灰度投影找眼睛、模板匹配判断睁眼闭眼。这思路听起来直接,实际放到驾驶舱里几乎不可用。
先说肤色分割。车内光照是出了名的恶劣,晴天侧光、隧道暗光、夜间仪表盘反光、中控屏幕补光,肤色在RGB空间里的分布会剧烈漂移。你调一套HSV阈值,早晚隧道里能把座椅皮套当成脸。
第二个死穴是灰度投影找眼睛。它假设眼睛在面部图像里是一块明显暗区,但驾驶员戴墨镜、戴普通眼镜反光、刘海遮住眉毛、口罩拉高遮住鼻梁,这些情况都会让投影曲线完全失效。我在测试中遇到最多的情况就是眼镜框边缘的阴影被当成眼睛,导致后期EAR(眼睛纵横比)计算偏差巨大。
第三个问题更根本:疲劳状态不是一个静态特征,而是眼睛开合、嘴巴张合、头部姿态随时间变化的过程。传统方法每一帧都独立判断,帧间抖动大、时序信息完全丢失,根本撑不起“持续闭眼2秒判定微睡眠”这类逻辑。
1.2 深度学习方案的设计边界
用深度学习不是因为它“新”,而是因为它把特征提取和状态分类这两件最不稳的事,变成了数据驱动的问题。但深度学习也不是万能药,设计这套系统时必须先定清楚边界。
我画定的系统边界是:单目摄像头采集驾驶员面部图像,检测人脸区域、定位眼睛和嘴巴关键区域、分类眼睛开合状态、统计时间维度上的疲劳指标、触发分级告警。这个边界意味着,系统不需要判断车辆是否偏移车道,不需要检测方向盘握持状态,只聚焦“驾驶员生理疲劳特征”。
在这个边界下,系统的技术栈分成三层:
- 感知层:人脸检测和人脸关键点定位
- 状态层:基于关键点或ROI图像判断眼睛闭合、嘴巴张开、头部姿态
- 决策层:用PERCLOS、持续闭眼时长、打哈欠频率做时序累积,输出疲劳等级
这个三层划分非常重要,它决定了源码的模块边界,也让后期调试能快速定位问题。比如误报率高,先看是状态层分类错了,还是决策层阈值设置不合理,而不是在几百行代码里大海捞针。
2. 从驾驶室图像到疲劳判定:核心算法链路拆解
2.1 人脸检测与关键点定位:为什么我放弃了dlib
疲劳检测的第一步是找到人脸和面部关键区域。网上大量教程用dlib的68关键点模型,我一开始也这么干,但很快发现它在真实驾驶场景下有几个硬伤。
dlib官方的shape_predictor_68_face_landmarks模型是基于老数据集训练的,对正面、光照均匀、无遮挡的人脸表现尚可。但驾驶场景里,驾驶员经常侧头看后视镜、低头看中控屏、抬手扶方向盘遮挡面部,关键点会频繁抖动甚至丢失。而且dlib模型在ARM平台和部分低端CPU上推理速度一般,没有GPU时很难保证实时性。
我最终采用了两条腿走路的方案:先用轻量级人脸检测器(MediaPipe Face Detection / BlazeFace)定位人脸框,再用MediaPipe Face Mesh输出人脸关键点。Face Mesh会给出468个关键点,其中眼睛轮廓有16个点,嘴巴区域有20多个点,比dlib的68点模型更适合计算精细的开合度。
这里有个容易被忽略的坑:Face Mesh本身是个全脸网格回归模型,它的输出是归一化的坐标,需要配合图像尺寸还原成像素坐标。很多人直接把归一化坐标当像素用,导致ROI裁剪位置飘得厉害。
2.2 眼睛开合度EAR的计算逻辑
眼睛状态判断有两种主流做法,一种是计算EAR(Eye Aspect Ratio),一种是把眼睛区域裁出来丢给CNN分类。我最终在系统里同时保留了这两种,但主力用的是CNN分类,EAR作为辅助校验。
先讲EAR,因为它背后的原理很有意思。EAR的计算公式是基于眼睛轮廓关键点的几何关系:
在一个眼睛的轮廓关键点中,取左右眼角点P1、P4,上眼睑两个点P2、P3,下眼睑两个点P5、P6,则:
EAR = (||P2 - P6|| + ||P3 - P5||) / (2 * ||P1 - P4||)
这个比例在睁眼时相对稳定(一般在0.25到0.35之间),闭眼时因为垂直距离急剧缩小,EAR会掉到0.1以下。它用比值代替绝对像素距离,好处是对人脸尺度不敏感,不管摄像头是靠近还是拉远,睁眼时的EAR都落在差不多的区间。
但EAR有个天生缺陷:它极度依赖关键点的精度。只要眼镜框遮挡、极端侧脸、眼睛区域光线过暗导致关键点轻微偏移,EAR就会出现周期性跳动。我测试中见过最离谱的情况是,戴黑框眼镜的测试者正常睁眼时EAR被算到0.08,直接触发闭眼判定。
所以我在源码里把CNN眼睛状态分类器作为第一判定,EAR作为兜底逻辑。这个设计在后面“踩坑”部分还会细说。
2.3 疲劳判定指标:PERCLOS、持续闭眼、打哈欠频率
单帧的眼睛状态不足以判定疲劳,因为正常眨眼本身就是一个闭合过程,闭眼持续100到400毫秒完全正常。疲劳判定必须在时间轴上做统计。
系统里用了三个指标:
第一个是PERCLOS,这是疲劳驾驶研究领域最经典的指标。它的定义是单位时间内眼睛闭合程度超过80%的时间占比。P80标准下,通常认为PERCLOS超过0.4就属于疲劳状态。实现时我用一个滑动窗口,统计窗口内闭眼帧数占总帧数的比例。
第二个是持续闭眼时长。这是微睡眠事件的核心特征。正常眨眼再怎么慢也不会超过半秒,如果检测到连续闭眼超过1.5秒,基本可以判定微睡眠。这里用连续帧计数实现,假设摄像头帧率是30FPS,那么45帧连续闭眼就应该触发告警。
第三个是打哈欠频率。哈欠的检测逻辑和眼睛类似,通过嘴巴开合度(MAR,Mouth Aspect Ratio)判断张嘴状态,然后统计单位时间内的哈欠次数。连续5分钟内出现3次以上哈欠,配合PERCLOS指标,疲劳概率就很高了。
这三个指标不是简单叠加,在源码里我用了分级告警状态机:单指标超标给一级提醒,两个指标同时超标给二级警告,持续闭眼直接触发三级强制休息告警。分级处理的好处是降低误报对驾驶员的干扰——没人希望偶尔打个哈欠就被疯狂报警。
3. 源码工程拆解:模块划分与核心实现
3.1 工程目录与推理引擎选择
这套系统的源码目录结构如下:
fatigue_detection/ ├── config.yaml # 全局配置(阈值、路径、参数) ├── requirements.txt ├── data/ │ ├── raw/ # 原始采集数据 │ ├── processed/ # 预处理后的ROI数据集 │ └── models/ # 权重文件存放 ├── src/ │ ├── detector/ │ │ ├── face_detector.py # 人脸检测封装 │ │ ├── eye_classifier.py # 眼睛状态分类 │ │ └── mouth_classifier.py # 嘴巴状态分类 │ ├── evaluator/ │ │ └── fatigue_evaluator.py # 时序疲劳判定 │ ├── utils/ │ │ ├── roi_extractor.py # 关键点转ROI │ │ └── visualization.py # 画面绘制 │ ├── train.py # 训练入口 │ ├── export_onnx.py # 模型导出 │ └── detect.py # 实时检测入口 └── tests/ └── test_fatigue_logic.py # 单元测试设计时有一个关键决策:推理引擎选ONNX Runtime,而不是直接调PyTorch。原因有三个。第一,系统最终要跑在车内嵌入式设备上,ONNX Runtime的部署生态更成熟,可以无缝切到TensorRT或OpenVINO。第二,ONNX模型是静态计算图,推理速度快且更稳定。第三,pyTorch推理需要依赖完整的torch库和模型定义代码,部署时体积大、版本兼容麻烦,而ONNX Runtime只是个轻量推理库。
3.2 眼睛状态分类器的实现细节
眼睛状态分类器是整个系统的核心。它接收一个裁剪后的眼睛ROI图像,输出“睁眼”和“闭眼”两个类别的置信度。模型结构用的是MobileNetV3-Small,输入尺寸是64x64的单通道灰度图。
为什么用64x64这么小的输入?因为眼睛ROI本身像素信息就不多,输入太大只会增加计算量,不会带来精度收益。我用MobileNetV3实测下来,单张眼睛ROI的推理耗时在CPU上约5到8毫秒,GPU上则可以忽略不计。
关键实现细节在ROI提取而不是分类网络。眼睛区域的裁剪直接用Face Mesh的关键点坐标,取上眼皮最高点和下眼皮最低点,向外扩展20%的padding。这个padding非常重要,因为直接紧贴眼睑裁剪会把上下眼皮之外的皮肤也框进去,干扰分类器;padding太小又可能因关键点抖动导致内容不全。
ROI提取后进行两步预处理:
- 灰度化后用自适应直方图均衡化(CLAHE),解决光线不均的问题
- 缩放到64x64并做归一化
这里有个细节:分类器训练时的数据分布和实时推理时的数据分布可能不一致。我在源码里加入了简单的在线校准逻辑——持续统计当前视频流中眼睛ROI的亮度直方图,如果与训练集统计值偏移过大,就调整CLAHE的clipLimit参数。这个机制在后来的夜间测试中帮了大忙。
3.3 疲劳判定与告警模块:状态机与滑动窗口
疲劳判定模块我实现为一个有状态的类,叫FatigueEvaluator。它内部维护一个环形缓冲区,存储最近150帧的眼睛和嘴巴状态结果,然后在这个滑动窗口上计算疲劳指标。
核心逻辑如下:
class FatigueEvaluator: def __init__(self, config): self.window_size = config["window_size"] # 默认150帧 self.eye_buffer = deque(maxlen=self.window_size) self.mouth_buffer = deque(maxlen=self.window_size) self.alert_level = 0 def update(self, eye_state, mouth_state): self.eye_buffer.append(eye_state) self.mouth_buffer.append(mouth_state) # 计算PERCLOS closed_frames = self.eye_buffer.count("closed") perc_los = closed_frames / len(self.eye_buffer) # 计算持续闭眼 continuous_closed = self._get_continuous_closed_frames() # 计算哈欠频率 yawn_count = self._count_yawns() self.alert_level = self._decide_level(perc_los, continuous_closed, yawn_count) return self.alert_level判定逻辑最需要注意的,是连续闭眼帧数的计算方式。如果简单地从头到尾遍历缓冲区的闭眼帧,无法区分“一次持续闭眼”和“多次短暂闭眼”。正确做法是反向遍历,数出从最新一帧开始连续闭眼的帧数,一旦遇到睁眼帧就停止。这样得到的数字才是真正的持续闭眼时长。
告警输出方面,系统支持声音告警和界面告警。声音用winsound或蜂鸣器,根据告警等级播放不同频率的提示音。界面告警是在视频画面上绘制人脸框和状态标签,同时在侧边栏绘制PERCLOS的实时曲线,方便调试时观察。
4. 模型训练与数据准备:多数人不会细抠的环节
4.1 数据集选择:公开数据集和自己采集怎么配合
眼睛状态分类器的训练数据,我用了三个来源的混合:
- CEW(Closed Eyes in the Wild)数据集,包含大量闭眼人脸图像
- MRL Eye Dataset,包含不同光照和头部姿态下的眼睛图像
- 自己用摄像头采集的驾驶员模拟场景数据
混合数据集有个坑:不同数据集的图像尺寸、光照条件、标注口径差异很大。MRL的眼睛图像是稳定的正视视角,而自采数据包含大量侧脸和俯仰角变化。直接混合训练会导致模型在某个数据集上表现好,在另一个上明显变差。
我的处理方式是分阶段训练:先用MRL和CEW做预训练,让模型学到眼睛开合的基础特征,再用自采数据做微调,让模型适配驾驶舱的实际视角和光照。微调阶段把学习率降为预训练的十分之一,避免破坏已经学到的通用特征。
数据增强我们用了随机水平翻转、亮度扰动、高斯噪声、随机遮挡。其中随机遮挡特别有用,它的思路是模拟眼镜框、方向盘、手指对眼睛部分的遮挡。实测发现,加入遮挡增强后,模型在戴眼镜人群上的准确率提升了约6个百分点。
4.2 训练过程与调参记录
眼睛分类器的训练参数如下表:
| 参数 | 值 | 说明 |
|---|---|---|
| 输入尺寸 | 64x64 | 灰度图 |
| 网络结构 | MobileNetV3-Small | 分类头改为2类 |
| 优化器 | AdamW | 权重衰减1e-4 |
| 初始学习率 | 0.001 | 余弦退火调度 |
| Batch Size | 128 | 单卡 |
| Epochs | 40 | 微调阶段只用15轮 |
| 损失函数 | CrossEntropyLoss | 带类别权重 |
训练时要注意类别不平衡。睁眼样本数量远多于闭眼样本,如果不处理,模型会把所有输入都判为睁眼,准确率还能虚高到90%以上。我在损失函数里给闭眼类别加了1.5倍的权重,同时训练时做了简单的过采样,让每个batch中睁闭眼样本比例接近1:1。
最终验证集准确率在98.7%左右,但这里我要提醒一句,验证集不能只从公开数据里抽。我用自采数据单独做了留出集,这个留出集上的准确率只有95.4%。差出来的3个百分点就是数据分布差异造成的,项目中如果只看公开数据验证集,很容易高估模型真实表现。
4.3 模型导出与量化:从PyTorch到ONNX的坑
训练完成后,模型导出成ONNX格式。导出命令本身很简单:
python export_onnx.py --weights best.pt --output eye_classifier.onnx但有一个常规文档里不会提的坑:PyTorch模型默认使用动态shape,而ONNX Runtime在动态shape下推理可能触发额外的内存分配和性能损耗。我在导出时把输入尺寸固定为1x1x64x64,用opset_version=11,并在导出参数里设置dynamic_axes为空,确保ONNX模型是静态shape。
量化部分,我用ONNX Runtime的整数量化工具把模型从FP32压到INT8。量化结果让我有点意外:眼睛分类器从FP32到INT8,准确率只掉了0.8个百分点,但推理速度提升了近3倍。原因是MobileNetV3里的激活函数和卷积对量化不敏感,这个网络结构很适合低精度推理。但要注意,量化校准数据集必须用真实场景的图像,不能用训练集,否则量化后的激活值分布会偏移。
5. 实时检测、界面与告警联动:从算法到可用的系统
5.1 视频流接入与帧率控制
检测系统的输入来自摄像头,OpenCV的VideoCapture是常规选择。但直接循环读帧+推理有个问题:摄像头帧率可能不稳定,某个瞬间帧率突然降低会导致疲劳判定窗口的时间跨度失真。
我的处理方案是生产消费者模式。主线程用VideoCapture持续读帧,写入一个大小为4的队列;推理线程从队列取帧进行检测。这样摄像头读取和模型推理解耦,队列满了就丢弃旧帧,保证推理始终处理最新画面。这个方案下,检测延迟大概增加一帧,但帧率稳定性好了很多。
帧率控制还有一个细节:疲劳判定依赖时间窗口,不同帧率下的窗口长度必须换算成帧数。配置文件里我设置了fps=30,滑动窗口150帧即5秒。如果实际检测帧率掉到20FPS,同样150帧窗口的实际时间就变成了7.5秒,PERCLOS指标的判定口径就变了。所以FatigueEvaluator会动态读取当前帧率,自适应用帧数除以实际帧率换算成秒。
5.2 告警输出:声音、界面与日志的联动设计
告警模块的架构比较直接:状态输出接口返回当前告警等级,界面层负责显示,音频层负责发声,日志层负责持久化。
界面我用OpenCV的绘图函数实现,不引入额外的GUI框架。画面左上角显示实时PERCLOS值和当前疲劳等级,人脸框根据等级变色:绿色正常、黄色一级提醒、橙色二级警告、红色三级强制休息。侧边栏画一个PERCLOS历史曲线,方便观察趋势。
音频告警的实现要考虑一个实际问题:疲劳驾驶场景下告警声音如果太温和,驾驶员根本没感觉。我用winsound.Beep生成了两种声音:一级提醒用800Hz、持续0.3秒;二级警告用1200Hz、持续0.6秒且重复三次;三级强制休息用高低频交替的脉冲音,频率和强度都明显区别于前两级。实测中,三级告警音在车辆行驶噪音环境下仍然有辨识度。
所有告警事件都会写入日志文件,记录时间、告警等级、当前的PERCLOS值、持续闭眼时长。日志格式是JSON Lines,方便后续做数据分析。
5.3 一次完整的夜间低光实测记录
系统完成后我做了多轮实车模拟测试,最有参考价值的是夜间低光场景。测试环境是地下车库,车内只有仪表盘灯光,光照强度远低于正常白天驾驶。
第一次测试结果很惨,模型频繁把闭眼判成睁眼,PERCLOS指标完全失真。排查后发现问题出在ROI预处理上:地下车库的环境光偏黄且暗,眼睛区域对比度过低,CLAHE在低对比度下反而放大了噪声。
我调整了两处参数:CLAHE的clipLimit从2.0降低到1.5,并把网格大小从8x8改成4x4;同时抬高眼睛ROI的亮度下限,像素值低于设定值的区域先做线性拉伸再做直方图均衡化。调整后夜间场景的检测准确率恢复到了白天测试的九成水平。
这个测试还暴露了一个重要问题:单摄像头方案在极端低光下的可靠性始终有限,夜间的终极解决方案是近红外摄像头加红外补光。但如果项目阶段没有红外硬件,图像预处理参数的本地自适应是退而求其次的有效手段。
6. 实测中的三个大坑与完整排查链路
6.1 戴眼镜的驾驶员被频繁误报
这个坑出现过很多次,值得单独立一节。现象的完整描述是:测试者戴上黑框眼镜后,系统在没有疲劳动作的情况下频繁触发一级提醒,偶发二级警告。摘下眼镜后一切恢复正常。
我的排查链路是这样展开的。第一步,在界面上叠加显示眼睛关键点和EAR数值,观察数值波动。结果发现EAR在0.15到0.08之间反复横跳,明显低于裸眼时的0.3左右。这个现象表明要么是关线点定位出错,要么是ROI内容本身被污染。
第二步,把每帧的ROI图像持久化到本地,逐帧检查裁剪结果。发现一个规律:当眼镜框下边缘恰好位于眼睛上方约10个像素的位置时,关键点定位会把眼镜框下边缘当成上眼睑,导致眼睛的ROI中混入大片镜框阴影。
第三步,检查CNN分类器的输出。用ROC分析发现,模型在“带镜框阴影的睁眼图”上的置信度在0.4到0.6之间徘徊,属于典型的分类边界样本。
最终解决是组合拳:一是ROI裁剪时把上眼睑区域向上扩展再缩小,尽量规避镜框边缘;二是在数据增强里加入“随机黑色横向条带”模拟镜框阴影;三是决策层不再单独依赖分类器输出,而是结合EAR做二次校验,如果EAR异常偏低但分类器置信度也偏低,则判定为“可疑但不告警”,等待后续帧确认。
6.2 驾驶员低头时人脸检测跟丢
另一个高频问题:测试者低头看手机或中控屏时,人脸平面旋转角度超过40度,MediaPipe的人脸检测器直接丢失目标,导致所有指标中断。
这个问题的本质是人脸检测器的召回率在极端姿态下不足。我的排查过程是先在离线视频上统计不同俯仰角下的人脸检测召回率。用Face Mesh的头部姿态估计输出pitch角,从0度到60度均匀划分区间,统计每个区间的检出率。结果发现pitch角超过35度后,检出率从95%以上掉到70%以下。
针对这个问题我做了三个改进。第一,在感知层前面加一个运动检测器,如果上一帧有脸、当前帧没脸,且画面中心区域存在明显运动区域,就判断为“可能低头了”,此时不立即清空疲劳统计窗口,而是保留最近30帧的数据。第二,把关键点缓冲区改成tracking机制,即使检测器丢掉目标,也用光流法预测关键点位置,最多延续15帧。第三,如果持续跟丢超过2秒,系统不做疲劳判定,而是输出“检测中断”状态,避免在目标已丢失的情况下继续累积PERCLOS造成误报。
6.3 模型在真实车内光照下性能暴跌
第三个大坑是模型在公开数据集上表现得很好,一上车就崩。这个问题在深度学习项目里太常见了,根源是训练数据和推理数据的分布不一致。
具体到我的项目,训练数据主要是MRL和CEW里的网络图片,这些图片以实验室环境、正脸、均匀光照为主。而车内真实环境有复杂的色温变化、车窗外的动态背景、仪表盘和中控屏的补光,甚至还有驾驶员佩戴帽子造成的头部阴影。
解决思路分三层。第一层是数据层面,扩大自采数据的比例,覆盖清晨、中午、傍晚、夜间、地下车库五种光照场景。第二层是图像预处理层面,引入多尺度的光照归一化——不只用CLAHE,而是同时计算全局亮度统计量和局部对比度统计量,做自适应调整。第三层是推理层面,在输出端增加时间平滑滤波,避免光照突变导致单帧结果剧烈抖动。
我个人的体会是,第三层往往被忽视,但它对系统稳定性的提升非常明显。用一个带截止频率的时间低通滤波器处理分类置信度序列,置信度的抖动幅度能从原来的一地鸡毛变得相对平稳,误报率能下降一半以上。
如果让我重新做一遍这个项目,我最想调整的是数据采集这一步。现在回想起来,当时硬啃公开数据集花费的时间,如果花在真实驾驶舱环境的数据采集上,收益会大得多。另外分享一个小技巧:疲劳判定逻辑千万别用单帧硬判断,把窗口拉长到3到5秒做统计投票,系统稳定性会有一个质的提升。这套系统跑通之后,再往工程化方向走,无非是换更强的检测器、做模型剪枝、接实车CAN总线,但核心的算法链路和判定逻辑基本不会变了。
本文还有配套的精品资源,点击获取