☰
司机危险驾驶行为识别告警系统:从YOLOv5到PyQt5的完整实现
2026/9/27 23:05:46 网站建设 项目流程

简介:这是一份基于深度学习的司机危险驾驶行为识别与告警系统完整Python项目,源码配备GUI界面和详细注释,主要面向计算机相关专业毕业生、课程设计及期末大作业需要完整实战项目的学习者。项目围绕驾驶员打电话、抽烟、打哈欠、分神等危险行为构建检测流程,集成人脸检测、表情识别与SSD目标检测等模块,可帮助理解从模型训练到界面交互的完整工程实现。压缩包共64个文件,包含Python源码(py/pyc)、预训练模型和权重文件(pth/h5/hdf5/npy)、测试图片(jpg)、演示视频(mp4)、GUI界面文件(ui/xml)及说明文档等,整体约212.52MB,目录结构便于按模块查阅。项目源于高分毕业设计,评审分98分,目前已有274人学习下载,适合作为毕设参考和深度学习项目实战练习的完整范本。

1. 司机危险驾驶行为识别告警系统:先解决“机器替你盯人”这道题

一个车队调度室同时挂了三十路行车记录仪画面,安排专人盯着屏幕看谁在打瞌睡,头五分钟还有效,半小时后全是视觉噪音。基于深度学习实现的司机危险驾驶行为识别告警系统,就是把这件反人性的工作交给机器:视频流逐帧分析驾驶员的脸部和手部,遇到闭眼、打哈欠、低头、玩手机这类危险行为,GUI 界面立刻弹告警并留存影像证据。它适合三类人:做车联网或车队安全管理的开发者,想做深度学习和 GUI 结合的课程设计或毕业设计的学生,以及想把手头检测模型接到上位机界面的工程师。它解决的问题不是一个“模型能识别猫狗”的演示,而是“视频从进来开始,怎么检测、怎么判定、怎么告警、怎么让人看见并反应”的完整链路。

2. 危险行为定义与数据准备:先让计算机知道“要盯的是什么”

2.1 行为判定矩阵:不同危险动作要对应不同的视觉证据

很多人一上来就把所有危险行为塞进一个多分类网络里,训练集又小又乱,最后模型把打哈欠和低头混在一起。我在实际项目里惯用的拆法是按视觉证据分三类:

行为类型典型动作判定依据告警输出建议
疲劳类闭眼、打哈欠人脸关键点算 EAR、MAR疲劳预警
姿态类低头、视线偏离头部姿态欧拉角 pitch/yaw分心预警
操作类手持手机、吸烟、喝水目标检测框之间的空间关系高风险告警

这样分级的原因在于时间尺度完全不同。闭眼半秒钟就足够触发预警,低头可能持续几分钟,手持手机和吸烟则要求目标检测的框足够稳定。如果都用同一个多分类模型去判决,要么把短暂动作漏掉,要么把连续动作反复告警,最后只能靠后处理救场,还不如一开始就拆开。

2.2 数据集从哪里找:公开集起步,自己补一轮差异样本

公开的开源驾驶行为数据集是常见的初版选择,例如早期常被引用的 State Farm 分心驾驶识别数据集,已经按手机通话、操作中控、喝水、正常驾驶等类别分好。但我不会拿它直接训练最终模型。公开数据集的摄像头多位于驾驶室正前方固定角度,而实际部署时摄像头可能装在仪表台偏左、偏上甚至 A 柱附近,视角一变,人脸关键点坐标和手部遮挡关系都会整体偏移。

自己采一轮数据是必须做的。让不同司机分别做正常驾驶、看导航、打电话、喝水、抽烟的动作,每人五到八分钟。条件有限至少覆盖不同身高和坐姿,这直接决定了检测框的上下位置范围。标注建议直接用 YOLO 格式,每帧一个 txt,每行一条class cx cy w h。这里有个容易踩的坑:类别设计。一开始只标了phone和smoke,结果模型把喝水也分到phone,因为手柄形态太接近。后来把drink单列为一类,误报降了三分之一。类别边界先想清楚:喝水、手持手机、吸烟三者画面相似,业务上不需要区分时直接合并成“手持异物”也行,同时要保证标注标准一致。

训练前先统计类别分布,避免某个行为样本过少导致模型压根不学它。

