☰
PyTorch从零实现FCN语义分割:VOC数据加载、模型与mIoU评估
2026/10/22 5:37:38 网站建设 项目流程

跑一条FCN在VOC上并不是什么新鲜事,网上随便一搜就有各种开箱即用的仓库。但如果你想真正理解语义分割这条线,而不是只会调torchvision那两行API,我建议你从头把数据集加载、模型改造、损失计算、评估指标这四块自己写一遍。这篇博文就是按这个思路来的。

我默认你已经会PyTorch的基本操作,知道Tensor、DataLoader、Module这些概念,但对语义分割、FCN、VOC数据集还比较陌生。我会把每一个关键环节都拆开讲,代码直接给,参数直接给,遇到问题怎么排查也直接给。

1. 动手前的全局认识:FCN与VOC的前世今生

1.1 FCN到底解决什么问题

FCN全称Fully Convolutional Network,直译就是“全卷积网络”。在它出现之前,图像分类网络最后都会接全连接层,输出的是一个固定长度的特征向量,最后映射成“这张图是猫还是狗”。但分割任务要的是逐像素的类别标签,比如图像里每一个像素属于“人”、“车”、“背景”中的哪一类。

FCN的核心改动有两个。第一,把网络最后几层全连接替换成卷积层,这样网络就不限制输入尺寸了,而且空间位置信息得以保留。第二,用上采样把低分辨率的特征图逐步恢复到原图分辨率,在每个像素上做分类,输出一张与输入等大的“标注图”。

你可以把这个过程类比成:先通过下采样把整张图压缩成一张带语义的缩略图,再通过上采样把缩略图“放大”回原图,同时给每个像素填上类别。下采样阶段负责“看懂”图像内容,上采样阶段负责把内容“画”回精确位置。

全卷积化还有一个容易被忽略的好处:模型可以接收任意尺寸的输入。分类网络通常会固定输入大小,因为全连接层的维度是写死的,但全是卷积层就完全没有这个约束。训练时可以租到大图,推理时也可以对大图直接过一遍,不需要切块。

1.2 VOC数据集的关键结构

PASCAL VOC是最经典的语义分割基准数据集,其中VOC 2012的语义分割任务包含20个前景类别加1个背景类别,所以模型输出通道数通常是21。类别ID和名称对应关系很常用,建议你存个表:

ID类别ID类别ID类别
0background7car14motorbike
1aeroplane8cat15person
2bicycle9chair16potted plant
3bird10cow17sheep
4boat11dining table18sofa
5bottle12dog19train
6bus13horse20tv monitor

如果你下载过VOC数据集,会发现目录结构大概是:

VOCdevkit/ └── VOC2007/ └── VOC2012/ ├── JPEGImages/ ├── SegmentationClass/ └── ImageSets/ └── Segmentation/ ├── train.txt ├── val.txt └── trainval.txt

JPEGImages里放的是原始图片,SegmentationClass里放的是PNG格式的分割标注。每个标注图的大小和对应的原图一致,但每个像素的值不是普通灰度值,而是调色板索引。这一点特别重要,我后面会专门讲。

ImageSets/Segmentation目录下的txt文件,每一行是图片的文件名(不含扩展名),train.txt是用来训练的图片ID列表,val.txt是用来验证的,trainval是两者合并。

分割任务的常规做法是:用train.txt里的图片做训练,用val.txt做验证。如果你想让效果更好一点,还可以考虑加上标注增强版(比如SegmentationClassAug),但这里我以标准VOC 2012为例讲主流程。

1.3 整体技术路线图

用PyTorch训练FCN,技术链路分成五大块:数据读取、数据增强、模型前向、损失与反向、评估与可视化。数据读取要做到“给一个文件名,返回图像Tensor和标注Tensor”;数据增强要让图像和标注做完全同步的随机变换;模型前向要输出一个[B, 21, H, W]的logits;损失用交叉熵且忽略某些像素;评估用mIoU而不是像素准确率。

