diff --git a/docs/04-architecture.md b/docs/04-architecture.md index c24933f..bffec66 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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 导航与配置生效生命周期 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 939ee50..ac6b12a 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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 | diff --git a/docs/current-state.md b/docs/current-state.md index f484f5c..6e474c5 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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 日志)。 ## 当前可运行内容 diff --git a/progress.md b/progress.md index 40a0160..7757873 100644 --- a/progress.md +++ b/progress.md @@ -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 事件日志。 diff --git a/v1/app.py b/v1/app.py new file mode 100644 index 0000000..54efd27 --- /dev/null +++ b/v1/app.py @@ -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()) diff --git a/v1/gui.py b/v1/gui.py new file mode 100644 index 0000000..007c25b --- /dev/null +++ b/v1/gui.py @@ -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) diff --git a/v1/tests/test_view_model.py b/v1/tests/test_view_model.py new file mode 100644 index 0000000..c06f588 --- /dev/null +++ b/v1/tests/test_view_model.py @@ -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) diff --git a/v1/view_model.py b/v1/view_model.py new file mode 100644 index 0000000..d1bfdaa --- /dev/null +++ b/v1/view_model.py @@ -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