☰
钢材表面缺陷检测实战:NEU-DET转YOLOv8格式训练全流程
2026/10/11 23:50:01 网站建设 项目流程

简介:NEU-DET钢材表面缺陷数据集采用YOLOv8格式,面向工业质检、计算机视觉等方向的开发者和研究者,可用于训练钢材表面缺陷目标检测模型,以解决人工检测效率低、易漏检等问题;适合钢铁生产质检、课程设计、竞赛备赛等场景。资源共包含2000个文件,以1800个TXT标注文件为主,配合195张JPG原图、3个JSON配置及2个CACHE缓存,压缩包仅23.51MB,轻量实用。数据覆盖crazing、inclusion、patches、pitted_surface、rolled-in_scale、scratches六类典型缺陷,并划分训练集1260张、验证集361张、测试集180张,图片与标签一一对齐,可直接用于YOLO系列模型训练与评测。已有1455人学习下载,包内数据划分清晰,配套缓存可加快环境验证;对需要节省标注时间、快速评估算法效果或完成相关课程设计的用户,能有效降低钢铁缺陷检测的入门门槛。钢材表面缺陷会造成产品性能下降甚至引发安全风险,该数据集正好为相关检测算法提供标准化训练样本,助力质量管控与研究落地。

1. NEU-DET不是玩具数据集:用yolov8格式跑通钢材表面缺陷检测的最小闭环

NEU-DET是钢材表面缺陷检测里最常用到的公开基准之一,六大类缺陷、每张图都是热轧钢带表面的实拍图。标题里的划分很明确:训练集1260张、验证集361张、测试集180张,并且已经给成了yolov8格式。很多第一次接触这个任务的人以为拿到yolov8格式就能直接开始训练,实际上一跑就翻车:要么类别编号对不上,要么标签坐标越界,要么训练完mAP只有0.3。这篇文章会按“数据组织→格式转换→训练调参→排障→进阶”的路径,把一套能复现的流程讲清楚。适合正在做工业质检、想把钢材表面缺陷检测落地的开发者,也适合刚入手yolov8做目标检测的从业者。

2. 认识NEU-DET的六大类缺陷与数据划分:训练1260、验证361、测试180意味着什么

2.1 六大类缺陷的形态与检测难点

NEU-DET的六大类分别是:裂纹(Crazing)、夹杂(Inclusion)、斑块(Patches)、麻点(Pitted Surface)、氧化铁皮(Rolled-in Scale)、划痕(Scratches)。这六类的英文缩写和中文翻译在论文里经常混用,你最好在项目一开始就固定一张类别映射表,否则转yolov8格式时很容易标错编号。

从图像上看,裂纹是短小的网状线,夹杂是深色块状物,斑块是大面积亮暗不均的区域,麻点是细小凹坑,氧化铁皮是不规则片状附着物,划痕是长条亮纹。检测难点主要在“类间相似性”:斑块和氧化铁皮在局部纹理上非常接近,麻点和夹杂在低分辨率下也容易混淆。更麻烦的是图片背景本身,钢材表面的纹理、光照不均、油污都会成为干扰。比如我训练第一个模型时,val mAP只有0.6左右,打开预测图一看,模型把很多斑块都识别成了氧化铁皮。

所以不要以为数据只有1801张图、任务只是六类,就轻视它。工业场景里的类别重叠和标注噪声在这个数据集里同样存在。不同类别样本数量在原始NEU-DET是均衡的,但如果你重新划分训练验证集,一定要确认每个类别在三个集合里都出现,否则某个类在验证集里一个样本都没有,mAP会直接掉到0.5甚至更低。

2.2 官方划分与yolov8格式的目录约定

标题里给出的划分是训练1260、验证361、测试180。很多人拿到数据后习惯性把test并进val,觉得“反正都是验证”,这是错误的。测试集应该保留到最后,只在模型训练完、调完参之后跑一次,用来估计真实泛化性能。val集是训练过程中用来早停和选模型的,如果val集参与了你的所有调参,那么val上的指标就存在过拟合风险。