这篇博文后面的章节就是按照这条链路展开的。你会发现真正难的不是模型定义,而是那些“图像和mask没对齐”、“标注读完变了颜色”、“loss不降”之类的问题,所以我花了大篇幅讲避坑。

2. 数据集预处理:搭建一个能正确“吃”VOC的DataLoader

2.1 下载与目录整理

VOC数据集有两种常见获取方式,一种是从官网下载,另一种是从开源镜像站下载。下载下来后解压,你只需要关注JPEGImages和SegmentationClass两个目录,以及ImageSets/Segmentation下的txt文件。

我建议你把VOCdevkit放到一个干净的路径下,比如:

data/ └── VOCdevkit/ └── VOC2012/

然后代码里通过环境变量或配置项来指向这个路径,不要写死绝对路径。因为你之后可能在服务器、笔记本、云平台上切换训练,写死路径会带来不必要的麻烦。

2.2 自定义Dataset的关键细节

PyTorch的数据读取核心是torch.utils.data.Dataset。继承它并实现__len__和__getitem__两个方法即可。下面是VOC分割数据集的一个最小实现:

import os import numpy as np from PIL import Image from torch.utils.data import Dataset class VOCSegmentation(Dataset): def __init__(self, root, image_set='train', transform=None): self.root = root self.image_set = image_set self.transform = transform # 拼接出各个子目录路径 self.image_dir = os.path.join(root, 'JPEGImages') self.mask_dir = os.path.join(root, 'SegmentationClass') # 读取文件列表 split_file = os.path.join( root, 'ImageSets', 'Segmentation', image_set + '.txt' ) with open(split_file, 'r') as f: self.ids = [line.strip() for line in f if line.strip()] def __len__(self): return len(self.ids) def __getitem__(self, index): img_id = self.ids[index] img_path = os.path.join(self.image_dir, img_id + '.jpg') mask_path = os.path.join(self.mask_dir, img_id + '.png') image = Image.open(img_path).convert('RGB') mask = Image.open(mask_path) # 图像和mask同步做数据增强 if self.transform is not None: image, mask = self.transform(image, mask) # PIL图像转Tensor,mask转long image = np.array(image).transpose(2, 0, 1) / 255.0 mask = np.array(mask).astype(np.int64) return torch.from_numpy(image).float(), torch.from_numpy(mask).long()

这里的核心注意点有三个:

第一,mask不能用convert('RGB')。VOC的mask是带调色板的PNG,如果用PIL打开后转RGB,你会得到一个三通道彩色图,但里面的像素值完全不是类别ID。正确做法是直接用PIL打开,后面转numpy数组时取到的就是调色板索引,这个索引正好等于类别ID。

第二,mask要转成torch.long。因为交叉熵损失的target要求是整数张量,不是float。很多新手在这里用float,结果报错或者loss为0。

第三,mask和image必须用同一个变换。如果你分别对image和mask做随机翻转,训练基本废掉。下面一节专门讲这个。

如果你把mask直接plt.imshow(),会看到一片黑乎乎的图,那不是bug。因为索引图在用普通灰度色图显示时,0~20的值都很小,看起来当然接近黑色。正确显示方式要先给PIL设置VOC调色板,或者用自定义的colour map。

2.3 数据增强怎么设计才不掉坑

分割任务里最常见的增强是随机翻转、随机缩放、随机裁剪。如果你不想引入太多第三方依赖,自己写同步变换也不难。下面是一个同时处理image和mask的随机裁剪和水平翻转示例:

import random import torchvision.transforms.functional as TF class Compose: def __init__(self, transforms): self.transforms = transforms def __call__(self, image, mask): for t in self.transforms: image, mask = t(image, mask) return image, mask class RandomHorizontalFlip: def __init__(self, p=0.5): self.p = p def __call__(self, image, mask): if random.random() < self.p: image = TF.hflip(image) mask = TF.hflip(mask) return image, mask class RandomResizedCrop: def __init__(self, size=512, scale=(0.5, 2.0)): self.size = size self.scale = scale def __call__(self, image, mask): w, h = image.size # 随机缩放比例 scale = random.uniform(*self.scale) new_w, new_h = int(w * scale), int(h * scale) image = image.resize((new_w, new_h), Image.BILINEAR) mask = mask.resize((new_w, new_h), Image.NEAREST) # 随机裁剪到固定尺寸 i, j, h2, w2 = TF.random_crop_get_params( image, (self.size, self.size) ) image = TF.crop(image, i, j, h2, w2) mask = TF.crop(mask, i, j, h2, w2) return image, mask

