简介:一篇发表于《桂林电子科技大学学报》的学术论文PDF,围绕基于深度学习的疲劳驾驶检测方法展开,适合计算机视觉、智慧交通与安全驾驶方向的研究生、工程师阅读。该研究针对疲劳检测实时性差、准确率低的问题,从主观法和客观法两类检测思路切入,提出以MTCNN完成人脸检测,用眼睛精定位(FEL)模型提取眼部区域,通过OC-Net判定眼睛状态,再结合PERCLOS算法与眨眼频率进行疲劳判定;实验准确率97.18%,在复杂环境下鲁棒性较高。文中还包含CNN卷积层、池化层、全连接层的结构说明与关键公式,便于理解模型实现。压缩包内共1个PDF文件,约2.69MB,含完整正文、图表与9篇参考文献,可离线查看。已有325人浏览学习,适合作为论文写作、技术复现及毕业设计的参考资料。
1. 疲劳驾驶检测为什么绕不开深度学习这条路
疲劳驾驶检测的核心难点从来不是“能不能捕捉到人脸”,而是“能不能在低算力、复杂光照、遮挡和姿态变化下稳定判断人的困倦状态”。早期基于传统机器学习的方法,例如提取HOG、LBP特征后送入SVM分类器,在公开数据集上准确率尚可,但一旦进入真实车载环境——驾驶员佩戴墨镜、低头看导航、光线忽明忽暗——特征工程的鲁棒性迅速崩塌。疲劳驾驶检测本质上是一个高维非线性特征学习问题,而深度学习恰好擅长在此类场景下隐式学习到从像素到状态的映射。当前主流方案已经收敛到两条技术路线:基于CNN的人脸关键点回归加PERCLOS运算,以及基于轻量级分类网络的三分类(清醒、疲劳、困倦)。这两条路线都依赖深度神经网络作为特征提取器,区别只在于输出层如何定义“疲劳”这个标签。本文按从数据到模型再到部署的完整链路,把每一步的参数设置和踩坑点讲透,读者不需要完整复现论文也能按这套方法搭出可运行的检测系统。
2. 数据准备与预处理:先让模型学会“打盹”的视觉特征
2.1 疲劳标签的三种定义方式,别混着用
在动手写Dataset类之前,必须先明确一个问题:你的模型到底要输出什么?不同标签定义直接决定网络结构、损失函数和标注成本。目前从业者最常用的有三种方式,我推荐在项目初期选第一种,到后期再混合第二种做集成。
第一种是状态分类,将视频帧标记为“正常”或“疲劳”,统计算法上等价于二分类问题。优点是标注成本低,一周内可以完成数千帧的标注;缺点是模型只能给出概率值,无法精确计算PERCLOS参数,也感知不到闭眼时长这种时序特征。第二种是眼睛状态二分类,裁剪出左右眼区域分别标注开闭状态,再通过滑动窗口统计闭眼帧占比。第三种是回归方式,直接预测人脸的68个关键点坐标,用关键点计算EAR(Eye Aspect Ratio)或PERCLOS,这种方案的物理意义最强,抗个体差异能力也最好,但要求标注工具支持关键点级别的标注,成本是分类方式的五倍以上。
实际项目中我建议这样组合:主干网络用关键点回归模型,同时在模型的中间层接一个二分类分支,分类分支的label来自EAR阈值的自动判断。这样可以同时拿到关键点坐标和疲劳概率,后者用于平滑输出,避免关键点抖动导致的误判。
2.2 数据增强要模拟车载环境,而不是通用目标检测的增强
通用的随机裁剪、翻转和色彩抖动在这类任务上效果有限。疲劳驾驶检测的真实难点是墨镜遮挡、驾驶员低头导致的眼睛不可见,以及夜间红外图像的灰度分布偏移。我一般会构造一组专门针对人脸关键点任务的增强方法,先把代码写出来。
import albumentations as A import cv2 import numpy as np # 针对近红外车载相机采集的图像设计增强管线 transform = A.Compose([ # 模拟红外相机自动增益引起的亮度突变 A.RandomBrightnessContrast(brightness_limit=(-0.3, 0.3), contrast_limit=(-0.2, 0.2), p=0.8), # 模拟车辆通过隧道时光照色温变化 A.HueSaturationValue(hue_shift_limit=5, sat_shift_limit=10, val_shift_limit=20, p=0.5), # 模拟座椅震动导致的轻微模糊,这是最容易被忽略的增强 A.MotionBlur(blur_limit=(5, 9), p=0.3), # 模拟佩戴墨镜时眼睛区域被遮挡,用矩形遮罩块代替 A.CoarseDropout(max_holes=1, max_height=30, max_width=60, fill_value=0, p=0.2), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=10, border_mode=cv2.BORDER_CONSTANT, p=0.5) ], keypoint_params=A.KeypointParams(format="xy", remove_invisible=False))这段代码里有几个参数需要特别解释。CoarseDropout在目标检测领域通常用来增强遮挡物体的上下文,但在人脸关键点任务里它的作用更偏正则化——强制网络不依赖眼睛周围某几个特定像素,而学整体的轮廓关系。fill_value=0对应红外人脸图像的黑色背景;如果你用的是普通RGB相机,可以改成fill_value=127保留一些灰度信息。ShiftScaleRotate的旋转角度我只给了10度,很多人喜欢给到30度,但实际驾驶时人脸偏转很少超过15度,过大的旋转反而会让关键点标注变得不合理。
2.3 标注格式和YOLO格式的转换细节
如果你用YOLO框架做人脸检测,再把检测结果送入关键点模型,就需要一套格式转换逻辑。模型输出的人脸框坐标是归一化后的cx, cy, w, h,而关键点模型输入需要的是裁剪后的人脸图像。这里有一个常见坑:直接按检测框裁剪会把眉毛和额头裁掉大半,导致关键点回归的上半部分特征不足。我一般会按检测框高度外扩20%,宽度外扩10%后再裁剪。
# 将YOLO检测结果转换为裁剪坐标时,手动扩展边界 # bbox格式: center_x, center_y, width, height (归一化比例) python -c " import cv2 img = cv2.imread('frame.jpg') h, w = img.shape[:2] # 假设模型输出的人脸框参数 cx, cy, bw, bh = 0.5, 0.45, 0.22, 0.28 x1 = int((cx - bw/2) * w - 0.05 * bw * w) y1 = int((cy - bh/2) * h - 0.16 * bh * h) x2 = int((cx + bw/2) * w + 0.05 * bw * w) y2 = int((cy + bh/2) * h + 0.05 * bh * h) face_crop = img[max(0,y1):y2, max(0,x1):x2] cv2.imwrite('face_crop.jpg', face_crop) "y1向外扩展了检测框高度的16%,这个值不是拍脑袋定的。观察公开数据集的标注分布可以发现,人的眉毛上缘到额头顶点约占人脸检测框高度的15%到18%,如果按原框裁剪,瞳孔上方的信息量会大幅减少。0.05 * bw * w的宽度扩展则是对左右方位的宽容处理,避免检测框轻微偏移导致后续关键点回归的边界伪影。
3. 模型选型与训练:从MobileNetV3到关键点回归的轻量化设计
3.1 为什么MobileNetV3是比ResNet更合适的主干
疲劳驾驶检测模型的部署场景多半是车机端的嵌入式设备,算力通常只有几TOPS甚至更少,训练和部署这两个环节对模型的要求完全不同。训练阶段可以用ResNet50或EfficientNet-B3这种大模型拿到高精度,但最后真正上车的必须是轻量级模型。
MobileNetV3在这个任务上的优势不仅是参数量小。它的h-swish激活函数在嵌入式设备上可以直接用定点运算近似,避免了标准swish的指数运算;SE模块的引入让网络能自动加权通道特征,对眼睛这种细节区域的分辨能力反而优于参数更多的ResNet18。如果你有NVIDIA的嵌入式平台,用TensorRT部署MobileNetV3时还可以合并BN层和ReLU6操作,推理速度比ResNet快三倍左右。
我在实际项目中对比过MobileNetV3-Small、ShuffleNetV2和GhostNet三个模型在关键点回归任务上的表现。训练参数完全一致的情况下,MobileNetV3-Small的NME(归一化平均误差)比ShuffleNetV2低7%左右,而推理速度只慢3毫秒。GhostNet的精度与MobileNetV3接近,但它在TensorRT上的INT8量化支持不如MobileNetV3成熟,所以最终选择了MobileNetV3-Small。
3.2 关键点回归的训练流程,直接复用的PyTorch代码
下面给出一段完整的关键点回归训练核心代码,采用坐标热图(Heatmap)方式而不是直接回归坐标值。热图方式的训练更稳定,不容易出现坐标跳跃,且对标注噪声的容忍度更高。
import torch import torch.nn as nn import torch.nn.functional as F class MobileV3Keypoint(nn.Module): def __init__(self, num_points=68, heatmap_size=48): super().__init__() from torchvision.models import mobilenet_v3_small backbone = mobilenet_v3_small(pretrained=True) # 去掉分类层,保留到第13层输出特征图 self.features = backbone.features # 人脸关键点热图的4608 = 48*48*2,8是通道数 self.heatmap_head = nn.Sequential( nn.Conv2d(576, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Conv2d(256, num_points, kernel_size=1) ) # 上采样到48x48 self.upsample = nn.Upsample(size=(heatmap_size, heatmap_size), mode="bilinear", align_corners=False) def forward(self, x): f = self.features(x) # shape: [B, 576, 4, 4] h = self.heatmap_head(f) # shape: [B, 68, 4, 4] h = self.upsample(h) # shape: [B, 68, 48, 48] return h def spatial_softmax_2d(heatmap): """从热图得到归一化坐标,可微分,用于计算NME损失""" b, c, h, w = heatmap.shape heatmap = heatmap.view(b, c, -1) prob = F.softmax(heatmap, dim=-1) # 生成x和y的坐标网格 y_grid = torch.linspace(0, 1, h, device=heatmap.device) x_grid = torch.linspace(0, 1, w, device=heatmap.device) grid_y, grid_x = torch.meshgrid(y_grid, x_grid, indexing="ij") grid_x = grid_x.reshape(-1).unsqueeze(0).unsqueeze(0) grid_y = grid_y.reshape(-1).unsqueeze(0).unsqueeze(0) x_coord = (prob * grid_x).sum(dim=-1).view(b, c, 1) y_coord = (prob * grid_y).sum(dim=-1).view(b, c, 1) return torch.cat([x_coord * w, y_coord * h], dim=-1)这段代码有两个设计考量。heatmap_head先用3x3卷积进行特征融合再降到68个通道,避免直接从576维映射导致的空间信息丢失。spatial_softmax_2d用了可微的softmax求期望坐标,而不是对热图取最大值,这保证了梯度可以回传到网络的每一层。
训练时用Smooth L1 Loss计算预测坐标与真实标注坐标的误差,初始学习率设为1e-3,batch size为128时,约40个epoch收敛。关键的超参数是热图尺寸,heatmap_size=48在车载算力上是精度和耗时的平衡点;如果设备性能较差可以降到32,NME通常只会恶化0.5%到1%。
3.3 用EAR和PERCLOS把关键点坐标换算成疲劳指标
得到关键点坐标后,接下来是计算两个广泛使用的生理指标。EAR衡量单帧的眼睛闭合程度,PERCLOS衡量一段时间的闭眼占比。这里给出标准公式的Python实现。
import numpy as np from collections import deque def compute_ear(eye_points): """eye_points: 6个关键点的坐标,顺序按照左眼角顺时针排列""" p2_p6 = np.linalg.norm(eye_points[1] - eye_points[5]) p3_p5 = np.linalg.norm(eye_points[2] - eye_points[4]) p1_p4 = np.linalg.norm(eye_points[0] - eye_points[3]) ear = (p2_p6 + p3_p5) / (2.0 * p1_p4 + 1e-6) return ear class FatigueEstimator: def __init__(self, window_size=60, ear_threshold=0.21): self.window = deque(maxlen=window_size) self.ear_threshold = ear_threshold self.closed_count = 0 def update(self, ear_value): self.window.append(ear_value) self.closed_count = sum(1 for v in self.window if v < self.ear_threshold) perc = self.closed_count / len(self.window) return perc # 假设从关键点中提取左右眼的坐标 left_eye = np.array([[1,2],[3,4],[5,6],[7,8],[9,10],[11,12]], dtype=np.float32) right_eye = np.array([[13,14],[15,16],[17,18],[19,20],[21,22],[23,24]], dtype=np.float32) ear_left = compute_ear(left_eye) ear_right = compute_ear(right_eye) avg_ear = (ear_left + ear_right) / 2.0 print(f"EAR: {avg_ear:.3f}")ear_threshold的标准值在0.2到0.25之间,但不同驾驶员的眼睛大小差异很大。我习惯在系统启动后的前20帧做一个自适应校准,取这20帧EAR均值乘0.75作为当前驾驶员的阈值。window_size=60对应20秒的统计窗口(30fps),这个时长足够覆盖一次眨眼过程,也能在驾驶员连续闭眼3秒时快速触发告警。
4. 部署与推理优化:ONNX导出、INT8量化与帧级流水线设计
4.1 模型导出过程中的算子兼容问题
PyTorch模型在训练机上跑得再好,到了车机上都要先转换为ONNX再推到推理引擎。这个转换过程最常见的坑是torchvision里MobileNetV3的F.relu和hardswish在旧版ONNX算子集里不支持。我的做法是用更高版本的算子集导出,再针对目标推理引擎做验证。
# 导出ONNX时统一使用13号算子集 python -c " import torch from models import MobileV3Keypoint model = MobileV3Keypoint(num_points=68) checkpoint = torch.load('best_model.pth', map_location='cpu') model.load_state_dict(checkpoint['state_dict']) model.eval() dummy = torch.randn(1, 3, 192, 192) torch.onnx.export(model, dummy, 'model.onnx', opset_version=13, input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}}) "导出时把dynamic_axes加在batch维度上,这样推理时不需要固定batch size,方便调试。但注意不要将宽高维度设为动态,MobileNetV3的全局平均池化对输入尺寸有一定要求,动态宽高会导致部分推理引擎重新做图优化,得不偿失。opset_version=13对hardswish和h-sigmoid的支持已经完整,低于11的话需要手动替换激活函数,不建议在老版本上浪费时间。
4.2 INT8量化时的校准数据集和精度回退
量化是车载部署不可避免的一步。FP32的MobileNetV3-Small单帧推理在Jetson Nano上大约要35毫秒,INT8量化后可以压到不到15毫秒。关键在量化校准这一步,校准数据集选得不好会导致眼睛区域的敏感度大幅下降。
# 量化校准数据收集脚本,返回符合实际分布的样本 import numpy as np import cv2 from torch.utils.data import Dataset class CalibrationDataset(Dataset): def __init__(self, video_path, num_samples=500): cap = cv2.VideoCapture(video_path) self.frames = [] count = 0 # 按间隔采样,覆盖不同人脸姿态 while cap.isOpened() and count < num_samples: ret, frame = cap.read() if not ret: break if count % 3 == 0: face = frame[80:400, 120:520] # 裁剪ROI区域,模拟检测框输出 face = cv2.resize(face, (192, 192)) face = cv2.cvtColor(face, cv2.COLOR_BGR2RGB) face = face.astype(np.float32) / 255.0 self.frames.append(face) count += 1 cap.release() def __len__(self): return len(self.frames) def __getitem__(self, idx): return self.frames[idx]校准数据集必须以“驾驶员样本”为主,包含戴眼镜的人、眯眼状态的人和正常睁眼的人。如果只用标准人脸数据集做校准,常见的结果是量化后的模型对闭眼状态的响应不敏感,也就是闭眼时EAR值下降不够明显。另一个细节是每3帧采样一次,避免连续帧过度相似导致校准数据的信息熵过低。
4.3 两级推理流水线:人脸检测与关键点回归并行化
在推理阶段,如果每帧都跑人脸检测加关键点回归,在低算力平台上仍然会跑不满30fps。业界通用的工程方案是双级流水线:低帧率跑人脸检测,高帧率跑关键点回归。
import threading import time import numpy as np class DetectionPipeline: def __init__(self, detector, keypoint_model, det_interval=3): self.detector = detector self.kp_model = keypoint_model self.det_interval = det_interval self.frame_count = 0 self.last_bbox = None # 缓存上次检测到的人脸框 def process(self, frame): self.frame_count += 1 # 只有第1帧和每隔det_interval帧做人脸检测 if self.frame_count % self.det_interval == 1: boxes = self.detector(frame) if len(boxes) > 0: self.last_bbox = boxes[0] # 关键点模型始终使用最新的检测框(或缓存框) if self.last_bbox is None: return None x1, y1, x2, y2 = self.last_bbox # 按2.3节的外扩比例裁剪人脸区域 h, w = frame.shape[:2] bw, bh = x2 - x1, y2 - y1 cx1 = max(0, int(x1 - 0.05 * bw)) cy1 = max(0, int(y1 - 0.16 * bh)) cx2 = min(w, int(x2 + 0.05 * bw)) cy2 = min(h, int(y2 + 0.05 * bh)) face = frame[cy1:cy2, cx1:cx2] if face.size == 0: return None face_resized = cv2.resize(face, (192, 192)) # 推理过程,这里省略具体的tensor转换 keypoints = self.kp_model(face_resized) return keypoints, self.last_bbox这个设计的巧妙之处在于:人脸检测每三帧才执行一次,中间两帧直接用上一次的检测框。连续视频帧中的人脸框位移很小,这种策略可以把检测的开销摊薄到三分之一。如果检测间隔过大(比如5帧以上),驾驶员快速转头时可能出现关键点模型输入框偏移过多的情况,所以det_interval=3是在Jetson Nano上实测的较优值。
5. 进阶优化与验证技巧:从误报率治理到多帧时序融合
5.1 用EMA平滑EAR时序,去除抖动噪点
单帧EAR的抖动幅度远大于预期,尤其是低分辨率红外图像下,关键点坐标偶尔会出现像素级跳动。直接在原始EAR序列上设置阈值,很容易出现频繁的“疲劳-清醒”跳变。我一般在EAR进入疲劳判定之前先做一次指数移动平均。
# EMA平滑示例,alpha根据帧率调整 import numpy as np class EARSmoother: def __init__(self, alpha=0.15): self.alpha = alpha self.ema = None def update(self, ear): if self.ema is None: self.ema = ear else: # alpha越小平滑越强,但反应越慢 self.ema = self.alpha * ear + (1 - self.alpha) * self.ema return self.ema smoother = EARSmoother(alpha=0.15) ear_sequence = [0.3, 0.12, 0.25, 0.1, 0.28] # 模拟带噪的原始EAR序列 for raw_ear in ear_sequence: smoothed_ear = smoother.update(raw_ear) print(f"raw: {raw_ear:.3f} -> smoothed: {smoothed_ear:.3f}")alpha=0.15对应30fps下的响应时间大约200毫秒左右,既能滤掉单帧的异常跳动,又不会掩盖真实的闭眼趋势。如果你的帧率只有15fps,建议把alpha调小到0.08,保证时间常数不变。
5.2 接入传感器融合提升鲁棒性:方向盘转角与车道偏移信号
仅靠视觉信号很难区分“闭眼”和“看向下方仪表盘”这两个动作。视觉检测方案落地时通常需要传感器级融合,这个思路在基于深度学习的疲劳驾驶检测里越来越受重视。方向盘转角传感器和车道线摄像头信号不需要额外硬件,通过车辆的CAN总线就能读取。
融合逻辑并不复杂:方向盘转角的方差在疲劳状态下会显著增大,尤其在车道保持场景中,驾驶员的修正频率和幅度都会高于清醒状态。视觉模块给出疲劳概率,CAN信号给出方向盘异常概率,二者加权后延迟一段时间再触发告警,把单模态的误报率砍掉大半。这里的重点是时间窗口的选择——方向盘信号应当延迟1秒进入融合逻辑,因为视觉疲劳的统计已包含数秒窗口,但方向盘修正动作是瞬时的。
5.3 模型的端到端验证:从指标到告警时延
不要只看模型的ACC和NME指标,部署后的系统级验证至少要覆盖三个维度:误报率(每100分钟误触发告警次数)、漏报率(模拟疲劳状态下的未触发比例)、告警时延(从闭眼到告警的延迟)。误报率统计需要收集至少5小时真实驾驶视频,漏报率测试则可以用数据集中已知的疲劳片段回放。
验证时建议按以下步骤操作:记录连续1000帧的EAR输出与关键点可视化结果,检查EAR是否在闭眼瞬间有明确的数值下降;分别在白天强光、夜间红外、佩戴墨镜三种条件下重复测试,对比三种场景下的PERCLOS直方图差异。如果夜间红外场景下的误报率明显偏高,通常不是模型问题,而是红外图像中人脸区域亮度溢出导致关键点偏移,需要在前处理阶段做局部直方图均衡化后再送入模型。这一步调优比重新训练模型性价比高得多。
本文还有配套的精品资源,点击获取