疲劳检测这个方向,我前前后后折腾了大半年,踩了不少坑,也积累了一些实实在在的经验。很多初学者一上来就奔着深度学习模型去了,上来就训一个啥都能干的端到端网络,结果数据不够、场景复杂,最后效果反而不稳定。这个项目其实更适合用“拆解”的思路去做:先把“疲劳”这个抽象概念拆成眼睛闭合频率、嘴巴张合、头部姿态这些可量化的信号,再逐个击破,最后融合判断。这篇就完整复盘一下这套实验方案,从数据准备到模型训练再到部署优化,把关键细节和踩过的坑都写清楚。
1. 项目思路与方案选型
1.1 为什么放弃端到端方案
最早我确实尝试过直接用视频帧训练一个三维卷积网络,把“疲劳/清醒”当二分类做。想法很直接,但实际操作下来问题一堆:首先是数据量根本不够,视频样本动辄几万帧,标注疲劳程度本来就是件主观且耗时的事,几轮标注下来人先疲劳了;其次是模型解释性太差,它到底在看眼睛还是看嘴还是看姿势,完全是个黑盒,出了问题根本没法排查。
当时我拿少量样本训了一版,测试集准确率看着还行,大概85%左右,但拿到真实场景一测,误报率直接起飞。后来冷静分析了一下,疲劳检测这个任务和通用图像分类不太一样,它强依赖时序上下文和局部细节。打个比方,一个人靠在椅背上闭眼两秒钟,和一个人正常眨眼两帧,在单帧图像上差距很小,但放到时序里就完全不同。端到端网络要把这种时序敏感性学出来,需要的数据量和训练成本远超项目承受范围,所以果断放弃。
1.2 核心指标怎么选
回到物理世界的可观测信号,疲劳的典型表现无非这么几类:眼皮下垂、眨眼频率下降或闭眼时间变长、打哈欠、头部无意识下垂。对应到计算机视觉里,就是人脸关键点检测加几何特征计算。
最经典的血缘特征有两个:一个是眼睛纵横比(EAR),用来描述眼睛张开程度;另一个是嘴部纵横比(MAR),用来描述嘴巴张合程度。前者配合闭眼时长阈值,可以算出单位时间内的闭眼比例(PERCLOS),这是驾驶疲劳领域用得最广的指标。后者则用来识别打哈欠,作为辅助判据。
还有一类信号是头部姿态,当人开始点头打瞌睡时,头部俯仰角会发生规律性变化。但我不建议一开始就把姿态也加进来,变量越多,调参越痛苦。第一版先做眼部和嘴部,跑通了再叠加姿态,迭代节奏会舒服很多。我这套流程走下来最终的方案是:EAR和PERCLOS为主判据,MAR为辅助判据,头部位姿作兜底。
1.3 整体流程框架
整个实验流程其实可以分成五段:人脸检测、关键点检测、几何特征计算、时序窗口判定、输出告警。人脸检测负责在画面里把人框出来,关键点检测负责锁定眼睛和嘴部的位置,几何特征计算把关键点坐标转换成有物理含义的数值,时序窗口判定负责在时间维度上平滑和累积,最后根据规则输出对应的疲劳等级。
这五段划分清楚之后,每一段都能独立实验、独立调优。比如人脸检测掉了帧,是换模型还是换输入分辨率;关键点抖动严重,是加滤波还是调回帧率;判定阈值不合适,是调整秒数还是调整百分比。这种模块化的思路,后期排查问题的效率会高很多。
2. 数据准备与实验环境
2.1 数据集怎么搭
疲劳检测实验的数据来源通常分两块:开源数据集和自己采集。开源数据集专门带疲劳标注的其实数量有限,当时我主要用了几个公开的人脸关键点数据集做预训练,再结合自己录制的疲劳模拟视频做微调。关键点数据集的标注格式得统一起来,68点、106点、人脸框坐标这些字段要提前对齐,不然送进模型之前还得写一堆转换脚本。
自己采集数据时,有两个问题特别容易被忽略。第一个是光照,白天窗前、黑夜灯光下、手机补光灯前,肤色和阴影完全不一样,模型很挑光照。第二个是遮挡,戴眼镜的、戴口罩的、有刘海儿的,这些都得覆盖到。我自己的做法是,找实验室几个同学帮忙,在上午、中午、傍晚、夜间四个时间段分别录制,每人录3到5段,每段5分钟左右的行为视频,涵盖正常状态、频繁眨眼、闭眼停留、打哈欠、点头犯困这几类动作。
录制的时候不需要刻意摆拍太久,真实状态下的自然动作最好。关键点标注工作可以借助半自动工具,先跑一版检测结果,再人工修正严重偏移的帧,比纯手动逐帧标注效率高不少。最终我整理出大概一万两千张图像用于微调,其中约三成带有不同程度的遮挡,保证模型在复杂场景下不至于崩掉。
2.2 预处理和数据增强
预处理里最重要的步骤就是对齐人脸。检测出人脸框之后,先按两眼中心点做旋转校正,把脸摆正,再缩放到固定尺寸输入关键点模型。这一步如果不做,同一个人的同一动作在画面不同位置时,模型输出的关键点会漂移得很厉害,后续算EAR的时候数值噪声会非常大。
数据增强我分了三个梯队:几何变换(小幅旋转、平移、缩放)、光度扰动(亮度、对比度、高斯噪声)、遮挡模拟(随机的矩形遮罩和黑色圆斑)。这里有个经验,遮挡模拟不要做得太狠,遮掉眼睛区域会让模型学到“看不见眼睛就认为疲劳”,反而把判定规则带偏了。增强幅度要适可而止,以不破坏关键点语义为准。
2.3 实验环境配置
计算资源方面,我当时用的是一块消费级显卡,显存8GB,数据集规模不算大,训练一个轻量关键点模型一次大概40分钟。推理阶段的环境则直接放到了CPU上,因为这类检测最终要跑在边缘设备上,不能依赖GPU。所以从模型选型开始,我就刻意控制了参数量,避免后期部署时发现速度不达标再返工。
软件栈相对常规,Python、OpenCV、PyTorch这三件套就够了。人脸检测部分可以用现成的轻量检测器,关键点模型是自己改造的,后面会详细说结构。推理时考虑到性能,我后来把模型转换成了ONNX格式,用OpenCV的DNN模块来跑,省去了PyTorch的运行开销。
3. 模型训练与指标计算
3.1 关键点检测模型结构
关键点模型我没有用太重的结构,参考了移动端轻量网络的思路,主干用深度可分离卷积堆叠,最后接一个全连接层直接回归所有关键点的坐标。输入图像是64x64像素,输出68个点的x、y坐标,一共136个值。选择64x64的分辨率是经过权衡的:再大一点精度能提一些,但推理时间明显变长;再小一点关键点定位偏差就会影响后续EAR的稳定性。
训练损失函数用的就是坐标点的均方误差,但这里有个细节需要注意,眼睛区域的关键点坐标数值很小,嘴巴和下巴区域数值很大,如果直接用原始坐标做MSE损失,网络会把优化重点放在大幅度值的关键点上,眼睛区域的误差反而容易被忽略。我当时的做法是对每个点的误差按权重加权,眼睛轮廓的8个点权重设为1.5,眉毛和嘴部设为1.2,其他点设为1.0。这个改动不用增加任何计算量,但对眼睛状态判定的精度提升非常明显。
训练过程采用了两阶段策略:先在公开关键点数据集上预训练到收敛,再用自采数据微调。预训练阶段前5个epoch的学习率是3e-3,之后衰减到1e-3,微调阶段直接降到5e-4。优化器用的Adam,批量大小64,总共训练了40个epoch左右。监控指标除了验证集上的坐标误差,我还额外打印了眼睛区域点的平均误差,这个更能反映疲劳检测关心的核心区域是否够准。
3.2 疲劳指标的计算逻辑
拿到68个关键点之后,下一步就是计算EAR和MAR。以右眼为例,取六个点:眼头、眼角、上眼皮两个点、下眼皮两个点。EAR的定义是纵向距离的均值除以横向距离:
def eye_aspect_ratio(eye_points): # eye_points: 6个关键点的坐标 vertical_1 = dist(eye_points[1], eye_points[5]) vertical_2 = dist(eye_points[2], eye_points[4]) horizontal = dist(eye_points[0], eye_points[3]) return (vertical_1 + vertical_2) / (2.0 * horizontal)正常情况下,人的EAR值大约在0.25到0.35之间,闭眼时会掉到0.1以下。这个数值在不同人之间有差异,但对同一个人的同一摄像头设置下比较稳定。所以实验时我做了个自适应校准,开机后先采集这个人前30秒正常状态下的EAR基准值,再根据这个基准动态调整判定阈值,比全局固定阈值靠谱得多。
MAR的计算方式类似,取嘴部外轮廓左右两个角和上下唇的四个点,纵向距离除以横向距离。嘴巴完全闭合时MAR接近0.15,张大嘴打哈欠时会超过0.6,这个区分度还是挺明显的。不过嘴部特征有个偶发干扰,就是说话,嘴里不停说话时MAR也会波动很大,所以处理时我做了个简单的时域约束,嘴巴持续张开超过一定时间才判定为哈欠,而不是单帧超阈值就报警。
PERCLOUS的计算则是最核心的,它统计的是单位时间内眼睛闭合帧数占比。这里要处理一个问题,就是眨眼和闭眼的区别。正常人一秒眨眼一到两次,每次持续100到150毫秒,如果按100毫秒一帧算,也就一两帧的事。疲劳时闭眼会持续300毫秒以上甚至几秒。所以我把闭眼帧定义改成“EAR低于阈值且持续超过3帧”,用这个过滤普通眨眼。
def compute_perclos(ear_list, frame_total): closed_frames = 0 streak = 0 for ear in ear_list: if ear < ear_threshold: streak += 1 else: if streak >= 3: closed_frames += streak streak = 0 if streak >= 3: closed_frames += streak return closed_frames / frame_total3.3 时序窗口的选取
判定疲劳不能只靠某一帧,得观察一段时间内的累计特征,这个观察窗口的大小很关键。窗口太短,比如两秒,正常的眨眼也会被算进闭眼时间,误报很高;窗口太长,比如一分钟,报警太慢,真到犯困出事的时候还没触发,失去预警意义。
我做了一组对照实验,分别用5秒、10秒、15秒、30秒窗口测试。结果显示10秒窗口在延迟和准确率之间平衡最好。具体到PERCLOS,当10秒内闭眼时间占比超过40%时,也就是大约4秒以上处于闭眼状态,判定为疲劳一级;连续两个窗口都超过40%,判定为疲劳二级,这时候输出声光提醒。哈欠判据是10秒内检测到至少2次持续张开超过1秒的嘴部动作,会作为辅助证据累加到疲劳评分里。
窗口滑动的方式也有讲究,用滑窗比分段独立窗口更稳。分段窗口在边界处容易产生突变,上一段刚判完疲劳,下一段又清零重来,用户感知很突兀。滑窗则是每帧滚动更新最近的10秒数据,判定结果平滑连续,报警状态一旦进入,不会因为某帧恢复正常就立刻解除,有效防止反复报警的抖动。
4. 实验结果与指标分析
4.1 评价指标怎么定
疲劳检测实验的评价指标不能只看准确率,因为正常状态的数据在真实场景里占绝大多数,无脑判“正常”就能拿到95%以上的准确率,完全没有意义。所以我把重点放在这几个指标上:误报率、漏报率、平均检测延迟、正检率。
- 误报率:清醒状态下被判定为疲劳的比例。这个指标直接影响产品体验,误报太多用户会直接关掉系统。
- 漏报率:疲劳状态下没有被检测出来的比例。这个指标与安全性直接挂钩。
- 平均检测延迟:从疲劳状态开始到系统输出报警的时间。延迟越高,预警价值越低。
- 正检率:疲劳状态中触发报警的比例,与漏报率互补。
测试时我准备了4段总长20分钟的模拟视频,其中清醒段和疲劳段交替出现,每个状态持续1到2分钟。测试人员按照脚本执行动作,比如正常眨眼、刻意闭眼5秒、做连续打哈欠动作,然后统计系统状态和实际状态的匹配情况。
4.2 实验结果汇总
经过多轮调参和测试,最终系统的指标如下表所示。这个结果是在室内稳定光照、摄像头正对脸部的条件下测得的,大概能说明系统的性能基线。
| 指标 | 数值 | 说明 |
|---|---|---|
| 疲劳状态正检率 | 94.2% | 疲劳段内成功触发报警的比例 |
| 清醒状态误报率 | 5.6% | 清醒段内错误触发报警的比例 |
| 平均检测延迟 | 3.8秒 | 从闭眼动作开始到报警输出 |
| 单帧推理耗时(CPU) | 18毫秒 | 关键点模型在CPU上的推理 |
| 综合帧率 | 25 FPS | 包含人脸检测的全流程帧率 |
整体看,正检率是可接受的,但误报率还是偏高,细看误报样本基本集中在戴眼镜反光和侧脸角度大的片段。眼镜反光那几帧,关键点检测把镜片边缘误认成眼皮轮廓,EAR被强行压低了,于是判定成闭眼。针对这个现象我在后面部署优化部分专门做了处理。
4.3 特征贡献的消融分析
我还做了一组消融实验,验证不同特征组合的重要性。只依赖EAR闭眼判据,正检率能到90%,但误报率明显上升;加入哈欠判据之后,疲劳段的检出更可靠,因为打哈欠往往比闭眼出现得更早;再叠加头部位姿信息之后,侧脸低头状态的漏报下降了不少。
有意思的是,头部位姿单独用效果并不好,点头动作在正常聊天中也经常出现,单靠俯仰角判定疲劳会误伤。但它和闭眼特征叠加效果很好:当头部持续下垂且眼睛处于闭合状态时,疲劳置信度可以给得很高;而头部姿态正常但眼睛闭合,也可能只是普通闭眼休息,置信度给中等。这种按特征置信度加权融合的策略,比简单阈值投票稳健得多。
5. 实时部署与常见问题排查
5.1 性能优化思路
实验原型是用PyTorch直接跑的,测试没问题之后,部署优化就提上日程了。首先把关键点模型导出成ONNX格式,推理引擎换成了OpenCV的DNN模块。这一步就省掉了PyTorch全套运行库的加载时间,内存占用也降了一大截。实测模型推理从40毫秒左右降到了18毫秒,效果非常直观。
人脸检测部分用的也是轻量检测器,但它在CPU上仍然是最耗时的模块。优化措施是降低检测频率,采用动态检测策略:当上一帧检测到人脸时,下一帧直接用跟踪算法延续人脸位置,隔5帧再重新检测一次。这样既保证了对人脸移动的跟随,又大幅减少了检测器的调用次数。整套流程在普通办公笔记本CPU上跑到了25 FPS左右,基本满足实时需求。
多线程方面,把视频采集、人脸检测、关键点推断、疲劳判定分成了四个线程,用队列传递数据。采集线程控制帧率,避免过度占用CPU;检测和推断线程共享一个工作池;判定线程只处理最新的结果。需要注意的是,采集和检测之间不能用阻塞队列,否则某一帧处理慢了会拖垮整个流程的帧率。我用的策略是丢弃旧帧,始终处理最新图像。
5.2 眼镜反光与光线变化
眼镜反光这个问题非常恶心,镜片边缘的反光会在关键点附近形成伪轮廓,而且反光的位置随着头部转动不停变化,导致EAR值忽高忽低。最终我用了两个对策:第一是训练数据里加入大量戴眼镜的样本,模型逐渐学会忽略镜框边缘的干扰;第二是加了一个中值滤波,对连续5帧的EAR值做平滑,滤掉单个异常帧的跳变。两个对策叠加之后,戴眼镜场景的误报率下降了一多半。
光照变化是另一个考验,窗户边逆光时整个脸部会过曝,关键点基本找不到;夜间只有屏幕亮光时人脸很暗,也会影响检测。解决方案是接入一个简单的自动曝光补偿模块,基于人脸区域的平均亮度动态调整摄像头的曝光参数,让人脸的亮度和对比度维持在一个舒适的区间。这个模块成本很低、收益很高,强烈建议加上。
阴暗环境下的补充方案是启用红外摄像头。红外模式下光照色彩信息基本没用,但关键点的灰度轮廓依然清楚,检测效果不受影响。如果项目预算允许,优先选自带红外补光的摄像头,夜间体验提升非常明显。
5.3 排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 检测不到人脸 | 人脸太小或画面过曝 | 检查人脸框尺寸和曝光参数 |
| EAR值频繁跳到0 | 眼镜反光造成误判 | 加中值滤波,检查训练集中是否有遮挡样本 |
| 侧脸时关键点漂移 | 数据集缺少大角度样本 | 采集增加左右转头数据,适当加旋转增强 |
| 报警抖动频繁 | 窗口太短或阈值过紧 | 增加窗口长度,放松闭眼阈值,加入滞回区间 |
| CPU占用过高 | 检测器频繁调用 | 检测间隔调整,跟踪模块启用 |
5.4 其他容易踩的坑
摄像头帧率不稳定是个隐蔽问题。采集帧率如果忽高忽低,PERCLOS计算的时间基准就不对,因为每一帧代表的时间长度不一样。我踩过这个坑,测试时用一台帧率波动大的摄像头,疲劳判定完全不准,数据看起来闭眼很频繁,但实际是帧率抽风导致的。解决办法是给每帧打上精确的时间戳,统计时用真实时间长度而不是帧数。
多目标场景也要考虑。画面里出现多张人脸时,系统只取最大的人脸作为检测主体,这样能避免把远处模糊的人脸也纳入计算。如果以后要做多人同时监测,需要为每个人维护独立的状态缓存,复杂度会上升到另一个量级,现阶段不建议一上来就做。
6. 个人复盘与扩展方向
6.1 实验教训总结
整个项目做下来,一个很深的体会是“数据比模型重要”。关键点模型的网络结构并不复杂,真正拉开效果差距的,是训练数据的覆盖面和标注质量。项目中期有一版模型结构改得很花哨,但测试效果不升反降,回补数据之后反而明显改善。扎实的数据基础是一切花哨技巧的前提。
阈值标定要按人自适应。EAR和PERCLOS的绝对值存在明显的个体差异,有人天生眼睛大、EAR基准值就高,有人眼睛小、闭眼和睁眼的差距本来就不大。固定阈值方案上线后误报会很高,一定要做开机自适应校准,采集每个人清醒状态下的正常数据来动态确定阈值。这个改动能把跨用户误报率降低30%左右。
6.2 后续可以扩展的方向
这套框架的模块耦合度很低,每一块都能独立升级。关键点检测模型可以换成参数更小的新结构,人脸检测也能替换成效率更高的算法,规则判定部分则可以逐步引入轻量级时序分类器做端到端的决策融合。如果后面有机会采集更长周期的数据,还可以加入用脑电信号做真值校准的对照实验,验证视觉特征的准确性边界。
我以前把手头这套系统接到了一个低功耗开发板上跑,稍微裁剪了一下模型宽度,帧率也能稳在15帧左右。如果对部署体积敏感,可以考虑对模型做量化处理,权重压到8位整数之后体积砍到四分之一,推理速度能再提一截,代价是精度掉个零点几个百分点,对疲劳判定影响很小。
最后说一个实操建议,就是日志记录一定要做好。每帧的EAR值、MAR值、判定状态、人脸位置,都要落一份结构化日志。后头调参的时候,只有翻日志才能定位到具体是哪些样本导致误报,空对空猜原因效率极低。加了日志之后,我调参的时间至少省了一半,很多时候看一眼日志就知道问题出在哪,不需要反复跑实验猜了。