ARTICLE DETAIL

建站实战干货

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

DeepLab-ResNet建筑物变化检测实战:双塔共享权重与工程避坑指南

2026/10/1 6:30:21 拓冰建站 浏览量
DeepLab-ResNet建筑物变化检测实战:双塔共享权重与工程避坑指南 简介面向遥感与 GIS 领域研究者和深度学习开发者这份资源提供基于 Deeplab-resnet 的建筑物变化检测完整 Python 实现。算法融合 Deeplab 空洞卷积与 ResNet 残差连接可对高分辨率影像中的建筑边界进行精细分割适用于城市规划、灾害评估、环境监测等需要及时识别建筑新建、拆除或损坏的场景。包内共 40 个文件zip 压缩包约 170KB以 33 个 Python 脚本为主体覆盖训练、预测、参数配置、数据加载、损失计算、评估指标等完整流程另有 5 个文本文件用于说明与使用指引1 份 LICENSE 和 1 个 .gitignore 保证开源规范与仓库整洁。已有 330 人浏览学习。通过学习源码可完整了解数据预处理、模型搭建、训练评估和推理部署的实现思路工程目录组织清晰适合希望复现语义分割算法、开展变化检测实验的初学者也方便研究者在已有框架上扩展改进。1. 拿到两期遥感影像你的第一个判断不该是“我要训练一个变化检测网络”如果你手里正好有两个时间点的同一片区影像想搞清楚哪些位置冒出了新房子、哪些楼被拆掉了那你搜到Deeplab-resnet 建筑物变化检测源码这个方向不算绕路。常见做法不是去端到端地学习“两期影像到变化图”的映射而是把 DeepLab-ResNet 当作一个非常耐用的特征提取骨架先把每一期影像分割语义提出来再让两期特征去“对账”。这套方案稳定、可复现、翻车点明确适合做自然资源监测、项目验收复核以及遥感方向课程的课程设计。这篇文章按我自己的落地路径来写从框架设定、数据预处理、训练参数一路讲到排查血泪经验新手可以照着跑熟手可以直接跳到避坑章节。2. 为什么选 Deeplab-resnet 做变化检测双塔共享权重才是真正的主角2.1 先搞懂 Deeplab-resnet 不是分割模型而是特征提取器很多人第一次接触Deeplab-resnet是在语义分割任务里于是拿到变化检测题目后第一反应是把两期影像拼成六个通道直接喂给 DeepLab-ResNet 做二分类分割。这个思路能跑通但指标往往很难看原因是网络被要求同时理解“地物长什么样”和“两期差了什么”两件事挤在同一组卷积里互相干扰。DeepLab-ResNet 真正值钱的部分是两个底层的 ResNet 负责把影像从像素空间抽象到语义特征空间顶层的 ASPP 空洞空间金字塔池化负责在不同感受野下聚合上下文。ASPP 用不同空洞率的并行卷积捕捉多尺度信息这对建筑物变化特别友好。建筑物边缘尖锐、尺度差异大一层普通卷积很难同时照顾到城中村密布的小房子和工业厂房的超大屋顶而 ASPP 的多分支设计恰好把这个矛盾拆开了。在变化检测里我一般把 ResNet 当作“特征提取器”而不是“分类器”来用ASPP 则作为特征精炼模块。两个时相的影像各自经过同一套参数的前向计算得到两组特征图再做逐像素差异或者通道拼接最后用一个轻量卷积头判断每个像素是“变化”还是“不变”。这个结构和单图语义分割的最大区别在于参数是共享的但判断是相对的。2.2 变化检测的三种框架怎么选单流输入、双流拼接、共享权重双塔把两期影像送进网络从业界落地经验看主要有三种组织方式。第一种是单流输入把 T1、T2 影像在通道维度上拼接成 6 通道输入直接训练一个分割网络。优点是代码最简单缺点也明显网络要自己学会“先分别提取特征再比较”这件事而且 3 通道预训练权重不能直接用第一层卷积得重新初始化训练数据不够时非常容易过拟合。第二种是双流不共享权重T1 和 T2 各走一套独立的 ResNet最后把特征拼接或求差。这个方案的表达能力强但参数量翻倍训练难度也随之上涨。小数据集上两套网络会各自学到不同的特征分布变化检测本身是在找“差异”如果两侧特征分布本身不一致模型会倾向于把这种不一致误判为变化。第三种是共享权重双塔T1 和 T2 分别输入但经过的是同一套网络参数。这是我在建筑物变化检测里最常用的方案。它既保留了双流的“先提取再比较”结构又让两侧特征保持在同一个语义空间里差异计算更干净。参数量只比单图分割略多一个变化头训练稳定预训练权重也能完整迁移。我自己做选型判断时就看三点数据量够不够支撑双流独立参数、两期影像是不是同传感器同季节、项目周期允不允许做精细调参。数据量小、时间紧无脑选共享权重双塔数据量大且两期影像风格差异大可以考虑不共享权重的双流但需要在损失函数上额外约束两侧特征分布的一致性。2.3 最小可跑通的主干拼接把分割头换成变化头下面给出一段可以直接放进训练脚本的主干构造代码。以 torchvision 官方的 DeepLabV3-ResNet101 为起点取它的 Backbone 和 ASPP 部分再接一个输出 1 个通道的变化头。import torch import torch.nn as nn from torchvision.models.segmentation import deeplabv3_resnet101 from torchvision.models.segmentation import DeepLabV3_ResNet101_Weights def build_change_detection_model(in_channels3): # 新版 torchvision 传 weights旧版传 pretrainedTrue deeplab deeplabv3_resnet101(weightsDeepLabV3_ResNet101_Weights.DEFAULT) backbone deeplab.backbone # ResNet101 特征提取部分 aspp deeplab.classifier # ASPP 1x1 卷积 class SharedChangeHead(nn.Module): def __init__(self, aspp, out_channels1): super().__init__() self.aspp aspp self.change_conv nn.Sequential( nn.Conv2d(256 * 2, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, out_channels, kernel_size1) ) def forward(self, feat_t1, feat_t2): # 共享权重体现在同一个 self.aspp 处理两期特征 out_t1 self.aspp(feat_t1) out_t2 self.aspp(feat_t2) # 拼接而不是直接做差保留更多信息 diff torch.cat([out_t1, out_t2], dim1) return self.change_conv(diff) class ChangeDetector(nn.Module): def __init__(self, backbone, aspp): super().__init__() self.backbone backbone self.change_head SharedChangeHead(aspp) def forward(self, img_t1, img_t2): feat_t1 self.backbone(img_t1)[out] feat_t2 self.backbone(img_t2)[out] return self.change_head(feat_t1, feat_t2) return ChangeDetector(backbone, aspp)这段代码的逻辑是两期影像分别过同一个backbone得到各自的语义特征图这两组特征再分别过同一个aspp模块完成多尺度上下文聚合最后一层变化头把两期特征拼接起来用一个小卷积网络判断变化。注意self.aspp和self.backbone在SharedChangeHead和ChangeDetector中都是同一个对象引用所以两期输入的权重天然就是共享的。这里的关键参数是通道数。torchvision 中 DeepLabV3-ResNet101 的 ASPP 输出通道是 256拼接后变成 512所以变化头第一层卷积输入是256 * 2。如果你换用 ResNet50 版本这个数字还是 256因为 ASPP 的输出通道和 Backbone 层数无关。变化头最后输出 1 个通道配合 Sigmoid 做二分类。3. 数据准备是变化检测的胜负手对齐、归一化、patch 切分3.1 两期影像必须先做相对归一化否则模型学到的是“亮度差”建筑物变化检测的训练数据比普通分割数据难伺候得多。最常见的翻车现场是训练集和验证集都是从同一对两期影像里切的 patch模型 F1 分数高得惊人一放到不同季节、不同光照的新影像上就大面积误检。原因出在两期影像的辐射差异上。如果 T1 是上午拍的、T2 是下午拍的或者 T1 是夏天、T2 是冬天哪怕地面上什么都没变像素值也差了一大截。模型如果只靠颜色亮度差异来判断变化就会把阴影、植被枯荣、水体倒影全部报成变化区域。所以数据预处理的第一步不是增强而是把两期影像拉到同一个辐射水平上。我一般用直方图匹配做相对归一化。以 T1 影像为基准把 T2 影像的每个波段的直方图匹配到 T1 的对应波段上。注意不是匹配全局灰度而是逐波段匹配。如果传感器不同还要先做重采样保证空间分辨率一致。这一步做完后再做归一化常见做法是把两期影像都缩放到[0, 1]或者按整幅影像的均值和标准差做 z-score。需要特别强调的是不要直接套用 ImageNet 的均值标准差。遥感影像的波段分布和自然图像差异很大尤其多光谱影像用 ImageNet 参数归一化会把近红外等波段的数据压到一个非常窄的区间导致网络在浅层就丢失信息。3.2 写一个最省事的 PyTorch Dataset同时读取 T1、T2 和标签下面给出一个适合遥感影像变化检测的 Dataset 类用 Rasterio 读取 GeoTIFF支持随机裁剪、翻转和旋转增强。import numpy as np import rasterio import torch from torch.utils.data import Dataset import random class ChangeDetectionDataset(Dataset): def __init__(self, t1_path, t2_path, label_path, patch_size512, augmentTrue): self.t1_path t1_path self.t2_path t2_path self.label_path label_path self.patch_size patch_size self.augment augment # 读取影像元信息用于计算patch的起始位置 with rasterio.open(t1_path) as src: self.height, self.width src.height, src.width self.bands src.count self.patches [] for top in range(0, self.height - patch_size 1, patch_size): for left in range(0, self.width - patch_size 1, patch_size): self.patches.append((top, left)) def __len__(self): return len(self.patches) def __getitem__(self, idx): top, left self.patches[idx] with rasterio.open(self.t1_path) as src1, \ rasterio.open(self.t2_path) as src2, \ rasterio.open(self.label_path) as src_label: t1 src1.read(window((top, top self.patch_size), (left, left self.patch_size))) t2 src2.read(window((top, top self.patch_size), (left, left self.patch_size))) label src_label.read(window((top, top self.patch_size), (left, left self.patch_size))) # 转成 float32 并缩放到 [0,1] t1 t1.astype(np.float32) / 255.0 t2 t2.astype(np.float32) / 255.0 label label.astype(np.float32) / 255.0 label (label 0.5).astype(np.float32) # 标签二值化 # 数据增强保持 t1、t2、label 做同样的几何变换 if self.augment: if random.random() 0.5: t1 t1[:, :, ::-1].copy() t2 t2[:, :, ::-1].copy() label label[:, :, ::-1].copy() if random.random() 0.5: # 随机旋转 90 度的倍数 k random.choice([1, 2, 3]) t1 np.rot90(t1, kk, axes(1, 2)).copy() t2 np.rot90(t2, kk, axes(1, 2)).copy() label np.rot90(label, kk, axes(1, 2)).copy() return ( torch.from_numpy(t1), torch.from_numpy(t2), torch.from_numpy(label).unsqueeze(0) )这个 Dataset 的核心逻辑是坐标对齐。t1、t2、label必须来自同一套地理坐标切 patch 时用同一个top和left保证三个文件在窗口范围内严格对应。增强部分的关键点是 T1、T2、标签必须做完全相同的变换否则模型会被迫去学习“错位”的伪变化。patch_size512是兼顾显存和感受野的常用选择。小于 256 会让 ASPP 的大空洞率卷积失去意义大于 1024 则会让训练显存压力很大。影像分辨率特别高时建议先按比例压缩到 1024 像素以内再切 patch而不是直接从原始分辨率硬切。3.3 没有双时相标注时怎么办很多读者拿到手的只有一份模型源码没有配套数据。如果手上只有单一时相影像和建筑物标注又想验证变化检测流程可以自己造模拟变化样本。做法是对原图做一块区域的平移、遮挡或颜色扰动把扰动区域当成“变化标签”。这种数据能帮你调通训练和推理流程也能让模型学到一些边缘特征但不要指望它能代表真实的变化真实世界的变化更多是结构变化不是颜色扰动。真实项目里更可靠的路径是找同一区域两期公开影像手动标注或半自动标注变化区域。标注时注意一条边界只有建筑物本体结构发生变化才算变化屋顶颜色翻新、阴影变化、车辆移动都不算。这个标注标准直接影响模型学到的语义很多人后期误检严重回头查标注才发现标注本身的噪声太大。4. 训练和评估损失函数怎么定、超参数怎么设、指标怎么读4.1 损失函数选型BCE 不够Dice 来凑变化检测的本质是一个极度不平衡的二分类任务。绝大多数遥感场景里变化像素占比不超过 5%有些项目甚至低于 1%。如果直接用 BCE Loss模型很快学会“全部预测为不变”因为这样 Loss 已经很低了。我一般用 BCE 和 Dice 的加权和。Dice Loss 对类别不平衡不敏感能让模型把注意力放到变化区域的结构完整性上。两部分的权重通常各取 1.0变化区域极稀疏时可以把 Dice 权重提高到 1.2。import torch import torch.nn.functional as F def dice_loss(pred, target, smooth1.0): # pred 是未经过 sigmoid 的 logits pred torch.sigmoid(pred) inter (pred * target).sum(dim(1, 2, 3)) union pred.sum(dim(1, 2, 3)) target.sum(dim(1, 2, 3)) dice (2.0 * inter smooth) / (union smooth) return 1.0 - dice.mean() def bce_dice_loss(pred, target, dice_weight1.0): bce F.binary_cross_entropy_with_logits(pred, target) dice dice_loss(pred, target) return bce dice_weight * dice这段代码里pred是模型直接输出的 logits没有做 SigmoidF.binary_cross_entropy_with_logits内部会处理数值稳定性。Dice 计算时先做 Sigmoid 再计算交并比。smooth1.0是为了防止公式里的分母为 0也有一点平滑作用。如果训练过程中发现模型对变化区域的大小极端敏感比如大块变化区域被你检测出来了小块变化区域几乎全丢可以把 Dice 换成带平方项的变体。但一般先用上面这个版本就够了换损失函数属于调参的最后手段前面数据对齐和标签质量的影响更大。4.2 训练循环关键代码冻结 BatchNorm 值得单独提共享权重双塔模型在训练时有一个容易被忽略的细节BatchNorm 层的统计量更新。因为两期影像的亮度分布不完全相同即使做了直方图匹配T1 和 T2 的 BatchNorm 统计量也会被拉到两个不同的状态这会削弱共享权重的意义。常见做法是冻结 Backbone 里所有 BatchNorm 的统计量更新只让它们使用预训练累积的全局统计量。def freeze_batchnorm(model): for m in model.modules(): if isinstance(m, torch.nn.modules.batchnorm._BatchNorm): m.eval() # 切换到推理模式停止更新 running_mean/running_var这段代码要在训练开始前调用。注意m.eval()会让 BatchNorm 使用 running 统计量而不是当前 batch 统计量对于 batch size 偏小的训练场景尤其有效。如果你的显卡只允许 batch size 为 4 或 8这个技巧几乎是必做的否则 BatchNorm 在小 batch 上估计的统计量波动太大训练过程会非常不稳定。训练循环相对常规下面给出一段骨架model build_change_detection_model() model model.cuda() freeze_batchnorm(model) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) for epoch in range(50): model.train() # 注意freeze_batchnorm 后仍要调 model.train() # 但 BatchNorm 会因为之前 eval() 的覆盖而保持冻结 for t1, t2, label in train_loader: t1, t2, label t1.cuda(), t2.cuda(), label.cuda() pred model(t1, t2) loss bce_dice_loss(pred, label) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()学习率我一般从1e-4起步用 AdamW 而不是原始 Adam因为权重衰减对遥感小数据集防止过拟合更有效。T_max50配合余弦退火可以在训练后期自动降低学习率让损失函数在极小值附近稳定下来。4.3 评估指标不能只看 Accuracy变化检测的及格线是 F1变化检测领域里全体像素 Accuracy 是一个极具欺骗性的指标。变化区域只占 1% 的情况下模型什么都不预测Accuracy 也有 99%。所以我一般要求项目至少汇报四个指标Precision、Recall、F1、IoU。Precision 衡量预测的变化像素里有多少是真的Recall 衡量真实变化像素里有多少被找出来F1 是两者的调和平均IoU 对边界质量更敏感。这里给一份常用的计算代码def compute_metrics(pred, target, threshold0.5): pred_bin (pred threshold).astype(np.uint8) target_bin (target 0.5).astype(np.uint8) pred_pos pred_bin.sum() target_pos target_bin.sum() tp ((pred_bin 1) (target_bin 1)).sum() fp ((pred_bin 1) (target_bin 0)).sum() fn ((pred_bin 0) (target_bin 1)).sum() precision tp / (tp fp) if (tp fp) 0 else 0.0 recall tp / (tp fn) if (tp fn) 0 else 0.0 f1 2 * precision * recall / (precision recall) if (precision recall) 0 else 0.0 iou tp / (tp fp fn) if (tp fp fn) 0 else 0.0 return {precision: precision, recall: recall, f1: f1, iou: iou}实际项目里我更关心 Recall 和 F1 的组合。如果项目的目标是“发现所有疑似违建”Recall 优先宁可多报一些让人工复核如果目标是“自动出图直接入库”Precision 要拉高误检太多会导致成果被质疑。训练轮数方面遥感变化检测数据集通常不大50 到 100 epoch 就够。我见过有人用 300 epoch 硬训小数据集最后 F1 在训练集上接近 1.0验证集上却不升反降这是典型的过拟合。把训练过程可视化出来确认验证 Loss 的最低点然后选那个 checkpoint 而不是最后一个。5. 避坑指南变化检测常见的五个翻车现场5.1 验证指标很漂亮一换影像整片误检现象训练集和测试集都是从同一地区切出来的F1 能到 0.85 以上但把模型迁移到邻区影像上大量绿地、水体被报成变化。原因两期影像之间存在光照、季节或土壤湿度差异模型没有真正学会“建筑物结构变化”而是学会了“两期像素值差异大就报变化”。植物枯荣造成的绿光波段变化、水体反射角度变化都会被当成变化。解决数据预处理阶段强制做直方图匹配两期影像都匹配到同一个参考影像上。另外可以把特征从 RGB 空间扩展到 NDVI 等指数特征抑制植被变化对模型的干扰。如果项目允许尽量在训练数据里混入不同季节的影像对让模型见过足够多的“干扰变化”才能学会忽略它们。5.2 显存不够调小 batch size训练直接不收敛现象batch size 从 16 降到 4 后Loss 曲线在初期下降一点后开始震荡甚至直接发散。换用更大的显卡后同样的代码又正常了。原因ResNet 的 BatchNorm 层依赖 batch 内统计量估计batch size 过小时统计量噪声太大训练过程不稳定。共享权重双塔模型尤其明显因为两期影像的 batch 统计差异被放大了。解决先冻结 Backbone 的 BatchNorm让小 batch 只影响变化头和 ASPP 的新增层。如果仍然不稳定把 Backbone 里的 BatchNorm 替换成 GroupNormGroupNorm 的统计不依赖 batch 维度单卡 batch size 为 1 也能稳定训练。这个替换只需改一行代码但要注意替换后预训练权重的 BN 参数就失效了需要重新训练足够轮数。5.3 整幅影像推理结果全是棋盘格拼接缝现象训练和验证都是按 patch 切出来的单块 patch 预测效果不错但拼回整幅影像后patch 边界一条一条的变化区域在边界处断成锯齿。原因训练时做了随机翻转和旋转增强模型学到了不同朝向的特征但推理时每个 patch 是独立预测的patch 之间没有重叠区域边界像素缺少上下文信息预测结果在边界处跳变。解决推理时做带重叠的滑窗处理。patch size 保持 512每次滑动 256 像素让相邻 patch 有 50% 重叠重叠区域取两次预测的平均值。这样边界处的预测会被两个 patch 的上下文共同决定拼接缝基本消失。代价是推理时间增加约一倍但效果提升非常明显属于性价比最高的后处理手段。5.4 模型预测结果全是不变现象训练 Loss 正常下降但所有预测结果接近全黑变化区域一个像素都没有。原因类别不平衡走到极端了。变化像素占比低于 1% 时即使加了 Dice Loss模型的初始预测也可能把梯度主导权让给“预测不变”的一方处于一种损失局部最小值附近。解决先把 BCE 和 Dice 的权重比调成 1:2并提高学习率到3e-4让模型跳出局部极小值。如果还不行检查标签文件是否有问题比如标签二值化阈值设错全部小于 0.5 导致真值全为零。最后的手段是给变化像素加权重采样。5.5 模型预测结果覆盖了整栋房子但房子边缘向外扩了一圈现象建筑物的变化区域基本被识别出来了但预测结果总是比真实建筑轮廓大一圈相邻较近的房子会连成一片。原因ASPP 的大空洞率卷积感受野很大会引入周围地物的上下文信息。同时训练标签里建筑边缘标注不够精细模型学到的是“边缘附近也属于变化”的模糊印象。解决训练结束后使用条件随机场或多尺度融合来处理边缘。更简单的方法是推理时把变化概率图先做一次形态学腐蚀去掉边缘外扩。形态学腐蚀的 kernel size 先设为 3如果外扩仍然严重再扩大到 5。注意腐蚀会同时让细小的变化目标消失所以要配合面积过滤来保护小建筑。6. 进阶验证技巧多尺度融合测试与变化区域矢量化输出模型训练完成之后我习惯做两件事来验证它是否真的能用多尺度测试增强和变化结果矢量化。多尺度测试增强的思路是推理时不只使用原始分辨率而是把影像缩小到 0.75 倍和放大到 1.25 倍分别预测再把多尺度的预测结果按双线性插值统一到原始尺寸后取平均。这个做法能让 ASPP 的多尺度特性真正发挥作用对小房子和大厂房同时保持较好的召回能力。实测变化检测的 F1 通常能涨 1 到 3 个点边缘也更稳定。代价是推理时间增加两倍适合在出成果前做一次全面的验证评估而不是在实时系统的每次推理里都开。变化区域矢量化是让结果可交付的关键一步。模型输出的概率图是一张 GeoTIFF但甲方和 GIS 部门需要的通常是 Shapefile 或 GeoJSON。我一般先做连通域分析把小于某个面积阈值的碎块过滤掉再对每个连通域生成轮廓多边形最后写入 GeoJSON。如果影像有地理坐标信息用 Rasterio 读取仿射变换参数把像素坐标转换为经纬度或平面坐标。这段代码值得单独贴出来from scipy import ndimage import numpy as np def mask_to_geojson(prob_map, transform, min_area100, threshold0.5): binary (prob_map threshold).astype(np.uint8) labeled, num_features ndimage.label(binary) features [] for i in range(1, num_features 1): mask (labeled i) area mask.sum() if area min_area: continue # 提取外轮廓 filled ndimage.binary_fill_holes(mask) contour ndimage.find_objects(filled)[0] # contour 是切片元组可以拿到外接矩形进一步用 marching squares 提取精细边界 # 在遥感代码里通常直接调用 GDAL 的 Polygonize这里用简化方式示意 coords [] rows, cols np.where(filled) xs transform[2] cols * transform[0] ys transform[5] rows * transform[4] for x, y in zip(xs, ys): coords.append([float(x), float(y)]) features.append({ type: Feature, properties: {area_px: int(area)}, geometry: {type: MultiPoint, coordinates: coords} }) return {type: FeatureCollection, features: features}这段代码的关键点是transform参数来自 Rasterio 的src.transform它把像素坐标映射到投影坐标。实际项目里通常不会用 MultiPoint 来交付而是用rasterio.features.shapes直接提取多边形这里简化是为了展示面积过滤和坐标映射的逻辑。min_area的设置取决于影像分辨率0.5 米分辨率下 100 像素对应约 25 平方米太小就只剩噪声。我早期做变化检测项目时总觉得多尺度融合是锦上添花、矢量化是后处理杂活把精力全放在调模型网络上。后来回过头来才发现真正让成果被认可的不是 F1 涨了 0.5 个点而是交付的矢量边界不碎、不糊、能直接叠到 GIS 里和现状地形图比对。从那以后我把这两个环节放到了和训练同等重要的位置。这个方案从数据预处理到矢量输出每一步都有明确的可验证产出希望你按这个路径跑下来能少走我之前走过的弯路。希望帮到你。本文还有配套的精品资源点击获取