全卷积网络FCN

初步了解

什么是FCN网络

  • FCN将传统卷积网络后面的全连接层换成了卷积层;同时为了解决因为卷积和池化对图像尺寸的影响,提出使用上采样的方式恢复。

为什么需要FCN网络

  • 或者可以说,FCN网络比传统的CNN语义分割好在哪里。
  • 传统的CNN语义分割:首先传统的CNN语义分割也是像素级别的,但是它是将某个像素周围的一个图像块作为输入数据放入CNN进行训练分类的,也就是说每次仅仅对一个像素进行分类。那么它的问题就出现了。
  • 它的缺点是:一是存储开销很大。例如对每个像素使用的图像块的大小为15x15,然后不断滑动窗口,每次滑动的窗口给CNN进行判别分类,因此则所需的存储空间根据滑动窗口的次数和大小急剧上升;二是计算效率低下。相邻的像素块基本上是重复的,针对每个像素块逐个计算卷积,这种计算也有很大程度上的重复;三是像素块大小的限制了感知区域的大小。通常像素块的大小比整幅图像的大小小很多,只能提取一些局部的特征,从而导致分类的性能受到限制。
  • 其实给你的直观感觉就是传统的CNN语义分割算法很笨拙,好像是在用蛮力来解决问题。
  • 全卷积网络FCN:将传统CNN网络中的全连接层换成卷积层,同时由于多次卷积和池化操作,feature map变得越来越小,channels变得越来越大,而FCN的思想是让feature map和输入的图像尺寸保持一致,然后channels为分类数,分别对应分到某类的概率。这样就实现了对每个像素进行分类。于是其采用了上采样的操作来使heatMap(feature map到最后不再是feature map而是heatMap)恢复到原图的尺寸。

为什么要用卷积层而不是全连接层

  • 事实上,全连接层将特征图转化为了一个向量,使得其失去了原来的形状特征和空间信息。

总结

  • 其实FCN并不是特别难的网络结构,你可以把它理解为一个把多个卷积池化操作堆叠在一起的结构,也就是说,假如你需要进行图像语义分割,那么你可以找一个backbone(比如restNet34)接在前面,然后对输出的特征图接上咱们的FCN网络,一顿卷积操作,最终得到一个原图尺寸且channels等于像素分类数的三维矩阵,就可以得到每个像素点的分类类别了。