import os from collections import Counter def count_yolo_classes(labels_dir, class_names): """遍历YOLO格式标注目录,统计每个类别的目标数量""" counter = Counter() for file_name in os.listdir(labels_dir): if not file_name.endswith(".txt"): continue path = os.path.join(labels_dir, file_name) with open(path, "r") as fp: for line in fp: cls_id = int(line.strip().split()[0]) counter[class_names[cls_id]] += 1 return counter class_names = ["normal", "phone", "smoke", "drink", "yawn"] counter = count_yolo_classes("datasets/train/labels", class_names) total = sum(counter.values()) # 用类别频率的倒数做损失权重,缓解样本不均衡 weights = { name: min(10.0, total / (count * len(class_names))) for name, count in counter.items() } print("类别数量:", dict(counter)) print("建议loss权重:", weights)

这段代码的用途是给训练配置里的 loss 权重做参考。min(10.0, ...)是限制稀缺类别的权重不能无限放大,否则训练早期会出现震荡。实际操作中更稳的做法是把normal类权重压到 1.0,稀缺类别权重设在 2 到 5 之间,而不是直接采用纯反比计算结果。

2.3 模型选型:检测和关键点是两条线,不是一个模型干完

这个项目的模型链路不是“一个网络识别所有行为”。常见可靠做法是分成两条线:目标检测模型负责人脸、手、手机、烟的框,用 YOLOv5s 这类轻量模型就够,PC 上能跑到实时帧率;人脸关键点模型负责输出眼睛、嘴巴、鼻尖等坐标,因为疲劳判定需要亚像素级别的稳定性,检测框的顶点坐标不够用。

为什么不直接训练一个端到端的视频分类模型?因为实时性不好做。端到端视频分类要累积几十帧才有稳定的判断,而关键点加状态机每帧都能算出相对量,告警延迟能控制在几百毫秒内。另外,换了摄像头角度时,关键点模型的标定逻辑几乎不用改,端到端模型通常要重新采数据训练。

算力要考虑清楚。普通 PC 上同时跑 YOLOv5s 和 68 点关键点模型,CPU 可以勉强到 15 帧,有 GPU 可以流畅到 30 帧。如果部署目标是 Jetson 这类边缘设备,检测模型导出 TensorRT FP16,关键点模型用 ONNX Runtime,才能保证车载场景下的稳定性。标题里带 Python 源码,先把 YOLOv5 训练跑通,再谈边缘优化更现实。

3. 模型训练与行为判定链路:从检测框到“该不该告警”

3.1 训练目标检测模型:先跑通 YOLOv5 最小流程

把标注好的数据整理成 YOLOv5 能吃的目录结构,然后写一个数据配置文件driver_behavior.yaml。

# driver_behavior.yaml train: datasets/train/images val: datasets/val/images nc: 5 names: ["normal", "phone", "smoke", "drink", "yawn"]

接下来用官方训练脚本跑一轮。

python train.py --img 640 --batch 16 --epochs 80 \ --data driver_behavior.yaml \ --cfg models/yolov5s.yaml \ --weights yolov5s.pt \ --hyp data/hyps/hyp.scratch-low.yaml

--img 640是在精度和速度之间最常见的折中,再大就明显掉帧率。--batch 16按显存调整,8G 显存建议降到 8。--epochs 80对行为类别不多、背景相对固定的项目够用,不是说越多越好。如果看训练日志发现验证集 loss 在第 40 轮左右开始反弹,基本就是过拟合,直接回调到 50。--hyp hyp.scratch-low.yaml用较低的数据增强强度,因为驾驶室场景光线变化大,增强太激进反而让模型学到假的纹理。

训练完用best.pt而不是last.pt。这轮训练里最容易翻车的是类别名带空格,YOLOv5 读配置时会把名字切开,类别对不上,推理时全是错标签。

3.2 人脸关键点与欧拉角:EAR、MAR、低头角怎么算

目标检测只给了人脸框,疲劳判定还需要眼睛和嘴巴的关键点。以 68 点模型为例,左眼取索引 36 到 41,右眼取 42 到 47,嘴巴外轮廓取 48 到 59。眼睛长宽比 EAR 和嘴巴长宽比 MAR 的计算方式如下。

