Files
reTraining/backend/live_source.py
T

132 lines
4.9 KiB
Python

"""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)