上采样

  • 上采样的方法有很多,这里fcn论文里使用的是比较常用的转置卷积来实现上采样,关于转置卷积,大家不懂的可以看下这篇博客:https://blog.csdn.net/tsyccnh/article/details/87357447
    但是其实FCN使用的并不是真正意义上的转置卷积,转置卷积应当理解为一种技术,因为经过了特征提取之后的feature map过滤了一些图像中的冗杂信息(有一定的信息损失,但是主要是一些与目标无关的损失),而语义分割需要实现像素级别的分类那么就需要尽可能的恢复,而转置卷积作为上采样的一种技术,就可以对feature map进行还原,但并不能做到和之前完全一样,不然就不叫损失了,在卷积的过程中一些信息是不可逆转的损失掉了,而我们能做的只是尽可能的还原。而重点是,fcn里的转置卷积核并不是随机初始化并且可以学习的,而是使用双线性插值进行初始化的,参数是固定的,且不可学习。 如果你了解双线性插值你就知道双线性插值可以一定程度对像素进行还原,放大图像,且可以通过转置卷积来实现,只需要把转置卷积的卷积核的weight通过双线性插值来生成就好了。
  • 一般来说,转置卷积的卷积核参数是随机初始化的,如下图torch.nn.ConvTranspose1d中_ConvNd的源码:
    在这里插入图片描述
    源码中是使用torch.Tensor(*sizes)来随机创建指定形状的Tensor的。
  • FCN是在skip layer使用三种不同形式的上采样,详情如下:
    在这里插入图片描述
    如上图所示,对原图像进行卷积conv1、pool1后原图像缩小为1/2;之后对图像进行第二次conv2、pool2后图像缩小为1/4;接着继续对图像进行第三次卷积操作conv3、pool3缩小为原图像的1/8,此时保留pool3的featureMap;接着继续对图像进行第四次卷积操作conv4、pool4,缩小为原图像的1/16,保留pool4的featureMap;最后对图像进行第五次卷积操作conv5、pool5,缩小为原图像的1/32,然后把原来CNN操作中的全连接变成卷积操作conv6、conv7,图像的featureMap数量改变但是图像大小依然为原图的1/32,此时图像不再叫featureMap而是叫heatMap。
  • 现在我们有1/32尺寸的heatMap,1/16尺寸的featureMap和1/8尺寸的featureMap,1/32尺寸的heatMap进行upsampling操作之后,因为这样的操作还原的图片仅仅是conv5中的卷积核中的特征,限于精度问题不能够很好地还原图像当中的特征。因此在这里向前迭代,把conv4中的卷积核对上一次upsampling之后的图进行反卷积补充细节(相当于一个插值过程),最后把conv3中的卷积核对刚才upsampling之后的图像进行再次反卷积补充细节,最后就完成了整个图像的还原。
    具体来说,就是将不同池化层的结果进行上采样,然后结合这些结果来优化输出,分为FCN-32s,FCN-16s,FCN-8s三种,第一行对应FCN-32s,第二行对应FCN-16s,第三行对应FCN-8s。 具体结构如下:
    在这里插入图片描述
  • 图中,image是原图像,conv1,conv2…,conv5为卷积操作,pool1,pool2,…pool5为pool操作(pool就是使得图片变为原图的1/2),注意con6-7是最后的卷积层,最右边一列是upsample后的end to end结果。必须说明的是图中nx是指对应的特征图上采样n倍(即变大n倍),并不是指有n个特征图,如32x upsampled 中的32x是图像只变大32倍,不是有32个上采样图像,又如2x conv7是指conv7的特征图变大2倍。
  • (1)FCN-32s过程
    只需要留意第一行,网络里面有5个pool,所以conv7的特征图是原始图像1/32,可以发现最左边image的是32x32(假设以倍数计),同时我们知道在FCN中的卷积是不会改变图像大小(或者只有少量像素的减少,特征图大小基本不会小很多)。看到pool1是16x16,pool2是8x8,pool3是4x4,pool4是2x2,pool5是1x1,所以conv7对应特征图大小为1x1,然后再经过32x upsampled prediction 图片变回32x32。FCN作者在这里增加一个卷积层,卷积后的大小为输入图像的32(2^5)倍,我们简单假设这个卷积核大小也为32,这样就是需要通过反馈训练32x32个权重变量即可让图像实现end to end,完成了一个32s的upsample。FCN作者称做后卷积,他也提及可以称为反卷积。事实上在源码中卷积核的大小为64,同时没有偏置bias。还有一点就是FCN论文中最后结果都是21×*,这里的21是指FCN使用的数据集分类,总共有21类。
  • (2)FCN-16s过程
    现在我们把1,2两行一起看,忽略32x upsampled prediction,说明FCN-16s的upsample过程。FCN作者在conv7先进行一个2x conv7操作,其实这里也只是增加1个卷积层,这次卷积后特征图的大小为conv7的2倍,可以从pool5与2x conv7中看出来。此时2x conv7与pool4的大小是一样的,FCN作者提出对pool4与2x conv7进行一个fuse操作(事实上就是将pool4与2x conv7相加,另一篇博客说是拼接,个人认为是拼接)。fuse结果进行16x upsampled prediction,与FCN-32s一样,也是增加一个卷积层,卷积后的大小为输入图像的16(2^4)倍。我们知道pool4的大小是2x2,放大16倍,就是32x32,这样最后图像大小也变为原来的大小,至此完成了一个16s的upsample。现在我们可以知道,FCN中的upsample实际是通过增加卷积层,通过bp反馈的训练方法训练卷积层达到end to end,这时卷积层的作用可以看作是pool的逆过程。
  • (3)FCN-8s过程
    这是我们看第1行与第3行,忽略32x upsampled prediction。conv7经过一次4x upsample,即使用一个卷积层,特征图输出大小为conv7的4倍,所得4x conv7的大小为4x4。然后pool4需要一次2x upsample,变成2x pool4,大小也为4x4。再把4x conv7,2x pool4与pool3进行fuse,得到求和后的特征图。最后增加一个卷积层,使得输出图片大小为pool3的8倍,也就是8x upsampled prediction的过程,得到一个end to end的图像。实验表明FCN-8s优于FCN-16s,FCN-32s。
    我们可以发现,如果继续仿照FCN作者的步骤,我们可以对pool2,pool1实现同样的方法,可以有FCN-4s,FCN-2s,最后得到end to end的输出。这里作者给出了明确的结论,超过FCN-8s之后,结果并不能继续优化。
  • 结合上述的FCN的全卷积与upsample,在upsample最后加上softmax,就可以对不同类别的大小概率进行估计,实现end to end。最后输出的图是一个概率估计,对应像素点的值越大,其像素为该类的结果也越大。FCN的核心贡献在于提出使用卷积层通过学习让图片实现end to end分类。
    事实上,FCN有一些短处,例如使用了较浅层的特征,因为fuse操作会加上较上层的pool特征值,导致高维特征不能很好得以使用,同时也因为使用较上层的pool特征值,导致FCN对图像大小变化有所要求,如果测试集的图像远大于或小于训练集的图像,FCN的效果就会变差。

