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
+1 -1
View File
@@ -31,7 +31,7 @@
| T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | BLOCKED |
| T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | BLOCKED |
| T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE |
| T-207 | 抓流低延迟参数进配置与设置页 | T-206 | 配置支持 transport(tcp/udp)/连接超时/低延迟,程序拼 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 并在开流前注入;设置页用下拉/数字/勾选而非原始字符串;启动可不依赖 run 脚本设环境变量。 | DOING |
| T-207 | 抓流低延迟参数进配置与设置页 | T-206 | 配置支持 transport(tcp/udp)/连接超时/低延迟,程序拼 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 并在开流前注入;设置页用下拉/数字/勾选而非原始字符串;启动可不依赖 run 脚本设环境变量。 | DONE |
## Phase 3 · Go V2 迁移门槛与实现
+1
View File
@@ -35,6 +35,7 @@
- 来源二选一:`rtsp_url_env`(从同名环境变量读取真实 URL)**或**结构化字段 `host`/`port`(默认 554)/`channel`(默认 `101`,`102` 为子码流)/`username`/`password`;两者都会在内存中解析出 RTSP URL。结构化凭证只允许出现在**未跟踪的** `config.local.json`;公开示例 `config.example.json` 只能用 `rtsp_url_env`,禁止任何 `host`/`username`/`password`/完整 URL(有测试守卫)。
- 结构化模式下应用用 `build_rtsp_url` 拼接,并对用户名/密码做 RFC 3986 百分号编码,因此密码含 `@`、`:`、`/` 也不会破坏 URL 解析。
- `source.mode` 显式声明来源类型,取值 `stream`(默认,实时 RTSP,允许有界重连)或 `replay`(本地录像,EOF 不重放);由配置决定,不再按 URL 前缀猜测。
- 抓流低延迟调优(非凭证,可进公开示例):`source.transport`(`tcp` 默认 / `udp`)、`source.timeout_seconds`(默认 5)、`source.low_latency`(默认 true)。应用用 `build_ffmpeg_options` 拼成 `OPENCV_FFMPEG_CAPTURE_OPTIONS`,并在打开流之前写入 `os.environ`(OpenCV 在打开时读取该环境变量);因此无需再靠外部脚本设置该变量。
- 凭证(`host`/`username`/`password`/解析出的 URL)绝不进入 `runtime_config_version`、事件 JSONL、日志或截图文件名。
- `model.confidence_threshold` 是模型检测置信度,可在设置草稿中调整,并在下次开始监控时经 `PoseAdapter.set_confidence_threshold` 真正生效。
- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。
+3 -3
View File
@@ -5,13 +5,13 @@
## 当前快照
- 日期:2026-07-21
- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件与结构化摄像头配置/连接测试已建立;T-202、T-206 已验收,T-203 进行中(真实海康流已连通,GUI 现场预览待 Windows 冒烟)。
- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件、结构化摄像头配置/连接测试与抓流低延迟参数已建立;T-202、T-206、T-207 已验收,T-203 进行中(真实海康流已连通,GUI 现场预览待 Windows 冒烟)。
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。
- 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。
- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。
- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览,以及传输协议/连接超时/低延迟三项抓流调优(由 `config` 存值、`app` 开流前写入 `OPENCV_FFMPEG_CAPTURE_OPTIONS`)。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。
- V2 代码:`v2/` 目录存在但尚无实现。
- 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 60 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件和摄像头/URL 构建测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 64 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建和抓流参数拼串测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
- 当前标准启动:`./init.ps1`。
- 当前标准验证:`python -m compileall -q demo`。
+9
View File
@@ -271,3 +271,12 @@
- 阻塞:设置页下拉/数字/勾选控件需 Windows + PyQt5 冒烟;配置解析与 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 拼串在本环境可真实验证。
- 决策:`OPENCV_FFMPEG_CAPTURE_OPTIONS` 是 OpenCV 在打开流时读的环境变量,因此由 config 存值、app 在开流前写 `os.environ` 注入;不向操作员暴露原始字符串,只暴露 transport(tcp/udp)/连接超时(秒)/低延迟(勾选) 三个友好控件,程序内部拼串。这些不是凭证,可进公开示例默认值;启动可不再依赖 run 脚本设环境变量。
- 下一步:先写 `build_ffmpeg_options` 拼串与配置解析的失败测试,再实现;随后加设置页控件与保存。
## 【2026-07-21】T-207 抓流低延迟参数进配置与设置页(完成)
- 状态:DONE
- 变更:`config.py` 新增 `build_ffmpeg_options(transport, timeout_seconds, low_latency)`(拼 `rtsp_transport;..|stimeout;..(微秒)|fflags;nobuffer|flags;low_delay`),`AppConfig` 增 `transport`/`timeout_seconds`/`low_latency` 字段与 `ffmpeg_capture_options` 属性;`load_config` 解析并校验三者(默认 tcp/5/true);`write_local_camera_source` 一并写回。`app.py` 的 `FrameWorker.run` 在打开流之前把 `ffmpeg_capture_options` 写进 `os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"]`。`gui.py` 设置页摄像头分组新增「传输协议」下拉、「连接超时」数字、「低延迟」勾选,保存时一并落盘;`config.example.json` 加入三项安全默认值。
- 验证:新增 config 四项测试(`build_ffmpeg_options` 三种组合与非法值、调优默认值、显式解析、非法 transport 被拒);`python3 -m pytest v1/tests -q` 为 64 passed;`python3 -m compileall -q v1 demo` 退出码 0。以现有 `v1/config.local.json` 端到端加载确认 `transport=tcp/timeout=5/low_latency=True`、`ffmpeg_capture_options=rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay`。
- 阻塞:设置页三个新控件需 Windows + PyQt5 冒烟;拼串、配置解析与 env 注入逻辑本环境已验证。
- 决策:不向操作员暴露原始 ffmpeg 字符串,只给 transport/超时/低延迟三个友好控件,程序内部拼串;由 config 存值、app 在开流前注入环境变量;这些非凭证,进入公开示例默认值。启动可不再依赖 `run_v1.ps1` 设 `OPENCV_FFMPEG_CAPTURE_OPTIONS`。
- 下一步:Windows 上冒烟设置页三控件与实际低延迟效果;继续 T-203 现场 GUI 验收。
+4 -1
View File
@@ -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__":
+4 -1
View File
@@ -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",
+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,
)
+17
View File
@@ -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
View File
@@ -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(