探索 YOLO v3 实现细节 - 第3篇 网络

YOLO,即You Only Look Once(你只能看一次)的缩写,是一个基于卷积神经网络(CNN)的物体检测算法。而YOLO v3是YOLO的第3个版本(即YOLOYOLO 9000YOLO v3),检测效果,更准更强。

YOLO v3的更多细节,可以参考YOLO的官网

YOLO

YOLO是一句美国的俗语,You Only Live Once,你只能活一次,即人生苦短,及时行乐。

本文主要分享,如何实现YOLO v3的算法细节,Keras框架。这是第3篇,网络,以DarkNet为基础。当然还有第4篇,至第n篇,毕竟,这是一个完整版 :)这篇略长。

本文的GitHub源码:https://github.com/SpikeKing/keras-yolo3-detection


1. 网络

在模型中,通过传入输入层image_input、每层的anchor数num_anchors//3和类别数num_classes,调用yolo_body()方法,构建YOLO v3的网络model_body。其中,image_input的结构是(?, 416, 416, 3)。

model_body = yolo_body(image_input, num_anchors // 3, num_classes)  # model
复制代码

model_body中,最终的输入是image_input,最终的输出是3个矩阵的列表:

[(?, 13, 13, 18), (?, 26, 26, 18), (?, 52, 52, 18)]
复制代码

YOLO v3的基础网络是DarkNet网络,将DarkNet网络中底层和中层的特征矩阵,通过卷积操作和多个矩阵的拼接操作,创建3个尺度的输出,即[y1, y2, y3]

def yolo_body(inputs, num_anchors, num_classes):
    darknet = Model(inputs, darknet_body(inputs))
    
    x, y1 = make_last_layers(darknet.output, 512, num_anchors * (num_classes + 5))

    x = compose(
        DarknetConv2D_BN_Leaky(256, (1, 1)),
        UpSampling2D(2))(x)
    x = Concatenate()([x, darknet.layers[152].output])
    x, y2 = make_last_layers(x, 256, num_anchors * (num_classes + 5))

    x = compose(
        DarknetConv2D_BN_Leaky(128, (1, 1)),
        UpSampling2D(2))(x)
    x = Concatenate()([x, darknet.layers[92].output])
    x, y3 = make_last_layers(x, 128, num_anchors * (num_classes + 5))

    return Model(inputs, [y1, y2, y3])
复制代码

2. Darknet

Darknet网络的输入是图片数据集inputs,即(?, 416, 416, 3),输出是darknet_body()方法的输出。将网络的核心逻辑封装在darknet_body()方法中。即:

darknet = Model(inputs, darknet_body(inputs))
复制代码

其中,darknet_body的输出格式是(?, 13, 13, 1024)。

Darknet网络的简化图,如下:

Darknet

YOLO v3所使用的Darknet版本是Darknet53。那么,为什么是Darknet53呢?因为Darknet53是53个卷积层和池化层的组合,与Darknet简化图一一对应,即:

53 = 2 + 1*2 + 1 + 2*2 + 1 + 8*2 + 1 + 8*2 + 1 + 4*2 + 1
复制代码

darknet_body()中,Darknet网络含有5组重复的resblock_body()单元,即:

def darknet_body(x):
    '''Darknent body having 52 Convolution2D layers'''
    x = DarknetConv2D_BN_Leaky(32, (3, 3))(x)
    x = resblock_body(x, num_filters=64, num_blocks=1)
    x = resblock_body(x, num_filters=128, num_blocks=2)
    x = resblock_body(x, num_filters=256, num_blocks=8)
    x = resblock_body(x, num_filters=512, num_blocks=8)
    x = resblock_body(x, num_filters=1024, num_blocks=4)
    return x
复制代码

在第1个卷积操作DarknetConv2D_BN_Leaky()中,是3个操作的组合,即:

  • 1个Darknet的2维卷积Conv2D层,即DarknetConv2D()
  • 1个批正在化(BN)层,即BatchNormalization();
  • 1个LeakyReLU层,斜率是0.1,LeakyReLU是ReLU的变换;

即:

def DarknetConv2D_BN_Leaky(*args, **kwargs):
    """Darknet Convolution2D followed by BatchNormalization and LeakyReLU."""
    no_bias_kwargs = {'use_bias': False}
    no_bias_kwargs.update(kwargs)
    return compose(
        DarknetConv2D(*args, **no_bias_kwargs),
        BatchNormalization(),
        LeakyReLU(alpha=0.1))
复制代码

其中,LeakyReLU的激活函数,如下:

LeakyReLU

其中,Darknet的2维卷积DarknetConv2D,具体操作如下:

  • 核的权重矩阵的正则化,l2正则化,参数是5e-4,即对于w参数操作;
  • padding使用same模式,当步长为(2,2)时,使用valid模式。这样做,避免在降采样时,添加无用的边界信息;
  • 其余参数不变,与二维卷积操作Conv2D一致;

实现:

@wraps(Conv2D)
def DarknetConv2D(*args, **kwargs):
    """Wrapper to set Darknet parameters for Convolution2D."""
    darknet_conv_kwargs = {'kernel_regularizer': l2(5e-4)}
    darknet_conv_kwargs['padding'] = 'valid' if kwargs.get('strides') == (2, 2) else 'same'
    darknet_conv_kwargs.update(kwargs)
    return Conv2D(*args, **darknet_conv_kwargs)
复制代码

其中,kernel_regularizer是针对于核参数w的正则化,而BatchNormalization是针对于输入x的正则化。

接着,在第1个残差块resblock_body中,输入是x是(?, 416, 416, 32),通道filters是64个,重复num_blocks是1次,与网络图完全对应。

x = resblock_body(x, num_filters=64, num_blocks=1)
复制代码

resblock_body中:

  • ZeroPadding2D填充x的边界为0,由(?, 416, 416, 32)转换为(?, 417, 417, 32),步长为2,需要边长为奇数;
  • DarknetConv2D_BN_Leaky是DarkNet的2维卷积操作,核是(3,3),步长是(2,2),注意,这会导致特征尺寸变小,由(?, 417, 417, 32)转换为(?, 208, 208, 64),num_filters为64,所以有64个通道。
  • y的compose()操作,是函数组合,先执行1x1的卷积操作,再执行3x3的卷积操作,filter先降低后增加,最后与输入相同,都是64;
  • x = Add()([x, y])是残差(Residual)操作,将x的值与y的值相加。

实现:

def resblock_body(x, num_filters, num_blocks):
    '''A series of resblocks starting with a downsampling Convolution2D'''
    # Darknet uses left and top padding instead of 'same' mode
    x = ZeroPadding2D(((1, 0), (1, 0)))(x)
    x = DarknetConv2D_BN_Leaky(num_filters, (3, 3), strides=(2, 2))(x)
    for i in range(num_blocks):
        y = compose(
            DarknetConv2D_BN_Leaky(num_filters // 2, (1, 1)),
            DarknetConv2D_BN_Leaky(num_filters, (3, 3)))(x)
        x = Add()([x, y])
    return x
复制代码

残差操作,如图:

![Residual网络层图](quiver-image-url/D041837A784CC66938F5AE8B482AFD23.jpg =400x633)

接着,在darknet_body()中,执行5组resblock_body,重复[1, 2, 8, 8, 4]次双卷积操作,5组每组均含有步长为2的卷积操作,一共降低5次,即32=2^5,输出的特征图维度为416/32=13。最后一层的通道(filter)数为1024,则输出的结构为(?, 13, 13, 1024),即:

Tensor("add_23/add:0", shape=(?, 13, 13, 1024), dtype=float32)
复制代码

至此,Darknet模型的输入是(?, 416, 416, 3),输出是(?, 13, 13, 1024)。


3. 特征图

YOLO v3网络,需要输出3个尺度的特征图,用于检测不同大小的物体,调用make_last_layers(),输出特征图y1、y2和y3。

13x13检测图

第1部分的输出结构,13x13,如下:

x, y1 = make_last_layers(darknet.output, 512, num_anchors * (num_classes + 5))
复制代码

make_last_layers()方法中,输入参数:

  • darknet.output:DarkNet网络的输出,即(?, 13, 13, 1024);
  • num_filters:512,通道个数,用于生成中间值x,再传导x值至第2个特征图;
  • out_filters:第1个特征图的输出通道,即num_anchors * (num_classes + 5)

其中,输出y的通道是由:anchor框的个数*(类别数+4个框值xywh+1个框置信度)所组成。

包含两步操作:

  • 第1步,x反复执行1x1的卷积操作和3x3的卷积操作,filter先扩大再减少,最后filter保持不变,仍为512,则x由(?, 13, 13, 1024),转变为(?, 13, 13, 512);
  • 第2步,x先执行3x3的卷积操作,再执行不含BN和Leaky的1x1的卷积操作,作为y;

其中,最后一步,使用1x1的卷积操作进行预测,不执行BN和Leaky,保证输出数据y的完整性。

即:

def make_last_layers(x, num_filters, out_filters):
    '''6 Conv2D_BN_Leaky layers followed by a Conv2D_linear layer'''
    x = compose(
        DarknetConv2D_BN_Leaky(num_filters, (1, 1)),
        DarknetConv2D_BN_Leaky(num_filters * 2, (3, 3)),
        DarknetConv2D_BN_Leaky(num_filters, (1, 1)),
        DarknetConv2D_BN_Leaky(num_filters * 2, (3, 3)),
        DarknetConv2D_BN_Leaky(num_filters, (1, 1)))(x)
    y = compose(
        DarknetConv2D_BN_Leaky(num_filters * 2, (3, 3)),
        DarknetConv2D(out_filters, (1, 1)))(x)
    return x, y
复制代码

这样,输出的x是(?, 13, 13, 512),输出的y是(?, 13, 13, 18),假设y只有1个类别,即3*(1+5)=18

26x26检测图

第2部分的输出结构,26x26,如下:

  1. 通过DarknetConv2D_BN_Leaky卷积,将x由512的通道数,转换为256的通道数;
  2. 通过2倍上采样UpSampling2D,将x由13x13的结构,转换为26x26的结构;
  3. 将x与DarkNet的第152层拼接Concatenate,作为第2个尺度特征图;

其中,输入的x和darknet.layers[152].output的结构所示26x26的尺寸,如下:

x: Tensor("up_sampling2d_1/ResizeNearestNeighbor:0", shape=(?, 26, 26, 256), dtype=float32)
darknet.layers[152].output: Tensor("add_19/add:0", shape=(?, 26, 26, 512), dtype=float32)
复制代码

输出的x:

Tensor("concatenate_1/concat:0", shape=(?, 26, 26, 768), dtype=float32)
复制代码

这样做的目的是,将最底层的抽象信息darknet.output,经过若干次转换之后,除了输出第1个检测层,还被用于第2个检测层,经过上采样,再与Darknet骨干中,上一次降维的数据拼接,共同作为第2个检测层的输入。底层信息含有全局特征,中层信息含有局部特征,这样拼接,可以两者兼顾。

最后,还是执行相同的make_last_layers,输出第2个检测层y2和临时数据x。

实现:

x = compose(
    DarknetConv2D_BN_Leaky(256, (1, 1)),
    UpSampling2D(2))(x)
x = Concatenate()([x, darknet.layers[152].output])
x, y2 = make_last_layers(x, 256, num_anchors * (num_classes + 5))
复制代码

最终输出,因为filter的数量是256,所以x结构是(?, 26, 26, 256),而检测层y2的结构是(?, 26, 26, 18),即:

Tensor("leaky_re_lu_64/LeakyRelu/Maximum:0", shape=(?, 26, 26, 256), dtype=float32)
Tensor("conv2d_67/BiasAdd:0", shape=(?, 26, 26, 18), dtype=float32)
复制代码

52x52检测图

第3部分的输出结构,52x52,与第2部分类似,如下:

x = compose(
    DarknetConv2D_BN_Leaky(128, (1, 1)),
    UpSampling2D(2))(x)
x = Concatenate()([x, darknet.layers[92].output])
_, y3 = make_last_layers(x, 128, num_anchors * (num_classes + 5))
复制代码

逻辑如下:

  • x经过128个filter的卷积,再执行上采样,输出为(?, 52, 52, 128);
  • darknet.layers[92].output,与152层类似,结构是(?, 52, 52, 256);
  • 两者拼接之后是(?, 52, 52, 384);
  • 最后输入至make_last_layers,生成y3是(?, 52, 52, 18),忽略x的输出;

最后,则是模型的重组,输入inputs依然保持不变,即(?, 416, 416, 3),而输出转换为3个尺度的预测层,即[y1, y2, y3]。

return Model(inputs, [y1, y2, y3])
复制代码

[y1, y2, y3]的结构如下:

Tensor("conv2d_59/BiasAdd:0", shape=(?, 13, 13, 18), dtype=float32)
Tensor("conv2d_67/BiasAdd:0", shape=(?, 26, 26, 18), dtype=float32)
Tensor("conv2d_75/BiasAdd:0", shape=(?, 52, 52, 18), dtype=float32)
复制代码

最终,在yolo_body中,完成整个YOLO v3模型的构建,基础网络是DarkNet。

model_body = yolo_body(image_input, num_anchors // 3, num_classes)
复制代码

示意图,层次序号略有不同:

![Image](quiver-image-url/6EF16038F0CDC1792FABA6B0433773E0.jpg =1901x1057)


补充1. 卷积Padding

在卷积操作中,针对于边缘数据,有两种操作,一种是舍弃valid,一种是填充same。

如:

数据:1 2 3 4 5 6 7 8 9 10 11 12 13
输入数据 = 13
过滤器宽度 = 6
步长 = 5
复制代码

第1种,valid操作,宽度是6,步长是5,执行数据:

1 2 3 4 5 6
6 7 8 9 10 11
11 12 13(不足,舍弃)
复制代码

第2种,same操作,执行数据:

1 2 3 4 5 6(前两步相同)
6 7 8 9 10 11
11 12 13 0 0(不足,填充)
复制代码

其中,same模式中数据利用率更高,valid模式中避免引入无效的边缘数据,两种模式各有千秋。


补充2. compose函数

compose()函数,使用Python的Lambda表达式,顺次执行函数列表,且前一个函数的输出是后一个函数的输入。compose()函数适用于在神经网络中连接两个层。

例如:

def compose(*funcs):
    if funcs:
        return reduce(lambda f, g: lambda *a, **kw: g(f(*a, **kw)), funcs)
    else:
        raise ValueError('Composition of empty sequence not supported.')
def func_x(x):
    return x * 10
def func_y(y):
    return y - 6
z = compose(func_x, func_y)  # 先执行x函数,再执行y函数
print(z(10))  # 10*10-6=94
复制代码

补充3. UpSampling2D上采样

UpSampling2D上采样操作,将特征矩阵按倍数扩大,其核心是通过resize的方式,默认使用最邻近(Nearest Neighbor)插值算法。data_format是数据模式,默认是channels_last,即通道在最后,如(128,128,3)

源码:

def call(self, inputs):
    return K.resize_images(inputs, self.size[0], self.size[1],
                           self.data_format)
// ...
x = tf.image.resize_nearest_neighbor(x, new_shape)                           
复制代码

例如:数据(?, 13, 13, 256),经过上采样2倍操作,即UpSampling2D(2),生成(?, 26, 26, 256)的特征图。


补充4. 1x1卷积操作与全连接

参考

1x1的卷积层和全连接层都可以作为最后一层的预测输出,两者之间略有不同。

第1点:

  • 1x1的卷积层,可以不考虑输入的通道数,输出固定通道数的特征矩阵;
  • 全连接层(Dense),输入和输出都是固定的,在设计网络时,固定就不能修改;

这样,1x1的卷积层,比全连接层,更为灵活;

第2点:

输入(13,13,1024),输出为(13,13,18)

  • 1x1的卷积层,参数较少,只需与输出通道匹配的参数,如13x13x1x1x18个参数;
  • 全连接层,参数较多,需要与输入和输出都匹配的参数,如13x13x1028x18个参数;

OK, that's all! Enjoy it!

欢迎 扫描或长按 下图二维码,关注 深度算法 ,了解更多技术。

深度算法

猜你喜欢

转载自juejin.im/post/5b66a2d751882536054a74e8