三年前我第一次被拉到某钢厂冷轧精整线的尾端,站在那条跑得飞快的带钢旁边,手里攥着一份“人工开卷检查”的排班表。检查工拿着手电筒趴在带钢上找缺陷,一卷几千米,眼睛盯到发花,漏检全靠运气,一卷判级判错就是几万块的损失。那一刻我脑子里只有一个念头:这事儿必须交给深度学习来做,靠人眼和传统算子已经顶不住了。基于深度学习的钢铁缺陷识别,说白了就是用卷积网络代替人眼,在带钢、板坯、棒材甚至铸坯表面上自动找出裂纹、夹杂、划痕、麻点这些毛病,并且把位置、类别、尺寸一起报出来。它解决的核心问题不是“能不能看见”,而是“在每秒几米的生产节拍下、在油污粉尘和反光干扰下,稳定地把缺陷和正常纹理分开,同时把过杀率压到产线能接受的水平”。
这套东西适合谁来参考?如果你是在工厂里做自动化改造的工程师,看第三、五章就够上手;如果你是做视觉算法的,第二、四章的数据和调参细节更对胃口;如果你只是刚接触工业质检的学生或者转行者,前面两章的背景和路线对比能帮你少走半年弯路。我下面写的是我自己从数据集搭建、模型训练到产线部署踩过来的完整链路,包含大量“文档里不会写、但现场一定会遇到”的细节。很多环节的结论和网上教程是反着来的,比如数据增强不能随便翻转、评估指标不能只看准确率、模型精度往往不是瓶颈而存储带宽才是。
1. 先想清楚:钢铁缺陷检测为什么必须换赛道
1.1 传统机器视觉方案的天花板到底在哪
很多工厂在换深度学习之前,其实已经上过一套基于传统算子的视觉系统:打一束条形光,用高分辨率相机拍灰度图,然后做中值滤波、Sobel 边缘、阈值分割、形态学闭运算,最后按面积和长宽比判缺陷。这套方案在缺陷对比度高、背景干净的场景下确实能跑,比如某些镀层表面的明显划伤。但只要换个钢种、换一批来料、换一个班次的照明状态,阈值就得重调,调完这一批好使,下一批又崩。
根本原因在于,钢铁表面缺陷的“特征”根本不是几何特征。裂纹可能只有几个像素宽、灰度差不到 10 个灰阶,和轧制纹路、氧化皮残留、水渍在低层特征上几乎无法区分。传统算子做的是“人工定义规则”,而人的规则表达能力有上限;深度学习做的是“从数据里学规则”,它能利用纹理的上下文、缺陷的走向连续性、周边区域的统计分布这些很难用公式写出来的东西。我做过一个对比:同一批含夹杂和氧化皮压入的样本,传统方案在调到最优阈值时召回率 76%、过杀率 18%;换成一个只有 4.7M 参数的轻量分类网络,召回率 93%、过杀率 6%。这个差距不是调参能补上的,是方法论层面的代差。
但我也要说句公道话:传统算子不是没用。在整套系统里,它最适合干“粗定位”和“异常初筛”的活——用简单的行列灰度投影、局部方差突变先把可疑区域圈出来,把送给神经网络的图像面积压缩到原来的 1%~5%。这一步省下来的算力,比你在模型上抠十天参数都值钱。
1.2 工业现场的四道硬约束,决定了你所有的技术选型
做学术数据集调模型,你只需要关心 mAP;做产线系统,你要同时被四根绳子拴着,任何一根断了系统就废了。
第一根是节拍。精整线带钢速度常见 2~5 m/s,高端线更快。假设检测精度要求 0.1 mm/pixel,线速度 3 m/s,那么线阵相机的行频必须是 3000 / 0.1 = 30000 行/秒,也就是 30 kHz。这个行频下,单台 8K 灰度相机每秒吐出约 8192 × 30000 × 1 字节 ≈ 245 MB 的原始数据,双相机拼一幅 16000 像素宽的全幅图,数据量直接翻倍。你没有可能把这玩意儿全存下来,更没可能全送进 GPU。所以架构上必须做“选择性留存 + 分级筛查”,这一点后面第五章会展开算。
第二根是精度。客户不会跟你说“mAP 要到多少”,他们说的是“漏检率低于 0.5%,过杀率不高于 5%”。注意这两个指标是互相拉扯的,你把置信度阈值调低,漏检下去了,过杀立刻上来,产线因为频繁误报停机,班组长第二天就来找你。真正可用的工作点,是在过杀率约束下把召回做到最高,而不是追求某个单一指标的漂亮数字。
第三根是光照与污染。钢厂环境里镜头一天不擦就一层灰,轧机乳化液飞溅、氧化铁皮粉尘、水雾,全都会落在镜头和光源罩上。这意味着你的模型必须对“整体对比度缓慢下降”这件事有鲁棒性,而不是只在你实验室那套打光下有效。
第四根是样本。缺陷是小概率事件,一条线一天可能就出几十个缺陷,你要凑够每类几千张标注样本,往往要攒几个月甚至跨产线收集。所以少样本、类别不均衡、标注成本,这三件事从一开始就得设计进方案里,不能等训练崩了再补。
2. 数据集这件事,决定了项目八成的成败
2.1 公开数据集只能用来验证思路,别指望它上线
业内流传比较广的公开钢材表面缺陷数据集,一般是六类典型缺陷、几百到一千多张图、200×200 左右的灰度图,类别包括夹杂物、斑块、麻点、氧化皮压入、划痕、裂纹。拿来跑通流程、对比几个骨干网络、验证损失函数有没有写错,它非常好用。但我必须直说:拿它训出来的模型直接上线,一次都不会成功。
原因有三个。第一是成像条件完全不同,公开集是离线采集、打光均匀、背景干净的裁剪块,产线是线扫、有反光带、有周期性轧制纹路。第二是缺陷尺度分布不同,公开集里缺陷基本占满画面,产线上一个 0.3 mm 的点状麻点在 512×512 的图里可能只占 3×3 个像素。第三是类别定义不同,公开集的“斑块”和产线上的“氧化皮压入”在视觉上高度相似,你按公开集的标签体系训练,上线后必然出现大量类别混淆。
我的做法是:公开集只用来做两件事——快速验证训练脚本和损失函数是否正常(loss 能不能收敛、能不能过拟合一个小批次),以及做骨干网络的初步选型对比。真正的训练集,必须从目标产线自己采。
2.2 类别定义与标注规范,这是最容易翻车的地方
你现在让三个老师傅去标同一批图,他们给出的标签一致率可能只有 70%。这不是他们不专业,是缺陷本身的边界就模糊。所以正式标注之前,必须先把标签体系定死,写成一张表,标注人员照着执行。
| 缺陷类别 | 视觉特征 | 标注边界规则 | 常见混淆对象 |
|---|---|---|---|
| 裂纹 | 细长、走向连续、灰度偏暗、两端渐细 | 按可见连续性框完整条,断开超过 5 mm 拆成两个 | 划痕、轧制纹 |
| 夹杂 | 块状或条带状、内部灰度不均、边界清晰 | 沿外轮廓紧贴框选,不含周边过渡区 | 氧化皮压入、斑块 |
| 划痕 | 直线或弧线、宽度均匀、常成组平行 | 只框划痕本体,不含机械压痕 | 裂纹、导板擦伤 |
| 麻点 | 密集点状、直径常在 1 mm 以下 | 密集区整体框一个,稀疏点逐个框 | 氧化铁皮颗粒、水渍 |
| 氧化皮压入 | 不规则片状、边缘参差、灰度深浅混杂 | 整片框选,允许包含内部正常区 | 夹杂、斑块 |
| 辊印 | 周期性横向条纹、间距与辊径对应 | 整条周期性区域框选,标注周期 | 振动纹、来料原始条纹 |
规范之外还有两个动作必须做。一是双人交叉复核,任何一张图至少两个人标过,不一致的进仲裁队列,由工艺工程师拍板。二是负样本要主动收集,很多人只标缺陷,结果模型没见过“长得像缺陷的正常纹理”,一上线到处误报。我通常会按 1:3 到 1:5 的比例加入难负样本,专门挑那些曾经误报过的区域重新标注为“正常”,这部分数据的价值比额外的正样本高得多。
提示:标注工具里一定要保留“不确定”这个选项。强迫标注员在二选一里猜,最后污染的是整个数据集,而且你事后根本查不出来哪些是猜的。
2.3 数据增强有边界,钢厂的图和自然图像不是一回事
网上讲数据增强的文章,一上来就是随机旋转、随机裁剪、色彩抖动、MixUp。放到钢铁缺陷上,其中一半是毒药。
随机旋转要禁用或者严格限制。轧制方向是有物理意义的,裂纹基本沿轧向延伸,辊印是横向周期性条纹,你把图转 90 度,裂纹变成横向,模型学到的方向先验就被破坏了。我一般只允许 ±5 度以内的小角度旋转,或者直接不转。
水平/垂直翻转要分情况。对于斑块、麻点这类各向同性的缺陷,翻转没问题;对于裂纹、划痕这种有明确走向的,翻转相当于凭空造了一批现实中不存在的样本,会拉低模型对走向的敏感度。稳妥做法是:只在各向同性类别上开翻转。
真正有效的是这几类:亮度和对比度的随机扰动(模拟光源老化和镜头污染)、高斯噪声和模糊(模拟对焦漂移)、随机遮挡小块(模拟水滴和粉尘遮挡)、以及针对小样本缺陷的 Copy-Paste——把标注好的缺陷实例抠出来,随机贴到正常背景上。Copy-Paste 对小样本类别特别管用,我用它把夹杂类的样本量从 300 张扩到 4000 张,那一类的召回直接从 68% 拉到 88%。但注意贴合边缘要做羽化和泊松融合,硬贴会产生明显的接缝伪影,模型会去学那个接缝而不是缺陷本身。
import random, cv2, numpy as np def industrial_augment(img, label, defect_cls): # 各向同性类别才允许翻转 isotropic = {"pitted", "patch", "inclusion"} if defect_cls in isotropic: if random.random() < 0.5: img = cv2.flip(img, 1) # 小角度旋转,绝不 90 度 if random.random() < 0.3: ang = random.uniform(-5, 5) h, w = img.shape[:2] M = cv2.getRotationMatrix2D((w / 2, h / 2), ang, 1.0) img = cv2.warpAffine(img, M, (w, h), borderMode=cv2.BORDER_REFLECT) # 光照扰动:模拟镜头污染导致的对比度衰减 if random.random() < 0.5: alpha = random.uniform(0.75, 1.15) # 对比度 beta = random.uniform(-18, 18) # 亮度偏移 img = np.clip(img.astype(np.float32) * alpha + beta, 0, 255).astype(np.uint8) # 高斯模糊:模拟对焦漂移与粉尘 if random.random() < 0.2: k = random.choice([3, 5]) img = cv2.GaussianBlur(img, (k, k), 0) return img3. 模型选型:先问要输出什么,再问用什么网络
3.1 分类、检测、分割三条路线,别一上来就上最贵的
选型的第一步不是挑骨干网络,是确定你要的输出粒度。这三条路线的成本差距能达到十倍以上,很多项目死在“明明只需要判有没有,却非要上分割”。
| 路线 | 输出 | 标注成本 | 推理开销 | 适用场景 |
|---|---|---|---|---|
| 图像分类 | 整张图的类别 | 最低,只标图级标签 | 极低 | 固定工位、缺陷必定落在视野中心、只需报警不需定位 |
| 目标检测 | 缺陷框 + 类别 + 置信度 | 中,需画框 | 中 | 大部分带钢表面检测,需要知道位置和尺寸 |
| 语义/实例分割 | 像素级掩膜 | 最高,需描边 | 高 | 需要精确计算缺陷面积、影响判级,如夹杂面积占比 |
我的经验是:先做分类,再做检测,分割按需。分类模型可以作为第一级粗筛,把“明显正常”的图挡掉,只把可疑图交给后面的检测模型,整个系统的吞吐能提升五到十倍。而分割只有在客户明确要求“输出缺陷面积用于判级”时才值得上,因为像素级标注的成本是画框的三到五倍,而且标注一致性更差。
检测模型上,工程上我更倾向单阶段的 YOLO 系列实现,选 n 或 s 这种小模型,理由是部署链路成熟、支持动态输入尺寸、导出 ONNX 后量化路径清晰。Faster R-CNN 这类两阶段方案精度在某些小目标场景确实略优,但推理链路复杂、后处理里的 NMS 参数在密集小目标上很难调,产线上不划算。分割就用轻量的 U-Net 变体或者基于 Transformer 的分割网络,前者参数少、对小数据更友好,后者在缺陷边界模糊时表现更好但要更多数据。
3.2 骨干网络与轻量化的取舍逻辑
骨干网络的选型要看你的算力预算和精度缺口。给一组我实测过的数字,输入 512×512 灰度图,单张 T4 上的推理耗时(FP16,含后处理):
| 骨干 | 参数量 | 单图耗时 | 缺陷召回 | 备注 |
|---|---|---|---|---|
| MobileNetV3-Small | 2.5M | 2.1 ms | 86.2% | 边缘设备首选,精度略欠 |
| ResNet18 | 11.7M | 4.8 ms | 91.5% | 性价比最高的基线 |
| ResNet50 | 25.6M | 11.3 ms | 93.1% | 精度提升边际递减明显 |
| EfficientNet-B0 | 5.3M | 5.6 ms | 92.4% | 小数据上容易过拟合 |
| 轻量 ViT 变体 | 5.7M | 7.9 ms | 92.8% | 需要更多数据,标注少时别碰 |
从 ResNet18 换到 ResNet50,参数量翻了一倍多,耗时翻了一倍多,召回只涨了 1.6 个百分点。这 1.6 个点在产线上值不值,取决于你的 GPU 预算和节拍余量。我的做法是先拿 ResNet18 打到瓶颈,如果瓶颈是“模型容量不够”再往上换;如果瓶颈是“数据标注质量不行”,换再大的网络也是白搭——这一点我吃过亏,有次硬上 ResNet101,训练集 loss 掉到 0.01,验证集纹丝不动,问题出在训练集里有一批标签标错了类别。
另外要提醒一句:工业图像很多是单通道灰度,别硬转成三通道喂给 ImageNet 预训练模型就完事。我的做法是保留三通道结构、把灰度值复制三份,这样能直接吃预训练权重;同时把第一层卷积的权重按通道求平均再复制回去,比随机初始化收敛快大概 30% 的 epoch。
3.3 细长缺陷和小目标,是这类任务的真正难点
钢铁缺陷里最难的不是块状缺陷,是裂纹。一条长 200 mm、宽 0.2 mm 的裂纹,在 0.1 mm/pixel 的图上就是 2000×2 像素,长宽比 1000:1。你用什么 anchor 都覆盖不了,常规的 3×3 卷积核在感受野里也几乎看不到它。
我试过三种解法,效果从差到好排列。第一种是加大输入分辨率,直接把 512 提到 1024,小目标确实好了一些,但推理耗时翻了四倍,节拍撑不住。第二种是改 anchor 比例,加上 1:8、1:12 这种极端长宽比,召回涨了 5 个点,但对不规则走向的裂纹依然无力。第三种是把图像切成沿轧向的长条 tile,比如 512×128,专门给裂纹类做一条独立的检测分支,和主分支的结果做融合。这个方案召回能到 90% 以上,代价是推理要做两次前向。最终我采用的是级联结构:主分支跑全图找块状缺陷,同时输出一个“疑似细长结构”的掩膜,只把这些掩膜区域的坐标送进第二条分支放大重检。综合耗时只增加了 18%,但裂纹召回从 71% 提到了 89%。
还有一个偏门但特别管用的小技巧:在通道维度上叠加梯度幅值图。把原图和它的 Sobel 幅值图堆成两通道输入,模型对边缘的敏感度会明显提升,尤其是低对比度的浅裂纹。这个改动只增加了第一层卷积的输入通道数,几乎不增加计算量。
4. 训练与调参:那些让你模型“看着能跑其实不能上线”的坑
4.1 类别不均衡,是所有工业质检的共性难题
正负样本比 1:100 是常态,某些稀有缺陷能到 1:2000。这时候用普通的交叉熵,模型会学到一个“全部判正常”的退化解,准确率还有 99%,看起来特别漂亮。
常规解法是 Focal Loss 或者加权交叉熵,但我实测下来,在钢铁缺陷上更有效的是难例挖掘 + 重采样组合拳。具体做法是:第一个 epoch 用普通交叉熵把所有样本过一遍,然后按每个样本的 loss 排序,取 loss 最高的前 20% 正样本和 top 5% 的负样本,组成一个“难例池”,后续训练时这个池子的采样概率放大 3 倍。Focal Loss 的 γ 参数我会设 1.5 而不是论文常用的 2.0,因为工业缺陷的难负样本(氧化皮质感)本身就很难,γ 太大容易把这些难负样本直接放弃掉,反而导致过杀率上升。
分割任务上,Dice Loss 和 BCE 的混合比单用任何一个都稳,比例大概 1:1。Dice 负责拉整体重叠度,BCE 负责稳住像素级分类的梯度。我在一个夹杂分割任务上试过纯 Dice,训练前期 loss 震荡得非常厉害,混了 BCE 之后曲线立刻平滑。
4.2 超参数不需要玄学,按数据量推就行
很多人调参靠试,我给一套我常用的推算逻辑。学习率:用带预训练权重的骨干时,backbone 的 lr 设为检测头的 1/10,初始值取 1e-3(AdamW)或 1e-2(SGD + momentum 0.9)。Batch size:能塞多大塞多大,但因为工业数据集往往只有几千张,batch 太大一个 epoch 只有几十步,BN 统计量也不稳,我一般控制在 16~32,配合梯度累积模拟大 batch。训练轮次:看验证集召回曲线的拐点,钢铁缺陷任务通常在 40~80 个 epoch 收敛,超过 100 还没收敛基本是数据或者标签有问题,别硬训。
学习率调度用余弦退火 + 3 个 epoch 的 warmup,warmup 这一条千万别省。工业数据集小,开头几步梯度方向噪声很大,不做 warmup 很容易一开始就把预训练权重带偏,后面怎么训都回不来。我遇到过最典型的一次,去掉 warmup 后模型前 5 个 epoch 的 loss 一直在 0.69 附近打转(二分类的随机水平),加回 warmup 立刻正常下降。
import torch, torch.nn as nn from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR def build_optim(model, backbone_lr=1e-4, head_lr=1e-3, warmup_epochs=3, total_epochs=60): backbone_params, head_params = [], [] for name, p in model.named_parameters(): (backbone_params if name.startswith("backbone") else head_params).append(p) opt = torch.optim.AdamW([ {"params": backbone_params, "lr": backbone_lr}, {"params": head_params, "lr": head_lr}, ], weight_decay=1e-4) warmup = LinearLR(opt, start_factor=0.1, total_iters=warmup_epochs) cosine = CosineAnnealingLR(opt, T_max=total_epochs - warmup_epochs, eta_min=1e-6) return opt, SequentialLR(opt, [warmup, cosine], milestones=[warmup_epochs]) # 类别加权:按频率的倒数开方,避免稀有类权重爆炸 def class_weights(counts, power=0.5): inv = 1.0 / torch.tensor(counts, dtype=torch.float32).clamp(min=1) w = inv.pow(power) return (w / w.sum() * len(counts)).clamp(max=8.0)注意最后那个clamp(max=8.0),这是我自己加的保险。某些稀有类样本只有几十张,按倒数算出来的权重能到 50 以上,训练直接发散。开方之后再截断,是我试下来最稳的组合。
4.3 评估指标别只看准确率,那玩意儿在工业上会骗人
产线关心的指标和学术界完全不是一套。我总结的对照关系是这样:漏检率对应召回(Recall),是红线指标,通常要求 99% 以上(注意是召回,也就是缺陷里被找出来的比例);过杀率对应假正率(False Positive Rate),是成本指标,通常要求 5% 以内;定位精度用 IoU 0.5 下的 mAP 衡量,主要影响后续自动判级;响应延迟是硬指标,超过节拍就是废的。
还有一个特别容易被忽略的指标:按卷统计的稳定性。单张图的指标好看,不代表一整卷几千米的检测稳定——可能某一段光照变了,模型就整段失效。所以我做验收时一定会按卷统计“每千米缺陷数”和“单卷内误报次数的方差”,方差大的模型在产线上是不可信的,哪怕它的平均指标更好。
5. 从实验室到产线:部署这一段才是真正的地狱
5.1 算力预算要先算清楚,再决定模型多大
我在第五章开头提过数据量,这里把它算完整。假设幅宽 1500 mm,精度 0.1 mm/pixel,则横向需要 15000 像素,用两台 8192 像素的线阵相机拼接覆盖。线速 3 m/s,行频 30 kHz。如果按 512×512 的 tile 做推理,每个 tile 覆盖 51.2 mm × 51.2 mm,那么每秒钟产生的 tile 数是:
横向 15000 / 512 ≈ 30 列,纵向 3000 mm/s / 51.2 mm ≈ 58.6 行/秒,合计约 1758 个 tile/秒。
就算单张推理只要 2 ms,1758 张也需要 3.5 秒的 GPU 时间,而实际只有 1 秒。所以你绝无可能对全幅全速做逐 tile 推理。可行的架构只有一种:两级筛查。第一级用极轻量的分类器(MobileNetV3-Small 级别,输入降到 128×128),对降采样后的图像做粗筛,耗时控制在 0.2 ms 以内,理论吞吐能到 5000 张/秒,把所有 tile 快速过一遍,只保留置信度落在可疑区间的 3%~8%。第二级才用高分辨率检测模型处理这些可疑 tile,此时每秒只需要处理 50~140 张,算力完全富余。
| 环节 | 输入 | 模型 | 单张耗时 | 每秒处理量 | 硬件 |
|---|---|---|---|---|---|
| 一级粗筛 | 128×128 | 轻量分类 | 0.2 ms | 5000 | 单张 T4 |
| 二级精检 | 512×512 | 小目标检测 | 4.8 ms | 200 | 单张 T4 |
| 分割(可选) | 512×512 | 轻量 U-Net | 9 ms | 110 | 单张 T4 |
这套架构还有个额外好处:一级粗筛的模型更新频率可以很高,因为它训练成本低、对光照漂移最敏感,出了新样本一周就能迭代一版;二级模型则保持稳定,不轻易动。
5.2 与产线联动,坐标映射比模型本身更容易出错
模型输出的是“图上的框”,产线要的是“第 3 号卷、距头部 1247.3 米、距操作侧 682 mm 处有一处长 15 mm 的裂纹”。这中间的坐标映射,是整个项目里最容易出低级错误的地方,而且错了很难发现——因为系统看起来在正常工作,只是位置全偏了。
映射链路是这样的:编码器脉冲 → 相机行号 → 带钢纵向位置;相机标定 → 像素坐标 → 横向物理位置;再叠加上焊缝跟踪信号,扣除活套和张力带来的位置漂移。每一步都要单独验证。我的验证方法很土但有效:在产线停机时,人为在带钢上贴一张带刻度的标定纸,让系统识别并输出坐标,然后拿卷尺去量,误差控制在 ±3 mm 以内才算过。
注意:焊缝信号丢失的那几百毫秒,坐标会整体漂移,缺陷会被算到错误的卷上。这类问题的表现是“某个卷突然报了几十个缺陷、下一个卷干干净净”,排查时先看焊缝跟踪日志,别一头扎进模型里。
推理加速这块,路径很标准:PyTorch 训练 → 导出 ONNX(注意固定输入尺寸,动态尺寸会拖慢 TRT 优化)→ 用推理引擎做 FP16 或 INT8 量化。FP16 基本不掉精度,速度提升 1.6~2 倍,无脑上。INT8 要小心,我用 500 张正常图 + 200 张缺陷图做校准集,量化后召回掉了 2.3 个点,过杀涨了 1.8 个点,最后是“一级粗筛用 INT8、二级精检用 FP16”才把指标捞回来。
# 导出 ONNX,注意固定 batch=1、固定输入尺寸 python export.py \ --weights runs/detect/best.pt \ --imgsz 512 512 \ --batch 1 \ --opset 12 \ --simplify \ --include onnx5.3 现场环境带来的三个“模型之外”的失效点
镜头污染是一次缓慢的正弦式衰减。刚擦完镜头那天,对比度最好,模型表现最佳;十天之后,整体对比度下降 15%,浅裂纹开始漏检。解决办法不是天天擦镜头(产线不会给你这个时间窗),而是在系统里加一个“图像质量监控”模块:实时统计每帧的灰度均值、方差、梯度能量,一旦低于基线一定比例就触发清洗提示,同时把这段时间的图像标记为“低置信区间”,在判级时给一个降级标记。这个功能上线后,客户抱怨“系统时好时坏”的声音基本消失了。
机械振动带来的是横向像素偏移,典型表现是缺陷框在同一个纵向位置上反复横跳。如果你定义了缺陷合并逻辑(同一位置的框要去重),横向抖动会导致同一个缺陷被算成两个,缺陷数量翻倍。我的处理是在后处理里加一个基于纵向距离的聚类,纵向间隔小于 20 mm 的同类框合并成一个,横向取加权中心。这个简单的后处理让缺陷计数准确率从 82% 提到了 96%。
光源频闪与相机行频不同步,会产生周期性的明暗条纹,这类条纹在某些角度下和辊印高度相似。这种问题必须从硬件上解决——用相机触发信号同步驱动光源,不能靠模型去学。我见过一个团队花了两个月调模型想消除条纹影响,最后发现是光源驱动器没接同步信号。
6. 常见问题与排查速查表
6.1 典型故障对照表
| 现象 | 最可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 训练 loss 不下降,停在 0.69 附近 | 学习率过大、无 warmup、标签全为同一类 | 打印一个 batch 的标签分布 | 加 warmup,lr 降 10 倍,检查标签 |
| 训练集 loss 极低,验证集召回很低 | 标签错误、训练/验证集来自不同批次 | 抽 50 张验证集图人工看预测结果 | 重新核对标签,按批次划分数据集 |
| 上线后过杀率突增 | 光照变化、镜头污染、模型与现场成像不匹配 | 对比现场图和训练集图的灰度直方图 | 补现场负样本重训,加图像质量监控 |
| 缺陷位置整体偏移 | 编码器标定错误、焊缝信号丢失 | 用标定纸做坐标验证 | 重新标定,加焊缝信号冗余检测 |
| 同一缺陷被重复计数 | 横向振动导致框分裂 | 统计同一纵向位置的框数量 | 加纵向距离聚类后处理 |
| 裂纹类长期漏检 | 长宽比过大、输入分辨率不足 | 统计裂纹类召回 | 加长条 tile 分支或梯度通道 |
| 推理延迟超节拍 | 全幅逐 tile 推理、未做量化 | 用性能分析工具看耗时分布 | 改两级筛查,上 FP16/INT8 |
| 模型版本更新后指标回退 | 新数据集分布偏移、旧难例丢失 | 对比新旧版本在固定测试集上的表现 | 建立固定回归测试集,版本灰度的上线 |
6.2 几条用真金白银换来的经验
固定测试集必须冷冻,永远不许拿去训练。我在项目早期犯过一个错:每次补新样本,都顺手加进训练集,结果验证指标一直涨,上线毫无改善。后来才知道,我一直在用“见过的题”考自己。正确做法是留一个 500~1000 张的固定测试集,按钢种、按班次、按月份分层抽样,覆盖尽可能多的工况,这个集合只在版本发布前跑一次,任何情况下都不进训练。
模型的版本管理要和生产系统的灰度机制配合。我们现在的做法是新模型先跑“影子模式”——接实时数据做推理但不输出控制信号,只在后台记录它和当前生产模型的分歧,跑满一周、分歧率稳定在合理区间后才切换。这个机制救过好几次,有一次新模型在夜班时段(光照条件不同)召回掉了 12 个点,影子模式直接提前发现了。
别忽略“模型能解释”这件事。产线上的班组长不信任黑盒。我们用 Grad-CAM 做了一个简单的热力图叠加,缺陷框旁边能显示模型关注了哪块区域。这玩意儿对精度没任何帮助,但对项目推进的作用极大——老师傅看到模型关注的地方和他们的判断一致,才开始愿意相信系统。技术之外的说服工作,往往比调参更耗时间。
最后再补一个数据回流的设计。系统上线不等于结束,产线每天都会产生新的难例。我们在检测端做了一个“低置信度自动留图”的机制:任何置信度落在 0.3~0.7 区间的样本,连同上下文自动存盘并打上时间戳和卷号标签,每周由工艺人员复核标注一批,回灌进训练集。这个闭环跑起来之后,模型的月度迭代基本不需要额外组织采样,靠系统自己就能持续变好。这套东西听着简单,但真正在产线上跑通,从存储带宽、样本去重到标注任务的派发,每一步都得设计,我花了大概三个月才让它稳定运转起来。