☰
全卷积网络FCN详解:语义分割原理、跳跃连接与行人分割实战
2026/11/6 4:25:44 网站建设 项目流程

简介:面向图像语义分割初学者与行人检测实践者,这套资料以全卷积网络(FCN)在 Penn-Fudan 数据库上的训练与评测为主线,包含街景图像、分割掩模、标注及模型评估结果,可用于理解端到端像素级分类、跳跃连接与上采样恢复分辨率的实现。压缩包共 533 个文件、约 184.68MB,以 png 图像与掩模、txt 日志/标注、py 训练推理脚本为主,并配有 npy 指标和 xml 标注,目录保留多组不同超参数的实验记录,便于对照 batch、学习率、优化器与调度策略对分割效果的影响。已有 507 人学习,读者可据此复现 FCN 训练流程,查看 meanIU、meanPixel 等评测结果,并利用 Penn-Fudan 的精细轮廓标注完成行人分割模型的调试与结果分析。

1. 从一张街景照片说起:全卷积网络到底解决了什么

从一张街景照片里把行人“抠”出来,这件事在计算机视觉里叫语义分割——不是画一个方框,而是给每一个像素贴上“人”或“背景”的标签。很多人第一次听到全卷积网络(Fully Convolutional Network,FCN)会以为它只是把网络末尾的全连接层删掉的小手术,其实它是把“整张图判一个类别”改造成“每个像素判一个类别”的那次转身。打个比方,传统分类网络像阅卷老师只给整张卷子打一个总分,全卷积网络则是逐题批改、每道题都留一个分数,最后还原出一张和原卷同样大小的评分表。Penn-Fudan 这类行人数据集,就是练这张评分表的合适素材:图不多,标注却细到像素。

2. 全卷积网络到底在做什么:从整图打分到逐像素贴标签

把“分类”和“分割”放在一起对比,差别其实只有一句话:分类网络的输出是一个长度为类别数的向量,分割网络的输出是一张和原图一样大的标签图。全卷积网络的全部设计,都是围绕“怎么让输出重新变回一张图”展开的。理解这一点,后面那些上采样、跳跃连接、1×1 卷积才不至于变成一堆孤立的零件。

2.1 卷积、下采样、上采样:一条先压扁再摊开的流水线

前两个动作并不新鲜。卷积层负责在图像上滑动取样,提取边缘、纹理、部件这些特征;池化层(或者步长大于 1 的卷积)负责把特征图的长宽一次次减半。一张 512×512 的图经过五次减半,就剩 16×16,通道数却从 3 涨到几百——空间信息被压缩,语义信息被浓缩。到这一步为止,全卷积网络和普通的卷积分类网络走的是同一条路。

真正分岔的地方在末尾。分类网络会把 16×16×256 的特征图拉平成一个长向量,接几层全连接,吐出“有人/没人”两个数;全卷积网络则用一层 1×1 卷积,把每个空间位置上的 256 维向量单独映射成“背景/行人”两个分数,得到一张 16×16×2 的粗打分图。注意这里的关键:1×1 卷积在数学上等价于对每个位置各做一次全连接,但它保住了“哪个分数属于哪个位置”的对应关系。

最后一步是上采样。把这 16×16 的打分图放大 32 倍,回到 512×512,每个像素取分数最高的那一类,就得到最终的分割掩码。整条流水线可以概括成:卷积提特征,池化压尺寸,1×1 卷积换通道,上采样还原分辨率。

提示:上采样本身不产生新信息。放大 32 倍的打分图,边缘一定是模糊的,这是全卷积网络最原始的形态(FCN-32s)最被诟病的地方,也是下一节要解决的问题。

2.2 从 FCN-32s 到 FCN-8s:跳跃连接补的是细节

第一次跑通 FCN-32s 的人往往会失望:行人的大致位置是对的,但轮廓像被水泡过,手臂和躯干糊成一团,两个人挨得近就直接粘在一起。原因不难找——最终的打分图只有原图的三十二分之一,一个格子对应原图 32×32 的一块区域,边界精度天然被限制在这个尺度上。

