ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

PCB缺陷检测数据集VOC转YOLO与去泄漏切分实践

PCB缺陷检测数据集VOC转YOLO与去泄漏切分实践

简介:这份docx文档面向电路板PCB缺陷检测的学习者与算法开发者,用于说明一套VOC与YOLO双格式数据集的获取方式及基本构成,帮助读者在训练目标检测模型前快速判断数据规模、类别分布与适用性。压缩包内共1个docx文件,大小约3.38MB,正文给出数据集的百度云下载地址,并记录9666张jpg图片、9666个VOC格式xml标注和9666个YOLO格式txt标注,涵盖falsecopper、missinghole、mousebite、opencircuit等8类缺陷,总标注框数51549,同时说明使用labelImg按类别绘制矩形框。文档还提示数据集中包含较多椒盐噪声生成的增强图片,并逐类列出标注框数,便于读者提前了解样本特点、类别不均衡情况与潜在噪声影响,也方便按类别抽样、设计损失函数和划分训练验证集。目前已有232人学习,适合需要核对数据规模、评估数据质量或搭建PCB缺陷检测实验的读者参考。

1. 9666张PCB缺陷图能干什么:先看清这8类和51549个框

拿到一个 PCB 缺陷检测数据集,第一件事不是急着敲训练命令,而是算清楚它到底能撑起什么量级的任务。这份资源是 VOC + YOLO 双格式,9666 张 jpg 对应 9666 个 xml、9666 个 txt,8 个类别合计 51549 个矩形框,平均每张图 5.3 个目标,属于典型的多目标小目标工业质检场景——一块板子上同时出现毛刺、鼠咬、开路、针孔很正常。产出方用 labelImg 拉框,类别名全英文,格式干净但不含 ImageSets 路径文件,切分要自己做。它适合三类人:想跑通工业缺陷检测全流程的算法工程师、需要一个真实多类别数据集做课程设计的在校学生、以及想验证小目标检测方案在实际产线上能不能用的人。需要提前说清一点:包里掺了数量不小的椒盐噪声增强图,这件事直接决定了后面的切分策略,第 4 章会专门拆。

2. 拆开数据包:VOC与YOLO双格式的目录结构与一次全量体检

解压之后最先看到的是裸露的 jpg 和 xml/txt 混在一起,还是已经按子目录分好,这取决于打包方式。不管哪种,先把目录摆放成两边都能读的结构,后面所有脚本都基于这套路径写,中途改路径是自找麻烦。

2.1 两套目录一次摆清,别让路径成为第一个坑

VOC 的惯例是JPEGImages存图、Annotations存 xml、ImageSets/Main存划分清单。这份资源没有 ImageSets,所以划分清单得自己生成。YOLO 的惯例是images与labels平行,且两者内部路径结构必须完全一致,images/train/xxx.jpg对应labels/train/xxx.txt,差一层目录都读不到。

我一般整理成这个形状,VOC 那份保留做溯源和回读校验,YOLO 那份给训练框架吃:

pcb8/ ├── JPEGImages/ # 9666 张 jpg,文件名不带后缀的 stem 作为主键 ├── Annotations/ # 9666 个 xml,与 jpg 同名 ├── images/ │ ├── train/ val/ test/ └── labels/ ├── train/ val/ test/

有一处容易忽略:jpg 和 xml 的 stem 必须严格一致。实际打包里常出现xxx.JPG大写后缀、或者 xml 里<filename>写的是原图名而磁盘文件名被改过的情况,这两种都会让标注工具和训练脚本对不上号,后面第 5 章会具体说症状。

2.2 八个类别定死 id:映射表与框数分布

类别顺序是整条链路上最容易翻车的地方。YOLO 的 txt 里只有数字 id,没有类别名,id 和名字的绑定关系完全靠data.yaml里的names列表维持。转换脚本用的顺序、yaml 里写的顺序、评估时打印的顺序,三处必须完全一致,错一位就变成"模型训得很好但 mAP 极低"的玄学现场。

按资源给出的类别名,我固定成下面这张表,id 从 0 开始按原始顺序排,不做任何"按框数重排"的小聪明:

id类别名常见中文叫法框数占全部框比例
0falsecopper冗余铜/残铜48529.4%
1missinghole缺孔47439.2%
2mousebite鼠咬(边缘缺口)788315.3%
3opencircuit开路803315.6%
4pinhole针孔745314.5%
5scratch划伤554310.8%
6shortcircuit短路673713.1%
7spur毛刺630512.2%

