为了促进自己技术的提升,鄙人打算开一个语义分割系列,讲语义分割中经典的网络论文与具体实现,并应用于一些数据集中。FCN是我第一篇语义分割文章,在此做个记录。
本文假设读者已具备基本的图像分类知识
FCN网络,出自论文Fully Convolutional Networks for Semantic Segmentation,是语义分割的开山之作。2012年,AlexNet凭借在ImageNet比赛中获取冠军,开启了使用深度学习解决计算机视觉问题的时代。
2015年,大家还在研究图像分类,ImageNet 2012年冠军AlexNet,2014年冠军GoogLeNet,2014年亚军VGG,2015年冠军ResNet。而在大家都热衷于研究图像分类这种热潮下,FCN作者能够独创新网络结构用于语义分割,实属不易,而FCN的思想也深深影响了后续语义分割的研究者。
目前,在google scholar上看到,FCN的引用数已达3w+,可见影响之深。
论文Introduction开篇就提到卷积神经网络在识别中有着天然的优势,不仅在提升了图像分类的准确度,同时在具有结构化输出的局部任务中攻城略地(如object detection目标检测,keypoint prediction关键点预测...)
接下来最重要的一句话来了!!!
The natural next step in the progression from coarse to fine inference is to make a prediction at every pixel.
这句话什么意思呢?简而言之,“那么很自然地就想到,下一步就是:对每一个像素做预测”,即像素级分类
我们可以这样想,图像分类是整张图的像素对一个标签进行预测,而语义分割这是整张图的像素对整张图的像素类别进行预测。
图像分类:一图->一类;语义分割:一图->一图
那作者是怎么做的呢?他将图像分类中的全连接层(fully connected layer)换成了卷积层,因此网络里都是卷积层,也是网络名字的由来(Fully Convolutional Networks,全卷积网络)
而我们知道,经过一系列卷积层和池化层后,感受野不断地增大,同时图片的尺寸不断地减小,而我们最后要求输出的结果要跟原图大小相同,我们怎么还原回原图的大小呢?(论文中说到Adding differentiable interpolation layers
)
这个从小图还原回大图的步骤叫作上采样,而作者在论文里提及的上采样有:
最终采用转置卷积的原因是什么呢?
最终,作者通过使用将全连接层转换成卷积层和转置卷积作为上采样方法的FCN网络,在PASCAL VOC数据集和NYUDv2数据集上达到了state of the art(达到最高水准的)
上面说过,FCN将分类网络中的全连接层换成了卷积层,而前面这部分可复用于语义分割的网络层(卷积,池化),叫作backbone。
通过backbone来提取特征,配以不同的网络层,完成不同的任务,是计算机视觉(cv)领域的常规操作。
作者经过实验,测试出VGG16作为backbone的性能最好,便在该backbone基础上进行其他实验。
FCN网络,作者提出了三个版本,分别叫做FCN-32s,FCN-16s,FCN-8s(见下图)
其中,pool5前的网络是VGG16网络。(上图中的poolx为卷积后输出的特征图,image为输入图像)
VGG网络的特征为它每个卷积层的kernel_size=3,stride=1,padding=1(该配置下,经过一个卷积层,图片大小不会更改),每经过一个convx
就会有一个kernel_size=2,stride=2的max pooling
进行下采样,将图片缩小为原来的1/2大小,这是理解FCN网络前必须掌握的知识。
因此pool1为原图1/2的大小,pool2为1/4,pool3为1/8,pool4为1/16,pool5为1/32。而conv6-7则是将VGG16中用于分类的两个全连接层换成了1x1的卷积层(1x1卷积用于改变通道数)。
而FCN-32s网络结构则是在conv6-7后添加一个转置卷积,而这个转置卷积的作用是将特征图(1/32大小)直接放大32倍,得到跟输入图像大小相等的特征图,从而实现语义分割。
看完FCN-32s的思路后,你或许会产生“就这?我上我也行”的想法,没错,确实就这。
一步放大32倍的效果会不会不太好?作者因此有了FCN-16s和FCN-8s的思路。
FCN-16s:在conv6-7后添加一个将特征图放大2倍的转置卷积层,因此在经过该转置卷积层后,特征图大小为1/16(对应上图中的2x conv7),随后将pool4特征图(1/16)与2x conv7合并,完成语义信息的融合,此时大小为1/16(不妨叫它fuse_pool4),最后添加一个将特征图放大16倍的转置卷积层,将特征图还原为输入图大小。
FCN-8s:与16s操作类似,得到fuse_pool4(1/16)后,让其经过一个放大2倍的转置卷积层,变为1/8大小的特征图,此时将其与pool3合并,完成语义信息融合,此时特征图大小为1/8,最后让其经过一个放大8倍的转置卷积层,还原回原图大小。
(上述pool与放大后的特征图信息融合时实际上还需要对齐通道数,但为了讲述方便,省略了)
这就是FCN三个网络结构的原理了。
上述一直在提及转置卷积,读者不必过于深究其原理,只要知道它的作用是上采样(将特征图尺寸放大)即可。
如果想其了解原理,请看该链接
抽丝剥茧,带你理解转置卷积(反卷积)_史丹利复合田的博客-CSDN博客_转置卷积和反卷积(详细)
转置卷积(Transposed Convolution)_太阳花的小绿豆的博客-CSDN博客_转置卷积(有gif动图演示)
经过转置卷积后的尺寸大小计算公式
其中stride[0]表示高度方向的stride,padding[0]表示高度方向的padding,kernel_size[0]表示高度方向的kernel_size,索引[1]都表示宽度方向上的。
为方便读者理解论文网络实现细节,鄙人搭建一个VGG16作为backbone的FCN网络。
此处使用paddle深度学习框架进行网络模型的搭建,以下为网络结构代码
class FCN8s(nn.Layer):
def __init__(self, num_classes=21):
super(FCN8s, self).__init__()
# num_classes要包含背景,如果是PASCAL VOC则是20+1
self.layer1 = self.make_block(num=2, in_channels=3, out_channels=64)
self.layer2 = self.make_block(num=2, in_channels=64, out_channels=128)
self.layer3 = self.make_block(num=3, in_channels=128, out_channels=256)
self.layer4 = self.make_block(num=3, in_channels=256, out_channels=512)
self.layer5 = self.make_block(num=3, in_channels=512, out_channels=512)
# 下面的两个卷积层代替了原来VGG网络的全连接层(原本为4096,此处可根据gpu性能,设置为其他数,此处设为2048)
mid_channels = 2048
self.conv6 = nn.Conv2D(in_channels=512, out_channels=mid_channels, kernel_size=7, padding=3)
self.conv7 = nn.Conv2D(in_channels=mid_channels, out_channels=mid_channels, kernel_size=1)
# 3个1*1的卷积,用于改变pool的通道数,为了后续融合语义信息
self.score32 = nn.Conv2D(in_channels=mid_channels, out_channels=num_classes, kernel_size=1)
self.score16 = nn.Conv2D(in_channels=512, out_channels=num_classes, kernel_size=1)
self.score8 = nn.Conv2D(in_channels=256, out_channels=num_classes, kernel_size=1)
# 3个转置卷积,用于扩大特征图
# 若参数kernel_size:stride:padding=4:2:1,此时stride为扩大倍数
weight_8x = paddle.ParamAttr(
initializer=paddle.nn.initializer.Assign(bilinear_kernel(num_classes, num_classes, 16))
)
self.up_sample8x = nn.Conv2DTranspose(
in_channels=num_classes,
out_channels=num_classes,
kernel_size=16, stride=8, padding=4,
weight_attr=weight_8x
)
weight_16x = paddle.ParamAttr(
initializer=paddle.nn.initializer.Assign(bilinear_kernel(num_classes, num_classes, 4))
)
self.up_sample16x = nn.Conv2DTranspose(
in_channels=num_classes,
out_channels=num_classes,
kernel_size=4, stride=2, padding=1,
weight_attr=weight_16x
)
weight_32x = paddle.ParamAttr(
initializer=paddle.nn.initializer.Assign(bilinear_kernel(num_classes, num_classes, 4))
)
self.up_sample32x = nn.Conv2DTranspose(
in_channels=num_classes,
out_channels=num_classes,
kernel_size=4, stride=2, padding=1,
weight_attr=weight_32x
)
def make_block(self, num: int, in_channels: int, out_channels: int, padding=1):
"""根据传入的in,out和需要构建的块数搭建网络块"""
blocks = []
blocks.append(nn.Conv2D(in_channels=in_channels, out_channels=out_channels, kernel_size=3, padding=padding))
blocks.append(nn.ReLU())
for i in range(num-1):
blocks.append(nn.Conv2D(in_channels=out_channels, out_channels=out_channels, kernel_size=3, padding=1))
blocks.append(nn.ReLU())
blocks.append(nn.MaxPool2D(kernel_size=2, stride=2, ceil_mode=True))
return nn.Sequential(*blocks)
def forward(self, inputs):
# inputs [3, 1, 1],以原始输入图像尺寸为1
# features
out = self.layer1(inputs) # [64, 1/2, 1/2]
out = self.layer2(out) # [128, 1/4, 1/4]
pool3 = self.layer3(out) # [256, 1/8, 1/8]
pool4 = self.layer4(pool3) # [512, 1/16, 1/16]
pool5 = self.layer5(pool4) # [512, 1/32, 1/32]
x = self.conv6(pool5) # [mid_channels, 1/32, 1/32]
x = self.conv7(x) # [mid_channels, 1/32, 1/32]
score32 = self.score32(x) # [num_classes, 1/32, 1/32]
up_pool16 = self.up_sample32x(score32) # [num_classes, 1/16, 1/16]
score16 = self.score16(pool4) # [num_classes, 1/16, 1/16]
fuse_16 = paddle.add(up_pool16, score16)
up_pool8 = self.up_sample16x(fuse_16) # [num_classes, 1/8, 1/8]
score8 = self.score8(pool3) # [num_classes, 1/8, 1/8]
fuse_8 = paddle.add(up_pool8, score8)
heatmap = self.up_sample8x(fuse_8)
return heatmap
其中需要注意的几个点
该函数的实现如下:
def bilinear_kernel(in_channels, out_channels, kernel_size):
factor = (kernel_size + 1) // 2
if kernel_size % 2 == 1:
center = factor - 1
else:
center = factor - 0.5
og = np.ogrid[:kernel_size, :kernel_size]
filt = (1 - abs(og[0] - center) / factor) * \
(1 - abs(og[1] - center) / factor)
weight = np.zeros((in_channels, out_channels, kernel_size, kernel_size),
dtype='float32')
weight[range(in_channels), range(out_channels), :, :] = filt
return paddle.to_tensor(weight, dtype="float32")
具体原理我并不是很清楚,但这是论文作者github源码提供的权重初始化方法。
放大2倍和8倍有着两种不同的方式(但放大倍数都取决于stride),读者可以代入公式进行验证计算。
为什么最终选取了(a)方式呢?因为设置为(a)方式时网络能够收敛,且性能较好。选取(b)方式时,网络像没有初始化权重参数一样,miou卡在32miou左右,最后越训练越低
同时,又因为VGG16网络参数量大,从零训练非常难,非常考验调参功力。所以可以采用迁移学习的方式,基于预训练模型训练。
基于预训练模型训练,此处我实验了基于VGG16预训练模型为backbone的分割性能和基于ResNet34预训练模型为backbone的分割性能。
最终选定ResNet34作为backbone,参数量小,易调参,性能表现优。
optimizer采用SGD,learning_rate设置为0.03,weight_decay设置为1e-2,mean iou可达53%,鄙人最高训练到56%miou,=_=但仍未达到fcn论文中提到的65.5% miou,有较大差距。(论文作者甚至还是基于VGG16调的65.5,泪目了,调参真是门技术活)
最终模型的预测效果如图所示:
下一篇文章将会带着你了解PASCAL VOC2012数据集,手把手教你读取数据集,利用paddle深度学习框架进行FCN网络的训练验证!
文章链接:读取PSACAL VOC,训练FCN全流程_Horace_01的博客-CSDN博客