遥感建筑物提取与变化检测:从数据到矢量化落地的工程实践
1. 建筑物提取到底在解什么题——任务定义与数据准备的关键取舍刚接手模拟项目X时我的第一反应是遥感建筑物提取早就被深度学习刷了很多年开源代码一抓一大把跑个U-Net还不简单真正上手之后才发现把测试集上的IoU数字变成甲方能直接叠加到GIS里的干净矢量图斑中间隔着无数细节。这篇博客不聊那种“论文榜上又刷了多少个点”的炫技只讲这个项目从数据、模型、训练、变化检测到后处理落地全流程里那些真正值得你花时间的地方。这个任务适合谁参考一类是做遥感应用、城市规划信息化、GIS开发的人想把深度学习工具用在自己辖区或业务范围内另一类是刚接触遥感图像分割的研究生想把公开代码改造成自己能跑通、能复现的完整工程。整篇文章会围绕“建筑物提取”和“变化检测”两条主线展开这是标题里最关键的两个能力指标也是实战项目里最容易被低估难度的部分。1.1 先想清楚你需要的到底是哪种“提取”很多人一上来就扎进模型对比但第一步应该想清楚产品形态。甲方说“我要建筑物提取”实际可能是三种需求只要建筑物像素掩膜用来做统计或质检例如计算某区域房屋覆盖率。要建筑物轮廓矢量能直接落到地形图或不动产底图里。要分栋的独立建筑单元每一栋作为单独对象入库这种其实是实例分割或检测轮廓修正工作量和难度会明显上两个台阶。模拟项目X的需求是第2种需要把掩膜转化成可用的矢量面。这意味着我对算法输出的要求不只是“像素分对”还要边缘干净、小型建筑不碎、大建筑内部无孔洞。这些问题不是靠换更强模型能解决的而是要靠数据组织、后处理流程来补齐。所以在这类项目里我通常建议先用单阶段的语义分割解决90%的问题不要一上来就上Mask R-CNN这类实例分割框架训练成本、标注成本、调参成本都会显著上升而在高分影像上语义分割的精度其实已经相当够用了。1.2 数据准备矢量标注转栅格时最容易埋雷遥感建筑物分割的标注来源绝大多数情况下是已有的地籍矢量数据或者人工画的多边形。把矢量转成栅格label听起来一行代码的事但实际操作里有一个很致命的问题矢量边界和影像地物的真实边界经常不对齐。原因是多源数据坐标系转换或者人工勾绘误差导致的1到2像素偏移。如果只按照“框框内全部填1”来生成mask模型会把边缘一圈的背景比如阴影、屋顶边缘的植被当成建筑物的一部分学进去最后预测出的轮廓永远鼓出来一圈。我在这个项目里踩过这个坑之后生成训练mask时会刻意做一个边缘收缩处理把多边形向内侧腐蚀1到2个像素再写入栅格。表面上看这会轻微降低训练集本身的分割精度但模型预测出来的轮廓反而更贴合真实屋顶边缘因为它在训练时学到的边界就是“略偏内侧”的不会把周边背景包进来。另一个经常被忽略的问题是数据集划分方式。做常规图像分类时随机切分还好但遥感影像里同一景影像内部、同一街区的建筑风格和光照非常一致随机打乱像素会让验证集虚高。更合理的切法是先对整个覆盖区划分成多个独立子区每个子区要么全在训练集要么全在验证集模型学到的才是跨区域泛化能力而不是“见过这个角度的太阳”。1.3 切片要保留坐标信息别把地理信息切丢了高分影像动辄几万乘几万像素不可能整幅塞进GPU。标准做法是滑窗切片训练但切完以后大多数人是直接存成jpg/png就跑了等到推理完成再想拼回去、叠加到GIS里发现坐标全丢了。我的做法是用rasterio读取影像切片时保留每个patch的窗口在原影像中的偏移量row_offset, col_offset以及原影像的地理仿射变换参数。这样推理完成后我只要把每个patch的预测结果按原坐标位置写回一整幅掩膜tif成果就能直接落到ArcGIS或QGIS里叠加查看不用额外做配准。这个细节很基础但在实际协作时特别容易翻车——建议一开始就把坐标信息作为切片的默认属性保留下来。import rasterio from rasterio.windows import Window with rasterio.open(input.tif) as src: height, width src.height, src.width patch_size 256 stride 128 # 重叠 50% rows range(0, height - patch_size, stride) cols range(0, width - patch_size, stride) for i, row in enumerate(rows): for j, col in enumerate(cols): window Window(col, row, patch_size, patch_size) patch src.read([1,2,3], windowwindow) transform src.window_transform(window) # 保存 patch 以及对应的 transform、row、col滑窗重叠率我推荐至少50%。如果完全不重叠推理时模型在patch边缘的感受野不完整拼出来会有一圈圈拼接缝。重叠后对每个像素取平均值再阈值化能有效抹掉大部分拼接痕迹这个我在后处理部分还会细讲。2. 模型选型不是越深越好实际训练中稳定度优先的搭配方案模型结构是整个项目里大家最热衷讨论的部分但我的结论可能和一些论文推荐不太一样。对这个任务来说模型选型的第一原则不是刷榜而是训练稳定、显存可控、推理链路简单可解释。2.1 语义分割我为什么还是选了U-NetU-Net这套编码器-解码器结构虽然老但放在高分遥感建筑物分割上非常合适。它最大的优点是跳层连接能把编码器早期的空间细节直接带到解码器让边界信息不容易丢失。建筑物在高分辨率影像里最值钱的就是边缘一旦边缘糊了后续矢量化出来的多边形质量就会大幅下降。DeepLabV3的ASPP模块在多尺度上下文上有优势但它整个结构对显存的占用更高而且在这个任务里带来精度提升通常是有限的——尤其是建筑物这种只有前景和背景两类、且尺度相对固定的目标多尺度语义信息的增益并不明显。另一个很现实的因素是后处理。U-Net输出的特征是相对连续的在边缘处的置信度分布比较平滑这对我后续做连通域分析、矢量化非常友好。而有些分割网络为了追求“类别区分度”会把输出推得很极端虽然像素精度可能看着更好但边界锯齿严重反而给矢量化增加麻烦。2.2 骨干网络替换和多光谱输入的处理U-Net原版编码器的卷积堆叠能力偏弱我通常会把主干换成一个预训练好的分类网络比如ResNet34或ResNet50。高分影像的建筑纹理信息比较丰富用ImageNet预训练的backbone能显著加快收敛速度。关键步骤是当输入包含近红外波段时第一层卷积的输入通道数不再是RGB的3而是4。修改方式很简单把第一个卷积层从nn.Conv2d(3, 64, ...)改成nn.Conv2d(4, 64, ...)但这样预训练权重就用不上了因为输入通道不匹配。一种折中的做法是用预训练RGB权重初始化前3个通道第4个通道用这三通道权重的均值来初始化。实测这个做法在前期收敛速度上比随机初始化要快很多最终精度能保持让近红外通道的信息自然融合。conv1 nn.Conv2d(4, 64, kernel_size7, stride2, padding3, biasFalse) with torch.no_grad(): # 用预训练的前3通道权重复制给第4通道 conv1.weight[:, :3] pretrained_conv1_weight conv1.weight[:, 3] pretrained_conv1_weight.mean(dim1)需要说明的是近红外波段并不是所有数据集都有的。但如果你的影像提供了NIR强烈建议用上——建筑物和植被在近红外波段的反差非常大这一波段的加入能在屋顶边缘已经被植被遮一半的场景里救回很多边界像素。2.3 训练策略不是拼GPU而是拼这些默认配置我在模拟项目X里用单张消费级GPU完成全部训练。关键超参数如下优化器AdamW初始学习率1e-3权重衰减1e-4调度器CosineAnnealingLR从1e-3衰减到1e-5影像块尺寸256×256可视显存情况调整Batch size8训练轮数100轮左右配合EarlyStopping训练到中后期时SGD加Momentum其实也能跑得很好但AdamW在前50轮的稳定性更出色。我的经验是如果Dice Loss和交叉熵联合使用时训练曲线震荡得厉害优先把学习率降下来而不是去换模型结构。很多开源代码默认给1e-4但我实测1e-3配合CosineAnnealing在遥感分割上收敛更快且不会发散原因是我们的输入是固定的高分影像不是大规模语义分布漂移的自然图像问题本身的loss landscape没那么崎岖。训练时养成一个好习惯每5轮保存一次权重不要只留最后一个。我有一次训练到第80轮时验证集指标轻微过拟合了回滚到第60轮的权重后处理效果反而更好。模型权重这种东西不是越靠后越好的保存多个checkpoint让你有后悔药可吃。3. 最容易拖垮训练效果的三个细节切片、增强与损失函数这一节我想集中讲三个“看起来不起眼、实际影响非常大”的细节。它们不在任何模型的forward代码里但决定了整个训练流程能不能收敛、泛化能力强不强。3.1 切片重叠率不够预测拼缝会毁掉整幅图前面提到过切片的stride设置。训练阶段patch是随机采样还是滑窗重叠采样其实影响不大毕竟每次迭代都是随机取的真正影响大的是推理阶段的切片方式。如果推理切片用stride256完全不重叠输出拼接后会出现很明显的棋盘格状拼缝原因是模型在每个patch边缘的预测置信度天然偏低就像人类看东西时余光区域比较模糊一样。要解决这个推理时应该与训练时保持相同的patch大小但stride要小于patch让相邻patch之间有重叠区域。每个像素会出现在多个patch里对多次预测结果取平均作为最终置信度。这个操作看起来只是工程小技巧但它能让最终mask的边缘连续性显著提升。我这里给一个很实用的实现思路预先分配一个二维累加array和一个count array每张patch预测完sigmoid之后按坐标位置累加进去最后相除得到每个像素的平均置信度。3.2 数据增强别只翻转旋转色彩扰动同样关键遥感影像分割的增强策略几乎每个开源repo里都有随机翻转和旋转但很多repo遗漏了色彩类的增强。高分遥感影像的来源多变不同传感器、不同季节、不同太阳高度角拍出来的同一栋楼色彩差异可以非常大。如果训练集来自夏季影像冬季影像的对比度、色调都会偏移不少。我在增强流程里除了基础的翻转和旋转还额外加入了随机亮度、对比度、饱和度和少量HSV扰动。使用albumentations库实现时用Compose把图像增强和目标mask增强绑定在一起这样不需要手动维护“图和mask同步变换”的逻辑。这里有一个容易被忽略的边界情况色彩增强的强度不能拉太满否则建筑物屋顶的材质颜色会被扭曲得过猛模型会过度关注纹理细节而忽略结构特征。一般亮度±0.2、对比度±0.2就足够了。3.3 损失函数组合Dice Loss不是救命稻草建筑物分割的类别极度不平衡背景面积在大多数切片中远大于建筑前景面积只用交叉熵的话模型可能陷入“全部预测为背景”的局部最优里。因此主流做法是加上Dice Loss来强调前景区域的重合度。但Dice Loss有个反直觉的副作用它的梯度在小目标上极其陡峭训练初期会让loss剧烈跳动。我习惯的组合是0.5×交叉熵 0.5×Dice Loss这样在早期训练阶段交叉熵能让分类快速收敛到一个合理的稳定水平Dice则负责把前景区域拉准。两个loss各自归一化到同数量级后再加权系数并不是非0.5不可但0.5是实测最稳的取值。def combined_loss(pred, target): bce F.binary_cross_entropy_with_logits(pred, target) dice dice_loss(torch.sigmoid(pred), target) return 0.5 * bce 0.5 * dice这里还有一个血泪教训mask标签必须保证是float类型且取值严格落在[0,1]区间。如果误把某类边缘值写成了255BCE会瞬间爆炸到几百然后loss曲线就再也救不回来了。建议每次训练前都打印一下batch里target的数值范围这个检查只需要一行代码但能省一整天的排查时间。4. 变化检测的两条技术路线与落地选择如果说建筑物提取解决的是“哪里是房子”变化检测要回答的就是“哪里多了房子、哪里少了房子”。这个话题网上讨论很多但真正工程化的路线其实就两条。4.1 线路一先分割后变化两期结果做差值最直接的做法是分别用训练好的分割模型对T1期和T2期影像做推理得到两幅建筑物mask然后逐像素做差值。差值图中从0变成1的就是“新增建筑”从1变成0的就是“拆除建筑”。这个路线最大的好处是简单、可控、每条中间产物都可解释。模型出了错你一眼就能看出是第一期分割错了还是第二期分割错了方便定位。缺点是它对两期影像的“分割一致性”要求很高——如果两期影像光照条件差异大同一个建筑在第一期被完整分割了第二期却因为阴影影响漏掉一半这个区域就会被误判成“拆除”产生大量假变化。缓解办法是在差值比较之前先对两期mask做一次中值滤波或形态学闭运算把零星的漏检补回来。另外可以设置一个最小变化面积阈值比如小于20平方米的差异区块直接忽略。城市环境的配准误差、阴影变化造成的零碎差异通常面积很小面积过滤能去掉绝大部分噪声。4.2 线路二孪生网络直接学习“变化样本”另一条路线是端到端的变化检测网络。把两期影像成对输入一个双分支编码器网络网络输出直接就是“是否变化”的掩膜。这种方法在学术论文里的指标通常更好看因为它专门为变化检测任务调优了结构。典型实现是把两期影像的特征提取后通过一个差分模块比如求差、求绝对值或concatenate融合再接解码器输出变化图。但这条路线的工程代价要比看起来大得多。它需要“成对标注”训练数据——同一地点、两期影像、标注出哪些区域发生了变化这种样本的获取和标注成本不是一般项目承受得起的。而且两期影像必须经过严格的配准和匀色预处理否则网络会把光照、季节差异当成“变化”学进去。class SiameseChangeDetector(nn.Module): def __init__(self, encoder): super().__init__() self.encoder encoder # 共享编码器 self.decoder UNetDecoder() self.output_head nn.Sequential( nn.Conv2d(64, 32, 3, padding1), nn.ReLU(), nn.Conv2d(32, 1, 1) ) def forward(self, x1, x2): f1 self.encoder(x1) f2 self.encoder(x2) diff torch.abs(f1 - f2) # 差分特征 out self.decoder(diff) return self.output_head(out)变化检测的网络结构本身不复杂真正的门槛在数据预处理。以T1和T2为例如果两期影像不是同一传感器、同一大气条件下拍摄的直接喂进去网络会学到一堆噪声特征。一般做法是先做直方图匹配让两期影像的整体色调接近再做像素级配准。4.3 我给实际项目的配置建议如果是第一次做这个课题我的建议很明确优先做线路一也就是“先分割后差分”。原因是绝大多数业务变化检测场景并不需要区分新增和拆除的具体类型只需要提供一个候选变化区域再由人工复核。你把两期mask叠加之后用颜色区分“新增/拆除/不变”人工复核的效率已经非常高了。只有当业务要求全自动输出变化类型而且成对标注数据足够充分时才值得去啃孪生网络那条线。5. 后处理与精度评估从测试指标走向真正能用的结果分割模型的原始输出是一张float置信度图离“能用的成果”还差着十万八千里。后处理这一节的内容在论文里很少被提及但实际占比非常高我认为至少占整个项目工作量的一半。5.1 置信度图到干净矢量图斑的处理链条我的标准后处理流程是重叠推理取平均置信度。用验证集搜索出的最佳阈值二值化例如0.45不要死守0.5。用形态学闭运算例如3×3核把细小空洞、锯齿缺口填平。用连通域分析剔除面积小于阈值的小块噪声。用cv2.findContours提取轮廓再用shapely简化多边形输出成shapefile。这里面的关键是阈值搜索。很多人直接用0.5但其实最佳阈值往往不等于0.5。在模拟项目X的验证集上我把阈值从0.3到0.7每隔0.02扫描了一遍发现0.46时IoU最高。这一招没什么技术含量却能提升大约1到2个百分点的IoU。连通域剔除小块的代码是这样的from skimage import measure import numpy as np def remove_small_areas(mask, min_area50): label_map, num measure.label(mask, background0, return_numTrue) out np.zeros_like(mask) for idx in range(1, num 1): area (label_map idx).sum() if area min_area: out[label_map idx] 1 return out需要特别说明的是面积阈值到底取多少取决于影像分辨率。假设影像分辨率是0.5米/像素一个20平方米的临时棚户约等于80个像素如果分辨率变成1米/像素就只剩20个像素。这个阈值应该根据业务里的最小建筑单元来确定而不是拍脑袋。5.2 评估指标IoU、F1、数量召回率各管一件事遥感建筑物分割的评估指标各说各话我见过很多甲方汇报材料只写一个“总体精度99%”这个数字完全没有意义因为背景占了绝大多数像素全猜背景也能有很高的像素精度。真正值得看的是这几个指标指标关注点适用场景IoU预测mask和真实mask的重叠占比衡量像素级重合度的最核心指标F1精确率和召回率的调和平均关注漏检和误检的均衡性建筑数量召回率检测出真实建筑数量的比例对独立小建筑比较敏感边界F1边界像素的匹配程度关注轮廓质量用于矢量化我在评估时同时记录上述四类指标。IoU能反映整体的分割质量但有一个盲区如果模型把一栋大建筑漏了一半IoU可能只掉几个点从建筑数量角度来看却是完全漏检了一栋楼。对业务而言一栋楼没了远比半栋楼歪了严重。所以我还会额外统计独立建筑的数量召回率做法是把预测连通域与真实建筑mask做交集凡是与某些真实建筑完全没有交集的就视为漏检。5.3 泛化能力检验换一块区域测一测评估阶段有个很容易自欺欺人的做法验证集和训练集来自于同一批影像的随机拆分这样测出来的指标根本不能代表模型对新地区的能力。我通常会在项目收尾时专门留一块完全没有参与过训练的区域做“盲测”。在盲测集上模型的指标往往掉5到10个点这是正常现象——不同地区的建筑风格、屋顶颜色、周围地物差异都很不一样。如果你也在意盲测指标有几个补救办法一是准备少量新区域标注做模型微调只需要几百张patch就能把指标拉回来不少二是在推理阶段用OOD检测思路对低置信度区域打标记让AI只负责高置信度区域其余部分交给人工复核。真实业务没有“一个模型通吃所有城市”的童话但“AI初筛人工复核”的组合拳完全够用。6. 代码仓库结构与复现建议少走两个月的弯路最后这部分给那些准备动手复现的人一个完整的代码结构和踩坑清单让结构设计直接从我的经验里起飞。6.1 推荐的项目组织方式我见过太多人的代码只有一个train.py几百行全塞在一起改一个参数要找半天。遥感分割项目至少要分成数据、模型、训练、推理、后处理和工具六个模块这样后续换数据、换模型、调整后处理时都只需要动对应模块。project/ ├── data/ │ ├── raw/ # 原始遥感影像 │ ├── patch/ # 切片后的训练样本 │ └── label/ # 对应的mask ├── models/ │ ├── unet.py # 分割模型 │ ├── siamese.py # 变化检测模型 │ └── losses.py # 损失函数 ├── tools/ │ ├── tif2patch.py # 切片工具 │ ├── make_mask.py # 矢量转栅格 │ └── vectorize.py # 掩膜转矢量 ├── train.py # 训练入口 └── infer_stitch.py # 滑窗推理拼接这种结构在项目跟进时非常友好。新同学来了接手的不是一段代码而是一条生产线数据在哪、模型是哪个文件、改哪里不影响别的模块一目了然。6.2 推理拼接的核心逻辑推理拼接是整个repo里另一个容易翻车的地方我直接把关键代码逻辑写出来def infer_stitch(model, src_path, output_path, patch_size256, stride128, threshold0.45): # 初始化累加器 height, width src.height, src.width acc np.zeros((height, width), dtypenp.float32) count np.zeros((height, width), dtypenp.float32) for row in range(0, height - patch_size, stride): for col in range(0, width - patch_size, stride): patch src.read([1,2,3], windowWindow(col, row, patch_size, patch_size)) prob model(patch) # 输出0-1之间的置信度 acc[row:rowpatch_size, col:colpatch_size] prob count[row:rowpatch_size, col:colpatch_size] 1 avg acc / np.maximum(count, 1) mask (avg threshold).astype(np.uint8) # 用rasterio将mask写成tif或shp要注意最后一圈不足patch_size的位置如果影像边缘不够一个整patch要么做padding要么记录实际有效区域再做裁剪。我习惯用rasterio的Window读取时让它自然返回实际可用区域然后单独处理边缘避免推理结果在边界处失真。6.3 复现模拟项目X时最值的三个发现整个项目跑下来有几个发现我觉得比任何模型调参都值钱分享在这里。第一模型预测的置信度本身可以用来辅助后处理。我的做法是输出两层结果0.45阈值以上的作为肯定建筑区0.3到0.45之间的作为“待人工复核区”这个中间地带往往正好对应阴影遮挡或屋顶结构复杂的区域非常符合实际业务需要。第二测试集上的高IoU并不可靠要看你最终生成的矢量面积和已知土地统计数字对不对得上。我把模型的输出与当地统计年鉴里的建筑用地面积做了一次总量对比发现整体偏差在合理范围内这反而比像素指标的沟通效果更好。第三训练集里加入一定比例的“困难负样本”能明显提升模型稳定性。我在数据准备时故意保留了一批看起来像房顶但其实不是建筑的区域比如篮球场、大型广告牌、白色广场把这些补进数据里模型误报率下降很可观。最后再分享一个小经验如果你打算把这份代码用在别的城市、别的传感器上不要急着全部重训。先拿少量新数据跑一个快速实验确认数据格式、mask生成流程、推理拼接链路都通顺再去花算力调参。数据链路的坑几乎都集中在第一步这一步理顺了剩下的就是迭代次数的问题。