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:
ykcheungab
2026-10-04 13:38:03 +08:00
parent b6f6a06851
commit cf49b0836e
11 changed files with 2731 additions and 29 deletions
+38 -4
View File
@@ -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"
}
}
+38 -4
View File
@@ -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": "正在采集:停止后再分析"
}
}
+125
View File
@@ -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)
+111
View File
@@ -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)
+211 -14
View File
@@ -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)
+906
View File
@@ -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