
最早用PyTorch跑语义分割很多人选的路子都是FCN VOC。这个组合经典、数据好找、模型结构也清楚特别适合拿来把整套流程从数据到训练完整串一遍。但说实话它对于新手来说坑也不少数据集格式要处理、上采样的倍数容易算错、255的忽略标签没处理好直接导致mIoU异常。这篇文章我就把从VOC数据准备到FCN训练完的整体流程拆开讲一遍包括我实际跑的时候调过的参数和踩过的坑。适合刚入门语义分割、准备拿PyTorch完整跑一遍分割训练流程的读者有基础的话可以直接跳到自己想看的章节。1. 整体设计思路为什么从FCN VOC开始1.1 FCN在语义分割中的地位FCNFully Convolutional Network是第一个端到端的像素级语义分割网络。和分类网络不一样它最后不是输出一个类别概率而是输出一个和输入尺寸一致的“每个像素的类别预测图”。它的核心贡献在于把分类网络末尾的全连接层替换成卷积层并引入转置卷积上采样解决了全连接层丢掉空间信息的问题。我第一次跑分割实验的时候总觉得分割比分类难很多。真正上手之后才发现FCN把分割任务重新定义为“像素级别的分类”只要把网络结构、损失函数、数据流这三件事搞明白分割训练流程和分类并没有本质差异。而且FCN提供了一个非常扎实的框架后面看UNet、DeepLab这些模型时就有了对比的坐标系很多概念跳跃连接、多尺度融合、上采样策略都能在FCN里找到雏形。1.2 为什么选择VOC作为入门数据集VOC 2012是语义分割领域使用最广泛的数据集之一。它有20个前景类别加上背景一共21类。原始训练集1464张图像验证集1449张每张图片都有像素级标注类别覆盖人、动物、交通工具、室内物品等日常物体。选VOC而不是其他数据集的几个原因标注质量高物体边界标得很精细模型能学到比较清晰的分割边界。类别数量适中既不是两类这种玩具级任务也不像COCO那样类别多到难以调参。数据集规模小单卡就能训练笔记本也能跑非常适合用来验证流程。大量预训练权重和开源代码都以VOC为基准遇到问题很容易对照排查。如果你的目标是发论文或者刷榜单后续可以考虑用SBD增强版本训练图片会多出一个量级。但对入门来说先拿原始数据跑通流程才是关键。1.3 整体实现的流程拆解整个任务可以拆成4个部分也就是我后面几章要讲的内容数据准备VOC目录结构、label格式解析、DataLoader构建。模型搭建VGG16改成FCN尤其是跳跃连接怎么处理。训练配置损失函数、优化器、学习率策略、评估指标。问题排查把训练中常见的坑提前列出来省得踩了再找。这里有个经验想提前说整个流程要当成一个闭环来看数据 - 模型 - loss - 指标 - 可视化。只看loss曲线是不够的必须定期看一眼预测图和标注的差异很多问题只有翻出可视化结果才能明白是模型没收敛还是数据出了问题。2. VOC数据集的准备与预处理2.1 VOC目录结构与label格式解析下载VOC 2012后解压会得到一个VOCdevkit目录。分割相关的内容集中在VOC2012子目录下VOC2012/ ├── JPEGImages/ # 原图JPG格式 ├── SegmentationClass/ # 分割标签PNG格式索引色模式 └── ImageSets/ └── Segmentation/ # train.txt / val.txt / trainval.txtJPEGImages里的原图是普通RGB图片尺寸不固定大部分图片的边长在300到500像素之间。SegmentationClass里的标签文件是PNG格式但和一般理解的三通道PNG不一样它是基于调色板palette的索引图。每个像素存的是类别索引值而不是RGB值。像素值0表示背景1表示飞机2表示自行车以此类推20表示餐桌。读取mask的正确方式是用PILfrom PIL import Image import numpy as np mask np.array(Image.open(mask_path).convert(P))读出来是一个[H, W]的整数数组之后再转成torch.LongTensor就可以算交叉熵了。如果直接用OpenCV的imread去读得到的是三通道彩色数组这时候再想转成索引图就很麻烦。2.2 特殊像素值255的处理VOC的标注里有一个很关键的特殊值255。这个值标注的是物体边界处的模糊区域表示“这个像素不参与训练”。训练时必须把255设置成CrossEntropyLoss的ignore_index否则模型会在这些边界像素上被错误地“教学”导致收敛不稳定或者分割边界一塌糊涂。这一点看起来简单但特别容易被忽略。我第一次跑的时候直接拿原始mask算loss结果mIoU怎么都上不去。后来对照了别人的预处理代码才发现他们都在loss里传了ignore_index255。2.3 数据增强与固定尺寸处理分割任务的数据增强和分类不太一样不能随便裁剪和翻转因为image和mask必须同步处理。我常用的策略随机水平翻转概率0.5image和mask一起翻转。随机缩放scale在0.5到2.0之间image和mask同步resize。固定resize把训练图片统一resize到512x512mask也同步处理。统一resize到512x512是最简单稳定的做法。虽然图片会变形但对入门效果影响不大。另一个常见选择是随机裁剪固定patch比如从原图和mask中同时裁剪512x512的区域。两种方式都能跑但固定resize的代码更少也更不容易出bug。这里有一个必须注意的点resize mask时只能用最近邻插值不能用双线性插值。因为mask是离散的类别索引双线性插值会生成1.2、3.7这种根本不存在的类别值。在torchvision里对应的参数是InterpolationMode.NEAREST。2.4 自定义Dataset与DataLoader构建自己写Dataset时重点是把image和mask的处理流程分开。示例代码如下from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class VOCSegDataset(Dataset): def __init__(self, img_dir, mask_dir, img_list, scale512): self.img_dir img_dir self.mask_dir mask_dir self.img_ids [line.strip() for line in open(img_list)] self.scale scale self.img_transform T.Compose([ T.Resize((scale, scale)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) self.mask_transform T.Compose([ T.Resize((scale, scale), interpolationT.InterpolationMode.NEAREST), T.PILToTensor() ]) def __len__(self): return len(self.img_ids) def __getitem__(self, idx): img_id self.img_ids[idx] img Image.open(f{self.img_dir}/{img_id}.jpg).convert(RGB) mask Image.open(f{self.mask_dir}/{img_id}.png).convert(P) img self.img_transform(img) mask self.mask_transform(mask).squeeze(0).long() return img, maskDataLoader配置时建议设置num_workers大于0pin_memoryTrue。如果不固定resize尺寸DataLoader会因为不同图片尺寸不一致而报collate错误。所以对入门来说统一resize最省心。3. FCN模型搭建与核心实现3.1 从VGG16到全卷积网络的转换FCN的经典backbone是VGG16。VGG16原本由13个卷积层和3个全连接层组成输入要缩放到224x224最后输出1000类。FCN的做法很直接把最后3个全连接层全部替换成卷积层。原来的fc6变成7x7卷积fc7变成1x1卷积fc8变成1x1卷积并且输出通道数改成类别数。这样一个任意尺寸的输入经过网络后输出的是一个通道数等于类别数的“分数图”而不是一维的类别向量。在PyTorch里实现最简单的做法是直接用torchvision里带预训练权重的vgg16保留features部分再对分类头做改造。3.2 FCN-32s / 16s / 8s三种变体VGG16的features部分有5个maxpool所以输入图片经过backbone后特征图尺寸变成输入的1/32。FCN的三种变体区别就在上采样策略FCN-32s在1/32的特征图上做1x1卷积输出21维分数图然后一次上采样32倍恢复原尺寸。FCN-16s把1/32的分数图上采样2倍到1/16同时把第4个pool层的输出也转成21维分数图两者逐元素相加再上采样16倍。FCN-8s继续融合第3个pool层的输出到1/8再上采样8倍。从效果上看FCN-8s最好因为它融合了更多浅层细节信息分割边界更精细。实践里大家基本都是直接跑FCN-8s很少还有人从32s开始做。3.3 上采样层的实现细节实现上采样有两种主流方式。一种是用F.interpolatemodebilinearalign_cornersTrue。这种方式没有可学习参数实现简单不容易出问题。另一种是转置卷积也就是FCN论文里的方式上采样核是可学习的理论上能学到更优的恢复细节。转置卷积做2倍上采样时推荐参数是kernel_size3stride2padding1output_padding1输出尺寸计算公式out (in - 1) * stride - 2 * padding kernel_size output_padding代入之后(in-1) * 2 - 2 3 1 2 * in刚好翻倍。如果想把1/32的特征图直接恢复到原图尺寸可以用大kernel的转置卷积比如kernel_size64、stride32。但工程上更推荐分步组合先做几次2倍上采样最后再用双线性插值补齐。这样参数量更小训练也更稳定。我的习惯是FCN-8s的8倍上采样用一次kernel_size16、stride8、padding4的转置卷积效果稳定。3.4 FCN-8s的PyTorch实现示例下面给一个基于torchvision vgg16的FCN-8s实现骨架重点看跳跃连接的部分import torch import torch.nn as nn import torchvision.models as models class FCN8s(nn.Module): def __init__(self, num_classes21): super().__init__() vgg models.vgg16(pretrainedTrue) self.features vgg.features self.pool3 nn.Sequential(*list(vgg.features)[:17]) # 输出1/8 self.pool4 nn.Sequential(*list(vgg.features)[:24]) # 输出1/16 self.pool5 nn.Sequential(*list(vgg.features)) # 输出1/32 self.fc6 nn.Conv2d(512, 4096, kernel_size7, padding3) self.fc7 nn.Conv2d(4096, 4096, kernel_size1) self.score_fr nn.Conv2d(4096, num_classes, kernel_size1) self.score_pool4 nn.Conv2d(512, num_classes, kernel_size1) self.score_pool3 nn.Conv2d(256, num_classes, kernel_size1) self.upscore2 nn.ConvTranspose2d(num_classes, num_classes, kernel_size4, stride2, padding1) self.upscore_pool4 nn.ConvTranspose2d(num_classes, num_classes, kernel_size4, stride2, padding1) self.upscore8 nn.ConvTranspose2d(num_classes, num_classes, kernel_size16, stride8, padding4) self._init_upscore() def _init_upscore(self): for m in self.modules(): if isinstance(m, nn.ConvTranspose2d): nn.init.kaiming_normal_(m.weight) if m.bias is not None: nn.init.zeros_(m.bias) def forward(self, x): pool3 self.pool3(x) pool4 self.pool4(x) pool5 self.pool5(x) f self.fc6(pool5) f self.fc7(f) f self.score_fr(f) # 1/32 - 1/16并融合pool4 upscore2 self.upscore2(f) score_pool4 self.score_pool4(pool4) fuse_pool4 upscore2 score_pool4 # 1/16 - 1/8并融合pool3 upscore_pool4 self.upscore_pool4(fuse_pool4) score_pool3 self.score_pool3(pool3) fuse_pool3 upscore_pool4 score_pool3 # 1/8 - 原图大小 out self.upscore8(fuse_pool3) return out这个写法在固定输入尺寸的前提下比较稳。注意pool3、pool4、pool5是对features不同阶段的切片为了让代码清晰牺牲了一点重复计算效率实际大规模训练时可以用单路径forward保存中间特征图来优化。3.5 为什么必须加载预训练backboneFCN的backbone直接加载VGG16在ImageNet上的预训练权重收敛速度和最终的精度都会好很多。分割数据集本身不大从头训练一个VGG16级别的backbone几乎不可能达到理想效果。torchvision的vgg16(pretrainedTrue)会自动下载预训练权重省去手动处理的麻烦。这里的经验是backbone部分的学习率应该设得比新加层低一些比如新层lr是0.001backbone就用0.0001。因为预训练权重已经比较好了新加的层是随机初始化两者的梯度量级不一样用一个学习率容易让预训练权重被冲乱。4. 训练配置与关键参数调整4.1 损失函数的选择与实现分割任务最基础的损失就是多分类交叉熵。PyTorch的CrossEntropyLoss内置了softmax计算所以模型输出的logits不需要再做softmax直接把logits和mask传给loss就好。关键参数criterion nn.CrossEntropyLoss(ignore_index255)ignore_index255是必须的。如果类别不均衡很严重可以给CrossEntropyLoss传class_weight参数。但对VOC来说背景和前景的比例虽然有一定偏差但没到需要加权的程度直接跑效果就挺正常。4.2 优化器与学习率策略对于FCN这类全卷积模型经典配置是SGD加momentum而不是Adam。Adam虽然前期收敛快但分割任务最终的精度往往不如SGD。我常用的配置optimizer torch.optim.SGD(model.parameters(), lr0.001, momentum0.9, weight_decay5e-4)学习率策略推荐poly衰减。原理很简单训练后期学习率慢慢变小模型参数更加稳定。公式是lr base_lr * (1 - iter / total_iter) ** 0.9PyTorch里可以用LambdaLR实现也可以在每个step手动更新。我习惯手动更新因为分割任务里iter的计算通常更直观写起来也不复杂。4.3 Batch Size、输入尺寸与显存的权衡这里有一个绕不开的权衡。VOC原图大约500x500如果直接按原尺寸训练显存压力非常大。把输入resize成512x512batch size设为8在24G显存的卡上很顺利。如果只有12G或16G显存batch size降到4再把输入降到384x384通常也能跑。我习惯用下面这个表来快速确定配置配置显存占用适用场景512x512, batch8约20G以上24G显卡512x512, batch4约10G到12G16G显卡384x384, batch8约10G到12G16G显卡256x256, batch8约5G到6G笔记本也能跑resize小一些会掉精度但作为流程验证完全够用。如果显存非常紧张还可以用torch.cuda.amp混合精度训练batch size接近翻倍对精度影响很小。4.4 训练循环与尺寸对齐检查训练循环本身不复杂但有几个容易出错的地方forward后模型输出尺寸是[B, 21, H, W]H和W应该等于输入尺寸。因为FCN-8s经历了8倍下采样和8倍上采样如果转置卷积参数不对输出尺寸就会和输入对不上。loss计算时模型输出和mask的尺寸必须严格一致。如果都把输入resize成512x512两者就都是512x512。训练时调用model.train()验证时调用model.eval()。一个基础训练循环的大致结构for epoch in range(epochs): model.train() total_loss 0.0 for images, masks in train_loader: images images.to(device) masks masks.to(device) # [B, H, W], LongTensor optimizer.zero_grad() logits model(images) # [B, 21, H, W] loss criterion(logits, masks) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) # 手动更新学习率 current_iter 1 lr base_lr * (1 - current_iter / total_iter) ** 0.9 for param_group in optimizer.param_groups: param_group[lr] lr4.5 评估指标与可视化训练过程中要定期做验证。VOC分割最常用的指标是mIoUmean Intersection over Union。每个类别的IoU计算公式IoU 该类别预测正确的像素数 / (预测为该类别的像素数 真实为该类别的像素数 - 预测正确的像素数)代码实现上就是遍历所有类别统计每个类别的交集和并集最后求平均。如果不想手写也可以用sklearn的confusion_matrix来计算但自己写一个计算confusion matrix的函数也就几十行灵活很多。可视化同样重要。我一般把原图、真实mask、预测mask三张图并排保存。预测mask要对logits做argmax然后用VOC的调色板转成彩色图否则直接显示索引图会是一团黑。5. 常见问题与排查技巧实录5.1 loss变成NaN训练过程中loss突然变成NaN常见原因是学习率太大或者数据里有异常值。排查顺序把学习率降到原来的1/10观察是否恢复。检查mask里除了255之外有没有负值或者其他异常值。检查输入图片归一化是否在[0,1]范围内Normalize的mean和std是否设置正确。如果用了混合精度确认amp的配置没有问题。如果以上都检查过还是NaN可以逐步降低batch size试试。极端情况下是某个很特别的样本触发了数值溢出这种时候往往需要换一个数据增强方式。5.2 训练了很久但mIoU很低先不要只盯着loss曲线看一眼预测可视化。如果预测结果几乎全是背景大概率是下面几种情况255没有设成ignore_index模型被边界像素干扰严重。mask读取成了RGB图类别数对不上。模型输出尺寸和mask尺寸不一致导致loss根本没算到对应像素上。学习率太低backbone没有充分适配分割任务。另外如果每一类都有预测但mIoU一直在20以下大概率是数据加载环节出了问题建议把dataloader里的image和mask可视化出来和原文件对比一下。5.3 上采样后输出尺寸不匹配模型输出和mask尺寸对不上基本就是转置卷积参数计算错误。最直接的办法是写一小段测试代码x torch.randn(2, 3, 512, 512) out model(x) print(out.shape) # 期望是 [2, 21, 512, 512]如果不是期望的尺寸检查转置卷积的kernel_size、stride、padding、output_padding。转置卷积输出尺寸公式我再重复一遍out (in - 1) * stride - 2 * padding kernel_size output_padding每次改完参数都跑一下测试能省很多排查时间。5.4 DataLoader的worker报错或collate失败新手比较常见的报错是DataLoader的collate阶段尺寸不一致。因为我前面强调了固定resize这种报错不太容易出现。如果用了随机裁剪要保证所有图片裁剪后的尺寸一致。另外有的教程里用albumentations做数据增强它内部对mask的处理和PIL不同。如果你同时混用了两套框架很容易出现mask索引错乱。建议要么全用PIL要么全用albumentations不要混着来。5.5 训练速度极慢怎么优化如果训练速度慢得离谱逐个检查是否真的在用GPU训练。device有没有设置成cuda模型和tensor有没有都to(device)。验证频率是否过高。比如说每个epoch都验证而验证又很慢那训练会被拖慢。DataLoader的num_workers是否为0。为0的话数据加载会成为瓶颈。训练循环里是否不小心重复加载了预训练权重。我见过有人每个epoch都重新加载一遍backbone权重训练速度自然慢得离谱。预训练权重只加载一次加载完之后就锁定参数或者低学习率微调即可。5.6 一个可以直接抄的参数速查表把前面提到的关键参数整理成一张速查表第一次跑可以完全按照这个来参数推荐值说明输入尺寸512x512与显存直接相关batch_size824G卡/ 416G卡小显存可搭配ampbase_lr0.001SGD下的合适起步值backbone_lr_mult0.1预训练层用小倍率momentum0.9SGD标配weight_decay5e-4VGG常用正则系数epoch数40到60或者按iter计数lossCrossEntropyLoss(ignore_index255)最简单且有效上采样方式转置卷积也可以替换为interpolate代码和参数都不是固定死的但建议第一次跑完全按这组参数来能跑通之后再自己调整。最后分享一个我自己的习惯。第一次跑FCN时我养成了每训练完一个epoch就保存几张预测图的习惯十几轮之后翻出来看能清楚感觉到分割边界从一团糊到逐渐清晰的过程。分割模型的调参最有效的反馈不是loss数字而是可视化结果。你如果后面准备换UNet、DeepLab这套数据加载、评估和可视化的代码几乎都能复用但FCN这一步扎实了后面很多问题都会少走弯路。