基于PyTorch的高分遥感语义分割实战:从数据准备到地物分类
2026/9/23 12:56:35 网站建设 项目流程

简介:面向遥感影像智能解译与深度学习语义分割开发者,这份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_size=512, stride=256, save_dir='data/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:y+patch_size, x:x+patch_size] lbl_patch = lbl[y:y+patch_size, x:x+patch_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)], dtype=np.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, 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=3, num_classes=5): 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, stride=2) self.dec1 = DoubleConv(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec2 = DoubleConv(256, 128) self.up3 = nn.ConvTranspose2d(128, 64, 2, stride=2) 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], dim=1)) d2 = self.dec2(torch.cat([self.up2(d1), e2], dim=1)) d3 = self.dec3(torch.cat([self.up3(d2), e1], dim=1)) 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, gamma=2.0, alpha=None): super().__init__() self.gamma = gamma self.alpha = alpha # shape: [num_classes] def forward(self, logits, targets): ce = F.cross_entropy(logits, targets, weight=self.alpha, reduction='none') 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_size=16, shuffle=True, num_workers=4) model = UNet(in_channels=3, num_classes=5).cuda() focal_loss = FocalLoss(gamma=2.0, alpha=class_weight.cuda()) dice_loss = DiceLoss(num_classes=5) # 实现略,参照 dice 系数公式 optimizer = optim.Adam(model.parameters(), lr=1e-4) scheduler = optim.lr_scheduler.PolynomialLR(optimizer, total_iters=100, power=0.9)

注意RemoteSegDataset里对影像做了x / 255.0归一化,这是稳定训练的关键。有人习惯用 ImageNet 的均值和标准差做标准化,但遥感影像的像素分布和自然图像差异很大,直接用 0-1 归一化更稳妥。num_workers设 4 还是 8 取决于机器配置,我一般在 Windows 上设 2,Linux 上设 8,因为 Windows 下多进程数据加载偶发卡死。

4. 训练与验证指标:别被整体准确率骗了,地物分类要看 mIoU

4.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(dim=1) ious.append(compute_iou(pred.cpu().numpy(), lbl.cpu().numpy(), 5))

这段代码里用argmax(dim=1)取每个像素预测概率最大的类别作为最终标签。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_size=512, stride=384, num_classes=5): h, w = image.shape[:2] pred_sum = np.zeros((h, w, num_classes), dtype=np.float32) count_map = np.zeros((h, w, 1), dtype=np.float32) for y in range(0, h - window_size + 1, stride): for x in range(0, w - window_size + 1, stride): patch = image[y:y+window_size, x:x+window_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:y+window_size, x:x+window_size] += logits.softmax(dim=1).squeeze(0).permute(1, 2, 0).cpu().numpy() count_map[y:y+window_size, x:x+window_size] += 1 pred_prob = pred_sum / np.maximum(count_map, 1) pred_label = np.argmax(pred_prob, axis=2) return pred_label

stride=384小于window_size=512,这样每个像素至少被两个窗口覆盖,重叠区域的预测概率被平均,图斑边缘的锯齿感会轻很多。count_map记录每个像素被累计了几次,防止除零。如果一张影像非常大,建议先按网格切块,对每个块做一次sliding_predict,再重新拼接。拼接时的对齐问题可以用rasterio的仿射变换信息来保证,我一开始偷懒直接按像素坐标拼接,结果在影像裁剪或投影转换后出现了偏移,后来统一用地理坐标定位才彻底解决。

推理完成后,要做两件收尾的事:一是去除孤立小图斑,用skimage.morphology.remove_small_objects按类别分别处理,比如面积小于 50 像素的水体大概率是噪声,直接替换成周围最多的类别;二是如果业务需要地物面积,按每个类别的像素数乘以单像素对应的地面面积,注意高分影像的像素分辨率需要从影像元数据里读取,不是猜一个固定值。GF-2 全色分辨率是 1 米,多光谱是 4 米,融合后是 1 米,计算时如果用了错误的分辨率,面积结果会差一个数量级。

remove_small_objects有注意事项,它默认判断的是二值图的连通区域,多类别标签图需要逐类别操作。我一般在处理完耕地和水体两个大类之后再合并,建筑物和道路这类目标本来就小,不能盲目过滤,否则会把真实的道路断成一段一段。

现在再做遥感分割项目,我已经习惯把训练和推理分开成两个独立的工程模块,中间用模型权重文件和标签映射表对接。训练时实时记录每个类别的 IoU,推理时对预测不确定的区域(softmax 最大概率低于某个阈值,比如 0.7)专门输出一份待人工检查的掩膜,而不是把所有像素都硬分类。这套流程跑通之后,从拿到影像到交付分类图,一景图全自动处理只需要几分钟,人工只在最后核对歧义区域。希望这些你踩过的或者将要踩的坑,能在这条路上帮你省下几个通宵。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询