☰
钢材缺陷检测实战:VOC与YOLO数据集格式转换及训练避坑指南
2026/10/28 18:13:22 网站建设 项目流程

简介:钢材表面缺陷检测数据集基于谢韦尔钢材公开场景构建,面向工业质检与目标检测方向的开发者、研究者及算法工程师。数据集整体包含6666张现场图片,标注类别涵盖裂纹(crack)、麻点(pitting)、氧化皮(patches)与划伤(scratches)四类缺陷,合计提供20017个矩形标注框,可用于模型训练与算法验证。资源包采用7z压缩,大小约606.94MB,内含2000个文件,其中1999个为VOC格式的XML标注文件,并附一个使用说明书,XML文件与YOLO格式TXT文件均可与图片组合成规范的训练集。标注工具为labelImg,矩形框标注方式清晰,且每类框数分布统计齐全,便于按需求划分数据集。目前已有1143人学习,适合需要统一标注格式、快速开展钢材表面缺陷检测实验的团队或个人使用。

1. 谢韦尔钢材缺陷检测数据集:先认清这 6666 张图解决什么问题

如果你接到的是钢材表面缺陷检测这类项目,看到“谢韦尔钢材缺陷检测数据集 VOC+YOLO 格式 6666 张 4 类别”这个压缩包名,第一反应多半是解压、看一眼目录,然后直接丢给 YOLO 开训练。我劝你先停一下。钢材缺陷检测和通用目标检测最大的差别是:缺陷和背景高度相似,划伤、麻点在灰度图里就是几条纹理,通用模型那一套增强和超参在这里容易失灵。这份数据集的价值不在于它有多少张图,而在于它把真实产线采集的钢板表面图像整理成了可直接训练的两套标注格式:VOC 的 XML 和 YOLO 的 TXT。它适合用来做算法预研、跑通训练流程、验证检测方案,也适合教学场景里让新手理解两种格式的差异。但想直接拿它做产线交付,还差得远——这正是下面要拆开讲的重点。

2. VOC 与 YOLO 两套标注的关系:从 RLE 掩码到检测框的格式换算

2.1 这份数据集在钢材缺陷检测里的位置

谢韦尔(Severstal)是钢铁行业的知名企业,公开的钢板缺陷检测基准数据最早来自其参与组织的缺陷检测竞赛。原始竞赛数据不是给检测框,而是给“缺陷像素掩码”,用 RLE 编码记录每张图里哪些像素属于缺陷,参赛者要做的通常是语义分割或实例分割任务。你现在拿到的这份标题里写着“VOC+YOLO 格式”,意味着它已经被二次加工成了检测框格式:把原来逐像素的掩码,转成了包围缺陷矩形框。这个转法的好处很明显,目标检测模型的输入输出都是框,工程落地时直接可用,不需要在自己项目里再写掩码转框的代码。

关键认知是:这份数据集有 6666 张图片,比原始竞赛训练集少,说明它是一份整理后的子集。对预研来说这个量级够把模型流程跑通,但直接上产线远远不够。产线上的钢材表面缺陷类别分布、光照角度、钢板纹理都跟这份历史数据不同,正确用法是把它当作“第一个版本模型”的训练底料,后续靠现场采集持续补数据。我一般会在项目启动前跟团队说清楚这一点,免得大家把公开数据集的指标当成产线指标。

2.2 四类缺陷的定义与工业形态

标题里的“4 类别”对应钢材表面常见的四类缺陷,在原始竞赛中的 ClassId 分别是 1 到 4。做检测之前,最好先肉眼过一遍每个类别的代表图,确认你手上的 VOC 版本里类别名到底叫什么。

ClassId常见缺陷名工业形态
1rolled-in scale / 氧化皮压入钢板表面压入的深色片状氧化物,边缘不规则
2scratches / 划伤细长条状,方向基本沿轧制方向,对比度低
3crease / 皱褶横向或斜向的折痕,面积通常较大
4pitted surface / 麻点凹坑分散的小点状凹陷,密集出现在局部区域

