简介:本资源面向计算机视觉初学者与图像分割实战开发者,提供一套基于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_classes=2对应背景和裂缝。注意,输出层不加 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(inplace=True) self.conv1 = nn.Conv2d(in_channels, mid_channels, 3, padding=1) self.bn1 = nn.BatchNorm2d(mid_channels) self.conv2 = nn.Conv2d(mid_channels, out_channels, 3, padding=1) 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_classes=2, input_channels=3, deep_supervision=False): 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_factor=2, mode='bilinear', align_corners=True) # 编码器部分,逐层下采样 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_size=1) 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_size=256, transform=None): 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), interpolation=cv2.INTER_NEAREST) # 掩码二值化并归一化到 0/1 mask = (mask > 127).astype(np.float32) if self.transform: augmented = self.transform(image=img, mask=mask) 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(p=0.5), A.VerticalFlip(p=0.3), A.RandomBrightnessContrast(p=0.4), ]) train_dataset = CrackDataset("dataset/images", "dataset/masks", img_size=256, transform=train_transform) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4)img_size设 256 是速度和精度的折中,裂缝宽度在 2-5 像素时,256 分辨率下仍能保留足够细节。batch_size=8在 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, lr=1e-3): if opt_name == 'adam': # Adam 对学习率不敏感,1e-3 是安全起点 return optim.Adam(model.parameters(), lr=lr, betas=(0.9, 0.999)) elif opt_name == 'sgd': # SGD 需要配合动量和 weight decay 才能稳定 return optim.SGD(model.parameters(), lr=lr, momentum=0.9, weight_decay=1e-4) elif opt_name == 'rmsprop': # RMSProp 的 alpha 控制历史梯度平方的衰减速度 return optim.RMSprop(model.parameters(), lr=lr, alpha=0.99, eps=1e-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_weight=0.5): super().__init__() self.bce_weight = bce_weight # pos_weight 用来放大正样本的损失贡献,根据正负比设置 self.bce = nn.BCEWithLogitsLoss(pos_weight=torch.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_weight=5.0是根据裂缝像素占比大概 1:5 来设的,你可以先统计训练集里正负像素比,把这个值设成负样本数/正样本数的量级。bce_weight=0.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_lambda=lambda epoch: 1.0) elif scheduler_name == 'cosine': # 余弦退火,T_max 设为总 epoch 数 return CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-6) elif scheduler_name == 'step': # 每 20 个 epoch 学习率乘 0.1 return StepLR(optimizer, step_size=20, gamma=0.1) else: raise ValueError(f"不支持的学习率策略: {scheduler_name}")余弦退火的eta_min=1e-6是学习率下限,别设 0,否则后期梯度消失。Step 衰减的step_size=20和gamma=0.1适合总 epoch 在 60-100 的情况,如果只训 30 个 epoch,改成step_size=10。
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_classes=2).to(device) optimizer = build_optimizer(model, 'adam', lr=1e-3) scheduler = build_scheduler(optimizer, 'cosine', epochs=50, steps_per_epoch=len(train_loader)) criterion = BCEDiceLoss(bce_weight=0.5).to(device) best_dice = 0.0 os.makedirs("checkpoints", exist_ok=True) 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(f"Epoch {epoch+1}/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 loss,dice 对正样本的权重天然更高。第三,在验证指标里除了 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 下设 0,Linux 下设 4 就行,别设 8 以上。还有一个隐藏坑:验证阶段没加torch.no_grad(),显存会持续累积。
4.5 学习率衰减策略与总 epoch 数不匹配
现象:用余弦退火训 30 个 epoch,结果最后 10 个 epoch 学习率已经降到 1e-6,loss 几乎不动,dice 停滞。
原因:CosineAnnealingLR的T_max设成了 100,但实际只训 30 个 epoch,学习率还没降到最低就停了,或者反过来,T_max设成 10,学习率过早衰减到 0。
解决:T_max必须等于总 epoch 数。如果你不确定训多少轮,先用恒定学习率跑 20 个 epoch 看 loss 曲线,确定收敛区间后再换余弦退火。Step 衰减的step_size设为总 epoch 的 1/3 到 1/2,gamma设 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_size=256, threshold=0.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]), interpolation=cv2.INTER_NEAREST) return mask_original # 使用示例 model = NestedUNet(num_classes=2).to(device) model.load_state_dict(torch.load("checkpoints/best_model.pth", map_location=device)) mask = predict_single(model, "test_images/crack_001.jpg", device) cv2.imwrite("output_mask.png", mask)threshold=0.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 就行。比如初始 lr=1e-3,eta_min=1e-5。这样后期还能有微小的梯度更新,不会完全停滞。
从那以后我每次换优化器或改 batch size,都强制走一遍「先跑 5 个 epoch 看 loss 是否稳定下降」的流程,确认没问题再开完整训练。希望帮到你。
本文还有配套的精品资源,点击获取