☰
U-Net遥感图像语义分割毕设实战:数据、训练与避坑指南
2026/10/11 13:05:38 网站建设 项目流程

简介:毕业设计基于U-Net网络的遥感图像语义分割项目,提供可直接运行的Python源码与配套论文,适合本科/高职计算机、遥感、人工智能方向学生用于毕业设计或课程设计。项目覆盖数据集预处理、U-Net模型搭建、训练验证与遥感影像分割结果可视化等完整流程,源码经过本地编译验证,难度适中。压缩包共46.94MB,含Python脚本、论文文档及项目说明文件,可对照论文理解网络结构与实现细节。该资源目前已吸引177人浏览学习,内容经助教审定,评审分达98分,兼具工程完整性与教学参考价值,适合作为高分毕设蓝本或深度学习语义分割入门进阶的实战范例。

1. 毕业设计用U-Net做遥感图像语义分割:为什么这套组合是“高分毕设”的常见解

不少同学是到了毕设开题那两周才第一次认真查U-Net和语义分割的关系。它要解决的问题很具体:给一张卫星或者无人机拍回来的遥感影像,让模型逐像素标出建筑、道路、水体、植被和耕地。U-Net在这类任务里之所以高频出现,是因为它的跳跃连接能把浅层边缘信息一路送到解码器,训练数据只需要几十张标注图也能压出一个可用的结果,比FCN收敛快,比DeepLab系列好调参,一个人一学期做得完。这篇文章按我完整跑通这个选题的顺序来讲,覆盖数据切片、网络搭建、训练调参和论文里最出效果的可视化结果,适合刚定题或者卡在“数据怎么喂给模型”这一步的人直接照着改。

2. 让U-Net吃上遥感影像:TIF切片、标注掩膜与数据集划分的落地细节

2.1 数据格式统一:把矢量和伪彩色标注转成单通道整数掩膜

遥感影像的原始素材大多是TIF格式,三波段或者带近红外的四波段。真正麻烦的不是影像,而是标注。从GIS同事或者公开数据集拿到的标注可能是矢量面,也可能是伪彩色渲染图,背景是黑色、每类一个颜色。U-Net训练时需要的监督信号是一个单通道整数掩膜,每个像素的值是0、1、2、3这样的类别索引,而不是RGB颜色。所以预处理第一步永远是先把标注统一成这样的mask,越早做越省事。

# convert_label_to_mask.py # 将三通道伪彩色标签图转为单通道整数分类掩膜 import rasterio import numpy as np palette = { (0, 0, 0): 0, # 背景 (255, 0, 0): 1, # 建筑 (255, 255, 0): 2, # 道路 (0, 0, 255): 3, # 水体 (0, 255, 0): 4, # 植被 } with rasterio.open("label_color.tif") as src: data = src.read() rgb = data[:3].transpose(1, 2, 0) # 转成 (H, W, 3),方便按像素匹配颜色 mask = np.zeros((rgb.shape[0], rgb.shape[1]), dtype=np.uint8) for color, cls in palette.items(): match = np.all(rgb == np.array(color).reshape(1, 1, 3), axis=2) mask[match] = cls profile = src.profile profile.update(count=1, dtype=rasterio.uint8) with rasterio.open("label_mask.tif", "w", **profile) as dst: dst.write(mask, 1)

这段脚本做的事情很直白:遍历在palette里定义好的颜色到类别映射,把原图中所有等于某个颜色的像素置成对应的类别号。np.all(..., axis=2)是在比较RGB三通道都相等时才算匹配,避免只凭单一通道颜色相近就被误判。转换完以后,一定要检查一次每个类别的像素占比,很多数据集的调色板里存着没有出现的颜色,或者标注时有多余的碎斑,这类像素会被留在0里当作背景,轻则拉低mIoU,重则让某个类别完全没训练信号。