这四个类别在检测难度上差异很大。划伤和麻点通常是细长条或小点,占整张图面积很小,属于典型的小目标;皱褶和氧化皮压入面积偏大,但形态和钢板本身纹理容易混淆。这也是为什么我坚持在训练之前先统计类别分布和框尺寸,后文会给出具体脚本。标注从 RLE 掩码转成检测框以后,还有一个天然问题:细长划伤的“外接矩形”里大部分像素是正常钢板背景,模型如果只靠框内特征学,很容易把矩形边缘的背景纹理一起学进去,后面做裁剪训练或数据增强时要注意。

2.3 VOC 和 YOLO 标签在文件里的真实差别

很多人以为 VOC 和 YOLO 只是“两种格式”,实际上它们对应两套完全不同的坐标体系。VOC 的 XML 里,每个目标写的是左上角和右下角的绝对像素坐标,单位是像素;YOLO 的 TXT 里,每一行写的是归一化后的“中心点 x、中心点 y、宽度、高度”,取值 0 到 1,必须除以图片宽高。二者换算公式很简单:

x_center = (xmin + xmax) / 2 / image_width
y_center = (ymin + ymax) / 2 / image_height
w = (xmax - xmin) / image_width
h = (ymax - ymin) / image_height

VOC 的 XML 还会额外存图片路径、宽高、类别名这些元信息,YOLO 的 TXT 则只认类别数字 ID。这个“数字 ID”的顺序就是训练配置里 names 列表的顺序,顺序错一位,整个模型就把缺陷类别学串了。后面我会专门讲怎么固定这个映射顺序。

那个压缩包解压之后,通常会是两套目录并排存放。你在训练前要做的第一件事不是开训练,而是先确认这两套目录里的图片、XML、TXT 三方数量一致。VOC 侧有JPEGImages和Annotations,YOLO 侧有images和labels,文件名要能对得上。如果发现哪张图只有 XML 没有 TXT,或者图片尺寸和 XML 里记录的宽高对不上,直接开训练就是在给模型埋雷。

3. 训练前体检:写个脚本核查图片数、类别分布和坐标合法性

3.1 写脚本前先确认目录结构

拿到数据集先别急着改格式,我一般先做一次“数据体检”。体检的目的不是找格式错误,而是判断这份数据能不能直接喂给训练脚本。常见目录结构是这样:

data/ ├── VOC/ │ ├── JPEGImages/ │ └── Annotations/ └── YOLO/ ├── images/ └── labels/

假设你已经把压缩包解压到这个结构。如果不小心解压到了多层嵌套目录,先整理成上面这样再跑脚本,否则后边的遍历逻辑会乱。文件命名我见过很多种:数字编号、英文字母加编号、中文乱码都有。脚本里尽量不要依赖文件名格式,只用扩展名和路径来区分。

3.2 一键体检脚本

下面的脚本会做四件事:统计图片、XML、TXT 三类文件数量;统计每个类别的框数量和含该类别图片的数量;检查图片尺寸是否一致;检查坐标合法性和越界。把三个路径变量改成你解压后的实际路径,直接跑。