框数最多的 opencircuit 8033 与最少的 missinghole 4743 之间大约 1.7 倍差距,在工业数据集里算相当均衡了,不需要一上来就上重采样。真正麻烦的是框的尺寸——pinhole 和 missinghole 这类目标在整板图上经常只占十几到几十像素,这才是影响召回的主因,而不是类别不平衡。

2.3 全量体检脚本:一次性把数量、越界框、空标签、尺寸分布查干净

在写任何转换逻辑之前,先跑一遍体检。这一步花三分钟,能省掉后面半天的瞎猜。脚本只依赖标准库,xml.etree足够解析 labelImg 的输出。

import os from collections import Counter from xml.etree.ElementTree import parse ROOT = "pcb8" ANN = os.path.join(ROOT, "Annotations") IMG = os.path.join(ROOT, "JPEGImages") CLASSES = ["falsecopper", "missinghole", "mousebite", "opencircuit", "pinhole", "scratch", "shortcircuit", "spur"] # stem -> 文件名,用集合运算查缺图、缺标注 img_stems = {os.path.splitext(f)[0] for f in os.listdir(IMG) if f.lower().endswith(".jpg")} xml_stems = {os.path.splitext(f)[0] for f in os.listdir(ANN) if f.lower().endswith(".xml")} print("jpg:", len(img_stems), " xml:", len(xml_stems)) print("有图无标注:", len(img_stems - xml_stems)) print("有标注无图:", len(xml_stems - img_stems)) cls_cnt = Counter() # 每类框数 size_cnt = Counter() # 图像分辨率分布 boxes_per_img = [] # 每图目标数 bad_boxes = [] # 越界、退化框 empty_xml = [] # 零目标的 xml for stem in xml_stems: root = parse(os.path.join(ANN, stem + ".xml")).getroot() W = float(root.findtext("size/width")) H = float(root.findtext("size/height")) size_cnt[(int(W), int(H))] += 1 objs = root.findall("object") boxes_per_img.append(len(objs)) if not objs: empty_xml.append(stem) for o in objs: name = (o.findtext("name") or "").strip() cls_cnt[name] += 1 b = o.find("bndbox") x1, y1, x2, y2 = (float(b.findtext(k)) for k in ("xmin", "ymin", "xmax", "ymax")) # 三个判据:未知类别、零/负面积、超出图像边界 if name not in CLASSES: bad_boxes.append((stem, "unknown_cls", name)) elif x2 - x1 < 1 or y2 - y1 < 1: bad_boxes.append((stem, "degenerate", (x1, y1, x2, y2))) elif x1 < 0 or y1 < 0 or x2 > W or y2 > H: bad_boxes.append((stem, "out_of_bound", (x1, y1, x2, y2), (W, H))) print("框数统计:", dict(cls_cnt), "总计:", sum(cls_cnt.values())) print("分辨率 Top5:", size_cnt.most_common(5)) print("每图目标数 min/avg/max:", min(boxes_per_img), round(sum(boxes_per_img) / len(boxes_per_img), 2), max(boxes_per_img)) print("空标注 xml:", len(empty_xml)) print("异常框:", len(bad_boxes))

逻辑很直白:用集合差查配对,用size节点查分辨率是否统一,用bndbox四个角查退化框和越界框。参数上唯一要改的是ROOT,其余全是从 xml 里现读的,不写死。

跑完重点看三个数。第一,有图无标注是否为 0,不为 0 说明打包时丢了 xml,那部分图只能当负样本或者直接剔掉,别硬塞进训练集。第二,分辨率 Top5是不是只有一项,如果出现两三种分辨率混用,letterbox 之外的框架(比如按固定尺寸 resize 的)会把目标拉变形,框的位置随之偏移。第三,异常框里out_of_bound的数量,labelImg 手拉框贴边时xmax等于宽度甚至多 1 像素很常见,这类框不致命但要 clamp 掉,否则归一化后大于 1,部分框架会直接报错退出。

空标注 xml也别忽略。零目标的图有两种用法:丢掉,或者保留成负样本帮助降误检。工业质检里背景干净的板子多,保留一部分负样本通常有益,但比例别超过 5%,否则正样本梯度会被稀释。

3. VOC转YOLO:归一化换算与一份可回读校验的转换脚本

格式转换本身不复杂,麻烦全在边界上。资源同时给了 xml 和 txt,理论上可以直接用 txt,但我仍然建议自己从 xml 转一遍,原因是:你能控制 id 顺序、能顺手 clamp 越界框、能记录转换前后的框数对账,出了问题有据可查。

