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:
@@ -32,6 +32,7 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一
|
||||
| --- | --- | --- | --- |
|
||||
| 应用入口 | `v1/app.py` | 装配配置、窗口、线程和依赖 | 推理细节、事件判定 |
|
||||
| 配置 | `v1/config.py` | 解析示例和本地配置,校验非敏感字段 | 保存真实凭证 |
|
||||
| 摄像头接入 | `v1/camera.py` | 由 host/port/channel/账号/密码构建 RTSP URL(凭证百分号编码),并对流做一次有界连接测试抓帧 | 事件判定、持久化 |
|
||||
| 视频源 | `v1/video_source.py` | 打开、读取、重连 RTSP 或录像;输出帧、单调回放时间戳和显式来源状态 | Pose、报警 |
|
||||
| Pose 适配器 | `v1/pose.py` | 校验锁定模型的 SHA-256、pose/person/17×3 契约,统一返回 box、关键点、置信度 | 跟踪、摔倒业务结论 |
|
||||
| 跟踪 | `v1/tracking.py` | 以归一化 box 中心距离为连续人员输出稳定 `track_id` | 根据姿态报警 |
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
| T-203 | 接入海康 RTSP 与断流恢复 | T-202 | 现场有效流可预览;断流状态明确、可重连且不报警。 | DOING |
|
||||
| T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | 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 迁移门槛与实现
|
||||
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期: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` 单元测试与假适配器管线冒烟覆盖视图逻辑。
|
||||
- 旧生产基线:`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/` 目录存在但尚无实现。
|
||||
- 非代码设计工件: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` 哈希一致。
|
||||
- 当前标准启动:`./init.ps1`。
|
||||
- 当前标准验证:`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,并在命令非零退出时失败。
|
||||
|
||||
|
||||
@@ -253,3 +253,12 @@
|
||||
- 阻塞:设置页掩码输入、测试连接按钮与预览显示需 Windows + PyQt5 冒烟;配置解析、RTSP URL 构建(凭证百分号编码)与连接测试逻辑在本环境可真实验证。
|
||||
- 决策:配置支持「结构化 host/port/channel/username/password」或原有 `rtsp_url_env` 二选一;密码按 RFC 3986 百分号编码后拼 URL(顺带解决 `@` 问题)。护栏:公开示例 `config.example.json` 继续禁止任何 host/账号/密码/URL 并加测试守卫;`runtime_config_version` 继续排除凭证;凭证绝不进 JSONL、日志、截图名、错误信息。
|
||||
- 下一步:先写 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 预览与断流重连人工验收。
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PyQt5 import QtCore, QtWidgets
|
||||
|
||||
@@ -82,7 +83,13 @@ def _draft_from_config(config: AppConfig) -> SettingsDraft:
|
||||
class ApplicationController:
|
||||
"""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
|
||||
|
||||
self._config = config
|
||||
@@ -90,7 +97,9 @@ class ApplicationController:
|
||||
self._draft = _draft_from_config(config)
|
||||
env_ready = bool(config.source_url)
|
||||
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.stop_requested.connect(self.stop)
|
||||
self._worker: Optional[FrameWorker] = None
|
||||
@@ -137,11 +146,24 @@ def main(config_path: Optional[str] = None) -> int:
|
||||
pose_adapter = PoseAdapter(
|
||||
config.model_path, config.model_sha256, config.confidence_threshold
|
||||
)
|
||||
camera = _camera_fields(path)
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
controller = ApplicationController(config, pose_adapter)
|
||||
controller = ApplicationController(config, pose_adapter, str(path), camera)
|
||||
controller.window.show()
|
||||
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__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -114,6 +114,38 @@ def _resolve_path(config_path: Path, value: Any, field_name: str) -> Path:
|
||||
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:
|
||||
"""Load one public/local configuration pair without persisting credentials.
|
||||
|
||||
|
||||
@@ -208,16 +208,42 @@ class MonitorTab(QtWidgets.QWidget):
|
||||
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):
|
||||
"""Edit a non-sensitive draft; changes apply only on the next start."""
|
||||
|
||||
def __init__(
|
||||
self, draft: SettingsDraft, env_ready: bool, model_summary: str,
|
||||
config_path: Optional[str] = None,
|
||||
camera: Optional[Dict] = None,
|
||||
parent: Optional[QtWidgets.QWidget] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._draft = draft
|
||||
self._config_path = config_path
|
||||
self._spins: Dict[str, QtWidgets.QDoubleSpinBox] = {}
|
||||
self._probe_worker: Optional[ConnectionTestWorker] = None
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
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("模型", QtWidgets.QLabel(model_summary))
|
||||
layout.addWidget(source_box)
|
||||
layout.addWidget(self._build_camera_box(camera or {}))
|
||||
|
||||
params_box = QtWidgets.QGroupBox("事件参数草稿")
|
||||
form = QtWidgets.QFormLayout(params_box)
|
||||
@@ -272,19 +299,113 @@ class SettingsTab(QtWidgets.QWidget):
|
||||
spin.blockSignals(False)
|
||||
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):
|
||||
"""Top dual-tab window: 实时监控 / 设置."""
|
||||
|
||||
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:
|
||||
super().__init__()
|
||||
self.setWindowTitle("Silver Pose · {0}".format(source_name))
|
||||
self.resize(1280, 800)
|
||||
self.setStyleSheet(_QSS)
|
||||
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.setTabPosition(QtWidgets.QTabWidget.North)
|
||||
tabs.addTab(self.monitor, "实时监控")
|
||||
|
||||
@@ -107,6 +107,28 @@ def test_structured_credentials_are_excluded_from_config_version(tmp_path):
|
||||
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():
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
Reference in New Issue
Block a user