feat(v1): settings camera connection test and preview

Settings tab gains a camera group (host/port/channel/user/masked password),
a background connection-test worker that probes one frame off the GUI
thread and shows it in a preview, and a save button that writes the
structured source to the untracked config.local.json. 60 tests pass;
Qt shell compiles and needs a Windows smoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ila
2026-07-21 22:44:09 +08:00
co-authored by Claude Opus 4.8
parent edf8a9a94e
commit 2603a10fb1
8 changed files with 218 additions and 11 deletions
+1
View File
@@ -32,6 +32,7 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 应用入口 | `v1/app.py` | 装配配置、窗口、线程和依赖 | 推理细节、事件判定 | | 应用入口 | `v1/app.py` | 装配配置、窗口、线程和依赖 | 推理细节、事件判定 |
| 配置 | `v1/config.py` | 解析示例和本地配置,校验非敏感字段 | 保存真实凭证 | | 配置 | `v1/config.py` | 解析示例和本地配置,校验非敏感字段 | 保存真实凭证 |
| 摄像头接入 | `v1/camera.py` | 由 host/port/channel/账号/密码构建 RTSP URL(凭证百分号编码),并对流做一次有界连接测试抓帧 | 事件判定、持久化 |
| 视频源 | `v1/video_source.py` | 打开、读取、重连 RTSP 或录像;输出帧、单调回放时间戳和显式来源状态 | Pose、报警 | | 视频源 | `v1/video_source.py` | 打开、读取、重连 RTSP 或录像;输出帧、单调回放时间戳和显式来源状态 | Pose、报警 |
| Pose 适配器 | `v1/pose.py` | 校验锁定模型的 SHA-256、pose/person/17×3 契约,统一返回 box、关键点、置信度 | 跟踪、摔倒业务结论 | | Pose 适配器 | `v1/pose.py` | 校验锁定模型的 SHA-256、pose/person/17×3 契约,统一返回 box、关键点、置信度 | 跟踪、摔倒业务结论 |
| 跟踪 | `v1/tracking.py` | 以归一化 box 中心距离为连续人员输出稳定 `track_id` | 根据姿态报警 | | 跟踪 | `v1/tracking.py` | 以归一化 box 中心距离为连续人员输出稳定 `track_id` | 根据姿态报警 |
+1 -1
View File
@@ -30,7 +30,7 @@
| T-203 | 接入海康 RTSP 与断流恢复 | T-202 | 现场有效流可预览;断流状态明确、可重连且不报警。 | DOING | | T-203 | 接入海康 RTSP 与断流恢复 | T-202 | 现场有效流可预览;断流状态明确、可重连且不报警。 | DOING |
| 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` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DOING | | T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE |
## Phase 3 · Go V2 迁移门槛与实现 ## Phase 3 · Go V2 迁移门槛与实现
+4 -4
View File
@@ -5,17 +5,17 @@
## 当前快照 ## 当前快照
- 日期:2026-07-21 - 日期:2026-07-21
- 阶段:V1 事件管线、PyQt 双 Tab 界面外壳与本地报警工件闭环已建立;T-202 已验收,等待 T-203(需现场海康 RTSP,暂受阻)。 - 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件与结构化摄像头配置/连接测试已建立;T-202、T-206 已验收,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` 提供声音与一次性弹窗)触发一次声音/弹窗。真实海康 RTSP 接入(T-203)尚未实现。 - 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)。
- 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` 语法);`python -m pytest v1/tests -v` 当前有 51 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型和报警工件测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一的复核修复)。`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` 当前有 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 测试,但不会安装软件包。
- 模型:`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`。
- 当前 blocker:PyQt 界面外壳与报警闭环已实现,但可视化、声音与弹窗冒烟需在 Windows 执行;T-203(海康 RTSP 与断流恢复)需现场真实流,T-204(正反例回归录像与现场验收)需经同意成年人的摔倒/反例录像与现场安全措施,T-205 依赖 T-204,三者在当前无摄像头、无录像素材的环境无法真实验收,均标为 BLOCKED;尚无带标签的正反例回归录像及事件标签。 - 当前 blocker:PyQt 界面、报警闭环与摄像头连接测试/预览已实现,但可视化、声音、弹窗与设置页测试连接的冒烟需在 Windows 执行;T-203 的真实海康流已连通,剩「带骨架的操作员现场预览」与「真实断流→重连观察」待 Windows 完成;T-204(正反例回归录像与现场验收)需经同意成年人的摔倒/反例录像与现场安全措施,T-205 依赖 T-204,二者仍标为 BLOCKED;尚无带标签的正反例回归录像及事件标签。
全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。 全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。
+9
View File
@@ -253,3 +253,12 @@
- 阻塞:设置页掩码输入、测试连接按钮与预览显示需 Windows + PyQt5 冒烟;配置解析、RTSP URL 构建(凭证百分号编码)与连接测试逻辑在本环境可真实验证。 - 阻塞:设置页掩码输入、测试连接按钮与预览显示需 Windows + PyQt5 冒烟;配置解析、RTSP URL 构建(凭证百分号编码)与连接测试逻辑在本环境可真实验证。
- 决策:配置支持「结构化 host/port/channel/username/password」或原有 `rtsp_url_env` 二选一;密码按 RFC 3986 百分号编码后拼 URL(顺带解决 `@` 问题)。护栏:公开示例 `config.example.json` 继续禁止任何 host/账号/密码/URL 并加测试守卫;`runtime_config_version` 继续排除凭证;凭证绝不进 JSONL、日志、截图名、错误信息。 - 决策:配置支持「结构化 host/port/channel/username/password」或原有 `rtsp_url_env` 二选一;密码按 RFC 3986 百分号编码后拼 URL(顺带解决 `@` 问题)。护栏:公开示例 `config.example.json` 继续禁止任何 host/账号/密码/URL 并加测试守卫;`runtime_config_version` 继续排除凭证;凭证绝不进 JSONL、日志、截图名、错误信息。
- 下一步:先写 URL 构建与结构化配置解析的失败测试,再实现;随后加 Qt 外壳(设置页字段、测试按钮、预览)。 - 下一步:先写 URL 构建与结构化配置解析的失败测试,再实现;随后加 Qt 外壳(设置页字段、测试按钮、预览)。
## 【2026-07-21】T-206 结构化摄像头配置、连接测试与设置页预览(完成)
- 状态:DONE
- 变更:`config.py` 新增 `build_rtsp_url`(凭证 RFC 3986 百分号编码)与结构化来源解析(`host/port/channel/username/password` 或原 `rtsp_url_env` 二选一),并新增 `write_local_camera_source` 把结构化来源写回未跟踪的 `config.local.json`。新增 `v1/camera.py`:`probe_stream` 用 `VideoSource(STREAM)` 做一次有界连接测试并回传抓拍帧(失败不抛异常、消息不含凭证)。`v1/gui.py` 设置页新增「摄像头连接」分组:host/端口/通道(101/102 下拉)/账号/密码(掩码)、`ConnectionTestWorker(QThread)` 后台测试连接、成功后在独立预览区显示抓拍帧、`保存连接到本地配置`按钮写回本地文件;`app.py` 从本地配置预填这些字段并传入配置路径。
- 验证:新增测试——camera 四项(密码 `@`/`:`/`/` 百分号编码、默认主码流、probe 成功回帧、probe 失败不抛)、config 五项(结构化来源拼 URL、缺密码被拒、凭证不进 config_version、公开示例无凭证守卫、`write_local_camera_source` 经 `load_config` 往返);`python3 -m pytest v1/tests -q` 为 60 passed;`python3 -m compileall -q v1 demo` 退出码 0(含 gui/app 新 Qt 代码语法)。真实相机连接此前已在 T-203 冒烟验证(1920×1080 取帧)。
- 阻塞:设置页掩码输入、测试按钮点击与预览显示需 Windows + PyQt5 冒烟;本环境已真实验证 URL 构建、结构化解析、凭证护栏与连接测试逻辑。
- 决策:按用户选择「全存本地配置文件」,凭证明文写入未跟踪 `config.local.json`(`git check-ignore` 保护);护栏落测试:公开示例禁含凭证、`config_version` 排除凭证、消息/日志不含密码;密码百分号编码顺带修掉 `@` 解析问题。
- 下一步:在 Windows 用设置页填写并测试连接、确认预览,再保存到本地配置;随后继续 T-203 的现场 GUI 预览与断流重连人工验收。
+26 -4
View File
@@ -6,11 +6,12 @@ only renders them. Settings edits become a new immutable runtime snapshot at the
next start, so screenshots and logs stay traceable to the actual config version. next start, so screenshots and logs stay traceable to the actual config version.
""" """
import json
import os import os
import sys import sys
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Dict, Optional
from PyQt5 import QtCore, QtWidgets from PyQt5 import QtCore, QtWidgets
@@ -82,7 +83,13 @@ def _draft_from_config(config: AppConfig) -> SettingsDraft:
class ApplicationController: class ApplicationController:
"""Own the window, draft and worker lifecycle without doing event logic.""" """Own the window, draft and worker lifecycle without doing event logic."""
def __init__(self, config: AppConfig, pose_adapter: PoseAdapter) -> None: def __init__(
self,
config: AppConfig,
pose_adapter: PoseAdapter,
config_path: Optional[str] = None,
camera: Optional[Dict] = None,
) -> None:
from v1.gui import MainWindow from v1.gui import MainWindow
self._config = config self._config = config
@@ -90,7 +97,9 @@ class ApplicationController:
self._draft = _draft_from_config(config) self._draft = _draft_from_config(config)
env_ready = bool(config.source_url) env_ready = bool(config.source_url)
model_summary = "{0} · {1}…".format(config.model_path.name, config.model_sha256[:12]) model_summary = "{0} · {1}…".format(config.model_path.name, config.model_sha256[:12])
self.window = MainWindow(self._draft, env_ready, model_summary, config.source_id) self.window = MainWindow(
self._draft, env_ready, model_summary, config.source_id, config_path, camera
)
self.window.monitor.start_requested.connect(self.start) self.window.monitor.start_requested.connect(self.start)
self.window.monitor.stop_requested.connect(self.stop) self.window.monitor.stop_requested.connect(self.stop)
self._worker: Optional[FrameWorker] = None self._worker: Optional[FrameWorker] = None
@@ -137,11 +146,24 @@ def main(config_path: Optional[str] = None) -> int:
pose_adapter = PoseAdapter( pose_adapter = PoseAdapter(
config.model_path, config.model_sha256, config.confidence_threshold config.model_path, config.model_sha256, config.confidence_threshold
) )
camera = _camera_fields(path)
app = QtWidgets.QApplication(sys.argv) app = QtWidgets.QApplication(sys.argv)
controller = ApplicationController(config, pose_adapter) controller = ApplicationController(config, pose_adapter, str(path), camera)
controller.window.show() controller.window.show()
return app.exec_() return app.exec_()
def _camera_fields(path: Path) -> Dict:
"""Pre-fill the settings camera form from a structured local source, if any."""
try:
source = json.loads(Path(path).read_text(encoding="utf-8")).get("source", {})
except (OSError, ValueError):
return {}
if "host" not in source:
return {}
return {k: source.get(k) for k in ("host", "port", "channel", "username", "password")}
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
+32
View File
@@ -114,6 +114,38 @@ def _resolve_path(config_path: Path, value: Any, field_name: str) -> Path:
return (config_path.parent / raw_path).resolve() return (config_path.parent / raw_path).resolve()
def write_local_camera_source(
config_path: Path,
source_id: str,
host: str,
port: int,
channel: str,
username: str,
password: str,
mode: str = "stream",
) -> Path:
"""Persist a structured camera source into an untracked local config file.
Only ever call this on ``config.local.json`` (git-ignored). Credentials are
written in plaintext by design of the chosen policy; the public example must
never receive them.
"""
path = Path(config_path)
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
data["source"] = {
"id": str(source_id),
"host": str(host),
"port": int(port),
"channel": str(channel),
"username": str(username),
"password": str(password),
"mode": mode,
}
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def load_config(path: Path) -> AppConfig: def load_config(path: Path) -> AppConfig:
"""Load one public/local configuration pair without persisting credentials. """Load one public/local configuration pair without persisting credentials.
+123 -2
View File
@@ -208,16 +208,42 @@ class MonitorTab(QtWidgets.QWidget):
self.stop_button.setEnabled(running) self.stop_button.setEnabled(running)
class ConnectionTestWorker(QtCore.QThread):
"""Build the RTSP URL and probe one frame off the GUI thread."""
probed = QtCore.pyqtSignal(bool, str, object)
def __init__(self, host, port, channel, username, password) -> None:
super().__init__()
self._args = (host, port, channel, username, password)
def run(self) -> None:
from v1.camera import build_rtsp_url, probe_stream
host, port, channel, username, password = self._args
try:
url = build_rtsp_url(host, port, username, password, channel)
except Exception as exc: # noqa: BLE001 - report any build error to the UI
self.probed.emit(False, "参数错误:{0}".format(exc), None)
return
result = probe_stream(url, attempts=40)
self.probed.emit(result.ok, result.message, result.frame)
class SettingsTab(QtWidgets.QWidget): class SettingsTab(QtWidgets.QWidget):
"""Edit a non-sensitive draft; changes apply only on the next start.""" """Edit a non-sensitive draft; changes apply only on the next start."""
def __init__( def __init__(
self, draft: SettingsDraft, env_ready: bool, model_summary: str, self, draft: SettingsDraft, env_ready: bool, model_summary: str,
config_path: Optional[str] = None,
camera: Optional[Dict] = None,
parent: Optional[QtWidgets.QWidget] = None, parent: Optional[QtWidgets.QWidget] = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._draft = draft self._draft = draft
self._config_path = config_path
self._spins: Dict[str, QtWidgets.QDoubleSpinBox] = {} self._spins: Dict[str, QtWidgets.QDoubleSpinBox] = {}
self._probe_worker: Optional[ConnectionTestWorker] = None
layout = QtWidgets.QVBoxLayout(self) layout = QtWidgets.QVBoxLayout(self)
source_box = QtWidgets.QGroupBox("来源与模型(只读)") source_box = QtWidgets.QGroupBox("来源与模型(只读)")
@@ -225,6 +251,7 @@ class SettingsTab(QtWidgets.QWidget):
source_form.addRow("RTSP 环境变量", QtWidgets.QLabel("已就绪" if env_ready else "未就绪")) source_form.addRow("RTSP 环境变量", QtWidgets.QLabel("已就绪" if env_ready else "未就绪"))
source_form.addRow("模型", QtWidgets.QLabel(model_summary)) source_form.addRow("模型", QtWidgets.QLabel(model_summary))
layout.addWidget(source_box) layout.addWidget(source_box)
layout.addWidget(self._build_camera_box(camera or {}))
params_box = QtWidgets.QGroupBox("事件参数草稿") params_box = QtWidgets.QGroupBox("事件参数草稿")
form = QtWidgets.QFormLayout(params_box) form = QtWidgets.QFormLayout(params_box)
@@ -272,19 +299,113 @@ class SettingsTab(QtWidgets.QWidget):
spin.blockSignals(False) spin.blockSignals(False)
self.status.setText("已重置为已保存值") self.status.setText("已重置为已保存值")
def _build_camera_box(self, camera: Dict) -> QtWidgets.QGroupBox:
box = QtWidgets.QGroupBox("摄像头连接(保存至本地未跟踪配置)")
outer = QtWidgets.QHBoxLayout(box)
form = QtWidgets.QFormLayout()
self.host_edit = QtWidgets.QLineEdit(str(camera.get("host", "")))
self.port_spin = QtWidgets.QSpinBox()
self.port_spin.setRange(1, 65535)
self.port_spin.setValue(int(camera.get("port", 554)))
self.channel_combo = QtWidgets.QComboBox()
self.channel_combo.addItem("101 主码流", "101")
self.channel_combo.addItem("102 子码流", "102")
self.channel_combo.setCurrentIndex(1 if str(camera.get("channel", "102")) == "102" else 0)
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)
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)
self.test_button = QtWidgets.QPushButton("测试连接")
self.save_camera_button = QtWidgets.QPushButton("保存连接到本地配置")
self.test_button.clicked.connect(self._on_test_connection)
self.save_camera_button.clicked.connect(self._on_save_camera)
self.camera_status = QtWidgets.QLabel("")
buttons = QtWidgets.QHBoxLayout()
buttons.addWidget(self.test_button)
buttons.addWidget(self.save_camera_button)
buttons.addStretch(1)
form.addRow(buttons)
form.addRow(self.camera_status)
self.preview = QtWidgets.QLabel("预览区域")
self.preview.setMinimumSize(320, 180)
self.preview.setAlignment(QtCore.Qt.AlignCenter)
self.preview.setStyleSheet("background:#0B1220;color:#EAF1F8;")
outer.addLayout(form, 1)
outer.addWidget(self.preview, 1)
return box
def _on_test_connection(self) -> None:
if self._probe_worker is not None:
return
self.camera_status.setText("正在测试连接…")
self.test_button.setEnabled(False)
worker = ConnectionTestWorker(
self.host_edit.text().strip(),
self.port_spin.value(),
self.channel_combo.currentData(),
self.user_edit.text().strip(),
self.password_edit.text(),
)
worker.probed.connect(self._on_probe_done)
worker.finished.connect(self._on_probe_finished)
self._probe_worker = worker
worker.start()
def _on_probe_done(self, ok: bool, message: str, frame) -> None:
self.camera_status.setText(message)
if ok and frame is not None:
image = bgr_to_qimage(frame)
pixmap = QtGui.QPixmap.fromImage(image).scaled(
self.preview.size(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation
)
self.preview.setPixmap(pixmap)
def _on_probe_finished(self) -> None:
self._probe_worker = None
self.test_button.setEnabled(True)
def _on_save_camera(self) -> None:
if not self._config_path:
self.camera_status.setText("未指定本地配置路径,无法保存")
return
from v1.config import write_local_camera_source
try:
write_local_camera_source(
self._config_path,
source_id=self.host_edit.text().strip() or "ip-camera",
host=self.host_edit.text().strip(),
port=self.port_spin.value(),
channel=self.channel_combo.currentData(),
username=self.user_edit.text().strip(),
password=self.password_edit.text(),
)
except Exception as exc: # noqa: BLE001 - surface any write error to the UI
self.camera_status.setText("保存失败:{0}".format(exc))
return
self.camera_status.setText("已保存到本地配置,将在下次开始监控时生效")
class MainWindow(QtWidgets.QMainWindow): class MainWindow(QtWidgets.QMainWindow):
"""Top dual-tab window: 实时监控 / 设置.""" """Top dual-tab window: 实时监控 / 设置."""
def __init__( def __init__(
self, draft: SettingsDraft, env_ready: bool, model_summary: str, source_name: str self, draft: SettingsDraft, env_ready: bool, model_summary: str, source_name: str,
config_path: Optional[str] = None, camera: Optional[Dict] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.setWindowTitle("Silver Pose · {0}".format(source_name)) self.setWindowTitle("Silver Pose · {0}".format(source_name))
self.resize(1280, 800) self.resize(1280, 800)
self.setStyleSheet(_QSS) self.setStyleSheet(_QSS)
self.monitor = MonitorTab() self.monitor = MonitorTab()
self.settings = SettingsTab(draft, env_ready, model_summary) self.settings = SettingsTab(draft, env_ready, model_summary, config_path, camera)
tabs = QtWidgets.QTabWidget() tabs = QtWidgets.QTabWidget()
tabs.setTabPosition(QtWidgets.QTabWidget.North) tabs.setTabPosition(QtWidgets.QTabWidget.North)
tabs.addTab(self.monitor, "实时监控") tabs.addTab(self.monitor, "实时监控")
+22
View File
@@ -107,6 +107,28 @@ def test_structured_credentials_are_excluded_from_config_version(tmp_path):
assert first.runtime_config_version == second.runtime_config_version assert first.runtime_config_version == second.runtime_config_version
def test_write_local_camera_source_round_trips_through_load_config(tmp_path):
from v1.config import write_local_camera_source
config_file = tmp_path / "config.local.json"
_write_config(config_file, {"id": "seed", "rtsp_url_env": "SILVER_POSE_RTSP_URL"})
write_local_camera_source(
config_file,
source_id="hik",
host="192.0.2.10",
port=554,
channel="102",
username="admin",
password="p@ss/w:d",
)
config = load_config(config_file)
assert config.source_url == "rtsp://admin:p%40ss%2Fw%3Ad@192.0.2.10:554/Streaming/Channels/102"
assert config.source_mode == "stream"
assert config.source_id == "hik"
def test_public_example_config_has_no_embedded_credentials(): def test_public_example_config_has_no_embedded_credentials():
from pathlib import Path from pathlib import Path