简介:这是一份基于深度学习的城市高分辨率遥感图像水体提取Python源码,适合计算机、人工智能、通信工程等专业学生用于毕业设计、课程设计或项目演示。代码包含完整的模型定义、数据加载、训练评估与测试流程,并提供了U-Net与注意力U-Net两种网络结构,可帮助读者理解语义分割在水体提取中的实际应用。压缩包共27个文件,以Python脚本为主(11个py),另含13张PNG图像(用于展示网络结构与提取效果)、1个CSV结果文件、1个MD项目介绍以及1个已训练的模型权重文件,整体仅726KB,轻量便捷。项目还附带了图像增强、数据预处理等实用工具,配合测试脚本可直接运行验证。已有493人学习下载,项目经过测试运行成功,上手门槛较低,适合需要快速搭建遥感图像处理方案或进行二次开发的学习者。
1. 高分辨率城市遥感图像水体提取:从 NDWI 失灵到语义分割的必然选择
城市内涝应急、河湖岸线监测,如今都要求在一两个小时之内从高分辨率遥感影像里把水体边界提出来。过去用 NDWI 指数阈值分割,在空旷自然区域还能用,但到了 0.5 米分辨率的城市影像里,黑色沥青屋面、玻璃幕墙阴影、塑胶跑道都会和水体产生同样的光谱响应,传统方法几乎不可交付。深度学习模型的主要优势不是像素级颜色判断,而是懂得看形状、边缘和上下文:水面总是连续、平滑、有岸线轮廓。标题里的“基于深度学习实现的高分辨率城市遥感图像的水体提取 python 源码”指向的正是这种基于语义分割的完整工程包。它适合遥感算法工程师、GIS 开发者和水利信息化实施者,拿到后可以在 Python 环境里完成训练、验证、推理,也能根据城市特有场景重新微调模型。
2. 数据准备与模型选型:U-Net 为什么能吃掉高分影像中的细碎水网
水体提取项目里最耗时间的部分往往不是模型代码,而是数据准备。高分辨率城市遥感影像的特点是地物碎片化:河面被桥梁打断,池塘边停着集装箱,高层建筑阴影横跨半个水面。如果数据切分和增强策略不够稳,后面模型多少都会出问题。这一章先把模型选型和输入侧的关键决策讲清楚,并给出可以抄走的裁剪脚本。
2.1 模型选型:U-Net 是默认起点,不是唯一解
水体提取本质是逐像素二分类问题。FCN 可以端到端预测,但连续下采样后,细小河流和池塘的边界信息在解码阶段很难完全恢复。SegNet 记忆编码阶段池化位置,对噪声有抵抗,但对多尺度水体不够稳。DeepLabv3+ 的 ASPP 模块能捕捉多尺度上下文,在城市大湖面场景表现很好,但模型重,显存占用高,CPU 部署也麻烦。
U-Net 之所以被多数开源水体提取项目作为默认结构,核心在于跳过连接。编码器下采样得到语义特征,解码器上采样恢复空间分辨率;跳跃连接把浅层边缘信息直接送回到解码器,边缘细节不容易丢。对高分辨率影像里几米宽的支流和碎小池塘,这种结构天然更友好。常见做法是编码器用 ResNet34 或 ResNet50 预训练权重,解码器保持 U-Net 对称结构;如果训练数据只有几百张 patch,从头训练一个更轻的 U-Net 反而比加载 ImageNet 权重更可控。
从参数角度看,一张 512×512 RGB patch,以 ResNet34 为编码器的 U-Net 参数量约 24M,单样本前向显存约 1.2G。而 DeepLabv3+ 在相同输入下通常要 1.8G 以上。如果只有一块 8G 显存,ResNet34-U-Net 配合 batch size 4 能跑起来,DeepLabv3+ 就很难同时保 batch 和 patch 尺寸。所以我在工程实施时,第一版模型基本固定为 ResNet34-U-Net,后面根据边界精度再决定是否换更强 encoder。
2.2 裁剪高分影像:从整景 TIFF 到 512×512 训练样本
高分辨率遥感影像动辄上万像素宽,整幅送进 GPU 既不现实也没必要。通常先按固定 patch 裁剪,并且把标签做成同分辨率栅格。如果标签是河流或水库的 GeoJSON,需要先用 rasterio.features.rasterize 转成和原图对齐的掩膜;再检查投影和采样范围,否则后面训练数据全是错位。常见做法是用 GDAL 或 rasterio 读取原始 TIFF,直接按窗口读取,减少内存压力。
下面是裁剪脚本的核心部分,和常见工程里的预处理脚本逻辑一致:
import numpy as np import rasterio from rasterio.windows import Window def make_train_patches(image_path, label_path, out_dir, patch_size=512): with rasterio.open(image_path) as src_img, rasterio.open(label_path) as src_lbl: width, height = src_img.width, src_img.height for i in range(0, height, patch_size): for j in range(0, width, patch_size): win = Window(j, i, patch_size, patch_size) img = src_img.read(window=win, boundless=True, fill_value=0) lbl = src_lbl.read(window=win, boundless=True, fill_value=0) img = np.transpose(img, (1, 2, 0)) lbl = np.squeeze(lbl) if (lbl == 1).sum() < 512: continue np.savez_compressed( f"{out_dir}/patch_{i}_{j}.npz", image=img.astype("float32"), label=lbl.astype("uint8"), )逻辑说明:Window(j, i, patch_size, patch_size)的第一个参数是列起点(x),第二个参数才是行起点(y),和影像处理库的约定一致;boundless=True允许窗口超出图像边界,超出部分自动用 0 填充,省去手工 pad。每个 patch 保存为 npz 而不是 tif,是为了训练时 IO 更快,直接用 np.load 就能拿到数组。
参数说明:patch_size=512是我做城市高分影像的默认值,分辨率为 0.5 米时约覆盖 256 米范围,既能覆盖一层楼体阴影,又不至于把整条大江全塞进一个 patch;如果显存只有 8G 并打算用 ResNet34-U-Net,patch 降到 384 更稳。标签中水体像素少于 512 的 patch 直接跳过,是为了避免大量全背景样本拖慢收敛;如果任务包含大量小池塘,阈值可以降到 64,但要配合过采样。
容易被忽视的一个细节:裁剪步长不要总等于 patch 大小。我一般保留 15% 重叠,让断裂在 patch 边缘的水体在相邻 patch 中仍有机会被完整看到。代码里把步长改成int(patch_size * (1 - overlap_ratio))即可。
2.3 归一化与增强:高分影像水体的“玄学”输入通道
遥感影像的归一化不要照搬 ImageNet 的均值方差。高分影像的光谱分布和自然照片完全不同,常见做法是先统计训练集每个通道的均值和标准差,再把影像标准化到 0 均值和单位方差。这个统计值要保存下来,推理时用同一组参数。如果统计来自全部 patch,输入顺序要一致。
增强策略直接影响模型对城市阴影的耐受度。我常用的增强包括:随机水平翻转、垂直翻转、90 度旋转、亮度扰动、轻微高斯模糊。这里不建议随机缩放。用线性插值缩放影像不会改变语义,但会改变水体边缘的硬边界,标签中会出现 1-2 像素的灰色过渡区,模型容易学会“模糊边缘=水”,推理时反而对真实锐边界不敏感。
对城市阴影问题,可以在增强中加一个“随机暗化”分支:把整个 patch 的 RGB 乘上 0.5 到 0.8 的系数,模拟部分阴影状态。这比单纯收集阴影样本成本低,也能明显降低误检率。另外,如果影像有近红外波段,强烈建议把RGB+NIR四通道作为输入,并额外计算 NDWI 作为第五个通道。NDWI 对薄云和暗区有响应差异,模型拿它做参考能更好地区分水体与阴影。只是这个操作必须保证训练和推理时通道顺序完全相同,否则模型一上线就废。
数据划分上,不要按 patch 随机分训练验证,因为同一景影像相邻 patch 高度相似,验证 IOU 会虚高。更好的划分单位是原始影像或行政区/瓦片范围,让验证集完全由未见过的区域组成。这也是我在第 5 章会再展开的一个坑。
3. 用 PyTorch 训练水体提取模型:训练脚本、损失函数与 3 组关键参数
有了裁剪好的 patch,下面进入源码包的核心:训练脚本。训练脚本要解决三件事:数据装载、损失计算、超参控制。这一章把训练主流程拆开,并解释每个参数背后为什么这样设。
3.1 训练主流程代码:从 DataLoader 到反向传播
训练代码并不复杂,但要稳定运行还需要在细节上打磨。下面是可复现的训练循环主体,也是大多数水体提取项目的骨架:
import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset from torch.optim import AdamW class WaterDataset(Dataset): def __init__(self, file_list, mean, std): self.file_list = file_list self.mean = mean self.std = std def __len__(self): return len(self.file_list) def __getitem__(self, idx): data = np.load(self.file_list[idx]) image = data["image"] label = data["label"] image = torch.from_numpy(image).permute(2, 0, 1).float() image = (image - self.mean) / (self.std + 1e-8) label = torch.from_numpy(label).float() return image, label model = create_unet(encoder="resnet34", in_channels=3, out_channels=1) model.cuda() optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) dice_loss = DiceLoss() bce_loss = nn.BCEWithLogitsLoss() for epoch in range(epochs): model.train() for images, labels in dataloader: images, labels = images.cuda(), labels.cuda() logits = model(images) loss = bce_loss(logits, labels.unsqueeze(1)) + dice_loss(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() torch.save({"model_state": model.state_dict(), "epoch": epoch}, f"checkpoints/unet_{epoch:03d}.pth")逻辑说明:数据集返回的标签是(B,H,W),但模型输出是(B,1,H,W),所以要用labels.unsqueeze(1)扩展维度,否则 BCELoss 会报 shape 错误。BCE 和 Dice 直接相加,dice_loss内部会对 logits 做 sigmoid,不需要提前归一化。每个 epoch 保存 checkpoint,是为了后面做早停和多候选回退。
参数说明:in_channels=3对应 RGB,如果你按 2.3 加了 NDWI 通道,要改成 4;lr=1e-4是 AdamW 比较稳的起点,配合weight_decay=1e-5避免过拟合;batch size 取决于显存,patch=512 时 bs=8 在 8-10G 显存上比较紧,我通常先保 batch=8,不行再降 patch,因为小 patch 会让模型看不到大阴影的全貌。训练循环里我还会额外加两行代码:torch.nn.utils.clip_grad_norm_(model.parameters(), 5)和 AMP 混合精度scaler.scale(loss).backward(),第一次跑通时可以不加。
3.2 损失函数细节:BCE 与 Dice 为什么不能省一个
城市水体分割最大的痛点是类别不均衡。在一个 512×512 的 patch 里,水体可能只占 3%,纯 BCE 会让模型很自然地把所有像素预测成背景,因为即使全猜错,损失也能降到很低的水平。Dice 损失直接关注预测和水体掩膜的交叠程度,对正样本不敏感,能迫使模型去抓住那些稀疏的水体区域。
但单独使用 Dice 也有问题:训练早期 Dice 梯度很大,容易振荡。把 BCE 和 Dice 加在一起,BCE 保持像素级的稳定拟合,Dice 负责拉高整体重叠度,两者互补。下面是常用的 DiceLoss 实现:
class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, logits, targets): probs = torch.sigmoid(logits) probs = probs.reshape(probs.size(0), -1) targets = targets.reshape(targets.size(0), -1) intersection = (probs * targets).sum(dim=1) union = probs.sum(dim=1) + targets.sum(dim=1) dice = (2 * intersection + self.smooth) / (union + self.smooth) return 1 - dice.mean()逻辑说明:把每个样本的特征图拉平成(N,)向量,计算每个样本的 Dice 再求平均。smooth在目标整体为 0(全背景 patch)时防止分母为 0。如果按 2.2 做了前景占比过滤,全背景 patch 很少,但也不是绝对没有。
参数说明:smooth一般取 1.0,不需要调大,太大的平滑会让损失对边界不敏感。如果使用 batch 级 Dice,batch size 不同时损失波动很大;我建议按样本计算,也就是代码里的dim=1版本。目标是多类时,DiceLoss 要拆成多类分别算,但水体提取是二分类,单通道输出就够了。
3.3 学习率、batch size 与多尺度输入:3 个参数背后的实际效应
很多人把学习率当成玄学,其实训练水体提取模型时,它是最容易出问题的超参数。我一般先用1e-4跑 20 个 epoch,如果训练 loss 一直平稳下降,就继续;如果 loss 在前几个 epoch 出现振荡,就把学习率降到2e-5,并把 weight decay 调回1e-5。若使用OneCycleLR,初始峰值学习率可以给到3e-4,但总 epoch 数必须足够,否则后半程会欠拟合。
batch size 对最终精度的影响不是单调的。较大的 batch(16 或 32)让梯度更平滑,训练更稳定,但在城市高分影像上,每个 patch 间的水体形态差异很大,过大 batch 会让模型学到“平均”的水体形状,对细碎目标反而不利。我通常固定 batch size 为 8,并用梯度累积凑等效 batch。如果显存不够,不要试图把 patch 从 512 缩到 256 硬扛,因为池化下采样后,16 像素宽的池塘在 256 输入下只剩 8 像素,模型几乎看不见。
多尺度输入是一个有效但不绝对必要的技巧:每个 epoch 随机把 patch 缩放 0.8、1.0、1.25 三档,再剪裁回 512×512,可以让模型对不同传感器的地面分辨率更鲁棒。但前面提到过,缩放会让水体边缘出现过渡带;如果标签二值化不干净,模型会学到一条模糊边缘。我的做法是只在训练初期做多尺度,最后 10 个 epoch 切回固定尺度,这样模型既能泛化,又保留了清晰边界输出。
4. 推理与面积统计:把模型输出的掩膜转成可量算的水域矢量
训练后的模型用于实际影像时,不能一次性把整景影像塞进显卡。推理阶段要解决三个问题:如何把大图拆开预测再拼回去、如何清理预测噪声、如何把掩膜转成可统计面积的矢量。这一章给出常用脚本和参数。
4.1 推理脚本:滑动窗口预测与重叠融合
整景高分影像通常超过 1 万像素边长,显存放不下。常见做法是滑动窗口预测,并在窗口重叠区域取平均。重叠比例能有效避免 patch 边缘出现接缝。推理脚本核心如下:
def slide_predict(model, full_img, patch_size=512, overlap_ratio=0.15): model.eval() h, w = full_img.shape[:2] prob_map = np.zeros((h, w), dtype=np.float32) count_map = np.zeros((h, w), dtype=np.float32) step = int(patch_size * (1 - overlap_ratio)) with torch.no_grad(): for y in range(0, h, step): for x in range(0, w, step): patch = full_img[y:y+patch_size, x:x+patch_size] pad_y = patch_size - patch.shape[0] pad_x = patch_size - patch.shape[1] patch = cv2.copyMakeBorder(patch, 0, pad_y, 0, pad_x, cv2.BORDER_CONSTANT, value=0) # 这里要与训练时完全一致的mean/std归一化 patch = (patch - mean) / (std + 1e-8) patch_tensor = torch.from_numpy(patch).permute(2, 0, 1).unsqueeze(0).float().cuda() out = torch.sigmoid(model(patch_tensor)[0, 0]).cpu().numpy() # 去掉边缘填充,只保留有效区域 out = out[:min(patch_size, h - y), :min(patch_size, w - x)] prob_map[y:y+out.shape[0], x:x+out.shape[1]] += out count_map[y:y+out.shape[0], x:x+out.shape[1]] += 1 prob_map /= np.maximum(count_map, 1) return prob_map逻辑说明:step按重叠 15% 计算,也就是从第二个窗口开始会滑动到上一个窗口内 15% 像素的位置,重叠区域每个像素会有两到三次预测结果,最后求平均。边缘不足时用 0 填充,但填充区域不参与拼接;(image - mean) / (std + 1e-8)必须使用训练时保存的统计量,不能重新算整幅图,否则通道分布不同,模型输出概率会被整体拉偏。
参数说明:overlap_ratio取 0.15 是速度与接缝的折中;如果目标是河流、水网密集地区,建议调到 0.25。推理时可以用model.half()开启 FP16,速度接近翻倍,但要注意如果模型里用了 BatchNorm,必须保持model.eval()状态,否则 BatchNorm 在 FP16 下会重新计算 batch 统计量,精度可能掉得很快。如果显存足够,也可以一次预测多个 patch,但需要额外做 padding 到同一尺寸。
4.2 后处理:去掉噪声小斑块、填补水域内部空洞
模型输出是连续概率图,通常以 0.5 为阈值转成 0/1 掩膜。城市影像里地面噪声多,直接阈值化会留下许多小斑块。常见后处理分三步:连通域面积过滤、形态学闭运算、边缘平滑。以下代码可以直接放在推理脚本末尾:
import cv2 import numpy as np mask = (prob_map > 0.5).astype(np.uint8) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8) for label_id in range(1, num_labels): if stats[label_id, cv2.CC_STAT_AREA] < 40: mask[labels == label_id] = 0 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) mask = cv2.medianBlur(mask, 5)逻辑说明:connectedComponentsWithStats会把预测结果中每个白色连通区域编号,stats[label_id, cv2.CC_STAT_AREA]是该区域像素总数;小于阈值的区域直接置 0,能清掉道路反光形成的小碎片。MORPH_CLOSE先用膨胀补上桥体或船造成的空洞,再用腐蚀恢复外部轮廓。medianBlur对边缘做中值平滑,去掉孤立噪声点。顺序不能反,如果先闭运算再删小斑,桥墩被填掉后的小面积区域仍然会被删除,影响不大;但先删小斑再闭运算,小水面也容易一起消失。
参数说明:面积阈值 40 对应 0.5 米分辨率下约 10 平方米,适合中小城市池塘;如果只关心大水面,可以提升到 200。结构元大小 5×5 在 0.5 米影像上等效 2.5×2.5 米,太小填不住桥洞,太大会把细支流吞掉。如果还要做矢量边界,后处理不要过于激进,否则矢量化出来的岸线会比真实位置偏出好几个像素。
4.3 从像素掩膜到地理坐标面积:投影与比例尺换算
如果只是统计“这片水面占比”,可以用(mask.sum() * pixel_area)。但在地理信息系统里,最终交付往往需要 GeoJSON 或 shapefile。用 rasterio 的 features.shapes 可以直接把掩膜和仿射变换转成多边形:
import geopandas as gpd import rasterio from rasterio.features import shapes with rasterio.open("original.tif") as src: transform = src.transform geoms = ( {"properties": {"raster_val": v}, "geometry": s} for i, (s, v) in enumerate(shapes(mask, mask=mask, transform=transform)) if v == 1 ) gdf = gpd.GeoDataFrame.from_features(geoms) # 转到当地平面坐标系计算面积 gdf = gdf.to_crs("EPSG:32650") # UTM 50N示例,按实际位置选 gdf["area_m2"] = gdf.geometry.area water_area = gdf["area_m2"].sum()逻辑说明:shapes(mask, mask=mask, transform=transform)遍历掩膜上值等于 1 的连通域,生成 Polygon;GeoDataFrame.from_features把几何变成 GeoDataFrame。to_crs("EPSG:32650")是 UTM 投影,以米为单位投影后直接算面积才是平方米。如果原始影像不是带坐标 TIFF,可以先用图像左下角坐标和像元大小构造 transform,但这类操作必须非常小心,坐标偏移一个像素就会导致面积偏差。
参数说明:UTM 带号要按城市经度选,不能随便套;没有 GIS 经验的话,直接用原始影像的投影坐标计算风险很大。一般做法是先用src.crs读取原始投影,再用 pyproj 转成当地高斯投影,再求面积。
5. 避坑清单:城市水体提取最容易翻车的 5 个地方
下面是过去做城市高分影像水体标注和训练时反复遇到的几个问题,按“现象—原因—解决”记录,每一条都能在工程排查时直接对照。
5.1 阴影被当成水体:深层原因与缓解手段
现象:白天阳光充足时,高层建筑阴影、桥梁遮挡区域被输出成成片水体,尤其集中在旧城区。原因:可见光影像中阴影部分的亮度很低,近红外反射也被削弱,和水体的低反射率区间高度重叠,模型如果只靠光谱特征,很容易学到“暗像素=水”这种粗糙规则。解决:首要是给模型更丰富的上下文通道,把 NDWI 作为额外输入,因为 NDWI 对水是正值、对阴影是负值;其次是增强,把样本随机变暗 30%-50%,让模型见过“暗但非水”的样本;最后是结果后处理,对高密度阴影区做连通域过滤,但没法根治。实际项目中只靠后处理,误检率最多下降三成,必须从训练数据层面动手。排查误检位置时,把预测概率图和原始影像叠加,使用 GIS 点选误检区域,能快速判断是阴影还是道路反光。
5.2 薄云遮挡后的漏检:数据增强难解决的分布漂移
现象:多云天气的影像上,水体被漏检成碎片,或概率值普遍低于 0.3。原因:薄云相当于在地表反射率上叠加了一个平滑的低频偏移,改变了水体在可见光波段的表现;训练时如果全部来自晴空数据,推理时就遇到了分布外样本。解决:一种常见做法是在训练时模拟薄云,给随机 patch 乘以一个 0.7 到 1.1 变化的系数,并叠加高斯模糊,模拟云的遮蔽效果;另一种是采集目标区域的多云历史影像做微调。数据增强只能缓解,想要根治需要把去云算法或相对辐射校正放进前处理,但那些方法的成本和稳定性在工程里并不理想。注意:做了薄云模拟后,验证集最好也加入少量真实多云样本,否则验证分数依旧会虚高,等到了部署阶段才会暴露。
5.3 小水体在损失函数里被忽略:加权采样与加权损失
现象:训练收敛后,大河大湖分割得很好,但窄河、池塘边缘断裂或缺失。原因:小水体像素占比太低,BCE 和 Dice 都被大面积背景稀释,模型认为预测成背景也足够“降低损失”。解决:我一般用双重策略。第一重是 DataLoader 级加权采样,按 patch 内前景像素比例排序,前景占比在 1%-10% 的 patch 重复采样;第二重是损失函数加权,把 BCE 的前景权重设为 2 或 3,或者引入 Focal Loss。需要在训练日志里记录每个 patch 的前景占比,否则很难判断增强是否真的覆盖了小水体。Dice 在小目标 patch 上的值很波动,不能因为某个 epoch 看到 Dice 掉到 0.7 就早停,要连续观察 10 个 epoch 或 EMA 平滑后的曲线。
5.4 边缘锯齿与内部空洞:形态学操作的时机要放在最后
现象:模型输出的掩膜边缘像锯齿,水体内部桥梁、船体位置出现空洞,视觉上不干净。原因:逐像素分类本身不具备平滑约束,训练标签如果只标注水体不包含船桥,模型就会把船桥位置当作背景;锯齿是模型边界概率不均的结果。解决:不要在模型输出前做平滑,也不要一上来就膨胀。标准流程是:先阈值化,再连通域面积过滤,然后闭运算填洞,最后中值滤波。如果还要把结果交给矢量软件做“边界简化”,栅格后处理越少越好,否则矢量化出来的边界会偏离真实岸线好几个像素。实际项目中,我会针对不同水体类型分两个后处理分支:细窄河道只做小核中值滤波,大湖面才做闭运算补洞。如果做多期变化监测,后处理核大小必须固定,否则两期之间的面积差会包含伪变化。
5.5 训练和推理尺寸不一致导致精度下降:固定 patch 大小
现象:训练用 512×512 patch,推理时为了省事直接把整景影像 resize 到 512×512,输出的水体边界完全糊掉,河流断成一截一截。原因:resize 相当于把每个水体的尺度压缩了几十倍,模型从未在这个尺度下见过目标;高分辨率影像中 20 米宽的池塘在 resize 后可能只剩 2 像素,网络无法识别。解决:必须用与训练一致尺寸的滑动窗口推理,重叠区域取平均;如果显卡不够,缩小 batch 或 patch 尺寸,但训练和推理的 patch 大小必须统一。另外,如果训练时用了 BatchNorm,推理时即使 patch 尺寸一致,也要记得调用model.eval(),确保 BN 使用 running stats,而不是重新计算 batch 统计量。希望模型适应不同空间分辨率,可以在训练时加入多尺度输入,但推理仍然建议按原分辨率切块,而不是整幅 resize。
6. 验证你的模型:不看测试集也能提前止损的方法
训练结束后,验证 IOU 不能代表真实效果。我见过太多项目训练 IOU 0.95,一换城市掉到 0.6。原因出在验证方式上:如果把验证集 patch 从同一景影像里随机抽出,相邻 patch 高度相似,空间自相关会让分数虚高。所以我的验证习惯分三步走。
第一,保留一个按行政区划分的“地域测试集”。训练集、验证集、测试集不共享任何一景影像,测试集最好来自不同季节甚至不同城市。每训练完一个版本,用跨城市验证集跑一遍推理,把输出概率图和原图叠成四格图,人工扫一遍。视觉扫图能快速发现系统性问题:如果误检全部集中在道路或楼宇阴影,就能判断需要补阴影样本,而不是盲目调损失函数。
第二,除了 IOU,还要看边界置信度。对验证集每个水体边缘取内外各 5 像素的缓冲区,计算该缓冲区内预测正确的比例。城市水体提取交付时最怕边缘“糊”,这个指标的下降往往比整体 IOU 更早暴露问题。实现不复杂:用 Canny 提取标签边缘,膨胀和腐蚀得到内外带,叠加预测概率求平均。
第三,做轻微扰动稳定性测试:对同一张输入叠加 1% 的高斯噪声,连续预测 5 次,统计每次输出掩膜的变化程度。如果稍微扰动就产生大面积跳变,说明模型决策面距离训练数据太近,进入新的城市或光照条件时很容易崩。可以把三个指标放在一张表格里,作为每次版本迭代的检查项。
| 指标 | 计算方式 | 什么时候看 |
|---|---|---|
| IOU | 预测与标签交集 / 并集 | 整体精度 |
| 边界置信度 | 岸线内外 5 像素内预测正确率 | 城市交付边缘质量 |
| 稳定性 | 加噪声前后掩膜变化比例 | 上线前判断鲁棒性 |
这里也是我自己最深的教训:有一回训练 loss 降得很快,验证 IOU 到了 0.92,但把模型拿到另一个区域试用,IOU 直接掉到 0.6。根源就是训练集全部来自夏季晴天影像,而目标场景是春季多云城市。后来我把“换地域换季节”设成上线前硬指标,再也不敢只盯验证集。希望帮到你。
本文还有配套的精品资源,点击获取