尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

混凝土裂缝分割实战:2300张标注图+Unet++全流程解析

混凝土裂缝分割实战:2300张标注图+Unet++全流程解析 简介本资源面向计算机视觉初学者与图像分割实战开发者提供一套基于Unet网络对混凝土墙面、道路裂缝进行像素级分割的完整Python项目可用于基础设施病害检测、道路巡检等场景的算法验证与二次开发。压缩包共约2000个文件以1877张png与116张jpg图像为主涵盖原始裂缝图与对应标注掩码另有5个py脚本承载模型定义、训练与推理流程2个txt记录数据说明整体约320.54MB采用7z格式打包。训练环节支持Adam、SGD、RMSProp多种优化器损失函数采用BCE逻辑损失学习率可选用恒定、余弦退火或step衰减策略并自动输出最优与最终权重、预处理可视化图、dice与loss曲线及训练日志便于对比实验与调参分析。目前已有223人学习适合希望快速跑通裂缝分割基线、理解Unet结构与评估指标的读者参考。1. 裂缝分割项目拆包2300 张标注图 Unet 全套代码能跑出什么混凝土墙面和道路裂缝的自动提取难的不是「有没有模型」而是「拿到一份能直接跑通的数据和代码」。我见过太多人卡在第一步网上找的裂缝数据集要么只有原图没有掩码要么标注格式对不上要么类别定义混乱。这个项目给的是 2 类别分割任务——背景和裂缝约 2300 张图像及对应标注文件名里带着top_right、bottom_left、center这类位置标记说明采集时做了多角度覆盖。代码侧用的是 Unet 网络结构训练环节开放了 Adam、SGD、RMSProp 三种优化器损失函数走 BCE 逻辑损失学习率衰减支持恒定、余弦退火和 step 三种策略。训练完会输出最优权重、最终权重、预处理可视化图、dice 曲线、loss 曲线和训练日志。适合谁做道路巡检、建筑结构健康监测、无人机墙面检测的工程师以及想拿一个完整分割 pipeline 练手的学生。下面按「数据怎么组织 → 代码怎么跑 → 参数怎么调 → 坑在哪」的顺序拆。2. 数据管线与 Unet 结构从文件名到网络输入张量2.1 数据集的组织逻辑与类别定义拿到这批数据第一件事不是急着写 DataLoader而是把目录结构和文件名规则摸清楚。从项目正文给出的文件名样本看命名格式大致是编号_类别_序号_位置_png_jpg.rf.哈希.jpg。这里的编号是图像唯一标识类别字段对应裂缝类型0 或 1位置标记了拍摄区域在整体结构中的方位。这种命名方式的好处是你可以在不打开图像的情况下按位置做分层采样避免训练集和验证集在空间分布上偏斜。常见做法是建两个平行目录images/放原图masks/放同名的标注掩码。掩码是单通道灰度图像素值 0 表示背景255 表示裂缝。如果你拿到的掩码是彩色三通道需要先转灰度再二值化。我一般会写一个快速校验脚本确认每张原图都有对应的掩码且尺寸一致。import os from PIL import Image import numpy as np img_dir dataset/images mask_dir dataset/masks # 检查图像与掩码的配对情况和尺寸一致性 img_files sorted(os.listdir(img_dir)) mask_files sorted(os.listdir(mask_dir)) assert len(img_files) len(mask_files), 图像与掩码数量不匹配 for img_name in img_files[:10]: # 抽样检查前10对 img_path os.path.join(img_dir, img_name) mask_path os.path.join(mask_dir, img_name) img Image.open(img_path) mask Image.open(mask_path) # 尺寸必须一致否则后续 resize 会引入对齐误差 if img.size ! mask.size: print(f尺寸不一致: {img_name}, img{img.size}, mask{mask.size}) # 检查掩码像素值分布确认只有 0 和 255 mask_arr np.array(mask) unique_vals np.unique(mask_arr) if not set(unique_vals).issubset({0, 255}): print(f掩码值异常: {img_name}, unique{unique_vals})这段脚本做三件事数量配对检查、尺寸一致性检查、掩码像素值合法性检查。参数上img_dir和mask_dir按你实际解压后的路径改。如果掩码值不是 0/255 而是 0/1需要在 Dataset 类里做归一化否则 BCE 损失计算时会出问题。2.2 Unet 的嵌套跳跃连接与输入输出维度Unet 和原始 Unet 的核心区别在于跳跃连接的方式。原始 Unet 是编码器某一层直接连到解码器对应层Unet 在中间插了多个嵌套的卷积块形成密集的跳跃路径。这样做的好处是浅层特征和深层特征在融合前会经过更多次非线性变换对裂缝这种细长、对比度低的 target 更友好。具体到代码里你会看到NestedUNet类里面用VGGBlock或类似的卷积单元堆叠。输入是(batch, 3, H, W)的 RGB 图像输出是(batch, num_classes, H, W)的 logits。num_classes2对应背景和裂缝。注意输出层不加 softmax因为 BCE 损失内部会做 sigmoid。import torch import torch.nn as nn class VGGBlock(nn.Module): def __init__(self, in_channels, mid_channels, out_channels): super().__init__() self.relu nn.ReLU(inplaceTrue) self.conv1 nn.Conv2d(in_channels, mid_channels, 3, padding1) self.bn1 nn.BatchNorm2d(mid_channels) self.conv2 nn.Conv2d(mid_channels, out_channels, 3, padding1) self.bn2 nn.BatchNorm2d(out_channels) def forward(self, x): x self.relu(self.bn1(self.conv1(x))) x self.relu(self.bn2(self.conv2(x))) return x class NestedUNet(nn.Module): def __init__(self, num_classes2, input_channels3, deep_supervisionFalse): super().__init__() nb_filter [32, 64, 128, 256, 512] self.deep_supervision deep_supervision self.pool nn.MaxPool2d(2, 2) self.up nn.Upsample(scale_factor2, modebilinear, align_cornersTrue) # 编码器部分逐层下采样 self.conv0_0 VGGBlock(input_channels, nb_filter[0], nb_filter[0]) self.conv1_0 VGGBlock(nb_filter[0], nb_filter[1], nb_filter[1]) self.conv2_0 VGGBlock(nb_filter[1], nb_filter[2], nb_filter[2]) self.conv3_0 VGGBlock(nb_filter[2], nb_filter[3], nb_filter[3]) self.conv4_0 VGGBlock(nb_filter[3], nb_filter[4], nb_filter[4]) # 嵌套解码路径每层接收同层编码输出和上一层解码输出 self.conv0_1 VGGBlock(nb_filter[0]nb_filter[1], nb_filter[0], nb_filter[0]) self.conv1_1 VGGBlock(nb_filter[1]nb_filter[2], nb_filter[1], nb_filter[1]) self.conv2_1 VGGBlock(nb_filter[2]nb_filter[3], nb_filter[2], nb_filter[2]) self.conv3_1 VGGBlock(nb_filter[3]nb_filter[4], nb_filter[3], nb_filter[3]) # 最终输出层1x1 卷积把通道数压到 num_classes self.final nn.Conv2d(nb_filter[0], num_classes, kernel_size1) def forward(self, x): x0_0 self.conv0_0(x) x1_0 self.conv1_0(self.pool(x0_0)) x0_1 self.conv0_1(torch.cat([x0_0, self.up(x1_0)], 1)) output self.final(x0_1) return output这里只展示了前两层嵌套完整版会一直嵌套到x4_0。参数上nb_filter控制每层的通道数显存不够就整体减半。deep_supervision如果设为 True会在每个解码节点都输出一个预测训练时把多个尺度的损失加权求和对裂缝边缘的收敛有提升但显存占用会增加约 30%。2.3 Dataset 与 DataLoader 的落地写法数据增强对裂缝分割很关键因为裂缝的形态变化大但方向性又比较强。我一般用albumentations做在线增强包括随机旋转、水平翻转、亮度对比度扰动。注意几何变换必须同时作用于原图和掩码且掩码要用最近邻插值否则边缘会出现灰度过渡破坏二值性。import cv2 import numpy as np import torch from torch.utils.data import Dataset, DataLoader import albumentations as A class CrackDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size256, transformNone): self.img_dir img_dir self.mask_dir mask_dir self.img_size img_size self.transform transform self.img_files sorted(os.listdir(img_dir)) def __len__(self): return len(self.img_files) def __getitem__(self, idx): img_name self.img_files[idx] img cv2.imread(os.path.join(self.img_dir, img_name)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask cv2.imread(os.path.join(self.mask_dir, img_name), cv2.IMREAD_GRAYSCALE) # 统一 resize 到网络输入尺寸 img cv2.resize(img, (self.img_size, self.img_size)) mask cv2.resize(mask, (self.img_size, self.img_size), interpolationcv2.INTER_NEAREST) # 掩码二值化并归一化到 0/1 mask (mask 127).astype(np.float32) if self.transform: augmented self.transform(imageimg, maskmask) img augmented[image] mask augmented[mask] # 转 tensor 并调整维度顺序 img torch.from_numpy(img).permute(2, 0, 1).float() / 255.0 mask torch.from_numpy(mask).unsqueeze(0).float() return img, mask # 增强策略旋转、翻转、亮度扰动 train_transform A.Compose([ A.RandomRotate90(), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.3), A.RandomBrightnessContrast(p0.4), ]) train_dataset CrackDataset(dataset/images, dataset/masks, img_size256, transformtrain_transform) train_loader DataLoader(train_dataset, batch_size8, shuffleTrue, num_workers4)img_size设 256 是速度和精度的折中裂缝宽度在 2-5 像素时256 分辨率下仍能保留足够细节。batch_size8在 8GB 显存上跑 Unet 比较稳如果 OOM 就降到 4。num_workers按 CPU 核数设Windows 下如果报错就改成 0。3. 训练脚本参数拆解优化器、损失函数与学习率衰减怎么配3.1 三种优化器的选择逻辑与实测差异项目里给了 Adam、SGD、RMSProp 三个选项这不是凑数而是对应不同的训练阶段和数据规模。Adam 适合快速起步自适应学习率让它在初期收敛很快但后期容易在局部最优附近震荡。SGD 配合动量在分割任务里往往能拿到更好的泛化但需要手动调学习率起步慢。RMSProp 介于两者之间对非平稳目标比如裂缝这种稀疏分布有一定优势。我的习惯是先用 Adam 跑 20 个 epoch 看 loss 能不能降下去确认数据和网络没问题然后切 SGD 从头训学习率设 0.01动量 0.9配合余弦退火最终 dice 通常比 Adam 高 1-2 个点。如果显存小、batch size 只能设 4 或 8那 SGD 的梯度噪声会比较大这时候 RMSProp 更稳。import torch.optim as optim def build_optimizer(model, opt_name, lr1e-3): if opt_name adam: # Adam 对学习率不敏感1e-3 是安全起点 return optim.Adam(model.parameters(), lrlr, betas(0.9, 0.999)) elif opt_name sgd: # SGD 需要配合动量和 weight decay 才能稳定 return optim.SGD(model.parameters(), lrlr, momentum0.9, weight_decay1e-4) elif opt_name rmsprop: # RMSProp 的 alpha 控制历史梯度平方的衰减速度 return optim.RMSprop(model.parameters(), lrlr, alpha0.99, eps1e-8) else: raise ValueError(f不支持的优化器: {opt_name})参数上Adam 的lr默认 1e-3如果 loss 出现 NaN 就降到 1e-4。SGD 的lr建议从 0.01 起weight_decay设 1e-4 防止过拟合。RMSProp 的alpha设 0.99 是常见值eps保持默认即可。3.2 BCE 损失与 dice 指标的配合方式项目用的是 BCE 逻辑损失也就是BCEWithLogitsLoss。这个损失把 sigmoid 和交叉熵合在一起数值稳定性比先 sigmoid 再 BCE 好。但裂缝分割有个问题正负样本极度不均衡裂缝像素可能只占全图的 2%-5%纯 BCE 会让模型倾向于全预测背景。常见做法是给正样本加权重或者 BCE 和 dice loss 按比例混合。import torch.nn as nn class BCEDiceLoss(nn.Module): def __init__(self, bce_weight0.5): super().__init__() self.bce_weight bce_weight # pos_weight 用来放大正样本的损失贡献根据正负比设置 self.bce nn.BCEWithLogitsLoss(pos_weighttorch.tensor([5.0])) def forward(self, pred, target): bce_loss self.bce(pred, target) # dice loss 计算smooth 防止除零 pred_sigmoid torch.sigmoid(pred) intersection (pred_sigmoid * target).sum() union pred_sigmoid.sum() target.sum() dice_loss 1 - (2. * intersection 1e-6) / (union 1e-6) return self.bce_weight * bce_loss (1 - self.bce_weight) * dice_losspos_weight5.0是根据裂缝像素占比大概 1:5 来设的你可以先统计训练集里正负像素比把这个值设成负样本数/正样本数的量级。bce_weight0.5是经验值如果发现预测的裂缝偏粗就加大 dice 的权重如果漏检多就加大 BCE 的权重。3.3 学习率衰减策略的代码实现与触发时机恒定学习率适合短周期训练比如 30 个 epoch 以内。余弦退火让学习率按余弦曲线从初始值降到接近 0适合长周期训练能让模型在后期精细调整。Step 衰减是每过固定 epoch 数把学习率乘一个系数简单直接但系数和步长需要试。from torch.optim.lr_scheduler import CosineAnnealingLR, StepLR, LambdaLR def build_scheduler(optimizer, scheduler_name, epochs, steps_per_epoch): if scheduler_name constant: # 恒定学习率用 LambdaLR 返回原值 return LambdaLR(optimizer, lr_lambdalambda epoch: 1.0) elif scheduler_name cosine: # 余弦退火T_max 设为总 epoch 数 return CosineAnnealingLR(optimizer, T_maxepochs, eta_min1e-6) elif scheduler_name step: # 每 20 个 epoch 学习率乘 0.1 return StepLR(optimizer, step_size20, gamma0.1) else: raise ValueError(f不支持的学习率策略: {scheduler_name})余弦退火的eta_min1e-6是学习率下限别设 0否则后期梯度消失。Step 衰减的step_size20和gamma0.1适合总 epoch 在 60-100 的情况如果只训 30 个 epoch改成step_size10。3.4 训练循环与权重保存、日志记录训练循环里要同时记录 loss、dice、学习率每个 epoch 结束在验证集上算一次 dice保存 dice 最高的权重和最后一个 epoch 的权重。预处理可视化图是在第一个 epoch 开始时把几张原图、掩码、增强后的图拼成一张大图存下来方便确认数据管线没出错。import os import time import torch import numpy as np from torch.utils.tensorboard import SummaryWriter def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for imgs, masks in loader: imgs, masks imgs.to(device), masks.to(device) optimizer.zero_grad() preds model(imgs) loss criterion(preds, masks) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader) def validate(model, loader, criterion, device): model.eval() total_loss 0 total_dice 0 with torch.no_grad(): for imgs, masks in loader: imgs, masks imgs.to(device), masks.to(device) preds model(imgs) loss criterion(preds, masks) total_loss loss.item() # 计算 dice 系数 pred_bin (torch.sigmoid(preds) 0.5).float() intersection (pred_bin * masks).sum() dice (2. * intersection 1e-6) / (pred_bin.sum() masks.sum() 1e-6) total_dice dice.item() return total_loss / len(loader), total_dice / len(loader) # 主训练流程 device torch.device(cuda if torch.cuda.is_available() else cpu) model NestedUNet(num_classes2).to(device) optimizer build_optimizer(model, adam, lr1e-3) scheduler build_scheduler(optimizer, cosine, epochs50, steps_per_epochlen(train_loader)) criterion BCEDiceLoss(bce_weight0.5).to(device) best_dice 0.0 os.makedirs(checkpoints, exist_okTrue) for epoch in range(50): train_loss train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_dice validate(model, val_loader, criterion, device) scheduler.step() # 保存最优权重 if val_dice best_dice: best_dice val_dice torch.save(model.state_dict(), checkpoints/best_model.pth) # 每个 epoch 都保存最后权重 torch.save(model.state_dict(), checkpoints/last_model.pth) print(fEpoch {epoch1}/50 | train_loss{train_loss:.4f} | val_loss{val_loss:.4f} | val_dice{val_dice:.4f})best_model.pth是验证集 dice 最高时的权重用于最终推理。last_model.pth是最后一个 epoch 的权重用于分析过拟合情况。日志里如果 train_loss 持续下降但 val_dice 不涨说明过拟合了需要加数据增强或减小模型容量。4. 避坑与排查裂缝分割训练里最容易翻车的五个点4.1 掩码值不是 0/255 导致 loss 不收敛现象训练几个 epoch 后 loss 一直在 0.6-0.7 附近震荡dice 始终低于 0.3预测结果全是背景。原因掩码图像保存时被压缩成 JPG像素值从 0/255 变成了 0-255 之间的连续值或者标注工具导出的是 0/1 而不是 0/255。BCE 损失期望 target 在 0-1 之间但如果你的代码里做了mask / 255.0而实际掩码已经是 0/1就会变成 0-0.004 的极小值正样本信号被淹没。解决在 Dataset 里统一做二值化不要依赖原始像素值。用mask (mask 127).astype(np.float32)强制转成 0/1。如果掩码是 0/1 存储的这个逻辑同样成立因为 1 127 为 False会变成 0所以要先判断掩码的最大值再决定阈值。4.2 图像与掩码增强不同步导致边缘错位现象训练 loss 能降但验证时预测的裂缝边缘总是偏移几个像素dice 卡在 0.5 左右上不去。原因用了两套增强逻辑原图走一套掩码走另一套或者用了torchvision.transforms分别处理随机种子没对齐。几何变换旋转、缩放、裁剪必须对原图和掩码用完全相同的参数。解决统一用albumentations的Compose它保证image和mask走同一套随机变换。如果非要用torchvision就手动固定随机种子或者用torchvision.transforms.functional的affine同时处理两者。4.3 正负样本极度不均衡导致漏检现象dice 看着还行但可视化结果里细裂缝全丢了只预测出粗裂缝。原因裂缝像素占比太低BCE 损失被背景像素主导模型学到「全预测背景」就能拿到很低的 loss。解决三管齐下。第一BCEWithLogitsLoss里设pos_weight值设为负正样本比的量级。第二混合 dice lossdice 对正样本的权重天然更高。第三在验证指标里除了 dice再加一个 recall专门看漏检率。4.4 显存溢出与 batch size 的取舍现象训练到第二个 epoch 突然报CUDA out of memory或者把 batch size 降到 2 才能跑。原因Unet 的嵌套结构参数量比原始 Unet 大 30%-50%如果nb_filter设了[64, 128, 256, 512, 1024]显存占用会翻倍。另外num_workers设太大也会占用共享内存。解决先把nb_filter整体减半从[32, 64, 128, 256, 512]起步。如果还不够把img_size从 512 降到 256。num_workers在 Windows 下设 0Linux 下设 4 就行别设 8 以上。还有一个隐藏坑验证阶段没加torch.no_grad()显存会持续累积。4.5 学习率衰减策略与总 epoch 数不匹配现象用余弦退火训 30 个 epoch结果最后 10 个 epoch 学习率已经降到 1e-6loss 几乎不动dice 停滞。原因CosineAnnealingLR的T_max设成了 100但实际只训 30 个 epoch学习率还没降到最低就停了或者反过来T_max设成 10学习率过早衰减到 0。解决T_max必须等于总 epoch 数。如果你不确定训多少轮先用恒定学习率跑 20 个 epoch 看 loss 曲线确定收敛区间后再换余弦退火。Step 衰减的step_size设为总 epoch 的 1/3 到 1/2gamma设 0.1 或 0.5。5. 推理与调优从权重文件到裂缝掩码的完整链路5.1 加载最优权重做单张推理训练完之后拿best_model.pth做推理流程是读图 → resize 到 256 → 归一化 → 转 tensor → 前向传播 → sigmoid → 阈值二值化 → resize 回原图尺寸。注意推理时的预处理必须和验证时完全一致不能加随机增强。import cv2 import numpy as np import torch def predict_single(model, img_path, device, img_size256, threshold0.5): model.eval() # 读图并预处理 img cv2.imread(img_path) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img_resized cv2.resize(img_rgb, (img_size, img_size)) img_tensor torch.from_numpy(img_resized).permute(2, 0, 1).float() / 255.0 img_tensor img_tensor.unsqueeze(0).to(device) with torch.no_grad(): pred model(img_tensor) pred_prob torch.sigmoid(pred) pred_bin (pred_prob threshold).float() # 转回 numpy 并 resize 到原图尺寸 mask pred_bin.squeeze().cpu().numpy() mask (mask * 255).astype(np.uint8) mask_original cv2.resize(mask, (img.shape[1], img.shape[0]), interpolationcv2.INTER_NEAREST) return mask_original # 使用示例 model NestedUNet(num_classes2).to(device) model.load_state_dict(torch.load(checkpoints/best_model.pth, map_locationdevice)) mask predict_single(model, test_images/crack_001.jpg, device) cv2.imwrite(output_mask.png, mask)threshold0.5是默认值如果发现漏检多就降到 0.3误检多就升到 0.6。这个阈值可以在验证集上画 precision-recall 曲线来选最优值。5.2 用 dice 和 IoU 做定量验证光看可视化不够得有数字。dice 和 IoU 是最常用的两个分割指标。dice 对正样本更敏感IoU 更严格。我一般两个都算如果 dice 高但 IoU 低说明预测的裂缝偏粗有大量假阳性。def compute_metrics(pred_mask, gt_mask): # pred_mask 和 gt_mask 都是 0/1 二值图 pred pred_mask.flatten() gt gt_mask.flatten() intersection (pred * gt).sum() union pred.sum() gt.sum() - intersection dice (2. * intersection 1e-6) / (pred.sum() gt.sum() 1e-6) iou (intersection 1e-6) / (union 1e-6) return dice, iou在验证集上跑一遍把每张图的 dice 和 IoU 存成 CSV然后看分布。如果有些图 dice 低于 0.3单独拿出来看大概率是标注质量有问题或者图像本身模糊。5.3 学习率与 batch size 的联合调参经验最后说一个我踩过的坑学习率和 batch size 是耦合的。如果你把 batch size 从 8 降到 4学习率也要相应减半否则梯度更新的方差变大训练容易发散。反过来batch size 翻倍学习率可以适当放大 1.5 倍左右但别直接翻倍。另一个经验是余弦退火的eta_min别设 0设成初始学习率的 1/100 就行。比如初始 lr1e-3eta_min1e-5。这样后期还能有微小的梯度更新不会完全停滞。从那以后我每次换优化器或改 batch size都强制走一遍「先跑 5 个 epoch 看 loss 是否稳定下降」的流程确认没问题再开完整训练。希望帮到你。本文还有配套的精品资源点击获取
返回列表