
简介全卷积网络FCN是语义分割领域的基石模型本套资源以Penn-Fudan行人分割数据集为载体面向需要在实战中掌握FCN原理、训练流程与评估方法的深度学习和计算机视觉学习者。压缩包共533个文件总体积184.68MB核心内容包含340张街道场景及对应分割掩码的PNG图像、172个TXT训练日志与标注说明、6个Python脚本以及XML、npy、pyc等模型与中间结果文件结构完整清晰。目前已有504人学习资源中提供了多种超参数训练方案例如不同epoch轮数、RMSprop优化器、学习率阶梯式衰减以及带二值交叉熵的损失函数并附带平均交并比、平均像素准确率等评估数据方便读者快速复现、对比不同设置下的分割效果。通过这套资源读者能够深入理解FCN如何利用全卷积结构和跳跃连接实现像素级预测也为行人检测、自动驾驶等实际场景提供可迁移的实践经验。 做语义分割的第一课很多教程会直接把你扔进DeepLab或者U-Net的坑里但真正把“分类网络”改造成“像素级预测网络”的那一步是FCN迈出去的。我最初在Penn-Fudan数据集上把全卷积网络FCN从零跑通的时候才对“全卷积”三个字的分量有了实感。这篇东西不是复读论文而是记录我怎么用这个经典结构在Penn-Fudan行人数据集上完成语义分割的完整过程包括网络怎么改、Mask怎么处理、训练时踩了哪些坑适合刚接触分割任务、想亲手跑通一个模型而不是只看示意图的读者。1. 全卷积网络到底改了什么丢掉全连接层之后1.1 为什么分类网络不能直接做分割在接触FCN之前我习惯的CNN结构是VGG、ResNet那一套堆卷积、池化最后接几个全连接层输出一个固定长度的向量。这个向量的每个位置对应一个类别的概率比如“这张图里有行人、有自行车、有背景”。分类任务只需要回答“这张图里有什么”所以全连接层是合理的——它把所有空间信息压缩成一个全局判断。但分割任务要回答的是“这张图里每个像素属于什么”。这里存在一个根本矛盾全连接层把空间位置彻底打散了。一旦经过全连接层图像的空间结构就没了——某个像素在图片左上角还是右下角在分类任务里无关紧要但在分割任务里是决定性的。所以FCN的第一个关键改动就是把最后几层全连接层换成卷积层。具体做法是VGG16里的classifier部分原本是三个全连接层4096-4096-1000FCN把前两层改造成1x1卷积把最后一层改造成1x1卷积并输出需要的类别数。1x1卷积在数学上和全连接等价但它保留了特征图的空间维度只是把通道数变换一下。这么一改网络就能接受任意尺寸的输入输出也不再是一个分类向量而是一张“热图”——每个位置都对应原图上某个区域的分类结果。1.2 分辨率下降的问题strided卷积和池化的代价网络虽然能输出热图了但热图的尺寸远远小于原图。VGG16里一共有5次2x2最大池化每次池化把分辨率减半所以最后一层特征图只有原图的1/32。在分类任务里1/32的尺寸完全没问题反正最后要全局平均池化或全连接但在分割任务里把一张32x32的热图上采样回1024x1024的预测图边缘细节早就糊掉了。FCN的做法不是一次性暴力上采样而是结合不同层的特征逐步恢复。论文里提出了三种结构FCN-32s直接把1/32的特征图上采样32倍FCN-16s把1/32的特征图上采样2倍和1/16的特征图相加再上采样16倍FCN-8s同理是在1/8尺度上融合。融合更浅层的特征相当于把底层的细粒度边缘信息和高层的语义信息拼在一起分割效果明显更好。我实际测试下来FCN-8s的轮廓质量比FCN-32s好不少行人边缘不再是一坨糊状色块。这也是为什么大多数开源代码默认实现FCN-8s。但要注意融合多层特征也意味着代码里要处理不同层之间的通道对齐这部分我后面会详细写。1.3 上采样不是简单缩放转置卷积的原理FCN里最容易被误解的部分是转置卷积也叫反卷积但这个叫法其实不精确。一开始我以为转置卷积就是某种“反向池化”后来查了资料才理解它本质上还是有参数的卷积操作只不过正向传播时把输入映射到更大的输出。理解转置卷积最直观的方式是看它怎么计算梯度普通卷积的梯度回传是把输出梯度分散回输入位置转置卷积做的就是把这套计算反过来。所以它不像双线性插值那样是固定的、没有参数的它的上采样核是可以学习的。FCN最初用的是固定的双线性插值初始化再用学习率微调实际训练中这样做收敛更快也不会出现棋盘效应。在PyTorch里转置卷积对应nn.ConvTranspose2d。代码里我常用的初始化方式是import torch.nn as nn def bilinear_kernel(in_channels, out_channels, kernel_size): factor (kernel_size 1) // 2 center kernel_size / 2 kernel torch.zeros((in_channels, out_channels, kernel_size, kernel_size)) for i in range(kernel_size): for j in range(kernel_size): kernel[:, :, i, j] (1 - abs(i 0.5 - center) / factor) * \ (1 - abs(j 0.5 - center) / factor) return kernel这个初始化在训练前期能显著稳定loss下降比从零学习一个上采样核靠谱得多。2. Penn-Fudan数据集几十张图也能做分割的理由2.1 数据集长什么样Penn-Fudan Database for Pedestrian Detection and Segmentation是宾夕法尼亚大学和复旦大学合作标注的行人检测与分割数据集。它一共包含170张图像其中96张需要做分割标注每张图像里标注了所有行人的实例掩码。别看它数量少它的价值在于标注质量高——掩码是逐像素手工标注的连行人的轮廓、手持物品的边界都比较精细。数据集的目录结构大致是PennFudanPed/ ├── PNMImage/ │ ├── FudanPed00001.png │ ├── FudanPed00002.png │ └── ... └── PedMasks/ ├── FudanPed00001_mask.png └── ...每个mask是一张PNG图背景像素值为0不同的行人实例用不同的像素值标记比如第一个人是1第二个人是2。这和很多目标检测数据集的0/255二值掩码不一样必须注意。2.2 用FCN做分割时怎么看语义标签严格来说Penn-Fudan的mask是“实例分割”级别的标注——每个行人都有独立编号。但FCN做的是“语义分割”只需要区分“行人”和“背景”。所以最简单的做法是把mask中所有非0像素都视为前景训练目标是二分类。这时候有个容易踩的坑如果直接对原始mask做torch.unique()你会看到像素值从1到N不等但语义分割的标签通道只有2个或者用1个通道表示0为背景1为行人。我的处理方式是在数据加载阶段把mask转成二值import torch import numpy as np def convert_mask_to_label(mask): mask np.array(mask) label (mask 0).astype(np.uint8) return torch.as_tensor(label, dtypetorch.long)这里要特别提一个坑FCN的输出层如果用nn.CrossEntropyLoss标签必须是long类型而且类别索引必须从0开始连续排列。如果你直接把原始mask的像素值比如行人是7喂给损失函数CrossEntropyLoss会把7当成第7个类别但网络只输出了2个通道训练直接报维度错误。别问我为什么知道——我深更半夜排查过两小时。2.3 几十张图怎么训练数据增强的必要性Penn-Fudan总共只有170张图训练集可能只有100多张。以这个规模直接端到端训练一个FCN几乎必然过拟合。所以数据增强不是“锦上添花”而是“保命手段”。我常用的增强组合随机水平翻转行人左右对称翻转不会破坏语义、随机缩放0.8到1.2之间、保持宽高比、随机裁剪到固定尺寸比如512x512。如果做翻转mask也要跟着翻做裁剪原图和mask要使用相同的裁剪参数。这就是为什么我习惯写一个自定义的Dataset类把原图和mask的transform绑在一起而不是分别对原图和mask调用不同的随机操作。值得注意的是这些几十张图的场景里测试集划分也很重要。我的策略是按图像编号间隔划分避免同一个场景里相似的行人姿势同时出现在训练集和测试集否则指标会虚高。3. 从零搭建FCN-8s网络结构、上采样与跳跃连接3.1 用预训练VGG16做backboneFCN论文里的backbone是VGG16。直接用随机初始化的VGG16去训练分割任务收敛极慢而且数据量不足的情况下效果很差。我建议使用在ImageNet上预训练过的VGG16权重。在PyTorch中加载预训练权重后要把classifier替换掉import torchvision.models as models backbone models.vgg16(weightsmodels.VGG16_Weights.IMAGENET1K_V1) features backbone.features # 保留卷积特征提取部分features部分输出的特征图通道数是512分辨率是1/32。后面我在这个基础上接FCN的头部。3.2 三个尺度分支怎么融合FCN-8s的结构我从后往前梳理最后一层特征图stride 32经过一个1x1卷积把通道数降到类别数num_classes再转置卷积上采样2倍得到1/16尺度的预测。同时取VGG16中第四个池化层前的特征图features[17]附近stride 16也做1x1卷积降维到num_classes然后和上面上采样后的特征逐元素相加。相加后的结果上采样2倍再和features[7]第三个池化层前stride 8的降维结果相加。最后上采样8倍输出和原图尺寸一致的预测图。我把这个结构封装成一个类import torch.nn as nn class FCN8s(nn.Module): def __init__(self, num_classes2): super().__init__() backbone models.vgg16(weightsmodels.VGG16_Weights.IMAGENET1K_V1) self.features backbone.features self.conv1x1_32 nn.Conv2d(512, num_classes, kernel_size1) self.conv1x1_16 nn.Conv2d(512, num_classes, kernel_size1) self.conv1x1_8 nn.Conv2d(256, num_classes, kernel_size1) self.upsample2x nn.ConvTranspose2d(num_classes, num_classes, kernel_size4, stride2, padding1) self.upsample8x nn.ConvTranspose2d(num_classes, num_classes, kernel_size16, stride8, padding4) self._init_upsample() def _init_upsample(self): for m in self.modules(): if isinstance(m, nn.ConvTranspose2d): w bilinear_kernel(m.in_channels, m.out_channels, m.kernel_size[0]) m.weight.data.copy_(w) def forward(self, x): pool3 None pool4 None for i, layer in enumerate(self.features): x layer(x) if i 16: # pool3之前 pool3 x if i 23: # pool4之前 pool4 x x self.conv1x1_32(x) x self.upsample2x(x) x x self.conv1x1_16(pool4) x self.upsample2x(x) x x self.conv1x1_8(pool3) x self.upsample8x(x) return x注意pool3和pool4的索引位置需要和features的实际结构对齐我用的VGG16里features前24层的索引是卷积层索引0、2、5、7、10、12、14、17、19、21池化层索引4、8、15、23。所以索引16是pool3之前的最后特征索引23是pool4所在位置。这个索引经常容易出错建议打印一下features[i]确认。3.3 为什么跳跃连接能缓解边缘模糊我一开始以为叠加多层特征只是多了一些语义信息后来观察分割结果才理解得更深浅层特征图的分辨率高保留了行人的边缘细节深层特征图分辨率低但语义上能区分出这是行人而不是乱七八糟的背景纹理。跳跃连接做的事情就是让最终预测同时利用两边的优势。如果只做FCN-32s不加跳跃连接模型往往会把行人轮廓扩大或缩小几圈看起来像水彩晕染。加上了跳跃连接之后边缘的锐利度有明显的提升。在Penn-Fudan这种行人轮廓比较规整的数据集上FCN-8s的边缘误差通常能比FCN-32s小一半左右。4. 训练细节与踩坑实录小数据集的特殊打法4.1 损失函数与类别不平衡问题语义分割里最常见的损失函数就是nn.CrossEntropyLoss。但Penn-Fudan的标注有个显著特点背景像素占据绝大多数。如果直接按照原始像素比例训练模型会倾向于把所有像素都预测成背景尤其当行人占画面的比例很小时。我用过一个很笨但有效的策略给CrossEntropyLoss传入weight提高前景类的权重。具体来说统计训练集中每个类别的像素比例把背景的权重设为1前景行人的权重设为背景像素数除以行人像素数。这个比例在Penn-Fudan上大概是几十比一到几百比一不等取决于行人大小。另一种常用的损失是Dice Loss它对类别不平衡没那么敏感因为它直接衡量预测区域和真实区域的重合度。我在实验里会把CrossEntropy和Dice按7:3的比例加起来用一来保持分类准确性二来避免模型对背景过拟合。4.2 学习率与优化器该用SGD还是Adam很多做分割的教材默认使用SGD加momentum但我实际在小数据集上体验Adam收敛更快调试成本更低。如果你只有一晚上时间跑实验我建议用Adam初始学习率设1e-4配合ReduceLROnPlateau等验证loss停滞时把学习率降一半。如果用SGD初始学习率可以设1e-3但是momentum设为0.9weight decay不要太大。小数据集上weight decay过大会导致模型欠拟合。我试过weight decay设1e-2的情况val mIoU直接掉了近10个百分点。关于batch size显存允许的情况下尽量用大batch。FCN本身比较吃显存我用512x512输入、batch size为4的时候一张12GB的卡刚好能训练。如果你的显卡只有8GB输入尺寸降到384x384或者把batch size降到2但要注意batch normalization在batch size太小时表现不稳定。4.3 我踩过的那些坑标签错位、Mask格式与验证集划分这个部分是我最想分享的内容。第一个坑是加载mask时用了PIL.Image.open但没有调用convert(L)导致读取到的mask带四个通道RGBA后面转torch.long时直接报错。第二个坑是数据增强时只翻转了原图没翻转mask训练loss正常下降但val的IoU一直上不去看了可视化结果才发现mask和原图左右错位了。第三个坑相对隐蔽验证集的划分没有注意数据集是按场景拍摄的。Penn-Fudan中有几张图拍摄于同一地点行人姿态和背景高度相似如果这些图同时出现在训练集和验证集验证指标会比真实水平高很多。所以按文件名的奇偶性划分并不可靠最好按拍摄场景分组。我把训练流程简单写成这样optimizer torch.optim.Adam(model.parameters(), lr1e-4) criterion nn.CrossEntropyLoss(weightclass_weight) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemax, patience10) for epoch in range(100): model.train() total_loss 0 for images, masks in train_loader: images, masks images.cuda(), masks.cuda() preds model(images) loss criterion(preds, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item()5. 评估与可视化别只盯着指标看5.1 mIoU和Pixel Accuracy什么时候会骗你语义分割最常用的评估指标是IoUIntersection over Union公式是预测正确的像素数 / (预测为该类别的像素数 真实为该类别的像素数 - 预测正确的像素数)。mIoU是所有类别IoU的平均。但Penn-Fudan这类行人分割场景里背景类占比太大Pixel Accuracy整体像素准确率会非常高——哪怕模型完全没分割出行人只预测全背景准确率也可能超过95%。所以我看结果时最关注前景类IoU而不是平均IoU更不是Pixel Accuracy。我的习惯是跑完每个epoch后单独计算行人类别的IoU把它作为早停的指标。如果模型的整体mIoU很高但行人IoU很低说明它在背景上表现好、在目标物体上偷懒这种情况在类别不平衡的数据集里太常见了。5.2 可视化预测Mask的几个工具与技巧指标始终是抽象的语义分割的最终效果看的是可视化结果。我一般在训练结束后随机挑几个验证集样本把原图、预测Mask、真实Mask叠加画出来。可视化不能只看“看起来差不多的”更要注意那些预测失败的区域——行人被栅栏遮挡、行人之间互相重叠、背景中有和行人颜色相似的物体这类场景往往是分割模型的真正瓶颈。我用一段简单的代码保存对比图import matplotlib.pyplot as plt def visualize_prediction(image, pred, mask, idx): fig, axes plt.subplots(1, 3, figsize(15, 5)) axes[0].imshow(image.permute(1, 2, 0).cpu().numpy()) axes[0].set_title(Image) axes[1].imshow(pred.cpu().numpy(), cmapgray) axes[1].set_title(Prediction) axes[2].imshow(mask.cpu().numpy(), cmapgray) axes[2].set_title(Ground Truth) plt.savefig(fresult_{idx}.png, bbox_inchestight) plt.close()5.3 实测效果我在Penn-Fudan上得到的经验数字虽然模型效果受随机种子影响但我在多轮实验里得到一个大概范围FCN-8s VGG16预训练 Adam CrossEntropy Dice混合损失在96张分割图的子集上把训练集和验证集按约8:2划分行人IoU大约在0.72到0.78之间。如果再叠加一些简单的后处理比如用全连接条件随机场CRF做边缘细化还能再提高一个百分点左右但代价是每张图的推理时间增加不少。对比FCN-32s实测行人IoU会低大约5到8个点。差距主要体现在边缘和细小部位比如行人的头部、手持雨伞的边缘。FCN-16s介于两者之间。如果只用随机初始化而不加载预训练权重同样的训练轮数下行人IoU可能只有0.3左右差距非常大。所以在小数据集上预训练权重几乎决定了下限。另外还有一点Penn-Fudan的分割任务是实例级的如果你想在它的基础上做实例分割可以保留每个实例的编号把训练目标改成“按实例ID区分通道”那就接近Mask R-CNN的思路了。不过FCN本身做不了这个至少需要加一个实例分组分支这也是我后续准备尝试的方向。就我自己跑下来的体会FCN虽然看起来“老”但它把分割问题的核心矛盾展现得非常直白怎么保留空间信息、怎么恢复分辨率、怎么用跳跃连接融合多尺度特征。后面学U-Net、DeepLab的时候你会发现它们的许多设计都是在解决FCN暴露出来的问题。把FCN吃透了后面读论文调模型的效率会高很多。本文还有配套的精品资源点击获取