ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

全卷积网络FCN:语义分割原理、上采样与Penn-Fudan实战

全卷积网络FCN:语义分割原理、上采样与Penn-Fudan实战

简介:这是一份围绕全卷积网络(FCN)与Penn-Fudan Database构建的图像分割实践资料,面向已具备CNN基础、希望掌握像素级语义分割与行人检测流程的学习者。内容以FCN端到端分割为主线,涉及VGG/ResNet骨干、上采样、跳跃连接及交叉熵损失等关键环节,并配合Penn-Fudan街景图像与精细掩模,便于理解训练、预测和评估的完整链路。压缩包共533个文件,约184.68MB,主要由png图像与分割掩模、txt说明及日志、py训练脚本、npy评估指标与xml标注等组成,兼顾数据、代码和实验记录,目录组织适合按模块查阅。已有507人学习或下载,可作为课程作业、分割入门实验和行人分割复现的参考,帮助读者快速理清FCN的数据组织、参数配置与结果分析思路。

1. 全卷积网络到底是个什么东西:从「图里有没有人」到「人占了哪几个像素」

给一张照片,问「图里有没有人」,这是分类;换一个问法——「图里每一个像素,属于人还是属于背景」,这是分割。全卷积网络(Fully Convolutional Network,常缩写成 FCN)就是干后面这件事的模型底座。

打个比方,分类模型像保安站在门口喊一嗓子「有人进来了」;全卷积网络像保安拿荧光笔,把监控画面里每个属于人的像素都涂上颜色,最后交给你一张描好边的图。区别不在谁更聪明,而在输出的形状根本不同——一个是几个数字,一个是和原图一样大的一整张标签图。

值得搞懂它的原因很实在:今天你见到的大多数图像分割模型,往回追都能追到这套「全卷积」的思路。搞懂它,等于拿到了理解像素级预测这件事的钥匙,也才看得懂后面那些模型到底在改什么。

2. 为什么非要把全连接层拆掉:像素级输出绕不过去的那道坎

2.1 分类网络最后那块「压路机」

传统图像分类网络的套路很固定:几组卷积加池化,把图越缩越小、通道越堆越多,最后把特征图拉平成一维向量,接上两三层全连接,输出每个类别的分数。整条链路下来,输入一张图,输出一句话。

问题出在「拉平」这一步。特征图本来是 7×7×512 的三维结构,里面记录的是「图左上角有什么、右下角有什么」,拉平成一维以后,这些位置信息被搅在了一起。再经过全连接层的权重矩阵,输出的向量里只剩「整张图整体像猫」这样的结论,具体哪块区域像猫,信息已经找不回来了。

打个比方,把一张地图揉成纸团,再问「第三行第五列是河还是山」。纸团的重量还在,图上画的内容也还在,但你没法按坐标去问它问题了。分割任务恰恰全是按坐标问问题的,这一步就把路堵死了。

还有第二个副作用:全连接权重矩阵的形状是死的,输入维度一变,矩阵就对不上。所以分类网络通常要求固定输入尺寸,比如 224×224。分割数据里图像尺寸五花八门,硬缩放会让人变形,这个限制很要命。

2.2 把「打分」换成「逐像素投票」

全卷积网络的核心动作只有一句话:把最后那几层用于分类的全连接层,换成卷积层。

这个替换不是硬凑。数学上,全连接层可以看成一个核大小等于整张特征图的卷积——输入 7×7×512、输出 1×1×4096 的全连接,等价于用 7×7×512 的卷积核卷一次,得到 1×1×4096。既然是一回事,那就有得商量:卷积核不必铺满整张图。

换成 1×1 卷积以后,输出形状从 1×1×C 变成 h×w×C。这里的 h 和 w 是下采样之后的特征图尺寸,C 是类别数。这张 h×w×C 的东西,本质上就是一张「低分辨率的分割图」——每个位置存着该位置属于各类的分数。

这里有个容易被忽略的细节:输出的每个位置对应的不是原图的单个像素,而是原图上一块区域,也就是感受野。所以这张低分辨率分割图其实是「块级判断」,还得放大回原图尺寸,这就是上采样要干的事。整套流程的骨架可以概括成一句:下采样 → 块级分类 → 上采样。

好处跟着来了。输入尺寸不再固定,同一套权重能吃 256×256,也能吃 512×384,只要显存扛得住;位置对应关系保住了;参数量还降了一大截,原来那几层全连接往往是整个网络参数的大头。

