ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

疲劳驾驶数据集图片与XML标注不匹配?清洗与YOLO转换指南

疲劳驾驶数据集图片与XML标注不匹配?清洗与YOLO转换指南

简介:这是一份面向深度学习和图像识别入门练习的疲劳驾驶监测数据集,包含人体疲劳状态下的真实照片,可重点将打哈欠时的张嘴状态与闭眼状态作为疲劳判定标准,适合有一定Python基础、正在学习目标检测或图像分类的初学者,也适用于课程设计与毕业设计中的小型项目。资源包大小约256.28MB,解压后主要为2915张jpg图片与2914个xml标注文件,图片存放于JPEGImages目录,标注文件存放于Annotations目录,可直接接入常见目标检测框架的数据组织方式。已有4742人学习。值得注意的是,原始数据存在图片与标注不匹配的问题,作者提供了Python清洗脚本思路,通过比对JPEGImages和Annotations目录自动删除无对应xml的图片,可帮助使用者快速获得干净的数据集。拿到资源后可直接进行数据预处理、划分训练集并开展模型训练,减少在数据整理上耗费的精力,专注算法效果调优。

1. 疲劳驾驶数据集图片和标注对不上:先别急着把模型跑起来

下载好“疲劳驾驶监测数据集”之后,多数人的第一反应是解压、看两眼图片,然后直接扔进深度学习训练脚本里跑。结果通常会卡在一个很不起眼的地方:JPEGImages 目录下的某张照片,在 Annotations 目录里找不到同名的 xml。轻则训练时数据加载报错,重则模型练完发现验证精度很低,完全不知道问题出在哪里。这个现象在公开的疲劳驾驶数据集里不算罕见,图片和标注不匹配是新手最容易踩的坑。下面这条排查路径从“为什么会不匹配”讲起,覆盖清理数据、解析 xml 到验证数据的完整流程,适合刚入门人工智能图像识别、准备拿疲劳驾驶数据练手的新手——也适合被数据集折腾得怀疑人生的熟手。看清根因之后再动手,比直接开训省下几倍时间。

2. 图片与 XML 对不上:目录结构、命名规则与差集定位

2.1 数据集目录结构与命名规律

这份疲劳驾驶监测数据集是典型的 VOC 风格目录布局。图片统一放在 JPEGImages 文件夹里,标注文件统一放在 Annotations 文件夹里,两者一一对应:图片叫什么名,标注文件就叫什么名,只是后缀不同。标注文件是 xml,里面记录的是这张图片的宽度、高度、通道数、目标类别和检测框坐标。因为定位是新手练习项目,整体目录层级不复杂,解压后核心就这两个文件夹。

实际解压后的布局大致是下面这样:

Dataset/dataset/ ├── JPEGImages │ ├── 894.jpg │ ├── 1429.jpg │ ├── 1062.jpg │ └── ... └── Annotations ├── 894.xml ├── 1429.xml ├── 1062.xml └── ...

命名规律看起来很简单,但恰恰是命名在坑人。我拿这份数据做清洗时观察到,图片编号从八百多号一路排到两千六百多号,也就是八百多号到两千五百多号之间,中间有不少断号位置——这些断号附近往往就是图片与标注失配的重灾区。如果你的训练代码只是把 JPEGImages 整个文件夹喂进去,模型并不知道哪张图没有标签。遇到缺标注时,数据加载器要么直接抛异常中断,要么更隐蔽地把下一张 xml 错配给当前图片。

这种错位学习比直接报错更危险:Loss 看起来也会下降,训练过程完全不报错,但模型学到的是“人脸位置和标签错开”的假特征,验证集上一测 mAP 就到不了合理值。所以第一步永远是先看结构,而不是先跑模型。把两个目录的文件数打出来对比,是成本最低、但最能暴露问题的健康检查。

2.2 为什么会产生“有图无标注”和“有标无图”

同一个数据集里出现图片和标注对不上,通常来自三个来源。

第一种是标注软件漏标。采集到的疲劳驾驶照片会先做人工筛选和标注,标注过程中难免有漏掉的图片:照片还在目录里,对应的 xml 没有被生成。第二种是数据合并时没有对齐。比如有人分两批收集图片,一批带了标注,另一批只有裸图,合并时只把图片拷贝进 JPEGImages,Annotations 目录没有同步更新,两边数量就对不上。第三种是文件名规则不统一。某些 xml 里记录的 filename 字段和实际文件名不一致,或者图片从 png 格式转成 jpg 后后缀变了,而 xml 里还写着旧文件名。

