经典目标检测YOLO系列(三)YOLOv3的复现(2)正样本的匹配、损失函数的实现

经典目标检测YOLO系列(三)YOLOv3的复现(2)正样本的匹配、损失函数的实现

我们在之前实现YOLOv2的基础上,加入了多级检测及FPN,快速的实现了YOLOv3的网络架构,并且实现了前向推理过程。

经典目标检测YOLO系列(三)YOLOV3的复现(1)总体网络架构及前向处理过程

我们继续进行YOLOv3的复现。

1 正样本匹配策略

1.1 基于先验框的正样本匹配策略

  • 官方YOLOv2的正样本匹配思路是根据预测框和目标框的IoU来确定中心点所在的网格,哪一个预测框是正样本。

  • 大体上,官方YOLOv3也沿用这一思路,但是细节上有差距。官方YOLOv3也会出现之前所说的三种情况:

    • 前2种情况,IoU都小于iou_thresh或者仅有一个IoU值大于iou_thresh,那么此时会有一个正样本;
    • 第3种情况,即有多个IoU值大于iou_thresh时候,仅仅将IoU最大的哪一个作为正样本。对于剩下样本,由于IoU值已经大于iou_thresh,因此不会被标记为正样本,将其忽略。
  • 我们继续沿用之前复现YOLOv2的做法。对于第3种情况,我们不忽略,还是标记为正样本。

    • 假设一个含有目标框中心的网格上的5个先验框分别为A、B、C、D、E,那么需要计算这5个先验框与目标框O的IoU值,分别为:IoU_A、IoU_B、IoU_C、IoU_D、IoU_E,然后设定一个阈值iou_thresh:
      • 第1种情况:如果IoU_A、IoU_B、IoU_C、IoU_D、IoU_E都小于iou_thresh,为了不丢失这个训练样本,我们选择选择IoU值最大的先验框P_A。将P_A对应的预测框B_A,标记为正样本,即先验框决定哪些预测框会参与到何种损失的计算中去
      • 第2种情况:仅有一个IoU值大于iou_thresh,那么这个先验框所对应的预测框会被标记为正样本,会参与到置信度、类别及位置损失的计算。
      • 第3种情况:有多个IoU值大于iou_thresh,那么这些先验框所对应的预测框都会被标记为正样本,即一个目标会被匹配上多个正样本
  • 由于YOLOv3中添加了多级检测,因此部分代码细节有所差异。

1.2 代码实现

1.2.1 正样本匹配

pytorch读取VOC数据集:

  • 一批图像数据的维度是 [B, 3, H, W] ,分别是batch size,色彩通道数,图像的高和图像的宽。

  • 标签数据是一个包含 B 个图像的标注数据的python的list变量(如下所示),其中,每个图像的标注数据的list变量又包含了 M 个目标的信息(类别和边界框)。

  • 获得了这一批数据后,图片是可以直接喂到网络里去训练的,但是标签不可以,需要再进行处理一下。

[
        {
            'boxes': torch.tensor([[120.,   0., 408.,  23.],
                                   [160.,  59., 416., 256.],
                                   [172.,  24., 218., 128.],
                                   [408.,  35., 416.,  75.],
                                   [  0.,  64.,   8., 186.]]),  # bbox的坐标(xmin, ymin, xmax, ymax
            'labels': torch.tensor([ 6,  6, 14,  6, 19]),       # 标签
            'orig_size': [416, 416]                             # 图片的原始大小
         },
        {
            'boxes': torch.tensor([[367., 255., 416., 416.],
                                   [330., 302., 416., 416.]]),
            'labels': torch.tensor([14, 13]),
            'orig_size': [416, 416]
        }
]

标签处理主要包括3个部分,

  • 一是将真实框中心所在网格对应正样本位置(anchor_idx)的置信度置为1,其他默认为0
  • 二是将真实框中心所在网格对应正样本位置(anchor_idx)的标签类别为1(one-hot格式),其他类别设置为0
  • 三是将真实框中心所在网格对应正样本位置(anchor_idx)的bbox信息设置为真实框的bbox信息。
# 处理好的shape如下:
# gt_objectness  
torch.Size([2, 10647, 1])  # 10647=52×52×3 + 26×26×3 + 13×13×3
# gt_classes
torch.Size([2, 10647, 20])
# gt_bboxes
torch.Size([2, 10647, 4])

1.2.2 具体代码实现

  • 对于一个目标框,我们先计算它和9个先验框的IoU,然后先用阈值进行筛选
  • 然后,我们会遇到之前说的3种情况,处理方法和YOLOv2一致。
  • 在确定哪个先验框为正样本后,我们还要通过公式iou_ind // self.num_anchors确定这个先验框来自哪个尺度。
    • 一个很小的目标框,它和较小的先验框的IoU理应大一些,因此会被分配到网格密集的C3尺度上;
    • 相反,一个很大的目标框,它和较大的先验框的IoU理应大一些,因此会被分配到网格稀疏的C5尺度上;
    • 中等大小的目标框,被分配到C4尺度上。
# RT-ODLab/models/detectors/yolov3/matcher.py
import numpy as np
import torch


class Yolov3Matcher(object):
    def __init__(self, num_classes, num_anchors, anchor_size, iou_thresh):
        self.num_classes = num_classes
        self.num_anchors = num_anchors
        self.iou_thresh = iou_thresh
        self.anchor_boxes = np.array(
            [[0., 0., anchor[0], anchor[1]]
            for anchor in anchor_size]
            )  # [KA, 4]


    def compute_iou(self, anchor_boxes, gt_box):
        """
        函数功能: 计算目标框和9个先验框的IoU值
            anchor_boxes : ndarray -> [KA, 4] (cx, cy, bw, bh).
            gt_box : ndarray -> [1, 4] (cx, cy, bw, bh).
            
            返回值: iou变量,类型为ndarray类型,shape为[9,], iou[i]就表示该目标框和第i个先验框的IoU值
        """
        # 1、计算9个anchor_box的面积
        # anchors: [KA, 4]
        anchors = np.zeros_like(anchor_boxes)
        anchors[..., :2] = anchor_boxes[..., :2] - anchor_boxes[..., 2:] * 0.5  # x1y1
        anchors[..., 2:] = anchor_boxes[..., :2] + anchor_boxes[..., 2:] * 0.5  # x2y2
        anchors_area = anchor_boxes[..., 2] * anchor_boxes[..., 3]
        # 2、gt_box复制9份,计算9个相同gt_box的面积
        # gt_box: [1, 4] -> [KA, 4]
        gt_box = np.array(gt_box).reshape(-1, 4)
        gt_box = np.repeat(gt_box, anchors.shape[0], axis=0)
        gt_box_ = np.zeros_like(gt_box)
        gt_box_[..., :2] = gt_box[..., :2] - gt_box[..., 2:] * 0.5  # x1y1
        gt_box_[..., 2:] = gt_box[..., :2] + gt_box[..., 2:] * 0.5  # x2y2
        gt_box_area = np.prod(gt_box[..., 2:] - gt_box[..., :2], axis=1)
        # 3、计算计算目标框和9个先验框的IoU值
        # intersection
        inter_w = np.minimum(anchors[:, 2], gt_box_[:, 2]) - \
                  np.maximum(anchors[:, 0], gt_box_[:, 0])
        inter_h = np.minimum(anchors[:, 3], gt_box_[:, 3]) - \
                  np.maximum(anchors[:, 1], gt_box_[:, 1])
        inter_area = inter_w * inter_h
        
        # union
        union_area = anchors_area + gt_box_area - inter_area

        # iou
        iou = inter_area / union_area
        iou = np.clip(iou, a_min=1e-10, a_max=1.0)
        
        return iou


    @torch.no_grad()
    def __call__(self, fmp_sizes, fpn_strides, targets):
        """
            fmp_size: (List) [fmp_h, fmp_w]
            fpn_strides: (List) -> [8, 16, 32, ...] stride of network output.
            targets: (Dict) dict{'boxes': [...], 
                                 'labels': [...], 
                                 'orig_size': ...}
        """
        assert len(fmp_sizes) == len(fpn_strides)
        # prepare
        bs = len(targets)
        gt_objectness = [
            torch.zeros([bs, fmp_h, fmp_w, self.num_anchors, 1]) 
            for (fmp_h, fmp_w) in fmp_sizes
            ]
        gt_classes = [
            torch.zeros([bs, fmp_h, fmp_w, self.num_anchors, self.num_classes]) 
            for (fmp_h, fmp_w) in fmp_sizes
            ]
        gt_bboxes = [
            torch.zeros([bs, fmp_h, fmp_w, self.num_anchors, 4]) 
            for (fmp_h, fmp_w) in fmp_sizes
            ]
        # 第一层for循环遍历每一张图像
        for batch_index in range(bs):
            targets_per_image = targets[batch_index]
            # [N,]   N表示一个图像中有N个目标对象
            tgt_cls = targets_per_image["labels"].numpy()
            # [N, 4]
            tgt_box = targets_per_image['boxes'].numpy()
            # 第二层for循环遍历这张图像标签的每一个目标数据
            for gt_box, gt_label in zip(tgt_box, tgt_cls):
                # get a bbox coords
                x1, y1, x2, y2 = gt_box.tolist()
                # xyxy -> cxcywh
                xc, yc = (x2 + x1) * 0.5, (y2 + y1) * 0.5
                bw, bh = x2 - x1, y2 - y1
                gt_box = [0, 0, bw, bh]

                # check target
                if bw < 1. or bh < 1.:
                    # invalid target
                    continue
                # 1、计算该目标框和9个先验框的IoU值
                # compute IoU
                iou = self.compute_iou(self.anchor_boxes, gt_box)
                iou_mask = (iou > self.iou_thresh)
                # 2、基于先验框的标签分配策略
                label_assignment_results = []
                # 第一种情况:所有的IoU值均低于阈值,选择IoU最大的先验框
                if iou_mask.sum() == 0:
                    # We assign the anchor box with highest IoU score.
                    iou_ind = np.argmax(iou)
                    # 确定选择的先验框在pyramid上的level及anchor index
                    level = iou_ind // self.num_anchors              # pyramid level
                    anchor_idx = iou_ind - level * self.num_anchors  # anchor index

                    # get the corresponding stride
                    stride = fpn_strides[level]

                    # compute the grid cell
                    # 计算该目标框在level尺度的网格坐标
                    xc_s = xc / stride
                    yc_s = yc / stride
                    grid_x = int(xc_s)
                    grid_y = int(yc_s)
                    # 存下网格坐标、尺度level以及anchor_idx
                    label_assignment_results.append([grid_x, grid_y, level, anchor_idx])
                else:
                    # 第二种和第三种情况:至少有一个IoU值大于阈值
                    for iou_ind, iou_m in enumerate(iou_mask):
                        if iou_m:
                            level = iou_ind // self.num_anchors              # pyramid level
                            anchor_idx = iou_ind - level * self.num_anchors  # anchor index

                            # get the corresponding stride
                            stride = fpn_strides[level]

                            # compute the gride cell
                            xc_s = xc / stride
                            yc_s = yc / stride
                            grid_x = int(xc_s)
                            grid_y = int(yc_s)

                            label_assignment_results.append([grid_x, grid_y, level, anchor_idx])

                # label assignment
                # 获取到被标记为正样本的先验框,我们就可以为这次先验框对应的预测框制作学习标签
                for result in label_assignment_results:
                    grid_x, grid_y, level, anchor_idx = result
                    fmp_h, fmp_w = fmp_sizes[level]

                    if grid_x < fmp_w and grid_y < fmp_h:
                        # objectness标签,采用0,1离散值(gt_objectness为list,存3个尺度的正样本)
                        gt_objectness[level][batch_index, grid_y, grid_x, anchor_idx] = 1.0
                        # classification标签,采用one-hot格式
                        cls_ont_hot = torch.zeros(self.num_classes)
                        cls_ont_hot[int(gt_label)] = 1.0
                        gt_classes[level][batch_index, grid_y, grid_x, anchor_idx] = cls_ont_hot
                        # box标签,采用目标框的坐标值
                        gt_bboxes[level][batch_index, grid_y, grid_x, anchor_idx] = torch.as_tensor([x1, y1, x2, y2])

        # [B, M, C]
        gt_objectness = torch.cat([gt.view(bs, -1, 1) for gt in gt_objectness], dim=1).float()
        gt_classes = torch.cat([gt.view(bs, -1, self.num_classes) for gt in gt_classes], dim=1).float()
        gt_bboxes = torch.cat([gt.view(bs, -1, 4) for gt in gt_bboxes], dim=1).float()

        return gt_objectness, gt_classes, gt_bboxes


if __name__ == '__main__':
    anchor_size = [[10, 13], [16, 30], [33, 23],
                   [30, 61], [62, 45], [59, 119],
                   [116, 90], [156, 198], [373, 326]]
    matcher = Yolov3Matcher(iou_thresh=0.5, num_classes=20, anchor_size=anchor_size, num_anchors=3)

    fmp_sizes =   [torch.Size([52, 52]), torch.Size([26, 26]), torch.Size([13, 13])]
    fpn_strides = [8, 16, 32]
    targets = [
        {
            'boxes': torch.tensor([[120.,   0., 408.,  23.],
                                   [160.,  59., 416., 256.],
                                   [172.,  24., 218., 128.],
                                   [408.,  35., 416.,  75.],
                                   [  0.,  64.,   8., 186.]]),  # bbox的坐标(xmin, ymin, xmax, ymax
            'labels': torch.tensor([ 6,  6, 14,  6, 19]),       # 标签
            'orig_size': [416, 416]                             # 图片的原始大小
         },
        {
            'boxes': torch.tensor([[367., 255., 416., 416.],
                                   [330., 302., 416., 416.]]),
            'labels': torch.tensor([14, 13]),
            'orig_size': [416, 416]
        }
    ]

    gt_objectness, gt_classes, gt_bboxes = matcher(fmp_sizes=fmp_sizes, fpn_strides=fpn_strides, targets=targets)
    print(gt_objectness.shape)
    print(gt_classes.shape)
    print(gt_bboxes.shape)

