ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

高分辨率城市遥感图像水体提取:基于深度学习的像素级语义分割实践

高分辨率城市遥感图像水体提取:基于深度学习的像素级语义分割实践 简介面向遥感、地理信息等专业毕业设计的高分辨率城市遥感图像水体提取项目基于 Python 深度学习实现内置 U-Net 与注意力 U-Net 两种网络结构有效解决城市复杂背景下水体自动提取的精度不足问题可广泛应用于内涝分析、水域监测等场景。资源共 27 个文件以 Python 源码为主另含模型权重、数据集、说明文档、结果汇总表和网络结构示意图代码注释详细训练、验证、预测脚本齐备独立的数据预处理与增强工具可迁移到其他遥感分割任务训练好的权重文件支持直接加载验证无需从头训练。包体约 726KB体积轻巧而功能完整简单部署即可运行适合作为毕业设计、期末大作业或课程设计的完整参考。已有 199 人学习浏览项目经过充分调试确保稳定可用新手阅读文档后即可复现水体提取结果并能基于清晰代码结构继续改进网络用于高分论文或答辩展示。1. 高分辨率城市遥感图像的水体提取这个毕设课题到底在做什么打开一张城市的高分遥感影像第一眼最抢眼的不是楼而是河网、湖面和零散的水塘。但如果真拿它去做水体提取很快就会发现传统的 NDWI 阈值一上城市影像就翻车高层阴影、暗色屋面、刚铺的沥青路全都被算成“水”。这正是高分辨率城市遥感图像的水体提取这个毕设课题要解决的核心问题——不是“见蓝即水”而是利用深度学习和像素级语义分割从城市复杂地物里把真正的水体像元剥出来。这个课题的本质是一门“图像语义分割”的落地演练输入高分辨率遥感影像输出逐像素的水体掩膜。一套完整的高分毕设交付物通常包含四块——Python 源代码、文档说明、带标注的数据集、以及训练好可复现的模型权重导出流程。适合做遥感、GIS、深度学习方向毕设或课设的学生也适合把分割模型迁移到其他地物建筑物、道路、植被提取的从业者。你不需要会写遥感算法但要能跑通 PyTorch 训练流程并且愿意在数据处理上花时间。2. 为什么水体提取不能只看“蓝色”原理与模型选型2.1 城市高分影像里“水”长什么样在低分辨率影像上水体像一个均质的“暗色吸光体”蓝绿波段有少量反射红波段开始变暗近红外波段几乎全吸收。所以传统方法喜欢构造 NDWI (G - NIR) / (G NIR)把水面顶到高值区再用阈值一分为二。这套逻辑放到城市高分影像上会出问题因为城市里“在近红外波段也暗”的东西太多了。高层建筑阴影在可见光和近红外上都很暗光谱曲线和水体高度相似新铺沥青路面的近红外反射率也低深色屋顶、桥底透光区、湿地泥滩每一类都可能让查表式阈值翻车。更麻烦的是高分辨率影像把水的“不均质性”暴露出来了水面有波纹、有船、有漂浮物岸线有混凝土硬边界湖里还有荷花和芦苇。也就是说水体在像素级不再是一个干净的光谱类别而是一个“由上下文关系定义”的地物类别。这是你选模型和设计损失函数时始终要记住的前提。2.2 UNet 与 DeepLabV3两个能直接上手的基线毕设水体提取的主流做法是用带预训练 backbone 的编码-解码结构最常见的是 UNet对比方案用 DeepLabV3。UNet 的跳跃连接能把 encoder 下采样丢掉的空间细节找回来对岸线边缘比较友好DeepLabV3 的 ASPP 模块用多个不同空洞率的卷积并行提取特征rate 一般为 6、12、18对“一条大江 一堆小水洼”这种多尺度水体并存的情况更有优势。我一般建议主线用 UNet resnet34 预训练 backbone显存占用适中数据集 500 张 patch 就能训出能看的精度。DeepLabV3 作为对比实验写进论文这两者在代码层面的差异主要集中在 decoder 上UNet 是逐层拼接特征DeepLabV3 是 concat 多尺度空洞特征后上采样。需要注意不要自己从头训练 backbone城市遥感影像虽然和下采样 ImageNet 的图片风格差异大但预训练权重能显著加快收敛也能缓解标注样本不足的问题。代码里模型结构通常长这样重点不是把每一层抄对而是理解输入输出尺寸的变化import torch.nn as nn class UNetResNet(nn.Module): def __init__(self, backbone, in_channels3, num_classes1): super().__init__() # 以resnet34为例取前4个stage作为encoder self.encoder backbone self.filters [64, 64, 128, 256, 512] # decoder每一层先上采样再和encoder对应层awa7拼接 self.up1 UpBlock(512 256, 256) self.up2 UpBlock(256 128, 128) self.up3 UpBlock(128 64, 64) self.up4 UpBlock(64 64, 64) self.head nn.Conv2d(64, num_classes, kernel_size1) def forward(self, x): feats self.encoder_features(x) # [c1, c2, c3, c4] out self.up1(feats[3], feats[2]) out self.up2(out, feats[1]) out self.up3(out, feats[0]) out self.up4(out, feats[0]) return self.head(out)这里的encoder_features需要你在 resnet 中间层手动把特征导出来常见的做法是注册 forward hook或者直接改 forward 函数返回各 stage 输出。UpBlock的典型实现是反卷积或双线性上采样 卷积 BN ReLU。整条链路里最容易写错的是通道数resnet34 四个 stage 的输出通道是 64、128、256、512拼接后要自己在代码里对齐否则跑起来 dim mismatch 报错会找半天。2.3 损失函数与类不平衡城市影像里水体面积占比常常只有 5% 到 15%大面积的非水区会把交叉熵梯度“稀释”掉模型学到最后变成了“全都预测为非水也损失不大”。解决这个问题最稳定的做法是 Dice Loss 和 BCE 一起用Dice 负责拉高水体类的前景召回BCE 负责稳定梯度和边界细节。水域占比越低Dice 在总损失里的权重就应该越大。import torch import torch.nn as nn import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, dice_weight1.0, smooth1e-6): super().__init__() self.dice_weight dice_weight self.smooth smooth def forward(self, logits, targets): # logits: [B, 1, H, W] 未经过sigmoid # targets: [B, 1, H, W] 取值0或1 probs torch.sigmoid(logits) bce F.binary_cross_entropy_with_logits(logits, targets) inter (probs * targets).sum(dim(2, 3)) union probs.sum(dim(2, 3)) targets.sum(dim(2, 3)) dice 1.0 - (2.0 * inter self.smooth) / (union self.smooth) return bce dice.mean() * self.dice_weight参数上要留意两点第一Dice 在 batch 维度上取平均而不是把整批图拼成一张大图再算。因为不同 patch 的水体占比差异极大逐样本算 Dice 能避免大水体的 patch 主导梯度。第二dice_weight默认 1.0水体占比特别小时可以调到 1.5 到 2.0如果发现训练早期 loss 波动大可以把 BCE 换成带权重的pos_weight让正样本的梯度放大 3 到 5 倍。不要一上来就用 Focal Loss它的两个超参数对新手不友好调不好反而比 Dice 还难收敛。3. 把数据集跑起来整理、裁剪与增强的固定流程3.1 数据集的目录约定与划分原则不管数据集是老师给的、网上下载的公开遥感数据集还是自己用无人机飞出来的 DOM拿到手第一步都是“洗数据”。你需要的核心文件只有两类原始影像和对应的逐像素标签掩膜。原始影像常见格式是 TIF 或 PNG多波段时先确认是 RGB 三波段还是 RGBN 四波段标签掩膜必须是单通道、和影像同分辨率的栅格习惯上非水体记 0水体记 255 或 1。先拿 Python 看一眼每个 band 的 dtype 和取值范围经常能撞见标签是 uint16 的 0 和 65535这种直接用会把你整个训练曲线带歪。建议目录固定成这个结构所有脚本都按这个约定读写project/ ├── data/ │ ├── images/ # 原始影像瓦片或整幅图 │ ├── masks/ # 标签掩膜同名单通道png │ ├── train.txt # 每行一个文件名不含扩展名 │ ├── val.txt │ └── test.txt ├── configs/ ├── models/ ├── utils/ ├── train.py ├── eval.py └── predict.py划分数据时有一条铁律按“影像”划分而不是按“patch”划分。也就是说先把整景影像列表切成 train/val/test 三份再对每一景做滑窗裁剪。如果先把所有影像裁剪成 patch 再随机划分同一景影像上有几十个 patch 被拆到 train 和 val模型在验证集上见过来自同一场景的相邻子图验证 IoU 会虚高 5 到 10 个点答辩时一拿整幅影像测试就露馅。这个操作不需要代码就是先写文本列表再裁剪但很多毕设翻车都翻在这一步。3.2 影像裁剪成 patch尺寸、重叠与边界处理高分影像动辄几万像素宽原图没法直接进 GPU。常见做法是滑窗裁剪成 512×512 或 256×256 的 patch。patch 越大上下文越完整河流不容易被拦腰截断但显存占用和单张 patch 的水域占比波动都变大patch 越小训练样本越多但小河道在 patch 里只占几个像素模型很容易把它们当成噪点忽略。城市水体提取我通常默认 512训练时对应 stride 256推理时 stride 128 并做重叠平均缓解拼图边缘的响应不一致。import cv2 import numpy as np import os def crop_patches(img_path, mask_path, out_root, patch512, stride256): img cv2.imread(img_path, cv2.IMREAD_UNCHANGED) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) h, w mask.shape[:2] idx 0 for y in range(0, h - patch 1, stride): for x in range(0, w - patch 1, stride): p_img img[y:y patch, x:x patch] p_mask mask[y:y patch, x:x patch] # 过滤掉几乎全背景或几乎全水体的patch water_rate (p_mask 255).mean() if water_rate 0.005 or water_rate 0.995: continue cv2.imwrite(os.path.join(out_root, images, f{idx}.png), p_img) cv2.imwrite(os.path.join(out_root, masks, f{idx}.png), p_mask) idx 1 print(fgenerated {idx} patches from {os.path.basename(img_path)})这段脚本的两个参数要注意water_rate 0.005过滤掉的不是“无水体”的样本而是“水体几乎不存在”的样本。如果城市里水体非常稀少把阈值改成 0.01能显著加快收敛因为模型不用把太多时间花在无数个全是楼房的 patch 上但如果你的数据里有大量极细的排水渠过滤阈值设太高会把它们全滤掉漏检率会直接拉高。 0.995是保留极少量全水体 patch防止模型把“整块水体”当成默认输出。还有边界问题滑窗到达图像右下边缘时如果剩余宽度不足 patch脚本会直接丢弃这些边缘区域。城市影像的边缘经常是岸线或道路直接丢弃意味着模型没学过这类边界样本。更稳的做法是对图像做 reflect 填充再在填充区上裁剪如果嫌麻烦至少把边缘样本单独补采一次别让模型对边界风格一无所知。3.3 数据增强哪些能用、哪些会把河道搞断数据增强对水体提取的收益很高因为城市水体在形态上高度重复——无非就是带状、块状、不规则形状旋转和翻转就能产生大量等价样本。翻转变换、90 度旋转、亮度对比度扰动、高斯噪声都是安全的它们不改变水陆拓扑关系但随机缩放、弹性变形、随机擦除这类会拉断细长河道的增强在城市小水体数据集上要谨慎。做毕设时我一般只保留第一类否则训练过程中细小支流的标签被拉伸到变形模型学到的边界是歪的。import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.RandomRotate90(p0.5), A.RandomBrightnessContrast(p0.3, brightness_limit0.15, contrast_limit0.15), A.GaussNoise(p0.2), ]) val_transform A.Compose([])这里的RandomBrightnessContrast是专门针对遥感影像增强稳定性的城市阴影像元本身和水分不清如果亮度扰动范围过大等于人为制造更多“假水”样本所以我限制在 0.15 以内。GaussNoise概率也设得不高因为高分影像的传感器噪声已经很小过度加噪反而让模型学的不是真实水体纹理。需要注意的是 albumentations 的随机旋转、翻转会同时作用于原图和掩膜但它的文档里明确写了 mask 要传 key 为mask的数组代码里写错成masks会导致标签和影像错位。训练时把增强放在 DataLoader 的__getitem__里做在线增强不要离线生成一份膨胀的数据集否则硬盘写爆而且 shuffle 后相邻样本相关性太高。4. 从命令行到训练出模型代码结构与关键参数4.1 代码包怎么组织一套好交付的水体提取毕设源码不需要花哨但要路径清晰。常见结构是configs放超参数配置文件models放网络定义utils放损失函数和评估指标train.py、eval.py、predict.py三个入口脚本分别是训练、验证和推理。配套文档一般按四块来写环境配置、数据准备、训练评估流程、常见问题排查。环境部分最关键因为评分老师很可能照着你的文档在自己电脑上复现PyTorch、OpenCV、albumentations 的版本要对齐。环境配置这一块最容易被卡住的是 Windows 上装 GPU 版 PyTorch。用 vscode 配 python 环境时建议先用 conda 创建独立环境再在 vscode 右下角选择解释器指向这个虚拟环境不要直接用系统全局解释器。装 torch 时如果pip install torch默认装了 CPU 版训练会慢得让人怀疑人生正确做法是去 PyTorch 官网选 CUDA 版本对应的安装命令。没有 GPU 的机器也能跑但建议把 backbone 换成 resnet18patch 缩到 256batch 设成 2勉强能在一夜之间跑完一个 mini 实验用于验证代码正确性。4.2 训练命令与超参数batch size、学习率、epoch 怎么定训练入口脚本支持从命令行传配置路径这是我比较推荐的做法因为毕设后期要做多组对比实验硬编码超参数在代码里会累死自己。一个典型的启动命令长这样python train.py --config configs/unet_resnet34.yaml对应的 yaml 里核心参数如下这些都是城市水体提取比较稳的起点不是拍脑袋model: unet_resnet34 input_channels: 3 patch_size: 512 batch_size: 8 num_epochs: 120 base_lr: 1.0e-4 optimizer: adamw weight_decay: 0.05 lr_schedule: cosine val_interval: 5 save_best_metric: val_ioubatch size 8 的前提是有 12G 以上显存8G 卡要降到 46G 卡降到 2 并配合梯度累积。学习率 1e-4 对 resnet 预训练骨干是一个通用起点如果 backbone 不冻结通常骨干层用 0.1 倍学习率decoder 用全速率但毕设实验不必做得这么细统一 1e-4 也能收敛。epoch 不是越多越好120 个 epoch 配合 cosine 退火大部分结果在 60 到 80 个 epoch 时已经稳定训练时盯着验证 IoU 保存最优权重即可。optimizer torch.optim.AdamW(model.parameters(), lrargs.base_lr, weight_decay0.05) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxargs.num_epochs) best_iou 0.0 for epoch in range(args.num_epochs): model.train() for step, (imgs, masks) in enumerate(train_loader): imgs imgs.to(device) masks masks.to(device) logits model(imgs) loss criterion(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % args.val_interval 0: val_iou evaluate(model, val_loader) print(fepoch {epoch}, loss {loss.item():.4f}, val_iou {val_iou:.4f}) if val_iou best_iou: best_iou val_iou torch.save(model.state_dict(), best_unet.pth)这段训练挖的关键点是优化器选了 AdamW配合 weight_decay 0.05 在大模型上比 SGD 稳判断最优模型的指标是验证集 IoU 而不是训练 loss因为水体提取的最终目标是分割精度不是拟合程度。训练初期 loss 下降不快别着急预训练 backbone 的底层特征在适应遥感图像域一般在 10 个 epoch 后 IoU 才有肉眼可见的提升。如果 loss 出现 NaN优先检查是否输入图里出现了 NaN 像素或标签越界。4.3 评估指标与可视化把预测结果叠回原始影像毕设答辩最看重两个东西一张精度表格和一组可视化对比图。精度表格至少要包含 IoU、F1、Precision、Recall 和总体精度 OA。对水体这种单类分割IoU 就是水体的类别 IoUF1 是 Precision 和 Recall 的调和平均。城市场景里漏检和虚警的代价不一样如果项目要求水系监测完整性Recall 优先如果要求是“图上画出水边界错别太多”IoU 优先。import numpy as np def compute_metrics(pred, gt): # pred/gt 传入前需归一化为0/1 pred (pred 0.5).astype(np.uint8) gt (gt 0.5).astype(np.uint8) tp np.sum((pred 1) (gt 1)) fp np.sum((pred 1) (gt 0)) fn np.sum((pred 0) (gt 1)) tn np.sum((pred 0) (gt 0)) iou tp / (tp fp fn 1e-6) precision tp / (tp fp 1e-6) recall tp / (tp fn 1e-6) f1 2 * precision * recall / (precision recall 1e-6) oa (tp tn) / (tp tn fp fn 1e-6) return {IoU: iou, F1: f1, Precision: precision, Recall: recall, OA: oa}读取预测图时要注意存图格式。模型输出是 0 到 1 的概率要比较或存图时先乘 255 再转 uint8否则 cv2.imwrite 直接写入浮点数组会得到全黑或全白的废图。可视化对比时把原图、真值、预测、差异四列并排放进一张图里差异图用红色标注漏检、绿色标注虚警这是所有答辩老师都能一眼看懂的表达。5. 水体提取的常见翻车现场与排查方法5.1 阴影和暗色屋顶被误判成水体现象预测结果里上午时段高层建筑的东侧阴影一整片都是“水”深蓝色玻璃幕墙也被提取成水体。整体 IoU 看着还行但投影到整景影像上满是“假河”。原因水体在高分影像上的光谱形态和阴影太接近尤其是近红外波段同样暗。模型如果没在训练集里见过足够的“带建筑物阴影的水体样本”就会把“暗”当成“水”。解决治本手段是让数据里阴影样本足够多训练时不要手动过滤那张满是阴影的 patch反而要保留它们。治标手段有两个一是给模型加输入通道把 NDWI 或 NDVI 作为第 4 通道拼进去让模型在光谱上有额外线索区分阴影阴影区域 NDVI 也很低但水体 NDWI 更高二是在后处理阶段对预测结果做阴影检测掩膜把判定为阴影的连通域从水体结果中扣掉。如果你用了多光谱影像我建议做 5 通道输入R、G、B、NDWI、NDVI比单靠三通道稳得多。5.2 细小河流漏检连通性不达标现象主干河流和湖泊都提取出来了但城区段细河道断成数截一些只占 3 到 5 个像素宽的小水渠直接消失。指标上 Recall 很低。原因裁剪成 patch 后细河流在单张 patch 里只占很小面积Dice Loss 对它几乎不敏感且划窗裁到水陆交界处时patch 里只有一小段河道模型学不到“这截暗色细线延伸到图外是水道”的上下文。解决训练阶段把 patch 内水体占比的过滤阈值调低特意保留含细水体的样本并可以按水体面积做重采样让包含细小水体的 patch 权重加大推理阶段用重叠滑窗加平均投票stride 取 patch 的一半这样细河道至少被两个窗口覆盖后处理用形态学闭运算连通断口。闭运算核不能大3×3 即可核一大容易把小水塘和真实水体糊成一团。5.3 显存溢出patch 太大或 batch 太大现象训练刚起步就报RuntimeError: CUDA out of memory代码没有任何逻辑问题纯是资源不够。原因512×512 patch、batch 8、resnet34 编码器在 8G 显存显卡上显存占用约 7G 左右算上计算图细节很容易超。解决三选一或组合用。batch 降到 2 最省事但收敛会变慢用梯度累积模拟大 batch在 4 步内累积梯度再更新一次参数等效 batch 仍是 8或者把骨干换成 resnet18参数量少一半512 patch 下能保住语义信息。梯度累积的代码就是跳过 optimizer.step代码如下accum_steps 4 optimizer.zero_grad() for step, (imgs, masks) in enumerate(train_loader): logits model(imgs.to(device)) loss criterion(logits, masks.to(device)) loss loss / accum_steps # 平均累积损失的梯度 loss.backward() if (step 1) % accum_steps 0: optimizer.step() optimizer.zero_grad()注意第 8 行的loss / accum_steps不能省否则累积得到的梯度是原来 batch 的 accum_steps 倍等价于学习率被放大训练会震荡甚至发散。显存不够时优先降 batch其次是降 patch最后才是换模型。尽量别用 torch.no_grad 推理来解决训练显存问题。5.4 训练集和验证集同源导致指标虚高现象训练日志里 val_iou 冲到 0.95看着稳得很但用训练好的权重对另一景未参与训练的影像做预测效果明显下降。原因这是毕设里最常见也最阴的坑——数据划分按 patch 随机分而不是按影像分。同一景影像上相邻 patch 高度相似验证集里混进了训练集的“近亲”模型记忆的是影像风格不是水体泛化特征。解决回到第 3.1 节严格按影像级划分。更进一步的建议是如果数据集包含多个不同来源或不同季节的影像尽量让 train、val、test 各自覆盖不同来源测试集务必从没参与过训练调参的影像中选。写裁剪脚本时先按影像名哈希分桶再进循环裁剪这样后续新加的影像不用改逻辑也不会再犯同源划分错误。5.5 标签里的“半水半陆”像元现象训练 loss 在某个水平下不去预测结果在岸线附近总有一圈模糊带指标不高不低卡在 0.8 附近上不去了。原因手工标注的岸线区域存在大量灰色地带——浅水、湿地、软泥、苔藓标注人员可能把同一块地方在不同的 patch 里标成 0 或 255。模型被迫拟合这些互斥标签学到的边界是概率模糊的。解决清洗标签是最痛苦但收益最高的一步。把掩膜中既不是 0 也不是 255 的像素强制二值化做法是取 128 为阈值小于 128 归非水大于等于 128 归水。评估时不要对整条岸线求 IoU而是把真值掩膜向内腐蚀 5 个像素再算指标只评估水体内部的稳定区域。这不算作弊因为论文里要说明清楚评估策略主体指标之外的边界指标可以单独报告。如果标签实在太乱可以在 loss 里对靠近岸线的像素降权但对毕设而言直接清洗标签更省事。6. 验收前要做的事精度把关与进阶方向跑通训练只是完成了一半答辩前一定要用真正没见过的测试影像做一次整景推理验收。做法是把测试影像裁成 patch逐 patch 预测后按滑窗位置拼回整幅图拼接时把重叠区域取预测概率平均值这样能消除窗口边缘强度偏低的拼缝伪影。拼完后再把小于一定像素面积例如 50 像素的孤立连通域当成噪声滤掉但注意不要误删真实的小水塘过滤阈值按你数据里最小水体的面积来定。精度把关的核心是把结果按场景拆开看而不是只看一个总 IoU。我习惯把预测结果叠加到影像上按“城市河道、开阔湖面、山区暗色区域、桥下阴影”四类场景各截取一张大图统计每个场景里的虚警与漏检。这样做的好处是提前发现自己模型最怕哪一类样本并能在答辩 PPT 上给出诚实的失败案例分析——老师更认可你知道边界在哪里。进阶方向主要有三条一是把 UNet 换成带 transformer encoder 的 SegFormer看 pretrained 权重下能不能提升细河道的长程依赖建模二是给模型加一个边缘监督分支对岸线做 3 像素宽的边缘权重增强能明显改善边界精度三是把传统特征工程接进来比如将 NDWI 作为第四通道输入拿它和纯 RGB 的结果做消融这种对比实验是大论文里最有说服力的一节。我自己当年做这个题目时最大的教训就是数据集划分图省事导致答辩前一周才发现验证集指标虚高临时重新划分、重训、重调整个人被折腾到脱层皮。从那以后我拿到任何分割数据第一件事永远是先按景划分、再裁剪、再清洗标签顺序错一步后面全是血泪。这套流程对建筑物提取、道路提取同样适用希望帮到你。本文还有配套的精品资源点击获取
返回列表