ARTICLE DETAIL

资讯详情

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

FCN全卷积网络实战:从VGG到Penn-Fudan行人语义分割

FCN全卷积网络实战:从VGG到Penn-Fudan行人语义分割 简介面向语义分割入门与实战的FCN全卷积网络资源包基于Penn-Fudan Database行人分割数据集构建适合深度学习初学者、课程学员以及需要跑通图像分割训练流程的开发者。包内共533个文件压缩包约184.68MB主要包含340张行人/街景PNG图像及对应掩模、172个txt标签或训练日志、6个Python训练脚本以及模型权重、npy评估指标、xml配置等便于直接复现训练、测试与结果评估。已有504人学习下载。资源提供了基于BCEWithLogits、RMSprop等策略的多组训练配置覆盖epoch100到epoch500等不同规模代码中保留数据集加载、损失计算、上采样与跳跃连接等关键模块可支持理解FCN原理并动手调整参数。配套掩码和meanIU/meanPixel等评价文件能帮助完成从数据准备到模型评估的完整项目链路适合作为作业、实验或算法对比的基础工程。1. 全卷积网络 Penn-Fudan把“分类网络”改成“像素分割”的最短路径做语义分割的同行应该都听过这个名字全卷积网络FCN2015 年提出的老架构但直到现在只要你想把分类网络改成端到端的像素级分割它依然是最容易讲清楚、最容易复现的模板。Penn-Fudan Database 则是配合它最顺手的行人分割数据集——图像尺寸不大、标注是黑白掩膜、没有乱七八糟的类别干扰非常适合拿来验证 FCN 的跳跃结构和上采样逻辑到底是怎么工作的。这篇笔记从数据集解析开始到写 FCN-8s 训练脚本再到推理和 mIoU 验证全程按我实际复现时的顺序展开参数直接给出来坑也标出来。2. 从分类到分割FCN 的核心改动与选型理由2.1 为什么全连接层必须去掉在 FCN 之前VGG、GoogLeNet 这类分类网络最后都是全连接层输出一个 1000 维的向量。全连接层的问题在于输入特征图一旦被展平成一维向量空间位置信息就彻底丢了。分割任务恰恰是要给每个像素一个类别所以全连接层对这个任务来说就是天敌。FCN 的做法是把最后的全连接层替换成卷积层。例如 VGG-16 原本的三个全连接层一个变成 7×7 卷积两个变成 1×1 卷积。这样网络就变成纯卷积结构输入任意尺寸的图输出仍然是二维特征图。更关键的是卷积层保留的空间信息可以通过后面的上采样还原到原图分辨率。我一般把这个改动理解成两个动作第一个是“降维”把分类头的 1000 类输出变成 C 类C 是分割类别数第二个是“保位”用 1×1 卷积输出每个像素的类别得分而不是展平后做全局分类。你在代码里看到最后那层nn.Conv2d(512, num_classes, kernel_size1)就是这个意思。2.2 上采样反卷积还是双线性插值FCN 原文用的是转置卷积反卷积做上采样但实际工程里我更推荐先用双线性插值把特征图拉到目标尺寸再用 1×1 卷积调整通道数。原因很简单转置卷积会引入可学习的参数训练不好会出现棋盘效应而且对数据集较小时反而容易过拟合。Penn-Fudan 一共只有 170 张带标注的图哪怕做数据增强参数量大的转置卷积也未必占便宜。我第一次复现时用的转置卷积验证集 mIoU 只到 62% 上下换成双线性上采样之后到了 67%代价只是上采样层没有参数而已。所以后面的代码我全程用F.interpolate(modebilinear, align_cornersFalse)只在最后拼接层保持原文的跳跃结构。2.3 跳跃结构为什么能救边缘FCN 有 FCN-32s、FCN-16s、FCN-8s 三档。32s 是把 VGG 的 pool5 输出直接上采样 32 倍边缘非常糊16s 加入 pool4 的预测结果8s 再加 pool3。跳跃结构的本质是把浅层的高分辨率信息与深层的语义信息相加浅层特征边缘细节足深层特征类别判别力强两者融合之后分割边界明显扎实。选 FCN-8s 作为复现目标是因为它在效果和实现复杂度之间最平衡。Penn-Fudan 行人边缘不算特别复杂但遮挡和重叠很多只用 32s 会连成一片8s 能勉强把人分开。训练时我会分别拿到 pool3、pool4、fc7注意这里 fc7 其实是卷积后的特征图的输出先对每个分支做 1×1 卷积预测再用双线性插值到同一尺寸最后相加。3. 数据准备Penn-Fudan 的目录结构、掩膜解析与 Dataset 封装3.1 数据集文件长什么样Penn-Fudan Database 解压后是两个目录PNGImages存放原始 PNGAnnotation存放掩膜 PNG。掩膜里有三个像素值0 是背景255 是行人整体128 是行人边缘contour。这个 128 值很关键因为训练时要决定是否把它单独当一类。直接下载后170 张图里大约有一半是单人另一半是多人和遮挡场景。图像分辨率统一到差不多 500×300 左右但文件里并没有固定尺寸读进来之后需要自己处理。项目里常见的做法是先把所有图缩放到固定长宽我用的是 480×320长边缩放的同时按比例缩另一条边不足的地方补零。3.2 Dataset 类的正确写法这里我直接把踩过坑的版本贴出来你复制到自己的dataset.py里就能跑import os import torch from PIL import Image from torch.utils.data import Dataset class PennFudanDataset(Dataset): def __init__(self, root, transformNone, target_transformNone): self.root root self.transform transform self.target_transform target_transform self.imgs sorted(os.listdir(os.path.join(root, PNGImages))) self.masks sorted(os.listdir(os.path.join(root, Annotation))) def __getitem__(self, idx): img_path os.path.join(self.root, PNGImages, self.imgs[idx]) mask_path os.path.join(self.root, Annotation, self.masks[idx]) img Image.open(img_path).convert(RGB) mask Image.open(mask_path) # 掩膜中 255 是行人128 是轮廓这里把两者都归为前景 mask mask.point(lambda p: 1 if p 128 else 0) if self.transform is not None: img, mask self.transform(img, mask) if self.target_transform is not None: mask self.target_transform(mask) return img, mask def __len__(self): return len(self.imgs)这段代码有两个设计点要说明。第一我用mask.point(lambda p: 1 if p 128 else 0)把 255 和 128 统一成前景这样训练时就是二分类分割避免把轮廓单列一类后样本极度不平衡。第二transform必须同时处理 img 和 mask不能只对图片做随机翻转否则掩膜和原图对不上。我用的 transform 是一套自定义的Compose里面包含随机水平翻转、随机亮度抖动和尺寸缩放掩膜部分只做几何变换不做亮度处理。3.3 归一化参数怎么定Penn-Fudan 是自然场景图片不是 ImageNet 那种通用图库所以直接用 ImageNet 的 mean/std 可能会偏。稳妥做法是先统计整个数据集的均值和标准差我扫了一遍得到mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]和 ImageNet 恰好非常接近说明直接用 ImageNet 的参数没问题。如果你的项目换成其他数据集我还是建议先跑一段统计代码看看别直接抄参数。from torchvision import transforms transform_train transforms.Compose([ transforms.Resize((320, 480)), # 高, 宽 transforms.RandomHorizontalFlip(0.5), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) transform_mask transforms.Compose([ transforms.Resize((320, 480), interpolationImage.NEAREST), transforms.RandomHorizontalFlip(0.5), transforms.ToTensor() ])注意 mask 的Resize必须用Image.NEAREST不能用默认的双线性。否则掩膜边缘会被插值成 0.5 这类小数后面算损失时标签就不是 0/1 了。mask 的RandomHorizontalFlip一定要和 img 的翻转概率保持一致最好在同一个随机种子下执行我是在自定义 transform 里把两个随机状态绑定在一起的。4. FCN-8s 模型搭建与训练参数从 VGG 权重复用到逐步解冻4.1 backbone 复用还是自建最省事的做法是用 torchvision 里预训练的 VGG16把classifier整个丢掉只保留features部分。但注意features里最后一个 maxpool 的 ceil_mode 默认是 False输出尺寸偶尔会差 1 像素我的经验是直接把ceil_modeTrue改回来这样 480 宽的输入经过 5 次下采样后刚好是 15后续插值对齐方便。import torch.nn as nn import torch.nn.functional as F from torchvision import models class FCN8s(nn.Module): def __init__(self, num_classes2): super(FCN8s, self).__init__() vgg models.vgg16(pretrainedTrue) features list(vgg.features.children()) self.pool3 nn.Sequential(*features[0:17]) # 到 pool3 self.pool4 nn.Sequential(*features[17:24]) # 到 pool4 self.pool5 nn.Sequential(*features[24:31]) # 到 pool5 # 替换 fc6/fc7 为卷积 self.fc6 nn.Conv2d(512, 512, kernel_size7, padding3) self.fc7 nn.Conv2d(512, 512, kernel_size1) self.score_pool3 nn.Conv2d(256, num_classes, kernel_size1) self.score_pool4 nn.Conv2d(512, num_classes, kernel_size1) self.score_fc7 nn.Conv2d(512, num_classes, kernel_size1) self.upscore2 nn.ConvTranspose2d(num_classes, num_classes, kernel_size4, stride2, padding1) self.upscore8 nn.ConvTranspose2d(num_classes, num_classes, kernel_size16, stride8, padding4) def forward(self, x): pool3 self.pool3(x) pool4 self.pool4(pool3) pool5 self.pool5(pool4) fc7 self.fc7(self.fc6(pool5)) s3 self.score_pool3(pool3) s4 self.score_pool4(pool4) s7 self.score_fc7(fc7) # pool4 分支上采样 2 倍后与 pool3 相加 s7_2x F.interpolate(s7, sizes4.shape[2:], modebilinear, align_cornersFalse) s4 s4 s7_2x # pool3 分支上采样 2 倍后与上面结果相加 s4_2x F.interpolate(s4, sizes3.shape[2:], modebilinear, align_cornersFalse) s3 s3 s4_2x # 最终 8 倍上采样回原尺寸 out F.interpolate(s3, scale_factor8, modebilinear, align_cornersFalse) return out这里我保留了两个转置卷积层做形状转换但只用它们做浅层的 2 倍上采样最终 8 倍上采样还是用F.interpolate。这样设计的原因前面说过深层的 8 倍大跨度转置卷积容易出棋盘格而 2 倍的转置卷积只要初始化得当问题不大。如果你想全部用插值也可以把upscore2那两个层去掉直接F.interpolate(s4_2x, scale_factor2)效果几乎一样。4.2 权重初始化和学习率设置backbone 用预训练权重新增的fc6、fc7和三个score卷积层建议用高斯初始化标准差 0.01。我见过有人直接不初始化让 PyTorch 默认初始化训练初期 loss 下降特别慢。加上这一行就够了def init_new_weights(m): if isinstance(m, nn.Conv2d): nn.init.normal_(m.weight, std0.01) if m.bias is not None: nn.init.zeros_(m.bias) model FCN8s(num_classes2) model.apply(init_new_weights)学习率方面backbone 部分用 1e-4新加的层用 1e-3用两个参数组分别传入 Adam。如果不分开设置预训练权重很快会被新层的梯度带偏整个模型直接退化。我用的迭代轮数是 60batch size 设 4每 20 轮把学习率乘以 0.1。这个配置在 Penn-Fudan 上大概 8 分钟能训完一轮单张 RTX 3060。4.3 损失函数选 CrossEntropy 还是 DicePenn-Fudan 里行人区域占整张图的比例很小背景像素占绝对多数。直接 CrossEntropy 会出现“全都预测成背景”的假收敛loss 看着很小但 mIoU 是 0。我试过两种方案效果如下损失函数验证 mIoU备注普通 CrossEntropy41%背景占优导致分割几乎失效加权 CrossEntropy正类权重 566%有效但需要调权重CrossEntropy DiceLoss0.5 系数69%收敛更稳边界更连续所以我最后的训练脚本是loss 0.5 * ce_loss 0.5 * dice_loss。Dice 损失对前景像素不敏感天然处理不平衡但单独用 Dice 容易在训练后期震荡和 CE 结合起来最稳。实现时注意 Dice 是对每个 batch 单独算还是对整个 batch 聚合我习惯对整个 batch 的每一张图算 Dice 再取平均。4.4 训练循环里必须做的三件事第一每 5 个 epoch 在验证集上计算 mIoU不要只盯着 loss第二保存模型时同时保存model.state_dict()和optimizer.state_dict()方便断点继续第三对验证集的 mask 要做和训练一致的 resize否则尺寸对不上。代码里我用一个简单的评估函数把预测图argmax后和标签求交并比def compute_miou(pred, target, num_classes2): pred pred.argmax(dim1).cpu().numpy().flatten() target target.cpu().numpy().flatten() ious [] for cls in range(num_classes): p (pred cls) t (target cls) inter (p t).sum() union (p | t).sum() if union 0: continue ious.append(inter / union) return sum(ious) / len(ious)这个函数简单到有点粗暴但对二分类分割够用了。如果你要更严格的评估建议用torchmetrics.JaccardIndex不过自己写一遍能更好地理解 mIoU 的边界条件。5. 避坑手册Penn-Fudan FCN 最常见的五个翻车现场5.1 掩膜 resize 后类别变成小数现象训练 loss 刚开始非常小但验证集完全预测不出行人检查标签发现 mask 里的值是 0.2、0.8 这类小数。原因使用transforms.Resize时没有指定interpolationImage.NEAREST默认双线性插值把 0/1 硬编码的掩膜插成连续值。解决mask 的 transform 单独定义Resize 强制用 NEAREST。另外注意transforms.ToTensor()对 mask 也会除以 255所以如果再用point()函数先把像素变成 0/1再 ToTensor 就会变成 0/1没有缩放问题。5.2 预训练 backbone 的 BatchNorm 状态混乱现象训练前几个 epoch 正常后面验证 loss 突然升高预测图出现大块噪声。原因VGG16 features 里其实是带 BatchNorm 的VGG16_bn但很多人用models.vgg16不含 BN如果你换成了vgg16_bn在训练模式下 BN 会持续更新 running_mean/running_var而验证模式下又用固定统计量两者差异如果没处理好就会崩。解决既然 Penn-Fudan 数据集小直接冻结 backbone 的 BN 参数或者干脆用不带 BN 的普通 vgg16。我的做法是for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval()放在训练循环外面确保 BN 不参与训练更新。5.3 输入尺寸不是 32 的倍数导致形状对不上现象跑 forward 时在F.interpolate(s4, sizes3.shape[2:])报错尺寸差 1 或 2 像素。原因VGG 的卷积不改变 H/W但 maxpool 是下采样如果输入宽高不是 2 的倍数经过 5 次池化后可能出现上下采样后无法对齐的奇偶差异。解决把输入统一Resize到 320×480这两个数都能被 32 整除5 次池化后是 10×15反推每一步都对齐。如果要用动态尺寸在 forward 里别用scale_factor8直接乘改成显式size参数避免浮点误差。5.4 标签里 255 和 128 没合并导致类别数变成 3现象训练时 loss 正常但 mIoU 极低可视化预测图发现行人内部有大片空洞边缘倒是很准。原因Penn-Fudan 的掩膜里 255 是行人128 是轮廓如果直接除以 255 让 255 变 1、128 变 0.5再经过round()变成 0就把轮廓区域当背景了。或者你把它当作三分类训练但 mIoU 评估却按二分类算。解决参照我在 3.2 节写的那行mask.point(lambda p: 1 if p 128 else 0)在加载时就把 255 和 128 统一。如果你确实想区分轮廓就定义成三类但评估也要按三类算别混。5.5 训练时数据增强只作用于图片没作用于掩膜现象可视化训练样本时发现 mask 和图片内容错位行人在图片左边mask 的白色区域在右边。原因写了transform_train(img)和transform_mask(mask)两个独立 transform但没有保证随机水平翻转的随机种子一致导致图片翻转过而 mask 没翻转或两者翻转方向相反。解决不要用两个独立的transforms.RandomHorizontalFlip自己写一个类同时处理 img 和 mask用同一个torch.rand()结果决定是否翻转。我一般这样写class RandomHorizontalFlipBoth: def __init__(self, p0.5): self.p p def __call__(self, img, mask): if torch.rand(1).item() self.p: img torch.flip(img, dims[2]) mask torch.flip(mask, dims[2]) return img, mask注意这里 img 和 mask 都是 tensor 之后才能用torch.flip所以 transform 顺序是 ToTensor 之后再做翻转。6. 验证与进阶把 FCN-8s 的预测结果变成能用的行人掩膜6.1 推理时为什么要用滑动窗口或整图预测Penn-Fudan 测试阶段可以直接整图输入不需要滑动窗口因为图像分辨率很低。但要注意模型训练时用了随机翻转推理时要关掉model.eval()并且设置torch.no_grad()否则 BN 或 dropout 会让预测不稳定。model.eval() with torch.no_grad(): img_tensor img.unsqueeze(0).to(device) # 加 batch 维 output model(img_tensor) pred torch.argmax(output, dim1).squeeze(0).cpu().numpy()6.2 可视化预测掩膜的细节把预测的 0/1 数组转成彩色图时我通常会叠加在原图上半透明显示。如果只是存黑白图行人边缘的锯齿看着不明显但叠在原图上会非常清楚。你还可以把预测的前景区域用 scipy 的ndimage.binary_opening去掉小噪点再做binary_closing填补内部空洞。这两个形态学操作在 Penn-Fudan 这种行人分割图上很有用能把零散的预测点聚合成完整人形。from scipy import ndimage pred_mask pred.astype(bool) pred_mask ndimage.binary_opening(pred_mask, iterations1) pred_mask ndimage.binary_closing(pred_mask, iterations1)6.3 继续提升的验证从二分类到实例级后处理FCN 输出的是类别同一个人的多个连通域无法区分。如果你需要数人头还得接连通域标记。用ndimage.label(pred_mask)可以把每个连通区域标成不同 ID再按面积过滤掉小于 50 像素的碎块。这个后处理对遮挡场景能缓解不少但你别指望 FCN 能把重叠的人完全分开它的感受野决定它天生不擅长实例区分。最后一个技巧训练结束前我会把最后一个 epoch 的模型单独复制一份不覆盖最优模型。因为 FCN 的 loss 曲线后半段经常有微小震荡最优 mIoU 往往不在最后一个 epoch。所以我保存best_model.pth和last_model.pth两个文件评估时用 best继续训练用 last。从那以后我每次训练分割模型都强制走一遍“双模型保存 形态学后处理 连通域过滤”这套流程确实少走不少弯路。希望帮到你。本文还有配套的精品资源点击获取
返回列表