另外要注意类别索引必须从0开始连续编号。PyTorch的CrossEntropyLoss内部做one-hot时,索引就是直接映射到输出的类别通道,如果mask里出现了palette没定义的颜色,该像素值为0但实际语义不是背景,模型会在每个epoch都往这个区域输出错误的梯度。所以转完mask后顺手跑一句np.unique(mask)看看值域是不是[0, 1, 2, 3, 4],这是整个毕设里最便宜的检查。

2.2 大图切块:用滑动窗口控制patch大小、重叠和无效块

遥感原图尺寸动辄上万像素,直接resize成512×512送进网络等于把分割问题降级成分类问题,小目标全被抹平。标准做法是把大图切成小块,术语叫patch或者tile。我习惯用rasterio的Window接口按滑动窗口切,既能控制读取范围,又不用先整图加载进内存。

# crop_tiles.py # 把大尺寸遥感 TIF 与对应掩膜同步切成固定大小的小图 import rasterio from rasterio.windows import Window import numpy as np import os TILE_SIZE = 512 # 切块大小,U-Net一般选能被整除以2的数 OVERLAP = 64 # 重叠像素,避免目标刚好被切在边界上 STRIDE = TILE_SIZE - OVERLAP def crop_tiles(image_path, mask_path, out_img, out_mask): os.makedirs(out_img, exist_ok=True) os.makedirs(out_mask, exist_ok=True) idx = 0 with rasterio.open(image_path) as src, rasterio.open(mask_path) as msrc: width, height = src.width, src.height for row in range(0, height, STRIDE): r0 = min(row, height - TILE_SIZE) # 右边界收拢 for col in range(0, width, STRIDE): c0 = min(col, width - TILE_SIZE) # 下边界收拢 window = Window(c0, r0, TILE_SIZE, TILE_SIZE) img = src.read([1, 2, 3], window=window) mask = msrc.read(1, window=window) # 整块都是背景的patch直接丢弃,这种样本信息量太低 if mask.max() == 0: continue np.save(f"{out_img}/{idx:05d}.npy", img.astype(np.uint8)) np.save(f"{out_mask}/{idx:05d}.npy", mask.astype(np.uint8)) idx += 1 return idx

这一步里有两个参数值得解释。第一是TILE_SIZE选择512,因为U-Net编码器做了4到5次下采样,输入边长需要能被2的次方整除,512是最稳妥的;同时512×512的三通道float32数据在常见8G显存上能跑一个不小batch。第二是OVERLAP,遥感影像里建筑边界长什么样并不总容易被窄条状裁剪抓住。不加重叠的话,道路、围墙这类细长目标在patch边缘会碎掉,训练时标签被削掉半边,预测时整个目标轮廓断裂。64像素是我试过比较平衡的值,既不把数据量撑大太多,也能缓解边缘截断问题。

切完之后建议用npy格式直接存,而不是存PNG。遥感影像波段位深可能是16bit,存成PNG会损失灰度层次,npy能保留原始数值。后面DataLoader读取时再把数据归一化,把控制权留在训练阶段而不是数据生成阶段。

2.3 训练/验证/测试划分:按场景分而不是按块随机分

很多人切完patch之后直接train_test_split乱序划分,这是最隐蔽的一个坑。同一栋建筑、同一条路,如果部分patch进了训练集、部分进了验证集,验证mIoU会虚高到让你误以为自己已经做完了。因为模型在训练时已经看到过同一块区域的像素,验证时只是换个位置再认一遍。论文里答辩老师拿到你的测试结果图,一对比原图发现是“背题”而不是“泛化”,这比指标低还要尴尬。

我一般按区域划分数据集:先把文件名前缀里同一个场景的patch归在一起,再把整个场景分进训练、验证或测试,而不是单独分patch。

