From f03662bf5a576f2a3fcc1ac824eaea2c9caed65b Mon Sep 17 00:00:00 2001 From: QiuSW Date: Tue, 21 Jul 2026 11:57:22 +0800 Subject: [PATCH] feat(v1): compose temporal fall event pipeline --- docs/03-tech-stack.md | 2 +- docs/04-architecture.md | 19 +++-- docs/06-tasks.md | 2 +- docs/api.md | 8 +- docs/current-state.md | 12 +-- progress.md | 9 +++ v1/config.py | 20 +++++ v1/fall_policy.py | 45 +++++++++++ v1/fall_state.py | 24 +++++- v1/pipeline.py | 139 ++++++++++++++++++++++++++++++++++ v1/tests/test_config.py | 15 ++++ v1/tests/test_fall_policy.py | 53 +++++++++++++ v1/tests/test_fall_state.py | 57 ++++++++++++-- v1/tests/test_pipeline.py | 121 +++++++++++++++++++++++++++++ v1/tests/test_video_source.py | 39 ++++++++-- v1/video_source.py | 23 ++++-- 16 files changed, 554 insertions(+), 34 deletions(-) create mode 100644 v1/fall_policy.py create mode 100644 v1/pipeline.py create mode 100644 v1/tests/test_fall_policy.py create mode 100644 v1/tests/test_pipeline.py diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 3d95d19..97fcea4 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -10,7 +10,7 @@ | 桌面 UI | PyQt5 | 已验证 | 现有 GUI 已使用,适合实时画面、弹窗和信号线程。 | | 视频与图像 | OpenCV(含 FFmpeg) | 已验证 | 读取 RTSP、绘制叠加层和保存截图。 | | Pose 推理 | Ultralytics 8.3.205 + YOLOv8 Pose | 已验证 | 当前 `best.pt` 能加载为 17 点 person Pose 模型。 | -| 跟踪 | Ultralytics ByteTrack | 已定 | 为时序事件提供人员 ID;实现前需用现场录像验证 ID 稳定性。 | +| 跟踪 | 轻量归一化框中心最近邻 | 已实现基线 | 为时序事件提供人员 ID;固定机位、稀疏人员适用;多人交叉可能换 ID,待有回归素材后再评估 ByteTrack 或全局匹配。 | | 事件逻辑 | 纯 Python 模块 + pytest | 已定 | 与 GUI 解耦,便于正反例录像回归。 | | 本地配置 | JSON 示例文件 + 未跟踪的 `config.local.json` | 已定 | 不把 RTSP 凭证提交仓库。 | | 日志与截图 | JSONL + PNG/JPG 文件 | 已定 | 无数据库的可审计演示证据。 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index d0bcf64..c24933f 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -13,6 +13,8 @@ Pose 推理适配器(person box + 17 keypoints) ↓ 姿态质量门控 + 倒地证据提取 ↓ +倒地领域规则(突发下移 → 水平姿态) + ↓ 每人摔倒事件状态机 ↓ 确认事件 @@ -34,7 +36,9 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一 | Pose 适配器 | `v1/pose.py` | 校验锁定模型的 SHA-256、pose/person/17×3 契约,统一返回 box、关键点、置信度 | 跟踪、摔倒业务结论 | | 跟踪 | `v1/tracking.py` | 以归一化 box 中心距离为连续人员输出稳定 `track_id` | 根据姿态报警 | | 质量与证据 | `v1/evidence.py` | 拒绝缺失肩/髋/膝/踝的姿态,计算水平姿态和躯干归一化下移证据 | GUI 状态、确认事件 | -| 状态机 | `v1/fall_state.py` | 管理每个 ID 的 NORMAL、SUSPECT、CONFIRMED、RECOVERING,并在首次确认时产生一个带确认延迟的事件 | 播放声音或存文件 | +| 倒地领域规则 | `v1/fall_policy.py` | 将每人连续的 `PoseEvidence` 映射为状态机 `Evidence`;要求快速下移后在 suspect 窗口内转为水平,确认后才接受恢复证据 | GUI、报警副作用、模型推理 | +| 事件管线 | `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/tests/` 与 `v1/scripts/` | 回放录像、断言事件和延迟 | 生产 UI | @@ -43,7 +47,7 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一 PyQt 主窗口只包含实时监控和设置两个顶部 Tab。实时监控 Tab 保持视频画面优先;设置 Tab 不能直接调用视频源、Pose 或状态机。 -设置页面产生的是已校验的草稿配置。用户点击开始监控时,应用创建不可变的运行配置快照与配置版本,并将该快照传给视频源、Pose、证据和状态机。运行期间编辑设置不会修改该快照;保存后的草稿在下一次开始监控时才会成为新的运行配置。FallEvent 继续记录 config_version,使截图和 JSONL 可以追溯到实际阈值。 +设置页面产生的是已校验的草稿配置。用户点击开始监控时,应用创建不可变的运行配置快照与配置版本,并将该快照传给视频源、Pose、证据和状态机。运行期间编辑设置不会修改该快照;保存后的草稿在下一次开始监控时才会成为新的运行配置。`AppConfig.runtime_config_version` 从非敏感来源 ID、锁定模型与事件参数计算,不含 RTSP 地址或凭证;`FallEvent` 继续记录它,使截图和 JSONL 可以追溯到实际阈值。 真实 RTSP 凭证仍只由环境变量或未跟踪本地配置提供。UI 只显示环境变量是否就绪,不能回显或记录具体值。 @@ -53,10 +57,10 @@ PyQt 主窗口只包含实时监控和设置两个顶部 Tab。实时监控 Tab ```text NORMAL - └─ 高质量证据显示快速下移或倒地姿态 → SUSPECT + └─ 高质量证据显示快速下移,且在 suspect 窗口内转为水平姿态 → SUSPECT SUSPECT - ├─ 倒地证据在配置时间窗内持续 → CONFIRMED(产生一次 FallEvent) - └─ 证据消失 → NORMAL + ├─ 水平倒地证据在 confirm 时间窗内持续 → CONFIRMED(产生一次 FallEvent) + └─ 缺失、低质量或非倒地证据 → NORMAL CONFIRMED └─ 经配置冷却与恢复稳定站立 → RECOVERING RECOVERING @@ -66,6 +70,8 @@ RECOVERING 状态机只以秒和单调时间为准,不以固定帧数为准。这样 15 FPS、30 FPS、丢帧或录像回放速度变化不会改变 1–3 秒业务目标。 +录像源和实时流使用不同的计时策略:回放优先容器 PTS、再回退到帧序号/FPS;实时流以成功读帧时的 `time.monotonic()` 计时。任意非连接帧、人员缺帧或低质量姿态都会向该人员输入拒绝证据,不能被计入连续倒地确认时间。 + ## 数据和文件 | 数据 | 位置 | 规则 | @@ -86,6 +92,7 @@ RECOVERING | 单帧误报 | 当前 `demo/` 任一规则命中即报警 | V1 用质量门控、跟踪和时序状态机;反例录像必测。 | | 俯视关键点不稳 | 1.6 米俯视、遮挡或远距离会影响膝踝 | 全身可见前提、ROI、质量拒绝和现场回归;必要时再采集数据。 | | RTSP 断流 | 网络抖动或摄像头重连 | 视频源显式状态、指数退避重连、断流不报警。 | +| 多人交叉换 ID | 当前 V1 使用贪心中心点匹配,未做全局最优匹配 | 固定机位、稀疏人员演示中记录为已知限制;多人密集场景在有回归素材后再评估升级。 | | 同一事件重复报警 | CONFIRMED 状态持续多帧 | 每个事件 ID 仅执行一次报警副作用,恢复后才允许新事件。 | | Go 行为漂移 | ONNX 预后处理与 Python 不同 | 导出后跑同一录像,比较关键点、事件数量、确认时间和截图。 | | 模型误解 | Pose 指标被误当摔倒指标 | 文案仅说明姿态模型;事件级指标单独记录。 | @@ -111,7 +118,9 @@ v1/ ├── pose.py ├── tracking.py ├── evidence.py +├── fall_policy.py ├── fall_state.py +├── pipeline.py ├── alerts.py ├── models/ ├── scripts/ diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 7d999eb..5a843c3 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -19,7 +19,7 @@ | T-103 | 实现 Pose 适配器与模型来源校验 | T-102 | 输出 person box、17 点和置信度;错误模型或哈希不符时给出明确错误。 | DONE | | T-104 | 实现人员跟踪与姿态质量门控 | T-103 | 连续人员维持 ID;低质量、缺失膝踝或空帧不会产生倒地候选。 | DONE | | T-105 | 实现按 ID 的时序摔倒状态机 | T-104 | 正例在配置秒数内确认;坐下、弯腰、短时低姿态回到 NORMAL;事件副作用只触发一次。 | DONE | -| T-106 | 装配事件管线与固化事件契约 | T-105 | Pose → 跟踪 → 质量/几何证据 → 领域规则 → 按 ID 状态机可回放运行;缺失/低质量证据会中断确认;全部公开事件参数生效;事件可追溯运行配置版本;录像 EOF 不会重放。 | DOING | +| T-106 | 装配事件管线与固化事件契约 | T-105 | Pose → 跟踪 → 质量/几何证据 → 领域规则 → 按 ID 状态机可回放运行;缺失/低质量证据会中断确认;全部公开事件参数生效;事件可追溯运行配置版本;录像 EOF 不会重放。 | DONE | ## Phase 2 · V1 演示闭环 diff --git a/docs/api.md b/docs/api.md index 3f1e532..ce43910 100644 --- a/docs/api.md +++ b/docs/api.md @@ -32,7 +32,7 @@ ``` - `rtsp_url_env` 必填;应用从同名环境变量读取真实 URL。 -- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。 +- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。 - 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。 ## 核心数据 @@ -66,6 +66,7 @@ PoseEvidence = { FallEvent = { event_id: string, track_id: string, + config_version: string, suspected_at_monotonic: float, confirmed_at_monotonic: float, latency_seconds: float, @@ -73,7 +74,7 @@ FallEvent = { } ``` -`FallEvent` 是 T-105 状态机的纯内存确认事件,只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。T-202 的 `alerts` 会在不改变事件幂等语义的前提下,为截图/JSONL 记录补充来源、UTC 时间、证据和配置版本。 +`FallEvent` 是状态机的纯内存确认事件,只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。`config_version` 是由运行配置快照计算的非敏感版本标识;T-202 的 `alerts` 会在不改变事件幂等语义的前提下,为截图/JSONL 记录补充来源、UTC 时间和证据。 `PersonPose` 是 T-103 的纯模型输出,不带人员 ID;T-104 的跟踪模块产生 `TrackedPersonPose` 后,才允许事件证据按人员连续积累。 @@ -88,7 +89,8 @@ FramePacket = { } ``` -- 录像优先使用容器时间戳;首帧时间戳无效或倒退时,回退为帧序号/FPS,保证回放时间单调。 +- `SourceMode.REPLAY` 的录像优先使用容器时间戳;首帧时间戳无效或倒退时,回退为帧序号/FPS,保证回放时间单调;到达 EOF 后保持 EOF,不重放。 +- `SourceMode.STREAM` 的实时流以成功读帧时的单调时钟计时;读取失败进入有界指数退避重连,不使用 `CAP_PROP_POS_MSEC` 作为事件时间。 - `retrying`、`error`、`eof` 和 `closed` 都没有图像,且绝不伪造人员、姿态或摔倒事件。 - 可重连来源以有界指数退避重新打开;断流不推进状态机的证据时间。 diff --git a/docs/current-state.md b/docs/current-state.md index bad74df..f484f5c 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,17 +5,17 @@ ## 当前快照 - 日期:2026-07-21 -- 阶段:V1 事件引擎基线已建立;T-106(事件管线与事件契约)进行中。 +- 阶段:V1 事件管线与事件契约已建立;T-106 已验收,等待 T-201。 - 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。 - 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。 -- V1 代码:已建立安全配置、视频源、Pose、跟踪、质量/几何证据,以及 `v1/fall_state.py` 的按 ID 四态事件机;Pose/跟踪/证据到状态机的事件管线与事件契约正在由 T-106 装配。PyQt GUI、声音、弹窗、截图、JSONL 和真实 RTSP 接入尚未实现。 +- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。PyQt GUI、声音、弹窗、截图、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` 当前有 20 项配置/视频源/Pose/跟踪/证据/状态机测试并已通过。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒,首帧 Pose smoke 得到 2 名人员、每人 17 点。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。 +- 测试:`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 测试,但不会安装软件包。 - 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。 - 当前标准启动:`./init.ps1`。 - 当前标准验证:`python -m compileall -q demo`。 -- 当前 blocker:T-106 尚未完成 Pose/跟踪/证据到状态机的事件管线、缺失证据中断语义与运行配置版本契约;回归录像及事件标签尚未创建;真实海康 RTSP 流尚未接入。 +- 当前 blocker:尚无带标签的正反例回归录像及事件标签;真实海康 RTSP 流尚未接入,实时流单调计时策略尚待 T-203 在现场验证;PyQt 与本地报警闭环尚未实现。 全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。 @@ -33,8 +33,8 @@ ## 任务状态 - 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)、T-104(跟踪与姿态质量证据)、T-105(按 ID 时序状态机)。 -- 正在进行:T-106(装配事件管线与固化事件契约)。 -- 下一个可领取:无;完成 T-106 后为 T-201。 +- 正在进行:无。 +- 下一个可领取:T-201(顶部双 Tab 的 PyQt 监控与设置界面)。 ## 当前可运行内容 diff --git a/progress.md b/progress.md index df17192..9e64d1d 100644 --- a/progress.md +++ b/progress.md @@ -170,3 +170,12 @@ - 阻塞:无。 - 决策:不把事件判断规则混入 T-201 的 PyQt UI;T-106 先提供真实、可测试的管线输出。`demo/` 保持未跟踪旧基线,不纳入本任务提交。 - 下一步:先为领域规则、缺失证据、配置版本和来源模式写失败测试,再作最小实现。 + +## 【2026-07-21】T-106 装配事件管线与固化事件契约(完成) + +- 状态:DONE +- 变更:新增 `v1/fall_policy.py`,将快速下移后在 suspect 窗口内形成的水平姿态映射为候选,并只在确认/恢复状态处理恢复证据;新增 `v1/pipeline.py`,将 Pose、跟踪、质量、几何证据、领域规则和按 ID 状态机连接。缺失人员、低质量姿态和非连接帧均向状态机输入拒绝证据,不能跨空档确认。`FallEvent` 现在包含运行配置的非敏感 `config_version`;`cooldown_seconds` 在确认后延迟恢复判断。视频源新增显式 `SourceMode.REPLAY`/`STREAM`:录像 EOF 不重放,实时流以收帧单调时钟计时并重连。 +- 验证:以失败测试先后覆盖领域规则、配置版本、cooldown、Replay EOF、Stream 计时、完整管线确认及缺帧中断;最终 `python -m pytest v1/tests -v` 为 30 passed,`python -m compileall -q v1 demo` 退出码 0。只读真实 smoke 使用 `demo/best.pt` 与 `demo/1.mp4` 驱动 T-106 管线两帧,结果为首帧 `connected/2 people/0 events`、第二帧 `connected/0 people/0 events`。 +- 阻塞:无任务内 blocker。该 smoke 未使用带标签摔倒/反例录像,不能作为事件准确率、1–3 秒延迟或海康 RTSP 现场验收证据。 +- 决策:`suspect_window_seconds` 定义为“快速下移到水平姿态”的最大间隔,`cooldown_seconds` 定义为确认后开始恢复判断的最短等待时间;运行配置版本排除 RTSP 地址和凭证。不会仅为延长确认而增大 tracker 的身份寿命,证据连续性由管线的拒绝证据保证。当前轻量跟踪多人交叉换 ID 记录为已知限制。 +- 下一步:T-201,实现顶部双 Tab 的 PyQt 监控与设置界面,只消费 T-106 的管线输出,不执行事件判定。 diff --git a/v1/config.py b/v1/config.py index 7200582..405c493 100644 --- a/v1/config.py +++ b/v1/config.py @@ -1,5 +1,6 @@ """Validated, credential-safe configuration loading for Silver Pose V1.""" +import hashlib import json import os import re @@ -31,6 +32,25 @@ class AppConfig: event: EventConfig event_dir: Path + @property + def runtime_config_version(self) -> str: + """Return a stable, non-secret identifier for the active event settings.""" + + payload = { + "source_id": self.source_id, + "model_sha256": self.model_sha256, + "confidence_threshold": self.confidence_threshold, + "event": { + "keypoint_confidence_threshold": self.event.keypoint_confidence_threshold, + "suspect_window_seconds": self.event.suspect_window_seconds, + "confirm_window_seconds": self.event.confirm_window_seconds, + "recovery_window_seconds": self.event.recovery_window_seconds, + "cooldown_seconds": self.event.cooldown_seconds, + }, + } + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return "cfg-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + _ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") diff --git a/v1/fall_policy.py b/v1/fall_policy.py new file mode 100644 index 0000000..f49ec33 --- /dev/null +++ b/v1/fall_policy.py @@ -0,0 +1,45 @@ +"""Translate per-track pose facts into state-machine evidence.""" + +from typing import Dict + +from v1.evidence import PoseEvidence +from v1.fall_state import Evidence, FallState + + +class FallEvidencePolicy: + """Require a recent rapid drop before a horizontal pose becomes a candidate.""" + + def __init__(self, suspect_window_seconds: float) -> None: + self._suspect_window_seconds = float(suspect_window_seconds) + self._rapid_drop_at: Dict[str, float] = {} + + def evaluate( + self, + track_id: str, + pose_evidence: PoseEvidence, + now: float, + state: FallState, + ) -> Evidence: + timestamp = float(now) + if not pose_evidence.accepted: + self._rapid_drop_at.pop(track_id, None) + return Evidence(accepted=False, is_fall_candidate=False) + if pose_evidence.rapid_vertical_change: + self._rapid_drop_at[track_id] = timestamp + + candidate = False + if state is FallState.SUSPECT: + candidate = pose_evidence.horizontal_pose + elif pose_evidence.horizontal_pose and track_id in self._rapid_drop_at: + candidate = timestamp - self._rapid_drop_at[track_id] <= self._suspect_window_seconds + + recovery = ( + state in (FallState.CONFIRMED, FallState.RECOVERING) + and not pose_evidence.horizontal_pose + and not pose_evidence.rapid_vertical_change + ) + return Evidence( + accepted=pose_evidence.accepted, + is_fall_candidate=candidate, + is_recovery_candidate=recovery, + ) diff --git a/v1/fall_state.py b/v1/fall_state.py index 5b695e9..1f12954 100644 --- a/v1/fall_state.py +++ b/v1/fall_state.py @@ -23,6 +23,7 @@ class Evidence: class FallEvent: event_id: str track_id: str + config_version: str suspected_at_monotonic: float confirmed_at_monotonic: float latency_seconds: float @@ -33,6 +34,7 @@ class FallEvent: class _Record: state: FallState suspect_started_at: Optional[float] = None + confirmed_at: Optional[float] = None recovery_started_at: Optional[float] = None last_updated_at: Optional[float] = None @@ -40,13 +42,25 @@ class _Record: class FallStateMachine: """Confirm only uninterrupted, accepted fall evidence for one tracked person.""" - def __init__(self, confirm_window_seconds: float, recovery_window_seconds: float) -> None: + def __init__( + self, + confirm_window_seconds: float, + recovery_window_seconds: float, + config_version: str, + cooldown_seconds: float = 0.0, + ) -> None: if not 1.0 <= confirm_window_seconds <= 3.0: raise ValueError("confirm_window_seconds must be between 1 and 3 seconds") if recovery_window_seconds <= 0: raise ValueError("recovery_window_seconds must be positive") + if cooldown_seconds < 0: + raise ValueError("cooldown_seconds must be non-negative") + if not isinstance(config_version, str) or not config_version.strip(): + raise ValueError("config_version must be a non-empty string") self._confirm_window_seconds = float(confirm_window_seconds) self._recovery_window_seconds = float(recovery_window_seconds) + self._cooldown_seconds = float(cooldown_seconds) + self._config_version = config_version.strip() self._records: Dict[str, _Record] = {} self._next_event_number = 1 @@ -79,13 +93,17 @@ class FallStateMachine: return [] if timestamp - record.suspect_started_at >= self._confirm_window_seconds: record.state = FallState.CONFIRMED + record.confirmed_at = timestamp event = self._new_event(track_id, record.suspect_started_at, timestamp) record.suspect_started_at = None return [event] return [] if record.state is FallState.CONFIRMED: - if evidence.is_recovery_candidate: + if ( + evidence.is_recovery_candidate + and timestamp - record.confirmed_at >= self._cooldown_seconds + ): record.state = FallState.RECOVERING record.recovery_started_at = timestamp return [] @@ -117,6 +135,7 @@ class FallStateMachine: def _set_normal(record: _Record) -> None: record.state = FallState.NORMAL record.suspect_started_at = None + record.confirmed_at = None record.recovery_started_at = None def _new_event( @@ -125,6 +144,7 @@ class FallStateMachine: event = FallEvent( event_id="FALL-{0:06d}".format(self._next_event_number), track_id=track_id, + config_version=self._config_version, suspected_at_monotonic=suspected_at, confirmed_at_monotonic=confirmed_at, latency_seconds=confirmed_at - suspected_at, diff --git a/v1/pipeline.py b/v1/pipeline.py new file mode 100644 index 0000000..223546d --- /dev/null +++ b/v1/pipeline.py @@ -0,0 +1,139 @@ +"""Compose Pose, tracking, evidence policy, and temporal fall state.""" + +from dataclasses import dataclass +from typing import Dict, Sequence, Tuple + +from v1.config import AppConfig +from v1.evidence import PoseEvidence, assess_pose_quality, extract_evidence +from v1.fall_policy import FallEvidencePolicy +from v1.fall_state import Evidence, FallEvent, FallState, FallStateMachine +from v1.tracking import PersonTracker, TrackedPersonPose +from v1.video_source import FramePacket, SourceStatus + + +@dataclass(frozen=True) +class PersonAnalysis: + tracked_pose: TrackedPersonPose + pose_evidence: PoseEvidence + state: FallState + + +@dataclass(frozen=True) +class FrameAnalysis: + packet: FramePacket + people: Tuple[PersonAnalysis, ...] + events: Tuple[FallEvent, ...] + + +class FallPipeline: + """Run one source frame through the V1 event decision flow.""" + + def __init__( + self, + pose_adapter, + tracker: PersonTracker, + policy: FallEvidencePolicy, + state_machine: FallStateMachine, + keypoint_confidence_threshold: float, + ) -> None: + if not 0.0 <= keypoint_confidence_threshold <= 1.0: + raise ValueError("keypoint_confidence_threshold must be between 0 and 1") + self._pose_adapter = pose_adapter + self._tracker = tracker + self._policy = policy + self._state_machine = state_machine + self._keypoint_confidence_threshold = float(keypoint_confidence_threshold) + self._previous_evidence: Dict[str, PoseEvidence] = {} + self._active_track_ids = set() + + @classmethod + def from_config(cls, config: AppConfig, pose_adapter) -> "FallPipeline": + """Create one immutable runtime decision flow from validated config.""" + + return cls( + pose_adapter=pose_adapter, + tracker=PersonTracker(), + policy=FallEvidencePolicy(config.event.suspect_window_seconds), + state_machine=FallStateMachine( + confirm_window_seconds=config.event.confirm_window_seconds, + recovery_window_seconds=config.event.recovery_window_seconds, + cooldown_seconds=config.event.cooldown_seconds, + config_version=config.runtime_config_version, + ), + keypoint_confidence_threshold=config.event.keypoint_confidence_threshold, + ) + + def process(self, packet: FramePacket) -> FrameAnalysis: + if packet.status is not SourceStatus.CONNECTED or packet.image is None: + events = self._reject_active_tracks(packet.timestamp_monotonic) + return FrameAnalysis(packet=packet, people=(), events=tuple(events)) + + height, width = packet.image.shape[:2] + poses = self._pose_adapter.infer(packet.image) + tracked_poses = self._tracker.update( + poses, + detected_at_monotonic=packet.timestamp_monotonic, + frame_size=(width, height), + ) + current_ids = {tracked.track_id for tracked in tracked_poses} + events = self._reject_missing_tracks(current_ids, packet.timestamp_monotonic) + people = [] + for tracked in tracked_poses: + quality = assess_pose_quality( + tracked.pose, threshold=self._keypoint_confidence_threshold + ) + pose_evidence = extract_evidence( + tracked.pose, + quality=quality, + previous=self._previous_evidence.get(tracked.track_id), + ) + state_before = self._state_machine.state_of(tracked.track_id) + evidence = self._policy.evaluate( + tracked.track_id, + pose_evidence, + now=packet.timestamp_monotonic, + state=state_before, + ) + events.extend( + self._state_machine.update( + tracked.track_id, evidence, now=packet.timestamp_monotonic + ) + ) + if pose_evidence.accepted: + self._previous_evidence[tracked.track_id] = pose_evidence + else: + self._previous_evidence.pop(tracked.track_id, None) + people.append( + PersonAnalysis( + tracked_pose=tracked, + pose_evidence=pose_evidence, + state=self._state_machine.state_of(tracked.track_id), + ) + ) + self._active_track_ids = current_ids + return FrameAnalysis(packet=packet, people=tuple(people), events=tuple(events)) + + def _reject_missing_tracks(self, current_ids: set, now: float) -> list: + missing_ids = self._active_track_ids - current_ids + events = [] + for track_id in sorted(missing_ids): + events.extend(self._reject_track(track_id, now)) + return events + + def _reject_active_tracks(self, now: float) -> list: + events = [] + for track_id in sorted(self._active_track_ids): + events.extend(self._reject_track(track_id, now)) + self._active_track_ids = set() + return events + + def _reject_track(self, track_id: str, now: float) -> Sequence[FallEvent]: + rejected = Evidence(accepted=False, is_fall_candidate=False) + self._previous_evidence.pop(track_id, None) + self._policy.evaluate( + track_id, + PoseEvidence(False, False, False, None, None, None, "missing_pose"), + now=now, + state=self._state_machine.state_of(track_id), + ) + return self._state_machine.update(track_id, rejected, now=now) diff --git a/v1/tests/test_config.py b/v1/tests/test_config.py index 795127d..341cd8a 100644 --- a/v1/tests/test_config.py +++ b/v1/tests/test_config.py @@ -54,3 +54,18 @@ def test_load_config_rejects_embedded_source_address(tmp_path): with pytest.raises(ConfigError, match="rtsp_url_env"): load_config(config_file) + + +def test_runtime_config_version_is_stable_and_excludes_rtsp_address(tmp_path, monkeypatch): + config_file = tmp_path / "config.json" + _write_config( + config_file, + {"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL"}, + ) + monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@camera-a/live") + first = load_config(config_file) + monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:other-secret@camera-b/live") + second = load_config(config_file) + + assert first.runtime_config_version == second.runtime_config_version + assert first.runtime_config_version.startswith("cfg-") diff --git a/v1/tests/test_fall_policy.py b/v1/tests/test_fall_policy.py new file mode 100644 index 0000000..e123a5c --- /dev/null +++ b/v1/tests/test_fall_policy.py @@ -0,0 +1,53 @@ +from v1.evidence import PoseEvidence +from v1.fall_policy import FallEvidencePolicy +from v1.fall_state import FallState + + +def _evidence(horizontal=False, rapid_drop=False, accepted=True): + return PoseEvidence( + accepted=accepted, + horizontal_pose=horizontal, + rapid_vertical_change=rapid_drop, + horizontal_angle_degrees=10.0 if horizontal else 80.0, + hip_center_y=100.0, + torso_length=50.0, + reason="accepted" if accepted else "missing_pose", + ) + + +def test_rapid_drop_followed_by_horizontal_pose_within_suspect_window_starts_candidate(): + policy = FallEvidencePolicy(suspect_window_seconds=0.5) + + onset = policy.evaluate( + "P-0001", _evidence(rapid_drop=True), now=0.0, state=FallState.NORMAL + ) + candidate = policy.evaluate( + "P-0001", _evidence(horizontal=True), now=0.5, state=FallState.NORMAL + ) + + assert onset.is_fall_candidate is False + assert candidate.is_fall_candidate is True + + +def test_rejected_pose_clears_pending_drop_before_the_next_horizontal_pose(): + policy = FallEvidencePolicy(suspect_window_seconds=0.5) + + policy.evaluate("P-0001", _evidence(rapid_drop=True), now=0.0, state=FallState.NORMAL) + policy.evaluate("P-0001", _evidence(accepted=False), now=0.1, state=FallState.NORMAL) + candidate = policy.evaluate( + "P-0001", _evidence(horizontal=True), now=0.2, state=FallState.NORMAL + ) + + assert candidate.accepted is True + assert candidate.is_fall_candidate is False + + +def test_upright_pose_is_recovery_evidence_only_after_confirmation(): + policy = FallEvidencePolicy(suspect_window_seconds=0.5) + + recovery = policy.evaluate( + "P-0001", _evidence(horizontal=False), now=4.0, state=FallState.CONFIRMED + ) + + assert recovery.accepted is True + assert recovery.is_recovery_candidate is True diff --git a/v1/tests/test_fall_state.py b/v1/tests/test_fall_state.py index 5941d5e..521bda8 100644 --- a/v1/tests/test_fall_state.py +++ b/v1/tests/test_fall_state.py @@ -3,8 +3,15 @@ import pytest from v1.fall_state import Evidence, FallState, FallStateMachine +CONFIG_VERSION = "cfg-test-20260721" + + def test_confirmed_event_is_emitted_once_after_persistent_evidence(): - machine = FallStateMachine(confirm_window_seconds=1.8, recovery_window_seconds=2.0) + machine = FallStateMachine( + confirm_window_seconds=1.8, + recovery_window_seconds=2.0, + config_version=CONFIG_VERSION, + ) assert machine.update("P-0007", Evidence(True, True), now=0.0) == [] events = machine.update("P-0007", Evidence(True, True), now=1.8) @@ -12,12 +19,17 @@ def test_confirmed_event_is_emitted_once_after_persistent_evidence(): assert len(events) == 1 assert events[0].track_id == "P-0007" assert events[0].latency_seconds == 1.8 + assert events[0].config_version == CONFIG_VERSION assert machine.update("P-0007", Evidence(True, True), now=2.0) == [] assert machine.state_of("P-0007") is FallState.CONFIRMED def test_brief_low_posture_returns_to_normal_without_event(): - machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0) + machine = FallStateMachine( + confirm_window_seconds=1.0, + recovery_window_seconds=2.0, + config_version=CONFIG_VERSION, + ) machine.update("P-0007", Evidence(True, True), now=0.0) events = machine.update("P-0007", Evidence(True, False), now=0.3) @@ -27,7 +39,11 @@ def test_brief_low_posture_returns_to_normal_without_event(): def test_rejected_pose_resets_suspect_and_cannot_shorten_confirmation_window(): - machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0) + machine = FallStateMachine( + confirm_window_seconds=1.0, + recovery_window_seconds=2.0, + config_version=CONFIG_VERSION, + ) machine.update("P-0007", Evidence(True, True), now=0.0) machine.update("P-0007", Evidence(False, False), now=0.9) @@ -38,7 +54,11 @@ def test_rejected_pose_resets_suspect_and_cannot_shorten_confirmation_window(): def test_recovery_must_persist_before_new_event_is_allowed(): - machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0) + machine = FallStateMachine( + confirm_window_seconds=1.0, + recovery_window_seconds=2.0, + config_version=CONFIG_VERSION, + ) machine.update("P-0007", Evidence(True, True), now=0.0) first_event = machine.update("P-0007", Evidence(True, True), now=1.0) assert len(first_event) == 1 @@ -55,11 +75,19 @@ def test_recovery_must_persist_before_new_event_is_allowed(): def test_confirmation_window_must_remain_within_customer_target(): with pytest.raises(ValueError, match="between 1 and 3"): - FallStateMachine(confirm_window_seconds=0.9, recovery_window_seconds=2.0) + FallStateMachine( + confirm_window_seconds=0.9, + recovery_window_seconds=2.0, + config_version=CONFIG_VERSION, + ) def test_each_track_has_an_independent_confirmation_window(): - machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0) + machine = FallStateMachine( + confirm_window_seconds=1.0, + recovery_window_seconds=2.0, + config_version=CONFIG_VERSION, + ) machine.update("P-0001", Evidence(True, True), now=0.0) machine.update("P-0002", Evidence(True, True), now=0.6) @@ -69,3 +97,20 @@ def test_each_track_has_an_independent_confirmation_window(): assert first_events[0].track_id == "P-0001" assert second_events[0].track_id == "P-0002" assert first_events[0].event_id != second_events[0].event_id + + +def test_cooldown_delays_recovery_after_confirmation(): + machine = FallStateMachine( + confirm_window_seconds=1.0, + recovery_window_seconds=2.0, + config_version=CONFIG_VERSION, + cooldown_seconds=3.0, + ) + machine.update("P-0007", Evidence(True, True), now=0.0) + machine.update("P-0007", Evidence(True, True), now=1.0) + + machine.update("P-0007", Evidence(True, False, True), now=1.1) + assert machine.state_of("P-0007") is FallState.CONFIRMED + + machine.update("P-0007", Evidence(True, False, True), now=4.0) + assert machine.state_of("P-0007") is FallState.RECOVERING diff --git a/v1/tests/test_pipeline.py b/v1/tests/test_pipeline.py new file mode 100644 index 0000000..d451824 --- /dev/null +++ b/v1/tests/test_pipeline.py @@ -0,0 +1,121 @@ +import numpy as np +from pathlib import Path + +from v1.config import AppConfig, EventConfig +from v1.fall_policy import FallEvidencePolicy +from v1.fall_state import FallState, FallStateMachine +from v1.pipeline import FallPipeline +from v1.pose import Keypoint, PersonPose +from v1.tracking import PersonTracker +from v1.video_source import FramePacket, SourceStatus + + +class _SequencePoseAdapter: + def __init__(self, frames): + self._frames = iter(frames) + + def infer(self, _image): + return next(self._frames) + + +def _pose(horizontal=False): + points = [Keypoint(float(index), float(index), 0.9) for index in range(17)] + if horizontal: + points[5] = Keypoint(20.0, 80.0, 0.9) + points[6] = Keypoint(30.0, 80.0, 0.9) + points[11] = Keypoint(70.0, 100.0, 0.9) + points[12] = Keypoint(80.0, 100.0, 0.9) + else: + points[5] = Keypoint(30.0, 10.0, 0.9) + points[6] = Keypoint(40.0, 10.0, 0.9) + points[11] = Keypoint(30.0, 30.0, 0.9) + points[12] = Keypoint(40.0, 30.0, 0.9) + return PersonPose( + box_xyxy=(20.0, 20.0, 160.0, 160.0), + box_confidence=0.9, + keypoints=tuple(points), + ) + + +def _packet(timestamp): + return FramePacket( + image=np.zeros((180, 180, 3), dtype=np.uint8), + timestamp_monotonic=timestamp, + status=SourceStatus.CONNECTED, + ) + + +def _pipeline(frames): + machine = FallStateMachine( + confirm_window_seconds=1.0, + recovery_window_seconds=2.0, + config_version="cfg-test-pipeline", + ) + return ( + FallPipeline( + pose_adapter=_SequencePoseAdapter(frames), + tracker=PersonTracker(), + policy=FallEvidencePolicy(suspect_window_seconds=0.5), + state_machine=machine, + keypoint_confidence_threshold=0.4, + ), + machine, + ) + + +def _config(): + return AppConfig( + source_id="lobby-camera-01", + source_url="rtsp://not-written-to-disk/live", + model_path=Path("models/best.pt"), + model_sha256="a" * 64, + confidence_threshold=0.25, + event=EventConfig( + keypoint_confidence_threshold=0.4, + suspect_window_seconds=0.5, + confirm_window_seconds=1.0, + recovery_window_seconds=2.0, + cooldown_seconds=3.0, + ), + event_dir=Path("artifacts/events"), + ) + + +def test_pipeline_confirms_a_recent_drop_that_remains_horizontal(): + pipeline, machine = _pipeline([(_pose(),), (_pose(horizontal=True),), (_pose(horizontal=True),)]) + + pipeline.process(_packet(0.0)) + pipeline.process(_packet(0.1)) + result = pipeline.process(_packet(1.1)) + + assert len(result.events) == 1 + assert result.events[0].config_version == "cfg-test-pipeline" + assert machine.state_of("P-0001") is FallState.CONFIRMED + + +def test_pipeline_rejects_a_suspect_when_the_track_is_missing_for_one_frame(): + pipeline, machine = _pipeline([(_pose(),), (_pose(horizontal=True),), (), (_pose(horizontal=True),)]) + + pipeline.process(_packet(0.0)) + pipeline.process(_packet(0.1)) + pipeline.process(_packet(0.2)) + result = pipeline.process(_packet(1.1)) + + assert result.events == () + assert machine.state_of("P-0001") is FallState.NORMAL + + +def test_pipeline_from_config_uses_runtime_version_for_confirmed_event(): + config = _config() + pipeline = FallPipeline.from_config( + config, + pose_adapter=_SequencePoseAdapter( + [(_pose(),), (_pose(horizontal=True),), (_pose(horizontal=True),)] + ), + ) + + pipeline.process(_packet(0.0)) + pipeline.process(_packet(0.1)) + result = pipeline.process(_packet(1.1)) + + assert result.events[0].config_version == config.runtime_config_version diff --git a/v1/tests/test_video_source.py b/v1/tests/test_video_source.py index fcb7bcd..ce4ecff 100644 --- a/v1/tests/test_video_source.py +++ b/v1/tests/test_video_source.py @@ -2,7 +2,7 @@ import cv2 import numpy as np import pytest -from v1.video_source import SourceStatus, VideoSource +from v1.video_source import SourceMode, SourceStatus, VideoSource def _write_sample_video(path): @@ -18,7 +18,7 @@ def _write_sample_video(path): def test_file_source_emits_monotonic_timestamps(tmp_path): sample_video = tmp_path / "sample.avi" _write_sample_video(sample_video) - source = VideoSource(sample_video, reconnect=False) + source = VideoSource(sample_video, mode=SourceMode.REPLAY) first = source.read(now=10.0) second = source.read(now=10.1) @@ -31,7 +31,7 @@ def test_file_source_emits_monotonic_timestamps(tmp_path): def test_missing_source_returns_error_state_without_frame(tmp_path): - source = VideoSource(tmp_path / "missing.avi", reconnect=False) + source = VideoSource(tmp_path / "missing.avi", mode=SourceMode.REPLAY) packet = source.read(now=1.0) @@ -77,7 +77,7 @@ def test_reconnect_waits_then_reopens_with_bounded_backoff(): source = VideoSource( "demo-source", - reconnect=True, + mode=SourceMode.STREAM, retry_initial_seconds=2.0, retry_max_seconds=2.0, capture_factory=lambda _source: captures.pop(0), @@ -119,7 +119,9 @@ class _NegativeFirstTimestampCapture: def test_negative_first_timestamp_falls_back_to_frame_index_and_fps(): source = VideoSource( - "demo-source", reconnect=False, capture_factory=lambda _source: _NegativeFirstTimestampCapture() + "demo-source", + mode=SourceMode.REPLAY, + capture_factory=lambda _source: _NegativeFirstTimestampCapture(), ) first = source.read(now=10.0) @@ -127,3 +129,30 @@ def test_negative_first_timestamp_falls_back_to_frame_index_and_fps(): assert first.timestamp_monotonic == 0.0 assert second.timestamp_monotonic == pytest.approx(1.0 / 30.0) + + +def test_replay_source_reports_eof_without_restarting(tmp_path): + sample_video = tmp_path / "sample.avi" + _write_sample_video(sample_video) + source = VideoSource(sample_video, mode=SourceMode.REPLAY) + + for timestamp in (0.0, 0.1, 0.2): + assert source.read(now=timestamp).status is SourceStatus.CONNECTED + eof = source.read(now=0.3) + still_eof = source.read(now=0.4) + + assert eof.status is SourceStatus.EOF + assert still_eof.status is SourceStatus.EOF + + +def test_stream_source_uses_read_clock_instead_of_capture_timestamp(): + source = VideoSource( + "rtsp://not-a-real-address", + mode=SourceMode.STREAM, + capture_factory=lambda _source: _OpenCapture(), + ) + + packet = source.read(now=42.0) + + assert packet.status is SourceStatus.CONNECTED + assert packet.timestamp_monotonic == 42.0 diff --git a/v1/video_source.py b/v1/video_source.py index f2029f2..61ab92e 100644 --- a/v1/video_source.py +++ b/v1/video_source.py @@ -18,6 +18,11 @@ class SourceStatus(str, Enum): CLOSED = "closed" +class SourceMode(str, Enum): + REPLAY = "replay" + STREAM = "stream" + + @dataclass(frozen=True) class FramePacket: image: Optional[np.ndarray] @@ -35,7 +40,7 @@ class VideoSource: def __init__( self, source: Union[str, Path], - reconnect: bool = True, + mode: SourceMode, retry_initial_seconds: float = 1.0, retry_max_seconds: float = 16.0, capture_factory: Optional[CaptureFactory] = None, @@ -44,13 +49,15 @@ class VideoSource: raise ValueError("retry_initial_seconds must be positive") if retry_max_seconds < retry_initial_seconds: raise ValueError("retry_max_seconds must not be smaller than retry_initial_seconds") + if not isinstance(mode, SourceMode): + raise ValueError("mode must be a SourceMode") self._source = str(source) - self._reconnect = reconnect + self._mode = mode self._retry_initial_seconds = retry_initial_seconds self._retry_max_seconds = retry_max_seconds self._capture_factory = capture_factory or cv2.VideoCapture self._capture = None - self._status = SourceStatus.RETRYING if reconnect else SourceStatus.ERROR + self._status = SourceStatus.RETRYING if mode is SourceMode.STREAM else SourceStatus.ERROR self._retry_delay_seconds = retry_initial_seconds self._next_retry_at = 0.0 self._last_timestamp: Optional[float] = None @@ -77,6 +84,9 @@ class VideoSource: if self._closed: return self._packet(timestamp, SourceStatus.CLOSED, "source is closed") + if self._mode is SourceMode.REPLAY and self._status is SourceStatus.EOF: + return self._packet(timestamp, SourceStatus.EOF, self._last_error) + if self._capture is None: if self._status is SourceStatus.RETRYING and timestamp < self._next_retry_at: return self._packet(timestamp, SourceStatus.RETRYING, self._last_error) @@ -86,7 +96,7 @@ class VideoSource: success, image = self._capture.read() if not success or image is None: self._release_capture() - if self._reconnect: + if self._mode is SourceMode.STREAM: self._schedule_retry(timestamp, "frame read failed; retry scheduled") return self._packet(timestamp, SourceStatus.RETRYING, self._last_error) self._status = SourceStatus.EOF @@ -104,7 +114,7 @@ class VideoSource: if capture is None or not capture.isOpened(): if capture is not None: capture.release() - if self._reconnect: + if self._mode is SourceMode.STREAM: self._schedule_retry(now, "unable to open source; retry scheduled") else: self._status = SourceStatus.ERROR @@ -115,6 +125,9 @@ class VideoSource: return True def _frame_timestamp(self, fallback_now: float) -> float: + if self._mode is SourceMode.STREAM: + self._last_timestamp = fallback_now + return fallback_now source_seconds = float(self._capture.get(cv2.CAP_PROP_POS_MSEC)) / 1000.0 frame_seconds = self._timestamp_from_frame_index() if source_seconds < 0: