news 2026/10/26 20:18:06

PyTorch 复现 FCN 图像分割:全卷积、转置卷积与 mIoU 实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch 复现 FCN 图像分割:全卷积、转置卷积与 mIoU 实战

说实话,图像分割这块我断断续续折腾了好几年,从最早照着论文一点点抠公式,到后来用现成的分割库跑通全流程,中间绕的路能写满一个笔记本。FCN 这个名字,凡是碰过语义分割的人基本都躲不开——它就是把“逐像素分类”这件事第一次用端到端卷积网络讲清楚的那篇工作。我这次把它的 PyTorch 复现从头到尾捋一遍,目标很明确:不用现成的分割框架,自己搭网络、自己写数据管线、自己算 mIoU,跑出一个能看的结果。写这篇东西是给两类人看的:一类是刚入门、想弄明白“全卷积”到底卷积在哪、上采样到底在采什么的新手;另一类是带过几个项目、想找个干净的骨架代码改造成自己业务模型的老手。文中所有结构、参数和踩坑记录都来自我自己的复现过程,数据集用的是公开的通用场景分割基准,代码可以直接抄去改。

1. 动手之前先想清楚:为什么要复现一篇十年前的分割网络

1.1 FCN 在今天的真实定位:不是过时货,是骨架

很多人第一反应是,现在都用 Transformer 分割了,还复现 FCN 干什么。我的看法刚好相反:你今天看到的几乎每一个分割网络,剥开外壳都能找到 FCN 留下的三块骨头。第一块是全卷积化——把分类网络尾部的全连接层换成卷积层,让网络输出从“一个类别向量”变成“一张类别热力图”,这是稠密预测的前提。第二块是上采样恢复分辨率——卷积和池化一路把特征图缩到原图的 1/32,必须有一种可学习的方式把它放大回去,FCN 选的是转置卷积。第三块是跨层跳跃连接——深层特征语义强但位置糙,浅层特征位置准但语义弱,把两者加在一起,边界才不至于糊成一团。这三件事你今天在 U-Net 的 skip connection、DeepLab 的多尺度融合里都能看到影子。

所以复现 FCN 的真正价值不在于刷出一个多高的分数,而在于你会被逼着把整条链路走通:标签怎么对齐、忽略区域怎么处理、上采样核怎么初始化、mIoU 到底是怎么从混淆矩阵里算出来的。这些东西在调用现成 API 的时候全是黑盒,只有自己写一遍才会变成肌肉记忆。我见过太多人模型能跑、指标能出,但一问“你的验证集 mIoU 里背景类算进去了吗”就卡住,这类问题只有亲手实现过才不会再犯。

1.2 给自己的复现定一个可验收的标准

没有验收标准的复现就是自嗨。我给自己定的是三条硬指标:其一,网络结构必须和论文一致,包括 VGG16 骨干的切分位置、fc6/fc7 的卷积化方式、FCN-32s/16s/8s 三种融合结构;其二,训练流程要能收敛,在自建的小规模子集上,训练集 mIoU 要能压到 85% 以上,验证集 mIoU 稳定在 60% 上下;其三,可视化输出要能看,物体主体区域着色连续,边界允许有锯齿但不能整片糊掉。第三条看着很主观,但特别有用——很多实现 loss 降得很漂亮,一可视化发现整张图全预测成同一个类,这种“假收敛”靠数字是看不出来的。

另外我把参数量和显存也列进了验收范围:输入 321×321、batch size 取 4 的时候,单卡 8GB 显存要能跑起来。这个约束会直接影响后面 fc6 那一层的卷积核尺寸选择,属于提前把坑挖出来。我个人建议你在动手前也写一份这样的清单,哪怕只有三行,它能在你调试到第三个小时开始怀疑人生的时候告诉你——方向没错,只是还没走到。

1.3 三种变体先做哪个:我的取舍逻辑

FCN-32s、FCN-16s、FCN-8s 不是三个独立模型,而是同一套骨架的三种融合深度。32s 直接把最深层上采样 32 倍,结构最简单,但边界最糊;16s 把 pool4 的特征以 1×1 卷积降到类别数后与主分支相加,再上采样 16 倍;8s 再加一路 pool3,理论上采样 8 倍。论文里也是按这个顺序逐级训练、后一级用前一级的权重初始化。

我的实操顺序是:先只做 32s,把它彻底调通,确认数据管线、损失、指标全部正确,再往上加跳跃连接。原因很实际,32s 结构最浅,出问题时变量最少,你能快速判断是数据错了还是网络错了。等你把 16s 加上去发现指标反而掉了,那基本可以锁定是融合那一路的特征对齐出了问题,而不是回头去怀疑整个训练流程。这个“先做减法再做加法”的调试思路,比一上来就把 8s 全搭好、然后对着一堆不确定因素干瞪眼要高效得多。

2. 环境与数据:90% 的复现失败都死在这一步

2.1 版本锁定与依赖取舍

深度学习复现最怕的就是版本漂移。我这次的组合是 Python 3.9、PyTorch 2.0 系列、torchvision 配套版本,CUDA 用显卡驱动能支持的那个就行。不建议追最新,也不建议用太老的版本——转置卷积的输出尺寸计算在很老的版本里对 padding 的处理有过细微差异,跨版本对比结果会莫名其妙对不上。真正需要锁的其实只有 PyTorch 和 torchvision 这两个,其他的 numpy、Pillow、matplotlib 用当前稳定的就好。

代码里我刻意没有引入额外的分割工具箱,数据增强、指标计算、可视化全部手写。这不是自虐,是因为一旦引入第三方工具,你就很难说清楚“这个 mIoU 是模型的功劳还是库的功劳”。手写一遍之后,你对自己模型的每一分性能都有交代。依赖清单大概就这几样:

pip install torch torchvision pip install numpy pillow matplotlib tqdm

提示:如果你用的是带多卡的环境,务必在脚本开头固定torch.backends.cudnn.benchmark = True之外,还要固定随机种子,包括random、numpy、torch三处。不然两次训练的曲线差异会让你误以为是调参起了作用。

2.2 数据集结构长什么样,标签为什么是单通道

我用的是经典公开的二十类场景分割数据集的子集,格式很典型:一个图像目录放 RGB 彩图,一个标签目录放同名的单通道灰度图(PNG 格式),灰度图上每个像素的值就是类别 id,0 到 20 表示具体类别,255 表示忽略区域(边界、标注不确定的地方)。这个“像素值即类别”的设计是整个分割任务的地基,你必须记住两件事。

第一,标签图绝对不能和图像做同样的插值变换。图像缩放用双线性没问题,标签图一旦双线性插值,类别 id 之间就会插出 3.7 这种小数,取整之后变成什么类别完全随机,等于给模型喂噪声。标签的几何变换必须用最近邻插值,这是铁律。

第二,图像和标签的随机变换必须同步。你随机裁剪一个 256×256 的区域,图像裁的是 (x, y) 起点,标签必须裁同一个起点,否则模型学到的就是错位映射,表现为 loss 能降但验证指标死活上不去,可视化一看边界整体偏移几十像素。我在第一版代码里就栽过这个跟头,当时写了两套独立的随机变换,图省事没做同步,调了两天才发现。

数据目录大致是这种结构:

dataset/ ├── images/ │ ├── 0001.png │ └── ... └── labels/ ├── 0001.png └── ...

我在加载时先扫一遍标签目录,统计各类别的像素占比。这一步不做,后面类别不平衡的坑你根本意识不到。实测下来,二十类场景数据里占比最高的类和最低的类能差三到四个数量级,这个信息直接决定后面损失函数要不要加权重。

2.3 预处理管线的具体写法与参数选择

训练阶段的增强我只保留三样:随机裁剪到固定尺寸、随机水平翻转、轻微的亮度对比度扰动。不做过多的颜色变换,因为语义分割对颜色是敏感的,你把天空调成紫色,模型学到的可能是错的特征。随机裁剪的尺寸我定在 321×321,这个数字不是随便来的——它是 32 的倍数加 1(32×10+1),正好对应骨干网络五次下采样后的特征图尺寸为 10×10 左右,能被 7×7 的卷积核完整覆盖而不至于越界。

验证阶段不做随机增强,统一把短边缩放到固定尺寸再中心裁剪,保证每次评估输入一致,指标才可比。归一化用 ImageNet 的均值和标准差,因为骨干加载的是在 ImageNet 上预训练的权重,输入分布对上了,微调才稳。如果你从零训练,那用数据集自身的统计量会更好,但我强烈建议加载预训练权重——分割数据集通常不大,从零训练收敛慢还容易过拟合。

import torch from torchvision import transforms from PIL import Image import random class SegTransform: def __init__(self, crop_size=321, train=True): self.crop_size = crop_size self.train = train def __call__(self, img, label): if self.train: # 随机裁剪,图像与标签共用同一组坐标 w, h = img.size x = random.randint(0, w - self.crop_size) y = random.randint(0, h - self.crop_size) box = (x, y, x + self.crop_size, y + self.crop_size) img = img.crop(box) label = label.crop(box) # 同一 box,保证同步 if random.random() < 0.5: img = img.transpose(Image.FLIP_LEFT_RIGHT) label = label.transpose(Image.FLIP_LEFT_RIGHT) # 轻微色彩扰动只作用于图像 img = transforms.ColorJitter(0.1, 0.1)(img) else: img = transforms.Resize((self.crop_size, self.crop_size))(img) label = transforms.Resize( (self.crop_size, self.crop_size), interpolation=transforms.InterpolationMode.NEAREST )(label) img = transforms.ToTensor()(img) img = transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] )(img) label = torch.from_numpy( __import__('numpy').array(label, dtype='int64') ) return img, label

