ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

遥感语义分割实战:UNet与Deeplab V3+从数据到部署全解析

遥感语义分割实战:UNet与Deeplab V3+从数据到部署全解析 简介这份资源面向计算机、遥感与人工智能方向的本科生与研究生提供一套可直接运行的遥感图像语义分割项目用于毕业设计、期末大作业或课程设计场景。项目基于Python实现分别采用Deeplab V3与UNet两种主流分割网络便于对比不同模型在遥感影像上的表现。压缩包共11个文件约8.67MB包含4个py源码文件、5个png与1个jpg示例图像、1份docx说明文档代码注释完整新手也能理解训练与推理流程。资源已积累335人学习下载说明其可参考性较强。读者可获得完整的数据制作脚本、模型定义、训练与可视化代码以及配套数据集和文档说明下载后简单部署即可复现实验快速搭建起从数据预处理到结果展示的完整链路为论文撰写与答辩提供扎实支撑。1. 遥感语义分割落地从 Deeplab V3 与 UNet 的选型到跑通遥感图像语义分割这件事真正卡住大多数人的从来不是模型结构而是数据怎么进来、标签怎么对齐、显存怎么不炸。我见过太多人一上来就 clone 一份 unet 代码拿遥感数据集直接怼进去结果 loss 不降、mask 全黑、预测图一片糊。这个标题里其实藏着两条主流路线Deeplab V3 靠空洞卷积加多尺度 ASPP 抓大范围地物UNet 靠编码器-解码器加跳跃连接抠边界细节。前者适合建筑、水体、耕地这类连片目标后者在道路、田埂、小目标上更稳。这篇笔记面向的是手里已经有遥感影像、想用 Python 把语义分割真正跑起来的人从环境、数据、模型、训练到排错一步步给到能抄的配置和参数。新手能照着复现熟手能直接看边界条件和踩坑点。2. 遥感数据准备从原始影像到可训练 mask 的完整链路遥感语义分割和自然图像分割最大的差别在数据侧。自然图像是 RGB 三通道、尺寸规整、标注精细遥感影像动辄几千乘几千像素、波段可能是 4 通道甚至更多、标签往往是栅格 mask 而不是多边形。如果数据这步没处理好后面换什么模型都是白搭。我一般把数据准备拆成四件事波段选择与拉伸、切片与重叠、标签对齐与类别映射、数据集划分。2.1 波段选择、拉伸与归一化遥感影像常见的有 RGB、多光谱如 4 波段含近红外、高光谱。做语义分割时如果只是区分建筑、道路、植被、水体RGB 三波段通常够用要区分更细的植被类型或水体浑浊度加近红外会有帮助。但注意不同波段的数值范围差异极大直接送进网络会导致训练不稳定。import rasterio import numpy as np def read_and_normalize(img_path, bands(1, 2, 3)): 读取遥感影像指定波段并做 2%-98% 拉伸归一化 with rasterio.open(img_path) as src: # rasterio 波段从 1 开始计数 img src.read(bands) # shape: (C, H, W) img np.transpose(img, (1, 2, 0)).astype(np.float32) # (H, W, C) # 逐波段做百分位拉伸避免异常值拉偏整体分布 out np.zeros_like(img, dtypenp.float32) for c in range(img.shape[2]): band img[:, :, c] p2, p98 np.percentile(band, (2, 98)) band np.clip(band, p2, p98) band (band - p2) / (p98 - p2 1e-6) out[:, :, c] band return out这段代码的关键在百分位拉伸而不是简单除以最大值。遥感影像里常有云、阴影、异常高亮像元用 min-max 归一化会被这些极值带偏2%-98% 是实践中比较稳的选择。参数bands按你的数据实际情况改如果用了近红外记得同步改模型输入通道数。2.2 大图切片与重叠策略遥感影像动辄 5000×5000 以上不可能整图送进网络。常见做法是切成 512×512 或 256×256 的 patch。但直接无重叠切会在边界处产生拼接缝预测时相邻 patch 边缘不一致。我一般用 stride 小于 patch size 的重叠切法推理时对重叠区域做加权平均。def slide_crop(img, patch_size512, stride256): 重叠切片返回 patch 列表和对应左上角坐标 h, w img.shape[:2] patches, coords [], [] for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): patches.append(img[y:ypatch_size, x:xpatch_size]) coords.append((y, x)) # 处理右边缘和下边缘不足一个 patch 的情况 if (h - patch_size) % stride ! 0: for x in range(0, w - patch_size 1, stride): patches.append(img[h-patch_size:h, x:xpatch_size]) coords.append((h-patch_size, x)) if (w - patch_size) % stride ! 0: for y in range(0, h - patch_size 1, stride): patches.append(img[y:ypatch_size, w-patch_size:w]) coords.append((y, w-patch_size)) return patches, coordspatch_size和stride的比值决定重叠程度。stride 取 patch_size 的一半是常见配置重叠 50%拼接缝基本看不出来。如果显存紧张patch_size 降到 256stride 取 128。注意边缘补丁要单独处理否则最后几行几列会丢。2.3 标签对齐与类别映射遥感标签常见两种一种是栅格 mask像素值就是类别 id另一种是 shapefile 多边形需要栅格化。不管哪种都要确保标签和影像严格对齐——同样的尺寸、同样的地理范围、同样的切片坐标。我踩过最坑的一次是影像和标签的坐标系差了一个像素训练时 loss 看着在降预测出来整体偏移一格。def build_label_mapping(raw_mask, class_map): 把原始 mask 像素值映射到 0~N-1 连续类别 id out np.zeros_like(raw_mask, dtypenp.uint8) for raw_val, new_id in class_map.items(): out[raw_mask raw_val] new_id return out # 示例原始值 0背景, 1建筑, 2道路, 255忽略 class_map {0: 0, 1: 1, 2: 2} # 255 不映射训练时用 ignore_index255 忽略类别映射必须连续否则 CrossEntropyLoss 会报 index 越界。忽略类如云、阴影、未标注区域统一设成 255训练时传给ignore_index。这一步不做模型会把忽略区域也当正样本学精度虚高。2.4 数据集划分与增强遥感数据划分不能随机打乱因为相邻 patch 高度相似随机划分会导致训练集和验证集泄漏。正确做法是按地理区域划分或者按整图划分后再切片。增强方面水平翻转、垂直翻转、90 度旋转是安全的颜色抖动要谨慎遥感影像的颜色和地物类型有强关联抖太狠会破坏语义。3. UNet 在遥感分割上的实现与训练配置UNet 是遥感语义分割里最稳的基线。结构简单、对小目标友好、训练快适合作为第一个跑通的模型。这一章给到完整的模型定义、损失函数、训练循环和参数配置。3.1 UNet 结构定义与通道配置标准 UNet 是 4 层下采样加 4 层上采样每层两个 3×3 卷积加 BN 加 ReLU。遥感影像如果输入是 4 波段第一层卷积的 in_channels 要改成 4。import torch import torch.nn as nn 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, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1, biasFalse), 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_classes3, base64): super().__init__() # 编码器 self.down1 DoubleConv(in_channels, base) self.down2 DoubleConv(base, base*2) self.down3 DoubleConv(base*2, base*4) self.down4 DoubleConv(base*4, base*8) self.pool nn.MaxPool2d(2) # 瓶颈 self.bottleneck DoubleConv(base*8, base*16) # 解码器 self.up4 nn.ConvTranspose2d(base*16, base*8, 2, stride2) self.dec4 DoubleConv(base*16, base*8) self.up3 nn.ConvTranspose2d(base*8, base*4, 2, stride2) self.dec3 DoubleConv(base*8, base*4) self.up2 nn.ConvTranspose2d(base*4, base*2, 2, stride2) self.dec2 DoubleConv(base*4, base*2) self.up1 nn.ConvTranspose2d(base*2, base, 2, stride2) self.dec1 DoubleConv(base*2, base) self.out nn.Conv2d(base, num_classes, 1) def forward(self, x): d1 self.down1(x) d2 self.down2(self.pool(d1)) d3 self.down3(self.pool(d2)) d4 self.down4(self.pool(d3)) b self.bottleneck(self.pool(d4)) u4 self.dec4(torch.cat([self.up4(b), d4], dim1)) u3 self.dec3(torch.cat([self.up3(u4), d3], dim1)) u2 self.dec2(torch.cat([self.up2(u3), d2], dim1)) u1 self.dec1(torch.cat([self.up1(u2), d1], dim1)) return self.out(u1)base64是标准配置显存不够降到 32。num_classes按你的类别数改包含背景。跳跃连接用torch.cat拼接这是 UNet 恢复边界细节的关键。注意上采样用ConvTranspose2d还是bilinear conv有争议前者可能产生棋盘伪影后者更平滑实践中两者差距不大我一般用前者。3.2 损失函数与类别不平衡处理遥感数据类别极不平衡背景往往占 70% 以上。纯 CrossEntropy 会让模型偏向背景。常见做法是 CrossEntropy 加 Dice Loss 组合。class DiceLoss(nn.Module): def __init__(self, ignore_index255): super().__init__() self.ignore_index ignore_index def forward(self, logits, targets): probs torch.softmax(logits, dim1) valid targets ! self.ignore_index targets targets.clone() targets[~valid] 0 one_hot torch.nn.functional.one_hot( targets, num_classeslogits.shape[1] ).permute(0, 3, 1, 2).float() valid valid.unsqueeze(1).float() probs probs * valid one_hot one_hot * valid inter (probs * one_hot).sum(dim(0, 2, 3)) union probs.sum(dim(0, 2, 3)) one_hot.sum(dim(0, 2, 3)) dice (2 * inter 1e-6) / (union 1e-6) return 1 - dice.mean() # 组合损失 ce nn.CrossEntropyLoss(ignore_index255) dice DiceLoss(ignore_index255) def criterion(logits, targets): return ce(logits, targets) dice(logits, targets)Dice Loss 对类别不平衡更鲁棒但训练初期梯度不稳定所以和 CE 组合。权重上我一般 CE 占 1.0Dice 占 0.5 到 1.0。如果小目标还是学不出来可以给 Dice 加权到 2.0。3.3 训练循环与关键参数from torch.utils.data import DataLoader from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model UNet(in_channels3, num_classes3, base64).cuda() optimizer AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max100) scaler torch.cuda.amp.GradScaler() for epoch in range(100): model.train() for imgs, masks in train_loader: imgs, masks imgs.cuda(), masks.cuda() optimizer.zero_grad() with torch.cuda.amp.autocast(): logits model(imgs) loss criterion(logits, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step()关键参数lr1e-3配 AdamW 是遥感分割的常用起点如果 loss 震荡降到 5e-4。weight_decay1e-4防过拟合。混合精度训练能省 30% 到 40% 显存512×512 patch、base64 的 UNet 在 8G 显存上 batch_size 能到 4。CosineAnnealingLR比 StepLR 更平滑T_max 设成总 epoch 数。3.4 验证指标与模型保存遥感分割不能只看 loss要看 mIoU 和各类 IoU。背景类 IoU 通常很高会拉高整体指标所以要单独看小目标类。def compute_iou(pred, target, num_classes, ignore_index255): pred pred.argmax(dim1) valid target ! ignore_index ious [] for c in range(num_classes): pred_c (pred c) valid target_c (target c) valid inter (pred_c target_c).sum().item() union (pred_c | target_c).sum().item() ious.append(inter / (union 1e-6)) return ious保存模型时存state_dict而不是整个模型方便后续加载。验证 mIoU 最高的那个 epoch 单独存一份best.pth别只存最后一个 epoch。4. Deeplab V3 的骨干替换与多尺度训练Deeplab V3 比 UNet 复杂但它在连片大目标上优势明显。核心是 ASPP 多尺度空洞卷积加编码器-解码器结构。遥感场景下骨干网络的选择和 ASPP 的膨胀率配置是关键。4.1 骨干网络选择ResNet 还是 MobileNetDeeplab V3 原版用 Xception但实践中 ResNet-50/101 更常见因为 torchvision 直接有预训练权重。MobileNetV2 适合显存紧张或要部署到边缘设备的场景但精度会掉 2 到 3 个点。import torchvision.models as models import torch.nn as nn class ASPP(nn.Module): def __init__(self, in_ch, out_ch256, rates(6, 12, 18)): super().__init__() self.branches nn.ModuleList() for r in rates: self.branches.append(nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, paddingr, dilationr, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), )) # 全局池化分支 self.global_pool nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_ch, out_ch, 1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) self.project nn.Sequential( nn.Conv2d(out_ch * (len(rates) 1), out_ch, 1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Dropout(0.5), ) def forward(self, x): h, w x.shape[2:] feats [branch(x) for branch in self.branches] gp self.global_pool(x) gp nn.functional.interpolate(gp, size(h, w), modebilinear, align_cornersFalse) feats.append(gp) return self.project(torch.cat(feats, dim1))膨胀率(6, 12, 18)是原版配置对应 16 倍下采样的特征图。如果输出步长改成 8膨胀率要相应减半成(3, 6, 9)否则感受野过大反而丢细节。全局池化分支不能省它提供图像级上下文对遥感大场景很重要。4.2 输出步长与空洞卷积配置Deeplab V3 默认 output_stride16即特征图缩小 16 倍。遥感影像里小目标多建议改成 8保留更多空间细节。改法是把骨干网络最后两个 stage 的 stride 从 2 改成 1同时用膨胀卷积维持感受野。def replace_stride_with_dilation(model, dilation(False, True, True)): 把 ResNet 最后两个 stage 的 stride 换成 dilation # 实际实现需要逐层替换这里给出思路 # layer3 和 layer4 的 Bottleneck stride 改为 1 # 对应卷积的 dilation 改为 2 和 4 pass这一步比较繁琐偷懒的做法是直接用output_stride16然后在解码器里多保留一层低层特征。精度差距在 1 个点以内但省很多事。4.3 多尺度训练与推理遥感影像里地物尺度差异大同一张图里既有几十像素的小车也有上千像素的连片建筑。多尺度训练能提升泛化。import random def multi_scale_aug(img, mask, scales(0.75, 1.0, 1.25, 1.5)): scale random.choice(scales) h, w img.shape[:2] new_h, new_w int(h * scale), int(w * scale) img cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (new_w, new_h), interpolationcv2.INTER_NEAREST) # 随机裁剪回原尺寸 if new_h h and new_w w: y random.randint(0, new_h - h) x random.randint(0, new_w - w) img img[y:yh, x:xw] mask mask[y:yh, x:xw] return img, maskmask 缩放必须用最近邻否则类别 id 会被插值成小数。推理时可以用多尺度测试增强TTA把原图、翻转图、不同尺度的预测结果平均mIoU 通常能涨 1 到 2 个点代价是推理时间翻几倍。4.4 学习率与训练策略差异Deeplab V3 比 UNet 难训学习率要更低。骨干用预训练权重时骨干 lr 设 1e-4ASPP 和解码器设 1e-3这叫差分学习率。如果从头训统一 1e-3 但要有 warmup。def get_param_groups(model, backbone_lr1e-4, head_lr1e-3): backbone_params, head_params [], [] for name, param in model.named_parameters(): if backbone in name or layer in name: backbone_params.append(param) else: head_params.append(param) return [ {params: backbone_params, lr: backbone_lr}, {params: head_params, lr: head_lr}, ]差分学习率能让预训练骨干不被大梯度破坏同时让新初始化的 ASPP 快速收敛。warmup 一般设 500 到 1000 步从 1e-6 线性升到目标 lr。5. 避坑与排查遥感分割训练中最容易翻车的五个点这一章是我自己踩过的坑按现象、原因、解决三段式写。每一条都对应真实训练日志里能看到的异常。5.1 现象loss 不降或降得很慢预测全黑原因通常是三类学习率太大导致梯度爆炸、标签类别 id 不连续导致 loss 计算异常、输入归一化没做导致数值范围失控。先检查标签最大值是否等于 num_classes-1再打印一个 batch 的输入范围正常应该在 0 到 1 之间。如果输入范围是 0 到 255说明归一化没生效。学习率从 1e-3 降到 1e-4 试一轮如果 loss 开始降就是 lr 问题。5.2 现象训练集 mIoU 很高验证集很低典型过拟合。遥感数据如果按 patch 随机划分相邻 patch 高度相似会造成数据泄漏。改成按整图或按地理区域划分。另外增强强度不够也会过拟合加随机旋转、缩放、颜色抖动。如果数据量确实小用预训练骨干加冻结前几层。5.3 现象预测结果有网格状拼接缝推理时 patch 无重叠或重叠不足。把 stride 改成 patch_size 的一半重叠区域做加权平均。权重可以用高斯核中心高边缘低。如果还有缝检查是否在拼接时坐标算错特别是边缘补丁的坐标。5.4 现象小目标类别 IoU 始终为 0类别不平衡导致模型放弃小目标。三个方向损失函数加 Dice 或 Focal Loss、采样时对小目标区域过采样、后处理时对小目标类别降低阈值。如果小目标在整图里占比不到 1%考虑先做目标检测再分割或者用滑动窗口推理时对小目标区域单独放大。5.5 现象显存溢出batch_size 只能设 1显存瓶颈通常在上采样阶段特征图尺寸大、通道多。三个手段开混合精度训练、把 patch_size 从 512 降到 256、用 gradient checkpointing。如果还不够把 base 通道数从 64 降到 32精度掉 1 到 2 个点但能跑起来。另外注意 DataLoader 的 num_workers 别设太大否则内存也会爆。6. 从跑通到好用推理加速与精度提升的几个实战技巧模型训完只是开始真正上线还要解决推理速度和精度平衡。这一章给几个我常用的技巧都是能直接落地的。第一个是 ONNX 导出加 TensorRT 加速。PyTorch 模型推理在遥感大图上很慢一张 5000×5000 的图切 patch 后可能要几十秒。导出 ONNX 再用 TensorRT 能快 2 到 3 倍。import torch.onnx model.eval() dummy torch.randn(1, 3, 512, 512).cuda() torch.onnx.export( model, dummy, unet_remote.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch, 2: h, 3: w}, output: {0: batch, 2: h, 3: w}}, opset_version11, )dynamic_axes让模型支持可变输入尺寸遥感推理时不用固定 patch 大小。opset 用 11 兼容性最好。导出后可以用 onnxruntime 或 TensorRT 加载TensorRT 需要额外做 FP16 或 INT8 量化精度损失在 0.5 个点以内。第二个是推理时的滑动窗口加权。前面提过重叠切片这里给个完整的拼接函数。def merge_patches(patches, coords, img_shape, patch_size512, num_classes3): 重叠区域加权平均拼接 h, w img_shape[:2] prob_map np.zeros((num_classes, h, w), dtypenp.float32) count_map np.zeros((h, w), dtypenp.float32) # 高斯权重中心高边缘低 weight np.outer( np.hanning(patch_size), np.hanning(patch_size) ).astype(np.float32) for patch, (y, x) in zip(patches, coords): prob_map[:, y:ypatch_size, x:xpatch_size] patch * weight count_map[y:ypatch_size, x:xpatch_size] weight prob_map / (count_map 1e-6) return prob_map.argmax(axis0)np.hanning生成一维汉宁窗外积得到二维权重。中心权重接近 1边缘接近 0这样拼接缝自然消失。如果追求更快可以用矩形权重但边缘会有轻微不一致。第三个技巧是类别后处理。遥感分割结果常有零星误分类用连通域分析去掉小于阈值的斑块。from scipy import ndimage def remove_small_objects(mask, min_size100): 去掉小于 min_size 像素的连通域 result mask.copy() for c in np.unique(mask): if c 0: continue binary mask c labeled, num ndimage.label(binary) for i in range(1, num 1): if (labeled i).sum() min_size: result[labeled i] 0 return resultmin_size按你的分辨率定0.5 米分辨率下 100 像素大概是 25 平方米能去掉大部分噪声。注意别把真实小目标也去掉道路上的车、小建筑要保留的话阈值设小一点。最后一个习惯每次训完模型我都会拿几张典型图做可视化对比原图、标签、UNet 预测、Deeplab V3 预测放一起看。指标好看不代表实际好用有些模型 mIoU 高但在特定地物上翻车。这个习惯帮我省了很多次返工。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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