简介:这份资源面向计算机视觉方向的研究者、算法工程师与深度学习学习者,提供在Cityscapes数据集上训练DeepLabV3语义分割模型的完整PyTorch实现,帮助读者理解ASPP空洞空间金字塔池化与全局上下文模块的设计思路,并掌握从数据预处理到模型评估的全流程。压缩包共18个文件,以12个Python脚本为核心,涵盖模型定义、训练、评估与数据加载等模块,另含4个pth预训练权重、1个md说明文档与1个license许可文件,整体约258.23MB,目录结构清晰,便于按模块查阅与二次开发。目前已有2094人学习下载,适合希望快速复现基线、改进分割精度或深入理解DeepLabV3内部机制的读者参考。
1. 语义分割落地为什么绕不开 DeepLabV3 + Cityscapes
如果你手头有一批街景、园区或道路图像,需要把每个像素分到「车、人、路面、建筑」这些类别里,那语义分割就是绕不开的一环。而 Cityscapes 是这套任务里最常被拿来当基准的数据集,30 个类别、5000 张精细标注图,覆盖城市道路场景。DeepLabV3 则是把空洞卷积和 ASPP 模块做到工程上足够稳的经典结构,PyTorch 实现版本多、改起来方便,适合做二次开发和迁移。
这份资源就是一套在 Cityscapes 上训练 DeepLabV3 的 PyTorch 代码,包含数据加载、模型定义、训练循环和推理脚本。它解决的不是「从零教你 PyTorch」的问题,而是让你跳过环境折腾和结构拼装,直接跑通一条完整的训练链路。适合已经装好 PyTorch、想快速验证分割效果的人,也适合拿它当骨架改自己数据集的从业者。下面按「资源是什么 → 怎么用 → 坑在哪」的顺序拆开讲。
2. 环境搭建与数据准备:从 pytorch 安装到 Cityscapes 目录结构
2.1 环境依赖与 pytorch 安装的版本选择
这套代码对 PyTorch 版本不算挑剔,但 CUDA 版本和显卡驱动要对上。常见做法是用 conda 建一个独立环境,避免和系统里的 python 安装冲突。如果你用的是较新的显卡,比如 7900xtx 这类,在 WSL 下跑 pytorch 环境搭建也能走通,只是要确认 ROCm 或 CUDA 的适配情况。
# 创建独立环境,python 版本建议 3.8 到 3.10 conda create -n deeplab python=3.9 -y conda activate deeplab # 安装 pytorch,以 CUDA 11.8 为例,具体命令去 pytorch 官网按你的驱动选 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 其余依赖 pip install numpy opencv-python pillow tqdm tensorboard这里的关键参数是--index-url,它决定你下载的是 CPU 版还是 GPU 版。很多人 pytorch 安装教程超详细看了一堆,结果装完torch.cuda.is_available()返回 False,多半是驱动版本和 CUDA 版本没对齐。装完先跑一句验证:
import torch print(torch.__version__) print(torch.cuda.is_available()) # 期望 True print(torch.cuda.get_device_name(0))如果第二行是 False,别急着改代码,先查驱动。这是最常见的翻车点,后面避坑章节会细说。
2.2 Cityscapes 数据集的下载与目录组织
Cityscapes 官方提供两个包:leftImg8bit是原图,gtFine是标注。训练用 train 划分,验证用 val 划分。下载后解压,目录结构要整理成代码能识别的形式:
cityscapes/ ├── leftImg8bit/ │ ├── train/ │ │ ├── aachen/ │ │ │ ├── aachen_000000_000019_leftImg8bit.png │ │ └── ... │ └── val/ └── gtFine/ ├── train/ │ ├── aachen/ │ │ ├── aachen_000000_000019_gtFine_labelIds.png │ │ ├── aachen_000000_000019_gtFine_instanceIds.png │ │ └── ... └── val/代码里读的是labelIds.png,不是labelTrainIds.png,这两个容易搞混。labelIds是原始类别 ID,labelTrainIds是映射到 19 个训练类别的版本。如果你直接用labelTrainIds,需要改数据加载里的映射逻辑,否则类别对不上,训练 loss 会异常。
2.3 数据加载器的参数配置
数据加载部分通常继承torch.utils.data.Dataset,核心是__getitem__里做同步的随机裁剪和归一化。下面是一个典型的加载逻辑:
import os import torch import numpy as np from PIL import Image from torch.utils.data import Dataset, DataLoader import torchvision.transforms as T class CityscapesDataset(Dataset): def __init__(self, root, split='train', crop_size=(512, 1024)): self.root = root self.split = split self.crop_size = crop_size self.images = [] self.labels = [] # 遍历目录收集文件对 img_dir = os.path.join(root, 'leftImg8bit', split) lbl_dir = os.path.join(root, 'gtFine', split) for city in os.listdir(img_dir): for f in os.listdir(os.path.join(img_dir, city)): if f.endswith('_leftImg8bit.png'): img_path = os.path.join(img_dir, city, f) lbl_name = f.replace('_leftImg8bit.png', '_gtFine_labelIds.png') lbl_path = os.path.join(lbl_dir, city, lbl_name) if os.path.exists(lbl_path): self.images.append(img_path) self.labels.append(lbl_path) def __len__(self): return len(self.images) def __getitem__(self, idx): img = Image.open(self.images[idx]).convert('RGB') lbl = Image.open(self.labels[idx]) # 同步随机裁剪,保证图像和标签对齐 i, j, h, w = T.RandomCrop.get_params(img, self.crop_size) img = T.functional.crop(img, i, j, h, w) lbl = T.functional.crop(lbl, i, j, h, w) img = T.functional.to_tensor(img) img = T.functional.normalize(img, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) lbl = torch.from_numpy(np.array(lbl)).long() return img, lblcrop_size设成 (512, 1024) 是显存和精度的折中。Cityscapes 原图是 1024x2048,直接整图训练对显存要求高,裁剪后 batch size 能开到 8 到 16。normalize用的 ImageNet 均值方差,因为 DeepLabV3 主干通常是在 ImageNet 上预训练的。标签转long是因为交叉熵损失要求 int64。如果这里忘了同步裁剪,图像和标签会错位,训练出来的模型预测全是乱的,这是血泪经验。
3. DeepLabV3 模型结构与 ASPP 模块的实现细节
3.1 主干网络选型:ResNet 还是 MobileNet
DeepLabV3 本身是个元结构,主干可以换。代码里常见的是 ResNet-101 和 MobileNetV2 两种。ResNet-101 精度高但显存吃紧,MobileNetV2 轻量适合边缘部署。选哪个取决于你的场景:如果只是验证算法效果,用 ResNet-101;如果要往移动端推,MobileNetV2 更实际。
import torchvision.models as models import torch.nn as nn def build_backbone(name='resnet101', pretrained=True): if name == 'resnet101': model = models.resnet101(pretrained=pretrained) # 去掉最后的全连接和平均池化,保留卷积特征 return nn.Sequential(*list(model.children())[:-2]), 2048 elif name == 'mobilenetv2': model = models.mobilenet_v2(pretrained=pretrained) return model.features, 1280 else: raise ValueError(f'Unsupported backbone: {name}')pretrained=True会下载 ImageNet 预训练权重,第一次跑需要联网。返回的通道数 2048 或 1280 要传给 ASPP 模块,作为输入通道。如果这里通道数写错,后面 ASPP 的卷积层会直接报维度不匹配。
3.2 ASPP 模块的空洞卷积率设置
ASPP 是 DeepLabV3 的核心,用不同空洞率的卷积并行提取多尺度特征。标准配置是 rates=[6, 12, 18],加上一个全局平均池化分支。空洞率的选择和输入尺寸有关,如果裁剪尺寸改成 256x512,rates 也要相应调小,否则感受野超出图像边界,padding 会引入大量无效信息。
class ASPP(nn.Module): def __init__(self, in_channels, out_channels=256, rates=[6, 12, 18]): super().__init__() self.branches = nn.ModuleList() # 1x1 卷积分支 self.branches.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) )) # 不同空洞率的 3x3 卷积分支 for r in rates: self.branches.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=r, dilation=r, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) )) # 全局池化分支 self.global_pool = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) self.project = nn.Sequential( nn.Conv2d(out_channels * (len(rates) + 2), out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), nn.Dropout(0.5) ) def forward(self, x): size = x.shape[-2:] feats = [branch(x) for branch in self.branches] gp = self.global_pool(x) gp = nn.functional.interpolate(gp, size=size, mode='bilinear', align_corners=False) feats.append(gp) out = torch.cat(feats, dim=1) return self.project(out)padding=r和dilation=r必须相等,这样输出尺寸才和输入一致。Dropout(0.5)是防止过拟合,如果数据集小可以调到 0.3。全局池化分支最后要插值回原尺寸再拼接,align_corners=False是 PyTorch 分割任务里的常见设置,和True的区别在边界像素上,用错会导致边缘预测偏移。
3.3 分类头与输出层
ASPP 输出后接一个 1x1 卷积把通道数映射到类别数。Cityscapes 训练用 19 类,如果直接用 30 类原始标签,这里改成 30。
class DeepLabV3(nn.Module): def __init__(self, backbone='resnet101', num_classes=19): super().__init__() self.backbone, in_ch = build_backbone(backbone) self.aspp = ASPP(in_ch, 256) self.classifier = nn.Conv2d(256, num_classes, 1) def forward(self, x): size = x.shape[-2:] feat = self.backbone(x) feat = self.aspp(feat) out = self.classifier(feat) # 上采样回输入尺寸 out = nn.functional.interpolate(out, size=size, mode='bilinear', align_corners=False) return out输出上采样回原图尺寸是为了和标签算 loss 时维度对齐。如果显存不够,可以在低分辨率上算 loss 再上采样,但代码里通常直接插值。num_classes这个参数一定要和数据集类别数一致,否则交叉熵会报 index 越界。
4. 训练循环与损失函数:从交叉熵到忽略标签的处理
4.1 损失函数与忽略标签
Cityscapes 里有些像素是无效的,比如 ego vehicle 或者未标注区域,标签 ID 是 255。交叉熵要设ignore_index=255,否则这些像素会参与梯度计算,拉偏模型。
import torch.nn as nn criterion = nn.CrossEntropyLoss(ignore_index=255)如果类别不均衡严重,可以加类别权重,但 Cityscapes 的 19 类分布还算均匀,一般不加也能收敛。ignore_index这个参数是必须的,忘了设的话 loss 会莫名其妙偏高,而且验证集指标上不去。
4.2 优化器与学习率策略
常见做法是 SGD 加 poly 衰减,初始学习率 0.01,动量 0.9,权重衰减 1e-4。poly 衰减的公式是lr = base_lr * (1 - iter / max_iter) ** 0.9。
import torch.optim as optim optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) def poly_lr(base_lr, iter, max_iter, power=0.9): return base_lr * (1 - iter / max_iter) ** power # 训练循环里每个 iter 更新 for it, (imgs, labels) in enumerate(loader): lr = poly_lr(0.01, it, max_iter=40000) for param_group in optimizer.param_groups: param_group['lr'] = lr imgs, labels = imgs.cuda(), labels.cuda() outputs = model(imgs) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step()max_iter设 40000 是 Cityscapes 上的常见值,batch size 8 的话大概跑几十个 epoch。poly 衰减比 step 衰减更平滑,后期学习率降得慢,有利于收敛到更优解。如果 loss 震荡厉害,先把学习率降到 0.005 试试。
4.3 验证指标与模型保存
验证时算 mIoU,按类别求交并比再平均。保存模型建议按 mIoU 最高的存,而不是按 loss 最低,因为 loss 和分割质量不完全正相关。
def compute_iou(pred, label, num_classes=19, ignore=255): pred = pred.argmax(dim=1) mask = label != ignore pred = pred[mask] label = label[mask] ious = [] for c in range(num_classes): inter = ((pred == c) & (label == c)).sum().item() union = ((pred == c) | (label == c)).sum().item() if union > 0: ious.append(inter / union) return sum(ious) / len(ious) if ious else 0.0argmax(dim=1)是在类别维度上取最大,得到每个像素的预测类别。mask过滤掉忽略标签。如果某个类别在验证集里没出现,union为 0,跳过不计入平均,这是常见处理方式。保存模型时用torch.save(model.state_dict(), 'best.pth'),只存权重,加载时先建模型再load_state_dict。
5. 避坑与排查:训练不收敛、显存溢出、指标异常的常见原因
5.1 现象:loss 一直不降,停在 2.9 左右
原因通常是标签映射错了。如果你用的是labelTrainIds.png但代码按labelIds的 19 类去读,类别 ID 对不上,模型学不到有效信息。解决方法是确认读的是labelIds还是labelTrainIds,两者选其一,并保证num_classes和映射逻辑一致。
5.2 现象:CUDA out of memory
原因可能是 batch size 太大、裁剪尺寸太大,或者没释放中间变量。先把 batch size 降到 4,裁剪尺寸降到 256x512 试试。如果还不行,检查是不是在验证时忘了torch.no_grad(),导致计算图一直累积。加上with torch.no_grad():能省不少显存。
5.3 现象:mIoU 卡在 0.3 上不去
原因可能是学习率太大导致震荡,或者 ASPP 的 rates 和输入尺寸不匹配。先看训练 loss 是否正常下降,如果 loss 降但 mIoU 不涨,多半是过拟合或者验证集预处理和训练不一致。检查验证时有没有做同样的归一化,均值和方差是否一致。
5.4 现象:预测结果全是同一类
原因通常是类别权重严重失衡,或者最后一层卷积初始化有问题。检查classifier的权重初始化,默认 PyTorch 的初始化一般没问题,但如果自己改了要确认。另外看看训练数据里是不是某一类占了绝大多数,如果是,考虑加类别权重或者重采样。
5.5 现象:训练速度特别慢
原因可能是数据加载成了瓶颈。num_workers设成 4 或 8,pin_memory=True,能明显加快。如果用的是机械硬盘,数据读取慢是硬伤,换 SSD 或者把数据预加载到内存里。另外确认cudnn.benchmark = True有没有开,开了能自动选最优卷积算法。
6. 进阶技巧:用预训练权重加速收敛与推理部署
6.1 加载预训练权重做迁移学习
如果不想从零训练,可以加载在 Cityscapes 上已经训好的权重,只微调最后几层。常见做法是冻结主干的前面几层,只训 ASPP 和分类头。
model = DeepLabV3(backbone='resnet101', num_classes=19) state = torch.load('deeplabv3_cityscapes.pth', map_location='cpu') model.load_state_dict(state, strict=False) # 冻结主干前 5 层 for name, param in model.backbone.named_parameters(): if 'layer1' in name or 'layer2' in name: param.requires_grad = False optimizer = optim.SGD(filter(lambda p: p.requires_grad, model.parameters()), lr=0.001, momentum=0.9)strict=False允许部分权重不匹配,适合你改了分类头类别数的情况。冻结浅层是因为浅层特征通用性强,没必要重新学。学习率调小到 0.001,因为预训练权重已经不错了,大步长容易破坏。
6.2 推理脚本与单张图像预测
推理时要注意模型切到eval()模式,并且用no_grad包住。
model.eval() with torch.no_grad(): img = Image.open('test.png').convert('RGB') img_t = T.functional.to_tensor(img) img_t = T.functional.normalize(img_t, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) img_t = img_t.unsqueeze(0).cuda() out = model(img_t) pred = out.argmax(dim=1).squeeze().cpu().numpy()unsqueeze(0)是加 batch 维度,因为模型要求输入是 4D。argmax后得到每个像素的类别索引,可以映射成颜色可视化。如果推理结果和训练时差距大,检查eval()有没有调,BatchNorm 在训练和推理时的行为不一样。
6.3 导出 ONNX 做部署
如果需要部署到非 PyTorch 环境,可以导出 ONNX。
dummy = torch.randn(1, 3, 512, 1024).cuda() torch.onnx.export(model, dummy, 'deeplabv3.onnx', input_names=['input'], output_names=['output'], opset_version=11)opset_version=11兼容性较好,dummy的尺寸要和实际推理一致。导出后可以用 onnxruntime 验证输出是否和 PyTorch 一致,差异在 1e-3 以内算正常。
从那以后我每次跑分割任务,都强制先验证一遍数据加载的标签映射和归一化参数,这两处出错最隐蔽,也最耗时间。希望帮到你。
本文还有配套的精品资源,点击获取