在笔者上一篇文章《一文打尽目标检测NMS——精度提升篇》中,总结了近几年出现的一些可以提升NMS精度的方法。可以看到,NMS由于顺序处理的原因,运算效率较为低下。在笔者的实际项目中,NMS往往能占模型计算总时间的40%甚至更多,极大影响了模型的效率。经过笔者一段时间的调研,关于提升NMS运算速度的方法,在这里也将结合代码进行阶段性总结。
所参考的代码库列举如下:
在进入正题之前,首先我们应该考察一下NMS运算效率的瓶颈在哪?答案自然是IoU计算及顺序迭代抑制。
假如一张图片中有 n n n 个检测框,由于顺序处理的原因,某一个框与其他框计算IoU,最少一次,最多有 n − 1 n-1 n−1 次。再加上顺序迭代抑制,NMS算法在计算IoU方面,共需要计算IoU至少 n − 1 n-1 n−1 次,最多 ( n − 1 ) + ( n − 2 ) + ⋯ + 1 = 1 2 n 2 − n 2 (n-1) + (n-2) + \cdots + 1=\frac{1}{2}n^2-\frac{n}{2} (n−1)+(n−2)+⋯+1=21n2−2n次。
因此,要想加速NMS,首当其冲应该要将IoU的计算并行化。这个操作在我们使用IoU-based loss的时候就有,只需计算检测框集合 B = { B i } i = 1 t o n B=\{B_i\}_{i=1\,to\,n} B={Bi}i=1ton 与自身的IoU即可。检测框集合 B B B 事先会按照得分降序排列,也就是说 B 1 B_1 B1 是最高得分框, B n B_n Bn 是最低得分框。得到如下这个IoU矩阵:
X = I o U ( B , B ) = ( x 11 x 12 ⋯ x 1 n x 21 x 22 ⋯ x 2 n ⋮ ⋮ ⋱ ⋮ x n 1 x n 2 ⋯ x n n ) , x i j = I o U ( B i , B j ) X=IoU(B,B)=\left(\begin{array}{cccc} x_{11}&x_{12}&\cdots&x_{1n}\\ x_{21}&x_{22}&\cdots&x_{2n}\\ \vdots&\vdots&\ddots&\vdots\\ x_{n1}&x_{n2}&\cdots&x_{nn}\\ \end{array}\right),\quad x_{ij}=IoU(B_i, B_j) X=IoU(B,B)=⎝⎜⎜⎜⎛x11x21⋮xn1x12x22⋮xn2⋯⋯⋱⋯x1nx2n⋮xnn⎠⎟⎟⎟⎞,xij=IoU(Bi,Bj)
得益于GPU的并行计算,我们可以一次性得到IoU的全部计算结果。这一步就已经极大地解决了IoU计算繁琐又耗时的问题。代码如下:
def box_iou(boxes1, boxes2):
# https://github.com/pytorch/vision/blob/master/torchvision/ops/boxes.py
"""
Return intersection-over-union (Jaccard index) of boxes.
Both sets of boxes are expected to be in (x1, y1, x2, y2) format.
Arguments:
boxes1 (Tensor[N, 4])
boxes2 (Tensor[M, 4])
Returns:
iou (Tensor[N, M]): the NxM matrix containing the pairwise
IoU values for every element in boxes1 and boxes2
"""
def box_area(box):
# box = 4xn
return (box[2] - box[0]) * (box[3] - box[1])
area1 = box_area(boxes1.t())
area2 = box_area(boxes2.t())
lt = torch.max(boxes1[:, None, :2], boxes2[:, :2]) # [N,M,2]
rb = torch.min(boxes1[:, None, 2:], boxes2[:, 2:]) # [N,M,2]
inter = (rb - lt).clamp(min=0).prod(2) # [N,M]
return inter / (area1[:, None] + area2 - inter) # iou = inter / (area1 + area2 - inter)
到这里为止,以上列出的5种NMS都可以做到,从速度上来说CUDA NMS和torchvision NMS相对底层,编译后使用,速度稍快,但必然损失了一些灵活度,后面会讲。(关于CUDA NMS的教程,有兴趣的小伙伴可以参考faster-rcnn源码阅读:nms的CUDA编程,非常详实。
在有了IoU矩阵之后,接下来就是应该要如何利用它来抑制冗余框。
以下列举的三篇文献,可谓是将IoU矩阵玩出了花,从不同的角度发扬光大,在NMS加速方面也确实走在正轨上。(所用的一些符号,笔者进行了统一)
Fast NMS是《YOLACT: Real-time Instance Segmentation》一文的其中一个创新点。由于IoU的对称性,即 I o U ( B i , B j ) = I o U ( B j , B i ) IoU(B_i,B_j)=IoU(B_j,B_i) IoU(Bi,Bj)=IoU(Bj,Bi) ,看出 X X X 是一个对称矩阵。再加上一个框自己与自己算IoU也是无意义的,因此Fast NMS首先对 X X X 使用pytorch的triu函数进行上三角化,得到了一个对角线元素及下三角元素都为0的IoU矩阵 X X X 。
X = ( 0 x 12 x 13 ⋯ x 1 n 0 0 x 23 ⋯ x 2 n 0 0 0 ⋯ x 3 n ⋮ ⋮ ⋮ ⋱ ⋮ 0 0 0 ⋯ 0 ) X=\left(\begin{array}{ccccc} 0&x_{12}&x_{13}&\cdots&x_{1n}\\ 0&0&x_{23}&\cdots&x_{2n}\\ 0&0&0&\cdots&x_{3n}\\ \vdots&\vdots&\vdots&\ddots&\vdots\\ 0&0&0&\cdots&0\\ \end{array}\right) X=⎝⎜⎜⎜⎜⎜⎛000⋮0x1200⋮0x13x230⋮0⋯⋯⋯⋱⋯x1nx2nx3n⋮0⎠⎟⎟⎟⎟⎟⎞
接着对 X X X 执行按列取最大值操作,得到一维张量 b = [ b 1 , b 2 , ⋯ , b n ] b=[b_1,b_2,\cdots,b_n] b=[b1,b2,⋯,bn], b i b_i bi 代表了 X X X 的第 i i i 列上元素的最大值。最后使用NMS阈值,论文取0.5,对 b b b 二值化。 b b b 中元素小于NMS阈值的是保留的框,≥NMS阈值的是冗余框。例如
1 0 1 0 0 ↑ N M S 阈 值 二 值 化 b = 0 0.6 0.2 0.72 0.8 ↑ 列 最 大 值 X = ( 0 0.6 0.1 0.3 0.8 0 0.2 0.72 0.1 0 0.45 0.12 0 0.28 0 ) \begin{array}{cccccc} &\;\;1\;\quad\;0\;\;\;\quad1\;\;\;\quad0\;\;\;\quad0\;\;\;\;\;\\ &\quad\quad\quad\quad\;\;\quad\quad\quad\quad\uparrow\;NMS阈值二值化\quad\\ b=&0\quad0.6\quad0.2\quad0.72\quad\,0.8\;\\ &\quad\quad\quad\quad\quad\uparrow\;列最大值\quad\\ X=&\left(\begin{array}{ccccc} 0&0.6&0.1&0.3&0.8\\ &0&0.2&0.72&0.1\\ &&0&0.45&0.12\\ &&&0&0.28\\ &&&&0\\ \end{array}\right)\end{array} b=X=10100↑NMS阈值二值化00.60.20.720.8↑列最大值⎝⎜⎜⎜⎜⎛00.600.10.200.30.720.4500.80.10.120.280⎠⎟⎟⎟⎟⎞
1代表保留,0代表抑制。
代码如下:
def fast_nms(self, boxes, scores, NMS_threshold:float=0.5):
'''
Arguments:
boxes (Tensor[N, 4])
scores (Tensor[N, 1])
Returns:
Fast NMS results
'''
scores, idx = scores.sort(1, descending=True)
boxes = boxes[idx] # 对框按得分降序排列
iou = box_iou(boxes, boxes) # IoU矩阵
iou.triu_(diagonal=1) # 上三角化
keep = iou.max(dim=0)[0] < NMS_threshold # 列最大值向量,二值化
return boxes[keep], scores[keep]
缺点:
这里有必要解释一下,为什么Fast NMS会抑制更多的框?
我们知道NMS的思想是:当一个框是冗余框,被抑制后,将不会对其他框产生任何影响。但在Fast NMS中,如果一个框 B i B_i Bi 的得分比 B j B_j Bj 高,且 B i B_i Bi 被抑制了,矩阵 X X X 的第 i i i 行正是 B i B_i Bi 与得分低于它的所有框的IoU,如果 x i j x_{ij} xij 这个元素≥NMS阈值的话,那么在取列最大值这个操作时, b b b 的第 j j j 个元素必然≥NMS阈值,于是很不幸地 B j B_j Bj 就被抑制掉了。
也就是在刚刚的例子中,由于第二个框 B 2 B_2 B2 被抑制,那么第二行第四列的 0.72 0.72 0.72 就不应该存在,可是Fast NMS允许冗余框去抑制其他框,导致了第四个框 B 4 B_4 B4 被错误地抑制了。
1 0 1 0 0 ↑ N M S 阈 值 二 值 化 b = 0 0.6 0.2 0.72 0.8 ↑ 列 最 大 值 X = ( 0 0.6 0.1 0.3 0.8 0 0.2 0.72 0.1 0 0.45 0.12 0 0.28 0 ) \begin{array}{cccccc} &\;\;1\;\quad\;0\;\;\;\quad1\;\;\;\quad0\;\;\;\quad0\;\;\;\;\;\\ &\quad\quad\quad\quad\;\;\quad\quad\quad\quad\uparrow\;NMS阈值二值化\quad\\ b=&0\quad0.6\quad0.2\quad0.72\quad\,0.8\;\\ &\quad\quad\quad\quad\quad\uparrow\;列最大值\quad\\ X=&\left(\begin{array}{ccccc} 0&0.6&0.1&0.3&0.8\\ &0&0.2&0.72&0.1\\ &&0&0.45&0.12\\ &&&0&0.28\\ &&&&0\\ \end{array}\right)\end{array} b=X=10100↑NMS阈值二值化00.60.20.720.8↑列最大值⎝⎜⎜⎜⎜⎛00.600.10.200.30.720.4500.80.10.120.280⎠⎟⎟⎟⎟⎞
不过呢,由于YOLACT主要针对的是实例分割,mask是从box中裁剪出来的,Fast NMS对mask AP的下降比较轻微,约0.1~0.3的AP,但似乎对目标检测的box AP会下降更多。
Cluster NMS出自《Enhancing Geometric Factors in Model Learning and Inference for Object Detection and Instance Segmentation》一文。研究者主要旨在弥补Fast NMS的性能下降,期望也利用pytorch的GPU矩阵运算进行NMS,但同时又使得性能保持与Traditional NMS相同。
最开始看到这个名字时,笔者还以为是采取聚类的NMS,这不禁让人想起了今年2月的FeatureNMS。但后来仔细看了过后,发现这里的cluster的含义不一样。
以上是论文的原话,意思是说,cluster是一个框集合,若某一个框A属于这个cluster,则必有cluster中的框与A的IoU≥NMS阈值,且A不会与除这个cluster之外的框有IoU≥NMS阈值。举个简单的例子:
上图中的黑红蓝橙四个框构成一个cluster,而绿色的两个框构成一个cluster,虽然两个cluster之间有相交,但都没有超过NMS阈值,于是这两个框集合不能合并成一个cluster。
然后我们看一下Cluster NMS是怎么做的?
其实就是一个迭代式的Fast NMS。前面的过程与Fast NMS一模一样,都是 B B B 按得分降序排列,计算IoU矩阵,上三角化。然后按列取最大值,经过NMS阈值二值化得到一维张量 b b b 。但不同于Fast NMS直接输出了 b b b ,Cluster NMS而是利用 b b b ,将它展开成一个对角矩阵 E E E 。也就是这个对角矩阵 E E E 的对角线元素与 b b b 相同。然后用 E E E 去左乘IoU矩阵 X X X 。然后再按列取最大值,NMS阈值二值化得到一个新的一维张量 b b b,再展开成一个新的对角矩阵 E E E,继续左乘IoU矩阵 X X X 。直到出现某两次迭代后, b b b 保持不变了,那么谁该保留谁该抑制也就确定了。
这里借用一下作者在github的流程图。
以笔者的角度看,这种利用矩阵左乘的方式,其实就是在省略上一次Fast NMS迭代中被抑制的框对其他框的影响。
因为我们知道一个对角矩阵左乘一个矩阵,就是在做行变换啊,对应行是1,乘完的结果这一行保持不变,如果对应行是0,乘完的结果就是把这一行全部变成0。而 b b b 中某个元素若是0,就代表这个位置是冗余框,于是乘完之后,这个冗余框在下一次的迭代中就不再对其他框产生影响。为什么这里要强调下一次迭代?是因为这个迭代过程 b b b 会不断发生变动!可能某个框这会儿是冗余框,到了下一次迭代又变成了保留框。但是但是!到了最终 (其实未必是迭代满n次), b b b 就保持不变了!你说奇怪不?这里论文还给出了一个命题,说Cluster NMS的输出结果与Traditional NMS的结果一模一样。
论文里还给出了对这个命题的证明,又是数学数学数学!大致一看是利用数学归纳法证的,感兴趣的可以去看论文,这里请允许我跳过。
def cluster_nms(self, boxes, scores, NMS_threshold:float=0.5):
'''
Arguments:
boxes (Tensor[N, 4])
scores (Tensor[N, 1])
Returns:
Fast NMS results
'''
scores, idx = scores.sort(1, descending=True)
boxes = boxes[idx] # 对框按得分降序排列
iou = box_iou(boxes, boxes).triu_(diagonal=1) # IoU矩阵,上三角化
C = iou
for i in range(200):
A=C
maxA = A.max(dim=0)[0] # 列最大值向量
E = (maxA < NMS_threshold).float().unsqueeze(1).expand_as(A) # 对角矩阵E的替代
C = iou.mul(E) # 按元素相乘
if A.equal(C)==True: # 终止条件
break
keep = maxA < NMS_threshold # 列最大值向量,二值化
return boxes[keep], scores[keep]
好了,至此Cluster NMS算是完成了对Fast NMS性能下降的弥补。我们直接看结果好了
嗯,效果还是可以的,保持了AP与AR一样,运算效率比Fast NMS下降了一些,毕竟是迭代Fast NMS的操作,但也比Traditional NMS快多了。
以为这样就完了?接下来才是重头戏!
这里又分为两个部分,一个是实用上的,另一个是理论上的。先说一下实用上的。
之前说过基于pytorch的NMS方法灵活度要比CUDA NMS更高,就在于这些基于pytorch的NMS是高层语言编写,专为研究人员开发,矩阵运算清晰简洁,于是乎可以很方便地与一些能够提升精度的NMS方法结合!正所谓强强联合,于是就诞生出了又快又好的一系列Cluster NMS的变体。
论文里主要举了三种变体:
def SPM_cluster_nms(self, boxes, scores, NMS_threshold:float=0.5):
'''
Arguments:
boxes (Tensor[N, 4])
scores (Tensor[N, 1])
Returns:
Fast NMS results
'''
scores, idx = scores.sort(1, descending=True)
boxes = boxes[idx] # 对框按得分降序排列
iou = box_iou(boxes, boxes).triu_(diagonal=1) # IoU矩阵,上三角化
C = iou
for i in range(200):
A=C
maxA = A.max(dim=0)[0] # 列最大值向量
E = (maxA < NMS_threshold).float().unsqueeze(1).expand_as(A) # 对角矩阵E的替代
C = iou.mul(E) # 按元素相乘
if A.equal(C)==True: # 终止条件
break
scores = torch.prod(torch.exp(-C**2/0.2),0)*scores #惩罚得分
keep = scores > 0.01 #得分阈值筛选
return boxes[keep], scores[keep]
说到底DIoU就是该团队提出来的,那怎能不用上中心点距离呢?于是论文在两个方面添加了中心点距离。一个是IoU矩阵直接变成DIoU矩阵,由于DIoU也是满足尺度不变性的,所以完全没问题,相距很远的框之间的DIoU会变成负数,不过不影响过程。第二个是基于上面的SPM,公式如下:
s j = s j ∏ i min { e − C i j 2 σ + D β , 1 } s_j=s_j\prod\limits_i\min\{e^{-\frac{C_{ij}^2}{\sigma}}+D^\beta,1\} sj=sji∏min{e−σCij2+Dβ,1}
这里多添了一个 D D D 也就是DIoU loss的惩罚项,两框中心点距离²/最小包围矩形对角线长度²。 β \beta β 用于控制中心点距离惩罚的幅度。然后又与1相比较,取最小值,以避免惩罚因子大于1。
加了这两个变体之后,AP与AR得到了明显的改善,速度也是略微下降,很香
也是利用矩阵 C C C ,先与得分张量 S = [ s 1 , s 2 , ⋯ , s n ] S=[s_1,s_2,\cdots,s_n] S=[s1,s2,⋯,sn] 按列相乘得到 C ′ C' C′ ,随后
B = C ′ × B B=C'\times B B=C′×B
就可以更新框的坐标了。
在YOLOv3上的效果有了不错的改进,虽然速度不及torchvision NMS,增加了5ms的运算成本,但结合Weighted NMS与DIoU,可以提升精度 (最后一行)。
def Weighted_cluster_nms(self, boxes, scores, NMS_threshold:float=0.5):
'''
Arguments:
boxes (Tensor[N, 4])
scores (Tensor[N, 1])
Returns:
Fast NMS results
'''
scores, idx = scores.sort(1, descending=True)
boxes = boxes[idx] # 对框按得分降序排列
iou = box_iou(boxes, boxes).triu_(diagonal=1) # IoU矩阵,上三角化
C = iou
for i in range(200):
A=C
maxA = A.max(dim=0)[0] # 列最大值向量
E = (maxA < NMS_threshold).float().unsqueeze(1).expand_as(A) # 对角矩阵E的替代
C = iou.mul(E) # 按元素相乘
if A.equal(C)==True: # 终止条件
break
keep = maxA < NMS_threshold # 列最大值向量,二值化
weights = (C*(C>0.8).float() + torch.eye(n).cuda()) * (scores.reshape((1,n)))
xx1 = boxes[:,0].expand(n,n)
yy1 = boxes[:,1].expand(n,n)
xx2 = boxes[:,2].expand(n,n)
yy2 = boxes[:,3].expand(n,n)
weightsum=weights.sum(dim=1) # 坐标加权平均
xx1 = (xx1*weights).sum(dim=1)/(weightsum)
yy1 = (yy1*weights).sum(dim=1)/(weightsum)
xx2 = (xx2*weights).sum(dim=1)/(weightsum)
yy2 = (yy2*weights).sum(dim=1)/(weightsum)
boxes = torch.stack([xx1, yy1, xx2, yy2], 1)
return boxes[keep], scores[keep]
接下来说一下Cluster NMS理论上的好处。
Cluster NMS的迭代次数通常少于Traditional NMS的迭代次数。
这一优点,从理论上给了它使用CUDA编程更进一步加速的可能。大致意思是说,平常我们在做NMS时,迭代都是顺序处理每一个cluster的。在Traditional NMS中,虽然不同的cluster之间本应毫无关系,但计算IoU重复计算了属于不同cluster之间的框,顺序迭代抑制的迭代次数也仍然保持不变。
但在Cluster NMS中,使用这种行变换的方式,就可以将本应在所有cluster上迭代,化简为只需在一个拥有框数量最多的cluster上迭代就够了(妙啊)。不同的cluster间享有相同的矩阵操作,且它们互不影响。这导致迭代次数最多不超过一张图中最大cluster所拥有的框的个数。也就是下面这种情形
上图中共有10个检测框,分成了3个cluster,它的IoU矩阵(被NMS阈值二值化了)在右边。Cluster NMS做迭代的次数最多不超过4次,因为上图中框数量最多的那个cluster(红色)一共只有4个框。而实际上这张图使用Cluster NMS,只需迭代2轮便结束了。
因此这带来的好处是时间复杂度的下降。特别是对于一张图中有很多个cluster时,效果更为显著。比如这种情形,密密麻麻的狗狗
物体数量越多,到时候的检测框也就越多,形成的cluster必然也会增多,于是乎Cluster NMS这种对所有cluster并行处理的算法必然迭代次数非常少,不会随着物体的增多而过分地增加迭代轮数。最极端的情形是 n n n 个物体形成 n n n 个cluster,Traditional NMS需要迭代 ⩾ n \geqslant n ⩾n 次,而Cluster NMS不与cluster数量有关,只与需要迭代次数最多的那一个cluster有关。这也是为什么文中说,有可能可以被进一步使用工程技巧加速的原因,比如底层CUDA实现。
优点:
缺点:
Matrix NMS出自《SOLOv2: Dynamic, Faster and Stronger》,也是利用排序过后的上三角化的IoU矩阵。不同在于它针对的是mask IoU。我们知道mask IoU的计算会比box IoU计算更耗时一些,特别是在Traditional NMS中,会加剧运算开销。因此Matrix NMS将mask IoU并行化是它最大的一个贡献。然后论文同样采取了得分惩罚机制来抑制冗余mask。具体代码如下:
不过笔者在实现这个代码时遇到了问题,惩罚因子decay通常会≥1,在考虑一个mask B j B_j Bj 的惩罚因子decay时,与两个东西有关,一个是所有得分高于它的mask与 B j Bj Bj 的IoU,该IoU越大,惩罚应该更多(也就是上图中decay的分子);另一个是所有得分高于 B j B_j Bj 的mask B i B_i Bi 被抑制的概率(也就是上图中decay的分母),这是由 B i B_i Bi 所处的这一列的最大IoU决定,越大则 B i B_i Bi 越可能是冗余mask,就应该降低对 B j B_j Bj 的惩罚力度。
随后decay取列最小值。但依据如上代码,decay通常会≥1,这会增大mask的得分,而代码又以0.05作为score筛选的阈值,说明应该是减少得分的思想没有错,这个矛盾导致得出的结果奇差无比。如有任何人知道Matrix NMS出现这种问题的原因,还请告知我问题在哪。
这里直接贴出论文结果,从这里似乎也看出了相比于Soft-NMS每次迭代重排序的做法,直接得分惩罚也是可取的。
缺点:
YOLACT: Real-time Instance Segmentation. ICCV 2019
Enhancing Geometric Factors in Model Learning and Inference for Object Detection and Instance Segmentation. Arxiv 2020.05
SOLOv2: Dynamic, Faster and Stronger. Arxiv 2020.03
就分享到这了,有任何错误,欢迎指正,一起学习。