使用FCN实现图像语义分割

  • 只听理论讲解有时候会比较蒙圈,下面呈上一份实际的语义分割代码,通过代码来理解FCN具体做了什么有时候会更加直观易懂。
  • 下面第一部分代码很多,但是大家不要害怕,这部分代码需要迅速略读,它主要是读取图片,然后对像素标签做一个像素-to-label的映射而已。话不多说,上代码
import torch
from torch import nn
import torch.nn.functional as f
import torchvision
import torchvision.transforms as tfs
from torch.utils.data import DataLoader
from torch.autograd import Variable
import torchvision.models as models
import numpy as np
import os
from PIL import Image
import matplotlib.pyplot as plt
from datetime import datetime


voc_root = "./data/VOC2012"

"""
读取图片
图片的名称在/ImageSets/Segmentation/train.txt ans val.txt里
如果传入参数train为True,则读取train.txt的内容,否则读取val.txt的内容
图片都在./data/VOC2012/JPEGImages文件夹下面,需要在train.txt读取的每一行后面加上.jpg
标签都在./data/VOC2012/SegmentationClass文件夹下面,需要在读取的每一行后面加上.png
最后返回记录图片路径的集合data和记录标签路径集合的label

"""
def read_images(root=voc_root, train=True):
    txt_fname = root + '/ImageSets/Segmentation/' + ('train.txt' if train else 'val.txt')
    with open(txt_fname, 'r') as f:
        images = f.read().split()
    data = [os.path.join(root, 'JPEGImages', i+'.jpg') for i in images]
    label = [os.path.join(root, 'SegmentationClass', i+'.png') for i in images]
    return data, label


"""
切割函数,默认都是从图片的左上角开始切割
切割后的图片宽为width,长为height
"""
def crop(data, label, height, width):
    """
    data和lable都是Image对象
    """
    box = (0, 0, width, height)
    data = data.crop(box)
    label = label.crop(box)
    return data, label



#下面我们需要将标签和像素点颜色之间建立映射关系
# VOC数据集中对应的标签
classes = ['background','aeroplane','bicycle','bird','boat',
           'bottle','bus','car','cat','chair','cow','diningtable',
           'dog','horse','motorbike','person','potted plant',
           'sheep','sofa','train','tv/monitor']

# 各种标签所对应的颜色
colormap = [[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]]
#因为图片是三通道的,并且每一个通道都有0-255一共256中取值,所以我们初始化一个256^3大小的数组就可以做映射了
cm2lbl = np.zeros(256**3)

# 枚举的时候i是下标,cm是一个三元组,分别标记了RGB值
for i, cm in enumerate(colormap):
    cm2lbl[(cm[0]*256 + cm[1])*256 + cm[2]] = i

"""
转换函数
将label中的阴影标签部分填入阴影所属于的类别的标签,其实就是建立起colormap和classes的映射,
使label里的colormap转化为对应的classes类别的下标
"""
# 将标签按照RGB值填入对应类别的下标信息
def image2label(im):
    data = np.array(im, dtype="int32")
    idx = (data[:,:,0]*256 + data[:,:,1])*256 + data[:,:,2]
    return np.array(cm2lbl[idx], dtype="int64")

#展示
# data, label = read_images(voc_root)#返回data和label的路径
# im = Image.open(label[20]).convert("RGB")
# label_im = image2label(im)
# plt.imshow(im)
# plt.show()
# print(label_im[100:110, 200:210])



"""
预处理函数
就是将上述函数对于读入数据的各种操作集合打包起来。
"""
#下面定义数据和标签的预处理函数
def image_transforms(data, label, height, width):
    data, label = crop(data, label, height, width)#先切割尺寸
#     print(data.size,label.size) 都是224,224
    # 将数据转换成tensor,并且做标准化处理
    im_tfs = tfs.Compose([
        tfs.ToTensor(),
        tfs.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
    ])
    
    data = im_tfs(data)
    label = image2label(label)#建立标签的映射
    label = torch.from_numpy(label)#从numpy转化为tensor