按yolov8的标准格式,数据目录一般长这样:

dataset/ ├── images/ │ ├── train/ # 1260张 │ ├── val/ # 361张 │ └── test/ # 180张 └── labels/ ├── train/ # 1260个txt ├── val/ # 361个txt └── test/ # 180个txt

这里每个labels下的txt文件名必须和images下的图片名一一对应,比如crazing_1.jpg对应crazing_1.txt。yolov8训练时只要求train和val,test是可选,但你要做最终评估就把它写进data.yaml。

注意:图片和标签的数量要严格一致。常见做法是先在images目录放好图片,再写个脚本,遍历所有图片名,找到对应的标签文件,缺失的单独列出来。不要手动复制粘贴,1801个文件靠肉眼检查一定会漏。

另外,很多人会把dataset/直接放在项目根目录下,然后data.yaml里写相对路径../dataset/data.yaml,这样在不同机器上跑容易出问题。我一般会写绝对路径,或者用一个环境变量指向数据集根目录,在yaml里引用。虽然麻烦一点,但能避免换机器后路径错乱导致的低级报错。

2.3 类别分布与样本量对训练的影响

训练1260张,平均每类也就210张。这个量级对于yolov8这种数据饥渴模型来说偏少,所以我们默认要开启数据增强。yolov8自带的增强包括随机翻转、缩放、马赛克(mosaic)等,默认开启,你不需要额外加增强库。但正因样本量少,训练轮次不宜过长,否则val很快过一个峰值然后开始掉点。我在训练时通常设置epochs=200,配合patience=30早停,意思是最多等30轮,如果val指标没有变好就停止。这样既能避免过拟合,也不用天天盯着loss曲线。

关于验证集361张,比例大约20%,对超参数调整来说足够了。测试集180张只做最终评估,不在训练过程中暴露。注意,yolov8在训练完成后会自动在val上评估,它不会自动评估test,你需要单独用yolo detect val指定test路径,或者把data.yaml里的test字段写上。

这里还有一个容易踩的坑:如果你用的是别人已经划分好的yolov8格式数据集,最好自己写一个统计脚本,数一数每个集合里每类样本的数目。因为有些划分是随机打乱后按文件前缀分的,可能导致某个类在test集里只有个位数。比如我见过某个整理好的数据集,test集里“裂纹”类只有4张,这样测出来的mAP波动会非常大,同一模型跑两次能差出0.1。所以拿到数据后先统计、后训练,这是血泪经验。

3. 把原始标注转成yolov8格式:从XML/坐标到txt标签的落地脚本

3.1 原始NEU-DET标注格式与yolov8标签的对应关系

NEU-DET最常见的原始标注是VOC格式的XML文件。每个XML对应一张图片,里面有若干个<object>节点,每个节点包含<name>和<bndbox>。<bndbox>给出xmin, ymin, xmax, ymax,单位是像素。yolov8需要的标签格式是纯文本,每行形如:

class_id x_center y_center width height

其中x_center, y_center, width, height都是相对图片宽高的归一化值,范围在0到1之间。转换公式很简单:x_center = (xmin + xmax) / 2 / width,y_center = (ymin + ymax) / 2 / height,box_width = (xmax - xmin) / width,box_height = (ymax - ymin) / height。

这里有一个最容易出错的地方:XML里的类名是字符串,比如crazing、inclusion,而yolov8要求类别编号0到5。你必须维护一个固定的顺序映射。常见做法是写一个字典,比如{'crazing':0, 'inclusion':1, 'patches':2, 'pitted_surface':3, 'rolled-in_scale':4, 'scratches':5}。注意,pitted_surface和rolled-in_scale在原始数据里有时写成pitted或rolled,你最好先扫描所有XML,把出现过的类名打印出来。

3.2 转换脚本:解析、归一化、按划分写入txt

下面是一个可以直接用的转换脚本,假设原始数据目录里有Annotations/和JPEGImages/,还有一个包含图片名及其split的CSV或列表。为简化,我直接按一个split.txt来读取划分关系。

