7.14. Présentation détaillée de YOLOv8¶
Le post-traitement YOLOv8 est suffisamment petit pour être parcouru du tenseur d’entrée jusqu’à la liste retournée. Le lire une fois montre ce que fait chaque autre post-traitement du catalogue : appliquer un seuil aux scores quantifiés à peu de frais, prendre ce qui survit, déquantifier, décoder la géométrie, pousser vers la NMS, retourner des listes par classe.
7.14.1. Le tenseur de départ¶
Un modèle YOLOv8 émet un unique tenseur de sortie dont la forme est (1, C, A) – une trame, C canaux, A prédictions d’ancre. Les quatre premiers canaux sont la géométrie de la boîte – cx, cy, w, h – normalisée dans [0, 1] des dimensions d’entrée du réseau. Les C - 4 canaux restants sont les scores par classe, déjà dans [0, 1] pour un modèle entraîné. Chaque ancre est une colonne le long des canaux.
Une ancre YOLOv8 est une colonne le long des canaux : quatre nombres de boîte et N scores de classe.¶
Le yolov8n_192.tflite fourni est un détecteur de personnes à classe unique, donc C = 5 et A se compte en milliers ; un modèle personnalisé entraîné sur l’ensemble complet COCO à 80 classes a C = 84 pour le même A. Le décodage ci-dessous vaut pour n’importe quel nombre de classes.
7.14.2. Appliquer le seuil avant de déquantifier¶
L’étape bon marché d’abord. Le tenseur de sortie est dans le dtype entier quantifié du modèle, et déquantifier chaque valeur toucherait chaque élément d’un tenseur comptant des milliers d’entrées – dont la plupart sont sous le seuil de score et finissent rejetées. Le post-traitement quantifie plutôt le seuil une seule fois et compare dans l’espace quantifié brut
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 est le tenseur remodelé en (C, A) de sorte que les canaux sont des lignes et les ancres des colonnes. score_block est le sous-tenseur des scores de classe – tout depuis la ligne 4 vers le bas. ml.utils.threshold() réduit ce bloc le long de l’axe 0 (find_max=True, find_max_axis=0) au score maximal par ancre, puis retourne les indices des ancres dont le maximum passe le seuil quantifié. Le tenseur entier n’a jamais été déquantifié ; seulement une réduction au maximum par colonne dans l’espace entier quantifié.
Si aucune ancre ne passe, le post-traitement retourne le tuple vide que predict() interprète comme une absence de détection.
Deux décisions dans ce code font la différence entre un post-traitement exécutable et un post-traitement inutilisablement lent. La première est de faire passer l’arithmétique par numpy : le tenseur de sortie compte des milliers d’éléments, et l’itérer en Python brut prend des secondes entières par inférence, là où la même arithmétique vectorisée via numpy s’exécute en millisecondes. La seconde est de déquantifier après le filtre de seuil plutôt qu’avant. Déquantifier d’abord allouerait un tenseur flottant quatre fois plus grand que le tenseur quantifié et parcourrait chaque élément avant de les rejeter presque tous ; ne déquantifier que les colonnes survivantes ne touche au plus qu’une poignée de valeurs et économise à la fois le temps et la RAM qu’aurait consommés la conversion complète.
7.14.3. Déquantifier les survivantes¶
Seules les ancres survivantes ont besoin que leur géométrie soit décodée. numpy.take() extrait ces colonnes et un seul appel à ml.utils.dequantize() les convertit en flottants
bb = dequantize(model,
np.take(column_outputs, score_indices, axis=1))
bb est maintenant (C, K) où K est le nombre d’ancres survivantes – généralement une poignée même lorsque A se comptait en milliers.
7.14.4. Lire la géométrie¶
Les quatre canaux de boîte et les scores par classe sont extraits directement
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 est le meilleur score de classe par ancre survivante ; bb_classes est l’index de classe qui a fourni ce score. La géométrie de la boîte est toujours normalisée dans [0, 1] des dimensions d’entrée du réseau, donc l’étape suivante la met à l’échelle en pixels
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
Après cela, les boîtes sont dans l’espace de pixels d’entrée du réseau – l’espace de coordonnées que NMS attend en entrée.
7.14.5. Suppression des non-maxima¶
Les survivantes passent par la NMS et sont retournées sous forme de listes par 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 lit inputs[0].roi afin que les boîtes retournées soient dans l’espace de coordonnées de l’image d’origine, et non dans celui du réseau – l’application les dessine directement sur la trame capturée sans remappage supplémentaire.
7.14.6. Ce que le script récupère¶
La valeur de retour est une liste de listes par classe indexée par classe. Un exemple à trois classes pourrait ressembler à ceci
[
[((23, 41, 95, 142), 0.92), ((180, 60, 88, 130), 0.71)],
[],
[((310, 95, 55, 70), 0.85)],
]
Chaque entrée est un tuple ((x, y, w, h), score) : (x, y) est le coin supérieur gauche de la boîte englobante dans les coordonnées en pixels de l’image d’origine, w et h sont sa largeur et sa hauteur en pixels, et score est la confiance que le réseau a attribuée à la détection. Ainsi ((180, 60, 88, 130), 0.71) se lit comme une boîte dont le coin supérieur gauche se situe au pixel (180, 60), qui s’étend de 88 pixels vers la droite et de 130 pixels vers le bas, et qui a été rapportée avec une confiance de 0.71.
La liste externe montre deux boîtes survivantes pour la classe 0, rien pour la classe 1, une pour la classe 2. La liste vide pour la classe 1 est conservée en place afin que l’index externe corresponde toujours à l’index de classe. Pour le détecteur de personnes fourni, la liste externe comporte un seul élément dont la liste interne contient les boîtes de personnes survivantes. Pour un modèle à 80 classes, elle comporte 80 listes internes, la plupart vides sur une trame donnée, les entrées non vides contenant les boîtes des classes qui se sont déclenchées. L’application lit le résultat avec enumerate(boxes) pour parcourir les index de classe en même temps que les listes de boîtes – la même forme que ciblent les post-traitements de détection à travers le catalogue.