#     print(data.shape)  3,224,224
#     print(label.shape)   224,224
    return data, label


"""
数据集类
就是将数据处理后放入数据集中
"""
#定义VOCSegDataset类继承torch.utils.data.Dataset
#当我们集成了一个 Dataset类之后,我们需要重写 len 方法,该方法提供了dataset的大小; getitem 方法, 该方法支持从 0 到 len(self)的索引
class VOCSegDataset(torch.utils.data.Dataset):
    
    # 构造函数
    def __init__(self, train, height, width, transforms):
        self.height = height
        self.width = width
        self.fnum = 0    # 用来记录被过滤的图片数
        self.transforms = transforms
        data_list, label_list = read_images(train=train)#返回的是data和label的路径
        self.data_list = self._filter(data_list)#过滤
        self.label_list = self._filter(label_list)
        if(train==True):
            print("训练集:加载了 " + str(len(self.data_list)) + " 张图片和标签" + ",过滤了" + str(self.fnum) + "张图片")
        else:
            print("测试集:加载了 " + str(len(self.data_list)) + " 张图片和标签" + ",过滤了" + str(self.fnum) + "张图片")
            
#         img = self.data_list[0]
#         print(img)
#         print("*****************")
#         label = self.label_list[0]
#         print(label)
#         img = Image.open(img)
#         label = Image.open(label).convert('RGB')
#         img, label = self.transforms(img, label, self.height, self.width)#image_transforms处理
        
    
    # 过滤掉长小于height和宽小于width的图片
    def _filter(self, images): 
        img = []
        for im in images:
            if (Image.open(im).size[1] >= self.height and 
               Image.open(im).size[0] >= self.width):
                img.append(im)
            else:
                self.fnum  = self.fnum+1
        return img
    
    # 重载getitem函数,使类可以迭代
    def __getitem__(self, idx):
        img = self.data_list[idx]
        label = self.label_list[idx]
        img = Image.open(img)
        label = Image.open(label).convert('RGB')
        img, label = self.transforms(img, label, self.height, self.width)#image_transforms处理
        return img, label   #返回的是tensor,尺寸分别是 torch.Size([3, 224, 224])   torch.Size([224, 224])
    
    
    def __len__(self):
        return len(self.data_list)

# 以上就是整个VOC数据集的读取过程,对于数据读取过程也可以用于segnet网络
# 下面我们来实例化数据集
height = 224
width = 224
voc_train = VOCSegDataset(True, height, width, image_transforms)
voc_test = VOCSegDataset(False, height, width, image_transforms)

train_data = DataLoader(voc_train, batch_size=8, shuffle=True)
valid_data = DataLoader(voc_test, batch_size=8)

  • 然后是第二部分,初始化转置卷积卷积核的函数。这就是通过双线性插值来初始化转置卷积卷积核的函数,参数是固定的,不可学习。这一部分是比较重要的内容。
# 初始化转置卷积卷积核的函数
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 torch.from_numpy(np.array(weight))

  • 然后第三部分接一个backbone,我们选择restNet34
# 加载预训练的resnet34网络
model_root = "./model/resnet34-333f7ec4.pth"
#加载模型
pre = torch.load(model_root)
#将pre的模型复制到pretrained_net   pretrained=False表示只要网络结构,不需要参数
pretrained_net = models.resnet34(pretrained=False)
#load_state_dict区别于直接load,它只是提取参数存放到了pretrained_net,pretrained_net只是一个初始参数的网络,它是有网络结构的,只是没有参数,
#所以没必要读取全部网络,只需要load_state_dict读取参数再赋值给pretrained_net就好了。
pretrained_net.load_state_dict(pre)
# 分类的总数
num_classes = len(classes)
  • 第四部分是FCN的网络结构,刚开始我们对图片进行了裁剪使其能够适应RestNet34网络结构的输入,然后该结构输出2828128的图像。