2.3 跳层连接:粗轮廓和细边缘怎么拼

只用最后一层特征做上采样,会得到一个很尴尬的结果:大块区域分对了,边界糊成一团。原因不难推——下采样 32 倍以后,原图上一个 32×32 的方块被压成了 1 个像素,这个像素再去判断自己属于什么,边界在哪早就丢了。

全卷积网络给出的补丁是跳层连接:把浅层的高分辨率特征,和深层上采样后的结果逐元素相加。浅层特征分辨率高、语义弱,但边界清晰;深层特征语义强、分辨率低。两者一加,等于把「看得清但看不懂」和「看得懂但看不清」两张草图叠在一起比对。

具体做法有三个版本。只从最后一层上采样 32 倍的,最省事也最糙;把倒数第二层特征融合进来、上采样 16 倍的,边界好一截;再多融一层、上采样 8 倍的,边界最细,代价是显存和计算量往上走。融合层数越多,浅层的高频细节保留得越完整。

注意:融合用的是逐元素相加,不是通道拼接。相加要求两条支路通道数一致,所以深层支路通常得先用 1×1 卷积把通道数压到和浅层一样,才能加得上去。这一步漏了,张量形状会直接报错。

2.4 端到端一次前向,省掉了一堆手工拼图

在变成可训练的网络之前,做图像分割有另一条路:先算超像素,把图切成几百个小块,再对每个小块提特征、分类、后处理合并。这条路能跑,但每一步都得手工设计和调参,前面的错误会一路传到后面,改一个参数整条链路都要重来。

全卷积网络把这一串动作收进了一个网络里。输入图、输出掩码,中间的所有变换都是权重,都靠反向传播自动调。这才是它真正改变行业的地方——不是因为某一层设计得多巧妙,而是因为它把「分割」变成了一个标准的监督学习问题,能端到端训,能换骨干,能加损失。

反过来说,端到端也意味着出问题时排查链变长了:数据管道错、标签错位、上采样倍数算错、损失函数接错,最终都表现为「掩码不好看」。这也是为什么后面要专门讲一遍训练时最先翻车的几个地方。

3. 一张图进、一张掩码出:全卷积网络的三步走法

3.1 第一步:下采样编码,把图缩小到底换到了什么

卷积核滑动、池化窗口取最大,每隔几步把图缩小一半。缩小的过程不只是丢信息,它在换两样东西:感受野和语义。

感受野可以手算。一层 3×3 卷积感受野是 3,两层 3×3 堆叠是 5,三层是 7。加上 stride 或者池化,感受野涨得更快。输出步长(output stride)这个参数说的就是:特征图上 1 个点,对应原图多少乘多少的区域。output stride 等于 32,意思是特征图一个格子管原图 32×32。

举个具体例子。原图 512×512,走一遍常见的骨干结构:第一层卷积步长 2 变成 256,最大池化步长 2 变成 128,后面三个阶段各下采样一次,分别是 64、32、16。最后拿到一张 16×16 的特征图,output stride 就是 32。这张 16×16 的图,每个格子要对原图 32×32 的区域做判断——一个只占 40×80 像素的行人,在这张图上只剩 1×2 个格子,边界信息基本清零。

这就是分割任务里小目标难受的根源,也是后续调参要盯的第一个数。想让它变好,要么把 output stride 降下来,要么用别的办法把分辨率保住。

3.2 第二步:上采样,把 16×16 长回 512×512

上采样有三条路,选哪条直接决定边界质量。

最近邻插值,放大的时候复制最近的像素。速度快、零参数,但边缘是锯齿状的方块,放大倍数越大越明显。

双线性插值,按距离对周围四个点加权平均。平滑,同样零参数,但会把边界糊软,锐利的轮廓变成渐变过渡。

转置卷积,也叫反卷积,带可学习权重。它在输入元素之间插零,再用卷积核去卷,输出的每个位置由权重决定,所以它能「学会」怎么放大。想提边界质量,一般从这条路上想办法。

这里有句话必须说清楚:转置卷积不是卷积的逆运算。它只是把卷积的矩阵乘法转置了一下,形状上反着来,信息上并不还原。多个不同的输入,经过转置卷积可以落到同一片输出区域——所以它只能叫可学习的上采样,叫「还原」是不准确的。

