ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python深度学习实现高分辨率城市遥感图像水体提取系统

Python深度学习实现高分辨率城市遥感图像水体提取系统 简介这份资源是面向计算机相关专业在校生与项目实战学习者的毕业设计级源码包主题为基于Python深度学习的高分辨率城市遥感图像水体提取系统。项目已通过导师指导与评审适合用作毕设、课程设计、期末大作业或竞赛初期立项演示也便于在已有基础上进行二次开发。压缩包共56个文件约1.42MB包含22个Python脚本、26张PNG图像、2个pth模型权重、2个CSV结果文件及若干说明文档覆盖数据预处理、模型训练、测试评估与结果输出等完整流程并附有U-Net与AttU-Net网络结构示意图。已有196人学习关注。读者可借此掌握遥感水体提取的深度学习实现思路理解数据加载、网络搭建、训练求解与指标评估等关键模块获得可运行、可复现的完整项目参考并在此基础上开展功能扩展与算法改进。1. 城市水体提取从遥感影像到二值掩膜的工程化路径拿到一幅高分辨率城市遥感影像想自动把河流、湖泊、池塘的边界勾出来这件事在遥感领域叫水体提取。传统做法靠 NDWI、MNDWI 这类光谱指数做阈值分割在郊区大江大河上效果还行一旦进入高密度建成区就频繁翻车——建筑阴影、沥青路面、深色屋顶在近红外波段的反射特性跟水体高度相似阈值一卡就混进去一大片。基于 Python 深度学习实现高分辨率城市遥感图像的水体提取系统核心思路是用卷积神经网络替代人工阈值让模型从数据里自己学出「什么是水」的判别边界。这套方案适合有 Python 基础、做过图像分类或语义分割、想切入遥感解译方向的从业者也适合毕业设计选题落在遥感深度学习的同学。读完你能拿到一条从数据准备、模型选型、训练调参到推理出图的完整链路知道每一步的参数怎么设、哪里容易踩坑、什么情况下该换方案。2. 数据管线从原始影像到可训练样本2.1 高分辨率城市遥感影像的获取与预处理城市遥感影像的来源常见有几类公开数据集如 LoveDA、DeepGlobe、Inria Aerial Image Labeling商业卫星如 WorldView、GF-2以及无人机航拍正射影像。高分辨率通常指空间分辨率优于 1 米这个量级下城市地物的类内差异极大——同样是水深水区、浅水区、含泥沙的河道、被树冠遮挡的池塘像素表现完全不同。预处理阶段要做的事包括辐射定标把 DN 值转成表观反射率、大气校正可选FLAASH 或 6S 模型、影像裁剪切成 512×512 或 256×256 的瓦片、以及波段选择。波段选择直接决定模型输入通道数。常见的高分影像波段组合是 R、G、B、NIR 四通道NIR 对水体判别至关重要因为水体在近红外波段吸收极强反射率接近零。如果只有 RGB 三通道模型也能跑但阴影和水体的混淆率会明显上升。我一般会保留 NIR输入通道设为 4如果原始数据没有 NIR就用 RGB 加一个 NDWI 计算通道作为补充。import rasterio import numpy as np from rasterio.windows import Window def read_and_normalize(tif_path, bands[1, 2, 3, 4]): 读取多波段遥感影像并做归一化 with rasterio.open(tif_path) as src: # 按指定波段索引读取rasterio 波段从 1 开始 img src.read(bands) # shape: (C, H, W) img img.astype(np.float32) # 逐波段 min-max 归一化到 [0, 1] for i in range(img.shape[0]): band img[i] bmin, bmax band.min(), band.max() if bmax - bmin 0: img[i] (band - bmin) / (bmax - bmin) return img, src.profile def tile_image(img, tile_size512, overlap64): 将大图切成带重叠的瓦片避免边缘地物被截断 C, H, W img.shape tiles [] coords [] step tile_size - overlap for y in range(0, H - tile_size 1, step): for x in range(0, W - tile_size 1, step): tile img[:, y:ytile_size, x:xtile_size] tiles.append(tile) coords.append((y, x)) return np.array(tiles), coordsread_and_normalize里逐波段做 min-max 归一化是最简单的方案实际项目中如果影像跨多景、光照差异大建议改用全局统计量或百分位裁剪比如 2% 和 98% 分位来避免异常值拉偏归一化范围。tile_image的overlap参数设 64 是为了推理阶段做滑窗拼接时消除接缝训练阶段可以设 0 节省显存。tile_size选 512 是折中值256 太碎、1024 对显存要求高具体看你的 GPU 显存和影像地物尺度。2.2 标注数据的组织与增强策略水体提取是二值语义分割任务标签是一张跟影像同尺寸的单通道掩膜水体像素为 1非水体为 0。标注来源有三种人工勾绘最准但最贵、现有水体产品二值化如 JRC Global Surface Water但分辨率可能不匹配、以及弱监督方式用 NDWI 阈值生成粗标签再人工修正。城市高分辨率场景下我建议至少人工标注 200 到 500 张 512×512 的瓦片正负样本比例控制在 1:1 到 1:3 之间负样本里要刻意包含建筑阴影、深色路面、绿色屋顶这些难例。数据增强对水体提取特别有效因为水体的形态变化极大——河流是长条形、湖泊是块状、池塘是散点状。常用的增强手段包括随机旋转90°、180°、270°、水平垂直翻转、随机缩放裁剪、色彩抖动。注意不要用大角度任意旋转遥感影像有方向性任意旋转会引入不真实的纹理。色彩抖动幅度也要控制±10% 亮度、±5% 对比度就够了太大反而让水体光谱特征失真。import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.RandomRotate90(p0.5), A.RandomBrightnessContrast( brightness_limit0.1, contrast_limit0.05, p0.3 ), A.GaussNoise(var_limit(5.0, 15.0), p0.2), ]) # 使用时注意影像和掩膜要同步变换 # augmented train_transform(imageimg_hwc, maskmask_hw) # img_aug, mask_aug augmented[image], augmented[mask]albumentations 要求输入影像格式为 HWC而 PyTorch 习惯 CHW所以在 Dataset 的__getitem__里要做一次 transpose。GaussNoise的方差上限设 15 是经验值再高会让浅水区纹理被噪声淹没。增强策略不是越多越好我见过有人堆了十几种增强结果模型在验证集上震荡严重后来砍到四种就稳定了。原则是增强后的样本要看起来仍然像真实遥感影像。3. 模型选型与训练U-Net 还是 DeepLabV33.1 语义分割骨干网络的对比与选择水体提取本质是二值语义分割主流骨干网络有 U-Net、DeepLabV3、SegFormer、HRNet 这几类。U-Net 的优势是结构简单、对小数据集友好、跳跃连接能保留边界细节缺点是感受野有限对大面积水域的上下文建模能力弱。DeepLabV3 用空洞卷积和 ASPP 模块扩大感受野适合处理大尺度水体但参数量大小数据集上容易过拟合。SegFormer 是 Transformer 架构全局建模能力强但需要更多数据才能发挥优势。HRNet 保持高分辨率特征贯穿全程边界精度最好但显存占用高。我的经验是标注数据少于 500 张时选 U-Net500 到 2000 张选 DeepLabV3ResNet-50 骨干超过 2000 张可以试 SegFormer。城市水体提取的难点在边界——水岸线往往只有几个像素宽U-Net 的跳跃连接在这方面有天然优势。如果算力有限用 MobileNetV2 做 U-Net 编码器也能跑出可用的结果mIoU 大概掉 3 到 5 个百分点。import torch import torch.nn as nn class DoubleConv(nn.Module): U-Net 基础卷积块两次 3x3 卷积 BN ReLU def __init__(self, in_ch, out_ch): super().__init__() self.block nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.block(x) class UNet(nn.Module): def __init__(self, in_channels4, num_classes1): super().__init__() # 编码器 self.enc1 DoubleConv(in_channels, 64) self.enc2 DoubleConv(64, 128) self.enc3 DoubleConv(128, 256) self.enc4 DoubleConv(256, 512) self.pool nn.MaxPool2d(2) # 瓶颈层 self.bottleneck DoubleConv(512, 1024) # 解码器 self.up4 nn.ConvTranspose2d(1024, 512, 2, stride2) self.dec4 DoubleConv(1024, 512) self.up3 nn.ConvTranspose2d(512, 256, 2, stride2) self.dec3 DoubleConv(512, 256) self.up2 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec2 DoubleConv(256, 128) self.up1 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec1 DoubleConv(128, 64) self.out nn.Conv2d(64, num_classes, 1) 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)) b self.bottleneck(self.pool(e4)) d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)in_channels4对应 R、G、B、NIR 四波段输入如果你的数据只有三通道就改成 3。num_classes1表示二分类输出用 Sigmoid 激活后跟 0.5 阈值比较得到掩膜如果改成num_classes2配合 CrossEntropyLoss 也可以但二值分割任务里单通道加 BCE 更常见。ConvTranspose2d做上采样会有棋盘格伪影介意的话换成nn.Upsample(modebilinear)加一次 3×3 卷积。3.2 损失函数、优化器与训练参数设置水体提取的类别不平衡问题很突出——城市影像里水体占比通常只有 5% 到 15%用普通 BCE 会让模型倾向于全预测为非水体。常见做法是 BCE 和 Dice Loss 加权组合BCE 稳定训练初期Dice 直接优化重叠度。权重比设 0.5:0.5 或 0.3:0.7 都行我一般用 0.5:0.5 起步如果验证集上召回率明显低于精确率就把 Dice 权重调高。优化器选 AdamW初始学习率 1e-3权重衰减 1e-4。学习率调度用 CosineAnnealingLR周期设总 epoch 数。Batch size 在 8 到 16 之间取决于显存。训练 epoch 数一般 80 到 150早停策略看验证集 mIoU连续 15 个 epoch 不提升就停。import torch.nn.functional as F class BCEDiceLoss(nn.Module): def __init__(self, bce_weight0.5): super().__init__() self.bce_weight bce_weight def forward(self, pred, target): # pred: (B, 1, H, W) logits, target: (B, 1, H, W) 0/1 bce F.binary_cross_entropy_with_logits(pred, target) pred_sigmoid torch.sigmoid(pred) intersection (pred_sigmoid * target).sum() dice 1 - (2 * intersection 1e-6) / ( pred_sigmoid.sum() target.sum() 1e-6 ) return self.bce_weight * bce (1 - self.bce_weight) * dice # 训练循环关键片段 # optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) # scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100) # for epoch in range(100): # model.train() # for img, mask in train_loader: # img, mask img.cuda(), mask.cuda() # pred model(img) # loss criterion(pred, mask) # optimizer.zero_grad() # loss.backward() # optimizer.step() # scheduler.step()Dice Loss 里的1e-6是平滑项防止分母为零。binary_cross_entropy_with_logits内部做了 Sigmoid所以模型输出不要再过激活函数。验证阶段算 mIoU 时阈值从 0.5 开始试有些场景下 0.4 或 0.6 效果更好这个阈值本质是在精确率和召回率之间做权衡——阈值低召回高但误检多阈值高精确高但漏检多。城市水体提取里漏检一条河比误检几块阴影更严重所以我倾向把阈值设低一点0.4 左右。4. 推理与后处理让输出掩膜真正可用4.1 滑窗推理与大图拼接训练时切了瓦片推理时也要切但拼接回去的时候要处理重叠区域。最简单的方式是重叠区取平均但边缘瓦片可能尺寸不足需要做 padding。更稳妥的做法是记录每个像素被预测的次数最后做加权平均。def sliding_window_inference(model, img, tile_size512, overlap64, threshold0.4): 对大图做滑窗推理并拼接 model.eval() C, H, W img.shape step tile_size - overlap # 填充到 step 的整数倍 pad_h (step - (H - tile_size) % step) % step pad_w (step - (W - tile_size) % step) % step img_pad np.pad(img, ((0,0),(0,pad_h),(0,pad_w)), modereflect) _, Hp, Wp img_pad.shape prob_map np.zeros((Hp, Wp), dtypenp.float32) count_map np.zeros((Hp, Wp), dtypenp.float32) with torch.no_grad(): for y in range(0, Hp - tile_size 1, step): for x in range(0, Wp - tile_size 1, step): tile img_pad[:, y:ytile_size, x:xtile_size] tile_tensor torch.from_numpy(tile).unsqueeze(0).cuda() pred torch.sigmoid(model(tile_tensor)).squeeze().cpu().numpy() prob_map[y:ytile_size, x:xtile_size] pred count_map[y:ytile_size, x:xtile_size] 1 prob_map / np.maximum(count_map, 1) mask (prob_map[:H, :W] threshold).astype(np.uint8) return maskmodereflect做 padding 比补零好补零会在边缘产生虚假的低概率区域。count_map记录每个像素被覆盖的次数重叠区取平均能平滑接缝。threshold参数在推理阶段可以按场景调如果发现输出掩膜碎片化严重先检查是不是阈值太高导致水体内部出现空洞适当降到 0.35 试试。4.2 形态学后处理与矢量导出模型输出的二值掩膜往往有毛刺、小孔洞、孤立噪点。后处理三板斧先做开运算去掉小噪点再做闭运算填补内部空洞最后按面积过滤掉小于阈值的连通域。面积阈值根据你的分辨率定0.5 米分辨率下小于 100 像素的连通域基本是误检。import cv2 def postprocess(mask, min_area100, kernel_size3): 形态学后处理 面积过滤 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) # 开运算去噪点 mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 闭运算填空洞 mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 连通域面积过滤 num_labels, labels, stats, _ cv2.connectedComponentsWithStats(mask, connectivity8) cleaned np.zeros_like(mask) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] min_area: cleaned[labels i] 1 return cleanedkernel_size3对应 3×3 椭圆核如果掩膜噪声特别碎可以加到 5但别超过 7否则细窄河道会被腐蚀断。min_area设 100 是 0.5 米分辨率的经验值如果你的分辨率是 2 米同样物理面积对应的像素数要除以 16阈值相应调小。后处理完的掩膜可以用rasterio.features.shapes转成 GeoJSON 矢量方便导入 QGIS 或 ArcGIS 做进一步分析。5. 避坑与排查那些让 mIoU 掉 10 个点的细节5.1 现象验证集 mIoU 很高但推理结果全是噪声原因通常是训练集和推理数据的归一化方式不一致。训练时用了逐景 min-max 归一化推理时如果对整张大图做全局归一化局部对比度会被压缩模型看到的数值分布跟训练时对不上。解决办法是推理时也按瓦片做归一化或者训练阶段就改用固定的均值和标准差类似 ImageNet 的 mean/std推理时沿用同一套参数。5.2 现象水体边界出现规律性锯齿这是ConvTranspose2d的棋盘格伪影在 U-Net 解码器里很常见。换成双线性上采样加卷积能缓解或者在损失函数里加一个边界加权的 BCE让模型更关注水岸线附近的像素。另一个可能原因是瓦片重叠不够拼接时边界没对齐把overlap从 64 加到 128 试试。5.3 现象建筑阴影被大量误检为水体这是城市水体提取最典型的翻车场景。阴影在 RGB 上跟水体很像但在 NIR 波段阴影的反射率并不低而水体在 NIR 几乎全吸收。如果你的模型输入没有 NIR误检率会显著上升。补救措施有两个一是加 NIR 通道重新训练二是在后处理阶段用 NDWI 对模型输出做一次交叉验证把 NDWI 低于阈值的区域从水体掩膜里剔除。5.4 现象训练 loss 震荡不收敛先检查学习率是不是太大AdamW 从 1e-3 降到 3e-4 试试。如果还震荡看 batch size 是不是太小batch size 小于 4 时 BatchNorm 的统计量不稳定改成 GroupNorm 或者把 batch size 加上去。另一个隐蔽原因是数据增强里的色彩抖动幅度太大导致同一场景在不同 epoch 里表现差异过大把亮度限制从 0.2 降到 0.1。5.5 现象小池塘全部漏检类别不平衡的典型表现。小水体像素占比极低BCE 损失里被大水体主导了。解决办法是在损失函数里给小水体更高的权重或者用 Focal Loss 替代 BCE。另一个思路是在数据增强时对小水体区域做过采样把包含小池塘的瓦片重复采样几次。后处理阶段的min_area也别设太大否则小池塘刚被模型检出来就被面积过滤掉了。6. 进阶技巧用测试时增强和模型集成再榨几个点训练完一个模型只是起点推理阶段还有不少免费的性能可以拿。测试时增强TTA是最容易见效的手段对同一张输入做水平翻转、垂直翻转、旋转 90° 等变换分别推理后再把结果逆变换回去取平均。代价是推理时间翻几倍但 mIoU 通常能涨 1 到 3 个点对于毕业设计或项目验收来说这点提升往往很关键。def tta_inference(model, img_tensor): 测试时增强四种变换取平均 model.eval() preds [] with torch.no_grad(): # 原始 preds.append(torch.sigmoid(model(img_tensor))) # 水平翻转 preds.append(torch.flip(torch.sigmoid(model(torch.flip(img_tensor, [3]))), [3])) # 垂直翻转 preds.append(torch.flip(torch.sigmoid(model(torch.flip(img_tensor, [2]))), [2])) # 旋转180度 preds.append(torch.flip(torch.sigmoid(model(torch.flip(img_tensor, [2, 3]))), [2, 3])) return torch.stack(preds).mean(dim0)模型集成是另一个方向用不同随机种子训练 3 到 5 个同架构模型推理时取平均。如果算力允许还可以混搭 U-Net 和 DeepLabV3 的输出不同架构的误差模式不一样互补效果比同架构多训几个种子更好。集成后的概率图做阈值分割时阈值可以适当调高到 0.5因为平均操作本身就有平滑效果。还有一个容易被忽略的点是推理分辨率。训练时用 512×512 的瓦片推理时如果直接对整张大图做一次前向显存扛不住而且模型没见过的尺寸会导致性能下降。坚持滑窗推理窗口大小跟训练一致这是最稳妥的做法。如果发现某些场景下模型表现明显差比如浑浊河道或水草覆盖的浅滩别急着调模型先看看训练集里有没有类似样本——没有的话补几十张标注比调参管用得多。我自己做这类项目的习惯是每换一个研究区先跑一遍预训练模型看误检漏检的分布再决定是补数据还是改后处理。模型不是越复杂越好数据质量和场景覆盖度才是天花板。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进