图像语义分割模型 FCN

全卷积网络(Fully Convolutional Networks,FCN) 是用深度神经网络来做语义分割的开山之作,它是首个端对端的针对像素级预测的全卷积网络,自从该网络提出后,就成为语义分割的基本框架,后续算法基本都是在该网络框架中改进而来。

FCN

FCN 以 VGG16 作为 backbone 提取不同层次的特征,之后再通过双线性插值方法恢复特征图的分辨率,在这过程中同时利用了跳跃连接,逐步融合下采样端产生的不同层次的特征信息,从而提高了分割效果。

FCN 将传统 CNN 中的全连接层都替换成了卷积层,例如在传统的 CNN 结构 VGG16 中前面 5 层对应着卷积层,第 6、7 为全连接层长度为 4096 的一维向量,第 8 层也为全连接层长度为 1000 的一维向量,代表着分类的类别为 1000。

图像语义分割模型 FCN_第1张图片

而 FCN 通过将这 6、7、8 层都改为卷积层,对应着的卷积核大小分别为(7,7,4096)、(1,1,4096)、(1,1,1000)。由于网络中所有层都为卷积层,因此也称为全卷积神经网络。

通过多层的卷积和池化操作之后,我们得到的图像也会变得越来越小,随之分辨率也会变得也来越低,在 FCN 中通过使用上采样技术进行图像恢复,恢复到原始的图像大小以及分辨率,例如当网络经过 5 次卷积和池化操作以后,图像的分辨率依次缩小到了 2、4、8、16、32 倍的大小,因此对应着最后一层图像的输出,则需要经过一次 32 倍的上采样技术才能够得到与原始图像一样大小的图片。

图像语义分割模型 FCN_第2张图片

在 FCN 中上采样技术是通过反卷积技术和跳层连接进行实现的,例如如果只通过对第 5 层的输出结果进行上采样得到原始图像大小这样的结果往往是不够的精确的,会导致一些细节特征丢失从而无法恢复,出于这样的考虑,作者又将第 4 层的输出和第 3 层的输出结果同样的进行反卷积操作,它们分别需要进行一次 16 倍的上采样和 8 倍的上采样,之后将它们得到的结果进行融合,从而达到了更加精确的分割效果。

如下图所示,图像进行 32 倍、16 倍、8 倍上采样的结果对比图可以发现,它们得到的分割结果也变得越来越精确了。

图像语义分割模型 FCN_第3张图片

FCN-32s

原始论文中 FCN 算法实现的架构细节,模型前半部分和一般的 CNN 结构类似,交替使用卷积层和池化层,卷积层因先做了扩边处理(外围一圈填充 0,是 Padding-100)故处理完大小不变,池化层使图像长宽均缩短为原先的 1/2,这一部分总共经历 5 层池化层后图像长宽尺寸均减小到原先的 1/32,对此输出直接上采样就得到 FCN-32 的输出(第一个预测结果,精度略差),这里的上采样就是反卷积运算,主要通过间隔补零来改变大小。

由上图可以看到,图像进行32倍上采样对于一张输入图片(h,w,3) 通过 VGG16 backbone 的前 5 个卷积和池化层之后图像大小缩小为(h/32, w/32, 512)。

经过 FC6 卷积核大小为(7,7,4096),图像大小变为(h/32, w/32, 4096)。

经过 FC7 卷积核大小为(1,1,4096),图像大小变为(h/32, w/32, 4096)。

经过 FC8 卷积核大小为(1,1,num_cls),图像大小变成了(h/32, w/32, num_cls),最后通过反卷积操作对结果图进行32倍上采样,恢复到了原始图像大小。

代码实现如下,首先导入需要的工具包:

import torch
import torch.nn as nn
import numpy as np

建立 layer 和 block,建立 VGG 重复的 stage 结构,block 依次包含 conv-bn-relu:

# Block 包含:conv-bn-relu
class Block(nn.Module):
    def __init__(self, in_ch, out_ch, kernel_size=3, padding=1, stride=1):
        super(Block, self).__init__()
        self.conv1 = nn.Conv2d(in_ch, out_ch, kernel_size=kernel_size, padding=padding, stride=stride)
        self.bn1 = nn.BatchNorm2d(out_ch)
        self.relu1 = nn.ReLU(inplace=True)
    def forward(self, x):
        out = self.relu1(self.bn1(self.conv1(x)))
        return out
        
        