# split_by_area.py # 按场景划分 train/val,避免同一片区同时出现在两个集合里 import os import random from glob import glob img_files = sorted(glob("data/tiles/image/*.npy")) areas = {} for path in img_files: # 文件名约定:area_01_00023.npy,取前两段作为场景key parts = os.path.basename(path).split("_") area = parts[0] + "_" + parts[1] areas.setdefault(area, []).append(path) area_names = list(areas.keys()) random.shuffle(area_names) val_num = max(int(len(area_names) * 0.2), 1) val_areas = set(area_names[:val_num]) val_imgs = [p for area in val_areas for p in areas[area]] train_imgs = [p for area in area_names[val_num:] for p in areas[area]]

执行完这个脚本,再顺手看一眼len(train_imgs)和len(val_imgs)的比例。如果某个场景特别大导致验证集比例偏大,就按区域面积重新分配,而不是写死了20%。分割任务的训练数据量本来就比分类任务吃得多,一个patch里的有效标签占比可能只有30%,所以宁可多切几个场景,也不要让训练和验证在空间上重叠。

数据增强在这个阶段就要想好:遥感影像和自然图像不一样,地物方向没有固定“正着看”的语义,旋转90°、翻转都合理,但标签必须和影像同步变换。颜色抖动、对比度扰动可以随机加,因为同一场景不同时相的光照差异客观存在。不要做随机裁剪,因为所有patch已经是512×512的统一尺寸,再做裁剪会让边缘标注比例紊乱。

3. U-Net网络结构的PyTorch实现:编码器、跳跃连接与输出头怎么搭

3.1 编码器中的卷积块:3x3卷积+BN+ReLU是遥感分割的地基

U-Net的编码器部分和VGG很像,本质是一串下采样卷积块。区别在于每个块不是单层卷积,而是“两次3×3卷积+批归一化+ReLU”的DoubleConv结构。第一次卷积提取局部特征,第二次卷积在感受野更大的前提下进一步整合信息;连续两个3×3卷积等效于一个5×5卷积,但参数量少,非线性更强。遥感图像里建筑边缘、道路细线这类高频信息,恰恰需要这种局部叠加来保留。

padding=1保证卷积后特征图宽度高度不变。如果不加padding,每过一次卷积尺寸变小2,编码器里还好,解码器拼接时尺寸对齐会变得相当痛苦。BatchNorm在这里比GroupNorm常见,因为遥感数据虽然存在光照差异,但同一batch内的patch如果来自同一场景,均值和方差相对稳定;真怕跨场景干扰,把batch size调大一点比换归一化更省事。

3.2 跳跃连接与特征拼接:cat时维度和尺寸不一致的修正

U-Net区别于FCN最核心的就是跳跃连接。每次池化前把编码器特征图存一份,解码器上采样后再把它和深层特征拼起来。深层的语义特征知道这块区域是建筑还是道路,浅层的特征保留着边缘在哪,两者拼起来才能让分割结果既有类别语义,又有锐利边界。实现上就是torch.cat,方向是channel维度。

一个容易翻车的地方是上采样后的特征图和对应编码器层特征图尺寸对不齐。如果输入尺寸是512×512,经过4次池化后是32×32,转置卷积上采样依次得到64、128、256、512,尺寸刚好对上。但如果输入图像不是2的整数次幂,比如513×513,池化时向下取整会让下采样结果和上采样结果错开1到2个像素,直接cat会报错。所以要么在数据管线里强制检查和修正尺寸,要么在每次cat之前做一次自适应resize。

# unet.py # U-Net完整实现:编码器4层、bottleneck、解码器4层,带跳跃连接 import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels=3, num_classes=6): super().__init__() self.pool = nn.MaxPool2d(2) self.enc1 = DoubleConv(in_channels, 64) self.enc2 = DoubleConv(64, 128) self.enc3 = DoubleConv(128, 256) self.enc4 = DoubleConv(256, 512) self.bottleneck = DoubleConv(512, 1024) self.up1 = nn.ConvTranspose2d(1024, 512, kernel_size=2, stride=2) self.dec1 = DoubleConv(1024, 512) self.up2 = nn.ConvTranspose2d(512, 256, kernel_size=2, stride=2) self.dec2 = DoubleConv(512, 256) self.up3 = nn.ConvTranspose2d(256, 128, kernel_size=2, stride=2) self.dec3 = DoubleConv(256, 128) self.up4 = nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2) self.dec4 = DoubleConv(128, 64) self.out_conv = nn.Conv2d(64, num_classes, kernel_size=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.dec1(torch.cat([self.up1(b), e4], dim=1)) d3 = self.dec2(torch.cat([self.up2(d4), e3], dim=1)) d2 = self.dec3(torch.cat([self.up3(d3), e2], dim=1)) d1 = self.dec4(torch.cat([self.up4(d2), e1], dim=1)) logits = self.out_conv(d1) return logits

