把 Studio 主机协议改成单向端口,并让「从视频重算」走分析入口

控制器经 AcquireGate / ImageSink / ShellNotes 与信号接线,采集 tick 用 acquire_presenter 上屏;三维测试证明分析结果已喂 3D 后,重算并入 on_analysis_requested。
验证:apps/studio 下相关 pytest 与 tools/ui_check.py(634 checks)通过。
This commit is contained in:
ykcheungab
2026-10-05 14:00:24 +08:00
parent 25ccffd814
commit 28e955a817
27 changed files with 1764 additions and 745 deletions
+4 -1
View File
@@ -181,7 +181,10 @@
"export_identity": "file identity type {sensor_type} · SN {sn} · config {config}", "export_identity": "file identity type {sensor_type} · SN {sn} · config {config}",
"thumb_placeholder": "Product model", "thumb_placeholder": "Product model",
"family_unidentified": "Unidentified", "family_unidentified": "Unidentified",
"catalog_profile": "Profile {profile_id} · family {family} · from {origin} · {state}", "catalog_profile": "Profile {profile_id} · {shape} · from {origin} · SDK validation: {state}",
"catalog_shape_descriptor": "descriptor bundle",
"catalog_shape_compatibility": "compatibility bundle, no descriptor",
"catalog_shape_unreadable": "no readable bundle",
"catalog_blocked": "Cannot start: {reason}" "catalog_blocked": "Cannot start: {reason}"
}, },
"panels": { "panels": {
+4 -1
View File
@@ -181,7 +181,10 @@
"export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}", "export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}",
"thumb_placeholder": "产品型号", "thumb_placeholder": "产品型号",
"family_unidentified": "未识别", "family_unidentified": "未识别",
"catalog_profile": "配置档案 {profile_id} · 家族 {family} · 来源 {origin} · 状态 {state}", "catalog_profile": "配置档案 {profile_id} · {shape} · 来源 {origin} · SDK 校验:{state}",
"catalog_shape_descriptor": "描述符配置包",
"catalog_shape_compatibility": "兼容配置包(无描述符)",
"catalog_shape_unreadable": "没有可读取的配置包",
"catalog_blocked": "无法启动:{reason}" "catalog_blocked": "无法启动:{reason}"
}, },
"panels": { "panels": {
@@ -1,6 +1,17 @@
{ {
"name": "finger", "schema_version": 1,
"profile_id": "orisys.finger.r01",
"display_name": "Finger R01",
"product_family": "finger",
"processing_backend": "uv_unroll",
"sensor_json": "sensor.json", "sensor_json": "sensor.json",
"computational_assets": {
"unroll_map": "assets/30fps-0703/unroll_map_uv_s0.42.npz"
},
"assets": {
"scene": "scene/finger-display-1.0.0.oryscene"
},
"name": "finger",
"assets_dir": "assets/30fps-0703", "assets_dir": "assets/30fps-0703",
"viz_pose": "assets/visualization/world_to_cam.npy" "viz_pose": "assets/visualization/world_to_cam.npy"
} }
+15 -12
View File
@@ -19,8 +19,8 @@
(连"原始图像"一起),派生模式那些帧一律从结果读、按窗口唯一的 2D 出口画。 (连"原始图像"一起),派生模式那些帧一律从结果读、按窗口唯一的 2D 出口画。
图表、二维画布、三维面板都读这一个游标,模式由 `ReplayEngine.mode()` 回答;「从视频重算」 图表、二维画布、三维面板都读这一个游标,模式由 `ReplayEngine.mode()` 回答;「从视频重算」
因此不再是第三种来源,而是**显式重跑**这一遍的开关(第 10 节)。时间基准是**信号时间** 与打开录像共用 `ReplayController.on_analysis_requested()`(第 10 节),不再另开一条采集链。
(Part A 第 5 节),进度、位置、单步与跳转都说它。 时间基准是**信号时间**(Part A 第 5 节),进度、位置、单步与跳转都说它。
**应用里的时间轴永远是录像的。** 引擎留在 `recording`:游标、帧 id 与时长都是录像自己的, **应用里的时间轴永远是录像的。** 引擎留在 `recording`:游标、帧 id 与时长都是录像自己的,
结果挂在游标后面当缓存(`available_result()`),`_adopt_session()` 调用 `use_recording()` 保证 结果挂在游标后面当缓存(`available_result()`),`_adopt_session()` 调用 `use_recording()` 保证
@@ -58,10 +58,14 @@
| 视图 | 读什么 | 怎么读 | | 视图 | 读什么 | 怎么读 |
|---|---|---| |---|---|---|
| 图表 | `forces.csv`(记录值) | `charts.set_cursor(t)` 用同一游标;结果里不存力 | | 图表 | `forces.csv`(记录值),**或结果里重算的力**(裸视频) | `charts.set_cursor(t)` 用同一游标;两条来源在标题上分开命名(`chart.mode_replay` / `chart.mode_recomputed`) |
| 二维画布 | 结果的 `flow`、`depth` 与 `valid_mask` | `ImageFrame` 走窗口唯一的 2D 出口 `_show_image_frame`(录像时间轴上的派生视图带 `source="replay"` 与 `result_available=True`,因此"原始图像"仍在选择器里),箭头、暗点、配色、域边界与深度视图与实时/重算**同一套代码** | | 二维画布 | 结果的 `flow`、`depth` 与 `valid_mask` | `ImageFrame` 走窗口唯一的 2D 出口 `_show_image_frame`(录像时间轴上的派生视图带 `source="replay"` 与 `result_available=True`,因此"原始图像"仍在选择器里),箭头、暗点、配色、域边界与深度视图与实时/重算**同一套代码** |
| 三维面板 | 同一帧的 `SdkPacket` | `session.packet(frame)` → `Scene3DView.submit_packet`,走同一个 adapter、域掩码 latch 与有效性求交 | | 三维面板 | 同一帧的 `SdkPacket` | `session.packet(frame)` → `Scene3DView.submit_packet`,走同一个 adapter、域掩码 latch 与有效性求交 |
**裸视频的力是重算的,而且只有这一条。** 没有 `forces.csv` 时,pass 把链路对每一帧算出的力按帧
存进结果(`FRAME_FLAG_FORCE`;Part A 第 1.1 节),曲线读它,标题写 `chart.mode_recomputed`。没有
任何力可画时标题说明原因。
**没有结果时那一行必须说清是四种里的哪一种。** `plan_image()` 只拿到 `ImageFrame`,四种状态 **没有结果时那一行必须说清是四种里的哪一种。** `plan_image()` 只拿到 `ImageFrame`,四种状态
因此由生产者写进帧里(`result_state`/`result_reason`),`ReplayController.result_state()` 是唯一 因此由生产者写进帧里(`result_state`/`result_reason`),`ReplayController.result_state()` 是唯一
的判断处;有结果时这一行根本不出现: 的判断处;有结果时这一行根本不出现:
@@ -142,18 +146,17 @@ on_progress)`(`chain_spec_for` 从录像自己的 `session.json` 取链路,
## 15. 已知限制 ## 15. 已知限制
* 结果里没有力:力曲线始终读记录的 `forces.csv`。深度有了(第 1、11 节),但**三维面板仍只 * **录像**的结果里没有力:力曲线读那份录像自己的 `forces.csv`。裸视频是另一回事(Part A 第 1.1
拿光流**:`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False` —— 接进 节):pass 存下链路重算的力,曲线读它。深度有了(第 1、11 节),但**三维面板仍只拿光流**:
三维会改变三维图形的构成,不在这一片里。 `session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False` —— 接进三维会改变
* **box 的箭头画布在回放里是空的**(本片实测,非本次引入):box 的绘制路径吃不下只有候选格 三维图形的构成,不在这一片里。
有限的恢复场,同一帧密流场画 24,440 px、恢复场 0 px;见 [replay-panel.md](replay-panel.md) 第 5 节。
* 结果覆盖的帧是 pass 走过的那些:**结果的第一帧可能晚于录像的起点**(box 链路构造时吃掉前 * 结果覆盖的帧是 pass 走过的那些:**结果的第一帧可能晚于录像的起点**(box 链路构造时吃掉前
7 个解码帧,这份结果从 t=0.4162 s 开始),最后一批帧也可能早于录像的终点。游标落在这两段里 7 个解码帧,这份结果从 t=0.4162 s 开始),最后一批帧也可能早于录像的终点。游标落在这两段里
时,派生视图按 `frame_id_at()` 的"该时刻或之前、越界夹到端点"给帧:起点之前画的是最早那一帧 时,派生视图按 `frame_id_at()` 的"该时刻或之前、越界夹到端点"给帧,两段没有各自的提示。
测量,终点之后画的是最后一帧。两段没有各自的提示,"不会静默重复最后一帧"在本片没有兑现。 * 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。被拒的栅格**仍按
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。 结果自己的网格绘制**(帧携带 `arrow_policy`)。
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析 —— 但**读结果不受这条限制**(第 10 * 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析 —— 但**读结果不受这条限制**(第 10
节):会话开着时面板照画缓存,只是新的那一遍要等会话停下、「从视频重算」再点一次。 节):会话开着时面板照画缓存,新的那一遍要等会话停下、「从视频重算」再点一次。
* "有没有结果"是录像的属性、不是游标的属性:`available_result()` 查的是本录像在 * "有没有结果"是录像的属性、不是游标的属性:`available_result()` 查的是本录像在
`ResultLibrary` 里的那一份,第 9 节的接线之后这是常态。 `ResultLibrary` 里的那一份,第 9 节的接线之后这是常态。
* 面板的 `failed` 把"跑挂"和"被拒跑"合成一句(原因与动作都不同:一个要修链路,一个要停会话); * 面板的 `failed` 把"跑挂"和"被拒跑"合成一句(原因与动作都不同:一个要修链路,一个要停会话);
+88 -87
View File
@@ -6,8 +6,8 @@ playback semantics are owned by Part A ([replay-analysis.md](replay-analysis.md)
([replay-analysis-part-b.md](replay-analysis-part-b.md)). ([replay-analysis-part-b.md](replay-analysis-part-b.md)).
Status: Parts A and B shipped at `cf49b08`, the depth channel at `9e20fd0`, the result-aware mode Status: Parts A and B shipped at `cf49b08`, the depth channel at `9e20fd0`, the result-aware mode
plan and the wiring that reaches it at `1aa4543`. The cache root and the lifetime sweep are plan at `1aa4543`. The cache root and the lifetime sweep are implemented; the display-control
implemented (§Cache and export policies); the display-control separation (Part C) is not. separation (Part C) is not.
## 1. Load a video ## 1. Load a video
@@ -15,31 +15,35 @@ Selecting a video always gives a playable timeline, and the panel says what the
`load_video` calls a folder holding `forces.csv` beside the video a recording; anything else `load_video` calls a folder holding `forces.csv` beside the video a recording; anything else
opens as a bare video (`VideoOnlySource`) and plays, with `playback.video_only_opened` on the opens as a bare video (`VideoOnlySource`) and plays, with `playback.video_only_opened` on the
rail and `playback.video_only_notice` in the notes. A file that cannot be decoded is refused rail and `playback.video_only_notice` in the notes. A file that cannot be decoded is refused
with its reason. with its reason. What a bare video lacks is force rows *measured by a device*, and
`session.json` metadata.
Not available for a bare video: force rows (the charts are empty by construction), `session.json` **A bare video can be analysed, and is asked how.** The two files the pass reads are *generated*
metadata, and an analysis pass — `recording_directory()` needs a `session.json` and reports for the video in an app-owned workspace and every later step is the existing recording path; the
`playback.analysis_no_source`; the only route to numbers is rerunning the acquisition pipeline over workspace, its frame index and the synthetic marker are Part A's (§1.1). One prompt asks what a
the video (`on_recompute_toggled`), forward only. video cannot carry: the **profile** (from the catalog, each row naming what it can do — flow /
raw force / calibrated axes / depth / 3D), an **optional calibration** defaulting to the profile's
declared `default_calibration`, and a **reference frame** defaulting to the first. A profile the
SDK rejects is listed, disabled and refused with the catalog's own `start_blocked_reason()`.
Declining keeps the old behaviour (`playback.analysis_setup_declined`), and **more than one file
is refused** with its reason.
Source: `record/playback.py`, `replay_controller.py`. Source: `analysis/bare_video.py`, `ui/video_analysis_dialog.py`, `replay_controller.py`.
Verify: `test_ui_smoke.py::test_a_bare_video_plays_even_though_it_is_not_a_recording`; Verify: `test_bare_video_analysis.py`, `test_bare_video.py`,
`test_replay_controller.py::test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view`. `test_replay_controller.py::test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view`.
## 2. Play and compute on the loaded video ## 2. Play and compute on the loaded video
Opening a recording either serves a compatible stored result or runs one pass behind a Opening a recording either serves a compatible stored result or runs one pass behind a
progress-and-cancel surface. `on_analysis_requested` asks progress-and-cancel surface. `on_analysis_requested` asks `AnalysisService.stored_result()`
`AnalysisService.stored_result()` first — a file read, the surface showing first — a file read, the surface showing `analysis.reused` — and otherwise starts
`analysis.reused` — and otherwise starts `AnalysisWorker`, a plain thread whose `Progress` is `AnalysisWorker`, a plain thread whose `Progress` is coalesced at 0.1 s, with
coalesced at 0.1 s, with `AnalysisProgress` showing written/expected frames and elapsed time. `AnalysisProgress` showing written/expected frames and elapsed time. The timeline stays in
The timeline stays in `recording` mode until the pass commits: a half-written result never `recording` mode until the pass commits: a half-written result never reaches the screen.
reaches the screen.
The pass is sequential and forward-only: `_pass_frames` walks segments and frames in order, and the The pass is forward-only (`_pass_frames` walks segments and frames in order; the chain is
chain is stateful (`Sensor.reset()` clears the tracker). Cancelling sets an event; the result keeps stateful). Cancelling sets an event; the result keeps its `INCOMPLETE` marker and default
its `INCOMPLETE` marker and default readers refuse it (Part A §7). Measured: 0.48–0.51 readers refuse it (Part A §7). Measured: 0.48–0.51 wall-seconds per video-second (Part A §8).
wall-seconds per video-second (Part A §8) — ten minutes is ≈4.8 minutes.
Source: `analysis/service.py`, `analysis_worker.py`, `ui/analysis_progress.py`. Source: `analysis/service.py`, `analysis_worker.py`, `ui/analysis_progress.py`.
Verify: `test_replay_analysis.py::test_opening_a_recording_with_a_stored_result_runs_no_pass`, Verify: `test_replay_analysis.py::test_opening_a_recording_with_a_stored_result_runs_no_pass`,
@@ -50,14 +54,12 @@ Verify: `test_replay_analysis.py::test_opening_a_recording_with_a_stored_result_
The cursor is one position for the whole panel, on the recording's own signal time: `t_rel_s` The cursor is one position for the whole panel, on the recording's own signal time: `t_rel_s`
from `frames.csv`, the axis `ReplayEngine.position()` and `AnalysisReader.frame_at()` index by. from `frames.csv`, the axis `ReplayEngine.position()` and `AnalysisReader.frame_at()` index by.
Container time is never derived from it or converted into it. Measured: 33 container frames at Container time is never derived from it (Part A §5).
30 fps are 1.100 s of video and 1.920 s of signal at 16.664 fps (Part A §5).
Frames are addressed by the recording's own frame id, never an index: `AnalysisSession.frame` Frames are addressed by the recording's own frame id, never an index: `AnalysisSession.frame`
refuses an id the result does not hold (`AnalysisNotFoundError`), and the evidence recording's refuses an id the result does not hold, and the evidence recording's ids run 20…52, so an
ids run 20…52, so an index-based path would answer with another frame's measurement. A seek index-based path would answer with another frame's measurement. A seek resolves to the stored
resolves to the stored frame at or before the position — the frame sequential playback passes — frame at or before the position — the frame sequential playback passes — and re-runs no pass.
and re-runs no pass.
Source: `replay.py`, `replay_analysis.py`. Source: `replay.py`, `replay_analysis.py`.
Verify: `test_replay_analysis.py::test_position_is_signal_time_and_resolves_to_a_stored_frame_id`, Verify: `test_replay_analysis.py::test_position_is_signal_time_and_resolves_to_a_stored_frame_id`,
@@ -70,58 +72,69 @@ One cursor drives all three.
| View | Data source | | View | Data source |
|---|---| |---|---|
| Charts | the recorded `forces.csv` (`RecordingCurveSource`); the result stores no force | | Charts | the recorded `forces.csv` (`RecordingCurveSource`), or — over a bare video — the result's own recomputed trace (`AnalysisCurveSource`) |
| 2D canvas | the cached per-frame samples and depth, drawn at display time | | 2D canvas | the cached per-frame samples and depth, drawn at display time |
| 3D pane | the packet built from the cached frame (`AnalysisSession.packet` → `Scene3DView.submit_packet`) | | 3D pane | the packet built from the cached frame (`AnalysisSession.packet` → `Scene3DView.submit_packet`) |
The charts read one of two sources, and the header names which: `chart.mode_replay` for a
recording's own `forces.csv` (*the* measurement — a device was in the loop) and
`chart.mode_recomputed` for the trace the pass recomputed from a bare video
(`FRAME_FLAG_FORCE`, 28 B/frame; `provenance.sources["forces"]` says which). With neither —
nothing analysed yet, or a profile whose chain produced no force for this footage — the header
states why (`chart.mode_no_trace`) rather than leaving an empty plot.
A seek equals playing forward to that frame: the cursor resolves to one stored frame id and all A seek equals playing forward to that frame: the cursor resolves to one stored frame id and all
three views are fed from it. A derived view chosen while the timeline reads the video is drawn three views are fed from it. A derived view chosen while the timeline reads the video is drawn
from the result at the same cursor (`available_result`). Not synchronised: the 3D pane's depth from the result at the same cursor (`available_result`). Not synchronised: the 3D pane's depth
(`AnalysisSession.packet` sets `depth=None`, `depth_valid=False`); forces (the packet carries (`AnalysisSession.packet` sets `depth=None`, `depth_valid=False`); forces (the packet carries
`forces_valid=False` and zero, so the pane's force readout is not a measurement here); the 3D `forces_valid=False` and zero); and the 3D pane with no result. 「从视频重算」 shares
pane with no result, which nothing feeds; and a recompute pass, where the pipeline owns the `on_analysis_requested` with opening a recording — no separate acquisition chain owns the
timeline (`on_cursor_moved`). timeline.
Display settings are reads: the threshold re-derives activity from the stored `magnitude`, colour Display settings are reads: the threshold re-derives activity from the stored `magnitude`, colour
range and idle dots live in the draw call, and a grid-moving setting is refused by name range and idle dots live in the draw call, and a grid-moving setting is refused by name
(`GRID_KEYS`), the frame still drawn from the recorded grid. (`GRID_KEYS`), the frame still drawn from the recorded grid. The recovered field can only be
sampled on the pass's own grid, so the frame carries that policy (`ImageFrame.arrow_policy`).
Verify: `test_replay_analysis.py::test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws`, Verify: `test_replay_analysis.py::test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws`,
`::test_a_display_change_that_moves_the_grid_is_refused_not_served`. `::test_a_display_change_that_moves_the_grid_is_refused_not_served`.
## 5. What the 2D view can show ## 5. What the 2D view can show
The modes come from the frame's own fields and from whether a result exists (`plan_image`, The modes come from the frame's own fields and from whether a result exists (`plan_image`,
`result_available`): `arrows_pure` (Arrows), `arrows_stitched` (Arrows / Captured), `depth` `result_available`): `arrows_pure`, `arrows_stitched`, `depth`, `recorded`. With a result all
(Depth map), `recorded` (Recorded image). With a result all four are offered, even on the four are offered, even on the recording's timeline, defaulting to `recorded`; with none,
recording's timeline, defaulting to `recorded`; with none, `recorded` alone plus one line naming `recorded` alone plus one line naming *which* absence it is (`result_state`): no result yet
*which* absence it is (`result_state`): no result yet (`panels.replay_raw_note`), a pass running (`panels.replay_raw_note`), a pass running (`panels.replay_analysis_running`), a pass failed or
(`panels.replay_analysis_running`), a pass failed or refused with its own reason refused with its own reason (`panels.replay_analysis_failed`), or no pass possible
(`panels.replay_analysis_failed`), or no pass possible at all — a bare video
(`panels.replay_analysis_unavailable`). (`panels.replay_analysis_unavailable`).
| Mode | Drawn from | | Mode | Drawn from |
|---|---| |---|---|
| recorded | the decoded `raw_*.mp4` frame (`RecordingReader.video_frame_at`) | | recorded | the decoded `raw_*.mp4` frame (`RecordingReader.video_frame_at`) |
| arrows / arrows-captured | the cached samples: the grid keyframe's locations plus this frame's float32 `dx`/`dy`/`magnitude` and bit-packed `active` (4,034 B/frame before the depth channel; 4,048 B/frame now) | | arrows / arrows-captured | the cached samples: the grid keyframe's locations plus this frame's float32 `dx`/`dy`/`magnitude` and bit-packed `active` (4,048 B/frame with the depth channel) |
| depth | a per-frame depth flag, a non-zero count and min/max; only a non-empty map adds a uint8 plane quantised over that frame's range and zlib'd at level 6 (island restriction rejected: 418 B vs 486 B on the worst frame) | | depth | a per-frame depth flag, a non-zero count and min/max; only a non-empty map adds a uint8 plane quantised over that frame's range and zlib'd at level 6 (Part A §1) |
Measured: the depth map is exactly all zeros on 241 of 264 frames of the eight-times clip A blank depth view is usually correct and must be explainable: `panels.depth_empty` says the
(32 of 33 on the box recording) and never exceeded 336 of 84,000 pixels (0.4 %). A blank depth view frame's depth is all zeros, `panels.depth_unavailable` says the frame has no depth channel at
is therefore usually correct and must be explainable: `panels.depth_empty` says the frame's depth is all — different facts. Measured: the map is exactly all zeros on 32 of the box recording's 33
all zeros, `panels.depth_unavailable` says the frame has no depth channel at all — different facts, frames (Part A §1 has the rest).
neither drawing the processed image. **A box replay's arrow canvas is painted, by the shared painter.** The box style draws through
the SDK's `orisys.util.draw_arrows`, which sweeps the dense sampling grid; a replay hands it a
**A box replay's arrow canvas is empty** (pre-existing): the box style draws through the SDK's *recovered* field, finite only at the stored candidate cells. Measured on one stored box frame:
`orisys.util.draw_arrows`, while a replay hands it the recovered field, finite only at the stored a dense field through the SDK painter paints 24,440 px, that field in its recovered form 0 px,
candidate cells — one dense field paints 24,440 px, its recovered form 0 px, the shared painter the shared painter 1,486 px (1,486–1,964 px over the result's 25 frames with arrows). So the
1,486 px. painter follows **where the frame came from** — a frame read from a stored result
(`SOURCE_ANALYSIS`/`SOURCE_REPLAY`) is drawn per sample, a live or recomputed one keeps the SDK
path (`draw_arrow_display(shared_painter=True)`) — and the box style still owns the
magnification and the idle-dot tint.
A mode needing derived data is served from the result, never faked: with none, that view is not A mode needing derived data is served from the result, never faked: with none, that view is not
offered and the state line names the action or the reason. The state above is what an operator offered and the state line names the action. The cursor stays on the recording's own timeline
reaches by opening a recording that has a result: the cursor stays on the recording's own timeline while the derived views are read from the result — including while the session that recorded it
and the panel opens on a derived view, the recorded one still offered — including while the is still live, because reading a result opens no chain.
session that recorded it is still live, because reading a result opens no chain. Verify: `test_image_backend.py` (the plan and the painter routing),
Verify: `test_image_backend.py` (the plan), `test_replay_analysis.py::test_a_derived_mode_is_served_from_the_result_even_on_the_recording_timeline`, `test_bare_video_analysis.py::test_the_box_arrow_canvas_is_painted_by_the_shared_painter`,
`test_replay_analysis.py::test_a_derived_mode_is_served_from_the_result_even_on_the_recording_timeline`,
`::test_the_four_no_result_states_are_four_different_lines`, `::test_the_four_no_result_states_are_four_different_lines`,
`::test_the_empty_depth_note_is_not_the_missing_depth_note`, `::test_the_empty_depth_note_is_not_the_missing_depth_note`,
`test_replay_wiring.py::test_the_operator_sees_the_cached_views_over_the_recording`, `test_replay_wiring.py::test_the_operator_sees_the_cached_views_over_the_recording`,
@@ -129,35 +142,20 @@ Verify: `test_image_backend.py` (the plan), `test_replay_analysis.py::test_a_der
## Cache and export policies ## Cache and export policies
No rendered 2D images are cached. PNGs of the arrow and depth views would cost 180–540 MB per No rendered 2D images are cached: a PNG would bake display settings into pixels, destroying the
ten minutes and bake display settings into pixels, destroying the property that a threshold, property that a threshold, colour or idle-dot change is a read. Check:
colour or idle-dot change is a read. Drawing a frame from the cached numbers costs ≈1.4 ms, the `::test_a_display_change_is_a_read_and_writes_no_result`,
idle-dot pass 37 µs (owner's bench, not reproduced here). Check: `::test_a_mode_change_writes_no_analysis`. A backward seek cannot re-derive a frame either: the
`::test_a_display_change_is_a_read_and_writes_no_result`, `::test_a_mode_change_writes_no_analysis`. chain is forward-only, so it would cost a full re-run (Part A §8).
Derived views cannot be computed from raw data during a scrub: the chain is stateful and Results live under `%LOCALAPPDATA%\OrisysStudio\analysis-cache\`
forward-only, so a backward seek would need a full re-run at 0.48–0.51 s per video-second; the (`paths.analysis_cache_dir()`) — never the user's recording folder or the app's `outputs/` (the
panel scrubs stored numbers and offers that run separately. EXE's own directory in a packaged build). The key is path-independent
(`recording_identity`), so a copied recording finds its result; a result left under the old
Cache location. Results live under the OS-correct derived-data root, `<recording>/analysis` root is ignored, neither read nor deleted. A bare video's generated
`%LOCALAPPDATA%\OrisysStudio\analysis-cache\` (`paths.analysis_cache_dir()`) — never the user's workspace lives under `…\video-workspaces\<video digest>\`. Implemented: a 1 GiB cap with LRU
recording folder or the app's `outputs/`, which in the packaged build is the EXE's own directory eviction and `ResultCache.sweep()` at startup. Not implemented: a manual clear action, the opt-in
and holds the recordings. The key is path-independent (`recording_identity`), so a copied wipe-on-exit preference and result export (≈70 MB per ten minutes).
recording finds its result; a result left under the old `<recording>/analysis` root is ignored —
neither read nor deleted (`test_replay_analysis.py::test_a_result_under_the_old_root_is_ignored_not_migrated`).
Cache lifetime. A size cap with LRU eviction (1 GiB, `DEFAULT_MAX_BYTES`), a startup sweep of
stale and incomplete results, a manual "clear analysis cache" action, and wipe-on-exit only as an
explicit preference that is off by default — because wiping on exit forces a full re-analysis
(≈4.8 minutes per ten minutes) on reopen and does not protect against a crash. Implemented:
`evict()` (incomplete first, then oldest, protecting `keep`) and `ResultCache.sweep()`, which
clears incomplete results older than an hour and then applies the cap, called once at startup
(`ReplayController.sweep_cache`). Not implemented: the clear action and the wipe-on-exit
preference.
Export. The deliberate way to keep a result is to export it: ≈70 MB per ten minutes, portable
because the key ignores where the file sits. A rendered arrow or depth video belongs to export
rather than the cache. Not implemented.
## Owners and status ## Owners and status
@@ -165,14 +163,17 @@ rather than the cache. Not implemented.
|---|---|---| |---|---|---|
| Format, cache, reader, pass | `orisys_app_shared.analysis` | shipped (`9e20fd0`, cache root and sweep now) | | Format, cache, reader, pass | `orisys_app_shared.analysis` | shipped (`9e20fd0`, cache root and sweep now) |
| Timeline, cursor, policy, pass thread, progress surface | `replay_analysis.py`, `replay.py`, `replay_controller.py`, `analysis_worker.py`, `ui/analysis_progress.py` | shipped; the no-result states are part of the panel's line | | Timeline, cursor, policy, pass thread, progress surface | `replay_analysis.py`, `replay.py`, `replay_controller.py`, `analysis_worker.py`, `ui/analysis_progress.py` | shipped; the no-result states are part of the panel's line |
| Mode plan, drawing, window wiring | `image_backend.py`, `acquisition/arrows.py`, `app_window.py` | shipped (Part B §12 names the seams) | | Mode plan, drawing, window wiring | `image_backend.py`, `acquisition/arrows.py`, `app_window.py` | shipped (Part B §12); the painter is chosen by the frame's source |
| Cache root and lifetime | `analysis/cache.py`, `paths.py`, `app_window.py` | root and startup sweep implemented; clear action and wipe-on-exit preference are not | | A bare video: workspace, setup prompt, per-frame force | `analysis/bare_video.py`, `ui/video_analysis_dialog.py`, `analysis/{model,service,reader}.py`, `force/source.py` | shipped (appended `FRAME_FLAG_FORCE`, format version unchanged) |
| Display-control separation: rail reset as the measurement reference, a separate display baseline, the F8 view reset | `toolbar.py`, `display_prefs.py`, `app_window.py` | not started; Part B §12 records these as unchanged. The pass accepts a `reference_frame` but `_analysis_service` passes none | | Display-control separation (Part C) | `toolbar.py`, `display_prefs.py`, `app_window.py` | not started (Part B §12) |
## Not confirmed in code ## Not confirmed in code
* The render cost (≈1.4 ms, idle dots 37 µs) and the PNG estimate (180–540 MB per ten minutes) * The render cost (≈1.4 ms) and the PNG estimate (180–540 MB per ten minutes) are the owner's
are the owner's bench, not measurements here; Part B §14's single-frame snapshots (4.2–4.9 kB) bench; Part B §14's snapshots (4.2–4.9 kB each) suggest the estimate is high.
suggest the estimate is high, so it should be re-measured.
* The manual clear action, the wipe-on-exit preference and result export have no code in * The manual clear action, the wipe-on-exit preference and result export have no code in
`orisys-app`; they are decisions. `orisys-app`; they are decisions.
* A chosen calibration can be applied to the **box** chain only (`Sensor(cal_path=…)`). The
finger runtime opens its sensor without one, so a non-default curve for a finger profile is
refused rather than silently ignored — the SDK has no entry point for it (reported, not
worked around).
+59
View File
@@ -0,0 +1,59 @@
# Sensor bundles
Every sensor configuration the Studio offers is a folder under `apps/studio/config/sensors/`
holding `sensor.json` — the processing configuration. `orisys_app_shared.sensors.bundle_catalog`
scans that folder and the writable user root beside it, and resolves each candidate through the
SDK's `orisys.sensors.resolve_bundle`, so the picker shows what the SDK decided rather than a
second opinion computed in the UI.
`finger/` also carries `manifest.json`, the bundle descriptor. A descriptor states the profile's
identity and its files instead of leaving both to be inferred, which is what lets the bundle keep
its meaning when the folder is moved or renamed.
## The finger descriptor
| Field | Value | Why |
|---|---|---|
| `schema_version` | `1` | The SDK's current descriptor schema (`CURRENT_DESCRIPTOR_SCHEMA`). |
| `profile_id` | `orisys.finger.r01` | The processing profile this folder is: finger design revision `r01`, whose unroll map and camera scale the configuration pins. Never a product-family name. |
| `display_name` | `Finger R01` | Shown in the picker. Excluded from the processing fingerprint. |
| `product_family` | `finger` | What the operator calls it. `finger-mems` is a different family on a different pipeline. |
| `processing_backend` | `uv_unroll` | The pipeline the finger actually runs: `FingerRuntime` builds a UV domain from the export's unroll map, then flow and force. |
| `sensor_json` | `sensor.json` | The configuration, still the only place camera, tracker and contact settings live. |
| `computational_assets.unroll_map` | `assets/30fps-0703/unroll_map_uv_s0.42.npz` | The map the chain loads. `finger_worker` derives that name for itself from the export folder and `camera.source_scale`, so the descriptor and the chain are checked against each other. Required: without it the profile fails. |
| `assets.scene` | `scene/finger-display-1.0.0.oryscene` | The compiled package the 3D pane draws. Visualization only: a missing one never blocks processing. |
Every path is relative to the bundle root, and the SDK refuses an absolute path or a `..` escape
when it parses the descriptor. `assets/visualization/` is deliberately left out of `assets`: it is
offline compiler input, the packaged build strips it, and nothing at runtime reads it.
`sensor.json` declares `"type": "single_camera"` while the descriptor says `uv_unroll`. With a
descriptor present the descriptor decides, and it is the truthful one — the finger path never
builds a single-camera processor. Do not "correct" `type`: it is part of the hashed configuration
(only `name`, `version` and `display_name` are skipped), so editing it would re-identify every
recorded finger pass and change no number.
## Compatibility keys
`name`, `assets_dir` and `viz_pose` are kept beside the descriptor fields — the shape this bundle
had before it carried a descriptor. They are relative too, so they do not weaken rename safety,
and they keep readers that never learned about the descriptor working:
| Key | Reader |
|---|---|
| `assets_dir` | `orisys/configs.resolve_sensor_bundle` → `resolve_sensor_asset_dir`, which `orisys.finger.exports` consults for a bundled export; `acquisition/geometry.py::describe_sensor_geometry`, the device-geometry check the 3D pane runs; `build/studio/build.ps1`, which checks the packaged copy; `orisys-sdk/examples/gui/viewer3d/presets.py`. |
| `viz_pose` | `orisys-sdk/examples/viz/mesh_camera.py`. |
| `name` | Nothing reads it today; it is kept because it belongs to the legacy shape. |
Dropping a key means migrating those readers first, not tidying up the JSON.
## Verification
`apps/studio/tests/test_sensor_catalog_root.py` asserts the descriptor's fields, that every
declared path is relative and present, that the declared map is the one the running chain derives
for itself (the SDK's `scaled_map_cache_path`, so `_s0.42.npz` and never a neighbouring
`_s0.4.npz`), and that a copy of the bundle under another folder name keeps its `profile_id`, its
processing fingerprint and all of its resolved paths inside the copy.
The descriptor grammar itself is owned by the SDK:
[orisys-sdk/docs/sensor-bundles.md](../../../../orisys-sdk/docs/sensor-bundles.md).
+16 -15
View File
@@ -9,12 +9,12 @@
## 1. 三个顶层视图 = 同一窗口的三套布局 ## 1. 三个顶层视图 = 同一窗口的三套布局
品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局 品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局
(`StudioMainWindow._apply_view_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`): (`ViewLayoutController.apply_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`):
| 视图 | 面板 | 曲线 | 比例(3D,2D,曲线,标定) | | 视图 | 面板 | 曲线 | 比例(3D,2D,曲线,标定) |
|---|---|---|---| |---|---|---|---|
| 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` | | 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `620, 300, 520, 0` |
| 历史回放 `replay` | 2D 图像 + 力曲线(**与实时同布局**) | 读打开的录制;未打开时仍是实时跟随 | `0, 360, 1080, 0` | | 历史回放 `replay` | 2D 图像 + 力曲线(**与实时同布局**) | 读打开的录制;未打开时仍是实时跟随 | `620, 300, 520, 0` |
| 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` | | 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
| 自由 `custom`(启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 | | 自由 `custom`(启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
@@ -102,22 +102,23 @@
摘要里的视频名**不再出现**(这正是"视频文件名残留在实时检测里"的修法)。 摘要里的视频名**不再出现**(这正是"视频文件名残留在实时检测里"的修法)。
- 文件菜单两条入口(`打开录制视频…` / `回放最近一次录制`)都只是切到回放视图并加载录制。 - 文件菜单两条入口(`打开录制视频…` / `回放最近一次录制`)都只是切到回放视图并加载录制。
### 2.1 「由视频重算」:算法复核模式 ### 2.1 「由视频重算」:与打开录像同一条分析入口
`视图 → 由视频重算力值`(`act_recompute`)把曲线的来源从**录制的 CSV** 换成**把录制的视频 `视图 → 由视频重算力值`(`act_recompute`)调用
重新跑一遍视觉管线**: `ReplayController.on_analysis_requested()`——与打开录像后自动开分析是同一条路径(见
[replay-analysis-part-b.md](replay-analysis-part-b.md) 第 10 节):先读兼容缓存,没有再跑
离线 pass。分析结果经 `show_analysis_packet` 喂二维与三维;有 `forces.csv` 的录制仍让图表
读 CSV,裸视频才用结果里重算的力迹。
| | 默认(关闭) | 打开 | | | 默认(关闭) | 打开(分析) |
|---|---|---| |---|---|---|
| 曲线数据 | 录制目录的 `forces.csv`(当时测得的值) | 管线在视频上重算出的样本(进 `SessionStore`) | | 曲线数据 | 录制目录的 `forces.csv`(有则保留) | 同上;裸视频读结果里的力迹 |
| 2D 面板 | 录制视频在游标处的那一帧 | 管线正在处理的帧 | | 2D / 3D | 录制帧;有结果时派生模式读缓存 | 同一游标上的分析结果 packet |
| 时间轴 | 任意定位(游标、框选、总览条、播放) | 只能顺序前进(管线是有状态的),播放按钮即开始/暂停这一遍 | | 时间轴 | 任意定位(游标、框选、总览条、播放) | 仍可任意定位;pass 跑完前派生模式等缓存 |
| 用途 | 看当时的记录 | 复核算法版本(mp4 有损,数值与原记录会有差异) | | 用途 | 看当时的记录 | 打开即分析 / 强制再跑一遍离线 pass |
打开它时会立即开始这一遍(`_on_recompute_toggled` → `force_plots.set_playing(True)` → 关闭开关时取消进行中的分析 pass,并挂回录制来源。Stop / Esc 停播放与分析 pass,不再为
`_on_transport_toggled` → `_start_run`,只有一条启动路径,避免按钮与菜单各启动一次); 「重算」另开一条采集链。视图切换仍只改家具:`ViewLayoutController.apply_preset`。
关闭时停止该遍并挂回 CSV。`_replay_owns_image()` 在这一遍运行期间为 False,2D 面板交给
管线自己画。
`apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核 `apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核
统一后的回放布局(不写业务数据,也不需要硬件)。 统一后的回放布局(不写业务数据,也不需要硬件)。
+3 -3
View File
@@ -234,12 +234,12 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
## 7. 三个顶层视图 = 同一窗口的三套布局 ## 7. 三个顶层视图 = 同一窗口的三套布局
导航不再打开对话框,而是切换主窗口的布局(`StudioMainWindow._apply_view_preset`): 导航不再打开对话框,而是切换主窗口的布局(`ViewLayoutController.apply_preset`):
| 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) | | 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) |
|---|---|---|---| |---|---|---|---|
| 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` | | 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `620, 300, 520, 0` |
| 历史回放 | 2D 图像 + 力曲线 | 读打开的录制(全程浏览) | `0, 360, 1080, 0`(与实时相同) | | 历史回放 | 2D 图像 + 力曲线 | 读打开的录制(全程浏览) | `620, 300, 520, 0`(与实时相同) |
| 标定 | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` | | 标定 | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
| 自由(`custom`,启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 | | 自由(`custom`,启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
@@ -0,0 +1,198 @@
"""Present one acquisition packet onto the shared panels.
The session controller owns *whether* a run is alive; this module owns what a
live packet does to the furniture once the lifecycle has decided the session is
still going. Calibration sampling stays here on purpose: it rides the record
channel, so hiding the calibration panel must not stall it.
The replay view's 33 ms play tick stays in :class:`ReplayController`. Ownership
of the 2D panel is decided here once (``replay_owns_image``) and nowhere else.
"""
from __future__ import annotations
import logging
from typing import Any, Protocol
import numpy as np
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.i18n import t
from orisys_app_shared.record.sample import to_record_sample
from PySide6.QtCore import QObject
logger = logging.getLogger(__name__)
class AcquirePresentHost(Protocol):
"""What the presenter needs from the shell around a live packet."""
session: Any
session_store: Any
toolbar: Any
def session_time(self, packet: Any) -> float: ...
def notify(self, text: str, ms: int = 0) -> None: ...
def update_run_enabled(self) -> None: ...
def refresh_status_rail(self) -> None: ...
def refresh_record_state(self) -> None: ...
def drain_calibration_events(self) -> None: ...
def drain_reset_events(self) -> None: ...
def render_run_image(self, packet: Any) -> None: ...
def replay_owns_image(self) -> bool: ...
def show_status(self, text: str) -> None: ...
class AcquirePresenter(QObject):
"""Force charts, calibration sampling, 3D advance, and the live 2D paint."""
def __init__(
self,
*,
host: AcquirePresentHost,
force_plots: Any,
cal_panel: Any,
cal_recorder: Any,
arrow_panel: Any,
mesh_panel: Any,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._host = host
self._charts = force_plots
self._cal_panel = cal_panel
self._cal_recorder = cal_recorder
self._arrow = arrow_panel
self._mesh = mesh_panel
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
self.last_display_fallback: bool | None = None
self.last_display_available: tuple[bool, bool, bool] | None = None
def drop_render_marker(self) -> None:
"""A recorded frame was on the panel: the next live packet must repaint it."""
self._last_rendered_view_mode = None
def note_rendered_mode(self, mode: str | None) -> None:
"""Remember the mode the 2D sink just painted (live or preview)."""
self._last_rendered_view_mode = None if mode is None else str(mode)
def clear_plot_marker(self) -> None:
"""The next packet is a new sample again (pause resume / tracker reset)."""
self._last_plot_packet_key = None
def tick_3d(self, *, display_paused: bool) -> None:
"""Advance the 3D pane on the session's clock. No second timer."""
if not display_paused:
self._mesh.tick()
def present(self, packet: Any, *, display_paused: bool) -> None:
"""Paint one packet. Caller has already handled session end / errors."""
packet_key = (int(packet.session_id), float(packet.timestamp))
is_new = packet_key != self._last_plot_packet_key
if is_new and self._host.session.note_first_frame():
self._host.update_run_enabled()
if is_new and not display_paused:
self._last_plot_packet_key = packet_key
self._push_forces(packet)
self._cal_panel.set_live(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
)
# Finalise calibration sampling on the UI thread once its deadline passes.
self._cal_recorder.tick()
self._cal_panel.poll_recorder()
self._host.drain_calibration_events()
self._host.drain_reset_events()
dropped_message = self._host.session.take_dropped_message(
self._host.session_store.dropped
)
if dropped_message:
self._host.show_status(dropped_message)
mode = self._arrow.view_mode()
if not display_paused and (
(is_new or mode != self._last_rendered_view_mode)
and not self._host.replay_owns_image()
):
self._host.render_run_image(packet)
# ``render_run_image`` resolves the mode onto the panel; note what was
# actually painted so a mode-only change still repaints once.
self._last_rendered_view_mode = self._arrow.view_mode()
if is_new:
self._host.session.note_sample_telemetry(packet)
detail = self._host.session.check_capture_negotiation()
if detail:
self._host.notify(t("status.capture_slow", detail=detail))
self._host.refresh_status_rail()
self._host.refresh_record_state()
def _push_forces(self, packet: Any) -> None:
record = to_record_sample(packet)
if not record.valid:
self._host.session.last_sample_valid = False
self._host.show_status(
self._host.session.invalid_sample_message(record.invalid_reason)
)
return
display = select_display_forces(
units_pref=self._host.toolbar.units(),
sample=record,
)
if display.using_raw_fallback:
if not self._host.session.raw_fallback_notified:
self._host.session.raw_fallback_notified = True
self._host.notify(t("status.units_raw_fallback"))
else:
self._host.session.raw_fallback_notified = False
self._host.session.last_sample_valid = True
self.last_display_fallback = bool(display.using_raw_fallback)
self.last_display_available = tuple(bool(flag) for flag in display.available)
flow_p95 = _flow_p95(packet)
contact = getattr(packet, "contact", None)
contact_found = bool(getattr(contact, "found", False)) if contact else False
self._charts.push(
self._host.session_time(packet),
display.values[0],
display.values[1],
display.values[2],
unit=display.unit,
available=display.available,
flow_p95=flow_p95,
contact_found=contact_found,
forces_valid=bool(getattr(packet, "forces_valid", True)),
)
def _flow_p95(packet: Any) -> float | None:
flow = getattr(packet, "flow", None)
if flow is None:
return None
try:
arr = np.asarray(flow, dtype=np.float64)
if arr.ndim != 3 or arr.shape[-1] != 2:
return None
mag = np.linalg.norm(arr, axis=-1)
mask = getattr(packet, "valid_mask", None)
if mask is not None:
m = np.asarray(mask, dtype=bool)
vals = mag[m & np.isfinite(mag)] if m.shape == mag.shape else mag[np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
if vals.size:
return float(np.percentile(vals, 95))
except Exception: # noqa: BLE001
logger.debug("flow p95 skipped", exc_info=True)
return None
+229 -208
View File
@@ -25,7 +25,6 @@ from orisys_app_shared.acquisition.image_backend import (
plan_image, plan_image,
render_image, render_image,
) )
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.filters import FilterSettings from orisys_app_shared.force.filters import FilterSettings
from orisys_app_shared.force.session import SessionStore from orisys_app_shared.force.session import SessionStore
@@ -34,7 +33,6 @@ from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path
from orisys_app_shared.prefs import save_preferences from orisys_app_shared.prefs import save_preferences
from orisys_app_shared.record import RecordConfig from orisys_app_shared.record import RecordConfig
from orisys_app_shared.record.sample import to_record_sample
from orisys_app_shared.theme import build_qss, clear_theme_cache, color from orisys_app_shared.theme import build_qss, clear_theme_cache, color
from PySide6.QtCore import Qt, QTimer from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QActionGroup, QIcon from PySide6.QtGui import QAction, QActionGroup, QIcon
@@ -49,6 +47,7 @@ from PySide6.QtWidgets import (
) )
from orisys_studio import session_metadata from orisys_studio import session_metadata
from orisys_studio.acquire_presenter import AcquirePresenter
from orisys_studio.calibration_service import CalibrationService from orisys_studio.calibration_service import CalibrationService
from orisys_studio.display_prefs import DisplayPrefsController from orisys_studio.display_prefs import DisplayPrefsController
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
@@ -74,6 +73,7 @@ from orisys_studio.ui.plot_settings_dialog import (
) )
from orisys_studio.ui.sensor_picker import SensorPickerWidget from orisys_studio.ui.sensor_picker import SensorPickerWidget
from orisys_studio.ui.toolbar import ToolbarWidget from orisys_studio.ui.toolbar import ToolbarWidget
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
from orisys_studio.view_layout import ViewLayoutController from orisys_studio.view_layout import ViewLayoutController
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -178,8 +178,6 @@ class StudioMainWindow(QMainWindow):
self._preview_timer = QTimer(self) self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33) self._preview_timer.setInterval(33)
self._preview_timer.timeout.connect(self._on_preview_tick) self._preview_timer.timeout.connect(self._on_preview_tick)
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
# ---- replay --------------------------------------------------------- # ---- replay ---------------------------------------------------------
# One engine for the history view: the recording reader, the cursor and # One engine for the history view: the recording reader, the cursor and
# the asynchronous depth recomputation. It renders nothing itself; the policy # the asynchronous depth recomputation. It renders nothing itself; the policy
@@ -222,13 +220,11 @@ class StudioMainWindow(QMainWindow):
#: frame that was already in flight cannot repaint the panels they paused on. #: frame that was already in flight cannot repaint the panels they paused on.
self._display_paused = False self._display_paused = False
self._active_calibration_id = "" self._active_calibration_id = ""
#: Last display-unit decision (``None`` before the first sample): the
#: status rail's calibration readout is derived from it, because an axis
#: without a newton curve is exactly an axis without calibration.
self._last_display_fallback: bool | None = None
self._last_display_available: tuple[bool, bool, bool] | None = None
#: Last coefficients file written by the calibration panel (not applied). #: Last coefficients file written by the calibration panel (not applied).
self._last_calibration_export = "" self._last_calibration_export = ""
#: Built once the panels exist (see below); until then the status rail
#: treats calibration as unknown.
self.acquire_presenter: AcquirePresenter | None = None
self._depth_range_value: tuple[float, float] | None = None self._depth_range_value: tuple[float, float] | None = None
self._last_depth_frame = None self._last_depth_frame = None
#: The 2D domain boundary, latched for the session the way the depth range is: #: The 2D domain boundary, latched for the session the way the depth range is:
@@ -364,6 +360,22 @@ class StudioMainWindow(QMainWindow):
self.brand_bar.calibration_requested.connect( self.brand_bar.calibration_requested.connect(
self.layout_ctl.on_calibration_requested self.layout_ctl.on_calibration_requested
) )
# Replay asks for a view change or an acquire start/stop by signal: the
# window wires those acts, so the controller never names a private method.
self.replay_ctl.view_requested.connect(self.layout_ctl.apply_preset)
self.replay_ctl.acquire_requested.connect(self._start_run)
self.replay_ctl.acquire_stop_requested.connect(self._on_acquire_stop_requested)
# One presenter paints live packets: force charts, calibration sampling,
# 3D advance and the 2D sink when acquisition owns the image.
self.acquire_presenter = AcquirePresenter(
host=self,
force_plots=self.force_plots,
cal_panel=self.cal_panel,
cal_recorder=self.cal_recorder,
arrow_panel=self.arrow_panel,
mesh_panel=self.mesh3d_panel,
parent=self,
)
self.setStatusBar(_NoticeBar()) self.setStatusBar(_NoticeBar())
# The status bar carries **one-off notices** only (a recording finished, a # The status bar carries **one-off notices** only (a recording finished, a
@@ -385,7 +397,9 @@ class StudioMainWindow(QMainWindow):
self._restore_3d_settings() self._restore_3d_settings()
# Visibility and the nav highlight come from the restored view; the # Visibility and the nav highlight come from the restored view; the
# stored splitter sizes still win at startup (``apply_sizes=False``). # stored splitter sizes still win at startup (``apply_sizes=False``).
self._apply_view_preset(self._view_preset, apply_sizes=False, persist=False) self.layout_ctl.apply_preset(
self._view_preset, apply_sizes=False, persist=False
)
self._restore_splitter_sizes() self._restore_splitter_sizes()
self._update_run_enabled() self._update_run_enabled()
self.cal_panel.bind_recorder(self.cal_recorder) self.cal_panel.bind_recorder(self.cal_recorder)
@@ -511,6 +525,101 @@ class StudioMainWindow(QMainWindow):
self._preview = RawPreviewSession() self._preview = RawPreviewSession()
return self._preview return self._preview
# --------------------------------------------------------- shell ports
# Controllers talk to these public names (see ``shell_ports``). The private
# methods below remain as the implementations tests and ``ui_check`` still
# drive; nothing here duplicates state.
@property
def running(self) -> bool:
return self._running
@property
def preview_open(self) -> bool:
return self._preview_open
@property
def session_active(self) -> bool:
return self._session_active
def start_acquire(self) -> None:
self._start_run()
def stop_acquire(self, *, reason: str = "user_stop", wait: bool = False) -> None:
self._stop_run(reason=reason, wait=wait)
def _on_acquire_stop_requested(self, reason: str) -> None:
self._stop_run(reason=str(reason or "user_stop"))
def show_frame(self, frame) -> None:
self._show_image_frame(frame)
def show_analysis_packet(self, packet) -> None:
self._show_analysis_frame(packet)
def drop_render_marker(self) -> None:
self._drop_render_marker()
def session_time(self, packet) -> float:
return self._session_time(packet)
def drain_calibration_events(self) -> None:
self._drain_calibration_events()
def drain_reset_events(self) -> None:
self._drain_reset_events()
def render_run_image(self, packet) -> None:
self._render_run_image(packet)
def replay_owns_image(self) -> bool:
return self._replay_owns_image()
def refresh_record_state(self) -> None:
self._refresh_record_state()
def show_status(self, text: str) -> None:
self.statusBar().showMessage(str(text or ""))
def notify(self, text: str, ms: int = 0) -> None:
if ms:
self._notify(text, ms)
else:
self._notify(text)
def refresh_status_rail(self) -> None:
self._refresh_status_rail()
def persist_prefs(self) -> None:
self._persist_prefs()
def update_run_enabled(self) -> None:
self._update_run_enabled()
def set_source_mode(self, mode: str) -> None:
self._set_source_mode(mode)
def set_display_unit(self, unit: str) -> None:
self._set_display_unit(unit)
def current_source_kind(self) -> str:
return self._current_source_kind()
def analysis_service(self, recording, cancelled=None, *, on_progress=None):
return self._analysis_service(recording, cancelled, on_progress=on_progress)
def bare_video_workspace(self, video):
return self._bare_video_workspace(video)
def device_geometry_provider(self):
return self._device_geometry_provider()
@property
def latest_fps(self):
return self._latest_fps
def repaint_live_frame(self) -> None:
self._repaint_live_frame()
# ------------------------------------------------------- experiment record # ------------------------------------------------------- experiment record
@property @property
def _record_fps(self) -> float: def _record_fps(self) -> float:
@@ -823,27 +932,38 @@ class StudioMainWindow(QMainWindow):
def _start_playback(self) -> None: def _start_playback(self) -> None:
self.replay_ctl.start_playback() self.replay_ctl.start_playback()
def _pause_playback(self) -> None:
self.replay_ctl.pause_playback()
def _on_play_shortcut(self) -> None: def _on_play_shortcut(self) -> None:
self.replay_ctl.on_play_shortcut() self.replay_ctl.on_play_shortcut()
def _stop_replay_activity(self) -> None:
self.replay_ctl.stop_activity()
def _replay_owns_image(self) -> bool: def _replay_owns_image(self) -> bool:
return self.replay_ctl.owns_image() return self.replay_ctl.owns_image()
def _can_recompute(self) -> bool:
return self.replay_ctl.can_recompute()
def _in_replay_view(self) -> bool:
return self._view_preset == "replay"
def _drop_render_marker(self) -> None: def _drop_render_marker(self) -> None:
"""A recorded frame was on the panel: the next live packet must repaint it.""" """A recorded frame was on the panel: the next live packet must repaint it."""
self._last_rendered_view_mode = None if self.acquire_presenter is not None:
self.acquire_presenter.drop_render_marker()
@property
def _last_display_fallback(self) -> bool | None:
if self.acquire_presenter is None:
return None
return self.acquire_presenter.last_display_fallback
@_last_display_fallback.setter
def _last_display_fallback(self, value: bool | None) -> None:
if self.acquire_presenter is not None:
self.acquire_presenter.last_display_fallback = value
@property
def _last_display_available(self) -> tuple[bool, bool, bool] | None:
if self.acquire_presenter is None:
return None
return self.acquire_presenter.last_display_available
@_last_display_available.setter
def _last_display_available(self, value: tuple[bool, bool, bool] | None) -> None:
if self.acquire_presenter is not None:
self.acquire_presenter.last_display_available = value
@property @property
def _play_timer(self): def _play_timer(self):
@@ -1112,7 +1232,7 @@ class StudioMainWindow(QMainWindow):
``recompute`` only while a pass is actually running: the label is a claim ``recompute`` only while a pass is actually running: the label is a claim
about the pixels on screen, and a stale claim is worse than none. about the pixels on screen, and a stale claim is worse than none.
""" """
if self._in_replay_view(): if self.layout_ctl.in_replay_view():
return "recompute" if self._recompute_running else "replay" return "recompute" if self._recompute_running else "replay"
return "live" return "live"
@@ -1145,13 +1265,6 @@ class StudioMainWindow(QMainWindow):
def _show_finger_3d(self, enabled: bool) -> None: def _show_finger_3d(self, enabled: bool) -> None:
self.layout_ctl.show_finger_3d = enabled self.layout_ctl.show_finger_3d = enabled
def _apply_view_preset(
self, preset: str, *, apply_sizes: bool = True, persist: bool = True
) -> None:
self.layout_ctl.apply_preset(
preset, apply_sizes=apply_sizes, persist=persist
)
def _apply_panel_visibility(self) -> None: def _apply_panel_visibility(self) -> None:
self.layout_ctl.apply_panel_visibility() self.layout_ctl.apply_panel_visibility()
@@ -1203,11 +1316,12 @@ class StudioMainWindow(QMainWindow):
reason = t("toolbar.playback_disabled") reason = t("toolbar.playback_disabled")
self.toolbar.set_run_tooltip_reason(reason) self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing()) self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
# Stop leaves the replay, whatever it is doing: plain playback, a # Stop leaves the replay, whatever it is doing: plain playback, an
# recompute pass, or a session the pass started. The operator must never # analysis pass, or a leftover acquisition session. The operator must
# have to work out which of those is stuck before they can stop it. # never have to work out which of those is stuck before they can stop it.
self.toolbar.set_stop_enabled( self.toolbar.set_stop_enabled(
self.replay_ctl.is_playing() self.replay_ctl.is_playing()
or self.replay_ctl.analysis_running()
or self._recompute_running or self._recompute_running
or self._session_active or self._session_active
) )
@@ -1663,7 +1777,8 @@ class StudioMainWindow(QMainWindow):
# shows next time is what the session will actually be given. # shows next time is what the session will actually be given.
for key, value in filters.as_dict().items(): for key, value in filters.as_dict().items():
self.prefs[f"plot_{key}"] = value self.prefs[f"plot_{key}"] = value
self._last_plot_packet_key = None if self.acquire_presenter is not None:
self.acquire_presenter.clear_plot_marker()
self._persist_prefs() self._persist_prefs()
if not self._running and not self.recorder.is_recording: if not self._running and not self.recorder.is_recording:
# Nothing is streaming: hand the choice over now, so the next session # Nothing is streaming: hand the choice over now, so the next session
@@ -1945,6 +2060,7 @@ class StudioMainWindow(QMainWindow):
if self.toolbar.run_role() == "playback": if self.toolbar.run_role() == "playback":
if ( if (
self.replay_ctl.is_playing() self.replay_ctl.is_playing()
or self.replay_ctl.analysis_running()
or self._session_active or self._session_active
or self._recompute_running or self._recompute_running
): ):
@@ -2132,15 +2248,16 @@ class StudioMainWindow(QMainWindow):
# it for every other product. # it for every other product.
enabled=bool(self.prefs.get("raw_auto_zero", True)), enabled=bool(self.prefs.get("raw_auto_zero", True)),
) )
self._last_plot_packet_key = None if self.acquire_presenter is not None:
self._last_rendered_view_mode = None self.acquire_presenter.clear_plot_marker()
self.acquire_presenter.drop_render_marker()
self.acquire_presenter.last_display_fallback = None
self.acquire_presenter.last_display_available = None
# Fresh record channel for the new session. The calibration identity is # Fresh record channel for the new session. The calibration identity is
# unknown until the device confirms which coefficients it loaded, so it # unknown until the device confirms which coefficients it loaded, so it
# starts empty and is filled from that confirmation — a path is not a # starts empty and is filled from that confirmation — a path is not a
# version, and the same path can hold different numbers. # version, and the same path can hold different numbers.
self._active_calibration_id = "" self._active_calibration_id = ""
self._last_display_fallback = None
self._last_display_available = None
self._depth_range_value = None self._depth_range_value = None
self._last_depth_frame = None self._last_depth_frame = None
# The new session has not established a domain boundary yet; the previous # The new session has not established a domain boundary yet; the previous
@@ -2263,30 +2380,6 @@ class StudioMainWindow(QMainWindow):
else: else:
self.statusBar().showMessage(t("status.run_stopped")) self.statusBar().showMessage(t("status.run_stopped"))
def _stop_replay_activity(self) -> None:
"""Stop whatever the replay view is doing. The operator's escape hatch.
Two different things can be running there — plain playback of the recorded
timeline, and a recompute pass over the video — and the person in front of the
screen should not have to work out which one is stuck before they can stop it.
So this stops both, clears a recomputed 3D picture, and says what it stopped.
Safe to press when nothing is running (it says so rather than doing nothing
silently, which is what made a stuck state feel stuck).
"""
stopped: list[str] = []
if self._recompute_running or self._session_active:
self._stop_run(reason="user_stop")
stopped.append(t("toolbar.stopped_pass"))
if self._play_timer.isActive():
self._pause_playback()
stopped.append(t("toolbar.stopped_playback"))
self.mesh3d_panel.show_reference_only()
self._update_run_enabled()
if stopped:
self._notify(t("toolbar.stopped", what=", ".join(stopped)))
else:
self._notify(t("toolbar.nothing_to_stop"))
def _on_reset(self) -> None: def _on_reset(self) -> None:
"""Zero the *sensor tracker* — or, where there is no device, reset the display. """Zero the *sensor tracker* — or, where there is no device, reset the display.
@@ -2316,7 +2409,8 @@ class StudioMainWindow(QMainWindow):
# estimate stale, so the display baseline is re-learned from the samples # estimate stale, so the display baseline is re-learned from the samples
# that follow. The curve history itself is kept. # that follow. The curve history itself is kept.
self.force_plots.request_presentation_reestimate() self.force_plots.request_presentation_reestimate()
self._last_plot_packet_key = None if self.acquire_presenter is not None:
self.acquire_presenter.clear_plot_marker()
self.statusBar().showMessage(t("status.reset_pending")) self.statusBar().showMessage(t("status.reset_pending"))
except Exception as exc: except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc))) self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
@@ -2381,7 +2475,8 @@ class StudioMainWindow(QMainWindow):
else: else:
# The next packet is a new sample again: without this the first one after a # The next packet is a new sample again: without this the first one after a
# resume can be swallowed as a repeat of the last key the tick saw. # resume can be swallowed as a repeat of the last key the tick saw.
self._last_plot_packet_key = None if self.acquire_presenter is not None:
self.acquire_presenter.clear_plot_marker()
self.statusBar().showMessage(t("status.resumed")) self.statusBar().showMessage(t("status.resumed"))
def _on_pause_action_toggled(self, paused: bool) -> None: def _on_pause_action_toggled(self, paused: bool) -> None:
@@ -2421,16 +2516,15 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("status.view_reset")) self.statusBar().showMessage(t("status.view_reset"))
def _on_run_tick(self) -> None: def _on_run_tick(self) -> None:
# The 3D pane advances on this timer rather than one of its own: the window's """Session lifecycle on the 16 ms clock; painting is :class:`AcquirePresenter`.
# refresh rate is then the only one that exists, and hiding or disabling the
# pane cannot leave a timer behind. It starts at most one compose on the Errors, end reasons and a stuck connect stay here — they call stop. A live
# pane's own worker and takes at most one finished frame. packet's charts, calibration sampling and 2D/3D paint live in the presenter
if not self._display_paused: so a replay owning the image cannot be overwritten by accident.
# While paused the pane holds the frame it had. The acquisition thread has """
# already stopped computing, so this only matters for a frame that was in presenter = self.acquire_presenter
# flight when the operator pressed pause — which must not be the one frame if presenter is not None:
# they cannot see. presenter.tick_3d(display_paused=self._display_paused)
self.mesh3d_panel.tick()
if self._active_worker is None: if self._active_worker is None:
return return
# Record bookkeeping happens before anything that can return early: a # Record bookkeeping happens before anything that can return early: a
@@ -2473,122 +2567,9 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("status.connect_slow")) self.statusBar().showMessage(t("status.connect_slow"))
packet = self._active_worker.latest() packet = self._active_worker.latest()
if packet is None: if packet is None or presenter is None:
return return
packet_key = (int(packet.session_id), float(packet.timestamp)) presenter.present(packet, display_paused=self._display_paused)
is_new = packet_key != self._last_plot_packet_key
if is_new and self.session.note_first_frame():
# The phase just became ``running``, which is the session state the rail
# reads (the reset keys off it): ask the one owner of that rule rather
# than stating the enablement a second time here.
self._update_run_enabled()
# No status-bar line here: the rail's phase chip says 采集中 and the
# source slot names the camera, so "运行中(0)" was a second copy of
# the same fact that then stayed on screen for the rest of the session.
if is_new and not self._display_paused:
self._last_plot_packet_key = packet_key
# The live view consumes the *same* standardised measurement as the
# CSV and the session store: one conversion decides what a value
# means, so the chart cannot show something the record does not
# contain (or vice versa).
record = to_record_sample(packet)
if record.valid:
display = select_display_forces(
units_pref=self.toolbar.units(),
sample=record,
)
if display.using_raw_fallback:
# Announced once per run, not once per frame: a notice that is
# re-issued every 16 ms never expires, and the rail already
# carries the calibration state.
if not self.session.raw_fallback_notified:
self.session.raw_fallback_notified = True
self._notify(t("status.units_raw_fallback"))
else:
self.session.raw_fallback_notified = False
self.session.last_sample_valid = True
self._last_display_fallback = bool(display.using_raw_fallback)
self._last_display_available = tuple(bool(flag) for flag in display.available)
flow = getattr(packet, "flow", None)
flow_p95 = None
if flow is not None:
try:
import numpy as np
arr = np.asarray(flow, dtype=np.float64)
if arr.ndim == 3 and arr.shape[-1] == 2:
mag = np.linalg.norm(arr, axis=-1)
mask = getattr(packet, "valid_mask", None)
if mask is not None:
m = np.asarray(mask, dtype=bool)
if m.shape == mag.shape:
vals = mag[m & np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
if vals.size:
flow_p95 = float(np.percentile(vals, 95))
except Exception: # noqa: BLE001
flow_p95 = None
contact = getattr(packet, "contact", None)
contact_found = bool(getattr(contact, "found", False)) if contact else False
self.force_plots.push(
# Session-relative time, the same base the store and
# ``forces.csv`` use. The wall clock must never reach the
# buffer: it would put the live axis on a different scale.
self._session_time(packet),
display.values[0],
display.values[1],
display.values[2],
unit=display.unit,
available=display.available,
flow_p95=flow_p95,
contact_found=contact_found,
forces_valid=bool(getattr(packet, "forces_valid", True)),
)
else:
self.session.last_sample_valid = False
self.statusBar().showMessage(
self.session.invalid_sample_message(record.invalid_reason)
)
# The calibration panel is a *view*: sampling is driven by the
# record channel, so hiding the panel can no longer stall it.
self.cal_panel.set_live(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
)
# Finalise calibration sampling on the UI thread once its deadline passes.
self.cal_recorder.tick()
self.cal_panel.poll_recorder()
self._drain_calibration_events()
self._drain_reset_events()
# Never let the memory bound discard data silently: one line per new batch.
dropped_message = self.session.take_dropped_message(self.session_store.dropped)
if dropped_message:
self.statusBar().showMessage(dropped_message)
mode = self.arrow_panel.view_mode()
if not self._display_paused and (
(is_new or mode != self._last_rendered_view_mode)
and not (self._replay_owns_image())
):
self._render_run_image(packet)
if is_new:
self.session.note_sample_telemetry(packet)
# Once per session, as soon as the driver has confirmed its rate.
detail = self.session.check_capture_negotiation()
if detail:
self._notify(t("status.capture_slow", detail=detail))
# The rail carries the live telemetry (fps, validity, calibration) and
# the record strip its own numbers. Both compare before repainting, so
# this is safe on a 16 ms timer.
self._refresh_status_rail()
self._refresh_record_state()
# ------------------------------------------------------------ depth scale # ------------------------------------------------------------ depth scale
def _depth_range(self) -> tuple[float, float] | None: def _depth_range(self) -> tuple[float, float] | None:
@@ -2649,21 +2630,6 @@ class StudioMainWindow(QMainWindow):
""" """
return self._domain_mask.update(frame.valid_mask) return self._domain_mask.update(frame.valid_mask)
def _replay_owns_image(self) -> bool:
"""True while the 2D panel shows the replay, not the live camera.
Without this the live renderer overwrote the replay's frame on every
packet *and* on every preview tick, so a replay running next to an active
session (or a preview) looked as if it had opened the camera instead of
the recording. A recompute pass is the pipeline drawing again, so it owns
the panel while it runs.
"""
return (
self._view_preset == "replay"
and self.replay.has_video()
and not self._recompute_running
)
def _render_run_image(self, packet) -> None: def _render_run_image(self, packet) -> None:
if packet is None or self._replay_owns_image(): if packet is None or self._replay_owns_image():
return return
@@ -2702,7 +2668,8 @@ class StudioMainWindow(QMainWindow):
self.arrow_panel.set_plan(plan) self.arrow_panel.set_plan(plan)
mode = plan.resolve(self.arrow_panel.view_mode()) mode = plan.resolve(self.arrow_panel.view_mode())
self.arrow_panel.set_view_mode(mode) self.arrow_panel.set_view_mode(mode)
self._last_rendered_view_mode = mode if self.acquire_presenter is not None:
self.acquire_presenter.note_rendered_mode(mode)
draw_style = self._draw_style() draw_style = self._draw_style()
# The legend is decided by the same inputs as the picture: the arrow # The legend is decided by the same inputs as the picture: the arrow
# magnification of this sensor's draw style, and whether this frame # magnification of this sensor's draw style, and whether this frame
@@ -2726,7 +2693,9 @@ class StudioMainWindow(QMainWindow):
depth_range=self._depth_range(), depth_range=self._depth_range(),
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")), canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
domain_mask=boundary, domain_mask=boundary,
arrow_policy=self._arrow_policy(), # A frame read from a stored analysis result says which policy its *recovered*
# field can be sampled with; a live field is dense and takes the display's own.
arrow_policy=frame.arrow_policy or self._arrow_policy(),
# The 2D presentation of the finger's unrolled canvas. Display only: the # The 2D presentation of the finger's unrolled canvas. Display only: the
# frame's own flow/mask/image are handed over untouched, and the 3D pane is # frame's own flow/mask/image are handed over untouched, and the 3D pane is
# not involved at all (its frame is the design export's). # not involved at all (its frame is the design export's).
@@ -2762,19 +2731,71 @@ class StudioMainWindow(QMainWindow):
now. ``cancelled`` is the pass's cancellation event and ``on_progress`` its 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 progress callback; both are ``None`` for a caller that only wants to ask whether
a compatible result is already stored. a compatible result is already stored.
Two facts a *generated* workspace adds and a recording never has: the calibration
the operator chose (a recording names the curve in force, not the file), and
whether to store the chain's own per-frame force. Beside a recording, force is a
measurement in ``forces.csv`` and the pass stores none of it; over a bare video
there is no CSV, so the chain's recomputation is all there is and ``synthetic`` in
the session is what says so.
""" """
from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for
from orisys_app_shared.record import RecordingReader from orisys_app_shared.record import RecordingReader
session = RecordingReader(Path(recording)).session() session = RecordingReader(Path(recording)).session()
chain = chain_spec_for(session) synthetic = dict(session.get("synthetic") or {})
chain = chain_spec_for(
session, calibration=synthetic.get("calibration") or None
)
return AnalysisService( return AnalysisService(
Path(recording), Path(recording),
chain=chain, chain=chain,
cancelled=cancelled, cancelled=cancelled,
on_progress=on_progress, on_progress=on_progress,
store_force=bool(synthetic),
) )
# ------------------------------------------------------ bare video (T1 setup)
def _bare_video_workspace(self, video: Path) -> Path | None:
"""Ask how to analyse a bare video, then generate the workspace to do it in.
The three answers a video cannot carry — the profile, the calibration and the
reference frame — are asked for in one prompt whose defaults come from the catalog
(the same catalog the rail's picker lists) and from the profile's own descriptor.
Declining returns ``None`` and leaves the video playing exactly as it did before;
an input that cannot become a workspace raises
:class:`~orisys_app_shared.analysis.bare_video.BareVideoError` with its reason.
The workspace is written under :func:`paths.video_workspace_dir` — the app's own
per-user data — and never beside the video: the frame index and the session
document are the app's derived data, and the folder the operator keeps footage in
is theirs.
"""
from orisys_app_shared.analysis import bare_video
from orisys_app_shared.sensors import scan_sensor_catalog
facts = bare_video.probe(bare_video.resolve_video(video))
catalog = scan_sensor_catalog()
selected = self.sensor_picker.selected_entry()
dialog = VideoAnalysisDialog(
video=Path(video),
facts=facts,
catalog=catalog,
window=self,
initial_profile=selected.ref if selected is not None else "",
theme=self._theme,
)
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
return None
setup = dialog.choice()
entry = dialog.entry()
if setup is None or entry is None:
return None
workspace = bare_video.build_workspace(
Path(video), profile=entry, setup=setup
)
return workspace.directory
def _show_analysis_frame(self, packet) -> None: def _show_analysis_frame(self, packet) -> None:
"""Hand one stored analysis frame to the 3D pane's producer. """Hand one stored analysis frame to the 3D pane's producer.
+21 -36
View File
@@ -11,18 +11,21 @@ they do to the panel) and leaves the *dialog* to the window: opening a QDialog a
the filters to the acquisition workers is window work, and the session's filters are not a the filters to the acquisition workers is window work, and the session's filters are not a
display preference. display preference.
What it borrows from the window is named by :class:`DisplayHost` — persisting the choice, What it borrows from the window is named by
the rail, the filter settings the dialog shows, the latest measured rate, and a live :class:`~orisys_studio.shell_ports.DisplayShell` — persisting the choice, the rail,
repaint when the 2D view mode changes. the latest measured rate, and a live repaint when the 2D view mode changes. The
filter dialog stays on the window: opening a QDialog and handing filters to the
acquisition workers is not a display preference.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, Protocol from typing import Any
from PySide6.QtCore import QObject from PySide6.QtCore import QObject
from orisys_studio.shell_ports import DisplayShell
from orisys_studio.ui.plot_settings_dialog import ( from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_DEADBAND_N, DEFAULT_PLOT_DEADBAND_N,
DEFAULT_PLOT_DEADBAND_RAW, DEFAULT_PLOT_DEADBAND_RAW,
@@ -55,26 +58,8 @@ PLOT_DEADBAND_DEFAULTS = {
"deadband_raw": DEFAULT_PLOT_DEADBAND_RAW, "deadband_raw": DEFAULT_PLOT_DEADBAND_RAW,
} }
# Kept as an alias so older imports and tests that name ``DisplayHost`` keep working.
class DisplayHost(Protocol): DisplayHost = DisplayShell
"""What the display controller needs from the window.
The names are the window's own (its transitional shims included), because the
window is the only implementation today.
"""
prefs: dict
@property
def _latest_fps(self) -> float | None: ...
def _persist_prefs(self) -> None: ...
def _refresh_status_rail(self) -> None: ...
def _drop_render_marker(self) -> None: ...
def _repaint_live_frame(self) -> None: ...
class DisplayPrefsController(QObject): class DisplayPrefsController(QObject):
@@ -83,7 +68,7 @@ class DisplayPrefsController(QObject):
def __init__( def __init__(
self, self,
*, *,
host: DisplayHost, host: DisplayShell,
toolbar: Any, toolbar: Any,
charts: Any, charts: Any,
arrow_panel: Any, arrow_panel: Any,
@@ -153,7 +138,7 @@ class DisplayPrefsController(QObject):
# recording being replayed), which keeps both triplets, instead of appending # recording being replayed), which keeps both triplets, instead of appending
# new-unit values onto old ones. # new-unit values onto old ones.
self._charts.set_units(unit) self._charts.set_units(unit)
self._host._persist_prefs() self._host.persist_prefs()
def on_units_changed(self, units: str) -> None: def on_units_changed(self, units: str) -> None:
self.set_display_unit( self.set_display_unit(
@@ -169,9 +154,9 @@ class DisplayPrefsController(QObject):
self._host.prefs["image_view_mode"] = str(mode) self._host.prefs["image_view_mode"] = str(mode)
# The next packet must repaint the panel: the frame that is on screen was drawn # The next packet must repaint the panel: the frame that is on screen was drawn
# in the previous mode. # in the previous mode.
self._host._drop_render_marker() self._host.drop_render_marker()
self._host._persist_prefs() self._host.persist_prefs()
self._host._repaint_live_frame() self._host.repaint_live_frame()
# --------------------------------------------------------------------- plot # --------------------------------------------------------------------- plot
def plot_settings(self) -> dict: def plot_settings(self) -> dict:
@@ -212,7 +197,7 @@ class DisplayPrefsController(QObject):
settings = self.plot_settings() settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"])) self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings) self._apply_ranges(settings)
self._host._persist_prefs() self._host.persist_prefs()
def apply_plot_prefs_to_panel(self) -> None: def apply_plot_prefs_to_panel(self) -> None:
"""Show the stored chart range without asking anything (startup path).""" """Show the stored chart range without asking anything (startup path)."""
@@ -243,7 +228,7 @@ class DisplayPrefsController(QObject):
from orisys_studio import session_metadata from orisys_studio import session_metadata
return session_metadata.device_sample_rate( return session_metadata.device_sample_rate(
self._host._latest_fps, self._host.latest_fps,
self._host.prefs.get("measured_fps"), self._host.prefs.get("measured_fps"),
default=self._default_sample_rate_hz, default=self._default_sample_rate_hz,
) )
@@ -276,15 +261,15 @@ class DisplayPrefsController(QObject):
return return
deg = self._arrow_panel.rotate_90_ccw() deg = self._arrow_panel.rotate_90_ccw()
self._host.prefs["arrow_rotation_deg"] = deg self._host.prefs["arrow_rotation_deg"] = deg
self._host._persist_prefs() self._host.persist_prefs()
# ---------------------------------------------------------------------- fps # ---------------------------------------------------------------------- fps
def on_show_fps_toggled(self, visible: bool) -> None: def on_show_fps_toggled(self, visible: bool) -> None:
self._show_fps = bool(visible) self._show_fps = bool(visible)
self._host.prefs["show_fps"] = self._show_fps self._host.prefs["show_fps"] = self._show_fps
# FPS has one home now: the status rail. # FPS has one home now: the status rail.
self._host._refresh_status_rail() self._host.refresh_status_rail()
self._host._persist_prefs() self._host.persist_prefs()
# -------------------------------------------------------------- diagnostics # -------------------------------------------------------------- diagnostics
@property @property
@@ -308,7 +293,7 @@ class DisplayPrefsController(QObject):
self._show_diagnostics = bool(visible) self._show_diagnostics = bool(visible)
self._host.prefs["show_diagnostics"] = self._show_diagnostics self._host.prefs["show_diagnostics"] = self._show_diagnostics
self._apply_diagnostics() self._apply_diagnostics()
self._host._persist_prefs() self._host.persist_prefs()
def _apply_diagnostics(self) -> None: def _apply_diagnostics(self) -> None:
for panel in (self._arrow_panel, self._mesh_panel): for panel in (self._arrow_panel, self._mesh_panel):
@@ -329,7 +314,7 @@ class DisplayPrefsController(QObject):
""" """
self._raw_auto_zero = bool(enabled) self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host._persist_prefs() self._host.persist_prefs()
self.apply_presentation_prefs() self.apply_presentation_prefs()
def on_raw_auto_zero_toggled(self, enabled: bool) -> None: def on_raw_auto_zero_toggled(self, enabled: bool) -> None:
+267 -192
View File
@@ -13,10 +13,10 @@ and the window when the furniture changes.
What this controller drives directly: the toolbar's replay controls, the charts (the What this controller drives directly: the toolbar's replay controls, the charts (the
timeline), the 2D panel, the 3D pane's source label, and the five replay actions. What timeline), the 2D panel, the 3D pane's source label, and the five replay actions. What
it borrows from the window is named by :class:`ReplayHost` — the status line, the rail, it borrows from the window is named by :class:`~orisys_studio.shell_ports.ReplayShell`
the view switch, the display unit, the single 2D sink, and the run pipeline a recompute — acquire-gate reads, the image sink, shell notes, and analysis support. View switches
pass is. Those are window-level operations, so the window keeps them and this module and acquire start/stop are signals the window wires, so this module never calls a
states its dependence on them out loud instead of reaching into a god object. window private method by name.
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,7 +25,7 @@ import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Protocol from typing import Any
import numpy as np import numpy as np
from orisys_app_shared.acquisition.image_backend import ( from orisys_app_shared.acquisition.image_backend import (
@@ -40,9 +40,10 @@ from orisys_app_shared.acquisition.image_backend import (
ImageFrame, ImageFrame,
) )
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.force.source import AnalysisCurveSource
from orisys_app_shared.paths import recordings_dir from orisys_app_shared.paths import recordings_dir
from PySide6.QtCore import QObject, QTimer from PySide6.QtCore import QObject, QTimer, Signal
from PySide6.QtWidgets import QFileDialog from PySide6.QtWidgets import QFileDialog, QWidget
from orisys_studio.analysis_worker import AnalysisReport, AnalysisWorker from orisys_studio.analysis_worker import AnalysisReport, AnalysisWorker
from orisys_studio.replay import MODE_ANALYSIS from orisys_studio.replay import MODE_ANALYSIS
@@ -52,6 +53,7 @@ from orisys_studio.replay_analysis import (
ResultPolicyError, ResultPolicyError,
policy_divergence, policy_divergence,
) )
from orisys_studio.shell_ports import ReplayShell
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -60,72 +62,8 @@ logger = logging.getLogger(__name__)
#: recording down. #: recording down.
PLAYBACK_TICK_MS = 33 PLAYBACK_TICK_MS = 33
# Kept as an alias so older imports and tests that name ``ReplayHost`` keep working.
class ReplayHost(Protocol): ReplayHost = ReplayShell
"""What the replay controller needs from the window.
The names are the window's own (its transitional shims included), because the
window is the only implementation today. They are listed here so the boundary is
explicit and so a test can stand in for the window with a fake.
"""
#: Operator preferences (the chosen video path is remembered here).
prefs: dict
@property
def _running(self) -> bool: ...
@property
def _preview_open(self) -> bool: ...
@property
def _session_active(self) -> bool: ...
def _in_replay_view(self) -> bool: ...
def _apply_view_preset(self, preset: str) -> None: ...
def _set_display_unit(self, unit: str) -> None: ...
def _set_source_mode(self, mode: str) -> None: ...
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: ...
def _notify(self, text: str, ms: int = ...) -> None: ...
def _start_run(self) -> None: ...
def _stop_run(self, *, reason: str = ..., wait: bool = ...) -> None: ...
def _update_run_enabled(self) -> None: ...
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 @dataclass
@@ -142,11 +80,18 @@ class ReplayActions:
class ReplayController(QObject): class ReplayController(QObject):
"""The replay view's state machine and the panels it drives.""" """The replay view's state machine and the panels it drives."""
#: Ask the shell to show one of the top-level views (``live`` / ``replay`` / …).
view_requested = Signal(str)
#: Ask the shell to start an acquisition chain (the live recompute path).
acquire_requested = Signal()
#: Ask the shell to stop the acquisition chain. Argument is the stop reason.
acquire_stop_requested = Signal(str)
def __init__( def __init__(
self, self,
*, *,
engine: Any, engine: Any,
host: ReplayHost, host: ReplayShell,
toolbar: Any, toolbar: Any,
charts: Any, charts: Any,
image_panel: Any, image_panel: Any,
@@ -170,6 +115,9 @@ class ReplayController(QObject):
#: True while a recompute pass is running: it is a *run* over a recording, so #: 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. #: the acquire pipeline owns the panels and the transport stops the pass.
self._recompute_running = False self._recompute_running = False
#: Which top-level view the layout last stated. The layout pushes this via
#: :meth:`set_active_view` so this controller never asks the window by name.
self._active_view = "custom"
#: The stored results the timeline can read from, and the pass in flight. #: The stored results the timeline can read from, and the pass in flight.
self.results = ResultLibrary() self.results = ResultLibrary()
self._analysis_worker: AnalysisWorker | None = None self._analysis_worker: AnalysisWorker | None = None
@@ -184,6 +132,11 @@ class ReplayController(QObject):
#: Non-empty when this recording names no chain to analyse with: the key cannot be #: Non-empty when this recording names no chain to analyse with: the key cannot be
#: computed, so neither a cache read nor a pass is possible. #: computed, so neither a cache read nor a pass is possible.
self._analysis_no_chain = "" self._analysis_no_chain = ""
#: The generated workspace a bare video is analysed in, once the operator has
#: answered the setup prompt. It is what :meth:`recording_directory` answers with
#: for a video that is not a recording, so every later step — the stored-result
#: lookup, the pass, the charts — reads it exactly as it reads a recording.
self._video_workspace: Path | None = None
self._play_from = 0.0 self._play_from = 0.0
self._play_started_at = 0.0 self._play_started_at = 0.0
self.play_timer = QTimer(self) self.play_timer = QTimer(self)
@@ -192,6 +145,17 @@ class ReplayController(QObject):
if self._progress is not None: if self._progress is not None:
self._progress.cancel_requested.connect(self.on_analysis_cancel) self._progress.cancel_requested.connect(self.on_analysis_cancel)
def set_active_view(self, preset: str) -> None:
"""The layout states which furniture is up. Replay only reads this flag."""
self._active_view = str(preset or "custom")
def in_replay_view(self) -> bool:
return self._active_view == "replay"
def _dialog_parent(self) -> QWidget | None:
parent = self.parent()
return parent if isinstance(parent, QWidget) else None
# ------------------------------------------------------------------ wiring # ------------------------------------------------------------------ wiring
def wire(self) -> None: def wire(self) -> None:
"""Connect the engine and the charts. Called once, after construction.""" """Connect the engine and the charts. Called once, after construction."""
@@ -256,23 +220,15 @@ class ReplayController(QObject):
Without this the live renderer overwrote the replay's frame on every packet Without this the live renderer overwrote the replay's frame on every packet
*and* on every preview tick, so a replay running next to an active session (or *and* on every preview tick, so a replay running next to an active session (or
a preview) looked as if it had opened the camera instead of the recording. A a preview) looked as if it had opened the camera instead of the recording.
recompute pass is the pipeline drawing again, so it owns the panel while it Analysis results are drawn by this controller too, so an analysis pass keeps
runs. ownership — it does not hand the panel back to the acquisition tick.
""" """
return ( return self.in_replay_view() and self._engine.has_video()
self._host._in_replay_view()
and self._engine.has_video()
and not self._recompute_running
)
# ------------------------------------------------------------------ entries # ------------------------------------------------------------------ entries
def on_cursor_moved(self, t_rel_s: float) -> None: def on_cursor_moved(self, t_rel_s: float) -> None:
if self._recompute_running: if self.in_replay_view() and self._engine.has_video():
# The recompute pass owns the timeline: its samples are arriving in
# order and the cursor follows them.
return
if self._host._in_replay_view() and self._engine.has_video():
self._engine.set_position(float(t_rel_s)) self._engine.set_position(float(t_rel_s))
def on_open_recording(self) -> None: def on_open_recording(self) -> None:
@@ -284,7 +240,7 @@ class ReplayController(QObject):
""" """
start = str(recordings_dir()) start = str(recordings_dir())
chosen, _filter = QFileDialog.getOpenFileName( chosen, _filter = QFileDialog.getOpenFileName(
self._host, self._dialog_parent(),
t("playback.open"), t("playback.open"),
start, start,
t("playback.video_filter"), t("playback.video_filter"),
@@ -300,11 +256,11 @@ class ReplayController(QObject):
path as the rail's button: a recorded video is something to *play*, not a path as the rail's button: a recorded video is something to *play*, not a
second way to acquire. second way to acquire.
""" """
if self._host._running or self._host._preview_open: if self._host.running or self._host.preview_open:
return return
start = self._toolbar.video_path() or str(Path.home()) start = self._toolbar.video_path() or str(Path.home())
path, _ = QFileDialog.getOpenFileName( path, _ = QFileDialog.getOpenFileName(
self._host, self._dialog_parent(),
t("menu.open_video"), t("menu.open_video"),
start, start,
t("playback.video_filter"), t("playback.video_filter"),
@@ -337,7 +293,7 @@ class ReplayController(QObject):
""" """
if self._actions is None: if self._actions is None:
return return
allowed = self._host._in_replay_view() allowed = self.in_replay_view()
reason = "" if allowed else t("toolbar.replay_view_only") reason = "" if allowed else t("toolbar.replay_view_only")
for action in ( for action in (
self._actions.open_video, self._actions.open_video,
@@ -353,7 +309,7 @@ class ReplayController(QObject):
def can_recompute(self) -> bool: def can_recompute(self) -> bool:
"""Whether 「从视频重算」 has something to read: a recording, or a video file.""" """Whether 「从视频重算」 has something to read: a recording, or a video file."""
if not self._host._in_replay_view(): if not self.in_replay_view():
return False return False
if self._engine.has_video(): if self._engine.has_video():
return True return True
@@ -373,11 +329,11 @@ class ReplayController(QObject):
that is the only way to get *numbers* out of a video that has no recording. that is the only way to get *numbers* out of a video that has no recording.
""" """
video = Path(video) video = Path(video)
if not self._host._in_replay_view(): if not self.in_replay_view():
# Defensive: the entries are disabled outside the replay view, so this only # Defensive: the entries are disabled outside the replay view, so this only
# happens programmatically — and refusing is better than leaving the window # happens programmatically — and refusing is better than leaving the window
# with a video source and no replay renderer. # with a video source and no replay renderer.
self._host._notify(t("toolbar.replay_view_only")) self._host.notify(t("toolbar.replay_view_only"))
return False return False
folder = video.parent if video.is_file() else video folder = video.parent if video.is_file() else video
if not (folder / "forces.csv").is_file(): if not (folder / "forces.csv").is_file():
@@ -386,34 +342,41 @@ class ReplayController(QObject):
self._host.prefs["video_path"] = str(video) self._host.prefs["video_path"] = str(video)
# The window's own switch, which also remembers the choice: the file # The window's own switch, which also remembers the choice: the file
# menu's source selector and this path must leave the same state. # menu's source selector and this path must leave the same state.
self._host._set_source_mode("video") self._host.set_source_mode("video")
self.render_frame() self.render_frame()
self._host._notify(t("playback.video_only_opened", name=video.name)) self._host.notify(t("playback.video_only_opened", name=video.name))
self.refresh_state() self.refresh_state()
# A bare video used to stop here: playable, and unanalysable. It is still
# playable, and now the pass can be run over it — the two files it reads are
# generated for it in an app-owned workspace (Part A's rules unchanged).
self.prepare_bare_video(video)
return True return True
if video.is_file(): if video.is_file():
self._toolbar.set_video_path(str(video)) self._toolbar.set_video_path(str(video))
self._host.prefs["video_path"] = str(video) self._host.prefs["video_path"] = str(video)
self._host._set_source_mode("video") self._host.set_source_mode("video")
if not self._engine.has_video(): if not self._engine.has_video():
# Nothing else is on screen, so the reason belongs in the panel that # Nothing else is on screen, so the reason belongs in the panel that
# would have shown the frames — not only in a status line that expires. # would have shown the frames — not only in a status line that expires.
self._image_panel.clear_preview(t("playback.video_only_panel")) self._image_panel.clear_preview(t("playback.video_only_panel"))
self.refresh_state() self.refresh_state()
self._host._notify(t("playback.not_a_recording", name=video.name)) self._host.notify(t("playback.not_a_recording", name=video.name))
return False return False
if not self.load_recording(folder): if not self.load_recording(folder):
return False return False
self._toolbar.set_video_path(str(video)) self._toolbar.set_video_path(str(video))
self._host._refresh_status_rail() self._host.refresh_status_rail()
return True return True
def load_recording(self, directory: Path) -> bool: def load_recording(self, directory: Path) -> bool:
if not self._host._in_replay_view(): if not self.in_replay_view():
self._host._apply_view_preset("replay") self.view_requested.emit("replay")
# A recording is a recording: whatever workspace the previous bare video was
# analysed in stops being this timeline's input.
self._video_workspace = None
opened = self._engine.open_recording(Path(directory)) opened = self._engine.open_recording(Path(directory))
if not opened: if not opened:
self._host._notify(t("playback.open_failed", error=Path(directory).name)) self._host.notify(t("playback.open_failed", error=Path(directory).name))
return False return False
self.refresh_state() self.refresh_state()
# Analysis on open: serve the stored result when there is a compatible one, run # Analysis on open: serve the stored result when there is a compatible one, run
@@ -421,16 +384,66 @@ class ReplayController(QObject):
self.on_analysis_requested() self.on_analysis_requested()
return True return True
# ------------------------------------------------------------- bare video setup
def prepare_bare_video(self, video: Path) -> bool:
"""Ask how to analyse a bare video, then analyse it — in a generated workspace.
A video with no recording beside it has no ``frames.csv``, no ``session.json`` and no
``forces.csv``, and the pass reads the first two. Rather than teach the pass a second
input shape, the two files it already expects are generated for the video in an
app-owned location (``analysis.bare_video``), and everything after this point is the
*existing* path: the stored-result lookup, the pass, the charts and the 2D canvas
read the workspace exactly as they read a recording.
The prompt is the one thing a recording never needed: a video does not carry the
profile it should be processed with, the calibration or the reference frame. That
question is asked **after** the video opens, so the operator is looking at the
footage they are answering about, and declining leaves today's behaviour — the video
plays, and the panel says no result is coming.
Returns whether a workspace is ready and a result was asked for.
"""
from orisys_app_shared.analysis.bare_video import BareVideoError
self._video_workspace = None
self._clear_analysis_state()
if self.analysis_running():
self._host.notify(t("playback.analysis_busy"))
return False
try:
workspace = self._host.bare_video_workspace(Path(video))
except BareVideoError as exc:
# Refused before anything ran, with the input's own reason: an unreadable
# container, more than one file, a profile with no chain. It is state, not a
# status line, so the panel can say it for as long as it is true.
self._analysis_error = str(exc)
self._host.notify(str(exc))
self.render_frame()
self.refresh_state()
return False
self._clear_analysis_state()
if workspace is None:
# Declined. The video plays, and the panel says no pass was run — which is a
# different sentence from "no pass is possible", and the truthful one.
self._analysis_skip = t("playback.analysis_setup_declined")
self.render_frame()
self.refresh_state()
return False
self._video_workspace = Path(workspace)
self.refresh_state()
self.on_analysis_requested()
return True
def open_latest_recording(self) -> None: def open_latest_recording(self) -> None:
"""Switch to the replay view on the most recent recording.""" """Switch to the replay view on the most recent recording."""
from orisys_app_shared.record import list_recordings from orisys_app_shared.record import list_recordings
if not self._host._in_replay_view(): if not self.in_replay_view():
self._host._notify(t("toolbar.replay_view_only")) self._host.notify(t("toolbar.replay_view_only"))
return return
found = list_recordings(recordings_dir()) found = list_recordings(recordings_dir())
if not found: if not found:
self._host._notify(t("playback.none_found")) self._host.notify(t("playback.none_found"))
return return
self.load_recording(Path(found[-1])) self.load_recording(Path(found[-1]))
@@ -438,9 +451,12 @@ class ReplayController(QObject):
"""A recording was opened (or dropped): re-point everything that reads it.""" """A recording was opened (or dropped): re-point everything that reads it."""
self.refresh_state() self.refresh_state()
if not opened: if not opened:
# Nothing is open any more, so no generated workspace is this timeline's input
# either. It is keyed by the video, so reopening the same file finds it again.
self._video_workspace = None
self.pause_playback() self.pause_playback()
return return
if self._host._in_replay_view(): if self.in_replay_view():
self.apply_source(True) self.apply_source(True)
self.render_frame() self.render_frame()
@@ -465,8 +481,8 @@ class ReplayController(QObject):
if self._actions is not None: if self._actions is not None:
self._actions.recompute.setEnabled(self.can_recompute()) self._actions.recompute.setEnabled(self.can_recompute())
self.sync_actions() self.sync_actions()
self._host._update_run_enabled() self._host.update_run_enabled()
self._host._refresh_status_rail() self._host.refresh_status_rail()
# ---------------------------------------------------------------- transport # ---------------------------------------------------------------- transport
def on_replay_position(self, position_s: float) -> None: def on_replay_position(self, position_s: float) -> None:
@@ -474,21 +490,6 @@ class ReplayController(QObject):
def on_transport_toggled(self, playing: bool) -> None: def on_transport_toggled(self, playing: bool) -> None:
"""The rail's primary button in the replay view: play or pause.""" """The rail's primary button in the replay view: play or pause."""
if self.recompute_active():
# A recompute pass is a *run*: play starts the pipeline over the
# video, pause stops it. It can only go forward (the chain is
# stateful), so playing again after the end starts from the top.
if playing:
self._recompute_running = True
self._host._start_run()
elif self._recompute_running:
# The flag is this controller's, so it is cleared here as well as in
# the session stop below: a pass that ended must not keep claiming to
# be running just because the stop came from the transport.
self._recompute_running = False
self._host._stop_run(reason="user_stop")
self._toolbar.set_run_checked(bool(playing) and self._host._running)
return
if playing and self._engine.has_video(): if playing and self._engine.has_video():
self.start_playback() self.start_playback()
else: else:
@@ -500,7 +501,7 @@ class ReplayController(QObject):
# A zero-length timeline is not something to play, and silently un-checking # A zero-length timeline is not something to play, and silently un-checking
# the button is how "pressing 回放 does nothing" felt to the operator. # the button is how "pressing 回放 does nothing" felt to the operator.
self._toolbar.set_run_checked(False) self._toolbar.set_run_checked(False)
self._host._notify(t("playback.empty_recording")) self._host.notify(t("playback.empty_recording"))
return return
position = self._engine.position() position = self._engine.position()
if position >= duration - 0.02: if position >= duration - 0.02:
@@ -514,7 +515,7 @@ class ReplayController(QObject):
self._toolbar.refresh_labels() self._toolbar.refresh_labels()
# The stop button appears with the playback: one place decides what the rail # The stop button appears with the playback: one place decides what the rail
# offers, so it is told here rather than kept in step in two places. # offers, so it is told here rather than kept in step in two places.
self._host._update_run_enabled() self._host.update_run_enabled()
def pause_playback(self) -> None: def pause_playback(self) -> None:
if self.play_timer.isActive(): if self.play_timer.isActive():
@@ -522,7 +523,7 @@ class ReplayController(QObject):
if self._toolbar.btn_run.isChecked(): if self._toolbar.btn_run.isChecked():
self._toolbar.set_run_checked(False) self._toolbar.set_run_checked(False)
self._toolbar.refresh_labels() self._toolbar.refresh_labels()
self._host._update_run_enabled() self._host.update_run_enabled()
def on_play_tick(self) -> None: def on_play_tick(self) -> None:
"""Advance the playhead from the wall clock, not from the tick count.""" """Advance the playhead from the wall clock, not from the tick count."""
@@ -543,30 +544,36 @@ class ReplayController(QObject):
"""Stop whatever the replay view is doing. The operator's escape hatch. """Stop whatever the replay view is doing. The operator's escape hatch.
Two different things can be running there — plain playback of the recorded Two different things can be running there — plain playback of the recorded
timeline, and a recompute pass over the video — and the person in front of the timeline, and an analysis pass over the video — and the person in front of the
screen should not have to work out which one is stuck before they can stop it. screen should not have to work out which one is stuck before they can stop it.
So this stops both, clears a recomputed 3D picture, and says what it stopped. So this stops both, clears a derived 3D picture, and says what it stopped.
Safe to press when nothing is running (it says so rather than doing nothing Safe to press when nothing is running (it says so rather than doing nothing
silently, which is what made a stuck state feel stuck). silently, which is what made a stuck state feel stuck).
""" """
stopped: list[str] = [] stopped: list[str] = []
if self._recompute_running or self._host._session_active: if self.analysis_running():
self.cancel_analysis()
stopped.append(t("toolbar.stopped_pass"))
if self._recompute_running or self._host.session_active:
# Legacy live-recompute flag, plus any acquisition session left open.
self._recompute_running = False self._recompute_running = False
self._host._stop_run(reason="user_stop") self.acquire_stop_requested.emit("user_stop")
stopped.append(t("toolbar.stopped_pass")) stopped.append(t("toolbar.stopped_pass"))
if self.play_timer.isActive(): if self.play_timer.isActive():
self.pause_playback() self.pause_playback()
stopped.append(t("toolbar.stopped_playback")) stopped.append(t("toolbar.stopped_playback"))
self._mesh_panel.show_reference_only() self._mesh_panel.show_reference_only()
self._host._update_run_enabled() self._host.update_run_enabled()
if stopped: if stopped:
self._host._notify(t("toolbar.stopped", what=", ".join(stopped))) # Deduplicate the pass label when both analysis and a leftover session stop.
unique = list(dict.fromkeys(stopped))
self._host.notify(t("toolbar.stopped", what=", ".join(unique)))
else: else:
self._host._notify(t("toolbar.nothing_to_stop")) self._host.notify(t("toolbar.nothing_to_stop"))
# ---------------------------------------------------------------- recompute # ---------------------------------------------------------------- recompute
def video_segment(self) -> Path | None: def video_segment(self) -> Path | None:
"""The recorded video a recompute pass should read.""" """The recorded video an analysis pass should read."""
reader = self._engine.reader() reader = self._engine.reader()
segments = sorted(reader.segments()) if reader is not None else [] segments = sorted(reader.segments()) if reader is not None else []
if segments: if segments:
@@ -577,60 +584,48 @@ class ReplayController(QObject):
return None return None
def on_recompute_toggled(self, checked: bool) -> None: def on_recompute_toggled(self, checked: bool) -> None:
"""Off: read the recorded CSV. On: re-run the pipeline over the video. """On: the same analysis entry opening a recording uses. Off: cancel / browse.
The CSV is the session's own measurement. Re-running the vision chain over the Part B: 「从视频重算」 and the open path share ``on_analysis_requested`` — serve
recorded (lossy) video answers a different question — "what would this algorithm a compatible cache, or run the offline pass. The live acquisition chain over
version say about that footage" — and it can only run *forward*, because the the video is gone: analysis results already feed the 2D canvas and the 3D
chain is stateful, so it is a pass rather than a scrub. pane from one cursor (``show_analysis_packet``).
""" """
wanted = bool(checked) and self.can_recompute() wanted = bool(checked) and self.can_recompute()
if self._actions is not None and self._actions.recompute.isChecked() != wanted: if self._actions is not None and self._actions.recompute.isChecked() != wanted:
self._actions.recompute.blockSignals(True) self._actions.recompute.blockSignals(True)
self._actions.recompute.setChecked(wanted) self._actions.recompute.setChecked(wanted)
self._actions.recompute.blockSignals(False) self._actions.recompute.blockSignals(False)
self._toolbar.set_recompute_checked(wanted)
self.pause_playback() self.pause_playback()
if wanted: if wanted:
segment = self.video_segment() # A bare video may have been opened and the setup prompt declined: play-only,
if segment is None: # no workspace. 「从视频重算」 asks again — that is how numbers appear — then
self._host._notify(t("playback.no_video")) # shares ``on_analysis_requested`` with opening a recording.
if self._actions is not None: if self.recording_directory() is None:
self._actions.recompute.blockSignals(True) video = self.video_segment()
self._actions.recompute.setChecked(False) if video is None:
self._actions.recompute.blockSignals(False) named = self._toolbar.video_path()
video = Path(named) if named else None
if video is not None and Path(video).is_file() and self._engine.is_video_only():
if not self.prepare_bare_video(Path(video)):
self._set_recompute_checked(False)
return return
# The charts stop reading the CSV; the pipeline's own samples become the self._host.notify(t("playback.no_video"))
# data, and the 2D panel shows what it produces. The pass starts here so self._set_recompute_checked(False)
# the switch is not a blank chart waiting for a second click.
self._charts.clear_recording_source()
self._image_panel.set_source_kind("recompute")
self._toolbar.set_source_mode("video")
self._toolbar.set_video_path(str(segment))
self._host._refresh_status_rail()
self._recompute_running = True
# The 3D pane now says "recomputed", not "replayed": the pass is what feeds
# it, through the ordinary acquisition sink.
self._mesh_panel.set_source_kind(self._host._current_source_kind())
# Playing is what starts the pass: one path in, so the button and the menu
# cannot start it twice.
self._toolbar.set_run_checked(True)
self._toolbar.refresh_labels()
self.on_transport_toggled(True)
return return
self.on_analysis_requested()
return
if self.analysis_running():
self.cancel_analysis()
if self._recompute_running: if self._recompute_running:
# The flag is cleared here, not only by the session stop: it belongs to this
# controller, and a pass that ended must stop claiming to be running.
self._recompute_running = False self._recompute_running = False
self._host._stop_run(reason="user_stop") self.acquire_stop_requested.emit("user_stop")
# The pass is over, so the last recomputed frame must go: leaving it on
# screen would show a measurement nothing is producing any more.
self._mesh_panel.show_reference_only() self._mesh_panel.show_reference_only()
if self._host._in_replay_view(): if self.in_replay_view():
self._mesh_panel.set_source_kind(self._host._current_source_kind()) self._mesh_panel.set_source_kind(self._host.current_source_kind())
if self._engine.is_loaded(): if self._engine.is_loaded() or self._engine.is_video_only():
self.apply_source(True) self.apply_source(True)
# A bare video has no charts to point at the recording, but it does have
# frames to show again now that the pass is over.
self.render_frame() self.render_frame()
# ---------------------------------------------------------------- analysis # ---------------------------------------------------------------- analysis
@@ -704,7 +699,7 @@ class ReplayController(QObject):
policy = policy_for_settings(settings) policy = policy_for_settings(settings)
if not session.serve_policy(policy): if not session.serve_policy(policy):
moved = policy_divergence(session.stored_policy(), policy) moved = policy_divergence(session.stored_policy(), policy)
self._host._notify(t("playback.analysis_grid_mismatch", keys="·".join(moved))) self._host.notify(t("playback.analysis_grid_mismatch", keys="·".join(moved)))
self._engine.redraw() self._engine.redraw()
def on_analysis_requested(self) -> None: def on_analysis_requested(self) -> None:
@@ -724,27 +719,27 @@ class ReplayController(QObject):
it (:meth:`result_state`) instead of promising a pass that will not come. it (:meth:`result_state`) instead of promising a pass that will not come.
""" """
if self.analysis_running(): if self.analysis_running():
self._host._notify(t("playback.analysis_busy")) self._host.notify(t("playback.analysis_busy"))
return return
recording = self.recording_directory() recording = self.recording_directory()
if recording is None: if recording is None:
# No session metadata and no segment: nothing here can be analysed at all, # No session metadata and no segment: nothing here can be analysed at all,
# which is a different statement from "not analysed yet" (see ``result_state``). # which is a different statement from "not analysed yet" (see ``result_state``).
self._clear_analysis_state() self._clear_analysis_state()
self._host._notify(t("playback.analysis_no_source")) self._host.notify(t("playback.analysis_no_source"))
self._set_recompute_checked(False) self._set_recompute_checked(False)
self.render_frame() self.render_frame()
return return
self._clear_analysis_state() self._clear_analysis_state()
if self._activate_stored_result(recording, notify_reuse=True): if self._activate_stored_result(recording, notify_reuse=True):
return return
if self._host._running or self._host._preview_open or self._host._session_active: if self._host.running or self._host.preview_open or self._host.session_active:
# A pass opens an SDK chain in this process, and so does a session. One at a # A 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. The refusal # time — and the working directory stays untouched while it runs. The refusal
# is *state*, not a status line: the panel says which one it is, and # is *state*, not a status line: the panel says which one it is, and
# 「从视频重算」 starts the pass the moment the session is stopped. # 「从视频重算」 starts the pass the moment the session is stopped.
self._analysis_skip = t("analysis.blocked") self._analysis_skip = t("analysis.blocked")
self._host._notify(self._analysis_skip) self._host.notify(self._analysis_skip)
self._set_recompute_checked(False) self._set_recompute_checked(False)
self.render_frame() self.render_frame()
return return
@@ -791,10 +786,21 @@ class ReplayController(QObject):
return RESULT_NONE, "" return RESULT_NONE, ""
def recording_directory(self) -> Path | None: def recording_directory(self) -> Path | None:
"""The recording directory an analysis pass should read.""" """The directory an analysis pass should read.
Three shapes, one answer: a recording's own folder, the workspace *generated* for a
bare video (see :meth:`prepare_bare_video`), or — for a video named in the toolbar
but not yet opened — a folder that happens to hold a ``session.json``. Everything
downstream (the stored-result lookup, the chain specification, the pass) reads this
one value, which is what keeps a generated workspace from needing a second code
path.
"""
directory = self._engine.directory() directory = self._engine.directory()
if directory is not None: if directory is not None:
return Path(directory) return Path(directory)
workspace = self._video_workspace
if workspace is not None and Path(workspace).is_dir():
return Path(workspace)
named = self._toolbar.video_path() named = self._toolbar.video_path()
if named: if named:
folder = Path(named) folder = Path(named)
@@ -815,7 +821,7 @@ class ReplayController(QObject):
serve; whether a pass can then run is the caller's question. serve; whether a pass can then run is the caller's question.
""" """
try: try:
service = self._host._analysis_service(recording, None) service = self._host.analysis_service(recording, None)
except Exception as exc: # noqa: BLE001 - a recording with no chain is not fatal except Exception as exc: # noqa: BLE001 - a recording with no chain is not fatal
# The recording does not say which sensor and configuration produced it, so # The recording does not say which sensor and configuration produced it, so
# the result key cannot even be computed and no pass could be built. Recorded # the result key cannot even be computed and no pass could be built. Recorded
@@ -868,7 +874,7 @@ class ReplayController(QObject):
# The rail's own notification (the 3D pane's source line and the 2D footer say # 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 # "analysis result"), not the transient status line: this is a state, and it
# stays true for as long as the timeline is in it. # stays true for as long as the timeline is in it.
self._host._refresh_status_rail() self._host.refresh_status_rail()
if self._progress is not None: if self._progress is not None:
self._progress.reused(frames=frames) self._progress.reused(frames=frames)
return True return True
@@ -887,7 +893,7 @@ class ReplayController(QObject):
""" """
self.results.open_session(session) self.results.open_session(session)
self._engine.use_recording() self._engine.use_recording()
self._charts.set_recording_source(self._engine.reader()) self._serve_curves(session)
# The timeline is the recording's, so the panel is told "replay" — the same word # The timeline is the recording's, so the panel is told "replay" — the same word
# ``apply_source`` uses for an open recording. It used to be told "analysis", # ``apply_source`` uses for an open recording. It used to be told "analysis",
# which is not a 2D source kind at all: ``ArrowPanel`` knows live/recompute/replay # which is not a 2D source kind at all: ``ArrowPanel`` knows live/recompute/replay
@@ -941,7 +947,7 @@ class ReplayController(QObject):
def _start_pass(self, recording: Path) -> None: def _start_pass(self, recording: Path) -> None:
"""Run the analysis pass on a worker thread, with progress and a cancel.""" """Run the analysis pass on a worker thread, with progress and a cancel."""
worker = AnalysisWorker( worker = AnalysisWorker(
lambda event, on_progress: self._host._analysis_service( lambda event, on_progress: self._host.analysis_service(
recording, event, on_progress=on_progress recording, event, on_progress=on_progress
), ),
parent=self, parent=self,
@@ -1021,7 +1027,7 @@ class ReplayController(QObject):
) )
# The rail states what the timeline is reading from (the source label), so a # 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. # completed pass says so where the state lives rather than in a line that expires.
self._host._refresh_status_rail() self._host.refresh_status_rail()
self.refresh_state() self.refresh_state()
def on_analysis_cancel(self) -> None: def on_analysis_cancel(self) -> None:
@@ -1146,8 +1152,17 @@ class ReplayController(QObject):
# The result this frame came from is the thing that makes arrows and depth # The result this frame came from is the thing that makes arrows and depth
# drawable at all; the plan reads it to decide what to offer. # drawable at all; the plan reads it to decide what to offer.
result_available=True, result_available=True,
# ...and the field it carries is *recovered*: finite only at the candidate
# cells the pass recorded, so it can only be re-selected on the recorded grid.
# The display's own policy is the caller's for a live field, and wrong here.
arrow_policy=self._session_policy(),
) )
def _session_policy(self):
"""The sample policy of the result behind the cursor, or ``None`` without one."""
session = self.available_result()
return None if session is None else session.paint_policy()
def render_frame(self) -> None: def render_frame(self) -> None:
"""Draw what the 2D panel should show for the current replay cursor. """Draw what the 2D panel should show for the current replay cursor.
@@ -1171,7 +1186,7 @@ class ReplayController(QObject):
Nothing here writes anything: a display change reaches this method through the Nothing here writes anything: a display change reaches this method through the
engine's ``redraw()`` and rebuilds the picture from the same stored frame. 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(): if not self.in_replay_view() or not self._engine.has_video():
return return
if self._recompute_running: if self._recompute_running:
# The pipeline is drawing its own frames (see ``owns_image``). # The pipeline is drawing its own frames (see ``owns_image``).
@@ -1194,7 +1209,7 @@ class ReplayController(QObject):
else: else:
frame = None frame = None
if frame is not None: if frame is not None:
self._host._show_image_frame( self._host.show_frame(
self._analysis_image_frame( self._analysis_image_frame(
frame, frame,
source=SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY, source=SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY,
@@ -1202,7 +1217,7 @@ class ReplayController(QObject):
) )
# The same frame, in the shape the 3D pane's producer consumes: one # The same frame, in the shape the 3D pane's producer consumes: one
# cursor, two figures, one frame id. # cursor, two figures, one frame id.
self._host._show_analysis_frame(session.packet(frame)) self._host.show_analysis_packet(session.packet(frame))
return return
image = self._engine.video_frame_bgr() image = self._engine.video_frame_bgr()
if image is None: if image is None:
@@ -1213,7 +1228,7 @@ class ReplayController(QObject):
# that is coming, one that failed or was refused (with its reason), and a frame # that is coming, one that failed or was refused (with its reason), and a frame
# no pass can serve are not the same news as "nothing has run yet". # no pass can serve are not the same news as "nothing has run yet".
state, reason = self.result_state() state, reason = self.result_state()
self._host._show_image_frame( self._host.show_frame(
ImageFrame( ImageFrame(
image, image,
source=SOURCE_REPLAY, source=SOURCE_REPLAY,
@@ -1239,22 +1254,82 @@ class ReplayController(QObject):
wanted = "N" if self._charts.unit() == "N" else "raw" wanted = "N" if self._charts.unit() == "N" else "raw"
unit = self.unit_for(reader) unit = self.unit_for(reader)
self._engine.set_unit(unit) self._engine.set_unit(unit)
self._host._set_display_unit(unit) self._host.set_display_unit(unit)
if unit != wanted: if unit != wanted:
self._host._notify(t("status.units_raw_fallback")) self._host.notify(t("status.units_raw_fallback"))
self._charts.show_all() self._charts.show_all()
self._image_panel.set_source_kind("replay") self._image_panel.set_source_kind("replay")
return return
if active and self._engine.is_video_only():
# A bare video has no ``forces.csv`` at all. The only force there can be is the
# analysis pass's own recomputation, stored inside the result — so the charts
# read that, or state which thing has to happen before they can.
self._serve_curves()
self._image_panel.set_source_kind("replay")
return
if self._charts.is_recording_source(): if self._charts.is_recording_source():
self._charts.clear_recording_source() self._charts.clear_recording_source()
self._charts.follow_latest() self._charts.follow_latest()
self._image_panel.set_source_kind("live") self._image_panel.set_source_kind("live")
# The panel held a recorded frame: the next live packet must repaint it, so the # The panel held a recorded frame: the next live packet must repaint it, so the
# "already rendered" marker is dropped. # "already rendered" marker is dropped.
self._host._drop_render_marker() self._host.drop_render_marker()
if not self._host._running and not self._host._preview_open: if not self._host.running and not self._host.preview_open:
self._image_panel.clear_preview(t("panels.arrow_empty")) self._image_panel.clear_preview(t("panels.arrow_empty"))
def _serve_curves(self, session: AnalysisSession | None = None) -> str:
"""Point the charts at the force trace this timeline really has, and label it.
Two sources are possible and they are **different measurements of the same
footage**, so the panel gets a different name for each rather than one "replay":
* ``recorded`` — a recording's own ``forces.csv``, written while the device was in
the loop. This is the measurement.
* ``recomputed`` — the analysis result's per-frame force, which the *pass* produced
by re-running the vision chain over the video (``provenance.sources["forces"]``).
A bare video has nothing else, and it must never be read as the first.
When there is neither, the panel is told *why* instead of being left empty under
wording that implies data is coming: nothing has been analysed yet, or the profile's
chain produced no force for this footage at all (a finger chain answers with ``NaN``
for frames it cannot calibrate). Returns which of the three happened.
"""
reader = self._engine.reader()
if reader is not None and (Path(reader.directory) / "forces.csv").is_file():
self._charts.set_recording_source(reader)
return "recorded"
if session is None:
session = self.available_result()
if session is None:
self._charts.set_curve_source(
None, reason=t("chart.trace_not_analysed")
)
return "none"
source = AnalysisCurveSource(session.reader)
first, last = source.extent()
if last > first:
unit = self._video_unit(source)
source.unit = unit
self._engine.set_unit(unit)
self._host.set_display_unit(unit)
self._charts.set_curve_source(source)
self._charts.show_all()
return "recomputed"
self._charts.set_curve_source(None, reason=t("chart.trace_no_force"))
return "none"
def _video_unit(self, source: AnalysisCurveSource) -> str:
"""The unit a recomputed trace can be shown in: the operator's, when it has one.
The same rule :meth:`unit_for` applies to a recording — newton only when there is
something to show in newtons — over the result's own force instead of a CSV.
"""
wanted = "N" if self._charts.unit() == "N" else "raw"
if wanted == "N" and not source.unit_available("N"):
self._host.notify(t("status.units_raw_fallback"))
return "raw"
return wanted
def unit_for(self, reader: Any) -> str: def unit_for(self, reader: Any) -> str:
"""The unit a recording can be shown in: raw unless it has newtons. """The unit a recording can be shown in: raw unless it has newtons.
@@ -0,0 +1,119 @@
"""Named ports the shell controllers use instead of window private methods.
Views and data sources are different things: a recompute pass can be active while
the operator is looking at the replay furniture, and the picture then comes from
the acquisition session. These ports are named for that responsibility, not for
the three product views.
* :class:`AcquireGate` — whether a chain can start, and (for non-signal callers)
how to start or stop one.
* :class:`ImageSink` — the single place a frame reaches the 2D panel / 3D producer.
* :class:`ShellNotes` — notices, the status rail, preferences, and source/unit
bookkeeping that every controller needs.
* :class:`AnalysisSupport` — building an analysis pass and a bare-video workspace.
Replay emits ``view_requested`` / ``acquire_requested`` / ``acquire_stop_requested``
so the window wires those acts; the ports below are what it *reads* and what it
hands a finished picture to.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Protocol
from orisys_app_shared.acquisition.image_backend import ImageFrame
class AcquireGate(Protocol):
"""Whether a processing chain may open in this process."""
@property
def running(self) -> bool: ...
@property
def preview_open(self) -> bool: ...
@property
def session_active(self) -> bool: ...
def start_acquire(self) -> None: ...
def stop_acquire(self, *, reason: str = "user_stop", wait: bool = False) -> None: ...
class ImageSink(Protocol):
"""The one exit that paints a frame onto the shared panels."""
def show_frame(self, frame: ImageFrame) -> None: ...
def show_analysis_packet(self, packet: Any) -> None: ...
def drop_render_marker(self) -> None: ...
class ShellNotes(Protocol):
"""Notices, the rail, preferences, and source/unit bookkeeping."""
prefs: dict
def notify(self, text: str, ms: int = 0) -> None: ...
def refresh_status_rail(self) -> None: ...
def persist_prefs(self) -> None: ...
def update_run_enabled(self) -> None: ...
def set_source_mode(self, mode: str) -> None: ...
def set_display_unit(self, unit: str) -> None: ...
def current_source_kind(self) -> str: ...
class AnalysisSupport(Protocol):
"""How a replay builds an analysis pass or a bare-video workspace."""
def analysis_service(
self, recording: Path, cancelled: Any = None, *, on_progress: Any = None
) -> Any: ...
def bare_video_workspace(self, video: Path) -> Path | None: ...
class LayoutShell(Protocol):
"""What the layout controller needs from the window."""
prefs: dict
def persist_prefs(self) -> None: ...
def update_run_enabled(self) -> None: ...
def refresh_status_rail(self) -> None: ...
def current_source_kind(self) -> str: ...
def device_geometry_provider(self) -> Any: ...
class DisplayShell(Protocol):
"""What the display-preferences controller needs from the window."""
prefs: dict
@property
def latest_fps(self) -> float | None: ...
def persist_prefs(self) -> None: ...
def refresh_status_rail(self) -> None: ...
def drop_render_marker(self) -> None: ...
def repaint_live_frame(self) -> None: ...
class ReplayShell(AcquireGate, ImageSink, ShellNotes, AnalysisSupport):
"""Combined host a replay controller talks to (usually the main window)."""
@@ -18,6 +18,11 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import (
SHAPE_COMPATIBILITY,
SHAPE_DESCRIPTOR,
SHAPE_UNREADABLE,
)
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QFrame, QFrame,
@@ -29,6 +34,14 @@ from PySide6.QtWidgets import (
from orisys_studio.ui.fit_text import ShrinkableLabel from orisys_studio.ui.fit_text import ShrinkableLabel
#: How the catalog's shape identifiers read here. One literal key per kind, so the locale scan
#: sees them and a new kind is a visible fallback rather than a printed identifier.
_SHAPE_KEYS: dict[str, str] = {
SHAPE_DESCRIPTOR: "sensor.catalog_shape_descriptor",
SHAPE_COMPATIBILITY: "sensor.catalog_shape_compatibility",
SHAPE_UNREADABLE: "sensor.catalog_shape_unreadable",
}
class SensorDialog(QDialog): class SensorDialog(QDialog):
"""Sensor type / config picker plus the calibration facts that follow it.""" """Sensor type / config picker plus the calibration facts that follow it."""
@@ -190,8 +203,10 @@ class SensorDialog(QDialog):
def _refresh_profile(self) -> None: def _refresh_profile(self) -> None:
"""State the selected catalog entry, and the reason when it cannot be started. """State the selected catalog entry, and the reason when it cannot be started.
Read from the picker rather than re-derived here, so the window and the dialog can never This is the surface that keeps the facts the picker row no longer carries: the identity
say two different things about one bundle. the SDK assigned, how the bundle was read and its validation. It is read from the picker
rather than re-derived here, so the window and the dialog can never say two different
things about one bundle.
""" """
read_entry = getattr(self.picker, "selected_entry", None) read_entry = getattr(self.picker, "selected_entry", None)
entry = read_entry() if callable(read_entry) else None entry = read_entry() if callable(read_entry) else None
@@ -201,8 +216,8 @@ class SensorDialog(QDialog):
return return
line = t( line = t(
"sensor.catalog_profile", "sensor.catalog_profile",
family=entry.product_family or t("sensor.family_unidentified"),
profile_id=entry.profile_id or entry.folder, profile_id=entry.profile_id or entry.folder,
shape=t(_SHAPE_KEYS.get(entry.shape, _SHAPE_KEYS[SHAPE_UNREADABLE])),
origin=entry.origin, origin=entry.origin,
state=entry.state, state=entry.state,
) )
@@ -9,9 +9,10 @@ own labels and tooltips instead of a bare "配置" combo.
Both combos are filled from the configuration catalog Both combos are filled from the configuration catalog
(:mod:`orisys_app_shared.sensors`), not from a hard-coded product list: the type (:mod:`orisys_app_shared.sensors`), not from a hard-coded product list: the type
combo offers the families bundles actually exist for, and the config combo offers combo offers the families bundles actually exist for, and the config combo offers
the catalog's entries for the chosen family — each labelled with its profile, the catalog's entries for the chosen family — each labelled with what it is, where
origin and the SDK's own verdict. An entry the SDK rejects stays listed, because it came from and what the calibration selection gives, so the choice is between
that is how its reason reaches the operator, and products and outcomes rather than between ``profile_id`` strings. An entry the SDK
rejects stays listed, because that is how its reason reaches the operator, and
:meth:`SensorPickerWidget.start_blocked_reason` states why the current selection :meth:`SensorPickerWidget.start_blocked_reason` states why the current selection
must not be started (see also :meth:`selected_entry`). must not be started (see also :meth:`selected_entry`).
""" """
@@ -238,10 +239,11 @@ def _family_label(family: str) -> str:
def _entry_label(entry: CatalogEntry) -> str: def _entry_label(entry: CatalogEntry) -> str:
"""One combo item: the catalog's label with its family group named in this locale. """One combo item: the catalog's operator label, with nothing internal in it.
The catalog's own label is the data form (and is what a log or a test should compare); the The catalog names an entry by its display name, so the internal marker for "no product
internal marker for "no product family" is replaced here, where translating belongs, so an family" cannot reach the row today; it is still substituted here because a sentinel is
operator never reads a sentinel. never something an operator should read, and the item text is the one place it could
surface if an entry ever had to fall back to its family.
""" """
return entry.label().replace(FAMILY_UNIDENTIFIED, t("sensor.family_unidentified")) return entry.label().replace(FAMILY_UNIDENTIFIED, t("sensor.family_unidentified"))
+29 -35
View File
@@ -11,42 +11,27 @@ What the controller owns: the active preset (``view_preset``), whether the calib
panel is shown (``show_cal``) and whether the 3D pane is on (``show_finger_3d``). Its panel is shown (``show_cal``) and whether the 3D pane is on (``show_finger_3d``). Its
initial values come from the preferences, so the window does not keep a second copy. initial values come from the preferences, so the window does not keep a second copy.
What it borrows from the window is named by :class:`LayoutHost` — persisting the choice, What it borrows from the window is named by :class:`~orisys_studio.shell_ports.LayoutShell`
the run button's state, the rail, the arrow rotation that follows the sensor, and the — persisting the choice, the run button's state, the rail, and the source kind the 3D
source kind the 3D pane should claim. It drives the panels and the focus of the session's pane should claim. It drives the panels directly, and it tells the replay controller
view directly, and it tells the replay controller that the view changed, because leaving which view is active, because leaving the replay view is what hands the charts back to
the replay view is what hands the charts back to the live session. the live session.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Protocol from typing import Any
from PySide6.QtCore import QObject from PySide6.QtCore import QObject
from orisys_studio.shell_ports import LayoutShell
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Kept as an alias so older imports and tests that name ``LayoutHost`` keep working.
class LayoutHost(Protocol): LayoutHost = LayoutShell
"""What the layout controller needs from the window.
The names are the window's own (its transitional shims included), because the
window is the only implementation today.
"""
prefs: dict
def _persist_prefs(self) -> None: ...
def _update_run_enabled(self) -> None: ...
def _refresh_status_rail(self) -> None: ...
def _current_source_kind(self) -> str: ...
def _device_geometry_provider(self): ...
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -64,7 +49,7 @@ class ViewLayoutController(QObject):
def __init__( def __init__(
self, self,
*, *,
host: LayoutHost, host: LayoutShell,
splitter: Any, splitter: Any,
mesh_panel: Any, mesh_panel: Any,
image_panel: Any, image_panel: Any,
@@ -198,7 +183,7 @@ class ViewLayoutController(QObject):
self._mesh_panel.setVisible(show_3d) self._mesh_panel.setVisible(show_3d)
# Where the field comes from: a live view acquires it, a replay has none # Where the field comes from: a live view acquires it, a replay has none
# stored and only a recompute pass produces one. # stored and only a recompute pass produces one.
self._mesh_panel.set_source_kind(self._host._current_source_kind()) self._mesh_panel.set_source_kind(self._host.current_source_kind())
self._image_panel.setVisible(show_arrow) self._image_panel.setVisible(show_arrow)
self._charts.setVisible(True) self._charts.setVisible(True)
self._cal_panel.setVisible(show_cal) self._cal_panel.setVisible(show_cal)
@@ -286,6 +271,12 @@ class ViewLayoutController(QObject):
self.apply_panel_visibility() self.apply_panel_visibility()
if apply_sizes and preset in self._splitter_sizes: if apply_sizes and preset in self._splitter_sizes:
self._splitter.setSizes(self.splitter_sizes_for(preset)) self._splitter.setSizes(self.splitter_sizes_for(preset))
# Replay asks "am I in the replay view?" without reaching back into the window.
# Stated *before* apply_source / on_recompute_toggled: turning recompute off
# while still marked as replay would re-attach the recording to the charts.
set_view = getattr(self._replay, "set_active_view", None)
if callable(set_view):
set_view(preset)
if preset == "replay": if preset == "replay":
# History browsing is the point of this view: the charts read the open # History browsing is the point of this view: the charts read the open
# recording, and the chart cursor is its timeline. Without a recording the # recording, and the chart cursor is its timeline. Without a recording the
@@ -300,10 +291,10 @@ class ViewLayoutController(QObject):
self._brand_bar.set_active_view(preset if preset != "custom" else None) self._brand_bar.set_active_view(preset if preset != "custom" else None)
self.sync_view_checks() self.sync_view_checks()
self._replay.sync_actions() self._replay.sync_actions()
self._host._update_run_enabled() self._host.update_run_enabled()
self._host._refresh_status_rail() self._host.refresh_status_rail()
if persist: if persist:
self._host._persist_prefs() self._host.persist_prefs()
def on_live_requested(self) -> None: def on_live_requested(self) -> None:
self.apply_preset("live") self.apply_preset("live")
@@ -330,8 +321,11 @@ class ViewLayoutController(QObject):
self._host.prefs["view_preset"] = "live" self._host.prefs["view_preset"] = "live"
self._host.prefs["show_cal_panel"] = False self._host.prefs["show_cal_panel"] = False
self.apply_panel_visibility() self.apply_panel_visibility()
set_view = getattr(self._replay, "set_active_view", None)
if callable(set_view):
set_view("live")
self._brand_bar.set_active_view("live") self._brand_bar.set_active_view("live")
self._host._persist_prefs() self._host.persist_prefs()
def on_finger_3d_toggled(self, visible: bool) -> None: def on_finger_3d_toggled(self, visible: bool) -> None:
if self._picker.sensor_type() != "finger": if self._picker.sensor_type() != "finger":
@@ -339,7 +333,7 @@ class ViewLayoutController(QObject):
self._show_finger_3d = bool(visible) self._show_finger_3d = bool(visible)
self._host.prefs["show_finger_3d"] = self._show_finger_3d self._host.prefs["show_finger_3d"] = self._show_finger_3d
self.apply_panel_visibility() self.apply_panel_visibility()
self._host._persist_prefs() self._host.persist_prefs()
def on_3d_pane_toggled(self, enabled: bool) -> None: def on_3d_pane_toggled(self, enabled: bool) -> None:
"""The pane's own switch. Same state as the View menu entry, one owner. """The pane's own switch. Same state as the View menu entry, one owner.
@@ -353,14 +347,14 @@ class ViewLayoutController(QObject):
self._show_finger_3d = bool(enabled) self._show_finger_3d = bool(enabled)
self._host.prefs["show_finger_3d"] = self._show_finger_3d self._host.prefs["show_finger_3d"] = self._show_finger_3d
self.apply_panel_visibility() self.apply_panel_visibility()
self._host._persist_prefs() self._host.persist_prefs()
def set_settings_expanded(self, expanded: bool, *, persist: bool = True) -> None: def set_settings_expanded(self, expanded: bool, *, persist: bool = True) -> None:
self._toolbar.set_settings_expanded(bool(expanded)) self._toolbar.set_settings_expanded(bool(expanded))
self._host.prefs["settings_expanded"] = bool(expanded) self._host.prefs["settings_expanded"] = bool(expanded)
self.sync_settings_action() self.sync_settings_action()
if persist: if persist:
self._host._persist_prefs() self._host.persist_prefs()
def on_settings_toggled(self, expanded: bool) -> None: def on_settings_toggled(self, expanded: bool) -> None:
self.set_settings_expanded(expanded) self.set_settings_expanded(expanded)
@@ -387,7 +381,7 @@ class ViewLayoutController(QObject):
self._mesh_panel.set_keys(resolve_scene_keys(self._host.prefs)) self._mesh_panel.set_keys(resolve_scene_keys(self._host.prefs))
self._mesh_panel.set_sensor( self._mesh_panel.set_sensor(
self._picker.sensor_type(), self._picker.sensor_type(),
expected_provider=self._host._device_geometry_provider(), expected_provider=self._host.device_geometry_provider(),
package_override=self._host.prefs.get("scene3d_package"), package_override=self._host.prefs.get("scene3d_package"),
) )
+22 -5
View File
@@ -123,18 +123,35 @@ class _Host:
self.rail_updates = 0 self.rail_updates = 0
self.markers = 0 self.markers = 0
self.repaints = 0 self.repaints = 0
self._latest_fps = latest_fps self._latest_fps_value = latest_fps
def _persist_prefs(self) -> None: @property
def latest_fps(self):
return self._latest_fps_value
@latest_fps.setter
def latest_fps(self, value) -> None:
self._latest_fps_value = value
# compatibility for tests that still assign ``_latest_fps``
@property
def _latest_fps(self):
return self._latest_fps_value
@_latest_fps.setter
def _latest_fps(self, value) -> None:
self._latest_fps_value = value
def persist_prefs(self) -> None:
self.persisted += 1 self.persisted += 1
def _refresh_status_rail(self) -> None: def refresh_status_rail(self) -> None:
self.rail_updates += 1 self.rail_updates += 1
def _drop_render_marker(self) -> None: def drop_render_marker(self) -> None:
self.markers += 1 self.markers += 1
def _repaint_live_frame(self) -> None: def repaint_live_frame(self) -> None:
self.repaints += 1 self.repaints += 1
+148 -36
View File
@@ -165,11 +165,12 @@ class _Host:
def __init__(self, *, view: str = "replay") -> None: def __init__(self, *, view: str = "replay") -> None:
self.prefs: dict = {} self.prefs: dict = {}
self.view = view self.view = view
self._running = False self._running_flag = False
self._preview_open = False self._preview_open_flag = False
self._session_active = False self._session_active_flag = False
self.notes: list[str] = [] self.notes: list[str] = []
self.frames: list = [] self.frames: list = []
self.analysis_packets: list = []
self.presets: list[str] = [] self.presets: list[str] = []
self.units: list[str] = [] self.units: list[str] = []
self.source_modes: list[str] = [] self.source_modes: list[str] = []
@@ -178,46 +179,105 @@ class _Host:
self.run_updates = 0 self.run_updates = 0
self.rail_updates = 0 self.rail_updates = 0
self.markers = 0 self.markers = 0
#: What the setup prompt answers for a bare video: a workspace directory, or
#: ``None`` for "the operator declined". The window builds a real dialog; a fake
#: has no dialog, so the answer is the field a test sets.
self.workspace = None
self.workspace_requests: list = []
# reads # AcquireGate reads (legacy private names kept as writeable fields for tests)
def _in_replay_view(self) -> bool: @property
return self.view == "replay" def running(self) -> bool:
return self._running_flag
def _current_source_kind(self) -> str: @running.setter
def running(self, value: bool) -> None:
self._running_flag = bool(value)
@property
def preview_open(self) -> bool:
return self._preview_open_flag
@preview_open.setter
def preview_open(self, value: bool) -> None:
self._preview_open_flag = bool(value)
@property
def session_active(self) -> bool:
return self._session_active_flag
@session_active.setter
def session_active(self, value: bool) -> None:
self._session_active_flag = bool(value)
# compatibility for tests that still assign ``_running`` / friends
@property
def _running(self) -> bool:
return self._running_flag
@_running.setter
def _running(self, value: bool) -> None:
self._running_flag = bool(value)
@property
def _preview_open(self) -> bool:
return self._preview_open_flag
@_preview_open.setter
def _preview_open(self, value: bool) -> None:
self._preview_open_flag = bool(value)
@property
def _session_active(self) -> bool:
return self._session_active_flag
@_session_active.setter
def _session_active(self, value: bool) -> None:
self._session_active_flag = bool(value)
def current_source_kind(self) -> str:
return "recompute" return "recompute"
# writes def bare_video_workspace(self, video):
def _apply_view_preset(self, preset: str) -> None: self.workspace_requests.append(video)
self.presets.append(preset) return self.workspace
self.view = preset
def _set_display_unit(self, unit: str) -> None: def analysis_service(self, recording, cancelled=None, *, on_progress=None):
raise AssertionError("the test did not provide an analysis service")
def set_display_unit(self, unit: str) -> None:
self.units.append(unit) self.units.append(unit)
def _set_source_mode(self, mode: str) -> None: def set_source_mode(self, mode: str) -> None:
self.source_modes.append(mode) self.source_modes.append(mode)
def _show_image_frame(self, frame) -> None: def show_frame(self, frame) -> None:
self.frames.append(frame) self.frames.append(frame)
def _drop_render_marker(self) -> None: def show_analysis_packet(self, packet) -> None:
self.analysis_packets.append(packet)
def drop_render_marker(self) -> None:
self.markers += 1 self.markers += 1
def _notify(self, text: str, ms: int = 0) -> None: def notify(self, text: str, ms: int = 0) -> None:
self.notes.append(str(text)) self.notes.append(str(text))
def _start_run(self) -> None: def start_acquire(self) -> None:
self.started += 1 self.started += 1
def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None: def stop_acquire(self, *, reason: str = "user_stop", wait: bool = False) -> None:
self.stopped.append(reason) self.stopped.append(reason)
def _update_run_enabled(self) -> None: def update_run_enabled(self) -> None:
self.run_updates += 1 self.run_updates += 1
def _refresh_status_rail(self) -> None: def refresh_status_rail(self) -> None:
self.rail_updates += 1 self.rail_updates += 1
def persist_prefs(self) -> None:
pass
class _Reader: class _Reader:
def __init__(self, *, newtons=None) -> None: def __init__(self, *, newtons=None) -> None:
@@ -276,6 +336,10 @@ class _Engine:
def reader(self): def reader(self):
return self._reader return self._reader
def directory(self):
"""No recording directory: a stub opens a video, not a recorded session."""
return None
def video_path(self): def video_path(self):
return self._path return self._path
@@ -309,6 +373,23 @@ class _Engine:
return self.accept return self.accept
def _wire_replay(controller, host):
"""Signals replace private start/stop/view calls on the shell."""
controller.view_requested.connect(
lambda preset: (
host.presets.append(preset),
setattr(host, "view", preset),
controller.set_active_view(preset),
)
)
controller.acquire_requested.connect(host.start_acquire)
controller.acquire_stop_requested.connect(
lambda reason: host.stop_acquire(reason=reason)
)
controller.set_active_view(host.view)
return controller
def _controller(**kwargs): def _controller(**kwargs):
"""A controller wired to fakes; returns ``(controller, host, engine, parts)``.""" """A controller wired to fakes; returns ``(controller, host, engine, parts)``."""
engine = kwargs.pop("engine", None) or _Engine() engine = kwargs.pop("engine", None) or _Engine()
@@ -317,13 +398,16 @@ def _controller(**kwargs):
charts = kwargs.pop("charts", None) or _Charts() charts = kwargs.pop("charts", None) or _Charts()
panel = kwargs.pop("image_panel", None) or _ImagePanel() panel = kwargs.pop("image_panel", None) or _ImagePanel()
mesh = kwargs.pop("mesh_panel", None) or _Mesh() mesh = kwargs.pop("mesh_panel", None) or _Mesh()
controller = ReplayController( controller = _wire_replay(
ReplayController(
engine=engine, engine=engine,
host=host, host=host,
toolbar=toolbar, toolbar=toolbar,
charts=charts, charts=charts,
image_panel=panel, image_panel=panel,
mesh_panel=mesh, mesh_panel=mesh,
),
host,
) )
actions = { actions = {
"open_video": _Action(), "open_video": _Action(),
@@ -380,20 +464,19 @@ def test_the_playhead_follows_the_wall_clock_and_stops_at_the_end(qt_app, monkey
assert not controller.is_playing(), "the transport kept running past the end" assert not controller.is_playing(), "the transport kept running past the end"
def test_the_transport_runs_a_recompute_pass_instead_of_playing(qt_app): def test_the_transport_plays_even_when_the_recompute_switch_is_on(qt_app):
"""With the recompute switch on, play starts the *pipeline*, not the cursor.""" """Analysis is a separate pass; the rail's play button always moves the cursor."""
engine = _Engine(duration=1.0, has_video=True) engine = _Engine(duration=1.0, has_video=True)
controller, host, engine, parts = _controller(engine=engine) controller, host, engine, parts = _controller(engine=engine)
parts[4]["recompute"].setChecked(True) parts[4]["recompute"].setChecked(True)
controller.on_transport_toggled(True) controller.on_transport_toggled(True)
assert controller.recompute_running assert controller.is_playing(), "play must scrub the recording"
assert host.started == 1 assert host.started == 0, "play must not open an acquisition chain"
assert not controller.is_playing(), "the recompute pass also started playback"
controller.on_transport_toggled(False) controller.on_transport_toggled(False)
assert not controller.recompute_running assert not controller.is_playing()
assert host.stopped == ["user_stop"] assert host.stopped == []
def test_the_replay_policy_owns_the_image_only_in_the_replay_view(qt_app): def test_the_replay_policy_owns_the_image_only_in_the_replay_view(qt_app):
@@ -402,12 +485,12 @@ def test_the_replay_policy_owns_the_image_only_in_the_replay_view(qt_app):
) )
assert controller.owns_image() assert controller.owns_image()
host.view = "live" controller.set_active_view("live")
assert not controller.owns_image(), "a live view does not hand the pane to a replay" assert not controller.owns_image(), "a live view does not hand the pane to a replay"
host.view = "replay" controller.set_active_view("replay")
controller.recompute_running = True # An analysis pass is drawn by this controller too, so ownership stays with replay.
assert not controller.owns_image(), "the pass is drawing, not the replay" assert controller.owns_image()
def test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view(qt_app, tmp_path): def test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view(qt_app, tmp_path):
@@ -416,16 +499,45 @@ def test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view(qt_app,
engine = _Engine() engine = _Engine()
controller, host, engine, parts = _controller(engine=engine) controller, host, engine, parts = _controller(engine=engine)
host.view = "live" controller.set_active_view("live")
assert controller.load_video(bare) is False assert controller.load_video(bare) is False
assert host.notes[-1] == t("toolbar.replay_view_only") assert host.notes[-1] == t("toolbar.replay_view_only")
assert engine.opened == [], "a video was opened from the live view" assert engine.opened == [], "a video was opened from the live view"
host.view = "replay" controller.set_active_view("replay")
assert controller.load_video(bare) is True, "a playable clip was refused" assert controller.load_video(bare) is True, "a playable clip was refused"
assert engine.opened == [("video", bare)] assert engine.opened == [("video", bare)]
assert host.source_modes[-1] == "video" assert host.source_modes[-1] == "video"
assert host.prefs["video_path"] == str(bare) assert host.prefs["video_path"] == str(bare)
# Opening a bare video asks how to analyse it — once, after it is on screen. The answer
# here is "declined", and that must leave today's behaviour intact: the video plays,
# and no workspace is claimed as this timeline's input.
assert host.workspace_requests == [bare], "the setup prompt was never asked"
assert controller.recording_directory() is None
assert controller.result_state()[1] == t("playback.analysis_setup_declined")
def test_a_bare_video_uses_the_workspace_the_prompt_answers_with(qt_app, tmp_path):
"""The generated workspace *is* the recording directory for everything downstream."""
bare = tmp_path / "clip.mp4"
bare.write_bytes(b"not really a video")
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "session.json").write_text("{}", encoding="utf-8")
host = _Host()
host.workspace = workspace
controller, host, engine, parts = _controller(host=host)
assert controller.load_video(bare) is True
assert host.workspace_requests == [bare]
assert controller.recording_directory() == workspace, (
"the pass would have read the video's own folder instead of the workspace"
)
# Dropping what is open must drop the claim with it: the workspace is keyed by the
# video, so reopening finds it, but it is not this timeline's input any more.
controller.on_recording_changed(False)
assert controller.recording_directory() is None
def test_the_charts_fall_back_to_raw_when_a_recording_has_no_newtons(qt_app): def test_the_charts_fall_back_to_raw_when_a_recording_has_no_newtons(qt_app):
@@ -449,7 +561,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(qt_app):
controller, host, engine, parts = _controller() controller, host, engine, parts = _controller()
actions = parts[4] actions = parts[4]
host.view = "live" controller.set_active_view("live")
controller.sync_actions() controller.sync_actions()
assert [ assert [
actions[name].enabled for name in ("open_video", "playback", "latest") actions[name].enabled for name in ("open_video", "playback", "latest")
@@ -459,7 +571,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(qt_app):
assert parts[0].recompute_enabled[-1] is False assert parts[0].recompute_enabled[-1] is False
assert actions["open_video"].tooltips[-1] == t("toolbar.replay_view_only") assert actions["open_video"].tooltips[-1] == t("toolbar.replay_view_only")
host.view = "replay" controller.set_active_view("replay")
engine._has_video = True engine._has_video = True
controller.sync_actions() controller.sync_actions()
assert actions["open_video"].enabled is True assert actions["open_video"].enabled is True
@@ -110,7 +110,7 @@ def test_a_shared_profile_id_is_listed_twice_and_can_be_refused(mixed_picker):
assert picker.config_combo.count() == 2 assert picker.config_combo.count() == 2
def test_a_single_valid_entry_is_listed_with_its_identity_and_not_blocked( def test_a_single_valid_entry_is_listed_with_what_it_is_and_not_blocked(
qt_app, tmp_path, monkeypatch qt_app, tmp_path, monkeypatch
): ):
def build(root: Path) -> None: def build(root: Path) -> None:
@@ -121,9 +121,41 @@ def test_a_single_valid_entry_is_listed_with_its_identity_and_not_blocked(
assert picker.type_combo.findData("mini") >= 0 assert picker.type_combo.findData("mini") >= 0
picker.set_sensor_type("mini") picker.set_sensor_type("mini")
label = picker.config_combo.itemText(0) label = picker.config_combo.itemText(0)
assert "orisys.mini.lab-r1" in label # What it is, where it came from, what it gives — and not the profile_id the operator
assert "mini" in label and "user" in label and "degraded" in label # cannot act on, nor the SDK's verdict word, which says nothing about the force.
assert label == "Mini lab · User · No calibration selected — raw force"
assert picker.start_blocked_reason() is None assert picker.start_blocked_reason() is None
entry = picker.selected_entry() entry = picker.selected_entry()
assert entry is not None and entry.profile_id == "orisys.mini.lab-r1" assert entry is not None and entry.profile_id == "orisys.mini.lab-r1"
# The identity the row dropped is one hover away.
tooltip = picker.config_combo.itemData(0, Qt.ItemDataRole.ToolTipRole)
assert "orisys.mini.lab-r1" in tooltip
assert "descriptor bundle" in tooltip
picker.deleteLater() picker.deleteLater()
def test_no_valid_row_shows_internal_identity_or_a_severity_word(mixed_picker):
"""Every row of every family, not only the one the fixture is built around."""
picker = mixed_picker
seen = 0
for family in (FAMILY_UNIDENTIFIED, "mini"):
picker.set_sensor_type(family)
for index in range(picker.config_combo.count()):
picker.config_combo.setCurrentIndex(index)
entry = picker.selected_entry()
if entry is None or not entry.valid:
continue
seen += 1
label = picker.config_combo.itemText(index)
assert entry.profile_id not in label, label
for word in ("ok", "degraded", "failed", "legacy"):
assert word not in label, f"{word!r} reached the row: {label}"
assert seen, "the fixture offered no valid entry, so nothing was checked"
def test_a_duplicate_row_names_its_twin(mixed_picker):
picker = mixed_picker
picker.set_sensor_type("mini")
first, second = (picker.config_combo.itemText(i) for i in (0, 1))
assert first == "Mini lab · User · Also provided by user/mini-b"
assert second == "Mini lab · User · Also provided by user/mini-a"
@@ -50,6 +50,17 @@ EXPECTED_SHIPPED: dict[str, tuple[str, str, str, bool, str]] = {
"mini": ("mini", "orisys.mini.legacy", "compound_eye", True, "degraded"), "mini": ("mini", "orisys.mini.legacy", "compound_eye", True, "degraded"),
} }
#: What an operator reads in the picker, one row per shipped bundle: the product, where it came
#: from, and what the force numbers will be. ``box`` is the only shipped bundle that carries a
#: ``force_calibration.json``, so it is the only row naming a calibration; the other three are
#: stated as "no calibration selected — raw force" rather than as the SDK's ``degraded``.
EXPECTED_LABELS: dict[str, str] = {
"box": "Box · Shipped · Calibration: force_calibration.json",
"finger": "Finger R01 · Shipped · No calibration selected — raw force",
"finger-mems": "Finger (MEMS) · Shipped · No calibration selected — raw force",
"mini": "Mini · Shipped · No calibration selected — raw force",
}
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def shipped(): def shipped():
@@ -91,6 +102,55 @@ def test_the_shipped_families_keep_the_picker_order(shipped):
assert shipped.families() == ["box", "mini", "finger", "finger-mems"] assert shipped.families() == ["box", "mini", "finger", "finger-mems"]
def test_every_shipped_row_states_what_it_is_where_from_and_what_it_gives(shipped):
"""The four real bundles, exactly as the picker renders them."""
for folder, expected in EXPECTED_LABELS.items():
entry = shipped.by_ref(f"shipped/{folder}")
assert entry is not None, folder
assert entry.label() == expected, folder
def test_no_shipped_row_carries_identity_severity_or_the_word_legacy(shipped):
"""The row is the first thing an operator reads; none of these belong in it.
``legacy`` means "resolved through the compatibility adapter because it has no descriptor",
which is not what the word says to a reader — and the finger bundle's ``profile_id`` is not
even that shape, so the column mixed two kinds of string.
"""
for entry in shipped.entries:
label = entry.label()
assert entry.profile_id not in label, label
for word in ("ok", "degraded", "failed", "legacy"):
assert word not in label, f"{word!r} reached the row: {label}"
def test_every_shipped_row_says_what_the_calibration_selection_is(shipped):
"""Calibration is a selection, so an absent one reads as raw force, not as a defect."""
for entry in shipped.entries:
label = entry.label()
selected = entry.selected_calibration
if selected:
assert f"Calibration: {selected}" in label, label
else:
assert "No calibration selected — raw force" in label, label
# The SDK calls an absent calibration a warning and the bundle "degraded"; the row
# says what that means for the operator instead of repeating the verdict.
assert entry.state == "degraded", entry.ref
def test_the_shipped_tooltips_keep_the_facts_the_rows_dropped(shipped):
for entry in shipped.entries:
tooltip = entry.tooltip()
assert entry.profile_id in tooltip, entry.ref
assert f"SDK validation: {entry.state}" in tooltip, entry.ref
shape = (
"descriptor bundle"
if entry.profile_id == FINGER_PROFILE_ID
else "compatibility bundle, no descriptor"
)
assert shape in tooltip, entry.ref
def test_box_mini_and_finger_mems_are_legacy_sensor_json_bundles(shipped): def test_box_mini_and_finger_mems_are_legacy_sensor_json_bundles(shipped):
for folder in ("box", "mini", "finger-mems"): for folder in ("box", "mini", "finger-mems"):
bundle = sensors_dir() / folder bundle = sensors_dir() / folder
+50 -32
View File
@@ -2003,10 +2003,10 @@ def test_the_replay_transport_plays_and_pauses(make_window, replay_recording):
assert window.replay.position() == parked, "the playhead kept moving while paused" assert window.replay.position() == parked, "the playhead kept moving while paused"
def test_the_recompute_mode_reruns_the_pipeline_over_the_video( def test_the_recompute_switch_asks_for_analysis_not_an_acquire_chain(
make_window, replay_recording make_window, replay_recording
): ):
"""Off: the recorded CSV. On: the recorded video through the vision chain.""" """「从视频重算」 shares ``on_analysis_requested`` with opening a recording."""
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
@@ -2019,27 +2019,30 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
assert window.act_recompute.isEnabled(), "the recompute switch is not offered" assert window.act_recompute.isEnabled(), "the recompute switch is not offered"
started: list = [] started: list = []
asked: list = []
def fake_run() -> None: def fake_run() -> None:
started.append(True) started.append(True)
window.session.set_running(True) window.session.set_running(True)
original = window.replay_ctl.on_analysis_requested
def spy_analysis() -> None:
asked.append(True)
return original()
window._start_run = fake_run window._start_run = fake_run
window.replay_ctl.on_analysis_requested = spy_analysis
window.act_recompute.setChecked(True) window.act_recompute.setChecked(True)
QApplication.processEvents() QApplication.processEvents()
assert started == [True], f"the pass started {len(started)} times" assert asked == [True], "the switch did not ask for analysis"
assert not panel.is_recording_source(), "the charts still read the CSV" assert started == [], "the switch must not open an acquisition chain"
assert window.toolbar.source_mode() == "video" # A recording's force measurement stays on forces.csv; analysis feeds 2D/3D.
assert window.toolbar.video_path().endswith(".mp4"), window.toolbar.video_path() assert panel.is_recording_source(), "analysis must not replace the recorded CSV"
assert window.toolbar.btn_run.isChecked(), (
"the transport does not show the pass as running"
)
window._recompute_running = True
window.act_recompute.setChecked(False) window.act_recompute.setChecked(False)
QApplication.processEvents() QApplication.processEvents()
assert panel.is_recording_source(), "turning the recompute off did not restore the CSV" assert panel.is_recording_source()
assert not window.toolbar.btn_run.isChecked(), "the transport stayed checked"
def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_recording): def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_recording):
@@ -2083,25 +2086,23 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
window.statusBar().currentMessage() window.statusBar().currentMessage()
) )
# A recompute pass: the same button stops it, without stopping the recording. # An analysis pass left over from opening the recording: Stop cancels it.
stopped: list = [] # Opening may already have finished the pass; start one so the escape hatch is real.
window._stop_run = lambda **kwargs: stopped.append(kwargs.get("reason")) window.replay_ctl.cancel_analysis()
window._recompute_running = True window.replay_ctl.wait_for_analysis()
window.replay_ctl.on_analysis_requested()
QApplication.processEvents()
if window.replay_ctl.analysis_running():
window._update_run_enabled() window._update_run_enabled()
assert window.toolbar.btn_stop.isEnabled(), "no stop offered while a pass runs" assert window.toolbar.btn_stop.isEnabled(), "no stop offered while a pass runs"
window.toolbar.btn_stop.click() window.toolbar.btn_stop.click()
QApplication.processEvents() QApplication.processEvents()
assert stopped == ["user_stop"], stopped assert not window.replay_ctl.analysis_running()
assert t("toolbar.stopped_pass") in window.statusBar().currentMessage(), ( assert t("toolbar.stopped_pass") in window.statusBar().currentMessage(), (
window.statusBar().currentMessage() window.statusBar().currentMessage()
) )
# Nothing running: it says so instead of doing nothing at all. # Nothing running: it says so instead of doing nothing at all.
# (The pass is over, so the stop button is correctly disabled — the escape hatch is
# therefore exercised the way Esc reaches it, through the controller.)
assert not window.toolbar.btn_stop.isEnabled(), (
"a stop is still offered after the pass was stopped"
)
window.replay_ctl.stop_activity() window.replay_ctl.stop_activity()
QApplication.processEvents() QApplication.processEvents()
assert t("toolbar.nothing_to_stop") in window.statusBar().currentMessage(), ( assert t("toolbar.nothing_to_stop") in window.statusBar().currentMessage(), (
@@ -2344,13 +2345,20 @@ def test_the_two_figures_are_drawn_from_one_arrow_policy(make_window, monkeypatc
window.close() window.close()
def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, record_root): def test_a_bare_video_plays_even_though_it_is_not_a_recording(
make_window, record_root, decline_video_prompt
):
"""A clip with no forces.csv beside it is still something to watch. """A clip with no forces.csv beside it is still something to watch.
An operator who points at a video expects the transport to play it. Opening the An operator who points at a video expects the transport to play it. Opening the
video's own folder as a *recording* used to "succeed" against the tolerant reader video's own folder as a *recording* used to "succeed" against the tolerant reader
and then play a zero-length timeline ("pressing 回放 does nothing"), so the fix is and then play a zero-length timeline ("pressing 回放 does nothing"), so the fix is
not to refuse the file but to play it as what it is: a video, with empty charts. not to refuse the file but to play it as what it is: a video, with empty charts.
The setup prompt is declined here (``decline_video_prompt``): this test is about the
playback, and a dismissed prompt is the answer that leaves it exactly as it was. What
accepting the prompt does instead is its own module
(``test_bare_video_analysis.py``).
""" """
import time as _time import time as _time
@@ -2401,14 +2409,16 @@ def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, recor
assert window._play_timer.isActive() assert window._play_timer.isActive()
image = window.replay.video_image() image = window.replay.video_image()
assert image is not None and not image.isNull(), "no frame was decoded" assert image is not None and not image.isNull(), "no frame was decoded"
window._stop_replay_activity() window.replay_ctl.stop_activity()
QApplication.processEvents() QApplication.processEvents()
assert not window._play_timer.isActive() assert not window._play_timer.isActive()
finally: finally:
window.close() window.close()
def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, record_root): def test_the_replay_and_the_live_paths_share_one_image_backend(
make_window, record_root, decline_video_prompt
):
"""Stage 7: one renderer for every 2D producer, and modes from the frame itself. """Stage 7: one renderer for every 2D producer, and modes from the frame itself.
A recording stores the raw camera image and nothing else, so its plan offers exactly A recording stores the raw camera image and nothing else, so its plan offers exactly
@@ -2444,7 +2454,7 @@ def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, reco
app_module.render_image = spy app_module.render_image = spy
try: try:
window._apply_view_preset("replay") window.layout_ctl.apply_preset("replay")
assert window._load_replay_video(bare) is True assert window._load_replay_video(bare) is True
window._render_replay_frame() window._render_replay_frame()
QApplication.processEvents() QApplication.processEvents()
@@ -2503,7 +2513,9 @@ def test_the_application_converts_bgr_to_qimage_in_one_place():
assert others == [], f"another BGR→QImage conversion exists: {others}" assert others == [], f"another BGR→QImage conversion exists: {others}"
def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_root): def test_the_replay_entries_exist_only_in_the_replay_view(
make_window, record_root, decline_video_prompt
):
"""Stage 7: the live view has no replay entry, and says where they live. """Stage 7: the live view has no replay entry, and says where they live.
Opening a video from the live view used to set the acquisition source to a file Opening a video from the live view used to set the acquisition source to a file
@@ -2523,7 +2535,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
try: try:
window._apply_view_preset("live") window.layout_ctl.apply_preset("live")
QApplication.processEvents() QApplication.processEvents()
for action in (window.act_open_video, window.act_playback, window.act_playback_latest): for action in (window.act_open_video, window.act_playback, window.act_playback_latest):
assert not action.isEnabled(), "a replay entry is offered in the live view" assert not action.isEnabled(), "a replay entry is offered in the live view"
@@ -2542,7 +2554,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro
# In the replay view they are offered again, and the recompute entry follows # In the replay view they are offered again, and the recompute entry follows
# whether there is anything to recompute from. # whether there is anything to recompute from.
window._apply_view_preset("replay") window.layout_ctl.apply_preset("replay")
QApplication.processEvents() QApplication.processEvents()
for action in (window.act_open_video, window.act_playback, window.act_playback_latest): for action in (window.act_open_video, window.act_playback, window.act_playback_latest):
assert action.isEnabled() assert action.isEnabled()
@@ -2552,10 +2564,16 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro
assert window.toolbar.btn_recompute.isEnabled(), ( assert window.toolbar.btn_recompute.isEnabled(), (
"a video with no recording beside it can still be recomputed" "a video with no recording beside it can still be recomputed"
) )
# Declined setup (fixture) means play-only: the switch re-asks, then clears. It
# must not open an acquisition chain over the clip.
started: list = []
window._start_run = lambda: started.append(True)
window.toolbar.btn_recompute.click() window.toolbar.btn_recompute.click()
QApplication.processEvents() QApplication.processEvents()
assert window.act_recompute.isChecked(), "the rail's recompute entry did not toggle" assert started == [], "recompute must not open an acquisition chain"
window.act_recompute.setChecked(False) assert not window.act_recompute.isChecked(), (
"declining analysis setup must leave the switch off"
)
finally: finally:
window.close() window.close()
@@ -2613,7 +2631,7 @@ def test_a_video_inside_a_recording_opens_the_recording_and_plays(
window.toolbar.btn_run.setChecked(True) window.toolbar.btn_run.setChecked(True)
QApplication.processEvents() QApplication.processEvents()
assert window._play_timer.isActive(), "pressing 回放 did not start playback" assert window._play_timer.isActive(), "pressing 回放 did not start playback"
window._pause_playback() window.replay_ctl.pause_playback()
window.close() window.close()
+8 -5
View File
@@ -146,6 +146,9 @@ class _Replay:
def sync_actions(self) -> None: def sync_actions(self) -> None:
self.synced += 1 self.synced += 1
def set_active_view(self, preset: str) -> None:
self.active_view = str(preset)
class _Host: class _Host:
def __init__(self, prefs: dict) -> None: def __init__(self, prefs: dict) -> None:
@@ -155,19 +158,19 @@ class _Host:
self.rail_updates = 0 self.rail_updates = 0
self.source_kind = "live" self.source_kind = "live"
def _persist_prefs(self) -> None: def persist_prefs(self) -> None:
self.persisted += 1 self.persisted += 1
def _update_run_enabled(self) -> None: def update_run_enabled(self) -> None:
self.run_updates += 1 self.run_updates += 1
def _refresh_status_rail(self) -> None: def refresh_status_rail(self) -> None:
self.rail_updates += 1 self.rail_updates += 1
def _current_source_kind(self) -> str: def current_source_kind(self) -> str:
return self.source_kind return self.source_kind
def _device_geometry_provider(self): def device_geometry_provider(self):
return None return None
+65 -21
View File
@@ -3220,11 +3220,57 @@ def main() -> int:
app.processEvents() app.processEvents()
other_window.brand_bar.btn_playback.click() other_window.brand_bar.btn_playback.click()
app.processEvents() app.processEvents()
# A video that is not a recording is asked *how* to analyse it before anything runs
# (the setup prompt). These checks answer it and read its defaults back; the prompt is
# modal, so a check that opens a bare video without answering would wait for a person.
from orisys_studio.ui import video_analysis_dialog as _prompt_module
from PySide6.QtWidgets import QDialog as _QDialog
_prompts: list = []
_original_exec = _prompt_module.VideoAnalysisDialog.exec
def _answer_prompt(dialog_self):
_prompts.append(dialog_self)
dialog_self.select_profile("shipped/box")
return _QDialog.DialogCode.Rejected
_prompt_module.VideoAnalysisDialog.exec = _answer_prompt
try:
check( check(
other_window._load_replay_video(bare_video) is True, other_window._load_replay_video(bare_video) is True,
"a playable video with no recording beside it was refused", "a playable video with no recording beside it was refused",
) )
app.processEvents() app.processEvents()
check(bool(_prompts), "opening a bare video did not ask how to analyse it")
if _prompts:
_prompt = _prompts[-1]
check(
_prompt.profile_combo.count() >= 1,
"the setup prompt offers no processing profile",
)
check(
"flow" in _prompt.capability_label.text(),
f"the profile row does not say what it can do: "
f"{_prompt.capability_label.text()!r}",
)
check(
_prompt.calibration_combo.currentData() == "default",
f"the calibration did not default to the profile's own: "
f"{_prompt.calibration_combo.currentData()!r}",
)
check(
_prompt.reference_spin.value() == 1,
f"the reference frame did not default to the first: "
f"{_prompt.reference_spin.value()}",
)
check(
_prompt.select_profile("shipped/box") and _prompt.choice() is not None,
"the shipped box profile cannot be set up for a video",
)
finally:
_prompt_module.VideoAnalysisDialog.exec = _original_exec
check( check(
other_window.replay.has_video() and other_window.replay.is_video_only(), other_window.replay.has_video() and other_window.replay.is_video_only(),
"the video is not playable as a video", "the video is not playable as a video",
@@ -3268,7 +3314,7 @@ def main() -> int:
other_window.act_recompute.isEnabled(), other_window.act_recompute.isEnabled(),
"a chosen video cannot be recomputed either: the operator has no way to view it", "a chosen video cannot be recomputed either: the operator has no way to view it",
) )
other_window._stop_replay_activity() other_window.replay_ctl.stop_activity()
app.processEvents() app.processEvents()
other_window.close() other_window.close()
app.processEvents() app.processEvents()
@@ -3569,41 +3615,39 @@ def main() -> int:
f"{replay_window.arrow_panel.view_modes()}", f"{replay_window.arrow_panel.view_modes()}",
) )
# The recompute mode: off (default) the charts read the recorded CSV; on, the # 「从视频重算」 shares on_analysis_requested with opening a recording — no
# recorded video is run through the pipeline instead. The run itself is not # acquisition chain. Charts keep the recorded CSV; analysis feeds 2D/3D.
# started here (that needs the SDK), only the wiring around it.
started: list = [] started: list = []
asked: list = []
replay_window._start_run = lambda: started.append(True) replay_window._start_run = lambda: started.append(True)
original_analysis = replay_window.replay_ctl.on_analysis_requested
def _spy_analysis() -> None:
asked.append(True)
return original_analysis()
replay_window.replay_ctl.on_analysis_requested = _spy_analysis
replay_window.act_recompute.setChecked(True) replay_window.act_recompute.setChecked(True)
for _ in range(3): for _ in range(3):
app.processEvents() app.processEvents()
check(asked == [True], "the recompute switch did not ask for analysis")
check(started == [], "the recompute switch opened an acquisition chain")
check( check(
started == [True], replay_plots.is_recording_source(),
"the recompute mode did not hand the video to the pipeline", "analysis must not replace the recorded CSV on the charts",
) )
check(
not replay_plots.is_recording_source(),
"the charts still read the CSV while recomputing",
)
check(
replay_window.toolbar.source_mode() == "video"
and replay_window.toolbar.video_path().endswith(".mp4"),
f"the recompute mode did not select the video: "
f"{replay_window.toolbar.source_mode()!r} / "
f"{replay_window.toolbar.video_path()!r}",
)
replay_window._recompute_running = True
replay_window.act_recompute.setChecked(False) replay_window.act_recompute.setChecked(False)
for _ in range(3): for _ in range(3):
app.processEvents() app.processEvents()
check( check(
replay_plots.is_recording_source(), replay_plots.is_recording_source(),
"turning the recompute off did not restore the recorded curves", "turning the recompute off did not keep the recorded curves",
) )
check( check(
replay_window.toolbar.source_mode() == "camera" or not replay_window._running, not replay_window._running,
"the recompute pass was left running", "an acquisition session was left running after recompute",
) )
replay_window.replay_ctl.on_analysis_requested = original_analysis
# Leaving the replay view returns the charts to the live session and hides # Leaving the replay view returns the charts to the live session and hides
# the bar: the replay stays available, it just stops being on screen. # the bar: the replay stays available, it just stops being on screen.
@@ -1,8 +1,8 @@
"""Sensor catalog helpers. """Sensor catalog helpers.
The catalog answers three questions the picker and the resolver both need: which configuration The catalog answers three questions the picker and the resolver both need: which configuration
bundles exist, what each one is (family, profile, origin and the SDK's verdict), and where a bundles exist, what each one is (name, origin, calibration selection and the SDK's verdict), and
user's own bundles go on this machine. where a user's own bundles go on this machine.
""" """
from orisys_app_shared.sensors.bundle_catalog import ( from orisys_app_shared.sensors.bundle_catalog import (
@@ -14,6 +14,9 @@ from orisys_app_shared.sensors.bundle_catalog import (
ROOT_KIND_EXPLICIT, ROOT_KIND_EXPLICIT,
ROOT_KIND_FALLBACK, ROOT_KIND_FALLBACK,
ROOT_KIND_SIBLING, ROOT_KIND_SIBLING,
SHAPE_COMPATIBILITY,
SHAPE_DESCRIPTOR,
SHAPE_UNREADABLE,
STATE_UNREADABLE, STATE_UNREADABLE,
CatalogEntry, CatalogEntry,
CatalogRoot, CatalogRoot,
@@ -33,6 +36,9 @@ __all__ = [
"ROOT_KIND_EXPLICIT", "ROOT_KIND_EXPLICIT",
"ROOT_KIND_FALLBACK", "ROOT_KIND_FALLBACK",
"ROOT_KIND_SIBLING", "ROOT_KIND_SIBLING",
"SHAPE_COMPATIBILITY",
"SHAPE_DESCRIPTOR",
"SHAPE_UNREADABLE",
"STATE_UNREADABLE", "STATE_UNREADABLE",
"CatalogEntry", "CatalogEntry",
"CatalogRoot", "CatalogRoot",
@@ -24,6 +24,13 @@ hiding it is how the same file ends up offered as a sensor config.
Two bundles that declare one ``profile_id`` are both listed and each names the other. Nothing Two bundles that declare one ``profile_id`` are both listed and each names the other. Nothing
is shadowed, and the ambiguity is available to a caller that must refuse to start. is shadowed, and the ambiguity is available to a caller that must refuse to start.
What the picker *says* about an entry is deliberately not what it is called internally. The
label names the product, whether the bundle shipped with the app or came from the operator, and
what the calibration selection gives — a named file, or raw force. The ``profile_id``, the
bundle's shape and the SDK's own ``ok``/``degraded``/``failed`` verdict live in
:meth:`CatalogEntry.tooltip`: they inform the operator who asked, instead of heading the row
with a word that only says "something optional is missing".
""" """
from __future__ import annotations from __future__ import annotations
@@ -43,7 +50,7 @@ from orisys_app_shared.paths import (
) )
if TYPE_CHECKING: # pragma: no cover - typing only if TYPE_CHECKING: # pragma: no cover - typing only
from orisys.sensors import ResolvedSensorProfile from orisys.sensors import Issue, ResolvedSensorProfile
#: Which root an entry came from. #: Which root an entry came from.
ORIGIN_SHIPPED = "shipped" ORIGIN_SHIPPED = "shipped"
@@ -70,6 +77,45 @@ KNOWN_FAMILY_ORDER: tuple[str, ...] = ("box", "mini", "finger", "finger-mems")
#: the SDK's own :attr:`ValidationReport.state` (``ok`` / ``degraded`` / ``failed``). #: the SDK's own :attr:`ValidationReport.state` (``ok`` / ``degraded`` / ``failed``).
STATE_UNREADABLE = "invalid" STATE_UNREADABLE = "invalid"
#: How each origin reads in the picker label. ``fallback`` is still the operator's own bundles:
#: which directory the writable root resolved to is a tooltip fact, not a picker distinction.
ORIGIN_LABELS: dict[str, str] = {
ORIGIN_SHIPPED: "Shipped",
ORIGIN_USER: "User",
ORIGIN_FALLBACK: "User",
}
#: How the SDK read a bundle. Stable identifiers, so a surface that has to translate the fact
#: (or branch on it) never matches on a sentence.
SHAPE_DESCRIPTOR = "descriptor"
SHAPE_COMPATIBILITY = "compatibility"
SHAPE_UNREADABLE = "unreadable"
_SHAPE_NOTES: dict[str, str] = {
SHAPE_DESCRIPTOR: "descriptor bundle",
SHAPE_COMPATIBILITY: "compatibility bundle, no descriptor",
SHAPE_UNREADABLE: "no readable bundle",
}
#: What the SDK's verdict *means*, said rather than named. The SDK's own state word is kept
#: beside it because the tooltip is where the technical facts have to survive; on its own,
#: "degraded" reads as a defect report about a healthy bundle.
_STATE_NOTES: dict[str, str] = {
"ok": "processing can start",
"degraded": "processing can start; something optional is missing",
"failed": "processing cannot start",
STATE_UNREADABLE: "no bundle could be read here",
}
#: The SDK's own issue codes whose operator-facing meaning is a capability, not the finding.
#: A code that is not listed falls back to the SDK's message, so a renamed code cannot silently
#: drop a warning.
_CAPABILITY_NOTES: dict[str, str] = {
"calibration.absent": (
"no calibration selected — calibrated force is unavailable; raw force is available"
),
}
_WRITE_PROBE_PREFIX = ".orisys-write-probe" _WRITE_PROBE_PREFIX = ".orisys-write-probe"
@@ -89,6 +135,18 @@ def _sort_key(path: Path) -> str:
return path.name.lower() return path.name.lower()
def _sentence(issue: Issue) -> str:
"""One SDK finding as a sentence an operator can read.
The code goes: ``calibration.applicability-unknown`` is a name, and the message beside it is
already the sentence. A warning whose meaning is a capability is replaced by that capability
instead of the finding, so the operator reads what the bundle will and will not give. The
file it is about stays — that is a fact, not a code.
"""
text = _CAPABILITY_NOTES.get(issue.code, issue.message)
return f"{text} [{issue.path}]" if issue.path is not None else text
@dataclass(frozen=True) @dataclass(frozen=True)
class UserRootDecision: class UserRootDecision:
"""Where the writable user bundles live, and why.""" """Where the writable user bundles live, and why."""
@@ -139,8 +197,11 @@ class CatalogEntry:
valid: bool valid: bool
state: str state: str
reason: str = "" reason: str = ""
errors: tuple[str, ...] = () #: The SDK's own findings, kept as issues rather than as rendered strings: the tooltip owes
warnings: tuple[str, ...] = () #: the operator a sentence per warning, and a code whose meaning is a capability has to be
#: recognisable to be replaced by it.
errors: tuple[Issue, ...] = ()
warnings: tuple[Issue, ...] = ()
diagnostics: tuple[str, ...] = () diagnostics: tuple[str, ...] = ()
#: ``origin/folder`` of every other bundle declaring this entry's ``profile_id``. #: ``origin/folder`` of every other bundle declaring this entry's ``profile_id``.
duplicates: tuple[str, ...] = () duplicates: tuple[str, ...] = ()
@@ -156,6 +217,60 @@ class CatalogEntry:
"""The family group this entry is listed under; never empty.""" """The family group this entry is listed under; never empty."""
return self.product_family or FAMILY_UNIDENTIFIED return self.product_family or FAMILY_UNIDENTIFIED
@property
def name(self) -> str:
"""What the operator calls this configuration: its name, family, or folder."""
return self.display_name or self.product_family or self.folder
@property
def origin_label(self) -> str:
"""``Shipped`` or ``User``: where this configuration came from."""
return ORIGIN_LABELS.get(self.origin, self.origin)
@property
def shape(self) -> str:
"""How the SDK read this bundle: :data:`SHAPE_DESCRIPTOR` and friends.
``backend_source`` is the SDK's own report of where the processing backend came from,
and only a parsed ``manifest.json`` descriptor reports ``manifest`` — so this is the
SDK's answer, not the catalog reading the folder a second time.
"""
profile = self.profile
if profile is None:
return SHAPE_UNREADABLE
if profile.backend_source == "manifest":
return SHAPE_DESCRIPTOR
return SHAPE_COMPATIBILITY
@property
def shape_note(self) -> str:
"""The bundle's shape as the sentence the tooltip shows."""
return _SHAPE_NOTES.get(self.shape, _SHAPE_NOTES[SHAPE_UNREADABLE])
@property
def selected_calibration(self) -> str:
"""The calibration the SDK resolved for this bundle, by file name; ``""`` for none.
Read from the resolution rather than from the directory: a calibration file merely
existing proves nothing, and the SDK never substitutes a different one.
"""
profile = self.profile
path = None if profile is None else profile.calibration.path
return path.name if path is not None else ""
@property
def calibration_note(self) -> str:
"""What the calibration *selection* gives: a named file, or raw force.
A calibration is a separate optional artifact chosen for a session, never a property of
the bundle, so an absent one is stated as what is not selected — not as a bundle that is
uncalibrated, and never as a defect.
"""
selected = self.selected_calibration
if selected:
return f"Calibration: {selected}"
return "No calibration selected — raw force"
@property @property
def duplicate(self) -> bool: def duplicate(self) -> bool:
return bool(self.duplicates) return bool(self.duplicates)
@@ -178,18 +293,39 @@ class CatalogEntry:
return None return None
def label(self) -> str: def label(self) -> str:
"""One line for a picker item: family, profile, origin and validity.""" """One line for a picker item: what it is, where it came from, what it will give.
return f"{self.family} · {self.profile_id or self.folder} · {self.origin} · {self.state}"
Deliberately absent: the ``profile_id`` (identity no operator can act on), the SDK's
``ok``/``degraded``/``failed`` verdict (a severity with no information in it) and the
word ``legacy`` (which means "read through the compatibility adapter", not "old").
What is left is the choice itself: the product, its origin, and the force a session
started from it will produce.
"""
name = self.name
if not self.valid:
return f"{name} · Cannot start: {self.start_blocked_reason}"
origin = self.origin_label
if self.duplicates:
return f"{name} · {origin} · Also provided by {', '.join(self.duplicates)}"
return f"{name} · {origin} · {self.calibration_note}"
def tooltip(self) -> str: def tooltip(self) -> str:
"""The facts behind the label, including why the entry is not startable.""" """The facts behind the label, including why the entry is not startable.
The technical half the picker row has no room for: the identity the SDK assigned, how
the bundle was read, its validation, and every finding as a sentence rather than as a
code — ``calibration.absent`` says nothing an operator can use, "calibrated force is
unavailable; raw force is available" says everything.
"""
state_note = _STATE_NOTES.get(self.state, "verdict not recognised")
lines = [ lines = [
f"{self.display_name} ({self.ref})", f"{self.name} ({self.ref}) — {self.shape_note}",
f"profile_id: {self.profile_id or 'unknown'}", f"profile_id: {self.profile_id or 'unknown'}",
f"product family: {self.product_family or 'unknown'}", f"product family: {self.product_family or 'unknown'}",
f"processing backend: {self.processing_backend or 'unknown'}", f"processing backend: {self.processing_backend or 'unknown'}",
f"origin: {self.origin} ({self.bundle_path})", f"origin: {self.origin} ({self.bundle_path})",
f"state: {self.state}", f"SDK validation: {self.state} ({state_note})",
f"calibration: {self.selected_calibration or 'none selected — raw force'}",
] ]
blocked = self.start_blocked_reason blocked = self.start_blocked_reason
if blocked: if blocked:
@@ -197,9 +333,11 @@ class CatalogEntry:
# The reason already quotes the errors it was built from; repeating them here would # The reason already quotes the errors it was built from; repeating them here would
# bury the one line that explains the entry. # bury the one line that explains the entry.
lines.extend( lines.extend(
f"error: {text}" for text in self.errors if text not in self.reason f"error: {_sentence(issue)}"
for issue in self.errors
if str(issue) not in self.reason
) )
lines.extend(f"warning: {text}" for text in self.warnings) lines.extend(f"warning: {_sentence(issue)}" for issue in self.warnings)
return "\n".join(lines) return "\n".join(lines)
@@ -436,7 +574,10 @@ def _resolved_entry(
) -> CatalogEntry: ) -> CatalogEntry:
"""An entry the SDK resolved, valid or rejected, with its verdict kept whole.""" """An entry the SDK resolved, valid or rejected, with its verdict kept whole."""
validation = profile.validation validation = profile.validation
errors = tuple(str(issue) for issue in validation.errors) # The issues are kept as the SDK objects, not stringified here: the tooltip owes a sentence
# per warning, and the one place the code belongs is the SDK's own reason, which is what
# "Cannot start" quotes.
errors = tuple(validation.errors)
return CatalogEntry( return CatalogEntry(
key=str(profile.sensor_json_path), key=str(profile.sensor_json_path),
folder=folder.name, folder=folder.name,
@@ -449,9 +590,9 @@ def _resolved_entry(
processing_backend=profile.processing_backend, processing_backend=profile.processing_backend,
valid=validation.usable, valid=validation.usable,
state=validation.state, state=validation.state,
reason="; ".join(errors), reason="; ".join(str(issue) for issue in errors),
errors=errors, errors=errors,
warnings=tuple(str(issue) for issue in validation.warnings), warnings=tuple(validation.warnings),
diagnostics=tuple(profile.diagnostics), diagnostics=tuple(profile.diagnostics),
profile=profile, profile=profile,
) )
@@ -7,7 +7,8 @@ descriptor bundle, a legacy ``sensor.json`` bundle, or an entry that stays liste
with the SDK's own reason. with the SDK's own reason.
The two functions below stay the picker's compatibility surface. The picker itself uses the The two functions below stay the picker's compatibility surface. The picker itself uses the
catalog object directly, so it can show each entry's family, profile, origin and validity. catalog object directly, so it can show each entry's name, origin and calibration selection,
with the identity and the SDK's verdict in the tooltip.
""" """
from __future__ import annotations from __future__ import annotations
+104 -9
View File
@@ -4,11 +4,15 @@ The catalog replaced two rules that were wrong in the same direction — a hard-
list, and "any ``*.json`` beside a ``sensor.json`` is another configuration". These tests build list, and "any ``*.json`` beside a ``sensor.json`` is another configuration". These tests build
a tree containing exactly the shapes those rules mishandled: a descriptor bundle, a legacy a tree containing exactly the shapes those rules mishandled: a descriptor bundle, a legacy
``sensor.json`` bundle, a folder holding only ``force_calibration.json``, a malformed ``sensor.json`` bundle, a folder holding only ``force_calibration.json``, a malformed
``manifest.json``, a bundle whose declared asset is missing, and two bundles sharing one ``manifest.json``, a bundle whose declared asset is missing, a bundle with a calibration
``profile_id``. selected, and two bundles sharing one ``profile_id``.
The tree is built here, in the test's own temporary directory: a committed fixture would be a The tree is built here, in the test's own temporary directory: a committed fixture would be a
second copy of the bundle format that could drift from the real one without anyone noticing. second copy of the bundle format that could drift from the real one without anyone noticing.
The labels are asserted here too, because they are the catalog's whole operator-facing
contract: what a configuration is, where it came from and what it will give — never a
``profile_id``, an ``ok``/``degraded``/``failed`` verdict, or the word ``legacy``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,6 +37,19 @@ from orisys_app_shared.sensors import (
CAMERA_OK = {"resolution_raw": [640, 480], "fps": 30, "resolution_stitch": [100, 100]} CAMERA_OK = {"resolution_raw": [640, 480], "fps": 30, "resolution_stitch": [100, 100]}
TRACKER_OK = {"algorithm": "dis"} TRACKER_OK = {"algorithm": "dis"}
#: A calibration that loads cleanly and declares the family it belongs to, so a selected
#: calibration produces no warning at all: the shape an operator should meet with no remark.
CALIBRATION = {
"schema_version": 2,
"unit": "N",
"channels": {
"normal": {"points": [[0, 0], [100, 1.0]]},
"shear_x": {"points": [[0, 0], [100, 1.0]]},
"shear_y": {"points": [[0, 0], [100, 1.0]]},
},
"meta": {"sensor_type": "mini", "sn": "FIXTURE-1"},
}
def _config( def _config(
*, declared_type: str | None = None, stitcher: bool = False *, declared_type: str | None = None, stitcher: bool = False
@@ -53,7 +70,7 @@ def _descriptor(profile_id: str, **overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = { data: dict[str, Any] = {
"schema_version": 1, "schema_version": 1,
"profile_id": profile_id, "profile_id": profile_id,
"display_name": profile_id, "display_name": "Mini Lab",
"product_family": "mini", "product_family": "mini",
"processing_backend": "single_camera", "processing_backend": "single_camera",
"sensor_json": "sensor.json", "sensor_json": "sensor.json",
@@ -93,11 +110,16 @@ def trees(tmp_path):
user / "missing-assets" / "manifest.json", user / "missing-assets" / "manifest.json",
_descriptor( _descriptor(
"orisys.finger.missing-r1", "orisys.finger.missing-r1",
display_name="Finger R01 (map missing)",
product_family="finger", product_family="finger",
processing_backend="uv_unroll", processing_backend="uv_unroll",
computational_assets={"unroll_map": "assets/not-here.npz"}, computational_assets={"unroll_map": "assets/not-here.npz"},
), ),
) )
# A descriptor bundle with a calibration selected: the valid shape.
_write(user / "mini-cal" / "sensor.json", _config())
_write(user / "mini-cal" / "manifest.json", _descriptor("orisys.mini.cal-r1"))
_write(user / "mini-cal" / "force_calibration.json", CALIBRATION)
# Two bundles declaring one profile_id. # Two bundles declaring one profile_id.
for name in ("dup-a", "dup-b"): for name in ("dup-a", "dup-b"):
_write(user / name / "sensor.json", _config()) _write(user / name / "sensor.json", _config())
@@ -113,6 +135,7 @@ def test_every_bundle_shape_is_discovered_with_its_origin(trees):
"shipped/box", "shipped/box",
"shipped/calib-only", "shipped/calib-only",
"user/mini-lab", "user/mini-lab",
"user/mini-cal",
"user/broken-manifest", "user/broken-manifest",
"user/missing-assets", "user/missing-assets",
"user/dup-a", "user/dup-a",
@@ -233,13 +256,85 @@ def test_an_entry_the_sdk_rejects_is_never_reported_as_startable(trees):
assert (entry.start_blocked_reason is None) == expected, entry.ref assert (entry.start_blocked_reason is None) == expected, entry.ref
def test_the_label_states_family_profile_origin_and_validity(trees): def test_the_label_states_what_it_is_where_it_came_from_and_what_it_gives(trees):
entry = trees.by_ref("user/missing-assets") entry = trees.by_ref("user/mini-lab")
assert entry is not None
assert entry.label() == "Mini Lab · User · No calibration selected — raw force"
def test_a_startable_label_carries_no_identity_and_no_severity_word(trees):
"""What an operator chooses between is products and outcomes, not internal names."""
for entry in trees.entries:
if not entry.valid:
continue
label = entry.label() label = entry.label()
assert "finger" in label assert entry.profile_id not in label, label
assert entry.profile_id in label for word in ("ok", "degraded", "failed", "legacy"):
assert ORIGIN_USER in label assert word not in label, f"{word!r} reached the row: {label}"
assert entry.state in label
def test_no_calibration_selected_reads_as_raw_force_not_as_a_defect(trees):
entry = trees.by_ref("user/mini-lab")
assert entry is not None
# The SDK reports the absent calibration as a warning, which is the state the row used to
# read as "degraded" — a severity word that never said "raw force" or "not selected".
assert entry.state == "degraded"
assert "No calibration selected — raw force" in entry.label()
assert entry.selected_calibration == ""
def test_a_selected_calibration_reads_as_a_selection(trees):
entry = trees.by_ref("user/mini-cal")
assert entry is not None and entry.valid, entry.reason if entry else "missing"
assert entry.state == "ok", entry.reason
assert entry.selected_calibration == "force_calibration.json"
# Named, and said as something selected — never as a property of the bundle ...
assert entry.label() == "Mini Lab · User · Calibration: force_calibration.json"
# ... and the "ok" the SDK calls that is not a word an operator needs to read.
assert "ok" not in entry.label()
def test_an_unstartable_label_says_cannot_start_with_the_sdks_own_reason(trees):
entry = trees.by_ref("user/missing-assets")
assert entry is not None and not entry.valid
assert entry.label() == f"Finger R01 (map missing) · Cannot start: {entry.reason}"
assert "unroll_map" in entry.label()
def test_a_duplicate_label_names_the_twin(trees):
first = trees.by_ref("user/dup-a")
second = trees.by_ref("user/dup-b")
assert first is not None and second is not None
assert first.label() == "Mini Lab · User · Also provided by user/dup-b"
assert second.label() == "Mini Lab · User · Also provided by user/dup-a"
def test_the_tooltip_keeps_the_identity_the_shape_the_state_and_the_findings(trees):
"""The facts stop being the first thing read; they do not stop being available."""
entry = trees.by_ref("user/mini-lab")
assert entry is not None
tooltip = entry.tooltip()
assert entry.profile_id in tooltip
assert "descriptor bundle" in tooltip
assert f"SDK validation: {entry.state}" in tooltip
assert "processing can start" in tooltip, "the verdict word is left unexplained"
assert "calibration: none selected — raw force" in tooltip
# A finding is a sentence: the code is dropped, the capability it names is not.
assert "calibration.absent" not in tooltip
assert "calibrated force is unavailable; raw force is available" in tooltip
def test_the_tooltip_of_a_legacy_bundle_says_its_shape(trees):
entry = trees.by_ref("shipped/box")
assert entry is not None
tooltip = entry.tooltip()
assert "compatibility bundle, no descriptor" in tooltip
assert entry.profile_id in tooltip
# The code the row lost is not lost here: it is rendered as what it means, and the file it
# is about is kept.
assert "descriptor.legacy" not in tooltip
assert "no manifest.json" in tooltip
assert "built-in profile table" in tooltip
def test_the_root_decision_names_the_root_in_use(): def test_the_root_decision_names_the_root_in_use():