7.14. Spiegazione dettagliata di YOLOv8¶
Il post-processore di YOLOv8 è abbastanza piccolo da poter essere ripercorso passo dopo passo dal tensore di input alla lista restituita. Leggerlo una volta mostra cosa sta facendo ogni altro post-processore del catalogo: applicare la soglia ai punteggi quantizzati a basso costo, prendere ciò che sopravvive, dequantizzare, decodificare la geometria, inviare alla NMS, restituire le liste per classe.
7.14.1. Il tensore di partenza¶
Un modello YOLOv8 emette un singolo tensore di output la cui forma è (1, C, A) – un frame, C canali, A predizioni di anchor. I primi quattro canali sono la geometria del box – cx, cy, w, h – normalizzati a [0, 1] delle dimensioni di input della rete. I restanti C - 4 canali sono i punteggi per classe, già in [0, 1] per un modello addestrato. Ogni anchor è una colonna lungo i canali.
Un anchor YOLOv8 è una colonna lungo i canali: quattro numeri del box e N punteggi di classe.¶
Il yolov8n_192.tflite fornito è un rilevatore di persone a classe singola, quindi C = 5 e A è nell’ordine delle migliaia; un modello personalizzato addestrato sull’intero set COCO a 80 classi ha C = 84 con lo stesso A. La decodifica seguente vale per qualsiasi numero di classi.
7.14.2. Applicare la soglia prima di dequantizzare¶
Prima il passo a basso costo. Il tensore di output è nel dtype intero quantizzato del modello, e dequantizzare ogni valore toccherebbe ogni elemento di un tensore con migliaia di voci – la maggior parte delle quali è al di sotto della soglia di punteggio e finisce per essere scartata. Il post-processore quantizza invece la soglia una volta e confronta nello spazio quantizzato grezzo:
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 è il tensore rimodellato a (C, A) così che i canali siano righe e gli anchor siano colonne. score_block è il sotto-tensore dei punteggi di classe – tutto a partire dalla riga 4 in giù. ml.utils.threshold() riduce quel blocco lungo l’asse 0 (find_max=True, find_max_axis=0) al punteggio massimo per anchor, poi restituisce gli indici degli anchor il cui massimo supera la soglia quantizzata. L’intero tensore non è mai stato dequantizzato; solo una riduzione del massimo per colonna nello spazio intero quantizzato.
Se nessun anchor supera la soglia, il post-processore restituisce la tupla vuota che predict() interpreta come nessun rilevamento.
Due decisioni in questo codice fanno la differenza tra un post-processore eseguibile e uno inutilizzabilmente lento. La prima è guidare l’aritmetica attraverso numpy: il tensore di output ha migliaia di elementi, e iterarlo in puro Python richiede interi secondi per inferenza, mentre la stessa aritmetica vettorizzata tramite numpy viene eseguita in millisecondi. La seconda è dequantizzare dopo il filtro di soglia anziché prima. Dequantizzare prima allocherebbe un tensore float di dimensioni quattro volte superiori a quello quantizzato e percorrerebbe ogni elemento prima di scartarne quasi tutti; dequantizzare solo le colonne sopravvissute tocca al massimo una manciata di valori e fa risparmiare sia il tempo sia la RAM che la conversione completa avrebbe consumato.
7.14.3. Dequantizzare i sopravvissuti¶
Solo gli anchor sopravvissuti necessitano della decodifica della loro geometria. numpy.take() estrae quelle colonne e una singola chiamata a ml.utils.dequantize() le converte in float:
bb = dequantize(model,
np.take(column_outputs, score_indices, axis=1))
bb è ora (C, K) dove K è il numero di anchor sopravvissuti – tipicamente una manciata anche quando A era nell’ordine delle migliaia.
7.14.4. Leggere la geometria¶
I quattro canali del box e i punteggi per classe vengono estratti direttamente:
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 è il miglior punteggio di classe per ogni anchor sopravvissuto; bb_classes è l’indice di classe che ha fornito quel punteggio. La geometria del box è ancora in [0, 1] normalizzato delle dimensioni di input della rete, quindi il passo successivo la scala in pixel:
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
Dopo questo i box sono nello spazio di pixel di input della rete – lo spazio di coordinate che NMS si aspetta in input.
7.14.5. Non-max suppression¶
I sopravvissuti passano attraverso la NMS e vengono restituiti come liste per classe:
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 legge inputs[0].roi così che i box restituiti siano nello spazio di coordinate dell’immagine originale, non in quello della rete – l’applicazione li disegna direttamente sul frame catturato senza ulteriore rimappaggio.
7.14.6. Cosa riceve lo script¶
Il valore di ritorno è una lista di liste per classe indicizzata per classe. Un esempio a tre classi potrebbe apparire così:
[
[((23, 41, 95, 142), 0.92), ((180, 60, 88, 130), 0.71)],
[],
[((310, 95, 55, 70), 0.85)],
]
Ogni voce è una tupla ((x, y, w, h), score): (x, y) è l’angolo in alto a sinistra del bounding box nelle coordinate in pixel dell’immagine originale, w e h sono la sua larghezza e altezza in pixel, e score è la confidenza che la rete ha assegnato al rilevamento. Quindi ((180, 60, 88, 130), 0.71) si legge come un box il cui angolo in alto a sinistra si trova al pixel (180, 60), si estende 88 pixel a destra e 130 pixel verso il basso, ed è stato riportato con confidenza 0.71.
La lista esterna mostra due box sopravvissuti per la classe 0, niente per la classe 1, uno per la classe 2. La lista vuota per la classe 1 viene mantenuta al suo posto in modo che l’indice esterno corrisponda sempre all’indice di classe. Per il rilevatore di persone fornito la lista esterna ha un singolo elemento la cui lista interna contiene i box di persone sopravvissuti. Per un modello a 80 classi ha 80 liste interne, la maggior parte vuote in un dato frame, con le voci non vuote che contengono i box per le classi che si sono attivate. L’applicazione legge il risultato con enumerate(boxes) per percorrere gli indici di classe insieme alle liste di box – la stessa forma a cui mirano i post-processori di rilevamento in tutto il catalogo.