Guzmán D. Darío Senior Python Developer English Contratar
Taller autoguiado

De bloqueante a tiempo real: un pipeline de video multi-cámara con asyncio

Paso 21 de 23
Paso 21 de 23

Bonus: video del navegador con WebRTC

Las tres fuentes que llevas necesitaban un hilo o un servicio intermedio. Esta no. WebRTC con aiortc es la única fuente del taller que es asíncrona de nacimiento, y por eso encaja en el pipeline como un guante.

Al terminar el capítulo vas a poder abrir una página web en tu teléfono, darle a un botón, y ver la cámara de tu teléfono entrando al pipeline como una fuente más.

terminal 2 bash
pip install aiortc

Qué hace distinto a WebRTC

aiortc es una implementación de WebRTC escrita sobre asyncio. Recibir un frame de video es literalmente esto:

la línea que importa python
frame = await track.recv()

Un await de verdad, que cede el control como cualquier otro. Sin to_thread, sin executor, sin hilos en el borde.

Hay un matiz honesto: la decodificación del video sí ocurre, y la hace la librería av por debajo. Pero libera el GIL mientras trabaja y aiortc está diseñado para no bloquear el bucle, así que desde tu código se comporta como I/O.

Cómo se conectan dos extremos

WebRTC necesita un paso previo llamado señalización: antes de que fluya el video, los dos extremos tienen que intercambiar una descripción de qué códecs hablan y cómo alcanzarse. Ese intercambio no lo define WebRTC, lo montas tú como quieras.

Vamos a hacerlo con lo más simple que existe: una petición HTTP con la oferta y la respuesta.

Crea webrtc_source.py:

webrtc_source.py python
import asyncio
import cv2
from aiohttp import web
from aiortc import RTCPeerConnection, RTCSessionDescription


class WebRtcSource:
    def __init__(self, cam_id="browser0", jpeg_quality=80):
        self.cam_id = cam_id
        self.jpeg_quality = jpeg_quality
        self.track = None
        self.ready = asyncio.Event()
        self.pcs = set()

    async def offer(self, request):
        params = await request.json()
        pc = RTCPeerConnection()
        self.pcs.add(pc)

        @pc.on("track")
        def on_track(track):
            if track.kind == "video":
                self.track = track
                self.ready.set()

        @pc.on("connectionstatechange")
        async def on_state():
            if pc.connectionState in ("failed", "closed"):
                await pc.close()
                self.pcs.discard(pc)

        await pc.setRemoteDescription(
            RTCSessionDescription(sdp=params["sdp"], type=params["type"]))
        answer = await pc.createAnswer()
        await pc.setLocalDescription(answer)

        return web.json_response({
            "sdp": pc.localDescription.sdp,
            "type": pc.localDescription.type,
        })

    async def frames(self):
        await self.ready.wait()
        while True:
            frame = await self.track.recv()
            img = frame.to_ndarray(format="bgr24")
            ok, buf = cv2.imencode(
                ".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
            if ok:
                yield buf.tobytes()

    async def close(self):
        await asyncio.gather(*(pc.close() for pc in self.pcs))
        self.pcs.clear()

Vale la pena detenerse en un par de piezas:

asyncio.Event otra vez. Es el mismo tipo que usaste para las señales en el paso 15, y aquí resuelve un problema de orden: el pipeline quiere empezar a consumir frames antes de que ningún navegador se haya conectado. await self.ready.wait() deja esa tarea esperando tranquila, sin consumir nada, hasta que llegue el primer navegador.

frame.to_ndarray() y imencode convierten el frame que entrega WebRTC a los mismos bytes JPEG que producen todas tus demás fuentes. Otra vez la misma interfaz.

La página del navegador

Necesitas una página que pida la cámara y haga la señalización. Guárdala como static/webrtc.html:

static/webrtc.html html
<!doctype html>
<meta charset="utf-8">
<title>send camera</title>
<video id="local" autoplay playsinline muted></video>
<button id="go">send my camera</button>
<script>
document.getElementById("go").onclick = async () => {
  const stream = await navigator.mediaDevices.getUserMedia({video: true});
  document.getElementById("local").srcObject = stream;

  const pc = new RTCPeerConnection();
  stream.getTracks().forEach(t => pc.addTrack(t, stream));

  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);

  const response = await fetch("/offer", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({
      sdp: pc.localDescription.sdp,
      type: pc.localDescription.type,
    }),
  });
  await pc.setRemoteDescription(await response.json());
};
</script>

Enchufarlo al pipeline

En main.py, levanta el servidor de señalización y agrega la fuente como una más:

main.py python
webrtc = WebRtcSource()

app = web.Application()
app.router.add_post("/offer", webrtc.offer)
app.router.add_static("/", "static")
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, "0.0.0.0", 8080).start()

ingest.append(asyncio.create_task(
    ingest_source(webrtc, infer_q, stats)))

Ese servidor web corre en el mismo event loop que todo tu pipeline. No hay un hilo para el servidor y otro para la ingesta: son tareas conviviendo en el mismo bucle, igual que los trabajadores de inferencia.

Checkpoint · Tu teléfono como cámara

Arranca el pipeline y abre http://<la-ip-de-tu-computadora>:8080/webrtc.html desde tu teléfono, conectado a la misma red wifi. Pulsa el botón, acepta el permiso de cámara, y mira el dashboard del pipeline.

Ahí está browser0, con sus detecciones, junto a las cámaras de archivo. Acabas de convertir tu teléfono en una cámara IP sin instalar nada en él.

Ojo

Los navegadores solo dan acceso a la cámara en contextos seguros: HTTPS, o localhost. Desde el teléfono, con una IP normal y HTTP, Chrome y Safari van a rechazar getUserMedia sin explicación clara.

Para probarlo en local tienes dos salidas: abrir la página en la misma computadora con http://localhost:8080/webrtc.html, o exponer el puerto con un túnel que dé HTTPS, por ejemplo ngrok http 8080, y usar esa URL en el teléfono.

Lo que este capítulo demuestra

Compara las cuatro fuentes que has conectado:

Las cuatro terminan entregando bytes JPEG por la misma interfaz, y el pipeline no distingue entre ellas. Esa uniformidad no salió sola: salió de haber decidido en el paso 9 que una fuente es "algo que produce frames con async for", y de haber respetado esa forma incluso cuando por dentro hacía falta un hilo.