关键点很清楚:图像缩放用双线性,mask缩放必须用最邻近插值。如果用双线性缩放mask,类别边界会被插值成小数,产生不存在的类别ID。这一点怎么强调都不为过。

如果训练时输入的图片尺寸小于预设的裁剪尺寸,会报错。VOC里大部分图尺寸大于512,但仍有小概率遇到小图。稳妥的做法是在RandomResizedCrop前先做一次padding,或者把裁剪尺寸设小一些。实际项目里我建议用256或320起步,先把链路跑通,再逐步放大到512。

2.4 DataLoader与批次整理

Dataset定义好之后,构造DataLoader很简单:

from torch.utils.data import DataLoader train_dataset = VOCSegmentation(root='data/VOCdevkit/VOC2012', image_set='train', transform=compose) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4, pin_memory=True)

几个参数的考量:

  • batch_size:FCN吃显存很厉害,尤其输入512x512时。如果显存有限,别硬上大batch,8或4都可以。后面说OOM怎么处理。
  • num_workers:Linux下可以设4或8,Windows下建议1或2,设太大会报DataLoader worker崩溃。
  • pin_memory=True:GPU训练时把数据锁页,传输效率更高,几乎没副作用。

3. 模型搭建:从VGG16到FCN-8s的完整改造

3.1 为什么用VGG16作为backbone

FCN论文里同时实验了AlexNet、VGG16和GoogLeNet,最终效果最好且后续被广泛使用的是VGG16。原因有三点:VGG16层级规整,特征从浅到深非常清晰,pool3、pool4、pool5的下采样倍数分别是8、16、32,天然适合做跳层融合;VGG16的预训练权重在ImageNet上表现好,迁移到分割任务收益大;VGG16结构简单,没有过多分支,便于改造和调试。

VGG16的缺点是参数多、计算量大,训练和推理都比ResNet慢。但作为学习FCN原理的载体,它是最合适的选择。你把它跑通了,再换ResNet做backbone只是替换主干层而已。

3.2 全卷积化的核心改造

标准的VGG16由features部分和classifier部分组成。features是一串卷积和池化,classifier是三个全连接层。要把它改造成FCN,需要做两件事:

第一,丢掉classifier里的全连接层。全连接层的输入是固定维度,一旦保留就限制死了输入尺寸。而且全连接层会丢失空间位置信息,这与分割目标冲突。

第二,用等价卷积层替代全连接层。VGG16原版classifier第一个全连接输入是25088维,对应的是7x7x512的feature map拉平。把它改造成卷积层时,可以使用kernel_size=7、padding=3的卷积,这样当输入是224x224时,经过pool5后的7x7特征图经过它后还是7x7;输入更大时也能保持空间尺寸不塌陷。

不过在PyTorch里,更省事的做法是直接用torchvision里的vgg16模型,取它的features部分用,classifier整个不要。然后在features后面自己接新的卷积层。这样既拿到了预训练权重,又摆脱了固定尺寸限制。

3.3 跳层结构与上采样细节

FCN有三种经典变体:FCN-32s、FCN-16s、FCN-8s。数字代表最终上采样的倍数。FCN-32s直接用pool5后的特征上采样32倍到原图;FCN-16s把pool4的特征也拉进来融合后上采样16倍;FCN-8s再融合pool3的特征,上采样8倍。

跳层融合的意义在于恢复细节。深层的特征虽然语义信息丰富,但经过多次下采样后,位置信息损失很严重,小物体、细边界都糊掉了。浅层的pool3、pool4特征图分辨率更高,保留了更多边缘细节,把它们和深层特征叠加,能显著改善分割边界质量。

下面给出一个精简但可用的FCN-8s实现:

