☰
遥感图像语义分割毕设实战:U-Net从数据标注到论文写作全流程
2026/10/1 8:53:58 网站建设 项目流程

简介:这份资源面向计算机、人工智能及相关专业的学生与开发者,提供一套基于U-Net网络的遥感图像语义分割系统完整实现,可用于毕业设计、课程设计或期末大作业。项目包含可运行的Python源码与配套论文,源码经本地编译验证,评审得分达98分,难度适中,适合具备一定深度学习基础的学习者参考。压缩包共68个文件,约46.93MB,涵盖6个py脚本与3个ipynb笔记本,分别负责模型定义、数据处理、训练与预测流程;另有32张png与6个svg用于结果展示,5个tex及bib、pdf构成论文排版素材,并附字体与说明文档,目录结构清晰。目前已有219人学习下载。读者可据此掌握U-Net在遥感影像上的编解码结构、数据集制作、训练调参与预测可视化全流程,并借助论文与代码对照理解语义分割的关键实现细节,快速完成项目复现与二次开发。

1. 遥感图像语义分割毕设:从 U-Net 跑通到论文能写清楚

遥感图像语义分割这个方向,每年毕业季都会被大量计算机、测绘、遥感相关专业的同学翻出来做毕设。原因很直接:数据公开、任务定义清晰、模型结构不算复杂,而且 U-Net 这种编码器-解码器架构在遥感场景里确实能跑出看得过去的结果。但真正动手时,问题往往不在模型本身,而在“遥感图像标注怎么做”“语义分割数据集制作从哪下手”“Python 环境怎么配才不翻车”这些前置环节。这篇笔记按一线做过的路径拆一遍:U-Net 为什么适合遥感语义分割、数据怎么准备、代码怎么跑、参数怎么调、论文里哪些图必须放。适合正在做基于 Python 的毕业设计、需要一套能复现的遥感图像语义分割系统的同学,也适合想快速判断这个方向值不值得投入的人。

2. U-Net 在遥感语义分割里的选型理由与最小可跑通路径

遥感图像和自然图像有个本质差别:同一张图里,地物尺度差异极大。一条乡村道路可能只占几十个像素,一片农田可能覆盖半张图。U-Net 的跳跃连接把编码器的高分辨率特征直接送到解码器,小目标不会被深层下采样彻底抹掉,这是它在遥感语义分割里长期被当作 baseline 的核心原因。另一个现实因素是数据量:毕设能拿到的标注数据通常只有几百到几千张,U-Net 在这种规模下比 Transformer 类模型更容易训起来,不需要预训练权重也能收敛。

2.1 遥感语义分割的任务定义与标签体系

遥感语义分割的输出是逐像素类别。常见类别体系包括:建筑、道路、水体、植被、裸地、农田。类别数直接决定输出通道数,也决定损失函数怎么选。如果类别不均衡严重(比如道路像素只占 3%),交叉熵会被背景类主导,这时候要换 Dice Loss 或带权交叉熵。

标签格式常见两种:单通道灰度图(像素值 0/1/2… 对应类别)和 RGB 彩色图。U-Net 训练时通常转成单通道,用torchvision.transforms或自定义 Dataset 读入。这里有个容易忽略的点:遥感图像标注工具导出的标签,边界像素经常有半透明过渡,直接读进来会出现 254、128 这种非整数类别值,必须在 Dataset 里做一次映射或阈值化。

2.2 用 Python 搭出 U-Net 最小训练脚本

下面是一个可以直接跑的最小训练循环,数据集假设已经整理成images/和masks/两个文件夹,文件名一一对应。

