7.14. YOLOv8 详解

YOLOv8 后处理器足够简短,可以从输入张量一直走读到返回的列表。读一遍它就能看清目录中其他每个后处理器都在做什么:廉价地对量化得分做阈值过滤、取出存活的部分、反量化、解码几何形状、推送给 NMS、返回按类划分的列表。

7.14.1. 起始张量

一个 YOLOv8 模型输出单个输出张量,其形状为 (1, C, A) ——一帧、C 个通道、A 个锚点预测。前四个通道是框的几何信息——cxcywh ——归一化到网络输入维度的 [0, 1] 范围。其余 C - 4 个通道是按类得分,对于训练好的模型已处于 [0, 1] 范围。每个锚点是沿通道方向的一列。

一个侧放的网格:各行标注为 cx、cy、w、h、 score_0、score_1、……、score_(N-1);各列标注为 anchor 0 到 anchor A-1。其中一列被勾勒出来,以 表明单个锚点的预测是沿通道方向的一列。

一个 YOLOv8 锚点是沿通道方向的一列:四个框数值和 N 个类别得分。

内置的 yolov8n_192.tflite 是一个单类别的人体检测器,因此 C = 5A 达数千;针对完整 80 类 COCO 集训练的自定义模型则在相同的 A 下有 C = 84。下面的解码过程对任意类别数都成立。

7.14.2. 在反量化之前进行阈值过滤

先做廉价的步骤。输出张量处于模型的量化整数 dtype,而对每个值都进行反量化会触及一个有数千条目的张量的每个元素——其中大多数都低于得分阈值,最终会被丢弃。后处理器转而对阈值进行一次量化,并在原始量化空间中进行比较:

from ml.utils import quantize, threshold, dequantize, NMS
from ulab import numpy as np

oh, ow, oc = model.output_shape[0]
scale = model.output_scale[0]
t = quantize(model, self.threshold)

column_outputs = outputs[0].reshape((oh * ow, oc))

score_block = column_outputs[4:, :]
score_indices = threshold(score_block, t, scale,
                          find_max=True,
                          find_max_axis=0)
if not len(score_indices):
    return ()

column_outputs 是被重塑为 (C, A) 的张量,使得通道为行、锚点为列。score_block 是类别得分子张量——从第 4 行往下的所有内容。ml.utils.threshold() 沿轴 0 对该块进行归约(find_max=Truefind_max_axis=0)得到每个锚点的最大得分,然后返回最大值通过了量化阈值的那些锚点的索引。整个张量从未被反量化;仅在量化整数空间中做了一次按列的最大值归约。

如果没有锚点通过,后处理器会返回空元组,predict() 将其解释为无检测结果。

这段代码中的两个决定,决定了一个后处理器是可运行的还是慢得无法使用的。第一个是通过 numpy 来驱动运算:输出张量有数千个元素,用纯 Python 迭代它每次推理需要好几秒,而通过 numpy 矢量化的同样运算则在毫秒级完成。第二个是阈值过滤之后而非之前进行反量化。先反量化会分配一个比量化张量大四倍的浮点张量,并在丢弃几乎所有元素之前遍历每个元素;而只对存活下来的列进行反量化最多只触及少数几个值,既节省了时间,也节省了完整转换本会消耗的 RAM。

7.14.3. 对存活者进行反量化

只有存活下来的锚点才需要解码其几何信息。numpy.take() 把那些列取出来,然后单次 ml.utils.dequantize() 调用将它们转换为浮点数:

bb = dequantize(model,
                np.take(column_outputs, score_indices, axis=1))

bb 现在是 (C, K),其中 K 是存活锚点的数量——即使 A 曾达数千,这通常也只是少数几个。

7.14.4. 读取几何信息

四个框通道和按类得分被直接取出:

bb_scores  = np.max(bb[4:, :],    axis=0)
bb_classes = np.argmax(bb[4:, :], axis=0)

x_center = bb[0, :]
y_center = bb[1, :]
w_half   = bb[2, :] * 0.5
h_half   = bb[3, :] * 0.5

bb_scores 是每个存活锚点的最佳类别得分;bb_classes 是给出该得分的类别索引。框的几何信息仍处于网络输入维度的归一化 [0, 1] 范围,因此下一步将其缩放到像素:

ib, ih, iw, ic = model.input_shape[0]
xmin = (x_center - w_half) * iw
ymin = (y_center - h_half) * ih
xmax = (x_center + w_half) * iw
ymax = (y_center + h_half) * ih

此后,这些框就处于网络输入像素空间——也就是 NMS 在输入时所期望的坐标空间。

7.14.5. 非极大值抑制

存活者经过 NMS 处理,并以按类划分的列表返回:

nms = NMS(iw, ih, inputs[0].roi)
for i in range(bb.shape[1]):
    nms.add_bounding_box(xmin[i], ymin[i],
                         xmax[i], ymax[i],
                         bb_scores[i], bb_classes[i])
return nms.get_bounding_boxes(threshold=self.nms_threshold,
                              sigma=self.nms_sigma)

NMS 读取 inputs[0].roi,因此返回的框处于原始图像的坐标空间,而非网络的坐标空间——应用程序无需进一步重映射即可将它们直接绘制到所捕获的帧上。

7.14.6. 脚本拿回什么

返回值是一个按类索引的、按类划分的列表。一个三类别的例子可能看起来像这样:

[
    [((23, 41, 95, 142), 0.92), ((180, 60, 88, 130), 0.71)],
    [],
    [((310, 95, 55, 70), 0.85)],
]

每个条目都是一个 ((x, y, w, h), score) 元组:(x, y) 是边界框在原始图像像素坐标中的左上角,wh 是它以像素为单位的宽度和高度,score 是网络对该检测赋予的置信度。因此 ((180, 60, 88, 130), 0.71) 读作:一个左上角位于像素 (180, 60) 的框,向右延伸 88 像素、向下延伸 130 像素,并以置信度 0.71 报告。

外层列表显示类别 0 有两个存活的框,类别 1 没有,类别 2 有一个。类别 1 的空列表被保留在原位,以便外层索引始终与类别索引相匹配。对于内置的人体检测器,外层列表只有一个元素,其内层列表包含存活的人体框。对于一个 80 类的模型,它有 80 个内层列表,在任意给定的一帧中大多数为空,非空的条目则保存着被激活类别的框。应用程序使用 enumerate(boxes) 读取结果,以在遍历类别索引的同时一并获取框列表——这正是整个目录中检测后处理器所采用的相同结构。