ARTICLE DETAIL

资讯详情

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

UNet遥感图像语义分割实战:原理、实现与避坑指南

UNet遥感图像语义分割实战:原理、实现与避坑指南 简介面向遥感图像语义分割与毕业设计场景的完整实现方案适合计算机视觉相关专业学生快速搭建基于UNet的分割系统解决从数据集标注到模型训练评估的实践难题。资源包共69个文件压缩后约47.26MB6个Python源文件对应模型、数据、训练、预测等核心模块3个ipynb笔记本演示数据创建、训练与推理过程辅以32个PNG和6个SVG展示网络结构、训练曲线与分割结果5个TeX文件构成毕业论文各章节另有PDF成品、字体及辅助脚本目录组织清晰便于按需取用。已有346人浏览学习可直接参考或二次改造。内容完整给出UNet编码器-解码器实现细节涵盖数据准备、预处理、训练评估与推理应用全链路同时配套参考论文和LaTeX模板帮助读者理解像素级语义分割关键操作结合IoU等指标完善实验分析并高效产出规范毕业设计文档。1. 毕业设计选UNet做遥感图像语义分割最稳的起点但坑也不少遥感影像里房子是一片一片的路是一段一段的水是一块一块的。要把它们从图上准确分出来最省事、也最常被写进毕设摘要的做法就是搭一个UNet跑语义分割。这个组合之所以稳是因为UNet结构简单数据需求比Transformer类模型低遥感图像又有清晰的空间纹理不太依赖大规模预训练就能出图。它解决的实际问题是逐像素分类每个像素属于建筑、道路、植被还是背景。适合带着毕设压力、想尽快跑通模型再逐步改进的同学也适合第一次接触语义分割算法、想拿真实遥感影像练手的人。但真做起来你会在数据标注、大图切块、类别不平衡这些地方反复翻车。这篇文章就把这条路完整走一遍从概念、数据准备、训练代码到参数设置和避坑一次讲透。2. UNet和遥感图像为什么这么搭先搞懂这几件事再开写代码2.1 语义分割、实例分割、目标检测遥感题目里先分清概念很多人第一次接触语义分割算法时爱拿YOLO来类比这能帮你建立直觉但千万别把边界混淆。目标检测输出的是矩形框语义分割输出的是和原图一样大的标签图每个像素有一个类别ID。实例分割比语义分割更进一步同一类里面的每个独立对象还要分开。放在遥感图像里这个差别非常具体你要提取一片水体语义分割只需要把所有水域像素标成“水体”如果要在水面上区分每一条船那才需要实例分割。所以毕设题目是“基于UNet的遥感图像语义分割”评估指标要用IoU、mIoU这类像素级指标不能用目标检测里的mAP。训练标签也应该是像素级mask不是矩形框。不少同学把题目写成“遥感图像目标检测”那是另一条线检测只需要框和类别不需要分割出地物的锯齿状边缘。有些工作会在检测框的基础上再切图做分割做法上是两套标签、两套模型别混在一起。在遥感场景里还有一个天然矛盾地物边界往往不清晰不同类别之间的光谱很接近。比如阴影下的建筑和道路、水面和湿润裸地肉眼都容易看错。语义分割模型要解决的正是这种像素级判别问题这也是它比检测框更适合做面积统计、边界分析的原因。2.2 UNet结构里真正影响遥感分割效果的三个位置UNet是典型的编码器-解码器结构基础版本其实就几行代码编码器四次下采样提取多尺度特征最底部是瓶颈层解码器四次上采样逐渐恢复分辨率中间用跳跃连接把编码器同尺度特征拼回来。它的优势在遥感图像上刚好对得上编码器让模型看到“大范围上下文”解码器把细节恢复到原分辨率跳跃连接把边缘纹理送回来。遥感图像和自然影像有两个不同特点一是地物尺度差异大道路细、建筑小、水体大二是边界往往模糊阴影和相似光谱会干扰。于是UNet里这三个位置最值得盯。第一下采样次数和最后一层的分辨率。很多公开UNet默认下采样4次输入512x512时瓶颈层只有32x32。对小目标来说4次已经偏激进。如果数据集里大量是像素宽的道路网可以考虑把输入切成256x256或者只下采样3次。这个调整比乱加模块管用。第二跳跃连接。UNet能强过FCN全靠跳连恢复细节但跳连也把编码器里的噪声和未归一化信息直接送进解码器。遥感tif如果没做归一化跳跃连接通道的数值差异会盖过解码器特征训练初期loss波动会很大后面会专门讲这个问题。第三输入通道数。普通UNet用in_channels3假设输入是RGB。遥感影像常有四波段甚至更多比如R、G、B、NIR。如果直接换tif第一个卷积层的通道数必须改成4。很多人忘了改loss不降还在模型上找原因其实数据根本不匹配。只要不追求SOTA毕设入门完全不需要一上来就上DeepLabV3或PSPNet。UNet结构透明、参数少、显存压力小后面想加attention、换backbone、做深监督都容易写清楚。这也是我推荐它做基线的原因。2.3 遥感图像标注和数据集制作跑UNet之前最花时间的一步公开数据集能用就用常见的包括DeepGlobe土地利用、ISPRS Potsdam、LoveDA。以Potsdam为例它提供tif格式的原始影像和对应标签命名和类别定义比较标准适合先用它跑通流程再换自己的数据。这类数据集一般需要自己写加载代码不同数据集标签格式不统一有的标签是RGB图像有的直接是灰度ID图。如果要自己标注流程一般是用QGIS或LabelMe画多边形。LabelMe导出的JSON每个shape含label和points。之后写脚本把JSON转换成单通道mask。这里给一个最简转换版本import json import numpy as np import cv2 def json_to_mask(json_path, img_h, img_w, class_map): with open(json_path, r, encodingutf-8) as f: data json.load(f) mask np.zeros((img_h, img_w), dtypenp.uint8) for shape in data[shapes]: cls_id class_map.get(shape[label], 0) pts np.array(shape[points], dtypenp.int32).reshape(-1, 1, 2) cv2.fillPoly(mask, [pts], cls_id) return mask这段代码里class_map是标签名到类别ID的映射例如{building: 1, water: 2}。拿到cls_id后用OpenCV的fillPoly把当前多边形内部区域填充成对应类别。两点需要特别注意一是LabelMe导出坐标通常是图像像素坐标但如果你从GIS平台转出先确认坐标对应的投影和分辨率否则标签和影像之间会整图偏移二是mask必须存成pngpng支持无损且能存uint8单通道不要存成jpgjpg的压缩伪影会把类别值破坏掉。制作完标注后把影像和mask统一重采样到同一尺寸类别从0开始编号。背景设为0还是255需要和loss里的ignore_index一致。我习惯把背景设为0其他类别从1开始个别边缘像素设为255训练时用ignore_index255跳过。数据集制作阶段多花一小时检查坐标对齐后面训练能省两天。别急着上模型先挑几张图和mask叠加看看轮廓对不对这一步是后面所有结果的基数。3. 跑通一个UNet网络遥感图像从文件到训练曲线的完整流程3.1 先定目录结构和数据清单想跑通一个UNet网络第一件事不是写模型而是先确认数据能正常喂进去。整个工程我建议这么放remote_seg/ data/ images/ masks/ train.txt val.txt src/ unet_model.py dataset.py train.py predict.pytrain.txt每行写images/a.tif;masks/a.png分号隔开路径相对data目录。不要用绝对路径换机器跑的时候能少改一堆东西。先把一个完整项目固定成这种结构调参、复现、写说明文档都有依据。环境方面只需要PyTorch、Pillow、numpy、OpenCV。如果影像是大tif读图时要用tifffile或rasterioPillow直接打开多波段tif容易丢波段。显存不够时按第4章的方法切图先把流程跑通最重要不要一上来就在最高分辨率上死磕。3.2 最小UNet模型定义下面这个UNet不花哨但足够你完成毕设主体。基础特征通道数是64输入通道数可调输出通道数和类别数一致。# unet_model.py 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), 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): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels3, num_classes2, base64): super().__init__() self.inc DoubleConv(in_channels, base) self.down1 nn.Sequential(nn.MaxPool2d(2), DoubleConv(base, base * 2)) self.down2 nn.Sequential(nn.MaxPool2d(2), DoubleConv(base * 2, base * 4)) self.down3 nn.Sequential(nn.MaxPool2d(2), DoubleConv(base * 4, base * 8)) self.down4 nn.Sequential(nn.MaxPool2d(2), DoubleConv(base * 8, base * 16)) self.up1 nn.ConvTranspose2d(base * 16, base * 8, 2, stride2) self.conv1 DoubleConv(base * 16, base * 8) self.up2 nn.ConvTranspose2d(base * 8, base * 4, 2, stride2) self.conv2 DoubleConv(base * 8, base * 4) self.up3 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.conv3 DoubleConv(base * 4, base * 2) self.up4 nn.ConvTranspose2d(base * 2, base, 2, stride2) self.conv4 DoubleConv(base * 2, base) self.outc nn.Conv2d(base, num_classes, 1) def forward(self, x): x1 self.inc(x) x2 self.down1(x1) x3 self.down2(x2) x4 self.down3(x3) x5 self.down4(x4) x self.up1(x5) x self.conv1(torch.cat([x, x4], dim1)) x self.up2(x) x self.conv2(torch.cat([x, x3], dim1)) x self.up3(x) x self.conv3(torch.cat([x, x2], dim1)) x self.up4(x) x self.conv4(torch.cat([x, x1], dim1)) return self.outc(x)这段代码的逻辑是典型UNet对称结构down1到down4逐层下采样每层通道数翻倍up1到up4用转置卷积上采样再把对应的编码器特征在通道维拼回去。torch.cat([x, x4], dim1)就是跳跃连接。输出层outc用1x1卷积把通道数压缩成类别数不做softmax因为后面的CrossEntropyLoss自带softmax提前做反而容易出数值问题。参数上in_channels必须和实际图像波段数一致。如果只用RGB的jpg切片填3如果用了RGBN四波段tif填4。num_classes填类别总数背景也算一类。base默认为64显存紧张时改成32能明显降低占用代价是特征表达弱一些。3.3 数据加载器和训练主循环数据加载部分直接继承PyTorch的Dataset接口。有一个核心原则图像用双线性插值缩放mask必须用最近邻插值缩放否则类别值会被插成小数训练时等于随机改标签。# dataset.py import numpy as np from PIL import Image import torch from torch.utils.data import Dataset class RemoteSegDataset(Dataset): def __init__(self, file_list, img_size256, augFalse): self.samples [] self.img_size img_size self.aug aug with open(file_list, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue img_path, mask_path line.split(;) self.samples.append((img_path, mask_path)) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, mask_path self.samples[idx] img Image.open(img_path) mask Image.open(mask_path) img img.resize((self.img_size, self.img_size), Image.BILINEAR) mask mask.resize((self.img_size, self.img_size), Image.NEAREST) img np.array(img, dtypenp.float32) / 255.0 mask np.array(mask, dtypenp.int64) if img.ndim 2: img np.stack([img] * 3, axis-1) if self.aug: if np.random.rand() 0.5: img img[:, ::-1] mask mask[:, ::-1] if np.random.rand() 0.5: img img[::-1] mask mask[::-1] img torch.from_numpy(img).permute(2, 0, 1).float() mask torch.from_numpy(mask) return img, mask这里有几个容易忽略的点。img / 255.0把像素压到0到1之间后面才稳。增强只做了水平翻转和垂直翻转没有做颜色抖动因为遥感影像的光谱信息对分割结果影响很大色相变化可能把水体变成草地。mask转成int64是为了配合交叉熵损失的标签格式PyTorch的分类标签必须是整数不能是one-hot。训练主循环很短核心思想就是前向传播算loss反向传播更新参数每个epoch打一次平均loss# train.py 核心训练循环只保留主干 import torch import torch.nn as nn from torch.utils.data import DataLoader from unet_model import UNet from dataset import RemoteSegDataset NUM_CLASS 3 BATCH_SIZE 8 EPOCHS 60 LR 1e-3 model UNet(in_channels3, num_classesNUM_CLASS) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lrLR) train_set RemoteSegDataset(data/train.txt, img_size256, augTrue) train_loader DataLoader(train_set, batch_sizeBATCH_SIZE, shuffleTrue, num_workers2) for epoch in range(EPOCHS): model.train() total_loss 0.0 for img, mask in train_loader: pred model(img) loss criterion(pred, mask) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch} | loss {total_loss / len(train_loader):.4f})这个循环里pred的形状是(B, NUM_CLASS, H, W)mask的形状是(B, H, W)。CrossEntropyLoss会自动把pred按通道维做softmax再和整数标签对起来算loss。如果你想用GPU在模型和输入上分别调用.cuda()即可笔记本CPU也能跑只是训练慢一点建议先用小图小epoch验证链路通不通。3.4 训练参数怎么设从lr到损失函数的选择毕设调参不需要玄学记住下面这张表就能覆盖大多数场景。参数常见取值设置理由patch size256或512显存有限用小图地物细节多的用512batch size8到32256分辨率常用16512分辨率只能4到8lr1e-3或1e-4从头训练用1e-3迁移学习用1e-4optimizerAdam收敛快调参压力小loss交叉熵或交叉熵Dice类别均衡用CE不均衡用联合lossepochs60到120配合早停防止过拟合最需要盯的是loss和验证mIoU的曲线。如果loss下降特别慢先查数据有没有读错再考虑降低lr如果loss一直在降但验证指标不涨说明模型过拟合这时候减少epoch或者加强数据增强比换模型有用。遥感图像类别不均衡非常常见。一块1000x1000的影像里道路可能只有几千像素背景却有几十万像素。此时普通交叉熵会被背景主导哪怕道路全没分割出来loss也显得很低。最简单的办法是给每个类别加权重下一章会给出具体代码。4. UNet用于遥感图像语义分割的避坑清单5个高频翻车点4.1 整图输入导致内存溢出现象原图tif分辨率动辄几千乘几千batch size设为1也报OOM甚至CPU内存先爆掉。原因UNet的特征图尺寸随输入线性放大。一张5000x5000的影像输入进去第一层特征图就接近原图大小显存根本扛不住。解决训练阶段把影像切成256或512的patch。可以按网格切也可以做10%到15%重叠增加边缘样本的覆盖。推理阶段用滑窗预测每个窗口只保留中心区域的结果边缘部分丢弃因为边缘缺少上下文预测质量差。用重叠推理再取平均边界会更连续。4.2 类别严重不均衡导致loss不下降或预测全变成背景现象模型训练几十个epoch后预测结果几乎全是背景但loss却不高。原因交叉熵损失被背景类主导前景类的梯度被淹没模型学到一个“全预测背景”的局部最优解。解决统计训练集里每个类别的像素数量给像素多的类别低权重、像素少的类别高权重# 统计类别像素数量后生成权重 import numpy as np import torch import torch.nn as nn counts np.bincount(all_mask_ravel, minlengthNUM_CLASS) weights 1.0 / (counts 1e-6) weights weights / weights.sum() * NUM_CLASS criterion nn.CrossEntropyLoss( weighttorch.tensor(weights, dtypetorch.float32) )这段代码里all_mask_ravel是所有验证集mask展平后的numpy数组。1.0 / counts让像素少的类别得到更大的loss权重然后归一化到类别数均值附近保持loss尺度稳定。注意minlengthNUM_CLASS防止某些类别一个像素都没有时报错。如果加了权重后训练震荡把lr降到原来的一半重跑。4.3 JSON标注转mask时坐标错位、类别错乱现象训练出来的结果和原图对不上或者有一些类别完全没学过预测图上全是背景。原因最常见是两个一是LabelMe导出的坐标是x,y顺序但你在numpy里用y,x索引mask导致多边形发生转置二是标注类别名和class_map里的键不一致class_map.get(shape[label], 0)把所有未知标签都归到了背景等于把一部分标注静默丢弃。解决转mask后立刻做一次可视化检查把原图和mask叠加看每个类别的轮廓是否贴合。另外mask缩放必须用最近邻插值任何线性插值都会在类别边界产生不属于任何类别的中间值训练时这些像素会给loss带来噪声。我一般会在json_to_mask里加一句断言assert set(class_map.keys()) set( shape[label] for shape in data[shapes] ), 存在未在class_map中定义的标签这句断言能保证你有漏标类别时第一时间报错而不是等训练结束后才发现标签有问题。4.4 mIoU看着还行可视化效果却一塌糊涂现象验证集mIoU达到0.7打开预测图却发现道路断断续续建筑边界像锯齿。原因mIoU是所有类别的IoU取平均大类别只要分割得差不多就能把整体指标拉高。小类别比如只有几个像素宽的道路IoU很低但被其他类别平均后掩盖了。解决评估时不要只看mIoU把每个类别的IoU单独打印出来重点关注数值最低的类别。同时保存预测mask和原图的叠加可视化结果。指标是给论文看的可视化是给自己看的两者要同时过关才算真正解决。4.5 训练过程中loss突然变成NaN现象训练到某个epoch后loss变成NaN并且再也回不来。原因常见是lr过高导致梯度爆炸或者输入图像里存在NaN值或者mask里类别ID超过了num_classes计算交叉熵时直接越界。解决先说排查顺序。第一个batch打印img.min()、img.max()、torch.unique(mask)确认输入和标签范围正常。然后确认tif按float32读入并除以255很多原始tif是uint16数值范围0到65535直接喂给网络会让第一层卷积输出爆炸。如果数据没问题就在反向传播前加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)这句代码把梯度的二范数限制在5以内能避免个别样本把参数瞬间推飞。加了裁剪之后如果还出NaN再下调lr。5. 评测与低成本改进UNet从能跑到能写进论文5.1 用mIoU和每类IoU做评测语义分割算法最常见的评测指标是IoU和mIoU。IoU计算的是预测和真实标签的交集除以并集完全重合是1完全不重合是0。mIoU是各类IoU的平均值但只算有真实目标出现的类别。# 评估阶段计算每类IoU def compute_iou(pred_mask, true_mask, num_classes, ignore_index255): pred_mask pred_mask.flatten() true_mask true_mask.flatten() ious [] for cls in range(num_classes): p (pred_mask cls) t (true_mask cls) inter (p t).sum().item() union (p | t).sum().item() if union 0: ious.append(inter / union) else: ious.append(float(nan)) return ious这里pred_mask是模型argmax之后的结果true_mask是原始标签。ignore_index把边界像素排除在外避免那些本来就没标注的区域干扰指标。最后汇总时把NaN当成0参与平均同时单独列出一个类别的IoU表写成论文时才不会被答辩老师问到无话可说。评估时记得用torch.no_grad()包起来否则会累积计算图导致显存持续上涨。5.2 预测结果可视化写论文除了指标还需要图片展示。一个低成本做法是把预测mask转成RGB再和原图做半透明叠加import cv2 import numpy as np mask_rgb np.zeros((h, w, 3), dtypenp.uint8) for cls, color in class_colors.items(): mask_rgb[pred cls] color overlay cv2.addWeighted(img_rgb, 0.7, mask_rgb, 0.3, 0) cv2.imwrite(result_overlay.png, overlay)叠加图能直观看出边界准不准、小目标有没有漏。这里class_colors是类别到BGR颜色的映射例如建筑用红色(0, 0, 255)水体用蓝色(255, 0, 0)。保存的图直接放进论文结果章节比只贴一张标签图更有说服力。5.3 低成本改进方向先改数据再改模型毕设如果不想止步于原版UNet按性价比排序可以考虑三个方向。第一个方向是换backbone。把UNet的编码器替换成ResNet34或ResNet50并用ImageNet预训练权重初始化。遥感图像和自然图像不完全同分布但预训练的底层边缘纹理特征还是能迁移收敛会明显加快。改动量不大效果通常比原版UNet好不少。第二个方向是加轻量注意力模块比如CBAM或SE模块。加在解码器最后一层之前只增加十几个参数却能改善边界模糊问题。这个改进写进论文里属于“注意力机制优化UNet”是很多遥感分割文章的主流套路。第三个方向是深监督。在UNet的每一层解码器输出旁都加一个辅助损失让浅层特征也收到明确的梯度信号。实现时只需把深层特征上采样到原图尺寸分别计算loss后加权求和权重系数一般取0.3左右。对道路这种细目标深监督的提升比加卷积更明显。一个很容易被忽略的便宜改进是推理时测试增强。预测时把输入翻转一下再预测得到两个结果取平均往往能让mIoU提升一两个点论文里写成“TTA策略”也不显得凑数。我会先挑一张最复杂的图把预测图打开和原图对照看再决定要不要继续加模块。指标能骗人可视化不会。最后希望帮到你。本文还有配套的精品资源点击获取
返回列表