import os import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from PIL import Image import numpy as np from torchvision import transforms class RemoteSensingDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size=256): self.img_dir = img_dir self.mask_dir = mask_dir self.img_size = img_size self.names = sorted(os.listdir(img_dir)) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img = Image.open(os.path.join(self.img_dir, name)).convert("RGB") mask = Image.open(os.path.join(self.mask_dir, name)).convert("L") img = img.resize((self.img_size, self.img_size)) mask = mask.resize((self.img_size, self.img_size), Image.NEAREST) img = transforms.ToTensor()(img) mask = torch.from_numpy(np.array(mask)).long() # 把非连续标签值映射到 0..num_classes-1 mask = torch.clamp(mask, 0, 5) return img, mask class UNet(nn.Module): def __init__(self, in_ch=3, num_classes=6): super().__init__() def block(i, o): return nn.Sequential( nn.Conv2d(i, o, 3, padding=1), nn.BatchNorm2d(o), nn.ReLU(inplace=True), nn.Conv2d(o, o, 3, padding=1), nn.BatchNorm2d(o), nn.ReLU(inplace=True) ) self.enc1 = block(in_ch, 64) self.enc2 = block(64, 128) self.enc3 = block(128, 256) self.pool = nn.MaxPool2d(2) self.bottleneck = block(256, 512) self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec3 = block(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec2 = block(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec1 = block(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)) b = self.bottleneck(self.pool(e3)) d3 = self.dec3(torch.cat([self.up3(b), 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) if __name__ == "__main__": device = torch.device("cuda" if torch.cuda.is_available() else "cpu") ds = RemoteSensingDataset("data/images", "data/masks") dl = DataLoader(ds, batch_size=4, shuffle=True, num_workers=2) model = UNet(num_classes=6).to(device) opt = torch.optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.CrossEntropyLoss() for epoch in range(50): model.train() total = 0 for img, mask in dl: img, mask = img.to(device), mask.to(device) opt.zero_grad() pred = model(img) loss = loss_fn(pred, mask) loss.backward() opt.step() total += loss.item() print(f"epoch {epoch}, loss {total/len(dl):.4f}")

这段代码里几个参数值得单独说。img_size=256是显存和精度的折中,遥感图像原始尺寸动辄 5000×5000,必须切块;batch_size=4在 8GB 显存下比较稳,想加大先看nvidia-smi;num_classes=6要和标签映射范围一致,否则 CrossEntropyLoss 会直接报 index out of range。torch.clamp(mask, 0, 5)是血泪经验,很多标注工具导出的 PNG 在边界处有 255 或 254,不处理的话训练中途才崩,排查起来很费时间。

2.3 训练前必须确认的三个数据检查点

第一,图像和标签文件名是否严格一一对应。遥感数据集经常出现img_001.tif对应img_001_mask.png这种命名,脚本里要统一。第二,标签像素值分布。用np.unique(np.array(mask))打印一下,确认只有 0 到 num_classes-1。第三,图像通道数。遥感图像可能是 4 通道(RGB+近红外),convert("RGB")会丢掉近红外,如果论文里想用多光谱,Dataset 里要改成读 4 通道并调整 U-Net 的in_ch。

3. 遥感图像标注与语义分割数据集制作的完整操作链

这一章解决“数据从哪来、怎么标、怎么切”的问题。毕设里数据集质量直接决定论文能写多深,标注不规范,后面调参全是玄学。

3.1 公开数据集选择与自有数据补充

常见可用的公开遥感语义分割数据集包括 LoveDA、ISPRS Potsdam、DeepGlobe。LoveDA 覆盖城市和农村,类别有建筑、道路、水体、植被、裸地、背景,比较适合毕设。如果导师要求自有数据,可以用无人机或公开卫星图切片,再用 QGIS 或 ArcGIS 做初步标注。

选择逻辑:如果论文重点是模型改进,用公开数据集,保证可比性;如果重点是应用系统,自有数据加公开数据混合,但要在论文里写清楚混合比例和标注规范。

3.2 用 Python 做图像切块与标签同步增强

遥感图像太大,必须切块。下面脚本把大图和标签同步切成 256×256,并做简单增强。

