回放与实时统一为一套界面;修复 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:
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 2.0 MiB |
@@ -34,6 +34,8 @@
|
|||||||
"settings": "Settings area",
|
"settings": "Settings area",
|
||||||
"plot_settings": "Plot settings…",
|
"plot_settings": "Plot settings…",
|
||||||
"rotate_arrows": "Rotate arrows 90°",
|
"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",
|
"run": "Run / Stop",
|
||||||
"stop": "Cancel acquisition",
|
"stop": "Cancel acquisition",
|
||||||
"preview": "Preview / Stop preview",
|
"preview": "Preview / Stop preview",
|
||||||
@@ -61,7 +63,9 @@
|
|||||||
"search_cameras": "Search",
|
"search_cameras": "Search",
|
||||||
"cam_id": "Camera",
|
"cam_id": "Camera",
|
||||||
"use_camera": "Use 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": "Preview",
|
||||||
"preview_stop": "Stop preview",
|
"preview_stop": "Stop preview",
|
||||||
"run": "Run",
|
"run": "Run",
|
||||||
@@ -110,8 +114,8 @@
|
|||||||
"view_depth": "Depth map",
|
"view_depth": "Depth map",
|
||||||
"view_recorded": "Recorded image",
|
"view_recorded": "Recorded image",
|
||||||
"view_mode_tip": "Image display mode while running",
|
"view_mode_tip": "Image display mode while running",
|
||||||
"replay_pipeline": "Recorded video → depth recomputed",
|
"replay_pipeline": "Recorded video (raw camera image)",
|
||||||
"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_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",
|
"replay_empty": "No recording open\nPress “Open recording…” below",
|
||||||
"arrow_empty": "No image yet\nPick a video or camera, then press Run",
|
"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",
|
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
|
||||||
@@ -138,6 +142,10 @@
|
|||||||
"fx_name": "Shear X",
|
"fx_name": "Shear X",
|
||||||
"fy_name": "Shear Y",
|
"fy_name": "Shear Y",
|
||||||
"window_readout": "{start} – {end} s / {total} s total",
|
"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",
|
"window_tip": "Drag the highlighted region to move the window: this strip is the timeline of the whole session",
|
||||||
"no_data": "—",
|
"no_data": "—",
|
||||||
"mode_follow": "Following live",
|
"mode_follow": "Following live",
|
||||||
@@ -165,22 +173,13 @@
|
|||||||
},
|
},
|
||||||
"playback": {
|
"playback": {
|
||||||
"title": "Replay recording",
|
"title": "Replay recording",
|
||||||
"open": "Open recording…",
|
"open": "Open a recorded video…",
|
||||||
"open_tip": "Pick a recording directory under outputs/recordings (video + curves + metadata)",
|
"video_filter": "Recorded video (*.mp4 *.avi *.mov *.mkv);;All files (*)",
|
||||||
"scrub_tip": "Drag the progress bar to move the playhead: video, depth and the chart cursor follow",
|
"no_video": "This recording has no usable video segment",
|
||||||
"loaded_short": "{name} · {frames} frames / {duration} s",
|
|
||||||
"open_failed": "Could not open that recording: {error}",
|
"open_failed": "Could not open that recording: {error}",
|
||||||
"none_found": "No recording found under outputs/recordings",
|
"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_dropped": "This frame never reached the video (the record queue overflowed)",
|
||||||
"frame_unreadable": "This frame could not be decoded from the video",
|
"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_running": "Recomputing depth… (on a worker thread)",
|
||||||
"depth_busy": "The previous recomputation is still running",
|
"depth_busy": "The previous recomputation is still running",
|
||||||
"depth_empty": "No depth data for this frame",
|
"depth_empty": "No depth data for this frame",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
"settings": "设置区",
|
"settings": "设置区",
|
||||||
"plot_settings": "绘图设置…",
|
"plot_settings": "绘图设置…",
|
||||||
"rotate_arrows": "旋转箭头图 90°",
|
"rotate_arrows": "旋转箭头图 90°",
|
||||||
|
"recompute": "由视频重算力值",
|
||||||
|
"recompute_tip": "关闭:曲线读录制的 forces.csv(当时测得的值)。打开:把录制的视频重新跑一遍视觉管线(算法复核,只能顺序前进)。",
|
||||||
"run": "运行 / 停止",
|
"run": "运行 / 停止",
|
||||||
"stop": "取消采集",
|
"stop": "取消采集",
|
||||||
"preview": "预览 / 停止预览",
|
"preview": "预览 / 停止预览",
|
||||||
@@ -61,7 +63,9 @@
|
|||||||
"search_cameras": "搜索",
|
"search_cameras": "搜索",
|
||||||
"cam_id": "摄像机",
|
"cam_id": "摄像机",
|
||||||
"use_camera": "使用摄像机",
|
"use_camera": "使用摄像机",
|
||||||
"use_camera_tip": "回到摄像机采集(当前正在用视频文件采集)",
|
"use_camera_tip": "回到摄像机采集(当前的数据源是视频文件)",
|
||||||
|
"choose_video": "选择录制视频…",
|
||||||
|
"choose_video_tip": "打开一段录制的视频:2D 面板放视频,曲线读同目录的 forces.csv(原始记录值)",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"preview_stop": "停止预览",
|
"preview_stop": "停止预览",
|
||||||
"run": "运行",
|
"run": "运行",
|
||||||
@@ -110,8 +114,8 @@
|
|||||||
"view_depth": "深度图",
|
"view_depth": "深度图",
|
||||||
"view_recorded": "原始图像",
|
"view_recorded": "原始图像",
|
||||||
"view_mode_tip": "运行时的图像显示模式",
|
"view_mode_tip": "运行时的图像显示模式",
|
||||||
"replay_pipeline": "录制视频 → 深度重算",
|
"replay_pipeline": "录制视频(原始相机图像)",
|
||||||
"replay_pipeline_tip": "回放显示的是录制下来的原始相机图像;深度图由录制视频与配置离线重算,不含实时箭头叠加",
|
"replay_pipeline_tip": "回放显示的是录制下来的原始相机图像,不含实时箭头叠加;要复核算法请用「视图 → 由视频重算力值」",
|
||||||
"replay_empty": "尚未打开录制\n点击下方「打开录制…」",
|
"replay_empty": "尚未打开录制\n点击下方「打开录制…」",
|
||||||
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
|
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
|
||||||
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
|
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
|
||||||
@@ -138,6 +142,10 @@
|
|||||||
"fx_name": "切向力 X",
|
"fx_name": "切向力 X",
|
||||||
"fy_name": "切向力 Y",
|
"fy_name": "切向力 Y",
|
||||||
"window_readout": "{start} – {end} s / 全程 {total} s",
|
"window_readout": "{start} – {end} s / 全程 {total} s",
|
||||||
|
"play": "播放",
|
||||||
|
"play_tip": "从游标处按 1× 播放这段录制(空格键);拖动游标即暂停",
|
||||||
|
"pause": "暂停",
|
||||||
|
"pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
||||||
"window_tip": "拖动高亮区域即可移动视窗;这是整段会话的时间轴",
|
"window_tip": "拖动高亮区域即可移动视窗;这是整段会话的时间轴",
|
||||||
"no_data": "—",
|
"no_data": "—",
|
||||||
"mode_follow": "跟随实时",
|
"mode_follow": "跟随实时",
|
||||||
@@ -165,22 +173,13 @@
|
|||||||
},
|
},
|
||||||
"playback": {
|
"playback": {
|
||||||
"title": "录制回放",
|
"title": "录制回放",
|
||||||
"open": "打开录制…",
|
"open": "打开录制视频…",
|
||||||
"open_tip": "选择 outputs/recordings 下的一个录制目录(视频 + 曲线 + 元数据)",
|
"video_filter": "录制的视频 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)",
|
||||||
"scrub_tip": "拖动进度条移动回放位置:视频、深度与曲线游标同步",
|
"no_video": "这段录制没有可用的视频分段",
|
||||||
"loaded_short": "{name} · {frames} 帧 / {duration} s",
|
|
||||||
"open_failed": "无法打开该录制: {error}",
|
"open_failed": "无法打开该录制: {error}",
|
||||||
"none_found": "outputs/recordings 下没有找到录制",
|
"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_dropped": "该帧未被写入视频(记录队列曾溢出)",
|
||||||
"frame_unreadable": "该帧无法从视频中解码",
|
"frame_unreadable": "该帧无法从视频中解码",
|
||||||
"depth_hint": "深度未存储:点击「重算深度」按原视频与配置离线重算",
|
|
||||||
"depth_running": "正在重算深度…(在后台线程)",
|
"depth_running": "正在重算深度…(在后台线程)",
|
||||||
"depth_busy": "上一次重算尚未完成",
|
"depth_busy": "上一次重算尚未完成",
|
||||||
"depth_empty": "该帧没有深度数据",
|
"depth_empty": "该帧没有深度数据",
|
||||||
|
|||||||
@@ -65,11 +65,22 @@ UI 线程 _on_run_tick ──> 曲线显示(仍可降帧)
|
|||||||
帧,因此它的计数与"重复"无关,直接忽略(返回 `None`,行为不变)。
|
帧,因此它的计数与"重复"无关,直接忽略(返回 `None`,行为不变)。
|
||||||
- `packet.fps` 改为**速率**:`(窗口内帧数 - 1) / (首末到达时刻之差)`,窗口 `FPS_WINDOW_S`
|
- `packet.fps` 改为**速率**:`(窗口内帧数 - 1) / (首末到达时刻之差)`,窗口 `FPS_WINDOW_S`
|
||||||
= 1 s。按"1 秒内到达了多少帧"数数会系统性地偏大(窗口里正好装一秒,抖动就多算一帧)。
|
= 1 s。按"1 秒内到达了多少帧"数数会系统性地偏大(窗口里正好装一秒,抖动就多算一帧)。
|
||||||
- 这一层是"哪些帧算一帧"的唯一判据,记录、曲线、标定与 CSV 都跟着它走;它不改动
|
- **等待新帧的间隔必须真的睡得着**:Windows 默认计时器分辨率 15.6 ms,`time.sleep(0.002)`
|
||||||
`packet.fps` 的语义之外的东西 —— **容器速率**仍然是传感器配置值(见 §6.1)。
|
实际睡 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 次
|
`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` 已删除,拆成
|
回放**与实时是同一套界面**:`ui/playback_dialog.py` 与 `ui/replay_bar.py` 都已删除。
|
||||||
`replay.py::ReplayController`(读盘 + 游标 + 深度重算)与 `ui/replay_bar.py::ReplayBar`
|
`replay.py::ReplayController` 只读录制并发信号,主窗口把它接到同一套面板上——曲线表头的
|
||||||
(录制选择 + 进度条 + 单位 + 提示),入口在「文件 → 打开录制目录…/回放最近一次录制」
|
播放按钮就是播放头,来源槽在回放视图里换成「选择录制视频…」。布局、单位、看点与关闭流程
|
||||||
与品牌轨的「历史回放」。布局与关闭流程见 `docs/shell-lifecycle.md` §2。
|
见 `docs/shell-lifecycle.md` §2(含「由视频重算」复核模式)。
|
||||||
|
|
||||||
- **一个游标驱动三处**:移动位置同时更新视频帧、深度面板与三张曲线的游标线;
|
- **一个游标驱动三处**:移动位置同时更新视频帧与三张曲线的游标线;并且通过 `cursor_moved`
|
||||||
并且通过 `cursor_moved` 信号同步到主界面的曲线面板。
|
信号同步到主界面的曲线面板。
|
||||||
- 单位可切换(raw/N),数据直接来自录制目录的 `forces.csv`。
|
- 单位可切换(raw/N),数据直接来自录制目录的 `forces.csv`。
|
||||||
- **回放按录制实际有的单位打开**:录制里 `FN_N` 等列全空(没有标定)时退回 raw,
|
- **回放按录制实际有的单位打开**:录制里 `FN_N` 等列全空(没有标定)时退回 raw,
|
||||||
否则选 N 会画出三张空图;`raw` 一定存在(那是设备测到的值)。
|
否则选 N 会画出三张空图;`raw` 一定存在(那是设备测到的值)。
|
||||||
单位由 `ReplayController.set_unit` 一处决定,回放条、曲线与设置区下拉同时改。
|
|
||||||
- **数值卡读的是游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`):浏览历史或回放
|
- **数值卡读的是游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`):浏览历史或回放
|
||||||
时,三个大数字是播放头所指的样本,而不是实时采集的最后一个样本 —— 否则一张 7.5e5
|
时,三个大数字是播放头所指的样本,而不是实时采集的最后一个样本 —— 否则一张 7.5e5
|
||||||
原始计数的曲线旁边会写着实时的 `0.0`,看起来就像"回放根本没打开"。
|
原始计数的曲线旁边会写着实时的 `0.0`,看起来就像"回放根本没打开"。
|
||||||
实时视图仍是"最新样本就是游标",所以跟随状态下不额外读盘。
|
实时视图仍是"最新样本就是游标",所以跟随状态下不额外读盘。
|
||||||
- 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。
|
- 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。
|
||||||
- **深度重算在守护线程上执行**,UI 线程立即返回。实测 `_on_recompute_depth` 返回耗时
|
- **深度不随录制保存,界面也不再提供深度视图**(用户明确不需要这条回放路径);需要复核
|
||||||
0.001 s,而 SDK 调用仍在后台跑。
|
算法时用「由视频重算」,它把录制视频重新跑一遍管线(含深度、形变与接触)。离线深度重算
|
||||||
|
引擎(`ReplayController.request_depth` / `DepthRecomputer`)保留在 `replay.py` 里并有
|
||||||
|
独立测试,但已没有界面入口。
|
||||||
|
|
||||||
两个必须知晓的边界:
|
两个必须知晓的边界:
|
||||||
|
|
||||||
|
|||||||
@@ -54,30 +54,32 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 回放并入主窗口(含进度条)
|
## 2. 回放与实时是同一套界面(只换来源)
|
||||||
|
|
||||||
回放**不再是一个独立窗口**。`PlaybackDialog` 已删除,它被拆成两半:
|
「历史回放」不另开窗口,**也不另起一套控件**:它与「实时监测」是同一套布局、同一条状态轨、
|
||||||
|
同一套图表手势。**唯一的差别是左上的来源槽**——实时是摄像机,回放是一段录制视频。
|
||||||
|
`ui/replay_bar.py`(进度条 + 录制目录选择)与 `PlaybackDialog` 都已删除。
|
||||||
|
|
||||||
- `apps/studio/src/orisys_studio/replay.py::ReplayController(QObject)`:录制读取、游标、
|
- `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` /
|
它不画任何东西,只发信号:`position_changed` / `frames_changed` /
|
||||||
`status_changed` / `recording_changed`。
|
`status_changed` / `recording_changed`。
|
||||||
- `apps/studio/src/orisys_studio/ui/replay_bar.py::ReplayBar`:录制选择、录制信息、
|
- **选视频即选录制**:状态轨的「选择录制视频…」(`toolbar.btn_replay_video`)与
|
||||||
**可拖拽的 QSlider 进度条**、`pos / dur` 读数、单位切换、重算深度、以及第二行的提示。
|
「文件 → 打开视频…」都走 `_load_replay_video(path)`——视频所在目录就是录制目录,
|
||||||
|
于是曲线自动配对同目录的 `forces.csv`。视频与 CSV 仍然同时记录(录制格式不变)。
|
||||||
主窗口把两者接起来:
|
|
||||||
|
|
||||||
- 曲线:`ForcePlotsPanel.set_recording_source(reader)` 用 `RecordingCurveSource` 直接读
|
- 曲线:`ForcePlotsPanel.set_recording_source(reader)` 用 `RecordingCurveSource` 直接读
|
||||||
录制目录(区间按需读盘 + 缓存包络),所以全程总览、拖动、缩放、框选都在同一套曲线上;
|
录制目录(区间按需读盘 + 缓存包络),所以全程总览、拖动、缩放、框选都在同一套曲线上;
|
||||||
表头显示「回放录制(浏览)」,`返回最新` 在录制模式下禁用。
|
表头显示「回放录制(浏览)」,`返回最新` 在录制模式下禁用。
|
||||||
- **回放按录制实际有的单位打开**(`_replay_unit_for`):`FN_N` 等列全空的录制退回 raw,
|
- **回放按录制实际有的单位打开**(`_replay_unit_for`):`FN_N` 等列全空的录制退回 raw,
|
||||||
否则在 newton 偏好下会画出三张空图、每张数值卡一个横杠——看起来就是"回放没加载"。
|
否则在 newton 偏好下会画出三张空图、每张数值卡一个横杠——看起来就是"回放没加载"。
|
||||||
单位由 `ReplayController.set_unit` 一处决定,回放条、曲线、数值卡与设置区下拉一起改。
|
表头的单位芯片是唯一的单位开关(`_set_display_unit` 同时告诉曲线、工具栏与控制器)。
|
||||||
- 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像 / 深度图」
|
- 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像」一项
|
||||||
(录制里没有光流,不提供箭头),页脚显示「录制视频 → 深度重算」并隐藏放大倍率与有效区域;
|
(`REPLAY_VIEW_MODES`;录制里没有光流,也没有存储深度),页脚显示「录制视频(原始相机
|
||||||
深度模式显示按录制锁存的色条与范围。
|
图像)」并隐藏放大倍率与有效区域。
|
||||||
- 游标是同一个:拖进度条 → 视频帧 + 曲线游标;拖曲线游标 → 回放位置。
|
- **游标就是播放头**:曲线表头的播放按钮(`chart.play` / `chart.pause`,编辑器习惯:
|
||||||
|
空格键同效)以 1× 推进游标,到末尾自动停;拖动游标、框选、滚轮平移或拖总览条都会
|
||||||
|
**自动暂停**(`ForcePlotsPanel.pause_playback`)。没有独立进度条。
|
||||||
- **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时,
|
- **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时,
|
||||||
三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放
|
三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放
|
||||||
时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。
|
时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。
|
||||||
@@ -85,12 +87,39 @@
|
|||||||
仍然在跑,但 `_on_run_tick` / `_on_preview_tick` 不再把实时帧画进 2D 面板,
|
仍然在跑,但 `_on_run_tick` / `_on_preview_tick` 不再把实时帧画进 2D 面板,
|
||||||
`_render_run_image` 直接返回。否则回放画面会被摄像头帧一帧一帧盖掉("回放时好像用的是
|
`_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` 会合成一段短录制并打开它,用于人工复核
|
`apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核
|
||||||
进度条与回放布局(不写业务数据,也不需要硬件)。
|
统一后的回放布局(不写业务数据,也不需要硬件)。
|
||||||
|
|
||||||
|
### 2.2 状态栏与来源摘要的分工
|
||||||
|
|
||||||
|
- 状态轨的来源槽按视图切换:实时 = 摄像机编号 + 搜索;回放 = 「选择录制视频…」。
|
||||||
|
`source_summary` 在回放视图里写「视频文件: xxx.mp4」,在实时视图里**只**写帧率——
|
||||||
|
视频名出现在实时视图里就是残留。
|
||||||
|
- 底部状态栏只放一次性通知(见 §1 表格与 `_NoticeBar`),回放提示与完整路径放在来源摘要的
|
||||||
|
tooltip 里。
|
||||||
|
|
||||||
### 曲线缩放与默认模式
|
### 曲线缩放与默认模式
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,11 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
|
|||||||
三个数值卡决定,而不是由一行统计文字决定。`ui_check.py` 的 `session browsing` 段断言
|
三个数值卡决定,而不是由一行统计文字决定。`ui_check.py` 的 `session browsing` 段断言
|
||||||
该小字 `width() >= sizeHint().width()`(中文与英文都不裁切)。
|
该小字 `width() >= sizeHint().width()`(中文与英文都不裁切)。
|
||||||
|
|
||||||
|
9. **两个视图共用一条状态轨**:来源槽是唯一的差别(实时=摄像机、回放=「选择录制视频…」),
|
||||||
|
因此轨上的文字宽度预算对两个视图是同一个。回放的视频名写在来源摘要里,**只在回放视图**
|
||||||
|
(`_refresh_status_rail` 按 `_view_preset` 决定),中英与 1280/1440 都由
|
||||||
|
`ui_check.rail_squeezed` 覆盖。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 三个顶层视图 = 同一窗口的三套布局
|
## 7. 三个顶层视图 = 同一窗口的三套布局
|
||||||
@@ -234,7 +239,7 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
|
|||||||
| 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) |
|
| 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
|
| 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
|
||||||
| 历史回放 | 2D 图像 + 力曲线 | 进入全程浏览 | `0, 320, 1120, 0` |
|
| 历史回放 | 2D 图像 + 力曲线 | 读打开的录制(全程浏览) | `0, 360, 1080, 0`(与实时相同) |
|
||||||
| 标定 | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
|
| 标定 | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
|
||||||
| 自由(`custom`,启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
|
| 自由(`custom`,启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ from orisys_studio.ui.plot_settings_dialog import (
|
|||||||
DEFAULT_PLOT_WINDOW_SEC,
|
DEFAULT_PLOT_WINDOW_SEC,
|
||||||
PlotSettingsDialog,
|
PlotSettingsDialog,
|
||||||
)
|
)
|
||||||
from orisys_studio.ui.replay_bar import ReplayBar
|
|
||||||
from orisys_studio.ui.sensor_picker import SensorPickerWidget
|
from orisys_studio.ui.sensor_picker import SensorPickerWidget
|
||||||
from orisys_studio.ui.toolbar import ToolbarWidget
|
from orisys_studio.ui.toolbar import ToolbarWidget
|
||||||
|
|
||||||
@@ -82,15 +81,21 @@ SESSION_PHASES = ("idle", "connecting", "running", "error")
|
|||||||
VIEW_PRESETS = ("custom", "live", "replay", "calibration")
|
VIEW_PRESETS = ("custom", "live", "replay", "calibration")
|
||||||
#: Splitter sizes per view, in panel order (3D, 2D, charts, calibration).
|
#: Splitter sizes per view, in panel order (3D, 2D, charts, calibration).
|
||||||
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
|
#: ``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 = {
|
VIEW_SPLITTER_SIZES = {
|
||||||
"live": (0, 360, 1080, 0),
|
"live": (0, 360, 1080, 0),
|
||||||
"replay": (0, 320, 1120, 0),
|
"replay": (0, 360, 1080, 0),
|
||||||
"calibration": (0, 0, 560, 880),
|
"calibration": (0, 0, 560, 880),
|
||||||
}
|
}
|
||||||
#: The 3D panel only belongs to the live view, and only for a finger sensor.
|
#: The 3D panel only belongs to the live view, and only for a finger sensor.
|
||||||
VIEW_SHOWS_3D = ("custom", "live")
|
VIEW_SHOWS_3D = ("custom", "live")
|
||||||
#: After this long in ``connecting``, tell the user that Stop is the way out.
|
#: After this long in ``connecting``, tell the user that Stop is the way out.
|
||||||
CONNECT_SLOW_HINT_S = 8.0
|
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:
|
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
|
||||||
@@ -145,6 +150,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._connect_slow_notified = False
|
self._connect_slow_notified = False
|
||||||
#: Whether this run has already said "showing raw, nothing is calibrated".
|
#: Whether this run has already said "showing raw, nothing is calibrated".
|
||||||
self._raw_fallback_notified = False
|
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 = None
|
||||||
self._preview_timer = QTimer(self)
|
self._preview_timer = QTimer(self)
|
||||||
self._preview_timer.setInterval(33)
|
self._preview_timer.setInterval(33)
|
||||||
@@ -237,12 +244,15 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.content_splitter.setChildrenCollapsible(False)
|
self.content_splitter.setChildrenCollapsible(False)
|
||||||
root.addWidget(self.content_splitter, 1)
|
root.addWidget(self.content_splitter, 1)
|
||||||
|
|
||||||
# The replay bar lives under the panels (not inside the splitter): it is
|
# Playback drives the *chart cursor* (the playhead) instead of a slider
|
||||||
# a timeline about the whole session, and it appears only in the replay
|
# under the panels: the two views share one layout, and the transport is
|
||||||
# view. The splitter keeps its four panels and its four stored sizes.
|
# a single button on the chart header.
|
||||||
self.replay_bar = ReplayBar()
|
self._play_timer = QTimer(self)
|
||||||
self.replay_bar.setVisible(False)
|
self._play_timer.setInterval(PLAYBACK_TICK_MS)
|
||||||
root.addWidget(self.replay_bar)
|
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())
|
self.setStatusBar(_NoticeBar())
|
||||||
# The status bar carries **one-off notices** only (a recording finished, a
|
# The status bar carries **one-off notices** only (a recording finished, a
|
||||||
@@ -583,6 +593,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
|
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
|
||||||
self.toolbar.sensor_clicked.connect(self._open_sensor_dialog)
|
self.toolbar.sensor_clicked.connect(self._open_sensor_dialog)
|
||||||
self.toolbar.use_camera_clicked.connect(self._on_use_camera)
|
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.run_toggled.connect(self._on_run_toggled)
|
||||||
self.toolbar.record_toggled.connect(self._on_record_toggled)
|
self.toolbar.record_toggled.connect(self._on_record_toggled)
|
||||||
self.toolbar.reset_clicked.connect(self._on_reset)
|
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)
|
self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle)
|
||||||
|
|
||||||
def _wire_replay(self) -> None:
|
def _wire_replay(self) -> None:
|
||||||
"""The bar drives the engine; the engine updates the bar and the views."""
|
"""The controller feeds the views; the chart is the only timeline."""
|
||||||
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)
|
|
||||||
self.replay.position_changed.connect(self._on_replay_position)
|
self.replay.position_changed.connect(self._on_replay_position)
|
||||||
self.replay.frames_changed.connect(self._render_replay_frame)
|
self.replay.frames_changed.connect(self._render_replay_frame)
|
||||||
self.replay.status_changed.connect(self._refresh_replay_state)
|
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)
|
self.force_plots.cursor_moved.connect(self._on_cursor_moved)
|
||||||
|
|
||||||
def _on_cursor_moved(self, t_rel_s: float) -> None:
|
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():
|
if self._view_preset == "replay" and self.replay.is_loaded():
|
||||||
self.replay.set_position(float(t_rel_s))
|
self.replay.set_position(float(t_rel_s))
|
||||||
|
|
||||||
def _on_open_recording(self) -> None:
|
def _on_open_recording(self) -> None:
|
||||||
"""Choose a recording and switch to the view that replays it."""
|
"""Choose a recorded video and switch to the view that replays it.
|
||||||
from orisys_app_shared.record import list_recordings
|
|
||||||
|
|
||||||
chosen = QFileDialog.getExistingDirectory(
|
The recording a video belongs to is its own directory, so the picker
|
||||||
self, t("playback.open"), str(recordings_dir())
|
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:
|
if not chosen:
|
||||||
return
|
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:
|
def _load_recording(self, directory: Path) -> bool:
|
||||||
if self._view_preset != "replay":
|
if self._view_preset != "replay":
|
||||||
self._apply_view_preset("replay")
|
self._apply_view_preset("replay")
|
||||||
opened = self.replay.open_recording(Path(directory))
|
opened = self.replay.open_recording(Path(directory))
|
||||||
if not opened:
|
if not opened:
|
||||||
self.statusBar().showMessage(
|
self._notify(t("playback.open_failed", error=Path(directory).name))
|
||||||
t("playback.open_failed", error=Path(directory).name)
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
self._refresh_replay_state()
|
self._refresh_replay_state()
|
||||||
return True
|
return True
|
||||||
@@ -643,7 +669,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
found = list_recordings(recordings_dir())
|
found = list_recordings(recordings_dir())
|
||||||
if not found:
|
if not found:
|
||||||
self.statusBar().showMessage(t("playback.none_found"))
|
self._notify(t("playback.none_found"))
|
||||||
return
|
return
|
||||||
self._load_recording(Path(found[-1]))
|
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."""
|
"""A recording was opened (or dropped): re-point everything that reads it."""
|
||||||
self._refresh_replay_state()
|
self._refresh_replay_state()
|
||||||
if not opened:
|
if not opened:
|
||||||
|
self._pause_playback()
|
||||||
return
|
return
|
||||||
if self._view_preset == "replay":
|
if self._view_preset == "replay":
|
||||||
self._apply_replay_source(True)
|
self._apply_replay_source(True)
|
||||||
self._render_replay_frame()
|
self._render_replay_frame()
|
||||||
|
|
||||||
def _refresh_replay_state(self) -> None:
|
def _refresh_replay_state(self) -> None:
|
||||||
"""Push the engine's state into the bar, the charts' mode and the rail."""
|
"""Push the engine's state into the charts' mode and the rail."""
|
||||||
loaded = self.replay.is_loaded()
|
if self.replay.is_loaded() and not self.toolbar.video_path():
|
||||||
duration = self.replay.duration_s() if loaded else 0.0
|
# Opened without naming a video (the file menu's "latest"): name the
|
||||||
self.replay_bar.set_loaded(loaded, duration)
|
# segment that is on screen so the rail states the same thing the
|
||||||
if loaded:
|
# picker would.
|
||||||
info = self.replay.info()
|
reader = self.replay.reader()
|
||||||
name = self.replay.directory().name if self.replay.directory() else ""
|
segments = sorted(reader.segments()) if reader is not None else []
|
||||||
self.replay_bar.set_info(
|
if segments:
|
||||||
t(
|
self.toolbar.set_video_path(str(segments[0]))
|
||||||
"playback.loaded_short",
|
self.force_plots.set_playing(self._play_timer.isActive())
|
||||||
name=name,
|
# Loading a recording is what makes a recompute pass possible at all.
|
||||||
frames=len(info.frames) if info is not None else 0,
|
self.act_recompute.setEnabled(
|
||||||
duration=f"{duration:.1f}",
|
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()
|
self._refresh_status_rail()
|
||||||
|
|
||||||
def _on_replay_position(self, position_s: float) -> None:
|
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))
|
self.force_plots.set_cursor(float(position_s))
|
||||||
|
|
||||||
def _on_replay_unit_toggled(self) -> None:
|
def _on_replay_unit_toggled(self) -> None:
|
||||||
"""The replay's unit switch drives the same unit the charts display."""
|
"""The chart's unit chip drives the same unit the replay reads."""
|
||||||
unit = self.replay.toggle_unit()
|
self._set_display_unit("raw" if self.force_plots.unit() == "N" else "N")
|
||||||
self.replay_bar.set_unit(unit)
|
|
||||||
self._set_display_unit(unit)
|
# ------------------------------------------------------------- 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:
|
def _render_replay_frame(self) -> None:
|
||||||
"""Draw what the 2D panel should show for the current replay cursor."""
|
"""Draw what the 2D panel should show for the current replay cursor."""
|
||||||
if self._view_preset != "replay" or not self.replay.is_loaded():
|
if self._view_preset != "replay" or not self.replay.is_loaded():
|
||||||
return
|
return
|
||||||
if self.arrow_panel.view_mode() == "depth":
|
if self._recompute_running:
|
||||||
self.arrow_panel.set_depth_scale(self._replay_depth_scale_text())
|
# The pipeline is drawing its own frames (see ``_replay_owns_image``).
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
self.arrow_panel.set_depth_scale(None)
|
self.arrow_panel.set_depth_scale(None)
|
||||||
image = self.replay.video_image()
|
image = self.replay.video_image()
|
||||||
@@ -707,7 +832,12 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.arrow_panel.show_frame(image)
|
self.arrow_panel.show_frame(image)
|
||||||
|
|
||||||
def _replay_depth_scale_text(self) -> str:
|
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()
|
span = self.replay.depth_range()
|
||||||
if span is None:
|
if span is None:
|
||||||
return ""
|
return ""
|
||||||
@@ -724,10 +854,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# the recording can honour it.
|
# the recording can honour it.
|
||||||
wanted = "N" if self.force_plots.unit() == "N" else "raw"
|
wanted = "N" if self.force_plots.unit() == "N" else "raw"
|
||||||
unit = self._replay_unit_for(reader)
|
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)
|
self._set_display_unit(unit)
|
||||||
if unit != wanted:
|
if unit != wanted:
|
||||||
self.statusBar().showMessage(t("status.units_raw_fallback"))
|
self._notify(t("status.units_raw_fallback"))
|
||||||
self.force_plots.show_all()
|
self.force_plots.show_all()
|
||||||
self.arrow_panel.set_source_kind("replay")
|
self.arrow_panel.set_source_kind("replay")
|
||||||
return
|
return
|
||||||
@@ -829,6 +959,14 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.act_playback_latest.triggered.connect(self._open_latest_recording)
|
self.act_playback_latest.triggered.connect(self._open_latest_recording)
|
||||||
self.file_menu.addAction(self.act_playback_latest)
|
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.view_menu = menu_bar.addMenu("")
|
||||||
self.act_cal_panel = QAction(self)
|
self.act_cal_panel = QAction(self)
|
||||||
self.act_cal_panel.setCheckable(True)
|
self.act_cal_panel.setCheckable(True)
|
||||||
@@ -861,6 +999,15 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.act_rotate_arrows = QAction(self)
|
self.act_rotate_arrows = QAction(self)
|
||||||
self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows)
|
self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows)
|
||||||
self.view_menu.addAction(self.act_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.view_menu.addSeparator()
|
||||||
|
|
||||||
self.act_run = QAction(self)
|
self.act_run = QAction(self)
|
||||||
@@ -932,6 +1079,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.act_finger_3d.blockSignals(True)
|
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.setChecked(bool(self._show_finger_3d) if is_finger else False)
|
||||||
self.act_finger_3d.blockSignals(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()
|
self._apply_arrow_rotation_for_sensor()
|
||||||
|
|
||||||
def _apply_panel_visibility(self) -> None:
|
def _apply_panel_visibility(self) -> None:
|
||||||
@@ -997,21 +1147,25 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._show_cal = True
|
self._show_cal = True
|
||||||
elif preset in ("live", "replay"):
|
elif preset in ("live", "replay"):
|
||||||
self._show_cal = False
|
self._show_cal = False
|
||||||
# The rail shows the picker this view needs: a camera while acquiring,
|
# The rail shows the source this view needs, and only that source: a
|
||||||
# nothing while replaying (the replay bar owns the recording picker).
|
# 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.toolbar.set_source_view("replay" if preset == "replay" else "live")
|
||||||
self._apply_panel_visibility()
|
self._apply_panel_visibility()
|
||||||
self.replay_bar.setVisible(preset == "replay")
|
|
||||||
if apply_sizes and preset in VIEW_SPLITTER_SIZES:
|
if apply_sizes and preset in VIEW_SPLITTER_SIZES:
|
||||||
self.content_splitter.setSizes(list(VIEW_SPLITTER_SIZES[preset]))
|
self.content_splitter.setSizes(list(VIEW_SPLITTER_SIZES[preset]))
|
||||||
if preset == "replay":
|
if preset == "replay":
|
||||||
# History browsing is the point of this view: the charts read the
|
# History browsing is the point of this view: the charts read the
|
||||||
# open recording, and the bar scrubs it. Without a recording the
|
# open recording, and the chart cursor is its timeline. Without a
|
||||||
# charts stay on the live session and follow it.
|
# recording the charts stay on the live session and follow it.
|
||||||
self._apply_replay_source(True)
|
self._apply_replay_source(True)
|
||||||
self._refresh_replay_state()
|
self._refresh_replay_state()
|
||||||
elif preset == "live":
|
elif preset == "live":
|
||||||
self._apply_replay_source(False)
|
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.brand_bar.set_active_view(preset if preset != "custom" else None)
|
||||||
self._sync_menu_checks()
|
self._sync_menu_checks()
|
||||||
self._refresh_status_rail()
|
self._refresh_status_rail()
|
||||||
@@ -1053,6 +1207,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.act_settings.setText(t("menu.settings"))
|
self.act_settings.setText(t("menu.settings"))
|
||||||
self.act_plot_settings.setText(t("menu.plot_settings"))
|
self.act_plot_settings.setText(t("menu.plot_settings"))
|
||||||
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
|
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_run.setText(t("menu.run"))
|
||||||
self.act_stop.setText(t("menu.stop"))
|
self.act_stop.setText(t("menu.stop"))
|
||||||
self.act_preview.setText(t("menu.preview"))
|
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
|
# 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.
|
# only what it cannot: the frame rate, and — **in the replay view only** —
|
||||||
if self.toolbar.source_mode() == "video":
|
# the video part being played. Naming the video in the live view was how a
|
||||||
path = self.toolbar.video_path()
|
# replay's file stayed on the rail after switching back to the camera.
|
||||||
detail = t("status.rail_video_name", name=Path(path).name if path else "?")
|
|
||||||
else:
|
|
||||||
detail = ""
|
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:
|
if self._show_fps and self._latest_fps is not None:
|
||||||
fps = f"{self._latest_fps:.1f} FPS"
|
fps = f"{self._latest_fps:.1f} FPS"
|
||||||
detail = f"{detail} · {fps}" if detail else fps
|
detail = f"{detail} · {fps}" if detail else fps
|
||||||
self.toolbar.set_source_summary(detail)
|
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())
|
self.toolbar.set_calibration_summary(self._calibration_summary())
|
||||||
if self._last_sample_valid is None:
|
if self._last_sample_valid is None:
|
||||||
self.toolbar.set_data_state("")
|
self.toolbar.set_data_state("")
|
||||||
@@ -1330,6 +1497,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
"""
|
"""
|
||||||
if sync_toolbar:
|
if sync_toolbar:
|
||||||
self.toolbar.set_units("newton" if unit == "N" else "raw")
|
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.prefs["units"] = self.toolbar.units()
|
||||||
self.force_plots.set_fixed_newton(unit == "N")
|
self.force_plots.set_fixed_newton(unit == "N")
|
||||||
# Rebuild the visible curves from the attached source (the session store
|
# 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_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
|
||||||
self.act_reset.setEnabled(self.toolbar.btn_reset.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:
|
def _on_run_shortcut(self) -> None:
|
||||||
if not self.toolbar.btn_run.isEnabled():
|
if not self.toolbar.btn_run.isEnabled():
|
||||||
return
|
return
|
||||||
@@ -1661,7 +1837,12 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._persist_prefs()
|
self._persist_prefs()
|
||||||
|
|
||||||
def _on_browse_video(self) -> None:
|
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:
|
if self._running or self._preview_open:
|
||||||
return
|
return
|
||||||
start = self.toolbar.video_path() or str(Path.home())
|
start = self.toolbar.video_path() or str(Path.home())
|
||||||
@@ -1669,13 +1850,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self,
|
self,
|
||||||
t("menu.open_video"),
|
t("menu.open_video"),
|
||||||
start,
|
start,
|
||||||
"Video (*.mp4 *.avi *.mov *.mkv);;All (*)",
|
t("playback.video_filter"),
|
||||||
)
|
)
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
self.toolbar.set_video_path(path)
|
self._load_replay_video(Path(path))
|
||||||
self.prefs["video_path"] = path
|
|
||||||
self._set_source_mode("video")
|
|
||||||
|
|
||||||
def _on_reveal_config(self) -> None:
|
def _on_reveal_config(self) -> None:
|
||||||
sensor_type = self.sensor_picker.sensor_type()
|
sensor_type = self.sensor_picker.sensor_type()
|
||||||
@@ -1891,6 +2070,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._active_worker.stop(wait=wait)
|
self._active_worker.stop(wait=wait)
|
||||||
was_connecting = self._session_phase == "connecting"
|
was_connecting = self._session_phase == "connecting"
|
||||||
self._running = False
|
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._set_phase("error" if reason == "failed" else "idle")
|
||||||
self.cal_panel.set_running(False)
|
self.cal_panel.set_running(False)
|
||||||
self.toolbar.set_run_checked(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
|
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
|
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
|
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:
|
def _render_run_image(self, packet) -> None:
|
||||||
if packet is None or self._replay_owns_image():
|
if packet is None or self._replay_owns_image():
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ class ReplayController(QObject):
|
|||||||
if self._reader is None:
|
if self._reader is None:
|
||||||
self._video_position = None
|
self._video_position = None
|
||||||
self._video_qimage = None
|
self._video_qimage = None
|
||||||
self._video_reason = t("playback.empty")
|
self._video_reason = t("playback.none_found")
|
||||||
return
|
return
|
||||||
if self._video_position == self._position:
|
if self._video_position == self._position:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ from orisys_app_shared.theme import color
|
|||||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||||
|
|
||||||
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
|
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
|
||||||
#: While replaying a recording there is no optical flow to draw: the frames come
|
#: While replaying a recording there is no optical flow to draw and no depth is
|
||||||
#: from the recorded video, and the only processed view is the recomputed depth.
|
#: stored: the panel shows the recorded frames, and nothing else.
|
||||||
REPLAY_VIEW_MODES = ("recorded", "depth")
|
REPLAY_VIEW_MODES = ("recorded",)
|
||||||
_VIEW_LABELS = {
|
_VIEW_LABELS = {
|
||||||
"arrows_pure": "panels.view_arrows_pure",
|
"arrows_pure": "panels.view_arrows_pure",
|
||||||
"arrows_stitched": "panels.view_arrows_stitched",
|
"arrows_stitched": "panels.view_arrows_stitched",
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ class ForcePlotsPanel(QFrame):
|
|||||||
mode_changed = pyqtSignal(str)
|
mode_changed = pyqtSignal(str)
|
||||||
#: The unit chip was clicked: switch between newton and raw.
|
#: The unit chip was clicked: switch between newton and raw.
|
||||||
unit_toggle_requested = pyqtSignal()
|
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:
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -173,6 +175,13 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.mode_label.setObjectName("hintText")
|
self.mode_label.setObjectName("hintText")
|
||||||
header.addWidget(self.mode_label)
|
header.addWidget(self.mode_label)
|
||||||
header.addStretch(1)
|
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 three window actions are symbols: the tooltip explains each one, and
|
||||||
# the pointer stays inside the chart instead of reading three labels.
|
# the pointer stays inside the chart instead of reading three labels.
|
||||||
self.btn_latest = self._header_button("latest")
|
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
|
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
|
||||||
return button
|
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(
|
def _build_value_card(
|
||||||
self, channel: str
|
self, channel: str
|
||||||
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel, QLabel]:
|
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel, QLabel]:
|
||||||
@@ -383,7 +418,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
text = color(theme, "text_primary")
|
text = color(theme, "text_primary")
|
||||||
muted = color(theme, "text_muted")
|
muted = color(theme, "text_muted")
|
||||||
# Icons are painted in the theme's own colour, so they follow a switch.
|
# 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", "")
|
name = getattr(button, "_icon_name", "")
|
||||||
if name:
|
if name:
|
||||||
button.setIcon(icons.icon(name, theme))
|
button.setIcon(icons.icon(name, theme))
|
||||||
@@ -781,6 +816,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
value = float(self.cursors[0].value())
|
value = float(self.cursors[0].value())
|
||||||
if value == self._cursor_t:
|
if value == self._cursor_t:
|
||||||
return
|
return
|
||||||
|
self.pause_playback()
|
||||||
self.set_cursor(value, emit=True)
|
self.set_cursor(value, emit=True)
|
||||||
|
|
||||||
def _on_box_zoom_toggled(self, checked: bool) -> None:
|
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])
|
x0, x1 = float(view_range[0][0]), float(view_range[0][1])
|
||||||
if not (x1 > x0):
|
if not (x1 > x0):
|
||||||
return
|
return
|
||||||
|
self.pause_playback()
|
||||||
first, last = self._source.extent()
|
first, last = self._source.extent()
|
||||||
if last <= first:
|
if last <= first:
|
||||||
return
|
return
|
||||||
@@ -1012,6 +1049,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
start, end = (float(v) for v in self.overview_region.getRegion())
|
start, end = (float(v) for v in self.overview_region.getRegion())
|
||||||
if not (end > start):
|
if not (end > start):
|
||||||
return
|
return
|
||||||
|
self.pause_playback()
|
||||||
first, last = self._source.extent()
|
first, last = self._source.extent()
|
||||||
if last <= first:
|
if last <= first:
|
||||||
return
|
return
|
||||||
@@ -1035,11 +1073,15 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.mode_label.setText(t(key))
|
self.mode_label.setText(t(key))
|
||||||
self.mode_label.setToolTip(t(f"{key}_tip"))
|
self.mode_label.setToolTip(t(f"{key}_tip"))
|
||||||
# "Back to latest" only means something while reading a live session; a
|
# "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.btn_latest.setEnabled(
|
||||||
self._mode == "browse" and not self.is_recording_source()
|
self._mode == "browse" and not self.is_recording_source()
|
||||||
)
|
)
|
||||||
self.btn_overview.setEnabled(self._source_ready)
|
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:
|
def _reload_from_store(self) -> None:
|
||||||
"""Re-populate the visible buffer from the attached source."""
|
"""Re-populate the visible buffer from the attached source."""
|
||||||
@@ -1199,6 +1241,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
name_label.setText(t(f"chart.{key}_name"))
|
name_label.setText(t(f"chart.{key}_name"))
|
||||||
self.btn_latest.setToolTip(t("chart.return_latest_tip"))
|
self.btn_latest.setToolTip(t("chart.return_latest_tip"))
|
||||||
self.btn_latest.setAccessibleName(t("chart.return_latest"))
|
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.setToolTip(t("chart.overview_tip"))
|
||||||
self.btn_overview.setAccessibleName(t("chart.overview"))
|
self.btn_overview.setAccessibleName(t("chart.overview"))
|
||||||
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
|
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from PyQt5.QtCore import QRectF, Qt
|
from PyQt5.QtCore import QPointF, QRectF, Qt
|
||||||
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap, QPolygonF
|
||||||
|
|
||||||
from orisys_app_shared.theme import color
|
from orisys_app_shared.theme import color
|
||||||
|
|
||||||
#: Names of the icons this module can paint.
|
#: 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
|
ICON_SIZE = 16
|
||||||
_PEN_WIDTH = 1.4
|
_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 = {
|
_PAINTERS = {
|
||||||
"zoom_fit": _paint_zoom_fit,
|
"zoom_fit": _paint_zoom_fit,
|
||||||
"box_zoom": _paint_box_zoom,
|
"box_zoom": _paint_box_zoom,
|
||||||
"latest": _paint_latest,
|
"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"))
|
|
||||||
@@ -77,6 +77,8 @@ class ToolbarWidget(QWidget):
|
|||||||
sensor_clicked = pyqtSignal()
|
sensor_clicked = pyqtSignal()
|
||||||
#: "Back to the camera" while a video file is the run source.
|
#: "Back to the camera" while a video file is the run source.
|
||||||
use_camera_clicked = pyqtSignal()
|
use_camera_clicked = pyqtSignal()
|
||||||
|
#: Replay view: pick the recorded video to play.
|
||||||
|
replay_video_clicked = pyqtSignal()
|
||||||
run_toggled = pyqtSignal(bool)
|
run_toggled = pyqtSignal(bool)
|
||||||
reset_clicked = pyqtSignal()
|
reset_clicked = pyqtSignal()
|
||||||
units_changed = pyqtSignal(str)
|
units_changed = pyqtSignal(str)
|
||||||
@@ -193,8 +195,21 @@ class ToolbarWidget(QWidget):
|
|||||||
self.btn_use_camera.setMinimumHeight(BUTTON_HEIGHT)
|
self.btn_use_camera.setMinimumHeight(BUTTON_HEIGHT)
|
||||||
self.btn_use_camera.clicked.connect(self.use_camera_clicked.emit)
|
self.btn_use_camera.clicked.connect(self.use_camera_clicked.emit)
|
||||||
video_layout.addWidget(self.btn_use_camera)
|
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.camera_group)
|
||||||
slot.addWidget(self.video_group)
|
slot.addWidget(self.video_group)
|
||||||
|
slot.addWidget(self.replay_group)
|
||||||
|
|
||||||
self.btn_record = QPushButton()
|
self.btn_record = QPushButton()
|
||||||
self.btn_record.setObjectName("primaryButton")
|
self.btn_record.setObjectName("primaryButton")
|
||||||
@@ -313,6 +328,10 @@ class ToolbarWidget(QWidget):
|
|||||||
self.source_summary.setToolTip(self.source_summary.text())
|
self.source_summary.setToolTip(self.source_summary.text())
|
||||||
self.source_summary.setVisible(bool(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:
|
def set_calibration_summary(self, text: str) -> None:
|
||||||
self.calibration_label.setText(str(text or ""))
|
self.calibration_label.setText(str(text or ""))
|
||||||
self.calibration_label.setToolTip(self.calibration_label.text())
|
self.calibration_label.setToolTip(self.calibration_label.text())
|
||||||
@@ -405,6 +424,9 @@ class ToolbarWidget(QWidget):
|
|||||||
self.btn_search.setEnabled(allowed and camera)
|
self.btn_search.setEnabled(allowed and camera)
|
||||||
self.cam_id_spin.setEnabled(allowed and camera)
|
self.cam_id_spin.setEnabled(allowed and camera)
|
||||||
self.btn_use_camera.setEnabled(allowed and not 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:
|
def set_source_controls_enabled(self, enabled: bool) -> None:
|
||||||
self._source_controls_enabled = bool(enabled)
|
self._source_controls_enabled = bool(enabled)
|
||||||
@@ -518,15 +540,16 @@ class ToolbarWidget(QWidget):
|
|||||||
"""Show the picker the current view needs, and nothing else.
|
"""Show the picker the current view needs, and nothing else.
|
||||||
|
|
||||||
Live + camera: the camera id and its search button. Live + video: a
|
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
|
one-click way back to the camera (that source is the recompute pass's).
|
||||||
file menu and named by the rail's summary). Replay: neither — the replay
|
Replay: the recorded video, which is the one difference between the two
|
||||||
bar owns the recording picker.
|
views' rails.
|
||||||
"""
|
"""
|
||||||
live = self._source_view == "live"
|
live = self._source_view == "live"
|
||||||
video = self.source_mode() == "video"
|
video = self.source_mode() == "video"
|
||||||
self.camera_group.setVisible(live and not video)
|
self.camera_group.setVisible(live and not video)
|
||||||
self.video_group.setVisible(live and 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)
|
self.set_source_controls_enabled(self._source_controls_enabled)
|
||||||
if video and self.btn_preview.isChecked():
|
if video and self.btn_preview.isChecked():
|
||||||
self.set_preview_checked(False)
|
self.set_preview_checked(False)
|
||||||
@@ -536,6 +559,7 @@ class ToolbarWidget(QWidget):
|
|||||||
path = self.video_path()
|
path = self.video_path()
|
||||||
self.source_summary.setToolTip(path or self.source_summary.text())
|
self.source_summary.setToolTip(path or self.source_summary.text())
|
||||||
self.btn_use_camera.setToolTip(t("toolbar.use_camera_tip"))
|
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:
|
def _on_preview_toggled(self, checked: bool) -> None:
|
||||||
self._refresh_preview_label()
|
self._refresh_preview_label()
|
||||||
@@ -619,6 +643,7 @@ class ToolbarWidget(QWidget):
|
|||||||
self.cam_id_label.setText(t("toolbar.cam_id"))
|
self.cam_id_label.setText(t("toolbar.cam_id"))
|
||||||
self.btn_search.setText(t("toolbar.search_cameras"))
|
self.btn_search.setText(t("toolbar.search_cameras"))
|
||||||
self.btn_use_camera.setText(t("toolbar.use_camera"))
|
self.btn_use_camera.setText(t("toolbar.use_camera"))
|
||||||
|
self.btn_replay_video.setText(t("toolbar.choose_video"))
|
||||||
self.refresh_labels()
|
self.refresh_labels()
|
||||||
self.btn_reset.setText(t("toolbar.reset"))
|
self.btn_reset.setText(t("toolbar.reset"))
|
||||||
self.units_label.setText(t("toolbar.units"))
|
self.units_label.setText(t("toolbar.units"))
|
||||||
|
|||||||
@@ -1518,26 +1518,44 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
|
|||||||
|
|
||||||
|
|
||||||
def test_the_source_picker_follows_the_view(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
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
window, stuck = _running_window(make_window)
|
window, stuck = _running_window(make_window)
|
||||||
assert window.toolbar.camera_group.isVisible()
|
assert window.toolbar.camera_group.isVisible()
|
||||||
assert not window.toolbar.video_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"
|
assert not hasattr(window.toolbar, "source_combo"), "the source is still a combobox"
|
||||||
|
|
||||||
window.brand_bar.btn_playback.click()
|
window.brand_bar.btn_playback.click()
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert not window.toolbar.camera_group.isVisible(), "the replay view shows a camera picker"
|
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()
|
window.brand_bar.btn_live.click()
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert window.toolbar.camera_group.isVisible()
|
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.toolbar.set_video_path("C:/tmp/demo.mp4")
|
||||||
window._set_source_mode("video")
|
window._set_source_mode("video")
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert window.toolbar.video_group.isVisible()
|
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"
|
assert not window.toolbar.btn_preview.isEnabled(), "preview is offered for a video file"
|
||||||
|
|
||||||
window.toolbar.use_camera_clicked.emit()
|
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()
|
window.brand_bar.btn_playback.click()
|
||||||
assert window.view_preset() == "replay"
|
assert window.view_preset() == "replay"
|
||||||
assert window.arrow_panel.isVisible() and not window.cal_panel.isVisible()
|
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
|
# No recording is open yet: the charts keep following the live session
|
||||||
# the live session instead of pretending to replay something.
|
# instead of pretending to replay something, and the layout is the live one.
|
||||||
assert window.replay_bar.isVisible() and not window.replay.is_loaded()
|
assert not window.replay.is_loaded()
|
||||||
assert window.force_plots.mode() == "follow", "browsing starts with no recording"
|
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()
|
window.brand_bar.btn_live.click()
|
||||||
assert window.view_preset() == "live"
|
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):
|
def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
|
||||||
"""The old replay window's controls had no text: it never retranslated."""
|
"""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
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
bar = window.replay_bar
|
window.brand_bar.btn_live.click()
|
||||||
assert bar.btn_open.text() == t("playback.open")
|
from PyQt5.QtWidgets import QApplication
|
||||||
assert bar.btn_open.toolTip() == t("playback.open_tip")
|
|
||||||
assert bar.btn_depth.text() == t("playback.recompute_depth")
|
QApplication.processEvents()
|
||||||
assert bar.btn_depth.toolTip() == t("playback.recompute_depth_tip")
|
live_sizes = tuple(window.content_splitter.sizes())
|
||||||
assert bar.btn_unit.text(), "the unit switch is unlabelled"
|
|
||||||
assert bar.slider.toolTip() == t("playback.scrub_tip")
|
window.brand_bar.btn_playback.click()
|
||||||
assert bar.info_label.text() == t("playback.empty")
|
QApplication.processEvents()
|
||||||
assert not bar.slider.isEnabled(), "the progress bar is live with no recording"
|
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):
|
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 PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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 = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_playback.click()
|
window.brand_bar.btn_playback.click()
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert window.replay_bar.isVisible(), "the replay view has no progress bar"
|
|
||||||
|
|
||||||
assert window._load_recording(replay_recording)
|
assert window._load_recording(replay_recording)
|
||||||
QApplication.processEvents()
|
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]
|
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 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.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.
|
# The cursor is the playhead: setting it moves the video.
|
||||||
window.replay_bar.slider.setValue(window.replay_bar.slider.maximum() // 2)
|
window.replay.set_position(window.replay.duration_s() / 2)
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert 0.0 < window.replay.position() < window.replay.duration_s()
|
assert 0.0 < window.replay.position() < window.replay.duration_s()
|
||||||
assert abs(panel.cursors[0].value() - window.replay.position()) < 0.05
|
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)
|
panel.cursors[0].setValue(0.25)
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert abs(window.replay.position() - 0.25) < 0.05
|
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.
|
# Leaving the view returns the charts to the live session.
|
||||||
window.brand_bar.btn_live.click()
|
window.brand_bar.btn_live.click()
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert not panel.is_recording_source()
|
assert not panel.is_recording_source()
|
||||||
assert panel.mode() == "follow"
|
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 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
|
# ------------------------------------------------- correctness regressions
|
||||||
@@ -1870,7 +1917,7 @@ def test_a_replay_keeps_the_2d_pane_from_the_live_camera(
|
|||||||
label = window.arrow_panel.image_label
|
label = window.arrow_panel.image_label
|
||||||
# Park the playhead at the end of the recording and remember what the pane and
|
# 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.
|
# 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()
|
QApplication.processEvents()
|
||||||
before_key = label.pixmap().cacheKey()
|
before_key = label.pixmap().cacheKey()
|
||||||
before_text = label.text()
|
before_text = label.text()
|
||||||
@@ -1922,12 +1969,11 @@ def test_the_readout_describes_the_point_the_cursor_is_on(
|
|||||||
plots = window.force_plots
|
plots = window.force_plots
|
||||||
assert plots.mode() == "browse", "opening a recording did not browse it"
|
assert plots.mode() == "browse", "opening a recording did not browse it"
|
||||||
|
|
||||||
slider = window.replay_bar.slider
|
window.replay.set_position(0.0)
|
||||||
slider.setValue(slider.minimum())
|
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
first = plots.value_labels[0].text()
|
first = plots.value_labels[0].text()
|
||||||
assert first not in ("", t("chart.value_unavailable")), first
|
assert first not in ("", t("chart.value_unavailable")), first
|
||||||
slider.setValue(slider.maximum())
|
window.replay.set_position(window.replay.duration_s())
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
last = plots.value_labels[0].text()
|
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
|
panel = window.force_plots
|
||||||
assert panel.unit() == "raw", "a raw-only recording was shown in newton"
|
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.toolbar.units() == "raw", "the toolbar disagrees with the charts"
|
||||||
assert window.replay_bar.btn_unit.text() == t("playback.show_newton"), (
|
assert panel.unit_badge.text() == t("units.raw_short"), (
|
||||||
"the replay bar's unit switch disagrees with the charts"
|
"the chart's unit chip disagrees with the charts"
|
||||||
)
|
)
|
||||||
drawn = [int(curve.getData()[0].size) for curve in panel.curves]
|
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 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"))
|
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
|
# ------------------------------------------- selection readout and notices
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+150
-46
@@ -517,7 +517,7 @@ def main() -> int:
|
|||||||
from orisys_app_shared.force.calibration_io import CalSample
|
from orisys_app_shared.force.calibration_io import CalSample
|
||||||
from orisys_app_shared.i18n import set_locale, t
|
from orisys_app_shared.i18n import set_locale, t
|
||||||
from orisys_app_shared.theme import build_qss, load_colors
|
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)
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
set_locale("zh_CN")
|
set_locale("zh_CN")
|
||||||
@@ -1491,17 +1491,44 @@ def main() -> int:
|
|||||||
not window.cal_panel.isVisible() and window.arrow_panel.isVisible(),
|
not window.cal_panel.isVisible() and window.arrow_panel.isVisible(),
|
||||||
"the replay view is not the 2D + charts arrangement",
|
"the replay view is not the 2D + charts arrangement",
|
||||||
)
|
)
|
||||||
# No recording is open yet: the bar says so and the charts keep following the
|
# No recording is open yet: the charts keep following the live session
|
||||||
# live session instead of pretending to replay something.
|
# instead of pretending to replay something, and the layout is the live one.
|
||||||
check(
|
check(not window.replay.is_loaded(), "a recording is open before one was chosen")
|
||||||
window.replay_bar.isVisible() and not window.replay.is_loaded(),
|
|
||||||
"the replay view is not showing an empty progress bar",
|
|
||||||
)
|
|
||||||
check(
|
check(
|
||||||
window.force_plots.mode() == "follow",
|
window.force_plots.mode() == "follow",
|
||||||
"the replay view browses before a recording is open",
|
"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()
|
window.brand_bar.btn_live.click()
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
@@ -1859,13 +1886,14 @@ def main() -> int:
|
|||||||
"the toolbar and the charts disagree about a recording's unit",
|
"the toolbar and the charts disagree about a recording's unit",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
pane_window.replay_bar.btn_unit.text() == t("playback.show_newton"),
|
pane_window.force_plots.unit_badge.text() == t("units.raw_short"),
|
||||||
"the replay bar's unit switch disagrees with the charts",
|
"the chart's unit chip disagrees with the charts",
|
||||||
)
|
)
|
||||||
drawn = [int(curve.getData()[0].size) for curve in pane_window.force_plots.curves]
|
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}")
|
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.
|
# 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()
|
app.processEvents()
|
||||||
cursor_card = pane_window.force_plots.value_labels[0].text()
|
cursor_card = pane_window.force_plots.value_labels[0].text()
|
||||||
check(
|
check(
|
||||||
@@ -2706,8 +2734,8 @@ def main() -> int:
|
|||||||
"returning to the live view did not restore the camera picker",
|
"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
|
# A video file is a *replay* source: the live view offers the way back to the
|
||||||
# the way back, with no source combobox anywhere.
|
# camera, and the video is named only in the replay view.
|
||||||
window.toolbar.set_video_path("C:/tmp/demo.mp4")
|
window.toolbar.set_video_path("C:/tmp/demo.mp4")
|
||||||
window._set_source_mode("video")
|
window._set_source_mode("video")
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
@@ -2716,10 +2744,25 @@ def main() -> int:
|
|||||||
window.toolbar.video_group.isVisible() and not window.toolbar.camera_group.isVisible(),
|
window.toolbar.video_group.isVisible() and not window.toolbar.camera_group.isVisible(),
|
||||||
"video mode still offers the camera group",
|
"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(
|
check(
|
||||||
"demo.mp4" in window.toolbar.source_summary.text(),
|
"demo.mp4" in window.toolbar.source_summary.text(),
|
||||||
f"the rail does not name the video file: {window.toolbar.source_summary.text()!r}",
|
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()
|
window.toolbar.use_camera_clicked.emit()
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
@@ -2777,8 +2820,8 @@ def main() -> int:
|
|||||||
replay_window.brand_bar.btn_playback.click()
|
replay_window.brand_bar.btn_playback.click()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
check(
|
check(
|
||||||
replay_window.replay_bar.isVisible(),
|
replay_window.toolbar.btn_replay_video.isVisible(),
|
||||||
"the replay view has no progress bar",
|
"the replay view does not offer a recorded video",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
not replay_window.toolbar.camera_group.isVisible(),
|
not replay_window.toolbar.camera_group.isVisible(),
|
||||||
@@ -2789,6 +2832,13 @@ def main() -> int:
|
|||||||
"the replay view could not open the recording",
|
"the replay view could not open the recording",
|
||||||
)
|
)
|
||||||
app.processEvents()
|
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 = replay_window.replay
|
||||||
replay_plots = replay_window.force_plots
|
replay_plots = replay_window.force_plots
|
||||||
@@ -2813,20 +2863,39 @@ def main() -> int:
|
|||||||
"a recording offers 'back to latest'",
|
"a recording offers 'back to latest'",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The progress bar is the playhead, and the chart cursor is a second one.
|
# The chart cursor is the playhead, and the transport moves it at 1x.
|
||||||
check(replay_window.replay_bar.slider.isEnabled(), "the progress bar is disabled")
|
check(replay_plots.btn_play.isEnabled(), "the replay has no transport")
|
||||||
replay_window.replay_bar.slider.setValue(replay_window.replay_bar.slider.maximum() // 2)
|
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()
|
app.processEvents()
|
||||||
check(
|
check(
|
||||||
0.0 < replay.position() < replay.duration_s(),
|
replay.position() > 0.05,
|
||||||
f"dragging the progress bar did not move the playhead: {replay.position()}",
|
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(
|
check(
|
||||||
abs(float(replay_plots.cursors[0].value()) - replay.position()) < 0.05,
|
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)
|
replay_plots.cursors[0].setValue(0.25)
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
|
check(
|
||||||
|
not replay_plots.btn_play.isChecked(),
|
||||||
|
"dragging the cursor did not pause the playback",
|
||||||
|
)
|
||||||
check(
|
check(
|
||||||
abs(replay.position() - 0.25) < 0.05,
|
abs(replay.position() - 0.25) < 0.05,
|
||||||
f"dragging the chart cursor did not scrub the replay: {replay.position()}",
|
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",
|
"no video frame was rendered at the cursor position",
|
||||||
)
|
)
|
||||||
check(
|
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",
|
"the 2D panel still offers the live-only views while replaying",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2871,9 +2940,9 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
|
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
|
||||||
in replay_window.replay_bar.warning_label.text(),
|
in replay_window.toolbar.source_summary.toolTip(),
|
||||||
f"the warnings are not shown in the bar: "
|
f"the replay notes are not reachable from the rail: "
|
||||||
f"{replay_window.replay_bar.warning_label.text()!r}",
|
f"{replay_window.toolbar.source_summary.toolTip()!r}",
|
||||||
)
|
)
|
||||||
|
|
||||||
moved: list[float] = []
|
moved: list[float] = []
|
||||||
@@ -2886,22 +2955,17 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
check(moved == [1.0], f"the cursor signal did not fire once: {moved}")
|
check(moved == [1.0], f"the cursor signal did not fire once: {moved}")
|
||||||
check(
|
check(
|
||||||
"1.00" in replay_window.replay_bar.position_label.text(),
|
abs(replay_plots.cursor_time() - 1.0) < 0.05,
|
||||||
f"the bar does not show the playhead: "
|
f"the chart cursor did not follow a programmatic position: "
|
||||||
f"{replay_window.replay_bar.position_label.text()!r}",
|
f"{replay_plots.cursor_time()}",
|
||||||
)
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# The unit switch rebuilds the recording's curves, not the live ones.
|
# The chart's unit chip rebuilds the recording's curves, not the live ones.
|
||||||
replay_window.replay_bar.unit_toggled.emit()
|
replay_plots.unit_badge.click()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
check(
|
check(
|
||||||
replay.unit() == "N" and replay_plots._unit == "N",
|
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
|
# 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,
|
replay.depth_image() is None,
|
||||||
"a failed recomputation left an image in the depth pane",
|
"a failed recomputation left an image in the depth pane",
|
||||||
)
|
)
|
||||||
# The 2D panel shows the depth pane's explanation, not the raw frame.
|
# The replay view no longer offers a depth pane: depth is not stored with a
|
||||||
replay_window.arrow_panel.set_view_mode("depth")
|
# recording, and re-running the pipeline over the video is the recompute
|
||||||
app.processEvents()
|
# mode's job (see the `recompute` section).
|
||||||
check(
|
check(
|
||||||
replay_window.arrow_panel.depth_scale_label.text() == ""
|
replay_window.arrow_panel.view_modes() == ("recorded",),
|
||||||
and replay_window.arrow_panel.colorbar.isVisible(),
|
f"the replay still offers a depth pane: "
|
||||||
"the replay depth view does not show its colour bar",
|
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
|
# 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
|
# whether the state is really recoverable; and a request must belong to the
|
||||||
@@ -3057,6 +3119,42 @@ def main() -> int:
|
|||||||
second_controller.close()
|
second_controller.close()
|
||||||
app.processEvents()
|
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
|
# Leaving the replay view returns the charts to the live session and hides
|
||||||
# the bar: the replay stays available, it just stops being on screen.
|
# the bar: the replay stays available, it just stops being on screen.
|
||||||
check(
|
check(
|
||||||
@@ -3071,8 +3169,14 @@ def main() -> int:
|
|||||||
"leaving the replay view did not restore the live charts",
|
"leaving the replay view did not restore the live charts",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
not replay_window.replay_bar.isVisible(),
|
not replay_plots.btn_play.isEnabled(),
|
||||||
"the progress bar is still on screen in the live view",
|
"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(
|
check(
|
||||||
replay_window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth"),
|
replay_window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth"),
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import ctypes
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -39,9 +42,43 @@ HANDOVER_TIMEOUT_S = 60.0
|
|||||||
#: Window over which the delivered frame rate is measured.
|
#: Window over which the delivered frame rate is measured.
|
||||||
FPS_WINDOW_S = 1.0
|
FPS_WINDOW_S = 1.0
|
||||||
#: How long to wait before asking again when the capture source has not produced
|
#: 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
|
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
|
# Re-exported so ``from ...sdk_worker import SdkPacket`` keeps working; the
|
||||||
# definitions live in ``models`` so neither worker depends on the other.
|
# definitions live in ``models`` so neither worker depends on the other.
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -557,6 +594,10 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
|
|
||||||
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
|
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
|
||||||
try:
|
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)
|
self._run_session(session, previous)
|
||||||
finally:
|
finally:
|
||||||
# The finishing thread owns the teardown — this is what lets
|
# 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*:
|
#: The capture sequence of the last frame that was actually *processed*:
|
||||||
#: an unchanged counter means the same frame came back again.
|
#: an unchanged counter means the same frame came back again.
|
||||||
last_delivered: Optional[int] = None
|
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,
|
# Per-session monotonic frame index: the join key shared by the video,
|
||||||
# the CSV and the calibration records.
|
# the CSV and the calibration records.
|
||||||
frame_id = 0
|
frame_id = 0
|
||||||
@@ -655,7 +700,15 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
# per call, so it has no repeat marker and is left alone.
|
# per call, so it has no repeat marker and is left alone.
|
||||||
delivered = _capture_sequence(sensor)
|
delivered = _capture_sequence(sensor)
|
||||||
if delivered is not None and delivered == last_delivered:
|
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
|
continue
|
||||||
last_delivered = delivered
|
last_delivered = delivered
|
||||||
processed = sensor.get_img()
|
processed = sensor.get_img()
|
||||||
@@ -721,7 +774,15 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
# Wall clock for metadata; monotonic for ordering.
|
# Wall clock for metadata; monotonic for ordering.
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
wall_t=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,
|
fps=fps,
|
||||||
process_ms=(t1 - t0) * 1000.0,
|
process_ms=(t1 - t0) * 1000.0,
|
||||||
normal_force=forces["normal_force"],
|
normal_force=forces["normal_force"],
|
||||||
@@ -753,6 +814,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
frame_id += 1
|
frame_id += 1
|
||||||
saw_frame = True
|
saw_frame = True
|
||||||
last_frame_mono = t1
|
last_frame_mono = t1
|
||||||
|
last_frame_at = t1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._set_end_reason("error")
|
self._set_end_reason("error")
|
||||||
if self._on_error is not None and session == self._session_id:
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ from pathlib import Path
|
|||||||
APP_ROOT = Path(__file__).resolve().parents[2]
|
APP_ROOT = Path(__file__).resolve().parents[2]
|
||||||
BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json"
|
BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json"
|
||||||
|
|
||||||
#: The fake device's own frame period: ~100 frames per second.
|
#: The fake device's own frame period: 30 frames per second, the rate the studio
|
||||||
FRAME_PERIOD_S = 0.01
|
#: 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.
|
#: How many times the SDK hands the same frame back before the next one arrives.
|
||||||
REPEATS_PER_FRAME = 3
|
REPEATS_PER_FRAME = 3
|
||||||
|
|
||||||
@@ -153,7 +154,9 @@ def _collect(duration_s: float = 1.2):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
worker.add_sink(sink)
|
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)
|
time.sleep(duration_s)
|
||||||
worker.stop(wait=True)
|
worker.stop(wait=True)
|
||||||
delivered = CREATED[-1].capture_source.status.last_seq if CREATED else 0
|
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():
|
def test_the_reported_rate_is_the_delivered_rate():
|
||||||
"""``packet.fps`` describes the frames, not how often the loop asked."""
|
"""``packet.fps`` describes the frames, not how often the loop asked."""
|
||||||
packets, delivered = _collect()
|
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)
|
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
|
if __name__ == "__main__": # pragma: no cover - manual runner
|
||||||
failures = 0
|
failures = 0
|
||||||
for name, function in sorted(globals().items()):
|
for name, function in sorted(globals().items()):
|
||||||
|
|||||||
Reference in New Issue
Block a user