
简介基于深度学习的高分辨率城市遥感图像水体提取项目是一套完整可运行的 Python 源码包面向计算机视觉、人工智能等相关专业的在校学生与开发者可直接用于毕业设计、课程大作业也可作为遥感图像分析入门实践的练手项目。压缩包共 27 个文件包含 11 个 Python 脚本负责模型定义、数据加载、训练求解、评估与图像增强等、13 张网络结构与效果示意图、1 个 CSV 结果文件、1 份项目介绍文档以及一个训练好的 PTH 权重文件整体体积仅 726KB结构清晰便于按模块调用。项目内置 U-Net 与 Attention U-Net 两种分割网络支持加载预训练权重对高分辨率城市遥感图像进行水体区域预测并提供数据预处理、训练测试、结果可视化等完整流程。训练集、验证集与预测结果在包内按目录分开存放可对照项目文档直接复现实验或继续调优迁移到其他数据集时也只需调整数据加载路径。已有 494 人学习该资源适合需要快速构建完整水体提取流程并产出课设或毕设演示的学习者。1. 高分辨率城市遥感水体提取为什么深度学习方案在城区里最容易翻车城市里的水体提取一直是遥感分割里最“卷”的任务之一。你用传统 NDWI 指数在郊区水库上跑效果还能看但一旦换到高分辨率城市影像建筑物阴影、暗色屋顶、沥青路面和细小河道搅在一起阈值怎么调都有人工痕迹。这个标题里的“基于深度学习实现的高分辨率城市遥感图像水体提取”正是要把这块硬骨头啃下来做法就是自己训练一个分割模型把每一个像素判断成“水”或“非水”。源码用 Python 组织常见的落点是 PyTorch 或 Keras 写成 U-Net、DeepLabV3 这类语义分割网络交给显卡训练再对整幅影像做推理。适合的读者很明确手里有高分影像数据、想绕过手工阈值、愿意动手标一批标签并接受训练周期的人。这篇博文就按我平时做项目的顺序来写先把数据准备讲透再给出可直接改的模型与训练源码然后是推理后处理最后把我在城区水体上踩过的坑挨个摆出来。主线只有一个——让你拿到一份源码后能跑通、能调、能知道结果到底行不行。顺带说一句最近大家喜欢用 Codex 之类的工具跑深度学习代码但那种工具只能帮你补语法模型能不能收敛还得靠你对数据分布的理解这一点后面会反复提到。2. 从数据到标签先把训练集做对模型才有得学2.1 影像源与分辨率选择什么数据能喂给模型模型不挑数据格式挑的是分辨率和波段组合。做城市水体提取我一般优先用 0.5 到 2 米分辨率的影像比如高分二号、GeoEye、WorldView 系列或者无人机正射影像。分辨率太低比如 10 米 Sentinel-2城市里的小河道和池塘在几个像素里根本看不出来分辨率太高0.1 米以下同一片水体会出现波纹、反光、船只反而增加标注难度。所以 0.5~2 米是个折中区间水体边界足够清楚类别特征也相对稳定。波段选择上常见做法是使用 R、G、B 加近红外NIR四个波段因为水体在近红外波段吸收强烈即使阴影里水体的 NIR 反射率也显著低于植被和土壤。有些影像还会提供红边波段可以一并放进去。如果只有 RGB 三波段也可以训练但阴影区的误检会明显上升建议在数据增强里加入光谱扰动来弥补。预处理阶段我通常会做辐射定标和大气校正如果嫌麻烦至少要做直方图匹配让不同时相的影像亮度分布一致。这些处理可以直接用 GDAL 或 rasterio 完成不需要进入深度学习流程。2.2 标签制作与样本裁剪用 QGIS 和 Python 把标注变成训练数据没有标签就没有深度学习。城市水体标注的痛点是细小水体太多我一般采用“半自动标注 人工修正”的路子先用 NDWI 粗提一遍转成矢量再在 QGIS 里人工修掉错分和漏分最后栅格化为单波段 mask。这样比纯手工快很多而且 NDWI 粗提能帮你找到大多数水面。注意 mask 的类别要定义清楚我习惯把 0 设为背景1 设为水体255 设为忽略区比如云影边界、无法确认的区域这样训练时可以直接抑制不确定区域。拿到矢量和影像后下一步是裁剪成训练样本。高分辨率影像动辄上亿像素不可能整图喂给网络需要滑窗裁剪成 256×256 或 512×512 的小块。窗口大小取决于 GPU 显存和模型感受野256 适合快速验证512 适合最终训练。重叠率我一般设在 20% 到 50% 之间避免水体正好被切在边缘而破坏标签连续性。下面是裁剪脚本的核心部分import rasterio import numpy as np from rasterio.windows import Window def slide_crop(image_path, mask_path, out_dir, window_size512, overlap0.25): with rasterio.open(image_path) as src_img, rasterio.open(mask_path) as src_mask: height, width src_img.height, src_img.width step int(window_size * (1 - overlap)) x_steps range(0, width - window_size 1, step) y_steps range(0, height - window_size 1, step) for i, x in enumerate(x_steps): for j, y in enumerate(y_steps): win Window(x, y, window_size, window_size) img src_img.read(windowwin) # shape: (bands, h, w) mask src_mask.read(1, windowwin) # shape: (h, w) # 跳过水体占比过低的样本减少类别不平衡 water_ratio (mask 1).mean() if water_ratio 0.005: continue np.savez_compressed( f{out_dir}/patch_{i}_{j}.npz, imageimg.astype(np.float32), maskmask.astype(np.uint8) )逻辑说明先用 rasterio 打开影像和 mask按重叠率计算步长遍历每个窗口。读取的影像波段数保持原始顺序mask 只读第一波段。裁剪后计算水体像素占比低于 0.5% 的样本直接扔掉避免训练集被大量纯背景块淹没。最后压缩保存为 npz方便后续加载。参数说明window_size512要跟显卡显存匹配6G 显存跑 U-Net 512 输入需要小 batchoverlap0.25是常用折中重叠太小会丢失跨块目标太大则训练样本高度重合导致过拟合。water_ratio阈值可以按场景调如果你希望模型学会找细小水体阈值不要设太高如果只关心大面积水体可以提高到 0.05。另外我强烈建议在裁剪前检查影像和 mask 是否严格对齐很多翻车都是因为影像经过重投影后位移了一个像素视觉上无感但模型会学到模糊边界。对齐方法很简单用 rasterio 对比两个文件的 transform 和分辨率不匹配就先重采样。2.3 数据增强让模型在阴影、季节变化下不那么脆城市水体的“长相”变化很大夏天是绿色浊水冬天是灰褐色阴影里的水偏黑阳光直射的水面可能产生镜面反射。如果训练数据太“干净”模型很容易在新增影像上翻车。我常用的增强手段包括随机水平/垂直翻转、90 度旋转、随机亮度对比度调整、高斯模糊、随机擦除模拟云和建筑阴影遮挡。其中随机擦除对水体提取特别有效因为城市里水体常被桥、树、建筑物部分遮挡模型不能只看到完整的水面才认得出。下面是增强部分的实现import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.RandomRotate90(p0.5), A.Flip(p0.5), A.RandomBrightnessContrast(brightness_limit0.2, contrast_limit0.2, p0.5), A.GaussNoise(var_limit(10.0, 30.0), p0.3), A.CoarseDropout(max_holes4, max_height32, max_width32, fill_value0, p0.3), A.Normalize(mean(0.5, 0.5, 0.5, 0.5), std(0.5, 0.5, 0.5, 0.5)), ToTensorV2() ])逻辑说明A.Compose把多个增强算子串起来训练时每次随机执行一部分。CoarseDropout会在图像中随机挖掉小块区域以 0 填充模拟云层或建筑遮挡注意这里fill_value0用的是归一化前的像素值如果你直接喂原始 DN 值就要改成 255 或背景均值。Normalize的均值和标准差先简单设为 0.5后续可以根据数据统计替换。参数说明brightness_limit0.2表示像素亮度最大变化 20%这个范围比较安全过大会让水体与阴影彻底混淆。max_holes4是挖洞数量max_height32对应窗口 512 时约 6% 的边长这个尺寸能模拟桥梁、船只等小遮挡物。增强不是越多越好尤其是高斯噪声加多了会让模型误把噪声当作纹理特征所以 p 值都控制在 0.3 以下。实现增强时我唯一要提醒的是mask 必须和 image 做同样的几何变换albumentations 会自动同步但如果你自己写翻转循环忘了同步 mask 就会导致训练时标签错位损失函数一路诡异下跌但验证 mIoU 涨不上去。3. 用 PyTorch 搭一个 U-Net模型结构、损失函数与训练参数3.1 为什么选 U-Net 而不是 DeepLabV3城区小目标对比在水体提取这个任务上我第一选择通常是 U-Net而不是 DeepLabV3。原因有三第一城市水体边界细碎河道、池塘边缘的完成度要求高U-Net 的跳跃连接能把底层纹理直接送到解码端边缘恢复得比空洞卷积更锐利。第二U-Net 结构简单显存占用相对可控即便只有一张消费级显卡也能训练。第三它在少量样本下表现稳定前几轮就能看到明显效果适合快速迭代。DeepLabV3 的优势在于大范围语义一致性比如大片湖泊、成片水体它不太容易漏但训练时间更长而且对边界细节不友好。如果你只有几百张样本我更建议从 U-Net 开始。当然U-Net 不是没有缺点。它的感受野有限对于非常大的连续水面可能出现内部空洞或预测不全。我的做法是在 U-Net 的 bottleneck 层后用空洞卷积替换一部分普通卷积或者干脆用预训练的 ResNet34 作为 encoder本质上是在不增加结构复杂度的情况下提升特征抽象能力。下面给出的代码是一个轻量 U-Net适合快速验证。3.2 最小可跑通的 U-Net 源码从编码器到跳跃连接这里给出一个基于 PyTorch 的 U-Net 实现输入是 4 波段影像输出是每个像素属于水体的概率。模型结构采用经典的双层卷积块加池化下采样通道数从 32 开始逐层翻倍到 256再通过上采样和跳跃连接恢复分辨率。为了适应高分辨率城市影像的细节我把第一层卷积的 stride 保持为 1不做过早降采样。import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch, dropout0.1): super().__init__() self.conv 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) ) self.dropout nn.Dropout2d(dropout) def forward(self, x): return self.dropout(self.conv(x)) class UNet(nn.Module): def __init__(self, in_channels4, n_classes1, init_depth32): super().__init__() self.down1 DoubleConv(in_channels, init_depth) self.down2 DoubleConv(init_depth, init_depth * 2) self.down3 DoubleConv(init_depth * 2, init_depth * 4) self.down4 DoubleConv(init_depth * 4, init_depth * 8) self.pool nn.MaxPool2d(2) self.bottleneck DoubleConv(init_depth * 8, init_depth * 16) self.up4 nn.ConvTranspose2d(init_depth * 16, init_depth * 8, 2, stride2) self.up3 nn.ConvTranspose2d(init_depth * 8, init_depth * 4, 2, stride2) self.up2 nn.ConvTranspose2d(init_depth * 4, init_depth * 2, 2, stride2) self.up1 nn.ConvTranspose2d(init_depth * 2, init_depth, 2, stride2) self.dconv4 DoubleConv(init_depth * 16, init_depth * 8) self.dconv3 DoubleConv(init_depth * 8, init_depth * 4) self.dconv2 DoubleConv(init_depth * 4, init_depth * 2) self.dconv1 DoubleConv(init_depth * 2, init_depth) self.out nn.Conv2d(init_depth, n_classes, 1) def forward(self, x): e1 self.down1(x) e2 self.down2(self.pool(e1)) e3 self.down3(self.pool(e2)) e4 self.down4(self.pool(e3)) b self.bottleneck(self.pool(e4)) d4 self.up4(b) d4 self.dconv4(torch.cat([d4, e4], dim1)) d3 self.up3(d4) d3 self.dconv3(torch.cat([d3, e3], dim1)) d2 self.up2(d3) d2 self.dconv2(torch.cat([d2, e2], dim1)) d1 self.up1(d2) d1 self.dconv1(torch.cat([d1, e1], dim1)) return self.out(d1)逻辑说明DoubleConv是连续两个 3×3 卷积加批归一化卷积后不改变空间尺寸每个下采样块之间用MaxPool2d将尺寸减半。跳跃连接通过torch.cat把编码器特征与解码器特征在通道维拼接这样细粒度边缘信息能跨层传递。ConvTranspose2d是转置卷积上采样也可以换成双线性插值区别是转置卷积可学习但容易产生棋盘格双线性插值更稳但表达力弱一点。最后输出层用 1×1 卷积把通道数压到 1不加 Sigmoid放在损失函数里算。参数说明in_channels4对应 RGBN 四波段如果你只有 RGB就把这里改成 3模型第一层会自动适应。init_depth32是初始通道数显存紧张可以改成 16但模型表达能力会弱一些显存充裕且样本充足时可以改成 64。dropout0.1加在每个卷积块后防止过拟合。这个模型的参数量约 3 千万用 1080Ti 级别的显卡512×512 输入 batch size 8 可以跑得动显存不够就减 init_depth 或输入尺寸。3.3 损失函数怎么选BCE 还是 Dice以及训练超参数水体提取本质上是二分类分割但正负样本往往极度不平衡。在密集城区水体可能只占整幅影像的 2% 到 5%如果直接用 BCEWithLogitsLoss模型可能学成一个“永远输出非水”的傻瓜损失也很低。我的做法是使用 Dice Loss 和 BCE Loss 的加权组合让模型同时优化区域重叠和像素分类。Dice Loss 对类别不平衡天然不敏感但单独使用容易造成训练振荡和 BCE 组合后两害相权取其轻。class CombinedLoss(nn.Module): def __init__(self, bce_weight0.5, dice_weight0.5, smooth1e-6): super().__init__() self.bce nn.BCEWithLogitsLoss() self.bce_weight bce_weight self.dice_weight dice_weight self.smooth smooth def forward(self, logits, masks): bce self.bce(logits, masks.float()) preds torch.sigmoid(logits) preds_flat preds.view(preds.size(0), -1) masks_flat masks.view(masks.size(0), -1) intersection (preds_flat * masks_flat).sum(dim1) dice 1 - (2 * intersection self.smooth) / (preds_flat.sum(dim1) masks_flat.sum(dim1) self.smooth) dice dice.mean() return self.bce_weight * bce self.dice_weight * dice逻辑说明BCEWithLogitsLoss直接吃模型输出的 logits内部包含 Sigmoid数值上比单独算 BCE 要稳定。Dice 计算时先把预测概率和 mask 展平逐样本计算 Dice 系数最后取均值。smooth参数防止水体像素为 0 时除零一般设 1e-6 即可。两个损失的权重相等如果发现训练早期 Dice 波动太大可以临时把dice_weight降到 0.3后期再调回。参数说明训练轮数我一般设 80 到 120 轮优化器用 Adam初始学习率 1e-4。每 10 轮用验证集计算 mIoU如果连续 5 轮不涨就把学习率降到 1e-5。batch size 在显存允许范围内尽量大至少要让每个 batch 里包含几个有水的样本。另外我习惯在训练前统计训练集每个 band 的均值和标准差而不是简单用 0.5 归一化这样模型在真实高分影像上的泛化会好不少。你可以用下面的代码快速统计def compute_channel_stats(dataset_path): means np.zeros(4) stds np.zeros(4) count 0 for root, _, files in os.walk(dataset_path): for f in files: if f.endswith(.npz): data np.load(os.path.join(root, f)) img data[image] # (4, H, W) means img.mean(axis(1, 2)) stds img.std(axis(1, 2)) count 1 means / count stds / count return means, stds这个脚本遍历所有裁剪样本累计各通道均值然后除以样本数。注意这里算的是全局均值适合大多数场景如果影像间亮度差异巨大更稳妥的做法是按影像分别归一化而不是统一到一个均值。4. 模型推理与后处理把像素概率变成干净的水体边界4.1 滑窗推理与重叠拼接如何避免预测图出现“补丁效应”训练完成后要对一整幅高分影像做预测。因为影像太大不能一次输入模型只能滑窗推理。如果直接用无重叠的窗口拼接窗口边界会出现明显的接缝尤其是当窗口内的水体被切成两半时模型在边界上的判断会犹豫。所以我采用重叠推理然后对重叠区域取平均。具体做法是每个窗口预测出的概率图乘以一个窗口权重掩膜权重在窗口边缘低、中心高例如正弦窗或线性斜坡叠加到最终概率图上。def predict_full_image(model, image, window_size512, overlap0.5, devicecuda): model.eval() _, h, w image.shape step int(window_size * (1 - overlap)) # 构建梯形权重掩膜中心权重为1边缘为0 weight np.ones((window_size, window_size), dtypenp.float32) ramp np.linspace(0, 1, step // 2 if step 1 else 1, dtypenp.float32) for i in range(window_size): for j in range(window_size): ui min(i, window_size - 1 - i, j, window_size - 1 - j) weight[i, j] min(1.0, ui / (step // 2 1e-6)) weight weight[None, None, ...] # (1,1,H,W) prob_map torch.zeros((1, 1, h, w), devicedevice) weight_map torch.zeros((1, 1, h, w), devicedevice) with torch.no_grad(): for y in range(0, h - window_size 1, step): for x in range(0, w - window_size 1, step): patch image[:, y:ywindow_size, x:xwindow_size] patch_t torch.from_numpy(patch).unsqueeze(0).float().to(device) out torch.sigmoid(model(patch_t)) prob_map[:, :, y:ywindow_size, x:xwindow_size] out * torch.from_numpy(weight).to(device) weight_map[:, :, y:ywindow_size, x:xwindow_size] torch.from_numpy(weight).to(device) prob_map prob_map / weight_map.clamp(min1e-6) return prob_map.squeeze().cpu().numpy()逻辑说明predict_full_image一次只处理一个 patch遍历整幅图的 y 和 x 坐标。每个 patch 的预测概率乘上权重矩阵再累加到全局概率图中同时把权重矩阵累加到weight_map最终做除法得到平均概率。这样重叠区域内部是多个预测的加权平均接缝处的预测因为权重低而不会突然跳变。如果使用多 GPU 或者 batch 推理可以把 patch 收集到 list 里然后再合并这里为清晰展示单 patch 版本。参数说明overlap0.5是我常用的值能显著减少接缝但推理时间会增加约 1/3。显存不够时可以把window_size降到 256并适当提高 overlap。注意图像边缘不足一个窗口的地方会漏掉所以输入前最好用 0 值把影像 padding 到能被 step 整除的尺寸或者在循环里对边界做 clamp。上面的代码没有处理边界实际项目里我会在开头补一行image np.pad(image, ((0,0),(pad_h,pad_h),(pad_w,pad_w)), modeconstant)。4.2 后处理技巧去掉小连通域、填补空洞、平滑边界模型的原始输出是概率图直接阈值化往往会得到一堆噪点单个像素的误检、水体内部的小空洞、河岸边的碎块。常规后处理我做三步第一步用阈值把概率图变成二值图阈值一般取 0.5但城市水体我有时会用 0.4 来提高召回因为阴影里水体预测概率偏低。第二步用形态学开闭运算去掉小噪点并填充内部空洞。第三步用连通域分析移除面积小于某个阈值的区域例如小于 50 平方米具体像素数按分辨率换算。这里用 OpenCV 实现最顺手import cv2 from scipy import ndimage def postprocess(prob_map, threshold0.5, min_area_pixels50, kernel_size3): binary (prob_map threshold).astype(np.uint8) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 去小噪点 binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 填补空洞 labeled, num ndimage.label(binary) for label_id in range(1, num 1): if np.sum(labeled label_id) min_area_pixels: binary[labeled label_id] 0 return binary逻辑说明MORPH_OPEN是腐蚀后膨胀能去掉孤立点但同时会把细小河道断开MORPH_CLOSE是膨胀后腐蚀能把水体内的桥洞、噪声填上。注意顺序不能反过来先开运算再闭运算对细小水体更友好。ndimage.label给每个连通区域一个编号遍历所有区域凡是面积小于阈值的都清成背景。这样最终得到的水体掩膜就不会有零碎小洞。参数说明min_area_pixels的取值要看分辨率。0.5 米分辨率下1 平方米约对应 4 像素50 像素相当于 12.5 平方米可以滤掉大部分阴影小斑块如果是 2 米分辨率面积阈值要改成 10 像素以内。kernel_size3对 512 分辨率比较合适过大会把细长河道“腐蚀”断过小则滤波效果差。如果发现河道断裂严重可以把 OPEN 这一步去掉只用 CLOSE 和面积过滤。5. 避坑手册城市水体提取最常见的 5 个“玄学”问题城市水体提取这个方向代码能跑通和结果能交付是两码事。我在项目里踩过不少坑很多问题一开始看像模型缺陷实际上出在数据或后处理上。下面这 5 条是我认为最值得写下来的。5.1 阴影误检率居高不下模型把暗处全当水现象训练完成后验证集 mIoU 看着还行但拿到新影像上大面积建筑物阴影和山谷阴影被标成水体有些阴影区域甚至比真水体还完整。原因训练数据里阴影区域的纹理、颜色和水体很相似尤其是在 RGB 影像上。模型没有学到“阴影边缘通常连接建筑或地形”这种空间上下文只学到了“暗色像素像水”。解决第一训练时加入更多带阴影的负样本尤其是建筑物阴影和桥梁阴影第二在特征输入中加入 NDWI 或 NDVI 作为额外通道让模型有光谱先验第三后处理时叠加数字表面模型DSM如果阴影区域的 DSM 高于周边直接剔除。最简单的一条把影像从 RGB 扩展到 RGBN 四波段近红外能大幅区分水体和柏油路阴影。5.2 细小河道断断续续预测结果像虚线现象河道宽度只有 3 到 5 米模型在细河段出现间歇性漏检视觉上河道成了虚线。原因训练样本里细水体占比少且滑窗裁剪时河道正好跨越多个窗口标签不完整。另外特征提取阶段的下采样让小目标信息丢失。解决在数据裁剪时降低water_ratio阈值确保包含细小水体的样本不被过滤将输入分辨率提高到 0.5 米并将init_depth从 32 增加到 48推理时使用 overlap 更高的重叠拼接降低窗口切断河道的影响。如果还有问题尝试用双线性插值替代转置卷积做上采样边缘会更连贯。5.3 训练损失一直降但验证 mIoU 停滞疑似过拟合现象前 20 轮损失从 0.8 降到 0.3之后训练集损失继续降验证集 mIoU 却不涨甚至轻微下降。原因训练样本数量太少模型开始记忆训练集里特殊的光照、季节或背景纹理也可能是数据增强太弱或者 dropout 没有真正起作用。解决增加数据增强强度尤其是随机亮度和色彩抖动把 dropout 提高到 0.3用带有 ImageNet 预训练的 ResNet34 作为编码器可以显著提升百万级参数模型的泛化能力。顺便提一句如果你在 Python 环境里折腾过深度学习库应该知道版本不一致也会导致这类问题——比如用 Codex 帮你跑深度学习代码时生成的 PyTorch 代码可能依赖某个较新 API而你的环境还是老版本模型结构没变但数值行为不同导致验证集表现诡异。遇到这种情况先固定 torch 版本再谈调参。5.4 不同影像域差异过大训练集表现好换一张图就崩现象用某地区高分的影像训练换到另一个城市mIoU 直接跌一半。原因不同传感器、不同季节、不同大气条件导致影像色调差异巨大模型学到的特征是“这组图的色调”而不是“水的物理属性”。解决训练时把多个地区、多个时相的数据混合在一起并做辐射归一化推理前用直方图匹配把新影像的亮度分布对齐到训练集如果条件允许用数据增强里的RandomHSV模拟不同色调。还有一个常见但容易被忽略的点检查影像位深。很多高分影像是 16 位而你训练时如果转成 8 位水体与暗色地物的对比度会进一步恶化。5.5 水体边界锯齿严重矢量后处理无法直接用现象模型预测的 mask 边界毛糙转成矢量后锯齿明显不符合 GIS 入库要求。原因模型输出是像素级分类本就不带平滑约束后处理只做了形态学没有做过边缘平滑。解决在二值化后使用cv2.GaussianBlur对概率图做轻微平滑再二值化或者用cv2.findContours提取边界后用 Douglas-Peucker 算法简化多边形。我常用的平滑方案是对 mask 做一次中值滤波然后用scipy.ndimage.binary_opening去掉细小毛刺。注意不要过度平滑否则会丢失真实水体的曲折边界。6. 最后一步用验证指标和可视化检查你的结果别只看 mIoU在你觉得模型“差不多”之前我建议你用以下流程做一次完整的验证。首先从测试集中随机挑 10 张没有参与训练的样本计算 mIoU、Precision、Recall 和 F1。mIoU 是行业里最爱看的指标但它在类别极端不平衡时有迷惑性——如果水体只占 2%模型全预测背景mIoU 可能也有 60 以上。所以要结合 Precision 和 Recall 看Precision 低说明误检多Recall 低说明漏检多。水域提取项目我通常要求 F1 大于 0.85越高越好。如果某个指标不达标就用下面的代码把预测结果和标签可视化在一起检查错在哪里。import matplotlib.pyplot as plt def visualize_prediction(image, mask, pred, idx, save_pathNone): fig, axes plt.subplots(1, 3, figsize(15, 5)) axes[0].imshow(image[:, :, :3].transpose(1, 2, 0).astype(np.uint8)) axes[0].set_title(Original) axes[1].imshow(mask, cmapgray) axes[1].set_title(GT Mask) axes[2].imshow(pred, cmapgray) axes[2].set_title(Prediction) for ax in axes: ax.axis(off) plt.tight_layout() if save_path: plt.savefig(save_path, dpi300, bbox_inchestight) plt.close()这个代码输出三联图原图、标签、预测。强烈建议你逐张看重点检查三类错误一是阴影里的水体是否漏掉二是桥和码头周围是否误检三是河道较窄处的连续性。可视化比任何指标都能让你快速定位模型弱点。我个人的教训是曾经有一个项目 mIoU 高达 0.92但拿到一张以暗色屋顶为主的城区影像时屋顶被成片识别成水后来才发现测试集里屋顶样本极少模型的暗色纹理特征错配到了水体上。从那以后我养成了一个习惯——每次训练结束一定要拿一张全新的、不同场景的影像做盲测而不是只在测试集上刷指标。盲测不通过宁可继续调数据也不急着交付。希望这篇从数据准备到模型训练再到后处理的笔记能帮到你。水体提取不是玄学但也绝不是把源码跑完就能交差的事。关键还是把标签做扎实、把验证做严格、把后处理方案定好这样你手上的 Python 源码才会变成真正能交付的工具。本文还有配套的精品资源点击获取