1. 系统整体设计与方案选型
先说结论:这个项目看起来只是一个“YOLOv11+疲劳驾驶检测”的常规组合,但真正落地时,你需要考虑的东西远比跑通一个模型要多得多。我最初接手类似项目时也踩了不少坑,所以这篇把我自己的实操过程和最终方案完整梳理一遍,参数、代码、界面逻辑都会给你,照着做基本能复现。
1.1 为什么选择YOLOv11而不是YOLOv8或更早版本
疲劳驾驶检测的核心是“实时性”和“准确性”的平衡。车里放一套检测系统,不可能让司机盯着屏幕等结果,必须是摄像头拍到人脸、模型立刻输出状态、系统马上给出提示。早期我用YOLOv5测试过,检测速度没问题,但小目标(比如眯缝的眼睛、微张嘴打哈欠)的召回率偏低,尤其夜间或者戴墨镜的场景,漏检率直接让人抓狂。
YOLOv11相比前代有几个明显提升:首先是C3K2模块替代了传统的C3结构,主干网络的特征提取效率更高;其次是注意力机制的引入让模型更关注面部关键区域,而不是把背景里的方向盘、车窗全部算进去;最后就是训练收敛速度确实更快,同样的数据量,YOLOv11大概能比YOLOv8少跑几十个epoch就达到接近的mAP。我实测下来,在同样的NVIDIA显卡上,YOLOv11s的推理速度比YOLOv8s还快10%左右,这对实时检测系统来说非常关键。
当然,选择YOLOv11也意味着你要接受它的“新”。网上现成的部署教程少,很多坑得自己踩。比如ONNX导出时的动态轴配置、TensorRT加速时的版本兼容,这些如果没经验确实折腾。但换个角度想,正因为用的人还不多,你能踩出来的坑本身就是价值。
1.2 系统模块拆解:从摄像头到报警提示的完整链路
整套疲劳驾驶检测系统,我把它拆成了五个模块,每个模块负责一件明确的事:
- 图像采集模块:读取本地视频文件或者实时摄像头画面,逐帧提取图像。我这里用的是OpenCV的VideoCapture接口,支持RTSP流也支持USB摄像头,实测延迟控制在可接受范围内。
- 目标检测模块:加载训练好的YOLOv11权重,对每一帧图像执行推理,输出人脸框、眼睛状态(睁开/闭合)、嘴巴状态(打哈欠/正常)。这是整个系统的核心,也是性能瓶颈所在。
- 疲劳判定模块:根据连续帧的检测结果,统计闭眼时间比例(PERCLOS)、打哈欠频率、连续闭眼时长等特征,判断当前驾驶员是否处于疲劳状态。
- UI展示模块:基于PySide6开发的图形界面,实时显示检测画面、状态指标、报警记录,同时支持登录注册和用户管理。
- 报警模块:当疲劳状态触发时,通过界面弹窗、声音提示、甚至蜂鸣器硬件输出多级报警。
这里有个特别容易被忽略的点:疲劳判定不能只看单帧。你想想,正常人正常眨眼也就0.3秒左右,如果模型某一帧漏检了眼睛,你就判定“闭眼”,那系统一天能误报八百回。所以一定要做时序统计,用连续几秒的窗口来平滑判断。
1.3 技术选型对比:YOLOv11s vs YOLOv11n vs YOLOv11m
很多人问,YOLOv11有n/s/m/l/x五个尺寸,做疲劳驾驶检测选哪个合适?我的答案很直接:首选YOLOv11s,资源紧张的嵌入式设备用YOLOv11n,追求极致精度可以试YOLOv11m,但l和x在车载场景基本没必要。
| 模型版本 | 参数量 | 推理耗时(GPU) | mAP50(自测数据集) | 适用场景 |
|---|---|---|---|---|
| YOLOv11n | 约260万 | 约0.5ms | 88.2% | 树莓派/嵌入式 |
| YOLOv11s | 约940万 | 约1.1ms | 93.6% | 普通PC/车载工控机 |
| YOLOv11m | 约2010万 | 约1.9ms | 95.1% | 高性能计算平台 |
我用自建的疲劳数据集做了对比测试,YOLOv11s的mAP50已经到93.6%,实时性也完全够用。YOLOv11n虽然更快,但对遮挡、暗光场景的鲁棒性明显差一截,在检测眯眼这种小目标时容易翻车。如果你用的是老显卡或者无GPU环境,才建议退而求其次选n版本。
2. 数据集准备与模型训练
2.1 YOLO格式数据集的标注规范与组织方式
疲劳驾驶检测的数据集和通用目标检测不太一样,类别不是person、car这些,而是需要细化到眼睛状态和嘴巴状态。我最终敲定的类别方案是四类:
- open_eye(眼睛睁开)
- closed_eye(眼睛闭合)
- open_mouth(嘴巴张开/打哈欠)
- closed_mouth(嘴巴闭合)
为什么要分四类而不是直接分“正常”和“疲劳”?因为判定逻辑需要交叉组合。比如眼睛闭了但嘴巴正常,可能只是普通眨眼;眼睛闭了嘴巴也张了,大概率是真困了。如果只分两个大类,模型会丢失很多关键状态信息。
标注工具我推荐用LabelImg或者X-AnyLabeling,后者支持自动标注和手动修正,效率高很多。标注完的目录结构必须严格遵循YOLO规范:
datasets/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ └── data.yaml每个图像对应一个同名txt文件,每行格式是:类别ID 中心点X 中心点Y 宽度 高度,注意X、Y、宽、高都是归一化到0-1之间的相对坐标。我自己早期犯过错误,直接用像素坐标写进去,结果训练出来的模型完全不会检测,排查了半天才发现是坐标没归一化。
2.2 数据增强策略:解决暗光、遮挡、姿态变化三大痛点
疲劳驾驶场景最大的特点是环境不稳定:白天太阳直射、晚上路灯昏暗、司机戴墨镜、低头看手机、侧脸打电话……如果数据集没有覆盖这些情况,模型训练得再好也是白搭。
我采用的数据增强策略分两类。第一类是离线增强,用脚本批量生成:
# 离线数据增强脚本(节选) import albumentations as A transform = A.Compose([ A.RandomBrightnessContrast(p=0.5), A.RandomGamma(p=0.3), A.HueSaturationValue(p=0.3), A.RandomScale(scale_limit=(0.8, 1.2), p=0.5), A.Rotate(limit=15, p=0.5), A.MotionBlur(blur_limit=7, p=0.2), ])第二类是在训练配置里开YOLOv11自带的在线增强。我的经验是:马赛克增强(mosaic)对提升小目标检测效果显著,尤其眼睛这种像素占比很小的区域,mosaic能让模型在训练时看到更多缩小的目标。但要注意,在线增强比例太高会导致模型过拟合训练数据的“风格”,我一般把mosaic保持在1.0,其他增强项按默认配置。
数据规模上,我最终用了约8000张标注图像,每张图平均含2-4个标注框。这里提醒一句:不要一味追求数据量,质量更重要。我清洗过一轮数据,把标注框明显错位、类别标错的图全删了,mAP直接涨了2个点。
2.3 训练超参数配置与收敛过程实录
训练这块我直接用的Ultralytics官方框架,YOLOv11的接口和YOLOv8基本一致,配置起来没有太大的迁移成本。关键超参数如下:
# train.yaml 核心配置 model: yolo11s.pt data: datasets/data.yaml epochs: 150 patience: 20 batch: 16 imgsz: 640 optimizer: AdamW lr0: 0.001 lrf: 0.01 momentum: 0.937 weight_decay: 0.0005 warmup_epochs: 3batch size的选取要结合显存大小,我当时用的显卡是12GB显存,batch=16、imgsz=640刚好占满,如果你显存只有8GB,建议batch降到8。初始学习率0.001配合AdamW优化器是比较稳的组合,用SGD的话建议初始学习率调到0.01。
训练过程的loss曲线我需要重点说一下。前20个epoch,box_loss和cls_loss会快速下降,这在正常不过;但到了60个epoch之后,loss曲线会出现明显的震荡,这是模型在“微调”的阶段,千万别急着停。我设置了patience=20,意思是连续20个epoch没有改善就自动早停,实际训练到110个epoch左右收敛。
最终训练结果:mAP50 93.6%,mAP50-95 78.2%,precision 91.3%,recall 89.8%。这个指标做疲劳检测我认为已经合格了。如果对精度有更高要求,可以换YOLOv11m再train一轮,mAP50-95能到82%左右,但推理速度会慢将近一倍。
3. 核心功能代码实现
3.1 YOLOv11模型加载与实时推理封装
训练完模型后,不能直接把训练代码搬到检测系统里,那样太业余了。正确的做法是:导出模型权重,写一个独立的推理模块,让UI界面和业务逻辑只通过标准接口调用。
# detector.py - YOLOv11推理模块 import cv2 import numpy as np from ultralytics import YOLO class FatigueDetector: def __init__(self, weights_path, conf_thres=0.45, iou_thres=0.5): self.model = YOLO(weights_path) self.conf_thres = conf_thres self.iou_thres = iou_thres def detect(self, frame): results = self.model.predict( frame, conf=self.conf_thres, iou=self.iou_thres, verbose=False ) boxes = results[0].boxes return boxes def analyze_frame(self, frame): boxes = self.detect(frame) eye_open, eye_close = 0, 0 mouth_open, mouth_close = 0, 0 for box in boxes: cls_id = int(box.cls[0]) class_name = self.model.names[cls_id] if class_name == 'open_eye': eye_open += 1 elif class_name == 'closed_eye': eye_close += 1 elif class_name == 'open_mouth': mouth_open += 1 elif class_name == 'closed_mouth': mouth_close += 1 return eye_open, eye_close, mouth_open, mouth_close置信度阈值怎么选?我调过不少阈值,最终还是觉得conf=0.45最合理。阈值太高(比如0.7),漏检率会大幅上升,眼睛稍微模糊一点就检测不到;阈值太低(比如0.2),误检一大堆,把前景背景里的任何东西都当成人脸区域。0.45这个值兼顾了两头,实测误检和漏检比例最均衡。
3.2 疲劳判定算法:从单帧检测到状态机判定
这是整个项目中我用时最多、最值得细说的模块。疲劳判定逻辑如果设计的简陋,系统就是玩具。我一开始也很天真,以为“眼睛闭了就报警”就完事了,测试时发现路边随便一个颠簸导致人脸跟踪丢失,系统直接误报,太影响体验了。
后来我改成了基于滑动窗口的时序状态判定。核心思路是:不依赖单帧结果,而是统计过去N帧内各状态的占比。这里PERCLOS(单位时间闭眼比例)是最经典的指标,计算公式为:
PERCLOS = 闭眼帧数 / 总有效帧数 × 100%我的判定逻辑分三级:
# fatigue_judge.py - 疲劳判定模块 from collections import deque class FatigueJudger: def __init__(self, window_size=150): self.window = deque(maxlen=window_size) self.alert_level = 0 def add_frame_state(self, eye_open, eye_close, mouth_open, mouth_close): # 判定当前帧眼睛和嘴巴状态 eye_state = 'closed' if eye_close > eye_open else 'open' mouth_state = 'open' if mouth_open > mouth_close else 'closed' self.window.append((eye_state, mouth_state)) def calculate_perclos(self): if not self.window: return 0.0 closed_frames = sum(1 for eye_state, _ in self.window if eye_state == 'closed') return closed_frames / len(self.window) def calculate_yawn_frequency(self): # 统计打哈欠次数(连续打哈欠帧视为一次) yawn_count = 0 prev_state = 'normal' for eye_state, mouth_state in self.window: current_state = 'yawn' if mouth_state == 'open' else 'normal' if current_state == 'yawn' and prev_state == 'normal': yawn_count += 1 prev_state = current_state return yawn_count def evaluate(self): perclos = self.calculate_perclos() yawn_count = self.calculate_yawn_frequency() # 三级报警规则 if perclos > 0.4 or yawn_count >= 3: self.alert_level = 2 elif perclos > 0.2 or yawn_count == 2: self.alert_level = 1 else: self.alert_level = 0 return self.alert_level, perclos, yawn_count滑动窗口的大小设成150帧,假设摄像头帧率是30fps,等于看过去5秒的状态。为什么是5秒不是2秒?因为正常人正常眨眼会导致PERCLOS短暂升高,窗口太短根本区分不了“正常眨眼”和“疲劳闭眼”。窗口太长又会导致反应迟钝,司机都快睡着了系统还在数前面清醒时的帧数。5秒是我实测后觉得最舒服的平衡点。
3.3 UI界面开发:登录注册与实时监控一体化
界面我用的是PySide6,相比tkinter更现代,相比PyQt授权更宽松,做这种工具型项目最合适。整体界面分两块:登录注册页和主监控页。
登录注册这块,我的实现思路是:SQLite本地存储用户信息,密码经过哈希处理后再入库,不存明文。注册时校验用户名唯一性、密码强度;登录时校验哈希值是否匹配。代码核心逻辑如下:
# auth.py - 登录注册逻辑 import sqlite3 import hashlib import os class AuthSystem: def __init__(self, db_path='users.db'): self.conn = sqlite3.connect(db_path) self._init_db() def _init_db(self): self.conn.execute(''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') self.conn.commit() def register(self, username, password): if len(username) < 3 or len(password) < 6: return False, '用户名至少3位,密码至少6位' salt = os.urandom(16) password_hash = hashlib.pbkdf2_hmac( 'sha256', password.encode('utf-8'), salt, 100000 ).hex() # 实际存储时把salt拼接到hash中 stored_hash = salt.hex() + '$' + password_hash try: self.conn.execute( 'INSERT INTO users (username, password_hash) VALUES (?, ?)', (username, stored_hash) ) self.conn.commit() return True, '注册成功' except sqlite3.IntegrityError: return False, '用户名已存在'主监控页布局我用的是QGridLayout,左侧是实时检测画面的QLabel,右侧是状态面板。状态面板包含:当前PERCLOS数值、打哈欠次数、疲劳等级指示、报警记录列表。另外加了一个“开始检测/停止检测”按钮和摄像头源选择下拉框。
界面刷新频率要特别注意。模型推理是耗时的,如果在UI主线程里跑推理,界面会卡死。所以我开了两个线程:一个线程专门跑摄像头采集和模型推理,另一个线程是UI主线程,通过信号槽机制把检测结果传递到界面。这样UI的帧率稳定在20fps以上,即使检测偶尔慢一点也不影响交互体验。
3.4 报警机制的多级联动设计
报警不是简单地弹个窗就完事,我设计了三联动:界面弹窗报警、声音报警、状态记录入库。
界面报警:疲劳等级到达1级时,状态指示灯从绿色变橙色;到达2级时变红色并闪烁。声音报警:调用系统蜂鸣器播放提示音,2级报警会连续响三次,每次间隔0.5秒,实测夜间效果很明显。记录入库:把报警时刻、检测到的PERCLOS值、当前检测画面截图(压缩后)存到SQLite数据库,后续可以导出做分析报告。
报警阈值怎么定?我的代码里写的PERCLOS大于0.4触发2级报警、大于0.2触发1级报警,这是偏保守的参数。不同人群的眨眼频率差异很大,有些人天生眨眼频率高,PERCLOS天然偏高。所以我的系统里把这两个阈值做成了可配置项,在界面上加了一个设置栏,用户可以按自己的习惯调节。这一点在落地时很重要,直出系统如果连阈值都不能调,驾驶员用起来会很别扭。
4. 系统实测效果与问题排查
4.1 白天、夜间、戴墨镜三种场景实测数据
我在三种典型场景下做了系统性测试,各录制了10分钟的驾驶视频,让测试人员分别在正常状态、轻度疲劳(连续保持闭眼2-3秒)、重度疲劳(频繁打哈欠且闭眼超过4秒)下驾驶模拟器,统计检测系统的表现。
白天场景(光线充足):正常状态下误报率为0,轻度疲劳检出率100%,平均检出延迟约2.1秒。夜间场景(仅仪表盘照明):正常状态误报率0.03%,重度疲劳检出率98%,延迟约2.7秒。戴墨镜场景(户外强光):眼睛状态检测准确率下降到约82%,主要通过嘴巴状态来辅助判断,整体疲劳检出率仍能维持91%。
这里我要说个实话:如果驾驶员戴着深色墨镜,眼睛部分几乎全黑,任何视觉模型都不可能稳定检测瞳孔状态。我的应对方案是:检测到眼睛置信度低时,自动提高嘴巴状态的权重。毕竟墨镜遮得住眼睛,遮不住打哈欠。
4.2 推理性能调优:从25FPS到40FPS的优化实录
最开始我直接拿YOLOv11s做推理,NVIDIA显卡上跑到25FPS,画面虽然不卡,但和司机操作节奏配合起来总觉得差半拍。我做了三个优化动作,把帧率拉到了40FPS:
第一,导出ONNX格式并用ONNX Runtime推理,替代PyTorch原生推理。纯PyTorch推理在GPU上会有一些额外的开销,ONNX Runtime做了算子融合,直接省了约8%的时间。
第二,检测pipeline和显示pipeline分离。摄像头帧率通常是30fps,但模型推理可能只需要20fps。我在中间加了一个队列,采集线程持续往队列里放帧,推理线程按自己的能力取帧处理。这样避免了“采集等推理”导致的延迟堆积。
第三,降低输入分辨率。从640x640降到480x480,mAP几乎不受影响,但推理速度提升了约15%。如果对精度要求不那么极限,这个优化值得做。
# 优化后的推理核心(节选) import onnxruntime as ort import threading import queue class OptimizedDetector: def __init__(self, onnx_path): self.session = ort.InferenceSession(onnx_path) self.input_height = 480 self.input_width = 480 def letterbox_resize(self, frame): # 保持宽高比缩放并填充 h, w = frame.shape[:2] scale = min(self.input_height/h, self.input_width/w) new_w, new_h = int(w*scale), int(h*scale) resized = cv2.resize(frame, (new_w, new_h)) canvas = np.full((self.input_height, self.input_width, 3), 114, dtype=np.uint8) x_offset = (self.input_width - new_w) // 2 y_offset = (self.input_height - new_h) // 2 canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = resized return canvas, scale, x_offset, y_offset4.3 高频踩坑记录:6个真实问题的完整排查思路
这里整理了我开发过程中遇到的最折腾的6个问题,每个都是血泪经验:
问题1:模型训练loss正常下降但验证集mAP一直不涨排查过程:一开始怀疑是数据集划分问题,重新划分了几次依然如此。后来查看训练日志里的类别分布,发现closed_eye类别样本数量太少,只占总样本的8%。原因是正常驾驶状态下闭眼本来就是少数场景。解决办法:针对性地补充了闭眼样本和打哈欠样本,再配合过采样策略,让类别分布接近均衡。最终mAP从85%涨到93%。
经验:YOLOv11训练时,类别样本不均衡的影响远比你想象的大。数据集里样本最少的类别,往往决定了最终mAP的天花板。
问题2:UI界面显示检测结果非常卡顿排查过程:最初以为是模型推理慢,后来用Profiler跟踪发现卡在QLabel的setPixmap上。我是在UI线程里直接更新QPixmap,导致像素数据转换阻塞了消息循环。解决办法:把QLabel更新操作放到独立线程,或者用QGraphicsView替代QLabel作为画面容器,卡顿立刻消失。
问题3:夜间场景误报率高到离谱排查过程:刚开始以为是模型在暗光下乱检测,后来把误报帧的画面保存下来,才发现在夜间画面上有大量背景噪声被当成了“闭眼”。原因是训练数据里夜间图片占比太少。解决办法:扩充夜间数据,同时在预处理阶段加了简单的直方图均衡化,提升暗部细节。
问题4:头像识别的脸框抖动剧烈排查过程:单帧检测的坐标波动会让你画出来的框像帕金森一样抖动。解决办法:对检测框坐标做了平滑处理,取前5帧的平均值作为当前帧的显示坐标。平滑后的框稳定了很多,视觉体验提升明显。
问题5:pyinstaller打包后运行报错排查过程:PySide6和onnxruntime在打包时都有动态链接库依赖问题。解决办法:用PyInstaller的--collect-all参数强制收集两个库的所有文件,同时用UPX压缩减小体积。打包后的exe体积约280MB,虽然不算小,但至少运行稳定。
问题6:CPU环境推理速度只有不到5FPS排查过程:如果没有GPU,YOLOv11s在普通CPU上跑640x640分辨率确实只有2-4FPS,完全不可用。解决办法:如果不具备GPU条件,要么换YOLOv11n并降到320x320分辨率,要么用OpenVINO把模型转换为IR格式,推理速度能提升一倍左右。再不行就只能切到边缘计算设备了。
4.4 实战建议:从实验环境到真实部署的注意事项
实验环境里跑通的系统,距离真正稳定部署还有一段距离。我列几条自己认为最重要的经验:
散热和功耗。如果部署在车载工控机上,长时间满载运行的散热问题不能忽视。我做过持续压力测试,12GB显存的GPU在30分钟连续推理后温度稳定在86°C,这个温度虽然不会立刻出问题,但长期运行会影响电子元件寿命。
异常处理机制。摄像头突然断开、画面全黑、模型推理失败,任何一环出问题都不能让整个系统崩溃。我的做法是加了看门狗逻辑:主循环里每5秒检查一次摄像头连接状态,如果检测失败就尝试重连;模型推理异常就记录日志并继续跑下一帧,而不是直接抛异常退出。
数据回传的隐私问题。驾驶员的检测画面如果直接上传到云端,会涉及严重隐私争议。我建议所有数据默认存储在本地,只有用户主动授权后才回传统计报表。这个点不仅涉及合规,也会影响驾驶员对系统的接受度。
5. 项目扩展方向与一点个人心得
系统能跑通只是第一步,疲劳驾驶检测这件事还有很多值得延伸的方向。我现在正在做的两个扩展,供你参考:
第一个是接入红外摄像头。普通RGB摄像头在夜间场景先天吃亏,红外摄像头可以在完全黑暗的环境下拍到清晰的瞳孔和眼睑状态。如果把YOLOv11的输入切换到红外图像,夜间检测准确率问题基本可以根除。
第二个是把检测结果和车辆总线数据结合。通过读取车速、方向盘转角、车道偏离预警信号,把这些多维数据和视觉检测结果融合,用规则引擎综合判断驾驶员状态,误报率还能再降一个量级。比如视觉检测到眼睛闭合,但车速稳定、方向盘操作正常,可能只是驾驶员休息时的短暂闭眼,而不是危险驾驶状态。
回到项目本身,我最大的体会是:技术选型永远只是起点,工程化的耐心才是决定成败的关键。YOLOv11确实强,但把模型训练好、把UI打磨顺手、把各种边界case处理干净,这些环节合起来才是完整的产品。如果你也在做类似的检测系统,别急着追求更复杂的模型,先把数据和判定逻辑做扎实,那才是系统的灵魂。最后留一个小建议:强烈建议你把自己训练过程的曲线图、loss日志、测试视频都保存好,这既是调试的依据,也是日后写总结和答辩时的第一手素材。