☰
钢材缺陷检测实战:YOLO小目标优化与数据划分技巧
2026/10/13 9:27:34 网站建设 项目流程

简介:本资源为YOLO谢韦尔钢材缺陷检测数据集,面向从事工业质检、目标检测算法学习与竞赛实践的学生及开发者,帮助解决钢材表面缺陷样本获取难、标注格式不统一的问题。压缩包共2000个文件,约61.48MB,以1986个xml标注文件为主,另含少量html教程、txt说明与py脚本,覆盖voc、coco、yolo三种标签格式,可直接接入YOLO系列模型训练。资源附赠环境搭建、训练案例教程及数据集划分脚本,支持按需生成训练集、验证集与测试集,并输出ImageSets下的划分文件。目前已有398人学习下载,读者可借此快速完成数据准备、格式转换与训练流程搭建,适合作为钢材缺陷检测项目的入门与实战素材。

1. 钢材缺陷检测为什么总在“小目标”上翻车

拿到一份 5000 张规模的钢材缺陷数据集,还配好了 VOC、COCO、YOLO 三种标签格式和划分脚本,很多人第一反应是“直接开训”。但真正跑过产线质检的人都知道,钢材表面缺陷检测的难点从来不是数据量,而是缺陷本身的形态:裂纹可能只有几个像素宽、夹杂呈不规则点状、划痕方向随机且对比度极低。YOLO 系列在通用目标检测上表现稳定,可一旦面对这类细长、低对比、密集小目标,默认配置下的召回率会掉得很难看。

这份数据集的价值在于它把“数据准备”这一最耗时的环节标准化了:5000 张图片覆盖多类典型钢材表面缺陷,同时提供 VOC、COCO、YOLO 三种标注格式,意味着你不需要自己写格式转换脚本,也不用担心标注坐标系不一致导致的玄学偏移。划分脚本则解决了训练集/验证集/测试集切分时类别分布不均的问题——这个问题在缺陷检测里特别致命,某一类缺陷如果验证集里只有两三张,mAP 曲线会剧烈抖动,让你误以为模型没学好。

这篇文章面向的是已经了解 YOLO 基本训练流程、但想在钢材缺陷场景下把效果真正做出来的从业者。我会从数据格式的对应关系讲起,落到划分脚本怎么改、训练参数怎么调、增强策略怎么选,最后给出排查清单。新手可以按步骤复现,熟手可以直接跳到参数边界和踩坑部分。

2. 三种标签格式的对应关系与转换逻辑

2.1 VOC、COCO、YOLO 的坐标体系差异

同样一个缺陷框,在三种格式里的表达方式完全不同,理解这一点是后续所有操作的基础。

VOC 格式用 XML 存储,坐标是绝对像素值,xmin、ymin、xmax、ymax分别对应框的左上角和右下角。它的优点是直观,打开文件就能看懂框在哪;缺点是不同图片尺寸下数值范围差异大,不适合直接喂给网络。

COCO 格式用 JSON 存储,坐标是[x, y, width, height],其中 x、y 是左上角绝对坐标,width、height 是框的宽高。COCO 还额外维护images、annotations、categories三个顶层字段,通过image_id和category_id做关联。它的优势是能同时承载检测、分割、关键点任务,生态工具多;缺点是单文件体积大,5000 张图的标注 JSON 可能达到几十 MB,解析时内存占用明显。

YOLO 格式用 txt 存储,每行一个目标,格式是class_id x_center y_center width height,后四个值全部是相对于图片宽高的归一化值,范围 0 到 1。它的优点是轻量、读取快、和 YOLO 训练框架天然契合;缺点是脱离图片尺寸就无法还原真实框位置,调试时需要反归一化。

格式存储方式坐标类型类别字段典型用途
VOCXML绝对像素name 字符串传统检测框架、数据交换
COCOJSON绝对像素category_id 整数多任务、评测基准
YOLOTXT归一化class_id 整数YOLO 系列训练

2.2 用脚本验证三种格式是否对齐

拿到数据集后,不要急着训练,先写一个校验脚本,随机抽若干张图,把三种格式的框画出来对比。这一步能提前发现标注错位、类别映射错误、图片与标签不匹配等问题。