import os import xml.etree.ElementTree as ET from glob import glob # 类别映射,顺序就是yolov8训练时的类别顺序 CLASS_MAPPING = { 'crazing': 0, 'inclusion': 1, 'patches': 2, 'pitted_surface': 3, 'rolled-in_scale': 4, 'scratches': 5, } IMG_DIR = 'NEU-DET/JPEGImages' XML_DIR = 'NEU-DET/Annotations' OUTPUT_DIR = 'dataset' SPLITS = ['train', 'val', 'test'] # 实际用到的划分 # 假设有个split.txt,每行是 "image_name split" split_map = {} with open('split.txt', 'r', encoding='utf-8') as f: for line in f: name, split = line.strip().split() split_map[name] = split for image_path in glob(os.path.join(IMG_DIR, '*.jpg')): name = os.path.splitext(os.path.basename(image_path))[0] if name not in split_map: continue split = split_map[name] xml_path = os.path.join(XML_DIR, name + '.xml') if not os.path.exists(xml_path): print(f'[WARN] missing xml for {name}') continue tree = ET.parse(xml_path) root = tree.getroot() # 图片宽高,需要从xml的size节点读 img_w = int(root.find('size/width').text) img_h = int(root.find('size/height').text) out_label_dir = os.path.join(OUTPUT_DIR, 'labels', split) os.makedirs(out_label_dir, exist_ok=True) out_txt = os.path.join(out_label_dir, name + '.txt') lines = [] for obj in root.findall('object'): class_name = obj.find('name').text.strip() if class_name not in CLASS_MAPPING: print(f'[WARN] unknown class {class_name} in {name}') continue cls_id = CLASS_MAPPING[class_name] xmin = float(obj.find('bndbox/xmin').text) ymin = float(obj.find('bndbox/ymin').text) xmax = float(obj.find('bndbox/xmax').text) ymax = float(obj.find('bndbox/ymax').text) # 归一化 x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h # 防止坐标越界,clip一次很有必要 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) lines.append(f'{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}') with open(out_txt, 'w', encoding='utf-8') as f: f.write('\n'.join(lines) + '\n') print('done')

这个脚本的核心逻辑分三步:读XML里的object、归一化坐标、按split写入对应labels目录。参数说明:img_w和img_h必须从XML的size节点读,不要用图像文件实际尺寸,虽然两者通常一致,但少数数据集可能不一致;CLASS_MAPPING的键必须是XML里真实出现的类名字符串,建议先运行一个扫描脚本把唯一类名打出来,再填进这个字典。SPLITS变量里写了三个划分,如果你只需要train和val,就不用建test文件夹。

写完脚本后,别忘了在dataset/labels/train/下抽查几个文件。打开一个txt,里面每行第一个数字应该在0到5之间,后面的坐标值应该在0到1之间。如果出现负值或大于1的值,说明原始XML里存在越界框或者读错了尺寸,需要先修数据再训练。

图片也要同步拷贝到dataset/images/对应的split目录。可以用一段简单命令,按同样的split_map做复制:

import shutil import os split_map = {} with open('split.txt', 'r', encoding='utf-8') as f: for line in f: name, split = line.strip().split() split_map[name] = split for image_path in glob(os.path.join(IMG_DIR, '*.jpg')): name = os.path.splitext(os.path.basename(image_path))[0] if name not in split_map: continue split = split_map[name] out_dir = os.path.join(OUTPUT_DIR, 'images', split) os.makedirs(out_dir, exist_ok=True) shutil.copy(image_path, os.path.join(out_dir, os.path.basename(image_path)))

这里没有做去重,如果split.txt里同一个名字出现两次,最后一次写入生效,使用前最好先检查split.txt没有重复行。

3.3 数据集校验:类别编号、坐标范围与空标签检查

转换完成不等于数据没问题。我习惯写一个几十行的校验脚本,统计每个split的图片数、标签数、每类目标数,以及所有坐标的最小最大值。下面是示例:

import os from glob import glob SPLITS = ['train', 'val', 'test'] LABEL_DIR = 'dataset/labels' IMG_DIR = 'dataset/images' for split in SPLITS: txts = sorted(glob(os.path.join(LABEL_DIR, split, '*.txt'))) imgs = sorted(glob(os.path.join(IMG_DIR, split, '*.jpg'))) print(f'[{split}] images={len(imgs)} labels={len(txts)}') class_count = [0] * 6 coords_min = 1.0 coords_max = 0.0 empty = 0 for txt in txts: lines = open(txt).read().strip().splitlines() if not lines: empty += 1 for line in lines: parts = line.split() if not parts: continue cls = int(parts[0]) class_count[cls] += 1 vals = list(map(float, parts[1:])) coords_min = min(coords_min, min(vals)) coords_max = max(coords_max, max(vals)) print(f' class distribution: {class_count}') print(f' coord range: {coords_min:.4f} ~ {coords_max:.4f}, empty labels={empty}')

这个输出能直接暴露出三类问题:某个split里图片和标签数量不一致;某个类别没有样本;坐标范围超出了0到1。空标签文件会导致训练时yolov8直接跳过该图,但如果你同时给了空图片,通常不会报错,只是该图对loss无贡献,属于隐性浪费。遇到坐标越界,优先回去修XML或转换脚本,而不是在标签里硬clip,因为clip会让目标框变形,模型学到的位置是错的。

注意:yolov8的labels目录里不要放非txt文件。实际项目中,很多人把时间浪费在数据校验上而延误了训练。我的习惯是:转换脚本+校验脚本总共控制在200行以内,跑完看一遍输出,没问题就马上开训练。

4. 用yolov8训练钢材缺陷模型:命令、参数与loss曲线判读

4.1 训练环境与data.yaml的写法

在开始训练之前,先确认安装的是ultralytics的yolov8。训练时建议用带GPU的机器(显存至少6G,跑yolov8n或yolov8s没问题),如果没有GPU,CPU也能跑,但一次epoch可能要几分钟甚至十几分钟,还是建议用云GPU或本地显卡。现在要写data.yaml,内容如下:

# dataset.yaml path: /full/path/to/dataset # 数据集根目录,建议绝对路径 train: images/train val: images/val test: images/test nc: 6 names: 0: crazing 1: inclusion 2: patches 3: pitted_surface 4: rolled-in_scale 5: scratches

注意yaml里path、train、val是相对还是绝对。path写绝对路径时,train和val用相对path的路径,这样最不容易出错。test可以不写,写了最后验证时好用。names的编号顺序必须和标签txt里的第一个数字一致,类名只是显示用,写错不影响训练,但会影响你读混淆矩阵和输出结果。

4.2 训练命令与关键超参数

在终端里进入项目目录,运行:

yolo detect train data=dataset.yaml model=yolov8s.pt epochs=200 imgsz=640 batch=16 device=0 lr0=0.01 patience=30

这里有几个参数要说明。model=yolov8s.pt表示从预训练权重继续训练,比从头训收敛快很多,建议用s或m,不要一上来就l或x,数据量只有1260张,大模型很容易过拟合。imgsz=640是训练输入尺寸,NEU-DET原图是200x200左右,640并不匹配原图分辨率,但yolov8会做resize和填充。使用640是当前目标检测训练的主流尺寸,如果你显存紧张可以改成512;不要直接用200,因为200不是yolov8的stride(32)的倍数,会自动填充,反而学不到更丰富的上下文。batch=16是相对保守的batch,如果你的显存不够可以调小到8,但batch太小会导致BN统计不稳定。lr0=0.01是初始学习率,如果训练时loss突然变成nan,优先降到0.005或0.001。patience=30是早停,30个epoch没有更好的val结果就停止。

如果你是第一次跑,想快速验证流程能否走通,可以先用:

yolo detect train data=dataset.yaml model=yolov8n.pt epochs=5 imgsz=640 batch=16 device=0

