ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

疲劳驾驶检测数据集处理:VOC/COCO/YOLO标签转换与YOLO训练避坑指南

疲劳驾驶检测数据集处理:VOC/COCO/YOLO标签转换与YOLO训练避坑指南

简介:面向从事目标检测的学习者与开发者,这套疲劳驾驶目标检测数据集以一千张真实场景图片为核心,场景覆盖多种路况与光照,且使用标注工具完成高质量标注,同时提供voc、coco、yolo三种格式的标签,切换到不同系列模型时无需额外转换。压缩包内共两千个文件,除图片与标签外,还包含六份图文教程、三个数据集划分脚本与一个配置文件;xml和txt分别对应voc与yolo标注体系,python脚本可以自动划分训练集、验证集和测试集,html文档覆盖了Windows与Linux环境搭建以及训练案例修改。压缩包整体大小约77.53MB,目前已有166人学习或下载。拿到手即可直接使用可训练的数据集、划分工具与配套教程,省去自行采集标注和反复调试环境的时间,尤其适合刚接触yolo或缺少疲劳驾驶场景训练样本的开发者按需取用,也可以在此基础上继续扩充类别与数据量。

1. 疲劳驾驶检测数据集到手,先别急着训练:盘清1000张图和三套标签才是正经事

接手疲劳驾驶目标检测项目时,我最怕的不是选模型,而是拿到手的数据集根本没法直接开训。这套YOLO疲劳驾驶目标检测数据集,1000张图片不算大,但它的价值在于把“数据集落地”最麻烦的一步提前做完了:每张图同时给了voc、coco、yolo三种格式的标签,外加划分脚本和训练教程。也就是说,你不用再花两三天写转换脚本,解压后就有条件用ultralytics或老版darknet直接跑通训练流程。适合两类人:一是0基础纯小白,想把疲劳驾驶检测从纸面落到本地;二是短工期项目,想快速验证yolo在这个场景的上限到底怎么样。我按“先盘目录、再改格式、最后调训练”的顺序,把这套包拆开讲透,坑也会逐个点名。

2. 先盘清家底:1000张图片和voc/coco/yolo标签到底怎么对应

解压之后不要急着跑训练命令,先把目录结构摸清楚。疲劳驾驶场景里,图片大多是驾驶员面部、手部或上半身,类别一般就是闭眼、打哈欠、打电话、正常驾驶这几种,有时还会细分到“打哈欠且闭眼”这种复合状态。先弄清每个文件夹的职责,后面改配置时才知道去哪里找对应文件,才知道划分脚本生成的东西喂给谁吃。

2.1 解压后的目录长什么样:rar里那堆文件夹,每个都有明确分工

常见打包结构是这样的,我一般会先用tree或ls走一遍:

. ├── JPEGImages/ # 原始图片,1000张jpg ├── Annotations/ # VOC标签,每张图对应一个xml ├── coco/ # COCO标签,通常是annotations/train.json ├── labels/ # YOLO标签,每张图对应一个txt(归一化坐标) ├── 划分脚本.py / split.py # 划分训练集和验证集的脚本 └── 训练教程.md / train.py # 训练参考说明或可直接改的启动脚本

各目录的分工很清楚:JPEGImages是所有原始图像,Annotations里是VOC格式的xml文件,coco目录里是json标注,labels里是YOLO训练直接吃的txt。划分脚本负责生成train.txt、val.txt这类清单,有些打包版本还会额外给一份data.yaml,里面写好了类别名称和路径映射。我拿到手的第一件事是数文件数:JPEGImages里jpg数量应该等于Annotations里xml数量,也等于labels里txt数量。三个数对不上,就先找原因再训练,别指望训练时报错帮你定位到具体哪张图。还有一个更隐蔽的问题:疲劳驾驶图片很多来自视频抽帧,连续帧之间画面高度相似,如果划分脚本是纯随机切分,训练集和验证集里会出现几乎一样的图,后面第4章会详细讲这个坑。先记住一个判定标准:验证集图片和训练集图片如果肉眼难分,那mAP再高也是虚高。

2.2 三种格式的设计逻辑:xml是给标注工具看的,json是给coco api看的,txt是给yolo训练吃的

