☰
DeepLabv3+图像分割实战:基于PyTorch训练VOC与Cityscapes的完整方案
2026/10/2 18:16:46 网站建设 项目流程

简介:这是基于Pytorch实现DeepLabv3+图像分割算法的实战项目源码包,覆盖VOC与Cityscapes两大数据集的训练、验证与预测完整流程,并配套详细流程教程,适合具备一定深度学习基础、正在学习语义分割或准备竞赛项目的开发者。包内共55个文件,主要包含23个Python脚本与2个txt配置说明,按metrics、datasets、network、utils等模块组织,内置ResNet、Xception、MobileNetV2、HRNet等多种骨干网络,涵盖损失函数、学习率调度、数据增强、可视化与预测等关键环节;17张png图片展示分割结果与原图对照,便于直观评估效果,并附扩展内容与说明文档。整体压缩包仅2.25MB,轻量易用;目前已有139人学习下载。项目代码结构清晰,从数据预处理到模型训练再到结果可视化均有对应实现,可作为图像分割入门到进阶的参考模板,也适合在此基础上改造迁移至自有数据集或二次开发。

1. 图像分割实战:为什么 DeepLabv3+ 是跑通 VOC 和 Cityscapes 的首选

做图像分割的从业者都有个共识:与其在五花八门的 SOTA 模型里挑花眼,不如先吃透 DeepLabv3+。这个架构把"空洞卷积 + 多尺度特征融合 + 解码器"这三板斧练到了极致,在 VOC 2012 和 Cityscapes 这两个最经典的基准上都有成熟稳定的 PyTorch 实现。你的诉求很直接:拿到一份能跑的训练流程,知道每个参数为什么这么设,遇到问题知道去哪排查。这篇文章就按"环境搭建 → 数据准备 → 模型构建 → 训练调参 → 避坑 → 验证"的顺序,把一套完整可复现的 DeepLabv3+ 训练方案讲透。适合刚入门语义分割的研究生,也适合要把分割模型落地到自有数据的工程师。

2. PyTorch 环境与数据集准备:先把"原料"备齐

2.1 PyTorch 环境搭建:CUDA 版本与 conda 隔离

训练 DeepLabv3+ 这类分割模型,GPU 几乎是必须的。纯 CPU 跑 Cityscapes 这种 2975 张训练图的数据集,一个 epoch 可能要几个小时,完全失去调参的意义。我常用的方式是先用 conda 建一个独立环境,避免把系统 Python 搞乱:

conda create -n deeplab python=3.9 -y conda activate deeplab conda install pytorch torchvision torchaudio cudatoolkit=11.3 -c pytorch -c conda-forge python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

这段命令的关键在于第三行的cudatoolkit=11.3。PyTorch 的安装版本必须和你本机 NVIDIA 驱动支持的 CUDA 版本匹配——注意是"驱动支持",不是"驱动自带版本"更不是"系统里装了 CUDA"。我在 WSL 里配置 PyTorch 环境时就踩过坑:Windows 下驱动装的是 535 系列,对应的 CUDA 上限是 12.2,我强行装了 CUDA 11.3 的 PyTorch,结果 torch.cuda.is_available() 返回 False。

提示:判断驱动支持的最高 CUDA 版本,在命令行执行nvidia-smi,右上角的 "CUDA Version" 就是上限。只要 PyTorch 要求的 CUDA 不高于这个值,就能正常工作。

2.2 VOC 2012 数据集的下载与目录结构

VOC 2012 是分割入门最友好的数据集,20 个物体类 + 1 个背景类,JPEG 原图加 PNG 掩码。下载和解压没有技术含量,但目录结构必须一次搞对,否则后面所有路径都要改:

wget http://host.robots.ox.ac.uk/pascal/VOC/voc2012/VOCtrainval_11-May-2012.tar tar -xvf VOCtrainval_11-May-2012.tar # 解压后得到 VOCdevkit/VOC2012/ 目录 # 关键子目录: # JPEGImages/ —— 原图,统一 .jpg # SegmentationClass/ —— 分割掩码,PNG 格式,每个像素是类别编号 # ImageSets/Segmentation/ —— train.txt, val.txt 等划分文件