import torch import torch.nn as nn import torchvision.models as models class FCN8s(nn.Module): def __init__(self, num_classes=21, pretrained=True): super().__init__() # 加载VGG16,取features部分 vgg = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1 if pretrained else None) features = vgg.features # 拆成三段:pool3之前、pool3到pool4、pool4之后 self.pool3 = nn.Sequential(*features[:17]) # 到pool3,下采样8倍 self.pool4 = nn.Sequential(*features[17:24]) # 到pool4,下采样16倍 self.pool5 = nn.Sequential(*features[24:]) # 到pool5,下采样32倍 # 深层的分类卷积 self.conv6 = nn.Conv2d(512, 4096, kernel_size=7, padding=3) self.relu6 = nn.ReLU(inplace=True) self.drop6 = nn.Dropout2d(0.5) self.conv7 = nn.Conv2d(4096, 4096, kernel_size=1) self.relu7 = nn.ReLU(inplace=True) self.drop7 = nn.Dropout2d(0.5) self.conv8 = nn.Conv2d(4096, num_classes, kernel_size=1) # 浅层分支的降维卷积 self.conv_pool4 = nn.Conv2d(512, num_classes, kernel_size=1) self.conv_pool3 = nn.Conv2d(256, num_classes, kernel_size=1) self._init_new_layers() def _init_new_layers(self): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.zeros_(m.bias) def forward(self, x): pool3_out = self.pool3(x) pool4_out = self.pool4(pool3_out) pool5_out = self.pool5(pool4_out) # 深层主分支 score = self.relu6(self.conv6(pool5_out)) score = self.drop6(score) score = self.relu7(self.conv7(score)) score = self.drop7(score) score = self.conv8(score) # 32倍下采样,通道数为num_classes # 与pool4分支融合,上采样2倍 score_pool4 = self.conv_pool4(pool4_out) score = nn.functional.interpolate(score, size=pool4_out.shape[2:], mode='bilinear', align_corners=True) score = score + score_pool4 # 与pool3分支融合,再上采样2倍 score_pool3 = self.conv_pool3(pool3_out) score = nn.functional.interpolate(score, size=pool3_out.shape[2:], mode='bilinear', align_corners=True) score = score + score_pool3 # 最终上采样8倍回原图尺寸 score = nn.functional.interpolate(score, scale_factor=8, mode='bilinear', align_corners=True) return score

这里我用双线性插值完成上采样,而不是论文里的转置卷积。实际测试下来,双线性插值加跳层融合的方式训练更稳定,参数更少,效果差距很小。如果你要严格复现论文结构,可以把最后的interpolate(scale_factor=8)换成一个转置卷积层,中间两处也可以用转置卷积。但要注意转置卷积初始化不好容易产生棋盘格效应,建议用双线性核初始化。

需要特别提醒的是,上面代码里features[:17]、features[17:24]这种切片位置取决于torchvision版本。不同版本的VGG16实现里,每个子模块的索引可能略有不同。拿到代码后先打印一下features结构,确认pool3、pool4、pool5对应的是第几个模块,再改切片范围。

3.4 权重初始化与预训练加载

模型里包含两部分参数,一部分是从VGG16迁移过来的backbone参数,一部分是新增的分类与融合层参数。backbone参数直接加载ImageNet预训练权重,不要重新初始化;新增层用kaiming初始化,ReLU激活对应的推荐初始化方式就是kaiming_normal。

我上面的代码里,_init_new_layers会遍历所有卷积层做初始化,包括backbone里的层。这样会覆盖掉预训练权重,所以实际使用时要调整一下:只初始化新增的层,backbone保持不变。一个简单做法是,把_init_new_layers里的for m in self.modules()改成只检查新增卷积层的name in ['conv6', 'conv7', 'conv8', 'conv_pool4', 'conv_pool3']。

如果你真的想偷懒,还有更直接的方式:让整段网络结构从零开始训练而不加载任何预训练权重。但本项目里别这么干。VOC数据量不大,FCN参数量很大,不加载预训练权重的话,模型收敛慢,效果很差。预训练权重带来的提升是决定性的。

