news 2026/10/28 16:54:53

NEU-DET钢材表面缺陷检测实战:从VOC转YOLO到产线部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
NEU-DET钢材表面缺陷检测实战:从VOC转YOLO到产线部署

简介:钢材表面缺陷数据集NEU-DET面向计算机视觉、机器学习方向的研究者与学生,汇集真实钢材表面图像及缺陷标注,可用于裂纹、夹杂、斑块、麻点等缺陷的识别、定位与检测模型训练评测,适合工业质检入门实验与中级算法复现。资源包共2000个文件,以jpg图像、xml边界框标注和txt标签为主,另含格式转换脚本与yaml配置文件,压缩包约26.68MB,便于接入YOLO类或CNN检测流程。已有4958人学习下载,在工业缺陷检测学习中具有一定参考价值。读者可据此完成数据预处理、标注格式转换、训练集与验证集划分,并围绕分类和定位任务搭建基线模型,观察损失收敛与泛化表现,为钢铁产线自动化质检研究提供可复用的数据基础。

1. 钢材表面缺陷数据集NEU-DET:1800 张 200×200 灰度图,凭什么撑起一条检测流水线

钢材表面缺陷数据集NEU-DET 是我见过的、最容易被低估的一份工业数据。它只有 1800 张图,分辨率清一色 200×200,灰度,六类缺陷每类 300 张,标注是 PASCAL VOC 的 XML。第一次打开时我其实有点失望——这尺寸放到今天的检测模型里,连一次下采样都经不起。但正是这份数据,把热轧带钢表面最常见的六种缺陷形态——龟裂、夹杂、斑块、麻点、氧化皮压入、划痕——定义得干干净净,标注边界清晰,类别互斥。它解决的不是“能不能检测”的问题,而是“你有没有把工业缺陷的形态差异吃透”的问题。适合两类人:一类是刚接手表面缺陷检测、需要一个干净起点把流程跑通的人;另一类是手上已经有产线数据、想拿一份公开基准做对照和回归验证的人。前者拿它练手,后者拿它当尺子。

2. 六类缺陷与 VOC 标注:先把数据的脾气摸清楚再谈模型

拿到一份新数据,我一般不会立刻开训练脚本,而是先花半天时间做三件事:数框、看面积分布、把每类最典型的图和最不典型的图各挑十张肉眼看。NEU-DET 尤其需要这一步,因为它的六类缺陷在 200×200 的画幅里,面积跨度能从整张图的 60% 一路掉到 1% 以下,而模型对这两端的态度完全不同。

2.1 六类缺陷的形态差异与真正的判别难点

龟裂(crazing)是细密的网状微裂纹,灰度对比极低,很多时候人眼要凑近了才看得出来;划痕(scratches)是细长直线或弧线,方向性强,对比度比龟裂略高。这两类是新手最容易混淆的一对,因为在小画幅下,一条短划痕和一段龟裂的局部几乎长得一样。夹杂(inclusion)通常呈条带状或块状暗区,边界相对硬;斑块(patches)是不规则的大块明暗区域,面积往往最大;麻点(pitted_surface)是密集分布的小凹坑,单点极小但成簇出现;氧化皮压入(rolled-in_scale)呈层状或片状,边界发虚,和斑块在低分辨率下有重叠。

真正需要提前想明白的是:这些类别的难点不在类间相似,而在尺度悬殊。斑块可能占半张图,麻点的单个凹坑可能只有十几个像素。如果你把输入统一 resize 到 640,麻点会被双线性插值抹成一片模糊的灰,模型学到的就不是凹坑而是噪声。所以我在动手前会先统计一遍框的相对面积分布,这个数决定了后面输入分辨率怎么定。

2.2 用一段脚本把标注分布统计清楚

