金属表面缺陷检测这个方向,做过工业视觉项目的人应该都不陌生。前几年大家还在用传统图像处理做阈值分割,现在基本都转向深度学习目标检测了。但真正上手第一个卡点往往不是模型选型,而是数据格式——你从公开渠道拿到的工业缺陷数据集,十有八九是CSV标注格式,而YOLO要的是每张图配一个txt。谢韦尔钢铁缺陷数据集就是典型代表:图像质量不错、缺陷类别清晰,但标注文件是CSV,直接喂给YOLOv5或YOLOv8会报错。这篇内容就是把我自己踩过的格式转换坑完整梳理一遍,从CSV结构解析到YOLO归一化坐标计算,再到转换后的校验流程,全部拆开讲清楚。适合刚接触工业缺陷检测的算法工程师、做课程设计的学生,以及需要快速把公开数据集跑通baseline的从业者参考。读完你至少能做到:拿到任意一份CSV标注的缺陷数据集,半小时内转成YOLO可训练格式,并且知道怎么验证转换没出错。
1. 先搞清楚两份格式到底差在哪
1.1 CSV标注的组织逻辑与谢韦尔数据集的实际结构
谢韦尔金属缺陷数据集(Severstal Steel Defect Detection)最初是某数据竞赛的公开数据,图像是钢铁板材表面俯拍图,缺陷类型分四类:类1到类4分别对应不同形态的表面瑕疵。它的标注文件是一个CSV,每行代表一个缺陷实例,核心字段通常包括ImageId、ClassId,以及缺陷区域的编码表示。这里有个关键点需要说透:谢韦尔的CSV并不是直接给矩形框的左上角右下角坐标,而是用游程编码(Run-Length Encoding,RLE)存储缺陷的像素掩码。也就是说,一行数据里ClassId告诉你这是什么缺陷,后面一长串数字是掩码的游程编码,需要解码才能还原出缺陷区域的像素集合。
这个设计对分割任务很友好,但对目标检测就不太直接了。YOLO要的是矩形框,你得先从RLE解码出掩码,再求掩码的外接矩形,最后归一化成YOLO的格式。很多新手在这里翻车,以为CSV里直接有xmin、ymin、xmax、ymax,结果打开文件一看全是数字串,完全不知道从哪下手。我见过有人硬着头皮去猜数字含义,浪费了大半天。所以第一步必须明确:谢韦尔CSV是RLE掩码编码,不是坐标框,转换链路是“RLE解码→掩码→外接矩形→归一化→YOLO txt”。
RLE的具体规则是:从图像左上角开始按行扫描(也有按列的实现,谢韦尔是按行),用“起始位置 游程长度”成对表示连续像素。比如“1 3 10 5”表示从第1个像素开始连续3个像素属于缺陷,从第10个像素开始连续5个像素属于缺陷。解码时把图像展平成一维数组,按这些区间置1,再reshape回原图尺寸,就得到二值掩码。理解这一点,后面写代码就是水到渠成的事。
1.2 YOLO格式的硬性要求与归一化规则
YOLO的标注格式极其简洁:每张图对应一个同名txt文件,每行一个目标,格式为class_id x_center y_center width height,五个值用空格分隔。其中后四个都是归一化到0到1之间的浮点数,x_center和y_center是框中心点相对图像宽高的比例,width和height是框宽高相对图像宽高的比例。class_id从0开始编号,这一点和CSV里的ClassId往往不一致,需要做映射。
归一化的计算公式不复杂但必须记牢:假设外接矩形在像素坐标下是(xmin, ymin, xmax, ymax),图像宽W高H,那么:
- x_center = ((xmin + xmax) / 2) / W
- y_center = ((ymin + ymax) / 2) / H
- width = (xmax - xmin) / W
- height = (ymax - ymin) / H
这里有个容易忽略的细节:像素坐标的边界处理。外接矩形算出来可能是浮点数,也可能超出图像边界(RLE解码偶尔会因为编码误差越界),必须做clip操作把坐标限制在[0, W-1]和[0, H-1]范围内,否则归一化后会出现大于1或小于0的值,训练时YOLO的数据加载器会直接报错或者静默丢弃该目标。我早期就吃过这个亏,转换完看着文件都生成了,训练时loss异常,排查半天才发现有几个框的width算出来是负数——原因是xmax小于xmin,掩码解码时行列搞反了。
另外YOLO对空标注文件的处理也要注意:如果某张图没有任何缺陷,理论上应该生成一个空txt文件(0字节),而不是不生成文件。YOLOv5/v8的默认配置下,没有txt的图片会被当作背景图处理,但显式生成空文件更稳妥,避免数据集划分时出现图片和标签数量对不上的告警。
1.3 两种格式的核心差异对照
把差异列成表更直观,也方便转换时逐项核对:
| 对比维度 | 谢韦尔CSV格式 | YOLO txt格式 |
|---|---|---|
| 标注载体 | 单个CSV汇总所有图 | 每图一个txt |
| 缺陷表示 | RLE游程编码掩码 | 归一化矩形框 |
| 类别编号 | ClassId通常1到4 | 从0开始连续编号 |
| 坐标单位 | 像素(需解码) | 0到1归一化浮点 |
| 一图多缺陷 | 多行记录 | 多行记录 |
| 空图处理 | 无记录 | 建议生成空txt |
这张表建议转换前打印出来贴在屏幕边上,每完成一步对照打勾。差异看似只有几项,但每一项背后都有坑,下面逐个拆解。
2. 转换方案设计与工具选型
2.1 为什么用Python脚本而不是现成工具
市面上确实有一些标注格式转换工具,但针对谢韦尔这种RLE编码的CSV,通用工具基本无能为力——它们大多只处理VOC XML或COCO JSON这类直接带坐标框的格式。硬套通用工具的结果往往是先要把CSV转成COCO,再转YOLO,中间多一道转换就多一层出错概率。我的建议是直接写Python脚本,理由有三:第一,RLE解码逻辑简单,几十行代码搞定,可控性最强;第二,转换过程中需要做类别映射、边界裁剪、空图补全等定制化处理,脚本改起来灵活;第三,脚本可以复用到其他RLE编码的数据集上,一次投入长期受益。
依赖库方面只需要三个:pandas读CSV、numpy做数组运算和RLE解码、opencv-python(cv2)读图像尺寸和做可视化校验。这三个库在主流Python环境里都是标配,不需要额外折腾。有人问能不能用PIL代替cv2,可以,但cv2读图速度快、后续画框校验也方便,工业数据集动辄上万张图,速度差异还是明显的。
2.2 转换流程的整体设计
整个转换链路我设计成五步流水线,每步独立可验证:
- 读取CSV并解析字段:确认列名,提取ImageId、ClassId、RLE编码三列。
- 按ImageId分组:同一张图可能有多个缺陷,需要聚合处理。
- 逐图RLE解码求外接矩形:解码掩码,用numpy的
where找非零像素的边界。 - 坐标归一化并写入txt:按YOLO公式计算,类别做减一映射(如果ClassId从1开始)。
- 校验与可视化抽查:随机抽几张图把框画上去,肉眼确认框的位置对不对。
这个设计的关键考量是分组处理。如果按CSV行逐行转换,同一张图会生成多个txt互相覆盖,最后只剩最后一个缺陷。必须先groupby再处理,这是新手最容易犯的错误之一。另外每步之间建议把中间结果落盘(比如解码后的掩码存成npy),出问题时可以断点排查,不用每次从头跑。
2.3 类别映射的坑与处理策略
谢韦尔数据集的ClassId是1、2、3、4,对应四类缺陷。YOLO要求从0开始,所以映射关系是1→0、2→1、3→2、4→3,统一减一即可。但这里有个隐藏问题:某些图像可能只包含部分类别,转换后生成的txt里类别编号不连续是正常的,不需要补全。真正需要警惕的是CSV里出现ClassId为0或大于4的异常记录,这通常是数据采集或标注环节的脏数据。我的处理策略是转换前先做一次value_counts统计,把异常类别打印出来人工确认,确认是脏数据就直接过滤掉,别让它污染训练集。
还有一个细节:如果后续要做多类别训练,建议在转换时同步生成一个classes.txt或data.yaml里的names列表,顺序严格按映射后的0到3排列。我见过有人转换时类别映射对了,但yaml里names顺序写反了,训练出来的模型把类1预测成类4,排查了一整天才发现是配置文件的问题。这种低级错误完全可以在转换阶段用脚本自动生成配置文件来规避。
3. 核心代码实现与逐段解析
3.1 RLE解码函数的实现细节
RLE解码是整个转换的核心,写错了后面全错。谢韦尔的RLE是按行扫描的,解码函数如下:
import numpy as np def rle_decode(rle_string, height, width): if not isinstance(rle_string, str) or rle_string.strip() == '': return np.zeros((height, width), dtype=np.uint8) s = rle_string.split() starts = np.asarray(s[0::2], dtype=int) - 1 lengths = np.asarray(s[1::2], dtype=int) img = np.zeros(height * width, dtype=np.uint8) for start, length in zip(starts, lengths): img[start:start + length] = 1 return img.reshape((height, width))这段代码有几个点必须说清楚。第一,starts要减1,因为RLE的起始位置是从1开始计数的,而numpy数组索引从0开始,不减1会导致整体偏移一个像素。这个偏移在掩码上不明显,但转成框后会有系统性偏差,尤其是小缺陷,偏一个像素可能框就偏出去了。第二,reshape的顺序是(height, width),对应按行优先展平,如果搞成(width, height),掩码会整体转置,框的位置完全错乱——这就是我前面提到的“行列搞反”的典型场景。第三,空字符串要返回全零掩码而不是报错,因为有些记录可能RLE字段为空。
注意:如果你的数据集RLE是按列扫描的(少数数据集如此),解码后需要先转置再reshape,判断方法是拿一张已知缺陷位置的图做可视化验证,框对不上就试试转置。
3.2 从掩码到外接矩形的计算
拿到二值掩码后,求外接矩形用numpy的where最直接:
def mask_to_bbox(mask): rows = np.any(mask, axis=1) cols = np.any(mask, axis=0) if not rows.any() or not cols.any(): return None ymin, ymax = np.where(rows)[0][[0, -1]] xmin, xmax = np.where(cols)[0][[0, -1]] return xmin, ymin, xmax, ymaxnp.any(mask, axis=1)得到每一行是否有缺陷像素,np.where取第一个和最后一个True的索引就是上下边界,列同理得左右边界。这里返回的xmax和ymax是包含端点的索引,计算宽高时要注意是xmax - xmin还是xmax - xmin + 1。严格来说像素索引包含端点,宽高应该是xmax - xmin + 1,但YOLO归一化时用xmax - xmin的误差在一个像素以内,对训练影响可以忽略。我个人的做法是统一用xmax - xmin,保持和主流开源实现一致,避免和其他脚本对比时出现莫名其妙的差异。
如果掩码全零(理论上不该出现,但脏数据难免),函数返回None,调用方要跳过该记录并记日志。千万别让None流到归一化环节,否则会抛TypeError,而且报错信息不直观,排查起来费劲。
3.3 归一化与txt写入的完整流程
主流程代码把前面几块串起来:
import pandas as pd import cv2 import os def convert(csv_path, img_dir, out_dir, img_size=(1600, 256)): df = pd.read_csv(csv_path) os.makedirs(out_dir, exist_ok=True) W, H = img_size for image_id, group in df.groupby('ImageId'): lines = [] for _, row in group.iterrows(): mask = rle_decode(row['EncodedPixels'], H, W) bbox = mask_to_bbox(mask) if bbox is None: continue xmin, ymin, xmax, ymax = bbox xmin, xmax = max(0, xmin), min(W - 1, xmax) ymin, ymax = max(0, ymin), min(H - 1, ymax) xc = (xmin + xmax) / 2.0 / W yc = (ymin + ymax) / 2.0 / H bw = (xmax - xmin) / W bh = (ymax - ymin) / H cls = int(row['ClassId']) - 1 lines.append(f"{cls} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") txt_name = os.path.splitext(image_id)[0] + '.txt' with open(os.path.join(out_dir, txt_name), 'w') as f: f.write('\n'.join(lines))几个实操要点:图像尺寸img_size必须和实际图像一致,谢韦尔原图是1600×256,如果你做了resize,这里要同步改,否则归一化全错。归一化保留6位小数足够,YOLO内部也是按float处理,位数太多没必要。空图(lines为空)时写入空字符串,生成0字节txt,符合前面说的空图处理策略。类别减一用int()强转,防止CSV里类别列被读成浮点导致格式异常。
3.4 批量处理与进度反馈
上万张图跑起来需要点时间,加个进度条心里有数:
from tqdm import tqdm for image_id, group in tqdm(df.groupby('ImageId'), total=df['ImageId'].nunique()): ...tqdm不是必装,但强烈建议装一个,转换大几千张图时没有进度反馈会让人怀疑程序卡死。另外建议把转换日志写到文件里,记录每张图的缺陷数量、跳过的异常记录,出问题时直接翻日志比重新跑一遍快得多。
4. 转换后的校验与常见问题排查
4.1 可视化抽查:最可靠的验证手段
代码跑完不代表转换正确,必须做可视化抽查。写个简单的脚本随机抽20张图,把txt里的框按归一化坐标反算回像素坐标画上去:
def visualize(img_path, txt_path, out_path): img = cv2.imread(img_path) H, W = img.shape[:2] with open(txt_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls, xc, yc, bw, bh = int(parts[0]), *map(float, parts[1:]) x1 = int((xc - bw / 2) * W) y1 = int((yc - bh / 2) * H) x2 = int((xc + bw / 2) * W) y2 = int((yc + bh / 2) * H) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(out_path, img)抽查时重点看三类情况:框是否紧贴缺陷区域(松了说明掩码解码有问题)、框是否在图像边界内(越界说明clip没生效)、多缺陷图的框数量是否和CSV记录数一致(少了说明分组或跳过逻辑有bug)。我一般抽20张,覆盖单缺陷、多缺陷、小缺陷、边缘缺陷四种场景,基本能覆盖绝大多数转换错误。
4.2 常见问题速查表
把实际踩过的坑整理成表,遇到问题先对照排查:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 训练报错坐标大于1 | 归一化前未clip或图像尺寸传错 | 检查img_size是否与原图一致,加clip |
| 框位置整体偏移 | RLE起始位置未减1 | 确认解码时starts减了1 |
| 框位置转置错乱 | reshape顺序写反 | 改为(height, width) |
| 多缺陷图只剩一个框 | 未按ImageId分组,txt被覆盖 | 用groupby聚合后统一写入 |
| 类别预测全错 | ClassId映射或names顺序错 | 核对减一映射与yaml中names顺序 |
| 部分图无txt文件 | 空图未生成空文件 | 空lines也写文件,内容为空 |
| 框宽高为负 | xmax小于xmin,行列搞反 | 检查mask_to_bbox的axis参数 |
这张表建议转换前先看一遍,转换后出问题再对照一遍,能省下大量瞎猜的时间。
4.3 数据集划分与配置文件生成
转换完txt只是第一步,YOLO训练还需要划分train/val、生成data.yaml。划分建议按8:2随机分,但要注意同一张图的所有缺陷必须在同一个集合,不能按行划分,否则会出现数据泄漏。配置文件里nc填类别数(谢韦尔是4),names按映射后的顺序写。我习惯在转换脚本末尾顺手把划分和yaml生成也做了,一条龙跑完直接能训练,省得来回切脚本。
提示:划分时如果某类缺陷样本极少,建议做分层抽样(stratified split),保证train和val里各类别比例接近,否则验证集指标波动会很大,误判模型效果。
4.4 几个提升效率的实操心得
第一个心得:先小批量验证再全量跑。拿CSV的前100行先跑一遍,可视化确认无误后再放开全量,避免跑完一万张发现逻辑错了重来。第二个心得:保留中间产物。解码后的掩码、外接矩形坐标都存一份,后续想改归一化方式或换输出格式(比如转COCO)时直接复用,不用重新解码。第三个心得:版本化你的转换脚本。数据集转换这种一次性工作,脚本改着改着就乱了,用git管理或者至少按日期备份,出问题能回滚到上一个能跑的版本。
工业缺陷检测的数据准备阶段,格式转换看似是体力活,但里面的细节密度很高,一个像素的偏移、一次reshape的顺序错误,都可能导致模型训练效果大打折扣甚至完全失效。把转换链路拆清楚、每步验证到位,后面模型调优才有可靠的基础。我个人在实际项目中的体会是,数据格式转换花的时间从来不会白费,前期多花两小时做校验,后期能省两天排查训练异常的时间。