feat: sync live-count, exemplar annotation modules, and update .gitignore
This commit is contained in:
1 parent
b6624eeff9
commit
ac95674c07
39 files changed
+3979
-444
No files matched your search
@@ -0,0 +1,131 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user