简介一套面向计算机相关专业学生的基于UNet遥感图像语义分割毕业设计项目源码与配套论文齐备适合用于毕业设计、课程设计、期末大作业等场景源码均经过本地编译验证可运行。压缩包共68个文件整体约46.94MB主要包含Python模型与训练预测代码、Jupyter Notebook交互式脚本、参考论文PDF、毕业论文LaTeX源码、SVG/PNG图表及启动脚本文件分类清晰可快速定位源码、论文与数据集制作模块。目前已有369人下载学习项目评审达98分难度适中能够满足多数本科毕业设计或课程项目的需求。内容上覆盖从遥感数据集创建、UNet模型定义到训练与预测的完整Pipeline配套Notebook可逐步复现运行同时提供郭子睿所撰遥感图像语义分割参考PDF和可二次编辑的论文LaTeX源码便于理解核心算法、撰写论文或更换自定义数据集做进一步实验。1. 毕业设计做遥感图像语义分割为什么 UNet 仍然是最常被选中的模型一张 20000×20000 像素的遥感大图直接扔进 UNet显存爆掉只是第一步更麻烦的是模型根本找不到地物边界。语义分割要做的是给影像里每一像素贴上类别标签建筑、道路、水体、林地、农田。基于 UNet 的遥感图像语义分割是 Python 方向毕业设计里比较常见的组合很多论文也围绕它做改进和消融实验。适合手里只有一套公开数据集、想在三个月内把源码、训练和论文串起来的人。下面按数据切片、模型训练、参数调整、避坑、论文闭环这条路线往下走新手能跟熟手能抄。2. 遥感影像切片与标注先把一张大图改成能喂给模型的瓦片遥感影像和自然图像最大的差异是尺寸。一张标准场景分类图可能是 512×512而一景遥感影像动辄上万像素宽直接读进显存不现实更关键的是地物尺度和目标密度差异太大。我一般不会把整幅影像送进网络而是先切成固定大小的瓦片再按瓦片训练。这样做有三个理由第一语义分割模型对输入尺寸有固定偏好统一尺寸方便 batch 训练第二一张大图里的地物分布极不均匀切片后可以做更细的样本筛选第三切片时保留一定重叠相当于给边缘目标增加了训练样本对道路、河流这类细长地物尤其明显。2.1 遥感影像为什么不能整图直接训练从显存角度看UNet 是编码-解码结构中间特征图的分辨率不低。512×512 输入下一张图的中间特征可能占 12 GB 显存换成 5000×5000 输入绝大多数显卡连第一层卷积都跑不完。从学习角度看遥感影像里的地物大小差异很大一栋房子可能只占几十个像素一段道路可能横跨整幅图。如果不切片模型需要同时兼顾全局和局部收敛速度会慢很多。从标注角度看很多公开数据集的标签是按图幅提供的训练之前必须把影像和标签做同样的裁剪否则影像和掩码的空间位置对不上模型会学到完全错误的内容。切片这事最容易被当成预处理跳过实际却是整个项目里性价比最高的一步。切片还要注意传感器差异。不同来源的遥感影像可能来自不同卫星或航空平台波段数、分辨率、投影方式都不一样。我常用的是红绿蓝三波段影像如果拿到的是多光谱数据先决定用哪几个波段不要把所有波段都堆进去。波段选择会直接影响模型输入通道数也直接决定后面的归一化参数。把这些统一以后再做切片后续代码就不会反复改。2.2 用 rasterio 把大图 TIF 切成训练瓦片遥感影像大多以 GeoTIFF 格式存储直接 OpenCV 读容易丢失坐标信息。常见做法是用 rasterio 处理它既能读影像也能在切片后保留新的仿射变换参数方便后续把预测结果拼回原图坐标。下面这段代码是我常用的切片逻辑import os import rasterio from rasterio.windows import Window def split_raster(src_path, out_dir, patch_size512, stride256): os.makedirs(out_dir, exist_okTrue) with rasterio.open(src_path) as src: width, height src.width, src.height index 0 for y in range(0, height - patch_size 1, stride): for x in range(0, width - patch_size 1, stride): win Window(x, y, patch_size, patch_size) data src.read(windowwin) meta src.meta.copy() meta.update({ height: patch_size, width: patch_size, transform: src.window_transform(win) }) out_path os.path.join(out_dir, fpatch_{index:05d}.tif) with rasterio.open(out_path, w, **meta) as dst: dst.write(data) index 1 # 补切右下角避免图像右边缘和下边缘被漏掉 for y, x in [(height - patch_size, width - patch_size)]: if y 0 or x 0: continue win Window(x, y, patch_size, patch_size) data src.read(windowwin) meta src.meta.copy() meta.update({ height: patch_size, width: patch_size, transform: src.window_transform(win) }) out_path os.path.join(out_dir, fpatch_edge_{index:05d}.tif) with rasterio.open(out_path, w, **meta) as dst: dst.write(data) index 1这段代码里patch_size 是瓦片边长我一般取 512兼顾上下文信息和显存占用。stride 是切片的移动步长当 stride 小于 patch_size 时相邻瓦片会有重叠。重叠区域让同一个地物在多个切片里以不同位置出现等效于一种数据增强。实际训练中重叠比例一般控制在 1/4 到 1/2 之间也就是 stride 取 256 或 384。如果 stride 等于 patch_size切片之间没有重叠训练样本会更干净但大图推理时容易出现接缝问题所以训练阶段保留一定重叠更省心。切完影像后标签掩码要用同样参数切一遍。最稳妥的做法是复用同一个函数把掩码路径传入只不过读取时使用最近邻重采样避免对类别编号做插值。千万不要为了省事把掩码缩小或裁剪到不同尺寸影像和标签必须逐像素对齐。2.3 标注掩码的读写规范别让 one-hot 毁在索引上UNet 语义分割的监督信号是像素级类别编号也就是一张单通道掩码每个像素的值是 0 到 N-1 的整数。遥感数据集的标注有时是 PNG有时是 TIF偶尔还带着调色板。这里常见的一个坑是直接用 PIL 的默认模式打开结果单通道掩码被强行转成三通道 RGB类别索引全部错乱。我处理掩码时一般会用 OpenCV 的 IMREAD_UNCHANGED先确认通道和类别再往下走import cv2 import numpy as np mask cv2.imread(labels/patch_00000.png, cv2.IMREAD_UNCHANGED) print(mask.shape, mask.dtype, np.unique(mask)) if len(mask.shape) 3: mask mask[:, :, 0] assert len(np.unique(mask)) 10, 类别数异常检查标注文件这段代码做的事很直接先打印掩码的形状、数据类型和类别集合。形状如果是 (H, W)说明是单通道可以正常送入损失函数如果是 (H, W, 3)说明标注被存成了 RGB 彩色图。很多标注工具在导出 PNG 时会把不同类别渲染成不同颜色而不是直接存储类别索引这时候就需要先做颜色到类别的映射不能简单取第一通道。还有一种情况是类别编号本身不连续比如背景是 0建筑是 5道路是 9。大部分交叉熵实现要求类别编号从 0 开始连续排列所以训练前要做重映射。我一般会维护一个字典{0: 0, 5: 1, 9: 2}把原始编号映射成 0、1、2。另外标注中常有模糊或未知区域公开数据集里常见做法是把这些像素设为 255训练时在损失函数里通过 ignore_index 跳过这样模型不会被迫在这些区域上过拟合。2.4 数据目录组织与训练/验证拆分代码写得再好目录乱了也容易白干。我见过不少项目把所有瓦片堆在一个文件夹里训练脚本里再用字符串拼接去找标签结果一个文件名后缀对不上就全部报错。这里整理一套比较省心的目录结构目录内容说明data/images/train训练原图瓦片所有瓦片统一为 .tif 或 .pngdata/images/val验证原图瓦片与训练数据同分布data/images/test测试原图瓦片可能是整幅大图或独立切片data/masks/train训练掩码和 images/train 一一对应data/masks/val验证掩码和 images/val 一一对应data/masks/test测试掩码有标签时用于评估无标签时只做可视化目录约定好后Dataset 代码只需要把 images 和 masks 两个目录按文件名排序然后一一配对。这样做最简单也最容易排查问题。训练和验证拆分不要直接按大图随机切因为同一张大图上相邻瓦片的内容高度相似如果一部分进训练集、一部分进验证集验证结果会虚高。常见做法是先给每张大图编号再按大图级别拆分或者干脆把同一张图的所有瓦片作为一个 group对 group 做随机划分。拆分比例我一般保持 8:1:1训练集占八成验证和测试各占一成。早期实验可以只留一个验证集把所有不参与训练的数据都放进去等模型定下来再补测试集。测试集的重要性在毕业论文里会被放大因为评阅老师通常会要求模型在没见过的图上有可量化的表现而不是只看训练曲线。3. UNet 训练工程模型定义、损失函数与参数配置数据准备好以后项目主体就轮到模型和训练了。UNet 在遥感语义分割里之所以普及是因为它的结构直观、训练曲线容易理解、改进空间也大。很多论文标题里写着“基于改进 UNet 的遥感图像语义分割”本质上都是在基础 UNet 上换主干、加注意力、换损失函数。先把最基础版本跑通后面做的改进才有对照意义。3.1 从网络结构图到代码UNet 的跳连接为什么适配遥感UNet 名字来自网络结构图里的 U 形走向。左侧是编码器不断下采样逐步提取高层次语义信息右侧是解码器不断上采样把低分辨率特征恢复到原图尺寸。关键在跳连接编码器每一层提取到的特征会直接拼接到解码器对应层的特征上。低层特征保留边缘、纹理、边界位置高层特征保留“这块区域是什么”的语义判断两者拼接后模型既能判断类别又能恢复精细边界。遥感影像里最难的往往不是大块地物而是道路、田埂、独立房屋这类细长或离散目标。跳连接相当于给解码器提供了一张“边缘草稿”帮助模型在输出阶段把边界修得更干净。这也是为什么在同样数据量下UNet 比 FCN 更容易在小目标上拿到更好的 IoU。理解了这一点后续做改进就知道该往哪使劲想提升细长目标可以加重跳连接特征的利用想提升大区域分类可以在编码器顶部加全局上下文模块。3.2 最小可运行的 UNet 模型定义PyTorch我习惯用 PyTorch 搭 UNet代码量少调试也直观。下面是去掉复杂装饰后仍然能跑通的最小结构import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_c, out_c): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_c, out_c, 3, padding1, biasFalse), nn.BatchNorm2d(out_c), nn.ReLU(inplaceTrue), nn.Conv2d(out_c, out_c, 3, padding1, biasFalse), nn.BatchNorm2d(out_c), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.conv(x) class Down(nn.Module): def __init__(self, in_c, out_c): super().__init__() self.mpconv nn.Sequential( nn.MaxPool2d(2), ConvBlock(in_c, out_c) ) def forward(self, x): return self.mpconv(x) class Up(nn.Module): def __init__(self, in_c, skip_c, out_c): super().__init__() self.up nn.ConvTranspose2d(in_c, in_c // 2, 2, stride2) self.conv ConvBlock(in_c // 2 skip_c, out_c) def forward(self, x1, x2): x1 self.up(x1) diff_y x2.size()[2] - x1.size()[2] diff_x x2.size()[3] - x1.size()[3] x1 nn.functional.pad( x1, [diff_x // 2, diff_x - diff_x // 2, diff_y // 2, diff_y - diff_y // 2] ) x torch.cat([x2, x1], dim1) return self.conv(x) class UNet(nn.Module): def __init__(self, n_channels3, n_classes5): super().__init__() self.inc ConvBlock(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(512, 512, 256) self.up2 Up(256, 256, 128) self.up3 Up(128, 128, 64) self.up4 Up(64, 64, 32) self.outc nn.Conv2d(32, 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)这段代码里的 n_channels 是输入影像的通道数三波段遥感图就是 3n_classes 是类别数如果你的数据集包含建筑、道路、水体、林地、背景五类这里就填 5。模型输出的张量形状是 (N, n_classes, H, W)N 是 batch sizeH 和 W 与输入一致最后用 softmax 得到每个像素的类别概率即可。Down 模块里的 MaxPool2d(2) 会让特征图尺寸逐层减半编码器最深处的特征图尺寸只有输入的 1/16。Up 模块用 ConvTranspose2d 做上采样再接跳连接。这里有一个常见误用直接对解码器上采样后的特征和编码器特征做加法而不是拼接。UNet 原版是拼接加法会让通道数对不上而且会丢失各自的独立性。保持拼接后面改进注意力模块时也更方便。3.3 损失函数单一交叉熵不够用遥感语义分割最典型的问题是类别不平衡。一张 512×512 的瓦片里背景可能占 90%道路只有不到 2%。如果用普通交叉熵模型只要把所有像素预测成背景loss 也能降得很低但道路、房屋这类目标几乎全部丢光。常见做法是把交叉熵和 Dice Loss 混合起来用Dice Loss 直接优化区域重叠对小目标更敏感。一个常用的组合权重是 0.4 倍交叉熵加 0.6 倍 Dice Lossimport torch import torch.nn as nn import torch.nn.functional as F def dice_loss(pred, target, eps1e-7): pred F.softmax(pred, dim1) target_one_hot F.one_hot(target, num_classespred.shape[1]) target_one_hot target_one_hot.permute(0, 3, 1, 2).float() dims (0, 2, 3) inter (pred * target_one_hot).sum(dims) union pred.sum(dims) target_one_hot.sum(dims) dice (2 * inter eps) / (union eps) return 1 - dice.mean() loss 0.4 * F.cross_entropy(logits, target) 0.6 * dice_loss(logits, target)这个 dice_loss 函数先对 logits 做 softmax再把 target 转成 one-hot 编码。dims 选 (0, 2, 3)表示在 batch、高、宽三个维度上求和相当于对每个类别独立计算 Dice 系数。eps 的作用是防止分母为 0。如果你在实验中发现某些类别一直不出现可以把 loss 改成对每类 Dice 单独取平均或者给少数类别更高的权重。还有一点容易被忽略如果掩码里存在 255 的 ignore 区域上面的 dice_loss 会把 255 也当成一个类别算进去结果就不对了。处理含 ignore 标签的数据时要么在数据加载阶段把 ignore 像素替换成背景类要么用支持 ignore_index 的交叉熵实现Dice 部分单独用有效像素 mask 过滤。不要为了省事把所有像素一股脑送进去。3.4 训练参数如何设置先跑通再调优训练 UNet 时最关键的几个参数分别是学习率、batch size、训练轮数和归一化方式。下面这张表是我在遥感语义分割项目里的默认起点先保证能跑通再根据验证 mIoU 做调整。参数建议范围说明optimizerAdam / SGDAdam 用起来省心SGD 调好后期指标更可预测learning rate1e-4 ~ 3e-4太高容易震荡太低训练太慢batch size4 ~ 16取决于显卡显存512×512 输入下推荐从 8 开始epochs50 ~ 100配合验证集早停不用死守固定轮数image size512×512上下文和显存的折中normalizeper-image 或数据集统计多传感器数据优先用 per-image训练时我一般看三类指标总 loss、验证 mIoU、每类 IoU。总 loss 只能说明模型在拟合训练集验证 mIoU 才能反映真实效果。每类 IoU 的作用是定位弱点如果道路 IoU 明显低于其他类说明模型对细长目标的拟合不够这时再去调损失权重和数据增强才有方向。如果只看平均 mIoU很可能被背景类的高分掩盖看不出模型实际不会分割道路。训练脚本里通常会暴露这样一组命令行参数python train.py \ --data data/ \ --model unet \ --epochs 60 \ --batch-size 8 \ --lr 1e-4 \ --img-size 512 \ --device cuda:0这个命令里的每条参数都能在模型训练代码里对应到一个配置项。我建议把设备选择、随机种子、模型保存路径也做成参数不要写死在代码里。毕业论文需要复现同一个实验换一台机器跑如果路径写死评阅老师可能重启就报错。用参数的方式暴露出来也能逼自己把数据加载逻辑写清楚。4. UNet 遥感语义分割避坑指南5 个最常见的翻车现场这部分我想直接写教训。遥感语义分割踩过的坑很多不是模型结构的问题而是数据读取、评估方式和预测拼接上的细节。每个问题我都会按“现象、原因、解决”的顺序展开。4.1 训练 Loss 在降但 mIoU 始终不涨现象训练前几个 epoch loss 从 1.2 降到 0.5看起来一切都正常但验证 mIoU 一直卡在 0.2 附近接近随机水平。原因语义分割的 loss 是全体像素的平均误差背景类占绝大多数时交叉熵只要把背景预测对loss 就会持续下降而道路、建筑这些小目标被完全忽略mIoU 是按类别平均的小目标没被识别分数自然上不去。解决训练时同时监控每类 IoU损失函数里加 Dice Loss对少数类做加权采样或者把背景裁剪比例控制住。还有一个很实用的做法是在 Dataset 里统计每个瓦片的类别比例如果某张图标签里前景像素太少直接降低它的采样权重让模型多看一些包含道路和房屋的区域。4.2 掩码读取被 PIL 转成 RGB类别索引全乱了现象训练流程看起来没问题但预测出的分割图颜色完全随机或者所有像素都变成某一类。原因掩码是单通道调色板 PNGPIL 默认打开后会转成三通道 RGB类别编号 0、1、2 被解释成三个颜色通道one-hot 编码全部错位。解决用 OpenCV 的 IMREAD_UNCHANGED 读掩码读完后立刻打印 shape 和 np.unique 验证类别集合。如果已经转成 RGB必须先做颜色到类别索引的映射。我遇到过最隐蔽的情况是某个类别的 RGB 颜色是 (0, 0, 0)和 255 的 ignore 区域撞在一起导致背景信息吞噬了未知区域。所以读掩码后做一次类别频次统计非常必要这个检查最好放在数据加载类的__getitem__里每次迭代都验证一下宁可慢一点也不要迷迷糊糊地训练。4.3 归一化统计全图结果不同影像之间偏色明显现象模型在训练集上 mIoU 很高换一张测试图就明显变差而且预测图上有一层淡淡的色斑。原因很多代码直接用整幅影像所有像素计算 mean 和 std遥感影像的边缘经常有黑色无数据区、云、噪声这些像素会把统计值拉偏。解决归一化的统计量只从有效像素里计算。如果用的是 uint8 影像可以先用mask data ! 0或data 0过滤无效区域再分别算 mean 和 std。对于来自不同传感器、不同成像时间的影像单纯用一套全局统计值往往不够我习惯在训练时对每个瓦片单独做 min-max 归一化到 0 到 1推理时用同样处理这样模型对新影像的适应性更强。注意这里的 per-image 归一化不是数据增强而是对传感器差异的补偿参数要写进论文实验部分否则别人复现时会对不上。4.4 验证集指标虚高训练时开着随机增强没有关现象训练集 mIoU 只有 0.7验证集居然比训练集还高或者某一天实验突然掉了 5 个点。原因验证流程里没有关闭随机翻转、随机缩放等增强操作。随机增强在训练时有正向作用但验证时它会改变输入像素的位置导致模型输出和标签的空间位置不再严格对齐验证分数自然失真。解决验证和测试阶段必须把数据增强关闭只保留 resize 和归一化。这里要注意测时增强 TTA 是另一回事它是在推理时固定做几次翻转或缩放然后对输出做平均是有意引入的推理策略不是默认开启的随机过程。如果你论文里使用了 TTA要在实验表里单独列出来而不是把它混在默认验证流程里。4.5 图像拼接预测时接缝明显肉眼可见一条条边界线现象把整幅大图切块预测后再拼回去道路和建筑在瓦片边界处明显断裂边界线呈规则的方块状。原因模型在预测每个瓦片时只能看到局部上下文瓦片边缘像素缺乏周围信息预测结果不够稳定。重叠推理可以缓解这个问题但简单拼接会把重叠区域后写的预测覆盖掉。解决推理时采用重叠滑窗对所有重叠区域的 softmax 概率做平均再取 argmax。下面是一个典型的累计概率拼接结构pred_acc np.zeros((H, W, n_classes), dtypenp.float32) weight np.zeros((H, W, 1), dtypenp.float32) for y in range(0, H - patch_size 1, stride): for x in range(0, W - patch_size 1, stride): patch img[y:y patch_size, x:x patch_size] prob model(patch) # softmax 后 pred_acc[y:y patch_size, x:x patch_size] prob weight[y:y patch_size, x:x patch_size] 1 pred pred_acc / np.maximum(weight, 1) pred_label pred.argmax(axis-1)这里的 n_classes 是类别数patch_size 和推理时的滑窗步长要保持一致。stride 建议取 patch_size 的一半比如 patch_size 为 512 时stride 取 256这样每个像素至少会被预测两次。权重图还可以进一步做高斯加权让越靠近瓦片中心的像素权重越高边缘权重越低拼接结果会更自然。我在实际项目里会先跑一版不重叠的快速推理确认模型整体没问题再切到重叠推理出最终大图。5. 从源码到论文预测拼接、消融实验和报告写作的收尾技巧答辩前最容易被卡住的地方往往不是模型指标而是“论文里的实验能不能被复现”。我自己的习惯是在调参之前就先写好三个独立脚本train.py 负责训练和保存模型predict.py 负责把大图切成瓦片、推理、拼接回整幅图evaluate.py 负责在同一份验证集上计算各类别 IoU 和 mIoU。这样每次改模型或损失函数只要重新跑一遍三个脚本论文里所有数字都能对得上。论文实验部分建议至少放三张表。第一张是数据集类别分布表列出每类像素占比用来解释为什么背景类精度高而少数类精度低。第二张是消融实验表至少包含基础 UNet、UNet Dice Loss、UNet 数据增强、UNet 重叠推理四行每一行都给出 mIoU 和每类 IoU。第三张是难例分析把验证集里错分最严重的瓦片可视化出来对应说明模型在哪些场景失效。这三张表做齐论文的论述就比较完整了。最后说一个容易被忽略的细节代码交付时所有路径不要写死成自己电脑里的绝对路径尽量用脚本所在目录的相对路径训练时固定随机种子并在 README 里记录 Python 依赖、训练参数和数据集来源。我第一次做这类项目时把大量时间花在调模型上最后一周才发现预测拼接脚本没有跑通论文里的结果图和实际代码对不上连续加班才补救回来。后来的习惯是先跑通整条预测链路再回头调模型细节顺序反过来的话越到后期越被动。这套流程虽然朴素对毕业设计来说却足够实用希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?