import os import glob import xml.etree.ElementTree as ET from collections import Counter, defaultdict ANN_DIR = "NEU-DET/ANNOTATIONS" CLASSES = ["crazing", "inclusion", "patches", "pitted_surface", "rolled-in_scale", "scratches"] box_cnt = Counter() # 每类框总数 img_cnt = Counter() # 每类出现的图片数 area_ratio = defaultdict(list) # 每类框的相对面积 boxes_per_img = defaultdict(list) for xml_path in glob.glob(os.path.join(ANN_DIR, "*.xml")): root = ET.parse(xml_path).getroot() size = root.find("size") W = float(size.find("width").text) H = float(size.find("height").text) local = Counter() for obj in root.findall("object"): name = obj.find("name").text.strip() b = obj.find("bndbox") x1 = float(b.find("xmin").text) y1 = float(b.find("ymin").text) x2 = float(b.find("xmax").text) y2 = float(b.find("ymax").text) box_cnt[name] += 1 local[name] += 1 # 相对面积,用图像总面积归一化,方便跨分辨率比较 area_ratio[name].append((x2 - x1) * (y2 - y1) / (W * H)) for name, n in local.items(): img_cnt[name] += 1 boxes_per_img[name].append(n) for name in CLASSES: ar = sorted(area_ratio[name]) n = len(ar) if n == 0: print(f"{name}: 0 boxes") continue print(f"{name:16s} boxes={box_cnt[name]:4d} imgs={img_cnt[name]:4d} " f"area p10={ar[n // 10]:.4f} p50={ar[n // 2]:.4f} p90={ar[9 * n // 10]:.4f} " f"max_boxes/img={max(boxes_per_img[name])}")

这段脚本的重点不在统计本身,而在三个输出值的用法。area p10告诉你这一类里最小的那 10% 框有多大,如果 p10 小于 0.01,说明存在相对面积不到 1% 的目标,输入分辨率就不能压得太狠。max_boxes/img决定了后处理里 NMS 之前的 top-k 该设多大,你如果设成默认的 300,而某张麻点图有 40 个框,一般没事,但如果你做的是切片推理,单张切片的框数可能翻几倍,就得往上调。img_cnt和box_cnt的比值告诉你平均每张图几个目标,这个数直接影响正样本的采样密度。

跑完你会看到,六类的图片数都是 300,但框数差异明显——麻点这类往往一张图里十几个甚至几十个框,而斑块常常一张图一两个。这种“类别在图像层面的平衡”和“类别在框层面的不平衡”同时存在,是后面调 loss 权重的直接依据。

2.3 从 VOC 转 YOLO:坐标归一化和四个边界坑

NEU-DET 给的是 VOC 格式,绝大多数检测框架的训练入口吃的是归一化的cx cy w h。转换本身十行代码,但每年都有人在几个边界条件上翻车。

