SWDD钢表面缺陷数据集YOLO/VOC/COCO标注格式转换实战指南
2026/9/16 21:47:17 网站建设 项目流程

做工业视觉的朋友应该都经历过这种场景:好不容易找到一个合适的数据集,兴奋地解压完,发现图片质量不错、缺陷类型也很全,结果一看标注格式——Pascal VOC的XML。想直接丢进YOLO跑个基线,它要的是每张图配一个txt的YOLO格式;想换成COCO预训练模型做迁移学习,它又得吃一个包含images、annotations、categories三张表的JSON。SWDD这个钢表面缺陷数据集,我最早拿到的时候就是这个心态。SWDD和SWDD v2是工业质检场景里比较有代表性的数据集,缺陷类型贴近真实产线,不少论文都拿它当基准集。这篇文章就把我从下载、整理标注到完成YOLO/VOC/COCO三种格式互相转换的整个流程写清楚,代码可以直接拿走去改,适合所有刚开始把公共数据集用于目标检测训练的读者。

1. SWDD是什么:先确认这个数据集值不值得用

1.1 钢表面缺陷检测的场景与SWDD的定位

钢铁产线上的表面缺陷检测,看着是个"找瑕疵"的活,实际做起来比想象中难很多。连续铸轧的带钢速度快,表面光照不均匀,背景还有氧化皮和反光干扰,缺陷种类和形态变化也大——同一种划痕,在粗轧和精轧阶段的表现完全不一样。SWDD这类数据集的价值,就是把这些产线真实场景里的缺陷样本集中起来,让算法工程师不用自己去现场搭采集装置,也能有一个相对标准的验证环境。

SWDD的缺陷类型主要集中在钢表面比较典型的几类瑕疵上,比如划痕、氧化皮残留、麻点、夹杂等,具体类别名会因为发布渠道不同有细微差异,下载后以自带的labelmap或说明文档为准。这个数据集的定位既不是像ImageNet那样追求海量,也不是像某些合成数据集那样过于干净。它的缺陷尺寸偏小、对比度偏低,和真实质检场景的分布很贴近,拿来验证模型在"小目标+低对比度"场景下的表现,比在Pascal VOC上刷精度更有说服力。

1.2 SWDD v2相比v1的差异:数据量和标注质量都升级了

SWDD v2和v1之间没有那种翻天覆地的变化,但改进的点都很切中要害。

v2在视觉上最明显的提升是缺陷样本数量增加了,尤其是那些"难样本"——边缘模糊的、反光严重的、被氧化皮部分遮挡的缺陷。这类样本在v1里数量偏少,训练出来的模型很容易把它们漏检。v2还做了一轮标注复审,把不少边界框的标注位置重新校准过。这一点在工业数据集中格外关键,因为表面缺陷的边界往往不像猫狗目标那么清晰,标注员的判断差异很大,复审后的标注一致性明显更好。

我实际对比v1和v2的感受是:v2训练出来的模型在验证集上mAP提升不算夸张,但漏检率明显下降,尤其是小缺陷的那一档。如果你是从零开始做这个方向,直接下SWDD v2就好,v1的价值更多在于做消融实验时对比不同训练数据规模的影响。

1.3 下载渠道、目录结构与收到压缩包后的检查清单

SWDD的下载渠道比较分散,没有唯一的官方入口。比较常见的是GitHub上作者项目仓库的附加材料,Kaggle上也有人做过镜像,另外OpenDataLab这类公共数据集平台也能搜到。无论从哪下载,建议先看一眼License和引用说明。工业数据集的授权通常比学术数据集严格,有些版本只允许研究用途,商用前务必确认清楚。

下载解压之后,先别急着写训练脚本。花十分钟做三件检查,能省掉后面好多坑:

  1. 用脚本把图片全部读一遍,确认没有损坏的JPEG或PNG,尤其要检查是否有通道数不统一的灰度图混在里面。
  2. 数一下图片数量和标注文件数量是否对得上。公共数据集偶尔会有个别样本漏标注,数量对不上时后面转格式很容易莫名报错。
  3. 看一眼标注的坐标是整数还是浮点数,类别名有没有带空格或特殊字符。空格和破折号这类符号在写脚本解析时很容易被忽略,但确实会导致奇怪的问题。

