简介:本资源为面向PCB缺陷检测任务的图像数据集,适合从事工业质检、深度学习目标检测的开发者与研究人员使用,可用于训练与验证缺陷识别模型。数据集覆盖六类常见PCB缺陷,包括Missing_hole、Mouse_bite、Open_circuit、Short、Spur、Spurious_copper,标注精确、类别分布均匀,有助于拟合效果较好的检测模型。压缩包共1386个文件,由693张jpg图像与693个xml标注文件组成,整体约907.52MB,标签同时提供VOC与YOLO两种格式,方便直接接入主流检测框架。目前已有2904人学习下载,数据按类别编号组织,便于快速划分训练集与验证集,减少前期清洗与格式转换成本,适合作为课程设计、科研实验或工程原型的训练数据基础。
1. 拿到 PCB 缺陷数据集先别急着训练:2700 张、双格式标签到底意味着什么
刚入行做工业质检视觉那会儿,我最容易犯的错就是看到「数据集」三个字,先wget下来解压,然后直接往 YOLO 里一丢开始跑。跑完一看 mAP 还行,上线一测全是误报,回头翻数据才发现标签格式根本没对齐。PCB 缺陷数据集 2700 张含 VOC 和 YOLO 两种格式标签,这个标题里其实藏了三个关键信息:规模是 2700 张(属于中小规模,够做迁移学习微调,不够从零训大模型)、任务类型是缺陷检测(不是分类,是带框的目标检测)、标签给了 Pascal VOC 的 XML 和 YOLO 的 TXT 两套。对做 PCB 外观质检、AOI 复判、或者拿嘉立创 EDA 画完板子想做缺陷检测练手的工程师来说,这套数据最大的价值不是「大」,而是「双格式」——它省掉了你自己写 VOC 转 YOLO 脚本那一步,但也正因为两套标签并存,坐标系、类别映射、空标签文件这些坑会集中爆发。这一章先把这套数据能干什么、不能干什么说清楚,后面几章再拆怎么用、怎么避坑。
2. 先搞懂 VOC 与 YOLO 标签的坐标系差异:为什么直接混用必翻车
2.1 两套标签描述的是同一个框,但原点、单位、归一化方式全不一样
Pascal VOC 的标注文件是 XML,一个缺陷框长这样:<bndbox>里有xmin、ymin、xmax、ymax,这四个值是像素绝对坐标,原点在图片左上角,x 向右、y 向下。YOLO 的 TXT 标签则是每行一个框:class_id x_center y_center width height,这五个值里后四个都是相对整图宽高的归一化值(0~1 之间),中心点坐标而不是角点。很多人第一次转格式时把xmin/ymin直接当中心点写进去,训练 loss 能降但框全偏,这就是典型的坐标系没对齐。
我一般会先写一个校验脚本,把两种标签各自画到图上对比,确认转换逻辑没错再往下走。下面这段代码读一张图的 VOC XML 和对应 YOLO TXT,把框画出来存成对比图,跑几张就能肉眼看出对不对。
import xml.etree.ElementTree as ET import cv2 import os def voc_to_xyxy(xml_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find('size') w, h = int(size.find('width').text), int(size.find('height').text) boxes = [] for obj in root.findall('object'): name = obj.find('name').text bb = obj.find('bndbox') xmin = float(bb.find('xmin').text) ymin = float(bb.find('ymin').text) xmax = float(bb.find('xmax').text) ymax = float(bb.find('ymax').text) boxes.append((name, xmin, ymin, xmax, ymax)) return w, h, boxes def yolo_to_xyxy(txt_path, img_w, img_h): boxes = [] with open(txt_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cid, xc, yc, bw, bh = map(float, parts) xmin = (xc - bw / 2) * img_w ymin = (yc - bh / 2) * img_h xmax = (xc + bw / 2) * img_w ymax = (yc + bh / 2) * img_h boxes.append((int(cid), xmin, ymin, xmax, ymax)) return boxes # 用法:分别读同一张图的 xml 和 txt,画框对比 img = cv2.imread('sample.jpg') h, w = img.shape[:2] _, _, voc_boxes = voc_to_xyxy('sample.xml') yolo_boxes = yolo_to_xyxy('sample.txt', w, h) for name, x1, y1, x2, y2 in voc_boxes: cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) for cid, x1, y1, x2, y2 in yolo_boxes: cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 0, 255), 1) cv2.imwrite('compare.jpg', img)逻辑说明:VOC 分支直接读像素坐标,YOLO 分支先把归一化中心点还原成像素角点,绿色是 VOC 框、红色是 YOLO 框,如果两者基本重合说明这套数据的双格式是对齐的;如果红框整体偏移或大小不对,说明转换时宽高取反了或者用了错误的图片尺寸。参数上唯一要注意的是img_w、img_h必须用标注时对应的原图尺寸,不能拿缩放后的图去还原,否则归一化反算全错。
2.2 类别映射表要先固定下来,别让 class_id 在不同脚本里各说各话
VOC 里类别是字符串(比如missing_hole、mouse_bite、open_circuit、short、spur、spurious_copper这类 PCB 常见缺陷名),YOLO 里是整数class_id。这套数据如果两种格式都给全了,理论上 class_id 和类别名的对应关系是固定的,但实际拿到的数据里经常出现「VOC 有 6 类、YOLO 只用了 4 个 id」或者「id 从 1 开始而不是 0」的情况。我的习惯是先扫一遍所有标签,把类别集合和 id 集合都统计出来,生成一份classes.txt和映射字典,后续训练、推理、可视化全部引用同一份,避免训练时 id=0 是 missing_hole、推理时 id=0 变成 mouse_bite 这种玄学问题。
import os, glob from collections import Counter voc_names = Counter() yolo_ids = Counter() for xml in glob.glob('annotations/*.xml'): root = ET.parse(xml).getroot() for obj in root.findall('object'): voc_names[obj.find('name').text] += 1 for txt in glob.glob('labels/*.txt'): with open(txt) as f: for line in f: if line.strip(): yolo_ids[int(line.split()[0])] += 1 print('VOC 类别分布:', voc_names) print('YOLO id 分布:', yolo_ids)跑完这份统计,你就能判断两套标签是否自洽。如果 VOC 有 6 类而 YOLO 只有 0~3 四个 id,那要么是部分类别没转,要么是转换脚本里做了类别合并,必须搞清楚再训,否则模型学到的类别空间和你的业务需求对不上。
3. 用这套数据跑通 YOLO 训练的最小闭环:从目录结构到第一轮收敛
3.1 目录结构按 YOLO 官方约定摆,别自创层级
YOLO 系列(v5/v8/v11 都类似)对目录结构有约定:images/train、images/val、labels/train、labels/val,图片和标签同名不同后缀,放在平行目录里。这套数据既然已经给了 YOLO 格式标签,最省事的做法是直接按这个结构组织,而不是把 VOC 的 XML 也塞进去。我一般会保留一份原始 VOC 目录不动,另外生成一份纯 YOLO 结构用于训练,这样出问题还能回溯。
# 假设原始数据:images/ 下所有 jpg,voc_xml/ 下所有 xml,yolo_txt/ 下所有 txt mkdir -p dataset/images/train dataset/images/val mkdir -p dataset/labels/train dataset/labels/val # 按 8:2 划分,固定随机种子保证可复现 python - <<'EOF' import os, random, shutil random.seed(42) imgs = sorted([f for f in os.listdir('images') if f.endswith('.jpg')]) random.shuffle(imgs) split = int(len(imgs) * 0.8) for i, img in enumerate(imgs): phase = 'train' if i < split else 'val' stem = os.path.splitext(img)[0] shutil.copy(f'images/{img}', f'dataset/images/{phase}/{img}') txt = f'yolo_txt/{stem}.txt' if os.path.exists(txt): shutil.copy(txt, f'dataset/labels/{phase}/{stem}.txt') else: open(f'dataset/labels/{phase}/{stem}.txt', 'w').close() # 空标签=负样本 EOF逻辑说明:这段脚本做三件事——建目录、按 8:2 切分、把图片和对应 TXT 复制到对应 phase。关键点是没有标签的图片要生成空 TXT,YOLO 会把空标签文件当作「这张图没有目标」的负样本,这对 PCB 缺陷检测很重要,因为大量正常板子就是负样本,能显著降低误报。参数上random.seed(42)是为了让每次划分一致,方便复现和对比实验;比例 8:2 是中小数据集的常规选择,2700 张里约 540 张做验证,够看趋势但不适合做最终精度评估,真要严谨还得再切一份 test。
3.2 data.yaml 里三个参数决定训练能不能正常起步
YOLO 训练靠一个data.yaml描述数据路径和类别,这个文件写错是最常见的「训练启动即报错」来源。典型内容如下:
path: /abs/path/to/dataset train: images/train val: images/val nc: 6 names: ['missing_hole', 'mouse_bite', 'open_circuit', 'short', 'spur', 'spurious_copper']path建议写绝对路径,相对路径在不同工作目录下启动容易找不到;nc必须和names长度一致,且和标签里的最大 class_id+1 一致,如果标签里出现 id=6 但 nc=6(合法 id 是 0~5),训练会直接报索引越界;names的顺序必须和 class_id 严格对应,顺序错了模型能训但类别全乱。我一般会在训练前跑一遍nc与标签 id 最大值的断言校验,几行代码省掉半小时排查。
import yaml, glob cfg = yaml.safe_load(open('data.yaml')) max_id = -1 for txt in glob.glob('dataset/labels/train/*.txt'): with open(txt) as f: for line in f: if line.strip(): max_id = max(max_id, int(line.split()[0])) assert max_id < cfg['nc'], f'标签最大 id {max_id} 超出 nc {cfg["nc"]}' assert len(cfg['names']) == cfg['nc'], 'names 长度与 nc 不一致' print('校验通过,可以开训')3.3 第一轮训练命令与关键超参:中小数据集别照搬大模型配置
数据就绪后,用 YOLOv8 起步的命令大致是这样:
yolo detect train \ data=data.yaml \ model=yolov8s.pt \ epochs=100 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=20 \ project=runs/pcb \ name=exp1参数说明:model=yolov8s.pt是拿 COCO 预训练权重做迁移学习,2700 张从零训基本不可能收敛好,迁移学习是必选项;imgsz=640是默认值,PCB 缺陷往往很小,如果缺陷在图上占比很小,可以提到 1024 甚至 1280,但显存和速度要权衡;batch=16在 8G 显存上跑 640 一般够,跑 1024 就得降到 4 或 8;lr0=0.01是 YOLO 默认初始学习率,微调时如果发现 loss 震荡厉害可以降到 0.001;patience=20表示 20 轮验证指标不提升就早停,中小数据集上这个值别设太大,否则过拟合了还在跑。第一轮跑完重点看results.png里的train/box_loss和val/box_loss是否同步下降,如果 train 降 val 不降甚至上升,就是过拟合信号,该加数据增强或减模型容量了。
4. 从 VOC 补转 YOLO 或反向转换:脚本、边界与四个必查项
4.1 VOC 转 YOLO 的标准脚本与坐标裁剪
虽然这套数据已经给了 YOLO 格式,但实际项目里你经常需要把新标注的 VOC 补进来,或者反过来把 YOLO 转回 VOC 给标注工具用。VOC 转 YOLO 的核心就是坐标归一化,但有个容易忽略的边界:xmax可能等于图片宽度、ymax等于高度,归一化后中心点加半宽会略大于 1,YOLO 虽然容忍轻微越界,但最好裁到 [0,1]。
import xml.etree.ElementTree as ET import os def voc2yolo(xml_path, out_path, class_map): root = ET.parse(xml_path).getroot() size = root.find('size') w, h = int(size.find('width').text), int(size.find('height').text) lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in class_map: continue cid = class_map[name] bb = obj.find('bndbox') xmin = max(0, float(bb.find('xmin').text)) ymin = max(0, float(bb.find('ymin').text)) xmax = min(w, float(bb.find('xmax').text)) ymax = min(h, float(bb.find('ymax').text)) xc = (xmin + xmax) / 2 / w yc = (ymin + ymax) / 2 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h # 裁到 [0,1],避免浮点误差越界 xc, yc = min(max(xc, 0), 1), min(max(yc, 0), 1) bw, bh = min(max(bw, 0), 1), min(max(bh, 0), 1) lines.append(f'{cid} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}') with open(out_path, 'w') as f: f.write('\n'.join(lines))逻辑说明:先读原图宽高,把像素角点裁到图片范围内,再算归一化中心点和宽高,最后再裁一次防止浮点误差。class_map是类别名到 id 的字典,必须和训练时的names顺序一致。参数上保留 6 位小数是 YOLO 常见精度,够用且文件不会太大。
4.2 四个必查项:空标签、越界框、重复框、类别名拼写
转换或整理完数据,别急着开训,先跑一遍体检。我固定查四件事:一是空标签文件数量,负样本太多会导致正负失衡,太少又可能漏标;二是越界框,归一化值超出 [0,1] 的框要修;三是重复框,同一位置多个几乎重合的框往往是标注重复,会让模型学到冗余;四是类别名拼写,missing_hole和missinghole会被当成两类。下面这段脚本一次查完:
import glob, os from collections import Counter empty, oob, dup, names = 0, 0, 0, Counter() for txt in glob.glob('dataset/labels/**/*.txt', recursive=True): boxes = [] with open(txt) as f: for line in f: if not line.strip(): continue p = line.split() cid, xc, yc, bw, bh = int(p[0]), *map(float, p[1:]) names[cid] += 1 if not all(0 <= v <= 1 for v in (xc, yc, bw, bh)): oob += 1 boxes.append((cid, round(xc, 3), round(yc, 3), round(bw, 3), round(bh, 3))) if not boxes: empty += 1 if len(boxes) != len(set(boxes)): dup += 1 print(f'空标签文件: {empty}, 越界框: {oob}, 含重复框文件: {dup}') print('类别 id 分布:', names)跑完根据结果决定:空标签占比超过 30% 要考虑是否负样本过多;越界框必须修;重复框建议人工抽查;类别 id 分布严重不均(某类只有几十个框)要考虑过采样或类别加权。
5. 训练与验证阶段最容易踩的坑:从 loss 不降到验证集指标虚高
5.1 现象:loss 一直不降或剧烈震荡 → 原因:学习率与 batch 不匹配 → 解决:先小 lr 试跑
PCB 缺陷数据集 2700 张属于中小规模,很多人直接套用大模型训练配置,lr0=0.01加batch=64,结果 loss 前几轮就炸。血泪经验是:中小数据集微调,先把lr0降到 0.001、batch降到 8 或 16,跑 5 轮看 loss 是否平稳下降,稳了再逐步加。如果 loss 震荡但整体下降,可以加 warmup(YOLO 默认有 3 轮 warmup)或开cos_lr。
5.2 现象:验证集 mAP 很高但实际推理误报多 → 原因:训练验证同分布、缺负样本 → 解决:补正常板负样本
如果验证集是从同一批数据里随机切的,且负样本(正常板)很少,模型会学到「每张图都有缺陷」的先验,mAP 虚高。解决方法是往训练集里补一批纯正常板的空标签图片,占比 10%~20%,同时验证集也要包含负样本,看模型在正常板上的误报率。
5.3 现象:小缺陷检测不到 → 原因:输入分辨率不够或 anchor 不匹配 → 解决:提高 imgsz 或换更小模型
PCB 上的spur、mouse_bite这类缺陷在原图上可能只有十几个像素,640 输入下缩到几个像素,特征全丢了。常见做法是把imgsz提到 1024 或 1280,或者用 YOLOv8n/s 这种小模型配合高分辨率,别用大模型硬扛。如果显存不够,可以切图推理(把大图切成小块分别检测再合并)。
5.4 现象:训练报「Label class xxx exceeds nc」→ 原因:class_id 从 1 开始或类别数写少 → 解决:统一从 0 开始并核对 nc
这是最直接的报错,原因通常是标注工具导出时 id 从 1 开始,或者data.yaml里nc写少了。解决就是全量扫一遍标签最大 id,把nc设成max_id+1,并把所有 id 统一减 1(如果确实是从 1 开始)。
5.5 现象:同一张图 VOC 和 YOLO 框对不上 → 原因:图片尺寸记录不一致或转换时宽高取反 → 解决:用原图尺寸重算并可视化抽查
双格式数据最隐蔽的坑就是两套标签其实没对齐,可能 XML 里记的宽高和实际图片不一致,或者转换脚本把 w、h 写反了。解决方法是随机抽 20 张,用第 2 章的对比脚本画出来看,红绿框重合才算过关。
6. 把 2700 张用到极致:分层采样、难例回流与一个可复用的验证习惯
数据量固定的时候,提升效果靠的是「怎么用」而不是「换模型」。我一般会做三件事。第一是分层采样划分训练验证:按缺陷类别做分层,保证每个类别在训练和验证里都有足够样本,而不是纯随机切导致某类验证集只有几个框。第二是难例回流:第一轮训完,把验证集里漏检和误报的图挑出来,人工复核标签,确认是标注问题就修,是模型问题就加进下一轮训练集,迭代两三轮通常能涨几个点。第三是固定一套验证流程:每次改超参或换模型,都在同一份验证集上跑,记录 mAP50、mAP50-95、每类 AP 和误报率,别只看一个总数。
下面这个脚本用来按类别分层划分,比纯随机更稳:
import glob, os, random, shutil from collections import defaultdict random.seed(42) # 统计每张图包含的类别 img_classes = defaultdict(set) for txt in glob.glob('labels/*.txt'): stem = os.path.splitext(os.path.basename(txt))[0] with open(txt) as f: for line in f: if line.strip(): img_classes[stem].add(int(line.split()[0])) # 按主类别分组,组内 8:2 切 groups = defaultdict(list) for stem, classes in img_classes.items(): key = min(classes) if classes else -1 # 空标签归为 -1 组 groups[key].append(stem) for key, stems in groups.items(): random.shuffle(stems) split = int(len(stems) * 0.8) for i, stem in enumerate(stems): phase = 'train' if i < split else 'val' for ext in ('.jpg', '.png'): src = f'images/{stem}{ext}' if os.path.exists(src): shutil.copy(src, f'dataset/images/{phase}/{stem}{ext}') shutil.copy(f'labels/{stem}.txt', f'dataset/labels/{phase}/{stem}.txt')逻辑说明:先统计每张图含哪些类别,按最小类别 id 分组(空标签单独一组),组内再 8:2 切,这样每个类别在训练和验证里的比例大致均衡。参数上key = min(classes)是一种简化分组,如果一张图含多类,归到最小编号那组,实际项目里也可以按类别做多标签分层,但实现复杂些,中小数据集用这个简化版够用。
最后说个我自己的习惯:每次拿到新数据集,先花半小时做三件事——可视化 20 张标签、统计类别分布、跑一遍格式校验。这三件事做完,后面训练出问题能快速定位是数据问题还是模型问题。PCB 缺陷检测这行,数据质量比模型结构重要得多,2700 张用好了,比 10000 张脏数据强。希望帮到你。
本文还有配套的精品资源,点击获取