主要涵盖如下内容:
-
卷积神经网络:卷积神经网络(Convolutional Neural Networks, CNN)是计算机视觉技术最经典的模型结构。这里主要介绍卷积神经网络的常用模块,包括:卷积、池化等。
-
图像分类:介绍图像分类算法的经典模型结构,并通过眼疾筛查的案例展示算法的应用。
-
目标检测:介绍目标检测YOLO-V3算法,并通过林业病虫害数据集中的虫子检测任务案例展示YOLO-V3算法的应用。
如何让计算机也能像人一样看懂周围的世界呢?研究者尝试着从不同的角度去解决这个问题,由此也发展出一系列的子任务,如 下图 所示。
(a) Image Classification: 图像分类,用于识别图像中物体的类别(如:bottle、cup、cube)
(b) Object Localization: 目标检测,用于检测图像中每个物体的类别,并准确标出它们的位置。
(c) Semantic Segmentation: 图像语义分割,用于标出图像中每个像素点所属的类别,属于同一类别的像素点用一个颜色标识。
(d) Instance Segmentation: 实例分割,值得注意的是,(b)中的目标检测任务只需要标注出物体位置,而(d)中的实例分割任务不仅要标注出物体位置,还需要标注出物体的外形轮廓。
卷积神经网络
卷积神经网络是目前计算机视觉中使用最普遍的模型结构。本章节主要为读者介绍卷积神经网络的一些基础模块,包括:
- 卷积(Convolution)
- 池化(Pooling)
- ReLU激活函数
- 批归一化(Batch Normalization)
- 丢弃法(Dropout)
在前面介绍的手写数字识别任务,应用的是全连接层的特征提取,即将一张图片上的所有像素点展开成一个1维向量输入网络,存在如下两个问题:
1. 输入数据的空间信息被丢失。 空间上相邻的像素点往往具有相似的RGB值,RGB的各个通道之间的数据通常密切相关,但是转化成1维向量时,这些信息被丢失。同时,图像数据的形状信息中,可能隐藏着某种本质的模式,但是转变成1维向量输入全连接神经网络时,这些模式也会被忽略。
2. 模型参数过多,容易发生过拟合。 在手写数字识别案例中,每个像素点都要跟所有输出的神经元相连接。当图片尺寸变大时,输入神经元的个数会按图片尺寸的平方增大,导致模型参数过多,容易发生过拟合。
为了解决上述问题,我们引入卷积神经网络进行特征提取,既能提取到像相邻素点之间的特征模式,又能保证参数的个数不随图片尺寸变化。图3 是一个典型的卷积神经网络结构,多层卷积和池化层组合作用在输入图片上,在网络的最后通常会加入一系列全连接层,ReLU激活函数一般加在卷积或者全连接层的输出上,网络中通常还会加入Dropout来防止过拟合。
下图为卷积神经网络典型结构
在卷积神经网络中,计算范围是在像素点的空间邻域内进行的,卷积核参数的数目也远小于全连接层。卷积核本身与输入图片大小无关,它代表了对空间临域内某种特征模式的提取。比如,有些卷积核提取物体边缘特征,有些卷积核提取物体拐角处的特征,图像上不同区域共享同一个卷积核。当输入图片大小不一样时,仍然可以使用同一个卷积核进行操作。
卷积(Convolution)
下面介绍卷积算法的原理和实现方案,并通过具体的案例展示如何使用卷积对图片进行操作,主要涵盖如下内容:
-
卷积计算
-
填充(padding)
-
步幅(stride)
-
感受野(Receptive Field)
-
多输入通道、多输出通道和批量操作
-
飞桨卷积API介绍
-
卷积算子应用举例
卷积计算
卷积是数学分析中的一种积分变化的方法,在图像处理中采用的是卷积的离散形式。这里需要说明的是,在卷积神经网络中,卷积层的实现方式实际上是数学中定义的互相关 (cross-correlation)运算,与数学分析中的卷积定义有所不同,这里跟其他框架和卷积神经网络的教程保持一致,都使用互相关运算作为卷积的定义,具体的计算过程如下图所示。
说明:
卷积核(kernel)也被叫做滤波器(filter),假设卷积核的高和宽分别为kh和kw,则将称为kh×kw卷积,比如3×5卷积,就是指卷积核的高为3, 宽为5。
计算过程如上图所示。
填充(padding)
在上面的例子中,输入图片尺寸为3×3,输出图片尺寸为2×2,经过一次卷积之后,图片尺寸变小。卷积输出特征图的尺寸计算方法如下:
Hout=H−kh+1
Wout=W−wh+1
如果输入尺寸为4,卷积核大小为3时,输出尺寸为4−3+1=2。通过多次计算我们发现,当卷积核尺寸大于1时,输出特征图的尺寸会小于输入图片尺寸。说明经过多次卷积之后尺寸会不断减小。为了避免卷积之后图片尺寸变小,通常会在图片的外围进行填充(padding),如下图所示。
如果在图片高度方向,在第一行之前填充ph1行,在最后一行之后填充ph2行;在图片的宽度方向,在第1列之前填充pw1列,在最后1列之后填充pw2列;则填充之后的图片尺寸为(H+ph1+ph2)×(W+pw1+pw2)。经过大小为kh×kw的卷积核操作之后,输出图片的尺寸为:
Hout=H+ph1+ph2−kh+1
Wout=W+pw1+pw2−kw+1
在卷积计算过程中,通常会在高度或者宽度的两侧采取等量填充,即ph1=ph2=ph, pw1=pw2=pw,上面计算公式也就变为:
Hout=H+2ph−kh+1
Wout=W+2pw−kw+1
卷积核大小通常使用1,3,5,7这样的奇数,如果使用的填充大小为ph=(kh−1)/2,pw=(kw−1)/2,则卷积之后图像尺寸不变。例如当卷积核大小为3时,padding大小为1,卷积之后图像尺寸不变;同理,如果卷积核大小为5,使用padding的大小为2,也能保持图像尺寸不变。
如何实现卷积之后图像大小不变,可以参考上面公式。
步幅(stride)
上图中卷积核每次滑动一个像素点,这是步幅为1的特殊情况。下图是步幅为2的卷积过程,卷积核在图片上移动时,每次移动大小为2个像素点。
当宽和高方向的步幅分别为sh和sw时,输出特征图尺寸的计算公式是:
Hout = (H+2ph−kh) / sh + 1
Wout = (W+2pw−kw) / sw + 1
假设输入图片尺寸是H×W=100×100,卷积核大小kh×kw=3×3,填充ph=pw=1,步幅为sh=sw=2,则输出特征图的尺寸为:
Hout=(100+2−3) / 2 +1=50
Wout=(100+2−3) / 2+1=50
感受野(Receptive Field)
输出特征图上每个点的数值,是由输入图片上大小为kh×kw的区域的元素与卷积核每个元素相乘再相加得到的,所以输入图像上kh×kw区域内每个元素数值的改变,都会影响输出点的像素值。我们将这个区域叫做输出特征图上对应点的感受野。感受野内每个元素数值的变动,都会影响输出点的数值变化。比如3×3卷积对应的感受野大小就是3×3。
多输入通道、多输出通道和批量操作
前面介绍的卷积计算过程比较简单,实际应用时,处理的问题要复杂的多。例如:对于彩色图片有RGB三个通道,需要处理多输入通道的场景。输出特征图往往也会具有多个通道,而且在神经网络的计算中常常是把一个批次的样本放在一起计算,所以卷积算子需要具有批量处理多输入和多输出通道数据的功能,下面将分别介绍这几种场景的操作方式。
- 多输入通道场景
上面的例子中,卷积层的数据是一个2维数组,但实际上一张图片往往含有RGB三个通道,要计算卷积的输出结果,卷积核的形式也会发生变化。假设输入图片的通道数为Cin,输入数据的形状是Cin×Hin×Win,计算过程如下图所示。
-
对每个通道分别设计一个2维数组作为卷积核,卷积核数组的形状是Cin×kh×kw。
-
对任一通道cin∈[0,Cin),分别用大小为kh×kw的卷积核在大小为Hin×Win的二维数组上做卷积。
-
将这Cin个通道的计算结果相加,得到的是一个形状为Hout×Wout的二维数组。此时,多层堆叠的图像变成一个二维的平面图像了。
- 多输出通道场景
一般来说,卷积操作的输出特征图也会具有多个通道Cout,这时我们需要设计Cout个维度为Cin×kh×kw的卷积核,卷积核数组的维度是Cout×Cin×kh×kw,如下图所示。
- 对任一输出通道cout∈[0,Cout),分别使用上面描述的形状为Cin×kh×kw的卷积核对输入图片做卷积。
- 将这Cout个形状为Hout×Wout的二维数组拼接在一起,形成维度为Cout×Hout×Wout的三维数组。
- 批量操作
在卷积神经网络的计算中,通常将多个样本放在一起形成一个mini-batch进行批量操作,即输入数据的维度是N×Cin×Hin×Win。由于会对每张图片使用同样的卷积核进行卷积操作,卷积核的维度与上面多输出通道的情况一样,仍然是Cout×Cin×kh×kw,输出特征图的维度是N×Cout×Hout×Wout,如下图 所示。
飞桨卷积API介绍
飞桨卷积算子对应的API是paddle.fluid.dygraph.nn.Conv2D,用户可以直接调用API进行计算,也可以在此基础上修改。常用的参数如下:
- name_scope, 卷积层的名字,数据类型是字符串,可以是"conv1"或者"conv2"等形式。
- num_filters, 输出通道数目,相当于上文中的Cout。
- filter_size, 卷积核大小,可以是整数,比如3;或者是两个整数的list,例如[3, 3]。
- stride, 步幅,可以是整数,比如2;或者是两个整数的list,例如[2, 2]。
- padding, 填充大小,可以是整数,比如1;或者是两个整数的list,例如[1, 1]。
- act, 激活函数,卷积操作完成之后使用此激活函数作用在神经元上。
输入数据维度[N,Cin,Hin,Win],输出数据维度[N,num_filters,Hout,Wout],权重参数w的维度[num_filters,Cin,filter_size_h,filter_size_w],偏置参数b的维度是[num_filters]。
权重w和偏执b与filter有很大关系。
卷积算子应用举例
下面介绍卷积算子在图片中应用的三个案例,并观察其计算结果。
案例1——简单的黑白边界检测
下面是使用Conv2D算子完成一个图像边界检测的任务。图像左边为光亮部分,右边为黑暗部分,需要检测出光亮跟黑暗的分界处。 可以设置宽度方向的卷积核为[1,0,−1],此卷积核会将宽度方向间隔为1的两个像素点的数值相减。当卷积核在图片上滑动的时候,如果它所覆盖的像素点位于亮度相同的区域,则左右间隔为1的两个像素点数值的差为0。只有当卷积核覆盖的像素点有的处于光亮区域,有的处在黑暗区域时,左右间隔为1的两个点像素值的差才不为0。将此卷积核作用到图片上,输出特征图上只有对应黑白分界线的地方像素值才不为0。具体代码如下所示,结果输出在下方的图案中。
1 import matplotlib.pyplot as plt 2 3 import numpy as np 4 import paddle 5 import paddle.fluid as fluid 6 from paddle.fluid.dygraph.nn import Conv2D 7 from paddle.fluid.initializer import NumpyArrayInitializer 8 %matplotlib inline 9 10 with fluid.dygraph.guard(): 11 # 创建初始化权重参数w 12 w = np.array([1, 0, -1], dtype='float32') 13 # 将权重参数调整成维度为[cout, cin, kh, kw]的四维张量 14 w = w.reshape([1, 1, 1, 3]) 15 # 创建卷积算子,设置输出通道数,卷积核大小,和初始化权重参数 16 # filter_size = [1, 3]表示kh = 1, kw=3 17 # 创建卷积算子的时候,通过参数属性param_attr,指定参数初始化方式 18 # 这里的初始化方式时,从numpy.ndarray初始化卷积参数 19 conv = Conv2D('conv', num_filters=1, filter_size=[1, 3], 20 param_attr=fluid.ParamAttr( 21 initializer=NumpyArrayInitializer(value=w))) 22 23 # 创建输入图片,图片左边的像素点取值为1,右边的像素点取值为0 24 img = np.ones([50,50], dtype='float32') 25 img[:, 30:] = 0. 26 # 将图片形状调整为[N, C, H, W]的形式 27 x = img.reshape([1,1,50,50]) 28 # 将numpy.ndarray转化成paddle中的tensor 29 x = fluid.dygraph.to_variable(x) 30 # 使用卷积算子作用在输入图片上 31 y = conv(x) 32 # 将输出tensor转化为numpy.ndarray 33 out = y.numpy() 34 35 f = plt.subplot(121) 36 f.set_title('input image', fontsize=15) 37 plt.imshow(img, cmap='gray') 38 39 f = plt.subplot(122) 40 f.set_title('output featuremap', fontsize=15) 41 # 卷积算子Conv2D输出数据形状为[N, C, H, W]形式 42 # 此处N, C=1,输出数据形状为[1, 1, H, W],是4维数组 43 # 但是画图函数plt.imshow画灰度图时,只接受2维数组 44 # 通过numpy.squeeze函数将大小为1的维度消除 45 plt.imshow(out.squeeze(), cmap='gray') 46 plt.show()
1 # 查看卷积层的参数 2 with fluid.dygraph.guard(): 3 # 通过 conv.parameters()查看卷积层的参数,返回值是list,包含两个元素 4 print(conv.parameters()) 5 # 查看卷积层的权重参数名字和数值 6 print(conv.parameters()[0].name, conv.parameters()[0].numpy()) 7 # 参看卷积层的偏置参数名字和数值 8 print(conv.parameters()[1].name, conv.parameters()[1].numpy())
[name conv/Conv2D_0.w_0, dtype: VarType.FP32 shape: [1L, 1L, 1L, 3L] lod: {} dim: 1, 1, 1, 3 layout: NCHW dtype: float data: [1 0 -1] , name conv/Conv2D_0.b_0, dtype: VarType.FP32 shape: [1L] lod: {} dim: 1 layout: NCHW dtype: float data: [0] ] (u'conv/Conv2D_0.w_0', array([[[[ 1., 0., -1.]]]], dtype=float32)) (u'conv/Conv2D_0.b_0', array([0.], dtype=float32))
案例2——图像中物体边缘检测
上面展示的是一个人为构造出来的简单图片使用卷积检测明暗分界处的例子,对于真实的图片,也可以使用合适的卷积核对它进行操作,用来检测物体的外形轮廓,观察输出特征图跟原图之间的对应关系,如下代码所示:
1 import matplotlib.pyplot as plt 2 from PIL import Image 3 import numpy as np 4 import paddle 5 import paddle.fluid as fluid 6 from paddle.fluid.dygraph.nn import Conv2D 7 from paddle.fluid.initializer import NumpyArrayInitializer 8 9 img = Image.open('./work/images/section1/000000098520.jpg') 10 with fluid.dygraph.guard(): 11 # 设置卷积核参数 12 w = np.array([[-1,-1,-1], [-1,8,-1], [-1,-1,-1]], dtype='float32')/8 13 w = w.reshape([1, 1, 3, 3]) 14 # 由于输入通道数是3,将卷积核的形状从[1,1,3,3]调整为[1,3,3,3] 15 w = np.repeat(w, 3, axis=1) 16 # 创建卷积算子,输出通道数为1,卷积核大小为3x3, 17 # 并使用上面的设置好的数值作为卷积核权重的初始化参数 18 conv = Conv2D('conv', num_filters=1, filter_size=[3, 3], 19 param_attr=fluid.ParamAttr( 20 initializer=NumpyArrayInitializer(value=w))) 21 22 # 将读入的图片转化为float32类型的numpy.ndarray 23 x = np.array(img).astype('float32') 24 # 图片读入成ndarry时,形状是[H, W, 3], 25 # 将通道这一维度调整到最前面 26 x = np.transpose(x, (2,0,1)) 27 # 将数据形状调整为[N, C, H, W]格式 28 x = x.reshape(1, 3, img.height, img.width) 29 x = fluid.dygraph.to_variable(x) 30 y = conv(x) 31 out = y.numpy() 32 33 plt.figure(figsize=(20, 10)) 34 f = plt.subplot(121) 35 f.set_title('input image', fontsize=15) 36 plt.imshow(img) 37 f = plt.subplot(122) 38 f.set_title('output feature map', fontsize=15) 39 plt.imshow(out.squeeze(), cmap='gray') 40 plt.show()
对于三次w的值,打印如下:
1 # 设置卷积核参数 2 w = np.array([[-1,-1,-1], [-1,8,-1], [-1,-1,-1]], dtype='float32')/8 3 print('first time:',w) 4 w = w.reshape([1, 1, 3, 3]) 5 print('second time:',w) 6 # 由于输入通道数是3,将卷积核的形状从[1,1,3,3]调整为[1,3,3,3] 7 w = np.repeat(w, 3, axis=1) 8 print('third time:',w)
('first time:', array([[-0.125, -0.125, -0.125], [-0.125, 1. , -0.125], [-0.125, -0.125, -0.125]], dtype=float32)) ('second time:', array([[[[-0.125, -0.125, -0.125], [-0.125, 1. , -0.125], [-0.125, -0.125, -0.125]]]], dtype=float32)) ('third time:', array([[[[-0.125, -0.125, -0.125], [-0.125, 1. , -0.125], [-0.125, -0.125, -0.125]], [[-0.125, -0.125, -0.125], [-0.125, 1. , -0.125], [-0.125, -0.125, -0.125]], [[-0.125, -0.125, -0.125], [-0.125, 1. , -0.125], [-0.125, -0.125, -0.125]]]], dtype=float32))
案例3——图像均值模糊
另外一种比较常见的卷积核是用当前像素跟它邻域内的像素取平均,这样可以使图像上噪声比较大的点变得更平滑,如下代码所示:
1 import matplotlib.pyplot as plt 2 3 from PIL import Image 4 5 import numpy as np 6 import paddle 7 import paddle.fluid as fluid 8 from paddle.fluid.dygraph.nn import Conv2D 9 from paddle.fluid.initializer import NumpyArrayInitializer 10 11 # 读入图片并转成numpy.ndarray 12 #img = Image.open('./images/section1/000000001584.jpg') 13 img = Image.open('./work/images/section1/000000355610.jpg').convert('L') 14 img = np.array(img) 15 16 # 换成灰度图 17 18 with fluid.dygraph.guard(): 19 # 创建初始化参数 20 w = np.ones([1, 1, 5, 5], dtype = 'float32')/25 21 conv = Conv2D('conv', num_filters=1, filter_size=[5, 5], 22 param_attr=fluid.ParamAttr( 23 initializer=NumpyArrayInitializer(value=w))) 24 25 x = img.astype('float32') 26 x = x.reshape(1,1,img.shape[0], img.shape[1]) 27 x = fluid.dygraph.to_variable(x) 28 y = conv(x) 29 out = y.numpy() 30 31 plt.figure(figsize=(20, 12)) 32 f = plt.subplot(121) 33 f.set_title('input image') 34 plt.imshow(img, cmap='gray') 35 36 f = plt.subplot(122) 37 f.set_title('output feature map') 38 out = out.squeeze() 39 plt.imshow(out, cmap='gray') 40 41 plt.show()
池化(Pooling)
池化是使用某一位置的相邻输出的总体统计特征代替网络在该位置的输出,其好处是当输入数据做出少量平移时,经过池化函数后的大多数输出还能保持不变。比如:当识别一张图像是否是人脸时,我们需要知道人脸左边有一只眼睛,右边也有一只眼睛,而不需要知道眼睛的精确位置,这时候通过约化某一片区域的像素点来得到总体统计特征会显得很有用。由于池化之后特征图会变得更小,如果后面连接的是全连接层,能有效的减小神经元的个数,节省存储空间并提高计算效率。 如 下图所示,将一个2×2的区域池化成一个像素点。通常有两种方法,平均池化和最大池化。
与卷积核类似,池化窗口在图片上滑动时,每次移动的步长称为步幅,当宽和高方向的移动大小不一样时,分别用sh和sw表示。也可以对需要进行池化的图片进行填充,填充方式与卷积类似,假设在第一行之前填充ph1行,在最后一行后面填充ph2行。在第一列之前填充pw1p_{w1}pw1列,在最后一列之后填充pw2p_{w2}pw2列,则池化层的输出特征图大小为:
Hout=(H+ph1+ph2−kh)/ sh+1
Wout=(W+pw1+pw2−kw)/ sw+1
在卷积神经网络中,通常使用2×2大小的池化窗口,步幅也使用2,填充为0,则输出特征图的尺寸为:
Hout=H/2
Wout=W/2
通过这种方式的池化,输出特征图的高和宽都减半,但通道数不会改变。
ReLU激活函数
前面介绍的网络结构中,普遍使用Sigmoid函数做激活函数。在神经网络发展的早期,Sigmoid函数用的比较多,而目前用的较多的激活函数是ReLU。这是因为Sigmoid函数在反向传播过程中,容易造成梯度的衰减。让我们仔细观察Sigmoid函数的形式,就能发现这一问题。
Sigmoid激活函数定义如下:
y=1 /(1+e(−x))
ReLU激活函数的定义如下:
y=0,(x<0);x,(x≥0)
下面的程序画出了Sigmoid和ReLU函数的曲线图:
1 # ReLU和Sigmoid激活函数示意图 2 import numpy as np 3 import matplotlib.pyplot as plt 4 import matplotlib.patches as patches 5 6 plt.figure(figsize=(10, 5)) 7 8 # 创建数据x 9 x = np.arange(-10, 10, 0.1) 10 11 # 计算Sigmoid函数 12 s = 1.0 / (1 + np.exp(0. - x)) 13 14 # 计算ReLU函数 15 y = np.clip(x, a_min=0., a_max=None) 16 17 ##################################### 18 # 以下部分为画图代码 19 f = plt.subplot(121) 20 plt.plot(x, s, color='r') 21 currentAxis=plt.gca() 22 plt.text(-9.0, 0.9, r'$y=Sigmoid(x)$', fontsize=13) 23 currentAxis.xaxis.set_label_text('x', fontsize=15) 24 currentAxis.yaxis.set_label_text('y', fontsize=15) 25 26 f = plt.subplot(122) 27 plt.plot(x, y, color='g') 28 plt.text(-3.0, 9, r'$y=ReLU(x)$', fontsize=13) 29 currentAxis=plt.gca() 30 currentAxis.xaxis.set_label_text('x', fontsize=15) 31 currentAxis.yaxis.set_label_text('y', fontsize=15) 32 33 plt.show()
梯度消失现象
在神经网络里面,将经过反向传播之后,梯度值衰减到接近于零的现象称作梯度消失现象。
从上面的函数曲线可以看出,当x为较大的正数的时候,Sigmoid函数数值非常接近于1,函数曲线变得很平滑,在这些区域Sigmoid函数的导数接近于零。当x为较小的负数的时候,Sigmoid函数值非常接近于0,函数曲线也很平滑,在这些区域Sigmoid函数的导数也接近于0。只有当x的取值在0附近时,Sigmoid函数的导数才比较大。可以对Sigmoid函数求导数,结果如下所示:
dy/dx = −1/(1+e^(−x))^2 ⋅ d(e^(−x))/dx = 1/(2+e^x+e^(−x))
从上面的式子可以看出,Sigmoid函数的导数dy/dx最大值为1/4。前向传播时,y=Sigmoid(x);而在反向传播过程中,x的梯度等于y的梯度乘以Sigmoid函数的导数,如下所示:
∂L/∂x = ∂L/∂y ⋅ ∂y/∂x
使得x的梯度数值最大也不会超过y的梯度的1/4。
由于最开始是将神经网络的参数随机初始化的,x很有可能取值在数值很大或者很小的区域,这些地方都可能造成Sigmoid函数的导数接近于0,导致x的梯度接近于0;即使x取值在接近于0的地方,按上面的分析,经过Sigmoid函数反向传播之后,x的梯度不超过y的梯度的1/4,如果有多层网络使用了Sigmoid激活函数,则比较靠前的那些层梯度将衰减到非常小的值。
ReLU函数则不同,虽然在x<0的地方,ReLU函数的导数为0。但是在x≥0的地方,ReLU函数的导数为1,能够将y的梯度完整的传递给x,而不会引起梯度消失。