ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

遥感图像语义分割实战:UNet模型从数据到部署全流程

遥感图像语义分割实战:UNet模型从数据到部署全流程 简介这份毕业设计资源包围绕UNet神经网络在遥感图像语义分割中的应用展开面向计算机视觉方向的高年级本科生与研究生帮助读者理解并复现像素级分类任务涵盖建筑物、水体、植被等典型地物识别场景。压缩包共69个文件约46.92MB包含6个Python源码文件、3个Jupyter Notebook、5个LaTeX论文源文件、32张PNG图表及若干字体与配置脚本覆盖模型定义、数据处理、训练与预测全流程并附有完整毕业论文与参考文献。已有299人学习下载。读者可从中获得一套可运行的UNet分割工程代码、数据制作与训练演示笔记、TensorBoard可视化启动脚本以及论文写作与图表排版参考适合作为课程设计、毕业设计或遥感分割入门实践的完整参考方案。1. 从一张遥感图说起UNet 语义分割到底在解决什么问题遥感图像语义分割说白了就是给卫星或航拍图里的每个像素贴标签——建筑、道路、水体、植被、农田一个像素都不放过。这件事的难点不在于「分类」而在于「定位精度」遥感图分辨率高、目标尺度差异大一栋楼可能占几千个像素一条乡间小路可能只有两三个像素宽。传统滑窗分类方法在这种场景下基本翻车边缘糊成一团小目标直接丢失。UNet 之所以成为这个方向最常被选用的基线核心在于它的编码器-解码器结构加跳跃连接。编码器逐层下采样提取语义特征解码器逐层上采样恢复空间分辨率而跳跃连接把编码器浅层的高分辨率特征直接拼到解码器对应层让边缘和小目标不至于在深层特征里被「抹掉」。这套结构在医学影像分割里先跑通后来被大量迁移到遥感领域效果稳定代码量也不大非常适合作为毕业设计的技术底座。这篇文章面向的是需要从零跑通一套遥感图像语义分割方案的人——不管你是拿它做毕设、做课程项目还是想快速验证一个遥感分割想法。我会按「数据怎么准备 → 模型怎么搭 → 训练怎么调 → 坑在哪 → 怎么验证」的顺序把每一步的参数、代码和判断依据讲清楚。不堆概念只讲能直接上手的东西。2. 遥感数据从原始影像到可训练标签标注、切图与增强2.1 遥感语义分割数据集长什么样遥感语义分割的数据集和自然图像分割比如 COCO、VOC有本质区别。自然图像里一张图通常只有几个目标遥感图一张可能覆盖几平方公里包含几百个建筑、几十条道路。这意味着两件事第一标注成本极高第二图像尺寸极大不可能直接塞进网络。常见做法是把大幅遥感影像切成固定大小的图块patch比如 512×512 或 256×256标签同步切。切的时候要有重叠overlap否则边缘目标会被切断训练时模型学到的是「半个建筑」推理时遇到完整建筑反而分不对。我一般设 overlap 为 patch 尺寸的 25%比如 512 的图块用 128 像素重叠。标注格式方面遥感领域常见的有三种栅格标签每个像素一个类别值单通道 PNG/TIF、多边形矢量Shapefile/GeoJSON、以及 COCO 格式的 polygon JSON。训练 UNet 最直接的是栅格标签因为损失函数直接按像素算。如果你拿到的是矢量标注需要先栅格化。import rasterio import numpy as np from rasterio.features import rasterize # 读取原始遥感影像 with rasterio.open(raw_image.tif) as src: image src.read() # shape: (bands, H, W) transform src.transform crs src.crs # 假设有 Shapefile 读取后的几何和类别 # geoms [(geom, class_id), ...] # 栅格化标签与影像空间对齐 label rasterize( shapesgeoms, out_shape(image.shape[1], image.shape[2]), transformtransform, fill0, # 背景类 dtypenp.uint8 )这段代码的关键参数是transform和out_shape——必须和原始影像完全一致否则标签和图像会错位。fill0表示未标注区域归为背景如果你的数据里有「忽略区域」比如云遮挡可以单独设一个 ignore index在损失函数里排除。2.2 切图脚本与四个边界坑切图看起来简单但实际操作里有几个容易忽略的点。第一切图时要保证图像和标签用同一套坐标不能一个从左上角切、一个从中心切。第二边缘不足一个 patch 的区域要补齐padding否则最后一行一列的图块尺寸不对。第三如果图像有 NoData 值比如影像边缘的黑边要在切图时记录有效区域掩膜训练时排除。第四类别不平衡在切图后会加剧——大片农田里可能一个小图块全是农田没有建筑这种图块对训练建筑分割没有正贡献。import os import numpy as np from PIL import Image def sliding_window_crop(image, label, patch_size512, overlap128): 滑动窗口切图返回图块列表 stride patch_size - overlap H, W image.shape[:2] patches [] for y in range(0, H, stride): for x in range(0, W, stride): y_end min(y patch_size, H) x_end min(x patch_size, W) # 边缘补齐 img_patch np.zeros((patch_size, patch_size, image.shape[2]), dtypeimage.dtype) lbl_patch np.zeros((patch_size, patch_size), dtypelabel.dtype) h y_end - y w x_end - x img_patch[:h, :w] image[y:y_end, x:x_end] lbl_patch[:h, :w] label[y:y_end, x:x_end] patches.append((img_patch, lbl_patch)) return patchesstride patch_size - overlap决定了图块之间的重叠量。overlap 越大边缘目标被完整切到的概率越高但图块总数也越多训练时间线性增长。512 图块配 128 重叠是我在 1080Ti/3090 级别显卡上比较常用的配置显存占用和覆盖率比较平衡。2.3 遥感场景下的数据增强策略自然图像常用的增强随机裁剪、翻转、色彩抖动在遥感里也能用但要注意几点。翻转和旋转90°、180°、270°是安全的因为遥感图没有「上下」的语义概念。色彩抖动要谨慎——遥感图的颜色和地物类别有强关联水体偏蓝、植被偏绿过度抖动会让模型学到错误的颜色先验。我一般只做轻微的亮度和对比度扰动不做色相偏移。另外遥感图里小目标多随机缩放增强要控制范围。缩得太小道路这种细长目标直接消失缩得太大建筑超出图块边界。一般缩放范围控制在 0.75 到 1.5 倍之间比较稳妥。3. UNet 模型搭建编码器选型、跳跃连接与输出层设计3.1 原始 UNet 结构在遥感图上的局限原始 UNet 是 2015 年为医学细胞分割设计的编码器只有四层下采样感受野有限。遥感图里的大目标比如大型厂房、成片农田需要更大的感受野才能被完整识别。直接套原始 UNet在大目标上容易出现「内部空洞」——边缘分对了中间反而分错。常见改进方向有三个一是加深编码器用 ResNet 或 EfficientNet 作为 backbone 替换原始卷积堆叠二是引入空洞卷积dilated convolution扩大感受野而不损失分辨率三是加注意力模块如 SE、CBAM让模型关注重要通道和空间区域。对于毕设级别的项目我建议先用 ResNet34 替换编码器改动量小效果提升明显代码也好找。import torch import torch.nn as nn import torchvision.models as models class ResNetUNet(nn.Module): def __init__(self, n_classes5): super().__init__() # 用预训练 ResNet34 做编码器 resnet models.resnet34(weightsmodels.ResNet34_Weights.DEFAULT) self.encoder0 nn.Sequential(resnet.conv1, resnet.bn1, resnet.relu) # 1/2 self.encoder1 nn.Sequential(resnet.maxpool, resnet.layer1) # 1/4 self.encoder2 resnet.layer2 # 1/8 self.encoder3 resnet.layer3 # 1/16 self.encoder4 resnet.layer4 # 1/32 # 解码器 self.up4 nn.ConvTranspose2d(512, 256, 2, stride2) self.dec4 self._conv_block(512, 256) self.up3 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec3 self._conv_block(256, 128) self.up2 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec2 self._conv_block(128, 64) self.up1 nn.ConvTranspose2d(64, 32, 2, stride2) self.dec1 self._conv_block(64, 32) self.final nn.Conv2d(32, n_classes, 1) def _conv_block(self, in_ch, out_ch): return 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): e0 self.encoder0(x) # 1/2, 64 e1 self.encoder1(e0) # 1/4, 64 e2 self.encoder2(e1) # 1/8, 128 e3 self.encoder3(e2) # 1/16, 256 e4 self.encoder4(e3) # 1/32, 512 d4 self.up4(e4) d4 self.dec4(torch.cat([d4, e3], dim1)) d3 self.up3(d4) d3 self.dec3(torch.cat([d3, e2], dim1)) d2 self.up2(d3) d2 self.dec2(torch.cat([d2, e1], dim1)) d1 self.up1(d2) d1 self.dec1(torch.cat([d1, e0], dim1)) return self.final(d1)这段代码里最关键的是torch.cat那几行——跳跃连接把编码器对应层的特征和解码器上采样后的特征在通道维度拼接。注意拼接前要保证空间尺寸一致ConvTranspose2d的stride2和kernel_size2保证上采样正好放大一倍。如果尺寸对不上要么调 padding要么在拼接前做插值。3.2 输出层与损失函数类别不平衡怎么破遥感分割的类别不平衡比自然图像严重得多。一张图里背景可能占 80%建筑占 15%道路占 4%水体占 1%。如果用普通交叉熵模型会倾向于全预测背景准确率看起来很高但 IoU 惨不忍睹。常见做法是组合损失交叉熵 Dice Loss。Dice Loss 直接优化预测和标签的重叠度对类别不平衡不敏感。我一般用0.5 * CE 0.5 * Dice如果某一类特别少比如水体可以给这一类单独加权重。class DiceLoss(nn.Module): def __init__(self, smooth1.0): super().__init__() self.smooth smooth def forward(self, logits, targets): probs torch.softmax(logits, dim1) targets_onehot torch.nn.functional.one_hot(targets, num_classeslogits.shape[1]) targets_onehot targets_onehot.permute(0, 3, 1, 2).float() intersection (probs * targets_onehot).sum(dim(2, 3)) union probs.sum(dim(2, 3)) targets_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 0.5 * ce_loss(pred, label) 0.5 * dice_loss(pred, label)ignore_index255用来排除未标注区域这个值要和数据集里设定的忽略标签一致。Dice Loss 里的smooth防止分母为零一般设 1.0 就行太小了数值不稳定太大了梯度会被平滑掉。3.3 训练参数学习率、batch size 与迭代次数遥感分割的训练参数没有万能公式但有几个经验区间。学习率用 1e-4 到 1e-3 之间配合余弦退火或 ReduceLROnPlateau。batch size 取决于显存512×512 图块在 8GB 显存上大概能跑 4 到 8 张。如果显存不够要么减小图块到 256要么用梯度累积模拟大 batch。迭代次数看数据量。一般训练到验证集 IoU 连续 10 个 epoch 不提升就可以停。遥感数据集通常不大几千到几万图块50 到 100 个 epoch 是常见范围。优化器用 AdamW 比 SGD 收敛快但最终精度可能略低毕设级别用 AdamW 完全够。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR optimizer AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max100, eta_min1e-6) for epoch in range(100): model.train() for img, lbl in train_loader: img, lbl img.cuda(), lbl.cuda() pred model(img) loss 0.5 * ce_loss(pred, lbl) 0.5 * dice_loss(pred, lbl) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()weight_decay1e-4是 AdamW 的典型值比 Adam 的 L2 正则更合理。CosineAnnealingLR让学习率从 1e-3 平滑降到 1e-6避免后期震荡。如果训练 loss 下降但验证 IoU 不升大概率是过拟合加 dropout 或减小模型容量。4. 训练中常见的翻车现场与排查手册4.1 现象loss 不下降输出全是背景原因通常有三个学习率太大导致梯度爆炸、损失函数没有正确处理类别不平衡、或者标签本身有问题比如全是 0。先检查标签的类别分布如果某一类占比超过 95%交叉熵会主导训练。解决方法是加 Dice Loss 或给稀有类加权。学习率方面先用 1e-4 跑几个 batch看 loss 是否稳定下降如果震荡剧烈就降到 1e-5。4.2 现象验证集 IoU 很高但推理结果全是噪点这是典型的「数据泄漏」或「预处理不一致」。检查验证集和训练集的归一化参数是否一致——如果训练时用了 ImageNet 均值方差验证时忘了模型看到的输入分布就变了。另外检查验证集的标签是否在切图时和图像对齐错位一个像素都会让 IoU 虚高但实际推理崩掉。4.3 现象小目标道路、小建筑分割效果极差原因可能是下采样次数太多小目标在深层特征里已经消失了。解决方向减少编码器下采样层数、在浅层加辅助损失、或者用空洞卷积替代部分下采样。另一个实用技巧是在训练时对小目标区域过采样——切图时统计每个图块的目标密度高密度图块以更高概率被采样。4.4 现象训练 loss 正常但验证 loss 突然飙升大概率是遇到了脏数据。遥感数据集里常见的问题包括标签类别值超出预设范围、图像有坏像素NaN 或 Inf、标签和图像尺寸不匹配。写一个数据校验脚本遍历所有图块检查图像像素范围、标签唯一值、以及两者尺寸是否一致。这个脚本花十分钟写能省掉几天排查时间。4.5 现象多卡训练比单卡还慢如果用了 DataParallel 但 batch size 没变多卡通信开销会拖慢训练。正确做法是 batch size 随卡数线性放大同时学习率也相应调整一般按 sqrt 比例放大。另外遥感图块 I/O 是瓶颈用num_workers开多进程读数据但注意 Windows 下多进程有坑Linux 下更稳。5. 验证与调优从 IoU 到实际可用性的最后一公里5.1 评价指标不只看 IoUIoU 是语义分割的标准指标但遥感场景下还要看几个补充指标。一是边界 F1Boundary F1衡量预测边缘和真实边缘的贴合度对建筑轮廓分割特别重要。二是小目标召回率单独统计面积小于某个阈值的类别的召回。三是推理速度遥感应用经常要处理大幅影像模型在 512×512 图块上的单张推理时间要控制在可接受范围。def compute_iou(pred, target, n_classes): 逐类计算 IoU ious [] pred pred.flatten() target target.flatten() for cls in range(n_classes): pred_mask (pred cls) target_mask (target cls) intersection (pred_mask target_mask).sum().float() union (pred_mask | target_mask).sum().float() if union 0: ious.append(float(nan)) else: ious.append((intersection / union).item()) return ious这个函数返回每个类的 IoU如果某一类 IoU 是 nan说明验证集里没有这个类需要检查数据划分是否漏掉了稀有类。5.2 用测试时增强TTA榨出最后几个点TTA 的思路是推理时对同一张图做多种变换翻转、旋转分别预测后再融合。遥感图对翻转和 90° 旋转天然友好TTA 通常能涨 1 到 3 个 IoU 点。代价是推理时间翻几倍适合对精度要求高、对速度不敏感的场景。def tta_predict(model, image): 测试时增强原图 水平翻转 垂直翻转 180度旋转 preds [] preds.append(torch.softmax(model(image), dim1)) preds.append(torch.flip(torch.softmax(model(torch.flip(image, [3])), dim1), [3])) preds.append(torch.flip(torch.softmax(model(torch.flip(image, [2])), dim1), [2])) preds.append(torch.flip(torch.softmax(model(torch.flip(image, [2, 3])), dim1), [2, 3])) return torch.stack(preds).mean(dim0)注意翻转后要翻回来再平均否则预测和原图空间不对应。融合方式用平均概率图再取 argmax比直接投票更平滑。5.3 一个我踩过的坑验证集划分不能随机遥感图有空间自相关性——相邻图块的内容高度相似。如果随机划分验证集训练集和验证集里可能有来自同一区域的图块验证 IoU 会虚高。正确做法是按空间区域划分比如用几景完整的影像做验证其余做训练。这样验证集和训练集在地理上不重叠评估结果更接近实际部署表现。我当初做第一个遥感分割项目时就是随机划分验证 IoU 跑到 0.82信心满满拿去跑新影像结果只有 0.6 出头。后来改成按影像划分验证 IoU 降到 0.72但新影像上的表现稳定在 0.7 左右。这个教训让我后来所有遥感项目都坚持空间划分验证集。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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