2. VOC、COCO、YOLO三种标注格式的坐标系差异

格式转换的报错,九成以上不是代码写错,而是坐标系没想清楚。所以在接触代码之前,我建议先把这三种格式的存储方式和坐标定义理清楚。

2.1 VOC XML:像素绝对坐标,人类最友好

Pascal VOC的标注是每张图一个XML文件,根节点annotation下面有filename、size这些图片信息,每个object节点里存类别名和bndbox。bndbox里是xmin、ymin、xmax、ymax四个值,全部是像素绝对坐标,左上角为原点,向y轴和x轴正方向延伸。

VOC格式的好处是直白,打开XML就知道框在哪、有多大。缺点是不利于直接训练——大部分检测框架内部用的都是相对坐标或归一化坐标,所以VOC通常需要先经过一个转换层。这也是为什么刚下到SWDD时,你会发现很多tutorial都会告诉你"先转成YOLO格式"。

2.2 COCO JSON:三张表的结构化存储

COCO格式不是按图片拆文件,而是整个数据集一个JSON。最简结构包含三张表:

  • images:每张图片的id、file_name、width、height。
  • annotations:每个标注框的id、image_id、category_id、bbox、area、iscrowd。
  • categories:类别id与类别名的映射。

COCO的bbox格式是[x, y, width, height],依然是像素绝对坐标,但表示的语义从VOC的"左上角和右下角"变成了"左上角坐标加宽高"。这个差别看起来小,却是转换时最容易出错的地方——直接把VOC的xmin、ymin、xmax、ymax四个值原样填进COCO的bbox里,检测框就完全错位了。

还有一个隐藏细节:COCO的category_id通常从1开始,而不是从0开始。这一点在后面的转换脚本里要特别注意,因为YOLO的类别索引是从0开始的。

2.3 YOLO txt:一行一个框的归一化坐标

YOLO最流行的标注形态是每张图片对应一个同名txt文件,每一行表示一个目标,格式是:

class_id x_center y_center width height

其中坐标全部做了归一化:x_center和y_center是目标中心点相对于图片宽度和高度的比例,width和height是目标宽高相对于图片宽高的比例,取值都落在0到1之间。类别id从0开始计数。

这种格式对训练框架最友好,因为不需要在加载数据时再做坐标换算。但对人来说不直观,想象一个框在图片里的位置,还得在心里反算一遍像素坐标。

2.4 一张表格看懂三者差异

项目VOC XMLCOCO JSONYOLO txt
文件组织每图一个XML整个数据集一个JSON每图一个txt
坐标形式xmin, ymin, xmax, ymaxx, y, width, heightx_center, y_center, width, height
坐标单位像素绝对坐标像素绝对坐标归一化比例0~1
类别索引字符串类别名从1开始的整数ID从0开始的整数ID
可读性直观结构化但较繁琐训练友好但不直观

把这张表完全吃透之后再去看转换代码,会顺畅很多。

3. 动手转换前的第一步:统一中间数据结构

3.1 为什么不建议直接写三套转换代码

很多人拿到标注后,会直接写一个"VOC转YOLO"的脚本,再用另一个"VOC转COCO"的脚本,第三份数据又单独写一套。三个脚本各有各的逻辑,改一个坑得找半天。

我的做法是先定义一个统一的内存结构,把原始标注解析成这个结构,再由这个结构派生出YOLO、COCO、VOC任意一种目标格式。这样解析部分只需要写一遍,输出部分各写一段独立代码,后续如果还想转YOLO之外的格式,只需要加一个新的输出函数。

那用什么作为统一结构呢?我习惯用list of dict,每个dict表示一张图片的标注:

annotation = { "image_path": "images/xxx.jpg", "width": 640, "height": 480, "objects": [ {"category": "scratch", "bbox": [xmin, ymin, xmax, ymax]}, {"category": "patches", "bbox": [xmin, ymin, xmax, ymax]} ] }