转置卷积还有一个副作用叫棋盘格伪影:卷积核覆盖不均匀,输出被周期性放大,图上会出现规律性的网格。缓解办法有两条,一是让卷积核尺寸能被 stride 整除,二是直接用双线性插值的权重去初始化它,甚至干脆改成「先插值、再普通卷积」。

import torch import torch.nn as nn # 输入: (N, C_in, H, W) 的特征图 up = nn.ConvTranspose2d( in_channels=256, # 输入通道数,必须和上一层的输出对上 out_channels=21, # 输出通道数,等于类别数(含背景),不是特征通道数 kernel_size=4, # 能被 stride 整除,减轻棋盘格伪影 stride=2, # 放大倍数,2 表示 H、W 各翻一倍 padding=1, # 与 kernel_size/2 搭配,控制输出尺寸 output_padding=0 # 只补尺寸,不改变卷积核的覆盖方式 ) x = torch.randn(1, 256, 16, 16) y = up(x) print(y.shape) # torch.Size([1, 21, 32, 32])

输出尺寸的公式是H_out = (H_in - 1) * stride - 2 * padding + kernel_size + output_padding。拿上面这组参数代进去:(16-1)×2 - 2×1 + 4 + 0 = 32,对得上。改 kernel_size 或者 padding 之前先用纸笔算一遍,比跑起来看报错快得多。

参数里最容易踩的两个坑:out_channels要等于类别数,不是随便填个特征通道数;output_padding只影响输出尺寸,不改变卷积核的覆盖方式,别拿它当调质量的旋钮。如果发现上采样之后的尺寸和原图差了一两个像素,先去检查这两个参数,而不是急着改网络结构。

3.3 第三步:逐像素算损失,让每个位置都有话可说

全卷积网络输出的是 (N, C, H, W) 的分数图,标签是 (N, H, W) 的整型图,每个位置一个类别编号。损失函数用逐像素交叉熵,本质上是把 H×W 个位置各自当成一次小分类。

import torch import torch.nn as nn # logits: (N, C, H, W),未经过 softmax 的原始分数 # target: (N, H, W),每个位置的类别编号,取值 0..C-1,必须是 long 类型 criterion = nn.CrossEntropyLoss(ignore_index=255) logits = torch.randn(2, 21, 64, 64) target = torch.randint(0, 21, (2, 64, 64)) loss = criterion(logits, target) print(loss.item())

几个点必须对上:logits 在传入前不要做 softmax,这个损失内部会做 log_softmax,多做一次等于把分数压平了;target 是 long 类型;形状顺序是 (N, C, H, W) 对 (N, H, W),不是 one-hot。

ignore_index=255这个参数在实际项目里几乎必开。行人数据集通常只标注行人,其他区域(天空、马路、树丛)没有像素级标注,这些位置不该算进损失,否则模型会被迫去学一个错误的「背景」定义。惯常做法是把未标注像素的标签值设成 255,然后忽略掉。

还有一个反直觉的点:整体像素准确率在分割任务里几乎没有参考价值。一张图里背景可能占 95%,模型全部预测背景,准确率 95%,好看得离谱,但 IoU 是 0。所以指标要看 IoU 或 Dice,而且要按类别分开看,别只看平均数。

4. 拿 Penn-Fudan 数据集跑一遍:小数据、小目标、真实边界

4.1 这个数据集长什么样

Penn-Fudan 行人数据集是一个公开的行人检测与分割数据集。规模不大:大约 170 张图像,取自户外场景,每张图里有若干行人,合计三百多个行人实例,每个实例都带像素级的分割掩码,同时也给出边界框标注。

它的价值不在大,在干净且任务明确:只需要区分行人和背景两类,标注是逐像素的,数据量和显存需求都很友好,一张普通显卡就能跑完一轮实验。对第一次接触全卷积网络的人来说,这是个很合适的练手场——流程完整,代价可控,跑通一遍能把前面讲的下采样、上采样、损失对齐全都串起来。

需要适应的第一件事是尺寸不统一。分类数据集常被预处理成统一尺寸,这个数据集的原图分辨率各不相同。全卷积结构本身不挑输入尺寸,但一个 batch 内的张量必须同形,所以还是要做 resize 或 padding。这里有取舍:缩得太小,行人只剩几十个像素,边界彻底没了;保持原尺寸,显存又吃紧。常见折中是等比缩放,短边缩到 512 左右,长边按比例走,batch size 压到 2 或 4,配合梯度累积凑出等效的大 batch。

