继上篇《基于Tensorflow2的YOLOV4 网络结构及代码解析(2)——NECK部分》博文后继续解析yoloV4的yolohead
本篇博客主要介绍两个个方面:
在进入yolo_head之前,先看一下源码中的参数配置以及一些语法细节,代码如下:
if self.eager:
self.input_image_shape = Input([2,],batch_size=1)
inputs = [*self.yolo_model.output, self.input_image_shape]
outputs = Lambda(yolo_eval, output_shape=(1,), name='yolo_eval',arguments={'anchors': self.anchors, 'num_classes': len(self.class_names), 'image_shape': self.model_image_size, 'score_threshold': self.score, 'eager': True, 'max_boxes': self.max_boxes})(inputs)
self.yolo_model = Model([self.yolo_model.input, self.input_image_shape], outputs)
这段代码中,有几个值得注意的地方:
1.inputs = [*self.yolo_model.output, self.input_image_shape]中“*”的用法:
*号的意思表示将列表解开,当作独立的参数传入函数。**的意思是将字典解开,当作独自的参数传入函数。上面代码意思就是将3个model.output的Tensor和Input生成的Tensor组成inputs列表
2."Lambda"的用法:
此处的Lambad不是python自带的lambda语法。他更应该理解为自定义层的一种简便写法,生成层对象,适用于简单的操作。因此,源码可以理解为将诸如anchors,num_class,image_shape等参数传给yolo_eval函数,得到1维结果
3.eager模式:
tf1.5之后引入eager模式,到了tf2之后默认采用eager模式。之前调试tf的时候,需要先构建好完整的图后再run.这样debug的时候极其麻烦,更不利于自定义层的创建。而通过eager模式,做一步便可以看到结果,调试难度大大降低。
完成传参后进入“yolo_eval"函数,该函数实现了解码,非极大值抑制,门限删选等一系列工作。
for l in range(num_layers):
_boxes, _box_scores =yolo_boxes_and_scores(yolo_outputs[l],anchors[anchor_mask[l]], num_classes, input_shape, image_shape)
该函数中调用了”yolo_head“和”yolo_correct_boxes“。它们的作用是将特征图解码和对应到原图上的位置和尺寸。
该函数代码中,利用变量anchor_mask对anchor进行配置(可能为了美观,在anchor初始时未调整顺序)。anchor与特征图相对于关系为:
# 13x13的特征层对应的anchor是[142, 110], [192, 243], [459, 401]
# 26x26的特征层对应的anchor是[36, 75], [76, 55], [72, 146]
# 52x52的特征层对应的anchor是[12, 16], [19, 36], [40, 28]
box_xy, box_wh, box_confidence, box_class_probs = yolo_head(feats, anchors, num_classes, input_shape)
函数"yolo_head"做了大量的维度转换,故直接在代码注解中逐步分析。
def yolo_head(feats, anchors, num_classes, input_shape, calc_loss=False):
#num_anchors=3
num_anchors = len(anchors)
#转换为tensor类型。实际测试发现没必要转换为tensor,因为传入的feats数据类型就是tensor
feats = tf.convert_to_tensor(feats)
anchors_tensor = K.reshape(K.constant(anchors), [1, 1, 1, num_anchors, 2])
#---------------------------------------------------#
#获取grid的宽和高
#---------------------------------------------------#
grid_shape = K.shape(feats)[1:3] # height, width
#对grid_y进行维度编号,得到(13,13,1,1)
grid_y = K.tile(K.reshape(K.arange(0, stop=grid_shape[0]), [-1, 1, 1, 1]),
[1, grid_shape[1], 1, 1])
#对grid_x进行维度编号,得到(13,13,1,1)
grid_x = K.tile(K.reshape(K.arange(0, stop=grid_shape[1]), [1, -1, 1, 1]),
[grid_shape[0], 1, 1, 1])
#对grid 进行维度编号,得到(13,13,1,2)
grid = K.concatenate([grid_x, grid_y])
grid = K.cast(grid, K.dtype(feats))
#---------------------------------------------------#
# 将预测结果调整成(batch_size,13,13,3,85)
# 85可拆分成4 + 1 + 80
# 4代表的是中心宽高的调整参数
# 1代表的是框的置信度
# 80代表的是种类的置信度
#---------------------------------------------------#
feats = K.reshape(feats, [-1, grid_shape[0], grid_shape[1], num_anchors, num_classes + 5])
上述代码中有几点细节需要注意:
1.Tensor和EagerTensor:笔者在写文档过程中本来想获取Tensor中的具体指,以便更直断的进行分析。而在此过程中发现,若数据类型为Tensor时,仅仅可以获取维度而无法获取具体的值。查看tf官网发现,若数据类型为EagerTensor时,可获取Value,而Tensor只有shape和name两个属性。个人理解,应该将Tensor理解为一个操作或者一个占位符更加合适,而不是一个数据对象。(尝试网上说的如tf.session(),numpy()等方法,均无效)
2.K.tile的用法:用于tensor的扩展。input为输入维度,multiples为扩张倍数。注:扩张维度必须与input维度相同,扩张倍数一一对应
tf.tile(
input,
multiples,
name=None
)
3.K.concatenate的用法:tf.keras.backend.concatenate与tf.concat等价。相对维度做连接。axis默认为-1。也就是在这里使用的。直观的理解是grid_x表示13*13个x,grid_y表示13*13个y。连接起来后表示13*13个【x,y】,这样就可以表示每个grid的位置。
#---------------------------------------------------#
# 将预测值调成真实值
# box_xy对应框的中心点
# box_wh对应框的宽和高
#---------------------------------------------------#
box_xy = (K.sigmoid(feats[..., :2]) + grid) / K.cast(grid_shape[...,::-1], K.dtype(feats))
box_wh = K.exp(feats[..., 2:4]) * anchors_tensor / K.cast(input_shape[...,::-1], K.dtype(feats))
box_confidence = K.sigmoid(feats[..., 4:5])
box_class_probs = K.sigmoid(feats[..., 5:])
#---------------------------------------------------------------------#
# 在计算loss的时候返回grid, feats, box_xy, box_wh
# 在预测的时候返回box_xy, box_wh, box_confidence, box_class_probs
#---------------------------------------------------------------------#
if calc_loss == True:
return grid, feats, box_xy, box_wh
return box_xy, box_wh, box_confidence, box_class_probs
上述代码为grid位置坐标的转换。网上有太多相关接受,故不作详解。这里唯一值得注意的是,获取位置偏移后,做了归一化处理。
对于初学者这个图也有一定的迷惑性质,可以把上图的每个网格想象成feature map上的一个点,则第一个像素对应的偏移为(0,0),第一行第二个偏移为(1,0)以此类推。图中标注的点偏移量为(1,1)。
yolo_head中转换为真实值时gride偏移相对于特征图尺寸做了归一化。
代码对于预测出的值进行了Sigmoid操作目的是为了让坐标值在0-1之间。
假设蓝色点为13*13的feature map 中的cell预测的中心点坐标为x,y,取sigmoid后其坐标为 (0.3, 0.5),则真实框在这个尺度上的中心点坐标值为(0.3+1, 0.5+1),映射到原图尺度为(1.3,1,5)*scale。参考(https://www.cnblogs.com/wangxinzhe/p/10648465.html)
整个yolo_head的输出值根据train与test有所不同。在train下,输出参数(grid, feats, box_xy, box_wh),Test下输出参数为(box_xy, box_wh, box_confidence, box_class_probs)
#-----------------------------------------------------------------#
# 在图像传入网络预测前会进行letterbox_image给图像周围添加灰条
# 因此生成的box_xy, box_wh是相对于有灰条的图像的
# 我们需要对齐进行修改,去除灰条的部分。
# 将box_xy、和box_wh调节成y_min,y_max,xmin,xmax
#-----------------------------------------------------------------#
boxes = yolo_correct_boxes(box_xy, box_wh, input_shape, image_shape)
上述函数将box_xy和box_wh对应到原始图像中。做了类似图像缩放,对应点偏移的工作,个人认为没有讨论必要,如有需要可私信我。
mask = box_scores >= score_threshold
利用预设的门限值,摒弃部分置信值较低的类别。
nms_index = tf.image.non_max_suppression(class_boxes, class_box_scores, max_boxes_tensor, iou_threshold=iou_threshold)
TF中自带非极大值抑制函数,可直接使用。
非极大值抑制的处理步骤如下”
1.遍历图片中所有识别出目标的类,对每个类进行单独分析
2.遍历某个类中所有的置信框
3.选出得分最大的置信框
4.去除与该置信框IOU超过阈值的框
5.继续遍历剩余的框,并重复步骤2-4.
api定义为:
tf.image.non_max_suppression(
boxes, scores, max_output_size, iou_threshold=0.5,
score_threshold=float('-inf'), name=None
)
官方解释:摒弃在之前选定框中高重合(IOU)框,Bounding box 提供形式为[y1,x1,y2,x2],其中(y1,x1)和(y2,x2)是对角线坐标并且进行归一化。
参数:
boxes: 二维Tensor,shape为[num_boxes,4]
scores:一维Tensor,shape[num_boxes]表示单个根据每个box获取的单个分数
max_output_size:标量,表示通过非极大值抑制可选择最大的Box个数
iou_threshold:iou阈值
score_threshold:根据分数移除box
经过NMS后,再进行维度堆叠,可获得最终结果。
到这里,已经将yoloV4整个模型全部解释完整,包括算法与相关源码的解析。个人认为,其中最为晦涩难懂的是yolohead部分,大量的维度转换以及坐标映射关系。