bbox统一用VOC的[xmin, ymin, xmax, ymax]像素绝对坐标作为中间表示。为什么选这个?因为它不丢失信息,可以无损推导出中心点格式和宽高格式,反过来则不行——如果你在中间层就转成了中心点加宽高,再想写回VOC的xmin就需要额外记录图片尺寸,麻烦不少。

3.2 解析SWDD的原始标注:以XML版本为例

假设你拿到的SWDD版本是VOC风格目录,图片在images文件夹,XML在annotations文件夹,姿势如下:

SWDD/ ├── images/ │ ├── 001.jpg │ └── 002.jpg └── annotations/ ├── 001.xml └── 002.xml

用一个基于xml.etree.ElementTree的解析函数就能读完所有标注:

import xml.etree.ElementTree as ET def parse_voc_xml(xml_path): tree = ET.parse(xml_path) root = tree.getroot() filename = root.findtext("filename") size = root.find("size") width = int(float(size.findtext("width"))) height = int(float(size.findtext("height"))) objects = [] for obj in root.findall("object"): name = obj.findtext("name").strip() box = obj.find("bndbox") # 这里不强制转int,因为有些标注工具导出的是float坐标 xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) objects.append({"category": name, "bbox": [xmin, ymin, xmax, ymax]}) return { "image_path": f"images/{filename}", "width": width, "height": height, "objects": objects }

如果你的SWDD版本不是XML,而是更现代的"images + labels的txt"结构,那么解析函数就换成读txt,一行一行按空格或者逗号切分即可。核心不变:解析出来后仍然统一塞进上面那个中间结构。

有个细节值得提醒:解析XML时把坐标用float而不是int。很多人图省事直接写int(box.findtext('xmin')),但有些标注工具(尤其半自动标注工具)导出的坐标是浮点,直接截断成int会引入1~2像素偏差。对于钢材表面那种小缺陷,差两三个像素有时候就是边界上一整个目标的判定差异了。

4. 代码实战:从中间结构生成YOLO/VOC/COCO

4.1 YOLO格式:归一化坐标与目录组织

生成YOLO格式时,最容易写错的一行代码是宽高的换算。先看一个标准实现:

def voc_bbox_to_yolo(img_w, img_h, bbox): xmin, ymin, xmax, ymax = bbox c_x = (xmin + xmax) / 2.0 / img_w c_y = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h return c_x, c_y, w, h

有一些细节处理也是实际跑训练时才会发现的:

4.1.1 类别ID映射表的建立

中间结构里存的是类别字符串,而YOLO需要的是从0开始的整数ID。所以你要维护一个固定顺序的类别列表,让字符串和ID一一对应。千万别在遍历过程中用category_set.add(name)这种临时收集方式,因为set不保证顺序,两次运行出来的ID对应关系可能不同,训练和推理对不上就是灾难。

正确做法是写死或读配置文件:

CLASS_NAMES = ["inclusion", "patches", "pitted_surface", "rolled-in_scale", "scratches"] CLASS_TO_ID = {name: idx for idx, name in enumerate(CLASS_NAMES)} # 后续写入txt时统一用 CLASS_TO_ID[obj["category"]]

SWDD具体有几个类别、名字怎么拼,以你下载版本里的说明文件为准。但我强烈建议把这个列表保存成一个class_names.txt或者写进data.yaml,以后每次用数据集都从这里读,而不是抄在脚本里。

4.1.2 YOLO目标检测的标准目录结构

生成YOLO格式后,推荐按Ultralytics的习惯组织目录:

swdd_yolo/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml

对应生成脚本大致长这样:

import os def write_yolo_format(annotations, image_dir, label_dir): os.makedirs(image_dir, exist_ok=True) os.makedirs(label_dir, exist_ok=True) for ann in annotations: img_name = os.path.basename(ann["image_path"]) stem = os.path.splitext(img_name)[0] label_path = os.path.join(label_dir, stem + ".txt") with open(label_path, "w", encoding="utf-8") as f: for obj in ann["objects"]: cat_id = CLASS_TO_ID[obj["category"]] c_x, c_y, w, h = voc_bbox_to_yolo( ann["width"], ann["height"], obj["bbox"] ) # 防止归一化后出现越界的小数,比如 1.0000001 c_x = min(max(c_x, 0.0), 1.0) c_y = min(max(c_y, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) f.write(f"{cat_id} {c_x:.6f} {c_y:.6f} {w:.6f} {h:.6f}\n")