import os import glob import xml.etree.ElementTree as ET CLASSES = ["crazing", "inclusion", "patches", "pitted_surface", "rolled-in_scale", "scratches"] def voc_to_yolo(xml_path, txt_path): root = ET.parse(xml_path).getroot() size = root.find("size") W = float(size.find("width").text) H = float(size.find("height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in CLASSES: continue # 未登记的类别丢弃,别让它悄悄映射成 0 号类 cls_id = CLASSES.index(name) # 索引顺序必须和 data.yaml 的 names 完全一致 b = obj.find("bndbox") x1 = float(b.find("xmin").text) y1 = float(b.find("ymin").text) x2 = float(b.find("xmax").text) y2 = float(b.find("ymax").text) # 坑一:VOC 里偶见 xmax = W + 1 或坐标为 0 的框,不夹紧归一化后会 > 1 x1 = max(0.0, min(x1, W)) x2 = max(0.0, min(x2, W)) y1 = max(0.0, min(y1, H)) y2 = max(0.0, min(y2, H)) w = x2 - x1 h = y2 - y1 # 坑二:夹紧后宽高可能退化为 0,留着会污染回归分支的 loss if w <= 1 or h <= 1: continue cx = (x1 + x2) / 2.0 / W cy = (y1 + y2) / 2.0 / H lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {w / W:.6f} {h / H:.6f}") if not lines: return False # 坑三:转换后为空的标注文件不要生成,否则训练时被当成负样本 with open(txt_path, "w") as f: f.write("\n".join(lines)) return True if __name__ == "__main__": os.makedirs("labels", exist_ok=True) empty = 0 for p in glob.glob("NEU-DET/ANNOTATIONS/*.xml"): stem = os.path.splitext(os.path.basename(p))[0] if not voc_to_yolo(p, f"labels/{stem}.txt"): empty += 1 print("skipped empty:", empty) # 坑四:这个数应该接近 0,明显大于 0 说明类别名对不上

这四个坑里,第一个和第三个最隐蔽。第一个不会报错,只会让个别框的归一化坐标略大于 1,训练时被静默裁掉,表现是“这个类总差那么一点点 AP”。第三个更麻烦——空 txt 在 YOLO 体系里等价于“这张图没有目标”,是合法的负样本,你会莫名其妙地给一个含缺陷的图喂了负标签,模型开始学会漏检。所以脚本里留了skipped empty这个计数,只要它不是 0,就说明要么类别名写错了,要么真有问题的标注混进来了,必须人工看一眼。

第二个坑是宽高退化。夹紧坐标之后宽或高变成 0 或负数,如果不拦,w/W会算出负值,训练框架要么报invalid label,要么安静地把它当成极小目标,两种情况都很难查。

第四个坑就是类别顺序。CLASSES.index(name)这个顺序必须和训练配置文件里的names逐项对应,一旦错位,模型不会报任何错,只是把龟裂学成了麻点,最后 mAP 看着还行,但每一类的 AP 全乱。我吃过这个亏,现在固定做法是把CLASSES单独放在一个classes.py里,转换脚本和训练配置都从它 import,物理上杜绝两处不一致。

3. 用 NEU-DET 跑通第一个基线:划分、增强与评估口径

基线阶段最容易犯的错是“跑出个数就以为成了”。NEU-DET 只有 1800 张,随机划分带来的方差极大,同一份配置换个 seed,mAP 波动三五个点是常有的事。所以基线的意义不是拿到一个高分,而是把训练、验证、评估这条链路的数据流和评价标准固定下来,后面所有调优都在这条链路上做对照。

3.1 目录约定与按类别分层的划分脚本

我习惯把数据组织成下面这样,好处是训练配置里只写相对路径,换机器不用改:

neu-det-yolo/ ├── images/ │ ├── train/ # 1440 张 │ └── val/ # 360 张 ├── labels/ │ ├── train/ │ └── val/ └── neu_det.yaml

划分的关键是分层。NEU-DET 每类正好 300 张,如果纯随机抽 20%,很可能某一类在验证集里只剩 48 张,评估出来的该类 AP 置信区间宽得没法用。

import os import glob import shutil import random from collections import defaultdict random.seed(42) # 固定种子,否则每次划分结果不同,前后实验没法比 SRC_IMG = "NEU-DET/IMAGES" SRC_LAB = "labels" DST = "neu-det-yolo" VAL_RATIO = 0.2 CLASSES = ["crazing", "inclusion", "patches", "pitted_surface", "rolled-in_scale", "scratches"] # 原始数据按类别分文件夹存放,正好可以直接按目录分层 buckets = defaultdict(list) for cls in CLASSES: for p in glob.glob(os.path.join(SRC_IMG, cls, "*.jpg")): buckets[cls].append(p) for split in ("train", "val"): os.makedirs(f"{DST}/images/{split}", exist_ok=True) os.makedirs(f"{DST}/labels/{split}", exist_ok=True) for cls, paths in buckets.items(): random.shuffle(paths) n_val = int(len(paths) * VAL_RATIO) # 每类各抽 60 张进验证集 for i, p in enumerate(paths): split = "val" if i < n_val else "train" stem = os.path.splitext(os.path.basename(p))[0] shutil.copy(p, f"{DST}/images/{split}/{stem}.jpg") lab = f"{SRC_LAB}/{stem}.txt" if os.path.exists(lab): shutil.copy(lab, f"{DST}/labels/{split}/{stem}.txt")

seed=42和VAL_RATIO=0.2这两个值本身没什么讲究,讲究的是所有实验必须用同一个划分。我在项目目录里会把这个脚本的输出固化成文件,后面调参时只读不重新生成。见过太多人每跑一次实验就重新划分一次,然后拿两次不同验证集上的 mAP 比高低,这种对比毫无意义——这就是我说的玄学来源之一。

对应的neu_det.yaml:

path: /data/neu-det-yolo train: images/train val: images/val names: 0: crazing 1: inclusion 2: patches 3: pitted_surface 4: rolled-in_scale 5: scratches

names的键必须是整数且从 0 连续,值必须和转换脚本里的CLASSES逐字一致,包括下划线。rolled-in_scale这种带连字符和下划线的名字,手敲最容易错,建议直接复制。

3.2 训练入口与三个必调参数

yolo detect train \ model=yolov8n.pt \ # 换成你手头任一 COCO 预训练权重即可 data=neu_det.yaml \ imgsz=320 \ # 关键参数,见下文 epochs=150 \ batch=32 \ optimizer=SGD \ lr0=0.01 \ lrf=0.01 \ mosaic=0.0 \ # 基线阶段先关掉 mosaic close_mosaic=0 \ degrees=0.0 \ fliplr=0.5 \ flipud=0.0 \ hsv_h=0.0 \ hsv_s=0.0 \ hsv_v=0.2 \ patience=50 \ seed=42 \ name=neu_baseline

逐项说清楚为什么这么设:

imgsz=320是这份数据最关键的参数。原始图 200×200,设 320 相当于放大约 1.6 倍,麻点这类小目标还能保住形态;设 640 就是 3.2 倍插值,细节全丢。我试过 640,麻点的 AP 反而比 320 低,因为模型看到的是一团被插值糊开的灰斑,噪声比信号多。如果显存允许,也可以试 256 或 384,但 200 以下不建议,会丢小目标。

mosaic=0.0是基线阶段的刻意选择。Mosaic 会把四张图拼成一张,对 NEU-DET 这种小目标密集的数据,很多时候会把麻点、龟裂这类目标切到只剩半个或者干脆切没,标签还在但目标已经不完整了。基线先关掉,等确认模型本身没问题,再开回来试 0.5 或 1.0,看是涨是跌,这样你能分清涨跌是增强带来的还是模型带来的。

hsv_h=0.0, hsv_s=0.0, hsv_v=0.2是因为输入是灰度图。色相和饱和度扰动对灰度图没有意义,框架会自动把三通道复制,扰动 hue 相当于给三个通道加了不同偏移,纯属引入噪声。亮度扰动hsv_v保留,因为实际产线的光照确实会变,这是有物理意义的增强。

flipud=0.0, degrees=0.0是保守起见。钢材表面图像理论上是俯拍,上下翻转在物理上说得通,但如果你后面要迁到真实产线,相机安装角度未必是正俯视,垂直翻转和旋转可能引入不真实的形态。水平翻转fliplr=0.5一般安全,缺陷本身没有左右手性。

lrf=0.01配合lr0=0.01是余弦退火到初始学习率的 1%。小数据集上,末端学习率不降下来,最后几十个 epoch 的 loss 会一直在抖。

patience=50给得比较宽松,因为小数据集的验证曲线本身波动大,早停太激进会停在半山腰。

3.3 评估口径:别用默认 conf 去比论文数字

评估这块有个特别常见的误会。很多人跑完val,看到 mAP@0.5 是 0.62,再看看别人报的 0.78,就以为自己模型不行。实际上两边的置信度阈值、NMS IoU、是否用 TTA 都可能不一样。

指标口径要点NEU-DET 上的经验区间用途
mAP@0.5IoU=0.5,六类 AP 算术平均,conf 扫到很低0.72~0.85(轻量骨干、320 输入)主对照指标,看整体趋势
mAP@0.5:0.95IoU 从 0.5 到 0.95 步长 0.05 取平均比 mAP@0.5 低 15~25 个点看框的定位精度,斑块类拉低明显
单类 AP尤其看 crazing 和 pitted_surfacecrazing 常最低定位短板,决定往哪加增强
Recall@conf=0.25固定阈值下的召回0.70~0.85接近产线实际工作点
每类框数偏差预测框数 / 标注框数0.8~1.3 合理查是否系统性漏检或误检

关键在 conf 那一行。检测框架在算 mAP 时,默认会把 conf 阈值设到极低(比如 0.001),把所有预测都拿去和 GT 匹配,这样才能画出完整的 PR 曲线。如果你手动把 conf 设成 0.25 再去看 mAP,数字一定偏低,但那不是模型变差了,是口径变了。所以我的习惯是:报告 mAP 时永远用极低 conf,报告产线效果时才用工作点 conf,两组数字分开写,不混着比。

另外要盯一下每类框数偏差。如果某类的预测框数只有标注框数的 0.6 倍,说明系统性漏检,问题大概率在输入分辨率或者该类目标太小,改增强没用,得回去改imgsz。

4. 灰度、小目标、类间不平衡:NEU-DET 上的三个调优方向

基线跑通之后,提升空间基本集中在三处:输入是灰度怎么处理、增强到什么程度算过头、六类之间 AP 差距怎么拉平。这三件事互相牵连,改一个要重跑另外两个的对照,所以我一般一次只动一个变量。

4.1 灰度图的两种喂法:复制三通道还是改第一层

主流检测框架的骨干默认吃三通道,而 NEU-DET 是单通道灰度。两条路:

路线 A:复制成三通道,直接用 COCO 或 ImageNet 预训练权重。一行cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)搞定,预训练权重的第一层卷积完全可用,收敛快,是绝大多数人的选择。代价是三个通道输入完全相同,第一层卷积实际上在做三倍冗余计算,而且预训练学到的“颜色通道间关系”在这里是伪信息。

