# 语义分割:FCN
物体检测(objective detection)是识别图片里面的主要物体,和找出里面物体的边框。语义分割则在之上更进一步,它对每个像素预测它是否只是背景,还是属于哪个我们感兴趣的物体。
跟物体检测相比,语义分割预测的边框更加精细。
本项目我们将利用卷积神经网络解决语义分割的一个开创性工作之一:[全链接卷积网络](https://arxiv.org/abs/1411.4038)。
## 数据集
[VOC2012](http://host.robots.ox.ac.uk/pascal/VOC/voc2012/)是一个常用的语义分割数据集。输入图片跟之前的数据集类似,但标注也是保存称相应大小的图片来方便查看。下面代码下载这个数据集并解压,可以预先下好放置在`data_root`下。
```{.python .input n=1}
import os
import tarfile
from mxnet import gluon
data_root = '../data'
voc_root = data_root + '/VOCdevkit/VOC2012'
url = ('http://host.robots.ox.ac.uk/pascal/VOC/voc2012'
'/VOCtrainval_11-May-2012.tar')
sha1 = '4e443f8a2eca6b1dac8a6c57641b67dd40621a49'
fname = gluon.utils.download(url, data_root, sha1_hash=sha1)
if not os.path.isfile(voc_root+'/ImageSets/Segmentation/train.txt'):
with tarfile.open(fname, 'r') as f:
f.extractall(data_root)
```
下面定义函数将训练图片和标注按序读进内存。
```{.python .input n=2}
from mxnet import image
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()
n = len(images)
data, label = [None] * n, [None] * n
for i, fname in enumerate(images):
data[i] = image.imread('%s/JPEGImages/%s.jpg' % (
root, fname))
label[i] = image.imread('%s/SegmentationClass/%s.png' % (
root, fname))
return data, label
```
为了能将多张图片合并成一个批量来加速计算,我们需要输入图片都是同样的大小。
这里我们使用剪切来解决这个问题。就是说对于输入图片,我们随机剪切出一个固定大小的区域,然后对标号图片做同样位置的剪切。
```{.python .input n=4}
def rand_crop(data, label, height, width):
data, rect = image.random_crop(data, (width, height))
label = image.fixed_crop(label, *rect)
return data, label
imgs = []
for _ in range(3):
imgs += rand_crop(train_images[0], train_labels[0],
200, 300)
utils.show_images(imgs, nrows=3, ncols=2, figsize=(12,8))
```
接下来我们列出每个物体和背景对应的RGB值
```{.python .input n=5}
classes = ['background','aeroplane','bicycle','bird','boat',
'bottle','bus','car','cat','chair','cow','diningtable',
'dog','horse','motorbike','person','potted plant',
'sheep','sofa','train','tv/monitor']
# RGB color for each class
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]]
len(classes), len(colormap)
```
这样给定一个标号图片,我们就可以将每个像素对应的物体标号找出来。
```{.python .input n=6}
import numpy as np
from mxnet import nd
cm2lbl = np.zeros(256**3)
for i,cm in enumerate(colormap):
cm2lbl[(cm[0]*256+cm[1])*256+cm[2]] = i
def image2label(im):
data = im.astype('int32').asnumpy()
idx = (data[:,:,0]*256+data[:,:,1])*256+data[:,:,2]
return nd.array(cm2lbl[idx])
```
## 数据读取
每一次我们将图片和标注随机剪切到要求的形状,并将标注里每个像素转成对应的标号。简单起见我们将小于要求大小的图片全部过滤掉了。
```{.python .input n=8}
from mxnet import gluon
from mxnet import nd
rgb_mean = nd.array([0.485, 0.456, 0.406])
rgb_std = nd.array([0.229, 0.224, 0.225])
def normalize_image(data):
return (data.astype('float32') / 255 - rgb_mean) / rgb_std
class VOCSegDataset(gluon.data.Dataset):
def _filter(self, images):
return [im for im in images if (
im.shape[0] >= self.crop_size[0] and
im.shape[1] >= self.crop_size[1])]
def __init__(self, train, crop_size):
self.crop_size = crop_size
data, label = read_images(train=train)
data = self._filter(data)
self.data = [normalize_image(im) for im in data]
self.label = self._filter(label)
print('Read '+str(len(self.data))+' examples')
def __getitem__(self, idx):
data, label = rand_crop(
self.data[idx], self.label[idx],
*self.crop_size)
data = data.transpose((2,0,1))
label = image2label(label)
return data, label
def __len__(self):
return len(self.data)
```
我们采用$320\times 480$的大小用来训练。
```{.python .input n=9}
# height x width
input_shape = (320, 480)
voc_train = VOCSegDataset(True, input_shape)
voc_test = VOCSegDataset(False, input_shape)
```
最后定义批量读取。
```{.python .input n=10}
batch_size = 64
train_data = gluon.data.DataLoader(
voc_train, batch_size, shuffle=True,last_batch='discard')
test_data = gluon.data.DataLoader(
voc_test, batch_size,last_batch='discard')
for data, label in train_data:
print(data.shape)
print(label.shape)
break
```
## 全连接卷积网络
下面我们基于Resnet18来创建FCN。首先我们下载一个预先训练好的模型。
```{.python .input n=12}
from mxnet.gluon.model_zoo import vision as models
pretrained_net = models.resnet18_v2(pretrained=True)
(pretrained_net.features[-4:], pretrained_net.output)
```
我们看到`feature`模块最后两层是`GlobalAvgPool2D`和`Flatten`,都是我们不需要的。所以我们定义一个新的网络,它复制除了最后两层的`features`模块的权重。
```{.python .input n=13}
net = nn.HybridSequential()
for layer in pretrained_net.features[:-2]:
net.add(layer)
x = nd.random.uniform(shape=(1,3,*input_shape))
print('Input:', x.shape)
print('Output:', net(x).shape)
```
然后接上一个通道数等于类数的$1\times 1$卷积层。注意到`net`已经将输入长宽减少了32倍。那么我们需要接入一个`strides=32`的卷积转置层。我们使用一个比`stides`大两倍的`kernel`,然后补上适当的填充。
```{.python .input n=14}
num_classes = len(classes)
with net.name_scope():
net.add(
nn.Conv2D(num_classes, kernel_size=1),
nn.Conv2DTranspose(num_classes, kernel_size=64, padding=16,strides=32)
)
```
## 训练
将卷积转置层初始化成双线性差值函数。
```{.python .input n=15}
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 nd.array(weight)
```
所以网络的初始化包括了三部分。主体卷积网络从训练好的ResNet18复制得来,替代ResNet18最后全连接的卷积层使用随机初始化。
最后的卷积转置层则使用双线性差值。对于卷积转置层,我们可以自定义一个初始化类。简单起见,这里我们直接通过权重的`set_data`函数改写权重。
```{.python .input n=110}
from mxnet import init
conv_trans = net[-1]
conv_trans.initialize(init=init.Zero())
net[-2].initialize(init=init.Xavier())
x = nd.zeros((batch_size, 3, *input_shape))
net(x)
shape = conv_trans.weight.data().sha