解决办法不是把下采样去掉,而是把中间层的细节“借”回来。主干网络在 1/2、1/4、1/8、1/16 这些尺度上都留下了特征图,它们分辨率更高、语义更弱。FCN-16s 的做法是:把最终 1/32 的预测上采样 2 倍,和 1/16 那一层的预测逐元素相加,再一起上采样 16 倍;FCN-8s 则继续把 1/8 那层也加进来,最后上采样 8 倍。这套相加的操作叫跳跃连接(skip connection)。

把它想象成画一张大地图:先用粗笔勾出城市轮廓,再把中间尺度的图纸叠上去补出街区,最后叠上最细的图纸补出街道。每一层都在修正上一层的模糊,但没人指望最细的图纸能提供“这是住宅区还是商业区”的判断——语义仍然由最粗那层说了算。所以 FCN-8s 通常比 FCN-32s 好,但也不是层数叠得越多越好,尺度差太远的特征硬加在一起反而互相干扰。

2.3 把行人数据集喂进去:输入、标签、输出各长什么样

Penn-Fudan 行人数据集的体量很小,百张级别的街景图、几百个行人实例,标注形式是每张图配一张掩码图,像素值只有 0 和 1(有些版本存成 0 和 255)。这意味着类别数是 2:背景和行人。样本少、类别少,恰好适合把全卷积网络的每一个环节都看清楚,不至于一上来就被大规模数据和几十个类别淹没。

送入网络前,图像要做归一化并转成[C, H, W]的浮点张量,掩码要保持整型——它是类别索引,不能当连续值处理。网络的输出是[B, C, H, W],掩码是[B, H, W],两者在空间维度上必须严格对齐,差一个像素,损失函数就会把边界学歪。

下面这段骨架把前面说的四步串了起来,主干只保留三次下采样以便观察形状变化:

import torch import torch.nn as nn class TinyFCN(nn.Module): def __init__(self, num_classes=2): super().__init__() # 主干:逐步下采样,总步长 8(真实 FCN 用 5 次池化,步长 32) self.backbone = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 1/2 nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 1/4 nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 1/8 ) # 1x1 卷积:逐位置把 256 维特征映射成 num_classes 个类别打分 self.classifier = nn.Conv2d(256, num_classes, kernel_size=1) # 双线性插值上采样,把打分图拉回输入分辨率 self.up = nn.Upsample(scale_factor=8, mode="bilinear", align_corners=False) def forward(self, x): feat = self.backbone(x) # [B, 256, H/8, W/8] score = self.classifier(feat) # [B, 2, H/8, W/8] return self.up(score) # [B, 2, H, W] x = torch.randn(2, 3, 256, 256) print(TinyFCN()(x).shape) # torch.Size([2, 2, 256, 256])

这段代码里有三个参数值得盯住:kernel_size=1决定了“逐像素分类”这件事,改成 3 就变成看邻域了;scale_factor=8必须和主干的总下采样倍数一致,写错就是尺寸对不上;mode="bilinear"是可学习转置卷积之外最省事的选择,它没有参数,但也不会自己学出更锐利的边界。真正训练时,输出还要和掩码算逐像素交叉熵,取 argmax 之后才是给人看的分割图。

3. 为什么要换掉全连接层:三个绕不开的理由

如果只是想给图片分类,全连接层工作得很好,没人会费劲改结构。全卷积网络的存在,是因为“输出一张图”这个需求把全连接层的三个短板同时暴露了出来。搞清楚这三件事,比记住网络结构图有用得多。

3.1 全连接层为什么“卡”分辨率

全连接层的权重矩阵形状是固定的,输入维度一变,矩阵就对不上。而它前面的特征图长宽由输入图像尺寸决定,所以整条链路被迫要求固定输入分辨率:训练时用 224×224,测试时给一张 1024×768 的图,要么缩放(细节没了),要么裁切(内容没了)。

