ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深度学习场景语义分割实战:从FCN到DeepLabV3的PyTorch实现

深度学习场景语义分割实战:从FCN到DeepLabV3的PyTorch实现 简介面向计算机视觉方向学生的毕设与课程作业场景语义分割项目包聚焦深度学习在像素级图像分类中的完整落地流程涵盖模型设计、数据准备、训练优化与部署关键环节。压缩包共24个文件以10个Python脚本为核心覆盖模型结构定义如SUPnet、训练流程train_SUPnet.py、数据加载与预处理工具data_utils、indoor3d_util等另含6个XML配置、2个TXT说明、2个PNG效果图及工程配置文件大小约820KB结构清晰便于按模块查阅。内容从FCN、U-Net等经典架构出发涉及数据增强、损失函数、评估指标像素准确率、IoU与模型部署转换并补充C高性能推理思路适合需要系统完成课程设计或毕业项目的学习者快速对照实践。已有157人学习下载可作为深度学习语义分割项目从零搭建的参考模板。1. 基于深度学习的场景语义分割毕设和课程作业最常见的落地点毕业答辩倒计时一开始十个同学里有八个都在做图像分类剩下两个里一个做目标检测另一个就是做基于深度学习的场景语义分割。这个任务很直接把图像里的每个像素都打上语义标签路面、天空、行人、墙面各归各。跟目标检测比它要输出像素级掩码更细跟实例分割比它不区分同一类别的不同个体落在本科毕设和课程作业范围内难度刚好能在一学期内憋出来最后还能可视化出一张张彩色掩码图答辩演示效果很好。一个典型的“基于深度学习的场景语义分割”项目压缩包里通常装着模型训练代码、数据预处理脚本、训练好的权重、验证可视化脚本和一份报告文档。这篇笔记按我平时做分割项目的工作流展开从网络选型讲到数据准备、训练参数、踩坑记录让拿到包的人能跑通也能讲明白每一个环节。2. 场景语义分割的网络选型从FCN到DeepLabV3原理和参数一次说清第一次接触场景语义分割的人最容易把“端到端”挂在嘴边却说不清网络为什么要做成全卷积。原因很简单语义分割是像素级分类输入一张 H×W×3 的图像输出的是和输入同尺寸的 H×W×num_classes 概率图。如果沿用分类网络最后用全连接层把所有空间位置压成一个向量每个像素的位置信息就彻底丢了边界自然标不出来。FCN 的核心改动就是去掉全连接层把最后一层改成 1×1 卷积让特征图从“图像描述”变成“逐像素的分类得分”再用转置卷积把低分辨率得分图上采样回原图尺寸。这个改动听起来不大但意味着模型可以接受任意尺寸输入也是所有后续分割模型的基础。2.1 全卷积网络为什么是语义分割的基石FCN 里有两个细节值得盯一下。第一个是上采样方式原版用转置卷积也就是通常说的反卷积。转置卷积有可学习的参数能通过训练自动调整上采样系数但也会带来棋盘格噪声后来的很多实现干脆改用双线性插值比如 DeepLabV3 的最终上采样就是用双线性插值把输出恢复到输入尺寸。第二个是跳跃连接FCN-8s 里把第三、第四个池化层的输出和最终预测融合用来恢复小物体的细节。这个思路后来被 UNet 的编码-解码结构发扬光大只不过 UNet 用拼接而不是相加。现在你回头看 DeepLabV3 的网络结构会发现它依然是“卷积特征提取 空间池化 轻量解码头”的框架只是把编码阶段的降采样策略改了改。理解这点对你调参很有帮助分割模型的难点不是“谁在上采样”而是“在小物体和大物体同时出现时怎么让特征保留足够的空间分辨率”。FCN 的粗糙在于连续池化把分辨率降到原图的 1/32小物体基本被洗没了VOC 上的 mIOU 也只有 60 出头。所以当下做毕设或课程作业很少有人直接用原始 FCN而是把它当 baseline 理解然后用 UNet 或 DeepLabV3 做主力。另一个容易忽略的点是输入分辨率。很多教程默认用 256×256但如果你跑的是街景数据路牌、行人这类小目标占的像素很少256 的输入会让它们在特征图里只剩一两个像素。我一般建议先看数据里最小目标的尺寸再决定输入大小。VOC 用 512 居多Cityscapes 用 769 或 1024UNet 这类医学分割常用 256 到 512。输入尺寸直接影响显存和感受野改模型不如先改这个参数划算。2.2 DeepLabV3 的空洞卷积和 ASPP 模块选型的核心依据DeepLab 系列最大的特点是使用空洞卷积。空洞卷积可以在不增加参数量的前提下扩大感受野简单说就是让一个 3×3 的卷积核“看到”更大的范围。dilation rate2 的 3×3 卷积等效于一个 5×5 感受野rate4 等效于 9×9。VOC 和 Cityscapes 这类街景数据里一辆车可能占几百像素一个人可能只有几十像素单一感受野很难同时照顾两者。DeepLabV3 的 ASPP 模块用四个并行的分支来解决一个 1×1 卷积三个 dilation rate 分别为 6、12、18 的 3×3 空洞卷积再加一个全局平均池化后再 1×1 卷积。四个分支的特征被拼在一起再用 1×1 卷积融合相当于让网络自己决定在哪个尺度上看目标。从工程选型角度看如果你的数据是街景、遥感这类各类别尺度差异很大的场景优先选 DeepLabV3 加 ResNet50/ResNet101 backbone如果数据是医学影像、微小目标UNet 的编码解码结构保留细节更稳而且从头训练效果也不差。下表是我常用的选型参考注意里面 mIOU 是公开数据集上的大致水平不是绝对指标。模型backbone参数体量VOC mIOU约单卡训练建议FCN-32sVGG16约 134M62不推荐新项目UNet自编码约 31M72医学、小数据DeepLabV3ResNet50约 42M81街景、遥感DeepLabV3ResNet101约 59M88追求精度、显存充足需要注意的是DeepLabV3 的 backbone 输出 stride 一般是 8也就是预测图是输入尺寸的 1/8然后靠双线性插值放大回去。1/8 分辨率虽然损失不少细节但大部分时候够用如果物体很小你可以把 backbone 的输出 stride 改成 16 试试特征更密代价是显存更高。torchvision 的实现里这个 stride 直接由最后一组残差块的步长控制修改时要注意同时调整空洞卷积的 rate否则感受野会突然变小。单纯加输入尺寸也能缓解但显存开销更大。2.3 预训练权重和迁移学习的实际用法场景语义分割很少从零开始训练因为 ImageNet 上预训练的 backbone 已经掌握了边缘、纹理、颜色等基础特征能大幅缩短收敛时间。最常见的做法是用 torchvision 里的分割模型直接加载 COCO 预训练权重然后把分类头换成自己的类别数。下面是从 torchvision 加载 DeepLabV3 并适配自定义类别的代码import torchvision num_classes 8 # 你自己数据集的类别数记得加上背景 model torchvision.models.segmentation.deeplabv3_resnet50( pretrainedTrue, num_classes21 ) model.classifier[-1] torch.nn.Conv2d(256, num_classes, kernel_size1) model.aux_classifier[-1] torch.nn.Conv2d(256, num_classes, kernel_size1)这段代码的逻辑是先用 pretrainedTrue 构造一个在 COCO 上训练过的 DeepLabV3COCO 有 21 类包含背景所以分类头输出是 21。然后用一个新的 1×1 卷积替换classifier的输出层把通道数改成自己的 num_classes。aux_classifier是 DeepLabV3 的辅助观测分支训练时它也会输出一个预测图用来合并 loss所以要一起换掉。参数说明model.classifier是一个 Sequential最后一个是 1×1 卷积原实现里in_channels256你的新输出层要保留这个输入通道。如果你的 num_classes 超过 21新的卷积层参数会随机初始化后面的训练需要额外跑几轮才能跟上前面的 backbone。另外pretrainedTrue 会下载权重文件默认存到~/.cache/torch/hub/checkpoints如果下载不顺利就手动下载后放到该目录文件名要跟 torchvision 要求的一致。迁移学习时如果显存紧张可以把 backbone 的前几层requires_gradFalse冻结起来但不要全部冻结否则网络学不到分割特有的空间信息。另一个常见做法是开启aux_lossTrue这个辅助 loss 只在前向时多一个分支对整体收敛有一点帮助代价是显存多一小块可以在环境跑通后再关掉。提示如果加载预训练权重后直接替换分类头记得把新分类头的 bias 初始化为 0weight 用正态分布初始化。torchvision 的 reset_parameters 方法已经做了但你自定义的nn.Conv2d需要手动nn.init.normal_(conv.weight, 0, 0.01)不然第一轮 loss 可能异常大。3. 数据准备与标签处理VOC格式转成训练用的Tensor数据部分是语义分割项目里最脏最累的一步也是最容易让模型“玄学翻车”的地方。绝大多数毕设包用的数据集是 VOC2012 或者自己标注的小数据集。不管哪种你需要先把图片和标签整理成对齐的结构再转换成语义分割训练真正需要的索引掩码最后考虑数据增强。这里每一步的坑我都踩过写出来给你省时间。3.1 数据来源与目录结构先介绍最常见的 VOC2012 目录结构这也是课程作业最常用的数据集。你需要JPEGImages放原图SegmentationClass放分割掩码ImageSets/Segmentation里放训练/验证集的文件名列表如下VOCdevkit/VOC2012/ ├── JPEGImages/ │ ├── 2008_000001.jpg │ ├── 2008_000002.jpg │ └── ... ├── SegmentationClass/ │ ├── 2008_000001.png │ ├── 2008_000002.png │ └── ... └── ImageSets/Segmentation/ ├── train.txt ├── val.txt └── trainval.txttrain.txt 里每一行是图片名去掉后缀的名称比如 2008_000001。多数代码里会直接读这个 txt然后拼接出图片和掩码的完整路径。这里有一个容易踩的坑VOC 的掩码图不是普通 RGB 图片而是调色板模式。如果你直接用Image.open(mask).convert(RGB)读它得到的不是类别索引而是被解释成三个通道的调色板颜色训练起来 loss 会一直卡在不合理的数值上。正确做法是保持 P 模式读入再转成 numpy 数组取到的就是 0~20 的类别索引。from PIL import Image import numpy as np mask Image.open(VOCdevkit/VOC2012/SegmentationClass/2008_000001.png) print(mask.mode) # 输出 P表示调色板模式 mask_arr np.array(mask, dtypenp.uint8) print(np.unique(mask_arr)) # 输出所有出现的类别索引这段检查代码的作用是让你确认掩码的取值是类别索引而不是 RGB 颜色。如果np.unique里出现 255说明 VOC 中有边缘区域被标注为 255也就是“忽略区域”训练时要传给损失函数的 ignore_index 跳过去。熟悉这个数据结构后再迁移到 Cityscapes 或其他自定义数据集就顺手了因为它们的掩码读取方式大同小异只是掩码所在目录不同。Cityscapes 的 gtFine 子目录里还分 labelIds 和 instanceIds用 labelIds 做语义分割就够了。3.2 标签编码把mask的类别值对齐到训练损失数据准备好的下一件事是写一个 Dataset 类把图像路径、掩码路径和对齐逻辑封装起来。很多新手以为只是读图就完事了实际还要做三件事把读进来的掩码从 numpy 数组转成 torch 的 LongTensor、把 255 统一标记为 ignore_index、最后再交给增强函数同步处理。下面是一个最小可用的 VOC Dataset 实现import os import numpy as np from PIL import Image from torch.utils.data import Dataset class VOCSegmentDataset(Dataset): def __init__(self, img_dir, mask_dir, split_file, transformNone, ignore_index255): with open(split_file, r) as f: self.ids [line.strip() for line in f.readlines()] self.img_dir img_dir self.mask_dir mask_dir self.transform transform self.ignore_index ignore_index def __len__(self): return len(self.ids) def __getitem__(self, idx): name self.ids[idx] img Image.open(os.path.join(self.img_dir, name .jpg)).convert(RGB) mask Image.open(os.path.join(self.mask_dir, name .png)) img np.array(img, dtypenp.float32) mask np.array(mask, dtypenp.uint8) # 关键P模式转索引数组 mask[mask 255] self.ignore_index if self.transform is not None: aug self.transform(imageimg, maskmask) img aug[image] mask aug[mask] img torch.from_numpy(img.transpose(2, 0, 1)).float() mask torch.from_numpy(mask).long() return img, mask这里的逻辑要点在于mask np.array(mask, dtypenp.uint8)它拿到的是像素级的类别标签而不是三通道的彩色图。然后mask[mask 255] self.ignore_index把 VOC 的 255 边缘置为 ignore。torch.from_numpy(mask).long()确保掩码是 LongTensor因为nn.CrossEntropyLoss要求目标为 LongTensor且不能是 one-hot。要注意img.transpose(2, 0, 1)是把 HWC 变成 CHW因为 torch 的卷积层默认期望(B, C, H, W)。如果你用了 albumentations 的A.ToTensorV2()它会自动做转置和换类型这里为了保持优雅没有在 transform 里返回 tensor而是在 Dataset 末尾统一转换这样掩码插值问题也更容易排查。如果你的ignore_index不是 255一定要同时改损失函数里的参数否则两处设置不一致边缘区域会被当成真实类别参与梯度。3.3 数据增强与归一化的同步更新语义分割里的增强技巧跟分类不同对图像做翻转、裁剪、缩放掩码必须做完全相同的几何变换而颜色抖动、归一化只能作用于图像不能作用在掩码上。albumentations 是目前最方便的工具它自动保证这种情况同步。训练、验证两套变换如下import albumentations as A train_transform A.Compose([ A.RandomResizedCrop(size(256, 256), scale(0.5, 2.0), ratio(0.75, 1.33)), A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(p0.3), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225), max_pixel_value255.0), ]) val_transform A.Compose([ A.Resize(256, 256), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225), max_pixel_value255.0), ])这段代码在训练时用了随机裁剪、水平翻转和亮度对比度调整验证集只做等比压缩和归一化。A.RandomResizedCrop的 scale 参数控制随机裁剪面积在原图 50% 到 200% 之间ratio 控制宽高比这样模型能经常看到不同尺度的目标对场景语义分割很有用。A.Normalize用的均值方差和 ImageNet 预训练模型一致因为 backbone 在 ImageNet 上预训练过输入分布不统一会在开头几个 epoch 拉低收敛速度。时刻记得A.Compose内部已经知道 image 和 mask 是同步处理的所以你在 Dataset 里用aug self.transform(imageimg, maskmask)就行不要自己先对图片做 resize 再对 mask 做 resize。如果不得已要用 torchvision 的 Resize一定要给 mask 显式设置interpolationImage.NEAREST防止双线性插值把类别索引插成小数造成训练掩码出现非法标签。另外如果原图尺寸和训练尺寸差太多比如原图 1024训练 256建议先用滑动窗口抽样或在 RandomResizedCrop 的 scale 里把裁剪范围调大否则模型看到的多是局部纹理学不到全局结构。4. 训练流程与关键参数用PyTorch跑通一个最小可用的基线网络结构和数据准备好了接下来就是训练。我这里用一个 PyTorch 的最小训练脚本做例子目标是让你把 DeepLabV3 在 VOC 上跑通而不是追求最高的 mIOU。关键在四个点DataLoader、损失函数、优化器和训练循环。每一个点都有参数要调我给出常用的那组。4.1 数据集类与加载器使用上一章的 Dataset 类直接构造训练集和验证集然后给 DataLoader 配置参数from torch.utils.data import DataLoader train_dataset VOCSegmentDataset( img_dirVOCdevkit/VOC2012/JPEGImages, mask_dirVOCdevkit/VOC2012/SegmentationClass, split_fileVOCdevkit/VOC2012/ImageSets/Segmentation/train.txt, transformtrain_transform, ) val_dataset VOCSegmentDataset( img_dirVOCdevkit/VOC2012/JPEGImages, mask_dirVOCdevkit/VOC2012/SegmentationClass, split_fileVOCdevkit/VOC2012/ImageSets/Segmentation/val.txt, transformval_transform, ) train_loader DataLoader(train_dataset, batch_size8, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue) val_loader DataLoader(val_dataset, batch_size8, shuffleFalse, num_workers4, pin_memoryFalse)DataLoader 的参数里面shuffleTrue只用于训练集验证集必须保持顺序否则会影响评估的确定性。num_workers一般设置为 CPU 内核数的一半Windows 上不要设太大否则容易报多进程错误。pin_memoryTrue在训练时会加快显存拷贝但如果机器内存不足反而可能拖慢。drop_lastTrue是避免最后一个 batch 的尺寸不齐特别是有 BatchNorm 层时。如果你的显存只有 4GBbatch_size8很可能会爆。一个常见技巧是梯度累积先跑完几个小 batch 再更新一次参数。假设你想用等效 batch size 32显存只支持 8就每 4 个小 batch 做一次优化器 step。代码里需要改训练循环下面一并给出。还有一个容易忽略的设置是torch.backends.cudnn.benchmarkTrue当你的输入尺寸固定时它能帮 cudnn 选择最快的卷积算法对训练提速有帮助如果输入尺寸不固定这个开关反而会浪费时间建议让它保持 False。4.2 损失函数交叉熵与类别权重语义分割最常用的损失就是逐像素交叉熵PyTorch 的nn.CrossEntropyLoss直接支持像素级多分类。它内部先对每个像素做 softmax再计算交叉熵目标必须是 LongTensor不能是 one-hot。遇到类别不平衡直接默认加权的方式会给背景过大的权重因为背景像素数量常常是前景的几倍甚至十几倍。常见做法是统计训练集每个类别的像素频率然后计算中位数频率权重import numpy as np import torch.nn as nn def compute_class_weights(mask_dir, split_file, num_classes21): with open(split_file, r) as f: ids [line.strip() for line in f.readlines()] counts np.zeros(num_classes, dtypenp.float64) for name in ids: mask_path os.path.join(mask_dir, name .png) mask np.array(Image.open(mask_path), dtypenp.uint8) counts np.bincount(mask[mask num_classes].flatten(), minlengthnum_classes) median np.median(counts[counts 0]) weights median / counts.astype(np.float64) weights[counts 0] 1.0 return torch.from_numpy(weights.astype(np.float32)) class_weights compute_class_weights(...).cuda() criterion nn.CrossEntropyLoss(weightclass_weights, ignore_index255)逻辑说明这里用中位数频率权重而不是直接反比是为了防止某个极高频类别把权重压得太低导致训练不稳。ignore_index255让 VOC 里的边缘和难例区域不参与 loss 计算。如果你的自定义数据集没有 255把 ignore_index 设成 21 或某个不会出现的数字也行但要保证 mask 里不会真的出现这个值。参数说明weight的长度必须等于类别数顺序要跟模型输出的通道顺序一样。如果你发现模型一直在预测背景优先检查权重是否生效比如输出类别 mean 是否过小。另一个高阶方案是使用 OHEM也叫在线困难样本挖掘只回传 loss 最高的 20% 像素的梯度这种做法在类别不均衡场景下比权重更稳定但入门项目不必追求先把权重调好就够了。如果你用的是 DeepLabV3 的 aux_lossweight参数要同时传给两个损失分支否则 aux 分支的不平衡问题会带偏早期梯度。4.3 优化器、学习率调度与训练循环优化器我建议用 SGD虽然 Adam 收敛快但语义分割在 SGD 下跑久一点mIOU 通常更高。常用的学习率是 0.01配合多项式衰减而不是固定的 decay 步长。下面是一个完整的训练循环model.cuda() optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.PolynomialLR( optimizer, total_itersnum_epochs, power0.9) num_epochs 30 accumulation_steps 4 # 等效batch_size 8 * 4 32 for epoch in range(num_epochs): model.train() total_loss 0.0 optimizer.zero_grad() for i, (imgs, masks) in enumerate(train_loader): imgs imgs.cuda(non_blockingTrue) masks masks.cuda(non_blockingTrue) outputs model(imgs) if isinstance(outputs, dict): out outputs[out] else: out outputs loss criterion(out, masks) if isinstance(outputs, dict) and aux in outputs: loss 0.4 * criterion(outputs[aux], masks) loss loss / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad() total_loss loss.item() * accumulation_steps scheduler.step() print(fepoch {epoch}, loss {total_loss / len(train_loader):.4f})逻辑说明PolynomialLR会在每个 epoch 后把学习率按power0.9逐步衰减到接近 0比线性衰减更适合分割。outputs[out]是主分支outputs[aux]是辅助分支aux loss 乘以 0.4 加进总 loss让前期的梯度信号更丰富。梯度累积的核心是loss loss / accumulation_steps然后达到累积步数后再optimizer.step()。注意scheduler.step()在每个 epoch 结束后调用而不是每个 batch。参数说明SGD 的 momentum 固定 0.9weight_decay 5e-4。如果你的 batch size 不是 8学习率要做线性缩放batch size 翻倍lr 也翻倍比如 batch size 32 时 lr0.02。训练 30 个 epoch 在 VOC 上差不多能收敛如果时间紧可以只跑 15 个 epochmIOU 会有差距但能演示。训练过程中如果 loss 突然变成 NaN大概率是学习率太大或者输入里有 NaN先检查数据流。训练完不要立刻保存模型要保存权重和 optimizer 状态方便继续训练和评估。常用torch.save({ model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), }, checkpoint.pth)这比单独保存 model.state_dict() 更好因为你可以在中途恢复训练而不需要重新初始化学习率。恢复训练时用torch.load(checkpoint.pth)读进来再model.load_state_dict(ckpt[model_state_dict])就继续了。如果你的实验需要对比多个模型建议每次保存时把 mIOU 和 epoch 记进文件名避免覆盖掉最好的权重。5. 避坑指南语义分割里最常翻车的5个细节这一章把我在做毕设和课程作业时亲身踩过的坑整理了一遍。下面每一条都按“现象 → 原因 → 解决”来写你在复现时如果撞上可以直接对号入座。5.1 标签错位mask的类别序号和训练设置不一致现象训练 loss 能降但在验证集上可视化预测图整个乱串原本是汽车的像素被预测成行人而且预测颜色整体偏色mIOU 长时间在 0.3 以下。原因最典型的是把 VOC 的分割掩码当 RGB 图片读入然后又用 0-255 的灰度值当类别号。VOC 掩码的调色板里某些前景类别索引并不等于 RGB 值比如类别 1 是汽车但 RGB 存储可能不是 1。一旦你convert(RGB)后再np.array数值变成了颜色通道值类别就漂了。解决按 3.2 节的方法保持Image.open的 P 模式直接np.array(mask, dtypenp.uint8)。如果是从别人代码里继承的数据先跑一遍np.unique(mask)确认索引范围比如 VOC 是 0-20Cityscapes 是 0-33。出现 255 表示 ignore 区域。养成这个检查习惯比事后调半天参数都省时间。5.2 图像和mask的resize不同步现象训练时 loss 正常但验证集 mIOU 低得离谱而且验证 loss 和训练 loss 差别很大。原因用 torchvision 的Resize单独处理图片和 mask图片用了默认双线性插值mask 没指定interpolationNEAREST。双线性插值会在掩码边界上产生 1.5、2.7 这样的小数导致掩码中出现无效的类别标签。解决优先用 albumentations 同步处理它是同一个 transform 作用于 image 和 mask不会出现插值冲突。如果只能用 torchvision必须写成transforms.Resize((size,size), interpolationPIL.Image.NEAREST)用于 mask。另外在 Dataset 里拿到增广后的 mask 后做一个mask mask.round().long()防一手这属于“后悔药”能拦截非法标签。不过最好还是从根源上避免。5.3 类别不平衡模型只预测背景现象训练 loss 一直下降但预测图里全是背景色前景物体一个都没标出来mIOU 在 0.1 以下。原因VOC 里背景像素占了绝大多数交叉熵的梯度被背景类别主导。没有类别权重时模型最优策略就是“全都猜背景”因为它这样能让 loss 降到很低。很多课程作业代码不处理权重最后就是这种结果。解决按 4.2 节计算中位数频率权重加到CrossEntropyLoss的weight参数里。如果你想更快看到效果也可以在构造 Dataset 时做类别采样只从包含小物体的图片里抽样本。一个简单的做法是训练时对每张图片随机裁剪裁剪窗口里如果有目标区域保留的概率更高这相当于用数据增强来平衡效果也不错。至少先检查训练集每个类别的像素占比把占最大的类别和占最小的类别对比一下。5.4 GPU显存不足batch size调不下来现象运行训练脚本时提示CUDA out of memory把 batch_size 降到 2 还是爆代码作者又没有给替代方案。原因DeepLabV3 在num_classes21下每张 512×512 的输入会产生相当大的中间特征图尤其是残差 block 的 stage4 输出通道数很大。再加上aux_loss会多一份特征显存翻倍。很多人一上来用 512 尺寸和 num_workers 12机器跟不上。解决按优先级依次处理先用A.Resize(256,256)把输入降到 256关掉 aux_loss也就是训练时model deeplabv3_resnet50(..., aux_lossFalse)少一份辅助分支再不行就把 DataLoader 的 batch_size 设成 1用梯度累积来模拟大 batch。代码在 4.3 里已经写了累积循环这里说下关键参数accumulation_steps 4表示 4 个小 batch 才更新一次显存占用约等于 batch_size 1但梯度和 batch_size 4 差不太远。另外把pin_memory关掉也可能省一点内存但影响很小。5.5 评估mIOU时漏掉ignore_index和背景现象验证集 mIOU 看起来很高比如 0.9但是可视化预测图明显有很多错误分数和实际表现对不上。原因评估时直接把预测的 argmax 结果和 ground truth 做全局对比把 255 的边缘区域也算进去了或者没有排除背景。由于 255 区域不参与训练模型在这些像素上通常是随机的如果把它当普通像素计算会额外引入很多错误或正确命中导致指标失真。另外背景类别在很多课题里不算 mIOU只看前景平均挨个计算时应该屏蔽掉。解决正确计算 mIOU 的做法是逐类别统计confusion matrix对每个类别 c计算intersection / union最后对除背景外的所有类别取平均。计算时要设置一个valid mask ! 255然后把预测结果和真实值都过滤到这个 valid 区域否则 255 像素会影响统计。下面这段计算代码可以直接用def compute_miou(pred, mask, num_classes21, ignore_index255): pred pred.argmax(dim1).cpu().numpy().flatten() mask mask.cpu().numpy().flatten() valid mask ! ignore_index pred pred[valid] mask mask[valid] ious [] for cls in range(num_classes): if cls 0: # 背景不算按需调整 continue pred_inds (pred cls) gt_inds (mask cls) inter (pred_inds gt_inds).sum() union (pred_inds | gt_inds).sum() if union 0: ious.append(float(nan)) else: ious.append(inter / union) return np.nanmean(ious)注意这里跳过了背景类别如果你的任务希望把背景也算进去把if cls 0去掉即可。对于某些类在验证集没有出现union 0时跳过不参与平均这是语义分割评估的惯例。另外pred.argmax(dim1)要求模型输出已经是(B, C, H, W)的概率图如果你的模型头输出是(B, H, W, C)记得先转通道顺序。6. 验证与进阶计算mIOU并用可视化结果判断模型好坏除了数字指标我强烈建议在验证时同时保存预测可视化图。因为 mIOU 是一个宏观分数很多小区域被淹没了比如道路边缘的细线、天空和楼房的交界。可视化能让你直接看出模型在哪些结构上失效。保存可视化结果最简单的方法是把预测的索引图转成 VOC 调色板颜色再和原图并排保存def colorize_mask(mask, palette): color_mask np.zeros((mask.shape[0], mask.shape[1], 3), dtypenp.uint8) for cls, color in enumerate(palette): color_mask[mask cls] color return color_mask然后对验证集跑前向保存一份pred.png。实际项目里你会发现loss 降到 0.2 并不等于边界清晰边缘处的像素本来就在类别之间这是语义分割的固有难题。想提高分数可以试试多尺度推理同一个输入分别用 0.75、1.0、1.5 倍缩放送进模型把三个概率图双线性采样到原图尺寸再取均值。这个技巧在单个模型上通常能涨 1-2 个点的 mIOU不用改训练代码。我自己的习惯是先跑一批 30 个 epoch 的 baseline记录 mIOU然后只改一个变量比如换 backbone 或加多尺度推理对比曲线再往下走。毕设那会儿我为了省时间跳过可视化结果 loss 降得很好看实际上模型把白色墙面全预测成了天空那一幕到现在还记得。所以我的教训是每 5 个 epoch 存一次预测图花不了几分钟却能在答辩前拦住最离谱的翻车。希望这些步骤能帮你把场景语义分割的 baseline 稳稳跑起来再在这个基础上做文章。本文还有配套的精品资源点击获取
返回列表