import os import json import xml.etree.ElementTree as ET import cv2 # 配置路径 img_dir = "images" voc_dir = "voc_labels" yolo_dir = "yolo_labels" coco_json = "annotations/coco.json" # 读取 COCO 标注,建立 image_id 到文件名的映射 with open(coco_json, "r") as f: coco = json.load(f) img_id_to_name = {img["id"]: img["file_name"] for img in coco["images"]} name_to_anns = {} for ann in coco["annotations"]: fname = img_id_to_name[ann["image_id"]] name_to_anns.setdefault(fname, []).append(ann) def draw_voc(img, xml_path): tree = ET.parse(xml_path) root = tree.getroot() for obj in root.findall("object"): name = obj.find("name").text bbox = obj.find("bndbox") xmin = int(bbox.find("xmin").text) ymin = int(bbox.find("ymin").text) xmax = int(bbox.find("xmax").text) ymax = int(bbox.find("ymax").text) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, name, (xmin, ymin - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) return img def draw_yolo(img, txt_path, img_w, img_h): with open(txt_path, "r") as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls_id, xc, yc, w, h = map(float, parts) xc *= img_w yc *= img_h w *= img_w h *= img_h x1 = int(xc - w / 2) y1 = int(yc - h / 2) x2 = int(xc + w / 2) y2 = int(yc + h / 2) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2) return img # 随机抽 5 张做对比 import random files = [f for f in os.listdir(img_dir) if f.endswith((".jpg", ".png"))] for fname in random.sample(files, 5): img_path = os.path.join(img_dir, fname) img = cv2.imread(img_path) h, w = img.shape[:2] stem = os.path.splitext(fname)[0] voc_img = draw_voc(img.copy(), os.path.join(voc_dir, stem + ".xml")) yolo_img = draw_yolo(img.copy(), os.path.join(yolo_dir, stem + ".txt"), w, h) # 并排显示,人工核对框位置是否一致 combined = cv2.hconcat([voc_img, yolo_img]) cv2.imshow("VOC(left) vs YOLO(right)", combined) cv2.waitKey(0) cv2.destroyAllWindows()

这段脚本的逻辑是:VOC 框画绿色,YOLO 框画红色,并排显示。如果同一个缺陷在左右两边的框位置基本重合,说明格式转换没问题;如果出现明显偏移,通常是归一化时用错了图片尺寸,或者 VOC 的xmax/ymax被误当成宽高。参数上需要注意cv2.rectangle的坐标必须是整数,YOLO 反归一化后要取整,否则会报类型错误。

2.3 类别映射不一致是沉默的杀手

三种格式里,VOC 用类别名字符串,COCO 和 YOLO 用整数 ID。如果转换时没有维护一张固定的映射表,很容易出现“VOC 里裂纹是第 0 类,YOLO 里裂纹变成第 2 类”的情况。这种错误不会报异常,但会让模型学到一个完全混乱的类别边界。

常见做法是单独建一个classes.txt,每行一个类别名,行号就是 class_id。所有格式转换脚本都读这个文件,保证映射唯一。我一般会在训练前打印一次类别分布:

from collections import Counter import glob class_names = open("classes.txt").read().strip().split("\n") counter = Counter() for txt in glob.glob("yolo_labels/*.txt"): with open(txt) as f: for line in f: cid = int(line.split()[0]) counter[class_names[cid]] += 1 print(counter)

如果某一类数量少于总标注的 1%,就要考虑是否合并类别或单独做重采样,否则训练时该类几乎不会被正样本激活。

3. 划分脚本怎么改才不让验证集“偏科”

3.1 随机划分在缺陷检测里的致命缺陷

很多划分脚本就是random.shuffle之后按 8:1:1 切一刀。在通用目标检测里这没问题,但在钢材缺陷场景下,缺陷本身分布就不均匀:某些图片可能包含多个缺陷,某些类别天然稀少。纯随机划分会导致验证集里某类缺陷只有个位数实例,mAP 波动极大,你根本分不清是模型不行还是验证集太小。

更合理的做法是分层抽样:先统计每张图片包含的类别组合,按组合做分层,保证训练集、验证集、测试集里各类别的实例比例接近全局比例。如果做不到严格分层,至少保证每个类别在验证集里至少有 20 到 30 个实例。

3.2 一个可复用的分层划分脚本

