经典目标检测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种情况,我们不忽略,还是标记为正样本。
- 第1种情况:如果IoU都小于iou_thresh,为了不丢失这个训练样本,我们选择选择IoU值最大的先验框P_A。将P_A对应的预测框B_A,标记为正样本,即
先验框决定哪些预测框会参与到何种损失的计算中去
。 - 第2种情况:仅有一个IoU值大于iou_thresh,那么这个先验框所对应的预测框会被标记为正样本,会参与到置信度、类别及位置损失的计算。
- 第3种情况:有多个IoU值大于iou_thresh,那么这些先验框所对应的预测框都会被标记为正样本,即
一个目标会被匹配上多个正样本
。
- 第1种情况:如果IoU都小于iou_thresh,为了不丢失这个训练样本,我们选择选择IoU值最大的先验框P_A。将P_A对应的预测框B_A,标记为正样本,即
-
由于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 P_i Pi ,其尺度范围是 ( m i − 1 , m i ) (m_{i−1},m_i) (mi−1,mi) ,这五个尺度范围分别为 (0,64) 、(64,128)、(128,256)、(256,512),以及(512,∞)。
首先,我们去遍历特征图Pi上的每一个anchor,假设每一个anchor的坐标为 ( x s a + 0.5 , y s a + 0.5 ) (xs_a+0.5,ys_a+0.5) (xsa+0.5,ysa+0.5) ,其中 ( x s a , y s a ) (xs_a,ys_a) (xsa,ysa)为anchor的左上角点坐标,也就是我们以前熟悉的网格左上角坐标的概念,但我们又为之加上了0.5亚像素坐标,即网格的中心点。我们求出特征图 P i P_i Pi上的anchor在输入图像上的坐标 ( x a , y a ) (x_a,y_a) (xa,ya) ,计算公式如下所示:
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=xsa∗s+s/2ya=ysa∗s+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∗=xa−x1t∗=ya−y1r∗=x2−xab∗=y2−ya
我们取其中的最大值 m = m a x ( l ∗ , t ∗ , r ∗ , b ∗ ) m=max(l∗,t∗,r∗,b∗) m=max(l∗,t∗,r∗,b∗),如果 m 满足 m i − 1 < m < m i m_{i−1}<m<m_i mi−1<m<mi ,则该anchor将被视为正样本,去学习自己到目标框四条边的距离。反之则为负样本。若是目标框的尺寸偏小,那它内部的anchor就会更多地落在较小的范围内,比如: (0,64),反之,则会更多地落在较大的范围内,如: (256,512) 。
换言之,FCOS设置的五个范围本质上是一种和目标自身大小相关的尺度范围,是基于一种
小的目标框更应该让输出步长小的也就是更大的特征图去学习,大的目标框则应该让输出步长更大的特征图去学习
的直观理解。 -
但这个尺度还需要人工设计,没有摆脱人工先验的超参。
-
-
2021年旷视科技在YOLOX中提出了SimOTA,通过
动态标签分配
摆脱了人工先验的超参,实现了真正意义的anchor-free,具体细节以后再讲。这种标签分配策略在后续版本的YOLO中(YOLOV5之后的版本,例如:YOLOV6、YOLOV7、YOLOV8…),也被继承和发展。
-