简介:面向钢板表面缺陷检测与工业视觉目标识别场景,数据集由铝型材数据集和德国DAGM数据集混合制作而成,精选划伤、孔洞、焊缝三类典型缺陷,并引入高斯噪声扩充样本,既保留真实工业表面纹理,又增强模型对噪声干扰的鲁棒性,适合用于训练YOLO、SSD、Faster R-CNN等目标检测模型,也可作为缺陷分类与分割研究的起步数据。压缩包共2000个文件,主体为jpg图像与对应xml标签,xml采用VOC格式标注缺陷位置,另附1个txt说明文件,整体约755MB,解压后目录结构清晰,便于直接接入常见深度学习框架。部分标签经手工标注修正,质量较稳定,能够帮助使用者快速完成数据集划分与模型迭代。目前已有1023人学习下载,适合刚接触工业缺陷检测的初学者以及需要扩充样本的进阶开发者使用。
1. 钢板表面缺陷数据集:4282 张图能撑起什么样的缺陷检测任务
想做钢板表面缺陷检测,第一道坎往往不是模型,而是数据。工业现场的缺陷图不能随便外传,公开的钢板数据集又少得可怜,很多项目就卡在没数据可练这一步。这份钢板表面缺陷数据集把划伤、孔洞、焊缝三类最常见的缺陷整理成 4282 张 jpg 图加 4282 个 xml 标签,Pascal VOC 格式,解压就能用。它的来源比较特殊:由铝型材数据集和德国 DAGM 数据集各取对应三个类别混合制作,再叠了一部分手工标注,最后用高斯噪声做了数据扩充。DAGM 是机器视觉圈熟知的纹理缺陷基准集,铝型材数据集提供金属表面的真实瑕疵形态,两套图混在一起后背景纹理更杂,模型不容易对某一种光照过拟合。文件一共 1.67G,分两次上传,这次下载的是第一部分。适合正在跑缺陷检测、目标检测的从业者,拿现成数据先验证流程、比对模型,能省掉到处找数据的功夫。
2. 数据集构成与 XML 标签:DAGM 和铝型材的混合场景怎么组织
拿到数据集第一件事,不是直接丢进训练脚本,而是搞清楚这批数据是怎么拼出来的、标签里存的到底是什么。这一章把构成逻辑和 XML 结构拆开讲,后面转格式、训练时就不至于对着报错信息发呆。
2.1 三类缺陷样本从哪来:铝型材与 DAGM 各取其三
制作方说明写得很清楚:铝型材数据集有 10 个类别,德国 DAGM 数据集也有 10 个类别,两边都挑了划伤、孔洞、焊缝这三类,混合成这一份钢板缺陷集。这个选材逻辑是合理的。钢板和铝型材同属金属表面,划痕形态、凹坑边界、焊缝纹理在视觉上高度相似,迁移过来比从布匹、纸张这类非金属缺陷集借图靠谱得多。
DAGM 是德国一个工业纹理缺陷基准集,它的特点是背景纹理相对干净、缺陷区域边界规整,检测头很容易收敛。但真实钢板表面有氧化皮、油渍、光照不均,单靠 DAGM 训练出来的模型换个现场大概率掉点。铝型材那部分图恰好补充了金属表面的真实干扰。两类来源按类别混在一起,相当于人为拉大了背景分布,这对泛化是有帮助的。
另外注意摘要里那句「部分标签文件手工标注」。这句话翻译一下就是:这批数据不全是官方精标,有一部分框是后来人补的,框的贴合度参差。手工标注的框常见问题是整体偏松,或者把缺陷周边的锈斑、水渍也圈进去了。这些框训练时不算致命,但验证集里如果混太多松框,mAP 会被拉低。我拿到手会先随机画几十张图看一遍,心里有个底,具体画框脚本放到第 6 章。
类别分布方面,摘要没给每个类具体的张数,解压后用脚本统计一下是最快的。一般这种混合数据集最容易出现某个类样本特别少的情况,如果划伤有 2000 张、焊缝只有 300 张,训练前就要考虑要不要做类别的过采样或者针对性增强。
2.2 XML 标签结构:每个字段对应训练脚本里的哪个参数
这批 xml 是标准 Pascal VOC 结构,训练脚本里最常见的几个字段如下:
| 字段路径 | 含义 | 训练脚本里怎么用它 |
|---|---|---|
| folder / filename | 图片来源目录与文件名 | 拼接图片路径时用 filename |
| size/width | 原图宽度(像素) | 转 YOLO 归一化时的分母 |
| size/height | 原图高度(像素) | 转 YOLO 归一化时的分母 |
| size/depth | 通道数,通常为 3 | 预处理时确认是彩色还是灰度 |
| object/name | 类别名 | 映射成 class id |
| object/truncated | 目标是否被图像边界截断 | 可选择性过滤 |
| object/difficult | 是否属于难例 | 建议过滤掉,否则拖低训练稳定性 |
| object/bndbox | xmin、ymin、xmax、ymax | 计算中心点坐标与宽高 |
bndbox 里存的是绝对像素坐标,不是归一化值。比如 xmin=120,y min=80,指的是目标左上角在原图第 120 列、第 80 行。这个细节在转 YOLO 格式时非常重要,很多人漏了除以宽高,直接把绝对坐标写进 txt,训练出来的框就全偏到一边。
手工标注的部分,object 的数量不一定每个文件都一样,有的图可能同时有两个缺陷,就会有两个 object 块。训练脚本通常按图片遍历所有 object,这个逻辑没问题,但要注意同一张图如果有两个同类别目标,转 YOLO 时会产生两行相同 class id 的标注,这是正常的,别当成重复数据删掉。
2.3 文件命名规律:1519.jpg 这种编号能看出什么
数据集的图片名是纯数字,类似 1519.jpg、1323.jpg、1301.jpg,对应同名 xml。从命名跳跃能看出这是重新编号过的,不是沿用的原数据集文件名。制作者大概率做过一轮筛选,把模糊、漏标的图剔掉了,剩下的才编成连续批次。这对使用者是好事,至少说明这批数据过了人眼。
按摘要给的总量和体积估算,1.67G 分两次上传,4282 张图平均单张三百到四百 KB,分辨率通常在 512 到 1024 这个区间,具体尺寸以每张 xml 里 size 字段为准。解压后 jpg 和 xml 要么平铺在同一目录,要么分 annotations 和 images 两个目录,以实际包内结构为准,不管哪种,同名对应关系是确定的。
还有一点值得确认:图片到底是彩色还是灰度。DAGM 官方数据里有部分是灰度纹理图,铝型材那边多是彩色图,混合后如果训练脚本统一按三通道读图,灰度图会被复制成三通道,这是兼容做法,不影响框坐标。但如果脚本按单通道读彩色图就会出错,所以训练前随机打印几张图的 shape 是值得做的动作。
3. 把 VOC XML 转成 YOLO 格式:三个转换步骤与归一化细节
这一章是动手的核心。数据集给的是 xml,可大多数人训练用的 YOLO 系列框架默认吃 txt 标注。不把格式统一,后面全是报错。
3.1 为什么大多数训练流程会先转成 YOLO txt
Ultralytics 的 YOLOv5、YOLOv8 默认的数据组织方式是:每张图片对应一个同名 txt,txt 里一行一个目标,格式是class_id x_center y_center width height,五个值全部归一化到 0~1 之间。VOC 的 xml 是绝对像素坐标,而且一个文件里塞了多个 object 块,框架没法直接读。
不转格式硬训也行,写个自定义 Dataset 类在读取时解析 xml,但这属于给自己找活。更稳的做法是写个一次性的转换脚本,把 xml 全部转成 YOLO txt,后面换框架、换模型都不用再碰原始标签。转换的本质就是两件事:类别名到数字 id 的映射,以及绝对坐标到归一化坐标的换算。
3.2 归一化坐标计算:核心三行代码的逻辑
YOLO 的归一化中心点公式很简单:中心点 x 等于 (xmin + xmax) / 2 再除以图片宽度,中心点 y 同理;目标宽等于 (xmax - xmin) 除以宽度,高同理。除以的是原图宽高,不是 416 也不是 640,这是最容易错的地方。如果训练时做了 letterbox 缩放,框架自己会同步变换标签,不需要你提前缩。
import xml.etree.ElementTree as ET def parse_voc_bbox(xml_path, class_map, skip_difficult=True): tree = ET.parse(xml_path) root = tree.getroot() size = root.find('size') w = int(size.find('width').text) h = int(size.find('height').text) if w == 0 or h == 0: print(f'[skip] 尺寸为0: {xml_path}') return [] lines = [] for obj in root.iter('object'): name = obj.find('name').text.strip() if name not in class_map: print(f'[warn] 未知类别 {name}: {xml_path}') continue if skip_difficult: difficult = obj.find('difficult') if difficult is not None and int(difficult.text) == 1: continue box = obj.find('bndbox') x1 = float(box.find('xmin').text) y1 = float(box.find('ymin').text) x2 = float(box.find('xmax').text) y2 = float(box.find('ymax').text) if x2 <= x1 or y2 <= y1: print(f'[warn] 非法框 {name}: {x1},{y1},{x2},{y2}') continue # 核心换算:绝对像素 -> 归一化中心点 + 归一化宽高 x_center = (x1 + x2) / 2.0 / w y_center = (y1 + y2) / 2.0 / h box_w = (x2 - x1) / w box_h = (y2 - y1) / h lines.append(f'{class_map[name]} {x_center:.6f} ' f'{y_center:.6f} {box_w:.6f} {box_h:.6f}') return lines这段代码里有三个值得注意的点。一是skip_difficult开关,默认滤掉 difficult=1 的框,这类框通常是严重遮挡或模糊的难例,混进训练集容易让 loss 震荡;二是x2 <= x1的校验,手工标注偶尔会画出右小于左的框,不拦截的话转换出来的 width 是负数,训练直接报错;三是格式化输出保留六位小数,足够表达亚像素精度,多了也没意义。
3.3 完整转换脚本与 train/val 划分
单张转换逻辑有了,接下来把 4282 个 xml 批量处理,顺手按比例划分训练集和验证集。我一般习惯按 9:1 划分,固定随机种子,保证每次复现结果一致。
from pathlib import Path import random class_map = {'scratch': 0, 'hole': 1, 'weld': 2} ann_dir = Path('annotations') out_root = Path('yolo_data') random.seed(42) xmls = sorted(ann_dir.glob('*.xml')) random.shuffle(xmls) val_n = int(len(xmls) * 0.1) val_set, train_set = set(xmls[:val_n]), set(xmls[val_n:]) for split, samples in [('train', train_set), ('val', val_set)]: out_dir = out_root / split out_dir.mkdir(parents=True, exist_ok=True) image_list = [] for xml_path in samples: lines = parse_voc_bbox(xml_path, class_map) if not lines: continue txt_path = out_dir / (xml_path.stem + '.txt') txt_path.write_text('\n'.join(lines) + '\n') image_list.append(xml_path.stem + '.jpg') (out_root / f'{split}.txt').write_text('\n'.join(image_list) + '\n') print(f'{split}: {len(image_list)} 张图')class_map 里三个键的命名,以你解压后 xml 里实际的 object name 为准。如果手工标注用的是中文「划伤、孔洞、焊缝」,就改成中文字符串映射,别硬套英文,否则会触发上面代码里的未知类别告警,那些框会被静默跳过。划分完之后,Ultralytics 的典型目录结构是images/train、images/val放图,labels/train、labels/val放对应 txt,把脚本输出的 yolo_data 里两个子目录分别挪进去即可。train.txt 和 val.txt 这种清单文件,只在部分框架里用,Ultralytics 其实用不到,留着不碍事。
提示:转换完别急着删 xml。后续做数据清洗、重新划分、换成别的框架时,xml 才是原始资产,txt 随时可以再生成。
4. 高斯噪声扩充:噪声参数怎么设才不伤缺陷特征
数据集制作说明里提到用了高斯噪声扩充,这一章说清楚噪声在这个场景里到底帮了什么忙,以及自己动手扩的时候参数怎么给。
4.1 高斯噪声在缺陷检测里的作用边界
工业相机在低照度、高增益条件下,传感器读出噪声和暗电流噪声近似服从高斯分布。模型如果只见过干净纹理图,到了现场遇到有噪点的图,缺陷特征容易被当成背景跳过去。在训练集里混入带高斯噪声的样本,等于提前让模型适应传感器的脾气。
但噪声是把双刃剑。划伤是细长的低对比度结构,孔洞是边缘锐利的小目标,焊缝是区域型纹理,三者对噪声的容忍度完全不同。噪声强度太大会把划伤边缘糊掉,模型学到的不是缺陷特征而是噪声纹理。所以高斯噪声在缺陷检测里只能做辅助扩充,不能喧宾夺主。
不同 sigma 取值的效果差异很明显,下面这张表是实践里的大概分档:
| sigma 取值 | 视觉效果 | 对训练的影响 |
|---|---|---|
| 0 | 原图 | 无 |
| 3~5 | 轻微颗粒感,肉眼几乎不可见 | 能提升一点鲁棒性,风险极低 |
| 8~12 | 可见噪点,类似低照度工业相机画面 | 推荐的扩充区间,能保住大多数缺陷边缘 |
| 20 以上 | 明显雪花感 | 划伤、细裂纹容易被吞掉,不建议 |
sigma 指的是高斯分布的标准差,也就是噪声的振幅。均值一般取 0,这样加噪不会整体改变图像亮度,否则模型会学到亮度偏移这种假特征。
4.2 判断哪些图加过噪:用方差说话
拿到这份数据集,你可能想知道哪些图是被高斯噪声扩出来的。肉眼看小图不一定分得清,一个客观办法是取背景平坦区域算标准差。干净纹理图的平坦区域标准差通常很小,加了噪声的区域会明显偏高。
import cv2 img = cv2.imread('1519.jpg', cv2.IMREAD_GRAYSCALE) # 选一块看起来没有缺陷的平坦背景区域 patch = img[200:260, 200:260] print(f'背景区域标准差: {patch.std():.2f}')这个数值只是一个相对判断依据。DAGM 的干净纹理背景,平坦处标准差常常在 3 以下;加过噪声的图普遍到 8 以上;铝型材真实表面因为本身有金属拉丝纹理,区域标准差到 10~15 也正常。所以单个数值不能直接定性,要把同批图横向对比,明显偏高那一批基本就是加噪图。做这个检查的意义在于:如果训练集里加噪图比例非常高,验证集又是原始干净图,会人为拉大训练和验证的分布差距,评测结果会虚低。
4.3 自己再扩充一份:代码与参数建议
如果训练过程中发现某个类别样本量明显不足,可以自己再补一轮噪声扩充。常见做法是直接对原图加高斯噪声,标签完全不用动,因为加噪不改变目标的位置和大小。
import cv2 import numpy as np def add_gaussian_noise(img_path, sigma=10, seed=None): img = cv2.imread(str(img_path)) if img is None: return None if seed is not None: np.random.seed(seed) # 固定种子,保证可复现 noise = np.random.normal(loc=0.0, scale=sigma, size=img.shape) noisy = img.astype(np.float32) + noise noisy = np.clip(noisy, 0, 255).astype(np.uint8) # 越界像素截断 return noisy参数上,loc=0 保证亮度不漂移;scale 就是上面表格里的 sigma,建议从 8 到 12 之间取,别一上来就试 25;clip 到 0~255 是必须的,否则 float 转 uint8 时溢出会出现奇怪的条纹伪影。扩的时候每个原始图生成一到两版带噪图就够了,同一个原始图扩太多份,模型会把噪声模式背下来,跟过拟合是一回事。
我一般不建议把扩充图直接写盘,尤其数据集本身已经 1.67G,再存一份加噪版很占空间。更常见的做法是在训练脚本的数据增强管线里挂一个噪声层,每个 epoch 现加现用,相当于每次训练看到的噪声版本都不一样,鲁棒性反而更好。这份数据集自带的加噪图是固定的,你拿来做基准测试没问题,想进一步提点的话,管线内实时加噪是更好的路子。
5. 踩坑排查:加载、转换、训练前最容易翻车的五个点
这批数据整理得算干净,但「整理过、直接能用」这句话不能全信。下面五个问题是我处理同类混合数据集时真实踩过的坑,按现象到原因到解决写出来,供你排查时对照。
5.1 现象:转换出来的 txt 类别 id 全是 0,训练 loss 不收敛
原因:class_map 里用的类别名和 xml 里实际的 object name 对不上。比如 xml 里存的是中文「划伤」,映射表里写的是 scratch,代码查不到就跳过,或者某些转换脚本的兜底逻辑把未知类别全写成 0 类,导致所有目标都被归成划伤。
解决:转换前先跑一段脚本,把全部 xml 的 name 字段去重打印出来,用真实存在的字符串建映射表。不要凭摘要里的描述猜类别名,解压后以实际文件为准。
5.2 现象:训练时报标签文件为空或找不到标签
原因:图片文件存在,但对应的 txt 没生成。常见是那张图的 xml 里 bndbox 坐标非法被过滤了,或者该 xml 解析时图片尺寸字段读出来是 0,转换函数直接返回空列表。Ultralytics 遇到空标签不会报致命错,但会跳过这张图,导致图数对不上。
解决:转换脚本里把每次过滤的原因都打出来,转换完统计原始 xml 数和生成的 txt 数,数字不一致就回去看日志。我在第 3 章的代码里已经埋了告警输出,跑一遍就知道哪些图被丢了。
5.3 现象:训练时框全偏,目标在左上角框却跑到右下角
原因:转换时归一化的分母用错了。一种是把 center 坐标写成了 (xmax - xmin) / 2 这种宽度一半的值当中心点,另一种是用 640 或 416 这种固定值当分母,而不是用 xml 里 size 字段的真实宽高。手工标注的图片尺寸不统一时,这种错误特别隐蔽,因为部分图碰巧尺寸接近 640,看起来只偏了一点点。
解决:严格按x_center = (xmin + xmax) / 2 / width来算,分母必须来自同一张 xml 的 size 字段。转换完抽三张图把框画在图上,肉眼确认一下就露馅了。
5.4 现象:1.67G 数据一次性加载,内存直接爆掉
原因:习惯用 PIL 或 cv2 把所有图片一次性读进 list 再转 numpy,4282 张图全量加载,内存一两个 G 根本扛不住,尤其是训练脚本里还同时存了原始图和增强图。
解决:改用数据加载器按 batch 流式读取,Ultralytics 默认就是这么做的不需要额外配置。自己写训练循环的话,用 PyTorch 的 DataLoader,num_workers 给到 4 或 8,别在循环外面提前 load 全部图片。内存实在紧张就把图片路径存成 txt,按行读。
5.5 现象:验证集 mAP 虚高,换到现场数据掉点严重
原因:训练集里高斯噪声扩充图占比过高,验证集里也混了同类噪声图,模型等于在噪声风格上过拟合,学到的不是缺陷本质而是噪声模式。真实现场的噪声分布和合成噪声并不完全一致,换场景就现原形。
解决:验证集尽量用原始干净图,别放加噪图;训练时把 sigma 设成区间随机而不是固定值,比如 5 到 15 里均匀采样,让模型见过多种噪声强度,而不是盯死一种。这个做法能明显缓解迁移掉点。
6. 训练前最后一道自查:可视化脚本替你核对标签对齐
转换完格式、配好 yaml,先别急着开训。花十分钟跑一遍下面这套自查,能挡掉大部分低级错误。训练到一半发现标签有问题再返工,成本高得多。
6.1 随机抽图把框画出来看
import cv2 import xml.etree.ElementTree as ET from pathlib import Path import random def draw_boxes(img_path, xml_path): img = cv2.imread(str(img_path)) if img is None: print(f'图片缺失: {img_path}') return tree = ET.parse(xml_path) for obj in tree.getroot().iter('object'): name = obj.find('name').text box = obj.find('bndbox') x1 = int(float(box.find('xmin').text)) y1 = int(float(box.find('ymin').text)) x2 = int(float(box.find('xmax').text)) y2 = int(float(box.find('ymax').text)) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2) cv2.putText(img, name, (x1, max(0, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) cv2.imshow('check', img) cv2.waitKey(0) xmls = list(Path('annotations').glob('*.xml')) random.seed(0) for xml_path in random.sample(xmls, 30): draw_boxes(xml_path.with_suffix('.jpg'), xml_path) cv2.destroyAllWindows()这段脚本直接读取原始 xml 画框,不经过转换后的 txt,目的是验证原始标签本身的质量。重点看三点:框是否包住完整缺陷、有没有把无关背景圈进去、类别名和图形是否匹配。手工标注的框偏松是常态,但偏得离谱就要考虑清洗。
6.2 统计类别分布确认没有断层
from collections import Counter import xml.etree.ElementTree as ET from pathlib import Path counter = Counter() empty = 0 for xml_path in Path('annotations').glob('*.xml'): tree = ET.parse(xml_path) objs = list(tree.getroot().iter('object')) if not objs: empty += 1 for obj in objs: counter[obj.find('name').text] += 1 print(f'类别分布: {dict(counter)}') print(f'无目标标签数: {empty}')类别分布打印出来之后,对照任务需求判断要不要做处理。如果焊缝只有一两百个框而划伤有两千个,训练时少数类很容易被淹没。常见处理是复制少数类样本做几轮增强,或者给损失函数加类别权重。另外无目标标签的数量也值得留意,空 xml 通常是制作者标记了「无缺陷」,这类图可以留作负样本,但要在数据加载时明确区分,别让它们混进有标签的目录里干扰训练。
6.3 固定动作:每次拿到外部数据都走一遍
最后这步是自查的收尾,也是我给自己定的规矩。第一遍按 6.1 的脚本把框画在图上人工过一遍,第二遍按 6.2 的脚本看类别分布和空标签数量,第三遍才是转格式开训。三步加起来不超过十五分钟,但能拦住绝大多数换数据翻车。
整套脚本不依赖框架,xml 和 jpg 的路径改一下就能跑。后来我把这三步从手动执行改成了一段串行脚本,argparse 传数据根目录进去,一键输出抽样画框图和分布报告,团队里的同事拿新数据也先跑这个,已经成了固定习惯。也因为吃过亏,从那以后我每次拿别人整理的数据集,不管对方怎么保证「整理过、直接能用」,都会强制走一遍这套流程。数据这东西,训练前多花十分钟,比训练中花三天排查要划算得多。希望帮到你,训练顺利。
本文还有配套的精品资源,点击获取