# 建立 layer 加入很多 Block
def make_layers(in_channels, layer_list):
    layers = []
    for out_channels in layer_list:
      layers += [Block(in_channels, out_channels)]
      in_channels = out_channels
    return nn.Sequential(*layers)
            

class Layer(nn.Module):
    def __init__(self, in_channels, layer_list):
        super(Layer, self).__init__()
        self.layer = make_layers(in_channels, layer_list)
    def forward(self, x):
        out = self.layer(x)
        return out

修改 VGG 的代码,改为 FCN-32s

  • 在 VGG 的代码里第一层的 padding=1 改成 padding = 100,是为了适应不同大小的输入图像,如果不设置padding,那么当输入图片大小 小于 192 × 192 192 \times 192 192×192 ,则在 7 × 7 7 \times 7 7×7 的卷积核处做卷积时会报错。

  • 将全连接层 self.fc6 = nn.Linear(512*7*7, 4096) 改为卷积层 self.fc6 = nn.Conv2d(512, 4096, 7) ,这样可以使用预训练模型的权重。后面的两个全连接层修改成 1 × 1 1 \times 1 1×1 的卷积

  • 上采样(upsampling)使用的是 Conv2DTranspose 方法,上采样32倍,self.upscore = nn.ConvTranspose2d(n_class, n_class, 64, 32)

  • 因有 padding=100 的操作,上采样 32 倍之后需要裁剪成与原图一样,左右裁剪 19 个像素,具体计算如下。

原图 S S S 经过self.conv1 之后特征图的大小变为 1 + (S + 200 -3) / 1 = S + 198;

后续的卷积层 kernel_size=3, padding=1, stride=1,尺寸不变,5 次降采样2倍,尺寸变为 (S+198)/32;

直到 self.fc6 = nn.Conv2d(512, 4096, 7),尺寸变为 1 + ( (S+198)/32 - 7 ) / 1 = (S+6)/32;

最后上采样 32 倍 self.upscore = nn.ConvTranspose2d(n_class, n_class, 64, 32),尺寸变为 ((S+6)/32 - 1 )* 32 + 64 = S + 38,中心裁剪 38/2 = 19。

class VGG_fcn32s(nn.Module):
    '''
    将 VGG model 改变成 FCN-32s 
    '''
    def __init__(self, n_class=21):
        super(VGG_fcn32s, self).__init__()
        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, padding=100) # padding = 100,传统 VGG 为1
        self.bn1 = nn.BatchNorm2d(64)
        self.relu1 = nn.ReLU(inplace=True)
        self.layer1 = Layer(64, [64]) # 第一组 Stage
        self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 降采样 /2
        self.layer2 = Layer(64, [128, 128]) # 第二组 Stage
        self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2)# 降采样 /4
        self.layer3 = Layer(128, [256, 256, 256, 256]) # 第三组 Stage
        self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2)# 降采样 /8
        self.layer4 = Layer(256, [512, 512, 512, 512]) # 第四组 Stage
        self.pool4 = nn.MaxPool2d(kernel_size=2, stride=2)# 降采样 /16
        self.layer5 = Layer(512, [512, 512, 512, 512]) # 第五组 Stage
        self.pool5 = nn.MaxPool2d(kernel_size=2, stride=2)# 降采样 /32

        # modify to be compatible with segmentation and classification
        #self.fc6 = nn.Linear(512*7*7, 4096) # 全连接层 VGG
        self.fc6 = nn.Conv2d(512, 4096, 7) # padding = 0
        self.relu6 = nn.ReLU(inplace=True)
        self.drop6 = nn.Dropout()

        #self.fc7 = nn.Linear(4096, 4096) # 全连接层 VGG
        self.fc7 = nn.Conv2d(4096, 4096, 1)
        self.relu7 = nn.ReLU(inplace=True)
        self.drop7 = nn.Dropout()

        #self.score = nn.Linear(4096, n_class) # 全连接层 VGG
        self.score = nn.Conv2d(4096, n_class, 1)
        
        self.upscore = nn.ConvTranspose2d(n_class, n_class, 64, 32) # 上采样 32 倍

    def forward(self, x):
        f0 = self.relu1(self.bn1(self.conv1(x)))
        f1 = self.pool1(self.layer1(f0))
        f2 = self.pool2(self.layer2(f1))
        f3 = self.pool3(self.layer3(f2))
        f4 = self.pool4(self.layer4(f3))
        f5 = self.pool5(self.layer5(f4))
        #f5 = f5.view(f5.size(0), -1) 
        print('f5.shape:', f5.shape)
        f6 = self.drop6(self.relu6(self.fc6(f5)))
        print('f6.shape:', f6.shape)
        f7 = self.drop7(self.relu7(self.fc7(f6)))
        print('f7.shape:', f7.shape)
        score = self.score(f7)
        upscore = self.upscore(score)
        # crop 19 再相加融合 [batchsize, channel, H, W ] 要对 H、W 维度裁剪
        upscore = upscore[:, :, 19:19+x.size(2), 19:19+x.size(3)].contiguous() 
        return upscore