import os import random from collections import defaultdict import glob random.seed(42) # 固定种子,保证可复现 label_dir = "yolo_labels" img_dir = "images" val_ratio = 0.1 test_ratio = 0.1 # 统计每张图包含的类别集合 img_to_classes = {} for txt in glob.glob(os.path.join(label_dir, "*.txt")): stem = os.path.splitext(os.path.basename(txt))[0] classes = set() with open(txt) as f: for line in f: classes.add(int(line.split()[0])) img_to_classes[stem] = frozenset(classes) # 按类别组合分组 group = defaultdict(list) for stem, classes in img_to_classes.items(): group[classes].append(stem) train, val, test = [], [], [] for classes, stems in group.items(): random.shuffle(stems) n = len(stems) n_val = max(1, int(n * val_ratio)) n_test = max(1, int(n * test_ratio)) val.extend(stems[:n_val]) test.extend(stems[n_val:n_val + n_test]) train.extend(stems[n_val + n_test:]) # 写出划分文件 for name, split in [("train", train), ("val", val), ("test", test)]: with open(f"{name}.txt", "w") as f: for stem in split: f.write(os.path.join(img_dir, stem + ".jpg") + "\n") print(f"train: {len(train)}, val: {len(val)}, test: {len(test)}")

关键参数是val_ratio和test_ratio,钢材缺陷数据量不大时,验证集和测试集各占 10% 比较合适,训练集保留 80%。random.seed(42)必须固定,否则每次运行划分结果不同,实验无法对比。分组时用frozenset而不是tuple,是因为类别顺序不重要,只要组合相同就归为一组。

3.3 划分后必须做一次分布校验

划分脚本跑完不代表万事大吉,还要验证三个集合的类别分布是否接近。下面这段代码输出每个类别的实例数和占比:

def count_instances(split_file, label_dir): counter = defaultdict(int) with open(split_file) as f: for line in f: stem = os.path.splitext(os.path.basename(line.strip()))[0] txt = os.path.join(label_dir, stem + ".txt") if not os.path.exists(txt): continue with open(txt) as lf: for l in lf: counter[int(l.split()[0])] += 1 return counter for split in ["train", "val", "test"]: c = count_instances(f"{split}.txt", label_dir) total = sum(c.values()) print(split, {k: f"{v}({v/total:.1%})" for k, v in sorted(c.items())})

如果验证集里某一类占比和训练集差超过 3 个百分点,建议调整种子或手动干预,把该类的图片多分几张到验证集。

4. YOLO 训练参数怎么设才不浪费这 5000 张图

4.1 从数据规模反推 epoch 和 batch

5000 张图在目标检测里属于中等偏小规模。以 YOLOv8 为例,官方在 COCO 上推荐 300 epoch,但 COCO 有 11.8 万张图。5000 张图如果也跑 300 epoch,大概率过拟合,验证集 loss 会在 80 到 120 epoch 之间开始回升。

我一般会按“总迭代次数”来估算:小数据集建议总迭代在 2 万到 5 万次之间。假设 batch=16,5000 张图一个 epoch 约 312 次迭代,那么 100 epoch 就是 3.1 万次迭代,落在合理区间。所以起始配置可以设epochs=100, batch=16,然后看验证集曲线决定是否延长。

如果显存不够,batch 降到 8,同时把lr0从 0.01 降到 0.005,因为小 batch 下梯度噪声更大,学习率太大会震荡。

4.2 针对钢材缺陷的增强策略

默认增强里,mosaic和mixup对小目标检测有帮助,但钢材缺陷的对比度低,mixup可能把缺陷和背景混得难以区分。我的经验是:mosaic=1.0保留,mixup=0.0先关掉,等模型稳定后再尝试 0.1 到 0.2。

hsv_h、hsv_s、hsv_v控制色调、饱和度、亮度扰动。钢材表面以灰度为主,色调扰动意义不大,可以设hsv_h=0.0;亮度和饱和度扰动保留,hsv_v=0.4、hsv_s=0.5,模拟不同光照和氧化程度。

degrees旋转增强要谨慎。裂纹和划痕有方向性,过度旋转会破坏方向先验,建议degrees=10以内。flipud和fliplr可以开到 0.5,因为钢材缺陷上下左右翻转后仍然合理。

yolo detect train \ data=steel.yaml \ model=yolov8s.pt \ epochs=100 \ batch=16 \ imgsz=640 \ lr0=0.01 \ lrf=0.01 \ mosaic=1.0 \ mixup=0.0 \ hsv_h=0.0 \ hsv_s=0.5 \ hsv_v=0.4 \ degrees=10 \ flipud=0.5 \ fliplr=0.5 \ patience=30 \ seed=42

