diff --git a/chapter_computer-vision/fcn.md b/chapter_computer-vision/fcn.md index 6fa6c2e53a2f8ad6bd6a0a83a48812eee9da0eb0..9ef7492f8421faa9344e5009bf48995968028cbf 100644 --- a/chapter_computer-vision/fcn.md +++ b/chapter_computer-vision/fcn.md @@ -1,243 +1,77 @@ -# 语义分割 +# 全连接卷积网络:FCN -我们已经学习了如何识别图片里面的主要物体,和找出里面物体的边框。语义分割则在之上更进一步,它对每个像素预测它是否只是背景,还是属于哪个我们感兴趣的物体。 +在图片分类里,我们通过卷积层和池化层逐渐减少图片高宽最终得到跟预测类别数长的向量。例如用于ImageNet分类的ResNet 18里,我们将高宽为224的输入图片首先减少到高宽7,然后使用全局池化层得到512维输出,最后使用全连接层输出长为1000的预测向量。 -![Semantic Segmentation](../img/segmentation.svg) +但在语义分割里,我们需要对每个像素预测类别,也就是需要输出形状需要是$1000\times 224\times 224$。如果仍然使用全链接层作为输出,那么这一层权重将多达数百GB。本小节我们将介绍利用卷积神经网络解决语义分割的一个开创性工作之一:全链接卷积网络(fully convolutional network,简称FCN)[1]。FCN里将最后的全连接层修改称转置卷积层(transposed convolution)来得到所需大小的输出。 -可以看到,跟物体检测相比,语义分割预测的边框更加精细。 - -也许大家还听到过计算机视觉里的一个常用任务:图片分割。它跟语义分割类似,将每个像素划分的某个类。但它跟语义分割不同的时候,图片分割不需要预测每个类具体对应哪个物体。因此图片分割经常只需要利用像素之间的相似度即可,而语义分割则需要详细的类别标号。这也是为什么称其为**语义**的原因。 - - -本章我们将介绍利用卷积神经网络解决语义分割的一个开创性工作之一:[全链接卷积网络](https://arxiv.org/abs/1411.4038)。在此之前我们先了解用来做语义分割的数据。 - -## 数据集 - -[VOC2012](http://host.robots.ox.ac.uk/pascal/VOC/voc2012/)是一个常用的语义分割数据集。输入图片跟之前的数据集类似,但标注也是保存称相应大小的图片来方便查看。下面代码下载这个数据集并解压。注意到压缩包大小是2GB,可以预先下好放置在`data_root`下。 - -```{.python .input n=1} +```{.python .input n=2} %matplotlib inline import sys sys.path.append('..') import gluonbook as gb -import tarfile -from mxnet import nd, image, gluon, init +from mxnet import gluon, init, nd, image from mxnet.gluon import nn import numpy as np ``` -```{.python .input n=2} -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) +## 转置卷积层 -with tarfile.open(fname, 'r') as f: - f.extractall(data_root) -``` +假设$f$是一个卷积层,给定输入$x$,我们可以计算前向输出$y=f(x)$。在反向求导$z=\frac{\partial\, f(y)}{\partial\,x}$时,我们知道$z$会得到跟$x$一样形状的输出。因为卷积运算的导数的导数是自己本身,我们可以合法定义转置卷积层,记为$g$,为交互了前向和反向求导函数的卷积层。也就是$z=g(y)$。 -下面定义函数将训练图片和标注按序读进内存。 +下面我们构造一个卷积层并打印其输出形状。 ```{.python .input n=3} -def read_images(root=voc_root, train=True): - txt_fname = '%s/ImageSets/Segmentation/%s'%( - root, 'train.txt' if train else 'val.txt') - with open(txt_fname, 'r') as f: - images = f.read().split() - data, label = [None] * len(images), [None] * len(images) - 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 - -train_images, train_labels = read_images() -``` - -我们画出前面三张图片和它们对应的标号。在标号中,白色代表边框黑色代表背景,其他不同的颜色对应不同物体。 - -```{.python .input n=4} -n = 5 -imgs = train_images[0:n] + train_labels[0:n] -gb.show_images(imgs, 2, n); -``` - -同时注意到图片的宽度基本是500,但高度各不一样。为了能将多张图片合并成一个批量来加速计算,我们需要输入图片都是同样的大小。之前我们通过`imresize`来将他们调整成同样的大小。但在语义分割里,我们需要对标注做同样的变化来达到像素级别的匹配。但调整大小将改变像素颜色,使得再将它们映射到物体类别变得困难。 - -这里我们仅仅使用剪切来解决这个问题。就是说对于输入图片,我们随机剪切出一个固定大小的区域,然后对标号图片做同样位置的剪切。 - -```{.python .input n=5} -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(n): - imgs += rand_crop(train_images[0], train_labels[0], 200, 300) -gb.show_images(imgs[::2]+imgs[1::2], 2, n); -``` - -接下来我们列出每个物体和背景对应的RGB值 - -```{.python .input n=6} -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=7} -import numpy as np - -cm2lbl = nd.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 cm2lbl[idx] -``` - -可以看到第一张训练图片的标号里面属于飞机的像素被标记成了1. - -```{.python .input n=8} -y = image2label(train_labels[0]) -y[105:115, 130:140] -``` - -现在我们可以定义数据读取了。每一次我们将图片和标注随机剪切到要求的形状,并将标注里每个像素转成对应的标号。简单起见我们将小于要求大小的图片全部过滤掉了。 - -```{.python .input n=9} -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$的大小用来训练,注意到这个比前面我们使用的$224\times 224$要大上很多。但是同样我们将长宽都定义成了32的整数倍。 - -```{.python .input n=10} -# height x width -input_shape = (320, 480) -voc_train = VOCSegDataset(True, input_shape) -voc_test = VOCSegDataset(False, input_shape) -``` - -最后定义批量读取。可以看到跟之前的不同是批量标号不再是一个向量,而是一个三维数组。 - -```{.python .input n=11} -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') +conv = nn.Conv2D(10, kernel_size=4, padding=1, strides=2) +conv.initialize() -for data, label in train_data: - print(data.shape) - print(label.shape) - break +x = nd.random.uniform(shape=(1,3,64,64)) +y = conv(x) +y.shape ``` -## 全连接卷积网络 - -在数据的处理过程我们看到语义分割跟前面介绍的应用的主要区别在于,预测的标号不再是一个或者几个数字,而是每个像素都需要有标号。在卷积神经网络里,我们通过卷积层和池化层逐渐减少数据长宽但同时增加通道数。例如ResNet18里,我们先将输入长宽减少32倍,由$3\times 224\times 224$的图片转成$512\times 7 \times 7$的输出,应该全局池化层变成$512$长向量,然后最后通过全链接层转成一个长度为$n$的输出向量,这里$n$是类数,既`num_classes`。但在这里,对于输出为$3\times 320 \times 480$的图片,我们需要输出是$n \times 320 \times 480$,就是每个输入像素都需要预测一个长度为$n$的向量。 - -全连接卷积网络(FCN)的提出是基于这样一个观察。假设$f$是一个卷积层,而且$y=f(x)$。那么在反传求导时,$\partial f(y)$会返回一个跟$x$一样形状的输出。卷积是一个对偶函数,就是$\partial^2 f = f$。那么如果我们想得到跟输入一样的输入,那么定义$g = \partial f$,这样$g(f(x))$就能达到我们想要的。 +使用用样的卷积窗、填充和步幅的转置卷积层,我们可以得到跟`x`一样的输出。 -具体来说,我们定义一个卷积转置层(transposed convolutional, 也经常被错误的叫做deconvolutions),它就是将卷积层的`forward`和`backward`函数兑换。 - -下面例子里我们看到使用同样的参数,除了替换输入和输出通道数外,`Conv2DTranspose`可以将`nn.Conv2D`的输出还原其输入大小。 - -```{.python .input n=12} -conv = nn.Conv2D(10, kernel_size=4, padding=1, strides=2) +```{.python .input n=4} conv_trans = nn.Conv2DTranspose(3, kernel_size=4, padding=1, strides=2) - -conv.initialize() conv_trans.initialize() - -x = nd.random.uniform(shape=(1,3,64,64)) -y = conv(x) -print('Input:', x.shape) -print('After conv:', y.shape) -print('After transposed conv', conv_trans(y).shape) +conv_trans(y).shape ``` -另外一点要注意的是,在最后的卷积层我们同样使用平化层(`nn.Flattern`)或者(全局)池化层来使得方便使用之后的全连接层作为输出。但是这样会损害空间信息,而这个对语义分割很重要。一个解决办法是去掉不需要的池化层,并将全连接层替换成$1\times 1$卷基层。 +简单来说,卷积层通常使得输入高宽变小,而转置卷积层则一般用来将高宽增大。 -所以给定一个卷积网络,FCN主要做下面的改动 +## FCN模型 -- 替换全连接层成$1\times 1$卷基 -- 去掉过于损失空间信息的池化层,例如全局池化 -- 最后接上卷积转置层来得到需要大小的输出 -- 为了训练更快,通常权重会初始化称预先训练好的权重 +FCN的核心思想是将一个卷积网络的最后全连接输出层替换成转置卷积层来获取对每个输入像素的预测。具体来说,它去掉了过于损失空间信息的全局池化层,并将最后的全链接层替换成输出通道是原全连接层输出大小的$1\times 1$卷积层,最后接上卷积转置层来得到需要形状的输出。 -![FCN](../img/fcn.svg) +![FCN模型。](../img/fcn.svg) -下面我们基于Resnet18来创建FCN。首先我们下载一个预先训练好的模型。 +下面我们基于ResNet 18来创建FCN。首先我们下载一个预先训练好的模型,并打印其最后的数个神经层。 -```{.python .input n=13} +```{.python .input n=3} pretrained_net = gluon.model_zoo.vision.resnet18_v2(pretrained=True) (pretrained_net.features[-4:], pretrained_net.output) ``` -我们看到`feature`模块最后两层是`GlobalAvgPool2D`和`Flatten`,都是我们不需要的。所以我们定义一个新的网络,它复制除了最后两层的`features`模块的权重。 +可以看到`feature`模块最后两层是`GlobalAvgPool2D`和`Flatten`,在FCN里均不需要,`output`模块里的全链接层也需要舍去。下面我们定义一个新的网络,它复制除了`feature`里除去最后两层的所有神经层以及权重。 -```{.python .input n=14} +```{.python .input n=4} 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) +给定高宽为224的输入,`net`的输出将输入高宽减少了32倍。 + +```{.python .input} +x = nd.random.uniform(shape=(1,3,224,224)) +net(x).shape ``` -然后接上一个通道数等于类数的$1\times 1$卷积层。注意到`net`已经将输入长宽减少了32倍。那么我们需要接入一个`strides=32`的卷积转置层。我们使用一个比`stides`大两倍的`kernel`,然后补上适当的填充。 +为了是的输出跟输入有同样的高宽,我们构建一个步幅为32的转置卷积层,卷积核的窗口高宽设置成步幅的2倍,并补充适当的填充。在转置卷积层之前,我们加上$1\times 1$卷积层来将通道数从512降到标注类别数,对Pascal VOC数据集来说是21。 -```{.python .input n=15} -num_classes = len(classes) +```{.python .input n=5} +num_classes = 21 net.add( nn.Conv2D(num_classes, kernel_size=1), @@ -245,11 +79,11 @@ net.add( ) ``` -## 训练 +## 模型初始化 -训练的时候我们需要初始化新添加的两层。我们可以随机初始化,但实际中发现将卷积转置层初始化成双线性差值函数可以使得训练更容易。 +`net`中的最后两层需要对权重进行初始化,通常我们会使用随机初始化。但新加入的转置卷积层的功能有些类似于将输入调整到更大的尺寸。在图片处理里面,我们可以通过有适当卷积核的卷积运算符来完成这个操作。常用的包括双线性差值核,下面函数构造核权重。 -```{.python .input n=16} +```{.python .input n=6} def bilinear_kernel(in_channels, out_channels, kernel_size): factor = (kernel_size + 1) // 2 if kernel_size % 2 == 1: @@ -264,84 +98,81 @@ def bilinear_kernel(in_channels, out_channels, kernel_size): dtype='float32') weight[range(in_channels), range(out_channels), :, :] = filt return nd.array(weight) - ``` -下面代码演示这样的初始化等价于对图片进行双线性差值放大。 - -```{.python .input n=17} -x = train_images[0] -print('Input', x.shape) -x = x.astype('float32').transpose((2,0,1)).expand_dims(axis=0)/255 +接下来我们构造一个步幅为2的转置卷积层,将其权重初始化成双线性差值核。 -conv_trans = nn.Conv2DTranspose( - 3, in_channels=3, kernel_size=8, padding=2, strides=4) -conv_trans.initialize() -conv_trans(x) -conv_trans.weight.set_data(bilinear_kernel(3, 3, 8)) +```{.python .input n=7} +conv_trans = nn.Conv2DTranspose(3, kernel_size=4, padding=1, strides=2) +conv_trans.initialize(init.Constant(bilinear_kernel(3, 3, 4))) +``` +可以看到这个转置卷积层的前向函数的效果是将输入图片高宽扩大2倍。 +```{.python .input} +img = image.imread('../img/catdog.jpg') +print('Input', img.shape) +x = img.astype('float32').transpose((2,0,1)).expand_dims(axis=0)/255 y = conv_trans(x) y = y[0].clip(0,1).transpose((1,2,0)) print('Output', y.shape) - gb.plt.imshow(y.asnumpy()); ``` -所以网络的初始化包括了三部分。主体卷积网络从训练好的ResNet18复制得来,替代ResNet18最后全连接的卷积层使用随机初始化。 - -最后的卷积转置层则使用双线性差值。对于卷积转置层,我们可以自定义一个初始化类。简单起见,这里我们直接通过权重的`set_data`函数改写权重。记得我们介绍过Gluon使用延后初始化来减少构造网络时需要制定输入大小。所以我们先随意初始化它,计算一次`forward`,然后再改写权重。 +下面对`net`的最后两层进行初始化。其中$1\times 1$卷积层使用Xavier,转置卷积层则使用双线性差值核。 -```{.python .input n=18} -conv_trans = net[-1] -conv_trans.initialize(init=init.Zero()) +```{.python .input n=8} +trans_conv_weights = bilinear_kernel(num_classes, num_classes, 64) +net[-1].initialize(init.Constant(trans_conv_weights)) net[-2].initialize(init=init.Xavier()) - -x = nd.zeros((batch_size, 3, *input_shape)) -net(x) - -shape = conv_trans.weight.data().shape -conv_trans.weight.set_data(bilinear_kernel(*shape[0:3])) - ``` -这时候我们可以真正开始训练了。值得一提的是我们使用卷积转置层的通道来预测像素的类别。所以在做`softmax`和预测的时候我们需要使用通道这个维度,既维度1. 所以在`SoftmaxCrossEntropyLoss`里加入了额外了`axis=1`选项。其他的部分跟之前的训练一致。 +## 训练 -```{.python .input n=19} -loss = gluon.loss.SoftmaxCrossEntropyLoss(axis=1) +这时候我们可以真正开始训练了。我们使用较大的输入图片尺寸,其值选成了32的倍数。因为我们使用转置卷积层的通道来预测像素的类别,所以在做softmax是作用在通道这个维度(维度1),所以在`SoftmaxCrossEntropyLoss`里加入了额外了`axis=1`选项。 +```{.python .input n=9} +input_shape = (320, 480) +batch_size = 32 ctx = gb.try_all_gpus() +loss = gluon.loss.SoftmaxCrossEntropyLoss(axis=1) net.collect_params().reset_ctx(ctx) - trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': .1, 'wd':1e-3}) - +train_data, test_data = gb.load_data_pascal_voc(batch_size, input_shape) gb.train(train_data, test_data, net, loss, trainer, ctx, num_epochs=10) ``` ## 预测 -预测函数跟之前的图片分类预测类似,但跟上面一样,主要不同在于我们需要在`axis=1`上做`argmax`. 同时我们定义`image2label`的反函数,它将预测值转成图片。 +预测一张新图片时,我们只需要将其归一化并转成卷积网络需要的4D格式。 -```{.python .input n=22} +```{.python .input n=57} def predict(im): - data = normalize_image(im) + data = test_data._dataset.normalize_image(im) data = data.transpose((2,0,1)).expand_dims(axis=0) yhat = net(data.as_in_context(ctx[0])) pred = nd.argmax(yhat, axis=1) return pred.reshape((pred.shape[1], pred.shape[2])) +``` + +同时我们根据每个像素预测的类别找出其RGB颜色以便画图。 + +```{.python .input} def label2image(pred): - x = pred.astype('int32').asnumpy() - cm = nd.array(colormap).astype('uint8') - return nd.array(cm[x,:]) + colormap = nd.array( + test_data._dataset.voc_colormap, ctx=ctx[0],dtype='uint8') + x = pred.astype('int32') + return colormap[x,:] ``` -我们读取前几张测试图片并对其进行预测。 +现在我们读取前几张测试图片并对其进行预测。 -```{.python .input n=27} -test_images, test_labels = read_images(train=False) +```{.python .input n=58} +test_images, test_labels = gb.read_voc_images(train=False) +n = 5 imgs = [] for i in range(n): x = test_images[i] @@ -353,15 +184,20 @@ gb.show_images(imgs[::3]+imgs[1::3]+imgs[2::3], 3, n); ## 小结 -* 通过使用卷积转置层,我们可以得到更大分辨率的输出。 +FCN通过使用转置卷积层来为每个像素预测类别。 ## 练习 -* 试着改改最后的卷积转置层的参数设定 -* 看看双线性差值初始化是不是必要的 -* 试着改改训练参数来使得收敛更好些 -* [FCN论文](https://arxiv.org/abs/1411.4038)中提到了不只是使用主体卷积网络输出,还可以将前面层的输出也加进来。试着实现。 +* 试着改改最后的转置卷积层的参数设定。 +* 看看双线性差值初始化是不是必要的。 +* 试着改改训练参数来使得收敛更好些。 +* FCN论文[1]中提到了不只是使用主体卷积网络输出,还可以考虑其中间层的输出。试着实现这个想法。 ## 扫码直达[讨论区](https://discuss.gluon.ai/t/topic/3041) ![](../img/qr_fcn.svg) + + +## 参考文献 + +[1] Long, Jonathan, Evan Shelhamer, and Trevor Darrell. "Fully convolutional networks for semantic segmentation." CVPR. 2015. diff --git a/chapter_computer-vision/index.md b/chapter_computer-vision/index.md index 1f2d2bf03db24d2951b5a9420cd1092198088dcf..3afeee9a5b36929fce98c1e80aaa56e5d14afbd9 100644 --- a/chapter_computer-vision/index.md +++ b/chapter_computer-vision/index.md @@ -9,10 +9,11 @@ image-augmentation fine-tuning bounding-box - pikachu anchor + pikachu ssd rcnn + voc fcn neural-style kaggle-gluon-cifar10 diff --git a/chapter_computer-vision/voc.md b/chapter_computer-vision/voc.md new file mode 100644 index 0000000000000000000000000000000000000000..c80c4b778397c259588e3da305e5adf5daea96bd --- /dev/null +++ b/chapter_computer-vision/voc.md @@ -0,0 +1,181 @@ +# 语义分割和数据集 + +图片分类关心识别图片里面的主要物体,物体识别则进一步找出图片的多个物体以及它们的方形边界框。本小节我们将介绍语义分割(semantic segmentation),它在物体识别上更进一步的找出物体的精确边界框。换句话说,它识别图片中的每个像素属于哪类我们感兴趣的物体还是只是背景。下图演示猫和狗图片在语义分割中的标注。可以看到,跟物体识别相比,语义分割预测的边框更加精细。 + +![语义分割的训练数据和标注。](../img/segmentation.svg) + +在计算机视觉里,还有两个跟语义分割相似的任务。一个是图片分割(image segmentation),它也是将像素划分到不同的类。不同的是,语义分割里我们赋予像素语义信息,例如属于猫、狗或者背景。而图片分割则通常根据像素本身之间的相似性,它训练时不需要像素标注信息,其预测结果也不能保证有语义性。例如图片分割可能将上图中的狗划分成两个区域,其中一个嘴巴和眼睛,其颜色以黑色为主,另一个是身体其余部分,其主色调是黄色。 + +另一个应用是实例分割(instance segementation),它不仅需要知道每个像素的语义,即属于那一类物体,还需要进一步区分物体实例。例如如果图片中有两只狗,那么对于预测为对应狗的像素是属于地一只狗还是第二只。 + +## Pascal VOC语义分割数据集 + +下面我们使用一个常用的语义分割数据集 +[Pascal VOC2012](http://host.robots.ox.ac.uk/pascal/VOC/voc2012/)来介绍这个应用。 + +```{.python .input n=1} +%matplotlib inline +import sys +sys.path.append('..') +import gluonbook as gb +import tarfile +from mxnet import nd, image, gluon +``` + +我们首先下载这个数据集到`../data`下。压缩包大小是2GB,下载需要一定时间。解压之后这个数据集将会放置在`../data/VOCdevkit/VOC2012`下。 + +```{.python .input n=2} +data_dir = '../data' +voc_dir = data_dir + '/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_dir, sha1_hash=sha1) + +with tarfile.open(fname, 'r') as f: + f.extractall(data_dir) +``` + +在`ImageSets/Segmentation`下有文本文件指定哪些样本用来训练,哪些用来测试。样本图片放置在`JPEGImages`下,标注则放在`SegmentationClass`下。这里标注也是图片格式,图片大小与对应的样本图片一致,其中颜色相同的像素属于同一个类。 + +下面定义函数将图片和标注全部读进内存。 + +```{.python .input n=3} +def read_voc_images(root=voc_dir, train=True): + txt_fname = '%s/ImageSets/Segmentation/%s'%( + root, 'train.txt' if train else 'val.txt') + with open(txt_fname, 'r') as f: + images = f.read().split() + data, label = [None] * len(images), [None] * len(images) + 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 + +train_images, train_labels = read_voc_images() +``` + +我们画出前面五张图片和它们对应的标注。在标注,白色代表边框黑色代表背景,其他不同的颜色对应不同物体类别。 + +```{.python .input n=4} +n = 5 +imgs = train_images[0:n] + train_labels[0:n] +gb.show_images(imgs, 2, n); +``` + +接下来我们列出标注中每个RGB颜色值对应的类别。 + +```{.python .input n=5} +voc_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]] + +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'] +``` + +这样给定一个标号图片,我们就可以将每个像素对应的物体标号找出来。 + +```{.python .input n=6} +colormap2label = nd.zeros(256**3) +for i, cm in enumerate(voc_colormap): + colormap2label[(cm[0]*256+cm[1]) * 256 + cm[2]] = i + +def voc_label_indices(img): + data = img.astype('int32') + idx = (data[:,:,0]*256+data[:,:,1])*256+data[:,:,2] + return colormap2label[idx] +``` + +可以看到第一张样本中飞机头部对应的标注里属于飞机的像素被标记成了1。 + +```{.python .input n=7} +y = voc_label_indices(train_labels[0]) +y[105:115, 130:140] +``` + +### 数据预处理 + +我们知道小批量训练需要输入图片的形状一致。之前我们通过图片缩放来得到同样形状的输入。但语义分割里,如果对样本图片进行缩放,那么重新映射每个像素对应的类别将变得困难,特别是对应物体边缘的像素。 + +为了避免这个困难,这里我们将图片剪裁成固定大小而不是缩放。特别的,我们使用随机剪裁来附加图片增广。下面定义随机剪裁函数,其对样本图片和标注使用用样的剪裁。 + +```{.python .input n=8} +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(n): + imgs += rand_crop(train_images[0], train_labels[0], 200, 300) +gb.show_images(imgs[::2]+imgs[1::2], 2, n); +``` + +### 数据读取 + +下面我们定义Gluon可以使用的数据集类,它可以返回任意的第$i$个样本图片和标号。除了随机剪裁外,这里我们将样本图片进行了归一化,同时过滤了小于剪裁尺寸的图片。 + +```{.python .input n=9} +class VOCSegDataset(gluon.data.Dataset): + def __init__(self, train, crop_size): + self.rgb_mean = nd.array([0.485, 0.456, 0.406]) + self.rgb_std = nd.array([0.229, 0.224, 0.225]) + self.crop_size = crop_size + data, label = read_voc_images(train=train) + self.data = [self.normalize_image(im) for im in self.filter(data)] + self.label = self.filter(label) + print('Read '+str(len(self.data))+' examples') + + def normalize_image(self, data): + return (data.astype('float32') / 255 - self.rgb_mean) / self.rgb_std + + 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 __getitem__(self, idx): + data, label = rand_crop(self.data[idx], self.label[idx], + *self.crop_size) + return data.transpose((2,0,1)), voc_label_indices(label) + + def __len__(self): + return len(self.data) +``` + +假设我们剪裁$320\times 480$图片来进行训练,我们可以查看训练和测试各保留了多少图片。 + +```{.python .input n=10} +output_shape = (320, 480) # 高和宽 +voc_train = VOCSegDataset(True, output_shape) +voc_test = VOCSegDataset(False, output_shape) +``` + +最后定义批量读取,这里使用4个进程来加速读取(代码保存在gluonbook的`load_data_pascal_voc`函数里方便之后使用)。 + +```{.python .input n=11} +batch_size = 64 +train_data = gluon.data.DataLoader( + voc_train, batch_size, shuffle=True,last_batch='discard', num_workers=4) +test_data = gluon.data.DataLoader( + voc_test, batch_size,last_batch='discard', num_workers=4) +``` + +打印第一个批量可以看到,不同于图片分类和物体识别,这里的标注是一个三维的数组。 + +```{.python .input n=12} +for data, label in train_data: + print(data.shape) + print(label.shape) + break +``` + +## 小节 + +## 练习 diff --git a/gluonbook/utils.py b/gluonbook/utils.py index 96874f89cdef708f6f757183e91680aa4744a366..296dcb6087c5ba53e2722f5ad03abefbf00d985e 100644 --- a/gluonbook/utils.py +++ b/gluonbook/utils.py @@ -1,4 +1,6 @@ import random +import os +import tarfile from time import time from IPython.display import set_matplotlib_formats @@ -487,3 +489,97 @@ def load_data_pikachu(batch_size, edge_size=256): data_shape=(3, edge_size, edge_size), shuffle=False) return (train_data, val_data) + +def _download_voc_pascal(data_dir='../data'): + voc_dir = data_dir + '/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_dir, sha1_hash=sha1) + if not os.path.exists(voc_dir+'/ImageSets/Segmentation/train.txt'): + with tarfile.open(fname, 'r') as f: + f.extractall(data_dir) + return voc_dir + +def read_voc_images(root='../data/VOCdevkit/VOC2012', train=True): + txt_fname = '%s/ImageSets/Segmentation/%s'%( + root, 'train.txt' if train else 'val.txt') + with open(txt_fname, 'r') as f: + images = f.read().split() + data, label = [None] * len(images), [None] * len(images) + 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 + + +voc_rgb_mean = nd.array([0.485, 0.456, 0.406]) +voc_rgb_std = nd.array([0.229, 0.224, 0.225]) + +def normalize_voc_image(data): + return (data.astype('float32') / 255 - voc_rgb_mean) / voc_rgb_std + +class VOCSegDataset(gluon.data.Dataset): + def __init__(self, train, crop_size): + self.train = train + self.crop_size = crop_size + self.rgb_mean = nd.array([0.485, 0.456, 0.406]) + self.rgb_std = nd.array([0.229, 0.224, 0.225]) + self.voc_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]] + self.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'] + self.colormap2label = None + self.load_images() + + def voc_label_indices(self, img): + if self.colormap2label is None: + self.colormap2label = nd.zeros(256**3) + for i, cm in enumerate(self.voc_colormap): + self.colormap2label[(cm[0]*256+cm[1]) * 256 + cm[2]] = i + data = img.astype('int32') + idx = (data[:,:,0]*256+data[:,:,1])*256+data[:,:,2] + return self.colormap2label[idx] + + def rand_crop(self, data, label, height, width): + data, rect = image.random_crop(data, (width, height)) + label = image.fixed_crop(label, *rect) + return data, label + + def load_images(self): + voc_dir = _download_voc_pascal() + data, label = read_voc_images(root=voc_dir, train=self.train) + self.data = [self.normalize_image(im) for im in self.filter(data)] + self.label = self.filter(label) + print('Read '+str(len(self.data))+' examples') + + def normalize_image(self, data): + return (data.astype('float32') / 255 - self.rgb_mean) / self.rgb_std + + 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 __getitem__(self, idx): + data, label = self.rand_crop(self.data[idx], self.label[idx], + *self.crop_size) + return data.transpose((2,0,1)), self.voc_label_indices(label) + + def __len__(self): + return len(self.data) + +def load_data_pascal_voc(batch_size, output_shape): + voc_train = VOCSegDataset(True, output_shape) + voc_test = VOCSegDataset(False, output_shape) + + train_data = gluon.data.DataLoader( + voc_train, batch_size, shuffle=True,last_batch='discard', num_workers=4) + test_data = gluon.data.DataLoader( + voc_test, batch_size,last_batch='discard', num_workers=4) + return train_data, test_data diff --git a/img/fcn.svg b/img/fcn.svg index 95b0336e5e03d59b569f3caf7e4d78740c60e93d..3330094981ccd79dc3e7b32c51aa6f06d9231746 100644 --- a/img/fcn.svg +++ b/img/fcn.svg @@ -1,5 +1,5 @@ - + @@ -42,31 +42,31 @@ - + - + - + - + - + - + - + - + - + @@ -77,6 +77,12 @@ + + + + + + @@ -88,22 +94,22 @@ - + - + - - + + - + - - + + @@ -118,79 +124,93 @@ - + - - - - + + + + - + - - - - + + + + - + - - - - + + + + - + - - - - - + + + + + + + + + - - + + - - - - - - - - + + + + + + + + - - - - - - - - - - + + + + + + + + + + - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + +