10.4. Partilhar um ciclo de captura entre vários espetadores

Cada cliente ligado a chamar csi0.snapshot() de forma independente é ineficiente e, quando dois streams estão abertos em simultâneo, a situação agrava-se: o sensor entrega fotogramas ao seu próprio ritmo, e cada captura duplicada abranda toda a gente. A abordagem correta é uma corrotina de captura única que publica «o fotograma mais recente» numa posição partilhada, mais iteradores por cliente que lêem dessa posição.

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. A tarefa de captura

Uma corrotina em segundo plano obtém fotogramas tão rapidamente quanto o sensor os entrega, comprime cada um em JPEG para um bytes partilhado e emite um evento para que qualquer cliente em espera acorde:

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()

O par set() / clear() é o padrão de pulso. set() desbloqueia de uma vez todas as corrotinas atualmente à espera do evento; clear() repõe imediatamente o evento para que o próximo wait() bloqueie novamente. Com múltiplos consumidores (um espetador, outro espetador, qualquer outra corrotina que precise de reagir a um novo fotograma), nenhum consumidor é responsável por repor o evento e ninguém rouba um despertar a outra pessoa.

Nota

O embrulho bytes(...) em redor do JPEG é aqui essencial. bytearray() devolve uma vista para o buffer de imagem da câmara; a próxima chamada a snapshot() reescreve esse buffer no local com o fotograma seguinte. latest_jpeg sobrevive ao img local, por isso sem a cópia cada leitor veria a posição mudar sob os seus pés a cada captura.

10.4.2. Os iteradores por cliente lêem da posição partilhada

O handler da stream MJPEG deixa de chamar csi0.snapshot() por si mesmo. Em vez disso, cada instância de FrameStream aguarda no evento partilhado e lê dos bytes partilhados:

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')

A rota de captura também muda: já não despoleta uma captura, devolve o que latest_jpeg contiver nesse momento:

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

A tupla (body, status) é a abreviatura do microdot para definir um código de estado HTTP sem construir um microdot.Response. 503 significa estou aqui mas não estou pronto – o código correto para «tente novamente daqui a pouco».

10.4.3. Executar a captura a par do servidor

main tem agora duas corrotinas de nível superior: o ciclo de captura e o servidor HTTP. asyncio.gather() executa ambas e, se alguma falhar, a outra é cancelada:

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

asyncio.run(main())

Agora o sensor lê um fotograma por ciclo independentemente de quantos espetadores estejam ligados. O primeiro browser em /stream.jpg vê fotogramas; o segundo também, o terceiro, o décimo – todos partilham a mesma captura e a câmara mantém-se igualmente responsiva nas suas outras rotas.