分割任务里有个经典做法叫"augmented set":VOC 官方 trainval 只有 2913 张图,很多人会额外引入 Hariharan 等人的 SBD 数据(包含更多边界标注)来扩充训练集,通常能把 mIOU 提升 2 到 4 个百分点。具体做法是下载 SBD 的掩码后,把它的训练图片并入 VOC 的train.txt,同时用 VOC 自己的掩码覆盖掉 SBD 的掩码,因为 SBD 的标注边界更粗。这个操作在后续训练脚本里就是一个train.txt的文本合并操作。

2.3 Cityscapes 数据集的注册与格式转换

Cityscapes 有 5000 张精细标注(gtFine),覆盖 50 个城市街景,19 个类别。它的下载不像 VOC 那样直接 wget,需要在官网注册学术账号后同意条款才能拿链接。下载下来的是 ZIP 压缩包,解压后的目录结构如下:

leftImg8bit/ train/ {city}/{city}_000000_000000_leftImg8bit.png val/ {city}/{city}_000000_000000_leftImg8bit.png gtFine/ train/ {city}/{city}_000000_000000_gtFine_labelTrainIds.png val/ {city}/{city}_000000_000000_gtFine_labelTrainIds.png

这里有个新手最容易绕晕的点:gtFine 每个图例有 5 个不同后缀的 png——

  • _gtFine_labelIds.png:原始标注,包含 id(0~33 的稀疏编号)
  • _gtFine_labelTrainIds.png:转换后的标注,将 34 个原始类合并成 19 个训练类(trainId),像素值 0~18

训练时一定用labelTrainIds,因为 34 类里很多类(如墙体、栅栏下的细分)在评估时会被忽略,直接用原始 labelIds 训练会引入不可控的噪声和类别不平衡。更坑的是,有些人从网盘拿到的是已经合并好的trainId版本,但文件名里仍然保留labelIds,肉眼根本分不出来——唯一的判断方式是打印像素值看看是不是 0~18 连续分布。

如果拿到的是原始 labelIds,需要自己转换。常见做法是维护一个 34 维的映射数组(每项填对应 trainId,无效类填 255),用 PyTorch 的 Tensor 索引一次性完成。这个逻辑写在后面的训练脚本里更合适,此处先放一个验证脚本:

# verify_cityscapes.py —— 检查数据格式是否统一 import numpy as np label = np.array(Image.open("xxx_gtFine_labelIds.png")) print("像素值集合:", np.unique(label)) # 期望输出:{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 255} # 如果出现 33 这种稀疏 id,说明是原始 labelIds,需要先转换

2.4 数据集目录统一:让训练脚本只依赖一个配置项

我的习惯是把 VOC 和 Cityscapes 的数据读取逻辑写进同一个Dataset类,用data_root参数切换。如果你拿到一个开源项目源码,重点关注它是否已经做好这种抽象——很多教程项目的代码把路径写死,换数据集就要改源码,这是体验极差但极为普遍的情况。一个清爽的组织方式是把两个数据集放到同一级目录:

data/ voc/ JPEGImages/ SegmentationClass/ ImageSets/ cityscapes/ leftImg8bit/ gtFine/

3. DeepLabv3+ 模型结构拆解:从 ResNet 骨干到 ASPP 再到解码器

3.1 架构总览:编码器-解码器和空洞卷积的核心价值

DeepLabv3+ 整体分两段:编码器负责从原图 512×512 提取高层语义特征(输出 stride 16),解码器把特征恢复到 4× 分辨率后再上采样回原图。和纯编码器模型(如 PSPNet)比,v3+ 加入了解码器,边界细节明显更好,这是它在 Cityscapes 这种细节密集的数据集上至今仍是强 baseline 的原因。

