132 lines
4.9 KiB
Python
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)
|