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() останавливает весь цикл событий, пока датчик не выдаст кадр. Это было нормально, когда один запрос вызывал один снимок и больше ничего не работало. Как только поток открыт, сервер должен продолжать обрабатывать другие запросы, пока захватывается следующий кадр – вызов захвата должен уступать управление циклу событий, пока он ожидает датчик.

Этот шаблон – тонкая обёртка 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() заменяется ожидаемой версией, которая позволяет циклу событий планировать другие корутины между опросами.

Замените голый 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(). Два браузера означают два чтения датчика на интервал кадра, три – три, и так далее. Датчик на самом деле не может выдавать кадры быстрее своей собственной частоты кадров, поэтому запросы выстраиваются в очередь друг за другом, и потоки у всех замедляются.

Решение – единый общий цикл захвата, публикующий один кадр для многих читателей.