编码器内部最关键的是 ASPP(Atrous Spatial Pyramid Pooling)。它用 4 个并行的空洞卷积分支(rate 分别为 6、12、18,加一个全局平均池化分支),每个分支输出 256 通道,最后 concat 成 1280 通道再过一层 1×1 卷积压回 256。空洞卷积的意义在于:不增加参数量就能扩大感受野,rate=18 时 3×3 卷积的实际感受野等效于 37×37。如果没有 ASPP,单纯堆卷积层到 stride 16 会丢失大量上下文——典型场景就是道路分割里的大片同色路面,小感受野网络容易把路面中央误判成其他类。

解码器部分是 v3+ 的招牌设计:把编码器输出的 stride 16 特征上采样 4 倍,与 ResNet 中 stage2(即 layer1 输出,stride 4)的低层特征做 concat。低层特征先过 1×1 卷积把 512 通道压到 48,目的是平衡与高层特征的通道数比重,防止低层特征喧宾夺主。concat 后接两层 3×3 卷积(256 通道),最后上采样到原图尺寸。

3.2 用 PyTorch 构建最小可用的 DeepLabv3+ 模型

这里给一个精简但完整的模型实现。为了让你能直接对照,删掉了 Dropout 等细节,保留核心结构:

# modeling.py import torch import torch.nn as nn class ASPP(nn.Module): def __init__(self, in_ch=2048, inner_ch=256): super().__init__() self.conv1 = nn.Conv2d(in_ch, inner_ch, 1, bias=False) self.bn1 = nn.BatchNorm2d(inner_ch) self.conv2 = nn.Conv2d(in_ch, inner_ch, 3, padding=6, dilation=6, bias=False) self.bn2 = nn.BatchNorm2d(inner_ch) self.conv3 = nn.Conv2d(in_ch, inner_ch, 3, padding=12, dilation=12, bias=False) self.bn3 = nn.BatchNorm2d(inner_ch) self.conv4 = nn.Conv2d(in_ch, inner_ch, 3, padding=18, dilation=18, bias=False) self.bn4 = nn.BatchNorm2d(inner_ch) self.pool = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_ch, inner_ch, 1, bias=False), nn.BatchNorm2d(inner_ch) ) self.final = nn.Sequential( nn.Conv2d(inner_ch * 5, inner_ch, 1, bias=False), nn.BatchNorm2d(inner_ch) ) def forward(self, x): x1 = torch.relu(self.bn1(self.conv1(x))) x2 = torch.relu(self.bn2(self.conv2(x))) x3 = torch.relu(self.bn3(self.conv3(x))) x4 = torch.relu(self.bn4(self.conv4(x))) x5 = torch.relu(self.pool(x)) # 全局池化分支要上采样回原尺寸 x5 = torch.nn.functional.interpolate(x5, size=x.shape[2:], mode='bilinear', align_corners=True) return torch.relu(self.final(torch.cat([x1, x2, x3, x4, x5], dim=1)))

ASPP 分支参数的逻辑:rate 从 6 到 18 逐渐增大,覆盖从近距离到远距离的上下文;每个分支内padding = dilation,保证输出 feature map 尺寸不变。这里必须注意最终 concat 的维度是inner_ch * 5(4 个卷积分支 + 1 个池化分支),很多初学者在这个维度上少乘 5 导致 channel 对不上。

class DeepLabV3Plus(nn.Module): def __init__(self, n_classes=21, backbone='resnet101'): super().__init__() resnet = torchvision.models.resnet101(pretrained=True) # 去掉 resnet 最后的全局池化和全连接层 self.layer0 = nn.Sequential(resnet.conv1, resnet.bn1, resnet.relu, resnet.maxpool) self.layer1 = resnet.layer1 self.layer2 = resnet.layer2 self.layer3 = resnet.layer3 self.layer4 = resnet.layer4 # 低层特征(layer1 输出)压缩到 48 通道 self.low_conv = nn.Conv2d(256, 48, 1, bias=False) self.low_bn = nn.BatchNorm2d(48) self.aspp = ASPP(in_ch=2048) self.decoder = nn.Sequential( nn.Conv2d(256 + 48, 256, 3, padding=1, bias=False), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Conv2d(256, 256, 3, padding=1, bias=False), nn.BatchNorm2d(256), nn.ReLU(inplace=True) ) self.classifier = nn.Conv2d(256, n_classes, 1) def forward(self, x): h = self.layer0(x) low_feat = self.layer1(h) # stride 4, 256通道 h = self.layer2(low_feat) h = self.layer3(h) h = self.layer4(h) # stride 16, 2048通道 h = self.aspp(h) low_feat = torch.relu(self.low_bn(self.low_conv(low_feat))) h = torch.nn.functional.interpolate(h, size=low_feat.shape[2:], mode='bilinear', align_corners=True) h = torch.cat([h, low_feat], dim=1) # (256+48)通道 h = self.decoder(h) # 直接上采样到输入尺寸,不额外学反卷积 return torch.nn.functional.interpolate(h, size=x.shape[2:], mode='bilinear', align_corners=True)

