ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unet++裂缝分割实战:从数据标注到推理加速的完整指南

Unet++裂缝分割实战:从数据标注到推理加速的完整指南 简介本资源面向计算机视觉入门与进阶开发者提供基于Unet网络模型的混凝土墙面与道路裂缝图像分割完整Python项目可用于基础设施病害检测、语义分割算法学习与工程落地实践。压缩包共约2000个文件以1877张png与116张jpg图像构成数据集与标注样本另有5个py脚本承载模型训练与推理代码、2个txt说明文件整体约320.54MB采用7z格式打包。项目支持Adam、SGD、RMSProp多种优化器切换损失函数采用BCE逻辑损失学习率衰减可选恒定、余弦退火与step策略训练过程会输出最优与最终权重并生成数据预处理可视化图、dice曲线、loss曲线及训练日志便于观察收敛情况与调参对比。数据集含2类别约2300张图像及对应标注可直接用于训练与验证。目前已有223人学习适合希望掌握裂缝分割全流程、复用代码快速搭建实验的读者参考。1. 裂缝分割这件事为什么 Unet 比你想的更值得上手混凝土墙面和道路裂缝的自动检测本质上是一个像素级二分类问题每个像素要么是裂缝要么是背景。听起来简单但真正做过的人都知道裂缝的形态极其刁钻——宽度可能只有一两个像素走向随机还经常被阴影、水渍、接缝线干扰。传统阈值法在光照均匀的室内墙面还能凑合一到户外路面就集体翻车。Unet 这个网络结构最初是为医学图像分割设计的但它在裂缝分割任务上的表现同样能打。核心原因在于它的嵌套稠密跳跃连接不是简单地把编码器的特征直接拼给解码器而是在中间层做了多级融合让浅层的边缘信息和深层的语义信息在多个尺度上反复交互。对于裂缝这种“细长且需要精确定位”的目标这种设计比原始 Unet 的直连跳跃更能抓住细节。这个方案适合谁如果你手头有一批裂缝图像想用 python 跑一个能落地的图像分割模型又不想从零设计网络结构Unet 加一个标准数据集就是最省事的起点。下面从数据准备到训练调参把整条链路拆开讲。2. 裂缝数据集怎么选、怎么标、怎么转成网络能吃的格式2.1 公开数据集与自建数据集的取舍做裂缝分割数据来源无非两条路用公开数据集或者自己拍自己标。公开数据集里比较常见的有 CrackForest、DeepCrack、AigleRN 等这些数据集一般提供原始图像和对应的二值掩码。优点是省时间缺点是场景单一——很多是路面裂缝墙面裂缝的样本偏少。如果你要做的是混凝土墙面裂缝公开数据集只能当预训练或者补充主力数据还得自己采集。采集时注意几点分辨率不要低于 512×512否则细裂缝在缩放后直接消失光照尽量均匀避免正午强光直射和浓重阴影每张图里裂缝占比不要太小否则正负样本极度不平衡。标注工具用 LabelMe 或者 CVAT 都行导出格式选 PNG 掩码裂缝像素值为 255背景为 0。标注时有个血泪经验裂缝边缘不要标得太粗宁可稍微保守一点因为边缘模糊的标注会让模型学到一个“膨胀”的裂缝概念推理时会把阴影也吞进去。2.2 从原始图像到训练样本的转换脚本假设你已经有了 images 文件夹和 masks 文件夹图像是 jpg掩码是 png。下面这个脚本做三件事统一尺寸、归一化、划分训练验证集。import os import cv2 import numpy as np from sklearn.model_selection import train_test_split # 配置路径和参数 IMG_DIR data/images MASK_DIR data/masks OUT_DIR data/processed TARGET_SIZE (512, 512) # 统一尺寸根据显存调整 VAL_RATIO 0.2 os.makedirs(os.path.join(OUT_DIR, train, images), exist_okTrue) os.makedirs(os.path.join(OUT_DIR, train, masks), exist_okTrue) os.makedirs(os.path.join(OUT_DIR, val, images), exist_okTrue) os.makedirs(os.path.join(OUT_DIR, val, masks), exist_okTrue) img_files sorted(os.listdir(IMG_DIR)) pairs [] for f in img_files: name os.path.splitext(f)[0] mask_path os.path.join(MASK_DIR, name .png) if os.path.exists(mask_path): pairs.append((os.path.join(IMG_DIR, f), mask_path)) train_pairs, val_pairs train_test_split(pairs, test_sizeVAL_RATIO, random_state42) def process(pairs, split): for img_path, mask_path in pairs: img cv2.imread(img_path) img cv2.resize(img, TARGET_SIZE, interpolationcv2.INTER_LINEAR) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) mask cv2.resize(mask, TARGET_SIZE, interpolationcv2.INTER_NEAREST) # 二值化确保掩码只有0和255 _, mask cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY) name os.path.basename(img_path) cv2.imwrite(os.path.join(OUT_DIR, split, images, name), img) cv2.imwrite(os.path.join(OUT_DIR, split, masks, name.replace(.jpg, .png)), mask) process(train_pairs, train) process(val_pairs, val) print(f训练集 {len(train_pairs)} 对验证集 {len(val_pairs)} 对)这段代码里有两个参数需要根据实际情况调。TARGET_SIZE 设为 512×512 是折中方案显存 8G 以上可以上 768 或 1024但要注意裂缝宽度在缩放后不能低于 2 个像素。VAL_RATIO 一般取 0.15 到 0.25数据量少于 500 张时建议用交叉验证而不是固定划分。掩码缩放必须用 INTER_NEAREST用线性插值会引入中间灰度值二值化后边缘会抖动。这个坑我踩过训练 loss 正常下降但 IoU 死活上不去排查半天才发现是掩码插值方式错了。3. Unet 网络结构在裂缝分割中的关键改动与训练配置3.1 编码器选型与嵌套跳跃连接的实现要点原始 Unet 论文里编码器用的是 VGG 或 ResNet实际做裂缝分割时我一般用 ResNet34 做骨干原因很简单裂缝数据集通常不大ResNet34 参数量适中预训练权重好找收敛稳定。如果你数据量超过一万张可以换 ResNet50 或 EfficientNet-B4。嵌套跳跃连接是 Unet 的核心。用代码说就是每个解码节点 X[i][j] 会接收同一层前一个节点 X[i][j-1] 和下一层所有节点 X[i1][0..j-1] 的特征拼接后再卷积。这样做的代价是显存占用比 Unet 高不少但换来的是多尺度特征融合更充分。import torch import torch.nn as nn import torchvision.models as models class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UnetPlusPlus(nn.Module): def __init__(self, num_classes1, deep_supervisionTrue): super().__init__() backbone models.resnet34(pretrainedTrue) self.deep_supervision deep_supervision # 编码器各阶段 self.enc0 nn.Sequential(backbone.conv1, backbone.bn1, backbone.relu) # 1/2 self.enc1 nn.Sequential(backbone.maxpool, backbone.layer1) # 1/4 self.enc2 backbone.layer2 # 1/8 self.enc3 backbone.layer3 # 1/16 self.enc4 backbone.layer4 # 1/32 # 解码节点通道数按需设置 filters [32, 64, 128, 256, 512] self.up nn.Upsample(scale_factor2, modebilinear, align_cornersTrue) # 这里只示意节点定义完整嵌套需要按Unet拓扑连接 self.conv0_1 ConvBlock(filters[0]filters[1], filters[0]) self.conv1_1 ConvBlock(filters[1]filters[2], filters[1]) self.conv2_1 ConvBlock(filters[2]filters[3], filters[2]) self.conv3_1 ConvBlock(filters[3]filters[4], filters[3]) self.final nn.Conv2d(filters[0], num_classes, 1) def forward(self, x): # 编码路径 x0_0 self.enc0(x) x1_0 self.enc1(x0_0) x2_0 self.enc2(x1_0) x3_0 self.enc3(x2_0) x4_0 self.enc4(x3_0) # 解码路径示意上采样后与同层编码拼接 x0_1 self.conv0_1(torch.cat([x0_0, self.up(x1_0)], dim1)) x1_1 self.conv1_1(torch.cat([x1_0, self.up(x2_0)], dim1)) x2_1 self.conv2_1(torch.cat([x2_0, self.up(x3_0)], dim1)) x3_1 self.conv3_1(torch.cat([x3_0, self.up(x4_0)], dim1)) out self.final(x0_1) return torch.sigmoid(out)上面是简化示意完整的 Unet 需要把 X0_2、X0_3、X0_4 等节点都建出来。实际项目中我建议直接用现成的实现比如 segmentation_models_pytorch 里的 UnetPlusPlus指定 encoder 为 resnet34encoder_weights 为 imagenet能省掉大量调试时间。3.2 损失函数选择与正负样本不平衡的处理裂缝分割最大的痛点之一是正负样本极度不平衡。一张 512×512 的图里裂缝像素可能只占 2% 到 5%。如果用普通交叉熵模型很快学会全预测背景准确率还能到 95% 以上但 IoU 接近零。常见做法是 Dice Loss 加 BCE 的组合。Dice Loss 直接优化预测和真实掩码的重叠度对不平衡不敏感BCE 提供稳定的梯度。我一般用 0.5 倍 BCE 加 0.5 倍 Dice如果裂缝特别细把 Dice 权重提到 0.7。import torch.nn.functional as F def dice_loss(pred, target, smooth1.0): pred pred.contiguous().view(-1) target target.contiguous().view(-1) intersection (pred * target).sum() return 1 - (2. * intersection smooth) / (pred.sum() target.sum() smooth) def combined_loss(pred, target, bce_weight0.5): bce F.binary_cross_entropy(pred, target) dice dice_loss(pred, target) return bce_weight * bce (1 - bce_weight) * dicesmooth 参数防止分母为零一般取 1.0。如果训练初期 loss 震荡厉害可以把 BCE 权重临时调到 0.8等 loss 稳定后再调回 0.5。另外如果数据里有些图完全没有裂缝这些图也要保留它们能帮助模型抑制误检。3.3 训练超参设置与显存优化优化器用 AdamW学习率初始 1e-4权重衰减 1e-4。Batch size 在 8G 显存下用 4 或 8配合梯度累积可以等效更大 batch。学习率调度用 CosineAnnealingLR周期设为总 epoch 数。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model UnetPlusPlus(num_classes1).cuda() optimizer AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max100, eta_min1e-6) for epoch in range(100): model.train() for img, mask in train_loader: img, mask img.cuda(), mask.cuda().float().unsqueeze(1) / 255.0 pred model(img) loss combined_loss(pred, mask) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()如果显存不够优先降 batch size 而不是降分辨率。分辨率降了细裂缝直接消失模型再强也学不到。另外混合精度训练能省 30% 到 40% 显存用 torch.cuda.amp 几行代码就能加上。4. 裂缝分割训练与推理中那些让人翻车的坑4.1 掩码边缘模糊导致 IoU 虚高但视觉效果差现象验证集 IoU 能到 0.75 以上但把预测结果叠回原图看裂缝边缘像糊了一层。原因通常是标注时用了抗锯齿画笔或者缩放掩码时用了线性插值导致边缘存在灰度过渡二值化后边缘像素随机分布。解决方法是标注时用硬边画笔所有掩码缩放统一用最近邻插值训练前检查掩码是否只有 0 和 255 两个值。4.2 模型把阴影和接缝线误判为裂缝现象推理时墙面接缝、管道阴影被标成裂缝。原因是训练数据里这些负样本太少模型没见过。解决办法是在数据增强里加入随机亮度调整和对比度扰动同时专门收集一批含阴影和接缝的负样本图掩码全黑加入训练集。负样本比例控制在总数据的 10% 到 15% 左右。4.3 学习率过大导致 loss 从 0.3 直接跳到 nan现象训练几个 batch 后 loss 变成 nan。原因通常是学习率太大或者混合精度训练时梯度溢出。解决方法是把初始学习率降到 1e-5 试跑确认稳定后再逐步提高。用混合精度时加上 GradScaler它能自动跳过溢出步。4.4 验证集 loss 下降但 IoU 不涨现象验证 loss 一直在降但 IoU 卡在 0.4 不动。原因可能是损失函数里 BCE 权重过高模型在优化像素级准确率而不是重叠度。解决办法是把 Dice 权重提高到 0.7 以上或者直接换成 Focal Loss 加 Dice。另外检查一下验证集的掩码有没有问题有时候是验证集标注质量差导致的。4.5 推理时整张图预测全黑或全白现象模型在训练集上表现正常换一张新图推理输出全黑或全白。原因通常是推理时的预处理和训练时不一致比如训练时做了归一化但推理时忘了或者输入图像尺寸和训练时差太多。解决办法是把预处理和推理封装成同一个函数确保训练和推理走完全相同的代码路径。5. 从训练完到真正能用推理加速与效果验证的实操技巧训练完一个 Unet 模型只是第一步真正放到工程里用还得解决推理速度和效果验证的问题。先说推理加速。Unet 的嵌套结构在推理时其实可以剪枝——训练时用深监督推理时只保留最后一路输出中间节点可以砍掉。segmentation_models_pytorch 里的实现默认就支持这个把 deep_supervision 设为 False 就能省 20% 到 30% 的推理时间。另外如果部署在边缘设备上可以把模型导出成 ONNX 再用 ONNX Runtime 跑CPU 上也能做到单张 512×512 图 200ms 以内。import torch.onnx model.eval() dummy torch.randn(1, 3, 512, 512).cuda() torch.onnx.export( model, dummy, unetpp_crack.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version11 )导出时 opset_version 建议用 11 或以上低版本对插值算子支持不好。dynamic_axes 让 batch 维度可变部署时能灵活处理单张或批量请求。再说效果验证。IoU 和 Dice 是常规指标但裂缝分割还得看两个东西一是裂缝的连通性预测结果不能断成碎段二是裂缝宽度的估计误差这对后续修补工程量计算很关键。我一般会写一个后处理脚本对预测掩码做形态学闭运算把断开的裂缝连起来再统计裂缝像素总数和骨架长度。import cv2 import numpy as np def postprocess(mask, kernel_size3): # mask: 0-1 浮点数组阈值化后做闭运算 binary (mask 0.5).astype(np.uint8) * 255 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return closed def crack_metrics(binary_mask): # 统计裂缝像素占比和骨架长度 pixel_ratio np.sum(binary_mask 0) / binary_mask.size skeleton cv2.ximgproc.thinning(binary_mask) if hasattr(cv2, ximgproc) else binary_mask skeleton_length np.sum(skeleton 0) return pixel_ratio, skeleton_lengthkernel_size 一般取 3 或 5太大把细裂缝也吞掉了。闭运算之后如果裂缝还是断的说明模型在弱边缘处置信度低可以考虑在训练时对裂缝边缘像素加大损失权重。最后说一个我自己的习惯每次训练完我会随机抽 20 张验证集图片把原图、真实掩码、预测掩码叠成三列拼图保存下来。指标再好也不如肉眼过一遍来得踏实。很多时候 IoU 只差 0.02但视觉效果差了一个档次问题就藏在那些拼图里。希望帮到你。本文还有配套的精品资源点击获取
返回列表