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.
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:
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:
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:
<!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:
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:
- MJPEG por HTTP:
awaitsobre un socket, sin hilos. - Webcam USB: un hilo en el borde, porque el hardware no ofrece otra cosa.
- RTSP: un hilo en el borde, porque decodificar H.264 es cómputo.
- WebRTC:
await track.recv(), asíncrono de arriba abajo.
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.