7.14. YOLOv8-doorloop

De YOLOv8 post-processor is klein genoeg om stap voor stap te doorlopen van invoertensor tot geretourneerde lijst. Hem één keer lezen toont wat elke andere post-processor in de catalogus doet: pas een drempelwaarde toe op gekwantiseerde scores op een goedkope manier, neem wat overblijft, dekwantiseer, decodeer de geometrie, duw naar NMS, geef lijsten per klasse terug.

7.14.1. De begintensor

Een YOLOv8-model geeft een enkele uitvoertensor uit waarvan de vorm (1, C, A) is – één frame, C kanalen, A ankervoorspellingen. De eerste vier kanalen zijn vakgeometrie – cx, cy, w, h – genormaliseerd naar [0, 1] van de invoerafmetingen van het netwerk. De overige C - 4 kanalen zijn scores per klasse, voor een getraind model al in [0, 1]. Elk anker is een kolom langs de kanalen.

Een grid op zijn kant gelegd: rijen gelabeld cx, cy, w, h, score_0, score_1, ..., score_(N-1); kolommen gelabeld anker 0 tot en met anker A-1. Eén kolom is omlijnd om te tonen dat de voorspelling van één enkel anker één kolom langs de kanalen is.

Eén YOLOv8-anker is één kolom langs de kanalen: vier vakgetallen en N klassescores.

De meegeleverde yolov8n_192.tflite is een persoonsdetector met één klasse, dus C = 5 en A ligt in de duizenden; een aangepast model getraind op de volledige 80-klassen COCO-set heeft C = 84 tegen dezelfde A. De onderstaande decodering geldt voor elk aantal klassen.

7.14.2. Drempelwaarde toepassen vóór het dekwantiseren

Eerst de goedkope stap. De uitvoertensor is in het gekwantiseerde integer-dtype van het model, en het dekwantiseren van elke waarde zou elk element raken van een tensor met duizenden vermeldingen – waarvan de meeste onder de score-drempelwaarde liggen en uiteindelijk worden weggegooid. De post-processor kwantiseert in plaats daarvan de drempelwaarde één keer en vergelijkt in ruwe gekwantiseerde ruimte:

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 is de tensor hervormd naar (C, A) zodat de kanalen rijen zijn en de ankers kolommen. score_block is de subtensor met klassescores – alles vanaf rij 4 naar beneden. ml.utils.threshold() reduceert dat blok langs as 0 (find_max=True, find_max_axis=0) tot de maximale score per anker, en geeft vervolgens de indices terug van de ankers waarvan het maximum de gekwantiseerde drempelwaarde passeert. De hele tensor werd nooit gedekwantiseerd; alleen een maximum-reductie per kolom in gekwantiseerde integer-ruimte.

Als geen enkel anker passeert, geeft de post-processor de lege tuple terug die predict() interpreteert als geen detectie.

Twee beslissingen in deze code maken het verschil tussen een uitvoerbare post-processor en een onbruikbaar trage. De eerste is het aandrijven van de rekenkunde via numpy: de uitvoertensor heeft duizenden elementen, en het in ruw Python doorlopen ervan kost hele seconden per inferentie, terwijl dezelfde rekenkunde gevectoriseerd via numpy in milliseconden draait. De tweede is het dekwantiseren na het drempelwaardefilter in plaats van ervoor. Eerst dekwantiseren zou een float-tensor toewijzen die vier keer zo groot is als de gekwantiseerde en elk element doorlopen voordat het er bijna allemaal weggooit; alleen de overgebleven kolommen dekwantiseren raakt hooguit een handvol waarden en bespaart zowel de tijd als de RAM die de volledige conversie zou hebben verbruikt.

7.14.3. De overgeblevenen dekwantiseren

Alleen de overgebleven ankers hebben hun geometrie gedecodeerd nodig. numpy.take() haalt die kolommen eruit en een enkele ml.utils.dequantize() aanroep zet ze om naar floats:

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

bb is nu (C, K) waarbij K het aantal overgebleven ankers is – typisch een handvol, zelfs wanneer A in de duizenden lag.

7.14.4. De geometrie lezen

De vier vakkanalen en de scores per klasse worden er rechtstreeks uitgehaald:

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 is de beste klassescore per overgebleven anker; bb_classes is de klasse-index die die score leverde. De vakgeometrie is nog steeds in genormaliseerde [0, 1] van de invoerafmetingen van het netwerk, dus de volgende stap schaalt het naar 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

Hierna zijn de vakken in de pixelruimte van de netwerkinvoer – de coördinatenruimte die NMS op invoer verwacht.

7.14.5. Non-max suppression

De overgeblevenen gaan door NMS en worden teruggegeven als lijsten per klasse:

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 leest inputs[0].roi zodat de geretourneerde vakken in de coördinatenruimte van de oorspronkelijke afbeelding zijn, niet die van het netwerk – de applicatie tekent ze rechtstreeks op het vastgelegde frame zonder verdere terugzetting.

7.14.6. Wat het script terugkrijgt

De retourwaarde is een lijst van lijsten per klasse, geïndexeerd op klasse. Een voorbeeld met drie klassen zou er als volgt uit kunnen zien:

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

Elke vermelding is een ((x, y, w, h), score) tuple: (x, y) is de linkerbovenhoek van het begrenzingsvak in de pixelcoördinaten van de oorspronkelijke afbeelding, w en h zijn de breedte en hoogte in pixels, en score is het vertrouwen dat het netwerk aan de detectie toekende. Dus ((180, 60, 88, 130), 0.71) leest als een vak waarvan de linkerbovenhoek zich op pixel (180, 60) bevindt, zich 88 pixels naar rechts en 130 pixels naar beneden uitstrekt, en gerapporteerd werd met vertrouwen 0.71.

De buitenste lijst toont twee overgebleven vakken voor klasse 0, niets voor klasse 1, één voor klasse 2. De lege lijst voor klasse 1 blijft op zijn plaats zodat de buitenste index altijd overeenkomt met de klasse-index. Voor de meegeleverde persoonsdetector heeft de buitenste lijst één element waarvan de binnenste lijst de overgebleven persoonsvakken bevat. Voor een 80-klassen model heeft het 80 binnenste lijsten, de meeste leeg op een gegeven frame, met de niet-lege vermeldingen die de vakken bevatten voor de klassen die reageerden. De applicatie leest het resultaat met enumerate(boxes) om de klasse-indices samen met de vaklijsten te doorlopen – dezelfde vorm waar detectie-post-processors in de hele catalogus op richten.