4. 损失函数、评价指标与训练配置

4.1 交叉熵与ignore_index

语义分割的损失函数本质上是像素级的交叉熵。模型对每个像素输出21个类别的logits,标签是0到20的整数。PyTorch的nn.CrossEntropyLoss天然支持这种输入输出形式,所以一行就能搞定:

criterion = nn.CrossEntropyLoss(ignore_index=255)

ignore_index=255的作用是让标签为255的像素不参与loss计算。虽然标准VOC标注里没有255,但很多预处理流程会在图像边界补一圈255,或者某些标注版本在物体边缘标了255。保留这个参数能防止意外情况把loss带偏。

交叉熵默认对每个有效像素的loss取平均,而不是对整张图所有像素取平均。这个区别在类别不平衡时很重要。VOC里背景像素占比很高,如果背景占90%,即便模型把所有像素都预测为背景,像素级准确率也有90%,但mIoU会很低。所以我们要用mIoU作为主要评估指标。

4.2 mIoU的计算

mIoU的核心思想是:对每个类别,分别计算预测集合和真实集合的交集与并集的比值,最后对所有类别取平均。并集 = 该类别的预测像素数 + 该类别的真实像素数 - 交集像素数。

一个高效的实现方式是维护一个全局混淆矩阵。每处理一个batch,就统计预测结果和标签的匹配情况,更新混淆矩阵;所有数据跑完后,从混淆矩阵一次性计算每个类别的IoU。代码大概长这样:

def compute_iou(pred, target, num_classes=21, ignore_index=255): # pred和target都是[H, W]的整数tensor mask = target != ignore_index pred = pred[mask] target = target[mask] # 计算混淆矩阵 conf = torch.zeros(num_classes, num_classes, dtype=torch.int64) for t, p in zip(target.view(-1), pred.view(-1)): conf[t, p] += 1 # 每个类别的IoU ious = [] for c in range(num_classes): tp = conf[c, c].item() fn = conf[c].sum().item() - tp fp = conf[:, c].sum().item() - tp union = tp + fn + fp if union > 0: ious.append(tp / union) else: # 该类别在数据中未出现,跳过 pass return sum(ious) / len(ious)

混淆矩阵的行代表真实类别,列代表预测类别。对角线是正确分类的像素数。每个类别的TP就是对角线值,FN等于该行所有非对角线值的和,FP等于该列所有非对角线值的和,并集等于TP加FN加FP。如果某个类别的union为0,说明这类在验证集里压根没出现,直接跳过,不参与平均。

在验证集上评估时,要用模型输出的logits做argmax后得到的类别索引来计算,不能拿logits算。另外自己写的循环里,zip(target.view(-1), pred.view(-1))在数据量大时会比较慢,工程上可以把混淆矩阵改成用torch.bincount或者矩阵乘法批量计算,速度会快很多。

4.3 优化策略与超参数选择

FCN训练通常用带动量的SGD,而不是Adam。SGD在分割任务上收敛更稳定,最终精度往往更高。常见配置是:

optimizer = torch.optim.SGD( model.parameters(), lr=0.01, momentum=0.9, weight_decay=0.0001 )

学习率的选择取决于是否微调预训练权重。如果你加载了完整的VGG16预训练权重,初始学习率可以设置在0.001到0.01之间;如果是从零开始训练,0.01都不一定够,但效果会很差。

FCN论文里还用一个很有名的学习率策略叫poly衰减,公式是:

[ lr = lr_{init} \times (1 - \frac{iter}{total_iter})^{power} ]

其中power一般取0.9。这个策略比阶梯式下降更适合分割任务,因为它在训练后期保持一个较低且缓慢下降的学习率,有助于精细收敛。实现起来也不复杂:

def poly_lr(optimizer, init_lr, curr_iter, total_iter, power=0.9): lr = init_lr * (1 - curr_iter / total_iter) ** power for param_group in optimizer.param_groups: param_group['lr'] = lr

然后在每个iteration之后调用这个函数更新学习率。如果你用PyTorch的torch.optim.lr_scheduler,也可以自己写一个继承_LRScheduler的类,但直接每次循环里设置也没有问题。