路线 B:保留单通道,改骨干第一层,把预训练权重的三通道权重压缩成一通道。

import torch import torch.nn as nn def adapt_first_conv(model, in_ch=1): """把骨干第一层卷积从 3 通道改成 in_ch 通道,权重按通道求和后平均""" old = model.conv1 new = nn.Conv2d( in_ch, old.out_channels, kernel_size=old.kernel_size, stride=old.stride, padding=old.padding, bias=old.bias is not None, ) with torch.no_grad(): # 三个输入通道的权重取均值,保持输出响应的量级不突变 new.weight.copy_(old.weight.mean(dim=1, keepdim=True)) model.conv1 = new return model

用mean(除以 3)比sum更稳妥。用sum的话第一层输出幅值会变成三倍,后面接 BN 的话 BN 能吸收掉,但如果你同时冻结了 BN,就开始出问题。mean相当于假设三个颜色通道对灰度图的贡献均等,刚好对上“灰度 = 三通道等权”的定义。

这条路在 NEU-DET 上的收益不算大,通常 1~2 个点,主要价值在计算量——第一层卷积的输入通道从 3 降到 1,理论 FLOPs 降到三分之一,对边缘部署有意义。如果你只是做研究复现,路线 A 完全够用,别在这上面耗时间。但要提醒一句,改第一层之后,预训练权重的“适配程度”是下降的,如果你的训练轮数少于 50,路线 B 可能反而不如路线 A,因为可迁移的先验被削弱了。

