diff --git a/apps/studio/config/compound_eye.png b/apps/studio/config/compound_eye.png index 7ea4e12..c50ca69 100644 Binary files a/apps/studio/config/compound_eye.png and b/apps/studio/config/compound_eye.png differ diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index bbab459..831e3d0 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -34,6 +34,8 @@ "settings": "Settings area", "plot_settings": "Plot settings…", "rotate_arrows": "Rotate arrows 90°", + "recompute": "Recompute forces from the video", + "recompute_tip": "Off: the charts read the recorded forces.csv. On: re-run the vision pipeline over the recorded video (an algorithm check, forward-only).", "run": "Run / Stop", "stop": "Cancel acquisition", "preview": "Preview / Stop preview", @@ -61,7 +63,9 @@ "search_cameras": "Search", "cam_id": "Camera", "use_camera": "Use camera", - "use_camera_tip": "Go back to acquiring from a camera (a video file is in use)", + "use_camera_tip": "Go back to acquiring from a camera (the data source is a video file)", + "choose_video": "Choose a recorded video…", + "choose_video_tip": "Open a recorded video: the 2D panel plays it and the charts read the forces.csv beside it", "preview": "Preview", "preview_stop": "Stop preview", "run": "Run", @@ -110,8 +114,8 @@ "view_depth": "Depth map", "view_recorded": "Recorded image", "view_mode_tip": "Image display mode while running", - "replay_pipeline": "Recorded video → depth recomputed", - "replay_pipeline_tip": "Replay shows the recorded raw camera image; the depth map is recomputed offline from that video and the config, with no live arrow overlay", + "replay_pipeline": "Recorded video (raw camera image)", + "replay_pipeline_tip": "Replay shows the recorded raw camera image with no live arrow overlay; to check the algorithm, use View → Recompute forces from the video", "replay_empty": "No recording open\nPress “Open recording…” below", "arrow_empty": "No image yet\nPick a video or camera, then press Run", "arrow_empty_tip": "The deformation arrow image appears here while running or previewing", @@ -138,6 +142,10 @@ "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", @@ -165,22 +173,13 @@ }, "playback": { "title": "Replay recording", - "open": "Open recording…", - "open_tip": "Pick a recording directory under outputs/recordings (video + curves + metadata)", - "scrub_tip": "Drag the progress bar to move the playhead: video, depth and the chart cursor follow", - "loaded_short": "{name} · {frames} frames / {duration} s", + "open": "Open a recorded video…", + "video_filter": "Recorded video (*.mp4 *.avi *.mov *.mkv);;All files (*)", + "no_video": "This recording has no usable video segment", "open_failed": "Could not open that recording: {error}", "none_found": "No recording found under outputs/recordings", - "loaded": "{name} — {frames} frames / {duration} s", - "empty": "Pick a recording directory to start replay", - "position": "{position} / {duration} s", - "show_newton": "Show newtons", - "show_raw": "Show raw", - "recompute_depth": "Recompute depth", - "recompute_depth_tip": "Depth is not stored with a recording; recompute this frame offline from the video and the config", "frame_dropped": "This frame never reached the video (the record queue overflowed)", "frame_unreadable": "This frame could not be decoded from the video", - "depth_hint": "Depth is not stored: press Recompute depth to rebuild it from the video and the config", "depth_running": "Recomputing depth… (on a worker thread)", "depth_busy": "The previous recomputation is still running", "depth_empty": "No depth data for this frame", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 78b2eae..737dccf 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -34,6 +34,8 @@ "settings": "设置区", "plot_settings": "绘图设置…", "rotate_arrows": "旋转箭头图 90°", + "recompute": "由视频重算力值", + "recompute_tip": "关闭:曲线读录制的 forces.csv(当时测得的值)。打开:把录制的视频重新跑一遍视觉管线(算法复核,只能顺序前进)。", "run": "运行 / 停止", "stop": "取消采集", "preview": "预览 / 停止预览", @@ -61,7 +63,9 @@ "search_cameras": "搜索", "cam_id": "摄像机", "use_camera": "使用摄像机", - "use_camera_tip": "回到摄像机采集(当前正在用视频文件采集)", + "use_camera_tip": "回到摄像机采集(当前的数据源是视频文件)", + "choose_video": "选择录制视频…", + "choose_video_tip": "打开一段录制的视频:2D 面板放视频,曲线读同目录的 forces.csv(原始记录值)", "preview": "预览", "preview_stop": "停止预览", "run": "运行", @@ -110,8 +114,8 @@ "view_depth": "深度图", "view_recorded": "原始图像", "view_mode_tip": "运行时的图像显示模式", - "replay_pipeline": "录制视频 → 深度重算", - "replay_pipeline_tip": "回放显示的是录制下来的原始相机图像;深度图由录制视频与配置离线重算,不含实时箭头叠加", + "replay_pipeline": "录制视频(原始相机图像)", + "replay_pipeline_tip": "回放显示的是录制下来的原始相机图像,不含实时箭头叠加;要复核算法请用「视图 → 由视频重算力值」", "replay_empty": "尚未打开录制\n点击下方「打开录制…」", "arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」", "arrow_empty_tip": "运行或预览后在此显示形变箭头图", @@ -138,6 +142,10 @@ "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": "跟随实时", @@ -165,22 +173,13 @@ }, "playback": { "title": "录制回放", - "open": "打开录制…", - "open_tip": "选择 outputs/recordings 下的一个录制目录(视频 + 曲线 + 元数据)", - "scrub_tip": "拖动进度条移动回放位置:视频、深度与曲线游标同步", - "loaded_short": "{name} · {frames} 帧 / {duration} s", + "open": "打开录制视频…", + "video_filter": "录制的视频 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)", + "no_video": "这段录制没有可用的视频分段", "open_failed": "无法打开该录制: {error}", "none_found": "outputs/recordings 下没有找到录制", - "loaded": "{name} — {frames} 帧 / {duration} s", - "empty": "选择一个录制目录开始回放", - "position": "{position} / {duration} s", - "show_newton": "显示牛顿值", - "show_raw": "显示原始值", - "recompute_depth": "重算深度", - "recompute_depth_tip": "深度未随录制保存,按录像与配置离线重算当前帧", "frame_dropped": "该帧未被写入视频(记录队列曾溢出)", "frame_unreadable": "该帧无法从视频中解码", - "depth_hint": "深度未存储:点击「重算深度」按原视频与配置离线重算", "depth_running": "正在重算深度…(在后台线程)", "depth_busy": "上一次重算尚未完成", "depth_empty": "该帧没有深度数据", diff --git a/apps/studio/docs/record-channel.md b/apps/studio/docs/record-channel.md index 6f07936..ecff83a 100644 --- a/apps/studio/docs/record-channel.md +++ b/apps/studio/docs/record-channel.md @@ -65,11 +65,22 @@ UI 线程 _on_run_tick ──> 曲线显示(仍可降帧) 帧,因此它的计数与"重复"无关,直接忽略(返回 `None`,行为不变)。 - `packet.fps` 改为**速率**:`(窗口内帧数 - 1) / (首末到达时刻之差)`,窗口 `FPS_WINDOW_S` = 1 s。按"1 秒内到达了多少帧"数数会系统性地偏大(窗口里正好装一秒,抖动就多算一帧)。 -- 这一层是"哪些帧算一帧"的唯一判据,记录、曲线、标定与 CSV 都跟着它走;它不改动 - `packet.fps` 的语义之外的东西 —— **容器速率**仍然是传感器配置值(见 §6.1)。 +- **等待新帧的间隔必须真的睡得着**:Windows 默认计时器分辨率 15.6 ms,`time.sleep(0.002)` + 实际睡 15.6 ms;两拍就跨过 30 fps 的 33 ms 周期,于是 30 fps 的设备被以 16.7 fps 消费 + (隔帧丢)。`_high_resolution_timer()` 在会话期间把分辨率提到 1 ms(结束即还原), + 且重复帧的等待取 `min(距下一帧的时间, 半个周期)`,已经到期时只 `sleep(0)` 让出而不是 + 再睡一拍。实测:同样是 30 fps 的假设备,不带 1 ms 计时器时只能消费 21.6 fps,带上即 30 fps + (`shared/tests/test_worker_frame_rate.py::test_a_30_fps_device_is_consumed_at_30_fps`)。 +- 这一层是"哪些帧算一帧"与"每秒多少帧"的唯一判据,记录、曲线、标定与 CSV 都跟着它走; + 它不改动 `packet.fps` 的语义之外的东西 —— **容器速率**仍然是传感器配置值(见 §6.1)。 `shared/tests/test_worker_frame_rate.py` 用一个假 SDK 驱动真实循环验证:同一帧返回 3 次 -时,packet 数 ≈ 交付帧数(去掉判据后是 11317 : 39),且 `packet.fps` 与交付速率一致。 +时,packet 数 ≈ 交付帧数(去掉判据后是 11317 : 39),30 fps 的假设备必须被以 27–31.5 fps +消费,且 `packet.fps` 与交付速率一致。 + +**文件源的时间轴是视频自己的**:`source` 是字符串(文件)时,`mono_t = frame_id × 1/fps` +(用配置的容器速率),而不是墙钟。否则把 5 s 的录制以 60 fps 跑一遍会落在 0–2 s 的轴上, +无法与被复核的 `forces.csv` 对齐。实时相机仍用单调时钟。 ### 关键文件 @@ -313,24 +324,25 @@ outputs/recordings/_session/ ### 回放界面 -回放**不再是一个独立窗口**:`ui/playback_dialog.py` 已删除,拆成 -`replay.py::ReplayController`(读盘 + 游标 + 深度重算)与 `ui/replay_bar.py::ReplayBar` -(录制选择 + 进度条 + 单位 + 提示),入口在「文件 → 打开录制目录…/回放最近一次录制」 -与品牌轨的「历史回放」。布局与关闭流程见 `docs/shell-lifecycle.md` §2。 +回放**与实时是同一套界面**:`ui/playback_dialog.py` 与 `ui/replay_bar.py` 都已删除。 +`replay.py::ReplayController` 只读录制并发信号,主窗口把它接到同一套面板上——曲线表头的 +播放按钮就是播放头,来源槽在回放视图里换成「选择录制视频…」。布局、单位、看点与关闭流程 +见 `docs/shell-lifecycle.md` §2(含「由视频重算」复核模式)。 -- **一个游标驱动三处**:移动位置同时更新视频帧、深度面板与三张曲线的游标线; - 并且通过 `cursor_moved` 信号同步到主界面的曲线面板。 +- **一个游标驱动三处**:移动位置同时更新视频帧与三张曲线的游标线;并且通过 `cursor_moved` + 信号同步到主界面的曲线面板。 - 单位可切换(raw/N),数据直接来自录制目录的 `forces.csv`。 - **回放按录制实际有的单位打开**:录制里 `FN_N` 等列全空(没有标定)时退回 raw, 否则选 N 会画出三张空图;`raw` 一定存在(那是设备测到的值)。 - 单位由 `ReplayController.set_unit` 一处决定,回放条、曲线与设置区下拉同时改。 - **数值卡读的是游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`):浏览历史或回放 时,三个大数字是播放头所指的样本,而不是实时采集的最后一个样本 —— 否则一张 7.5e5 原始计数的曲线旁边会写着实时的 `0.0`,看起来就像"回放根本没打开"。 实时视图仍是"最新样本就是游标",所以跟随状态下不额外读盘。 - 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。 -- **深度重算在守护线程上执行**,UI 线程立即返回。实测 `_on_recompute_depth` 返回耗时 - 0.001 s,而 SDK 调用仍在后台跑。 +- **深度不随录制保存,界面也不再提供深度视图**(用户明确不需要这条回放路径);需要复核 + 算法时用「由视频重算」,它把录制视频重新跑一遍管线(含深度、形变与接触)。离线深度重算 + 引擎(`ReplayController.request_depth` / `DepthRecomputer`)保留在 `replay.py` 里并有 + 独立测试,但已没有界面入口。 两个必须知晓的边界: diff --git a/apps/studio/docs/shell-lifecycle.md b/apps/studio/docs/shell-lifecycle.md index 19a918d..9687594 100644 --- a/apps/studio/docs/shell-lifecycle.md +++ b/apps/studio/docs/shell-lifecycle.md @@ -54,30 +54,32 @@ --- -## 2. 回放并入主窗口(含进度条) +## 2. 回放与实时是同一套界面(只换来源) -回放**不再是一个独立窗口**。`PlaybackDialog` 已删除,它被拆成两半: +「历史回放」不另开窗口,**也不另起一套控件**:它与「实时监测」是同一套布局、同一条状态轨、 +同一套图表手势。**唯一的差别是左上的来源槽**——实时是摄像机,回放是一段录制视频。 +`ui/replay_bar.py`(进度条 + 录制目录选择)与 `PlaybackDialog` 都已删除。 - `apps/studio/src/orisys_studio/replay.py::ReplayController(QObject)`:录制读取、游标、 - 深度重算(daemon 线程 + 120 ms 轮询、请求绑定 `(_record_serial, seq, frame)`、旧结果一律丢弃)、 - 深度色标(按录制锁存,与实时同一规则)、回放提示(降采样 / 跨版本 / 深度状态)。 + 离线深度重算引擎(daemon 线程 + 120 ms 轮询、请求绑定 `(_record_serial, seq, frame)`、 + 旧结果一律丢弃;**界面已不提供深度视图**)、深度色标、回放提示(降采样 / 跨版本)。 它不画任何东西,只发信号:`position_changed` / `frames_changed` / `status_changed` / `recording_changed`。 -- `apps/studio/src/orisys_studio/ui/replay_bar.py::ReplayBar`:录制选择、录制信息、 - **可拖拽的 QSlider 进度条**、`pos / dur` 读数、单位切换、重算深度、以及第二行的提示。 - -主窗口把两者接起来: - +- **选视频即选录制**:状态轨的「选择录制视频…」(`toolbar.btn_replay_video`)与 + 「文件 → 打开视频…」都走 `_load_replay_video(path)`——视频所在目录就是录制目录, + 于是曲线自动配对同目录的 `forces.csv`。视频与 CSV 仍然同时记录(录制格式不变)。 - 曲线:`ForcePlotsPanel.set_recording_source(reader)` 用 `RecordingCurveSource` 直接读 录制目录(区间按需读盘 + 缓存包络),所以全程总览、拖动、缩放、框选都在同一套曲线上; 表头显示「回放录制(浏览)」,`返回最新` 在录制模式下禁用。 - **回放按录制实际有的单位打开**(`_replay_unit_for`):`FN_N` 等列全空的录制退回 raw, 否则在 newton 偏好下会画出三张空图、每张数值卡一个横杠——看起来就是"回放没加载"。 - 单位由 `ReplayController.set_unit` 一处决定,回放条、曲线、数值卡与设置区下拉一起改。 -- 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像 / 深度图」 - (录制里没有光流,不提供箭头),页脚显示「录制视频 → 深度重算」并隐藏放大倍率与有效区域; - 深度模式显示按录制锁存的色条与范围。 -- 游标是同一个:拖进度条 → 视频帧 + 曲线游标;拖曲线游标 → 回放位置。 + 表头的单位芯片是唯一的单位开关(`_set_display_unit` 同时告诉曲线、工具栏与控制器)。 +- 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像」一项 + (`REPLAY_VIEW_MODES`;录制里没有光流,也没有存储深度),页脚显示「录制视频(原始相机 + 图像)」并隐藏放大倍率与有效区域。 +- **游标就是播放头**:曲线表头的播放按钮(`chart.play` / `chart.pause`,编辑器习惯: + 空格键同效)以 1× 推进游标,到末尾自动停;拖动游标、框选、滚轮平移或拖总览条都会 + **自动暂停**(`ForcePlotsPanel.pause_playback`)。没有独立进度条。 - **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时, 三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放 时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。 @@ -85,12 +87,39 @@ 仍然在跑,但 `_on_run_tick` / `_on_preview_tick` 不再把实时帧画进 2D 面板, `_render_run_image` 直接返回。否则回放画面会被摄像头帧一帧一帧盖掉("回放时好像用的是 摄像头")。切回实时视图才把面板交还给实时帧。 -- 离开回放视图:曲线挂回实时 store 并恢复跟随,回放条隐藏,**已打开的录制保留**。 -- 文件菜单两条入口(`打开录制目录…` / `回放最近一次录制`)都只是切到回放视图并加载录制, - 不再弹第二个窗口。 +- **回放提示不占位置**:降采样 / 跨版本等说明放在来源摘要的 tooltip 里(状态栏只留一次性 + 通知,见 §2.2),打开录制时另发一条 6 秒通知。 +- 离开回放视图:曲线挂回实时 store 并恢复跟随,来源槽换回摄像机,**已打开的录制保留**, + 摘要里的视频名**不再出现**(这正是"视频文件名残留在实时检测里"的修法)。 +- 文件菜单两条入口(`打开录制视频…` / `回放最近一次录制`)都只是切到回放视图并加载录制。 + +### 2.1 「由视频重算」:算法复核模式 + +`视图 → 由视频重算力值`(`act_recompute`)把曲线的来源从**录制的 CSV** 换成**把录制的视频 +重新跑一遍视觉管线**: + +| | 默认(关闭) | 打开 | +|---|---|---| +| 曲线数据 | 录制目录的 `forces.csv`(当时测得的值) | 管线在视频上重算出的样本(进 `SessionStore`) | +| 2D 面板 | 录制视频在游标处的那一帧 | 管线正在处理的帧 | +| 时间轴 | 任意定位(游标、框选、总览条、播放) | 只能顺序前进(管线是有状态的),播放按钮即开始/暂停这一遍 | +| 用途 | 看当时的记录 | 复核算法版本(mp4 有损,数值与原记录会有差异) | + +打开它时会立即开始这一遍(`_on_recompute_toggled` → `force_plots.set_playing(True)` → +`_on_chart_play_toggled` → `_start_run`,只有一条启动路径,避免按钮与菜单各启动一次); +关闭时停止该遍并挂回 CSV。`_replay_owns_image()` 在这一遍运行期间为 False,2D 面板交给 +管线自己画。 `apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核 -进度条与回放布局(不写业务数据,也不需要硬件)。 +统一后的回放布局(不写业务数据,也不需要硬件)。 + +### 2.2 状态栏与来源摘要的分工 + +- 状态轨的来源槽按视图切换:实时 = 摄像机编号 + 搜索;回放 = 「选择录制视频…」。 + `source_summary` 在回放视图里写「视频文件: xxx.mp4」,在实时视图里**只**写帧率—— + 视频名出现在实时视图里就是残留。 +- 底部状态栏只放一次性通知(见 §1 表格与 `_NoticeBar`),回放提示与完整路径放在来源摘要的 + tooltip 里。 ### 曲线缩放与默认模式 diff --git a/apps/studio/docs/ui-palette.md b/apps/studio/docs/ui-palette.md index db604d2..2c5b178 100644 --- a/apps/studio/docs/ui-palette.md +++ b/apps/studio/docs/ui-palette.md @@ -225,6 +225,11 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00 三个数值卡决定,而不是由一行统计文字决定。`ui_check.py` 的 `session browsing` 段断言 该小字 `width() >= sizeHint().width()`(中文与英文都不裁切)。 +9. **两个视图共用一条状态轨**:来源槽是唯一的差别(实时=摄像机、回放=「选择录制视频…」), + 因此轨上的文字宽度预算对两个视图是同一个。回放的视频名写在来源摘要里,**只在回放视图** + (`_refresh_status_rail` 按 `_view_preset` 决定),中英与 1280/1440 都由 + `ui_check.rail_squeezed` 覆盖。 + --- ## 7. 三个顶层视图 = 同一窗口的三套布局 @@ -234,7 +239,7 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00 | 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) | |---|---|---|---| | 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` | -| 历史回放 | 2D 图像 + 力曲线 | 进入全程浏览 | `0, 320, 1120, 0` | +| 历史回放 | 2D 图像 + 力曲线 | 读打开的录制(全程浏览) | `0, 360, 1080, 0`(与实时相同) | | 标定 | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` | | 自由(`custom`,启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 | diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 43a90b7..db2553a 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -61,7 +61,6 @@ from orisys_studio.ui.plot_settings_dialog import ( DEFAULT_PLOT_WINDOW_SEC, PlotSettingsDialog, ) -from orisys_studio.ui.replay_bar import ReplayBar from orisys_studio.ui.sensor_picker import SensorPickerWidget from orisys_studio.ui.toolbar import ToolbarWidget @@ -82,15 +81,21 @@ SESSION_PHASES = ("idle", "connecting", "running", "error") VIEW_PRESETS = ("custom", "live", "replay", "calibration") #: Splitter sizes per view, in panel order (3D, 2D, charts, calibration). #: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``). +#: Replay is the live layout on purpose: the two views differ in their source +#: (a camera, or a recorded video), not in their furniture. VIEW_SPLITTER_SIZES = { "live": (0, 360, 1080, 0), - "replay": (0, 320, 1120, 0), + "replay": (0, 360, 1080, 0), "calibration": (0, 0, 560, 880), } #: The 3D panel only belongs to the live view, and only for a finger sensor. VIEW_SHOWS_3D = ("custom", "live") #: After this long in ``connecting``, tell the user that Stop is the way out. CONNECT_SLOW_HINT_S = 8.0 +#: Playback tick while a recording is playing. 33 ms is one frame at 30 fps; the +#: cursor is advanced from the wall clock, so a missed tick does not slow the +#: recording down. +PLAYBACK_TICK_MS = 33 def _bgr_to_qimage(frame: np.ndarray) -> QImage: @@ -145,6 +150,8 @@ class StudioMainWindow(QMainWindow): self._connect_slow_notified = False #: Whether this run has already said "showing raw, nothing is calibrated". self._raw_fallback_notified = False + #: True while the run over the video of the recompute mode is going. + self._recompute_running = False self._preview = None self._preview_timer = QTimer(self) self._preview_timer.setInterval(33) @@ -237,12 +244,15 @@ class StudioMainWindow(QMainWindow): self.content_splitter.setChildrenCollapsible(False) root.addWidget(self.content_splitter, 1) - # The replay bar lives under the panels (not inside the splitter): it is - # a timeline about the whole session, and it appears only in the replay - # view. The splitter keeps its four panels and its four stored sizes. - self.replay_bar = ReplayBar() - self.replay_bar.setVisible(False) - root.addWidget(self.replay_bar) + # 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. + 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 @@ -583,6 +593,7 @@ class StudioMainWindow(QMainWindow): self.toolbar.camera_id_changed.connect(self._on_camera_id_changed) self.toolbar.sensor_clicked.connect(self._open_sensor_dialog) self.toolbar.use_camera_clicked.connect(self._on_use_camera) + self.toolbar.replay_video_clicked.connect(self._on_open_recording) self.toolbar.run_toggled.connect(self._on_run_toggled) self.toolbar.record_toggled.connect(self._on_record_toggled) self.toolbar.reset_clicked.connect(self._on_reset) @@ -598,11 +609,7 @@ class StudioMainWindow(QMainWindow): self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle) def _wire_replay(self) -> None: - """The bar drives the engine; the engine updates the bar and the views.""" - self.replay_bar.open_requested.connect(self._on_open_recording) - self.replay_bar.position_requested.connect(self.replay.set_position) - self.replay_bar.unit_toggled.connect(self._on_replay_unit_toggled) - self.replay_bar.depth_requested.connect(self.replay.request_depth) + """The controller feeds the views; the chart is the only timeline.""" self.replay.position_changed.connect(self._on_replay_position) self.replay.frames_changed.connect(self._render_replay_frame) self.replay.status_changed.connect(self._refresh_replay_state) @@ -611,28 +618,47 @@ class StudioMainWindow(QMainWindow): self.force_plots.cursor_moved.connect(self._on_cursor_moved) 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._view_preset == "replay" and self.replay.is_loaded(): self.replay.set_position(float(t_rel_s)) def _on_open_recording(self) -> None: - """Choose a recording and switch to the view that replays it.""" - from orisys_app_shared.record import list_recordings + """Choose a recorded video and switch to the view that replays it. - chosen = QFileDialog.getExistingDirectory( - self, t("playback.open"), str(recordings_dir()) + The recording a video belongs to is its own directory, so the picker + takes the *video* (what the operator recognises) and the charts pair it + with the ``forces.csv`` written beside it. + """ + start = str(recordings_dir()) + chosen, _filter = QFileDialog.getOpenFileName( + self, + t("playback.open"), + start, + t("playback.video_filter"), ) if not chosen: return - self._load_recording(Path(chosen)) + self._load_replay_video(Path(chosen)) + + def _load_replay_video(self, video: Path) -> bool: + """Open the recording a recorded video belongs to.""" + video = Path(video) + folder = video.parent if video.is_file() else video + if not self._load_recording(folder): + return False + self.toolbar.set_video_path(str(video)) + self._refresh_status_rail() + return True def _load_recording(self, directory: Path) -> bool: if self._view_preset != "replay": self._apply_view_preset("replay") opened = self.replay.open_recording(Path(directory)) if not opened: - self.statusBar().showMessage( - t("playback.open_failed", error=Path(directory).name) - ) + self._notify(t("playback.open_failed", error=Path(directory).name)) return False self._refresh_replay_state() return True @@ -643,7 +669,7 @@ class StudioMainWindow(QMainWindow): found = list_recordings(recordings_dir()) if not found: - self.statusBar().showMessage(t("playback.none_found")) + self._notify(t("playback.none_found")) return self._load_recording(Path(found[-1])) @@ -651,53 +677,152 @@ class StudioMainWindow(QMainWindow): """A recording was opened (or dropped): re-point everything that reads it.""" self._refresh_replay_state() if not opened: + self._pause_playback() return if self._view_preset == "replay": self._apply_replay_source(True) self._render_replay_frame() def _refresh_replay_state(self) -> None: - """Push the engine's state into the bar, the charts' mode and the rail.""" - loaded = self.replay.is_loaded() - duration = self.replay.duration_s() if loaded else 0.0 - self.replay_bar.set_loaded(loaded, duration) - if loaded: - info = self.replay.info() - name = self.replay.directory().name if self.replay.directory() else "" - self.replay_bar.set_info( - t( - "playback.loaded_short", - name=name, - frames=len(info.frames) if info is not None else 0, - duration=f"{duration:.1f}", - ) - ) - self.replay_bar.set_position(self.replay.position(), duration) - self.replay_bar.set_warnings(self.replay.warnings_text()) - self.replay_bar.set_unit(self.replay.unit()) + """Push the engine's state into the charts' mode and the rail.""" + if self.replay.is_loaded() and not self.toolbar.video_path(): + # Opened without naming a video (the file menu's "latest"): name the + # segment that is on screen so the rail states the same thing the + # picker would. + reader = self.replay.reader() + 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()) + # 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._refresh_status_rail() def _on_replay_position(self, position_s: float) -> None: - self.replay_bar.set_position(position_s, self.replay.duration_s()) self.force_plots.set_cursor(float(position_s)) def _on_replay_unit_toggled(self) -> None: - """The replay's unit switch drives the same unit the charts display.""" - unit = self.replay.toggle_unit() - self.replay_bar.set_unit(unit) - self._set_display_unit(unit) + """The chart's unit chip drives the same unit the replay reads.""" + 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.""" + 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._start_run() + elif self._recompute_running: + self._stop_run(reason="user_stop") + self.force_plots.set_playing(bool(playing) and self._running) + return + if playing and self.replay.is_loaded(): + self._start_playback() + else: + self._pause_playback() + + def _start_playback(self) -> None: + duration = self.replay.duration_s() + if duration <= 0: + self.force_plots.set_playing(False) + return + position = self.replay.position() + if position >= duration - 0.02: + # At the end: playing again means playing from the start. + self._set_replay_position(0.0) + position = 0.0 + self._play_from = float(position) + self._play_started_at = time.perf_counter() + self._play_timer.start() + self.force_plots.set_playing(True) + + def _pause_playback(self) -> None: + if self._play_timer.isActive(): + self._play_timer.stop() + self.force_plots.set_playing(False) + + def _on_play_tick(self) -> None: + """Advance the playhead from the wall clock, not from the tick count.""" + duration = self.replay.duration_s() + elapsed = time.perf_counter() - self._play_started_at + position = self._play_from + elapsed + if position >= duration: + self._set_replay_position(duration) + self._pause_playback() + return + self._set_replay_position(position) + + def _set_replay_position(self, position_s: float) -> None: + """Move the playhead without the position signal echoing back into it.""" + self.replay.set_position(float(position_s)) + + def _recompute_active(self) -> bool: + return bool(self.act_recompute.isChecked()) + + def _replay_video_segment(self) -> Path | None: + """The recorded video a recompute pass should read.""" + reader = self.replay.reader() + segments = sorted(reader.segments()) if reader is not None else [] + if segments: + return Path(segments[0]) + named = self.toolbar.video_path() + if named and Path(named).is_file(): + return Path(named) + return None + + def _on_recompute_toggled(self, checked: bool) -> None: + """Off: read the recorded CSV. On: re-run the pipeline over the video. + + 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. + """ + wanted = bool(checked) and self._view_preset == "replay" and self.replay.is_loaded() + if self.act_recompute.isChecked() != wanted: + self.act_recompute.blockSignals(True) + self.act_recompute.setChecked(wanted) + self.act_recompute.blockSignals(False) + self._pause_playback() + if wanted: + segment = self._replay_video_segment() + if segment is None: + self._notify(t("playback.no_video")) + self.act_recompute.blockSignals(True) + self.act_recompute.setChecked(False) + self.act_recompute.blockSignals(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.force_plots.clear_recording_source() + self.arrow_panel.set_source_kind("live") + self.toolbar.set_source_mode("video") + self.toolbar.set_video_path(str(segment)) + self._refresh_status_rail() + 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) + return + if self._recompute_running: + self._stop_run(reason="user_stop") + if self._view_preset == "replay" and self.replay.is_loaded(): + self._apply_replay_source(True) + self._render_replay_frame() def _render_replay_frame(self) -> None: """Draw what the 2D panel should show for the current replay cursor.""" if self._view_preset != "replay" or not self.replay.is_loaded(): return - if self.arrow_panel.view_mode() == "depth": - self.arrow_panel.set_depth_scale(self._replay_depth_scale_text()) - image = self.replay.depth_image() - if image is None: - self.arrow_panel.clear_preview(self.replay.depth_message()) - else: - self.arrow_panel.show_frame(image) + if self._recompute_running: + # The pipeline is drawing its own frames (see ``_replay_owns_image``). return self.arrow_panel.set_depth_scale(None) image = self.replay.video_image() @@ -707,7 +832,12 @@ class StudioMainWindow(QMainWindow): self.arrow_panel.show_frame(image) def _replay_depth_scale_text(self) -> str: - """The depth colour scale, latched per recording by the controller.""" + """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 "" @@ -724,10 +854,10 @@ class StudioMainWindow(QMainWindow): # the recording can honour it. wanted = "N" if self.force_plots.unit() == "N" else "raw" unit = self._replay_unit_for(reader) - self.replay_bar.set_unit(self.replay.set_unit(unit)) + self.replay.set_unit(unit) self._set_display_unit(unit) if unit != wanted: - self.statusBar().showMessage(t("status.units_raw_fallback")) + self._notify(t("status.units_raw_fallback")) self.force_plots.show_all() self.arrow_panel.set_source_kind("replay") return @@ -829,6 +959,14 @@ class StudioMainWindow(QMainWindow): self.act_playback_latest.triggered.connect(self._open_latest_recording) self.file_menu.addAction(self.act_playback_latest) + # The transport is on the chart header; the space bar is the editing + # convention everyone's hands already know. + self.act_play = QAction(self) + self.act_play.setShortcut("Space") + self.act_play.setShortcutContext(Qt.ApplicationShortcut) + self.act_play.triggered.connect(self._on_play_shortcut) + self.addAction(self.act_play) + self.view_menu = menu_bar.addMenu("") self.act_cal_panel = QAction(self) self.act_cal_panel.setCheckable(True) @@ -861,6 +999,15 @@ class StudioMainWindow(QMainWindow): self.act_rotate_arrows = QAction(self) self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows) self.view_menu.addAction(self.act_rotate_arrows) + + # The replay reads the recorded CSV (the values the session actually + # measured). This switches it to re-running the vision pipeline over the + # recorded video instead — an algorithm check, not the record. + self.act_recompute = QAction(self) + self.act_recompute.setCheckable(True) + self.act_recompute.setChecked(False) + self.act_recompute.toggled.connect(self._on_recompute_toggled) + self.view_menu.addAction(self.act_recompute) self.view_menu.addSeparator() self.act_run = QAction(self) @@ -932,6 +1079,9 @@ class StudioMainWindow(QMainWindow): self.act_finger_3d.blockSignals(True) self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False) self.act_finger_3d.blockSignals(False) + self.act_recompute.setEnabled( + self._view_preset == "replay" and self.replay.is_loaded() + ) self._apply_arrow_rotation_for_sensor() def _apply_panel_visibility(self) -> None: @@ -997,21 +1147,25 @@ class StudioMainWindow(QMainWindow): self._show_cal = True elif preset in ("live", "replay"): self._show_cal = False - # The rail shows the picker this view needs: a camera while acquiring, - # nothing while replaying (the replay bar owns the recording picker). + # The rail shows the source this view needs, and only that source: a + # camera while measuring, a recorded video while replaying. It is the one + # visible difference between the two views. + self._pause_playback() self.toolbar.set_source_view("replay" if preset == "replay" else "live") self._apply_panel_visibility() - self.replay_bar.setVisible(preset == "replay") if apply_sizes and preset in VIEW_SPLITTER_SIZES: self.content_splitter.setSizes(list(VIEW_SPLITTER_SIZES[preset])) if preset == "replay": # History browsing is the point of this view: the charts read the - # open recording, and the bar scrubs it. Without a recording the - # charts stay on the live session and follow it. + # open recording, and the chart cursor is its timeline. Without a + # recording the charts stay on the live session and follow it. self._apply_replay_source(True) self._refresh_replay_state() elif preset == "live": self._apply_replay_source(False) + # Leaving the replay must not leave its video on the rail: the live + # view is a camera, and the summary says so (see ``_refresh_status_rail``). + self._on_recompute_toggled(False) self.brand_bar.set_active_view(preset if preset != "custom" else None) self._sync_menu_checks() self._refresh_status_rail() @@ -1053,6 +1207,8 @@ class StudioMainWindow(QMainWindow): self.act_settings.setText(t("menu.settings")) self.act_plot_settings.setText(t("menu.plot_settings")) self.act_rotate_arrows.setText(t("menu.rotate_arrows")) + self.act_recompute.setText(t("menu.recompute")) + self.act_recompute.setToolTip(t("menu.recompute_tip")) self.act_run.setText(t("menu.run")) self.act_stop.setText(t("menu.stop")) self.act_preview.setText(t("menu.preview")) @@ -1123,16 +1279,27 @@ class StudioMainWindow(QMainWindow): ) ) # The picker on the rail already names the camera, so the summary carries - # only what it cannot: the video file in use, and the frame rate. - if self.toolbar.source_mode() == "video": + # only what it cannot: the frame rate, and — **in the replay view only** — + # the video part being played. Naming the video in the live view was how a + # replay's file stayed on the rail after switching back to the camera. + detail = "" + if self._view_preset == "replay": path = self.toolbar.video_path() - detail = t("status.rail_video_name", name=Path(path).name if path else "?") - else: - detail = "" + if path: + detail = t("status.rail_video_name", name=Path(path).name) if self._show_fps and self._latest_fps is not None: fps = f"{self._latest_fps:.1f} FPS" detail = f"{detail} · {fps}" if detail else fps self.toolbar.set_source_summary(detail) + # The full path and the replay's notes do not fit on a 30 px rail, so + # they live in the summary's tooltip (the bar that used to show them is + # gone: the two views share one layout). + tip_parts = [self.toolbar.video_path()] + if self._view_preset == "replay" and self.replay.is_loaded(): + tip_parts.append(self.replay.warnings_text()) + self.toolbar.set_source_summary_tip( + "\n".join(part for part in tip_parts if part) + ) self.toolbar.set_calibration_summary(self._calibration_summary()) if self._last_sample_valid is None: self.toolbar.set_data_state("") @@ -1330,6 +1497,9 @@ class StudioMainWindow(QMainWindow): """ if sync_toolbar: self.toolbar.set_units("newton" if unit == "N" else "raw") + if self.replay.is_loaded(): + # One unit for the charts, the rail and the replay controller. + self.replay.set_unit(unit) self.prefs["units"] = self.toolbar.units() self.force_plots.set_fixed_newton(unit == "N") # Rebuild the visible curves from the attached source (the session store @@ -1637,6 +1807,12 @@ class StudioMainWindow(QMainWindow): self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled()) self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled()) + 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(): + return + self.force_plots.btn_play.setChecked(not self.force_plots.btn_play.isChecked()) + def _on_run_shortcut(self) -> None: if not self.toolbar.btn_run.isEnabled(): return @@ -1661,7 +1837,12 @@ class StudioMainWindow(QMainWindow): self._persist_prefs() def _on_browse_video(self) -> None: - """Acquire from a video file instead of a camera (file menu).""" + """Open a recorded video for replay (file menu). + + Choosing a video is the replay view's source selection, so this takes the + same path as the rail's button: a recorded video is something to *play*, + not a second way to acquire. + """ if self._running or self._preview_open: return start = self.toolbar.video_path() or str(Path.home()) @@ -1669,13 +1850,11 @@ class StudioMainWindow(QMainWindow): self, t("menu.open_video"), start, - "Video (*.mp4 *.avi *.mov *.mkv);;All (*)", + t("playback.video_filter"), ) if not path: return - self.toolbar.set_video_path(path) - self.prefs["video_path"] = path - self._set_source_mode("video") + self._load_replay_video(Path(path)) def _on_reveal_config(self) -> None: sensor_type = self.sensor_picker.sensor_type() @@ -1891,6 +2070,9 @@ class StudioMainWindow(QMainWindow): self._active_worker.stop(wait=wait) was_connecting = self._session_phase == "connecting" 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) self._set_phase("error" if reason == "failed" else "idle") self.cal_panel.set_running(False) self.toolbar.set_run_checked(False) @@ -2148,9 +2330,14 @@ class StudioMainWindow(QMainWindow): 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. + 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.is_loaded() + return ( + self._view_preset == "replay" + and self.replay.is_loaded() + and not self._recompute_running + ) def _render_run_image(self, packet) -> None: if packet is None or self._replay_owns_image(): diff --git a/apps/studio/src/orisys_studio/replay.py b/apps/studio/src/orisys_studio/replay.py index dc0c373..48584f2 100644 --- a/apps/studio/src/orisys_studio/replay.py +++ b/apps/studio/src/orisys_studio/replay.py @@ -244,7 +244,7 @@ class ReplayController(QObject): if self._reader is None: self._video_position = None self._video_qimage = None - self._video_reason = t("playback.empty") + self._video_reason = t("playback.none_found") return if self._video_position == self._position: return diff --git a/apps/studio/src/orisys_studio/ui/arrow_panel.py b/apps/studio/src/orisys_studio/ui/arrow_panel.py index 21d61d1..a430bf2 100644 --- a/apps/studio/src/orisys_studio/ui/arrow_panel.py +++ b/apps/studio/src/orisys_studio/ui/arrow_panel.py @@ -19,9 +19,9 @@ from orisys_app_shared.theme import color from orisys_studio.ui.fit_text import ShrinkableLabel VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth") -#: While replaying a recording there is no optical flow to draw: the frames come -#: from the recorded video, and the only processed view is the recomputed depth. -REPLAY_VIEW_MODES = ("recorded", "depth") +#: While replaying a recording there is no optical flow to draw and no depth is +#: stored: the panel shows the recorded frames, and nothing else. +REPLAY_VIEW_MODES = ("recorded",) _VIEW_LABELS = { "arrows_pure": "panels.view_arrows_pure", "arrows_stitched": "panels.view_arrows_stitched", diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index d6f4d09..39a132d 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -118,6 +118,8 @@ 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) def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) @@ -173,6 +175,13 @@ 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. self.btn_latest = self._header_button("latest") @@ -325,6 +334,32 @@ 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) + def _build_value_card( self, channel: str ) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel, QLabel]: @@ -383,7 +418,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): + for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom, self.btn_play): name = getattr(button, "_icon_name", "") if name: button.setIcon(icons.icon(name, theme)) @@ -781,6 +816,7 @@ class ForcePlotsPanel(QFrame): value = float(self.cursors[0].value()) if value == self._cursor_t: return + self.pause_playback() self.set_cursor(value, emit=True) def _on_box_zoom_toggled(self, checked: bool) -> None: @@ -806,6 +842,7 @@ class ForcePlotsPanel(QFrame): x0, x1 = float(view_range[0][0]), float(view_range[0][1]) if not (x1 > x0): return + self.pause_playback() first, last = self._source.extent() if last <= first: return @@ -1012,6 +1049,7 @@ class ForcePlotsPanel(QFrame): start, end = (float(v) for v in self.overview_region.getRegion()) if not (end > start): return + self.pause_playback() first, last = self._source.extent() if last <= first: return @@ -1035,11 +1073,15 @@ class ForcePlotsPanel(QFrame): self.mode_label.setText(t(key)) self.mode_label.setToolTip(t(f"{key}_tip")) # "Back to latest" only means something while reading a live session; a - # recording has no latest sample to return to. + # recording has no latest sample to return to. The transport exists only + # where there is a recording to move through. self.btn_latest.setEnabled( 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.""" @@ -1199,6 +1241,7 @@ 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")) diff --git a/apps/studio/src/orisys_studio/ui/icons.py b/apps/studio/src/orisys_studio/ui/icons.py index 44b2c53..46ee3cf 100644 --- a/apps/studio/src/orisys_studio/ui/icons.py +++ b/apps/studio/src/orisys_studio/ui/icons.py @@ -14,13 +14,13 @@ from __future__ import annotations from functools import lru_cache -from PyQt5.QtCore import QRectF, Qt -from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap +from PyQt5.QtCore import QPointF, QRectF, Qt +from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap, QPolygonF from orisys_app_shared.theme import color #: Names of the icons this module can paint. -NAMES = ("zoom_fit", "box_zoom", "latest") +NAMES = ("zoom_fit", "box_zoom", "latest", "play", "pause") ICON_SIZE = 16 _PEN_WIDTH = 1.4 @@ -76,10 +76,36 @@ 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, } diff --git a/apps/studio/src/orisys_studio/ui/replay_bar.py b/apps/studio/src/orisys_studio/ui/replay_bar.py deleted file mode 100644 index b67a632..0000000 --- a/apps/studio/src/orisys_studio/ui/replay_bar.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Replay bar: pick a recording, scrub it, and read what it can tell you. - -One full-width line under the panels, shown only in the history/replay view: -the recording picker, the draggable progress bar that *is* the playhead, the -position readout, the unit switch, the depth recomputation, and a second line -for the replay's own warnings (decimation, cross-version notes, depth state). - -The bar owns no replay logic: it renders what -:class:`~orisys_studio.replay.ReplayController` reports and reports gestures -back through signals. -""" - -from __future__ import annotations - -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtWidgets import ( - QHBoxLayout, - QLabel, - QPushButton, - QSlider, - QVBoxLayout, - QWidget, -) - -from orisys_app_shared.i18n import t - -from orisys_studio.ui.fit_text import ShrinkableLabel - -#: Slider resolution. Fine enough for frame-level scrubbing of a long session. -SLIDER_STEPS = 2000 - - -class ReplayBar(QWidget): - """Progress bar and controls for the recording being replayed.""" - - #: The user asked to choose another recording. - open_requested = pyqtSignal() - #: The user dragged the progress bar to this relative time. - position_requested = pyqtSignal(float) - #: The user asked to switch between newton and raw. - unit_toggled = pyqtSignal() - #: The user asked to recompute the depth for the current position. - depth_requested = pyqtSignal() - - def __init__(self, parent=None) -> None: - super().__init__(parent) - self._duration = 0.0 - self._loaded = False - root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(2) - - row = QHBoxLayout() - row.setSpacing(8) - self.btn_open = QPushButton() - self.btn_open.setProperty("compact", "true") - self.btn_open.clicked.connect(self.open_requested.emit) - self.info_label = ShrinkableLabel() - self.info_label.setObjectName("panelMeta") - self.slider = QSlider(Qt.Horizontal) - self.slider.setRange(0, SLIDER_STEPS) - self.slider.setTracking(True) - self.slider.setEnabled(False) - self.slider.valueChanged.connect(self._on_slider) - self.position_label = QLabel() - self.position_label.setObjectName("panelMeta") - self.position_label.setMinimumWidth(120) - self.btn_unit = QPushButton() - self.btn_unit.setProperty("compact", "true") - self.btn_unit.clicked.connect(self.unit_toggled.emit) - self.btn_depth = QPushButton() - self.btn_depth.setProperty("compact", "true") - self.btn_depth.clicked.connect(self.depth_requested.emit) - - row.addWidget(self.btn_open) - row.addWidget(self.info_label, 1) - row.addWidget(self.slider, 3) - row.addWidget(self.position_label) - row.addWidget(self.btn_unit) - row.addWidget(self.btn_depth) - root.addLayout(row) - - self.warning_label = ShrinkableLabel() - self.warning_label.setObjectName("hintText") - self.warning_label.setWordWrap(False) - root.addWidget(self.warning_label) - - self.retranslate() - self.set_loaded(False, 0.0) - - # ------------------------------------------------------------------ state - def set_loaded(self, loaded: bool, duration_s: float = 0.0) -> None: - self._loaded = bool(loaded) - self._duration = max(0.0, float(duration_s)) - self.slider.setEnabled(self._loaded and self._duration > 0) - self.btn_unit.setEnabled(self._loaded) - self.btn_depth.setEnabled(self._loaded) - if not self._loaded: - self.info_label.setText(t("playback.empty")) - self.position_label.setText("") - self.warning_label.setText("") - self.slider.blockSignals(True) - self.slider.setValue(0) - self.slider.blockSignals(False) - - def set_info(self, text: str) -> None: - self.info_label.setText(str(text or "")) - self.info_label.setToolTip(self.info_label.text()) - - def set_position(self, position_s: float, duration_s: float | None = None) -> None: - """Reflect the controller's cursor without echoing it back.""" - if duration_s is not None: - self._duration = max(0.0, float(duration_s)) - self.position_label.setText( - t( - "playback.position", - position=f"{max(0.0, float(position_s)):.2f}", - duration=f"{self._duration:.2f}", - ) - ) - if self._duration <= 0: - return - value = int(SLIDER_STEPS * max(0.0, min(float(position_s), self._duration)) / self._duration) - self.slider.blockSignals(True) - self.slider.setValue(value) - self.slider.blockSignals(False) - - def set_warnings(self, text: str) -> None: - self.warning_label.setText(str(text or "")) - self.warning_label.setToolTip(self.warning_label.text()) - self.warning_label.setVisible(bool(text)) - - def set_depth_enabled(self, enabled: bool) -> None: - self.btn_depth.setEnabled(bool(enabled) and self._loaded) - - def set_unit(self, unit: str) -> None: - self.btn_unit.setText( - t("playback.show_raw") if unit == "N" else t("playback.show_newton") - ) - - def duration_s(self) -> float: - return self._duration - - # -------------------------------------------------------------- internals - def _on_slider(self, value: int) -> None: - if not self._loaded or self._duration <= 0: - return - self.position_requested.emit(self._duration * (value / SLIDER_STEPS)) - - def retranslate(self) -> None: - self.btn_open.setText(t("playback.open")) - self.btn_open.setToolTip(t("playback.open_tip")) - self.btn_depth.setText(t("playback.recompute_depth")) - self.btn_depth.setToolTip(t("playback.recompute_depth_tip")) - self.slider.setToolTip(t("playback.scrub_tip")) - self.set_unit("N") - if not self._loaded: - self.info_label.setText(t("playback.empty")) diff --git a/apps/studio/src/orisys_studio/ui/toolbar.py b/apps/studio/src/orisys_studio/ui/toolbar.py index 9c2138e..f57cf5b 100644 --- a/apps/studio/src/orisys_studio/ui/toolbar.py +++ b/apps/studio/src/orisys_studio/ui/toolbar.py @@ -77,6 +77,8 @@ class ToolbarWidget(QWidget): sensor_clicked = pyqtSignal() #: "Back to the camera" while a video file is the run source. use_camera_clicked = pyqtSignal() + #: Replay view: pick the recorded video to play. + replay_video_clicked = pyqtSignal() run_toggled = pyqtSignal(bool) reset_clicked = pyqtSignal() units_changed = pyqtSignal(str) @@ -193,8 +195,21 @@ class ToolbarWidget(QWidget): self.btn_use_camera.setMinimumHeight(BUTTON_HEIGHT) self.btn_use_camera.clicked.connect(self.use_camera_clicked.emit) video_layout.addWidget(self.btn_use_camera) + # The replay view's source is a recorded video: one action in the same + # slot the live view uses for its camera. The file itself is named by the + # summary next to it, so the rail reads the same way in both views. + self.replay_group = QWidget() + replay_layout = QHBoxLayout(self.replay_group) + replay_layout.setContentsMargins(0, 0, 0, 0) + replay_layout.setSpacing(6) + self.btn_replay_video = QPushButton() + self.btn_replay_video.setProperty("compact", "true") + self.btn_replay_video.setMinimumHeight(BUTTON_HEIGHT) + self.btn_replay_video.clicked.connect(self.replay_video_clicked.emit) + replay_layout.addWidget(self.btn_replay_video) slot.addWidget(self.camera_group) slot.addWidget(self.video_group) + slot.addWidget(self.replay_group) self.btn_record = QPushButton() self.btn_record.setObjectName("primaryButton") @@ -313,6 +328,10 @@ class ToolbarWidget(QWidget): self.source_summary.setToolTip(self.source_summary.text()) self.source_summary.setVisible(bool(text)) + def set_source_summary_tip(self, text: str) -> None: + """What the summary cannot fit: the full path, and any replay notes.""" + self.source_summary.setToolTip(str(text or "")) + def set_calibration_summary(self, text: str) -> None: self.calibration_label.setText(str(text or "")) self.calibration_label.setToolTip(self.calibration_label.text()) @@ -405,6 +424,9 @@ class ToolbarWidget(QWidget): self.btn_search.setEnabled(allowed and camera) self.cam_id_spin.setEnabled(allowed and camera) self.btn_use_camera.setEnabled(allowed and not camera) + # The replay's video picker belongs to the replay view, and follows the + # same rule as the camera picker: not while a session is running. + self.btn_replay_video.setEnabled(bool(enabled) and not live) def set_source_controls_enabled(self, enabled: bool) -> None: self._source_controls_enabled = bool(enabled) @@ -518,15 +540,16 @@ class ToolbarWidget(QWidget): """Show the picker the current view needs, and nothing else. Live + camera: the camera id and its search button. Live + video: a - one-click way back to the camera (the file itself is chosen from the - file menu and named by the rail's summary). Replay: neither — the replay - bar owns the recording picker. + one-click way back to the camera (that source is the recompute pass's). + Replay: the recorded video, which is the one difference between the two + views' rails. """ live = self._source_view == "live" video = self.source_mode() == "video" self.camera_group.setVisible(live and not video) self.video_group.setVisible(live and video) - self.source_slot.setVisible(live) + self.replay_group.setVisible(not live) + self.source_slot.setVisible(True) self.set_source_controls_enabled(self._source_controls_enabled) if video and self.btn_preview.isChecked(): self.set_preview_checked(False) @@ -536,6 +559,7 @@ class ToolbarWidget(QWidget): path = self.video_path() self.source_summary.setToolTip(path or self.source_summary.text()) self.btn_use_camera.setToolTip(t("toolbar.use_camera_tip")) + self.btn_replay_video.setToolTip(t("toolbar.choose_video_tip")) def _on_preview_toggled(self, checked: bool) -> None: self._refresh_preview_label() @@ -619,6 +643,7 @@ class ToolbarWidget(QWidget): self.cam_id_label.setText(t("toolbar.cam_id")) self.btn_search.setText(t("toolbar.search_cameras")) self.btn_use_camera.setText(t("toolbar.use_camera")) + self.btn_replay_video.setText(t("toolbar.choose_video")) self.refresh_labels() self.btn_reset.setText(t("toolbar.reset")) self.units_label.setText(t("toolbar.units")) diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 29ff2fb..711306c 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -1518,26 +1518,44 @@ def test_the_sensor_summary_opens_the_dialog(make_window): def test_the_source_picker_follows_the_view(make_window): - """Live offers a camera, a video-file run offers the way back, replay neither.""" + """Live offers a camera, the replay a recorded video — and no residue.""" from PyQt5.QtWidgets import QApplication window, stuck = _running_window(make_window) assert window.toolbar.camera_group.isVisible() assert not window.toolbar.video_group.isVisible() + assert not window.toolbar.btn_replay_video.isVisible() assert not hasattr(window.toolbar, "source_combo"), "the source is still a combobox" window.brand_bar.btn_playback.click() QApplication.processEvents() assert not window.toolbar.camera_group.isVisible(), "the replay view shows a camera picker" + assert window.toolbar.btn_replay_video.isVisible() window.brand_bar.btn_live.click() QApplication.processEvents() assert window.toolbar.camera_group.isVisible() + # A recorded video belongs to the replay view: the live view is a camera, and + # it must not keep naming a file after the view has changed. window.toolbar.set_video_path("C:/tmp/demo.mp4") window._set_source_mode("video") QApplication.processEvents() assert window.toolbar.video_group.isVisible() - assert "demo.mp4" in window.toolbar.source_summary.text() + assert "demo.mp4" not in window.toolbar.source_summary.text(), ( + f"the live view names a replay's video: " + f"{window.toolbar.source_summary.text()!r}" + ) + window.brand_bar.btn_playback.click() + QApplication.processEvents() + assert "demo.mp4" in window.toolbar.source_summary.text(), ( + f"the replay view does not name its video: " + f"{window.toolbar.source_summary.text()!r}" + ) + window.brand_bar.btn_live.click() + QApplication.processEvents() + assert "demo.mp4" not in window.toolbar.source_summary.text(), ( + "the video name stayed on the rail after leaving the replay" + ) assert not window.toolbar.btn_preview.isEnabled(), "preview is offered for a video file" window.toolbar.use_camera_clicked.emit() @@ -1565,10 +1583,12 @@ def test_the_nav_items_switch_the_main_window_layout(make_window): window.brand_bar.btn_playback.click() assert window.view_preset() == "replay" assert window.arrow_panel.isVisible() and not window.cal_panel.isVisible() - # No recording is open yet: the bar says so, and the charts keep following - # the live session instead of pretending to replay something. - assert window.replay_bar.isVisible() and not window.replay.is_loaded() + # No recording is open yet: the charts keep following the live session + # instead of pretending to replay something, and the layout is the live one. + assert not window.replay.is_loaded() assert window.force_plots.mode() == "follow", "browsing starts with no recording" + assert window.toolbar.btn_replay_video.isVisible(), "no video picker in the replay" + live_sizes = tuple(window.content_splitter.sizes()) window.brand_bar.btn_live.click() assert window.view_preset() == "live" @@ -1612,24 +1632,45 @@ def test_the_2d_area_keeps_the_output_aspect_ratio(make_window): ) -def test_the_replay_bar_labels_its_controls(make_window): - """The old replay window's controls had no text: it never retranslated.""" +def test_the_replay_view_is_the_live_view_with_a_video_source(make_window): + """One layout for both views; the only difference is the source slot. + + The replay used to carry its own bar (a progress slider plus a recording + picker). It is gone: the chart's cursor is the playhead, its header carries + the transport, and the rail shows the video instead of the camera. + """ from orisys_app_shared.i18n import t window = _show(make_window(), (1440, 880)) - bar = window.replay_bar - assert bar.btn_open.text() == t("playback.open") - assert bar.btn_open.toolTip() == t("playback.open_tip") - assert bar.btn_depth.text() == t("playback.recompute_depth") - assert bar.btn_depth.toolTip() == t("playback.recompute_depth_tip") - assert bar.btn_unit.text(), "the unit switch is unlabelled" - assert bar.slider.toolTip() == t("playback.scrub_tip") - assert bar.info_label.text() == t("playback.empty") - assert not bar.slider.isEnabled(), "the progress bar is live with no recording" + window.brand_bar.btn_live.click() + from PyQt5.QtWidgets import QApplication + + QApplication.processEvents() + live_sizes = tuple(window.content_splitter.sizes()) + + window.brand_bar.btn_playback.click() + QApplication.processEvents() + assert not hasattr(window, "replay_bar"), "the replay still has a separate bar" + assert tuple(window.content_splitter.sizes()) == live_sizes, ( + "the replay view is not laid out like the live view" + ) + assert window.toolbar.btn_replay_video.isVisible(), "no video picker while replaying" + assert window.toolbar.btn_replay_video.text() == t("toolbar.choose_video") + assert window.toolbar.btn_replay_video.toolTip() == t("toolbar.choose_video_tip") + assert not window.toolbar.camera_group.isVisible(), "the replay offers the camera" + assert window.brand_bar.btn_live.property("navActive") != "true" + + window.brand_bar.btn_live.click() + QApplication.processEvents() + assert window.toolbar.camera_group.isVisible(), "the live view lost its camera picker" + assert not window.toolbar.btn_replay_video.isVisible(), ( + "the live view still shows the replay's video picker" + ) + assert not window.toolbar.btn_replay_video.isVisible() def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording): - """The replay view reads the recording: charts, 2D panel and progress bar.""" + """The replay view reads the recording: charts, 2D panel and transport.""" from PyQt5.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -1637,7 +1678,6 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec window = _show(make_window(), (1440, 880)) window.brand_bar.btn_playback.click() QApplication.processEvents() - assert window.replay_bar.isVisible(), "the replay view has no progress bar" assert window._load_recording(replay_recording) QApplication.processEvents() @@ -1649,28 +1689,35 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec drawn = [int(curve.getData()[0].size) for curve in panel.curves] assert all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}" - assert window.arrow_panel.view_modes() == ("recorded", "depth") + assert window.arrow_panel.view_modes() == ("recorded",) assert window.replay.video_image() is not None, "no frame at the cursor" - assert window.replay_bar.slider.isEnabled() + assert panel.btn_play.isEnabled(), "the replay has no transport" + assert not panel.btn_play.isChecked(), "the replay starts out playing" - # The progress bar is the playhead. - window.replay_bar.slider.setValue(window.replay_bar.slider.maximum() // 2) + # The cursor is the playhead: setting it moves the video. + window.replay.set_position(window.replay.duration_s() / 2) QApplication.processEvents() assert 0.0 < window.replay.position() < window.replay.duration_s() assert abs(panel.cursors[0].value() - window.replay.position()) < 0.05 - # Dragging the chart cursor scrubs the recording too. + # Dragging the chart cursor scrubs the recording too, and pauses it. + panel.btn_play.setChecked(True) + QApplication.processEvents() 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" # 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 window.replay_bar.isVisible() + assert not panel.btn_play.isEnabled(), "the live view still offers the transport" 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" + ) # ------------------------------------------------- correctness regressions @@ -1870,7 +1917,7 @@ def test_a_replay_keeps_the_2d_pane_from_the_live_camera( label = window.arrow_panel.image_label # Park the playhead at the end of the recording and remember what the pane and # the cards show there, so a live packet that overwrote either would show up. - window.replay_bar.slider.setValue(window.replay_bar.slider.maximum()) + window.replay.set_position(window.replay.duration_s()) QApplication.processEvents() before_key = label.pixmap().cacheKey() before_text = label.text() @@ -1922,12 +1969,11 @@ def test_the_readout_describes_the_point_the_cursor_is_on( plots = window.force_plots assert plots.mode() == "browse", "opening a recording did not browse it" - slider = window.replay_bar.slider - slider.setValue(slider.minimum()) + window.replay.set_position(0.0) QApplication.processEvents() first = plots.value_labels[0].text() assert first not in ("", t("chart.value_unavailable")), first - slider.setValue(slider.maximum()) + window.replay.set_position(window.replay.duration_s()) QApplication.processEvents() last = plots.value_labels[0].text() @@ -1960,14 +2006,91 @@ def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recor panel = window.force_plots assert panel.unit() == "raw", "a raw-only recording was shown in newton" assert window.toolbar.units() == "raw", "the toolbar disagrees with the charts" - assert window.replay_bar.btn_unit.text() == t("playback.show_newton"), ( - "the replay bar's unit switch disagrees with the charts" + assert panel.unit_badge.text() == t("units.raw_short"), ( + "the chart's unit chip disagrees with the charts" ) drawn = [int(curve.getData()[0].size) for curve in panel.curves] assert all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}" assert panel.value_labels[0].text() not in ("", t("chart.value_unavailable")) +def test_the_replay_transport_plays_and_pauses(make_window, replay_recording): + """Play/pause like an editor: 1× forward, and a manual gesture takes over.""" + import time + + from PyQt5.QtWidgets import QApplication + + window = _show(make_window(), (1440, 880)) + window.brand_bar.btn_playback.click() + QApplication.processEvents() + assert window._load_recording(replay_recording) + QApplication.processEvents() + panel = window.force_plots + + assert panel.btn_play.isEnabled() and not panel.btn_play.isChecked() + panel.btn_play.setChecked(True) + started = time.perf_counter() + for _ in range(10): + QApplication.processEvents() + time.sleep(0.03) + elapsed = time.perf_counter() - started + assert window.replay.position() > 0.05, "play did not advance the playhead" + assert window.replay.position() <= elapsed + 0.2, ( + f"the playhead ran ahead of the clock: {window.replay.position()} > {elapsed}" + ) + assert abs(panel.cursors[0].value() - window.replay.position()) < 0.05, ( + "the chart cursor did not follow the transport" + ) + + # 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 abs(window.replay.position() - 0.2) < 0.05 + parked = window.replay.position() + for _ in range(6): + QApplication.processEvents() + time.sleep(0.03) + assert window.replay.position() == parked, "the playhead kept moving while paused" + + +def test_the_recompute_mode_reruns_the_pipeline_over_the_video( + make_window, replay_recording +): + """Off: the recorded CSV. On: the recorded video through the vision chain.""" + from PyQt5.QtWidgets import QApplication + + window = _show(make_window(), (1440, 880)) + window.brand_bar.btn_playback.click() + QApplication.processEvents() + assert window._load_recording(replay_recording) + QApplication.processEvents() + panel = window.force_plots + assert panel.is_recording_source(), "the charts do not read the recording" + assert window.act_recompute.isEnabled(), "the recompute switch is not offered" + + started: list = [] + + def fake_run() -> None: + started.append(True) + window._running = True + + window._start_run = fake_run + 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 panel.btn_play.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" + + # ------------------------------------------- selection readout and notices diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index fb9d9cb..404887d 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -517,7 +517,7 @@ def main() -> int: from orisys_app_shared.force.calibration_io import CalSample from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.theme import build_qss, load_colors - from orisys_studio.app_window import StudioMainWindow + from orisys_studio.app_window import VIEW_SPLITTER_SIZES, StudioMainWindow app = QApplication.instance() or QApplication(sys.argv) set_locale("zh_CN") @@ -1491,17 +1491,44 @@ def main() -> int: not window.cal_panel.isVisible() and window.arrow_panel.isVisible(), "the replay view is not the 2D + charts arrangement", ) - # No recording is open yet: the bar says so and the charts keep following the - # live session instead of pretending to replay something. - check( - window.replay_bar.isVisible() and not window.replay.is_loaded(), - "the replay view is not showing an empty progress bar", - ) + # No recording is open yet: the charts keep following the live session + # instead of pretending to replay something, and the layout is the live one. + check(not window.replay.is_loaded(), "a recording is open before one was chosen") check( window.force_plots.mode() == "follow", "the replay view browses before a recording is open", ) - + # The replay view is the live arrangement, measured rather than assumed: the + # splitter hands out its handles' width, so compare like with like. + window.brand_bar.btn_live.click() + for _ in range(3): + app.processEvents() + live_sizes = tuple(window.content_splitter.sizes()) + window.brand_bar.btn_playback.click() + for _ in range(3): + app.processEvents() + replay_sizes = tuple(window.content_splitter.sizes()) + check( + replay_sizes == live_sizes, + f"the replay view is not the live arrangement: {replay_sizes} vs {live_sizes}", + ) + check( + not hasattr(window, "replay_bar"), + "the replay view still carries a separate bar", + ) + check( + window.toolbar.btn_replay_video.isVisible(), + "the replay view does not offer a video to replay", + ) + # The one difference between the two views: the source slot. + window.brand_bar.btn_live.click() + for _ in range(3): + app.processEvents() + check( + not window.toolbar.btn_replay_video.isVisible() + and window.toolbar.source_slot.isVisible(), + "the live view still shows the replay's video picker", + ) window.brand_bar.btn_live.click() for _ in range(3): app.processEvents() @@ -1859,13 +1886,14 @@ def main() -> int: "the toolbar and the charts disagree about a recording's unit", ) check( - pane_window.replay_bar.btn_unit.text() == t("playback.show_newton"), - "the replay bar's unit switch disagrees with the charts", + pane_window.force_plots.unit_badge.text() == t("units.raw_short"), + "the chart's unit chip disagrees with the charts", ) drawn = [int(curve.getData()[0].size) for curve in pane_window.force_plots.curves] check(all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}") # The cards read the point under the playhead, not the live acquisition. - pane_window.replay_bar.slider.setValue(pane_window.replay_bar.slider.maximum()) + duration = pane_window.replay.duration_s() + pane_window.replay.set_position(duration) app.processEvents() cursor_card = pane_window.force_plots.value_labels[0].text() check( @@ -2706,8 +2734,8 @@ def main() -> int: "returning to the live view did not restore the camera picker", ) - # A video file is an offline acquisition run: the rail states it and offers - # the way back, with no source combobox anywhere. + # A video file is a *replay* source: the live view offers the way back to the + # camera, and the video is named only in the replay view. window.toolbar.set_video_path("C:/tmp/demo.mp4") window._set_source_mode("video") for _ in range(3): @@ -2716,10 +2744,25 @@ def main() -> int: window.toolbar.video_group.isVisible() and not window.toolbar.camera_group.isVisible(), "video mode still offers the camera group", ) + check( + "demo.mp4" not in window.toolbar.source_summary.text(), + f"the live view names a replay's video: " + f"{window.toolbar.source_summary.text()!r}", + ) + window.brand_bar.btn_playback.click() + for _ in range(3): + app.processEvents() check( "demo.mp4" in window.toolbar.source_summary.text(), f"the rail does not name the video file: {window.toolbar.source_summary.text()!r}", ) + window.brand_bar.btn_live.click() + for _ in range(3): + app.processEvents() + check( + "demo.mp4" not in window.toolbar.source_summary.text(), + "the video name stayed on the rail after leaving the replay", + ) window.toolbar.use_camera_clicked.emit() for _ in range(3): app.processEvents() @@ -2777,8 +2820,8 @@ def main() -> int: replay_window.brand_bar.btn_playback.click() app.processEvents() check( - replay_window.replay_bar.isVisible(), - "the replay view has no progress bar", + replay_window.toolbar.btn_replay_video.isVisible(), + "the replay view does not offer a recorded video", ) check( not replay_window.toolbar.camera_group.isVisible(), @@ -2789,6 +2832,13 @@ def main() -> int: "the replay view could not open the recording", ) app.processEvents() + replay_window.toolbar.set_video_path(str(replay_root / "raw_0001.mp4")) + replay_window._refresh_status_rail() + check( + "raw_0001.mp4" in replay_window.toolbar.source_summary.text(), + f"the replay does not name its video: " + f"{replay_window.toolbar.source_summary.text()!r}", + ) replay = replay_window.replay replay_plots = replay_window.force_plots @@ -2813,20 +2863,39 @@ def main() -> int: "a recording offers 'back to latest'", ) - # The progress bar is the playhead, and the chart cursor is a second one. - check(replay_window.replay_bar.slider.isEnabled(), "the progress bar is disabled") - replay_window.replay_bar.slider.setValue(replay_window.replay_bar.slider.maximum() // 2) + # 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) + 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) app.processEvents() check( - 0.0 < replay.position() < replay.duration_s(), - f"dragging the progress bar did not move the playhead: {replay.position()}", + replay.position() > 0.05, + f"the transport did not advance the playhead: {replay.position()}", + ) + check( + replay.position() <= elapsed + 0.2, + f"the playhead ran ahead of the clock: {replay.position()} > {elapsed}", ) check( abs(float(replay_plots.cursors[0].value()) - replay.position()) < 0.05, - "the chart cursor did not follow the progress bar", + "the chart cursor did not follow the transport", ) + check(not replay_plots.btn_play.isChecked(), "the transport stayed checked after pause") + # A manual gesture on the timeline takes over: playback pauses. + replay_plots.btn_play.setChecked(True) + app.processEvents() replay_plots.cursors[0].setValue(0.25) app.processEvents() + check( + not replay_plots.btn_play.isChecked(), + "dragging the cursor did not pause the playback", + ) check( abs(replay.position() - 0.25) < 0.05, f"dragging the chart cursor did not scrub the replay: {replay.position()}", @@ -2836,7 +2905,7 @@ def main() -> int: "no video frame was rendered at the cursor position", ) check( - replay_window.arrow_panel.view_modes() == ("recorded", "depth"), + replay_window.arrow_panel.view_modes() == ("recorded",), "the 2D panel still offers the live-only views while replaying", ) @@ -2871,9 +2940,9 @@ def main() -> int: ) check( t("playback.warn_depth_recomputed").split("{")[0].strip()[:4] - in replay_window.replay_bar.warning_label.text(), - f"the warnings are not shown in the bar: " - f"{replay_window.replay_bar.warning_label.text()!r}", + 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}", ) moved: list[float] = [] @@ -2886,22 +2955,17 @@ def main() -> int: ) check(moved == [1.0], f"the cursor signal did not fire once: {moved}") check( - "1.00" in replay_window.replay_bar.position_label.text(), - f"the bar does not show the playhead: " - f"{replay_window.replay_bar.position_label.text()!r}", - ) - check( - replay_window.replay_bar.slider.value() - == int(replay_window.replay_bar.slider.maximum() * 1.0 / replay.duration_s()), - "the progress bar did not follow a programmatic position", + abs(replay_plots.cursor_time() - 1.0) < 0.05, + f"the chart cursor did not follow a programmatic position: " + f"{replay_plots.cursor_time()}", ) - # The unit switch rebuilds the recording's curves, not the live ones. - replay_window.replay_bar.unit_toggled.emit() + # The chart's unit chip rebuilds the recording's curves, not the live ones. + replay_plots.unit_badge.click() app.processEvents() check( replay.unit() == "N" and replay_plots._unit == "N", - f"the replay unit switch did not reach the charts: {replay.unit()}", + 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 @@ -2960,16 +3024,14 @@ def main() -> int: replay.depth_image() is None, "a failed recomputation left an image in the depth pane", ) - # The 2D panel shows the depth pane's explanation, not the raw frame. - replay_window.arrow_panel.set_view_mode("depth") - app.processEvents() + # 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). check( - replay_window.arrow_panel.depth_scale_label.text() == "" - and replay_window.arrow_panel.colorbar.isVisible(), - "the replay depth view does not show its colour bar", + replay_window.arrow_panel.view_modes() == ("recorded",), + f"the replay still offers a depth pane: " + f"{replay_window.arrow_panel.view_modes()}", ) - replay_window.arrow_panel.set_view_mode("recorded") - app.processEvents() # 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 @@ -3057,6 +3119,42 @@ def main() -> int: 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. + started: list = [] + replay_window._start_run = lambda: started.append(True) + replay_window.act_recompute.setChecked(True) + for _ in range(3): + app.processEvents() + check( + started == [True], + "the recompute mode did not hand the video to the pipeline", + ) + 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", + ) + check( + replay_window.toolbar.source_mode() == "camera" or not replay_window._running, + "the recompute pass was left running", + ) + # 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. check( @@ -3071,8 +3169,14 @@ def main() -> int: "leaving the replay view did not restore the live charts", ) check( - not replay_window.replay_bar.isVisible(), - "the progress bar is still on screen in the live view", + not replay_plots.btn_play.isEnabled(), + "the live view still offers the replay's transport", + ) + check( + not replay_window.toolbar.source_summary.text().endswith(".mp4") + and "raw_0001" not in replay_window.toolbar.source_summary.text(), + f"the replay's video is still named in the live view: " + f"{replay_window.toolbar.source_summary.text()!r}", ) check( replay_window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth"), diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 414663b..f876d34 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -2,8 +2,11 @@ from __future__ import annotations +import contextlib +import ctypes import json import logging +import sys import threading import time from dataclasses import dataclass @@ -39,9 +42,43 @@ HANDOVER_TIMEOUT_S = 60.0 #: Window over which the delivered frame rate is measured. FPS_WINDOW_S = 1.0 #: How long to wait before asking again when the capture source has not produced -#: a new frame yet. Long enough not to spin, short enough not to add latency. +#: a new frame yet. Long enough not to spin, short enough not to add latency — +#: but only if the platform can actually sleep this finely: see +#: ``_high_resolution_timer``. CAPTURE_REPEAT_POLL_S = 0.002 + +@contextlib.contextmanager +def _high_resolution_timer(): + """Ask Windows for a 1 ms timer for the duration of the block. + + The default Windows timer resolution is 15.6 ms, so ``time.sleep(0.002)`` + sleeps 15.6 ms. The acquisition loop waits for the *next* frame between + frames, and two of those ticks overshoot a 33 ms frame period: a 30 fps + sensor is then consumed at ~16.7 fps with every other frame dropped. Raising + the resolution to 1 ms makes the wait behave as written. It is restored on + exit, so idle power behaviour is unchanged, and on other platforms this does + nothing. + """ + if sys.platform != "win32": # pragma: no cover - platform specific + yield + return + winmm = None + try: + winmm = ctypes.WinDLL("winmm") + winmm.timeBeginPeriod(1) + except Exception: # noqa: BLE001 - the loop still works, only coarser + logger.debug("Could not raise the timer resolution", exc_info=True) + winmm = None + try: + yield + finally: + if winmm is not None: + try: + winmm.timeEndPeriod(1) + except Exception: # noqa: BLE001 + pass + # Re-exported so ``from ...sdk_worker import SdkPacket`` keeps working; the # definitions live in ``models`` so neither worker depends on the other. __all__ = [ @@ -557,7 +594,11 @@ class SdkAcquisitionWorker(RecordSinkMixin): def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None: try: - self._run_session(session, previous) + # A fine timer for the whole session: the loop waits for the next + # frame between frames, and on Windows a sub-tick wait otherwise + # costs a full 15.6 ms (see ``_high_resolution_timer``). + with _high_resolution_timer(): + self._run_session(session, previous) finally: # The finishing thread owns the teardown — this is what lets # ``stop()`` return immediately without leaking the device. @@ -626,6 +667,10 @@ class SdkAcquisitionWorker(RecordSinkMixin): #: The capture sequence of the last frame that was actually *processed*: #: an unchanged counter means the same frame came back again. last_delivered: Optional[int] = None + #: When that frame arrived, and how far apart this device's frames are + #: meant to be: the wait below never steps over the next frame's arrival. + last_frame_at = 0.0 + frame_period = 1.0 / max(1.0, float(mode.fps or 30.0)) # Per-session monotonic frame index: the join key shared by the video, # the CSV and the calibration records. frame_id = 0 @@ -655,7 +700,15 @@ class SdkAcquisitionWorker(RecordSinkMixin): # per call, so it has no repeat marker and is left alone. delivered = _capture_sequence(sensor) if delivered is not None and delivered == last_delivered: - time.sleep(CAPTURE_REPEAT_POLL_S) + # Wait for the next frame, but never *past* the moment it is + # due: sleeping a whole timer tick before looking again is how + # a 30 fps sensor gets consumed at 16.7 fps. When the frame is + # already due, yield and look again instead. + remaining = frame_period - (now - last_frame_at) + if remaining > CAPTURE_REPEAT_POLL_S: + time.sleep(min(remaining, frame_period * 0.5)) + else: + time.sleep(0) continue last_delivered = delivered processed = sensor.get_img() @@ -721,7 +774,15 @@ class SdkAcquisitionWorker(RecordSinkMixin): # Wall clock for metadata; monotonic for ordering. timestamp=time.time(), wall_t=time.time(), - mono_t=max(0.0, t1 - self._mono_t0), + # A file has the timeline of the *video*, not of how long the + # pass took: recomputing a 5 s recording at 60 fps would + # otherwise land on a 0–2 s axis and could not be compared + # with the forces.csv it is being checked against. + mono_t=( + frame_id * frame_period + if is_file + else max(0.0, t1 - self._mono_t0) + ), fps=fps, process_ms=(t1 - t0) * 1000.0, normal_force=forces["normal_force"], @@ -753,6 +814,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): frame_id += 1 saw_frame = True last_frame_mono = t1 + last_frame_at = t1 except Exception as exc: self._set_end_reason("error") if self._on_error is not None and session == self._session_id: diff --git a/shared/tests/test_worker_frame_rate.py b/shared/tests/test_worker_frame_rate.py index 524ca3d..14fc06c 100644 --- a/shared/tests/test_worker_frame_rate.py +++ b/shared/tests/test_worker_frame_rate.py @@ -18,8 +18,9 @@ from pathlib import Path APP_ROOT = Path(__file__).resolve().parents[2] BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json" -#: The fake device's own frame period: ~100 frames per second. -FRAME_PERIOD_S = 0.01 +#: The fake device's own frame period: 30 frames per second, the rate the studio +#: sensor configs declare. +FRAME_PERIOD_S = 1.0 / 30.0 #: How many times the SDK hands the same frame back before the next one arrives. REPEATS_PER_FRAME = 3 @@ -153,7 +154,9 @@ def _collect(duration_s: float = 1.2): try: worker.add_sink(sink) - worker.start("0", config_ref=BOX_CONFIG) + # Camera id 0, not "0": a string source is a file, and a file runs on the + # video's timeline rather than the wall clock (see the file test below). + worker.start(0, config_ref=BOX_CONFIG) time.sleep(duration_s) worker.stop(wait=True) delivered = CREATED[-1].capture_source.status.last_seq if CREATED else 0 @@ -181,6 +184,22 @@ def test_the_same_frame_is_not_counted_twice(): ) +def test_a_30_fps_device_is_consumed_at_30_fps(): + """The loop must not add a timer tick of latency per frame. + + ``time.sleep(0.002)`` costs a full 15.6 ms on Windows, so a loop that sleeps + before looking for the next frame spends ~47 ms per frame on a 30 fps device + and records 16.7 fps with half the frames dropped. + """ + packets, delivered = _collect(duration_s=1.5) + assert packets, "the loop emitted nothing at all" + rate = _rate(packets) + assert 27.0 <= rate <= 31.5, ( + f"a 30 fps device was consumed at {rate:.1f} fps " + f"({len(packets)} packets for {delivered} delivered frames)" + ) + + def test_the_reported_rate_is_the_delivered_rate(): """``packet.fps`` describes the frames, not how often the loop asked.""" packets, delivered = _collect() @@ -192,6 +211,40 @@ def test_the_reported_rate_is_the_delivered_rate(): assert abs(settled[-1] - measured) < 0.25 * measured, (settled[-1], measured) +def test_a_file_source_runs_on_the_video_s_own_timeline(): + """A pass over a video is stamped with the video's time, not the wall clock.""" + from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker + + CREATED.clear() + saved = _install() + worker = SdkAcquisitionWorker() + packets: list = [] + lock = threading.Lock() + + def sink(packet) -> None: + with lock: + packets.append(packet) + + try: + worker.add_sink(sink) + worker.start("recorded.mp4", config_ref=BOX_CONFIG) + time.sleep(0.4) + worker.stop(wait=True) + finally: + _restore(saved) + assert len(packets) > 3, f"the pass produced {len(packets)} packets" + steps = [ + round(packets[index + 1].mono_t - packets[index].mono_t, 6) + for index in range(min(5, len(packets) - 1)) + ] + assert packets[0].mono_t == 0.0, packets[0].mono_t + assert all(abs(step - 1.0 / 30.0) < 1e-6 for step in steps), steps + assert packets[-1].mono_t < 0.4 + 1.0, ( + f"the pass ran on the wall clock: {packets[-1].mono_t} s of video in " + f"{0.4} s of real time" + ) + + if __name__ == "__main__": # pragma: no cover - manual runner failures = 0 for name, function in sorted(globals().items()):