10.3. Transmisja na żywo – jeden widz#

Przeglądarki potrafią renderować wieloczęściowe strumienie Motion JPEG (MJPEG) bezpośrednio wewnątrz znacznika <img>. Podaj przeglądarce jedną odpowiedź HTTP, która nigdy się nie kończy, zapisuj obrazy JPEG oddzielone granicą multipart, a przeglądarka wyświetli każdą ramkę w miarę jej napływania.

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.

Łącze jest proste: jeden nagłówek odpowiedzi, Content-Type: multipart/x-mixed-replace; boundary=frame, następnie linia --frame, Content-Type: image/jpeg, pusta linia, bajty JPEG, \r\n, i powtórka. Przeglądarka zamyka połączenie, gdy znacznik <img> zostaje usunięty lub karta zostaje zamknięta.

10.3.1. Przechwytywanie bez blokowania#

Blokujące csi0.snapshot() używane do tej pory zatrzymuje całą pętlę zdarzeń, dopóki sensor nie dostarczy ramki. To było w porządku, gdy jedno żądanie wyzwalało jeden zrzut obrazu i nic innego nie działało. Po otwarciu strumienia serwer musi nadal obsługiwać inne żądania podczas przechwytywania kolejnej ramki – wywołanie przechwytywania musi oddawać sterowanie pętli zdarzeń, gdy oczekuje na sensor.

Wzorcem jest cienka nakładka AsyncCSI, która odpytuje csi.CSI.snapshot() w trybie nieblokującym i usypia korutynę między odpytaniami. Rozdział o asyncio przeszedł przez ten wzorzec w AsyncCSI; na razie wstaw go bezpośrednio do skryptu:

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żda inna metoda CSI (reset(), pixformat(), framesize(), gain_db(), …) jest przekazywana przez __getattr__; tylko snapshot() jest zastępowana wersją oczekiwalną, która pozwala pętli zdarzeń planować inne korutyny między odpytaniami.

Zamień zwykłe csi.CSI() z trasy zrzutu obrazu na AsyncCSI():

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

10.3.2. Treści strumieniujące to iteratory oparte na klasach#

Treść strumieniującej odpowiedzi to po prostu obiekt, po którym microdot iteruje za pomocą async for, wysyłając każdy wygenerowany fragment do gniazda. W CPython jest to zwykle funkcja generatora asynchronicznegoasync def z yield. MicroPython tego nie obsługuje:

Informacja

asyncio w MicroPython nie obsługuje funkcji generatorów asynchronicznych (async def name(): ... yield ...). Treści strumieniujących odpowiedzi muszą być iteratorami asynchronicznymi opartymi na klasach, w których __aiter__ zwraca self, a __anext__ jest zdefiniowane jako async def.

Dla strumienia MJPEG oznacza to klasę, której __anext__ oczekuje na jedną ramkę i zwraca ją opakowaną w opakowanie multipart:

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

Instancja jest tworzona od nowa dla każdego żądania, więc każdy podłączony klient otrzymuje własny iterator. Gdy przeglądarka się rozłącza, microdot przestaje oczekiwać na __anext__, a iterator jest usuwany przez mechanizm zbierania nieużytków.

Informacja

Opakowanie bytes(...) wokół danych JPEG jest zabezpieczeniem. bytearray() zwraca widok do bufora obrazu kamery, a kolejne wywołanie snapshot() nadpisuje ten bufor w miejscu. Opakowanie w bytes kopiuje dane JPEG na zewnątrz, dzięki czemu fragment, który microdot jest w trakcie zapisywania, pozostaje stabilny, nawet jeśli opróżnianie zapisu nie zakończyło się do czasu, gdy __anext__ zostaje uruchomione ponownie.

10.3.3. Uruchamianie serwera wewnątrz asyncio#

Wcześniejsze wywołanie app.run(host=..., port=...) jest blokujące. Procedura obsługi MJPEG musi współdzielić pętlę z odpytaniami zrzutu obrazu AsyncCSI, więc zamień app.run na start_server() wewnątrz asyncio.run():

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

asyncio.run(main())

Opakowanie asyncio.run() pozwala, by serwer był jednym zadaniem spośród wielu – korutyna main jest wtedy naturalnym miejscem do uruchomienia przechwytywania, wykrywania ruchu i wszystkiego innego, co musi współdzielić pętlę z serwerem HTTP.

10.3.4. Jeden widz naraz#

Każdy podłączony klient uruchamia własny iterator FrameStream, co oznacza, że każdy klient wyzwala własne wywołanie csi0.snapshot(). Dwie przeglądarki oznaczają dwa odczyty sensora na interwał ramki, trzy oznaczają trzy, i tak dalej. Sensor nie jest w stanie dostarczać ramek szybciej niż jego własna częstotliwość ramek, więc żądania ustawiają się w kolejce jedno za drugim i strumień wszystkich zwalnia.

Rozwiązaniem jest pojedyncza współdzielona pętla przechwytywania publikująca jedną ramkę dla wielu czytelników.