代码里的pretrained=True是训练成败的分水岭。分割任务的数据量普遍比 ImageNet 少一个数量级,冷启动训练会让 backbone 很难收敛。用 ImageNet 预训练权重初始化后,模型只需在分割数据上做"微调",通常能比随机初始化高出 10 个点以上的 mIOU。VOC 是 21 类(含背景),Cityscapes 是 19 类,所以n_classes必须跟着改,最后一层classifier的参数数量也会随之变化,预训练权重里没有这一层的对应项,加载时要用strict=False。

提示:如果你的显卡显存不足 16GB,把 backbone 换成resnet50(在__init__里把resnet101换成resnet50,同时 layer4 的输出通道还是 2048,不需要改 ASPP),训练速度提升约 40%,mIOU 只下降 1 到 2 个点。

4. 训练流程与参数设置:跑通 VOC 和 Cityscapes 的完整方案

4.1 数据加载:标签忽略机制与数据增强

分割训练加载图片和掩码,两者路径不同但名称有规律。VOC 的图片是2007_000033.jpg,掩码是2007_000033.png;Cityscapes 的图片名结尾是leftImg8bit.png,掩码名结尾是gtFine_labelTrainIds.png。DataLoader 的核心逻辑分三步:读图 → 同步裁剪 → 归一化。这里单独强调同步裁剪:图片和掩码必须使用同一个随机裁剪偏移,否则训练时特征和标签对不上,模型看到的全是错位数据。这个 Bug 的特征是 loss 不下降还偶尔上升,很难排查。

# dataset.py 核心逻辑 def __getitem__(self, idx): img_path = self.images[idx] mask_path = self.masks[idx] img = Image.open(img_path).convert('RGB') mask = Image.open(mask_path) # 随机裁剪到 513x513(VOC)或 768x768(Cityscapes) if self.crop_size is not None: w, h = img.size th, tw = self.crop_size i = random.randint(0, h - th) j = random.randint(0, w - tw) img = img.crop((j, i, j + tw, i + th)) mask = mask.crop((j, i, j + tw, i + th)) # 随机水平翻转,注意图片和掩码要同步 if random.random() < 0.5: img = img.transpose(Image.FLIP_LEFT_RIGHT) mask = mask.transpose(Image.FLIP_LEFT_RIGHT) img = torch.from_numpy(np.array(img)).permute(2, 0, 1).float() / 255.0 mask = torch.from_numpy(np.array(mask)).long() return img, mask

