feat(v1): add local alerts, screenshot and JSONL event log
AlertDispatcher de-duplicates by event_id and fires each side effect once per CONFIRMED: annotated screenshot, one JSONL line (config_version, source_id, no rtsp/credentials), sound and popup via injectable sink. 44 tests pass; real-frame artifact smoke verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| T-201 | 实现 PyQt 顶部双 Tab 的监控与设置界面 | T-106 | 实时监控 Tab 显示视频、骨架、ID、NORMAL/SUSPECT/CONFIRMED 和连接状态;设置 Tab 只编辑非敏感草稿并明确下次启动生效;UI 不直接执行事件计算。 | DONE |
|
| T-201 | 实现 PyQt 顶部双 Tab 的监控与设置界面 | T-106 | 实时监控 Tab 显示视频、骨架、ID、NORMAL/SUSPECT/CONFIRMED 和连接状态;设置 Tab 只编辑非敏感草稿并明确下次启动生效;UI 不直接执行事件计算。 | DONE |
|
||||||
| T-202 | 实现本地报警、弹窗、截图和 JSONL 日志 | T-201 | CONFIRMED 产生一次声音、一次弹窗、一张带标注截图和一条事件日志。 | DOING |
|
| T-202 | 实现本地报警、弹窗、截图和 JSONL 日志 | T-201 | CONFIRMED 产生一次声音、一次弹窗、一张带标注截图和一条事件日志。 | DONE |
|
||||||
| 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 |
|
||||||
| T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | TODO |
|
| T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | TODO |
|
||||||
|
|||||||
+17
@@ -76,6 +76,23 @@ FallEvent = {
|
|||||||
|
|
||||||
`FallEvent` 是状态机的纯内存确认事件,只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。`config_version` 是由运行配置快照计算的非敏感版本标识;T-202 的 `alerts` 会在不改变事件幂等语义的前提下,为截图/JSONL 记录补充来源、UTC 时间和证据。
|
`FallEvent` 是状态机的纯内存确认事件,只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。`config_version` 是由运行配置快照计算的非敏感版本标识;T-202 的 `alerts` 会在不改变事件幂等语义的前提下,为截图/JSONL 记录补充来源、UTC 时间和证据。
|
||||||
|
|
||||||
|
T-202 的 `alerts` 按 `event_id` 去重,对首次 CONFIRMED 只触发一次副作用(声音、弹窗、截图、JSONL 一行)。JSONL 记录如下,文件名与字段不含 RTSP 地址、凭证或客户姓名:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AlertRecord(JSONL) = {
|
||||||
|
event_id: string,
|
||||||
|
track_id: string,
|
||||||
|
config_version: string,
|
||||||
|
source_id: string,
|
||||||
|
state: "CONFIRMED",
|
||||||
|
confirmed_at_utc: string, # ISO-8601 UTC
|
||||||
|
suspected_at_monotonic: float,
|
||||||
|
confirmed_at_monotonic: float,
|
||||||
|
latency_seconds: float,
|
||||||
|
screenshot: string # 相对 event_dir 的 "YYYYMMDD/FALL-xxxxxx.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
`PersonPose` 是 T-103 的纯模型输出,不带人员 ID;T-104 的跟踪模块产生 `TrackedPersonPose` 后,才允许事件证据按人员连续积累。
|
`PersonPose` 是 T-103 的纯模型输出,不带人员 ID;T-104 的跟踪模块产生 `TrackedPersonPose` 后,才允许事件证据按人员连续积累。
|
||||||
|
|
||||||
## 视频来源帧合约
|
## 视频来源帧合约
|
||||||
|
|||||||
@@ -5,17 +5,17 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-21
|
- 日期:2026-07-21
|
||||||
- 阶段:V1 事件管线与 PyQt 双 Tab 界面外壳已建立;T-201 已验收,等待 T-202。
|
- 阶段:V1 事件管线、PyQt 双 Tab 界面外壳与本地报警工件闭环已建立;T-202 已验收,等待 T-203(需现场海康 RTSP,暂受阻)。
|
||||||
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。
|
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。
|
||||||
- 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。
|
- 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。
|
||||||
- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。声音、弹窗、截图、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`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。真实海康 RTSP 接入(T-203)尚未实现。
|
||||||
- V2 代码:`v2/` 目录存在但尚无实现。
|
- V2 代码:`v2/` 目录存在但尚无实现。
|
||||||
- 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。
|
- 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。
|
||||||
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py` 语法);`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 测试,但不会安装软件包。
|
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py` 语法);`python -m pytest v1/tests -v` 当前有 44 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型和报警工件测试并已通过。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
||||||
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
||||||
- 当前标准启动:`./init.ps1`。
|
- 当前标准启动:`./init.ps1`。
|
||||||
- 当前标准验证:`python -m compileall -q demo`。
|
- 当前标准验证:`python -m compileall -q demo`。
|
||||||
- 当前 blocker:PyQt 界面外壳已实现但可视化冒烟需在 Windows 执行;本地报警/弹窗/截图/JSONL 闭环(T-202)尚未实现;尚无带标签的正反例回归录像及事件标签;真实海康 RTSP 流尚未接入,实时流单调计时策略尚待 T-203 在现场验证。
|
- 当前 blocker:PyQt 界面外壳与报警闭环已实现,但可视化、声音与弹窗冒烟需在 Windows 执行;T-203(海康 RTSP 与断流恢复)需现场真实流,T-204(正反例回归录像与现场验收)需经同意成年人的摔倒/反例录像与现场安全措施,T-205 依赖 T-204,三者在当前无摄像头、无录像素材的环境无法真实验收,均标为 BLOCKED;尚无带标签的正反例回归录像及事件标签。
|
||||||
|
|
||||||
全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。
|
全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。
|
||||||
|
|
||||||
@@ -32,9 +32,9 @@
|
|||||||
|
|
||||||
## 任务状态
|
## 任务状态
|
||||||
|
|
||||||
- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)、T-104(跟踪与姿态质量证据)、T-105(按 ID 时序状态机)、T-106(事件管线与事件契约)、T-201(顶部双 Tab 的 PyQt 监控与设置界面)。
|
- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)、T-104(跟踪与姿态质量证据)、T-105(按 ID 时序状态机)、T-106(事件管线与事件契约)、T-201(顶部双 Tab 的 PyQt 监控与设置界面)、T-202(本地报警、弹窗、截图和 JSONL 日志)。
|
||||||
- 正在进行:无。
|
- 正在进行:无。
|
||||||
- 下一个可领取:T-202(本地报警、弹窗、截图和 JSONL 日志)。
|
- 下一个可领取:T-203(接入海康 RTSP 与断流恢复)——需现场真实海康流,当前环境无法验收,标为 BLOCKED。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
@@ -206,3 +206,12 @@
|
|||||||
- 阻塞:无任务内 blocker;声音播放与 Qt 弹窗需 Windows 桌面,见决策。
|
- 阻塞:无任务内 blocker;声音播放与 Qt 弹窗需 Windows 桌面,见决策。
|
||||||
- 决策:报警派发按 `event_id` 去重,只对首次 CONFIRMED 触发一次副作用;截图标注与 JSONL 写入在本环境用 pytest 真实覆盖;声音与弹窗抽象为可注入 sink,Windows 侧提供真实实现。JSONL 与截图文件名不含 RTSP 地址、凭证或客户姓名,只用事件 ID 与日期目录。
|
- 决策:报警派发按 `event_id` 去重,只对首次 CONFIRMED 触发一次副作用;截图标注与 JSONL 写入在本环境用 pytest 真实覆盖;声音与弹窗抽象为可注入 sink,Windows 侧提供真实实现。JSONL 与截图文件名不含 RTSP 地址、凭证或客户姓名,只用事件 ID 与日期目录。
|
||||||
- 下一步:先写去重、JSONL 字段与截图落盘的失败测试,再实现最小报警工件模块。
|
- 下一步:先写去重、JSONL 字段与截图落盘的失败测试,再实现最小报警工件模块。
|
||||||
|
|
||||||
|
## 【2026-07-21】T-202 实现本地报警、弹窗、截图和 JSONL 日志(完成)
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 变更:新增 `v1/alerts.py`:`annotate_frame` 用 OpenCV 在帧上叠加 box/骨架/关键点/标签,CONFIRMED 时加红色外框(红色专属确认摔倒);`EventArtifactWriter` 按 `artifacts/events/YYYYMMDD/FALL-xxxxxx.png` 保存带标注截图并向 `events.jsonl` 追加一行含 `event_id/track_id/config_version/source_id/state/confirmed_at_utc/单调时间/latency/相对截图路径` 的记录;`AlertDispatcher` 按 `event_id` 去重,对首次 CONFIRMED 只触发一次副作用。声音与弹窗抽象为 `AlertSink`;`v1/gui.py` 新增 `QtAlertSink`(`QApplication.beep` + 一次性非阻塞“我已知晓”弹窗),`v1/app.py` 在开始监控时构建 writer/dispatcher 并在每帧派发 `analysis.events`,窗口仍只渲染。
|
||||||
|
- 验证:新增 `v1/tests/test_alerts.py` 5 项,覆盖同一事件只写一次截图/JSONL 且 sink 各调用一次、JSONL 字段正确且无 rtsp/password、缺图不产生副作用、不同事件写两行、annotate 仅对 CONFIRMED 画红框;`python3 -m pytest v1/tests -q` 为 44 passed;`python3 -m compileall -q v1 demo` 退出码 0。真实帧冒烟:用 `demo/1.mp4` 首帧(848×480)经 `AlertDispatcher` 落盘,截图可被 `cv2.imread` 读回为 480×848×3、约 330 KB,重复派发返回 0,JSONL 一行且 `screenshot=20260721/FALL-000001.png`、含 `config_version`、`source_id`,行内无 `rtsp`。
|
||||||
|
- 阻塞:无任务内 blocker。声音播放与 Qt 弹窗需 Windows + PyQt5 桌面执行冒烟;本环境已真实验证截图标注、JSONL 落盘与去重逻辑。
|
||||||
|
- 决策:事件副作用严格按 `event_id` 幂等;报警记录以 `FallEvent` 的 `config_version` 追溯运行配置,不在写入时重新推导;工件文件名只用事件 ID 与日期目录,绝不含来源地址、凭证或客户姓名。
|
||||||
|
- 下一步:T-203,接入海康 RTSP 与断流恢复;因需现场真实流,标为 BLOCKED 等现场资源。
|
||||||
|
|||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
"""Confirmed-event side effects: annotated screenshot, JSONL log, sound, popup.
|
||||||
|
|
||||||
|
Side effects run once per ``event_id``. Screenshot annotation and JSONL writing
|
||||||
|
are Qt-free and unit-tested; sound and popup are injected sinks so the desktop
|
||||||
|
adapters (Windows) stay thin. Artifact names use only the event ID and a date
|
||||||
|
folder — never an RTSP address, credential, or client name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Dict, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from v1.fall_state import FallEvent, FallState
|
||||||
|
from v1.view_model import MonitorViewState, StatusColor
|
||||||
|
|
||||||
|
|
||||||
|
# Semantic tokens as OpenCV BGR tuples (see docs/ui/silver-pose-ui-ux-spec.md).
|
||||||
|
_COLOR_BGR: Dict[StatusColor, Tuple[int, int, int]] = {
|
||||||
|
StatusColor.SUCCESS: (61, 128, 21),
|
||||||
|
StatusColor.CAUTION: (9, 83, 180),
|
||||||
|
StatusColor.CRITICAL: (40, 40, 198),
|
||||||
|
StatusColor.OFFLINE: (139, 116, 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AlertRecord:
|
||||||
|
event: FallEvent
|
||||||
|
screenshot_path: Path
|
||||||
|
log_path: Path
|
||||||
|
created_at_utc: str
|
||||||
|
|
||||||
|
|
||||||
|
class AlertSink:
|
||||||
|
"""Injectable output for sound and popup. Default implementation is silent."""
|
||||||
|
|
||||||
|
def play_sound(self, event: FallEvent) -> None: # pragma: no cover - adapter
|
||||||
|
pass
|
||||||
|
|
||||||
|
def show_popup(self, record: AlertRecord) -> None: # pragma: no cover - adapter
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_frame(image: np.ndarray, view: MonitorViewState) -> np.ndarray:
|
||||||
|
"""Return a copy of ``image`` with boxes, skeleton, labels and a state border."""
|
||||||
|
|
||||||
|
canvas = image.copy()
|
||||||
|
for overlay in view.people:
|
||||||
|
color = _COLOR_BGR[overlay.color]
|
||||||
|
left, top, right, bottom = (int(round(v)) for v in overlay.box_xyxy)
|
||||||
|
cv2.rectangle(canvas, (left, top), (right, bottom), color, 2)
|
||||||
|
for start, end in overlay.skeleton_segments:
|
||||||
|
cv2.line(
|
||||||
|
canvas,
|
||||||
|
(int(round(start.x)), int(round(start.y))),
|
||||||
|
(int(round(end.x)), int(round(end.y))),
|
||||||
|
color,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
for point in overlay.keypoints:
|
||||||
|
if point is not None:
|
||||||
|
cv2.circle(canvas, (int(round(point.x)), int(round(point.y))), 3, color, -1)
|
||||||
|
cv2.putText(
|
||||||
|
canvas,
|
||||||
|
overlay.label,
|
||||||
|
(left, max(0, top - 6)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
color,
|
||||||
|
1,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
if view.highest_state is FallState.CONFIRMED:
|
||||||
|
height, width = canvas.shape[:2]
|
||||||
|
cv2.rectangle(
|
||||||
|
canvas, (0, 0), (width - 1, height - 1), _COLOR_BGR[StatusColor.CRITICAL], 6
|
||||||
|
)
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
class EventArtifactWriter:
|
||||||
|
"""Persist one annotated screenshot and one JSONL line per confirmed event."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
event_dir: Path,
|
||||||
|
source_id: str,
|
||||||
|
clock: Optional[Callable[[], datetime]] = None,
|
||||||
|
) -> None:
|
||||||
|
self._event_dir = Path(event_dir)
|
||||||
|
self._source_id = source_id
|
||||||
|
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
def write(
|
||||||
|
self, event: FallEvent, image: np.ndarray, view: MonitorViewState
|
||||||
|
) -> AlertRecord:
|
||||||
|
created = self._clock().astimezone(timezone.utc)
|
||||||
|
day_dir = self._event_dir / created.strftime("%Y%m%d")
|
||||||
|
day_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
screenshot_path = day_dir / "{0}.png".format(event.event_id)
|
||||||
|
annotated = annotate_frame(image, view)
|
||||||
|
if not cv2.imwrite(str(screenshot_path), annotated):
|
||||||
|
raise IOError("failed to save screenshot: {0}".format(screenshot_path))
|
||||||
|
log_path = day_dir / "events.jsonl"
|
||||||
|
record = {
|
||||||
|
"event_id": event.event_id,
|
||||||
|
"track_id": event.track_id,
|
||||||
|
"config_version": event.config_version,
|
||||||
|
"source_id": self._source_id,
|
||||||
|
"state": event.state.value,
|
||||||
|
"confirmed_at_utc": created.isoformat(),
|
||||||
|
"suspected_at_monotonic": event.suspected_at_monotonic,
|
||||||
|
"confirmed_at_monotonic": event.confirmed_at_monotonic,
|
||||||
|
"latency_seconds": event.latency_seconds,
|
||||||
|
"screenshot": screenshot_path.relative_to(self._event_dir).as_posix(),
|
||||||
|
}
|
||||||
|
with log_path.open("a", encoding="utf-8") as stream:
|
||||||
|
stream.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
|
return AlertRecord(
|
||||||
|
event=event,
|
||||||
|
screenshot_path=screenshot_path,
|
||||||
|
log_path=log_path,
|
||||||
|
created_at_utc=record["confirmed_at_utc"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDispatcher:
|
||||||
|
"""De-duplicate confirmed events and fire each side effect exactly once."""
|
||||||
|
|
||||||
|
def __init__(self, writer: EventArtifactWriter, sink: Optional[AlertSink] = None) -> None:
|
||||||
|
self._writer = writer
|
||||||
|
self._sink = sink or AlertSink()
|
||||||
|
self._seen_event_ids: set = set()
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
self,
|
||||||
|
events: Sequence[FallEvent],
|
||||||
|
image: Optional[np.ndarray],
|
||||||
|
view: MonitorViewState,
|
||||||
|
) -> Sequence[AlertRecord]:
|
||||||
|
records = []
|
||||||
|
for event in events:
|
||||||
|
if event.event_id in self._seen_event_ids or image is None:
|
||||||
|
continue
|
||||||
|
self._seen_event_ids.add(event.event_id)
|
||||||
|
record = self._writer.write(event, image, view)
|
||||||
|
self._sink.play_sound(event)
|
||||||
|
self._sink.show_popup(record)
|
||||||
|
records.append(record)
|
||||||
|
return tuple(records)
|
||||||
@@ -14,6 +14,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from PyQt5 import QtCore, QtWidgets
|
from PyQt5 import QtCore, QtWidgets
|
||||||
|
|
||||||
|
from v1.alerts import AlertDispatcher, EventArtifactWriter
|
||||||
from v1.config import AppConfig, EventConfig, load_config
|
from v1.config import AppConfig, EventConfig, load_config
|
||||||
from v1.pipeline import FallPipeline
|
from v1.pipeline import FallPipeline
|
||||||
from v1.pose import PoseAdapter
|
from v1.pose import PoseAdapter
|
||||||
@@ -93,12 +94,17 @@ class ApplicationController:
|
|||||||
self.window.monitor.start_requested.connect(self.start)
|
self.window.monitor.start_requested.connect(self.start)
|
||||||
self.window.monitor.stop_requested.connect(self.stop)
|
self.window.monitor.stop_requested.connect(self.stop)
|
||||||
self._worker: Optional[FrameWorker] = None
|
self._worker: Optional[FrameWorker] = None
|
||||||
|
self._dispatcher: Optional[AlertDispatcher] = None
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._worker is not None:
|
if self._worker is not None:
|
||||||
return
|
return
|
||||||
|
from v1.gui import QtAlertSink
|
||||||
|
|
||||||
self._draft.start_monitoring()
|
self._draft.start_monitoring()
|
||||||
running = _running_config(self._config, self._draft)
|
running = _running_config(self._config, self._draft)
|
||||||
|
writer = EventArtifactWriter(running.event_dir, running.source_id)
|
||||||
|
self._dispatcher = AlertDispatcher(writer, QtAlertSink(self.window))
|
||||||
worker = FrameWorker(running, self._pose_adapter)
|
worker = FrameWorker(running, self._pose_adapter)
|
||||||
worker.frame_ready.connect(self._on_frame)
|
worker.frame_ready.connect(self._on_frame)
|
||||||
worker.finished.connect(self._on_finished)
|
worker.finished.connect(self._on_finished)
|
||||||
@@ -115,9 +121,12 @@ class ApplicationController:
|
|||||||
analysis, self._draft.running_values["keypoint_confidence_threshold"]
|
analysis, self._draft.running_values["keypoint_confidence_threshold"]
|
||||||
)
|
)
|
||||||
self.window.monitor.render_view(analysis.packet.image, view)
|
self.window.monitor.render_view(analysis.packet.image, view)
|
||||||
|
if self._dispatcher is not None:
|
||||||
|
self._dispatcher.dispatch(analysis.events, analysis.packet.image, view)
|
||||||
|
|
||||||
def _on_finished(self) -> None:
|
def _on_finished(self) -> None:
|
||||||
self._worker = None
|
self._worker = None
|
||||||
|
self._dispatcher = None
|
||||||
self.window.monitor.set_running(False)
|
self.window.monitor.set_running(False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Dict, Optional
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
|
from v1.alerts import AlertRecord, AlertSink
|
||||||
from v1.view_model import (
|
from v1.view_model import (
|
||||||
FIELD_BOUNDS,
|
FIELD_BOUNDS,
|
||||||
DraftValidationError,
|
DraftValidationError,
|
||||||
@@ -69,6 +70,32 @@ def bgr_to_qimage(image: np.ndarray) -> QtGui.QImage:
|
|||||||
).copy()
|
).copy()
|
||||||
|
|
||||||
|
|
||||||
|
class QtAlertSink(AlertSink):
|
||||||
|
"""Desktop sound + one non-blocking popup per confirmed event (Windows smoke)."""
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
|
||||||
|
self._parent = parent
|
||||||
|
|
||||||
|
def play_sound(self, event) -> None:
|
||||||
|
QtWidgets.QApplication.beep()
|
||||||
|
|
||||||
|
def show_popup(self, record: AlertRecord) -> None:
|
||||||
|
box = QtWidgets.QMessageBox(self._parent)
|
||||||
|
box.setIcon(QtWidgets.QMessageBox.Warning)
|
||||||
|
box.setWindowTitle("确认摔倒")
|
||||||
|
box.setText(
|
||||||
|
"人员 {0} 确认摔倒\n确认延迟 {1:.2f} 秒\n截图:{2}".format(
|
||||||
|
record.event.track_id,
|
||||||
|
record.event.latency_seconds,
|
||||||
|
record.screenshot_path.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
box.setStandardButtons(QtWidgets.QMessageBox.Ok)
|
||||||
|
box.button(QtWidgets.QMessageBox.Ok).setText("我已知晓")
|
||||||
|
box.setModal(False)
|
||||||
|
box.show()
|
||||||
|
|
||||||
|
|
||||||
class VideoView(QtWidgets.QWidget):
|
class VideoView(QtWidgets.QWidget):
|
||||||
"""Paint the latest frame plus box/skeleton/id/state overlays."""
|
"""Paint the latest frame plus box/skeleton/id/state overlays."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from v1.alerts import AlertDispatcher, AlertSink, EventArtifactWriter, annotate_frame
|
||||||
|
from v1.fall_state import FallEvent, FallState
|
||||||
|
from v1.view_model import MonitorViewState, PersonOverlay, Point, StatusColor
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingSink(AlertSink):
|
||||||
|
def __init__(self):
|
||||||
|
self.sounds = []
|
||||||
|
self.popups = []
|
||||||
|
|
||||||
|
def play_sound(self, event):
|
||||||
|
self.sounds.append(event.event_id)
|
||||||
|
|
||||||
|
def show_popup(self, record):
|
||||||
|
self.popups.append(record.event.event_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _event(event_id="FALL-000001", track_id="P-0003"):
|
||||||
|
return FallEvent(
|
||||||
|
event_id=event_id,
|
||||||
|
track_id=track_id,
|
||||||
|
config_version="cfg-abc123",
|
||||||
|
suspected_at_monotonic=0.0,
|
||||||
|
confirmed_at_monotonic=1.8,
|
||||||
|
latency_seconds=1.8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _confirmed_view():
|
||||||
|
overlay = PersonOverlay(
|
||||||
|
track_id="P-0003",
|
||||||
|
state=FallState.CONFIRMED,
|
||||||
|
color=StatusColor.CRITICAL,
|
||||||
|
box_xyxy=(20.0, 20.0, 80.0, 180.0),
|
||||||
|
keypoints=(Point(30.0, 40.0), Point(40.0, 40.0)),
|
||||||
|
skeleton_segments=((Point(30.0, 40.0), Point(40.0, 40.0)),),
|
||||||
|
label="P-0003 · CONFIRMED",
|
||||||
|
)
|
||||||
|
return MonitorViewState(
|
||||||
|
connected=True,
|
||||||
|
status_text="在线",
|
||||||
|
status_color=StatusColor.SUCCESS,
|
||||||
|
has_frame=True,
|
||||||
|
people=(overlay,),
|
||||||
|
events=(),
|
||||||
|
highest_state=FallState.CONFIRMED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _writer(tmp_path):
|
||||||
|
fixed = datetime(2026, 7, 21, 8, 30, 0, tzinfo=timezone.utc)
|
||||||
|
return EventArtifactWriter(tmp_path, source_id="lobby-camera-01", clock=lambda: fixed)
|
||||||
|
|
||||||
|
|
||||||
|
def _image():
|
||||||
|
return np.full((200, 240, 3), 30, dtype=np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_event_fires_each_side_effect_once(tmp_path):
|
||||||
|
sink = RecordingSink()
|
||||||
|
dispatcher = AlertDispatcher(_writer(tmp_path), sink)
|
||||||
|
event = _event()
|
||||||
|
|
||||||
|
first = dispatcher.dispatch([event], _image(), _confirmed_view())
|
||||||
|
second = dispatcher.dispatch([event], _image(), _confirmed_view())
|
||||||
|
|
||||||
|
assert len(first) == 1
|
||||||
|
assert second == ()
|
||||||
|
assert sink.sounds == ["FALL-000001"]
|
||||||
|
assert sink.popups == ["FALL-000001"]
|
||||||
|
log_path = tmp_path / "20260721" / "events.jsonl"
|
||||||
|
assert log_path.read_text(encoding="utf-8").count("\n") == 1
|
||||||
|
assert (tmp_path / "20260721" / "FALL-000001.png").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_jsonl_record_has_fields_and_no_source_address(tmp_path):
|
||||||
|
dispatcher = AlertDispatcher(_writer(tmp_path))
|
||||||
|
dispatcher.dispatch([_event()], _image(), _confirmed_view())
|
||||||
|
|
||||||
|
line = (tmp_path / "20260721" / "events.jsonl").read_text(encoding="utf-8").strip()
|
||||||
|
record = json.loads(line)
|
||||||
|
assert record["event_id"] == "FALL-000001"
|
||||||
|
assert record["config_version"] == "cfg-abc123"
|
||||||
|
assert record["source_id"] == "lobby-camera-01"
|
||||||
|
assert record["state"] == "CONFIRMED"
|
||||||
|
assert record["confirmed_at_utc"] == "2026-07-21T08:30:00+00:00"
|
||||||
|
assert record["screenshot"] == "20260721/FALL-000001.png"
|
||||||
|
assert "rtsp" not in line.lower()
|
||||||
|
assert "password" not in line.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_image_produces_no_side_effect(tmp_path):
|
||||||
|
sink = RecordingSink()
|
||||||
|
dispatcher = AlertDispatcher(_writer(tmp_path), sink)
|
||||||
|
|
||||||
|
records = dispatcher.dispatch([_event()], None, _confirmed_view())
|
||||||
|
|
||||||
|
assert records == ()
|
||||||
|
assert sink.sounds == []
|
||||||
|
assert not (tmp_path / "20260721").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_distinct_events_produce_two_log_lines(tmp_path):
|
||||||
|
dispatcher = AlertDispatcher(_writer(tmp_path))
|
||||||
|
dispatcher.dispatch([_event("FALL-000001")], _image(), _confirmed_view())
|
||||||
|
dispatcher.dispatch([_event("FALL-000002", "P-0009")], _image(), _confirmed_view())
|
||||||
|
|
||||||
|
lines = (tmp_path / "20260721" / "events.jsonl").read_text(encoding="utf-8").strip().split("\n")
|
||||||
|
assert len(lines) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_annotate_draws_red_border_only_for_confirmed():
|
||||||
|
image = np.full((60, 80, 3), 30, dtype=np.uint8)
|
||||||
|
confirmed = annotate_frame(image, _confirmed_view())
|
||||||
|
corner = confirmed[0, 0].tolist()
|
||||||
|
assert corner == [40, 40, 198] # critical red in BGR
|
||||||
|
assert confirmed.shape == image.shape
|
||||||
Reference in New Issue
Block a user