这里dec1到dec4的输入通道数就是“上采样结果通道数+对应encoder特征图通道数”。比如up1把1024通道缩到512,和e4的512通道cat后变成1024,再进dec1做DoubleConv。如果跳过这步,直接让dec1输入只有512通道,训练也能跑,但跳连带来的边缘信息就全被扔掉了。

out_conv是最后那个1×1卷积,它不改变空间维度,只把64通道映射成num_classes通道。返回值叫logits而不是probabilities,因为它没有经过softmax,训练时损失函数内部会做softmax,推理时再argmax。不要在forward里提前softmax,否则和CrossEntropyLoss叠加会出现双重归一化,数值会变得非常怪。

关于转置卷积,还有个常见选择是换成“Upsample + 3×3卷积”。转置卷积在特征图分辨率奇数时容易在边缘产生棋盘伪影,Upsample模式更平滑。毕设场景下两者差异不大,我更推荐转置卷积,因为它能在上采样时学习更合适的插值权重,代码改动也更小。

3.3 输入尺寸和padding策略:为什么512是稳妥的默认值

U-Net对输入尺寸的要求是长宽都能被2整除,层层池化下去不会被截断。512这种数值天然满足,且显存占用适中。如果原图切块时选了500,那就要注意每次MaxPool之后尺寸是250、125,到第三层就出现了奇数125,后续上采样很难和编码器特征对齐。

遇到这种问题有两种修法。一是所有patch强制成2的幂次边长再加padding,比如输入512,不足部分用0填充,mask里对应填充区域设成背景类忽略不计。二是把ConvTranspose层的输出大小直接用F.interpolate强制成和encoder特征图一致。这个做法更自适应,但会引入轻微的缩放误差。我的建议是:数据准备阶段统一成512×512,网络结构就不需要任何额外分支,简洁且不容易出隐藏bug。

如果你在做一个四波段遥感影像,也就是RGB之外还有近红外,只需要把in_channels从3改成4,编码器第一层的输入通道会自动适配,其他全部不用动。近红外对水体分割的提升非常明显,因为我做过的实验里,水体在RGB下容易和阴影混淆,近红外波段让光谱差异变得直观。有数据就用,别放着。

4. 训练U-Net的关键参数:损失函数、学习率调度与模型保存策略

4.1 损失函数:先用CrossEntropy,稳定后混合Dice Loss,别一上来就上Focal

语义分割的损失函数选择直接决定模型能不能收敛到能用的状态。CrossEntropyLoss理论上是默认选项,它对每个像素独立计算交叉熵,梯度稳定。但遥感图像类别分布极度不均衡,一张5000×5000图里背景可能占80%,道路只有2%。纯CE训出来往往是背景类mIoU很高,道路类几乎全灭,因为多数像素的梯度被背景主导。解决方式是给每个类别一个权重,低频类别权重放大,高频类别权重压低。

我实际使用的结果是:CE和Dice Loss混合最稳。Dice Loss直接优化区域重叠率,天然以IoU为重心,对类别数量不敏感;缺点是训练初期梯度不稳定,尤其softmax概率接近0或1时。所以初期以CE为主,训练几十个epoch后将两部分的权重拉平,效果比全程只用任何单一种都更稳定。

