FCN学习:Semantic Segmentation

FCN学习:Semantic Segmentation

感谢@huangh12 @郑途 @麦田守望者对标签图像生成的研究和讨论,这几天研究了一下,补充如下。

-----------------------------------------------------分割线------------------------------------------------------------

谢谢@潘达的评论,这一篇确实有很多点没有分析完,当时想着后来加上去,但是由于工作的关系,也就不了了之了。最近终于忙完了一个项目,有时间更新一下文章了!

----------------------------------------------------- 分割线------------------------------------------------------------

本来这一篇是想写Faster-RCNN的,但是Faster-RCNN中使用了RPN(Region Proposal Network)替代Selective Search等产生候选区域的方法。RPN是一种全卷积网络,所以为了透彻理解这个网络,首先学习一下FCN(fully convolutional networks)Fully Convolutional Networks for Semantic Segmentation

全卷积网络首现于这篇文章。这篇文章是将CNN结构应用到图像语义分割领域并取得突出结果的开山之作,因而拿到了CVPR 2015年的best paper honorable mention.

图像语义分割,简而言之就是对一张图片上的所有像素点进行分类


如上图就是一个语义分割的例子,不同的颜色代表不同的类别

下面简单介绍一下论文,重点介绍文中的那几个结构:

一.论文解读

1.Introduction

CNN这几年一直在驱动着图像识别领域的进步。无论是整张图片的分类(ILSVRC),还是物体检测,关键点检测都在CNN的帮助下得到了非常大的发展。但是图像语义分割不同于以上任务,这是个空间密集型的预测任务,换言之,这需要预测一幅图像中所有像素点的类别

以往的用于语义分割的CNN,每个像素点用包围其的对象或区域类别进行标注,但是这种方法不管是在速度上还是精度上都有很大的缺陷。

本文提出了全卷积网络(FCN)的概念,针对语义分割训练一个端到端,点对点的网络,达到了state-of-the-art。这是第一次训练端到端的FCN,用于像素级的预测;也是第一次用监督预训练的方法训练FCN。

2.Fully Convolutional networks & Architecture

下面我们重点看一下FCN所用到的三种技术:

1.卷积化(convolutionalization)


分类所使用的网络通常会在最后连接全连接层,它会将原来二维的矩阵(图片)压缩成一维的,从而丢失了空间信息,最后训练输出一个标量,这就是我们的分类标签。

而图像语义分割的输出则需要是个分割图,且不论尺寸大小,但是至少是二维的。所以,我们丢弃全连接层,换上卷积层,而这就是所谓的卷积化了。


这幅图显示了卷积化的过程,图中显示的是AlexNet的结构,简单来说卷积化就是将其最后三层全连接层全部替换成卷积层

2.上采样(Upsampling)

上采样也就是对应于上图中最后生成heatmap的过程。

在一般的CNN结构中,如AlexNet,VGGNet均是使用池化层来缩小输出图片的size,例如VGG16,五次池化后图片被缩小了32倍;而在ResNet中,某些卷积层也参与到缩小图片size的过程。我们需要得到的是一个与原图像size相同的分割图,因此我们需要对最后一层进行上采样,在caffe中也被称为反卷积(Deconvolution),可能叫做转置卷积(conv_transpose)更为恰当一点。

为理解转置卷积,我们先来看一下Caffe中的卷积操作是怎么进行的

在caffe中计算卷积分为两个步骤:

1)使用im2col操作将图片转换为矩阵

2)调用GEMM计算实际的结果

简单来说就是以下的矩阵相乘操作:

下面使用一下@贾扬清大神在知乎上解释Caffe计算卷积的图来解释一下上面的操作:

这是将输入转换为feature matrix的过程(im2col),这里的feature matrix对应于上图中的矩阵B的转置,k即是卷积核的尺寸,C为输入的维度,矩阵B中的K=C x k x k,当然N就等于H' x W'了,H',W'对应于输出的高和宽,显然H'=(H-k+2*pad)/stride+1,W'=(W-k+2*pad)/stride+1,这组公式想必大家很熟悉了,就不加以解释了,接下来我们看看A矩阵,


