简介:这份资源面向遥感图像处理、深度学习图像分割方向的学生与研究人员,提供基于注意力增强卷积的ResUNet道路提取与语义分割完整实现,适合作为相关专业毕业设计或课程设计的参考方案。压缩包共9个文件,约13KB,以7个Python源码文件为主,涵盖模型定义、训练脚本、注意力增强卷积模块、数据增强生成器、IoU损失函数与评估指标等核心环节,另附requirement依赖清单与README说明文档,结构清晰便于快速理解项目组织。目前已有71人学习下载。读者可据此在本地部署运行,掌握残差结构与注意力机制结合提升道路区域识别精度的思路,理解模型在复杂背景下的分割鲁棒性,并借助评估脚本验证效果,为城市规划、交通管理、灾害监测等应用场景提供可复现的基线模型与改进起点。
1. 遥感道路提取为什么总在树荫和立交桥下翻车
遥感图像道路提取,说白了就是从卫星或航拍图里把道路像素抠出来,本质是二分类语义分割。这个方向做的人多,但真正落地时你会发现一个玄学现象:模型在开阔直路上 IoU 能到 0.8,一到树荫遮挡、立交桥多层交错、窄巷子就崩,断裂、粘连、毛刺全出来了。原因不复杂——标准卷积的感受野是局部的,道路又恰恰是长距离连续结构,局部特征根本判断不了“这条线该不该连过去”。
注意力增强卷积加 ResUNet 这套组合,就是冲着这个矛盾去的。ResUNet 负责多尺度编码解码和残差跳连,注意力模块负责告诉网络“哪些位置的特征更重要”。它适合做毕业设计、课程项目,也适合遥感解译方向的入门落地。下面我按本地部署能跑通的路径,把选型、代码、参数和坑一条条讲清楚。
2. 注意力增强卷积塞进 ResUNet:结构怎么搭才不白加参数
2.1 为什么是 ResUNet 而不是原版 U-Net
原版 U-Net 的编码器是普通卷积堆叠,层数一深就梯度消失,遥感图像里道路占比往往不到 5%,正负样本极度不平衡,训练很容易塌到“全预测背景”。ResUNet 把编码器和解码器的每个卷积块换成残差块,梯度能跨层回传,深层特征也能保住。更关键的是跳连:编码器的高分辨率特征直接接到解码器,道路的细边缘靠这条路径恢复。
我一般用的残差块是两层 3×3 卷积加 BatchNorm 加 ReLU,再叠一个恒等映射。注意下采样用步长为 2 的卷积而不是最大池化,池化会丢位置信息,道路这种细长目标对位置很敏感。
2.2 注意力增强卷积到底加在哪一层
注意力模块不是越多越好。全塞进去参数量翻倍,小数据集直接过拟合。常见做法是只在编码器每个残差块后面加一个通道注意力,解码器最后一层加一个空间注意力。通道注意力用全局平均池化加两层全连接算权重,空间注意力用通道维度的最大池化和平均池化拼接后过 7×7 卷积。
import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, channels, reduction=16): super().__init__() # 全局平均池化,把 HxW 压成 1x1 self.avg_pool = nn.AdaptiveAvgPool2d(1) # 两层全连接做通道权重,先降维再升维 self.fc = nn.Sequential( nn.Linear(channels, channels // reduction, bias=False), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels, bias=False), nn.Sigmoid() ) def forward(self, x): b, c, _, _ = x.size() y = self.avg_pool(x).view(b, c) y = self.fc(y).view(b, c, 1, 1) return x * y # 逐通道加权这段代码里reduction=16是压缩比,通道数小于 64 时建议改成 8,否则降维后信息损失太大。Sigmoid保证权重落在 0 到 1,乘回原特征就是软注意力。空间注意力类似,但池化维度换成通道维,卷积核用 7×7 是为了覆盖足够大的邻域,判断“这个位置是不是道路”需要看周围上下文。
2.3 完整前向流程和跳连对齐
编码器四级下采样,通道数 64、128、256、512,解码器逐级上采样后和对应编码器特征拼接。拼接前如果尺寸差一个像素,直接报错,这是新手最常见的翻车点。解决办法是上采样用nn.ConvTranspose2d并显式指定output_padding=1,或者拼接前做一次F.interpolate对齐。
class ResUNet(nn.Module): def __init__(self, in_ch=3, out_ch=1): super().__init__() # 编码器:残差块 + 通道注意力 self.enc1 = self._block(in_ch, 64) self.enc2 = self._block(64, 128) self.enc3 = self._block(128, 256) self.enc4 = self._block(256, 512) self.pool = nn.MaxPool2d(2) # 解码器:转置卷积上采样 self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec3 = self._block(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec2 = self._block(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec1 = self._block(128, 64) self.head = nn.Conv2d(64, out_ch, 1) def _block(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ChannelAttention(out_ch) ) 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)) d3 = self.dec3(torch.cat([self.up3(e4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.head(d1)torch.cat的dim=1是通道维拼接,拼接后通道数翻倍,所以解码器块的输入通道要写成两倍。这个结构参数量大概 7M 左右,单张 11GB 显存的卡跑 512×512 输入 batch size 设 4 没问题。
3. 本地跑通训练:数据、损失和显存三件事
3.1 遥感道路数据集怎么准备和增强
公开数据集常见的是 DeepGlobe 和 Massachusetts Roads,前者 512×512 覆盖多国场景,后者 1500×1500 需要切块。我一般统一切成 512×512,重叠 64 像素,避免道路被切断。标签是二值掩码,0 背景 1 道路。
增强别用随机旋转 90 度以外的角度,遥感图像有方向性,任意旋转会让道路纹理失真。常用的是水平翻转、垂直翻转、随机裁剪和亮度对比度抖动。亮度抖动幅度控制在 0.2 以内,太大模型会学成对光照敏感。
import albumentations as A transform = A.Compose([ A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomCrop(512, 512), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3), ])p是触发概率,翻转类可以给 0.5,亮度类给 0.3 就够。别加高斯噪声,遥感图像本身噪声分布和自然图像不同,加了反而掉点。
3.2 损失函数选 Dice 还是 BCE
道路提取正负样本比例可能到 1:20,纯 BCE 会让模型偏向背景。我一般用 BCE 加 Dice 的加权和,权重 0.5 比 0.5。Dice 直接优化重叠度,对不平衡更鲁棒。如果数据里窄路特别多,可以把 Dice 权重提到 0.7。
class BCEDiceLoss(nn.Module): def __init__(self, bce_w=0.5, dice_w=0.5): super().__init__() self.bce = nn.BCEWithLogitsLoss() self.bce_w = bce_w self.dice_w = dice_w def forward(self, pred, target): bce_loss = self.bce(pred, target) # Dice 计算前先过 sigmoid pred = torch.sigmoid(pred) inter = (pred * target).sum(dim=(2, 3)) union = pred.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) dice_loss = 1 - (2 * inter + 1e-6) / (union + 1e-6) return self.bce_w * bce_loss + self.dice_w * dice_loss.mean()1e-6是平滑项,防止分母为零。BCEWithLogitsLoss内部带 sigmoid,所以 Dice 那路要手动再过一次 sigmoid,别重复激活。
3.3 训练脚本和显存不够时的三个降级手段
优化器用 AdamW,学习率 1e-3,余弦退火到 1e-5,训练 100 轮。显存不够时按顺序试:把 batch size 降到 2、把输入切成 256×256、把编码器通道数整体减半。通道减半会掉 2 到 3 个点 IoU,但能跑起来比跑不起来强。
from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model = ResUNet().cuda() opt = AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) sched = CosineAnnealingLR(opt, T_max=100, eta_min=1e-5) criterion = BCEDiceLoss().cuda() for epoch in range(100): model.train() for img, mask in train_loader: img, mask = img.cuda(), mask.cuda() opt.zero_grad() out = model(img) loss = criterion(out, mask) loss.backward() opt.step() sched.step()weight_decay=1e-4是 L2 正则,防止过拟合。余弦退火让学习率平滑下降,比阶梯下降稳定。每轮结束记得在验证集上算 IoU,别只看 loss,loss 降了 IoU 不涨说明过拟合了。
4. 推理、可视化和部署时最容易踩的五个坑
4.1 坑一:推理结果全是噪点小连通域
现象是预测图里道路主线上挂着一堆小斑点。原因是模型在低置信度区域输出 0.5 附近的概率,二值化后变成碎片。解决办法是推理后做连通域分析,去掉面积小于 50 像素的连通域,再做一次形态学闭运算把断裂处接上。
import cv2 import numpy as np def post_process(prob_map, thresh=0.5, min_area=50): binary = (prob_map > thresh).astype(np.uint8) # 闭运算连接断裂 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 去小连通域 num, labels, stats, _ = cv2.connectedComponentsWithStats(binary) for i in range(1, num): if stats[i, cv2.CC_STAT_AREA] < min_area: binary[labels == i] = 0 return binarythresh可以按验证集调,0.5 是起点,窄路多就降到 0.4。min_area别设太大,否则短路段会被误删。
4.2 坑二:验证集 IoU 高但可视化一塌糊涂
这是典型的指标和视觉不一致。IoU 对大面积背景不敏感,道路占比小的时候,背景预测对就能拿高分。解决办法是额外看召回率和 F1,召回率低说明漏检多,F1 低说明误检多。我一般把 IoU、召回、F1 三个一起打印,单看一个容易自欺。
4.3 坑三:部署时输入尺寸和训练不一致
训练用 512×512,推理直接喂 1024×1024,BatchNorm 的统计量对不上,输出会偏。解决办法是推理时也 resize 到 512,或者用F.interpolate把输出还原到原尺寸。别指望模型自动适应任意尺寸,卷积对尺度敏感。
4.4 坑四:模型文件加载报 key 不匹配
保存时用了torch.save(model.state_dict()),加载时却用torch.load直接赋给模型,会报 missing keys。正确做法是先实例化同结构模型,再load_state_dict。如果改过结构,用strict=False跳过不匹配的层,但要确认跳过的不是关键层。
4.5 坑五:CPU 推理慢到没法演示
毕业设计演示经常在没独显的机器上跑。CPU 推理 512×512 大概 2 到 3 秒一张,能接受。如果更慢,把模型转成 ONNX 再用 ONNX Runtime,速度能快一倍。转的时候注意 opset 版本选 11 以上,注意力里的AdaptiveAvgPool2d低版本不支持。
5. 把 IoU 从 0.72 推到 0.80 的三个具体技巧
第一个技巧是深监督。在解码器每一级输出后加一个辅助头,算损失时给辅助头 0.4 的权重,主头 1.0。这样浅层也能收到梯度,边缘更准。实现上就是在dec3、dec2后面各接一个 1×1 卷积上采样到原尺寸,训练时算 loss,推理时只用主头。
第二个技巧是测试时增强。推理时把图水平翻转、垂直翻转各跑一次,三次概率图平均。代价是推理时间三倍,但 IoU 通常能涨 1 到 2 个点。代码上就是把输入做变换,输出逆变换后累加。
def tta_predict(model, img): preds = [] # 原图 preds.append(torch.sigmoid(model(img))) # 水平翻转 preds.append(torch.flip(torch.sigmoid( model(torch.flip(img, [3]))), [3])) # 垂直翻转 preds.append(torch.flip(torch.sigmoid( model(torch.flip(img, [2]))), [2])) return torch.stack(preds).mean(dim=0)torch.flip的维度参数 3 是宽,2 是高,别搞反。平均后再二值化,比单次推理稳。
第三个技巧是学习率 warmup。前 5 轮从 1e-5 线性升到 1e-3,再余弦退火。不加 warmup 时前几轮 loss 会震荡,加了之后收敛曲线平滑很多。这个技巧在大 batch 时效果更明显,小 batch 也能用。
我自己的习惯是每改一个结构或损失,先跑 20 轮看验证 IoU 曲线,涨了就继续,平了就回退。别一次改三个地方,出了问题根本不知道是哪个引起的。遥感道路提取这方向,数据和后处理的重要性不亚于模型本身,注意力模块是锦上添花,不是救命稻草。希望帮到你。
本文还有配套的精品资源,点击获取