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:
ila
2026-07-21 19:49:20 +08:00
co-authored by Claude Opus 4.8
parent 861532ffb0
commit afe7a5e781
8 changed files with 347 additions and 7 deletions
+1 -1
View File
@@ -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
View File
@@ -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` 后,才允许事件证据按人员连续积累。
## 视频来源帧合约 ## 视频来源帧合约
+6 -6
View File
@@ -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。
## 当前可运行内容 ## 当前可运行内容
+9
View File
@@ -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
View File
@@ -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)
+9
View File
@@ -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)
+27
View File
@@ -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."""
+123
View File
@@ -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