10.3. Živé streamování – jeden divák#

Prohlížeče dokážou vykreslit vícedílné Motion JPEG (MJPEG) streamy přímo uvnitř tagu <img>. Předejte prohlížeči jednu HTTP odpověď, která nikdy neskončí, zapisujte JPEGy oddělené multipart hranicí a prohlížeč zobrazí každý snímek, jakmile dorazí.

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.

Přenos je přímočarý: jedna hlavička odpovědi, Content-Type: multipart/x-mixed-replace; boundary=frame, poté řádek --frame, Content-Type: image/jpeg, prázdný řádek, JPEG bajty, \r\n a opakovat. Prohlížeč spojení uzavře, když je <img> odebrán nebo je zavřena karta.

10.3.1. Zachytávání bez blokování#

Doposud používané blokující csi0.snapshot() zastaví celou event loop, dokud senzor nedodá snímek. To bylo v pořádku, když jeden požadavek spustil jeden snímek a nic jiného neběželo. Jakmile je stream otevřen, server musí pokračovat v obsluze ostatních požadavků, zatímco se zachytává další snímek – volání zachycení musí předat řízení event loopu, zatímco čeká na senzor.

Vzorem je tenký wrapper AsyncCSI, který dotazuje csi.CSI.snapshot() v neblokujícím režimu a mezi dotazy korutinu uspí. Kapitola o asyncio tento vzor prošla v AsyncCSI; pro teď jej vložte přímo do skriptu:

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)

Každá další metoda CSI (reset(), pixformat(), framesize(), gain_db(), …) je přeposlána přes __getattr__; pouze snapshot() je nahrazena awaitable verzí, která umožňuje event loopu naplánovat mezi dotazy jiné korutiny.

Vyměňte holé csi.CSI() z cesty snapshot za AsyncCSI():

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

10.3.2. Těla streamů jsou iterátory založené na třídách#

Tělo streamovací odpovědi je jen objekt, který microdot iteruje pomocí async for a posílá každý vyprodukovaný kus do socketu. V CPythonu je to běžně async generátorová funkceasync def s yield. MicroPython to nepodporuje:

Poznámka

asyncio v MicroPythonu nepodporuje async-generátorové funkce (async def name(): ... yield ...). Těla streamovacích odpovědí musí být async iterátory založené na třídách s __aiter__ vracejícím self a __anext__ definovaným jako async def.

Pro MJPEG stream to znamená třídu, jejíž __anext__ počká na jeden snímek a vrátí jej zabalený v multipart obalu:

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,
        },
    )

Instance je pro každý požadavek čerstvá, takže každý připojený klient dostane svůj vlastní iterátor. Když se prohlížeč odpojí, microdot přestane čekat na __anext__ a iterátor je uvolněn garbage collectorem.

Poznámka

Obal bytes(...) kolem JPEGu je obranný. bytearray() vrací pohled do obrazového bufferu kamery a další volání snapshot() tento buffer přepíše na místě. Zabalení do bytes zkopíruje JPEG ven, takže kus, který microdot právě zapisuje, zůstane stabilní, i kdyby zapisovač nedokončil vyprázdnění, než se __anext__ znovu spustí.

10.3.3. Spuštění serveru uvnitř asyncio#

Dřívější volání app.run(host=..., port=...) je blokující. MJPEG handler potřebuje sdílet smyčku s dotazy AsyncCSI snapshot, takže vyměňte app.run za start_server() uvnitř asyncio.run():

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

asyncio.run(main())

Wrapper asyncio.run() umožňuje, aby byl server jednou úlohou z několika – korutina main je pak přirozeným místem pro spuštění zachytávání, detekce pohybu a čehokoli dalšího, co musí sdílet smyčku s HTTP serverem.

10.3.4. Jeden divák najednou#

Každý připojený klient spouští svůj vlastní iterátor FrameStream, což znamená, že každý klient spouští své vlastní volání csi0.snapshot(). Dva prohlížeče znamenají dvě čtení senzoru na interval snímku, tři znamenají tři a tak dále. Senzor ve skutečnosti nedokáže dodávat snímky rychleji než je jeho vlastní snímková frekvence, takže se požadavky řadí za sebe a stream každého se zpomalí.

Řešením je jediná sdílená smyčka zachytávání, která publikuje jeden snímek mnoha čtenářům.