4.2 增强的边界:哪些操作会直接毁掉缺陷

NEU-DET 上我见过最典型的翻车是:为了涨点,把增强开满,结果 mAP 不升反降,还找不到原因。这里列一下我在六类缺陷上验证过的增强取舍。

增强建议强度判断依据
水平翻转0.5缺陷无左右手性,安全
垂直翻转0~0.5俯拍图像可用,产线相机有倾角时慎用
随机旋转±10°超过 ±15° 框要重新算,且形态可能不真实
亮度/对比度±20%模拟产线光照变化,有物理意义
高斯噪声σ ≤ 0.02模拟传感器噪声,过量会淹没龟裂的微弱纹理
高斯模糊慎用麻点、龟裂靠高频细节,模糊直接抹掉判别依据
Mosaic0~0.5小目标密集,切块后常剩半个目标
Cutout / 随机遮挡不建议缺陷本身就是局部特征,遮挡等于删标签
色彩抖动关灰度输入无意义

最需要警惕的是模糊和 Cutout。龟裂这类缺陷的信号本身就是低对比度的高频纹理,一次 3×3 高斯模糊就能让它退化到和背景噪声同量级,模型学到的全是伪影。Cutout 更直接——NEU-DET 的缺陷经常只占图像一小块,随机挖掉一块,有五成概率正好挖在缺陷上,标签还在,目标没了,这是标准的毒样本。

