简介:基于DeepLabv3+的高分辨率航拍图像语义分割项目,面向计算机视觉方向毕业设计选题,适合具备Python与深度学习基础、希望快速构建语义分割系统的学生。项目以航拍影像地物分类为核心,覆盖道路、建筑、植被等典型分割场景,集成ResNet、HRNet、Swin、Twins、BiSeNetV2等多种骨干网络,并提供DeepLabv3+基线、SAM架构在线与离线对比实验。整个资源包共包含184个文件,主体为95个Python源码脚本和84个pyc中间文件,另有3个Jupyter Notebook用于流程演示与实验记录,以及README说明文档和txt文本,压缩包仅477KB,体积极小,轻量易部署,便于直接修改运行。目前已有204人学习下载。资源内容涉及模型结构搭建、数据加载、训练评估与多网络适配等关键环节,可帮助读者理解DeepLabv3+原理、掌握航拍图像分割的完整实验流程,并可为后续算法改进、毕业设计论文写作提供可直接使用的代码基础。
1. 高分辨率航拍图的语义分割,为什么毕业设计绕不开DeepLabv3+
研二那年拿到一批几千乘几千像素的航拍正射影像,要完成房顶、道路、林地、水体四分类。最开始用FCN跑了一版,边界糊得像水彩晕染,小房子漏检到没法看。换成DeepLabv3+之后,同样数据、同样标注,mIoU直接提了七八个点。这篇笔记就是我把这条路重新走一遍的记录:选型理由、高分辨率影像的切块预处理、PyTorch下ASPP与解码器的实现、训练显存管理、大图滑窗推理,以及一路踩过的坑。如果你正在做或打算做遥感图像语义分割方向的毕业设计,照着顺序跑通一遍,就知道这个方向值不值得投入。
2. 航拍语义分割选型:DeepLabv3+的结构与数据准备
2.1 DeepLabv3+在航拍场景的优势:编码器-解码器与多尺度ASPP
高分辨率航拍影像和自然图像最大的区别在于尺度跨度。一棵树的树冠可能只有十几个像素,一条主干道却横跨整张大图。FCN只有同一层特征直接上采样,小目标的分辨率和上下文两头够不着。U-Net把编码器每层都并给解码器,细节够了,但缺少对上下文多尺度的显式建模。DeepLabv3+恰好是这两条思路的组合:编码器部分用带空洞卷积的ASPP提取多尺度上下文,解码器部分再融合一层低层特征把边界找回来。
ASPP模块里有四路并行:一个1x1卷积、三个rate分别为6/12/18的空洞卷积,外加一个全局平均池化分支。空洞卷积在参数不增加的前提下放大感受野,rate=18那一支能覆盖约8倍于普通3x3卷积的范围,正好应对航拍里"同一个语义类在不同区域的尺度差异"。
低层特征在这里扮演的角色常被忽略。ASPP输出的是stride=16或32的粗糙特征图,直接上采样到原图尺寸,房顶和道路的边界会偏移好几个像素。DeepLabv3+的解码器把stride=4的低层特征用1x1卷积降到48通道后与高层特征拼接,再走一次3x3卷积。低层特征提供的锐利边缘和高层特征提供的类别语义互相补位,这就是它在遥感图像语义分割上比FCN稳定、比U-Net省显存的原因。
| 模型 | 多尺度建模 | 边界恢复 | 训练显存 | 实现复杂度 |
|---|---|---|---|---|
| FCN | 无,依赖深层感受野 | 差 | 低 | 低 |
| U-Net | 弱,靠跳跃连接 | 中 | 中 | 低 |
| DeepLabv3+ | 强,ASPP多rate空洞卷积 | 好 | 高 | 中 |
选型结论很直接:毕业设计要的是可解释性、可调参空间和结果上限。DeepLabv3+在这三个维度上恰好都是问题的入口,ASPP、空洞卷积、解码器融合这些概念拿出来都可以在答辩时讲清楚。
2.2 遥感影像语义分割的数据准备:TIFF转RGB、切patch与标签映射
拿到原始航拍数据,第一步不是写模型,而是看数据是什么格式。常见的高分辨率航拍数据有TIFF多波段文件,可能带R、G、B、NIR四个波段,也可能是16位深度的单波段灰度。直接用PIL或opencv去读这种文件,经常会读到奇怪的颜色,甚至只读到第一个波段。
我一般会先把原始影像统一转成8位RGB的三通道PNG,再进入切块流程。转换用tifffile和numpy就行:
import tifffile import numpy as np import cv2 # 原始TIFF可能是 (波段数, H, W) 的布局 img = tifffile.imread("ortho.tif") if img.ndim == 3 and img.shape[0] in (3, 4): img = img.transpose(1, 2, 0) # 转为 (H, W, 波段数) # 取前三个波段作为RGB,四波段数据一般可以保留NIR后面单独用 rgb = img[:, :, :3] # 16位数据先做线性拉伸再转8位,直接astype会丢失大量细节 if rgb.dtype == np.uint16: rgb = np.clip(rgb / 257, 0, 255).astype(np.uint8) cv2.imwrite("ortho_rgb.png", cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR))这里的核心参数是数据布局和位深转换。tifffile读出来的多波段影像常常是(C, H, W),需要先转成(H, W, C);16位转8位除以257,是因为16位范围是0~65535,除以257刚好映射到0~255,比直接做除法再四舍五入更接近原始亮度分布。
标签文件同样要注意编码。很多公开数据集和人工标注工具导出的标签是RGB颜色索引图,比如(255, 255, 255)代表不透水面、(0, 0, 255)代表建筑物。网络训练需要的是0到k-1的整数索引,颜色图不能直接喂给CrossEntropyLoss。
切patch这一步决定了后面所有训练和推理的体验。航拍原图动辄6000x6000像素,整个图进显存基本不可能,常见做法是切512x512或256x256的patch送入训练:
from pathlib import Path def split_image(image, label, patch_size=512, overlap=64): h, w = image.shape[:2] step = patch_size - overlap patches = [] for y in range(0, h - patch_size + 1, step): for x in range(0, w - patch_size + 1, step): img_patch = image[y:y + patch_size, x:x + patch_size] lab_patch = label[y:y + patch_size, x:x + patch_size] # 过滤掉几乎全背景的patch,航拍数据背景占比经常超过80% if (lab_patch == 0).mean() < 0.95: patches.append((img_patch, lab_patch, x, y)) return patches # 使用示例 train_data = split_image(rgb, label_idx, patch_size=512, overlap=64)切patch时最重要的参数是overlap。overlap=64意味着相邻patch之间有约12%的区域重复,这不会影响训练效率,但能保证patch边缘的语义被多个patch共同覆盖,后续推理时拼接接缝也更好处理。过滤阈值0.95的意思是:如果一个patch里95%以上都是背景类,这个patch对学习贡献很小,直接丢掉,省下训练时间。如果目标区域在图上占比很小,这个过滤阈值要放宽到0.99甚至关闭过滤,否则目标类别可能只剩少量样本。
切完的patch我通常会直接存成.npy文件或写入LMDB,避免每次训练都重新读原始大图做切块。如果数据量不大,也可以直接在Dataset里动态读取,用内存换IO简单度。
2.3 数据增强与类别不均衡:ce_loss加权重还是直接用dice
遥感数据里背景类和目标类往往严重失衡。一片城市航拍图里,建筑物可能有30%,道路10%,树木15%,其余全是不透水面或空地。这种情况下直接用CrossEntropyLoss,模型会倾向于把不确定像素全部预测为高频类。
常见做法有两个方向:一个是对CrossEntropyLoss按类别频率加权重,另一个是直接把损失换成Dice Loss。我一般两者结合,主损失用加权交叉熵,辅助损失用Dice Loss,比例控制在1比1左右。
import torch import torch.nn.functional as F def weighted_ce_loss(pred, label, class_weight): # pred: (B, C, H, W), label: (B, H, W) return F.cross_entropy(pred, label, weight=class_weight) def dice_loss(pred, label, n_classes=6, smooth=1.0): # pred经过softmax后与one-hot标签计算每个类的Dice probs = F.softmax(pred, dim=1) # (B, C, H, W) one_hot = F.one_hot(label, n_classes).permute(0, 3, 1, 2).float() intersection = (probs * one_hot).sum(dim=(2, 3)) cardinality = probs.sum(dim=(2, 3)) + one_hot.sum(dim=(2, 3)) dice = (2 * intersection + smooth) / (cardinality + smooth) return 1 - dice.mean()类别权重怎么算,常见公式是median frequency balancing:统计每个类像素占比,取占比的中位数除以各类占比,最后归一化。直接用1 / class_freq也有效,但会放大像素占比极小的类别,训练早期抖动明显。
数据增强方面,航拍图不像自然图像那样存在大量上下颠倒的问题,但旋转和翻转依然非常有效。90度旋转、水平垂直翻转、随机裁剪是三个最基础也最稳的增强。颜色增强只作用在RGB通道上,不要作用在标签上。如果训练数据里同一地点有多个时相影像,考虑随机亮度扰动有利于增强泛化。
3. 用PyTorch搭建DeepLabv3+:核心模块与训练参数
3.1 从零实现ASPP与解码器:核心代码框架
这里用PyTorch手动实现DeepLabv3+的两个关键模块。不建议直接调torchvision的预训练DeepLabv3+模型,虽然它更快,但答辩时被问到ASPP内部结构会讲不清楚。自己写一遍,结构摆在明面上,改起来也方便。
import torch import torch.nn as nn import torch.nn.functional as F class ASPP(nn.Module): def __init__(self, in_channels=2048, out_channels=256): super().__init__() # 1x1卷积分支 self.branch1 = nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) # 三个不同空洞率的分支 self.branches = nn.ModuleList() for rate in (6, 12, 18): self.branches.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=rate, dilation=rate, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) )) # 图像级全局池化分支 self.pool_branch = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) # 融合后的1x1投影 self.project = nn.Sequential( nn.Conv2d(out_channels * 5, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def forward(self, x): size = x.shape[-2:] # 全局池化分支需要插值回输入尺寸 pooled = self.pool_branch(x) pooled = F.interpolate(pooled, size=size, mode='bilinear', align_corners=False) outs = [self.branch1(x)] for branch in self.branches: outs.append(branch(x)) outs.append(pooled) out = torch.cat(outs, dim=1) return self.project(out)关键参数是padding=rate。空洞卷积输出尺寸不变的前提是padding等于dilation,这里rate取6、12、18,对应的padding也是6、12、18。如果把padding写成普通卷积的1,特征图尺寸会逐层缩小,后面decode阶段对齐就乱了。
align_corners=False同样重要。PyTorch官方插值默认是False,BatchNorm的behavior计算维度也要注意。整个训练过程保持align_corners=False,采样的坐标对齐方式就统一了,否则训练和推理之间会固定差出半个像素,结果就是边缘系统性偏移。
解码器模块负责把ASPP输出和低层特征融合:
class Decoder(nn.Module): def __init__(self, high_channels=256, low_channels=256, out_channels=48): super().__init__() # 低层特征先做1x1卷积降维 self.low_conv = nn.Sequential( nn.Conv2d(low_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) # 融合高低层特征 self.fuse = nn.Sequential( nn.Conv2d(high_channels + out_channels, 256, 3, padding=1, bias=False), nn.BatchNorm2d(256), nn.ReLU(inplace=True) ) def forward(self, high, low): low = self.low_conv(low) high = F.interpolate(high, size=low.shape[-2:], mode='bilinear', align_corners=False) return self.fuse(torch.cat([high, low], dim=1))低层特征的通道数通常是256或512,直接从ResNet的layer1输出取,特征图尺寸是原图的1/4。1x1卷积把这个维度压到48,目的是让低层特征不要在高维通道里喧宾夺主,只作为边界细节的补充。论文里这个数字就是48,增大到64或128时边界反而更碎,因为低层特征里包含太多纹理噪声。
整体模型组装时,骨干网络用torchvision的ResNet-50预训练权重。torchvision的resnet50去掉avgpool和fc之后,从children()里切分:前4个模块是stem,输出stride=4;后面的layer1到layer4输出stride=32。这样切分比手动写ResNet更省事,也不容易抄错结构:
import torchvision.models as models class DeepLabV3Plus(nn.Module): def __init__(self, n_classes=6): super().__init__() backbone = models.resnet50( weights=models.ResNet50_Weights.DEFAULT) features = list(backbone.children())[:-2] self.stem = nn.Sequential(*features[:4]) # stride=4 self.layers = nn.Sequential(*features[4:]) # stride=32 self.aspp = ASPP(in_channels=2048, out_channels=256) self.decoder = Decoder(high_channels=256, low_channels=256, out_channels=48) self.seg_head = nn.Conv2d(256, n_classes, 1) def forward(self, x): low = self.stem(x) # (B, 256, H/4, W/4) high = self.layers(low) # (B, 2048, H/32, W/32) high = self.aspp(high) # (B, 256, H/32, W/32) out = self.decoder(high, low) # (B, 256, H/4, W/4) out = F.interpolate(out, size=x.shape[-2:], mode='bilinear', align_corners=False) return self.seg_head(out) # (B, C, H, W)需要注意ResNet-50输出的低层特征来自stem,这里stem包含conv1、bn1、relu、maxpool,输出的通道数是256而不是论文里的64。64是论文对Xception骨干的取值,ResNet骨干改成256,降维到48即可,不会影响效果。
3.2 训练配置:batch_size、学习率策略与混合精度
配好网络结构后,训练参数直接决定收敛速度和最终精度。DeepLabv3+在航拍数据集上最稳的训练配置如下:
import torch.optim as optim optimizer = optim.SGD( model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4 ) def poly_lr(optimizer, base_lr, iteration, total_iters, power=0.9): lr = base_lr * (1 - iteration / total_iters) ** power for param_group in optimizer.param_groups: param_group['lr'] = lrSGD配合poly学习率衰减是语义分割的主流方案。batch_size根据显存来,512x512输入、ResNet-50骨干下,12G显存可以跑batch_size=8;显存不够就先降到4,再不行降到256patch。注意poly衰减的power取0.9是分割任务的标准值,power=1.0就是线性衰减,实际差不了太多,但0.9能保留更长时间的高学习率,对前期的收敛速度更有利。
混合精度训练在航拍分割里非常值得开,显存直接降三分之一到一半,而且PyTorch的原生AMP封装很好用:
from torch.cuda.amp import GradScaler, autocast scaler = GradScaler() for imgs, masks in train_loader: imgs, masks = imgs.cuda(), masks.cuda() optimizer.zero_grad() with autocast(): preds = model(imgs) loss = criterion(preds, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()AMP有一个坑要注意:BatchNorm在fp16下统计方差比较敏感,所以PyTorch的autocast会自动把BatchNorm保持在fp32。人工实现时不建议自己写half(),直接用autocast上下文是标准做法。如果训练中loss突然变成nan,大概率是梯度溢出,GradScaler会自动跳过这一步,但频繁出现就要调小学习率或者检查数据里是否有异常像素值。
3.3 训练循环与验证:混合损失与最佳模型保存
训练循环本身不复杂,但每一轮结束要验证一次,验证集不参与训练,只用来保存最优模型:
def train_one_epoch(model, loader, optimizer, criterion, scaler): model.train() total_loss = 0.0 for imgs, masks in loader: imgs, masks = imgs.cuda(), masks.cuda() optimizer.zero_grad() with autocast(): preds = model(imgs) loss = criterion(preds, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss += loss.item() return total_loss / len(loader) def evaluate(model, loader, n_classes): model.eval() iou_list = [] with torch.no_grad(): for imgs, masks in loader: imgs = imgs.cuda() preds = model(imgs) preds = torch.argmax(preds, dim=1).cpu().numpy() masks = masks.numpy() iou_list.append(compute_iou(preds, masks, n_classes)) return np.mean(iou_list)这里criterion建议用第2.3节里的加权CE加Dice组合。只加CE,小目标类别容易躺平;只用Dice,整个训练过程会振荡,因为Dice对类别分布非常敏感。我常用的比例是0.6CE + 0.4DiceLoss。如果验证集mIoU超过几个epoch都不动,先检查损失是否还在下降,再把Dice比例加到0.5。
模型保存只保存推理效果最好的那一份,不要看训练loss。判断标准是验证集mIoU,不是某个类别的准确率。每轮训练后算一次mIoU,比历史最优就覆盖保存一次best_model.pth。毕设后期要做实验对比,在模型命名里带上epoch和mIoU两个信息,省得后面翻车。
4. 高分辨率大图推理:滑窗拼接、后处理与评价指标
4.1 重叠滑窗推理与高斯基尼权重融合
训练时用的是512x512的patch,推理时整张大图不能直接喂进模型,需要把大图切成patch逐个推理再拼回去。最简单的做法是滑窗时分块推理然后直接拼图,但这样做patch接缝处容易出现明显的条带,尤其在高分辨率航拍图上,看起来就像马赛克拼贴。
我一般用带重叠的滑窗推理,同时对每个patch的贡献乘一个权重窗口。位于patch中心位置的像素权重高,靠近patch边缘的像素权重低,重叠区域多个patch的概率按权重加权平均,接缝就平滑了。实际实现里常用Hanning窗口近似高斯权重,计算成本低且效果稳定:
import numpy as np import torch def slide_inference(model, image, patch_size=512, stride=448, n_classes=6): h, w = image.shape[:2] prob = np.zeros((n_classes, h, w), dtype=np.float32) weight = np.zeros((h, w), dtype=np.float32) # 二维Hanning窗口作为融合权重 win = np.outer(np.hanning(patch_size), np.hanning(patch_size)) win = win.astype(np.float32) with torch.no_grad(): for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): crop = image[y:y + patch_size, x:x + patch_size] tensor = torch.from_numpy( crop.transpose(2, 0, 1) ).unsqueeze(0).float().cuda() # 归一化到0~1再进网络 tensor = tensor / 255.0 pred = model(tensor) pred = torch.softmax(pred, dim=1).cpu().numpy()[0] prob[:, y:y + patch_size, x:x + patch_size] += pred * win weight[y:y + patch_size, x:x + patch_size] += win # 权重归一化后取argmax prob = prob / (weight + 1e-6) return np.argmax(prob, axis=0)stride=patch_size-overlap,上面stride=448对应overlap=64。如果显存富余,可以把patch调到768或1024,窗口权重更平滑,但需要确认模型能接受任意尺寸输入。模型用的是全局平均池化加卷积,对输入尺寸没有严格限制,所以大尺寸patch可以直接推理。
推理时还有一个容易忽略的问题:原始大图边缘不一定是patch_size的整数倍。上面的代码在滑窗循环里会因range最后一个窗口覆盖不到边缘而导致右下角一圈像素没有预测值。常见的做法是在滑窗前把图padding成patch_size的整数倍,网络里只对预测结果做一次对应的裁剪。最高效省事的是用np.pad把大图四边补上镜像像素,推理后再裁掉。
4.2 预测图后处理:形态学开闭运算与类别过滤
分割模型的输出是每个像素的分类索引。航拍图上会残留两类噪声:一类是孤立的小块错分,比如一个大房子中间冒出一两个树的像素;另一类是细碎的边缘毛刺。对单类别的预测图做形态学开运算和闭运算,能有效处理这些小噪块。
from scipy import ndimage def postprocess_mask(pred, n_classes=6, structure_size=3): # pred: (H, W) 类别索引图 structure = np.ones((structure_size, structure_size), dtype=bool) refined = np.zeros_like(pred) for c in range(n_classes): binary = pred == c # 开运算去孤点,闭运算补小洞 binary = ndimage.binary_opening(binary, structure=structure) binary = ndimage.binary_closing(binary, structure=structure) refined[binary] = c return refined后处理的核心参数是structure_size。3x3可以滤掉单像素噪声,5x5会让边界变得过于圆滑,在道路和房顶这类需要锐利边缘的类别上反而降低精度。我的经验是formask先用3x3,如果跑下来某个类别出现大量碎块,单独对这个类别加大结构尺寸,而不是整体改。
另一个实用后处理是概率阈值过滤。如果网络对某个像素的各类概率都很平均,说明置信度低,这些像素往往集中在类别边界。把softmax最大概率低于0.5的像素标记为不确定区域,再用周围多数类别填充,可以减少边界上的带状错分。这个方法对遥感影像尤其有用,因为水体边界和树冠边缘本来就是模糊区域。
4.3 评估指标计算:mIoU、PA与Kappa
毕设报告里最常用的分割指标是mIoU、Pixel Accuracy和Kappa系数。mIoU是所有类别IoU的平均值,直观反映模型对每一类的分割能力。Kappa系数的特点是能剔除随机一致性的影响,在类别比例极不平衡时比mIoU更能暴露问题。
import numpy as np def confusion_matrix(pred, label, n_classes=6): # pred / label: 展平后的类别索引数组 conf = np.zeros((n_classes, n_classes), dtype=np.int64) np.add.at(conf, (label, pred), 1) return conf def compute_metrics(pred, label, n_classes=6): conf = confusion_matrix(pred, label, n_classes) # 逐类别IoU ious = [] for c in range(n_classes): tp = conf[c, c] union = conf[c, :].sum() + conf[:, c].sum() - tp ious.append(tp / (union + 1e-6)) mIoU = np.mean(ious) # Pixel Accuracy pa = np.diag(conf).sum() / conf.sum() + 1e-6 # Kappa系数 po = np.diag(conf).sum() / conf.sum() pe = (conf.sum(axis=0) * conf.sum(axis=1)).sum() / (conf.sum() ** 2) kappa = (po - pe) / (1 - pe + 1e-6) return { "mIoU": mIoU, "IoU_per_class": ious, "PA": pa, "Kappa": kappa }用np.add.at构建混淆矩阵可以避免双重循环的慢速写法。np.add.at(conf, (label, pred), 1)的括号必须写成元组形式,直接传(label, pred)两个数组,numpy会自动做重复索引累加。如果写conf[label, pred] += 1,重复的索引只会累加一次,结果就错了,这是个小坑。
提交毕设时把每类的IoU列成表格,再附一张混淆矩阵热力图,比只写一个mIoU数字要直观得多。Kappa系数大于0.8通常认为模型一致性很好,大于0.9则说明分割结果和真值几乎一致。迁移到新数据集上时,先看Kappa再看mIoU,Kappa掉得比mIoU多时,大概率是某个占比小的类被模型忽略掉了。
5. 高分辨率分割的避坑清单:python环境、显存与标签的5个翻车点
5.1 python环境配置与PyTorch版本不匹配
现象:代码明明写了model.cuda(),一跑就报RuntimeError: CUDA error: no kernel image is available for execution on the device,或者torch.cuda.is_available()返回False。原因:PyTorch编译时绑定的CUDA版本和机器驱动支持的CUDA版本不一致,常见于python环境配置得太随意,装了一个新版本PyTorch,但驱动停留在旧版本。解决:先用nvidia-smi看驱动支持的CUDA版本,再选择匹配的PyTorch版本。命令行验证环境有效性:
conda create -n seg python=3.8 conda activate seg python -c "import torch; print(torch.__version__, torch.cuda.is_available())"如果打印出True,说明环境没问题;如果一直打印False,优先检查conda环境是否混用了不同python版本的包缓存。毕设阶段不要在系统全局python里倒腾这些包,隔离环境是省时间的首要手段。
5.2 12G显存的翻车:batch_size调到2还是爆显存
现象:训练时显存占用一路冲到11.8G,然后报CUDA out of memory,把batch_size从8降到2仍然报错。原因:显存大户不只是输入batch,ResNet-50中间特征图在512x512输入下每层都有几百兆,而DeepLabv3+的ASPP和上采样路径又会同时保有多个高分辨率特征。batch_size降到2反而把所有中间张量一起放大,因为反向传播需要保存的激活值数量不变。解决:先把输入patch降到256x256跑通流程,显存会降到1/4;开启AMP混合精度(见3.2节);再用torch.utils.checkpoint包装backbone,用计算换显存。真正跑比较的时候再把patch加回来。
5.3 标签图读入后全黑或类别值错乱
现象:训练集加载后,可视化几个样本发现标签图全黑,或者有些图片标签是RGB三通道彩色,有些已经是0~5的索引图,混在一起训练出来mIoU恒等于0。原因:cv2默认imread会把单通道标签也读成三通道BGR,全黑其实是因为标签像素值范围是0~5,直接显示在0~255的LUT下几乎看不见。另一个更隐蔽的原因是标注工具导出的RGB颜色图和索引之间的映射表没有统一,不同文件里同一颜色可能代表不同类别。解决:统一用cv2.imread(path, cv2.IMREAD_UNCHANGED)读取标签,禁止走默认三通道转换链路。在Dataset类的__init__里打印一次label的dtype、unique values和shape,把这三项作为环境自检。如果出现RGB标签,先用颜色字典转成索引再进训练:
# 假设color2idx是标注工具导出的映射表 # {(255, 255, 255): 0, (0, 0, 255): 1, ...} def rgb2index(rgb_label, color2idx): h, w = rgb_label.shape[:2] idx = np.zeros((h, w), dtype=np.int64) for color, class_id in color2idx.items(): mask = (rgb_label == np.array(color, dtype=np.uint8)).all(axis=-1) idx[mask] = class_id return idx5.4 loss不降、mIoU恒为0,所有像素都被预测成背景
现象:训练了十几个epoch,loss稳定在一个不低的值不再下降,验证集mIoU保持0,打开预测图发现整张图全是一种类别。原因:这是类别极度不均衡加上初始化不合理共同作用的结果。背景类占90%以上,模型一开始偏向预测背景,梯度回传时背景类的梯度占绝对主导,小类别被淹没。解决:启用类权重CE加Dice的混合损失(见2.3节),另外检查backbone是否加载了预训练权重。没有预训练权重、用随机初始化ResNet在数据量不足时几乎必然翻车。加载预训练权重后,前5个epoch可以跑一遍warmup,学习率从0到0.01线性增加,大模型的收敛稳定性会明显变好:
def warmup_lr(optimizer, lr_start, lr_max, current_epoch, warmup_epochs=5): if current_epoch < warmup_epochs: lr = lr_start + (lr_max - lr_start) * current_epoch / warmup_epochs for g in optimizer.param_groups: g['lr'] = lr5.5 大图推理后预测图有明显的拼接马赛克
现象:用小patch训练出的模型对整张大图推理,拼出来的结果呈现规则的块状条纹,位置正好对应滑窗的patch边界。原因:推理滑窗时没有overlap,直接硬拼。patch边缘像素感受野与patch中心不同,模型对边缘像素稳定性较差,每个patch边缘的预测结果本来就是噪声。解决:按4.1节使用overlap=64的滑窗加Hanning权重融合。如果用了overlap后还有接缝,检查是否在边界处使用了reflectpadding,模型输入尺寸是512,但最后上采样到原图大小时边缘像素被重复计算了。另一个容易忽略的点是,训练和推理时的align_corners参数必须一致,训练用False推理用True,边界也会系统性偏移。
6. 把训练好的DeepLabv3+用出效果的验证与部署技巧
6.1 特征图可视化:看看ASPP到底学到了什么
毕设答辩时,一张特征图比十句话管用。在ASPP的输出投影层后面挂一个forward hook,把256通道的特征图抓出来,挑激活最强的几张转成灰度图,能看到ASPP对不同尺度目标的敏感度差异。如果某些通道专门在大尺度路面上激活,某些通道专门在细小建筑轮廓上激活,就说明多尺度建模真的起作用了。
features = {} def hook_fn(module, input, output): features['aspp_out'] = output.detach().cpu() model.aspp.project.register_forward_hook(hook_fn) sample = torch.zeros(1, 3, 512, 512).cuda() _ = model(sample) feat = features['aspp_out'][0] # (256, 512, 512) # 取通道均值最大的4个通道 channel_mean = feat.mean(dim=(1, 2)) topk_idx = torch.topk(channel_mean, k=4).indices.tolist() for idx in topk_idx: vis = feat[idx].numpy() vis = (vis - vis.min()) / (vis.max() - vis.min() + 1e-6) # vis 直接存入列表,后面拼图输出特征图不需要追求好看,重要的是能看出"不同通道响应不同的语义区域"。如果所有通道都长得差不多,说明ASPP退化成了普通卷积,这时检查空洞卷积的rate和padding有没有写错。
6.2 出图与最终验证:把分割结果叠加回原图
毕设最终要交的是结果图和指标对比表。预测索引图是黑的,直接放论文里没人看得懂,需要把每个类映射回颜色,再和原图半透明叠加。这个输出脚本值得做得规整一点,因为后面要反复跑不同patch、不同后处理参数的对照实验。
def overlay_result(image, pred_idx, color_map, alpha=0.5): # image: (H, W, 3) 0~255 RGB # color_map: (n_classes, 3) mask_rgb = color_map[pred_idx].astype(np.uint8) overlay = (image * (1 - alpha) + mask_rgb * alpha).astype(np.uint8) return overlay对比实验的保存路径带上参数名,比如pred_s512_o64_post3.npy,否则调了几轮后就会分不清哪个结果对应哪个配置。我自己因为偷懒吃过这个亏,最后重跑了两天真实验,从那以后所有推理结果都带参数后缀。
最后的验证也不要只看mIoU。从原图里挑几个实际场景放大,逐个类别检查错误形态:边界是否偏了半个房子、小目标是否断裂、相邻类别是否互相渗透。这些肉眼可见的问题,往往比mIoU数字更能帮你发现后续改模型的方向。
如果手头有另外一块不同传感器的高分辨率影像,哪怕只有100张左右,也可以在训练好的模型上做个简单微调实验。通常冻结backbone、只训练ASPP和解码器,用较低学习率跑几轮,就能让模型在迁移场景下继续提升。这一步做完了,整个项目的技术闭环就算是完整了,希望帮到你。
本文还有配套的精品资源,点击获取