最后打印模型的输出,查看效果。
图像语义分割模型 FCN_第4张图片

FCN-16 和 FCN-8 的计算思想类似,只是把最后一个预测卷积层的输入修改了一下大小,以增加输入信息。

FCN-16s

输入图片(h, w, 3) 通过 VGG16 backbone 的前 5 个卷积和池化层之后图像大小缩小为(h/32, w/32, 512),然后经过 FC6、FC7 卷积核大小分别为(7,7,4096)、(1,1,4096),图像大小变为(h/32, w/32, 4096);

其次经过 FC8 卷积核大小为(1,1,num_cls),图像大小变成了(h/32, w/32,num_cls),之后经过反卷积 2 倍上采样将图像大小变为(h/16, w/16, num_cls);

另一部分则经过 pool4 图像大小变成了(h/16, w/16, 512),然后通过卷积核大小为(1,1,num_cls),图像大小变成了(h/16, w/16, num_cls),最后将得到的两部分进行融合,得到的结果经过一次 16 倍的上采样技术恢复到原始图片的大小。

FCN-8s

FCN-16 和 FCN-8 代码实现类似,这里代码实现稍微复杂的 FCN-8s。

图像语义分割模型 FCN_第5张图片

输入图片经过 FC8 卷积核大小为(1,1,num_cls),图像大小变成了(h/32, w/32, num_cls),之后经过反卷积 2 倍上采样将图像大小变为(h/16, w/16, num_cls),代码标记为 up2_feat

另一部分则经过 pool4 图像大小变成了(h/16,w/16,512),然后通过卷积核大小为(1,1,num_cls),图像大小变成了(h/16, w/16, num_cls),代码实现细节在h = self.trans_f4(f4)

将以上得到的两部分进行融合,代码实现细节在h = h + up2_feat

得到的结果经过一次 2 倍的上采样技术得到图像大小为(h/8, w/8, num_cls),代码实现细节在up4_feat = self.up4times(h)

h 之后与经过 pool3 图像大小变为(h/8, w/8, 256),通过卷积和大小为(1,1,num_cls),图像大小变成了(h/8, w/8, num_cls)两部分进行融合,代码细节在h = self.trans_f3(f3) h = h + up4_feat

最终通过一次8倍的上采样技术,恢复到了原始图像的大小,代码细节在h = self.up32times(h)

整体代码如下:

class VGG_19bn_8s(nn.Module):
    def __init__(self, n_class=21):
        super(VGG_19bn_8s, self).__init__()
        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, padding=100)
        self.bn1 = nn.BatchNorm2d(64)
        self.relu1 = nn.ReLU(inplace=True)
        self.layer1 = Layer(64, [64])
        self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2)
        self.layer2 = Layer(64, [128, 128])
        self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2)
        self.layer3 = Layer(128, [256, 256, 256, 256])
        self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2)
        self.layer4 = Layer(256, [512, 512, 512, 512])
        self.pool4 = nn.MaxPool2d(kernel_size=2, stride=2)
        self.layer5 = Layer(512, [512, 512, 512, 512])
        self.pool5 = nn.MaxPool2d(kernel_size=2, stride=2)
        
        self.fc6 = nn.Conv2d(512, 4096, 7) # padding=0
        self.relu6 = nn.ReLU(inplace=True)
        self.drop6 = nn.Dropout2d()

        self.fc7 = nn.Conv2d(4096, 4096, 1)
        self.relu7 = nn.ReLU(inplace=True)
        self.drop7 = nn.Dropout2d()

        self.score_fr = nn.Conv2d(4096, n_class, 1)
        self.trans_f4 = nn.Conv2d(512, n_class, 1) # 通道数归一化成 n_calss
        self.trans_f3 = nn.Conv2d(256, n_class, 1) # 通道数归一化成 n_calss

        self.up2times = nn.ConvTranspose2d(
            n_class, n_class, 4, stride=2, bias=False) # 上采样2倍
        self.up4times = nn.ConvTranspose2d(
            n_class, n_class, 4, stride=2, bias=False) # 上采样2倍
        self.up32times = nn.ConvTranspose2d(
            n_class, n_class, 16, stride=8, bias=False) # 上采样8倍
        for m in self.modules():
            if isinstance(m, nn.ConvTranspose2d):
                m.weight.data = bilinear_kernel(n_class, n_class, m.kernel_size[0])
    
    def forward(self, x):
        f0 = self.relu1(self.bn1(self.conv1(x)))
        f1 = self.pool1(self.layer1(f0))
        f2 = self.pool2(self.layer2(f1))
        f3 = self.pool3(self.layer3(f2))
        f4 = self.pool4(self.layer4(f3))
        f5 = self.pool5(self.layer5(f4))
        
        f6 = self.drop6(self.relu6(self.fc6(f5)))
        f7 = self.score_fr(self.drop7(self.relu7(self.fc7(f6))))
        
        up2_feat = self.up2times(f7) # 上采样2倍
        h = self.trans_f4(f4) # pool4 通道数归一化成 n_calss,便于相加
        print(h.shape)
        print(up2_feat.shape)
        h = h[:, :, 5:5 + up2_feat.size(2), 5:5 + up2_feat.size(3)] 
        h = h + up2_feat
        
        up4_feat = self.up4times(h) # 上采样2倍
        h = self.trans_f3(f3) # pool3 通道数归一化成 n_calss,便于相加
        print(h.shape)
        print(up4_feat.shape)
        h = h[:, :, 9:9 + up4_feat.size(2), 9:9 + up4_feat.size(3)]
        h = h + up4_feat
        
        h = self.up32times(h) # 上采样8倍
        print(h.shape)
        final_scores = h[:, :, 31:31 + x.size(2), 31:31 + x.size(3)].contiguous()
        
        return final_scores

最后总结一下 FCN-32s、FCN-8s 的中心裁剪细节。

FCN-32s 裁剪细节

输出特征图大小计算公式 O = (I + 2P – K) / S + 1

上采样的计算公式: O = (I - 1)* S + K - 2P

self.conv1 = nn.Conv2d(3, 64, kernel_size=3, padding=100)

尺寸
conv1 (S + 200 -3) / 1 + 1 = S + 198
Pool1 (S + 198) / 2
Pool2 (S + 198) / 4
Pool3 (S + 198) / 8
Pool4 (S + 198) / 16
Pool5 (S + 198) / 32
Fc6 (S + 198) / 32 -7 + 1 = (S + 198 - 6*32) / 32 = (S + 6) / 32
Fc7 (S + 6) / 32 - 1 + 1 = (S + 6) / 32

FCN-32S : 上采样Fc7 32倍 为 [(S + 6) / 32 - 1 ] * 32 + 64 - 2*0 = (S + 6) -32 + 64 = S + 38

FCN-32S 要跟原图一样,应该前后左右裁剪 38/2 = 19

FCN-8s 裁剪细节

Fc7:
上采样 2 倍 为 [(S + 6) / 32 - 1 ] * 2 + 4 - 2*0 = (S + 6) / 16 + 2 = ( S + 38) / 16

上采样 4 倍为[(S + 6) / 32 -1] * 4 + 8 = (S+6)/8 + 4 = (S + 38) / 8

h-up2_feat 是上采样 2 倍的 fc7 与 pool4 相加,即 ( S + 38) / 16 + (S + 198) / 16

只看分母 pool4 相比 h-up2_feat 多160,计算过程是 198-38 = 160 —> 再除分子 160/16 = 10 —> 中心对称裁剪 10 / 2 =5 —> Pool4 前后裁剪5

h-up4_feat:上采样 2 倍 h-up2_feat 与 pool3 相加

上采样2倍的 h-up2_feat : [( S + 38) / 16 - 1] * 2 + 4 = (S+38) / 8 + 2 = (S + 54) / 8

Pool3:(S + 198) / 8

198-54 = 144 --> 144 / 8 = 18 —> 18/2 = 9—> Pool3 前后裁剪 9

最后上采样 8 倍:[(S + 54) / 8 -1] * 8 + 16 = S + 54 + 8 = S + 62 —> 62/2 = 31,与原图S相比(前后裁剪31)。
~

参考文献:

[1]http://home.ustc.edu.cn/~liujunyan/blog/fcn/

[2]Nucleus image segmentation method based on GAN and FCN model

[3]https://blog.csdn.net/m0_56192771/article/details/124113078

[4]https://www.wandouip.com/t5i87567/

你可能感兴趣的:(CV,深度学习,计算机视觉,cnn)