☰
基于计算机视觉的疲劳检测系统:从关键点提取到边缘部署
2026/10/5 7:09:20 网站建设 项目流程

简介:这篇PDF论文介绍了一套基于计算机视觉的司机驾驶疲劳检测系统,面向学习图像处理、人脸识别及智能驾驶安全的研究者。系统以人眼状态为判断依据,首先采用dlib库检测人脸68个特征点,其中36-47号分别对应左右眼特征点,取代了识别能力较弱的Haarcascades包;随后基于特征点距离计算眼睛纵横比EAR,通过EAR值实时判断睁眼、闭眼状态,并结合连续帧闭眼计数来识别疲劳驾驶。文中完整论述了算法设计、系统实现流程、总体设计图、接口设计及实验结果,实验表明系统成功率高达90%。资源为单份PDF文献,大小2.66MB,内容兼具理论原理与工程实现细节,适合作为计算机视觉、疲劳驾驶检测相关课题的参考文献与专业指导。目前已有165人学习下载,可为后续研究提供完整的技术路线与可复现的检测方案。

1. 疲劳检测系统在做什么:为什么盯着“闭眼”反而最危险

深夜连续驾驶四个小时后,人最危险的一刻往往不是眼睛完全闭上,而是从“专注”滑向“涣散”的那一两秒:眨眼变慢、低头频率变高、眼皮开合幅度变小。基于计算机视觉的司机驾驶疲劳检测系统,做的就是从摄像头画面里把这些微小信号按时序抓出来。这篇笔记围绕疲劳检测这个方向,把一套可复现的视觉检测项目从原理、数据、模型、部署到排查讲透。它既是典型的计算机视觉项目,也常被用作计算机视觉大作业的选题:你不需要激光雷达,不需要脑电设备,一个普通摄像头加一台边缘设备就能跑。适合谁读?准备立项做车载预警的学生团队,以及想把这个方向落到量产前验证的工程师。先立一个结论:不要等闭眼再报警,要在闭眼前的特征退化阶段就出手,这套系统的价值恰恰在这里。

2. 用 OpenCV + dlib 跑通最小疲劳检测链路:关键点、EAR 与眨眼统计

一个可用的疲劳检测系统,不是直接拿深度学习模型端到端分类“困了”或“没困”就完事。工程上更常见的做法是先抽几何特征,再做时序统计,最后用轻量分类器判断状态。这样做的好处是中间量可解释:眼睛开合度、嘴巴张合度、头部俯仰角分别是什么状态,一眼就能从曲线里看出来,调参也有依据。

2.1 疲劳检测的三条信号源,先从视频帧里能拿到的开始

疲劳检测的信号源大体分三类,落地难度和稳定性差异很大,先用一张表对比:

信号源获取成本稳定性典型用途
生理信号(脑电、心电、肌电)高,需要接触式穿戴设备最稳定科研级精测,不适合量产装车
车辆行为信号(方向盘转角、车道偏移)中,需要接入车辆总线受驾驶习惯影响大辅助决策层
视觉信号(人脸关键点、眼部状态、头部姿态)低,一个摄像头即可受光照和遮挡影响车载预警主流方案

基于计算机视觉的疲劳检测系统,绝大多数落在第三条信号源上。原因很实际:摄像头安装成本最低、对驾驶员零打扰,而且可以直接输出“眼睛开了多少、嘴巴张多大、头低没低”这类语义明确的中间结果。系统采集的信号越接近人的直观感受,报警阈值越容易标定。

2.2 最小实现:dlib 提取 68 点,用 EAR 算眼睛开合度

疲劳检测的第一步不是分类,而是回归——从单帧图像里回归出眼睛轮廓点的坐标。我一般用 dlib 的 68 点人脸关键点模型,原因就一个:它在前向摄像头场景下久经考验,且每个关键点的序号是公开固定的,不用自己重新标定模型。眼角到眼睑的距离变化,是判断眼睛是否闭合的核心几何特征,工程上称为 EAR(Eye Aspect Ratio)。

以下是最小链路的可运行代码:

import cv2 import dlib import numpy as np detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") cap = cv2.VideoCapture(0) def ear(landmarks, idxs): # 取眼睛外轮廓 6 个点:左右眼角 2 个 + 上下眼睑 4 个 pts = [(landmarks.part(i).x, landmarks.part(i).y) for i in idxs] A = np.linalg.norm(np.array(pts[1]) - np.array(pts[5])) B = np.linalg.norm(np.array(pts[2]) - np.array(pts[4])) C = np.linalg.norm(np.array(pts[0]) - np.array(pts[3])) return (A + B) / (2.0 * C) while True: ret, frame = cap.read() gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = detector(gray, 0) for face in faces: landmarks = predictor(gray, face) left_ear = ear(landmarks, range(36, 42)) # 左眼 6 点 right_ear = ear(landmarks, range(42, 48)) # 右眼 6 点 avg_ear = (left_ear + right_ear) / 2.0 cv2.putText(frame, f"EAR: {avg_ear:.2f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) # 判断规则:低于阈值算一帧闭眼,持续超过 N 帧才视为疲劳 if avg_ear < 0.20: cv2.putText(frame, "EYE CLOSED", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) cv2.imshow("fatigue demo", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

逻辑说明:EAR 是眼睛纵向宽度与横向宽度的比值。眼睛睁开时上下眼睑距离大,比值通常在 0.25~0.35 之间;闭合时纵向距离趋近于零,比值会掉到 0.15 以下。这段代码先做整帧人脸检测,再用关键点模型取眼睛周围的 6 个点计算 EAR,最后把平均值实时绘制在画面窗口里。

参数说明:三个参数最值得调。第一是 EAR 阈值,0.20 是一个面向普通坐姿的保守起点,戴上眼镜、眯眼、侧脸都会把正常值压到 0.22 附近,调高容易误报,调低则疲劳闭眼会被漏掉;第二是 detector 的第二个参数 0,表示对单帧图像不做金字塔上采样,画面里人脸小于 80 像素时需要改成 1 或 2;第三是 dlib 模型文件,不要从不可靠的站点下载,网上流传的一些老模型对侧脸和低头场景标注误差非常大,直接影响整条 EAR 曲线的质量。想验证效果,可以录制一段自己正常睁眼、眨眼、闭眼的视频,离线跑一遍,先把阈值看着曲线调准,再回到实时流上。

2.3 从“单帧闭眼”到“时序疲劳”:PERCLOS 才是被写进论文的判据

单帧的 EAR 阈值只能告诉你“这一瞬间眼睛是否闭合”,而疲劳是一个持续过程。行业内被广泛引用的判断标准叫 PERCLOS,即单位时间内眼睛闭合时间所占的百分比。相关研究里把 PERCLOS 超过 0.15 视作显著疲劳信号。在代码里实现 PERCLOS 不需要引入复杂的状态机,维护一个固定长度的滑动窗口即可:

from collections import deque window = deque(maxlen=300) # 300 帧,30fps 下即 10 秒窗口 closed_count = 0 # 每帧更新逻辑 if avg_ear < 0.20: closed_count = 1 else: closed_count = 0 window.append(closed_count) perclos = sum(window) / len(window) if perclos > 0.15: print("PERCLOS alarm: drowsiness detected")

逻辑说明:deque 的 maxlen 参数让窗口自动滑过最旧的一帧,省去手动维护先进先出队列的代码。PERCLOS 值等于窗口内闭眼帧的计数除以窗口总长度。这里刻意不写完整的报警逻辑,因为报警还要结合持续时间、行驶速度、时间段做多级融合,直接拉响警报会误报频繁,驾驶员五分钟就关设备了。

参数说明:窗口长度 300 是按 30fps 设定的,对应 10 秒统计周期。如果摄像头实际帧率不稳定,窗口长度就必须改成按时间戳计算,否则早晚高峰堵车时帧率掉到 15fps,10 秒窗口实际只有 5 秒,PERCLOS 会虚高一倍。这是个很隐蔽的数据陷阱,下面避坑章还会展开。如果你的计算机视觉学习路线正好走到这里,把这套链路当作第一个可交付的里程碑非常合适:数据流清晰、中间量可视化、后续换成深度学习模型时,EAR 曲线仍然可以作为标签质量的参照物。