3.1 归一化不是除以宽高那么简单

VOC 的bndbox是像素绝对坐标,左上角(xmin, ymin)、右下角(xmax, ymax),labelImg 存的是 0 基坐标。YOLO 要的是中心点加宽高、并且归一化到 0~1:

xc = (xmin + xmax) / 2 / W yc = (ymin + ymax) / 2 / H bw = (xmax - xmin) / W bh = (ymax - ymin) / H

三个细节决定成败。一是xmax允许等于W,此时xc + bw/2恰好等于 1,浮点误差可能算出 1.0000001,得 clamp 到[0, 1]。二是宽度或高度算出来小于 1 像素的框必须先剔除再归一化,否则会生成w=0.00003这种在特征图上完全消失的框,训练时表现为 loss 突然 NaN 或者某类 AP 恒为 0。三是写文件时保留 6 位小数足够,YOLO 解析器不在乎精度,但保留太多位会让 txt 体积膨胀。

3.2 转换脚本

从 xml 转 txt,单文件对单文件,顺手做 clamp 和退化框过滤。这份资源已经带了 txt,脚本跑完先备份原 txt,再覆盖,方便对比两边框数是否一致。

import os from xml.etree.ElementTree import parse ROOT = "pcb8" ANN = os.path.join(ROOT, "Annotations") LBL = os.path.join(ROOT, "labels_all") # 先输出到临时目录,校验通过再替换 CLASSES = ["falsecopper", "missinghole", "mousebite", "opencircuit", "pinhole", "scratch", "shortcircuit", "spur"] CLS2ID = {c: i for i, c in enumerate(CLASSES)} os.makedirs(LBL, exist_ok=True) stat = {"imgs": 0, "boxes_in": 0, "boxes_out": 0, "drop_degenerate": 0, "drop_unknown": 0} for fn in sorted(os.listdir(ANN)): if not fn.endswith(".xml"): continue stem = os.path.splitext(fn)[0] root = parse(os.path.join(ANN, fn)).getroot() W = float(root.findtext("size/width")) H = float(root.findtext("size/height")) lines = [] for o in root.findall("object"): stat["boxes_in"] += 1 name = (o.findtext("name") or "").strip() if name not in CLS2ID: # 拼写不一致、大小写混用都在这里被拦下 stat["drop_unknown"] += 1 continue b = o.find("bndbox") x1 = float(b.findtext("xmin")); y1 = float(b.findtext("ymin")) x2 = float(b.findtext("xmax")); y2 = float(b.findtext("ymax")) # 先裁到图像范围内,再判退化,顺序不能反 x1 = max(0.0, min(x1, W)); x2 = max(0.0, min(x2, W)) y1 = max(0.0, min(y1, H)); y2 = max(0.0, min(y2, H)) if x2 - x1 < 1.0 or y2 - y1 < 1.0: stat["drop_degenerate"] += 1 continue xc = (x1 + x2) / 2.0 / W yc = (y1 + y2) / 2.0 / H bw = (x2 - x1) / W bh = (y2 - y1) / H xc = min(max(xc, 0.0), 1.0); yc = min(max(yc, 0.0), 1.0) bw = min(max(bw, 0.0), 1.0); bh = min(max(bh, 0.0), 1.0) lines.append(f"{CLS2ID[name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") stat["boxes_out"] += 1 # 空文件也要写出来,表示这是一张负样本,而不是缺失标注 with open(os.path.join(LBL, stem + ".txt"), "w", encoding="utf-8") as f: f.write("\n".join(lines)) stat["imgs"] += 1 print(stat)

参数上只有ROOT和CLASSES需要按实际改。CLASSES的顺序就是最终的 id,改这里等于改标签语义,一旦定了就别再动。

脚本做了两件"多余"的事,都是血泪经验换的。第一,未知类别不静默跳过而是计数,跑完drop_unknown必须是 0;不为 0 就说明 xml 里存在拼写变体(比如false_copper、ShortCircuit),得回头清洗,不能让它变成一批无标注图悄悄进入训练集。第二,空标注写空文件而不是不写文件。训练框架区分"这张图没有目标"和"这张图的标注丢了",前者是合法负样本,后者是数据错误,少写文件会让框架把这张图当背景丢掉,等于白扔。

3.3 回读校验:把 txt 反算回像素框再和 xml 对账

