简介:一套基于UNet的遥感图像语义分割毕业设计项目,源码与论文齐整,面向计算机视觉方向的学生与开发者,可用于毕业设计、课程设计、期末大作业等场景。项目源码均本地编译通过且可运行,评审分达九十八分,难度适中,内容经助教老师审定,具备较强的参考与复用价值。资源包共六十八个文件,大小约四十六点九四兆字节,主要包含Python脚本(数据处理、模型构建、训练预测)、Jupyter笔记本演示(创建数据集、训练、预测)、论文相关的Tex与PDF文档,以及PNG、SVG图表等辅助材料,便于按模块开展学习。目前已有三百六十九人浏览学习,读者可从完整源码、可运行示例、论文底稿与清晰目录结构中,系统掌握UNet在遥感图像语义分割中的落地流程,亦可直接改造用于其他分割任务。
1. 基于UNet的遥感图像语义分割:毕设选题为什么总是它
每年临近毕业季,总有一批人被同一个题目卡住:基于UNet的遥感图像语义分割。这个题看起来到处都是教程,真正动手做的时候才发现,公开代码跑不通、数据格式对不上、loss不收敛、预测图全是噪点,最后靠熬夜和玄学调参勉强交差。如果你也在做这个方向,或者正准备选这个题,这篇文章就是按你从头到尾会踩的坑来写的。
遥感图像语义分割说白了就是让模型给每个像素打标签:哪些是建筑、哪些是道路、哪些是水体、哪些是植被。UNet之所以成为这个任务的默认起点,不是因为它最新,而是因为它结构简单、对小样本数据友好,论文和答辩都能讲清楚。它不依赖预训练权重也能从零训练出可用的分割结果,这对没有GPU集群的本科生来说就是最大的优势。接下来我会从网络结构、数据准备、训练代码到答辩论文一条线讲完,代码能直接复现,参数给出经验值,坑也提前标好。
2. 从任务到模型:遥感影像分割为什么认准UNet
2.1 语义分割任务的核心矛盾与遥感影像的独特性
语义分割不是简单的分类,它要同时回答"是什么"和"在哪里"两个问题。一个224x224的遥感图像块,如果是图像分类任务,模型只需要输出一个类别标签;但分割任务要求输出一个224x224的像素级类别矩阵,每个像素都要有归属。这意味着模型必须在压缩特征提取语义信息的同时,保留空间位置精度,这两个目标天然冲突——网络越深,感受野越大,语义越强,但下采样导致的空间信息丢失也越严重。
遥感影像进一步放大了这个矛盾。以常见的0.3米分辨率卫星影像为例,一栋房子的屋顶可能只占几十个像素,一条乡村道路的宽度可能只有三五个像素。小目标多、边界模糊、地物类别之间光照差异大,这是遥感分割区别于自然图像分割的三个典型特征。还有一个常被忽视的问题是影像幅面巨大,通常几千乘几千像素,不可能整图送入模型,必须切块处理,这让上下文信息的利用变得更加困难。
与自然图像分割相比,遥感影像还有一个显著差异:地物分布极不均衡。水域可能占整幅图的40%,而独立房屋可能只占0.5%。如果直接用交叉熵损失训练,模型会倾向于把所有像素预测为占比最大的类别,因为这样loss最小。这也是很多初学者第一次跑遥感分割时发现"模型啥也不学,只输出全图背景"的根本原因。理解这个特殊性,才能理解后面为什么要做数据增强、为什么要用加权损失。
2.2 UNet的编码器-解码器结构与跳跃连接的真正作用
UNet的命名来自它的U型结构,左边是编码器,右边是解码器,中间靠跳跃连接拼接。编码器部分连续做卷积和下采样,每经过一次下采样,特征图尺寸减半、通道数翻倍,这是为了逐步扩大感受野,让高层特征能"看到"更大的范围。到最底层时特征图已经缩小到原始尺寸的1/16或1/32,通道数可能达到512甚至1024,这时候特征图承载的主要是"这里是什么类别"的语义信息。
解码器做的事情刚好反过来,连续做上采样和卷积,把特征图逐步恢复到原始分辨率。上采样有两种常见做法:转置卷积和双线性插值。转置卷积可学习但有棋盘效应,双线性插值稳定但上采样后特征不够锐利。UNet的经典实现用的是转置卷积配合跳跃连接来缓解细节丢失,但这不代表转置卷积一定优于插值,后面训练代码里我会给出另一种选择。
跳跃连接是UNet区别于FCN的核心设计。它把编码器第i层输出直接拼接到解码器对应层,让解码器在恢复空间细节时能同时看到浅层的边缘纹理信息和深层的语义类别信息。对遥感影像来说,这条跳跃连接几乎决定了小目标的分割质量——屋顶边缘、道路边界这些精细结构的信息主要存在浅层特征里,如果没有跳跃连接,解码器只能靠上采样插值来猜测边界位置,结果就是分割图边缘像锯齿一样不平整。
需要提醒的是,UNet的层数选择不是越多越好。常用的深度是4次下采样,对应特征图尺寸从原始分辨率降到1/16。如果你用ResNet34或ResNet50做编码器主干,下采样次数可能不一样,跳跃连接的对齐方式也要相应调整。用5次下采样时,1/32特征图对于小目标来说可能已经丢失了太多细节,在遥感场景里反而不如4次下采样效果好。
2.3 损失函数选择:交叉熵、Dice Loss与Focal Loss
损失函数是训练分割模型最容易忽略却影响最大的一个环节。交叉熵对每个像素独立计算损失然后取平均,计算简单、梯度稳定,但它在类别不平衡时完全失效——如果背景占95%,模型全预测背景就已经有5%的错误率,再往其他类别上优化反而可能拉升总loss,于是模型陷入局部最优不动了。
Dice Loss的思路是直接优化分割结果与真实标注的重叠度,公式是2倍交集除以并集。它对类别不平衡不敏感,即使某类只占1%也能参与训练。但Dice Loss的梯度在预测概率接近0或1时容易变得非常陡峭,训练前期模型还处于乱猜阶段时,loss可能震荡得很厉害。实践中我见到最多的情况是:单独用Dice Loss训练,前几十个epoch loss像心电图,然后骤降,属于需要耐心等的那种。
Focal Loss是在交叉熵基础上给难分样本加权的方案,通过调整聚焦参数gamma让模型更关注那些预测概率低的像素。对于遥感图像中大量存在的难例——阴影遮挡的房屋、雨后泥泞的道路——focal loss通常比普通交叉熵更稳。实际项目里最常见的做法不是三选一,而是把交叉熵和Dice Loss按1:1加权组合,兼顾稳定性和不平衡鲁棒性。这个组合在公开的遥感分割数据集上是验证过最不挑超参的方案。
3. 数据准备与预处理:跑通UNet之前真正花时间的环节
3.1 环境搭建:版本选择背后的兼容性考虑
在写任何模型代码之前,先把环境固定下来。遥感分割项目最常用的框架搭配是Python 3.8或3.10,PyTorch 1.12到2.x,配合CUDA 11.3或12.1。如果你的电脑是NVIDIA显卡,最好安装GPU版PyTorch,训练速度差一个数量级,CPU跑UNet不是不能跑,但训练一个epoch可能要等十几分钟,调参体验非常痛苦。
我的建议是直接用conda创建一个独立环境,python版本选3.10,避免某些依赖包在3.11以上版本还有兼容问题。安装命令如下:
conda create -n unet_seg python=3.10 -y conda activate unet_seg pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy opencv-python pillow matplotlib tqdm tensorboard pip install scikit-learn pandastorch和torchvision的版本必须匹配,否则import会直接报错。如果你已经装了CPU版想换GPU版,需要先pip uninstall torch torchvision再重装,不要直接覆盖。数据集如果是TIFF格式的遥感影像,还需要装gdal或rasterio来读取,这两个库的安装依赖系统底层库,Windows下建议直接用pip install rasterio,它会自动拉预编译的wheel包。
3.2 遥感数据集的获取与预处理管线
公开的遥感分割数据集里,最常见的是建筑、道路、水体这几类地物,因为标注相对明确。以某公开的航空影像建筑分割数据集为例,图像是RGB三通道,对应标注是单通道的类别掩膜。做毕设时选择公开数据集是最稳妥的,自己标注遥感图像的像素级标签工作量巨大,一块512x512的影像标注一栋建筑的轮廓就要十几分钟。
拿到数据后的第一件事是检查图像和标注是否对齐。我见过数据集里个别样本的图像是错的但标注对得上,或者图像旋转了标注没跟着转的现象。写个简单的脚本把所有样本过一遍,把图像和标注叠在一起保存成对比图,半透明叠加看一眼就能发现问题。
import cv2 import numpy as np from pathlib import Path img_dir = Path('data/images') mask_dir = Path('data/masks') save_dir = Path('data/overview') save_dir.mkdir(exist_ok=True) for img_path in img_dir.glob('*.png'): name = img_path.stem mask_path = mask_dir / f'{name}.png' if not mask_path.exists(): print(f'{name}: mask missing') continue img = cv2.imread(str(img_path)) mask = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) if img.shape[:2] != mask.shape[:2]: print(f'{name}: size mismatch {img.shape} vs {mask.shape}') continue colored = cv2.applyColorMap(mask, cv2.COLORMAP_JET) overlay = cv2.addWeighted(img, 0.6, colored, 0.4, 0) cv2.imwrite(str(save_dir / f'{name}.jpg'), overlay)这段代码做三件事:检查标注是否存在、检查尺寸是否一致、生成半透明叠加预览图。如果标注缺文件或尺寸对不上,后面训练时DataLoader会在随机裁剪时报错,提前发现能省很多时间。叠加图建议全部人工扫一遍,重点看有没有标注错位和类别标错的情况。
预处理管线里还有一个关键参数是裁剪尺寸。遥感影像通常很大,直接resize到小尺寸会丢失小目标,不resize又放不进显存。常规做法是随机裁剪固定大小的图像块,常见选择是256x256或512x512。512x512对显存要求高,8GB显存跑UNet加batch size 4已经比较吃力;256x256则可能让大目标被切碎,上下文不足。没有绝对正确答案,我的经验是先用256试试,确认loss能收敛再考虑加大。
3.3 数据增强:遥感分割必做的五类操作
数据增强不是锦上添花,而是让小样本遥感分割跑起来的必要步骤。遥感图像有明确的"上帝视角"特性,物体朝向没有统一的"上下",所以几何增强要比自然图像更激进。我总结必备五类:随机水平翻转、随机垂直翻转、随机旋转90度/180度/270度、随机亮度对比度扰动、随机缩放裁剪。
但有几个操作要特别谨慎:随机旋转任意角度(不是90度的倍数)会引入插值伪影,边界会变得模糊;随机ranslate平移幅度过大可能把目标挪出图像块;颜色抖动范围太大会让分割模型把颜色当成唯一线索,真实场景泛化反而变差。把这些增强封装成一个类,挂在训练集的Dataset里。
import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform = A.Compose([ A.RandomCrop(256, 256), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2(), ]) val_transform = A.Compose([ A.CenterCrop(256, 256), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2(), ])这里用的是albumentations库,它对分割任务的特殊之处在于所有增强操作会同步应用到图像和标注掩膜,不需要你手动对齐坐标变换。Normalize的参数用的是ImageNet的均值和标准差,这是很多遥感分割项目的默认选择。要注意的是,如果你的训练集整体色调和自然图像差别很大——例如只有红外波段或只有灰度图——就不要硬套ImageNet统计值,直接算自己数据集的mean和std会更好。
4. 从零实现UNet遥感分割:训练主流程与必调参数
4.1 模型定义:从UNet到带有BatchNorm的稳定版本
网上能搜到的UNet实现版本很多,但很多是直接搬运原始论文代码,缺少BatchNorm和合适的初始化,训练起来非常不稳定。我给出的版本是在经典结构上做了三处必要改动:每个卷积块后加BatchNorm、激活函数从ReLU换成LeakyReLU、上采样用双线性插值替代转置卷积。
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, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.LeakyReLU(0.1, inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.LeakyReLU(0.1, inplace=True), ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels=3, num_classes=2): super().__init__() self.enc1 = DoubleConv(in_channels, 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.Upsample(scale_factor=2, mode='bilinear', align_corners=False) self.dec4 = DoubleConv(1024, 512) self.up3 = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False) self.dec3 = DoubleConv(512, 256) self.up2 = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False) self.dec2 = DoubleConv(256, 128) self.up1 = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False) 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], 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)模型的核心结构是4层编码、4层解码。编码器每层输出通道数翻倍,解码器每层先把特征图放大再和对应编码层按channel维度拼接,所以解码器输入通道是上采样输出通道加跳跃连接通道之和。比如dec4的输入是bottleneck上采样后的1024通道加e4的512通道,共1536通道,第一个卷积改成1536正好输出512通道。
batch size和图像尺寸决定显存需求。这个模型参数量约3100万,输入512x512的图,batch size设为4时大约需要9到10GB显存。如果你的卡只有6GB,把输入尺寸降到256并把batch size设为2,基本能跑;再不够就换成输入尺寸256、batch size 1加梯度累积。注意BatchNorm在batch size为1时统计量不稳定,表现会明显变差,至少用2。
4.2 数据加载器与训练主循环:评估指标别只用准确率
训练代码最需要写清楚的是评估部分。很多同学只知道用准确率(Accuracy)评模型,但分割任务里如果背景占95%,准确率90%以上可能模型什么都没学会。语义分割的标准评估指标是mIoU(mean Intersection over Union),计算每个类别的预测结果与标注的交并比再取平均。这个指标对不平衡类别更敏感,也是大多数遥感分割论文和竞赛用的指标。
import torch from torch.utils.data import Dataset, DataLoader import numpy as np class SegDataset(Dataset): def __init__(self, img_dir, mask_dir, transform=None): self.img_paths = sorted(list(img_dir.glob('*.png'))) self.mask_paths = sorted(list(mask_dir.glob('*.png'))) assert len(self.img_paths) == len(self.mask_paths) self.transform = transform def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = cv2.imread(str(self.img_paths[idx])).astype(np.float32) / 255.0 mask = cv2.imread(str(self.mask_paths[idx]), cv2.IMREAD_GRAYSCALE) mask[mask > 0] = 1 # 二值分割:背景0,前景1 if self.transform: augmented = self.transform(image=img, mask=mask) img = augmented['image'] mask = augmented['mask'].long() return img, mask def compute_miou(pred, target, num_classes=2): pred = torch.argmax(pred, dim=1) ious = [] for cls in range(num_classes): p = (pred == cls) t = (target == cls) intersection = (p & t).sum().float() union = (p | t).sum().float() iou = (intersection + 1e-6) / (union + 1e-6) ious.append(iou.item()) return np.mean(ious)Dataset的__getitem__里做了一件事:把标注掩膜中所有非零像素统一改成1。这是因为数据集里有的标注背景标0、前景标255,有的前景标1,不做二值化统一的话模型输出两个类别但标注有三个值,mIoU会乱套。如果你的数据集有多个类别,就把这句去掉,改成mask = torch.from_numpy(mask).long(),同时把model的num_classes改成实际类别数。
训练循环中除了常规的前向和反向传播,还需要注意两个细节:保存最佳模型用mIoU而不是loss,因为loss下降不代表分割效果好;每个epoch结束后在验证集上评估,验证时不要开梯度计算和数据增强,图片要center crop而不是random crop,保证结果可比。
model = UNet(in_channels=3, num_classes=2).cuda() criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) best_iou = 0.0 for epoch in range(50): model.train() train_loss = 0.0 for images, masks in train_loader: images, masks = images.cuda(), masks.cuda() outputs = model(images) loss = criterion(outputs, masks) optimizer.zero_grad() loss.backward() optimizer.step() train_loss += loss.item() model.eval() val_ious = [] with torch.no_grad(): for images, masks in val_loader: images, masks = images.cuda(), masks.cuda() outputs = model(images) val_ious.append(compute_miou(outputs, masks)) mean_iou = np.mean(val_ious) if mean_iou > best_iou: best_iou = mean_iou torch.save(model.state_dict(), 'best_unet.pth') print(f'Epoch {epoch}: loss={train_loss/len(train_loader):.4f}, mIoU={mean_iou:.4f}')优化器选择Adam是遥感分割训练里最省心的方案,初始学习率1e-4基本不需要调整,比SGD对学习率的敏感度低很多。CosineAnnealingLR让学习率从1e-4沿余弦曲线降到接近0,与手动每20轮衰减一半相比能更好收敛。训练50轮在小数据集上已经能看出明显效果,如果数据集超过5000张图,可以增加到80到100轮。每轮epoch后打印loss和mIoU,如果mIoU连续10轮不升反降就加载best_unet.pth继续调,不要跑完所有epoch才去看结果。
4.3 训练中的关键参数经验值
第一个要盯的是损失函数的收敛曲线。交叉熵loss在前几个epoch从较高的数值快速下降是正常现象,如果loss不下降或下降极慢,先检查学习率是否过大导致震荡,再看数据增强是否太激进让模型学不到稳定特征。学习率从3e-4到1e-3对Adam来说是常见范围,我从1e-4起步,跑20轮后如果loss停在某个值不动,再降学习率到5e-5续跑。
第二个容易被忽略的是图像归一化位置。我见过有人把归一化写在DataLoader外面,读图时直接除以255,但到了GPU上又做了ImageNet归一化,等于归一化了两次。归一化只需要做一次,要么在transform里做,要么在Dataset里做,不要两边都做。
第三个是类别权重的设置方法。如果选择用交叉熵而不做加权,可以在损失函数里传一个weight参数,权重按类别像素占比的倒数来设。计算方式很简单:统计训练集每类像素总数,用总像素数除以每类像素数就得到权重向量。例如背景占80%、前景占20%,权重就是100/80=1.25和100/20=5,前景权重远大于背景,模型会更卖力地学习前景区域。
5. 遥感分割避坑指南:5个高频翻车现场与排查方法
5.1 模型预测输出全黑或全白
现象:训练过程loss在下降,但预测图全部是背景,或者全部是前景,完全没有任何结构信息。这个翻车现场在初学者项目里出现频率最高。
原因:根本原因是类别极端不平衡,背景像素占比过高,模型发现全预测背景就能把loss压到很低,于是钻了这个空子。另一个常见诱因是标注掩膜里的类别值和模型输出约定不一致,例如模型用softmax输出2类,标注里却混着255这样的像素值,导致计算交叉熵时把255当成一个额外类别。
解决:先在验证集上单独算每个类别的IoU,确认模型是不是只学了一个类别。然后用加权交叉熵或Dice Loss替换普通交叉熵,按像素占比倒数设置class weight。最后检查标注像素值,打印np.unique(mask),把非0非1的值全部映射掉。做完这三步再重训,通常问题能解决。
5.2 训练loss下降但验证mIoU不涨
现象:训练集的loss从2.0降到0.1,可验证集mIoU一直卡在0.4上下,怎么调都不涨。这种过拟合现象在遥感分割项目里很典型。
原因:没有正常泛化的原因有两个方向:一是数据划分有问题,验证集和训练集来自同一张大幅影像的相邻切片,训练时模型已经"见过"验证集中区域的特征;二是数据增强不够,模型把颜色分布当成分类依据,到验证集上遇到底层颜色不同的同类目标就崩溃。还有一类隐蔽原因是验证集太小,几十张图的mIoU波动很大,看不出真实水平。
解决:基于地理区域的划分方式是按图切块划分而不是按随机样本划分,类似大数据集的切割方式。同一幅来源影像的切块都放到同一集合里,避免空间信息泄漏。数据增强上增加随机亮度扰动,让模型不再依赖具体颜色。验证集至少保留200张图,否则mIoU的标准差太大,看不出优化效果。
5.3 预测结果出现明显的棋盘格子或斑块
现象:预测图在物体边缘呈现出整齐的方块状纹理,像马赛克;或者分割结果里出现不应该存在的小斑块,像是噪声。这类翻车属于模型结构问题而非数据问题。
原因:使用转置卷积上采样时,卷积核如果不能被步长整除,会导致重叠区域权重不均匀,产生棋盘效应,这是转置卷积的固有特性。小斑块则通常是CRF后处理或softmax温度设置不当导致的,也可能是模型最后一层卷积输出通道数不对,被view操作强行reshape产生的错乱。
解决:最直接的办法是把我前面模型代码里的nn.Upsample(mode='bilinear', align_corners=False)换成双线性上采样,再用一个3x3卷积做语义融合,这样能消除棋盘现象。如果一定要用转置卷积,可以先把特征图四倍上采样到一个大尺寸再做卷积,而不是直接两倍两倍地堆叠。小斑块问题可以用简单的形态学后处理,对预测概率图做一次中值滤波或开闭运算,能消掉大多数孤立噪点。
5.4 显存溢出OOM,batch size调成1还是崩
现象:8GB显存,batch size设为4直接报CUDA out of memory,改成2也崩,改成1勉强能跑但训练速度慢到怀疑人生。
原因:这个问题大概率不是batch size本身的问题,而是输入图像尺寸太大了。常见误区是直接用原始遥感切片做训练,比如2048x2048的图完全不裁剪,那batch size为1也需要大量显存,更何况UNet中间特征图的channel数很大,显存占用在编码器最深处达到峰值。另一个容易忽略的地方是PyTorch的默认benchmark模式没有开启,导致卷积选择的算法不稳定且浪费显存。
解决:先把输入尺寸固定为256x256或512x512,用random crop切块再进模型。然后开启torch.backends.cudnn.benchmark = True,这行代码能让cuDNN自动选择最优卷积算法,显存占用和速度都有改善。如果还是不够,可以用梯度累积模拟更大的batch size:每批次算完梯度不立即更新,累积几步再更新,显存开销不变,但等效batch size变大,训练更稳。
5.5 毕设论文写作时发现代码结果和论文结论对不上
现象:跑通了代码也出了分割图,但论文里的实验数据怎么都复现不出来,补实验的时候发现模型参数没保存,或者某个类别的IoU和初稿里写的不一样。这个坑很多人到答辩前一周才意识到。
原因:根源在于训练和实验记录不规范。跑实验时不记录随机种子、不固定数据划分方式、不记录每次实验的配置参数,导致两次训练结果有差异。有些情况下是验证时用了不同的预处理管线,归一化统计量或裁剪方式不一致,模型性能自然不同。这些都是训练代码里模棱两可的参数设置导致的。
解决:从第一天训练就养成分步保存的习惯:固定随机种子,保存完整训练配置到配置文件中。我自己的标准做法是把模型参数、优化器参数、数据划分方式、随机种子、最终mIoU一并记录在项目目录下,每条实验记录一个配置文件。答辩复现实验时直接加载配置文件重跑,绝对不会出现"上次的结果和这次不一致"的问题。
6. 把项目从60分做到90分:消融实验与特殊场景验证
一个UNet跑通训练流程并输出预测图,对毕设来说只完成了基础部分。有区分度的项目通常围绕三个方向做增量:编码器换主干、损失函数改进、后处理优化。从这三个方向入手,每个方向做一个改动并记录mIoU变化,论文的"消融实验"章节就有着落了。
第一个值得做的是把编码器从原始的4层卷积换成带有预训练权重的ResNet34或ResNet50。UNet的编码器是从零训练的,没有ImageNet预训练特征,收敛慢且最终精度偏低。ResNet50作为UNet编码器时,解码器通道需要配合调整,跳跃连接从ResNet的不同stage引出。如果显卡显存够,建议做一组对比:原始UNet vs ResNet34-UNet vs ResNet50-UNet,往往mIoU能提升5到10个百分点。这个实验是遥感分割论文里最常见的可视化材料,也能体现你对模型结构的理解深度。
第二个方向是边界优化。语义分割的一大难点是小目标的边界精度,建筑物边缘多一个像素或少一个像素,在遥感评估里IoU差异很大。常见做法是在解码器的最后一层旁边接一个边界预测分支,用边缘检测的辅助损失约束共享编码器,让网络在学习分类的同时学习"哪里是边界"。这样主分割logits和边界logits共享低层特征,训练收敛后主观视觉上边界会锐利很多,对这个方向的改进可以体现在论文的消融实验表格里。
第三个方向是推理时的滑窗预测。大幅遥感影像在预测时需要切成小块,每个小块独立经过模型再拼接。直接拼接会在小块边界产生明显的拼接痕迹,正确做法是滑窗时设置重叠区域,对重叠部分的预测概率做加权平均,越靠近窗口中心的像素权重越高。这个技巧属于"做了看不见但工整"的工作,论文里可以放一张拼接对比图,说明重叠滑窗对消除边界伪影的有效性。在代码上实现时,思路是在重叠区域用高斯权重合并,每个像素的最终概率 = 该像素被各窗口预测的概率乘以窗口权重再归一化。
此外,还有一个细节容易被忽略:预测的置信度校准。模型的输出logits经过softmax后是概率分布,但遥感分割项目里的"置信度"和真实精度往往不对等。如果模型对某个像素输出0.7的建筑概率,这个像素实际属于建筑的可能性未必是70%。不要直接用0.5作为二分类阈值,可以在验证集上遍历阈值,选mIoU最高的那个作为最终推理阈值。不同类别的类间混淆模式不同,每个类别单独找最优阈值往往还能再提升1到2个点的mIoU。
最后,我始终建议把训练过程的每个中间环节都沉淀为可视化图表:loss曲线、mIoU曲线、连续多个epoch的预测对比图,这些既是调试手段,也是论文中证明方法有效性的重要素材。我自己做这个方向时最深刻的教训是,数据准备阶段多花一周整理,训练阶段能少花一个月调参。所有翻车现场追根溯源,几乎都发生在数据格式和划分方式上,而不是模型结构上。希望这篇文章能帮你把这个方向做扎实,无论是代码实现还是论文写作,都能少走弯路。希望帮到你。
本文还有配套的精品资源,点击获取