ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于PyTorch的高分遥感语义分割实战:从数据准备到地物分类

基于PyTorch的高分遥感语义分割实战:从数据准备到地物分类 简介面向遥感影像智能解译与深度学习语义分割开发者这份PyTorch实现的“高分遥感语义分割地物分类”项目实践包完整覆盖从数据准备、模型训练到预测后处理的流程。包内共858个文件819张PNG图像承载了遥感样本、预测结果与可视化对比内容35个Python脚本实现膨胀预测、伪标签半监督训练等关键模块另附CSV标签、JPG样例与MD说明文档压缩包总大小约547.74MB目录层级清晰便于按步骤学习。已有3040人学习项目加入TensorBoardX可视化输出能直观监控训练曲线与参数变化同时基于高分二号GF2真实影像数据可直接复现地物分类实验。资源不仅提供完整源码还包含后处理与半监督方法示例并配有大量预测结果对比图方便检验分割效果。对于希望深入语义分割实战、开展遥感地物分类研究的工程师和研究生这套项目实践具有较高参考与复用价值。1. 高分遥感语义分割为什么用 PyTorch 把它从“看图”变成“算地”做遥感地物分类的人大概率都经历过这种痛苦从高分影像上人工勾耕地、建筑物、道路边界一个镇区画下来眼睛快瞎了结果甲方一句“数据要更新”全部重来。深度学习里的语义分割恰好就是来解决这个问题的——它给影像的每一个像素贴一个类别标签相当于把“人在看图”变成“机器算地”。而这个方向里PyTorch 几乎成了默认选项动态图机制让调试分割网络特别顺手torchvision 里现成的分割模型可以直接改社区里遥感分割的开源代码也基本以 PyTorch 为主。这篇文章要讲的就是基于 PyTorch 把高分遥感语义分割从零跑通覆盖数据集准备、模型实现、训练调参和推理落地的完整链路最终目标是让你拿到一批影像能独立做出一个可靠的地物分类结果而不是停留在跑通一个 Demo 的层面。2. 数据准备高分影像的地物标签怎么组织才不翻车2.1 高分遥感数据的固有特点大、多波段、标注贵高分遥感影像的典型特点是尺寸巨大一景 GF-2 或者高分一号的影像可能达到上万乘以上万像素。直接把整景影像塞进神经网络是不可能的显存放不下训练效率也低所以第一步必须做切片。另外一个特点是多波段常见的包含 R、G、B 和近红外波段部分数据源还有全色波段。PyTorch 的卷积网络输入通常是(B, C, H, W)C 可以是 3 也可以更多问题在于很多预训练模型是在 ImageNet 的 RGB 三通道上训练的如果要用迁移学习输入通道必须保持 3。我的处理方式是优先使用 R、G、B 做训练把近红外作为后续优化选项先把流程跑通再看要不要加通道。地物分类的标注是整条链路里最贵的部分。常见做法是用 LabelMe 或者 ArcGIS 手动勾绘导出为 PNG 格式的掩膜每个类别对应一个像素值耕地是 1建筑是 2水体是 3背景是 0。这里有一个最关键的纪律——类别必须互斥同一个像素不能既是耕地又是建筑。标注完成后要仔细检查类别重叠的地方这部分问题在做训练时会以损失不下降的形式暴露出来后面避坑章会专门讲定位方法。2.2 切片与数据集划分直接全图裁切会让模型学到“边界假象”把大影像和对应的标注图切成小 patch是遥感分割数据准备的核心操作。一般我会切成 512×512 或者 256×256步长等于切片尺寸就没有重叠步长小于切片尺寸就有重叠采样。有重叠会增加训练样本量也会让模型对边界更鲁棒但代价是训练时间变长。我一般用 512×512 配 256 步长兼顾效率与增强。import numpy as np import cv2 import glob image_paths glob.glob(data/images/*.tif) label_paths glob.glob(data/labels/*.png) def crop_and_save(img_path, lbl_path, patch_size512, stride256, save_dirdata/train): img cv2.imread(img_path) lbl cv2.imread(lbl_path, cv2.IMREAD_GRAYSCALE) h, w img.shape[:2] index 0 for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): img_patch img[y:ypatch_size, x:xpatch_size] lbl_patch lbl[y:ypatch_size, x:xpatch_size] cv2.imwrite(f{save_dir}/img_{index:06d}.png, img_patch) cv2.imwrite(f{save_dir}/lbl_{index:06d}.png, lbl_patch) index 1 for img_p, lbl_p in zip(image_paths[:50], label_paths[:50]): crop_and_save(img_p, lbl_p)这段代码的思路是滑动窗口切图文件名用统一的前缀加序号保证影像和标签一一对应。crop_and_save里先读图再裁切注意cv2.imread对 16 位深度的影像默认会转成 8 位如果原图是 16 位需要额外处理。切完还要做一次数据划分常见比例是训练集、验证集、测试集按 6:2:2 分配划分必须发生在切片之前否则同一景影像的切片会同时出现在训练集和验证集里造成数据泄漏验证指标虚高。2.3 类别权重计算为什么地物分类必须算它遥感地物分类的类别天然不均衡一景影像里可能有 60% 是耕地但建筑物只占 5%。如果不做处理模型会把所有像素都预测成耕地因为全对的准确率也有 60%。交叉熵损失对这个现象无能为力常见做法是给每个类别一个权重稀有类权重放大、常见类权重缩小。权重可以从训练集标注的像素频次统计出来。import numpy as np from collections import Counter def compute_class_weight(label_dir): counter Counter() for lbl_path in label_paths: lbl cv2.imread(lbl_path, cv2.IMREAD_GRAYSCALE) counter.update(lbl.flatten().tolist()) total sum(counter.values()) num_classes len([k for k in counter.keys() if k ! 0]) weights {} for cls, count in counter.items(): if cls 0: # 背景类权重设小避免模型只学背景 weights[cls] 0.1 else: weights[cls] total / (num_classes * count) weight_vec np.array([weights[i] for i in range(max(counter.keys()) 1)], dtypenp.float32) return weight_vec这个逻辑是逆频率加权背景类强制设小权重避免它主导梯度。注意counter.update(lbl.flatten().tolist())对每一张图都做了全量遍历数据集大时这段代码会跑很久可以先对一小部分抽样统计再手动微调权重向量。算好的weight_vec在训练时传给CrossEntropyLoss(weight... )即可。3. 用 PyTorch 搭建遥感语义分割模型从 U-Net 到 DeepLab 的取舍3.1 遥感场景下为什么 U-Net 依然能打语义分割的模型选型里U-Net 和 DeepLabV3 是被讨论得最多的两个名字。遥感影像的特点是目标尺度差异大——一条道路只有几个像素宽一片农田可以占据几百个像素。U-Net 的编码器-解码器结构和跳跃连接正好能同时保留高分辨率细节和语义信息所以在小数据集上常常比 DeepLab 更稳。DeepLabV3 的 ASPP 模块用不同膨胀率的空洞卷积捕获多尺度上下文在 Cityscapes 这类街景数据集上表现更好但它在遥感影像上的优势需要较大的数据量和较多的训练轮次才能显现。我的建议是数据量在几千张 patch 以内优先 U-Net数据量大且类别多再上 DeepLabV3 或者 DeepLabV3。另一个现实理由是工程成本。torchvision 里直接有deeplabv3_resnet50这样的预训练模型但 U-Net 的 PyTorch 实现也就一百多行代码改起来没有任何黑匣子这对科研和项目定制都很重要。3.2 用 PyTorch 手写 U-Net 的完整结构下面这个 U-Net 实现是遥感分割任务的常用基线编码器部分是卷积加下采样解码器部分用转置卷积恢复分辨率跳跃连接把同尺度的特征图拼在一起。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().__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 UNet(nn.Module): def __init__(self, in_channels3, num_classes5): super().__init__() self.enc1 DoubleConv(in_channels, 64) self.enc2 DoubleConv(64, 128) self.enc3 DoubleConv(128, 256) self.pool nn.MaxPool2d(2) self.bridge DoubleConv(256, 512) self.up1 nn.ConvTranspose2d(512, 256, 2, stride2) self.dec1 DoubleConv(512, 256) self.up2 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec2 DoubleConv(256, 128) self.up3 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec3 DoubleConv(128, 64) self.out nn.Conv2d(64, num_classes, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) b self.bridge(self.pool(e3)) d1 self.dec1(torch.cat([self.up1(b), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d1), e2], dim1)) d3 self.dec3(torch.cat([self.up3(d2), e1], dim1)) return self.out(d3)DoubleConv是 U-Net 的基础块两次 3×3 卷积加 BatchNorm 加 ReLU。编码器每一层通道数翻倍从 64 到 128 再到 256池化层负责降采样。解码器用转置卷积上采样再把编码器同尺度的特征在通道维度上拼接得到双倍通道后送入DoubleConv融合。最后一层是 1×1 卷积把通道数压到类别数。前向传播里有一个torch.cat的细节值得注意——当输入宽高不是 2 的整数倍时编码器的特征尺寸和解码器上采样后的尺寸会差 1 个像素这时直接拼接会报错常见做法是用F.pad做补偿不如在数据准备阶段保证尺寸能被 8 整除更省心。3.3 损失函数与训练脚本Focal 和 Dice 怎么配分割任务最常用的损失是交叉熵但在地物分类这种类别极度不均衡的场景下单独用交叉熵往往效果不好。Focal Loss 让模型专注于难分类的样本Dice Loss 直接优化区域重叠度。我常用的组合是主损失用 Focal辅损失用 Dice两个损失按 0.7 和 0.3 的权重相加。Focal Loss 的实现并不复杂import torch.nn.functional as F import torch class FocalLoss(nn.Module): def __init__(self, gamma2.0, alphaNone): super().__init__() self.gamma gamma self.alpha alpha # shape: [num_classes] def forward(self, logits, targets): ce F.cross_entropy(logits, targets, weightself.alpha, reductionnone) pt torch.exp(-ce) focal_loss (1 - pt) ** self.gamma * ce return focal_loss.mean()pt是模型对正确类别的预测概率(1 - pt)^gamma就是调制因子。当样本容易被分对时pt 接近 1调制因子趋向 0损失被压低难分类样本的损失被放大。gamma默认取 2如果发现模型对难例过拟合可以把gamma降到 1.5如果发现模型忽略困难样本可以升到 2.5。alpha可以传前面算好的类别权重向量。训练脚本里还需要设定优化器和学习率策略。遥感分割常用 Adam 配初始学习率 1e-4也可以用 SGD 配 momentum 0.9 加 poly 学习率衰减。对遥感任务poly 衰减通常比阶梯式衰减更稳定。from torch.utils.data import DataLoader, Dataset import os import torch.optim as optim class RemoteSegDataset(Dataset): def __init__(self, img_dir, lbl_dir): self.img_paths sorted([os.path.join(img_dir, f) for f in os.listdir(img_dir)]) self.lbl_paths sorted([os.path.join(lbl_dir, f) for f in os.listdir(lbl_dir)]) def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img cv2.imread(self.img_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img img.astype(np.float32) / 255.0 lbl cv2.imread(self.lbl_paths[idx], cv2.IMREAD_GRAYSCALE) img torch.from_numpy(img).permute(2, 0, 1) lbl torch.from_numpy(lbl).long() return img, lbl dataset RemoteSegDataset(data/train/img, data/train/lbl) dataloader DataLoader(dataset, batch_size16, shuffleTrue, num_workers4) model UNet(in_channels3, num_classes5).cuda() focal_loss FocalLoss(gamma2.0, alphaclass_weight.cuda()) dice_loss DiceLoss(num_classes5) # 实现略参照 dice 系数公式 optimizer optim.Adam(model.parameters(), lr1e-4) scheduler optim.lr_scheduler.PolynomialLR(optimizer, total_iters100, power0.9)注意RemoteSegDataset里对影像做了x / 255.0归一化这是稳定训练的关键。有人习惯用 ImageNet 的均值和标准差做标准化但遥感影像的像素分布和自然图像差异很大直接用 0-1 归一化更稳妥。num_workers设 4 还是 8 取决于机器配置我一般在 Windows 上设 2Linux 上设 8因为 Windows 下多进程数据加载偶发卡死。4. 训练与验证指标别被整体准确率骗了地物分类要看 mIoU4.1 混淆矩阵、mIoU、PA、F1遥感分割用哪几个指标遥感地物分类的评估跟前景背景分割不一样类别多且分布不均。只看整体准确率PA会非常虚因为耕地占了 60%把所有像素都预测成耕地 PA 也有 60%但显然毫无价值。需要看的是 mIoU 和每个类别的 IoU前者反应模型在所有类别上的平均表现后者暴露稀有类上的短板。计算 IoU 时需要自己组装混淆矩阵def compute_iou(pred, label, num_classes): iou_list [] pred pred.flatten() label label.flatten() for cls in range(num_classes): intersection ((pred cls) (label cls)).sum() union ((pred cls) | (label cls)).sum() if union 0: iou_list.append(float(nan)) else: iou_list.append(intersection.item() / union.item()) return iou_list # 在验证集上调用 model.eval() ious [] with torch.no_grad(): for img, lbl in val_loader: img, lbl img.cuda(), lbl.cuda() logits model(img) pred logits.argmax(dim1) ious.append(compute_iou(pred.cpu().numpy(), lbl.cpu().numpy(), 5))这段代码里用argmax(dim1)取每个像素预测概率最大的类别作为最终标签。union 0的情况在遥感数据里很常见——验证集某个 patch 里可能完全没有水体这时这个类别的 IoU 应该跳过而不是记 0否则会拉低整体 mIoU误导判断。把每一类的 IoU 求平均得到 mIoU用每一类的像素准确率加 F1 分数作为辅助指标。我在实际项目里还会单独打印出每个类别的 IoU 从大到小排序这能非常直观地看出哪类地物是模型死活学不会的。4.2 超参数怎么调学习率、batch size、训练轮次的遥感经验值遥感分割模型调参我的经验做法是先固定 batch size 和输入尺寸把学习率跑出来再回来调其他参数。学习率用 1e-4 起步观察损失曲线如果 loss 长时间横盘不降可能是学习率太小如果 loss 剧烈震荡不收敛就是学习率偏大。batch size 的设置受显存限制输入 512×512 且模型是 U-Net 时一张 12GB 显存的卡只能跑 batch size 4 到 8显存不够就把输入尺寸降到 256。训练轮次方面遥感分割不像 ImageNet 分类需要几十个 epoch在小数据集上 30 到 50 个 epoch 通常就足够收敛因为切片本身就引入了大量样本。验证集不是用来在训练结束后跑一遍就完事的训练过程中每个 epoch 都要验证一次。我习惯把每个 epoch 的 mIoU 和 loss 存成日志画出曲线观察验证集 mIoU 是否在某个时间点开始下降而训练集还在上升。出现这种现象时要考虑早停或模型权重回滚这两步要在调参之前做成自动化流程。4.3 过拟合与欠拟合的辨识方法遥感分割任务里过拟合的典型表现是训练集的 loss 降到很低但验证集 mIoU 不涨甚至在掉预测图上出现很多与真实地物无关的碎斑。欠拟合的表现则相反——训练验证两边的 loss 都高预测结果把所有东西都归到背景类里。欠拟合优先查学习率和模型容量过拟合优先加数据增强、减小模型容量或加 Dropout。遥感影像的数据增强与自然图像有区别随机翻转、旋转、缩放都安全颜色抖动要慎用因为地物的光谱特征是有物理含义的把绿植增强成水体颜色就是错误样本。5. 遥感地物分类避坑指南五个让我重跑实验的问题5.1 切片重叠导致验证集“作弊”现象训练时验证集 mIoU 特别高有大几十甚至 90 多但放到一整景新影像上推断效果惨不忍睹。原因如果先对大图切片再划分数据集同一个大图产生的切片之间高度相似训练集和验证集里头像极了亲兄弟验证指标虚高。解决划分必须发生在切片之前确保验证集和训练集来自完全不同的影像。如果影像数量少可以考虑按经纬度划分区域而不是随机划分切片。5.2 标签类别重叠或空洞导致损失函数震荡现象训练 loss 前期不降后期反复震荡预测图上出现相互矛盾的区域。原因标注时不同类别有重叠区域或者某个类别的像素值写错例如背景是 0但标注时把道路也画成了 0导致两个语义完全不同的地物共享一个标签。解决训练前写一个脚本统计每张标签的类别数如果一个 patch 里的类别数和预设差太多单独抽出来人工检查。对重叠区域先到后覆盖只保留优先级最高的那一类标签。5.3 显存溢出却不知道是哪里爆的现象程序跑一半报CUDA out of memory但显存监控显示还有空闲。原因PyTorch 是动态分配显存的前面的中间变量在计算图释放前不会立即腾出显存而且 batch size 过大或输入尺寸过大时模型输出的特征图会成倍占用显存。解决把 batch size 减半或者把输入尺寸从 512 降到 256再用torch.cuda.empty_cache()在验证阶段清理缓存推理时用with torch.no_grad():包住前向传播。5.4 验证集 mIoU 很高但推理结果满图碎斑现象验证集指标不错但对整景影像做推理时结果图上有大量椒盐噪声一样的孤立小区域。原因模型是逐 patch 预测的patch 与 patch 之间存在重叠区域同一个地物在不同 patch 中的预测类别不一致也有一些是 CRF 后处理缺失导致的边缘不够平滑。解决推理时用重叠滑动窗口对重叠区域的预测结果做平均投票而不是直接取最大值还可以在模型后面接一个条件随机场或简单的多数滤波把面积小于阈值的孤立斑块过滤掉。5.5 输入影像波段数与模型不匹配现象代码报Expected input batch_size (4) to match target size (3)之类的通道错误或者模型能跑但预测结果完全不对。原因高分影像可能是 4 波段或 8 波段 TIF而模型的in_channels设成了 3读取时不做波段选择。解决用rasterio读取多波段影像明确指定要使用的波段索引或者用gdal_translate转成 RGB 三波段后再进入流程。gdal_translate -b 1 -b 2 -b 3 input_8band.tif output_rgb.tif这条命令的作用是把 8 波段影像的前三个波段提取出来生成一份 RGB 影像避免后续代码因为通道数问题反复报错。6. 高分遥感地物分类的推理进阶分块推理、预测平滑与面积估算推理阶段跟训练阶段有很多不同。训练时数据是切好的 patch推理时要面对一整景大影像。直接整图前向传播通常爆显存所以推理也要做滑动窗口但和训练切片有两个关键差别第一推理窗口之间必须有重叠一般重叠 64 到 128 像素最终预测结果取多个窗口预测的平均值这样可以显著消除拼缝效应第二推理时要做反射填充避免边缘像素因为 padding 不够导致预测退化。def sliding_predict(model, image, window_size512, stride384, num_classes5): h, w image.shape[:2] pred_sum np.zeros((h, w, num_classes), dtypenp.float32) count_map np.zeros((h, w, 1), dtypenp.float32) for y in range(0, h - window_size 1, stride): for x in range(0, w - window_size 1, stride): patch image[y:ywindow_size, x:xwindow_size] patch_tensor torch.from_numpy(patch).permute(2, 0, 1).unsqueeze(0).float().cuda() with torch.no_grad(): logits model(patch_tensor) pred_sum[y:ywindow_size, x:xwindow_size] logits.softmax(dim1).squeeze(0).permute(1, 2, 0).cpu().numpy() count_map[y:ywindow_size, x:xwindow_size] 1 pred_prob pred_sum / np.maximum(count_map, 1) pred_label np.argmax(pred_prob, axis2) return pred_labelstride384小于window_size512这样每个像素至少被两个窗口覆盖重叠区域的预测概率被平均图斑边缘的锯齿感会轻很多。count_map记录每个像素被累计了几次防止除零。如果一张影像非常大建议先按网格切块对每个块做一次sliding_predict再重新拼接。拼接时的对齐问题可以用rasterio的仿射变换信息来保证我一开始偷懒直接按像素坐标拼接结果在影像裁剪或投影转换后出现了偏移后来统一用地理坐标定位才彻底解决。推理完成后要做两件收尾的事一是去除孤立小图斑用skimage.morphology.remove_small_objects按类别分别处理比如面积小于 50 像素的水体大概率是噪声直接替换成周围最多的类别二是如果业务需要地物面积按每个类别的像素数乘以单像素对应的地面面积注意高分影像的像素分辨率需要从影像元数据里读取不是猜一个固定值。GF-2 全色分辨率是 1 米多光谱是 4 米融合后是 1 米计算时如果用了错误的分辨率面积结果会差一个数量级。remove_small_objects有注意事项它默认判断的是二值图的连通区域多类别标签图需要逐类别操作。我一般在处理完耕地和水体两个大类之后再合并建筑物和道路这类目标本来就小不能盲目过滤否则会把真实的道路断成一段一段。现在再做遥感分割项目我已经习惯把训练和推理分开成两个独立的工程模块中间用模型权重文件和标签映射表对接。训练时实时记录每个类别的 IoU推理时对预测不确定的区域softmax 最大概率低于某个阈值比如 0.7专门输出一份待人工检查的掩膜而不是把所有像素都硬分类。这套流程跑通之后从拿到影像到交付分类图一景图全自动处理只需要几分钟人工只在最后核对歧义区域。希望这些你踩过的或者将要踩的坑能在这条路上帮你省下几个通宵。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进