7.14. Recorrido de YOLOv8

El postprocesador de YOLOv8 es lo bastante pequeño como para repasarlo desde el tensor de entrada hasta la lista devuelta. Leerlo una vez muestra lo que hace cualquier otro postprocesador del catálogo: aplicar umbral a las puntuaciones cuantizadas de forma económica, tomar lo que sobrevive, descuantizar, decodificar la geometría, enviar a NMS y devolver listas por clase.

7.14.1. El tensor de partida

Un modelo YOLOv8 emite un único tensor de salida cuya forma es (1, C, A) – un fotograma, C canales, A predicciones de ancla. Los primeros cuatro canales son la geometría del cuadro – cx, cy, w, h – normalizada a [0, 1] de las dimensiones de entrada de la red. Los C - 4 canales restantes son las puntuaciones por clase, ya en [0, 1] para un modelo entrenado. Cada ancla es una columna a lo largo de los canales.

Una cuadrícula colocada de lado: filas etiquetadas cx, cy, w, h, score_0, score_1, ..., score_(N-1); columnas etiquetadas ancla 0 hasta ancla A-1. Una columna está delineada para mostrar que la predicción de un único ancla es una columna a lo largo de los canales.

Un ancla de YOLOv8 es una columna a lo largo de los canales: cuatro números de cuadro y N puntuaciones de clase.

El yolov8n_192.tflite incluido es un detector de personas de una sola clase, así que C = 5 y A está en los miles; un modelo personalizado entrenado con el conjunto completo de 80 clases de COCO tiene C = 84 contra el mismo A. La decodificación de abajo es válida para cualquier número de clases.

7.14.2. Aplicar umbral antes de descuantizar

El paso económico primero. El tensor de salida está en el dtype entero cuantizado del modelo, y descuantizar cada valor tocaría cada elemento de un tensor con miles de entradas – la mayoría de las cuales están por debajo del umbral de puntuación y acaban descartadas. En su lugar, el postprocesador cuantiza el umbral una vez y compara en el espacio cuantizado en bruto:

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 es el tensor reformado a (C, A) de modo que los canales son filas y las anclas son columnas. score_block es el subtensor de puntuaciones de clase – todo desde la fila 4 hacia abajo. ml.utils.threshold() reduce ese bloque a lo largo del eje 0 (find_max=True, find_max_axis=0) a la puntuación máxima por ancla, y luego devuelve los índices de las anclas cuyo máximo supera el umbral cuantizado. El tensor completo nunca se descuantizó; solo una reducción de máximo por columna en el espacio entero cuantizado.

Si ninguna ancla supera, el postprocesador devuelve la tupla vacía que predict() interpreta como ausencia de detección.

Dos decisiones en este código marcan la diferencia entre un postprocesador ejecutable y uno inusablemente lento. La primera es realizar la aritmética a través de numpy: el tensor de salida tiene miles de elementos, e iterarlo en Python puro tarda segundos enteros por inferencia, mientras que la misma aritmética vectorizada a través de numpy se ejecuta en milisegundos. La segunda es descuantizar después del filtro de umbral en lugar de antes. Descuantizar primero asignaría un tensor flotante cuatro veces el tamaño del cuantizado y recorrería cada elemento antes de descartar casi todos; descuantizar solo las columnas supervivientes toca, como mucho, un puñado de valores y ahorra tanto el tiempo como la RAM que la conversión completa habría consumido.

7.14.3. Descuantizar los supervivientes

Solo las anclas supervivientes necesitan que se decodifique su geometría. numpy.take() extrae esas columnas y una única llamada a ml.utils.dequantize() las convierte a flotantes:

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

bb es ahora (C, K) donde K es el número de anclas supervivientes – normalmente un puñado incluso cuando A estaba en los miles.

7.14.4. Leer la geometría

Los cuatro canales del cuadro y las puntuaciones por clase se extraen directamente:

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 es la mejor puntuación de clase por ancla superviviente; bb_classes es el índice de clase que produjo esa puntuación. La geometría del cuadro sigue estando en [0, 1] normalizado de las dimensiones de entrada de la red, así que el siguiente paso la escala a píxeles:

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

Tras esto, los cuadros están en el espacio de píxeles de entrada de la red – el espacio de coordenadas que NMS espera en la entrada.

7.14.5. Supresión de no máximos

Los supervivientes pasan por NMS y se devuelven como listas por clase:

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 lee inputs[0].roi de modo que los cuadros devueltos están en el espacio de coordenadas de la imagen original, no en el de la red – la aplicación los dibuja sobre el fotograma capturado directamente sin más reasignación.

7.14.6. Lo que recibe el script

El valor de retorno es una lista de listas por clase indexada por clase. Un ejemplo de tres clases podría tener este aspecto:

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

Cada entrada es una tupla ((x, y, w, h), score): (x, y) es la esquina superior izquierda del cuadro delimitador en las coordenadas de píxel de la imagen original, w y h son su ancho y alto en píxeles, y score es la confianza que la red asignó a la detección. Así que ((180, 60, 88, 130), 0.71) se lee como un cuadro cuya esquina superior izquierda se sitúa en el píxel (180, 60), se extiende 88 píxeles a la derecha y 130 píxeles hacia abajo, y se reportó con una confianza de 0.71.

La lista exterior muestra dos cuadros supervivientes para la clase 0, nada para la clase 1, uno para la clase 2. La lista vacía para la clase 1 se mantiene en su lugar para que el índice exterior siempre coincida con el índice de clase. Para el detector de personas incluido, la lista exterior tiene un único elemento cuya lista interior contiene los cuadros de personas supervivientes. Para un modelo de 80 clases tiene 80 listas interiores, la mayoría vacías en cualquier fotograma dado, con las entradas no vacías conteniendo los cuadros de las clases que se activaron. La aplicación lee el resultado con enumerate(boxes) para recorrer los índices de clase junto con las listas de cuadros – la misma forma a la que apuntan los postprocesadores de detección en todo el catálogo.