资讯详情

基于UNet的双时相遥感影像新增建筑物检测实践指南

📅 2026/9/15 3:40:13 | 华诺云谱 👁 阅读
基于UNet的双时相遥感影像新增建筑物检测实践指南
简介面向计算机视觉与遥感应用学习者这份航拍图像新增建筑物检测项目基于UNet实现了端到端的卫星图像分割可服务于城市规划、建设监管与灾害应急响应。资源包共30个文件包含13个Python脚本覆盖数据预处理、模型训练、预测推理、后处理去噪等完整流程12张示意图直观展示UNet网络结构、损失曲线、数据增强及标注效果另有2个JSON配置与标注文件、1个Jupyter示例、1份README说明压缩包仅7.21MB目录组织清晰便于快速定位与复现。项目针对小目标分割中的类别不平衡问题在Dice损失、数据增强、形态学后处理等环节给出了具体实现并提供了从数据准备到模型评估的闭环方案还融入了迁移学习与模型调优思路。目前已有254人学习下载适合希望借助真实源码掌握UNet在遥感建筑分割中应用的研究者与开发者。1. 新增建筑物检测为什么要把 UNet 用在“两张图的差”上卫星图上的新增建筑物检测难点不在“看见房子”而在“看见旧地图上没有的房子”。直接拿单张当期影像训练语义分割模型模型学到的会是“所有楼房的像素分布”它会把已经存在多年的建筑一起标出来而航拍图像检测真正要解决的是两期影像之间新增了哪些建筑属于变化检测与语义分割的交叉任务。工程上最稳的做法是把历史影像和当前影像同时喂给 UNet让编码器抽取两期语义、跳跃连接保留下采样丢失的轮廓信息最后输出一张逐像素的新增概率图。这篇按数据准备、模型训练、大图推理、评估提点的顺序展开适合做遥感信息提取、测绘数据生产和智慧城市项目开发的技术人员。2. 数据先行两期影像对齐、样本构造与 UNet 训练数据组织项目里最容易引发返工的不是模型选型而是两期影像没有对齐。新增建筑检测需要“两期影像变化位置”的监督信号如果 T1 和 T2 之间存在 2 到 3 个像素的平移UNet 会把路边停车、树冠摆动、阴影位移都当成变化训练信号里全是噪声。把 UNet 训练自己的数据集这道题拆开看我会先花一半时间处理四个问题输入通道怎么设计、两期影像是否严格配准、掩膜怎么从矢量转栅格、样本怎么划分才不会泄漏。2.1 双时相输入通道构造先把模型要学什么定清楚最常见的做法是把 T1 和 T2 的 RGB 各 3 个通道沿通道维拼接得到 6 通道输入。模型在 encoder 第一个卷积层就能同时看到两期影像至于“哪里变了”完全交给网络自己学。这个方案对两期影像的光照差异不敏感因为模型可以从颜色、纹理、上下文综合判断而不是依赖像素差值。另一种做法是构造 4 通道输入即当期 3 通道加一个差值通道T2 - T1的灰度。差值通道把变化位置显式摆到模型面前收敛通常更快但它对两期影像的辐射一致性要求很高。只要两期影像来自不同传感器、不同季节或不同太阳高度角差值通道里会充满植被物候和阴影造成的伪变化反而干扰 UNet 判断。我一般把 6 通道作为 baseline差值通道留给后续调优时做对照实验。注意这里说的“历史影像”不一定是同一颗卫星拍的。实际项目中常见情况是只有当前期高清影像历史底图来自公开遥感影像或者旧版地图瓦片。用这种混合数据时要先把两期影像重采样到相同分辨率再按地理位置裁剪不能直接拿原始瓦片做像素对齐。2.2 用 GDAL 检查两期影像的像素级对齐两期影像没有地理配准信息时不能直接进入训练。常见做法是先读两个 GeoTiff 的地理变换参数和尺寸计算左上角坐标在像素单位下的偏移量判断是否需要重新配准。from osgeo import gdal def check_alignment(path_t1: str, path_t2: str) - None: ds1, ds2 gdal.Open(path_t1), gdal.Open(path_t2) gt1, gt2 ds1.GetGeoTransform(), ds2.GetGeoTransform() shape1 (ds1.RasterYSize, ds1.RasterXSize) shape2 (ds2.RasterYSize, ds2.RasterXSize) offset_px_x abs(gt1[0] - gt2[0]) / abs(gt1[1]) offset_px_y abs(gt1[3] - gt2[3]) / abs(gt1[5]) print(f影像尺寸: {shape1} vs {shape2}) print(f左上角偏移约: {offset_px_x:.2f} px, {offset_px_y:.2f} px) if offset_px_x 1.0 or offset_px_y 1.0: print(偏移超过 1 像素建议先做配准或手动选取同名点) else: print(对齐状态可用)这段代码的前提是两期影像采用相同投影。gt[0]和gt[3]是左上角的地图坐标gt[1]和gt[5]是单个像素的宽度和高度相减再除以像素尺寸就得到偏移量gt[5]在北半球常用的 UTM 投影里通常是负值所以要取绝对值。偏移超过 1 个像素时我一般用控制点配准或干脆裁掉边缘区域而不是直接喂给网络。2.3 滑窗裁剪与掩膜栅格化整景影像通常有几万乘几万像素显存放不下需要切成瓦片。裁剪的兴趣区域要同时作用于影像和掩膜否则训练时输入和标签对不上。滑窗裁剪时 stride 要小于窗口尺寸保留一定重叠否则目标建筑物恰好落在切缝上时会被截断。import numpy as np def slide_crop(img: np.ndarray, mask: np.ndarray, size: int 512, stride: int 384): h, w img.shape[:2] ys list(range(0, max(1, h - size 1), stride)) xs list(range(0, max(1, w - size 1), stride)) # 补上最后一行/列避免漏掉边缘目标 if ys[-1] size h: ys.append(h - size) if xs[-1] size w: xs.append(w - size) for y in ys: for x in xs: yield img[y:y size, x:x size], \ mask[y:y size, x:x size], (x, y)size512是 UNet 常见的输入尺寸stride384表示相邻瓦片有 128 像素重叠。重叠区域里的目标物体会在多个瓦片中出现训练阶段相当于做了位置增强推理阶段则要用加权拼接来消除重复预测这在第 4 章会说。掩膜数据一般是以 GeoJSON 或 Shapefile 存储的建筑物矢量先把它们转换成与影像完全对齐的栅格再参与裁剪。from osgeo import gdal, ogr def rasterize_mask(geojson_path: str, geotransform, xsize, ysize, out_path): src ogr.Open(geojson_path) lyr src.GetLayer() drv gdal.GetDriverByName(GTiff) dst drv.Create(out_path, xsize, ysize, 1, gdal.GDT_Byte) dst.SetGeoTransform(geotransform) srs lyr.GetSpatialRef() dst.SetProjection(srs.ExportToWkt()) gdal.RasterizeLayer(dst, [1], lyr, burn_values[1]) dst.FlushCache()RasterizeLayer把多边形内部像素烧录为 1背景为 0。这里唯一需要确认的是矢量数据的坐标系必须与 GeoTiff 一致否则烧录结果会整体偏移。栅格化之后的掩膜要和影像一起裁掩膜的尺寸、地理范围和影像严格一致这是后续所有评估的前提。2.4 样本划分与同步增强数据增强时要特别小心T1、T2 和掩膜必须使用同一套变换参数。如果 T1 做了水平翻转而 T2 没有模型会学到“两期影像方位不一致”这个假特征。一套我会直接沿用的增强配置如下随机水平/垂直翻转概率各 0.5。随机旋转 90 度的整数倍避免插值破坏建筑物边缘。对两期影像同时做轻微的色彩抖动幅度控制在 0.05 以内。不使用随机裁剪和缩放因为遥感目标尺度固定过度缩放会让建筑物变成噪声。数据划分有一个容易忽略的坑按瓦片随机划分会造成数据泄漏。同一栋新增建筑的多个相邻瓦片可能同时落在训练集和验证集里验证 IoU 虚高。更稳妥的方式是按地理位置分块划分比如按列分成三段或按行政区边界划分确保验证集里的建筑物在训练时没有见过。3. UNet 模型搭建、损失函数与训练参数选择数据准备完成之后进入模型部分。对新增建筑这类小目标检测我一般不会一上来就换复杂骨干网络而是先把标准 UNet 跑通让它成为后续所有改进的 baseline。UNet 网络结构图里最值得关注的两个设计是四次下采样和跳跃连接这两点恰好对应遥感小目标的痛点感受野和边缘精度。3.1 从 UNet 网络结构图上读出“为什么适合新增建筑”UNet 的编码器把输入从 512 分辨率逐步下采样到 32 分辨率底层特征图的感受野覆盖了整张瓦片的语义范围。新增建筑往往和工地、裸地、停车场混在一起单看局部像素很难判断是不是建筑需要更大的上下文来确定“这里原来没有房子”。但下采样必然损失空间位置精度这正是跳跃连接的用武之地。编码器每一层都保留着不同尺度的边缘和纹理信息解码器上采样时把这些特征直接拼接回来让输出掩膜的轮廓不至于糊成一团。新增建筑的边缘通常锐利屋顶和背景对比度高跳跃连接对这类目标的增益非常明显。这就是为什么不建议在 baseline 阶段直接引入注意力模块或 Transformer 结构。UNet 模型改进可以放在其后比如把编码器替换成预训练的 ResNet34 来加速收敛或在跳跃连接处加 SE block 做通道重标定。但第一次训练时结构越简单定位问题越容易。3.2 最小可运行的 UNet 实现下面给出一个没有花哨技巧的 PyTorch 实现。输入是 6 通道的双时相影像输出是 1 通道的 logit 图训练时配合 sigmoid 计算损失。import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block 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): return self.block(x) class Down(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.pool nn.MaxPool2d(2) self.conv ConvBlock(in_ch, out_ch) def forward(self, x): return self.conv(self.pool(x)) class Up(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.up nn.Upsample(scale_factor2, modebilinear, align_cornersFalse) self.conv ConvBlock(in_ch, out_ch) def forward(self, x, skip): x self.up(x) # 拼接前处理边界尺寸差异 diff_h skip.size(2) - x.size(2) diff_w skip.size(3) - x.size(3) x nn.functional.pad(x, [diff_w // 2, diff_w - diff_w // 2, diff_h // 2, diff_h - diff_h // 2]) return self.conv(torch.cat([x, skip], dim1)) class UNet(nn.Module): def __init__(self, in_channels6, base32): super().__init__() self.inc ConvBlock(in_channels, base) self.down1 Down(base, base * 2) self.down2 Down(base * 2, base * 4) self.down3 Down(base * 4, base * 8) self.bridge Down(base * 8, base * 16) self.up1 Up(base * 16, base * 8) self.up2 Up(base * 8, base * 4) self.up3 Up(base * 4, base * 2) self.up4 Up(base * 2, base) self.outc nn.Conv2d(base, 1, 1) def forward(self, x): s1 self.inc(x) s2 self.down1(s1) s3 self.down2(s2) s4 self.down3(s3) x self.bridge(s4) x self.up1(x, s4) x self.up2(x, s3) x self.up3(x, s2) x self.up4(x, s1) return self.outc(x)base32表示第一个卷积层的输出通道数。输入是 512×512 时这个结构在 11GB 显存上可以跑 batch size 4 到 8。Up模块里先上采样再与跳跃连接拼接cat之后通道数翻倍由ConvBlock完成特征融合。输出层没有加 sigmoid是为了在训练时把二值交叉熵和 sigmoid 合并计算数值上更稳定推理时再单独调用torch.sigmoid。3.3 用 DiceBCE 损失处理新增建筑的样本不均衡新增建筑在整景影像中通常占比不足 1%纯 BCE 损失会被背景像素主导模型倾向于把所有像素预测为背景。常见做法是把 BCE 与 Dice 损失相加Dice 系数直接优化预测与真值的重叠程度对正样本占比低的情况有天然的纠偏作用。import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, smooth1.0): super().__init__() self.smooth smooth def forward(self, pred_logit, mask): pred torch.sigmoid(pred_logit) pred pred.contiguous().view(pred.size(0), -1) mask mask.contiguous().view(mask.size(0), -1) inter (pred * mask).sum(dim1) union pred.sum(dim1) mask.sum(dim1) dice (2.0 * inter self.smooth) / (union self.smooth) bce F.binary_cross_entropy(pred, mask, reductionnone).mean(dim1) return (bce (1.0 - dice)).mean()pred经过 sigmoid 后变成 0 到 1 的概率mask是 0/1 标签。inter是预测与真值的逐像素乘积之和近似交集面积union是两者之和近似并集面积。smooth 参数防止分子分母同时为 0。训练时监控 train loss 和 val IoU如果 train loss 持续下降但 val IoU 停滞优先检查数据划分和增强是否引入泄漏再考虑调整学习率。3.4 训练参数表与实际收敛判断下面这组参数是我在类似遥感分割项目里的默认起点适用于 512×512 输入、单卡训练。参数推荐值说明优化器AdamW权重衰减设为 1e-4比 Adam 泛化略好初始学习率1e-4大于 1e-3 容易发散学习率调度CosineAnnealingLR配合 80 个 epoch 使用Batch Size811GB 显存可跑显存小就降到 4Epoch 数80 到 120以验证集 IoU 是否还有上升为准早停patience 15验证 IoU 连续 15 个 epoch 不更新就停训练时每个 epoch 打印一次 train loss、val loss、val IoU 和 val F1不打印单 batch 的 loss避免被噪声干扰判断。新增建筑场景下val IoU 在 0.5 到 0.7 之间都是可用的模型低于 0.4 时先别调结构回去看训练数据的掩膜和影像有没有对齐这个原因占了我遇到问题的一半以上。4. 大影像滑窗推理、后处理参数与矢量化输出训练完成的 UNet 输出的是瓦片级概率图而业务方要的是叠加在地图上的建筑物图斑。中间隔着三步大影像滑窗推理时的重叠如何消除、概率阈值怎么选、后处理参数如何定。这一节把航拍图像检测最终产出 GeoJSON 的完整链路跑通。4.1 大影像重叠推理与中心加权拼接推理阶段不能简单地把瓦片预测结果贴回原图。瓦片边缘的模型看不到完整上下文预测置信度偏低直接拼接会留下明显的接缝。常见做法是让相邻瓦片有重叠拼接时对每个像素按它到瓦片中心的距离加权越靠近中心权重越高。import numpy as np import torch def _center_weight(size: int) - np.ndarray: d np.abs(np.arange(size) - (size - 1) / 2) d2d np.sqrt(d[None, :] ** 2 d[:, None] ** 2) return np.cos(np.pi * d2d / size) 1.0 def tile_predict(model, img, size512, overlap128, devicecuda): model.eval() h, w img.shape[:2] prob np.zeros((h, w), dtypenp.float32) weight np.zeros((h, w), dtypenp.float32) step size - overlap for y in range(0, max(1, h - size 1), step): for x in range(0, max(1, w - size 1), step): patch img[y:y size, x:x size] tensor torch.from_numpy(patch).permute(2, 0, 1).unsqueeze(0).float().to(device) with torch.no_grad(): p torch.sigmoid(model(tensor)).cpu().numpy()[0, 0] wgt _center_weight(size) prob[y:y size, x:x size] p * wgt weight[y:y size, x:x size] wgt return prob / np.maximum(weight, 1e-6)_center_weight生成一个二维权重矩阵中心约等于 2边缘约等于 1重叠区域靠中心一侧的预测占主导step size - overlap控制瓦片平移距离overlap 通常取 128即窗口的四分之一。最后把累计概率除以累计权重得到无接缝的全图概率矩阵。4.2 概率阈值与形态学后处理参数多数人会把概率阈值默认设成 0.5但新增建筑场景里 0.5 往往偏低或偏高。正确的做法是在验证集上枚举阈值选 F1 最高的那个。阈值越高结果越干净但召回下降阈值越低漏检减少但误报增多。参数范围建议概率阈值0.3 到 0.7 步长 0.05按验证集 F1 最大选取最小图斑面积32 到 128 像素过滤孤立噪点开运算核大小3×3 或 5×5去除零星假阳性闭运算核大小3×3填补屋顶细小空洞UNet 使用时的注意事项里最容易忽略的是预测概率图的数值范围。有些实现把 logit 当成概率直接输出没有调用 sigmoid导致阈值完全失效。推理代码里必须显式执行torch.sigmoid确认输出范围在 0 到 1 之间再进入后处理。4.3 从概率图到 GeoJSON 的业务闭环后处理完成后的二值图还需要转换成带地理坐标的矢量图斑才能进入 GIS 系统做台账更新。我用 OpenCV 提取外轮廓再把像素坐标换算成地图坐标。import cv2 import json from pyproj import Transformer def prob_to_geojson(prob, geotransform, src_crs, threshold0.5, min_area_px64, out_pathnew_buildings.geojson): binary (prob threshold).astype(np.uint8) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) transformer Transformer.from_crs(src_crs, EPSG:4326, always_xyTrue) features [] for c in contours: area_px cv2.contourArea(c) if area_px min_area_px: continue polygon_px c[:, 0, :] polygon_map [ (geotransform[0] px * geotransform[1] py * geotransform[2], geotransform[3] px * geotransform[4] py * geotransform[5]) for px, py in polygon_px ] polygon_wgs84 [transformer.transform(x, y) for x, y in polygon_map] features.append({ type: Feature, geometry: {type: Polygon, coordinates: [polygon_wgs84]}, properties: {area_px: int(area_px)} }) with open(out_path, w, encodingutf-8) as f: json.dump({type: FeatureCollection, features: features}, f, ensure_asciiFalse)像素坐标到地图坐标的换算展开就是X gt[0] px * gt[1] py * gt[2]Y gt[3] px * gt[4] py * gt[5]。src_crs是训练影像自身的投影坐标系通常是 UTM转成 EPSG:4326 经纬度方便在 Leaflet、QGIS 或任意地图服务里展示。属性里带上像素面积乘上分辨率平方后可以换算成平方米。5. 像素级与图斑级评估一个复现成本低、返工少的验证思路训练和推理链路跑通后下一步是评估。新增建筑场景下只看像素级 IoU 会得到过于乐观或过于悲观的结论IoU 对边缘像素敏感而业务方真正关心的是“有没有把这栋新增楼整体圈出来”。我会在像素级指标之外补充一层图斑级匹配评估。5.1 图斑级匹配把预测多边形和真值多边形逐一配对实现思路是对预测概率图做连通域分析得到预测图斑对真值掩膜同样提取图斑然后用 IoU 或中心距离做配对。一个预测图斑与某个真值图斑的 IoU 超过阈值就算命中否则计入误检真值里没被命中的就是漏检。from shapely.geometry import Polygon from shapely.ops import unary_union def match_polygons(pred_polys, gt_polys, iou_thr0.5): matched set() tp 0 for pp in pred_polys: best_iou 0 best_j -1 for j, gp in enumerate(gt_polys): if j in matched: continue inter pp.intersection(gp).area union pp.union(gp).area if union 0: continue iou inter / union if iou best_iou: best_iou iou best_j j if best_j 0 and best_iou iou_thr: tp 1 matched.add(best_j) fp len(pred_polys) - tp fn len(gt_polys) - tp return tp, fp, fn这里的pred_polys和gt_polys是shapely.geometry.Polygon对象列表。匹配时一个真值图斑只能被一个预测图斑命中避免一个屋顶被切成三块时全算命中。图斑级指标算出的精确率和召回率通常比像素级数值低 5 到 15 个百分点但对业主来说更接近真实可用度。5.2 让评估结果可以直接用于人工抽检光出数字还不够我会在推理后自动生成一组抽检图。每张图把历史影像、当前影像、模型概率叠加图、真值掩膜拼成四宫格叠加图用半透明红色标注预测结果按图斑编号命名。人工复核时只需要打开当前影像和概率叠加图对照就能判断阈值是否偏低、哪些图斑是误报。这个抽检逻辑直接接在推理脚本后面每次跑完模型输出一组 PNG 和一个 GeoJSON存档名带影像编号和时间戳。图斑级匹配给出的精确率、召回率和这些抽检图绑定在一起评审会上谁都可以对着图说问题不用翻原始数据再猜一遍。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。