
简介这份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 个 txt8 个类别合计 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~1xc (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 像素的框必须先剔除再归一化否则会生成w0.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_okTrue) 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, encodingutf-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), encodingutf-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))正常结果应该是mismatch0、最大像素误差小于 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_okTrue) for name, ks in split.items(): with open(os.path.join(OUT, f{name}.txt), w, encodingutf-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 datapcb8.yaml modelyolov8s.pt imgsz640 epochs100 batch16 workers8 seed42seed42和切分脚本里的种子保持一致保证可复现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 三档挑召回最高的那一档再上产线。从那以后我每次拿到带增强图片字样的数据集都强制先跑一遍同源分组和跨集近似检索再动训练脚本——这个习惯救过我不止一次虚高的验证指标比低指标更害人。希望帮到你。本文还有配套的精品资源点击获取