10.4. שיתוף לולאת לכידה אחת בין צופים

כל לקוח מחובר שקורא ל-csi0.snapshot() באופן עצמאי הוא בזבזני, וברגע ששני זרמים פתוחים בו-זמנית זה מחמיר: החיישן מספק פריימים בקצב שלו, וכל לכידה כפולה מאטה את כולם. הגישה הנכונה היא קורוטינת לכידה אחת שמפרסמת את ”הפריים האחרון“ אל משבצת משותפת, בתוספת איטרטורים לכל-לקוח שקוראים מהמשבצת.

One capture task writes JPEG bytes to a single latest_jpeg slot; three stream-client iterators read from the slot and each wait on the shared new_frame event.

10.4.1. משימת הלכידה

קורוטינת רקע תופסת פריימים מהר ככל שהחיישן מספק אותם, דוחסת כל אחד ב-JPEG אל bytes משותף, ופועמת אירוע כך שכל לקוח ממתין מתעורר:

latest_jpeg = None
new_frame = asyncio.Event()

async def capture_loop():
    global latest_jpeg
    while True:
        img = await csi0.snapshot()
        latest_jpeg = bytes(img.compress(quality=85).bytearray())
        new_frame.set()
        new_frame.clear()

הצמד set() / clear() הוא תבנית ה-פעימה. set() משחרר כל קורוטינה שממתינה כעת לאירוע בבת אחת; clear() מאפס מיד את האירוע כך שה-wait() הבא חוסם שוב. עם מספר צרכנים (צופה, צופה נוסף, כל קורוטינה אחרת שצריכה להגיב לפריים חדש), אף צרכן יחיד אינו אחראי לאיפוס האירוע, ואף אחד לא גונב התעוררות מאף אחד.

הערה

העטיפה bytes(...) סביב ה-JPEG נושאת משקל כאן. bytearray() מחזיר תצוגה אל חוצץ התמונה של המצלמה; הקריאה הבאה ממש ל-snapshot() כותבת מחדש את אותו חוצץ במקום עם הפריים הבא. latest_jpeg שורד מעבר ל-img המקומי, כך שללא ההעתקה כל קורא היה רואה את המשבצת מתחלפת מתחתיו בכל לכידה.

10.4.2. איטרטורים לכל-לקוח קוראים מהמשבצת

מטפל זרם ה-MJPEG מפסיק לקרוא ל-csi0.snapshot() בעצמו. במקום זאת, כל מופע FrameStream ממתין לאירוע המשותף וקורא מהבתים המשותפים:

class FrameStream:
    # One instance per connected client. Each one independently
    # waits on the shared new_frame pulse; the capture loop is
    # responsible for resetting the event between frames.

    def __aiter__(self):
        return self

    async def __anext__(self):
        await new_frame.wait()
        if latest_jpeg is None:
            return b''
        return (b'--' + BOUNDARY + b'\r\n'
                b'Content-Type: image/jpeg\r\n\r\n'
                + latest_jpeg + b'\r\n')

גם נתיב תמונת הבזק משתנה: הוא כבר אינו מפעיל לכידה, הוא מחזיר כל מה ש-latest_jpeg מחזיק כעת:

@app.get('/snapshot.jpg')
async def snapshot(request):
    if latest_jpeg is None:
        return 'no frame yet', 503
    return Response(
        body=latest_jpeg,
        headers={'Content-Type': 'image/jpeg'},
    )

הטופל (tuple) (body, status) הוא הקיצור של microdot להגדרת קוד סטטוס HTTP מבלי לבנות microdot.Response. 503 אומר אני כאן אבל לא מוכן – הקוד הנכון ל“שאל שוב עוד רגע“.

10.4.3. הרצת הלכידה לצד השרת

ל-main יש כעת שתי קורוטינות ברמה העליונה: לולאת הלכידה ושרת ה-HTTP. asyncio.gather() מריץ את שתיהן, ואם אחת מהן קורסת השנייה מבוטלת:

async def main():
    await asyncio.gather(
        capture_loop(),
        app.start_server(host='0.0.0.0', port=80),
    )

asyncio.run(main())

כעת החיישן קורא פריים אחד לכל מחזור לא משנה כמה צופים מחוברים. הדפדפן הראשון אל /stream.jpg רואה פריימים; כך גם השני, השלישי, העשירי – כולם חולקים את אותה לכידה, והמצלמה נשארת רספונסיבית באותה מידה בנתיבים האחרים שלה.