注意那个clip操作。坐标越界是个很隐蔽的问题:某个框紧贴图片右边缘时,x_center + width/2在浮点计算下可能算出1.0000001,写进txt后训练框架读出来就是>1的越界框,轻则警告,重则训练损失异常。

4.2 COCO JSON:类别ID和bbox的细节

COCO格式生成要复杂一些,因为有两个ID体系:image_id和annotation_id。image_id从1开始递增,annotation_id也建议从1开始,两者独立。

import json def write_coco_format(annotations, output_path): images = [] annotations_list = [] ann_id = 1 for img_id, ann in enumerate(annotations, start=1): images.append({ "id": img_id, "file_name": os.path.basename(ann["image_path"]), "width": ann["width"], "height": ann["height"] }) for obj in ann["objects"]: xmin, ymin, xmax, ymax = obj["bbox"] w = xmax - xmin h = ymax - ymin annotations_list.append({ "id": ann_id, "image_id": img_id, "category_id": CLASS_TO_ID[obj["category"]] + 1, # COCO从1开始 "bbox": [xmin, ymin, w, h], "area": w * h, "iscrowd": 0 }) ann_id += 1 categories = [ {"id": idx + 1, "name": name} for idx, name in enumerate(CLASS_NAMES) ] coco_dict = { "images": images, "annotations": annotations_list, "categories": categories } with open(output_path, "w", encoding="utf-8") as f: json.dump(coco_dict, f, indent=2)

这段代码有三个关键点:

第一,category_id必须加1。这是COCO和YOLO之间最典型的差异,很多人转完之后训练不报错,但mAP一直上不去,查到最后发现所有框的类别整体错了一位。

第二,bbox是[x, y, width, height],不是中心点格式。如果你从中间结构转出来的是VOC的xmin,就需要用xmax - xmin换算宽度,而不是直接把坐标赋值过去。

第三,没有提供segmentation字段。像MMDetection这类框架在加载COCO格式时,如果只有bbox没有segmentation是可以正常训练检测模型的。除非你后续要做实例分割,才需要补上polygon级别的掩码标注。

4.3 VOC XML:从中间结构反向生成

有时候也需要往VOC格式反向转换,比如你想用某些只支持VOC的标注工具修改SWDD的标注。反向生成就是解析的逆过程:

def write_voc_xml(ann, output_path): annotation = ET.Element("annotation") ET.SubElement(annotation, "folder").text = "SWDD" ET.SubElement(annotation, "filename").text = os.path.basename(ann["image_path"]) size = ET.SubElement(annotation, "size") ET.SubElement(size, "width").text = str(ann["width"]) ET.SubElement(size, "height").text = str(ann["height"]) ET.SubElement(size, "depth").text = "3" for obj in ann["objects"]: object_elem = ET.SubElement(annotation, "object") ET.SubElement(object_elem, "name").text = obj["category"] bndbox = ET.SubElement(object_elem, "bndbox") xmin, ymin, xmax, ymax = obj["bbox"] ET.SubElement(bndbox, "xmin").text = f"{xmin:.2f}" ET.SubElement(bndbox, "ymin").text = f"{ymin:.2f}" ET.SubElement(bndbox, "xmax").text = f"{xmax:.2f}" ET.SubElement(bndbox, "ymax").text = f"{ymax:.2f}" tree = ET.ElementTree(annotation) tree.write(output_path, encoding="utf-8", xml_declaration=True)

注意反向生成时坐标保留的小数位。写成整数虽然看起来干净,但遇到细长的裂缝缺陷时,四舍五入带来的1像素偏差在放大后可能占整个目标宽度的20%以上。写成浮点数能保住原始标注的信息。

4.4 划分train/val并生成data.yaml

格式转换完成后,别忘了划分数据集。公共数据集常见的问题是缺陷类别分布不均衡,随手切train/val可能让某个类在验证集里只有几个样本,评估结果波动极大。

比较靠谱的做法是分层划分,保证每个类别在train和val里的比例基本一致。简单实现可以按类别做groupby,再在每类内部随机划分。比如swdd整体样本量不大,train:val按8:2或者9:1都是可以的。

data.yaml是Ultralytics YOLO训练时的配置文件,里面需要指定路径、类别数和类别名:

path: /path/to/swdd_yolo train: images/train val: images/val nc: 5 names: 0: inclusion 1: patches 2: pitted_surface 3: rolled-in_scale 4: scratches

这个文件里的nc写成别的数字一般也不会报错,但会导致最后那层检测头和你实际类别数不匹配,迁移效果莫名其妙变差。从class_names.txt读出来自动生成是最稳妥的。

4.5 整体调用示例

把上面几个函数串起来,一次调用完成全部转换:

import glob annotations = [] for xml_path in sorted(glob.glob("annotations/*.xml")): annotations.append(parse_voc_xml(xml_path)) # 按8:2划分 random.shuffle(annotations) split_idx = int(len(annotations) * 0.8) train_annotations = annotations[:split_idx] val_annotations = annotations[split_idx:] # 输出YOLO格式 write_yolo_format(train_annotations, "swdd_yolo/images/train", "swdd_yolo/labels/train") write_yolo_format(val_annotations, "swdd_yolo/images/val", "swdd_yolo/labels/val") # 输出COCO格式 write_coco_format(train_annotations, "swdd_coco/annotations/train.json") write_coco_format(val_annotations, "swdd_coco/annotations/val.json")

注意random.shuffle之前最好设一下random seed,保证每次跑出来的划分一致,不然复现实验结果时你会发现两次训练的val set都不一样。

5. 转换完后必须做的检查:可视化与常见坐标坑

格式转换完了,千万别直接开训练。先把标注可视化一遍,这一步能拦下绝大多数低级错误。

5.1 把YOLO txt画回原图

用Pillow加载图片,把归一化坐标还原成像素坐标,然后画框:

from PIL import Image, ImageDraw def visualize_yolo_txt(image_path, txt_path, class_names=None): img = Image.open(image_path).convert("RGB") w, h = img.size draw = ImageDraw.Draw(img) with open(txt_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if not parts: continue cls_id = int(parts[0]) c_x, c_y, box_w, box_h = map(float, parts[1:]) # 反归一化 x1 = (c_x - box_w / 2) * w y1 = (c_y - box_h / 2) * h x2 = (c_x + box_w / 2) * w y2 = (c_y + box_h / 2) * h label = class_names[cls_id] if class_names else str(cls_id) draw.rectangle([x1, y1, x2, y2], outline="red", width=2) draw.text((x1, max(y1 - 10, 0)), label, fill="red") img.save("check.png")

每周挑十来张图看一眼,比写一堆单元测试都管用。

5.2 常见坐标坑:归一化、越界、ID错位

我在这里把排错过程中遇过的坑汇总一下,方便你对照查:

现象根本原因
框的位置偏到图片右上角把VOC的xmin当成中心点的x坐标直接除以宽度
框的大小明显错误,占据了几乎整图width和height用了中心点的两倍,忘了除以2,或把面积当宽高
训练时标签反复警告坐标越界归一化后没有clip,浮点运算产生1.0000001
所有目标类别整体错一位YOLO转COCO时没有自动加1,或COCO转YOLO时忘了减1
图片数量与txt数量对不上有文件名带特殊字符,或解析索引时用startswith误匹配了前缀相同的文件

还有一个容易被忽略的:检查txt文件名和图片文件名是否完全一致。很多数据集的命名是00001.jpg1.txt这种不一致,看起来没什么,写到脚本里匹配不上就是找不到标签。好一点的处理是统一用zfill补零,或者干脆统一用os.path.splitext取stem来拼接。

5.3 批量校验脚本:写一个简单的标签自检

除了可视化,我还会跑一个批量检查,统计所有标签文件里是否出现以下情况:

  • 有空文件(有些框架会警告,但最好提前知道)
  • 类别ID超出了nc-1
  • 坐标值小于0或大于1
  • 宽度或高度为0

这种脚本不难写,但关键时刻能救命。我见过不少人用了一个月才发现某个类别的ID在训练配置里被写反了,重跑一次实验的时间成本远比写脚本高。

6. 用SWDD训练YOLO的实操建议与几个容易忽略的细节

6.1 数据增强的取舍:别让增强抹掉小缺陷

SWDD这类工业质检数据集的样本量通常不算大,很多人习惯开满YOLO自带的增强参数。但这里有个矛盾:数据增强是为了增加多样性,可对于微小缺陷目标来说,剧烈增强可能直接改变缺陷的外观特征。

一个典型的例子是mosaic增强。mosaic会把四张图拼接成一张,小目标在拼接后进一步缩小,很可能小于模型下采样后的最小感受野,等于这次训练就是在教模型"忽略小目标"。所以我在SWDD上训练时,会把mosaic概率降下来,而把翻转、轻微噪声和颜色扰动保留。具体数值因人而异,但你可以从mosaic默认的1.0降到0.5试一下,对比一下验证集上小尺寸目标的AP变化。

6.2 光照不均和过曝光样本:简单但有效的处理

钢表面图像最大的干扰不是背景复杂,而是光照。同一张图片上,反光区和阴影区的缺陷对比度可以差出好几倍。如果直接用原始图训练,模型很容易学到"高对比度区域才是缺陷",在低对比度区域漏检。

我试过的最有效做法是加一个轻量的对比度归一化预处理:对每张图做局部直方图均衡(CLAHE),同时保留原图作为一个额外的通道或分支。有些部署环境不支持复杂的预处理,那就至少保证训练时的预处理和推理时完全一致,不要训练用增强图、部署用原图,效果会崩得很惨。

6.3 关于"YOLO第几代了"和版本选择

很多人问现在YOLO到第几代了,比如YOLOv8、YOLOv9、YOLOv10、YOLO11,甚至社区里讨论的更新版本。我的态度是:对数据集格式转换这件事,版本完全不重要。本文里的YOLO格式就是Ultralytics通用的那种txt格式,从v5到最新的版本都兼容。训练时选哪个版本,取决于你对部署硬件的了解和实验baseline的对照需求,而不是"最新就是最好"。如果你用的是AMD显卡跑YOLO,注意Ultralytics对ROCm的支持需要安装对应版本的PyTorch,官方文档里有明确说明,安装对了之后训练流程和NVIDIA显卡没太大区别。

一条实际可落的命令大概是这样的:

yolo detect train \ data=swdd_yolo/data.yaml \ model=yolo11n.pt \ epochs=100 \ imgsz=640 \ batch=16

先跑一个轻量backbone的模型,比如nano或者small系列,快速验证数据流程没问题,再跑大模型调优。一上来就跑x模型,如果报错你根本分不清是数据问题还是训练参数问题。

6.4 关于类别不均衡的一个小技巧

SWDD的几个缺陷类别样本量之间通常有明显差距,高频类别和低频类别可能差出一个数量级。最简单的处理是在损失函数上给低频类别加权重,YOLO的训练配置里支持cls loss相关参数,这个可以调。更实用的技巧是:训练完成后单独按类别看AP,而不要只看一个mAP。如果某个低频类别AP接近零,而可视化检查发现标注没问题,那基本就是样本量不够,先收集数据比硬调参有效。

我在实际使用SWDD时养成的一个习惯是:每次转换完数据集,都保留一份转换脚本和class_names.txt放进数据集的根目录,连同训练config一起归档。这样几周后想复现实验结果,或者数据集更新了需要重新转换,不用再回忆当时是怎么处理的。做工业项目不同于打比赛,可追溯性和可复现性很多时候比那零点几个点的mAP更重要。

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

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

立即咨询