从COCO JSON到YOLO:疲劳驾驶行为数据集标注解析与训练避坑指南

发布时间:2026/10/2 8:34:21
从COCO JSON到YOLO:疲劳驾驶行为数据集标注解析与训练避坑指南 简介面向计算机视觉与智能驾驶安全领域研究者这份疲劳驾驶行为数据集以COCO JSON格式提供完整标注官方覆盖21668张图片可用于训练驾驶员状态检测、注意力识别等模型。压缩包内共2000个文件其中1997张JPG图像对应不同驾驶姿态与表情特写3个JSON文件分别保存目标检测所需的类别、边界框与关键点信息整体约789.26MB。数据包含专注、昏昏欲睡、闭眼、张嘴、打哈欠、睡着、不打哈欠等多种行为状态样本分布较均衡适合作为疲劳预警系统的训练集或基准测试集。已有706人学习下载。标注结构清晰类别标签与图片文件名一一关联便于直接接入Detectron2、MMDetection等常用框架省去自行整理标注的时间。1. 疲劳驾驶行为数据集21668 张图背后先读懂 COCO JSON 再谈训练拿到这套疲劳驾驶行为数据集第一件事不是急着训练而是把 COCO JSON 格式标注里的标签结构拆开看一遍。21668 张图片听着不少但里面「闭眼」和「睡着」会同时出现「张嘴」和「打哈欠」也有大量重叠如果拿到手就直接当互斥类别去跑目标检测模型会在「专注」和「昏昏欲睡」之间反复横跳。这套数据要解决的是 DMS驾驶员状态监控里的核心问题把面部状态变成可量化的检测信号供 yolov8 这类模型训练使用。适合正在做驾驶员状态识别、想用现成标注数据起步的工程师也适合做数据治理的人——把这一套 COCO JSON 的数据结构和检查流程吃透后续换任何行为数据集都能少走弯路。2. 读懂 COCO JSON 标注三个顶层字段与行为类别的两层设计2.1 COCO JSON 的字段结构images、annotations、categories 怎么对应COCO 格式之所以成为目标检测生态里的事实标准是因为 labelimg、cvat 这类标注工具都能导出detectron2、mmdetection 也原生支持读取。它本质是一个 JSON 数组套对象的纯文本文件不用专用软件普通文本编辑器或者一段 python 脚本就能打开这也是 json 格式在数据集交换里最方便的地方——工具链通吃人也能读。一个完整的 COCO JSON 顶层只有三个字段对应三张「表」顶层字段类型存放内容images数组每张图片一条记录包含 id、file_name、width、heightannotations数组每个行为框一条记录包含 id、image_id、category_id、bbox、areacategories数组行为类别定义包含 id、name、supercategory三者的关联方式是annotations 里的 image_id 指向 images 里的 idcategory_id 指向 categories 里的 id。也就是说一张图可以有多条 annotation行为类别可以有多个靠 id 做外键关联而不是把类别名直接写进标注里。我一般拿到 dataset 的第一步就是写一段最简脚本把三个数组的长度和类别统计打出来先确认数据和描述对得上import json from collections import Counter with open(annotations.json, r, encodingutf-8) as f: coco json.load(f) img_id2name {img[id]: img[file_name] for img in coco[images]} cat_id2name {cat[id]: cat[name] for cat in coco[categories]} print(图片数:, len(coco[images])) print(标注框数:, len(coco[annotations])) print(类别数:, len(coco[categories])) ann_per_cat Counter() img_per_cat Counter() seen set() for ann in coco[annotations]: cid ann[category_id] ann_per_cat[cid] 1 key (ann[image_id], cid) if key not in seen: seen.add(key) img_per_cat[cid] 1 for cid in sorted(cat_id2name.keys()): print(f{cat_id2name[cid]}: 实例数 {ann_per_cat[cid]}, 覆盖图片数 {img_per_cat[cid]})这段代码里有一个容易写错的细节统计「每个类别覆盖多少张图片」时不能用set.add直接往 Counter 值上追加我见过不少人在这一步踩坑。正确做法是先把(image_id, category_id)组合去重再计数。输出结果能直接看出闭眼、打哈欠这类动作类别的实例数通常远多于睡着这种整体状态类别这就是后面训练要处理类别不均衡的依据。2.2 行为类别的层级状态与动作不能当互斥标签用这套数据集的行为类别可以分成两层整体状态层和局部动作层。整体状态描述驾驶员的清醒程度比如「专注」「昏昏欲睡」「睡着」局部动作描述面部细节比如「闭眼」「张嘴」「打哈欠」「不打哈欠」。这两层在语义上根本不是互斥的——一个人完全可以在「昏昏欲睡」状态下同时「闭眼」和「打哈欠」。COCO JSON 里的 categories 常常是把这七个行为平铺成一个列表看起来像互斥分类实际标注时同一张图会给同一个驾驶员挂多个框。比如「打哈欠」那条 annotation 的 bbox 框住嘴部「张嘴」那条又框住同一个嘴部两个 category_id 不同框还几乎重叠。转换到 YOLO 这种默认单标签格式时这个问题就暴露出来了。我在实际落地时一般按任务目标选一条路线如果只关心是否疲劳就把整体状态做组合标签例如「睡着闭眼」合成一个「沉睡」类「专注」单独一类如果下游要做分阶段预警就保留动作层做多标签检测但训练时要改损失函数支持多标签。有一套思路是把「不打哈欠」作为「打哈欠」的负样本类留着用来做二分类——但检测模型里同时出现「打哈欠」和「不打哈欠」两个互相否定的类别训练时类别间距离会很别扭我建议这类数据拿出来做分类任务更合适。注意COCO 的 bbox 格式是 [x, y, width, height]x、y 是框左上角坐标不是中心点。转换脚本写反是出现错框最高频的原因。3. 把 COCO JSON 转成 YOLO 格式转换脚本与四个边界坑3.1 转换脚本从 bbox 到归一化中心坐标COCO JSON 虽然生态好但 yolov8 训练自己的数据集时默认读的是每张图一个 txt 的 YOLO 格式所以 COCO JSON 转 YOLO 是绕不开的一步。转换逻辑不复杂把 bbox 的 xywh 换算成中心点坐标 cx、cy 和宽高再分别除以图片宽高做归一化类别 id 从 COCO 的 category_id 映射成从 0 开始的 YOLO 索引。import json import os CLASS_NAMES [ focused, drowsy, eyes_closed, mouth_open, yawning, sleeping, not_yawning ] def coco_to_yolo(coco_json_path, out_dir, class_names): with open(coco_json_path, r, encodingutf-8) as f: coco json.load(f) # 用 file_name 的 basename 做输出文件名统一路径格式 img_map {img[id]: img for img in coco[images]} cat_map {cat[id]: cat[name] for cat in coco[categories]} cat_id2idx {cat[id]: class_names.index(cat[name]) for cat in coco[categories]} anns_by_img {} for ann in coco[annotations]: anns_by_img.setdefault(ann[image_id], []).append(ann) os.makedirs(out_dir, exist_okTrue) for img_id, anns in anns_by_img.items(): info img_map[img_id] w, h info[width], info[height] base os.path.splitext(os.path.basename(info[file_name]))[0] txt_path os.path.join(out_dir, base .txt) with open(txt_path, w, encodingutf-8) as f: for ann in anns: cat_name cat_map.get(ann[category_id]) if cat_name not in class_names: continue # 类别不在目标列表里直接跳过不写坏行 cls_idx cat_id2idx[ann[category_id]] x, y, bw, bh ann[bbox] if bw 0 or bh 0: continue # 零宽高框没有意义 cx (x bw / 2.0) / w cy (y bh / 2.0) / h nw bw / w nh bh / h # 越界框先裁剪回 [0,1]避免训练时读取异常 cx min(1.0, max(0.0, cx)) cy min(1.0, max(0.0, cy)) nw min(1.0, max(0.0, nw)) nh min(1.0, max(0.0, nh)) f.write(f{cls_idx} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}\n) if __name__ __main__: coco_to_yolo(annotations.json, yolo_labels, CLASS_NAMES)代码里三个关键点一是 category 映射按 name 查找而不是按 categories 列表的下标顺序因为 COCO 的 category_id 经常从 1 开始直接拿数组下标当 YOLO 类别索引会让整个类别错位二是输出 txt 的 basename 要和图片文件名严格一致YOLO 训练就是靠文件名把图片和标签配对的三是越界裁剪放在归一化之后保证坐标永远在 [0,1] 区间内。3.2 训练集划分按图随机是数据泄漏的温床21668 张图如果按 8:1:1 划分训练集约 17000 多张看起来够用。但这里有一个容易被当成玄学、其实是标准数据泄漏的问题图片大概率是从视频里抽帧来的同一个驾驶员同一段行程的连续帧如果被随机拆进训练集和验证集模型记住的是背景和衣服不是疲劳状态验证集指标会虚高。import json import random with open(annotations.json, r, encodingutf-8) as f: coco json.load(f) # 按视频片段分组需要 images 里有 video_id 或 person_id 字段 # 没有的话可以按 file_name 的目录前缀分组 video_groups {} for img in coco[images]: # 假设 file_name 形如 videos/01/frame_0001.jpg video_key img[file_name].rsplit(/, 2)[0] video_groups.setdefault(video_key, []).append(img[id]) video_keys list(video_groups.keys()) random.seed(2026) random.shuffle(video_keys) n_train int(len(video_keys) * 0.8) n_val int(len(video_keys) * 0.1) train_videos video_keys[:n_train] val_videos video_keys[n_train:n_train n_val] train_ids set() for vk in train_videos: train_ids.update(video_groups[vk]) val_ids set() for vk in val_videos: val_ids.update(video_groups[vk]) print(f训练片段 {len(train_videos)} 个图片 {len(train_ids)} 张) print(f验证片段 {len(val_videos)} 个图片 {len(val_ids)} 张)如果数据集的 images 表里没有 video_id 字段我一般会按 file_name 的目录层级做分组。代价是验证集图片分布可能和全局不一致但换来的是验证指标的可靠性。训练好之后要保留这部分划分脚本后面补数据、洗数据时还会反复用到。3.3 转 YOLO 时最容易碰到的四个边界坑第一个坑是类别 ID 映射错位。JSON 里的 annotation 经常出现 category_id 不在 categories 定义里的情况或者 categories 列表里 id 不是连续的。直接拿category_id减一当 YOLO 索引轻则训练报错重则整个类别错乱但 loss 还在降。解决办法就是上面脚本里的做法先按 name 建映射表转换时遇到未知 id 就跳过并计数最后看报告。第二个坑是 bbox 越界和零宽高。标注框坐标偶尔会超出图片范围或者因为标注员手抖把框缩成一条线。YOLO 对越界标签宽容度低轻则警告重则 loss 跳动归一化后必须做裁剪并在转换日志里统计出越界框的总数方便回头修数据。第三个坑是 file_name 路径格式不统一。COCO JSON 里 file_name 可能是完整相对路径也可能是纯文件名还可能带反斜杠。YOLO 训练是按文件名匹配的标签文件和图片文件不在同一个目录层级就会全部丢失。我习惯在转换时统一取 basename同时检查是否有同名文件冲突。第四个坑是多标签框重叠。同一张图里「张嘴」和「打哈欠」的框几乎重叠如果两个类别都保留YOLO 训练时同一个位置会有两个相近的标签模型输出会两头摇摆。我一般按业务优先级做合并比如「张嘴」在哈欠发生时让位给「打哈欠」只保留语义更明确的那个类别。4. 标注质量检查统计脚本找出错标、漏标和越界框4.1 统计类别分布与空图先摸清 21668 张的家底训练前跑质量检查脚本是最值得花的半个小时相当于给自己吃一颗后悔药——等训练到一半发现某个类别一张图都没有那才是真的难受。检查脚本要覆盖三个点图片总数和标注总数是否对得上、每个类别有多少实例和覆盖图数、有没有一张标注都没有的空图。import json from collections import Counter with open(annotations.json, r, encodingutf-8) as f: coco json.load(f) cat_map {cat[id]: cat[name] for cat in coco[categories]} img_ids_with_ann set() category_counter Counter() image_counter Counter() bad_cat_ids set() for ann in coco[annotations]: cid ann[category_id] img_ids_with_ann.add(ann[image_id]) category_counter[cid] 1 image_counter[cid] 1 if cid not in cat_map: bad_cat_ids.add(cid) print(标注框总数:, len(coco[annotations])) print(有标注的图片数:, len(img_ids_with_ann)) print(无标注图片数:, len(coco[images]) - len(img_ids_with_ann)) empty_images [img[file_name] for img in coco[images] if img[id] not in img_ids_with_ann] if empty_images: print(空图示例前 10 张:, empty_images[:10]) if bad_cat_ids: print(非法 category_id:, bad_cat_ids) for cid, cnt in category_counter.most_common(): name cat_map.get(cid, fUNKNOWN_{cid}) print(f{name}: 框数 {cnt}, 图片数 {image_counter[cid]})注意 category_counter 和 image_counter 这里我用了同样的递增逻辑严格来说 image_counter 应该对 (image_id, category_id) 去重但这里把它当作「每个类别的空图数下限」先看着后面细查再用去重逻辑。重点是排查空图和非法类别 ID这两类问题不解决后面全是无效训练。4.2 检查 bbox 越界、零面积与解析异常标注文件是手工或者半自动生成的出现坐标问题几乎不可避免。最常见的几类框的右边界超出图片宽度、下边界超出高度、宽或高为 0、面积字段和 bbox 计算不一致。有的数据集还会在 JSON 里带上 segmentation 多边形多边形坐标也可能越界。import json with open(annotations.json, r, encodingutf-8) as f: coco json.load(f) img_size {img[id]: (img[width], img[height]) for img in coco[images]} errors [] for ann in coco[annotations]: iid ann[image_id] if iid not in img_size: errors.append((orphan_annotation, ann[id], iid)) continue w, h img_size[iid] x, y, bw, bh ann[bbox] if bw 0 or bh 0: errors.append((zero_box, ann[id])) if abs(ann.get(area, 0) - bw * bh) 1e-3 and segmentation not in ann: errors.append((area_mismatch, ann[id], ann.get(area), bw * bh)) if x -1 or y -1 or x bw w 1 or y bh h 1: errors.append((out_of_bound, ann[id], ann[image_id], x, y, bw, bh, w, h)) print(发现的异常标注数:, len(errors)) for err in errors[:50]: print(err)检查脚本里我留了 ±1 像素的容差因为有些标注工具会在边界上多出 1 像素的精度误差直接判越界会误伤。如果异常标注超过总数的 1%建议先回退修改标注而不是在训练时靠 ignore 参数硬扛。4.3 自动筛查加人工抽检把可疑框画出来看脚本能找出数值异常但「框位置偏了半个脸」这种语义错误必须靠人眼。我习惯的做法是写一个可视化脚本把检查出来的疑似异常框画到原图上导出到一个单独的 check 目录然后按类别抽 50 到 100 张图轮流看一遍。import cv2 import json import os with open(annotations.json, r, encodingutf-8) as f: coco json.load(f) img_map {img[id]: img for img in coco[images]} cat_map {cat[id]: cat[name] for cat in coco[categories]} out_dir check os.makedirs(out_dir, exist_okTrue) # bad_ids 是上一节检查脚本产出的异常 annotation id 集合 bad_anns [ann for ann in coco[annotations] if ann[id] in bad_ids] for ann in bad_anns[:200]: info img_map[ann[image_id]] img_path info[file_name] if not os.path.exists(img_path): print(图片不存在:, img_path) continue img cv2.imread(img_path) if img is None: continue x, y, bw, bh [int(v) for v in ann[bbox]] cv2.rectangle(img, (x, y), (x bw, y bh), (0, 0, 255), 2) label cat_map.get(ann[category_id], unknown) cv2.putText(img, label, (x, max(0, y - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1) out_path os.path.join(out_dir, f{ann[image_id]}_{ann[id]}.jpg) cv2.imwrite(out_path, img) print(f导出 {min(len(bad_anns), 200)} 张可疑图到 {out_dir}/)这个可视化脚本还有一个隐藏价值画红框时如果发现「框住的是整张脸而不是嘴部」「框里只有半只眼睛」说明标注规范执行不一致这类问题脚本查不出来但人眼扫一眼就能看出来。看完之后把反馈整理成两条哪些类别需要重新规范哪些框需要删除。5. 避坑疲劳行为检测训练时的五个常见翻车点5.1 翻车点一专注样本压垮了哈欠检测loss 降但召回为零训练日志里 loss 一直在降但验证集上「打哈欠」「睡着」的召回率趋近于零这是这类数据集最典型的翻车现象。原因在于类别分布极不均衡——专注状态的图片占比可能超过一半闭眼次之打哈欠和睡着的样本非常少。模型学到的策略是全部预测成多数类loss 依然很低。解决思路分三层第一层是采样训练时对少数类过采样把每个 batch 里各个类别的图片比例拉平第二层是损失函数给少数类别加权重或者直接换成 focal loss降低易分类样本的 loss 贡献第三层是回到数据层面按类别统计分布后找标注方确认是否需要补标。注意不要用简单的随机过采样太多轮哈欠样本反复出现会让模型过拟合我一般把采样倍率控制在 3 倍以内。5.2 翻车点二数据增强把「闭眼」增强成「张嘴」训练时开了随机旋转、随机裁剪或者 mosaic结果验证集上闭眼和张嘴互相误报。原因很直接随机旋转 90 度或 180 度会改变头部姿态语义随机裁剪会把嘴部框裁掉一半mosaic 拼接时小目标的 bbox 被缩到几个像素标签还在但内容已经不是原来的嘴了。解决的办法是给增强策略分层。行为检测这种细粒度任务我一般只开水平翻转、亮度抖动、轻微缩放和模糊旋转角度控制在 ±10 度以内不开 mosaic。如果一定要用 mosaic要保证参与拼接的小图里目标面积占比不低于原图的一半并且拼接后重算 bbox面积低于阈值的直接丢弃。5.3 翻车点三同一行为两种标法类别内部互相打架打开标注文件发现「张嘴」和「打哈欠」的框高度重叠甚至有人把「不打哈欠」标成「闭嘴」。这不是模型的问题是标注规范没有定义清楚类别边界。原因通常是标注员对「张嘴到什么程度算哈欠」理解不一致或者有人按整个面部画框有人只框嘴部。解决的办法是训练前做一次类别语义归并把业务上不关心的子类别合并。比如「张嘴」在哈欠场景下本来就是哈欠的一部分直接合并进「打哈欠」「不打哈欠」如果只在分类任务里用检测训练时先去掉。合并后重新统计每个类别的框数确认没有某个类别变成空集再训练。5.4 翻车点四json 和图片目录不同步训练时大量标签丢失训练启动后日志里刷出大量「no labels found」的 warning图片找到了但标签是空的。原因多半是中途清理过图片或者标注方重新导出过一轮 JSON但 images 表里还残留了已经删除的 file_name。这类问题在 21668 张图级别很容易发生因为文件数量大人工根本查不过来。解决的办法是在转 YOLO 之前做一次同步核对把 images 表里的 file_name 和实际图片目录里的文件做差集把 labels 目录里的 txt 和图片目录做差集两边都为空才算同步完成。这个脚本不值得单独存直接写在转换脚本的前半段就行。5.5 翻车点五单帧检测结果在视频里疯狂抖动检测模型在单张图片上表现尚可接到视频流里发现状态在「专注」「昏昏欲睡」之间来回跳人眼一看就是误报。原因是单帧分类的置信度通常在 0.7 到 0.9 之间30fps 下只要有 10% 的帧误判输出状态就会高频抖动。这个问题不是换模型能解决的需要在时间维度做平滑。解决的办法是加一个滑动窗口投票维护最近 15 到 30 帧的检测结果窗口内某个状态占比超过阈值才输出。这个方案我在下一章给出具体实现它几乎是所有疲劳报警项目上车的必经步骤。6. 从单帧检测到连续状态判定一套可落地的疲劳报警方案6.1 用检测结果计算 PERCLOS 与哈欠频率疲劳驾驶行业里最常用的量化指标是 PERCLOS即单位时间内眼睛闭合时间所占比例。实际落地时我用简化版把检测模型输出的「闭眼」框对应到每一帧统计一个滑动窗口内闭眼帧的占比。窗口长度按照视频帧率设定30fps 下一般取 90 帧也就是 3 秒超过 40% 就触发一级疲劳报警。打哈欠频率同理统计窗口内「打哈欠」类别的帧数连续 1 分钟内出现 2 次以上就认为存在疲劳倾向。嘴部 bbox 的宽高比变化也可以用来辅助判断哈欠但直接用类别检测结果更省事前提是打哈欠类别的召回率已经调到位。6.2 状态平滑与上线前的验证习惯单帧检测结果直接输出会抖动我习惯用一个带缓冲的投票器做平滑这是疲劳状态输出能不能上车的分水岭from collections import deque class StateSmoother: def __init__(self, window15, threshold0.6): self.buf deque(maxlenwindow) self.threshold threshold def update(self, state_id): self.buf.append(state_id) if len(self.buf) self.buf.maxlen: return None # 窗口未满暂不输出防止启动阶段误报 best max(set(self.buf), keyself.buf.count) if self.buf.count(best) / len(self.buf) self.threshold: return best return None窗口大小要和帧率匹配30fps 下 15 帧就是 0.5 秒阈值 0.6 意味着至少 9 帧一致才输出。阈值太低平滑不掉抖动太高会让真实疲劳状态延迟输出我一般从 0.6 起步实车录制一段含正常驾驶和疲劳驾驶的视频反复调。验证方法也很重要把一段连续视频按帧序推理把每个窗口输出的状态画成时间线和人工标注的疲劳时段对照。我在做这类项目时习惯保留每一轮的统计脚本和可视化结果任何状态跳变都能回放找出是哪一帧引起的误判。这套流程多跑几轮比盲目调模型参数有效得多。希望帮到你。本文还有配套的精品资源点击获取