基于全卷积的图像语义分割—《Fully Convolutional Networks for Semantic Segmentation》

两年前,我曾想做一个自动抠“人”的系统,目标是去除路人甲或者自动合成照片。当时“井底之蛙”般搞了一个混合高斯模型,通过像素聚类的方式来抠“人”。这个模型,每跑一张小图片需要几分钟,抠出来的前景“噪音”很严重,完全没办法使用。最后这个通过“抠人”去除路人甲的项目告吹。

两年后的今天,这种“去除路人甲”的软件好像早已经有了,并且笨妞也发现,换成现在的我,做一个效果好的“抠人”神器太容易了。下载deeplab最新的图像语义分割开源项目,并下载预训练的模型,效果简直不要太好。而是deeplab早期版本16年之前早就出来了,只是当时太无知(实际上,现在也很无知)。所以,科技在进步,我们这些底层的搬砖工还是得关注金字塔上层在搞什么事情哦。


(图1. deeplab v3+的图像语义分割效果)

好了,说了这么多,回到今天的主题吧。今天不玩deeplab最新语义分割项目。今天学习基础的语义分割——全卷积。

图像分割比较热门的是语义分割和实例分割。语义分割的重点也是挑战点有两个方面——语义、位置,也就是分割出来的结果包含两点:分割出了什么(what),他们分别在图像中的具体位置(where),位置是像素级别的。实例分割笨妞完全不了解,貌似比语义分割更进一步,除了what,where,貌似还得分清图像种每一类what各有几个,分别在哪儿。


(图2:分割原图; 图3:语义分割结果;  图4: 实例分割结果)


笨妞了解语义图像分割从论文《Fully Convolutional Networks for Semantic Segmentation》入手,可能也止于这篇论文,毕竟不是做机器视觉的。原论文在这里

一、论文理解

从这篇论文中学到了两点:1. .卷积神经网络从图像分类到图像分割的转化过程; 2. 卷积转置(反卷积)过程,浅层与深层的跳跃式组合结构。


1. 图像分类和图像分割的区别

图像分类是“图像与类别”的关系映射。输入图像经过卷积神经网络层层深入,提取特征,然后,这些特征被全连接网络展平,通过softmax映射成各类别的概率,计算出图像的类别。


(图5. 图像分类和图像分割的网络差别)

图像分割是“图像与图像”的关系映射,也可以理解为“像素与像素”的关系映射,要达成“像素与像素的映射”,目标的尺寸和输入图像的尺寸就是一致的。但是,在卷积过程中,通常特征图的数量越来越多,但尺寸却越来越小。于是,有了卷积的“逆向”过程。


2. 从图像分类到图像分割的过渡

卷积的逆转要怎么实现呢?卷积逆转的目的是把卷积中池化变小的特征图再变大回来。卷积中,特征图变小主要是通过stride > 1的池化过程完成的,这种称为下池化,也称为池化过程下采样。那么问题来了,在卷积过程中,不做下采样的池化不就可以了。然后,下采样池化能够带来更好的泛化能力,不做下采样,CNN的效果就没那么好了。不能不做下采样,只能把下采样变小的图像,再上采样回来,个人理解,无论下采样池化还是上采样池化都能提高网络的泛化能力。

所以,卷积的逆转重点在于上采样池化。


(图7: 图像分割全卷积网络)

嘿嘿,作为资深模仿和调参党,笨妞之前尝试图像分类,基本都是运用vgg19、inception v3等现成的网络结构,然后加载imagenet预训练后的模型(no-top),然后用自己的数据集再微调参数。毕竟,自己的数据集有限,计算力更有限,加载预训练模型确实能更快满足需求。

那么,在FCN上,我们也是希望可以借用预训练模型的参数的,怎么借用呢?像VGGnet, 有3个全连接层呢,即便是no-top模型,依然还有两个全连接层。通常有两种办法,以Vvgg16为例:A.  自己设计模型,5个CNN block和vgg16一样,并逐层借用vgg16的参数,后面的就自己搞定了; B. 把n维的全连接层看做是尺寸为(1,n)的卷积核, 这样,模型的前半段CNN过程,依然是5个CNN block和2个(1,n)卷积核的卷积层,整个vgg16的模型参数可以一起加载进模型。


3. 跳跃式结构


(图8: 跳跃式结构)

个人对CNN的理解,层次越深时,特征图关注的点越局部。论文作者设计这样的结构目的就是将浅层的特征和深层的特征融合起来,达到特征图多尺度的目的,从而使“映射回图像”时,既关注细粒度的特征吗,也留住粗粒度的特征。

vgg16 网络中,5个cnn block的特征图的下采样尺寸是这样2->4->8->16->32. 作者做了3个实验,对比效果,第一种是“single-stream”,就是把前半段卷积的结果一次性上采样回去,即32倍上采样,作者把这种方式称为FCN-32S。第二种是“two-stream”,将第4个CNN block的池化结果加入进来,第4个block的输出需要16倍的上采样,将16倍上采样的结果和"single-stream”的结果融合,称之为FCN-16S。第三种是“three-stream”,将第3个block的池化结果通过8倍上采样,结果与“two-stram”的结果融合。