先说结论:voc、coco、yolo三套标签记录的目标信息完全一致,都是类别名加边界框坐标,区别只在存储方式和坐标单位。VOC用xml,每个目标一个object节点,框坐标是像素整数;COCO用json,框写成[x, y, width, height],单位也是像素;YOLO的txt每一行是class_id x_center y_center width height,后面四个值已经除以图片宽高做过归一化,是0到1之间的小数。为什么要归一化?因为YOLO训练时输入图片会被letterbox拉伸到640x640或1280x1280,归一化坐标能保证框跟着图一起缩放,不用重新改标签。

| 格式 | 文件扩展名 | 坐标单位 | 一个目标怎么写 | | VOC | xml | 像素整数 | xmin, ymin, xmax, ymax | | COCO | json | 像素整数 | x, y, width, height | | YOLO | txt | 0~1归一化 | class_id, x_center, y_center, width, height |

表格只是概括,我举个具体例子。假设一张640x360的图里有一只眼睛框,左上角(300, 120),右下角(380, 190)。VOC里就是xmin=300, ymin=120, xmax=380, ymax=190;COCO里是[300, 120, 80, 70],因为宽是380-300=80,高是190-120=70;YOLO里x_center=(300+380)/2再除以640约等于0.53125,y_center=(120+190)/2除以360大约是0.43056,width和height也做同样处理。转换脚本的核心就是在做这件事。不过这里最容易翻车的是类别顺序:VOC和COCO记录的是类别名,比如closed_eye、yawning,YOLO文本里只写数字ID,ID对应哪个名字取决于data.yaml里names列表的顺序。如果转换脚本里把classes写成一个顺序,训练时data.yaml里又是另一个顺序,模型就会把闭眼学成打哈欠,而且这种错位在loss曲线上看不出来。

2.3 划分脚本到底划的是什么:train.txt、val.txt与图片列表的三种玩法

划分脚本的作用不是切文件,而是生成一份“哪些图片进训练、哪些进验证”的清单。最常见玩法有三种:第一种,生成train.txt和val.txt,每个txt一行一个图片路径,老版darknet和yolov5都用这种;第二种,直接在images和labels下建train/val子目录,把图片和标签物理挪过去,ultralytics的dataset结构常这么干;第三种,生成包含图片路径和标签路径的csv或json索引。三种玩法的最终目的都一样:让训练框架通过索引找到图片和对应标签。下面是最基础的一种生成清单脚本:

import os import random random.seed(42) img_dir = 'JPEGImages' label_dir = 'labels' train_ratio = 0.8 val_ratio = 0.1 imgs = [f for f in os.listdir(img_dir) if f.endswith('.jpg')] random.shuffle(imgs) n_train = int(len(imgs) * train_ratio) n_val = int(len(imgs) * val_ratio) train_imgs = imgs[:n_train] val_imgs = imgs[n_train:n_train + n_val] test_imgs = imgs[n_train + n_val:] def write_split(imgs, name): with open(f'{name}.txt', 'w') as f: for img in imgs: label = img.replace('.jpg', '.txt') if os.path.exists(os.path.join(label_dir, label)): f.write(f'{os.path.join(img_dir, img)}\n') write_split(train_imgs, 'train') write_split(val_imgs, 'val') write_split(test_imgs, 'test') print(f'train={len(train_imgs)} val={len(val_imgs)} test={len(test_imgs)}')

这段脚本按8:1:1切分,核心是random.seed(42)固定随机种子,保证每次运行生成同样的结果。write_split函数用图片文件名反查同名txt标签,只有图片和标签都齐了才写入清单。这个细节很多教程不讲:只检查图片存在而不检查标签存在,训练到一半报找不到标签文件,回头排查才发现是漏了几张没标注的图。如果你用的是ultralytics全家桶,可以不用test.txt,train和val分开给就行。切分比例上,我建议至少预留0.1给test,因为疲劳驾驶模型上线前需要一组完全没见过的图来评估泛化,而不是拿验证集凑合。

3. 从voc到yolo:动手改转换脚本之前,先把类别顺序定死

拿到包之后,先确认labels文件夹是不是已经能直接用。很多标注工具默认导出VOC,打包的人额外转了一遍YOLO,但如果转的时候类别顺序写错,或者归一化时除错了宽高,再新的训练脚本也白搭。我习惯自己从头跑一遍转换脚本,不迷信“别人已经转好”,毕竟训练跑十几个小时才发现标签错位,那才是真正的血泪经验。

3.1 为什么ultralytics吃yolo格式:data.yaml的路径规则与类别映射

