| """Shared live inference utilities for Gradio demos.
|
|
|
| Provides Twilio RTC configuration, a reusable WebRTC tab builder,
|
| and a FastRTC frame-queue patch to prevent unbounded memory growth.
|
| """
|
|
|
| import asyncio
|
| import os
|
| import time
|
| from typing import Any, Callable
|
|
|
| import gradio as gr
|
| import numpy as np
|
| from log_utils import setup_logger
|
|
|
| InferenceFn = Callable[[np.ndarray], np.ndarray]
|
|
|
| logger = setup_logger("LiveInference")
|
|
|
| TAB_SWITCH_AUTO_STOP_JS = """\
|
| <script>
|
| (function () {
|
| function setup() {
|
| var col = document.getElementById("webrtc-stream-col");
|
| if (!col) return setTimeout(setup, 500);
|
| var panel = col.closest(".tabitem") || col.closest('[role="tabpanel"]');
|
| if (!panel) return;
|
| new MutationObserver(function () {
|
| if (panel.style.display === "none" || panel.hidden) {
|
| col.querySelectorAll("button").forEach(function (b) {
|
| if (b.textContent.indexOf("Stop") >= 0) b.click();
|
| });
|
| }
|
| }).observe(panel, { attributes: true, attributeFilter: ["style", "hidden", "class"] });
|
| }
|
| setTimeout(setup, 1000);
|
| })();
|
| </script>"""
|
|
|
| _TOKEN_REFRESH_SECONDS = 12 * 3600
|
|
|
|
|
| def get_rtc_configuration() -> dict[str, Any]:
|
| """Build a WebRTC ICE configuration using Twilio TURN/STUN servers.
|
|
|
| Reads ``TWILIO_ACCOUNT_SID`` and ``TWILIO_AUTH_TOKEN`` from the
|
| environment. Returns an empty config when credentials are absent,
|
| which causes WebRTC to fall back to a direct peer-to-peer connection
|
| (works on LAN, may fail behind symmetric NATs).
|
|
|
| Returns:
|
| ICE server configuration dict (empty when no Twilio credentials).
|
| """
|
| account_sid = os.environ.get("TWILIO_ACCOUNT_SID")
|
| auth_token = os.environ.get("TWILIO_AUTH_TOKEN")
|
| if not account_sid or not auth_token:
|
| logger.info(
|
| "Twilio credentials not set -- WebRTC will use direct connection "
|
| "(set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN for TURN relay)"
|
| )
|
| return {}
|
|
|
| from twilio.rest import Client
|
|
|
| client = Client(account_sid, auth_token)
|
| token = client.tokens.create()
|
| return {
|
| "iceServers": token.ice_servers,
|
| "iceTransportPolicy": "relay",
|
| }
|
|
|
|
|
| class RtcConfigProvider:
|
| """Lazily creates and caches a Twilio ICE configuration, refreshing on expiry.
|
|
|
| Twilio tokens typically last 24 h. This class re-creates the token after
|
| ``_TOKEN_REFRESH_SECONDS`` (12 h by default) so that new WebRTC connections
|
| always get a valid config.
|
| """
|
|
|
| def __init__(self) -> None:
|
| self._config: dict[str, Any] | None = None
|
| self._created_at: float = 0.0
|
|
|
| def get(self) -> dict[str, Any]:
|
| """Return the ICE configuration, creating or refreshing as needed."""
|
| now = time.monotonic()
|
| if self._config is None or (now - self._created_at) > _TOKEN_REFRESH_SECONDS:
|
| self._config = get_rtc_configuration()
|
| self._created_at = now
|
| return self._config
|
|
|
|
|
| RtcConfigurationInput = Callable[[], dict[str, Any]] | dict[str, Any] | None
|
| """Accepted types for WebRTC ICE configuration.
|
|
|
| A callable is invoked per-connection by FastRTC (allowing credential refresh)
|
| and **must** return a dict (not ``None``) — FastRTC's client-side JS crashes on
|
| ``null``. A dict is used as-is for every connection; ``None`` falls back to
|
| direct P2P (handled safely by FastRTC's ``or {}`` fallback).
|
| """
|
|
|
|
|
| def build_live_inference_tab(
|
| rtc_configuration: RtcConfigurationInput,
|
| description_html: str = "",
|
| tab_label: str = "Live Inference",
|
| width: int = 640,
|
| height: int = 360,
|
| ) -> tuple[gr.TabItem, Any]:
|
| """Build a Live Inference tab containing a WebRTC video stream.
|
|
|
| Must be called inside a ``gr.Blocks`` / ``gr.Tabs`` context. The caller
|
| is responsible for wiring the stream event.
|
|
|
| Args:
|
| rtc_configuration: ICE configuration dict, callable that returns one,
|
| or ``None`` for direct connection. A callable is called per-connection
|
| by FastRTC, enabling credential refresh.
|
| description_html: Optional HTML shown above the stream.
|
| tab_label: Label for the tab.
|
| width: Camera frame width in pixels.
|
| height: Camera frame height in pixels.
|
|
|
| Returns:
|
| Tuple of ``(tab, webrtc_stream)``.
|
| """
|
| with gr.TabItem(tab_label) as tab:
|
| if description_html:
|
| gr.HTML(description_html)
|
| webrtc_stream = build_webrtc_stream(rtc_configuration, width=width, height=height)
|
|
|
| return tab, webrtc_stream
|
|
|
|
|
| def build_webrtc_stream(
|
| rtc_configuration: RtcConfigurationInput,
|
| max_fps: int = 15,
|
| width: int = 640,
|
| height: int = 360,
|
| ) -> Any:
|
| """Create a WebRTC video-stream component.
|
|
|
| Must be called inside a Gradio layout context (e.g. ``gr.Row``,
|
| ``gr.Column``, ``gr.TabItem``).
|
|
|
| Args:
|
| rtc_configuration: ICE configuration dict, callable that returns one,
|
| or ``None`` for direct connection. A callable is called per-connection
|
| by FastRTC, enabling credential refresh.
|
| max_fps: Maximum frame rate requested from the browser camera.
|
| width: Camera frame width in pixels.
|
| height: Camera frame height in pixels.
|
|
|
| Returns:
|
| A ``fastrtc.WebRTC`` component instance.
|
| """
|
| from fastrtc import WebRTC
|
|
|
| with gr.Row():
|
| with gr.Column(
|
| elem_classes="webrtc-stream-col",
|
| elem_id="webrtc-stream-col",
|
| ):
|
| webrtc_stream = WebRTC(
|
| label="Live Detection",
|
| mode="send-receive",
|
| modality="video",
|
| rtc_configuration=rtc_configuration,
|
| container=True,
|
| show_label=True,
|
| mirror_webcam=False,
|
| track_constraints={
|
| "width": {"exact": width},
|
| "height": {"exact": height},
|
| "frameRate": {"max": max_fps},
|
| },
|
|
|
| full_screen=False,
|
| button_labels={
|
| "start": "Start Inference",
|
| "stop": "Stop Inference",
|
| },
|
| )
|
| return webrtc_stream
|
|
|
|
|
| def patch_fastrtc_frame_queue() -> None:
|
| """Replace FastRTC's unbounded frame queue with a latest-frame-only queue.
|
|
|
| FastRTC's ``VideoCallback`` uses an unbounded ``asyncio.Queue`` for
|
| incoming WebRTC frames. When the handler can't keep up (e.g. pipe
|
| round-trip ~35-50 ms at 30 fps), frames accumulate faster than
|
| they're consumed. Each queued 1280×720 ``VideoFrame`` is ~1.38 MB
|
| of C memory (invisible to ``tracemalloc``), causing >1 MB/s growth
|
| on Windows.
|
|
|
| We can never "catch up" on skipped frames, so always discard stale
|
| ones and keep only the most recent frame.
|
|
|
| Must be called **before** any ``WebRTC`` component is created
|
| (typically at the top of ``__main__``).
|
| """
|
| from typing import cast as _cast
|
|
|
| import fastrtc.tracks as frt
|
| from aiortc.mediastreams import MediaStreamError
|
|
|
| _orig_init = frt.VideoCallback.__init__
|
|
|
| def _patched_init(self, *args, **kwargs):
|
| _orig_init(self, *args, **kwargs)
|
| self.frame_queue = asyncio.Queue(maxsize=1)
|
|
|
| async def _latest_frame_accept_input(self):
|
| self.has_started = True
|
| while not self.thread_quit.is_set():
|
| try:
|
| frame = _cast(frt.VideoFrame, await self.track.recv())
|
| self.latest_frame = frame
|
|
|
| while not self.frame_queue.empty():
|
| try:
|
| self.frame_queue.get_nowait()
|
| except asyncio.QueueEmpty:
|
| break
|
| self.frame_queue.put_nowait(frame)
|
| except MediaStreamError:
|
| self.stop()
|
| return
|
|
|
| frt.VideoCallback.__init__ = _patched_init
|
| frt.VideoCallback.accept_input = _latest_frame_accept_input
|
|
|
|
|
|
|
|
|
| from fastrtc import WebRTC as _WebRTC
|
|
|
| _orig_preprocess = _WebRTC.preprocess
|
|
|
| def _safe_preprocess(self, payload):
|
| if payload is None:
|
| return None
|
| return _orig_preprocess(self, payload)
|
|
|
| _WebRTC.preprocess = _safe_preprocess
|
|
|
|
|
| def patch_aiortc_h264_nvenc() -> None:
|
| """Patch aiortc's H264Encoder to use GPU encoding (h264_nvenc) when available.
|
|
|
| The default aiortc H264Encoder hardcodes ``libx264`` (CPU). On machines
|
| with an NVIDIA GPU and NVENC-enabled FFmpeg, this patch swaps in
|
| ``h264_nvenc`` to offload encoding from the CPU.
|
|
|
| Falls back silently if NVENC is not available (no patch applied).
|
|
|
| Must be called **before** any WebRTC component is created.
|
| """
|
| import fractions
|
| from collections.abc import Iterator
|
|
|
| import av
|
| from aiortc.codecs.h264 import MAX_FRAME_RATE, H264Encoder
|
|
|
| try:
|
| ctx = av.CodecContext.create("h264_nvenc", "w")
|
| ctx.width = 64
|
| ctx.height = 64
|
| ctx.pix_fmt = "yuv420p"
|
| ctx.open()
|
| ctx.close()
|
| except Exception:
|
| logger.info("WebRTC: h264_nvenc unavailable, keeping libx264")
|
| return
|
|
|
| logger.info("WebRTC: patching H264Encoder to use h264_nvenc")
|
|
|
| def _encode_frame_nvenc(
|
| self: H264Encoder, frame: av.VideoFrame, force_keyframe: bool
|
| ) -> Iterator[bytes]:
|
| if self.codec and (
|
| frame.width != self.codec.width
|
| or frame.height != self.codec.height
|
| or abs(self.target_bitrate - self.codec.bit_rate) / self.codec.bit_rate > 0.1
|
| ):
|
| self.buffer_data = b""
|
| self.buffer_pts = None
|
| self.codec = None
|
|
|
| if force_keyframe:
|
| frame.pict_type = av.video.frame.PictureType.I
|
| else:
|
| frame.pict_type = av.video.frame.PictureType.NONE
|
|
|
| if self.codec is None:
|
| self.codec = av.CodecContext.create("h264_nvenc", "w")
|
| self.codec.width = frame.width
|
| self.codec.height = frame.height
|
| self.codec.bit_rate = self.target_bitrate
|
| self.codec.pix_fmt = "yuv420p"
|
| self.codec.framerate = fractions.Fraction(MAX_FRAME_RATE, 1)
|
| self.codec.time_base = fractions.Fraction(1, MAX_FRAME_RATE)
|
| self.codec.options = {"preset": "p1", "delay": "0"}
|
| self.codec.profile = "Baseline"
|
|
|
| data_to_send = b""
|
| for package in self.codec.encode(frame):
|
| data_to_send += bytes(package)
|
|
|
| if data_to_send:
|
| yield from self._split_bitstream(data_to_send)
|
|
|
| H264Encoder._encode_frame = _encode_frame_nvenc
|
|
|
|
|
| def patch_aioice_stun_transaction() -> None:
|
| """Guard aioice's STUN Transaction.__retry against already-resolved futures.
|
|
|
| aioice schedules ``Transaction.__retry`` via ``loop.call_later``. When the
|
| retry fires after ``response_received`` has already resolved the future,
|
| ``set_exception(TransactionTimeout())`` raises ``InvalidStateError``. This
|
| is a known upstream race condition (aiortc/aioice#78).
|
|
|
| The patch adds a ``future.done()`` guard identical to the one already
|
| present in ``response_received``, and logs a debug message when the race is
|
| hit.
|
|
|
| Must be called **before** any WebRTC component is created.
|
| """
|
| from aioice.stun import Transaction
|
|
|
| _orig_retry = Transaction._Transaction__retry
|
|
|
| def _safe_retry(self) -> None:
|
| if self._Transaction__future.done():
|
| logger.debug(
|
| "aioice STUN transaction already resolved — "
|
| "suppressing stale retry (aiortc/aioice#78)"
|
| )
|
| return
|
| _orig_retry(self)
|
|
|
| Transaction._Transaction__retry = _safe_retry
|
|
|
|
|
| def patch_fastrtc_yuv420p_output() -> None:
|
| """Pre-convert outbound frames to yuv420p to avoid BufferError on Python 3.13.
|
|
|
| PyAV's ``VideoFrame.from_ndarray()`` wraps numpy memory via internal
|
| ``BytesIO`` objects. When aiortc's VP8 encoder later calls
|
| ``frame.reformat(format="yuv420p")``, the intermediate ``BytesIO``
|
| cleanup raises ``BufferError`` because the numpy array still holds an
|
| active buffer export (Python 3.13's stricter buffer protocol).
|
|
|
| By converting BGR→YUV420p in numpy-space (via OpenCV) and constructing
|
| the ``VideoFrame`` directly in ``yuv420p``, the VP8 encoder sees the
|
| target format and skips ``reformat()`` entirely — avoiding the conflict.
|
|
|
| Must be called **before** any ``WebRTC`` component is created.
|
| """
|
| import cv2
|
| from av import VideoFrame
|
|
|
| import fastrtc.tracks as frt
|
|
|
| def _yuv420p_array_to_frame(self, array: np.ndarray) -> VideoFrame:
|
| yuv = cv2.cvtColor(array, cv2.COLOR_BGR2YUV_I420)
|
| return VideoFrame.from_ndarray(yuv, format="yuv420p")
|
|
|
| frt.VideoCallback.array_to_frame = _yuv420p_array_to_frame
|
| frt.ServerToClientVideo.array_to_frame = _yuv420p_array_to_frame
|
|
|