(补充代码说明:在crop时取h - th可能出现负数,比如图片尺寸小于裁剪尺寸;实际工程里要么先 resize 到目标尺寸再裁,要么加 padding,这里默认你的原始图片都大于 crop_size。) Cityscapes 用 768×768 裁剪,VOC 用 513×513。裁剪尺寸直接影响显存和精度——裁太小会丢失上下文,ASPP 的感受野优势发挥不出来;裁太大显存撑不住。RTX 3090 等 24GB 显存跑 ResNet101 + 768×768 + batch size 4 基本顶格,如果 OOM 就先降到 64×64` 而不是急着换小模型。

验证阶段不做随机裁剪,而是直接把整图缩放成模型输入的固定尺寸,或者用滑动窗口推理。常见做法是 padding 到 32 的倍数(因为 ResNet 的 stride 是 32),然后把配对的 255 忽略掉。

4.2 损失函数与类别不平衡处理

分割任务默认用交叉熵逐像素计算损失。VOC 和 Cityscapes 都有明显的类别不平衡问题——VOC 的"人"和"背景"占据大部分像素,"摩托车"几乎只有几十个实例。直接把交叉熵应用上去,模型会严重偏向高频类别。常规处理方案分三级:第一级是权重系数,按类别像素占比的倒数加权;第二级是 OHEM(在线难例挖掘),只取 loss 最高的 20% 像素回传梯度;第三级是 Lovász-Softmax 这类 IoU 代理损失。我用下来效果最稳的是第一级加权 + 第三级 Lovász 组合,但 Lovász 实现复杂,对于刚跑通流程的场景先不推荐,权重法简单直接:

# 用像素频率统计生成权重 —— 每类一个权重系数 class_freq = np.bincount(all_pixels, minlength=n_classes) weights = 1.0 / (class_freq + 1e-5) weights = torch.from_numpy(weights).float() criterion = nn.CrossEntropyLoss(weight=weights, ignore_index=255)

ignore_index=255是永久必备项。VOC 的掩码里有些标注区域是"硬边"(未标注类),Cityscapes 的空白区域填的是 255,这些像素必须被忽略。如果没有 ignore_index,模型会硬学一个"第 255 类",把边界和空洞区域学出一堆噪声。

4.3 优化器与学习率策略:poly 衰减是分割标配

分割任务不用 StepLR(学习率每隔若干 epoch 骤降),而用 poly 衰减——每轮迭代都把学习率乘以(1 - iter / total_iters) ** 0.9。这是 DeepLab 系列论文里的标准做法,理由是分割网络需要在整个训练过程中持续"细调",而不是像分类那样大步长逼近再骤降微调。实测效果:同样的初始 LR 0.007,poly 衰减比 StepLR 的最终 mIOU 高 1.5 到 2 个点。

def poly_lr_scheduler(optimizer, init_lr, iter, max_iter, power=0.9): lr = init_lr * (1 - iter / max_iter) ** power for param_group in optimizer.param_groups: param_group['lr'] = lr

优化器选 SGD + momentum 0.9 + weight decay 5e-4,这是分割训练多年验证过的稳定组合。Adam 在这类像素级密集任务上容易收敛到次优解,一般不建议。BC(BatchNorm)在 backbone 部分用 ImageNet 预训练统计量,新加的分割头则从零开始——实现上常用"冻结 backbone 的 BN 统计量"(model.train()后手动把 backbone 的 BN 设为eval()),或者干脆不同部分用不同学习率,新加层的学习率乘 10 倍。这样能加速收敛,写代码时在 optimizer 构造时用两个 param_group:

backbone_params = list(model.layer0.parameters()) + list(model.layer1.parameters()) + \ list(model.layer2.parameters()) + list(model.layer3.parameters()) + \ list(model.layer4.parameters()) head_params = list(model.aspp.parameters()) + list(model.decoder.parameters()) + \ list(model.classifier.parameters()) + list(model.low_conv.parameters()) optimizer = torch.optim.SGD([ {'params': backbone_params, 'lr': 0.007}, {'params': head_params, 'lr': 0.07}, # 新加层用10倍学习率 ], momentum=0.9, weight_decay=5e-4, lr=0.007)

(参数说明:backbone 的初始 lr 是 0.007,head 的初始 lr 是 0.07,因为 head 是随机初始化,需要更大的步长快速热身;随着 poly 衰减推进,两者最终都会逼近 0。)

4.4 评估指标:mIOU 的计算方式必须和任务对齐

mIOU(平均交并比)是分割任务唯一公认的核心指标。计算方式:对每一类,求模型预测为该类的像素集合与真实该类的像素集合的交集 / 并集,然后对所有类取平均。注意两类特殊像素:255 不参与任何计算;VOC 的 21 类里背景类也算一类参与平均。

def compute_miou(pred_mask, gt_mask, n_classes): ious = [] pred_mask = pred_mask.flatten() gt_mask = gt_mask.flatten() # 去掉 ignore 像素 valid = gt_mask != 255 pred_mask = pred_mask[valid] gt_mask = gt_mask[valid] for cls in range(n_classes): pred_cls = pred_mask == cls gt_cls = gt_mask == cls inter = (pred_cls & gt_cls).sum().item() union = (pred_cls | gt_cls).sum().item() if union == 0: ious.append(float('nan')) # 该类别在验证集里没出现 else: ious.append(inter / union) # 忽略 nan 类别 valid_ious = [iou for iou in ious if not math.isnan(iou)] return sum(valid_ious) / len(valid_ious)

这个函数里valid_ious的逻辑很关键:某类在验证集里完全没出现时,union 是 0,直接跳过它。如果不跳过,mIOU 会被拉低到离谱;如果错误地把它当成 0 再平均,结果会好看到失真。我在实际项目中就见过有人把 n_classes 从 19 改成 20 参与求平均,导致 mIOU 虚高 3 个点,完全是错的。评估时跑在每个 epoch 末尾,保存"验证集 mIOU 最高"的权重作为最终模型,比盲目保存最后一轮要好得多。

4.5 训练主循环脚本:VOC 和 Cityscapes 都能跑

把所有组件拼起来,核心训练循环如下(删掉了断点续训等枝节,保持可读性):

for epoch in range(max_epochs): model.train() for i, (imgs, masks) in enumerate(train_loader): imgs, masks = imgs.cuda(), masks.cuda() optimizer.zero_grad() outputs = model(imgs) # (B, C, H, W) loss = criterion(outputs, masks) loss.backward() optimizer.step() # poly 学习率更新 cur_iter = epoch * len(train_loader) + i poly_lr_scheduler(optimizer, init_lr=0.007, iter=cur_iter, max_iter=max_epochs * len(train_loader)) if i % 20 == 0: print(f"Epoch {epoch} iter {i} loss {loss.item():.4f}") # 每个 epoch 结束做一次验证 model.eval() total_miou = [] with torch.no_grad(): for imgs, masks in val_loader: imgs = imgs.cuda() outputs = model(imgs) pred = outputs.argmax(dim=1).cpu() total_miou.append(compute_miou(pred, masks, n_classes)) mIOU = np.nanmean(total_miou) print(f"Epoch {epoch} val mIOU {mIOU:.4f}")

这套流程直接跑 VOC 2012,batch size 16 配 ResNet101 在单卡 A100 上约 3 个小时能到 70+ 的 mIOU;在 V100 上时间翻倍。Cityscapes 建议 batch size 4 配 768×768,单卡 A100 上约 12 到 15 小时能到 75 左右。

5. 训练避坑指南:四个最常翻车的环节

5.1 显存不足:不是换小模型而是先降 batch size 和裁切尺寸

现象:程序运行到第一个 batch 就报CUDA out of memory,或者训练中途随机崩溃。

原因分析:分割模型的显存占用是"输入分辨率 × 通道数"的数次方关系,513×513扩大到768×768时显存需求几乎翻倍;加上 ResNet101 中间层输出的特征图都是全分辨率存储,仅 layer4 的输出就有 512×512×2048 个浮点数,这一层就要 2GB 显存。

解决办法:优先把crop_size从768改成640,batch size 从 8 改成 4;再不行换 ResNet50。这三步的价值是逐步降低显存,同时尽量保护精度。注意不要贪心把 batch size 降成 2 还要跑 BN——BN 在 batch size 太小时统计量会非常不稳定,训练直接崩。

5.2 验证集 mIOU 一直不动:先检查 mask 里有没有 255

现象:训练曲线 loss 在下降,验证集 mIOU 却纹丝不动,甚至低于随机猜测。

原因分析:最常见的两种情况。第一,验证集图片像素值范围没归一化——模型在训练时见过归一化后的数据,验证时直接把 0~255 的原始像素喂进去,模型完全"不认识"输入。第二,验证集的 mask 没做ignore_index=255的掩码操作,255 像素被算进类别人数统计里。

解决办法:第一步打印验证集 mask 的像素类别集合,确认是否有 255;第二步复制训练时的Normalize逻辑(用 ImageNet 的 mean/std)到验证集上。在这个项目里 90% 的"验证集不涨"属于这两类原因,而不是网络设计问题。

提示:Cityscapes 的labelTrainIds已经把 255 从原始 34 类里剔除了,但有些预处理脚本会把"未标注区域"也转成 255,这导致数据集看起来是 19 类但实际有 20 个像素值,ignore_index=255必须保留。

5.3 加载预训练权重时 shape mismatch

现象:RuntimeError: Error(s) in loading state_dict for DeepLabV3Plus: Missing key(s) in state_dict: "classifier.weight"... Unexpected key(s): "fc.weight"...

原因分析:ResNet 预训练权重里包含fc层(ImageNet 1000 类分类头),而我们的模型里没有它;同时我们的classifier输出 19 或 21 类,预训练权重里也不存在。

解决办法:加载时加strict=False,只让 backbone 部分加载权重;或者手动构造权重字典,只保留layer0到layer4的键。我用后者:

state_dict = torch.load('resnet101.pth') new_state_dict = {k: v for k, v in state_dict.items() if k.startswith('layer')} model.load_state_dict(new_state_dict, strict=False)

把fc、classifier过滤掉,确保只加载卷积骨干部分,这是分割项目标准做法。

5.4 Cityscapes 的 mask 颜色可视化全是紫的:trainId 不是 RGB 映射

现象:拿 matplotlib 直接显示gtFine_labelTrainIds.png,出来一片紫色或乱的,看起来像数据坏了。

原因分析:labelTrainIds每个像素是标量类别编号(如 11 是"道路"),不是 RGB 三通道颜色映射。直接用 plt.imshow 会把它当成灰度图或索引色处理,丢失语义。Cityscapes 官方提供了一个color列表(19 个 RGB 三元组),可视化时必须先将标量类别索引映射成 RGB 再显示。

解决办法:在可视化和验证脚本里,定义一个trainId_to_color的二维数组(19×3),用 NumPy 索引一次性映射:

color_map = np.array(CITYSCAPES_COLORS, dtype=np.uint8) # 19x3 rgb = color_map[mask] # (H, W, 3) plt.imshow(rgb)

这个索引式映射比 for 循环快两个数量级,是处理分割掩码可视化的常用技巧。

6. 模型验证与进阶:从 mIOU 数字到看得见的落地效果

训练收敛后,验证环节不能只看 mIOU 一个数字。我每次都会做三类验证:第一,用可视化的方式把预测 mask 叠在原图上,人眼检查边界细节——mIOU 高不代表边界干净,尤其 Cityscapes 里"人"和"摩托车"这类小物体,mIOU 可能很高但边缘毛糙。第二,按类别打印 IoU 列表,找出模型最弱的几个类,针对性地补充训练数据。第三,用整图推理而不是裁剪推理跑一遍大图,验证模型在真实部署输入上是否稳定,这一步能暴露训练时裁剪尺寸和实际推理尺寸不一致导致的问题。

进阶调参方向上,最值得投入的是两点:一是用多尺度推理(flip + scale 0.75/1.0/1.25)替代单尺度推理,通常能白捡 1 到 2 个点 mIOU;二是把 backbone 从 ResNet101 换成更大感受野的系列(但显存和时间成本立刻上升)。如果要把模型部署到 TensorRT,记得在最后的interpolate前把输入尺寸固定下来,动态 shape 在 TensorRT 下会有额外转换成本。

这个方案做完后,你手里会有一份完整的"DeepLabv3+ 训练容器":换掉数据根目录和类别数,就能复用到自有数据集上。我个人的教训是最初把 70% 的时间耗在调学习率上,后来发现先把数据预处理和 ignore_index 查对,mIOU 能凭空涨 5 个点以上。这行最花时间的永远是排查"数据长什么样"而不"模型怎么做"。希望帮到你。

最后的最后,给自己打个硬广——如果你正好在学 PyTorch 和图像分割,这个项目就是最好的练手素材:代码完整、流程清晰、数据也好拿。照着跑一遍,比看十篇原理文章都管用。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询