4.4 训练循环与checkpoint保存

一个基础的训练循环大概是这样:

for epoch in range(start_epoch, num_epochs): model.train() total_loss = 0 for batch_idx, (images, masks) in enumerate(train_loader): images = images.cuda() masks = masks.cuda() outputs = model(images) # [B, 21, H, W] loss = criterion(outputs, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() # 更新poly学习率 poly_lr(optimizer, init_lr, global_iter, total_iters) # 每若干epoch做一次验证 if epoch % 2 == 0: miou = evaluate(model, val_loader) torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'miou': miou }, f'checkpoints/fcn8s_epoch{epoch}_miou{miou:.4f}.pth')

保存checkpoint时记得把optimizer状态也存下来,这样中断后可以恢复训练。如果只存模型权重,恢复训练时优化器的momentum等状态会丢失,等于白跑了几轮。

5. 训练与调试实录:常见踩坑与排查技巧

5.1 标注文件读出来是黑的或颜色错乱

这是新手最高频的问题。你看到SegmentationClass里的PNG,用Windows照片查看器打开是彩色的,但用matplotlib读出来变成全黑。原因就是前面说的,标注图存的是调色板索引,不是RGB值。matplotlib默认把单通道灰度显示,而0~20的数值太小,看起来就是黑。

解决方法是给PIL Image设置VOC调色板后再显示:

def voc_colormap(num_classes=256): cmap = np.zeros((num_classes, 3), dtype=np.uint8) for i in range(num_classes): cmap[i, 0] = (i & 1) * 255 cmap[i, 1] = ((i >> 1) & 1) * 255 cmap[i, 2] = ((i >> 2) & 1) * 255 return cmap mask_pil = Image.open(mask_path).convert('P') mask_pil.putpalette(voc_colormap().flatten().tolist()) mask_pil.show()

训练时不需要调色板,直接取索引值就行。但调试时如果mask可视化错误,会误导你怀疑数据有问题。正确显示几个样本,确认边界和类别大致合理,再开始训练。

5.2 显存不足(OOM)的应对策略

FCN在512x512输入下,batch_size=8,单张显卡很容易爆显存。你可能会遇到CUDA out of memory,这时有几个处理思路:

第一,降低batch_size到2或4。这是最直接的。第二,减小输入尺寸到320或256,显存占用会显著下降。第三,开启混合精度训练,torch.cuda.amp可以大幅减少显存占用,而且训练速度更快,对最终精度影响很小。第四,使用梯度累积来“模拟”大batch:

accumulation_steps = 4 for i, (images, masks) in enumerate(train_loader): outputs = model(images) loss = criterion(outputs, masks) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

梯度累积的原理是:把loss除以累积步数,然后连续多步反向传播,梯度会累加,攒够步数后再更新参数。效果等效于batch_size乘以累积步数。要注意的是,BN层在梯度累积时统计的是每个小batch的均值和方差,和真正的大batch不完全等价,但实践中问题不大。

如果你用到了BatchNorm,还要注意输入的batch_size不能为1,否则BN会报错或产生错误的统计量。用梯度累积辅助时,每个小batch最好还是保持2以上。

5.3 Loss不降或一直震荡

训练初期loss不降,先检查三件事。

第一,标签是否正确。打印一两个batch的masks.unique(),确认里面只有0~20这些值。如果你在预处理时误把mask除以255,标签就会变成0和1两个值,模型永远学不对。第二,初始化是否正确。新增卷积层如果没初始化,或者用了默认的均匀初始化,可能导致梯度爆炸或消失。当loss出现NaN时优先怀疑这里。第三,学习率是否合适。poly衰减初始学习率如果设到0.1,对微调来说太大了,loss大概率震荡不降。降到0.001或者0.0001试试。

如果loss一直在2.9左右徘徊,说明模型在输出均匀分布,还没开始有效学习。可以先只训练一小批数据,比如32张图片,看看loss有没有下降趋势。如果小批量都过拟合不了,那模型实现大概率有bug。

