语义分割(Semantic Segmentation)是计算机视觉领域中的一项重要任务,它旨在将图像或视频中的每个像素点分类到特定的类别中。语义分割是计算机视觉的基础任务之一,图像分类的目的是对一副图像进行整体归类,判断这副图像属于哪一类;更进一步的是目标识别,也就是识别图像中的某一类物体并把物体找出来,不过只要找到大致范围就可以了;再此基础上再进一步的就是语义分割,语义分割的目标也是把图像中的某类物体找出来,不过不仅仅框出范围,而且要把物体的边界精准的识别出来,好比把物体从图像背景中分割出来;再进一步的是实例分割,语义分割是分割出图像中的某类物体,实例分割不光要分割出这类物体,还要把一类物体中的各个实例区分开来,粒度更细,要求也更高。
全卷积网络FCN(fully Convolutional network)是应用深度学习进行语义分割的奠基之作。它定义总体的语义分割结构,后面的Unet,Deeplab系列,PSPnet,包括基于注意力机制的语义分割网络都遵循了编码——解码框架。语义分割在遥感影像以及医学等领域有非常广泛的应用,比如需要对遥感影像进行地物分类,判断哪一类是耕地,哪一类是建成区等等,通过对历史遥感影像的语义分割,发现某类地物的变化,从而制定相应的对策。
FCN模型比较简单,它的核心原理就是用卷积神经网络提取图像的特征,并在舍弃了图像分类网络中的最后一层分类层,替换成了一个1X1卷积,得到要分类的特征图,之后通过上采样恢复成原始图像的大小,从而实现每个像素点的分类,得到分割的结果。如下图所示:
FCN论文(https://arxiv.org/abs/1411.4038)里面给出了三种FCN的设计:
1.最简单的就是得到分类特征图后直接上采样,经过卷积后,特征图变成原始图像的32分之一,直接上采样32倍,恢复成原始图像的大小,得到结果,称为FCN-32s;
2.第二种,就是把特征图上采样到原图的16分之一,再加上原始图像在卷积过程中得到的原图16分之一的特征图,再恢复成原始图像的大小,得到结果,称为FCN-16s;
3.第三种,就是把特征图上采样到原图的16分之一,再加上原始图像在卷积过程中得到的原图16分之一的特征图,然后再上采样2倍,回复到原图的8分之一,此时再加上原始图像在卷积过程中得到的原图8分之一的特征图,最后再恢复成原始图像的大小,得到结果,称为FCN-8s;结构如下图所示:
其实很明显,直接恢复成原始图像大小的模型,效果肯定最差,第三种方法加入了更多的特征图信息,效果最好,所以在实际应用中,我们一般使用FCN-8s。
模型代码如下:
class FCN(nn.Module):
def __init__(self, preTrained=True, n_class=5) -> None:
super(FCN, self).__init__()
self.n_class = n_class
self.preTrained = preTrained
if self.preTrained:
vgg = models.vgg16(pretrained=True)
else:
vgg = models.vgg16(pretrained=False)
features, classifier = list(vgg.features.children()), list(vgg.classifier.children())
num_classes = self.n_class
self.features3 = nn.Sequential(*features[: 17])
self.features4 = nn.Sequential(*features[17: 24])
self.features5 = nn.Sequential(*features[24:])
self.score_pool3 = nn.Conv2d(256, num_classes, kernel_size=1)
self.score_pool4 = nn.Conv2d(512, num_classes, kernel_size=1)
fc6 = nn.Conv2d(512, 4096, kernel_size=3, stride=1, padding=1)
fc7 = nn.Conv2d(4096, 4096, kernel_size=1)
score_fr = nn.Conv2d(4096, num_classes, kernel_size=1)
self.score_fr = nn.Sequential(
fc6, nn.ReLU(inplace=True), nn.Dropout(),
fc7, nn.ReLU(inplace=True), nn.Dropout(),
score_fr
)
self.upscore2 = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=4, stride=2, padding=1, bias=True)
self.upscore4 = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=4, stride=2, padding=1, bias=True)
self.upscore8 = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=16, stride=8, padding=4, bias=True)
def forward(self, x, y=None):
pool3 = self.features3(x)
print("pool3.shape: ", pool3.shape)
pool4 = self.features4(pool3)
print("pool4.shape: ", pool4.shape)
pool5 = self.features5(pool4)
print("pool5.shape: ", pool5.shape)
score_fr = self.score_fr(pool5)
print("score_fr.shape: ", score_fr.shape)
upscore2 = self.upscore2(score_fr)
print("upscore2.shape: ", upscore2.shape)
score_pool4 = self.score_pool4(pool4)
print("score_pool4.shape: ", score_pool4.shape)
upscore4 = self.upscore4(upscore2 + score_pool4)
score_pool3 = self.score_pool3(pool3)
cls_pred = self.upscore8(upscore4 + score_pool3)
print("cls_pred.shape: ", cls_pred.shape)
return cls_pred
if __name__ == "__main__":
net = FCN(True, 6)
x = torch.ones((16, 3, 480, 320))
x = net(x)
print(x.shape)
#输出:
pool3.shape: torch.Size([16, 256, 60, 40])
pool4.shape: torch.Size([16, 512, 30, 20])
pool5.shape: torch.Size([16, 512, 15, 10])
score_fr.shape: torch.Size([16, 6, 15, 10])
upscore2.shape: torch.Size([16, 6, 30, 20])
score_pool4.shape: torch.Size([16, 6, 30, 20])
cls_pred.shape: torch.Size([16, 6, 480, 320])
torch.Size([16, 6, 480, 320])
这里为了特征提取的简便,我没有从头开始写编码器,而是直接把vgg模型的特征提取部分拿过来了。下面说明一下代码的含义:
6~9行:获取vgg模型,如果preTrained=True就选择预训练过的模型,否则就选择没有预训练的模型,一般来说,我们不希望从头开始训练,所以都选择有预训练的模型;
10行:把vgg模型分为特征提取部分和分类部分,我们需要的就是特征提取部分
12~14行:把特征提取器分成三个部分,从第0层到16层称为feature3,从第17层到23层称为feature4,从24层到最后的特征提取层称为feature5。从最后的输出可以看到,feature3输出的通道是256,feature4和feature5的输出通道是512。
15~16行:这两个打分层是将feature3和feature4的输出直接做卷积,将通道变为num_classes,也即是分类数目,为的是上采样过程中,和传过来的分类结果相加,添加上特征提取过程中的信息,也就是我们前面说的FCN-8s的概念。
17~18行:对特征提取器输出的结果再做两次卷积,一次是卷积核为3,填充为1的卷积,步长为,根据卷积计算的公示可以知道,这个运算是不会改变特征图的大小的,接下来的一次卷积是1X1卷积,也不会改变大小。
19行:最后的打分层,也就是通过1X1卷积将特征图的通道数转换成num_classes。
20~24行:将最后两次卷积和打分层结合在一起。
25~27行:三个转置卷积,通过转置卷积实现上采样的操作,通道数不变,都是num_classes,但是特征图不断放大,直到恢复成原始图像的大小。
在forward函数中可以看到,40~44行在上采样过程中不断加入特征提取的信息,最后恢复成原图大小,得到分割的结果。
最后,我们构建一个类别数为6的预训练FCN网络,将一个batch_size=16,通道数为3,长宽为480X320的图像数据集输入到网络中,可以看到最终输出是[16,6,480,320],也就是图像大小不变,但把通道数变成了6。
我在GID数据集上做了测试,得到了不错的效果。GID 数据集(Gaofen Image Dataset)由武汉大学夏桂松团队制作。它包含从中国60多个不同城市获得的150张高分辨率Gaofen-2(GF-2)图像(7200×6800)。这些图像覆盖了超过50,000平方公里的地理区域。 GID中的图像具有高的类内分集以及较低的类间可分离性。分为建成区、农用地、林地、草地、水系与未标记区域,共6类。
我用FCN网络训练GID数据集,经过50个epoch的训练,总体训练精度达到了0.83,测试结果如下:
可以看到,经过50轮的训练后,分割的结果勉强可以接受,虽然很多细节都没有分割出来,因为首先FCN是一个比较简单的模型,特征提取能力不是特别强,其次训练次数也比较少只有50次,如果进一步训练的话,精度还可以再略微上升一些。下次我们可以看一下Unet的效果。总之,FCN是一种非常重要且经典的语义分割模型,提出了一些非常经典的思想,比如编码解码架构,以及把上采样部分结果和特征提取结果相结合的思想。