还有一件事值得留意:这个数据集既有框标注又有掩码标注,意味着它同时能喂给检测模型和分割模型。做分割时框标注不是废的,可以用来做数据筛选、做裁剪增强,或者作为辅助损失的一项。

4.2 小数据集的现实:它能验证流程,不能证明能力

170 张图训练一个深度分割网络,过拟合是必然,不是意外。训练集 IoU 冲到 0.9、验证集停在 0.5,这种曲线在小数据集上非常常见,看到它别慌,也别急着换模型。

正确的期待是:这套数据用来验证「数据管道对不对、标签有没有错位、损失函数有没有接对、上采样倍数算没算错」,而不是用来比较「哪个模型更强」。在这个规模上得出「A 模型比 B 模型好」的结论,换一批随机种子可能就翻转了。

想在这种规模上拿到能看的掩码,基本靠两件事:用在大规模分类数据上预训练过的骨干网络初始化编码器,然后只微调解码器;以及足够强的数据增强,随机裁剪、水平翻转、颜色抖动、亮度扰动,能上的都上。冻结前几层、只训后几层,也是常用的省事办法。

打个比方:170 张照片学画画,能学会的是「怎么握笔、怎么调色」,学不会「怎么成为画家」。指望它训出能上生产的分割模型,那是把练手场当考场了。它的正确用途是让你在不心疼算力的前提下,把整条链路跑顺、把坑踩完。

4.3 最先翻车的四个地方

第一个,掩码和图像用了同一个插值。图像 resize 用双线性没问题,掩码也这么干就出事了——标签会被插出 0.5、1.7 这种不存在的类别编号,损失函数拿到这些值要么报错,要么默默算出一个毫无意义的数。掩码必须用最近邻,这条没有例外。

第二个,小目标在下采样里消失。output stride 32 意味着一个 40 像素高的行人最后只剩 1 个像素出头。可选的路有三条:把最后一级下采样去掉、把 output stride 降到 16 或 8;用空洞卷积在不降分辨率的前提下扩大感受野;多尺度训练,让模型见过不同大小的行人。三条可以叠加用。

第三个,只看准确率。前面说过,全预测背景也能有 95% 的准确率。第一次训出来一张全黑的掩码,回头一看 loss 还挺低——这不是玄学,是类别不平衡加上指标选错的必然结果。改法是在损失里给行人像素加权,或者用 Dice 这类对类别比例不那么敏感的指标。

第四个,忘了归一化。骨干网络在预训练时用的是一套固定的均值方差,输入没按同样方式归一化,微调阶段收敛会明显变慢,甚至干脆不收敛。这个坑踩一次能记很久,因为它不报错,只是「效果莫名其妙地差」。

注意:验证阶段做可视化的频率要高过看指标的频率。前几个 epoch 指标几乎不动,但掩码图能直接告诉你问题在哪——全黑对应类别不平衡,全白对应标签读错,只框住一半多半是尺寸或坐标没对齐。

5. 易混概念辨析:五组最常被搅在一起的说法

5.1 全卷积网络和普通卷积网络:差在最后那一段

易混点:名字里都有卷积,骨干部分长得几乎一样,看起来只是「深度不同」。很多人第一次听到,会以为这是两种完全不同的结构。

为什么混:全卷积网络的编码器本来就是从经典分类网络改过来的,前面十几层卷积完全一致,只看前半段根本分不出谁是谁。

怎么区分:看输出形状。分类网络输出 (N, C),一张图一个结论;全卷积网络输出 (N, C, H, W),一张图 H×W 个结论。或者更直接,看有没有把那几层全连接换成卷积。这个区别决定了前者只能回答「是什么」,后者能回答「在哪里」。

5.2 转置卷积和卷积的逆运算:名字骗人

易混点:带个「反」字,直觉上以为它能把卷积的结果原样还原回来。

为什么混:形状上确实反着来——卷积把大图变小,转置卷积把小图变大,一进一出好像能对上,于是「反卷积」这个叫法就流传开了。

怎么区分:转置卷积做的是矩阵转置乘法,不是求逆矩阵。卷积在降维过程中会丢信息,多个不同输入映射到同一个输出,丢了就回不来。转置卷积只是提供了一种可学习的放大方式,放大出来的内容是猜的,不是还原的。把它理解成「带参数的上采样」,比理解成「反卷积」准确得多,也少一堆误解。