转换完直接开训是最常见的偷懒。我一般加一道反向校验:读 txt,按W/H反算回像素坐标,和原 xml 的四个角比,误差超过 1 像素就报出来。这一步能抓住两类隐蔽错误——归一化时把宽高用反(拿 H 除 x),以及某个类别的 id 映射整体偏移一位。

import os from xml.etree.ElementTree import parse ROOT = "pcb8" ANN = os.path.join(ROOT, "Annotations") LBL = os.path.join(ROOT, "labels_all") max_err = 0.0 mismatch = 0 for fn in os.listdir(ANN): if not fn.endswith(".xml"): continue stem = os.path.splitext(fn)[0] root = parse(os.path.join(ANN, fn)).getroot() W = float(root.findtext("size/width")); H = float(root.findtext("size/height")) gt = [] for o in root.findall("object"): b = o.find("bndbox") gt.append(tuple(float(b.findtext(k)) for k in ("xmin", "ymin", "xmax", "ymax"))) with open(os.path.join(LBL, stem + ".txt"), encoding="utf-8") as f: rows = [ln.split() for ln in f.read().strip().splitlines() if ln.strip()] if len(rows) != len(gt): mismatch += 1 continue for row, (gx1, gy1, gx2, gy2) in zip(rows, gt): xc, yc, bw, bh = map(float, row[1:5]) px1 = (xc - bw / 2) * W; py1 = (yc - bh / 2) * H px2 = (xc + bw / 2) * W; py2 = (yc + bh / 2) * H err = max(abs(px1 - gx1), abs(py1 - gy1), abs(px2 - gx2), abs(py2 - gy2)) max_err = max(max_err, err) print("框数不一致的图:", mismatch, " 最大像素误差:", round(max_err, 3))

正常结果应该是mismatch=0、最大像素误差小于 0.01。如果误差在 1 像素上下,说明某处做了 round 取整,不影响训练;如果误差到了几十像素,基本就是宽高用反了,回去查公式。

4. 椒盐噪声增强图:分组切分、采样与去泄漏

这份资源最需要当心的地方不在格式,在数据构成。原始说明里写得很清楚:里面有很多椒盐噪声生成的增强图片。这句话意味着同一张原始板图对应多张加噪版本,它们的内容几乎相同、标注完全相同。如果按文件随机切分,同一张原图的噪声版会同时落进训练集和验证集,验证指标会漂亮得不像话,上线一测直接打回原形。

4.1 同源增强图跨集,指标虚高能有多离谱

假设 9666 张里有 3000 张是噪声增强版,随机按 8:1:1 切,粗略估算会有三成左右的验证图能在训练集里找到"双胞胎"。模型不需要学会识别缺陷,只要记住噪声纹理和大致位置就能给出高分。这种虚高在验证集上可能反映为 mAP 涨 10 个点以上,而换一个真实产线批次去测,掉回来的幅度同样惊人。

判断依据很简单:把验证集里的每张图拿去训练集做一次近似检索(缩略图哈希、感知哈希都行),命中率高就说明切分有泄漏。做这个检查花不了多少时间,比训完再怀疑人生划算得多。

4.2 先探测命名规律,再定分组键

去泄漏的前提是能认出"哪些图同源"。多数增强脚本会在文件名尾部留下痕迹,比如board_0123_1.jpg、board_0123_noise.jpg、board_0123_sp.jpg。但也有人直接重排成img_00001.jpg到img_09666.jpg,把原始对应关系抹掉,那就只能靠内容相似度分组。

先打印一批文件名看看规律,别上来就写死正则:

import os, re from collections import Counter IMG = "pcb8/JPEGImages" stems = [os.path.splitext(f)[0] for f in os.listdir(IMG) if f.lower().endswith(".jpg")] for s in stems[:15]: print(s) # 统计"最后一个下划线后面那段"的分布,能快速看出增强后缀长什么样 tail = Counter(re.split(r"[_\-]", s)[-1] for s in stems) print(tail.most_common(20))

看tail的分布:如果出现大量noise、sp、aug、纯数字这类尾部,基本可以确定后缀规则;如果尾部全是随机字符或者根本看不出模式,说明命名被重排过,只能走内容哈希。资源里给的是网盘打包,命名风格没法提前断言,所以这一步不能省。

4.3 分组切分脚本

确定后缀规则后,把 stem 剥掉增强后缀得到group_key,同一个 key 下的所有图必须整组进入同一个集合。按 8:1:1 分,测试集可以留也可以不留,做课程设计建议留一份完全不碰的 holdout。

