简介:基于Deeplab-resnet算法的建筑物变化检测设计源码,聚焦遥感与GIS领域的建筑物变化监测任务,面向深度学习研发者与地理信息研究者。算法融合Deeplab空洞卷积与ResNet残差结构,在高分辨率图像分割中保持边界精度并扩大感受野,适合城市变化分析、灾害评估等场景。压缩包内共40个文件,以33个Python脚本为核心,涵盖模型训练(train.py)、预测(predict.py)、配置管理(config.py)、数据加载与工具模块(dataloaders、utils),另有5个文本说明、LICENSE与.gitignore,包体仅170KB。项目结构清晰,readme.txt提供使用指引,VOCdevkit暗示适配Pascal VOC数据集,便于复现训练与测试流程。已有330人学习浏览,适合需要完整工程参考、快速上手深度分割任务的研究者。源码同时展示训练、评估、学习率调度等模块的衔接方式,并强调边界的精确划分,为后续算法改进提供可直接拓展的代码基础。
1. 基于Deeplab-resnet算法的建筑物变化检测设计源码到底在做什么
做遥感变化检测的同行应该都遇到过这种场景:手上有一前一后两期影像,领导要让把新增的、拆除的房子都圈出来。人工在ArcGIS里拉选框,一个小县城就能画两天。基于Deeplab-resnet算法的建筑物变化检测设计源码,恰恰就是把这套流程自动化——它用DeepLabV3+配合ResNet骨干从双时相影像里提取建筑物分割结果,再做逐像素差分或特征差分,最终输出一张“哪里变了”的二值掩膜。源码的意义不在于变幻检算法本身,而在于给了后来者一条能落地的训练、推理、后处理完整链路。适合手里有已配准的双时相遥感影像、希望用最少的PyTorch代码搭建一个可复现基线的工程师。
先说结论:这条路线最大的坑不是网络结构,而是“分割结果好不等于变化检测结果好”。后文我会把网络怎么搭、样本怎么做、训练怎么调、最后怎么后处理,按踩过的顺序讲清楚。
2. Deeplab-resnet为什么是变化检测的合适骨架:语义分割与特征复用的取舍
2.1 从ResNet到Deeplab:空洞卷积如何保住感受野与分辨率
变化检测本质上要求模型同时理解“这个物体是什么”(语义)和“这个物体在哪”(位置)。普通ResNet分类网络为了加大感受野,会一次次地做下采样,到最后一层特征图只有输入的1/32。拿这个特征图做逐像素预测,边缘和细小建筑会糊成一片。Deeplab-resnet的核心改动是:把ResNet后面几个block里的普通卷积替换成空洞卷积,在不增加参数的前提下让输出特征图保持在1/8或1/16分辨率。
我一般用深度学习框架里自带的DeeplabV3作为参考,自己组装ResNet101和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_channels=256, atrous_rates=(6, 12, 18)): super().__init__() self.branch1 = nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels) ) # 三个不同空洞率的3x3卷积,用于捕捉不同尺度的物体 self.branch2 = self._make_atrous(in_channels, out_channels, 3, atrous_rates[0]) self.branch3 = self._make_atrous(in_channels, out_channels, 3, atrous_rates[1]) self.branch4 = self._make_atrous(in_channels, out_channels, 3, atrous_rates[2]) # 全局平均池化分支,提供全图上下文 self.branch5 = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels) ) self.project = nn.Sequential( nn.Conv2d(out_channels * 5, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def _make_atrous(self, in_c, out_c, kernel_size, rate): return nn.Sequential( nn.Conv2d(in_c, out_c, kernel_size, padding=rate, dilation=rate, bias=False), nn.BatchNorm2d(out_c) ) def forward(self, x): size = x.shape[-2:] b1 = self.branch1(x) b2 = F.relu(self.branch2(x)) b3 = F.relu(self.branch3(x)) b4 = F.relu(self.branch4(x)) b5 = F.relu(self.branch5(x)) b5 = F.interpolate(b5, size=size, mode='bilinear', align_corners=True) return self.project(torch.cat([b1, b2, b3, b4, b5], dim=1))ASPP的设计逻辑很简单:不同空洞率相当于用不同“视野”的滤镜去观察同一个特征图。rate=6、12、18分别覆盖小、中、大建筑尺度;全局平均池化分支则把整张图的语境拉进去,防止近处的相似地物造成误判。需要注意的是,project输出通道数不是固定256,只在这里,后续接Decoder时会用。很多翻车场景恰恰是这里的输出通道和后续网络写得不一致,导致加载预训练权重时报shape不匹配。
2.2 双时相输入为什么要共享权重:变化检测的两种解码套路
Deeplab-resnet本身是为单张语义分割设计的,拿到变化检测任务上,常见有两类接法。第一类是“先分割后差分”:两期影像分别送入同一个Deeplab,得到两幅建筑物概率图,然后直接做差,超过阈值即为变化。这套源码的优点是不用改网络结构,训练时只需要单期影像的建筑物掩膜,变化检测逻辑在后期叠加。第二类是“孪生特征差分”:两个时相的图像经过同一套ResNet骨干,各自得到特征图,再在特征层面做减法或拼接,最后接一个变化分类头。后者能学到“哪些特征差异代表建筑变化”,准确率上限更高,但改动量大,训练数据也要成对标注。
我一般建议先跑通第一种,因为它对数据标注的要求低,可以用公开的建筑物分割数据集(比如WHU或Inria)预训练模型,再去微调自己的双时相数据。共享权重这个点很关键:如果两个时相分别用了两个不同参数的网络,那输出的概率分布在数值域上可能完全不同,做差分时即使没有变化的建筑也会因为网络输出差异被误判成变化。所以要么直接共用同一个encoder,要么至少共用预训练权重并冻结BN层。
2.3 源码里的网络结构形态:DeepLabV3+与ResNet的组装关系
公开的“基于Deeplab-resnet算法”的源码,绝大多数是DeepLabV3+结构而不是最早的V1/V2。V3+相比V3多了一个Decoder模块,把ResNet第二层(layer2)的低级特征和ASPP的高级特征进行融合,用来恢复建筑边缘细节。一次典型的组装顺序是:输入双时相影像(各3通道)→ ResNet层到layer4(stride=16)→ ASPP → 上采样4倍 → 与layer2特征拼接 → 再上采样4倍 → 输出原分辨率概率图。
写代码时,最好把骨干分成__init__里可以替换的部分。有些源码是直接调torchvision.models.segmentation.deeplabv3_resnet101,但那个输出通道是21(COCO类别),你要把最后的分类层改成2(前景/背景)或者3(变化/不变/背景)。这里有个血泪经验:源码默认的aux_loss如果是True,会要求训练的时候同时提供分割标签和辅助标签,否则直接报错。如果你的数据只有变化标签,记得把辅助损失关掉,或者改成在单时相分割图上计算。
3. 样本准备与数据增强:让模型认出“建筑变了”而不是“光线变了”
3.1 双时相样本对的制作:图像配准与切块
变化检测最怕数据没对齐。两期影像如果来自不同传感器,即便经过地理配准,像素级也不一定对齐。实操中,我会先用GDAL或Rasterio把二期影像重采样到同一分辨率,再做一次互相关平移配准。这一步不必在深度学习代码里做,而是放到数据预处理阶段。下面是一段用GDAL做裁剪和重采样的脚本,确保两个时相像素坐标严格对应。
from osgeo import gdal import numpy as np def crop_pair(path_t1, path_t2, output_size, out_prefix): ds1 = gdal.Open(path_t1) ds2 = gdal.Open(path_t2) # 统一坐标系和分辨率,以第一期为基准 ds2_warped = gdal.Warp('', ds2, dstNodata=0, format='VRT', xRes=ds1.GetGeoTransform()[1], yRes=ds1.GetGeoTransform()[1]) width, height = ds1.RasterXSize, ds1.RasterYSize # 按256x256滑窗切块,步长设置为128,保留重叠避免建筑被切断 for i in range(0, height, 128): for j in range(0, width, 128): xoff, yoff = j, i w, h = min(256, width - xoff), min(256, height - yoff) arr1 = ds1.ReadAsArray(xoff, yoff, w, h)[:3] arr2 = ds2_warped.ReadAsArray(xoff, yoff, w, h)[:3] np.save(f'{out_prefix}_t1_{i}_{j}.npy', arr1.astype(np.float32)) np.save(f'{out_prefix}_t2_{i}_{j}.npy', arr2.astype(np.float32))重点在于gdal.Warp这一步:它会把第二期影像重采样到与第一期相同的格网。很多源码默认假设两期影像已严格配准,实际上这是最常踩的坑。切块步长设置成重叠128像素,能让建筑在块边缘时至少有一个完整的框在里面,模型预测时不会因为边界视野不完整而误判。存储用npy临时保存,比每轮都重复读GeoTIF快很多。
3.2 标签的三种写法:变化图、双时相分割图与特征差异图
源码里标签格式直接决定网络输出层长什么样。最常见的三种写法是:
| 标签类型 | 每个像素的数值含义 | 适用网络输出 |
|---|---|---|
| 变化图 | 0=不变,1=变化 | 单分支输出1通道,sigmod二分类 |
| 双时相分割图 | 0=背景,1=建筑(每个时相一张) | 双分支分别加分类头,后期比较 |
| 特征差异图 | 连续值,如NDVI差等 | 仅作为辅助输入,不是直接标签 |
如果你走“先分割后差分”路线,需要的是双时相分割图,变化图反而要在后处理里生成。如果你走“孪生差分”路线,标签是变化图。一套源码里如果同时出现这两种标签,可能是因为它用了“伪标签”做预训练。
给变化图生成二值掩膜时,有一个容易忽略的操作:建筑边缘的标签抖动会导致模型训练时同一像素在不同epoch输出概率剧烈波动。建议在生成标签时做一次3x3最大池化,把变化区域边缘向外扩1像素,让模型不那么敏感于边界标注误差。
3.3 数据增强的坑:不能翻转一致性破坏变化语义
一般语义分割用水平翻转、旋转90度、颜色抖动都能提升泛化。但变化检测必须保证“双时相影像和标签”三者在几何变换上完全一致。比如你对第一期做了水平翻转,第二期也必须同样翻转,否则变化目标的位置就错位了。用albumentations的DualTransform可以有效避免这种错误。
import albumentations as A import numpy as np # 双时相增强,保证t1和t2经历完全相同的几何变换 aug = A.Compose([ A.HorizontalFlip(p=0.5), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, p=0.5), ], additional_targets={'image2': 'image'}) def augment_pair(t1, t2, label): # label是变化标签,和t1/t2一起变换 data = aug(image=t1, image2=t2, mask=label) return data['image'], data['image2'], data['mask']这里使用additional_targets把第二个时相纳入同一个增强管线,保证旋转和缩放参数一致。还有一个细节:不能对两期影像分别做不同的颜色抖动,因为光照变化本身是模型需要“忽略”的干扰,如果你在增强时把第一期调亮、第二期调暗,模型就会学到亮度差异等于变化,这是很多人验证集指标高但真实场景翻车的根源。颜色增强只能同时作用于两张图,且强度要低。
4. 模型训练与损失函数:从源码看懂迭代细节
4.1 最小可跑通的训练循环:双时相输入如何喂给网络
设计源码时最核心的问题是想清楚输入通道。如果走孪生结构,一个简单做法是把两期影像直接拼成6通道输入,第一个卷积层就变成6通道。但这样会丢失预训练权重(预训练ResNet是3通道)。常见方案还是用一个共享的encoder,分别处理两个3通道输入,再在特征层面融合。下面是最小训练循环的骨架。
import torch import torch.nn.functional as F from model.deeplab_resnet import load_deeplab_resnet model = load_deeplab_resnet(pretrained=True).cuda() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) for epoch in range(30): for t1, t2, change_mask in dataloader: t1, t2 = t1.cuda(), t2.cuda() mask = change_mask.cuda() # 分别经过共享骨干,得到两个分割概率 pred1, feat1 = model(t1, return_feat=True) pred2, feat2 = model(t2, return_feat=True) # 特征差分 -> 变化概率,这里简单用L1距离 diff = torch.abs(feat1 - feat2) pred_change = model.change_head(diff) # change_head是1x1卷积 loss = F.binary_cross_entropy_with_logits(pred_change, mask) optimizer.zero_grad() loss.backward() optimizer.step()这段代码里的model(t1, return_feat=True)是从源码里常见的接口,它同时返回分割概率和ASPP输出特征。变化头是一个1x1卷积,把拼接或差值的特征投影成单通道。要注意的是:这里训练的梯度同时流经两个时相的encoder,所以一个batch里两张影像必须严格配好对。如果dataloader不小心把两个时相的顺序打乱,模型会瞬间学崩。我记得第一次跑时只是把shuffle设为True,一个epoch后loss直接变成NaN。
4.2 损失函数选择:Dice Loss与Focal Loss的组合拳
变化检测里“变化”像素往往只占整张图的2%到5%,极度不平衡。只使用BCE loss,模型会很快学会把所有像素预测成“不变”,因为那样损失也很低。源码里常见的补救是组合损失:
def dice_loss(pred, target): pred = torch.sigmoid(pred) smooth = 1.0 intersection = (pred * target).sum() dice = (2.0 * intersection + smooth) / (pred.sum() + target.sum() + smooth) return 1.0 - dice def focal_loss(pred, target, alpha=0.75, gamma=2.0): prob = torch.sigmoid(pred) pt = prob * target + (1 - prob) * (1 - target) focal_weight = (1 - pt) ** gamma return F.binary_cross_entropy_with_logits(pred, target, weight=focal_weight * alpha)dice_loss对前景与背景不敏感,能强制模型把变化区域讲出来;focal_loss通过gamma降低易分样本的权重,让模型更专注那些被误分类的像素。我用的时候一般把两个损失按 0.5:0.5 相加。如果发现模型只出小碎块,就调高dice权重到0.7;如果变化区域太保守、边界太窄,就调高focal权重到0.7。这两个损失写进设计源码里后,收敛速度会明显变稳。
需要提醒的是,weight参数里的alpha不是所有源码都默认存在。如果你拿到的源码里只有一个固定的weight=torch.tensor([0.2, 0.8]),那是给单时相分割用的,不是给变化检测用的。变化检测里的“正样本”是变化区域,而不是建筑区域,搞混了会出大问题。
4.3 训练参数:学习率、批次、冻结骨干的节奏
基于Deeplab-resnet的源码,训练参数有自己的一套默认习惯。下表是我反复调参后觉得最容易复现的参数组合:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 256x256 | 显存有限时优先用256,512需要多卡 |
| batch size | 8 | 双时相等于一次前向要处理16张3通道图 |
| 学习率 | 1e-4 | 预训练骨干用1e-4,随机初始化的change_head用1e-3 |
| 骨干冻结 | 前10个epoch冻结 | 冻结后change_head先收敛,防止用力过猛 |
| BN状态 | 冻结骨干时同时冻结BN | BN统计量会因小batch而抖动 |
| 优化器 | Adam | 比SGD稳定,配合poly学习率衰减更好 |
前期冻结骨干并非必须,但很有效。常见源码里会在model.requires_grad上做手脚,比如遍历所有参数,param.requires_grad = False for backbone_only。这么做的好处是,当你的数据集只有几百对影像时,先让变化头学到“特征差值的模式”,再解冻骨干做微调,比整个网络一起训更容易避免灾难性遗忘。
我一般会写一个pytorch的学习率自动调整:
from torch.optim.lr_scheduler import LambdaLR def poly_lr(epoch, max_epoch=30, power=0.9): return (1 - epoch / max_epoch) ** power scheduler = LambdaLR(optimizer, lr_lambda=poly_lr)poly策略在Deeplab系列源码里几乎成了标配,因为它比step下降更平滑,能让模型在最后几个epoch也不至于冲过头。
5. 变化检测的坑与排查:从“完全不收敛”到“预测全是白墙”
5.1 现象:模型只学会了分割当前时相,不会比变化
你把训练曲线打印出来,发现单时相分割的Dice已经0.9了,但变化检测的输出基本是空的。原因很常见:训练时用的标签是变化掩膜,而网络的主干仍然在学“当前建筑在哪”,变化头接收的差值特征没有有效梯度。另外,如果你把两个时相分割结果直接做差作为输出,那训练损失根本算不到分割结果上,梯度无法回传。
解决:改走孪生特征差分,像第4章训练循环里那样,把feat1和feat2的差值喂给change_head。核心是让变化头和骨干共享底层特征,但变化头自己有独立的参数。如果源码里没有change_head,你现在最省事的做法是加一个简单的两个1x1卷积加ReLU的分支,输入是差值特征,输出变化概率。这样至少网络能学到“哪里有差”。
5.2 现象:预测结果出现棋盘格噪声
棋盘格一般出现在物体边缘,往往是因为Decoder层用了转置卷积,且学习率太高。DeeplabV3+里Decoder那层如果直接拼上layer2的低级特征,低级特征分辨率和高级特征分辨率不一致,处理不好就会互相干扰。我遇到过有源码把低级特征直接interpolate到1/4分辨率,而高级特征只有1/16,叠加时把噪声放大了。
解决:把低级特征先经过一个1x1卷积降通道到48或64,和上采样后的高级特征concat之前,用align_corners=True的F.interpolate把空间尺寸对齐。训练时用weight decay可以抑制高频噪声。如果还是不行,把ASPP里rate=18的branch去掉,很多小数据集上大的空洞率反而会产生周期性伪影。
5.3 现象:验证集指标高但实际效果差
这是变化检测最常见的“虚假高指标”,根源几乎都是数据泄漏。比如训练和验证的切块来自同一张原始影像,重叠的块让模型“见过”了验证区域。或者两期影像中有同一个建筑,虽然切块不同,但纹理信息太相似,模型记住了并不具备泛化性。
解决:切块时先把矢量地块划分成训练/验证集合,确保一栋建筑的所有像素只出现在一个集合里;或者直接按地理坐标做空间划块,比如用影像左上角坐标的行列号分块,训练集和验证集之间留出至少256像素的缓冲区。验证指标只看变化检测的IoU,不要过分关注像素准确率,因为太容易被“不变类”拉高。
5.4 现象:显存溢出与训练速度慢
双时相输入让模型显存开销基本翻倍。我在8G显存的卡上跑512x512输入、batch=4,直接OOM。原因是ASPP里的全局平均池化和多个空洞卷积并行分支,每个分支都保留一个中间特征。再加上差分特征计算,反向传播时梯度存储翻倍。
解决:优先用梯度累积。把batch=1累积8次再更新参数,效果几乎等同batch=8。再就是输入尺寸从512降至256,对建筑检测这类中尺度目标完全够用。如果你源码里有aux_loss,训练时会额外算一个浅层辅助分割头,同样占用显存,请果断关闭。最后一个技巧是使用混合精度训练,AMP可以让显存占用下降约一半。
5.5 现象:源码默认参数直接跑崩
很多设计源码自带一套“作者环境”里的参数,比如num_classes=21、mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225]。这些默认值直接放到遥感影像上,通常情况是loss跑飞或输出全黑。遥感影像的均值不是ImageNet那个值,尤其多光谱影像有红外通道,归一化参数完全不同。
解决:取你自己的训练集所有像素,提前算一次全局均值和标准差,写死在数据加载器里。
# 在Dataset初始化时算一遍,缓存到本地 mean = np.mean(all_t1_train, axis=(0, 2, 3)) std = np.std(all_t1_train, axis=(0, 2, 3))注意两期影像的均值和标准差要分别计算,但一般相差不大。如果两期影像来自不同传感器,最安全的做法是分别归一化到[0,1]区间,再减去各自均值。很多源码的normalize是放在transform里的,跑崩时先检查这里。
6. 进阶技巧:用测试时增强和二值化阈值把你的变化图再提一个点
6.1 TTA的推理代码
模型训练结束后,用一个简单测试时增强(TTA)就能稳定提升变化检测的IoU。做法是把输入和水平翻转、垂直翻转后的图都送进网络,再把概率取平均。因为Deeplab的预测结果对翻转比较敏感,平均化能消除边缘上的抖动。
def predict_tta(model, t1, t2): model.eval() with torch.no_grad(): p1 = torch.sigmoid(model.change_pred(t1, t2)) p2 = torch.sigmoid(model.change_pred(torch.flip(t1, [2]), torch.flip(t2, [2]))) p3 = torch.sigmoid(model.change_pred(torch.flip(t1, [3]), torch.flip(t2, [3]))) p = (p1 + torch.flip(p2, [2]) + torch.flip(p3, [3])) / 3.0 return pTTA推理时唯一要注意的是要把翻转后的概率再翻回来对齐,否则求平均等于把不同位置的像素搅在一起。推理速度会慢三倍,但如果你的应用不是实时,这很值得。
6.2 阈值选择与后处理:让掩膜不再像雪花
Deeplab输出的概率图上,变化区域往往有很多孤立的小亮点。直接取0.5阈值会产生大量假阳性的碎斑。我通常的做法是在验证集上跑一遍不同阈值,选取IoU最大那个,而不是固定0.5。
后处理用连通域滤波,小于一定面积的区域直接删掉,因为一栋建筑最少也有几百个像素。
from skimage import measure, morphology prob = predict_tta(model, t1, t2).squeeze().cpu().numpy() mask = prob > best_threshold mask = morphology.remove_small_objects(mask, min_size=200, connectivity=2) # 再用闭运算补齐建筑物边缘的空洞 mask = morphology.binary_closing(mask, morphology.disk(2))remove_small_objects的min_size要根据你影像分辨率来定。如果一栋建筑只有50平方米,而一个像素是1米,那min_size设为50以上就行。调这个参数时,应该拿你验证集里“最难的小建筑”作为基准,不要为了去噪把真建筑也删了。
这一套做完,你的变化检测结果基本能达到让人看得过去的水平。我个人的习惯是每次改完一个参数,先在一张图上做预测,肉眼检查一下边缘是否连续、碎斑是否还是多。如果碎斑多,先调后处理,再决定要不要重新训练。模型在变化检测里不是万能的,很多看起来玄学的问题,最后都出在数据配准或标签不一致上。希望这些踩坑记录能帮你少走一两天的弯路,也祝你的源码改造一次跑通。
本文还有配套的精品资源,点击获取