import math def eye_aspect_ratio(eye): """输入为单眼6个关键点坐标,返回眼睛长宽比EAR""" v1 = math.dist(eye[1], eye[5]) v2 = math.dist(eye[2], eye[4]) h = math.dist(eye[0], eye[3]) return (v1 + v2) / (2.0 * h) def mouth_aspect_ratio(mouth): """输入为嘴部8个关键点坐标,返回嘴巴长宽比MAR""" v1 = math.dist(mouth[2], mouth[10]) v2 = math.dist(mouth[4], mouth[8]) h = math.dist(mouth[0], mouth[6]) return (v1 + v2) / (2.0 * h)

EAR 的闭眼参考阈值在 0.2 附近,MAR 打哈欠阈值约 0.6,但这两个数会随摄像头安装高度和司机眼部轮廓变化。我把它们做成 GUI 里的可调参数,而不是写死在代码里。低头判定看的是头部姿态的 pitch 角,当 pitch 小于负 15 度时判定低头。具体正负方向取决于关键点模型坐标系,第一次接入时先打印一段正常驾驶的 pitch 均值确认方向。与其纠结绝对值,不如记录正常驾驶时的 pitch 基线,用相对偏移判断低头行为,换司机时不用重新调参数。

3.3 告警状态机:连续帧确认加冷却时间

疲劳告警最怕“一秒响一下”。单一帧的 EAR 低于阈值不代表人真的在闭眼,可能是光线抖动或关键点飘了一下。合理做法是连续多帧确认,告警后加冷却时间。

class AlertStateMachine: """对单个危险行为做帧级去抖和防重复告警""" def __init__(self, trigger_frames=10, cooldown_sec=3.0, fps=30): self.trigger_frames = trigger_frames # 连续多少帧判定有效 self.cooldown_frames = int(cooldown_sec * fps) self.frame_count = 0 self.last_alert_frame = -self.cooldown_frames def update(self, hit, frame_id): """hit 表示本帧该行为是否成立,返回 alert / standby / ok""" if hit: self.frame_count += 1 if (self.frame_count >= self.trigger_frames and frame_id - self.last_alert_frame >= self.cooldown_frames): self.last_alert_frame = frame_id return "alert" return "standby" else: # 未命中时帧计数回落但不清零,防止边缘抖动 self.frame_count = max(0, self.frame_count - 2) return "ok" fatigue_machine = AlertStateMachine(trigger_frames=10, cooldown_sec=3.0, fps=30)

trigger_frames=10意味着一帧闭眼到告警的最坏延迟约 10 帧,也就是三分之一步。冷却时间 3 秒是防止同一轮疲劳反复弹窗。未命中时计数器减 2 而不是清零,是因为一次打哈欠可能被张嘴幅度的小波动分成两段,彻底清零会导致第二次误报。这个细节是调试中一点点磨出来的,看起来很不起眼,但直接决定告警体验是“烦人”还是“可信”。

3.4 实时链路:三个线程各干各的,别挤在一起

完整的实时推理链路建议拆成采集、推理、消费三个部分,中间用队列连接。采集线程只负责读摄像头,推理线程从队列取帧做检测和判定,GUI 线程只拿结果做展示。

import queue import threading import cv2 frame_queue = queue.Queue(maxsize=2) result_queue = queue.Queue(maxsize=2) def capture_loop(source): cap = cv2.VideoCapture(source) while True: ok, frame = cap.read() if not ok: continue # 队列满则丢旧帧,保证画面是实时的 if frame_queue.full(): frame_queue.get() frame_queue.put(frame) def infer_loop(): frame_id = 0 while True: frame = frame_queue.get() dets = detector(frame) # YOLO检测结果 faces = keypoints(frame, dets) # 关键点坐标 hit = judge_fatigue(faces) # 综合EAR/MAR/姿态 state = fatigue_machine.update(hit, frame_id) frame_id += 1 result_queue.put((frame, dets, state))

maxsize=2是为了让队列只保留最新两帧,推理速度跟不上时直接丢弃旧帧,而不是越积越多导致画面延迟越来越大。视频流处理里“丢帧保实时”是常规操作,比阻塞等待更合适。结果队列的消费端在 GUI 线程里,只负责刷新画面和触发告警提示,不参与任何模型计算。

4. GUI 告警界面:让系统不只是“跑在终端里”