3. 从传统特征换到深度学习:数据集、模型选型与训练参数

dlib 加 EAR 这套方案的优点是解释性强、开发快,缺点也很明确:对头部大角度偏转、遮挡、戴墨镜的场景鲁棒性不足。要往实车环境走,就得换成数据驱动的模型。这一章解决三个问题:数据从哪来、模型怎么选、训练参数怎么给。

3.1 公开数据集与自采数据:别迷信 NTHU,要匹配你自己的安装视角

先说数据集。疲劳检测领域公开数据集不少,但直接拿来训练往往出问题。原因不在数据量,而在采集视角。看一张对比:

数据集场景特点优点坑
NTHU-DDD室内正脸为主,多睡眠程度标注疲劳状态标注细视角偏高,与实车前装视角差异大
YawDD车载前装视角拍摄场景接近实车标注偏向打哈欠,闭眼样本比例不足
自采数据按自己安装位置拍摄与部署环境完全同源标注成本高,状态分布不可控

疲劳检测系统最常见的翻车不是模型不够深,而是训练集和部署场景的视角、光照、脸部占比三个维度不一致。网上下载的 NTHU 数据集很多样本是室内正脸视角,而实车摄像头通常装在挡风玻璃中央后视镜位置,是俯仰角 15 度左右的俯视视角,脸部在画面里的占比、额头反光、眼睛在画面中的位置分布都不同。我一般的分配策略是:用公开数据做预训练,再用自采数据的 60% 做微调,剩下 40% 做验证和阈值标定。完全不用公开数据也可以,但自采数据通常需要两周以上的积累才能覆盖昼夜和天气变化。

3.2 选型:为什么从 MobileNetV3 加时序模块入手,而不是一上来就 ViT

模型要解决两个子任务:一是从人脸图里提取眼部、嘴部、头部姿态特征,二是在时间序列上判断疲劳趋势。第一个子任务我常用的路线是 MobileNetV3-Small 或 ShuffleNetV2,都是轻量化分类骨干网络,参数规模在 2M 到 4M 之间,算力要求低,适合车载边缘设备。第二个子任务要根据数据量决定:少量数据用单层 GRU 或 LSTM,数据量大再考虑 Transformer 序列模型。一上来就用 ViT 在嵌入式设备上是和自己过不去,推不动,还容易在几千量级的样本上过拟合。

这里给出训练脚本的核心片段,以 PyTorch 为例:

import torch import torch.nn as nn from torchvision import models class FatigueHead(nn.Module): def __init__(self, num_classes=3, hidden_size=64): super().__init__() # 用预训练的 MobileNetV3-Small 提特征,去掉最后分类层 backbone = models.mobilenet_v3_small(weights=models.MobileNet_V3_Small_Weights.IMAGENET1K_V1) self.features = nn.Sequential(*list(backbone.children())[:-1]) # 时序建模:输入每帧特征向量,输出疲劳状态序列 self.gru = nn.GRU(input_size=576, hidden_size=hidden_size, batch_first=True) self.classifier = nn.Linear(hidden_size, num_classes) def forward(self, x): # x 形状: (batch, seq_len, C, H, W) batch, seq_len = x.size(0), x.size(1) x = x.view(batch * seq_len, *x.shape[2:]) feats = self.features(x).flatten(1) feats = feats.view(batch, seq_len, -1) out, _ = self.gru(feats) return self.classifier(out[:, -1, :]) model = FatigueHead() criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) # 序列采样器:从视频里每 30 帧抽一段 16 帧的滑动片段作输入

逻辑说明:批数据的组织方式是关键。每批样本不是单张图,而是一段 16 帧的连续视频切片,先从预训练骨干网络逐帧提取 576 维特征,再输入 GRU,拿最后一个时间步的输出做分类。这样模型学到的不是“单帧是否闭眼”,而是“这 16 帧里眼睛闭合的趋势是逐渐变为全闭还是快速眨动”,后者才是疲劳判定的核心。

