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:
@@ -35,6 +35,8 @@ class FrameWorker(QtCore.QThread):
|
||||
self._stop = False
|
||||
|
||||
def run(self) -> None:
|
||||
# OpenCV reads this env var when it opens the stream, so set it first.
|
||||
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = self._config.ffmpeg_capture_options
|
||||
mode = SourceMode.STREAM if self._config.source_mode == "stream" else SourceMode.REPLAY
|
||||
source = VideoSource(self._config.source_url, mode=mode)
|
||||
pipeline = FallPipeline.from_config(self._config, self._pose_adapter)
|
||||
@@ -162,7 +164,8 @@ def _camera_fields(path: Path) -> Dict:
|
||||
return {}
|
||||
if "host" not in source:
|
||||
return {}
|
||||
return {k: source.get(k) for k in ("host", "port", "channel", "username", "password")}
|
||||
fields = ("host", "port", "channel", "username", "password", "transport", "timeout_seconds", "low_latency")
|
||||
return {k: source.get(k) for k in fields if k in source}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
"source": {
|
||||
"id": "lobby-camera-01",
|
||||
"rtsp_url_env": "SILVER_POSE_RTSP_URL",
|
||||
"mode": "stream"
|
||||
"mode": "stream",
|
||||
"transport": "tcp",
|
||||
"timeout_seconds": 5.0,
|
||||
"low_latency": true
|
||||
},
|
||||
"model": {
|
||||
"path": "models/best.pt",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -314,11 +314,25 @@ class SettingsTab(QtWidgets.QWidget):
|
||||
self.user_edit = QtWidgets.QLineEdit(str(camera.get("username", "")))
|
||||
self.password_edit = QtWidgets.QLineEdit(str(camera.get("password", "")))
|
||||
self.password_edit.setEchoMode(QtWidgets.QLineEdit.Password)
|
||||
self.transport_combo = QtWidgets.QComboBox()
|
||||
self.transport_combo.addItem("TCP", "tcp")
|
||||
self.transport_combo.addItem("UDP", "udp")
|
||||
self.transport_combo.setCurrentIndex(1 if str(camera.get("transport", "tcp")) == "udp" else 0)
|
||||
self.timeout_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.timeout_spin.setRange(0.0, 60.0)
|
||||
self.timeout_spin.setSingleStep(1.0)
|
||||
self.timeout_spin.setDecimals(1)
|
||||
self.timeout_spin.setValue(float(camera.get("timeout_seconds", 5.0)))
|
||||
self.low_latency_check = QtWidgets.QCheckBox("低延迟(丢弃缓冲)")
|
||||
self.low_latency_check.setChecked(bool(camera.get("low_latency", True)))
|
||||
form.addRow("IP / 主机", self.host_edit)
|
||||
form.addRow("端口", self.port_spin)
|
||||
form.addRow("通道", self.channel_combo)
|
||||
form.addRow("账号", self.user_edit)
|
||||
form.addRow("密码", self.password_edit)
|
||||
form.addRow("传输协议", self.transport_combo)
|
||||
form.addRow("连接超时(秒)", self.timeout_spin)
|
||||
form.addRow("", self.low_latency_check)
|
||||
|
||||
self.test_button = QtWidgets.QPushButton("测试连接")
|
||||
self.save_camera_button = QtWidgets.QPushButton("保存连接到本地配置")
|
||||
@@ -386,6 +400,9 @@ class SettingsTab(QtWidgets.QWidget):
|
||||
channel=self.channel_combo.currentData(),
|
||||
username=self.user_edit.text().strip(),
|
||||
password=self.password_edit.text(),
|
||||
transport=self.transport_combo.currentData(),
|
||||
timeout_seconds=self.timeout_spin.value(),
|
||||
low_latency=self.low_latency_check.isChecked(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - surface any write error to the UI
|
||||
self.camera_status.setText("保存失败:{0}".format(exc))
|
||||
|
||||
+53
-1
@@ -2,7 +2,7 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from v1.config import ConfigError, load_config
|
||||
from v1.config import ConfigError, build_ffmpeg_options, load_config
|
||||
|
||||
|
||||
def _write_config(path, source):
|
||||
@@ -140,6 +140,58 @@ def test_public_example_config_has_no_embedded_credentials():
|
||||
assert "rtsp_url_env" in source
|
||||
|
||||
|
||||
def test_build_ffmpeg_options_variants():
|
||||
assert build_ffmpeg_options() == "rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay"
|
||||
assert build_ffmpeg_options("udp", 3, False) == "rtsp_transport;udp|stimeout;3000000"
|
||||
with pytest.raises(ValueError):
|
||||
build_ffmpeg_options("http")
|
||||
|
||||
|
||||
def test_source_capture_tuning_defaults_to_tcp_low_latency(tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
_write_config(
|
||||
config_file,
|
||||
{"id": "c", "host": "192.0.2.10", "username": "u", "password": "p"},
|
||||
)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.transport == "tcp"
|
||||
assert config.timeout_seconds == 5.0
|
||||
assert config.low_latency is True
|
||||
assert (
|
||||
config.ffmpeg_capture_options
|
||||
== "rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay"
|
||||
)
|
||||
|
||||
|
||||
def test_source_capture_tuning_is_parsed(tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
_write_config(
|
||||
config_file,
|
||||
{
|
||||
"id": "c", "host": "192.0.2.10", "username": "u", "password": "p",
|
||||
"transport": "udp", "timeout_seconds": 3, "low_latency": False,
|
||||
},
|
||||
)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.transport == "udp"
|
||||
assert config.ffmpeg_capture_options == "rtsp_transport;udp|stimeout;3000000"
|
||||
|
||||
|
||||
def test_invalid_transport_is_rejected(tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
_write_config(
|
||||
config_file,
|
||||
{"id": "c", "host": "192.0.2.10", "username": "u", "password": "p", "transport": "http"},
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigError, match="transport"):
|
||||
load_config(config_file)
|
||||
|
||||
|
||||
def test_source_mode_defaults_to_stream(tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.json"
|
||||
_write_config(
|
||||
|
||||
Reference in New Issue
Block a user