7.14. YOLOv8-Durchführung

Der YOLOv8-Post-Prozessor ist klein genug, um ihn vom Eingabetensor bis zur zurückgegebenen Liste durchzugehen. Ihn einmal zu lesen zeigt, was jeder andere Post-Prozessor im Katalog tut: quantisierte Scores günstig schwellwertfiltern, die Überlebenden nehmen, dequantisieren, Geometrie dekodieren, an NMS schieben, Listen pro Klasse zurückgeben.

7.14.1. Der Ausgangstensor

Ein YOLOv8-Modell gibt einen einzelnen Ausgabetensor aus, dessen Form (1, C, A) ist – ein Einzelbild, C Kanäle, A Ankervorhersagen. Die ersten vier Kanäle sind Rahmengeometrie – cx, cy, w, h –, normalisiert auf [0, 1] der Eingabedimensionen des Netzes. Die verbleibenden C - 4 Kanäle sind Scores pro Klasse, bei einem trainierten Modell bereits in [0, 1]. Jeder Anker ist eine Spalte entlang der Kanäle.

Ein auf die Seite gelegtes Gitter: Zeilen beschriftet mit cx, cy, w, h, score_0, score_1, ..., score_(N-1); Spalten beschriftet mit anchor 0 bis anchor A-1. Eine Spalte ist umrandet, um zu zeigen, dass die Vorhersage eines einzelnen Ankers eine Spalte entlang der Kanäle ist.

Ein YOLOv8-Anker ist eine Spalte entlang der Kanäle: vier Rahmenzahlen und N Klassen-Scores.

Die mitgelieferte yolov8n_192.tflite ist ein einklassiger Personendetektor, sodass C = 5 ist und A in den Tausenden liegt; ein benutzerdefiniertes Modell, das auf dem vollen 80-klassigen COCO-Satz trainiert wurde, hat C = 84 gegen dasselbe A. Das nachfolgende Dekodieren gilt für jede Klassenanzahl.

7.14.2. Schwellwertfilterung vor dem Dequantisieren

Zuerst der günstige Schritt. Der Ausgabetensor liegt im quantisierten Integer-dtype des Modells vor, und das Dequantisieren jedes Werts würde jedes Element eines Tensors mit Tausenden von Einträgen berühren – die meisten davon liegen unter dem Score-Schwellenwert und werden letztlich verworfen. Der Post-Prozessor quantisiert stattdessen den Schwellenwert einmal und vergleicht im rohen quantisierten Raum:

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 ist der auf (C, A) umgeformte Tensor, sodass die Kanäle die Zeilen und die Anker die Spalten sind. score_block ist der Unter-Tensor der Klassen-Scores – alles ab Zeile 4 abwärts. ml.utils.threshold() reduziert diesen Block entlang der Achse 0 (find_max=True, find_max_axis=0) auf den maximalen Score pro Anker und gibt dann die Indizes der Anker zurück, deren Maximum den quantisierten Schwellenwert übersteigt. Der gesamte Tensor wurde nie dequantisiert; nur eine Max-Reduktion pro Spalte im quantisierten Integer-Raum.

Wenn kein Anker besteht, gibt der Post-Prozessor das leere Tupel zurück, das predict() als keine Erkennung interpretiert.

Zwei Entscheidungen in diesem Code machen den Unterschied zwischen einem lauffähigen Post-Prozessor und einem unbrauchbar langsamen aus. Die erste ist, die Arithmetik durch numpy zu treiben: Der Ausgabetensor hat Tausende von Elementen, und ihn in reinem Python zu durchlaufen dauert ganze Sekunden pro Inferenz, während dieselbe Arithmetik, durch numpy vektorisiert, in Millisekunden läuft. Die zweite ist, nach dem Schwellwertfilter zu dequantisieren statt davor. Zuerst zu dequantisieren würde einen Float-Tensor von viermal der Größe des quantisierten allokieren und jedes Element durchlaufen, bevor nahezu alle davon verworfen werden; nur die überlebenden Spalten zu dequantisieren berührt höchstens eine Handvoll Werte und spart sowohl die Zeit als auch den RAM, den die vollständige Konvertierung verbraucht hätte.

7.14.3. Die Überlebenden dequantisieren

Nur die überlebenden Anker benötigen das Dekodieren ihrer Geometrie. numpy.take() zieht diese Spalten heraus, und ein einzelner Aufruf von ml.utils.dequantize() konvertiert sie in Floats:

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

bb ist nun (C, K), wobei K die Anzahl der überlebenden Anker ist – typischerweise eine Handvoll, selbst wenn A in den Tausenden lag.

7.14.4. Die Geometrie auslesen

Die vier Rahmenkanäle und die Scores pro Klasse werden direkt herausgezogen:

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 ist der beste Klassen-Score pro überlebendem Anker; bb_classes ist der Klassenindex, der diesen Score geliefert hat. Die Rahmengeometrie liegt noch im normalisierten [0, 1] der Netzeingabedimensionen vor, sodass der nächste Schritt sie auf Pixel skaliert:

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

Danach liegen die Rahmen im Netzeingabe-Pixelraum vor – dem Koordinatenraum, den NMS als Eingabe erwartet.

7.14.5. Non-Maximum-Suppression

Die Überlebenden durchlaufen NMS und werden als Listen pro Klasse zurückgegeben:

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 liest inputs[0].roi, sodass die zurückgegebenen Rahmen im Koordinatenraum des ursprünglichen Bildes liegen, nicht in dem des Netzes – die Anwendung zeichnet sie ohne weitere Rückabbildung direkt auf das erfasste Einzelbild.

7.14.6. Was das Skript zurückbekommt

Der Rückgabewert ist eine Liste von Listen pro Klasse, indiziert nach Klasse. Ein Beispiel mit drei Klassen könnte so aussehen:

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

Jeder Eintrag ist ein ((x, y, w, h), score)-Tupel: (x, y) ist die obere linke Ecke des Begrenzungsrahmens in den Pixelkoordinaten des ursprünglichen Bildes, w und h sind seine Breite und Höhe in Pixeln, und score ist die Konfidenz, die das Netz der Erkennung zugewiesen hat. So liest sich ((180, 60, 88, 130), 0.71) als ein Rahmen, dessen obere linke Ecke am Pixel (180, 60) sitzt, sich 88 Pixel nach rechts und 130 Pixel nach unten erstreckt und mit der Konfidenz 0.71 gemeldet wurde.

Die äußere Liste zeigt zwei überlebende Rahmen für Klasse 0, nichts für Klasse 1, einen für Klasse 2. Die leere Liste für Klasse 1 wird beibehalten, sodass der äußere Index stets dem Klassenindex entspricht. Für den mitgelieferten Personendetektor hat die äußere Liste ein einzelnes Element, dessen innere Liste die überlebenden Personenrahmen enthält. Für ein 80-klassiges Modell hat sie 80 innere Listen, die meisten in einem gegebenen Einzelbild leer, wobei die nicht leeren Einträge die Rahmen für die Klassen enthalten, die gefeuert haben. Die Anwendung liest das Ergebnis mit enumerate(boxes), um die Klassenindizes zusammen mit den Rahmenlisten zu durchlaufen – dieselbe Form, auf die Erkennungs-Post-Prozessoren im gesamten Katalog abzielen.