5.4 图像和mask不同步的坑

数据增强时如果只对image做随机翻转,不对mask做,你会发现训练loss能降到很低但mIoU上不去。因为模型看到的图像和标签根本对不上,它只能记住最粗的统计规律。这种错误很隐蔽,因为训练过程看起来一切正常。

我建议在每次epoch开始前,随机抽取一个batch,把image和mask并排打印出来,肉眼扫一眼。特别是做翻转和裁剪后,检查图片里的物体和mask的轮廓是否对齐。这个习惯能帮你避免很多无效训练。

5.5 预测结果可视化的实用套路

训练过程中可视化预测结果,能非常直观地发现模型问题。流程是:模型输出logits[1, 21, H, W],取argmax得到[H, W],然后把这个索引映射到调色板颜色,再和原图拼在一起保存。示例代码:

def visualize_prediction(image, mask, pred, save_path): # image: [H, W, 3] 0~1 float # mask/pred: [H, W] int cmap = voc_colormap(256) / 255.0 mask_rgb = cmap[mask] pred_rgb = cmap[pred] import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(image) axes[1].imshow(mask_rgb) axes[2].imshow(pred_rgb) axes[0].set_title('Image') axes[1].set_title('GT') axes[2].set_title('Pred') for ax in axes: ax.axis('off') plt.savefig(save_path, bbox_inches='tight', dpi=150) plt.close()

如果你想叠加显示,把pred_rgb和image按半透明混合即可。这个可视化脚本放在验证循环里,每几个epoch存一张图,长期追踪模型的变化。

5.6 跨环境的可复现性注意事项

代码在本机能跑,到服务器上跑不起来的情况很常见。几个容易忽视的坑:PyTorch版本不同会导致VGG16的features层切片索引不同,建议用print(vgg.features)确认一下;CUDA版本和PyTorch版本不匹配会在import时直接报错;DataLoader的num_workers在Windows上如果设太大,可能会报“DataLoader worker exited unexpectedly”,设成0或1最省心;显存大小不同导致batch_size必须调整,这很正常,别迷信别人的配置。

如果你在训练时发现速度特别慢,可以检查是不是用了CPU训练而不是GPU。model.cuda()和tensor.cuda()缺一不可,缺少任何一个,模型或数据都在CPU上,计算会慢几十倍。

6. 调参经验与后续扩展建议

跑通基线之后,想提升mIoU可以从几个方向入手。数据方面,把输入尺寸从256升到512,一般能带来1到3个点的提升,但显存和训练时间也会涨。模型方面,把backbone换成ResNet50或ResNet101,主干特征更强,效果提升明显。损失函数方面,可以给不同类别设置不同的loss权重,缓解类别不平衡,尤其是小物体类别。

我自己最喜欢的一个小技巧是:训练时把加权交叉熵和普通交叉熵做线性组合,权重设置为0.5和0.5。这样既不会让模型过于偏向背景,也能对小物体类别保持敏感。但要注意,这个组合系数是一个超参数,需要在验证集上试验,不要直接抄。

如果你只是想快速出效果,不妨越过手工实现FCN,直接试用一些现成的分割库或预训练模型。但前提是,你已经理解了这条链路里的每一个环节。否则出了bug,你连从哪里排查都不知道。

这个项目后续再往深走,可以尝试DeepLab系列的空洞卷积、PSPNet的金字塔池化、U-Net的编码器-解码器结构,也可以试试现在流行的SegFormer、Mask2Former这些Transformer方案。但万变不离其宗,数据加载、模型前向、损失计算、评估可视化这套骨架是完全通用的。把FCN彻底玩明白,后面再换任何模型,对你来说都只是换个网络结构的事。

我在实际训练FCN的过程中,最大的体会是:真正花费时间的地方不是模型定义,而是那一堆数据处理的细节——mask的索引、同步增强、ignore_index、mIoU的计算口径。每一个小坑都会让你白跑好几个小时,但踩过一次之后就再也不会犯了。趁着VOC数据集小、FCN结构清晰,沉下心来把整条链路跑通,比什么都值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询