这三类问题有一个共同特征:它们解压时不会给出任何错误提示,只会在训练中途或者最终评估时以“精度低、Loss 奇怪、验证集指标为 0”的形式暴露。也就是说,等报错再处理就晚了,必须主动做一次配对检查。配对检查的思路就是把 JPEGImages 当作“事实图片集合”,把 Annotations 当作“有效标签集合”,两边不重合的部分就是需要处理的对象。

2.3 用脚本找出不匹配项:先统计再动手

配对检查的常见做法是:把 JPEGImages 下所有文件名抽出来,把 Annotations 下所有文件名抽出来,分别去掉后缀,然后做集合差。我一般先跑一段统计脚本,把两边数量和不匹配数量一次性打出来:

import os jpeg_dir = 'Dataset/dataset/JPEGImages' anno_dir = 'Dataset/dataset/Annotations' jpeg_names = {os.path.splitext(f)[0] for f in os.listdir(jpeg_dir)} anno_names = {os.path.splitext(f)[0] for f in os.listdir(anno_dir)} print('JPEG 数量:', len(jpeg_names)) print('XML 数量:', len(anno_names)) print('有图无标注:', len(jpeg_names - anno_names)) print('有标注无图:', len(anno_names - jpeg_names))

os.path.splitext会把文件名和后缀拆开,比如894.jpg变成894和.jpg,这样图片和 xml 就能用同一套主名比较。差集用集合减法直接算出来:jpeg_names - anno_names是“有图片但没标注”的文件,属于清理对象;反方向的差集是“有标注但缺图片”的文件,这种多半是图片漏拷贝了,要优先去补图片,而不是顺手删 xml。

这段脚本的统计结果不要只看数字,还要把差集的具体文件名打印出来。如果“有图无标注”占比到了百分之十几,说明这批数据的标注工作没有完成,需要重新评估能不能用;只有零星几个文件对不上,才适合直接走清理流程。我还会注意一个细节:os.listdir返回的是目录下所有文件名,如果系统生成了隐藏文件,比如 mac 下的.DS_Store,会混进集合干扰统计,必要时用endswith('.jpg')之类的条件过滤。

3. 清理不匹配图片:删除脚本、路径修改与误删后悔药

3.1 直接删除不匹配图片的脚本

明确差集之后,下一步就是把“有图无标注”的图片从 JPEGImages 里清掉。原数据集自带的清理思路是遍历图片目录,逐个检查同名 xml 是否存在,不存在就删除图片。脚本本身不长,我把它完整贴在这里:

import os, shutil jpeg = 'Dataset/dataset/JPEGImages' # 图片目录,按实际路径修改 jpeg_list = os.listdir(jpeg) anno = 'Dataset/dataset/Annotations' # 标注目录,按实际路径修改 anno_list = os.listdir(anno) for pic in jpeg_list: name = pic.split('.')[0] # 去掉图片扩展名,得到主名 anno_name = name + '.xml' # 拼出期望的标注文件名 print(anno_name) if anno_name not in anno_list: # 如果标注不存在 os.remove(os.path.join(jpeg, pic)) # 删除这张图片

这个脚本按顺序做了四件事:第一,列出 JPEGImages 下所有文件名;第二,列出 Annotations 下所有文件名;第三,对每张图片提取主名,拼出同名 xml 去anno_list里查;第四,查不到就调用os.remove删除图片。os.path.join(jpeg, pic)的作用是把目录和文件名拼成完整路径,避免手写路径时漏掉分隔符。

原脚本里name = pic.split('.')[0]有两个隐患需要注意。第一个隐患是多后缀文件名:如果图片叫driver.01.jpg,split('.')[0]得到的是driver而不是driver.01,拼出来的driver.xml自然匹配不上,最后会把有标注的图片误删。更稳妥的写法是os.path.splitext(pic)[0],它只剥离最后一个扩展名。第二个隐患是anno_list里可能混入非 xml 文件,比如隐藏的Thumbs.db,判断时最好加上anno_name.endswith('.xml')条件。

3.2 路径改法和“安全删除”模式

脚本里的jpeg和anno两个变量必须改成你解压后的实际路径。用相对路径时要注意当前工作目录:如果脚本在 notebook 里跑,而 notebook 所在的目录和Dataset不在同一层,Dataset/dataset/JPEGImages这个相对路径就会指向不存在的位置,os.listdir直接报 FileNotFoundError。这种“路径少了一个层级”的问题,是新手最容易翻车的地方。建议先跑一小段print(os.path.exists(jpeg))确认路径存在,再执行删除。

