7.14. Разбор YOLOv8

Постпроцессор YOLOv8 достаточно мал, чтобы пройти его шаг за шагом от входного тензора до возвращаемого списка. Прочитав его один раз, понимаешь, что делает любой другой постпроцессор в каталоге: дёшево отсечь квантованные оценки по порогу, взять уцелевших, деквантовать, декодировать геометрию, передать в NMS, вернуть списки по классам.

7.14.1. Начальный тензор

Модель YOLOv8 выдаёт единственный выходной тензор, форма которого (1, C, A) – один кадр, C каналов, A предсказаний якорей. Первые четыре канала – геометрия рамки – cx, cy, w, h – нормализованная к [0, 1] от размерностей входа сети. Оставшиеся C - 4 каналов – оценки по классам, уже в [0, 1] для обученной модели. Каждый якорь – это столбец вдоль каналов.

Сетка, положенная на бок: строки помечены cx, cy, w, h, score_0, score_1, ..., score_(N-1); столбцы помечены от anchor 0 до anchor A-1. Один столбец выделен контуром, чтобы показать, что предсказание одного якоря -- это один столбец вдоль каналов.

Один якорь YOLOv8 – это один столбец вдоль каналов: четыре числа рамки и N оценок по классам.

Поставляемая модель yolov8n_192.tflite – одноклассовый детектор людей, поэтому C = 5, а A исчисляется тысячами; пользовательская модель, обученная на полном наборе из 80 классов COCO, имеет C = 84 при том же A. Декодирование ниже справедливо для любого числа классов.

7.14.2. Отсечение по порогу до деквантования

Сначала дешёвый шаг. Выходной тензор имеет квантованный целочисленный тип данных модели, а деквантование каждого значения затронуло бы каждый элемент тензора с тысячами записей – большинство из которых ниже порога по оценке и в итоге отбрасываются. Вместо этого постпроцессор квантует порог один раз и сравнивает в сыром квантованном пространстве:

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 – это тензор, изменённый по форме до (C, A), так что каналы становятся строками, а якоря – столбцами. score_block – это подтензор оценок по классам – всё начиная со строки 4 и ниже. ml.utils.threshold() сворачивает этот блок вдоль оси 0 (find_max=True, find_max_axis=0) до максимальной оценки на якорь, затем возвращает индексы якорей, чей максимум превышает квантованный порог. Весь тензор так и не был деквантован; только свёртка по максимуму по столбцам в квантованном целочисленном пространстве.

Если ни один якорь не проходит, постпроцессор возвращает пустой кортеж, который predict() интерпретирует как отсутствие обнаружения.

Два решения в этом коде определяют разницу между работоспособным постпроцессором и непригодно медленным. Первое – проведение арифметики через numpy: выходной тензор имеет тысячи элементов, и перебор его в чистом Python занимает целые секунды на вывод, тогда как та же арифметика, векторизованная через numpy, выполняется за миллисекунды. Второе – деквантование после фильтра по порогу, а не до. Деквантование сначала выделило бы вещественный тензор вчетверо большего размера, чем квантованный, и обошло бы каждый элемент, прежде чем отбросить почти все из них; деквантование только уцелевших столбцов затрагивает самое большее горстку значений и экономит как время, так и оперативную память, которые поглотило бы полное преобразование.

7.14.3. Деквантование уцелевших

Геометрию нужно декодировать только у уцелевших якорей. numpy.take() извлекает эти столбцы, а единственный вызов ml.utils.dequantize() преобразует их в вещественные числа:

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

bb теперь имеет форму (C, K), где K – число уцелевших якорей – обычно горстка, даже когда A исчислялось тысячами.

7.14.4. Чтение геометрии

Четыре канала рамки и оценки по классам извлекаются напрямую:

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 – лучшая оценка класса на каждый уцелевший якорь; bb_classes – индекс класса, который дал эту оценку. Геометрия рамки всё ещё нормализована к [0, 1] от размерностей входа сети, поэтому следующий шаг масштабирует её в пиксели:

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

После этого рамки находятся в пиксельном пространстве входа сети – координатном пространстве, которое NMS ожидает на входе.

7.14.5. Подавление немаксимумов

Уцелевшие проходят через NMS и возвращаются в виде списков по классам:

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 читает inputs[0].roi, чтобы возвращённые рамки были в координатном пространстве исходного изображения, а не сети – приложение отрисовывает их на захваченном кадре напрямую без дальнейшего пересчёта.

7.14.6. Что получает скрипт обратно

Возвращаемое значение – список списков по классам, индексируемый по классу. Пример с тремя классами мог бы выглядеть так:

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

Каждая запись – это кортеж ((x, y, w, h), score): (x, y) – левый верхний угол ограничивающей рамки в пиксельных координатах исходного изображения, w и h – её ширина и высота в пикселях, а score – уверенность, которую сеть присвоила обнаружению. Так что ((180, 60, 88, 130), 0.71) читается как рамка, левый верхний угол которой находится в пикселе (180, 60), простирается на 88 пикселей вправо и на 130 пикселей вниз, и была сообщена с уверенностью 0.71.

Внешний список показывает две уцелевшие рамки для класса 0, ничего для класса 1, одну для класса 2. Пустой список для класса 1 сохранён на месте, чтобы внешний индекс всегда соответствовал индексу класса. Для поставляемого детектора людей внешний список имеет один элемент, внутренний список которого содержит уцелевшие рамки людей. Для модели на 80 классов он имеет 80 внутренних списков, большинство из которых пусты на любом отдельно взятом кадре, а непустые записи содержат рамки для классов, которые сработали. Приложение читает результат с помощью enumerate(boxes), чтобы обходить индексы классов вместе со списками рамок – ту же форму, на которую нацелены постпроцессоры обнаружения по всему каталогу.