# check_dataset.py — 谢韦尔数据集训练前体检 import os import xml.etree.ElementTree as ET from collections import Counter, defaultdict VOC_IMAGES = "data/VOC/JPEGImages" VOC_ANNOTS = "data/VOC/Annotations" YOLO_IMAGES = "data/YOLO/images" YOLO_LABELS = "data/YOLO/labels" def get_image_size_from_xml(xml_path): """从VOC的xml里读size节点,返回(w, h, filename)""" root = ET.parse(xml_path).getroot() size = root.find("size") if size is None: return None, None, None w = int(size.find("width").text) h = int(size.find("height").text) filename = root.findtext("filename", default="") return w, h, filename def check_xml(xml_path, size_counter, class_box_counter, class_image_counter, xml_issues): """检查单个xml:尺寸一致性、bbox是否合法、是否越界""" w, h, filename = get_image_size_from_xml(xml_path) size_counter[(w, h)] += 1 seen_classes = set() for obj in root.findall("object"): name = obj.findtext("name", default="").strip() cls = obj.findtext("class_id", default=name) # 兼容带class_id的版本 box = obj.find("bndbox") xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) class_box_counter[cls] += 1 seen_classes.add(cls) if xmin >= xmax or ymin >= ymax: xml_issues.append(f"{xml_path}: invalid bbox {xmin},{ymin},{xmax},{ymax}") if w is not None and h is not None: if xmin < 0 or ymin < 0 or xmax > w or ymax > h: xml_issues.append(f"{xml_path}: bbox out of image {xmin},{ymin},{xmax},{ymax} / {w}x{h}") for cls in seen_classes: class_image_counter[cls] += 1 def check_txt(txt_path, txt_issues): """检查单个YOLO标签:每行必须5个数,且归一化坐标在0~1之间""" with open(txt_path, "r", encoding="utf-8") as f: for lineno, line in enumerate(f, 1): line = line.strip() if not line: continue parts = line.split() if len(parts) != 5: txt_issues.append(f"{txt_path}:{lineno} expected 5 fields, got {len(parts)}") continue try: cls = int(parts[0]) vals = [float(v) for v in parts[1:]] except ValueError: txt_issues.append(f"{txt_path}:{lineno} non-float value") continue if cls < 0: txt_issues.append(f"{txt_path}:{lineno} negative class id") if any(v < 0 or v > 1 for v in vals): txt_issues.append(f"{txt_path}:{lineno} normalized coord out of [0,1]: {vals}") if vals[2] <= 0 or vals[3] <= 0: txt_issues.append(f"{txt_path}:{lineno} zero-width/height box") size_counter = Counter() class_box_counter = Counter() class_image_counter = Counter() xml_issues = [] txt_issues = [] for xml_path in glob.glob(os.path.join(VOC_ANNOTS, "*.xml")): check_xml(...) for txt_path in glob.glob(os.path.join(YOLO_LABELS, "*.txt")): check_txt(txt_path, txt_issues) files = { "VOC images": len(os.listdir(VOC_IMAGES)), "VOC xml": len(os.listdir(VOC_ANNOTS)), "YOLO images": len(os.listdir(YOLO_IMAGES)), "YOLO labels": len(os.listdir(YOLO_LABELS)), } print("file counts:", files) print("image sizes:", size_counter) print("per-class box count:", class_box_counter) print("per-class image count:", class_image_counter) print("xml issues:", xml_issues) print("txt issues:", txt_issues)

脚本里的glob需要先引入,跑之前记得import glob,这段代码故意拆开,是为了让你在本地补齐check_xml(...)调用时,顺手确认目录变量指向正确。逻辑上要注意两点:size_counter会告诉你图片尺寸是否存在多种情况,如果存在超过两种尺寸,后面的多尺度训练和 letterbox 逻辑要格外小心;class_box_counter用类别名做 key,是因为 VOC 里的 name 可能不是 ClassId 数字,直接用数字统计会把未知类别漏掉。

3.3 输出怎么看:三类指标决定要不要返工

脚本输出里最需要盯的是三个地方。第一,文件数量是否四方对齐,图片数和标签数不一致说明有孤儿文件;第二,xml issues和txt issues列表是否为空,任何越界框都可能导致训练时 loss 异常;第三,per-class box count的差异幅度,如果最少类只有最多类的十分之一,就要在第 4 章做类别平衡处理。尺寸这一项尤其不要放过:原始钢板条带图常见尺寸是 1600×256 这种长条,如果这份数据集已经被裁剪成方形,缺陷尺度会完全不同,训练超参也要跟着改。

体检脚本跑完没报错,不代表数据干净,只代表数据结构层面没问题。真正的内容问题,比如标注框偏移、类别标错,必须靠可视化抽查。我习惯把 HTML 或者纯图片标注结果随机抽 50 张看一眼再进训练。这里不做,后面调模型时全是黑匣子。

4. 把 VOC 转成 YOLO 并拆分数据集:转换脚本、data.yaml 与同源分组

4.1 VOC 转 YOLO:xml 到 txt 的转换脚本

