ARTICLE DETAIL

资讯详情

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

遥感图像语义分割实战:SegNet与UNet从数据到推理全流程

遥感图像语义分割实战:SegNet与UNet从数据到推理全流程 简介这份资源面向计算机、人工智能、遥感与电子信息等方向的在校学生、授课教师及企业开发者提供基于Segnet与Unet两种深度学习算法实现遥感图像语义分割的完整项目源码可用于毕业设计、课程大作业或项目立项演示。压缩包共15个文件以11个Python脚本为核心涵盖Segnet与Unet网络定义、训练主函数、推理预测及数据预处理模块另附3张png效果图与1份md项目说明整体约190KB结构清晰便于按模块查阅。项目已通过测试在Windows10、Anaconda3、PyTorch1.0环境下可稳定运行并借助tensorboard与tensorboardX实现训练过程可视化。数据预处理脚本支持将大图切割为256×256并完成旋转、加噪等增强操作配套数据集下载地址与处理流程一并给出。目前已有376人学习适合希望快速上手遥感语义分割、对比两种经典网络结构并在此基础上扩展创新的读者参考借鉴。1. 遥感图像语义分割SegNet 与 UNet 双模型落地从数据到推理的完整路径遥感图像语义分割这件事真正上手做的时候卡住大多数人的往往不是网络结构本身而是数据怎么组织、标签怎么对齐、训练时显存怎么不炸、推理结果怎么拼回原图。SegNet 和 UNet 是这条路上最常被拿来对比的两个基线前者用编码器-解码器加池化索引的方式做上采样后者用跳跃连接把浅层细节直接送到深层。两者都能在遥感场景下跑出可用的结果但适用边界不同。这个方向适合做毕设、课程设计也适合想从分类任务跨到密集预测的工程师。下面按数据准备、模型搭建、训练调参、推理验证、踩坑排查的顺序把一套可复现的流程讲清楚。2. 数据准备与标签体系遥感语义分割的第一道门槛2.1 遥感数据集的常见格式与类别定义遥感语义分割的数据来源通常有三类公开数据集如 ISPRS、LoveDA、DeepGlobe、自己标注的无人机影像、以及从地图服务截取的瓦片。不管来源如何最终都要统一成「图像 单通道标签图」的形式。标签图的像素值就是类别索引0 通常留给背景或忽略区域1 到 N 对应具体地物。常见类别包括建筑、道路、水体、植被、裸地、农田。类别数不要一上来就设十几类遥感影像里类间边界模糊类别越多标注一致性越差模型越难收敛。我一般建议毕设场景控制在 5 到 8 类先把流程跑通再扩类。标签图必须是 PNG 或 TIFF 这类无损格式不能用 JPG。JPG 的有损压缩会在类别边界产生中间像素值训练时这些像素既不属于 A 也不属于 B直接污染损失函数。2.2 用 Python 检查图像与标签的一一对应关系拿到数据后第一件事不是写模型而是写一个校验脚本。遥感数据经常出现图像有、标签没有或者文件名对不上、尺寸不一致的情况。import os from PIL import Image img_dir data/images mask_dir data/masks img_files sorted(os.listdir(img_dir)) mask_files sorted(os.listdir(mask_dir)) # 检查数量是否一致 print(f图像数量: {len(img_files)}, 标签数量: {len(mask_files)}) # 检查文件名是否一一对应 img_stems {os.path.splitext(f)[0] for f in img_files} mask_stems {os.path.splitext(f)[0] for f in mask_files} missing_mask img_stems - mask_stems missing_img mask_stems - img_stems print(f缺标签的图像: {missing_mask}) print(f缺图像的标签: {missing_img}) # 检查尺寸是否一致 for f in img_files[:5]: stem os.path.splitext(f)[0] img Image.open(os.path.join(img_dir, f)) mask_path os.path.join(mask_dir, stem .png) if os.path.exists(mask_path): mask Image.open(mask_path) print(f{f}: 图像尺寸 {img.size}, 标签尺寸 {mask.size}, 标签模式 {mask.mode})这段脚本做三件事数量比对、文件名集合差集、尺寸与模式抽查。img_stems - mask_stems得到的是有图无标签的样本这类样本必须删掉或补标否则训练时__getitem__会直接抛异常。标签模式必须是L8 位灰度或P调色板如果是RGB说明标注工具导出时没转格式需要用mask.convert(L)处理。2.3 数据增强在遥感场景下的边界遥感图像和自然图像不同翻转和旋转要谨慎。水平翻转、垂直翻转、90 度旋转通常没问题因为遥感是俯视图方向语义弱。但如果是道路提取任务任意角度旋转会改变道路的走向分布可能让模型学到错误的纹理先验。颜色抖动也要克制。遥感影像的波段响应和光照条件相对稳定过度调整亮度、对比度会让模型对真实推理时的成像条件产生偏差。我一般只做小幅度的亮度扰动±10%和轻微的噪声注入。裁剪策略上遥感图像尺寸往往很大几千乘几千必须切 patch。切的时候要保证标签同步切且 patch 之间可以有重叠推理时再拼回去。重叠区域用投票或平均能减少拼接缝处的类别跳变。3. SegNet 与 UNet 的模型搭建结构差异与代码实现3.1 SegNet 的池化索引机制为什么省显存SegNet 的核心设计是编码器每次最大池化时记录下最大值的位置索引解码器上采样时直接用这些索引把值放回原位而不是做反卷积或双线性插值。这样做的好处是解码器不需要学习上采样的权重参数量比 UNet 少一截显存占用也更低。在遥感场景下这个特性有实际意义遥感图像分辨率高patch 切到 512 乘 512 时UNet 的解码器反卷积层会吃掉大量显存。SegNet 的索引上采样几乎不增加额外参数适合显存有限的机器。但代价是索引上采样是硬编码的无法学习。当类别边界需要亚像素级别的平滑时SegNet 的结果会比 UNet 更「块状」。建筑边缘、道路边线这些地方SegNet 容易出现锯齿。3.2 用 PyTorch 实现一个可跑的 SegNet 编码器下面是一个精简版 SegNet 编码器VGG 风格每层卷积后接 BN 和 ReLU池化时返回索引。import torch import torch.nn as nn class SegNetEncoder(nn.Module): def __init__(self, in_channels3): super().__init__() # 每个 block: 两层卷积 一次最大池化 self.block1 nn.Sequential( nn.Conv2d(in_channels, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), ) self.block2 nn.Sequential( nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), ) self.pool nn.MaxPool2d(2, 2, return_indicesTrue) def forward(self, x): indices [] x self.block1(x) x, idx1 self.pool(x) indices.append(idx1) x self.block2(x) x, idx2 self.pool(x) indices.append(idx2) return x, indicesreturn_indicesTrue是 SegNet 的关键池化层会额外返回每个最大值在原特征图上的扁平索引。indices列表按编码顺序保存解码时逆序使用。注意MaxPool2d的return_indices只在 PyTorch 中可用TensorFlow 需要自己写 argmax 加 gather。3.3 UNet 的跳跃连接在遥感分割中的实际收益UNet 的跳跃连接把编码器浅层的高分辨率特征直接拼到解码器对应层。遥感图像里浅层特征包含大量边缘、纹理信息这些对区分建筑轮廓、水体边界非常关键。没有跳跃连接解码器只能靠深层语义特征恢复空间细节边界会糊。但跳跃连接也不是越多越好。如果编码器浅层特征噪声大比如影像有云、有阴影直接拼到解码器会把噪声也带过去。我一般会在跳跃连接上加一个 1 乘 1 卷积做通道压缩再拼这样能过滤一部分无用通道。class UNetUp(nn.Module): def __init__(self, in_ch, skip_ch, out_ch): super().__init__() self.up nn.ConvTranspose2d(in_ch, in_ch // 2, 2, stride2) # 跳跃连接先做通道压缩减少噪声和参数量 self.skip_conv nn.Conv2d(skip_ch, in_ch // 2, 1) self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x, skip): x self.up(x) skip self.skip_conv(skip) x torch.cat([x, skip], dim1) return self.conv(x)ConvTranspose2d的stride2把特征图放大一倍skip_conv把跳跃特征的通道数对齐到in_ch // 2拼接后总通道数是in_ch再经过卷积融合。这个结构在遥感 512 乘 512 patch 上显存占用比 SegNet 高约 30%但边界 IoU 通常能高 2 到 4 个百分点。4. 训练配置与调参学习率、损失函数与 batch size 的联动4.1 遥感分割的损失函数选型CE 还是 Dice交叉熵CE是逐像素算的对类别不平衡敏感。遥感场景里背景、植被往往占大头建筑、道路占比小纯 CE 会让模型倾向于预测多数类。Dice Loss 直接优化预测和标签的重叠度对小类更友好。我一般用 CE 加 Dice 的加权和loss 0.5 * CE 0.5 * Dice。CE 保证每个像素都有梯度Dice 拉小类。权重可以根据类别分布调如果小类 IoU 一直上不去把 Dice 权重提到 0.7。import torch.nn.functional as F def dice_loss(pred, target, smooth1e-6): # pred: [B, C, H, W] logits, target: [B, H, W] long num_classes pred.shape[1] pred_soft F.softmax(pred, dim1) target_onehot F.one_hot(target, num_classes).permute(0, 3, 1, 2).float() intersection (pred_soft * target_onehot).sum(dim(2, 3)) union pred_soft.sum(dim(2, 3)) target_onehot.sum(dim(2, 3)) dice (2 * intersection smooth) / (union smooth) return 1 - dice.mean() def combined_loss(pred, target, ce_weight0.5): ce F.cross_entropy(pred, target, ignore_index255) dice dice_loss(pred, target) return ce_weight * ce (1 - ce_weight) * diceignore_index255用来屏蔽标注中的无效区域比如影像边缘的未标注像素。one_hot把标签转成 one-hotpermute调整维度顺序对齐 pred。Dice 计算时对 batch 和空间维度求和得到每个类别的 dice 后取平均。4.2 学习率与 batch size 的实操配置遥感分割的 batch size 受显存限制512 乘 512 patch 下8GB 显存大概能跑 batch size 4 到 6。batch size 小的时候BN 的统计量不稳定建议把 BN 换成 GroupNorm或者用 SyncBN多卡时。学习率初始值我一般设 1e-3 配 Adam或者 1e-2 配 SGD。Adam 收敛快适合快速验证SGD 最终精度可能略高但需要调 momentum 和 weight decay。毕设场景用 Adam 就够了。学习率调度用 CosineAnnealingLR从初始值降到 1e-6周期设成总 epoch 数。比 StepLR 更平滑后期不会突然掉点。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR model UNet(num_classes6).cuda() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max100, eta_min1e-6) for epoch in range(100): model.train() for img, mask in train_loader: img, mask img.cuda(), mask.cuda() pred model(img) loss combined_loss(pred, mask) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()weight_decay1e-4是轻量正则防止过拟合。T_max100要和总 epoch 一致eta_min1e-6是学习率下限。注意scheduler.step()放在 epoch 末尾不是 batch 末尾。4.3 验证指标mIoU 的计算与陷阱mIoU 是遥感分割最常用的指标。计算时按类别算 IoU 再平均。陷阱在于如果某个类别在验证集里没出现它的 IoU 是 0 还是跳过我一般跳过只对出现过的类别求平均否则 mIoU 会被拉低误导判断。def compute_miou(pred, target, num_classes, ignore_index255): pred pred.argmax(dim1) mask target ! ignore_index ious [] for cls in range(num_classes): pred_cls (pred cls) mask target_cls (target cls) mask if target_cls.sum() 0: continue intersection (pred_cls target_cls).sum().item() union (pred_cls | target_cls).sum().item() ious.append(intersection / union) return sum(ious) / len(ious) if ious else 0.0mask把忽略区域排除target_cls.sum() 0时跳过该类。intersection和union都是布尔张量的与或求和。这个函数在每个验证 batch 上调用最后按样本数加权平均。5. 推理与结果拼接从 patch 到整幅影像5.1 滑窗推理与重叠投票训练时切了 patch推理时也要切。整幅遥感影像可能上万像素直接送进网络会爆显存。滑窗推理的思路是按固定步长滑动窗口每个窗口送进模型得到预测再把预测拼回大图。重叠区域的处理有两种平均和投票。平均是把重叠区域的 softmax 概率相加再平均投票是每个像素取出现次数最多的类别。我一般用平均因为概率信息更丰富边界过渡更自然。import numpy as np def sliding_inference(model, image, patch_size512, stride256, num_classes6): model.eval() H, W image.shape[:2] prob_map np.zeros((num_classes, H, W), dtypenp.float32) count_map np.zeros((H, W), dtypenp.float32) for y in range(0, H, stride): for x in range(0, W, stride): y1, y2 y, min(y patch_size, H) x1, x2 x, min(x patch_size, W) patch image[y1:y2, x1:x2] # 边缘 patch 补齐到 patch_size pad_h patch_size - (y2 - y1) pad_w patch_size - (x2 - x1) patch np.pad(patch, ((0, pad_h), (0, pad_w), (0, 0)), modereflect) tensor torch.from_numpy(patch).permute(2, 0, 1).float().unsqueeze(0).cuda() with torch.no_grad(): out torch.softmax(model(tensor), dim1).squeeze(0).cpu().numpy() out out[:, :y2 - y1, :x2 - x1] prob_map[:, y1:y2, x1:x2] out count_map[y1:y2, x1:x2] 1 count_map[count_map 0] 1 prob_map / count_map return prob_map.argmax(axis0)stride256表示相邻窗口重叠一半重叠越多拼接缝越不明显但推理时间成倍增加。modereflect补齐边缘比补零更自然。count_map记录每个像素被预测了几次最后做归一化。5.2 后处理去除小连通域与边界平滑模型输出往往有零散的小误检比如建筑类别里冒出几个孤立像素。用连通域分析去掉面积小于阈值的区域能明显提升视觉效果。from scipy import ndimage def remove_small_objects(pred, min_size100): result pred.copy() for cls in np.unique(pred): if cls 0: continue mask pred cls labeled, num ndimage.label(mask) for i in range(1, num 1): if (labeled i).sum() min_size: result[labeled i] 0 return resultmin_size100是像素面积阈值512 乘 512 patch 下100 像素大约对应 10 乘 10 的区域。太小去不掉噪声太大会误删真实小目标。边界平滑可以用 CRF条件随机场但计算量大毕设场景用连通域过滤就够了。6. 避坑与排查遥感分割训练中最容易翻车的五个点6.1 损失不下降mIoU 卡在 0.1 左右现象训练几个 epoch 后loss 在某个值附近震荡mIoU 几乎不动。原因最常见的是标签像素值不是从 0 开始的连续整数。比如标注工具导出时背景是 0建筑是 1道路是 2但植被是 4中间缺了 3。CrossEntropyLoss会认为类别 3 存在但永远预测不到梯度被稀释。解决用np.unique(mask)检查所有标签图的像素值确认是[0, 1, 2, ..., N-1]。如果有跳跃写一个映射表把像素值重映射到连续索引。6.2 训练 loss 正常验证 mIoU 极低现象训练集 loss 降到 0.1 以下验证集 mIoU 只有 0.2。原因训练集和验证集的类别分布差异太大或者验证集的标签有系统性错误。遥感数据里如果训练集全是城市区域验证集全是农田模型没见过农田自然预测不出来。解决检查两个集合的类别像素占比用直方图对比。如果差异大重新划分数据保证每个类别在训练集和验证集里都有足够样本。另外抽查验证集标签确认没有整体偏移或颜色映射错误。6.3 显存溢出batch size 降到 1 还是 OOM现象512 乘 512 patchbatch size 设为 1仍然CUDA out of memory。原因UNet 的跳跃连接在拼接时通道数翻倍中间特征图占用大量显存。另外如果用了torch.no_grad()之外的地方保留了计算图显存不会释放。解决把 patch 降到 256 乘 256或者把 UNet 的编码器换成轻量 backbone如 MobileNetV2。检查训练循环里有没有在loss.backward()之前累积了不必要的张量。用torch.cuda.empty_cache()清理缓存但根本办法还是减小模型或输入。6.4 推理结果有网格状拼接缝现象滑窗推理拼出来的大图在窗口边界处有明显的直线跳变。原因重叠区域太小或者边缘 patch 补齐方式不对。stride等于patch_size时没有重叠拼接缝必然出现。解决把stride设为patch_size的一半保证 50% 重叠。边缘补齐用reflect而不是zero减少边界效应。如果还有缝在拼接后用高斯滤波对边界区域做平滑。6.5 模型对某一类完全预测不出来现象mIoU 里建筑类一直是 0其他类正常。原因该类在训练集里像素占比极低比如不到 0.1%CE 损失被其他类主导模型直接放弃这一类。解决在损失函数里给该类加权重weight 1 / class_freq归一化后传给CrossEntropyLoss的weight参数。或者把 Dice Loss 的权重提高Dice 对小类更敏感。另外过采样包含该类的 patch让每个 batch 里都有正样本。7. 把 SegNet 和 UNet 跑成对比实验一个可复用的评估脚本做毕设或技术选型时光跑一个模型不够需要把 SegNet 和 UNet 放在同一套数据、同一套训练配置下对比。下面这个脚本把训练、验证、指标记录串起来输出两个模型的 mIoU 和参数量对比。import time import torch from torch.utils.data import DataLoader def train_and_eval(model, train_loader, val_loader, epochs50, lr1e-3): model model.cuda() optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) best_miou 0.0 history [] for epoch in range(epochs): model.train() total_loss 0.0 for img, mask in train_loader: img, mask img.cuda(), mask.cuda() pred model(img) loss combined_loss(pred, mask) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() scheduler.step() # 验证 model.eval() miou_sum 0.0 count 0 with torch.no_grad(): for img, mask in val_loader: img, mask img.cuda(), mask.cuda() pred model(img) miou_sum compute_miou(pred, mask, num_classes6) count 1 miou miou_sum / count history.append((epoch, total_loss / len(train_loader), miou)) if miou best_miou: best_miou miou torch.save(model.state_dict(), fbest_{model.__class__.__name__}.pth) print(fEpoch {epoch}: loss{total_loss/len(train_loader):.4f}, mIoU{miou:.4f}) return best_miou, history # 参数量对比 def count_params(model): return sum(p.numel() for p in model.parameters() if p.requires_grad) segnet SegNet(num_classes6) unet UNet(num_classes6) print(fSegNet 参数量: {count_params(segnet)/1e6:.2f}M) print(fUNet 参数量: {count_params(unet)/1e6:.2f}M)train_and_eval返回最佳 mIoU 和完整训练历史history可以画 loss 和 mIoU 曲线。count_params统计可训练参数量SegNet 通常在 15M 左右UNet 在 25M 到 30M 之间具体取决于通道数配置。跑对比实验时两个模型必须用同一个train_loader和val_loader随机种子也要固定。我一般设torch.manual_seed(42)和np.random.seed(42)保证数据打乱顺序一致。训练 epoch 数相同学习率调度相同这样对比才有意义。从我的经验看遥感建筑提取任务上UNet 的 mIoU 通常比 SegNet 高 2 到 5 个百分点但推理速度慢 20% 到 30%。如果显存紧张或者要求实时SegNet 更合适如果追求精度、可以离线跑UNet 是更稳的选择。做毕设的话两个都跑把对比数据放进论文比只跑一个更有说服力。最后说一个习惯每次改完数据或模型先跑一个 epoch 的小实验确认 loss 在降、mIoU 在动再开完整训练。遥感数据预处理环节多直接开长训练翻车了浪费的是几个小时甚至一整晚。希望帮到你。本文还有配套的精品资源点击获取
返回列表