# 下面就是fcn的网络结构,上采样的卷积核都使用bilinear_kernel进行初始化,一共三次上采样
# 关于resnet34网络结构,请移步:https://zhuanlan.zhihu.com/p/79378841
class fcn(nn.Module):
    def __init__(self, num_classes):
        super(fcn, self).__init__()
        
        # 第一段,通道数为128,输出特征图尺寸为28*28
        self.stage1 = nn.Sequential(*list(pretrained_net.children())[:-4]) 
        # 第二段,通道数为256,输出特征图尺寸为14*14
        self.stage2 = list(pretrained_net.children())[-4] 
        # 第三段,通道数为512,输出特征图尺寸为7*7
        self.stage3 = list(pretrained_net.children())[-3] 
        
        # 三个1*1的卷积操作,各个通道信息融合
        self.scores1 = nn.Conv2d(512, num_classes, 1)
        self.scores2 = nn.Conv2d(256, num_classes, 1)
        self.scores3 = nn.Conv2d(128, num_classes, 1)
        
        # 将特征图尺寸放大八倍
        self.upsample_8x = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=16, stride=8, padding=4, bias=False)
        self.upsample_8x.weight.data = bilinear_kernel(num_classes, num_classes, 16) # 使用双线性 kernel
        # 这是放大了两倍,下同
        self.upsample_4x = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=4, stride=2, padding=1, bias=False)
        self.upsample_4x.weight.data = bilinear_kernel(num_classes, num_classes, 4) # 使用双线性 kernel
        
        self.upsample_2x = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=4, stride=2, padding=1, bias=False)   
        self.upsample_2x.weight.data = bilinear_kernel(num_classes, num_classes, 4) # 使用双线性 kernel

        
    def forward(self, x):
        x = self.stage1(x)
        s1 = x # 224/8 = 28
        
        x = self.stage2(x)
        s2 = x # 224/16 = 14
        
        x = self.stage3(x)
        s3 = x # 224/32 = 7
        
        s3 = self.scores1(s3)      # 将各通道信息融合,其实就是1*1卷积,变成7*7*21
        s3 = self.upsample_2x(s3)  # 上采样
        s2 = self.scores2(s2)      # 1*1卷积,变为14*14*21
        s2 = s2 + s3  # 14*14
        
        s1 = self.scores3(s1)      # 1*1卷积,变为28*28*21
        s2 = self.upsample_4x(s2)  # 上采样,变成28*28
        s = s1 + s2                # 28*28

        s = self.upsample_8x(s)   # 放大八倍,变成224*224
        return s                   # 返回特征图


注意,我们的原图是224的,通过了backbone变成28*28的,也就是说投入到fcn网络的是28*28*128的特征图,代码中的self.stage是进行卷积,为什么单单摘出这28、14、7三个特征图,就是为了构造skip layer进行上采样,而self.scores操作其实就是1*1卷积,用于将通道数全部变成分类数,剩下的就单纯是上采样求和了。
再有就是nn.ConvTranspose2d用来得到转置卷积之后的矩阵。计算公式:output = (input-1)stride+outputpadding -2padding+kernelsize,其中
padding: 输入的每一条边补充0的层数,高宽都增加2*padding
output_padding:输出边补充0的层数,高宽都增加padding
咱们以upsample_8x为例,其参数是:num_classes, num_classes, kernel_size=16,stride=8, padding=4, bias=False,然后作用于s,s的尺寸是28,所以output=(28-1)*8-8+16=224,bingo!没问题。
上述代码模拟了一遍skip layer的上采样操作。对了另外上述使用了双线性卷积核来初始化卷积核的权重而不是随机初始化,即第二部分的内容。
后面就是常规的训练代码了,我这里就不补充了,这不是我们要讲的重点。

FCN总体过程

  • 首先选一个backbone,提取特征
    在这里插入图片描述
    以经典的分类网络为初始化。最后两级是全连接(红色),参数弃去不用。
  • 然后分三种上采样:
    在这里插入图片描述
    ①从特征小图(16164096)预测分割小图(161621)(使用1*1卷积实现),之后直接上采样为大图。
    反卷积(橙色)的步长为32,这个网络称为FCN-32s。
    这一阶段使用单GPU训练约需3天。
    在这里插入图片描述
    ②上采样分为两次完成(橙色×2)。
    在第二次升采样前,把第4个pooling层(绿色)的预测结果(蓝色)融合进来。使用跳级结构提升精确性。
    第二次反卷积步长为16,这个网络称为FCN-16s。
    这一阶段使用单GPU训练约需1天。
    在这里插入图片描述
    ③上采样分为三次完成(橙色×3)。
    进一步融合了第3个pooling层的预测结果。
    第三次反卷积步长为8,记为FCN-8s。
    这一阶段使用单GPU训练约需1天。
    较浅层的预测结果包含了更多细节信息。比较2,3,4阶段可以看出,跳级结构利用浅层信息辅助逐步上采样,有更精细的结果。
    效果:
    在这里插入图片描述

本文主要引用

  • https://www.cnblogs.com/xiaoboge/p/10502697.html
  • https://blog.csdn.net/haohulala/article/details/107660273
  • 侵删
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CtrlZ1

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值