5.3 语义分割、实例分割、全景分割:都输出掩码,但问题不同

易混点:三个任务的可视化结果都是彩色色块,看起来是一回事,指标也都是 IoU。

为什么混:输出形状类似、评价方式类似,不看任务定义很难区分;很多教程又都拿「给行人描边」当例子,更容易混。

怎么区分:语义分割只回答「这个像素属于哪一类」,两个挨在一起的人会被涂成同一块颜色。实例分割要回答「这个像素属于第几个个体」,两个人会被分开涂。全景分割是两者的合并:能数清的东西按实例分,数不清的东西按语义分。

回到全卷积网络:它是语义分割的范式,天生处理不了「同类个体分开」这件事。想数人头,得往实例分割的路子上走,要么先检测框再框内分割,要么在网络里加一条区分个体的分支。

5.4 上采样和池化:方向相反,但不止方向相反

易混点:一个把图变大,一个把图变小,感觉是互逆操作,配一对就能还原。

为什么混:转置卷积的 stride 设成 2,正好和 2×2 最大池化的尺寸变化对得上,看起来像一对。

怎么区分:池化是不可学习的,只做取最大或取平均,信息单向丢失;上采样里的插值也不可学习,但转置卷积带权重、能在训练中调整。更关键的是,池化丢掉的细节不会因为后面做一次上采样就回来——最大值之外的那些值,网络再也没见过。所以跳层连接才必须存在:从浅层把丢之前的信息另存一份拿过来用。

5.5 「全卷积」不等于「一个全连接都没有」

易混点:以为叫全卷积,网络里就绝对不能出现全连接层,出现了就是抄错代码。

为什么混:名字起得太绝对,字面上就是「全部由卷积构成」。

怎么区分:说的是「用卷积替换掉用于分类的那几层全连接」,不是禁止一切全局操作。实际实现里,全局平均池化、1×1 卷积这类把空间信息汇总的操作依然常见,作用接近全连接,但保留了对输入尺寸的适应性。判断标准不是「有没有全连接层」,而是「输出有没有保留空间维度」。

6. 什么时候该上全卷积网络,什么时候该换路子

需要像素级输出、类别数不多、边界精度不要求到发丝级、数据规模中等偏小、部署端算力有限——这五条同时成立时,全卷积这一套依然是最省心的选择。结构简单、推理路径清晰、显存可控,而且改起来方便:想提边界质量就加跳层连接,想控显存就调 output stride,想压参数量就换轻量骨干。

该换路子的时候也有几种典型信号。要区分同类个体,比如画面里有几个人、各自在哪,别在语义分割上硬改,直接上实例分割或者两段式方案。边界要求极高,比如抠图级的人像分割,单一上采样路径到不了那个精度,得换高分辨率特征融合的思路。数据只有几百张又没有可用的预训练权重,先想清楚是不是该用更轻的方法,别一上来就堆网络。

| 需求 | 更合适的选择 | 原因 | | 只要框,不要轮廓 | 目标检测 | 输出是矩形,不必逐像素判断 | | 要轮廓,同类不分个体 | 全卷积网络 | 语义分割的标准范式 | | 要轮廓,还要区分个体 | 实例分割 | 需要额外的个体区分机制 | | 边界要极精细 | 高分辨率特征融合方案 | 单一路径的上采样不够用 |

举个具体判断:任务是「统计路口行人数并标出区域」。先问一句要不要数个数,要数,语义分割就不够,得上实例分割。如果退一步,只要求标出所有行人区域、不数个数,那全卷积网络配一个预训练骨干就够了,一百多张图也能跑出个能看的基线,把最后一级下采样换成空洞卷积保分辨率、跳层连接补边界、损失里给行人像素加权,基本到位。

我第一次在这个数据集上训练时,看到像素准确率 0.96 就以为成了,把掩码图导出来一看——全黑,一个行人都没标出来,那 0.96 全是背景像素撑起来的。想自己跑一遍的话,这套流程和可复现的配置都整理在一起了,拿下来对照着改,比自己从零拼要少走不少弯路。从那以后我每次跑完分割任务,都强制先导三张掩码图看一遍,再去看任何指标,指标只用来横向比较,不用来判断「能不能用」。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进