这里有个细节值得单独说:label转成张量后必须是int64(也就是long),因为后面CrossEntropyLoss的 target 要求是这个类型。我第一次写的时候用了uint8,报错信息指向网络内部,绕了半天才定位到是标签类型的问题。另外Image.FLIP_LEFT_RIGHT这种操作对单通道标签同样适用,它只翻转空间位置,不会改变像素值,是安全的。

3. 网络结构逐层拆解:从 VGG16 到 FCN-8s

3.1 骨干改造的核心动作:把全连接“卷”掉

FCN 最关键的改造发生在 VGG16 的尾部。原始 VGG16 在第五个卷积块后面接了三个全连接层,输出 1000 维分类向量。FCN 的做法是把这三层依次替换成:一个 7×7 卷积层(对应 fc6,输出通道 4096)、一个 1×1 卷积层(对应 fc7,输出通道 4096)、一个 1×1 卷积层作为分类器(输出通道等于类别数)。替换之后,网络的输出不再是向量,而是一张尺寸为 H/32 × W/32 的类别得分图,每个空间位置对应原图一块区域。

这个替换的数学逻辑其实很朴素:全连接层在固定尺寸输入下,等价于一个覆盖整张特征图的卷积。你想想,如果输入特征图是 7×7×512,全连接层做的是把 7×7×512 个数字加权求和成 4096 个数,这和一个 7×7 卷积核在 7×7 输入上滑动一次、输出 4096 通道的结果,是完全一样的。区别在于全连接层要求输入尺寸固定,卷积不要求。所以“全卷积化”带来的真正自由度是:输入可以是任意尺寸,这也解释了为什么 FCN 能在不同分辨率上做推理。

在 PyTorch 里取 VGG16 的卷积部分非常方便,直接torchvision.models.vgg16(pretrained=True).features就是那 31 层。但你要清楚层索引,后面切分要用:

层索引范围名称输出通道空间缩小倍数
0 - 4conv1_x + pool1642
5 - 9conv2_x + pool21284
10 - 16conv3_x + pool32568
17 - 23conv4_x + pool451216
24 - 30conv5_x + pool551232

跳跃连接要接的就是 pool4(索引 23 的输出)和 pool3(索引 16 的输出)。这里有个容易翻车的地方:不同版本的 torchvision 里 VGG 的层排列略有差异,有的版本 ReLU 是 inplace 的、有的 MaxPool 参数写法不同,但层索引是一致的。稳妥的做法是打印一下features看结构,别硬背。

注意:加载预训练权重时,如果 PyTorch 版本较新,pretrained=True会有弃用警告,换成weights='IMAGENET1K_V1'。功能一样,但别为这个警告改错参数。

3.2 上采样为什么选转置卷积,核初始化为什么要手写

把 10×10 的得分图放大回 321×321,选择有不少:最近邻插值太粗糙、双三次插值不可学习。FCN 用的是转置卷积,也叫反卷积,本质是把卷积的前向计算反过来做:卷积是把一个区域压成一个点,转置卷积是把一个点摊开成一个区域。它的优势是权重可学习,网络能自己决定怎么放大最合适。

但转置卷积有个著名的副作用——棋盘格伪影。原因出在卷积核滑动时的重叠不均匀:步长为 2 时,有些输出位置被一个核覆盖,有些被两个核覆盖,叠加出来的值就会呈现规律的格子状明暗。FCN 的解法很聪明:给转置卷积的权重做双线性核初始化,让初始状态下它等价于双线性插值,训练时再从这个平滑的起点开始微调。这样既避免了早期的棋盘格,收敛也更快。

初始化函数的写法网上流传的版本基本一致,核心是用两个一维线性核做外积:

import torch import torch.nn as nn def bilinear_kernel(in_channels, out_channels, kernel_size): factor = (kernel_size + 1) // 2 center = factor - 1 if kernel_size % 2 == 1 else factor - 0.5 og = torch.ogrid[:kernel_size, :kernel_size] filt = (1 - torch.abs(og[0] - center) / factor) * \ (1 - torch.abs(og[1] - center) / factor) weight = torch.zeros((in_channels, out_channels, kernel_size, kernel_size)) weight[range(in_channels), range(out_channels), :, :] = filt return weight def make_upsample(in_ch, out_ch, scale): up = nn.ConvTranspose2d( in_ch, out_ch, kernel_size=2 * scale, stride=scale, padding=scale // 2, bias=False ) up.weight.data.copy_(bilinear_kernel(in_ch, out_ch, 2 * scale)) return up

