ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

UNet+ASPP遥感图像语义分割:多尺度小目标精度提升实战

2026/10/7 5:35:58 拓冰建站 浏览量
UNet+ASPP遥感图像语义分割:多尺度小目标精度提升实战 简介这份资源面向具备一定深度学习基础、希望动手实践语义分割的开发者与研究生围绕UNet结合ASPP模块构建通用图像分割系统可同时支持二分类与多类别任务适用于医学影像、遥感等场景。压缩包共10个文件以4个py源码文件为核心辅以pyc缓存、txt依赖清单、md说明与docx项目说明书整体约22KB体积轻量便于快速部署。系统覆盖数据预处理、模型训练、验证评估全流程数据端支持自定义图像与掩码格式、自动处理不连续标签并集成多种增强模型端可灵活调整输入尺寸与卷积通道数通过num_classes参数适配不同类别数训练端支持GPU加速、超参数配置并实时记录损失曲线与IoU、Dice等指标自动保存最优权重。项目说明书配合完整代码讲解原理读者可据此掌握UNet与ASPP的融合思路、命令行参数用法及掩码规范并在此基础上通过加深网络或引入注意力机制进一步优化性能。目前已有115人学习。1. 从一张分割翻车图说起unetASPP 到底补了哪块短板拿遥感图像做语义分割的人多半遇到过这种场景模型在训练集上 miou 刷到 0.85换一张不同分辨率的卫星图道路断成虚线小目标建筑整片消失。翻回去看预测热力图问题往往出在同一个地方——多尺度目标共存时固定感受野的卷积核顾此失彼。unet 的编码器靠逐层下采样扩大感受野但下采样本身丢空间细节解码器再能跳连也补不回已经丢掉的上下文。asppatrous spatial pyramid pooling空洞空间金字塔池化就是冲着这个矛盾来的用不同膨胀率的空洞卷积并行采样在不额外下采样的前提下拿到多尺度上下文。把 aspp 嵌进 unet 的瓶颈层等于给编码器末端装了一个多焦段镜头近处细节和远处语义同时抓。这篇笔记面向已经跑通过基础 unet、想在不换 backbone 的前提下把分割精度再往上推一截的从业者从模块原理、代码实现、参数设置到踩坑排查给一条能直接复现的路径。遥感图像语义分割、unet 模型改进这类需求核心痛点基本都落在多尺度和小目标上aspp 是性价比很高的一刀。2. unet 瓶颈层为什么需要 ASPP感受野与多尺度的账要算清2.1 固定感受野在分割任务里的三个硬伤unet 的编码器结构是 convpooling 交替每经过一次下采样特征图边长减半单个像素对应的原始感受野翻倍。以 512×512 输入、五层下采样为例瓶颈层 16×16 特征图上每个点的理论感受野已经覆盖整图但这是理论值。实际有效感受野远小于理论值因为卷积核权重分布集中在中心边缘响应衰减很快。这就带来第一个硬伤大目标内部区域预测一致但边界模糊因为边界像素的上下文采样不足。第二个硬伤是小目标比如遥感图里几像素宽的道路经过四次下采样后在瓶颈层只剩不到一个像素的响应信息基本被池化抹平。第三个硬伤是尺度变化同一类目标在不同分辨率下尺寸差异大固定膨胀率的卷积核只能匹配一种尺度。aspp 的解法是并行多个分支每个分支用不同膨胀率输出在通道维拼接让网络自己学哪个尺度该被激活。2.2 ASPP 的四个分支与全局池化分支怎么配标准 aspp 结构包含一个 1×1 卷积分支和三个 3×3 空洞卷积分支膨胀率通常取 6、12、18再加一个全局平均池化分支。1×1 分支保留原始尺度信息三个空洞分支分别捕捉中、大、超大感受野全局池化分支把整图压成一个向量再上采样回去提供图像级上下文。五个分支输出通道数一致拼接后经过一个 1×1 卷积融合降维。这里有个容易忽略的点膨胀率不是随便设的它和特征图尺寸强相关。如果瓶颈层特征图只有 16×16膨胀率 18 的 3×3 卷积实际有效核尺寸接近 37已经超过特征图边长边缘大量补零这个分支基本失效。所以 aspp 放在 unet 的哪一层、输入分辨率多少直接决定膨胀率怎么选。常见做法是把 aspp 放在编码器最末端、解码器开始之前此时特征图最小膨胀率要相应调小或者把 aspp 放在倒数第二层特征图稍大膨胀率可以按标准值走。2.3 把 ASPP 接进 unet 的最小改动点不改 backbone、不改解码器结构的前提下接入 aspp 只需要动一个地方编码器输出到解码器第一个上采样之间的那一段。原始 unet 在这里通常是一个 convbnrelu 或者直接就是 bottleneck 卷积。替换成 aspp 模块后输入输出通道数保持一致这样解码器的跳连和上采样逻辑完全不用改。下面这段代码定义了一个可配置的 aspp 模块膨胀率通过参数传入方便根据特征图尺寸调整。import torch import torch.nn as nn import torch.nn.functional as F class ASPP(nn.Module): def __init__(self, in_channels, out_channels256, rates(6, 12, 18)): super(ASPP, self).__init__() # 1x1 分支保留原始尺度 self.branch1 nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) ) # 三个空洞卷积分支padding 等于膨胀率以保持特征图尺寸 self.branches nn.ModuleList() for r in rates: self.branches.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, paddingr, dilationr, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) )) # 全局池化分支 self.global_pool nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) ) # 融合层输入通道数为分支数乘以 out_channels self.fuse nn.Sequential( nn.Conv2d(out_channels * (len(rates) 2), out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), nn.Dropout(0.5) ) def forward(self, x): size x.shape[2:] outs [self.branch1(x)] for branch in self.branches: outs.append(branch(x)) # 全局池化分支需要上采样回原尺寸再拼接 gp self.global_pool(x) gp F.interpolate(gp, sizesize, modebilinear, align_cornersFalse) outs.append(gp) return self.fuse(torch.cat(outs, dim1))逻辑说明rates参数控制三个空洞分支的膨胀率默认 6/12/18 适合瓶颈层特征图边长在 32 以上的情况。paddingr和dilationr配合保证空洞卷积输出尺寸不变这是拼接的前提。全局池化分支用AdaptiveAvgPool2d(1)把任意尺寸压成 1×1再双线性插值回原尺寸align_cornersFalse是分割任务里更稳的选择。融合层输入通道数是out_channels * (len(rates) 2)因为有一个 1×1 分支、三个空洞分支、一个全局分支共五个。Dropout(0.5)在 aspp 原文里就有小数据集上能明显压过拟合。参数设置上out_channels一般取 256和 unet 解码器通道对齐如果显存吃紧可以降到 128但不要再低否则多尺度信息融合能力会打折。3. 从零搭一个带 ASPP 的 unet代码结构与训练配置3.1 编码器、ASPP、解码器的拼接顺序完整模型的结构是编码器四级下采样每级两个 3×3 卷积加 bn 和 relu下采样用 maxpool。第四级输出后接 asppaspp 输出再进解码器。解码器每级先上采样和编码器对应层跳连拼接再两个 3×3 卷积。最后 1×1 卷积输出类别数。这里的关键是 aspp 的输出通道要和解码器第一级上采样后的通道数匹配。如果编码器最深层通道是 512aspp 输出 256解码器第一级上采样后是 256拼接后 512再卷积降到 256。下面给出编码器和解码器的核心代码。class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNetASPP(nn.Module): def __init__(self, in_ch3, num_classes2, base_ch64): super().__init__() # 编码器 self.enc1 DoubleConv(in_ch, base_ch) self.enc2 DoubleConv(base_ch, base_ch * 2) self.enc3 DoubleConv(base_ch * 2, base_ch * 4) self.enc4 DoubleConv(base_ch * 4, base_ch * 8) self.pool nn.MaxPool2d(2) # ASPP 接在最深层 self.aspp ASPP(base_ch * 8, base_ch * 4, rates(6, 12, 18)) # 解码器 self.up3 nn.ConvTranspose2d(base_ch * 4, base_ch * 4, 2, stride2) self.dec3 DoubleConv(base_ch * 8, base_ch * 4) self.up2 nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride2) self.dec2 DoubleConv(base_ch * 4, base_ch * 2) self.up1 nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride2) self.dec1 DoubleConv(base_ch * 2, base_ch) self.out nn.Conv2d(base_ch, num_classes, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) # ASPP 处理最深层特征 a self.aspp(self.pool(e4)) d3 self.up3(a) d3 torch.cat([d3, e3], dim1) d3 self.dec3(d3) d2 self.up2(d3) d2 torch.cat([d2, e2], dim1) d2 self.dec2(d2) d1 self.up1(d2) d1 torch.cat([d1, e1], dim1) d1 self.dec1(d1) return self.out(d1)逻辑说明base_ch64是标准 unet 的起点显存不够可以降到 32。ASPP的输入通道是base_ch*8输出base_ch*4这样解码器第一级上采样后通道也是base_ch*4拼接后base_ch*8进dec3降到base_ch*4。跳连顺序是 e3、e2、e1和原始 unet 一致。注意 aspp 前面又加了一次self.pool(e4)这是因为编码器第四级输出后本来就要下采样一次才到瓶颈这里把 pool 和 aspp 串起来实际瓶颈特征图是输入的 1/16。如果输入 512×512瓶颈是 32×32膨胀率 6/12/18 都有效不会超界。3.2 训练参数与损失函数的选择分割任务里交叉熵和 dice 损失各有侧重。交叉熵对类别不平衡敏感遥感图里背景通常占大头小目标容易被淹没。dice 损失直接优化重叠度对小目标更友好。常见做法是两者加权loss ce dice权重各 0.5。优化器用 adam 起步学习率 1e-3如果训练后期 loss 震荡明显换 sgdmomentum 0.9学习率降到 1e-2。batch size 在 8g 显存上512×512 输入、base_ch64 大概能跑 4。学习率调度用 cosineannealinglr周期设 50 个 epoch。下面给出训练循环的核心片段。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR def dice_loss(pred, target, smooth1e-6): pred torch.sigmoid(pred) pred pred.contiguous().view(pred.size(0), -1) target target.contiguous().view(target.size(0), -1) intersection (pred * target).sum(dim1) return 1 - ((2. * intersection smooth) / (pred.sum(dim1) target.sum(dim1) smooth)).mean() model UNetASPP(in_ch3, num_classes2, base_ch64).cuda() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50) ce nn.CrossEntropyLoss() for epoch in range(50): model.train() for img, mask in train_loader: img, mask img.cuda(), mask.cuda() pred model(img) loss ce(pred, mask) 0.5 * dice_loss(pred, mask.float()) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()逻辑说明dice_loss里先对 pred 做 sigmoid因为二分类输出是单通道 logits交叉熵内部会做 softmax但 dice 需要概率值。smooth1e-6防止分母为零。weight_decay1e-4是 adam 的常规正则数据量小可以加到 1e-3。CosineAnnealingLR的T_max等于总 epoch 数学习率从 1e-3 余弦降到接近零。如果显存不够把base_ch降到 32batch size 提到 8学习率相应降到 5e-4。验证阶段用 miou 和 pixel accuracy 两个指标miou 对类别不平衡更敏感是主要参考。3.3 数据增强里哪些操作会帮倒忙遥感图像分割的数据增强和自然图像有区别。随机旋转、翻转、裁剪是安全的但颜色抖动要慎用因为遥感图的颜色和地物类别强相关过度抖动会让模型学到错误的颜色先验。随机缩放可以用但缩放比例不要超过 0.5 到 2.0否则小目标在缩放后可能消失。cutout 和 cutmix 在小数据集上有效但 cutout 的孔洞尺寸不要超过图像边长的 1/8否则可能把整个目标挖掉。常见做法是水平翻转、垂直翻转、90 度旋转、随机缩放 0.75 到 1.5、随机裁剪到 512。颜色方面只做轻微的亮度和对比度扰动幅度控制在 0.1 以内。下面是一个基于 albumentations 的增强配置。import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.RandomRotate90(p0.5), A.RandomScale(scale_limit0.5, p0.5), A.RandomCrop(height512, width512, p1.0), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.3), ])逻辑说明RandomScale的scale_limit0.5表示缩放范围是 0.5 到 1.5 倍。RandomCrop放在缩放之后保证输出尺寸固定。RandomBrightnessContrast的幅度限制在 0.1避免颜色先验被破坏。如果数据集里小目标特别多可以把RandomScale的概率降到 0.3减少小目标被缩没的风险。4. 避坑与排查ASPP 接进 unet 后最容易翻车的五个地方4.1 膨胀率设太大导致空洞分支失效现象训练 loss 能降但验证 miou 比不加 aspp 还低可视化发现大目标边界反而更碎。原因瓶颈层特征图尺寸太小膨胀率 18 的 3×3 卷积有效核尺寸超过特征图边长边缘全是零填充这个分支输出的特征图边缘区域全是无效响应拼接后反而引入噪声。解决先打印瓶颈层特征图尺寸如果边长小于 32把膨胀率降到 3/6/9 或者 2/4/6。判断标准是最大膨胀率乘以 2 加 1 不超过特征图边长。4.2 全局池化分支上采样引入棋盘伪影现象预测图上有规律的网格状纹理尤其在类别边界附近。原因F.interpolate用双线性插值从 1×1 上采样到 32×32放大倍数太大插值核覆盖范围广边界处产生振铃。解决把全局池化分支的上采样改成先 1×1 卷积升维再nn.Upsample配合卷积或者直接用F.interpolate的modenearest再接一个 3×3 卷积平滑。更稳的做法是全局分支只输出到 4×4 或 8×8再和主分支拼接前用一次卷积融合。4.3 跳连拼接时通道数对不上现象torch.cat报错提示通道维不匹配。原因aspp 输出通道设成了 256但解码器对应层通道是 512拼接后融合层输入通道算错。解决把 aspp 的out_channels设成解码器第一级上采样后的通道数通常是编码器最深层通道的一半。如果编码器最深层是 512aspp 输出 256解码器上采样后也是 256拼接后 512融合层输入就是 512。改完通道后记得同步改融合层的输入通道参数。4.4 显存溢出但不知道哪一层吃最多现象训练时 cuda out of memory调小 batch size 后还是溢出。原因aspp 的五个分支并行计算中间特征图通道数是out_channels * 5如果out_channels256中间张量通道数 1280显存占用是普通卷积的五倍。解决用torch.cuda.memory_summary()看每层占用把 aspp 的out_channels降到 128或者把三个空洞分支改成串行而不是并行串行会损失一些多尺度并行性但显存减半。另一个办法是在 aspp 内部加checkpoint用时间换显存。4.5 训练集 miou 高但测试集崩盘现象训练集 miou 0.9测试集 0.5差距巨大。原因aspp 的参数量比普通瓶颈卷积大很多小数据集上过拟合严重。解决把 aspp 融合层的Dropout从 0.5 提到 0.7加 l2 正则weight_decay1e-3数据增强里增加随机裁剪和缩放的强度。如果还不行冻结编码器前两层只训练 aspp 和解码器。另一个容易被忽略的点是 batchnorm 在 batch size 很小时统计量不准把 aspp 里的 bn 换成 groupnormgroup 数设 8小 batch 下更稳。5. 进阶技巧用 ASPP 输出做多尺度预测融合与验证5.1 把 ASPP 的中间分支拿出来做辅助监督aspp 的五个分支输出的是不同尺度的特征除了拼接融合还可以在训练时对每个分支加辅助损失。具体做法是每个分支后面接一个 1×1 卷积输出类别预测和主输出一起算 loss辅助 loss 权重设 0.2。这样梯度能直接传到每个空洞分支防止某个分支在训练早期就被融合层忽略。辅助监督在训练结束后可以去掉推理时只走主输出不增加计算量。代码上只需要在 aspp 的 forward 里多返回一个列表训练循环里对每个辅助输出算 ce loss 再加权求和。5.2 验证阶段用滑动窗口加多尺度推理遥感图像通常尺寸很大直接缩放到 512 会丢细节。常见做法是滑动窗口裁剪窗口 512重叠 128每个窗口单独预测再拼回去。重叠区域用高斯权重融合边缘权重低中心权重高避免拼接缝。多尺度推理是另一个技巧把图像缩放到 0.75、1.0、1.25 三个尺度分别预测再取平均。aspp 本身有多尺度能力但输入尺度变化和网络内部多尺度是互补的。下面给出滑动窗口推理的核心代码。def sliding_window_inference(model, img, window512, stride384, num_classes2): model.eval() b, c, h, w img.shape output torch.zeros(b, num_classes, h, w).cuda() count torch.zeros(b, 1, h, w).cuda() for y in range(0, h, stride): for x in range(0, w, stride): y1, y2 y, min(y window, h) x1, x2 x, min(x window, w) patch img[:, :, y1:y2, x1:x2] # 边缘 patch 补零到 window 尺寸 ph, pw y2 - y1, x2 - x1 if ph window or pw window: patch F.pad(patch, (0, window - pw, 0, window - ph)) with torch.no_grad(): pred model(patch) pred pred[:, :, :ph, :pw] output[:, :, y1:y2, x1:x2] pred count[:, :, y1:y2, x1:x2] 1 return output / count.clamp(min1)逻辑说明stride384表示窗口 512 重叠 128。count记录每个像素被预测的次数最后取平均。边缘 patch 补零到窗口尺寸再推理推理后裁掉补零区域。这个函数在验证和测试阶段都能用比直接缩放整图 miou 通常能高 2 到 3 个点。注意model.eval()和torch.no_grad()都要加否则显存会爆。5.3 一个我常用的验证习惯每次改完 aspp 的膨胀率或通道数我不会直接跑完整训练而是先拿 20 张图过拟合一遍。如果 20 张图能在 50 个 iteration 内 loss 降到 0.1 以下说明模型结构和数据管道没问题再上全量数据。这个习惯帮我省了很多次白跑几小时的翻车。另外验证 miou 的时候一定同时看混淆矩阵有时候 miou 涨了但某个小类 recall 掉了这种涨是虚的上线后小目标照样漏。aspp 的膨胀率调整对混淆矩阵的影响很直接调完膨胀率先看小类 recall 有没有回来比看总体 miou 更有用。希望帮到你。本文还有配套的精品资源点击获取