资讯动态

DeepLabv3+图像分割实战:空洞卷积、ASPP与训练细节全解析

发布时间:2026/9/11 22:53:47 来源:尧图企业网站定制
简介一套基于 PyTorch 在 VOC 与 Cityscapes 数据集上训练 DeepLabv3 图像分割算法的完整实战项目面向深度学习、计算机视觉方向的学习者与开发者可帮助解决从数据准备、模型训练到预测推理的全流程落地问题。压缩包共 55 个文件体积约 2.25MB以 23 个 Python 脚本为核心配套 17 张结果可视化图片、2 个文本说明文件以及 README 文档、LICENSE 与 Git 配置等另有 9 个 .zbak 备份文件可见工程迭代痕迹同时附带独立附赠压缩包便于扩展学习。目录按 datasets、metrics、network、utils 等模块划分涵盖 VOC 与 Cityscapes 数据预处理流程、DeepLabv3 网络主体内置 ResNet、MobileNetV2、Xception、HRNetV2 多种骨干选择、损失函数、学习率调度、可视化工具、训练与预测脚本samples 目录另附原图、目标掩码、预测结果及叠加效果对比图便于直观评估模型表现。目前已有 139 人学习下载适合希望系统跑通图像分割实验、理解模型结构并在此基础上二次开发的读者。1. 拿同一套 DeepLabv3 从 VOC 换到 Cityscapes先改的不是网络是这三处同样的 DeepLabv3 结构在 VOC 上能跑到 78 以上的 mIoU换到 Cityscapes 直接掉到 68 以下很多人第一反应是调 backnone实际问题大多出在数据加载和统计口径上。这个项目把main.py、datasets/voc.py、datasets/cityscapes.py、network/_deeplab.py完整串起来了VOC 和 Cityscapes 两个数据集的训练都能跑。对刚接触图像分割的读者来说它是一份能直接对照源码看懂 ASPP、空洞卷积、mIoU 计算的完整流程对有几年经验的工程来说值得关注的是 ignore_index255 如何影响 loss 和评估指标、Cityscapes 忽略 void 像素后为何 mIoU 波动变大以及 poly 学习率和训练总步数的配合。以下几章按网络建模、数据集、训练、推理的顺序逐层拆并给出可直接复用的代码。2. 空洞卷积替换 strideDeepLabv3 的 ASPP 与 Decoder 建模思路2.1 用空洞卷积保住 1/16 分辨率而不是继续下采样图像分割和分类一个核心差异是输出是逐像素的特征图不能一路池化到 7×7。ResNet 默认做 32 倍下采样对于分割任务细节丢失太多。DeepLabv3 的做法是保留 backbone 前两层的正常下采样从 layer3、layer4 开始把 stride 改成 1同时用空洞卷积补偿感受野。network/backbone/resnet.py里的replace_stride_with_dilation参数就是干这个的常见实现如下def _make_layer(self, block, planes, blocks, stride1, dilateFalse): norm_layer self._norm_layer downsample None previous_dilation self.dilation if dilate: self.dilation * stride stride 1 if stride ! 1 or self.inplanes ! planes * block.expansion: downsample nn.Sequential( nn.Conv2d(self.inplanes, planes * block.expansion, 1, stridestride, biasFalse), norm_layer(planes * block.expansion)) layers [] layers.append(block(self.inplanes, planes, stride, downsample, self.groups, self.base_width, previous_dilation, norm_layer)) self.inplanes planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes, groupsself.groups, base_widthself.base_width, dilationself.dilation, norm_layernorm_layer)) return nn.Sequential(*layers)注意看previous_dilation self.dilation这行扩容前先把当前膨胀率存下来传给这一层的第一个 block。layer3 设置dilateTrue时 dilation 从 1 变成 2layer4 再从 2 变成 4最终输出保持原图的 1/16。如果配置output_stride8就在 layer2 也做同样的替换但显存占用会显著上升Cityscapes 这类大分辨率数据集我一般不用 8。2.2 ASPP用四种采样率做多尺度上下文聚合拿到 1/16 特征图后DeepLabv3 用 ASPP 模块并行提取不同感受野的信息一个 1×1 卷积、三个膨胀率分别为 6、12、18 的 3×3 空洞卷积再加一个全局平均池化分支。五个分支的结果在通道维拼接再用 1×1 卷积压缩回 256 通道。这种设计让网络同时看到小目标细节和大物体轮廓class ASPP(nn.Module): def __init__(self, in_channels, out_channels256, rates(6, 12, 18)): super().__init__() self.convs nn.ModuleList() self.convs.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue))) for rate in rates: self.convs.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, paddingrate, dilationrate, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue))) self.pool nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue)) self.project nn.Sequential( nn.Conv2d(5 * out_channels, out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue)) def forward(self, x): res [conv(x) for conv in self.convs] res.append(F.interpolate(self.pool(x), sizex.shape[-2:], modebilinear, align_cornersTrue)) return self.project(torch.cat(res, dim1))rates(6, 12, 18)是针对 1/16 特征图调的如果 backbone 输出变成 1/8这些 rate 要相应减半否则等效感受野变大小目标反而容易漏。全局池化分支先压缩到 1×1 再上采样是为了把整图级别的上下文塞进每个位置对 Cityscapes 里大面积的道路、天空区域很有帮助。project把五路拼接后的 1280 通道压到 256后面的 Decoder 才接得住。2.3 Decoder低层特征和 ASPP 输出拼接DeepLabv3 的 Decoder 部分很多人会忽略实际上它决定了边缘精细度。ASPP 输出先上采样 4 倍和 backbone layer1 的低层特征做通道拼接再接两个 3×3 卷积最后上采样 4 倍回到原图。低层特征要先经过一个 1×1 卷积把通道降到 48否则边缘纹理会把语义信息淹没。backbone 文件特点在 Cityscapes 上的常见表现resnet.py参数适中收敛稳易加载预训练性价比最高mIoU 中上xception.py原版 DeepLabv3 标配感受野大上限高显存占用大mobilenetv2.py轻量适合实时推理精度略低速度最快hrnetv2.py高分辨率特征保持好小目标好训练成本高训练时model(x)直接返回原图尺寸的 logitsloss 就在这个输出上计算。而predict.py推理时同样走这条通路所以网络结构代码里不需要额外接分类头。如果从modeling.py里换 backbone最常踩的坑是输出通道对不上比如 hrnetv2 的 last_channel 和 resnet 不同_deeplab.py里in_channels要同步改。3. 数据集的坑voc.py 与 cityscapes.py 为何不能共用一套读取逻辑3.1 train_aug.txt 与 trainId 编码的差异VOC 和 Cityscapes 的标注存储方式完全不同。VOC 的SegmentationClassAug是每个像素存类别索引的 P 模式 PNG0 是背景1 到 20 是物体类Cityscapes 的gtFine里存的是 trainId0 到 18 是训练类别255 是 void。用 PIL 读的时候都要用modeP但千万不能convert(RGB)一转换索引就对不上了。项目里datasets/voc.py的数据读取核心逻辑大概是class VOCSegmentation(data.Dataset): def __init__(self, root, image_settrain, transformNone): super().__init__() self.root root self.transform transform with open(os.path.join(root, train_aug.txt)) as f: self.images [line.strip() for line in f] def __getitem__(self, index): name self.images[index] img_path os.path.join(self.root, JPEGImages, name .jpg) lb_path os.path.join(self.root, SegmentationClassAug, name .png) image Image.open(img_path).convert(RGB) label Image.open(lb_path) return self.transform(image, label)注意 VOC 这侧是把文件名行直接读进来而不是在__init__里扫描整个文件夹训练集和验证集的可复现性完全由 txt 决定。train_aug.txt是增强后的训练清单数量比原始的 1464 张多很多这是 mIoU 能上去的关键。Cityscapes 侧则要自己把路径拼出来并且读取后把非 0 到 18 的像素统一成 255label torch.from_numpy(np.array(label, dtypenp.uint8)) label[label 19] 255 # 只保留 0-18其余全部视为 void这行处理是 Cityscapes 训练最重要的预处理之一。原图里有很多未标注或标注为 ignore 的区域不统一成 255 的话loss 里会把这些区域当成有效类别反向传播模型会在栅栏、车辆边缘这些地方产生大量错误预测。3.2 ext_transforms 的随机缩放裁剪项目里的utils/ext_transforms.py提供了一套扩展版 transforms和 torchvision 自带的差异在于随机缩放、随机裁剪、水平翻转这些操作要同时作用于 image 和 label并且 label 的插值方式不能是 BILINEAR必须用 NEAREST否则会产生原本不存在的类别值。训练时的标准增强流程如下train_transform ext_transforms.ExtCompose([ ext_transforms.ExtRandomScale((0.5, 2.0)), ext_transforms.ExtRandomCrop(size(513, 513), pad_if_neededTrue), ext_transforms.ExtRandomHorizontalFlip(), ext_transforms.ExtToTensor(), ext_transforms.ExtNormalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])这里ExtRandomScale先把整图随机制成 0.5 到 2.0 倍再裁剪出固定大小的 patch比直接 resize 更接近分割竞赛里的多尺度训练能缓解 Cityscapes 这类数据集里物体尺度差异大的问题。pad_if_neededTrue很重要因为随机缩放后图片可能小于裁剪尺寸不 pad 就崩了。Cityscapes 原始分辨率是 1024×2048如果显存不足常见的做法是ExtRandomCrop到 768×768 而不是直接 resize 到 512×1024前者保留更多细节。3.3 ignore_index 如何同时影响 loss 和评估指标ignore_index不是一个只在 CrossEntropyLoss 里出现一次的参数它同时进入两个逻辑。训练时nn.CrossEntropyLoss(ignore_index255)会跳过 label 为 255 的像素不参与梯度计算评估时 confusion matrix 也需要排除这些像素。如果忽略了后者mIoU 会被 void 区域的大面积真实标签干扰尤其 Cityscapes 里很多图像上下边缘都有未标注区域。正确做法是判断像素是否大于等于 19这些位置直接不累积到混淆矩阵中。项目VOC 2012Cityscapes类别数20 类 1 背景19 类 255 void训练集规模约 10582含增强2975验证集规模1449500ignore 值255255典型 crop 尺寸513×513768×768 或更大从数据规模看Cityscapes 训练集只有 VOC 增强后的四分之一不到但训练难度反而更大因为每张图都是高分辨率场景类别像素分布极不均衡道路面积可能占三分之一而摩托车可能只有几十个像素。这个不均衡直接反映在 mIoU 波动上我一般会多留几个 checkpoint用验证集 mIoU 而不是最后一个 epoch 的结果来选模型。4. main.py 训练流程从命令行参数到 poly 学习率与 mIoU 计算4.1 训练入口与关键参数main.py把网络构建、数据加载、优化器调度和评估串成一条流水线。运行前需要确认三件事backbone 预训练权重路径、数据集根目录、device 数量。常见启动命令如下python main.py \ --model deeplabv3plus_resnet101 \ --dataset voc --year 2012_aug \ --gpu_id 0 \ --batch_size 8 \ --lr 0.01 \ --lr_scheduler poly \ --total_itrs 30000 \ --ckpt ./checkpoints/best_deeplabv3plus_voc.pth--model deeplabv3plus_resnet101决定_deeplab.py里加载哪个 backbone 和对应输出通道--dataset决定走voc.py还是cityscapes.py--total_itrs不是 epoch 数而是总迭代步数Cityscapes 一般需要 60000 到 90000 步VOC 30000 步左右就能收敛。Batch size 受显存限制Cityscapes 大图通常只能开到 4 到 8这时 BatchNorm 统计量会很不稳定多卡训练要开 SyncBN。下面的参数表是我认为在这个项目里最值得优先调的部分其它参数保持默认即可参数建议值说明--lr0.01单卡大模型或大 batch 调低到 0.007--momentum0.9SGD 默认--weight_decay5e-4过大的 wd 会让 Cityscapes 掉点--crop_size513 / 768由数据集决定需与 transform 一致--total_itrs30000 / 60000配合 poly 学习率衰减--val_interval500每 500 步跑一次验证4.2 Poly 学习率与 WarmUp 的实际作用项目里有utils/scheduler.py它实现的不是 torchvision 里常见的 StepLR而是分割任务最常用的 poly 策略学习率随迭代次数按指数衰减到接近 0。配合total_itrs而不是 epoch 来算是因为每个 epoch 的步数会随 batch size 变化而迭代次数是固定的这样无论怎么改 batch size学习率曲线形状都不变。def poly_lr(base_lr, current_step, max_steps, power0.9): return base_lr * (1.0 - current_step / max_steps) ** powerpower0.9是 DeepLab 系列的标准配置。前 10% 的迭代步里很多工程会叠加一个 warmup让 lr 从 0 线性上升到基学习率避免加载 ImageNet 预训练权重后前几步 loss 直接爆掉。如果发现训练到一半 loss 还在震荡先检查是不是 warmup 没生效再看 batch size 和 BN 统计量。4.3 CrossEntropyLoss 与 mIoU 的计算口径训练时 loss 直接用交叉熵但评估时如果对每个 batch 单独算 mIoU 再平均小类别会被大类别稀释产生偏差。正确做法是用utils/metrics/stream_metrics.py里的StreamSegMetrics把整个验证集的混淆矩阵累积起来最后统一计算metric StreamSegMetrics(num_classes, ignore_index255) for images, targets in val_loader: with torch.no_grad(): logits model(images) pred logits.argmax(dim1).cpu().numpy() metric.update(targets.cpu().numpy(), pred) score metric.get_results() print(mIoU:, score[Mean IoU])StreamSegMetrics.update内部是按像素累积混淆矩阵get_results时才对各类别逐行算 IoU再对 19 个类别取平均。使用这个类的时候要确认创建时传入的num_classes和ignore_index和数据集一致否则混淆矩阵维度对不上或者把 void 也算进去最终数值完全不可信。5. predict.py 推理与可视化从模型输出到带调色板的 PNG5.1 推理时的预处理必须和训练严格对齐推理阶段最常见的错误是把训练时的随机裁剪原样搬过来结果每张图的输出尺寸都不一样。predict.py的流程是先读 checkpoint取出model_state_dict或state_dict字段加载模型再对单张图做和验证一致的 resize 与归一化最后输出和原图一样大的预测。关键点是归一化的 mean、std 必须和训练相同通道顺序 BGR 和 RGB 也要确认否则 logits 分布完全错乱。5.2 用调色板保存 P 模式预测图分割结果不能直接存成 RGB因为每类颜色需要固定映射这样后续与原图、GT 叠加比较才有意义。VOC 和 Cityscapes 都有各自的调色板把预测类别索引存入 P 模式 PNG再挂上调色板import numpy as np from PIL import Image def save_pred(pred, palette, out_path): # pred: (H, W) int 数组每个像素是类别索引 out Image.fromarray(pred.astype(np.uint8), modeP) out.putpalette(palette) out.save(out_path) palette cityscapes_palette() # 长度为 256*3 的列表idx - (r, g, b) save_pred(pred, palette, city_1_pred.png)putpalette接收一个长度 768 的列表每三个元素对应一个索引的 RGB 颜色。Cityscapes 的调色板在很多开源工具里叫trainId2colorVOC 的调色板则是固定的 21 类加一个黑色背景。保存后打开图片如果发现某个类整体颜色异常先检查调色板顺序是不是和类别索引对齐而不是去怀疑模型。5.3 从示例图判断模型状态项目里samples目录存放了1_image.png、1_target.png、1_pred.png、1_overlay.png和 city 系列样例。overlay是把预测结果半透明叠加在原图上用来观察边缘贴合度。看到prod和target的差异时先分清是整体误差还是局部误差整体 mIoU 低通常是类别不平衡或训得不够局部边缘锯齿大多是低层特征融合问题可以回看 2.3 的 Decoder大块区域被错分成同类则大概率是 ASPP 的 rate 没适配分辨率。这张对比图也是调参时最直接的反馈比盯着 loss 曲线有效得多。6. 训练后必须检查的三个细节mIoU 口径、忽略像素与权重加载6.1 多次验证的 mIoU 不能简单平均验证集很大时常见做法是分几个 batch 推理最后把混淆矩阵相加再统一算 IoU。如果图省事在每个 batch 算一次 mIoU 然后取平均大类别比如 road、terrain占比越大偏差越明显。这个偏差不是线性的小类别的 IoU 波动大平均法会把这些波动放大。正确实现是StreamSegMetrics.get_results()之前只update不reset把所有验证样本累积完再出指标。6.2 手动核对 Cityscapes 的 void 像素是否被真正忽略城市道路实拍图里物品的边缘会有大量未标注像素读取 GT 后这些区域的 label 可能是 255也可能是 0 到 18 之外的其它 trainId。训练脚本里的label[label 19] 255是一次性清洗如果验证时忘了做这一步混淆矩阵里会出现非法类别mIoU 直接崩掉。这里可以写一个小脚本验证python -c from PIL import Image import numpy as np lb np.array(Image.open(city_1_gtFine_labelTrainIds.png)) print(max label:, lb.max()) print(void ratio:, (lb 19).mean()) max label应该是 18 或 255void ratio一般在 5% 到 20% 之间。如果 ratio 为 0说明这张图边缘全部标注了不太符合 Cityscapes 的真实情况ratio 超过 30%则要考虑是不是 trainId 映射写错了把 33 个原始类别直接当成了索引。6.3 加载预训练权重时的 key 前缀问题从 torchvision 下载的 resnet 权重 key 是layer1.0.conv1.weight而_deeplab.py里的模型会用backbone.conv1.weight包裹这两层结构直接load_state_dict会报 missing key。常见做法是先按前缀剥离再加载保留 backbone 之外的模块随机初始化state torch.load(resnet101.pth) new_state {} for k, v in state.items(): new_state[backbone. k] v missing, unexpected model.load_state_dict(new_state, strictFalse) print(missing:, missing.keys()[:5])打印的 missing 应该是 classifier 这类分割头参数unexpected 为空。这部分对应network/backbone/resnet.py顶层接口如果是 xception 或 hrnetv2 预训练前缀又会不同。建议把 loading 逻辑统一封装在modeling.py的init_weights里这样切换 backbone 时不用改训练主流程。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价