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:
ila
2026-07-21 19:43:49 +08:00
co-authored by Claude Opus 4.8
parent 16ba9b3944
commit 289f781134
8 changed files with 832 additions and 9 deletions
+3 -1
View File
@@ -40,7 +40,9 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一
| 事件管线 | `v1/pipeline.py` | 按一帧顺序装配 Pose、跟踪、质量/证据、领域规则和状态机;对缺帧、低质量和断流输入拒绝证据 | 读取视频、加载模型、GUI、报警副作用 | | 事件管线 | `v1/pipeline.py` | 按一帧顺序装配 Pose、跟踪、质量/证据、领域规则和状态机;对缺帧、低质量和断流输入拒绝证据 | 读取视频、加载模型、GUI、报警副作用 |
| 状态机 | `v1/fall_state.py` | 管理每个 ID 的 NORMAL、SUSPECT、CONFIRMED、RECOVERING,并在首次确认时产生带确认延迟和 `config_version` 的事件 | 播放声音或存文件 | | 状态机 | `v1/fall_state.py` | 管理每个 ID 的 NORMAL、SUSPECT、CONFIRMED、RECOVERING,并在首次确认时产生带确认延迟和 `config_version` 的事件 | 播放声音或存文件 |
| 报警工件 | `v1/alerts.py` | 对确认事件去重、播放声音、保存截图、写日志 | 推理或事件计算 | | 报警工件 | `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 | | 回归工具 | `v1/tests/` 与 `v1/scripts/` | 回放录像、断言事件和延迟 | 生产 UI |
## UI 导航与配置生效生命周期 ## UI 导航与配置生效生命周期
+1 -1
View File
@@ -25,7 +25,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | 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-202 | 实现本地报警、弹窗、截图和 JSONL 日志 | T-201 | CONFIRMED 产生一次声音、一次弹窗、一张带标注截图和一条事件日志。 | TODO |
| T-203 | 接入海康 RTSP 与断流恢复 | T-202 | 现场有效流可预览;断流状态明确、可重连且不报警。 | TODO | | T-203 | 接入海康 RTSP 与断流恢复 | T-202 | 现场有效流可预览;断流状态明确、可重连且不报警。 | TODO |
| T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | TODO | | T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | TODO |
+7 -7
View File
@@ -5,17 +5,17 @@
## 当前快照 ## 当前快照
- 日期:2026-07-21 - 日期:2026-07-21
- 阶段:V1 事件管线与事件契约已建立;T-106 已验收,等待 T-201。 - 阶段:V1 事件管线与 PyQt 双 Tab 界面外壳已建立;T-201 已验收,等待 T-202。
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。 - 已验证环境: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`,缺帧/低质量/断流会中断证据确认。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/` 目录存在但尚无实现。 - 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 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` 哈希一致。 - 模型:`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:尚无带标签的正反例回归录像及事件标签;真实海康 RTSP 流尚未接入,实时流单调计时策略尚待 T-203 在现场验证;PyQt 与本地报警闭环尚未实现。 - 当前 blocker:PyQt 界面外壳已实现但可视化冒烟需在 Windows 执行;本地报警/弹窗/截图/JSONL 闭环(T-202)尚未实现;尚无带标签的正反例回归录像及事件标签;真实海康 RTSP 流尚未接入,实时流单调计时策略尚待 T-203 在现场验证。
全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。 全局环境的 `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 日志)。
## 当前可运行内容 ## 当前可运行内容
+9
View File
@@ -188,3 +188,12 @@
- 阻塞:无任务内 blocker;PyQt5 渲染冒烟受环境限制,见决策。 - 阻塞:无任务内 blocker;PyQt5 渲染冒烟受环境限制,见决策。
- 决策:把可验收的 UI 逻辑(设置草稿与运行配置快照隔离、状态→语义色映射、骨架与人员叠加数据推导、连接状态文案)抽到不依赖 Qt 的 `v1/view_model.py`,在本环境用 pytest 真实覆盖;`v1/gui.py` 与 `v1/app.py` 仅作薄 Qt 外壳与装配,只消费 T-106 管线输出,不执行事件判定。GUI 的可视化冒烟标注为需 Windows + PyQt5 执行。 - 决策:把可验收的 UI 逻辑(设置草稿与运行配置快照隔离、状态→语义色映射、骨架与人员叠加数据推导、连接状态文案)抽到不依赖 Qt 的 `v1/view_model.py`,在本环境用 pytest 真实覆盖;`v1/gui.py` 与 `v1/app.py` 仅作薄 Qt 外壳与装配,只消费 T-106 管线输出,不执行事件判定。GUI 的可视化冒烟标注为需 Windows + PyQt5 执行。
- 下一步:先写 view-model 的失败测试(草稿隔离、状态色、骨架推导、连接文案),再实现最小 UI 外壳。 - 下一步:先写 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 事件日志。
+137
View File
@@ -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())
+265
View File
@@ -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)
+148
View File
@@ -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)
+262
View File
@@ -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