10.3. הזרמה חיה – צופה אחד

דפדפנים יכולים להציג זרמי Motion JPEG (MJPEG) רב-חלקיים ישירות בתוך תגית <img>. תנו לדפדפן תגובת HTTP אחת שלעולם לא מסתיימת, כתבו קובצי JPEG מופרדים בגבול רב-חלקי, והדפדפן יציג כל פריים ככל שהוא מגיע.

The browser sends GET /stream.jpg; the cam responds with Content-Type multipart/x-mixed-replace and writes one JPEG-bodied part per frame until the browser disconnects.

החוט פשוט: כותרת תגובה אחת, Content-Type: multipart/x-mixed-replace; boundary=frame, אחר כך שורת --frame, Content-Type: image/jpeg, שורה ריקה, בתי ה-JPEG, \r\n, וחוזר חלילה. הדפדפן סוגר את החיבור כשה-<img> מוסר או הלשונית נסגרת.

10.3.1. לכידה ללא חסימה

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

התבנית היא עוטף AsyncCSI דק שסוקר את csi.CSI.snapshot() במצב לא-חוסם ומרדים את הקורוטינה בין סקירות. פרק ה-asyncio עבר על תבנית זו ב-AsyncCSI; שלבו אותה בתוך הסקריפט בשלב זה:

import asyncio

class AsyncCSI:
    def __init__(self, *args, **kwargs):
        self._csi = csi.CSI(*args, **kwargs)

    def __getattr__(self, name):
        return getattr(self._csi, name)

    async def snapshot(self):
        while True:
            img = self._csi.snapshot(blocking=False)
            if img is not None:
                return img
            await asyncio.sleep_ms(0)

כל שיטת CSI אחרת (reset(), pixformat(), framesize(), gain_db(), …) מועברת דרך __getattr__; רק snapshot() מוחלפת בגרסה הניתנת להמתנה (awaitable) שמאפשרת ללולאת האירועים לתזמן קורוטינות אחרות בין סקירות.

החליפו את ה-csi.CSI() החשוף מנתיב תמונת הבזק ב-AsyncCSI():

csi0 = AsyncCSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QVGA)

10.3.2. גופי הזרמה הם איטרטורים מבוססי-מחלקה

גוף תגובת הזרמה הוא פשוט אובייקט ש-microdot עובר עליו באיטרציה עם async for, ושולח כל מקטע מוחזר אל השקע. ב-CPython זוהי בדרך כלל פונקציית מחולל אסינכרוניasync def עם yield. MicroPython אינה תומכת בכך:

הערה

ה-asyncio של MicroPython אינה תומכת בפונקציות מחולל-אסינכרוני (async def name(): ... yield ...). גופי תגובת הזרמה חייבים להיות איטרטורים אסינכרוניים מבוססי-מחלקה עם __aiter__ שמחזיר self ו-__anext__ שמוגדר כ-async def.

עבור זרם MJPEG פירוש הדבר מחלקה שה-__anext__ שלה ממתין לפריים אחד ומחזיר אותו ארוז בעטיפה הרב-חלקית:

BOUNDARY = b'frame'

class FrameStream:
    def __aiter__(self):
        return self

    async def __anext__(self):
        img = await csi0.snapshot()
        jpeg = bytes(img.compress(quality=85).bytearray())
        return (b'--' + BOUNDARY + b'\r\n'
                b'Content-Type: image/jpeg\r\n\r\n'
                + jpeg + b'\r\n')

@app.get('/stream.jpg')
async def stream(request):
    return Response(
        body=FrameStream(),
        headers={
            'Content-Type':
                b'multipart/x-mixed-replace; boundary=' + BOUNDARY,
        },
    )

המופע טרי לכל בקשה, כך שכל לקוח מחובר מקבל את האיטרטור שלו. כשהדפדפן מתנתק, microdot מפסיקה להמתין ל-__anext__ והאיטרטור נאסף כאשפה.

הערה

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

10.3.3. הרצת השרת בתוך asyncio

הקריאה הקודמת app.run(host=..., port=...) חוסמת. מטפל ה-MJPEG צריך לחלוק את הלולאה עם סקירות תמונת הבזק של AsyncCSI, אז החליפו את app.run ב-start_server() בתוך asyncio.run():

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

asyncio.run(main())

העוטף asyncio.run() מאפשר לשרת להיות משימה אחת מתוך כמה – הקורוטינה main היא אז המקום הטבעי להוליד לכידה, זיהוי תנועה, וכל דבר אחר שחייב לחלוק את הלולאה עם שרת ה-HTTP.

10.3.4. צופה אחד בכל פעם

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

התיקון הוא לולאת לכידה משותפת אחת שמפרסמת פריים אחד לקוראים רבים.