实际做法是:从零开始调增强时,一次只加一种,跑三次不同 seed,看三次的 mAP 均值和方差。如果均值涨了但方差也涨了很多,说明这类增强对样本的扰动不均,要降强度。我一般会把seed设成 42 / 43 / 44 各跑一遍,取均值报结果,单次结果不做结论。

4.3 类别不平衡与难例:把 crazing 的 AP 单独拎出来看

六类的图片数一样,但框数差很多,麻点可能占全部框的四成,斑块只占一成。更关键的是难度不均——龟裂的 AP 常年垫底,往往比第二低的类低 10 个点以上。这属于难度不平衡,不是数量不平衡。

处理思路有三个层次,成本从低到高:

第一层,先确认是不是分辨率问题。把imgsz从 320 提到 384,只测龟裂的 AP。如果明显涨了,那就是小目标被压没了,不是 loss 的问题,别急着改网络。

第二层,对难类做针对性增强。龟裂的难点是对比度低,那就加重亮度/对比度扰动的幅度,或者对难类样本做局部裁剪放大后再贴回原图(copy-paste 类增强)。这类操作要注意贴回的位置不能和已有标注重叠,IoU 超过 0.3 就跳过。

第三层,才动 loss。常见做法是给回归分支加权,或者用 focal loss 压制大量易分的负样本。NEU-DET 上因为背景占比不算极端,focal 的收益通常很有限,反而是 OHEM(挑 loss 最高的那批样本优先回传)在龟裂上有效果。

我的实操顺序永远是:调分辨率 → 调增强 → 调 loss。前两步改配置就行,第三步要改代码,成本和风险都高一档。见过太多人一上来就换 loss 函数,折腾两周发现真正的问题是输入尺寸设小了——这种血泪经验,早点知道能省很多时间。

5. NEU-DET 训练避坑:五个把 mAP 和产线指标同时拖垮的现场问题

5.1 数据与标注侧的两个坑

坑一:loss 一路降,mAP 死活不动,每一类 AP 都差不多低。

现象是训练曲线很漂亮,box_loss 从 2.x 掉到 0.8,但验证集 mAP@0.5 卡在 0.2 左右上不去,六类 AP 分布均匀地低。

原因几乎都是类别索引错位。转换脚本里的CLASSES顺序和data.yaml里的names顺序不一致,模型学的是一个置换过的标签空间。这种情况下模型确实在学习,只是学错了对象,所以 loss 会降,但预测框和 GT 的类别对不上,mAP 靠定位分撑着一点点。

解决:写一个断言,训练前把names读出来和CLASSES逐项比对,不一致直接抛异常。更省事的做法是 yaml 里的names直接从classes.py生成,不手写。

坑二:验证集 mAP 0.9,换一批真实图就崩到 0.4。

现象很明显,验证集指标好看得不像话,一上真实数据全线崩盘。

原因是划分泄漏。NEU-DET 原始数据里有些图是同一块钢板不同位置裁出来的,纹理高度相似。如果你在划分之前做了任何形式的数据扩充(比如把一张图切块成四张),然后对扩充后的数据随机划分,同一块钢板的切片会同时出现在训练集和验证集里,模型等于在背答案。

