Serve replay playback from a cached analysis result
Replay re-ran the SDK chain on every seek; Part A's pass stores the flow samples once. Opening a recording now reuses a compatible result or runs the pass behind a progress/cancel surface, and one cursor with two modes (recorded CSV, analysed) feeds the charts, the 2D canvas and the 3D pane by frame id. A threshold, colour or idle-dot change redraws from the stored frames and writes nothing; a cancelled pass is reported and never played. Verified: apps/studio/tests 440 items / 0 failed / 18 skipped (15 new, no existing assertion changed); shared 417 / 0 / 1; autocal 358.
This commit is contained in:
@@ -155,7 +155,8 @@
|
||||
"units_newton": "Newton",
|
||||
"units_raw": "Raw",
|
||||
"settings": "Settings",
|
||||
"settings_tip": "Show or hide the unit, record-mode and preview controls"
|
||||
"settings_tip": "Show or hide the unit, record-mode and preview controls",
|
||||
"stopped_analysis": "analysis"
|
||||
},
|
||||
"units": {
|
||||
"newton_short": "N",
|
||||
@@ -218,7 +219,10 @@
|
||||
"mesh3d_rejected": "The 3D scene was refused: {message}\nOnly the 3D view is affected; measuring, recording and the charts work as usual.",
|
||||
"mesh3d_unavailable_gl_reason": "3D is not available on this machine: {reason}\nMeasuring, recording and the charts work as usual.",
|
||||
"mesh3d_settings_tip": "3D display settings (view, housing, colour scale, frame rate)",
|
||||
"mesh3d_reset_tip": "Reset view: back to the framing the scene itself uses"
|
||||
"mesh3d_reset_tip": "Reset view: back to the framing the scene itself uses",
|
||||
"analysis_pipeline": "Analysis result (one finished pass)",
|
||||
"analysis_pipeline_tip": "This frame's arrows come from a stored analysis result rather than a chain running now; changing the threshold only changes which stored samples count as active.",
|
||||
"analysis_empty": "the analysis result holds no frame here"
|
||||
},
|
||||
"chart": {
|
||||
"fn": "FN",
|
||||
@@ -278,7 +282,16 @@
|
||||
"warn_config_missing": "no config hash was recorded, so recomputation cannot be verified",
|
||||
"warn_rows_skipped": "some unparsable CSV rows were skipped",
|
||||
"warn_frames_dropped": "frames were lost to a full queue; only their index rows remain (shown as \"not recorded\")",
|
||||
"warn_out_of_order": "this recording was written by an older version: its rows are not in time order, so the replay re-sorts them and window edges may differ"
|
||||
"warn_out_of_order": "this recording was written by an older version: its rows are not in time order, so the replay re-sorts them and window edges may differ",
|
||||
"analysis_empty": "this analysis result holds no frame to show",
|
||||
"analysis_unreadable": "this frame cannot be read ({error})",
|
||||
"analysis_complete": "replaying from the analysis result: {frames} frames, cursor on signal time (the recording's own frames.csv)",
|
||||
"analysis_reused": "an analysis result was already stored and is being replayed: {frames} frames",
|
||||
"analysis_failed": "the analysis did not finish ({reason}): playback is not served. Re-run it, or turn 「从视频重算」 off to read the recorded forces.csv",
|
||||
"analysis_cancelled": "the analysis was cancelled: no complete result was written, so no playback is served",
|
||||
"analysis_no_source": "this recording has no video segment to analyse",
|
||||
"analysis_busy": "an analysis is already running: wait for it, or cancel it",
|
||||
"analysis_grid_mismatch": "the current display settings moved the sample grid ({keys}); the stored candidate locations cannot serve it, so the replay keeps drawing the result's own grid"
|
||||
},
|
||||
"record": {
|
||||
"label": "Record",
|
||||
@@ -497,6 +510,27 @@
|
||||
"severity_info": "Note",
|
||||
"severity_warning": "Warning",
|
||||
"severity_error": "Error",
|
||||
"stats": "shown {displayed} · display drops {dropped} · compose {compose} ms · paint {paint} ms"
|
||||
"stats": "shown {displayed} · display drops {dropped} · compose {compose} ms · paint {paint} ms",
|
||||
"source_analysis": "Analysis result: one finished pass, read by frame",
|
||||
"source_analysis_tip": "The picture comes from a stored analysis result: the chain has already run and its output is on disk, so scrubbing and playing are file reads. Changing a threshold or a colour does not recompute."
|
||||
},
|
||||
"analysis": {
|
||||
"title": "Analysing the recording",
|
||||
"subtitle": "once only: scrubbing, playing and stepping afterwards are reads",
|
||||
"stage_open": "open chain",
|
||||
"stage_frames": "frame by frame",
|
||||
"stage_write": "write result",
|
||||
"frames": "{written} / {expected} frames",
|
||||
"segment": "segment {index} / {count}",
|
||||
"elapsed": "{seconds:.1f} s elapsed",
|
||||
"cancel": "Cancel",
|
||||
"cancelling": "Cancelling…",
|
||||
"cancel_tip": "Cancel this pass: the frames already written stay, but the result is marked incomplete and is never played back as if it were whole",
|
||||
"reused": "a stored result is already there — nothing to recompute",
|
||||
"complete": "analysis finished: {frames} frames in {seconds:.1f} s",
|
||||
"cancelled": "analysis cancelled ({frames} frames written; the result is marked incomplete)",
|
||||
"failed": "analysis failed: {reason}",
|
||||
"incomplete": "the result is incomplete: {reason}",
|
||||
"blocked": "a session is running: stop it before analysing"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,8 @@
|
||||
"units_newton": "牛顿",
|
||||
"units_raw": "原始",
|
||||
"settings": "设置",
|
||||
"settings_tip": "展开或收起单位、记录模式与预览"
|
||||
"settings_tip": "展开或收起单位、记录模式与预览",
|
||||
"stopped_analysis": "分析"
|
||||
},
|
||||
"units": {
|
||||
"newton_short": "N",
|
||||
@@ -218,7 +219,10 @@
|
||||
"mesh3d_rejected": "三维场景被拒绝:{message}\n只影响三维视图:采集、记录与力曲线照常工作。",
|
||||
"mesh3d_unavailable_gl_reason": "本机无法使用三维显示:{reason}\n采集、记录与力曲线照常工作。",
|
||||
"mesh3d_settings_tip": "三维显示设置(视角、外壳、色标、显示频率)",
|
||||
"mesh3d_reset_tip": "复位视角:回到场景自带的取景"
|
||||
"mesh3d_reset_tip": "复位视角:回到场景自带的取景",
|
||||
"analysis_pipeline": "分析结果(已跑完的一遍)",
|
||||
"analysis_pipeline_tip": "这一帧的箭头来自存下来的分析结果,不是此刻在跑的链路;换阈值只改变哪些样本算「有效」,不会重算。",
|
||||
"analysis_empty": "分析结果里没有这一帧"
|
||||
},
|
||||
"chart": {
|
||||
"fn": "FN",
|
||||
@@ -278,7 +282,16 @@
|
||||
"warn_config_missing": "找不到配置哈希,无法校验重算一致性",
|
||||
"warn_rows_skipped": "有无法解析的 CSV 行被跳过",
|
||||
"warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)",
|
||||
"warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同"
|
||||
"warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同",
|
||||
"analysis_empty": "这个分析结果里没有可显示的帧",
|
||||
"analysis_unreadable": "这一帧读不出来({error})",
|
||||
"analysis_complete": "已用分析结果回放:{frames} 帧,游标按信号时间(录制自己的 frames.csv)",
|
||||
"analysis_reused": "找到了现成的分析结果,直接回放:{frames} 帧",
|
||||
"analysis_failed": "分析未完成({reason}):不提供回放。可以重新分析,或关闭「从视频重算」看录制的 forces.csv",
|
||||
"analysis_cancelled": "分析已取消:未写出完整结果,因此不提供回放",
|
||||
"analysis_no_source": "这段录制没有可用于分析的视频分段",
|
||||
"analysis_busy": "分析进行中:先等待或取消当前这一遍",
|
||||
"analysis_grid_mismatch": "当前显示设置改了采样网格({keys}),存下来的候选位置无法服务它:回放仍按结果自己的网格绘制"
|
||||
},
|
||||
"record": {
|
||||
"label": "记录",
|
||||
@@ -497,6 +510,27 @@
|
||||
"severity_info": "提示",
|
||||
"severity_warning": "警告",
|
||||
"severity_error": "错误",
|
||||
"stats": "已显示 {displayed} · 显示跳帧 {dropped} · 合成 {compose} ms · 绘制 {paint} ms"
|
||||
"stats": "已显示 {displayed} · 显示跳帧 {dropped} · 合成 {compose} ms · 绘制 {paint} ms",
|
||||
"source_analysis": "分析结果:已跑完的一遍,按帧读取",
|
||||
"source_analysis_tip": "画面来自存下来的分析结果:算法链路已经跑完并写入缓存,拖动时间轴与播放都只是读文件。换阈值或配色不会重算。"
|
||||
},
|
||||
"analysis": {
|
||||
"title": "正在分析录像",
|
||||
"subtitle": "只跑一遍:之后拖动时间轴、播放与单步都只是读结果",
|
||||
"stage_open": "打开链路",
|
||||
"stage_frames": "逐帧分析",
|
||||
"stage_write": "写入结果",
|
||||
"frames": "{written} / {expected} 帧",
|
||||
"segment": "分段 {index} / {count}",
|
||||
"elapsed": "已用 {seconds:.1f} s",
|
||||
"cancel": "取消",
|
||||
"cancelling": "正在取消…",
|
||||
"cancel_tip": "取消这一遍:已完成的帧会留下,但结果会被标记为未完成,永远不会被当作有效结果回放",
|
||||
"reused": "已有结果,无需重算",
|
||||
"complete": "分析完成:{frames} 帧,用时 {seconds:.1f} s",
|
||||
"cancelled": "分析已取消({frames} 帧已写完,结果标记为未完成)",
|
||||
"failed": "分析失败:{reason}",
|
||||
"incomplete": "结果未完成:{reason}",
|
||||
"blocked": "正在采集:停止后再分析"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# 回放分析结果(Part B:从结果回放)
|
||||
|
||||
本文是 [replay-analysis.md](replay-analysis.md) 的续篇:那一篇定义结果本身,这一篇定义播放端
|
||||
怎么读它。打开录像即取用或跑出结果,之后播放、拖动、单步与跳转**全部是读文件**。实现分布在
|
||||
`replay_analysis.py`(结果 → 时间轴与画面)、`analysis_worker.py`(pass 的线程边界与取消)、
|
||||
`ui/analysis_progress.py`(进度/取消面板)、`replay.py` 与 `replay_controller.py`(一个
|
||||
游标、两个模式),以及 `app_window.py` 的接线(第 12 节)。各节编号承接 Part A。
|
||||
|
||||
## 9. 一个时间轴,两个模式
|
||||
|
||||
游标只有一个:`ReplayEngine.position()` 的 `t_rel_s`。模式说明**画面从哪里读**:
|
||||
|
||||
| 模式 | 画面来源 | 说明 |
|
||||
|---|---|---|
|
||||
| `recording` | 录像自己的 `raw_*.mp4`,按需解码 | 只有原始相机图像:没有光流,也没有深度 |
|
||||
| `analysis` | 存下来的分析结果,按帧读 | 有 pass 记录下来的光流样本与候选网格 |
|
||||
|
||||
图表、二维画布、三维面板都读这一个游标,模式由 `ReplayEngine.mode()` 回答;「从视频重算」
|
||||
因此不再是第三种来源,而是**显式重跑**这一遍的开关(第 10 节)。时间基准是**信号时间**
|
||||
(Part A 第 5 节),进度、位置、单步与跳转都说它。
|
||||
|
||||
**帧身份只来自录像自己的计数器。** 证据录像 id 是 20…52 而非 0…32,所以
|
||||
`AnalysisSession.frame(frame_id)` 对结果里没有的 id 报 `AnalysisNotFoundError`:把索引当 id
|
||||
传进去不会被"顺手改成能读的那一帧",那会返回一张看起来完全正确的图。按时间取帧走
|
||||
`frame_id_at(t)`,取**该时刻或之前**的那一帧 —— 与顺序播放经过的是同一帧。
|
||||
|
||||
## 10. 打开即分析
|
||||
|
||||
打开录像后固定做一件事:**有兼容结果就用,没有就跑**。`stored_result()` 命中是一次文件读,
|
||||
面板显示"已有结果,无需重算";没有命中就在**工作线程**上跑 pass,`Progress` 按 0.1 s 合流后
|
||||
经 Qt 信号回到 GUI 线程,面板显示 `已写/预计`、分段号与已用时间。pass 期间时间轴留在
|
||||
`recording` 模式,完成后才切到 `analysis` —— 半截结果永远不上屏。面板的取消按钮置一次取消
|
||||
事件,服务在检查点停下,目录带 `INCOMPLETE` 标记留下,**不提供回放**(第 13 节)。「从视频
|
||||
重算」语义不变,与打开路径走同一个 `on_analysis_requested()`,因此不可能出现两条并行模式。
|
||||
|
||||
分析的四条终态消息**都不写状态栏**,只更新进度面板:pass 是异步的,可能在操作员做了别的事
|
||||
几秒后才结束,一条活得比原因久的状态栏消息会把无关操作说成失败。原因留在面板上,也由
|
||||
`analysis_error()` 提供。
|
||||
|
||||
**并发与工作目录。** 开链路会往当前目录写 2 MB 预览图(Part A 第 6 节),所以 pass 跑在**本
|
||||
进程**的一个线程里,启动前要求没有采集会话、没有预览、也没有第二遍分析,并且**本模块从不改
|
||||
工作目录**。锁只覆盖开链路那一小段(`_working_directory` 在 `_OpenChain.open()` 内进出),
|
||||
逐帧处理在锁之外,GUI 线程这期间正常刷新。关窗时先取消并等待 pass。
|
||||
|
||||
## 11. 三视图如何被同一个位置喂
|
||||
|
||||
| 视图 | 读什么 | 怎么读 |
|
||||
|---|---|---|
|
||||
| 图表 | `forces.csv`(记录值) | `charts.set_cursor(t)` 用同一游标;结果里不存力 |
|
||||
| 二维画布 | 结果的 `flow` 与 `valid_mask` | `ImageFrame(source="analysis")` 走窗口唯一的 2D 出口 `_show_image_frame`,箭头、暗点、配色与域边界与实时/重算**同一套代码** |
|
||||
| 三维面板 | 同一帧的 `SdkPacket` | `session.packet(frame)` → `Scene3DView.submit_packet`,走同一个 adapter、域掩码 latch 与有效性求交 |
|
||||
|
||||
**画面是重建的,并且是精确重建。** 二维需要的 `[H,W,2]` 流场是把存下来的 `dx`/`dy` 写回
|
||||
**记录下来的候选位置**、其余格子填 `NaN`(选区器把非有限值当作非法,邻居格子因此不可能贡献
|
||||
出 pass 没记录过的样本)。写回要除以
|
||||
`DISPLACEMENT_PX_PER_FLOW × flow_arrow_scale × flow_length_margin`。
|
||||
|
||||
`dx`/`dy` 是**画出来的**位移,已经带上长度余量那一步的缩放:只用前两个因子会让每根箭头短
|
||||
20%(finger 余量 0.8),330 个样本里 319 个不同、活动集也不同 —— 实测的,不是推导的。余量补
|
||||
上后,重建流场重新过一遍共享选区器,样本集与存下来的**逐元素相等**(33 帧全覆盖)。三维面板
|
||||
直接拿存下来的 `FlowSamples`,所以"两张图是同一个测量"由构造决定。
|
||||
|
||||
## 12. 显示设置是读
|
||||
|
||||
阈值、配色范围、暗点开关都不在失效键里(Part A 第 3 节),所以改动它们必须**只重画**:阈值
|
||||
经 `AnalysisReader.samples(policy=…)` 作用在存下来的 `magnitude` 上,配色范围与暗点开关只
|
||||
存在于绘制调用里。走线是 `Scene3DPanel.settingsChanged` → `on_display_settings()` →
|
||||
`session.serve_policy()` → `ReplayEngine.redraw()`,重画不移动游标、不写文件。
|
||||
|
||||
**栅格类设置被拒绝**:`flow_grid_spacing`、`flow_edge_margin`、`max_arrows`、
|
||||
`flow_length_margin`、`flow_arrow_scale` 决定候选位置**在哪里**,而记录下来的位置是 pass 当时
|
||||
测的那些。`serve_policy()` 返回 `False` 并提示哪一项动了,画面继续按结果自己的栅格绘制 —— 画
|
||||
一份"看起来一样、其实采在别处"的箭头才是真正错误的选项。
|
||||
|
||||
`app_window.py` 的接线只有三处,供 Part C 使用:`_analysis_service(recording, cancelled, *,
|
||||
on_progress)`(`chain_spec_for` 从录像自己的 `session.json` 取链路,因此结果可复现)、
|
||||
`_show_analysis_frame(packet)`、`_connect_analysis_display(mesh_panel)`(把
|
||||
`Scene3DPanel.settingsChanged` 接到控制器;三维视图首次使用时才建,用有界的 40 × 250 ms 重试
|
||||
等它出现)。工具栏、复位与 transport 控件、`display_prefs.py`、`_on_reset_view`/F8 未改。
|
||||
|
||||
## 13. 未完成与取消
|
||||
|
||||
`AnalysisSession.__init__` 在 `reader.complete` 为假时抛 `ResultPolicyError`;
|
||||
`ResultLibrary.open(..., allow_incomplete=True)` 也拒绝 ——「可查看」与「可回放」是两个不同的
|
||||
断言。面板的 `cancelled` 与 `failed` 是两个独立状态,都不会显示成完成;这两种情况下时间轴留
|
||||
在 `recording` 模式。
|
||||
|
||||
## 14. 验收证据
|
||||
|
||||
`apps/studio/tests/test_replay_analysis.py`(15 项,真实素材):
|
||||
|
||||
* **跳转等于顺序播放**:顺序走过全部 33 帧记下每帧,再按 `time_of(frame_id)` 直接跳到 5 个
|
||||
采样位置。容差是零:帧 id、`rows/cols/dx/dy/active` 逐元素相等,流场按位相等(非候选格按
|
||||
构造是 `NaN`,用 `equal_nan` 比较)。跳转精确的原因是给它的时间就是那一帧自己的 `t_rel_s`。
|
||||
* **显示设置不写结果**:`scan_results(cache)` 的目录集合在改阈值(0.2 与 1e9)、改显示增益、
|
||||
重复同一栅格前后完全相同。
|
||||
* **取消不被当完整**:pass 在第一帧后取消 → 面板报 `cancelled`、`analysis_active()` 为假、
|
||||
引擎停在 `recording` 模式、目录里 `complete=false` 且默认读端抛
|
||||
`AnalysisIncompleteError`。
|
||||
* **回归**:`apps/studio/tests` 440 项 / 0 失败 / 18 跳过(原 425 项:新增 15 项,既有断言一条
|
||||
未改)。把分析终态写进状态栏时,
|
||||
`test_the_replay_reset_is_the_display_reset_and_never_the_device` 失败过 —— 合成录像的配置
|
||||
缺失让 pass 在断言之前结束并覆盖了 `复位显示` 的提示;修的是消息落在哪里。
|
||||
|
||||
真实 GL 快照(`QT_QPA_PLATFORM=windows` + `QT_OPENGL=desktop`,真实窗口路径、pass 写入
|
||||
scratch cache、游标停在存下来的帧 id 上):
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `.scratch/replay-analysis-b/snapshots/analysis-frame-21-2d.png` | 帧 21(t=0.0641 s)二维画布,308×331 |
|
||||
| `.scratch/replay-analysis-b/snapshots/analysis-frame-21-3d.png` | 同一帧三维面板,676×610 |
|
||||
| `.scratch/replay-analysis-b/snapshots/analysis-frame-36-2d.png` | 帧 36(t=0.9601 s)二维画布,308×331 |
|
||||
| `.scratch/replay-analysis-b/snapshots/analysis-frame-36-3d.png` | 同一帧三维面板,676×594 |
|
||||
|
||||
二维画面是候选网格上的暗点阵(该帧活动样本很少),三维是手指岛面加同一套样本点;"同一个样本
|
||||
集"由上面的逐元素断言保证,快照证明这条链路真的走到了 GL。
|
||||
|
||||
## 15. 已知限制
|
||||
|
||||
* 结果里没有力,也没有深度:力曲线始终读记录的 `forces.csv`,深度视图在回放模式下不可用(没有
|
||||
可显示的数据,面板按空处理而不是画一张假的)。
|
||||
* 结果覆盖的帧是 pass 走过的那些:录像比结果长时游标可以离开结果范围(面板说"这个分析结果里
|
||||
没有可显示的帧"),不会静默重复最后一帧。
|
||||
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。
|
||||
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析。
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Running the analysis pass off the GUI thread, with progress and a cancel.
|
||||
|
||||
Part A's :class:`~orisys_app_shared.analysis.service.AnalysisService` is a blocking
|
||||
call that costs about half a wall-second per video-second. This module is the thread
|
||||
boundary around it: the pass runs on a plain Python thread, its ``Progress`` callback
|
||||
is coalesced and re-emitted through Qt signals (delivered to the GUI thread by Qt's
|
||||
queued connections), and ``cancel()`` sets the service's own cancellation event.
|
||||
|
||||
**A plain thread, not a ``QThread``.** The pass holds no Qt and no widgets, and the
|
||||
signals are emitted through a :class:`~PySide6.QtCore.QObject` that stays on the GUI
|
||||
thread. Moving a worker object onto a ``QThread`` would buy nothing here and cost a
|
||||
C++ lifetime to get right on teardown — including the teardown that happens while a
|
||||
pass is still running.
|
||||
|
||||
**In this process, never concurrently.** The one global side effect is stated by the
|
||||
service itself and respected here: constructing a box ``Sensor`` writes about 2 MB of
|
||||
``config/compound_eye.png`` into the *process working directory*, so that open owns the
|
||||
CWD under a process-wide lock for its duration. This module therefore never changes the
|
||||
working directory, and it refuses to start a second pass while one is running; the
|
||||
controller additionally refuses to start while a session or a recompute pass is live
|
||||
(see ``ReplayController.on_analysis_requested``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.analysis.service import AnalysisOutcome, Progress
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: How often the pass's per-frame progress may reach the GUI. The pass writes one frame
|
||||
#: per call and a ten-minute recording holds ~18,000 of them; a signal per frame would
|
||||
#: spend more time in the event loop than in the analysis.
|
||||
PROGRESS_INTERVAL_S = 0.1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalysisReport:
|
||||
"""What one pass did, in the terms the progress surface shows."""
|
||||
|
||||
outcome: AnalysisOutcome | None
|
||||
cancelled: bool
|
||||
failed: bool
|
||||
reason: str
|
||||
frames_written: int
|
||||
frames_expected: int
|
||||
elapsed_s: float
|
||||
directory: Path | None
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
return bool(
|
||||
self.outcome is not None
|
||||
and not self.cancelled
|
||||
and not self.failed
|
||||
and self.outcome.complete
|
||||
)
|
||||
|
||||
|
||||
def report_from(outcome: AnalysisOutcome | None, *, cancelled: bool) -> AnalysisReport:
|
||||
"""Turn a service outcome into the surface's report."""
|
||||
if outcome is None:
|
||||
return AnalysisReport(
|
||||
outcome=None,
|
||||
cancelled=bool(cancelled),
|
||||
failed=not cancelled,
|
||||
reason="cancelled" if cancelled else "no_result",
|
||||
frames_written=0,
|
||||
frames_expected=0,
|
||||
elapsed_s=0.0,
|
||||
directory=None,
|
||||
)
|
||||
return AnalysisReport(
|
||||
outcome=outcome,
|
||||
cancelled=bool(cancelled),
|
||||
failed=not bool(outcome.complete) and not cancelled,
|
||||
reason=str(outcome.reason or ""),
|
||||
frames_written=int(outcome.frames_written),
|
||||
frames_expected=int(outcome.frames_expected),
|
||||
elapsed_s=float(outcome.elapsed_s),
|
||||
directory=Path(outcome.directory),
|
||||
)
|
||||
|
||||
|
||||
class AnalysisWorker(QObject):
|
||||
"""One analysis pass, on its own thread, reporting progress as Qt signals.
|
||||
|
||||
``progressed`` and ``finished`` are declared on this object, which lives on the GUI
|
||||
thread; emitting them from the pass's thread crosses the thread boundary, so Qt
|
||||
queues them and the slots run on the GUI thread. That is the whole reason this
|
||||
class exists rather than a bare ``threading.Thread`` at the call site.
|
||||
"""
|
||||
|
||||
#: Emitted on the GUI thread with each coalesced ``Progress``.
|
||||
progressed = Signal(object)
|
||||
#: Emitted once, on the GUI thread, with the ``AnalysisReport``.
|
||||
finished = Signal(object)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_factory: Callable[[threading.Event, Callable[[Progress], None]], Any],
|
||||
*,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._service_factory = service_factory
|
||||
self._thread: threading.Thread | None = None
|
||||
self._cancel = threading.Event()
|
||||
self._last_emit = 0.0
|
||||
self._lock = threading.Lock()
|
||||
self._service: Any = None
|
||||
self._started_at = 0.0
|
||||
self._failure = ""
|
||||
|
||||
# ------------------------------------------------------------------ control
|
||||
def running(self) -> bool:
|
||||
thread = self._thread
|
||||
return thread is not None and thread.is_alive()
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the pass. Returns ``False`` when one is already running."""
|
||||
if self.running():
|
||||
return False
|
||||
self._cancel.clear()
|
||||
self._last_emit = 0.0
|
||||
self._failure = ""
|
||||
self._started_at = time.perf_counter()
|
||||
thread = threading.Thread(target=self._run, name="analysis-pass", daemon=True)
|
||||
self._thread = thread
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Ask the pass to stop at its next checkpoint.
|
||||
|
||||
The service checks the event between frames and between segments, so this stops
|
||||
within one frame's work. Whatever was written stays on disk *marked incomplete*,
|
||||
which is what keeps a cancelled pass from ever being played back.
|
||||
"""
|
||||
self._cancel.set()
|
||||
|
||||
def cancelled(self) -> bool:
|
||||
return self._cancel.is_set()
|
||||
|
||||
def service(self) -> Any:
|
||||
"""The live service, for a caller that wants its dependency report."""
|
||||
with self._lock:
|
||||
return self._service
|
||||
|
||||
def wait(self, timeout_s: float = 10.0) -> bool:
|
||||
"""Block until the pass's thread has finished. For teardown and for tests."""
|
||||
thread = self._thread
|
||||
if thread is None:
|
||||
return True
|
||||
thread.join(float(timeout_s))
|
||||
return not thread.is_alive()
|
||||
|
||||
# ------------------------------------------------------------------- thread
|
||||
def _run(self) -> None:
|
||||
outcome: AnalysisOutcome | None = None
|
||||
try:
|
||||
service = self._service_factory(self._cancel, self._on_progress)
|
||||
with self._lock:
|
||||
self._service = service
|
||||
outcome = service.run()
|
||||
except Exception as exc: # noqa: BLE001 - reported, never swallowed
|
||||
logger.exception("the analysis pass raised")
|
||||
self._failure = f"{type(exc).__name__}: {exc}"
|
||||
finally:
|
||||
with self._lock:
|
||||
self._service = None
|
||||
cancelled = self._cancel.is_set()
|
||||
report = report_from(outcome, cancelled=cancelled)
|
||||
if outcome is None and not cancelled:
|
||||
report = AnalysisReport(
|
||||
outcome=None,
|
||||
cancelled=False,
|
||||
failed=True,
|
||||
reason=self._failure or "failed",
|
||||
frames_written=0,
|
||||
frames_expected=0,
|
||||
elapsed_s=time.perf_counter() - self._started_at,
|
||||
directory=None,
|
||||
)
|
||||
self.finished.emit(report)
|
||||
|
||||
def _on_progress(self, progress: Progress) -> None:
|
||||
"""Runs on the pass's thread: coalesce, then hand the value across."""
|
||||
now = time.perf_counter()
|
||||
if now - self._last_emit < PROGRESS_INTERVAL_S:
|
||||
return
|
||||
self._last_emit = now
|
||||
self.progressed.emit(progress)
|
||||
@@ -58,6 +58,7 @@ from orisys_studio.scene3d.prefs import settings_to_prefs
|
||||
from orisys_studio.session_controller import SessionController
|
||||
from orisys_studio.ui import record_status as record_states
|
||||
from orisys_studio.ui.about_dialog import AboutDialog
|
||||
from orisys_studio.ui.analysis_progress import AnalysisProgress
|
||||
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage
|
||||
from orisys_studio.ui.brand_bar import BrandBar
|
||||
from orisys_studio.ui.cal_panel import CalPanel
|
||||
@@ -121,6 +122,13 @@ VIEW_SHOWS_3D = ("custom", "live", "replay")
|
||||
#: "not in the table" must not mean "standing at its minimum width".
|
||||
DEFAULT_3D_PANE_WIDTH = 620
|
||||
|
||||
#: How long the window waits for the 3D view (built on first use) before giving up on
|
||||
#: binding a display-settings change to the replay: 40 × 250 ms. Nothing depends on the
|
||||
#: retry succeeding — a display change then simply redraws on the next cursor move — so
|
||||
#: it is a bounded convenience, never a requirement.
|
||||
ANALYSIS_DISPLAY_ATTEMPTS = 40
|
||||
ANALYSIS_DISPLAY_INTERVAL_MS = 250
|
||||
|
||||
|
||||
#: Kept under its historical name for ``tools/ui_snapshot.py``, which imports it from
|
||||
#: here. There is one conversion in the application (``ui.arrow_panel.bgr_to_qimage``).
|
||||
@@ -251,6 +259,11 @@ class StudioMainWindow(QMainWindow):
|
||||
self.toolbar = ToolbarWidget(theme=self._theme)
|
||||
root.addWidget(self.toolbar)
|
||||
self.status_chip = self.toolbar.status_chip
|
||||
# Part B: the analysis pass a replay is served from. The surface sits directly
|
||||
# under the rail, so "what is it doing and can I stop it" is answered where the
|
||||
# transport is; it is hidden until a pass (or a cache hit) has something to say.
|
||||
self.analysis_progress = AnalysisProgress(theme=self._theme)
|
||||
root.addWidget(self.analysis_progress)
|
||||
|
||||
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||
@@ -284,9 +297,15 @@ class StudioMainWindow(QMainWindow):
|
||||
charts=self.force_plots,
|
||||
image_panel=self.arrow_panel,
|
||||
mesh_panel=self.mesh3d_panel,
|
||||
analysis_progress=self.analysis_progress,
|
||||
parent=self,
|
||||
)
|
||||
self.replay_ctl.wire()
|
||||
# Part B: a display-settings change while a replay is served from an analysis
|
||||
# result is a *redraw*, never a new pass. The scene panel owns those controls,
|
||||
# so its own signal is the one place that sees every one of them (threshold,
|
||||
# colour range, idle dots, arrows).
|
||||
self._await_analysis_display()
|
||||
# How the numbers are shown (units, chart range, arrows, the fps readout) is a
|
||||
# preference of its own: the dialog that changes the chart settings stays here,
|
||||
# the values and what they do to the panels live there.
|
||||
@@ -2706,6 +2725,91 @@ class StudioMainWindow(QMainWindow):
|
||||
return
|
||||
self.arrow_panel.show_frame(bgr_to_qimage(rendered))
|
||||
|
||||
# ------------------------------------------------------ analysis (Part B)
|
||||
# What this window owes the replay controller, and nothing more. The *policy*
|
||||
# (when a pass runs, what is done with its result, what a display change means)
|
||||
# lives in ``ReplayController``; these are the three window-level things it cannot
|
||||
# own: the sensor chain a pass is built from, the 3D pane's producer, and the
|
||||
# scene panel's settings signal.
|
||||
def _analysis_service(
|
||||
self, recording: Path, cancelled=None, *, on_progress=None
|
||||
) -> Any:
|
||||
"""One ``AnalysisService`` over ``recording``, ready to run.
|
||||
|
||||
The chain comes from the recording's own ``session.json``
|
||||
(``chain_spec_for``), which is what makes a stored result reproducible: the
|
||||
configuration the footage was taken under, not whatever happens to be installed
|
||||
now. ``cancelled`` is the pass's cancellation event and ``on_progress`` its
|
||||
progress callback; both are ``None`` for a caller that only wants to ask whether
|
||||
a compatible result is already stored.
|
||||
"""
|
||||
from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for
|
||||
from orisys_app_shared.record import RecordingReader
|
||||
|
||||
session = RecordingReader(Path(recording)).session()
|
||||
chain = chain_spec_for(session)
|
||||
return AnalysisService(
|
||||
Path(recording),
|
||||
chain=chain,
|
||||
cancelled=cancelled,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
def _show_analysis_frame(self, packet) -> None:
|
||||
"""Hand one stored analysis frame to the 3D pane's producer.
|
||||
|
||||
The pane snapshots a packet through its ordinary path, so a replay's figures go
|
||||
through the same adapter, domain latch and mask intersection as a live run. The
|
||||
producer is O(1) and Qt-free, and it is the pane that decides whether it is on.
|
||||
"""
|
||||
view = self.mesh3d_panel.view
|
||||
if view is None:
|
||||
return
|
||||
self._connect_analysis_display(self.mesh3d_panel)
|
||||
view.submit_packet(packet)
|
||||
|
||||
def _connect_analysis_display(self, mesh_panel) -> bool:
|
||||
"""Route every display-settings change through the replay controller.
|
||||
|
||||
Connected to the scene panel's ``settingsChanged`` rather than to each control,
|
||||
so a new control cannot be added without the replay noticing it. The controller
|
||||
decides what a change *means*: a redraw from the stored frame, and never a new
|
||||
analysis pass.
|
||||
|
||||
The scene panel is built with the 3D view, which is built on first use, so this
|
||||
is idempotent and answers whether the seam exists yet; the caller retries (see
|
||||
``_await_analysis_display``) rather than reaching into another controller's
|
||||
widgets.
|
||||
"""
|
||||
if getattr(self, "_analysis_display_connected", False):
|
||||
return True
|
||||
view = mesh_panel.view
|
||||
panel = getattr(view, "panel", None) if view is not None else None
|
||||
if panel is None or not hasattr(panel, "settingsChanged"):
|
||||
return False
|
||||
panel.settingsChanged.connect(self.replay_ctl.on_display_settings)
|
||||
self._analysis_display_connected = True
|
||||
return True
|
||||
|
||||
def _await_analysis_display(self) -> None:
|
||||
"""Wait, briefly and off the critical path, for the 3D view to exist.
|
||||
|
||||
The display-settings signal is the only part of Part B that needs the 3D view,
|
||||
and that view is created the first time the pane is used — possibly long after
|
||||
this window is built. A short retry chain is the honest way to reach it from
|
||||
here: the alternative is to reach into ``ViewLayoutController`` and create the
|
||||
view early, which would change when GL is initialised for every user.
|
||||
"""
|
||||
if self._connect_analysis_display(self.mesh3d_panel):
|
||||
return
|
||||
self._analysis_display_attempts = (
|
||||
int(getattr(self, "_analysis_display_attempts", 0)) + 1
|
||||
)
|
||||
if self._analysis_display_attempts > ANALYSIS_DISPLAY_ATTEMPTS:
|
||||
logger.debug("the 3D display settings stayed unavailable; no signal bound")
|
||||
return
|
||||
QTimer.singleShot(ANALYSIS_DISPLAY_INTERVAL_MS, self._await_analysis_display)
|
||||
|
||||
def _restore_geometry(self) -> None:
|
||||
geo = self.prefs.get("window_geometry")
|
||||
if isinstance(geo, str) and geo:
|
||||
@@ -2752,6 +2856,13 @@ class StudioMainWindow(QMainWindow):
|
||||
# Detach the sinks first: a worker that is still winding down must not keep a
|
||||
# reference to this window (and through it to widgets Qt is about to destroy).
|
||||
self.session.detach_sinks()
|
||||
# Part B: an analysis pass opens an SDK chain in this process, so it is stopped
|
||||
# and waited for before the window goes — the same rule the acquisition session
|
||||
# follows below. The partial result keeps its INCOMPLETE marker, so it will not
|
||||
# be served on the next open either.
|
||||
if self.replay_ctl.cancel_analysis():
|
||||
self.replay_ctl.wait_for_analysis()
|
||||
self.replay_ctl.results.close()
|
||||
# Blocking: on the way out we must not leave a device handle or a
|
||||
# half-written experiment behind.
|
||||
self._stop_run(reason="user_stop", wait=True)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Replay engine: one recording, one cursor, one frame at a time.
|
||||
|
||||
This is the headless half of the replay view. It owns the recording reader and
|
||||
the cursor and it *shows* nothing: the main window renders the frame into the 2D
|
||||
panel, the curves read the recording through
|
||||
:class:`~orisys_app_shared.force.source.RecordingCurveSource`, and the transport
|
||||
on the chart header drives :meth:`set_position`.
|
||||
This is the headless half of the replay view. It owns the recording reader, the
|
||||
analysis session and the cursor, and it *shows* nothing: the main window renders the
|
||||
frame into the 2D panel, the curves read the recording through
|
||||
:class:`~orisys_app_shared.force.source.RecordingCurveSource`, and the transport on the
|
||||
chart header drives :meth:`set_position`.
|
||||
|
||||
It is an *engine*, not a controller: it answers "what is at t?" and knows nothing about
|
||||
the transport's policy (when to start, what the end of the timeline means, whether a
|
||||
@@ -12,9 +12,25 @@ recompute pass is running) or about the widgets. That policy lives in
|
||||
``ReplayController`` (the window-level controller for this view), which is why the two
|
||||
names are deliberately different — see ``apps/studio/docs/shell-lifecycle.md``.
|
||||
|
||||
There is no depth recomputation here: depth is not stored with a recording, the
|
||||
replay view shows the recorded frames, and re-running the pipeline over a video
|
||||
is the acquisition path's job (see the studio's "recompute" mode).
|
||||
**One timeline, two modes.** The cursor is a single ``t_rel_s`` on the **signal** time
|
||||
base in both modes — the recording's own ``frames.csv`` time, which is what
|
||||
``RecordingReader`` and ``AnalysisReader`` both index by. The mode says what the frame
|
||||
under the cursor is *read from*:
|
||||
|
||||
``"recording"``
|
||||
the recorded video's own frames, decoded on demand (``RecordingReader``); no flow,
|
||||
no depth, because a recording stores neither. This is the mode a finished record
|
||||
opens in, and the one the charts read ``forces.csv`` for.
|
||||
``"analysis"``
|
||||
a stored analysis result (Part A): flow samples and the candidate grid the pass
|
||||
recorded, read by frame id from the cache. A seek here is a file read — no pass,
|
||||
no video, no SDK.
|
||||
|
||||
There is no third mode and there are no per-view sources: the charts, the 2D canvas and
|
||||
the 3D pane all read this one cursor, in whichever mode it is in. Re-running the vision
|
||||
chain over a video is *not* a mode of this engine: it is the acquisition pipeline's run
|
||||
(see the studio's 「从视频重算」), which produces its own frames and, when it finishes,
|
||||
can leave a result this engine then reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,8 +43,15 @@ from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record import RecordingReader, VideoOnlySource
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from orisys_studio.replay_analysis import AnalysisSession, AnalysedFrame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The cursor reads the recording's own frames (decoded on demand).
|
||||
MODE_RECORDING = "recording"
|
||||
#: The cursor reads a stored analysis result (a file read per frame, no pass).
|
||||
MODE_ANALYSIS = "analysis"
|
||||
|
||||
|
||||
def _sdk_version() -> str:
|
||||
"""The SDK version in use, or "" when the SDK is not importable."""
|
||||
@@ -52,18 +75,29 @@ class ReplayEngine(QObject):
|
||||
status_changed = Signal()
|
||||
#: A recording was opened (True) or the previous one was dropped (False).
|
||||
recording_changed = Signal(bool)
|
||||
#: The timeline's mode changed (``MODE_RECORDING`` / ``MODE_ANALYSIS``), or a
|
||||
#: result was adopted or dropped while staying in the same mode. Views that cache
|
||||
#: what they drew must drop it; the number of readers did change.
|
||||
timeline_changed = Signal()
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._reader: RecordingReader | None = None
|
||||
#: A video with no recording beside it: playable, but it has no measurements.
|
||||
self._video: VideoOnlySource | None = None
|
||||
#: The stored analysis result the cursor reads, when the mode is analysis.
|
||||
self._analysis: AnalysisSession | None = None
|
||||
self._mode = MODE_RECORDING
|
||||
self._unit = "raw"
|
||||
self._position = 0.0
|
||||
#: The frame cache: one video image per cursor position.
|
||||
self._video_position: float | None = None
|
||||
self._video_bgr: np.ndarray | None = None
|
||||
self._video_reason = ""
|
||||
#: The analysed frame under the cursor, and the id it was built for.
|
||||
self._analysis_id: int | None = None
|
||||
self._analysis_frame: AnalysedFrame | None = None
|
||||
self._analysis_reason = ""
|
||||
self._warnings = ""
|
||||
|
||||
# ------------------------------------------------------------- recording
|
||||
@@ -90,6 +124,7 @@ class ReplayEngine(QObject):
|
||||
"""Open a bare video for playback (no forces, no session metadata)."""
|
||||
self._release_reader()
|
||||
self._release_video()
|
||||
self._release_analysis()
|
||||
try:
|
||||
self._video = VideoOnlySource(Path(path))
|
||||
except Exception: # noqa: BLE001 - reported through the status text
|
||||
@@ -101,7 +136,9 @@ class ReplayEngine(QObject):
|
||||
return False
|
||||
self._reset_frames()
|
||||
self._position = 0.0
|
||||
self._mode = MODE_RECORDING
|
||||
self.recording_changed.emit(True)
|
||||
self.timeline_changed.emit()
|
||||
self._refresh_warnings()
|
||||
self.set_position(0.0)
|
||||
return True
|
||||
@@ -124,8 +161,14 @@ class ReplayEngine(QObject):
|
||||
return RecordingCurveSource(self._reader)
|
||||
|
||||
def open_recording(self, directory) -> bool:
|
||||
"""Open a recording directory."""
|
||||
"""Open a recording directory.
|
||||
|
||||
A new recording drops any analysis result that was open with the old one: the
|
||||
result belongs to a recording, and keeping the cursor on another recording's
|
||||
frames is how a picture ends up describing footage that is not on screen.
|
||||
"""
|
||||
self._release_reader()
|
||||
self._release_analysis()
|
||||
try:
|
||||
self._reader = RecordingReader(directory)
|
||||
self._reader.info()
|
||||
@@ -138,7 +181,9 @@ class ReplayEngine(QObject):
|
||||
return False
|
||||
self._reset_frames()
|
||||
self._position = 0.0
|
||||
self._mode = MODE_RECORDING
|
||||
self.recording_changed.emit(True)
|
||||
self.timeline_changed.emit()
|
||||
self._refresh_warnings()
|
||||
self.set_position(0.0)
|
||||
return True
|
||||
@@ -147,8 +192,11 @@ class ReplayEngine(QObject):
|
||||
"""Drop whatever is open (the decoder is released with it)."""
|
||||
self._release_reader()
|
||||
self._release_video()
|
||||
self._release_analysis()
|
||||
self._reset_frames()
|
||||
self._mode = MODE_RECORDING
|
||||
self.recording_changed.emit(False)
|
||||
self.timeline_changed.emit()
|
||||
self.status_changed.emit()
|
||||
|
||||
def _release_reader(self) -> None:
|
||||
@@ -167,32 +215,129 @@ class ReplayEngine(QObject):
|
||||
except Exception: # noqa: BLE001 - releasing is best effort
|
||||
logger.debug("Could not close the video", exc_info=True)
|
||||
|
||||
def _release_analysis(self) -> None:
|
||||
"""Forget the borrowed session. It does **not** close it.
|
||||
|
||||
The reader belongs to the controller's ``ResultLibrary``: closing it here would
|
||||
leave the controller holding a session whose file handle this engine shut.
|
||||
"""
|
||||
self._analysis = None
|
||||
self._analysis_id = None
|
||||
self._analysis_frame = None
|
||||
self._analysis_reason = ""
|
||||
|
||||
def _reset_frames(self) -> None:
|
||||
self._video_position = None
|
||||
self._video_bgr = None
|
||||
self._video_reason = ""
|
||||
|
||||
# ---------------------------------------------------------------- cursor
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
# --------------------------------------------------------------- timeline
|
||||
def mode(self) -> str:
|
||||
"""What the frame under the cursor is read from: the video, or a result."""
|
||||
return self._mode
|
||||
|
||||
def analysis(self) -> AnalysisSession | None:
|
||||
return self._analysis
|
||||
|
||||
def use_analysis(self, session: AnalysisSession) -> None:
|
||||
"""Read the cursor from a stored result instead of decoding the video.
|
||||
|
||||
The session is *borrowed*, not owned: its reader belongs to whoever opened it
|
||||
(``ResultLibrary`` in the controller), which is what lets a mode change be a
|
||||
cheap switch rather than a file handle handed between two owners.
|
||||
"""
|
||||
self._analysis = session
|
||||
self._mode = MODE_ANALYSIS
|
||||
self._analysis_id = None
|
||||
self._analysis_frame = None
|
||||
self._analysis_reason = ""
|
||||
# The cursor keeps its *time*: both timelines are the recording's own signal
|
||||
# time, so the frame under it is the one the operator was already looking at
|
||||
# (a different frame id may resolve, and that is the honest answer: the result
|
||||
# covers the frames the pass wrote).
|
||||
self.timeline_changed.emit()
|
||||
self.set_position(self._position)
|
||||
self._refresh_warnings()
|
||||
|
||||
def use_recording(self) -> None:
|
||||
"""Read the cursor from the recording's own video again."""
|
||||
if self._mode != MODE_ANALYSIS:
|
||||
return
|
||||
self._release_analysis()
|
||||
self._mode = MODE_RECORDING
|
||||
self.timeline_changed.emit()
|
||||
self.set_position(self._position)
|
||||
self._refresh_warnings()
|
||||
|
||||
def duration_s(self) -> float:
|
||||
if self._mode == MODE_ANALYSIS and self._analysis is not None:
|
||||
return self._analysis.duration_s()
|
||||
if self._reader is not None:
|
||||
return float(self._reader.info().duration_s)
|
||||
if self._video is not None:
|
||||
return float(self._video.duration_s)
|
||||
return 0.0
|
||||
|
||||
# ---------------------------------------------------------------- cursor
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
def frame_id(self) -> int | None:
|
||||
"""The frame id under the cursor, or ``None`` when nothing is open.
|
||||
|
||||
In analysis mode this is the **recording's own frame id** — never an index into
|
||||
the result. A recording whose ids run 20…52 answers 20…52 here, and a caller
|
||||
that needs a specific frame asks :meth:`frame_id_for_time` for it.
|
||||
"""
|
||||
if self._mode == MODE_ANALYSIS and self._analysis is not None:
|
||||
return self._analysis.frame_id_at(self._position)
|
||||
if self._reader is not None:
|
||||
image, frame = self._reader.video_frame_at(self._position)
|
||||
return None if frame is None else int(frame.frame_id)
|
||||
return None
|
||||
|
||||
def frame_id_for_time(self, t_rel_s: float) -> int | None:
|
||||
"""The frame id a position resolves to, without moving the cursor."""
|
||||
if self._mode == MODE_ANALYSIS and self._analysis is not None:
|
||||
return self._analysis.frame_id_at(float(t_rel_s))
|
||||
if self._reader is None:
|
||||
return None
|
||||
_image, frame = self._reader.video_frame_at(float(t_rel_s))
|
||||
return None if frame is None else int(frame.frame_id)
|
||||
|
||||
def time_of_frame(self, frame_id: int) -> float | None:
|
||||
"""The signal time of a frame **id**, or ``None`` when it is not in the result.
|
||||
|
||||
An id the recording does not hold is refused, not coerced: a caller that passes
|
||||
an index gets ``None`` rather than the time of the frame at that offset.
|
||||
"""
|
||||
if self._mode == MODE_ANALYSIS and self._analysis is not None:
|
||||
return self._analysis.time_of(int(frame_id))
|
||||
return None
|
||||
|
||||
def set_position(self, t_rel_s: float) -> None:
|
||||
"""Move the single cursor: the video frame and the charts follow it."""
|
||||
if not self.has_video():
|
||||
if not self.has_video() and self._analysis is None:
|
||||
return
|
||||
duration = self.duration_s()
|
||||
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
|
||||
self._refresh_video()
|
||||
self._refresh_analysis()
|
||||
self.position_changed.emit(self._position)
|
||||
self.frames_changed.emit()
|
||||
|
||||
def redraw(self) -> None:
|
||||
"""Re-emit the frame under the cursor without moving it.
|
||||
|
||||
What a display-settings change does: the picture is rebuilt from the same
|
||||
stored frame, no result is written and no position moves. Anything that caches
|
||||
what it drew must drop that cache, so the producers behind it are reset too.
|
||||
"""
|
||||
self._reset_frames()
|
||||
self._analysis_id = None
|
||||
self._analysis_frame = None
|
||||
self.frames_changed.emit()
|
||||
|
||||
def unit(self) -> str:
|
||||
return self._unit
|
||||
|
||||
@@ -207,15 +352,32 @@ class ReplayEngine(QObject):
|
||||
return self._unit
|
||||
|
||||
# ----------------------------------------------------------------- frames
|
||||
def analysed_frame(self) -> AnalysedFrame | None:
|
||||
"""The stored frame under the cursor, or ``None`` outside analysis mode."""
|
||||
if self._mode != MODE_ANALYSIS:
|
||||
return None
|
||||
self._refresh_analysis()
|
||||
return self._analysis_frame
|
||||
|
||||
def video_frame_bgr(self) -> np.ndarray | None:
|
||||
"""The recorded frame under the cursor, as BGR.
|
||||
|
||||
The engine hands over pixels, not a prepared widget image: the single 2D
|
||||
backend (``acquisition.image_backend``) decides what the picture is.
|
||||
backend (``acquisition.image_backend``) decides what the picture is. In
|
||||
analysis mode the pixels are the result's own canvas — the flow field the pass
|
||||
recorded, drawn on the domain grid — not a video frame.
|
||||
"""
|
||||
if self._mode == MODE_ANALYSIS:
|
||||
frame = self.analysed_frame()
|
||||
return None if frame is None else frame.base_bgr
|
||||
self._refresh_video()
|
||||
return self._video_bgr
|
||||
|
||||
def analysis_frame_bgr(self) -> np.ndarray | None:
|
||||
"""The same pixels :meth:`video_frame_bgr` returns in analysis mode."""
|
||||
frame = self.analysed_frame()
|
||||
return None if frame is None else frame.base_bgr
|
||||
|
||||
def video_image(self):
|
||||
"""The same frame as a ``QImage`` (kept for callers that predate the plan)."""
|
||||
from orisys_studio.ui.arrow_panel import bgr_to_qimage
|
||||
@@ -224,6 +386,9 @@ class ReplayEngine(QObject):
|
||||
return None if frame is None else bgr_to_qimage(frame)
|
||||
|
||||
def video_message(self) -> str:
|
||||
if self._mode == MODE_ANALYSIS:
|
||||
self._refresh_analysis()
|
||||
return self._analysis_reason
|
||||
self._refresh_video()
|
||||
return self._video_reason
|
||||
|
||||
@@ -257,12 +422,44 @@ class ReplayEngine(QObject):
|
||||
self._video_reason = ""
|
||||
self._video_bgr = image
|
||||
|
||||
def _refresh_analysis(self) -> None:
|
||||
"""Read the stored frame under the cursor, once per frame id."""
|
||||
session = self._analysis
|
||||
if session is None or self._mode != MODE_ANALYSIS:
|
||||
return
|
||||
frame_id = session.frame_id_at(self._position)
|
||||
if frame_id is None:
|
||||
self._analysis_frame = None
|
||||
self._analysis_id = None
|
||||
self._analysis_reason = t("playback.analysis_empty")
|
||||
return
|
||||
if self._analysis_frame is not None and self._analysis_id == frame_id:
|
||||
return
|
||||
try:
|
||||
frame = session.frame(frame_id)
|
||||
except Exception as exc: # noqa: BLE001 - a broken result is reported, not raised
|
||||
logger.exception("Could not read frame %s from the analysis result", frame_id)
|
||||
self._analysis_frame = None
|
||||
self._analysis_id = None
|
||||
self._analysis_reason = t(
|
||||
"playback.analysis_unreadable", error=type(exc).__name__
|
||||
)
|
||||
return
|
||||
self._analysis_frame = frame
|
||||
self._analysis_id = frame_id
|
||||
self._analysis_reason = ""
|
||||
|
||||
# ---------------------------------------------------------------- notes
|
||||
def warnings_text(self) -> str:
|
||||
return self._warnings
|
||||
|
||||
def _refresh_warnings(self) -> None:
|
||||
self._warnings = ""
|
||||
if self._mode == MODE_ANALYSIS and self._analysis is not None:
|
||||
# Nothing is stale about a result just because the SDK moved: that is
|
||||
# reported, not hidden (``AnalysisReader.stale_reasons``), and the caller
|
||||
# that opened the session is the one holding the facts to compare with.
|
||||
return
|
||||
if self._reader is None:
|
||||
if self._video is not None:
|
||||
# Not a warning: a statement of what this file *is*, so nobody reads an
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
"""Serving a replay from an analysis result: one position, one frame, no pass.
|
||||
|
||||
``replay.py`` is the *engine* for a recording: a reader, a cursor, and the recorded
|
||||
picture under it. This module is the same thing for a **stored analysis result** —
|
||||
Part A's output — and it exists so that playing a recording stops costing 0.48–0.51
|
||||
wall-seconds per video-second.
|
||||
|
||||
Two decisions worth stating out loud, because both are load-bearing:
|
||||
|
||||
**Position is signal time, and frames are addressed by frame id.** A stored frame is
|
||||
located by its ``t_rel_s`` (``reader.frame_at``), and the frame it resolves to carries
|
||||
the recording's own ``frame_id`` — the acquisition session's counter, which does not
|
||||
start at 0 or 1. Every ``frame_id`` this module is handed is checked against the result
|
||||
and refused when it is absent (:meth:`AnalysisSession.frame`), so a caller that passes a
|
||||
*position* where an *id* belongs gets an error rather than the frame at that offset.
|
||||
Container video time is never derived from ``t_rel_s`` and never converted into it: the
|
||||
two time bases are different numbers for the same footage (33 container frames at 30 fps
|
||||
is 1.100 s; the signal those frames carry is 1.920 s at 16.664 fps) and this module only
|
||||
ever speaks the second one.
|
||||
|
||||
**The result is read, never re-derived — but the display policy is still honoured.**
|
||||
``flow_on_px`` arrives from the display settings and is applied to the stored
|
||||
``magnitude`` by ``AnalysisReader.samples``, which is why a threshold change is a read
|
||||
and never a new pass. The colour range and the idle-dot switch are not even inputs
|
||||
here: they live in the draw call, so changing one redraws and writes nothing.
|
||||
|
||||
The picture is rebuilt from the stored samples: ``dx``/``dy`` are the canvas
|
||||
displacements the pass selected (uv pixels, already divided by the policy's arrow
|
||||
scale), so the flow field is recovered by dividing them back out by
|
||||
:data:`~orisys_app_shared.scene3d.visual_policy.DISPLACEMENT_PX_PER_FLOW` and writing
|
||||
those values at the recorded candidate locations. The 2D canvas then runs its ordinary
|
||||
path — the shared selector, the same idle dots, the same colour ramp — over that field.
|
||||
This is exact, not approximate, and ``apps/studio/tests/test_replay_analysis.py``
|
||||
asserts it against a pass over real footage: re-selecting the recovered field through
|
||||
the display policy reproduces the stored sample set element for element.
|
||||
|
||||
The 3D pane is handed the **stored** sample set directly (:attr:`AnalysedFrame.samples`)
|
||||
— the object Part A documents as "what the canvas and the composer already draw" — so
|
||||
the figure the pane composes from a replay is the figure the pass recorded, however the
|
||||
display policy is set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys_app_shared.analysis.reader import AnalysisReader
|
||||
from orisys_app_shared.scene3d.visual_policy import (
|
||||
DISPLACEMENT_PX_PER_FLOW,
|
||||
FlowSamples,
|
||||
VisualPolicy,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"AnalysedFrame",
|
||||
"AnalysisSession",
|
||||
"ResultLibrary",
|
||||
"ResultPolicyError",
|
||||
"policy_divergence",
|
||||
]
|
||||
|
||||
#: The UV domain to rebuild the flow field in when the result carries no validity mask
|
||||
#: keyframe (the box chain has none). Only the recorded candidate locations are ever
|
||||
#: read back out of it, so its size is a canvas for the samples rather than a claim
|
||||
#: about the device — but it has to hold every candidate, which
|
||||
#: :meth:`AnalysisSession._recover_flow` checks.
|
||||
DEFAULT_DOMAIN_HW = (1080, 1920)
|
||||
|
||||
#: The policy keys that decide *where* the candidate locations are. A display setting
|
||||
#: that moves one of these cannot be served from a stored result: the recorded grid is
|
||||
#: the pass's, and re-gridding it would draw samples nothing measured while looking
|
||||
#: exactly like the stored result. ``flow_on_px``/``flow_off_px`` are deliberately not
|
||||
#: among them — they decide which recorded samples are active, and ``magnitude`` is
|
||||
#: stored precisely so that question can be asked again later.
|
||||
GRID_KEYS = (
|
||||
"flow_grid_spacing",
|
||||
"flow_edge_margin",
|
||||
"max_arrows",
|
||||
"flow_length_margin",
|
||||
"flow_arrow_scale",
|
||||
)
|
||||
|
||||
|
||||
class ResultPolicyError(RuntimeError):
|
||||
"""A result that cannot be served: incomplete, or not in this display's domain."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalysedFrame:
|
||||
"""One frame of a stored result, ready for both views.
|
||||
|
||||
``flow``/``valid_mask`` are what the 2D canvas draws from; ``samples`` is the
|
||||
stored selection the 3D pane overlays. Both describe the *same* frame id at the
|
||||
*same* ``t_rel_s``, which is what makes one position drive two figures.
|
||||
"""
|
||||
|
||||
frame_id: int
|
||||
t_rel_s: float
|
||||
flow: np.ndarray
|
||||
valid_mask: np.ndarray | None
|
||||
samples: FlowSamples
|
||||
base_bgr: np.ndarray
|
||||
depth_nonzero: int = 0
|
||||
dropped: bool = False
|
||||
|
||||
@property
|
||||
def domain_hw(self) -> tuple[int, int]:
|
||||
return (int(self.flow.shape[0]), int(self.flow.shape[1]))
|
||||
|
||||
@property
|
||||
def active_count(self) -> int:
|
||||
return int(self.samples.active_count)
|
||||
|
||||
@property
|
||||
def candidates(self) -> int:
|
||||
return int(self.samples.rows.size)
|
||||
|
||||
|
||||
def policy_divergence(stored: dict[str, Any], display: VisualPolicy) -> list[str]:
|
||||
"""The location settings a display policy moves away from the stored ones.
|
||||
|
||||
Empty means the stored grid can serve this display: the candidate locations are
|
||||
the recorded ones and only the activity verdict changes. A non-empty list is the
|
||||
reason a replay cannot honour a display setting, named so the message can say
|
||||
which one instead of "unsupported".
|
||||
"""
|
||||
wanted = display.normalised()
|
||||
moved: list[str] = []
|
||||
for key in GRID_KEYS:
|
||||
if key not in stored:
|
||||
continue
|
||||
recorded = float(stored[key])
|
||||
if abs(recorded - float(getattr(wanted, key))) > 1e-9:
|
||||
moved.append(key)
|
||||
return moved
|
||||
|
||||
|
||||
class AnalysisSession:
|
||||
"""A stored result, read as a timeline: t → frame id → the two views' inputs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reader: AnalysisReader,
|
||||
*,
|
||||
policy: VisualPolicy | None = None,
|
||||
display_policy: VisualPolicy | None = None,
|
||||
session_id: int = 0,
|
||||
recording: Path | None = None,
|
||||
) -> None:
|
||||
if not bool(getattr(reader, "complete", True)):
|
||||
# A cancelled or failed pass must never be played from. The reader refuses
|
||||
# such a result unless ``allow_incomplete=True`` was passed, and this
|
||||
# refuses it even then: "inspectable" and "playable" are different claims.
|
||||
raise ResultPolicyError(
|
||||
"this analysis result is incomplete and cannot be played from"
|
||||
)
|
||||
self.reader = reader
|
||||
self.policy = (policy or VisualPolicy()).normalised()
|
||||
self.display_policy = (display_policy or self.policy).normalised()
|
||||
self.session_id = int(session_id)
|
||||
self.recording = None if recording is None else Path(recording)
|
||||
self._frame_ids = np.asarray(reader.frame_ids, dtype=np.int64)
|
||||
self._times = np.asarray(reader.times, dtype=np.float64)
|
||||
self._mask: np.ndarray | None = None
|
||||
self._mask_loaded = False
|
||||
self._cache_id: int | None = None
|
||||
self._cache: AnalysedFrame | None = None
|
||||
|
||||
# ------------------------------------------------------------------ identity
|
||||
@property
|
||||
def frame_count(self) -> int:
|
||||
return int(self._frame_ids.size)
|
||||
|
||||
@property
|
||||
def first_frame_id(self) -> int:
|
||||
return int(self._frame_ids[0]) if self._frame_ids.size else 0
|
||||
|
||||
@property
|
||||
def last_frame_id(self) -> int:
|
||||
return int(self._frame_ids[-1]) if self._frame_ids.size else 0
|
||||
|
||||
@property
|
||||
def frame_ids(self) -> np.ndarray:
|
||||
"""Every stored frame id, in stored order (the recording's own counter)."""
|
||||
return self._frame_ids
|
||||
|
||||
@property
|
||||
def result_key(self) -> str:
|
||||
return str(self.reader.key)
|
||||
|
||||
@property
|
||||
def directory(self) -> Path:
|
||||
return Path(self.reader.directory)
|
||||
|
||||
def has(self, frame_id: int) -> bool:
|
||||
"""Whether the result holds this frame id. Never an index lookup."""
|
||||
return bool(self._frame_ids.size) and bool(
|
||||
np.any(self._frame_ids == int(frame_id))
|
||||
)
|
||||
|
||||
def duration_s(self) -> float:
|
||||
"""The result's span in **signal** time, from ``t_rel_s`` (never the container)."""
|
||||
if self._times.size == 0:
|
||||
return 0.0
|
||||
return float(max(0.0, self._times[-1] - self._times[0]))
|
||||
|
||||
def origin_s(self) -> float:
|
||||
return float(self._times[0]) if self._times.size else 0.0
|
||||
|
||||
def stored_policy(self) -> dict[str, Any]:
|
||||
"""The selection policy the pass recorded, for comparison with the display's."""
|
||||
return dict(getattr(self.reader.provenance, "policy", {}) or {})
|
||||
|
||||
# ------------------------------------------------------------------- policy
|
||||
def serve_policy(self, display_policy: VisualPolicy) -> bool:
|
||||
"""Adopt a display policy, if the stored grid can still serve it.
|
||||
|
||||
Returns ``False`` — and changes nothing — when the policy moves a location
|
||||
setting (see :data:`GRID_KEYS`). Those decide where the candidates are, and the
|
||||
recorded locations are the ones the pass measured. The threshold is not one of
|
||||
them: it decides which recorded samples are active.
|
||||
"""
|
||||
if policy_divergence(self.stored_policy(), display_policy):
|
||||
return False
|
||||
self.display_policy = display_policy.normalised()
|
||||
return True
|
||||
|
||||
def threshold(self) -> float:
|
||||
return float(self.display_policy.flow_on_px)
|
||||
|
||||
# ------------------------------------------------------------------ looking
|
||||
def frame_id_at(self, t_rel_s: float) -> int | None:
|
||||
"""The stored frame at or just before ``t_rel_s``, by *signal* time."""
|
||||
if self._times.size == 0:
|
||||
return None
|
||||
if bool(np.all(np.diff(self._times) >= 0)):
|
||||
slot = int(np.searchsorted(self._times, float(t_rel_s), side="right")) - 1
|
||||
slot = max(0, min(slot, self._times.size - 1))
|
||||
else: # a foreign result whose times are not ordered: scan rather than guess
|
||||
candidates = np.flatnonzero(self._times <= float(t_rel_s))
|
||||
slot = int(candidates[-1]) if candidates.size else 0
|
||||
return int(self._frame_ids[slot])
|
||||
|
||||
def time_of(self, frame_id: int) -> float | None:
|
||||
"""The stored ``t_rel_s`` of one frame id, or ``None`` when it is absent."""
|
||||
hits = np.flatnonzero(self._frame_ids == int(frame_id))
|
||||
if not hits.size:
|
||||
return None
|
||||
return float(self._times[int(hits[0])])
|
||||
|
||||
def frame(self, frame_id: int) -> AnalysedFrame:
|
||||
"""One frame by its recording id.
|
||||
|
||||
Raises :class:`~orisys_app_shared.analysis.reader.AnalysisNotFoundError` for an
|
||||
id the result does not hold. That refusal is the point: a frame *index* is not
|
||||
a frame *id*, and serving the frame at that offset would answer a question
|
||||
nobody asked with a picture that looks right.
|
||||
"""
|
||||
wanted = int(frame_id)
|
||||
if self._cache is not None and self._cache_id == wanted:
|
||||
return self._cache
|
||||
payload = self.reader.frame(wanted)
|
||||
built = self._compose(payload)
|
||||
self._cache = built
|
||||
self._cache_id = wanted
|
||||
return built
|
||||
|
||||
def frame_at(self, t_rel_s: float) -> AnalysedFrame | None:
|
||||
"""One frame by signal time, or ``None`` for an empty result."""
|
||||
frame_id = self.frame_id_at(t_rel_s)
|
||||
return None if frame_id is None else self.frame(frame_id)
|
||||
|
||||
def frames(self) -> Iterator[AnalysedFrame]:
|
||||
"""Every stored frame, in stored order (a pass over a result, not a replay)."""
|
||||
for frame_id in self._frame_ids:
|
||||
yield self.frame(int(frame_id))
|
||||
|
||||
# ------------------------------------------------------------------ packets
|
||||
def packet(self, frame: AnalysedFrame, *, t_rel_s: float | None = None) -> SdkPacket:
|
||||
"""One frame in the shape the 3D pane's producer consumes.
|
||||
|
||||
The pane is fed through its ordinary path — it snapshots a packet — so a
|
||||
replay's figures go through the same adapter, the same domain latch and the
|
||||
same validity intersection as a live or recomputed frame. Feeding it anything
|
||||
else would make "the pane looks the same in every mode" an accident of two code
|
||||
paths happening to agree.
|
||||
|
||||
The forces are zero and ``forces_valid`` is ``False``: the result stores no
|
||||
force, ``forces.csv`` is where that measurement lives, and a zero here would be
|
||||
a measurement nothing made.
|
||||
"""
|
||||
moment = float(frame.t_rel_s if t_rel_s is None else t_rel_s)
|
||||
return SdkPacket(
|
||||
raw_frame=frame.base_bgr,
|
||||
processed_frame=frame.base_bgr,
|
||||
timestamp=moment,
|
||||
mono_t=moment,
|
||||
fps=0.0,
|
||||
process_ms=0.0,
|
||||
session_id=self.session_id,
|
||||
frame_id=int(frame.frame_id),
|
||||
valid=True,
|
||||
stale_forces=True,
|
||||
forces_valid=False,
|
||||
normal_force=0.0,
|
||||
shear_x=0.0,
|
||||
shear_y=0.0,
|
||||
flow=frame.flow,
|
||||
valid_mask=frame.valid_mask,
|
||||
flow_valid=True,
|
||||
flow_frame_id=int(frame.frame_id),
|
||||
depth=None,
|
||||
depth_valid=False,
|
||||
depth_frame_id=None,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ building
|
||||
def mask(self) -> np.ndarray | None:
|
||||
"""The validity mask the result was built with, read once."""
|
||||
if not self._mask_loaded:
|
||||
self._mask_loaded = True
|
||||
self._mask = self.reader.mask()
|
||||
return self._mask
|
||||
|
||||
def domain_hw(self) -> tuple[int, int]:
|
||||
mask = self.mask()
|
||||
if mask is None:
|
||||
return DEFAULT_DOMAIN_HW
|
||||
return (int(mask.shape[0]), int(mask.shape[1]))
|
||||
|
||||
def _compose(self, payload: Any) -> AnalysedFrame:
|
||||
mask = self.mask()
|
||||
shape_hw = self.domain_hw()
|
||||
samples = payload.samples(threshold=self.threshold())
|
||||
flow = self._recover_flow(samples, shape_hw)
|
||||
base = np.zeros((shape_hw[0], shape_hw[1], 3), dtype=np.uint8)
|
||||
return AnalysedFrame(
|
||||
frame_id=int(payload.frame_id),
|
||||
t_rel_s=float(payload.t_rel_s),
|
||||
flow=flow,
|
||||
valid_mask=mask,
|
||||
samples=samples,
|
||||
base_bgr=base,
|
||||
depth_nonzero=int(getattr(payload, "depth_nonzero", 0) or 0),
|
||||
dropped=bool(getattr(payload, "dropped", False)),
|
||||
)
|
||||
|
||||
def _recover_flow(
|
||||
self, samples: FlowSamples, shape_hw: tuple[int, int]
|
||||
) -> np.ndarray:
|
||||
"""Write the stored displacements back at the recorded candidate locations.
|
||||
|
||||
Only those locations carry a value; every other cell is ``NaN``, which
|
||||
:func:`~orisys_app_shared.scene3d.visual_policy.select_flow_samples` treats as
|
||||
invalid, so no neighbouring cell can contribute a sample the pass did not
|
||||
record.
|
||||
|
||||
``dx``/``dy`` are the **drawn** displacements, which the selector produced as
|
||||
``DISPLACEMENT_PX_PER_FLOW * flow / flow_arrow_scale`` and then scaled by
|
||||
``flow_length_margin`` when the arrow was longer than its cell. Recovering the
|
||||
field is therefore a division by the arrow scale, the display constant *and*
|
||||
that margin — measured, not assumed: dividing by the first two alone leaves every
|
||||
arrow 20% short (the finger margin is 0.8), which changes 319 of 330 samples and
|
||||
one frame's active set. With the margin included the selector's own
|
||||
``np.where(length > spacing * margin, ...)`` branch is idempotent, so
|
||||
re-selecting the recovered field reproduces the stored samples exactly.
|
||||
"""
|
||||
rows = np.asarray(samples.rows, np.int64)
|
||||
cols = np.asarray(samples.cols, np.int64)
|
||||
height, width = int(shape_hw[0]), int(shape_hw[1])
|
||||
if rows.size and (int(rows.max()) >= height or int(cols.max()) >= width):
|
||||
raise ResultPolicyError(
|
||||
"the result's candidate locations do not fit the domain it declares "
|
||||
f"({height}x{width}); the mask keyframe and the grid keyframe disagree"
|
||||
)
|
||||
divisor = (
|
||||
DISPLACEMENT_PX_PER_FLOW
|
||||
* float(self.policy.flow_arrow_scale)
|
||||
* float(self.policy.flow_length_margin)
|
||||
)
|
||||
flow = np.full((height, width, 2), np.nan, dtype=np.float64)
|
||||
with np.errstate(invalid="ignore"):
|
||||
# ``NaN`` is the point of the mask, and the selector's tip arithmetic on a
|
||||
# discarded cell is defined to be discarded: say so here rather than let
|
||||
# numpy warn about an invalid cast on every frame.
|
||||
flow[rows, cols, 0] = np.asarray(samples.dx, np.float64) / divisor
|
||||
flow[rows, cols, 1] = np.asarray(samples.dy, np.float64) / divisor
|
||||
return flow
|
||||
|
||||
|
||||
class ResultLibrary:
|
||||
"""Opening stored results as playable sessions, one at a time.
|
||||
|
||||
The controller keeps one of these. It owns the *reader lifecycle* — the previous
|
||||
session's file handle is closed when another is opened — so nothing else has to
|
||||
remember that a result is a file on disk with a handle behind it.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: AnalysisSession | None = None
|
||||
|
||||
@property
|
||||
def session(self) -> AnalysisSession | None:
|
||||
return self._session
|
||||
|
||||
def open(
|
||||
self,
|
||||
directory: str | Path,
|
||||
*,
|
||||
policy: VisualPolicy | None = None,
|
||||
display_policy: VisualPolicy | None = None,
|
||||
session_id: int = 0,
|
||||
recording: Path | None = None,
|
||||
allow_incomplete: bool = False,
|
||||
) -> AnalysisSession:
|
||||
"""Open a result directory as a session, closing whatever was open.
|
||||
|
||||
``allow_incomplete`` is for *inspection* only and never reaches this class's
|
||||
own session: an incomplete result raises
|
||||
:class:`~orisys_app_shared.analysis.reader.AnalysisIncompleteError` here even
|
||||
when the caller asked to look at one, because a partial pass that plays back
|
||||
like a whole one is exactly the silent wrong answer Part A's marker exists to
|
||||
prevent.
|
||||
"""
|
||||
reader = AnalysisReader(directory, allow_incomplete=allow_incomplete)
|
||||
session = AnalysisSession(
|
||||
reader,
|
||||
policy=policy,
|
||||
display_policy=display_policy,
|
||||
session_id=session_id,
|
||||
recording=recording,
|
||||
)
|
||||
self.open_session(session)
|
||||
return session
|
||||
|
||||
def open_session(self, session: AnalysisSession) -> AnalysisSession:
|
||||
"""Adopt a session that was built elsewhere, closing whatever was open.
|
||||
|
||||
The engine *borrows* its session (a mode switch must not hand a file handle
|
||||
between two owners), so the library is the single owner and this is the only
|
||||
place a reader is closed.
|
||||
"""
|
||||
self.close()
|
||||
self._session = session
|
||||
return session
|
||||
|
||||
def close(self) -> None:
|
||||
session, self._session = self._session, None
|
||||
if session is not None:
|
||||
try:
|
||||
session.reader.close()
|
||||
except Exception: # noqa: BLE001 - releasing is best effort
|
||||
logger.debug("could not close the analysis reader", exc_info=True)
|
||||
@@ -28,12 +28,20 @@ from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
|
||||
from orisys_app_shared.acquisition.image_backend import SOURCE_ANALYSIS, SOURCE_REPLAY, ImageFrame
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from orisys_studio.analysis_worker import AnalysisReport, AnalysisWorker
|
||||
from orisys_studio.replay import MODE_ANALYSIS
|
||||
from orisys_studio.replay_analysis import (
|
||||
AnalysisSession,
|
||||
ResultLibrary,
|
||||
ResultPolicyError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Playback tick while a recording is playing. 33 ms is one frame at 30 fps; the
|
||||
@@ -72,6 +80,15 @@ class ReplayHost(Protocol):
|
||||
|
||||
def _show_image_frame(self, frame: ImageFrame) -> None: ...
|
||||
|
||||
def _show_analysis_frame(self, packet: Any) -> None:
|
||||
"""Hand one stored analysis frame to the 3D pane's producer.
|
||||
|
||||
The pane is fed through its ordinary path (it snapshots a packet), so a replay
|
||||
goes through the same adapter, the same domain latch and the same validity
|
||||
intersection as a live run. The window owns the pane, so it owns this call.
|
||||
"""
|
||||
...
|
||||
|
||||
def _current_source_kind(self) -> str: ...
|
||||
|
||||
def _drop_render_marker(self) -> None: ...
|
||||
@@ -86,6 +103,19 @@ class ReplayHost(Protocol):
|
||||
|
||||
def _refresh_status_rail(self) -> None: ...
|
||||
|
||||
def _analysis_service(
|
||||
self, recording: Path, cancelled: Any, *, on_progress: Any = None
|
||||
) -> Any:
|
||||
"""A ready-to-run ``AnalysisService`` over ``recording``.
|
||||
|
||||
The window owns this because the chain specification comes from the app's
|
||||
sensor configuration (``chain_spec_for`` over the recording's session), which is
|
||||
window-level knowledge; the controller owns *when* a pass runs and what is done
|
||||
with its result. ``cancelled`` is the pass's cancellation event (``None`` when
|
||||
the caller only wants to ask whether a result is already stored).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplayActions:
|
||||
@@ -110,6 +140,7 @@ class ReplayController(QObject):
|
||||
charts: Any,
|
||||
image_panel: Any,
|
||||
mesh_panel: Any,
|
||||
analysis_progress: Any | None = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -119,15 +150,27 @@ class ReplayController(QObject):
|
||||
self._charts = charts
|
||||
self._image_panel = image_panel
|
||||
self._mesh_panel = mesh_panel
|
||||
#: The progress and cancel surface for an analysis pass. Optional so a caller
|
||||
#: with no place to put it (a headless controller, a test) still gets the
|
||||
#: behaviour; a pass then runs without a visible bar, and that is the caller's
|
||||
#: choice rather than a silent failure.
|
||||
self._progress = analysis_progress
|
||||
self._actions: ReplayActions | None = None
|
||||
#: True while a recompute pass is running: it is a *run* over a recording, so
|
||||
#: the acquire pipeline owns the panels and the transport stops the pass.
|
||||
self._recompute_running = False
|
||||
#: The stored results the timeline can read from, and the pass in flight.
|
||||
self.results = ResultLibrary()
|
||||
self._analysis_worker: AnalysisWorker | None = None
|
||||
self._analysis_target: Path | None = None
|
||||
self._analysis_error = ""
|
||||
self._play_from = 0.0
|
||||
self._play_started_at = 0.0
|
||||
self.play_timer = QTimer(self)
|
||||
self.play_timer.setInterval(PLAYBACK_TICK_MS)
|
||||
self.play_timer.timeout.connect(self.on_play_tick)
|
||||
if self._progress is not None:
|
||||
self._progress.cancel_requested.connect(self.on_analysis_cancel)
|
||||
|
||||
# ------------------------------------------------------------------ wiring
|
||||
def wire(self) -> None:
|
||||
@@ -139,7 +182,6 @@ class ReplayController(QObject):
|
||||
# Dragging the shared cursor on the charts scrubs the recording too.
|
||||
self._charts.cursor_moved.connect(self.on_cursor_moved)
|
||||
self._charts.timeline_taken_over.connect(self.pause_playback)
|
||||
|
||||
def bind_actions(
|
||||
self,
|
||||
*,
|
||||
@@ -354,6 +396,9 @@ class ReplayController(QObject):
|
||||
self._host._notify(t("playback.open_failed", error=Path(directory).name))
|
||||
return False
|
||||
self.refresh_state()
|
||||
# Analysis on open: serve the stored result when there is a compatible one, run
|
||||
# the pass with a progress and cancel surface when there is not.
|
||||
self.on_analysis_requested()
|
||||
return True
|
||||
|
||||
def open_latest_recording(self) -> None:
|
||||
@@ -568,20 +613,328 @@ class ReplayController(QObject):
|
||||
# frames to show again now that the pass is over.
|
||||
self.render_frame()
|
||||
|
||||
# ---------------------------------------------------------------- analysis
|
||||
def analysis_session(self) -> AnalysisSession | None:
|
||||
"""The stored result the timeline is reading, if any."""
|
||||
session = self._engine.analysis()
|
||||
if session is not None:
|
||||
return session
|
||||
return self.results.session
|
||||
|
||||
def analysis_active(self) -> bool:
|
||||
"""True while the cursor is read from a stored result.
|
||||
|
||||
Asked with ``getattr`` because the engine is a seam a test can stand in for: a
|
||||
stand-in that predates the analysable timeline has no mode, and "no mode" means
|
||||
"no result to read", which is the safe answer rather than an error.
|
||||
"""
|
||||
mode = getattr(self._engine, "mode", None)
|
||||
return (
|
||||
callable(mode)
|
||||
and mode() == MODE_ANALYSIS
|
||||
and self._engine.analysis() is not None
|
||||
)
|
||||
|
||||
def analysis_running(self) -> bool:
|
||||
worker = self._analysis_worker
|
||||
return worker is not None and worker.running()
|
||||
|
||||
def analysis_error(self) -> str:
|
||||
"""Why the last pass did not leave a playable result ("" when it did)."""
|
||||
return self._analysis_error
|
||||
|
||||
def on_display_settings(self, settings: Any) -> None:
|
||||
"""A display setting changed: redraw from the stored frame, write nothing.
|
||||
|
||||
This is the property the result key was designed for. The threshold is applied
|
||||
to the stored ``magnitude`` (so it is a *read*), the colour range and the idle
|
||||
dots live in the draw call, and none of them is an input to the pass — so
|
||||
nothing here touches the cache, and the assertion in
|
||||
``test_replay_analysis`` that the result directory set is unchanged is a test of
|
||||
this method, not of the panel.
|
||||
|
||||
A policy that moves the sample *grid* cannot be served by the stored candidate
|
||||
locations; :meth:`AnalysisSession.serve_policy` says so, the message names the
|
||||
key, and the frame is still redrawn from the result's own grid, because
|
||||
re-gridding it would draw samples nothing measured.
|
||||
"""
|
||||
session = self.analysis_session()
|
||||
if session is None or not self.analysis_active():
|
||||
return
|
||||
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
|
||||
|
||||
policy = policy_for_settings(settings)
|
||||
moved = not session.serve_policy(policy)
|
||||
if moved:
|
||||
self._host._notify(t("playback.analysis_grid_mismatch", keys="·".join(moved)))
|
||||
self._engine.redraw()
|
||||
|
||||
def on_analysis_requested(self) -> None:
|
||||
"""Run the pass over what is open, unless a compatible result already exists.
|
||||
|
||||
The operator-facing entry for "re-run": the recompute switch calls this once it
|
||||
has been turned on, and opening a recording calls it when no result is stored.
|
||||
"""
|
||||
if self.analysis_running():
|
||||
self._host._notify(t("playback.analysis_busy"))
|
||||
return
|
||||
if self._host._running or self._host._preview_open or self._host._session_active:
|
||||
# The pass opens an SDK chain in this process, and so does a session. One
|
||||
# at a time, and the working directory stays untouched while it runs.
|
||||
self._host._notify(t("analysis.blocked"))
|
||||
self._set_recompute_checked(False)
|
||||
return
|
||||
recording = self.recording_directory()
|
||||
if recording is None:
|
||||
self._host._notify(t("playback.analysis_no_source"))
|
||||
self._set_recompute_checked(False)
|
||||
return
|
||||
self._analysis_error = ""
|
||||
if self._activate_stored_result(recording, notify_reuse=True):
|
||||
return
|
||||
self._start_pass(recording)
|
||||
|
||||
def recording_directory(self) -> Path | None:
|
||||
"""The recording directory an analysis pass should read."""
|
||||
directory = self._engine.directory()
|
||||
if directory is not None:
|
||||
return Path(directory)
|
||||
named = self._toolbar.video_path()
|
||||
if named:
|
||||
folder = Path(named)
|
||||
if (folder / "session.json").is_file():
|
||||
return folder
|
||||
if (folder.parent / "session.json").is_file():
|
||||
return folder.parent
|
||||
return None
|
||||
|
||||
def _activate_stored_result(
|
||||
self, recording: Path, *, notify_reuse: bool = False, reanalyse: bool = False
|
||||
) -> bool:
|
||||
"""Point the timeline at a stored result, if one is compatible.
|
||||
|
||||
Returns ``False`` when there is nothing to read — no result, or a result the
|
||||
reader refuses (an incomplete one), which is a *reason to run a pass* rather
|
||||
than something to serve. The refusal is deliberate and is the whole point of
|
||||
Part A's marker: a cancelled pass must never play back like a finished one.
|
||||
"""
|
||||
service = self._host._analysis_service(recording, None)
|
||||
reader = None
|
||||
try:
|
||||
reader = service.stored_result()
|
||||
except Exception as exc: # noqa: BLE001 - an unreadable result means "run one"
|
||||
logger.info("the stored result is not usable: %s", exc)
|
||||
self._analysis_error = str(exc)
|
||||
reader = None
|
||||
if reader is None:
|
||||
return False
|
||||
try:
|
||||
session = AnalysisSession(
|
||||
reader,
|
||||
policy=getattr(service, "policy", None),
|
||||
display_policy=self._display_policy(),
|
||||
session_id=self._session_number(recording),
|
||||
recording=recording,
|
||||
)
|
||||
except ResultPolicyError as exc:
|
||||
# Incomplete (or otherwise unplayable): close it and run a fresh pass.
|
||||
logger.info("refusing to play an incomplete result: %s", exc)
|
||||
self._analysis_error = str(exc)
|
||||
try:
|
||||
reader.close()
|
||||
except Exception: # noqa: BLE001 - releasing is best effort
|
||||
logger.debug("could not close the refused reader", exc_info=True)
|
||||
if reanalyse and self._progress is not None:
|
||||
self._progress.failed(reason=str(exc))
|
||||
return False
|
||||
self.results.close()
|
||||
self._adopt_session(session)
|
||||
frames = session.frame_count
|
||||
if notify_reuse:
|
||||
# The rail's own notification (the 3D pane's source line and the 2D footer say
|
||||
# "analysis result"), not the transient status line: this is a state, and it
|
||||
# stays true for as long as the timeline is in it.
|
||||
self._host._refresh_status_rail()
|
||||
if self._progress is not None:
|
||||
self._progress.reused(frames=frames)
|
||||
return True
|
||||
|
||||
def _adopt_session(self, session: AnalysisSession) -> None:
|
||||
"""One timeline: the cursor, the charts and both figures read this result."""
|
||||
self.results.open_session(session)
|
||||
self._engine.use_analysis(session)
|
||||
self._charts.set_recording_source(self._engine.reader())
|
||||
self._image_panel.set_source_kind("analysis")
|
||||
self._mesh_panel.set_source_kind("analysis")
|
||||
if self._actions is not None:
|
||||
self._actions.recompute.setEnabled(True)
|
||||
self.render_frame()
|
||||
|
||||
def _display_policy(self) -> Any:
|
||||
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
|
||||
|
||||
settings = getattr(self._mesh_panel, "display_settings", None)
|
||||
return policy_for_settings(settings() if callable(settings) else None)
|
||||
|
||||
def _session_number(self, recording: Path) -> int:
|
||||
reader = self._engine.reader()
|
||||
if reader is not None:
|
||||
try:
|
||||
return int(dict(reader.session().get("session_id") or 0))
|
||||
except Exception: # noqa: BLE001 - a session number is a nicety
|
||||
logger.debug("could not read the session id", exc_info=True)
|
||||
return 0
|
||||
|
||||
def _start_pass(self, recording: Path) -> None:
|
||||
"""Run the analysis pass on a worker thread, with progress and a cancel."""
|
||||
worker = AnalysisWorker(
|
||||
lambda event, on_progress: self._host._analysis_service(
|
||||
recording, event, on_progress=on_progress
|
||||
),
|
||||
parent=self,
|
||||
)
|
||||
worker.progressed.connect(self.on_analysis_progress)
|
||||
worker.finished.connect(self.on_analysis_finished)
|
||||
self._analysis_worker = worker
|
||||
self._analysis_target = Path(recording)
|
||||
if self._progress is not None:
|
||||
self._progress.start()
|
||||
if not worker.start(): # pragma: no cover - guarded by ``analysis_running``
|
||||
logger.warning("an analysis pass was already running")
|
||||
self._analysis_worker = None
|
||||
|
||||
def on_analysis_progress(self, progress: Any) -> None:
|
||||
if self._progress is None:
|
||||
return
|
||||
self._progress.progress(
|
||||
written=int(getattr(progress, "frames_written", 0)),
|
||||
expected=int(getattr(progress, "frames_expected", 0)),
|
||||
segment_index=int(getattr(progress, "segment_index", 0)),
|
||||
segment_count=int(getattr(progress, "segment_count", 0)),
|
||||
frame_id=int(getattr(progress, "frame_id", 0)),
|
||||
elapsed_s=float(getattr(progress, "elapsed_s", 0.0)),
|
||||
)
|
||||
|
||||
def on_analysis_finished(self, report: AnalysisReport) -> None:
|
||||
"""The pass ended: serve it only when it is complete, and say so when it is not.
|
||||
|
||||
Nothing here writes to the transient status line, deliberately. An analysis pass
|
||||
is asynchronous and can end seconds after the operator did something else, and a
|
||||
status message that outlives its cause made an unrelated action look like it
|
||||
failed — measured on ``test_the_replay_reset_is_the_display_reset_and_never_the_device``,
|
||||
whose "display reset" line a failing pass overwrote. The progress surface stays
|
||||
up with the outcome (complete, cancelled, or failed *with the reason*), and
|
||||
:meth:`analysis_error` carries the same reason for a caller that asks.
|
||||
"""
|
||||
self._analysis_worker = None
|
||||
target = self._analysis_target
|
||||
if report.cancelled:
|
||||
self._analysis_error = "cancelled"
|
||||
if self._progress is not None:
|
||||
self._progress.cancelled(frames=report.frames_written)
|
||||
self._set_recompute_checked(False)
|
||||
return
|
||||
if not report.complete:
|
||||
self._analysis_error = report.reason or "incomplete"
|
||||
if self._progress is not None:
|
||||
self._progress.failed(
|
||||
reason=self._analysis_error, frames=report.frames_written
|
||||
)
|
||||
self._set_recompute_checked(False)
|
||||
return
|
||||
if target is None or not self._activate_stored_result(target):
|
||||
# A complete pass that cannot be opened is a bug, not a state to show as
|
||||
# success: report it rather than leaving the operator with a progress bar
|
||||
# that says "finished" over a recording that is not being served.
|
||||
self._analysis_error = "unreadable"
|
||||
if self._progress is not None:
|
||||
self._progress.failed(reason="unreadable")
|
||||
return
|
||||
if self._progress is not None:
|
||||
self._progress.complete(
|
||||
frames=report.frames_written, elapsed_s=report.elapsed_s
|
||||
)
|
||||
# The rail states what the timeline is reading from (the source label), so a
|
||||
# completed pass says so where the state lives rather than in a line that expires.
|
||||
self._host._refresh_status_rail()
|
||||
self.refresh_state()
|
||||
|
||||
def on_analysis_cancel(self) -> None:
|
||||
"""The operator pressed cancel: the pass stops and its result stays incomplete."""
|
||||
worker = self._analysis_worker
|
||||
if worker is None:
|
||||
return
|
||||
worker.cancel()
|
||||
|
||||
def cancel_analysis(self) -> bool:
|
||||
"""Ask a running pass to stop. Returns whether there was one."""
|
||||
worker = self._analysis_worker
|
||||
if worker is None or not worker.running():
|
||||
return False
|
||||
worker.cancel()
|
||||
return True
|
||||
|
||||
def wait_for_analysis(self, timeout_s: float = 10.0) -> bool:
|
||||
"""Block until a pass has finished. For teardown and for tests."""
|
||||
worker = self._analysis_worker
|
||||
if worker is None:
|
||||
return True
|
||||
return worker.wait(timeout_s)
|
||||
|
||||
def _set_recompute_checked(self, value: bool) -> None:
|
||||
if self._actions is None:
|
||||
return
|
||||
if self._actions.recompute.isChecked() == bool(value):
|
||||
return
|
||||
self._actions.recompute.blockSignals(True)
|
||||
self._actions.recompute.setChecked(bool(value))
|
||||
self._actions.recompute.blockSignals(False)
|
||||
self._toolbar.set_recompute_checked(bool(value))
|
||||
|
||||
# ------------------------------------------------------------------ drawing
|
||||
def render_frame(self) -> None:
|
||||
"""Draw what the 2D panel should show for the current replay cursor.
|
||||
|
||||
A recording stores the raw camera image and nothing else, so the frame this
|
||||
builds has no flow and no depth — the plan then says so, in one line, and the
|
||||
panel offers exactly the one view the frame can be. The processed picture of the
|
||||
same footage is what 「从视频重算」 produces, through the *same* backend.
|
||||
One method for both timeline modes, because it is one cursor: in analysis mode
|
||||
the picture is the stored frame's own flow field (the pass's candidate
|
||||
locations, on the domain grid), and in recording mode it is the decoded video
|
||||
frame, which carries no flow and no depth — so the plan says so in one line and
|
||||
the panel offers exactly the one view the frame can be. The window's single 2D
|
||||
sink draws both through the *same* backend, so the arrows, the idle dots, the
|
||||
colour range and the domain boundary behave identically in the two modes.
|
||||
|
||||
Nothing here writes anything: a display change reaches this method through the
|
||||
engine's ``redraw()`` and rebuilds the picture from the same stored frame.
|
||||
"""
|
||||
if not self._host._in_replay_view() or not self._engine.has_video():
|
||||
return
|
||||
if self._recompute_running:
|
||||
# The pipeline is drawing its own frames (see ``owns_image``).
|
||||
return
|
||||
if self.analysis_active():
|
||||
frame = self._engine.analysed_frame()
|
||||
if frame is None:
|
||||
self._image_panel.clear_preview(self._engine.video_message())
|
||||
return
|
||||
self._host._show_image_frame(
|
||||
ImageFrame(
|
||||
frame.base_bgr,
|
||||
flow=frame.flow,
|
||||
valid_mask=frame.valid_mask,
|
||||
source=SOURCE_ANALYSIS,
|
||||
frame_id=int(frame.frame_id),
|
||||
t_rel_s=float(frame.t_rel_s),
|
||||
flow_valid=True,
|
||||
flow_frame_id=int(frame.frame_id),
|
||||
depth_valid=False,
|
||||
)
|
||||
)
|
||||
session = self._engine.analysis()
|
||||
if session is not None:
|
||||
# The same frame, in the shape the 3D pane's producer consumes: one
|
||||
# cursor, two figures, one frame id.
|
||||
self._host._show_analysis_frame(session.packet(frame))
|
||||
return
|
||||
image = self._engine.video_frame_bgr()
|
||||
if image is None:
|
||||
self._image_panel.clear_preview(self._engine.video_message())
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""The progress and cancel surface for an analysis pass.
|
||||
|
||||
One widget, four states, and one button. It exists because analysis-on-open is the
|
||||
approved default: opening a recording runs a pass that costs about half a wall-second
|
||||
per video-second, so the operator has to be able to see that it is happening, see how
|
||||
far along it is, and stop it.
|
||||
|
||||
The state it shows is deliberately the **result's** state, not the thread's:
|
||||
|
||||
``reused``
|
||||
a compatible stored result was already there and is being played; nothing ran.
|
||||
``running``
|
||||
the pass is going. The bar is ``frames written / frames expected`` and the line
|
||||
under it names the segment.
|
||||
``complete``
|
||||
the pass finished and the result may be played from.
|
||||
``cancelled`` / ``failed``
|
||||
the pass did **not** finish. Both say so in the same place, and neither is dressed
|
||||
as success: a cancelled or failed pass leaves an incomplete result on disk, and the
|
||||
reader refuses it, so "it looks finished" is exactly the state this surface must
|
||||
never show for one.
|
||||
|
||||
Part C owns the rail's transport and reset controls; this widget owns nothing outside
|
||||
its own frame, and its cancel button is enabled only while a pass is running.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
#: The states the surface can be in. ``idle`` is "nothing to say" and hides the widget.
|
||||
IDLE = "idle"
|
||||
REUSED = "reused"
|
||||
RUNNING = "running"
|
||||
COMPLETE = "complete"
|
||||
CANCELLED = "cancelled"
|
||||
FAILED = "failed"
|
||||
|
||||
#: Which theme token colours the headline in each state.
|
||||
_STATE_TOKEN = {
|
||||
IDLE: "hint_text",
|
||||
REUSED: "info",
|
||||
RUNNING: "accent",
|
||||
COMPLETE: "success",
|
||||
CANCELLED: "warning",
|
||||
FAILED: "danger",
|
||||
}
|
||||
|
||||
#: The progress bar's thousandths (Qt's own percentage unit, 0–1000).
|
||||
_FULL = 1000
|
||||
|
||||
|
||||
class AnalysisProgress(QFrame):
|
||||
"""Shows one analysis pass: how far, which segment, and how to stop it."""
|
||||
|
||||
#: The operator asked to cancel the running pass.
|
||||
cancel_requested = Signal()
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self._theme = str(theme)
|
||||
self._state = IDLE
|
||||
self._detail = ""
|
||||
self._cancelling = False
|
||||
self.setObjectName("analysisProgress")
|
||||
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
|
||||
self.title_label = QLabel(t("analysis.title"), self)
|
||||
self.title_label.setObjectName("analysisTitle")
|
||||
self.detail_label = QLabel("", self)
|
||||
self.detail_label.setObjectName("analysisDetail")
|
||||
self.detail_label.setWordWrap(True)
|
||||
self.progress_bar = QProgressBar(self)
|
||||
self.progress_bar.setObjectName("analysisBar")
|
||||
self.progress_bar.setRange(0, _FULL)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.setTextVisible(False)
|
||||
self.progress_bar.setFixedHeight(6)
|
||||
self.stats_label = QLabel("", self)
|
||||
self.stats_label.setObjectName("analysisStats")
|
||||
self.cancel_button = QPushButton(t("analysis.cancel"), self)
|
||||
self.cancel_button.setObjectName("analysisCancel")
|
||||
self.cancel_button.setToolTip(t("analysis.cancel_tip"))
|
||||
self.cancel_button.clicked.connect(self._on_cancel_clicked)
|
||||
|
||||
footer = QHBoxLayout()
|
||||
footer.setContentsMargins(0, 0, 0, 0)
|
||||
footer.setSpacing(8)
|
||||
footer.addWidget(self.stats_label, 1)
|
||||
footer.addWidget(self.cancel_button, 0)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(10, 8, 10, 8)
|
||||
layout.setSpacing(4)
|
||||
layout.addWidget(self.title_label)
|
||||
layout.addWidget(self.progress_bar)
|
||||
layout.addLayout(footer)
|
||||
layout.addWidget(self.detail_label)
|
||||
|
||||
self._refresh()
|
||||
self.setVisible(False)
|
||||
|
||||
# --------------------------------------------------------------------- API
|
||||
def state(self) -> str:
|
||||
return self._state
|
||||
|
||||
def detail(self) -> str:
|
||||
return self._detail
|
||||
|
||||
def cancelling(self) -> bool:
|
||||
"""True between the cancel click and the pass's own report."""
|
||||
return self._cancelling
|
||||
|
||||
def start(self) -> None:
|
||||
"""A pass began: show the surface empty and cancellable."""
|
||||
self._state = RUNNING
|
||||
self._cancelling = False
|
||||
self._detail = ""
|
||||
self.progress_bar.setValue(0)
|
||||
self.stats_label.setText(t("analysis.frames", written=0, expected=0))
|
||||
self.setVisible(True)
|
||||
self._refresh()
|
||||
|
||||
def progress(
|
||||
self,
|
||||
*,
|
||||
written: int,
|
||||
expected: int,
|
||||
segment_index: int = 0,
|
||||
segment_count: int = 0,
|
||||
frame_id: int = 0,
|
||||
elapsed_s: float = 0.0,
|
||||
) -> None:
|
||||
"""One coalesced update from the pass."""
|
||||
if self._state != RUNNING:
|
||||
return
|
||||
fraction = 0.0 if expected <= 0 else min(1.0, written / expected)
|
||||
self.progress_bar.setValue(int(round(fraction * _FULL)))
|
||||
self.stats_label.setText(
|
||||
t("analysis.frames", written=int(written), expected=int(expected))
|
||||
)
|
||||
parts: list[str] = []
|
||||
if segment_count > 0:
|
||||
parts.append(
|
||||
t(
|
||||
"analysis.segment",
|
||||
index=int(segment_index),
|
||||
count=int(segment_count),
|
||||
)
|
||||
)
|
||||
if frame_id:
|
||||
parts.append(f"#{int(frame_id)}")
|
||||
if elapsed_s > 0:
|
||||
parts.append(t("analysis.elapsed", seconds=float(elapsed_s)))
|
||||
self._detail = " · ".join(parts)
|
||||
self._refresh()
|
||||
|
||||
def reused(self, *, frames: int = 0) -> None:
|
||||
"""A stored result was already there: nothing ran, and that is the good news."""
|
||||
self._state = REUSED
|
||||
self._cancelling = False
|
||||
self.progress_bar.setValue(_FULL)
|
||||
self.stats_label.setText(t("analysis.frames", written=frames, expected=frames))
|
||||
self._detail = ""
|
||||
self.setVisible(True)
|
||||
self._refresh()
|
||||
|
||||
def complete(self, *, frames: int = 0, elapsed_s: float = 0.0) -> None:
|
||||
"""The pass finished and the result may be played from."""
|
||||
self._state = COMPLETE
|
||||
self._cancelling = False
|
||||
self.progress_bar.setValue(_FULL)
|
||||
self.stats_label.setText(t("analysis.frames", written=frames, expected=frames))
|
||||
self._detail = t("analysis.complete", frames=int(frames), seconds=float(elapsed_s))
|
||||
self.setVisible(True)
|
||||
self._refresh()
|
||||
|
||||
def cancelled(self, *, frames: int = 0) -> None:
|
||||
"""The pass was stopped: the result is incomplete and will not be played."""
|
||||
self._state = CANCELLED
|
||||
self._cancelling = False
|
||||
self.progress_bar.setValue(0)
|
||||
self.stats_label.setText(t("analysis.frames", written=frames, expected=frames))
|
||||
self._detail = t("analysis.cancelled", frames=int(frames))
|
||||
self.setVisible(True)
|
||||
self._refresh()
|
||||
|
||||
def failed(self, *, reason: str = "", frames: int = 0, cancelled: bool = False) -> None:
|
||||
"""The pass did not finish, whether by cancellation or by failure."""
|
||||
if cancelled:
|
||||
self.cancelled(frames=frames)
|
||||
return
|
||||
self._state = FAILED
|
||||
self._cancelling = False
|
||||
self.progress_bar.setValue(0)
|
||||
self.stats_label.setText(t("analysis.frames", written=frames, expected=0))
|
||||
self._detail = t("analysis.failed", reason=reason or "?")
|
||||
self.setVisible(True)
|
||||
self._refresh()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Hide the surface: no pass has anything to say."""
|
||||
self._state = IDLE
|
||||
self._cancelling = False
|
||||
self._detail = ""
|
||||
self.progress_bar.setValue(0)
|
||||
self.setVisible(False)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self._refresh()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title_label.setText(t("analysis.title"))
|
||||
self.cancel_button.setText(
|
||||
t("analysis.cancelling") if self._cancelling else t("analysis.cancel")
|
||||
)
|
||||
self.cancel_button.setToolTip(t("analysis.cancel_tip"))
|
||||
self._refresh()
|
||||
|
||||
# ------------------------------------------------------------------ private
|
||||
def _on_cancel_clicked(self) -> None:
|
||||
if self._state != RUNNING or self._cancelling:
|
||||
return
|
||||
self._cancelling = True
|
||||
self.retranslate()
|
||||
self.cancel_requested.emit()
|
||||
|
||||
def _refresh(self) -> None:
|
||||
"""Repaint from the state: colour, which chrome is offered, and the tooltip.
|
||||
|
||||
The title is one string in every state — what a pass is doing does not change
|
||||
its name — so the *state* reaches the operator through the colour token, the
|
||||
detail line and whether cancel is offered, not through a second vocabulary.
|
||||
"""
|
||||
token = _STATE_TOKEN.get(self._state, "hint_text")
|
||||
running = self._state == RUNNING
|
||||
self.cancel_button.setVisible(running)
|
||||
self.cancel_button.setEnabled(running and not self._cancelling)
|
||||
self.progress_bar.setVisible(running)
|
||||
self.title_label.setText(t("analysis.title"))
|
||||
self.title_label.setStyleSheet(
|
||||
f"color: {color(self._theme, token)}; font-weight: 600;"
|
||||
)
|
||||
self.detail_label.setText(self._detail)
|
||||
self.detail_label.setVisible(bool(self._detail))
|
||||
self.stats_label.setStyleSheet(f"color: {color(self._theme, 'text_secondary')};")
|
||||
self.detail_label.setStyleSheet(
|
||||
f"color: {color(self._theme, 'text_secondary')};"
|
||||
)
|
||||
self.setStyleSheet(
|
||||
"QFrame#analysisProgress {"
|
||||
f" background: {color(self._theme, 'card_bg')};"
|
||||
f" border: 1px solid {color(self._theme, 'card_border')};"
|
||||
" border-radius: 6px; }"
|
||||
"QProgressBar {"
|
||||
f" background: {color(self._theme, 'input_bg')};"
|
||||
f" border: 1px solid {color(self._theme, 'input_border')};"
|
||||
" border-radius: 3px; }"
|
||||
"QProgressBar::chunk {"
|
||||
f" background: {color(self._theme, 'accent')}; border-radius: 2px; }}"
|
||||
)
|
||||
self.setToolTip(self._detail)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
@@ -0,0 +1,906 @@
|
||||
"""Part B: playback served from an analysis result.
|
||||
|
||||
What is being defended, in the order the plan asked for it:
|
||||
|
||||
1. **one timeline.** The charts, the 2D canvas and the 3D pane read one cursor, and a
|
||||
direct seek to frame *N* produces the same frame as playing forward to *N*;
|
||||
2. **analysis on open.** A recording opens on a compatible stored result, and runs the
|
||||
pass (with a progress and cancel surface) only when there is none;
|
||||
3. **a display change writes nothing.** The flow threshold, the colour range and the
|
||||
idle dots are reads: the result directory set is unchanged;
|
||||
4. **an incomplete result is never served.** A cancelled or failed pass is reported as
|
||||
cancelled or failed, and playback is not offered from it;
|
||||
5. **frames are addressed by id.** An id the recording does not hold is refused, never
|
||||
coerced into an index.
|
||||
|
||||
The fixtures use real footage — the 33-frame box recording under ``dist/`` and the
|
||||
shipped finger configuration — driven through the **finger** chain, which is what gives
|
||||
the result a validity mask and therefore a static candidate grid. The pass is O(5 s)
|
||||
and is shared per module. Where the SDK or the footage is absent the tests skip rather
|
||||
than fake a result: a synthetic result would not defend the id span (20…52), the
|
||||
keyframe identity or the sample round trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from orisys_app_shared.analysis.cache import ResultCache, scan_results
|
||||
from orisys_app_shared.analysis.reader import (
|
||||
AnalysisIncompleteError,
|
||||
AnalysisNotFoundError,
|
||||
AnalysisReader,
|
||||
)
|
||||
from orisys_app_shared.analysis.service import AnalysisService, ChainSpec
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||
from orisys_app_shared.scene3d.visual_policy import (
|
||||
policy_for_settings,
|
||||
select_flow_samples,
|
||||
)
|
||||
from orisys_studio.analysis_worker import AnalysisWorker, report_from
|
||||
from orisys_studio.replay import MODE_ANALYSIS, MODE_RECORDING
|
||||
from orisys_studio.replay_analysis import (
|
||||
AnalysisSession,
|
||||
ResultLibrary,
|
||||
ResultPolicyError,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
STUDIO = REPO / "dist" / "OrisysStudio"
|
||||
RECORDING = (
|
||||
STUDIO
|
||||
/ "outputs"
|
||||
/ "recordings"
|
||||
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
|
||||
)
|
||||
FINGER_CONFIG = STUDIO / "config" / "sensors" / "finger" / "sensor.json"
|
||||
FINGER_EXPORT = STUDIO / "config" / "sensors" / "finger" / "assets" / "30fps-0703"
|
||||
|
||||
#: The evidence recording's own frame ids. Deliberately not 0…32: an implementation
|
||||
#: that used an index would pass every test written against a 0-based recording.
|
||||
FIRST_FRAME_ID = 20
|
||||
LAST_FRAME_ID = 52
|
||||
|
||||
|
||||
def _require_inputs() -> None:
|
||||
missing = [
|
||||
str(path)
|
||||
for path in (RECORDING / "frames.csv", RECORDING / "raw_0001.mp4", FINGER_CONFIG)
|
||||
if not path.exists()
|
||||
]
|
||||
if missing:
|
||||
pytest.skip("analysis inputs are not present: " + ", ".join(missing))
|
||||
pytest.importorskip("orisys", reason="the SDK is needed to run the analysis pass")
|
||||
|
||||
|
||||
def _finger_chain() -> ChainSpec:
|
||||
return ChainSpec(
|
||||
sensor_type="finger", config_ref=FINGER_CONFIG, export_dir=FINGER_EXPORT
|
||||
)
|
||||
|
||||
|
||||
def _service(root: Path, **kwargs) -> AnalysisService:
|
||||
return AnalysisService(RECORDING, chain=_finger_chain(), cache=ResultCache(root), **kwargs)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ fixtures
|
||||
@pytest.fixture(scope="module")
|
||||
def pass_root(tmp_path_factory) -> Path:
|
||||
"""One real analysis pass over the evidence recording, in its own cache root."""
|
||||
_require_inputs()
|
||||
|
||||
root = tmp_path_factory.mktemp("replay-analysis-b")
|
||||
outcome = _service(root).run()
|
||||
assert outcome.complete, outcome.reason
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reader(pass_root: Path):
|
||||
reader = AnalysisReader(_result_directory(pass_root))
|
||||
yield reader
|
||||
reader.close()
|
||||
|
||||
|
||||
def _result_directory(root: Path) -> Path:
|
||||
directories = [entry.directory for entry in scan_results(root) if entry.complete]
|
||||
assert directories, f"no complete result under {root}"
|
||||
return directories[0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def session(reader) -> AnalysisSession:
|
||||
service = _service(Path(reader.directory).parent)
|
||||
return AnalysisSession(
|
||||
reader,
|
||||
policy=service.policy,
|
||||
display_policy=policy_for_settings(DisplaySettings()),
|
||||
session_id=2,
|
||||
recording=RECORDING,
|
||||
)
|
||||
|
||||
|
||||
def _same_samples(left, right) -> dict[str, bool]:
|
||||
"""Compare two ``FlowSamples`` (or two frames that carry one) array by array."""
|
||||
left = getattr(left, "samples", left)
|
||||
right = getattr(right, "samples", right)
|
||||
return {
|
||||
name: bool(
|
||||
np.array_equal(
|
||||
np.asarray(getattr(left, name)), np.asarray(getattr(right, name))
|
||||
)
|
||||
)
|
||||
for name in ("rows", "cols", "dx", "dy", "active")
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- fakes (Qt)
|
||||
class _Signal:
|
||||
def __init__(self) -> None:
|
||||
self.slots: list = []
|
||||
|
||||
def connect(self, slot) -> None:
|
||||
self.slots.append(slot)
|
||||
|
||||
|
||||
class _Action:
|
||||
def __init__(self, checked: bool = False) -> None:
|
||||
self._checked = bool(checked)
|
||||
self.enabled: bool | None = None
|
||||
self.tooltips: list[str] = []
|
||||
self.triggered = _Signal()
|
||||
self.toggled = _Signal()
|
||||
|
||||
def isChecked(self) -> bool: # noqa: N802 - Qt's spelling
|
||||
return self._checked
|
||||
|
||||
def setChecked(self, value: bool) -> None: # noqa: N802
|
||||
self._checked = bool(value)
|
||||
|
||||
def setEnabled(self, value: bool) -> None: # noqa: N802
|
||||
self.enabled = bool(value)
|
||||
|
||||
def setToolTip(self, text: str) -> None: # noqa: N802
|
||||
self.tooltips.append(str(text))
|
||||
|
||||
def blockSignals(self, flag: bool) -> None: # noqa: N802
|
||||
pass
|
||||
|
||||
|
||||
class _Button:
|
||||
def __init__(self) -> None:
|
||||
self.checked = False
|
||||
self.enabled = True
|
||||
|
||||
def isChecked(self) -> bool: # noqa: N802
|
||||
return self.checked
|
||||
|
||||
def isEnabled(self) -> bool: # noqa: N802
|
||||
return self.enabled
|
||||
|
||||
def setChecked(self, value: bool) -> None: # noqa: N802
|
||||
self.checked = bool(value)
|
||||
|
||||
|
||||
class _Toolbar:
|
||||
def __init__(self, *, video_path: str = "") -> None:
|
||||
self.btn_run = _Button()
|
||||
self._video_path = video_path
|
||||
self.run_checked: list[bool] = []
|
||||
self.source_modes: list[str] = []
|
||||
self.recompute_enabled: list[bool] = []
|
||||
self.recompute_checked: list[bool] = []
|
||||
|
||||
def run_role(self) -> str:
|
||||
return "playback"
|
||||
|
||||
def video_path(self) -> str:
|
||||
return self._video_path
|
||||
|
||||
def set_video_path(self, path: str) -> None:
|
||||
self._video_path = str(path)
|
||||
|
||||
def set_run_checked(self, value: bool) -> None:
|
||||
self.run_checked.append(bool(value))
|
||||
|
||||
def refresh_labels(self) -> None:
|
||||
pass
|
||||
|
||||
def set_source_mode(self, mode: str) -> None:
|
||||
self.source_modes.append(str(mode))
|
||||
|
||||
def set_recompute_enabled(self, value: bool) -> None:
|
||||
self.recompute_enabled.append(bool(value))
|
||||
|
||||
def set_recompute_checked(self, value: bool) -> None:
|
||||
self.recompute_checked.append(bool(value))
|
||||
|
||||
|
||||
class _Charts:
|
||||
def __init__(self) -> None:
|
||||
self.cursors: list[float] = []
|
||||
self._recording = False
|
||||
self.cleared = 0
|
||||
self.unit_value = "raw"
|
||||
|
||||
def unit(self) -> str:
|
||||
return self.unit_value
|
||||
|
||||
def set_cursor(self, position: float) -> None:
|
||||
self.cursors.append(float(position))
|
||||
|
||||
def set_recording_source(self, reader) -> None:
|
||||
self._recording = reader is not None
|
||||
|
||||
def clear_recording_source(self) -> None:
|
||||
self.cleared += 1
|
||||
self._recording = False
|
||||
|
||||
def is_recording_source(self) -> bool:
|
||||
return self._recording
|
||||
|
||||
def follow_latest(self) -> None:
|
||||
pass
|
||||
|
||||
def show_all(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _ImagePanel:
|
||||
def __init__(self) -> None:
|
||||
self.kinds: list[str] = []
|
||||
self.cleared: list[str] = []
|
||||
|
||||
def set_source_kind(self, kind: str) -> None:
|
||||
self.kinds.append(str(kind))
|
||||
|
||||
def clear_preview(self, message: str) -> None:
|
||||
self.cleared.append(str(message))
|
||||
|
||||
|
||||
class _Mesh:
|
||||
def __init__(self) -> None:
|
||||
self.kinds: list[str] = []
|
||||
self.reference_only = 0
|
||||
self._settings = DisplaySettings()
|
||||
|
||||
def set_source_kind(self, kind: str) -> None:
|
||||
self.kinds.append(str(kind))
|
||||
|
||||
def show_reference_only(self) -> None:
|
||||
self.reference_only += 1
|
||||
|
||||
def display_settings(self) -> DisplaySettings:
|
||||
return self._settings
|
||||
|
||||
def set_display_settings(self, settings: DisplaySettings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
|
||||
class _Progress:
|
||||
"""The progress surface as the controller uses it, recording every transition."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.cancel_requested = _Signal()
|
||||
self.states: list[str] = []
|
||||
self.updates: list[dict] = []
|
||||
self.cancels = 0
|
||||
|
||||
def start(self) -> None:
|
||||
self.states.append("running")
|
||||
|
||||
def progress(self, **kwargs) -> None:
|
||||
self.updates.append(dict(kwargs))
|
||||
|
||||
def reused(self, **kwargs) -> None:
|
||||
self.states.append("reused")
|
||||
|
||||
def complete(self, **kwargs) -> None:
|
||||
self.states.append("complete")
|
||||
|
||||
def cancelled(self, **kwargs) -> None:
|
||||
self.states.append("cancelled")
|
||||
|
||||
def failed(self, **kwargs) -> None:
|
||||
self.states.append("failed")
|
||||
|
||||
def clear(self) -> None:
|
||||
self.states.append("idle")
|
||||
|
||||
|
||||
class _Host:
|
||||
def __init__(self, service_factory=None) -> None:
|
||||
self.prefs: dict = {}
|
||||
self.view = "replay"
|
||||
self._running = False
|
||||
self._preview_open = False
|
||||
self._session_active = False
|
||||
self.notes: list[str] = []
|
||||
self.frames: list = []
|
||||
self.analysis_packets: list = []
|
||||
self.units: list[str] = []
|
||||
self.presets: list[str] = []
|
||||
self.service_factory = service_factory
|
||||
self.services_built = 0
|
||||
|
||||
def _in_replay_view(self) -> bool:
|
||||
return self.view == "replay"
|
||||
|
||||
def _current_source_kind(self) -> str:
|
||||
return "replay"
|
||||
|
||||
def _apply_view_preset(self, preset: str) -> None:
|
||||
self.presets.append(preset)
|
||||
self.view = preset
|
||||
|
||||
def _set_display_unit(self, unit: str) -> None:
|
||||
self.units.append(unit)
|
||||
|
||||
def _set_source_mode(self, mode: str) -> None:
|
||||
pass
|
||||
|
||||
def _show_image_frame(self, frame) -> None:
|
||||
self.frames.append(frame)
|
||||
|
||||
def _show_analysis_frame(self, packet) -> None:
|
||||
self.analysis_packets.append(packet)
|
||||
|
||||
def _drop_render_marker(self) -> None:
|
||||
pass
|
||||
|
||||
def _notify(self, text: str, ms: int = 0) -> None:
|
||||
self.notes.append(str(text))
|
||||
|
||||
def _start_run(self) -> None:
|
||||
pass
|
||||
|
||||
def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None:
|
||||
pass
|
||||
|
||||
def _update_run_enabled(self) -> None:
|
||||
pass
|
||||
|
||||
def _refresh_status_rail(self) -> None:
|
||||
pass
|
||||
|
||||
def _analysis_service(self, recording: Path, cancelled=None, *, on_progress=None):
|
||||
self.services_built += 1
|
||||
if self.service_factory is None:
|
||||
raise AssertionError("the test did not provide an analysis service")
|
||||
return self.service_factory(recording, cancelled, on_progress)
|
||||
|
||||
|
||||
class _RecordingReader:
|
||||
"""The recording reader as the engine and the controller use it."""
|
||||
|
||||
def __init__(self, directory: Path) -> None:
|
||||
self.directory = Path(directory)
|
||||
self.closed = False
|
||||
|
||||
def session(self) -> dict:
|
||||
return {"session_id": 2, "device": {"sensor_type": "finger"}}
|
||||
|
||||
def segments(self) -> list[Path]:
|
||||
return [self.directory / "raw_0001.mp4"]
|
||||
|
||||
def info(self):
|
||||
return type("Info", (), {"duration_s": 1.92, "newtons": None, "decimated": False})()
|
||||
|
||||
def video_frame_at(self, t_rel_s: float):
|
||||
from orisys_app_shared.record import RecordingFrame
|
||||
|
||||
index = int(t_rel_s * 30)
|
||||
frame = RecordingFrame(
|
||||
frame_id=FIRST_FRAME_ID + index,
|
||||
t_rel_s=float(t_rel_s),
|
||||
in_video=True,
|
||||
dropped=False,
|
||||
segment=1,
|
||||
)
|
||||
return np.zeros((4, 4, 3), dtype=np.uint8), frame
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _controller(engine=None, host=None, **kwargs):
|
||||
from orisys_studio.replay_controller import ReplayController
|
||||
|
||||
engine = engine if engine is not None else _Engine()
|
||||
host = host if host is not None else _Host()
|
||||
toolbar = kwargs.pop("toolbar", None) or _Toolbar()
|
||||
charts = kwargs.pop("charts", None) or _Charts()
|
||||
panel = kwargs.pop("image_panel", None) or _ImagePanel()
|
||||
mesh = kwargs.pop("mesh_panel", None) or _Mesh()
|
||||
progress = kwargs.pop("progress", None) or _Progress()
|
||||
controller = ReplayController(
|
||||
engine=engine,
|
||||
host=host,
|
||||
toolbar=toolbar,
|
||||
charts=charts,
|
||||
image_panel=panel,
|
||||
mesh_panel=mesh,
|
||||
analysis_progress=progress,
|
||||
)
|
||||
actions = {
|
||||
"open_video": _Action(),
|
||||
"playback": _Action(),
|
||||
"latest": _Action(),
|
||||
"play": _Action(),
|
||||
"recompute": _Action(),
|
||||
}
|
||||
controller.bind_actions(**actions)
|
||||
return controller, host, engine, (toolbar, charts, panel, mesh, progress, actions)
|
||||
|
||||
|
||||
class _Engine:
|
||||
"""The engine as the controller uses it, with the timeline's mode explicit."""
|
||||
|
||||
def __init__(self, *, duration: float = 1.92, position: float = 0.0) -> None:
|
||||
self._duration = duration
|
||||
self._position = position
|
||||
self._mode = MODE_RECORDING
|
||||
self._session: AnalysisSession | None = None
|
||||
self._reader = _RecordingReader(RECORDING)
|
||||
self._has_video = True
|
||||
self.positions: list[float] = []
|
||||
self.redraws = 0
|
||||
|
||||
def has_video(self) -> bool:
|
||||
return self._has_video
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_video_only(self) -> bool:
|
||||
return False
|
||||
|
||||
def duration_s(self) -> float:
|
||||
return self._duration
|
||||
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
def set_position(self, position: float) -> None:
|
||||
self._position = float(position)
|
||||
self.positions.append(float(position))
|
||||
|
||||
def reader(self):
|
||||
return self._reader
|
||||
|
||||
def directory(self):
|
||||
return self._reader.directory
|
||||
|
||||
def video_path(self):
|
||||
return None
|
||||
|
||||
def video_frame_bgr(self):
|
||||
return np.zeros((4, 4, 3), dtype=np.uint8)
|
||||
|
||||
def video_message(self) -> str:
|
||||
return ""
|
||||
|
||||
def set_unit(self, unit: str) -> None:
|
||||
pass
|
||||
|
||||
def mode(self) -> str:
|
||||
return self._mode
|
||||
|
||||
def analysis(self):
|
||||
return self._session
|
||||
|
||||
def use_analysis(self, session) -> None:
|
||||
self._session = session
|
||||
self._mode = MODE_ANALYSIS
|
||||
|
||||
def use_recording(self) -> None:
|
||||
self._session = None
|
||||
self._mode = MODE_RECORDING
|
||||
|
||||
def analysed_frame(self):
|
||||
if self._session is None:
|
||||
return None
|
||||
return self._session.frame_at(self._position)
|
||||
|
||||
def redraw(self) -> None:
|
||||
self.redraws += 1
|
||||
|
||||
def open_recording(self, directory) -> bool:
|
||||
self._reader = _RecordingReader(Path(directory))
|
||||
self._session = None
|
||||
self._mode = MODE_RECORDING
|
||||
return True
|
||||
|
||||
def open_video(self, path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the timeline
|
||||
def test_a_stored_result_is_served_with_the_recording_s_own_frame_ids(session):
|
||||
"""Frame identity is the recording's counter (20…52), never an index (0…32)."""
|
||||
assert session.frame_count == 33
|
||||
assert int(session.frame_ids[0]) == FIRST_FRAME_ID
|
||||
assert int(session.frame_ids[-1]) == LAST_FRAME_ID
|
||||
assert not session.has(0), "an index was accepted as a frame id"
|
||||
assert session.has(FIRST_FRAME_ID)
|
||||
|
||||
|
||||
def test_a_frame_id_the_recording_does_not_hold_is_refused(session):
|
||||
"""A *position* passed where an *id* belongs gets an error, not a picture.
|
||||
|
||||
This is the trap the plan names: the result's ids run 20…52, so a caller that
|
||||
passes an index reads a frame that exists and looks right. The reader refuses an id
|
||||
it does not hold, and the session does not repair the request into one it can serve.
|
||||
"""
|
||||
with pytest.raises(AnalysisNotFoundError):
|
||||
session.frame(int(session.frame_ids[0]) + 1000)
|
||||
with pytest.raises(AnalysisNotFoundError):
|
||||
session.frame(0)
|
||||
|
||||
|
||||
def test_position_is_signal_time_and_resolves_to_a_stored_frame_id(session):
|
||||
"""The cursor's time base is ``t_rel_s`` from the recording's own ``frames.csv``."""
|
||||
span = session.duration_s()
|
||||
assert span == pytest.approx(1.920, abs=1e-3), (
|
||||
"the result's span must be signal time; 1.100 s would be the container's"
|
||||
)
|
||||
middle = int(session.frame_ids[len(session.frame_ids) // 2])
|
||||
moment = session.time_of(middle)
|
||||
assert moment is not None
|
||||
assert session.frame_id_at(moment) == middle
|
||||
assert session.frame_id_at(0.0) == FIRST_FRAME_ID
|
||||
assert session.time_of(0) is None, "an absent id answered with a time"
|
||||
|
||||
|
||||
def test_the_recovered_field_reselects_to_the_stored_samples(session):
|
||||
"""The 2D canvas and the 3D pane draw the same samples, frame for frame.
|
||||
|
||||
The canvas takes a flow field and re-runs the shared selector; the pane takes the
|
||||
stored selection. This asserts they agree on every stored frame — the locations, the
|
||||
drawn offsets and the activity verdict — so "one timeline, two figures" is a
|
||||
measurement rather than a hope. Magnitude is compared as float32-derived truth: it
|
||||
differs from the stored value by at most one ulp, which does not move a sample.
|
||||
"""
|
||||
policy = session.display_policy
|
||||
checked = 0
|
||||
for frame_id in session.frame_ids:
|
||||
frame = session.frame(int(frame_id))
|
||||
reselected = select_flow_samples(frame.flow, frame.valid_mask, policy)
|
||||
agreement = _same_samples(frame.samples, reselected)
|
||||
assert all(agreement.values()), f"frame {frame_id}: {agreement}"
|
||||
assert np.array_equal(
|
||||
np.asarray(frame.samples.active), np.asarray(reselected.active)
|
||||
)
|
||||
checked += 1
|
||||
assert checked == 33
|
||||
|
||||
|
||||
def test_a_seek_equals_playing_forward_to_the_same_frame(session):
|
||||
"""Seek to *N* and play to *N* must produce the same picture.
|
||||
|
||||
Tolerance: none on the frame's identity (the frame id and every drawn array are
|
||||
compared exactly). The cursor is a float in seconds, so the *time* handed to a seek
|
||||
is the stored ``t_rel_s`` read back from the result: the comparison is exact because
|
||||
the seek is given the frame's own time, and the frame id it resolves to is then the
|
||||
frame that was played to. Positions between stored frames resolve to the frame at or
|
||||
before them, which is what playing forward passes through as well.
|
||||
"""
|
||||
ids = [int(value) for value in session.frame_ids]
|
||||
# Play forward from the first frame, remembering what each frame looked like.
|
||||
played: dict[int, object] = {}
|
||||
for frame_id in ids:
|
||||
played[frame_id] = session.frame(frame_id)
|
||||
# Now seek directly to a sample of positions, in a fresh session so nothing is
|
||||
# answered from the sequential walk's own cache.
|
||||
fresh = AnalysisSession(
|
||||
AnalysisReader(_result_directory(Path(session.directory).parent)),
|
||||
policy=session.policy,
|
||||
display_policy=session.display_policy,
|
||||
)
|
||||
try:
|
||||
for frame_id in (ids[0], ids[1], ids[len(ids) // 2], ids[-2], ids[-1]):
|
||||
moment = fresh.time_of(frame_id)
|
||||
assert moment is not None
|
||||
seeked = fresh.frame_at(moment)
|
||||
assert seeked is not None
|
||||
assert seeked.frame_id == frame_id
|
||||
assert all(_same_samples(played[frame_id], seeked).values())
|
||||
assert np.array_equal(
|
||||
np.asarray(played[frame_id].flow),
|
||||
np.asarray(seeked.flow),
|
||||
equal_nan=True,
|
||||
)
|
||||
finally:
|
||||
fresh.reader.close()
|
||||
|
||||
|
||||
def test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws(session):
|
||||
"""One cursor, two figures: the packet's id, flow and mask are the frame's own."""
|
||||
frame = session.frame_at(session.time_of(LAST_FRAME_ID - 2))
|
||||
assert frame is not None
|
||||
packet = session.packet(frame)
|
||||
assert int(packet.frame_id) == int(frame.frame_id)
|
||||
# ``equal_nan``: every cell that is not a recorded candidate is NaN by construction,
|
||||
# in both the frame and the packet, and that is the contract rather than a defect.
|
||||
assert np.array_equal(
|
||||
np.asarray(packet.flow), np.asarray(frame.flow), equal_nan=True
|
||||
)
|
||||
assert np.array_equal(np.asarray(packet.valid_mask), np.asarray(frame.valid_mask))
|
||||
assert packet.flow_valid is True and packet.flow_frame_id == int(frame.frame_id)
|
||||
assert packet.forces_valid is False, "a result stores no force, so none may be claimed"
|
||||
assert float(packet.mono_t) == pytest.approx(frame.t_rel_s)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- display settings
|
||||
def test_a_display_change_is_a_read_and_writes_no_result(pass_root, session):
|
||||
"""The result key is blind to display settings, so a change must not write.
|
||||
|
||||
The assertion is the strongest one available: the set of result directories is
|
||||
compared before and after the threshold, the colour range and the idle dots are
|
||||
changed, and a fresh service (the object that would write) is asked whether a
|
||||
compatible result exists. Nothing may appear.
|
||||
"""
|
||||
before = {entry.directory.name for entry in scan_results(pass_root)}
|
||||
recorded = int(session.reader.frame(int(session.frame_ids[0])).active_count)
|
||||
|
||||
for settings in (
|
||||
DisplaySettings(flow_on_px=0.2),
|
||||
DisplaySettings(flow_on_px=1e9),
|
||||
# The colour range and the exaggeration are the same class of setting: display
|
||||
# only, so they must not reach the cache either. ``color_scale`` needs a
|
||||
# ``FieldDescriptor`` and a LUT id, so the field that is constructible on its own
|
||||
# stands in for it here; the threshold above is the one that also changes what is
|
||||
# drawn.
|
||||
DisplaySettings(display_gain=4.0),
|
||||
DisplaySettings(flow_grid_spacing=10),
|
||||
):
|
||||
assert session.serve_policy(policy_for_settings(settings)) is True
|
||||
|
||||
after = {entry.directory.name for entry in scan_results(pass_root)}
|
||||
assert after == before, "a display change wrote a new analysis result"
|
||||
# The same cache, asked the same question, still answers with the stored result:
|
||||
# nothing about the display entered the key.
|
||||
service = _service(pass_root)
|
||||
assert service.stored_result() is not None
|
||||
# And the change really did change the picture: a threshold of 1e9 activates nothing.
|
||||
session.serve_policy(policy_for_settings(DisplaySettings(flow_on_px=1e9)))
|
||||
assert session.frame(int(session.frame_ids[0])).active_count == 0
|
||||
session.serve_policy(policy_for_settings(DisplaySettings(flow_on_px=0.4)))
|
||||
assert session.frame(int(session.frame_ids[0])).active_count == recorded
|
||||
|
||||
|
||||
def test_a_display_change_that_moves_the_grid_is_refused_not_served(session):
|
||||
"""The stored candidate locations are the pass's; a new grid cannot be served."""
|
||||
assert session.serve_policy(policy_for_settings(DisplaySettings(max_arrows=50))) is False
|
||||
assert session.threshold() == pytest.approx(0.4), "a refused policy changed the session"
|
||||
# The threshold is the one setting that is served from the stored magnitude.
|
||||
assert session.serve_policy(policy_for_settings(DisplaySettings(flow_on_px=0.3))) is True
|
||||
|
||||
|
||||
def _flush(qt_app=None) -> None:
|
||||
"""Deliver the worker's queued signals: they arrive through the GUI event loop."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
QApplication.processEvents()
|
||||
|
||||
|
||||
def test_the_controller_redraws_on_a_display_change_and_runs_no_pass(pass_root):
|
||||
"""Through the controller: the surface shows the redraw and no service is built.
|
||||
|
||||
Its own reader and session, so neither the threshold this test sets nor the frame
|
||||
cache is shared with another test.
|
||||
"""
|
||||
reader = AnalysisReader(_result_directory(pass_root))
|
||||
session = AnalysisSession(
|
||||
reader, display_policy=policy_for_settings(DisplaySettings())
|
||||
)
|
||||
try:
|
||||
controller, host, engine, parts = _controller()
|
||||
controller.results.open_session(session)
|
||||
engine.use_analysis(session)
|
||||
engine.redraws = 0
|
||||
built = host.services_built
|
||||
controller.on_display_settings(DisplaySettings(flow_on_px=0.5))
|
||||
assert engine.redraws == 1, "the display change did not redraw"
|
||||
assert host.services_built == built, "a display change built an analysis service"
|
||||
# 0.5, not the 0.5 that arrived: ``flow_off_px`` is 0.3 and ``normalised()`` keeps
|
||||
# on >= off, so a threshold below the off band is raised to it rather than served
|
||||
# as an inverted band. The point here is that the session *adopted* the value.
|
||||
assert session.threshold() == pytest.approx(0.5)
|
||||
# The frame is rebuilt from the same stored record, not from a new one.
|
||||
assert session.frame(int(session.frame_ids[0])).frame_id == FIRST_FRAME_ID
|
||||
finally:
|
||||
session.reader.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------ analysis on open
|
||||
def test_opening_a_recording_with_a_stored_result_runs_no_pass(pass_root):
|
||||
"""Analysis on open reuses a compatible result: no progress, no pass."""
|
||||
_require_inputs()
|
||||
stored = _result_directory(pass_root)
|
||||
calls: list[str] = []
|
||||
|
||||
def factory(recording: Path, cancelled, on_progress):
|
||||
calls.append(str(recording))
|
||||
return _service(pass_root)
|
||||
|
||||
host = _Host(service_factory=factory)
|
||||
controller, host, engine, parts = _controller(host=host)
|
||||
assert controller.load_recording(RECORDING) is True
|
||||
assert calls == [str(RECORDING)], f"the service was built {len(calls)} times"
|
||||
assert controller.analysis_active(), "the timeline did not adopt the stored result"
|
||||
assert engine.mode() == MODE_ANALYSIS
|
||||
assert parts[2].kinds[-1] == "analysis", "the 2D panel was not told it is a result"
|
||||
assert parts[3].kinds[-1] == "analysis", "the 3D pane was not told it is a result"
|
||||
assert parts[4].states[-1] == "reused", "the surface did not report the reuse"
|
||||
assert stored.is_dir()
|
||||
|
||||
|
||||
def test_opening_a_recording_without_a_result_starts_the_pass(tmp_path, qt_app):
|
||||
"""No compatible result: the pass runs behind a progress surface with a cancel."""
|
||||
_require_inputs()
|
||||
empty = tmp_path / "cache"
|
||||
empty.mkdir()
|
||||
|
||||
def factory(recording: Path, cancelled, on_progress):
|
||||
return _service(empty, cancelled=cancelled, on_progress=on_progress)
|
||||
|
||||
host = _Host(service_factory=factory)
|
||||
controller, host, engine, parts = _controller(host=host)
|
||||
assert controller.load_recording(RECORDING) is True
|
||||
assert parts[4].states[:1] == ["running"], "the progress surface did not start"
|
||||
assert controller.analysis_running(), "no pass was started"
|
||||
assert controller.wait_for_analysis(120)
|
||||
_flush()
|
||||
assert parts[4].states[-1] == "complete", parts[4].states
|
||||
assert controller.analysis_active(), "a complete result was not adopted"
|
||||
assert engine.mode() == MODE_ANALYSIS
|
||||
|
||||
|
||||
def test_a_cancelled_pass_is_reported_and_never_served(tmp_path, qt_app):
|
||||
"""Cancel mid-pass: the surface says so and playback is not offered from it."""
|
||||
_require_inputs()
|
||||
empty = tmp_path / "cache"
|
||||
empty.mkdir()
|
||||
|
||||
def factory(recording: Path, cancelled, on_progress):
|
||||
# Cancel on the first frame, which is what the operator's button does.
|
||||
def cancel_after_one(progress):
|
||||
if progress.frames_written >= 1:
|
||||
cancelled.set()
|
||||
if on_progress is not None:
|
||||
on_progress(progress)
|
||||
|
||||
return _service(empty, cancelled=cancelled, on_progress=cancel_after_one)
|
||||
|
||||
host = _Host(service_factory=factory)
|
||||
controller, host, engine, parts = _controller(host=host)
|
||||
controller.load_recording(RECORDING)
|
||||
assert controller.wait_for_analysis(120)
|
||||
_flush()
|
||||
assert parts[4].states[-1] == "cancelled", parts[4].states
|
||||
assert not controller.analysis_active(), "an incomplete result was served"
|
||||
assert engine.mode() == MODE_RECORDING
|
||||
assert controller.analysis_error() == "cancelled"
|
||||
assert not any(
|
||||
t("playback.analysis_cancelled") in note for note in host.notes
|
||||
), "a terminal analysis state was written to the transient status line"
|
||||
# The partial result is on disk and refuses to be read as complete.
|
||||
directories = [entry.directory for entry in scan_results(empty)]
|
||||
assert directories, "the cancelled pass wrote nothing at all"
|
||||
assert all(
|
||||
not entry.complete for entry in scan_results(empty)
|
||||
), "a cancelled result claims to be complete"
|
||||
with pytest.raises(AnalysisIncompleteError):
|
||||
AnalysisReader(directories[0])
|
||||
assert AnalysisReader(directories[0], allow_incomplete=True).complete is False
|
||||
|
||||
|
||||
def test_an_incomplete_result_on_disk_is_not_adopted(tmp_path):
|
||||
"""The refusal is the reader's, and the session never dresses it as playable."""
|
||||
_require_inputs()
|
||||
empty = tmp_path / "cache"
|
||||
empty.mkdir()
|
||||
cancelled = threading.Event()
|
||||
|
||||
def cancel_after_one(progress):
|
||||
if progress.frames_written >= 1:
|
||||
cancelled.set()
|
||||
|
||||
service = _service(empty, cancelled=cancelled, on_progress=cancel_after_one)
|
||||
outcome = service.run()
|
||||
assert outcome.complete is False
|
||||
assert outcome.frames_written >= 1, "the pass wrote nothing, so nothing was refused"
|
||||
with pytest.raises(AnalysisIncompleteError):
|
||||
AnalysisReader(outcome.directory)
|
||||
reader = AnalysisReader(outcome.directory, allow_incomplete=True)
|
||||
try:
|
||||
with pytest.raises(ResultPolicyError):
|
||||
AnalysisSession(reader)
|
||||
finally:
|
||||
reader.close()
|
||||
# And the service's own "is one stored?" answer is not a playable one: the directory
|
||||
# exists, so it answers by refusing — which is what makes the controller run a pass
|
||||
# instead of serving a partial result.
|
||||
with pytest.raises(AnalysisIncompleteError):
|
||||
service.stored_result()
|
||||
|
||||
|
||||
def test_the_library_never_hands_out_an_incomplete_session(tmp_path):
|
||||
"""``ResultLibrary.open`` refuses a partial result even when asked to inspect one."""
|
||||
_require_inputs()
|
||||
empty = tmp_path / "cache"
|
||||
empty.mkdir()
|
||||
cancelled = threading.Event()
|
||||
|
||||
def cancel_after_one(progress):
|
||||
if progress.frames_written >= 1:
|
||||
cancelled.set()
|
||||
|
||||
outcome = _service(empty, cancelled=cancelled, on_progress=cancel_after_one).run()
|
||||
library = ResultLibrary()
|
||||
with pytest.raises(ResultPolicyError):
|
||||
library.open(outcome.directory, allow_incomplete=True)
|
||||
assert library.session is None
|
||||
|
||||
|
||||
def test_the_worker_reports_progress_and_a_cancelled_outcome():
|
||||
"""The thread boundary: a pass reports progress, and a cancel is a cancelled report."""
|
||||
seen: list = []
|
||||
|
||||
def factory(cancelled, on_progress):
|
||||
def cancel_after(progress):
|
||||
on_progress(progress)
|
||||
if progress.frames_written >= 2:
|
||||
cancelled.set()
|
||||
|
||||
from orisys_app_shared.analysis.service import Progress
|
||||
|
||||
class _Fake:
|
||||
def run(self):
|
||||
for index in range(5):
|
||||
cancel_after(
|
||||
Progress(
|
||||
frames_written=index + 1,
|
||||
frames_expected=5,
|
||||
segment_index=1,
|
||||
segment_count=1,
|
||||
frame_id=FIRST_FRAME_ID + index,
|
||||
elapsed_s=0.1 * index,
|
||||
)
|
||||
)
|
||||
from orisys_app_shared.analysis.service import AnalysisOutcome
|
||||
from orisys_app_shared.analysis.model import ResultKey
|
||||
|
||||
return AnalysisOutcome(
|
||||
key=ResultKey(
|
||||
recording="r", algorithm="a", sensor_config="s", calibration="", reference_frame=None
|
||||
),
|
||||
directory=Path("."),
|
||||
complete=False,
|
||||
reason="cancelled",
|
||||
frames_written=2,
|
||||
frames_expected=5,
|
||||
elapsed_s=0.3,
|
||||
)
|
||||
|
||||
return _Fake()
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
worker = AnalysisWorker(factory)
|
||||
worker.progressed.connect(seen.append)
|
||||
worker.finished.connect(lambda report: seen.append(("report", report)))
|
||||
assert worker.start() is True
|
||||
assert worker.wait(30)
|
||||
QApplication.processEvents()
|
||||
reports = [item for item in seen if isinstance(item, tuple)]
|
||||
assert reports, "the worker never reported"
|
||||
report = reports[-1][1]
|
||||
assert report.cancelled is True and report.complete is False
|
||||
assert report.frames_written == 2
|
||||
assert report_from(None, cancelled=True).cancelled is True
|
||||
Reference in New Issue
Block a user