跑5轮,确认没有报错、loss在下降,再启动长训练。不要一上来就200轮,万一数据集有问题,白白浪费算力。

4.3 训练过程怎么看:loss、mAP和过拟合信号

训练结束后,在runs/detect/train/目录下会生成results.png、confusion_matrix.png、labels.png等文件。results.png包含三行:box_loss、cls_loss、dfl_loss,以及precision、recall、mAP50、mAP50-95。你需要看的核心是val的mAP50-95,而不是loss。loss是训练集上的,理论上持续下降,但如果train loss一直降、val loss开始上升,就是过拟合信号,这时候回看patience早停是否触发,或者把epochs缩短。

confusion_matrix.png可以看出哪些类互相混淆,比如我那次斑块被识别成氧化铁皮,混淆矩阵里这两个类的交叉点就特别亮。labels.png展示训练集里目标框的分布和类别数量,能帮你确认标签是不是符合预期。如果发现labels.png里某个类别的框特别密集,而实际图片中目标较小,可能要在标签层面做均衡,但这属于进阶操作。

对于NEU-DET这个数据集,一个正常的s模型在val上mAP50大概能到0.75到0.85之间,mAP50-95在0.55到0.7左右。如果远低于这个范围,先检查标签,再检查训练集是否完整。不要盲目加大模型或调高学习率,数据问题排在第一位。

4.4 在测试集上做一次干净的评估:验证泛化能力

训练完模型,你手上有一个best.pt。先跑一遍val确认训练过程没有异常,再用测试集做最终评估。常见做法是写一个临时的test.yaml,把val字段指向images/test,然后运行:

yolo detect val data=test.yaml model=runs/detect/train/weights/best.pt

如果你用的ultralytics版本支持split=test,也可以直接写data=dataset.yaml split=test,但为了在不同版本上稳定复现,我通常还是新建一个test.yaml。这样test集不会在训练阶段被任何脚本引用,保证评估的独立性。

如果想看直观效果,可以用predict导出带框图像:

yolo detect predict model=runs/detect/train/weights/best.pt source=dataset/images/test conf=0.25

conf=0.25是置信度阈值,工业场景下如果希望漏检更少、宁可多框,可以把conf降到0.1;如果你希望输出干净、少误检,可以调到0.4。这个阈值不影响模型本身的性能,只影响预测结果的可视化。

5. 避坑与排障:NEU-DET转yolov8最常见的5个翻车现场

5.1 标签类别编号对不上,输出结果全乱套

现象:训练正常,loss正常,但预测时发现模型把裂纹识别成划痕,把斑块识别成麻点,而且是有规律地整体错位。

原因:原始XML里的类名字符串和转换脚本里的CLASS_MAPPING不一致,或者不同来源的数据集类名顺序不同。比如有人把crazing放在第0位,有人把scratches放在第0位,而你用了别人的xlsx类别说明去写映射,编号就错了。

解决:先统计原始XML里所有<name>的唯一值,打印出来,再和你的映射表逐项核对。训练前用前面的校验脚本看class distribution,如果每个split的类别计数和你预期不符,立刻停下来排查映射字典,不要继续训练。

5.2 标签坐标出现负值或大于1,训练时报警但不报错

现象:校验脚本输出coord range: -0.02 ~ 1.03,但训练也能跑,只是mAP一直很低。

原因:原始标注框越出图像边界,或者解析XML时读错了size节点。某些标注工具允许框稍微越界,但yolov8在计算损失时对越界坐标的处理是先clip,这会让框的位置和大小都失真。

解决:在转换脚本里打印出越界的图片名和原始坐标,回到XML里看是不是标注本身出了问题。如果是少数几帧,手动修XML或直接在该图片里剔掉这个框。不要靠归一化后的clip掩盖问题,因为clip后的框中心点其实已经偏移了。

5.3 图片和标签数量不一致,某个split缺文件

现象:校验脚本输出train images=1260,但labels只有1258,或者反过来labels多了两个。

原因:划分split时用了不同的规则。比如图片按文件名正则匹配,但部分文件名里带test字样,被错误划到了test;或者转换脚本里漏掉了某些xml,导致标签没生成。