# loss.py # CE + Dice 混合损失,是遥感分割里兼顾收敛速度和类别平衡的常见组合 import torch import torch.nn.functional as F def hybrid_loss(logits, mask, ce_weight=1.0, dice_weight=0.5): # logits: (B, C, H, W),未归一化 # mask: (B, H, W),每个像素是类别索引 ce = F.cross_entropy(logits, mask) prob = torch.softmax(logits, dim=1) one_hot = F.one_hot(mask, num_classes=logits.shape[1]).permute(0, 3, 1, 2).float() intersection = (prob * one_hot).sum(dim=(2, 3)) # 每个类别逐像素相乘再求和 denominator = prob.sum(dim=(2, 3)) + one_hot.sum(dim=(2, 3)) dice = (2.0 * intersection + 1e-6) / (denominator + 1e-6) # 加平滑项防除零 return ce_weight * ce + dice_weight * (1.0 - dice.mean())

1 - dice.mean()是把Dice相似度转成损失,值越小越好。1e-6是平滑项,防止某个类别在patch里完全不存在时出现除零。ce_weight和dice_weight的比例我一般用1:0.5起步,如果发现前景目标召回率上不去,就把dice_weight提到1.0。不要全程只跑Dice,前期高学习率下Dice曲线会剧烈振荡,CE相当于一个稳定器。

Focal Loss我在毕设任务里不太推荐。它是为单阶段目标检测设计的,γ参数对遥感分割效果敏感,需要慢慢调,而且训练时长明显增加。如果不是前景目标极度稀疏,比如检测几百个像素的油罐,没必要上。

4.2 评价指标:mIoU、Dice、像素准确率各说明什么问题

论文里老师最认的指标是mIoU和每类IoU。像素准确率PA在这种数据上是最不可信的指标:背景占90%时,模型什么都不预测、输出全背景,PA就有90%,论文里写这个指标基本是给自己挖坑。我用验证脚本打印每个类别的IoU,比只看综合mIoU更有诊断价值。比如总mIoU有0.65,但道路只有0.3,那说明模型把大量道路像素预测成了背景或是建筑,定位问题比笼统调参效率高很多。

# evaluate.py # 计算全类别混淆矩阵,打印每类 IoU 与 mIoU @torch.no_grad() def evaluate(model, val_loader, num_classes): model.eval() confusion = torch.zeros(num_classes, num_classes).cuda() for img, mask in val_loader: img = img.cuda() pred = torch.argmax(model(img), dim=1) # 取logits最大值索引 pred = pred.cuda() for t, p in zip(mask.flatten(), pred.flatten()): confusion[t, p] += 1 miou = 0.0 for cls in range(num_classes): tp = confusion[cls, cls].item() fn = confusion[cls, :].sum().item() - tp fp = confusion[:, cls].sum().item() - tp union = tp + fn + fp if union == 0: continue iou = tp / union miou += iou print(f"class {cls}: IoU = {iou:.4f}") return miou / num_classes

混淆矩阵是整个验证的核心数据。confusion[t, p]表示真实类别为t、预测类别为p的像素数量,对角线就是正确预测数。某一类如果fp特别高,说明模型喜欢把别的东西误判成它;fn高则说明该类像素大量丢失。打印出来的每类IoU直接指导我调整数据增强或者损失权重,比盯着TensorBoard等损失曲线玄学得多。

4.3 训练参数:batch size、学习率、scheduler与最优模型保存

训练循环本身的写法不复杂,但有几个参数会显著影响最终结果。优化器我推荐AdamW而不是Adam,weight decay对BN层的正则效果更干净。学习率用1e-3起步,scheduler用CosineAnnealing,把80个epoch的余弦曲线拉到底。遥感图像patch的batch size在8G显存上建议4到8,太大容易OOM,太小BN的统计量会抖。