A矩阵对应于filter matrix,Cout是输出的维度,亦即卷积核的个数,K= C x k x k.

所以在caffe中,先调用im2col将filters和input转换为对应filter matrix(A)和feature matrix(B'),然后再用filter matrix乘以feature matrix的转置,就得到了C矩阵,亦即输出矩阵,再将C矩阵通过col2im转换为对应的feature map,这就是caffe中完整的卷积的前向传播过程

那么当反向传播时又会如何呢?首先我们已经有从更深层的网络中得到的\frac{\partial Loss}{\partial C}.

根据矩阵微分公式\frac{\partial Ax +b}{\partial x}=A^T,可推得
\frac{\partial Loss}{\partial B} = \frac{\partial Loss}{\partial C} \cdot \frac{\partial C}{\partial B} =A^T\frac{\partial Loss}{\partial y},

Caffe中的卷积操作简单来说就是这样,那转置卷积呢?
其实转置卷积相对于卷积在神经网络结构的正向和反向传播中做相反的运算。
所以所谓的转置卷积其实就是正向时左乘A^T,而反向时左乘(A^T)^T,即A的运算。

虽然转置卷积层和卷积层一样,也是可以train参数的,但是实际实验过程中,作者发现,让转置卷积层可学习,并没有带来performance的提升,所以实验中的转置卷积层的lr全部被置零了

注意:在代码中可以看出,为了得到和原图像size一模一样的分割图,FCN中还使用了crop_layer来配合deconvolution层

3.跳跃结构(Skip Architecture)

其实直接使用前两种结构就已经可以得到结果了,但是直接将全卷积后的结果上采样后得到的结果通常是很粗糙的。所以这一结构主要是用来优化最终结果的,思路就是将不同池化层的结果进行上采样,然后结合这些结果来优化输出,具体结构如下:


而不同的结构产生的结果对比如下:

二.实践与代码分析

作者在github上开源了代码:Fully Convolutional Networks

git clone https://github.com/shelhamer/fcn.berkeleyvision.org.git

我们首先将项目克隆到本地

项目文件结构很清晰,如果想train自己的model,只需要修改一些文件路径设置即可,这里我们应用已经train好的model来测试一下自己的图片:

我们下载voc-fcn32s,voc-fcn16s以及voc-fcn8s的caffemodel(根据提供好的caffemodel-url),fcn-16s和fcn32s都是缺少deploy.prototxt的,我们根据train.prototxt稍加修改即可。

-------------------------------------------------标签图像生成--------------------------------------------------------

VOCdevkit处下载VOC2012的训练/验证集,解压之后,在SegmentationClass文件夹下可以看到标签图像。

在PIL中,图像有很多种模式,如'L'模式,’P'模式,还有常见的'RGB'模式,模式'L'为灰色图像,它的每个像素用8个bit表示,0表示黑,255表示白,其他数字表示不同的灰度。模式“P”为8位彩色图像,它的每个像素用8个bit表示,其对应的彩色值是按照调色板索引值查询出来的。标签图像的模式正是'P'模式,因此测试时要生成对应标签图像的图片的话,构建一个调色板即可。


按照上图,对应修改调色板和infer.py,就可以测试我们自己的图片了

import numpy as np
from PIL import Image
import caffe

# load image, switch to BGR, subtract mean, and make dims C x H x W for Caffe
im = Image.open('data/pascal/VOCdevkit/VOC2012/JPEGImages/2007_000129.jpg')
in_ = np.array(im, dtype=np.float32)
in_ = in_[:,:,::-1]
in_ -= np.array((104.00698793,116.66876762,122.67891434))
in_ = in_.transpose((2,0,1))

# load net
net = caffe.Net('voc-fcn8s/deploy.prototxt', 'voc-fcn8s/fcn8s-heavy-pascal.caffemodel', caffe.TEST)
# shape for input (data blob is N x C x H x W), set data
net.blobs['data'].reshape(1, *in_.shape)
net.blobs['data'].data[...] = in_
# run net and take argmax for prediction
net.forward()
out = net.blobs['score'].data[0].argmax(axis=0)

arr=out.astype(np.uint8)
im=Image.fromarray(arr)

palette=[]
for i in range(256):
    palette.extend((i,i,i))
palette[:3*21]=np.array([[0, 0, 0],
                            [128, 0, 0],
                            [0, 128, 0],
                            [128, 128, 0],
                            [0, 0, 128],
                            [128, 0, 128],
                            [0, 128, 128],
                            [128, 128, 128],
                            [64, 0, 0],
                            [192, 0, 0],
                            [64, 128, 0],
                            [192, 128, 0],
                            [64, 0, 128],
                            [192, 0, 128],
                            [64, 128, 128],
                            [192, 128, 128],
                            [0, 64, 0],
                            [128, 64, 0],
                            [0, 192, 0],
                            [128, 192, 0],
                            [0, 64, 128]], dtype='uint8').flatten()

im.putpalette(palette)
im.show()
im.save('test.png')

如上述,对代码的主要修改是增加了一个调色板,将L模式的图像转变为P模式,得到类似标签图像的图片。

接下来,只需要修改script中的图片路径和model的路径,就可以测试自己的图片了:

---------------------------------------------------原测试结果---------------------------------------------------------


-----------------------------------------------------P模式测试结果--------------------------------------------------
这是我跑出来的最终结果,可以看出skip architecture对最终的结果确实有优化作用。

这里没有对最终结果上色,按照VOC的颜色设置之后,就可以得到和论文中一模一样的结果了

下面是测试其他一些图片的结果:

--------------------------------------------以下为对FCN中关键代码的分析-------------------------------------

这里我们着重分析在VOC数据集上的代码,其他几个数据集上的代码类似。

首先我们下载VOC2012数据集(根据data/pascal/README.md文件下载),从中可以看到图像(JPEGImages)和ground truth(SegmentationClass).

看过了数据集之后,我们去voc-fcn32s下看一下train.prototxt,你会发现它的输入层是作者自定义的python layer,也就是voc_layers.py里面定义的,所以,我们来看一下voc_layers.py里面的内容:

class VOCSegDataLayer(caffe.Layer):
    """
    Load (input image, label image) pairs from PASCAL VOC
    one-at-a-time while reshaping the net to preserve dimensions.

    Use this to feed data to a fully convolutional network.
    """

    def setup(self, bottom, top):
        """
        Setup data layer according to parameters:

        - voc_dir: path to PASCAL VOC year dir
        - split: train / val / test
        - mean: tuple of mean values to subtract
        - randomize: load in random order (default: True)
        - seed: seed for randomization (default: None / current time)

        for PASCAL VOC semantic segmentation.

        example

        params = dict(voc_dir="/path/to/PASCAL/VOC2011",
            mean=(104.00698793, 116.66876762, 122.67891434),
            split="val")
        """
        # config
        params = eval(self.param_str)
        self.voc_dir = params['voc_dir']
        self.split = params['split']
        self.mean = np.array(params['mean'])
        self.random = params.get('randomize', True)
        self.seed = params.get('seed', None)

        # two tops: data and label
        if len(top) != 2:
            raise Exception("Need to define two tops: data and label.")
        # data layers have no bottoms
        if len(bottom) != 0:
            raise Exception("Do not define a bottom.")

        # load indices for images and labels
        split_f  = '{}/ImageSets/Segmentation/{}.txt'.format(self.voc_dir,
                self.split)
        self.indices = open(split_f, 'r').read().splitlines()
        self.idx = 0

        # make eval deterministic
        if 'train' not in self.split:
            self.random = False

        # randomization: seed and pick
        if self.random:
            random.seed(self.seed)
            self.idx = random.randint(0, len(self.indices)-1)


    def reshape(self, bottom, top):
        # load image + label image pair
        self.data = self.load_image(self.indices[self.idx])
        self.label = self.load_label(self.indices[self.idx])
        # reshape tops to fit (leading 1 is for batch dimension)
        top[0].reshape(1, *self.data.shape)
        top[1].reshape(1, *self.label.shape)


    def forward(self, bottom, top):
        # assign output
        top[0].data[...] = self.data
        top[1].data[...] = self.label

        # pick next input
        if self.random:
            self.idx = random.randint(0, len(self.indices)-1)
        else:
            self.idx += 1
            if self.idx == len(self.indices):
                self.idx = 0


    def backward(self, top, propagate_down, bottom):
        pass


    def load_image(self, idx):
        """
        Load input image and preprocess for Caffe:
        - cast to float
        - switch channels RGB -> BGR
        - subtract mean
        - transpose to channel x height x width order
        """
        im = Image.open('{}/JPEGImages/{}.jpg'.format(self.voc_dir, idx))
        in_ = np.array(im, dtype=np.float32)
        in_ = in_[:,:,::-1]
        in_ -= self.mean
        in_ = in_.transpose((2,0,1))
        return in_


    def load_label(self, idx):
        """
        Load label image as 1 x height x width integer array of label indices.
        The leading singleton dimension is required by the loss.
        """
        im = Image.open('{}/SegmentationClass/{}.png'.format(self.voc_dir, idx))
        label = np.array(im, dtype=np.uint8)
        label = label[np.newaxis, ...]
        return label

里面定义了两个类,我们只看其中一个,该数据层继承自caffe.Layer,因而必须重写setup(),reshape(),forward(),backward()函数。

setup()是类启动时该做的事情,比如层所需数据的初始化。

reshape()就是取数据然后把它规范化为四维的矩阵。每次取数据都会调用此函数,load_image()很容易理解,就是转化为caffe的标准输入数据,我们重点关注一下load_label()这个方法,我们会发现这里的label不同于以往的分类标签,而是一个二维的label,也就是SegmentationClass文件夹中的ground truth图片(这里很好奇这样的图片是怎么生成的,我后来测试时发现并不能直接生成这样的图片,希望有知道的知友告知一下

原来的图片是这样:


但是我们按照上面的方法把这张图片load进来之后,label就是一个(1,500,334)的二维数据(严格来说是三维),每个位置的数值正是原图在这个位置的类别,原图是这样的:


注:255是边界数据,在训练时会被忽略

forward()就是网络的前向运行,这里就是把取到的数据往前传递,因为没有其他运算。

backward()就是网络的反馈,data层是没有反馈的,所以这里就直接pass。

评论中有很多知友问到如何训练自己的数据,其实分析到这里就可以发现,大致可以以下三步:

1.为自己的数据制作label;

2.将自己的数据分为train,val和test集;

3.仿照voc_lyaers.py编写自己的输入数据层

但其实第一步还是挺困难的~~T_T

接下来我们分析一下论文中的关键结构:上采样和跳跃结构

ok,我们还是先从简单的结构开始,voc-fcns/train.prototxt

这里推荐使用Netscope进行网络结构的可视化

里面有这几个地方比较难以理解:


1.为什么首层卷积层要pad 100像素?

要解决这个问题,我们先来推算顺着结构推算一下:(假设是一般的VGG16结构,第一个卷积层只pad 1)

我们假设输入图片的高度是h,根据我们的卷积公式

conv1_1: h^1=(h+2*1-3)/1+1=h

conv1_2: h^2=(h+2*1-3)/1+1=h

。。。。。。

我们发现,VGG中缩小输出map只在池化层,所以下面我们忽略卷积层:

pool1: h^1=(h-2)/2+1=h/2

pool2: h^2=(h^1-2)/2+1=h/2^2

pool3: h^3=(h^2-2)/2+1=h/2^3

。。。。。。

pool5: h^5=(h^4-2)/2+1=h/2^5

很明显,feature map的尺寸缩小了32倍,接下来是卷积化的fc6层,如下图


fc6: h^6=(h^5-7)/1+1=(h-192)/2^5,

接下来还有两个卷积化的全连接层,fc7以及score_fr,但他们都是1*1的卷积核,对输出的尺寸并不会有影响,所以最终在输入反卷积之前的尺寸就是h^6!

推导到这里pad 100的作用已经很明显了,如果不进行padding操作,对于长或宽不超过192像素的图片是没法处理的,而当我们pad 100像素之后,再进行以上推导,可以得到:

h^6=(h+6)/2^5,这样就解决了以上问题,但是毋庸置疑,这会引入很多噪声。


2.反卷积层和Crop层如何产生和原图尺寸相同的输出?
卷积和反卷积在解读部分已经做了详细介绍,这里不再赘述,只是再重复一下公式,在卷积运算时,

W^1=(W+2*pad-kernelsize)/stride+1

H^1=(H+2*pad-kernelsize)/stride+1

有这样一组公式;而在上面我们提到,反卷积就是卷积计算的逆过程,所以在做反卷积时,W=(W^1-1)*stride+kernelsize-2*padH=(H^1-1)*stride+kernelsize-2*pad

显然输出的高度和宽度应该这样计算;下面我们继续上面的推导,

upscore: h^7=(h^6-1)*32+64=((h+6)/32-1)*32+64=h+38

反卷积之后,输出尺寸h^7与原图像不一致,接下来就是Crop层起作用的时候了

我们首先了解一下Caffe中的Crop层

注:以下解读来自caffe-users中Mohamed Ezz的回答

---------------------------------------------------------------------------------------------------------------------------

为了理解Crop层,我们首先回答一个问题:Caffe是如何知道在哪里开始裁剪?

很显然Caffe并不知道,应该是程序员为它指定的,right,我们正是通过offset参数来指定。

下面我们举一个例子:

Caffe中的Blob是4D数据:(N,C,H,W),Corp层有两个bottom blob,也就是两个输入,第一个是要crop的blob,假设为blob A:(20,50,512,512),第二个是参考的blob,假设为blob B:(20, 10, 256, 256),还有一个top blob,亦即输出,假设blob C:(20,10,256,256)。

很显然crop的作用就是参考B裁剪A。

在这个例子中,我们只想裁剪后三个维度,而保持第一个维度不变,所以我们需要指定axis=1,表明我们只想裁剪从1开始的所有维度;

接下来我们需要指定offset参数,保证正确crop.有两种指定方式:

1.为想要crop的每个维度指定特定值,比如在这里我们可以指定offset=(25,128,128),这样在实际crop过程中:C = A[: , 25: 25+B.shape[1] , 128: 128+B.shape[2] , 128: 128+B.shape[3] ],换句话说,就是我们只保留A第二维中25-35的部分而放弃了其他的,以及二维图中的中间部分

2.只指定一个offset值,假设我们offest=25,这样在上例中我们相当于指定了offset=(25,25,25),也就是为所有要crop的维度指定一个相同的值

---------------------------------------------------------------------------------------------------------------------------

好了,了解了这些之后我们再来看一下刚才的Crop层,现在很明白了,我们参考data层将upscore层裁剪成score层,参数axis=2,表明我们只想裁剪后两个维度,亦即输出尺寸,offset=19,对应于C = A[: , : , 19: 19+h , 19: 19+h ],这正是从(h+38,w+38)的upscore层中裁剪出中间的(h,h)的图像,这也就产生了和原图尺寸相同的最终输出!

可以看出,这中间的每一个设置都是独具匠心的,让人不得不生出敬意。

但是只是用pool5层进行上采样最后产生的结果是比较粗糙的,所以作者又将不同层级的池化层分别上采样,然后叠加到一起,这样产生了更好的结果。当然理解上述之后,大家可以自行分析FCN16s和FCN8s,这里就不再赘述了!

三.总结

图像语义分割可能是自动驾驶技术下一步发展的一个重要突破点,而且本身也特别有意思!借此机会也学习了一下Caffe计算卷积和转置卷积的过程,对语义分割也有了一个初步的了解,收获颇丰!

编辑于 2017-10-18