"""Opening a live source, and the URL algebra around it. Split out of `live_count.py` so that file stays inside the 400-line limit: this is the transport layer (what a "source" is, how it is opened, how a WebRTC URL maps to the RTSP leg of the same stream), not the counting logic. """ import os import threading import time import cv2 class LiveCountError(Exception): pass def _is_stream(source: str) -> bool: return str(source).startswith(("rtsp://", "rtmp://", "http://", "https://")) # MediaMTX fans one camera out to several protocols on one host: WHEP for # browsers, RTSP for decoders. The ports are the server's, not ours, so they # come from the environment rather than the code (REQ-176). WHEP_PATH = os.environ.get("MEDIAMTX_WHEP_PATH", "/whep") RTSP_PORT = os.environ.get("MEDIAMTX_RTSP_PORT", "8554") def whep_url(source: str) -> str: """The URL a browser POSTs its SDP offer to, for a WebRTC stream URL.""" trimmed = source.rstrip("/") return trimmed if trimmed.endswith(WHEP_PATH) else trimmed + WHEP_PATH def whep_to_rtsp(source: str) -> str: """`http://host:8889/cam` -> `rtsp://host:8554/cam`. The browser and the counter watch the same camera, but not over the same protocol: WebRTC is what makes the *preview* cheap, while pulling it into Python would add ICE and a jitter buffer on top of exactly the same H.264 decode RTSP already does. So the AI counts from the RTSP leg of the same MediaMTX path — one ingest, two consumers. """ from urllib.parse import urlparse parsed = urlparse(source) if parsed.scheme not in ("http", "https") or not parsed.hostname: raise LiveCountError( "A live source must be a WebRTC (WHEP) URL, e.g. http://host:8889/cam") path = parsed.path.rstrip("/") if path.endswith(WHEP_PATH): path = path[: -len(WHEP_PATH)] if not path.strip("/"): raise LiveCountError(f"No stream path in {source} — expected e.g. http://host:8889/cam") return f"rtsp://{parsed.hostname}:{RTSP_PORT}{path}" class _ThreadedStream: """Decode in a background thread and always hand out the newest frame. A plain VideoCapture.read() on RTSP is blocking, and getting the frames there costs far more than looking at them does — measured on this camera at 8.3 fps for decode alone against 205 fps for tracking and inference. Worse, reading slower than the camera sends builds a backlog, so the picture drifts further behind real time the longer it runs. Dropping stale frames keeps latency flat, which is what a counting test needs to mean anything. `predict.py` does the same thing. """ def __init__(self, source: str): self._capture = cv2.VideoCapture(source) try: self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1) except Exception: pass self._frame = None self._lock = threading.Lock() self._running = True self._thread = threading.Thread(target=self._pump, daemon=True) if self._capture.isOpened(): self._thread.start() def _pump(self) -> None: while self._running: ok, frame = self._capture.read() if not ok: time.sleep(0.01) continue with self._lock: self._frame = frame def isOpened(self) -> bool: return self._capture.isOpened() def read(self): with self._lock: if self._frame is None: return False, None frame, self._frame = self._frame, None return True, frame def release(self) -> None: self._running = False # The thread is only started when the capture opened, so a failed # source would otherwise raise "cannot join thread before it is # started" here — inside the caller's finally, skipping the GPU lock # release and wedging every later session on "GPU busy". if self._thread.is_alive(): self._thread.join(timeout=2) self._capture.release() # TCP, and it is worth writing down why, because the obvious reasoning gives the # wrong answer here. The link to the streaming server is a ZeroTier VPN with 15% # packet loss and a 41-104 ms round trip, which is exactly the case where UDP is # supposed to win — and with the ffmpeg CLI it does, 16 fps against 6.8. Through # OpenCV it loses badly: measured over 30 s on this camera, tcp 6.4 fps, udp with # a socket buffer 4.4, bare udp 2.4. OpenCV drops what it cannot reassemble # rather than showing it, so the loss becomes missing frames. Left overridable # because on a clean LAN the answer flips back. RTSP_TRANSPORT = os.environ.get("RTSP_TRANSPORT", "tcp") def _open(source: str): if _is_stream(source): os.environ.setdefault( "OPENCV_FFMPEG_CAPTURE_OPTIONS", f"rtsp_transport;{RTSP_TRANSPORT}|buffer_size;1048576|max_delay;200000", ) return _ThreadedStream(source) return cv2.VideoCapture(source)