ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于YOLO11+DeepSORT的驾驶员疲劳检测系统实战详解

基于YOLO11+DeepSORT的驾驶员疲劳检测系统实战详解 简介面向驾驶安全预警与疲劳监测场景这套基于YOLO11和DeepSORT的驾驶员检测跟踪系统适合安全驾驶研究人员、算法工程师及高校相关专业学生。系统通过目标检测快速定位驾驶员面部区域再借助多目标跟踪持续识别闭眼、打哈欠、低头等疲劳状态并及时发出预警。资源包共包含九十三个文件主要有程序源码及编译文件、训练好的检测模型、目标跟踪模型、示例图像与演示视频、配置文件和操作说明文档压缩后大小约一百八十一兆字节目录清晰便于按模块查阅。目前已有六十九人学习下载。资源提供可直接部署的检测模型与配套数据集涵盖不同光照、角度和表情的驾驶员面部样本有助提升模型在真实驾驶环境中的泛化能力。完整源码、演示视频和运行说明可让使用者快速复现流程也便于二次开发或算法改进对工程项目、课程设计和毕业设计具有实用价值。1. 这套 YOLO11DeepSORT 驾驶员疲劳检测项目解决的不只是“有没有闭眼”把摄像头对准驾驶员实时检测眼睛和哈欠再判断这人是不是疲劳了——这是驾驶安全预警系统里最典型的一类需求。标题里压着的 YOLO11、DeepSORT、数据集和训练好的检测模型组合起来就是一个可以直接验证的驾驶疲劳监测方案YOLO11 负责逐帧把驾驶员的眼睛、嘴巴状态检测出来DeepSORT 负责在连续帧之间稳住同一个人的身份编号疲劳判定逻辑再根据眼睛闭合时长、打哈欠频次等指标去触发预警。适合谁刚入手驾驶员监控项目又想少走弯路的工程师做车辆安全终端想快速验证 DMS 算法可行性的团队以及在读学生拿现成数据与权重做复现研究的人。拿到这个压缩包你最该关心的是四件事数据集能不能直接训、训练好的模型跑起来准不准、DeepSORT 接进去会不会乱跳 ID、疲劳阈值设置得合不合理。这篇文章就把这四条路一步步趟开。2. 原理拆解YOLO11 检测器与 DeepSORT 跟踪器为什么这样配合2.1 YOLO11 在驾驶舱小目标场景的选型理由YOLO11 是 Ultralytics 继 YOLOv8 之后推出的检测系列和之前几代比骨干网络和颈部结构重新做了设计参数量更省推理速度更快。在驾驶舱这种固定机位、目标尺度波动剧烈人脸忽远忽近、侧脸转过去之后框变小、光照变化极强的场景里YOLO11n 或 YOLO11s 这种小体量模型通常比大模型更合适——不是大模型不准而是 DMS 要求 30 帧以上的实时性还得把算力预算留给后面的 DeepSORT 特征提取。这个项目里检测器输出的目标一般不只是“人脸”常见的类别设计是 face、open_eye、closed_eye、yawn也可能是 mouth_open 这类细粒度状态。类别划分直接决定了疲劳判定怎么往下写如果模型已经把闭眼单独训成一个类那疲劳逻辑就简单很多只需要关心 closed_eye 的置信度和连续帧数如果模型只检测人脸那你还得再加一个关键点模型或者眼部分类器。所以拿到打包好的训练好的检测模型之后第一件事永远是打开它的类别定义文件确认类别顺序这比急着跑推理重要得多。2.2 DeepSORT 跟踪器到底解决什么问题以及代价YOLO11 做的是单帧检测每一帧的检测结果互相之间没有关联。问题就来了驾驶员眨一下眼检测框轻微抖动甚至某一帧闭眼目标没检出来下一帧又出现这时候按帧去统计疲劳状态数据是碎的。DeepSORT 把检测框串成轨迹。它的核心机制有三个卡尔曼滤波预测每个轨迹在下一帧的位置外观特征用一个小型 CNN 提取并做余弦距离匹配级联匹配优先处理最近确认的轨迹再处理老轨迹。简单说YOLO11 负责回答“这一帧里哪里有目标”DeepSORT 负责回答“这个框是刚才那个人的不是另一个人”。在多乘客场景下这种人脸跟得稳不稳直接决定了后面按 track_id 统计疲劳数据的可信度。代价也很明确DeepSORT 不是免费的外观特征提取器如果在 CPU 上跑每帧要多花几十毫秒一旦视频分辨率高或者帧率要求高CPU 就成瓶颈。这也是为什么真正落地的时候很多人会把 DeepSORT 的轻量化改造提上日程关于这点后面避坑章再详细说。2.3 检测器和跟踪器的协作链路整套系统的数据流是这样走的摄像头或视频文件逐帧读入YOLO11 先做一次前向推理得到这一帧所有目标的边界框、类别和置信度把这些框按类别过滤之后转成 DeepSORT 需要的 Detection 对象交给 tracker.update()tracker 内部维护着若干条轨迹每一条轨迹有一个稳定的 track_id下游的疲劳判定模块把每条轨迹的 closed_eye 置信度、嘴巴张开幅度这些状态积累起来滑动窗口统计之后决定要不要报警。记住这个链路里有一个关键取舍跟踪器需要的是稳定的输入所以给 DeepSORT 的检测框不能照单全收。置信度低于 0.3 的框、不属于关键类别的框都应该在送入 tracker 之前过滤掉否则会出现大量短暂存在的假轨迹疲劳统计就被噪声毁掉了。3. 数据集和训练好的检测模型把推理先跑通3.1 拿到压缩包先做的三层检查这类项目打包一般都有固定结构datasets 目录下放 images 和 labels 的 train/val 划分外加一个 data.yamlruns/weights 或 weights 目录下放着 best.pt 权重文件deep_sort 目录放跟踪器源码根目录有一个主入口脚本。我拿到手一定会按这个顺序检查缺一不可第一层是检查 data.yaml 里的类别列表和数量。YOLO 训练用的标签是索引数字类别顺序直接决定推理时 cls 索引含义。比如 data.yaml 写的是0: face, 1: open_eye, 2: closed_eye那么推理结果里 cls2 的就是闭眼。很多翻车事故就是类别定义和后续判定逻辑里写死的索引对不上。第二层是抽查标签。随便取几张训练图片把对应的 txt 标签和图片重叠画出来看看框准不准。这一步能快速发现标签坐标归一化错没、有没有错标漏标。驾驶员疲劳场景常见的问题是闭眼标签框得过大把眉毛和前额都框进去这会导致模型学到的闭眼特征不纯。第三层是确认权重文件对应的是哪个模型尺寸。YOLO11n 还是 YOLO11s参数量差好几倍。推理脚本里的 imgsz 也要对应调通常 DMS 场景用 640×640 或者缩到 416太大没意义。3.2 用 YOLO11 加载训练好的检测模型最小推理代码确认完结构后先别急着接 DeepSORT用下面这段代码把模型和数据的准确性验证一遍。这里用的是 Ultralytics 的标准接口from ultralytics import YOLO model YOLO(weights/best.pt) results model.predict( sourcetest_video.mp4, imgsz640, conf0.4, iou0.5, device0, streamFalse, ) for frame_idx, r in enumerate(results): boxes r.boxes.xyxy.cpu().numpy() confs r.boxes.conf.cpu().numpy() clss r.boxes.cls.cpu().numpy().astype(int) print(frame_idx, boxes.shape, clss)这段代码的作用是跑一条完整视频输出每一帧所有检测框的坐标、置信度和类别索引。conf0.4是个值得注意的参数疲劳检测场景里闭眼目标很小置信度天然偏低如果设成 0.5 会漏掉很多真实闭眼帧但如果低于 0.3又是后面的 DeepSORT 假轨迹的重灾区。常见调法是先设 0.4 跑一遍观察漏检和误检比例再微调。imgsz640是输入分辨率。过高没有意义过低会把本来就小的眼部目标压没。streamTrue和streamFalse的区别留到 3.4 讲但这里先记住对视频文件做验证用streamFalse拿全量结果对摄像头实时流一定要用streamTrue。3.3 把检测结果转成结构化数据为接 DeepSORT 做准备上一段代码输出的只是推理结果对象DeepSORT 需要的是标准的[x1, y1, x2, y2]边界框、置信度分数、以及对应的类别索引。更重要的跟踪器需要按帧组织这些检测因为它的update()一次接收一个帧的所有检测结果。import numpy as np def yolo_results_to_detections(r, cls_ids(0, 1, 2)): 把 YOLO11 单帧结果转成元组列表供 DeepSORT 的 Detection 构造使用 detections_info [] if r.boxes is None: return detections_info boxes r.boxes.xyxy.cpu().numpy() confs r.boxes.conf.cpu().numpy() clss r.boxes.cls.cpu().numpy().astype(int) for box, conf, cls in zip(boxes, confs, clss): if cls not in cls_ids: continue # 过滤掉过小的框避免把噪声喂给跟踪器 if box[2] - box[0] 10 or box[3] - box[1] 10: continue detections_info.append((box, conf, cls)) return detections_info这里做两件事按类别过滤只保留项目关心的 face 和眼部类别过滤掉面积太小的框。眼部检测框在图像里可能只有二三十像素但小于 10 像素的框基本是虚检留着只会让 DeepSORT 建一堆假的临时轨迹。这一步本身不参与跟踪但它是连接检测器和跟踪器之间质量的第一道闸门。3.4 视频流推理的一个常见误用整段视频一次性加载进内存不少人在第一次写疲劳检测脚本时会直接对摄像头或长视频调用model.predict()然后发现内存飙升、延迟极高甚至直接卡死。因为摄像头源是无限长的流如果streamFalseultralytics 会把整个源读进内存再推理。正确做法是实时源一律streamTrue让推理以生成器方式逐帧吐出结果from ultralytics import YOLO model YOLO(weights/best.pt) results model.predict(source0, imgsz640, conf0.4, streamTrue) for r in results: frame r.orig_img detections yolo_results_to_detections(r) # 接下游处理不能在这里 break 或堆积streamTrue的另一个行为差异是它不会把整段结果保存在内存里你处理完一帧就可以释放。疲劳检测系统的核心逻辑每帧都在跑这个逐帧循环就是后续所有跟踪和疲劳判定代码的宿主框架。4. 接入 DeepSORT 跟踪器从逐帧检测到稳定的轨迹 ID4.1 DeepSORT 初始化参数到底怎么设DeepSORT 的对外接口在很多开源实现里基本一致先构造距离度量器再构造跟踪器。下面是常见写法参数含义我逐个解释from deep_sort.deep_sort import nn_matching from deep_sort.deep_sort.detection import Detection from deep_sort.deep_sort.tracker import Tracker max_cosine_distance 0.2 nn_budget 100 metric nn_matching.NearestNeighborDistanceMetric( cosine, max_cosine_distance, nn_budget ) tracker Tracker( metric, max_iou_distance0.7, max_age30, n_init3, )max_cosine_distance0.2是外观特征匹配的阈值。驾驶舱场景里目标外观变化不大阈值设太大容易把不同人匹配到同一条轨迹上设太小又会让同一个人因为转头、遮挡而被误判为新目标。0.2 是一个保守起点如果发现 ID 频繁切换可以适当放宽到 0.25。nn_budget100是保存历史外观特征的数量上限防止特征库无限膨胀。单驾驶员场景 50 就够多乘客场景建议 150 以上这个参数会直接影响内存占用。max_age30表示一条轨迹最多能忍受 30 帧没有匹配到检测框。疲劳检测场景里闭眼状态检测不稳定某人低头或侧脸丢失几帧是常事如果 max_age 太短回来之后会变成新 ID疲劳统计就断了。30 帧在 30fps 下就是一秒钟比较合理。n_init3是一条轨迹需要连续被匹配 3 帧才算确认。确认前的轨迹用于抑制假目标不参与疲劳统计。4.2 逐帧更新循环把 YOLO11 检测框喂给 DeepSORT这是整套代码里承上启下的一段把 YOLO11 的推理结果、DeepSORT 的轨迹更新、以及按 ID 维护的疲劳状态串在一起。def process_frame(frame, model, tracker, result_processor): # 1. YOLO11 推理单帧 r model.predict(frame, imgsz640, conf0.4, verboseFalse)[0] detections_info result_processor(r) # 2. 转成 DeepSORT 的 Detection 列表 detections [] for box, conf, cls in detections_info: bbox_tlwh xyxy_to_tlwh(box) # [x1,y1,x2,y2] - [cx,cy,w,h] detections.append(Detection(bbox_tlwh, conf, None)) # 3. 跟踪器预测 更新 tracker.predict() tracker.update(detections) # 4. 取出确认的轨迹按 track_id 返回 outputs [] for track in tracker.tracks: if not track.is_confirmed(): continue bbox track.to_tlwh() track_id track.track_id outputs.append((track_id, bbox)) return outputsDetection的第三个参数是外观特征向量。在标准 DeepSORT 里这里会调用一个 ReID 网络提取 128 维特征但很多打包项目为了轻量会把这个特征置空退化成 IOU 匹配模式。我建议至少用一个简单的颜色直方图或小 CNN 特征填进去不然遮挡后恢复目标的能力会明显下降这点在第 4.3 节展开。tracker.predict()必须在update()之前调用因为 predict 用卡尔曼滤波把每条轨迹预测到当前帧update 再做匹配和修正顺序反了会出现轨迹位置滞后一帧的现象。xyxy_to_tlwh是 DeepSORT 默认的锚框格式约定它的内部 tracker 和可视化代码都基于 tlwh 计算转错格式会导致跟踪框位置偏一截下面这个转换函数是标准的def xyxy_to_tlwh(bbox_xyxy): x1, y1, x2, y2 bbox_xyxy w x2 - x1 h y2 - y1 return [x1, y1, w, h]4.3 track_id 在疲劳统计里的关键用法按 ID 维护状态检测是每帧独立的跟踪的价值就是让下游逻辑有一个恒定的维度去累积数据。疲劳统计必须以 track_id 为单位而不是以“这一帧检测到的闭眼框”为单位。from collections import defaultdict fatigue_state defaultdict(lambda: {closed_frames: 0, total_frames: 0}) for track_id, bbox in process_frame(frame, model, tracker, result_processor): state fatigue_state[track_id] state[total_frames] 1 # 这里假设闭眼判定来自检测结果里的 closed_eye 置信度 closed_conf get_closed_eye_conf(frame, bbox, model) if closed_conf 0.5: state[closed_frames] 1这里有一个很容易踩的坑max_age 超时后轨迹会从追踪器里移除再出现时拿到的是全新 track_id旧 ID 的累计状态就断了。所以在疲劳判断逻辑里不建议对同一个 track_id 做超过 30 帧的长期累计而应该用滑动窗口窗口长度小于 max_age 的三分之一比如 10 秒窗口。这样即使 ID 切换窗口内的统计也不会被上一次旧轨迹污染得太厉害。5. 疲劳判定阈值和预警触发EAR、闭眼时长与打哈欠的坑5.1 眼睛状态判定EAR 公式 vs 模型直接分类疲劳判定的第一层是判断某一帧眼睛是否闭合。有两种主流做法。第一种如果 YOLO11 训练集里已经有 open_eye / closed_eye 两个类别那直接用分类置信度做时间平滑即可。优点是不需要额外模型速度最快缺点是对标签质量依赖极高闭眼类别框得不准模型就学不到纯正的闭合特征。第二种用 EAR 公式计算。如果项目里带的是人脸关键点检测器或者检测框内能稳定输出 68 点就用眼睛的 6 个关键点算纵横比def eye_aspect_ratio(eye_points): eye_points: 6 个关键点坐标顺序按 dlib 68 点索引 p1, p2, p3, p4, p5, p6 eye_points vertical_1 dist(p2, p6) vertical_2 dist(p3, p5) horizontal dist(p1, p4) return (vertical_1 vertical_2) / (2.0 * horizontal)EAR 的好处是跨个体、跨相机距离的稳定性比直接分类置信度好因为它是归一化的几何比例和人脸尺度无关。常见阈值是 0.2 到 0.25低于阈值视为闭眼。但驾驶舱相机离人脸近脸的大幅晃动会导致 EAR 抖动很厉害直接低于阈值会误触发。我见过不少项目在 EAR 低于阈值后还要求持续 3 到 5 帧才判定一次闭眼事件这个缓冲在时序逻辑里几乎是必须的。5.2 预警触发组合规则连续帧计数、PERCLOS 滑动窗口、打哈欠频率疲劳不是“某一帧闭眼”而是状态在一段时间里持续恶化。通用的做法是把三个特征做加权组合第一个是连续闭眼帧数。人在正常眨眼时眼睛闭合时间通常不超过 0.2 秒在 30fps 下也就是 6 帧左右。如果连续闭眼超过 10 帧就要进入疑似疲劳状态。第二个是 PERCLOS 指标也就是在滑动时间窗口内闭眼帧占总帧数的比例。常见窗口是 60 秒阈值可以设在 0.2 到 0.4 之间。疲劳初期闭眼比例会明显上升但还没到长时间闭眼的程度PERCLOS 能更早捕捉到这个趋势。注意这个指标对 ID 连续性要求很高如果跟踪器频繁换 ID窗口内混进多条轨迹的数据数值就失去意义了。第三个是打哈欠频次。如果模型里有 yawn 类别每分钟打哈欠超过 4 到 6 次可以作为一个强提醒信号。打哈欠的持续时间相对固定用连续 15 到 30 帧检测到 yawn 类别且高置信度来判定一次打哈欠再乘以每分钟计数。5.3 预警触发代码和参数化下面这段代码把上述规则落成可调参的模块所有阈值都通过参数传入不要在代码里写死。class FatigueAlert: def __init__(self, ear_threshold0.22, closed_min_frames8, perclos_window60, perclos_threshold0.25, yawn_min_frames20, yawn_per_minute4): self.ear_threshold ear_threshold self.closed_min_frames closed_min_frames self.perclos_window perclos_window self.perclos_threshold perclos_threshold self.yawn_min_frames yawn_min_frames self.yawn_per_minute yawn_per_minute self.eye_buffer [] self.yawn_buffer [] self.yawn_count 0 self.start_time None def update(self, ear_value, yawn_flag): # 闭眼判定加帧数缓冲 self.eye_buffer.append(ear_value self.ear_threshold) if len(self.eye_buffer) self.perclos_window * 30: self.eye_buffer.pop(0) # PERCLOS 计算 if len(self.eye_buffer) 30 * 30: perclos sum(self.eye_buffer) / len(self.eye_buffer) if perclos self.perclos_threshold: return perclos_alert # 连续闭眼帧数 closed_streak 0 for closed in reversed(self.eye_buffer[-30:]): if not closed: break closed_streak 1 if closed_streak self.closed_min_frames: return closed_alert return normal这里有几个参数值得反复调。perclos_window60太短会导致正常眨眼也被拉高比例太长又让响应变得迟钝。closed_min_frames8在 30fps 下约 0.27 秒能过滤正常眨眼又不至于漏掉真正的微睡眠。预警还要做防抖同一状态触发后至少 5 秒内不重复报警否则连续蜂鸣会让驾驶员更烦躁。实际运行中我习惯把每天的视频片段和预警日志一起记录回放时看报警时刻是不是真的对应疲劳状态这是最靠谱的调参依据。6. 避坑这套系统最常见的 5 个问题现象、原因和处理6.1 现象一开摄像头就冒出几十个假轨迹track_id 疯狂跳动原因检测器把大量低置信度框和背景噪声框也送进了 DeepSORT跟踪器把这些都当成潜在目标n_init3还没走完又发现新的框轨迹数量立刻膨胀。处理在送入 tracker 之前把置信度下限提到 0.4同时按类别过滤只保留 face 和眼部类别。另外把max_iou_distance从 0.7 调到 0.6减少进入 IOU 关联的候选框数量。假轨迹少了疲劳统计的噪声自然就跟着降下来。6.2 现象驾驶员戴上墨镜之后系统几乎不再输出闭眼状态原因训练数据里大概率没有或者极少有墨镜样本。闭眼模型依赖眼睛周围皮肤特征墨镜直接把这些特征盖住了模型只能靠上下文猜测。处理如果条件的允许优先使用带红外补光的相机红外能穿透墨镜。如果只能用可见光相机则需要做数据增强把训练集的眼部样本做亮度降低、遮挡模拟人为增加闭眼样本多样性。这时候再配合 EAR 做兜底即模型输出置信度低时切换到面部关键点的几何判定能救回一部分场景。6.3 现象同一段视频离线跑效果很好接上摄像头画面翻转过来就崩原因摄像头安装角度的镜像翻转导致数据分布变化。模型训练时看到的左右眼位置是固定的镜像之后眼睛左右关系颠倒模型的特征激活位置对不上。处理在训练阶段就加入水平翻转增强并固定摄像头的安装位置和镜像设置。实际部署时在代码入口处加一个标准化的画面方向预处理把摄像头画面校正到和训练集一致的朝向。6.4 现象实时推理帧率低于 15fps报警延迟严重原因YOLO11 和 DeepSORT 的特征提取都压在同一处理器上CPU 成了瓶颈。视频源分辨率太高时模型输入做 resize 也在浪费大量时间。处理常见做法是把 YOLO11 换成 n 型号并开启 TensorRT 导出。DeepSORT 的特征提取器改成每 5 帧才提取一次中间帧只做卡尔曼预测。实测这类组合通常能从 10fps 提到 25fps 以上代价是高频率 ID 切换时匹配准确率略降。6.5 现象闭眼类别检测召回很低疲劳状态经常漏报原因数据集中闭眼样本太少尤其是驾驶场景下大多是正常睁眼闭眼只占标注数据的百分之几。模型对这个类别的学习不充分。处理除了加样本更切实际的做法是把 loss 里的类别权重调高比如把 closed_eye 的权重设为 open_eye 的 3 到 5 倍。另一种做法是把判定逻辑的先验阈值调宽松用更高的误报换更低的漏报疲劳预警宁可多报也不能不报这和通用目标检测的调参优先级正好相反。7. 进阶离线视频回放验证和 TensorRT 加速部署系统能跑起来之后最值得投入的一件事是建立离线验证习惯。我建议录制三段典型视频一段正常驾驶、一段模拟疲劳驾驶、一段包含低头和侧脸的复杂动作。跑完后把每一帧的 track_id、眼睛闭合状态、PERCLOS 值、预警状态写进 CSV再逐帧回放比对。这样能精准定位是检测器漏了还是跟踪器换了 ID还是阈值设得不对而不是靠肉眼反复看视频找问题。统计误报率要控制在每 30 分钟不超过 2 次漏报率要压到零这是 DMS 系统最基本的底线。部署层面先把训练好的模型导出成 ONNX再转 TensorRT这一步几乎不损失精度但帧率收益非常直观from ultralytics import YOLO model YOLO(weights/best.pt) model.export(formatonnx, imgsz640, dynamicFalse, simplifyTrue) # 再在 Jetson 或带 TensorRT 的设备上转成 engine导出时注意三个参数dynamicFalse固定输入尺寸TensorRT 会做更多图优化simplifyTrue会清洗掉部分冗余算子导出后一定重新评估一下精度ONNX 转换偶尔会把某些层的运算顺序打乱尤其在检测头部分。对 DeepSORT 的轻量化常见做法有两种一是把外观特征提取网络从较大的 ReID 模型换成 MobileNet 底座的小网络二是降低特征提取频率5 帧提取一次中间帧靠卡尔曼预测和 IOU 匹配。前面避坑章提过这个方案实际落地时收益通常比替换检测器更明显因为疲劳场景特征变化慢特征更新的频率不需要那么高。还可以尝试把级联匹配顺序调整优先匹配最近确认的轨迹而不是最新出现的轨迹这在低帧率场景下能减少不少 ID 切换。最后说一个我的习惯每次改完阈值或模型我都会把预警日志里的时间戳和真实驾驶录像对上回看报警前 10 秒到底发生了什么。这套方法救了我很多次因为阈值这东西真的像玄学不拿真实片段反复验证永远不知道它在上班路上会怎么误报。希望这套 YOLO11 加 DeepSORT 的疲劳监测思路能帮你在自己的项目里少踩一遍这些坑。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进