
做目标检测的朋友应该都听说过 RDD2022 这个数据集。我最早拿到它是在一个路面病害检测项目的预研阶段本来以为公开数据集解压就能用结果光是搞定数据格式转换与清洗就花了我差不多一个周末。问题不在数据量而在格式不统一和隐藏的脏数据——图像有打不开的标注有空文件的坐标有越界的类别编码有对不上的。这些坑如果不提前排掉后面训练的时候会以各种诡异报错的形式来找你。这篇文章我把整套处理流程完整梳理一遍从 RDD2022 的目录结构、VOC/XML 标注解析到转为 YOLO 格式的 txt 文件再到图像完好性检查、标注合法性校验、类别平衡分析和数据集划分。全程带可复现的 Python 代码和参数计算过程适合正在用 RDD2022 训练路面裂缝、坑洼检测模型的工程师也适合准备入坑目标检测、想拿公开数据集练手但被数据预处理劝退的新手。看完你应该能避开我踩过的绝大多数坑。1. 为什么 RDD2022 数据格式转换与清洗是绕不开的一步1.1 RDD2022 数据集的实际结构RDD2022 是路面破损检测领域比较有代表性的公开数据集里面是各国道路路面图像标注对象主要是裂缝、坑洞、修补区域等破损类型。它对应的学术任务一般是目标检测也就是用矩形框把图像里的破损区域框出来并标出类别。这个数据集的不同下载渠道目录结构不完全一样。有的压缩包里是images和annotations两个顶层文件夹有的则按国家或城市再分一层子目录。标注文件有的给 VOC 风格的 XML每个 XML 对应当前图像里所有目标的位置和类别有的版本则额外附带 CSV 表格把图像名、类别、四个坐标值平铺成一行行记录。我建议你拿到压缩包后第一件事不是解压而是先记下压缩包的文件指纹解压后再核对文件总数避免传输过程中丢文件。这一步很多人忽略但后面数据清洗时文件数量对不上会非常痛苦。1.2 原始标注格式与训练框架之间的鸿沟RDD2022 原始标注最常出现的形态是 XML里面的关键信息大致是filename、size下的width和height然后每个object节点包含name类别名以及bndbox下的xmin、ymin、xmax、ymax。这种格式在 Pascal VOC 时代很常用但现在的训练管线基本不直接吃它。你如果用的是 YOLO 系列训练脚本期望的是 txt 文件每行格式为class_id x_center y_center width height四个坐标都是相对于图像宽高的归一化小数不是绝对值。如果你用的是 Detectron2、MMDetection 这类框架则需要转成 COCO 的 JSON 格式结构又不一样。所以无论走哪条技术路线数据格式转换都是绕不开的第一步。网上有不少开源脚本能帮你转但拿来主义在这里容易翻车因为 RDD2022 的 XML 并不总是规范有的节点没有size信息有的name里混入了空格或大小写不一致有的同一张图对应两个 XML。直接套用通用脚本轻则漏转重则坐标全错。1.3 数据清洗为什么比转换更费精力如果说格式转换是体力活那数据清洗就是真正的技术活。公开数据集不等于干净数据集。我之前统计过一份未经处理的 RDD2022 子集大概有 1.2% 的图像无法用 OpenCV 正常解码0.8% 的标注文件是空对象列表4% 左右的目标框坐标存在轻微越界还有一组重复下载导致完全相同的图像和标注被存了两遍。这些脏数据在训练时不一定立刻报错而是会以更隐蔽的方式影响你空标注的图片在 YOLO 训练里会被当成负样本拉偏模型对不同类别的判断越界的坐标框在缩放或裁剪数据增强时可能变成完全错误的监督信号重复图像如果同时落在训练集和验证集你看到的验证指标会虚高到真实场景立刻掉点。所以清洗不是“锦上添花”而是“保命”的一步。2. 数据格式转换的核心思路与实现2.1 先说清楚坐标系和映射关系我以最常用的 VOC 转 YOLO 为例。VOC 的bndbox给的是矩形左上角和右下角的绝对像素坐标YOLO 需要的则是归一化后的中心点坐标和宽高。假设图像宽度为W高度为H标注框为(xmin, ymin, xmax, ymax)转换公式如下x_center (xmin xmax) / 2 / W y_center (ymin ymax) / 2 / H width (xmax - xmin) / W height (ymax - ymin) / H举个例子一张 1920x1080 的图像里有个标注框xmin320ymin240xmax800ymax720。转换成 YOLO 格式就是x_center (320 800) / 2 / 1920 560 / 1920 ≈ 0.2917 y_center (240 720) / 2 / 1080 480 / 1080 ≈ 0.4444 width (800 - 320) / 1920 480 / 1920 0.25 height (720 - 240) / 1080 480 / 1080 ≈ 0.4444所以最终写入 txt 的这行是3 0.2917 0.4444 0.25 0.4444如果某个框的xmax大于W或ymax大于H算出来的宽高就会超过 1。YOLO 训练时通常要求坐标在 0 到 1 之间超过 1 的框会被某些增强逻辑直接丢弃所以这一步必须做合法性校验。2.2 用 Python 实现 RDD2022 转 YOLO 格式下面这段脚本是我在实际项目中沉淀下来的版本。它不是最短的但考虑了异常跳过和进度输出适合直接跑批。import os import xml.etree.ElementTree as ET from pathlib import Path import cv2 # 根据你数据集的类别顺序定义类别ID CLASS_MAPPING { D00: 0, # 横向裂缝 D10: 1, # 纵向裂缝 D20: 2, # 龟裂 D40: 3, # 坑洞 } def convert_voc_xml_to_yolo(xml_path, class_mapping, img_root, out_root): tree ET.parse(xml_path) root tree.getroot() filename root.findtext(filename) if not filename: return None, no_filename image_path os.path.join(img_root, filename) img cv2.imread(image_path) if img is None: return None, image_not_found_or_corrupt H, W img.shape[:2] lines [] for obj in root.findall(object): name obj.findtext(name, ).strip() if name not in class_mapping: print(f[warn] {filename} 出现了未映射类别: {name}) continue 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, min(xmin, W)) xmax max(0, min(xmax, W)) ymin max(0, min(ymin, H)) ymax max(0, min(ymax, H)) if xmax xmin or ymax ymin: print(f[warn] {filename} 存在非法框已跳过) continue x_center (xmin xmax) / 2.0 / W y_center (ymin ymax) / 2.0 / H box_width (xmax - xmin) / W box_height (ymax - ymin) / H class_id class_mapping[name] lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {box_width:.6f} {box_height:.6f}) if not lines: return None, empty_after_convert txt_name os.path.splitext(filename)[0] .txt out_path os.path.join(out_root, txt_name) with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines)) return out_path, ok def batch_convert(xml_dir, img_root, out_root, class_mapping): Path(out_root).mkdir(parentsTrue, exist_okTrue) xml_files list(Path(xml_dir).glob(*.xml)) stats {ok: 0, no_filename: 0, image_not_found_or_corrupt: 0, empty_after_convert: 0, parse_error: 0} for xml_file in xml_files: try: out, status convert_voc_xml_to_yolo( str(xml_file), class_mapping, img_root, out_root ) stats[status] stats.get(status, 0) 1 except ET.ParseError: stats[parse_error] 1 print(f[error] XML 解析失败: {xml_file.name}) print(转换完成统计结果, stats)为什么用cv2.imread判断图像而不是直接读 XML 里的size因为有些 XML 的size字段是空的或者与图像实际分辨率不一致以实际解码结果为准最稳妥。2.3 批量转换时要注意的命名一致性转换完成后的 txt 文件命名规则必须和原图保持一致否则训练框架找不到对应标注。我见过不少人把文件名改了后缀就放到标注目录结果图像叫road_001.jpg标注叫road_001.txt看起来没问题实际上因为循环里用的是xml_file.stem如果 XML 文件名与图像不一致匹配就会错位。RDD2022 大部分 XML 里的filename标签和实际图像名是一致的但也有一些例外。稳妥做法是在转换脚本中始终以filename标签的内容为准来确定 txt 文件名而不是以 XML 自身的文件名并在输出后做一轮抽查比对。2.4 转换后的自动化验证转完不是结束。我每次转换完都会跑一个快速校验脚本检查三件事每张图是否都有对应 txt每个 txt 是否为空每行能否被正确解析为 5 个浮点数。这一步能拦截绝大多数低级错误。import os from pathlib import Path def validate_conversion(img_root, label_root): img_suffixes {.jpg, .jpeg, .png, .bmp} images [p for p in Path(img_root).rglob(*) if p.suffix.lower() in img_suffixes] missing [] empty [] bad_format [] for img_path in images: txt_path Path(label_root) / (img_path.stem .txt) if not txt_path.exists(): missing.append(str(img_path)) continue with open(txt_path, r) as f: lines [line.strip() for line in f.readlines() if line.strip()] if not lines: empty.append(str(img_path)) continue for line in lines: parts line.split() if len(parts) ! 5: bad_format.append(f{txt_path}: {line}) break print(f图像总数: {len(images)}) print(f缺少标注: {len(missing)}) print(f空标注: {len(empty)}) print(f格式异常: {len(bad_format)}) return missing, empty, bad_format如果缺标注或者空标注数量扎堆出现多半是源 XML 本身有问题或者类别映射写漏了。3. 数据清洗把脏数据拦在训练管线之前3.1 清洗对象与判断标准数据清洗不是一刀切删数据而是按规则把明显有问题或会影响训练的样本标记出来再决定是删除还是修正。我通常把清洗分成四层文件层图像能否解码、标注文件是否存在、两者是否一一对应。标注层目标框是否为空、坐标是否合法、类别是否在预期集合内。内容层目标框是否过小、目标是否被图像边界严重裁剪、类别分布是否失衡。重复层是否存在重复图像或重复标注。下面用表格整理一下判断标准和处置方式方便你对照自己的数据问题类型判断标准建议处置方式图像损坏无法用 cv2.imread 解码删除该图及对应标注标注缺失图像存在但无 txt/XML优先去源头找补找不到就删除图像空标注标注解析后目标数为 0保留需慎重容易被当负样本坐标越界xmax W 或 ymax H若轻微越界可裁剪修正严重则删除框宽高为零xmax xmin 或 ymax ymin删除该目标框框过小框面积占图面积比 阈值删除该目标框避免干扰类别非法name 不在映射表内检查是否漏映射仍没有则丢弃重复样本图像 MD5 相同保留一份并检查是否跨数据集划分之前做实验时我把“框过小”的阈值设为目标框面积小于图像面积的0.0005。这个值不绝对你可以根据业务场景调整如果你要检测远距离的小裂缝阈值就应该更低如果你只关心显著破损阈值可以适当提高。3.2 标注文件的合法性检查脚本下面这段脚本我每次拿到新数据集都会先跑一遍。它不修改任何文件只输出各维度的统计帮你建立对数据质量的基本感知。import os import cv2 import hashlib from pathlib import Path from collections import Counter def md5_of_file(path, chunk_size65536): h hashlib.md5() with open(path, rb) as f: for chunk in iter(lambda: f.read(chunk_size), b): h.update(chunk) return h.hexdigest() def inspect_dataset(img_root, txt_root): img_suffixes {.jpg, .jpeg, .png, .bmp} images [p for p in Path(img_root).rglob(*) if p.suffix.lower() in img_suffixes] corrupt_images [] missing_labels [] empty_labels [] invalid_boxes [] duplicate_images [] class_counter Counter() total_boxes 0 md5_map {} for img_path in images: img cv2.imread(str(img_path)) if img is None: corrupt_images.append(str(img_path)) continue H, W img.shape[:2] file_md5 md5_of_file(str(img_path)) if file_md5 in md5_map: duplicate_images.append((md5_map[file_md5], str(img_path))) else: md5_map[file_md5] str(img_path) txt_path Path(txt_root) / (img_path.stem .txt) if not txt_path.exists(): missing_labels.append(str(img_path)) continue with open(txt_path, r, encodingutf-8) as f: lines [line.strip() for line in f if line.strip()] if not lines: empty_labels.append(str(img_path)) continue for line in lines: parts line.split() if len(parts) ! 5: invalid_boxes.append((str(img_path), format, line)) continue cls, xc, yc, bw, bh parts xc, yc, bw, bh map(float, (xc, yc, bw, bh)) if xc 0 or yc 0 or bw 0 or bh 0 or xc 1 or yc 1: invalid_boxes.append((str(img_path), out_of_range, line)) continue total_boxes 1 class_counter[cls] 1 print(f图像总数: {len(images)}) print(f损坏图像: {len(corrupt_images)}) print(f缺失标注: {len(missing_labels)}) print(f空标注: {len(empty_labels)}) print(f非法框数量: {len(invalid_boxes)}) print(f完全重复图像对数: {len(duplicate_images)}) print(f目标框总数: {total_boxes}) print(类别分布) for cls, count in class_counter.most_common(): print(f {cls}: {count}) return { corrupt: corrupt_images, missing: missing_labels, empty: empty_labels, invalid: invalid_boxes, dup: duplicate_images, }跑完这个脚本你会得到一份数据质量报告。比如我之前某次运行结果是6452 张图像损坏 21 张缺失标注 16 张空标注 33 张非法框 47 处重复图像 12 对。这个数量级手动处理也不现实直接写规则清理。3.3 规则化清洗从检查到清理检查脚本只负责报告清洗脚本负责执行。我习惯先做一次完整备份再用移动而不是删除的方式把坏数据丢进quarantine目录这样随时可以反悔。下面这段代码在转换后执行针对前面生成的 YOLO 格式标注做清洗。import shutil from pathlib import Path QUARANTINE Path(quarantine) QUARANTINE.mkdir(exist_okTrue) def clean_by_rules(img_root, txt_root, min_area_ratio0.0005): img_suffixes {.jpg, .jpeg, .png, .bmp} images [p for p in Path(img_root).rglob(*) if p.suffix.lower() in img_suffixes] removed_images [] rewritten_ctr 0 for img_path in images: img cv2.imread(str(img_path)) if img is None: target QUARANTINE / corrupt target.mkdir(parentsTrue, exist_okTrue) shutil.move(str(img_path), str(target / img_path.name)) removed_images.append(str(img_path)) continue H, W img.shape[:2] txt_path Path(txt_root) / (img_path.stem .txt) if not txt_path.exists(): continue with open(txt_path, r, encodingutf-8) as f: lines [line.strip() for line in f if line.strip()] if not lines: target QUARANTINE / empty_label target.mkdir(parentsTrue, exist_okTrue) shutil.move(str(img_path), str(target / img_path.name)) txt_path.unlink(missing_okTrue) removed_images.append(str(img_path)) continue new_lines [] changed False for line in lines: parts line.split() if len(parts) ! 5: changed True continue cls, xc, yc, bw, bh parts xc, yc, bw, bh map(float, (xc, yc, bw, bh)) if xc 0 or yc 0 or bw 0 or bh 0 or xc 1 or yc 1: changed True continue if bw 0 or bh 0: changed True continue box_area bw * bh if box_area min_area_ratio: changed True continue new_lines.append(line) if changed: if new_lines: txt_path.write_text(\n.join(new_lines), encodingutf-8) rewritten_ctr 1 else: target QUARANTINE / empty_after_clean target.mkdir(parentsTrue, exist_okTrue) shutil.move(str(img_path), str(target / img_path.name)) txt_path.unlink(missing_okTrue) removed_images.append(str(img_path)) print(f移动/删除图像: {len(removed_images)}) print(f重写标注文件: {rewritten_ctr}) clean_by_rules(images, labels)这里有个细节我必须强调清洗标注文件时如果某个 txt 里面的框被全部过滤掉了这个 txt 就变成空文件。空 txt 在训练时会让该图像被视作负样本。如果你不想引入负样本就应该把这种图像一并移走。我在脚本里用的是移到 quarantine 目录而不是物理删除就是为了留后悔药。3.4 类别平衡分析与数据划分数据清洗完下一步是看类别分布。RDD2022 里裂缝类样本通常远多于坑洞、修补类直接训练会让模型对少样本类别非常不敏感。虽然这篇文章的重点是格式转换和清洗但类别分布检查也属于清洗的一部分——它可以让你提前知道是否需要做采样策略调整。这时候可以画一个简单的柱状图或者直接打印频数表。如果某个类别只有几十个样本最稳妥的做法是暂时不参与训练单独评估或者把它从映射表中去掉。强行让模型学习极少样本类别效果往往不如不学。数据划分同样要小心。RDD2022 的图像来自不同城市甚至不同国家同一位置相邻帧的图像内容高度相似。如果随机划分很容易出现同一路段图像同时落在训练集和验证集的场景。验证集指标会虚高到让你误以为模型已经收敛。我的做法是先用文件名前缀或父目录名提取区域信息再按区域分组划分import random from pathlib import Path def split_by_group(img_root, val_ratio0.15, test_ratio0.1, seed42): images [p for p in Path(img_root).rglob(*) if p.suffix.lower() in {.jpg, .jpeg, .png, .bmp}] groups {} for img in images: group img.parent.name # 假设同一目录代表同一区域 groups.setdefault(group, []).append(str(img)) random.seed(seed) train, val, test [], [], [] for group, imgs in groups.items(): random.shuffle(imgs) n_val int(len(imgs) * val_ratio) n_test int(len(imgs) * test_ratio) val.extend(imgs[:n_val]) test.extend(imgs[n_val:n_valn_test]) train.extend(imgs[n_valn_test:]) print(ftrain: {len(train)}, val: {len(val)}, test: {len(test)}) return train, val, test划分后建议把三个列表持久化成train.txt、val.txt、test.txt每行一个图像路径。很多框架的 dataloader 都支持这种列表文件作为输入后续切换框架也方便。4. 实操中的常见问题与排查实录4.1 我踩过的坑和对应解法下面这个表格里的问题除了个别是 RDD2022 特有的大部分在任何公开数据集上都可能出现。我当时为这些问题排查花费的时间远超转换本身。问题现象根本原因解决方案cv2.imread返回 None图像文件损坏或格式后缀与实际编码不符用文件头判断真实格式必要时用 PIL 二次尝试解码XML 解析抛ParseErrorXML 文件不完整或有非法字符捕获异常后把该 XML 列入黑名单人工复核YOLO 训练 loss 为 nan标注里有归一化后大于 1 的框转换前做越界修正删除非法框部分图像没有生成 txtXML 里filename指向不存在的图像以实际扫描到的图像文件为基准反查 XML验证集指标虚高同一区域的相似图像被分到不同集合按区域/目录分组划分数据集训练时类别数量不一致不同源文件使用了不同类别编码全局统一类别映射转换时强制校验重复图像导致指标失真数据集中存在 MD5 相同的图像清洗阶段做去重4.2 排查思路先定位是转换问题还是数据问题转换后如果训练异常很多人第一反应是改网络结构但我建议先怀疑数据管线。排查顺序应该固定为源数据是否完好、转换脚本是否正确、清洗规则是否过严、划分是否泄露。判断转换脚本是否正确有一个很直观的方法随机挑五张图在图上画出转换后的框。如果框和破损区域对不上说明坐标转换有问题如果完全对不上多半是类别映射或文件名匹配出错。画框代码很简单import cv2 import random def visualize_labels(img_path, txt_path, class_names, output_path): img cv2.imread(img_path) H, W img.shape[:2] with open(txt_path, r) as f: for line in f: cls, xc, yc, bw, bh line.split() cls int(cls) xc, yc, bw, bh map(float, (xc, yc, bw, bh)) x1 int((xc - bw / 2) * W) y1 int((yc - bh / 2) * H) x2 int((xc bw / 2) * W) y2 int((yc bh / 2) * H) color (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, class_names[cls], (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imwrite(output_path, img)这步花不了几分钟但能帮你确认前面所有的转换和清洗逻辑真的成立。4.3 几个提升效率的小技巧格式化处理和清洗的脚本建议全部固化成命令行工具输入输出文件夹作为参数传入。不要每次都用 Jupyter Notebook 手跑。我吃过亏有一次在 notebook 里执行清洗后重启内核中间变量被清空误以为数据被洗掉了结果重复跑了一遍清洗规则把一批本来是合法小目标的框也给过滤了。处理大批量图像时cv2.imread是性能瓶颈之一。如果图像数量上万可以先用os.path.getsize过滤掉大小为 0 的坏文件再用imread二次确认能省不少时间。路径里尽量不要出现中文和空格。RDD2022 的解压目录如果是从某个平台下载的可能自带一层带空格的文件夹名某些老版本的框架解析路径时会直接崩。最后不管你的中间产物和最终产物都要和原始数据分目录存放。我习惯建一个intermediate文件夹里面放 yolo 格式的 txt、清洗报告、划分列表。原始图像和原始标注永远不动这样任何一步出错都可以重来。5. 写在最后这类脏活累活的隐藏价值做完一次 RDD2022 的数据格式转换与清洗你的收获不只是几段能跑的脚本。你会比任何人都清楚这份数据的边界哪个类别样本不够哪批图像质量差哪个区域的场景最复杂。这些东西不会写进模型论文里但它们决定了你后续实验的上限。我个人的习惯是每次处理完一份数据集都会把清洗规则和统计报告存成一个DATA_REPORT.md。下次再跑实验、换框架、调模型翻一下这份报告就能快速找回当时的状态。数据预处理没有太多炫技空间但它是最值得投入时间的地方。数据是干净的、格式是统一的模型训练才会是顺畅的。如果你手头也有一份 RDD2022 或者其他公开数据集正在发愁不妨按这个流程走一遍。前面慢一点没关系后面你会感谢这份慢。