import os, re, random from collections import defaultdict IMG = "pcb8/JPEGImages" OUT = "pcb8/splits" SUFFIX = re.compile(r"[_\-](noise|salt|pepper|sp|aug\d*|aug|n\d+|\d{1,3})$", re.I) stems = sorted(os.path.splitext(f)[0] for f in os.listdir(IMG) if f.lower().endswith(".jpg")) groups = defaultdict(list) for s in stems: groups[SUFFIX.sub("", s)].append(s) # 剥掉增强后缀,同源图归到一组 keys = sorted(groups) random.seed(42) # 固定种子,保证每次切分结果可复现 random.shuffle(keys) n = len(keys) n_val = max(1, int(n * 0.1)) n_test = max(1, int(n * 0.1)) split = { "val": keys[:n_val], "test": keys[n_val:n_val + n_test], "train": keys[n_val + n_test:], } os.makedirs(OUT, exist_ok=True) for name, ks in split.items(): with open(os.path.join(OUT, f"{name}.txt"), "w", encoding="utf-8") as f: for k in ks: for s in groups[k]: f.write(s + "\n") print(name, "组数:", len(ks), "图片数:", sum(len(groups[k]) for k in ks))

关键参数是SUFFIX正则和seed。正则一定要对照 4.2 打印出来的真实后缀写,写宽了会把正常编号当后缀剥掉,导致大量不相干的图被强行归成一组,训练集缩水;写窄了等于没分组,泄漏照旧。判断标准很实在:跑完看train/val/test的图片数比例是不是接近 8:1:1,如果某一组图片数异常膨胀,多半是正则剥过头了。

4.4 采样与增强策略:噪声图该占多少

分组切分只解决了泄漏,没解决分布问题。如果噪声增强图占了总量的一半,训练集里就会有一半样本带着同一种人造噪声纹理,模型很容易把它当成判别特征。我的做法是分两步。

第一步,训练集内部对同源组做上限控制,同一group_key最多保留 2 到 3 张增强版,其余只在需要做鲁棒性对比实验时按需取用。第二步,验证集和测试集只放原始图,不放噪声版,这样报出来的指标对应的是真实场景,而不是"在噪声分布上的自评"。

增强策略上也要相应调整。既然数据里已经有人造椒盐噪声,训练侧就不该再无脑叠加同类型噪声增强,否则真实分布被推得更远。常见做法是训练增强以几何变换为主——随机缩放、平移、马赛克、HSV 抖动,噪声类增强只在做消融时单独开关。这个取舍在工业质检里很重要:产线上的噪声类型和增强脚本生成的噪声未必一致,过度拟合某一种噪声纹理反而降低泛化。

5. 避坑与排查:这份数据集落地时最容易翻车的五处

下面五条都是跑这类双格式工业数据集时反复出现的症状,按"现象 → 原因 → 解决"记,遇到了直接对号入座。

5.1 训练日志里正样本数远小于 51549,甚至提示大量背景图

现象:开训第一个 epoch,日志里的 instances 数量只有几万甚至更少,或者框架提示backgrounds占比异常高。

原因:images/与labels/的目录层级没对齐。YOLO 系列的标签路径是把 images 路径里的images替换成labels推出来的,如果一边是images/train/xxx.jpg、另一边是labels/xxx.txt,推出来的路径全部落空,框架会把所有图当负样本。

解决:用一条命令对账,两边 stem 集合必须完全相等,且相对路径一致:

cd pcb8 diff <(find images -name "*.jpg" | sed 's#images/##; s#\.jpg$##' | sort) \ <(find labels -name "*.txt" | sed 's#labels/##; s#\.txt$##' | sort) | head

输出为空才继续往下走,有输出就按提示补目录或改结构。

5.2 训练 loss 正常下降,但验证 mAP 一直在 0.01 附近

现象:分类 loss 和框回归 loss 都收敛得不错,评估时各类 AP 几乎全为 0,混淆矩阵一整片糊在背景上。

原因:id 顺序错位。转换脚本按字母序排了类别,data.yaml里按资源原始顺序写的,两边对不上,模型学到的"0 号类"和评估时的"0 号类"根本不是同一个缺陷。

解决:把 yaml 里的names和转换脚本里的CLASSES抄到同一份文件里做唯一来源,两边都从这个文件读。改完重转一遍 txt,别在原 txt 上打补丁。

5.3 图像分辨率不统一,框位置整体偏移

现象:可视化预测框时,框的大小看着对,位置整体平移了一段,或者框被拉成长条。

原因:数据集里混了两种以上分辨率,而训练配置用的是固定尺寸直接 resize 而不是 letterbox。直接 resize 会改变宽高比,归一化坐标虽然不变,但图像内容被拉伸,等效于框位置错位。

解决:先看 2.3 脚本输出的分辨率分布。多分辨率共存时优先用 letterbox 的框架配置,或者统一预处理到同一尺寸再入库,绝不靠训练时隐式 resize 兜底。

5.4 验证集指标好得离谱,换一批真实图就崩

现象:验证集 mAP 0.9 以上,拿产线新拍的板子去测,漏检一大片。

原因:椒盐噪声增强图和原图被随机切分打散,同源图跨了训练集和验证集,验证集实际上是训练分布的近邻。

解决:按第 4 章的分组切分重做一遍,验证集只留原图。重做完指标一般会掉几个点,那才是真实水平。掉得越多说明之前的泄漏越严重,不是模型退步了。

5.5 针孔、缺孔两类召回明显偏低,其他类正常

现象:8 个类里 pinhole 和 missinghole 的 AP 比其余类低一截,误检倒是不多,就是检不出来。

原因:这两类目标在原图上普遍只有几十像素,缩到 640 输入后特征图上不到 8 像素,卷积下采样几轮就没了。

解决:优先提高输入尺寸到 960 或 1280 试一轮,对比这两类的 AP 变化;如果显存不够,改用切图训练加切图推理(大图切片、重叠区做 NMS 合并)。这两条路我在小目标缺陷上都试过,切图对小目标召回提升最直接,代价是推理耗时翻倍,得按产线节拍权衡。

6. 训练与复核:把8类mAP拆开看的三个动作

6.1 先跑一个不加料的基线

基线的作用是给你一个参照系。数据按第 4 章切好、yaml 写对之后,先跑一个不动任何超参的版本,输入尺寸取 640,增强只开几何变换,记录总 mAP 和每类 AP。配置写成 yaml 放在项目根目录,和转换脚本共用同一份类别列表:

# pcb8.yaml path: /data/pcb8 train: splits/train.txt val: splits/val.txt test: splits/test.txt names: 0: falsecopper 1: missinghole 2: mousebite 3: opencircuit 4: pinhole 5: scratch 6: shortcircuit 7: spur

训练命令按你本地的框架来,以常见的检测器 CLI 为例:

# 权重换成你本地已有的检测权重;imgsz 先用 640 出基线 yolo detect train data=pcb8.yaml model=yolov8s.pt imgsz=640 epochs=100 batch=16 workers=8 seed=42

seed=42和切分脚本里的种子保持一致,保证可复现;workers按机器核数给,给太大会在读取大量小文件时卡 IO。基线跑完把results.csv存一份,后面任何一个改动都拿它对比,不做无参照的调参。

6.2 盯住 mousebite 和 spur 的混淆,而不是总 mAP

总 mAP 涨 0.5 个点没什么信息量,按类别拆开才有。这两类最容易互相串:鼠咬是板边被咬掉一块的缺口,毛刺是铜箔边缘多出来的尖出物,在低分辨率下都表现为边缘的小块异常,混淆矩阵里经常成对出现。我的习惯是先看混淆矩阵,如果mousebite → spur或反向的误分超过该类样本的 10%,就去检查标注本身——翻二十张这类图的原图和框,看有没有同一个缺陷被标成两个类的情况。数据集标注以矩形框为准,边界模糊的目标在不同标注者手里本来就有分歧,这类问题靠调模型解决不了。

6.3 小目标的后悔药:提高输入尺寸与切图推理

pinhole 和 missinghole 的 AP 是这套数据的天花板所在。先做一轮imgsz对比,640 / 960 / 1280 各跑一次,只看这两类的 AP 变化,其余类当对照。如果 960 相比 640 有明显提升而 1280 收益递减,说明分辨率已经够用,瓶颈在别处;如果一直线性涨,那就得考虑切图。

切图推理的实现要点是重叠率给到 0.2 到 0.3,切片边缘的目标在两个相邻切片里都会出现,合并时靠 NMS 去重。重叠率给低了会切掉目标,给高了显存和耗时都上去。我一般先在验证集上扫 0.1 / 0.2 / 0.3 三档,挑召回最高的那一档再上产线。

从那以后我每次拿到带"增强图片"字样的数据集,都强制先跑一遍同源分组和跨集近似检索,再动训练脚本——这个习惯救过我不止一次,虚高的验证指标比低指标更害人。希望帮到你。

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

RELATED READING

延伸阅读

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