7.14. YOLOv8 逐步解說¶
YOLOv8 後處理器夠小,足以從輸入張量一路逐步走到回傳清單。讀過它一遍,就能看出目錄中其餘每個後處理器都在做什麼:以低成本對量化分數進行閾值過濾、取出存活者、去量化、解碼幾何資訊、推送給 NMS、回傳各類別清單。
7.14.1. 起始張量¶
YOLOv8 模型輸出單一個輸出張量,其形狀為 (1, C, A) ── 一個影格、C 個通道、A 個錨點預測。前四個通道是框幾何資訊 ── cx、cy、w、h ── 已正規化為網路輸入維度的 [0, 1]。其餘的 C - 4 個通道是各類別分數,對於已訓練的模型而言同樣落在 [0, 1]。每個錨點是沿著通道向下的一欄。
一個 YOLOv8 錨點就是沿著通道向下的一欄:四個框數值與 N 個類別分數。¶
隨附的 yolov8n_192.tflite 是一個單類別的人員偵測器,因此 C = 5 而 A 達數千之譜;一個在完整 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=True、find_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) 是邊界框在原始影像像素座標中的左上角,w 與 h 是其以像素計的寬度與高度,而 score 是網路對該偵測所賦予的信心度。因此 ((180, 60, 88, 130), 0.71) 可解讀為一個左上角位於像素 (180, 60)、向右延伸 88 像素、向下延伸 130 像素,並以 0.71 的信心度回報的框。
外層清單顯示類別 0 有兩個存活的框、類別 1 沒有任何框、類別 2 有一個框。類別 1 的空清單會原位保留,使外層索引始終與類別索引相符。對於隨附的人員偵測器,外層清單只有單一個元素,其內層清單包含存活的人員框。對於一個 80 類別的模型,它有 80 個內層清單,在任一給定影格上多數為空,非空的項目則保存著有觸發之類別的框。應用程式以 enumerate(boxes) 讀取結果,以便同時走訪類別索引與框清單 ── 這正是整個目錄中偵測後處理器所一致採用的結構。