7.14. Passo a passo do YOLOv8

O pós-processador do YOLOv8 é pequeno o suficiente para ser percorrido do tensor de entrada até a lista retornada. Lê-lo uma vez mostra o que todos os outros pós-processadores do catálogo estão fazendo: aplicar limiar às pontuações quantizadas de forma barata, pegar o que sobrevive, desquantizar, decodificar a geometria, empurrar para o NMS, retornar listas por classe.

7.14.1. O tensor inicial

Um modelo YOLOv8 emite um único tensor de saída cujo formato é (1, C, A) – um quadro, C canais, A predições de âncora. Os primeiros quatro canais são a geometria da caixa – cx, cy, w, h – normalizados para [0, 1] das dimensões de entrada da rede. Os C - 4 canais restantes são pontuações por classe, já em [0, 1] para um modelo treinado. Cada âncora é uma coluna ao longo dos canais.

Uma grade deitada de lado: linhas rotuladas cx, cy, w, h, score_0, score_1, ..., score_(N-1); colunas rotuladas anchor 0 até anchor A-1. Uma coluna está destacada para mostrar que a predição de uma única âncora é uma coluna ao longo dos canais.

Uma âncora YOLOv8 é uma coluna ao longo dos canais: quatro números de caixa e N pontuações de classe.

O yolov8n_192.tflite fornecido é um detector de pessoas de classe única, então C = 5 e A está na casa dos milhares; um modelo personalizado treinado no conjunto COCO completo de 80 classes tem C = 84 contra o mesmo A. A decodificação abaixo vale para qualquer número de classes.

7.14.2. Aplicando o limiar antes de desquantizar

A etapa barata primeiro. O tensor de saída está no dtype inteiro quantizado do modelo, e desquantizar cada valor tocaria cada elemento de um tensor com milhares de entradas – a maioria das quais está abaixo do limiar de pontuação e acaba descartada. Em vez disso, o pós-processador quantiza o limiar uma vez e compara no espaço quantizado 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 é o tensor remodelado para (C, A) de modo que os canais são linhas e as âncoras são colunas. score_block é o subtensor de pontuações de classe – tudo a partir da linha 4 para baixo. ml.utils.threshold() reduz esse bloco ao longo do eixo 0 (find_max=True, find_max_axis=0) para a pontuação máxima por âncora, depois retorna os índices das âncoras cujo máximo passa o limiar quantizado. O tensor inteiro nunca foi desquantizado; apenas uma redução de máximo por coluna no espaço inteiro quantizado.

Se nenhuma âncora passar, o pós-processador retorna a tupla vazia que predict() interpreta como nenhuma detecção.

Duas decisões neste código fazem a diferença entre um pós-processador executável e um inutilizavelmente lento. A primeira é conduzir a aritmética através do numpy: o tensor de saída tem milhares de elementos, e iterá-lo em Python puro leva segundos inteiros por inferência, enquanto a mesma aritmética vetorizada através do numpy é executada em milissegundos. A segunda é desquantizar depois do filtro de limiar, em vez de antes. Desquantizar primeiro alocaria um tensor de ponto flutuante quatro vezes maior que o quantizado e percorreria cada elemento antes de descartar quase todos eles; desquantizar apenas as colunas sobreviventes toca, no máximo, um punhado de valores e economiza tanto o tempo quanto a RAM que a conversão completa teria consumido.

7.14.3. Desquantizando os sobreviventes

Apenas as âncoras sobreviventes precisam ter sua geometria decodificada. numpy.take() extrai essas colunas e uma única chamada de ml.utils.dequantize() as converte para ponto flutuante:

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

bb agora é (C, K) onde K é o número de âncoras sobreviventes – tipicamente um punhado, mesmo quando A estava na casa dos milhares.

7.14.4. Lendo a geometria

Os quatro canais de caixa e as pontuações por classe são extraídos diretamente:

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 é a melhor pontuação de classe por âncora sobrevivente; bb_classes é o índice de classe que entregou essa pontuação. A geometria da caixa ainda está normalizada em [0, 1] das dimensões de entrada da rede, então a próxima etapa a escala para 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

Depois disso, as caixas estão no espaço de pixels de entrada da rede – o espaço de coordenadas que NMS espera na entrada.

7.14.5. Non-max suppression

Os sobreviventes passam pelo NMS e são retornados como listas por 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)

NMSinputs[0].roi para que as caixas retornadas estejam no espaço de coordenadas da imagem original, não no da rede – a aplicação as desenha no quadro capturado diretamente, sem remapeamento adicional.

7.14.6. O que o script recebe de volta

O valor de retorno é uma lista de listas por classe indexada por classe. Um exemplo de três classes pode parecer assim:

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

Cada entrada é uma tupla ((x, y, w, h), score): (x, y) é o canto superior esquerdo da caixa delimitadora nas coordenadas de pixel da imagem original, w e h são sua largura e altura em pixels, e score é a confiança que a rede atribuiu à detecção. Então ((180, 60, 88, 130), 0.71) se lê como uma caixa cujo canto superior esquerdo fica no pixel (180, 60), estende-se 88 pixels para a direita e 130 pixels para baixo, e foi reportada com confiança 0.71.

A lista externa mostra duas caixas sobreviventes para a classe 0, nada para a classe 1, uma para a classe 2. A lista vazia para a classe 1 é mantida no lugar para que o índice externo sempre corresponda ao índice de classe. Para o detector de pessoas fornecido, a lista externa tem um único elemento cuja lista interna contém as caixas de pessoas sobreviventes. Para um modelo de 80 classes, ela tem 80 listas internas, a maioria vazia em qualquer quadro dado, com as entradas não vazias contendo as caixas para as classes que dispararam. A aplicação lê o resultado com enumerate(boxes) para percorrer os índices de classe junto com as listas de caixas – o mesmo formato que os pós-processadores de detecção têm como alvo em todo o catálogo.