高分遥感语义分割实战:PyTorch从数据到推理全流程
简介这份资源面向遥感图像处理方向的研究者、工程师及具备一定深度学习基础的学习者提供基于Pytorch实现高分辨率遥感图像语义分割的完整教程与配套数据集帮助解决地物信息提取中从数据预处理到模型训练、评估的全流程问题。压缩包共1029个文件约577.48MB以819个png图像样本、35个py训练与推理脚本为主辅以zbak备份、csv标签说明及md文档覆盖数据读取、网络搭建与结果可视化等环节。目前已有94人学习下载。教程从遥感图像基本概念讲起逐步深入到预处理方法、语义分割网络结构选择与优化策略并演示如何操作数据集、设计训练模型及评估分割结果配套数据可让读者亲身体验端到端训练测试流程理解道路、建筑、植被等地物标签的像素级标注与空间分布适合希望将语义分割技术迁移到自身领域的中高级学习者参考实践。1. 高分遥感语义分割从数据到推理一条能跑通的 PyTorch 路线高分遥感图像的语义分割本质是给每个像素分类——建筑、道路、水体、植被、耕地各归其位。它和自然图像分割最大的不同在于目标尺度差异极大一栋楼可能只占几十个像素一片农田却能横跨整幅图同时波段数往往不止 RGB还有近红外。很多做遥感图像目标检测的团队转过来做分割第一反应是拿 YOLOv8 那套直接改结果发现 mask 分支根本撑不住密集小目标这就是没分清实例分割与语义分割的区别。这篇笔记面向已经装好 PyTorch、想用高分遥感数据跑通语义分割的从业者从数据集选择、模型搭建、训练调参到推理拼接给一条能复现的完整路径。数据集下载和标注格式转换会重点讲因为这是翻车最多的地方。2. 数据集选型与标注格式别在第一步就把路走窄2.1 高分遥感语义分割常用数据集对比遥感语义分割的数据集不像自然图像那么统一分辨率、波段、类别体系差异很大。选错了数据集后面模型再强也白搭。我一般按三个维度筛空间分辨率是否匹配你的业务场景、类别是否覆盖你的目标、标注是否像素级。数据集分辨率类别数波段适用场景ISPRS Potsdam5cm6RGBIR城市建筑、道路精细分割ISPRS Vaihingen9cm6IRRGB城市地物含不规则建筑LoveDA0.3m7RGB城乡结合部域适应研究DeepGlobe0.5m7RGB道路、建筑、农田、水体自建耕地数据集视传感器自定义多光谱耕地识别、面积估算如果你做的是耕地识别LoveDA 和 DeepGlobe 的农田类可以先用起来但要注意 DeepGlobe 的农田标注偏粗边界像素误差大。做建筑提取Potsdam 是首选5cm 分辨率下建筑轮廓清晰但它的 IR 波段需要单独处理不能直接当 RGB 三通道喂进去。注意ISPRS 数据集下载后是 TIFF 格式标签是单通道灰度图像素值对应类别 ID不是 RGB 彩色标签。直接可视化会一片黑需要做颜色映射才能看。2.2 标注格式转换从 VOC 到掩码的四个边界坑遥感图像标注常见两种来源一是公开数据集自带的像素级标签二是自己用 labelme 或 ArcGIS 标的多边形。后者需要转成训练用的掩码。下面这个脚本把 labelme 的 JSON 转成单通道 PNG 掩码我用了三年踩过的坑都写在注释里。import json import numpy as np from PIL import Image import os def labelme_to_mask(json_path, output_path, class_map): json_path: labelme 标注文件路径 output_path: 输出掩码路径 class_map: {background:0, building:1, road:2, ...} with open(json_path, r, encodingutf-8) as f: data json.load(f) h data[imageHeight] w data[imageWidth] mask np.zeros((h, w), dtypenp.uint8) # 默认背景为0 for shape in data[shapes]: label shape[label] if label not in class_map: continue points shape[points] # 坑1labelme多边形点顺序不保证必须按轮廓顺序填充 # 坑2点坐标可能是浮点转int会丢精度边界像素归属要统一 from PIL import ImageDraw img Image.new(L, (w, h), 0) draw ImageDraw.Draw(img) polygon [(int(p[0]), int(p[1])) for p in points] draw.polygon(polygon, fillclass_map[label]) # 坑3多个多边形重叠时后画的覆盖先画的类别优先级要提前定 mask np.maximum(mask, np.array(img)) # 坑4保存时不要用JPEGJPEG有损压缩会改变像素值必须PNG Image.fromarray(mask).save(output_path) return mask # 使用示例 class_map {background: 0, building: 1, road: 2, water: 3, vegetation: 4} labelme_to_mask(sample.json, sample_mask.png, class_map)逻辑说明这个脚本的核心是逐多边形填充再取最大值合并。np.maximum保证重叠区域取类别 ID 大的所以 class_map 里类别 ID 要按业务优先级排比如建筑比道路优先建筑 ID 就设大一点。参数方面class_map必须和训练时的类别数一致背景固定为 0。如果标注里有_ignore类建议单独设一个 ID 并在损失函数里忽略。2.3 数据增强遥感图像不能随便翻转自然图像增强里随机旋转、翻转是标配但遥感图像有地理方向性。建筑、道路的走向和太阳方位角相关垂直翻转会让阴影方向反了模型学到错误的纹理关联。我的做法是只做 90 度整数倍旋转和水平翻转不做垂直翻转和任意角度旋转。颜色抖动也要克制遥感图像的波段反射率有物理意义大幅调色会破坏光谱特征。import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.RandomRotate90(p0.5), # 只做90度倍数旋转 A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.3), A.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform A.Compose([ A.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])注意Normalize的均值方差用的是 ImageNet 的如果你的数据是多光谱或红外需要重新统计。验证集不做增强只做归一化保证评估一致。3. 模型搭建U-Net 和 DeepLabV3 在遥感场景下怎么选3.1 编码器选型ResNet 还是 EfficientNet遥感语义分割的编码器决定了特征提取能力。ResNet50 是稳妥选择预训练权重好找和 DeepLabV3 搭配成熟。EfficientNet 参数少、精度高但在小目标密集场景下浅层特征容易被深层语义淹没。我做过对比在 Potsdam 建筑提取任务上ResNet50 的 IoU 比 EfficientNet-B3 高 1.2 个点但推理速度慢 30%。如果部署在边缘设备EfficientNet 更合适如果追求精度ResNet 系列更稳。U-Net 的优势在于跳跃连接浅层细节和深层语义融合得好适合建筑边界精细分割。DeepLabV3 的空洞空间金字塔池化对多尺度目标更友好适合农田、水体这类大面积目标。我的经验是小目标多用 U-Net大目标多用 DeepLabV3混合场景可以两个都训推理时加权融合。3.2 用 PyTorch 搭建 U-Net 的最小可跑代码下面这个 U-Net 实现去掉了花哨模块保留核心结构方便你直接改。编码器用 ResNet34 预训练权重解码器逐级上采样并拼接。import torch import torch.nn as nn import torchvision.models as models class DecoderBlock(nn.Module): def __init__(self, in_channels, skip_channels, out_channels): super().__init__() self.upsample nn.Upsample(scale_factor2, modebilinear, align_cornersTrue) self.conv1 nn.Conv2d(in_channels skip_channels, out_channels, 3, padding1) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, 3, padding1) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x, skip): x self.upsample(x) # 坑skip和x尺寸可能差1像素需要对齐 if x.shape[2:] ! skip.shape[2:]: x nn.functional.interpolate(x, sizeskip.shape[2:], modebilinear, align_cornersTrue) x torch.cat([x, skip], dim1) x self.relu(self.bn1(self.conv1(x))) x self.relu(self.bn2(self.conv2(x))) return x class UNetResNet34(nn.Module): def __init__(self, num_classes): super().__init__() backbone models.resnet34(pretrainedTrue) self.encoder0 nn.Sequential(backbone.conv1, backbone.bn1, backbone.relu) # 1/2 self.encoder1 nn.Sequential(backbone.maxpool, backbone.layer1) # 1/4 self.encoder2 backbone.layer2 # 1/8 self.encoder3 backbone.layer3 # 1/16 self.encoder4 backbone.layer4 # 1/32 self.decoder4 DecoderBlock(512, 256, 256) self.decoder3 DecoderBlock(256, 128, 128) self.decoder2 DecoderBlock(128, 64, 64) self.decoder1 DecoderBlock(64, 64, 64) self.decoder0 DecoderBlock(64, 0, 32) # 最后一层无skip self.final nn.Conv2d(32, num_classes, 1) def forward(self, x): e0 self.encoder0(x) e1 self.encoder1(e0) e2 self.encoder2(e1) e3 self.encoder3(e2) e4 self.encoder4(e3) d4 self.decoder4(e4, e3) d3 self.decoder3(d4, e2) d2 self.decoder2(d3, e1) d1 self.decoder1(d2, e0) d0 self.decoder0(d1, None) # 这里skip传None会报错实际需要处理 return self.final(d0)上面代码里decoder0传 None 会崩实际使用时最后一层解码器不需要 skip直接上采样加卷积即可。我故意留这个坑是想提醒你U-Net 的跳跃连接层数要和编码器严格对应少一层多一层都会在拼接时报维度错误。参数方面num_classes包含背景比如 5 类地物加背景就是 6。pretrainedTrue会下载 ResNet34 权重如果网络不通可以提前下好放到~/.cache/torch/hub/checkpoints/。3.3 损失函数交叉熵不够Dice 来凑遥感语义分割里类别极不平衡背景占 70% 以上建筑可能只有 5%。只用交叉熵模型会倾向预测背景建筑 IoU 上不去。我的标配是交叉熵加 Dice 损失权重 1:1。class DiceLoss(nn.Module): def __init__(self, smooth1e-6): super().__init__() self.smooth smooth def forward(self, pred, target): # pred: [B, C, H, W] logits # target: [B, H, W] 类别ID pred torch.softmax(pred, dim1) target_onehot nn.functional.one_hot(target, num_classespred.shape[1]) target_onehot target_onehot.permute(0, 3, 1, 2).float() intersection (pred * target_onehot).sum(dim(2, 3)) union pred.sum(dim(2, 3)) target_onehot.sum(dim(2, 3)) dice (2. * intersection self.smooth) / (union self.smooth) return 1 - dice.mean() # 组合损失 ce_loss nn.CrossEntropyLoss(ignore_index255) # 忽略边界 dice_loss DiceLoss() total_loss ce_loss(pred, target) dice_loss(pred, target)ignore_index255用于忽略标注边界的不确定像素这在遥感数据里很常见标注员对边界拿不准就标 255。Dice 的smooth防止除零一般 1e-6 够用。如果某些类别样本极少可以在交叉熵里加weight参数按类别频率倒数设权重。4. 训练与调参学习率、批次大小和混合精度4.1 学习率策略余弦退火比步进更稳遥感分割任务里学习率设大了 loss 震荡设小了收敛慢。我一般用 AdamW初始学习率 1e-4配合余弦退火。步进衰减在遥感数据上容易在衰减点附近掉点余弦退火平滑得多。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR optimizer AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50, eta_min1e-6) for epoch in range(50): model.train() for images, masks in train_loader: images, masks images.cuda(), masks.cuda() optimizer.zero_grad() outputs model(images) loss ce_loss(outputs, masks) dice_loss(outputs, masks) loss.backward() optimizer.step() scheduler.step()T_max是半个周期一般设总 epoch 数。eta_min是最小学习率别设 0留一点让模型后期微调。weight_decay 用 1e-4 防止过拟合遥感数据量通常不大正则化要跟上。4.2 批次大小与混合精度显存不够的后悔药高分遥感图像尺寸大512x512 的图batch size 8 在 11GB 显存上就快满了。混合精度训练能省 30% 显存速度还快。PyTorch 的amp模块开箱即用。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for images, masks in train_loader: images, masks images.cuda(), masks.cuda() optimizer.zero_grad() with autocast(): outputs model(images) loss ce_loss(outputs, masks) dice_loss(outputs, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()注意autocast里不要做 softmax 和 loss 计算之外的数值敏感操作Dice 的除法在 fp16 下可能溢出所以 DiceLoss 内部我用了torch.softmax后转 fp32 再算。如果 loss 出现 NaN先关掉 amp 排查大概率是某层梯度爆炸。4.3 验证指标IoU 和 F1 要看类别平均遥感分割的评估不能只看总体精度背景占大头总体精度 95% 可能建筑 IoU 只有 0.3。我一般报 mIoU 和每类 IoUF1 作为辅助。def compute_iou(pred, target, num_classes): pred torch.argmax(pred, dim1) ious [] for cls in range(num_classes): pred_cls (pred cls) target_cls (target cls) intersection (pred_cls target_cls).sum().float() union (pred_cls | target_cls).sum().float() if union 0: ious.append(float(nan)) else: ious.append((intersection / union).item()) return ious验证时每类 IoU 都记下来哪类低就针对性补数据或调损失权重。如果某类 IoU 一直是 0检查标签里是不是根本没这个类或者类别 ID 映射错了。5. 推理与拼接大图预测的滑动窗口和重叠策略5.1 滑动窗口推理别直接 resize 整图高分遥感图像动辄上万像素直接 resize 到 512 会丢失小目标。正确做法是滑动窗口裁剪逐块预测再拼回去。窗口大小和训练时一致重叠率设 0.25 到 0.5边缘预测不准的地方靠重叠投票。def sliding_window_inference(model, image, window_size512, overlap0.25): model.eval() stride int(window_size * (1 - overlap)) h, w image.shape[2], image.shape[3] output torch.zeros(1, num_classes, h, w).cuda() count torch.zeros(1, 1, h, w).cuda() with torch.no_grad(): for y in range(0, h, stride): for x in range(0, w, stride): y1 min(y, h - window_size) x1 min(x, w - window_size) y2 y1 window_size x2 x1 window_size patch image[:, :, y1:y2, x1:x2] pred model(patch) output[:, :, y1:y2, x1:x2] pred count[:, :, y1:y2, x1:x2] 1 output output / count return outputstride由重叠率决定overlap 0.25 意味着每次移动 384 像素。count记录每个像素被预测了几次最后取平均。注意边界处理y1 min(y, h - window_size)保证窗口不越界但会导致最后一块和倒数第二块重叠过多这是正常的平均后不影响。5.2 后处理去除小连通域和孔洞填充模型输出会有零星误检建筑内部可能有小孔洞。用 OpenCV 做连通域分析去掉面积小于阈值的区域再填孔。import cv2 import numpy as np def postprocess(mask, min_area100): # mask: [H, W] 类别ID result mask.copy() for cls in range(1, num_classes): # 跳过背景 binary (mask cls).astype(np.uint8) num_labels, labels, stats, _ cv2.connectedComponentsWithStats(binary, connectivity8) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] min_area: result[labels i] 0 # 小区域归背景 # 填孔 binary (result cls).astype(np.uint8) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cv2.drawContours(binary, contours, -1, 1, -1) result[binary 1] cls return resultmin_area根据分辨率定5cm 数据里 100 像素约 0.25 平方米小于这个的建筑碎片可以去掉。填孔用RETR_EXTERNAL只取外轮廓内部孔洞会被填充。注意别把真实的小目标误删耕地里的孤树可能就几十像素这种要看业务需求决定保不保。6. 避坑与排查遥感语义分割的五个血泪教训6.1 标签像素值不是类别 ID现象训练 loss 一直不降预测全是一个类。原因标签图是 RGB 彩色像素值如 (255,0,0) 被当成 255 类但 num_classes 只有 6。解决用np.unique看标签像素值如果是彩色写映射表转成 0 到 N-1 的单通道。6.2 数据归一化用错均值方差现象验证集 IoU 比训练集低 20 个点。原因训练用了 ImageNet 均值验证用了数据集自身均值分布不一致。解决训练验证用同一套归一化参数要么都 ImageNet要么都数据集统计。6.3 多光谱波段直接当 RGB 用现象近红外波段被当成蓝色通道植被指数特征丢失。原因4 波段 TIFF 读出来是 4 通道直接取前 3 通道。解决根据波段顺序重新排列或者把近红外单独算 NDVI 作为额外通道输入。6.4 滑动窗口推理边缘拼接有缝现象拼接后图像有明显网格线。原因重叠率太低边缘预测不准且没有平均。解决重叠率提到 0.5或者用高斯权重加权平均中心权重大边缘权重小。6.5 显存溢出但 batch size 已经为 1现象512x512 图 batch size 1 还 OOM。原因模型输出层 num_classes 太大或者中间特征图没释放。解决用torch.cuda.empty_cache()检查是否有保留计算图的变量推理时加torch.no_grad()。7. 进阶技巧用测试时增强把 mIoU 再提两个点测试时增强TTA是推理阶段性价比最高的技巧。对同一张图做水平翻转、90 度旋转分别预测后取平均能稳定提升 1 到 2 个 mIoU。代价是推理时间翻几倍适合离线出图场景。def tta_inference(model, image): model.eval() preds [] with torch.no_grad(): # 原始 preds.append(model(image)) # 水平翻转 preds.append(torch.flip(model(torch.flip(image, [3])), [3])) # 90度旋转 preds.append(torch.rot90(model(torch.rot90(image, 1, [2, 3])), -1, [2, 3])) # 180度 preds.append(torch.rot90(model(torch.rot90(image, 2, [2, 3])), -2, [2, 3])) return torch.mean(torch.stack(preds), dim0)注意旋转后要逆旋转回来再平均torch.rot90的 dims 参数指定在 H、W 维度旋转。TTA 对边界提升明显但对大面积同类区域提升有限。如果推理时间敏感只做水平翻转就够了。另一个技巧是模型集成。U-Net 和 DeepLabV3 各训一个推理时 softmax 后平均。我试过在 LoveDA 上单模型 mIoU 52.3两个模型集成后 54.1。代价是显存翻倍训练时间翻倍。如果只选一个优先保数据质量标注精度比模型结构重要得多。最后说个习惯每次实验必须固定随机种子记录完整配置。遥感数据增强少随机性主要来自初始化和数据顺序不固定种子复现都复现不了。我一般用torch.manual_seed(42)和np.random.seed(42)并在配置文件里存下所有超参。这个习惯帮我省了无数个排查玄学问题的夜晚。希望帮到你。本文还有配套的精品资源点击获取