7.14. شرح YOLOv8 المفصّل

معالج YOLOv8 اللاحق صغير بما يكفي للخطو عبره من الموتر المدخل إلى القائمة المُعادة. قراءته مرة واحدة تُظهر ما يفعله كل معالج لاحق آخر في الكتالوج: عتبة الدرجات المُكمَّمة بثمن زهيد، وأخذ الناجين، وفكّ التكميم، وفك ترميز الهندسة، والدفع إلى NMS، وإعادة القوائم لكل فئة.

7.14.1. الموتر الابتدائي

يُصدر نموذج YOLOv8 موتراً ناتجاً واحداً شكله (1, C, A) -- إطار واحد، وC قناة، وA تنبؤ مرساة. القنوات الأربع الأولى هندسة مربع -- cx وcy وw وh -- مُطبَّعة إلى [0, 1] من أبعاد مدخلات الشبكة. أما القنوات المتبقية البالغة C - 4 فهي درجات لكل فئة، تكون بالفعل في [0, 1] لنموذج مدرَّب. كل مرساة هي عمود نازل عبر القنوات.

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.

مرساة YOLOv8 واحدة هي عمود واحد نازل عبر القنوات: أربعة أرقام مربع وN درجة فئة.

النموذج المشحون yolov8n_192.tflite هو كاشف أشخاص أحادي الفئة، فيكون C = 5 ويكون A بالآلاف؛ أما نموذج مخصص مدرَّب على مجموعة COCO كاملة بـ80 فئة فيكون 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 في أجزاء من الثانية. الثاني هو فكّ التكميم بعد مرشّح العتبة لا قبله. فكّ التكميم أولاً سيخصّص موتراً عائماً بأربعة أضعاف حجم المُكمَّم ويمشي عبر كل عنصر قبل إسقاط جميعها تقريباً؛ أما فكّ تكميم الأعمدة الناجية فقط فيمسّ حفنة من القيم على الأكثر ويوفّر كلاً من الوقت والذاكرة RAM اللذين كان سيستهلكهما التحويل الكامل.

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) ليمشي عبر فهارس الفئات جنباً إلى جنب مع قوائم المربعات -- وهو شكل الكشف نفسه الذي تستهدفه المعالجات اللاحقة عبر الكتالوج.