ARTICLE DETAIL

资讯详情

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

Unet++实现混凝土墙面与道路裂缝分割的Python实战指南

Unet++实现混凝土墙面与道路裂缝分割的Python实战指南 简介面向混凝土墙面与道路裂缝检测场景的 Unet 图像分割 Python 项目适合深度学习初学者、算法工程师及土木工程病害检测相关从业者用于快速搭建并训练裂缝分割模型。7z 压缩包共 2000 个文件大小 320.54MB主体为 1877 张 PNG 图像与标注文件、116 张 JPG 原始图像另有 5 个 Python 脚本负责数据加载、训练与评估2 个 TXT 文档用于说明运行环境或数据集划分。数据集包含约 2300 张混凝土墙面、道路裂缝图像均为 2 类别标注可直接用于监督训练。代码在训练环节支持 Adam、SGD、RMSProp 多种优化器切换损失函数采用 BCE 逻辑损失并集成恒定学习率、余弦退火和 step 衰减三种策略训练过程会自动保存最优与最后一个权重同时输出预处理可视化效果图、dice/loss 曲线以及完整训练日志便于对比调参和结果分析。目前已有 222 人学习该项目适合作为 Unet 在裂缝分割任务中的完整复现参考。1. 混凝土墙面与道路裂缝分割为什么先从 Unet 开始道路养护里最抓狂的不是裂缝多而是裂缝细。0.2 毫米宽的裂纹在混凝土墙面上和自然纹理混在一起人的肉眼要看三遍算法也经常把一条完好的裂缝预测成一段一段的短点。早期 U-Net 在大多数图像分割任务里已经够用但遇到这种跨尺度、低对比度的目标普通跳跃连接直接把浅层细节和深层语义相加很容易在裂缝边缘附近出现断裂。Unet 针对的正是这个问题。它在 U-Net 的编码器和解码器之间插入了多组嵌套的密集卷积块让每个解码层都能吃到不同尺度的特征相当于把“多尺度”写进了网络结构。图像分割中对裂缝这种长条形、走向不定的目标很有效也是混凝土墙面、道路裂缝检测 Python 项目里最常被选择的模型之一。这篇文章按一个可复现的 Python 项目来拆先说明数据集和标注怎么准备再给出 Unet 的模型核心代码、损失函数与训练参数然后处理大图推理和后处理最后一组可以直接抄的超参和排错顺序。读者不需要读论文原文也能把模型在自有数据集上跑起来。2. Unet 网络原理与裂缝图像数据集的制作要点2.1 从 U-Net 到 Unet密集跳跃连接在裂缝分割中的优势U-Net 的跳跃连接把编码器第 i 层特征直接交给解码器第 i 层。这个设计对普通目标有效但放在混凝土墙面和道路裂缝场景里有两个明显问题浅层特征分辨率高却对石子阴影、模板接缝、刮痕非常敏感深层特征能分辨“这是裂缝”但分辨率低恢复出来边界容易偏离真实边缘。两者只在同一层做一次拼接信息交互太浅细裂缝在经过四次下采样后基本只剩残影。Unet 把这种单次拼接改成密集嵌套连接。用论文里的记号X^{i,j}表示第 i 个下采样尺度、第 j 个嵌套层的特征图X^{0,1}由X^{0,0}、上采样的X^{1,0}拼接后卷积得到X^{0,2}再接收X^{0,0}、X^{0,1}和上采样的X^{1,1}。每一层解码器的输入都包含同尺度之前的特征和上一层解码器的特征多个深度的信息会被反复重组合并。对裂缝这种局部连续、整体细长的目标多尺度融合能明显减少断线。从训练角度看密集连接也让梯度回传路径更短低层卷积可以直接吸收深层 loss在数万张量级的小数据集上更容易收敛。常见开源实现还会带上深度监督deep supervision训练时对X^{0,1}、X^{0,2}、X^{0,3}分别接一个 1×1 卷积算 loss让网络各个嵌套层都学习分割。下表是两者差异对比项U-NetUnet跳跃连接编码器与解码器逐层单次连接每个解码层接收多个嵌套层特征多尺度融合只有两个分辨率特征交互三到五个尺度的特征反复拼接训练复杂度较低稍高显存占用增加裂缝断裂现象常见细缝呈虚线段明显改善边缘更连续深度监督通常无容易实现多输出辅助监督需要提醒的是Unet 参数量和显存占用比同深度 U-Net 高 20% 到 40%在 512×512 输入下显存会比较紧张。实际项目里大家更常把编码器换成轻量化 Backbone后面第四章会讲优化方式。2.2 裂缝图像数据集从哪里找、怎么组织、怎么增强公开研究里能用的裂缝数据集不少DeepCrack、CrackForest、CRACK500、SDNET2018以及一些桥墩病害数据集标注形式大多是“像素级 mask”。拿到数据后先做三件事检查 mask 是单通道还是三通道统一成 8 位单通道 PNG确认裂缝是白底黑背景还是黑底白背景统一成背景 0、裂缝 255把数据集按 7:2:1 切成训练、验证、测试不要有同一场景连续帧出现在两个集合里。建议目录结构固定成下面这种形式匹配 PyTorch 的 Dataset 写起来最省事crack_dataset/ images/ crack_001.jpg crack_002.jpg masks/ crack_001.png crack_002.png文件对应关系靠主文件名关联不要用索引文件去匹配否则后续增加数据容易出错。mask 保存时尽量不压缩或使用 PNG 无损格式JPG 压缩会在裂缝边缘产生值得警惕的伪影模型学起来会很困惑。数据增强是裂缝分割里比网络结构更影响结果的一环。最有效的是随机水平翻转、垂直翻转、90 度旋转、随机亮度对比度扰动和随机裁剪。旋转角度不建议大范围任意旋转混凝土墙面和道路裂缝有大致方向性先验不要被破坏。随机裁剪比直接 resize 好因为原始图像往往 4000×3000直接缩到 256 会把细裂缝抹掉正确做法是在训练时从大图随机裁 256 或 512 的 patch验证时再滑窗推理。2.3 自己写 PyTorch DataLoader同步裁剪与归一化的关键参数下面是裂缝分割项目里最常用的一版 Dataset包含图像和 mask 的同步翻转、归一化以及 mask 的最近邻处理import os import cv2 import numpy as np import torch from torch.utils.data import Dataset class CrackDataset(Dataset): def __init__(self, img_dir, mask_dir, size256, augTrue): super().__init__() self.img_dir img_dir self.mask_dir mask_dir self.names sorted(os.listdir(img_dir)) self.size size self.aug aug def __len__(self): return len(self.names) def __getitem__(self, idx): name self.names[idx] img cv2.imread(os.path.join(self.img_dir, name)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask cv2.imread(os.path.join(self.mask_dir, name.replace(.jpg, .png)), 0) img cv2.resize(img, (self.size, self.size), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (self.size, self.size), interpolationcv2.INTER_NEAREST) if self.aug: if np.random.rand() 0.5: img cv2.flip(img, 1) mask cv2.flip(mask, 1) if np.random.rand() 0.5: img cv2.flip(img, 0) mask cv2.flip(mask, 0) img img.astype(np.float32) / 255.0 mask (mask 127).astype(np.float32) img torch.from_numpy(img).permute(2, 0, 1) mask torch.from_numpy(mask).unsqueeze(0) return img, mask这段代码里最关键的参数是interpolation图像放大用双线性没问题mask 缩放必须用最近邻。用双线性会把 255 和 0 之间插出一圈灰色像素后续mask 127会把细裂缝边缘变粗训练得到的分割边界会整体膨胀一圈。size建议先用 256 做快速验证模型能收敛后再用 512 做正式训练效果通常提升明显。数据加载器在训练代码里还要设置num_workers和pin_memory。Windows 下num_workers设置为 2 到 4Linux 可以到 8如果报 DataLoader 卡死先调成 0 排查是不是数据路径问题。pin_memoryTrue配合.cuda(non_blockingTrue)可以把数据拷贝和计算重叠起来训练节奏更稳定。注意不要在 Dataset 里做复杂的在线增强比如弹性形变。裂缝是细长结构扭曲稍大就会让标注失去参考意义。把简单翻转、裁剪、亮度扰动放在 Dataset 里复杂的增强留给单独的增强库分阶段排查更高效。3. 用 Python PyTorch 实现 Unet 训练脚本3.1 Unet 核心网络代码密集连接怎么落地网上开源仓库里的 Unet 实现动辄几百行项目实战时完全可以改写成更精简的模块。核心是把握住特征拼接通道数每个嵌套层输入通道数等于它能接收到的所有源特征通道数之和。下面是一个四尺度 Unet 的最小实现为方便阅读只写关键结构import torch import torch.nn as nn import torch.nn.functional as F 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, in_ch3, out_ch1): super().__init__() self.pool nn.MaxPool2d(2) self.up nn.Upsample(scale_factor2, modebilinear, align_cornersFalse) self.conv00 ConvBlock(in_ch, 64) self.conv10 ConvBlock(64, 128) self.conv20 ConvBlock(128, 256) self.conv30 ConvBlock(256, 512) self.conv01 ConvBlock(64 128, 64) self.conv11 ConvBlock(128 256, 128) self.conv21 ConvBlock(256 512, 256) self.conv02 ConvBlock(64 64 128, 64) self.conv12 ConvBlock(128 128 256, 128) self.conv03 ConvBlock(64 64 64 128, 64) self.out nn.Conv2d(64, out_ch, 1) def forward(self, x): x00 self.conv00(x) x10 self.conv10(self.pool(x00)) x20 self.conv20(self.pool(x10)) x30 self.conv30(self.pool(x20)) x01 self.conv01(torch.cat([x00, self.up(x10)], dim1)) x11 self.conv11(torch.cat([x10, self.up(x20)], dim1)) x21 self.conv21(torch.cat([x20, self.up(x30)], dim1)) x02 self.conv02(torch.cat([x00, x01, self.up(x11)], dim1)) x12 self.conv12(torch.cat([x10, x11, self.up(x21)], dim1)) x03 self.conv03(torch.cat([x00, x01, x02, self.up(x12)], dim1)) return torch.sigmoid(self.out(x03))这段代码的重点是conv01到conv03的输入拼接conv01接收同尺度x00和上采样的x10conv02接收x00、x01和上采样的x11而不是只接一条路径。这样裂缝多尺度信息在解码过程里被反复融合。实际完整版 Unet 会有五层下采样和四个嵌套深度本文代码把它压缩成四尺度核心结构不变显存占用更友好。self.up我用双线性插值而不是转置卷积因为转置卷积会增加可训练参数对裂缝这种目标增益有限。如果想获得更好的上采样效果可以先插值再接ConvBlock通道数按照代码中的拼接关系调整即可。输出层最后接sigmoid配合下面给的 BCE Dice 损失。3.2 损失函数与评估指标怎样让裂缝像素被“看见”裂缝像素占整张图像的比例经常不到 1%直接用 BCEWithLogitsLoss模型会倾向于把所有像素预测为背景因为这样总 loss 也不高。常见做法是把 BCE 和 Dice Loss 组合起来用 Dice 缓解正负样本不平衡。class BCEDiceLoss(nn.Module): def __init__(self): super().__init__() self.bce nn.BCEWithLogitsLoss() def forward(self, logits, targets): bce self.bce(logits, targets) probs torch.sigmoid(logits) smooth 1.0 dice 1 - (2 * (probs * targets).sum() smooth) / \ (probs.sum() targets.sum() smooth) return bce diceBCE保证每个像素独立优化Dice强制模型关注裂缝的整体重合度。由于dice是基于概率图计算的不是硬阈值预测梯度仍然能回传到网络。也可以换成 Focal Loss但需要调两个超参数裂缝这种细目标在多数项目里收益不明显BCEDice 是更稳的起点。评估指标建议同时记录 IoU、Dice、精确率和召回率不要只看一个。裂缝分割里常出现像素 IoU 不高但连通性很差的情况所以还需要检查预测结果的断裂次数def compute_ious(pred, mask, threshold0.5): pred_bin (pred threshold).int() mask_bin mask.int() intersection (pred_bin mask_bin).sum().item() union (pred_bin | mask_bin).sum().item() iou intersection / (union 1e-8) return iou这里threshold默认 0.5。裂缝预测概率往往不像人像分割那么集中训练结束后可以把验证集上 0.3、0.4、0.5、0.6 四个阈值各算一次 IoU选最高的一组固定下来。阈值也是后续后处理的一部分。3.3 训练循环与超参数设置训练脚本的骨架一般固定为“读数据、算 loss、反传、验证、存最优”。下面是最小可运行的训练循环省略了模型实例化后的具体路径model UNetPlusPlus(in_ch3, out_ch1).cuda() criterion BCEDiceLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, patience5, factor0.5) best_iou 0.0 for epoch in range(80): model.train() train_loss 0.0 for img, mask in train_loader: img, mask img.cuda(), mask.cuda() logits model(img) loss criterion(logits, mask) optimizer.zero_grad() loss.backward() optimizer.step() train_loss loss.item() val_iou validate(model, val_loader) # 验证函数自行定义 scheduler.step(val_iou) if val_iou best_iou: best_iou val_iou torch.save(model.state_dict(), best_unetplusplus.pt)参数选择参考下面这张表这是我通常跑裂缝分割项目的起始配置。参数推荐值说明image_size256 / 512细缝建议 512显存不足先用 256batch_size8 (256) / 4 (512)以不爆显存为准optimizerAdamWlr1e-4weight_decay1e-4schedulerReduceLROnPlateaupatience 5factor 0.5epochs80 ~ 120提前停止条件以验证 IoU 为准lossBCE Dice正负样本比例悬殊时更稳训练时建议开启混合精度用torch.autocast(cuda)包住前向和 loss 计算。显存可以降低 30% 左右再把batch_size往上顶一档。注意 BatchNorm 在开启混合精度时仍保持 fp32PyTorch 会自动处理不需要改代码。如果验证集 IoU 在某个 epoch 后长期不涨优先看学习率和数据增强强度而不是换模型。4. Unet 分割推理、后处理与模型加速技巧4.1 大图推理的滑窗策略与测试时增强训练时用的是 256 或 512 的 patch推理时如果直接输入整张 4000×3000 的照片显存扛不住小裂缝也容易在下采样后丢失。常用做法是滑窗推理把大图切成有重叠的 patch逐块预测后再拼回原尺寸。def infer_patches(model, image, patch_size512, stride384): model.eval() h, w image.shape[-2:] pred torch.zeros((h, w), dtypetorch.float32) count torch.zeros_like(pred) for y in range(0, h, stride): for x in range(0, w, stride): patch image[:, :, y:y patch_size, x:x patch_size] if patch.shape[-2] patch_size or patch.shape[-1] patch_size: patch F.pad(patch, (0, patch_size - patch.shape[-1], 0, patch_size - patch.shape[-2])) with torch.no_grad(): out torch.sigmoid(model(patch)[0, 0]).cpu() pred[y:y patch_size, x:x patch_size] out[:patch_size, :patch_size] count[y:y patch_size, x:x patch_size] 1 return pred / count.clamp(min1)stride必须小于patch_size重叠区域多次预测再取平均能抹掉 patch 边缘的人工拼接痕迹。推荐patch_size512, stride384如果显存只有 8G改成patch_size256, stride192。推理前记得把图像归一化到和训练时相同的范围否则输出概率整体偏移。如果项目不追求实时测试时增强TTA值得加把图像水平翻转后推理一次垂直翻转推理一次三次概率取平均。裂缝方向不定翻转增强通常能提升 1% 到 3% 的 IoU。代价是推理时间变为三倍实时检测场景不建议开。4.2 后处理连通域过滤与形态学闭运算的参数模型输出是概率图二值化后往往带两类误差背景纹理被识别成裂缝的散点以及真实裂缝中间的断点。后处理用连通域过滤去掉小面积噪点再用形态学闭运算连接断线import cv2 import numpy as np def postprocess(mask, min_area20, close_kernel3): mask (mask 0.5).astype(np.uint8) n, labels, stats, _ cv2.connectedComponentsWithStats(mask, connectivity8) out np.zeros_like(mask) for i in range(1, n): if stats[i, cv2.CC_STAT_AREA] min_area: out[labels i] 1 if close_kernel: kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_kernel, close_kernel)) out cv2.morphologyEx(out, cv2.MORPH_CLOSE, kernel) return outmin_area的值要跟着输入分辨率走。512×512 推理图上20 像素以上的噪点就有保留价值如果整张大图直接后处理要按分辨率放大到几百像素。close_kernel我通常用 3×3 或 5×5过大的核会把两条平行裂缝融成一片。后处理参数不要拍脑袋定在验证集上跑一遍阈值搜索。固定min_area为 10、20、50、100close_kernel为 0、3、5每组组合都算 IoU取最高的一组写进配置文件。这一步往往比换 Backbone 带来的提升更明显。参数推荐搜索范围备注二值化阈值0.3 ~ 0.6步长 0.05min_area10 ~ 100按输入分辨率调整close_kernel0 / 3 / 50 表示不启用4.3 导出 ONNX 并用半精度推理训练阶段用 PyTorch部署阶段通常导出 ONNX用 onnxruntime 推理。导出前先把模型切到 eval 模式否则 BatchNorm 的统计量会被冻结model.eval() dummy torch.randn(1, 3, 512, 512) torch.onnx.export(model, dummy, unetplusplus.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version17)动态轴只开 batch 就够了图像分辨率固定成推理时的 patch_size能避免动态维度带来的额外性能损失。导出后在 onnxruntime 里执行import onnxruntime as ort sess ort.InferenceSession(unetplusplus.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider]) out sess.run(None, {sess.get_inputs()[0].name: input_np})[0]如果训练时的输入只到torch.sigmoid之前导出时把out层后面的sigmoid也包进模型避免在部署侧重复实现。ONNX 对 GPU 上的批量推理友好CPU 上再用半精度可以减少一半内存但精度可能下降需要在验证集上重新测一次 IoU。5. 跑通 Unet 裂缝分割项目的调参清单与目录结构5.1 项目目录结构与快速运行整洁的目录是顺利复现的前提。参考结构如下unetplusplus_crack/ data/ images/ masks/ src/ dataset.py model.py loss.py train.py predict.py runs/ checkpoints/train.py里通常只需要两个命令行参数数据根目录和模型保存路径。常见的启动方式python src/train.py --data_dir ./data --epochs 100 --size 512如果环境还没有 PyTorch先装好 CUDA 版 PyTorch 2.x再安装 opencv-python、numpy、scikit-image、onnxruntime。不要直接在基础 Python 环境里装用 Anaconda 或 venv 单独建环境避免包冲突。5.2 推荐超参速查表下面是针对裂缝分割的推荐配置适合 512×512 输入、单 GPU 显存 11G 左右的场景。项目推荐值说明输入尺寸512×512细缝必须保证至少 4 像素宽度batch_size4再配合混合精度可开到 8学习率1e-4AdamW小数据集不要从 1e-3 开始训练轮数100以验证 IoU 为准提前停止数据增强翻转、亮度、裁剪禁用大角度旋转和强弹性形变mask 缩放最近邻避免边缘出现中间灰度5.3 先看假正例还是假反例裂缝分割的排错顺序模型跑完一版后如果验证 IoU 不到 0.5不要急着换网络。先看十张预测图和真实 mask 的叠加图把误差归成两类背景被误判为裂缝的假正例还是裂缝没检出来的假反例。假正例多优先检查训练样本里是不是包含太多干净墙面和路面模型只会记住纹理统计规律把后处理min_area调大二值化阈值向 0.6 靠近减少零散的小块误判。假反例多说明输入分辨率不够高裂缝在下采样后小于 2 像素先增大image_size或让训练裁剪窗口更小再考虑降低二值化阈值到 0.4最后才动网络架构。落到具体操作时我一般会写一段非常短的代码用验证集统计阈值在 0.3 到 0.7 之间每个点的 Precision、Recall 和 IoU选 IoU 最高的那一组。把这一步固化成训练完之后的必跑流程比反复凭感觉改模型参数有效得多。本文还有配套的精品资源点击获取
返回列表