简介:面向目标检测与路面病害识别场景的VOC格式数据集,已按训练集和测试集划分,可直接用于YOLO、Faster R-CNN等主流目标检测模型的训练与评估。图像为600×600分辨率的RGB路面图片,覆盖纵向裂纹、横向裂纹、坑洞、不规则裂缝4个类别;数据目录分为train和test,各自含images与labels子文件夹,结构清晰,省去自行划分数据集的步骤。压缩包内共2000个文件,以XML标注文件为主,并附带1个Python可视化脚本,随机传入一张图片即可自动绘制边界框并保存结果,无需修改即可运行,便于抽查标注质量。同时提供4类别的JSON字典文件,辅助类别映射与标签转换。资源包约194.72MB,已有567人学习下载,既适合目标检测初学者快速搭建实验流程,也适合开发者进行数据增强、类别扩展与模型效果对比。
1. 路面缺陷检测数据集:拿到 VOC 格式的 xml,离训练还差几步
做路面缺陷检测的人,最头疼的往往不是模型结构,而是数据。裂缝、坑槽、修补区这些目标,形态细长、背景纹理杂乱,标注质量直接决定模型上限。这套标题里的数据集,价值在于它已经把标注统一成了 VOC 格式的 xml 文件,并且做了训练集和测试集划分——拿到手理论上可以直接开训。但实际用的时候你会发现,xml 里存的是左上右下坐标,而主流检测框架默认读的是归一化中心坐标,这中间需要一个转换环节。另外划分好的 txt 列表和图片是否一一对应、类别分布是否均匀,也要先做验证,不能盲目相信文件名。这篇笔记会把从拿到数据到跑通训练的关键步骤和坑位都过一遍,适合正在被数据格式卡住、想尽快开始训练的新手,也适合想确认这套数据边界在哪儿的熟手。
2. VOC 标注的 xml 到底存了什么:解析字段与第一个校验脚本
2.1 VOC 格式的存储逻辑和关键字段
VOC 格式的本质,是每张图片对应一个同名 xml 文件,xml 里记录图片信息、目标类别和目标的矩形框坐标。理解这套结构不需要看完整文档,把根节点下的几个核心字段搞清楚就够了。根节点是 annotation,下面有 folder 记录图片所在目录,filename 记录图片文件名,size 节点里是 width、height、depth 三个值,分别代表宽度、高度和通道数。这几个字段是转 YOLO 格式时做归一化分母用的,缺一不可。
真正决定检测目标的是 object 节点。一个 object 对应图中的一个目标实例,里面 name 是类别名,bndbox 里是 xmin、ymin、xmax、ymax 四个像素坐标。要注意的是,VOC 坐标原点是图片左上角,x 向右增大,y 向下增大。xmin、ymin 是框左上角坐标,xmax、ymax 是右下角坐标,四个值都是整数。这套坐标系和很多人的直觉略有出入——有人会误以为 xmin、ymin 是中心点,导致后续转换全部错位,这个坑在后面会专门讲。
解析 xml 不需要额外装库,Python 自带的 xml.etree.ElementTree 就够。它是标准库,不会引入依赖问题,处理几千个 xml 文件的速度也完全能接受。常见的替代方案是 lxml,解析速度更快,但需要单独安装,在 Windows 上偶尔会遇到 wheel 安装失败的情况。如果只是做格式检查和坐标提取,ElementTree 足够稳妥。
2.2 用 Python 遍历全部 xml,统计类别和数量
拿到数据后第一步不是训练,是摸底。脚本要覆盖三类信息:每张图的目标数量、每个类别的实例总数、xml 里有哪些字段。前两项决定类别均衡性和是否能支撑训练,第三项决定格式是否统一。很多数据集的 xml 里会混入缺字段的文件,标注过程中人工编辑留下的格式问题也不少见,这一步能提前暴露。
下面这段脚本遍历指定目录下所有 xml 文件,解析出类别统计信息。注意代码里的 enumerate 遍历方式,以及 .iter() 方法的使用,前者用于处理文件名不连续的情况,后者用于多级嵌套节点的查找。
import xml.etree.ElementTree as ET import os from collections import Counter xml_dir = "./Annotations" # xml 文件所在目录 category_counter = Counter() img_obj_count = [] failed_files = [] for idx, xml_name in enumerate(os.listdir(xml_dir)): if not xml_name.endswith(".xml"): continue xml_path = os.path.join(xml_dir, xml_name) try: tree = ET.parse(xml_path) root = tree.getroot() objects = root.findall("object") img_obj_count.append(len(objects)) for obj in objects: name = obj.findtext("name", default="").strip() if name: category_counter[name] += 1 except Exception as e: failed_files.append((xml_name, str(e))) print("类别统计:", dict(category_counter)) print("单图目标数分布: 最少", min(img_obj_count), "最多", max(img_obj_count)) print("解析失败文件:", failed_files if failed_files else "无")这段脚本的核心逻辑分三步。第一步,os.listdir 拿到目录下全部文件,用 endswith 过滤出 xml,避免把无关文件读进来。第二步,ET.parse 解析 xml,root.findall("object") 提取全部目标节点,findtext 取出类别名。第三步,用 Counter 做类别计数,同时记录每张图的目标数。逻辑说明里需要提醒的是,findtext 方法在节点不存在时会返回默认值,这里用空字符串兜底,避免 None 参与字符串操作报错。
跑完这个脚本,你会得到三类关键信息。类别统计决定了需要几个输出通道,单图目标数分布决定了训练时是否需要调整批量大小或图像缩放策略,解析失败文件列表是需要优先排查的对象。如果某一类目标数量极少,比如只有几十个,这类缺陷在测试集上的指标参考意义有限,后续分析时要单独看待。
提示:如果 xml_dir 路径下有子目录,os.listdir 会把子目录也列出来,虽然 endswith 过滤能挡住,但建议用 os.path.isfile 再校验一次,避免路径拼接出错。
3. 把 VOC 的 xml 转成 YOLO 训练格式:转换脚本与四个边界坑
3.1 坐标归一化和类别映射的换算逻辑
YOLO 系列框架读取的标注格式是每张图一个 txt 文件,每一行对应一个目标,五个值分别是类别 id、归一化中心 x、归一化中心 y、归一化宽 w、归一化高 h。其中 x、y、w、h 都是相对图片宽高的比例,取值范围在 0 到 1 之间。转换的核心逻辑是从 bndbox 的四个像素坐标算出中心点和宽高,再分别除以图片宽高。
换算公式不复杂:center_x = (xmin + xmax) / 2 / width,center_y = (ymin + ymax) / 2 / height,box_w = (xmax - xmin) / width,box_h = (ymax - ymin) / height。但这里有个容易被忽视的细节:xml 的 size 节点里存的 width 和 height 必须与图片实际像素一致,否则归一化结果会整体偏移。某些数据集的 xml 是脚本自动生成的,size 值可能取自图片属性,也可能取自标注工具的画布设置,两者在特殊情况下不一致。
类别映射表需要单独维护一个字典。字符串类别名在训练框架里无法直接使用,必须映射成从 0 开始的连续整数。映射关系需要固定下来,训练和推理时用同一份,否则会出现类别错位的严重事故。常见做法是写一个 classes.txt 文件,按行记录类别名,行的索引就是类别 id。这个文件是转换脚本和训练配置的桥梁,需要仔细核对。
3.2 完整的转换脚本及参数含义
下面的脚本实现 VOC 到 YOLO 的转换,并在输出时检查坐标合法性。这个脚本可以直接使用,但建议你先读懂每个参数的含义再跑,遇到异常时才能定位问题。
import xml.etree.ElementTree as ET import os class_map = {"crack": 0, "pothole": 1, "repair": 2, "manhole": 3} xml_dir = "./Annotations" img_dir = "./JPEGImages" out_dir = "./labels" os.makedirs(out_dir, exist_ok=True) def convert_voc_to_yolo(xml_path, img_w, img_h): tree = ET.parse(xml_path) root = tree.getroot() img_width = img_w if img_w else int(root.findtext("size/width")) img_height = img_h if img_h else int(root.findtext("size/height")) lines = [] for obj in root.findall("object"): name = obj.findtext("name") if name not in class_map: raise ValueError(f"未知类别: {name} 在 {xml_path}") bndbox = obj.find("bndbox") xmin = float(bndbox.findtext("xmin")) ymin = float(bndbox.findtext("ymin")) xmax = float(bndbox.findtext("xmax")) ymax = float(bndbox.findtext("ymax")) if not (0 <= xmin < xmax <= img_width and 0 <= ymin < ymax <= img_height): print(f"坐标越界警告: {xml_path}, 框({xmin}, {ymin}, {xmax}, {ymax})") continue cx = (xmin + xmax) / 2.0 / img_width cy = (ymin + ymax) / 2.0 / img_height bw = (xmax - xmin) / img_width bh = (ymax - ymin) / img_height lines.append(f"{class_map[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") return lines for xml_name in os.listdir(xml_dir): if not xml_name.endswith(".xml"): continue xml_path = os.path.join(xml_dir, xml_name) img_path = os.path.join(img_dir, xml_name.replace(".xml", ".jpg")) img_w = img_h = None # 优先从图片读取实际宽高,避免 xml 里 size 字段错误 if os.path.exists(img_path): from PIL import Image with Image.open(img_path) as im: img_w, img_h = im.size lines = convert_voc_to_yolo(xml_path, img_w, img_h) out_path = os.path.join(out_dir, xml_name.replace(".xml", ".txt")) with open(out_path, "w") as f: f.write("\n".join(lines)) print("转换完成,标签输出到", out_dir)这段脚本有几个值得注意的设计决策。一是从图片读取实际宽高,而不是无脑信任 xml 的 size 字段,这一步能避免不少隐蔽的坐标漂移问题。二是坐标越界时输出警告后继续执行,而不是直接中断,因为路面缺陷数据里偶尔会有标注框超出图像边界几个像素的情况,这种框一般不影响训练,但数量多时说明标注工具或转换环节有问题。三是类别映射硬编码成字典,实际使用时建议读外部文件,方便切换数据集。
转换后建议抽查几个 txt 文件的内容。正常的一行应该是类似0 0.512345 0.678901 0.123456 0.087654这样五个数值,类别 id 是整数,坐标是 0 到 1 的小数。如果出现负数或大于 1 的值,说明换算过程有误,需要检查分母是否用错。这里把 .6f 保留六位小数,是因为 YOLO 训练时坐标精度在小数点后四位就足够,六位是留了余量,不影响训练结果。
注意:类别映射一旦确定,中途不要修改顺序。比如 crack 映射成 0,之后新增一个类别,应该追加为 4,而不是把 crack 改成 5 再把新类别放前面。否则已经生成的 txt 文件全部作废,训练日志里的类别指标也会失去连续性。
3.3 图片和标注文件命名对齐的硬性要求
YOLO 训练时图片和标签的对应关系只靠文件名匹配,这个匹配规则在不同框架里略有差异。Ultralytics YOLO 的默认规则是,标签文件与图片文件同名,扩展名为 .txt,放在与图片同级的 labels 目录下。如果你的数据里图片是 .jpg 而 xml 对应的标注是 .png 后缀,或者图片文件名和 xml 文件名大小写不一致,都会直接导致训练时标签缺失。
处理这个问题有两个层次。命名本身要规范,图片统一转成 .jpg 或 .png,xml 文件名和图片名完全一致。更可靠的做法是把图片文件名和对应的 xml 文件名做一个一一对应的清点,脚本输出差异列表,人工复核。很多数据集在打包时图片和标注来自两个不同来源,合并后文件名只对齐了一部分。
import os img_dir = "./JPEGImages" xml_dir = "./Annotations" img_names = set(f.split(".")[0] for f in os.listdir(img_dir)) xml_names = set(f.split(".")[0] for f in os.listdir(xml_dir)) missing_xml = img_names - xml_names missing_img = xml_names - img_names print("有图片无标注:", len(missing_xml), missing_xml) print("有标注无图片:", len(missing_img), missing_img)这段清点脚本逻辑很简单,但价值很高。有图片无标注的条目建议直接移出数据集,有标注无图片的条目在训练中不会产生实际影响,但会干扰统计脚本。处理这类问题的原则是宁缺毋滥,不要试图手补缺失的标注,因为路面缺陷的边界判断需要专业经验,随意补的框反而会引入噪声。
4. 训练集和测试集划分验证:比例核对与类别分布一致性检查
4.1 划分文件到底应该怎么用
标题里说已经做了训练集和测试集划分,常见的形式有两种:一种是 train.txt 和 test.txt 文本文件,里面每一行是图片的路径;另一种是 train 和 test 两个目录,分别存放图片和标签。两种形式本质上没有区别,训练框架最终需要的只是图片路径列表。
拿到划分文件后要检查的,不是谁分的、怎么分的,而是划分本身是否合理。训练集和测试集的比例是否符合预期,比如 8:2 或 9:1;测试集是否覆盖了全部类别,有没有某一类缺陷只在训练集出现、测试集完全没有的情况;两个集合之间有没有图片重叠。这三项检查做完了,划分的可靠性才算验证过。
重叠检查是最容易被忽略的。有些数据集是多次标注后合并的,划分脚本可能基于文件名排序取前百分之八十做训练,这时如果文件名有重复,或者同一种缺陷的两个视角被分到不同集合,就会造成数据泄漏隐患。更隐蔽的问题是测试集图片和训练集图片取自同一段路面视频的相邻帧,这种重叠肉眼看不出来,但会让测试指标虚高。
4.2 三个检查脚本:比例、类别覆盖、重叠
下面这段脚本把三项检查合在一起跑,输出一个相对完整的体检报告。它的输入是 train.txt 和 test.txt 两个文件的路径,以及标注目录的路径,输出是划分后的统计信息。
import os from collections import Counter def read_list(txt_path): with open(txt_path) as f: return [line.strip() for line in f if line.strip()] def stat_category(img_list, label_dir): counter = Counter() for img_path in img_list: base = os.path.basename(img_path) label_path = os.path.join(label_dir, base.replace(".jpg", ".txt")) if not os.path.exists(label_path): print("缺失标签:", label_path) continue with open(label_path) as f: for line in f: cls_id = int(line.split()[0]) counter[cls_id] += 1 return counter train_list = read_list("./train.txt") test_list = read_list("./test.txt") label_dir = "./labels" # 检查1: 比例 print(f"训练集 {len(train_list)} 张, 测试集 {len(test_list)} 张, 比例 {len(train_list)/(len(train_list)+len(test_list)):.1%}") # 检查2: 类别覆盖 train_cat = stat_category(train_list, label_dir) test_cat = stat_category(test_list, label_dir) print("训练集类别分布:", train_cat) print("测试集类别分布:", test_cat) missing = set(test_cat.keys()) - set(train_cat.keys()) print("测试集独有类别:", missing if missing else "无") # 检查3: 重叠 overlap = set(train_list) & set(test_list) print("重叠数量:", len(overlap))脚本里 stat_category 函数通过图片路径反推标签路径的做法,是建立在对命名规则有把握的前提上的。如果标签目录结构不是扁平的单目录,需要先调整这个函数里的路径拼接逻辑。类别分布打印出来之后,用肉眼对比两个 Counter 的数值,重点看比例关系:如果测试集中某类缺陷的数量只占训练集的百分之几,这个类别的测试指标基本没有参考价值。
三个检查的实际意义各有侧重。比例检查决定要不要提前补充验证集——严格来说很多路面色缺陷场景还需要训练集、验证集、测试集三份划分,验证集用于调超参数,测试集只做最终评估。类别覆盖检查决定了模型报告的可靠性。重叠检查决定测试指标是否可信,这项检查结果异常时,建议直接重新划分数据,不要尝试修补。
提示:如果原数据只有训练集和测试集两份,建议从训练集里再切出一部分做验证集,而不是把测试集拆开,因为测试集一旦参与调参就失去了评估意义。
5. 训练前避坑:标注质量问题和路径配置的连续翻车记录
5.1 现象:训练开始后 loss 不下降,检查发现 xml 坐标全为 0
有一次拿到类似的路面缺陷数据,没做校验直接转 YOLO 格式开训,训练到第 20 轮 loss 还在 9 左右徘徊,几乎没动。排查后发现 xml 文件里的 bndbox 四个坐标全是 0,相当于所有标注框都在图片左上角一个点。这类数据不算少见——标注工具在导出时如果图片加载失败,会用默认值填充坐标,四个 0 是最常见的默认值。解决方法是写个过滤脚本,把四个坐标全为 0 或宽高为 0 的 xml 直接剔除,不要尝试修复。出现这种问题的原因往往在于标注环节的某一次批量操作出错,修单条价值很低。
这种坑最麻烦的地方在于,表面看训练流程完全正常,数据加载不报错,loss 也按照预期缓慢下降,只是最终 mAP 惨不忍睹。更隐蔽的变体是:只有一部分 xml 文件坐标异常,比如十分之一,这时模型仍能学到部分信息,但精度上限被拉低。因此转格式前的坐标合法性检查是必须项,它花不了多少时间,但能避免后续十个小时的训练白跑。
5.2 现象:data.yaml 里路径写错,训练报错说找不到标签
YOLO 训练需要一份 data.yaml 文件,里面写 train、val、test 三个路径和 nc、names 两个字段。最常见的错误是路径层级写错。Ultralytics YOLO 对路径的处理是,如果显式给的是相对路径,会相对当前工作目录解析;如果给的是绝对路径,必须保证在训练机器上真实存在。跨机器迁移项目时,绝对路径是重灾区,换一台机器就全部失效。
这里的血泪经验是:路径全部写成绝对路径,并且把图片目录和标签目录设置成同级兄弟目录,而不是标签嵌套在图片目录里面。也就是./dataset/images/train/train_001.jpg和./dataset/labels/train/train_001.txt这样的结构。Ultralytics 默认会检查图片路径下是否存在同级 labels 目录,如果标签在别的位置,需要显式指定标签路径或在 yaml 中做映射。另外 nc 的数值必须和类别映射表数量一致,names 的顺序必须和类别的 id 对应,这三点经常一起出错,报错信息却不明显。出现找不到标签的情况,先查是否存在 labels 目录、文件名是否完全一致、类别 id 是否超出 nc 范围。
5.3 现象:训练集和测试集图片尺寸不一致,导致部分图片缺失标注
路面缺陷数据集的图片来源如果不统一,会出现部分图片是 1920x1080,部分是 1280x720 的情况。这听起来不影响训练,但实际上如果转换脚本从 xml 的 size 字段读取宽高,而 xml 里的 size 与实际图片不一致,归一化后该图片的所有框都是偏的。
排查方法是随机抽几张图,用 OpenCV 读取图片的宽高和 xml 的 size 比对,确认是否存在不一致。如果存在大批量不一致,建议在转换脚本里直接把图片读取宽高作为唯一真值,xml 的 size 字段完全忽略。另一种情况是图片有旋转信息,EXIF 里的 orientation 字段没有被处理,导致图片显示方向和实际像素排列不一致,标注框全部偏移九十度。这个问题在手机拍摄的测试图片上很常见。解决方案是在预处理阶段统一用不带 EXIF 的保存方式输出一遍。这个步骤看起来多此一举,但确实是多个项目里实际翻车过的位置。
5.4 现象:测试集指标虚高,检查发现训练集和测试集存在连续帧重叠
路面检测项目里,数据往往来自对道路视频的抽帧,如果划分时按时间顺序前百分之八十做训练、后百分之二十做测试,那么训练集末尾和测试集开头的帧在画面上几乎是同一段路面,只是差了几帧。模型在这类测试集上评估的结果会明显好于在真实新路段上的表现。
应对方法是在按文件划分之外,额外构造一个“序列级划分”,即把连续帧分组后再切分,确保同一路段只出现在训练或测试之一。这个操作不需要很精细,按时间戳或文件名序号做粗粒度分组即可。如果数据集提供的划分里没有考虑到这一点,建议重新划分自己用,不要为了省事拿着原有划分直接跑。这个问题的隐蔽性在于指标没有明显异常,mAP 可能在合理范围内,只是部署到现场后泛化能力不足。
6. 进阶验证:用可视化脚本确认标签转换正确,顺带生成一份 JSON 备用格式
绕过了前面所有坑,训练前最后一步是可视化验证。读取一张图片和对应的 txt 标签,把归一化坐标换算回像素坐标,画框后保存成图片,肉眼检查框是否贴合缺陷区域。这一步值得做,因为坐标计算正确不等于标注本身正确,有些标注框本身就偏了半个身位。建议每类抽查十几张,覆盖不同尺寸、不同光照条件的样本。
如果项目后续要接入自定义训练脚本或模型服务,JSON 格式往往比 txt 更方便。一个轻量的做法是把每张图的标签转成 COCO 风格的字典并序列化,接口对接时直接读 JSON,不用再解析 txt。下面这段代码把 YOLO 的 txt 格式转成 JSON 字典,同时读取图片尺寸用于坐标还原。
import json import os from PIL import Image def yolo_txt_to_json(label_path, img_path): with Image.open(img_path) as im: w, h = im.size items = [] 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) x1 = (cx - bw / 2) * w y1 = (cy - bh / 2) * h box_w = bw * w box_h = bh * h items.append({ "category_id": int(cls_id), "bbox": [round(x1, 2), round(y1, 2), round(box_w, 2), round(box_h, 2)] }) return {"image": os.path.basename(img_path), "width": w, "height": h, "annotations": items, "label_path": label_path}这段代码的运行结果不直接作用于训练,它的价值在于提供一个与框架无关的中间表示。比如你想用 Detectron2、MMDetection 这类框架时,它们的自定义数据集注册接口更习惯接收 JSON 或字典结构,有这个转换函数兜底,切框架的成本会低很多。后面的推理阶段做结果可视化也能复用同一套坐标换算逻辑,保持数据流的一致性。
最后一个习惯性的操作是把数据集信息汇总成一个 README 文件,记录类别映射表、划分比例、图片尺寸范围、已知问题这几项。这个文件不参与任何代码逻辑,但在项目隔一段时间重新捡起来时,能省去大量重新摸排的时间。自己做的历史数据集中,时间越久越容易忘记某些标注缺陷,凡是能用文档留存的细节,尽量别只放在记忆里。整理数据这件事没有终点,每次发现新的边界情况就往对应的处理函数里补一个判断分支,这套流程只会越用越顺手。希望这些步骤和踩坑记录能帮你少走一些弯路。
本文还有配套的精品资源,点击获取