import os import numpy as np from PIL import Image def slide_crop(img_path, mask_path, out_img_dir, out_mask_dir, size=256, stride=128): img = np.array(Image.open(img_path)) mask = np.array(Image.open(mask_path)) h, w = img.shape[:2] idx = 0 for y in range(0, h - size + 1, stride): for x in range(0, w - size + 1, stride): img_crop = img[y:y+size, x:x+size] mask_crop = mask[y:y+size, x:x+size] # 过滤掉标签全为背景的块,减少无效样本 if len(np.unique(mask_crop)) == 1 and mask_crop[0,0] == 0: continue Image.fromarray(img_crop).save(os.path.join(out_img_dir, f"{idx:05d}.png")) Image.fromarray(mask_crop).save(os.path.join(out_mask_dir, f"{idx:05d}.png")) idx += 1 if __name__ == "__main__": os.makedirs("crops/images", exist_ok=True) os.makedirs("crops/masks", exist_ok=True) slide_crop("raw/big_image.tif", "raw/big_mask.png", "crops/images", "crops/masks")

stride=128表示重叠一半,增加样本量同时避免边界目标被切断。过滤全背景块能显著降低类别不均衡。如果显存够,可以把size提到 512,但要注意 U-Net 下采样四次后,512 输入的最小特征图是 32×32,再小的小目标就丢了。

3.3 标签噪声清理与类别映射表

标注噪声主要来自三处:边界模糊、类别混淆(建筑和道路)、工具导出伪彩色。处理方式是建一张映射表,把原始像素值统一映射到 0 到 N-1。

原始值含义映射后
0背景0
128建筑1
255道路2
64水体3
192植被4
254边界噪声归入最近类或丢弃

映射表写进 Dataset 的__getitem__,不要等到训练时再处理。论文里可以把这张表放进“数据预处理”小节,审阅老师一看就知道你认真做过数据。

4. 训练参数、损失函数与评估指标的调参实战

模型能跑通只是起点,毕设论文里真正拉开差距的是调参记录和指标分析。

4.1 学习率、批大小与优化器的组合选择

U-Net 在遥感语义分割里,Adam + 初始学习率 1e-3 是稳妥起点。如果 loss 震荡,降到 3e-4;如果收敛太慢,用 OneCycleLR 做 warmup。批大小受显存限制,但可以用梯度累积模拟大 batch。

# 梯度累积示例:实际 batch 4,累积 4 次等效 batch 16 accum_steps = 4 opt.zero_grad() for i, (img, mask) in enumerate(dl): pred = model(img.to(device)) loss = loss_fn(pred, mask.to(device)) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: opt.step() opt.zero_grad()

注意 loss 要除以累积步数,否则梯度会放大。这个技巧在显存不够但想用大 batch 稳定 BatchNorm 时特别有用。

4.2 类别不均衡下的损失函数切换

遥感数据里道路、水体往往占比低。直接用 CrossEntropyLoss,模型会倾向预测背景。切换顺序建议:先试带权 CrossEntropy,权重按类别频率倒数;不行再上 Dice Loss 或 Combo Loss。

class DiceLoss(nn.Module): def __init__(self, smooth=1e-6): super().__init__() self.smooth = smooth def forward(self, pred, target): pred = torch.softmax(pred, dim=1) target_onehot = torch.nn.functional.one_hot(target, num_classes=pred.shape[1]) target_onehot = target_onehot.permute(0, 3, 1, 2).float() intersection = (pred * target_onehot).sum(dim=(2, 3)) union = pred.sum(dim=(2, 3)) + target_onehot.sum(dim=(2, 3)) dice = (2 * intersection + self.smooth) / (union + self.smooth) return 1 - dice.mean()

Dice Loss 对小类别更友好,但训练初期不稳定,常见做法是前 10 个 epoch 用 CrossEntropy,之后切 Combo(0.5 CE + 0.5 Dice)。

4.3 评估指标:mIoU、F1 与混淆矩阵怎么放进论文