4.1 界面框架怎么选:PyQt5 不是唯一答案,但最顺手

GUI 框架选择直接决定开发效率。我长期用的是 PyQt5,核心原因是 QThread 和信号槽让推理与界面天然解耦,QLabel 显示视频帧只是最基础能力,后续想加统计图表、轨迹回放也都有现成组件。Tkinter 上手快,但做多线程刷新时要自己处理线程安全,实时视频一卡一卡的。PySide6 是 PyQt5 的现代替代品,API 基本一致,适合 Python 3.10 以上环境。

框架实时视频刷新多线程安全性界面复杂度上限上手成本
PyQt5/PySide6流畅,QTimer 精确信号槽机制完善高中等
Tkinter可用但易卡需要自己加锁低低

如果这个系统最后要拿去演示或给非技术同事用,直接选 PyQt5,省下的调试时间远比学习成本值。

4.2 主界面布局:视频、状态、告警记录、参数四个区

常见布局是左侧 16:9 视频区,右侧从上到下放四个区块:当前状态标签、告警记录列表、检测结果置信度、参数调节区。参数区至少放 EAR 阈值、MAR 阈值、低头角度阈值、冷却秒数这四个,方便在演示现场直接调阈值看反应。把阈值做成可调不是懒,是因为司机个体差异真的很大,固定参数的系统换个人坐进去就翻车。

4.3 QThread 把推理放后台,界面只负责显示

直接在 QTimer 回调里跑推理是 GUI 卡死的第一原因。推理一次 50 到 300 毫秒,这段期间主线程无法处理窗口事件,画面就冻结。正确做法是把推理放进 QThread,通过信号把结果发回主线程。

from PyQt5.QtCore import QThread, pyqtSignal import cv2 class DetectWorker(QThread): frame_ready = pyqtSignal(object, object) # (图像, 检测结果) def __init__(self, source=0, parent=None): super().__init__(parent) self.source = source self._running = True def run(self): cap = cv2.VideoCapture(self.source) while self._running and cap.isOpened(): ok, frame = cap.read() if not ok: continue boxes, labels = self.detect(frame) self.frame_ready.emit(frame, (boxes, labels)) cap.release() def stop(self): self._running = False

信号槽的机制是跨线程安全的:frame_ready信号在主线程里被槽函数接收,等于把图像从工作线程安全地送回了界面线程。这里有个很容易犯的错,在run()里直接调用label.setPixmap(),看起来能跑,但偶尔会崩溃,而且很难复现。所有界面操作都走信号槽,别绕过。

4.4 告警联动:界面闪烁、语音播报、日志留痕

告警不能只是弹一行字,现场使用需要三种通道同时触发。界面层把状态标签切到红色并闪烁几次,用 QTimer 控制闪烁次数。语音层播放预生成的 WAV 片段,比如“请勿疲劳驾驶”“检测到吸烟”,比实时 TTS 稳定且不占 CPU。数据层把告警写入 CSV 或 JSONL,方便事后回放。

import csv import time def write_alert_log(path, behavior, conf, source): """每行一条告警记录,字段含时间、行为、置信度、视频源""" with open(path, "a", newline="", encoding="utf-8") as fp: writer = csv.writer(fp) writer.writerow([ time.strftime("%Y-%m-%d %H:%M:%S"), behavior, round(conf, 3), source ])

日志文件名按天拆分,避免单文件无限增长。写日志的动作不要直接放在告警回调里,一旦磁盘慢会拖累主线程,通常做法是丢进一个独立小队列,由后台线程统一落盘。

5. 避坑与排查:从“能跑”到“不误报”的 5 个坎

5.1 检测框乱跳:先看 NMS 与置信度,不是模型不收敛

现象:检测框不停抖动,人脸框一会儿大一会出现,关键点跟着漂移,告警状态在正常和危险之间反复横跳。

原因:检测模型对遮挡和低头姿态天然不稳定,但更多时候是推理时的置信度设得太低,模型把一些模糊背景也当成了目标。NMS 阈值太高时,同一个人脸会同时保留两个框。

解决:把检测置信度从 0.25 提到 0.45 以上,NMS IoU 取 0.5,对相邻帧的检测框再做一次指数平滑。牺牲 5% 的召回率换稳定性,对告警系统非常值。

