From 2688f2d4496911bf889ea00f942fa16ebf682356 Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 21 Jul 2026 23:52:59 +0800 Subject: [PATCH] feat(v1): add event-log summary tool for acceptance evidence v1/report.py reads events.jsonl and lists each confirmed event with its latency (flags the 1-3s target) and screenshot, or reports a clean pass when empty. Supports T-204 latency records and false-alarm counting. 78 tests pass. Co-Authored-By: Claude Opus 4.8 --- docs/current-state.md | 2 +- v1/report.py | 86 +++++++++++++++++++++++++++++++++++++++++ v1/tests/test_report.py | 60 ++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 v1/report.py create mode 100644 v1/tests/test_report.py diff --git a/docs/current-state.md b/docs/current-state.md index 4fa3100..6af60c7 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,7 +11,7 @@ - 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` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览,以及传输协议/连接超时/低延迟三项抓流调优(由 `config` 存值、`app` 开流前写入 `OPENCV_FFMPEG_CAPTURE_OPTIONS`)。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。 - 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 v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 74 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建、抓流参数拼串、每帧诊断、灵敏度放宽、截图 ASCII 标注和断流重连测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`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 测试,但不会安装软件包。 +- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 78 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建、抓流参数拼串、每帧诊断、灵敏度放宽、截图 ASCII 标注、断流重连和事件汇总测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`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` 哈希一致。 - 当前标准启动:`./init.ps1`。 - 当前标准验证:`python -m compileall -q demo`。 diff --git a/v1/report.py b/v1/report.py new file mode 100644 index 0000000..f19c211 --- /dev/null +++ b/v1/report.py @@ -0,0 +1,86 @@ +"""Summarize confirmed fall events for review, latency records and +false-alarm counting during acceptance sessions. + +Reads the JSONL written by ``alerts.EventArtifactWriter``. Use it after a test +session: run some normal activity (walking, sitting, bending, picking up) and +check that no events were recorded, or read back the latency of real falls. + + python -m v1.report # scans artifacts/events + python -m v1.report artifacts/events # a source directory + python -m v1.report .../20260721/events.jsonl +""" + +import json +import sys +from pathlib import Path +from typing import Dict, List + + +def load_events(jsonl_path: Path) -> List[Dict]: + path = Path(jsonl_path) + if not path.is_file(): + return [] + events = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line: + events.append(json.loads(line)) + return events + + +def collect_jsonl(root: Path) -> List[Path]: + """Return every events.jsonl under a source/day directory, or the file itself.""" + + path = Path(root) + if path.is_file(): + return [path] + return sorted(path.glob("**/events.jsonl")) + + +def summarize(events: List[Dict]) -> Dict: + latencies = [float(e.get("latency_seconds", 0.0)) for e in events] + within = [lat for lat in latencies if 1.0 <= lat <= 3.0] + return { + "count": len(events), + "latencies": latencies, + "latency_in_target": len(within), + } + + +def format_report(jsonl_path: Path, events: List[Dict]) -> str: + summary = summarize(events) + lines = [ + "{0}".format(jsonl_path), + " events: {0} (latency in 1-3s: {1}/{0})".format( + summary["count"], summary["latency_in_target"] + ), + ] + for event in events: + lines.append( + " {0} track={1} utc={2} latency={3:.2f}s shot={4}".format( + event.get("event_id", "?"), + event.get("track_id", "?"), + event.get("confirmed_at_utc", "?"), + float(event.get("latency_seconds", 0.0)), + event.get("screenshot", "?"), + ) + ) + if not events: + lines.append(" (no confirmed events — a clean pass for negative activity)") + return "\n".join(lines) + + +def main(argv=None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + root = Path(argv[0]) if argv else Path("artifacts/events") + files = collect_jsonl(root) + if not files: + print("no events.jsonl found under {0}".format(root)) + return 0 + for jsonl_path in files: + print(format_report(jsonl_path, load_events(jsonl_path))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/v1/tests/test_report.py b/v1/tests/test_report.py new file mode 100644 index 0000000..27312eb --- /dev/null +++ b/v1/tests/test_report.py @@ -0,0 +1,60 @@ +import json + +from v1.report import collect_jsonl, format_report, load_events, summarize + + +def _write_events(path, events): + path.write_text( + "\n".join(json.dumps(e, ensure_ascii=False) for e in events) + "\n", + encoding="utf-8", + ) + + +def _event(event_id, latency): + return { + "event_id": event_id, + "track_id": "P-0001", + "confirmed_at_utc": "2026-07-21T08:30:00+00:00", + "latency_seconds": latency, + "screenshot": "20260721/{0}.png".format(event_id), + } + + +def test_load_events_reads_each_jsonl_line(tmp_path): + jsonl = tmp_path / "events.jsonl" + _write_events(jsonl, [_event("FALL-A-000001", 1.8), _event("FALL-A-000002", 2.1)]) + + events = load_events(jsonl) + + assert len(events) == 2 + assert events[0]["event_id"] == "FALL-A-000001" + + +def test_summarize_counts_latency_in_target_window(tmp_path): + events = [_event("a", 1.8), _event("b", 0.9), _event("c", 3.0)] + + summary = summarize(events) + + assert summary["count"] == 3 + assert summary["latency_in_target"] == 2 # 1.8 and 3.0 are within 1-3s, 0.9 is not + + +def test_empty_log_reads_as_clean_pass(tmp_path): + jsonl = tmp_path / "events.jsonl" + jsonl.write_text("", encoding="utf-8") + + events = load_events(jsonl) + report = format_report(jsonl, events) + + assert events == [] + assert "no confirmed events" in report + + +def test_collect_jsonl_finds_day_directories(tmp_path): + day = tmp_path / "20260721" + day.mkdir() + _write_events(day / "events.jsonl", [_event("FALL-A-000001", 1.8)]) + + found = collect_jsonl(tmp_path) + + assert found == [day / "events.jsonl"]