简介:本资源面向图像分割初学者与进阶开发者,提供基于PyTorch在VOC与Cityscapes数据集上训练DeepLabv3+的完整项目源码与流程教程,帮助读者掌握语义分割从数据准备到模型部署的全链路实践。压缩包共43个文件,约2.13MB,以23个Python脚本为核心,覆盖网络结构、数据集加载、损失函数、学习率调度与可视化等模块,另含17张png效果图、2个txt依赖与说明文件及1个md文档,目录按datasets、network、utils等分层组织,便于按模块查阅。资源已吸引492人学习下载。读者可据此理解ASPP与解码器设计、空洞卷积原理,掌握VOC与Cityscapes的预处理、训练、IoU评估及微调思路,并借助可视化结果直观对比预测与标注,快速积累多类别分割的实战经验。
1. 从 VOC 到 Cityscapes:DeepLabv3+ 在 PyTorch 里到底解决了什么问题
如果你手头有一批街景或室内场景图,想把「人、车、路面、建筑」逐像素抠出来,大概率会撞上两个现实问题:一是 VOC 这种小分辨率、少类别数据集上跑得动的模型,换到 Cityscapes 的 1024×2048 大图就显存爆炸;二是同一套网络,在 VOC 上 mIoU 能到 80 上下,直接搬到 Cityscapes 却掉到 60 出头。DeepLabv3+ 就是冲着这两个矛盾来的——它用空洞卷积(Atrous Convolution)撑大感受野而不降分辨率,用 ASPP 多尺度抓不同大小的目标,再用一个轻量 Decoder 把底层边缘细节补回来。配合 PyTorch 的动态图和成熟的数据增强生态,你可以在单卡 12GB 显存上把 VOC 训到收敛,再用多尺度裁剪策略啃 Cityscapes。这篇笔记面向的是想自己复现、调参、踩坑的从业者,不是来读论文摘要的。下面从环境、数据、模型、训练、排错一路讲到进阶技巧,每一步都给可抄的命令和参数。
2. 环境搭建与数据准备:PyTorch、CUDA 和两套数据集的目录约定
2.1 PyTorch 环境怎么装才不翻车
先说结论:不要用pip install torch裸装,版本对不上 CUDA 是新手最常见的翻车点。我一般用 conda 建独立环境,再按 PyTorch 官网的版本矩阵装。以 CUDA 11.8 为例:
conda create -n deeplab python=3.9 -y conda activate deeplab # 按官网矩阵选对应 cuda 版本的 whl,不要直接 pip install torch pip install torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118 pip install numpy opencv-python pillow tqdm tensorboard逻辑说明:python=3.9是兼容性最稳的版本,3.11 以上部分 torchvision 算子会报错。--index-url指向 CUDA 11.8 的官方 wheel 源,避免装到 CPU 版。装完必须验证:
import torch print(torch.__version__, torch.cuda.is_available(), torch.version.cuda) # 期望输出类似 2.1.0 True 11.8如果cuda.is_available()返回 False,先查驱动版本nvidia-smi,驱动低于 450 就别硬上 CUDA 11.8。参数上,torch.backends.cudnn.benchmark = True在输入尺寸固定时能提速 10%~20%,但输入尺寸变化频繁时反而变慢,训练 Cityscapes 裁剪到固定 513×513 时可以开。
2.2 VOC 与 Cityscapes 的目录结构统一
两套数据集格式不同,VOC 是JPEGImages + SegmentationClass的 PNG 掩码,Cityscapes 是leftImg8bit + gtFine且标签是 trainId 映射后的灰度图。我一般统一成如下结构,方便同一份 Dataset 代码复用:
datasets/ VOC2012/ JPEGImages/ # 原图 jpg SegmentationClass/ # 掩码 png,像素值 0-20 ImageSets/Segmentation/train.txt, val.txt Cityscapes/ leftImg8bit/train/ # 原图 png gtFine/train/ # 掩码 png,需转 trainIdCityscapes 原始gtFine的像素值是 0-33 的 labelId,必须转成 0-18 的 trainId,否则类别对不上,mIoU 会莫名其妙低 20 个点。转换脚本核心逻辑:
import numpy as np from PIL import Image # Cityscapes labelId -> trainId 映射,-1 表示忽略 id_to_trainid = {7:0,8:1,11:2,12:3,13:4,17:5,19:6,20:7,21:8,22:9, 23:10,24:11,25:12,26:13,27:14,28:15,31:16,32:17,33:18} def convert(mask_path, out_path): m = np.array(Image.open(mask_path)) out = np.full(m.shape, 255, dtype=np.uint8) # 255 作为 ignore_index for k, v in id_to_trainid.items(): out[m == k] = v Image.fromarray(out).save(out_path)参数说明:255是 ignore_index,训练时 CrossEntropyLoss 要设ignore_index=255,否则背景类会被错误计入。VOC 的掩码本身已是 0-20,边界像素是 255,同样处理。这一步不做,后面所有指标都是错的,属于典型的「黑匣子」式错误。
3. DeepLabv3+ 网络结构:ASPP、空洞卷积和 Decoder 三件套
3.1 骨干网选 ResNet 还是 MobileNet
DeepLabv3+ 本身是「骨干网 + ASPP + Decoder」的框架,骨干网决定精度和速度的平衡。VOC 上我一般用 ResNet-101,mIoU 能到 82 左右;Cityscapes 上如果显存吃紧,用 ResNet-50 或 MobileNetV2。关键改动是把 ResNet 最后两个 stage 的步长从 2 改成 1,并用空洞卷积维持感受野,输出 stride 从 32 降到 16 或 8。PyTorch 里可以这样改:
import torch.nn as nn from torchvision.models import resnet101 def make_backbone(output_stride=16): model = resnet101(weights='IMAGENET1K_V2') # 替换最后两个 stage 的 stride,配合 dilation if output_stride == 16: model.layer4[0].conv2.stride = (1, 1) model.layer4[0].downsample[0].stride = (1, 1) for m in model.layer4.modules(): if isinstance(m, nn.Conv2d) and m.kernel_size == (3, 3): m.dilation = (2, 2); m.padding = (2, 2) return model逻辑说明:output_stride=16意味着最终特征图是输入的 1/16,比默认的 1/32 保留更多空间信息,对小目标和边界更友好。代价是显存和计算量上升约 1.5 倍。dilation=2保证感受野不缩水。这一步改错,ASPP 的多尺度就形同虚设。
3.2 ASPP 的四个分支与全局池化
ASPP 是 DeepLabv3+ 的核心,用不同 dilation rate 的空洞卷积并行抓多尺度。标准配置是 1×1 卷积 + 三个 3×3 空洞卷积(rate=6,12,18)+ 全局平均池化分支,最后 concat 再 1×1 降维。rate 的选择和 output_stride 强相关:output_stride=16 时用 (6,12,18),output_stride=8 时用 (12,24,36)。写错 rate 会导致大目标分割破碎。
class ASPP(nn.Module): def __init__(self, in_ch=2048, out_ch=256, rates=(6,12,18)): super().__init__() self.branches = nn.ModuleList() self.branches.append(nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU())) for r in rates: self.branches.append(nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=r, dilation=r, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU())) self.gap = nn.Sequential(nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_ch, out_ch, 1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU()) self.project = nn.Sequential(nn.Conv2d(out_ch*5, out_ch, 1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(), nn.Dropout(0.5)) def forward(self, x): size = x.shape[-2:] feats = [b(x) for b in self.branches] g = nn.functional.interpolate(self.gap(x), size=size, mode='bilinear', align_corners=False) feats.append(g) return self.project(torch.cat(feats, dim=1))参数说明:out_ch=256是论文默认值,显存紧张可降到 128。Dropout(0.5)在小数据集上防过拟合明显,Cityscapes 这种大数据集可以降到 0.1 或关掉。全局池化分支补的是图像级上下文,去掉它 mIoU 通常掉 1~2 个点。
3.3 Decoder 怎么把底层特征接回来
DeepLabv3+ 相比 v3 的最大改进就是加了 Decoder:把骨干网 stride=4 的底层特征拿过来,1×1 卷积降到 48 通道,和 ASPP 输出上采样 4 倍后 concat,再 3×3 卷积 refine,最后上采样回原图。底层特征带边缘和纹理,对细长目标(电线杆、栏杆)提升明显。
class Decoder(nn.Module): def __init__(self, low_ch=256, mid_ch=256, num_classes=21): super().__init__() self.low_proj = nn.Sequential(nn.Conv2d(low_ch, 48, 1, bias=False), nn.BatchNorm2d(48), nn.ReLU()) self.fuse = nn.Sequential( nn.Conv2d(48+mid_ch, 256, 3, padding=1, bias=False), nn.BatchNorm2d(256), nn.ReLU(), nn.Dropout(0.5), nn.Conv2d(256, 256, 3, padding=1, bias=False), nn.BatchNorm2d(256), nn.ReLU(), nn.Dropout(0.1)) self.cls = nn.Conv2d(256, num_classes, 1) def forward(self, low, mid): low = self.low_proj(low) mid = nn.functional.interpolate(mid, size=low.shape[-2:], mode='bilinear', align_corners=False) return self.cls(self.fuse(torch.cat([low, mid], dim=1)))参数说明:low_ch=48是论文实验出来的甜点值,太大反而稀释 ASPP 的语义信息。align_corners=False在 PyTorch 里是分割任务的标准选择,设 True 会导致边缘错位半个像素,累积起来 mIoU 掉 0.5 左右。
4. 训练流程:从 VOC 单卡跑通到 Cityscapes 多尺度裁剪
4.1 VOC 上的最小可跑训练脚本
先把 VOC 跑通再上 Cityscapes,这是血泪经验。VOC 数据小、收敛快,能快速验证数据管道和 loss 是否正确。核心训练循环:
import torch, torch.nn as nn from torch.utils.data import DataLoader from dataset import VOCSegDataset from model import DeepLabV3Plus device = 'cuda' model = DeepLabV3Plus(num_classes=21, backbone='resnet101', output_stride=16).to(device) criterion = nn.CrossEntropyLoss(ignore_index=255) optimizer = torch.optim.SGD(model.parameters(), lr=0.007, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.PolynomialLR(optimizer, total_iters=30000, power=0.9) train_loader = DataLoader(VOCSegDataset('datasets/VOC2012', split='train', crop_size=513), batch_size=8, shuffle=True, num_workers=4, drop_last=True) model.train() for it, (img, mask) in enumerate(train_loader): img, mask = img.to(device), mask.to(device) out = model(img) out = nn.functional.interpolate(out, size=mask.shape[-2:], mode='bilinear', align_corners=False) loss = criterion(out, mask) optimizer.zero_grad(); loss.backward(); optimizer.step(); scheduler.step() if it % 50 == 0: print(f'iter {it} loss {loss.item():.4f} lr {scheduler.get_last_lr()[0]:.5f}') if it >= 30000: break逻辑说明:lr=0.007是 DeepLab 系列的经典 poly 策略起点,batch=8 时稳定。PolynomialLR的power=0.9让学习率平滑衰减,比 StepLR 收敛更稳。ignore_index=255必须和数据集掩码一致。输出上采样到 mask 尺寸再算 loss,否则尺寸不匹配直接报错。VOC 上这套配置约 3 万 iteration 收敛,mIoU 80 左右。
4.2 Cityscapes 的裁剪、同步 BN 和类别平衡
Cityscapes 原图 1024×2048,直接整图训练 12GB 显存只能放 batch=1,BN 统计极不稳定。常见做法是随机裁剪 769×769 或 513×513,batch 提到 8~16。如果多卡,必须用SyncBatchNorm把 BN 统计跨卡同步:
model = nn.SyncBatchNorm.convert_sync_batchnorm(model) model = nn.parallel.DistributedDataParallel(model, device_ids=[local_rank])参数说明:单卡时不要用 SyncBN,反而慢。Cityscapes 类别极不平衡,road、building 占大头,pole、traffic light 很少。我一般给稀有类加权:
# 19 类权重,稀有类放大 2~3 倍 weights = torch.tensor([1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0, 3.0,3.0,1.0,1.0,1.0,1.0,1.0,2.0,2.0]).to(device) criterion = nn.CrossEntropyLoss(weight=weights, ignore_index=255)不加权的话,pole 类 IoU 经常只有 30 出头,拉低整体 mIoU。裁剪时用RandomCrop加RandomScale(0.5~2.0)和水平翻转,Cityscapes 上能涨 2~3 个点。
4.3 验证指标怎么算才可信
mIoU 计算必须逐类累加混淆矩阵,不能简单平均 batch 内的 IoU。正确做法:
def update_confusion(conf, pred, target, num_classes=21, ignore=255): mask = target != ignore pred, target = pred[mask], target[mask] idx = target * num_classes + pred conf += torch.bincount(idx, minlength=num_classes**2).reshape(num_classes, num_classes) def compute_miou(conf): iou = conf.diag() / (conf.sum(1) + conf.sum(0) - conf.diag() + 1e-10) return iou.mean().item(), iou参数说明:conf是 num_classes×num_classes 的混淆矩阵,行是真值、列是预测。1e-10防除零。验证时要把原图 resize 回原始尺寸再算,否则小目标 IoU 虚高。这套指标和官方 Cityscapes 评测脚本对齐,误差在 0.1 以内。
5. 避坑与排查:训练不收敛、显存爆炸、mIoU 虚低的真实原因
5.1 loss 一直卡在 2.9 不降
现象:训练几百 iteration,loss 稳定在 ln(21)≈3.04 附近不动。原因:掩码像素值和 num_classes 不匹配,比如 Cityscapes 没转 trainId,掩码里有 33 这种值,CrossEntropyLoss 直接忽略或报错。解决:打印mask.max()和mask.unique(),确认范围在 0~num_classes-1 或 255。VOC 掩码边界是 255,Cityscapes 转换后也应是 255。
5.2 显存爆炸但 batch 已经很小
现象:batch=2 还 OOM。原因:ASPP 的 dilation 太大导致中间特征图没降采样,或者 Decoder 里 concat 后通道数翻倍。解决:先确认output_stride=16而不是 8,8 会多占约 40% 显存;再把 ASPP 的out_ch从 256 降到 128;最后开torch.cuda.amp混合精度:
scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): out = model(img); loss = criterion(out, mask) scaler.scale(loss).backward(); scaler.step(optimizer); scaler.update()混合精度通常省 30%~40% 显存,mIoU 掉 0.2 以内,值得开。
5.3 mIoU 比论文低 10 个点
现象:VOC 只跑到 70。原因:验证时没做多尺度或翻转增强,或者忘了把模型切eval()导致 BN 用 batch 统计。解决:验证前model.eval(),用with torch.no_grad();推理时对 (0.75,1.0,1.25) 三个尺度加水平翻转取平均,VOC 上能涨 3~4 个点。另外确认预训练权重加载成功,骨干网没加载 ImageNet 权重会低 5 个点以上。
5.4 边缘分割毛刺严重
现象:物体边界锯齿、断裂。原因:Decoder 的底层特征没接,或者上采样用了最近邻。解决:确认 Decoder 输入是 stride=4 的layer1输出而不是 stride=8;所有上采样用mode='bilinear', align_corners=False;最后输出上采样到原图尺寸再 argmax,不要在低分辨率 argmax 再放大。
5.5 多卡训练比单卡还慢
现象:DDP 开了但吞吐没提升。原因:num_workers 太小导致数据加载成瓶颈,或者 SyncBN 在小 batch 下通信开销大。解决:num_workers设成 CPU 核数的 1/2,开pin_memory=True和persistent_workers=True;单卡 batch 小于 4 时别用 SyncBN。用torch.profiler看是数据加载还是前向慢,别凭感觉调。
6. 进阶技巧:把 DeepLabv3+ 推到生产可用的几个手段
训练收敛只是起点,真正上线还要过推理速度和部署这两关。第一个技巧是推理加速:把模型转 ONNX 再用 TensorRT,Cityscapes 1024×2048 单张从 200ms 降到 40ms 左右。导出时注意固定输入尺寸,动态轴只留 batch:
torch.onnx.export(model, torch.randn(1,3,513,513).cuda(), 'deeplabv3plus.onnx', input_names=['input'], output_names=['output'], dynamic_axes={'input':{0:'batch'}, 'output':{0:'batch'}}, opset_version=11)参数说明:opset_version=11对 interpolate 算子支持最好,低于 11 上采样会报错。导出后务必用 onnxruntime 对齐一次输出,误差超过 1e-3 说明有算子没对齐。
第二个技巧是类别后处理。Cityscapes 里 road 和 sidewalk 经常混,我一般对混淆矩阵里 IoU 最低的几类做形态学开闭运算,或者用 CRF 后处理,边界能干净不少,mIoU 涨 1 个点左右。CRF 慢,实时场景慎用。
第三个技巧是难例挖掘。训练后期把验证集里 IoU 最低的 10% 图像挑出来,过采样加入训练,对 pole、traffic sign 这类小目标提升明显。我一般每 5000 iteration 做一次,配合 poly 学习率,整体能再涨 1.5 个点。
最后一个习惯:每次改完配置,先跑 500 iteration 看 loss 曲线和验证 mIoU 趋势,别一上来就训 3 万 iteration。我踩过最深的坑就是数据转换脚本写错,白训了两天。现在固定流程是——转完数据先可视化 10 张掩码叠加图,确认类别颜色对得上再开训。这个后悔药,希望你不用吃。希望帮到你。
本文还有配套的精品资源,点击获取