我更推荐的做法是不要一上来就物理删除,而是先把删除动作改成移动,给误删留后悔药:

os.makedirs('unmatched', exist_ok=True) shutil.move(os.path.join(jpeg, pic), 'unmatched/' + pic)

把文件移动到unmatched/目录而不是删除,跑完检查无误后再手动清空这个目录。另一种更轻量的做法是把os.remove(...)临时换成print('will delete:', pic),先做一次 dry run,确认要删的文件名单没有误伤,再把 print 改回删除。这一步花不了两分钟,但能避免因为路径写错、命名规则判断错,把几百张有效图片一次性删掉。

3.3 清理方向:为什么删图而不是删 XML

清理时方向很有讲究。图片和标注对不上,标准做法是删除“有图无标注”的图片,保留所有 xml。原因是标注是人工成本最高的部分,一张图从截取、筛选到框选目标,可能花了几分钟甚至更久的人工投入,丢了 xml 等于把标签浪费了;而没有任何标注的图片,在目标检测训练里既不能提供正样本,也不能提供正确的负样本,留着只会让数据加载器报错。反过来,如果出现“有标注无图”,要先去检查图片是不是漏拷贝了,而不是删掉 xml。漏拷贝补上即可,删了 xml 就把有效标注废了。

这个原则同样适用于后续的类别筛选。如果某些 xml 里只有你不关心的类别,比如误标了方向盘、后视镜这类目标,正确做法是保留 xml,在转换标签时按类别名过滤,而不是把 xml 直接删掉。这样以后想扩展类别,原始标注还在。

3.4 清理后的目录核对

清理结束后不要急着训练。回到 2.3 的统计脚本,再跑一遍,确认“有图无标注”和“有标注无图”都变成 0,两边文件数量相等。我习惯把清理前后数量记录成一行文字存在项目笔记里,方便后续回溯“为什么数据集是这个规模”。数量对上了,接下来才进入标注解析和训练流程。如果数量仍然对不上,说明清理脚本的匹配逻辑还有问题,回到 3.1 检查文件名后缀处理方式。

4. 解析 XML 与构造训练标签:把打哈欠张嘴和闭眼状态喂进模型

4.1 XML 标注文件里有什么

清理干净后的 xml 文件才是真正要用的标注。打开任意一个文件,内容结构大致如下:

<annotation> <folder>JPEGImages</folder> <filename>894.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>yawn</name> <bndbox> <xmin>512</xmin> <ymin>301</ymin> <xmax>701</xmax> <ymax>402</ymax> </bndbox> </object> </annotation>

训练时真正要读的就三个部分:size里的宽高,object里的name,以及bndbox里的四个坐标。name表示这个目标属于哪个类别,bndbox表示目标在图片中的边界框位置。一张图上如果出现多个对象,xml 里就会有多个<object>块,解析时必须循环读取,不能只取第一个。

这里特别容易出错的是filename字段。有些 xml 里的filename写了旧名字,和 JPEGImages 里实际文件名不同,这类不一致不会影响清理脚本的配对判断,但会在后面对应图片时产生错位。所以解析 xml 时,我习惯以实际图片文件名为准,而不依赖 xml 里的filename字段去拼路径。

4.2 VOC 转 YOLO 完整脚本与归一化细节

现在很多目标检测训练框架用的是 YOLO 格式,标注是 txt 文件,每行表示一个目标,包含类别索引、中心点 x、中心点 y、目标宽度、目标高度,并且所有数值都归一化到 0 到 1 之间。把 VOC 格式转成 YOLO 格式的完整脚本如下:

import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, out_dir, class_names): tree = ET.parse(xml_path) root = tree.getroot() w = int(root.find('size/width').text) h = int(root.find('size/height').text) filename = root.find('filename').text name_no_ext = filename.rsplit('.', 1)[0] lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in class_names: continue cls_id = class_names.index(name) box = obj.find('bndbox') xmin = float(box.find('xmin').text) ymin = float(box.find('ymin').text) xmax = float(box.find('xmax').text) ymax = float(box.find('ymax').text) x_center = (xmin + xmax) / 2.0 / w y_center = (ymin + ymax) / 2.0 / h box_w = (xmax - xmin) / w box_h = (ymax - ymin) / h lines.append(f'{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}') with open(os.path.join(out_dir, name_no_ext + '.txt'), 'w') as f: f.write('\n'.join(lines)) class_names = ['yawn', 'closed_eye'] voc_to_yolo('Dataset/dataset/Annotations/894.xml', 'labels', class_names)

