简介:面向疲劳驾驶检测研究或计算机视觉目标检测学习者,这套VOC格式数据集专门用于训练识别闭眼、睁眼、闭嘴、张嘴四类状态,以判断驾驶疲劳程度。资源包约368.57MB,含4362张JPG图片、4362个对应XML标注文件及1份使用说明,图片均以矩形框精确标注,可直接用于YOLO、Faster R-CNN等常见检测模型的数据准备。四类标签分别为闭眼、闭嘴、睁眼、张嘴,其中睁眼框数最多、张嘴框数较少,样本覆盖真实驾驶场景差异,便于训练疲劳状态识别模型;虽不保证模型最终精度,但标注准确合理,可支撑算法验证。目前已有2800余人学习下载,适合疲劳驾驶检测、目标检测模型训练及自动驾驶安全研究等场景使用。
1. 疲劳驾驶数据集为什么选VOC格式:一份4类别4362张的数据能干多少事
VOC格式的疲劳驾驶数据集,4个类别、4362张图,拿到手第一反应往往是直接开训。但我见过太多项目死在这条捷径上——VOC格式的数据如果不先做格式体检和转换,喂给YOLO系模型基本是盲人摸象:要么类别标签对不上,要么坐标框整体漂移,要么训练集验证集划分完类别分布彻底失衡。这篇笔记就围绕这份「4类别4362张」的疲劳驾驶VOC数据集,讲清楚它内部长什么样、怎么安全转成YOLO训练格式、哪些参数最值得调、哪些坑最好提前绕开。适合手里已经有或准备购入疲劳驾驶标注数据、想直接训练检测模型的工程师,也适合第一次碰VOC格式的入门选手。
2. 拿到手先拆包装:VOC目录结构与xml标注里藏着什么
一份标准的VOC格式数据集,目录结构从来不是乱来的。拿到手先看这四件套是否齐全:JPEGImages放原始图片、Annotations放对应的xml标注、ImageSets/Main放训练验证划分清单,偶尔还会带一个JPEGImages的缩略版SegmentationClass之类的东西——这份疲劳驾驶数据如果没有做过分割任务,后两个目录不存在是正常的,别慌。
我一般拿到手的第一件事不是看图片,而是随机抽三个xml打开看字段。因为VOC标注文件是xml格式,里面每个字段都决定了后面转换脚本怎么写。一个典型的标注文件长这样:
<annotation> <folder>JPEGImages</folder> <filename>000001.jpg</filename> <source> <database>fatigue_driving</database> </source> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>yawn</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>320</xmin> <ymin>210</ymin> <xmax>486</xmax> <ymax>342</ymax> </bndbox> </object> </annotation>这里最关键的是三组字段:filename决定图片名,size决定图像的宽高,object里的name加bndbox决定类别和坐标。其中bndbox存的是绝对像素坐标,左上角和右下角,后面转YOLO格式时要把它们换算成归一化的中心点加宽高。difficult字段表示这张标注是不是难以辨认的样本,有的转换脚本直接跳过,有的保留——具体怎么处理我放到第4章讲。
2.1 一张xml标注到底写了什么:核心字段逐个拆
如果你不想手动一个个翻xml,可以直接上脚本来解析,顺便把整个数据集的关键信息批量摸一遍:
import os import xml.etree.ElementTree as ET anno_dir = "Annotations" xml_files = [f for f in os.listdir(anno_dir) if f.endswith(".xml")] for xml_name in xml_files[:5]: tree = ET.parse(os.path.join(anno_dir, xml_name)) root = tree.getroot() filename = root.findtext("filename") size = root.find("size") w = int(size.findtext("width")) h = int(size.findtext("height")) print(f"file: {filename}, size: {w}x{h}") for obj in root.findall("object"): name = obj.findtext("name") bndbox = obj.find("bndbox") xmin = float(bndbox.findtext("xmin")) ymin = float(bndbox.findtext("ymin")) xmax = float(bndbox.findtext("xmax")) ymax = float(bndbox.findtext("ymax")) print(f" class: {name}, bbox: ({xmin:.0f}, {ymin:.0f}) -> ({xmax:.0f}, {ymax:.0f})")代码逻辑很直白:先用ElementTree解析xml,取filename和size,再遍历所有object节点,把类别名和四个坐标值取出来打印。注意坐标转换时我用float()再接format输出,不用int()直接截断——因为有些标注工具会存类似320.5这样的带小数坐标,直接int()会丢掉精度,如果恰好落在边界上,框会偏1像素。
这个脚本跑完你能确认三件事:图片是否都按000001.jpg这种序号命名、尺寸是否统一、类别名是否规范。这三件事里任何一件出问题,后面转换都会踩坑。
2.2 4个类别怎么定:从标注口径到类别失衡
标题写的是4类别,但没写具体是哪四类。从疲劳驾驶检测的主流业务看,常见的四类设计是打哈欠、闭眼、打电话、分心(视线偏离或头部偏移)。有的数据集会用「正常驾驶」当背景类,有的把吸烟单独拆出来,这取决于采集场景。我没有看过这份数据的标注规范,但按行业惯例,大概率是下面这种映射关系:
| 标签名 | 含义 | 标注范围说明 |
|---|---|---|
| yawn | 打哈欠 | 嘴部区域,要求嘴张开有明显特征 |
| eye_close | 闭眼 | 眼部区域,上下眼睑接触 |
| phone | 打电话 | 手机贴近耳侧,含手部 |
| distraction | 分心 | 头部偏转或视线离开前方 |
这里最值得留意的是「闭眼」和「打哈欠」的边界。打哈欠的时候人大概率也闭眼,如果标注规范里没写清楚「打哈欠时眼部要不要单独标」,你拿到的数据里很可能出现同一张图有的标了eye_close有的没标。这种标注口径不一致的问题,是疲劳驾驶数据集最常见的隐形炸弹,后面第5章我会专门讲怎么排查。
另外,4个类别的数量通常不会均匀。闭眼和打哈欠在连续驾驶监控里出现频率高,打电话和分心相对少。拿到数据后第一件事就是统计类别频次,如果某个类别只占5%以下,训练时就要考虑欠采样或调loss权重。
3. 训练前先做数据体检:把4362张的底细摸清楚
数据拿到手不等于能直接用。4362张图、4362个xml,里面可能混着空标注、坏图、类别名写错的情况。先做一次全量体检,半小时能省下后面三天的调试时间。
3.1 用脚本统计类别分布与空标注:数据有没有偏
import os import xml.etree.ElementTree as ET from collections import Counter anno_dir = "Annotations" class_counter = Counter() empty_files = [] total_boxes = 0 for xml_name in os.listdir(anno_dir): if not xml_name.endswith(".xml"): continue tree = ET.parse(os.path.join(anno_dir, xml_name)) root = tree.getroot() objs = root.findall("object") if not objs: empty_files.append(xml_name) for obj in objs: name = obj.findtext("name").strip().lower() class_counter[name] += 1 total_boxes += 1 print("total xml files:", len(os.listdir(anno_dir))) print("total boxes:", total_boxes) print("empty annotation files:", len(empty_files)) for cls, cnt in class_counter.most_common(): print(f"{cls}: {cnt} ({cnt / total_boxes * 100:.1f}%)")这个脚本要看的核心是两个输出。第一个是class_counter的分布,如果某个类别的占比明显偏低,比如phone只有3%,训练出来的模型对该类别的召回率会很难看,后面要考虑做类别重加权。第二个是empty_files的数量,一份VOC数据里有几十个空xml并不罕见——采集时画面里确实没有目标,标注员就留空了。这些xml对应的图片训练时可以直接丢弃,也可以作为负样本背景图喂进去,取决于你的训练管线是否支持负样本。
另一个容易忽视的点是name字段的大小写。我在这段代码里做了.strip().lower(),是因为真实数据里经常出现Yawn和yawn混写的情况。不做归一化的话,转换脚本会把它们当成两个类别,模型输出层就多出一个幽灵类别。
3.2 图片尺寸与文件完整性检查:别让一张坏图毁掉一次训练
坐标转换依赖图片的真实宽高,而不是xml里size字段写的宽高。size字段如果和实际图片对不上,归一化坐标全会错位。所以体检时要用OpenCV把每张图真正读一遍,以读取结果为准:
import cv2 import os img_dir = "JPEGImages" bad_images = [] size_set = set() for img_name in os.listdir(img_dir): path = os.path.join(img_dir, img_name) img = cv2.imread(path) if img is None: bad_images.append(img_name) continue h, w = img.shape[:2] size_set.add((w, h)) print("unreadable images:", len(bad_images)) print("distinct image sizes:", len(size_set)) for s in sorted(size_set): print(s)这里cv2.imread读不出来的图,基本只有两种可能:文件损坏,或者后缀名与实际编码不符。常见做法是直接把这类图片从训练集里剔除,顺便删掉对应的xml,而不是去修图——修图的时间和收益不成正比。
如果size_set里出现多种分辨率,比如大部分是1280x720,但有一批是1920x1080,转换脚本里如果用xml的size做归一化,这批大图的框位置会整体偏移。所以转换时无论如何都要以实际读图的高宽为准,这是我在第4章代码里写死的一件事。
还有一个小检查值得做:对比JPEGImages和Annotations的文件名集合是否一致。用集合差集一跑就知道有多少xml没图、多少图没xml,避免转换脚本在中间报FileNotFoundError。
4. 把VOC转成YOLO格式:转换脚本与四个必调参数
VOC格式本身不能直接喂给YOLO系列训练,yolov5、yolov8训练自己的数据集时用的都是txt标注:每行一个目标,格式是class_id center_x center_y width height,四个坐标值全部归一化到0~1。这一步是整条链路里最容易出岔子的环节,也是值得你反复核对的环节。
4.1 VOC转YOLO的坐标换算:从xml到txt的一行命令
先上完整转换脚本,再逐段讲参数。这份代码的输入是Annotations目录和JPEGImages目录,输出的labels目录和images目录结构完全兼容YOLO系列训练框架:
import os import cv2 import xml.etree.ElementTree as ET # 类别映射表:必须与你后续训练时的data.yaml保持一致 class_map = { "yawn": 0, "eye_close": 1, "phone": 2, "distraction": 3, } anno_dir = "Annotations" img_dir = "JPEGImages" label_out = "labels" os.makedirs(label_out, exist_ok=True) def convert_one_xml(xml_path, img_path, label_path): tree = ET.parse(xml_path) root = tree.getroot() # 关键点:以实际读到的图片尺寸为准,不信任xml里的size字段 img = cv2.imread(img_path) if img is None: print(f"skip unreadable image: {img_path}") return h, w = img.shape[:2] lines = [] for obj in root.findall("object"): name = obj.findtext("name").strip().lower() if name not in class_map: print(f"skip unknown class: {name} in {xml_path}") continue cls_id = class_map[name] bndbox = obj.find("bndbox") xmin = float(bndbox.findtext("xmin")) ymin = float(bndbox.findtext("ymin")) xmax = float(bndbox.findtext("xmax")) ymax = float(bndbox.findtext("ymax")) # 越界裁剪:坐标拉到图像边界内 xmin = max(0.0, min(xmin, w - 1)) xmax = max(0.0, min(xmax, w - 1)) ymin = max(0.0, min(ymin, h - 1)) ymax = max(0.0, min(ymax, h - 1)) if xmax <= xmin or ymax <= ymin: print(f"skip invalid bbox: {xml_path} {name}") continue # VOC是绝对坐标(xmin,ymin,xmax,ymax),YOLO需要归一化的中心点+宽高 cx = (xmin + xmax) / 2.0 / w cy = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") if lines: with open(label_path, "w") as f: f.write("\n".join(lines)) else: # 没有有效目标的图片,生成空标签文件 open(label_path, "w").close() for xml_name in os.listdir(anno_dir): if not xml_name.endswith(".xml"): continue stem = xml_name[:-4] xml_path = os.path.join(anno_dir, xml_name) img_path = os.path.join(img_dir, stem + ".jpg") label_path = os.path.join(label_out, stem + ".txt") convert_one_xml(xml_path, img_path, label_path)这段代码里有几个参数和设计决策值得展开说。
第一个是class_map的顺序。这个顺序一旦确定,后续训练的data.yaml必须一字不差地对齐,否则类别标签全部错位。换句话讲,yawn在第0位就是第0位,训练时不能因为某个类别的框少就调换顺序——那会让已经转换好的所有txt全部作废。
第二个是越界裁剪。VOC标注里偶尔会出现xmax比图片宽度还大的情况,多半是标注工具拖拽时超出了画布边缘。如果不做裁剪,归一化后width会大于1,YOLO在解码时会有边界框超出图像的警告,某些版本直接忽略这部分损失,训练效果莫名缩水。裁剪到w - 1而不是w,是因为图像像素坐标从0开始,第w个像素实际上已经越界了。
第三个是空标签文件的处理。有些xml里所有目标都是unknown class或无效框,转换后没有任何有效行。我选择生成一个空txt而不是不生成文件——因为YOLO训练时会在images目录下找同名txt,找不到文件会报错或跳过,生成空文件则能保持一一对应。这个习惯能避免不少莫名其妙的训练中断。
4.2 训练集/验证集划分:按比例还是按场景
转换完标签之后,下一步是生成训练和验证清单。常见的做法是按9:1或8:2随机划分,但随机划分对疲劳驾驶场景有个隐患:同一段视频连续帧可能同时出现在训练集和验证集里,导致验证mAP虚高,部署时一换场景就崩。
import os import random random.seed(42) img_files = [f for f in os.listdir("JPEGImages") if f.endswith(".jpg")] img_files.sort() random.shuffle(img_files) val_ratio = 0.1 val_count = int(len(img_files) * val_ratio) val_files = img_files[:val_count] train_files = img_files[val_count:] with open("train.txt", "w") as f: for name in train_files: f.write(f"images/{name}\n") with open("val.txt", "w") as f: for name in val_files: f.write(f"images/{name}\n") print(f"train: {len(train_files)}, val: {len(val_files)}")这里有两个点需要按你的实际情况调整。第一,random.seed(42)固定随机种子,保证每次运行划分结果一致——这个习惯在复现实验结果时极其重要。第二,如果这份数据的图片来自多个采集时段或多个司机,更好的做法是按视频片段或司机ID划分,而不是按图片随机划分。但VOC格式的xml里未必有这层元数据,标题也没标注这一点,所以无法直接按场景切分时,随机划分是退而求其次的可靠方案。
划分完以后要跑一个核对脚本:确认验证集里4个类别都有样本,且每个类别的占比和全量数据大致接近。如果验证集里恰好没有某个类别,模型在该类别上的mAP就是0,你会误判为「模型学废了」,实际是划分运气太差。
4.3 转换后的抽查方法:转换器写完了怎么确认没转错
转换完不能直接开训,至少做一次可视化抽查。把txt标注画回图片上,肉眼比对一下框的位置和类别,这一步能发现绝大多数转换逻辑错误。
import cv2 def draw_yolo_label(image_path, label_path): img = cv2.imread(image_path) h, w = img.shape[:2] with open(label_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls_id, cx, cy, bw, bh = parts cx, cy, bw, bh = float(cx), float(cy), float(bw), float(bh) xmin = int((cx - bw / 2) * w) ymin = int((cy - bh / 2) * h) xmax = int((cx + bw / 2) * w) ymax = int((cy + bh / 2) * h) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 0, 255), 2) cv2.putText(img, str(cls_id), (xmin, max(0, ymin - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite("check_" + os.path.basename(image_path), img) # 抽查训练集和验证集各5张 for f in train_files[:5] + val_files[:5]: stem = f.split("/")[-1][:-4] draw_yolo_label(f"images/{f}", f"labels/{stem}.txt")抽查时重点看两个地方:一是框是否贴合目标,特别是eye_close这种小目标——如果框明显偏大或偏小,说明坐标换算有系统误差,需要回到第4.1节的脚本检查归一化分母用的是不是真实图片宽高;二是类别ID是否对得上,比如yawn的框上显示的应该是0而不是3,这种错位基本就是class_map顺序不一致导致的。
另外建议抽查夜里或逆光的图片。疲劳驾驶数据里暗光场景占比往往不低,暗光下标注框的质量通常比白天差,如果抽查只看白天的图,容易漏掉一批坏标注。
5. 转换与训练避坑:5条踩过的坑帮你省掉三天调试时间
这章写的是我在实际项目里踩过或排查过的具体问题。每条按「现象→原因→解决」的顺序写,你可以直接对照自己的情况排查。
5.1 xml里的size跟真实图片不一致:框全部漂移
现象:训练时loss下降正常,验证集mAP也不算低,但把模型部署到实际摄像头画面里,检测框的位置明显偏左上或右下,尤其在某个分辨率段特别严重。
原因:标注工具或脚本处理图片时改了分辨率,但xml里<size>字段没同步更新。转换时如果用的是xml里的宽高做归一化,真实图片实际尺寸不同,逆归一化后框自然偏移。
解决:转换脚本里严禁用root.find("size")里的宽高,必须用cv2.imread读出来的img.shape。这也是我在4.1节代码里坚持读图取尺寸的原因。已经转完的txt可以批量重新生成,不用手动改。
5.2 类别名大小写混杂:模型多出一个幽灵类别
现象:训练日志里显示的类别数比4多,比如出现了5类,其中某个类别样本数极少,且loss在该类上一直很高。
原因:xml里有的写Yawn,有的写yawn,有的写eye_Close。转换脚本直接拿原始字符串做key,大小写不同被当成不同类别,模型被迫多学一个几乎没样本的类。
解决:在转换脚本里统一做.strip().lower(),并在class_map查询前先归一化。如果已经训了一半,把labels目录里所有txt的类别ID重新映射一遍即可,不必重新转xml。
5.3 绝对路径导致跨机器失效:换台机器训练直接报错
现象:在自己电脑上跑得好好的,把数据集拷到服务器上,训练一开始就报FileNotFoundError,或者读图读到一堆空图。
原因:生成的train.txt和val.txt里写的是C:\Users\xxx\fatigue_dataset\images\000001.jpg这种带盘符的绝对路径,换机器后路径前缀全变了。
解决:txt里统一写相对路径,比如images/000001.jpg,然后训练脚本的工作目录固定指向数据集根目录。更稳妥的做法是连相对路径都不写,只写文件名,配合一个data.yaml里的path字段指定根目录。我一般用相对路径,因为在命令行调试时更直观。
5.4 打哈欠和闭眼互相污染:标注口径的隐形炸弹
现象:模型训练完,eye_close的precision很高但recall很低,或者yawn的框经常把眼睛也包进去。看混淆矩阵,两个类互相错分率超过30%。
原因:人在打哈欠时大概率闭眼,标注员对「同一张图里能不能同时标两个类」理解不一致。有的图只标了yawn没标eye_close,有的图两个都标,有的图把打哈欠的嘴部画得巨大包含整个下半脸——这类标注口径问题在疲劳驾驶数据里几乎无法完全避免。
解决:第一,看数据标注规范里有没有定义「标注范围」,比如打哈欠的框只包嘴部、闭眼的框只包眼部;第二,做一次针对性统计,随机抽样yawn和eye_close重叠的图,人工核对50张;第三,如果错分率实在压不下来,考虑把这两个类合并成一个fatigue类别做二分类检测,牺牲细粒度换稳定性。这个决策要在训练前做,训练后改类别等于重做。
5.5 划分后验证集类别失衡:mAP骗了你
现象:训练完模型在验证集上某个类别mAP是0,但训练时该类别loss在正常下降,看起来像「模型对这个类完全没学会」。
原因:全量数据里该类别本来就少,随机划分后又恰好全部落在训练集,验证集里一个该类的gt框都没有。mAP接口对没有gt框的类别直接给0分。
解决:划分时按类别做分层采样,保证验证集里每个类别至少有一定数量的框。具体做法是遍历所有txt标签,把包含某个类别的图片单独挑出来,再按比例抽到验证集,其余图片再做随机划分。代码不复杂,但很多人不做这一步,踩坑了才回头补。
6. 用这份数据训出靠谱模型:一个更聪明的训练与验证习惯
数据转换干净了,接下来才是训练本身。4362张图对疲劳驾驶检测来说属于中等规模,第一轮训练建议用yolov5s或yolov8n这类轻量模型跑通全流程,而不是直接上最大模型——先确认数据链路没有问题,再考虑提升精度。训练时把imgsz设为640即可,这份数据的原图如果是1280x720,640尺寸下闭眼这种小目标的信息损失会比较大,有条件可以试试768。
增强策略要针对驾驶场景做取舍。Mosaic和随机平移缩放可以开,但上下翻转必须关掉——驾驶画面里道路和车身的相对位置是物理约束,上下翻转会把路面翻到天上,模型学到的是完全错误的空间关系。左右翻转可以开,但要注意phone这个类别:左手举手机翻转为右手举手机,对检测本身没影响,检测框不会变错,可以放心翻。
验证时不只看总mAP,重点看混淆矩阵里eye_close和yawn的双向错分率。我之前就吃过一次亏:训练时mAP刷到了0.82,兴冲冲拿去路测,结果把眯眼频繁判成打哈欠,才发现两个类的混淆矩阵一团糟。后来养成的习惯是每次训练完第一件事拉混淆矩阵,而不是先看mAP——mAP骗人,混淆矩阵不骗人。顺手也看一眼每个类别的recall,疲劳驾驶场景漏报比误报恶劣得多。
如果你手里这份数据的类别分布严重不均衡,可以试试在data.yaml里给每个类配上不同的loss权重,或者用focal loss,但先别急着改,跑一版baseline再看混淆矩阵。我自己的习惯是:先不做任何花活,用干净的数据跑出baseline,再根据混淆矩阵的错分方向做针对性优化——是标注口径问题就修数据,是光照问题就加增强,是模型容量问题才换模型。把转换脚本、class_map映射表、随机种子一起存好,三个月后回来复现实验时,你会感谢自己当初的这个习惯。希望帮到你。
本文还有配套的精品资源,点击获取