# train.py # 最小可运行的训练流程,包含验证和最优权重保存 import torch from torch.utils.data import DataLoader model = UNet(in_channels=3, num_classes=6).cuda() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=80, eta_min=1e-5) best_miou = 0.0 best_path = "best_model.pt" for epoch in range(80): model.train() total_loss = 0.0 for img, mask in train_loader: img = img.cuda() mask = mask.cuda() logits = model(img) loss = hybrid_loss(logits, mask) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() val_miou = evaluate(model, val_loader, num_classes=6) scheduler.step() if val_miou > best_miou: best_miou = val_miou torch.save(model.state_dict(), best_path) print(f"epoch {epoch:02d}, loss {total_loss/len(train_loader):.4f}, val mIoU {val_miou:.4f}")

scheduler.step()在训练循环末尾执行,而不要丢掉。best_miou在验证后比较,用验证mIoU而不是训练loss选择模型。很多人把最后一个epoch的权重直接当成最终成果,结果训练后期过拟合了而不自知。我每次都会把torch.save封装成一个checkpoint字典,把epoch、optimizer.state_dict、val_miou一起存,这样万一中断还能继续训练,比只存state_dict更稳妥。

学习率这块尽量不要全局网格搜。常见做法是先固定1e-3跑20个epoch,看训练loss有没有下降趋势;如果loss震荡很凶,降到3e-4,如果下降太慢,升到3e-3。经过两三轮粗调就够用,不要在这种小数据集上把超参调参做成科研项目,时间成本不划算。

5. 避坑:遥感语义分割毕设里常见的翻车现场与排查方法

5.1 类别不均衡:道路和裸地在预测结果里一片黑

现象:训练loss正常下降,但预测结果里建筑和背景结构清晰,道路几乎消失,水体只剩零星噪声。

原因:数据集里道路、裸地的像素占比和背景差距过大,普通的CrossEntropy忽略这些低频类别。即使加了Dice混合损失,如果背景patch占了训练集80%,模型仍然会把道路预测成两边相邻的类别,因为这样可以压住背景误分。

解决:切块时先过滤纯背景patch,这是在数据层面做的第一道平衡;然后在损失函数层面加类别权重,用逆频率方式放大低频类。下面这段权重计算的思路可以直接嵌进训练代码:

import numpy as np import torch # mask_flat 是全部训练集掩膜的拼接数组,值域 [0, num_classes-1] counts = np.bincount(mask_flat, minlength=num_classes).astype(np.float32) weights = np.median(counts) / np.maximum(counts, 1) # 中位数缩放,避免权重差距过大 weights = np.clip(weights, 0.1, 10.0) class_weight = torch.from_numpy(weights).float().cuda() criterion_ce = torch.nn.CrossEntropyLoss(weight=class_weight)

np.median(counts)让权重不至于因为某个类别占比0.1%而放大到几千倍,clip到10倍以内是为了梯度稳定。如果加了权重后道路还是碎,那就回数据侧看掩膜有没有标对,很多道路标签边缘只有1像素,CNN下采样后根本留不住。

5.2 验证阶段OOM:不是显卡不行,而是推理流程没写对

现象:训练循环跑得好好的,到验证函数一运行就报torch.cuda.OutOfMemoryError。

原因:验证阶段没有包torch.no_grad(),模型推理时仍然构建了完整计算图,中间变量全部驻留显存;或者验证时一次性把整张大图送进模型,忘记切成patch。训练有梯度累积梯度回传后显存释放,反而验证时没有做这一层管理,就成了压垮显存的那根稻草。

解决:验证函数标准写法是model.eval()加with torch.no_grad(),自动求导图不建立。推理的输入尺寸和训练保持完全一致,如果非得在整幅图上跑,至少要按patch滑窗推理再拼接,这个过程不需要计算梯度。每个epoch结束之后可以执行torch.cuda.empty_cache(),但这不是主手段,只是释放碎片显存,不是解决OOM的根本办法。

5.3 掩膜和影像错位:mIoU一直上不去,先检查几何一致性

现象:loss正常下降,但验证mIoU只在0.1到0.2徘徊,生成的预测图边缘和影像目标有系统性偏移,所有建筑的预测边界都往左上角移了几个像素。