ultralytics的yolov8、yolov11训练默认要求图片和标签处于同一个父目录下,图片放在images/train,txt标签放在labels/train,data.yaml里写train和val的图片路径。为什么不直接用voc或coco训练?因为yolo的损失函数期望直接拿到归一化坐标,省去训练时解析xml或json的开销;COCO的json格式还要额外依赖pycocotools算mAP,训练管线里多一个依赖就多一个坑位。所以先看data.yaml的标准写法:

# data.yaml train: ./dataset/images/train val: ./dataset/images/val nc: 4 names: ['closed_eye', 'yawning', 'phone_use', 'normal']

train和val指向图片目录,ultralytics会自动在同级的labels目录里找对应txt。nc是类别数量,必须和labels里出现的最大class_id加1相等;names的顺序就是txt里数字ID到类别名的映射。很多翻车现场都出在这里:转换脚本用旧顺序生成了txt,data.yaml里names却写成另一种顺序,于是训练时0变成打哈欠、1变成闭眼,验证集mAP看起来不低,部署后全废。yolov8以上版本会在检测到names和标签类别数不一致时报警,但不会帮你纠正语义映射,所以定names的时机要在转换格式之前,转换之后不要轻易改。

3.2 xml转txt完整脚本:归一化、类别映射、越界框裁剪一起做

下面这版脚本是我常用的VOC转YOLO方案,核心逻辑是遍历Annotations下的xml,用xml.etree.ElementTree解析每个object,读xmin、ymin、xmax、ymax,先除以图片宽高归一化,再剔除越界和无效框:

import xml.etree.ElementTree as ET import os from PIL import Image classes = ['closed_eye', 'yawning', 'phone_use', 'normal'] src_xml_dir = 'Annotations' src_img_dir = 'JPEGImages' dst_label_dir = 'labels' os.makedirs(dst_label_dir, exist_ok=True) for ann_file in os.listdir(src_xml_dir): if not ann_file.endswith('.xml'): continue tree = ET.parse(os.path.join(src_xml_dir, ann_file)) root = tree.getroot() img_name = root.find('filename').text img_path = os.path.join(src_img_dir, img_name) w, h = Image.open(img_path).size out_lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in classes: continue cls_id = classes.index(name) bndbox = obj.find('bndbox') x1 = float(bndbox.find('xmin').text) y1 = float(bndbox.find('ymin').text) x2 = float(bndbox.find('xmax').text) y2 = float(bndbox.find('ymax').text) x1, x2 = max(0, min(x1, w)), max(0, min(x2, w)) y1, y2 = max(0, min(y1, h)), max(0, min(y2, h)) if x2 <= x1 or y2 <= y1: continue x_center = ((x1 + x2) / 2) / w y_center = ((y1 + y2) / 2) / h box_w = (x2 - x1) / w box_h = (y2 - y1) / h out_lines.append(f'{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}') dst_path = os.path.join(dst_label_dir, ann_file.replace('.xml', '.txt')) with open(dst_path, 'w') as f: f.write('\n'.join(out_lines))

这个脚本有两个容易忽略的细节。第一,我用PIL读真实图片宽高,而不是信xml里的size节点。标注工具写的size经常和实际图片不一致,尤其遇到经过裁剪、压缩或转码的图,用错宽高会导致所有归一化坐标整体偏移。第二,先裁剪越界框再归一化,因为手滑把框拖出画面的情况很常见,不裁剪会出现width大于1的异常值,yolo训练时框回归会不稳定。classes列表的顺序就是后面data.yaml的names顺序,转换前必须定死,转换后不要动。另外注意,xml里filename字段有时带目录前缀或大写后缀,比如JPEGImages/001.JPG,我在真实项目里见过因为大小写不匹配导致PIL打不开图片的情况,稳妥做法是用os.path.basename处理后再拼路径。

3.3 先划分还是先转换格式:按视频段分组切分才是疲劳驾驶的正解

很多教程先转换全部标签再做随机划分,这最简单。但如果你的图片来自视频抽帧,我建议先按场景或视频段划分,再对训练集做格式转换。原因很简单:随机切分容易让同一条视频的连续帧同时出现在训练和验证集,模型等于提前看过答案,验证分数虚高。疲劳驾驶视频连续性太强,一个打哈欠动作通常涵盖几十帧,前20帧进训练、后20帧进验证,模型在验证时看到的就是训练帧的轻微位移,mAP高得没有说服力。出问题的是数据包里已经给好的划分脚本,直接跑之前要看它划分依据是什么。有的脚本是纯随机打乱文件名,有的是按时间戳采样,有的按文件夹分。如果是纯随机,且文件名里有视频段标识,就按组切分:

import random from collections import defaultdict random.seed(7) img_dir = 'JPEGImages' imgs = [f for f in os.listdir(img_dir) if f.endswith('.jpg')] groups = defaultdict(list) for img in imgs: key = img.split('_')[0] # video01_frame_001.jpg 取 video01 groups[key].append(img) group_names = list(groups.keys()) random.shuffle(group_names) n_train = int(len(group_names) * 0.8) train_groups = set(group_names[:n_train]) val_groups = set(group_names[n_train:]) train_imgs = [img for g in train_groups for img in groups[g]] val_imgs = [img for g in val_groups for img in groups[g]] with open('train.txt', 'w') as f: f.write('\n'.join(train_imgs)) with open('val.txt', 'w') as f: f.write('\n'.join(val_imgs))

这段逻辑的关键是以组为单位切分,组粒度是video01这种前缀,保证同一个视频的帧不会跨进两个集合。random.seed(7)可以换成你自己的种子,建议至少试三个种子各跑一轮,挑验证结果稳定的一次;如果三次波动特别大,说明样本量不足或类别分布不均衡,后期要针对少数类做重采样。img.split('_')[0]是按video01_frame_001命名约定写的,如果文件名是face_0612_001,拆出来的分组可能和时间段无关,这时要改用正则提取真正的视频段标识。分组划分会让验证集mAP比纯随机低0.05到0.1,但那个数字才真实反映你的模型在没见过的视频片段上的表现。

4. 疲劳驾驶数据集避坑指南:5个让训练翻车的常见问题

这个数据集看起来简单,训练起来坑比想象的多。我把这几年在疲劳驾驶检测上踩过的坑,以及从数据集角度最容易出的问题整理成5条,每条按“现象、原因、解决”的顺序写,你照这个排查能省下大量试错时间。

4.1 类别定义模糊:闭眼和正常眯眼、打哈欠和说话,边界划不清

现象:训练时loss降得很漂亮,验证mAP有0.8以上,但实际部署后正常驾驶经常被误报成闭眼,打哈欠却没识别出来。原因:疲劳驾驶的类别本身是连续状态,半闭眼到底算闭眼还是正常,不同标注员的标法可能不一致。如果voc标签里closed_eye既有完全闭合的图,也有只垂了百分之二十的图,模型就会在两类之间反复横跳。解决:先把标签全量扫一遍,把界定模糊的样本单独挑出来重新定规则。我一般会写个统计脚本,算出所有closed_eye框的长短轴比例,比如框高除以框宽小于0.25的才算闭眼,然后手动删除或改掉中间地带的样本。这个步骤没法完全自动化,但至少能帮你发现标注风格是否统一。在训练教程里看到别人把打哈欠和说话标成两类时,你要特别警惕两类边界,说话张嘴幅度大时和打哈欠几乎无法靠单帧区分,要么合并成一类,要么引入时序特征。

4.2 只有1000张图,yolo照样过拟合:类别不均衡和增强策略的问题

现象:训练到第80轮,train loss还在降,val loss开始反弹,典型过拟合。再看各类别样本数,normal占比通常过半,closed_eye和phone_use可能只有一百多张。原因:1000张对yolo这种深度模型来说只是入门量级,尤其是小目标,比如远处的人脸或手部,需要更多样本;类别不均衡会让模型倾向预测样本多的类。解决:先统计每个类别的标签数量,不要急着加增强。如果某类只有150张,优先做离线增强,包括亮度扰动、高斯噪声、随机遮挡,以及把同一类的目标区域裁剪出来拼到新背景图上。疲劳驾驶的闭眼和打哈欠对颜色不敏感,网上那些调hsv参数的增强效果很有限,把样本数量做起来比调颜色有用得多。调增强参数在疲劳驾驶场景里多少有点玄学,我一般先用基础增强跑一版,再叠加亮度抖动和随机灰度化看验证集有没有提升。

4.3 夜间、戴墨镜、侧脸样本几乎为零:验证集虚高的真相

