简介:面向语义分割入门与实战的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:把“分类网络”改成“像素分割”的最短路径
做语义分割的同行应该都听过这个名字:全卷积网络(FCN),2015 年提出的老架构,但直到现在,只要你想把分类网络改成端到端的像素级分割,它依然是最容易讲清楚、最容易复现的模板。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_size=1)就是这个意思。
2.2 上采样:反卷积还是双线性插值
FCN 原文用的是转置卷积(反卷积)做上采样,但实际工程里我更推荐先用双线性插值把特征图拉到目标尺寸,再用 1×1 卷积调整通道数。原因很简单:转置卷积会引入可学习的参数,训练不好会出现棋盘效应,而且对数据集较小时反而容易过拟合。
Penn-Fudan 一共只有 170 张带标注的图,哪怕做数据增强,参数量大的转置卷积也未必占便宜。我第一次复现时用的转置卷积,验证集 mIoU 只到 62% 上下,换成双线性上采样之后到了 67%,代价只是上采样层没有参数而已。所以后面的代码我全程用F.interpolate(mode='bilinear', align_corners=False),只在最后拼接层保持原文的跳跃结构。
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存放原始 PNG,Annotation存放掩膜 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, transform=None, target_transform=None): 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), interpolation=Image.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_mode=True改回来,这样 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_classes=2): super(FCN8s, self).__init__() vgg = models.vgg16(pretrained=True) 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_size=7, padding=3) self.fc7 = nn.Conv2d(512, 512, kernel_size=1) self.score_pool3 = nn.Conv2d(256, num_classes, kernel_size=1) self.score_pool4 = nn.Conv2d(512, num_classes, kernel_size=1) self.score_fc7 = nn.Conv2d(512, num_classes, kernel_size=1) self.upscore2 = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=4, stride=2, padding=1) self.upscore8 = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=16, stride=8, padding=4) 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, size=s4.shape[2:], mode='bilinear', align_corners=False) s4 = s4 + s7_2x # pool3 分支上采样 2 倍后与上面结果相加 s4_2x = F.interpolate(s4, size=s3.shape[2:], mode='bilinear', align_corners=False) s3 = s3 + s4_2x # 最终 8 倍上采样回原尺寸 out = F.interpolate(s3, scale_factor=8, mode='bilinear', align_corners=False) return out这里我保留了两个转置卷积层做形状转换,但只用它们做浅层的 2 倍上采样,最终 8 倍上采样还是用F.interpolate。这样设计的原因前面说过:深层的 8 倍大跨度转置卷积容易出棋盘格,而 2 倍的转置卷积只要初始化得当,问题不大。如果你想全部用插值,也可以把upscore2那两个层去掉,直接F.interpolate(s4_2x, scale_factor=2),效果几乎一样。
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, std=0.01) if m.bias is not None: nn.init.zeros_(m.bias) model = FCN8s(num_classes=2) model.apply(init_new_weights)学习率方面,backbone 部分用 1e-4,新加的层用 1e-3,用两个参数组分别传入 Adam。如果不分开设置,预训练权重很快会被新层的梯度带偏,整个模型直接退化。我用的迭代轮数是 60,batch size 设 4,每 20 轮把学习率乘以 0.1。这个配置在 Penn-Fudan 上大概 8 分钟能训完一轮(单张 RTX 3060)。
4.3 损失函数选 CrossEntropy 还是 Dice
Penn-Fudan 里行人区域占整张图的比例很小,背景像素占绝对多数。直接 CrossEntropy 会出现“全都预测成背景”的假收敛,loss 看着很小,但 mIoU 是 0。我试过两种方案,效果如下:
| 损失函数 | 验证 mIoU | 备注 |
|---|---|---|
| 普通 CrossEntropy | 41% | 背景占优导致分割几乎失效 |
| 加权 CrossEntropy(正类权重 5) | 66% | 有效但需要调权重 |
| CrossEntropy + DiceLoss(0.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_classes=2): pred = pred.argmax(dim=1).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时没有指定interpolation=Image.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, size=s3.shape[2:])报错,尺寸差 1 或 2 像素。
原因:VGG 的卷积不改变 H/W,但 maxpool 是下采样,如果输入宽高不是 2 的倍数,经过 5 次池化后可能出现上下采样后无法对齐的奇偶差异。
解决:把输入统一Resize到 320×480,这两个数都能被 32 整除,5 次池化后是 10×15,反推每一步都对齐。如果要用动态尺寸,在 forward 里别用scale_factor=8直接乘,改成显式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, p=0.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, dim=1).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, iterations=1) pred_mask = ndimage.binary_closing(pred_mask, iterations=1)6.3 继续提升的验证:从二分类到实例级后处理
FCN 输出的是类别,同一个人的多个连通域无法区分。如果你需要数人头,还得接连通域标记。用ndimage.label(pred_mask)可以把每个连通区域标成不同 ID,再按面积过滤掉小于 50 像素的碎块。这个后处理对遮挡场景能缓解不少,但你别指望 FCN 能把重叠的人完全分开,它的感受野决定它天生不擅长实例区分。
最后一个技巧:训练结束前,我会把最后一个 epoch 的模型单独复制一份,不覆盖最优模型。因为 FCN 的 loss 曲线后半段经常有微小震荡,最优 mIoU 往往不在最后一个 epoch。所以我保存best_model.pth和last_model.pth两个文件,评估时用 best,继续训练用 last。从那以后我每次训练分割模型都强制走一遍“双模型保存 + 形态学后处理 + 连通域过滤”这套流程,确实少走不少弯路。希望帮到你。
本文还有配套的精品资源,点击获取