feat(v1): configurable low-latency capture options

Config gains source.transport (tcp/udp), timeout_seconds and low_latency;
build_ffmpeg_options assembles OPENCV_FFMPEG_CAPTURE_OPTIONS and app.py
sets it into the environment before opening the stream, so tuning lives
in config/settings instead of a launch script. Settings tab exposes a
dropdown/number/checkbox rather than the raw option string. 64 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ila
2026-07-21 22:56:14 +08:00
co-authored by Claude Opus 4.8
parent 5e4068db17
commit 1471e7ce78
9 changed files with 143 additions and 7 deletions
+51
View File
@@ -39,6 +39,29 @@ def build_rtsp_url(
return "rtsp://{0}:{1}@{2}:{3}{4}".format(user, secret, host, int(port), path)
def build_ffmpeg_options(
transport: str = "tcp",
timeout_seconds: float = 5.0,
low_latency: bool = True,
) -> str:
"""Build the OpenCV FFmpeg capture options string from friendly settings.
OpenCV reads ``OPENCV_FFMPEG_CAPTURE_OPTIONS`` when the stream is opened, so
the caller must set this into the environment before ``cv2.VideoCapture``.
Format is ``key;value`` pairs joined by ``|``.
"""
if transport not in ("tcp", "udp"):
raise ValueError("transport must be 'tcp' or 'udp'")
parts = ["rtsp_transport;{0}".format(transport)]
if timeout_seconds and float(timeout_seconds) > 0:
parts.append("stimeout;{0}".format(int(float(timeout_seconds) * 1_000_000)))
if low_latency:
parts.append("fflags;nobuffer")
parts.append("flags;low_delay")
return "|".join(parts)
@dataclass(frozen=True)
class EventConfig:
keypoint_confidence_threshold: float
@@ -58,6 +81,15 @@ class AppConfig:
event: EventConfig
event_dir: Path
source_mode: str = "stream"
transport: str = "tcp"
timeout_seconds: float = 5.0
low_latency: bool = True
@property
def ffmpeg_capture_options(self) -> str:
"""OpenCV FFmpeg options string to set before opening the stream."""
return build_ffmpeg_options(self.transport, self.timeout_seconds, self.low_latency)
@property
def runtime_config_version(self) -> str:
@@ -123,6 +155,9 @@ def write_local_camera_source(
username: str,
password: str,
mode: str = "stream",
transport: str = "tcp",
timeout_seconds: float = 5.0,
low_latency: bool = True,
) -> Path:
"""Persist a structured camera source into an untracked local config file.
@@ -141,6 +176,9 @@ def write_local_camera_source(
"username": str(username),
"password": str(password),
"mode": mode,
"transport": transport,
"timeout_seconds": float(timeout_seconds),
"low_latency": bool(low_latency),
}
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
@@ -189,6 +227,16 @@ def load_config(path: Path) -> AppConfig:
if source_mode not in ("replay", "stream"):
raise ConfigError("source.mode must be 'replay' or 'stream'")
transport = str(source.get("transport", "tcp")).strip().lower()
if transport not in ("tcp", "udp"):
raise ConfigError("source.transport must be 'tcp' or 'udp'")
timeout_seconds = _number(
source.get("timeout_seconds", 5.0), "source.timeout_seconds", 0.0, 60.0
)
low_latency = source.get("low_latency", True)
if not isinstance(low_latency, bool):
raise ConfigError("source.low_latency must be a boolean")
model = _mapping(root.get("model"), "model")
model_sha256 = _text(model.get("sha256"), "model.sha256").lower()
if not _SHA256.match(model_sha256):
@@ -240,4 +288,7 @@ def load_config(path: Path) -> AppConfig:
event=event,
event_dir=_resolve_path(config_path, artifacts.get("event_dir"), "artifacts.event_dir"),
source_mode=source_mode,
transport=transport,
timeout_seconds=timeout_seconds,
low_latency=low_latency,
)