把 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:
@@ -181,7 +181,10 @@
|
||||
"export_identity": "file identity type {sensor_type} · SN {sn} · config {config}",
|
||||
"thumb_placeholder": "Product model",
|
||||
"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}"
|
||||
},
|
||||
"panels": {
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}",
|
||||
"thumb_placeholder": "产品型号",
|
||||
"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}"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"viz_pose": "assets/visualization/world_to_cam.npy"
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
(连"原始图像"一起),派生模式那些帧一律从结果读、按窗口唯一的 2D 出口画。
|
||||
|
||||
图表、二维画布、三维面板都读这一个游标,模式由 `ReplayEngine.mode()` 回答;「从视频重算」
|
||||
因此不再是第三种来源,而是**显式重跑**这一遍的开关(第 10 节)。时间基准是**信号时间**
|
||||
(Part A 第 5 节),进度、位置、单步与跳转都说它。
|
||||
与打开录像共用 `ReplayController.on_analysis_requested()`(第 10 节),不再另开一条采集链。
|
||||
时间基准是**信号时间**(Part A 第 5 节),进度、位置、单步与跳转都说它。
|
||||
|
||||
**应用里的时间轴永远是录像的。** 引擎留在 `recording`:游标、帧 id 与时长都是录像自己的,
|
||||
结果挂在游标后面当缓存(`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`,因此"原始图像"仍在选择器里),箭头、暗点、配色、域边界与深度视图与实时/重算**同一套代码** |
|
||||
| 三维面板 | 同一帧的 `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`,四种状态
|
||||
因此由生产者写进帧里(`result_state`/`result_reason`),`ReplayController.result_state()` 是唯一
|
||||
的判断处;有结果时这一行根本不出现:
|
||||
@@ -142,18 +146,17 @@ on_progress)`(`chain_spec_for` 从录像自己的 `session.json` 取链路,
|
||||
|
||||
## 15. 已知限制
|
||||
|
||||
* 结果里没有力:力曲线始终读记录的 `forces.csv`。深度有了(第 1、11 节),但**三维面板仍只
|
||||
拿光流**:`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False` —— 接进
|
||||
三维会改变三维图形的构成,不在这一片里。
|
||||
* **box 的箭头画布在回放里是空的**(本片实测,非本次引入):box 的绘制路径吃不下只有候选格
|
||||
有限的恢复场,同一帧密流场画 24,440 px、恢复场 0 px;见 [replay-panel.md](replay-panel.md) 第 5 节。
|
||||
* **录像**的结果里没有力:力曲线读那份录像自己的 `forces.csv`。裸视频是另一回事(Part A 第 1.1
|
||||
节):pass 存下链路重算的力,曲线读它。深度有了(第 1、11 节),但**三维面板仍只拿光流**:
|
||||
`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False` —— 接进三维会改变
|
||||
三维图形的构成,不在这一片里。
|
||||
* 结果覆盖的帧是 pass 走过的那些:**结果的第一帧可能晚于录像的起点**(box 链路构造时吃掉前
|
||||
7 个解码帧,这份结果从 t=0.4162 s 开始),最后一批帧也可能早于录像的终点。游标落在这两段里
|
||||
时,派生视图按 `frame_id_at()` 的"该时刻或之前、越界夹到端点"给帧:起点之前画的是最早那一帧
|
||||
测量,终点之后画的是最后一帧。两段没有各自的提示,"不会静默重复最后一帧"在本片没有兑现。
|
||||
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。
|
||||
时,派生视图按 `frame_id_at()` 的"该时刻或之前、越界夹到端点"给帧,两段没有各自的提示。
|
||||
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。被拒的栅格**仍按
|
||||
结果自己的网格绘制**(帧携带 `arrow_policy`)。
|
||||
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析 —— 但**读结果不受这条限制**(第 10
|
||||
节):会话开着时面板照画缓存,只是新的那一遍要等会话停下、「从视频重算」再点一次。
|
||||
节):会话开着时面板照画缓存,新的那一遍要等会话停下、「从视频重算」再点一次。
|
||||
* "有没有结果"是录像的属性、不是游标的属性:`available_result()` 查的是本录像在
|
||||
`ResultLibrary` 里的那一份,第 9 节的接线之后这是常态。
|
||||
* 面板的 `failed` 把"跑挂"和"被拒跑"合成一句(原因与动作都不同:一个要修链路,一个要停会话);
|
||||
|
||||
@@ -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)).
|
||||
|
||||
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
|
||||
implemented (§Cache and export policies); the display-control separation (Part C) is not.
|
||||
plan at `1aa4543`. The cache root and the lifetime sweep are implemented; the display-control
|
||||
separation (Part C) is not.
|
||||
|
||||
## 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
|
||||
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
|
||||
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`
|
||||
metadata, and an analysis pass — `recording_directory()` needs a `session.json` and reports
|
||||
`playback.analysis_no_source`; the only route to numbers is rerunning the acquisition pipeline over
|
||||
the video (`on_recompute_toggled`), forward only.
|
||||
**A bare video can be analysed, and is asked how.** The two files the pass reads are *generated*
|
||||
for the video in an app-owned workspace and every later step is the existing recording path; the
|
||||
workspace, its frame index and the synthetic marker are Part A's (§1.1). One prompt asks what a
|
||||
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`.
|
||||
Verify: `test_ui_smoke.py::test_a_bare_video_plays_even_though_it_is_not_a_recording`;
|
||||
Source: `analysis/bare_video.py`, `ui/video_analysis_dialog.py`, `replay_controller.py`.
|
||||
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`.
|
||||
|
||||
## 2. Play and compute on the loaded video
|
||||
|
||||
Opening a recording either serves a compatible stored result or runs one pass behind a
|
||||
progress-and-cancel surface. `on_analysis_requested` asks
|
||||
`AnalysisService.stored_result()` first — a file read, the surface showing
|
||||
`analysis.reused` — and otherwise starts `AnalysisWorker`, a plain thread whose `Progress` is
|
||||
coalesced at 0.1 s, with `AnalysisProgress` showing written/expected frames and elapsed time.
|
||||
The timeline stays in `recording` mode until the pass commits: a half-written result never
|
||||
reaches the screen.
|
||||
progress-and-cancel surface. `on_analysis_requested` asks `AnalysisService.stored_result()`
|
||||
first — a file read, the surface showing `analysis.reused` — and otherwise starts
|
||||
`AnalysisWorker`, a plain thread whose `Progress` is coalesced at 0.1 s, with
|
||||
`AnalysisProgress` showing written/expected frames and elapsed time. The timeline stays in
|
||||
`recording` mode until the pass commits: a half-written result never reaches the screen.
|
||||
|
||||
The pass is sequential and forward-only: `_pass_frames` walks segments and frames in order, and the
|
||||
chain is stateful (`Sensor.reset()` clears the tracker). Cancelling sets an event; the result keeps
|
||||
its `INCOMPLETE` marker and default readers refuse it (Part A §7). Measured: 0.48–0.51
|
||||
wall-seconds per video-second (Part A §8) — ten minutes is ≈4.8 minutes.
|
||||
The pass is forward-only (`_pass_frames` walks segments and frames in order; the chain is
|
||||
stateful). Cancelling sets an event; the result keeps its `INCOMPLETE` marker and default
|
||||
readers refuse it (Part A §7). Measured: 0.48–0.51 wall-seconds per video-second (Part A §8).
|
||||
|
||||
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`,
|
||||
@@ -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`
|
||||
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
|
||||
30 fps are 1.100 s of video and 1.920 s of signal at 16.664 fps (Part A §5).
|
||||
Container time is never derived from it (Part A §5).
|
||||
|
||||
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
|
||||
ids run 20…52, so an index-based path would answer with another frame's measurement. A seek
|
||||
resolves to the stored frame at or before the position — the frame sequential playback passes —
|
||||
and re-runs no pass.
|
||||
refuses an id the result does not hold, and the evidence recording's ids run 20…52, so an
|
||||
index-based path would answer with another frame's measurement. A seek resolves to the stored
|
||||
frame at or before the position — the frame sequential playback passes — and re-runs no pass.
|
||||
|
||||
Source: `replay.py`, `replay_analysis.py`.
|
||||
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 |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| 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
|
||||
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
|
||||
(`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
|
||||
pane with no result, which nothing feeds; and a recompute pass, where the pipeline owns the
|
||||
timeline (`on_cursor_moved`).
|
||||
`forces_valid=False` and zero); and the 3D pane with no result. 「从视频重算」 shares
|
||||
`on_analysis_requested` with opening a recording — no separate acquisition chain owns the
|
||||
timeline.
|
||||
|
||||
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
|
||||
(`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`,
|
||||
`::test_a_display_change_that_moves_the_grid_is_refused_not_served`.
|
||||
|
||||
## 5. What the 2D view can show
|
||||
|
||||
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`
|
||||
(Depth map), `recorded` (Recorded image). With a result all four are offered, even on the
|
||||
recording's timeline, defaulting to `recorded`; with none, `recorded` alone plus one line naming
|
||||
*which* absence it is (`result_state`): no result yet (`panels.replay_raw_note`), a pass running
|
||||
(`panels.replay_analysis_running`), a pass failed or refused with its own reason
|
||||
(`panels.replay_analysis_failed`), or no pass possible at all — a bare video
|
||||
`result_available`): `arrows_pure`, `arrows_stitched`, `depth`, `recorded`. With a result all
|
||||
four are offered, even on the recording's timeline, defaulting to `recorded`; with none,
|
||||
`recorded` alone plus one line naming *which* absence it is (`result_state`): no result yet
|
||||
(`panels.replay_raw_note`), a pass running (`panels.replay_analysis_running`), a pass failed or
|
||||
refused with its own reason (`panels.replay_analysis_failed`), or no pass possible
|
||||
(`panels.replay_analysis_unavailable`).
|
||||
|
||||
| Mode | Drawn from |
|
||||
|---|---|
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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 (Part A §1) |
|
||||
|
||||
Measured: the depth map is exactly all zeros on 241 of 264 frames of the eight-times clip
|
||||
(32 of 33 on the box recording) and never exceeded 336 of 84,000 pixels (0.4 %). A blank depth view
|
||||
is therefore usually correct and must be explainable: `panels.depth_empty` says the frame's depth is
|
||||
all zeros, `panels.depth_unavailable` says the frame has no depth channel at all — different facts,
|
||||
neither drawing the processed image.
|
||||
|
||||
**A box replay's arrow canvas is empty** (pre-existing): the box style draws through the SDK's
|
||||
`orisys.util.draw_arrows`, while a replay hands it the recovered field, finite only at the stored
|
||||
candidate cells — one dense field paints 24,440 px, its recovered form 0 px, the shared painter
|
||||
1,486 px.
|
||||
A blank depth view is usually correct and must be explainable: `panels.depth_empty` says the
|
||||
frame's depth is all zeros, `panels.depth_unavailable` says the frame has no depth channel at
|
||||
all — different facts. Measured: the map is exactly all zeros on 32 of the box recording's 33
|
||||
frames (Part A §1 has the rest).
|
||||
**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
|
||||
*recovered* field, finite only at the stored candidate cells. Measured on one stored box frame:
|
||||
a dense field through the SDK painter paints 24,440 px, that field in its recovered form 0 px,
|
||||
the shared painter 1,486 px (1,486–1,964 px over the result's 25 frames with arrows). So the
|
||||
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
|
||||
offered and the state line names the action or the reason. The state above is what an operator
|
||||
reaches by opening a recording that has a result: the cursor stays on the recording's own timeline
|
||||
and the panel opens on a derived view, the recorded one still offered — including while the
|
||||
session that recorded it is still live, because reading a result opens no chain.
|
||||
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`,
|
||||
offered and the state line names the action. 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
|
||||
is still live, because reading a result opens no chain.
|
||||
Verify: `test_image_backend.py` (the plan and the painter routing),
|
||||
`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_empty_depth_note_is_not_the_missing_depth_note`,
|
||||
`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
|
||||
|
||||
No rendered 2D images are cached. PNGs of the arrow and depth views would cost 180–540 MB per
|
||||
ten minutes and bake display settings into pixels, destroying the property that a threshold,
|
||||
colour or idle-dot change is a read. Drawing a frame from the cached numbers costs ≈1.4 ms, the
|
||||
idle-dot pass 37 µs (owner's bench, not reproduced here). Check:
|
||||
`::test_a_display_change_is_a_read_and_writes_no_result`, `::test_a_mode_change_writes_no_analysis`.
|
||||
No rendered 2D images are cached: a PNG would bake display settings into pixels, destroying the
|
||||
property that a threshold, colour or idle-dot change is a read. Check:
|
||||
`::test_a_display_change_is_a_read_and_writes_no_result`,
|
||||
`::test_a_mode_change_writes_no_analysis`. A backward seek cannot re-derive a frame either: the
|
||||
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
|
||||
forward-only, so a backward seek would need a full re-run at 0.48–0.51 s per video-second; the
|
||||
panel scrubs stored numbers and offers that run separately.
|
||||
|
||||
Cache location. Results live under the OS-correct derived-data root,
|
||||
`%LOCALAPPDATA%\OrisysStudio\analysis-cache\` (`paths.analysis_cache_dir()`) — never the user's
|
||||
recording folder or the app's `outputs/`, which in the packaged build is the EXE's own directory
|
||||
and holds the recordings. The key is path-independent (`recording_identity`), so a copied
|
||||
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.
|
||||
Results live under `%LOCALAPPDATA%\OrisysStudio\analysis-cache\`
|
||||
(`paths.analysis_cache_dir()`) — never the user's recording folder or the app's `outputs/` (the
|
||||
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
|
||||
`<recording>/analysis` root is ignored, neither read nor deleted. A bare video's generated
|
||||
workspace lives under `…\video-workspaces\<video digest>\`. Implemented: a 1 GiB cap with LRU
|
||||
eviction and `ResultCache.sweep()` at startup. Not implemented: a manual clear action, the opt-in
|
||||
wipe-on-exit preference and result export (≈70 MB per ten minutes).
|
||||
|
||||
## 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) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 (Part C) | `toolbar.py`, `display_prefs.py`, `app_window.py` | not started (Part B §12) |
|
||||
|
||||
## Not confirmed in code
|
||||
|
||||
* The render cost (≈1.4 ms, idle dots 37 µs) and the PNG estimate (180–540 MB per ten minutes)
|
||||
are the owner's bench, not measurements here; Part B §14's single-frame snapshots (4.2–4.9 kB)
|
||||
suggest the estimate is high, so it should be re-measured.
|
||||
* The render cost (≈1.4 ms) and the PNG estimate (180–540 MB per ten minutes) are the owner's
|
||||
bench; Part B §14's snapshots (4.2–4.9 kB each) suggest the estimate is high.
|
||||
* The manual clear action, the wipe-on-exit preference and result export have no code in
|
||||
`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).
|
||||
|
||||
@@ -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).
|
||||
@@ -9,12 +9,12 @@
|
||||
## 1. 三个顶层视图 = 同一窗口的三套布局
|
||||
|
||||
品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局
|
||||
(`StudioMainWindow._apply_view_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`):
|
||||
(`ViewLayoutController.apply_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`):
|
||||
|
||||
| 视图 | 面板 | 曲线 | 比例(3D,2D,曲线,标定) |
|
||||
|---|---|---|---|
|
||||
| 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
|
||||
| 历史回放 `replay` | 2D 图像 + 力曲线(**与实时同布局**) | 读打开的录制;未打开时仍是实时跟随 | `0, 360, 1080, 0` |
|
||||
| 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `620, 300, 520, 0` |
|
||||
| 历史回放 `replay` | 2D 图像 + 力曲线(**与实时同布局**) | 读打开的录制;未打开时仍是实时跟随 | `620, 300, 520, 0` |
|
||||
| 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
|
||||
| 自由 `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`) |
|
||||
| 2D 面板 | 录制视频在游标处的那一帧 | 管线正在处理的帧 |
|
||||
| 时间轴 | 任意定位(游标、框选、总览条、播放) | 只能顺序前进(管线是有状态的),播放按钮即开始/暂停这一遍 |
|
||||
| 用途 | 看当时的记录 | 复核算法版本(mp4 有损,数值与原记录会有差异) |
|
||||
| 曲线数据 | 录制目录的 `forces.csv`(有则保留) | 同上;裸视频读结果里的力迹 |
|
||||
| 2D / 3D | 录制帧;有结果时派生模式读缓存 | 同一游标上的分析结果 packet |
|
||||
| 时间轴 | 任意定位(游标、框选、总览条、播放) | 仍可任意定位;pass 跑完前派生模式等缓存 |
|
||||
| 用途 | 看当时的记录 | 打开即分析 / 强制再跑一遍离线 pass |
|
||||
|
||||
打开它时会立即开始这一遍(`_on_recompute_toggled` → `force_plots.set_playing(True)` →
|
||||
`_on_transport_toggled` → `_start_run`,只有一条启动路径,避免按钮与菜单各启动一次);
|
||||
关闭时停止该遍并挂回 CSV。`_replay_owns_image()` 在这一遍运行期间为 False,2D 面板交给
|
||||
管线自己画。
|
||||
关闭开关时取消进行中的分析 pass,并挂回录制来源。Stop / Esc 停播放与分析 pass,不再为
|
||||
「重算」另开一条采集链。视图切换仍只改家具:`ViewLayoutController.apply_preset`。
|
||||
|
||||
`apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核
|
||||
统一后的回放布局(不写业务数据,也不需要硬件)。
|
||||
|
||||
@@ -234,12 +234,12 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
|
||||
|
||||
## 7. 三个顶层视图 = 同一窗口的三套布局
|
||||
|
||||
导航不再打开对话框,而是切换主窗口的布局(`StudioMainWindow._apply_view_preset`):
|
||||
导航不再打开对话框,而是切换主窗口的布局(`ViewLayoutController.apply_preset`):
|
||||
|
||||
| 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) |
|
||||
|---|---|---|---|
|
||||
| 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
|
||||
| 历史回放 | 2D 图像 + 力曲线 | 读打开的录制(全程浏览) | `0, 360, 1080, 0`(与实时相同) |
|
||||
| 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `620, 300, 520, 0` |
|
||||
| 历史回放 | 2D 图像 + 力曲线 | 读打开的录制(全程浏览) | `620, 300, 520, 0`(与实时相同) |
|
||||
| 标定 | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
|
||||
| 自由(`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
|
||||
@@ -25,7 +25,6 @@ from orisys_app_shared.acquisition.image_backend import (
|
||||
plan_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.filters import FilterSettings
|
||||
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.prefs import save_preferences
|
||||
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 PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QActionGroup, QIcon
|
||||
@@ -49,6 +47,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from orisys_studio import session_metadata
|
||||
from orisys_studio.acquire_presenter import AcquirePresenter
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.display_prefs import DisplayPrefsController
|
||||
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.toolbar import ToolbarWidget
|
||||
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
|
||||
from orisys_studio.view_layout import ViewLayoutController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -178,8 +178,6 @@ class StudioMainWindow(QMainWindow):
|
||||
self._preview_timer = QTimer(self)
|
||||
self._preview_timer.setInterval(33)
|
||||
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 ---------------------------------------------------------
|
||||
# One engine for the history view: the recording reader, the cursor and
|
||||
# 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.
|
||||
self._display_paused = False
|
||||
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).
|
||||
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._last_depth_frame = None
|
||||
#: 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.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())
|
||||
# The status bar carries **one-off notices** only (a recording finished, a
|
||||
@@ -385,7 +397,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self._restore_3d_settings()
|
||||
# Visibility and the nav highlight come from the restored view; the
|
||||
# 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._update_run_enabled()
|
||||
self.cal_panel.bind_recorder(self.cal_recorder)
|
||||
@@ -511,6 +525,101 @@ class StudioMainWindow(QMainWindow):
|
||||
self._preview = RawPreviewSession()
|
||||
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
|
||||
@property
|
||||
def _record_fps(self) -> float:
|
||||
@@ -823,27 +932,38 @@ class StudioMainWindow(QMainWindow):
|
||||
def _start_playback(self) -> None:
|
||||
self.replay_ctl.start_playback()
|
||||
|
||||
def _pause_playback(self) -> None:
|
||||
self.replay_ctl.pause_playback()
|
||||
|
||||
def _on_play_shortcut(self) -> None:
|
||||
self.replay_ctl.on_play_shortcut()
|
||||
|
||||
def _stop_replay_activity(self) -> None:
|
||||
self.replay_ctl.stop_activity()
|
||||
|
||||
def _replay_owns_image(self) -> bool:
|
||||
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:
|
||||
"""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
|
||||
def _play_timer(self):
|
||||
@@ -1112,7 +1232,7 @@ class StudioMainWindow(QMainWindow):
|
||||
``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.
|
||||
"""
|
||||
if self._in_replay_view():
|
||||
if self.layout_ctl.in_replay_view():
|
||||
return "recompute" if self._recompute_running else "replay"
|
||||
return "live"
|
||||
|
||||
@@ -1145,13 +1265,6 @@ class StudioMainWindow(QMainWindow):
|
||||
def _show_finger_3d(self, enabled: bool) -> None:
|
||||
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:
|
||||
self.layout_ctl.apply_panel_visibility()
|
||||
|
||||
@@ -1203,11 +1316,12 @@ class StudioMainWindow(QMainWindow):
|
||||
reason = t("toolbar.playback_disabled")
|
||||
self.toolbar.set_run_tooltip_reason(reason)
|
||||
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
|
||||
# Stop leaves the replay, whatever it is doing: plain playback, a
|
||||
# recompute pass, or a session the pass started. The operator must never
|
||||
# have to work out which of those is stuck before they can stop it.
|
||||
# Stop leaves the replay, whatever it is doing: plain playback, an
|
||||
# analysis pass, or a leftover acquisition session. The operator must
|
||||
# never have to work out which of those is stuck before they can stop it.
|
||||
self.toolbar.set_stop_enabled(
|
||||
self.replay_ctl.is_playing()
|
||||
or self.replay_ctl.analysis_running()
|
||||
or self._recompute_running
|
||||
or self._session_active
|
||||
)
|
||||
@@ -1663,7 +1777,8 @@ class StudioMainWindow(QMainWindow):
|
||||
# shows next time is what the session will actually be given.
|
||||
for key, value in filters.as_dict().items():
|
||||
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()
|
||||
if not self._running and not self.recorder.is_recording:
|
||||
# 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.replay_ctl.is_playing()
|
||||
or self.replay_ctl.analysis_running()
|
||||
or self._session_active
|
||||
or self._recompute_running
|
||||
):
|
||||
@@ -2132,15 +2248,16 @@ class StudioMainWindow(QMainWindow):
|
||||
# it for every other product.
|
||||
enabled=bool(self.prefs.get("raw_auto_zero", True)),
|
||||
)
|
||||
self._last_plot_packet_key = None
|
||||
self._last_rendered_view_mode = None
|
||||
if self.acquire_presenter is not 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
|
||||
# unknown until the device confirms which coefficients it loaded, so it
|
||||
# starts empty and is filled from that confirmation — a path is not a
|
||||
# version, and the same path can hold different numbers.
|
||||
self._active_calibration_id = ""
|
||||
self._last_display_fallback = None
|
||||
self._last_display_available = None
|
||||
self._depth_range_value = None
|
||||
self._last_depth_frame = None
|
||||
# The new session has not established a domain boundary yet; the previous
|
||||
@@ -2263,30 +2380,6 @@ class StudioMainWindow(QMainWindow):
|
||||
else:
|
||||
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:
|
||||
"""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
|
||||
# that follow. The curve history itself is kept.
|
||||
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"))
|
||||
except Exception as exc:
|
||||
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
|
||||
@@ -2381,7 +2475,8 @@ class StudioMainWindow(QMainWindow):
|
||||
else:
|
||||
# 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.
|
||||
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"))
|
||||
|
||||
def _on_pause_action_toggled(self, paused: bool) -> None:
|
||||
@@ -2421,16 +2516,15 @@ class StudioMainWindow(QMainWindow):
|
||||
self.statusBar().showMessage(t("status.view_reset"))
|
||||
|
||||
def _on_run_tick(self) -> None:
|
||||
# The 3D pane advances on this timer rather than one of its own: the window's
|
||||
# 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
|
||||
# pane's own worker and takes at most one finished frame.
|
||||
if not self._display_paused:
|
||||
# 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
|
||||
# flight when the operator pressed pause — which must not be the one frame
|
||||
# they cannot see.
|
||||
self.mesh3d_panel.tick()
|
||||
"""Session lifecycle on the 16 ms clock; painting is :class:`AcquirePresenter`.
|
||||
|
||||
Errors, end reasons and a stuck connect stay here — they call stop. A live
|
||||
packet's charts, calibration sampling and 2D/3D paint live in the presenter
|
||||
so a replay owning the image cannot be overwritten by accident.
|
||||
"""
|
||||
presenter = self.acquire_presenter
|
||||
if presenter is not None:
|
||||
presenter.tick_3d(display_paused=self._display_paused)
|
||||
if self._active_worker is None:
|
||||
return
|
||||
# Record bookkeeping happens before anything that can return early: a
|
||||
@@ -2473,122 +2567,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self.statusBar().showMessage(t("status.connect_slow"))
|
||||
|
||||
packet = self._active_worker.latest()
|
||||
if packet is None:
|
||||
if packet is None or presenter is None:
|
||||
return
|
||||
packet_key = (int(packet.session_id), float(packet.timestamp))
|
||||
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()
|
||||
presenter.present(packet, display_paused=self._display_paused)
|
||||
|
||||
# ------------------------------------------------------------ depth scale
|
||||
def _depth_range(self) -> tuple[float, float] | None:
|
||||
@@ -2649,21 +2630,6 @@ class StudioMainWindow(QMainWindow):
|
||||
"""
|
||||
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:
|
||||
if packet is None or self._replay_owns_image():
|
||||
return
|
||||
@@ -2702,7 +2668,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self.arrow_panel.set_plan(plan)
|
||||
mode = plan.resolve(self.arrow_panel.view_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()
|
||||
# The legend is decided by the same inputs as the picture: the arrow
|
||||
# magnification of this sensor's draw style, and whether this frame
|
||||
@@ -2726,7 +2693,9 @@ class StudioMainWindow(QMainWindow):
|
||||
depth_range=self._depth_range(),
|
||||
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
|
||||
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
|
||||
# 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).
|
||||
@@ -2762,19 +2731,71 @@ class StudioMainWindow(QMainWindow):
|
||||
now. ``cancelled`` is the pass's cancellation event and ``on_progress`` its
|
||||
progress callback; both are ``None`` for a caller that only wants to ask whether
|
||||
a compatible result is already stored.
|
||||
|
||||
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.record import RecordingReader
|
||||
|
||||
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(
|
||||
Path(recording),
|
||||
chain=chain,
|
||||
cancelled=cancelled,
|
||||
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:
|
||||
"""Hand one stored analysis frame to the 3D pane's producer.
|
||||
|
||||
|
||||
@@ -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
|
||||
display preference.
|
||||
|
||||
What it borrows from the window is named by :class:`DisplayHost` — persisting the choice,
|
||||
the rail, the filter settings the dialog shows, the latest measured rate, and a live
|
||||
repaint when the 2D view mode changes.
|
||||
What it borrows from the window is named by
|
||||
:class:`~orisys_studio.shell_ports.DisplayShell` — persisting the choice, the rail,
|
||||
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
|
||||
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
from orisys_studio.shell_ports import DisplayShell
|
||||
from orisys_studio.ui.plot_settings_dialog import (
|
||||
DEFAULT_PLOT_DEADBAND_N,
|
||||
DEFAULT_PLOT_DEADBAND_RAW,
|
||||
@@ -55,26 +58,8 @@ PLOT_DEADBAND_DEFAULTS = {
|
||||
"deadband_raw": DEFAULT_PLOT_DEADBAND_RAW,
|
||||
}
|
||||
|
||||
|
||||
class DisplayHost(Protocol):
|
||||
"""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: ...
|
||||
# Kept as an alias so older imports and tests that name ``DisplayHost`` keep working.
|
||||
DisplayHost = DisplayShell
|
||||
|
||||
|
||||
class DisplayPrefsController(QObject):
|
||||
@@ -83,7 +68,7 @@ class DisplayPrefsController(QObject):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: DisplayHost,
|
||||
host: DisplayShell,
|
||||
toolbar: Any,
|
||||
charts: Any,
|
||||
arrow_panel: Any,
|
||||
@@ -153,7 +138,7 @@ class DisplayPrefsController(QObject):
|
||||
# recording being replayed), which keeps both triplets, instead of appending
|
||||
# new-unit values onto old ones.
|
||||
self._charts.set_units(unit)
|
||||
self._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
|
||||
def on_units_changed(self, units: str) -> None:
|
||||
self.set_display_unit(
|
||||
@@ -169,9 +154,9 @@ class DisplayPrefsController(QObject):
|
||||
self._host.prefs["image_view_mode"] = str(mode)
|
||||
# The next packet must repaint the panel: the frame that is on screen was drawn
|
||||
# in the previous mode.
|
||||
self._host._drop_render_marker()
|
||||
self._host._persist_prefs()
|
||||
self._host._repaint_live_frame()
|
||||
self._host.drop_render_marker()
|
||||
self._host.persist_prefs()
|
||||
self._host.repaint_live_frame()
|
||||
|
||||
# --------------------------------------------------------------------- plot
|
||||
def plot_settings(self) -> dict:
|
||||
@@ -212,7 +197,7 @@ class DisplayPrefsController(QObject):
|
||||
settings = self.plot_settings()
|
||||
self._charts.set_window_sec(float(settings["window_sec"]))
|
||||
self._apply_ranges(settings)
|
||||
self._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
|
||||
def apply_plot_prefs_to_panel(self) -> None:
|
||||
"""Show the stored chart range without asking anything (startup path)."""
|
||||
@@ -243,7 +228,7 @@ class DisplayPrefsController(QObject):
|
||||
from orisys_studio import session_metadata
|
||||
|
||||
return session_metadata.device_sample_rate(
|
||||
self._host._latest_fps,
|
||||
self._host.latest_fps,
|
||||
self._host.prefs.get("measured_fps"),
|
||||
default=self._default_sample_rate_hz,
|
||||
)
|
||||
@@ -276,15 +261,15 @@ class DisplayPrefsController(QObject):
|
||||
return
|
||||
deg = self._arrow_panel.rotate_90_ccw()
|
||||
self._host.prefs["arrow_rotation_deg"] = deg
|
||||
self._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
|
||||
# ---------------------------------------------------------------------- fps
|
||||
def on_show_fps_toggled(self, visible: bool) -> None:
|
||||
self._show_fps = bool(visible)
|
||||
self._host.prefs["show_fps"] = self._show_fps
|
||||
# FPS has one home now: the status rail.
|
||||
self._host._refresh_status_rail()
|
||||
self._host._persist_prefs()
|
||||
self._host.refresh_status_rail()
|
||||
self._host.persist_prefs()
|
||||
|
||||
# -------------------------------------------------------------- diagnostics
|
||||
@property
|
||||
@@ -308,7 +293,7 @@ class DisplayPrefsController(QObject):
|
||||
self._show_diagnostics = bool(visible)
|
||||
self._host.prefs["show_diagnostics"] = self._show_diagnostics
|
||||
self._apply_diagnostics()
|
||||
self._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
|
||||
def _apply_diagnostics(self) -> None:
|
||||
for panel in (self._arrow_panel, self._mesh_panel):
|
||||
@@ -329,7 +314,7 @@ class DisplayPrefsController(QObject):
|
||||
"""
|
||||
self._raw_auto_zero = bool(enabled)
|
||||
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
|
||||
self._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
self.apply_presentation_prefs()
|
||||
|
||||
def on_raw_auto_zero_toggled(self, enabled: bool) -> None:
|
||||
|
||||
@@ -13,10 +13,10 @@ and the window when the furniture changes.
|
||||
|
||||
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
|
||||
it borrows from the window is named by :class:`ReplayHost` — the status line, the rail,
|
||||
the view switch, the display unit, the single 2D sink, and the run pipeline a recompute
|
||||
pass is. Those are window-level operations, so the window keeps them and this module
|
||||
states its dependence on them out loud instead of reaching into a god object.
|
||||
it borrows from the window is named by :class:`~orisys_studio.shell_ports.ReplayShell`
|
||||
— acquire-gate reads, the image sink, shell notes, and analysis support. View switches
|
||||
and acquire start/stop are signals the window wires, so this module never calls a
|
||||
window private method by name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +25,7 @@ import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
@@ -40,9 +40,10 @@ from orisys_app_shared.acquisition.image_backend import (
|
||||
ImageFrame,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.force.source import AnalysisCurveSource
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from PySide6.QtCore import QObject, QTimer, Signal
|
||||
from PySide6.QtWidgets import QFileDialog, QWidget
|
||||
|
||||
from orisys_studio.analysis_worker import AnalysisReport, AnalysisWorker
|
||||
from orisys_studio.replay import MODE_ANALYSIS
|
||||
@@ -52,6 +53,7 @@ from orisys_studio.replay_analysis import (
|
||||
ResultPolicyError,
|
||||
policy_divergence,
|
||||
)
|
||||
from orisys_studio.shell_ports import ReplayShell
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,72 +62,8 @@ logger = logging.getLogger(__name__)
|
||||
#: recording down.
|
||||
PLAYBACK_TICK_MS = 33
|
||||
|
||||
|
||||
class ReplayHost(Protocol):
|
||||
"""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).
|
||||
"""
|
||||
...
|
||||
# Kept as an alias so older imports and tests that name ``ReplayHost`` keep working.
|
||||
ReplayHost = ReplayShell
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -142,11 +80,18 @@ class ReplayActions:
|
||||
class ReplayController(QObject):
|
||||
"""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__(
|
||||
self,
|
||||
*,
|
||||
engine: Any,
|
||||
host: ReplayHost,
|
||||
host: ReplayShell,
|
||||
toolbar: Any,
|
||||
charts: 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
|
||||
#: the acquire pipeline owns the panels and the transport stops the pass.
|
||||
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.
|
||||
self.results = ResultLibrary()
|
||||
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
|
||||
#: computed, so neither a cache read nor a pass is possible.
|
||||
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_started_at = 0.0
|
||||
self.play_timer = QTimer(self)
|
||||
@@ -192,6 +145,17 @@ class ReplayController(QObject):
|
||||
if self._progress is not None:
|
||||
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
|
||||
def wire(self) -> None:
|
||||
"""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
|
||||
*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.
|
||||
a preview) looked as if it had opened the camera instead of the recording.
|
||||
Analysis results are drawn by this controller too, so an analysis pass keeps
|
||||
ownership — it does not hand the panel back to the acquisition tick.
|
||||
"""
|
||||
return (
|
||||
self._host._in_replay_view()
|
||||
and self._engine.has_video()
|
||||
and not self._recompute_running
|
||||
)
|
||||
return self.in_replay_view() and self._engine.has_video()
|
||||
|
||||
# ------------------------------------------------------------------ entries
|
||||
def on_cursor_moved(self, t_rel_s: float) -> None:
|
||||
if self._recompute_running:
|
||||
# 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():
|
||||
if self.in_replay_view() and self._engine.has_video():
|
||||
self._engine.set_position(float(t_rel_s))
|
||||
|
||||
def on_open_recording(self) -> None:
|
||||
@@ -284,7 +240,7 @@ class ReplayController(QObject):
|
||||
"""
|
||||
start = str(recordings_dir())
|
||||
chosen, _filter = QFileDialog.getOpenFileName(
|
||||
self._host,
|
||||
self._dialog_parent(),
|
||||
t("playback.open"),
|
||||
start,
|
||||
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
|
||||
second way to acquire.
|
||||
"""
|
||||
if self._host._running or self._host._preview_open:
|
||||
if self._host.running or self._host.preview_open:
|
||||
return
|
||||
start = self._toolbar.video_path() or str(Path.home())
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self._host,
|
||||
self._dialog_parent(),
|
||||
t("menu.open_video"),
|
||||
start,
|
||||
t("playback.video_filter"),
|
||||
@@ -337,7 +293,7 @@ class ReplayController(QObject):
|
||||
"""
|
||||
if self._actions is None:
|
||||
return
|
||||
allowed = self._host._in_replay_view()
|
||||
allowed = self.in_replay_view()
|
||||
reason = "" if allowed else t("toolbar.replay_view_only")
|
||||
for action in (
|
||||
self._actions.open_video,
|
||||
@@ -353,7 +309,7 @@ class ReplayController(QObject):
|
||||
|
||||
def can_recompute(self) -> bool:
|
||||
"""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
|
||||
if self._engine.has_video():
|
||||
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.
|
||||
"""
|
||||
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
|
||||
# happens programmatically — and refusing is better than leaving the window
|
||||
# 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
|
||||
folder = video.parent if video.is_file() else video
|
||||
if not (folder / "forces.csv").is_file():
|
||||
@@ -386,34 +342,41 @@ class ReplayController(QObject):
|
||||
self._host.prefs["video_path"] = str(video)
|
||||
# The window's own switch, which also remembers the choice: the file
|
||||
# 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._host._notify(t("playback.video_only_opened", name=video.name))
|
||||
self._host.notify(t("playback.video_only_opened", name=video.name))
|
||||
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
|
||||
if video.is_file():
|
||||
self._toolbar.set_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():
|
||||
# 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.
|
||||
self._image_panel.clear_preview(t("playback.video_only_panel"))
|
||||
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
|
||||
if not self.load_recording(folder):
|
||||
return False
|
||||
self._toolbar.set_video_path(str(video))
|
||||
self._host._refresh_status_rail()
|
||||
self._host.refresh_status_rail()
|
||||
return True
|
||||
|
||||
def load_recording(self, directory: Path) -> bool:
|
||||
if not self._host._in_replay_view():
|
||||
self._host._apply_view_preset("replay")
|
||||
if not self.in_replay_view():
|
||||
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))
|
||||
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
|
||||
self.refresh_state()
|
||||
# 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()
|
||||
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:
|
||||
"""Switch to the replay view on the most recent recording."""
|
||||
from orisys_app_shared.record import list_recordings
|
||||
|
||||
if not self._host._in_replay_view():
|
||||
self._host._notify(t("toolbar.replay_view_only"))
|
||||
if not self.in_replay_view():
|
||||
self._host.notify(t("toolbar.replay_view_only"))
|
||||
return
|
||||
found = list_recordings(recordings_dir())
|
||||
if not found:
|
||||
self._host._notify(t("playback.none_found"))
|
||||
self._host.notify(t("playback.none_found"))
|
||||
return
|
||||
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."""
|
||||
self.refresh_state()
|
||||
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()
|
||||
return
|
||||
if self._host._in_replay_view():
|
||||
if self.in_replay_view():
|
||||
self.apply_source(True)
|
||||
self.render_frame()
|
||||
|
||||
@@ -465,8 +481,8 @@ class ReplayController(QObject):
|
||||
if self._actions is not None:
|
||||
self._actions.recompute.setEnabled(self.can_recompute())
|
||||
self.sync_actions()
|
||||
self._host._update_run_enabled()
|
||||
self._host._refresh_status_rail()
|
||||
self._host.update_run_enabled()
|
||||
self._host.refresh_status_rail()
|
||||
|
||||
# ---------------------------------------------------------------- transport
|
||||
def on_replay_position(self, position_s: float) -> None:
|
||||
@@ -474,21 +490,6 @@ class ReplayController(QObject):
|
||||
|
||||
def on_transport_toggled(self, playing: bool) -> None:
|
||||
"""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():
|
||||
self.start_playback()
|
||||
else:
|
||||
@@ -500,7 +501,7 @@ class ReplayController(QObject):
|
||||
# A zero-length timeline is not something to play, and silently un-checking
|
||||
# the button is how "pressing 回放 does nothing" felt to the operator.
|
||||
self._toolbar.set_run_checked(False)
|
||||
self._host._notify(t("playback.empty_recording"))
|
||||
self._host.notify(t("playback.empty_recording"))
|
||||
return
|
||||
position = self._engine.position()
|
||||
if position >= duration - 0.02:
|
||||
@@ -514,7 +515,7 @@ class ReplayController(QObject):
|
||||
self._toolbar.refresh_labels()
|
||||
# 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.
|
||||
self._host._update_run_enabled()
|
||||
self._host.update_run_enabled()
|
||||
|
||||
def pause_playback(self) -> None:
|
||||
if self.play_timer.isActive():
|
||||
@@ -522,7 +523,7 @@ class ReplayController(QObject):
|
||||
if self._toolbar.btn_run.isChecked():
|
||||
self._toolbar.set_run_checked(False)
|
||||
self._toolbar.refresh_labels()
|
||||
self._host._update_run_enabled()
|
||||
self._host.update_run_enabled()
|
||||
|
||||
def on_play_tick(self) -> None:
|
||||
"""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.
|
||||
|
||||
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.
|
||||
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
|
||||
silently, which is what made a stuck state feel stuck).
|
||||
"""
|
||||
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._host._stop_run(reason="user_stop")
|
||||
self.acquire_stop_requested.emit("user_stop")
|
||||
stopped.append(t("toolbar.stopped_pass"))
|
||||
if self.play_timer.isActive():
|
||||
self.pause_playback()
|
||||
stopped.append(t("toolbar.stopped_playback"))
|
||||
self._mesh_panel.show_reference_only()
|
||||
self._host._update_run_enabled()
|
||||
self._host.update_run_enabled()
|
||||
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:
|
||||
self._host._notify(t("toolbar.nothing_to_stop"))
|
||||
self._host.notify(t("toolbar.nothing_to_stop"))
|
||||
|
||||
# ---------------------------------------------------------------- recompute
|
||||
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()
|
||||
segments = sorted(reader.segments()) if reader is not None else []
|
||||
if segments:
|
||||
@@ -577,60 +584,48 @@ class ReplayController(QObject):
|
||||
return 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
|
||||
recorded (lossy) video answers a different question — "what would this algorithm
|
||||
version say about that footage" — and it can only run *forward*, because the
|
||||
chain is stateful, so it is a pass rather than a scrub.
|
||||
Part B: 「从视频重算」 and the open path share ``on_analysis_requested`` — serve
|
||||
a compatible cache, or run the offline pass. The live acquisition chain over
|
||||
the video is gone: analysis results already feed the 2D canvas and the 3D
|
||||
pane from one cursor (``show_analysis_packet``).
|
||||
"""
|
||||
wanted = bool(checked) and self.can_recompute()
|
||||
if self._actions is not None and self._actions.recompute.isChecked() != wanted:
|
||||
self._actions.recompute.blockSignals(True)
|
||||
self._actions.recompute.setChecked(wanted)
|
||||
self._actions.recompute.blockSignals(False)
|
||||
self._toolbar.set_recompute_checked(wanted)
|
||||
self.pause_playback()
|
||||
if wanted:
|
||||
segment = self.video_segment()
|
||||
if segment is None:
|
||||
self._host._notify(t("playback.no_video"))
|
||||
if self._actions is not None:
|
||||
self._actions.recompute.blockSignals(True)
|
||||
self._actions.recompute.setChecked(False)
|
||||
self._actions.recompute.blockSignals(False)
|
||||
# A bare video may have been opened and the setup prompt declined: play-only,
|
||||
# no workspace. 「从视频重算」 asks again — that is how numbers appear — then
|
||||
# shares ``on_analysis_requested`` with opening a recording.
|
||||
if self.recording_directory() is None:
|
||||
video = self.video_segment()
|
||||
if video is None:
|
||||
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
|
||||
# The charts stop reading the CSV; the pipeline's own samples become the
|
||||
# data, and the 2D panel shows what it produces. The pass starts here so
|
||||
# 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)
|
||||
self._host.notify(t("playback.no_video"))
|
||||
self._set_recompute_checked(False)
|
||||
return
|
||||
self.on_analysis_requested()
|
||||
return
|
||||
if self.analysis_running():
|
||||
self.cancel_analysis()
|
||||
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._host._stop_run(reason="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.acquire_stop_requested.emit("user_stop")
|
||||
self._mesh_panel.show_reference_only()
|
||||
if self._host._in_replay_view():
|
||||
self._mesh_panel.set_source_kind(self._host._current_source_kind())
|
||||
if self._engine.is_loaded():
|
||||
if self.in_replay_view():
|
||||
self._mesh_panel.set_source_kind(self._host.current_source_kind())
|
||||
if self._engine.is_loaded() or self._engine.is_video_only():
|
||||
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()
|
||||
|
||||
# ---------------------------------------------------------------- analysis
|
||||
@@ -704,7 +699,7 @@ class ReplayController(QObject):
|
||||
policy = policy_for_settings(settings)
|
||||
if not session.serve_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()
|
||||
|
||||
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.
|
||||
"""
|
||||
if self.analysis_running():
|
||||
self._host._notify(t("playback.analysis_busy"))
|
||||
self._host.notify(t("playback.analysis_busy"))
|
||||
return
|
||||
recording = self.recording_directory()
|
||||
if recording is None:
|
||||
# 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``).
|
||||
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.render_frame()
|
||||
return
|
||||
self._clear_analysis_state()
|
||||
if self._activate_stored_result(recording, notify_reuse=True):
|
||||
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
|
||||
# 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
|
||||
# 「从视频重算」 starts the pass the moment the session is stopped.
|
||||
self._analysis_skip = t("analysis.blocked")
|
||||
self._host._notify(self._analysis_skip)
|
||||
self._host.notify(self._analysis_skip)
|
||||
self._set_recompute_checked(False)
|
||||
self.render_frame()
|
||||
return
|
||||
@@ -791,10 +786,21 @@ class ReplayController(QObject):
|
||||
return RESULT_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()
|
||||
if directory is not None:
|
||||
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()
|
||||
if named:
|
||||
folder = Path(named)
|
||||
@@ -815,7 +821,7 @@ class ReplayController(QObject):
|
||||
serve; whether a pass can then run is the caller's question.
|
||||
"""
|
||||
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
|
||||
# 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
|
||||
@@ -868,7 +874,7 @@ class ReplayController(QObject):
|
||||
# The rail's own notification (the 3D pane's source line and the 2D footer say
|
||||
# "analysis result"), not the transient status line: this is a state, and it
|
||||
# stays true for as long as the timeline is in it.
|
||||
self._host._refresh_status_rail()
|
||||
self._host.refresh_status_rail()
|
||||
if self._progress is not None:
|
||||
self._progress.reused(frames=frames)
|
||||
return True
|
||||
@@ -887,7 +893,7 @@ class ReplayController(QObject):
|
||||
"""
|
||||
self.results.open_session(session)
|
||||
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
|
||||
# ``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
|
||||
@@ -941,7 +947,7 @@ class ReplayController(QObject):
|
||||
def _start_pass(self, recording: Path) -> None:
|
||||
"""Run the analysis pass on a worker thread, with progress and a cancel."""
|
||||
worker = AnalysisWorker(
|
||||
lambda event, on_progress: self._host._analysis_service(
|
||||
lambda event, on_progress: self._host.analysis_service(
|
||||
recording, event, on_progress=on_progress
|
||||
),
|
||||
parent=self,
|
||||
@@ -1021,7 +1027,7 @@ class ReplayController(QObject):
|
||||
)
|
||||
# The rail states what the timeline is reading from (the source label), so a
|
||||
# completed pass says so where the state lives rather than in a line that expires.
|
||||
self._host._refresh_status_rail()
|
||||
self._host.refresh_status_rail()
|
||||
self.refresh_state()
|
||||
|
||||
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
|
||||
# drawable at all; the plan reads it to decide what to offer.
|
||||
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:
|
||||
"""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
|
||||
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
|
||||
if self._recompute_running:
|
||||
# The pipeline is drawing its own frames (see ``owns_image``).
|
||||
@@ -1194,7 +1209,7 @@ class ReplayController(QObject):
|
||||
else:
|
||||
frame = None
|
||||
if frame is not None:
|
||||
self._host._show_image_frame(
|
||||
self._host.show_frame(
|
||||
self._analysis_image_frame(
|
||||
frame,
|
||||
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
|
||||
# cursor, two figures, one frame id.
|
||||
self._host._show_analysis_frame(session.packet(frame))
|
||||
self._host.show_analysis_packet(session.packet(frame))
|
||||
return
|
||||
image = self._engine.video_frame_bgr()
|
||||
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
|
||||
# no pass can serve are not the same news as "nothing has run yet".
|
||||
state, reason = self.result_state()
|
||||
self._host._show_image_frame(
|
||||
self._host.show_frame(
|
||||
ImageFrame(
|
||||
image,
|
||||
source=SOURCE_REPLAY,
|
||||
@@ -1239,22 +1254,82 @@ class ReplayController(QObject):
|
||||
wanted = "N" if self._charts.unit() == "N" else "raw"
|
||||
unit = self.unit_for(reader)
|
||||
self._engine.set_unit(unit)
|
||||
self._host._set_display_unit(unit)
|
||||
self._host.set_display_unit(unit)
|
||||
if unit != wanted:
|
||||
self._host._notify(t("status.units_raw_fallback"))
|
||||
self._host.notify(t("status.units_raw_fallback"))
|
||||
self._charts.show_all()
|
||||
self._image_panel.set_source_kind("replay")
|
||||
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():
|
||||
self._charts.clear_recording_source()
|
||||
self._charts.follow_latest()
|
||||
self._image_panel.set_source_kind("live")
|
||||
# The panel held a recorded frame: the next live packet must repaint it, so the
|
||||
# "already rendered" marker is dropped.
|
||||
self._host._drop_render_marker()
|
||||
if not self._host._running and not self._host._preview_open:
|
||||
self._host.drop_render_marker()
|
||||
if not self._host.running and not self._host.preview_open:
|
||||
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:
|
||||
"""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 orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import (
|
||||
SHAPE_COMPATIBILITY,
|
||||
SHAPE_DESCRIPTOR,
|
||||
SHAPE_UNREADABLE,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
@@ -29,6 +34,14 @@ from PySide6.QtWidgets import (
|
||||
|
||||
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):
|
||||
"""Sensor type / config picker plus the calibration facts that follow it."""
|
||||
@@ -190,8 +203,10 @@ class SensorDialog(QDialog):
|
||||
def _refresh_profile(self) -> None:
|
||||
"""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
|
||||
say two different things about one bundle.
|
||||
This is the surface that keeps the facts the picker row no longer carries: the identity
|
||||
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)
|
||||
entry = read_entry() if callable(read_entry) else None
|
||||
@@ -201,8 +216,8 @@ class SensorDialog(QDialog):
|
||||
return
|
||||
line = t(
|
||||
"sensor.catalog_profile",
|
||||
family=entry.product_family or t("sensor.family_unidentified"),
|
||||
profile_id=entry.profile_id or entry.folder,
|
||||
shape=t(_SHAPE_KEYS.get(entry.shape, _SHAPE_KEYS[SHAPE_UNREADABLE])),
|
||||
origin=entry.origin,
|
||||
state=entry.state,
|
||||
)
|
||||
|
||||
@@ -9,9 +9,10 @@ own labels and tooltips instead of a bare "配置" combo.
|
||||
Both combos are filled from the configuration catalog
|
||||
(: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
|
||||
the catalog's entries for the chosen family — each labelled with its profile,
|
||||
origin and the SDK's own verdict. An entry the SDK rejects stays listed, because
|
||||
that is how its reason reaches the operator, and
|
||||
the catalog's entries for the chosen family — each labelled with what it is, where
|
||||
it came from and what the calibration selection gives, so the choice is between
|
||||
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
|
||||
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:
|
||||
"""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
|
||||
internal marker for "no product family" is replaced here, where translating belongs, so an
|
||||
operator never reads a sentinel.
|
||||
The catalog names an entry by its display name, so the internal marker for "no product
|
||||
family" cannot reach the row today; it is still substituted here because a sentinel is
|
||||
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"))
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
the run button's state, the rail, the arrow rotation that follows the sensor, and the
|
||||
source kind the 3D pane should claim. It drives the panels and the focus of the session's
|
||||
view directly, and it tells the replay controller that the view changed, because leaving
|
||||
the replay view is what hands the charts back to the live session.
|
||||
What it borrows from the window is named by :class:`~orisys_studio.shell_ports.LayoutShell`
|
||||
— persisting the choice, the run button's state, the rail, and the source kind the 3D
|
||||
pane should claim. It drives the panels directly, and it tells the replay controller
|
||||
which view is active, because leaving the replay view is what hands the charts back to
|
||||
the live session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
from orisys_studio.shell_ports import LayoutShell
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LayoutHost(Protocol):
|
||||
"""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): ...
|
||||
# Kept as an alias so older imports and tests that name ``LayoutHost`` keep working.
|
||||
LayoutHost = LayoutShell
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -64,7 +49,7 @@ class ViewLayoutController(QObject):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: LayoutHost,
|
||||
host: LayoutShell,
|
||||
splitter: Any,
|
||||
mesh_panel: Any,
|
||||
image_panel: Any,
|
||||
@@ -198,7 +183,7 @@ class ViewLayoutController(QObject):
|
||||
self._mesh_panel.setVisible(show_3d)
|
||||
# Where the field comes from: a live view acquires it, a replay has none
|
||||
# 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._charts.setVisible(True)
|
||||
self._cal_panel.setVisible(show_cal)
|
||||
@@ -286,6 +271,12 @@ class ViewLayoutController(QObject):
|
||||
self.apply_panel_visibility()
|
||||
if apply_sizes and preset in self._splitter_sizes:
|
||||
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":
|
||||
# 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
|
||||
@@ -300,10 +291,10 @@ class ViewLayoutController(QObject):
|
||||
self._brand_bar.set_active_view(preset if preset != "custom" else None)
|
||||
self.sync_view_checks()
|
||||
self._replay.sync_actions()
|
||||
self._host._update_run_enabled()
|
||||
self._host._refresh_status_rail()
|
||||
self._host.update_run_enabled()
|
||||
self._host.refresh_status_rail()
|
||||
if persist:
|
||||
self._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
|
||||
def on_live_requested(self) -> None:
|
||||
self.apply_preset("live")
|
||||
@@ -330,8 +321,11 @@ class ViewLayoutController(QObject):
|
||||
self._host.prefs["view_preset"] = "live"
|
||||
self._host.prefs["show_cal_panel"] = False
|
||||
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._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
|
||||
def on_finger_3d_toggled(self, visible: bool) -> None:
|
||||
if self._picker.sensor_type() != "finger":
|
||||
@@ -339,7 +333,7 @@ class ViewLayoutController(QObject):
|
||||
self._show_finger_3d = bool(visible)
|
||||
self._host.prefs["show_finger_3d"] = self._show_finger_3d
|
||||
self.apply_panel_visibility()
|
||||
self._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
|
||||
def on_3d_pane_toggled(self, enabled: bool) -> None:
|
||||
"""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._host.prefs["show_finger_3d"] = self._show_finger_3d
|
||||
self.apply_panel_visibility()
|
||||
self._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
|
||||
def set_settings_expanded(self, expanded: bool, *, persist: bool = True) -> None:
|
||||
self._toolbar.set_settings_expanded(bool(expanded))
|
||||
self._host.prefs["settings_expanded"] = bool(expanded)
|
||||
self.sync_settings_action()
|
||||
if persist:
|
||||
self._host._persist_prefs()
|
||||
self._host.persist_prefs()
|
||||
|
||||
def on_settings_toggled(self, expanded: bool) -> None:
|
||||
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_sensor(
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -123,18 +123,35 @@ class _Host:
|
||||
self.rail_updates = 0
|
||||
self.markers = 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
|
||||
|
||||
def _refresh_status_rail(self) -> None:
|
||||
def refresh_status_rail(self) -> None:
|
||||
self.rail_updates += 1
|
||||
|
||||
def _drop_render_marker(self) -> None:
|
||||
def drop_render_marker(self) -> None:
|
||||
self.markers += 1
|
||||
|
||||
def _repaint_live_frame(self) -> None:
|
||||
def repaint_live_frame(self) -> None:
|
||||
self.repaints += 1
|
||||
|
||||
|
||||
|
||||
@@ -165,11 +165,12 @@ class _Host:
|
||||
def __init__(self, *, view: str = "replay") -> None:
|
||||
self.prefs: dict = {}
|
||||
self.view = view
|
||||
self._running = False
|
||||
self._preview_open = False
|
||||
self._session_active = False
|
||||
self._running_flag = False
|
||||
self._preview_open_flag = False
|
||||
self._session_active_flag = False
|
||||
self.notes: list[str] = []
|
||||
self.frames: list = []
|
||||
self.analysis_packets: list = []
|
||||
self.presets: list[str] = []
|
||||
self.units: list[str] = []
|
||||
self.source_modes: list[str] = []
|
||||
@@ -178,46 +179,105 @@ class _Host:
|
||||
self.run_updates = 0
|
||||
self.rail_updates = 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
|
||||
def _in_replay_view(self) -> bool:
|
||||
return self.view == "replay"
|
||||
# AcquireGate reads (legacy private names kept as writeable fields for tests)
|
||||
@property
|
||||
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"
|
||||
|
||||
# writes
|
||||
def _apply_view_preset(self, preset: str) -> None:
|
||||
self.presets.append(preset)
|
||||
self.view = preset
|
||||
def bare_video_workspace(self, video):
|
||||
self.workspace_requests.append(video)
|
||||
return self.workspace
|
||||
|
||||
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)
|
||||
|
||||
def _set_source_mode(self, mode: str) -> None:
|
||||
def set_source_mode(self, mode: str) -> None:
|
||||
self.source_modes.append(mode)
|
||||
|
||||
def _show_image_frame(self, frame) -> None:
|
||||
def show_frame(self, frame) -> None:
|
||||
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
|
||||
|
||||
def _notify(self, text: str, ms: int = 0) -> None:
|
||||
def notify(self, text: str, ms: int = 0) -> None:
|
||||
self.notes.append(str(text))
|
||||
|
||||
def _start_run(self) -> None:
|
||||
def start_acquire(self) -> None:
|
||||
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)
|
||||
|
||||
def _update_run_enabled(self) -> None:
|
||||
def update_run_enabled(self) -> None:
|
||||
self.run_updates += 1
|
||||
|
||||
def _refresh_status_rail(self) -> None:
|
||||
def refresh_status_rail(self) -> None:
|
||||
self.rail_updates += 1
|
||||
|
||||
def persist_prefs(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, *, newtons=None) -> None:
|
||||
@@ -276,6 +336,10 @@ class _Engine:
|
||||
def reader(self):
|
||||
return self._reader
|
||||
|
||||
def directory(self):
|
||||
"""No recording directory: a stub opens a video, not a recorded session."""
|
||||
return None
|
||||
|
||||
def video_path(self):
|
||||
return self._path
|
||||
|
||||
@@ -309,6 +373,23 @@ class _Engine:
|
||||
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):
|
||||
"""A controller wired to fakes; returns ``(controller, host, engine, parts)``."""
|
||||
engine = kwargs.pop("engine", None) or _Engine()
|
||||
@@ -317,13 +398,16 @@ def _controller(**kwargs):
|
||||
charts = kwargs.pop("charts", None) or _Charts()
|
||||
panel = kwargs.pop("image_panel", None) or _ImagePanel()
|
||||
mesh = kwargs.pop("mesh_panel", None) or _Mesh()
|
||||
controller = ReplayController(
|
||||
controller = _wire_replay(
|
||||
ReplayController(
|
||||
engine=engine,
|
||||
host=host,
|
||||
toolbar=toolbar,
|
||||
charts=charts,
|
||||
image_panel=panel,
|
||||
mesh_panel=mesh,
|
||||
),
|
||||
host,
|
||||
)
|
||||
actions = {
|
||||
"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"
|
||||
|
||||
|
||||
def test_the_transport_runs_a_recompute_pass_instead_of_playing(qt_app):
|
||||
"""With the recompute switch on, play starts the *pipeline*, not the cursor."""
|
||||
def test_the_transport_plays_even_when_the_recompute_switch_is_on(qt_app):
|
||||
"""Analysis is a separate pass; the rail's play button always moves the cursor."""
|
||||
engine = _Engine(duration=1.0, has_video=True)
|
||||
controller, host, engine, parts = _controller(engine=engine)
|
||||
parts[4]["recompute"].setChecked(True)
|
||||
|
||||
controller.on_transport_toggled(True)
|
||||
assert controller.recompute_running
|
||||
assert host.started == 1
|
||||
assert not controller.is_playing(), "the recompute pass also started playback"
|
||||
assert controller.is_playing(), "play must scrub the recording"
|
||||
assert host.started == 0, "play must not open an acquisition chain"
|
||||
|
||||
controller.on_transport_toggled(False)
|
||||
assert not controller.recompute_running
|
||||
assert host.stopped == ["user_stop"]
|
||||
assert not controller.is_playing()
|
||||
assert host.stopped == []
|
||||
|
||||
|
||||
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()
|
||||
|
||||
host.view = "live"
|
||||
controller.set_active_view("live")
|
||||
assert not controller.owns_image(), "a live view does not hand the pane to a replay"
|
||||
|
||||
host.view = "replay"
|
||||
controller.recompute_running = True
|
||||
assert not controller.owns_image(), "the pass is drawing, not the replay"
|
||||
controller.set_active_view("replay")
|
||||
# An analysis pass is drawn by this controller too, so ownership stays with replay.
|
||||
assert controller.owns_image()
|
||||
|
||||
|
||||
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()
|
||||
controller, host, engine, parts = _controller(engine=engine)
|
||||
|
||||
host.view = "live"
|
||||
controller.set_active_view("live")
|
||||
assert controller.load_video(bare) is False
|
||||
assert host.notes[-1] == t("toolbar.replay_view_only")
|
||||
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 engine.opened == [("video", bare)]
|
||||
assert host.source_modes[-1] == "video"
|
||||
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):
|
||||
@@ -449,7 +561,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(qt_app):
|
||||
controller, host, engine, parts = _controller()
|
||||
actions = parts[4]
|
||||
|
||||
host.view = "live"
|
||||
controller.set_active_view("live")
|
||||
controller.sync_actions()
|
||||
assert [
|
||||
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 actions["open_video"].tooltips[-1] == t("toolbar.replay_view_only")
|
||||
|
||||
host.view = "replay"
|
||||
controller.set_active_view("replay")
|
||||
engine._has_video = True
|
||||
controller.sync_actions()
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
):
|
||||
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
|
||||
picker.set_sensor_type("mini")
|
||||
label = picker.config_combo.itemText(0)
|
||||
assert "orisys.mini.lab-r1" in label
|
||||
assert "mini" in label and "user" in label and "degraded" in label
|
||||
# What it is, where it came from, what it gives — and not the profile_id the operator
|
||||
# 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
|
||||
entry = picker.selected_entry()
|
||||
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()
|
||||
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
#: 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")
|
||||
def shipped():
|
||||
@@ -91,6 +102,55 @@ def test_the_shipped_families_keep_the_picker_order(shipped):
|
||||
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):
|
||||
for folder in ("box", "mini", "finger-mems"):
|
||||
bundle = sensors_dir() / folder
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
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
|
||||
):
|
||||
"""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
|
||||
|
||||
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"
|
||||
|
||||
started: list = []
|
||||
asked: list = []
|
||||
|
||||
def fake_run() -> None:
|
||||
started.append(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.replay_ctl.on_analysis_requested = spy_analysis
|
||||
window.act_recompute.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert started == [True], f"the pass started {len(started)} times"
|
||||
assert not panel.is_recording_source(), "the charts still read the CSV"
|
||||
assert window.toolbar.source_mode() == "video"
|
||||
assert window.toolbar.video_path().endswith(".mp4"), window.toolbar.video_path()
|
||||
assert window.toolbar.btn_run.isChecked(), (
|
||||
"the transport does not show the pass as running"
|
||||
)
|
||||
assert asked == [True], "the switch did not ask for analysis"
|
||||
assert started == [], "the switch must not open an acquisition chain"
|
||||
# A recording's force measurement stays on forces.csv; analysis feeds 2D/3D.
|
||||
assert panel.is_recording_source(), "analysis must not replace the recorded CSV"
|
||||
|
||||
window._recompute_running = True
|
||||
window.act_recompute.setChecked(False)
|
||||
QApplication.processEvents()
|
||||
assert panel.is_recording_source(), "turning the recompute off did not restore the CSV"
|
||||
assert not window.toolbar.btn_run.isChecked(), "the transport stayed checked"
|
||||
assert panel.is_recording_source()
|
||||
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
# A recompute pass: the same button stops it, without stopping the recording.
|
||||
stopped: list = []
|
||||
window._stop_run = lambda **kwargs: stopped.append(kwargs.get("reason"))
|
||||
window._recompute_running = True
|
||||
# An analysis pass left over from opening the recording: Stop cancels it.
|
||||
# Opening may already have finished the pass; start one so the escape hatch is real.
|
||||
window.replay_ctl.cancel_analysis()
|
||||
window.replay_ctl.wait_for_analysis()
|
||||
window.replay_ctl.on_analysis_requested()
|
||||
QApplication.processEvents()
|
||||
if window.replay_ctl.analysis_running():
|
||||
window._update_run_enabled()
|
||||
assert window.toolbar.btn_stop.isEnabled(), "no stop offered while a pass runs"
|
||||
window.toolbar.btn_stop.click()
|
||||
QApplication.processEvents()
|
||||
assert stopped == ["user_stop"], stopped
|
||||
assert not window.replay_ctl.analysis_running()
|
||||
assert t("toolbar.stopped_pass") in window.statusBar().currentMessage(), (
|
||||
window.statusBar().currentMessage()
|
||||
)
|
||||
|
||||
# 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()
|
||||
QApplication.processEvents()
|
||||
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()
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
image = window.replay.video_image()
|
||||
assert image is not None and not image.isNull(), "no frame was decoded"
|
||||
window._stop_replay_activity()
|
||||
window.replay_ctl.stop_activity()
|
||||
QApplication.processEvents()
|
||||
assert not window._play_timer.isActive()
|
||||
finally:
|
||||
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.
|
||||
|
||||
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
|
||||
try:
|
||||
window._apply_view_preset("replay")
|
||||
window.layout_ctl.apply_preset("replay")
|
||||
assert window._load_replay_video(bare) is True
|
||||
window._render_replay_frame()
|
||||
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}"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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))
|
||||
try:
|
||||
window._apply_view_preset("live")
|
||||
window.layout_ctl.apply_preset("live")
|
||||
QApplication.processEvents()
|
||||
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"
|
||||
@@ -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
|
||||
# whether there is anything to recompute from.
|
||||
window._apply_view_preset("replay")
|
||||
window.layout_ctl.apply_preset("replay")
|
||||
QApplication.processEvents()
|
||||
for action in (window.act_open_video, window.act_playback, window.act_playback_latest):
|
||||
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(), (
|
||||
"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()
|
||||
QApplication.processEvents()
|
||||
assert window.act_recompute.isChecked(), "the rail's recompute entry did not toggle"
|
||||
window.act_recompute.setChecked(False)
|
||||
assert started == [], "recompute must not open an acquisition chain"
|
||||
assert not window.act_recompute.isChecked(), (
|
||||
"declining analysis setup must leave the switch off"
|
||||
)
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
@@ -2613,7 +2631,7 @@ def test_a_video_inside_a_recording_opens_the_recording_and_plays(
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert window._play_timer.isActive(), "pressing 回放 did not start playback"
|
||||
window._pause_playback()
|
||||
window.replay_ctl.pause_playback()
|
||||
window.close()
|
||||
|
||||
|
||||
|
||||
@@ -146,6 +146,9 @@ class _Replay:
|
||||
def sync_actions(self) -> None:
|
||||
self.synced += 1
|
||||
|
||||
def set_active_view(self, preset: str) -> None:
|
||||
self.active_view = str(preset)
|
||||
|
||||
|
||||
class _Host:
|
||||
def __init__(self, prefs: dict) -> None:
|
||||
@@ -155,19 +158,19 @@ class _Host:
|
||||
self.rail_updates = 0
|
||||
self.source_kind = "live"
|
||||
|
||||
def _persist_prefs(self) -> None:
|
||||
def persist_prefs(self) -> None:
|
||||
self.persisted += 1
|
||||
|
||||
def _update_run_enabled(self) -> None:
|
||||
def update_run_enabled(self) -> None:
|
||||
self.run_updates += 1
|
||||
|
||||
def _refresh_status_rail(self) -> None:
|
||||
def refresh_status_rail(self) -> None:
|
||||
self.rail_updates += 1
|
||||
|
||||
def _current_source_kind(self) -> str:
|
||||
def current_source_kind(self) -> str:
|
||||
return self.source_kind
|
||||
|
||||
def _device_geometry_provider(self):
|
||||
def device_geometry_provider(self):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -3220,11 +3220,57 @@ def main() -> int:
|
||||
app.processEvents()
|
||||
other_window.brand_bar.btn_playback.click()
|
||||
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(
|
||||
other_window._load_replay_video(bare_video) is True,
|
||||
"a playable video with no recording beside it was refused",
|
||||
)
|
||||
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(
|
||||
other_window.replay.has_video() and other_window.replay.is_video_only(),
|
||||
"the video is not playable as a video",
|
||||
@@ -3268,7 +3314,7 @@ def main() -> int:
|
||||
other_window.act_recompute.isEnabled(),
|
||||
"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()
|
||||
other_window.close()
|
||||
app.processEvents()
|
||||
@@ -3569,41 +3615,39 @@ def main() -> int:
|
||||
f"{replay_window.arrow_panel.view_modes()}",
|
||||
)
|
||||
|
||||
# The recompute mode: off (default) the charts read the recorded CSV; on, the
|
||||
# recorded video is run through the pipeline instead. The run itself is not
|
||||
# started here (that needs the SDK), only the wiring around it.
|
||||
# 「从视频重算」 shares on_analysis_requested with opening a recording — no
|
||||
# acquisition chain. Charts keep the recorded CSV; analysis feeds 2D/3D.
|
||||
started: list = []
|
||||
asked: list = []
|
||||
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)
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
check(asked == [True], "the recompute switch did not ask for analysis")
|
||||
check(started == [], "the recompute switch opened an acquisition chain")
|
||||
check(
|
||||
started == [True],
|
||||
"the recompute mode did not hand the video to the pipeline",
|
||||
replay_plots.is_recording_source(),
|
||||
"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)
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
check(
|
||||
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(
|
||||
replay_window.toolbar.source_mode() == "camera" or not replay_window._running,
|
||||
"the recompute pass was left running",
|
||||
not replay_window._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
|
||||
# the bar: the replay stays available, it just stops being on screen.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Sensor catalog helpers.
|
||||
|
||||
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
|
||||
user's own bundles go on this machine.
|
||||
bundles exist, what each one is (name, origin, calibration selection and the SDK's verdict), and
|
||||
where a user's own bundles go on this machine.
|
||||
"""
|
||||
|
||||
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_FALLBACK,
|
||||
ROOT_KIND_SIBLING,
|
||||
SHAPE_COMPATIBILITY,
|
||||
SHAPE_DESCRIPTOR,
|
||||
SHAPE_UNREADABLE,
|
||||
STATE_UNREADABLE,
|
||||
CatalogEntry,
|
||||
CatalogRoot,
|
||||
@@ -33,6 +36,9 @@ __all__ = [
|
||||
"ROOT_KIND_EXPLICIT",
|
||||
"ROOT_KIND_FALLBACK",
|
||||
"ROOT_KIND_SIBLING",
|
||||
"SHAPE_COMPATIBILITY",
|
||||
"SHAPE_DESCRIPTOR",
|
||||
"SHAPE_UNREADABLE",
|
||||
"STATE_UNREADABLE",
|
||||
"CatalogEntry",
|
||||
"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
|
||||
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
|
||||
@@ -43,7 +50,7 @@ from orisys_app_shared.paths import (
|
||||
)
|
||||
|
||||
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.
|
||||
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``).
|
||||
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"
|
||||
|
||||
|
||||
@@ -89,6 +135,18 @@ def _sort_key(path: Path) -> str:
|
||||
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)
|
||||
class UserRootDecision:
|
||||
"""Where the writable user bundles live, and why."""
|
||||
@@ -139,8 +197,11 @@ class CatalogEntry:
|
||||
valid: bool
|
||||
state: str
|
||||
reason: str = ""
|
||||
errors: tuple[str, ...] = ()
|
||||
warnings: tuple[str, ...] = ()
|
||||
#: The SDK's own findings, kept as issues rather than as rendered strings: the tooltip owes
|
||||
#: 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, ...] = ()
|
||||
#: ``origin/folder`` of every other bundle declaring this entry's ``profile_id``.
|
||||
duplicates: tuple[str, ...] = ()
|
||||
@@ -156,6 +217,60 @@ class CatalogEntry:
|
||||
"""The family group this entry is listed under; never empty."""
|
||||
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
|
||||
def duplicate(self) -> bool:
|
||||
return bool(self.duplicates)
|
||||
@@ -178,18 +293,39 @@ class CatalogEntry:
|
||||
return None
|
||||
|
||||
def label(self) -> str:
|
||||
"""One line for a picker item: family, profile, origin and validity."""
|
||||
return f"{self.family} · {self.profile_id or self.folder} · {self.origin} · {self.state}"
|
||||
"""One line for a picker item: what it is, where it came from, what it will give.
|
||||
|
||||
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:
|
||||
"""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 = [
|
||||
f"{self.display_name} ({self.ref})",
|
||||
f"{self.name} ({self.ref}) — {self.shape_note}",
|
||||
f"profile_id: {self.profile_id or 'unknown'}",
|
||||
f"product family: {self.product_family or 'unknown'}",
|
||||
f"processing backend: {self.processing_backend or 'unknown'}",
|
||||
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
|
||||
if blocked:
|
||||
@@ -197,9 +333,11 @@ class CatalogEntry:
|
||||
# The reason already quotes the errors it was built from; repeating them here would
|
||||
# bury the one line that explains the entry.
|
||||
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)
|
||||
|
||||
|
||||
@@ -436,7 +574,10 @@ def _resolved_entry(
|
||||
) -> CatalogEntry:
|
||||
"""An entry the SDK resolved, valid or rejected, with its verdict kept whole."""
|
||||
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(
|
||||
key=str(profile.sensor_json_path),
|
||||
folder=folder.name,
|
||||
@@ -449,9 +590,9 @@ def _resolved_entry(
|
||||
processing_backend=profile.processing_backend,
|
||||
valid=validation.usable,
|
||||
state=validation.state,
|
||||
reason="; ".join(errors),
|
||||
reason="; ".join(str(issue) for issue in errors),
|
||||
errors=errors,
|
||||
warnings=tuple(str(issue) for issue in validation.warnings),
|
||||
warnings=tuple(validation.warnings),
|
||||
diagnostics=tuple(profile.diagnostics),
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
``manifest.json``, a bundle whose declared asset is missing, and two bundles sharing one
|
||||
``profile_id``.
|
||||
``manifest.json``, a bundle whose declared asset is missing, a bundle with a calibration
|
||||
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
|
||||
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
|
||||
@@ -33,6 +37,19 @@ from orisys_app_shared.sensors import (
|
||||
CAMERA_OK = {"resolution_raw": [640, 480], "fps": 30, "resolution_stitch": [100, 100]}
|
||||
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(
|
||||
*, 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] = {
|
||||
"schema_version": 1,
|
||||
"profile_id": profile_id,
|
||||
"display_name": profile_id,
|
||||
"display_name": "Mini Lab",
|
||||
"product_family": "mini",
|
||||
"processing_backend": "single_camera",
|
||||
"sensor_json": "sensor.json",
|
||||
@@ -93,11 +110,16 @@ def trees(tmp_path):
|
||||
user / "missing-assets" / "manifest.json",
|
||||
_descriptor(
|
||||
"orisys.finger.missing-r1",
|
||||
display_name="Finger R01 (map missing)",
|
||||
product_family="finger",
|
||||
processing_backend="uv_unroll",
|
||||
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.
|
||||
for name in ("dup-a", "dup-b"):
|
||||
_write(user / name / "sensor.json", _config())
|
||||
@@ -113,6 +135,7 @@ def test_every_bundle_shape_is_discovered_with_its_origin(trees):
|
||||
"shipped/box",
|
||||
"shipped/calib-only",
|
||||
"user/mini-lab",
|
||||
"user/mini-cal",
|
||||
"user/broken-manifest",
|
||||
"user/missing-assets",
|
||||
"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
|
||||
|
||||
|
||||
def test_the_label_states_family_profile_origin_and_validity(trees):
|
||||
entry = trees.by_ref("user/missing-assets")
|
||||
def test_the_label_states_what_it_is_where_it_came_from_and_what_it_gives(trees):
|
||||
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()
|
||||
assert "finger" in label
|
||||
assert entry.profile_id in label
|
||||
assert ORIGIN_USER in label
|
||||
assert entry.state in 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_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():
|
||||
|
||||
Reference in New Issue
Block a user