2 损失函数的计算

  • YOLOv3损失函数计算(RT-ODLab/models/detectors/yolov3/loss.py)和之前实现的YOLOv2基本一致,不再赘述
  • 对于数据预处理、数据增强等,我们不再采用之前SSD风格的处理手段,而是选择YOLOv5的数据处理方法来训练我们的YOLOv3,我们下次再聊。

结语

  • 我们现在已经知道,在多级检测框架时候,先验框自身尺度在标签分配环节起到了重要的作用。

  • 自Faster R-CNN工作问世以来,anchor box几乎成为了大多数先进的目标检测器的标准配置之一。但是anchor box的缺陷也是十分明显的,比如以下几点:

    • 首先,anchor box的长宽比、面积和数量依赖于人工设计。纵然YOLOv2给出了基于kmeans聚类算法的设计anchor box的尺寸,但是anchor box的数量仍旧是个问题;
    • 无论多么精心设计anchor box,一旦固定下来后,就不会再被改变。模型在一个训练集上被训练之后,已设定好的anchor box尽管可能在这个数据分布上表现够好,可一旦遇到不位于该数据分布的场景时,anchor box就可能存在不能泛化到新目标的问题;
    • 另外,大量的anchor box使得预测框的数量变多,从而使得后处理阶段要处理大量的预测框,不仅加剧了算力消耗,也会拖慢模型的检测速度;
  • 但是,如果没有先验框,能否做多级检测呢?

    • 没有先验框进行多级检测,即anchor-free架构,首先要解决哪个目标框应该被来自哪个尺度的预测框学习,即多尺度标签匹配问题。

    • 在2019年,FCOS检测器被提出,其最大的特点就是彻底抛去了一直以来的anchor box,那么FCOS如何解决多尺度匹配问题呢?

      • FCOS一共使用五个特征图 P3、P4、P5、P6和P7 ,其输出步长stride分别为 8、16、32、64和128。FCOS为这每一个尺度都设定了一个尺度范围,即对于特征图 P_i ,其尺度范围是 (m_i−1,m_i) ,这五个尺度范围分别为 (0,64) 、(64,128)、(128,256)、(256,512),以及(512,∞)。

        首先,我们去遍历特征图Pi上的每一个anchor,假设每一个anchor的坐标为 (xs_a+0.5,ys_a+0.5) ,其中(xs_a,ys_a)为anchor的左上角点坐标,也就是我们以前熟悉的网格左上角坐标的概念,但我们又为之加上了0.5亚像素坐标,即网格的中心点。我们求出特征图P_i上的anchor在输入图像上的坐标 (x_a,y_a) ,计算公式如下所示:
        x a = x s a ∗ s + s / 2 y a = y s a ∗ s + s / 2 x_a=xs_a∗s+s/2 \\ y_a=ys_a∗s+s/2 xa=xsas+s/2ya=ysas+s/2
        然后,我们求出处在边界框内的每一个anchor到边界框的四条边的距离:
        l ∗ = x a − x 1 t ∗ = y a − y 1 r ∗ = x 2 − x a b ∗ = y 2 − y a l^∗=x_a−x_1 \\ t^∗=y_a−y_1 \\ r^∗=x_2−x_a \\ b^∗=y_2−y_a l=xax1t=yay1r=x2xab=y2ya
        我们取其中的最大值 m=max(l∗,t∗,r∗,b∗) ,如果 m 满足 m_i−1

        若是目标框的尺寸偏小,那它内部的anchor就会更多地落在较小的范围内,比如: (0,64),反之,则会更多地落在较大的范围内,如: (256,512) 。

        换言之,FCOS设置的五个范围本质上是一种和目标自身大小相关的尺度范围,是基于一种 小的目标框更应该让输出步长小的也就是更大的特征图去学习,大的目标框则应该让输出步长更大的特征图去学习的直观理解。

      • 但这个尺度还需要人工设计,没有摆脱人工先验的超参。

    • 旷视科技在YOLOX种提出了SimOTA,摆脱了人工先验的超参,实现了真正意义的anchor-free,具体细节以后再讲。

你可能感兴趣的:(#,深度学习,目标检测,YOLO)