简介:面向计算机、电子信息及数学等专业学生的YOLO数据集清洗工具包,是课程设计、期末大作业与毕业设计的实用参考。资源由资深算法工程师整理,包含源代码与配套文档说明,代码采用参数化编程,关键参数可方便更改,注释明细、思路清晰,并附带运行结果,下载后可直接对照验证,适合快速上手YOLO数据预处理与项目复现。压缩包共7个文件,以C++源码、Qt界面文件、工程配置pro、说明文档md等类型为主,zip包仅12KB,体量轻但结构完整。文件分工明确:C++源文件与头文件构成核心逻辑,界面文件提供可视化操作界面,工程配置文件用于项目构建,说明文档提供使用指引,便于移植与二次开发。目前已有379人学习下载,适合课程设计、期末大作业及毕业设计阶段的同学们参考借鉴。
1. yolo数据集的清洗工具:先洗数据,再谈调参
第一次拿 YOLO 训练自采数据集时,我在锚框和超参上折腾了很久,mAP 始终卡在 0.4 上下。后来把标注文件导出,用脚本扫了一遍,问题根本不在模型——两成左右的 bbox 越界,两百多张重复图像,还有一批图只有标签没有对应文件。数据源不干净,后面所有调参都是在有噪声的输入上白费劲。yolo数据集的清洗工具+源代码+文档说明,要解决的就是这一层:在训练前用可复现的脚本,把图像、标注文件、类别分布和重复样本全部查一遍,输出清洗报告和一版干净、可以追溯的数据集。
这篇文章会从清洗原理讲到源码拆分、配置参数和真实踩坑。新手可以照着命令跑通全流程,老手可以直接拿走体检脚本和训练前校验逻辑。无论你用的是 COCO、BDD100K 这类公开数据集,还是自采的矿泉水瓶、受电弓、车辆巡检图像,这套思路都适用。清洗不是数据增强的替代品,它做的是把错误样本拿掉、把分布暴露出来,让后续的增强和调参真正作用在有效信息上。
2. 清洗工具在洗什么:从越界框到类别失衡的六项必查项
清洗工具有一个很容易被误解的点:它不是替你做数据决策,而是把影响训练收敛的问题找出来,分成“可自动修”“必须删”“需要人工确认”三类。我一般把必查项收敛成六项:越界坏框、图像不可读、文件名不匹配、重复与近似重复、类别分布失衡与空标注、格式转换合法性。六个里面只有前三个适合全自动处理,后三个需要先出报告再决定,否则清洗本身就会变成新的错误来源。
因为清洗工具的核心是“只读体检 + 受控修改”,我建议不管最终用什么工具,都要先保证第一份输出是问题清单而不是直接删改后的数据集。
2.1 坏框与越界框:先跑一段只读体检脚本
YOLO 的损失函数里有坐标回归项,bbox 越界会让这部分梯度指向无意义的方向。更隐蔽的问题是宽或高为 0 的框、左下角大于右上角的框,这些在 VOC 格式里不会报错,进入训练后却会导致 loss 震荡甚至 NaN。公开数据集也不一定干净,COCO2017 的 JSON 结构虽然经过多轮校对,个别边角样本仍存在框尺寸与图像尺寸不一致的情况;BDD100K 这类自动驾驶数据集里夜间样本的 bbox 也偏粗糙。所以不要假设下载来的数据可以直接训练。
我一般会先用一段只读脚本做统计,不删任何文件,只输出报告:
import os, glob import xml.etree.ElementTree as ET from PIL import Image def inspect_voc(anno_dir, img_dir): bad = 0 total = 0 for xml_path in glob.glob(os.path.join(anno_dir, "*.xml")): root = ET.parse(xml_path).getroot() img_name = root.find("filename").text img_path = os.path.join(img_dir, img_name) if not os.path.exists(img_path): print(f"[missing img] {img_name}") continue W, H = Image.open(img_path).size # 损坏图片在这里会抛异常 for obj in root.findall("object"): b = obj.find("bndbox") xmin = float(b.find("xmin").text) ymin = float(b.find("ymin").text) xmax = float(b.find("xmax").text) ymax = float(b.find("ymax").text) total += 1 if xmin < 0 or ymin < 0 or xmax <= xmin or ymax <= ymin or xmax > W or ymax > H: print(f"[bad box] {img_name}: ({xmin},{ymin},{xmax},{ymax}) img=({W},{H})") bad += 1 print(f"total_boxes={total}, bad_boxes={bad}, bad_ratio={bad/max(total,1):.4f}")这段代码的判定逻辑有几处容易忽略。xmax 用>而不是>=,是因为图像宽高按像素数算时,右下角等于宽高并不越界,等于宽高说明框恰好贴边,这是合法的。宽和高的判定用xmax <= xmin而非xmax < xmin,这样能把宽度为 0 的框也揪出来。读取图像尺寸这里用 PIL 而不是 cv2,因为cv2.imread遇到损坏文件会静默返回 None,后面取.shape会再抛一个更难定位的异常,而 PIL 会直接报一个带文件名的 OSError,方便排查哪个文件坏了。
体检之后要决定“修”还是“删”。我的经验是越界比例低于 5% 时可以直接 clip 修正,高于 15% 就要回去查标注流程,正确定位是哪一步把坐标写歪了。清洗报告里要单独列出 “bad_ratio” 这个数字,它比总数更能反映数据质量。
2.2 重复与近似重复:感知哈希做候选,标注重叠做确认
自采数据里重复图的重灾区来自连续抽帧。视频里同一辆车在相邻帧之间往往只移动了几个像素,抽出来的几十张图在模型眼里几乎一样。这些近似重复样本会被模型反复学习,相当于人为放大了某一个场景在损失函数里的权重,造成验证集 mAP 在个别类别上虚高、换个场景就崩。这类问题在公开数据集里相对少见,但在行车记录仪、无人机巡线、监控视频抽帧里非常普遍。
近重复检测我习惯分两步走。第一步用感知哈希生成指纹,第二步看标注框的重叠情况,两步都命中才进候选删除列表。哈希距离这块没有统一阈值,属于“玄学”区域,我一般先设 12,跑完看 quarantine 目录里的样本再微调。
from PIL import Image def dhash(img, size=8): img = img.convert("L").resize((size + 1, size), Image.BILINEAR) diff = [] for row in range(size): for col in range(size): left = img.getpixel((col, row)) right = img.getpixel((col + 1, row)) diff.append(left > right) return sum(v << i for i, v in enumerate(diff))这里size=8控制指纹精度,生成的指纹长度是 64 位,两张图的指纹按位比较,汉明距离越小越相似。只用 dhash 很容易误删,比如同一条路白天和傍晚各拍一张,像素分布接近但内容语义不同。所以我会在 dhash 相似的基础上追加一条约束:两图的 bbox 列表也要高度重合,至少一组框的 IoU 大于 0.8,才会把这一对标记为重复候选。这样就能把“同一场景不同光照”和“同一视频连续帧”区别开,前者通常保留,后者删除。
2.3 类别分布与空标注:清洗报告要暴露长尾,但不能替你决策
类别分布是清洗报告里最容易看出问题的一项。极端情况是某一类有 5000 个框、另一类只有 50 个框,这种长尾如果不暴露出来,训练时模型会倾向于把所有预测都推向高频类。CCPD 车牌数据集类别少、样本量大,主要清洗点是坏框和模糊样本;CrowdHuman 这种密集人群数据集类别只有 person 但遮挡严重,清洗重点变成低质量密集框;而自采的小样本数据如矿泉水瓶检测,往往还要面对某些类别只有二三十个框的情况。
清洗工具能做的是输出类别分布表,同时统计空标注图的数量。空标注处理没有一个通用解,我一般保留 10% 到 20% 的空样本进训练集,纯背景负样本对压制误检很有帮助,但要不要留、留多少应该由人来定,工具只负责把 empty 数量报出来。
from collections import Counter import glob, os import xml.etree.ElementTree as ET def class_stats(anno_dir): counter = Counter() empty = 0 total = 0 for xml_path in glob.glob(os.path.join(anno_dir, "*.xml")): root = ET.parse(xml_path).getroot() objs = root.findall("object") if not objs: empty += 1 for obj in objs: counter[obj.find("name").text] += 1 total += 1 print(f"images={total}, empty={empty}") print(counter.most_common(10))上面这段脚本在 VOC 格式上做统计。如果标注是 COCO 的 JSON,遍历方式会不一样,但统计逻辑相同。注意 empty 的统计口径是“没有对象的图像”,而不是“没有标签文件的图像”,两者不同,后者可能只是漏标。
2.4 格式互转合法性:VOC、COCO、YOLO 三种坐标系的四个校验点
YOLO 训练消费的标注绝大多数是 txt 格式,也就是 cx, cy, w, h 且全部归一化到 [0,1]。而标注平台导出的往往是 VOC 格式的 xyxy 绝对像素,或者 COCO 格式的 xywh 绝对像素。格式转换是清洗流程里最容易出脏数据的环节,也是最值得放断言的地方。
| 格式 | 坐标系 | 框表达 | 最常见的坑 |
|---|---|---|---|
| VOC | 绝对像素 | xmin, ymin, xmax, ymax | 忘记限制 xmax <= W, ymax <= H |
| COCO | 绝对像素 | x, y, width, height | 把 width 当成了右下角坐标 |
| YOLO | 归一化 | cx, cy, width, height | 忘记除以图像宽高 |
转换时我会在函数里加断言,宁可抛异常也不把一个坏坐标写进训练集目录:
def voc_to_yolo(xmin, ymin, xmax, ymax, W, H): cx = (xmin + xmax) / 2.0 / W cy = (ymin + ymax) / 2.0 / H bw = (xmax - xmin) / W bh = (ymax - ymin) / H assert 0 <= cx <= 1, f"center x out of range: {cx}" assert 0 <= cy <= 1, f"center y out of range: {cy}" assert bw > 0 and bh > 0, "zero width/height after convert" return cx, cy, bw, bh很多人在转换时只检查 bbox 四角是否越界,忽略中心点。实际上中心点越界比角点越界更致命,因为 YOLO 的回归目标天然以中心点为核心,中心点在图像外意味着目标主体根本不在画面内,模型永远学不到有效特征。VOC 转 YOLO 时还有一个常见翻车点是把xmax - xmin写成xmax - x这类混用变量,脚本不报错但结果全错。这就是为什么清洗工具要在转换后对结果再跑一遍合法性断言,而不是信任转换函数本身。
3. 源码怎么拆:inspect、clean、split 三段式与输出物
清洗工具的源码结构直接决定它能不能被团队长期使用。我见过不少一把梭的单文件工具,一个脚本里既做体检又做删除还做划分,改一个阈值要通读 500 行代码。合理的做法是把源码拆成三个入口加一个公共库:inspect 管只读体检,clean 管受控清洗,split 管数据集划分,common 放公共读写函数。三个入口的生命周期不同,体检要反复跑,清洗是破坏性操作,划分依赖清洗结果,混在一起会让误操作成本变大。
这一章讲的就是这套拆分方式,代码量不大,但边界清楚,后面接训练流程会很顺。
3.1 目录结构与三个入口脚本的职责边界
我一般用下面这个目录布局:
. ├── config.yaml # 所有路径与阈值都在这一个文件里 ├── src/ │ ├── inspect.py # 体检,只读不改,输出 report/report.json │ ├── clean.py # 清洗,先把原数据备份,再生成干净集 │ ├── split.py # 按种子切 train/val,保证可复现 │ └── common.py # 图像与标注的公共读写函数 ├── report/ # 体检输出、清洗日志 └── README.md # 快速开始、参数表、FAQ 三件事common.py 里放的是 load_annotations、save_json、compute_dhash 这类各脚本共用函数。inspect.py 只依赖 common 的读函数,不写任何文件;clean.py 负责移动文件到 quarantine;split.py 只读清洗后的目录,再按比例划分。这样三条命令可以独立执行,也可以串成一条流水线。要注意 clean.py 不直接调用 inspect 的统计逻辑,而是读 inspect 生成的 report.json,这样如果清洗参数调错了,可以只重跑 clean 而不必重新体检。
3.2 体检脚本 inspect.py:只读不写,把问题落成 JSON
体检脚本的核心约束是“只读不写”,它要能反复运行,并且输出结构化的报告,而不是一堆散落的 print。这里给一个针对 YOLO txt 格式的最小可运行版本:
# inspect.py —— 只读检查,不修改任何文件 import argparse, json, os, glob def check_txt(txt_path, img_root): txt_name = os.path.splitext(os.path.basename(txt_path))[0] img_candidates = glob.glob(os.path.join(img_root, txt_name + ".*")) img_path = img_candidates[0] if img_candidates else None if img_path is None: return ["missing_image"] problems = [] with open(txt_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: problems.append(f"bad_line:{line.strip()}") continue x, y, w, h = map(float, parts[1:]) if w <= 0 or h <= 0 or x < 0 or x > 1 or y < 0 or y > 1: problems.append(f"bad_box:{parts}") return problems def main(): p = argparse.ArgumentParser() p.add_argument("--txt-root", required=True) p.add_argument("--img-root", required=True) p.add_argument("--report", default="report.json") a = p.parse_args() agg = {} for txt in glob.glob(os.path.join(a.txt_root, "*.txt")): probs = check_txt(txt, a.img_root) if probs: agg[os.path.basename(txt)] = probs with open(a.report, "w") as f: json.dump(agg, f, ensure_ascii=False, indent=2) if __name__ == "__main__": main()这段脚本里最关键的一行是txt_name + ".*"。图片可能是 jpg、png、bmp 甚至 JPEG 大写后缀,用文件名主干去匹配任意扩展名,比写死.jpg稳妥得多,这个习惯在后面避坑章节里还会再提到。参数方面,--txt-root指向标签目录,--img-root指向图像目录,--report是输出 JSON 的路径。脚本不会修改任何文件,只会把问题样本按文件名汇总到 JSON 里。
真实项目里 inspect 还会扩展更多检查项,比如 bbox 面积小于min_box_area的疑似误标、类别 id 是否落在class_map范围内、空标注图数量的统计。但结构不变:每个检查项返回一个问题列表,最后统一聚合写盘。输出 JSON 而不是 print 的另一个好处是可以直接喂给后面的清洗执行器,也方便在训练后回溯数据问题。
3.3 清洗执行器 clean.py:先备份再移动,保留后悔药
清洗是破坏性操作,所以 clean.py 的第一原则是“先备份,再移动,最后才考虑删除”。我习惯把有问题的样本移到一个带时间戳的 quarantine 目录,而不是直接 os.remove。这样训练结果如果变差了,还能从 quarantine 里把样本找回来,这个习惯在项目初期无比重要。
# clean.py —— 破坏性操作前先备份 import shutil, os, json, datetime def move_to_quarantine(item, src_roots, dst_quarantine): # item 是文件名主干,src_roots 是 [图像目录, 标签目录] moved = [] for src_root in src_roots: for ext in [".jpg", ".jpeg", ".png", ".bmp", ".txt", ".xml", ".json"]: src = os.path.join(src_root, item + ext) if os.path.exists(src): dst = os.path.join(dst_quarantine, os.path.basename(src)) shutil.move(src, dst) moved.append(src) return moved def main(): report = json.load(open("report/report.json")) bad_items = list(report.keys()) quarantine = f"quarantine/{datetime.date.today().isoformat()}" os.makedirs(quarantine, exist_ok=True) for item in bad_items: move_to_quarantine(item, ["./images", "./labels"], quarantine)这里要注意一个细节:move_to_quarantine遍历扩展名时,会把图像和标签文件一起迁移,不允许只删一侧。很多清洗脚本只删了出问题的图片,对应的标签文件还留在原目录,训练初始化时 label 与 image 对不上,报一个更难定位的错。移动完成后再改 config 里的data_root指向清洗后的目录,而不是在原始目录上打补丁。如果磁盘空间允许,我还会在移动前给原始目录打一个 tar 包,虽然很少用到,但它是真正的后悔药。
3.4 split.py 与 report.json:固定随机种子,让数据集版本可追溯
第三个入口是数据集划分。清洗完成之后,要把数据按固定随机种子划分成 train 和 val,保证同一份数据跑两次实验得到的是同一份划分,否则实验结果没法对比。split.py 的核心逻辑不复杂:把清洗后的图像文件名读进来,按比例 shuffle 后写入两个清单文件。
# split.py —— 固定种子划分,输出 train.txt / val.txt import random, os def split_files(image_dir, train_list, val_list, ratio=0.8, seed=42): names = [os.path.splitext(f)[0] for f in os.listdir(image_dir)] names.sort() rng = random.Random(seed) rng.shuffle(names) split_idx = int(len(names) * ratio) with open(train_list, "w") as f: f.write("\n".join(names[:split_idx])) with open(val_list, "w") as f: f.write("\n".join(names[split_idx:]))seed=42是超参数里最容易被忽略的一个。不固定随机种子的话,每次划分结果不同,模型效果差异可能被误归因于清洗或模型改动。划分结果建议同时写入 report.json 的 manifest 字段,记录“这版数据集包含哪些文件、从哪个原始版本清洗而来”。数据版本可追溯这件事,等到你要向别人解释 mAP 为什么提升了 2 个点时,会省掉一整天的嘴仗。
4. 跑通完整清洗流程:config.yaml、命令顺序与文档说明的读法
工具源码有了,接下来是落地。这一章讲的是怎么把上一步的脚本接成一条可重复执行的流水线:先改配置,再跑体检,看报告没问题后再预演清洗,最后才动真格。整套流程的核心是“每一步都有输出物”,体检输出 report.json,清洗输出 quarantine 目录和 manifest,划分输出 train/val 清单,后面训练脚本只消费这些输出物,不直接碰原始数据。
另外还要回答一个实际问题:拿到一份清洗项目源码时,文档说明应该怎么看、怎么用。文档不是说明书,它是你判断这个工具是否可信的依据。
4.1 一份最小 config.yaml 和它背后八个参数
把路径和阈值全部收敛到一个 config 文件里,是清洗工具能复用的前提。下面这份配置覆盖了大部分 YOLO 检测数据的场景:
data_root: ./raw_dataset output_root: ./clean_dataset quarantine_root: ./quarantine format: voc # voc / coco / yolo img_exts: [.jpg, .jpeg, .png] checks: bad_box: true dedup: true missing_image: true empty: keep # reserve / keep / drop dedup_threshold: 12 # 感知哈希汉明距离小于该值视为重复候选 min_box_area: 16 # 小于 16 像素的框会提示 split_ratio: [0.8, 0.2] seed: 42| 参数 | 取值 | 作用与影响 |
|---|---|---|
| data_root | 目录路径 | 指向原始数据集根目录,清洗脚本不会修改它 |
| format | voc / coco / yolo | 决定解析标注文件的方式,选错会直接导致解析失败 |
| checks.empty | reserve / keep / drop | 空标注图的处理策略,keep 是保留为负样本 |
| dedup_threshold | 整数 | 去重灵敏度,设置过大会误删有效样本,过小则漏掉重复 |
| min_box_area | 像素数 | 小于该值的 bbox 会在报告中提示为疑似错误标注 |
| split_ratio | [train比例, val比例] | 划分比例,测试集建议单独留出,不要从 val 里再切 |
| seed | 整数 | 固定划分和清洗顺序的随机种子,必须固定 |
| img_exts | 列表 | 识别图像扩展名,用于文件名主干匹配 |
checks.empty的三个取值值得展开。keep 表示保留空标注图进入训练集,适合绝大多数检测任务;reserve 表示把空标注图单独移到一个 negative 目录,需要训练配置里再挂一次;drop 则是直接删除,只适合目标几乎必然出现、背景无意义的任务。我一般不会用 drop,哪怕训练集的负样本比例不高,也宁愿用 reserve。
4.2 三条命令的标准执行顺序:先体检、再预演、最后清洗
清洗工具的典型调用是三条命令依序执行:
python src/inspect.py --config config.yaml python src/clean.py --config config.yaml --dry-run python src/clean.py --config config.yaml第一次先跑 inspect,输出 report.json。这个阶段最重要的不是直接清,而是看问题分布:坏框多不多、重复多不多、空标注占多少。如果 bad_ratio 超过 15%,先回去查标注流程而不是急着清洗。第二次跑 clean 时带上--dry-run参数,脚本只打印将要移动的文件列表,不实际移动,这一步我强烈建议保留,因为误删后在训练阶段才会暴露,时间成本高得多。
在 PyCharm 里配置好解释器后,也可以用同样的命令跑通这三步,注意脚本里依赖的第三方库只有 PyYAML 和 Pillow,装这两个就够了。不要在这一步引入 torch 或 ultralytics,清洗工具应保持轻量,跑得快才能让人愿意每次都跑。
4.3 文档说明看四件事:依赖、样例命令、输出定义、已知限制
一份文档说明值不值得信,我拿到手会先找四个部分,缺一个都要警惕:环境依赖清单、样例命令、输出物定义、已知限制。环境依赖用来判断工具能不能在当前环境跑起来,样例命令用来验证工具的作者自己是否真的用过它,输出物定义用来判断清洗报告字段是否清晰,已知限制用来判断它能不能处理你的数据格式。
如果文档只有接口说明没有一条可以复制的命令,这个工具多半是半成品;如果文档里明确写了“当前不支持 COCO 的 instance segmentation 标注”,说明作者清楚自己的边界,反而更可信。文档说明在落地时扮演三个角色:让新手五分钟跑通、让老手知道参数边界、让接手的人敢在三个月后改代码。后面这一点最容易被忽略,清洗工具一旦进入团队,它的维护者大概率不是你。
4.4 清洗结果怎么喂给训练:Ultralytics 与 Darknet 两种格式
清洗完成后,需要把干净数据集转换成训练框架认识的配置。Ultralytics 的 YOLO 系列用的是 dataset.yaml 指向 images 和 labels 目录;Darknet 风格则要求 train.txt 和 val.txt 里每行一张图片的绝对路径。建议清洗工具里带一个生成 dataset.yaml 的小脚本:
# make_dataset_yaml.py import yaml def write_dataset_yaml(path, train_dir, val_dir, class_map): cfg = { "path": "/absolute/path/to/clean_dataset", "train": train_dir, "val": val_dir, "names": class_map, } with open(path, "w") as f: yaml.safe_dump(cfg, f, allow_unicode=True)一个容易踩的坑是 dataset.yaml 里的 path 写成相对路径,导致在别的目录执行训练命令时找不到数据。另一个坑是 names 字典里的 id 必须与清洗时输出的 class_map.json 一致,比如自采数据里“person”排第几这个顺序不能有第二种写法。如果项目用到 YOLO 实例分割,清洗工具的图片级操作仍然适用,删除一张图时要连带删掉对应的 segmentation JSON;但坏框检查对多边形标注不适用,需要单独检查 polygon 点是否落在图像范围外,这是分割标注特有的清洗项。
5. 清洗工具落地的五个翻车点:现象、原因与排查步骤
前面几章把工具搭建和流水线讲完了,这一章专门写真实数据上会翻车的地方。这些坑有一个共同特点:单看每一项都不难,但它们往往要等到训练启动甚至训练结束后才暴露,排查成本极高。下面五条按“现象、原因、解决”三段式写清楚,碰到类似问题可以直接照着排查。
5.1 体检通过但训练报坐标越界:检查对象错了
现象:inspect 报告显示没有任何坏框,但 YOLO 训练一启动就报坐标越界或者 loss 出现 NaN,进程直接退出。
原因:清洗工具检查的是 VOC XML 的绝对坐标,而训练读的是转换后的 txt。转换脚本里宽高参数传反了,或者某个文件用的是上一张图的宽高,导致转换结果超出 [0,1] 范围。体检和训练消费的不是同一份标注,检查自然对不上。
解决:清洗流程里一定要加“转换后复检”这一步。在 voc_to_yolo 之类的转换函数里加断言,或者 clean 完成后对输出目录里的所有 txt 再跑一遍 inspect。我通常的做法是在 clean.py 末尾自动调用一次针对目标格式的检查函数,如果不通过就返回非零退出码,后续训练命令不会执行。这样能把错误挡在训练之前。
5.2 图像扩展名混用导致漏删:文件名比较只看主干
现象:清洗报告已经把问题样本列出来了,但清洗完再看原目录,某些标了问题的图片还在,训练时 label 与 image 对不上。
原因:删除脚本里写死了glob("*.jpg"),而实际目录里同一文件名的图片是 .png 或 .jpeg 后缀。文件名匹配用的是“完整文件名”,而不是“文件名主干”。
解决:所有清洗工具的文件操作统一走os.path.splitext取主干来匹配。前面 inspect 脚本里已经用了txt_name + ".*"的写法,clean 脚本也要保持同样的逻辑。另外注意 .jpeg 和 .jpg 这类同后缀扩展名差异,写扩展名列表时最好包含全部可能的变体。
5.3 类别 id 错位让所有标签整体漂移:class_map 必须单一来源
现象:训练后 mAP 极低,随机抽查图片发现“人”类别的位置输出的是“车”,所有预测结果整体错位。
原因:classes.txt 的顺序来过一次变更。第一个人标注时顺序是 person, car, tree,第二个人标注时导出顺序变成 car, person, tree,YOLO txt 里类别都是整数 id,肉眼完全看不出错。清洗工具如果只关心坐标合法性,不检查类别 id 的语义映射,这个问题会被直接放行。
解决:清洗工具必须输出一份 class_map.json,并且训练、标注导出、清洗三个环节都引用同一份文件。只要 class_map 顺序变化,所有 txt 都要同步重写。训练前再加一个交叉校验:统计每个类别 id 的样本数,如果某个类别出现 0 样本,说明映射可能出问题了。
5.4 去重误删不同光照下的有效样本:阈值不能只看哈希距离
现象:清洗后验证集 mAP 反而掉了 1 到 2 个点,查 quarantine 目录发现大量“同一地点、不同时间”的照片被当成重复删掉了。
原因:去重只看感知哈希的汉明距离,没有结合标注内容。白天和傍晚的同一辆车,像素整体分布接近,dhash 距离可能很小,但语义上是两个有效样本,模型可以从中学到光照鲁棒性。
解决:去重判定改成双重条件,哈希距离是候选条件,还必须有一组 bbox 的 IoU 大于 0.8 才确认删除。阈值也要按数据来源调整,视频抽帧产生的数据,帧间隔小,阈值设低一点,比如 6;行车记录仪间隔较大的数据,阈值可以到 16。这个数字没有标准答案,每次调完阈值都抽查一下 quarantine 目录,看删的是不是真的重复样本。
5.5 空标注全删导致背景误检:负样本要有保留策略
现象:清洗后模型在纯背景、无目标画面上频繁输出假目标,误检率飙升。
原因:清洗配置把空标注图当成“数据质量问题”全部删掉了,模型在训练里完全没有见过“这里没有目标”的负例,推理时自然会把背景纹理当成物体。空标注图不是脏数据,它是负样本。
解决:config.yaml 里的checks.empty不要设成 drop。先设 keep 保留 10% 到 20% 的空标注图,或者设 reserve 单独放一个 negative 目录,在训练配置里独立挂载。清洗报告里要把 empty 数量单独列出来,方便后续调整保留比例。这里没有任何一个工具能替你决定,必须结合实际场景判断一个纯背景画面对模型是助力还是噪声。
6. 验证清洗效果的最小实验:mAP 对比与三分钟体检习惯
清洗做完了,怎么知道它真的有效?最直接的做法是拿同一份原始数据跑两次训练,一次用清洗前,一次用清洗后,网络结构、超参数、训练轮数全部保持一致,只改数据入口。我自己会用同一个固定种子训练 50 到 60 个 epoch,对比清洗前后的 loss 曲线和验证集 mAP。如果 mAP 提升不足 0.5 个点,说明数据源本来就不脏,这时候不应该继续在清洗上花时间,应该回到模型结构或者数据增强上。如果 loss 曲线从震荡变成平稳下降,清洗的作用就体现出来了。
6.1 用同一份超参对比清洗前后的 loss 曲线与 mAP
对比实验要记录三个指标:训练 loss 是否下降更平稳、验证集 mAP 的变化、以及每个类别 AP 的分布。清洗最典型的收益不是 mAP 整体暴涨,而是低频类别的 AP 明显回升、误检率下降。看指标时要注意一点,清洗后总样本数变少,训练轮数可能需要相应增加,否则对比的是“更少数据”而不是“更干净数据”。
6.2 把体检脚本焊在训练流程前面,省掉一个星期的重训时间
最后说一个我自己的习惯,也不算什么高级技巧,就是把 inspect 脚本放在每次训练命令之前,让数据体检成为默认前置步骤。新数据进目录的那一刻就自动跑一遍体检再决定是否进入训练队列。养成这个习惯之后,我几乎没有再遇到过训练三天后才发现类别顺序标错的局面。清洗工具的价值不在于它有多智能,而在于它把数据质量从不可见变成可见。希望帮到你。
本文还有配套的精品资源,点击获取