简介:工业质检中,目标检测模型正在替代人眼在传送带上寻找细微缺陷。要训练一个可靠的缺陷检测模型,首先需要理解数据集的标注格式与转换原理。RLE(游程编码)是常见的一种紧凑标注方式,通过解码像素起点与长度可还原缺陷掩码;而YOLO格式则要求每个目标以归一化的边界框坐标表示。掌握从RLE到YOLO的转换技术,能显著提升小目标缺陷的检测效果。在钢铁表面缺陷检测、表面质量在线监控等工业视觉场景中,基于连通域拆分的标注转换、合理的训练集划分以及针对灰度图与极端长宽比的参数调优,是保证模型泛化能力的关键。本文以谢韦尔钢铁缺陷数据集为例,完整演示了RLE解码、目标检测格式转换、YOLOv8训练与避坑指南,帮助工程人员快速搭建工业缺陷检测基线。
1. 谢韦尔钢铁缺陷检测数据集是什么:一块钢板上找针尖大的缺陷,怎么变成目标检测问题
在钢板上找针尖大的斑点,人眼盯着传送带看到第40分钟就开始漏检;换成目标检测模型,相机装在生产线上方,让模型在1600x256的灰度图里把缺陷框出来。这个场景里被引用最多的公开数据,就是谢韦尔钢铁缺陷检测数据集。它来自俄罗斯谢韦尔钢铁公司的真实产线采集,在Kaggle上以公开挑战赛形式发布,四类缺陷、工业噪声很足:灰度纹理、类别极不均衡、小目标扎堆。对这个数据集做目标检测,相当于练一套“工业视觉落地前的小规模预演”,适合正在做质检自动化、YOLO系目标检测项目,或者想拿真实数据集从头走一遍“下载→标注转换→训练→验证”全流程的人。下面按我跑通这条线的顺序写,包括RLE标注怎么读、怎么转成YOLO格式、训练参数,以及转换和训练里的坑。
2. 数据集结构与RLE标注:先搞懂train.csv里存的是什么
拿到任何带标注的数据集,第一步不是急着训练,而是把标注格式看懂。谢韦尔钢铁缺陷检测数据集的标注不是常见的XML或JSON,而是RLE(Run-Length Encoding),存法是“从第几个像素开始、连续多少个像素”。如果不先解码RLE,后续转YOLO格式时很容易把坐标算错。
2.1 下载与目录结构:train.csv、图片目录与sample_submission
在Kaggle官方数据页搜索“Severstal Steel Defect Detection”就能找到这个数据集,需要先登录Kaggle账号,然后通过数据集页的Download按钮下载。常见做法是直接用Kaggle API或者第三方下载工具拉取压缩包,解压后核心文件就几个:
- train.csv:训练标注文件,每一行是一条缺陷记录,列分别是ImageId、ClassId、EncodedPixels。
- train_images/:训练图片目录,图片命名与train.csv里的ImageId对应。
- test_images/:无标注的测试图片目录,用来做推理验证。
- sample_submission.csv:Kaggle比赛里的提交模板,自己本地跑可以不理会。
train.csv不是“一张图一行”的结构,而是一张图可能有多行。同一张ImageId下如果有两处缺陷,就会有两行记录,每行对应一个ClassId和一段RLE编码;没有缺陷的图片则完全不会出现在train.csv里。这个结构决定了后面转检测标注时要按ImageId做聚合,而不是简单按行读一条写一条。
注意:下载解压后先检查train.csv编码,用pandas读成DataFrame时如果报编码错误,改为encoding="utf-8"通常能解决。
2.2 读RLE标注:把“像素起点+长度”变成可画出的掩码
RLE字符串的格式是“起点1 长度1 起点2 长度2 …”,起点从1开始计数,像素顺序按照图像一列一列往下数,也就是列优先(column-major)。图片尺寸是宽1600、高256,总共409600个像素点。要把它还原成掩码,常规做法是写一个decode函数:
import numpy as np import pandas as pd def rle_decode(rle_str, height=256, width=1600): # 空值处理:没有缺陷的图片在train.csv里不会出现,但防御性判断要保留 if not isinstance(rle_str, str) or rle_str == "": return np.zeros((height, width), dtype=np.uint8) s = rle_str.split() # 偶数下标是起点,奇数下标是连续长度 starts = np.asarray(s[0::2], dtype=int) lengths = np.asarray(s[1::2], dtype=int) # 起点在RLE编码里是1-based,转成0-based starts -= 1 ends = starts + lengths img_flat = np.zeros(height * width, dtype=np.uint8) for lo, hi in zip(starts, ends): img_flat[lo:hi] = 1 # F表示按列填充,先填满第0列再到第1列,与RLE计数顺序一致 return img_flat.reshape((height, width), order="F")这个函数里最容易错的就是reshape(..., order="F")。如果不加order="F",解码结果会整张图错位,因为你把按列数出来的序列塞进了按行排列的数组里。判断decode是否正确的办法很简单:随机拿一行train.csv,解码后把掩码画出来,看看轮廓是不是落在钢板纹理上,而不是一条横贯全图的对角线。
2.3 四类缺陷的形态差异:为什么做目标检测而不是只用分割
原比赛任务其实是语义分割,提交时要求输出RLE掩码。但很多人拿到这个数据集会先想到目标检测,原因很现实:产线质检系统里,检测框比像素级掩码更容易落地,框出缺陷位置让机械臂或人工复核,比精分割更实用。
四类缺陷在形态上有明显区别,社区里按视觉特征一般这么归纳:类别1是多个片状孔洞混合在一起,边缘不规整;类别2是密集的小麻点,一个区域内可能有几十个细小缺陷;类别3是长条划痕,宽度窄但跨度大;类别4是大块片状区域,边界相对完整。类别2和类别1往往面积小、数量多,类别3则是典型的“细长条”目标,类别4相对大而完整。
这个差异直接影响检测框的生成策略。如果对类别2整个掩码取一个外接矩形,会把一片零散小点包成一个大框,框的定位意义就没了。所以把分割标注转成目标检测标注时,不能简单对整张掩码取框,必须按连通域拆开,逐个缺陷生成边界框。下一章就解决这个问题。
3. 把分割标注转成YOLO目标检测格式:RLE解码、连通域拆分与目录组织
目标检测数据集的标准格式是每张图对应一个同名txt文件,每行写“类别 x_center y_center width height”,坐标都归一化到0到1。谢韦尔数据集本身没有这个格式,所以要做一次转换。转换的关键不是写代码,而是决定“一个框代表什么”。
3.1 确定检测粒度:整类一框还是按连通域逐框
转换前先想清楚检测粒度,这直接决定模型学什么。如果一张图像只有一个类别2的缺陷掩码,但掩码其实是几十个不相连的小点,取整个掩码的外接矩形,模型看到的就是一个巨大框里套着几十个点,训练时损失函数被这个大面积框主导,对单个小点的定位能力反而被稀释。
常见做法是先对掩码做连通域拆分,每个连通域生成一个框。拆开后类别2这类密集小点会变成大量小框,数据量变大,但模型学的是“每个独立缺陷一个框”,更贴近实际检测需求。也有团队按区域网格聚合小点,避免框数量爆炸,但这对后续评估和产线定位都不友好。我一般建议按连通域拆分,再用面积阈值过滤掉小于20像素的噪点框。
3.2 转换脚本:RLE解码、连通域拆分、YOLO txt输出
下面这个脚本会把train.csv里每一条RLE记录解码成掩码,再对掩码找连通域,最后写出YOLO格式标注。用scikit-image的measure.label和regionprops处理连通域,干净且不容易自己写错边界条件。
import cv2 import numpy as np import pandas as pd from skimage import measure def rle_decode(rle_str, height=256, width=1600): # 章节2.2里的解码函数,这里直接复用 if not isinstance(rle_str, str) or rle_str == "": return np.zeros((height, width), dtype=np.uint8) s = rle_str.split() starts = np.asarray(s[0::2], dtype=int) - 1 lengths = np.asarray(s[1::2], dtype=int) img_flat = np.zeros(height * width, dtype=np.uint8) for lo, hi in zip(starts, starts + lengths): img_flat[lo:hi] = 1 return img_flat.reshape((height, width), order="F") def mask_to_boxes(mask, min_area=20): # 对掩码做连通域分析,每个连通域是一个独立缺陷 labels = measure.label(mask, connectivity=2) boxes = [] for region in measure.regionprops(labels): # 过滤零散噪点,像素数小于阈值的区域直接丢弃 if region.area < min_area: continue y0, x0, y1, x1 = region.bbox boxes.append((x0, y0, x1, y1)) return boxes def write_yolo_txt(image_id, boxes, dst_dir, height=256, width=1600): # 一个ImageId对应的所有缺陷写入同一个txt dst_path = f"{dst_dir}/{image_id}.txt" with open(dst_path, "w") as f: for (class_id, x0, y0, x1, y1) in boxes: # YOLO类别从0开始,ClassId是1到4,所以减1 xc = (x0 + x1) / 2 / width yc = (y0 + y1) / 2 / height bw = (x1 - x0) / width bh = (y1 - y0) / height f.write(f"{class_id} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}\n")主循环按ImageId聚合:用pandas读train.csv后按ImageId分组,每组内处理所有行,同一个图片ID下可能有多类缺陷,把它们的框全部合并写进同一个txt。注意mask_to_boxes返回的坐标是(y0, x0, y1, x1),我在返回时把x和y换成了(x0, y0, x1, y1),避免和YOLO的xyxy顺序搞混。YOLO要求的归一化中心点坐标是浮点数,统一保留6位小数,精度足够;不要用整数,量化误差在1600宽的图上会放大好几像素。
3.3 划分训练/验证集:按ImageId分层随机
数据集划分是最容易出错的一步。谢韦尔的train.csv里一张图多行,如果直接对这个DataFrame做train_test_split,同一条ImageId的不同缺陷行可能被分到训练集和验证集两边,造成验证集泄漏,评估指标虚高。正确做法是先取出所有唯一ImageId,按这个列表切分,再把图片和标注文件复制到对应目录。
import shutil from sklearn.model_selection import train_test_split df = pd.read_csv("train.csv", encoding="utf-8") image_ids = df["ImageId"].unique() # 分层切分:按缺陷类别分布分层,防止某一类缺陷只出现在验证集 def class_label(img_id): return "_".join(str(c) for c in sorted(df[df["ImageId"] == img_id]["ClassId"].tolist())) train_ids, val_ids = train_test_split( image_ids, test_size=0.15, random_state=42, stratify=[class_label(i) for i in image_ids] ) # 复制图片和标注到YOLO风格的目录 for img_id in train_ids: shutil.copy(f"train_images/{img_id}.jpg", "yolo/images/train/") shutil.copy(f"yolo_labels/{img_id}.txt", "yolo/labels/train/") for img_id in val_ids: shutil.copy(f"train_images/{img_id}.jpg", "yolo/images/val/") shutil.copy(f"yolo_labels/{img_id}.txt", "yolo/labels/val/")stratify参数按“该图包含哪些类别”的组合做分层,能有效防止类别不均衡数据集里某一小类在验证集中消失。如果切分后验证集里完全看不到类别3,训练出来的模型在类别3上的表现就是未知数,等于白训。random_state固定后每次运行结果一致,方便复现。
3.4 组织目录与可视化验证
转换完成后,目录结构应当是这样:
yolo/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml我习惯在开始训练前先做一轮可视化校验,把标注框画回原图上,人工扫一眼有没有坐标错位。OpenCV画框很简单:
import cv2 img = cv2.imread("yolo/images/train/0002ca04e.jpg") with open("yolo/labels/train/0002ca04e.txt") as f: for line in f: cls, xc, yc, bw, bh = map(float, line.split()) x0 = int((xc - bw / 2) * 1600) y0 = int((yc - bh / 2) * 256) x1 = int((xc + bw / 2) * 1600) y1 = int((yc + bh / 2) * 256) cv2.rectangle(img, (x0, y0), (x1, y1), (0, 255, 0), 2) cv2.imwrite("check.jpg", img)这一步不用先创建可视化脚本,临时写几行就够了。重点看两点:框是否贴合缺陷纹理、密集小点区域是否被拆成了多个小框而不是一个大框。如果看到大框,回到3.2节把min_area调大或检查连通域参数。
提示:这类已有RLE标注的数据集不需要再用手工标注工具,labelimg或Roboflow只有在处理完全没有标注的图片时才需要;转换脚本的正确性用可视化来保证,而不是靠肉眼数坐标。
4. 用YOLOv8训练自己的谢韦尔检测模型:一次跑通的命令与参数
数据集转换完成后,训练本身反而是相对顺的一步。YOLOv8对目录结构的要求固定,只要data.yaml写对,训练命令一行就能跑起来。但谢韦尔这个数据集有两个特殊性:图很短(1600x256)且是灰度图,参数必须结合这两点来调。
4.1 数据集YAML与类别数量设置
YOLO训练需要一份data.yaml,指定图片路径、验证集路径和类别名。类别名是给日志和评估看的,原则上尽量用英文,这里先用defect1到defect4代替。
path: /home/user/steel/yolo train: images/train val: images/val names: 0: defect1 1: defect2 2: defect3 3: defect4注意path要换成你自己的绝对路径。YOLO的txt标注第一列的数字就是类别索引,不用在yaml里额外配置类别对应关系。这个结构与COCO数据集的标注格式完全不同:COCO是单文件JSON带annotations数组,YOLO是每图一个txt;从COCO转过来的标注如果忘记把类别索引减1,训练时类别就整体错位,这种错误报表看不出来,只能靠可视化发现。
4.2 训练命令与关键超参配置
用Ultralytics的CLI训练是最省事的方式,模型先拿yolov8n跑通流程,再根据显存换m或l。
yolo detect train \ model=yolov8n.pt \ data=data.yaml \ imgsz=640 \ epochs=80 \ batch=16 \ device=0 \ patience=15 \ cos_lr=True \ close_mosaic=10各参数的实际作用:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| imgsz | 640 | 输入图像短边会通过letterbox补边,不是直接拉伸 |
| epochs | 80 | 小规模数据集80轮足够,太长反而过拟合 |
| batch | 16 | 根据显存调整,8G显存用16,24G显存可以到32 |
| patience | 15 | 连续15轮验证集mAP不涨就早停,省时间 |
| cos_lr | True | 余弦退火学习率,对这类小数据集比线性下降稳 |
| close_mosaic | 10 | 最后10轮关闭mosaic增强,让模型在接近真实分布上收敛 |
很多人拿到数据集第一个动作就是把epochs设成300,这没必要。谢韦尔数据集规模不算大,yolov8n在80轮左右就能收敛,后面的轮次基本在震荡。早停patience设15,既不会浪费训练时间,也不会在刚跌下去时过早掐断。如果显存紧张,imgsz降到480也能跑,但小目标检测精度会明显下降,后面会讲原因。
4.3 灰度图与1600x256长宽比:letterbox的作用与陷阱
谢韦尔的图片是1600x256的灰度图,直接读进来是三通道,因为OpenCV默认imread会把灰度图复制成三通道。YOLOv8训练时会先把长边缩放到imgsz并把短边补灰,形成正方形输入,这个操作叫letterbox,优点是原图比例不被破坏。但对这个数据集,长宽比达到6.25比1,长边1600缩到640后,高度只有102像素,缺陷纹理里的细节被压缩得很厉害,尤其是类别2那种密集小点,可能缩到几个像素大小。
我试过的惯常做法是分两档:先用imgsz=640跑通流程,拿到一个可用的baseline;如果小目标AP不满意,再把imgsz加到1280或1536,让短边保留更多细节。显存够用就上大分辨率,效果提升直接,副作用是训练时间变长。还有一种思路是训练时用原比例不强制正方形,但YOLOv8的默认设置letterbox已经做得很好,不要关掉它。推理时也要保持同样预处理。
4.4 评估结果怎么看:优先看小目标AP与混淆矩阵
训练结束后,在runs/detect/train/weights/下能看到best.pt和last.pt,评估指标在训练日志里会打印,但我不会只看最终的mAP数字。results.png里的mAP50和mAP50-95曲线要分开看:钢铁缺陷工业场景更看重mAP50,因为检测框不需要像素级精确;mAP50-95对框的贴合度敏感,在细长划痕上天然吃亏。
confusion_matrix.png是另一个重点检查对象。看类别2误检成类别4或背景的比例高不高。如果背景那一列的误检很多,说明模型把钢板纹理当成了缺陷,常见解决办法是增强数据里的负样本,谢韦尔数据集可以专门挑一批无缺陷图片放入训练集作为背景类别,但YOLO的检测训练不支持纯负样本图片,需要用高难负样本做难例挖掘,这是后话。验证集的可视化预测图val_batch0_pred.jpg也要打开看,模型输出的框和人工标注框的偏差比任何数字都直观。
5. 避坑指南:RLE转检测、数据划分与模型微调中的五处翻车现场
这组数据我前前后后处理过三次,每次踩的坑都不一样,但最有共性的就集中在RLE转换、类别不均衡和训练收敛上。
5.1 坑1:RLE掩码取外接矩形时把空白区间包了进去
现象:转换完成后可视化检查,发现某些框异常巨大,框住了大半个钢板区域,框内只有零星几个小缺陷点。
原因:原图的同一段RLE编码可能包含多个互不相连的缺陷区域,它们被编码成了一个长度连续的掩码段。直接对整个掩码取最小外接矩形,会把中间的空白区域一起包进去。这不是解码错了,而是粒度选择错了。
解决:在3.2节里用measure.label先做连通域拆分,再对每个独立连通域取bbox。洗数据阶段的肉眼检查就能发现,画完框先随机抽20张图看一眼,别急着训练。
5.2 坑2:类别2的密集麻点把模型整个带偏
现象:训练时loss正常下降,验证集mAP看着不错,但单独统计每个类别的AP,类别3和类别4低得可怜,只有类别2在撑数字。
原因:类别2的小点缺陷在转换后数量暴增,一张图几十个框,而类别3长条划痕一张图通常只有一两个框,类别不均衡在目标检测里最直接的表现就是小类被大类淹没。
解决:转换脚本里的min_area=20先过滤掉极小噪点框;训练时给类别3和类别4单独设置更高的cls损失权重,或者对小类做copy-paste增强,把类别3的缺陷区域复制到无缺陷图片上,人为扩大样本量。如果只看整体mAP,这类问题会被掩盖。
5.3 坑3:灰度图读入方式不统一,训练和推理表现不一致
现象:训练过程正常,但换到产线测试图后,同样的钢板缺陷检测率骤降,mAP掉十几个点。
原因:训练用的灰度图是复制成三通道喂给模型的,而测试图可能来自不同相机、不同亮度,灰度分布和训练集不一致。更隐蔽的问题是有人用cv2.imread默认三通道读灰度图,再用cv2.cvtColor转回灰度,灰度映射的gamma不一致,导致推理分布偏移。
解决:统一读入方式,无论是训练还是推理,都用cv2.imread(path, cv2.IMREAD_GRAYSCALE)读灰度,再np.stack([gray]*3, axis=-1)生成三通道,防止任何自动色彩变换介入。灰度图的归一化细节也要固定,YOLOv8内部会做归一化,但输入源必须一致。
5.4 坑4:目标检测模型微调崩了,loss在某个epoch后突然飞涨
现象:用预训练权重微调,前几个epoch loss在下降,突然某轮之后loss暴涨,后面再也回不去,模型彻底废掉。
原因:这种崩掉大多发生在学习率过大或者数据增强在后期仍然开着。数据增强太强会让模型在训练后期反复看到扭曲过的目标,warmup结束后本应缩小学习率让模型收敛,如果learning rate还是初始值,梯度更新步长过大,loss直接就飞了。另外显存不足时自动混合精度切换也可能导致梯度异常,但这种通常伴随NaN。
解决:早停加权重回退是最有效的后悔药。训练日志里记录每个epoch的loss,崩掉的轮次前后一定有一个保存的last.pt,直接回到那轮继续调低学习率或者关闭mosaic增强重训。参数上close_mosaic=10就是干这个用的,最后10轮去掉大幅裁剪的增强策略,让模型在接近真实数据分布上收尾。
5.5 坑5:划分训练集和验证集时拆散了同一张图的不同框
现象:训练出的模型验证集mAP特别高,但拿到新图片上效果明显不及验证指标,典型的评估虚高。
原因:train.csv按行划分数据集,同一条ImageId的不同缺陷被分到训练集和验证集两侧,验证集里出现了训练集图片的缺陷信息。模型实际是“见过”这张图的,评估数字当然好看,但换新图就打回原形。
解决:划分时先df["ImageId"].unique()取图片列表,按图片ID切分,再复制对应的txt和图片文件。3.3节的做法是正解,先切ID,再复制数据,顺序不能反。
6. 一个能马上用的验证技巧:按缺陷尺寸分段看AP,别被总mAP骗了
训练结束看指标时,我现在的习惯是先写一段脚本统计验证集里所有框的尺寸分布,再结合模型按面积分段的AP一起看。钢铁缺陷不同类别天然分属不同尺寸段:类别2的麻点框面积小,类别3的划痕框是细长条,类别4是大块区域。总mAP平衡了这些差异,但小目标和大目标对分辨率、anchor的敏感度完全不同,一个总mAP数字说明不了模型到底在哪个尺寸段上强。
统计框尺寸分布的脚本很简单:
import os, glob import numpy as np areas = [] for txt_path in glob.glob("yolo/labels/val/*.txt"): with open(txt_path) as f: for line in f: cls, xc, yc, bw, bh = map(float, line.split()) areas.append(bw * bh * 1600 * 256) areas = np.array(areas) print("小目标(<1024)占比:", (areas < 1024).mean()) print("中目标(1024~4096)占比:", ((areas >= 1024) & (areas < 4096)).mean()) print("大目标(>=4096)占比:", (areas >= 4096).mean())跑完这个统计再对比验证指标,如果小目标占比超过50%但模型的小目标AP只有20%多,那问题不在训练参数,而是输入分辨率不够或者小目标增强没开。对谢韦尔这类大图小目标场景,单纯提高imgsz是成本最低的改进手段;进一步的做法是切图推理,把1600x256的图切成四段,每段单独送进模型再合并结果,UItralytics官方文档里叫SAHI切片推理,本质上是用计算量换小目标召回率,工业场景里非常实用。我现在的习惯是任何钢板缺陷项目都先跑这个尺寸统计再决定分辨率策略,而不是训练几天后才发现小目标白费;这个习惯帮我避过好几次深夜重训,希望帮到你。
本文还有配套的精品资源,点击获取