mIoU 是语义分割标配,但毕设论文里只放一个 mIoU 数字太单薄。建议同时给出每类 IoU、F1 和混淆矩阵。混淆矩阵能直观看出哪两类容易混,比如建筑和道路。用 sklearn 的confusion_matrix加 matplotlib 画热力图,论文里放一张就够。

指标公式论文里放哪
mIoU各类 IoU 平均实验结果主表
F12PR/(P+R)补充表
混淆矩阵逐类统计分析图

5. 避坑与排查:遥感语义分割毕设里最容易翻车的五件事

5.1 现象:训练 loss 一直不降,输出全是一个类别

原因通常是标签映射错误或学习率过大。先打印一个 batch 的标签唯一值,确认在 0 到 num_classes-1。如果标签没问题,把学习率降到 1e-4 再跑 5 个 epoch 看 loss 是否下降。另一个隐蔽原因是图像归一化没做,ToTensor()只把像素转到 0-1,如果原始是 16 位遥感图,数值范围可能是 0-65535,必须手动除以 65535。

5.2 现象:验证集 mIoU 比训练集低很多

过拟合。遥感数据集小的时候尤其明显。解决顺序:先加数据增强(随机翻转、旋转、色彩抖动),再考虑 Dropout 或权重衰减,最后才是减小模型。不要一上来就换模型,毕设时间有限,U-Net 加增强通常够用。

5.3 现象:预测结果边界锯齿严重

上采样用了最近邻或双线性,没有可学习参数。把ConvTranspose2d的stride=2确认写对,或者改用nn.Upsample(scale_factor=2, mode='bilinear')加卷积。另一个原因是输入切块太小,边界上下文不足,把size从 256 提到 384 试试。

5.4 现象:训练中途报 CUDA out of memory

先降 batch_size,再降 img_size。如果都不想降,用梯度累积。还要检查 DataLoader 的num_workers,设太大反而占内存,设成 2 或 4 即可。另外,验证阶段记得torch.no_grad(),否则显存会持续累积。

5.5 现象:论文里图表和代码对不上

常见于中途改了类别数或数据划分,但论文里的表没同步更新。建议在项目根目录放一个config.yaml,把 num_classes、img_size、batch_size、学习率都写进去,代码读配置,论文写配置。这样改一处,两边一致。

6. 把 U-Net 遥感分割做成可展示系统的进阶技巧

毕设如果只交一个训练脚本,答辩时容易被问“系统在哪”。常见做法是加一个 Flask 或 Gradio 前端,上传遥感图,返回分割掩膜叠加图。Gradio 最快,十几行代码就能跑。

import gradio as gr import torch import numpy as np from PIL import Image model = UNet(num_classes=6) model.load_state_dict(torch.load("best_unet.pth", map_location="cpu")) model.eval() def predict(img): img = img.resize((256, 256)) x = torch.from_numpy(np.array(img)).permute(2, 0, 1).float().unsqueeze(0) / 255.0 with torch.no_grad(): out = model(x).argmax(dim=1).squeeze().numpy() color_map = np.array([[0,0,0],[255,0,0],[0,255,0],[0,0,255],[255,255,0],[255,0,255]], dtype=np.uint8) return color_map[out] gr.Interface(fn=predict, inputs=gr.Image(), outputs=gr.Image()).launch()

这段代码把模型输出映射成彩色掩膜,答辩演示够用。注意load_state_dict的map_location要写,否则在没 GPU 的答辩电脑上会报错。另外,Gradio 默认端口可能被占用,launch(server_port=7861)换一个。

论文里系统部分可以放一张界面截图加一段流程说明:上传图像 → 预处理 → U-Net 推理 → 掩膜叠加 → 结果下载。不要写太多前端细节,重点还是模型和指标。

最后说一个我自己的习惯:每次跑完实验,把配置、命令、mIoU、每类 IoU 记在一个experiments.md里,按日期排。写论文时直接翻这个文件,比回忆靠谱得多。遥感语义分割毕设不难,难的是数据干净、参数有记录、论文能自圆其说。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询