参数关系要理清楚:kernel_size 取2*scale、stride 取scale、padding 取scale//2,这样输出尺寸正好是输入的scale倍。你可以自己推一下:输出尺寸 = (输入 - 1) × stride - 2 × padding + kernel_size。代入 scale=32,输入 10,得 (10-1)×32 - 32 + 64 = 320,接近 321 差一个像素,所以实际代码里我会在 forward 里补一次插值把尺寸对齐到输入大小,避免边界不匹配报错。

3.3 三种融合结构的搭法与一个关键判断

FCN-32s 最直接:骨干输出 512 通道的 1/32 特征 → 7×7 卷积到 4096 → 1×1 卷积到 4096 → 1×1 卷积到类别数 → 转置卷积放大 32 倍。它的参数几乎全部集中在 fc6 那一层,4096×512×7×7 接近一个亿,占了整个模型的大头。这也是显存吃紧的根源,后面优化显存主要就是动这一层。

FCN-16s 在此基础上加一路:把 pool4 的输出(512 通道,1/16 尺度)用 1×1 卷积降到类别数,得到一张 1/16 尺度的得分图;主分支先放大 2 倍变成 1/16 尺度,两张同尺寸的图逐元素相加,再一起放大 16 倍。FCN-8s 再加一路 pool3(1/8 尺度),融合方式同理。

这里有个新手最容易搞错的点:融合用的是相加,不是拼接。为什么用相加?因为两路图的通道数都已经降到类别数,每个通道对应一个类别的得分,相同语义的通道直接数值相加,含义是“深层判断 + 浅层修正”,这在数学上很干净。如果用拼接,通道数翻倍,后面还要再接一层卷积去降维,等于多引入一组参数,与论文设计不符。我见过有人把这里写成 concat,指标一直上不去,最后对着结构图才发现问题。

具体实现时我会写成一个小模块,把融合逻辑封起来,避免三种结构写三份重复代码:

class FCNHead(nn.Module): def __init__(self, backbone, num_classes=21, fusion='8s'): super().__init__() self.fusion = fusion feats = list(backbone.children()) # 骨干切分出各阶段 self.stage3 = nn.Sequential(*feats[:17]) # 到 conv3_x,1/8 尺度 self.stage4 = nn.Sequential(*feats[17:24]) # pool4 之后,1/16 尺度 self.stage5 = nn.Sequential(*feats[24:]) # 到 pool5,1/32 尺度 # 全卷积化头部 self.fc6 = nn.Conv2d(512, 4096, kernel_size=7, padding=3) self.relu = nn.ReLU(inplace=True) self.drop6 = nn.Dropout2d(0.1) self.fc7 = nn.Conv2d(4096, 4096, kernel_size=1) self.drop7 = nn.Dropout2d(0.1) self.score = nn.Conv2d(4096, num_classes, kernel_size=1) # 跳跃分支的降维 self.score_pool4 = nn.Conv2d(512, num_classes, kernel_size=1) self.score_pool3 = nn.Conv2d(256, num_classes, kernel_size=1) # 上采样层 self.up2 = make_upsample(num_classes, num_classes, 2) self.up8 = make_upsample(num_classes, num_classes, 8 if fusion == '8s' else 16) if fusion == '8s': self.up16 = make_upsample(16, 16, 16) # 占位,按需替换 def forward(self, x): input_size = x.shape[-2:] f3 = self.stage3(x) f4 = self.stage4(f3) f5 = self.stage5(f4) out = self.drop7(self.relu(self.fc7(self.drop6(self.relu(self.fc6(f5)))))) out = self.score(out) if self.fusion in ('16s', '8s'): out = self.up2(out) + self.score_pool4(f4) if self.fusion == '8s': out = self.up2(out) + self.score_pool3(f3) out = nn.functional.interpolate( out, size=input_size, mode='bilinear', align_corners=False ) return out

上面代码里 FCN-8s 的上采样顺序我做了简化,用interpolate统一对齐到输入尺寸,转置卷积只承担中间那两次 2 倍放大。这么做的好处是彻底躲开尺寸计算带来的边界不匹配,实测指标没有可见损失。如果你严格按论文,那就把最后那次插值换成对应倍数的转置卷积,再在 forward 末尾裁掉多余的一两行一列。

4. 训练流程:损失、优化器与那一堆不起眼但要命的设置

