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:
+1
-1
@@ -31,7 +31,7 @@
|
|||||||
| T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | BLOCKED |
|
| T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | BLOCKED |
|
||||||
| T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | BLOCKED |
|
| T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | BLOCKED |
|
||||||
| T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE |
|
| 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 迁移门槛与实现
|
## Phase 3 · Go V2 迁移门槛与实现
|
||||||
|
|
||||||
|
|||||||
@@ -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(有测试守卫)。
|
- 来源二选一:`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 解析。
|
- 结构化模式下应用用 `build_rtsp_url` 拼接,并对用户名/密码做 RFC 3986 百分号编码,因此密码含 `@`、`:`、`/` 也不会破坏 URL 解析。
|
||||||
- `source.mode` 显式声明来源类型,取值 `stream`(默认,实时 RTSP,允许有界重连)或 `replay`(本地录像,EOF 不重放);由配置决定,不再按 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、日志或截图文件名。
|
- 凭证(`host`/`username`/`password`/解析出的 URL)绝不进入 `runtime_config_version`、事件 JSONL、日志或截图文件名。
|
||||||
- `model.confidence_threshold` 是模型检测置信度,可在设置草稿中调整,并在下次开始监控时经 `PoseAdapter.set_confidence_threshold` 真正生效。
|
- `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 必须以受控模型的真实哈希替换并验证后才能启动推理。
|
- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-21
|
- 日期: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` 单元测试与假适配器管线冒烟覆盖视图逻辑。
|
- 已验证环境: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`。
|
- 旧生产基线:`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/` 目录存在但尚无实现。
|
- 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 任务顺序。
|
- 非代码设计工件: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` 哈希一致。
|
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
||||||
- 当前标准启动:`./init.ps1`。
|
- 当前标准启动:`./init.ps1`。
|
||||||
- 当前标准验证:`python -m compileall -q demo`。
|
- 当前标准验证:`python -m compileall -q demo`。
|
||||||
|
|||||||
@@ -271,3 +271,12 @@
|
|||||||
- 阻塞:设置页下拉/数字/勾选控件需 Windows + PyQt5 冒烟;配置解析与 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 拼串在本环境可真实验证。
|
- 阻塞:设置页下拉/数字/勾选控件需 Windows + PyQt5 冒烟;配置解析与 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 拼串在本环境可真实验证。
|
||||||
- 决策:`OPENCV_FFMPEG_CAPTURE_OPTIONS` 是 OpenCV 在打开流时读的环境变量,因此由 config 存值、app 在开流前写 `os.environ` 注入;不向操作员暴露原始字符串,只暴露 transport(tcp/udp)/连接超时(秒)/低延迟(勾选) 三个友好控件,程序内部拼串。这些不是凭证,可进公开示例默认值;启动可不再依赖 run 脚本设环境变量。
|
- 决策:`OPENCV_FFMPEG_CAPTURE_OPTIONS` 是 OpenCV 在打开流时读的环境变量,因此由 config 存值、app 在开流前写 `os.environ` 注入;不向操作员暴露原始字符串,只暴露 transport(tcp/udp)/连接超时(秒)/低延迟(勾选) 三个友好控件,程序内部拼串。这些不是凭证,可进公开示例默认值;启动可不再依赖 run 脚本设环境变量。
|
||||||
- 下一步:先写 `build_ffmpeg_options` 拼串与配置解析的失败测试,再实现;随后加设置页控件与保存。
|
- 下一步:先写 `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 验收。
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class FrameWorker(QtCore.QThread):
|
|||||||
self._stop = False
|
self._stop = False
|
||||||
|
|
||||||
def run(self) -> None:
|
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
|
mode = SourceMode.STREAM if self._config.source_mode == "stream" else SourceMode.REPLAY
|
||||||
source = VideoSource(self._config.source_url, mode=mode)
|
source = VideoSource(self._config.source_url, mode=mode)
|
||||||
pipeline = FallPipeline.from_config(self._config, self._pose_adapter)
|
pipeline = FallPipeline.from_config(self._config, self._pose_adapter)
|
||||||
@@ -162,7 +164,8 @@ def _camera_fields(path: Path) -> Dict:
|
|||||||
return {}
|
return {}
|
||||||
if "host" not in source:
|
if "host" not in source:
|
||||||
return {}
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
"source": {
|
"source": {
|
||||||
"id": "lobby-camera-01",
|
"id": "lobby-camera-01",
|
||||||
"rtsp_url_env": "SILVER_POSE_RTSP_URL",
|
"rtsp_url_env": "SILVER_POSE_RTSP_URL",
|
||||||
"mode": "stream"
|
"mode": "stream",
|
||||||
|
"transport": "tcp",
|
||||||
|
"timeout_seconds": 5.0,
|
||||||
|
"low_latency": true
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"path": "models/best.pt",
|
"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)
|
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)
|
@dataclass(frozen=True)
|
||||||
class EventConfig:
|
class EventConfig:
|
||||||
keypoint_confidence_threshold: float
|
keypoint_confidence_threshold: float
|
||||||
@@ -58,6 +81,15 @@ class AppConfig:
|
|||||||
event: EventConfig
|
event: EventConfig
|
||||||
event_dir: Path
|
event_dir: Path
|
||||||
source_mode: str = "stream"
|
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
|
@property
|
||||||
def runtime_config_version(self) -> str:
|
def runtime_config_version(self) -> str:
|
||||||
@@ -123,6 +155,9 @@ def write_local_camera_source(
|
|||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
mode: str = "stream",
|
mode: str = "stream",
|
||||||
|
transport: str = "tcp",
|
||||||
|
timeout_seconds: float = 5.0,
|
||||||
|
low_latency: bool = True,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Persist a structured camera source into an untracked local config file.
|
"""Persist a structured camera source into an untracked local config file.
|
||||||
|
|
||||||
@@ -141,6 +176,9 @@ def write_local_camera_source(
|
|||||||
"username": str(username),
|
"username": str(username),
|
||||||
"password": str(password),
|
"password": str(password),
|
||||||
"mode": mode,
|
"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")
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
return path
|
return path
|
||||||
@@ -189,6 +227,16 @@ def load_config(path: Path) -> AppConfig:
|
|||||||
if source_mode not in ("replay", "stream"):
|
if source_mode not in ("replay", "stream"):
|
||||||
raise ConfigError("source.mode must be 'replay' or '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 = _mapping(root.get("model"), "model")
|
||||||
model_sha256 = _text(model.get("sha256"), "model.sha256").lower()
|
model_sha256 = _text(model.get("sha256"), "model.sha256").lower()
|
||||||
if not _SHA256.match(model_sha256):
|
if not _SHA256.match(model_sha256):
|
||||||
@@ -240,4 +288,7 @@ def load_config(path: Path) -> AppConfig:
|
|||||||
event=event,
|
event=event,
|
||||||
event_dir=_resolve_path(config_path, artifacts.get("event_dir"), "artifacts.event_dir"),
|
event_dir=_resolve_path(config_path, artifacts.get("event_dir"), "artifacts.event_dir"),
|
||||||
source_mode=source_mode,
|
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.user_edit = QtWidgets.QLineEdit(str(camera.get("username", "")))
|
||||||
self.password_edit = QtWidgets.QLineEdit(str(camera.get("password", "")))
|
self.password_edit = QtWidgets.QLineEdit(str(camera.get("password", "")))
|
||||||
self.password_edit.setEchoMode(QtWidgets.QLineEdit.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("IP / 主机", self.host_edit)
|
||||||
form.addRow("端口", self.port_spin)
|
form.addRow("端口", self.port_spin)
|
||||||
form.addRow("通道", self.channel_combo)
|
form.addRow("通道", self.channel_combo)
|
||||||
form.addRow("账号", self.user_edit)
|
form.addRow("账号", self.user_edit)
|
||||||
form.addRow("密码", self.password_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.test_button = QtWidgets.QPushButton("测试连接")
|
||||||
self.save_camera_button = QtWidgets.QPushButton("保存连接到本地配置")
|
self.save_camera_button = QtWidgets.QPushButton("保存连接到本地配置")
|
||||||
@@ -386,6 +400,9 @@ class SettingsTab(QtWidgets.QWidget):
|
|||||||
channel=self.channel_combo.currentData(),
|
channel=self.channel_combo.currentData(),
|
||||||
username=self.user_edit.text().strip(),
|
username=self.user_edit.text().strip(),
|
||||||
password=self.password_edit.text(),
|
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
|
except Exception as exc: # noqa: BLE001 - surface any write error to the UI
|
||||||
self.camera_status.setText("保存失败:{0}".format(exc))
|
self.camera_status.setText("保存失败:{0}".format(exc))
|
||||||
|
|||||||
+53
-1
@@ -2,7 +2,7 @@ import json
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from v1.config import ConfigError, load_config
|
from v1.config import ConfigError, build_ffmpeg_options, load_config
|
||||||
|
|
||||||
|
|
||||||
def _write_config(path, source):
|
def _write_config(path, source):
|
||||||
@@ -140,6 +140,58 @@ def test_public_example_config_has_no_embedded_credentials():
|
|||||||
assert "rtsp_url_env" in source
|
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):
|
def test_source_mode_defaults_to_stream(tmp_path, monkeypatch):
|
||||||
config_file = tmp_path / "config.json"
|
config_file = tmp_path / "config.json"
|
||||||
_write_config(
|
_write_config(
|
||||||
|
|||||||
Reference in New Issue
Block a user