3种方式的结果是这样的:


这种跳跃式结构比一次性暴力上采样回去,效果好了很多。但是,比deeplvb最新的语义分割架构deeplab v3+还是要差很多。


二、 跑程序收获

在读完论文之后,笨妞一直以为图像分割就是输入一张图像,然后输出和ground truth图像直接作损失,然后,反向调节参数。在看了实现程序之后才明白,二分类的图像分割确实是这样,但像PASCAL VOC这样的数据集,有20类事物,实际上是1张输入图像,对应ground truth生成的21张label图。这样一来,感觉图像分割还是有点分类的味道。

下面是程序和解析:

from keras.layers import merge, Input
from keras.layers.core import Activation
from keras.layers.convolutional import Convolution2D, Deconvolution2D, Cropping2D
from keras.models import Model
from keras.engine.topology import Layer
from keras.utils import np_utils, generic_utils
from keras import backend as K
from keras.applications.vgg16 import VGG16, preprocess_input,decode_predictions
from keras.utils.vis_utils import model_to_dot, plot_model
from keras.preprocessing import image
from keras.optimizers import Adam
from keras import backend as K

import cv2
import numpy as np
from PIL import Image
import h5py

class Softmax2D(Layer):
    def __init__(self, **kwargs):
        super(Softmax2D, self).__init__(**kwargs)

    def build(self, input_shape):
        pass

    def call(self, x, mask=None):
        e = K.exp(x - K.max(x, axis=1, keepdims=True))
        s = K.sum(e, axis=1, keepdims=True)
        return K.clip(e/s, 1e-7, 1)

    def get_output_shape_for(self, input_shape):
        return (input_shape)

class FullyConvolutionalNetwork():
    def __init__(self, batchsize=1, img_height=224, img_width=224, FCN_CLASSES=21):
        self.batchsize = batchsize
        self.img_height = img_height
        self.img_width = img_width
        self.FCN_CLASSES = FCN_CLASSES
        self.vgg16 = VGG16(include_top=False,
                           weights='imagenet',
                           input_tensor=None,
                           input_shape=(self.img_height, self.img_width, 3))

    def create_model(self, train_flag=True):
        #(samples, channels, rows, cols)
        ip = Input(shape=(self.img_height, self.img_width, 3))
        h = self.vgg16.layers[1](ip)
        h = self.vgg16.layers[2](h)
        h = self.vgg16.layers[3](h)
        h = self.vgg16.layers[4](h)
        h = self.vgg16.layers[5](h)
        h = self.vgg16.layers[6](h)
        h = self.vgg16.layers[7](h)
        h = self.vgg16.layers[8](h)
        h = self.vgg16.layers[9](h)
        h = self.vgg16.layers[10](h)

        # split layer
        p3 = h

        h = self.vgg16.layers[11](h)
        h = self.vgg16.layers[12](h)
        h = self.vgg16.layers[13](h)
        h = self.vgg16.layers[14](h)

        # split layer
        p4 = h

        h = self.vgg16.layers[15](h)
        h = self.vgg16.layers[16](h)
        h = self.vgg16.layers[17](h)
        h = self.vgg16.layers[18](h)

        p5 = h
        #以上所有层都来自vgg16,初始化参数也来自imagenet预训练的vgg16模型。

        # get scores
        #将第3个池化层的输出拿出来,做卷积
        p3 = Convolution2D(self.FCN_CLASSES, 1, 1, activation='relu', border_mode='valid')(p3)
        #将第4个池化层的输出拿出来,做卷积
        p4 = Convolution2D(self.FCN_CLASSES, 1, 1, activation='relu')(p4)
        #p4做2倍上采样
        p4 = Deconvolution2D(self.FCN_CLASSES, 4, 4,
                output_shape=(self.batchsize, 30, 30, self.FCN_CLASSES),
                subsample=(2, 2),
                border_mode='valid')(p4)
        #裁剪图像
        p4 = Cropping2D(((1, 1), (1, 1)))(p4)


        #将第5个池化层的输出拿出来,做卷积
        p5 = Convolution2D(self.FCN_CLASSES, 1, 1, activation='relu')(p5)
        #p5做4倍上采样
        p5 = Deconvolution2D(self.FCN_CLASSES, 8, 8,
                output_shape=(self.batchsize, 32, 32, self.FCN_CLASSES),
                subsample=(4, 4),
                border_mode='valid')(p5)
        p5 = Cropping2D(((2, 2), (2, 2)))(p5)

        # merge scores
        #p3、p4、p5合并
        h = merge([p3, p4, p5], mode="sum")
        合并后做8倍上采样。
        h = Deconvolution2D(self.FCN_CLASSES, 16, 16,
                output_shape=(self.batchsize, 232, 232, self.FCN_CLASSES),
                subsample=(8, 8),
                border_mode='valid')(h)
        h = Cropping2D(((4, 4), (4, 4)))(h)

        #2维softmax,生成21张二维图像
        h = Softmax2D()(h)
        return Model(ip, h)