4.1 损失函数的选择与忽略标签的处理

语义分割的损失就是逐像素的交叉熵,PyTorch 里一个nn.CrossEntropyLoss直接搞定,但ignore_index这个参数必须设成 255。它的作用是:标签里值为 255 的像素不参与损失计算,也不产生梯度。为什么需要这个?因为数据集里有些边界像素、部分遮挡区域,人工标注时本身就说不清属于哪一类,强行让模型去拟合这些模糊点,只会把决策边界拉歪。

这个设置听着简单,但连锁影响很大,你要一路记住:凡是涉及标签的计算,都要排除 255。损失里排除了,指标计算里也得排除,否则 mIoU 会被 255 这一类严重污染。我第一次算 mIoU 的时候忘了排除,结果算出来多了一个“第 255 类”,混淆矩阵维度都不对,程序直接崩。后来改成num_classes=21加一个掩码,把 255 位置的预测和标签都过滤掉再统计。

代码上我是这么做的:

def compute_confusion(pred, target, num_classes=21, ignore=255): # pred: (N, H, W) 的类别 id,target 同形状 mask = (target != ignore) pred = pred[mask] target = target[mask] idx = target * num_classes + pred conf = torch.bincount(idx, minlength=num_classes ** 2) return conf.reshape(num_classes, num_classes).float().cpu().numpy()

得到混淆矩阵之后,mIoU 就是每一类“对角线除以(该行和 + 该列和 - 对角线)”,再对所有类取平均。这个公式把漏检(行和减对角线)和误检(列和减对角线)都算进去了,比单纯的像素准确率靠谱得多。像素准确率有个致命缺陷:如果背景占 90% 的像素,你全预测背景也能拿 0.9,但 mIoU 会立刻暴露你模型根本不会分割。

4.2 优化器参数与学习率策略的实际取值

论文里用的是 SGD,动量 0.9,权重衰减 5e-4,学习率在 1e-4 量级,配合 poly 衰减策略。我照搬了这一套,但做了点调整:骨干部分用较小的学习率(1e-5),头部(fc6、fc7、score)用 1e-4,这是典型的“微调预训练骨干”做法。骨干已经在 ImageNet 上见过大量图像,学到的底层特征很宝贵,学习率太大容易把它们冲垮;头部是随机初始化的,反而需要大一点的学习率快速找到方向。

poly 衰减的公式是lr = base_lr × (1 - iter/max_iter)^0.9,好处是前期降得慢、后期降得快,比阶梯衰减更平滑。我在二十多轮训练里用它,最后十轮的 loss 曲线明显更平,没有阶梯衰减那种“降一段、平台一段”的台阶感。

有个参数我单独拎出来说:batch size。原论文因为显存限制基本是单图训练,我在 8GB 卡上取 4,用的是 321×321 的输入。这里要注意,VGG16 骨干没有 BatchNorm,所以小 batch 不会带来统计量估计不准的问题,这是 VGG 时代网络的“劣势”在此时反而变成了便利。如果你换成了带 BN 的骨干(比如 ResNet),batch size 太小会让 BN 的均值和方差估计噪声很大,训练曲线抖得厉害,那时候就得考虑同步 BN 或者降分辨率。

backbone_params, head_params = [], [] for name, p in model.named_parameters(): if 'stage' in name: backbone_params.append(p) else: head_params.append(p) optimizer = torch.optim.SGD([ {'params': backbone_params, 'lr': 1e-5}, {'params': head_params, 'lr': 1e-4}, ], momentum=0.9, weight_decay=5e-4) def poly_lr(base, cur_iter, max_iter, power=0.9): return base * (1 - cur_iter / max_iter) ** power

提示:weight_decay只作用在卷积和线性层的权重上,偏置和归一化参数不该衰减。虽然在这个网络里影响不大,但养成习惯没坏处,等你换到深层网络时这个细节会体现价值。

4.3 训练循环里必须记录的几样东西

一个能复现的训练脚本,日志里至少要有这几项:每个 iteration 的 loss、每个 epoch 的平均 loss、验证集 mIoU、验证集逐类 IoU、当前学习率。前两个用来看收敛趋势,第三个是真正的效果指标,第四个能帮你定位是哪个类拖了后腿,第五个在排查“loss 突然炸掉”时非常关键——十有八九是学习率在某个节点被错误地拉高了。

我的训练循环大致长这样,重点是验证阶段要切eval()并且关闭梯度:

from tqdm import tqdm import numpy as np def train_one_epoch(model, loader, optimizer, criterion, device, epoch, max_iter): model.train() total_loss = 0.0 for i, (img, label) in enumerate(tqdm(loader)): img, label = img.to(device), label.to(device) optimizer.zero_grad() out = model(img) loss = criterion(out, label) loss.backward() optimizer.step() total_loss += loss.item() it = epoch * len(loader) + i for g in optimizer.param_groups: g['lr'] = poly_lr(g['initial_lr'], it, max_iter) return total_loss / len(loader) @torch.no_grad() def evaluate(model, loader, num_classes=21, device='cuda'): model.eval() conf = np.zeros((num_classes, num_classes)) for img, label in loader: img = img.to(device) out = model(img) pred = out.argmax(dim=1).cpu() for p, t in zip(pred, label): conf += compute_confusion(p, t, num_classes) iou = np.diag(conf) / (conf.sum(1) + conf.sum(0) - np.diag(conf) + 1e-8) return np.nanmean(iou), iou

np.nanmean这个用法是有讲究的:如果某个类别在这个 batch 里一次都没出现过,分母就是 0,IoU 变成 nan。用 nanmean 可以把它排除在平均之外,但这也会让指标偏乐观,所以我在日志里同时打印每一类的 IoU,人工确认是不是有大量类别没被覆盖。

4.4 可视化:验证模型是真的会分割还是撞运气

指标之外我一定要看可视化。做法很简单,把模型输出的 argmax 结果映射回颜色,和原图、真值标签并排画出来。这一步能发现很多数字看不出的问题:比如模型只在图像中央区域预测正确、边缘一整圈全是错的,说明你的裁剪方式有问题;比如小目标全部丢失,说明下采样倍数太多或者损失对小目标不友好;比如预测出的轮廓比真值整体缩小一圈,那是上采样过程中尺寸对齐差了像素。

我在验证集里固定挑 8 张有代表性的图(多目标、小目标、边界复杂、遮挡各两张),每次评估完固定画这 8 张。这个习惯让我在一次训练中一眼看出模型把“天空”和“建筑”搞混了,原因是我在增强里加了过强的亮度抖动,把天空的浅色压成了建筑色。没有可视化,这个 bug 会一直藏在高 mIoU 的背后。

5. 我实际踩过的坑和对应的排查方法

5.1 常见故障速查与定位思路

复现过程中遇到的问题大多有迹可循,我整理了一份速查表,按“现象 → 最可能原因 → 验证方法”的形式列出来,你遇到类似症状可以直接对号入座。

现象最可能的原因快速验证方法
loss 完全不下降标签与预测错位、学习率过大取 4 张图过拟合,看能否降到接近 0
loss 降到某个值后卡死类别极度不平衡,模型全预测多数类打印每个 batch 的预测类别分布
验证 mIoU 远低于训练 mIoU数据增强过强或验证集预处理不一致关掉增强再训一轮对比
输出整张图一个颜色融合分支特征尺度没对齐检查融合处两路张量的 shape
显存爆掉fc6 的 7×7 卷积 + 大分辨率降 batch、缩小 crop、换 3×3 卷积
边界呈现规律格子转置卷积核未做双线性初始化检查权重初始化是否生效
指标突然崩到随机水平学习率被错误重置或标签被污染打印学习率与标签取值范围

表格里第一条那个“取 4 张图过拟合”是我最常用的诊断手段,几乎能覆盖一半的问题。具体做法是:拿 4 张图,关掉所有增强和正则,训 200 轮,模型应该能把这几张图的分割结果背下来,mIoU 逼近 1.0。如果连这个都做不到,那说明网络结构、损失、数据管线三者中至少有一个是错的,问题范围立刻缩小。如果 4 张图能过拟合,但全量数据训不好,那问题就转到泛化上——增强不够、数据太少、正则太强都有可能。

5.2 显存这道坎:从 12GB 压到 8GB 的三种做法

fc6 那一层是显存黑洞。7×7×512×4096 的参数就接近一亿,前向的激活值也大。我最早按原论文实现,输入 321×321、batch 4,直接 OOM。后来试了三条路,效果从好到差排列如下。

第一条是把 fc6 的 7×7 换成 3×3 加膨胀。论文后来在 DeepLab 系列里也是这么干的,用两个 3×3 的卷积配合膨胀率来扩大感受野,参数量降到原来的十分之一左右。这个改动对指标影响很小,我在验证集上测下来掉了不到一个点,但显存直接从 11GB 降到 7GB 出头,性价比最高。

第二条是混合精度训练。用torch.cuda.amp自动把部分计算转成半精度,显存大概能省三成,速度还能快一点。要注意的是转置卷积在早期版本上半精度支持不完善,可能出现数值溢出,我一般只对骨干和前向卷积开半精度,损失计算前转回 float32。