原因:影像和掩膜虽然文件成对,但一个经过了重投影,另一个没同步重采样,导致空间网格不重叠。切片时用同一个Window读两个文件,它们各自origin或者像元大小不同,切出来的内容在空间上对不上。

解决:在切片前先做一致性检查。除了打印两个文件的transform和crs,更直接的办法是把影像和mask用matplotlib叠在一起看两眼,如果错位肉眼可见就说明数据侧已经坏了,后面怎么调网络都没用。对齐处理常用nearest重采样,把mask重采样到影像的网格上,因为标注是类别标签,双线性插值会创造不存在的新类别,这种错位是后期最难排查的问题,做一次就记住教训了。

5.4 保存的权重不是最优模型:交上去的checkpoint和报告对不上

现象:训练日志里验证mIoU最高到了0.72,但用保存的模型测试时只跑到0.5左右,反复复现都不对。

原因:训练循环里把最后一个epoch的模型参数存成last_model.pt,而这个epoch已经过了余弦学习率衰减的后段,验证指标早就不是峰值;或者保存权重时用了CPU上的模型,加载时GPU和CPU键名不匹配,静默初始化出灾难结果。

解决:跟5.3相比这更像是一个工程纪律问题。保存checkpoint时要把val_miou写进文件名或者文件内,我一般在checkpoint里同时保存模型状态、优化器状态、学习率调度器状态和当前的val_miou。提交毕设前用测试集脚本完整跑一遍,确认能复现日志里的指标,这不是浪费时间,是避开答辩现场“图和文字对不上”的致命伤。

6. 出图阶段给语义分割结果做诊断:混淆矩阵与错分样本的可视化套路

6.1 按类别打印IoU的混淆矩阵

论文里放一张预测结果对比图当然需要,但如果只放预测图,老师看半天只能得到“好像分割出来了”的印象。真正提升完成度的是在附录或者实验节放一张包含每类IoU的混淆矩阵图,它说明你不仅调通了模型,还能解释模型的系统性失误在哪里。

实现上不需要装额外的画图库,直接用matplotlib画像素计数热力图。横轴是预测类别,纵轴是真实类别,对角线越亮越好;非对角线的高亮块就是当前模型的最大弱点。比如建筑和植被两个类互相误分,说明光谱特征或者标注本身有歧义,需要回到数据增强和类别定义上去。

6.2 原图、标签、预测三联图和错分样本复盘

答辩效果最好的是一组三联图:第一张原图、第二张人工标注的mask、第三张模型预测的mask。每张大图下面挑3到5个代表性细节放大,用红色框标出模型错分区域。这个动作在答辩初始阶段加进PPT,能够立刻把评价重点从“网络结构有没有创新”转移到“你懂不懂分析误差来源”,后者才是高分毕设拉开差距的地方。

我这里反复用过的一个教训是:第一次做可视化时只保存了预测mask,没有保留对应patch的坐标索引,结果论文需要反查原图位置时得重新推断。“保存patch的时候把(img_name, row, col)这个坐标信息同步丢进一个CSV,这个文件的成本极低,价值却不小”——这个教训帮我补足了寻址信息。图像可视化本身不需要特别复杂的代码,关键是保存坐标和原始patch的对应关系,如果你预计要和老师讨论某个错误样本,这一步就是后悔药。

还有一个心得:验证集跑完后,不要只看mIoU数字最高的模型,专门挑一个mIoU中等但错分样本有代表性的模型跑可视化。高分数模型输出的图往往很干净,看不出问题;中分数模型的错分位置才反映真实泛化边界。选图时优先挑道路交叉口、阴影遮挡区域、建筑和植被交界处,这些是遥感分割任务里最有讨论价值的错误类型,也是整场答辩里最能展示你做了分析的三个画面。到这一步,整个U-Net遥感语义分割项目才算真正闭环了。希望这些参数和踩坑经验能帮你的毕设少走一段弯路。

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

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

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

立即咨询