patience=30表示验证集指标 30 epoch 不提升就早停,避免无效计算。seed=42保证增强和划分可复现。imgsz=640是 YOLOv8 的默认输入尺寸,如果缺陷特别小,可以提到 1024,但显存占用会翻倍,需要同步降 batch。

4.3 学习率调度和 warmup

YOLOv8 默认用线性 warmup 加余弦退火。warmup_epochs=3表示前 3 个 epoch 学习率从 0 线性升到lr0,这对小数据集很重要,能避免一开始就大步长破坏预训练权重。lrf=0.01表示最终学习率是初始学习率的 1%,配合余弦退火,后期微调更稳。

如果训练 loss 在前 10 epoch 就剧烈震荡,先检查标注是否有越界框(归一化值超出 0 到 1),再检查lr0是否过大。钢材缺陷数据里,标注框越界是常见问题,通常是因为转换时没有做 clip。

5. 训练前后最容易踩的五个坑

5.1 图片和标签文件名不匹配

现象:训练启动后报No labels found,或者 loss 一直是 0。

原因:YOLO 按文件名 stem 查找标签,img001.jpg对应img001.txt。如果标签文件多了后缀或大小写不一致,就找不到。

解决:写一个批量重命名脚本,统一 stem 和扩展名。训练前用os.path.exists逐张检查,缺失的打印出来。

5.2 归一化坐标越界

现象:训练不报错,但 mAP 极低,画框发现框跑到图片外面。

原因:VOC 转 YOLO 时,xmax可能等于图片宽度,归一化后x_center + width/2略大于 1,或者标注本身有负值。

解决:转换时做 clip,x_center = min(max(x_center, 0), 1),宽高同理。转换后跑一遍校验,统计越界框数量。

5.3 验证集类别缺失导致 mAP 为 0

现象:某一类 mAP 始终是 0,但训练 loss 正常下降。

原因:验证集里没有该类实例,或者该类实例少于 5 个,计算 mAP 时被忽略。

解决:回到划分脚本,检查该类在验证集的实例数,必要时手动把训练集里含该类的图片挪几张到验证集。

5.4 显存溢出但 batch 已经很小

现象:CUDA out of memory,但 batch 已经降到 4。

原因:imgsz太大,或者mosaic增强会拼接 4 张图,等效 batch 翻 4 倍。

解决:先把imgsz降到 640,再关掉mosaic试一次。如果还溢出,检查是否有其他进程占用显存。

5.5 训练集 loss 下降但验证集不降

现象:训练 loss 持续下降,验证集 mAP 在 20 epoch 后停滞甚至下降。

原因:过拟合,或者训练集和验证集分布差异大。

解决:先加增强(hsv、flip、degrees),再加weight_decay,最后考虑减少模型参数量(从 yolov8m 换到 yolov8s)。如果验证集分布确实偏,重新做分层划分。

6. 用混淆矩阵和 PR 曲线定位最后一公里问题

训练完成后,别只看 mAP 一个数。YOLO 会在runs/detect/val下生成混淆矩阵和 PR 曲线,这两张图能告诉你模型到底错在哪。

混淆矩阵的横轴是预测类别,纵轴是真实类别。如果某一类大量被预测成背景,说明召回不足,可能是该类样本太少或增强不够;如果两类之间互相混淆,说明它们的视觉特征太接近,需要考虑合并类别或增加区分性特征。

PR 曲线看每个类别的曲线下面积。曲线在低召回区就掉下来的类别,通常是难例集中,需要针对性补充数据或调整 anchor。

我一般会做一次“错误可视化”:把验证集里置信度低于 0.3 但真实存在的缺陷框出来,人工看它们有什么共同点。常见发现是:低对比度缺陷在imgsz=640下被下采样得太厉害,细节丢失。这时候把imgsz提到 1024,或者用切片推理(把大图切成小块分别检测再合并),往往能救回一批。

切片推理的简化逻辑是:对每张图按 640×640 滑窗切块,每块单独推理,再把框映射回原图坐标做 NMS。代价是推理时间线性增加,但小目标召回能提升 10 到 20 个百分点。是否值得,取决于你的产线节拍要求。

最后说一个我自己的习惯:每次改完参数,先跑 10 epoch 看趋势,不要一上来就 100 epoch。10 epoch 里验证集 mAP 如果完全不涨,大概率是数据或配置有问题,继续跑只是浪费电。这个习惯帮我省下了大量无效实验时间。希望帮到你。

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

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

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

立即咨询