1. 项目整体拆解:先搞清楚你要做的是什么
疲劳驾驶检测这几年在智慧交通、车队管理、驾驶员状态监控(DMS)场景里越来越常见。很多人一看到“深度学习”就觉得高不可攀,但实际上这套项目可以拆成一条非常清晰的链路:用 YOLOv10 做目标检测,用整理成 YOLO 标注格式的图片数据集对模型做训练,再在 Python 环境里写一个带 UI 界面的桌面程序,最终交付内容包括项目源码和训练好的模型权重。我按这个思路完整跑通过一套演示系统,下面把每一步怎么落地、有哪些坑讲清楚。
先给没有基础的同学一个整体观感:疲劳驾驶检测系统不等于一个目标检测模型。YOLOv10 的职责是看清画面里驾驶员的眼睛是睁开还是闭合、嘴巴有没有大幅张开,然后把这些视觉信号变成标签和坐标。至于“连续几秒闭眼算疲劳”“几分钟内哈欠几次算疲劳”,这是后面时序逻辑和业务规则要做的事。如果只用单帧检测结果,看到眨眼就报警,这个系统基本没法用。
1.1 系统由哪几块组成
我习惯把项目分成四块:数据侧、模型侧、策略侧、展示侧。
- 数据侧:采集或整理图片,标注眼睛和嘴巴状态,统一转换成 YOLO txt 格式,划分训练集和验证集。
- 模型侧:选择 YOLOv10 预训练模型,微调到自己的疲劳状态数据上,导出 best.pt 或 ONNX。
- 策略侧:对模型输出做逐帧状态提取、闭眼比例统计、哈欠事件计数、多级告警状态机。
- 展示侧:Python 桌面 UI,接入摄像头或视频文件,实时显示检测框、状态和告警信息。
这样拆的好处是,哪里出了问题都能单独定位。比如策略误报不用重训模型,模型漏检也不用改界面,两边解耦得很干净。后面所有章节,我也会按照这个顺序来讲。
1.2 为什么选 YOLOv10 这套技术路线
在目标检测领域,YOLO 系列一直是把训练速度、推理速度和自定义数据集门槛平衡得最好的那一类。YOLOv10 相比更早版本,把推理阶段的 NMS 后处理去掉了,整个检测流程更干净,实时性表现更好,这对驾驶舱这种连续视频流非常友好。它同时提供多个不同尺寸的模型,从适合低算力的轻量版,到精度更高的标准版都有;数据集格式跟其他 YOLO 版本基本一致,训练脚本迁移成本很低。
注意:选模型不是越新越好。如果你的部署环境很老,或者手里参考资料都是基于旧版的,选 YOLOv5 或 YOLOv8 照样能做。这里用 YOLOv10 的理由是后处理更少、权重和教程容易获取,而不是说旧版本不能用。
1.3 技术栈全景
| 层 | 常见选择 | 作用 |
|---|---|---|
| 语言/框架 | Python + PyTorch | 训练和推理 |
| 检测模型 | YOLOv10(轻量版到标准版) | 眼睛、哈欠目标检测 |
| 图像处理 | OpenCV | 视频读取、画框、预处理 |
| 桌面 UI | PyQt5/PySide6 或 Tkinter | 视频显示、告警、参数设置 |
| 交付物 | 源码 + best.pt + ONNX + 使用说明 | 复现和部署 |
下面我按实际推进顺序写:第 2 节和第 3 节解决数据和模型,第 4 节解决疲劳判定算法,第 5 节做 UI 集成,第 6 节给排错速查。你不需要一次看完,照着对应章节抄作业就行。
2. 数据集与标注:模型效果的上限在这里
2.1 疲劳状态怎么定义
做数据集之前,先把疲劳的定义固定下来,不然后面会乱。业界用得最多的客观指标是 PERCLOS,也就是单位时间内眼睛闭合时间所占的比例。简单说,如果一段统计窗口里,驾驶员的眼睛经常处于闭合状态,说明嗜睡风险在上升。单次眨眼通常会持续 100 到 400 毫秒,正常驾驶时不会构成疲劳;而疲劳带来的闭眼往往持续 1 秒甚至几秒,这个时间差异就是区分眨眼和疲劳的关键。
对应的目标检测类别可以这样设计:
| 类别 | 标注对象 | 与疲劳的关系 |
|---|---|---|
| open_eye | 睁开眼睛的单个眼眶 | 正常状态的基准 |
| closed_eye | 明显闭合或遮住大半的眼眶 | 计入 PERCLOS |
| yawn | 嘴巴大幅张开的区域 | 计入哈欠事件 |
如果你有精力,还可以加一个 face 类做人脸兜底。后面做两阶段检测时,先检测人脸,再裁剪出眼部区域检测,效果会比直接在整张大图上找小眼睛好很多。
2.2 所谓 YOLO 数据集,其实就是这种 txt 标签格式
很多刚接触的人看到“YOLO 数据集”以为是某个公开数据集。其实这里指的是所有标注文件都按 YOLO 格式保存:每张图片对应一个同名 txt,每一行代表一个目标框,内容是类别 id、归一化后的中心点 x、中心点 y、宽、高。
0 0.4360 0.2841 0.0820 0.0943 1 0.5621 0.3052 0.0775 0.0889 2 0.5121 0.3804 0.1154 0.10210、1、2 对应上面定义的类别;所有坐标都除以图片的宽高,所以值都在 0 到 1 之间。一个标准的目录结构长这样:
fatigue_dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── fatigue.yaml建议写一个转换脚本,把公开数据集中常见的 COCO 或 VOC 格式转成 YOLO 格式,不要手工改坐标。转换脚本里最需要注意的是坐标归一化,很多新手在这个位置翻车,画出来的框全跑到图片外面。
2.3 标注规范和清洗
标注是这套项目里最枯燥但最影响效果的一步。我的建议只有一句话:宁可少标,不能错标。闭眼就要真的是闭合或接近闭合;眯眯眼、视线被头发挡住一半的不要强行标。两个眼睛要分别框出来,不要框整个脸,也不要把两只眼睛合成一个框。嘴巴只有明显张大才标 yawn,正常说话、微笑都不要标。
如果同一个小目标被多人标过,建议先做一致性检查:把训练集里所有闭眼样本抽出来看一遍,你会立刻发现很多不该出现的框。漏标闭眼等于让模型把闭眼当背景,错标睁眼等于让模型把睁眼和闭眼混淆,两种错误都会直接拉低模型效果。
数据清洗还有一个高效办法:用训练早期的模型预测训练集,生成伪标签,再和人工标签对比,专门把漏标和错标的帧挑出来重标。这比眼睛瞪着一万张图快得多。
2.4 数据增强与样本均衡
疲劳状态数据天然不均衡:正常状态下睁眼和闭嘴的样本很多,闭眼和哈欠是少数。如果不处理,模型容易把一切预测成正常状态,因为闭眼样本太少,在损失函数里几乎没有贡献。
解决手段一般有三条:一是多采集闭眼和打哈欠的片段,这是最有效的;二是训练时开 Mosaic、Mixup、HSV 扰动;三是如果某个类别样本确实少,把这一类小目标抠出来复制粘贴到其他画面里。复制粘贴会引入边缘痕迹,建议用模糊或透明度做微调,并且每次训练时随机贴,不要形成固定 pattern。
如果后面要在夜间或弱光场景跑,训练集里至少要有一批低光照画面,否则增强再多也补不了域差异。白天训练好的模型直接放到夜间摄像头下,误报率会比想象中高很多。
3. YOLOv10 训练实操:从环境到权重导出
3.1 环境准备与数据划分
训练环境建议使用 Python 3.8 以上的虚拟环境,装好 YOLOv10 相关的推理库和 OpenCV。有 GPU 最好,CPU 也能跑,但训练速度会让你失去耐心。安装命令大致是这样:
pip install ultralytics opencv-python数据划分的时候要按视频片段或摄像头机位来分,不能把所有帧随机塞进训练集和验证集。同一段视频里相邻帧高度相似,随机划分会让验证集指标虚高,真实场景一测就崩。按片段划分之后,训练集和验证集才是真正不同的数据。
数据集配置文件 fatigue.yaml 可以这样写:
path: /data/fatigue_dataset train: images/train val: images/val nc: 3 names: 0: open_eye 1: closed_eye 2: yawn如果你的 labels 和 images 路径不对,训练过程会直接报错。最常见的问题是目录名写错,或者 labels 里的 txt 比 images 里的图片少。训练前先数一下两个文件夹的数量,能省很多排查时间。
3.2 训练参数怎么调
训练代码可以这样写:
from ultralytics import YOLO model = YOLO("yolov10n.pt") model.train(data="fatigue.yaml", epochs=80, imgsz=640, batch=16, device=0, patience=15)眼睛是典型的小目标。如果摄像头视角覆盖整个驾驶舱,640 输入下人脸不到 100×100 像素,眼睛更小,直接训练很容易漏检。我的建议是优先做人脸裁剪的两阶段方案;如果坚持单模型做一体检测,imgsz 至少提到 960,并提前评估显存占用。下面是几个常用参数的参考值:
| 参数 | 我常用的值 | 说明 |
|---|---|---|
| epochs | 50~100 | 看早停,不需要硬跑满 |
| imgsz | 640~960 | 目标越小,输入分辨率越高 |
| batch | 4~16 | 显存不够就调小 |
| patience | 15 | 验证指标不涨就早停 |
| device | 0 | 不写就是 CPU,会很慢 |
为什么选 YOLOv10n 这种轻量版?主要是为了让演示视频保持流畅。如果你更关心准确率,可以换 s 或 m 版本,但眼睛目标本身很小,模型太轻也可能学不好。优先保证输入分辨率,再考虑模型大小。
3.3 训练过程监控与模型选择
训练时观察 loss 和 mAP。YOLOv10 会输出每一轮的结果,重点看 mAP50、mAP50-95、Precision 和 Recall。对疲劳检测来说,漏掉一次闭眼比多报一次误报更危险,所以 Recall 比较重要。如果 mAP50 还在 0.9 以下,一般先去补数据、改标注,而不是继续加训练轮数。
训练完会生成 best.pt 和 last.pt。我的习惯是两个都保留。best 是按验证集挑的,但偶尔 last 在真实场景里反而更稳,因为验证集的图片不代表所有光线条件。如果测试视频里效果不对,就换 last.pt 试试,比重新调参快得多。
3.4 导出模型并生成快速验证脚本
训练完成后,先用一小段没参与训练的视频或图片验证,不要直接上 UI。很多问题在模型阶段就能发现。一个最简单的验证脚本:
from ultralytics import YOLO import cv2 CLASS_NAMES = ["open_eye", "closed_eye", "yawn"] model = YOLO("runs/detect/train/weights/best.pt") img = cv2.imread("test.jpg") results = model.predict(img, conf=0.45, verbose=False) boxes = results[0].boxes for box in boxes: cls = int(box.cls[0]) conf = float(box.conf[0]) x1, y1, x2, y2 = map(int, box.xyxy[0]) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, f"{CLASS_NAMES[cls]}:{conf:.2f}", (x1, max(0, y1 - 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow("test", img) cv2.waitKey(0)如果想把模型导出给边缘设备或让桌面程序加载 ONNX,可以执行:
yolo export model=runs/detect/train/weights/best.pt format=onnx imgsz=640但课程设计或课堂演示直接用 pt 文件就行,少一层转化少一层坑。导出 ONNX 后,不同推理后端对算子的支持程度还不一样,容易遇到报错,没必要为了“看起来更专业”而提前徒增复杂度。
4. 疲劳判定算法:模型输出之后才是关键
4.1 先做逐帧状态提取
模型每秒会输出几十个检测框,不能直接拿来报警。我先把每一帧的检测框收敛成一个状态:这一帧眼睛是否闭合、嘴巴是否张大。这里需要处理一个简单但容易忽略的问题:同一只眼睛可能同时出现 open_eye 和 closed_eye 两个框,这时取置信度最高的类别,而不是两个都信。
def frame_state(boxes, conf_thres=0.45): best = {} for box in boxes: cls = int(box.cls[0]) conf = float(box.conf[0]) if conf < conf_thres: continue if cls not in best or conf > best[cls][0]: best[cls] = (conf, box.xyxy[0].tolist()) eye_closed = 1 in best yawn = 2 in best return eye_closed, yawn这样每一帧最终只留下两个布尔值。后面的滑动窗口、统计和状态机都基于这两个值,逻辑非常干净。
4.2 用滑动窗口算 PERCLOS 和哈欠次数
疲劳判定需要统计窗口。PERCLOS 的计算方法是:在最近 N 秒里,闭眼帧数占总帧数的比例。举个例子,25fps 下统计 30 秒,窗口就是 750 帧;如果闭眼帧达到 225 帧,PERCLOS 就是 30%。阈值可以设在 0.3 到 0.4 之间,低于这个阈值算提醒,高于算报警。关键是窗口长度用时间而不是随意帧数,否则换一个帧率的摄像头,行为就完全变了。
下面是一个足够用的实现:
import time from collections import deque class FatigueMonitor: def __init__(self, fps=25, window_sec=60, perclos_alarm=0.4, min_yawn_sec=0.6): self.eye_win = deque(maxlen=int(fps * window_sec)) self.yawn_times = deque() self.window_sec = window_sec self.perclos_alarm = perclos_alarm self.min_yawn_frames = int(fps * min_yawn_sec) self.yawn_ongoing = 0 def update(self, eye_closed, mouth_open): now = time.time() self.eye_win.append(1 if eye_closed else 0) if mouth_open: self.yawn_ongoing += 1 else: if self.yawn_ongoing >= self.min_yawn_frames: self.yawn_times.append(now) self.yawn_ongoing = 0 while self.yawn_times and now - self.yawn_times[0] > self.window_sec: self.yawn_times.popleft() perclos = sum(self.eye_win) / len(self.eye_win) return perclos, len(self.yawn_times)这里的 yawn_ongoing 累计嘴巴张开的帧数,只有连续张嘴超过 0.6 秒才算一次哈欠。说话时短暂张嘴不会累积成哈欠,比单纯数“有多少张嘴框”靠谱得多。如果你发现一个哈欠被模型偶尔漏检截成两段,可以把判结束的条件改成“连续 5 帧嘴没张才算结束”,稍微放宽一点。
4.3 多级状态机避免报警抖振
建议设计三档状态:正常、提醒、疲劳。不要用单个阈值直接切换,否则在阈值附近会出现忽报忽停的抖振。状态机需要加一个滞回逻辑:从正常到提醒,需要短时 PERCLOS 超过 0.3;从提醒到疲劳,需要长窗口 PERCLOS 超过 0.4 或哈欠次数达标;从疲劳回到正常,则需要持续一段时间指标回落到 0.25 以下。
| 状态 | 触发条件 | 界面动作 |
|---|---|---|
| 正常 | PERCLOS < 0.3 且哈欠 < 2 | 绿色 |
| 提醒 | 0.3 ≤ PERCLOS < 0.4 或哈欠 ≥ 2 | 黄色提示 |
| 疲劳 | PERCLOS ≥ 0.4 或哈欠 ≥ 3,持续数秒 | 红色 + 声音告警 |
这样既不会对一次两秒的闭眼立刻放大,也能把连续哈欠积累起来。实际落地时,你可以在 UI 线程里维护一个当前状态变量,只在状态发生跳变时触发声音,避免每一帧都重复报警。
4.4 误报抑制与鲁棒性
疲劳检测系统最大的敌人是误报。如果模型没检测到眼睛,可能是眼睛被墨镜遮住、驾驶员侧头、低头,也可能只是漏检。此时不要把“没检测到眼睛”当成闭眼,否则驾驶员一低头就会误报。应该单独维护一个状态:当没有检测到眼睛时,这一帧既不是睁眼也不是闭眼,不计入闭眼比例。
连续帧去抖也很实用。比如连续 5 帧里至少有 3 帧判为闭眼,才把当前帧标记为闭眼。这样模型偶尔抖一下不会影响统计结果。激光点、反光、眼镜框偶尔被误检成 closed_eye,配合去抖逻辑就能过滤掉大部分。
还有一个容易被忽视的点:yawn 类别如果只在嘴巴张大的框上训练,说话或大笑时也会被检测到。把哈欠最小时长设到 0.5 秒以上,并且标注时尽量区分大幅张嘴和普通说话张嘴,可以有效降低误报。
5. UI 界面与系统集成:把模型变成能演示的产品
5.1 UI 设计思路
演示系统至少要有一个实时画面、一个状态显示、一个启动/停止按钮和一组阈值设置。技术选型上,PyQt5/PySide6 最适合,视频刷新和控件交互都比较顺;Tkinter 能写,但图像显示要自己转格式,频繁刷新时容易卡。考虑到项目标题里明确要了 UI 界面,下面以 PyQt5 为例。
布局可以做成:左侧视频画面,右上状态灯和指标数字,右下启动/停止按钮和阈值输入。视频显示直接用 QLabel 绑 QPixmap,检测线程把 OpenCV 的 BGR 帧转成 RGB,再转成 QImage 显示。
重要:不要在 UI 主线程里跑模型推理。摄像头读取、模型预测、疲劳统计全是阻塞操作,放进主线程会导致窗口无响应。用 QThread 跑循环,用 pyqtSignal 把画面和状态发回主线程刷新。
5.2 线程化推理核心代码
下面是检测线程的核心骨架。它循环读摄像头、调用模型、更新疲劳统计,然后把画面和统计结果通过信号发送给 UI。
from PyQt5.QtCore import QThread, pyqtSignal import cv2 from ultralytics import YOLO CLASS_NAMES = ["open_eye", "closed_eye", "yawn"] class DetectThread(QThread): frame_ready = pyqtSignal(object, dict) def __init__(self, model_path, source=0, parent=None): super().__init__(parent) self.model = YOLO(model_path) self.source = source self.monitor = FatigueMonitor(fps=25, window_sec=60) self._stop = False def run(self): cap = cv2.VideoCapture(self.source) while not self._stop: ok, frame = cap.read() if not ok: continue results = self.model.predict(frame, conf=0.45, verbose=False) boxes = results[0].boxes eye_closed, yawn = frame_state(boxes) perclos, yawn_n = self.monitor.update(eye_closed, yawn) status = decide_status(perclos, yawn_n) info = { "perclos": round(perclos, 3), "yawns": yawn_n, "status": status, } self.frame_ready.emit(frame, info) cap.release() def stop(self): self._stop = True self.wait()在 UI 里只需要:
thread = DetectThread("best.pt", 0) thread.frame_ready.connect(self.update_ui) thread.start()如果 UI 刷新跟不上推理速度,建议不要每一帧都无限发信号,可以在线程里加一个最小间隔,比如 0.03 秒,相当于把显示帧率限制在 30fps 左右。否则推理线程非常快的时候,UI 信号会排长队,最终表现为卡顿。
5.3 告警、日志记录和演示模式
报警动作要简单可靠:状态变成疲劳时,界面切换红色边框、放大“请停车休息”文字,并播放声音。代码里用一个标志位检测状态是否从非疲劳切到疲劳,只在边沿触发一次声音,不要每一帧都响,否则报警声会把人吵晕。
日志记录非常重要。每一帧或每 0.5 秒写一行 CSV,包含时间戳、PERCLOS、哈欠次数和状态。后面复盘时,只有看到日志才能回答“为什么当时没有报警”这类问题。
import csv import time def write_log(log_path, perclos, yawn_n, status): with open(log_path, "a", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow([time.strftime("%Y-%m-%d %H:%M:%S"), perclos, yawn_n, status])演示的时候不要把命运压在真实摄像头上。准备一小段模拟疲劳驾驶的测试视频,把源码里的VideoCapture(0)改成视频文件路径,现场驱动有问题也不会影响效果。真实摄像头演示时,尽量把机位固定在驾驶台偏高的位置,镜头稍微往下压,确保眼睛和嘴巴一直出现在画面里。
6. 常见问题与排错手册
这套项目里我实际踩过不少坑,下面整理成速查表,按症状查就行。
6.1 训练指标低或完全不收敛
先检查标签和图片是否对应。最常见的问题是文件名不一致,或者 labels 里混入旧类别 id,导致 yaml 的 names 对不上。把 txt 内容打印出来,对照图片像素宽高看坐标是否正常。还有一个容易忽略的点:训练集和验证集按帧随机打散,导致两边来自同一段视频,mAP 很高但真实摄像头表现很差。按视频片段划分数据后,这个假象会立刻暴露。
如果数据没问题,接下来看目标大小。眼睛区域在整个画面里占比可能不到 1%,imgsz=640 下就只有十几个像素,模型很难学会准确判断睁闭。这时候要么降低检测范围,要么做两阶段人脸裁剪。
6.2 实时推理卡顿
如果 UI 里视频明显卡顿,先确定卡在模型推理还是 UI 刷新。可以在检测线程里打印每帧推理耗时。YOLOv10 轻量版在 CPU 上并不能保证 30fps,尤其你同时还开着摄像头、画着框、写着日志。
常用解决思路:模型换小、输入分辨率降到 640 或更低、推理帧率限制到 15 到 20fps、UI 只显示最新帧而不是排队显示。日志也不要每帧都写,写得太频繁会让程序越来越慢。
6.3 摄像头打不开
摄像头打不开大部分是 index 不对。0 打不开就试 1 或 2,有些设备还可能是 2。另外检查摄像头权限,其他程序占用摄像头也会导致 OpenCV 打不开。还有一个很容易踩的坑:在 UI 主线程里提前创建了 VideoCapture,再传给检测线程,结果两边冲突。最好把cv2.VideoCapture(self.source)放在检测线程的 run 方法内部初始化。
6.4 UI 界面卡死或退出崩溃
UI 卡死通常是主线程里做了推理或读摄像头。另一个常见错误是关窗时没有结束检测线程,线程还在跑,窗口销毁后又往 UI 发信号,程序直接崩溃。在窗体的closeEvent里先调用detect_thread.stop(),再执行accept()关闭窗口,基本能解决。
如果线程已经卡住,stop()里还会调用wait()等待线程退出,所以一定要确保 run 方法里的循环能在_stop置位后及时跳出,不要在 run 里再写一个无限阻塞等待。
6.5 误报漏报与最后的经验小结
误报漏报不要一上来就调阈值。先保存错误帧,分析是标注问题、数据分布问题还是阈值问题。比如白天效果不错,晚上乱报,那基本是训练数据缺少夜间样本,不是后处理能解决的;比如每次张嘴说话都报警,那是哈欠判定太宽松,可以把 min_yawn_sec 调大一点。
最后分享一个我实际做这个项目时印象很深的教训:有一版模型在训练集和验证集上的 mAP 都很高,但演示时只要驾驶员低头看手机就会报警。原因是模型漏检后,我把“没检测到眼睛”当成闭眼来统计。改成只看明确的 closed_eye 检测结果之后,误报立刻降下去了。模型和逻辑配合很重要,但更重要的原则是:不确定的检测结果,不要硬凑成疲劳证据。