简介:这是一篇发表于《地理与地理信息科学》期刊的学术论文PDF,聚焦高分辨率卫星遥感影像水体提取,面向遥感、地理信息、深度学习方向的科研人员与研究生。论文针对阴影和建筑物干扰水体提取精度的难题,基于卷积神经网络与Deeplabv3语义分割网络构建训练数据集并建模,实测精度分别达95.10%和92.14%,优于水体指数法、面向对象法和支持向量机法。资源还系统梳理了深度学习的应用优势及传统方法的局限,并对数据集构建、模型训练、精度评价等关键流程给出详细说明。包体为1个PDF文件,大小仅1.39MB,内容完整,适合作为课题研究、论文写作或专业指导的参考文献。该资源已有1600人学习,对需要掌握深度学习水体提取方案、了解高分辨率遥感应用案例的读者具有直接参考价值。
1. 基于深度学习的高分遥感影像水体提取:为什么老方法不够用
高分遥感影像水体提取,放在深度学习之前是个什么状态呢?用NDWI、MNDWI这类光谱指数配一个阈值,在开阔水面上能轻松拿下95%以上的识别率,所以很多刚定下这个课题的人会问:还有必要上深度学习吗?我的答案是必要,而且相当必要。山区细流、云影压制、城市河道边的浅水、结冰湖面、水产养殖塘——这些真实高分影像里最常见的疑难样本,恰恰是单一指数法反复翻车的地方。这篇文章就按「问题定义 → 数据准备 → 模型训练 → 避坑 → 成果输出」的顺序,把一套可行的深度学习水体提取方案完整讲一遍,适合刚把研究课题定在这个方向、需要快速产出基线结果的学生,也适合准备用模型替代人工矢量化作业的工程师。
2. 问题定义与技术选型:从光谱指数到语义分割的必然迁移
2.1 传统水体指数法的天花板:NDWI 为什么在复杂场景集体失效
水体提取在遥感领域是个经典二分类问题,传统做法从光谱指数出发。NDWI 的公式是 (Green − NIR) / (Green + NIR),利用水体在绿光波段反射率高、在近红外波段几乎全吸收的特性,把水体和非水体压到两个极端区间,再取一个阈值完成分割。高分二号只有蓝、绿、红、近红外四个波段,没有短波红外,所以只能用 NDWI 而不是 MNDWI。MNDWI 用短波红外替代近红外,对城镇建筑干扰的抑制更好,但前提是影像得有 SWIR 波段,比如哨兵二号。早期我用 ENVI 处理哨兵数据做水体提取,走的也是这条路线,处理大范围影像时需要反复试阈值,一个场景一个调法。
这方法在干净场景里确实好用,但存在几个结构性缺陷。第一个是山体阴影:阴影区的绿波段和近红外反射特征与水体高度相似,NDWI 计算出来同样是高值,阈值根本分不开。第二个是阈值漂移:同一景影像里,清洁水库、浑浊河流、富营养化池塘的水体指数分布完全不同,固定阈值只能顾一头。第三个是上下文缺失:指数法只看单像素光谱值,不知道这个像元周围是什么——被草包围的水塘和水田、被沥青包围的积水坑,光谱上几乎没差别,但语义上一个是水一个不是。这些问题的本质是:光谱指数把高维特征压缩成一个标量,信息损失太大。
深度学习做水体提取,本质上是在学「光谱 + 纹理 + 上下文」的组合特征。卷积网络在局部感受野内同时观察颜色跳变、形状轮廓和邻域结构,比单波段比值携带的信息多得多。特别是高分影像分辨率达到米级以后,地物细节极其丰富,深度学习模型能利用这些细节区分「水的纹理」和「阴影的纹理」——水体的空间平滑度高,而山体阴影内部有明显的坡向纹理变化。这就是这个方向值得做的核心原因。如果你手头的研究需要解释「为什么模型比指数法好」,可以从这三个缺陷入手做对比实验,而不是只贴一张效果图。
2.2 模型选型对比:UNet、DeepLabV3+ 还是 SegFormer
水体提取虽然叫「提取」,但在技术层面属于语义分割——逐像素做二分类,而不是目标检测。这个定位决定了模型选型的范围。遥感水体分割领域被验证过的主流架构有三类:UNet 系列、DeepLabV3+ 系列、SegFormer 为代表的 Transformer 系列。它们的差异集中在感受野的获取方式和特征融合策略上。
UNet 是编码器-解码器结构,编码器逐层下采样提取语义特征,解码器逐层上采样恢复空间分辨率,中间用跳跃连接把同尺度的浅层细节和深层语义拼接起来。它的优势是结构简单、训练数据需求量相对小、小目标边界保持好,在几百张样本规模下就能出可用结果。DeepLabV3+ 用空洞卷积构成 ASPP 模块,在特征图后端并行采多个膨胀率的上下文,对多尺度目标的适应力更强,但训练显存占用和调参复杂度都更高。SegFormer 把 Transformer 引入分割任务,靠自注意力做全局建模,对大面积水域的完整性保持得极好,但训练成本最高,容易在小数据集上过拟合。
如果你刚起步,我的建议是先用 UNet 把基线跑通,不要一上来就上 Transformer。我见过不少研究把大量时间耗在模型结构的细微改动上,最后发现数据质量才是决定 IoU 的瓶颈。可以参考的对比实验设计是:固定同一份数据、同一套训练参数,分别跑 UNet、DeepLabV3+、SegFormer,记录各自的 IoU、F1、参数量和单轮训练耗时。下面给一个参考对比表,数值范围来自我做过的类似水体项目的经验分布:
| 模型 | 参数量级 | 小目标表现 | 训练数据需求 | 显存占用 | 推理速度 |
|---|---|---|---|---|---|
| UNet | 30M 左右 | 较好 | 低 | 低 | 快 |
| DeepLabV3+ | 40M 左右 | 中等 | 中 | 中 | 中 |
| SegFormer-B0 | 10M 左右 | 较好 | 高 | 中 | 中 |
| SegFormer-B3 | 60M 左右 | 中等 | 很高 | 高 | 慢 |
2.3 为什么把水体提取当成二分类分割而不是检测或聚类
水体提取在表达上有多种方式:有人用目标检测框出湖泊,有人用超像素聚类水面,还有人用变化检测做时序分析。但多数研究与业务落地场景中,语义分割是最合适的框架。原因有三:水体没有规则的「框」级几何边界——一条蜿蜒的山区溪流,用矩形框表达会引入大量非水体区域;聚类方法缺乏语义标签的监督,无法区分水面和阴影;而逐像素分割直接输出与影像分辨率一致的水体掩膜,方便后续矢量化成多边形,直接对接 GIS 业务。
在高分场景里,分割结果的空间精度要求很高。比如河流管理项目中,河道宽度提取差一个像素,在 2 米分辨率的影像上就是 2 米的偏差,可能导致岸线面积计算误差。所以这个任务要关注的不只是分类准确率,还包括边界贴合度,这也是后续要专门做边界优化和矢量化处理的原因。把问题定义清楚会直接影响数据准备方式:你要做的是给每一张影像像素打 0/1 标签,而不是画矩形框或挑选聚类中心。
3. 高分影像数据准备:切瓦片、样本增强与数据集划分
3.1 拿到 GeoTIFF 之后的第一件事:统一坐标系与分辨率
高分影像数据通常以 GeoTIFF 格式交付,每景影像包含波段数据、地理变换信息和投影坐标系。深度学习框架不管这些地理信息,它只吃一个普通数组。但在数据准备阶段必须处理地理元数据,否则后续推理结果无法落回地图坐标。常见做法是先检查所有影像的投影是否一致,如果不一致就用 GDAL 做重投影和重采样。
检查坐标信息的命令很简单,用 Python 的rasterio库就能完成:
import rasterio with rasterio.open("GF2_001.tif") as src: print(src.crs) # 投影坐标系,例如 EPSG:32650 print(src.transform) # 仿射变换矩阵,记录像素到地理坐标的映射 print(src.width, src.height) # 影像宽度、高度(像素) print(src.count) # 波段数,高分二号是 4 波段这段代码做的是打开影像并打印元信息,核心是看 CRS 是否统一、分辨率是否一致。如果多景影像来自不同传感器,比如高分一号和高分二号混用,它们的空间分辨率分别是 2 米和 4 米,直接混在一起训练会让模型学习到错误尺度关联。解决方法是统一重采样到同一分辨率,比如全部重采样到 2 米。
gdalwarp -t_srs EPSG:32650 -tr 2 2 -r cubic input_GF1.tif output_GF1_2m.tif这条命令把影像投影到 UTM 50N 坐标系,像元大小重采样为 2 米,使用三次卷积插值保留光谱细节。注意-r cubic适合重采样光谱数据,但如果你的影像后续要做水体提取,我一般不建议用最邻近以外的插值去重采样标签栅格——标签是类别数据,用三次卷积会产生不存在的中间值。标签统一用-r near保持 0/1 值域。
3.2 用滑窗把大影像切成 256×256 的训练瓦片
高分影像普遍是几万乘几万的像素矩阵,直接整图送入 GPU 在显存上限死。常规做法是滑窗裁剪成 256×256 或 512×512 的小瓦片。窗口大小直接影响模型的感受野覆盖范围:256 窗口在 2 米分辨率下对应地面 512 米范围,足够覆盖中型池塘和河段;512 窗口包含更多上下文但训练更慢、边界细节略降。我做水体项目默认用 256,配合一定的重叠步长。
下面是切图的核心函数。输入是一张多光谱影像和对应的二值标签栅格,输出是训练瓦片目录:
import numpy as np import rasterio from rasterio.windows import Window import os def crop_tiles(img_path, label_path, out_img_dir, out_lab_dir, tile_size=256, stride=128, min_water_ratio=0.05): with rasterio.open(img_path) as img, rasterio.open(label_path) as lab: h, w = img.height, img.width for row in range(0, h - tile_size + 1, stride): for col in range(0, w - tile_size + 1, stride): win = Window(col, row, tile_size, tile_size) img_arr = img.read(window=win) # shape: (C, H, W) lab_arr = lab.read(1, window=win) # shape: (H, W) # 跳过水体像素占比过低的纯背景瓦片 water_ratio = (lab_arr > 0).sum() / (tile_size * tile_size) if water_ratio < min_water_ratio: continue img_arr = img_arr.astype(np.float32) lab_arr = (lab_arr > 0).astype(np.uint8) img_id = f"{row:05d}_{col:05d}" np.save(os.path.join(out_img_dir, f"{img_id}.npy"), img_arr) np.save(os.path.join(out_lab_dir, f"{img_id}.npy"), lab_arr)这段代码有几个关键参数要说明。stride是滑窗步长,当它小于tile_size时相邻瓦片有重叠,等于做了数据增广,但也会让训练样本之间高度相似。min_water_ratio是水体占比阈值,设置成 0.05 是为了滤掉完全没有水体的背景块——天然影像里水体往往只占很小面积,如果不滤掉,模型会看到大量纯背景样本,压抑对正例的学习。如果你做的是内陆干旱区水体提取,这个值可以调低到 0.02,保证细小的季节性河流不被丢弃。注意这里直接存成了.npy而不是重写 GeoTIFF,因为训练阶段不需要地理坐标,存成 NumPy 数组加载更快。但有一个细节不能省——你要把所有瓦片的行列号和原影像的地理变换单独保存下来,推理结束后回填坐标用。
3.3 样本增强与数据集划分:避免信息泄漏的两种关键做法
样本增强直接决定模型泛化能力,但遥感影像的增强策略和自然图像不完全一样。随机翻转、旋转 90 度倍数、亮度对比度扰动都是安全的增广方式;随机裁剪缩放不能用——高分影像的分辨率本身就是特征,缩小后水体与阴影的纹理差异会被抹掉。网格畸变、随机擦除这类强增强在检测任务里常用,但用在细长河流提取上会把连续水体打断,我踩过这个坑,后来不碰了。
数据集划分是最容易被忽视但影响最深的一步。很多人把瓦片随机分训练集和验证集,这在遥感任务里非常危险:同一景影像内的相邻瓦片包含几乎相同的地物,空间分辨率下自相关极强。随机划分会让验证集里出现训练集的「近亲」,IoU 虚高,换到新地区完全撑不住。正确的做法是按影像来源划分——比如你手里有 10 景高分影像,拿 8 景的全部瓦片做训练,1 景做验证,1 景做测试,保证验证集和训练集在地理位置上完全不相交。
增强代码示例,我用的是albumentations库,因为它能在同一组随机参数下同步处理影像和标签:
import albumentations as A train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.3), ]) def load_pair(img_path, lab_path): img = np.load(img_path) # (C, H, W) lab = np.load(lab_path) # (H, W) img = img.transpose(1, 2, 0) # albumentations 需要 HWC augmented = train_transform(image=img, mask=lab) return augmented["image"].transpose(2, 0, 1), augmented["mask"]RandomBrightnessContrast的幅度要控制在小范围,高分影像的光照差异本就来自太阳高度角变化,加大扰动会让模型去拟合不真实的颜色分布。增强的对象不仅是影像,标签掩膜也同步做同样的几何变换,这样水体位置和形状才能在增强后保持一致。我把数据准备阶段的时间占比通常控制在项目总耗时的一半左右,这听起来夸张,但遥感数据的地域差异太大,数据做得不扎实,模型结构再新也救不回来。
4. 训练一个可用的水体提取模型:UNet 落地的完整配置
4.1 网络结构:标准 UNet 加一个输入通道的改法
模型结构上,我建议第一次跑通采用标准 UNet 的 4 层下采样结构。输入是 N 个光谱波段的影像,对应的高分二号就是 4 通道,输出是一张概率图。网络编码器部分每层做两次 3×3 卷积加 ReLU,然后接 2×2 最大池化,通道数从 32 开始逐层翻倍到 256;解码器部分做 2×2 转置卷积上采样,与对应编码器层拼接后再做两次卷积,最终用 1×1 卷积输出单通道 logits。
在标准 UNet 基础上有一个很实用的小改动:把 NDWI 指数做成一个额外通道拼到输入里,让网络自己决定要不要用这个先验特征。做法就是切瓦片时顺便算好 NDWI,和原始波段一起存成五通道数组。这个改法能缓解山体阴影误检,因为网络等于多了一条现成的光谱线索,比让它只从原始波段里硬学要省事。代价只是输入通道数从 4 变成 5,显存开销几乎可以忽略。
UNet 的关键模块实现如下,注意跳跃连接用的是拼接而不是相加,这是 UNet 的原始设计:
import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels=5, num_classes=1): super().__init__() self.enc1 = DoubleConv(in_channels, 32) self.enc2 = DoubleConv(32, 64) self.enc3 = DoubleConv(64, 128) self.enc4 = DoubleConv(128, 256) self.pool = nn.MaxPool2d(2) self.bottleneck = DoubleConv(256, 512) self.up4 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec4 = DoubleConv(512, 256) self.up3 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec3 = DoubleConv(256, 128) self.up2 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec2 = DoubleConv(128, 64) self.up1 = nn.ConvTranspose2d(64, 32, 2, stride=2) self.dec1 = DoubleConv(64, 32) self.out = nn.Conv2d(32, 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], dim=1)) d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out(d1)这段代码里我用了padding=1的 3×3 卷积,保证特征图尺寸在下采样前不缩边。BatchNorm 在 batch size 偏小(比如小于 8)时不稳定,如果你只能跑 2 的 batch,建议换成 GroupNorm。in_channels=5对应前面说的「原始 4 波段 + NDWI 先验通道」方案,如果不用 NDWI,改成原始波段数即可。模型输出的 logits 没有接 Sigmoid,因为后面要跟损失函数,而 BCEWithLogitsLoss 内部自带 Sigmoid 计算,显式加 Sigmoid 会重复计算导致数值不稳定。
4.2 损失函数:BCE 与 Dice 的加权组合解决类别不平衡
水体提取的类别不平衡问题比常规分割任务更严重。以 2 米分辨率的高分影像为例,一景 20 公里乘以 20 公里的范围里,水体往往只占 2%~5% 的面积。如果用纯交叉熵损失,模型只要全输出背景就能拿到 95% 以上的准确率,但水体一条都提不出来。必须引入对正负样本不敏感的损失函数,或者显式加大错分正例的惩罚。
最稳定的组合是 BCE 加 Dice Loss 的加权和。Dice 系数直接优化的是分割结果与标签的重叠度,等价于在优化 IoU 的上界。实现如下:
class CombinedLoss(nn.Module): def __init__(self, bce_weight=0.5, dice_weight=0.5): super().__init__() self.bce_weight = bce_weight self.dice_weight = dice_weight def forward(self, logits, targets): bce = nn.functional.binary_cross_entropy_with_logits(logits, targets) probs = torch.sigmoid(logits) smooth = 1e-6 intersection = (probs * targets).sum(dim=(1, 2, 3)) dice = 1 - (2 * intersection + smooth) / ( probs.sum(dim=(1, 2, 3)) + targets.sum(dim=(1, 2, 3)) + smooth ) return self.bce_weight * bce + self.dice_weight * dice.mean()bce_weight和dice_weight我一般初始各取 0.5。如果你发现训练曲线里 loss 波动很大,可以考虑把 BCE 权重降到 0.3,Dice 提到 0.7——Dice 的梯度方向更贴合分割质量本身。公式里的smooth是平滑项,防止分母为 0,这个值在线段类小目标上可以调大到 1e-3。Dice Loss 的问题是梯度在困难样本上不够尖锐,所以有人用它和 Focal Loss 搭配,但 Focal Loss 的 α、γ 超参数又要多一轮调参,我建议先跑通 BCE+Dice 再考虑加码。
4.3 训练循环、优化器与评价指标的可复现配置
训练环节的配置直接影响收敛速度。我用 AdamW 优化器加余弦退火学习率,这种组合在分割任务上比 SGD 加阶梯下降更省心。常用参数是lr=3e-4、weight_decay=1e-4,训练 100 个 epoch,配合早停在验证集 IoU 连续 15 个 epoch 不提升时停止。batch size 在 256 输入、单卡 24G 显存下可以开到 16,如果显存不够就先降到 8,同时把 BatchNorm 换成 GroupNorm,否则小 batch 下的 BN 统计量不稳定。
训练循环的关键代码:
import torch from torch.utils.data import DataLoader, Dataset class WaterDataset(Dataset): def __init__(self, img_paths, lab_paths): self.img_paths = img_paths self.lab_paths = lab_paths def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = np.load(self.img_paths[idx]).astype(np.float32) lab = np.load(self.lab_paths[idx]).astype(np.float32) return torch.from_numpy(img), torch.from_numpy(lab).unsqueeze(0) model = UNet(in_channels=5, num_classes=1).cuda() optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) criterion = CombinedLoss(bce_weight=0.5, dice_weight=0.5) for epoch in range(100): model.train() epoch_loss = 0.0 for img, lab in train_loader: img, lab = img.cuda(), lab.cuda() optimizer.zero_grad() logits = model(img) loss = criterion(logits, lab) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() epoch_loss += loss.item() scheduler.step()clip_grad_norm_(max_norm=5.0)这一行容易被忽略,但它很关键——遥感影像里偶尔会出现异常像素值,导致梯度爆炸。剪裁后训练曲线稳定很多。CosineAnnealingLR的T_max要和总 epoch 数一致,它会把学习率按余弦曲线从初始值降到接近 0。如果你计划训练 200 轮,这个参数也要改成 200。
评价指标不能只用准确率,要重点盯 IoU 和 F1。IoU 的计算要小心,一个常见的坑是直接用混淆矩阵算而没有对每类分别加平滑。参考实现:
def compute_iou(pred, target, smooth=1e-6): pred = (pred > 0.5).float() intersection = (pred * target).sum() union = pred.sum() + target.sum() - intersection return (intersection + smooth) / (union + smooth)训练过程中每个 epoch 结束都要在验证集上算一次 IoU,以验证集 IoU 最高的权重作为最终模型保存。注意保存的是model.state_dict()而不是整个模型对象,后者在不同版本的 PyTorch 之间兼容性很差。还要记录验证集上表现最好的 epoch 号,方便回溯训练配置。
5. 水体提取模型避坑指南:五个把训练搞崩的常见细节
5.1 山体阴影误检:模型把影子当成了水
现象:预测图上整片山体背阴坡被标成水体,形成大面积伪多边形,尤其在西部高山峡谷区域,误检面积甚至超过真实水体。
原因:山体阴影和水体在光谱上高度相似,绿波段反射较高、近红外吸收较强,NDWI 和深度学习提取的光谱特征都会把这个区间误判。如果训练样本里山体阴影区域没有专门标注为背景,模型无法区分这两个类别。
解决:最有效的是在数据里显式加入阴影负样本。做法是把含阴影的影像瓦片单独划分为一个难例集,保证训练数据里「水体和阴影同时存在」的瓦片有一定占比。另一个通用做法是把 NDWI 作为额外输入通道,让网络同时比对光谱指数和原始光谱的差异——阴影的 NDWI 值虽然高,但其多波段光谱分布和水体仍有细微差异,网络有原始波段输入就能学到这一层区分。我后来把这两种方式配合用,阴影误检率能下降 50% 左右。
5.2 水体占比太小:准确率 99% 但漏检所有细流
现象:训练过程正常,loss 稳步下降,验证集准确率达到 99% 以上,但目视检查预测图,发现细小河流、窄沟渠全部漏检,只提出来大面积湖面。
原因:水体像素占比太低,模型在损失函数驱动下优先把绝大多数像素判为背景,因为这样对 loss 的贡献最小。准确率指标完全失真——99% 准确率只需要把所有像素预测成背景就能达到。
解决:换用第四节说的 BCE+Dice 组合,Dice 直接惩罚「预测水体部分和真实水体部分不重叠」的错误。也可以对训练数据做硬负样本挖掘:只保留水体占比在 0.05 到 0.6 之间的瓦片,滤掉那些几乎没有水体的纯背景块。还有一个偏门但有效的办法是镜像增强时多加小角度旋转,让细长水体出现更多朝向,避免模型只记得水平方向的河道。排查这个问题时,不要看准确率,直接看验证集水体类别的 IoU,这个指标掉下去基本就是类别不平衡在捣鬼。
5.3 大影像推理显存溢出:滑窗推理的拼接缝问题
现象:训练阶段模型跑得很顺,但到了预测整景影像时,显存爆掉。切块预测后,把瓦片拼回完整图,接缝部位出现明显的十字形亮度不一致或水体断裂。
原因:高分影像在推理时同样不能整图输入,必须滑窗预测。但每块瓦片边缘的卷积计算缺少邻域信息,尤其 3×3 卷积和池化层让边缘预测结果不如中心可靠。拼图时直接拼接会导致接缝两侧预测置信度不同,显式表现就是拼缝线。
解决:推理时用重叠滑窗,步长设为窗口的一半。支撑做法是每个像素被多个窗口覆盖,最终置信度取所有覆盖窗口预测的平均值。比如 256 的窗口,推理步长设 84 或 128,预测概率图加权平均后再阈值化。
def predict_full_image(model, full_img, tile_size=256, stride=128): model.eval() h, w = full_img.shape[1], full_img.shape[2] prob_map = torch.zeros((1, h, w), device="cuda") count_map = torch.zeros((1, h, w), device="cuda") for row in range(0, h - tile_size + 1, stride): for col in range(0, w - tile_size + 1, stride): tile = full_img[:, row:row+tile_size, col:col+tile_size].unsqueeze(0) with torch.no_grad(): prob = torch.sigmoid(model(tile.cuda())) prob_map[:, row:row+tile_size, col:col+tile_size] += prob count_map[:, row:row+tile_size, col:col+tile_size] += 1 prob_map /= count_map return prob_mapcount_map记录每个像素被多少个窗口覆盖,重叠区域预测被平均,边缘区域因为覆盖次数少而置信度偏低的问题也被摊平。推理显存不够时,把tile_size从 256 降到 224,或者把输入切成半精度fp16推理,这能在几乎不掉 IoU 的情况下省一半显存。
5.4 标注口径不一致:验证集分数剧烈波动
现象:训练集和验证集来自同一批数据,但验证 IoU 忽高忽低,某几个 epoch 突然掉十几个点,检查代码又没发现逻辑错误。
原因:标注规范在各景影像之间不一致。有人把水田、养殖塘标为水体,有人把云影、潮湿裸土也标为水体,还有人只标开阔水面。模型学到的是一团混合语义,验证集里恰好遇到不同标注风格的瓦片时分数就会剧烈波动。
解决:在数据准备阶段明确标注口径,写一份简短的标注规范。我的默认标准是「常年性开阔水面 + 河流主干 + 坑塘水面标注为正,水田、季节性积水、云影、阴影标注为负」。每景影像标注完成后,用一个统计脚本检查正样本占比,如果不同影像之间水体占比差异超过一倍,就要回查标注。另外,把标注规范里容易混的案例截图归类成「易错清单」,多人标注时统一参照。这个问题在自建数据集的项目里几乎必然出现,处理得越早,后面重标成本越低。
5.5 切瓦片丢地理坐标:预测结果无法叠加到 GIS
现象:模型预测结果 rasterio 写成 GeoTIFF 后,在 ArcGIS 或 QGIS 里打开完全错位,或者根本无法叠加到底图上。
原因:切瓦片阶段,np.save把地理变换和投影坐标全部丢掉了,预测得到的是像素坐标下的结果,回写时没有恢复原始仿射变换矩阵。
解决:切瓦片时保存一个元数据 JSON,记录瓦片行列号对应的原影像行列起始位置。推理完成后,把每个瓦片概率图写回一个大矩阵的对应位置,然后连同原影像的transform和crs重新写 GeoTIFF。
with rasterio.open("result.tif", "w", driver="GTiff", height=prob_map.shape[0], width=prob_map.shape[1], count=1, dtype="uint8", transform=src_transform, crs=src_crs) as dst: dst.write((prob_map > 0.5).astype("uint8") * 255, 1)transform和crs必须是原始影像的,不能手动拼接,否则坐标偏移。这个问题的隐蔽性在于训练过程完全不受影响,直到最后成果交付才爆发。
6. 水体提取结果的后处理与矢量化:从栅格到可提交的成果
模型输出的栅格掩膜只是半成品。高分影像分辨率高,预测结果往往存在两类瑕疵:一类是细小碎斑——个别像素被孤立地判成水体;另一类是边界锯齿——水体边缘粗糙,不够贴合真实岸线。针对这两类问题,我做最后一步处理时通常会按顺序执行:形态学滤波去掉碎斑,然后用闭运算填充细小的内部孔洞。逻辑是先腐蚀再膨胀,参数用 3×3 结构元素,迭代 1 次。迭代次数别超过 2,否则会把本来就细的河流完全抹掉。
处理完栅格后,下一步是转矢量。栅格结果在 GIS 里做面积统计、拓扑检查都不方便,水利和国土业务普遍要求 SHP 或 GeoJSON 格式的成果面。做法是 GDAL Polygonize,核心参数是字段值——把预测值 1 的部分转成面要素:
gdal_polygonize.py result.tif -f "ESRI Shapefile" water_polygons.shp转出的矢量图斑里会包含大量面积只有几个平方米的碎屑多边形,需要用矢量库按最小面积过滤。以 2 米分辨率为例,我通常把阈值设在 500 平方米,低于这个面积的图斑直接删除,它们九成是误检的孤立像素团。过滤后还要做一步简化边界,用 Douglas-Peucker 算法的容差设 2 米,把边界锯齿抹平的同时保留真实岸线的弯曲度。这一步做完,成果就能直接进 GIS 做接边、拓扑和专题图了。
这套流程跑通之后,你的研究成果就具备从 demo 落地成工程的能力。回头强调一下最值得记住的教训:水体提取模型的精度瓶颈通常不在网络结构,而在地理坐标错乱、标注口径不一、阈值和拼接方式这些工程细节上。我第一次做这个项目时,在模型调参上花了三周,最后发现把重叠推理和 NDWI 先验通道加上,比换任何网络都涨点多。希望帮到你。
本文还有配套的精品资源,点击获取