feat(v1): add PyQt dual-tab monitor and settings UI
Qt-free view_model builds monitor view state and isolates the settings draft from the running config snapshot; gui.py/app.py are a thin PyQt5 shell that only renders already-decided FrameAnalysis. 39 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,9 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一
|
||||
| 事件管线 | `v1/pipeline.py` | 按一帧顺序装配 Pose、跟踪、质量/证据、领域规则和状态机;对缺帧、低质量和断流输入拒绝证据 | 读取视频、加载模型、GUI、报警副作用 |
|
||||
| 状态机 | `v1/fall_state.py` | 管理每个 ID 的 NORMAL、SUSPECT、CONFIRMED、RECOVERING,并在首次确认时产生带确认延迟和 `config_version` 的事件 | 播放声音或存文件 |
|
||||
| 报警工件 | `v1/alerts.py` | 对确认事件去重、播放声音、保存截图、写日志 | 推理或事件计算 |
|
||||
| PyQt UI | `v1/gui.py` | 渲染帧、骨架、状态、设置和弹窗 | 直接读 RTSP 或写判定规则 |
|
||||
| 视图模型 | `v1/view_model.py` | 由 `FrameAnalysis` 构建 Qt-free 监控视图状态(连接文案、状态语义色、骨架段、人员叠加)与设置草稿(运行/已保存/草稿三份隔离,下次启动生效) | Qt 渲染、事件判定、读 RTSP |
|
||||
| PyQt UI | `v1/gui.py` | 渲染帧、骨架、状态、设置和弹窗,绑定视图模型 | 直接读 RTSP 或写判定规则 |
|
||||
| 应用装配 | `v1/app.py` | 装配配置、Pose、管线线程与窗口;`FrameWorker` 只发出已判定的 `FrameAnalysis` | 事件判定、渲染细节 |
|
||||
| 回归工具 | `v1/tests/` 与 `v1/scripts/` | 回放录像、断言事件和延迟 | 生产 UI |
|
||||
|
||||
## UI 导航与配置生效生命周期
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T-201 | 实现 PyQt 顶部双 Tab 的监控与设置界面 | T-106 | 实时监控 Tab 显示视频、骨架、ID、NORMAL/SUSPECT/CONFIRMED 和连接状态;设置 Tab 只编辑非敏感草稿并明确下次启动生效;UI 不直接执行事件计算。 | DOING |
|
||||
| T-201 | 实现 PyQt 顶部双 Tab 的监控与设置界面 | T-106 | 实时监控 Tab 显示视频、骨架、ID、NORMAL/SUSPECT/CONFIRMED 和连接状态;设置 Tab 只编辑非敏感草稿并明确下次启动生效;UI 不直接执行事件计算。 | DONE |
|
||||
| T-202 | 实现本地报警、弹窗、截图和 JSONL 日志 | T-201 | CONFIRMED 产生一次声音、一次弹窗、一张带标注截图和一条事件日志。 | TODO |
|
||||
| T-203 | 接入海康 RTSP 与断流恢复 | T-202 | 现场有效流可预览;断流状态明确、可重连且不报警。 | TODO |
|
||||
| T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | TODO |
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-21
|
||||
- 阶段:V1 事件管线与事件契约已建立;T-106 已验收,等待 T-201。
|
||||
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。
|
||||
- 阶段:V1 事件管线与 PyQt 双 Tab 界面外壳已建立;T-201 已验收,等待 T-202。
|
||||
- 已验证环境: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`,缺帧/低质量/断流会中断证据确认。PyQt GUI、声音、弹窗、截图、JSONL 和真实 RTSP 接入尚未实现。
|
||||
- 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`,窗口只渲染)。声音、弹窗、截图、JSONL 和真实 RTSP 接入尚未实现。
|
||||
- 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 demo` 已通过;`python -m pytest v1/tests -v` 当前有 30 项配置、视频源、Pose、跟踪、证据、领域规则、状态机和管线测试并已通过。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件,这只验证管线可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
||||
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py` 语法);`python -m pytest v1/tests -v` 当前有 39 项配置、视频源、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 人且不显示摔倒标签。这些只验证管线与视图可运行,不表示摔倒识别准确率。`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:尚无带标签的正反例回归录像及事件标签;真实海康 RTSP 流尚未接入,实时流单调计时策略尚待 T-203 在现场验证;PyQt 与本地报警闭环尚未实现。
|
||||
- 当前 blocker:PyQt 界面外壳已实现但可视化冒烟需在 Windows 执行;本地报警/弹窗/截图/JSONL 闭环(T-202)尚未实现;尚无带标签的正反例回归录像及事件标签;真实海康 RTSP 流尚未接入,实时流单调计时策略尚待 T-203 在现场验证。
|
||||
|
||||
全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
|
||||
## 任务状态
|
||||
|
||||
- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)、T-104(跟踪与姿态质量证据)、T-105(按 ID 时序状态机)。
|
||||
- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)、T-104(跟踪与姿态质量证据)、T-105(按 ID 时序状态机)、T-106(事件管线与事件契约)、T-201(顶部双 Tab 的 PyQt 监控与设置界面)。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取:T-201(顶部双 Tab 的 PyQt 监控与设置界面)。
|
||||
- 下一个可领取:T-202(本地报警、弹窗、截图和 JSONL 日志)。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
@@ -188,3 +188,12 @@
|
||||
- 阻塞:无任务内 blocker;PyQt5 渲染冒烟受环境限制,见决策。
|
||||
- 决策:把可验收的 UI 逻辑(设置草稿与运行配置快照隔离、状态→语义色映射、骨架与人员叠加数据推导、连接状态文案)抽到不依赖 Qt 的 `v1/view_model.py`,在本环境用 pytest 真实覆盖;`v1/gui.py` 与 `v1/app.py` 仅作薄 Qt 外壳与装配,只消费 T-106 管线输出,不执行事件判定。GUI 的可视化冒烟标注为需 Windows + PyQt5 执行。
|
||||
- 下一步:先写 view-model 的失败测试(草稿隔离、状态色、骨架推导、连接文案),再实现最小 UI 外壳。
|
||||
|
||||
## 【2026-07-21】T-201 实现 PyQt 顶部双 Tab 的监控与设置界面(完成)
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:新增 `v1/view_model.py`(Qt-free 可测核心):由 `FrameAnalysis` 构建监控视图状态(连接文案、状态→语义色、COCO-17 骨架段、人员 box/ID/状态标签,红色只用于 CONFIRMED,非连接帧不产生人员或摔倒标签),以及 `SettingsDraft`(运行快照/已保存/草稿三份隔离,编辑只改草稿、保存只标下次启动生效、`start_monitoring` 才提升为新运行配置并变更配置版本)。新增 `v1/gui.py` 薄 PyQt5 外壳(顶部双 Tab、`QTabWidget`、`VideoView` 用 `QPainter` 叠加、设置 `QDoubleSpinBox` 绑定草稿、浅色 Windows 主题 QSS)与 `v1/app.py` 装配(`FrameWorker(QThread)` 持有视频源与 `FallPipeline` 并只发出已判定的 `FrameAnalysis`,窗口只渲染;开始监控时按草稿生成不可变运行配置快照)。
|
||||
- 验证:新增 `v1/tests/test_view_model.py` 9 项,覆盖状态色(红仅 CONFIRMED)、断流不产生摔倒标签、低置信关键点被剔除、事件徽标、草稿隔离/保存/下次启动提升、越界拒绝与配置版本敏感性;`python3 -m pytest v1/tests -q` 为 39 passed;`python3 -m compileall -q v1 demo` 退出码 0(含 `gui.py`、`app.py` 语法)。真实链路冒烟:以 `demo/1.mp4` 真实解码 + 真实 `FallPipeline/PersonTracker/FallEvidencePolicy/FallStateMachine` + 确定性假 Pose 适配器驱动 `build_monitor_view`,连续帧得到稳定 `P-0001`、box、14 段骨架、17/17 关键点、`NORMAL/success`;注入 RETRYING 帧得到“正在重连…”、0 人、offline 色。
|
||||
- 阻塞:无任务内 blocker。当前 WSL/Linux 环境未安装 PyQt5,且本机 ultralytics/torch 版本与 Windows 基线(8.3.205)不兼容导致模型前向不可用;因此 `gui.py`/`app.py` 的可视化冒烟与真实模型端到端渲染必须在 Windows + PyQt5 上执行,不能以本轮 CI 结果冒充可视化验收。
|
||||
- 决策:把全部可验收 UI 逻辑抽到 Qt-free 的 `view_model.py` 并在本环境真实测试,Qt 渲染层保持最薄且不含事件判定;设置草稿采用“运行快照/已保存/草稿”三份隔离,运行配置版本只在开始监控时变更,使事件与截图可追溯 `config_version`。
|
||||
- 下一步:T-202,实现 CONFIRMED 的一次性声音、弹窗、带标注截图与 JSONL 事件日志。
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Assemble configuration, the fall pipeline worker, and the PyQt window.
|
||||
|
||||
The GUI never computes events. A background worker owns the video source and the
|
||||
``FallPipeline`` and emits already-decided ``FrameAnalysis`` objects; the window
|
||||
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 os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5 import QtCore, QtWidgets
|
||||
|
||||
from v1.config import AppConfig, EventConfig, load_config
|
||||
from v1.pipeline import FallPipeline
|
||||
from v1.pose import PoseAdapter
|
||||
from v1.video_source import SourceMode, SourceStatus, VideoSource
|
||||
from v1.view_model import SettingsDraft, build_monitor_view
|
||||
|
||||
|
||||
class FrameWorker(QtCore.QThread):
|
||||
"""Read one source, run the pipeline, and emit render-only analyses."""
|
||||
|
||||
frame_ready = QtCore.pyqtSignal(object)
|
||||
|
||||
def __init__(self, config: AppConfig, pose_adapter: PoseAdapter) -> None:
|
||||
super().__init__()
|
||||
self._config = config
|
||||
self._pose_adapter = pose_adapter
|
||||
self._stop = False
|
||||
|
||||
def run(self) -> None:
|
||||
mode = SourceMode.STREAM if self._config.source_url.startswith("rtsp") else SourceMode.REPLAY
|
||||
source = VideoSource(self._config.source_url, mode=mode)
|
||||
pipeline = FallPipeline.from_config(self._config, self._pose_adapter)
|
||||
try:
|
||||
while not self._stop:
|
||||
packet = source.read()
|
||||
analysis = pipeline.process(packet)
|
||||
self.frame_ready.emit(analysis)
|
||||
if packet.status in (SourceStatus.CLOSED, SourceStatus.EOF):
|
||||
break
|
||||
self.msleep(5)
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop = True
|
||||
|
||||
|
||||
def _running_config(config: AppConfig, draft: SettingsDraft) -> AppConfig:
|
||||
values = draft.running_values
|
||||
event = EventConfig(
|
||||
keypoint_confidence_threshold=values["keypoint_confidence_threshold"],
|
||||
suspect_window_seconds=values["suspect_window_seconds"],
|
||||
confirm_window_seconds=values["confirm_window_seconds"],
|
||||
recovery_window_seconds=values["recovery_window_seconds"],
|
||||
cooldown_seconds=values["cooldown_seconds"],
|
||||
)
|
||||
return replace(
|
||||
config, event=event, confidence_threshold=values["model_confidence_threshold"]
|
||||
)
|
||||
|
||||
|
||||
def _draft_from_config(config: AppConfig) -> SettingsDraft:
|
||||
return SettingsDraft(
|
||||
{
|
||||
"keypoint_confidence_threshold": config.event.keypoint_confidence_threshold,
|
||||
"suspect_window_seconds": config.event.suspect_window_seconds,
|
||||
"confirm_window_seconds": config.event.confirm_window_seconds,
|
||||
"recovery_window_seconds": config.event.recovery_window_seconds,
|
||||
"cooldown_seconds": config.event.cooldown_seconds,
|
||||
"model_confidence_threshold": config.confidence_threshold,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ApplicationController:
|
||||
"""Own the window, draft and worker lifecycle without doing event logic."""
|
||||
|
||||
def __init__(self, config: AppConfig, pose_adapter: PoseAdapter) -> None:
|
||||
from v1.gui import MainWindow
|
||||
|
||||
self._config = config
|
||||
self._pose_adapter = pose_adapter
|
||||
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.monitor.start_requested.connect(self.start)
|
||||
self.window.monitor.stop_requested.connect(self.stop)
|
||||
self._worker: Optional[FrameWorker] = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._worker is not None:
|
||||
return
|
||||
self._draft.start_monitoring()
|
||||
running = _running_config(self._config, self._draft)
|
||||
worker = FrameWorker(running, self._pose_adapter)
|
||||
worker.frame_ready.connect(self._on_frame)
|
||||
worker.finished.connect(self._on_finished)
|
||||
self._worker = worker
|
||||
self.window.monitor.set_running(True)
|
||||
worker.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._worker is not None:
|
||||
self._worker.stop()
|
||||
|
||||
def _on_frame(self, analysis) -> None:
|
||||
view = build_monitor_view(
|
||||
analysis, self._draft.running_values["keypoint_confidence_threshold"]
|
||||
)
|
||||
self.window.monitor.render_view(analysis.packet.image, view)
|
||||
|
||||
def _on_finished(self) -> None:
|
||||
self._worker = None
|
||||
self.window.monitor.set_running(False)
|
||||
|
||||
|
||||
def main(config_path: Optional[str] = None) -> int:
|
||||
path = Path(config_path or os.environ.get("SILVER_POSE_CONFIG", "v1/config.local.json"))
|
||||
config = load_config(path)
|
||||
pose_adapter = PoseAdapter(
|
||||
config.model_path, config.model_sha256, config.confidence_threshold
|
||||
)
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
controller = ApplicationController(config, pose_adapter)
|
||||
controller.window.show()
|
||||
return app.exec_()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Thin PyQt5 rendering shell for the V1 monitor and settings tabs.
|
||||
|
||||
This module only renders ``MonitorViewState`` and binds ``SettingsDraft``; it
|
||||
never runs Pose, tracking or fall decisions. All event facts arrive from the
|
||||
``FallPipeline`` worker in ``v1/app.py``. Because acceptance-critical logic lives
|
||||
in ``v1/view_model.py`` (Qt-free and unit-tested), this file is the only surface
|
||||
that requires a Windows + PyQt5 + display smoke test.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from v1.view_model import (
|
||||
FIELD_BOUNDS,
|
||||
DraftValidationError,
|
||||
MonitorViewState,
|
||||
SettingsDraft,
|
||||
StatusColor,
|
||||
)
|
||||
|
||||
|
||||
COLOR_HEX: Dict[StatusColor, str] = {
|
||||
StatusColor.SUCCESS: "#15803D",
|
||||
StatusColor.CAUTION: "#B45309",
|
||||
StatusColor.CRITICAL: "#C62828",
|
||||
StatusColor.OFFLINE: "#64748B",
|
||||
}
|
||||
|
||||
# Light Windows theme tokens (see docs/ui/silver-pose-ui-ux-spec.md).
|
||||
_QSS = """
|
||||
QWidget { background: #EAF1F8; color: #1E293B; font-family: 'Segoe UI Variable','Microsoft YaHei UI',sans-serif; }
|
||||
QTabWidget::pane { background: #FFFFFF; border: 1px solid #C8D4E3; }
|
||||
QTabBar::tab { padding: 8px 20px; background: #FFFFFF; color: #52657C; }
|
||||
QTabBar::tab:selected { color: #1E293B; border-bottom: 2px solid #2563EB; }
|
||||
QGroupBox { background: #FFFFFF; border: 1px solid #C8D4E3; border-radius: 8px; margin-top: 12px; }
|
||||
QPushButton { background: #FFFFFF; border: 1px solid #C8D4E3; border-radius: 8px; padding: 8px 14px; min-height: 40px; }
|
||||
QPushButton:focus { border: 2px solid #2563EB; }
|
||||
QLabel[role="primary-action"] { }
|
||||
"""
|
||||
|
||||
_FIELD_LABELS = {
|
||||
"keypoint_confidence_threshold": "关键点置信度阈值",
|
||||
"suspect_window_seconds": "快速下移到水平的最大间隔(秒)",
|
||||
"confirm_window_seconds": "确认窗口(秒,1–3)",
|
||||
"recovery_window_seconds": "恢复稳定窗口(秒)",
|
||||
"cooldown_seconds": "确认后恢复判断冷却(秒)",
|
||||
"model_confidence_threshold": "模型检测置信度阈值",
|
||||
}
|
||||
|
||||
_FIELD_STEP = {
|
||||
"keypoint_confidence_threshold": 0.05,
|
||||
"suspect_window_seconds": 0.1,
|
||||
"confirm_window_seconds": 0.1,
|
||||
"recovery_window_seconds": 0.5,
|
||||
"cooldown_seconds": 1.0,
|
||||
"model_confidence_threshold": 0.05,
|
||||
}
|
||||
|
||||
|
||||
def bgr_to_qimage(image: np.ndarray) -> QtGui.QImage:
|
||||
"""Convert an OpenCV BGR frame to a QImage owning a contiguous copy."""
|
||||
|
||||
rgb = np.ascontiguousarray(image[:, :, ::-1])
|
||||
height, width, _ = rgb.shape
|
||||
return QtGui.QImage(
|
||||
rgb.data, width, height, 3 * width, QtGui.QImage.Format_RGB888
|
||||
).copy()
|
||||
|
||||
|
||||
class VideoView(QtWidgets.QWidget):
|
||||
"""Paint the latest frame plus box/skeleton/id/state overlays."""
|
||||
|
||||
def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setMinimumSize(480, 270)
|
||||
self._image: Optional[QtGui.QImage] = None
|
||||
self._view: Optional[MonitorViewState] = None
|
||||
|
||||
def update_frame(self, image: Optional[np.ndarray], view: MonitorViewState) -> None:
|
||||
self._image = bgr_to_qimage(image) if image is not None else None
|
||||
self._view = view
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event: QtGui.QPaintEvent) -> None:
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.fillRect(self.rect(), QtGui.QColor("#0B1220"))
|
||||
if self._image is None:
|
||||
painter.setPen(QtGui.QColor("#EAF1F8"))
|
||||
text = self._view.status_text if self._view else "无画面"
|
||||
painter.drawText(self.rect(), QtCore.Qt.AlignCenter, text)
|
||||
painter.end()
|
||||
return
|
||||
target = self._fitted_rect(self._image)
|
||||
painter.drawImage(target, self._image)
|
||||
if self._view is not None:
|
||||
self._draw_overlays(painter, target, self._image)
|
||||
painter.end()
|
||||
|
||||
def _fitted_rect(self, image: QtGui.QImage) -> QtCore.QRect:
|
||||
widget = self.rect()
|
||||
scale = min(widget.width() / image.width(), widget.height() / image.height())
|
||||
width = int(image.width() * scale)
|
||||
height = int(image.height() * scale)
|
||||
left = widget.left() + (widget.width() - width) // 2
|
||||
top = widget.top() + (widget.height() - height) // 2
|
||||
return QtCore.QRect(left, top, width, height)
|
||||
|
||||
def _draw_overlays(
|
||||
self, painter: QtGui.QPainter, target: QtCore.QRect, image: QtGui.QImage
|
||||
) -> None:
|
||||
scale_x = target.width() / image.width()
|
||||
scale_y = target.height() / image.height()
|
||||
|
||||
def to_widget(x: float, y: float) -> QtCore.QPointF:
|
||||
return QtCore.QPointF(target.left() + x * scale_x, target.top() + y * scale_y)
|
||||
|
||||
for overlay in self._view.people:
|
||||
color = QtGui.QColor(COLOR_HEX[overlay.color])
|
||||
painter.setPen(QtGui.QPen(color, 3))
|
||||
left, top, right, bottom = overlay.box_xyxy
|
||||
painter.drawRect(
|
||||
QtCore.QRectF(to_widget(left, top), to_widget(right, bottom))
|
||||
)
|
||||
painter.setPen(QtGui.QPen(color, 2))
|
||||
for start, end in overlay.skeleton_segments:
|
||||
painter.drawLine(to_widget(start.x, start.y), to_widget(end.x, end.y))
|
||||
for point in overlay.keypoints:
|
||||
if point is not None:
|
||||
painter.drawEllipse(to_widget(point.x, point.y), 2.5, 2.5)
|
||||
painter.setPen(QtGui.QColor("#FFFFFF"))
|
||||
painter.drawText(to_widget(left, top - 6), overlay.label)
|
||||
|
||||
|
||||
class MonitorTab(QtWidgets.QWidget):
|
||||
"""Live view: video, connection status, recent events, start/stop."""
|
||||
|
||||
start_requested = QtCore.pyqtSignal()
|
||||
stop_requested = QtCore.pyqtSignal()
|
||||
|
||||
def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
self._status = QtWidgets.QLabel("未开始")
|
||||
self._status.setMinimumHeight(24)
|
||||
self.video = VideoView()
|
||||
self.events = QtWidgets.QListWidget()
|
||||
self.events.setMaximumHeight(140)
|
||||
self.start_button = QtWidgets.QPushButton("开始监控")
|
||||
self.stop_button = QtWidgets.QPushButton("停止监控")
|
||||
self.stop_button.setEnabled(False)
|
||||
self.start_button.clicked.connect(self.start_requested.emit)
|
||||
self.stop_button.clicked.connect(self.stop_requested.emit)
|
||||
|
||||
controls = QtWidgets.QHBoxLayout()
|
||||
controls.addWidget(self.start_button)
|
||||
controls.addWidget(self.stop_button)
|
||||
controls.addStretch(1)
|
||||
layout.addWidget(self._status)
|
||||
layout.addWidget(self.video, 1)
|
||||
layout.addWidget(QtWidgets.QLabel("最近事件"))
|
||||
layout.addWidget(self.events)
|
||||
layout.addLayout(controls)
|
||||
|
||||
def render_view(self, image, view: MonitorViewState) -> None:
|
||||
self.video.update_frame(image, view)
|
||||
color = COLOR_HEX[view.status_color]
|
||||
self._status.setText("连接状态:{0}".format(view.status_text))
|
||||
self._status.setStyleSheet("color: {0}; font-weight: 600;".format(color))
|
||||
for event in view.events:
|
||||
self.events.insertItem(
|
||||
0,
|
||||
"{0} · {1} · 延迟 {2:.2f}s".format(
|
||||
event.event_id, event.track_id, event.latency_seconds
|
||||
),
|
||||
)
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
self.start_button.setEnabled(not running)
|
||||
self.stop_button.setEnabled(running)
|
||||
|
||||
|
||||
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,
|
||||
parent: Optional[QtWidgets.QWidget] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._draft = draft
|
||||
self._spins: Dict[str, QtWidgets.QDoubleSpinBox] = {}
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
source_box = QtWidgets.QGroupBox("来源与模型(只读)")
|
||||
source_form = QtWidgets.QFormLayout(source_box)
|
||||
source_form.addRow("RTSP 环境变量", QtWidgets.QLabel("已就绪" if env_ready else "未就绪"))
|
||||
source_form.addRow("模型", QtWidgets.QLabel(model_summary))
|
||||
layout.addWidget(source_box)
|
||||
|
||||
params_box = QtWidgets.QGroupBox("事件参数草稿")
|
||||
form = QtWidgets.QFormLayout(params_box)
|
||||
values = draft.draft_values
|
||||
for key, (low, high) in FIELD_BOUNDS.items():
|
||||
spin = QtWidgets.QDoubleSpinBox()
|
||||
spin.setRange(low, high)
|
||||
spin.setSingleStep(_FIELD_STEP[key])
|
||||
spin.setDecimals(2)
|
||||
spin.setValue(values[key])
|
||||
spin.valueChanged.connect(lambda value, name=key: self._on_edit(name, value))
|
||||
self._spins[key] = spin
|
||||
form.addRow(_FIELD_LABELS[key], spin)
|
||||
layout.addWidget(params_box)
|
||||
|
||||
self.status = QtWidgets.QLabel("")
|
||||
self.save_button = QtWidgets.QPushButton("保存(下次启动生效)")
|
||||
self.reset_button = QtWidgets.QPushButton("重置")
|
||||
self.save_button.clicked.connect(self._on_save)
|
||||
self.reset_button.clicked.connect(self._on_reset)
|
||||
actions = QtWidgets.QHBoxLayout()
|
||||
actions.addWidget(self.save_button)
|
||||
actions.addWidget(self.reset_button)
|
||||
actions.addStretch(1)
|
||||
layout.addWidget(self.status)
|
||||
layout.addLayout(actions)
|
||||
layout.addStretch(1)
|
||||
|
||||
def _on_edit(self, key: str, value: float) -> None:
|
||||
try:
|
||||
self._draft.edit(key, value)
|
||||
except DraftValidationError as exc:
|
||||
self.status.setText("校验失败:{0}".format(exc))
|
||||
return
|
||||
self.status.setText("已修改,未保存" if self._draft.is_dirty else "")
|
||||
|
||||
def _on_save(self) -> None:
|
||||
self.status.setText(self._draft.save())
|
||||
|
||||
def _on_reset(self) -> None:
|
||||
self._draft.discard()
|
||||
for key, spin in self._spins.items():
|
||||
spin.blockSignals(True)
|
||||
spin.setValue(self._draft.draft_values[key])
|
||||
spin.blockSignals(False)
|
||||
self.status.setText("已重置为已保存值")
|
||||
|
||||
|
||||
class MainWindow(QtWidgets.QMainWindow):
|
||||
"""Top dual-tab window: 实时监控 / 设置."""
|
||||
|
||||
def __init__(
|
||||
self, draft: SettingsDraft, env_ready: bool, model_summary: str, source_name: str
|
||||
) -> 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)
|
||||
tabs = QtWidgets.QTabWidget()
|
||||
tabs.setTabPosition(QtWidgets.QTabWidget.North)
|
||||
tabs.addTab(self.monitor, "实时监控")
|
||||
tabs.addTab(self.settings, "设置")
|
||||
self.setCentralWidget(tabs)
|
||||
@@ -0,0 +1,148 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from v1.fall_state import FallState
|
||||
from v1.video_source import SourceStatus
|
||||
from v1.view_model import (
|
||||
DraftValidationError,
|
||||
SettingsDraft,
|
||||
StatusColor,
|
||||
build_monitor_view,
|
||||
config_version,
|
||||
)
|
||||
|
||||
|
||||
def _keypoint(x, y, confidence=0.9):
|
||||
return SimpleNamespace(x=float(x), y=float(y), confidence=float(confidence))
|
||||
|
||||
|
||||
def _person(track_id, state, keypoints=None, box=(10.0, 10.0, 40.0, 120.0)):
|
||||
points = keypoints or [_keypoint(index, index) for index in range(17)]
|
||||
pose = SimpleNamespace(box_xyxy=box, box_confidence=0.9, keypoints=tuple(points))
|
||||
tracked = SimpleNamespace(track_id=track_id, detected_at_monotonic=1.0, pose=pose)
|
||||
return SimpleNamespace(tracked_pose=tracked, pose_evidence=None, state=state)
|
||||
|
||||
|
||||
def _analysis(status=SourceStatus.CONNECTED, people=(), events=(), has_image=True):
|
||||
image = object() if has_image else None
|
||||
packet = SimpleNamespace(image=image, timestamp_monotonic=1.0, status=status, error=None)
|
||||
return SimpleNamespace(packet=packet, people=tuple(people), events=tuple(events))
|
||||
|
||||
|
||||
def test_state_maps_to_semantic_color_and_red_is_confirmed_only():
|
||||
view = build_monitor_view(
|
||||
_analysis(
|
||||
people=[
|
||||
_person("P-0001", FallState.NORMAL),
|
||||
_person("P-0002", FallState.SUSPECT),
|
||||
_person("P-0003", FallState.CONFIRMED),
|
||||
]
|
||||
)
|
||||
)
|
||||
colors = {overlay.track_id: overlay.color for overlay in view.people}
|
||||
assert colors["P-0001"] is StatusColor.SUCCESS
|
||||
assert colors["P-0002"] is StatusColor.CAUTION
|
||||
assert colors["P-0003"] is StatusColor.CRITICAL
|
||||
critical = [o for o in view.people if o.color is StatusColor.CRITICAL]
|
||||
assert [o.state for o in critical] == [FallState.CONFIRMED]
|
||||
assert view.highest_state is FallState.CONFIRMED
|
||||
|
||||
|
||||
def test_disconnected_frame_shows_reconnect_text_and_no_fall():
|
||||
view = build_monitor_view(_analysis(status=SourceStatus.RETRYING, people=(), has_image=False))
|
||||
assert view.connected is False
|
||||
assert view.status_text == "正在重连…"
|
||||
assert view.status_color is StatusColor.OFFLINE
|
||||
assert view.people == ()
|
||||
assert view.highest_state is FallState.NORMAL
|
||||
|
||||
|
||||
def test_skeleton_overlay_drops_low_confidence_keypoints_and_labels_person():
|
||||
points = [_keypoint(index, index, 0.9) for index in range(17)]
|
||||
points[9] = _keypoint(9, 9, 0.1) # left wrist below threshold
|
||||
view = build_monitor_view(
|
||||
_analysis(people=[_person("P-0007", FallState.SUSPECT, keypoints=points)]),
|
||||
keypoint_min_confidence=0.4,
|
||||
)
|
||||
overlay = view.people[0]
|
||||
assert overlay.keypoints[9] is None
|
||||
assert overlay.keypoints[7] is not None
|
||||
# Edges touching keypoint 9 (5-7-9 arm) must not draw the 7->9 segment.
|
||||
assert all(
|
||||
not (a is overlay.keypoints[7] and b is None) for a, b in overlay.skeleton_segments
|
||||
)
|
||||
assert overlay.label == "P-0007 · SUSPECT"
|
||||
assert overlay.box_xyxy == (10.0, 10.0, 40.0, 120.0)
|
||||
|
||||
|
||||
def test_confirmed_event_becomes_a_badge():
|
||||
event = SimpleNamespace(event_id="FALL-000001", track_id="P-0003", latency_seconds=1.8)
|
||||
view = build_monitor_view(_analysis(events=[event]))
|
||||
assert len(view.events) == 1
|
||||
assert view.events[0].event_id == "FALL-000001"
|
||||
assert view.events[0].latency_seconds == 1.8
|
||||
|
||||
|
||||
def _initial():
|
||||
return {
|
||||
"keypoint_confidence_threshold": 0.4,
|
||||
"suspect_window_seconds": 0.5,
|
||||
"confirm_window_seconds": 1.8,
|
||||
"recovery_window_seconds": 2.0,
|
||||
"cooldown_seconds": 10.0,
|
||||
"model_confidence_threshold": 0.25,
|
||||
}
|
||||
|
||||
|
||||
def test_editing_draft_does_not_change_running_or_saved_config():
|
||||
draft = SettingsDraft(_initial())
|
||||
draft.edit("confirm_window_seconds", 2.5)
|
||||
assert draft.is_dirty is True
|
||||
assert draft.draft_values["confirm_window_seconds"] == 2.5
|
||||
assert draft.saved_values["confirm_window_seconds"] == 1.8
|
||||
assert draft.running_values["confirm_window_seconds"] == 1.8
|
||||
assert draft.has_pending_for_next_start is False
|
||||
|
||||
|
||||
def test_save_marks_pending_but_running_only_changes_on_start():
|
||||
draft = SettingsDraft(_initial())
|
||||
before_version = draft.running_version
|
||||
draft.edit("confirm_window_seconds", 2.5)
|
||||
message = draft.save()
|
||||
assert "下次开始监控" in message
|
||||
assert draft.is_dirty is False
|
||||
assert draft.has_pending_for_next_start is True
|
||||
assert draft.running_values["confirm_window_seconds"] == 1.8
|
||||
assert draft.running_version == before_version
|
||||
|
||||
new_version = draft.start_monitoring()
|
||||
assert draft.running_values["confirm_window_seconds"] == 2.5
|
||||
assert draft.has_pending_for_next_start is False
|
||||
assert new_version != before_version
|
||||
|
||||
|
||||
def test_discard_reverts_draft_to_saved():
|
||||
draft = SettingsDraft(_initial())
|
||||
draft.edit("cooldown_seconds", 20.0)
|
||||
draft.discard()
|
||||
assert draft.is_dirty is False
|
||||
assert draft.draft_values["cooldown_seconds"] == 10.0
|
||||
|
||||
|
||||
def test_out_of_range_draft_value_is_rejected():
|
||||
draft = SettingsDraft(_initial())
|
||||
with pytest.raises(DraftValidationError):
|
||||
draft.edit("confirm_window_seconds", 5.0)
|
||||
with pytest.raises(DraftValidationError):
|
||||
draft.edit("keypoint_confidence_threshold", 1.5)
|
||||
with pytest.raises(DraftValidationError):
|
||||
draft.edit("unknown_field", 1.0)
|
||||
|
||||
|
||||
def test_config_version_is_stable_and_sensitive_to_values():
|
||||
values = _initial()
|
||||
assert config_version(values) == config_version(dict(values))
|
||||
changed = dict(values)
|
||||
changed["cooldown_seconds"] = 12.0
|
||||
assert config_version(values) != config_version(changed)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Pure-Python view state for the V1 monitor/settings UI (no Qt dependency).
|
||||
|
||||
The GUI layer only renders these structures; it never runs Pose, tracking or
|
||||
event decisions. All event facts arrive already computed from ``FallPipeline``.
|
||||
This keeps the acceptance-critical UI logic testable without PyQt5 or a display.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional, Sequence, Tuple
|
||||
|
||||
from v1.fall_state import FallState
|
||||
from v1.video_source import SourceStatus
|
||||
|
||||
|
||||
# COCO-17 skeleton edges (keypoint index pairs) used to draw the person overlay.
|
||||
SKELETON_EDGES: Tuple[Tuple[int, int], ...] = (
|
||||
(5, 7),
|
||||
(7, 9),
|
||||
(6, 8),
|
||||
(8, 10),
|
||||
(5, 6),
|
||||
(5, 11),
|
||||
(6, 12),
|
||||
(11, 12),
|
||||
(11, 13),
|
||||
(13, 15),
|
||||
(12, 14),
|
||||
(14, 16),
|
||||
(0, 5),
|
||||
(0, 6),
|
||||
)
|
||||
|
||||
|
||||
class StatusColor(str, Enum):
|
||||
"""Semantic color tokens from the UI spec. Red (CRITICAL) is CONFIRMED-only."""
|
||||
|
||||
SUCCESS = "success" # NORMAL / online -> #15803D
|
||||
CAUTION = "caution" # SUSPECT / notice -> #B45309
|
||||
CRITICAL = "critical" # CONFIRMED fall -> #C62828
|
||||
OFFLINE = "offline" # disconnected -> #64748B
|
||||
|
||||
|
||||
STATE_COLOR: Dict[FallState, StatusColor] = {
|
||||
FallState.NORMAL: StatusColor.SUCCESS,
|
||||
FallState.SUSPECT: StatusColor.CAUTION,
|
||||
FallState.CONFIRMED: StatusColor.CRITICAL,
|
||||
FallState.RECOVERING: StatusColor.CAUTION,
|
||||
}
|
||||
|
||||
_STATE_SEVERITY: Dict[FallState, int] = {
|
||||
FallState.NORMAL: 0,
|
||||
FallState.RECOVERING: 1,
|
||||
FallState.SUSPECT: 2,
|
||||
FallState.CONFIRMED: 3,
|
||||
}
|
||||
|
||||
CONNECTION_TEXT: Dict[SourceStatus, str] = {
|
||||
SourceStatus.CONNECTED: "在线",
|
||||
SourceStatus.RETRYING: "正在重连…",
|
||||
SourceStatus.ERROR: "连接错误",
|
||||
SourceStatus.EOF: "录像结束",
|
||||
SourceStatus.CLOSED: "已停止",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Point:
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersonOverlay:
|
||||
track_id: str
|
||||
state: FallState
|
||||
color: StatusColor
|
||||
box_xyxy: Tuple[float, float, float, float]
|
||||
keypoints: Tuple[Optional[Point], ...]
|
||||
skeleton_segments: Tuple[Tuple[Point, Point], ...]
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventBadge:
|
||||
event_id: str
|
||||
track_id: str
|
||||
latency_seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonitorViewState:
|
||||
connected: bool
|
||||
status_text: str
|
||||
status_color: StatusColor
|
||||
has_frame: bool
|
||||
people: Tuple[PersonOverlay, ...]
|
||||
events: Tuple[EventBadge, ...]
|
||||
highest_state: FallState
|
||||
|
||||
|
||||
def build_monitor_view(analysis, keypoint_min_confidence: float = 0.4) -> MonitorViewState:
|
||||
"""Turn one ``FrameAnalysis`` into render-only instructions.
|
||||
|
||||
Non-connected frames never carry people or a fall label, matching the rule
|
||||
that connection problems must not surface as fall alarms.
|
||||
"""
|
||||
|
||||
if not 0.0 <= keypoint_min_confidence <= 1.0:
|
||||
raise ValueError("keypoint_min_confidence must be between 0 and 1")
|
||||
packet = analysis.packet
|
||||
status = packet.status
|
||||
connected = status is SourceStatus.CONNECTED
|
||||
overlays = tuple(
|
||||
_person_overlay(person, keypoint_min_confidence) for person in analysis.people
|
||||
)
|
||||
events = tuple(
|
||||
EventBadge(event.event_id, event.track_id, event.latency_seconds)
|
||||
for event in analysis.events
|
||||
)
|
||||
return MonitorViewState(
|
||||
connected=connected,
|
||||
status_text=CONNECTION_TEXT.get(status, str(getattr(status, "value", status))),
|
||||
status_color=StatusColor.SUCCESS if connected else StatusColor.OFFLINE,
|
||||
has_frame=packet.image is not None,
|
||||
people=overlays,
|
||||
events=events,
|
||||
highest_state=_highest_state(overlays),
|
||||
)
|
||||
|
||||
|
||||
def _person_overlay(person, min_confidence: float) -> PersonOverlay:
|
||||
pose = person.tracked_pose.pose
|
||||
state = person.state
|
||||
points = tuple(
|
||||
Point(keypoint.x, keypoint.y) if keypoint.confidence >= min_confidence else None
|
||||
for keypoint in pose.keypoints
|
||||
)
|
||||
segments = tuple(
|
||||
(points[start], points[end])
|
||||
for start, end in SKELETON_EDGES
|
||||
if points[start] is not None and points[end] is not None
|
||||
)
|
||||
return PersonOverlay(
|
||||
track_id=person.tracked_pose.track_id,
|
||||
state=state,
|
||||
color=STATE_COLOR[state],
|
||||
box_xyxy=pose.box_xyxy,
|
||||
keypoints=points,
|
||||
skeleton_segments=segments,
|
||||
label="{0} · {1}".format(person.tracked_pose.track_id, state.value),
|
||||
)
|
||||
|
||||
|
||||
def _highest_state(overlays: Sequence[PersonOverlay]) -> FallState:
|
||||
highest = FallState.NORMAL
|
||||
for overlay in overlays:
|
||||
if _STATE_SEVERITY[overlay.state] > _STATE_SEVERITY[highest]:
|
||||
highest = overlay.state
|
||||
return highest
|
||||
|
||||
|
||||
# --- Settings draft with an explicit next-start apply lifecycle --------------
|
||||
|
||||
FIELD_BOUNDS: Dict[str, Tuple[float, float]] = {
|
||||
"keypoint_confidence_threshold": (0.0, 1.0),
|
||||
"suspect_window_seconds": (0.0, 30.0),
|
||||
"confirm_window_seconds": (1.0, 3.0),
|
||||
"recovery_window_seconds": (0.0, 300.0),
|
||||
"cooldown_seconds": (0.0, 3600.0),
|
||||
"model_confidence_threshold": (0.0, 1.0),
|
||||
}
|
||||
|
||||
|
||||
class DraftValidationError(ValueError):
|
||||
"""Raised when a settings draft value is outside its allowed range."""
|
||||
|
||||
|
||||
def config_version(values: Dict[str, float]) -> str:
|
||||
canonical = json.dumps(
|
||||
{key: float(values[key]) for key in FIELD_BOUNDS},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return "cfg-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
class SettingsDraft:
|
||||
"""Hold three isolated copies: running snapshot, saved, and editable draft.
|
||||
|
||||
Editing changes only the draft. Saving copies the draft into ``saved`` but
|
||||
does not touch the running snapshot. ``start_monitoring`` promotes the saved
|
||||
values into a new immutable running snapshot; this is the only moment the
|
||||
running configuration version changes.
|
||||
"""
|
||||
|
||||
def __init__(self, initial: Dict[str, float]) -> None:
|
||||
missing = set(FIELD_BOUNDS) - set(initial)
|
||||
if missing:
|
||||
raise ValueError("missing settings fields: {0}".format(sorted(missing)))
|
||||
self._running = {key: float(initial[key]) for key in FIELD_BOUNDS}
|
||||
for key, value in self._running.items():
|
||||
low, high = FIELD_BOUNDS[key]
|
||||
if not low <= value <= high:
|
||||
raise DraftValidationError(
|
||||
"{0} must be between {1} and {2}".format(key, low, high)
|
||||
)
|
||||
self._saved = dict(self._running)
|
||||
self._draft = dict(self._running)
|
||||
|
||||
def edit(self, key: str, value) -> None:
|
||||
if key not in FIELD_BOUNDS:
|
||||
raise DraftValidationError("unknown settings field: {0}".format(key))
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise DraftValidationError("{0} must be numeric".format(key))
|
||||
low, high = FIELD_BOUNDS[key]
|
||||
if not low <= parsed <= high:
|
||||
raise DraftValidationError(
|
||||
"{0} must be between {1} and {2}".format(key, low, high)
|
||||
)
|
||||
self._draft[key] = parsed
|
||||
|
||||
@property
|
||||
def draft_values(self) -> Dict[str, float]:
|
||||
return dict(self._draft)
|
||||
|
||||
@property
|
||||
def saved_values(self) -> Dict[str, float]:
|
||||
return dict(self._saved)
|
||||
|
||||
@property
|
||||
def running_values(self) -> Dict[str, float]:
|
||||
return dict(self._running)
|
||||
|
||||
@property
|
||||
def is_dirty(self) -> bool:
|
||||
return self._draft != self._saved
|
||||
|
||||
@property
|
||||
def has_pending_for_next_start(self) -> bool:
|
||||
return self._saved != self._running
|
||||
|
||||
@property
|
||||
def running_version(self) -> str:
|
||||
return config_version(self._running)
|
||||
|
||||
def save(self) -> str:
|
||||
self._saved = dict(self._draft)
|
||||
if self._saved == self._running:
|
||||
return "已保存,与当前运行配置一致"
|
||||
return "已保存,将在下次开始监控时生效"
|
||||
|
||||
def discard(self) -> None:
|
||||
self._draft = dict(self._saved)
|
||||
|
||||
def start_monitoring(self) -> str:
|
||||
self._running = dict(self._saved)
|
||||
return self.running_version
|
||||
Reference in New Issue
Block a user