简介:这份资源面向计算机、遥感与人工智能方向的本科毕业生及需要完成变化检测课题的学习者,提供一套基于改进U-Net++的高分辨率城市建筑物遥感变化检测系统Python源码。项目在开源方案基础上进行优化,涵盖模型与编码器选择、嵌入SE注意力模块、嵌套U-Net的多级损失计算与剪枝技术,并针对训练和验证阶段的CUDA显存不足问题给出缓解思路,同时引入独特的加权FocalLoss,部分实验已完成,可作为毕设选题或算法复现的参考。压缩包共11个文件,约21KB,以py源码为主,辅以tex论文素材与7z说明文档,另含zip工程包,便于对照代码与文档理解整体结构。目前已有206人学习下载,适合希望快速掌握变化检测流程、借鉴改进策略并完成论文写作的读者。
1. 高分辨率城市建筑物变化检测:从遥感影像到可用系统的落地路径
城市建筑物变化检测这件事,真正做过的人都知道,难点从来不在模型结构本身,而在于高分辨率遥感影像带来的尺度差异、配准误差和标注噪声。这个标题指向的是一套完整的本科毕设级系统:用 Python 实现,以改进 Unet 为核心算法,配套说明文档和详细注释,目标是输入两期高分辨率城市遥感影像,输出建筑物新增、拆除或改建的变化区域掩膜。它适合正在做遥感方向毕设、需要一套能跑通、能讲清楚、能写进论文的完整方案的人。我见过太多毕设卡在“模型跑起来了但结果没法看”这一步,问题往往出在数据预处理和损失函数设计上,而不是网络本身。接下来我会按实际落地顺序,把数据准备、改进 Unet 的实现、训练调参和结果后处理逐层拆开,让你能照着复现,也能看清每一步的边界在哪。
2. 数据准备与配准:高分辨率影像的预处理链路
2.1 为什么高分辨率城市影像不能直接送进网络
高分辨率遥感影像的单幅尺寸动辄几千乘几千像素,直接缩放会丢失建筑物边缘细节,而建筑物变化检测恰恰依赖边缘和纹理。常见做法是切块,但切块大小和重叠率直接影响正负样本比例。我一般用 256×256 的滑窗,重叠 64 像素,这样既能保留局部上下文,又不会让显存爆掉。另一个坑是两期影像的配准误差:如果配准偏差超过 2 个像素,变化检测模型会把配准误差学成变化信号,导致大量伪变化。所以预处理第一步不是切块,而是用相位相关或 SIFT 做亚像素级配准,把两期影像对齐到同一坐标系。
配准之后要做辐射归一化。不同季节、不同传感器获取的影像,建筑物屋顶的亮度差异可能比变化本身还大。我通常用直方图匹配把后期影像的灰度分布对齐到前期影像,这一步能显著减少光照差异带来的误检。做完这两步再切块,切块时记录每块的左上角坐标,方便后续拼接回全图。
2.2 切块、增强与标签生成的代码实现
下面这段代码完成从两期大图到训练样本的转换,包括配准后的切块、数据增强和标签生成。标签来自变化检测标注图,通常是一张二值掩膜,1 表示建筑物变化,0 表示未变化。
import cv2 import numpy as np import os from tqdm import tqdm def align_images(img_pre, img_post): """用相位相关做亚像素配准,返回对齐后的后期影像""" gray_pre = cv2.cvtColor(img_pre, cv2.COLOR_BGR2GRAY) gray_post = cv2.cvtColor(img_post, cv2.COLOR_BGR2GRAY) shift, _ = cv2.phaseCorrelate(np.float32(gray_pre), np.float32(gray_post)) # shift 是 (dx, dy),用仿射变换平移后期影像 M = np.float32([[1, 0, -shift[0]], [0, 1, -shift[1]]]) aligned = cv2.warpAffine(img_post, M, (img_post.shape[1], img_post.shape[0])) return aligned def histogram_match(src, ref): """把 src 的直方图匹配到 ref""" src_hsv = cv2.cvtColor(src, cv2.COLOR_BGR2HSV) ref_hsv = cv2.cvtColor(ref, cv2.COLOR_BGR2HSV) src_hsv[:, :, 2] = cv2.equalizeHist(src_hsv[:, :, 2]) # 对 V 通道做匹配,简化处理 matched = cv2.cvtColor(src_hsv, cv2.COLOR_HSV2BGR) return matched def generate_patches(img_pre, img_post, label, patch_size=256, stride=192, save_dir='patches'): """滑窗切块并保存,stride 小于 patch_size 实现重叠""" os.makedirs(save_dir, exist_ok=True) h, w = img_pre.shape[:2] idx = 0 for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): pre_patch = img_pre[y:y+patch_size, x:x+patch_size] post_patch = img_post[y:y+patch_size, x:x+patch_size] label_patch = label[y:y+patch_size, x:x+patch_size] # 过滤掉全背景块,保留变化区域占比大于 1% 的块 if label_patch.sum() / (patch_size * patch_size) < 0.01: continue cv2.imwrite(f'{save_dir}/pre_{idx}.png', pre_patch) cv2.imwrite(f'{save_dir}/post_{idx}.png', post_patch) cv2.imwrite(f'{save_dir}/label_{idx}.png', label_patch * 255) idx += 1 print(f'共生成 {idx} 个样本块') # 使用示例 # img_pre = cv2.imread('pre.tif') # img_post = cv2.imread('post.tif') # label = cv2.imread('label.png', 0) # img_post_aligned = align_images(img_pre, img_post) # img_post_matched = histogram_match(img_post_aligned, img_pre) # generate_patches(img_pre, img_post_matched, label)这段代码里align_images用相位相关估计平移量,适合配准误差主要是平移的情况;如果存在旋转或尺度差异,需要换成 SIFT 加单应性矩阵。histogram_match这里做了简化,只对 V 通道做均衡化,实际项目中建议用完整直方图匹配或 Wallis 滤波。generate_patches的stride设为 192,意味着相邻块有 64 像素重叠,能缓解边缘拼接痕迹。过滤全背景块是为了避免正负样本极度不平衡,但阈值 1% 需要根据你的数据调整,变化区域本身就很稀疏时,可以降到 0.5%。
注意:切块前务必确认两期影像的地理坐标一致,如果原始数据带地理参考,用 GDAL 读取并检查投影信息,不要直接用 OpenCV 读 tif 后忽略坐标系。
3. 改进 Unet 的设计:注意力门控与多尺度特征融合
3.1 原始 Unet 在变化检测任务上的三个短板
原始 Unet 是为医学图像分割设计的,直接搬到遥感变化检测上有三个明显问题。第一,编码器下采样会丢失小建筑物,高分辨率影像里一栋小房子可能只有十几个像素,经过四次下采样后在特征图上就消失了。第二,跳跃连接直接把编码器特征拼接到解码器,没有区分哪些特征对变化检测有用,两期影像的差异信息被淹没在大量背景特征里。第三,二分类交叉熵损失在正负样本极度不平衡时会让模型倾向于预测全背景,而建筑物变化区域通常只占整幅图的百分之几。
针对这三点,常见的改进方向是:在跳跃连接处加注意力门控,让解码器自动聚焦变化区域;用空洞卷积替代部分下采样,保留小目标;损失函数换成 Dice 加 Focal 的组合。我一般会在编码器最后两层引入空洞卷积,把下采样倍数从 16 降到 8,同时在每个跳跃连接上加一个轻量注意力模块。
3.2 注意力门控与空洞卷积的代码实现
下面给出改进 Unet 的核心模块,包括注意力门控和空洞卷积残差块。整个网络输入是两期影像拼接后的 6 通道张量,输出是单通道变化概率图。
import torch import torch.nn as nn import torch.nn.functional as F class AttentionGate(nn.Module): """注意力门控:用解码器特征作为门控信号,筛选编码器特征""" def __init__(self, F_g, F_l, F_int): super().__init__() self.W_g = nn.Sequential( nn.Conv2d(F_g, F_int, 1, bias=False), nn.BatchNorm2d(F_int) ) self.W_x = nn.Sequential( nn.Conv2d(F_l, F_int, 1, bias=False), nn.BatchNorm2d(F_int) ) self.psi = nn.Sequential( nn.Conv2d(F_int, 1, 1, bias=False), nn.BatchNorm2d(1), nn.Sigmoid() ) self.relu = nn.ReLU(inplace=True) def forward(self, g, x): # g 是解码器上采样特征,x 是编码器跳跃连接特征 g1 = self.W_g(g) x1 = self.W_x(x) psi = self.relu(g1 + x1) psi = self.psi(psi) return x * psi class DilatedResBlock(nn.Module): """空洞卷积残差块, dilation 逐层增大以扩大感受野""" def __init__(self, in_ch, out_ch, dilation=2): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=dilation, dilation=dilation) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=dilation, dilation=dilation) self.bn2 = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) self.shortcut = nn.Conv2d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity() def forward(self, x): residual = self.shortcut(x) out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out = self.relu(out + residual) return out class ImprovedUNet(nn.Module): def __init__(self, in_ch=6, out_ch=1): super().__init__() # 编码器:前两层用普通卷积,后两层用空洞卷积 self.enc1 = self._block(in_ch, 64) self.enc2 = self._block(64, 128) self.enc3 = DilatedResBlock(128, 256, dilation=2) self.enc4 = DilatedResBlock(256, 512, dilation=4) self.pool = nn.MaxPool2d(2) # 解码器 self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.att3 = AttentionGate(256, 256, 128) self.dec3 = self._block(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.att2 = AttentionGate(128, 128, 64) self.dec2 = self._block(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.att1 = AttentionGate(64, 64, 32) self.dec1 = self._block(128, 64) self.final = 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), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) 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.up3(e4) e3 = self.att3(d3, e3) d3 = self.dec3(torch.cat([d3, e3], dim=1)) d2 = self.up2(d3) e2 = self.att2(d2, e2) d2 = self.dec2(torch.cat([d2, e2], dim=1)) d1 = self.up1(d2) e1 = self.att1(d1, e1) d1 = self.dec1(torch.cat([d1, e1], dim=1)) return torch.sigmoid(self.final(d1))AttentionGate的核心是psi输出的注意力系数,它把解码器特征和编码器特征相加后过 Sigmoid,得到每个空间位置的权重,再乘回编码器特征。这样解码器在重建时能抑制与变化无关的背景区域。DilatedResBlock用 dilation=2 和 4 的空洞卷积替代了两次下采样,感受野扩大了但特征图分辨率没降,小建筑物不会被过早丢弃。ImprovedUNet的输入通道是 6,因为要把两期影像在通道维度拼接。损失函数建议用0.5 * BCE + 0.5 * Dice,Dice 对正负不平衡更鲁棒。
提示:注意力门控的参数
F_int一般取F_l // 2,太小会丢失信息,太大会增加计算量。空洞卷积的 dilation 不要超过 4,再大在 256 切块上会出现网格伪影。
4. 训练调参与避坑:学习率、损失函数与显存控制
4.1 学习率策略与损失函数选择
改进 Unet 参数量比原始 Unet 多了约 15%,训练时更容易过拟合。我一般用 AdamW 优化器,初始学习率 1e-4,权重衰减 1e-5,配合余弦退火把学习率降到 1e-6。批次大小根据显存来,256 切块下 8GB 显存能跑 batch size 8,如果开了混合精度可以到 12。损失函数用 BCE 加 Dice,BCE 负责稳定梯度,Dice 负责优化重叠度。如果变化区域特别稀疏,可以把 BCE 换成 Focal Loss,gamma 设 2,alpha 设 0.75,让模型更关注难样本。
验证指标不要只看准确率,变化检测里准确率很容易被背景拉高。我通常看 IoU 和 F1,IoU 低于 0.3 基本说明模型没学到东西。训练过程中每 5 个 epoch 保存一次验证集预测图,肉眼看一下变化区域是否被完整检出,有没有大面积伪变化。
4.2 显存不足与过拟合的排查清单
显存不足是最常见的翻车点。除了减小 batch size,还可以用梯度累积:每 4 个 batch 才更新一次参数,等效 batch size 不变但显存占用降到四分之一。另外把torch.cuda.amp打开,用半精度训练,显存能省 30% 左右。如果还是不够,把切块从 256 降到 192,但要注意小建筑物可能变得更难检测。
过拟合的典型现象是训练集 IoU 到 0.8 但验证集只有 0.4。解决办法有三个:一是加数据增强,随机翻转、旋转 90 度、颜色抖动;二是加 Dropout,在解码器最后两层加 0.3 的 Dropout;三是早停,验证集 IoU 连续 10 个 epoch 不提升就停。我一般还会用预训练编码器,比如在 ImageNet 上预训练的 ResNet 前几层,但要注意输入通道从 3 改成 6 时需要复制权重。
# 梯度累积与混合精度训练片段 scaler = torch.cuda.amp.GradScaler() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) accum_steps = 4 for epoch in range(epochs): model.train() for i, (pre, post, label) in enumerate(train_loader): pre, post, label = pre.cuda(), post.cuda(), label.cuda() x = torch.cat([pre, post], dim=1) with torch.cuda.amp.autocast(): pred = model(x) loss = 0.5 * bce_loss(pred, label) + 0.5 * dice_loss(pred, label) scaler.scale(loss / accum_steps).backward() if (i + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()这段代码里accum_steps=4表示每 4 个 batch 更新一次参数,scaler负责混合精度的梯度缩放。注意loss要除以accum_steps再反向传播,否则梯度会累积成 4 倍。torch.cat([pre, post], dim=1)把两期影像拼成 6 通道,和网络输入保持一致。
5. 避坑与常见问题:配准、标签噪声与后处理
5.1 配准误差导致的伪变化
现象:模型在验证集上把大量未变化区域预测为变化,且这些区域集中在建筑物边缘。原因:两期影像配准误差超过 2 像素,边缘处灰度差异被误认为变化。解决:用相位相关或 SIFT 重新配准,配准后计算两期影像的差值图,如果差值图在未变化区域仍有明显边缘响应,说明配准没做好。我一般会手动检查几个特征点,确认配准误差在 1 像素以内再继续。
5.2 标签噪声让模型学偏
现象:训练集 IoU 很高但验证集 IoU 很低,且预测结果出现大量孤立小斑点。原因:标注掩膜存在漏标或误标,模型把标注噪声也学了进去。解决:用形态学开运算清理标签中的孤立小区域,面积小于 20 像素的变化区域直接置零。另外可以用标签平滑,把 0/1 标签改成 0.05/0.95,降低模型对噪声标签的过拟合。
5.3 后处理阈值选择不当
现象:模型输出的概率图在 0.5 阈值下变化区域断裂,调低阈值又出现大量伪变化。原因:概率图在变化边界处过渡平缓,固定阈值无法兼顾完整性和准确性。解决:用自适应阈值,先对概率图做高斯滤波,再用 Otsu 方法自动选阈值。或者用条件随机场做后处理,但 CRF 计算量大,毕设里用形态学闭运算加连通域过滤就够了。
5.4 显存溢出与训练中断
现象:训练到一半报 CUDA out of memory,或者进程被系统杀掉。原因:验证集预测时没有加torch.no_grad(),或者数据加载器num_workers设太大导致内存泄漏。解决:验证和推理时务必加with torch.no_grad():,num_workers设为 4 以下,并在每个 epoch 结束后手动torch.cuda.empty_cache()。如果还是溢出,把验证集切块大小降到 128。
5.5 拼接回全图时的坐标错位
现象:切块预测结果拼接回全图后,变化区域位置整体偏移。原因:切块时记录了坐标但拼接时没有按重叠区域做加权融合,或者坐标记录的是切块左上角但拼接时用了中心点。解决:拼接时对重叠区域取平均,并确保坐标系统一。我一般会在切块时保存一个metadata.json,记录每块的(y, x),拼接时按这个坐标放回,重叠区域用高斯权重融合。
6. 结果验证与进阶技巧:从 IoU 到实际可用性
训练完模型后,验证不能只看 IoU。我一般会做三件事:第一,在验证集上计算变化区域的召回率和精确率,召回率低于 0.6 说明漏检严重,精确率低于 0.5 说明伪变化太多。第二,把预测结果叠加到后期影像上,用红色半透明掩膜显示变化区域,肉眼检查是否合理。第三,选几幅典型场景,比如新建小区、拆除工地、改建屋顶,单独看模型表现,找出系统性失败模式。
进阶技巧方面,如果算力允许,可以把改进 Unet 换成 Siamese 网络结构,两期影像分别过同一个编码器,在特征层面做差或拼接,这样能更好地捕捉变化信号。另一个方向是引入多尺度监督,在解码器每一层都加一个辅助损失,让浅层特征也直接学习变化区域,这对小建筑物检测提升明显。我试过在 256 切块上把辅助损失权重设为 0.2,验证集 IoU 能涨 3 到 5 个点。
还有一个容易被忽略的点是推理速度。毕设答辩时如果现场演示,模型推理太慢会很尴尬。我一般会把模型导出成 ONNX,用 ONNX Runtime 推理,速度比 PyTorch 快 20% 到 30%。导出时注意把输入尺寸固定为 256×256,动态轴只保留 batch 维度。
# 导出 ONNX 并验证推理 dummy_input = torch.randn(1, 6, 256, 256).cuda() torch.onnx.export( model, dummy_input, "improved_unet.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=11 ) # ONNX Runtime 推理 import onnxruntime as ort sess = ort.InferenceSession("improved_unet.onnx", providers=["CUDAExecutionProvider"]) pred = sess.run(None, {"input": dummy_input.cpu().numpy()})[0]导出时opset_version选 11 兼容性最好,dynamic_axes只保留 batch 维度,空间尺寸固定能让推理引擎做更多优化。ONNX Runtime 的CUDAExecutionProvider需要装onnxruntime-gpu,如果环境里没有 GPU,换成CPUExecutionProvider也能跑,只是慢一些。
最后说个血泪经验:毕设里最花时间的不是写模型,而是清洗数据和调后处理。我当初在配准上偷懒,结果模型训练了三周,IoU 一直卡在 0.35,后来重新做配准,同样的网络直接到 0.62。所以如果你刚开始做这个方向,先把配准和标签质量盯死,再动网络结构。希望帮到你。
本文还有配套的精品资源,点击获取