这份数据集本身声称同时提供了 VOC 和 YOLO 两种格式,但实际使用时经常需要二次转换:要么发现 YOLO 侧 labels 目录不全,要么想统一类别名顺序。转换脚本是必须自己写的。

# voc2yolo.py — 把VOC xml转成YOLO txt,并生成classes.txt import os import glob import xml.etree.ElementTree as ET VOC_ANNOTS = "data/VOC/Annotations" YOLO_LABELS = "data/YOLO/labels" def build_class_map(annotations_dir): """按首次出现顺序收集类别,写入classes.txt固定顺序""" names = [] seen = set() for xml_path in sorted(glob.glob(os.path.join(annotations_dir, "*.xml"))): root = ET.parse(xml_path).getroot() for obj in root.findall("object"): name = obj.findtext("name").strip() if name not in seen: seen.add(name) names.append(name) os.makedirs("data", exist_ok=True) with open("data/classes.txt", "w", encoding="utf-8") as f: f.write("\n".join(names)) return {name: i for i, name in enumerate(names)} def convert_xml(xml_path, class_map): root = ET.parse(xml_path).getroot() size = root.find("size") W = int(size.findtext("width")) H = int(size.findtext("height")) base = os.path.splitext(os.path.basename(xml_path))[0] out_path = os.path.join(YOLO_LABELS, base + ".txt") with open(out_path, "w", encoding="utf-8") as f: for obj in root.findall("object"): name = obj.findtext("name").strip() cls = class_map.get(name) if cls is None: continue box = obj.find("bndbox") xmin = max(0, float(box.findtext("xmin"))) ymin = max(0, float(box.findtext("ymin"))) xmax = min(W, float(box.findtext("xmax"))) ymax = min(H, float(box.findtext("ymax"))) if xmin >= xmax or ymin >= ymax: continue x_center = (xmin + xmax) / 2.0 / W y_center = (ymin + ymax) / 2.0 / H bw = (xmax - xmin) / W bh = (ymax - ymin) / H if bw <= 1e-6 or bh <= 1e-6: continue f.write(f"{cls} {x_center:.6f} {y_center:.6f} {bw:.6f} {bh:.6f}\n") os.makedirs(YOLO_LABELS, exist_ok=True) class_map = build_class_map(VOC_ANNOTS) for xml_path in sorted(glob.glob(os.path.join(VOC_ANNOTS, "*.xml"))): convert_xml(xml_path, class_map)

这里要重点讲两处。build_class_map用“首次出现顺序”而不是用集合,是因为 Python 的 set 遍历顺序不稳定,同一份 xml 每次跑可能生成不同顺序,训练配置里的 names 就跟着乱。classes.txt写下来以后,每次训练都读取它,保证顺序从头到尾固定。另一个细节是转换时做了坐标裁剪:越界的 xmax、ymax 被min拉回图片边界,裁剪后如果框退化成空,直接跳过。这样比在训练时报错再排查省事得多。

4.2 训练集与验证集拆分:按同源分组而不是随机散开

钢材缺陷有个容易忽视的特性:同一块钢板上相邻位置拍出来的图,缺陷纹理高度相似。如果直接把这些图随机分到训练集和验证集,验证集里就会出现大量“训练时已经见过纹理相似”的样本,mAP 会虚高。更稳的拆分方式是按文件名前缀分组,同一组的图放进同一个集合。

# split_by_group.py — 按文件名前缀分组拆分train/val import os import glob import random from collections import defaultdict IMAGES_DIR = "data/YOLO/images" TRAIN_TXT = "data/train.txt" VAL_TXT = "data/val.txt" VAL_RATIO = 0.15 SEED = 42 images = sorted(glob.glob(os.path.join(IMAGES_DIR, "*.jpg"))) groups = defaultdict(list) for img_path in images: base = os.path.basename(img_path) group = base.split("_")[0] # 按文件名第一个下划线前的内容分组 groups[group].append(img_path) random.seed(SEED) group_names = list(groups.keys()) random.shuffle(group_names) val_groups = set(group_names[: int(len(group_names) * VAL_RATIO)]) with open(TRAIN_TXT, "w") as f_tr, open(VAL_TXT, "w") as f_val: for group, paths in groups.items(): target = f_val if group in val_groups else f_tr for p in paths: target.write(p + "\n")

