简介:面向PCB缺陷识别与深度学习目标检测任务,这一数据集提供693张高清电路板图像及对应XML标注,覆盖Missing_hole、Mouse_bite、Open_circuit、Short、Spur、Spurious_copper六类常见缺陷,可配合YOLO等主流检测框架直接训练与验证。压缩包共1386个文件,主体为693张jpg原图和693个xml标注文件,整体大小约907.52MB,标注精确且类别分布均匀,既能作为工业质检场景下的实验基准,也可用于数据增强与迁移学习研究。资源已有2908人学习下载,适合计算机视觉方向的学生、算法工程师及PCB质检从业者,可在此基础上搭建缺陷检测项目、对比不同网络结构、完成毕业设计或工程实践。当前压缩包是完整数据集的一部分,剩余约2000余张图像可在资源页联系作者获取,以进一步扩充训练样本。
1. PCB缺陷数据集:2700张双格式标签到底能帮你省多少事
做工业视觉的人看到“PCB缺陷数据集”这个标题,第一反应应该是:终于不用自己拿坐标画框标一星期了。PCB缺陷检测是目前制造质检里落地频次最高的方向之一,漏铜、缺口、短路、毛刺、划痕、焊点不良这些缺陷,用通用目标检测框架跑出来的效果,很大程度上取决于训练数据的质量和数量。这套数据集一共2700张,同时给了VOC(XML)和YOLO(txt)两种格式的标签,意味着你不用纠结“拿来之后要不要先转格式”这个问题,直接解压就能喂给检测框架。适合两类人:一是刚入手检测项目、想快速搭一套baseline验证流程的开发者,二是被产线数据保密协议卡住、需要先拿公开数据把推理管线跑通的算法工程师。下面我按拿到压缩包之后实际操作顺序,从解压看结构一直写到训练前校验和踩坑,全程给可直接复制的脚本。
2. 先盘清压缩包里有什么:目录结构与标签格式核对
2.1 解压后大概长什么样
这类数据集压缩包解压后,最常看到的顶层目录结构是下面这样,虽然具体文件名可能不同,但核心模块基本一致:一个放图片的目录,一个放VOC XML标注的目录,一个放YOLO txt标注的目录,以及类别清单文件。拿到手第一件事不是急着训练,而是先把目录理顺,我一般会先解压然后列一次文件树:
unzip PCB缺陷数据集2700张含VOC和yolo两种格式标签.zip -d ./pcb_data cd ./pcb_data find . -maxdepth 2 -type d | sort find . -maxdepth 2 -type f | head -30逻辑说明:第一行解压到pcb_data,第二行进目录,第三行只看两级目录结构避免刷屏,第四行抽样看文件。参数说明:-d指定解压目的地,maxdepth 2限定查找深度到两级,太深会有嵌套目录。
常见布局是下面这种,你用上面的find命令就能确认实际结构:
pcb_data/ ├── images/ # 全部原始图片,jpg或png ├── annotations/ # VOC格式的xml标签 ├── labels/ # YOLO格式的txt标签 ├── classes.txt # 类别清单,一行一个 └── train.txt # 可选,预划分好的训练列表这里有个容易忽略的点:有些打包者会把图片、xml、txt放到同一个目录,靠后缀区分,也有的是单独分文件夹。如果解压后发现xml和txt存在同一个目录,不需要惊慌,文件名前缀相同就能配对,后文给的校验脚本就是用来干这个的。
2.2 读VOC标签:XML里到底存了什么
VOC格式的标签文件是XML,核心信息在<object>节点里,包含类别名<name>和边界框<bndbox>,边界框给的是左上角和右下角的像素坐标,也就是xmin、ymin、xmax、ymax。我用Python把单个XML读出来,直观展示它长什么样:
import xml.etree.ElementTree as ET xml_path = "annotations/xxx.xml" # 换成你解压后的实际文件 tree = ET.parse(xml_path) root = tree.getroot() print("文件名:", root.findtext("filename")) print("图像尺寸:", root.find("size/width").text, "x", root.find("size/height").text) for obj in root.iter("object"): name = obj.findtext("name") bndbox = obj.find("bndbox") xmin = int(bndbox.findtext("xmin")) ymin = int(bndbox.findtext("ymin")) xmax = int(bndbox.findtext("xmax")) ymax = int(bndbox.findtext("ymax")) print(f"目标: {name}, 框: ({xmin}, {ymin}) -> ({xmax}, {ymax})")逻辑说明:先解析XML树,再取根节点;findtext是ElementTree里专门取标签内文本的方法,省掉手动遍历。参数说明:bndbox里的坐标全部是整数像素坐标,这一点很重要,转YOLO格式时要用图像的宽高做归一化,坐标类型必须是数值而不是字符串。顺便提醒一句:如果size/width或size/height缺失,后面转格式时一定会出问题,这一批xml里如果有个别文件缺这个节点,第3章的脚本会报KeyError,到时就知道坑在哪了。
2.3 读YOLO标签:txt里五个数字怎么解释
另一个文件是YOLO格式的txt标签,它的规则比XML简单很多,每一行代表一个目标,格式是五个数字:class_id center_x center_y width height。注意,这里的中心坐标和宽高都是归一化到0到1之间的小数,不是像素坐标。读一个txt的脚本如下:
import numpy as np txt_path = "labels/xxx.txt" # 换成实际文件 with open(txt_path, "r") as f: lines = f.read().strip().splitlines() for line in lines: parts = line.split() if not parts: continue # 跳过空行 cls_id = int(parts[0]) cx, cy, w, h = map(float, parts[1:5]) print(f"类别ID: {cls_id}, 中心: ({cx:.3f}, {cy:.3f}), 宽高: ({w:.3f}, {h:.3f})") # 反推像素坐标,方便和VOC框对照 img_w, img_h = 1000, 800 # 这里填图片实际宽高 xmin_pix = (cx - w / 2) * img_w ymin_pix = (cy - h / 2) * img_h xmax_pix = (cx + w / 2) * img_w ymax_pix = (cy + h / 2) * img_h print(f"反推像素框: ({xmin_pix:.1f}, {ymin_pix:.1f}) -> ({xmax_pix:.1f}, {ymax_pix:.1f})")逻辑说明:按行解析,第一个元素转整数当类别ID,第二到第五个元素转浮点数当归一化框参数。参数说明:这里的cx、cy、w、h全部是相对图片宽高的比例,取值应当落在0到1之间,偶尔会有标注者把框贴着图像边缘导致数值略超边界,后文我会专门讲怎么过滤。把你读到的txt内容和对应xml反推出来的像素坐标对比一下,如果两者边框很接近,说明这套数据集的VOC和YOLO标签是同一标注源导出的,可以直接用;如果坐标对不上,说明两个格式可能是不同批次生成的,强烈建议统一以VOC为基准重新生成一遍YOLO标签,方法在第3章。
3. 把VOC转成YOLO格式:转换脚本与四个边界坑
3.1 为什么要自己转而不是直接用自带YOLO标签
既然压缩包已经给了YOLO标签,为什么还要自己转一遍?我的习惯是拿到外部数据集后,先用VOC标签作为基准重新生成一份YOLO标签。原因有三个:第一,你无法确定打包者转格式时用的类别排序和你要训练的排序一致,类别ID只要错一位,整个模型的预测结果就会张冠李戴;第二,有的txt标签是从别的工具链导出的,坐标精度或文件编码可能有问题,自己用脚本转出来的,心里有底;第三,如果你后续要加数据、合并数据集,自己掌握转换脚本才能保证新老标签格式统一。常见做法是写一个独立脚本,输入XML目录加类别清单,输出YOLO标签目录,下面这个脚本就是我长期在用的版本。
3.2 转换脚本:从XML到txt的实操
import os import xml.etree.ElementTree as ET from pathlib import Path def voc_to_yolo(xml_dir, txt_dir, classes_file): """ xml_dir: VOC格式xml标签目录 txt_dir: 输出的yolo txt标签目录 classes_file: 类别清单路径,一行一个类别名 """ os.makedirs(txt_dir, exist_ok=True) # 读取类别清单,顺序就是类别ID的依据 with open(classes_file, "r") as f: classes = [line.strip() for line in f.readlines() if line.strip()] class_to_id = {name: i for i, name in enumerate(classes)} xml_files = sorted(Path(xml_dir).glob("*.xml")) for xml_file in xml_files: tree = ET.parse(xml_file) root = tree.getroot() # 图像宽高,归一化必需 img_w = float(root.findtext("size/width")) img_h = float(root.findtext("size/height")) # 输出txt文件名与xml同名 out_path = os.path.join(txt_dir, xml_file.stem + ".txt") lines = [] for obj in root.iter("object"): name = obj.findtext("name") if name not in class_to_id: print(f"警告: {xml_file.name} 里出现未登记类别 {name},已跳过该目标") continue cls_id = class_to_id[name] bndbox = obj.find("bndbox") xmin = float(bndbox.findtext("xmin")) ymin = float(bndbox.findtext("ymin")) xmax = float(bndbox.findtext("xmax")) ymax = float(bndbox.findtext("ymax")) # 核心换算:像素框 -> 归一化中心点宽高 cx = ((xmin + xmax) / 2.0) / img_w cy = ((ymin + ymax) / 2.0) / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h # 后面三个参数说明见文末 cx = min(max(cx, 0.0), 1.0) cy = min(max(cy, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") with open(out_path, "w") as f: f.write("\n".join(lines)) if __name__ == "__main__": voc_to_yolo("annotations", "labels_yolo", "classes.txt")逻辑说明:脚本先读类别清单生成字典映射,类别顺序就是ID顺序,必须和训练配置里的类别顺序保持一致;然后逐个解析XML,取图片宽高,对每个目标计算归一化中心点坐标和宽高,写完一个txt。参数说明:坐标换算公式cx=(xmin+xmax)/2/width、w=(xmax-xmin)/width是VOC转YOLO的标准做法,保留6位小数足够训练用。我加了min(max(...))的裁剪,目的是把贴边目标的值限制在0到1之间,防止个别异常标注导致训练时anchor匹配出问题。
3.3 类别映射与归一化计算
class_to_id这个字典是整个转换脚本的灵魂。它的顺序完全由classes.txt决定,所以你在训练配置里写类别顺序时,必须一字不差地复制这个文件的行顺序。举个例子,如果classes.txt第一行是missing_solder、第二行是short,那么missing_solder在txt里就是0,short是1。有人习惯按英文字母排序,有人按标注熟练度排,没有绝对的对错,但转换脚本、训练配置、推理配置三处的顺序必须完全一致。血泪经验:这个顺序问题我至少栽过两次,第一次所有预测结果类别全错位,第二次看起来正常但某两类性能异常,最后查下来都是一个空格字符导致的映射失败。归一化计算本身不复杂,但要注意XML里的坐标是整数,如果不转成float,在Python2里会直接整除出0,现在用Python3虽然不会,还是养成了显式float的习惯。
3.4 四个边界坑
第一个坑是XML缺失size节点。上面脚本运行时如果报TypeError: 'NoneType' object is not subscriptable,八成就是某个xml的size/width取不到值。我碰到过一批扫描件,所有图片尺寸都写在文件名里,xml里反而没带,解决方法是提前从对应图片读取宽高。
第二个坑是类别清单不完整。数据集里可能有几个标注错误的类别名,比如手误打了个missing_soldering,但classes.txt里只有missing_solder,脚本会跳过这个目标,导致少标了框。建议脚本里把警告全部打出来,转换完之后统计每张图的框数,跟xml里的object数量对比,少了就该查。
第三个坑是坐标越界。有的标注工在图像边缘画框时会把xmax写成比图片宽度稍大,归一化后变成1.01,训练集里有这种值会让anchor匹配产生异常。我选择了直接裁剪到1.0,比过滤删除更稳妥,因为边缘缺陷本身也是有效样本。
第四个坑是空标签文件。有些图就是没有缺陷,xml里没有object,脚本会写一个空的txt文件。这是正常且必须的,训练时会跳过这种图片;反过来,如果漏生成空txt,框架会认为这张图标签缺失而报错。所以切记:每个xml都要输出对应txt,哪怕里面没有任何行。
4. 训练前先做三件事:标签校验、分布检查与数据划分
4.1 标签有效性校验:让每个XML/txt都能找到对应图片
拿到解压后的数据,训练前第一步不是直接开跑,而是做一次完整性校验。我写过一个脚本,同时检查三个方向:图片是否有对应标签、标签是否有对应图片、图片和标签是否同名同后缀。这一步能提前暴露打包时漏文件、文件名被截断、格式后缀不统一这类问题。
from pathlib import Path img_dir = Path("images") xml_dir = Path("annotations") txt_dir = Path("labels_yolo") img_files = {p.stem: p for p in img_dir.glob("*.jp*")} img_files.update({p.stem: p for p in img_dir.glob("*.png")}) xml_files = {p.stem for p in xml_dir.glob("*.xml")} txt_files = {p.stem for p in txt_dir.glob("*.txt")} # 检查1:图片没有对应xml for stem in sorted(img_files.keys() - xml_files): print(f"[缺VOC] {stem}") # 检查2:xml没有对应图片 for stem in sorted(xml_files - img_files.keys()): print(f"[缺图片] {stem}") # 检查3:xml和txt数量不一致 only_x = xml_files - txt_files only_t = txt_files - xml_files for stem in sorted(only_x): print(f"[缺txt] {stem}") for stem in sorted(only_t): print(f"[缺xml] {stem}") print(f"图片总数: {len(img_files)}, XML总数: {len(xml_files)}, TXT总数: {len(txt_files)}")逻辑说明:用集合差集快速找出不成对的样本,glob("*.jp*")同时匹配jpg和jpeg,再把png补进字典。参数说明:如果输出里[缺txt]数量较大,说明原始YOLO标签不完整,这时候必须用第3章的脚本从VOC重新生成,不能直接拿原始txt去训练。注意,有些数据集里图片是多层的比如.bmp,上面脚本没覆盖到,你自己用的时候把glob后缀补上就行。
4.2 类别与bbox分布检查
第二个必做检查是统计各类别目标数量以及边界框的尺寸分布。PCB缺陷里很多是小目标,比如一个直径只有十几像素的焊点空洞,如果不看分布,直接把图片resize到640x640,这些小目标可能只有几个像素大小,模型根本学不到特征。我一般会把每张图所有目标框的宽度和高度采集起来,画个分布直方图,或者简单打印分位数:
import numpy as np from pathlib import Path def analyze_boxes(txt_dir, img_sizes, classes): """统计每个类别数量以及归一化框宽高的分位数""" all_areas = [] class_count = {} for txt_path in txt_dir.glob("*.txt"): for line in txt_path.read_text().strip().splitlines(): parts = line.split() if len(parts) < 5: continue cls_id = int(parts[0]) w = float(parts[3]) h = float(parts[4]) class_count[cls_id] = class_count.get(cls_id, 0) + 1 all_areas.append(w * h) # 归一化面积 print("各类别数量:", class_count) if all_areas: arr = np.array(all_areas) print("框面积分位: 10%=", np.percentile(arr, 10), "50%=", np.percentile(arr, 50), "90%=", np.percentile(arr, 90)) print("框宽分位: 50%=", np.percentile(np.array([float(l.split()[3]) for l in open("temp")]), 50)) # 用法示例 # analyze_boxes(Path("labels_yolo"), None, None)逻辑说明:这个脚本直接把类别分布和框面积统计一起输出。参数说明:归一化面积小于0.01的框,在640x640输入下大约只有6x6像素,这样的目标必须靠更高的输入分辨率或者更小的anchor来兜住。你可以按这个阈值筛一下,如果某个缺陷类别的中位面积特别小,第5章和第6章的小目标处理策略就一定要用上。
4.3 用脚本划分train/val/test
数据划分看似简单,但PCB缺陷有个特点:同一块板子上多个缺陷可能同时出现在一张大图里,如果做全局随机划分时没处理干净,可能出现训练集和验证集里出现同一张图的不同裁剪块,导致验证集分数虚高。稳妥做法是按图片粒度做随机划分,并且固定随机种子保证可复现:
import random from pathlib import Path random.seed(42) # 固定种子,结果可复现 img_dir = Path("images") img_stems = sorted([p.stem for p in img_dir.glob("*.jp*")] + [p.stem for p in img_dir.glob("*.png")]) random.shuffle(img_stems) train_ratio, val_ratio = 0.8, 0.1 total = len(img_stems) train_stems = img_stems[: int(total * train_ratio)] val_stems = img_stems[int(total * train_ratio): int(total * (train_ratio + val_ratio))] test_stems = img_stems[int(total * (train_ratio + val_ratio)):] # 写三个txt文件,每行是图片的绝对路径或相对路径 for name, stems in [("train.txt", train_stems), ("val.txt", val_stems), ("test.txt", test_stems)]: with open(name, "w") as f: for stem in stems: f.write(f"images/{stem}.jpg\n") # 后缀按实际改 print(f"train: {len(train_stems)}, val: {len(val_stems)}, test: {len(test_stems)}")逻辑说明:先把所有图片文件名打乱,再按80%、10%、10%切成三份。参数说明:比例可以按需改,但测试集建议至少保留10%且数量不低于100张,否则mAP的置信区间宽到没有参考价值。划分完还要做一件事:检查三个集合里类别分布是否接近整体分布,有的数据集本身类别不平衡,切分后可能把稀少类别全分进测试集。一般做法是看train.txt里每个类别的框数量是否够训练,不够的话把这个类别的图片手动调整进训练集。
5. 避坑清单:PCB小缺陷训练的5个常见问题
5.1 现象:loss下降正常,但mAP几乎为零,预测结果全是背景框
原因:类别ID映射错位。我曾经拿到一份字符集不完整的数据,classes.txt里只有4类,但标注里实际有6类,转换脚本把未登记类别静默跳过,训练时模型看到的正样本数锐减,背景框占比上升。另一类情况是训练配置里的类别顺序和标签txt不一致,常见于直接复制别人训练yaml时忘了改names。
解决:回到第3章,重新核对转换脚本里的class_to_id与训练配置的names字段。打印一个txt的前几行,手工和XML中的对象名对照一次,确认0对应的是哪个类别。我的习惯是转换后保留一份classes.txt原始文件和一份classes_names_in_order.txt,训练yaml直接引用后者,杜绝手抄顺序。
5.2 现象:大缺陷检得很准,小缺陷漏检严重,召回率偏低
原因:输入分辨率不够或者anchor设置不合理。PCB缺陷里有大量微小目标,比如划痕、凹陷,直接resize到640x640后可能只剩几个像素,特征图上的信息几乎被丢光。另一个原因是很多框架默认anchor是按COCO数据集聚的,COCO里目标平均尺寸偏大,不适合PCB场景。
解决:先把输入分辨率从640提到960或1280,看漏检率是否明显回落,这一步通常立竿见影。如果还不行,用框架自带的anchor聚类工具,比如常见检测框架里跑一下autoanchor,让它基于当前数据重新聚类。聚类完看一眼每个类别的anchor尺寸和标注框面积中位数是否匹配,不匹配就得手动调。
5.3 现象:训练时报错,提示找不到图片文件或者标签文件名对不上
原因:数据集打包时图片文件名和xml/txt里的filename节点不一致,或者文件名里有空格、中文、特殊字符。我在一个项目上碰到过图片名是IMG_20230101_上板 前.jpg,xml里写的filename却是IMG_20230101_上板前.jpg,就差一个空格,文件配对直接失败。部分打包工具还会把图片后缀统一改成.jpg但内部实际是png编码,也会让读取器报错。
解决:别用xml里的filename字段作为配对依据,以实际图片文件名为主键,反向生成标签文件名。跑一遍第4章的校验脚本,把输出里的[缺图片]和[缺VOC]全部清到零再开工。文件名里的空格和中文直接用批量重命名改成下划线,省得后续推理时踩编码坑。
5.4 现象:某一类缺陷精度特别低,epoch数加再多也没用
原因:类别不平衡。常见的情况是漏铜这类只占全部目标的5%,而焊点类占60%,模型把所有精力都用来拟合大头类别。如果只看总体mAP,可能会觉得还行,但单独算每个类别的AP,少数类基本都是惨不忍睹。
解决:先看第4章的类别统计输出,确认每个类别的框数量。针对少数类,我一般用两种手段配合:第一种是训练时按类别做采样权重,让每个batch里少数类目标占比提高;第二种是离线数据增强,把少数类的图片做平移、旋转、亮度变化,复制出多份进训练集。注意增强时不要让同一张图在训练集和验证集同时出现,否则验证分是虚的。
5.5 现象:漏铜和缺口两类缺陷互相误检,怎么都分不开
原因:标注口径不一致。这类问题在缺陷检测里特别常见,两个类别的视觉特征本来就很接近,加上标注时不同人写的标签主观性较强,同一张图换个人标可能就是另一个类。我遇到过一批数据里漏铜框和缺口框的边缘区域高度重叠,模型无论怎么调参都学不到可靠边界。
解决:把两类缺陷的badcase图片专门做成一个文件夹,逐张看标注框的位置和大小,找规律。如果确实存在大量口径漂移,先定一个可以执行的标注规则,比如“焊盘上有裸露但没断开的标漏铜,完全断开标缺口”,然后重新整理这部分标签。整理后你会发现这两类AP一下拉开。这个步骤没法靠改参数解决,只能靠数据治理。
6. 进阶:把它变成你自己的迭代基线
你不会只用这个数据集跑一次就收工。我的做法是把它当作一条流水线的起点:先跑通一个默认训练,存下模型权重,然后写一个badcase收集脚本,每次推理后把置信度低于某个阈值、或者预测框和真值框IoU低于0.3的图全抽出来归到一个目录。这些图就是你的“后悔药清单”,每一张都能告诉你模型下一步该吃什么样的数据。
另一个值得做的方向是把数据增强脚本写进训练流程而不是简单堆样本。对PCB来说,亮度抖动、高斯噪声、轻微旋转(不超过15度)和mosaic组合是效果最稳的几招。有一种翻车做法是直接对缺陷区域做强随机旋转,结果把焊盘特征都旋没了,模型学到的是“方向错了也正常”。所以增强要先小步验证:挑一个类别,开增强跑20个epoch,看AP有没有掉,掉了就说明增强强度太大或方向不对。
还有一个我常用的技巧:用小模型做一次预标注,然后把高置信度检测结果喂给人做复核修正,修正完加回训练集。这个数据集的2700张规模不算大,走一轮半监督迭代后,有效训练样本能扩到4000张以上,而且增加的样本集中在原有模型表现差的那部分场景,比随机加数据高效得多。整个流程跑通之后,我就把这个数据集当成后续所有PCB相关项目的验收基线:换模型、调参、做蒸馏,都以它跑出的mAP做参照,这样每次改动到底有没有用,一眼就能看出来。这几步做完,你手里的就不再是2700张静态图片,而是一套可以持续进化的检测闭环。希望帮到你。
本文还有配套的精品资源,点击获取