从 YOLOv3 浅度理解目标检测

在前段时间,我们刚理了一遍 RetinaNet 是如何实现目标检测的。不过简单从一个方法上了解目标检测全貌有失偏颇,所以接下来我们继续看另一个 anchor-based 方法 YOLOv3,从两个方法上继续片面理解目标检测。

不过 YOLOv1 呢?因为 v3 是在其上的改进,就不再单独解释 v1,下文可能会以对比的形式提及。若没有提到版本,下文所有的 YOLO 均指 YOLOv3,代码可以参考ab

这两份代码多少有点相似,不知道是不是我的错觉。

网络概览

YOLO 网络的主要部分是作者自己用 C 和 CUDA 实现的 Darknet-53。

YOLO 网络结构较为线性,没有 RetinaNet 那种复杂的 U 型结构。借 Darknet 框架(它真的是个框架,不是库)和简单的网络结构,YOLO 的速度可是说一骑绝尘。不过对我们来讲想要看明白网络的结构就多少有点头疼了,不但需要额外学习这个框架,而且它的配置文件格式实在是有些……

那么,第 61 层是哪个呢……

所以我现在并不打算深入观察 YOLOv3 网络细节。Darknet 的主要组成仍为 1x1 和 3x3 的带残差连接卷积层叠成的深层网络。从最高层、次高层、次次高层三个 stage 上,YOLO 拿到 3 个不同尺度上的图像特征,通过上采样后,直接前后相连来生成 3 组特征,并使用这些特征分别生成 bbox。

Anchor

YOLO 同样是一个 anchor-based 方法。其 anchor 的设置从 v1 后经过改进,现在与 RetinaNet 等方法的 anchor 格式是基本一致的,同样为 (tx,ty,tw,th)(t_x,t_y,t_w,t_h),并且

x=σ(tx)+cxy=σ(ty)+cyw=Wexp(tw)h=Hexp(th)\begin{aligned} x &= \sigma(t_x) + c_x \\ y &= \sigma(t_y) + c_y \\ w &= W\exp(t_w) \\ h &= H\exp(t_h) \\ \end{aligned}

其中 W,HW,H 分别为图像的宽和高,σ\sigma 则是在特征坐标系和图像坐标系间的转换函数。有重大变化的是 x,yx,y 的计算中多出来的 cx,cyc_x,c_y。YOLO 将图像划分成为 S×SS \times S 的网格,为不同网格设置多个 anchor。作为对比,RetinaNet 没有网格,它的 anchor 是直接设置在每个像素点上的。

虽然设计上像是额外多出了「网格」的概念,落实到网络上其实就是特征尺寸和原图间的大小比例。卷积提取特征的网络一般都遵循相同的特点,也就是多层、层数越高通道越多、尺寸逐渐减小的金字塔式结构。那么一个 H×WH' \times W' 大小的特征,每个像素的特征应该和原图的局部区域有一定的对应关系,也就是对应了原图里的一个格。这对 YOLO 来说是个很自然的设计。

YOLO 确定 anchor scale 和 ratio 的方法相比 RetinaNet 有些赖皮:它将数据集中所有的框拉出来聚类,取聚类结果作为 anchor。我不评价这种方法,它在方法的落地应用上一定是有意义的,实践中预先的聚类相比指望网络经 IoU 指导从零学习预测框要好很多。

另外,YOLO 对于类别置信度的预测有很大不同。首先它对每个 anchor 输出一个置信度 P(object)P(\mathrm{object}),表示该框中有「任何物体」的概率,其次再输出 P(Ciobject)P(C_i \mid \mathrm{object}),表示框中物体的类别。所以如果有 8×88 \times 8 的网格和 33 个 anchor,网络的输出将会是 8×8×3×(4+1+C)8 \times 8 \times 3 \times (4+1+|C|),其中 CC 为类别集合[1]。配合这一设计,YOLO 不会受到正负类别失衡的影响,「Focal Loss 在 YOLO 上没有意义」。

训练

build targets

RetinaNet 中与 gound truth 的 IoU 超过 0.5 的任何 anchor 都会设为 positive。 YOLO 是如何确定 anchor 与 gound truth bbox 之间的关系的呢。我们需要谨慎地理解这一部分设计,必要时我会贴对应的代码实现,里面有很多作者的实践经验。

…YOLOv3 predicts an objectness score for each bounding box using logistic regression. This should be 1 if the bounding box prior overlaps a ground truth object by more than any other bounding box prior. If the bounding box prior is not the best but does overlap a ground truth object by more than some threshold we ignore the prediction

  1. YOLO 中每个 anchor 仅对应一个 ground truth,且每个 ground truth 仅对应一个最匹配(IoU\mathrm{IoU})的 anchor。
  2. 与 anchor 「足够」重合(IoU>0.5\mathrm{IoU}>0.5)但因第一点而不是 positive 的 anchor 在 loss 中会被忽略。
  3. 剩余 anchor 才是 negative。