这段代码的关键点在于归一化时的分母。w和h必须来自 xml 里的size字段,而不是用cv2.imread现读图片得到的尺寸。正常情况两者相等,但一旦原图被压缩过或经过批量缩放,xml 里的 size 还是原始分辨率,用图片实际尺寸去归一化,边界框就会整体偏移训练也看不出来,推理时框全打在错误位置。第二个容易错的地方是除法要用浮点数:(xmin + xmax) / 2.0 / w才能保留小数,如果写成/ 2,在 Python 2 环境里整数除法会直接截断。

脚本把每个 xml 转换并写出一个同名 txt 文件。class_names是类别列表,脚本按类别名找到索引,不在列表里的类别直接跳过。这一步相当于把“多标签的可能情况”在进入训练前就过滤干净。

4.3 类别策略:把打哈欠张嘴和闭眼作为疲劳标准

这份数据集的标注设计思路很直接:训练时把“打哈欠张嘴”和“闭眼”两种状态作为疲劳判别标准。从目标检测的角度看,就是两个类别:yawn表示张嘴打哈欠的状态,closed_eye表示闭眼状态。嘴和眼睛区域都可以用矩形框标出来,模型学的是“这块区域当前处于什么状态”。

新手练习时,我不建议一开始就同时训两个类别。先设置class_names = ['yawn']只跑张嘴检测,让数据加载、Loss 计算、验证评估整条链路先跑通,再扩展成['yawn', 'closed_eye']。两个类别一起训也不是不行,但一旦 Loss 不收敛,你要同时排查两个类别的标注质量,定位成本翻倍。先单类别后双类别,是一个性价比很高的推进方式。等模型能稳定输出一个状态,再叠第二个状态,之后调置信度阈值才有意义。

5. 避坑与排查:用这份数据集训练前最容易翻车的四个点

5.1 清理脚本误删了有标注的图片

现象:跑完清理脚本后,JPEGImages 数量少了很多,对照 Annotations 查看,发现某些图片明明有对应 xml,却也一起消失了。

原因:脚本的匹配方向或者匹配逻辑写错了。常见的有两种,一种是把 XML 列表当遍历主体,在图片列表里找不到同名文件就删除 xml,方向反了;另一种是图片文件名里有多后缀,比如driver.01.jpg,用split('.')[0]取主名导致匹配失败,把好图误删。

解决:执行删除前,先用统计脚本打印差集确认清理对象就是“有图无标注”的图片;删除动作改成printdry run 模式核对文件名单;更稳的做法是改用shutil.move把图片移到unmatched/目录,确认无误后再清空。从那以后,我每次清理数据集都会强制走一遍“先统计、再 dry run、最后删除”的顺序。

5.2 XML 解析报错:ParseError、空文件与编码问题

现象:批量转换标注时,ET.parse(xml_path)抛出xml.etree.ElementTree.ParseError,程序中断。排查看是某个 xml 文件是 0 字节,或者标签少了一个闭合。

原因:原数据集的标注文件有一部分没有正常收尾就导出了,也可能在拷贝过程中损坏。这类文件数量往往不多,但会卡住整个批量转换流程。编码问题也可能触发解析异常,比如 xml 头声明的是 UTF-8,实际内容里混入了 GBK 编码的中文字符。

解决:转换脚本里加异常捕获,把坏文件单独隔离出来:

try: tree = ET.parse(xml_path) except ET.ParseError as e: print('bad xml:', xml_path, e) continue

遇到ParseError的文件移到broken/目录,逐个决定修复还是放弃。0 字节说明这张图没有可用标注,按第 3 章的清理逻辑删掉对应图片;少闭合标签可以手工补上;编码问题则用errors='ignore'读取或统一转换成 UTF-8 后重新解析。批量转换前先全量扫一遍 xml 能不能被正常解析,能提前暴露绝大多数坏文件。

5.3 类别标签不统一:yawn、Yawn、打哈欠同时出现

现象:训练时发现类别数不对,预期两个类别,跑出来却有三四个。打印标签统计结果后,发现name字段有的写成yawn,有的写成Yawn,还有的写了中文“打哈欠”。

原因:标注过程是人工完成的,不同标注者对同一类目标的命名习惯不一样,有的用小写英文,有的用首字母大写,有的直接写中文。VOC 转 YOLO 时,class_names.index(name)找不到这些变体,要么跳过,要么当成额外类别处理。