更麻烦的是信息形态的变化。拉平(flatten)这个动作,把“第 3 行第 5 列有个什么特征”变成了“向量第 741 位是多少”。位置信息在拉平的瞬间就散了,后面无论多少层全连接,都无法回答“这个特征在原图哪个位置”。分类任务不在乎这件事,分割任务却必须回答——这正是全连接层在分割任务里水土不服的根本原因。

3.2 滑窗法与全卷积:重复计算带来的额外开销

在全卷积网络普及之前,用分类网络做分割的经典办法是滑窗:以每个像素为中心抠一个 patch,送进分类器,取 patch 中心像素的类别作为该像素的预测。逻辑上完全正确,工程上却很不经济。

一张 512×512 的图,用 64×64 的窗口逐像素滑一遍,要跑 26 万次前向。相邻两个 patch 有 63×64 个像素是重叠的,这些重叠区域的卷积被反复计算了成千上万次。全卷积网络的做法相当于把这 26 万次计算合并成一次:整图前向一遍,特征图上的每个位置天然对应原图的一块感受野,共享计算、共享参数。同样一张图,计算量差出好几个数量级,这就是当年全卷积思路最直接的价值。

注意:全卷积并不等于“每个像素独立判断”。一个输出位置看到的是它对应的感受野,感受野越大,判断时参考的上下文越多。所以“全卷积”省掉的是重复计算,不是上下文。

3.3 训练信号怎么落到每个像素上

分类任务一个样本产生一个损失值,分割任务一个样本产生 H×W 个损失值,然后取平均。用逐像素交叉熵时,每个位置的预测分布和该位置的真实类别比较,梯度沿着这个平均损失回传到所有位置。

实现上有两个细节容易翻车。一是掩码的数据类型,必须是long(int64)才能作为CrossEntropyLoss的目标,用 float 会直接报错或者算出莫名其妙的结果。二是忽略区域,有些数据集把“不确定属于谁”的像素标成 255,这时要给损失函数传ignore_index=255,否则这些像素会以“第 255 类”的身份参与训练,把模型带偏。

还有一个隐藏问题:行人像素往往只占全图百分之几,剩下全是背景。逐像素平均意味着背景贡献了绝大部分梯度,模型很快学会“全预测背景”这条捷径,损失降得漂漂亮亮,分割图却一片空白。这不是模型不行,是损失函数的默认设定没考虑类别不平衡。常见对策是给CrossEntropyLoss传weight=[0.1, 0.9]之类的类别权重,或者换用 Dice 这类对前景更敏感的损失。

4. 易混概念辨析:四组最容易搞错的关系

全卷积网络周边的名词密度很高,初学者经常把几个不同的东西当成同一件事,然后在调参时对着完全正确的代码怀疑人生。下面四组是出现频率最高的混淆点,每一组都值得单独说清楚。

4.1 语义分割、实例分割、目标检测

易混点:三者都能在图上标出行人,看起来只是“标注精细程度不同”。

为什么会混:很多演示图把检测框、彩色掩码画在同一张图上,视觉上都是“把目标圈出来”。

怎么区分:看输出是什么。目标检测输出一组矩形框加类别和置信度,同一个人只对应一个框;语义分割输出一张与原图同尺寸的类别图,所有行人共享同一个“行人”标签,两个紧挨着的人会连成一片;实例分割输出的是“每个个体一张掩码”,能区分出“这是第一个人、那是第二个人”。全卷积网络本身解决的是语义分割,它天然分不清个体。想在 Penn-Fudan 上做实例分割,得在像素分类之外再加一套实例区分机制,难度不在一个量级。

4.2 全卷积网络与普通卷积网络

易混点:既然名字里都有“卷积”,很多人默认“用了卷积的网络就是全卷积网络”。

为什么会混:绝大多数图像分类网络的主体确实是一堆卷积层,只有最后几层是全连接。从层数比例看,卷积占大头,于是“卷积网络”和“全卷积网络”被当成同义词。

