YOLOv4-tiny(三)锚框解码
Tags: YOLO, python, 图像识别, 深度学习, 目标检测
Created: May 14, 2024 4:36 PM
Last edited time: May 18, 2024 10:17 AM
Status: Done
一、前言
本期讲一下关于YOLOv4-tiny中锚框解码部分的内容。对于想了解非极大值抑制的过程、以及预测结果如何转换到图片上的真实预测框的可以参考。
1.1 锚框解码的目的
锚框解码的目的是与YOLO模型的思想是息息相关的,YOLO模型采取的思想就是利用锚框来进行位置预测和种类预测,因此我们得到的大量锚框数据要进行筛选和还原。
用直观点理解的方式说,一张图片(416x416)经模型特征提取后形成特征图,在其中一个特征图尺度上(例如13x13),每个像素点上都包含三个预设锚框,对该图片的该特征图共计有13x13x3=507个锚框,每个锚框包含“xywhc+num_classes”共计85个特征属性(锚框中心坐标,锚框宽与高,锚框内包含目标的置信度,以及锚框内包含num_classes种类别的每个类别的置信度),在这些锚框以及附带的信息中,我们需要进行筛选,因为有些锚框中是没有目标的,或者说目标的置信度值较低,这样的锚框我们要舍弃,另外,也存在着好多个锚框对同一目标进行识别的问题,如果最终都显示在画面上,将会看起来很乱,因此对这类锚框我们要进行非极大值抑制,筛选出最符合预测结果的锚框,其余的就直接舍弃掉了。
锚框解码本质上就是三步内容:一是对模型输出的结果结合预设的先验框转换成预测框,二是对预测框结果进行筛选,采用非极大值抑制的方法(Non-Maximum Suppression,NMS),三是将最终预测框结果还原至原图的尺度上。锚框解码程序存放在utils工具文件下的utils_bbox.py
程序中。程序中构建了Decodebox类,并定义了decode_box
、yolo_correct_boxes
、non_max_suppression
三个方法。
关于YOLOv4-tiny其他部分可以参考往期内容:
模型构建与训练实现参考:YOLOv4-tiny(一)模型构建与训练实现-CSDN博客
数据构建与数据增强参考:YOLOv4-tiny(二)数据构建与数据增强-CSDN博客
二、锚框解码
锚框解码过程通过在程序中构建了Decodebox类,并定义了decode_box
、yolo_correct_boxes
、non_max_suppression
三个方法,每个方法起到不同作用,
2.1 锚框解码方法:decode_box
锚框解码本质上是将经模型处理后的特征图数据转换为直观理解的预测框数据。
- 图片经模型处理后输出两路特征图(13x13和26x26),每个特征图包括c路通道,涵盖预测框的调整属性和种类属性(预测框的调整属性是对先验框的调整参数,并非是预测框本身的属性)。因此锚框解码就是将这些数据转换成最终的预测框属性。
- 简单理解这个过程是:我们先预设了先验框(只对宽高值预设,无中心值),并在特征图上设立的网格(网格点即为每个先验框的中心),在每个网格点上放置3个预设的先验框。通过网格坐标和特征图通道属性中的xy值相加得到预测框的中心值(相当于输出的通道是xy是相对网格的偏差值),通过先验框本身的宽高值和特征图通道属性中的wh值的自然指数两者相乘,从而得到预测框的宽高值。通过上述运算即可得到预测框的xywh属性。另外预测框位置置信度conf和种类置信度pred_cls从输出通道中直接得到。
- 直观理解,一个特征图上包含13x13x3=507个预测框,每个预测框都包含xywhc+种类(80个)共计85特征。并不是每个预测框内都有物体,我们可以根据每个预测框的特征来判断和筛选。
- 在本方法的最后,对预测框的xywh属性进行了归一化处理,即x和w除以特征图的宽,y和h除以特征图的高,数据归一化可以使得预测框同步至原输入尺寸(416,416)尺度上。
- 本方法最终得到预测框的数据存储在output张量中,对于2个特征图来说,每个预测结果的张量其形状为
[batch_size, 507, 85]
或[batch_size, 2028, 85]
,并存储在outputs列表中,即outputs=[[batch_size, 507, 85],[batch_size, 2028, 85]]
。后续筛选过程再调用非极大值抑制方法。应该注意的是,在筛选前,对于同一张图片来说,其不管在小的特征图还是大的特征图上进行预测筛选,可以将同一张图片的预测框进行合并筛选,具体操作是利用torch.cat(outputs, 1)
,将张量列表中的元素1和元素2在第二维进行合并,合并后的张量形状为[batch_size, 2535, 85]
-
源码
def decode_box(self, inputs): outputs = [] for i, input in enumerate(inputs): # -------------------------------# # 输入的input共2个,他们的shape分别是: # batch_size, 3x(5+80)=255, 13, 13 # batch_size, 3x(5+80)=255, 26, 26 # 具体可以看模型结构net_yolo中的输出端形状 # -------------------------------# # 获取批次大小,和特征图的高和宽 batch_size = input.size(0) input_height = input.size(2) input_width = input.size(3) # 获取先验框由原图到特征图尺度下的缩放比例 stride_h = input_height / self.input_shape[0] stride_w = input_width / self.input_shape[1] # 获取在特征图尺度下的先验框大小 scaled_anchors = [(anchor_width * stride_w, anchor_height * stride_h)for anchor_width, anchor_height in self.anchors[self.anchors_mask[i]]] # -----------------------------------------# # 将输入的input张量转换形状 # 利用view方法,只改变形状,不改变元素的物理顺序,再利用permute调整维度顺序 # -----------------------------------------# prediction = input.view(batch_size, len(self.anchors_mask[i]), self.bbox_attrs, input_height, input_width).permute(0, 1, 3, 4, 2).contiguous() # 先验框的中心位置调整参数 x = torch.sigmoid(prediction[..., 0]) y = torch.sigmoid(prediction[..., 1]) # 先验框的宽高调整参数 w = prediction[..., 2] h = prediction[..., 3] # 获得位置置信度,是否有物体 conf = torch.sigmoid(prediction[..., 4]) # 获得每个种类置信度 pred_cls = torch.sigmoid(prediction[..., 5:]) FloatTensor = torch.cuda.FloatTensor if x.is_cuda else torch.FloatTensor LongTensor = torch.cuda.LongTentor if x.is_cuda else torch.LongTensor # ------------------------------------------# # 生成网格,先验框的中心,每个网格的左上角 # 网格形状,batch_size, 3, 13, 13或batch, 3, 26, 26 # ------------------------------------------# grid_x = torch.linespace(0, input_width-1, input_width).repeat(input_height, 1).repeat( batch_size * len(self.anchors_mask[i]), 1, 1).view(x.shape).type(FloatTensor) grid_y = torch.linespace(0, input_height-1, input_height).repeat(input_width, 1).t().repeat( batch_size * len(self.anchors_mask[i]), 1, 1).view(y.shape).type(FloatTensor) # ------------------------------------------# # 按照网格格式生成先验框的宽高 # batch_size, 3, 13, 13或batch, 3, 26, 26 # ------------------------------------------# anchor_w = FloatTensor(scaled_anchors).index_select(1, LongTensor[0]) anchor_h = FloatTensor(scaled_anchors).index_select(1, LongTensor[1]) anchor_w = anchor_w.repeat(batch_size, 1).repeat(1, 1, input_height * input_width).view(w.shape) anchor_h = anchor_h.repeat(batch_size, 1).repeat(1, 1, input_height * input_width).view(h.shape) # ------------------------------------------# # 利用预测结果对先验框进行调整 # 首先调整先验框的中心,先验框中心向右下角调整 # 再调整先验框的宽高 # ------------------------------------------# pred_boxes = FloatTensor(prediction[..., :4].shape) pred_boxes[..., 0] = x.data + grid_x pred_boxes[..., 1] = y.data + grid_y pred_boxes[..., 2] = torch.exp(w.data) * anchor_w pred_boxes[..., 3] = torch.exp(h.data) * anchor_h # ------------------------------------------# # 将输出结果归一化成小数的形式 # 将预测框的中心和宽高分别除以特征图的宽高,相当于框与图的比例尺,进行归一化 # output 的shape=[batch_size, 3*13*13, 85]或shape=[batch_size, 3*26*26, 85] # ------------------------------------------# _scale = torch.Tensor([input_width, input_height, input_width, input_height]).type(FloatTensor) output = torch.cat((pred_boxes.view(batch_size, -1, 4) / _scale, conf.view(batch_size, -1, 1), pred_cls.view(batch_size, -1, self.num_classes)), -1) outputs.append(output.data) return outputs
2.2 非极大值抑制方法:non_max_suppression
非极大值抑制的本质是将所有得到的预测框进行筛选,只保留最优的结果。
- 首先要将预测框的前四个属性xywh转换为左上右下的形式。然后构建循环,遍历该批次中的每张图片的预测结果,每张图片的预测结果张量为
[2535, 85]
,即每张图片由2535个预测框,每个预测框用用85个特征属性,然后对该张量进行筛选处理。 - 首先将每个预测框的80种类别中最大置信度和其索引筛选出来,得到该预测框最可能的种类和预测值,然后利用置信度进行筛选,具体做法是“位置置信度×种类置信度>预设值”,通过对每个预测框进行筛选,将满足要求的预测框保留下来。代码上采取的方法是建立布尔掩码,布尔掩码中满足要求的预测框其为TRUE,不满足的为FALSE。然后用该布尔掩码进行筛选,只保留满足条件的预测框。此时张量形状为
[X, 85]
,X是2535筛选后剩余的预测框数量。 - 将张量进行转化,对于预测框来说,我们只关心该预测框最可能种类和预测值,其余种类和其预测值不重要,故进行张量转换,将张量的第二维元素进行删减。利用上述的布尔掩码和张量拼接方法得到新的张量detections,其形状为
[X, 7]
,其中7的内容为:x1, y1, x2, y2, obj_conf, class_conf, class_pred
。最后三个元素代表位置置信度、种类置信度、种类 - 在预测过程中,存在多个预测框对一个目标均满足上述预设值的要求,但是我们又不想将这些预测框都显示出来,会非常的乱,因此我们在这些多个预测框中选择一个最优的,最能代表这个目标识别结果的预测框。首先,我们要在detections张量中将识别的种类数提取出来,重复的种类合并,detections张量的第二维最后一个属性代表种类,我们利用切片和unique()方法提取出来,得到种类标签张量unique_labels,是一个一维张量。种类标签张量中的元素代表这张图片中可能的全部预测种类。
- 然后我们构建循环,遍历种类标签张量,例如当前种类为c,利用布尔索引筛选出detections中所有种类为c的预测框,然后调用非极大值抑制方法nms。nms的输入参数包括三个,一是预测框的x1y1x2y2属性,二是预测框的位置置信度和种类置信度的乘积,三是预设阈值,输出值为最优预测框的索引。这样一来对于种类c,我们在所有预测结果为c的预测框中找到了最优的预测框。(注:nms调用的官方库,其本质利用预测框之间的交并比,若两个预测框对同一个种类目前进行预测,其交并比肯定比较大,故要舍弃)
- 遍历所有种类后,得到这张图片每个可能种类的最优预测框,然后我们需要将这些预测框合并成一个张量,该张量即代表了这张图片的所有预测结果。然后需要对该张量进行操作,即再次转换为xywh的格式。但是此时的结果是相对特征图尺度上的数据,因此我们需要在调用方法
yolo_correct_boxes
,将预测框锚框还原至原始图片尺度。
-
源码
def non_max_suppression(self, prediction, num_classes, input_shape, image_shape, letterbox_image, conf_thres=0.5, nms_thres=0.4): # -----------------------------------------------# # 将预测结果转化为左上角右下角形式,并将预测张量的shape转化为[batch_size, num_anchors, 85], # 其中num_anchors代表总的先验框数量,每个像素点3个,对13x13特征图来说,共计507个 # -----------------------------------------------# box_corner = prediction.new(prediction.shape) box_corner[:, :, 0] = prediction[:, :, 0] - prediction[:, :, 2] / 2 box_corner[:, :, 1] = prediction[:, :, 1] - prediction[:, :, 3] / 2 box_corner[:, :, 2] = prediction[:, :, 0] + prediction[:, :, 2] / 2 box_corner[:, :, 3] = prediction[:, :, 1] + prediction[:, :, 3] / 2 prediction[:, :, :4] = box_corner[:, :, :4] output = [None for _ in range(len(prediction))] # 构建循环,针对每一张图片的预测结果进行处理 for i, image_pred in enumerate(prediction): # --------------------------------------------# # 将种类提取出来,80个种类选择种类置信度max和相应的种类(索引) # class_conf [num_anchors, 1] 种类中最大的置信度值 # class_pred [num_anchors, 1] 种类中最大的置信度的索引,即种类 # --------------------------------------------# class_conf, class_pred = torch.max(image_pred[:, 5:5 + num_classes], dim=1, keepdim=True) # --------------------------------------------# # 利用置信度进行第一轮筛选 # 本质上来说,对一张图片的预测结果包括num_anchors预测框,每个预测框均包含85个属性,xywhc+cls # 首先要找的每个预测框的最大种类置信度和其索引,即该框内的种类预测 # 然后再找到所有预测框中满足预设置信度的索引,用布尔索引完成该方法实现 # 判别式:位置置信度x种类置信度>=预设值 # --------------------------------------------# conf_mask = (image_pred[:, 4] * class_conf >= conf_thres).squeeze() # 根据置信度进行预测结果的筛选和保存 # 利用置信度布尔掩码进行筛选,保留满足要求的 image_pred = image_pred[conf_mask] class_conf = class_conf[conf_mask] class_pred = class_pred[conf_mask] # 判断该图片是否有预测结果,若无则循环重新开始 if not image_pred.size(0): continue # --------------------------------------------# # 将经过第一轮筛选的结果合并到一个张量detections中 # detections shape=[num_anchors, 7] # num_anchors为筛选后的数量 # 7代表x1, y1, x2, y2, obj_conf, class_conf, class_pred # --------------------------------------------# detections = torch.cat((image_pred[:, :5], class_conf.float(), class_pred.float()), 1) # --------------------------------------------# # 将预测结果中的所有预测种类提取出来,重复的种类合并 # --------------------------------------------# unique_labels = detections[:, -1].cpu().unique() if prediction.is_cuda: unique_labels = unique_labels.cuda() detections = detections.cuda() # 遍历预测种类,将对应类的全部预测结果提取,然后处理 # 举例来说,此时c代表狗,则利用布尔索引将所有预测框预测结果中含狗的提取出来,进行非极大值抑制 # 可能来说,对一个像素点上,3个预测框均预测出狗,但是我们只需要一个最准确的,因此其余2个要被抑制掉 for c in unique_labels: detections_class = detections[detections[:, -1] == c] # ------------------------------------------# # 使用官方自带的非极大抑制会速度更快一些! # # nms 函数是非极大值抑制(NMS)的实现。NMS 用于消除多个检测器可能产生的重叠或冗余的边界框。 # 它通常接受三个参数: # 边界框的坐标(通常是 [x1, y1, x2, y2] 的格式,其中 (x1, y1) 是左上角坐标,(x2, y2) 是右下角坐标)。 # 边界框的置信度得分(通常是一个表示模型对边界框内存在目标的确信程度的值)。 # 非极大值抑制的阈值(nms_thres),它决定了两个边界框的重叠程度需要达到多少才会被视为冗余。 # NMS 函数的返回值通常是一个整数数组(或类似的索引列表),这些整数对应于输入候选框数组中的索引, # 表示被保留的(非被抑制的)候选框。这些被保留的候选框通常是置信度较高且与其他候选框重叠较少的框。 # ------------------------------------------# keep = nms( detections_class[:, :4], detections_class[:, 4] * detections_class[:, 5], # 位置置信度x种类置信度 nms_thres ) # 根据nms结果,选着保留的预测框 max_detections = detections_class[keep] # ----------------------------------------------------------# # 这部分是另外的方法,自己编写nms # # 按照存在物体的置信度排序 # _, conf_sort_index = torch.sort(detections_class[:, 4]*detections_class[:, 5], descending=True) # detections_class = detections_class[conf_sort_index] # # 进行非极大抑制 # max_detections = [] # while detections_class.size(0): # # 取出这一类置信度最高的,一步一步往下判断,判断重合程度是否大于nms_thres,如果是则去除掉 # max_detections.append(detections_class[0].unsqueeze(0)) # if len(detections_class) == 1: # break # ious = bbox_iou(max_detections[-1], detections_class[1:]) # detections_class = detections_class[1:][ious < nms_thres] # # 堆叠 # max_detections = torch.cat(max_detections).data # ---------------------------------------------------------# # 将保留的预测框保存,因为是遍历所有种类,所以都要存储进去 # 根据output[i]的状态判断存储方式 # 如果是空的,就将max_detections直接添加进去 # 如果是非空,则将新的类别max_detections添加进去 # max_detections的shape=[num_anchors, 7],其中num_anchors数量又减少了,因为经历的nms # ---------------------------------------------------------# if output[i] is None: output[i] = max_detections else: torch.cat((output[i], max_detections)) # 因为遍历了batch_size的图片,所以针对每个图片的结果均要存储 if output[i] is not None: output[i] = output[i].cpu().numpy() # 将预测框由左上右下转换成中心宽高形式 box_xy, box_wh = (output[i][:, 0:2] + output[i][:, 2:4]) / 2, (output[i][:, 2:4] - output[i][:, 0:2]) # 注意:这里的box_xy和box_wh是归一化的数值,在方法def decode_box的最后已经进行了归一化处理 output[i][:, :4] = self.yolo_correct_boxes(box_xy, box_wh, input_shape, image_shape, letterbox_image) return output
2.3 预测锚框还原尺度方法:yolo_correct_boxes
预测锚框还原尺度方法本质上是将在特征图尺度上得到的所有预测框转换到原图尺度上的预测框,便于后续在原始图片上进行展示。
-
该方法最核心的部分就是对归一化的理解,在锚框解码方法decode_box的最后,我们对得到的所有预测框已经进行了归一化处理,也就是说预测框的预测框的xywh属性是相对特征图尺度上的,此处的归一化可以理解为比例关系,即x的值相对特征图宽度的比例。**这种归一化在输入图的宽高比和特征图宽高比一致的情况下是可以直接转化的。**假设x=0.3,也就是说预测框的中心横坐标为0.3*w,在宽度0.3比例的位置,因为从(416,416)到(13,13)宽高比一致,因此预测框在(416,416)的中心也在0.3比例位置。注意:(416,416)不代表原图尺寸,这个是模型的统一输入尺寸,原图的尺寸需要进行缩放填充才能满足(416,416)统一尺寸。
-
预测框由特征图尺度转化到输入图尺度后,下一步就要进行输入图input_shape到原图image_shape的转换了。输入图和原图的宽高比大概率是不同的,这里引入一个new_shape图,new_shape是原图经缩放后,使得new_shape宽或高的一条边与输入图宽或高尺寸保持一致,则另外的一条边将小于输入图,new_shape的宽高比与原图宽高比一致,因此两者上的归一化的预测框可以同步。将new_shape的左上角与输入图input_shape重合,则需要考虑的是在输入图input_shape上的预测框如何转换至new_shape图上。
-
代码中我们举了一个例子,即原图尺寸为(932,416),则new_shape=(416,208),如图所示蓝色底图为输入图input_shape尺寸(416,416),黄色部分为new_shape=(416,208)。因为在input_shape尺度上,预测框归一化中心为(0.5,0.5),因此还原至new_shape尺度上也应该是(0.5,0.5)。我们先
box_yx -offset
即得到在input_shape尺度上的新的中心位置(0.5,0.25),此时x为四分之一的input_shape宽。接下进行尺度转换,将归一化的分母转换为new_shape的宽,因此new_shape的宽是input_shape的宽的一半,为了保证转换前后x的物理数值(不是归一化值)不变,因此要将0.25乘以2。用公式理解就是x/input_shape * input_shape/new_shape。乘号前是相对input_shape的归一化数值,乘号后是input_shape与new_shape的比例。 -
当得到预测框在new_shape上的归一化属性后,因为new_shape与image_shape宽高比一致,因此则可以得到最终预测框在image_shape的归一化数值,将归一化数值乘以原图inamge_shape的实际宽高,就得到最终的预测结果,可以在图片上进行展示。
-
这里有个点还需要注意,在YOLO中对于图的尺寸描述都是先说高,再说宽,而对各种框(先验框、预测框、真实框)的尺寸描述都是先说宽,再说高,因此在将归一化的框属性转换为最终的物理属性时,注意乘积对象也要是对应的宽与高。
-
源码
def yolo_correct_boxes(self, box_xy, box_wh, input_shape, image_shape, letterbox_image): # -------------------------------------# # 将宽度方向和高度放的坐标值互换,方便后续与图像的高与宽相乘 # 在yolo中,凡是框都是宽->高的顺序,凡是图片都是高->宽的顺序 # -------------------------------------# box_yx = box_xy[:, :, ::-1] box_hw = box_wh[:, :, ::-1] input_shape = np.array(input_shape) image_shape = np.array(image_shape) # ---------------------------------------# # 这部分涉及从特征图尺度到input_shape尺度再到new_shape尺度在到image_shape的转化 # 函数输入的box_yx和box_hw是特征图尺度的归一化参数,由于特征图宽高比与input_shape宽高比相同,则可以直接同步(例如框的中心点坐标相对特征图的位置,转化为input_shape尺度是一致的) # 而new_shape和image_shape的宽高比是一致的,因此也可以直接同步。关键就在input_shape到new_shape这一步转换上。 # 当由input_shape尺度转化为new_shape尺度是需要缩放,因为两个宽高比可能不同 # 另外offset值可以这么理解,new_shape和input_shape左上角重合,因此相对input_shape的坐标要向左偏移才能转到new_shape上,并且还要进行缩放,转化到相对new_shape尺度的归一化 # 例如相对input_shape的框的中心点x的值,其归一化值为x/input_shape,要转换到相对new_shape尺度上,则x/input_shape * input_shape/new_shape = x/new_shape # 举例,框在特征图的中心,宽高为0.1比例 # 假设input_shape=(416,416),image_shape=(932,416),box_yx=(0.5,0.5),box_hw=(0.1,0.1) # 则new_shape=(416,208) # offset=(0,0.25),scale=(1,2) # box_yx=(0.5, 0.5),box_hw=(0.1,0.2) # ---------------------------------------# if letterbox_image: new_shape = np.round(image_shape * np.min(input_shape / image_shape)) offset = (input_shape - new_shape) / 2.0 / input_shape scale = input_shape / new_shape # box_yx -offset的值为相对input_shape尺度的框中心坐标,再乘以scale转化为相对new_shape尺度的框中心坐标,input_shape和new_shape的左上角重合,且为坐标原点 box_yx = (box_yx - offset) * scale box_hw *= scale box_mins = box_yx - (box_hw / 2.0) box_maxs = box_yx + (box_hw / 2.0) boxes = np.concatenate([box_mins[..., 0], box_mins[..., 1], box_maxs[..., 0], box_maxs[..., 1]], axis=-1) boxes *= np.concatenate([image_shape, image_shape], axis=-1) return boxes
三、总结
想要认真理解图像处理的话,建议还是多看源码,毕竟所有细节都在源码里进行体现。
欢迎提问交流~