PCB缺陷检测数据集实战:VOC/YOLO转换、噪声泄漏与切片推理 简介面向电路板PCB缺陷检测的VOCYOLO数据集说明文档适合目标检测学习者、算法工程师与高校教学用户快速了解数据构成并用于缺陷识别实验前的数据核对。包内仅1个docx文档约3.38MB集中说明数据集结构、标注格式与获取方式文档所说明的数据集包含9666张jpg图像及一一对应的VOC xml与YOLO txt标注覆盖falsecopper、missinghole、mousebite、opencircuit、pinhole、scratch、shortcircuit、spur八类缺陷总标注框数51549并含椒盐噪声增强样本。文档对各类别框数、labelImg矩形框标注规则等作了汇总便于读者评估类别分布是否均衡、规划训练集划分与数据增强策略也可用于课程演示、模型对比和标注质量检查。已有232人学习适合作为PCB缺陷检测项目的数据准备参考帮助读者在训练前明确样本规模、类别定义与标注文件对应关系以及后续模型评估的基准设置。1. 9666 张图、51549 个框这份 PCB 缺陷数据集能撑起什么样的任务工业质检项目里最难处理的环节从来不是模型结构而是数据集。相机架好、光源调完坏板样本却攒不够这件事做产线的人都经历过。这份资源提供 9666 张 jpg等量的 Pascal VOC xml 与 YOLO txt覆盖 falsecopper、missinghole、mousebite、opencircuit、pinhole、scratch、shortcircuit、spur 八类 PCB 缺陷用 labelImg 拉框标注合计 51549 个矩形框平均每张图 5.33 个目标。它只能做目标检测没有分割掩码谈不上像素级缺陷轮廓。先给一个反直觉的结论这份数据真正的麻烦不是“8 类平不平衡”最大类与最小类的框数比只有 1.69比大多数公开数据集都健康而是里面混着一批椒盐噪声生成的增强图。不处理训练集和验证集之间就会同源泄漏mAP 上涨得过于顺利一上线就会遇到问题。适合拿它做工业质检方案验证的算法工程师、要交课程设计的在校生以及想试小目标检测思路的人。2. 双格式并行VOC 的 xml 与 YOLO 的 txt 怎么一一对应拿到一个同时给出两种格式的包第一反应不该是“真贴心”而是“两份标注一定完全一致吗”。实际项目里VOC 和 YOLO 分别导出、分别改动的情况太常见了。这一章先把文件结构、字段含义和两侧的对应规则理清楚后面所有脚本都建立在这个认识上。2.1 目录结构与文件配比先对齐包内三类文件的数量完全对齐这是好消息说明不存在“有图没标”或“有标没图”的孤儿文件。但目录组织方式各家用得不一样常见的两种做法是「图片一个目录、标注一个目录」或者「按 split 分 train/val 各自带 images 和 labels」。无论哪种关联主键都是去掉扩展名后的文件名这一点不容妥协。文件类型数量内容使用时要注意jpg9666原始图像文件名 stem 是唯一主键转换脚本靠它找配对xml9666Pascal VOC 标注含 size 节点和 object 列表坐标是绝对像素txt9666YOLO 标注每行一个框无表头坐标是 0~1 归一化值资源说明里那句“不包含分割路径的 txt 文件”要重点理解它指的是没有 segmentation 多边形或 mask 文件不是指目录少了什么。也就是说这份数据的标注粒度是外接矩形框不是像素级轮廓。如果你的方案是 Mask R-CNN 那类实例分割得自己补掩码如果走 YOLO、Faster R-CNN、RetinaNet 这类检测路线直接可用。提示先在本地跑一遍文件数校验确认 jpg 的 stem 集合与 xml 的 stem 集合完全相等差集为空再往下做。这一步花三十秒能省掉后面半天的困惑。from pathlib import Path IMG_DIR Path(images) XML_DIR Path(annotations) TXT_DIR Path(labels) img_stems {p.stem for p in IMG_DIR.glob(*.jpg)} xml_stems {p.stem for p in XML_DIR.glob(*.xml)} txt_stems {p.stem for p in TXT_DIR.glob(*.txt)} print(图片数, len(img_stems), XML 数, len(xml_stems), TXT 数, len(txt_stems)) print(有图无 XML:, sorted(img_stems - xml_stems)[:10]) print(有 XML 无图:, sorted(xml_stems - img_stems)[:10]) print(XML 与 TXT 不一致:, sorted(xml_stems ^ txt_stems)[:10])这段代码做的是集合差运算三个集合两两对齐才算通过。^是对称差能一次抓出“只有 xml”和“只有 txt”两种情况。输出里任何一行不是空列表就说明包内文件不全先别急着转格式回头找完整版本。2.2 VOC XML 的六个关键字段VOC 的结构很固定真正要盯的只有六处。filename用来核对与图片是否同源size下的width、height决定后面归一化的分母object/name是类别字符串bndbox下的四个值是框的绝对像素坐标。至于depth、segmented、difficult这些字段在这份数据里基本是固定值转换时用不上但也别乱删有些训练框架读 VOC 时会要求节点存在。字段路径示例值含义转换时的作用filenameboard_00417.jpg对应图片文件名交叉校验防止 xml 放错目录size/width2048图像宽像素归一化分母必须用图自身的尺寸size/height1536图像高像素同上object/nameopencircuit类别字符串映射到 0~7 的整数索引bndbox/xmin,ymin812, 340左上角坐标换算中心点与宽高bndbox/xmax,ymax967, 361右下角坐标同上这里有一个关键问题我在三个不同项目里都遇到过labelImg 默认导出的坐标是 1-based。也就是说图像最左上角那个像素在标注文件里写的是1不是0右下边界最大可能写到width而不是width-1。这个 1 像素的偏差在整图缩放到 640 时被稀释到 0.3 像素看不出来但当你在 2048 原图上做切片推理、或者做 4 倍下采样的特征图对齐时它会让框系统性地向右下偏移一点表现为高 IoU 阈值下 mAP 掉得莫名其妙。判断方法很简单统计整份 xml 里xmin的最小值。如果全库最小值是 1 而不是 0基本可以确定是 1-based转换时统一减 1。别嫌麻烦这一步我每次都会写进脚本里做成一个开关。2.3 YOLO txt 的归一化坐标与类别索引YOLO 的每行是五个数class_id cx cy w h。后四个都是相对于图像宽高的归一化值落在 0~1 之间class_id从 0 开始顺序由训练时的names列表决定文本文件里不存类别名。这就带来一个隐性依赖txt 单独拿出来是没有意义的必须配一份类别顺序表。八个类别的推荐顺序直接沿用资源给出的原序[falsecopper, missinghole, mousebite, opencircuit, pinhole, scratch, shortcircuit, spur]对应 0~7。这个顺序一旦定下来data.yaml、转换脚本、评估脚本、可视化脚本里的列表必须是同一份任何一处写反模型照样能训练、loss 照样下降但预测结果全落在错误的类别上指标看起来“正常”却毫无意义。import xml.etree.ElementTree as ET from pathlib import Path CLASS_NAMES [falsecopper, missinghole, mousebite, opencircuit, pinhole, scratch, shortcircuit, spur] stem board_00417 root ET.parse(Path(annotations) / f{stem}.xml).getroot() size root.find(size) w, h int(size.find(width).text), int(size.find(height).text) print(f图像尺寸 {w} x {h}) for obj in root.iter(object): name obj.find(name).text.strip() b obj.find(bndbox) xmin, ymin float(b.find(xmin).text), float(b.find(ymin).text) xmax, ymax float(b.find(xmax).text), float(b.find(ymax).text) # 换算成 YOLO 的归一化中心点 宽高 cx (xmin xmax) / 2 / w cy (ymin ymax) / 2 / h bw (xmax - xmin) / w bh (ymax - ymin) / h print(f{name:14s} id{CLASS_NAMES.index(name)} fcx{cx:.6f} cy{cy:.6f} w{bw:.6f} h{bh:.6f}) print(--- 同一张图的 YOLO txt 原文 ---) print((Path(labels) / f{stem}.txt).read_text())这段代码的作用是人工抽检对照把 xml 算出来的五个数和 txt 原文并排看如果两者能对上说明这批数据的两份标注是同步生成的可以放心二选一如果对不上就得判断哪一份是更晚的版本。我一般随机抽 20 张做这个对照只要有一张不一致就整批以 xml 为准重新导出因为 VOC 里带着size信息更完整出错的概率更低。CLASS_NAMES.index(name)这一句同时兼做校验——如果 xml 里出现了列表外的类别名会直接抛出ValueError比默默跳过安全得多。3. 八类缺陷先算账分布、椒盐噪声与划分策略在把数据塞进训练器之前先把三个数字算清楚每类多少框、每张图多少框、增强图占多大比例。这三件事决定了你要不要重采样、要不要改损失函数、以及验证集该怎么切。跳过这一步直接开训最常见的结局是训完发现验证集指标漂亮得不像话换一批真实图就崩。3.1 把 51549 个框摊开看哪几类在拖后腿按资源给出的统计八类的框数分布如下。类别中文习惯叫法框数占比平均每张图出现次数falsecopper余铜48529.41%0.50missinghole缺孔47439.20%0.49mousebite鼠咬缺口788315.29%0.82opencircuit开路803315.58%0.83pinhole针孔745314.46%0.77scratch划痕554310.75%0.57shortcircuit短路673713.07%0.70spur毛刺630512.23%0.65最大类 opencircuit 与最小类 missinghole 的框数比是 8033 / 4743 ≈ 1.69。这个比例放在检测任务里属于相当健康的范围一般框数比在 5 以内都不需要专门做重采样或 focal loss 调参常规的随机裁剪、马赛克增强就够了。所以别被“8 类不均衡”这种说法带偏先自己算一遍再决定要不要动类别权重。真正值得关注的是另一组数字平均每张图 5.33 个框意味着大部分图里目标并不稀疏。这对 one-stage 检测器是好事正样本不会太少但也意味着同一张图里可能同时出现 3~4 个类划分数据集时不能简单按“主类”分层否则某些类会在验证集里彻底消失。这一点在 3.3 里展开。还有一组容易被忽略的falsecopper和missinghole这两类的平均出现次数不到 0.5说明它们只在少数图里出现且一旦出现往往不止一个。这类“稀疏但成群”的类别在 batch size 偏小的时候容易出现某个 batch 里完全没有该类正样本梯度噪声会明显变大。我通常会把 batch 调到能覆盖 8~16 张图同时打开 mosaic让单张图里能拼出更多类别组合。3.2 椒盐噪声增强图怎么识别、留还是删资源说明里那句“有很多椒盐噪声生成增强图片”是整个包里最需要注意的一句。椒盐噪声就是在图上随机撒黑白像素点噪点通常是孤立单像素用 3×3 中值滤波能几乎无损地抹掉。问题在于如果这些增强图是从原图复制出来加噪得到的那么原图和它的加噪版本在视觉上高度相似一旦一张进了训练集、另一张进了验证集验证集就不再是“没见过的数据”指标会虚高得离谱。识别方法我一般用中值滤波前后的差分中值滤波专门针对孤立极值点处理前后差异大的位置基本就是椒盐点。import cv2 import numpy as np from pathlib import Path def salt_pepper_score(path, diff_thr60): gray cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) if gray is None: return None med cv2.medianBlur(gray, 3) diff np.abs(gray.astype(np.int16) - med.astype(np.int16)) # 噪声点占比差分超过阈值的像素 / 总像素 return float(np.count_nonzero(diff diff_thr)) / gray.size scores [] for p in Path(images).glob(*.jpg): s salt_pepper_score(p) if s is not None: scores.append((p.stem, s)) scores.sort(keylambda x: -x[1]) print(噪声得分最高 5 张, scores[:5]) print(得分 0.02 的张数, sum(1 for _, s in scores if s 0.02))medianBlur(gray, 3)的核大小固定为 3对应“孤立单像素噪点”这个前提核再大就会开始抹掉真实的细走线。diff_thr60是灰度差阈值椒盐点与邻域中值的差通常在 100 以上60 是个留有裕量的取值如果你的图整体偏暗可以降到 40。判据s 0.02表示超过 2% 的像素是孤立极值点正常图像一般在 0.3% 以下加噪图会明显高于这个数。识别出来之后怎么处理我的做法是分两种情况如果加噪图的 stem 与某张原图 stem 存在明显对应关系比如只多了_sp后缀那就在划分时把它们绑成一个组整组只进训练集或只进验证集如果 stem 完全随机、无法追溯原图那就整批从验证集里剔除只留在训练集当数据增强用。后者更省事也更安全。顺带一提训练阶段本来就可以开在线噪声增强把静态加噪图留在训练集里等于把增强策略固定死了反而不如让训练器自己随机生成。注意删除增强图之前先备份一份清单别直接删文件。后面如果发现验证集样本不够还能从“无对应原图”的那批里挑一部分补进来前提是确认它们不与训练集同源。3.3 分层划分脚本别让同源图跨进两个集合因为有 8 个类别、多标签共存标准的train_test_split(stratifyy)用不了它只接受单标签。可行的做法是把每张图的“类别集合”当成分层键同一个键内部再按比例随机切。这样既保证了多标签分布一致实现起来也不依赖额外库。import random from collections import defaultdict from pathlib import Path def label_set(txt_path): ids set() for line in txt_path.read_text().splitlines(): line line.strip() if line: ids.add(int(line.split()[0])) return frozenset(ids) VAL_RATIO 0.15 SEED 20240501 buckets defaultdict(list) for txt in sorted(Path(labels).glob(*.txt)): buckets[label_set(txt)].append(txt.stem) rng random.Random(SEED) train, val [], [] for key, stems in buckets.items(): rng.shuffle(stems) n_val max(1, round(len(stems) * VAL_RATIO)) val.extend(stems[:n_val]) train.extend(stems[n_val:]) print(f训练 {len(train)} 张验证 {len(val)} 张分层键 {len(buckets)} 个) Path(train.txt).write_text(\n.join(sorted(train))) Path(val.txt).write_text(\n.join(sorted(val)))VAL_RATIO0.15对应约 1450 张验证图、8200 张训练图对 8 类任务来说验证集规模够用。SEED固定住保证每次复现划分结果一致这在跟别人对指标时特别重要。max(1, ...)是为了防止某个稀有类别组合只有两三张图时验证集里一张都没有。frozenset作为字典键天然去重且可哈希一张图里出现两类还是三类都会落进不同的桶。分完之后还有一个动作要做检查验证集的类别覆盖。把val.txt里的 stem 对应的 txt 全部读一遍统计每类出现了多少次如果某一类在验证集里少于 30 个框说明这个划分对它的评估不可靠得调整VAL_RATIO或者手动往验证集里塞几张含该类的图。这一步花两分钟能避免后面盯着一个波动 ±0.15 的 mAP 反复怀疑人生。4. 从 VOC 到 YOLO 的转换与回验一份能直接跑的脚本虽然包里已经给了 YOLO 格式我依然建议自己转一遍。理由有两个一是你无法确认两份标注是否严格同步自己转能保证以 xml 为唯一真源二是转换过程中能顺手做边界裁剪、零面积过滤这些清洗把脏数据挡在训练之前。这一章的脚本可以整段复制去用。4.1 转换脚本四个必须处理的边界import xml.etree.ElementTree as ET from pathlib import Path CLASS_NAMES [falsecopper, missinghole, mousebite, opencircuit, pinhole, scratch, shortcircuit, spur] def voc_to_yolo(xml_path, one_basedTrue, min_side1.0): root ET.parse(xml_path).getroot() size root.find(size) w float(size.find(width).text) h float(size.find(height).text) lines, dropped [], 0 for obj in root.iter(object): name obj.find(name).text.strip() if name not in CLASS_NAMES: continue # 类别名不在白名单直接跳过 cls_id CLASS_NAMES.index(name) 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) if one_based: # labelImg 1-based 坐标修正 xmin, ymin xmin - 1, ymin - 1 # 裁剪到图像范围内防止越界框把归一化值推出 0~1 xmin min(max(xmin, 0.0), w - 1) ymin min(max(ymin, 0.0), h - 1) xmax min(max(xmax, 0.0), w) ymax min(max(ymax, 0.0), h) bw, bh xmax - xmin, ymax - ymin if bw min_side or bh min_side: # 零面积或过小框丢弃 dropped 1 continue cx (xmin xmax) / 2 / w cy (ymin ymax) / 2 / h lines.append(f{cls_id} {cx:.6f} {cy:.6f} {bw / w:.6f} {bh / h:.6f}) return lines, dropped out_dir Path(labels_rebuilt) out_dir.mkdir(exist_okTrue) total_dropped 0 for xml in sorted(Path(annotations).glob(*.xml)): lines, dropped voc_to_yolo(xml) (out_dir / f{xml.stem}.txt).write_text(\n.join(lines)) total_dropped dropped print(重建完成丢弃过小框, total_dropped)one_basedTrue是那个 1 像素修正的开关默认打开如果抽检发现你的 xml 里xmin最小值为 0把它关掉。min_side1.0表示宽或高小于 1 像素的框整体丢弃这类框在缩放后会退化成零面积训练时算 IoU 会返回 NaN属于必须清掉的脏数据。裁剪那四行的顺序很重要先把xmin/ymin压到[0, w-1]再把xmax/ymax压到[0, w]这样即使原始标注越界也不至于把中心点推到图像外面。最后一行的格式化用:.6f六位小数足够表达 1 像素精度同时保持文件可读。跑完之后统计一下dropped的总数。如果丢弃量超过总框数的 1%说明原标注质量有问题值得回头抽查几张 xml 看看是什么情况如果只有几十个属于正常误差不用管。4.2 反向还原把 txt 画回图上做肉眼校验转换脚本写得再仔细也得亲眼看一眼才踏实。反向还原这件事我习惯用 PIL 直接画不依赖任何标注库出问题的时候排查链条最短。from PIL import Image, ImageDraw from pathlib import Path CLASS_NAMES [falsecopper, missinghole, mousebite, opencircuit, pinhole, scratch, shortcircuit, spur] def draw_yolo(img_path, txt_path, out_path): img Image.open(img_path).convert(RGB) W, H img.size draw ImageDraw.Draw(img) for line in Path(txt_path).read_text().splitlines(): if not line.strip(): continue c, cx, cy, bw, bh line.split() cx, cy, bw, bh map(float, (cx, cy, bw, bh)) x1 (cx - bw / 2) * W y1 (cy - bh / 2) * H x2 (cx bw / 2) * W y2 (cy bh / 2) * H draw.rectangle([x1, y1, x2, y2], outline(255, 0, 0), width2) # PIL 默认字体不支持中文这里直接用类别英文名 draw.text((x1 2, max(y1 - 12, 0)), CLASS_NAMES[int(c)], fill(255, 255, 0)) img.save(out_path) for stem in [board_00417, board_00912, board_02731]: draw_yolo(fimages/{stem}.jpg, flabels_rebuilt/{stem}.txt, fcheck_{stem}.jpg)int(c)是类别索引cx - bw/2是左边界乘回W得到绝对像素。width2的线宽在 2048 的原图上偏细看不清可以改成 4。抽检的时候别只挑“看起来正常”的图专门挑每类框数最多和最少的那几张以及尺寸最小的那张——小尺寸图是小目标问题的重灾区。肉眼校验的重点是三个框是否贴合缺陷边缘、细长条缺陷opencircuit、scratch、spur有没有被截断、同一张图里重叠框是不是重复标注。最后一项容易被忽略labelImg 手工画框时手抖重复标一个框很常见两个几乎重合的同类框在训练时会被当成两个正样本拉低定位精度。发现之后在转换脚本里加一层 NMS 后处理同类、IoU 0.9 的框只留一个就能清掉。4.3 data.yaml 与类别顺序的最终确认训练前最后一步是把data.yaml写死并且只写一份所有脚本都从它读类别列表杜绝多处维护。path: /data/pcb_det train: train.txt val: val.txt names: 0: falsecopper 1: missinghole 2: mousebite 3: opencircuit 4: pinhole 5: scratch 6: shortcircuit 7: spurpath是数据集根目录train和val是相对path的清单文件路径清单里每行一个图片的绝对或相对路径。names从 0 到 7顺序必须与转换脚本里的CLASS_NAMES完全一致。这个文件写错的代价极高——YOLO 不会校验 txt 里的class_id是否超出names的长度超出时有的实现会静默跳过有的会报一个与数据无关的索引错误排查起来非常费时。提示在data.yaml里写一个nc: 8不是必需的训练器会从names的键数推导。但如果你用其他框架读这份配置加上更保险。5. 排查手册五个让指标归零或虚高的坑这一章是我在这类数据集上真实踩过的记录每条按“现象、原因、解决”来写。如果你正在训第一版然后发现结果不对劲按顺序对着看能省掉大量盲猜时间。5.1 类别索引错位loss 正常下降预测全落在错类现象训练 loss 曲线平滑下降验证集的 mAP50 也能到 0.6 以上但把预测结果画到图上明明是真划痕的位置标成了shortcircuit本来是missinghole的标成了falsecopper。整体检测能力在分类全错。原因data.yaml的names顺序和 txt 里的class_id来源不一致。比如转换脚本里按字母序排成了falsecopper, missinghole, mousebite...而data.yaml里手写时把opencircuit和pinhole调了位置。这种错位在混淆矩阵里看得很清楚——矩阵会呈现一条偏离主对角线的平行带。解决把类别顺序收敛到一个唯一的来源。我的做法是单独放一个classes.py里面只有一行CLASS_NAMES [...]转换脚本、data.yaml生成脚本、评估脚本全部import它。改一个地方全链路跟着变比肉眼看两个列表是否一致可靠得多。5.2 坐标没归一化训练直接发散或框全挤在左上角现象第一轮 loss 就是nan或者勉强训完画出来的框全部堆在图像左上角一小块区域。原因txt 里写的是绝对像素坐标而不是 0~1 归一化值。2048 宽图上的cx1024在解析时被当成归一化值乘以宽高后得到1024 * 2048的越界坐标。解决转换后加一道断言统计所有 txt 里后四列的最大值。正常应该在 0.5~1.0 之间如果出现大于 1.05 的数立刻停下。这道断言我写成了转换脚本末尾的固定检查几行代码能挡住一类最常见的低级错误。import glob mx 0.0 for f in glob.glob(labels_rebuilt/*.txt): for line in open(f): parts line.split() if len(parts) 5: mx max(mx, *map(float, parts[1:])) print(后四列最大值:, round(mx, 4)) # 应当在 0~1 之间5.3 增强图跨集泄漏验证集 mAP 高得不像话现象验证集 mAP50 在第一个 epoch 就到了 0.9 以上训练集反而不如验证集。换到另一批真实拍摄的板子图上一测掉到 0.3 以下。原因就是 3.2 讲的那件事——原图和它的椒盐加噪版本被分到了两个集合。加噪版本模型见过了原图的每一个像素验证集失去了“未见数据”的意义。解决先按 3.2 的方法把加噪图识别出来然后在划分前做一次分组。判据是失真的 stem 前缀匹配如果board_00417_sp1的原始前缀board_00417出现在原图集合里就把这两个 stem 绑到同一个组用组为单位做划分而不是用文件为单位。5.4 极端长宽比的小框被裁掉细长缺陷集体消失现象整体 mAP 还行但按类看opencircuit、scratch、spur三类的 recall 显著低于其他五类只有 0.4 左右。原因这三类缺陷在图上表现为细长走线宽度可能只有 3~6 像素。把 2048 宽的图直接缩放到 640宽度被压到 1~2 像素再经过 backbone 的 32 倍下采样特征图上连一个完整像素都不占。训练时的默认数据增强随机缩放、马赛克还会进一步把它们裁没。解决三条一起上。把imgsz提到 1024 或 1280让细长目标至少保留 2~3 像素的宽度关掉或降低马赛克的触发概率评估时对这三类单独统计别被另外五类拉高的平均值骗过去。如果显存不够撑 1280就走 6.1 讲的切片推理把大图切成 640 的小块分别推理再合并。5.5 标注越界与零面积框训练中途报 NaN现象训到某个 epoch 突然 loss 变成nan重启后换了随机种子又能训但过一段时间再犯。原因个别框的xmax小于xmin或者宽高算出来是 0在计算 IoU 时出现0/0。labelImg 在快速拉框时会产出这类异常尤其是贴着图像边缘画的时候。解决转换阶段就过滤掉。4.1 脚本里min_side那个判断和四行裁剪就是干这个的bw min_side or bh min_side直接丢弃并计数。如果不想丢也可以把零面积框膨胀 1 像素但我不建议——零面积框本身就说明标注时没画准留着只会污染训练。6. 输入尺寸与按类复核把“能跑”推到“能用”前五章解决的是“跑得起来”。这一章是我每次交付前必做的两步复核能把模型从“指标看着还行”推到“换一批图也敢用”。6.1 切片推理显存不够时的替代方案如果原图长边在 2000 像素以上而显存只够imgsz640那直接缩放必然丢掉细长缺陷。切片推理的思路是把大图按固定步长切成互相重叠的小块逐块推理后把框映射回原图坐标再用 NMS 合并。重叠率取 0.2能保证跨块边界的缺陷至少在一块里完整出现。参数建议值说明切片尺寸640与训练输入尺寸一致避免尺度偏移重叠率0.2太低会切断跨边界缺陷太高推理耗时翻倍合并 IoU0.5同一目标在不同块中被重复检测时去重最小框面积4 px²过滤切片边缘产生的碎片框关键细节是训练和推理的尺度必须一致。如果你用 640 的切片训练推理时也切 640如果用 1024 训练就别在推理时切成 640否则模型看到的目标尺度分布完全变了recall 会掉得很明显。6.2 按类复核别只看 mAP50 的平均值我习惯在训练结束后做一张九列的表每类的 precision、recall、mAP50、AP75 各一列最后加一列“框数”。这张表能暴露很多平均值掩盖的问题。比如整体 mAP50 是 0.78看着不错但把表摊开scratch的 AP75 只有 0.31——说明它“找得到但框不准”定位精度是短板而missinghole的 recall 只有 0.55说明它漏检严重问题在正样本不足或者特征不明显。两种问题的修法完全不同。定位不准重点应该在回归损失和 anchor 匹配策略或者干脆把imgsz再提一档漏检重点在增强策略和置信度阈值先把阈值从 0.25 降到 0.15 看 recall 能不能上来如果能说明模型是有能力的只是被阈值卡住了。从那以后我每次拿到新的检测数据集都会先跑一遍“文件数对齐、类别顺序单一来源、坐标范围断言、按类框数统计”这四步然后再动训练脚本。这套流程不是从文档里抄的是几次把 mAP 调上去又发现是泄漏、白高兴一场之后用教训换来的习惯。希望帮到你。本文还有配套的精品资源点击获取