单阶段目标检测模型YOLO-V3

    与R-CNN系列算法不同,YOLO-V3使用单个网络结构,在产生候选区域的同时即可预测出物体类别和位置,不需要分成两阶段来完成检测任务。另外,YOLO-V3算法产生的预测框数目比Faster-RCNN少很多。Faster-RCNN中每个真实框可能对应多个标签为正的候选区域,而YOLO-V3里面每个真实框只对应一个正的候选区域。这些特性使得YOLO-V3算法具有更快的速度,能到达实时响应的水平。

    Joseph Redmon等人在2015年提出YOLO(You Only Look Once,YOLO)算法,通常也被称为YOLO V1;2016年,他们对算法进行改进,又提出YOLO V2版本;2018年发展出YOLO V3版本。

    主要涵盖如下内容:

    • YOLO-V3模型设计思想
    • 产生候选区域
      • 生成锚框
      • 生成预测框
      • 标注候选区域
    • 卷积神经网络提取特征
    • 建立损失函数
      • 获取样本标签
      • 建立各项损失函数
    • 多层级检测
    • 预测输出
      • 计算预测框得分和位置
      • 非极大值抑制

    YOLO V3算法的基本思想可以分成两部分:

    • 按一定规则在图片上产生一系列的候选区域,然后根据这些候选区域与图片上物体真实框之间的位置关系对候选区域进行标注。跟真实框足够接近的那些候选区域会被标注为正样本,同时将真实框的位置作为正样本的位置目标。偏离真实框较大的那些候选区域则会被标注为负样本,负样本不需要预测位置或者类别。
    • 使用卷积神经网络提取图片特征并对候选区域的位置和类别进行预测。这样每个预测框就可以看成是一个样本,根据真实框相对它的位置和类别进行了标注而获得标签值,通过网络模型预测其位置和类别,将网络预测值和标签值进行比较,就可以建立起损失函数。

    YOLO-V3算法训练过程的流程图如 图8 所示:

    图8:YOLO-V3算法训练流程图

    • 图8 左边是输入图片,上半部分所示的过程是使用卷积神经网络对图片提取特征,随着网络不断向前传播,特征图的尺寸越来越小,每个像素点会代表更加抽象的特征模式,直到输出特征图,其尺寸减小为原图的 单阶段目标检测模型YOLO-V3 - 图2
    • 图8 下半部分描述了生成候选区域的过程,首先将原图划分成多个小方块,每个小方块的大小是 ,然后以每个小方块为中心分别生成一系列锚框,整张图片都会被锚框覆盖到,在每个锚框的基础上产生一个与之对应的预测框,根据锚框和预测框与图片上物体真实框之间的位置关系,对这些预测框进行标注。
    • 将上方支路中输出的特征图与下方支路中产生的预测框标签建立关联,创建损失函数,开启端到端的训练过程。

    接下来具体介绍流程中各节点的原理和代码实现。

    产生候选区域

    如何产生候选区域,是检测模型的核心设计方案。目前大多数基于卷积神经网络的模型所采用的方式大体如下:

    • 按一定的规则在图片上生成一系列位置固定的锚框,将这些锚框看作是可能的候选区域,
    • 对锚框是否包含目标物体进行预测,如果包含目标物体,还需要预测所包含物体的类别,以及预测框相对于锚框位置需要调整的幅度。

    将原始图片划分成

    单阶段目标检测模型YOLO-V3 - 图4 个区域,如下图所示,原始图片高度H=640, 宽度W=480,如果我们选择小块区域的尺寸为 ,则m和n分别为:

    单阶段目标检测模型YOLO-V3 - 图6

    图9 所示,将原始图像分成了20行15列小方块区域。

    单阶段目标检测模型YOLO-V3 - 图8

    图9:将图片划分成多个32x32的小方块

    YOLO-V3算法会在每个区域的中心,生成一系列锚框。为了展示方便,我们先在图中第十行第四列的小方块位置附近画出生成的锚框,如 图10 所示。


    注意:

    这里为了跟程序中的编号对应,最上面的行号是第0行,最左边的列号是第0列**


    图10:在第10行第4列的小方块区域生成3个锚框

    图11 展示在每个区域附近都生成3个锚框,很多锚框堆叠在一起可能不太容易看清楚,但过程跟上面类似,只是需要以每个区域的中心点为中心,分别生成3个锚框。

    单阶段目标检测模型YOLO-V3 - 图10

    图11:在每个小方块区域生成3个锚框

    生成预测框

    在前面已经指出,锚框的位置都是固定好的,不可能刚好跟物体边界框重合,需要在锚框的基础上进行位置的微调以生成预测框。预测框相对于锚框会有不同的中心位置和大小,采用什么方式能产生出在锚框上面微调得到的预测框呢,我们先来考虑如何生成其中心位置坐标。

    比如上面图中在第10行第4列的小方块区域中心生成的一个锚框,如绿色虚线框所示。以小方格的宽度为单位长度,

    此小方块区域左上角的位置坐标是:

    单阶段目标检测模型YOLO-V3 - 图12

    此锚框的区域中心坐标是

    单阶段目标检测模型YOLO-V3 - 图14

    可以通过下面的方式生成预测框的中心坐标:

    单阶段目标检测模型YOLO-V3 - 图16

    其中

    单阶段目标检测模型YOLO-V3 - 图18 为实数, 是我们之前学过的Sigmoid函数,其定义如下:

    单阶段目标检测模型YOLO-V3 - 图20

    由于Sigmoid的函数值总是在

    之间,所以由上式计算出来的预测框中心点总是落在第十行第四列的小区域内部。

    单阶段目标检测模型YOLO-V3 - 图22 时, , 单阶段目标检测模型YOLO-V3 - 图24 ,预测框中心与锚框中心重合,都是小区域的中心。

    锚框的大小是预先设定好的,在模型中可以当作是超参数,下图中画出的锚框尺寸是

    单阶段目标检测模型YOLO-V3 - 图26

    通过下面的公式生成预测框的大小:

    单阶段目标检测模型YOLO-V3 - 图28

    如果

    ,则预测框跟锚框重合。

    如果给

    单阶段目标检测模型YOLO-V3 - 图30 随机赋值如下:

    则可以得到预测框的坐标是(154.98, 357.44, 276.29, 310.42),如 图12 中蓝色框所示。

    • 备注:这里坐标采用xywh的格式

    单阶段目标检测模型YOLO-V3 - 图32

    图12:生成预测框

    这里我们会问:当

    取值为多少的时候,预测框能够跟真实框重合?为了回答问题,只需要将上面预测框坐标中的 单阶段目标检测模型YOLO-V3 - 图34 设置为真实框的位置,即可求解出t的数值。

    令:

    单阶段目标检测模型YOLO-V3 - 图36

    单阶段目标检测模型YOLO-V3 - 图38

    可以求解出

    如果

    单阶段目标检测模型YOLO-V3 - 图40 是网络预测的输出值,将 作为目标值,以他们之间的差距作为损失函数,则可以建立起一个回归问题,通过学习网络参数,使得 单阶段目标检测模型YOLO-V3 - 图42 足够接近 ,从而能够求解出预测框的位置坐标跟大小。

    预测框可以看作是在锚框基础上的一个微调,每个锚框会有一个跟它对应的预测框,我们需要确定上面计算式中的

    单阶段目标检测模型YOLO-V3 - 图44 ,从而计算出与锚框对应的预测框的位置和形状。

    对候选区域进行标注

    每个在区域可以产生3种不同形状的锚框,每个锚框都是一个可能的候选区域,对这些候选区域我们希望知道这么几件事情:

    • 锚框是否包含了物体,这可以看成是一个二分类问题,包含了物体和没有包含物体,我们使用标签objectness来表示。当锚框包含了物体时,objectness=1,表示预测框属于正类;当锚框不包含物体时,设置objectness=0,表示锚框属于负类。

    • 如果锚框包含了物体,那么它对应的预测框的中心位置和大小应该是多少,或者说上面计算式中的

    应该是多少。

    • 如果锚框包含了物体,那么具体的具体类别是什么,这里使用变量label来表示其所属类别的标签。

    单阶段目标检测模型YOLO-V3 - 图46 和label,下面将分别讲述如何确定这三个标签的值。

    标注锚框是否包含物体的objectness标签

    图13 所示,这里一共有3个目标,以最左边的人像为例,其真实框是

    单阶段目标检测模型YOLO-V3 - 图48

    图13:选出与真实框中心位于同一区域的锚框

    真实框的中心点坐标是:

    单阶段目标检测模型YOLO-V3 - 图50

    单阶段目标检测模型YOLO-V3 - 图52

    它落在了第10行第4列的小方块内,如图(b)所示。此小方块区域可以生成3个不同形状的锚框,其在图上的编号和大小分别是

    用这3个不同形状的锚框跟真实框计算IoU,选出IoU最大的锚框。这里为了简化计算,只考虑锚框的形状,不考虑其跟真实框中心之间的偏移,具体计算结果如 图14 所示。

    单阶段目标检测模型YOLO-V3 - 图54

    图14:选出与真实框与锚框的IoU

    其中跟真实框IoU最大的是锚框

    ,形状是 单阶段目标检测模型YOLO-V3 - 图56 ,将它所对应的预测框的objectness标签设置为1,其所包括的物体类别就是真实框里面的物体所属类别。

    依次可以找出其他几个真实框对应的IoU最大的锚框,然后将它们的预测框的objectness标签也都设置为1。这里一共有

    个锚框,只有3个预测框会被标注为正。

    由于每个真实框只对应一个objectness标签为正的预测框,如果有些预测框跟真实框之间的IoU很大,但并不是最大的那个,那么直接将其objectness标签设置为0当作负样本,可能并不妥当。为了避免这种情况,YOLO-V3算法设置了一个IoU阈值iou_thresh,当预测框的objectness不为1,但是其与某个真实框的IoU大于iou_thresh时,就将其objectness标签设置为-1,不参与损失函数的计算。

    所有其他的预测框,其objectness标签均设置为0,表示负类。

    对于objectness=1的预测框,需要进一步确定其位置和包含物体的具体分类标签,但是对于objectness=0或者-1的预测框,则不用管他们的位置和类别。

    标注预测框的位置坐标标签

    当锚框objectness=1时,需要确定预测框位置相对于它微调的幅度,也就是锚框的位置标签。

    在前面我们已经问过这样一个问题:当

    单阶段目标检测模型YOLO-V3 - 图58 取值为多少的时候,预测框能够跟真实框重合?其做法是将预测框坐标中的 设置为真实框的坐标,即可求解出t的数值。

    令:

    单阶段目标检测模型YOLO-V3 - 图60

    单阶段目标检测模型YOLO-V3 - 图62

    对于

    单阶段目标检测模型YOLO-V3 - 图64 和 ,由于Sigmoid的反函数不好计算,我们直接将 单阶段目标检测模型YOLO-V3 - 图66 和 作为回归的目标

    单阶段目标检测模型YOLO-V3 - 图68

    单阶段目标检测模型YOLO-V3 - 图70

    如果

    单阶段目标检测模型YOLO-V3 - 图72 是网络预测的输出值,将 作为 单阶段目标检测模型YOLO-V3 - 图74 的目标值,以它们之间的差距作为损失函数,则可以建立起一个回归问题,通过学习网络参数,使得 足够接近 单阶段目标检测模型YOLO-V3 - 图76 ,从而能够求解出预测框的位置。

    标注锚框包含物体类别的标签

    对于objectness=1的锚框,需要确定其具体类别。正如上面所说,objectness标注为1的锚框,会有一个真实框跟它对应,该锚框所属物体类别,即是其所对应的真实框包含的物体类别。这里使用one-hot向量来表示类别标签label。比如一共有10个分类,而真实框里面包含的物体类别是第2类,则label为

    对上述步骤进行总结,标注的流程如 图15 所示。

    单阶段目标检测模型YOLO-V3 - 图78

    图15:标注流程示意图

    通过这种方式,我们在每个小方块区域都生成了一系列的锚框作为候选区域,并且根据图片上真实物体的位置,标注出了每个候选区域对应的objectness标签、位置需要调整的幅度以及包含的物体所属的类别。位置需要调整的幅度由4个变量描述

    ,objectness标签需要用一个变量描述 单阶段目标检测模型YOLO-V3 - 图80 ,描述所属类别的变量长度等于类别数C。

    对于每个锚框,模型需要预测输出

    ,其中 单阶段目标检测模型YOLO-V3 - 图82 是锚框是否包含物体的概率, 则是锚框包含的物体属于每个类别的概率。接下来让我们一起学习如何通过卷积神经网络输出这样的预测值。

    上面描述了如何对预锚框进行标注,但读者可能仍然对里面的细节不太了解,下面将通过具体的程序完成这一步骤。

    1. reader = multithread_loader('/home/aistudio/work/insects/train', batch_size=2, mode='train')
    2. img, gt_boxes, gt_labels, im_shape = next(reader())
    3. # 计算出锚框对应的标签
    4. label_objectness, label_location, label_classification, scale_location = get_objectness_label(img,
    5. gt_boxes, gt_labels,
    6. iou_threshold = 0.7,
    7. anchors = [116, 90, 156, 198, 373, 326],
    8. num_classes=7, downsample=32)
    1. img.shape, gt_boxes.shape, gt_labels.shape, im_shape.shape
    1. ((2, 3, 480, 480), (2, 50, 4), (2, 50), (2, 2))
    1. label_objectness.shape, label_location.shape, label_classification.shape, scale_location.shape
    1. ((2, 3, 15, 15), (2, 3, 4, 15, 15), (2, 3, 7, 15, 15), (2, 3, 15, 15))

    上面的程序实现了对锚框进行标注,对于每个真实框,选出了与它形状最匹配的锚框,将其objectness标注为1,并且将

    单阶段目标检测模型YOLO-V3 - 图84 作为正样本位置的标签,真实框包含的物体类别作为锚框的类别。而其余的锚框,objectness将被标注为0,无需标注出位置和类别的标签。

    • 注意:这里还遗留一个小问题,前面我们说了对于与真实框IoU较大的那些锚框,需要将其objectness标注为-1,不参与损失函数的计算。我们先将这个问题放一放,等到后面建立损失函数的时候再补上。

    在上一节图像分类的课程中,我们已经学习过了通过卷积神经网络提取图像特征。通过连续使用多层卷积和池化等操作,能得到语义含义更加丰富的特征图。在检测问题中,也使用卷积神经网络逐层提取图像特征,通过最终的输出特征图来表征物体位置和类别等信息。

    YOLO V3算法使用的骨干网络是Darknet53。Darknet53网络的具体结构如 图16 所示,在ImageNet图像分类任务上取得了很好的成绩。在检测任务中,将图中C0后面的平均池化、全连接层和Softmax去掉,保留从输入到C0部分的网络结构,作为检测模型的基础网络结构,也称为骨干网络。YOLO V3模型会在骨干网络的基础上,再添加检测相关的网络模块。

    图16:Darknet53网络结构

    下面的程序是Darknet53骨干网络的实现代码,这里将上图中C0、C1、C2所表示的输出数据取出,并查看它们的形状分别是,

    单阶段目标检测模型YOLO-V3 - 图86 , , 单阶段目标检测模型YOLO-V3 - 图88

    • 名词解释:特征图的步幅(stride)

    在提取特征的过程中通常会使用步幅大于1的卷积或者池化,导致后面的特征图尺寸越来越小,特征图的步幅等于输入图片尺寸除以特征图尺寸。例如C0的尺寸是

    ,原图尺寸是 单阶段目标检测模型YOLO-V3 - 图90 ,则C0的步幅是 。同理,C1的步幅是16,C2的步幅是8。

    1. import paddle.fluid as fluid
    2. from paddle.fluid.param_attr import ParamAttr
    3. from paddle.fluid.regularizer import L2Decay
    4. from paddle.fluid.dygraph.nn import Conv2D, BatchNorm
    5. from paddle.fluid.dygraph.base import to_variable
    6. # YOLO-V3骨干网络结构Darknet53的实现代码
    7. class ConvBNLayer(fluid.dygraph.Layer):
    8. """
    9. 卷积 + 批归一化,BN层之后激活函数默认用leaky_relu
    10. """
    11. def __init__(self,
    12. ch_in,
    13. ch_out,
    14. filter_size=3,
    15. stride=1,
    16. groups=1,
    17. padding=0,
    18. act="leaky",
    19. is_test=True):
    20. super(ConvBNLayer, self).__init__()
    21. self.conv = Conv2D(
    22. num_channels=ch_in,
    23. num_filters=ch_out,
    24. filter_size=filter_size,
    25. stride=stride,
    26. padding=padding,
    27. groups=groups,
    28. param_attr=ParamAttr(
    29. initializer=fluid.initializer.Normal(0., 0.02)),
    30. bias_attr=False,
    31. act=None)
    32. self.batch_norm = BatchNorm(
    33. num_channels=ch_out,
    34. is_test=is_test,
    35. param_attr=ParamAttr(
    36. initializer=fluid.initializer.Normal(0., 0.02),
    37. regularizer=L2Decay(0.)),
    38. bias_attr=ParamAttr(
    39. initializer=fluid.initializer.Constant(0.0),
    40. regularizer=L2Decay(0.)))
    41. self.act = act
    42. def forward(self, inputs):
    43. out = self.conv(inputs)
    44. out = self.batch_norm(out)
    45. if self.act == 'leaky':
    46. out = fluid.layers.leaky_relu(x=out, alpha=0.1)
    47. return out
    48. class DownSample(fluid.dygraph.Layer):
    49. """
    50. 下采样,图片尺寸减半,具体实现方式是使用stirde=2的卷积
    51. """
    52. def __init__(self,
    53. ch_in,
    54. ch_out,
    55. filter_size=3,
    56. stride=2,
    57. padding=1,
    58. is_test=True):
    59. super(DownSample, self).__init__()
    60. self.conv_bn_layer = ConvBNLayer(
    61. ch_in=ch_in,
    62. ch_out=ch_out,
    63. filter_size=filter_size,
    64. stride=stride,
    65. padding=padding,
    66. is_test=is_test)
    67. self.ch_out = ch_out
    68. def forward(self, inputs):
    69. out = self.conv_bn_layer(inputs)
    70. return out
    71. class BasicBlock(fluid.dygraph.Layer):
    72. """
    73. 基本残差块的定义,输入x经过两层卷积,然后接第二层卷积的输出和输入x相加
    74. """
    75. def __init__(self, ch_in, ch_out, is_test=True):
    76. super(BasicBlock, self).__init__()
    77. self.conv1 = ConvBNLayer(
    78. ch_in=ch_in,
    79. ch_out=ch_out,
    80. filter_size=1,
    81. stride=1,
    82. padding=0,
    83. is_test=is_test
    84. )
    85. self.conv2 = ConvBNLayer(
    86. ch_in=ch_out,
    87. ch_out=ch_out*2,
    88. filter_size=3,
    89. stride=1,
    90. padding=1,
    91. is_test=is_test
    92. )
    93. def forward(self, inputs):
    94. conv1 = self.conv1(inputs)
    95. conv2 = self.conv2(conv1)
    96. out = fluid.layers.elementwise_add(x=inputs, y=conv2, act=None)
    97. return out
    98. class LayerWarp(fluid.dygraph.Layer):
    99. """
    100. 添加多层残差块,组成Darknet53网络的一个层级
    101. """
    102. def __init__(self, ch_in, ch_out, count, is_test=True):
    103. super(LayerWarp,self).__init__()
    104. self.basicblock0 = BasicBlock(ch_in,
    105. ch_out,
    106. is_test=is_test)
    107. self.res_out_list = []
    108. for i in range(1, count):
    109. res_out = self.add_sublayer("basic_block_%d" % (i), #使用add_sublayer添加子层
    110. BasicBlock(ch_out*2,
    111. ch_out,
    112. is_test=is_test))
    113. self.res_out_list.append(res_out)
    114. def forward(self,inputs):
    115. y = self.basicblock0(inputs)
    116. for basic_block_i in self.res_out_list:
    117. y = basic_block_i(y)
    118. return y
    119. DarkNet_cfg = {53: ([1, 2, 8, 8, 4])}
    120. class DarkNet53_conv_body(fluid.dygraph.Layer):
    121. def __init__(self,
    122. name_scope,
    123. is_test=True):
    124. super(DarkNet53_conv_body, self).__init__(name_scope)
    125. self.stages = DarkNet_cfg[53]
    126. self.stages = self.stages[0:5]
    127. # 第一层卷积
    128. self.conv0 = ConvBNLayer(
    129. ch_in=3,
    130. ch_out=32,
    131. filter_size=3,
    132. stride=1,
    133. padding=1,
    134. is_test=is_test)
    135. # 下采样,使用stride=2的卷积来实现
    136. self.downsample0 = DownSample(
    137. ch_in=32,
    138. ch_out=32 * 2,
    139. is_test=is_test)
    140. # 添加各个层级的实现
    141. self.darknet53_conv_block_list = []
    142. self.downsample_list = []
    143. for i, stage in enumerate(self.stages):
    144. conv_block = self.add_sublayer(
    145. "stage_%d" % (i),
    146. LayerWarp(32*(2**(i+1)),
    147. 32*(2**i),
    148. stage,
    149. is_test=is_test))
    150. self.darknet53_conv_block_list.append(conv_block)
    151. # 两个层级之间使用DownSample将尺寸减半
    152. for i in range(len(self.stages) - 1):
    153. downsample = self.add_sublayer(
    154. "stage_%d_downsample" % i,
    155. DownSample(ch_in=32*(2**(i+1)),
    156. ch_out=32*(2**(i+2)),
    157. is_test=is_test))
    158. self.downsample_list.append(downsample)
    159. def forward(self,inputs):
    160. out = self.conv0(inputs)
    161. #print("conv1:",out.numpy())
    162. out = self.downsample0(out)
    163. #print("dy:",out.numpy())
    164. blocks = []
    165. for i, conv_block_i in enumerate(self.darknet53_conv_block_list): #依次将各个层级作用在输入上面
    166. out = conv_block_i(out)
    167. blocks.append(out)
    168. if i < len(self.stages) - 1:
    169. out = self.downsample_list[i](out)
    170. return blocks[-1:-4:-1] # 将C0, C1, C2作为返回值
    1. # 查看Darknet53网络输出特征图
    2. import numpy as np
    3. with fluid.dygraph.guard():
    4. backbone = DarkNet53_conv_body('yolov3_backbone', is_test=False)
    5. x = np.random.randn(1, 3, 640, 640).astype('float32')
    6. x = to_variable(x)
    7. C0, C1, C2 = backbone(x)
    8. print(C0.shape, C1.shape, C2.shape)
    1. [1, 1024, 20, 20] [1, 512, 40, 40] [1, 256, 80, 80]

    上面这段示例代码,指定输入数据的形状是

    单阶段目标检测模型YOLO-V3 - 图92 ,则3个层级的输出特征图的形状分别是 , 单阶段目标检测模型YOLO-V3 - 图94 和 。

    根据输出特征图计算预测框位置和类别

    YOLO-V3中对每个预测框计算逻辑如下:

    • 预测框是否包含物体。也可理解为objectness=1的概率是多少,可以用网络输出一个实数x,可以用Sigmoid(x)表示objectness为正的概率

    单阶段目标检测模型YOLO-V3 - 图96

    • 预测物体位置和形状。物体位置和形状

    可以用网络输出4个实数来表示 单阶段目标检测模型YOLO-V3 - 图98

    • 预测物体类别。预测图像中物体的具体类别是什么,或者说其属于每个类别的概率分别是多少。总的类别数为C,需要预测物体属于每个类别的概率

    ,可以用网络输出C个实数 单阶段目标检测模型YOLO-V3 - 图100 ,对每个实数分别求Sigmoid函数,让 ,则可以表示出物体属于每个类别的概率。

    对于一个预测框,网络需要输出

    单阶段目标检测模型YOLO-V3 - 图102 个实数来表征它是否包含物体、位置和形状尺寸以及属于每个类别的概率。

    由于我们在每个小方块区域都生成了K个预测框,则所有预测框一共需要网络输出的预测值数目是:

    还有更重要的一点是网络输出必须要能区分出小方块区域的位置来,不能直接将特征图连接一个输出大小为

    单阶段目标检测模型YOLO-V3 - 图104 的全连接层。

    建立输出特征图与预测框之间的关联

    现在观察特征图,经过多次卷积核池化之后,其步幅stride=32,

    大小的输入图片变成了 单阶段目标检测模型YOLO-V3 - 图106 的特征图;而小方块区域的数目正好是 ,也就是说可以让特征图上每个像素点分别跟原图上一个小方块区域对应。这也是为什么我们最开始将小方块区域的尺寸设置为32的原因,这样可以巧妙的将小方块区域跟特征图上的像素点对应起来,解决了空间位置的对应关系。

    图17:特征图C0与小方块区域形状对比

    下面需要将像素点

    单阶段目标检测模型YOLO-V3 - 图109 与第i行第j列的小方块区域所需要的预测值关联起来,每个小方块区域产生K个预测框,每个预测框需要 个实数预测值,则每个像素点相对应的要有 单阶段目标检测模型YOLO-V3 - 图111 个实数。为了解决这一问题,对特征图进行多次卷积,并将最终的输出通道数设置为 ,即可将生成的特征图与每个预测框所需要的预测值巧妙的对应起来。

    骨干网络的输出特征图是C0,下面的程序是对C0进行多次卷积以得到跟预测框相关的特征图P0。

    1. # 从骨干网络输出特征图C0得到跟预测相关的特征图P0
    2. class YoloDetectionBlock(fluid.dygraph.Layer):
    3. # define YOLO-V3 detection head
    4. # 使用多层卷积和BN提取特征
    5. def __init__(self,name_scope,ch_in,ch_out,is_test=True):
    6. super(YoloDetectionBlock, self).__init__(name_scope)
    7. assert ch_out % 2 == 0, \
    8. "channel {} cannot be divided by 2".format(ch_out)
    9. self.conv0 = ConvBNLayer(
    10. ch_in=ch_in,
    11. ch_out=ch_out,
    12. filter_size=1,
    13. stride=1,
    14. padding=0,
    15. is_test=is_test
    16. )
    17. self.conv1 = ConvBNLayer(
    18. ch_in=ch_out,
    19. ch_out=ch_out*2,
    20. filter_size=3,
    21. stride=1,
    22. padding=1,
    23. is_test=is_test
    24. )
    25. self.conv2 = ConvBNLayer(
    26. ch_in=ch_out*2,
    27. ch_out=ch_out,
    28. filter_size=1,
    29. stride=1,
    30. padding=0,
    31. is_test=is_test
    32. )
    33. self.conv3 = ConvBNLayer(
    34. ch_in=ch_out,
    35. ch_out=ch_out*2,
    36. filter_size=3,
    37. stride=1,
    38. padding=1,
    39. is_test=is_test
    40. )
    41. self.route = ConvBNLayer(
    42. ch_in=ch_out*2,
    43. ch_out=ch_out,
    44. filter_size=1,
    45. stride=1,
    46. padding=0,
    47. is_test=is_test
    48. )
    49. self.tip = ConvBNLayer(
    50. ch_in=ch_out,
    51. ch_out=ch_out*2,
    52. filter_size=3,
    53. stride=1,
    54. padding=1,
    55. is_test=is_test
    56. )
    57. def forward(self, inputs):
    58. out = self.conv0(inputs)
    59. out = self.conv1(out)
    60. out = self.conv2(out)
    61. out = self.conv3(out)
    62. route = self.route(out)
    63. tip = self.tip(route)
    64. return route, tip
    1. NUM_ANCHORS = 3
    2. NUM_CLASSES = 7
    3. num_filters=NUM_ANCHORS * (NUM_CLASSES + 5)
    4. with fluid.dygraph.guard():
    5. backbone = DarkNet53_conv_body('yolov3_backbone', is_test=False)
    6. detection = YoloDetectionBlock('detection', ch_in=1024, ch_out=512, is_test=False)
    7. conv2d_pred = Conv2D(num_channels=1024, num_filters=num_filters, filter_size=1)
    8. x = np.random.randn(1, 3, 640, 640).astype('float32')
    9. x = to_variable(x)
    10. C0, C1, C2 = backbone(x)
    11. route, tip = detection(C0)
    12. P0 = conv2d_pred(tip)
    13. print(P0.shape)
    1. [1, 36, 20, 20]

    如上面的代码所示,可以由特征图C0生成特征图P0,P0的形状是

    单阶段目标检测模型YOLO-V3 - 图113 。每个小方块区域生成的锚框或者预测框的数量是3,物体类别数目是7,每个区域需要的预测值个数是 ,正好等于P0的输出通道数。

    单阶段目标检测模型YOLO-V3 - 图115

    图18:特征图P0与候选区域的关联

    与输入的第t张图片上小方块区域 单阶段目标检测模型YOLO-V3 - 图117 第1个预测框所需要的12个预测值对应, 与输入的第t张图片上小方块区域 单阶段目标检测模型YOLO-V3 - 图119 第2个预测框所需要的12个预测值对应, 与输入的第t张图片上小方块区域 单阶段目标检测模型YOLO-V3 - 图121 第3个预测框所需要的12个预测值对应。

    与输入的第t张图片上小方块区域 单阶段目标检测模型YOLO-V3 - 图123 第1个预测框的位置对应, 与输入的第t张图片上小方块区域 单阶段目标检测模型YOLO-V3 - 图125 第1个预测框的objectness对应, 与输入的第t张图片上小方块区域 单阶段目标检测模型YOLO-V3 - 图127 第1个预测框的类别对应。

    图18 所示,通过这种方式可以巧妙的将网络输出特征图,与每个小方块区域生成的预测框对应起来了。

    计算预测框是否包含物体的概率

    根据前面的分析,

    与输入的第t张图片上小方块区域 单阶段目标检测模型YOLO-V3 - 图129 第1个预测框的objectness对应, 与第2个预测框的objectness对应,…,则可以使用下面的程序将objectness相关的预测取出,并使用fluid.layers.sigmoid计算输出概率。

    1. NUM_ANCHORS = 3
    2. NUM_CLASSES = 7
    3. num_filters=NUM_ANCHORS * (NUM_CLASSES + 5)
    4. with fluid.dygraph.guard():
    5. backbone = DarkNet53_conv_body('yolov3_backbone', is_test=False)
    6. detection = YoloDetectionBlock('detection', ch_in=1024, ch_out=512, is_test=False)
    7. conv2d_pred = Conv2D(num_channels=1024, num_filters=num_filters, filter_size=1)
    8. x = np.random.randn(1, 3, 640, 640).astype('float32')
    9. x = to_variable(x)
    10. C0, C1, C2 = backbone(x)
    11. route, tip = detection(C0)
    12. P0 = conv2d_pred(tip)
    13. reshaped_p0 = fluid.layers.reshape(P0, [-1, NUM_ANCHORS, NUM_CLASSES + 5, P0.shape[2], P0.shape[3]])
    14. pred_objectness = reshaped_p0[:, :, 4, :, :]
    15. pred_objectness_probability = fluid.layers.sigmoid(pred_objectness)
    16. print(pred_objectness.shape, pred_objectness_probability.shape)
    1. [1, 3, 20, 20] [1, 3, 20, 20]

    上面的输出程序显示,预测框是否包含物体的概率pred_objectness_probability,其数据形状是$[1, 3, 20, 20] $,与我们上面提到的预测框个数一致,数据大小在0~1之间,表示预测框为正样本的概率。

    单阶段目标检测模型YOLO-V3 - 图131 与输入的第t张图片上小方块区域 第1个预测框的位置对应, 单阶段目标检测模型YOLO-V3 - 图133 与第2个预测框的位置对应,…,使用下面的程序可以从P0中取出跟预测框位置相关的预测值。

    1. NUM_ANCHORS = 3
    2. NUM_CLASSES = 7
    3. num_filters=NUM_ANCHORS * (NUM_CLASSES + 5)
    4. with fluid.dygraph.guard():
    5. backbone = DarkNet53_conv_body('yolov3_backbone', is_test=False)
    6. detection = YoloDetectionBlock('detection', ch_in=1024, ch_out=512, is_test=False)
    7. conv2d_pred = Conv2D(num_channels=1024, num_filters=num_filters, filter_size=1)
    8. x = np.random.randn(1, 3, 640, 640).astype('float32')
    9. x = to_variable(x)
    10. C0, C1, C2 = backbone(x)
    11. route, tip = detection(C0)
    12. P0 = conv2d_pred(tip)
    13. reshaped_p0 = fluid.layers.reshape(P0, [-1, NUM_ANCHORS, NUM_CLASSES + 5, P0.shape[2], P0.shape[3]])
    14. pred_objectness = reshaped_p0[:, :, 4, :, :]
    15. pred_objectness_probability = fluid.layers.sigmoid(pred_objectness)
    16. pred_location = reshaped_p0[:, :, 0:4, :, :]
    17. print(pred_location.shape)

    网络输出值是

    ,还需要将其转化为 单阶段目标检测模型YOLO-V3 - 图135 这种形式的坐标表示。Paddle里面有专门的API fluid.layers.yolo_box直接计算出结果,但为了给读者更清楚的展示算法的实现过程,我们使用Numpy来实现这一过程。

    1. # 定义Sigmoid函数
    2. def sigmoid(x):
    3. return 1./(1.0 + np.exp(-x))
    4. # 将网络特征图输出的[tx, ty, th, tw]转化成预测框的坐标[x1, y1, x2, y2]
    5. def get_yolo_box_xxyy(pred, anchors, num_classes, downsample):
    6. """
    7. pred是网络输出特征图转化成的numpy.ndarray
    8. anchors 是一个list。表示锚框的大小,
    9. 例如 anchors = [116, 90, 156, 198, 373, 326],表示有三个锚框,
    10. 第一个锚框大小[w, h]是[116, 90],第二个锚框大小是[156, 198],第三个锚框大小是[373, 326]
    11. """
    12. batchsize = pred.shape[0]
    13. num_rows = pred.shape[-2]
    14. num_cols = pred.shape[-1]
    15. input_h = num_rows * downsample
    16. input_w = num_cols * downsample
    17. num_anchors = len(anchors) // 2
    18. # pred的形状是[N, C, H, W],其中C = NUM_ANCHORS * (5 + NUM_CLASSES)
    19. # 对pred进行reshape
    20. pred = pred.reshape([-1, num_anchors, 5+num_classes, num_rows, num_cols])
    21. pred_location = pred[:, :, 0:4, :, :]
    22. pred_location = np.transpose(pred_location, (0,3,4,1,2))
    23. anchors_this = []
    24. for ind in range(num_anchors):
    25. anchors_this.append([anchors[ind*2], anchors[ind*2+1]])
    26. anchors_this = np.array(anchors_this).astype('float32')
    27. # 最终输出数据保存在pred_box中,其形状是[N, H, W, NUM_ANCHORS, 4],
    28. # 其中最后一个维度4代表位置的4个坐标
    29. pred_box = np.zeros(pred_location.shape)
    30. for n in range(batchsize):
    31. for i in range(num_rows):
    32. for j in range(num_cols):
    33. for k in range(num_anchors):
    34. pred_box[n, i, j, k, 0] = j
    35. pred_box[n, i, j, k, 1] = i
    36. pred_box[n, i, j, k, 2] = anchors_this[k][0]
    37. pred_box[n, i, j, k, 3] = anchors_this[k][1]
    38. # 这里使用相对坐标,pred_box的输出元素数值在0.~1.0之间
    39. pred_box[:, :, :, :, 0] = (sigmoid(pred_location[:, :, :, :, 0]) + pred_box[:, :, :, :, 0]) / num_cols
    40. pred_box[:, :, :, :, 1] = (sigmoid(pred_location[:, :, :, :, 1]) + pred_box[:, :, :, :, 1]) / num_rows
    41. pred_box[:, :, :, :, 2] = np.exp(pred_location[:, :, :, :, 2]) * pred_box[:, :, :, :, 2] / input_w
    42. pred_box[:, :, :, :, 3] = np.exp(pred_location[:, :, :, :, 3]) * pred_box[:, :, :, :, 3] / input_h
    43. # 将坐标从xywh转化成xyxy
    44. pred_box[:, :, :, :, 0] = pred_box[:, :, :, :, 0] - pred_box[:, :, :, :, 2] / 2.
    45. pred_box[:, :, :, :, 1] = pred_box[:, :, :, :, 1] - pred_box[:, :, :, :, 3] / 2.
    46. pred_box[:, :, :, :, 2] = pred_box[:, :, :, :, 0] + pred_box[:, :, :, :, 2]
    47. pred_box[:, :, :, :, 3] = pred_box[:, :, :, :, 1] + pred_box[:, :, :, :, 3]
    48. pred_box = np.clip(pred_box, 0., 1.0)
    49. return pred_box

    通过调用上面定义的get_yolo_box_xxyy函数,可以从P0计算出预测框坐标来,具体程序如下:

    1. NUM_ANCHORS = 3
    2. NUM_CLASSES = 7
    3. num_filters=NUM_ANCHORS * (NUM_CLASSES + 5)
    4. with fluid.dygraph.guard():
    5. backbone = DarkNet53_conv_body('yolov3_backbone', is_test=False)
    6. detection = YoloDetectionBlock('detection', ch_in=1024, ch_out=512, is_test=False)
    7. conv2d_pred = Conv2D(num_channels=1024, num_filters=num_filters, filter_size=1)
    8. x = np.random.randn(1, 3, 640, 640).astype('float32')
    9. x = to_variable(x)
    10. C0, C1, C2 = backbone(x)
    11. route, tip = detection(C0)
    12. P0 = conv2d_pred(tip)
    13. reshaped_p0 = fluid.layers.reshape(P0, [-1, NUM_ANCHORS, NUM_CLASSES + 5, P0.shape[2], P0.shape[3]])
    14. pred_objectness = reshaped_p0[:, :, 4, :, :]
    15. pred_objectness_probability = fluid.layers.sigmoid(pred_objectness)
    16. pred_location = reshaped_p0[:, :, 0:4, :, :]
    17. # anchors包含了预先设定好的锚框尺寸
    18. anchors = [116, 90, 156, 198, 373, 326]
    19. # downsample是特征图P0的步幅
    20. pred_boxes = get_yolo_box_xxyy(P0.numpy(), anchors, num_classes=7, downsample=32) # 由输出特征图P0计算预测框位置坐标
    21. print(pred_boxes.shape)
    1. (1, 20, 20, 3, 4)

    上面程序计算出来的pred_boxes的形状是

    ,坐标格式是 单阶段目标检测模型YOLO-V3 - 图137 ,数值在0~1之间,表示相对坐标。

    计算物体属于每个类别概率

    与输入的第t张图片上小方块区域 单阶段目标检测模型YOLO-V3 - 图139 第1个预测框包含物体的类别对应, 与第2个预测框的类别对应,…,使用下面的程序可以从P0中取出那些跟预测框类别相关的预测值。

    1. NUM_ANCHORS = 3
    2. NUM_CLASSES = 7
    3. num_filters=NUM_ANCHORS * (NUM_CLASSES + 5)
    4. backbone = DarkNet53_conv_body('yolov3_backbone', is_test=False)
    5. detection = YoloDetectionBlock('detection', ch_in=1024, ch_out=512, is_test=False)
    6. conv2d_pred = Conv2D(num_channels=1024, num_filters=num_filters, filter_size=1)
    7. x = np.random.randn(1, 3, 640, 640).astype('float32')
    8. x = to_variable(x)
    9. C0, C1, C2 = backbone(x)
    10. route, tip = detection(C0)
    11. P0 = conv2d_pred(tip)
    12. reshaped_p0 = fluid.layers.reshape(P0, [-1, NUM_ANCHORS, NUM_CLASSES + 5, P0.shape[2], P0.shape[3]])
    13. # 取出与objectness相关的预测值
    14. pred_objectness = reshaped_p0[:, :, 4, :, :]
    15. pred_objectness_probability = fluid.layers.sigmoid(pred_objectness)
    16. # 取出与位置相关的预测值
    17. pred_location = reshaped_p0[:, :, 0:4, :, :]
    18. # 取出与类别相关的预测值
    19. pred_classification = reshaped_p0[:, :, 5:5+NUM_CLASSES, :, :]
    20. pred_classification_probability = fluid.layers.sigmoid(pred_classification)
    21. print(pred_classification.shape)
    1. [1, 3, 7, 20, 20]

    上面的程序通过P0计算出了预测框包含的物体所属类别的概率,pred_classification_probability的形状是

    单阶段目标检测模型YOLO-V3 - 图141 ,数值在0~1之间。

    上面一小节从概念上将输出特征图上的像素点与预测框关联起来了,那么要对神经网络进行求解,还必须从数学上将网络输出和预测框关联起来,也就是要建立起损失函数跟网络输出之间的关系。下面讨论如何建立起YOLO-V3的损失函数。

    对于每个预测框,YOLO-V3模型会建立三种类型的损失函数:

    • 表征是否包含目标物体的损失函数,通过pred_objectness和label_objectness计算
    1. loss_obj = fluid.layers.sigmoid_cross_entropy_with_logits(pred_objectness, label_objectness)
    • 表征物体位置的损失函数,通过pred_location和label_location计算
    1. pred_location_x = pred_location[:, :, 0, :, :]
    2. pred_location_y = pred_location[:, :, 1, :, :]
    3. pred_location_w = pred_location[:, :, 2, :, :]
    4. pred_location_h = pred_location[:, :, 3, :, :]
    5. loss_location_x = fluid.layers.sigmoid_cross_entropy_with_logits(pred_location_x, label_location_x)
    6. loss_location_y = fluid.layers.sigmoid_cross_entropy_with_logits(pred_location_y, label_location_y)
    7. loss_location_w = fluid.layers.abs(pred_location_w - label_location_w)
    8. loss_location_h = fluid.layers.abs(pred_location_h - label_location_h)
    9. loss_location = loss_location_x + loss_location_y + loss_location_w + loss_location_h
    • 表征物体类别的损失函数,通过pred_classification和label_classification计算
    1. loss_obj = fluid.layers.sigmoid_cross_entropy_with_logits(pred_classification, label_classification)

    在前面几个小节中我们已经知道怎么计算这些预测值和标签了,但是遗留了一个小问题,就是没有标注出哪些锚框的objectness为-1。为了完成这一步,我们需要计算出所有预测框跟真实框之间的IoU,然后把那些IoU大于阈值的真实框挑选出来。实现代码如下:

    1. # 挑选出跟真实框IoU大于阈值的预测框
    2. def get_iou_above_thresh_inds(pred_box, gt_boxes, iou_threshold):
    3. batchsize = pred_box.shape[0]
    4. num_rows = pred_box.shape[1]
    5. num_cols = pred_box.shape[2]
    6. num_anchors = pred_box.shape[3]
    7. ret_inds = np.zeros([batchsize, num_rows, num_cols, num_anchors])
    8. for i in range(batchsize):
    9. pred_box_i = pred_box[i]
    10. gt_boxes_i = gt_boxes[i]
    11. for k in range(len(gt_boxes_i)): #gt in gt_boxes_i:
    12. gt = gt_boxes_i[k]
    13. gtx_min = gt[0] - gt[2] / 2.
    14. gty_min = gt[1] - gt[3] / 2.
    15. gtx_max = gt[0] + gt[2] / 2.
    16. gty_max = gt[1] + gt[3] / 2.
    17. if (gtx_max - gtx_min < 1e-3) or (gty_max - gty_min < 1e-3):
    18. continue
    19. x1 = np.maximum(pred_box_i[:, :, :, 0], gtx_min)
    20. y1 = np.maximum(pred_box_i[:, :, :, 1], gty_min)
    21. x2 = np.minimum(pred_box_i[:, :, :, 2], gtx_max)
    22. y2 = np.minimum(pred_box_i[:, :, :, 3], gty_max)
    23. intersection = np.maximum(x2 - x1, 0.) * np.maximum(y2 - y1, 0.)
    24. s1 = (gty_max - gty_min) * (gtx_max - gtx_min)
    25. s2 = (pred_box_i[:, :, :, 2] - pred_box_i[:, :, :, 0]) * (pred_box_i[:, :, :, 3] - pred_box_i[:, :, :, 1])
    26. union = s2 + s1 - intersection
    27. iou = intersection / union
    28. above_inds = np.where(iou > iou_threshold)
    29. ret_inds[i][above_inds] = 1
    30. ret_inds = np.transpose(ret_inds, (0,3,1,2))
    31. return ret_inds.astype('bool')

    上面的函数可以得到哪些锚框的objectness需要被标注为-1,通过下面的程序,对label_objectness进行处理,将IoU大于阈值,但又不是正样本的那些锚框标注为-1。

    1. def label_objectness_ignore(label_objectness, iou_above_thresh_indices):
    2. # 注意:这里不能简单的使用 label_objectness[iou_above_thresh_indices] = -1,
    3. # 这样可能会造成label_objectness为1的那些点被设置为-1了
    4. # 只有将那些被标注为0,且与真实框IoU超过阈值的预测框才被标注为-1
    5. negative_indices = (label_objectness < 0.5)
    6. ignore_indices = negative_indices * iou_above_thresh_indices
    7. label_objectness[ignore_indices] = -1
    8. return label_objectness

    下面通过调用这两个函数,实现如何将部分预测框的label_objectness设置为-1。

    1. # 读取数据
    2. reader = multithread_loader('/home/aistudio/work/insects/train', batch_size=2, mode='train')
    3. img, gt_boxes, gt_labels, im_shape = next(reader())
    4. # 计算出锚框对应的标签
    5. label_objectness, label_location, label_classification, scale_location = get_objectness_label(img,
    6. gt_boxes, gt_labels,
    7. iou_threshold = 0.7,
    8. anchors = [116, 90, 156, 198, 373, 326],
    9. num_classes=7, downsample=32)
    10. NUM_ANCHORS = 3
    11. NUM_CLASSES = 7
    12. num_filters=NUM_ANCHORS * (NUM_CLASSES + 5)
    13. with fluid.dygraph.guard():
    14. backbone = DarkNet53_conv_body('yolov3_backbone', is_test=False)
    15. detection = YoloDetectionBlock('detection', ch_in=1024, ch_out=512, is_test=False)
    16. conv2d_pred = Conv2D(num_channels=1024, num_filters=num_filters, filter_size=1)
    17. x = to_variable(img)
    18. C0, C1, C2 = backbone(x)
    19. route, tip = detection(C0)
    20. P0 = conv2d_pred(tip)
    21. # anchors包含了预先设定好的锚框尺寸
    22. anchors = [116, 90, 156, 198, 373, 326]
    23. # downsample是特征图P0的步幅
    24. pred_boxes = get_yolo_box_xxyy(P0.numpy(), anchors, num_classes=7, downsample=32)
    25. iou_above_thresh_indices = get_iou_above_thresh_inds(pred_boxes, gt_boxes, iou_threshold=0.7)
    26. label_objectness = label_objectness_ignore(label_objectness, iou_above_thresh_indices)
    27. print(label_objectness.shape)
    1. (2, 3, 12, 12)

    使用这种方式,就可以将那些没有被标注为正样本,但又与真实框IoU比较大的样本objectness标签设置为-1了,不计算其对任何一种损失函数的贡献。

    计算总的损失函数的代码如下:

    1. def get_loss(output, label_objectness, label_location, label_classification, scales, num_anchors=3, num_classes=7):
    2. # 将output从[N, C, H, W]变形为[N, NUM_ANCHORS, NUM_CLASSES + 5, H, W]
    3. reshaped_output = fluid.layers.reshape(output, [-1, num_anchors, num_classes + 5, output.shape[2], output.shape[3]])
    4. # 从output中取出跟objectness相关的预测值
    5. pred_objectness = reshaped_output[:, :, 4, :, :]
    6. loss_objectness = fluid.layers.sigmoid_cross_entropy_with_logits(pred_objectness, label_objectness, ignore_index=-1)
    7. ## 对第1,2,3维求和
    8. #loss_objectness = fluid.layers.reduce_sum(loss_objectness, dim=[1,2,3], keep_dim=False)
    9. # pos_samples 只有在正样本的地方取值为1.,其它地方取值全为0.
    10. pos_objectness = label_objectness > 0
    11. pos_samples = fluid.layers.cast(pos_objectness, 'float32')
    12. pos_samples.stop_gradient=True
    13. #从output中取出所有跟位置相关的预测值
    14. tx = reshaped_output[:, :, 0, :, :]
    15. ty = reshaped_output[:, :, 1, :, :]
    16. tw = reshaped_output[:, :, 2, :, :]
    17. th = reshaped_output[:, :, 3, :, :]
    18. # 从label_location中取出各个位置坐标的标签
    19. dx_label = label_location[:, :, 0, :, :]
    20. dy_label = label_location[:, :, 1, :, :]
    21. tw_label = label_location[:, :, 2, :, :]
    22. th_label = label_location[:, :, 3, :, :]
    23. # 构建损失函数
    24. loss_location_x = fluid.layers.sigmoid_cross_entropy_with_logits(tx, dx_label)
    25. loss_location_y = fluid.layers.sigmoid_cross_entropy_with_logits(ty, dy_label)
    26. loss_location_w = fluid.layers.abs(tw - tw_label)
    27. loss_location_h = fluid.layers.abs(th - th_label)
    28. # 计算总的位置损失函数
    29. loss_location = loss_location_x + loss_location_y + loss_location_h + loss_location_w
    30. # 乘以scales
    31. loss_location = loss_location * scales
    32. # 只计算正样本的位置损失函数
    33. loss_location = loss_location * pos_samples
    34. #从ooutput取出所有跟物体类别相关的像素点
    35. pred_classification = reshaped_output[:, :, 5:5+num_classes, :, :]
    36. # 计算分类相关的损失函数
    37. loss_classification = fluid.layers.sigmoid_cross_entropy_with_logits(pred_classification, label_classification)
    38. # 将第2维求和
    39. loss_classification = fluid.layers.reduce_sum(loss_classification, dim=2, keep_dim=False)
    40. # 只计算objectness为正的样本的分类损失函数
    41. loss_classification = loss_classification * pos_samples
    42. total_loss = loss_objectness + loss_location + loss_classification
    43. # 对所有预测框的loss进行求和
    44. total_loss = fluid.layers.reduce_sum(total_loss, dim=[1,2,3], keep_dim=False)
    45. # 对所有样本求平均
    46. total_loss = fluid.layers.reduce_mean(total_loss)
    47. return total_loss
    1. # 计算损失函数
    2. # 读取数据
    3. reader = multithread_loader('/home/aistudio/work/insects/train', batch_size=2, mode='train')
    4. img, gt_boxes, gt_labels, im_shape = next(reader())
    5. # 计算出锚框对应的标签
    6. label_objectness, label_location, label_classification, scale_location = get_objectness_label(img,
    7. gt_boxes, gt_labels,
    8. iou_threshold = 0.7,
    9. anchors = [116, 90, 156, 198, 373, 326],
    10. num_classes=7, downsample=32)
    11. NUM_ANCHORS = 3
    12. NUM_CLASSES = 7
    13. num_filters=NUM_ANCHORS * (NUM_CLASSES + 5)
    14. with fluid.dygraph.guard():
    15. backbone = DarkNet53_conv_body('yolov3_backbone', is_test=False)
    16. detection = YoloDetectionBlock('detection', ch_in=1024, ch_out=512, is_test=False)
    17. conv2d_pred = Conv2D(num_channels=1024, num_filters=num_filters, filter_size=1)
    18. x = to_variable(img)
    19. C0, C1, C2 = backbone(x)
    20. route, tip = detection(C0)
    21. P0 = conv2d_pred(tip)
    22. # anchors包含了预先设定好的锚框尺寸
    23. anchors = [116, 90, 156, 198, 373, 326]
    24. # downsample是特征图P0的步幅
    25. pred_boxes = get_yolo_box_xxyy(P0.numpy(), anchors, num_classes=7, downsample=32)
    26. iou_above_thresh_indices = get_iou_above_thresh_inds(pred_boxes, gt_boxes, iou_threshold=0.7)
    27. label_objectness = label_objectness_ignore(label_objectness, iou_above_thresh_indices)
    28. label_objectness = to_variable(label_objectness)
    29. label_location = to_variable(label_location)
    30. label_classification = to_variable(label_classification)
    31. scales = to_variable(scale_location)
    32. label_objectness.stop_gradient=True
    33. label_location.stop_gradient=True
    34. label_classification.stop_gradient=True
    35. scales.stop_gradient=True
    36. total_loss = get_loss(P0, label_objectness, label_location, label_classification, scales,
    37. num_anchors=NUM_ANCHORS, num_classes=NUM_CLASSES)
    38. total_loss_data = total_loss.numpy()
    39. print(total_loss_data)

    上面的程序计算出了总的损失函数,看到这里,读者已经了解到了YOLO-V3算法的大部分内容,包括如何生成锚框、给锚框打上标签、通过卷积神经网络提取特征、将输出特征图跟预测框相关联、建立起损失函数。

    多尺度检测

    目前我们计算损失函数是在特征图P0的基础上进行的,它的步幅stride=32。特征图的尺寸比较小,像素点数目比较少,每个像素点的感受野很大,具有非常丰富的高层级语义信息,可能比较容易检测到较大的目标。为了能够检测到尺寸较小的那些目标,需要在尺寸较大的特征图上面建立预测输出。如果我们在C2或者C1这种层级的特征图上直接产生预测输出,可能面临新的问题,它们没有经过充分的特征提取,像素点包含的语义信息不够丰富,有可能难以提取到有效的特征模式。在目标检测中,解决这一问题的方式是,将高层级的特征图尺寸放大之后跟低层级的特征图进行融合,得到的新特征图既能包含丰富的语义信息,又具有较多的像素点,能够描述更加精细的结构。

    具体的网络实现方式如 图19 所示:

    图19:生成多层级的输出特征图P0、P1、P2

    YOLO-V3在每个区域的中心位置产生3个锚框,在3个层级的特征图上产生锚框的大小分别为P2 [(10×13),(16×30),(33×23)],P1 [(30×61),(62×45),(59× 119)],P0[(116 × 90), (156 × 198), (373 × 326]。越往后的特征图上用到的锚框尺寸也越大,能捕捉到大尺寸目标的信息;越往前的特征图上锚框尺寸越小,能捕捉到小尺寸目标的信息。

    因为有多尺度的检测,所以需要对上面的代码进行较大的修改,而且实现过程也略显繁琐,所以推荐大家直接使用Paddle提供的API fluid.layers.yolov3_loss,其具体说明如下:

    • fluid.layers.yolov3_loss(x, gt_box, gt_label, anchors, anchor_mask, class_num, ignore_thresh, downsample_ratio, gt_score=None, use_label_smooth=True, name=None))
      • x: 输入的图像数据
      • gt_box: 真实框
      • gt_label: 真实框标签
      • anchors: 使用到的anchor的尺寸,如[10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326]
      • anchor_mask: 每个层级上使用的anchor的掩码,[[6, 7, 8], [3, 4, 5], [0, 1, 2]]
      • class_num,物体类别数,AI识虫数据集为7
      • ignore_thresh,预测框与真实框IoU阈值超过ignore_thresh时,不作为负样本,YOLO-V3模型里设置为0.7
      • downsample_ratio,特征图P0的下采样比例,使用Darknet53骨干网络时为32
      • gt_score,真实框的置信度,在使用了mixup技巧时会用到
      • use_label_smooth,一种训练技巧,不使用就设置为False
      • name,该层的名字,比如’yolov3_loss’,可以不设置

    对于使用了多层级特征图产生预测框的方法,其具体实现代码如下:

    1. # 定义上采样模块
    2. class Upsample(fluid.dygraph.Layer):
    3. def __init__(self, name_scope, scale=2):
    4. super(Upsample,self).__init__(name_scope)
    5. self.scale = scale
    6. def forward(self, inputs):
    7. # get dynamic upsample output shape
    8. shape_nchw = fluid.layers.shape(inputs)
    9. shape_hw = fluid.layers.slice(shape_nchw, axes=[0], starts=[2], ends=[4])
    10. shape_hw.stop_gradient = True
    11. in_shape = fluid.layers.cast(shape_hw, dtype='int32')
    12. out_shape = in_shape * self.scale
    13. out_shape.stop_gradient = True
    14. # reisze by actual_shape
    15. out = fluid.layers.resize_nearest(
    16. input=inputs, scale=self.scale, actual_shape=out_shape)
    17. return out
    18. # 定义YOLO-V3模型
    19. class YOLOv3(fluid.dygraph.Layer):
    20. def __init__(self,name_scope, num_classes=7, is_train=True):
    21. super(YOLOv3,self).__init__(name_scope)
    22. self.is_train = is_train
    23. self.num_classes = num_classes
    24. # 提取图像特征的骨干代码
    25. self.block = DarkNet53_conv_body(self.full_name(),
    26. is_test = not self.is_train)
    27. self.block_outputs = []
    28. self.yolo_blocks = []
    29. self.route_blocks_2 = []
    30. # 生成3个层级的特征图P0, P1, P2
    31. for i in range(3):
    32. # 添加从ci生成ri和ti的模块
    33. yolo_block = self.add_sublayer(
    34. "yolo_detecton_block_%d" % (i),
    35. YoloDetectionBlock(self.full_name(),
    36. ch_in=512//(2**i)*2 if i==0 else 512//(2**i)*2 + 512//(2**i),
    37. ch_out = 512//(2**i),
    38. is_test = not self.is_train))
    39. self.yolo_blocks.append(yolo_block)
    40. num_filters = 3 * (self.num_classes + 5)
    41. # 添加从ti生成pi的模块,这是一个Conv2D操作,输出通道数为3 * (num_classes + 5)
    42. block_out = self.add_sublayer(
    43. "block_out_%d" % (i),
    44. Conv2D(num_channels=512//(2**i)*2,
    45. num_filters=num_filters,
    46. filter_size=1,
    47. stride=1,
    48. padding=0,
    49. act=None,
    50. param_attr=ParamAttr(
    51. initializer=fluid.initializer.Normal(0., 0.02)),
    52. bias_attr=ParamAttr(
    53. initializer=fluid.initializer.Constant(0.0),
    54. regularizer=L2Decay(0.))))
    55. self.block_outputs.append(block_out)
    56. if i < 2:
    57. # 对ri进行卷积
    58. route = self.add_sublayer("route2_%d"%i,
    59. ConvBNLayer(ch_in=512//(2**i),
    60. ch_out=256//(2**i),
    61. filter_size=1,
    62. stride=1,
    63. padding=0,
    64. is_test=(not self.is_train)))
    65. self.route_blocks_2.append(route)
    66. # 将ri放大以便跟c_{i+1}保持同样的尺寸
    67. self.upsample = Upsample(self.full_name())
    68. def forward(self, inputs):
    69. outputs = []
    70. blocks = self.block(inputs)
    71. for i, block in enumerate(blocks):
    72. if i > 0:
    73. # 将r_{i-1}经过卷积和上采样之后得到特征图,与这一级的ci进行拼接
    74. block = fluid.layers.concat(input=[route, block], axis=1)
    75. # 从ci生成ti和ri
    76. route, tip = self.yolo_blocks[i](block)
    77. # 从ti生成pi
    78. block_out = self.block_outputs[i](tip)
    79. # 将pi放入列表
    80. outputs.append(block_out)
    81. if i < 2:
    82. # 对ri进行卷积调整通道数
    83. route = self.route_blocks_2[i](route)
    84. # 对ri进行放大,使其尺寸和c_{i+1}保持一致
    85. route = self.upsample(route)
    86. return outputs
    87. def get_loss(self, outputs, gtbox, gtlabel, gtscore=None,
    88. anchors = [10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326],
    89. anchor_masks = [[6, 7, 8], [3, 4, 5], [0, 1, 2]],
    90. ignore_thresh=0.7,
    91. use_label_smooth=False):
    92. """
    93. 使用fluid.layers.yolov3_loss,直接计算损失函数,过程更简洁,速度也更快
    94. """
    95. self.losses = []
    96. downsample = 32
    97. for i, out in enumerate(outputs): # 对三个层级分别求损失函数
    98. anchor_mask_i = anchor_masks[i]
    99. loss = fluid.layers.yolov3_loss(
    100. x=out, # out是P0, P1, P2中的一个
    101. gt_box=gtbox, # 真实框坐标
    102. gt_label=gtlabel, # 真实框类别
    103. gt_score=gtscore, # 真实框得分,使用mixup训练技巧时需要,不使用该技巧时直接设置为1,形状与gtlabel相同
    104. anchors=anchors, # 锚框尺寸,包含[w0, h0, w1, h1, ..., w8, h8]共9个锚框的尺寸
    105. anchor_mask=anchor_mask_i, # 筛选锚框的mask,例如anchor_mask_i=[3, 4, 5],将anchors中第3、4、5个锚框挑选出来给该层级使用
    106. class_num=self.num_classes, # 分类类别数
    107. ignore_thresh=ignore_thresh, # 当预测框与真实框IoU > ignore_thresh,标注objectness = -1
    108. downsample_ratio=downsample, # 特征图相对于原图缩小的倍数,例如P0是32, P1是16,P2是8
    109. use_label_smooth=False) # 使用label_smooth训练技巧时会用到,这里没用此技巧,直接设置为False
    110. self.losses.append(fluid.layers.reduce_mean(loss)) #reduce_mean对每张图片求和
    111. downsample = downsample // 2 # 下一级特征图的缩放倍数会减半
    112. return sum(self.losses) # 对每个层级求和

    开启端到端训练

    训练过程的流程如下图所示,输入图片经过特征提取得到三个层级的输出特征图P0(stride=32)、P1(stride=16)和P2(stride=8),相应的分别使用不同大小的小方块区域去生成对应的锚框和预测框,并对这些锚框进行标注。

    • P0层级特征图,对应着使用

    单阶段目标检测模型YOLO-V3 - 图143 大小的小方块,在每个区域中心生成大小分别为 , 单阶段目标检测模型YOLO-V3 - 图145 , 的三种锚框。

    • P1层级特征图,对应着使用

    单阶段目标检测模型YOLO-V3 - 图147 大小的小方块,在每个区域中心生成大小分别为 , 单阶段目标检测模型YOLO-V3 - 图149 , 的三种锚框。

    • P2层级特征图,对应着使用

    单阶段目标检测模型YOLO-V3 - 图151 大小的小方块,在每个区域中心生成大小分别为 , 单阶段目标检测模型YOLO-V3 - 图153 , 的三种锚框。

    将三个层级的特征图与对应锚框之间的标签关联起来,并建立损失函数,总的损失函数等于三个层级的损失函数相加。通过极小化损失函数,可以开启端到端的训练过程。

    单阶段目标检测模型YOLO-V3 - 图155

    图20:端到端训练流程

    训练过程的具体实现代码如下:

    1. ############# 这段代码在本地机器上运行请慎重,容易造成死机#######################
    2. import time
    3. import os
    4. import paddle
    5. import paddle.fluid as fluid
    6. ANCHORS = [10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326]
    7. ANCHOR_MASKS = [[6, 7, 8], [3, 4, 5], [0, 1, 2]]
    8. IGNORE_THRESH = .7
    9. NUM_CLASSES = 7
    10. def get_lr(base_lr = 0.0001, lr_decay = 0.1):
    11. bd = [10000, 20000]
    12. lr = [base_lr, base_lr * lr_decay, base_lr * lr_decay * lr_decay]
    13. learning_rate = fluid.layers.piecewise_decay(boundaries=bd, values=lr)
    14. return learning_rate
    15. if __name__ == '__main__':
    16. TRAINDIR = '/home/aistudio/work/insects/train'
    17. TESTDIR = '/home/aistudio/work/insects/test'
    18. VALIDDIR = '/home/aistudio/work/insects/val'
    19. with fluid.dygraph.guard():
    20. model = YOLOv3('yolov3', num_classes = NUM_CLASSES, is_train=True) #创建模型
    21. learning_rate = get_lr()
    22. opt = fluid.optimizer.Momentum(
    23. learning_rate=learning_rate,
    24. momentum=0.9,
    25. regularization=fluid.regularizer.L2Decay(0.0005),
    26. parameter_list=model.parameters()) #创建优化器
    27. train_loader = multithread_loader(TRAINDIR, batch_size= 10, mode='train') #创建训练数据读取器
    28. valid_loader = multithread_loader(VALIDDIR, batch_size= 10, mode='valid') #创建验证数据读取器
    29. MAX_EPOCH = 200
    30. for epoch in range(MAX_EPOCH):
    31. for i, data in enumerate(train_loader()):
    32. img, gt_boxes, gt_labels, img_scale = data
    33. gt_scores = np.ones(gt_labels.shape).astype('float32')
    34. gt_scores = to_variable(gt_scores)
    35. img = to_variable(img)
    36. gt_boxes = to_variable(gt_boxes)
    37. gt_labels = to_variable(gt_labels)
    38. outputs = model(img) #前向传播,输出[P0, P1, P2]
    39. loss = model.get_loss(outputs, gt_boxes, gt_labels, gtscore=gt_scores,
    40. anchors = ANCHORS,
    41. anchor_masks = ANCHOR_MASKS,
    42. ignore_thresh=IGNORE_THRESH,
    43. use_label_smooth=False) # 计算损失函数
    44. loss.backward() # 反向传播计算梯度
    45. opt.minimize(loss) # 更新参数
    46. model.clear_gradients()
    47. if i % 1 == 0:
    48. timestring = time.strftime("%Y-%m-%d %H:%M:%S",time.localtime(time.time()))
    49. print('{}[TRAIN]epoch {}, iter {}, output loss: {}'.format(timestring, epoch, i, loss.numpy()))
    50. # save params of model
    51. if (epoch % 5 == 0) or (epoch == MAX_EPOCH -1):
    52. fluid.save_dygraph(model.state_dict(), 'yolo_epoch{}'.format(epoch))
    53. # 每个epoch结束之后在验证集上进行测试
    54. model.eval()
    55. for i, data in enumerate(valid_loader()):
    56. img, gt_boxes, gt_labels, img_scale = data
    57. gt_scores = np.ones(gt_labels.shape).astype('float32')
    58. gt_scores = to_variable(gt_scores)
    59. img = to_variable(img)
    60. gt_boxes = to_variable(gt_boxes)
    61. gt_labels = to_variable(gt_labels)
    62. outputs = model(img)
    63. loss = model.get_loss(outputs, gt_boxes, gt_labels, gtscore=gt_scores,
    64. anchors = ANCHORS,
    65. anchor_masks = ANCHOR_MASKS,
    66. ignore_thresh=IGNORE_THRESH,
    67. use_label_smooth=False)
    68. if i % 1 == 0:
    69. timestring = time.strftime("%Y-%m-%d %H:%M:%S",time.localtime(time.time()))
    70. print('{}[VALID]epoch {}, iter {}, output loss: {}'.format(timestring, epoch, i, loss.numpy()))
    71. model.train()

    预测过程流程 图21 如下所示:

    图21:端到端训练流程

    预测过程可以分为两步:

    • 通过网络输出计算出预测框位置和所属类别的得分。
    • 使用非极大值抑制来消除重叠较大的预测框。

    对于第1步,前面我们已经讲过如何通过网络输出值计算pred_objectness_probability, pred_boxes以及pred_classification_probability,这里推荐大家直接使用fluid.layers.yolo_box,其使用方法是:

    • fluid.layers.yolo_box(x, img_size, anchors, class_num, conf_thresh, downsample_ratio, name=None)

      • img_size,输入图片尺寸
      • anchors,使用到的anchor的尺寸,如[10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326]
      • anchor_mask: 每个层级上使用的anchor的掩码,[[6, 7, 8], [3, 4, 5], [0, 1, 2]]
      • class_num,物体类别数目
      • conf_thresh, 置信度阈值,得分低于该阈值的预测框位置数值不用计算直接设置为0.0
      • downsample_ratio, 特征图的下采样比例,例如P0是32,P1是16,P2是8
      • name=None,名字,例如’yolo_box’
    • 返回值包括两项,boxes和scores,其中boxes是所有预测框的坐标值,scores是所有预测框的得分。

    预测框得分的定义是所属类别的概率乘以其预测框是否包含目标物体的objectness概率,即

    单阶段目标检测模型YOLO-V3 - 图157

    在上面定义的类YOLO-V3下面添加函数,get_pred,通过调用fluid.layers.yolo_box获得P0、P1、P2三个层级的特征图对应的预测框和得分,并将他们拼接在一块,即可得到所有的预测框及其属于各个类别的得分。

    1. # 定义YOLO-V3模型
    2. class YOLOv3(fluid.dygraph.Layer):
    3. def __init__(self,name_scope, num_classes=7, is_train=True):
    4. super(YOLOv3,self).__init__(name_scope)
    5. self.is_train = is_train
    6. self.num_classes = num_classes
    7. # 提取图像特征的骨干代码
    8. self.block = DarkNet53_conv_body(self.full_name(),
    9. is_test = not self.is_train)
    10. self.block_outputs = []
    11. self.route_blocks_2 = []
    12. # 生成3个层级的特征图P0, P1, P2
    13. for i in range(3):
    14. # 添加从ci生成ri和ti的模块
    15. yolo_block = self.add_sublayer(
    16. "yolo_detecton_block_%d" % (i),
    17. YoloDetectionBlock(self.full_name(),
    18. ch_in=512//(2**i)*2 if i==0 else 512//(2**i)*2 + 512//(2**i),
    19. ch_out = 512//(2**i),
    20. is_test = not self.is_train))
    21. self.yolo_blocks.append(yolo_block)
    22. num_filters = 3 * (self.num_classes + 5)
    23. # 添加从ti生成pi的模块,这是一个Conv2D操作,输出通道数为3 * (num_classes + 5)
    24. block_out = self.add_sublayer(
    25. "block_out_%d" % (i),
    26. Conv2D(num_channels=512//(2**i)*2,
    27. num_filters=num_filters,
    28. filter_size=1,
    29. stride=1,
    30. padding=0,
    31. act=None,
    32. param_attr=ParamAttr(
    33. initializer=fluid.initializer.Normal(0., 0.02)),
    34. bias_attr=ParamAttr(
    35. initializer=fluid.initializer.Constant(0.0),
    36. regularizer=L2Decay(0.))))
    37. self.block_outputs.append(block_out)
    38. if i < 2:
    39. # 对ri进行卷积
    40. route = self.add_sublayer("route2_%d"%i,
    41. ConvBNLayer(ch_in=512//(2**i),
    42. ch_out=256//(2**i),
    43. filter_size=1,
    44. stride=1,
    45. padding=0,
    46. is_test=(not self.is_train)))
    47. self.route_blocks_2.append(route)
    48. # 将ri放大以便跟c_{i+1}保持同样的尺寸
    49. self.upsample = Upsample(self.full_name())
    50. def forward(self, inputs):
    51. outputs = []
    52. blocks = self.block(inputs)
    53. for i, block in enumerate(blocks):
    54. if i > 0:
    55. # 将r_{i-1}经过卷积和上采样之后得到特征图,与这一级的ci进行拼接
    56. block = fluid.layers.concat(input=[route, block], axis=1)
    57. # 从ci生成ti和ri
    58. route, tip = self.yolo_blocks[i](block)
    59. # 从ti生成pi
    60. block_out = self.block_outputs[i](tip)
    61. # 将pi放入列表
    62. outputs.append(block_out)
    63. if i < 2:
    64. # 对ri进行卷积调整通道数
    65. route = self.route_blocks_2[i](route)
    66. # 对ri进行放大,使其尺寸和c_{i+1}保持一致
    67. route = self.upsample(route)
    68. return outputs
    69. def get_loss(self, outputs, gtbox, gtlabel, gtscore=None,
    70. anchors = [10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326],
    71. anchor_masks = [[6, 7, 8], [3, 4, 5], [0, 1, 2]],
    72. ignore_thresh=0.7,
    73. use_label_smooth=False):
    74. self.losses = []
    75. downsample = 32
    76. for i, out in enumerate(outputs):
    77. anchor_mask_i = anchor_masks[i]
    78. loss = fluid.layers.yolov3_loss(
    79. x=out,
    80. gt_box=gtbox,
    81. gt_label=gtlabel,
    82. gt_score=gtscore,
    83. anchors=anchors,
    84. anchor_mask=anchor_mask_i,
    85. class_num=self.num_classes,
    86. ignore_thresh=ignore_thresh,
    87. downsample_ratio=downsample,
    88. use_label_smooth=False)
    89. self.losses.append(fluid.layers.reduce_mean(loss))
    90. downsample = downsample // 2
    91. return sum(self.losses)
    92. def get_pred(self,
    93. outputs,
    94. im_shape=None,
    95. anchors = [10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326],
    96. anchor_masks = [[6, 7, 8], [3, 4, 5], [0, 1, 2]],
    97. valid_thresh = 0.01):
    98. downsample = 32
    99. total_boxes = []
    100. total_scores = []
    101. for i, out in enumerate(outputs):
    102. anchor_mask = anchor_masks[i]
    103. anchors_this_level = []
    104. for m in anchor_mask:
    105. anchors_this_level.append(anchors[2 * m])
    106. anchors_this_level.append(anchors[2 * m + 1])
    107. boxes, scores = fluid.layers.yolo_box(
    108. x=out,
    109. img_size=im_shape,
    110. anchors=anchors_this_level,
    111. class_num=self.num_classes,
    112. conf_thresh=valid_thresh,
    113. downsample_ratio=downsample,
    114. name="yolo_box" + str(i))
    115. total_boxes.append(boxes)
    116. total_scores.append(
    117. fluid.layers.transpose(
    118. scores, perm=[0, 2, 1]))
    119. downsample = downsample // 2
    120. yolo_boxes = fluid.layers.concat(total_boxes, axis=1)
    121. yolo_scores = fluid.layers.concat(total_scores, axis=2)
    122. return yolo_boxes, yolo_scores

    第1步的计算结果会在每个小方块区域都会产生多个预测框,输出预测框中会有很多重合度比较大,需要消除重叠较大的冗余预测框。

    下面示例代码中的预测框是使用模型对图片预测之后输出的,这里一共选出了11个预测框,在图上画出预测框如下所示。在每个人像周围,都出现了多个预测框,需要消除冗余的预测框以得到最终的预测结果。

    1. # 画图展示目标物体边界框
    2. import numpy as np
    3. import matplotlib.pyplot as plt
    4. import matplotlib.patches as patches
    5. from matplotlib.image import imread
    6. import math
    7. # 定义画矩形框的程序
    8. def draw_rectangle(currentAxis, bbox, edgecolor = 'k', facecolor = 'y', fill=False, linestyle='-'):
    9. # currentAxis,坐标轴,通过plt.gca()获取
    10. # bbox,边界框,包含四个数值的list, [x1, y1, x2, y2]
    11. # edgecolor,边框线条颜色
    12. # facecolor,填充颜色
    13. # fill, 是否填充
    14. # linestype,边框线型
    15. # patches.Rectangle需要传入左上角坐标、矩形区域的宽度、高度等参数
    16. rect=patches.Rectangle((bbox[0], bbox[1]), bbox[2]-bbox[0]+1, bbox[3]-bbox[1]+1, linewidth=1,
    17. edgecolor=edgecolor,facecolor=facecolor,fill=fill, linestyle=linestyle)
    18. currentAxis.add_patch(rect)
    19. plt.figure(figsize=(10, 10))
    20. filename = '/home/aistudio/work/images/section3/000000086956.jpg'
    21. im = imread(filename)
    22. plt.imshow(im)
    23. currentAxis=plt.gca()
    24. # 预测框位置
    25. boxes = np.array([[4.21716537e+01, 1.28230896e+02, 2.26547668e+02, 6.00434631e+02],
    26. [3.18562988e+02, 1.23168472e+02, 4.79000000e+02, 6.05688416e+02],
    27. [2.62704697e+01, 1.39430557e+02, 2.20587097e+02, 6.38959656e+02],
    28. [4.24965363e+01, 1.42706665e+02, 2.25955185e+02, 6.35671204e+02],
    29. [2.37462646e+02, 1.35731537e+02, 4.79000000e+02, 6.31451294e+02],
    30. [3.19390472e+02, 1.29295090e+02, 4.79000000e+02, 6.33003845e+02],
    31. [3.28933838e+02, 1.22736115e+02, 4.79000000e+02, 6.39000000e+02],
    32. [4.44292603e+01, 1.70438187e+02, 2.26841858e+02, 6.39000000e+02],
    33. [2.17988785e+02, 3.02472412e+02, 4.06062927e+02, 6.29106628e+02],
    34. [2.00241089e+02, 3.23755096e+02, 3.96929321e+02, 6.36386108e+02],
    35. [2.14310303e+02, 3.23443665e+02, 4.06732849e+02, 6.35775269e+02]])
    36. # 预测框得分
    37. scores = np.array([0.5247661 , 0.51759845, 0.86075854, 0.9910175 , 0.39170712,
    38. 0.9297706 , 0.5115228 , 0.270992 , 0.19087596, 0.64201415, 0.879036])
    39. # 画出所有预测框
    40. for box in boxes:
    41. draw_rectangle(currentAxis, box)

    1. <Figure size 720x720 with 1 Axes>

    这里使用非极大值抑制(non-maximum suppression, nms)来消除冗余框,其基本思想是,如果有多个预测框都对应同一个物体,则只选出得分最高的那个预测框,剩下的预测框被丢弃掉。那么如何判断两个预测框对应的是同一个物体呢,标准该怎么设置?如果两个预测框的类别一样,而且他们的位置重合度比较大,则可以认为他们是在预测同一个目标。非极大值抑制的做法是,选出某个类别得分最高的预测框,然后看哪些预测框跟它的IoU大于阈值,就把这些预测框给丢弃掉。这里IoU的阈值是超参数,需要提前设置,YOLO-V3模型里面设置的是0.5。

    比如在上面的程序中,boxes里面一共对应11个预测框,scores给出了它们预测"人"这一类别的得分。

    • Step0 创建选中列表,keep_list = []
    • Step1 对得分进行排序,remain_list = [ 3, 5, 10, 2, 9, 0, 1, 6, 4, 7, 8],
    • Step2 选出boxes[3],此时keep_list为空,不需要计算IoU,直接将其放入keep_list,keep_list = [3], remain_list=[5, 10, 2, 9, 0, 1, 6, 4, 7, 8]
    • Step3 选出boxes[5],此时keep_list中已经存在boxes[3],计算出IoU(boxes[3], boxes[5]) = 0.0,显然小于阈值,则keep_list=[3, 5], remain_list = [10, 2, 9, 0, 1, 6, 4, 7, 8]
    • Step4 选出boxes[10],此时keep_list=[3, 5],计算IoU(boxes[3], boxes[10])=0.0268,IoU(boxes[5], boxes[10])=0.0268 = 0.24,都小于阈值,则keep_list = [3, 5, 10],remain_list=[2, 9, 0, 1, 6, 4, 7, 8]
    • Step5 选出boxes[2],此时keep_list = [3, 5, 10],计算IoU(boxes[3], boxes[2]) = 0.88,超过了阈值,直接将boxes[2]丢弃,keep_list=[3, 5, 10],remain_list=[9, 0, 1, 6, 4, 7, 8]
    • Step6 选出boxes[9],此时keep_list = [3, 5, 10],计算IoU(boxes[3], boxes[9]) = 0.0577,IoU(boxes[5], boxes[9]) = 0.205,IoU(boxes[10], boxes[9]) = 0.88,超过了阈值,将boxes[9]丢弃掉。keep_list=[3, 5, 10],remain_list=[0, 1, 6, 4, 7, 8]
    • Step7 重复上述Step6直到remain_list为空

    最终得到keep_list=[3, 5, 10],也就是预测框3、5、10被最终挑选出来了,如下图所示

    1. # 画图展示目标物体边界框
    2. import numpy as np
    3. import matplotlib.pyplot as plt
    4. import matplotlib.patches as patches
    5. from matplotlib.image import imread
    6. import math
    7. # 定义画矩形框的程序
    8. def draw_rectangle(currentAxis, bbox, edgecolor = 'k', facecolor = 'y', fill=False, linestyle='-'):
    9. # currentAxis,坐标轴,通过plt.gca()获取
    10. # bbox,边界框,包含四个数值的list, [x1, y1, x2, y2]
    11. # edgecolor,边框线条颜色
    12. # facecolor,填充颜色
    13. # fill, 是否填充
    14. # linestype,边框线型
    15. # patches.Rectangle需要传入左上角坐标、矩形区域的宽度、高度等参数
    16. rect=patches.Rectangle((bbox[0], bbox[1]), bbox[2]-bbox[0]+1, bbox[3]-bbox[1]+1, linewidth=1,
    17. edgecolor=edgecolor,facecolor=facecolor,fill=fill, linestyle=linestyle)
    18. currentAxis.add_patch(rect)
    19. plt.figure(figsize=(10, 10))
    20. filename = '/home/aistudio/work/images/section3/000000086956.jpg'
    21. im = imread(filename)
    22. plt.imshow(im)
    23. currentAxis=plt.gca()
    24. boxes = np.array([[4.21716537e+01, 1.28230896e+02, 2.26547668e+02, 6.00434631e+02],
    25. [3.18562988e+02, 1.23168472e+02, 4.79000000e+02, 6.05688416e+02],
    26. [2.62704697e+01, 1.39430557e+02, 2.20587097e+02, 6.38959656e+02],
    27. [4.24965363e+01, 1.42706665e+02, 2.25955185e+02, 6.35671204e+02],
    28. [2.37462646e+02, 1.35731537e+02, 4.79000000e+02, 6.31451294e+02],
    29. [3.19390472e+02, 1.29295090e+02, 4.79000000e+02, 6.33003845e+02],
    30. [3.28933838e+02, 1.22736115e+02, 4.79000000e+02, 6.39000000e+02],
    31. [4.44292603e+01, 1.70438187e+02, 2.26841858e+02, 6.39000000e+02],
    32. [2.17988785e+02, 3.02472412e+02, 4.06062927e+02, 6.29106628e+02],
    33. [2.00241089e+02, 3.23755096e+02, 3.96929321e+02, 6.36386108e+02],
    34. [2.14310303e+02, 3.23443665e+02, 4.06732849e+02, 6.35775269e+02]])
    35. scores = np.array([0.5247661 , 0.51759845, 0.86075854, 0.9910175 , 0.39170712,
    36. 0.9297706 , 0.5115228 , 0.270992 , 0.19087596, 0.64201415, 0.879036])
    37. left_ind = np.where((boxes[:, 0]<60) * (boxes[:, 0]>20))
    38. left_boxes = boxes[left_ind]
    39. left_scores = scores[left_ind]
    40. colors = ['r', 'g', 'b', 'k']
    41. # 画出最终保留的预测框
    42. inds = [3, 5, 10]
    43. for i in range(3):
    44. box = boxes[inds[i]]
    45. draw_rectangle(currentAxis, box, edgecolor=colors[i])

    单阶段目标检测模型YOLO-V3 - 图159

    1. <Figure size 720x720 with 1 Axes>

    非极大值抑制的具体实现代码如下面nms函数的定义,需要说明的是数据集中含有多个类别的物体,所以这里需要做多分类非极大值抑制,其实现原理与非极大值抑制相同,区别在于需要对每个类别都做非极大值抑制,实现代码如下面的multiclass_nms所示。

    1. # 非极大值抑制
    2. def nms(bboxes, scores, score_thresh, nms_thresh, pre_nms_topk, i=0, c=0):
    3. """
    4. nms
    5. """
    6. inds = np.argsort(scores)
    7. inds = inds[::-1]
    8. keep_inds = []
    9. while(len(inds) > 0):
    10. cur_ind = inds[0]
    11. cur_score = scores[cur_ind]
    12. # if score of the box is less than score_thresh, just drop it
    13. if cur_score < score_thresh:
    14. break
    15. keep = True
    16. for ind in keep_inds:
    17. current_box = bboxes[cur_ind]
    18. remain_box = bboxes[ind]
    19. iou = box_iou_xyxy(current_box, remain_box)
    20. if iou > nms_thresh:
    21. keep = False
    22. break
    23. if i == 0 and c == 4 and cur_ind == 951:
    24. print('suppressed, ', keep, i, c, cur_ind, ind, iou)
    25. if keep:
    26. keep_inds.append(cur_ind)
    27. inds = inds[1:]
    28. return np.array(keep_inds)
    29. # 多分类非极大值抑制
    30. def multiclass_nms(bboxes, scores, score_thresh=0.01, nms_thresh=0.45, pre_nms_topk=1000, pos_nms_topk=100):
    31. """
    32. This is for multiclass_nms
    33. """
    34. batch_size = bboxes.shape[0]
    35. class_num = scores.shape[1]
    36. rets = []
    37. for i in range(batch_size):
    38. bboxes_i = bboxes[i]
    39. scores_i = scores[i]
    40. ret = []
    41. for c in range(class_num):
    42. scores_i_c = scores_i[c]
    43. keep_inds = nms(bboxes_i, scores_i_c, score_thresh, nms_thresh, pre_nms_topk, i=i, c=c)
    44. if len(keep_inds) < 1:
    45. continue
    46. keep_bboxes = bboxes_i[keep_inds]
    47. keep_scores = scores_i_c[keep_inds]
    48. keep_results = np.zeros([keep_scores.shape[0], 6])
    49. keep_results[:, 0] = c
    50. keep_results[:, 1] = keep_scores[:]
    51. keep_results[:, 2:6] = keep_bboxes[:, :]
    52. ret.append(keep_results)
    53. if len(ret) < 1:
    54. rets.append(ret)
    55. continue
    56. ret_i = np.concatenate(ret, axis=0)
    57. scores_i = ret_i[:, 1]
    58. if len(scores_i) > pos_nms_topk:
    59. inds = np.argsort(scores_i)[::-1]
    60. inds = inds[:pos_nms_topk]
    61. ret_i = ret_i[inds]
    62. rets.append(ret_i)
    63. return rets

    下面是完整的测试程序,在测试数据集上的输出结果将会被保存在pred_results.json文件中。

    1. import json
    2. ANCHORS = [10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326]
    3. ANCHOR_MASKS = [[6, 7, 8], [3, 4, 5], [0, 1, 2]]
    4. VALID_THRESH = 0.01
    5. NMS_TOPK = 400
    6. NMS_POSK = 100
    7. NMS_THRESH = 0.45
    8. NUM_CLASSES = 7
    9. if __name__ == '__main__':
    10. TRAINDIR = '/home/aistudio/work/insects/train/images'
    11. TESTDIR = '/home/aistudio/work/insects/test/images'
    12. VALIDDIR = '/home/aistudio/work/insects/val'
    13. with fluid.dygraph.guard():
    14. model = YOLOv3('yolov3', num_classes=NUM_CLASSES, is_train=False)
    15. params_file_path = '/home/aistudio/work/yolo_epoch50'
    16. model_state_dict, _ = fluid.load_dygraph(params_file_path)
    17. model.load_dict(model_state_dict)
    18. model.eval()
    19. total_results = []
    20. test_loader = test_data_loader(TESTDIR, batch_size= 1, mode='test')
    21. for i, data in enumerate(test_loader()):
    22. img_name, img_data, img_scale_data = data
    23. img = to_variable(img_data)
    24. img_scale = to_variable(img_scale_data)
    25. outputs = model.forward(img)
    26. bboxes, scores = model.get_pred(outputs,
    27. im_shape=img_scale,
    28. anchors=ANCHORS,
    29. anchor_masks=ANCHOR_MASKS,
    30. valid_thresh = VALID_THRESH)
    31. bboxes_data = bboxes.numpy()
    32. scores_data = scores.numpy()
    33. result = multiclass_nms(bboxes_data, scores_data,
    34. score_thresh=VALID_THRESH,
    35. nms_thresh=NMS_THRESH,
    36. pre_nms_topk=NMS_TOPK,
    37. pos_nms_topk=NMS_POSK)
    38. for j in range(len(result)):
    39. result_j = result[j]
    40. img_name_j = img_name[j]
    41. total_results.append([img_name_j, result_j.tolist()])
    42. print('processed {} pictures'.format(len(total_results)))
    43. print('')
    44. json.dump(total_results, open('pred_results.json', 'w'))

    json文件中保存着测试结果,是包含所有图片预测结果的list,其构成如下:

    1. [[img_name, [[label, score, x1, x2, y1, y2], ..., [label, score, x1, x2, y1, y2]]],
    2. [img_name, [[label, score, x1, x2, y1, y2], ..., [label, score, x1, x2, y1, y2]]],
    3. ...
    4. [img_name, [[label, score, x1, x2, y1, y2],..., [label, score, x1, x2, y1, y2]]]]

    list中的每一个元素是一张图片的预测结果,list的总长度等于图片的数目,每张图片预测结果的格式是:

    1. [img_name, [[label, score, x1, x2, y1, y2],..., [label, score, x1, x2, y1, y2]]]

    其中第一个元素是图片名称image_name,第二个元素是包含该图片所有预测框的list, 预测框列表:

    1. [[label, score, x1, x2, y1, y2],..., [label, score, x1, x2, y1, y2]]

    预测框列表中每个元素[label, score, x1, x2, y1, y2]描述了一个预测框,label是预测框所属类别标签,score是预测框的得分;x1, x2, y1, y2对应预测框左上角坐标(x1, y1),右下角坐标(x2, y2)。每张图片可能有很多个预测框,则将其全部放在预测框列表中。

    在AI识虫比赛的基础版本中,老师提供了MAP指标计算代码,使用此pred_results.json文件即可计算出最终的评估指标。

    上面的程序展示了如何读取测试数据集的读片,并将最终结果保存在json格式的文件中。为了更直观的给读者展示模型效果,下面的程序添加了如何读取单张图片,并画出其产生的预测框。

    • 创建数据读取器以读取单张图片的数据
    1. # 读取单张测试图片
    2. def single_image_data_loader(filename, test_image_size=608, mode='test'):
    3. """
    4. 加载测试用的图片,测试数据没有groundtruth标签
    5. """
    6. batch_size= 1
    7. def reader():
    8. batch_data = []
    9. img_size = test_image_size
    10. file_path = os.path.join(filename)
    11. img = cv2.imread(file_path)
    12. img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    13. H = img.shape[0]
    14. W = img.shape[1]
    15. img = cv2.resize(img, (img_size, img_size))
    16. mean = [0.485, 0.456, 0.406]
    17. std = [0.229, 0.224, 0.225]
    18. mean = np.array(mean).reshape((1, 1, -1))
    19. std = np.array(std).reshape((1, 1, -1))
    20. out_img = (img / 255.0 - mean) / std
    21. out_img = out_img.astype('float32').transpose((2, 0, 1))
    22. img = out_img #np.transpose(out_img, (2,0,1))
    23. im_shape = [H, W]
    24. batch_data.append((image_name.split('.')[0], img, im_shape))
    25. if len(batch_data) == batch_size:
    26. yield make_test_array(batch_data)
    27. batch_data = []
    28. return reader
    • 定义绘制预测框的画图函数,代码如下。
    1. # 定义画图函数
    2. INSECT_NAMES = ['Boerner', 'Leconte', 'Linnaeus',
    3. 'acuminatus', 'armandi', 'coleoptera', 'linnaeus']
    4. # 定义画矩形框的函数
    5. def draw_rectangle(currentAxis, bbox, edgecolor = 'k', facecolor = 'y', fill=False, linestyle='-'):
    6. # currentAxis,坐标轴,通过plt.gca()获取
    7. # bbox,边界框,包含四个数值的list, [x1, y1, x2, y2]
    8. # edgecolor,边框线条颜色
    9. # facecolor,填充颜色
    10. # fill, 是否填充
    11. # linestype,边框线型
    12. # patches.Rectangle需要传入左上角坐标、矩形区域的宽度、高度等参数
    13. rect=patches.Rectangle((bbox[0], bbox[1]), bbox[2]-bbox[0]+1, bbox[3]-bbox[1]+1, linewidth=1,
    14. edgecolor=edgecolor,facecolor=facecolor,fill=fill, linestyle=linestyle)
    15. currentAxis.add_patch(rect)
    16. # 定义绘制预测结果的函数
    17. def draw_results(result, filename, draw_thresh=0.5):
    18. plt.figure(figsize=(10, 10))
    19. im = imread(filename)
    20. plt.imshow(im)
    21. currentAxis=plt.gca()
    22. colors = ['r', 'g', 'b', 'k', 'y', 'c', 'purple']
    23. for item in result:
    24. box = item[2:6]
    25. label = int(item[0])
    26. name = INSECT_NAMES[label]
    27. if item[1] > draw_thresh:
    28. draw_rectangle(currentAxis, box, edgecolor = colors[label])
    29. plt.text(box[0], box[1], name, fontsize=12, color=colors[label])
    • 使用上面定义的single_image_data_loader函数读取指定的图片,输入网络并计算出预测框和得分,然后使用多分类非极大值抑制消除冗余的框。将最终结果画图展示出来。