喜欢就关注 AIZOO 吧!
YOLOv5 凭借其不错的性能,以及傻瓜式的使用方法,让其在社区的确比较火🔥。YOLOv5 在其 anchor 编码(也就是label assign)方式上,与YOLOv2~v4 有很大不同,其代码又比较难懂,本文来写一下笔者对其的理解。
个人觉得,YOLOv5 这个仓库的代码,在追求性能的同时,并没有遵守良好的代码风格,导致有些地方的代码,非常难以看懂,大量类似 b,a,j,k 这种单字母的变量,让人很容易摸不着头脑。
YOLOv5 label assign 方式,看似只有短短的 53 行,但其实不是特别容易让人弄懂,笔者也是断断续续的花了好几天的时间,才把它彻底搞明白。下面,结合个人理解,对它做一下讲解,如有错误,欢迎指正。
YOLOv5 label assign 的代码,在这里:https://github.com/ultralytics/yolov5/blob/4c409332667477560200958b513b958bb8fdef71/utils/loss.py#L169
也就是这个 def build_targets(self, p, targets)
函数。其实,想搞明白这个函数的核心在于搞明白,一个物体,也就是 Ground truth (以下简称GT),是如何分配到几千上万个 anchor 上面去的。关于 anchor 是什么,笔者之前写个一篇通俗易懂的文章,对 anchor 不熟悉的朋友可以看看这一篇:
新手也能彻底搞懂的目标检测Anchor是什么?怎么科学设置?(附代码)
不同于 YOLOv2~v4,一个 GT 只会分配给一个 anchor,YOLOv5中,一个 anchor 可以被分配给多个anchor,还有可能被分配到三个不同的检测层中的两个甚至三个检测层。
让我们看一段源码:
r = t[:, :, 4:6] / anchors[:, None] # 目标的 w 和 h,与 anchor 相除
j = torch.max(r, 1 / r).max(2)[0] < self.hyp['anchor_t'] # 保留 1/4~4 之间的 GT
t = t[j] # filter
其中 t 就是GT数组,取 4:6 就是取出来 GT 的 width 和 hight,然后与当前检测层的三个 anchor 匹配,这三个 anchor 是这样的:
[[10,13],
[16,30],
[33,23]]
self.hyp['anchor_t']
默认是4,也就是目标 GT 与 anchor 相除,只要其比值在 1/4~4 之间,这个 就说明这个 GT 可以被分配到这个 anchor 上面。t = t[j]
,其实就是多个 GT 被过滤了一下,只留下了那些可以被分配到三组 anchor 其中 1~3个 的 GT。
你以为到这里就结束了吗,no,难理解的还在后面。让我们再来看看这一段
gxy = t[:, 2:4] # grid xy
gxi = gain[[2, 3]] - gxy # 反转一下,用 [feature_w, feature_h] 减去 GT 的 x 和 y 坐标。
j, k = ((gxy % 1 < g) & (gxy > 1)).T # 看余数是不是小于 g, gxy>1 是为了判断是不是在边界grid,防止扩张的时候溢出范围。
l, m = ((gxi % 1 < g) & (gxi > 1)).T # 看余数是不是大于 g
j = torch.stack((torch.ones_like(j), j, k, l, m))
t = t.repeat((5, 1, 1))[j] # 把目标复制 5份,然后再根据条件过滤一下
offsets = (torch.zeros_like(gxy)[None] + off[:, None])[j]
其中 gain[[2, 3]]
就是当前检测层特征图的长宽,例如[80,80]
,gxy 就是 GT 的 x 和 y 坐标(请注意,这个 x 和 y 不是 GT 在原图的大小,而是在当前特征图上的大小,例如特征图大小是 80x80,x 和 y 的范围就是 0~80 之间,是归一化值乘以特征图大小的结果)。
最难理解的,就是第三第四行,其中 g 默认是 0.5, gxy 对 1 取余数,也就是只保留小数部分,其实,这两行代码的本意,就是为了判断 GT,到底在一个 grid 的四个象限的哪个位置。
因为一个 GT 的中心,总得落入某个 Grid 中吧(包括grid 左边和上面的边界线),上面代码第三和第四行,就是为了确认物体落在一个 grid 中四个象限中的哪一个。
后面,还有一个比较难理解的,就是代码最后一行,其中 off 就是
(Pdb) off
tensor([[ 0.00000, 0.00000],
[ 0.50000, 0.00000],
[ 0.00000, 0.50000],
[-0.50000, 0.00000],
[ 0.00000, -0.50000]])
,将 off 复制成跟 t.repeat((5, 1, 1))
一样多份,然后通过 j 来过滤一下,保留对应位置的数值,那么它的作用是什么呢?且看代码
gxy = t[:, 2:4] # GT 中心点 xy
gwh = t[:, 4:6] # GT 的 w h
gij = (gxy - offsets).long()
将 gxy 与 offset 相减,因为 offset 有的是正 0.5, 有的是 -0.5,有的是 0,其实就是将每个 gxy,向两个方向扩张,将一个 grid,扩张成三个。也就是下面这个图中,每个 GT,根据所处的位置,向两个方向扩张,所以,YOLOv5,并不是只有一个 grid 中的某个 anchor 为正样本,而是一般会扩张两个 grid 来预测。
根据GT 中心点在 grid 中的位置,将会向两个方向扩张,不同位置扩张的 Grid,笔者用不同颜色的小球来表示了,一目了然。
所以,才会导致某个 grid 中预测的值,会出现负值,例如下面,如果 GT 在左下角,那下面的 grid 中的某个 anchor,预测的 y 值为负数,因为实际的物体中心 y 值,相比下面grid 的y 坐标,为负值。
这也是,为什么下面代码,pxy 的值会是 -0.5 ~ 1.5。
pxy = ps[:, :2].sigmoid() * 2 - 0.5 # pxy 范围是 -0.5 ~ 1.5
pwh = (ps[:, 2:4].sigmoid() * 2) ** 2 * anchors[i] # 值的范围是anchor 的 0~4 倍
最后补充一句,YOLOv5 预测的,是相对于 grid 左上角点的 x 和 y 的偏移。,具体可以看下面代码的最后一行,xy 是gxy - gij
gij = (gxy - offsets).long() # 向两个方向扩张两个grid
gi, gj = gij.T # grid xy indices
a = t[:, 6].long() # anchor indices
indices.append((b, a, gj.clamp_(0, gain[3] - 1), gi.clamp_(0, gain[2] - 1))) # image, anchor, grid indices
tbox.append(torch.cat((gxy - gij, gwh), 1)) # box
到这里,YOLOv5 label assign 核心部分算是讲完了,你应该大致知道 YOLOv5 anchor 是怎么分配的了。但是,你应该还是云里雾里,你应该多看看代码,pdb 调试一下,多看几遍文章,实在不懂,可以加群讨论。
欢迎扫描下方的二维码添加小助手微信,邀请您加入我们的微信交流群。
群里有多位清北复交、BAT、AI独角兽大牛和众多深度学习er在一起愉快的交流技术,有任何问题,都可以咨询大家,欢迎你的加入哦。
添加小助手微信,邀您进 AIZOO圈 技术交流群
听说点个在看的人运气都很好~