参数说明:AdamW 的 lr 设到 1e-4,weight_decay 用 1e-4,是针对小数据集防止过拟合的组合。序列长度 16 对应 0.5 秒到 1 秒的观察窗口,这个窗口不要拉太长。疲劳是缓慢渐变过程,序列窗口超过 5 秒反而会让模型对清醒状态下的短暂闭眼不敏感,导致漏报。分类数 3 对应清醒、轻度疲劳、重度疲劳三级,这比二分类更适合做分级预警,也让驾驶员更容易接受报警逻辑。

3.3 数据增强:先增强“部署时的环境劣化”,再谈常规增广

疲劳检测的数据增强不能照搬 ImageNet 那套。常规的随机翻转、随机裁剪适合物体分类,但人脸疲劳检测里,左右翻转会改变眼睛纹理方向,随机裁剪可能把人脸下巴裁掉。我常用的增强策略围绕部署环境劣化来做:随机亮度、随机对比度、高斯噪声、模拟镜头脏污的局部遮挡,再加少量随机旋转 5 度以内模拟车辆晃动。需要特别留意的是,hsv 色彩空间扰动的饱和度增强幅度不要超过 20%,否则车载环境里的红外补光画面会与训练分布严重偏离。

数据增强代码片段与参数说明:

import imgaug.augmenters as iaa seq = iaa.Sequential([ iaa.Add((-30, 30)), # 亮度扰动,模拟日照和隧道变化 iaa.Multiply((0.8, 1.2)), # 对比度扰动 iaa.AdditiveGaussianNoise(scale=(0, 8)), # 传感器噪声 iaa.Rotate((-5, 5)), # 车辆轻微震动 iaa.Crop(percent=(0, 0.05)), # 模拟人脸框定位偏差 ])

逻辑说明:这些增强全部作用在已经裁剪出的人脸区域上,而不是整帧画面上。很多教程对整帧做增强,结果是网格状噪声位置被模型当成环境特征,部署时画面一有轻微抖动,特征就错乱。只对裁剪后人脸区域做增强,模型的输入分布才能与推理时保持一致。

参数说明:Add 的区间设为正负 30,对应车载摄像头从室内到室外的典型亮度落差;Rotate 的角度范围 5 度正好对应车辆过弯时的车身倾斜量级,调大到 15 度以上会让眼睛关键点在训练阶段就被扭曲,疲劳特征反而被削弱。Crop 比例 0.05 对应人脸检测框抖动 5% 的场景。做计算机视觉大作业时,不用一上来把增强做满,先复现最朴素的版本,再加两个增强项做消融对比,这个习惯会让你的报告比大多数同学扎实很多。

4. 部署到车载边缘设备:模型导出、量化与推理优化

模型在服务器上精度再高,上了车跑不动都是白搭。车载环境的算力、功耗、散热都有硬约束,这一章讲的是从 PyTorch 原型到边缘设备推理的完整链路。

4.1 边缘设备选型:Jetson、RK3588 还是树莓派

先看常用设备的定位:

设备推理能力功耗量产难度
NVIDIA Jetson Orin Nano强,可跑 TensorRT 加速较高生态成熟,适合原型验证
RK3588中等,NPU 需适配各家工具链较低国产化方案常选,工具链文档参差
树莓派 4B弱,只能跑 CPU 优化模型低原型演示够用,量产不推荐

我一般的分法是:原型验证用 Jetson,TensorRT 的量化工具最省心;走向量产硬件选型时再看目标平台是安卓车机还是 Linux 盒子,RK3588 是国产域控里出现频率较高的选择,但它的 NPU 工具链版本更新频繁,换版本后算子兼容性需要重新回归测试。做计算机视觉大作业的话,树莓派或笔记本摄像头即可,不用在硬件上追规格。

4.2 把 PyTorch 模型导出到 ONNX:输入维度、动态轴与算子兼容

模型训练完毕,部署第一件事是转 ONNX。这里最容易踩的坑是动态输入维度。车载摄像头分辨率是固定的,建议导出时就锁死输入尺寸,不要开动态轴。动态轴会在推理时引入额外的形状计算开销,而且在部分 NPU 工具链上根本不支持。

import torch model.eval() dummy_input = torch.randn(1, 16, 3, 224, 224) # batch=1, 16帧, 3通道, 224x224 torch.onnx.export( model, dummy_input, "fatigue_model.onnx", opset_version=17, input_names=["frames"], output_names=["fatigue_level"], dynamic_axes={"frames": {0: "batch"}} # 只保留 batch 维度动态 )

逻辑说明:dummy_input 的形状必须和训练时序列采样器的形状完全一致,注意这里的通道维在第三位,是因为训练时已经把 RGB 图像从 HWC 转成了 CHW。opset_version 17 是 TensorRT 8.6 与 ONNX Runtime 1.17 之间的兼容版本,版本太低会丢失部分算子,版本太高在部分边缘设备推理框架上会提示无法解析。

参数说明:dynamic_axes 里只保留 batch 维度动态,其他维度全部固定。如果你是按单帧做人脸分类,而不是按 16 帧序列做判断,那么 input_names 对应的形状改成 [1, 3, 224, 224] 即可,相应的 GRU 层需要被去掉或者换成 1 帧长度的序列输入。导出后建议先用 onnxruntime 跑一遍 CPU 推理,确认输出数值与 PyTorch 推理结果相差不超过 1e-3,再进量化环节。这一条血泪经验值得记下来:跳过这个数值比对直接量化,定位精度问题是灾难,因为你分不清是量化损失还是导出错误。

4.3 TensorRT INT8 量化与实测帧率:只看 FPS 会漏掉预处理瓶颈

量化这一步,我用 TensorRT 的 INT8 模式能把模型体积压到原来的四分之一,推理速度提升 2 到 3 倍,但有两个前置条件:一是需要有校准数据集,二是量化后要做精度回归测试,特别是疲劳检测里的眼部纹理细节在 INT8 下容易损失。校准时从自采验证集里随机抽 500 张人脸图,覆盖白天、夜晚、逆光三档光照。

帧率优化常常被忽略的环节是预处理管线。很多团队在 Jetson 上发现模型推理 60fps,整个链路实际只有 12fps,原因在于图像缩放用的是 CPU 版的 cv2.resize,没有放到 GPU 上执行。常见做法是用 NVIDIA 的 dali 插件,或者用 CUDA 版 OpenCV 的 resize 函数,也可以直接让 TensorRT 的预处理层接收原始分辨率输入,把缩放和归一化一并集成进推理图里。一个经验值:摄像头采集 1080p 画面时,先在 ISP 层做 ROI 裁剪,只保留人脸所在区域再送给模型,能省掉约 40% 的预处理耗时。

如果是在树莓派这类 CPU 设备上跑,优化思路又不一样:把模型换成 MobileNetV3-Large 的量化版,线程数设成 4,再用 NCNN 框架推理,帧率能到 15fps 左右,虽然不高,做原型演示足够。ONNX Runtime 在树莓派上的性能不如 NCNN,原因是 NCNN 针对 ARM 架构做了汇编级优化。这里给一个通用的排查顺序:先确认图像采集帧率,再确认预处理耗时占比,最后才是模型推理耗时。前面两个环节没优化好,换再快的推理引擎都是空转。

4.4 模型换了,阈值必须重标:量化前后的报警参数不能共用

INT8 量化会把 EAR 曲线的微小变化压平,原先进 PyTorch 模型时标定的闭眼阈值,部署后直接沿用会导致报警延迟几百毫秒。所以每次量化后,都要用同一段标注视频回到线下重放,观察模型输出的疲劳概率曲线,重新确认三个决策参数:闭眼判定阈值、持续判定帧数、报警复位时间。

经验值:fp32 模型下闭眼判定阈值设在概率 0.7,INT8 量化后可能掉到 0.55 到 0.6,需要用验证集重新画 ROC 曲线选择最优点,而不是凭感觉调。

注意:报警复位时间指的是报警触发后多久允许再次报警。我习惯设成 30 秒,太短会在连续疲劳状态下反复拉响警报,驾驶员很快就麻木了;太长则失去预警意义。这个参数和阈值一样,需要在实车路测里反复迭代。

5. 疲劳检测最常见的 5 个坑:从漏检到误报的排查记录

这个方向的坑集中在采集、时序统计、数据标注三个环节。下面五条是我实际调试中反复遇到的,每一条都按现象、原因、解决的顺序写,可以直接拿去对照排查。

5.1 戴墨镜漏检:关键点定位失败不等于人脸检测失败

现象:驾驶员戴上墨镜后,dlib 的眼睛关键点跳到镜框边缘,EAR 曲线出现突刺或长期归零,系统反复误报。

原因:dlib 的 68 点模型是在无遮挡人脸上训练的,对强遮挡的眼睛区域回归不稳定,而且这种人脸检测器依然能框住人脸,就不会触发“无人脸”的兜底逻辑,误报因此直接进入后续判断。

解决:分两层处理。第一层,检测到眼部关键点置信度低时直接跳过该帧的 EAR 计算,用最近 5 帧的均值填充,避免突刺进入时序统计;第二层,把视觉结果与车辆行为信号融合,墨镜场景下依赖方向盘转角或车道偏移作为辅助判据。产品层面的通用方案是换用带红外透射功能的专用摄像头,可以透过镜片看到眼睛,但成本上升,不是每个项目都适合。

5.2 夜间红外画面让瞳孔特征消失,EAR 整体偏低

现象:夜晚开启红外补光后,眼睛区域变成两个亮斑,EAR 均值从白天的 0.30 掉到 0.20 附近,原来设定的 0.20 阈值频繁触发报警。

原因:红外光在视网膜上反射形成类似红眼效应,瞳孔区域和虹膜区域的灰度对比与白天自然光下相反,模型看到的是完全不同的纹理分布。

解决:不要在训练集里混用自然光和红外数据,而是分开训练两个分支模型,或者至少单独做红外场景的数据增强微调。做计算机视觉大作业时,最稳妥的办法是明确只使用同一个光照条件的训练集,测试时也用同型摄像头,不要在报告里混着说。

5.3 车辆震动让 EAR 曲线抖动,被误判为疲劳闭眼

现象:行驶在颠簸路段,人脸框随车身振动上下跳动,即使驾驶员完全清醒,EAR 曲线也会周期性下探触发报警。

原因:人脸检测框抖动导致裁剪出的人脸区域里眼睛位置偏移,关键点定位随之波动,EAR 数值不再真实反映眼睑开合。

解决:两个手段叠加。第一是卡尔曼滤波跟踪人脸框中心位置,平滑坐标跳变;第二是给 EAR 加时间一致性约束,只有当低 EAR 状态持续超过 4 帧才认定闭眼,单帧或双帧尖刺直接丢弃。

class KalmanBox: def __init__(self): self.kf = cv2.KalmanFilter(4, 2) self.kf.measurementMatrix = np.array([[1, 0, 0, 0], [0, 1, 0, 0]], np.float32) self.kf.transitionMatrix = np.array([[1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0], [0, 0, 0, 1]], np.float32) self.kf.processNoiseCov = np.eye(4, dtype=np.float32) * 1e-3 self.kf.measurementNoiseCov = np.eye(2, dtype=np.float32) * 1e-1

逻辑说明:卡尔曼滤波用上一帧的位置和速度预测当前帧的人脸框中心,再用当前帧检测到的位置做校正。transitionMatrix 里的两个 1 代表中心坐标对速度的耦合,让平滑更符合车辆振动的连续运动规律。

参数说明:processNoiseCov 和 measurementNoiseCov 的比值决定平滑强度。这里的 1e-3 对 1e-1 是“比较相信测量值、轻量平滑”的组合,适合 30fps 下的人脸跟踪。如果把 processNoiseCov 调大到 1e-1,人脸框会非常稳定但滞后明显,疲劳检测的实时性就被拖累了。

5.4 帧率波动让 PERCLOS 统计失真,报警时机完全错位

现象:系统在高速和堵车场景下的报警灵敏度差异巨大,高速上漏报,堵车时误报。

原因:用帧数计数而不是时间计数,摄像头实际帧率在光照、CPU 负载变化时可能从 30fps 掉到 15fps,一段固定帧数窗口对应的真实时间长度被压缩了。

解决:滑动窗口改用时间戳而不是帧数实现。每帧记录 time.time(),窗口内累计闭眼状态的总时长除以窗口总时长,PERCLOS 就与帧率无关了。

window = deque() closed_start = None while True: ts = time.time() if avg_ear < 0.20: if closed_start is None: closed_start = ts else: if closed_start is not None: window.append((closed_start, ts)) closed_start = None # 只保留最近 10 秒的闭眼区间 window = deque([(s, e) for s, e in window if ts - e < 10.0]) closed_time = sum(e - s for s, e in window) perclos = closed_time / 10.0

逻辑说明:这里用区间而不是帧来记录闭眼状态。闭眼开始和结束形成一个时间区间,存进滑动窗口;每次新帧到达后,把窗口里早于 10 秒的区间丢掉,再计算窗口内闭眼总时长占 10 秒的比例。这样即使帧率瞬时跳到 10fps,PERCLOS 依然是真实时间比例。

参数说明:10.0 秒是统计窗口长度,对应前文 PERCLOS 0.15 的判定口径。如果产品要求 30 秒内闭眼累计超过 5 秒报警,就把窗口和阈值改成 30.0 和 5.0。建议把这两个参数放到配置文件里,不要在代码里写死,因为接下来每次实车路测都可能要调。

5.5 标注不一致,模型学到的是“打哈欠”而不是“疲劳”

现象:模型在测试集上准确率高达 90%,实车场景却对明显疲劳驾驶毫无反应。

原因:很多公开数据集里“疲劳”的标注依赖打哈欠和揉脸动作,模型实际学到的是嘴部开合特征。而真实驾驶疲劳的早期表现可能只是眼睑下垂、注视带宽收窄,驾驶员根本没打哈欠。

解决:把训练数据的标签从“动作标签”改成“状态标签”。让至少两位标注员独立对同一段视频的每个 5 秒片段标疲劳等级,只保留两人结果一致的样本,分歧样本单独讨论。眼神发直、头部缓慢下垂这类表现必须显式纳入正样本,否则模型永远学不到真正的疲劳状态。

6. 进阶验证:用连续行为曲线量化疲劳度,而不是只看单帧

单帧阈值解决的是“眼睛有没有闭”,解决不了“疲劳程度几分”。实车环境里,驾驶员可能连续两小时保持眼睛半开状态,单帧 EAR 始终在阈值上方,但注意力水平已经明显下降。所以我习惯把三组信号拉成曲线看:EAR 时间曲线反映眼睑开合趋势,嘴部开合度曲线反映哈欠频率,头部俯仰角曲线反映点头频率。任何一组单独看都有噪声,但三组曲线的组合能给出一个更平滑的量化疲劳指数。

其中最有价值的一个指标是 EAR 曲线的下降斜率。疲劳早期,人的眨眼速度变慢、闭眼时间变长,EAR 的基线会缓慢下移,而不是像清醒时那样围绕固定值上下波动。用滑动窗口做一阶线性拟合,斜率就是这段时间里眼睛开合度的变化趋势:

def fatigue_trend(ear_series, window_size=90): # 返回滑动窗口内 EAR 的一阶线性拟合斜率 x = np.arange(window_size) y = np.array(ear_series[-window_size:]) k = np.polyfit(x, y, 1)[0] return k

逻辑说明:polyfit 对最近 90 帧的 EAR 数值做线性拟合,返回的 k 是拟合直线的斜率。k 为负且绝对值持续增大,说明眼睛正在持续闭合趋势中;k 接近 0 说明眼睑开合状态稳定。这个趋势指标比单帧 EAR 对个体差异的敏感度低,同一个阈值可以用在更多驾驶员身上。

参数说明:window_size 取 90 对应 3 秒窗口,适合捕捉缓慢的疲劳趋势。窗口太短会被正常眨眼带偏,太长则报警滞后。实际部署时我会把 k 和 PERCLOS 做一个加权融合,比如 k 小于负 0.001 且 PERCLOS 大于 0.10 时才触发一级预警,两级条件同时满足才确认疲劳,误报率比单指标低一个量级。

这个系统的玄学之处在于:参数调得再漂亮,没有实车路测都是白搭。我踩过最深的坑,是把实验室里的完美指标直接搬到车上,第一天就被隧道光照变化打回原形。从那以后我养成了一个习惯:每次改模型或阈值,先在车库里对着记录仪视频重放十遍,再到路上跑两圈,回来再改。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询