U-Net遥感图像语义分割实战:数据处理、模型训练与推理增强全流程
简介面向计算机视觉方向本科毕业设计的一套完整U-Net遥感图像语义分割资源聚焦多光谱影像中的建筑自动分割适合需要复现实验、撰写论文或快速搭建分割项目的读者。方案提出类别平衡交叉熵损失函数并与经典U-Net结合在Inria Aerial Image Labeling Dataset上完成训练和对比评估实验表明该损失函数相比普通交叉熵能将F1 Score从0.47提升至0.51提升了约8.5%。资源包共69个文件、约46.98MB涵盖Python核心源码数据读取、模型定义、训练与预测脚本、3个Jupyter Notebook演示、LaTeX论文源文件、PDF论文以及32张PNG图表、6个SVG矢量图等目录按数据处理、模型搭建、实验对比组织便于系统学习。同时附带论文PDF、Bib参考文献和启动脚本可直接作为毕业设计参考框架或二次开发基础。已有453人学习下载适合想深入理解U-Net在遥感语义分割中应用的研究者与学生。1. 拿到这份 U-Net 遥感图像语义分割资源先别急着跑训练把这份「U-Net 遥感图像语义分割」毕业设计压缩包解压后你第一个要找的不是train.py而是三个静态文件数据目录结构、标签色彩映射表、README 里有没有写明原始标注格式。我见过太多人拿到模型代码就急着跑训练结果三小时后才发现标签全是背景或者验证集切分和官方不一致mIoU 高得像假数据。这份资源把 U-Net 模型、遥感影像预处理、训练评估串成了一条能完整跑通的主线适合两类人一是要做图像分割方向毕业设计、想要可复现基线的同学二是刚接触语义分割算法、想知道卫星影像和普通照片在数据组织上差在哪的工程师。它解决的核心问题就一个别再让环境、数据和评估掩盖模型本身的真实效果。2. 遥感图像语义分割先把数据和标签理顺再谈模型2.1 遥感影像与自然图像的分割差异遥感图像语义分割本质上是像素级分类但和普通街景、COCO 数据集里的照片相比有两个显著差异。第一个是视角和尺度遥感影像是俯视视角同一张图里可能同时出现一整片农田和一条两三米宽的小路目标尺度跨度极大固定尺寸的裁剪策略很容易把细碎目标切碎。第二个是类分布背景、未利用地通常占掉 70% 以上像素建筑、道路只占一小部分用普通交叉熵训练时模型会倾向于把所有像素都判成背景。对 U-Net 这类全卷积网络来说遥感图还有一个实际约束原图往往是几千乘几千像素没法直接整图喂进显存。所以几乎所有遥感分割项目都得先做滑窗裁剪或随机裁剪把训练数据统一成 256×256 或 512×512。常见做法是训练时随机裁剪验证时中心裁剪推理时再做滑窗拼接后面我会专门讲。2.2 为什么毕设基线选 U-Net 而不是 FCNFCN 是语义分割的经典起点但它有一个很实际的问题连续池化和步长卷积让特征图一路缩小上采样时只能把低分辨率特征图直接拉回原尺寸细节恢复能力有限车辆、房檐这些边缘在预测图上经常是糊的。U-Net 的跳跃连接把编码器每层的高分辨率特征拼到解码器对应层浅层边缘信息能直接参与上采样在中等分辨率遥感图上通常能比 FCN-8s 高 815 个百分点的 mIoU。从复现角度看U-Net 对一个毕设项目来说也更稳妥。它没有空洞卷积、没有复杂的金字塔池化结构对称参数量大约在 3050M 量级一张 6GB 显存的显卡就能训练。对比一下常见选型模型参数量空间细节恢复训练难易度遥感场景典型 mIoUFCN-8s约 34M较弱上采样粗糙容易但精度上限低中等数据集约 5060U-Net约 47M较强跳跃连接保留高频信息容易收敛稳定中等数据集约 6578需要说明的是mIoU 数值跟数据集类别数、分辨率关系很大这个表格只是给你一个选型判断如果你的毕设创新点是网络结构改进U-Net 是最好对比的基线模型如果你的创新点偏应用比如做建筑物提取或水体识别直接用 U-Net 做完整流程也够写一篇像样的论文。2.3 标签转换与数据集划分RGB 标注不能直接当训练标签遥感数据集的标注格式比自然图像数据集更乱。常见的有两种一种是单通道灰度图像素值直接是类别索引另一种是 RGB 调色板图每个类别用一种颜色表示。第二种最容易翻车因为很多同学把 3 通道彩色标签图直接当输入数据一起归一化导致模型学到的标签是颜色值而不是类别索引。我一般会先写一个颜色到索引的映射函数把 RGB 标签统一转成单通道索引图import numpy as np from glob import glob def rgb_to_class_index(img_rgb, class_color_map): 把 RGB 标签图转换为单通道整数索引图。 class_color_map 形如 {0: [0,0,0], 1: [255,0,0]}255 保留给 ignore 区域。 h, w, _ img_rgb.shape out np.full((h, w), 255, dtypenp.uint8) # 255 表示不参与训练的像素 for cls_id, color in class_color_map.items(): diff np.abs(img_rgb.astype(np.int16) - np.array(color, dtypenp.int16)).sum(-1) out[diff 0] cls_id return out img_paths sorted(glob.glob(data/images/*.png)) label_paths sorted(glob.glob(data/labels/*.png)) assert len(img_paths) len(label_paths), 影像和标签数量必须一一对应 # 固定随机种子一次性切分验证集避免每次训练样本分布不一致 np.random.seed(42) indices np.random.permutation(len(img_paths)) val_idx indices[: int(len(indices) * 0.2)] train_idx indices[int(len(indices) * 0.2):]这段代码里有两个容易忽略的点。第一np.abs(...).sum(-1)是在比较每个像素的 RGB 值是否与目标颜色完全相等用int16是为了避免uint8减法溢出。第二255作为 ignore 索引非常关键遥感图边缘经常有云遮挡或标注缺失这些像素不该参与 loss 计算交给CrossEntropyLoss(ignore_index255)处理即可。数据集划分一定要固定种子。有些训练脚本每次运行都重新切分验证集导致两次实验的验证样本不同mIoU 忽高忽低。数据准备阶段就把train_idx、val_idx存成文件训练时直接读取这是让实验结果可复现的第一步。归一化时只对影像做标签图保持整数索引千万别把归一化操作应用到 label 上。3. U-Net 拆开复现编码器、解码器与跳跃拼接的实现要点3.1 编码器为什么偏好双层 3×3 卷积加 BNU-Net 编码器每一层都是两个 3×3 卷积加 BatchNorm 加 ReLU然后接一个 2×2 最大池化下采样。两个 3×3 卷积堆叠的感受野等价于一个 5×5 卷积但参数量更少且中间多了一次非线性激活特征表达能力更强。遥感图像里道路、水体这类目标的纹理相对规则3×3 卷积足够捕捉局部上下文没必要一上来就用大卷积核。通道设计上典型配置是第一层 64 通道之后每下采样一次翻倍64 → 128 → 256 → 512 → 512。这个翻倍节奏是精度和显存的平衡点。如果显存紧张可以把起始通道降到 32整体参数量降约四倍训练速度明显提升mIoU 通常只掉 24 个点适合先跑通流程再回来调大。BatchNorm 在遥感分割里的作用不能低估。不同时相的卫星影像亮度差异很大BN 会把每层输入拉回到稳定分布模型对光照变化的鲁棒性会好很多。但注意batch_size太小比如 2 或 4时BN 的统计量抖动大训练容易不稳。我一般会保证单卡 batch size 不低于 8如果显存不够就缩输入尺寸而不是缩 batch。3.2 解码器上采样选双线性还是转置卷积解码器负责把高维特征逐步恢复成原图分辨率。上采样有两种主流实现转置卷积和双线性插值加普通卷积。U-Net 原版用的是转置卷积但实际复现时我更推荐双线性插值加卷积的组合原因有两个。第一转置卷积容易产生棋盘效应尤其在训练不充分时预测图会出现规律的网格纹路双线性插值生成的梯度更平滑棋盘效应基本消失。第二双线性上采样本身没有可学习参数后续接的普通卷积负责融合语义信息训练更稳定。代价是高频边缘的锐利度略弱但对遥感建筑物提取这类任务后续可以加 CRF 或多尺度推理补救。3.3 一个可以直接跑的 PyTorch 版 U-Net项目代码包里通常已经实现好了完整的 U-Net我这里给一个精简但完整可跑的实现方便你对照理解每个模块的作用也方便在源码基础上改通道数或加注意力机制import torch import torch.nn as nn class DoubleConv(nn.Module): 两次 3x3 卷积 BN ReLUU-Net 的基本单元 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 Down(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.mpconv nn.Sequential( nn.MaxPool2d(2), DoubleConv(in_ch, out_ch) ) def forward(self, x): return self.mpconv(x) class Up(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.up nn.Upsample(scale_factor2, modebilinear, align_cornersTrue) self.conv DoubleConv(in_ch, out_ch) def forward(self, x1, x2): x1 self.up(x1) # 处理尺寸奇偶不一致导致的 padding 误差 diffY x2.size()[2] - x1.size()[2] diffX x2.size()[3] - x1.size()[3] x1 nn.functional.pad(x1, [diffX // 2, diffX - diffX // 2, diffY // 2, diffY - diffY // 2]) x torch.cat([x2, x1], dim1) # 跳跃连接直接拼接通道 return self.conv(x) class UNet(nn.Module): def __init__(self, n_channels3, n_classes6): super().__init__() self.inc DoubleConv(n_channels, 64) self.down1 Down(64, 128) self.down2 Down(128, 256) self.down3 Down(256, 512) self.down4 Down(512, 512) self.up1 Up(1024, 256) # 512512 拼接后为 1024 self.up2 Up(512, 128) # 256256 拼接后为 512 self.up3 Up(256, 64) self.up4 Up(128, 64) self.outc nn.Conv2d(64, n_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, x4) x self.up2(x, x3) x self.up3(x, x2) x self.up4(x, x1) return self.outc(x)代码里有三个地方值得细看。第一个是Up模块里的diffY和diffX因为最大池化和上采样组合时奇数尺寸输入会产生 1 像素的尺寸差直接cat会报错所以要先做 padding这个细节是初次复现最常卡住的报错点。第二个是拼接通道数跳跃连接把x2浅层特征和x1上采样结果在通道维度拼接所以每层Up输入通道是上一层的输出通道加上跳跃特征的通道数比如up1输入是 5125121024。第三n_channels和n_classes由你的数据决定遥感影像通常是 3 通道 RGB类别数按标签映射表填如果是多光谱数据就需要改n_channels。如果你用的是 TensorFlow/Keras有一个隐藏差异要注意UpSampling2D(size(2,2), interpolationbilinear)的默认interpolation是nearest不显式指定的话上采样结果会明显模糊训练出来的分割边界会像马赛克一样。4. 训练与评估把 loss、mIoU 和超参设到能复现的状态4.1 损失函数先用 CrossEntropy 做基线再用 Dice 精调遥感分割里类别不平衡是常态直接用朴素交叉熵会得到“全图都是背景”的预测。我习惯把nn.CrossEntropyLoss(ignore_index255)作为第一个基线。它收敛稳定梯度对每个类别相对公平而且通过观察各类别的 loss 曲线能快速判断哪些类学不动。基线跑通后如果发现小目标类别如车辆、独立房屋的召回率偏低再引入 Dice Loss。Dice 系数本质上是区域重叠的度量对类别不平衡不敏感。常见的做法是把交叉熵和 Dice 按 0.5:0.5 加权相加或者先用交叉熵训练 30 轮再切换成组合 Loss 精调 20 轮。注意 Dice Loss 本身不可导的近似实现一般用软预测概率和真实标签的平滑版本训练时不要把 mIoU 这类硬指标直接当成 loss 去反向传播这是新手很容易踩的坑。4.2 训练配置一组可复现的参数组合这套配置不是唯一解但能保证大多数遥感数据集在 50 轮内稳定收敛参数推荐值备注输入尺寸256×256 或 512×512小显存用 256追求精度用 512batch_size8低于 4 时 BN 会不稳定优化器Adam 或 SGDAdam 用 lr3e-4SGD 用 lr1e-2 momentum0.9学习率调度ReduceLROnPlateaupatience5factor0.5warmup5 epochs避免前几个 batch 梯度爆炸权重初始化kaiming_normal比默认初始化收敛更快数据增强随机翻转 旋转 色彩抖动只对影像做不对标签做归一化我用 Adam 时会把weight_decay设成 1e-4防止过拟合用 SGD 时反而经常不设 weight_decay让模型在遥感大图上更充分地拟合。训练早期如果发现 loss 从 2.0 掉到 0.4 就平飞了第一反应不要加模型容量先做一次学习率下降往往能再掉 0.1 左右。调参这事情很玄学但记录每组实验的 lr、batch、seed 和 mIoU比盲目改结构有用得多。4.3 mIoU 计算用混淆矩阵手写一个不依赖框架的版本mIoU 是语义分割最通用的评估指标但很多初学直接调库不知道它是怎么算出来的。这里给一个最小实现方便你验证自己的评估逻辑是否正确import numpy as np def compute_miou(preds, labels, num_classes): preds, labels 都是展平后的一维索引数组只统计有效类别 ious [] for c in range(num_classes): mask_true (labels c) mask_pred (preds c) # TP: 预测为该类且真实为该类 # FP: 预测为该类但真实不是 # FN: 真实为该类但预测不是 tp (mask_pred mask_true).sum() fp (mask_pred ~mask_true).sum() fn (~mask_pred mask_true).sum() denom tp fp fn iou tp / denom if denom 0 else 1.0 ious.append(iou) return np.mean(ious) # 推理时先对 logits 取 argmax再进入评估 # preds model(img).argmax(dim1).cpu().numpy().ravel() # labels label.cpu().numpy().ravel()这里denom 0时直接把 IoU 记为 1.0表示该类在验证集里不存在避免除零。实际项目中有些类在验证集里特别少单张图的 mIoU 会剧烈波动所以我会把整个验证集所有图片的混淆矩阵累加起来再算一次 mIoU而不是逐图算完再平均后者会高估小类别的贡献。除了 mIoU报告里最好补一个 Kappa 系数它对类别不均衡更敏感很多审稿人会关注这个指标。5. 避坑指南遥感分割训练里五个最容易翻车的点5.1 现象loss 下不去训练了 20 轮还是地板最常见的原因有两个。第一标签是 RGB 调色板图没有转换成类别索引模型拿颜色值当分类目标loss 当然降不下去。第二归一化时把影像和标签一起做了标准化label 从整数索引变成了浮点数CrossEntropyLoss直接报错或忽略。解决方法是先跑一遍2.3的标签转换脚本打印 label 的 dtype 和 unique 值确认是0,1,2,3这样的类别索引再开始训练。5.2 现象显存 OOM不是一开始就爆而是跑几分钟后爆这个翻车点很隐蔽。如果是一开始就 OOM多半是输入尺寸或 batch_size 太大跑了几个 epoch 才炸通常有三个嫌疑验证阶段没有包torch.no_grad()梯度图一直占着显存或者数据加载线程和模型训练同时堆积了太多中间变量或者混合精度训练时GradScaler没有正确更新导致 loss 溢出。我一般会先用 256×256 输入加 batch_size2 跑通一次确认没有显存泄漏再把 batch 调大同时打开torch.cuda.amp.autocast减少显存占用。5.3 现象mIoU 忽高忽低两次实验差距超过 5 个点模型结构没变、数据没变但结果差很多九成是随机种子没固定。PyTorch 里要同时固定random.seed、np.random.seed、torch.manual_seed和torch.cuda.manual_seed_all还要把DataLoader的shuffle随机源也一起固定。另外验证集切分要在数据准备阶段一次性完成并保存成文件不要每次运行训练脚本时都重新切分。只要train_idx变了验证结果就没有可比性。5.4 现象预测图碎得像盐粒边界像素在两类之间摇摆这类问题在遥感图上特别常见尤其是建筑和阴影、耕地和草地这种像素值接近的类别。单像素分类时模型对不确定区域会输出接近 0.5 的概率argmax 后出现椒盐噪声。解决办法有两个路径一是训练层面提高 Dice Loss 的权重让模型更关注区域一致性二是推理层面对概率图做最小连通域过滤小于一定像素数的区域并入邻域最大类别或者用 CRF 做后处理。工程上先做连通域过滤成本最低。5.5 现象混合精度训练后期 loss 变成 NaN用 AMP 后前面几步正常几百步后 loss 突然变成 NaN这个现象很容易让人怀疑是数据问题。常见原因是梯度在 FP16 下溢出尤其当学习率偏大时。解决思路是检查GradScaler是否正确调用了scaler.step(optimizer)和scaler.update()并让 lr 保持在 3e-4 以下。我遇到过最诡异的一次是 BN 层在 FP16 下统计量溢出把 BN 换成torch.float32前向传播就正常了。如果以上都没问题把 lr 降到 1e-4 再试一个 epoch基本能定位。6. 进阶用法多尺度推理和 TTA 让指标稳上一个台阶6.1 滑窗推理大图不裁剪直接跑会怎样遥感原图动辄上万像素直接喂给网络会显存溢出。常见做法是滑窗裁剪推理窗口大小建议 512重叠步长取 256。重叠区域不能简单丢弃而要把每个窗口的概率图累加最后除以累加次数这样接缝处会更平滑def slide_predict(model, big_img, window512, stride256, n_classes6): h, w big_img.shape[:2] prob_map np.zeros((h, w, n_classes), dtypenp.float32) weight_map np.zeros((h, w, 1), dtypenp.float32) for top in range(0, h - window 1, stride): for left in range(0, w - window 1, stride): patch big_img[top:topwindow, left:leftwindow] with torch.no_grad(): logits model(patch.unsqueeze(0)) prob torch.softmax(logits, dim1)[0].permute(1,2,0).cpu().numpy() prob_map[top:topwindow, left:leftwindow] prob weight_map[top:topwindow, left:leftwindow] 1 return np.argmax(prob_map / weight_map, axis-1)步长越小重叠越多精度越高但速度越慢步长等于窗口大小时没有重叠分块边界会出现明显割裂感。实际项目中我会先用window512, stride256跑一遍如果时间紧张再用stride384牺牲一点边界质量换速度。6.2 TTA翻转四次取平均白捡一到两个点TTA 是最划算的推理增强。对输入做水平翻转、垂直翻转和水平垂直翻转分别预测并协变回原方向最后对四个概率图取平均再用 argmax 出结果。代码量很小但 mIoU 通常能稳定提升 0.51.5 个点而且不会引入新的训练成本。def tta_predict(model, x, n_classes6): # x 是已经归一化的 tensor形状为 (C,H,W) x4 torch.stack([x, x.flip(2), x.flip(3), x.flip((2,3))], dim0) with torch.no_grad(): prob torch.softmax(model(x4), dim1) # (4,C,H,W) probs torch.stack([ prob[0], prob[1].flip(2), prob[2].flip(3), prob[3].flip((2,3)) ], dim0).mean(0) return torch.argmax(probs, dim0).cpu().numpy()从那以后我每次做分割项目都会强制留一个晚上跑一遍 TTA 和滑窗推理对比基线和增强后的差距。如果 TTA 的提升不到 0.5 个点说明模型本身已经比较稳定问题大概率出在数据标注质量上这时我会回去检查标签而不是继续调超参。这个检查顺序帮我省过不少冤枉时间希望帮到你。本文还有配套的精品资源点击获取