城市水体识别实战:基于PyTorch的遥感影像语义分割全流程解析

发布时间:2026/9/1 6:25:23
城市水体识别实战:基于PyTorch的遥感影像语义分割全流程解析 简介一套面向城市区域水体提取的轻量级实战方案融合U-Net与AttU-Net两种分割网络覆盖高分辨率遥感影像从预处理到训练、评估、推理的全流程适合本科毕业设计、遥感课程大作业及轻量级水体监测原型开发。压缩包共32个文件以Python脚本11个py、PNG图片13个和CSV标注文件为主另含已训练权重train.pth、说明文档及可视化结果包体仅733KB结构清晰便于直接上手。目前已有133人学习浏览。资源不仅提供可直接运行的完整工程还附带增强样本生成脚本与典型预测结果配套Urban_pre子集和res.csv标签文件可快速验证模型效果所有代码适配Python 3.8与PyTorch 1.9无需复杂配置即可投入实验或二次开发。 城市水体识别这个方向这几年盯着的人越来越多了。不管是河道非法排放监测、内涝风险评估、海绵城市建设效果评估还是蓝线划定之后的动态巡查“把水面精确框出来”都是一切分析的前置条件。但真上手做过的朋友应该都有体会自然场景下的水体分割相对好做城市里的水体识别才是真的折腾——建筑物阴影、深色柏油路面、玻璃幕墙反光、桥体遮挡这些东西在影像上和水的光谱特征经常撞脸一个处理不好就是大片大片的高亮误报。我整理了一份完整的实战流程覆盖数据集准备、PyTorch模型训练到推理落地的全链路目标场景就是城市遥感影像。这篇内容适合正在做遥感语义分割但被城市水体误检困扰的开发者也适合刚入门PyTorch、想找个有实际业务价值的项目练手的人。先把话说在前面这不是一个打包即食的教程而是把我在这个任务里踩过的所有坑、所有的取舍逻辑都摊开讲清楚。1. 城市水体识别到底难在哪先搞清楚任务边界1.1 为什么城市场景比自然场景更棘手自然场景里的水体识别靠光谱特征就能解决掉大部分问题。水体在近红外波段吸收强烈归一化差异水体指数NDWI拉出来河流、湖泊和周围植被对比鲜明阈值一卡就能用。但在城市里这条路走不通。城市影像里主要的误检来源有三个。第一是建筑物阴影尤其是高层建筑投下的阴影在可见光波段和近红外波段的表现和“深色水体”极其相似NDWI照样给出正值。第二是深色材质地表沥青路面、黑色屋顶、部分广场砖光谱曲线和水体在几个关键波段上是接近的。第三是混合像元问题城市里的水体往往不是一整块大水面而是被桥梁、船只、岸堤、树木遮挡切割成碎块一个像元里可能同时包含水和岸边的树荫、水泥地纯净的水体光谱被稀释了。所以城市水体识别实际上是一个典型的“同谱异物”问题——目标物和非目标物的光谱特征重叠严重。只用阈值法或者传统的机器学习分类器精度天花板非常低。这也是为什么必须上语义分割模型从空间上下文信息里去学“水的样子”而不仅仅是像素级的颜色判断。1.2 任务定义与评价指标在动手之前先把任务边界划清楚。我做的是二分类语义分割对输入影像中的每个像素输出它是水体类别1还是非水体类别0。这件事看起来简单但有几个点必须先确认。第一数据的空间分辨率。我用的是0.5米到1米分辨率的影像这个级别下游细小的河道还能看出形状建筑物阴影的边界也比较清晰。如果你用的是10米级别的Sentinel-2影像城市水体识别基本不用考虑那些小河小塘在像元层面就已经消失了。第二评价指标怎么选。遥感分割里常见的评价指标有像素精度PA、交并比IoU、F1分数。对于城市水体这种正负样本极端不平衡的任务——水体往往只占整幅影像的百分之几——像素精度毫无参考价值永远往“全预测为非水体”的方向跑就能拿到98%以上的PA。我的建议是主要盯着IoU和F1来看同时单独统计误检率和漏检率。水体识别业务里漏检是河道没发现误检是假报警两类错的代价不一样不能只看一个综合指标。第三推理粒度的设定。模型最终输出的是像素级分类结果但在实际业务里水利管理人员往往需要的是“矢量面”比如某个湖面的边界框、某条河的polygon。所以整个流程里分割和后处理必须一体化考虑模型输出的栅格要能方便地转成矢量这部分在后面章节展开。2. 数据集准备没有现成标注时该怎么攒2.1 影像源选择与预处理数据是这份实战包的核心资产之一。很多朋友拿到项目第一反应是去网上找现成的公开水体数据集但问题是公开数据集大多是自然场景或者大尺度区域城市高楼密集区的样本覆盖不足直接拿过来训练泛化到自己的业务区域时会很惨。我这里推荐的做法是自建样本库。影像源优先选择高分辨率影像国内可用的渠道包括天地图、各省级卫星中心的历史影像、以及一些商用遥感数据平台的样例数据。如果预算有限也可以先用公开的15米级影像做预训练再用自己的高分辨率数据做微调。拿到影像后预处理有三个关键步骤。辐射定标和大气校正如果做的是多时相对比这一步必须做否则不同时期影像之间的亮度差异会淹没真实的水体变化信号。影像裁剪原始影像动辄几万乘几万像素GPU根本吃不下。按固定大小切成瓦片我习惯用512×512既能覆盖足够空间上下文又不会让单样本的显存占用失控。数据集划分按“区域”而不是“瓦片”划分训练集、验证集、测试集。这一点特别重要——如果同一块区域的不同瓦片同时出现在训练集和验证集模型相当于提前见过答案验证集的指标会虚高到了真实场景直接打回原形。2.2 标注工具与标注规范标注工具我用的是LabelMe理由是开源、无需部署、支持多边形标注社区也活跃。QGIS配合半自动分类插件也能做但效率上不如LabelMe直接勾。设好工具之后真正决定数据集质量的是标注规范。没有规范的标注就是一场灾难我第一版数据就吃过这个亏。几个实操中必须明确的规则水陆边界以水边线为界水位线以下的裸露滩涂不算水体但要避免把岸边的湿润泥土画进去。桥梁和船只桥面本身不是水体桥下的水域如果被桥体遮住但仍是连续水面应标注为水体船体属非水体但小船周围的尾流、浪花属于水体。阴影下的水面如果水面被建筑物阴影遮挡但依旧目视可辨要标注为水体。这一点极其关键模型能不能在城市阴影干扰下保持稳健全靠训练样本里有没有覆盖足够多的“阴影水体”案例。细小水体宽度在2到3个像素以上的河道沟渠都应标注更细的可以舍弃否则噪点太多。一个团队有多个标注人员时建议先用20张瓦片做一轮全员标注统计标注一致性把争议像素全部拿出来讨论定标再铺开正式标注。这个前摇环节不能省能帮你省下后面清洗数据的几周时间。2.3 样本增强策略城市水体数据集的增强策略和常规图像分类的增强不太一样。常规的随机裁剪、水平翻转、色彩抖动大家都懂我重点说几个对遥感分割特别有效、但容易忽略的增强手段。多尺度缩放把训练影像按0.5倍、0.75倍、1.25倍、1.5倍随机缩放后再裁剪。城市河道的宽度在不同区域差异很大多尺度训练让模型对不同水体尺度更鲁棒。光学变焦式增强在RGB和近红外波段组合之间随机切换。很多遥感数据源是4波段RGBNIR训练时随机选择3个波段组成输入相当于做了通道级别的dropout能防止模型过度依赖某一个波段。阴影模拟用图像处理手段对部分样本加深暗部区域模拟建筑物阴影效果增加模型对阴影干扰的抗性。增强不是越多越好过强的几何增强会破坏地物空间结构。旋转角度我限制在90度的倍数不做任意角度旋转因为建筑物和水体在方向上是有物理意义的竖着的河道旋转45度就变得不自然了。3. 模型构建基于PyTorch实现语义分割网络3.1 为什么选U-Net而不是更花哨的结构水体识别这个任务模型骨架我直接选了U-Net。有人可能会问现在不是有SegFormer、DeepLabV3、Swin Transformer一大堆新结构吗为什么还在用这个2015年的老模型原因很简单遥感图像分割里U-Net的性价比依然是很高的。城市水体识别需要精细的边界信息U-Net这种编码器-解码器结构配合跳跃连接天然保留了多尺度特征浅层的高分辨率特征能直接指导边界像素的分类深层语义特征负责区分水体和非水体的整体格局。Transformer类的模型擅长长距离依赖建模但城市水体通常不会出现“几公里外的水体影响当前像素判断”这种需求而且这类模型对训练数据量要求更高、部署推理也更重。DeepLabV3的空洞卷积虽然把感受野提上去了但在细边界恢复上反而没有U-Net这种逐级上采样来得直接。当然U-Net也有自己的短板比如对大尺度地物的上下文利用效率不高。我的处理方式是加了一个轻量的注意力模块在跳跃连接处对特征做通道注意力加权让模型自动强化对“水色”有判别力的特征通道。实测下来相比原版U-NetIoU提升了1到2个百分点推理耗时几乎没增加。3.2 编码器与解码器的关键设计下面是基于PyTorch的核心代码骨架完整的工程代码在训练包里有这里展示最核心的模型结构片段。import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super(DoubleConv, self).__init__() self.conv 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.conv(x) class ChannelAttention(nn.Module): def __init__(self, in_ch, reduction8): super(ChannelAttention, self).__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Linear(in_ch, in_ch // reduction), nn.ReLU(inplaceTrue), nn.Linear(in_ch // reduction, in_ch), nn.Sigmoid() ) def forward(self, x): b, c, _, _ x.size() y self.avg_pool(x).view(b, c) y self.fc(y).view(b, c, 1, 1) return x * y class UNet(nn.Module): def __init__(self, in_channels3, num_classes1, features[64, 128, 256, 512]): super(UNet, self).__init__() self.ups nn.ModuleList() self.downs nn.ModuleList() self.pool nn.MaxPool2d(2) # 编码器 for feature in features: self.downs.append(DoubleConv(in_channels, feature)) in_channels feature # 解码器 for feature in reversed(features): self.ups.append(nn.ConvTranspose2d(feature*2, feature, kernel_size2, stride2)) self.ups.append(ChannelAttention(feature * 2)) self.ups.append(DoubleConv(feature*2, feature)) self.bottleneck DoubleConv(features[-1], features[-1]*2) self.final_conv nn.Conv2d(features[0], num_classes, kernel_size1) def forward(self, x): skip_connections [] for down in self.downs: x down(x) skip_connections.append(x) x self.pool(x) x self.bottleneck(x) skip_connections skip_connections[::-1] for idx in range(0, len(self.ups), 3): x self.ups[idx](x) skip skip_connections[idx//3] if x.shape ! skip.shape: x F.interpolate(x, sizeskip.shape[2:], modebilinear, align_cornersTrue) concat_skip torch.cat((skip, x), dim1) x self.ups[idx1](concat_skip) x self.ups[idx2](x) return torch.sigmoid(self.final_conv(x))几点设计上的考虑编码器的初始通道数我设为64每下采样一次翻倍到最深层的512。这个规模对512×512输入适应性很好显存占用也控制得住——实测在8G显存的卡上batch size设8没有压力。跳跃连接处的ChannelAttention模块是用来做特征选择的。传统U-Net直接把编码器特征和解码器特征拼接但编码器浅层特征里包含大量边缘纹理信息也包含大量阴影、屋顶等干扰信息。加一个通道注意力本质上是让模型学一个“哪些特征通道更像水”在拼接之前先做一次加权筛选。解码器上采样用的是转置卷积而不是简单的双线性插值。转置卷积虽然偶尔会有棋盘格伪影但它的参数是可学习的能让模型根据水体形状自适应地恢复边界细节。实测下来在细长河流这类目标上用转置卷积比用双线性插值的边界连续性更好。3.3 损失函数的选择边界像素怎么处理水体分割里最棘手的问题是边界。一个512×512的瓦片里真正处于水陆交界模糊地带的像素可能只有几百个但它们对最终成图的视觉效果起到决定性影响。单纯用交叉熵损失模型会把几乎所有注意力放在大块水体内部边界区域根本学不好。我试过几种组合最终用的是BCE Loss和Dice Loss的加权和。BCE负责逐像素的精确判断Dice负责处理正负样本不平衡——它把预测结果和真实标注的重叠度直接作为优化目标对类别不平衡天然免疫。class CombinedLoss(nn.Module): def __init__(self, weight_bce0.5, weight_dice0.5): super(CombinedLoss, self).__init__() self.weight_bce weight_bce self.weight_dice weight_dice self.bce nn.BCELoss() def forward(self, pred, target): bce_loss self.bce(pred, target) smooth 1e-5 pred_flat pred.contiguous().view(-1) target_flat target.contiguous().view(-1) intersection (pred_flat * target_flat).sum() dice_loss 1 - (2. * intersection smooth) / ( pred_flat.sum() target_flat.sum() smooth ) return self.weight_bce * bce_loss self.weight_dice * dice_loss还有一个细节我做了一个边缘加权。把训练标签里的真实边界像素提取出来在损失函数里给它们乘一个大于1的权重我用的1.5强制模型把更多的学习容量分配给边界区域。这种做法在视觉上见效很快大概训练几十个epoch之后就能看到河流边界的锯齿明显减少。4. 训练流程环境配置、超参数与监控4.1 环境配置与数据加载PyTorch环境配置是个老生常谈的话题但在遥感这个场景下有几个特殊注意点。首先CUDA版本必须和PyTorch版本严格对齐。我用的组合是CUDA 11.8 PyTorch 2.0.1这个组合在稳定性上经过了大量验证。安装命令如下# conda创建环境 conda create -n water-seg python3.9 conda activate water-seg # 安装PyTorchCUDA 11.8版本 pip install torch2.0.1 torchvision0.15.2 --index-url https://download.pytorch.org/whl/cu118 # 安装GIS相关依赖 pip install gdal rasterio shapely geopandas labelme opencv-pythonWindows用户特别容易在这里栽跟头。如果直接pip install torch不带--index-url参数默认安装的是CPU版本训练速度能差几十倍。装完之后务必在Python里验证一下CUDA是否可用import torch print(torch.cuda.is_available()) # 必须是True print(torch.cuda.get_device_name(0)) # 能看到你的显卡型号数据加载部分我用了PyTorch的Dataset和DataLoader机制。遥感影像用GDAL或rasterio读取注意一个问题影像通常是16位整型存储的DN值范围0-65535模型输入需要的是0-1区间的浮点数读取之后要做归一化。这里推荐用百分位拉伸而不是线性拉伸——线性拉伸会被少数极亮的反光点带偏百分位拉伸能把2%-98%的灰度范围映射到0-1水体与背景的对比度会更好。4.2 训练循环中的关键参数训练参数直接上我验证过的配置优化器AdamW初始学习率1e-4学习率调度CosineAnnealingLR最小学习率1e-6Batch size88G显存如果显存不够可以降到4Epoch数150-200但开启Early Stopping验证集IoU连续20轮不提升就停输入尺寸512×512随机裁剪数据加载num_workers建议4-8Windows上如果报错就设为0训练过程中的一个关键点是学习率策略。我一开始用固定学习率发现损失函数很快降到0.1左右就停滞不前了。换用CosineAnnealingLR之后后期学习率降下来模型能够在损失平面的底部缓慢振荡最终IoU提升了大概2个百分点。还有一个很多人容易忽略的问题——BatchNorm在遥感数据上的表现和普通图像不太一样。遥感影像的像元分布和自然图像不同方差更大BatchNorm如果batch size太小小于4统计量会非常不稳定。我建议在模拟推理阶段将模型切换为eval模式BatchNorm会使用训练阶段统计的全局均值方差效果更稳定。4.3 训练过程监控与调试训练时一定要做可视化监控。光看loss曲线远远不够loss下降不代表分割效果就好。我每个epoch结束都会把验证集上的预测结果可视化保存下来拼成大图直接看。这里分享几个我在监控中总结出来的经验信号如果训练集loss降了但验证集loss不降典型的过拟合信号加大数据增强强度或者加Dropout。如果预测图整体偏暗说明模型倾向把所有像素预测为非水体检查正负样本比例和Dice损失权重。如果预测图上面出现规则的网格状伪影十有八九是转置卷积的棋盘效应考虑换成F.interpolate上采样。如果水域边界出现连续的“锯齿”形态考虑增加边缘加权权重。训练推理代码里我同时提供了一份训练日志记录的示例代码把每个epoch的loss、IoU、F1、误检率、漏检率全部记录到CSV里方便后期做对比分析。没有记录的训练等于白训。5. 推理与后处理把模型输出变成可用成果5.1 滑窗推理策略训练完成后进入实际推理阶段。输入的大影像通常有几万像素宽直接整幅喂给模型必然显存溢出滑窗推理是标准方案。我的做法如下def sliding_window_infer(model, image, window_size512, stride384, devicecuda): model.eval() h, w image.shape[2], image.shape[3] output torch.zeros((1, 1, h, w), devicedevice) count torch.zeros((1, 1, h, w), devicedevice) for y in range(0, h, stride): for x in range(0, w, stride): y_end min(y window_size, h) x_end min(x window_size, w) y_start y_end - window_size x_start x_end - window_size if y_start 0: y_start 0 y_end window_size if x_start 0: x_start 0 x_end window_size patch image[:, :, y_start:y_end, x_start:x_end] with torch.no_grad(): pred model(patch) output[:, :, y_start:y_end, x_start:x_end] pred count[:, :, y_start:y_end, x_start:x_end] 1 output output / count return output窗口大小512步长384意味着相邻窗口有128像素的重叠。重叠区的预测结果会被平均这样能有效避免窗口边界处的“拼接缝”。如果追求极致速度可以把步长提到512不做重叠但分割质量会有明显下降边缘断开的风险加大。我的实测结论是重叠推理相比无重叠推理IoU能高出1到1.5个百分点代价是推理时间增加约1.3倍。在业务上这通常是划算的。5.2 后处理中值滤波与矢量化模型输出的是一个概率图需要经过后处理才能变成最终产品。第一步是阈值分割。概率图不是天然的二值图需要设定阈值把概率大于某个值的像素判为水体。默认阈值0.5但根据业务需求可以调整——如果更在意漏检比如防汛监测阈值降到0.35如果更在意误检比如给执法部门提供证据阈值抬到0.65。我在工程包里加了一个交互式阈值调整脚本可以实时滑动查看分割效果。第二步是去除噪点和空洞。这一步强烈推荐用形态学操作而不是简单的滤波。我用的是先开运算后闭运算的组合——开运算先去除散落在非水区域的小噪点闭运算再填充水体内部的细小空洞。核的大小建议3×3或5×5太大容易把细长河道和真实的小水塘洗掉。第三步是矢量化。用GDAL的Polygonize函数把二值栅格转成shapefile这一步在实际业务中往往是最终交付的形态。转矢量之后要做一次“小图斑剔除”——面积小于设定阈值比如50平方米的图斑大多是误检的阴影或深色屋顶直接删掉。这一步在栅格层面做不方便矢量层面一行代码就能搞定。6. 踩过的坑与解决思路几个影响精度的关键细节6.1 建筑物阴影最顽固的误检来源训练初期的模型在测试集上IoU只有78%其中最大的误差来源就是高层建筑阴影。这些阴影区域的光谱特征和水体太接近了模型经常把整栋楼的影子预测成水。尝试过几种方案最终有效的组合是一是在训练集里加入大量真实的阴影水体样本让模型学会区分“阴影中的水”和“阴影中的地”——这个区分往往靠的是空间上下文比如阴影周围是否有真实水体的连续性二是在模型输出后加一个基于对象的后处理——分割成连续区域后计算每个区域与周围水体的连通性孤立且形态细长的暗色区域大概率是阴影而不是水。这个问题的根治还是靠数据。如果标注样本里覆盖了不同朝向、不同高度的建筑物阴影案例模型的学习效果会明显改善。数据多样性在遥感任务中的重要性怎么强调都不过分。6.2 细小水体断连问题城市里的小河道、排水渠宽度在5到10米之间在高分辨率影像上也就十几个像素宽。这类目标在训练时占比小模型在预测时容易把它们断开成一段一段的或者干脆漏掉某一段。处理这个问题我在损失函数和推理两个层面都做了调整。损失层面我把边缘加权做得更激进对宽度小于8个像素的细长水体区域权重再加倍。推理层面在矢量化后加了一个“断裂连接”步骤——如果两个水体图斑相距小于某个阈值我用的15米且形态上处于同一走向、几乎没有角度偏转就把它们连接起来。这个方法本质上是利用城市水系的空间连续性做几何先验修正效果非常明显河流的连通率大幅提升。6.3 水体状态变化带来的误判城市水体的状态不是恒定的不同季节、不同时段采集的影像里水色、浊度、漂浮物、水量可能差异很大。一个在夏季清澈河道上学出来的模型到了冬季的浑浊河面上很可能失效。这个问题没有一劳永逸的解法只能做持续迭代。我的做法是保存推理结果中低置信度的区域定期抽出来重新标注加入训练集做增量训练。每次迭代之后模型的鲁棒性都会明显上一个台阶。训练包里的代码支持额外的微调脚本加载已有权重继续训练不需要从零开始。7. 一点使用建议训练推理代码包里的完整流程从我自己的项目经验来看一个大约1万张512×512瓦片的数据集配上一张8G显存的卡一两天的训练时间能拿到一个可用的模型。如果你手里的数据量没有这么大我建议先用预训练权重做迁移学习把带注意力机制的U-Net在公开的水体数据集上预训练一遍再在自己的小数据集上微调效果通常比从零训练好不少。如果只是想快速验证一下整个流程工程包里预制了一组小样本包含约200张标注瓦片数据量不大但足够跑通训练到推理全链路。跑通之后你就能直观地看到模型在哪些场景下表现好、在哪些场景下误检然后有针对性地扩充数据。城市水体识别不是一锤子买卖。模型训练出来只是第一步后续的数据迭代、误检分析、业务适配才是真正花时间的地方。希望这份实战流程能让你少走一些弯路。本文还有配套的精品资源点击获取