现象:白天测试视频效果好,一到夜间或司机戴墨镜就全崩,mAP直接腰斩。原因:数据集大概率是在白天、驾驶舱正面视角拍的,夜间红外和墨镜遮挡的样本根本没进1000张,模型没见过这个分布,白天验证集测出来自然虚高。解决:不要指望一个数据集解决所有光照场景。先用划分脚本把图片按“白天、夜间、逆光、墨镜”这类条件做标记,确认分布,再决定是补数据还是训练时针对性地做灰度化和亮度抖动。灰度化对墨镜场景有一定帮助,因为模型被迫学习纹理而不是颜色,但真实夜间红外图像还得靠补数据。这个坑没有完全的后悔药,前期盘点样本分布比调任何参数都值。数据包里如果只给了1000张白天图,上线夜间场景前一定要做专门的夜间采集,哪怕只补几百张。

4.4 标签对不上图:xml与txt行数不一致、文件名后缀踩坑

现象:训练时偶尔报错,提示图片和标签不匹配,或者某个txt行数明显比图片里的目标数多。原因:转换脚本按xml文件名生成txt,但xml里filename字段与JPEGImages里实际文件名不一致,比如图片是001.jpg,xml里写的是001.JPG,甚至带了目录前缀,导致labels匹配不上。解决:写一个校验脚本,在训练前遍历所有图片,检查同名txt是否存在,再逐行解析txt里的class_id是否在nc范围内、归一化坐标是否在0到1之间。我一般把校验放在训练命令之前,一旦有脏数据直接打印文件名。这个问题越早暴露越省时间,等训练到一半才暴露,就是既耗电又耗神。如果用ultralytics自带的数据校验,它也能报路径问题,但报错信息往往指向目录而不是具体哪个文件,反而不如一个小脚本直接。

4.5 划分脚本把连续帧拆散喂给训练和验证:验证集被污染

现象:用数据包自带的划分脚本跑出来的val mAP能到0.9,换一批实拍视频掉到0.6,落差大得吓人。原因:1000张图片里如果有同一段视频的连续抽帧,随机划分就会把第10帧放训练、第11帧放验证,这两帧构图几乎一样,模型等于见过答案,验证集失去意义。解决:按3.3的分组划分方案,先按视频段分组再切分。如果文件名里没有视频段标识,就需要靠时间戳或用感知哈希计算图片相似度做聚类,把相似帧归到同一组。不要偷懒用随机打乱,疲劳驾驶视频连续性太强,随机切分几乎必然翻车。另外,分组划分后的mAP会显得“不如”随机切分漂亮,但那个低一点的数字才对部署有参考意义。

5. 从数据到部署:0基础纯小白也能跑通的最小训练与导出验证

5.1 先跑通最小训练命令,别一上来就堆参

拿到整理好的数据集,我建议第一次训练不追求mAP,只求流程走通。用ultralytics的话,命令缩到最短:

pip install ultralytics yolo detect train data=data.yaml model=yolov8n.pt epochs=50 imgsz=640

data.yaml里的路径改成你机器上的实际路径。yolov8n.pt是官方预训练权重,第一次跑会联网下载,放在本地后也可以直接指定本地路径。epochs=50只是为了快速看loss曲线是否下降,不代表收敛,后期要加到150以上。跑通之后把model换成yolov11的变体或加了efficient head的改进版,在同一份划分好的数据上对比mAP,再决定要不要在注意力机制上花时间。硬件底线是8G显存,6G也能跑但batch size要降到4。数据包里如果带了训练教程,先照着跑通,再调自己的路径。

5.2 导出onnx做真实部署验证:预处理对齐和时序过滤不能省

训练完别只看val mAP,直接导出onnx跑一段真实视频才见真章:

from ultralytics import YOLO model = YOLO('runs/detect/train_v1/weights/best.pt') model.export(format='onnx', imgsz=640)

导出后推理最容易踩坑的是预处理不对齐。训练时ultralytics用letterbox把原图缩放到640并填充灰色边,导出onnx后如果直接用opencv的resize把图粗暴拉到640,宽高比变了,框就全部偏移。解决方法是推理脚本里也做letterbox,或者直接用ultralytics内置的模型预测接口加载onnx,别自己手工拼接resize。疲劳驾驶场景通常要接入RTSP摄像头或本地视频文件,我还习惯加一个滑窗过滤:连续多帧判定为闭眼或打哈欠才触发告警,避免单帧抖动造成误报。做过几次疲劳驾驶项目之后,我发现最有用的不是模型结构多新,而是第2章的目录盘点和第4章的每个坑都踩一遍。数据集的1000张图只是一个起点,训练命令跑通只算第一步,真正投入使用时要把夜间、墨镜、侧脸这些边界情况当成独立项目来补数。希望你拿到这套东西少走弯路,希望帮到你。

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

RELATED READING

延伸阅读

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