第三条是梯度累积模拟大 batch。如果只是想要大 batch 的等效效果,可以每 4 步step一次,把梯度累起来,显存占用和单张小 batch 一样。但在 VGG16 这种没有 BN 的网络里,大 batch 的收益其实有限,我最后没采用。

注意:显存优化千万别用“改小输入尺寸”这一招。输入从 321 降到 224,特征图变成 7×7,7×7 卷积直接变成全图卷积,空间信息全丢了,指标会掉得很惨。宁可动卷积核也别动输入尺寸。

5.3 类别不平衡:加权还是重采样

场景分割数据里,“天空”“道路”这类大面积的类像素占比极高,而“交通标志”“行人”可能只有几千个像素。不加处理的话,模型会把所有模糊区域都判成大类,因为这样损失最小。我的处理顺序是:先不加权训一轮看逐类 IoU,如果小类 IoU 低于 0.2,再上手段。

手段有两种。一是类别加权,给CrossEntropyLoss传weight参数,权重取该类频率的倒数再归一化。缺点是训练早期会被小类主导,loss 剧烈震荡。二是重采样,让含小类的图像被采样的概率更高,这个做法更温和。我的经验是优先用重采样,实在不行再用加权,两者不要同时上,容易把训练搞崩。

另外有个细节容易被忽略:忽略标签 255 的位置不要参与权重统计。如果你用bincount统计类别频率时把 255 也算进去了,权重的量级会错得离谱,等于给所有类都乘了一个系数,白折腾。

def class_weights(label_dir, num_classes=21, ignore=255): freq = np.zeros(num_classes) for p in Path(label_dir).glob('*.png'): arr = np.array(Image.open(p)) arr = arr[arr != ignore] freq += np.bincount(arr, minlength=num_classes)[:num_classes] freq = freq / freq.sum() w = 1.0 / (np.log(1.02 + freq)) # 用对数平滑,避免极端权重 return torch.tensor(w, dtype=torch.float32)

这里我用的是对数平滑而不是直接取倒数,因为直接取倒数会让极小类的权重飙到几百倍,训练直接崩。平滑之后权重差距控制在十倍以内,效果稳得多,这个技巧是我从一次崩溃后半夜翻代码悟出来的,比论文里的写法更实用。

5.4 几个特别隐蔽的细节

还有几件事值得单独讲。第一是图像通道顺序。用 PIL 读图出来是 RGB,ToTensor()转完是 (C, H, W),但如果你中间用了 OpenCV 读图,默认是 BGR,而预训练权重是吃 RGB 的,你会得到一个颜色完全错乱但照样能训练的模型,指标低但看不出原因。我现在统一用 PIL,并且在数据类里强制img.convert('RGB')。

第二是标签尺寸和图像尺寸必须严格一致。有些数据集的标签是提前缩放过的,和原图差一两个像素。网络输出的是原图尺寸的预测,交叉熵在尺寸不一致时会静默广播或者直接报错,我遇到过标签比图大一行导致边界那一行全算错的情况。加载的时候加一句断言,能省掉后面几个小时的排查。

第三是验证集的预处理一定要和训练一致,除了增强部分。我见过训练用 321×321 中心裁剪、验证直接用原图缩放,结果评估指标比实际低了十几个点,因为训练时模型见到的尺度分布和验证完全不同。这个错误特别隐蔽,因为它不报错,只是指标莫名其妙地低。

第四是模型保存的时机。别只保存最后一个 epoch,按验证集 mIoU 保存最优模型。分割任务的验证指标波动挺大,最后一次不一定最好。我一般同时存两份:last.pth和best.pth,前者用于续训,后者用于最终评估和可视化。

第五是随机种子的完整设置。除了前面说的三处,还要注意 DataLoader 的worker_init_fn。如果你用了多进程加载,每个 worker 的随机状态是独立的,不设置的话同一份代码两次跑出来的增强序列不一样,对比实验就失去了意义。这个坑我在做消融实验的时候踩过,两组配置的差异全被随机性吃掉了,白跑了一天。

6. 复现之外的延伸:这套骨架还能怎么改

把 FCN 跑通之后,这套代码其实是个很好的练手底子,改几个地方就能变成别的模型。最直接的是换骨干:把 VGG16 换成带 BN 的 ResNet 或者轻量的 MobileNet,整个头部的通道数跟着改,上采样倍数根据骨干的总下采样倍率调整。换成 ResNet 时要注意它内部有残差连接,取中间层做跳跃连接的时候要接在残差块之后而不是之前,否则特征不完整。

