NEU-DET钢材缺陷检测:YOLO数据整理与训练避坑 简介NEU-DET钢材表面缺陷数据集面向计算机视觉与工业质检方向的学习者和研究人员围绕钢材表面裂纹、夹杂、斑块、麻点、氧化皮、划痕等缺陷的识别与分类任务提供可直接用于模型训练和算法验证的真实图像样本。压缩包共2000个文件、约26.68MB其中1800张jpg缺陷图像与1800个xml标注文件一一对应并配有1800个txt标签文件另含xml2yolo.py转换脚本及yaml配置便于在YOLO等目标检测框架中组织数据。已有4958人学习下载说明其在缺陷检测入门与课程实践中有较高参考价值。读者可据此完成数据预处理、标注格式转换、训练集/验证集/测试集划分并开展CNN等深度学习模型的训练、评估与泛化能力对比积累工业表面缺陷检测的完整实验素材。1. 钢材表面缺陷数据集NEU-DET1800 张灰度图能撑起什么热轧产线下线质检工位每分钟过十几张板坯肉眼盯划痕和麻点两小时之后注意力就散了——钢材表面缺陷数据集 NEU-DET 就是从这个真实痛点里长出来的公开基准6 类缺陷、每类 300 张、单张 200×200 灰度图总量 1800 张。它刚好覆盖了工业质检里最难处理的三件事小样本、弱纹理、强反光干扰。它的价值不在于刷榜而在于给你一个一周内能跑通全链路的沙盒——数据量小到单卡几小时训完缺陷类型又足够复杂龟裂和斑块在灰度图里经常糊成一片麻点和氧化铁皮压入的纹理方向几乎同源。适合做质检算法落地的工程师、想验证小目标检测方案的学生以及要给产线做技术预研的团队。2. 六个类别与标注形态NEU-DET 到底给了你什么拿到一个数据集先别急着写训练脚本。我在实际项目里见过太多人上来就yolo train训完发现标签全错位或者类别名和索引对不上白烧两天卡时。NEU-DET 的信息密度不高但它有几个必须先摸清的结构性特征直接决定后面所有参数怎么设。2.1 六类缺陷在 200×200 灰度图里长什么样NEU-DET 的六类缺陷是固定的一组命名和视觉特征差异很大但两两之间存在明显的混淆对类别英文名中文习惯叫法视觉特征最容易混成谁crazing龟裂网状细裂纹对比度极低边缘发虚patchesinclusion夹杂条状或块状异物灰度突变明显rolled-in_scalepatches斑块成片灰度不均边界模糊无明确轮廓crazingpitted_surface麻点密集小凹坑尺寸小、数量多rolled-in_scalerolled-in_scale氧化铁皮压入条带状暗纹带方向性pitted_surfacescratches划痕细长直线方向单一对比度高无最好分的一类看清这张表你就知道后面调参的重点在哪crazing 和 patches 的区分靠的是纹理频率而不是亮度pitted_surface 和 rolled-in_scale 的区分靠的是形状密度而不是颜色。灰度图丢掉了颜色信息这两个混淆对只能靠增强策略和输入分辨率去救。实际做法是把输入尺寸抬到 640 甚至 1024 再做下采样让 200×200 的原始细节至少不被二次压缩。还有一点容易被忽略这批图是同一批钢板、同一套光照下采集的背景灰度分布高度一致。这意味着模型很容易学到“背景就是深灰偏均匀”这种捷径特征在验证集上表现漂亮换一批料就崩。玄学一点说这类数据集的精度是借来的早晚要还。2.2 标注形态决定任务选型分类、检测还是分割NEU-DET 在公开渠道常见的分发形态有两类一类是按类别名分文件夹存放的原始图像只带类别标签另一类是图像配 XML 标注文件每个object给一个矩形框和类别名。这两种形态对应完全不同的任务图像分类只能回答“这张图里有没有某类缺陷”不能定位也不能回答“有几处”。产线上如果只需要报警、不需要引导修磨分类就够。目标检测XML 框标注直接支持能给出位置和数量是绝大多数人的选择。语义分割原始数据没有像素级掩膜硬要做只能拿框当弱监督或者自己标一批性价比很低。所以默认路径是目标检测。但这里有个认知差NEU-DET 的框标注尺度差异很大。patches 那种成片缺陷一个框能覆盖大半张图pitted_surface 的麻点则是一堆 10×10 像素级别的小框挤在一起。同一套 anchor 或同一套输入尺寸很难同时伺候这两类。我的处理办法是统计每类框的宽高分布把面积小于图像面积 0.5% 的框单独标记出来训练时看它们的召回是否被牺牲掉了——这一步比盲目加 epoch 有用得多。2.3 每类 300 张意味着什么划分策略比模型选择更重要1800 张、6 类、每类严格 300 张类别是均衡的这在工业数据集里算难得。但均衡的另一面是类内多样性极低同一类缺陷基本来自同一批样本拍摄角度、光照强度变化很小。直接按 8:2 随机划分验证集和训练集几乎是同分布复制mAP 能到 0.85 以上你会误以为方案成了。更稳的划分方式是按“图像来源批次”分层如果原始数据没有批次信息就至少保证每类在训练集和验证集里的框数量比例一致并且在划分时固定随机种子。我一般用 8:1:1 分训练/验证/测试测试集只在最后跑一次中间调参全程不碰。测试集跑出来的数字比验证集低 5 到 10 个点很正常这个落差才是你要向产线承诺时该参考的基线。3. 把 NEU-DET 整理成 YOLO 格式目录、转换脚本与校验数据摸清之后就是工程活。这一步做不干净后面所有训练结果都不可信。常见做法是搭一套images/labels双轨目录图像和标注文件同名不同后缀一对一对得上。3.1 目录重组images/labels 双轨结构先把原始数据整理成下面这个结构后面脚本全部按这个约定跑neu-det-yolo/ ├── images/ │ ├── train/ # *.jpg │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ # *.txt与图像同名 │ ├── val/ │ └── test/ └── neu-det.yaml原始 XML 里的图像可能是 png 或 bmp统一转成 jpg 不损失信息灰度图转 jpg 质量设 95 以上即可文件名里带空格或中文的必须先规整成小写下划线YOLO 的 dataloader 对中文路径和空格的处理在不同系统上不一致这是后面排查半天找不到原因的常见源头。3.2 XML 转 YOLO txt坐标归一化脚本YOLO 的标签格式是类别索引 中心x 中心y 宽 高全部归一化到 [0,1]。转换时最容易出问题的是越界坐标和零面积框脚本里必须处理掉import xml.etree.ElementTree as ET from pathlib import Path # 类别索引必须与 neu-det.yaml 里的 names 顺序完全一致 CLASS_MAP { crazing: 0, inclusion: 1, patches: 2, pitted_surface: 3, rolled-in_scale: 4, scratches: 5, } def convert_one(xml_path: Path, img_w: int 200, img_h: int 200) - list[str]: root ET.parse(xml_path).getroot() size root.find(size) if size is not None: # 优先信任 XML 里记录的尺寸 img_w int(size.findtext(width, img_w)) img_h int(size.findtext(height, img_h)) rows, dropped [], 0 for obj in root.iter(object): name (obj.findtext(name) or ).strip().lower().replace( , _) if name not in CLASS_MAP: dropped 1 # 类别名拼写不一致的直接计数别静默丢 continue box obj.find(bndbox) x1 float(box.findtext(xmin)); y1 float(box.findtext(ymin)) x2 float(box.findtext(xmax)); y2 float(box.findtext(ymax)) # 关键一步把框裁剪到图像边界内越界坐标归一化后会大于 1 x1, x2 max(0.0, min(x1, img_w)), max(0.0, min(x2, img_w)) y1, y2 max(0.0, min(y1, img_h)), max(0.0, min(y2, img_h)) w, h (x2 - x1) / img_w, (y2 - y1) / img_h if w 0 or h 0: # 裁剪后退化成线或点的框直接丢弃 dropped 1 continue cx, cy (x1 x2) / 2 / img_w, (y1 y2) / 2 / img_h rows.append(f{CLASS_MAP[name]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) if dropped: print(f[warn] {xml_path.name}: 丢弃 {dropped} 个框) return rows def batch_convert(xml_dir: Path, out_dir: Path): out_dir.mkdir(parentsTrue, exist_okTrue) total 0 for xml_path in sorted(xml_dir.glob(*.xml)): rows convert_one(xml_path) (out_dir / f{xml_path.stem}.txt).write_text(\n.join(rows), encodingutf-8) total len(rows) print(fdone: {total} boxes - {out_dir})脚本里三个地方是血泪经验换来的类别名统一做lower 空格转下划线因为原始标注里rolled-in_scale和rolled_in_scale两种写法都可能出现越界坐标先裁剪再归一化不裁剪的话中心点会跑到 1 以外训练时 loss 直接变 nan零面积框丢弃时打印计数静默丢弃会让你以为数据没问题实际少了一批小目标。3.3 分层划分与一致性校验转换完不能直接训练先做划分和校验。下面是按类别分层的划分脚本保证每类在三个子集里的框数量比例接近 8:1:1import random, shutil from pathlib import Path from collections import defaultdict random.seed(42) # 种子固定保证每次划分一致结果可复现 RAW_IMG, RAW_LBL Path(raw/images), Path(raw/labels) OUT Path(neu-det-yolo) SPLIT {train: 0.8, val: 0.1, test: 0.1} def main_label(txt: Path) - str: # 用文件里出现次数最多的类别作为该图的“主类”用于分层 ids [line.split()[0] for line in txt.read_text().splitlines() if line.strip()] return max(set(ids), keyids.count) if ids else -1 buckets defaultdict(list) for txt in RAW_LBL.glob(*.txt): buckets[main_label(txt)].append(txt.stem) for cls, stems in buckets.items(): random.shuffle(stems) n len(stems); n_val int(n * SPLIT[val]); n_test int(n * SPLIT[test]) parts {test: stems[:n_test], val: stems[n_test:n_test n_val], train: stems[n_test n_val:]} for split, names in parts.items(): for d in (images, labels): (OUT / d / split).mkdir(parentsTrue, exist_okTrue) for stem in names: shutil.copy(RAW_IMG / f{stem}.jpg, OUT / images / split / f{stem}.jpg) shutil.copy(RAW_LBL / f{stem}.txt, OUT / labels / split / f{stem}.txt) print(fclass {cls}: {len(parts[train])}/{len(parts[val])}/{len(parts[test])})main_label用众数类别做分层依据是为了避免某类小目标因为整体随机而被切走大半。跑完之后必须做一次一致性校验每张图是否有同名标签、标签行数是否为零、坐标是否全在 [0,1]。零标签的图像在 YOLO 里会被当作纯背景参与训练少量可以当负样本多了会把召回压垮这类文件要单独列出来人工确认。4. 小样本灰度缺陷的训练参数输入尺寸、增强与超参取舍数据干净之后参数就是决定成败的地方。NEU-DET 的特殊性在于图像原始只有 200×200缺陷尺度跨度大且是单通道灰度。这三点会直接推翻很多默认配置。4.1 输入尺寸与灰度通道的处理imgsz是第一个要拍的参数。原始图 200×200如果按 640 训练等于放大 3.2 倍再送进网络插值带来的模糊对 crazing 这种低对比度细纹是致命的但设成 320 又会让 pitted_surface 的小凹坑缩到 3 到 5 个像素骨干网络下采样 32 倍后基本消失。我的取舍是imgsz640配合较强的锐度增强或者imgsz512作为折中。判断依据不是拍脑袋而是先把每类框的短边像素统计出来看 5% 分位数落在哪如果最小的一批框短边只有 12 像素640 输入、32 倍下采样后只剩 0.375 个特征点这类目标必然漏检那就得考虑减小下采样倍数用 P2 层或者干脆对 pitted_surface 单独训一个高分辨率分支。灰度通道的处理更简单也更常翻车不要试图改成 1 通道输入去省显存主流检测框架的预训练权重都是 3 通道改通道数等于放弃预训练。正确做法是复制三份当伪 RGB让cv2.imread正常读出 3 通道或者用cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)显式转换同时把颜色相关的增强HSV 色相扰动、饱和度扰动全部关掉——对灰度图做 HSV 增强本质上是给三个相同通道加不同噪声纯属自找麻烦。4.2 增强策略哪些增强会把缺陷改没数据增强在小样本上是刚需但 NEU-DET 有几类增强是毒药增强项建议值理由mosaic0.5 或关闭四图拼接会把 200×200 的小缺陷再缩一半pitted_surface 直接消失hsv_h / hsv_s0.0灰度图无颜色信息扰动等于加噪hsv_v0.3亮度扰动模拟产线光照波动有效fliplr0.5水平翻转安全缺陷形态无方向语义flipud0.0 到 0.2划痕和氧化铁皮压入有轧制方向性垂直翻转会破坏语义degrees5 到 10小幅旋转模拟拍摄角度偏差过大导致框外区域出现黑边scale0.3尺度扰动缓解大小目标共存问题但下限别低于 0.5mixup0.0两张缺陷图叠加会产生现实中不存在的复合缺陷标签语义被污染mosaic 这一项我踩过坑默认 1.0 开着训训练 loss 降得很漂亮验证集上 pitted_surface 的召回只有 0.4。关掉 mosaic 重训同一批数据召回回到 0.65 左右。原因就是拼接后小目标被压缩到几乎不可辨模型学到的是“拼接边界”这种伪特征。4.3 训练命令与关键超参配置文件先写死类别和路径注意names的顺序必须和转换脚本里的CLASS_MAP完全一致顺序错了模型照样能训只是所有类别名都会张冠李戴# neu-det.yaml path: ./neu-det-yolo train: images/train val: images/val test: images/test names: 0: crazing 1: inclusion 2: patches 3: pitted_surface 4: rolled-in_scale 5: scratches启动训练# 小模型起步先验证数据管道是否通畅再换大模型 yolo detect train \ dataneu-det.yaml \ modelyolov8s.pt \ imgsz640 \ epochs150 \ batch16 \ lr00.01 \ lrf0.01 \ warmup_epochs5 \ patience30 \ mosaic0.5 \ hsv_h0.0 hsv_s0.0 hsv_v0.3 \ fliplr0.5 flipud0.0 \ degrees8 scale0.3 \ seed42 device0参数逐个说lr00.01是 SGD 的常规起点如果换成 AdamW 要降到 0.001lrf0.01让学习率在训练末段退火到初始值的 1%小样本训 150 轮不退火容易在后期过拟合patience30是早停NEU-DET 这种量级大概率在 80 到 120 轮之间收敛超过 30 轮 mAP 不涨基本就是过拟合了warmup_epochs5在小数据集上不能省前几轮梯度噪声大直接满学习率容易把预训练权重冲坏。batch16在 640 输入、单张 24G 显存的卡上比较稳显存吃紧就降到 8 并同步把lr0减半别用梯度累积硬凑——累积会改变 BatchNorm 的统计量。5. NEU-DET 训练避坑五条现象、原因与止血方案这一章全部是踩过的坑按现象、原因、解决三段的格式写。有些坑不报错只是精度莫名其妙上不去这类最难查。5.1 数据侧的三次翻车坑一训练 loss 正常下降验证 mAP 一直卡在 0.3 以下。现象是各类 AP 都不高但混淆矩阵对角线明显说明模型学到了东西只是定位不准。查了半天发现标签文件里的坐标是像素绝对值没做归一化——原始 XML 转 txt 时有人图省事直接把xmin ymin xmax ymax写进去了。YOLO 读到大于 1 的坐标会做裁剪框全挤在图像右下角。解决办法是转换脚本里强制走中心点加宽高的归一化公式转完抽查 10 个文件把坐标乘以图像尺寸反算回像素和 XML 原值比对误差应该在 1 像素以内。坑二某几类 AP 高得离谱接近 0.99其他类惨不忍睹。原因是数据泄漏。原始数据按类别分文件夹存放有人直接对整个文件夹做随机划分同一张原始大图切出来的多个子图分别落进了训练集和验证集。这类数据集的图像很多是从几张整图上裁出来的肉眼看着不同实际高度相关。止血方案是划分前先对图像做感知哈希pHash去重汉明距离小于 5 的归为一组同组只进一个子集。这一条在任何工业缺陷数据集上都适用不止 NEU-DET。坑三训练报错Label class 4 is not in the data set但配置文件里明明写了六类。现象是启动时校验失败或者训完之后某一类的 AP 恒为 0。两个可能一是类别名大小写或连字符不一致rolled-in-scale和rolled_in_scale在字典里是两个键转换时被归到了continue分支静默丢弃二是配置文件里names用了列表形式[crazing, ...]索引从 0 开始没问题但有人从 1 开始编号导致索引整体偏移。解决办法是转换脚本统计每类的框数量并打印六个数字应该大致均衡每类 300 张图、框数在 400 到 800 之间某个类别是 0 就立刻回查。5.2 训练侧的两次翻车坑四训到第 60 轮左右 mAP 突然掉下去再也起不来。现象是 loss 曲线出现尖峰。原因是学习率没有 warmup或者lr0设得过大配合小 batch梯度爆炸。另外 NEU-DET 里存在少量标注框极度密集的图像麻点图一帧几十个框这批样本产生的 loss 量级远高于其他图容易把参数拽偏。止血办法是开warmup_epochs同时检查有没有单图框数超过 30 的极端样本把它们从训练集里剔掉或复制降采样。别指望梯度裁剪能解决一切裁剪只是不让它炸不解决分布失衡。坑五本地验证集 mAP 0.86换一批新采集的图掉到 0.4。这是最典型的分布偏移不算 bug但必须提前预期。原因是 NEU-DET 的图像来自固定光照和固定拍摄距离模型把背景灰度分布当成了强特征。缓解手段有两个训练时加亮度、对比度的随机扰动hsv_v开到 0.4再叠一层随机 gamma以及在验证阶段专门构造一个“跨批次测试集”——从新批次里挑 100 张人工标注只用它做验收绝不用它调参。没有这个跨批次集合你在产线上线的第一天就会知道什么叫现实。提示所有排查动作都要留日志。转换脚本打印每类框数、划分脚本打印每类子集数量、训练脚本保存完整命令行参数这三份记录在复盘时比任何直觉都可靠。6. 从 mAP 到产线验收盯住混淆矩阵的两个格子和单类召回训练跑完results.csv里的 mAP50 只是入场券。真正决定这套东西能不能上产线的是两件事哪两类在互相吞以及每一类的召回在什么置信度下还能接受。6.1 混淆矩阵里最该看的两组数字NEU-DET 训完之后混淆矩阵的非对角线上通常有两块最亮crazing 被判成 patchespitted_surface 被判成 rolled-in_scale。这两组混淆不是模型不够大是特征本身在 200×200 灰度下就分不开。处理顺序是先看背景列background——如果有大量真值框被判成背景说明召回不足问题在置信度阈值或增强如果背景列很干净但对角线外的格子亮说明是纯分类混淆只能靠提高输入分辨率或给这两类单独加权。我一般会在验证脚本里把混淆矩阵按行归一化导出成 csv直接看每类的“被误判去向”占比。crazing 有 30% 被判成 patches意味着这两类在实际使用中必须合并处理或者加人工复核硬调阈值只会把召回和误报一起拉高。6.2 用置信度扫描定每类的验收阈值统一的conf0.25在六类上不可能都合适。给每类单独扫一遍阈值找出满足目标召回的临界点from ultralytics import YOLO model YOLO(runs/detect/train/weights/best.pt) names model.names for conf in (0.10, 0.20, 0.25, 0.35, 0.50, 0.65): m model.val(dataneu-det.yaml, splitval, confconf, iou0.6, verboseFalse) # ap50 是按类别索引排列的 AP0.5直接对应 names per_cls {names[i]: round(float(v), 3) for i, v in enumerate(m.box.ap50)} print(fconf{conf:5} mAP50{m.box.map50:.4f} {per_cls})这段跑完会得到一张阈值与单类 AP 的对照表。划痕这类高对比度缺陷阈值提到 0.5 召回依然稳可以放心设高来压误报龟裂在 0.25 以上就开始掉只能接受较低阈值并配人工复核。产线验收谈的从来不是整体 mAP而是“每类的漏检率能不能压到千分之三以内、误报每天不超过多少次”这两条指标必须按类谈。我自己做这类项目的习惯是任何一次训练结束先不看 mAP 汇总数字先翻混淆矩阵和单类召回曲线确认没有哪一类被整体牺牲掉。NEU-DET 这种六类均衡的数据集最会骗人——平均值好看某一类全漏平均值照样能到 0.7。希望帮到你。本文还有配套的精品资源点击获取