本篇博客主要是ATSS
部分,这部分个人认为是核心之一,毕竟正负样本的选择很重要,ATSS
论文证实,anchor-based
和anchor-free
性能差异的根本原因在于正负样本的定义,好的正负样本定义方法能在很大程度上降低模型对Anchor Num
, Anchor Size
的依赖。这点在yolo v5
上也得到了证实——“正确的正负样本定义方式能引入更多的高质量正样本,加快拟合 并 提高模型性能”。
首先推荐一个写的很好的 ATSS博客,看完它再看代码会清晰许多。以及我写的ATSS部分代码注释。
根据代码总结的ATSS
流程如下:
ground truth
,遍历每个输出层,找出每层前topk
(超参,nanodet
中是9
)个L2
距离(anchor
和gt box
中心点距离)最小的anchor
。 nanodet
一共3
层输出层,则每个gt
会匹配到27
个候选anchor
,输出数组shape=(27, gt_num)
。这些anchor
里可能会有重复,但是没关系,下面还有筛选措施;gt
和与之对应的27
个anchor
的IOU
值,shape=(27, gt_num)
(注意是与anchor
左上右下的坐标做iou
,不是和bbox
,现在是给anchor
做正负样本分类,还没到bbox
呢);gt
对应的27
个IOU
值的均值mean_IOU
和标准差std_IOU
,两者相加得到每个gt
的自适应阈值,shape = (gt_num, )
;gt
的27
个anchor
中筛选出IOU
大于对应自适应阈值的anchor
;anchor
中心到其对应gt
四条边界线的距离,取四个距离中的最小值,过滤掉最小值小于0.01
的anchor
,剩下的就是挑选出的正样本;anchor
同时匹配了多个gt
,此时需选出IOU
值最大的那个gt
作为匹配对象。即一个anchor
只能匹配到一个gt
,但是一个gt
可以同时被多个anchcor
匹配。从上面流程来看,一个anchor
要成为一个正样本,需要满足三个条件:
① 其中心要与任何一个gt
中心的距离要排在前topk
内(升序);
② 其与gt
的iou
值要大于对应的iou
阈值;
③ 其中心与gt
四条边的距离都要大于0.01
。
由此引发疑惑并思考之:
1. IOU阈值为什么取均值与标准差之和?
个人理解:均值代表了候选anchor
与gt
整体匹配程度,越大说明整体匹配度越高,候选anchor
的质量也就越好。标准差本代表了数据的分散程度,越大说明候选anchor
之间与对应gt
匹配度相差越大,即候选anchor
之间质量相差越大。二者相加,可以筛除很多低质量候选anchor
。
2. 为什么要以anchor
中心与gt
四条边的距离来筛选候选anchor
个人理解:ATSS
论文里只要求anchor
中心在gt
内部,即中心到gt
边界的距离大于0
就够了,代码实现时改为大于0.01
,个人理解是借鉴了label smooth
的思路,毕竟对于softmax 函数
来说,0
是很难学习到的。
然后疑问是,为什么anchor
中心要在gt
内部,因为nanodet
是学习anchor
中心到gt
四条边界的距离,学习出来的四个距离都是正数(见下面代码),如果anchor
中心在gt
外部,那模型是学习不出来一个负数给它拉回去的。其次,anchor
中心在gt
内部,意味着anchor
有更多的特征来学习。
# 下面是 bbox 输出处理成 bbox 坐标的处理函数
class Integral(nn.Module):
def __init__(self, reg_max=16):
super(Integral, self).__init__()
self.reg_max = reg_max # 7
self.register_buffer(
"project", torch.linspace(0, self.reg_max, self.reg_max + 1) # 返回一维 tensor = [0, 1, 2, 3, ... reg_max]
)
def forward(self, x): # 输入 x 就是原始 bbox 输出
x = F.softmax(x.reshape(-1, self.reg_max + 1), dim=1) # softmax 之后,数据就是 (0, 1)之间了
x = F.linear(x, self.project.type_as(x)).reshape(-1, 4) # 与 self.project 做矩阵相乘,返回 (0, reg_max) 之间的数
return x # x 中每个元素都正数
最后反问一下,提高要求为gt
中心在anchor
内部行不行?那这样的话,一个gt
在每个输出层上只能与一个anchor
相对应,减少了高质量正样本,上文说过,更多的高质量正样本,能加快拟合 并 提高模型性能。
3. 为什么要加上 gt_idx * num_bboxes
?
个人理解:
# num_gt 是一张图片中 gt 数目,num_bboxes 是 anchor 数目
# 下面是疑惑 1
for gt_idx in range(num_gt): # (topk * 3, num_gt)
candidate_idxs[:, gt_idx] += gt_idx * num_bboxes
ep_bboxes_cx = ( # (num_bboxes, ) -> (1, num_bboxes) -> (num_gt, num_bboxes) -> (num_gt * num_bboxes)
bboxes_cx.view(1, -1).expand(num_gt, num_bboxes).contiguous().view(-1) # 注意它的长度是 num_gt * num_bboxes
)
candidate_idxs = candidate_idxs.view(-1) # (topk * 3 * num_gt)
l_ = ep_bboxes_cx[candidate_idxs].view(-1, num_gt) - gt_bboxes[:, 0]
candidate_idxs = candidate_idxs.view(-1)
一开始不明白为什么要candidate_idxs[:, gt_idx] += gt_idx * num_bboxes
这样,仔细想了下才搞清楚,这是因为起初candidate_idxs
里的值,都是相对的,什么意思呢?看下图吧。
candidate_idxs
的数据分布类似于上图左边的样式,它的列数就是当前输入图片中ground truth
的个数,它每列元素的含义是离每个ground truth
最近的 topk
个 anchor
的索引值(针对一个输出层而言),即行索引, shape = (topk, num_gt)
,而nanodet
有三个输出层,所以最后candidate_idxs.shape = (topk * 3, num_gt)
。但是它每列的索引值范围都是[0, num_bboxes)
,这就是我说的相对值。而ep_bboxes_cx
最后是view(-1)
成一个一维向量,这样一来相对位置不能用了,要把行数考虑进去,这也是加上gt_idx * num_bboxes
的原因。但奇怪的是candidate_idxs
的行数是num_gt
,怎么代码里面是num_bboxes
,这是因为最后处理的数组是ep_bboxes_cx
,它的shape = (num_gt * num_bboxes)
,行数正好是num_bboxes
。