解决:转换前先全量扫描所有 xml,对<name>字段做去重统计。我一般这样做:

import os import xml.etree.ElementTree as ET anno_dir = 'Dataset/dataset/Annotations' names = set() for f in os.listdir(anno_dir): if not f.endswith('.xml'): continue tree = ET.parse(os.path.join(anno_dir, f)) for obj in tree.getroot().findall('object'): names.add(obj.find('name').text) print(names)

如果names集合里出现多个写法,就维护一个映射字典,把所有变体统一成标准类别名后再转换。统一标签的另一个好处是类别索引不会乱,YOLO 训练时类别 ID 一旦分配错误,所有标注都会错位,训练后评估指标完全不可信。

5.4 训练 Loss 不收敛:归一化坐标和尺寸对不上

现象:模型训练几十轮,Loss 不降反升,或者验证集 mAP 一直是 0。可视化检查发现预测框位置整体偏到图的左上角或右下角。

原因:VOC 转 YOLO 时,归一化坐标的分母用错了尺寸。有人用cv2.imread读图片后得到的 shape 作为w和h,但 xml 里记录的是原始分辨率;更常见的是x_center = (xmin + xmax) / 2 / w里漏了/ 2.0,整数除法把中心点坐标算成了整数,框就整体偏移。

解决:回到 4.2 的转换脚本,确认w和h从size字段读取,所有除法用浮点数。转换完一个 xml 后挑一张图片手工核对:打开 txt 里的一行数值,代回x_center * w验证还原出的中心点是不是和 xml 里的 bndbox 中心一致。训练前再做一次可视化验证,把标注框画在图上,框的位置不对就不要开训。这个检查只要做一次,就能把 90% 以上的坐标转换问题拦在训练前面。

6. 快速定位:三步验证这份疲劳驾驶数据集是否干净

6.1 统计文件配对率

任何数据集到手,第一件事是跑数量对比。JPEGImages 和 Annotations 数量必须一致,不一致就找差集。可以一步到位:

find Dataset/dataset/JPEGImages -name '*.jpg' | wc -l find Dataset/dataset/Annotations -name '*.xml' | wc -l

这两条命令只回答“总数对不对”,真正要核对的是同名配对率,需要按第 2.3 节的方式把 jpg 和 xml 文件名分别提取出来做差集。只要差集不为空,就先跑清理,清理完再统计,直到两边数量相等、差集为 0。

6.2 可视化检查标注框是否贴合目标

数量对上不等于标注正确。我每次都会随机抽十张图,把真实边界框画到图片上看一眼。方法是读 xml 里的坐标,用 OpenCV 画矩形:

import cv2 import xml.etree.ElementTree as ET img = cv2.imread('Dataset/dataset/JPEGImages/894.jpg') tree = ET.parse('Dataset/dataset/Annotations/894.xml') root = tree.getroot() for obj in root.findall('object'): box = obj.find('bndbox') xmin = int(box.find('xmin').text) ymin = int(box.find('ymin').text) xmax = int(box.find('xmax').text) ymax = int(box.find('ymax').text) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, obj.find('name').text, (xmin, ymin - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite('check.jpg', img)

框应该正好圈住眼睛或嘴部区域,name文字和状态一致。如果框整体偏离目标,说明坐标转换有问题,而不是数据本身坏了;如果图里明明有目标但一个框都没有,说明这张图的标注没有被正确配对,返回第 5.2 节检查解析逻辑。

6.3 小样本冒烟训练

数据验证完,最后一步是冒烟测试。每次我都会从清理后的数据里随机抽 50 张图,把训练迭代数设置得极小,比如 10 个 epoch,先跑通数据加载、标签读取、Loss 计算整条链路。这几步正常后再上全量数据。这样做的成本很低,但能把 80% 的数据集问题拦在正式训练之前——包括类别索引错乱、标注文件路径读不到、图片解码异常这些隐藏问题,全都会在小样本训练时暴露。

从那以后,我每次拿到新数据集都会强制走一遍这三个检查:先对文件名,再画框看标注,最后小样本跑链路。磨合多了你会发现,很多训练翻车都不是模型结构的问题,而是数据集在最初就不干净。这份疲劳驾驶监测数据集本身质量不错,把不匹配的图片清掉,打哈欠张嘴和闭眼两个状态作为训练标签,完全够新手练手,源资源里也直接提供了可复现的清理脚本,照着跑就能得到一份干净的训练集。希望帮到你。

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

RELATED READING

延伸阅读

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