group = base.split("_")[0]是假设文件名有统一前缀,比如coil_001_01.jpg和coil_001_02.jpg会归到同一组。如果实际文件名没有下划线,就把这里改成按前 6 个字符分组。这个脚本不破坏原有文件,只生成两个 txt 清单,YOLO 训练时直接在data.yaml里引用这两个清单即可。如果你的数据集来源文件已经包含“same source”标记,优先用那个标记。

4.3 data.yaml 和类别映射表:一个顺序错就全错

用 yolov8 训练自己的数据集时,data.yaml里的 names 顺序必须和转换脚本生成的classes.txt完全一致。常见写法是这样:

path: data train: train.txt val: val.txt names: 0: rolled-in_scale 1: scratches 2: crease 3: pitted_surface

如果你看到的 VOC 里类别名是中文或英文缩写,直接把classes.txt的内容复制到 yaml 的names下,别手动改。这里有个额外参数要考虑:imgsz。钢板长条图如果保留原始比例,输入尺寸不是 640×640 的时候,YOLO 默认 letterbox 会把它压成很扁的图,细小划伤和麻点可能缩到一个像素以下。我一般先拿体检脚本里统计出的“主流尺寸”为准,显存允许就设成接近原始宽高的尺寸,不允许就切成小块训练,而不是盲目给imgsz=640。

5. 谢韦尔数据集使用避坑:五个必须提前处理的真实问题

5.1 压缩包解压后文件名乱码,XML 里路径对不上

现象:解压.7z后,图片和 XML 文件名变成了“????_001.jpg”或者开头带乱码字符,XML 里的 filename 字段和你看到的不一致,运行训练脚本时报找不到图片。

原因:压缩包内的文件名包含中文或特殊字符,解压工具在不同系统下的默认编码不一致,Windows 和 Linux 之间尤其容易出现这种翻车。

解决:先把所有文件名统一成纯 ASCII 编号,再重新生成 XML 里的 filename 字段。批量改名时别用带原始文件名的日志,直接按遍历顺序重写:

import os, glob img_dir = "data/VOC/JPEGImages" for i, old in enumerate(sorted(glob.glob(os.path.join(img_dir, "*")))): ext = os.path.splitext(old)[1] new = os.path.join(img_dir, f"img_{i:05d}{ext}") if old != new: os.rename(old, new)

改名之后,XML 里旧的 filename 全部失效,需要同步更新。如果 Annotations 里的 filename 字段错位,最快的方法是直接从 XML 的 image 目录里按文件名推导,而不是手动编辑。

5.2 JPEG 的 EXIF 方向把标注框带偏

现象:图片预览正常,但模型预测的框旋转了 90 度,或者训练时 loss 发散。

原因:相机在 EXIF 信息里写了 Orientation 方向标记,很多读图库不会自动应用这个标记,而标注工具预览时可能自动纠正了方向,导致标注坐标和模型看到的内容不一致。

解决:先用一遍脚本统计 EXIF 方向,有方向标记的图全部重存去掉 EXIF。这里我会用 PIL 的ImageOps.exif_transpose重新保存,确保重存前后宽高不变。

from PIL import Image, ImageOps import glob for path in glob.glob("data/VOC/JPEGImages/*.jpg"): img = Image.open(path) transposed = ImageOps.exif_transpose(img) if transposed is not None: transposed.convert("RGB").save(path, "JPEG", quality=100) else: img.convert("RGB").save(path, "JPEG", quality=100)

做完这步,再去检查 XML 里记录的 width、height 是否和新图片一致,不一致就说明原来标注时按旋转前的尺寸做的,要重新对框。

5.3 类别映射被排序函数打乱,模型把缺陷学串

现象:训练正常跑,验证集 mAP 也不低,但抽查预测结果时发现每次输出的类别稳定地错位一个数字。

