mgamsby-lattice's picture
Upload sensAI-Generic-Object-Detection with upload_repo.py
44a2bac verified
Raw
History Blame Contribute Delete
14 kB
"""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 # 12 hours
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},
},
#rtp_params={"degradationPreference": "maintain-resolution"},
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): # type: ignore[no-untyped-def]
_orig_init(self, *args, **kwargs)
self.frame_queue = asyncio.Queue(maxsize=1)
async def _latest_frame_accept_input(self): # type: ignore[no-untyped-def]
self.has_started = True
while not self.thread_quit.is_set():
try:
frame = _cast(frt.VideoFrame, await self.track.recv())
self.latest_frame = frame
# Flush any stale frame — we only ever want the latest
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 # type: ignore[assignment]
frt.VideoCallback.accept_input = _latest_frame_accept_input # type: ignore[assignment]
# Guard against None payload during client switches.
# FastRTC's WebRTC.preprocess assumes payload is never None, but Gradio
# can deliver a None when the outgoing connection drops mid-handoff.
from fastrtc import WebRTC as _WebRTC
_orig_preprocess = _WebRTC.preprocess
def _safe_preprocess(self, payload): # type: ignore[no-untyped-def]
if payload is None:
return None
return _orig_preprocess(self, payload)
_WebRTC.preprocess = _safe_preprocess # type: ignore[assignment]
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 # type: ignore[assignment]
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 # type: ignore[attr-defined]
def _safe_retry(self) -> None: # type: ignore[no-untyped-def]
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 # type: ignore[attr-defined]
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: # type: ignore[no-untyped-def]
yuv = cv2.cvtColor(array, cv2.COLOR_BGR2YUV_I420)
return VideoFrame.from_ndarray(yuv, format="yuv420p")
frt.VideoCallback.array_to_frame = _yuv420p_array_to_frame # type: ignore[assignment]
frt.ServerToClientVideo.array_to_frame = _yuv420p_array_to_frame # type: ignore[assignment]