回放与实时统一为一套界面;修复 30 fps 设备被以 16.7 fps 消费的计时器缺陷 - What: 1) 删除 ui/replay_bar.py(进度条、录制目录选择、重算深度按钮、提示行)与回放条的全部接线:历史回放现在与实时监测同布局(分割比例同为 0,360,1080,0)、同一条状态轨、同一套曲线手势,唯一差别是来源槽——实时是摄像机,回放是「选择录制视频…」(toolbar.btn_replay_video,与文件菜单「打开视频…」同一条 _load_replay_video 路径:视频所在目录即录制目录,曲线自动配对同目录 forces.csv)。时间轴改为曲线游标本身:表头新增播放/暂停图标按钮(icons.play/pause,空格键同效)以 1× 推进(按墙钟计时,漏拍不变慢),到末尾自动停;拖动游标、框选、滚轮平移或拖总览条一律自动暂停(ForcePlotsPanel.pause_playback)。回放的降采样/跨版本提示移入来源摘要 tooltip,打开录制时另发一条 6 秒通知。修复视频文件名残留在实时视图的问题:_refresh_status_rail 只在 _view_preset == "replay" 时写视频名。2) 录制格式不变:raw_*.mp4 与 forces.csv/frames.csv/session.json 继续一起写。按「两者都要」新增 视图 → 由视频重算力值(act_recompute):默认曲线读录制的 CSV,打开后把录制视频重新跑一遍视觉管线(进 SessionStore、2D 面板交给管线绘制、只能顺序前进,播放按钮即开始/暂停,关闭即挂回 CSV),启动路径唯一(set_playing → _on_chart_play_toggled → _start_run),并让文件源的时间轴改为视频自身时间(mono_t = 帧号 × 1/fps),否则重算结果会落在与 CSV 不同的横轴上。回放 2D 面板不再提供深度视图(REPLAY_VIEW_MODES=("recorded",)),离线深度重算引擎保留但无界面入口。3) 采集只有 16.67 fps 的根因是上一轮引入的等待:Windows 默认计时器分辨率 15.6 ms,time.sleep(0.002) 实测睡 15.5 ms,重复帧等两拍即跨过 30 fps 的 33 ms 周期,30 fps 设备被以 ~16.7 fps 消费并隔帧丢弃(配置本身正确:mini/sensor.json 写 camera.fps=30;离线实测整套管线 15.2 ms/帧≈66 fps,余量充足)。新增 _high_resolution_timer() 在会话期间把分辨率提到 1 ms(结束还原),并把重复帧等待改为 min(距下一帧时间, 半个周期)、到期只 sleep(0)。 - Why: 用户评审 3 点——回放界面与实时不一致且拖拽条效果一般、不想为回放录制深度信息、想同时保留视频与 CSV 记录、以及采集帧率只有 16.67 fps(应为 30)。 - Verification: ui_check.py 562 项 0 失败(新增两视图同布局/无 bar、来源槽随视图切换且无残留、播放暂停与手动手势暂停、重算模式切换与视频源、回放提示在 tooltip);test_ui_smoke.py 73 项 0 失败(新增视图统一、传输控制、重算模式);drive_shared.py 150 项 0 失败(新增 shared/tests/test_worker_frame_rate.py::test_a_file_source_runs_on_the_video_s_own_timeline,并把 30 fps 用例改成必须落在 27–31.5 fps;同文件实测无 1 ms 计时器 21.6 fps、有则 30.0 fps,同一帧返回 3 次时 11317:39);locale_studio.py 266 键齐全、zh==en;reproduce.py 退出 0;ui_snapshot.py 三张复核图(统一后的回放、实时、选区中英)。 - Not in this change: 录制文件格式与 CSV 列序不变;离线深度重算引擎(ReplayController.request_depth / DepthRecomputer)保留并有独立测试,但界面已无入口,可下一步删除;重算模式用有损 mp4,数值与原 CSV 会有差异(这正是它要显式呈现的)。

This commit is contained in:
ykcheungab
2026-09-13 22:04:01 +08:00
parent e6a1e77a5b
commit 9b3c5fe46c
17 changed files with 896 additions and 387 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

+14 -15
View File
@@ -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",
+14 -15
View File
@@ -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": "该帧没有深度数据",
+24 -12
View File
@@ -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/<UTC 时间戳>_session<NNNN>/
### 回放界面
回放**不再是一个独立窗口**:`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` 里并有
独立测试,但已没有界面入口。
两个必须知晓的边界:
+47 -18
View File
@@ -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 里。
### 曲线缩放与默认模式
+6 -1
View File
@@ -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`,启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
+258 -71
View File
@@ -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}",
"""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.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())
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":
path = self.toolbar.video_path()
detail = t("status.rail_video_name", name=Path(path).name if path else "?")
else:
# 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()
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():
+1 -1
View File
@@ -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
@@ -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",
@@ -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"))
+29 -3
View File
@@ -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,
}
@@ -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"))
+29 -4
View File
@@ -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"))
+153 -30
View File
@@ -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
+150 -46
View File
@@ -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"),
@@ -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,6 +594,10 @@ class SdkAcquisitionWorker(RecordSinkMixin):
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
try:
# 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
@@ -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:
+56 -3
View File
@@ -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()):