拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

遥感语义分割实战: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, dtype=np.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_size=512, stride=256): """重叠切片,返回 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:y+patch_size, x:x+patch_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:x+patch_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:y+patch_size, w-patch_size:w]) coords.append((y, w-patch_size)) return patches, coords

patch_size和stride的比值决定重叠程度。stride 取 patch_size 的一半是常见配置,重叠 50%,拼接缝基本看不出来。如果显存紧张,patch_size 降到 256,stride 取 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, dtype=np.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_index=255 忽略

类别映射必须连续,否则 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, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), 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=3, base=64): 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, stride=2) self.dec4 = DoubleConv(base*16, base*8) self.up3 = nn.ConvTranspose2d(base*8, base*4, 2, stride=2) self.dec3 = DoubleConv(base*8, base*4) self.up2 = nn.ConvTranspose2d(base*4, base*2, 2, stride=2) self.dec2 = DoubleConv(base*4, base*2) self.up1 = nn.ConvTranspose2d(base*2, base, 2, stride=2) 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], dim=1)) u3 = self.dec3(torch.cat([self.up3(u4), d3], dim=1)) u2 = self.dec2(torch.cat([self.up2(u3), d2], dim=1)) u1 = self.dec1(torch.cat([self.up1(u2), d1], dim=1)) return self.out(u1)

base=64是标准配置,显存不够降到 32。num_classes按你的类别数改,包含背景。跳跃连接用torch.cat拼接,这是 UNet 恢复边界细节的关键。注意上采样用ConvTranspose2d还是bilinear + conv有争议,前者可能产生棋盘伪影,后者更平滑,实践中两者差距不大,我一般用前者。

3.2 损失函数与类别不平衡处理

遥感数据类别极不平衡,背景往往占 70% 以上。纯 CrossEntropy 会让模型偏向背景。常见做法是 CrossEntropy 加 Dice Loss 组合。

class DiceLoss(nn.Module): def __init__(self, ignore_index=255): super().__init__() self.ignore_index = ignore_index def forward(self, logits, targets): probs = torch.softmax(logits, dim=1) valid = targets != self.ignore_index targets = targets.clone() targets[~valid] = 0 one_hot = torch.nn.functional.one_hot( targets, num_classes=logits.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_index=255) dice = DiceLoss(ignore_index=255) def criterion(logits, targets): return ce(logits, targets) + dice(logits, targets)

Dice Loss 对类别不平衡更鲁棒,但训练初期梯度不稳定,所以和 CE 组合。权重上我一般 CE 占 1.0,Dice 占 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_channels=3, num_classes=3, base=64).cuda() optimizer = AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=100) 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()

关键参数:lr=1e-3配 AdamW 是遥感分割的常用起点,如果 loss 震荡降到 5e-4。weight_decay=1e-4防过拟合。混合精度训练能省 30% 到 40% 显存,512×512 patch、base=64 的 UNet 在 8G 显存上 batch_size 能到 4。CosineAnnealingLR比 StepLR 更平滑,T_max 设成总 epoch 数。

3.4 验证指标与模型保存

遥感分割不能只看 loss,要看 mIoU 和各类 IoU。背景类 IoU 通常很高,会拉高整体指标,所以要单独看小目标类。

def compute_iou(pred, target, num_classes, ignore_index=255): pred = pred.argmax(dim=1) 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 还是 MobileNet

Deeplab 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_ch=256, 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, padding=r, dilation=r, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), )) # 全局池化分支 self.global_pool = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_ch, out_ch, 1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) self.project = nn.Sequential( nn.Conv2d(out_ch * (len(rates) + 1), out_ch, 1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), 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), mode='bilinear', align_corners=False) feats.append(gp) return self.project(torch.cat(feats, dim=1))

膨胀率(6, 12, 18)是原版配置,对应 16 倍下采样的特征图。如果输出步长改成 8,膨胀率要相应减半成(3, 6, 9),否则感受野过大反而丢细节。全局池化分支不能省,它提供图像级上下文,对遥感大场景很重要。

4.2 输出步长与空洞卷积配置

Deeplab V3+ 默认 output_stride=16,即特征图缩小 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_stride=16,然后在解码器里多保留一层低层特征。精度差距在 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), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (new_w, new_h), interpolation=cv2.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:y+h, x:x+w] mask = mask[y:y+h, x:x+w] return img, mask

mask 缩放必须用最近邻,否则类别 id 会被插值成小数。推理时可以用多尺度测试增强(TTA),把原图、翻转图、不同尺度的预测结果平均,mIoU 通常能涨 1 到 2 个点,代价是推理时间翻几倍。

4.4 学习率与训练策略差异

Deeplab V3+ 比 UNet 难训,学习率要更低。骨干用预训练权重时,骨干 lr 设 1e-4,ASPP 和解码器设 1e-3,这叫差分学习率。如果从头训,统一 1e-3 但要有 warmup。

def get_param_groups(model, backbone_lr=1e-4, head_lr=1e-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_version=11, )

dynamic_axes让模型支持可变输入尺寸,遥感推理时不用固定 patch 大小。opset 用 11 兼容性最好。导出后可以用 onnxruntime 或 TensorRT 加载,TensorRT 需要额外做 FP16 或 INT8 量化,精度损失在 0.5 个点以内。

第二个是推理时的滑动窗口加权。前面提过重叠切片,这里给个完整的拼接函数。

def merge_patches(patches, coords, img_shape, patch_size=512, num_classes=3): """重叠区域加权平均拼接""" h, w = img_shape[:2] prob_map = np.zeros((num_classes, h, w), dtype=np.float32) count_map = np.zeros((h, w), dtype=np.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:y+patch_size, x:x+patch_size] += patch * weight count_map[y:y+patch_size, x:x+patch_size] += weight prob_map /= (count_map + 1e-6) return prob_map.argmax(axis=0)

np.hanning生成一维汉宁窗,外积得到二维权重。中心权重接近 1,边缘接近 0,这样拼接缝自然消失。如果追求更快,可以用矩形权重,但边缘会有轻微不一致。

第三个技巧是类别后处理。遥感分割结果常有零星误分类,用连通域分析去掉小于阈值的斑块。

from scipy import ndimage def remove_small_objects(mask, min_size=100): """去掉小于 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 result

min_size按你的分辨率定,0.5 米分辨率下 100 像素大概是 25 平方米,能去掉大部分噪声。注意别把真实小目标也去掉,道路上的车、小建筑要保留的话阈值设小一点。

最后一个习惯:每次训完模型,我都会拿几张典型图做可视化对比,原图、标签、UNet 预测、Deeplab V3+ 预测放一起看。指标好看不代表实际好用,有些模型 mIoU 高但在特定地物上翻车。这个习惯帮我省了很多次返工。希望帮到你。

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

返回列表