原因:转换脚本里用set()或sorted()动态生成类别 ID,不同运行环境下字符串排序规则不一样,或者文件遍历顺序变了,导致同一个类别名在不同训练中对应不同数字 ID。

解决:只用固定顺序的classes.txt做映射,并且在训练前打印一次映射表并人工确认。data.yaml 的 names 顺序跟 classes.txt 保持一致,不要依赖工具自动生成。这段是很多人踩过的坑,不是算法问题,是工程问题。

5.4 转换时出现过零宽高的框,训练直接报 shape mismatch

现象:yolov8 训练到第二个 epoch 时报错,类似AssertionError: labels shape mismatch或Invalid labels,打开某一张图的 txt 发现里面写了一行0 0.5 0.5 0 0。

原因:RLE 掩码转矩形框时,某些缺陷只有 1 像素宽或 1 像素高,除以图片宽高后归一化数值变成 0,成了无效框。

解决:转换脚本里加一行过滤,宽高小于 2 像素的原始框直接丢弃,不要保留到 txt 里。正规数据集一般不会有这种情况,但二次加工出来的资源很容易带进来,体检脚本里也应该把零宽高考虑进去。

5.5 四类样本极不平衡,验证集 mAP 虚高

现象:平均 mAP 看起来不错,单独看一眼某类别的 precision-recall 曲线掉到 0.3 以下。最常见的是 pitted_surface 这类小目标类别,样本少且框小,模型学不动。

原因:钢材缺陷数据集天然长尾分布,划伤类样本可能是皱褶类的几十倍。平均 mAP 被高频类别拉高,低频类别拖垮但贡献小。

解决:先看体检脚本里各类别图片数。对少数类做数据层过采样:训练时把少数类图片多复制几份进到训练清单,或者用复制粘贴增强,把少数类的缺陷实例贴到空背景图里。不要依赖改损失函数权重,那种做法对检测头的收敛没有数据层过采样直接。另一个调整是验证时单独看每个类别的 AP,不要只看平均分。

6. 最后一个技巧:用混淆矩阵和 conf 阈值判断模型能否上线

6.1 从 val 时的 confusion_matrix 读出主要误报方向

训练完不要只盯 mAP,第一件事是看验证集输出的confusion_matrix.png。这张图能直接告诉你哪些类别互相混淆,最常见的问题是划伤和背景之间的误报。如果背景那一列占比高,说明模型把钢板纹理误判成缺陷,这时需要提高 conf 阈值或者补背景负样本。如果某个缺陷类别被大量认成另一类,先回头检查类别映射顺序,再看训练图里这两类缺陷是否长得过于接近。

6.2 用单图推理脚本把 conf 和 iou 阈值卡在产线能接受的位置

我习惯在训练完做一次批量推理,统计每个预测框的置信度分位数,再决定生产环境的阈值。比如要求每 100 张图最多 5 个误报,就去看对应误报数下的置信度分位点,把 conf 设到这个值,而不是一直用默认的 0.25。

from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") results = model.predict("data/YOLO/images", conf=0.35, iou=0.5, save=False) for r in results: boxes = r.boxes if boxes is None: continue for box in boxes: print(box.cls.item(), box.conf.item(), box.xyxy.tolist())

这段代码会打印每张图预测出的类别、置信度和坐标。你可以从输出里看两个东西:一是同一张图上重叠框多不多,多了就调高 iou 到 0.6;二是误报的置信度集中区间,如果大量误报的 conf 在 0.3 到 0.4 之间,把阈值抬到 0.45 往往能砍掉大半误报而保住召回。

我自己的教训是:钢板上细小缺陷的置信度天然偏低,阈值抬得太高会把真缺陷一起过滤掉,所以每次调阈值都要重新算一遍重召率曲线,不能拍脑袋。另一个习惯是保留训练时的数据集外图片做盲测,至少 20 张,因为验证集看久了会让人产生“模型已经很好”的错觉。这套流程走完,这份谢韦尔数据集的价值才算真正落地——希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询