5.2 GUI 界面卡死:推理绝不能放在主线程

现象:界面打开后拖动窗口发卡,关闭视频时直接假死。

原因:把检测函数写在了 QTimer 的回调里,每次推理阻塞主线程几十到几百毫秒,界面事件全部排队。

解决:把推理挪进 QThread,详情参考第 4.3 节。关闭窗口时先stop()线程,再释放摄像头资源,否则下次启动时摄像头端口还在被占用。这个顺序错一次,下次运行就打不开视频流,排查半天才想起来是释放顺序的问题。

5.3 光线突变集体误报:逆光与隧道是重灾区

现象:车辆刚进隧道,系统连续报三四个“疲劳”,出隧道又报一轮。

原因:亮度剧变让关键点坐标整体偏移,EAR 和 MAR 随噪声变化剧烈,固定阈值基本失效。

解决:预处理里加自适应直方图均衡化 CLAHE,同时让关键点模型输出置信度,置信度低于阈值的关键点不参与判定。还可以统计大窗口内的亮度均值,检测到突变后暂停疲劳类告警 1 秒,只保留目标检测类告警。真实疲劳行为的亮度变化是平缓的,不会因为暂停一秒就被漏掉。

5.4 固定阈值不通用:小眼睛和高矮坐姿都绕不过去

现象:司机 A 一坐进驾驶室,系统立刻报“闭眼”;司机 B 明明在打哈欠,系统没反应。

原因:EAR 固定阈值 0.2 对不同眼型、不同摄像头安装高度完全不适用。眼睛天生小的人 EAR 本来就低,等于一开机就处于“闭眼”状态。

解决:系统启动后做一个两分钟的个人基线标定,统计正常驾驶时的 EAR 均值,实际判定阈值设为均值乘 0.7 或 0.75。换司机时重新标定,不修改任何代码。这个方案的代价是系统开机后有两分钟静默期,但换来的误报下降非常明显。

5.5 手摸方向盘被当成手持电话:判定维度太单一

现象:司机双手扶方向盘过弯时触发“手持电话”告警。

原因:判定逻辑只用了“手部框与脸部区域重叠”这一个条件,方向盘在画面里恰好也接近人脸区域。

解决:引入手机目标类别,只有“手部框与手机框重叠”才判定手持电话。手部框与脸部重叠时,还要同时检出手机才会触发。如果手机类别不可用,就降级为“低头/视线偏离”预警,不上升到高风险告警。分级处理比一刀切精准得多。

提示:以上五条没有一条是数据量不够造成的,多数是判定逻辑和线程模型的问题,调试时别一股脑去采集更多数据。

6. 用一段真实驾驶视频做回归测试,比刷论文指标更管用

6.1 回归测试怎么设计:用小样本“告警曲线”取代主观感觉

训练完别急着看 mAP。我会准备一段两到三分钟的真实驾驶视频,包含匀速驾驶、变道、闭眼五秒、低头看手机几秒、正常聊天几个片段,手动记录真值行为发生的时间段,再跑一遍系统,对比系统告警事件与真值的时间重叠度。统计两类指标:该报的有没有报出来,不该报的有没有误报。

一次测试就把阈值暴露得很清楚。闭眼漏报时看的是 EAR 中间值和阈值差多少,如果中间值只比阈值高 0.03,说明阈值需要抬一点;如果误报多,看集中在哪个行为,多半是类别边界没画清。

6.2 一个我一直沿用的技巧:告警灵敏度三档热切换

把每个行为的trigger_frames做成三档灵敏度:高灵敏度 6 帧、中档 10 帧、低档 15 帧,在 GUI 里用下拉框随时切换。演示时先用中档,现场评价说反应慢了就切到高档,说误报多了就切回低档。这比每次改代码改重启高效得多,也让系统能适配不同车队的管理风格。

我在这上面吃过亏:有一版把疲劳检测调到了零误报,结果连续两天系统一次警都没报,查历史视频才发现司机频繁闭眼,只是所有告警都被“稳健”的阈值滤掉了。后来我再也不追求零误报,而是保留灵敏度档位让使用者自己权衡。识别告警系统的价值不是永远不出错,而是把值得人关注的异常挑出来。希望帮到你。

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

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

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

立即咨询