怎么区分:看有没有全连接层、看输出形态。只要网络末尾还用 flatten 加全连接输出一个向量,它就是普通卷积网络,输入尺寸受限制,输出没有空间维度。全卷积网络要求网络中所有层都对空间位置有感知,输出保留 H×W,可以直接和原图对齐。一个简单的自检方法:把输入尺寸从 256 改成 320,能正常跑通的才可能是全卷积;报维度不匹配的,一定还留着全连接。

4.3 转置卷积、上采样与“反卷积”

易混点:三个词经常被混着用来指“把特征图放大”这个动作。

为什么会混:早期文献把转置卷积叫成反卷积(deconvolution),这个名字误导性极强——它让人以为存在一个和卷积完全互逆的运算。

怎么区分:上采样是上位概念,只要尺寸变大就算,最近邻插值、双线性插值都属于这一类,没有任何可学习参数,只是按固定规则填值。转置卷积是一种可学习的上采样,它把输入每个位置乘上权重铺到输出的一块区域,权重在训练中学出来,可能学到比插值更锐利的边界,也可能学出一堆棋盘格伪影。至于“反卷积”,严格意义上指的是卷积的逆运算(已知输出求输入),和分割里的放大操作不是一回事,只是历史遗留的叫法。工程上的经验是:先用双线性插值加普通卷积,跑通了再考虑换成转置卷积,别一上来就调那些容易出伪影的层。

4.4 输出尺寸相同不等于信息没丢

易混点:看到最终输出和输入一样是 512×512,就认为“分辨率完全保留”。

为什么会混:尺寸对齐是肉眼可见的,而信息损失是看不见的。

怎么区分:看这张输出图是怎么来的。如果它是从 16×16 直接放大 32 倍得到的,那它本质上只有 256 个独立的判断,其余像素都是插值编出来的——像素数够了,独立信息量并没有增加。判断办法很简单:把输出下采样回 1/32 再放大回去,如果和原输出几乎一样,说明这张图的有效细节本来就只到那个尺度。这也是为什么要引入跳跃连接:不是为了把尺寸做大,而是为了让更多尺度的独立判断参与进来。

5. 训练细节里的坑:尺寸对齐、标签与指标

结构搞清楚之后,真正消耗时间的往往是那些不起眼的工程细节。在行人数据集这种小样本场景里,一个尺寸对齐问题就能让 IoU 掉十几个点,而你盯着损失曲线看半天也看不出问题出在哪。

5.1 32 倍下采样带来的对齐问题

主干有五次步长为 2 的下采样,总步长 32。输入 256×256 没问题,输入 250×250 就麻烦了:250 除不尽 32,最后一层池化会丢掉边缘的几个像素,上采样 32 倍之后得到 256×256,和原图差 6 个像素。等你把预测和掩码相减算损失时,形状对不上,程序直接报错;就算强行 resize 对齐,标签和预测也会整体错位几个像素,边界全部学歪。

稳妥的做法是预处理阶段就把图像和掩码一起 pad 到 32 的整数倍,记录下原始尺寸,推理完成后裁回去。注意掩码的 pad 值要填成忽略索引(比如 255),不能用 0——0 是背景,填 0 等于凭空给模型塞了一堆“这是背景”的假答案,边界附近会被压平。

def pad_to_multiple(img, mask, stride=32, ignore_index=255): # img: [C, H, W] 浮点张量;mask: [H, W] 整型张量 h, w = mask.shape ph = (stride - h % stride) % stride # 需要补的高度 pw = (stride - w % stride) % stride # 需要补的宽度 img = torch.nn.functional.pad(img, (0, pw, 0, ph), mode="constant", value=0) # 掩码补忽略值,避免把 pad 区域当成背景参与训练 mask = torch.nn.functional.pad(mask, (0, pw, 0, ph), mode="constant", value=ignore_index) return img, mask, (h, w)

(stride - h % stride) % stride这种写法是为了处理“刚好整除”的情况:直接写stride - h % stride会在整除时多补一整条边。返回原始尺寸(h, w)是为了推理后裁剪,训练时不需要。

5.2 标签编码与极端类别不平衡

