简介:面向深度学习、机器视觉及工业无损检测领域的研究者与工程师,这份PDF资料提出一种基于改进U-Net的轮毂缺陷自动分割方案,针对轮毂X射线图像中裂纹、缩孔等缺陷检测场景,给出从数据预处理、模型结构优化到性能评估的完整技术思路。内容重点介绍将原始U-Net的最大池化替换为卷积操作,并加入Dropout层提升网络可靠性,同时配合数据扩充与归一化处理完成模型训练;实验数据表明,该方法DICE系数达0.8554,SSIM系数达0.9655,单张识别速度约3 ms,具备较好的工程参考价值。资源包含1个PDF文件,压缩包大小815KB,篇幅精炼、图文与公式齐全,适合作为学习U-Net图像分割及轮毂缺陷检测的文献资料。目前已有141人学习下载,可帮助读者快速掌握卷积神经网络在无损检测中的应用要点。
1. 为什么轮毂质检到了必须做像素分割的阶段
轮毂出厂之前,表面缺陷检测是最后一道关卡。常见的做法是人工目检或者用目标检测网络画框,但轮毂这类铸造铝合金件上的气孔、缩松、裂纹在图像里往往只有几十个像素,画一个矩形框会把大量背景圈进去,后续想做缺陷面积统计或者给打磨机器人出路径,根本没有可用的几何信息。基于U-Net卷积神经网络的轮毂缺陷分割,解决的正是这个问题——把每个缺陷像素从背景里分离出来,输出一张和原图同尺寸的掩码图,缺陷边界、面积、形状一次拿到。这个方案适合产线上已经能稳定采集到清晰表面图像的团队,不管是从零搭建还是替换掉老的检测框方案,它都是目前落地性价比最高的路线。
U-Net在这里不是唯一解,但它是工业缺陷分割里最不容易翻车的那一个。它的编码器-解码器结构和跳连接设计,能让网络同时保留全局语义和边缘细节,训练数据只需要几十张标注图就能达到可用水平,这对标注成本敏感的轮毂产线太关键了。下面从缺陷类型、数据准备、网络改法、训练参数到部署踩坑,把一条能真正跑起来的链路讲透。
2. 轮毂缺陷分割和通用语义分割的差异:先认清任务再选网络
2.1 轮毂表面缺陷有哪些,为什么通用分割模型直接搬会失效
轮毂是低压铸造件,后续还要机加工和涂装,所以表面缺陷来源很杂。常见的有三类:铸造缺陷(气孔、缩松、冷隔)、机械损伤(划痕、磕碰、压伤)、以及涂装前处理留下的异物或氧化斑。这三类在图像上的表现完全不同——气孔是圆形暗斑,边缘模糊且有灰度渐变;划痕是细长条,方向随机,可能横跨整个轮辐;氧化斑是片状低对比度区域,和背景灰度差经常在10个灰度值以内。
把通用语义分割模型直接搬过来的典型问题是:模型在背景占比极高的图上严重偏向预测背景。轮毂表面缺陷占整幅图像的面积通常不到0.5%,拿标准U-Net配合普通交叉熵损失去训,网络很快就学会“全输出背景”——因为这样loss已经很低了。这不是U-Net结构的错,是数据分布和损失函数不匹配。所以做轮毂缺陷分割,第一个动作不是改网络,是先建立对数据分布的认知,后面每一步都围绕“小目标、低对比度、高类不平衡”这三个特征展开。
另一个差异在成像方式。自然场景分割用的是RGB照片,轮毂产线实际多用黑白工业相机加环形光源或低角度光,拍出来是单通道灰度图。这带来一个好处——通道数从3变1,计算量直接降三分之一,模型可以做得更轻;同时因为打光角度固定,缺陷的灰度特征比自然场景稳定得多,网络不用学复杂的颜色不变性。所以别把ImageNet上预训练的RGB三通道权重直接拿过来用,输入层要改,预训练权重相应也需要处理。
2.2 U-Net vs FCN vs DeepLab:为什么U-Net更适合产线迭代
给轮毂做缺陷分割,业界常对比的无非是FCN、DeepLab、U-Net这三类。FCN是分割网络的老祖宗,结构简单,但它把编码器的特征图直接上采样回原尺寸,细节恢复能力差,小缺陷边界往往是糊的。DeepLab系列靠空洞卷积扩大感受野,在物体边缘的精细度上做得很好,但模型体量大、推理慢,对产线常见的GPU如RTX 3060或3070来说,跑1080p图像到30FPS以上有压力。而且DeepLab对训练数据量要求更高,轮毂缺陷样本往往只有几百张,容易过拟合。
U-Net最贴合这个任务的原因有三个。第一,跳连接把编码器每个stage的细节特征直接拼到解码器对应stage,这让小缺陷的边缘信息不会在深层特征里丢失。第二,U-Net是典型的编码器-解码器对称结构,编码器可以用预训练的ResNet或VGG替换,解码器保持原样,这种“半迁移”在数据量不足时很管用。第三,它有大量的轻量变体,比如MobileNetV3做编码器的版本,能在几乎不损失mIoU的前提下把推理时间压到原来的三分之一,产线改造时不用换硬件。
选型上我建议分两种情况:缺陷种类多、纹理复杂,用ResNet34做编码器的U-Net,特征提取能力强,能hold住气孔和划痕这种形态差异极大的缺陷;缺陷单一、只需要检测气孔或缩松,直接原版U-Net或MobileNetV3版本就够了,训练更快,部署更省心。下面的实现也是按这两种情况给参数。
2.3 标注格式和生产环境的转换:从矩形框到像素掩码
如果产线上已经跑着目标检测,手里有VOC格式的XML或者COCO格式的JSON标注,转换成U-Net需要的PNG灰度掩码要特别小心。VOC的矩形框标注只能用来做分类和检测训练,不能直接生成像素级掩码——框内背景太多,直接当ground truth会让网络学习到“矩形框内全是缺陷”的错误语义,推理时缺陷边界会比真实偏大一圈。
正确做法是拿已有的检测框做初筛,然后用标注工具在框内重新精细勾勒缺陷轮廓。这一步没有捷径,缺陷边界标注的精细度直接决定模型的上限。标注时一般约定:边界贴合缺陷灰度突变处,气孔类缺陷沿着边缘最暗像素画,划痕类缺陷宽度按实际像素宽度标,不放大不缩小。团队标注一致性比单个标注员精度更重要,建议写一份标注规范,拿几张典型缺陷图做标准答案,多人标注时定期做一致性校验。
3. 数据准备与预处理:小样本下如何喂饱U-Net而不喂错
3.1 图像采集与样本清洗:先把成像稳定性做出来
U-Net对输入图像的灰度分布敏感,尤其轮毂这类表面反光不均匀的物体,同一缺陷在不同光照下灰度差可能超过50。产线采集时一定要固定相机参数和光源。相机曝光、增益、光圈全部锁死,光圈建议收一档保证景深覆盖轮毂弧面,光源亮度调到缺陷对比度最明显的位置,可以用一张标准缺陷样件反复调,目标是让缺陷和背景的灰度差达到最大。若使用了动态光源或频闪,必须做光源亮度校准,否则每次采集的图灰度分布漂移,模型在训练集上表现不错,到现场就崩。
清洗样本时我一般按三个原则筛选:模糊图直接扔,运动模糊或对焦不准的图即便标注了也是噪声源;灰度直方图异常的图要查原因,常见的是表面油污反光导致局部过曝,这种图要么改进光源要么去掉,不能用;缺陷不完整的图——缺陷被图像边界切断——建议保留并正常标注,因为推理时滑窗切patch天然会产生大量边界截断情况,训练时见不到这类样本,推理时就会在这些patch上误判。
3.2 大图切成patch来训练:解决显存瓶颈和不平衡问题
轮毂产线相机分辨率通常500万像素起步,高端点的1200万像素也很常见。整图直接送进U-Net,且不说显存,单是下采样四次之后小缺陷可能就消失了。常见的做法是裁patch训练,把大图切成一堆固定尺寸小图,既解决了显存问题,又等价于给每个缺陷放大了倍数。
下面是一段典型的数据预处理脚本,核心逻辑是滑窗裁patch并同步生成对应的mask。
import cv2 import numpy as np def extract_patches(image, mask, patch_size=512, stride=384): """ 从大图和对应mask中切patch image: (H, W, C) 灰度图或三通道图 mask: (H, W) 像素值0为背景,255为缺陷 """ h, w = image.shape[:2] patches_img = [] patches_mask = [] # 滑窗采样,stride小于patch_size让相邻patch有重叠 # 重叠区域保证缺陷如果恰好落在patch边缘,在相邻patch中仍是完整的 for y in range(0, h, stride): for x in range(0, w, stride): # 边界处对齐,保证不出现小于patch尺寸的残块 y_end = min(y + patch_size, h) x_end = min(x + patch_size, w) y_start = max(0, y_end - patch_size) x_start = max(0, x_end - patch_size) img_patch = image[y_start:y_end, x_start:x_end] mask_patch = mask[y_start:y_end, x_start:x_end] # 丢弃完全没有缺陷的patch,减少背景占比 # 但保留其中一部分,比例控制在20%左右,防止模型没见过纯背景 if mask_patch.sum() == 0 and np.random.random() > 0.2: continue patches_img.append(img_patch) patches_mask.append(mask_patch) return patches_img, patches_mask这段代码做了两件重要的事。第一,滑窗步长小于patch尺寸,让相邻patch之间有重叠,保证切在patch边缘的缺陷在另一个patch里是完整的。第二,对全背景patch做了随机丢弃,只保留约20%的纯背景样本,让模型见过背景,又不至于被背景淹没。
参数选择上,patch尺寸512是性价比比较高的值——512见方的图在U-Net四次下采样后是32×32的特征图,小缺陷还保留着足够的响应;显存方面,batch size 8在12GB显存的卡上训练原版U-Net没有压力。如果缺陷非常小,比如只有十几个像素的气孔,可以试试384的patch尺寸,效果往往比512好,推理时也按同样尺寸切。stride一般设为patch_size的75%左右,过小的stride会产生大量重复patch,训练效率低;过大的stride又会让patch之间覆盖不足,推理拼回来时容易出现接缝伪影。
3.3 数据增强怎么配:轮毂缺陷分割的增强策略和通用方案不同
通用语义分割的增强三板斧是随机翻转、随机缩放、色彩抖动,放在轮毂缺陷上要改一版。轮毂表面有规则的纹理——比如轮辐的筋线、螺栓孔——这些结构和缺陷长得很不一样,但如果增强过度,比如随机旋转90度,模型可能把纹理和缺陷搞混。常见做法是只用水平翻转和垂直翻转,不用旋转,因为轮毂的纹理方向是固定的,旋转会破坏先验。
亮度扰动要做但要克制。轮毂图像是受控光源下拍的,推理时灰度分布基本恒定,过强的亮度增强反而会让网络学到对亮度变化不敏感的错误特征。我一般把亮度扰动幅度控制在10%以内,对比度扰动控制在5%以内,本质是模拟光源的微小波动。高斯噪声适量加,模拟传感器噪声,方差不要超过0.01,否则缺陷边界会被噪声糊掉。弹性形变这类增强在医学图像分割里常见,轮毂缺陷不建议用,因为轮毂是刚性物体,形态不会变形。
下面给一组经过实操验证的增强配置,用imgaug库实现。
import imgaug.augmenters as iaa def get_augmenter(): """ 轮毂缺陷分割专用增强管线 原则:保持几何结构不变,模拟光照和噪声微变 """ return iaa.Sequential([ # 翻转不改变缺陷形态特征,浅层特征能正确学习 iaa.Fliplr(0.5), iaa.Flipud(0.5), # 亮度扰动幅度小,模拟光源微小漂移 iaa.Multiply((0.9, 1.1), per_channel=False), iaa.Add((-10, 10)), # 高斯噪声模拟传感器噪声 iaa.AdditiveGaussianNoise(scale=(0, 0.01*255)), # 轻微模糊,容忍对焦微偏差 iaa.GaussianBlur(sigma=(0.0, 0.5)) ], random_order=True)这套增强管线没有加旋转、裁剪缩放、弹性形变,是刻意为之。轮毂缺陷分割的难点在于小目标和低对比度,不在于形态变化,增强应该集中在让模型对光照和噪声更鲁棒,而不是让它见过更多不存在的形态。random_order=True让这些增强的排列顺序随机,避免模型过拟合到固定的变换序列上。
4. 训练U-Net模型的完整配置:从预训练权重到损失函数
4.1 编码器怎么选:ResNet34为主,轻量化按需切换
U-Net的编码器决定特征提取的上限。原版U-Net用卷积加池化堆叠,参数量不大但特征提取能力一般。换成预训练ResNet34之后,训练收敛速度和最终精度都有明显提升,尤其是划痕这类低对比度细长缺陷,ResNet34的深层语义特征能捕捉到大范围上下文,避免只看局部灰度相似区域导致误检。
实现上注意两个坑。第一,ResNet34预训练权重是RGB三通道的,轮毂图像如果是灰度图,输入层要改成单通道。常见做法是保留预训练权重里3通道的均值,把第一个卷积层的权重在通道维度上求平均复制成单通道,这样输入层改动后仍然能用预训练特征。第二,编码器部分要冻结BN层的统计量,因为产线数据量小,batch size往往开不大,BN统计量更新不稳会让训练震荡。实现方式是把编码器里所有BatchNorm层设为eval模式。
import torch import torchvision.models as models import torch.nn as nn def get_encoder(pretrained=True, in_channels=1): """ 构建U-Net编码器,支持灰度图输入 """ resnet = models.resnet34(weights=models.ResNet34_Weights.IMAGENET1K_V1 if pretrained else None) if in_channels == 1: # 关键操作:RGB三通道卷积权重求平均复制到单通道 # 这样保留了预训练学到的纹理特征,而不是随机初始化 original_conv = resnet.conv1 averaged_weight = original_conv.weight.mean(dim=1, keepdim=True) resnet.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) resnet.conv1.weight.data = averaged_weight # 取ResNet的前四个stage作为编码器 # 去掉后面的全局池化和全连接层 encoder_stages = [ nn.Sequential(resnet.conv1, resnet.bn1, resnet.relu), resnet.layer1, resnet.layer2, resnet.layer3, resnet.layer4 ] return nn.ModuleList(encoder_stages)这段代码的核心是输入层的通道适配。实现的原理是:灰度图可以看作三通道彩色图在通道维度上平均的结果,所以把预训练权重在通道维度上求均值,等价于让灰度图获得和彩色图相似的特征响应。这样做的效果比随机初始化输入层收敛快得多,特别适合缺陷样本少的情况。
如果产线对推理速度有硬指标,比如要求单张图像处理时间小于15ms,可以把编码器换成MobileNetV3或EfficientNet-Lite。以MobileNetV3-Large为例,计算量只有ResNet34的三分之一左右,mIoU掉2到3个点,但推理帧率能翻倍。没有硬实时要求前,我建议先拿ResNet34跑通再考虑优化,不要一开始就上轻量网络——调试期迭代速度比部署时那几毫秒更重要。
4.2 损失函数组合:BCE加Dice的权重怎么配
轮毂缺陷分割的损失函数直接决定了模型能不能学会“关注小缺陷”。单独用BCE,背景像素太多,损失被背景主导,小缺陷的梯度被稀释;单独用Dice Loss,小缺陷的Dice系数对预测误差极其敏感,训练早期梯度震荡剧烈,容易发散。常见的做法是BCE和Dice按权重相加,我一般给BCE权重0.3、Dice权重0.7。
这个配比背后的逻辑是:训练早期Dice Loss主导,让模型快速学会缺陷的大致位置;训练后期BCE帮忙精修像素级分类,把边界磨得干净一些。如果缺陷特别小,可以把Dice权重提到0.9,因为Dice天然对小目标更友好,但也别太极端——完全去掉BCE会让模型输出的置信度普遍偏低,不利于后续设置分割阈值。
import torch import torch.nn as nn import torch.nn.functional as F class BCEAndDiceLoss(nn.Module): """ 组合损失:BCE负责像素级精度,Dice负责缓解类别不平衡 """ def __init__(self, bce_weight=0.3, dice_weight=0.7, smooth=1.0): super().__init__() self.bce_weight = bce_weight self.dice_weight = dice_weight self.smooth = smooth def forward(self, pred, target): # pred: (B, 1, H, W) 未经过sigmoid的概率图 # target: (B, 1, H, W) 值域为0或1的掩码 pred_sigmoid = torch.sigmoid(pred) # BCE损失,reduction='mean'表示对所有像素求平均 bce_loss = F.binary_cross_entropy(pred_sigmoid, target, reduction='mean') # Dice损失:1 - Dice系数 # 注意:这里flatten成向量再计算,等价于全图范围内的Dice pred_flat = pred_sigmoid.reshape(pred_sigmoid.size(0), -1) target_flat = target.reshape(target.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) cardinality = pred_flat.sum(dim=1) + target_flat.sum(dim=1) dice_loss = 1.0 - (2.0 * intersection + self.smooth) / (cardinality + self.smooth) dice_loss = dice_loss.mean() return self.bce_weight * bce_loss + self.dice_weight * dice_lossDice Loss公式里加的smooth系数是防止分子分母都为零时除零报错,一般取1.0。如果目标里缺陷面积占比特别小(小于0.1%),可以把smooth调大到10左右,让梯度更平滑,避免训练初期Dice系数剧烈波动。
这里有个细节值得说:有些资料会在Dice公式里用平方求和,因为平方可以让大目标对损失贡献相对更小,在小目标分割时表现更好。但在轮毂缺陷上,我试过平方版本,收敛速度比非平方版本慢,精度没有明显提升,最终用了非平方写法。嫌调参繁琐的话,先按上面这份抄就行。
4.3 训练流程与超参数:batch size、学习率和早停策略
训练U-Net做缺陷分割,超参数不需要像训练分类网络那样精调,但有几个关键值必须先定好。
优化器建议用AdamW而不是SGD。AdamW对学习率的敏感度低,免去了手动调学习率策略的麻烦,在分割任务上收敛普遍更快。初始学习率取1e-4,这是ResNet34编码器加AdamW的常用安全值。如果预训练编码器的BN层被冻结了,可以给解码器部分设置更大一点的学习率,比如5e-4,加速新层收敛。
batch size以显存上限为准,但建议不要小于4。过小的batch会让BN统计量抖动,如果编码器BN冻结了还好,解码器的BN仍然会受影响。最省心的方案是batch size固定8,学习率1e-4,150个epoch内一定收敛。不需要用余弦退火,ReduceLROnPlateau就够用——验证集Dice系数连续5个epoch不涨就把学习率除以5。
早停策略我一般配两个条件:验证集Dice连续15个epoch不提升,或者训练epoch达到200,谁先触发谁停。注意Dice的提升曲线不是单调的,训练前30个epoch可能一直在震荡,需要耐心。保存模型时不要只看最后一个epoch的权重,要保存验证集Dice最高时的checkpoint,这是最实用的后悔药,否则训练中途波动一次就可能丢掉最好的模型。
import torch from torch.optim import AdamW from torch.optim.lr_scheduler import ReduceLROnPlateau def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0.0 for images, masks in dataloader: images = images.to(device) masks = masks.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, masks) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader) def validate(model, dataloader, criterion, device): model.eval() total_dice = 0.0 with torch.no_grad(): for images, masks in dataloader: images = images.to(device) masks = masks.to(device) outputs = torch.sigmoid(model(images)) preds = (outputs > 0.5).float() # 计算Dice系数 intersection = (preds * masks).sum() cardinality = preds.sum() + masks.sum() dice = (2.0 * intersection + 1.0) / (cardinality + 1.0) total_dice += dice.item() return total_dice / len(dataloader)验证集上的Dice系数是监控指标,不是最终考核指标。真实产线关心的是漏检率和误检率,这两个指标和Dice并不完全一致。Dice达到0.8以上时,可能仍然有大量小缺陷被漏检——因为小缺陷在Dice计算里贡献太小。所以训练过程中我习惯额外记录一个小缺陷子集的Dice,单独评估模型对小目标的敏感度,避免被整体Dice的“虚高”欺骗。
5. U-Net轮毂分割的避坑清单:训练到部署的5个常见问题
5.1 缺陷在patch里太小,模型直接学成背景
现象:训练的loss下降正常,验证集Dice也不错,但看预测结果时发现所有缺陷都没有被分割出来,输出全是全零的掩码。
原因:patch采样时虽然随机丢弃了部分全背景patch,但剩下的patch里缺陷占比依然极低——单个缺陷可能只占patch面积的0.1%。BCE+Dice组合损失对这种情况仍然不够敏感,Dice系数本身对极端小的目标给出的梯度很小。
解决:第一,训练前统计patch里缺陷像素的比例——如果大量patch的缺陷占比低于0.5%,考虑缩小patch到384或256,让缺陷在patch里相对变大。第二,缺陷的权重在损失函数里再额外乘一个系数,比如把缺陷像素的BCE权重放大5倍,相当于给模型一个放大镜。第三,最朴素的方案是复制包含缺陷的patch做重复采样——每个含缺陷patch多喂几次给模型,让模型在小批量训练里更频繁地见到它们。这套组合拳下来,小缺陷漏检的问题基本能兜住。
5.2 预训练BN层在微调时统计量崩掉
现象:训练时加预训练编码器后,前几个epoch的loss反而比不加预训练更高,收敛也变慢。
原因:预训练ResNet的BN层记录了ImageNet数据集的均值方差,轮毂灰度图的数据分布和ImageNet差异巨大。微调时BN层会持续更新统计量,但小batch size下统计量剧烈摆动,导致编码器输出的特征不稳定,解码器学到的映射也随之震荡。
解决:直接用代码把编码器里的BN层冻结,让它们保持在预训练状态。推理时BN的统计量用固定值,不随输入变化,特征提取行为变成确定性的。
def freeze_encoder_bn(model): """ 冻结编码器中的所有BatchNorm层 注意:解码器的BN层不要冻结,它们需要学习轮毂缺陷的分布 """ for name, module in model.named_modules(): # 这里约定编码器相关模块名包含'encoder' if 'encoder' in name and isinstance(module, torch.nn.BatchNorm2d): module.eval() # 使用预训练统计量,不再更新 for param in module.parameters(): param.requires_grad = False冻结BN层之后,如果发现收敛变慢,可以先解冻最后一层编码器的BN看看效果。如果验证集Dice提升,就保留解冻状态;如果变差,就冻回去。这属于调参阶段的常规试错,不丢人。
5.3 滑窗推理接缝处出现伪影,缺陷被判成两条
现象:推理时把patch预测结果拼回大图,发现缺陷本来是连着的,在patch边界处断开了,或者接缝处出现一条暗线。
原因:滑窗推理时patch边缘的预测置信度天然偏低——因为patch边缘的像素上下文不完整,U-Net看到的信息比中心区域少。直接拼接时,低置信度区域就表现为接缝伪影。
解决:推理时让相邻patch有足够重叠,重叠区域用加权平均融合而不是直接取一个patch的结果。常用做法是重叠区域的权重按三角分布——patch中心权重1,边缘权重0,这样接缝处的低置信度预测被相邻patch的高置信度预测“拉回来”。
import numpy as np def sliding_window_inference(model, image, patch_size=512, stride=384, device='cuda'): """ U-Net滑窗推理,带重叠区域加权融合 image: (C, H, W) 已经是预处理后的Tensor 返回: (H, W) 缺陷概率图,范围[0,1] """ model.eval() c, h, w = image.shape # 概率累加器和权重累加器 prob_map = np.zeros((h, w), dtype=np.float32) weight_map = np.zeros((h, w), dtype=np.float32) # 构造patch权重图:中心权重高,边缘权重低 patch_weight = np.ones((patch_size, patch_size), dtype=np.float32) taper = 16 # 过渡带宽度,像素 patch_weight[:taper, :] *= np.linspace(0, 1, taper).reshape(-1, 1) patch_weight[-taper:, :] *= np.linspace(1, 0, taper).reshape(-1, 1) patch_weight[:, :taper] *= np.linspace(0, 1, taper).reshape(1, -1) patch_weight[:, -taper:] *= np.linspace(1, 0, taper).reshape(1, -1) for y in range(0, h, stride): for x in range(0, w, stride): # 边界对齐,和训练时保持一致 y_end = min(y + patch_size, h) x_end = min(x + patch_size, w) y_start = max(0, y_end - patch_size) x_start = max(0, x_end - patch_size) # 裁剪对应的图像区域 patch = image[:, y_start:y_end, x_start:x_end].unsqueeze(0).to(device) with torch.no_grad(): pred = torch.sigmoid(model(patch)).squeeze().cpu().numpy() # 当前patch的有效区域(可能因为边界不足整块) len_y = y_end - y_start len_x = x_end - x_start # 对应权重图区域也要裁剪 weight = patch_weight[:len_y, :len_x] prob_map[y_start:y_end, x_start:x_end] += pred * weight weight_map[y_start:y_end, x_start:x_end] += weight # 归一化:加权平均 prob_map = prob_map / np.maximum(weight_map, 1e-7) return prob_map这段代码的要点是patch_weight的taper参数。taper越大,重叠区域的融合越平滑,但有效patch面积越小,推理次数会增多。taper取16到32都是合理范围,如果缺陷很大且接缝伪影严重,可以加大到64。注意拼接时最后一步是加权平均不是直接覆盖,这是消掉接缝伪影的关键。
5.4 标注边界不一致,模型学到“模糊正确”
现象:两个标注员对同一张图的同一条划痕标出来的边界差了3到5个像素,训练时模型预测的边界刚好落在两个标注的中间,看起来谁都不得罪,但实际上谁都不对。
原因:标注规范不明确,缺陷边界定义没有统一标准。不同的人对“缺陷边缘在哪里”的判断不同——是灰度突变中心还是突变起始点。
解决:在标注规范里写清楚边界判定规则,我常用的约定是:气孔类缺陷以最外圈暗像素为界,不包含过渡带;划痕类缺陷以灰度值降到中值的位置为界;氧化斑以与背景灰度差超过5个灰度值的像素为界。另外建议每张标注图过一遍“腐蚀膨胀一致性校验”——把标注mask腐蚀1个像素得到A、膨胀1个像素得到B,如果模型预测结果在A到B之间,就算合格。这本质上承认了标注本身有1像素的容忍度,评估时不过度惩罚模型。
5.5 产线新批次轮毂表面粗糙度变化,模型误检率飙升
现象:同一个模型,在A批次轮毂上误检率1%,到了B批次轮毂上误检率突然变成8%,而且误检的区域集中在轮辐表面。
原因:铸造批次之间表面粗糙度有差异,有的批次表面有细密的铸造纹理,灰度变化介于缺陷和正常背景之间,模型把纹理误判成了缺陷。
解决:第一时间采集新批次的无缺陷图像,跑一遍推理,统计误检率。如果误检率高,常见的做法是收集50张无缺陷图混入训练集,并标注为全背景。这是U-Net这类分割模型的常规补丁方案——让模型见过这种表面纹理,下次就不会当成缺陷了。如果新批次纹理变化太大,可能需要重新调整打光角度,这已经是工艺层面的事,模型层面只能尽量兜底。
6. 验证与部署落地的关键技巧:从混淆矩阵到模型导出
训练完的模型不能只看mIoU和Dice就上线,产线真正关心的指标是漏检率和误检率,这两个指标需要针对轮毂缺陷的特点单独核算。
核心做法是按缺陷尺寸分桶评估。把测试集里的缺陷按面积分成三档——小于100像素的小缺陷、100到1000像素的中等缺陷、大于1000像素的大缺陷——分别统计检出率。如果小缺陷检出率低于90%,模型是不能上线的,哪怕整体Dice到了0.85。这个分桶评估能暴露整体指标掩盖的问题,尤其是气孔这类小目标缺陷。
再配合一个像素级混淆矩阵分析误检来源。把预测结果和标注叠加展示,逐类查看误检区域:如果误检集中在轮毂边缘的高光区域,考虑在预处理环节对高光区域做局部对比度增强;如果误检集中在轮辐纹理密集处,说明模型学到了纹理特征,需要用无缺陷图继续微调。这种可视化排查比调任何损失函数都来得快。
部署导出时有一个容易踩的坑。PyTorch模型直接转ONNX再转TensorRT,fp16精度下小缺陷的概率值往往被压缩得不够准确,导致阈值分割时缺陷时有时无。我建议导出ONNX时固定输入尺寸,避免动态维度在TensorRT里产生额外开销和精度损失。如果显存和算力允许,先用fp16精度上线运行,确认没有精度掉点后再考虑int8量化——小目标分割对量化误差的容忍度很低,int8量化导致缺陷边界收缩1到2个像素是常见情况,这种精度损失在人眼看来不大,但下游缺陷面积计算会受影响。
部署框架方面,如果产线是工控机加NVIDIA显卡,TensorRT是常规选择;如果是纯CPU环境,可以考虑OpenVINO,但精度损失比TensorRT大,一般不建议小缺陷分割场景用CPU做实时推理。推理速度的底线建议按单张500万像素图不超过100ms来卡,超过这个数的话现场设备来不及处理流水线上的全部图像。
最后留一个亲测有效的习惯:每次迭代模型后,把训练集里20张典型缺陷图的预测结果存成对比图,一张原图、一张标注、一张预测,按缺陷类型归档。下次调参或新批次上线前,先翻这些对比图再决定要不要动模型。这个习惯帮我避开了很多次“指标涨了但现场实际效果没变”的假象。希望帮到你。
本文还有配套的精品资源,点击获取