听着就很合理。不过看了两份代码,实现上好像不是那么回事,他们都使用了另一种策略。我们来简单看一下。

这部分代码是在生成 target YY 和 anchor 编号 {0,1,2,}\{0,1,2,\dots\} 的笛卡尔积。

1
2
3
4
5
6
7
8
# targets: [[image, class, x, y, w, h]].
# na: number of anchor in each cell. nt: number of target.
# 关系不大
gain = torch.ones(7, device=targets.device)
# anchor 编号 [[0, 0, 0, ...],[1, 1, 1, ...]]
ai = torch.arange(na, device=targets.device).float().view(na, 1).repeat(1, nt)
# target (x) anchor -> (na, nt, 6+1)
targets = torch.cat((targets.repeat(na, 1, 1), ai[:, :, None]), 2)

此后就是计算 YAY \mapsto A 的关系了。前文我们提到,YOLO 在网络的多层上检测目标,所以此处是对所有层都计算一次。能看到这里的实现并没有按照 0.5 的阈值筛选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# targets: [[image, class, x, y, w, h]].
for i, yolo_layer in enumerate(model.yolo_layers):
# yolo 的配置中 anchor 是相对于图像大小设置的。此处将 anchor 缩放到相对于该层特征长宽的大小。
anchors = yolo_layer.anchors / yolo_layer.stride
# p 为网络输出,第 2 和第 3 维分别为图像在当前层的高度和宽度,target 为相对于原图的位置比例。此处将 target 同样放缩到相对于该层特征长宽的大小。
gain[2:6] = torch.tensor(p[i].shape)[[3, 2, 3, 2]] # xyxy gain
t = targets * gain

if nt:
# 计算 target 和 anchor 之间长宽的比
r = t[:, :, 4:6] / anchors[:, None]
# 剔除那些长宽比例太离谱的 (target, anchor) 组合,同时降维到 (?, 6+1)
j = torch.max(r, 1. / r).max(2)[0] < 4 # compare #TODO
t = t[j]
else:
t = targets[0]

# 下面的代码只是把 t 里的数据转换到合理格式,可以不用看那么仔细。

# Extract image id in batch and class id
b, c = t[:, :2].long().T
# We isolate the target cell associations.
# x, y, w, h are allready in the cell coordinate system meaning an x = 1.2 would be 1.2 times cellwidth
gxy = t[:, 2:4]
gwh = t[:, 4:6] # grid wh
# Cast to int to get an cell index e.g. 1.2 gets associated to cell 1
gij = gxy.long()
# Isolate x and y index dimensions
gi, gj = gij.T # grid xy indices

# Convert anchor indexes to int
a = t[:, 6].long()
# Add target tensors for this yolo layer to the output lists
# Add to index list and limit index range to prevent out of bounds
indices.append((b, a, gj.clamp_(0, gain[3] - 1), gi.clamp_(0, gain[2] - 1)))
# Add to target box list and convert box coordinates from global grid coordinates to local offsets in the grid cell
tbox.append(torch.cat((gxy - gij, gwh), 1)) # box
# Add correct anchor for each target to the list
anch.append(anchors[a])
# Add class for each target to the list
tcls.append(c)

如果去看看另一份代码,还能发现更多的东西。这份代码里甚至保留了被注释的 IoU 筛选代码,转而采取了这种边长比值的办法来筛选。

1
2
3
4
r = t[..., 4:6] / anchors[:, None]  # wh ratio
j = torch.max(r, 1 / r).max(2)[0] < self.hyp['anchor_t'] # compare
# j = wh_iou(anchors, t[:, 4:6]) > model.hyp['iou_t'] # iou(3,n)=wh_iou(anchors(3,2), gwh(n,2))
t = t[j] # filter

至少,两份代码的作者打开始就没解释这样做的原因。在浪费了大量时间踩坑之后我发现此处修改有可能是因为 IoU 的训练难度。IoU 在训练过程中容易把框给训飞了,也就是发散。像是这两份代码里使用的 GIoU、DIoU、CIoU,如果预测框没有包围真实的物体框,在训练中常见的现象是预测框先扩大去包围物体框,再逐步被 IoU 收缩框的大小。结果在预测框边长变大的时候,IoU 直接小小小,0.5 的阈值最后什么也选不到,在中途 loss 就就歇菜了。

在拿到那组 YAY \mapsto A 之后,剩下的就是计算 loss。

Focal Loss

说好的不用 Focal Loss 呢……能发现,两份实现都使用了 Focal Loss。算了,其实我觉得这两份代码之间应该是有什么不可言说的秘密,代码长得都快一样了。下文之后,统称「这份实现」。

这份实现里同时在 obj 和 cls 上都使用了 Focal Loss。在实践中可以发现,即使 P(Ciobject)P(C_i|\mathrm{object}) 可以不用 Focal Loss,P(object)P(\mathrm{object}) 也一定要用什么方式来平衡一下正负类别的。Focal Loss 就是个不错的办法。