掩码里的像素值必须和类别定义严格对应。常见错误是把掩码当灰度图读进来归一化到 0~1,再乘个 255 转回去,插值过程会凭空造出 37、128 这种不存在的类别索引,训练时直接触发越界错误。读取掩码时用最近邻插值、保持整型,是唯一安全的做法。

不平衡的问题在这个数据集上格外突出。一张街景图里行人可能只占 3% 的像素,模型全预测背景就能拿到 97% 的像素准确率——这个数字好看得让人不想改代码,但把预测图打出来一看,全黑。可行的缓解手段有三类:给损失函数加类别权重,让前景像素的梯度放大;用 Dice 损失代替或混合交叉熵,它直接优化重叠度而不是逐像素正确率;在采样阶段做文章,优先裁出含行人的区域作为训练 patch,让前景在 batch 里的占比接近一半。

5.3 指标:像素准确率为什么骗人

承接上一节,评估指标必须选对,否则你连“模型是不是在偷懒”都判断不出来。语义分割最常用的两个指标是 IoU(交并比)和 Dice,它们都按类别单独计算再平均,前景预测错了就一定会掉分,没法靠猜背景糊弄过去。IoU 的直觉含义是“预测为行人的区域和真实行人区域,重叠部分占两者并集的比例”,取值 0~1,越接近 1 越好。

def per_class_iou(pred, target, num_classes=2, eps=1e-6): # pred / target: [N, H, W] 整型张量,元素是类别索引 ious = [] for c in range(num_classes): p = (pred == c) # 预测为该类的布尔掩码 t = (target == c) # 真实为该类的布尔掩码 inter = (p & t).sum().item() # 交集 union = (p | t).sum().item() # 并集 ious.append((inter + eps) / (union + eps)) return ious

eps的作用是防止某一类在 batch 里完全没出现时出现 0/0;它对结果的影响在小数据集上不可忽略,所以对比不同实验时要确认用的是同一个eps。返回值是逐类的列表,通常报“前景 IoU”比报平均值更有意义——背景那一类的 IoU 常年 0.95 以上,把它平均进去只会掩盖问题。另外,小数据集上的单张图 IoU 波动很大,务必在验证集整体上算,别拿一张图的结果下结论。

6. 什么时候该用全卷积网络:一条判断线

判断标准其实可以压缩成一句话:你需要“每个像素一个答案”,而且不需要区分同类个体,那就用;只要有一条不满足,就该换别的路子。

适合的情况很明确:输出必须和输入同尺寸、逐像素对齐,比如行人区域提取、道路与背景的分离、缺陷区域的粗略定位;输入分辨率不固定,同一个模型要吃不同尺寸的图;数据量不大但标注是像素级的,这时候全卷积网络结构简单、参数少,反而不容易过拟合。在 Penn-Fudan 这种百张级别、只有两个类的数据集上,一个带跳跃连接的小型全卷积网络往往比一堆复杂模块更靠谱。

不该用的情况同样明确,列在下面这张表里更直观:

你的真实需求全卷积网络合适吗更合适的选择
只要知道图里有没有人、人在哪不合适,杀鸡用牛刀目标检测或图像分类
要区分“这是甲还是乙”不合适,语义分割天然合个体实例分割
需要实时处理高分辨率视频谨慎,逐像素计算代价高轻量主干 + 下采样推理
标注只有框、没有像素掩码无法直接训练先用框做弱监督,或补标注
目标极小、占几十个像素谨慎,多次下采样后可能消失提高输入分辨率或改膨胀卷积

最后说个自己的教训。早些年我在一个行人分割的小项目上,模型训了两天,损失稳稳下降到 0.08,像素准确率 96%,我以为大功告成,把结果图打出来才发现整张图全黑——模型学会了无脑预测背景。回头查原因,既不是网络结构的问题,也不是学习率的问题,就是损失函数没处理类别不平衡,加上评估只看了像素准确率这一项。从那以后,我每次开训之前都强制走一遍三件事:先把掩码的类别分布统计出来打印一眼,再把 IoU 按类算出来当基线,最后一定要把预测图可视化出来亲眼看几张。这三步加起来不到十分钟,省下的往往是两天。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询