7.14. סקירה מודרכת של YOLOv8

מעבד-הקצה של YOLOv8 קטן מספיק כדי לעבור עליו מטנזור הקלט ועד הרשימה המוחזרת. קריאתו פעם אחת מראה מה כל מעבד-קצה אחר בקטלוג עושה: ספּוּף ציונים מקוונטזים בזול לפי סף, לקיחת מה ששורד, ביטול קוונטיזציה, פענוח גאומטריה, דחיפה ל-NMS, החזרת רשימות לכל מחלקה.

7.14.1. טנזור ההתחלה

מודל YOLOv8 פולט טנזור פלט יחיד שצורתו (1, C, A) – פריים אחד, C ערוצים, A חיזויי anchor. ארבעת הערוצים הראשונים הם גאומטריית תיבה – cx, cy, w, h – מנורמלים ל-[0, 1] של מימדי הקלט של הרשת. שאר הערוצים C - 4 הם ציונים לכל מחלקה, כבר ב-[0, 1] עבור מודל מאומן. כל anchor הוא עמודה לאורך הערוצים.

A grid laid on its side: rows labelled cx, cy, w, h, score_0, score_1, ..., score_(N-1); columns labelled anchor 0 through anchor A-1. One column is outlined to show that a single anchor's prediction is one column down the channels.

anchor יחיד של YOLOv8 הוא עמודה אחת לאורך הערוצים: ארבעה מספרי תיבה ו-N ציוני מחלקה.

ה-yolov8n_192.tflite המובנה הוא גלאי אנשים חד-מחלקתי, ולכן C = 5 ו-A הוא באלפים; מודל מותאם אישית שאומן על מערך COCO המלא של 80 מחלקות הוא בעל C = 84 נגד אותו A. הפענוח להלן תקף לכל מספר מחלקות.

7.14.2. ספּוּף לפי סף לפני ביטול קוונטיזציה

השלב הזול תחילה. טנזור הפלט הוא ב-dtype השלם המקוונטז של המודל, וביטול קוונטיזציה של כל ערך היה נוגע בכל איבר של טנזור עם אלפי רשומות – שרובן מתחת לסף הציון ובסופו של דבר מושלכות. מעבד-הקצה במקום זאת מקוונטז את הסף פעם אחת ומשווה במרחב המקוונטז הגולמי:

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) כך שהערוצים הם שורות וה-anchors הם עמודות. score_block הוא תת-הטנזור של ציוני המחלקה – כל מה משורה 4 ומטה. ml.utils.threshold() מצמצם את הבלוק הזה לאורך ציר 0 (find_max=True, find_max_axis=0) לציון המקסימלי לכל anchor, ואז מחזיר את האינדקסים של ה-anchors שהמקסימום שלהם עובר את הסף המקוונטז. כל הטנזור מעולם לא עבר ביטול קוונטיזציה; רק צמצום-מקסימום לכל עמודה במרחב השלם המקוונטז.

אם אף anchor אינו עובר, מעבד-הקצה מחזיר את ה-tuple הריק ש-predict() מפרש כאי-זיהוי.

שתי החלטות בקוד הזה עושות את ההבדל בין מעבד-קצה בר-הרצה לבין כזה שאינו שמיש מרוב איטיות. הראשונה היא הנעת החשבון דרך numpy: לטנזור הפלט יש אלפי איברים, ואיטרציה עליו ב-Python גולמי לוקחת שניות שלמות לכל הסקה, בעוד אותו חשבון מווקטר דרך numpy רץ באלפיות שנייה. השנייה היא ביטול קוונטיזציה אחרי מסנן הסף ולא לפניו. ביטול קוונטיזציה תחילה היה מקצה טנזור float בגודל פי ארבעה מהמקוונטז ועובר על כל איבר לפני שמשליך כמעט את כולם; ביטול קוונטיזציה רק של העמודות השורדות נוגע בכמה ערכים לכל היותר וחוסך הן את הזמן והן את ה-RAM שההמרה המלאה הייתה צורכת.

7.14.3. ביטול קוונטיזציה של השורדים

רק ה-anchors השורדים זקוקים לפענוח הגאומטריה שלהם. numpy.take() מושך את העמודות האלה החוצה וקריאת ml.utils.dequantize() יחידה ממירה אותן ל-floats:

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

bb הוא כעת (C, K) כאשר K הוא מספר ה-anchors השורדים – בדרך כלל קומץ אפילו כש-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 הוא ציון המחלקה הטוב ביותר לכל anchor שורד; 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)],
]

כל רשומה היא tuple בצורת ((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) כדי לעבור על אינדקסי המחלקה לצד רשימות התיבות – אותה צורה שמעבדי-קצה של זיהוי מכוונים אליה לרוחב הקטלוג.