12.8. הזרמת פריימים

השימוש המעשי הנפוץ ביותר בערוץ מותאם אישית הוא הזרמת פריימים של תמונה מהמצלמה אל תוכנית מארחת בקצב הפריימים של המצלמה. המנגנון עדין יותר ממה שהוא נראה: JPEG עשוי להגיע ל-25 KB או יותר, ולכן המארח קורא אותו כמספר מקטעים, ויש למנוע מלולאת הלכידה של המצלמה לדרוס את החוצץ באמצע הקריאה. התבנית הנכונה – המוצגת כאן ובשימוש על ידי הכלים שב-openmv-projects/tools/נועלת (latches) את החוצץ עד שהמארח מסיים למשוך את הבית האחרון.

12.8.1. צד המצלמה

ערוץ פריימים שלוכד אל תוך framebuffer יחיד, נועל אותו בקריאה הראשונה של המארח, ולוקח את תמונת הבזק (snapshot) הבאה רק לאחר שהמארח צרך את התמונה המלאה:

import csi
import protocol

csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QVGA)
csi0.framebuffers(1)

img = csi0.snapshot()
img.compress(quality=85)
img_mv = memoryview(img.bytearray())
img_size = len(img_mv)
frame_available = True


class FrameChannel:
    def poll(self):
        return frame_available

    def size(self):
        return img_size

    def readp(self, offset, size):
        global frame_available
        end = offset + size
        mv = img_mv[offset:end]
        if end == img_size:
            # Host has just read the last byte of this frame --
            # release the buffer so the capture loop can refresh.
            frame_available = False
        return mv


ch = protocol.register(name='frame', backend=FrameChannel())

while True:
    if not frame_available:
        img = csi0.snapshot()
        img.compress(quality=85)
        img_mv = memoryview(img.bytearray())
        img_size = len(img_mv)
        frame_available = True
        ch.send_event(0x01)   # notify host that a new frame is ready

ארבעה רכיבים מבצעים כאן עבודה ממשית:

  • frame_available הוא הנועל (latch). לולאת הלכידה לוקחת תמונת בזק (snapshot) חדשה רק כאשר ערכו הוא False – כלומר המארח משך את הבית האחרון של הפריים הקודם. הקריאה של המארח מחזירה אותו ל-False מתוך readp ברגע שההיסט (offset) הסופי הוגש. ללא משמר זה, ה-csi0.snapshot() הבא היה דורס את החוצץ באמצע הקריאה והמארח היה מקבל פריים מאוחה משתי לכידות.

  • ה-backend מממש את readp ולא את read. ספריית הפרוטוקול מתייחסת לחוצץ המוחזר כסמכותי וקוראת את הבתים שלו ישירות אל תוך החבילה היוצאת – ללא העתקה. עבור מטענים בגודל פריים, readp מהיר באופן ניכר מ-read, אשר מאלץ העתקה ביניים.

  • size מחזיר את אורך ה-JPEG השמור במטמון מבלי לחשב מחדש דבר; לולאת הלכידה מתחזקת אותו בכל פעם שהיא מרעננת את החוצץ. המארח קורא ל-size בין poll ל-readp כדי לדעת כמה בתים למשוך.

  • send_event() מודיע למארח ברגע שפריים חדש נוחת כך שהוא יכול להתחיל למשוך מבלי לבצע סקרים (polling). מזהה האירוע 0x01 מוגדר על ידי היישום (”frame ready“ במקרה זה); השתמש במספר שלם קטן שונה עבור כל סוג של התראה.

12.8.2. פיצול למקטעים

QVGA RGB565 באיכות JPEG 85 נדחס לכ-10-25 KB, בהתאם לסצנה – הרבה יותר גדול מהמטען המרבי שעליו הוסכם בכל מצלמה (ראה את הטבלה לכל לוח ב-protocol.init()). קריאת JPEG אחת לא תיכנס בחבילה אחת, וזה בסדר, מכיוון שספריית הפרוטוקול מפצלת אותה למקטעים באופן שקוף.

