7.14. YOLOv8ウォークスルー

YOLOv8の後処理は、入力テンソルから返されるリストまで一通り追えるほど小さいものです。一度読めば、カタログ内の他のすべての後処理が何をしているかがわかります。量子化されたスコアを安価にしきい値処理し、生き残ったものを取り、逆量子化し、ジオメトリをデコードし、NMSに渡し、クラスごとのリストを返す、という流れです。

7.14.1. 開始時のテンソル

YOLOv8モデルは、形状が (1, C, A) の単一の出力テンソルを出力します。1フレーム、C チャンネル、A アンカー予測です。最初の4つのチャンネルはボックスのジオメトリ(cxcywh)で、ネットワークの入力次元の [0, 1] に正規化されています。残りの C - 4 チャンネルはクラスごとのスコアで、学習済みモデルでは既に [0, 1] の範囲にあります。各アンカーはチャンネル方向に並んだ1つの列です。

横向きに置かれたグリッド。行にはcx、cy、w、h、score_0、score_1、...、score_(N-1)のラベルが付き、列にはアンカー0からアンカーA-1までのラベルが付いています。1つの列が囲まれていて、単一のアンカーの予測がチャンネル方向の1つの列であることを示しています。

1つのYOLOv8アンカーはチャンネル方向の1つの列です。4つのボックスの数値と 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() が検出なしと解釈する空のタプルを返します。

このコード内の2つの判断が、実行可能な後処理と使い物にならないほど遅い後処理との違いを生みます。1つ目は numpy を通して演算を駆動することです。出力テンソルは数千の要素を持ち、生のPythonでそれを反復処理すると推論ごとに数秒かかりますが、同じ演算を numpy でベクトル化するとミリ秒で実行されます。2つ目は、しきい値フィルタの ではなく に逆量子化することです。先に逆量子化すると、量子化されたものの4倍のサイズの浮動小数点テンソルを確保し、そのほぼすべてを破棄する前にすべての要素をたどることになります。生き残った列だけを逆量子化すれば、せいぜい一握りの値に触れるだけで、完全な変換が消費したはずの時間とRAMの両方を節約できます。

7.14.3. 生き残ったものを逆量子化する

生き残ったアンカーだけがジオメトリのデコードを必要とします。numpy.take() がそれらの列を引き出し、1回の ml.utils.dequantize() 呼び出しでそれらを浮動小数点に変換します:

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

bb は今や (C, K) で、K は生き残ったアンカーの数です。A が数千であっても、通常はほんの一握りです。

7.14.4. ジオメトリを読み取る

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. Non-max suppression

生き残ったものは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)

NMSinputs[0].roi を読み取るため、返されるボックスはネットワークの座標空間ではなく元画像の座標空間になります。アプリケーションはそれらをさらに再マッピングすることなく、キャプチャされたフレームに直接描画します。

7.14.6. スクリプトが受け取るもの

戻り値はクラスでインデックス付けされた、クラスごとのリストのリストです。3クラスの例は次のようになります:

[
    [((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 に対して2つの生き残ったボックス、クラス 1 に対しては何もなし、クラス 2 に対して1つを示しています。クラス 1 の空のリストは、外側のインデックスが常にクラスインデックスと一致するように保持されます。出荷される人物検出器では、外側のリストは単一の要素を持ち、その内側のリストには生き残った人物ボックスが含まれます。80クラスのモデルでは、80個の内側のリストを持ち、そのほとんどは任意のフレームで空ですが、空でないエントリには発火したクラスのボックスが含まれます。アプリケーションは enumerate(boxes) で結果を読み取り、クラスインデックスとボックスのリストを一緒にたどります。これはカタログ全体で検出後処理が対象とするのと同じ形状です。