ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于改进Unet的高分辨率城市建筑物变化检测系统实战

基于改进Unet的高分辨率城市建筑物变化检测系统实战 简介这份资源面向计算机、遥感与人工智能方向的本科毕业生及需要完成变化检测课题的学习者提供一套基于改进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_size256, stride192, save_dirpatches): 滑窗切块并保存stride 小于 patch_size 实现重叠 os.makedirs(save_dir, exist_okTrue) 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:ypatch_size, x:xpatch_size] post_patch img_post[y:ypatch_size, x:xpatch_size] label_patch label[y:ypatch_size, x:xpatch_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, biasFalse), nn.BatchNorm2d(F_int) ) self.W_x nn.Sequential( nn.Conv2d(F_l, F_int, 1, biasFalse), nn.BatchNorm2d(F_int) ) self.psi nn.Sequential( nn.Conv2d(F_int, 1, 1, biasFalse), nn.BatchNorm2d(1), nn.Sigmoid() ) self.relu nn.ReLU(inplaceTrue) 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, dilation2): super().__init__() self.conv1 nn.Conv2d(in_ch, out_ch, 3, paddingdilation, dilationdilation) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, 3, paddingdilation, dilationdilation) self.bn2 nn.BatchNorm2d(out_ch) self.relu nn.ReLU(inplaceTrue) 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_ch6, out_ch1): super().__init__() # 编码器前两层用普通卷积后两层用空洞卷积 self.enc1 self._block(in_ch, 64) self.enc2 self._block(64, 128) self.enc3 DilatedResBlock(128, 256, dilation2) self.enc4 DilatedResBlock(256, 512, dilation4) self.pool nn.MaxPool2d(2) # 解码器 self.up3 nn.ConvTranspose2d(512, 256, 2, stride2) self.att3 AttentionGate(256, 256, 128) self.dec3 self._block(512, 256) self.up2 nn.ConvTranspose2d(256, 128, 2, stride2) self.att2 AttentionGate(128, 128, 64) self.dec2 self._block(256, 128) self.up1 nn.ConvTranspose2d(128, 64, 2, stride2) 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, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) 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], dim1)) d2 self.up2(d3) e2 self.att2(d2, e2) d2 self.dec2(torch.cat([d2, e2], dim1)) d1 self.up1(d2) e1 self.att1(d1, e1) d1 self.dec1(torch.cat([d1, e1], dim1)) return torch.sigmoid(self.final(d1))AttentionGate的核心是psi输出的注意力系数它把解码器特征和编码器特征相加后过 Sigmoid得到每个空间位置的权重再乘回编码器特征。这样解码器在重建时能抑制与变化无关的背景区域。DilatedResBlock用 dilation2 和 4 的空洞卷积替代了两次下采样感受野扩大了但特征图分辨率没降小建筑物不会被过早丢弃。ImprovedUNet的输入通道是 6因为要把两期影像在通道维度拼接。损失函数建议用0.5 * BCE 0.5 * DiceDice 对正负不平衡更鲁棒。提示注意力门控的参数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 加 DiceBCE 负责稳定梯度Dice 负责优化重叠度。如果变化区域特别稀疏可以把 BCE 换成 Focal Lossgamma 设 2alpha 设 0.75让模型更关注难样本。验证指标不要只看准确率变化检测里准确率很容易被背景拉高。我通常看 IoU 和 F1IoU 低于 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(), lr1e-4, weight_decay1e-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], dim1) 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_steps4表示每 4 个 batch 更新一次参数scaler负责混合精度的梯度缩放。注意loss要除以accum_steps再反向传播否则梯度会累积成 4 倍。torch.cat([pre, post], dim1)把两期影像拼成 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_version11 ) # 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。所以如果你刚开始做这个方向先把配准和标签质量盯死再动网络结构。希望帮到你。本文还有配套的精品资源点击获取
返回列表