7.14. Przewodnik po YOLOv8

Post-processor YOLOv8 jest na tyle mały, że można go prześledzić od tensora wejściowego do zwracanej listy. Jednokrotne jego przeczytanie pokazuje, co robi każdy inny post-processor w katalogu: taniego progowania skwantyzowanych wyników, brania tych, które przetrwały, dekwantyzacji, dekodowania geometrii, przekazania do NMS i zwrócenia list dla poszczególnych klas.

7.14.1. Tensor początkowy

Model YOLOv8 emituje pojedynczy tensor wyjściowy, którego kształtem jest (1, C, A) – jedna ramka, C kanałów, A predykcji kotwic. Pierwsze cztery kanały to geometria ramki – cx, cy, w, h – znormalizowana do [0, 1] względem wymiarów wejścia sieci. Pozostałe C - 4 kanałów to wyniki dla poszczególnych klas, już w [0, 1] dla wytrenowanego modelu. Każda kotwica to kolumna biegnąca w dół kanałów.

Siatka położona na boku: wiersze oznaczone cx, cy, w, h, score_0, score_1, ..., score_(N-1); kolumny oznaczone od anchor 0 do anchor A-1. Jedna kolumna jest obrysowana, aby pokazać, że predykcja pojedynczej kotwicy to jedna kolumna biegnąca w dół kanałów.

Jedna kotwica YOLOv8 to jedna kolumna biegnąca w dół kanałów: cztery liczby ramki i N wyników klas.

Dostarczany yolov8n_192.tflite to jednoklasowy detektor osób, więc C = 5, a A jest rzędu tysięcy; niestandardowy model trenowany na pełnym zbiorze 80 klas COCO ma C = 84 przy tym samym A. Poniższe dekodowanie obowiązuje dla dowolnej liczby klas.

7.14.2. Progowanie przed dekwantyzacją

Najpierw tani krok. Tensor wyjściowy jest w skwantyzowanym całkowitoliczbowym dtype modelu, a dekwantyzacja każdej wartości dotknęłaby każdego elementu tensora liczącego tysiące wpisów – z których większość jest poniżej progu wyniku i ostatecznie odrzucana. Post-processor zamiast tego kwantyzuje próg raz i porównuje w surowej skwantyzowanej przestrzeni:

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 to tensor przekształcony do (C, A), tak aby kanały były wierszami, a kotwice kolumnami. score_block to podtensor wyników klas – wszystko od wiersza 4 w dół. ml.utils.threshold() redukuje ten blok wzdłuż osi 0 (find_max=True, find_max_axis=0) do maksymalnego wyniku na kotwicę, a następnie zwraca indeksy kotwic, których maksimum przechodzi skwantyzowany próg. Cały tensor nigdy nie został zdekwantyzowany; jedynie redukcja do maksimum na kolumnę w skwantyzowanej przestrzeni całkowitoliczbowej.

Jeśli żadna kotwica nie przejdzie, post-processor zwraca pustą krotkę, którą predict() interpretuje jako brak wykrycia.

Dwie decyzje w tym kodzie stanowią różnicę między post-processorem nadającym się do uruchomienia a niemożliwie wolnym. Pierwszą jest przeprowadzanie arytmetyki przez numpy: tensor wyjściowy ma tysiące elementów, a iterowanie po nim w czystym Pythonie zajmuje całe sekundy na wnioskowanie, podczas gdy ta sama arytmetyka zwektoryzowana przez numpy działa w milisekundach. Drugą jest dekwantyzacja po filtrze progu, a nie przed nim. Dekwantyzacja jako pierwsza zaalokowałaby tensor zmiennoprzecinkowy czterokrotnie większy od skwantyzowanego i przeszłaby przez każdy element przed odrzuceniem niemal wszystkich z nich; dekwantyzacja tylko kolumn, które przetrwały, dotyka co najwyżej garstki wartości i oszczędza zarówno czas, jak i pamięć RAM, którą pochłonęłaby pełna konwersja.

7.14.3. Dekwantyzacja tych, które przetrwały

Tylko kotwice, które przetrwały, potrzebują zdekodowania swojej geometrii. numpy.take() wyciąga te kolumny, a pojedyncze wywołanie ml.utils.dequantize() konwertuje je na liczby zmiennoprzecinkowe:

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

bb ma teraz wymiary (C, K), gdzie K to liczba kotwic, które przetrwały – zwykle garstka, nawet gdy A było rzędu tysięcy.

7.14.4. Odczyt geometrii

Cztery kanały ramki i wyniki dla poszczególnych klas są wyciągane bezpośrednio:

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 to najlepszy wynik klasy na każdą kotwicę, która przetrwała; bb_classes to indeks klasy, która dostarczyła ten wynik. Geometria ramki jest nadal w znormalizowanym [0, 1] względem wymiarów wejścia sieci, więc następny krok skaluje ją do pikseli:

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

Po tym ramki znajdują się w przestrzeni pikselowej wejścia sieci – przestrzeni współrzędnych, której NMS oczekuje na wejściu.

7.14.5. Tłumienie niemaksymalne (NMS)

Te, które przetrwały, przechodzą przez NMS i są zwracane jako listy dla poszczególnych klas:

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 odczytuje inputs[0].roi, więc zwracane ramki znajdują się w przestrzeni współrzędnych oryginalnego obrazu, a nie sieci – aplikacja rysuje je bezpośrednio na przechwyconej ramce bez dalszego odwzorowywania.

7.14.6. Co skrypt otrzymuje z powrotem

Wartością zwracaną jest lista list dla poszczególnych klas, indeksowana według klasy. Przykład z trzema klasami mógłby wyglądać tak:

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

Każdy wpis to krotka ((x, y, w, h), score): (x, y) to lewy górny róg ramki ograniczającej we współrzędnych pikselowych oryginalnego obrazu, w i h to jej szerokość i wysokość w pikselach, a score to pewność, jaką sieć przypisała wykryciu. Zatem ((180, 60, 88, 130), 0.71) czyta się jako ramkę, której lewy górny róg znajduje się w pikselu (180, 60), rozciąga się o 88 pikseli w prawo i 130 pikseli w dół, i została zgłoszona z pewnością 0.71.

Lista zewnętrzna pokazuje dwie ramki, które przetrwały, dla klasy 0, nic dla klasy 1 i jedną dla klasy 2. Pusta lista dla klasy 1 jest zachowywana na swoim miejscu, tak aby zewnętrzny indeks zawsze odpowiadał indeksowi klasy. Dla dostarczanego detektora osób lista zewnętrzna ma jeden element, którego lista wewnętrzna zawiera ramki osób, które przetrwały. Dla modelu 80-klasowego ma 80 list wewnętrznych, w większości pustych w danej ramce, przy czym niepuste wpisy zawierają ramki dla klas, które się uruchomiły. Aplikacja odczytuje wynik za pomocą enumerate(boxes), aby przejść przez indeksy klas wraz z listami ramek – ten sam kształt, na który celują post-processory wykrywania w całym katalogu.