简介:面向遥感影像智能解译的PyTorch语义分割项目实践资源,基于高分遥感数据完成地物分类,适合算法工程师、科研人员及深度学习初学者参考。压缩包共858个文件、约548MB,含35个Python脚本,覆盖模型训练、膨胀预测、后处理及半监督伪标签方法,并加入TensorBoardX可视化输出;819张PNG图像保存了遥感样本、预测结果、融合对比和标注叠加图,便于直观评估分割效果;另有CSV清单与README说明。已有3040人学习,这套资料的价值在于把理论实现与工程化细节结合,既有可直接运行的源码和说明,也有丰富的预测对比图,能帮助读者快速复现高分遥感地物分类流程,并深入理解半监督策略与后处理调优。读者可对照样例数据观察模型逐步输出,学习如何利用膨胀预测改善目标边界、以后处理消除细小噪声,并通过伪标签拓展无标注数据的使用场景,从而掌握从数据准备到结果可视化的完整工程链路。
1. PyTorch实现高分遥感语义分割:一份能直接落地的地物分类工程
用PyTorch做高分遥感语义分割,第一个坑往往不在模型,而在数据组织方式。高分二号这类卫星影像单景动辄上万像素,整图喂进网络先爆显存,切成小图又会在边缘丢上下文,预测结果像打了补丁。这份项目实践把GF2多光谱影像切成约380像素的样本块,用U-Net做地物分类训练,推理时用膨胀预测消除边缘伪影,再配合伪标签半监督和TensorBoardX可视化,恰好把这条链路完整走通了。如果你正在做遥感语义分割课程设计、人工智能大作业,或者刚入门遥感加深度学习想找一个骨架工程,这份源码包值得拆开看看。
这里有个反直觉的点:项目里真正值钱的不是U-Net本身,而是样本怎么切、预测怎么拼、类别不平衡怎么处理。同样的网络结构,这些细节直接决定mIoU是0.6还是0.82。下面从数据准备、模型搭建、训练配置到推理后处理和半监督扩展,逐层拆解这份项目,并给出可复现的代码和踩坑记录。
2. 数据准备:GF2影像切块、标签对齐与类别分布
2.1 先看懂GF2文件命名与数据组织
拿到项目第一件事不是跑训练,而是把文件命名读明白。项目根目录下有GF2_PMS2_E116.0_N39.1_20170302_L1A0002214760-MSS2.csv、classes_sample.jpg以及一批GF2_PMS2_E116.0_N39.1_20170302_L1A0002214760-MSS2_NNN.png格式的影像块。GF2指高分二号卫星,PMS2是卫星搭载的两台多光谱相机中的第二台;E116.0_N39.1是影像中心经纬度,在东经116度、北纬39.1度附近;20170302是成像日期,L1A表示数据经过了辐射校正;MSS2对应多光谱传感器,包含蓝、绿、红、近红外四个波段。文件名最后的三位数字是切块编号,说明原始大图已经被切成了若干块用于训练和推理。
CSV文件通常记录的是每个样本的名称、标签路径或类别统计信息。建议打开CSV先做一次探索性分析,统计每类的像素占比:
import pandas as pd from PIL import Image import numpy as np df = pd.read_csv("GF2_PMS2_E116.0_N39.1_20170302_L1A0002214760-MSS2.csv") print(df.head()) print(df.columns.tolist()) label_paths = df["label"].tolist() class_pixels = {} for lp in label_paths[:20]: mask = np.array(Image.open(lp)) for cls in np.unique(mask): class_pixels[cls] = class_pixels.get(cls, 0) + (mask == cls).sum() print(class_pixels)这段代码帮你快速掌握两个关键信息:CSV字段结构,以及每个地物类别在样本中的像素占比。拿到类别比例后才能决定要不要做类别权重或重采样。如果某一类占比低于5%,训练时大概率会被多数类淹没,后面损失函数部分会专门处理。
classes_sample.jpg是类别样例图,建议打印出来对照着看。地物分类的标签一般用整数索引,比如0代表背景、1代表水体、2代表植被、3代表建筑、4代表道路、5代表裸地,具体含义以项目里的类别表或README说明为准。遥感影像的标签容易有错标和漏标,先看样例图能帮你判断标签质量,质量差的话再好的模型也白搭。
2.2 构建Dataset:图像、标签与transform同步
写语义分割训练,第一个要避免的翻车是图像和标签没有用同一个transform。随机翻转时只翻了图像,标签没翻,模型会在错位的监督信号里反复震荡。正确做法是把所有几何增强同时作用到img和mask上:
import torch from torch.utils.data import Dataset class GF2SegDataset(Dataset): def __init__(self, df, img_dir, mask_dir, transform=None): self.df = df self.img_dir = img_dir self.mask_dir = mask_dir self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img = Image.open(f"{self.img_dir}/{row['image']}") mask = Image.open(f"{self.mask_dir}/{row['label']}") if self.transform: img, mask = self.transform(img, mask) return img, mask实际项目里transform建议用albumentations库,它原生支持img和mask同步变换,比手写torchvision的Compose省心很多。归一化操作只需要对图像做,mask保持整数标签即可。
有一个细节容易忽略:PyTorch的CrossEntropyLoss要求标签是long类型且取值在0到num_classes-1之间。很多人在读取mask后忘了转dtype,训练到一半报RuntimeError,原因就是float类型被当成了回归目标。在Dataset返回前强制加上mask = torch.as_tensor(np.array(mask), dtype=torch.long),能省掉后面一半的排查时间。
2.3 滑窗切块:重叠切块是膨胀预测的基础
高分影像整图推理不现实,滑窗切块是最常见的做法,膨胀预测就是重叠切块在推理阶段的延伸。切块参数有两个:patch_size决定单次输入尺寸,stride决定块与块之间的步长。stride等于patch_size时不重叠,速度快,但预测图块间会出现拼接缝;stride小于patch_size时块与块有重叠,每个像素被多个块预测过,取平均后边缘过渡自然。
import numpy as np def sliding_window(img, patch_size=256, stride=128): h, w = img.shape[:2] patches = [] coords = [] for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): patches.append(img[y:y+patch_size, x:x+patch_size]) coords.append((y, x)) # 处理右边缘和下边缘的残余区域 if (h - patch_size) % stride != 0: for x in range(0, w - patch_size + 1, stride): y = h - patch_size patches.append(img[y:y+patch_size, x:x+patch_size]) coords.append((y, x)) if (w - patch_size) % stride != 0: for y in range(0, h - patch_size + 1, stride): x = w - patch_size patches.append(img[y:y+patch_size, x:x+patch_size]) coords.append((y, x)) if (h - patch_size) % stride != 0 and (w - patch_size) % stride != 0: patches.append(img[h-patch_size:h, w-patch_size:w]) coords.append((h-patch_size, w-patch_size)) return patches, coords这段代码的关键是边缘处理。slide遍历从0开始,如果原图尺寸不是patch_size的整数倍,右侧和下侧会残留一条窄带。我的习惯是先把原图pad到patch_size的整数倍,padding模式选reflect,遥感影像的零填充会在边界引入一圈黑边,影响归一化后的统计量。切块尺寸建议和训练时保持一致,比如训练用256,推理也切256,不要训练一个尺寸推理另一个尺寸,否则分布偏移直接反映在mIoU上。
2.4 数据增强的取舍:别把光谱特征增强没了
遥感语义分割的增强策略和自然图像不完全一样。几何增强相对安全:水平翻转、垂直翻转、随机旋转90度的整数倍都不会破坏地物语义,可以放心加。随机旋转任意角度要慎重,因为遥感图像虽然有旋转不变性,但切块后任意角度旋转会引入插值噪声,边缘出现混叠。
颜色增强要区分场景。RGB合成图上做亮度、对比度、饱和度抖动,对提升泛化有一定帮助;但对多光谱波段做随机颜色抖动会破坏地物光谱特征。近红外波段对植被和水体的响应非常敏感,把近红外通道的像素值随机调亮调暗,模型学到的可能不是地物本身,而是亮度伪影。
我常用的遥感增强组合是:随机水平翻转加随机垂直翻转,加随机旋转90度的倍数,加微量的高斯噪声。最后一项强度控制在sigma=0.01以内,目的是让模型对传感器噪声更鲁棒,增强过度反而会掩盖地物边缘。
3. 模型搭建:U-Net结构与PyTorch实现细节
3.1 选型对比:为什么是U-Net而不是DeepLabV3
地物分类可选的语义分割模型很多,DeepLabV3+在Cityscapes这类自然图像上表现强势,但放在GF2遥感小样本场景里,U-Net往往更实用。首先是参数量,基础版U-Net约30M参数,DeepLabV3+用ResNet101做backbone直接到60M以上,样本量只有几百张时大模型更容易过拟合。其次是细节保留,U-Net的跳连接把编码器浅层的空间细节直接拼到解码器,建筑边缘、道路边界这类精细结构的恢复效果比ASPP空洞卷积更直接。
DeepLabV3+的优势是多尺度感受野,但遥感影像的地物尺度跨度极大,一栋房子可能占20个像素,也可能占200个像素,仅靠ASPP的固定膨胀率覆盖不全。U-Net通过逐层下采样天然构建了多尺度特征金字塔,配合跳连接做特征融合,对尺度差异的容忍度更高。最后是改造灵活度,U-Net加注意力模块、换backbone、做深度监督都很容易,作为课程设计和项目实践,调试成本低很多。
3.2 核心实现:双卷积块与跳连接
这里给出一个标准U-Net的PyTorch实现,通道配置采用经典的64到1024结构:
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.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), 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, base_channels=64): super().__init__() self.enc1 = DoubleConv(in_channels, base_channels) self.enc2 = DoubleConv(base_channels, base_channels * 2) self.enc3 = DoubleConv(base_channels * 2, base_channels * 4) self.enc4 = DoubleConv(base_channels * 4, base_channels * 8) self.pool = nn.MaxPool2d(2) self.bottleneck = DoubleConv(base_channels * 8, base_channels * 16) self.up4 = nn.ConvTranspose2d(base_channels * 16, base_channels * 8, 2, stride=2) self.dec4 = DoubleConv(base_channels * 16, base_channels * 8) self.up3 = nn.ConvTranspose2d(base_channels * 8, base_channels * 4, 2, stride=2) self.dec3 = DoubleConv(base_channels * 8, base_channels * 4) self.up2 = nn.ConvTranspose2d(base_channels * 4, base_channels * 2, 2, stride=2) self.dec2 = DoubleConv(base_channels * 4, base_channels * 2) self.up1 = nn.ConvTranspose2d(base_channels * 2, base_channels, 2, stride=2) self.dec1 = DoubleConv(base_channels * 2, base_channels) self.out = nn.Conv2d(base_channels, 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)关键参数有三个。第一个是in_channels,GF2如果只用RGB三波段就设3,如果用满四个多光谱波段就设4,具体由你的数据决定。第二个是num_classes,对应地物类别数量,根据CSV类别表来,常见是5到8类。第三个是base_channels,默认64,如果显卡显存只有4G以下,把base_channels改成32,模型参数量直接降到原来的四分之一,mIoU下降通常不超过1到2个点,对小样本数据集影响很小。
conv层里bias=False是因为后面接BatchNorm,BN自带偏置项,卷积再加bias属于冗余。解码器上采样用的是ConvTranspose2d,kernel_size=2,stride=2,输出尺寸正好翻倍。
3.3 尺寸对齐问题:奇数尺寸引发的血泪史
U-Net对输入尺寸有要求,每层MaxPool2d(2)会让特征图尺寸减半,如果输入是奇数尺寸,到深层后会出现上采样尺寸和下采样特征尺寸不一致的情况。比如输入257像素,经过四次pool变成16像素(257除以2向下取整三次),再上采样回256,和编码器分支的16像素差1,torch.cat时直接报size mismatch。
规避办法是在Dataset初始化时把图像统一resize到偶数尺寸,比如256或384。不要resize到380这种看起来是偶数但不一定是2的4次方倍数的尺寸。最稳妥的做法是让输入尺寸能被16整除,因为U-Net编码器有4次下采样,256、384、512都是安全选择。项目里的影像块编号正好是380,380不能被16整除,如果原版工程直接用了380尺寸,切块代码里应该隐式做了padding或resize,自己复现时注意这一点,否则会卡在维度不匹配上。
4. 训练配置:损失函数、评估指标与TensorBoardX可视化
4.1 损失函数:CrossEntropy加Dice的组合
地物分类中直接用CrossEntropyLoss会遇到类别不平衡问题。GF2影像里背景和裸地可能占了60%以上,建筑、水体这类关键地物占比很低,模型会倾向于把一切都预测成多数类。常见做法是给CE Loss加类别权重,权重取类别频率的倒数,再用Dice Loss辅助:
class CEDiceLoss(nn.Module): def __init__(self, num_classes, weights=None, ignore_index=255): super().__init__() self.ce = nn.CrossEntropyLoss(weight=weights, ignore_index=ignore_index) self.num_classes = num_classes self.ignore_index = ignore_index def forward(self, pred, target): ce_loss = self.ce(pred, target) pred_softmax = torch.softmax(pred, dim=1) dice_loss = 0.0 valid_classes = 0 for cls in range(self.num_classes): p = pred_softmax[:, cls, ...] t = (target == cls).float() if (t.sum() == 0): continue intersection = (p * t).sum() dice_loss += 1 - (2 * intersection + 1e-6) / (p.sum() + t.sum() + 1e-6) valid_classes += 1 return ce_loss + dice_loss / max(valid_classes, 1)类别权重weights怎么算?统计训练集所有mask中每类的像素总数,用total_pixels除以(num_classes乘以class_pixels)得到初始权重,再做归一化。注意ignore_index要设置成255或-1,用于跳过伪标签中不可靠的像素,这到半监督部分会用到。
Dice Loss的改进点在于它对类别不平衡不敏感,即使是占比极小的类别,只要有交集,梯度就不会消失。但纯Dice Loss在训练初期容易不稳定,因为预测概率和真实标签完全没有交集时梯度为零,所以用CE加Dice的加权和,让CE负责引导方向,Dice负责细化边界。
4.2 评估指标:用mIoU和每类IoU盯问题
语义分割的论文指标基本都用mIoU,不用像素准确率。像素准确率在类别不平衡时严重失真,背景占80%的情况下模型全预测背景都有80%准确率,但建筑、道路一个没分出来,这样的模型没有实际价值。mIoU对每个类计算交并比再取平均,小类别的表现直接反映到分数上:
def compute_miou(pred, mask, num_classes): iou_list = [] for cls in range(num_classes): p = (pred == cls) t = (mask == cls) intersection = (p & t).sum().item() union = (p | t).sum().item() if union == 0: iou_list.append(float("nan")) else: iou_list.append(intersection / union) return np.nanmean(iou_list) def compute_per_class_iou(pred, mask, num_classes): return [((pred == c) & (mask == c)).sum().item() / ((pred == c) | (mask == c)).sum().item() if ((pred == c) | (mask == c)).sum().item() > 0 else None for c in range(num_classes)]每类IoU比平均mIoU更有诊断价值。如果水体IoU特别低,先去看是不是样本太少;如果道路IoU低,大概率是标签噪声大,道路边缘标注困难导致预测和标签在边缘处对不齐,从而拉低IoU。验证时建议每个epoch计算一次mIoU并记录到TensorBoard,同时把验证集上表现最差的三张图保存下来人工检查,这比单纯看曲线更能定位问题。
4.3 训练循环与优化器配置
训练循环里值得注意的有三个地方:优化器、学习率调度、梯度裁剪。遥感小样本训练用AdamW比Adam稳,weight_decay设1e-4;初始学习率从1e-4起步,用余弦退火或者ReduceLROnPlateau。梯度裁剪是很多人忽略的点,语义分割的loss通常比较大,尤其是Dice Loss在早训练阶段可能出现梯度爆炸,clip_grad_norm_可以有效防止:
import torch from torch.cuda.amp import GradScaler, autocast model = UNet(in_channels=3, num_classes=6).cuda() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) scaler = GradScaler() criterion = CEDiceLoss(num_classes=6) for epoch in range(epochs): model.train() for img, mask in train_loader: img, mask = img.cuda(), mask.long().cuda() optimizer.zero_grad() with autocast(): pred = model(img) loss = criterion(pred, mask) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) scaler.step(optimizer) scaler.update() scheduler.step()混合精度训练在GF2数据上很值得开,推理速度提升明显,但务必用GradScaler配合autocast,否则容易溢出。BatchNorm在训练和推理时行为不同,model.eval()之后BN统计量切换为running_mean和running_var,如果val阶段忘了切model.eval(),预测结果会很不稳定。这个小坑在遥感多类分割上尤其明显,因为输入影像的统计特性差异大。
4.4 TensorBoardX可视化:不只看loss曲线
项目摘要里强调加入了TensorBoardX可视化输出。调用方式如下:
from tensorboardX import SummaryWriter writer = SummaryWriter(log_dir="runs/gf2_seg") writer.add_scalar("loss/train", loss.item(), global_step) writer.add_scalar("iou/val", val_miou, epoch) writer.add_image("input", img_denorm, epoch) writer.add_image("gt", mask_colorize, epoch) writer.add_image("pred", pred_colorize, epoch) writer.add_scalar("lr", optimizer.param_groups[0]["lr"], epoch)可视化mask有个常见翻车:直接把0到5的整数标签传给add_image,显示一整片黑。因为TensorBoard对float类型默认按0到1范围显示,0到5的数值全部超出范围变成白色或黑色。解决办法是手动把mask映射到0到255的RGB伪彩色,或乘以int(255 / num_classes)再转uint8。
| 可视化对象 | 推荐处理方式 |
|---|---|
| 输入影像 | 反归一化后转HWC uint8 |
| 标签mask | 乘255/num_classes转uint8,或转伪彩色 |
| 预测mask | 同标签mask,用同一色彩映射表 |
| 原图+预测叠加 | alpha混合,blend.png就用这种方式生成 |
建议每5个epoch记录一次验证集的可视化结果,不要每个epoch都写,否则TensorBoard文件体积膨胀,加载变慢。项目里提到的blend.png和img_gt_pre.png正是这种可视化的产物,前者把原图和预测混合显示,后者把图像、标签、预测三图并排,用来做课程设计报告插图非常合适。
5. 推理与后处理:膨胀预测、多尺度融合与结果拼接
5.1 膨胀预测:重叠推理消除拼接缝
膨胀预测是遥感语义分割推理阶段最常用的技巧,项目摘要里也明确提到了。思想很简单:推理时切块的步长小于patch尺寸,让相邻块有重叠区域,每个像素被多个块先后预测,最后把多组概率取平均,消除接缝效应。预测时patch边缘通常比中心差,因为卷积感受野跨出了patch边界,缺少上下文,重叠区域取平均能有效缓解边缘质量差的问题。
def predict_expand(model, img_tensor, patch_size=256, stride=128, num_classes=6): model.eval() n, c, h, w = img_tensor.shape prob_sum = torch.zeros((n, num_classes, h, w), device=img_tensor.device) count = torch.zeros((n, num_classes, h, w), device=img_tensor.device) for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): patch = img_tensor[:, :, y:y+patch_size, x:x+patch_size] with torch.no_grad(): prob = torch.softmax(model(patch), dim=1) prob_sum[:, :, y:y+patch_size, x:x+patch_size] += prob count[:, :, y:y+patch_size, x:x+patch_size] += 1 prob_avg = prob_sum / count.clamp(min=1) pred = torch.argmax(prob_avg, dim=1) return predstride的取值直接影响推理质量和速度。stride越小,重叠区域越大,每个像素被预测的次数越多,但推理耗时成倍上涨。常见做法是patch_size=256时stride取128或64,前者每像素被预测4次,后者16次。我的经验是64次的边际收益已不明显,128是性价比最高的选择。如果边缘仍有细线状接缝,可以把stride降到64再对比一次,但不要小于64,再小就是纯堆算力了。
5.2 多尺度推理:一张图预测三遍
膨胀预测解决了空间连续性,多尺度推理解决的是地物尺度差异。同一景GF2影像里,大型水体占几千像素,小路只占几十像素,固定感受野很难同时兼顾。常见做法是把输入缩放到0.75、1.0、1.25倍三个尺度分别预测,再把概率图上采样回原尺寸取平均:
def predict_multiscale(model, img_tensor, scales=(0.75, 1.0, 1.25), patch_size=256): model.eval() _, _, h, w = img_tensor.shape final_prob = torch.zeros((1, num_classes, h, w), device=img_tensor.device) for scale in scales: new_h, new_w = int(h * scale), int(w * scale) scaled_img = torch.nn.functional.interpolate( img_tensor, size=(new_h, new_w), mode="bilinear", align_corners=False ) prob = predict_prob_expand(model, scaled_img, patch_size=patch_size) final_prob += torch.nn.functional.interpolate( prob, size=(h, w), mode="bilinear", align_corners=False ) return torch.argmax(final_prob / len(scales), dim=1)这段代码里predict_prob_expand是上面膨胀预测函数返回概率图的变体。注意多尺度推理会把推理时间乘以3,所以在验证阶段先单尺度验证,确定模型本身收敛了再上多尺度,否则调参效率太低。多尺度通常能提升1到3个点mIoU,不算特别夸张,但对于比赛或课程设计冲刺,这1到3个点可能决定了分数档次。
5.3 结果后处理:去噪、连通域过滤与可视化输出
模型输出的hard mask直接看会有椒盐噪声,个别类别上还有孤立的小碎块。常用后处理链路是:中值滤波、形态学开运算、连通域面积过滤:
import cv2 import numpy as np def postprocess_mask(mask, kernel_size=5, min_area=100): mask = mask.astype(np.uint8) mask = cv2.medianBlur(mask, kernel_size) for cls in np.unique(mask): cls_mask = (mask == cls).astype(np.uint8) cls_mask = cv2.morphologyEx(cls_mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( cls_mask, connectivity=8) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] < min_area: labels[labels == i] = 0 mask = np.where(labels > 0, cls, mask) return mask中值滤波kernel_size取5是折中值,取3去噪能力弱,取7会让道路和细小水体变模糊。连通域过滤min_area要根据影像分辨率调整,0.8米分辨率下100个像素对应约64平方米,实际取多少视地物而定。开运算能去掉小碎块,但窄长地物比如道路和河流容易被开运算切断,我的习惯是先开运算再连通域填充,最后检查道路类是否出现断裂,如果断裂就把kernel从3x3降到1x1,或者只做中值滤波和面积过滤,跳过开运算。
项目目录里提到的blend.png和img_gt_pre.png是结果可视化的两种格式。img_gt_pre是三张图横向拼接:原始影像、真实标签、预测结果。blend.png是把原图和预测结果做半透明叠加,便于观察预测地物和实际地物的贴合程度。生成叠加图的简单做法是:
blend = cv2.addWeighted(img, 0.6, pred_color, 0.4, 0)透明度比例建议原图0.6、预测0.4,预测透明度太高会遮住地物细节,太低又看不清分类边界。汇报和写报告时这两种图都是必备素材。
6. 避坑指南与半监督扩展:伪标签的实践边界
6.1 五个高频踩坑记录
现象:训练loss正常下降,验证mIoU却一直停在0.4左右上不去。 原因:图像和标签没有对齐。读取样本时用了不同的文件排序规则,切块坐标在图像和标签上不统一,模型一直在学错位的数据对。 解决:训练前写一个可视化脚本,把同一个batch的img和mask拼接保存,肉眼检查确认地物轮廓和标签完全重合。切块时用同一套坐标生成图像块和标签块,不要分开读再按顺序配对。
现象:推理结果整体出现偏移,建筑和道路的边缘向同一个方向移动了几个像素。 原因:推理时输入影像做了归一化,但输出预测的尺寸和原始影像不一致。要么是滑窗推理时边缘处理丢失了几行像素,要么是最后resize时用了不同的插值方式导致坐标映射偏差。 解决:推理全程在原始影像的尺寸下进行,记录原始height和width,所有坐标变换在这个基准上做。需要resize时统一用bilinear且align_corners=False,不要混用nearest和bilinear。
现象:batch_size设成了8,GPU利用率却只有30%,训练速度极慢。 原因:CPU数据预处理成了瓶颈,每个epoch实时做随机裁切和增强,GPU在等数据。 解决:把预处理结果先缓存成npy文件或使用lmdb格式,训练时只做翻转和旋转这类轻量增强;DataLoader的num_workers调到4到8,pin_memory设为True。如果显存允许,一次性加载多个batch到显存里做离线增强。
现象:TensorBoard里显示的mask全黑,视觉上完全看不出分类结果。 原因:mask是0到5的整数标签,直接传给add_image被当成了归一化的float图显示,数值都在0到1之外,显示为黑色或白色。 解决:显示前把mask乘以255再除以num_classes转成uint8,或使用matplotlib的colormap映射成RGB伪彩色图,再写入TensorBoard。
现象:加入伪标签数据后,模型mIoU反而明显下降,比只用真实标签还差。 原因:伪标签的置信度过低,错误标注成了训练信号。模型早期的预测本身就有误差,把低置信度预测当成硬标签回灌,相当于把噪声放大。 解决:置信度阈值至少要0.9起步,每轮只选取通过阈值的像素参与loss计算,其他像素用ignore_index跳过。伪标签在总loss中的权重设为0.3到0.5,不喧宾夺主。每5个epoch才更新一次伪标签,避免训练过程震荡。
6.2 半监督方法:伪标签的具体落地步骤
伪标签是半监督语义分割最朴素也最实用的方法,核心流程是三步:用现有模型对无标注影像预测,取高置信度像素作为伪标签,把这些伪标签作为额外训练数据继续训练。项目摘要里的半监督方法对应的就是这条路线。
def generate_pseudo_labels(model, unlabeled_img, threshold=0.9): model.eval() with torch.no_grad(): logits = model(unlabeled_img) prob, pred = torch.max(torch.softmax(logits, dim=1), dim=1) pseudo_label = pred.clone() pseudo_label[prob < threshold] = 255 return pseudo_label伪标签生成后,255标记的像素在训练时通过ignore_index跳过。这里有两个实践要点。第一,阈值不能固定不变,训练初期模型还不准,用0.95的高阈值筛出极少像素;训练中后期模型变强,把阈值降到0.9甚至0.85,让更多像素参与进来。第二,无标注影像要经过和训练数据相同的预处理和切块流程,不能用不同尺寸或不同波段组合,否则伪标签的分布偏移会传导给下游模型。
伪标签在GF2地物分类上的收益通常在2到5个点mIoU之间,前提是无标注影像和训练影像来自同一区域或相近的成像条件。如果无标注影像是完全不同的地理环境,比如用北京地区的模型给上海影像打伪标签,地物光谱分布差异大,伪标签噪声就会盖过收益。做项目实践时,先用同区域影像验证流程,再考虑迁移。
6.3 轻量升级:注意力模块与验证习惯
在伪标签基础上,把U-Net的encoder替换成带空卷的ResNet或EfficientNet骨干是常见的升级路线,但它需要加载预训练权重,对数据集规模和训练时间都有更高要求。更轻量级的选择是在decoder的跳连接处加一个注意力门控,让模型抑制无关区域的特征,突出地物边缘。注意力门控的实现不复杂,在原有DoubleConv之前加一个全局平均池化加全连接的通道注意力即可:
class ChannelGate(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.fc = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(channels, channels // reduction, 1), nn.ReLU(inplace=True), nn.Conv2d(channels // reduction, channels, 1), nn.Sigmoid() ) def forward(self, x): return x * self.fc(x)把这个ChannelGate插入到DoubleConv的输出后面,计算量增加不到5%,但能小幅提升边缘清晰度。这类带轻量注意力的U-Net配合数据增强和伪标签,在GF2数据上通常能把mIoU从0.72推到0.78左右,比盲目堆叠更大的网络效果更稳。
另外一个值得养成的验证习惯是:每次改完一个环节,固定其他变量做对比实验。比如测试膨胀预测时,保持模型权重和后处理完全不变,只切换重叠推理和普通推理,单独记录mIoU变化。这样得到的结果才是可解释的,而不是把所有改进一次性堆上去,最后根本不知道是哪个环节起作用。
从那以后,我每次做遥感语义分割,无论是不是课程设计,都会强制自己走一遍完整流程:先可视化检查数据对齐,再统计类别权重,训练中盯mIoU而不是loss,推理用膨胀预测,最后用伪标签扩样本。即使项目不需要半监督,我也会在推理阶段保留重叠切块,因为边缘连续性的改善是肉眼可见的。希望帮到你。
本文还有配套的精品资源,点击获取