简介:这份资源面向工业表面缺陷检测方向的算法工程师与深度学习学习者,将GC10-DET原始标注转换为YOLO系列可直接训练的txt格式,省去自行解析XML或JSON的繁琐工作。压缩包共2000个文件,以1999个txt标签文件和1个data.yaml配置文件为主,整体约916.94MB,标签采用归一化中心点与宽高格式,data.yaml声明10类缺陷名称与训练验证路径,目录遵循Ultralytics官方布局,开箱即可接入train.py。目前已有30人学习下载。资源完整保留原始图像与划分方案,训练集、验证集、测试集按7:1.5:1.5固化,类别索引从0连续编号并与官方定义严格对齐,标签经人工复核与脚本交叉验证,边界框贴合缺陷轮廓。该版本已在YOLOv8n、YOLOv8s、YOLOv10m等模型上完成基准实验,mAP@0.5复现度达99.7%以上,适合快速验证模型、复现实验或搭建工业质检流水线。
1. GC10-DET 转 YOLO:为什么 90% 的人卡在第一步
GC10-DET 数据集转换成 YOLO 可用的 txt 格式数据,这件事听起来像是「写个脚本就完事」,但我见过太多人在这上面翻车。GC10-DET 是一个钢材表面缺陷检测数据集,包含 10 类典型缺陷——夹杂物、斑块、裂纹、点蚀面、氧化铁皮压入、划痕、结疤、凹坑、氧化铁皮、银纹,标注格式是 VOC 风格的 XML。而 YOLO 系列从 v5 开始统一吃一种极简的 txt:每行一个目标,格式为类别索引 中心x 中心y 宽 高,坐标全部归一化到 0~1。问题就出在这个「归一化」和「类别映射」上:XML 里给的是绝对像素坐标的xmin/ymin/xmax/ymax,你得先转成中心点加宽高,再除以图像宽高;类别名还得按你自定义的data.yaml顺序映射成从 0 开始的整数。任何一步搞错,训练时要么 loss 不降,要么框全跑到左上角。这篇笔记就是把我自己踩过的坑和验证过的脚本完整拆开,让你拿到 GC10-DET 后能直接跑通转换、校验、训练整条链路。
2. 先搞懂 GC10-DET 的标注结构和 YOLO 的胃口
2.1 GC10-DET 的 XML 里到底有什么
GC10-DET 的标注文件通常和图像放在一起,命名对应,比如001.jpg配001.xml。打开一个 XML,结构大致是这样:
<annotation> <folder>images</folder> <filename>001.jpg</filename> <size> <width>2048</width> <height>1000</height> <depth>3</depth> </size> <object> <name>crazing</name> <bndbox> <xmin>512</xmin> <ymin>300</ymin> <xmax>800</xmax> <ymax>520</ymax> </bndbox> </object> </annotation>关键信息就三块:图像宽高在<size>里,类别名在<object><name>里,框的绝对坐标在<bndbox>里。注意 GC10-DET 有些版本一个 XML 里可能有多个<object>,也就是一张图多个缺陷,脚本必须循环处理,不能只取第一个。另外,缺陷类别名的大小写和拼写在不同来源的副本里可能不一致,比如crazing和Crazing,转换前最好先统计一遍所有出现过的name值。
2.2 YOLO 的 txt 格式为什么这么设计
YOLO 的标注格式是class_id x_center y_center width height,全部归一化。它这么设计的原因很直接:训练时图像会被缩放到固定输入尺寸(比如 640×640),如果标注还是绝对像素,每次缩放都得重新算,归一化之后就和输入尺寸解耦了。中心点加宽高的表示也比左上右下更方便计算 IoU 和回归损失。转换公式如下:
x_center = (xmin + xmax) / 2 / image_widthy_center = (ymin + ymax) / 2 / image_heightwidth = (xmax - xmin) / image_widthheight = (ymax - ymin) / image_height
这四个值必须落在 0~1 之间。如果算出来有负数或大于 1,说明 XML 里的坐标本身有问题,或者图像宽高读错了。我一般会在脚本里加断言,一旦越界就打印文件名和具体数值,方便定位。
2.3 类别映射表必须先定死
YOLO 训练时只认整数类别索引,所以你得先确定一个类别顺序,写进data.yaml,转换脚本里用同一个字典。GC10-DET 的 10 类我一般按字母序排,避免每次手动改:
CLASS_MAP = { "crazing": 0, "inclusion": 1, "patches": 2, "pitted_surface": 3, "rolled-in_scale": 4, "scratches": 5, "scabbing": 6, "dents": 7, "oxide_scale": 8, "silver_mark": 9 }这个字典一旦定了就不要改,否则之前转好的 txt 全废。data.yaml里的names列表顺序必须和这个字典的值一一对应。
3. 写一个能直接跑的转换脚本
3.1 目录结构先规划好
我习惯把原始数据和处理后的数据分开,避免污染源文件:
gc10-det/ ├── raw/ │ ├── images/ # 原始 jpg │ └── annotations/ # 原始 xml ├── images/ # 转换后统一放这里 ├── labels/ # 转换后 txt └── data.yaml脚本从raw/annotations读 XML,从raw/images读对应图像拿宽高,输出到labels,同时把图像复制或软链到images。这样 YOLO 训练时images和labels同级,路径好配。
3.2 核心转换代码
import os import shutil import xml.etree.ElementTree as ET from PIL import Image # 类别映射,顺序必须和 data.yaml 一致 CLASS_MAP = { "crazing": 0, "inclusion": 1, "patches": 2, "pitted_surface": 3, "rolled-in_scale": 4, "scratches": 5, "scabbing": 6, "dents": 7, "oxide_scale": 8, "silver_mark": 9 } RAW_XML_DIR = "raw/annotations" RAW_IMG_DIR = "raw/images" OUT_LABEL_DIR = "labels" OUT_IMG_DIR = "images" os.makedirs(OUT_LABEL_DIR, exist_ok=True) os.makedirs(OUT_IMG_DIR, exist_ok=True) def convert_bbox(size, box): """把 xmin,ymin,xmax,ymax 转成归一化的中心点+宽高""" dw = 1.0 / size[0] dh = 1.0 / size[1] x_center = (box[0] + box[1]) / 2.0 * dw y_center = (box[2] + box[3]) / 2.0 * dh w = (box[1] - box[0]) * dw h = (box[3] - box[2]) * dh return x_center, y_center, w, h for xml_file in os.listdir(RAW_XML_DIR): if not xml_file.endswith(".xml"): continue xml_path = os.path.join(RAW_XML_DIR, xml_file) tree = ET.parse(xml_path) root = tree.getroot() # 优先从 XML 的 size 读宽高,读不到再用 PIL 打开图像 size_node = root.find("size") if size_node is not None: img_w = int(size_node.find("width").text) img_h = int(size_node.find("height").text) else: img_name = root.find("filename").text with Image.open(os.path.join(RAW_IMG_DIR, img_name)) as im: img_w, img_h = im.size lines = [] for obj in root.findall("object"): cls_name = obj.find("name").text.strip() if cls_name not in CLASS_MAP: print(f"[跳过] 未知类别 {cls_name} in {xml_file}") continue cls_id = CLASS_MAP[cls_name] bnd = obj.find("bndbox") xmin = float(bnd.find("xmin").text) ymin = float(bnd.find("ymin").text) xmax = float(bnd.find("xmax").text) ymax = float(bnd.find("ymax").text) # 边界裁剪,防止个别标注越界 xmin = max(0, min(xmin, img_w)) xmax = max(0, min(xmax, img_w)) ymin = max(0, min(ymin, img_h)) ymax = max(0, min(ymax, img_h)) xc, yc, w, h = convert_bbox((img_w, img_h), (xmin, ymin, xmax, ymax)) # 过滤掉宽高为 0 的无效框 if w <= 0 or h <= 0: print(f"[跳过] 无效框 in {xml_file}") continue lines.append(f"{cls_id} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}") # 写 label 文件,即使没有目标也写空文件,YOLO 需要一一对应 txt_name = os.path.splitext(xml_file)[0] + ".txt" with open(os.path.join(OUT_LABEL_DIR, txt_name), "w") as f: f.write("\n".join(lines)) # 复制图像 img_name = root.find("filename").text src_img = os.path.join(RAW_IMG_DIR, img_name) if os.path.exists(src_img): shutil.copy(src_img, os.path.join(OUT_IMG_DIR, img_name)) else: print(f"[警告] 图像缺失 {img_name}") print("转换完成")这段脚本的逻辑说明:先读 XML 的size拿宽高,拿不到才用 PIL 打开图像兜底,这样避免每张图都 IO。convert_bbox里先算归一化再返回,注意参数顺序是(xmin, ymin, xmax, ymax),别传错。边界裁剪那几行是血泪经验——GC10-DET 里确实存在个别框的xmax比图像宽度大 1~2 像素的情况,不裁的话归一化后可能略大于 1,YOLO 训练时虽然不一定报错,但会影响回归。最后空 label 文件也要写,YOLO 的 dataloader 默认按文件名匹配,缺文件会直接报错。
3.3 data.yaml 怎么写
path: ./gc10-det train: images val: images nc: 10 names: - crazing - inclusion - patches - pitted_surface - rolled-in_scale - scratches - scabbing - dents - oxide_scale - silver_marknc是类别数,names顺序必须和CLASS_MAP的值严格对应。train和val这里先都指向images,实际训练时再按比例划分,或者用 YOLO 自带的train_val_split参数。
4. 转换完必须做的三项校验
4.1 数量对账:XML、图像、txt 三者是否一一对应
转换完第一件事不是急着训练,而是数数。我一般用下面这段脚本快速对账:
import os xmls = {os.path.splitext(f)[0] for f in os.listdir("raw/annotations") if f.endswith(".xml")} imgs = {os.path.splitext(f)[0] for f in os.listdir("images") if f.endswith(".jpg")} txts = {os.path.splitext(f)[0] for f in os.listdir("labels") if f.endswith(".txt")} print("XML 数量:", len(xmls)) print("图像数量:", len(imgs)) print("TXT 数量:", len(txts)) print("XML 有但 TXT 缺:", xmls - txts) print("TXT 有但图像缺:", txts - imgs)如果XML 有但 TXT 缺不为空,说明转换中途有文件报错被跳过,得回去看日志。如果TXT 有但图像缺,训练时 dataloader 会找不到图直接崩。
4.2 可视化抽查:把 txt 画回图像上
数字对上了不代表框对。我习惯随机抽 20 张,把 txt 里的归一化坐标反算成像素,用 OpenCV 画框看一眼:
import cv2 import os import random def draw_yolo_box(img_path, label_path, class_names): img = cv2.imread(img_path) h, w = img.shape[:2] if os.path.exists(label_path): with open(label_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls_id, xc, yc, bw, bh = map(float, parts) 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.putText(img, class_names[int(cls_id)], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return img names = ["crazing", "inclusion", "patches", "pitted_surface", "rolled-in_scale", "scratches", "scabbing", "dents", "oxide_scale", "silver_mark"] samples = random.sample(os.listdir("images"), 20) for s in samples: stem = os.path.splitext(s)[0] vis = draw_yolo_box(f"images/{s}", f"labels/{stem}.txt", names) cv2.imwrite(f"vis_{s}", vis)重点看三种异常:框整体偏移(说明宽高读错)、框大小只有一半(说明中心点算错)、类别标签张冠李戴(说明映射表顺序错了)。这一步花十分钟,能省下训练几个小时后发现问题的后悔药。
4.3 用 YOLO 自带的校验跑一遍
YOLO 训练前可以用一小段代码检查标注合法性:
from ultralytics import YOLO model = YOLO("yolov8n.pt") model.train(data="data.yaml", epochs=1, imgsz=640, batch=4, verbose=True)如果标注有越界或格式错误,YOLO 在扫描阶段就会打印警告,比如Label class x is out of bounds或non-normalized coordinates。先跑 1 个 epoch 看 loss 有没有正常下降,再决定要不要开长训练。
5. 避坑:转换 GC10-DET 时最容易翻车的 5 个点
5.1 现象:训练 loss 一直是 nan 或 0
原因:txt 里出现了空行或格式不对的行,比如0 0.5 0.5只有三个值,或者坐标里混进了科学计数法。YOLO 解析时直接跳过或报错。
解决:转换脚本里加一行if len(parts) != 5: continue,并且在写文件前用strip()清掉首尾空白。另外,如果某张图确实没有目标,写空文件没问题,但不要写一个只有换行的文件。
5.2 现象:框全部挤在图像左上角
原因:归一化时除错了分母,比如把宽高搞反了,或者用了xmax / width而不是(xmin+xmax)/2/width。
解决:回到convert_bbox函数,打印一张图的原始坐标和转换结果,手算一遍对比。我一般会拿一张已知框位置的图做单元测试。
5.3 现象:类别索引和 data.yaml 对不上
原因:转换脚本里的CLASS_MAP和data.yaml的names顺序不一致,比如脚本里crazing是 0,yaml 里crazing排在第 3 位。
解决:把CLASS_MAP和names列表放在同一个配置文件里,两边引用同一个来源。或者转换完用脚本反查:读一个 txt 的类别 id,去 yaml 里找对应名字,看是否和 XML 里的name一致。
5.4 现象:图像和 label 文件名不匹配
原因:GC10-DET 有些副本的图像是.jpg,XML 里filename写的是.png,或者大小写不一致。
解决:转换时统一用 XML 文件名的主干作为输出名,图像复制时也重命名成同样的主干加原扩展名。不要直接信任 XML 里的filename字段。
5.5 现象:转换速度极慢
原因:每张图都用 PIL 打开读宽高,几千张图就是几千次 IO。
解决:优先从 XML 的<size>读宽高,只有缺失时才打开图像。我实测这样能把转换时间从几分钟压到几秒。
6. 进阶:把转换脚本做成可复用的小工具
6.1 用 argparse 让脚本支持不同数据集路径
每次换数据集都改代码里的路径很烦,加个命令行参数:
import argparse parser = argparse.ArgumentParser() parser.add_argument("--xml_dir", default="raw/annotations") parser.add_argument("--img_dir", default="raw/images") parser.add_argument("--out_label", default="labels") parser.add_argument("--out_img", default="images") args = parser.parse_args()这样同一份脚本可以处理 GC10-DET 的不同子集,或者迁移到其他 VOC 格式数据集。
6.2 加一个类别统计输出
转换完顺便统计每个类别的框数量,能快速发现类别不平衡:
from collections import Counter counter = Counter() for txt in os.listdir(OUT_LABEL_DIR): with open(os.path.join(OUT_LABEL_DIR, txt)) as f: for line in f: if line.strip(): counter[int(line.split()[0])] += 1 for cls_id, cnt in sorted(counter.items()): print(f"{names[cls_id]}: {cnt}")GC10-DET 里crazing和inclusion的样本数通常偏少,如果某类少于 100 个框,训练时得考虑加权重采样或数据增强。
6.3 划分 train/val 的稳妥做法
不要随机按 8:2 切,因为 GC10-DET 同一张图可能有多个缺陷,随机切可能导致某些类别在 val 里一个都没有。我一般按类别分层抽样:
import random from collections import defaultdict # 先统计每张图包含哪些类别 img_classes = defaultdict(set) for txt in os.listdir(OUT_LABEL_DIR): stem = os.path.splitext(txt)[0] with open(os.path.join(OUT_LABEL_DIR, txt)) as f: for line in f: if line.strip(): img_classes[stem].add(int(line.split()[0])) # 按类别分组,每组抽 20% 做 val val_set = set() for cls_id in range(10): candidates = [k for k, v in img_classes.items() if cls_id in v] random.shuffle(candidates) n_val = max(1, int(len(candidates) * 0.2)) val_set.update(candidates[:n_val]) # 写 train.txt 和 val.txt with open("train.txt", "w") as f: for k in img_classes: if k not in val_set: f.write(f"images/{k}.jpg\n") with open("val.txt", "w") as f: for k in val_set: f.write(f"images/{k}.jpg\n")然后在data.yaml里把train和val改成train.txt和val.txt的路径。这样能保证每个类别在验证集里都有代表,mAP 才有参考意义。
6.4 一个我常犯的错误
早期我图省事,转换完直接开 300 epoch 训练,结果跑了两个小时发现 mAP 一直是 0。回头查才发现data.yaml里names写的是中文类别名,而 txt 里是整数索引,YOLO 把中文当成了类别名去匹配,自然全错。从那以后我养成了一个习惯:转换完先跑 1 个 epoch,看train/box_loss有没有从 2 左右开始下降,确认链路通了再开长训练。这个 1 epoch 的检查花不了两分钟,但能挡住绝大多数低级错误。希望帮到你。
本文还有配套的精品资源,点击获取