删除深度重算引擎;修好回放视频每帧重开文件导致的无法播放,运输控制改到状态轨主按钮 - What: 1) 彻底删除深度重算引擎:shared/.../record/depth.py(DepthRecomputer/DepthResult)与 depth_backends.py(BoxReplay/FingerReplay)、其专属测试 test_depth_replay.py,record/__init__ 不再导出,playback.py::replay_warnings 不再报 depth_recomputed;apps/studio/replay.py 由 515 行瘦身为只做读盘+游标+视频帧+提示,回放视图的 2D 面板只提供「原始图像」;session.json 的 depth.replay 由 recompute 改为 none;删除 31 个只剩深度使用的文案键(depth_*/warn_state_*/warn_reset_*),warn_config_changed 与 warn_sdk_changed 改为描述"由视频重算";ui_check 深度段、studio 两个深度用例、test_playback 三例、drive_shared 的 test_depth_replay 与 reproduce.py 的深度段一并清理(shared 套件 150→134 项)。2) 无法播放的根因是 RecordingReader.video_frame_at 每帧都 cv2.VideoCapture 重开并 seek:小片段已 ~10 ms/帧,1080p H.264 每次 seek 需从关键帧解码(上百 ms),而播放每 33 ms 要一帧且全在 UI 线程上,视频因此看着不动;改为按分段缓存解码器、顺序帧用 read() 步进(实测 10 ms → 0.5 ms/帧)、跳转才 seek,新增 RecordingReader.close() 释放。运输控制按用户建议上移到状态轨主按钮:toolbar.set_run_role("playback") 使回放视图的主按钮变成「▶ 回放」/「⏸ 暂停」(实时视图仍是「▶ 运行」),点按以 1× 推进游标(墙钟计时,漏拍不变慢)、到末尾自动停,拖动游标/框选/滚轮/拖总览条经 ForcePlotsPanel.timeline_taken_over 自动暂停,空格键同效;曲线表头不再放第二个播放按钮;按钮样式在回放角色下不再套用"停止"的红色语义。 - Why: 用户要求先清掉深度重算引擎、并反馈"现在无法播放视频",建议把上方播放按钮在历史回放形式下改为回放。 - Verification: ui_check.py 554 项 0 失败(新增 seek<250ms/步进<60ms 的解码预算、回放按钮文字随播放/暂停切换、手动拖游标即暂停、切回实时视图按钮恢复运行、按深度视图已移除);test_ui_smoke.py 71 项 0 失败;drive_shared.py 134 项 0 失败(深度引擎用例随代码删除);locale_studio.py 262 键齐全、zh==en;reproduce.py 退出 0;.repro/probe_play.py 实测按「回放」后 0.75 s 内游标推进 0.689 s、面板 12/12 帧都在更新;两张复核图(回放视图的「▶ 回放」、实时视图的「▶ 运行」)。 - Not in this change: 录制文件格式与 CSV 列序不变(raw_*.mp4 与 forces.csv 继续一起写);「由视频重算」复核模式保留(它复用实时管线,是有状态、只能顺序前进的一遍)。
This commit is contained in:
@@ -77,6 +77,11 @@
|
||||
"run_unavailable": "Not available yet",
|
||||
"run_disabled_video": "Select a video file first",
|
||||
"run_disabled_sensor": "Run is not available for this sensor type",
|
||||
"playback": "▶ Play",
|
||||
"playback_pause": "⏸ Pause",
|
||||
"playback_tip": "Play this recording at 1× (space bar does the same); dragging the cursor pauses",
|
||||
"playback_pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)",
|
||||
"playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)",
|
||||
"reset": "Reset",
|
||||
"units": "Units",
|
||||
"units_newton": "Newton",
|
||||
@@ -142,10 +147,6 @@
|
||||
"fx_name": "Shear X",
|
||||
"fy_name": "Shear Y",
|
||||
"window_readout": "{start} – {end} s / {total} s total",
|
||||
"play": "Play",
|
||||
"play_tip": "Play this recording from the cursor at 1× (space bar); dragging the cursor pauses",
|
||||
"pause": "Pause",
|
||||
"pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)",
|
||||
"window_tip": "Drag the highlighted region to move the window: this strip is the timeline of the whole session",
|
||||
"no_data": "—",
|
||||
"mode_follow": "Following live",
|
||||
@@ -180,37 +181,14 @@
|
||||
"none_found": "No recording found under outputs/recordings",
|
||||
"frame_dropped": "This frame never reached the video (the record queue overflowed)",
|
||||
"frame_unreadable": "This frame could not be decoded from the video",
|
||||
"depth_running": "Recomputing depth… (on a worker thread)",
|
||||
"depth_busy": "The previous recomputation is still running",
|
||||
"depth_empty": "No depth data for this frame",
|
||||
"depth_failed": "Depth recomputation failed: {detail}",
|
||||
"depth_sdk_unavailable": "Cannot recompute: the SDK is not available",
|
||||
"depth_config_missing": "Cannot recompute: the recording has no sensor config reference",
|
||||
"depth_config_file_missing": "Cannot recompute: the config file no longer exists at that path",
|
||||
"depth_no_video": "Cannot recompute: this recording has no video segment",
|
||||
"depth_not_recomputed": "Depth has not been recomputed for this cursor position\n(the previous frame's map is not reused)",
|
||||
"depth_recomputing": "Recomputing depth for the current cursor position…",
|
||||
"depth_frame_not_recorded": "This frame was not recorded: its data row was lost to a full queue and only the index row remains",
|
||||
"depth_frame_not_in_video": "This frame never reached the video, so its depth cannot be recomputed",
|
||||
"depth_state_across_segment": "Replay cannot carry the algorithm state across segments: this one restarts at its first frame",
|
||||
"depth_state_approximate": "No reset or pre-roll history for this segment: the algorithm state is an approximation",
|
||||
"depth_warnings": "Recomputation notes: {detail}",
|
||||
"warnings": "Replay notes: {detail}",
|
||||
"decimated": "This recording is long, so the curves are a decimated view ({shown} of {total} rows; intervals are read from disk on demand)",
|
||||
"warn_config_changed": "the config differs from recording time, so the recomputed depth may differ",
|
||||
"warn_sdk_changed": "the SDK version differs from recording time, so the recomputation may differ",
|
||||
"warn_config_changed": "the config differs from recording time, so recomputing from the video may differ",
|
||||
"warn_sdk_changed": "the SDK version differs from recording time, so recomputing may differ",
|
||||
"warn_config_missing": "no config hash was recorded, so recomputation cannot be verified",
|
||||
"warn_depth_recomputed": "depth comes from offline recomputation, not from stored data",
|
||||
"warn_rows_skipped": "some unparsable CSV rows were skipped",
|
||||
"warn_frames_dropped": "frames were lost to a full queue; only their index rows remain (shown as \"not recorded\")",
|
||||
"warn_out_of_order": "this recording was written by an older version: its rows are not in time order, so the replay re-sorts them and window edges may differ",
|
||||
"warn_state_across_segment": "the algorithm state cannot be carried across a segment",
|
||||
"warn_state_approximate": "the algorithm state is an approximation",
|
||||
"warn_state_approximate_after_rewind": "the video was reopened to reach an earlier frame, so the state may differ",
|
||||
"warn_state_too_far_back": "the zeroing is too far back: reading there would exceed the replay budget, so the state is approximate",
|
||||
"warn_reset_frame_not_indexed": "the zeroing frame is not in the frame index, so the state cannot be rebuilt from it",
|
||||
"warn_reset_frame_not_in_video": "the zeroing frame never reached the video, so the state cannot be rebuilt from it",
|
||||
"warn_reset_not_applied": "the replay could not re-apply the zeroing, so the state may differ"
|
||||
"warn_out_of_order": "this recording was written by an older version: its rows are not in time order, so the replay re-sorts them and window edges may differ"
|
||||
},
|
||||
"record": {
|
||||
"label": "Record",
|
||||
|
||||
@@ -77,6 +77,11 @@
|
||||
"run_unavailable": "当前不可运行",
|
||||
"run_disabled_video": "请先选择一个视频文件",
|
||||
"run_disabled_sensor": "此传感器类型暂不支持运行",
|
||||
"playback": "▶ 回放",
|
||||
"playback_pause": "⏸ 暂停",
|
||||
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
|
||||
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
||||
"playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)",
|
||||
"reset": "重置",
|
||||
"units": "单位",
|
||||
"units_newton": "牛顿",
|
||||
@@ -142,10 +147,6 @@
|
||||
"fx_name": "切向力 X",
|
||||
"fy_name": "切向力 Y",
|
||||
"window_readout": "{start} – {end} s / 全程 {total} s",
|
||||
"play": "播放",
|
||||
"play_tip": "从游标处按 1× 播放这段录制(空格键);拖动游标即暂停",
|
||||
"pause": "暂停",
|
||||
"pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
||||
"window_tip": "拖动高亮区域即可移动视窗;这是整段会话的时间轴",
|
||||
"no_data": "—",
|
||||
"mode_follow": "跟随实时",
|
||||
@@ -180,37 +181,14 @@
|
||||
"none_found": "outputs/recordings 下没有找到录制",
|
||||
"frame_dropped": "该帧未被写入视频(记录队列曾溢出)",
|
||||
"frame_unreadable": "该帧无法从视频中解码",
|
||||
"depth_running": "正在重算深度…(在后台线程)",
|
||||
"depth_busy": "上一次重算尚未完成",
|
||||
"depth_empty": "该帧没有深度数据",
|
||||
"depth_failed": "深度重算失败: {detail}",
|
||||
"depth_sdk_unavailable": "无法重算:SDK 不可用",
|
||||
"depth_config_missing": "无法重算:录制未记录传感器配置",
|
||||
"depth_config_file_missing": "无法重算:配置文件的原始路径已不存在",
|
||||
"depth_no_video": "无法重算:该录制没有视频分段",
|
||||
"depth_not_recomputed": "当前游标位置尚未重算深度\n(不会沿用其他帧的结果)",
|
||||
"depth_recomputing": "正在为当前游标位置重算深度…",
|
||||
"depth_frame_not_recorded": "该帧未录制:数据行在队列溢出时丢失,只有索引记录",
|
||||
"depth_frame_not_in_video": "该帧未写入视频,无法重算深度",
|
||||
"depth_state_across_segment": "重算无法跨分段延续算法状态:本段从段首重放",
|
||||
"depth_state_approximate": "本段缺少归零或预卷历史,算法参考状态为近似重建",
|
||||
"depth_warnings": "重算提示: {detail}",
|
||||
"warnings": "回放提示: {detail}",
|
||||
"decimated": "该录制较长,曲线为降采样视图(显示 {shown} / 共 {total} 行;区间数据按需从磁盘读取)",
|
||||
"warn_config_changed": "配置文件与录制时不一致,重算出的深度可能与当时不同",
|
||||
"warn_sdk_changed": "SDK 版本与录制时不一致,重算结果可能不同",
|
||||
"warn_config_changed": "配置文件与录制时不一致:由视频重算的结果可能与当时不同",
|
||||
"warn_sdk_changed": "SDK 版本与录制时不一致:由视频重算的结果可能不同",
|
||||
"warn_config_missing": "找不到配置哈希,无法校验重算一致性",
|
||||
"warn_depth_recomputed": "深度由离线重算得到,不是当时保存的数据",
|
||||
"warn_rows_skipped": "有无法解析的 CSV 行被跳过",
|
||||
"warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)",
|
||||
"warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同",
|
||||
"warn_state_across_segment": "重算不能跨分段延续算法状态",
|
||||
"warn_state_approximate": "算法参考状态为近似重建",
|
||||
"warn_state_approximate_after_rewind": "为回到更早的帧重新打开了视频,参考状态可能偏离当时",
|
||||
"warn_state_too_far_back": "归零点过于久远,需要读取的帧数超出上限,已按近似状态重建",
|
||||
"warn_reset_frame_not_indexed": "归零帧不在帧索引中,无法从该点重建状态",
|
||||
"warn_reset_frame_not_in_video": "归零帧未写入视频,无法从该点重建状态",
|
||||
"warn_reset_not_applied": "重放无法重新执行归零,参考状态可能偏离当时"
|
||||
"warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同"
|
||||
},
|
||||
"record": {
|
||||
"label": "记录",
|
||||
|
||||
@@ -273,37 +273,28 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
|
||||
(`calibration_session_*.json`,`force/calibration_session.py`),并可用「打开标定记录」
|
||||
重新载入检查。界面上的降采样 trace 只是预览,不是审计数据。
|
||||
|
||||
### 7.5 深度重算与游标同步(P1-3、P1-4)
|
||||
### 7.5 深度重算引擎(已删除)
|
||||
|
||||
- **不再重开回放位置**:`DepthRecomputer` 记录当前已读到的段内帧号,请求第 2 帧再请求第 3
|
||||
帧只多读 1 帧(旧实现每次都从头读,2 和 3 会落到 5);往回退才重开视频。
|
||||
- 每帧都执行**实时同一条链路**:盒子 `compute_deformation + compute_contact`,手指再加上
|
||||
`FingerRuntime` 的展开与有效区域(`_BoxReplay` / `_FingerReplay`)。
|
||||
- `config_name` 用**完整解析后的配置路径**(`load_fixed_mode`),不再缩减成父目录名或
|
||||
stem —— 那可能加载同名但不同的配置,而哈希校验还在比对原路径。
|
||||
- **算法参考状态**:SDK 的跟踪器/滤波是有状态的,离线复现需要历史。两条可用依据:
|
||||
1. 操作归零(`zeroing` 事件,带帧号)会清空跟踪器,因此从该帧重放是**精确**的;
|
||||
2. 录制开始前采集线程保留 `PREROLL_FRAMES`(30 帧,且不超过 192 MiB)的预处理帧,
|
||||
录制开始时写入 `preroll_0001.mp4`,`session.json` 记 `preroll_frames` /
|
||||
`preroll_segment`,回放先把它们喂给 SDK 再进入首帧。
|
||||
都不满足时(跨分段、无归零、无预卷)仍然重算,但结果带
|
||||
`state_approximate` / `state_across_segment` 提示,**不假装与当时一致**。
|
||||
- 回放游标与深度结果按**请求序号 + 帧号**绑定:等待期间移动游标会作废旧请求,结果回来
|
||||
只渲染属于当前帧的那一张;否则面板显示「当前游标位置尚未重算深度」,绝不复用旧图。
|
||||
这一节记录的 `DepthRecomputer` / `depth_backends`(离线把录制视频喂回 SDK 重算深度、按
|
||||
帧号增量读、归零/预卷恢复算法状态、请求绑定帧号)**已整体删除**:用户明确不需要"为回放再
|
||||
录/再算一遍深度"这条路径,回放视图只放录制的原始相机图像。要复核算法的当前做法是
|
||||
「视图 → 由视频重算力值」(把录制视频跑一遍采集管线,见 `docs/shell-lifecycle.md` §2.1),
|
||||
它复用实时那条链路,因此不再需要一份离线复现实现。
|
||||
|
||||
`session.json` 里的深度块保留(它说明"深度没有保存"这一事实),但 `replay` 字段改为
|
||||
`"none"`;`RecordingReader.replay_warnings()` 不再报 `depth_recomputed`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 同步回放与深度重算
|
||||
## 7. 同步回放与深度(不保存深度)
|
||||
|
||||
**决定:不保存深度。** 回放时按录像与配置**离线重算**。
|
||||
|
||||
理由是体积:30 Hz、320×240 的 float32 深度每帧约 300 KB,一小时就是 ~16 GB;
|
||||
即使降频到 1 Hz 也要百兆级。所以 `session.json` 里明确写:
|
||||
**决定:不保存深度。** 理由是体积:30 Hz、320×240 的 float32 深度每帧约 300 KB,
|
||||
一小时就是 ~16 GB;即使降频到 1 Hz 也要百兆级。所以 `session.json` 里明确写:
|
||||
|
||||
```json
|
||||
"depth": {
|
||||
"stored": false,
|
||||
"replay": "recompute",
|
||||
"replay": "none",
|
||||
"quantity": "relative deformation / algorithm response, not a calibrated length",
|
||||
"method": "re-run the SDK over raw_*.mp4 with this config_ref",
|
||||
"config_sha256": "...",
|
||||
@@ -311,23 +302,26 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
|
||||
}
|
||||
```
|
||||
|
||||
代价是重算要 SDK 能复现同样结果,因此 `RecordingReader.replay_warnings()` 会主动
|
||||
比对**当前配置文件哈希**与录制时的哈希、以及 SDK 版本,回放窗口顶部把这些提示出来:
|
||||
因此 `RecordingReader.replay_warnings()` 会比对**当前配置文件哈希**与录制时的哈希、以及
|
||||
SDK 版本,「由视频重算」前这些提示必须让操作者看到(回放视图里放在来源摘要的 tooltip 与
|
||||
一条 6 秒通知里):
|
||||
|
||||
| 提示 | 含义 |
|
||||
|---|---|
|
||||
| `depth_recomputed` | 深度来自离线重算,不是当时保存的数据(始终提示) |
|
||||
| `config_changed` | 配置文件已变,重算结果可能与当时不同 |
|
||||
| `config_changed` | 配置文件已变,由视频重算的结果可能与当时不同 |
|
||||
| `sdk_changed` | SDK 版本已变,重算结果可能不同 |
|
||||
| `config_missing` | 缺少配置哈希,无法校验一致性 |
|
||||
| `rows_skipped` | 有无法解析的 CSV 行被跳过 |
|
||||
| `frames_dropped` | 有帧在队列溢出时丢失,只留索引行 |
|
||||
| `out_of_order` | 旧版本写入的行序不是时间序 |
|
||||
|
||||
### 回放界面
|
||||
|
||||
回放**与实时是同一套界面**:`ui/playback_dialog.py` 与 `ui/replay_bar.py` 都已删除。
|
||||
`replay.py::ReplayController` 只读录制并发信号,主窗口把它接到同一套面板上——曲线表头的
|
||||
播放按钮就是播放头,来源槽在回放视图里换成「选择录制视频…」。布局、单位、看点与关闭流程
|
||||
见 `docs/shell-lifecycle.md` §2(含「由视频重算」复核模式)。
|
||||
回放**与实时是同一套界面**:`ui/playback_dialog.py`、`ui/replay_bar.py` 与深度重算引擎都
|
||||
已删除。`replay.py::ReplayController` 只读录制并发信号,主窗口把它接到同一套面板上——
|
||||
状态轨的主按钮在回放视图里变成「▶ 回放 / ⏸ 暂停」(`toolbar.set_run_role`),来源槽换成
|
||||
「选择录制视频…」。布局、单位、看点与关闭流程见 `docs/shell-lifecycle.md` §2
|
||||
(含「由视频重算」复核模式)。
|
||||
|
||||
- **一个游标驱动三处**:移动位置同时更新视频帧与三张曲线的游标线;并且通过 `cursor_moved`
|
||||
信号同步到主界面的曲线面板。
|
||||
@@ -339,10 +333,9 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
|
||||
原始计数的曲线旁边会写着实时的 `0.0`,看起来就像"回放根本没打开"。
|
||||
实时视图仍是"最新样本就是游标",所以跟随状态下不额外读盘。
|
||||
- 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。
|
||||
- **深度不随录制保存,界面也不再提供深度视图**(用户明确不需要这条回放路径);需要复核
|
||||
算法时用「由视频重算」,它把录制视频重新跑一遍管线(含深度、形变与接触)。离线深度重算
|
||||
引擎(`ReplayController.request_depth` / `DepthRecomputer`)保留在 `replay.py` 里并有
|
||||
独立测试,但已没有界面入口。
|
||||
- **解码器常开**(`RecordingReader.video_frame_at`):以前每帧重开文件 + seek,1080p 下
|
||||
每帧上百毫秒,30 fps 播放时视频根本不动;现在按分段缓存解码器、顺序帧 `read()` 步进
|
||||
(实测 0.5 ms/帧),跳转才 seek。
|
||||
|
||||
两个必须知晓的边界:
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| 视图 | 面板 | 曲线 | 比例(3D,2D,曲线,标定) |
|
||||
|---|---|---|---|
|
||||
| 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
|
||||
| 历史回放 `replay` | 2D 图像 + 力曲线 + **回放条** | 读打开的录制;未打开时仍是实时跟随 | `0, 320, 1120, 0` |
|
||||
| 历史回放 `replay` | 2D 图像 + 力曲线(**与实时同布局**) | 读打开的录制;未打开时仍是实时跟随 | `0, 360, 1080, 0` |
|
||||
| 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
|
||||
| 自由 `custom`(启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|---|---|
|
||||
| 实时监测 + 摄像机 | `摄像机 [编号] [搜索]` |
|
||||
| 实时监测 + 视频文件 | `使用摄像机` 按钮 + 摘要里的 `视频文件: xxx.mp4` |
|
||||
| 历史回放 | 不占位(回放条里有录制选择与路径) |
|
||||
| 历史回放 | 「选择录制视频…」按钮 + 摘要里的视频名 |
|
||||
|
||||
「用视频文件跑一次采集」仍从「文件 → 打开视频…」进入,`source_mode` / `video_path`
|
||||
偏好保留,但不再有一个需要先选「来源」才能用的下拉。
|
||||
@@ -61,10 +61,10 @@
|
||||
`ui/replay_bar.py`(进度条 + 录制目录选择)与 `PlaybackDialog` 都已删除。
|
||||
|
||||
- `apps/studio/src/orisys_studio/replay.py::ReplayController(QObject)`:录制读取、游标、
|
||||
离线深度重算引擎(daemon 线程 + 120 ms 轮询、请求绑定 `(_record_serial, seq, frame)`、
|
||||
旧结果一律丢弃;**界面已不提供深度视图**)、深度色标、回放提示(降采样 / 跨版本)。
|
||||
它不画任何东西,只发信号:`position_changed` / `frames_changed` /
|
||||
`status_changed` / `recording_changed`。
|
||||
回放提示(降采样 / 跨版本)。它不画任何东西,只发信号:`position_changed` /
|
||||
`frames_changed` / `status_changed` / `recording_changed`。深度重算引擎
|
||||
(`DepthRecomputer` / `depth_backends`)已随回放条一起删除——用户不需要这条回放路径,
|
||||
要复核算法就用「由视频重算」。
|
||||
- **选视频即选录制**:状态轨的「选择录制视频…」(`toolbar.btn_replay_video`)与
|
||||
「文件 → 打开视频…」都走 `_load_replay_video(path)`——视频所在目录就是录制目录,
|
||||
于是曲线自动配对同目录的 `forces.csv`。视频与 CSV 仍然同时记录(录制格式不变)。
|
||||
@@ -77,9 +77,15 @@
|
||||
- 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像」一项
|
||||
(`REPLAY_VIEW_MODES`;录制里没有光流,也没有存储深度),页脚显示「录制视频(原始相机
|
||||
图像)」并隐藏放大倍率与有效区域。
|
||||
- **游标就是播放头**:曲线表头的播放按钮(`chart.play` / `chart.pause`,编辑器习惯:
|
||||
空格键同效)以 1× 推进游标,到末尾自动停;拖动游标、框选、滚轮平移或拖总览条都会
|
||||
**自动暂停**(`ForcePlotsPanel.pause_playback`)。没有独立进度条。
|
||||
- **游标就是播放头,运输控制就是状态轨上那颗主按钮**:回放视图里
|
||||
`toolbar.set_run_role("playback")` 把它换成「▶ 回放」/「⏸ 暂停」(实时视图仍是「运行」),
|
||||
以 1× 推进游标,到末尾自动停;拖动游标、框选、滚轮平移或拖总览条都会**自动暂停**
|
||||
(`ForcePlotsPanel.timeline_taken_over` → `_pause_playback`)。空格键同效。没有独立进度条,
|
||||
曲线表头也不再放第二个播放按钮(一个运输控制,不会两处打架)。
|
||||
- **视频解码器常开**:`RecordingReader.video_frame_at` 以前每帧 `VideoCapture` 重开 + seek
|
||||
(小片段 10 ms,1080p H.264 上百 ms),30 fps 播放时每一拍都卡在 UI 线程上,视频看起来
|
||||
根本不动。现在按分段缓存一个解码器,**顺序帧用 `read()` 步进**(实测 0.5 ms/帧),
|
||||
跳转才 seek,`RecordingReader.close()` 负责释放。
|
||||
- **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时,
|
||||
三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放
|
||||
时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。
|
||||
@@ -106,7 +112,7 @@
|
||||
| 用途 | 看当时的记录 | 复核算法版本(mp4 有损,数值与原记录会有差异) |
|
||||
|
||||
打开它时会立即开始这一遍(`_on_recompute_toggled` → `force_plots.set_playing(True)` →
|
||||
`_on_chart_play_toggled` → `_start_run`,只有一条启动路径,避免按钮与菜单各启动一次);
|
||||
`_on_transport_toggled` → `_start_run`,只有一条启动路径,避免按钮与菜单各启动一次);
|
||||
关闭时停止该遍并挂回 CSV。`_replay_owns_image()` 在这一遍运行期间为 False,2D 面板交给
|
||||
管线自己画。
|
||||
|
||||
|
||||
@@ -180,15 +180,15 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
|
||||
| 品牌轨(`QFrame#brandBar`) | 28 px | logo + 字标 + 三个顶层视图(实时监测 / 历史回放 / 标定)+ 右侧产品名 |
|
||||
| 状态轨(`ToolbarWidget` 的 rail) | ~30 px | **传感器摘要按钮**、阶段、**当前视图的来源选择**(摄像机编号 + 搜索 / 使用摄像机)、标定状态、数据有效性、记录状态、记录/运行/重置/设置 |
|
||||
| 设置区(默认收起) | 0 / ~40 px | 单位、记录模式、预览 |
|
||||
| 回放条(仅历史回放视图) | ~50 px | 录制选择、录制信息、进度条、位置、单位、重算深度、提示行 |
|
||||
| ~~回放条~~(已取消) | 0 px | 回放视图与实时同布局:运输控制就是状态轨的主按钮(实时=运行,回放=回放/暂停) |
|
||||
|
||||
四点规则:
|
||||
|
||||
1. **传感器区是一个摘要按钮,不是两个下拉**。轨上只有 `BOX · box/sensor.json`;类型、配置
|
||||
与「当前生效的标定 / 最近导出的系数」都在它打开的 `SensorDialog` 里(见
|
||||
`docs/shell-lifecycle.md` §1.2)。摘要按钮属于状态轨,因此文本必须完整显示(见第 5 条)。
|
||||
2. **来源选择随视图**:实时视图显示摄像机编号 + 搜索;用视频文件采集时显示「使用摄像机」;
|
||||
回放视图不占位(回放条里有录制选择)。没有「来源」下拉,因此没有"先选来源才能用"的状态。
|
||||
2. **来源选择随视图**:实时视图显示摄像机编号 + 搜索;回放视图显示「选择录制视频…」,视频名
|
||||
写在来源摘要里(只在回放视图)。没有「来源」下拉,因此没有"先选来源才能用"的状态。
|
||||
3. **导航按钮在品牌底上取品牌前景色**。`navButton` 用 `brand_bar_fg_muted`,当前视图用
|
||||
`brand_bar_fg` + `navActive="true"`;两者都在 §2 的品牌底对比度审计里(7.67 / 12.94),
|
||||
不新增色值。
|
||||
|
||||
@@ -246,13 +246,13 @@ class StudioMainWindow(QMainWindow):
|
||||
|
||||
# Playback drives the *chart cursor* (the playhead) instead of a slider
|
||||
# under the panels: the two views share one layout, and the transport is
|
||||
# a single button on the chart header.
|
||||
# the rail's primary button — 「运行」while measuring, 「回放」while
|
||||
# replaying (see ``_on_run_toggled`` / ``toolbar.set_run_role``).
|
||||
self._play_timer = QTimer(self)
|
||||
self._play_timer.setInterval(PLAYBACK_TICK_MS)
|
||||
self._play_timer.timeout.connect(self._on_play_tick)
|
||||
self._play_started_at = 0.0
|
||||
self._play_from = 0.0
|
||||
self.force_plots.play_toggled.connect(self._on_chart_play_toggled)
|
||||
|
||||
self.setStatusBar(_NoticeBar())
|
||||
# The status bar carries **one-off notices** only (a recording finished, a
|
||||
@@ -526,12 +526,13 @@ class StudioMainWindow(QMainWindow):
|
||||
"device_sn": self._device_serial(info),
|
||||
},
|
||||
# Depth is deliberately NOT stored (decided with the team): it is a
|
||||
# function of the frame and the sensor configuration, so replay
|
||||
# recomputes it from raw_*.mp4. The hashes below are what a replay
|
||||
# checks before trusting that recomputation.
|
||||
# function of the frame and the sensor configuration, and the replay
|
||||
# view shows the recorded frames only. Re-running the pipeline over
|
||||
# raw_*.mp4 is the "recompute" mode's job; the hashes below are what
|
||||
# such a pass (and any other consumer) checks before trusting it.
|
||||
"depth": {
|
||||
"stored": False,
|
||||
"replay": "recompute",
|
||||
"replay": "none",
|
||||
"quantity": "relative deformation / algorithm response, not a calibrated length",
|
||||
"method": "re-run the SDK over raw_*.mp4 with this config_ref",
|
||||
"config_sha256": self._config_hash(config_ref),
|
||||
@@ -616,6 +617,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.replay.recording_changed.connect(self._on_recording_changed)
|
||||
# Dragging the shared cursor on the charts scrubs the recording too.
|
||||
self.force_plots.cursor_moved.connect(self._on_cursor_moved)
|
||||
self.force_plots.timeline_taken_over.connect(self._pause_playback)
|
||||
|
||||
def _on_cursor_moved(self, t_rel_s: float) -> None:
|
||||
if self._recompute_running:
|
||||
@@ -693,11 +695,12 @@ class StudioMainWindow(QMainWindow):
|
||||
segments = sorted(reader.segments()) if reader is not None else []
|
||||
if segments:
|
||||
self.toolbar.set_video_path(str(segments[0]))
|
||||
self.force_plots.set_playing(self._play_timer.isActive())
|
||||
self.toolbar.set_run_checked(self._play_timer.isActive())
|
||||
# Loading a recording is what makes a recompute pass possible at all.
|
||||
self.act_recompute.setEnabled(
|
||||
self._view_preset == "replay" and self.replay.is_loaded()
|
||||
)
|
||||
self._update_run_enabled()
|
||||
self._refresh_status_rail()
|
||||
|
||||
def _on_replay_position(self, position_s: float) -> None:
|
||||
@@ -708,8 +711,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self._set_display_unit("raw" if self.force_plots.unit() == "N" else "N")
|
||||
|
||||
# ------------------------------------------------------------- playback
|
||||
def _on_chart_play_toggled(self, playing: bool) -> None:
|
||||
"""The transport button on the chart header, like an editor's play/pause."""
|
||||
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
|
||||
@@ -719,7 +722,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self._start_run()
|
||||
elif self._recompute_running:
|
||||
self._stop_run(reason="user_stop")
|
||||
self.force_plots.set_playing(bool(playing) and self._running)
|
||||
self.toolbar.set_run_checked(bool(playing) and self._running)
|
||||
return
|
||||
if playing and self.replay.is_loaded():
|
||||
self._start_playback()
|
||||
@@ -729,7 +732,7 @@ class StudioMainWindow(QMainWindow):
|
||||
def _start_playback(self) -> None:
|
||||
duration = self.replay.duration_s()
|
||||
if duration <= 0:
|
||||
self.force_plots.set_playing(False)
|
||||
self.toolbar.set_run_checked(False)
|
||||
return
|
||||
position = self.replay.position()
|
||||
if position >= duration - 0.02:
|
||||
@@ -739,12 +742,15 @@ class StudioMainWindow(QMainWindow):
|
||||
self._play_from = float(position)
|
||||
self._play_started_at = time.perf_counter()
|
||||
self._play_timer.start()
|
||||
self.force_plots.set_playing(True)
|
||||
self.toolbar.set_run_checked(True)
|
||||
self.toolbar.refresh_labels()
|
||||
|
||||
def _pause_playback(self) -> None:
|
||||
if self._play_timer.isActive():
|
||||
self._play_timer.stop()
|
||||
self.force_plots.set_playing(False)
|
||||
if self.toolbar.btn_run.isChecked():
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.toolbar.refresh_labels()
|
||||
|
||||
def _on_play_tick(self) -> None:
|
||||
"""Advance the playhead from the wall clock, not from the tick count."""
|
||||
@@ -809,7 +815,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self._recompute_running = True
|
||||
# Playing is what starts the pass: one path in, so the button and the
|
||||
# menu cannot start it twice.
|
||||
self.force_plots.set_playing(True)
|
||||
self.toolbar.set_run_checked(True)
|
||||
self.toolbar.refresh_labels()
|
||||
self._on_transport_toggled(True)
|
||||
return
|
||||
if self._recompute_running:
|
||||
self._stop_run(reason="user_stop")
|
||||
@@ -831,18 +839,6 @@ class StudioMainWindow(QMainWindow):
|
||||
else:
|
||||
self.arrow_panel.show_frame(image)
|
||||
|
||||
def _replay_depth_scale_text(self) -> str:
|
||||
"""The depth colour scale, latched per recording by the controller.
|
||||
|
||||
Kept for the offline depth engine (``ReplayController.depth_image``); the
|
||||
replay view itself no longer offers a depth pane, because depth is not
|
||||
stored with a recording.
|
||||
"""
|
||||
span = self.replay.depth_range()
|
||||
if span is None:
|
||||
return ""
|
||||
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
|
||||
|
||||
def _apply_replay_source(self, active: bool) -> None:
|
||||
"""Point the charts at the recording (or back at the live session)."""
|
||||
if active and self.replay.is_loaded():
|
||||
@@ -1152,6 +1148,9 @@ class StudioMainWindow(QMainWindow):
|
||||
# visible difference between the two views.
|
||||
self._pause_playback()
|
||||
self.toolbar.set_source_view("replay" if preset == "replay" else "live")
|
||||
# The rail's primary button is this view's main action: acquire while
|
||||
# measuring, play back while replaying.
|
||||
self.toolbar.set_run_role("playback" if preset == "replay" else "acquire")
|
||||
self._apply_panel_visibility()
|
||||
if apply_sizes and preset in VIEW_SPLITTER_SIZES:
|
||||
self.content_splitter.setSizes(list(VIEW_SPLITTER_SIZES[preset]))
|
||||
@@ -1168,6 +1167,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self._on_recompute_toggled(False)
|
||||
self.brand_bar.set_active_view(preset if preset != "custom" else None)
|
||||
self._sync_menu_checks()
|
||||
self._update_run_enabled()
|
||||
self._refresh_status_rail()
|
||||
if persist:
|
||||
self._persist_prefs()
|
||||
@@ -1176,6 +1176,22 @@ class StudioMainWindow(QMainWindow):
|
||||
return self._view_preset
|
||||
|
||||
def _update_run_enabled(self) -> None:
|
||||
if self.toolbar.run_role() == "playback":
|
||||
# In the replay view the button is a transport: it needs a recording,
|
||||
# not a runnable sensor. While it plays it stays enabled so it can be
|
||||
# paused.
|
||||
loaded = self.replay.is_loaded()
|
||||
self.toolbar.set_run_tooltip_reason(
|
||||
"" if loaded else t("toolbar.playback_disabled")
|
||||
)
|
||||
self.toolbar.set_run_enabled(loaded or self._play_timer.isActive())
|
||||
self.toolbar.set_record_enabled(
|
||||
self._running or self.recorder.is_recording
|
||||
)
|
||||
if not self._running:
|
||||
self.toolbar.refresh_labels()
|
||||
self._sync_shortcut_actions()
|
||||
return
|
||||
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
|
||||
source_ok = True
|
||||
reason = ""
|
||||
@@ -1809,9 +1825,9 @@ class StudioMainWindow(QMainWindow):
|
||||
|
||||
def _on_play_shortcut(self) -> None:
|
||||
"""Space plays or pauses the recording, if there is one to move through."""
|
||||
if not self.force_plots.btn_play.isEnabled():
|
||||
if self.toolbar.run_role() != "playback" or not self.toolbar.btn_run.isEnabled():
|
||||
return
|
||||
self.force_plots.btn_play.setChecked(not self.force_plots.btn_play.isChecked())
|
||||
self.toolbar.btn_run.setChecked(not self.toolbar.btn_run.isChecked())
|
||||
|
||||
def _on_run_shortcut(self) -> None:
|
||||
if not self.toolbar.btn_run.isEnabled():
|
||||
@@ -1953,6 +1969,10 @@ class StudioMainWindow(QMainWindow):
|
||||
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
|
||||
|
||||
def _on_run_toggled(self, checked: bool) -> None:
|
||||
if self.toolbar.run_role() == "playback":
|
||||
# The primary button of the replay view is its transport.
|
||||
self._on_transport_toggled(bool(checked))
|
||||
return
|
||||
if checked:
|
||||
self._start_run()
|
||||
else:
|
||||
@@ -2072,7 +2092,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self._running = False
|
||||
# A recompute pass is over: the transport button goes back to "play".
|
||||
self._recompute_running = False
|
||||
self.force_plots.set_playing(False)
|
||||
if self.toolbar.run_role() == "playback" and self.toolbar.btn_run.isChecked():
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.toolbar.refresh_labels()
|
||||
self._set_phase("error" if reason == "failed" else "idle")
|
||||
self.cal_panel.set_running(False)
|
||||
self.toolbar.set_run_checked(False)
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
"""Replay engine: one recording, one cursor, and the depth it can recompute.
|
||||
"""Replay engine: one recording, one cursor, one frame at a time.
|
||||
|
||||
This is the headless half of the old replay window. It owns the recording
|
||||
reader, the cursor and the asynchronous depth recomputation, and it *shows*
|
||||
nothing: the main window renders the frames into the 2D panel, the curves read
|
||||
the recording through :class:`~orisys_app_shared.force.source.RecordingCurveSource`
|
||||
and the progress bar drives :meth:`set_position`.
|
||||
This is the headless half of the replay view. It owns the recording reader and
|
||||
the cursor and it *shows* nothing: the main window renders the frame into the 2D
|
||||
panel, the curves read the recording through
|
||||
:class:`~orisys_app_shared.force.source.RecordingCurveSource`, and the transport
|
||||
on the chart header drives :meth:`set_position`.
|
||||
|
||||
Depth is recomputed on demand rather than read from disk (see
|
||||
:mod:`orisys_app_shared.record.depth`), so the pane states plainly when it is
|
||||
unavailable instead of showing a stand-in image.
|
||||
There is no depth recomputation here: depth is not stored with a recording, the
|
||||
replay view shows the recorded frames, and re-running the pipeline over a video
|
||||
is the acquisition path's job (see the studio's "recompute" mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import QObject, QTimer, pyqtSignal
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
from PyQt5.QtGui import QImage
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record import (
|
||||
DepthRecomputer,
|
||||
DepthResult,
|
||||
RecordingReader,
|
||||
)
|
||||
from orisys_app_shared.record import RecordingReader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,14 +31,25 @@ def _bgr_to_qimage(frame: np.ndarray) -> QImage:
|
||||
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy()
|
||||
|
||||
|
||||
def _sdk_version() -> str:
|
||||
"""The SDK version in use, or "" when the SDK is not importable."""
|
||||
try:
|
||||
import orisys
|
||||
|
||||
return str(getattr(orisys, "__version__", "") or "")
|
||||
except Exception: # noqa: BLE001 - a missing SDK is a warning, not a crash
|
||||
logger.debug("Could not read the SDK version", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
class ReplayController(QObject):
|
||||
"""Open one recording and answer "what is at t?" for every view."""
|
||||
|
||||
#: The cursor moved; the progress bar and the charts' cursor follow.
|
||||
#: The cursor moved; the chart cursor and the 2D panel follow.
|
||||
position_changed = pyqtSignal(float)
|
||||
#: The frame under the cursor changed (video and/or depth).
|
||||
#: The frame under the cursor changed.
|
||||
frames_changed = pyqtSignal()
|
||||
#: Warnings, depth state or the loaded recording changed.
|
||||
#: The notes about this recording, or the loaded recording, changed.
|
||||
status_changed = pyqtSignal()
|
||||
#: A recording was opened (True) or the previous one was dropped (False).
|
||||
recording_changed = pyqtSignal(bool)
|
||||
@@ -52,51 +57,19 @@ class ReplayController(QObject):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._reader: RecordingReader | None = None
|
||||
self._recomputer: DepthRecomputer | None = None
|
||||
self._unit = "raw"
|
||||
self._position = 0.0
|
||||
self._depth_ok = False
|
||||
# A **daemon** thread, not a ThreadPoolExecutor: the SDK call can block
|
||||
# indefinitely and an executor's non-daemon worker would then keep the
|
||||
# whole process alive at exit. A blocked daemon thread cannot be
|
||||
# cancelled either, so it is left to die with the process.
|
||||
self._depth_thread: threading.Thread | None = None
|
||||
self._depth_result = None
|
||||
self._depth_error: BaseException | None = None
|
||||
#: Which cursor position each depth answer belongs to. Recomputation is
|
||||
#: asynchronous, so an answer can arrive after the cursor has moved; the
|
||||
#: request it answers is remembered and compared, never assumed.
|
||||
self._depth_request_seq = 0
|
||||
self._depth_pending_seq: int | None = None
|
||||
self._depth_pending_position: float | None = None
|
||||
self._depth_result_seq: int | None = None
|
||||
#: Which recording each answer belongs to (incremented on every open).
|
||||
self._record_serial = 0
|
||||
self._depth_pending_record: int | None = None
|
||||
self._depth_result_record: int | None = None
|
||||
self._depth_result_frame = -1
|
||||
#: Processors whose owning request was still running when it was retired.
|
||||
self._retired_recomputers: list = []
|
||||
#: The frame cache: one video image per cursor position.
|
||||
self._video_position: float | None = None
|
||||
self._video_qimage: QImage | None = None
|
||||
self._video_reason = ""
|
||||
self._depth_qimage: QImage | None = None
|
||||
self._depth_message = ""
|
||||
self._warnings = ""
|
||||
#: The depth colour scale, latched to the first recomputed frame of the
|
||||
#: recording so two frames stay comparable (same rule as the live view).
|
||||
self._depth_range: tuple[float, float] | None = None
|
||||
|
||||
self._depth_poll = QTimer(self)
|
||||
self._depth_poll.setInterval(120)
|
||||
self._depth_poll.timeout.connect(self._poll_depth)
|
||||
|
||||
# ------------------------------------------------------------- recording
|
||||
def is_loaded(self) -> bool:
|
||||
return self._reader is not None
|
||||
|
||||
def directory(self) -> Path | None:
|
||||
def directory(self):
|
||||
return self._reader.directory if self._reader is not None else None
|
||||
|
||||
def reader(self):
|
||||
@@ -113,82 +86,45 @@ class ReplayController(QObject):
|
||||
|
||||
return RecordingCurveSource(self._reader)
|
||||
|
||||
def open_recording(self, directory: Path) -> bool:
|
||||
"""Open a recording. Any depth answer still in flight is invalidated."""
|
||||
# A running request belongs to the *previous* recording: its result must
|
||||
# never be pasted into the new one, and its processor must be closed by
|
||||
# the thread that is using it, not here.
|
||||
self._retire_recomputer()
|
||||
self._record_serial += 1
|
||||
self._depth_request_seq += 1
|
||||
self._depth_pending_seq = None
|
||||
self._depth_pending_position = None
|
||||
self._depth_error = None
|
||||
def open_recording(self, directory) -> bool:
|
||||
"""Open a recording directory."""
|
||||
self._release_reader()
|
||||
try:
|
||||
self._reader = RecordingReader(Path(directory))
|
||||
info = self._reader.info()
|
||||
self._reader = RecordingReader(directory)
|
||||
self._reader.info()
|
||||
except Exception: # noqa: BLE001 - reported through the status text
|
||||
logger.exception("Could not open recording %s", directory)
|
||||
self._reader = None
|
||||
self._recomputer = None
|
||||
self._reset_frames()
|
||||
self.recording_changed.emit(False)
|
||||
self.status_changed.emit()
|
||||
return False
|
||||
self._recomputer = DepthRecomputer(self._reader)
|
||||
self._depth_ok = False
|
||||
self._depth_result = None
|
||||
self._depth_result_seq = None
|
||||
self._depth_range = None
|
||||
self._reset_frames()
|
||||
self._position = 0.0
|
||||
_ = info
|
||||
self.recording_changed.emit(True)
|
||||
self._refresh_warnings()
|
||||
self._refresh_depth_pane()
|
||||
self.set_position(0.0)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
"""Drop the recording and hand every processor to its owning thread."""
|
||||
self._depth_poll.stop()
|
||||
for recomputer in list(self._retired_recomputers):
|
||||
if not self._depth_busy():
|
||||
self._retired_recomputers.remove(recomputer)
|
||||
try:
|
||||
recomputer.close()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Could not close a retired recomputer")
|
||||
self._retire_recomputer()
|
||||
self._reader = None
|
||||
"""Drop the recording (the decoder is released with it)."""
|
||||
self._release_reader()
|
||||
self._reset_frames()
|
||||
self.recording_changed.emit(False)
|
||||
self.status_changed.emit()
|
||||
|
||||
def _release_reader(self) -> None:
|
||||
reader, self._reader = self._reader, None
|
||||
if reader is not None:
|
||||
try:
|
||||
reader.close()
|
||||
except Exception: # noqa: BLE001 - releasing is best effort
|
||||
logger.debug("Could not close the recording reader", exc_info=True)
|
||||
|
||||
def _reset_frames(self) -> None:
|
||||
self._video_position = None
|
||||
self._video_qimage = None
|
||||
self._video_reason = ""
|
||||
self._depth_qimage = None
|
||||
self._depth_message = ""
|
||||
|
||||
def _retire_recomputer(self) -> None:
|
||||
"""Hand the current processor to its worker, or close it here if idle.
|
||||
|
||||
Closing a processor that a worker thread is still reading is a use-after-
|
||||
free; only the thread that owns it may close it, so a busy one is marked
|
||||
for the worker to close when it finishes.
|
||||
"""
|
||||
recomputer, self._recomputer = self._recomputer, None
|
||||
if recomputer is None:
|
||||
return
|
||||
if self._depth_busy():
|
||||
self._retired_recomputers.append(recomputer)
|
||||
else:
|
||||
try:
|
||||
recomputer.close()
|
||||
except Exception: # noqa: BLE001 - closing is best effort
|
||||
logger.exception("Could not close the depth recomputer")
|
||||
|
||||
# ---------------------------------------------------------------- cursor
|
||||
def position(self) -> float:
|
||||
@@ -200,16 +136,12 @@ class ReplayController(QObject):
|
||||
return float(self._reader.info().duration_s)
|
||||
|
||||
def set_position(self, t_rel_s: float) -> None:
|
||||
"""Move the single cursor: video, depth and charts follow it."""
|
||||
"""Move the single cursor: the video frame and the charts follow it."""
|
||||
if self._reader is None:
|
||||
return
|
||||
duration = self.duration_s()
|
||||
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
|
||||
self._refresh_video()
|
||||
if self._depth_ok or self._depth_result is not None:
|
||||
# The held depth belongs to the frame it was computed for. Moving the
|
||||
# cursor must not keep showing it as if it were synchronised.
|
||||
self._refresh_depth_pane()
|
||||
self.position_changed.emit(self._position)
|
||||
self.frames_changed.emit()
|
||||
|
||||
@@ -217,20 +149,15 @@ class ReplayController(QObject):
|
||||
return self._unit
|
||||
|
||||
def set_unit(self, unit: str) -> str:
|
||||
"""Show the recording in ``unit``; the bar and the charts share one.
|
||||
"""Show the recording in ``unit``; the charts and the rail share one.
|
||||
|
||||
Opening a recording that carries no calibrated axis in newton mode would
|
||||
draw three empty charts, so the caller picks the unit from what the
|
||||
recording actually has and tells both views about it here.
|
||||
recording actually has and tells every view about it here.
|
||||
"""
|
||||
self._unit = "N" if str(unit).upper().startswith("N") else "raw"
|
||||
return self._unit
|
||||
|
||||
def toggle_unit(self) -> str:
|
||||
self._unit = "N" if self._unit == "raw" else "raw"
|
||||
self.status_changed.emit()
|
||||
return self._unit
|
||||
|
||||
# ----------------------------------------------------------------- frames
|
||||
def video_image(self) -> QImage | None:
|
||||
self._refresh_video()
|
||||
@@ -261,12 +188,7 @@ class ReplayController(QObject):
|
||||
self._video_reason = ""
|
||||
self._video_qimage = _bgr_to_qimage(image)
|
||||
|
||||
def depth_image(self) -> QImage | None:
|
||||
return self._depth_qimage
|
||||
|
||||
def depth_message(self) -> str:
|
||||
return self._depth_message
|
||||
|
||||
# ---------------------------------------------------------------- notes
|
||||
def warnings_text(self) -> str:
|
||||
return self._warnings
|
||||
|
||||
@@ -275,15 +197,12 @@ class ReplayController(QObject):
|
||||
if self._reader is None:
|
||||
return
|
||||
info = self._reader.info()
|
||||
_ok, sdk_version = DepthRecomputer.sdk_available()
|
||||
# The reader compares the recorded config hash against the config file as
|
||||
# it is now, which is what the depth recomputation would actually use.
|
||||
codes = self._reader.replay_warnings(sdk_version=sdk_version)
|
||||
codes = self._reader.replay_warnings(sdk_version=_sdk_version())
|
||||
text = ""
|
||||
if codes:
|
||||
text = t(
|
||||
"playback.warnings",
|
||||
detail=", ".join(t(f"playback.warn_{c}") for c in codes),
|
||||
detail=", ".join(t(f"playback.warn_{code}") for code in codes),
|
||||
)
|
||||
if info.decimated:
|
||||
# The curves are a view, not every sample: say so rather than let the
|
||||
@@ -295,221 +214,3 @@ class ReplayController(QObject):
|
||||
)
|
||||
text = f"{text} {note}" if text else note
|
||||
self._warnings = text
|
||||
|
||||
# -------------------------------------------------------------------- depth
|
||||
def request_depth(self) -> None:
|
||||
"""Kick off a recomputation on a worker thread.
|
||||
|
||||
Opening a video with the SDK and stepping to a frame can block, so it
|
||||
must never run on the UI thread — that is exactly how the replay would
|
||||
freeze.
|
||||
"""
|
||||
if self._recomputer is None:
|
||||
return
|
||||
problem = self._recomputer.preflight()
|
||||
if problem:
|
||||
self._depth_ok = False
|
||||
self._depth_qimage = None
|
||||
self._depth_message = t(f"playback.depth_{problem}")
|
||||
self.status_changed.emit()
|
||||
return
|
||||
if self._depth_thread is not None and self._depth_thread.is_alive():
|
||||
self._depth_message = t("playback.depth_busy")
|
||||
self.status_changed.emit()
|
||||
return
|
||||
self._depth_ok = False
|
||||
self._depth_result = None
|
||||
self._depth_result_seq = None
|
||||
self._depth_error = None
|
||||
self._depth_message = t("playback.depth_running")
|
||||
self.status_changed.emit()
|
||||
position = self._position
|
||||
# Bind the answer to this request: the recording, the frame and a request
|
||||
# sequence. A result that arrives after the cursor moved — or after
|
||||
# another recording was opened — is discarded rather than pasted on.
|
||||
recomputer = self._recomputer
|
||||
record = self._record_serial
|
||||
frame = self._current_frame()
|
||||
self._depth_request_seq += 1
|
||||
seq = self._depth_request_seq
|
||||
self._depth_pending_seq = seq
|
||||
self._depth_pending_position = position
|
||||
self._depth_pending_record = record
|
||||
|
||||
def work() -> None:
|
||||
# Runs off the UI thread and touches no Qt object. It captures the
|
||||
# processor it was given: the controller may already be showing
|
||||
# another recording by the time this returns.
|
||||
try:
|
||||
result = recomputer.depth_at(position)
|
||||
except BaseException as exc: # noqa: BLE001 - reported via the poll
|
||||
self._depth_error = (record, exc)
|
||||
return
|
||||
finally:
|
||||
if recomputer in self._retired_recomputers:
|
||||
# Retired while this request was running: the owning thread
|
||||
# closes it, not the UI thread.
|
||||
self._retired_recomputers.remove(recomputer)
|
||||
try:
|
||||
recomputer.close()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Could not close a retired recomputer")
|
||||
self._depth_result = result
|
||||
self._depth_result_seq = seq
|
||||
self._depth_result_record = record
|
||||
self._depth_result_frame = getattr(frame, "frame_id", -1)
|
||||
|
||||
self._depth_thread = threading.Thread(
|
||||
target=work, name="depth-replay", daemon=True
|
||||
)
|
||||
self._depth_thread.start()
|
||||
self._depth_poll.start()
|
||||
|
||||
def depth_busy(self) -> bool:
|
||||
return self._depth_thread is not None and self._depth_thread.is_alive()
|
||||
|
||||
# The old spelling, kept for the tests and for readability at call sites.
|
||||
_depth_busy = depth_busy
|
||||
|
||||
def _poll_depth(self) -> None:
|
||||
"""UI-thread poll for the worker result."""
|
||||
thread = self._depth_thread
|
||||
if thread is None or thread.is_alive():
|
||||
return
|
||||
self._depth_poll.stop()
|
||||
pending = self._depth_pending_seq
|
||||
record = self._depth_pending_record
|
||||
self._depth_pending_seq = None
|
||||
self._depth_pending_position = None
|
||||
self._depth_pending_record = None
|
||||
if self._depth_error is not None:
|
||||
error_record, exc = self._depth_error
|
||||
self._depth_error = None
|
||||
if error_record != self._record_serial:
|
||||
# The failure belongs to a recording that is no longer open.
|
||||
return
|
||||
self._depth_ok = False
|
||||
self._depth_qimage = None
|
||||
self._depth_message = t("playback.depth_failed", detail=str(exc))
|
||||
self.status_changed.emit()
|
||||
self.frames_changed.emit()
|
||||
return
|
||||
if record != self._record_serial:
|
||||
# Another recording was opened while this was running.
|
||||
return
|
||||
result = self._depth_result
|
||||
if result is None or self._depth_result_seq != pending:
|
||||
# A newer request (or a moved cursor) owns the pane now.
|
||||
self._refresh_depth_pane()
|
||||
self.frames_changed.emit()
|
||||
return
|
||||
if not self._depth_matches_cursor(result):
|
||||
# The cursor moved while the SDK was reading: do not draw an answer
|
||||
# for another frame, and say the pane needs recomputing.
|
||||
self._refresh_depth_pane()
|
||||
self.frames_changed.emit()
|
||||
return
|
||||
self._render_depth_result(result)
|
||||
self.status_changed.emit()
|
||||
self.frames_changed.emit()
|
||||
|
||||
def _current_frame(self):
|
||||
"""The recorded frame under the cursor, or None without a frame index."""
|
||||
frame_at = getattr(self._reader, "frame_at", None)
|
||||
if not callable(frame_at):
|
||||
return None
|
||||
try:
|
||||
return frame_at(self._position)
|
||||
except Exception: # noqa: BLE001 - a stub reader has no frame index
|
||||
return None
|
||||
|
||||
def _depth_matches_cursor(self, result) -> bool:
|
||||
"""True when ``result`` was computed for the frame under the cursor."""
|
||||
frame = self._current_frame()
|
||||
frame_id = getattr(result, "frame_id", None)
|
||||
if frame is not None and frame_id is not None and int(frame_id) >= 0:
|
||||
return int(frame_id) == int(frame.frame_id)
|
||||
t_rel = getattr(result, "t_rel_s", None)
|
||||
if t_rel is None:
|
||||
# Nothing in the result says which frame it belongs to, so it cannot
|
||||
# be shown to be stale either.
|
||||
return True
|
||||
if frame is not None:
|
||||
return abs(float(t_rel) - float(frame.t_rel_s)) < 1e-6
|
||||
return abs(float(t_rel) - self._position) < 1e-9
|
||||
|
||||
def _render_depth_result(self, result) -> None:
|
||||
code = str(getattr(result, "error", "") or "")
|
||||
if code in ("not_recomputed", "recomputing", "stale"):
|
||||
# A placeholder, not a failure: the pane describes the cursor.
|
||||
self._depth_ok = False
|
||||
self._depth_qimage = None
|
||||
self._depth_message = t(f"playback.depth_{code}")
|
||||
return
|
||||
if not result.ok:
|
||||
self._depth_ok = False
|
||||
self._depth_qimage = None
|
||||
self._depth_message = t("playback.depth_failed", detail=result.error)
|
||||
return
|
||||
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr
|
||||
|
||||
depth = np.asarray(result.depth)
|
||||
self._latch_depth_range(depth)
|
||||
bgr = depth_map_to_bgr(depth, value_range=self._depth_range)
|
||||
if bgr is None:
|
||||
self._depth_ok = False
|
||||
self._depth_qimage = None
|
||||
self._depth_message = t("playback.depth_empty")
|
||||
return
|
||||
self._depth_ok = True
|
||||
self._depth_message = self._depth_warning_text(result)
|
||||
self._depth_qimage = _bgr_to_qimage(bgr)
|
||||
|
||||
def _latch_depth_range(self, depth: np.ndarray) -> None:
|
||||
if self._depth_range is not None:
|
||||
return
|
||||
finite = np.isfinite(depth)
|
||||
if depth.size == 0 or not finite.any():
|
||||
return
|
||||
low = float(np.nanmin(depth[finite]))
|
||||
high = float(np.nanmax(depth[finite]))
|
||||
if high <= low:
|
||||
high = low + 1.0
|
||||
self._depth_range = (low, high)
|
||||
|
||||
def depth_range(self) -> tuple[float, float] | None:
|
||||
return self._depth_range
|
||||
|
||||
@staticmethod
|
||||
def _depth_warning_text(result) -> str:
|
||||
"""Say when a replay could not reproduce the live algorithm state."""
|
||||
codes = tuple(getattr(result, "warnings", ()) or ())
|
||||
if not codes:
|
||||
return ""
|
||||
return t(
|
||||
"playback.depth_warnings",
|
||||
detail=", ".join(t(f"playback.warn_{code}") for code in codes),
|
||||
)
|
||||
|
||||
def _refresh_depth_pane(self) -> None:
|
||||
"""Re-derive the depth pane for the cursor's frame.
|
||||
|
||||
Re-drawing the held map here is what used to show a stale depth as though
|
||||
it were in sync with the cursor.
|
||||
"""
|
||||
result = self._depth_result
|
||||
if result is not None and self._depth_matches_cursor(result):
|
||||
self._render_depth_result(result)
|
||||
return
|
||||
if (
|
||||
self._depth_pending_seq is not None
|
||||
and self._depth_pending_position == self._position
|
||||
and self._depth_busy()
|
||||
):
|
||||
self._render_depth_result(
|
||||
DepthResult(error="recomputing", t_rel_s=self._position)
|
||||
)
|
||||
return
|
||||
self._render_depth_result(
|
||||
DepthResult(error="not_recomputed", t_rel_s=self._position)
|
||||
)
|
||||
|
||||
@@ -118,8 +118,9 @@ class ForcePlotsPanel(QFrame):
|
||||
mode_changed = pyqtSignal(str)
|
||||
#: The unit chip was clicked: switch between newton and raw.
|
||||
unit_toggle_requested = pyqtSignal()
|
||||
#: The play/pause button was pressed (a recording is on screen).
|
||||
play_toggled = pyqtSignal(bool)
|
||||
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned
|
||||
#: or moved the overview marker): playback stops where it is.
|
||||
timeline_taken_over = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
@@ -175,15 +176,9 @@ class ForcePlotsPanel(QFrame):
|
||||
self.mode_label.setObjectName("hintText")
|
||||
header.addWidget(self.mode_label)
|
||||
header.addStretch(1)
|
||||
# Play/pause leads the header the way a transport leads an editor: the
|
||||
# cursor is the playhead, and dragging it pauses.
|
||||
self.btn_play = self._header_button("play")
|
||||
self.btn_play.setCheckable(True)
|
||||
self.btn_play.setEnabled(False)
|
||||
self.btn_play.toggled.connect(self._on_play_toggled)
|
||||
header.addWidget(self.btn_play)
|
||||
# The three window actions are symbols: the tooltip explains each one, and
|
||||
# the pointer stays inside the chart instead of reading three labels.
|
||||
# The transport lives on the rail (the primary button of the replay view);
|
||||
# the header keeps the window actions, which are symbols because the
|
||||
# tooltip explains each one.
|
||||
self.btn_latest = self._header_button("latest")
|
||||
self.btn_latest.clicked.connect(self.follow_latest)
|
||||
header.addWidget(self.btn_latest)
|
||||
@@ -334,31 +329,9 @@ class ForcePlotsPanel(QFrame):
|
||||
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
|
||||
return button
|
||||
|
||||
def _on_play_toggled(self, playing: bool) -> None:
|
||||
"""Draw the transport button for the state it is in, then tell the window."""
|
||||
self._set_play_icon(bool(playing))
|
||||
self.play_toggled.emit(bool(playing))
|
||||
|
||||
def _set_play_icon(self, playing: bool) -> None:
|
||||
name = "pause" if playing else "play"
|
||||
self.btn_play._icon_name = name # noqa: SLF001 - read back by set_theme
|
||||
self.btn_play.setIcon(icons.icon(name, self._theme))
|
||||
self.btn_play.setToolTip(t("chart.pause_tip" if playing else "chart.play_tip"))
|
||||
self.btn_play.setAccessibleName(
|
||||
t("chart.pause" if playing else "chart.play")
|
||||
)
|
||||
|
||||
def set_playing(self, playing: bool) -> None:
|
||||
"""Reflect playback that the window started or stopped itself."""
|
||||
if self.btn_play.isChecked() != bool(playing):
|
||||
self.btn_play.setChecked(bool(playing))
|
||||
else:
|
||||
self._set_play_icon(bool(playing))
|
||||
|
||||
def pause_playback(self) -> None:
|
||||
"""A manual gesture takes over the timeline: stop advancing it."""
|
||||
if self.btn_play.isChecked():
|
||||
self.btn_play.setChecked(False)
|
||||
"""The operator touched the timeline: playback stops where it is."""
|
||||
self.timeline_taken_over.emit()
|
||||
|
||||
def _build_value_card(
|
||||
self, channel: str
|
||||
@@ -418,7 +391,7 @@ class ForcePlotsPanel(QFrame):
|
||||
text = color(theme, "text_primary")
|
||||
muted = color(theme, "text_muted")
|
||||
# Icons are painted in the theme's own colour, so they follow a switch.
|
||||
for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom, self.btn_play):
|
||||
for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom):
|
||||
name = getattr(button, "_icon_name", "")
|
||||
if name:
|
||||
button.setIcon(icons.icon(name, theme))
|
||||
@@ -1079,9 +1052,6 @@ class ForcePlotsPanel(QFrame):
|
||||
self._mode == "browse" and not self.is_recording_source()
|
||||
)
|
||||
self.btn_overview.setEnabled(self._source_ready)
|
||||
self.btn_play.setEnabled(self.is_recording_source())
|
||||
if not self.is_recording_source():
|
||||
self.pause_playback()
|
||||
|
||||
def _reload_from_store(self) -> None:
|
||||
"""Re-populate the visible buffer from the attached source."""
|
||||
@@ -1241,7 +1211,6 @@ class ForcePlotsPanel(QFrame):
|
||||
name_label.setText(t(f"chart.{key}_name"))
|
||||
self.btn_latest.setToolTip(t("chart.return_latest_tip"))
|
||||
self.btn_latest.setAccessibleName(t("chart.return_latest"))
|
||||
self._set_play_icon(self.btn_play.isChecked())
|
||||
self.btn_overview.setToolTip(t("chart.overview_tip"))
|
||||
self.btn_overview.setAccessibleName(t("chart.overview"))
|
||||
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
|
||||
|
||||
@@ -14,13 +14,13 @@ from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from PyQt5.QtCore import QPointF, QRectF, Qt
|
||||
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap, QPolygonF
|
||||
from PyQt5.QtCore import QRectF, Qt
|
||||
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
||||
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
#: Names of the icons this module can paint.
|
||||
NAMES = ("zoom_fit", "box_zoom", "latest", "play", "pause")
|
||||
NAMES = ("zoom_fit", "box_zoom", "latest")
|
||||
|
||||
ICON_SIZE = 16
|
||||
_PEN_WIDTH = 1.4
|
||||
@@ -76,36 +76,10 @@ def _paint_latest(painter: QPainter, size: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _paint_play(painter: QPainter, size: int) -> None:
|
||||
"""A filled triangle: play the recording from the cursor."""
|
||||
left = size * 0.30
|
||||
right = size * 0.76
|
||||
painter.setBrush(painter.pen().color())
|
||||
painter.drawPolygon(
|
||||
QPolygonF(
|
||||
[
|
||||
QPointF(left, size * 0.22),
|
||||
QPointF(right, size * 0.50),
|
||||
QPointF(left, size * 0.78),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _paint_pause(painter: QPainter, size: int) -> None:
|
||||
"""Two bars: hold the recording where it is."""
|
||||
painter.setBrush(painter.pen().color())
|
||||
width = size * 0.16
|
||||
for x in (size * 0.32, size * 0.56):
|
||||
painter.drawRect(QRectF(x, size * 0.24, width, size * 0.52))
|
||||
|
||||
|
||||
_PAINTERS = {
|
||||
"zoom_fit": _paint_zoom_fit,
|
||||
"box_zoom": _paint_box_zoom,
|
||||
"latest": _paint_latest,
|
||||
"play": _paint_play,
|
||||
"pause": _paint_pause,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ class ToolbarWidget(QWidget):
|
||||
self._source_view = "live"
|
||||
self._source_controls_enabled = True
|
||||
self._video_path = ""
|
||||
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
|
||||
self._run_role = "acquire"
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(2, 2, 2, 2)
|
||||
@@ -369,6 +371,22 @@ class ToolbarWidget(QWidget):
|
||||
self._source_view = wanted
|
||||
self._apply_source_mode_ui()
|
||||
|
||||
def set_run_role(self, role: str) -> None:
|
||||
"""What the primary button does in this view: acquire, or play back."""
|
||||
wanted = "playback" if str(role) == "playback" else "acquire"
|
||||
if wanted == self._run_role:
|
||||
return
|
||||
self._run_role = wanted
|
||||
# A view switch never carries the other view's running state across.
|
||||
if self.btn_run.isChecked():
|
||||
self.btn_run.blockSignals(True)
|
||||
self.btn_run.setChecked(False)
|
||||
self.btn_run.blockSignals(False)
|
||||
self._refresh_run_label()
|
||||
|
||||
def run_role(self) -> str:
|
||||
return self._run_role
|
||||
|
||||
def source_view(self) -> str:
|
||||
return self._source_view
|
||||
|
||||
@@ -581,6 +599,15 @@ class ToolbarWidget(QWidget):
|
||||
self.btn_preview.setText(t("toolbar.preview"))
|
||||
|
||||
def _refresh_run_label(self) -> None:
|
||||
if self._run_role == "playback":
|
||||
# The primary action of the replay view is playing the recording —
|
||||
# the same button, doing what this view is for.
|
||||
self.btn_run.setText(
|
||||
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
|
||||
)
|
||||
self._refresh_emphasis()
|
||||
self._apply_run_tooltip()
|
||||
return
|
||||
if self.btn_run.isChecked():
|
||||
# While connecting, cancel is the action the user actually needs —
|
||||
# a device open can hang, and this is the way out of it.
|
||||
@@ -608,7 +635,14 @@ class ToolbarWidget(QWidget):
|
||||
# The phase counts as running even if the button has not been repainted
|
||||
# yet: the rail must describe the session, not the widget's lag.
|
||||
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
|
||||
if recording:
|
||||
if self._run_role == "playback":
|
||||
# The transport is the replay view's own action, not a session stop:
|
||||
# filled while it plays, an outline when idle, and never red.
|
||||
self.btn_record.setObjectName(
|
||||
"dangerButton" if recording else "primaryButton"
|
||||
)
|
||||
self.btn_run.setObjectName("primaryButton" if running else "ghostButton")
|
||||
elif recording:
|
||||
self.btn_record.setObjectName("dangerButton")
|
||||
self.btn_run.setObjectName("dangerOutline")
|
||||
else:
|
||||
@@ -624,6 +658,13 @@ class ToolbarWidget(QWidget):
|
||||
button.style().polish(button)
|
||||
|
||||
def _apply_run_tooltip(self) -> None:
|
||||
if self._run_role == "playback":
|
||||
key = "toolbar.playback_pause_tip" if self.btn_run.isChecked() else "toolbar.playback_tip"
|
||||
if not self._run_available and self._run_tooltip_reason:
|
||||
self.btn_run.setToolTip(self._run_tooltip_reason)
|
||||
else:
|
||||
self.btn_run.setToolTip(t(key))
|
||||
return
|
||||
if self.btn_run.isChecked():
|
||||
if self._run_phase == "connecting":
|
||||
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
|
||||
|
||||
@@ -848,109 +848,6 @@ def test_a_failed_recording_is_consumed_even_when_it_is_not_running(make_window)
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_the_depth_request_belongs_to_the_recording_it_started_in(make_window, record_root):
|
||||
"""Opening another recording must invalidate the request still running."""
|
||||
import threading as _threading
|
||||
import time as _time
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.record.depth import DepthResult
|
||||
from orisys_studio.replay import ReplayController
|
||||
|
||||
window = _show(make_window(), (1200, 700))
|
||||
replay = ReplayController()
|
||||
replay.open_recording(Path(record_root))
|
||||
first_serial = replay._record_serial
|
||||
|
||||
started = _threading.Event()
|
||||
release = _threading.Event()
|
||||
closed: list[str] = []
|
||||
|
||||
class _Blocking:
|
||||
def preflight(self) -> str:
|
||||
return ""
|
||||
|
||||
def depth_at(self, _position, **_kwargs):
|
||||
started.set()
|
||||
release.wait(10.0)
|
||||
return DepthResult(
|
||||
depth=np.ones((2, 2)), frame_id=0, t_rel_s=0.0, record_id="old"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
closed.append("closed")
|
||||
|
||||
blocking = _Blocking()
|
||||
replay._recomputer = blocking
|
||||
replay.request_depth()
|
||||
assert started.wait(5.0), "the depth request never started"
|
||||
|
||||
# Another recording is opened while the request is in flight.
|
||||
replay.open_recording(Path(record_root))
|
||||
assert replay._record_serial == first_serial + 1
|
||||
assert replay._recomputer is not blocking, "the processor was not replaced"
|
||||
assert closed == [], "the UI thread closed a processor a worker was reading"
|
||||
|
||||
release.set()
|
||||
deadline = _time.monotonic() + 10.0
|
||||
while not closed and _time.monotonic() < deadline:
|
||||
QApplication.processEvents()
|
||||
_time.sleep(0.01)
|
||||
assert closed == ["closed"], "the retired processor was never closed"
|
||||
assert replay._depth_thread is not None and not replay._depth_thread.is_alive()
|
||||
|
||||
# The answer belongs to the previous recording, so it is dropped, not drawn.
|
||||
replay._poll_depth()
|
||||
QApplication.processEvents()
|
||||
assert replay._depth_ok is False
|
||||
assert replay.depth_image() is None, "an answer for another recording was drawn"
|
||||
replay.close()
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_busy_depth_processor_is_not_closed_by_the_ui_thread(make_window, record_root):
|
||||
import threading as _threading
|
||||
import time as _time
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.record.depth import DepthResult
|
||||
from orisys_studio.replay import ReplayController
|
||||
|
||||
window = _show(make_window(), (1200, 700))
|
||||
replay = ReplayController()
|
||||
replay.open_recording(Path(record_root))
|
||||
started = _threading.Event()
|
||||
release = _threading.Event()
|
||||
closed: list[str] = []
|
||||
|
||||
class _Blocking:
|
||||
def preflight(self) -> str:
|
||||
return ""
|
||||
|
||||
def depth_at(self, _position, **_kwargs):
|
||||
started.set()
|
||||
release.wait(10.0)
|
||||
return DepthResult(depth=np.ones((2, 2)), frame_id=0, t_rel_s=0.0)
|
||||
|
||||
def close(self) -> None:
|
||||
closed.append("closed")
|
||||
|
||||
replay._recomputer = _Blocking()
|
||||
replay.request_depth()
|
||||
assert started.wait(5.0)
|
||||
replay.close() # closing must not free a processor in use
|
||||
QApplication.processEvents()
|
||||
assert closed == [], "the controller closed a processor that was still reading"
|
||||
release.set()
|
||||
deadline = _time.monotonic() + 10.0
|
||||
while not closed and _time.monotonic() < deadline:
|
||||
QApplication.processEvents()
|
||||
_time.sleep(0.01)
|
||||
assert closed == ["closed"], "the owning thread never closed the processor"
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_confirmed_calibration_is_snapshotted_into_the_recording(make_window, record_root):
|
||||
"""The recording keeps the coefficients themselves, not only their hash."""
|
||||
@@ -1691,8 +1588,10 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec
|
||||
|
||||
assert window.arrow_panel.view_modes() == ("recorded",)
|
||||
assert window.replay.video_image() is not None, "no frame at the cursor"
|
||||
assert panel.btn_play.isEnabled(), "the replay has no transport"
|
||||
assert not panel.btn_play.isChecked(), "the replay starts out playing"
|
||||
assert window.toolbar.run_role() == "playback", "the rail lost the transport"
|
||||
assert window.toolbar.btn_run.isEnabled(), "the replay has no transport"
|
||||
assert not window.toolbar.btn_run.isChecked(), "the replay starts out playing"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.playback")
|
||||
|
||||
# The cursor is the playhead: setting it moves the video.
|
||||
window.replay.set_position(window.replay.duration_s() / 2)
|
||||
@@ -1701,19 +1600,21 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec
|
||||
assert abs(panel.cursors[0].value() - window.replay.position()) < 0.05
|
||||
|
||||
# Dragging the chart cursor scrubs the recording too, and pauses it.
|
||||
panel.btn_play.setChecked(True)
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.playback_pause")
|
||||
panel.cursors[0].setValue(0.25)
|
||||
QApplication.processEvents()
|
||||
assert abs(window.replay.position() - 0.25) < 0.05
|
||||
assert not panel.btn_play.isChecked(), "dragging the cursor did not pause playback"
|
||||
assert not window.toolbar.btn_run.isChecked(), "dragging the cursor did not pause playback"
|
||||
|
||||
# Leaving the view returns the charts to the live session.
|
||||
window.brand_bar.btn_live.click()
|
||||
QApplication.processEvents()
|
||||
assert not panel.is_recording_source()
|
||||
assert panel.mode() == "follow"
|
||||
assert not panel.btn_play.isEnabled(), "the live view still offers the transport"
|
||||
assert window.toolbar.run_role() == "acquire", "the rail kept the transport"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||
assert window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth")
|
||||
assert "raw_0001" not in window.toolbar.source_summary.text(), (
|
||||
"the replay's video is still named in the live view"
|
||||
@@ -2027,8 +1928,8 @@ def test_the_replay_transport_plays_and_pauses(make_window, replay_recording):
|
||||
QApplication.processEvents()
|
||||
panel = window.force_plots
|
||||
|
||||
assert panel.btn_play.isEnabled() and not panel.btn_play.isChecked()
|
||||
panel.btn_play.setChecked(True)
|
||||
assert window.toolbar.btn_run.isEnabled() and not window.toolbar.btn_run.isChecked()
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
started = time.perf_counter()
|
||||
for _ in range(10):
|
||||
QApplication.processEvents()
|
||||
@@ -2045,7 +1946,7 @@ def test_the_replay_transport_plays_and_pauses(make_window, replay_recording):
|
||||
# Dragging the cursor is a manual gesture: playback pauses where it is.
|
||||
panel.cursors[0].setValue(0.2)
|
||||
QApplication.processEvents()
|
||||
assert not panel.btn_play.isChecked(), "dragging the cursor did not pause"
|
||||
assert not window.toolbar.btn_run.isChecked(), "dragging the cursor did not pause"
|
||||
assert abs(window.replay.position() - 0.2) < 0.05
|
||||
parked = window.replay.position()
|
||||
for _ in range(6):
|
||||
@@ -2082,13 +1983,15 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
|
||||
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 panel.btn_play.isChecked(), "the transport does not show the pass as running"
|
||||
assert window.toolbar.btn_run.isChecked(), (
|
||||
"the transport does not show the pass as running"
|
||||
)
|
||||
|
||||
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 panel.btn_play.isChecked(), "the transport stayed checked"
|
||||
assert not window.toolbar.btn_run.isChecked(), "the transport stayed checked"
|
||||
|
||||
|
||||
# ------------------------------------------- selection readout and notices
|
||||
|
||||
+55
-152
@@ -2864,15 +2864,31 @@ def main() -> int:
|
||||
)
|
||||
|
||||
# The chart cursor is the playhead, and the transport moves it at 1x.
|
||||
check(replay_plots.btn_play.isEnabled(), "the replay has no transport")
|
||||
check(not replay_plots.btn_play.isChecked(), "the replay starts while playing")
|
||||
replay_plots.btn_play.setChecked(True)
|
||||
check(
|
||||
replay_window.toolbar.run_role() == "playback",
|
||||
"the rail's primary button is not the replay's transport",
|
||||
)
|
||||
check(replay_window.toolbar.btn_run.isEnabled(), "the replay has no transport")
|
||||
check(
|
||||
not replay_window.toolbar.btn_run.isChecked(),
|
||||
"the replay starts while playing",
|
||||
)
|
||||
check(
|
||||
replay_window.toolbar.btn_run.text() == t("toolbar.playback"),
|
||||
f"the button does not say it plays: {replay_window.toolbar.btn_run.text()!r}",
|
||||
)
|
||||
replay_window.toolbar.btn_run.setChecked(True)
|
||||
app.processEvents()
|
||||
check(
|
||||
replay_window.toolbar.btn_run.text() == t("toolbar.playback_pause"),
|
||||
f"the button does not offer pause: {replay_window.toolbar.btn_run.text()!r}",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
for _ in range(12):
|
||||
app.processEvents()
|
||||
time.sleep(0.03)
|
||||
elapsed = time.perf_counter() - started
|
||||
replay_plots.btn_play.setChecked(False)
|
||||
replay_window.toolbar.btn_run.setChecked(False)
|
||||
app.processEvents()
|
||||
check(
|
||||
replay.position() > 0.05,
|
||||
@@ -2886,14 +2902,17 @@ def main() -> int:
|
||||
abs(float(replay_plots.cursors[0].value()) - replay.position()) < 0.05,
|
||||
"the chart cursor did not follow the transport",
|
||||
)
|
||||
check(not replay_plots.btn_play.isChecked(), "the transport stayed checked after pause")
|
||||
check(
|
||||
not replay_window.toolbar.btn_run.isChecked(),
|
||||
"the transport stayed checked after pause",
|
||||
)
|
||||
# A manual gesture on the timeline takes over: playback pauses.
|
||||
replay_plots.btn_play.setChecked(True)
|
||||
replay_window.toolbar.btn_run.setChecked(True)
|
||||
app.processEvents()
|
||||
replay_plots.cursors[0].setValue(0.25)
|
||||
app.processEvents()
|
||||
check(
|
||||
not replay_plots.btn_play.isChecked(),
|
||||
not replay_window.toolbar.btn_run.isChecked(),
|
||||
"dragging the cursor did not pause the playback",
|
||||
)
|
||||
check(
|
||||
@@ -2934,12 +2953,12 @@ def main() -> int:
|
||||
f"the recording reports {reader.info().total_rows} rows",
|
||||
)
|
||||
check(
|
||||
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
|
||||
t("playback.warn_config_changed").split("{")[0].strip()[:4]
|
||||
in replay.warnings_text(),
|
||||
f"the replay notes are missing: {replay.warnings_text()!r}",
|
||||
)
|
||||
check(
|
||||
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
|
||||
t("playback.warn_config_changed").split("{")[0].strip()[:4]
|
||||
in replay_window.toolbar.source_summary.toolTip(),
|
||||
f"the replay notes are not reachable from the rail: "
|
||||
f"{replay_window.toolbar.source_summary.toolTip()!r}",
|
||||
@@ -2968,157 +2987,41 @@ def main() -> int:
|
||||
f"the unit chip did not reach the replay: {replay.unit()}",
|
||||
)
|
||||
|
||||
# The depth recomputation must never run on the UI thread. A stub that blocks
|
||||
# proves the call returns immediately instead of freezing the window.
|
||||
import threading as _threading
|
||||
import time as _time
|
||||
|
||||
class _Blocking:
|
||||
released = _threading.Event()
|
||||
|
||||
def preflight(self) -> str:
|
||||
return ""
|
||||
|
||||
def depth_at(self, _t):
|
||||
_Blocking.released.wait(timeout=5.0)
|
||||
return type("R", (), {"ok": False, "error": "stub", "depth": None})()
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
replay._recomputer = _Blocking() # noqa: SLF001 - exercising the contract
|
||||
started = _time.perf_counter()
|
||||
replay.request_depth()
|
||||
elapsed = _time.perf_counter() - started
|
||||
check(elapsed < 0.5, f"the depth recompute blocked the UI for {elapsed:.2f}s")
|
||||
# Playing means decoding a frame per tick. Reopening and seeking the file for
|
||||
# every frame (10 ms for a small clip, hundreds for 1080p H.264) is what made
|
||||
# the video stand still; the reader keeps one decoder open and steps it.
|
||||
span = max(0.1, replay.duration_s() - 0.05)
|
||||
seeks: list[float] = []
|
||||
for fraction in (0.0, 0.25, 0.5, 0.75, 1.0):
|
||||
moment = fraction * span
|
||||
started_at = time.perf_counter()
|
||||
frame_image, _frame = replay.reader().video_frame_at(moment)
|
||||
seeks.append((time.perf_counter() - started_at) * 1000.0)
|
||||
check(frame_image is not None, f"no frame at {moment:.2f}s")
|
||||
check(
|
||||
replay._depth_thread is not None and replay._depth_thread.is_alive(),
|
||||
"the depth recompute did not run on a worker thread",
|
||||
max(seeks) < 250.0,
|
||||
f"a seek costs {max(seeks):.0f} ms, so playback would stall",
|
||||
)
|
||||
steps: list[float] = []
|
||||
for index in range(1, 8):
|
||||
started_at = time.perf_counter()
|
||||
replay.reader().video_frame_at(index / 30.0)
|
||||
steps.append((time.perf_counter() - started_at) * 1000.0)
|
||||
check(
|
||||
replay._depth_thread.daemon,
|
||||
"the depth worker is not a daemon, so a blocked SDK call would keep the "
|
||||
"process alive at exit",
|
||||
max(steps) < 60.0,
|
||||
f"stepping one frame costs {max(steps):.0f} ms, so 30 fps cannot hold",
|
||||
)
|
||||
replay.request_depth() # a second request is refused, not queued
|
||||
check(
|
||||
replay.depth_message() == t("playback.depth_busy"),
|
||||
f"a concurrent recompute was not refused: {replay.depth_message()!r}",
|
||||
)
|
||||
_Blocking.released.set()
|
||||
for _ in range(40):
|
||||
app.processEvents()
|
||||
_time.sleep(0.05)
|
||||
if not replay._depth_busy(): # noqa: SLF001
|
||||
break
|
||||
check(not replay._depth_busy(), "the depth worker never finished")
|
||||
# Deliver the worker result deterministically instead of waiting for the
|
||||
# 120 ms poll timer.
|
||||
replay._poll_depth()
|
||||
app.processEvents()
|
||||
check(
|
||||
t("playback.depth_failed").split("{")[0].strip()[:4] in replay.depth_message(),
|
||||
f"a failed recomputation was not reported: {replay.depth_message()!r}",
|
||||
)
|
||||
check(
|
||||
replay.depth_image() is None,
|
||||
"a failed recomputation left an image in the depth pane",
|
||||
)
|
||||
# The replay view no longer offers a depth pane: depth is not stored with a
|
||||
# recording, and re-running the pipeline over the video is the recompute
|
||||
# mode's job (see the `recompute` section).
|
||||
|
||||
# There is no depth recomputation any more: the engine was deleted with
|
||||
# the replay bar (depth is not stored with a recording, and the replay
|
||||
# view shows the recorded frames). The arrow panel in the replay view
|
||||
# therefore offers the recorded image and nothing else.
|
||||
check(
|
||||
replay_window.arrow_panel.view_modes() == ("recorded",),
|
||||
f"the replay still offers a depth pane: "
|
||||
f"{replay_window.arrow_panel.view_modes()}",
|
||||
)
|
||||
|
||||
# The plan must state where to start, which reset it is anchored to and
|
||||
# whether the state is really recoverable; and a request must belong to the
|
||||
# recording it started in.
|
||||
from orisys_app_shared.record.depth import (
|
||||
STATE_APPROXIMATE,
|
||||
STATE_PREROLL,
|
||||
DepthRecomputer,
|
||||
)
|
||||
from orisys_app_shared.record.playback import RecordingReader
|
||||
|
||||
plan_reader = RecordingReader(recording)
|
||||
planner = DepthRecomputer(plan_reader)
|
||||
frames = plan_reader.info().frames
|
||||
check(bool(frames), "the replay recording has no frame index")
|
||||
plan = planner.plan_for(frames[10])
|
||||
check(
|
||||
plan.state == STATE_APPROXIMATE and plan.anchor == "segment_start",
|
||||
f"a recording with no reset/pre-roll is not reported as approximate: {plan.state}",
|
||||
)
|
||||
check(
|
||||
plan.steps and plan.steps[0].source == plan_reader.segment_path(frames[10].segment),
|
||||
"the plan does not name the video to read",
|
||||
)
|
||||
check(
|
||||
"state_approximate" in plan.warnings,
|
||||
f"the approximate replay is not reported: {plan.warnings}",
|
||||
)
|
||||
# A pre-roll makes the start of a recording reproducible.
|
||||
session_path = recording / "session.json"
|
||||
session_doc = _json.loads(session_path.read_text(encoding="utf-8"))
|
||||
prerecord = recording / "preroll_0001.mp4"
|
||||
prerecord.write_bytes(b"")
|
||||
session_doc["preroll_frames"] = 30
|
||||
session_doc["preroll_segment"] = prerecord.name
|
||||
session_path.write_text(_json.dumps(session_doc), encoding="utf-8")
|
||||
planner.close()
|
||||
planner = DepthRecomputer(RecordingReader(recording))
|
||||
plan = planner.plan_for(planner.reader.info().frames[5])
|
||||
check(
|
||||
plan.state == STATE_PREROLL and plan.preroll == prerecord,
|
||||
f"the pre-roll was not used for the start of the recording: {plan.state}",
|
||||
)
|
||||
check(plan.frames_to_read == 30 + 6, f"unexpected replay cost: {plan.frames_to_read}")
|
||||
planner.close()
|
||||
|
||||
# A reset boundary is re-applied, and a request is bound to its recording.
|
||||
_Blocking.released.clear()
|
||||
second_controller = ReplayController()
|
||||
second_controller.open_recording(recording)
|
||||
serial_before = second_controller._record_serial
|
||||
blocking = _Blocking()
|
||||
second_controller._recomputer = blocking
|
||||
second_controller.request_depth()
|
||||
check(
|
||||
second_controller._depth_pending_record == serial_before,
|
||||
"the request has no recording id",
|
||||
)
|
||||
check(
|
||||
second_controller._depth_pending_seq is not None,
|
||||
"the request carries no sequence number",
|
||||
)
|
||||
# Opening another recording invalidates the running request...
|
||||
second_controller.open_recording(recording)
|
||||
check(
|
||||
second_controller._record_serial == serial_before + 1,
|
||||
"opening a recording did not invalidate the running request",
|
||||
)
|
||||
check(
|
||||
second_controller._recomputer is not blocking,
|
||||
"the invalidated processor was not replaced",
|
||||
)
|
||||
_Blocking.released.set()
|
||||
for _ in range(40):
|
||||
app.processEvents()
|
||||
_time.sleep(0.05)
|
||||
if not second_controller._depth_busy():
|
||||
break
|
||||
second_controller._poll_depth()
|
||||
app.processEvents()
|
||||
check(
|
||||
second_controller.depth_image() is None,
|
||||
"an answer for a previous recording was drawn into the new one",
|
||||
)
|
||||
second_controller.close()
|
||||
app.processEvents()
|
||||
|
||||
# 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.
|
||||
@@ -3169,8 +3072,8 @@ def main() -> int:
|
||||
"leaving the replay view did not restore the live charts",
|
||||
)
|
||||
check(
|
||||
not replay_plots.btn_play.isEnabled(),
|
||||
"the live view still offers the replay's transport",
|
||||
replay_window.toolbar.run_role() != "playback",
|
||||
"the rail kept the replay's transport in the live view",
|
||||
)
|
||||
check(
|
||||
not replay_window.toolbar.source_summary.text().endswith(".mp4")
|
||||
|
||||
Reference in New Issue
Block a user