ARTICLE DETAIL

资讯详情

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

UNet遥感图像语义分割实战:从数据标注到mIoU评估的完整指南

UNet遥感图像语义分割实战:从数据标注到mIoU评估的完整指南 简介这是一份基于UNet的遥感图像语义分割毕业设计完整项目包面向计算机视觉、遥感方向本科生及需要复现分割实验的开发者覆盖数据预处理、模型搭建、训练评估到论文撰写的全流程。压缩包共69个文件、约46.92MB内含6个Python脚本与6个pyc缓存主体代码包括model.py、train.py等三个Jupyter Notebook分别对应数据集创建、训练和预测另有TensorBoard/Jupyter启动脚本、毕业论文LaTeX源文件与PDF成品以及大量PNG/SVG结果图表目录按src、demo、毕业论文清晰划分。已有299人浏览学习。除可直接运行的UNet分割实现外还附有郭子睿撰写的完整报告便于参考研究思路与论文框架代码中涵盖图像增强、归一化、分块等预处理损失函数、优化器与学习率调度设置以及IoU、精度、召回率等评估指标配合TensorBoard监控训练过程适合快速二次开发并构建自己的毕设方案。1. 拿到“基于UNet的遥感图像语义分割”这个毕设标题先别急着搭模型很多人拿到毕业设计题目的时候看着“基于UNet的遥感图像语义分割”这个标题以为最难的部分是把UNet结构背出来、把训练脚本跑通。真正做完一次你会发现模型本身两三天就能搭完真正耗时间的是把遥感图像标注成语义分割标签以及把训练出来的结果调到一个能在答辩桌上讲清楚的精度。这篇笔记就沿着这个标题把整条链路拆开讲数据怎么做、模型怎么搭、Loss怎么调、评估怎么算以及最容易让结果翻车的几个坑。适合正准备动手做毕设的本科生也适合刚接触遥感图像分割、想快速跑通一个可用流程的工程师。如果你手里已经有一份类似的代码包这篇文字可以帮你理解每一段脚本为什么长成那样。2. 语义分割在遥感图上的真问题任务定义、结构选型与数据准备2.1 像素级分类与HDICT语义分割标签的对应关系遥感图像的语义分割本质上是给影像里的每一个像素分配一个类别。它不是把“房子”框出来而是把图像里所有属于房子的像素点都标出来。这和小目标检测的区别在于检测给出的是外接框分割给出的是精确边界。对于建筑物、道路、水体这类边界复杂的地物分割结果直接决定了面积统计、变化检测这类后续应用的精度。反过来看数据准备就明白为什么标题里这个“zip”项目最重的部分往往是data文件夹而不是model文件夹。常见的做法是先用标注工具在遥感大图上画多边形导出成标签图。HDICT语义分割标签这类工具或规范核心产出就是一张和原图同样尺寸的PNG索引图每个像素的灰度值对应一个类别编号比如0代表背景、1代表建筑、2代表道路。使用时的注意事项第一条就是类别编号必须从0开始连续编码训练脚本里num_classes要和标签里的最大值对上否则会在计算损失时出现索引越界。遥感影像来源不同标签风格差异也很大。高分二号、哨兵或者航拍影像波段数不一样直接拿别人训练好的模型跑自己的图效果大概率不理想。自己重新标注时类别不要设太多。我一般建议控制在5到8类以内背景、建筑、道路、水体、植被再加一到两个你的课题特殊地类。类别一多边界混淆和样本不均衡会把UNet的训练过程拖得非常难受。2.2 为什么是UNet而不是FCN网络结构选的三个关键点如果你去搜索“UNet网络结构图”会看到它是个典型的U型左边编码器逐层下采样提取语义特征右边解码器逐层上采样恢复分辨率中间靠跳跃连接把同尺度的特征拼起来。这个结构对遥感图像有几层额外好处。第一遥感图像里的地物边界往往非常尖锐道路边缘、建筑物轮廓都是强边界。FCN这类早期语义分割模型只顾着逐层池化上采样又太粗暴边界处容易糊成一片。UNet的跳跃连接在解码时把编码器同尺度的细节特征直接拼接回来等于给上采样过程提供了“原图细节参考”边界恢复能力明显更强。第二UNet的参数量相对可控。标准UNet第一层卷积是64个通道往下翻倍到128、256、512对显存压力比DeepLab系列的ASPP结构小得多。很多毕设用的还是单卡有的甚至只有一张6GB显存的消费级显卡UNet是能在这个条件下跑起来的为数不多的好选择。第三UNet对训练数据量要求不那么苛刻。遥感图像的标注成本很高几百张512×512的小图也能训出一个可用模型。换作大规模预训练模型数据量不够反而会在下游任务上过拟合得更快。2.3 从遥感图像标注到训练集制作裁剪、增强与验证集划分拿到一幅几千乘几千像素的遥感大图没人会直接整图塞给U-Net。显存不够是一方面另一方面是背景占比失衡。常见做法是用滑动窗口把大图裁成256或512像素的小图每个窗口里包含的地物细节更充分训练时模型能学到真正的局部纹理而不是把整幅图的统计特征背下来。下面这段代码是我常用的裁剪逻辑把原图和标签图一起裁成512×512的块并保证两者裁剪位置完全一致import cv2 import numpy as np from pathlib import Path def sliding_crop(image_path, label_path, crop_size512, overlap64, out_dir./crops): 滑动窗口裁剪遥感大图与对应的标签图 image_path: 原图路径如 GF2.tif label_path: 标签图路径如 GF2_label.png crop_size: 裁剪窗口边长 overlap: 相邻窗口重叠像素数 image cv2.imread(image_path) label cv2.imread(label_path, cv2.IMREAD_UNCHANGED) h, w label.shape[:2] stride crop_size - overlap idx 0 for y in range(0, h - crop_size 1, stride): for x in range(0, w - crop_size 1, stride): img_crop image[y:ycrop_size, x:xcrop_size] lab_crop label[y:ycrop_size, x:xcrop_size] cv2.imwrite(f{out_dir}/img_{idx:05d}.png, img_crop) cv2.imwrite(f{out_dir}/lab_{idx:05d}.png, lab_crop) idx 1 # 处理右边和下边剩余的不够一个窗口的部分 if h % stride ! 0: y_start h - crop_size for x in range(0, w - crop_size 1, stride): img_crop image[y_start:y_startcrop_size, x:xcrop_size] lab_crop label[y_start:y_startcrop_size, x:xcrop_size] cv2.imwrite(f{out_dir}/img_{idx:05d}.png, img_crop) cv2.imwrite(f{out_dir}/lab_{idx:05d}.png, lab_crop) idx 1这段代码的逻辑并不复杂先按步长从左到右、从上到下裁剪步长等于crop_size - overlap。overlap的作用是让相邻窗口之间有一段重叠区域避免地物恰好被切在窗口边缘导致只露出一半。最后补的这段逻辑处理的是图像尺寸不能被步长整除的情况把最右侧或最下侧剩余区域直接对齐到窗口大小宁可损失一部分边界也不要产生尺寸不足的异常样本。参数上我一般这样选crop_size在256到512之间显存小于8GB就选256overlap设置为crop_size的八分之一到四分之一太小接缝处语义断裂太大重复样本太多训练效率低。裁剪完成后按 8:1:1 的比例划分训练集、验证集和测试集注意划分时按“图幅”分而不是按“小窗口”分否则同一幅大图裁出的小图会同时出现在训练集和测试集里评估指标虚高到没有参考价值。3. 用Pytorch把UNet搭出来模型实现、损失函数与训练参数3.1 UNet编码器-解码器的实现要点Pytorch在毕设里几乎是标配PyTorch的nn.Module把UNet的编码器、解码器和跳跃连接表达得很直接。核心组件是两个卷积加一个ReLU的重复堆叠我一般习惯把它们封装成ConvBlock再用一个函数把通道数变化规律描述清楚。import torch import torch.nn as nn class ConvBlock(nn.Module): 两个 3x3 卷积 一个 ReLUUNet的基本构建单元 def __init__(self, in_channels, out_channels): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, padding1) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, padding1) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.conv1(x)) x self.relu(self.conv2(x)) return x class UNet(nn.Module): 标准UNet4次下采样4次上采样跳跃连接直接拼接特征图 def __init__(self, in_channels3, num_classes6): super().__init__() # 编码器 self.enc1 ConvBlock(in_channels, 64) self.enc2 ConvBlock(64, 128) self.enc3 ConvBlock(128, 256) self.enc4 ConvBlock(256, 512) # 最底层的瓶颈 self.bottleneck ConvBlock(512, 1024) # 解码器 self.up1 nn.ConvTranspose2d(1024, 512, kernel_size2, stride2) self.dec1 ConvBlock(1024, 512) self.up2 nn.ConvTranspose2d(512, 256, kernel_size2, stride2) self.dec2 ConvBlock(512, 256) self.up3 nn.ConvTranspose2d(256, 128, kernel_size2, stride2) self.dec3 ConvBlock(256, 128) self.up4 nn.ConvTranspose2d(128, 64, kernel_size2, stride2) self.dec4 ConvBlock(128, 64) # 输出分类 self.out_conv nn.Conv2d(64, num_classes, kernel_size1) self.pool nn.MaxPool2d(2) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) b self.bottleneck(self.pool(e4)) d1 self.dec1(torch.cat([self.up1(b), e4], dim1)) d2 self.dec2(torch.cat([self.up2(d1), e3], dim1)) d3 self.dec3(torch.cat([self.up3(d2), e2], dim1)) d4 self.dec4(torch.cat([self.up4(d3), e1], dim1)) return self.out_conv(d4)这段代码里的几个关键设计点需要注意。第一padding1保证了卷积不改变特征图尺寸跳跃连接拼接时两边的空间维度才能对得上。第二torch.cat在通道维度拼接下采样4次后编码器每个尺度的通道数分别是64、128、256、512解码器上采样后拼接回来通道数翻倍再卷积这正是UNet“U型”结构的来源。第三最后用1×1卷积把特征通道压缩到类别数量输出形状是[N, num_classes, H, W]后面接CrossEntropyLoss时不需要再做额外的softmax。如果你拿到的代码包里有更复杂的变体先别急着替换成DeepLab或者PSPNet。UNet的稳定性和低数据需求在毕设场景里是最高优先级先跑通再谈改进。3.2 遥感影像语义分割的损失函数交叉熵、Focal与Dice的混合遥感影像的类别分布天然不均衡。一张512×512的图里道路可能只占几百个像素背景占了大半。用普通的交叉熵损失模型会把所有像素都预测成背景因为这样损失已经很低了。UNet训练翻车很多情况下不是结构问题而是损失函数设计问题。我通常会用两个附加损失来处理。Focal Loss的做法是对难分类样本施加更大权重公式核心是给标准交叉熵乘上(1 - p_t)^gamma。当模型对某个像素预测概率很高时这个系数趋近于0损失被压低预测概率低时系数大梯度信号更强。在遥感图上道路、车辆这类目标天然预测困难Focal Loss能逼着模型不偷懒。Dice Loss直接在分割区域重叠度上做文章计算预测区域与真实区域的Dice系数并取负。它对类别不均衡不敏感因为每个类别单独算Dice再平均不会出现大类别淹没小类别的问题。import torch.nn.functional as F class FocalLoss(nn.Module): Focal Loss: 让模型更关注难分类像素 def __init__(self, gamma2.0, weightNone): super().__init__() self.gamma gamma self.weight weight def forward(self, logits, targets): ce F.cross_entropy(logits, targets, weightself.weight, reductionnone) p_t torch.exp(-ce) # 等价于模型对正确类别的预测概率 loss (1 - p_t) ** self.gamma * ce return loss.mean() def dice_loss(logits, targets, smooth1e-6): Dice Loss: 直接优化区域重叠度 prob F.softmax(logits, dim1) targets_onehot F.one_hot(targets, num_classeslogits.shape[1]).permute(0, 3, 1, 2).float() intersection (prob * targets_onehot).sum(dim(2, 3)) union prob.sum(dim(2, 3)) targets_onehot.sum(dim(2, 3)) dice (2 * intersection smooth) / (union smooth) return 1 - dice.mean()实际训练中我用 0.5倍的交叉熵 0.5倍的Focal Loss 0.5倍的Dice Loss三个损失直接相加效果比单独用任何一个都稳定。注意gamma不能设太大2.0是个常用值再大容易让训练初期梯度过于集中在极少数困难像素上导致损失震荡。weight参数可以按类别像素数的反比传入进一步平衡类别偏差但和Focal同时使用时要注意别把简单样本的权重压得过于极端。3.3 训练超参数怎么设学习率、batch size和权重衰减的经验值这一块在毕设答辩时经常被问也最容易暴露“你是不是只是把别人的代码跑了一遍”。我给出一组经过验证的常用参数你在这个基础上微调比你从零摸索要快得多。超参数常用值说明优化器AdamW比Adam多一个解耦的权重衰减泛化更好初始学习率1e-4 到 3e-4遥感语义分割比分类任务更需要小学习率权重衰减1e-4过大容易欠拟合过小起不到约束作用batch size8512×512/ 16256×256以不OOM为底线能大则大epoch60到100配合早停不必死磕整数学习率调度CosineAnnealingLR比阶梯下降更平滑后期收敛更稳一个重要的习惯是只在训练集上调整这些参数验证集只用来评估不要根据验证集结果反复改超参数否则你等于把验证集信息泄漏进了模型选择过程最后测试集的数字会骗你。4. 评估与结果可视化mIoU计算、最优权重挑选和预测出图4.1 用mIoU和像素精度评估语义分割模型毕设答辩时老师最常问的第一个问题就是“你的模型效果怎么样”。如果你只说“看起来挺准”分数基本就没了。语义分割模型的评估指标有两个最常用的mIoU和Pixel Accuracy。mIoU计算每个类别的预测区域和真实区域的交并比再对所有类别取平均。它惩罚两类错误把非该类像素预测成该类以及漏掉该类像素。像素精度的计算是所有预测正确的像素除以总像素数在背景占比极高的情况下会虚高所以论文里一般两者都报重点看mIoU。import numpy as np def compute_miou(pred_mask, true_mask, num_classes): 计算语义分割的 mIoU pred_mask: 模型预测的类别索引图 [H, W] true_mask: 真实标签索引图 [H, W] num_classes: 类别总数包含背景 ious [] for cls in range(num_classes): pred_cls (pred_mask cls) true_cls (true_mask cls) intersection (pred_cls true_cls).sum() union (pred_cls | true_cls).sum() if union 0: # 真实和预测都没有这个类别跳过而不是记0分 continue ious.append(intersection / union) return float(np.mean(ious)), ious这段代码有一个关键处理当某个类别在真实图和预测图中都不存在时union为0直接continue跳过。如果不跳过而是记0一个小类别的缺失会把整体mIoU拉得非常低这在只有几类的数据集上尤其明显。反过来如果某个类别在验证集中出现次数特别少它的IoU会剧烈波动所以验证集划分要保证每个类别都有足够像素。4.2 训练时如何保存最优模型别把最后一个epoch当成宝很多初学者训练完直接拿最后一个epoch的权重去测试这种做法在遥感分割里经常踩坑。训练后期验证集mIoU往往在某个epoch达到峰值之后开始掉——这就是过拟合的典型信号。再继续训练训练集损失还在降但验证集效果已经退化。我习惯在训练循环里加一个验证步骤每次验证完比较当前mIoU和best_miou优于历史最优就保存一份权重。这样最终手里掌握着两份模型一份是最后一个epoch的一份是验证集上表现最好的测试时用后者。best_miou 0.0 for epoch in range(num_epochs): train_one_epoch(model, train_loader, optimizer, criterion) val_miou, val_pa evaluate(model, val_loader, num_classes) if val_miou best_miou: best_miou val_miou torch.save(model.state_dict(), best_unet.pt) print(fEpoch {epoch}: val_miou improved to {best_miou:.4f})保存权重时我还会顺手记录对应的epoch号、验证loss和各类别IoU存在一个小的JSON里。PPT里画训练曲线时用得上答辩回答“为什么选这个epoch的模型”时也有据可依。4.3 预测结果可视化把分割标签叠加回遥感原图模型输出的是一张类别索引图直接看灰度图很难向非专业人士解释清楚。常见的做法是把索引图映射成彩色图再和原始遥感影像做半透明叠加这就是“遥感图像融合”在分割可视化里的典型应用——不是波段融合而是结果与影像的融合展示。import cv2 import numpy as np # 类别到RGB颜色的映射0号背景保持黑色 palette [ (0, 0, 0), # 背景 (255, 0, 0), # 建筑 (0, 255, 0), # 道路 (0, 0, 255), # 水体 (255, 255, 0), # 植被 (0, 255, 255) # 其他 ] def visualize_result(image, pred_mask, alpha0.5): color_mask np.zeros((*pred_mask.shape, 3), dtypenp.uint8) for cls, color in enumerate(palette): color_mask[pred_mask cls] color overlay cv2.addWeighted(image, 1 - alpha, color_mask, alpha, 0) return overlay叠加显示时alpha值我一般取0.4到0.6。alpha太大分割结果会盖住原始纹理看不出边界贴合的精度alpha太小又看不清楚分割区域的完整形状。另外注意cv2.addWeighted要求两张图尺寸一致预测前如果对输入做了缩放可视化前要把预测图resize回原图尺寸否则叠加时直接报错或者错位。5. UNet训练避坑指南我在遥感图像分割上踩过的5个坑5.1 显存不够512×512的图一训练就OOM现象训练刚开始几分钟RuntimeError报CUDA out of memory程序直接崩溃。原因UNet的特征图在编码器阶段分辨率很大512×512输入在第一层就占据64个通道再加上解码器里拼接操作会短暂地把两块特征图拼到一起显存峰值往往出现在这个位置。解决显存小于8GB时先把crop_size降到256batch size降到4如果还想跑512打开PyTorch的梯度累积每4个batch更新一次梯度等效于batch size 16的效果。注意梯度累积时要手动除以累积步数否则学习率相当于被放大了。5.2 裁剪后的训练图和标签图错位模型学了个寂寞现象训练损失降到很低但验证集上边界完全对不上预测结果像被平移过。原因数据增强时对图像做了随机旋转或翻转但标签图没有同步操作。比如代码里用torchvision.transforms.RandomHorizontalFlip只作用于imagelabel被跳过或者用了不同随机种子。解决重写一个能同时处理图像和标签的增强函数用同一个随机种子生成翻转和旋转参数再分别应用到image和label上。另外裁剪时保存的图片路径要成对命名读取时用zip同时遍历image和label目录而不是分别glob再排序防止两个列表顺序不一致。5.3 小目标被大背景吞掉道路断成一段一段现象分割结果里建筑轮廓还行但道路细碎、不连续小水体直接消失。原因交叉熵损失在背景占比90%以上的情况下只要把像素预测成背景就能获得极低的损失。道路和车辆这类小目标对损失的贡献太小梯度信号被背景淹没。解决把损失换成上一节说的混合损失交叉熵加Focal Loss加Dice Loss给困难类别单独设置weight。如果道路效果还是不行检查标签里道路是不是只有单像素宽——如果道路宽度小于两个像素UNet的下采样很难保留这种高频结构需要在标注阶段把道路统一加粗到3像素以上。5.4 mIoU虚高背景类别把分数撑起来了现象验证集mIoU到了0.85渲染出来的预测图却明显有很多错误分割。原因当数据集里背景占比超过80%时背景类别的IoU通常接近0.95以上把整体mIoU拉高了。模型的真实分割能力被这个高背景分数掩盖。解决评估时单独算每个类别的IoU重点关注建筑、道路、车辆等前景类别的均值foreground_mIoU。答辩PPT里把各类别IoU列成柱状图比只写一个总分数有说服力得多。另外在验证集划分时按类别比例分层抽样保证每个类别在验证集里都有足量像素。5.5 预测结果拼接回大图时出现网格状接缝现象用滑动窗口预测大图再把小块拼回去拼图边界处有明显的颜色断裂或者错位像一块块瓷砖拼接出来的。原因模型对小图中心区域的预测置信度高越靠近边缘置信度越低。滑动窗口步长太小导致窗口重叠部分被预测了多次取平均值或简单覆盖时边缘权重不一致就出现接缝。解决预测时用加权融合窗口中心区域的输出权重高、边缘权重低重叠区域按权重加权平均。一个简化做法是步长设成窗口大小的四分之一重叠区域直接取最后一遍预测结果也能显著缓解接缝。注意拼接前把每个窗口的预测图从模型输入尺寸resize回窗口对应的原始大图坐标避免因为padding导致坐标偏移。6. 想让毕设往上走一步UNet改进与验证的正确姿势如果你不满足于直接跑通UNet想在毕设里加一点自己的东西我建议从两个方向入手这也是“unet模型改进”最常见的两种思路。第一个方向是轻量化。遥感大图推理速度慢是实际工程里的痛点把标准卷积替换成深度可分离卷积或者把编码器主干换成MobileNetV2能在精度损失很小的情况下把推理耗时降低40%以上。实现时不用重写整个UNet只需要把ConvBlock里的nn.Conv2d换成深度可分离卷积组合再调整通道倍数就能看到显存占用和单张推理时间的变化。答辩时这部分的对比数据非常直观同精度下你的模型每秒能处理多少像素。第二个方向是多尺度特征融合。遥感图像的特点是一个目标在影像里可能跨越多个尺度小汽车和运动场在同一幅图里同时存在。在UNet的瓶颈层接入一个简单的ASPP模块用不同空洞率的并行卷积捕获不同感受野的信息再把结果拼接回解码器。这种做法改动小但对mIoU的提升通常比调整损失函数更明显尤其对跨尺度目标密集的区域。最后补一个验证方法改进的效果不能只看总mIoU涨了多少一定要看具体类别。以前我做完一个改进盯着总体分数涨了0.3就兴奋地去写报告答辩时老师问“建筑和道路分别涨了多少”我支支吾吾答不上来。从那以后我养成了一个习惯任何模型改动必须输出一张逐类别IoU的对比表并且选两个典型场景做可视化对比一张是改进前的错误分割图一张是改进后的结果图。分数是抽象的图是直观的两张图摆在一起你的工作价值一眼就能看出来。祝你的毕设顺利希望帮到你。本文还有配套的精品资源点击获取
返回列表