深入理解YOLO v3实现细节系列文章,是本人根据自己对YOLO v3原理的理解,结合开源项目tensorflow-yolov3,写的学习笔记。如有不正确的地方,请大佬们指出,谢谢!
目录
第1篇 数据预处理
第2篇 backbone&network
第3篇 构建v3的 Loss_layer
今天来讲讲YOLO v3的backbone——darknet53。YOLO每一代的提升很大一部分决定于backbone网络的提升,从v2的darknet-19到v3的darknet-53。本文主要讲解darknet-53的结构以及darknet-53的tf代码实现,下面切入正题。
1 backbone骨干
1.1Darknet-53的网络结构
Darknet-53 的主体框架如图所示,它主要由Convolutional
和Residual
结构所组成。可以看到Darknet-53没有池化层和全连接层。张量的尺寸变换是通过改变卷积核的步长来实现。需要特别注意的是,最后三层Avgpool
、Connected
和softmax
layer 是用于在Imagenet
数据集上作分类训练用的。当我们用 Darknet-53 层对图片提取特征时,是不会用到这三层的。
代码结构里的downsample
参数的意思是下采样,表示 feature map 输入该层 layer 后尺寸会变小。例如在第二层 layer 的输入尺寸是256X256
,输出尺寸则变成了128X128
,这就等于将图像边长缩小了一半(即面积缩小到原来的1/4)。 可以看到downsample出现了5次,特征图缩小到原输入尺寸的
1.2 Convolutional 结构
def convolutional(input_data, filters_shape, trainable, name, downsample=False, activate=True, bn=True):
with tf.variable_scope(name):
if downsample:
pad_h, pad_w = (filters_shape[0] - 2) // 2 + 1, (filters_shape[1] - 2) // 2 + 1
paddings = tf.constant([[0, 0], [pad_h, pad_h], [pad_w, pad_w], [0, 0]])
input_data = tf.pad(input_data, paddings, 'CONSTANT')
strides = (1, 2, 2, 1)
padding = 'VALID'
else:
strides = (1, 1, 1, 1)
padding = "SAME"
weight = tf.get_variable(name='weight', dtype=tf.float32, trainable=True,
shape=filters_shape, initializer=tf.random_normal_initializer(stddev=0.01))
conv = tf.nn.conv2d(input=input_data, filter=weight, strides=strides, padding=padding)
if bn:
conv = tf.layers.batch_normalization(conv, beta_initializer=tf.zeros_initializer(),
gamma_initializer=tf.ones_initializer(),
moving_mean_initializer=tf.zeros_initializer(),
moving_variance_initializer=tf.ones_initializer(), training=trainable)
else:
bias = tf.get_variable(name='bias', shape=filters_shape[-1], trainable=True,
dtype=tf.float32, initializer=tf.constant_initializer(0.0))
conv = tf.nn.bias_add(conv, bias)
if activate == True: conv = tf.nn.leaky_relu(conv, alpha=0.1)
return conv
对于if downsample
的情况,需要对图像进行padding。
input_data形状为[batch, height, width, channels],所以padding相对应也是4维。这里只对height,和width进行padding,batch和channels维度对应padding的大小设为0就ok了。对于else的情况,padding设置为"SAME",步长设为1。BN层这里不再多说了。默认使用的激活函数是leaky_relu。简单说一下leaky_relu激活函数。ReLU是将所有的负值都设为零,相反地,Leaky ReLU是给所有负值赋予一个非零斜率。以数学的方式我们可以表示为:
其中,ai是(1,+∞)区间内的固定参数。
1.3 Residual 残差模块
借鉴了ResNet的残差结构,使得网络结构更深,从v2的darknet-19上升到v3的darknet-53。
残差模块最显著的特点是使用了 short cut
机制(有点类似于电路中的短路机制)来缓解在神经网络中增加深度带来的梯度消失问题,从而使得神经网络变得更容易优化。它通过恒等映射(identity mapping)的方法使得输入和输出之间建立了一条直接的关联通道,从而使得网络集中学习输入和输出之间的残差。
def residual_block(input_data, input_channel, filter_num1, filter_num2, trainable, name):
short_cut = input_data
with tf.variable_scope(name):
input_data = convolutional(input_data, filters_shape=(1, 1, input_channel, filter_num1),
trainable=trainable, name='conv1')
input_data = convolutional(input_data, filters_shape=(3, 3, filter_num1, filter_num2),
trainable=trainable, name='conv2')
residual_output = input_data + short_cut
return residual_output
1个残差结构包含2个convolutional层,第一层的卷积核是1×1,第二层的卷积核是3×3。
1.4 upsample上采样模块
def upsample(input_data, name, method="deconv"):
assert method in ["resize", "deconv"]
if method == "resize":
with tf.variable_scope(name):
input_shape = tf.shape(input_data)
output = tf.image.resize_nearest_neighbor(input_data, (input_shape[1] * 2, input_shape[2] * 2))
if method == "deconv":
# replace resize_nearest_neighbor with conv2d_transpose To support TensorRT optimization
numm_filter = input_data.shape.as_list()[-1]
output = tf.layers.conv2d_transpose(input_data, numm_filter, kernel_size=2, padding='same',
strides=(2,2), kernel_initializer=tf.random_normal_initializer())
return output
定义的上采样函数input_data的形状为[batch, height, width, channels]。然后给出了2种方式,第1种方式是resize,使用tf.image.resize_nearest_neighbor函数,输出的大小尺寸为height2, width*2。第2钟方式是deconv,顾名思义是反卷积。先获取numm filter滤波器的数量,与input_data的channels相同,使用tf.layers.conv2d_transpose函数进行反卷积,padding方式为SAME,卷积核大小尺寸为2,步长设置为2。假设输入的图片尺寸为416×416,经过5次下采样,feature map的大小为13×13×1024。那么经过上采样,输出大小为26×26×1024。
1.5 route模块
def route(name, previous_output, current_output):
with tf.variable_scope(name):
output = tf.concat([current_output, previous_output], axis=-1) #axis=-1表示倒数第1个维度
return output
这个模块用于YOLO v3网络中的合并部分。
接下来,我们来看看YOLO v3是怎么样预测的
2. network网络
2.1 YOLOv3 的网格思想
YOLOv3 对输入图片进行了粗、中和细网格划分,以便分别实现对大、中和小物体的预测。其实在下面这幅图里面,每一个网格对应的就是一块 ROI 区域。如果某个物体的中心刚好落在这个网格中,那么这个网格就负责预测这个物体。
If the center of an object falls into a grid cell, that grid cell is responsible for detecting that object.
假如输入图片的尺寸为 416X416
, 那么得到粗、中和细网格尺寸分别为 13X13
、26X26
和52X52
。这样一算,那就是在长宽尺寸上分别缩放了32
、16
和8
倍,其实这些倍数正好也是这些 ROI 的尺寸大小。
2.2 准备图片
在将图片输入模型之前,需要将图片尺寸 resize 成固定的大小,如 416X416 或 608X608 。如果直接对图片进行 resize 处理,那么会使得图片扭曲变形从而降低模型的预测精度。
def image_preporcess(image, target_size, gt_boxes=None):
ih, iw = target_size # resize 尺寸
h, w, _ = image.shape # 原始图片尺寸
scale = min(iw/w, ih/h)
nw, nh = int(scale * w), int(scale * h) # 计算缩放后图片尺寸
image_resized = cv2.resize(image, (nw, nh))
# 制作一张画布,画布的尺寸就是我们想要的尺寸
image_paded = np.full(shape=[ih, iw, 3], fill_value=128.0)
dw, dh = (iw - nw) // 2, (ih-nh) // 2
# 将缩放后的图片放在画布中央
image_paded[dh:nh+dh, dw:nw+dw, :] = image_resized
image_paded = image_paded / 255.
if gt_boxes is None:
return image_paded
else: # 训练网络时需要对 groudtruth box 进行矫正
gt_boxes[:, [0, 2]] = gt_boxes[:, [0, 2]] * scale + dw
gt_boxes[:, [1, 3]] = gt_boxes[:, [1, 3]] * scale + dh
return image_paded, gt_boxes
来看看效果,直接上图!
2.3 网络输出
下面这幅图就是 YOLOv3 网络的整体结构,在图中我们可以看到:尺寸为 416X416 的输入图片进入 Darknet-53 网络后得到了 3 个分支,这些分支在经过一系列的卷积、上采样以及合并等操作后最终得到了三个尺寸不一的 feature map,形状分别为 [13, 13, 255]、[26, 26, 255] 和 [52, 52, 255]。
为了让yolo_v3结构图更好理解,对上图做一些补充解释:
DBL: DBL = conv + BN + Leaky relu. 上图左下角所示,就是文章前面介绍过的Convolutional 结构,YOLO v3的基本组件。对于v3来说,BN和leaky relu已经是和卷积层不可分离的部分了(最后一层卷积除外),共同构成了最小组件。
res unit:上图下方所示,文章前面介绍过的Residual 残差模块,也是YOLO v3的基本组件。
resn:n代表数字,有res1,res2, res4, res8,表示这个res_block里含有多少个res_unit。例如res8,表示这个res_block里有8个res_unit。这是yolo_v3的大组件,对于res_block的解释,可以在上图的右下角直观看到,基本组件也是DBL。
concat:张量拼接。将darknet中间层和后面的某一层的上采样进行拼接。拼接的操作和残差层add的操作是不一样的,拼接会扩充张量的维度,而add只是直接相加不会导致张量维度的改变。
讲了这么多,还是不如看代码来得亲切。
def __build_nework(self, input_data):
# 输入层进入 Darknet-53 网络后,得到了三个分支
route_1, route_2, input_data = backbone.darknet53(input_data, self.trainable)
# 见上图中的橘黄色模块(DBL),一共需要进行5次卷积操作
input_data = common.convolutional(input_data, (1, 1, 1024, 512), self.trainable, 'conv52')
input_data = common.convolutional(input_data, (3, 3, 512, 1024), self.trainable, 'conv53')
input_data = common.convolutional(input_data, (1, 1, 1024, 512), self.trainable, 'conv54')
input_data = common.convolutional(input_data, (3, 3, 512, 1024), self.trainable, 'conv55')
input_data = common.convolutional(input_data, (1, 1, 1024, 512), self.trainable, 'conv56')
# 橘黄色模块(DBL)
conv_lobj_branch = common.convolutional(input_data, (3, 3, 512, 1024), self.trainable, name='conv_lobj_branch')
# 蓝色模块(conv)
conv_lbbox = common.convolutional(conv_lobj_branch, (1, 1, 1024, 3*(self.num_class + 5)),
trainable=self.trainable, name='conv_lbbox', activate=False, bn=False)
输出y1,shape = [None, 13, 13, 255]。conv_lbbox 用于预测大尺寸物体(参考2.1网格思想中的细网格图片)。
input_data = common.convolutional(input_data, (1, 1, 512, 256), self.trainable, 'conv57')
# 这里的 upsample 使用的是最近邻插值方法,这样的好处在于上采样过程不需要学习,从而减少了网络参数
input_data = common.upsample(input_data, name='upsample0', method=self.upsample_method)
with tf.variable_scope('route_1'):
input_data = tf.concat([input_data, route_2], axis=-1)
input_data = common.convolutional(input_data, (1, 1, 768, 256), self.trainable, 'conv58')
input_data = common.convolutional(input_data, (3, 3, 256, 512), self.trainable, 'conv59')
input_data = common.convolutional(input_data, (1, 1, 512, 256), self.trainable, 'conv60')
input_data = common.convolutional(input_data, (3, 3, 256, 512), self.trainable, 'conv61')
input_data = common.convolutional(input_data, (1, 1, 512, 256), self.trainable, 'conv62')
# 橘黄色模块(DBL)
conv_mobj_branch = common.convolutional(input_data, (3, 3, 256, 512), self.trainable, name='conv_mobj_branch' )
# 蓝色模块(conv)
conv_mbbox = common.convolutional(conv_mobj_branch, (1, 1, 512, 3*(self.num_class + 5)),
trainable=self.trainable, name='conv_mbbox', activate=False, bn=False)
输出y2,shape = [None, 26, 26, 255]。conv_mbbox 用于预测中等尺寸物体(参考2.1网格思想中的中网格图片)。
input_data = common.convolutional(input_data, (1, 1, 256, 128), self.trainable, 'conv63')
input_data = common.upsample(input_data, name='upsample1', method=self.upsample_method)
with tf.variable_scope('route_2'):
input_data = tf.concat([input_data, route_1], axis=-1)
input_data = common.convolutional(input_data, (1, 1, 384, 128), self.trainable, 'conv64')
input_data = common.convolutional(input_data, (3, 3, 128, 256), self.trainable, 'conv65')
input_data = common.convolutional(input_data, (1, 1, 256, 128), self.trainable, 'conv66')
input_data = common.convolutional(input_data, (3, 3, 128, 256), self.trainable, 'conv67')
input_data = common.convolutional(input_data, (1, 1, 256, 128), self.trainable, 'conv68')
# 橘黄色模块(DBL)
conv_sobj_branch = common.convolutional(input_data, (3, 3, 128, 256), self.trainable, name='conv_sobj_branch')
# 蓝色模块(conv)
conv_sbbox = common.convolutional(conv_sobj_branch, (1, 1, 256, 3*(self.num_class + 5)),
trainable=self.trainable, name='conv_sbbox', activate=False, bn=False)
输出y3,shape = [None, 52, 52, 255]。conv_sbbox 用于预测中等尺寸物体(参考2.1网格思想中的粗网格图片)。
return [conv_sbbox, conv_mbbox, conv_lbbox]
yolo v3输出了3个不同尺度的feature map,如上图所示的y1, y2, y3。这也是v3论文中提到的为数不多的改进点:predictions across scales。这个借鉴了FPN(feature pyramid networks),采用多尺度来对不同size的目标进行检测,越精细的grid cell就可以检测出越精细的物体。y1,y2和y3的深度都是255,边长的规律是13:26:52。
对于COCO类别而言,有80个种类,所以每个box应该对每个种类都输出一个概率。yolo v3设定的是每个网格单元预测3个box,所以每个box需要有(x, y, w, h, confidence)五个基本参数,还要有80个类别的概率。所以3*(5 + 80) = 255。这个255就是这么来的。(还记得yolo v1的输出张量吗? 7x7x30,只能识别20类物体,而且每个cell只能预测2个box)
YOLO v3用上采样的方法来实现这种多尺度的feature map,concat连接的两个张量是具有一样尺度的(两处拼接分别是26x26尺度拼接和52x52尺度拼接,通过(2, 2)上采样来保证concat拼接的张量尺度相同)。
2.4 边界框的预测
如果物体的中心落在了这个网格里,那么这个网格就要负责去预测它。在下面这幅图里:黑色虚线框代表先验框(anchor),蓝色框表示的是预测框。
- b_h 和 b_w 分别表示预测框的长宽,P_h 和 P_w 分别表示先验框的长和宽。
- t_x 和 t_y 表示的是物体中心距离网格左上角位置的偏移量,C_x 和 C_y 则代表网格左上角的坐标。
接下来,按照上面的公式,定义decode函数。
def decode(self, conv_output, anchors, stride):
"""
return tensor of shape [batch_size, output_size, output_size, anchor_per_scale, 5 + num_classes]
contains (x, y, w, h, score, probability)
"""
# stride 分别对应三种网格尺度
# conv_output的形状为[batch_size, output_size, output_size, anchor_per_scale * (5 + num_classes)]
conv_shape = tf.shape(conv_output)
batch_size = conv_shape[0]
output_size = conv_shape[1]
anchor_per_scale = len(anchors) # 3
conv_output = tf.reshape(conv_output, (batch_size, output_size, output_size, anchor_per_scale, 5 + self.num_class))
conv_raw_dxdy = conv_output[:, :, :, :, 0:2] # 中心位置的偏移量
conv_raw_dwdh = conv_output[:, :, :, :, 2:4] # 预测框长宽的偏移量
conv_raw_conf = conv_output[:, :, :, :, 4:5]
conv_raw_prob = conv_output[:, :, :, :, 5: ]
# 好了,接下来需要画网格了。其中,output_size 等于 13、26 或者 52
y = tf.tile(tf.range(output_size, dtype=tf.int32)[:, tf.newaxis], [1, output_size])
x = tf.tile(tf.range(output_size, dtype=tf.int32)[tf.newaxis, :], [output_size, 1])
xy_grid = tf.concat([x[:, :, tf.newaxis], y[:, :, tf.newaxis]], axis=-1)
xy_grid = tf.tile(xy_grid[tf.newaxis, :, :, tf.newaxis, :], [batch_size, 1, 1, anchor_per_scale, 1])
xy_grid = tf.cast(xy_grid, tf.float32) # 计算网格左上角的位置,即C_x和C_y
# 根据上图公式计算预测框的中心位置
pred_xy = (tf.sigmoid(conv_raw_dxdy) + xy_grid) * stride
# 根据上图公式计算预测框的长和宽大小
pred_wh = (tf.exp(conv_raw_dwdh) * anchors) * stride
# 合并边界框的位置和长宽信息
pred_xywh = tf.concat([pred_xy, pred_wh], axis=-1)
pred_conf = tf.sigmoid(conv_raw_conf) # 计算预测框里object的置信度
pred_prob = tf.sigmoid(conv_raw_prob) # 计算预测框里object的类别概率
return tf.concat([pred_xywh, pred_conf, pred_prob], axis=-1)
输出预测框bboxes[batch_size, output_size, output_size, anchor_per_scale, 5 (x, y, w, h, score)+num_classes(probability)]
2.5 NMS处理
对预测框bboxes进行非极大值抑制(NMS)处理,选取最好的best_bboxes作为最终的预测框输出。
NMS算法流程:
非极大值抑制(Non-Maximum Suppression,NMS),顾名思义就是抑制不是极大值的元素,说白了就是去除掉那些重叠率较高并且 score 评分较低的边界框。 NMS 的算法非常简单,迭代流程如下:
- 流程1: 判断边界框的数目是否大于0,如果不是则结束迭代;
- 流程2: 按照 socre 排序选出评分最大的边界框 A 并取出;
- 流程3: 计算这个边界框 A 与剩下所有边界框的 iou 并剔除那些 iou 值高于阈值的边界框,重复上述步骤;
# 流程1: 判断边界框的数目是否大于0
while len(cls_bboxes) > 0:
# 流程2: 按照 socre 排序选出评分最大的边界框 A
max_ind = np.argmax(cls_bboxes[:, 4])
# 将边界框 A 取出并剔除
best_bbox = cls_bboxes[max_ind]
best_bboxes.append(best_bbox)
cls_bboxes = np.concatenate([cls_bboxes[: max_ind], cls_bboxes[max_ind + 1:]])
# 流程3: 计算这个边界框 A 与剩下所有边界框的 iou 并剔除那些 iou 值高于阈值的边界框
iou = bboxes_iou(best_bbox[np.newaxis, :4], cls_bboxes[:, :4])
weight = np.ones((len(iou),), dtype=np.float32)
iou_mask = iou > iou_threshold
weight[iou_mask] = 0.0
cls_bboxes[:, 4] = cls_bboxes[:, 4] * weight
score_mask = cls_bboxes[:, 4] > 0.
cls_bboxes = cls_bboxes[score_mask]
最后所有取出来的边界框 A 就是我们想要的。不妨举个简单的例子:假如5个边界框及评分为: A: 0.9,B: 0.08,C: 0.8, D: 0.6,E: 0.5,设定的评分阈值为 0.3,计算步骤如下。
- 步骤1: 边界框的个数为5,满足迭代条件;
- 步骤2: 按照 socre 排序选出评分最大的边界框 A 并取出;
- 步骤3: 计算边界框 A 与其他 4 个边界框的 iou,假设得到的 iou 值为:B: 0.1,C: 0.7, D: 0.02, E: 0.09, 剔除边界框 C;
- 步骤4: 现在只剩下边界框 B、D、E,满足迭代条件;
- 步骤5: 按照 socre 排序选出评分最大的边界框 D 并取出;
- 步骤6: 计算边界框 D 与其他 2 个边界框的 iou,假设得到的 iou 值为:B: 0.06,E: 0.8,剔除边界框 E;
- 步骤7: 现在只剩下边界框 B,满足迭代条件;
- 步骤8: 按照 socre 排序选出评分最大的边界框 B 并取出;
- 步骤9: 此时边界框的个数为零,结束迭代。
最后我们得到了边界框 A、B、D,但其中边界框 B 的评分非常低,这表明该边界框是没有物体的,因此应当抛弃掉。在 postprocess_boxes 代码中,
# # (5) discard some boxes with low scores
classes = np.argmax(pred_prob, axis=-1)
scores = pred_conf * pred_prob[np.arange(len(pred_coor)), classes]
score_mask = scores > score_threshold
在 YOLO 算法中,NMS 的处理有两种情况:一种是所有的预测框一起做 NMS 处理,另一种情况是分别对每个类别的预测框做 NMS 处理。后者会出现一个预测框既属于类别 A 又属于类别 B 的现象,这比较适合于一个小单元格中同时存在多个物体的情况。
补充
为了更好地理解2.4边界框的预测的xy_grid网格和pred_xy输出,举个简单的例子,3×3网格
output_size = 3 #假设输出的网格为3
batch_size = 1
anchor_per_scale = 3
y = tf.tile(tf.range(output_size, dtype=tf.int32)[:, tf.newaxis], [1, output_size])
x = tf.tile(tf.range(output_size, dtype=tf.int32)[tf.newaxis, :], [output_size, 1])
xy_grid = tf.concat([x[:, :, tf.newaxis], y[:, :, tf.newaxis]], axis=-1)
xy_grid1 = tf.tile(xy_grid[tf.newaxis, :, :, tf.newaxis, :], [batch_size, 1, 1, anchor_per_scale, 1])
xy_grid2 = tf.cast(xy_grid1, tf.float64)
sess = tf.Session()
print('y:n',sess.run(y))
print('x:n',sess.run(x))
print('xy_grid:n',sess.run(xy_grid))
print('xy_grid1:n',sess.run(xy_grid1))
xy_grid网格输出:
y: x: xy_grid:
[[0 0 0] [[0 1 2] [[[0 0] [[0 1] [[0 2]
[1 1 1] [0 1 2] [1 0] [1 1] [1 2]
[2 2 2]] [0 1 2]] [2 0]] [2 1]] [2 2]]]
pred_xy的计算:
conv_output = np.ones([1,3,3,1,25])
conv_output[:, :, :, :, 0] = 1 # 假设偏移量dx = 1
conv_output[:, :, :, :, 1] = 2 # 假设偏移量dy = 2
conv_raw_dxdy = conv_output[:, :, :, :, 0:2]
pred_xy = (tf.sigmoid(conv_raw_dxdy) + xy_grid2) * stride
print('pred_xy:n',sess.run(pred_xy))
pred_xy输出:
[[[[[23.39387452 28.1855065 ]] [[[23.39387452 60.1855065 ]] [[[23.39387452 92.1855065 ]]
[[55.39387452 28.1855065 ]] [[55.39387452 60.1855065 ]] [[55.39387452 92.1855065 ]]
[[87.39387452 28.1855065 ]]] [[87.39387452 60.1855065 ]]] [[87.39387452 92.1855065 ]]]]]
可以看出pred_xy对每一个网格都有1个坐标输出
tf.pad函数的用法:
TensorFlow填充张量函数:tf.pad_w3cschoolwww.w3cschool.cntf.image.resize_nearest_neighbor函数的用法:
TensorFlow函数:tf.image.resize_nearest_neighbor_w3cschoolwww.w3cschool.cntf.layers.conv2d_transpose函数的用法:
TensorFlow函数:tf.layers.conv2d_transpose_w3cschoolwww.w3cschool.cntf.concat函数用法:
将TensorFlow张量沿一个维度串联_w3cschoolwww.w3cschool.cn参考文章:
https://blog.csdn.net/leviopku/article/details/82660381blog.csdn.net谢谢观看,觉得好就点个赞呗!