简介:这是一套基于 PyQt5、YOLOv5 与 Dlib 的驾驶员行为监控系统课程设计完整源码,面向计算机视觉、深度学习和 GUI 开发方向的在校生与开发者,可支撑课程设计、毕业设计或驾驶安全预警原型搭建。系统利用 YOLOv5 实时检测人脸与头部姿态,结合 Dlib 精准定位眼睛、嘴巴等关键点,识别闭眼、张嘴、低头等危险行为并触发警报。压缩包共 105 个文件、约 224MB,其中 66 个 Python 源码覆盖视频处理、行为识别、界面与报警逻辑,4 个 ui 文件对应 PyQt 界面,pt、t7、dat 提供模型权重及人脸关键点模型,yaml/json 等为配置参数,bat 脚本支持快速启动运行,另有 mp3 提示音和说明文档,目录结构清晰。已有 273 人学习浏览。借助该资源,可得到一套直接可运行的项目方案,掌握 YOLOv5 与 Dlib 的工程整合方法,并能自行调整模型路径、界面布局和检测阈值,为扩展更多驾驶行为识别打下基础。
1. 驾驶员行为监控系统到底在监控什么:先拆清 yolov5 与 dlib 的分工
驾驶员行为监控系统在课程设计里通常不是让你真的去造一个量产车规级产品,而是把三样东西串起来:yolov5 负责检测手机、手部、水杯这些“分心因素”,dlib 负责从人脸 68 点里算出闭眼、打哈欠、低头的疲劳信号,pyqt 则把两路结果汇到一块屏幕上并触发声光报警。这个标题挑得好,因为它是典型的“检测 + 状态估计 + 界面集成”三段式,刚好覆盖计算机视觉和 GUI 编程的核心知识点。适合三类人:要做课程设计或毕设的学生、想快速搭一套行为分析演示原型的工程师,以及想把 yolov5 和 dlib 各自跑熟再串起来的自学者。整套系统做好之后,演示效果很直观:司机一闭眼超过阈值,界面立刻弹报警,这也是为什么它是答辩热门题目。当然,真正的挑战不在单个模型的精度,而在三套工具的生命周期、线程模型和推理延迟怎么互相兼容,这篇文章就按这个顺序讲透。
2. 整体架构与数据流转:yolov5 管手、dlib 管脸,pyqt 做那个“看得见”的壳
2.1 先定边界:yolov5 不负责“看脸”,dlib 不负责“看手”
新手最容易犯的错是试图让 yolov5 一个人把所有活干完:既检测手机,又检测眼睛闭合状态。yolov5 本质是目标检测网络,它能告诉你“手机在图像里哪个位置”,但给不了你“眼睛闭了多少”这种连续状态量——那是关键点估计(landmark estimation)的领域。dlib 恰好补上这一环:它通过 68 个人脸关键点,能在 2D 图像上稳定追踪眼睛轮廓和嘴唇轮廓,进而计算 EAR(Eye Aspect Ratio)和 MAR(Mouth Aspect Ratio)。
所以在我的方案里分工很明确:
| 组件 | 负责的检测 | 输出形式 | 时序要求 |
|---|---|---|---|
| yolov5 | 手机、手部靠近脸部、水杯 | 类别 + 置信度 + 边界框 | 单帧推理约 15-30ms |
| dlib | 人脸关键点、头部姿态 | 68 点坐标、EAR/MAR/Pitch/Yaw | 单帧约 5-10ms |
| pyqt | 汇总两路结果、判定规则、UI 报警 | 界面状态、声音提示 | 30FPS 刷新即可 |
这个划分不是拍脑袋。yolov5 对“人手里的手机”这类语义目标非常敏感,因为它见过的标注数据里手机就是被手攥着的;但你要是让它检测“眼睛睁开 0.7 毫米”,它就没有直接监督信号了。反过来,dlib 的人脸关键点在戴墨镜、侧脸时会乱飘,但你还是得靠它做疲劳判定,因为这是整套系统里唯一一个不在 GPU 上也能跑得动关键点估计的方案。两者串行跑在同一个视频流上,互不干扰,是课程设计里性价比最高的组合。
2.2 系统文件结构与各个模块的职责
我会把项目根目录整理成下面这种结构,每个文件夹只干一件事,后续出问题也不用到处翻文件:
driver_monitor/ ├── main.py # pyqt 入口,创建主窗口和所有管理器 ├── detector/ │ ├── yolo_detector.py # 封装 yolov5 的 detect 逻辑,返回 DetResult │ ├── dlib_worker.py # 封装 dlib 人脸检测 + 关键点 + 头部姿态 │ └── rule_engine.py # 疲劳/分心判定规则,帧序列投票 ├── ui/ │ ├── main_window.py # pyqt 主窗口,布局和刷新 │ └── alarm.py # 声光报警模块 ├── weights/ │ ├── best.pt # yolov5 训练好的权重 │ └── shape_predictor_68_face_landmarks.dat ├── datasets/ │ └── driver_behavior/ # 训练数据合集,下章详述 └── runs/ # yolov5 训练输出,results.csv 在这里每个模块在代码里就是两个角色:yolo_detector 负责把一帧图像变成“有没有分心”的结构化证据(比如在屏幕中央偏右位置出现手机,置信度 0.82);dlib_worker 负责把同一帧图像变成“疲劳信号”(左眼 EAR=0.18,嘴部 MAR=0.61,Pitch=-22 度);rule_engine 则把这两类证据按时间序列整合,输出最终报警指令。
2.3 为什么需要 rule_engine 而不直接 if 判断
课程设计做到后期你会发现,单帧报警会让系统乱叫:司机正常眨眼的 300ms 内 EAR 也会跌破阈值,低头看仪表盘的一瞬间 Pitch 也会超限。所以规则引擎必须做成“连续 N 帧投票”而不是“单帧触发”。我用的规则是:疲劳报警需要连续 10 帧(约 0.3 秒)EAR 低于 0.25 且 MAR 高于 0.6;分心报警需要连续 8 帧检测到手机类别置信度大于 0.7。
判断逻辑可以写在 rule_engine.py 里,类初始化维护一个双端队列,每帧推入结果,同时弹出超过窗口长度的旧帧:
# rule_engine.py 要点:滑窗投票判定 from collections import deque class RuleEngine: def __init__(self, window_frames=10): self.window_frames = window_frames self.ear_history = deque(maxlen=window_frames) self.phone_history = deque(maxlen=8) def feed_frame(self, ear_value, mar_value, has_phone, pitch_angle): self.ear_history.append(ear_value < 0.25 and mar_value > 0.6) self.phone_history.append(has_phone) if sum(self.ear_history) >= self.window_frames: return "fatigue" if sum(self.phone_history) >= 8: return "distraction" return None这段代码的核心在于deque(maxlen=N)天然维护滑动窗口,当新数据加入且超过长度时自动丢弃最老的元素,sum直接给出窗口内满足条件的帧数。这样不会因为单帧抖动误报警,也不会在司机已经放下手机后还持续报警 2 秒。手指头敲完这段,基本就理解了什么叫“状态机”而不是“一次性判断”。
3. 用 yolov5 训练自己的驾驶员数据集:从标注到调参的完整命令
3.1 标注数据与格式转换:VOC 到 YOLO 格式的转换脚本
课程设计场景下你想分类的往往是手机、水杯、烟盒、手部靠近面部这几类。不要指望直接下载一个现成的模型就能识别这些——通用模型你没准能检出 person 和 cell phone,但它不太可能稳定地给出“bottle 在方向盘上”这种驾驶语义。所以你需要自己标一小批数据。
常见做法是用 labelImg 或 labelme 标完,导出 VOC 格式的 XML,再转成 YOLO 要的 txt 标注文件。转换脚本是这种格式的门槛,我一般这样写:
# voc2yolo.py:把 labelImg 导出的 XML 转成 YOLO txt import xml.etree.ElementTree as ET import os classes = ["phone", "bottle", "hand", "smoke"] def convert_voc_to_yolo(xml_file, output_dir): tree = ET.parse(xml_file) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) yolo_lines = [] for obj in root.findall("object"): cls_name = obj.find("name").text if cls_name not in classes: continue cls_id = classes.index(cls_name) bbox = obj.find("bndbox") x_min = float(bbox.find("xmin").text) y_min = float(bbox.find("ymin").text) x_max = float(bbox.find("xmax").text) y_max = float(bbox.find("ymax").text) x_center = (x_min + x_max) / 2 / img_w y_center = (y_min + y_max) / 2 / img_h width = (x_max - x_min) / img_w height = (y_max - y_min) / img_h yolo_lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") if yolo_lines: base_name = os.path.splitext(os.path.basename(xml_file))[0] with open(os.path.join(output_dir, base_name + ".txt"), "w") as f: f.write("\n".join(yolo_lines))这个脚本干的活就是把 XML 里的绝对像素坐标归一化到 0~1 区间。注意一个坑:YOLO 坐标中心点(x_center, y_center)是相对图像宽高的比例,不是像素值,新手在这里经常不除img_w和img_h,导致训练时 loss 一开始就巨大且不下降。转换完记得随机找三张图,把 txt 里的坐标再画回原图确认一遍,这一步能省掉后面一晚上的排查时间。
3.2 训练命令与超参数选择:主要调什么,次要调什么
我用的是 yolov5 官方仓库的 7.0 分支。在命令行里训练自己数据集,最精简的启动命令是:
python train.py --data driver.yaml --weights yolov5s.pt --epochs 100 --batch-size 16 --imgsz 640 --hyp hyp.scratch-low.yaml --cachedriver.yaml里写数据集路径和类别数,核心内容大概是:
train: ./datasets/driver_behavior/images/train val: ./datasets/driver_behavior/images/val nc: 4 names: ['phone', 'bottle', 'hand', 'smoke']超参数重点盯几个:imgsz在驾驶场景下不要盲目用 1280,分辨率越高显卡越吃紧,课程设计里 640 足够,手机在画面上通常大于 40x40 像素,能检出来;batch-size按你显存来定,6G 显存跑 16 就合适,跑不动就降到 8,不要硬上;epochs在数据量只有几百张的情况下 100 轮足够,多了必过拟合。真正影响精度的隐藏参数是hyp.scratch-low.yaml里的lr0(初始学习率)和mosaic增强概率。yolov5 里mosaic=1.0默认开启,但驾驶舱背景高度相似,mosaic 合成图会和真实验收场景差异很大,我一般会改成 0.5。这些参数不在界面上显示,改完要重读 yaml 才能生效。
另一个值得留意的训练异常是 loss 曲线“假收敛”:如果你发现验证集 mAP 很高但实测视频里手机频繁漏检,常见原因是训练图和测试图存在数据不平衡——比如白天样本是 80%,晚上样本只有 20%。课程设计里建议直接固定一个时间段的数据采集,别尝试做全天候系统,那是产品级要做的事。
3.3 训练完的产物怎么看:results.csv 是黑匣子外唯一的窗户
训练结束不要只看results.png那张趋势图。runs/train/exp*/results.csv才是你能量化判断的依据。我把关键列列出来对比看:
| 指标列 | 正常值参考 | 如果异常 |
|---|---|---|
train/box_loss | 0.03~0.06 持续下降 | 不降或上升说明学习率不对 |
val/obj_loss | 0.02~0.05 | 过拟合时会回升 |
metrics/mAP_0.5 | > 0.85 | 数据不够或标注错误 |
metrics/precision | > 0.8 | 误报多时看它 |
有一个血泪经验是不要只看mAP_0.5,要看mAP_0.5:0.95,它对框的精度要求更苛刻。驾驶场景手机遮挡多,如果你的mAP_0.5很高但mAP_0.5:0.95低于 0.5,说明你的检测框经常半准不准,后面接的规则引擎会被“置信度够但位置偏”的框带偏。真遇到这种情况,优先检查标注框是不是没贴合目标边缘——换 labelImg 的“自动吸附多边形”工具重标一圈,通常比改模型参数见效快。
4. 基于 dlib 的疲劳与分心判定:68 点关键点、EAR/MAR 与头部姿态阈值
4.1 EAR/MAR 的计算:公式别抄错,边界点别取错
dlib 的预训练模型shape_predictor_68_face_landmarks.dat会输出人脸的 68 个关键点,其中左眼用点 42~47,右眼用点 36~41,嘴巴用点 61~67。EAR 的经典公式是:
EAR = (||p2 - p6|| + ||p3 - p5||) / (2 * ||p1 - p4||)对应到 dlib 索引上,左眼的 p2、p6 是点 43 和 47,p3、p5 是 44 和 46,p1、p4 是 42 和 45。写代码时最容易把垂直距离算成两两之间的距离和再除以 3,实际上分母的 2 是固定的,分子是两个垂直距离相加。代码里我这样写:
# dlib_worker.py 片段:EAR 和 MAR 计算 def eye_aspect_ratio(eye_landmarks): # eye_landmarks 是 6 个 Point 对象,按 dlib 索引顺序传入 vertical_a = euclidean_distance(eye_landmarks[1], eye_landmarks[5]) vertical_b = euclidean_distance(eye_landmarks[2], eye_landmarks[4]) horizontal = euclidean_distance(eye_landmarks[0], eye_landmarks[3]) return (vertical_a + vertical_b) / (2.0 * horizontal + 1e-6)加1e-6是为了防止眼睛全闭时水平距离也趋近于 0 导致除零异常,这种细节在你跑真实摄像头数据时一定会遇到。MAR 则是嘴部外侧到内侧距离比上嘴部宽度,阈值可以设在 0.6 附近,但这不是固定不变的,我下面会讲抖动问题和处理办法。注意一点:dlib 关键点在侧面脸时会因为遮挡导致个别点飘,所以阈值判断前要做一个“关键点有效性检查”,即六点之间的连线长度是否在合理区间,不然侧脸一帧就会把 EAR 打到 0.1。
4.2 头部姿态估计:Pitch/Yaw/Roll 怎么算出来
疲劳检测光靠眼睛闭不闭合不够,低头是一个更强的疲劳信号。dlib 本身不给姿态角,但我可以用 solvePnP 求解人脸关键点的 3D 投影来算。常规做法是需要一个 3D 人脸模型和相机内参,课程设计可以偷个懒:用一张标准正面脸的 68 点坐标作为参考 3D 点,相机内参用近似值,反正我们只需要相对角度而不是绝对标定值。
# 头部姿态估计:用 5 个标志点做 solvePnP import cv2 import numpy as np # 3D 模型点(单位 mm),取鼻尖、下巴、左眼左角等经典位置 model_points = np.array([ (0.0, 0.0, 0.0), # 鼻尖 (0.0, -63.6, -12.5), # 下巴 (-43.3, 32.7, -26.0), # 左眼左角 (43.3, 32.7, -26.0), # 右眼右角 (0.0, 63.6, -12.5) # 额头中心 ], dtype=np.float64) # image_points 从 68 点中挑对应索引 _, rvec, tvec = cv2.solvePnP(model_points, image_points, camera_matrix, dist_coeffs) rmat, _ = cv2.Rodrigues(rvec) pitch = np.degrees(np.arctan2(rmat[1, 0], rmat[2, 0])) yaw = np.degrees(np.arctan2(rmat[1, 2], rmat[2, 2]))实现里最坑的是image_points的选取,不要用全部 68 点,只选五个角点能避免外围点抖动带来的噪声。Pitch 负值代表低头,我一般在pitch < -15时判定为低头。这个 15 度阈值没有标准答案,和摄像头装的位置关系很大:摄像头架在仪表盘上方平视司机,阈值可以小到 12 度;如果架在挡风玻璃上方俯拍,头部正常姿势 Pitch 本身就会偏负,不重新标定阈值的话系统会一直误报。
4.3 报警规则引擎:连续帧投票的阈值修正
所有 dlib 的输出(EAR/MAR/角度)都要过一遍滑窗,才可以和 yolov5 的检测结果合并一起决定报不报警。我把这部分的逻辑写在 rule_engine.py 中,并用一个辅助函数把单帧健康状态归类:
def evaluate_frame(ear, mar, pitch, has_phone, conf): fatigue_score = 0 if ear < 0.25 and mar > 0.6: fatigue_score += 1 if pitch < -15: fatigue_score += 1 if fatigue_score >= 2: return "fatigue_level_1" if has_phone and conf > 0.7: return "distraction" return "normal"evaluate_frame只是单帧判定,后面再接滑窗。滑窗的长度要按摄像头帧率适配:如果代码跑到 25FPS,10 帧窗口约等于 0.4 秒,眨眼自然的时长在 0.1~0.2 秒,不会误触发;如果检测线程跑得慢只有 10FPS,10 帧就会变成 1 秒,报警会相当迟钝。所以我习惯把滑窗长度做成参数,直接跟随实测 FPS 动态计算,而不是写死。这也是后期调系统最常翻车的地方,很多小组把 window_frames 写死为 10,结果运行环境一变,疲劳报警忽快忽慢。
5. pyqt 界面集成与线程模型:为什么你的窗口一检测就“未响应”
5.1 线程模型:QTimer 驱动 UI 刷新,检测必须在子线程
很多教程会让你直接在 pyqt 的while True循环里读摄像头并跑检测,然后你就看到窗口标题变成“未响应”。原因是主线程被推理阻塞,事件循环没法处理鼠标点击和窗口重绘。解决思路是让检测全部跑在QThread里,通过信号把结果传回主线程,主线程里只做“拿最新结果刷到界面上”。
我启动检测线程的方式是这样:
# main_window.py 要点:QThread 跑检测,信号回传 UI class DetectThread(QThread): frame_ready = pyqtSignal(dict) def run(self): while self.running: frame = self.capture.read() det_result = yolo.detect(frame) face_metrics = dlib_worker.analyze(frame) self.frame_ready.emit({ "frame": frame, "fatigue": rule_engine.feed(...), "distraction": ... })pyqt 里信号frame_ready会在子线程 emit 后由事件循环调度到主线程执行,主线程的槽函数里只做 UI 更新,不做任何耗时操作。这个模式一旦跑顺,窗口再也不会假死。
5.2 坑1:dlib 模型加载写在init里,窗口 10 秒才弹出来
现象:启动程序后主窗口长时间不出现,任务管理器里看到 Python 进程 CPU 100%。
原因:shape_predictor_68_face_landmarks.dat有接近 100MB,在冰凉的磁盘上读取加解析需要 3~10 秒,而main_window.__init__()是在主线程里跑的,它不结束窗口就不会 show。
解决:把模型初始化放到DetectThread的run()方法开头,反正检测线程启动前也不会收帧;或者在主窗口先显示一个加载提示,再用单发 QTimer 启动检测线程。我之前觉得无所谓,直到演示时评委盯着黑屏窗口等 8 秒,才把加载挪到子线程。
5.3 坑2:摄像头被占用,升级版是“OpenCV 崩溃没有任何报错”
现象:程序跑着跑着摄像头突然断连,程序直接退出或卡死无异常信息。
原因:USB 摄像头被其他软件抢占,或者分辨率让摄像头驱动无法稳定输出。pyqt 的槽函数里还在画图,底层 OpenCV 却抛了一个 C++ 异常,Python 的 try/except 接不住。
解决:在主窗口加一个“摄像头发送状态”指示灯,读取帧失败时不仅打印日志,还要把帧计数置零并自动 reconnect。我习惯的做法是每次read()之后检查返回的ret,一旦连续 3 帧失败就关闭对应cv2.VideoCapture并延迟 2 秒重新打开,这个行为放在检测线程内而不放在 UI 层,因为 UI 层做重连操作会继续卡界面。
5.4 坑3:单帧推理时间太长,QTimer 刷新不过来了
现象:主窗口用QTimer(30ms)刷新视频标签,但实际视频更新可能 300ms 才动一次。
原因:yolov5 推理在无 GPU 环境下单帧可能 40~80ms,加上 dlib 的关键点和姿态估计又是 10~20ms,数据生产速率远低于 UI 期望的刷新速率。QTimer 只管“到时间就调用一次槽函数”,但槽函数拿到的可能还是旧帧。
解决:不要在 UI 槽函数里去调检测接口,应该由检测线程主动发布“最新帧”,UI 槽函数只负责把最新一帧贴到控件里。为了不让信号积压,我通常在 emit 后检查self.frame_ready.receivers(),如果已经积压超过 2 帧就丢弃旧帧再发新的。这样 UI 的帧率会跟随检测速度动态调整,显示效果始终是连贯的,没有积累延迟。
5.5 坑4:EAR 和 MAR 的“玄学阈值”,换个人就乱报警
现象:同一套代码,测试同学 A 眼睛大、不爱眨,一切正常;同学 B 眼睛小、近视镜反光,程序全程疲劳报警。
原因:EAR 阈值是经典论文里基于理想正面光照给的值,和实际摄像头分辨率、人脸距离、戴不戴眼镜高度绑定。特别是镜片反光时 dlib 关键点会往镜框上吸,轮廓会整体变形。
解决:做 10 秒静默标定——系统启动后提示司机正面看镜头 10 秒,采集这段 EAR/MAR 的均值和方差,然后把报警阈值设成“均值减去 2.5 倍标准差”。这样每台机器、每个人都会得到自己的个性化阈值,而不是硬编码 0.25。这一招几乎能让误报率下降一半,值得写进你们的答辩 PPT。
6. 验证与调优:三张图判断你的监控系统能不能上演示台
课程设计验收时,评委最烦听到“我这模型 mAP 可高了”,裁判最想看的是“这个系统在真实视频里可靠地报警”。所以我每次做完整套系统后,会用三张图做验收:
第一张是results.csv里提取的 P-R 曲线,确认不是过拟合情况(训练 loss 降而验证 loss 升)。第二张是把一段 2 分钟驾驶视频逐帧标注“是否应该报警”,然后跑系统,画出实际报警帧和期望报警帧的对比折线。第三张是报警延迟直方图,从真实危险动作发生到界面弹出报警的秒数分布。这三张图出来,任何评委提问都打不倒你。
调优的习惯我沿用了很久:每次只改一个变量,比如把mosaic从 1.0 调到 0.5,其余不动,重新训练一轮,记录 mAP 变化。不要一上来就调三个参数,不然模型变好了你也不知道是谁的功劳——机器学习本质上就是个控制变量实验。另一条经验是 dlib 的输入框不要用整张图,先通过 yolov5 的person检测框裁剪出人脸区域再送给 dlib。这会砍掉大约 60% 的背景计算量,推理速度立刻提上来。
这套系统做完的价值不只是应付一门课程。yolov5 的训练管线、dlib 的状态估计、pyqt 的线程模型,刚好是对工业视觉系统三个典型模块的速成练习。往后你在实际项目里做质检、做安防、做交互设备,都会碰到同样的问题:什么算法负责感知,什么算法负责状态估计,界面层怎么不卡死。把这三个角色分清楚,换任何模型都只是替换接口的事。
我的经验是,演示之前一定把摄像头架稳、光照调均匀,然后让系统先跑 5 分钟不报警,证明它不是个“惊弓之鸟”。这个细节救过我一次现场翻车——那次是头顶日光灯频闪,dlib 关键点跟着闪,疲劳指标周期性跳动,差点把一场演示变成闹剧。希望这些调试路径能帮你在同样的位置少花几个晚上,毕竟课程设计的最终目标不是模型多强,而是你能讲清楚每个环节为什么这么做。希望帮到你。
本文还有配套的精品资源,点击获取