#binarylab将ground truth按照内部的像素值生成21张二值图。
#这21张二值图中,第一张为背景图,背景的取值为1(白),前景取值为0(黑).
#后面的20张图中,每种类别占一张二值图,如果groung truth中有该类别的区域,则在该张二值图中,该区域为1,其他为0.
#如果ground truth中不包含该种类别,那么对应的二值图全为0(全黑)
def binarylab(labels, size, nb_class):
    y = np.zeros((size,size,nb_class))
    for i in range(size):
        for j in range(size):
            y[i, j,labels[i][j]] = 1
    return y

def load_data(path, size=224, mode=None):
    img = Image.open(path)
    w,h = img.size
    if w < h:
        if w < size:
            img = img.resize((size, size*h//w))
            w, h = img.size
    else:
        if h < size:
            img = img.resize((size*w//h, size))
            w, h = img.size
    img = img.crop((int((w-size)*0.5), int((h-size)*0.5), int((w+size)*0.5), int((h+size)*0.5)))
    if mode=="original":
        return img

    if mode=="label":
        y = np.array(img, dtype=np.int32)
        mask = y == 255
        y[mask] = 0
        y = binarylab(y, size, 21)
        y = np.expand_dims(y, axis=0)
        return y
    if mode=="data":
        X = image.img_to_array(img)
        X = np.expand_dims(X, axis=0)
        X = preprocess_input(X)
        return X

def generate_arrays_from_file(names, path_to_train, path_to_target, img_size, nb_class):
    while True:
        for name in names:
            Xpath = path_to_train + "{}.jpg".format(name)
            ypath = path_to_target + "{}.png".format(name)
            X = load_data(Xpath, img_size, mode="data")
            y = load_data(ypath, img_size, mode="label")
            yield (X, y)

def crossentropy(y_true, y_pred):
    return -K.sum(y_true*K.log(y_pred))

import argparse
parser = argparse.ArgumentParser(description='FCN via Keras')
parser.add_argument('--train_dataset', '-tr', default='dataset', type=str)
parser.add_argument('--target_dataset', '-ta', default='dataset', type=str)
parser.add_argument('--txtfile', '-t', type=str, required=True)
parser.add_argument('--weight', '-w', default="", type=str)
parser.add_argument('--epoch', '-e', default=100, type=int)
parser.add_argument('--classes', '-c', default=21, type=int)
parser.add_argument('--batchsize', '-b', default=32, type=int)
parser.add_argument('--lr', '-l', default=1e-4, type=float)
parser.add_argument('--image_size', default=224, type=int)

args = parser.parse_args(['--epoch', '5',
                          '--train_dataset', 'image/VOCtrainval_11-May-2012/VOCdevkit/VOC2012/JPEGImages/',
                          '--txtfile', 'image/VOCtrainval_11-May-2012/VOCdevkit/VOC2012/ImageSets/Segmentation/train.txt',
                         '--target_dataset', 'image/VOCtrainval_11-May-2012/VOCdevkit/VOC2012/SegmentationClass/'])
img_size = args.image_size
nb_class = args.classes
path_to_train = args.train_dataset
path_to_target = args.target_dataset
path_to_txt = args.txtfile
batch_size = args.batchsize

with open(path_to_txt,"r") as f:
    ls = f.readlines()
names = [l.rstrip('\n') for l in ls]
nb_data = len(names)

FCN = FullyConvolutionalNetwork(img_height=img_size, img_width=img_size, FCN_CLASSES=nb_class)
adam = Adam(lr=args.lr)
train_model = FCN.create_model(train_flag=True)
train_model.compile(loss=crossentropy, optimizer='adam')
if len(args.weight):
    model.load_weights(args.weight, model)
print("Num data: {}".format(nb_data))

train_model.fit_generator(generate_arrays_from_file(names,path_to_train,path_to_target,img_size, nb_class),
                    samples_per_epoch=nb_data,
                    nb_epoch=args.epoch)

if not os.path.exists("weights"):
    os.makedirs("weights")

train_model.save_weights("weights/temp", overwrite=True)
f = h5py.File("weights/temp")

layer_names = [name for name in f.attrs['layer_names']]
fcn = FCN.create_model(train_flag=False)

for i, layer in enumerate(fcn.layers):
    g = f[layer_names[i]]
    weights = [g[name] for name in g.attrs['weight_names']]
    layer.set_weights(weights)

fcn.save_weights("weights/fcn_params", overwrite=True)

f.close()
os.remove("weights/temp")

print("Saved weights")


不知道是怎么搞得,我跑这个网络的时候,loss一直不下降。从网上搜了下,有很多这样的情况,笨妞按照他们的方法修改了,但是,loss还是不动,就像网络根本没有被训练一样。暂时只能不纠结这个问题了。后面有机会再看看吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章