כאשר המארח מבקש channel_read('frame', 12000):

  1. ה-readp של המצלמה נקרא פעם אחת עם offset=0 והבקשה המלאה בגודל 12000 בתים. הוא מחזיר memoryview יחיד המכסה את כל הטווח.

  2. ספריית הפרוטוקול מפרקת את אותו memoryview למקטעים בגודל המטען המרבי על גבי החיבור, חבילת תשובה CHANNEL_READ אחת לכל מקטע, כל אחת עם הכותרת וה-CRC משלה. הבתים מוזרמים החוצה ישירות מהחוצץ של ה-backend – ללא העתקה.

  3. המארח מקבל את המקטעים לפי הסדר, שכבת המהימנות משדרת מחדש כל מקטע שנכשל ב-CRC שלו, וה-SDK של המארח מדביק את המקטעים אל תוך התוצאה בגודל 12000 בתים המוחזרת לקורא.

הערה

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

טיפ

רוצה לראות את הפער בעצמך? שנה את שם השיטה readp של ה-backend ל-read – שום דבר אחר לא משתנה; הספרייה תזהה את יכולת ה-read במקום זאת – והשווה את מונה קצב הפריימים של המארח לפני ואחרי. המספר האיטי יותר הוא עלות ההעתקה לכל מקטע וקריאת Python שאתה חוסך באמצעות שימוש ב-readp.

הנועל (latch) ב-FrameChannel.readp משחרר את החוצץ כאשר offset + size == img_size – הרגע שבו המארח משך את הבית האחרון. עד אז, החוצץ חייב להישאר תקף, וזו הסיבה שלולאת הלכידה לוקחת את תמונת הבזק (snapshot) הבאה רק לאחר ש-frame_available מתהפך בחזרה ל-False.

12.8.3. צד המארח

המארח מושך פריימים בלולאה צמודה:

import io
from PIL import Image
from openmv.camera import Camera

with Camera('/dev/ttyACM0', baudrate=921600) as cam:
    cam.update_channels()

    while True:
        size = cam.channel_size('frame')
        if not size:
            continue
        data = cam.channel_read('frame', size)
        img = Image.open(io.BytesIO(data))
        img.show()                  # or feed to a GUI

הקריאה channel_size() משמשת גם כבדיקת ”האם משהו מוכן“ – אפס פירושו שהמצלמה עדיין לא לכדה – ולכן הלולאה מדלגת על ניסיונות קריאה כשהחוצץ ריק. עבור יישומי GUI שכבר מבצעים סקרים על פי טיימר, זוהי התבנית הטבעית.

ה-Image.open של Pillow מפענח את ה-JPEG; המצלמה כבר דחסה אותו ל-JPEG כך שהמארח לא צריך לבצע מחדש אריזת ביטים יקרה על RGB565. הסקריפט של המארח יכול באותה קלות לשמור את הבתים לדיסק, להעביר אותם ל-OpenCV, או לדחוף אותם דרך תצוגת ווב.

12.8.4. חשיבה על תפוקה

שלושה דברים מגבילים את קצב הפריימים שניתן להשיג:

  • קצב הלכידה של המצלמה. הפרוטוקול אינו יכול לספק פריימים מהר יותר מכפי שהחיישן מייצר אותם; כל תקרה שתבנית הפיקסלים וגודל הפריים שנבחרו מטילים על הלכידה היא הגבול העליון.

  • המטען המרבי שעליו הוסכם. מטענים גדולים יותר פירושם פחות מקטעים לפריים ופחות תקורת מסגור, ולכן מצלמות עם חוצצי פרוטוקול גדולים יותר מעבירות בתים מהר יותר מקטנות.

  • תקורת CRC ו-ACK. כל חבילה עולה 14 בתים של מסגור בתוספת סבב הלוך-ושוב אחד של ACK. עבור מקטעים ארוכים התקורה לכל מטען קטנה; עבור מטענים זעירים היא דומיננטית.

עבור רוב עבודות ה-GUI מהמצלמה אל המחשב הנייד, הגורם המגביל הוא זמן הלכידה ודחיסת ה-JPEG של המצלמה, ולא מחסנית הפרוטוקול. במקום שבו הפרוטוקול כן הופך לצוואר הבקבוק – הזרמת פריימים גולמיים בלתי דחוסים בקצבי פריים גבוהים, למשל – המנופים הם כיבוי ה-ACK-ים (protocol.init(ack=False)), הגדלת חוצץ הפרוטוקול אם המצלמה תומכת בכך, או לכידה ב-GRAYSCALE כך שכל JPEG דחוס נושא ערוץ אחד במקום שלושה והפריים המקודד יוצא קטן באופן ניכר על גבי החיבור.

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