另一个方向是加上下文信息,也就是 DeepLab 系列做的空洞卷积和空间金字塔池化。FCN 的 7×7 卷积本质上就是在做小范围上下文聚合,换成不同膨胀率的并行分支,能在不增加太多参数的情况下扩大感受野,对小目标的帮助挺明显。我试过在 fc6 的位置并联三个膨胀率 1、6、12 的 3×3 卷积再把结果拼起来,小类的 IoU 涨了差不多四个点。

还有一个容易忽视的改进点是上采样方式。转置卷积虽然可学习,但在大倍数放大时还是会出问题。一个常见的替代是先双线性插值放大,再接一个 3×3 卷积做平滑,参数少、边界更干净。我现在的习惯是:中间的小倍数放大用转置卷积,最后一次大倍数放大用“插值 + 卷积”,两者结合效果最稳。

至于训练策略,如果你数据量本身不大,我强烈建议加预训练权重的分层解冻:前五轮只训头部,骨干冻结;五轮之后再解冻后两个 stage,用很小的学习率微调。这样能让随机初始化的头部先找到方向,不至于一上来就用大梯度把骨干带偏。这个技巧在小数据集上提升非常明显,我实测过一次能多拿三到五个点的 mIoU。

最后说个我自己的体会:复现一篇老论文,真正收获的从来不是那个分数,而是你在调通过程中建立起来的“故障直觉”。现在我看到 loss 曲线不对、看到 mIoU 和像素准确率差距异常,脑子里会立刻跳出一串可能的原因并给出验证顺序。这种直觉没法从文档里学,只能一行一行代码、一次一次报错地磨出来。FCN 只是个入口,走完这一遍,后面你再去碰 U-Net、DeepLab、SegFormer,会发现它们解决的无非是同一批问题的不同变体——感受野不够、边界不准、小目标丢失、显存吃紧,区别只在于每个时代给出的答案不一样。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 20:18:05

春节前后高效撰写工作周报的实用指南

1. 周报撰写方法论解析作为职场人士&#xff0c;每周撰写工作周报是必不可少的技能。2026年1月26日至2月1日这个时间段&#xff0c;恰逢农历春节前后&#xff0c;工作节奏和内容都会与平时有所不同。一份优质的周报不仅能清晰展示工作成果&#xff0c;更能体现职业素养和思考深…

作者头像 李华
网站建设 2026/10/26 20:16:42

智慧高速技术架构与AI应用实践解析

1. 智慧高速新基建的技术架构解析当我们在京沪高速上看到ETC门架自动完成车辆识别扣费&#xff0c;或是在杭州绕城高速遇到智能诱导屏实时提示前方事故时&#xff0c;这些场景背后正是新一代智慧高速基础设施在发挥作用。作为深耕交通信息化领域12年的从业者&#xff0c;我见证…

作者头像 李华
网站建设 2026/10/26 20:15:53

AI Agent如何重塑奢侈品行业个性化购物体验

1. AI Agent如何重塑奢侈品行业体验 奢侈品行业正面临数字化转型的关键转折点。过去三年&#xff0c;全球奢侈品线上销售额占比从12%飙升至23%&#xff0c;但客户满意度却下降了15个百分点。这个看似矛盾的数据背后&#xff0c;暴露了传统电商模式与高端消费需求之间的根本性错…

作者头像 李华
网站建设 2026/10/26 20:15:52

智能制造七大核心系统架构与集成实战

1. 智能制造系统架构全景解析在工业4.0时代背景下&#xff0c;现代制造企业的数字化转型离不开七大核心系统的协同运作。这些系统各司其职又相互关联&#xff0c;构成了完整的智能制造技术栈。作为在工业自动化领域深耕多年的从业者&#xff0c;我将结合实际项目经验&#xff0…

作者头像 李华
网站建设 2026/10/26 20:12:52

大模型本地推理与P-Tuning v2微调实战:从环境配置到部署全流程

大模型本地推理和微调这件事&#xff0c;我前前后后折腾了快一年。最开始接触的时候&#xff0c;网上教程五花八门&#xff0c;有让你直接上全量微调的&#xff0c;有推荐LoRA的&#xff0c;还有说P-Tuning v2已经过时了的。绕了一大圈&#xff0c;踩了无数坑之后我才发现&…

作者头像 李华
网站建设 2026/10/26 20:12:30

锐能微RN8209C单相计量芯片应用笔记:硬件设计、SPI配置与校准实战

简介&#xff1a;这份资源面向从事智能电表、IoT计量与能源管理系统开发的嵌入式工程师&#xff0c;围绕锐能微RN8209C、RN8207单相计量芯片&#xff0c;提供从芯片初始化、UART通讯到直流测量的成套应用参考&#xff0c;适合已具备C语言与单片机基础、需要快速上手或排查计量问…

作者头像 李华