解决:用文件名集合做差集,列出多余和缺失的列表。如果多出来的标签文件,检查它对应的图片是否真的存在;如果缺失标签,检查XML是否存在、split_map里是否包含了该图。最稳妥的方案是只用图片文件名作为唯一主键,从图片列表反推label路径,而不是用两个独立列表。

5.4 显存不够,训练直接OOM

现象:运行训练命令后几秒内报CUDA out of memory。

原因:imgsz=640、batch=16对yolov8s来说是正常负载,如果你用的是老显卡或核显,就会OOM。也有人把workers调得太大,数据加载线程占满内存导致假死。

解决:降低batch到8或4,或者把imgsz降到512。注意,imgsz降低会影响mAP,尤其是小目标,但NEU-DET的目标框相对较大,512影响不大。如果你必须在640下训练,可以加amp参数开启混合精度:

yolo detect train data=dataset.yaml model=yolov8s.pt epochs=100 imgsz=640 batch=8 amp=True device=0

5.5 loss突然变成NaN,或mAP一直不涨

现象:训练到第10个epoch,loss输出变成nan,或者前10个epoch mAP一直是0,之后缓慢上升但极慢。

原因:学习率过大是NaN最常见的来源,尤其在小数据集上预训练权重和新类别分布不匹配时。mAP不涨则通常是标签错误太多,或者类别不平衡严重到某个类完全没有正样本。

解决:先把lr0降到0.001,开启warmup(yolov8默认有3轮warmup)。然后跑一次短训练用epochs=10观察loss曲线。如果loss能稳定下降,再逐步调回0.01。如果mAP一直为0,重新检查标签txt内容,确认不是全零文件、不是类别编号超界。还有一个容易被忽略的点:data.yaml的nc写成了8,而标签里只有0-5,会导致训练时类别维度不匹配,这种错误通常会在日志里打出警告,要留意控制台输出。

6. 进阶技巧:用混淆矩阵和最差样本定位漏检,把mAP再推高3个点

训练完成后先别急着部署,花半小时看两组文件:confusion_matrix.png和results.png。混淆矩阵能告诉你是哪两个类在互相打架。我之前遇到val mAP50停在0.72,看矩阵发现“斑块”和“氧化铁皮”有大量交叉误判。这时候有两个调整方向:第一,在data.yaml里给这两个类配上更清晰的语义,比如检查训练图像里这两个类的框是不是有部分标注错位,如果存在标注噪声,清洗后重新训练;第二,针对难以区分的类,用scale=0.5、hsv_h=0.015这类温和增强,让模型不至于把纹理过度拟合。

另一个实用技巧是取最差样本。用训练好的模型跑test集,然后把分类置信度低于0.3的预测结果提取出来,按图片排列,人工看一遍。你会发现很多漏检其实不是模型能力不够,而是目标本身在图像里只有几个像素宽,比如裂纹,人工都容易看花眼。对这类目标,可以单独把裂纹图片复制一份做复制粘贴增强,或者用rect=True让yolov8保持原图比例训练,减少resize带来的形变损失。

如果你还想再往上提,可以试yolov8m或yolov8l,但要把patience调大到50,并配合dropout=0.1。考虑到训练集只有1260张,大模型反而更容易过拟合,所以建议先从数据清洗和数据增强入手,而不是无脑换大模型。我自己的习惯是把模型从s换成m,再用imgsz=672,mAP50-95能稳定上涨2到3个点,代价是训练时间翻倍。这个提升值不值得,取决于你的硬件和推理端延迟容忍度。

最后分享一个教训:我曾经为了省时间,直接用他人整理好的yolov8格式数据,没有做任何校验训练,结果模型在test上mAP只有0.5,后来发现是标签坐标整体偏移了约5个像素。从那以后,不管数据是哪里来的,我都会先跑一遍第3章里的校验输出再训练。这个习惯救了我很多次。希望帮到你。

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

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

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

立即咨询