LFocal(x,y)=1Li=1L(1pi)γlogpi,\mathcal L_{\mathrm{Focal}}(\mathbf x,\mathbf y)=-\frac{1}{L}\sum_{i=1}^L (1-p_i)^\gamma \log p_i,

Focal Loss 已经在 RetinaNet 一文中介绍了,此处不赘述。

IoU 及其变种

IoU 如上次 RetinaNet 所说,就是下面这个东西。

IoU=ABAB.\mathrm{IoU}=\frac{|A \cap B|}{|A \cup B|}.

IoU 本身在使用上有很多坑。最麻烦的是当两个框完全无交时 IoU 没有梯度。没有梯度就没有优化,就没得可学。当然,IoU 还有别的一些问题,比如它不太能反映预测框的真实效果。

(左)寄,梯度没了。(右)模型:这不怪我,是空子在这,我就钻了。

为了弥补 IoU 在设计上的这样那样的问题,有很多其他变种被逐步提出。例如GIoU。区别在于 GIoU 除了计算交并比外,多出了一项并集与凸包(包围两个框的最小凸多边形 CC)面积的比值。

GIoU(A,B)=IoU(A,B)ABC\mathrm{GIoU}(A,B)=IoU(A,B)-\frac{|A \cup B|}{|C|}

这一比值使得即使框间没有交集,GIoU 仍然能提供梯度,让 AABB 两个框逐步重合。但实际使用时,GIoU 也有问题:可能会出现 ABA \subset B 的情况,此时凸包比值是 11(并集和凸包都是 BB)。如果只是单纯退化到 IoU 倒也好,但因为多出一个优化目标,我在实际使用时发现更容易出现发散和震荡,如果学习率不够玄学,收敛相比之下更困难。

DIoU。入手方向略有不同,它试图把两个框的中心点推到一起,添加了一项框中心点距离和凸包直径距离的比值。这直观理解就没有什么问题了。实际使用时也比 GIoU 要好收敛得多。

我认为 DIoU 算是合格的完成了任务,该有的都有了。至于 CIoU 则是比 DIoU 额外考虑了长宽比,我没有太多研究。如有机会,另行补充。

Non-maximum Suppression

上次的 RetinaNet 没有说到这个东西。网络会对于每一个网格都输出若干个预测框,就会有同一个物体与多个预测框相交,我们显然只需要里面最合适的那一个就够了。Non-maximum Supression 就是一个用来筛选网络预测框的算法。

总的说来一句话就能讲清楚:将置信度最高的框加入最终结果,顺便踢掉与之过于重合的框,然后不断重复这个过程直到没有框。……我个人是感觉这「算法」相比其他算法来说份量略显得轻了一点,没什么意思。这只是个暴力的贪心算法,本身设计上没什么效率与证明可言,放经典算法设计里根本谈不上起新名字。

实现起来多少还是有需谨慎的地方。不过摊上 GPU 并行之后优化代码不是每个人都能做的,容易弄巧成拙。torchvision 里提供了一个 NMS 算法的实现,如果没什么特殊需求建议用那个。

下方是网上一个 python+pytorch 的简单实现。效率很差,只是放在这展示一下算法过程,不要用。想要优化这东西恐怕还是写 CUDA 来得方便,pytorch 的封装太高阶了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def nms3d(bboxes: Tensor, confidences: Tensor, iou_thres: float):
"""
Args:
bboxes: [[z, x, y, depth, height, width]]
confidences: (num of bbox)
iou_thres: iou threshold
"""
order = confidences.argsort(dim=0, descending=True)

res = []
while order.numel() != 0:
if order.numel() == 1:
res.append(order.item())
break
m = order[0]
res.append(m.item())

cur = bboxes[m]
rest = bboxes[order[1:]]
# 这句可能是整段代码里唯一的并行了
ious = cal_iou(cur.unsqueeze(0).repeat(rest.size(0), 1), rest)
idx = torch.where(ious <= iou_thres)[0]
if idx.numel() == 0:
break
order = order[idx + 1]

return res

说些有的没的

从理论上来讲,YOLO 的 anchor 是少于 RetinaNet 的,它的效果也应该不会更好。实验印证了这一想法。如果又快准确率还又高,RetinaNet 就可以打回去重造了。

YOLO 的作者 Joseph Redmon 也是个很有意思的家伙,不管是从自己实现 Darknet 上来看,还是从他网站和论文上看,或者是他的简历。前段时间他因无法容忍自己研究成果在军事等领域造成的负面影响,退出了 CV 研究。


  1. v1 有所不同,它对每个网格输出类别概率,而不是 anchor。

从 YOLOv3 浅度理解目标检测
https://blog.chenc.me/2022/07/21/understand-object-detection-with-yolov3/
作者
CC
发布于
2022年7月21日
许可协议