解决:划分必须在扩充之前做,并且按“原图 ID”分组划分,同一张原图派生出的所有切片只能落在同一个 split 里。分组键可以取文件名前缀,或者用感知哈希做一次近重复聚类,把相似的图聚成一组再按组划分。

5.2 训练与评估侧的三个坑

坑三:麻点和龟裂漏检严重,其他类正常。

现象是整体 mAP 尚可,但按类看,pitted_surface和crazing的 Recall 明显低于其他四类,预测框数只有标注框数的 0.6 倍左右。

原因通常是输入分辨率把细节抹掉了。200×200 的图 resize 到 640,放大 3.2 倍,插值过程相当于一次低通滤波,麻点的凹坑边缘和龟裂的细微纹理被平滑成灰雾。模型看到的目标和 GT 框对不上,回归学不动,最后干脆不预测。

解决:把imgsz降到 320 或 256,重新对照。如果还要更高分辨率,就别用插值放大,改用切片推理——把小图 padding 到目标尺寸而不是拉伸,或者对超大原图用滑窗切块、分块检测再合并。

坑四:训练到一半报invalid label或 loss 突然变 NaN。

现象是前几十个 epoch 正常,打开 mosaic 之后偶尔崩,或者训练日志里出现坐标超出 [0,1] 的警告。

原因是增强管线里的几何变换把框推出了图像边界。随机旋转、随机裁剪、mosaic 拼接,只要框有一角出了画幅,宽高就可能变负,归一化坐标就非法。

解决:在增强之后、送进 loss 之前加一道过滤,把w <= 0或h <= 0的框丢掉,并把坐标夹到 [0,1]。同时把旋转角度收到 ±10°,mosaic 的中心点抖动范围收窄,从源头减少越界概率。

坑五:同一份配置跑三次,mAP 差 6 个点。

现象不用解释,每次结果都不一样,不知道该信哪个。

原因是小数据集加随机性叠加。1800 张图、batch 32,一个 epoch 只有 45 个 iteration,随机种子、数据加载顺序、增强的随机性,任何一处没固定都会放大成指标波动。另外如果验证集只有 360 张,单张图的预测变化就能影响 0.3 个点的 mAP。

解决:固定seed、关掉deterministic=False之外的异步加载、把workers设成固定值;更重要的是接受波动,报结果时报三次的均值和标准差,别拿单次最好成绩去和别人比。三次标准差如果在 3 个点以上,说明验证集太小,考虑做 5 折交叉验证,或者把验证比例提到 0.25。

6. 从验证集到产线:跨域自检与一个能省一半排查时间的技巧

NEU-DET 上的指标和真实产线之间,永远隔着一层域差异。我在做完基线之后,会强制自己走一遍小规模跨域自检:找二三十张来自不同光照、不同相机、甚至不同钢种的真实图,人工标一遍,当成一个“野生测试集”。这个集子不参与任何训练和调参,只在最后跑一次。

跑完看什么?不只看 mAP,要看三个更细的数:一是各类的预测框数偏差,如果斑块类偏差超过 1.5,说明模型在新域上过度响应,大概率是把氧化皮或者光照不均当成了斑块;二是高置信误检的图,逐张看,把误检的形态记下来,往往能反推出训练集里缺了什么负样本;三是低置信的正确检测,这批是“差一点就漏”的目标,产线上最危险。

部署侧有个小技巧值得单独说:别把 200×200 的小图拉伸到 640 做推理,改成 padding 到 640 或者用滑窗。拉伸会把缺陷的宽高比改掉,而模型的回归分支是在原比例上训出来的,比例一变,框就偏。如果产线原图很大(比如 2048×2048),用滑窗切块、逐块检测、最后按 IoU 做跨块 NMS 合并,比直接缩到 640 的召回高得多。这个操作的成本只是推理时间线性增加,但换来的小目标召回提升往往超过换骨干网络。

上线前的自检清单我固定成这几项,每次发布前过一遍:

检查项通过标准不通过时的第一动作
类别映射一致性names 与训练时逐字一致重新生成配置文件
输入预处理与训练时的 resize/padding 方式完全相同对齐预处理,别在推理侧“优化”
归一化参数与训练一致(均值方差、是否 /255)检查推理脚本
置信度阈值在野生测试集上标定,不沿用默认 0.25扫 PR 曲线选工作点
框数偏差各类在 0.8~1.3 之间回到分辨率和小目标增强
推理耗时满足产线节拍,含前后处理先砍输入尺寸,再考虑换骨干

最后说个我自己的习惯。每次调完一轮参数,我会把配置、划分文件、随机种子、以及那一轮的三次结果写进一个experiments.md里,只记三行:改了什么、均值±标准差、下一步想试什么。一开始觉得麻烦,被“这个参数我上次是不是试过”坑过几次之后,这份记录成了我唯一敢信的后悔药。NEU-DET 这么小的数据集,真正的门槛从来不在模型结构上,而在你能不能把每一次改动都变成可复现、可对比的一步。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 16:54:34

STM32+RC522读卡失败?SPI时序、寄存器初始化与状态机避坑指南

简介&#xff1a;本资源是基于STM32F407微控制器与MFRC522 RFID模块的完整嵌入式项目&#xff0c;面向嵌入式初学者及STM32 HAL库实践者&#xff0c;解决RFID非接触识别系统从驱动开发、SPI通信实现到业务逻辑集成的一站式学习需求&#xff0c;适用于智能门禁、校园卡读写等典型…

作者头像 李华
网站建设 2026/10/28 16:54:23

Python+OpenCV实现高效签名识别与合同管理

1. 项目概述&#xff1a;当Python遇上签名识别合同管理一直是企业法务和行政工作中的痛点&#xff0c;传统纸质合同存在归档困难、真伪难辨等问题。这个Python项目通过签名识别技术&#xff0c;实现了合同电子化管理的最后一公里突破。我在金融行业做了五年风控系统开发&#x…

作者头像 李华
网站建设 2026/10/28 16:53:44

从spec coding到harness:Agent开发范式转移的核心剖析

最近有好几个正在做 Agent 项目的朋友&#xff0c;不约而同跑来问我同一个问题&#xff1a;能不能像以前做单点需求那样&#xff0c;先写一份详细的 spec&#xff0c;然后让模型照着生成 Agent 代码&#xff1f;我的回答基本都是——能做&#xff0c;但如果你把这当成 Agent 开…

作者头像 李华
网站建设 2026/10/28 16:53:39

完全本地部署:开源声音克隆与工作流引擎实战指南

1. 为什么我要折腾一套完全本地的语音工作室去年帮一个做有声书的朋友处理一批旁白&#xff0c;他图省事把稿子丢进了某个在线语音合成平台&#xff0c;结果平台条款里写着“上传内容可用于模型优化”。虽然最后没出什么大问题&#xff0c;但那种把素材交出去的不踏实感&#x…

作者头像 李华
网站建设 2026/10/28 16:51:54

LLM工程化落地:从GGUF内存映射到概率空间监护

1. 这不是“学LLM”&#xff0c;而是重建你对AI的认知坐标系很多人点开“深入学习LLM”这类标题&#xff0c;下意识以为要啃《Attention Is All You Need》原文、推导Transformer的梯度反向传播、或者手写一个mini-GPT。我试过——三个月后模型跑通了&#xff0c;但面对真实业务…

作者头像 李华
网站建设 2026/10/28 16:48:10

AI Agent实战经验:记忆、工具调用与并发压测避坑指南

最近一段时间&#xff0c;我把大量精力都扑在AI Agent上。从最早用大模型API做简单的文本生成&#xff0c;到后来尝试让模型自己决定调用哪些工具、自己拆分任务&#xff0c;再到折腾多Agent协作和并发压测&#xff0c;这里面的坑&#xff0c;比我过去几年写业务代码踩的还要多…

作者头像 李华