简介:这份资源面向计算机相关专业的本科生与课程设计学习者,提供一套基于UNet的遥感图像语义分割完整毕业设计项目,涵盖从数据制作到模型训练、预测与论文撰写的全流程,适合作为毕业设计、期末大作业或课程设计参考,难度适中。压缩包共68个文件,约46.94MB,包含6个Python源码文件与3个Jupyter Notebook,覆盖数据生成、模型定义、训练与预测脚本;另有32张png与6个svg用于结果图和网络结构展示,5个tex及bib、pdf构成毕业论文正文与参考文献,并附字体、说明文档等辅助材料。目前已有369人学习下载,说明该方案在同类选题中具有一定参考价值。读者可据此获得可本地运行的UNet分割代码、遥感数据集制作流程、训练与推理示例,以及配套论文的章节结构与图表组织方式,便于快速搭建自己的实验框架并完成写作。
1. 从一张遥感图到毕业设计:UNet 语义分割到底在做什么
遥感图像语义分割这件事,说白了就是给卫星或无人机拍下来的地表图像做逐像素分类:这张图里哪些像素是建筑、哪些是道路、哪些是水体、哪些是植被。和普通图像分类只输出一个标签不同,语义分割要求每个像素都有归属,输出是一张和原图同尺寸的掩膜图。毕业设计里选这个方向,好处是任务定义清晰、数据集公开、评价指标成熟,坏处是它对显存、标注质量和训练技巧都有实打实的要求,不是跑通一个 demo 就能交差的。
UNet 之所以成为这个任务的常客,是因为它的结构天然适合处理遥感图像的两个痛点:一是目标尺度差异大,既有大片农田也有细窄乡道;二是边界要求高,建筑轮廓糊了指标就掉。编码器逐层下采样提取语义,解码器逐层上采样恢复分辨率,中间的跳跃连接把浅层的高频细节直接送到对应层级,这套设计在遥感场景里非常吃香。这篇笔记面向的是正在做或准备做「Python 毕业设计基于 UNet 的遥感图像语义分割」的人,我会把数据准备、模型搭建、训练调参、指标验证和论文里该写什么一条线讲清楚,让你能照着复现,也能看懂自己每一步在干什么。
2. 遥感数据集的准备与预处理:从原始影像到可训练张量
2.1 为什么遥感数据不能直接喂给 UNet
遥感图像和自然图像最大的区别在于通道和动态范围。常见的高分影像可能是 RGB 三通道,也可能是包含近红外的四通道甚至多光谱的八通道以上;像素值往往是 16 位无符号整数,直接归一化到 0 到 1 会丢失大量对比度信息。另一个坑是图像尺寸,遥感影像动辄几千乘几千像素,而 UNet 的下采样层数决定了输入尺寸最好是 2 的整数次幂的倍数,常见做法是裁成 256×256 或 512×512 的图块。
我一般会先做两件事:统计全数据集的像素值分布,用百分位裁剪把极端值压掉,再做归一化;然后按固定步长滑窗裁块,裁的时候保证相邻块之间有重叠,避免把目标切断。标注掩膜要和影像严格对齐,遥感数据里最常见的错误就是影像和标签错位一个像素,训练时 loss 会一直震荡下不去。
2.2 用 Python 做滑窗裁块与数据集划分
下面这段代码是我常用的裁块脚本,输入是原始影像和对应掩膜,输出是规整的图块和一份划分清单。
import os import numpy as np import cv2 from sklearn.model_selection import train_test_split def sliding_crop(img, mask, patch=256, stride=128): """滑窗裁块,stride 小于 patch 保证重叠""" h, w = img.shape[:2] patches = [] for y in range(0, h - patch + 1, stride): for x in range(0, w - patch + 1, stride): img_p = img[y:y+patch, x:x+patch] mask_p = mask[y:y+patch, x:x+patch] # 过滤掉全背景块,减少类别不平衡 if mask_p.sum() == 0: continue patches.append((img_p, mask_p)) return patches def normalize_img(img): """按 2% 和 98% 分位裁剪后归一化,保留对比度""" lo, hi = np.percentile(img, (2, 98)) img = np.clip(img, lo, hi) img = (img - lo) / (hi - lo + 1e-6) return img.astype(np.float32) img_dir, mask_dir = "raw/images", "raw/masks" all_patches = [] for name in os.listdir(img_dir): img = cv2.imread(os.path.join(img_dir, name), cv2.IMREAD_UNCHANGED) mask = cv2.imread(os.path.join(mask_dir, name), cv2.IMREAD_GRAYSCALE) img = normalize_img(img) all_patches.extend(sliding_crop(img, mask)) train_p, val_p = train_test_split(all_patches, test_size=0.2, random_state=42) print(f"训练块 {len(train_p)},验证块 {len(val_p)}")这段代码里patch控制图块大小,stride控制重叠程度,步长越小样本越多但冗余也越大,我一般取 patch 的一半。normalize_img里的百分位裁剪是关键,遥感影像常有云层或异常高亮,不做裁剪归一化后大部分像素会挤在很小的区间里。mask_p.sum() == 0这行过滤全背景块,遥感数据里背景占比往往超过七成,不过滤的话模型会倾向于全预测背景。
2.3 类别不平衡与数据增强的取舍
遥感分割的类别极不平衡,道路、建筑这类目标可能只占几个百分点。常见做法是给损失函数加权,或者用带权重的交叉熵。数据增强方面,水平翻转、垂直翻转、90 度旋转对遥感图像是安全的,因为地物没有固定的上下方向;但颜色抖动要慎用,遥感影像的波段反射率是有物理意义的,乱调颜色会让模型学到错误的纹理关联。我一般只做几何增强,最多加一点轻微的高斯噪声。
提示:划分数据集时按整幅大图划分,不要按裁好的图块随机划分,否则同一幅图的相邻块会同时出现在训练集和验证集里,验证指标虚高,答辩时被问到就尴尬了。
3. UNet 模型搭建:编码器、解码器与跳跃连接的工程实现
3.1 为什么选 UNet 而不是 DeepLab 或 SegFormer
毕业设计选模型,要平衡效果、显存和可解释性。DeepLab 系列依赖空洞卷积和 ASPP 模块,结构复杂,调参空间大;SegFormer 基于 Transformer,效果好但训练需要更多数据和算力,本科阶段的显卡不一定扛得住。UNet 结构对称、代码量小、每一层的作用都能讲清楚,论文里画结构图和写原理都方便。更重要的是,UNet 的跳跃连接在遥感场景里对边界恢复帮助明显,这是有实验支撑的。
当然 UNet 也有短板:下采样会丢失小目标信息,对细窄道路和独立小建筑不友好。改进方向通常是加注意力模块、换更强的骨干网络、或者在跳跃连接上加门控。这些改进点正好是论文里可以展开写的地方。
3.2 用 PyTorch 实现一个可训练的 UNet
下面是我常用的 UNet 实现,双卷积块加最大池化下采样,转置卷积上采样,跳跃连接用通道拼接。
import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.net = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.net(x) class UNet(nn.Module): def __init__(self, in_ch=3, num_classes=5, base=64): super().__init__() # 编码器 self.enc1 = DoubleConv(in_ch, base) self.enc2 = DoubleConv(base, base*2) self.enc3 = DoubleConv(base*2, base*4) self.enc4 = DoubleConv(base*4, base*8) self.pool = nn.MaxPool2d(2) # 瓶颈 self.bottleneck = DoubleConv(base*8, base*16) # 解码器 self.up4 = nn.ConvTranspose2d(base*16, base*8, 2, stride=2) self.dec4 = DoubleConv(base*16, base*8) self.up3 = nn.ConvTranspose2d(base*8, base*4, 2, stride=2) self.dec3 = DoubleConv(base*8, base*4) self.up2 = nn.ConvTranspose2d(base*4, base*2, 2, stride=2) self.dec2 = DoubleConv(base*4, base*2) self.up1 = nn.ConvTranspose2d(base*2, base, 2, stride=2) self.dec1 = DoubleConv(base*2, base) self.out = nn.Conv2d(base, 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], dim=1)) d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out(d1)base控制第一层通道数,显存不够就降到 32,效果会掉一点但能跑起来。num_classes要和你标注的类别数一致,背景也算一类。torch.cat那几行就是跳跃连接,把解码器上采样的结果和编码器同层级的特征在通道维拼接。转置卷积会有棋盘格伪影,介意的话可以换成双线性插值加卷积的组合。
3.3 损失函数与评价指标的选型
遥感分割常用交叉熵加 Dice 损失的组合。交叉熵管像素级分类,Dice 管区域重叠度,两者互补。如果类别极不平衡,可以给交叉熵加权重,权重取类别频率的倒数。评价指标方面,mIoU 是标配,但遥感场景里我还建议看 F1 和召回率,因为漏检建筑和误检建筑的代价不一样,论文里把这两个指标都列出来会显得更专业。
def dice_loss(pred, target, eps=1e-6): """pred 是 softmax 后的概率,target 是 one-hot""" inter = (pred * target).sum(dim=(2, 3)) union = pred.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) return 1 - ((2 * inter + eps) / (union + eps)).mean() # 组合损失 ce = nn.CrossEntropyLoss(weight=class_weights) loss = ce(logits, mask) + dice_loss(torch.softmax(logits, 1), onehot_mask)class_weights按训练集里每个类别的像素占比算,占比越低权重越高。Dice 损失对空掩膜要加eps防止除零,这个细节不注意训练中途会出 NaN。
4. 训练、调参与验证:让 UNet 在遥感数据上真正收敛
4.1 训练循环里必须盯住的几个量
训练脚本本身不复杂,难的是判断模型有没有在正常学习。我一般会同时记录训练 loss、验证 loss、验证 mIoU 和学习率。如果训练 loss 下降但验证 loss 上升,说明过拟合,要加增强或加 dropout;如果两个都不降,先检查学习率是不是太大,或者数据归一化有没有做对。遥感数据里还有一个隐蔽问题:如果某类样本极少,模型可能直接放弃这一类,表现为 mIoU 卡在某个值不动,这时候要回头看类别权重和采样策略。
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) for epoch in range(50): model.train() for img, mask in train_loader: img, mask = img.cuda(), mask.cuda() logits = model(img) loss = ce(logits, mask) + dice_loss(torch.softmax(logits, 1), onehot(mask)) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 每个 epoch 后在验证集上算 mIoU miou = evaluate(model, val_loader, num_classes) print(f"epoch {epoch}, val mIoU {miou:.4f}")AdamW 的weight_decay设 1e-4 是比较稳的起点,余弦退火让学习率平滑下降,比固定学习率更容易收敛到好的解。T_max设成总 epoch 数。验证集评估时记得model.eval()并关掉梯度,否则显存会爆。
4.2 学习率、批大小与显存的三角关系
这三个参数互相牵制。批大小受显存限制,256×256 的图块、base=64 的 UNet,8G 显存大概能跑 batch size 8。批大小小了梯度噪声大,可以适当降低学习率或者用梯度累积。学习率太大 loss 会震荡甚至发散,太小收敛慢,1e-3 配 AdamW 是常见起点,如果 loss 前几个 epoch 就飙到 NaN,先降到 1e-4 试试。混合精度训练能省显存,但遥感数据里有些操作对精度敏感,开了之后要验证指标有没有掉。
4.3 验证阶段怎么算 mIoU 才不出错
mIoU 的计算有个容易翻车的地方:混淆矩阵的累加。正确做法是把整个验证集的预测和标签逐像素累加到一个 num_classes × num_classes 的矩阵里,最后一次性算 IoU,而不是每个 batch 算完再平均。后者会让小 batch 的噪声放大,指标不可比。
def evaluate(model, loader, num_classes): model.eval() conf = np.zeros((num_classes, num_classes), dtype=np.int64) with torch.no_grad(): for img, mask in loader: pred = model(img.cuda()).argmax(1).cpu().numpy() mask = mask.numpy() for p, t in zip(pred.flatten(), mask.flatten()): conf[t, p] += 1 ious = [] for i in range(num_classes): tp = conf[i, i] fp = conf[:, i].sum() - tp fn = conf[i, :].sum() - tp ious.append(tp / (tp + fp + fn + 1e-6)) return np.mean(ious)conf[t, p]的行是真实类别,列是预测类别,别搞反了。1e-6防止某类完全没出现时除零。如果某一类 IoU 明显低,回去看这一类在训练集里的样本量和权重设置。
5. 避坑与排查:遥感 UNet 训练里最常见的五个翻车现场
5.1 现象:loss 一直不降,mIoU 卡在 0.1 左右
原因通常是数据归一化没做对,或者标签的像素值不是从 0 开始的连续整数。遥感掩膜有时候用 0、255 表示两类,直接送进 CrossEntropyLoss 会报错或者学出奇怪的结果。解决办法是先np.unique(mask)看一眼标签值,用映射表转成 0 到 num_classes-1 的连续整数,再确认影像归一化后的数值范围在 0 到 1 之间。
5.2 现象:验证 mIoU 比训练 mIoU 还高
这基本可以断定是数据泄漏。最常见的是按图块随机划分数据集,同一幅大图的相邻块同时进了训练和验证。解决办法是按整幅图划分,或者按地理位置分块划分。另一个可能是验证集里背景占比特别高,模型全预测背景也能拿到不错的 mIoU,这时候要看各类别的 IoU 而不是只看平均值。
5.3 现象:训练到一半 loss 突然变成 NaN
原因多半是 Dice 损失里除了零,或者学习率太大导致梯度爆炸。解决办法是给 Dice 的分母加eps,加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0),并把学习率降一个数量级。混合精度训练下 NaN 更常见,可以先关掉 AMP 排查。
5.4 现象:建筑边界预测得毛毛糙糙,指标上不去
这是 UNet 下采样丢细节的典型表现。可以尝试三个方向:把输入图块从 256 提到 512,减少下采样对边界的影响;在跳跃连接上加注意力门控,让解码器更关注边界区域;后处理加条件随机场或者简单的形态学闭运算,把碎斑连起来。论文里可以把这几种改进做消融对比,是很实在的实验内容。
5.5 现象:换了台机器或换了 CUDA 版本就跑不起来
深度学习环境对版本很敏感。PyTorch、CUDA、cuDNN 三者版本要匹配,显卡驱动也要够新。我一般会在论文附录里写清楚环境配置,用conda建独立环境,把torch.__version__和torch.version.cuda打印出来存档。换机器时先跑一个最小测试脚本确认 GPU 可用,再跑完整训练,别一上来就开大任务。
6. 从跑通到写进论文:消融实验、可视化与可复现的工程习惯
模型跑通只是起点,毕业设计要拿得出手,还得有实验设计和结果呈现。我一般会做三组对比:基线 UNet、加了注意力模块的 UNet、换了损失函数的 UNet,在同一个验证集上比 mIoU、F1 和参数量。消融实验不用多,但每一组都要有明确的变量和可解释的结论,这样论文的第四章才有东西写。
可视化方面,除了常规的预测掩膜对比图,我建议加一张错误分布图:把预测错误的像素在原图上标红,一眼就能看出模型是在边界出错还是在某一类上系统性出错。这张图在答辩时非常有用,评委一看就知道你对自己的模型有深入理解。论文框架上,第二章讲 UNet 原理和遥感分割背景,第三章讲数据和方法,第四章讲实验和结果,第五章讲结论和不足,这个结构稳当且不容易被挑毛病。
代码工程习惯上,我会把配置项全部抽到一个config.yaml里,包括数据路径、patch 大小、batch size、学习率、epoch 数,训练脚本只读配置不写死参数。每次实验用一个独立的输出目录,存模型权重、训练日志和验证指标。这样回头补实验或者复现某次结果时不用靠记忆,也避免了「上次那个效果好的模型参数是多少来着」这种血泪追问。随机种子要固定,torch.manual_seed、np.random.seed和 DataLoader 的worker_init_fn都要设,不然两次跑出来的结果对不上,论文里的数字就没法复现。
最后一个习惯:训练日志里把每个 epoch 的验证指标和当时的学习率一起记下来,画成曲线放进论文。很多人只放 loss 曲线,其实 mIoU 曲线更能说明模型什么时候开始过拟合。如果验证 mIoU 在第 30 个 epoch 达到峰值然后下降,那最终模型就该取第 30 个 epoch 的权重,而不是最后一个。这个细节看着小,但能体现你对训练过程的掌控,也是我自己踩过坑之后才养成的习惯。希望帮到你。
本文还有配套的精品资源,点击获取