简介面向毕业设计场景的基于UNet的遥感图像语义分割完整实现适合计算机视觉方向的本科生及研究生参考。资源内含69个文件包含Python源码与pyc编译文件、Jupyter Notebook演示脚本、PNG示例图、LaTeX论文源文件以及PDF文档包体约47.26MB涵盖数据准备、模型构建、训练评估与推理预测的完整流程。已有346人学习下载。读者可获得可直接运行的UNet分割工程包括create_dataset、train、predict等Notebook以及模型定义、数据加载、训练代码和实验结果图同时附有中文学位论文LaTeX源码与成品PDF便于对照撰写毕业设计文档。整体目录结构清晰兼具代码实践与论文写作参考价值。1. 拿到遥感影像却分不出地物UNet语义分割为什么是毕设和工程的首选一张 0.5 米分辨率的遥感影像尺寸动辄两三万像素见方里面有耕地、建筑、水体、道路你要的不是“检测出几个目标框”而是把每一个像素都贴上一个类别标签。这就是语义分割而 UNet 这种编码器-解码器结构几乎是做这件事最稳妥的起点。不管你是为了完成毕业设计还是想快速验证一个“遥感地物识别”的想法UNet 都是先跑通流程、再谈改进的最短路径。它胜在两点一是结构简单训练收敛快个人电脑也能跑二是跳跃连接让边界恢复得比普通全卷积网络好最适合遥感里“道路窄、房屋边角锐利”这类细节。后面我会把从数据准备、网络复现到训练避坑的完整流程讲一遍代码能直接抄参数能直接套最后告诉你训练完到底看哪些指标才算数。2. 遥感图像和普通照片分割的三个差异尺寸、波段和标注方式2.1 影像尺寸整图输入不可能切片是躲不开的第一步自然图像分割比如 Cityscapes 数据集图片通常是一两百万像素缩放到 512×512 或 1024×1024 再进网络内存开销完全可控。遥感影像不是这样一景高分影像动辄 2 万×2 万像素单通道就是 4 亿个像素点直接缩放到 512×512 等于把一栋楼缩成一个点地物细节全没了。所以业内常规做法是滑窗切片把大影像切成 512×512 或 256×256 的小块再送进网络。切片有几个关键参数要提前定下来。patch size 建议 512×512太小了感受野不够建筑和道路这种中等尺度地物容易判错太大了显存扛不住overlap重叠率建议 0.25也就是滑动步长为 384 像素这样做预测时边缘不会出现明显的拼接缝。训练时可以用小一点的 overlap甚至不重叠但预测时必须有重叠原因是图像边缘的像素上下文信息不完整预测置信度低重叠后取平均能明显改善边界处的碎斑。2.2 波段数量和标注价值RGB 之外还有四波段甚至更多自然照片是三个波段RGB遥感影像常见的是四波段R、G、B、近红外。对植被来说近红外波段极其重要归一化植被指数NDVI就是靠它算出来的水体在近红外波段也几乎全吸收和陆地区别更大。如果你的数据源给了四波段不要只取 RGB 丢了近红外把四个波段都用上通道数从 3 变成 4UNet 第一层卷积输入通道改一下就行其他结构完全不用动。如果你手上的数据是 16 位深度的 TIFF单波段 0~65535而预训练权重是按 8 位0~255设计的直接喂进去会出现“均值偏移”的问题。我一般会先按百分比截断拉伸比如把 2% 和 98% 分位数的像素值映射到 0 和 255而不是全图最小最大值拉伸否则影像里个别高亮的云或人造目标会把整个直方图拉垮暗部地物全部挤在一起。2.3 标注文件别指望下载即用多数标注是 GeoJSON 矢量做语义分割你需要的是和影像一一对应的逐像素类别标签图。公开数据集还好模型训练标签通常是 PNG但如果你用的是自己拿 QGIS 标的数据或者某些数据商交付的标注大概率是 GeoJSON 或 Shapefile 矢量面需要栅格化才能变成 UNet 能吃的标签图。用 GDAL 做矢量转栅格是常见方案核心逻辑是遍历每一个地物面把它的类别属性值写到对应像素位置。类别 ID 从 0 开始0 留作背景或“未分类”类别从 1 开始编号。这个规则要和后面计算损失时的类别权重对齐不然训练时模型会学出一个奇怪的偏置。3. 用 PyTorch 从零搭一个 UNet结构拆解与核心代码3.1 DoubleConv 与编码器下采样为什么用 max pooling 而不是 stride 卷积UNet 编码器的基本单元是“两次卷积 ReLU”俗称 DoubleConv。我在实际代码里习惯给每一层加 BatchNorm遥感影像不同切片的亮度差异很大云雾遮挡、太阳高度角变化BatchNorm 能让中间特征分布在训练中保持稳定训练收敛速度明显更快这也是新手最容易忽略的地方。不加热身的话batch size 取 8 都容易训练不动。下采样我保留经典设计的 max pooling而不是换成 stride2 卷积。max pooling 不引入额外参数而且对纹理的微小位移更鲁棒。编码器每下采样一次特征图尺寸减半、通道数翻倍从 64 一路涨到 512。在显存不足时优先砍第一层的通道数比如从 64 降到 32而不是减小输入 patch 到 256因为 patch size 太小会牺牲上下文。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)这段代码的要点两次卷积都保持尺寸不变padding1通道数由 in_ch 变成 out_ch。BatchNorm 在激活之前epoch 数不多时效果比“卷积-ReLU-dropout”稳定得多遥感数据 BN 的均值统计在推理时要用训练集的滑动平均所以不要在推理前临时改训练模式。3.2 解码器与跳跃连接为什么说“边缘恢复就靠这一跳”解码器每层先做一次转置卷积把特征图放大一倍然后和编码器对应层做 channel 维度的拼接。编码器浅层特征保留了空间细节边缘、纹理深层特征更偏语义这是建筑、这是水体拼接后解码器同时获得“是什么”和“在哪里”两个信息。我见过很多人在复现时抄错一个关键点编码器的输出要保存下来供解码器拼接用而不是简单地把最终编码特征一路送到底。UNet 之所以在遥感分割上比 FCN 效果好靠的就是这些跨层连接把它们去掉小目标车辆、独立树木的预测结果会明显残缺。class UNet(nn.Module): def __init__(self, in_ch3, num_classes2): super().__init__() self.enc1 DoubleConv(in_ch, 64) self.enc2 DoubleConv(64, 128) self.enc3 DoubleConv(128, 256) self.enc4 DoubleConv(256, 512) self.pool nn.MaxPool2d(2) self.bottleneck DoubleConv(512, 1024) self.up4 nn.ConvTranspose2d(1024, 512, 2, stride2) self.dec4 DoubleConv(1024, 512) self.up3 nn.ConvTranspose2d(512, 256, 2, stride2) self.dec3 DoubleConv(512, 256) self.up2 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec2 DoubleConv(256, 128) self.up1 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec1 DoubleConv(128, 64) self.out nn.Conv2d(64, num_classes, 1) 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)) d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)几个参数说明in_ch 是输入通道数RGB 就是 3四波段就是 4多光谱可以改成 8 甚至更多。num_classes 是类别总数包含背景。比如你要分“耕地、建筑、水体”三类再加上背景这里写 4。转置卷积 kernel 用 2、stride 用 2输出尺寸正好翻倍不会出现奇奇怪怪的尺寸对不齐。最后一层是 1×1 卷积把 64 通道压缩到类别数后面接 CrossEntropyLoss 时不需要手动 softmaxPyTorch 的损失函数内部已经包含了 log_softmax。3.3 损失函数为什么交叉熵不够DiceLoss 才是遥感的好搭档遥感语义分割的类别分布极不均匀。一景影像里可能 70% 是农田10% 是水体3% 是道路道路只有几个像素宽。如果直接用交叉熵模型会倾向于把所有像素预测成农田因为这样它的准确率也能到 0.7。这就是为什么光看 accuracy 会被严重误导。我一般把 CrossEntropyLoss 和 DiceLoss 按 1:1 加权。DiceLoss 对小类别的惩罚更重它能直接优化“预测区域和真实区域的重合度”即使某一类的像素只占全图的 3%它在损失里也能占到相当的比重。class DiceLoss(nn.Module): def __init__(self, smooth1.0): super().__init__() self.smooth smooth def forward(self, logits, targets): probs torch.softmax(logits, dim1) num targets.shape[0] # batch size # one-hot 编码 targets_onehot torch.eye(logits.shape[1])[targets].permute(0, 3, 1, 2).to(logits.device) # 按 batch 和类别维度计算 dice intersection (probs * targets_onehot).sum(dim(2, 3)) union probs.sum(dim(2, 3)) targets_onehot.sum(dim(2, 3)) dice (2.0 * intersection self.smooth) / (union self.smooth) return 1 - dice.mean()注意 targets 的 shape 是 (B, H, W)里面每个值是类别索引 0、1、2…logits 的 shape 是 (B, C, H, W)。先对 targets 做 one-hot才能和概率图逐像素相乘。smooth 设为 1.0 是为了防止分母为零同时对类别极不平衡的情况有一定稳定作用。训练时 total_loss 0.5 * ce_loss 0.5 * dice_loss两个损失的数值量级要观察一下DiceLoss 的初始值通常在 0.3 到 0.7 之间交叉熵初始值可能偏大如果出现 CE 压过 Dice 的情况把权重改为 0.4 / 0.6 再试。4. 用自己的遥感数据集跑通 UNet切片、标注对齐与训练参数4.1 数据准备把大影像切成 patch 时最容易犯的错很多同学的毕设数据是自己标注的或者在公开数据集上下载的“原图 标签图”组合。无论来源如何第一步都是切片。我之前写过一版切片脚本坑很多这里直接给你一套稳定逻辑先读取大图和标签图然后按照 patch size 和 stride 遍历切出来的小图和标签图都存入数组再统一保存。注意两点影像和标签图的坐标系必须一致很多公开数据集的标签图是从矢量转的像素尺寸和原图不完全对齐切片后必须检查有没有“全背景”的 patch这类样本要滤掉否则训练时模型始终在看空白。可以用滑动窗口遍历同时在窗口内对标签图做一次判断如果某个 patch 的标签图里前景像素占比低于 5%直接丢掉。这样能显著提升训练效率避免大量无效计算。import numpy as np from PIL import Image def crop_patches(image, label, patch_size512, stride384): h, w image.shape[:2] patches, labels [], [] for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): img_patch image[y:ypatch_size, x:xpatch_size] lbl_patch label[y:ypatch_size, x:xpatch_size] # 过滤背景占比过大的 patch if (lbl_patch 0).sum() / (patch_size * patch_size) 0.95: continue patches.append(img_patch) labels.append(lbl_patch) return np.stack(patches), np.stack(labels)这个实现是最直白的双层循环慢但对小数据集够用。stride384 意味着相邻 patch 有 128 像素重叠训练数据量增大了约 30%但模型在整图拼接预测时边界更稳。如果你用 stride512那就完全没有重叠数据量小训练速度快但预测时的拼接缝要靠后处理去补我建议训练时用 512、预测时用 384。4.2 数据增强专注几何变换少做颜色抖动遥感图像的语义分割几何增强几乎是必需品。随机水平翻转、垂直翻转、旋转 90 度这几个操作不改变类别语义实现成本极低。我有一个习惯旋转 90 度的倍数而不是任意角度因为任意角度旋转会引入插值误差标签图是离散的类别 ID插值会把“1”变成“0.7”这种奇异值标签图就毁了。颜色增强要克制。适度做亮度扰动、对比度扰动可以模拟不同季节、不同太阳高度的成像差异但不要做随机擦除或大幅度高斯噪声遥感影像中地物边缘的噪点和自然照片不一样随机擦除很容易把完整的建筑屋顶或者道路中间挖掉一块模型会学到“不连续的地物”这和你想要的效果正好相反。4.3 训练参数batch size、学习率、epoch 怎么定我以 512×512 patch、单卡 12GB 显存为基准给出一个可以直接上手的配置表。如果你的显存是 6GB把 batch size 降到 2如果是 24GB试着把 batch size 提到 8并适当增大学习率收敛速度会明显提升。参数建议值说明patch size512×512太大显存溢出太小物体破碎batch size412GB 显存下的稳妥值初始学习率1e-3Adam 优化器配合学习率调度ReduceLROnPlateau验证 loss 连续 5 个 epoch 不降则乘 0.5epoch 数60~100遥感数据量小100 轮一般足够optimizerAdamW相比 Adamweight decay 更规范我见过很多人卡在“loss 一直降但验证 mIoU 不动”的怪圈问题往往出在优化器和学习率。遥感数据集不像 ImageNet 那么大初始学习率用 1e-3 是上限超过这个值第一轮 loss 就可能冲到 NaN。如果用的是 SGD学习率要降到 1e-2 附近但收敛明显更慢建议新手直接用 Adam把 betas 设为默认值就好。训练过程中每轮要记录两件事训练集的 loss 和验证集的 mIoU。如果训练 loss 在降、验证 mIoU 也在升说明一切正常如果训练 loss 在降、验证 mIoU 停滞说明过拟合去增强数据或者降低模型容量把第一层通道从 64 减到 48如果训练 loss 不降大概率是学习率设置不对或者数据标签有错位。5. 避坑UNet 跑遥感数据时最常见的 5 个问题与排查5.1 模型输出全黑或全背景类别映射混乱现象训练完预测输出图里几乎全是背景类前景地物只偶尔出现几个亮点。原因最常见的是类别 ID 设置错误比如你把标签值标成了 1、2、3但模型输出通道是 3不含背景背景类 0 又没有像素参与监督还有可能是标签图里 255 表示目标、0 表示背景你直接把 255 当成一个类别喂给了网络相当于所有目标都被当成同样的“第 255 类”网络根本学不出区别。解决在切片之前先统计标签图里都有哪些像素值print(np.unique(label))一定要确认类别集合和损失函数里的类别数完全对齐。用一个统一的预处理函数把 255 重映射到 1把 0 保持为 0再检查一下切片后的标签 patch 里类别分布和整图一致。5.2 训练 loss 正常但验证精度极低训练集和验证集来自同一景影像的不同区域?现象训练集上 mIoU 已经到 0.75 了验证集上只有 0.3差距巨大。原因遥感影像的空间自相关性极强。如果你用同一景影像随机切 patch 后按 8:2 分成训练集和验证集模型其实是在用“同一块地面”的上下文做记忆验证集里很多 patch 和训练集距离非常近甚至重叠。等拿到一景全新的影像上测试性能立刻崩。解决验证集和训练集必须按“影像”划分而不是按 patch 划分。同一景影像的 patch 要么全部进训练集要么全部进验证集。最好使用不同日期、不同区域的数据做验证。交叉验证时也要按影像分 fold不要按 patch 分。5.3 预测图有密集的椒盐噪声和小碎斑缺少后处理现象预测结果里单像素的类别点特别多大块连续地物被很多杂点污染像是被撒了盐。原因逐像素分类本质上对每个像素独立决策没有相邻像素的约束。UNet 虽然通过卷积隐式利用了局部上下文但它不会像条件随机场那样强制空间连续性因此小碎斑在遥感里常见。解决快速方法是对概率图做高斯模糊再重新取 argmax更有效的是用形态学开闭运算来过滤碎斑。我通常的做法是先对预测图做 mode filter用 3×3 窗口取众数再对少数类别道路、水体做闭运算连接断开的部分。不要把后处理做得太重否则真实的小地物独立树木、小汽车也会被抹掉。5.4 显存不够batch size 降到 1 还是溢出现象一张 512×512 的 patchbatch size 设为 1显存依然溢出。原因UNet 编码器第一层 64 通道、后续逐层翻倍到 1024对显存的需求不低但更常见的原因是反卷积层输出的中间特征图全部存在显存里用于反向传播没有开启混合精度。解决加一行 torch.cuda.amp.autocast() 包住 forward 和 loss用 GradScaler 做梯度缩放显存占用直接下降约三分之一。另外检查一下是不是输入图片被读成了 float64PyTorch 默认计算图用 float32但如果你把图像转成 double显存直接翻倍这种情况常发生在把 16 位 TIFF 读进来没转 float32。5.5 训练集和验证集的类别分布差异大mIoU 虚高现象验证集的 mIoU 看起来很高但逐类看小类别的 IoU 只有 0.1 左右大类却有 0.9。原因mIoU 是各类别 IoU 的算术平均但如果验证集切片恰好切到了大面积的水体或农田区域小类别在验证集里根本没出现几次IoU 计算时的分母样本太少波动极大。解决报告指标时至少要给出两个东西每类 IoU不平均和加权 mIoU按各类像素占比加权。如果某一类在验证集里的像素总数不足 1000这个 IoU 数值没有参考意义单独标出来别混进均值里。真正交付时用一景完整影像做全图预测再和人工标注算 IoU这才是最终指标。6. 训练完怎么判断模型能不能用从混淆矩阵到交付一张带彩色标签的图最后一关是精度评估和结果输出。mIoU 是一个粗糙的整体数但你最好把混淆矩阵打印出来看一眼道路被误判成什么水体主要混到哪一类如果道路大量被误判成阴影但阴影不是你的目标类别说明模型没有见过足够的道路阴影样本需要补充标注而不是调网络结构。另一个容易漏的环节是类别 ID 到颜色的映射。训练时标签用的是 0、1、2可视化时如果没有一份固定的 colormap两张预测图的对比就没有意义。我会定义一个字典0黑色背景、1绿色植被、2红色建筑、3蓝色水体然后输出成 PNG。同时输出一张半透明叠加在原始影像上的效果图透明度 0.4方便肉眼快速检查边界对得准不准。import numpy as np from PIL import Image def save_prediction(prob_map, save_path, color_map): # prob_map: (H, W, C) 每个像素是各类别概率 pred np.argmax(prob_map, axis-1) # (H, W) rgb np.zeros((pred.shape[0], pred.shape[1], 3), dtypenp.uint8) for cls_id, color in color_map.items(): mask pred cls_id rgb[mask] color Image.fromarray(rgb).save(save_path)这不仅仅是出图好看它帮你快速定位模型的系统性问题。如果建筑和道路在图上粘连不清你就知道下采样的层数太深了小物体细节在 bottleneck 里被压缩得太狠可以试试把 UNet 的 depth 从 4 降到 3如果是远处的细小道路断断续续说明 patch size 偏小或者训练数据里这类样本太少去增加“含道路”的 patch 数量比调网络更有效。我自己的习惯是保存四个文件预测标签 PNG、彩色可视化 PNG、半透明叠加图、以及每个类别的 IoU 数值表。这套东西齐全了无论你是交给导师还是部署到一个小工具里别人拿到都能秒懂结果好坏。我的血泪经验是毕设评审看重的不是那个 mIoU 数字有多高而是你能不能解释清楚每一类误差从哪里来以及模型在整景影像上的泛化表现。如果你能从混淆矩阵里指出一条可操作的改进路线这个项目基本就站稳了。希望帮到你。本文还有配套的精品资源点击获取
