回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 9 项评审问题 - What: 回放不再是独立窗口——删除 PlaybackDialog,拆成 replay.py::ReplayController(读盘、游标、深度重算,只发信号)与 ui/replay_bar.py(录制选择、可拖拽进度条、单位、重算深度、提示行),品牌轨「历史回放」直接换主窗口布局,文件菜单两条入口都切到该视图,曲线改读 RecordingCurveSource(区间按需读盘 + 缓存包络),2D 面板切「原始图像 / 深度图」;传感器与标定合并到 SensorDialog(类型/配置下拉 + 当前生效标定 + 最近导出系数),标定面板加纵向滚动区、九按钮两行、六列表头 ResizeToContents。评审 9 项:1) 开场尖峰两个根因——push() 遇单位变化只记 _unit 而保留旧单位样本(raw 计数被画在牛顿轴上),改为「切换即换单位」:清缓冲按新单位重建、触发切换的那一帧仍刷数值卡;_start_run 先 session_store.reset() 再 set_store(),新会话不再带上次的尾巴(复现脚本 .repro/probe_item1.py:旧 39 样本峰值 9.841 → 新 37 样本峰值 2.390)。2) 箭头页脚改为「箭头按位移放大 ×{factor}」。3) 表头单位芯片由只读 QLabel 改为可点按钮,一次点击同时改曲线、数值卡与设置区下拉。4-6) 全程总览/框选/返回最新 改为 ui/icons.py 按主题令牌运行时绘制的图标,tooltip 与 accessibleName 保留文字,全程总览区域名仍是文字。7) stats() 在录制进行中即返回 _last_t - _t0,记录条开始计时(此前只在 _finalise 写、整场停在 00:00.0)。8) 容器 fps 改用传感器配置的速率(packet.fps 是"每秒完成多少帧"的计数器,突发/文件源会读到 48),视频按 1/fps 节流并把超前的帧计入 resampled_video(frames.csv/forces.csv 一行不少,报告写明原因),session.json 同时记 fps 与 measured_fps。9) _replay_owns_image() 让回放视图独占 2D 面板,实时帧不再覆盖;push() 在录制源挂载时连单位都不切,曲线/数值卡/单位一并归录制。附带:数值卡改读游标处样本(_readout_at_cursor)、回放按录制实际有的单位打开(_replay_unit_for + ReplayController.set_unit,避免 raw-only 录制在 newton 偏好下画三张空图)、视频速率下限。 - Why: 用户评审 9 点,其中 1/7/8/9 是代码缺陷(开场尖峰、录制时长不更新、录制 48fps 与实际 30fps 不符、回放时 2D 面板仍被摄像头刷新),2-6 是可读性问题(×2 含义不明、单位与视图控制应可点/应符号化)。 - Verification: ui_check.py 524 项 0 失败(新增 chart header and unit chip / acquisition correctness / replay owns the 2D pane 三段)、test_ui_smoke.py 69 项 0 失败(新增 7 个回归用例:单位切换不混单位、单位芯片、图标语义、录制中时长、传感器帧率+节流、回放独占面板、回放游标读数与单位回退)、drive_shared.py 146 项 0 失败、locale_studio.py(268 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图(深色实时/回放、浅色 1280x720 实时与标定)。 - Not in this change: 录制文件格式(forces.csv 列序、frames.csv、session.json 路径与分段规则)不变,节流只影响"哪些帧进视频",并显式计数上报;三维面板仍是占位符。

This commit is contained in:
ykcheungab
2026-09-13 20:46:27 +08:00
parent 35e86ec1f7
commit 824544fc7a
20 changed files with 3129 additions and 834 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

+29 -17
View File
@@ -58,13 +58,10 @@
"cancel": "Cancel" "cancel": "Cancel"
}, },
"toolbar": { "toolbar": {
"source": "Source",
"source_camera": "Camera",
"source_video": "Video file",
"search_cameras": "Search", "search_cameras": "Search",
"cam_id": "Cam ID", "cam_id": "Camera",
"browse_video": "Browse…", "use_camera": "Use camera",
"video_placeholder": "No video selected", "use_camera_tip": "Go back to acquiring from a camera (a video file is in use)",
"preview": "Preview", "preview": "Preview",
"preview_stop": "Stop preview", "preview_stop": "Stop preview",
"run": "Run", "run": "Run",
@@ -81,7 +78,7 @@
"units_newton": "Newton", "units_newton": "Newton",
"units_raw": "Raw", "units_raw": "Raw",
"settings": "Settings", "settings": "Settings",
"settings_tip": "Show or hide the source, camera, unit and product settings" "settings_tip": "Show or hide the unit, record-mode and preview controls"
}, },
"units": { "units": {
"newton_short": "N", "newton_short": "N",
@@ -92,6 +89,18 @@
"config": "Sensor config", "config": "Sensor config",
"type_tip": "Pick the product/sensor: box, mini or finger. It decides which config files and algorithm parameters exist.", "type_tip": "Pick the product/sensor: box, mini or finger. It decides which config files and algorithm parameters exist.",
"config_tip": "The algorithm config file (config/*.json) this sensor uses. Switching the sensor switches the available configs with it.", "config_tip": "The algorithm config file (config/*.json) this sensor uses. Switching the sensor switches the available configs with it.",
"summary": "{type} · {config}",
"dialog_title": "Sensor and calibration",
"dialog_tip": "Pick the sensor type and config, and read the calibration in effect and the last exported coefficients",
"calib_section": "Calibration in effect",
"calib_version": "Version confirmed by the device",
"calib_axes": "Calibrated axes",
"calib_config": "Config",
"calib_none": "the device has not confirmed a calibration",
"export_section": "Last exported coefficients",
"export_none": "No coefficients exported yet",
"export_file": "file {name} ({size} KB, {time})",
"export_identity": "file identity type {sensor_type} · SN {sn} · config {config}",
"thumb_placeholder": "Product model" "thumb_placeholder": "Product model"
}, },
"panels": { "panels": {
@@ -99,12 +108,16 @@
"view_arrows_pure": "Arrows", "view_arrows_pure": "Arrows",
"view_arrows_stitched": "Arrows / Captured", "view_arrows_stitched": "Arrows / Captured",
"view_depth": "Depth map", "view_depth": "Depth map",
"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_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_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",
"depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)", "depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)",
"depth_scale": "Depth scale {low} – {high}", "depth_scale": "Depth scale {low} – {high}",
"arrow_scale": "Display ×{factor}", "arrow_scale": "Arrows: displacement ×{factor}",
"arrow_scale_tip": "Display magnification of the relative displacement: the arrows are drawn from the algorithm output of the captured image, with the measured displacement multiplied by this factor and capped at 0.8 of the grid spacing (a capped arrow no longer shows its true factor).", "arrow_scale_tip": "Display magnification of the relative displacement: the arrows are drawn from the algorithm output of the captured image, with the measured displacement multiplied by this factor and capped at 0.8 of the grid spacing (a capped arrow no longer shows its true factor).",
"arrow_pipeline": "Captured image → algorithm output", "arrow_pipeline": "Captured image → algorithm output",
"arrow_pipeline_tip": "Arrows are drawn over the algorithm output, not over the raw camera frame: points below the threshold get no arrow", "arrow_pipeline_tip": "Arrows are drawn over the algorithm output, not over the raw camera frame: points below the threshold get no arrow",
@@ -129,6 +142,7 @@
"no_data": "—", "no_data": "—",
"mode_follow": "Following live", "mode_follow": "Following live",
"mode_browse": "Browsing history (display paused; capture and recording continue)", "mode_browse": "Browsing history (display paused; capture and recording continue)",
"mode_replay": "Replaying a recording (browsing)",
"return_latest": "Back to latest", "return_latest": "Back to latest",
"return_latest_tip": "Leave history browsing and follow the newest data again", "return_latest_tip": "Leave history browsing and follow the newest data again",
"overview": "Whole session", "overview": "Whole session",
@@ -139,17 +153,16 @@
"selection_empty": "No valid samples in the selection", "selection_empty": "No valid samples in the selection",
"value_unavailable": "n/a", "value_unavailable": "n/a",
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)", "value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
"unit_toggle": "Switch unit",
"unit_toggle_tip": "Click to switch the three curves and readouts between newton and raw units",
"browse_only_tip": "Drag or scroll on the chart to browse history" "browse_only_tip": "Drag or scroll on the chart to browse history"
}, },
"layout": {
"default": "Free layout",
"preset_2d_name": "2D mode (depth + charts)",
"preset_2d": "2D mode: depth map on the left, force charts on the right, one shared time axis",
"preset_default": "Free layout restored"
},
"playback": { "playback": {
"title": "Replay recording", "title": "Replay recording",
"open": "Open recording directory…", "open": "Open recording…",
"open_tip": "Pick a recording directory under outputs/recordings (video + curves + metadata)",
"scrub_tip": "Drag the progress bar to move the playhead: video, depth and the chart cursor follow",
"loaded_short": "{name} · {frames} frames / {duration} s",
"open_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", "loaded": "{name} — {frames} frames / {duration} s",
@@ -318,8 +331,7 @@
"phase_connecting": "Connecting", "phase_connecting": "Connecting",
"phase_running": "Acquiring", "phase_running": "Acquiring",
"phase_error": "Error", "phase_error": "Error",
"rail_camera": "Camera {id}", "rail_video_name": "Video file: {name}",
"rail_video": "Video file",
"data_valid": "Data valid", "data_valid": "Data valid",
"data_invalid": "{count} invalid sample(s)", "data_invalid": "{count} invalid sample(s)",
"calibrated": "Calibrated", "calibrated": "Calibrated",
+28 -16
View File
@@ -58,13 +58,10 @@
"cancel": "取消" "cancel": "取消"
}, },
"toolbar": { "toolbar": {
"source": "来源",
"source_camera": "摄像机",
"source_video": "视频文件",
"search_cameras": "搜索", "search_cameras": "搜索",
"cam_id": "摄像机", "cam_id": "摄像机",
"browse_video": "浏览…", "use_camera": "使用摄像机",
"video_placeholder": "未选择视频", "use_camera_tip": "回到摄像机采集(当前正在用视频文件采集)",
"preview": "预览", "preview": "预览",
"preview_stop": "停止预览", "preview_stop": "停止预览",
"run": "运行", "run": "运行",
@@ -81,7 +78,7 @@
"units_newton": "牛顿", "units_newton": "牛顿",
"units_raw": "原始", "units_raw": "原始",
"settings": "设置", "settings": "设置",
"settings_tip": "展开或收起来源、摄像机、单位与产品配置" "settings_tip": "展开或收起单位、记录模式与预览"
}, },
"units": { "units": {
"newton_short": "N", "newton_short": "N",
@@ -92,6 +89,18 @@
"config": "传感器配置", "config": "传感器配置",
"type_tip": "选择产品/传感器:box、mini、finger。它决定可用的配置文件与算法参数。", "type_tip": "选择产品/传感器:box、mini、finger。它决定可用的配置文件与算法参数。",
"config_tip": "该传感器使用的算法配置文件(config/*.json)。切换传感器会同时切换可选配置。", "config_tip": "该传感器使用的算法配置文件(config/*.json)。切换传感器会同时切换可选配置。",
"summary": "{type} · {config}",
"dialog_title": "传感器与标定",
"dialog_tip": "选择传感器类型与配置文件,并查看当前生效的标定与最近导出的系数",
"calib_section": "当前生效的标定",
"calib_version": "设备确认的版本",
"calib_axes": "已标定轴",
"calib_config": "配置",
"calib_none": "设备尚未确认加载标定",
"export_section": "最近导出的系数",
"export_none": "还没有导出过系数",
"export_file": "文件 {name}({size} KB,{time})",
"export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}",
"thumb_placeholder": "产品型号" "thumb_placeholder": "产品型号"
}, },
"panels": { "panels": {
@@ -99,12 +108,16 @@
"view_arrows_pure": "箭头", "view_arrows_pure": "箭头",
"view_arrows_stitched": "箭头/采集图像", "view_arrows_stitched": "箭头/采集图像",
"view_depth": "深度图", "view_depth": "深度图",
"view_recorded": "原始图像",
"view_mode_tip": "运行时的图像显示模式", "view_mode_tip": "运行时的图像显示模式",
"replay_pipeline": "录制视频 → 深度重算",
"replay_pipeline_tip": "回放显示的是录制下来的原始相机图像;深度图由录制视频与配置离线重算,不含实时箭头叠加",
"replay_empty": "尚未打开录制\n点击下方「打开录制…」",
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」", "arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
"arrow_empty_tip": "运行或预览后在此显示形变箭头图", "arrow_empty_tip": "运行或预览后在此显示形变箭头图",
"depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)", "depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)",
"depth_scale": "深度色标 {low} – {high}", "depth_scale": "深度色标 {low} – {high}",
"arrow_scale": "放大 ×{factor}", "arrow_scale": "箭头按位移放大 ×{factor}",
"arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。", "arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。",
"arrow_pipeline": "原始图像 → 算法输出", "arrow_pipeline": "原始图像 → 算法输出",
"arrow_pipeline_tip": "箭头画在算法输出上,不是相机原始帧:阈值以下的区域不画箭头", "arrow_pipeline_tip": "箭头画在算法输出上,不是相机原始帧:阈值以下的区域不画箭头",
@@ -129,6 +142,7 @@
"no_data": "—", "no_data": "—",
"mode_follow": "跟随实时", "mode_follow": "跟随实时",
"mode_browse": "浏览历史(显示暂停,采集与记录继续)", "mode_browse": "浏览历史(显示暂停,采集与记录继续)",
"mode_replay": "回放录制(浏览)",
"return_latest": "返回最新", "return_latest": "返回最新",
"return_latest_tip": "退出历史浏览,恢复跟随最新数据", "return_latest_tip": "退出历史浏览,恢复跟随最新数据",
"overview": "全程总览", "overview": "全程总览",
@@ -139,17 +153,16 @@
"selection_empty": "选区无有效样本", "selection_empty": "选区无有效样本",
"value_unavailable": "不可用", "value_unavailable": "不可用",
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)", "value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
"unit_toggle": "切换单位",
"unit_toggle_tip": "点击在「牛顿」与「原始单位」之间切换三轴曲线与读数",
"browse_only_tip": "拖动或滚轮即可进入历史浏览" "browse_only_tip": "拖动或滚轮即可进入历史浏览"
}, },
"layout": {
"default": "自由布局",
"preset_2d_name": "二维模式(深度图 + 曲线)",
"preset_2d": "已切换到二维模式:左侧深度图、右侧三轴曲线,共用时间轴",
"preset_default": "已恢复自由布局"
},
"playback": { "playback": {
"title": "录制回放", "title": "录制回放",
"open": "打开录制目录…", "open": "打开录制…",
"open_tip": "选择 outputs/recordings 下的一个录制目录(视频 + 曲线 + 元数据)",
"scrub_tip": "拖动进度条移动回放位置:视频、深度与曲线游标同步",
"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", "loaded": "{name} — {frames} 帧 / {duration} s",
@@ -318,8 +331,7 @@
"phase_connecting": "连接中", "phase_connecting": "连接中",
"phase_running": "采集中", "phase_running": "采集中",
"phase_error": "出错", "phase_error": "出错",
"rail_camera": "摄像机 {id}", "rail_video_name": "视频文件: {name}",
"rail_video": "视频文件",
"data_valid": "数据有效", "data_valid": "数据有效",
"data_invalid": "无效样本 {count}", "data_invalid": "无效样本 {count}",
"calibrated": "已标定", "calibrated": "已标定",
+31 -1
View File
@@ -157,6 +157,26 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
- `raw_frame` 在入队时**必须拷贝**:packet 里的帧可能复用 SDK 的采集缓冲,直接持有引用 - `raw_frame` 在入队时**必须拷贝**:packet 里的帧可能复用 SDK 的采集缓冲,直接持有引用
会让录制内容随后续帧改变。 会让录制内容随后续帧改变。
### 6.1 录制速率与时长(视频 30 fps、时长实时递增)
`session.json` 的 `video.fps` 是**容器速率**,必须等于传感器的采集速率,取自传感器配置
(`config/sensors/<type>/sensor.json` 的 `fps`,box 为 30),**不是** packet 里那个
`fps` 字段 —— 后者是"管线每秒完成多少帧"的计数器,开流瞬间的缓冲突发或视频文件源会让
它读到 48,用它写 30 fps 的容器就等于把回放播成了快进。这是"传感器 30 fps、录制结果
48 fps"的直接原因。
- `StudioMainWindow._video_fps()`:实时相机 → 传感器配置的 `fps`;视频文件源 → 实测值;
实时相机的实测值另存 `video.measured_fps`,两者都进 `session.json`,对不上时看得见。
- **视频按速率节流,CSV 一行不少**(`ExperimentRecorder._video_frame_due`):比
`1/fps` 提前超过 `VIDEO_PACING_TOLERANCE`(0.5 个周期)到达的帧**只进
`frames.csv` / `forces.csv`,不进视频**,并计入 `stats.resampled_video`;报告里会写成
"N frame(s) were delivered faster than the 30 fps recording rate, so they are in
frames.csv but not in the video"。这不是丢数据,是显式记账。
- 节流表的调度基准取"上一帧排定时刻 + 周期"与"当前时间戳"的**较晚者**,所以卡顿之后不会
用一串补帧追进度。
- `stats()` 的 `duration_s` 在录制**进行中**就返回 `_last_t - _t0`(此前只有 `_finalise`
才写,界面上的录制时长因此整场停在 00:00.0);`forces.csv` 的行数与时间轴不受影响。
### 已知环境限制 ### 已知环境限制
本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,`conftest.py` 的 `tmp_path` 因此 本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,`conftest.py` 的 `tmp_path` 因此
@@ -274,11 +294,21 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
### 回放界面 ### 回放界面
`ui/playback_dialog.py`,入口在「文件 → 打开录制回放…/回放最近一次录制」。 回放**不再是一个独立窗口**:`ui/playback_dialog.py` 已删除,拆成
`replay.py::ReplayController`(读盘 + 游标 + 深度重算)与 `ui/replay_bar.py::ReplayBar`
(录制选择 + 进度条 + 单位 + 提示),入口在「文件 → 打开录制目录…/回放最近一次录制」
与品牌轨的「历史回放」。布局与关闭流程见 `docs/shell-lifecycle.md` §2。
- **一个游标驱动三处**:移动位置同时更新视频帧、深度面板与三张曲线的游标线; - **一个游标驱动三处**:移动位置同时更新视频帧、深度面板与三张曲线的游标线;
并且通过 `cursor_moved` 信号同步到主界面的曲线面板。 并且通过 `cursor_moved` 信号同步到主界面的曲线面板。
- 单位可切换(raw/N),数据直接来自录制目录的 `forces.csv`。 - 单位可切换(raw/N),数据直接来自录制目录的 `forces.csv`。
- **回放按录制实际有的单位打开**:录制里 `FN_N` 等列全空(没有标定)时退回 raw,
否则选 N 会画出三张空图;`raw` 一定存在(那是设备测到的值)。
单位由 `ReplayController.set_unit` 一处决定,回放条、曲线与设置区下拉同时改。
- **数值卡读的是游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`):浏览历史或回放
时,三个大数字是播放头所指的样本,而不是实时采集的最后一个样本 —— 否则一张 7.5e5
原始计数的曲线旁边会写着实时的 `0.0`,看起来就像"回放根本没打开"。
实时视图仍是"最新样本就是游标",所以跟随状态下不额外读盘。
- 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。 - 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。
- **深度重算在守护线程上执行**,UI 线程立即返回。实测 `_on_recompute_depth` 返回耗时 - **深度重算在守护线程上执行**,UI 线程立即返回。实测 `_on_recompute_depth` 返回耗时
0.001 s,而 SDK 调用仍在后台跑。 0.001 s,而 SDK 调用仍在后台跑。
+125 -19
View File
@@ -1,7 +1,8 @@
# Studio 外壳:视图与关闭流程 # Studio 外壳:视图与关闭流程
这份文档记录主窗口的两件"外壳"行为:三个顶层视图如何切换面板,以及窗口关闭后进程 这份文档记录主窗口的三件"外壳"行为:三个顶层视图如何切换面板、回放如何并入主窗口
如何回到命令行。两者都不属于记录通道,但都会直接影响可用性。 (含可拖拽进度条),以及窗口关闭后进程如何回到命令行。它们都不属于记录通道,但都会
直接影响可用性。
--- ---
@@ -10,31 +11,136 @@
品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局 品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局
(`StudioMainWindow._apply_view_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`): (`StudioMainWindow._apply_view_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`):
| 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) | | 视图 | 面板 | 曲线 | 比例(3D,2D,曲线,标定) |
|---|---|---|---| |---|---|---|---|
| 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` | | 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
| 历史回放 `replay` | 2D 图像 + 力曲线 | 进入全程浏览 | `0, 320, 1120, 0` | | 历史回放 `replay` | 2D 图像 + 力曲线 + **回放条** | 读打开的录制;未打开时仍是实时跟随 | `0, 320, 1120, 0` |
| 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` | | 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
| 自由 `custom`(启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 | | 自由 `custom`(启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
- **为什么标定视图给标定面板 880 px**:六列诊断表头加九个动作按钮,在 300–400 px 的 - **为什么标定视图给标定面板 880 px**:六列诊断表头加九个动作按钮,在 300–400 px 的
三栏布局里必然被挤压(原来的症状就是按钮文字被切成「排 除」「设为验证点」)。面板自身 三栏布局里必然被挤压。标定面板自身还有纵向滚动区,窗口再矮也够得到「应用到设备」。
还有纵向滚动区,窗口再矮也够得到「应用到标定」。 - **`custom` 不点亮任何导航项**:手工摆出来的布局不属于三个视图中的任何一个。
- **为什么导航不再自动打开回放对话框**:回放对话框(视频 + 离线深度重算 + 同一游标)仍
从「文件」菜单打开;导航的职责是把"看历史"这件事在主界面里摆好——全程浏览 + 2D 图像,
立刻可以拖时间轴。
- **`custom` 不点亮任何导航项**:手工摆出来的布局不属于三个视图中的任何一个,点亮一个
会误报状态。
- **`视图 → 标定面板` 与「标定」导航是同一个开关**:勾上进入标定视图,取消回到实时视图。 - **`视图 → 标定面板` 与「标定」导航是同一个开关**:勾上进入标定视图,取消回到实时视图。
不允许出现"面板已隐藏但导航还亮着"。 - **布局菜单里的「自由布局 / 二维模式」已删除**:导航视图 + 分割条已经覆盖它们,而
「深度图 + 曲线」由 2D 面板自带的「深度图」模式一键完成;`layout_preset` 偏好一并移除。
回放对话框的三颗按钮曾经完全没有文字——`PlaybackDialog.retranslate()` 写好了却从没被 ### 1.1 采集来源随视图,不再是「来源」下拉
调用过。现在它在 `__init__` 末尾调用一次(`test_the_playback_dialog_labels_its_buttons`
守住这一点)。 `ToolbarWidget` 的状态轨上有一个来源槽(`source_slot`):
| 情形 | 轨上显示 |
|---|---|
| 实时监测 + 摄像机 | `摄像机 [编号] [搜索]` |
| 实时监测 + 视频文件 | `使用摄像机` 按钮 + 摘要里的 `视频文件: xxx.mp4` |
| 历史回放 | 不占位(回放条里有录制选择与路径) |
「用视频文件跑一次采集」仍从「文件 → 打开视频…」进入,`source_mode` / `video_path`
偏好保留,但不再有一个需要先选「来源」才能用的下拉。
### 1.2 传感器与标定共用一个弹窗
轨上只有一个摘要按钮(`BOX · box/sensor.json`,`ToolbarWidget.btn_sensor`);
点击打开 `apps/studio/src/orisys_studio/ui/sensor_dialog.py::SensorDialog`:
- 传感器类型 / 传感器配置两个下拉(原 `SensorPickerWidget`,仍是窗口的属性,
因此 `sensor_type()` / `config_name()` 与信号语义不变);
- 「当前生效的标定」:设备确认的版本(`_active_calibration_id`)与已标定轴;
- 「最近导出的系数」:文件、大小、时间与文件身份(类型 / SN / 配置,来自
`calibration_identity()`);导出/应用按钮转调 `CalPanel.export_coefficients()` /
`apply_coefficients()`,实现只有一份。
弹窗常驻一个实例、每次打开 `refresh()`,所以设备刚确认的标定立刻可见。
--- ---
## 2. 关闭流程:窗口关掉后进程必须回到命令行 ## 2. 回放并入主窗口(含进度条)
回放**不再是一个独立窗口**。`PlaybackDialog` 已删除,它被拆成两半:
- `apps/studio/src/orisys_studio/replay.py::ReplayController(QObject)`:录制读取、游标、
深度重算(daemon 线程 + 120 ms 轮询、请求绑定 `(_record_serial, seq, frame)`、旧结果一律丢弃)、
深度色标(按录制锁存,与实时同一规则)、回放提示(降采样 / 跨版本 / 深度状态)。
它不画任何东西,只发信号:`position_changed` / `frames_changed` /
`status_changed` / `recording_changed`。
- `apps/studio/src/orisys_studio/ui/replay_bar.py::ReplayBar`:录制选择、录制信息、
**可拖拽的 QSlider 进度条**、`pos / dur` 读数、单位切换、重算深度、以及第二行的提示。
主窗口把两者接起来:
- 曲线:`ForcePlotsPanel.set_recording_source(reader)` 用 `RecordingCurveSource` 直接读
录制目录(区间按需读盘 + 缓存包络),所以全程总览、拖动、缩放、框选都在同一套曲线上;
表头显示「回放录制(浏览)」,`返回最新` 在录制模式下禁用。
- **回放按录制实际有的单位打开**(`_replay_unit_for`):`FN_N` 等列全空的录制退回 raw,
否则在 newton 偏好下会画出三张空图、每张数值卡一个横杠——看起来就是"回放没加载"。
单位由 `ReplayController.set_unit` 一处决定,回放条、曲线、数值卡与设置区下拉一起改。
- 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像 / 深度图」
(录制里没有光流,不提供箭头),页脚显示「录制视频 → 深度重算」并隐藏放大倍率与有效区域;
深度模式显示按录制锁存的色条与范围。
- 游标是同一个:拖进度条 → 视频帧 + 曲线游标;拖曲线游标 → 回放位置。
- **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时,
三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放
时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。
- **2D 面板归当前视图的来源所有**(`_replay_owns_image()`):回放视图打开录制后,采集线程
仍然在跑,但 `_on_run_tick` / `_on_preview_tick` 不再把实时帧画进 2D 面板,
`_render_run_image` 直接返回。否则回放画面会被摄像头帧一帧一帧盖掉("回放时好像用的是
摄像头")。切回实时视图才把面板交还给实时帧。
- 离开回放视图:曲线挂回实时 store 并恢复跟随,回放条隐藏,**已打开的录制保留**。
- 文件菜单两条入口(`打开录制目录…` / `回放最近一次录制`)都只是切到回放视图并加载录制,
不再弹第二个窗口。
`apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核
进度条与回放布局(不写业务数据,也不需要硬件)。
### 曲线缩放与默认模式
- 三条曲线在**所有**模式下 `mouseEnabled(x=True)`:滚轮缩放、左键平移。手势信号用
pyqtgraph 的 `sigRangeChangedManually`(只有真实滚轮/拖动才会发),不是
`sigXRangeChanged`——后者在窗口缩放沿 X 轴联动传播时也会发,会把"缩放窗口"误判成
"用户开始浏览"。y 轴仍按配置的固定量程。
- 浏览模式下共享光标线可拖动,拖动即移动播放头(回放视图里就是回放位置)。
- 启动、新一次运行、切换单位/传感器、切回「实时监测」都保证回到「跟随实时」;
只有打开录制后的回放视图才处于浏览状态。
### 曲线表头:动作是图标,单位是开关
表头右侧三个动作(`ForcePlotsPanel._header_button`)只画符号、不写字,符号由
`apps/studio/src/orisys_studio/ui/icons.py` 在运行时用当前主题的颜色绘制
(`zoom_fit` 取景框+角标、`box_zoom` 虚线框、`latest` 箭头进入竖条),因此**没有新增色值**,
换主题时按令牌重新着色,也不会出现位图在不同底色上发灰:
| 动作 | 符号 | 文字(tooltip / 无障碍名) |
|---|---|---|
| 全程总览 | `zoom_fit` | `chart.overview` |
| 框选 | `box_zoom` | `chart.box_zoom` |
| 返回最新 | `latest` | `chart.return_latest` |
- **符号不替代语义**:每个按钮都带 tooltip 与 `accessibleName`,`ui_check.py` 的
`chart header and unit chip` 段逐项断言 `text()==""` 且这三样齐全——只有符号没有说明
等于把动作藏起来。全程总览**区域名**(`window_caption`)仍是文字,它标注的是一块区域。
- **单位芯片是可点的按钮**(`QPushButton#unitBadge`,不再是 `QLabel`):点一下就切换
`N` ⇄ `raw`,同一条命令(`_set_display_unit`)同时改曲线、数值卡与设置区下拉,三处永远
一致。旧写法只有「raw/N」两个字,看起来像只读状态。
- 箭头图页脚的放大倍率写成「箭头按位移放大 ×2」,不再只有一个「×2」。
### 单位切换是"开关",不是"重新解释"
曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到
raw;标定一到位就切到 N —— 这一次切换**丢弃**缓冲区里的旧单位样本(会话存储仍然保留
它们,`RecordingCurveSource` / `SessionStore` 可重建),而不是把 raw 计数画到牛顿轴上。
旧写法只把 `_unit` 记成新单位、样本原样留着,于是每次开始采集的第一个画面都是一根
~1e5 的尖峰。触发切换的那一帧本身仍然会刷新数值卡(早期实现里它被 `return` 吃掉了),
而浏览/回放时卡片改为按新单位重读游标处的样本。
同样地,`_start_run` 现在**先** `session_store.reset()` 再 `force_plots.set_store(...)`:
反过来的话新会话开局会带着上一次会话的尾巴。
切换单位后(`set_units`)立即按新单位重读游标处的样本,因为浏览/回放时数值卡说的是游标,
不是实时样本——否则换一下单位,卡片会短暂显示上一次实时读数。
---
## 3. 关闭流程:窗口关掉后进程必须回到命令行
关闭顺序(`StudioMainWindow.closeEvent` → `main()`): 关闭顺序(`StudioMainWindow.closeEvent` → `main()`):
@@ -66,14 +172,14 @@ os._exit(code)
--- ---
## 3. 复核方式 ## 4. 复核方式
```powershell ```powershell
$py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe" $py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe"
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src" $env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src"
cd D:\orisys_ws\orisys-app\apps\studio cd D:\orisys_ws\orisys-app\apps\studio
& $py tools/ui_check.py # 含 top area / instrument panels / clean shutdown & $py tools/ui_check.py # 含 top area / views / instrument panels / replay / clean shutdown
& $py tools/ui_snapshot.py --demo --scene replay --out outputs/_snap_replay.png
& $py tools/ui_snapshot.py --demo --preset calibration --out outputs/_snap_calibration.png & $py tools/ui_snapshot.py --demo --preset calibration --out outputs/_snap_calibration.png
& $py tools/ui_snapshot.py --demo --scene recording --out outputs/_snap_recording.png
``` ```
+23 -17
View File
@@ -178,22 +178,24 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
| 轨 | 高度 | 承载 | | 轨 | 高度 | 承载 |
|---|---|---| |---|---|---|
| 品牌轨(`QFrame#brandBar`) | 28 px | logo + 字标 + 三个顶层视图(实时监测 / 历史回放 / 标定)+ 右侧产品名 | | 品牌轨(`QFrame#brandBar`) | 28 px | logo + 字标 + 三个顶层视图(实时监测 / 历史回放 / 标定)+ 右侧产品名 |
| 状态轨(`ToolbarWidget` 的 rail) | ~30 px | **传感器 / 传感器配置**、阶段、采集来源、标定状态、数据有效性、记录状态、记录/运行/重置/设置 | | 状态轨(`ToolbarWidget` 的 rail) | ~30 px | **传感器摘要按钮**、阶段、**当前视图的来源选择**(摄像机编号 + 搜索 / 使用摄像机)、标定状态、数据有效性、记录状态、记录/运行/重置/设置 |
| 设置区(默认收起) | 0 / ~40 px | 来源、摄像机编号、视频文件、单位、**记录模式** | | 设置区(默认收起) | 0 / ~40 px | 单位、记录模式、预览 |
| 回放条(仅历史回放视图) | ~50 px | 录制选择、录制信息、进度条、位置、单位、重算深度、提示行 |
三点规则: 四点规则:
1. **传感器选择在状态轨最左侧,且两个下拉都带标签**。`box / mini / finger` 是产品 1. **传感器区是一个摘要按钮,不是两个下拉**。轨上只有 `BOX · box/sensor.json`;类型、配置
(传感器)类型,它决定后面可选的配置文件;原来只有一个「配置」下拉,看不出在切什么。 与「当前生效的标定 / 最近导出的系数」都在它打开的 `SensorDialog` 里(见
现在写成「传感器」+「传感器配置」两个带 tooltip 的下拉,产品型号另有一个徽标 `docs/shell-lifecycle.md` §1.2)。摘要按钮属于状态轨,因此文本必须完整显示(见第 5 条)。
(`FINGER` 不得被截断成 `INGE`,所以徽标只有最小宽度)。 2. **来源选择随视图**:实时视图显示摄像机编号 + 搜索;用视频文件采集时显示「使用摄像机」;
2. **导航按钮在品牌底上取品牌前景色**。`navButton` 用 `brand_bar_fg_muted`,当前视图用 回放视图不占位(回放条里有录制选择)。没有「来源」下拉,因此没有"先选来源才能用"的状态。
3. **导航按钮在品牌底上取品牌前景色**。`navButton` 用 `brand_bar_fg_muted`,当前视图用
`brand_bar_fg` + `navActive="true"`;两者都在 §2 的品牌底对比度审计里(7.67 / 12.94), `brand_bar_fg` + `navActive="true"`;两者都在 §2 的品牌底对比度审计里(7.67 / 12.94),
不新增色值。 不新增色值。
3. **状态轨上的语义色一律算在 `toolbar_bg` 上审计**。记录状态(`status_idle` / 4. **状态轨上的语义色一律算在 `toolbar_bg` 上审计**。记录状态(`status_idle` /
`status_running` / `status_connecting` / `status_error`)、丢帧提示(`warning`)与数据 `status_running` / `status_connecting` / `status_error`)、丢帧提示(`warning`)与数据
有效性(`success` / `warning`)此前只检查 `panel_bg`,而它们实际画在顶栏底色上。 有效性(`success` / `warning`)此前只检查 `panel_bg`,而它们实际画在顶栏底色上。
4. **同一时刻只有一个实心按钮**(`ToolbarWidget._refresh_emphasis`): 5. **同一时刻只有一个实心按钮**(`ToolbarWidget._refresh_emphasis`):
| 状态 | 实心 | 描边 | | 状态 | 实心 | 描边 |
|---|---|---| |---|---|---|
@@ -202,18 +204,22 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
| 采集中 | `开始记录`(`primaryButton`) | `停止`(`dangerOutline`) | | 采集中 | `开始记录`(`primaryButton`) | `停止`(`dangerOutline`) |
| 记录中 | `停止记录`(`dangerButton`) | `停止`(`dangerOutline`) | | 记录中 | `停止记录`(`dangerButton`) | `停止`(`dangerOutline`) |
`dangerOutline` 是新增样式:透明底 + `danger` 文字与描边,hover/pressed 才填充 `dangerOutline`:透明底 + `danger` 文字与描边,hover/pressed 才填充 `danger_fill`;
`danger_fill`;`:checked`(即"采集正在跑")仍保持描边——否则一圈红边会在会话开始后 `:checked`(即"采集正在跑")仍保持描边——否则一圈红边会在会话开始后悄悄变成整块红色。
悄悄变成整块红色,那正是这次要修掉的观感。
原反馈里"提高停止按钮优先级"的诉求没有丢:停止始终是红色、始终一键可达,只是不再 6. **状态轨上的事实不得被压缩**(`ui_check.rail_squeezed`)。中文、英文、1280 与 1440
与当前主操作争夺同一时刻的唯一实心位置。
5. **状态轨上的事实不得被压缩**(`ui_check.rail_squeezed`)。中文、英文、1280 与 1440
四种组合下,轨上的按钮与下拉都必须显示完整文本;只有带 tooltip 的 `ShrinkableLabel` 四种组合下,轨上的按钮与下拉都必须显示完整文本;只有带 tooltip 的 `ShrinkableLabel`
允许在窗口极窄时被裁切。这也是"记录模式"下拉被挪进设置区的原因:英文的 允许在窗口极窄时被裁切。这也是"记录模式"下拉被挪进设置区的原因:英文的
`Start recording` + `Sensor config` 与它挤在同一行时,第一个被压缩的就是按钮文字。 `Start recording` + `Sensor config` 与它挤在同一行时,第一个被压缩的就是按钮文字。
7. **曲线表头只有符号,符号的颜色仍来自令牌**。全程总览 / 框选 / 返回最新三个按钮是
纯图标(`QPushButton[iconOnly="true"]`),图标由 `ui/icons.py` 用 `text_secondary`
实时绘制、切主题时按新令牌重画,因此配色审计的结论对它们同样成立,也不需要新增色值。
单位芯片 `QPushButton#unitBadge` 用 `card_bg` 底 + `card_border` 描边 + `text_secondary`
文字,hover 才换成 `text_primary` 文字与 `accent` 描边,它是"可点的状态"而不是实心主
按钮,所以不参与 §6.5 的"同一时刻只有一个实心按钮"计数(`QLabel#unitBadge` 仍是通道
徽标与传感器缩略图的样式,两者靠控件类型区分)。
--- ---
## 7. 三个顶层视图 = 同一窗口的三套布局 ## 7. 三个顶层视图 = 同一窗口的三套布局
+334 -125
View File
@@ -39,6 +39,7 @@ from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import RecordConfig from orisys_app_shared.record import RecordConfig
from orisys_app_shared.record.sample import to_record_sample from orisys_app_shared.record.sample import to_record_sample
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
from orisys_studio.replay import ReplayController
from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path from orisys_app_shared.paths import logo_path, sensor_json_path
@@ -60,6 +61,7 @@ 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
@@ -117,7 +119,6 @@ class StudioMainWindow(QMainWindow):
self._theme = str(self.prefs.get("theme") or "dark") self._theme = str(self.prefs.get("theme") or "dark")
self._language = str(self.prefs.get("language") or "zh_CN") self._language = str(self.prefs.get("language") or "zh_CN")
self._show_cal = bool(self.prefs.get("show_cal_panel", False)) self._show_cal = bool(self.prefs.get("show_cal_panel", False))
self._layout_preset = str(self.prefs.get("layout_preset") or "default")
preset = str(self.prefs.get("view_preset") or "custom") preset = str(self.prefs.get("view_preset") or "custom")
self._view_preset = preset if preset in VIEW_PRESETS else "custom" self._view_preset = preset if preset in VIEW_PRESETS else "custom"
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True)) self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
@@ -141,6 +142,10 @@ class StudioMainWindow(QMainWindow):
self._last_plot_packet_key: tuple[int, float] | None = None self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None self._last_rendered_view_mode: str | None = None
self._run_source_label = "" self._run_source_label = ""
# ---- replay ---------------------------------------------------------
# One engine for the history view: the recording reader, the cursor and
# the asynchronous depth recomputation. It renders nothing itself.
self.replay = ReplayController(self)
# ---- record channel ------------------------------------------------- # ---- record channel -------------------------------------------------
# Fed from the acquisition thread via the worker's sink list, so it does # Fed from the acquisition thread via the worker's sink list, so it does
# not depend on the UI refresh rate or on which panels are visible. # not depend on the UI refresh rate or on which panels are visible.
@@ -158,7 +163,6 @@ class StudioMainWindow(QMainWindow):
) )
self.recorder = self.recording.recorder self.recorder = self.recording.recorder
self._session_counter = 0 self._session_counter = 0
self._playback = None
self._active_calibration_id = "" self._active_calibration_id = ""
self._record_error_count = 0 self._record_error_count = 0
self._dropped_reported = 0 self._dropped_reported = 0
@@ -191,11 +195,13 @@ class StudioMainWindow(QMainWindow):
self.brand_bar.calibration_requested.connect(self._on_calibration_requested) self.brand_bar.calibration_requested.connect(self._on_calibration_requested)
root.addWidget(self.brand_bar) root.addWidget(self.brand_bar)
# The sensor picker leads the status rail: which product (box / mini / # The sensor picker itself lives in the sensor dialog; the window owns it
# finger) and which config the window is configured for decides what # (it is the state every other panel depends on) and the rail shows a
# everything else means, so it is not buried in a collapsed area. # summary button that opens it.
self.sensor_picker = SensorPickerWidget() self.sensor_picker = SensorPickerWidget()
self.toolbar = ToolbarWidget(theme=self._theme, sensor_picker=self.sensor_picker) #: Built on first use so the pickers are parented where they belong.
self.sensor_dialog = None
self.toolbar = ToolbarWidget(theme=self._theme)
root.addWidget(self.toolbar) root.addWidget(self.toolbar)
self.status_chip = self.toolbar.status_chip self.status_chip = self.toolbar.status_chip
@@ -213,6 +219,13 @@ 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
# a timeline about the whole session, and it appears only in the replay
# view. The splitter keeps its four panels and its four stored sizes.
self.replay_bar = ReplayBar()
self.replay_bar.setVisible(False)
root.addWidget(self.replay_bar)
self.setStatusBar(QStatusBar()) self.setStatusBar(QStatusBar())
# The phase/record state lives in the toolbar's rail; the status bar is # The phase/record state lives in the toolbar's rail; the status bar is
# for transient messages plus one permanent line of device identity. # for transient messages plus one permanent line of device identity.
@@ -226,6 +239,7 @@ class StudioMainWindow(QMainWindow):
self._build_menus() self._build_menus()
self._wire_toolbar() self._wire_toolbar()
self._wire_sensor_picker() self._wire_sensor_picker()
self._wire_replay()
self._restore_prefs_into_ui() self._restore_prefs_into_ui()
# Visibility and the nav highlight come from the restored view; the # Visibility and the nav highlight come from the restored view; the
# stored splitter sizes still win at startup (``apply_sizes=False``). # stored splitter sizes still win at startup (``apply_sizes=False``).
@@ -279,8 +293,35 @@ class StudioMainWindow(QMainWindow):
# ------------------------------------------------------- experiment record # ------------------------------------------------------- experiment record
@property @property
def _record_fps(self) -> float: def _record_fps(self) -> float:
"""The device's *measured* rate. A one-second completion counter, so a
burst of buffered frames (or an offline file source) pushes it well above
the configured rate: it is telemetry, not a time base."""
return self.recording.record_fps return self.recording.record_fps
def _video_fps(self) -> float:
"""The rate the recorded video is written at.
For a live camera that is the sensor configuration's own frame rate: the
container's fps must describe the spacing of the pictures, and using the
measured counter made a 30 fps sensor produce a video that declared (and
played at) 48 fps. A file source has no configured rate to honour, so its
measured rate is the honest one.
"""
measured = float(self._record_fps)
if not isinstance(self.toolbar.active_source(), str):
try:
from orisys_app_shared.acquisition.models import load_fixed_mode
mode = load_fixed_mode(self._resolve_config_ref() or "")
if mode.fps > 0:
return float(mode.fps)
except Exception: # noqa: BLE001 - the measured rate is the fallback
logger.debug("Could not read the configured frame rate", exc_info=True)
# Neither a stream that has not yet delivered a second of frames nor a
# config that could not be read may reach the recorder as a rate: a
# container of "0 fps" would thin the video to one picture per second.
return measured if measured > 0.0 else float(RecordConfig().fps)
@property @property
def _active_calibration_id(self) -> str: def _active_calibration_id(self) -> str:
return self.recording.active_calibration return self.recording.active_calibration
@@ -317,7 +358,7 @@ class StudioMainWindow(QMainWindow):
return return
mode = self.toolbar.record_mode() mode = self.toolbar.record_mode()
config = record_config_from_prefs( config = record_config_from_prefs(
self.prefs, mode=mode, fps=float(self._record_fps) self.prefs, mode=mode, fps=float(self._video_fps())
) )
try: try:
directory = self.recording.start( directory = self.recording.start(
@@ -433,7 +474,10 @@ class StudioMainWindow(QMainWindow):
"height": int(getattr(info, "raw_frame", None).shape[0]) "height": int(getattr(info, "raw_frame", None).shape[0])
if getattr(info, "raw_frame", None) is not None if getattr(info, "raw_frame", None) is not None
else None, else None,
"fps": float(self._record_fps), "fps": float(self._video_fps()),
# The device's own measured rate, kept so a mismatch between
# the two can be diagnosed after the fact.
"measured_fps": float(self._record_fps),
}, },
}, },
"versions": { "versions": {
@@ -520,8 +564,8 @@ class StudioMainWindow(QMainWindow):
self.toolbar.search_clicked.connect(self._on_search_cameras) self.toolbar.search_clicked.connect(self._on_search_cameras)
self.toolbar.preview_toggled.connect(self._on_preview_toggled) self.toolbar.preview_toggled.connect(self._on_preview_toggled)
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed) self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
self.toolbar.source_mode_changed.connect(self._on_source_mode_changed) self.toolbar.sensor_clicked.connect(self._open_sensor_dialog)
self.toolbar.browse_video_clicked.connect(self._on_browse_video) self.toolbar.use_camera_clicked.connect(self._on_use_camera)
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)
@@ -532,6 +576,170 @@ class StudioMainWindow(QMainWindow):
self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed) self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed)
self.sensor_picker.config_changed.connect(self._on_config_changed) self.sensor_picker.config_changed.connect(self._on_config_changed)
self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed) self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed)
self.arrow_panel.view_mode_changed.connect(lambda _mode: self._render_replay_frame())
# The unit chip on the chart header switches the same unit everywhere.
self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle)
def _wire_replay(self) -> None:
"""The bar drives the engine; the engine updates the bar and the views."""
self.replay_bar.open_requested.connect(self._on_open_recording)
self.replay_bar.position_requested.connect(self.replay.set_position)
self.replay_bar.unit_toggled.connect(self._on_replay_unit_toggled)
self.replay_bar.depth_requested.connect(self.replay.request_depth)
self.replay.position_changed.connect(self._on_replay_position)
self.replay.frames_changed.connect(self._render_replay_frame)
self.replay.status_changed.connect(self._refresh_replay_state)
self.replay.recording_changed.connect(self._on_recording_changed)
# Dragging the shared cursor on the charts scrubs the recording too.
self.force_plots.cursor_moved.connect(self._on_cursor_moved)
def _on_cursor_moved(self, t_rel_s: float) -> None:
if self._view_preset == "replay" and self.replay.is_loaded():
self.replay.set_position(float(t_rel_s))
def _on_open_recording(self) -> None:
"""Choose a recording and switch to the view that replays it."""
from orisys_app_shared.record import list_recordings
chosen = QFileDialog.getExistingDirectory(
self, t("playback.open"), str(recordings_dir())
)
if not chosen:
return
self._load_recording(Path(chosen))
def _load_recording(self, directory: Path) -> bool:
if self._view_preset != "replay":
self._apply_view_preset("replay")
opened = self.replay.open_recording(Path(directory))
if not opened:
self.statusBar().showMessage(
t("playback.open_failed", error=Path(directory).name)
)
return False
self._refresh_replay_state()
return True
def _open_latest_recording(self) -> None:
"""Switch to the replay view on the most recent recording."""
from orisys_app_shared.record import list_recordings
found = list_recordings(recordings_dir())
if not found:
self.statusBar().showMessage(t("playback.none_found"))
return
self._load_recording(Path(found[-1]))
def _on_recording_changed(self, opened: bool) -> None:
"""A recording was opened (or dropped): re-point everything that reads it."""
self._refresh_replay_state()
if not opened:
return
if self._view_preset == "replay":
self._apply_replay_source(True)
self._render_replay_frame()
def _refresh_replay_state(self) -> None:
"""Push the engine's state into the bar, the charts' mode and the rail."""
loaded = self.replay.is_loaded()
duration = self.replay.duration_s() if loaded else 0.0
self.replay_bar.set_loaded(loaded, duration)
if loaded:
info = self.replay.info()
name = self.replay.directory().name if self.replay.directory() else ""
self.replay_bar.set_info(
t(
"playback.loaded_short",
name=name,
frames=len(info.frames) if info is not None else 0,
duration=f"{duration:.1f}",
)
)
self.replay_bar.set_position(self.replay.position(), duration)
self.replay_bar.set_warnings(self.replay.warnings_text())
self.replay_bar.set_unit(self.replay.unit())
self._refresh_status_rail()
def _on_replay_position(self, position_s: float) -> None:
self.replay_bar.set_position(position_s, self.replay.duration_s())
self.force_plots.set_cursor(float(position_s))
def _on_replay_unit_toggled(self) -> None:
"""The replay's unit switch drives the same unit the charts display."""
unit = self.replay.toggle_unit()
self.replay_bar.set_unit(unit)
self._set_display_unit(unit)
def _render_replay_frame(self) -> None:
"""Draw what the 2D panel should show for the current replay cursor."""
if self._view_preset != "replay" or not self.replay.is_loaded():
return
if self.arrow_panel.view_mode() == "depth":
self.arrow_panel.set_depth_scale(self._replay_depth_scale_text())
image = self.replay.depth_image()
if image is None:
self.arrow_panel.clear_preview(self.replay.depth_message())
else:
self.arrow_panel.show_frame(image)
return
self.arrow_panel.set_depth_scale(None)
image = self.replay.video_image()
if image is None:
self.arrow_panel.clear_preview(self.replay.video_message())
else:
self.arrow_panel.show_frame(image)
def _replay_depth_scale_text(self) -> str:
"""The depth colour scale, latched per recording by the controller."""
span = self.replay.depth_range()
if span is None:
return ""
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
def _apply_replay_source(self, active: bool) -> None:
"""Point the charts at the recording (or back at the live session)."""
if active and self.replay.is_loaded():
reader = self.replay.reader()
self.force_plots.set_recording_source(reader)
# A recording is shown in a unit it actually carries: newton only when
# it has a calibrated axis somewhere, otherwise the charts would be
# empty and every card a dash. The operator's preference is kept when
# the recording can honour it.
wanted = "N" if self.force_plots.unit() == "N" else "raw"
unit = self._replay_unit_for(reader)
self.replay_bar.set_unit(self.replay.set_unit(unit))
self._set_display_unit(unit)
if unit != wanted:
self.statusBar().showMessage(t("status.units_raw_fallback"))
self.force_plots.show_all()
self.arrow_panel.set_source_kind("replay")
return
if self.force_plots.is_recording_source():
self.force_plots.clear_recording_source()
self.force_plots.follow_latest()
self.arrow_panel.set_source_kind("live")
# The panel held a recorded frame: the next live packet must repaint it,
# so the "already rendered" marker is dropped.
self._last_rendered_view_mode = None
if not self._running and not self._preview_open:
self.arrow_panel.clear_preview(t("panels.arrow_empty"))
def _replay_unit_for(self, reader) -> str:
"""The unit a recording can be shown in: raw unless it has newtons.
``raw`` is always present (it is what the device measured); the calibrated
triplet only exists for the axes the recording actually calibrated. Asking
for newton on a raw-only recording draws three empty charts, so the same
fallback the live view uses applies here.
"""
if self.force_plots.unit() != "N":
return "raw"
info = reader.info()
newtons = getattr(info, "newtons", None)
if newtons is None:
return "raw"
values = np.asarray(newtons, dtype=float)
return "N" if values.size and bool(np.isfinite(values).any()) else "raw"
def _restore_prefs_into_ui(self) -> None: def _restore_prefs_into_ui(self) -> None:
raw_id = str(self.prefs.get("camera_id") or "0").strip() raw_id = str(self.prefs.get("camera_id") or "0").strip()
@@ -598,7 +806,7 @@ class StudioMainWindow(QMainWindow):
self.file_menu.addAction(self.act_reveal_config) self.file_menu.addAction(self.act_reveal_config)
self.file_menu.addSeparator() self.file_menu.addSeparator()
self.act_playback = QAction(self) self.act_playback = QAction(self)
self.act_playback.triggered.connect(self._open_playback) self.act_playback.triggered.connect(self._on_open_recording)
self.file_menu.addAction(self.act_playback) self.file_menu.addAction(self.act_playback)
self.act_playback_latest = QAction(self) self.act_playback_latest = QAction(self)
self.act_playback_latest.triggered.connect(self._open_latest_recording) self.act_playback_latest.triggered.connect(self._open_latest_recording)
@@ -611,23 +819,6 @@ class StudioMainWindow(QMainWindow):
self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled) self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled)
self.view_menu.addAction(self.act_cal_panel) self.view_menu.addAction(self.act_cal_panel)
self.layout_group = QActionGroup(self)
self.layout_group.setExclusive(True)
self.act_layout_default = QAction(self)
self.act_layout_default.setCheckable(True)
self.act_layout_default.setChecked(self._layout_preset != "2d")
self.act_layout_default.triggered.connect(
lambda: self._on_layout_preset("default")
)
self.act_layout_2d = QAction(self)
self.act_layout_2d.setCheckable(True)
self.act_layout_2d.setChecked(self._layout_preset == "2d")
self.act_layout_2d.triggered.connect(lambda: self._on_layout_preset("2d"))
self.layout_group.addAction(self.act_layout_default)
self.layout_group.addAction(self.act_layout_2d)
self.view_menu.addAction(self.act_layout_default)
self.view_menu.addAction(self.act_layout_2d)
self.act_finger_3d = QAction(self) self.act_finger_3d = QAction(self)
self.act_finger_3d.setCheckable(True) self.act_finger_3d.setCheckable(True)
self.act_finger_3d.setChecked(self._show_finger_3d) self.act_finger_3d.setChecked(self._show_finger_3d)
@@ -720,34 +911,23 @@ class StudioMainWindow(QMainWindow):
self.act_cal_panel.setChecked(self._show_cal) self.act_cal_panel.setChecked(self._show_cal)
self.act_cal_panel.blockSignals(False) self.act_cal_panel.blockSignals(False)
is_finger = self.sensor_picker.sensor_type() == "finger" is_finger = self.sensor_picker.sensor_type() == "finger"
self.act_finger_3d.setEnabled(is_finger and self._layout_preset != "2d") self.act_finger_3d.setEnabled(is_finger)
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_layout_default.blockSignals(True)
self.act_layout_default.setChecked(self._layout_preset != "2d")
self.act_layout_default.blockSignals(False)
self.act_layout_2d.blockSignals(True)
self.act_layout_2d.setChecked(self._layout_preset == "2d")
self.act_layout_2d.blockSignals(False)
self._apply_arrow_rotation_for_sensor() self._apply_arrow_rotation_for_sensor()
def _apply_panel_visibility(self) -> None: def _apply_panel_visibility(self) -> None:
"""One place decides which panels the current view shows. """One place decides which panels the current view shows.
The view preset owns the 2D panel and the calibration panel; the 3D The view preset owns the 2D panel and the calibration panel; the 3D
panel additionally needs a finger sensor, its own toggle and a layout panel additionally needs a finger sensor and its own toggle. ``_show_cal``
preset that has not folded it away. ``_show_cal`` is kept in step so the is kept in step so the View menu, the preset and the panels cannot
View menu, the preset and the panels cannot disagree. disagree.
""" """
is_finger = self.sensor_picker.sensor_type() == "finger" is_finger = self.sensor_picker.sensor_type() == "finger"
preset = self._view_preset preset = self._view_preset
show_3d = ( show_3d = preset in VIEW_SHOWS_3D and is_finger and bool(self._show_finger_3d)
preset in VIEW_SHOWS_3D
and is_finger
and bool(self._show_finger_3d)
and self._layout_preset != "2d"
)
show_arrow = preset != "calibration" show_arrow = preset != "calibration"
show_cal = self._show_cal if preset == "custom" else preset == "calibration" show_cal = self._show_cal if preset == "custom" else preset == "calibration"
self._show_cal = show_cal self._show_cal = show_cal
@@ -759,17 +939,17 @@ class StudioMainWindow(QMainWindow):
self.arrow_panel.setMinimumWidth(MIN_PANEL_WIDTHS["arrow"]) self.arrow_panel.setMinimumWidth(MIN_PANEL_WIDTHS["arrow"])
self.force_plots.setMinimumWidth(MIN_PANEL_WIDTHS["plots"]) self.force_plots.setMinimumWidth(MIN_PANEL_WIDTHS["plots"])
self.cal_panel.setMinimumWidth(MIN_PANEL_WIDTHS["cal"]) self.cal_panel.setMinimumWidth(MIN_PANEL_WIDTHS["cal"])
self.act_finger_3d.setEnabled(is_finger and self._layout_preset != "2d") self.act_finger_3d.setEnabled(is_finger)
self.act_finger_3d.blockSignals(True) self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(show_3d) self.act_finger_3d.setChecked(show_3d)
self.act_finger_3d.blockSignals(False) self.act_finger_3d.blockSignals(False)
self.act_cal_panel.blockSignals(True) self.act_cal_panel.blockSignals(True)
self.act_cal_panel.setChecked(show_cal) self.act_cal_panel.setChecked(show_cal)
self.act_cal_panel.blockSignals(False) self.act_cal_panel.blockSignals(False)
# The 2D preset is the reviewed layout: depth map and force charts side # Panel weights: the charts are the widest panel in every view, and the
# by side, sharing one time axis, with calibration folded away. # 2D panel is deliberately the smaller one.
arrow_weight = 3 if self._layout_preset != "2d" else 5 arrow_weight = 3
plots_weight = 4 if self._layout_preset != "2d" else 6 plots_weight = 4
for i in range(self.content_splitter.count()): for i in range(self.content_splitter.count()):
widget = self.content_splitter.widget(i) widget = self.content_splitter.widget(i)
if widget is self.mesh3d_panel: if widget is self.mesh3d_panel:
@@ -800,39 +980,30 @@ 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,
# nothing while replaying (the replay bar owns the recording picker).
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: open on the whole # History browsing is the point of this view: the charts read the
# session so the overview strip is immediately draggable. # open recording, and the bar scrubs it. Without a recording the
self.force_plots.show_all() # charts stay on the live session and follow it.
self._apply_replay_source(True)
self._refresh_replay_state()
elif preset == "live": elif preset == "live":
self.force_plots.follow_latest() self._apply_replay_source(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()
if persist: if persist:
self._persist_prefs() self._persist_prefs()
def view_preset(self) -> str: def view_preset(self) -> str:
return self._view_preset return self._view_preset
def _on_layout_preset(self, preset: str) -> None:
"""Switch between the free layout and the reviewed 2D preset."""
self._layout_preset = "2d" if preset == "2d" else "default"
self.prefs["layout_preset"] = self._layout_preset
if self._layout_preset == "2d":
# Depth map on the left, force charts on the right, one timeline.
self.arrow_panel.set_view_mode("depth")
self._show_cal = False
self.prefs["show_cal_panel"] = False
self._apply_panel_visibility()
self._sync_menu_checks()
self._persist_prefs()
self.statusBar().showMessage(
t("layout.preset_2d" if self._layout_preset == "2d" else "layout.preset_default")
)
def _update_run_enabled(self) -> None: def _update_run_enabled(self) -> None:
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
source_ok = True source_ok = True
@@ -860,8 +1031,6 @@ class StudioMainWindow(QMainWindow):
self.act_playback_latest.setText(t("menu.playback_latest")) self.act_playback_latest.setText(t("menu.playback_latest"))
self.view_menu.setTitle(t("menu.view")) self.view_menu.setTitle(t("menu.view"))
self.act_cal_panel.setText(t("menu.calibration_panel")) self.act_cal_panel.setText(t("menu.calibration_panel"))
self.act_layout_default.setText(t("layout.default"))
self.act_layout_2d.setText(t("layout.preset_2d_name"))
self.act_finger_3d.setText(t("menu.finger_3d")) self.act_finger_3d.setText(t("menu.finger_3d"))
self.act_show_fps.setText(t("menu.show_fps")) self.act_show_fps.setText(t("menu.show_fps"))
self.act_settings.setText(t("menu.settings")) self.act_settings.setText(t("menu.settings"))
@@ -915,18 +1084,30 @@ class StudioMainWindow(QMainWindow):
# ------------------------------------------------------ status rail state # ------------------------------------------------------ status rail state
def _refresh_status_rail(self) -> None: def _refresh_status_rail(self) -> None:
"""Phase, device, calibration, validity, FPS and device identity. """Sensor, source detail, calibration, validity, FPS and device identity.
The rail is the single place these facts are written; the bottom status The rail is the single place these facts are written; the bottom status
bar keeps only transient messages. bar keeps only transient messages.
""" """
config = self.sensor_picker.config_name()
self.toolbar.set_sensor_summary(
t(
"sensor.summary",
type=self.sensor_picker.sensor_type().upper(),
config=Path(config).name if config else t("status.config_none"),
)
)
# The picker on the rail already names the camera, so the summary carries
# only what it cannot: the video file in use, and the frame rate.
if self.toolbar.source_mode() == "video": if self.toolbar.source_mode() == "video":
summary = t("status.rail_video") path = self.toolbar.video_path()
detail = t("status.rail_video_name", name=Path(path).name if path else "?")
else: else:
summary = t("status.rail_camera", id=self.toolbar.camera_id()) detail = ""
if self._show_fps and self._latest_fps is not None: if self._show_fps and self._latest_fps is not None:
summary = f"{summary} · {self._latest_fps:.1f} FPS" fps = f"{self._latest_fps:.1f} FPS"
self.toolbar.set_sensor_summary(summary) detail = f"{detail} · {fps}" if detail else fps
self.toolbar.set_source_summary(detail)
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("")
@@ -1085,54 +1266,60 @@ class StudioMainWindow(QMainWindow):
def _show_about(self) -> None: def _show_about(self) -> None:
AboutDialog(self).exec_() AboutDialog(self).exec_()
def _open_playback(self) -> None:
"""Replay a recording: video, depth (recomputed) and curves, one cursor."""
from orisys_studio.ui.playback_dialog import PlaybackDialog
if self._playback is not None:
self._playback.close()
self._playback = None
dialog = PlaybackDialog(theme=self._theme, parent=self)
# The replay cursor and the live charts share one time position.
dialog.cursor_moved.connect(self._on_playback_cursor)
self._playback = dialog
dialog.show()
def _on_playback_cursor(self, t_rel_s: float) -> None:
self.force_plots.set_cursor(float(t_rel_s))
def _open_latest_recording(self) -> None:
from orisys_app_shared.record import list_recordings
found = list_recordings(recordings_dir())
if not found:
self.statusBar().showMessage(t("playback.none_found"))
return
from orisys_studio.ui.playback_dialog import PlaybackDialog
dialog = PlaybackDialog(theme=self._theme, directory=found[0], parent=self)
dialog.cursor_moved.connect(self._on_playback_cursor)
self._playback = dialog
dialog.show()
def _on_camera_id_changed(self, value: int) -> None: def _on_camera_id_changed(self, value: int) -> None:
self.prefs["camera_id"] = str(int(value)) self.prefs["camera_id"] = str(int(value))
self._persist_prefs() self._persist_prefs()
def _on_source_mode_changed(self, mode: str) -> None: def _set_source_mode(self, mode: str) -> None:
self.prefs["source_mode"] = mode """Camera or a video file as the acquisition source (file menu only)."""
self.toolbar.set_source_mode(mode)
self.prefs["source_mode"] = self.toolbar.source_mode()
self._persist_prefs() self._persist_prefs()
self._update_run_enabled() self._update_run_enabled()
self._refresh_status_rail()
def _on_use_camera(self) -> None:
self.toolbar.set_video_path("")
self.prefs["video_path"] = ""
self._set_source_mode("camera")
def _open_sensor_dialog(self) -> None:
"""Show the sensor + calibration dialog (one instance, reused)."""
from orisys_studio.ui.sensor_dialog import SensorDialog
if self.sensor_dialog is None:
self.sensor_dialog = SensorDialog(
window=self, picker=self.sensor_picker, theme=self._theme
)
self.sensor_dialog.retranslate()
self.sensor_dialog.show()
self.sensor_dialog.raise_()
self.sensor_dialog.activateWindow()
def _on_units_changed(self, units: str) -> None: def _on_units_changed(self, units: str) -> None:
self.prefs["units"] = units self._set_display_unit(
self.force_plots.set_fixed_newton(str(units) == "newton") "N" if str(units) == "newton" else "raw", sync_toolbar=False
# Rebuild the visible curves from the whole-session store, which keeps
# both triplets, instead of appending new-unit values onto old ones.
self.force_plots.set_units(
"N" if str(units) == "newton" else "raw",
store=self.session_store,
) )
def _on_chart_unit_toggle(self) -> None:
"""The chart's own unit chip: newton <-> raw."""
self._set_display_unit("raw" if self.force_plots.unit() == "N" else "N")
def _set_display_unit(self, unit: str, *, sync_toolbar: bool = True) -> None:
"""One display unit for the toolbar, the charts and the value cards.
The toolbar combo can already *be* on the requested unit (for instance
after a replay's unit switch), in which case its signal never fires — so
the charts are told directly instead of relying on that signal.
"""
if sync_toolbar:
self.toolbar.set_units("newton" if unit == "N" else "raw")
self.prefs["units"] = self.toolbar.units()
self.force_plots.set_fixed_newton(unit == "N")
# Rebuild the visible curves from the attached source (the session store
# or the recording being replayed), which keeps both triplets, instead of
# appending new-unit values onto old ones.
self.force_plots.set_units(unit)
self._persist_prefs() self._persist_prefs()
def _on_image_view_mode_changed(self, mode: str) -> None: def _on_image_view_mode_changed(self, mode: str) -> None:
@@ -1384,6 +1571,9 @@ class StudioMainWindow(QMainWindow):
self._apply_panel_visibility() self._apply_panel_visibility()
self._apply_arrow_rotation_for_sensor() self._apply_arrow_rotation_for_sensor()
self._update_run_enabled() self._update_run_enabled()
# The rail's sensor summary and the sensor dialog both state the product
# and its config, so both follow a change.
self._refresh_status_rail()
self._persist_prefs() self._persist_prefs()
def _apply_arrow_rotation_for_sensor(self) -> None: def _apply_arrow_rotation_for_sensor(self) -> None:
@@ -1449,9 +1639,13 @@ class StudioMainWindow(QMainWindow):
def _on_config_changed(self, config_name: str) -> None: def _on_config_changed(self, config_name: str) -> None:
self.prefs["config_name"] = str(config_name) self.prefs["config_name"] = str(config_name)
# The config file is part of the rail's sensor summary and of the sensor
# dialog's calibration context.
self._refresh_status_rail()
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)."""
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())
@@ -1463,12 +1657,9 @@ class StudioMainWindow(QMainWindow):
) )
if not path: if not path:
return return
self.toolbar.set_source_mode("video")
self.toolbar.set_video_path(path) self.toolbar.set_video_path(path)
self.prefs["source_mode"] = "video"
self.prefs["video_path"] = path self.prefs["video_path"] = path
self._persist_prefs() self._set_source_mode("video")
self._update_run_enabled()
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()
@@ -1557,7 +1748,7 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("status.preview_stopped")) self.statusBar().showMessage(t("status.preview_stopped"))
def _on_preview_tick(self) -> None: def _on_preview_tick(self) -> None:
if self._preview is None: if self._preview is None or self._replay_owns_image():
return return
frame = self._preview.read() frame = self._preview.read()
if frame is None: if frame is None:
@@ -1620,13 +1811,19 @@ class StudioMainWindow(QMainWindow):
self._latest_fps = None self._latest_fps = None
self._depth_range_value = None self._depth_range_value = None
self._last_depth_frame = None self._last_depth_frame = None
self.force_plots.set_store(self.session_store) # The new session's store is cleared *before* the charts are attached to
# A new session invalidates the previous recording. # it: attaching first rebuilt the window from the previous session's
self._stop_recording(wait=True) # samples, so every run started with the old curve's tail on screen.
self._session_counter += 1 self._session_counter += 1
self.session_store.reset( self.session_store.reset(
session_id=self._session_counter, calibration_id="" session_id=self._session_counter, calibration_id=""
) )
self.force_plots.set_store(self.session_store)
# A new session is a live session: whichever view is on screen, the
# charts follow it from the first sample.
self.force_plots.follow_latest()
# A new session invalidates the previous recording.
self._stop_recording(wait=True)
self._ensure_workers() self._ensure_workers()
self._active_worker = ( self._active_worker = (
self._finger_worker if sensor_type == "finger" else self._box_worker self._finger_worker if sensor_type == "finger" else self._box_worker
@@ -1842,7 +2039,9 @@ class StudioMainWindow(QMainWindow):
self._report_dropped_samples() self._report_dropped_samples()
mode = self.arrow_panel.view_mode() mode = self.arrow_panel.view_mode()
if is_new or mode != self._last_rendered_view_mode: if (is_new or mode != self._last_rendered_view_mode) and not (
self._replay_owns_image()
):
self._render_run_image(packet) self._render_run_image(packet)
if is_new: if is_new:
self._latest_fps = getattr(packet, "fps", None) self._latest_fps = getattr(packet, "fps", None)
@@ -1918,8 +2117,18 @@ class StudioMainWindow(QMainWindow):
return "" return ""
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}") return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
def _replay_owns_image(self) -> bool:
"""True while the 2D panel shows the replay, not the live camera.
Without this the live renderer overwrote the replay's frame on every
packet *and* on every preview tick, so a replay running next to an active
session (or a preview) looked as if it had opened the camera instead of
the recording.
"""
return self._view_preset == "replay" and self.replay.is_loaded()
def _render_run_image(self, packet) -> None: def _render_run_image(self, packet) -> None:
if packet is None: if packet is None or self._replay_owns_image():
return return
from orisys_app_shared.acquisition.arrows import ( from orisys_app_shared.acquisition.arrows import (
arrow_display_scale, arrow_display_scale,
@@ -1,11 +1,14 @@
"""Synchronised replay of a recording: video, depth and force curves, one cursor. """Replay engine: one recording, one cursor, and the depth it can recompute.
This is the headless half of the old replay window. It owns the recording
reader, the cursor and the asynchronous depth recomputation, and it *shows*
nothing: the main window renders the frames into the 2D panel, the curves read
the recording through :class:`~orisys_app_shared.force.source.RecordingCurveSource`
and the progress bar drives :meth:`set_position`.
Depth is recomputed on demand rather than read from disk (see Depth is recomputed on demand rather than read from disk (see
:mod:`orisys_app_shared.record.depth`), so the depth pane states plainly when it :mod:`orisys_app_shared.record.depth`), so the pane states plainly when it is
is unavailable instead of showing a stand-in image. unavailable instead of showing a stand-in image.
The cursor is the single source of truth for the position: moving it updates the
video frame, the depth pane and the charts' cursor line together.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,52 +18,39 @@ import threading
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5.QtCore import QObject, QTimer, pyqtSignal
from PyQt5.QtGui import QImage, QPixmap from PyQt5.QtGui import QImage
from PyQt5.QtWidgets import (
QDialog,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QSplitter,
QVBoxLayout,
)
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import ( from orisys_app_shared.record import (
DepthRecomputer, DepthRecomputer,
DepthResult, DepthResult,
RecordingReader, RecordingReader,
list_recordings,
) )
from orisys_app_shared.record.playback import FORCE_COLUMNS
from orisys_app_shared.theme import color
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
raise ImportError("pyqtgraph is required for replay") from exc
CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
SLIDER_STEPS = 2000
#: Upper bound on the points handed to the replay curves.
CHART_MAX_POINTS = 4000
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PlaybackDialog(QDialog): def _bgr_to_qimage(frame: np.ndarray) -> QImage:
"""Replay one experiment directory.""" rgb = frame[:, :, ::-1].copy()
height, width, channels = rgb.shape
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy()
#: Emitted when the replay cursor moves, so the main charts can follow it.
cursor_moved = pyqtSignal(float)
def __init__(self, *, theme: str = "dark", directory: Path | None = None, parent=None) -> None: class ReplayController(QObject):
"""Open one recording and answer "what is at t?" for every view."""
#: The cursor moved; the progress bar and the charts' cursor follow.
position_changed = pyqtSignal(float)
#: The frame under the cursor changed (video and/or depth).
frames_changed = pyqtSignal()
#: Warnings, depth state or the loaded recording changed.
status_changed = pyqtSignal()
#: A recording was opened (True) or the previous one was dropped (False).
recording_changed = pyqtSignal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self._theme = theme
self._reader: RecordingReader | None = None self._reader: RecordingReader | None = None
self._recomputer: DepthRecomputer | None = None self._recomputer: DepthRecomputer | None = None
self._unit = "raw" self._unit = "raw"
@@ -87,100 +77,43 @@ class PlaybackDialog(QDialog):
self._depth_result_frame = -1 self._depth_result_frame = -1
#: Processors whose owning request was still running when it was retired. #: Processors whose owning request was still running when it was retired.
self._retired_recomputers: list = [] self._retired_recomputers: list = []
#: The frame cache: one video image per cursor position.
self._video_position: float | None = None
self._video_qimage: QImage | None = None
self._video_reason = ""
self._depth_qimage: QImage | None = None
self._depth_message = ""
self._warnings = ""
#: The depth colour scale, latched to the first recomputed frame of the
#: recording so two frames stay comparable (same rule as the live view).
self._depth_range: tuple[float, float] | None = None
self._depth_poll = QTimer(self) self._depth_poll = QTimer(self)
self._depth_poll.setInterval(120) self._depth_poll.setInterval(120)
self._depth_poll.timeout.connect(self._poll_depth) self._depth_poll.timeout.connect(self._poll_depth)
self.setWindowTitle(t("playback.title")) # ------------------------------------------------------------- recording
self.resize(1080, 640) def is_loaded(self) -> bool:
root = QVBoxLayout(self) return self._reader is not None
root.setSpacing(8)
controls = QHBoxLayout() def directory(self) -> Path | None:
self.open_button = QPushButton() return self._reader.directory if self._reader is not None else None
self.open_button.clicked.connect(self._on_open)
controls.addWidget(self.open_button)
self.path_label = QLabel()
self.path_label.setObjectName("hintText")
controls.addWidget(self.path_label, 1)
self.unit_button = QPushButton()
self.unit_button.setProperty("compact", "true")
self.unit_button.clicked.connect(self._on_toggle_unit)
controls.addWidget(self.unit_button)
self.depth_button = QPushButton()
self.depth_button.setProperty("compact", "true")
self.depth_button.clicked.connect(self._on_recompute_depth)
controls.addWidget(self.depth_button)
root.addLayout(controls)
self.warning_label = QLabel() def reader(self):
self.warning_label.setObjectName("hintText") return self._reader
self.warning_label.setWordWrap(True)
root.addWidget(self.warning_label)
splitter = QSplitter(Qt.Horizontal) def info(self):
self.video_label = QLabel() return self._reader.info() if self._reader is not None else None
self.video_label.setAlignment(Qt.AlignCenter)
self.video_label.setMinimumSize(280, 200)
self.video_label.setWordWrap(True)
splitter.addWidget(self.video_label)
self.depth_label = QLabel()
self.depth_label.setAlignment(Qt.AlignCenter)
self.depth_label.setMinimumSize(200, 160)
self.depth_label.setWordWrap(True)
splitter.addWidget(self.depth_label)
splitter.setSizes([420, 320])
root.addWidget(splitter, 1)
self.plot_widget = pg.GraphicsLayoutWidget() def source(self):
self.plot_widget.setBackground(color(theme, "plot_bg")) """The curve source for the charts, or None when nothing is open."""
self.plot_widget.setMinimumHeight(180) if self._reader is None:
root.addWidget(self.plot_widget, 1) return None
self.curves = [] from orisys_app_shared.force.source import RecordingCurveSource
self.cursor_lines = []
for row, token in enumerate(CHANNEL_TOKENS):
plot = self.plot_widget.addPlot(row=row, col=0)
plot.showGrid(x=True, y=True, alpha=0.25)
plot.setMouseEnabled(x=False, y=False)
pen = pg.mkPen(color(theme, "text_secondary"))
for axis_name in ("left", "bottom"):
plot.getAxis(axis_name).setPen(pen)
plot.getAxis(axis_name).setTextPen(pen)
plot.setLabel("left", ("FN", "FX", "FY")[row], color=color(theme, token))
self.curves.append(plot.plot(pen=pg.mkPen(color(theme, token), width=2)))
line = pg.InfiniteLine(pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888"))
plot.addItem(line, ignoreBounds=True)
self.cursor_lines.append(line)
position_row = QHBoxLayout() return RecordingCurveSource(self._reader)
self.slider = QSlider(Qt.Horizontal)
self.slider.setRange(0, SLIDER_STEPS)
self.slider.setEnabled(False)
self.slider.valueChanged.connect(self._on_slider)
position_row.addWidget(self.slider, 1)
self.position_label = QLabel()
self.position_label.setObjectName("panelMeta")
position_row.addWidget(self.position_label)
root.addLayout(position_row)
if directory is not None: def open_recording(self, directory: Path) -> bool:
self.open_recording(Path(directory))
# Labels last: without this the three buttons stayed empty, because the
# dialog never retranslated after being built.
self.retranslate()
# ------------------------------------------------------------------ opening
def _on_open(self) -> None:
start = str(recordings_dir())
chosen = QFileDialog.getExistingDirectory(self, t("playback.open"), start)
if not chosen:
return
try:
self.open_recording(Path(chosen))
except Exception as exc: # noqa: BLE001
self.warning_label.setText(t("playback.open_failed", error=str(exc)))
def open_recording(self, directory: Path) -> None:
"""Open a recording. Any depth answer still in flight is invalidated.""" """Open a recording. Any depth answer still in flight is invalidated."""
# A running request belongs to the *previous* recording: its result must # A running request belongs to the *previous* recording: its result must
# never be pasted into the new one, and its processor must be closed by # never be pasted into the new one, and its processor must be closed by
@@ -191,24 +124,53 @@ class PlaybackDialog(QDialog):
self._depth_pending_seq = None self._depth_pending_seq = None
self._depth_pending_position = None self._depth_pending_position = None
self._depth_error = None self._depth_error = None
self._reader = RecordingReader(directory) try:
self._reader = RecordingReader(Path(directory))
info = self._reader.info()
except Exception: # noqa: BLE001 - reported through the status text
logger.exception("Could not open recording %s", directory)
self._reader = None
self._recomputer = None
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
return False
self._recomputer = DepthRecomputer(self._reader) self._recomputer = DepthRecomputer(self._reader)
info = self._reader.info()
self.path_label.setText(
t(
"playback.loaded",
name=directory.name,
frames=len(info.frames),
duration=f"{info.duration_s:.1f}",
)
)
self.slider.setEnabled(info.duration_s > 0)
self._render_curves()
self._refresh_warnings()
self._depth_ok = False self._depth_ok = False
self._depth_result = None self._depth_result = None
self._depth_result_seq = None self._depth_result_seq = None
self._depth_range = None
self._reset_frames()
self._position = 0.0
_ = info
self.recording_changed.emit(True)
self._refresh_warnings()
self._refresh_depth_pane()
self.set_position(0.0) self.set_position(0.0)
return True
def close(self) -> None:
"""Drop the recording and hand every processor to its owning thread."""
self._depth_poll.stop()
for recomputer in list(self._retired_recomputers):
if not self._depth_busy():
self._retired_recomputers.remove(recomputer)
try:
recomputer.close()
except Exception: # noqa: BLE001
logger.exception("Could not close a retired recomputer")
self._retire_recomputer()
self._reader = None
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
def _reset_frames(self) -> None:
self._video_position = None
self._video_qimage = None
self._video_reason = ""
self._depth_qimage = None
self._depth_message = ""
def _retire_recomputer(self) -> None: def _retire_recomputer(self) -> None:
"""Hand the current processor to its worker, or close it here if idle. """Hand the current processor to its worker, or close it here if idle.
@@ -228,7 +190,88 @@ class PlaybackDialog(QDialog):
except Exception: # noqa: BLE001 - closing is best effort except Exception: # noqa: BLE001 - closing is best effort
logger.exception("Could not close the depth recomputer") logger.exception("Could not close the depth recomputer")
# ---------------------------------------------------------------- cursor
def position(self) -> float:
return self._position
def duration_s(self) -> float:
if self._reader is None:
return 0.0
return float(self._reader.info().duration_s)
def set_position(self, t_rel_s: float) -> None:
"""Move the single cursor: video, depth and charts follow it."""
if self._reader is None:
return
duration = self.duration_s()
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
self._refresh_video()
if self._depth_ok or self._depth_result is not None:
# The held depth belongs to the frame it was computed for. Moving the
# cursor must not keep showing it as if it were synchronised.
self._refresh_depth_pane()
self.position_changed.emit(self._position)
self.frames_changed.emit()
def unit(self) -> str:
return self._unit
def set_unit(self, unit: str) -> str:
"""Show the recording in ``unit``; the bar and the charts share one.
Opening a recording that carries no calibrated axis in newton mode would
draw three empty charts, so the caller picks the unit from what the
recording actually has and tells both views about it here.
"""
self._unit = "N" if str(unit).upper().startswith("N") else "raw"
return self._unit
def toggle_unit(self) -> str:
self._unit = "N" if self._unit == "raw" else "raw"
self.status_changed.emit()
return self._unit
# ----------------------------------------------------------------- frames
def video_image(self) -> QImage | None:
self._refresh_video()
return self._video_qimage
def video_message(self) -> str:
self._refresh_video()
return self._video_reason
def _refresh_video(self) -> None:
if self._reader is None:
self._video_position = None
self._video_qimage = None
self._video_reason = t("playback.empty")
return
if self._video_position == self._position:
return
self._video_position = self._position
image, frame = self._reader.video_frame_at(self._position)
if image is None:
self._video_qimage = None
self._video_reason = (
t("playback.frame_dropped")
if frame is not None and not frame.in_video
else t("playback.frame_unreadable")
)
return
self._video_reason = ""
self._video_qimage = _bgr_to_qimage(image)
def depth_image(self) -> QImage | None:
return self._depth_qimage
def depth_message(self) -> str:
return self._depth_message
def warnings_text(self) -> str:
return self._warnings
def _refresh_warnings(self) -> None: def _refresh_warnings(self) -> None:
self._warnings = ""
if self._reader is None: if self._reader is None:
return return
info = self._reader.info() info = self._reader.info()
@@ -251,122 +294,35 @@ class PlaybackDialog(QDialog):
total=info.total_rows, total=info.total_rows,
) )
text = f"{text} {note}" if text else note text = f"{text} {note}" if text else note
self.warning_label.setText(text) self._warnings = text
# ------------------------------------------------------------------ drawing
def _render_curves(self) -> None:
if self._reader is None:
return
info = self._reader.info()
if info.times.size == 0:
for curve in self.curves:
curve.setData([], [])
return
# Same rule as the live charts, per axis: a point is drawn only where the
# sample is valid and *that* axis is finite, so a single-axis calibration
# does not hide the axis that is calibrated. For a long recording the
# whole-session view is decimated while it is read from disk, in the unit
# being shown.
window_all = getattr(self._reader, "window_all", None)
if callable(window_all):
window = window_all(unit=self._unit, max_points=CHART_MAX_POINTS)
for index, curve in enumerate(self.curves):
times, values = window.plottable_axis(self._unit, index)
curve.setData(times, values)
return
for index, curve in enumerate(self.curves):
times, values = info.plottable_axis(self._unit, index)
curve.setData(times, values)
self.plot_widget.ci.layout.setRowStretchFactor(index, 1)
self.plot_widget.getItem(index, 0).setXRange(
float(info.times[0]), float(info.times[-1]), padding=0.02
)
def _on_toggle_unit(self) -> None:
self._unit = "N" if self._unit == "raw" else "raw"
self._render_curves()
self.set_position(self._position)
# ------------------------------------------------------------------ cursor
def position(self) -> float:
return self._position
def _on_slider(self, value: int) -> None:
if self._reader is None:
return
duration = self._reader.info().duration_s
self.set_position(duration * (value / SLIDER_STEPS))
def set_position(self, t_rel_s: float) -> None:
"""Move the single cursor: video, depth and charts follow it."""
if self._reader is None:
return
duration = self._reader.info().duration_s
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
self.slider.blockSignals(True)
if duration > 0:
self.slider.setValue(int(SLIDER_STEPS * self._position / duration))
self.slider.blockSignals(False)
self.position_label.setText(
t("playback.position", position=f"{self._position:.2f}", duration=f"{duration:.2f}")
)
for line in self.cursor_lines:
line.setValue(self._position)
self._show_video_frame()
if self._depth_ok or self._depth_result is not None:
# The held depth belongs to the frame it was computed for. Moving the
# cursor must not keep showing it as if it were synchronised.
self._show_depth_frame()
self.cursor_moved.emit(self._position)
def _show_video_frame(self) -> None:
if self._reader is None:
return
image, frame = self._reader.video_frame_at(self._position)
if image is None:
reason = (
t("playback.frame_dropped")
if frame is not None and not frame.in_video
else t("playback.frame_unreadable")
)
self.video_label.setPixmap(QPixmap())
self.video_label.setText(reason)
return
rgb = image[:, :, ::-1].copy()
height, width, channels = rgb.shape
qimage = QImage(
rgb.data, width, height, channels * width, QImage.Format_RGB888
).copy()
pixmap = QPixmap.fromImage(qimage).scaled(
self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
)
self.video_label.setPixmap(pixmap)
self.video_label.setText("")
# -------------------------------------------------------------------- depth # -------------------------------------------------------------------- depth
def _on_recompute_depth(self) -> None: def request_depth(self) -> None:
"""Kick off a recomputation on a worker thread. """Kick off a recomputation on a worker thread.
Opening a video with the SDK and stepping to a frame can block, so it Opening a video with the SDK and stepping to a frame can block, so it
must never run on the UI thread — that is exactly how the replay window must never run on the UI thread — that is exactly how the replay would
would freeze. freeze.
""" """
if self._recomputer is None: if self._recomputer is None:
return return
problem = self._recomputer.preflight() problem = self._recomputer.preflight()
if problem: if problem:
self._depth_ok = False self._depth_ok = False
self.depth_label.setPixmap(QPixmap()) self._depth_qimage = None
self.depth_label.setText(t(f"playback.depth_{problem}")) self._depth_message = t(f"playback.depth_{problem}")
self.status_changed.emit()
return return
if self._depth_thread is not None and self._depth_thread.is_alive(): if self._depth_thread is not None and self._depth_thread.is_alive():
self.depth_label.setText(t("playback.depth_busy")) self._depth_message = t("playback.depth_busy")
self.status_changed.emit()
return return
self._depth_ok = False self._depth_ok = False
self._depth_result = None self._depth_result = None
self._depth_result_seq = None self._depth_result_seq = None
self._depth_error = None self._depth_error = None
self.depth_label.setText(t("playback.depth_running")) self._depth_message = t("playback.depth_running")
self.status_changed.emit()
position = self._position position = self._position
# Bind the answer to this request: the recording, the frame and a request # Bind the answer to this request: the recording, the frame and a request
# sequence. A result that arrives after the cursor moved — or after # sequence. A result that arrives after the cursor moved — or after
@@ -382,8 +338,8 @@ class PlaybackDialog(QDialog):
def work() -> None: def work() -> None:
# Runs off the UI thread and touches no Qt object. It captures the # Runs off the UI thread and touches no Qt object. It captures the
# processor it was given: the dialog may already be showing another # processor it was given: the controller may already be showing
# recording by the time this returns. # another recording by the time this returns.
try: try:
result = recomputer.depth_at(position) result = recomputer.depth_at(position)
except BaseException as exc: # noqa: BLE001 - reported via the poll except BaseException as exc: # noqa: BLE001 - reported via the poll
@@ -409,6 +365,12 @@ class PlaybackDialog(QDialog):
self._depth_thread.start() self._depth_thread.start()
self._depth_poll.start() self._depth_poll.start()
def depth_busy(self) -> bool:
return self._depth_thread is not None and self._depth_thread.is_alive()
# The old spelling, kept for the tests and for readability at call sites.
_depth_busy = depth_busy
def _poll_depth(self) -> None: def _poll_depth(self) -> None:
"""UI-thread poll for the worker result.""" """UI-thread poll for the worker result."""
thread = self._depth_thread thread = self._depth_thread
@@ -427,8 +389,10 @@ class PlaybackDialog(QDialog):
# The failure belongs to a recording that is no longer open. # The failure belongs to a recording that is no longer open.
return return
self._depth_ok = False self._depth_ok = False
self.depth_label.setPixmap(QPixmap()) self._depth_qimage = None
self.depth_label.setText(t("playback.depth_failed", detail=str(exc))) self._depth_message = t("playback.depth_failed", detail=str(exc))
self.status_changed.emit()
self.frames_changed.emit()
return return
if record != self._record_serial: if record != self._record_serial:
# Another recording was opened while this was running. # Another recording was opened while this was running.
@@ -436,14 +400,18 @@ class PlaybackDialog(QDialog):
result = self._depth_result result = self._depth_result
if result is None or self._depth_result_seq != pending: if result is None or self._depth_result_seq != pending:
# A newer request (or a moved cursor) owns the pane now. # A newer request (or a moved cursor) owns the pane now.
self._show_depth_frame() self._refresh_depth_pane()
self.frames_changed.emit()
return return
if not self._depth_matches_cursor(result): if not self._depth_matches_cursor(result):
# The cursor moved while the SDK was reading: do not draw an answer # The cursor moved while the SDK was reading: do not draw an answer
# for another frame, and say the pane needs recomputing. # for another frame, and say the pane needs recomputing.
self._show_depth_frame() self._refresh_depth_pane()
self.frames_changed.emit()
return return
self._render_depth_result(result) self._render_depth_result(result)
self.status_changed.emit()
self.frames_changed.emit()
def _current_frame(self): def _current_frame(self):
"""The recorded frame under the cursor, or None without a frame index.""" """The recorded frame under the cursor, or None without a frame index."""
@@ -475,33 +443,42 @@ class PlaybackDialog(QDialog):
if code in ("not_recomputed", "recomputing", "stale"): if code in ("not_recomputed", "recomputing", "stale"):
# A placeholder, not a failure: the pane describes the cursor. # A placeholder, not a failure: the pane describes the cursor.
self._depth_ok = False self._depth_ok = False
self.depth_label.setPixmap(QPixmap()) self._depth_qimage = None
self.depth_label.setText(t(f"playback.depth_{code}")) self._depth_message = t(f"playback.depth_{code}")
return return
if not result.ok: if not result.ok:
self._depth_ok = False self._depth_ok = False
self.depth_label.setPixmap(QPixmap()) self._depth_qimage = None
self.depth_label.setText(t("playback.depth_failed", detail=result.error)) self._depth_message = t("playback.depth_failed", detail=result.error)
return return
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr from orisys_app_shared.acquisition.arrows import depth_map_to_bgr
bgr = depth_map_to_bgr(np.asarray(result.depth)) depth = np.asarray(result.depth)
self._latch_depth_range(depth)
bgr = depth_map_to_bgr(depth, value_range=self._depth_range)
if bgr is None: if bgr is None:
self._depth_ok = False self._depth_ok = False
self.depth_label.setPixmap(QPixmap()) self._depth_qimage = None
self.depth_label.setText(t("playback.depth_empty")) self._depth_message = t("playback.depth_empty")
return return
self._depth_ok = True self._depth_ok = True
self.depth_label.setText(self._depth_warning_text(result)) self._depth_message = self._depth_warning_text(result)
rgb = bgr[:, :, ::-1].copy() self._depth_qimage = _bgr_to_qimage(bgr)
height, width, channels = rgb.shape
qimage = QImage( def _latch_depth_range(self, depth: np.ndarray) -> None:
rgb.data, width, height, channels * width, QImage.Format_RGB888 if self._depth_range is not None:
).copy() return
pixmap = QPixmap.fromImage(qimage).scaled( finite = np.isfinite(depth)
self.depth_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation if depth.size == 0 or not finite.any():
) return
self.depth_label.setPixmap(pixmap) low = float(np.nanmin(depth[finite]))
high = float(np.nanmax(depth[finite]))
if high <= low:
high = low + 1.0
self._depth_range = (low, high)
def depth_range(self) -> tuple[float, float] | None:
return self._depth_range
@staticmethod @staticmethod
def _depth_warning_text(result) -> str: def _depth_warning_text(result) -> str:
@@ -514,8 +491,8 @@ class PlaybackDialog(QDialog):
detail=", ".join(t(f"playback.warn_{code}") for code in codes), detail=", ".join(t(f"playback.warn_{code}") for code in codes),
) )
def _show_depth_frame(self) -> None: def _refresh_depth_pane(self) -> None:
"""Draw the depth for the cursor's frame, or say it must be recomputed. """Re-derive the depth pane for the cursor's frame.
Re-drawing the held map here is what used to show a stale depth as though Re-drawing the held map here is what used to show a stale depth as though
it were in sync with the cursor. it were in sync with the cursor.
@@ -536,37 +513,3 @@ class PlaybackDialog(QDialog):
self._render_depth_result( self._render_depth_result(
DepthResult(error="not_recomputed", t_rel_s=self._position) DepthResult(error="not_recomputed", t_rel_s=self._position)
) )
# ------------------------------------------------------------------- labels
def retranslate(self) -> None:
self.setWindowTitle(t("playback.title"))
self.open_button.setText(t("playback.open"))
self.unit_button.setText(
t("playback.show_raw") if self._unit == "N" else t("playback.show_newton")
)
self.depth_button.setText(t("playback.recompute_depth"))
self.depth_button.setToolTip(t("playback.recompute_depth_tip"))
if self._reader is None:
self.video_label.setText(t("playback.empty"))
self.depth_label.setText(t("playback.depth_hint"))
self.path_label.setText("")
def closeEvent(self, event) -> None: # noqa: N802
self._depth_poll.stop()
# A depth recomputation that is blocked inside the SDK cannot be
# cancelled; it is a daemon thread, so it does not keep the process alive
# and the window always closes. The processor is closed by whichever
# thread owns it (see ``_retire_recomputer``), never by the UI thread
# while a worker is reading it.
for recomputer in list(self._retired_recomputers):
if not self._depth_busy():
self._retired_recomputers.remove(recomputer)
try:
recomputer.close()
except Exception: # noqa: BLE001
logger.exception("Could not close a retired recomputer")
self._retire_recomputer()
super().closeEvent(event)
def _depth_busy(self) -> bool:
return self._depth_thread is not None and self._depth_thread.is_alive()
+59 -15
View File
@@ -19,6 +19,15 @@ 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
#: from the recorded video, and the only processed view is the recomputed depth.
REPLAY_VIEW_MODES = ("recorded", "depth")
_VIEW_LABELS = {
"arrows_pure": "panels.view_arrows_pure",
"arrows_stitched": "panels.view_arrows_stitched",
"depth": "panels.view_depth",
"recorded": "panels.view_recorded",
}
_ROTATION_STEPS = (0, 90, 180, 270) _ROTATION_STEPS = (0, 90, 180, 270)
_COLORBAR_WIDTH = 120 _COLORBAR_WIDTH = 120
_COLORBAR_HEIGHT = 10 _COLORBAR_HEIGHT = 10
@@ -131,6 +140,7 @@ class ArrowPanel(QFrame):
self._depth_scale_text = "" self._depth_scale_text = ""
self._arrow_scale = 2.0 self._arrow_scale = 2.0
self._valid_area = False self._valid_area = False
self._source_kind = "live"
self.view_combo = QComboBox() self.view_combo = QComboBox()
self.view_combo.setMinimumWidth(140) self.view_combo.setMinimumWidth(140)
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed) self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
@@ -262,6 +272,29 @@ class ArrowPanel(QFrame):
t("panels.arrow_area_masked") if self._valid_area else t("panels.arrow_area_full") t("panels.arrow_area_masked") if self._valid_area else t("panels.arrow_area_full")
) )
def set_source_kind(self, kind: str) -> None:
"""Switch between live frames and a recording being replayed.
A recording has no optical flow to draw, so the arrow entries disappear
rather than offering a view that would be a lie.
"""
wanted = "replay" if str(kind) == "replay" else "live"
if wanted == self._source_kind:
return
self._source_kind = wanted
self._populate_view_combo()
self._refresh_pipeline_label()
self._refresh_footer_visibility()
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = self._empty_message()
self.image_label.setText(self._placeholder)
def source_kind(self) -> str:
return self._source_kind
def view_modes(self) -> tuple[str, ...]:
return REPLAY_VIEW_MODES if self._source_kind == "replay" else VIEW_MODES
def set_theme(self, theme: str) -> None: def set_theme(self, theme: str) -> None:
self._theme = str(theme) self._theme = str(theme)
self.colorbar.set_theme(self._theme) self.colorbar.set_theme(self._theme)
@@ -282,9 +315,12 @@ class ArrowPanel(QFrame):
def _refresh_footer_visibility(self) -> None: def _refresh_footer_visibility(self) -> None:
"""Arrow annotations and the depth legend are mutually exclusive.""" """Arrow annotations and the depth legend are mutually exclusive."""
depth = self.view_mode() == "depth" depth = self.view_mode() == "depth"
self.scale_label.setVisible(not depth) replay = self._source_kind == "replay"
# The magnification and the valid area describe the live flow drawing;
# neither exists for a recorded frame.
self.scale_label.setVisible(not depth and not replay)
self.pipeline_label.setVisible(not depth) self.pipeline_label.setVisible(not depth)
self.area_label.setVisible(not depth) self.area_label.setVisible(not depth and not replay)
self.colorbar.setVisible(depth) self.colorbar.setVisible(depth)
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text)) self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
@@ -292,11 +328,12 @@ class ArrowPanel(QFrame):
current = self.view_combo.currentData() current = self.view_combo.currentData()
self.view_combo.blockSignals(True) self.view_combo.blockSignals(True)
self.view_combo.clear() self.view_combo.clear()
for mode in VIEW_MODES: for mode in self.view_modes():
self.view_combo.addItem("", mode) self.view_combo.addItem(t(_VIEW_LABELS[mode]), mode)
if current: if current:
idx = self.view_combo.findData(current) idx = self.view_combo.findData(current)
self.view_combo.setCurrentIndex(max(0, idx)) if idx >= 0:
self.view_combo.setCurrentIndex(idx)
self.view_combo.blockSignals(False) self.view_combo.blockSignals(False)
def _on_view_index_changed(self, _index: int) -> None: def _on_view_index_changed(self, _index: int) -> None:
@@ -305,24 +342,31 @@ class ArrowPanel(QFrame):
def retranslate(self) -> None: def retranslate(self) -> None:
self.title.setText(t("panels.arrow")) self.title.setText(t("panels.arrow"))
labels = ( for i, mode in enumerate(self.view_modes()):
t("panels.view_arrows_pure"),
t("panels.view_arrows_stitched"),
t("panels.view_depth"),
)
for i, label in enumerate(labels):
if i < self.view_combo.count(): if i < self.view_combo.count():
self.view_combo.setItemText(i, label) self.view_combo.setItemText(i, t(_VIEW_LABELS[mode]))
self.view_combo.setToolTip(t("panels.view_mode_tip")) self.view_combo.setToolTip(t("panels.view_mode_tip"))
self.image_label.setToolTip(t("panels.arrow_empty_tip")) self.image_label.setToolTip(t("panels.arrow_empty_tip"))
self.pipeline_label.setText(t("panels.arrow_pipeline")) self._refresh_pipeline_label()
self.pipeline_label.setToolTip(t("panels.arrow_pipeline_tip"))
self._refresh_scale_label() self._refresh_scale_label()
self.set_valid_area(self._valid_area) self.set_valid_area(self._valid_area)
if not self.image_label.pixmap() or self.image_label.pixmap().isNull(): if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = t("panels.arrow_empty") self._placeholder = self._empty_message()
self.image_label.setText(self._placeholder) self.image_label.setText(self._placeholder)
def _refresh_pipeline_label(self) -> None:
if self._source_kind == "replay":
self.pipeline_label.setText(t("panels.replay_pipeline"))
self.pipeline_label.setToolTip(t("panels.replay_pipeline_tip"))
return
self.pipeline_label.setText(t("panels.arrow_pipeline"))
self.pipeline_label.setToolTip(t("panels.arrow_pipeline_tip"))
def _empty_message(self) -> str:
if self._source_kind == "replay":
return t("panels.replay_empty")
return t("panels.arrow_empty")
def _effective_rotation_deg(self) -> int: def _effective_rotation_deg(self) -> int:
if not self._rotation_enabled: if not self._rotation_enabled:
return 0 return 0
+10 -4
View File
@@ -243,8 +243,8 @@ class CalPanel(QFrame):
self.btn_clear_all.clicked.connect(self._on_clear_all) self.btn_clear_all.clicked.connect(self._on_clear_all)
self.btn_open_record.clicked.connect(self._on_open_record) self.btn_open_record.clicked.connect(self._on_open_record)
self.btn_save_record.clicked.connect(self._on_save_record) self.btn_save_record.clicked.connect(self._on_save_record)
self.btn_export.clicked.connect(self._on_export) self.btn_export.clicked.connect(self.export_coefficients)
self.btn_apply.clicked.connect(self._on_apply) self.btn_apply.clicked.connect(self.apply_coefficients)
# Two rows, not nine buttons in a row: in one line every label was # Two rows, not nine buttons in a row: in one line every label was
# squeezed until it was unreadable ("排 除 / 设 为 验 证 点"). # squeezed until it was unreadable ("排 除 / 设 为 验 证 点").
selection_actions = QHBoxLayout() selection_actions = QHBoxLayout()
@@ -678,7 +678,12 @@ class CalPanel(QFrame):
self._rebuild_store() self._rebuild_store()
self._refresh_table() self._refresh_table()
def _on_export(self) -> None: def export_coefficients(self) -> None:
"""Write the prepared model to the user's calibration directory.
Public because the sensor dialog offers the same action: one
implementation, reachable from both places.
"""
# Re-prepare first: the SN and the requested duration may have changed, # Re-prepare first: the SN and the requested duration may have changed,
# and the file must be exactly the model the panel is showing. # and the file must be exactly the model the panel is showing.
self._rebuild_store() self._rebuild_store()
@@ -720,7 +725,8 @@ class CalPanel(QFrame):
def _warnings(self) -> list[str]: def _warnings(self) -> list[str]:
return list(getattr(self._prepared, "warnings", []) or []) return list(getattr(self._prepared, "warnings", []) or [])
def _on_apply(self) -> None: def apply_coefficients(self) -> None:
"""Ask for the last export to be pushed into the running device."""
if not self._last_export_path: if not self._last_export_path:
self.status_message.emit(t("cal.apply_no_export")) self.status_message.emit(t("cal.apply_no_export"))
return return
+249 -54
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import time import time
import numpy as np import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtCore import Qt, QSize, pyqtSignal
from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QFrame, QFrame,
@@ -22,6 +22,9 @@ from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color from orisys_app_shared.theme import color
from orisys_studio.ui import icons
from orisys_studio.ui.icons import ICON_SIZE
try: try:
import pyqtgraph as pg import pyqtgraph as pg
except ImportError as exc: # pragma: no cover except ImportError as exc: # pragma: no cover
@@ -43,6 +46,10 @@ WINDOW_BRUSH_ALPHA = 70
#: Point size of the axis readings. They are the number an operator reads from #: Point size of the axis readings. They are the number an operator reads from
#: across the bench, so they are not the body-text size. #: across the bench, so they are not the body-text size.
VALUE_FONT_PT = 16 VALUE_FONT_PT = 16
#: Half-width of the slice read around the cursor when the value cards follow the
#: timeline. One second is wide enough that a sample is always inside it and small
#: enough that reading a recording from disk stays free while scrubbing.
CURSOR_READOUT_S = 0.5
def _window_brush(theme: str): def _window_brush(theme: str):
@@ -105,6 +112,8 @@ class ForcePlotsPanel(QFrame):
cursor_moved = pyqtSignal(float) cursor_moved = pyqtSignal(float)
#: "follow" (live) or "browse" (user-controlled window). #: "follow" (live) or "browse" (user-controlled window).
mode_changed = pyqtSignal(str) mode_changed = pyqtSignal(str)
#: The unit chip was clicked: switch between newton and raw.
unit_toggle_requested = pyqtSignal()
def __init__(self, parent=None, *, theme: str = "dark") -> None: def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent) super().__init__(parent)
@@ -120,10 +129,14 @@ class ForcePlotsPanel(QFrame):
#: Per axis: does ``_last_values`` hold a real value in the active unit? #: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True) self._available: tuple[bool, bool, bool] = (True, True, True)
# -- browsing state ------------------------------------------------- # -- browsing state -------------------------------------------------
self._store = None
#: Where the curves read from: the in-memory session store here, a #: Where the curves read from: the in-memory session store here, a
#: recording on disk in the replay. One interface either way. #: recording on disk in the replay. One interface either way.
self._source = StoreCurveSource() self._source = StoreCurveSource()
#: True once a source is attached (either kind). ``_store is None`` was
#: the old proxy for this and could not express a disk source.
self._source_ready = False
#: The live store to return to when a recording source is detached.
self._live_store = None
self._mode = "follow" self._mode = "follow"
self._window: tuple[float, float] = (0.0, 0.0) self._window: tuple[float, float] = (0.0, 0.0)
self._cursor_t = 0.0 self._cursor_t = 0.0
@@ -153,21 +166,25 @@ 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)
self.btn_latest = QPushButton() # The three window actions are symbols: the tooltip explains each one, and
self.btn_latest.setProperty("compact", "true") # the pointer stays inside the chart instead of reading three labels.
self.btn_latest = self._header_button("latest")
self.btn_latest.clicked.connect(self.follow_latest) self.btn_latest.clicked.connect(self.follow_latest)
header.addWidget(self.btn_latest) header.addWidget(self.btn_latest)
self.btn_overview = QPushButton() self.btn_overview = self._header_button("zoom_fit")
self.btn_overview.setProperty("compact", "true")
self.btn_overview.clicked.connect(self.show_all) self.btn_overview.clicked.connect(self.show_all)
header.addWidget(self.btn_overview) header.addWidget(self.btn_overview)
self.btn_box_zoom = QPushButton() self.btn_box_zoom = self._header_button("box_zoom")
self.btn_box_zoom.setProperty("compact", "true")
self.btn_box_zoom.setCheckable(True) self.btn_box_zoom.setCheckable(True)
self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled) self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled)
header.addWidget(self.btn_box_zoom) header.addWidget(self.btn_box_zoom)
self.unit_badge = QLabel() # The unit chip is a button: switching between newton and raw is a chart
# concern, so it lives on the chart.
self.unit_badge = QPushButton()
self.unit_badge.setObjectName("unitBadge") self.unit_badge.setObjectName("unitBadge")
self.unit_badge.setProperty("compact", "true")
self.unit_badge.setCursor(Qt.PointingHandCursor)
self.unit_badge.clicked.connect(self.unit_toggle_requested.emit)
header.addWidget(self.unit_badge) header.addWidget(self.unit_badge)
root.addLayout(header) root.addLayout(header)
@@ -210,19 +227,29 @@ class ForcePlotsPanel(QFrame):
for i, _name in enumerate(CHANNEL_LABELS): for i, _name in enumerate(CHANNEL_LABELS):
plot = self.plot_widget.addPlot(row=i, col=0) plot = self.plot_widget.addPlot(row=i, col=0)
plot.showGrid(x=True, y=True, alpha=GRID_ALPHA) plot.showGrid(x=True, y=True, alpha=GRID_ALPHA)
plot.setMouseEnabled(x=False, y=False) # Panning and zooming are always available: a chart that ignores the
# mouse until it is already browsing could never *enter* browsing by
# being dragged. Y stays locked to the configured limits.
plot.setMouseEnabled(x=True, y=False)
if i > 0: if i > 0:
plot.setXLink(self.plots[0]) plot.setXLink(self.plots[0])
curve = plot.plot(pen=pen) curve = plot.plot(pen=pen)
# One shared time cursor across all three channels. # One shared time cursor across all three channels. It is draggable
# while browsing (see ``_set_mode``), which makes the cursor a second
# timeline control next to the overview strip.
cursor = pg.InfiniteLine( cursor = pg.InfiniteLine(
pos=0.0, pos=0.0,
angle=90, angle=90,
movable=False, movable=False,
pen=pg.mkPen(color(theme, "text_muted"), width=1), pen=pg.mkPen(color(theme, "text_muted"), width=1),
) )
cursor.sigPositionChanged.connect(self._on_cursor_dragged)
plot.addItem(cursor, ignoreBounds=True) plot.addItem(cursor, ignoreBounds=True)
plot.getViewBox().sigXRangeChanged.connect(self._on_x_range_changed) # ``sigRangeChangedManually`` — not ``sigXRangeChanged`` — is what
# pyqtgraph emits for a wheel/drag gesture. The plain range signal
# also fires when a resize propagates through the X links, which is
# not a user gesture and must not switch the view to browsing.
plot.getViewBox().sigRangeChangedManually.connect(self._on_manual_range_change)
self.plots.append(plot) self.plots.append(plot)
self.curves.append(curve) self.curves.append(curve)
self.cursors.append(cursor) self.cursors.append(cursor)
@@ -276,6 +303,17 @@ class ForcePlotsPanel(QFrame):
self._apply_axis_mode() self._apply_axis_mode()
self.retranslate() self.retranslate()
def _header_button(self, icon_name: str) -> QPushButton:
"""A chart-header action: an icon, a tooltip and no text."""
button = QPushButton()
button.setProperty("compact", "true")
button.setProperty("iconOnly", "true")
button.setIcon(icons.icon(icon_name, self._theme))
button.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
button.setCursor(Qt.PointingHandCursor)
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
return button
def _build_value_card( def _build_value_card(
self, channel: str self, channel: str
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel]: ) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel]:
@@ -323,6 +361,11 @@ class ForcePlotsPanel(QFrame):
bg = color(theme, "plot_bg") bg = color(theme, "plot_bg")
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.
for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom):
name = getattr(button, "_icon_name", "")
if name:
button.setIcon(icons.icon(name, theme))
self.plot_widget.setBackground(bg) self.plot_widget.setBackground(bg)
axis_pen = pg.mkPen(text) axis_pen = pg.mkPen(text)
for i, plot in enumerate(self.plots): for i, plot in enumerate(self.plots):
@@ -433,20 +476,53 @@ class ForcePlotsPanel(QFrame):
only its normal-force axis calibrated). Those keep NaN and show as a gap only its normal-force axis calibrated). Those keep NaN and show as a gap
and a dash, never as a raw count wearing a newton label. and a dash, never as a raw count wearing a newton label.
""" """
if unit != self._unit: if self.is_recording_source():
# Never mix units in one buffer. Callers switch units through # The curves, the numbers and the unit all belong to the recording
# ``set_units``/``set_fixed_newton`` which rebuild from the session # being shown, so a live sample must not touch any of them — not even
# store; a bare change here would silently reinterpret old samples. # to switch the unit (``set_units`` rebuilds the visible curves from
self._unit = unit # the attached source). The acquisition is unaffected: the record strip
# and the data chip carry its state, and leaving the replay view puts
# the live values back within one frame.
return
self._last_values = (float(fn), float(fx), float(fy)) self._last_values = (float(fn), float(fx), float(fy))
if available is not None: if available is not None:
self._available = tuple(bool(flag) for flag in available) self._available = tuple(bool(flag) for flag in available)
self._render_value_cards() if unit != self._unit:
# A unit change mid-stream is a *switch*, never a reinterpretation:
# the buffered values were measured in the old unit and must not be
# drawn on the new axis. This is the spike that used to appear at the
# start of every session — a device that confirms its calibration a
# moment after the first frames reported raw counts for a few frames,
# and those counts were then drawn against the newton axis.
self._switch_unit(unit)
# The rebuild reads the session store, which already holds this
# sample (the record channel appends before the UI tick runs), so
# appending it again would duplicate — or reorder — the buffer.
state = self.history.state()
if state is not None:
self._syncing = True
try:
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
finally:
self._syncing = False
self._render_value_cards()
# ...and if the view is browsing (or showing a recording) the switch
# is not a reason to read the live sample out: the cards follow the
# cursor, so re-read it in the new unit.
self._readout_at_cursor()
self._overview_dirty = True
self._sync_overview()
return
if self._mode == "browse": if self._mode == "browse":
# Browsing is a display pause, not a capture pause: the record # Browsing is a display pause, not a capture pause: the record
# channel keeps running and the view simply stops following. # channel keeps running and the view simply stops following. The
# readout belongs to the cursor now, so a live sample must not
# overwrite it either.
self._overview_dirty = True self._overview_dirty = True
return return
self._render_value_cards()
use_fixed = self._uses_fixed_y() use_fixed = self._uses_fixed_y()
# Store the true value. Clamping here (the old ``max(0.0, fn)``) erased # Store the true value. Clamping here (the old ``max(0.0, fn)``) erased
# negative FN from the buffer and hid zero drift; out-of-range values are # negative FN from the buffer and hid zero drift; out-of-range values are
@@ -483,15 +559,16 @@ class ForcePlotsPanel(QFrame):
self._last_overview_at = now self._last_overview_at = now
self._sync_overview() self._sync_overview()
def set_units(self, unit: str, store=None) -> None: def set_units(self, unit: str) -> None:
"""Switch display units without mixing units in the curve buffer. """Switch display units without mixing units in the curve buffer.
When a session store is supplied the visible window is rebuilt from it, The visible window is rebuilt from whichever source is attached, because
because it keeps both the raw and the calibrated triplet for every a session store keeps both the raw and the calibrated triplet for every
sample. Otherwise the buffer is cleared rather than reinterpreted. sample and a recording stores both as well. Otherwise the buffer is
cleared rather than reinterpreted.
""" """
unit = "N" if str(unit).upper().startswith("N") else "raw" unit = "N" if str(unit).upper().startswith("N") else "raw"
if unit == self._unit and store is None: if unit == self._unit:
return return
self._unit = unit self._unit = unit
# ``clear`` keeps the buffer's time origin on purpose: the rebuilt history # ``clear`` keeps the buffer's time origin on purpose: the rebuilt history
@@ -499,34 +576,89 @@ class ForcePlotsPanel(QFrame):
self.history.clear() self.history.clear()
self._clear_curves() self._clear_curves()
self._overview_dirty = True self._overview_dirty = True
if store is not None: if self._source_ready:
self.set_store(store)
if self._mode == "browse": if self._mode == "browse":
self.refresh_from_store() self.refresh_from_store()
else: else:
self._reload_from_store(store) self._reload_from_store()
self._render_value_cards() self._render_value_cards()
self._apply_axis_mode() self._apply_axis_mode()
# Re-read the point under the cursor in the new unit: while browsing, or
# with a recording attached, the live sample is not what the cards state.
self._readout_at_cursor()
# ---------------------------------------------------------------- browsing # ---------------------------------------------------------------- browsing
def set_store(self, store) -> None: def set_store(self, store) -> None:
"""Attach the whole-session store the browser reads from.""" """Attach the whole-session store the browser reads from."""
self._store = store self._live_store = store
self._source.set_store(store) self._source = StoreCurveSource(store)
self._source_ready = store is not None
self._on_source_changed()
def set_recording_source(self, reader) -> None:
"""Read the curves from a recording on disk instead of the live store."""
from orisys_app_shared.force.source import RecordingCurveSource
self._source = RecordingCurveSource(reader)
self._source_ready = True
self._on_source_changed()
def clear_recording_source(self) -> None:
"""Go back to the live session store (a no-op if it already is)."""
if self.is_recording_source():
self.set_store(self._live_store)
def is_recording_source(self) -> bool:
return str(getattr(self._source, "kind", "")) == "disk"
def _switch_unit(self, unit: str) -> None:
"""Move the live buffer to another display unit without misreading it.
The window is rebuilt from the attached source, which keeps both the raw
and the calibrated triplet for every sample, so a sample that has no
value in the new unit becomes a gap instead of a number in the wrong
scale.
"""
self._unit = unit
self.history.clear()
self._clear_curves()
self._overview_dirty = True
if self._source_ready:
self._reload_from_store()
self._apply_axis_mode()
def _on_source_changed(self) -> None:
"""Reset the view for a new source: a recording is not a live session."""
self.history.clear()
self._clear_curves()
self._mode = "follow"
self._window = (0.0, 0.0)
self._overview_dirty = True self._overview_dirty = True
self._refresh_mode_label() self._refresh_mode_label()
self._apply_axis_mode()
if not self._source_ready:
self.overview_curve.setData([], [])
self.window_label.setText("")
self.stats_label.setText("")
return
self._sync_overview()
self._reload_from_store()
def mode(self) -> str: def mode(self) -> str:
return self._mode return self._mode
def unit(self) -> str:
"""The unit the curves are currently drawn in (``N`` or ``raw``)."""
return self._unit
def cursor_time(self) -> float: def cursor_time(self) -> float:
return self._cursor_t return self._cursor_t
def window(self) -> tuple[float, float]: def window(self) -> tuple[float, float]:
if self._mode == "browse": if self._mode == "browse":
return self._window return self._window
if self._store is not None: if self._source_ready:
return self._store.extent() return self._source.extent()
return (0.0, 0.0) return (0.0, 0.0)
def follow_latest(self) -> None: def follow_latest(self) -> None:
@@ -559,7 +691,7 @@ class ForcePlotsPanel(QFrame):
def refresh_from_store(self) -> None: def refresh_from_store(self) -> None:
"""Redraw the visible window. Called on pan, zoom and data changes.""" """Redraw the visible window. Called on pan, zoom and data changes."""
if self._store is None: if not self._source_ready:
return return
t0, t1 = self._window t0, t1 = self._window
# One shared rule for every view, decided **per axis**: valid and finite # One shared rule for every view, decided **per axis**: valid and finite
@@ -596,14 +728,30 @@ class ForcePlotsPanel(QFrame):
self._interactive(mode == "browse") self._interactive(mode == "browse")
self._refresh_mode_label() self._refresh_mode_label()
self._sync_overview() self._sync_overview()
self._readout_at_cursor()
self.mode_changed.emit(mode) self.mode_changed.emit(mode)
def _interactive(self, enabled: bool) -> None: def _interactive(self, browsing: bool) -> None:
"""Pan/zoom is only meaningful over a stored window.""" """Pan/zoom is always available; only the *cursor* is browse-only.
Gating the mouse made browsing unreachable: entering it needs a drag, and
a chart that ignores the mouse never produces one.
"""
for plot in self.plots: for plot in self.plots:
plot.setMouseEnabled(x=bool(enabled), y=False) plot.setMouseEnabled(x=True, y=False)
if not enabled: if not browsing:
plot.getViewBox().setMouseMode(pg.ViewBox.PanMode) plot.getViewBox().setMouseMode(pg.ViewBox.PanMode)
for cursor in self.cursors:
cursor.setMovable(bool(browsing))
def _on_cursor_dragged(self) -> None:
"""The shared cursor is a timeline control while browsing."""
if self._syncing or self._mode != "browse":
return
value = float(self.cursors[0].value())
if value == self._cursor_t:
return
self.set_cursor(value, emit=True)
def _on_box_zoom_toggled(self, checked: bool) -> None: def _on_box_zoom_toggled(self, checked: bool) -> None:
mode = pg.ViewBox.RectMode if checked else pg.ViewBox.PanMode mode = pg.ViewBox.RectMode if checked else pg.ViewBox.PanMode
@@ -612,16 +760,20 @@ class ForcePlotsPanel(QFrame):
for plot in self.plots: for plot in self.plots:
plot.getViewBox().setMouseMode(mode) plot.getViewBox().setMouseMode(mode)
def _on_x_range_changed(self, _viewbox, x_range) -> None: def _on_manual_range_change(self, mask=None) -> None:
"""User scrolled or dragged: leave live mode and read from the store. """The user zoomed or panned: leave live mode and read the store.
Programmatic range changes are ignored via ``self._syncing`` — without This is pyqtgraph's *manual* signal, so a programmatic range change (or
that, redrawing a window would feed back in as if the user had dragged, one propagated along the X links while the widget is resized) never
and "back to latest" could never take effect. arrives here. ``self._syncing`` still guards our own redraws.
""" """
if self._syncing or self._store is None: if self._syncing or not self._source_ready:
return return
x0, x1 = float(x_range[0]), float(x_range[1]) if mask is not None and len(mask) > 0 and not bool(mask[0]):
# A y-only gesture; the time axis did not change.
return
view_range = self.plots[0].getViewBox().viewRange()
x0, x1 = float(view_range[0][0]), float(view_range[0][1])
if not (x1 > x0): if not (x1 > x0):
return return
first, last = self._source.extent() first, last = self._source.extent()
@@ -647,11 +799,45 @@ class ForcePlotsPanel(QFrame):
for cursor in self.cursors: for cursor in self.cursors:
cursor.setValue(self._cursor_t) cursor.setValue(self._cursor_t)
self.overview_cursor.setValue(self._cursor_t) self.overview_cursor.setValue(self._cursor_t)
self._readout_at_cursor()
if emit: if emit:
self.cursor_moved.emit(self._cursor_t) self.cursor_moved.emit(self._cursor_t)
def _readout_at_cursor(self) -> None:
"""Make the value cards describe the point the cursor is on.
While following live the newest sample *is* the cursor — ``push`` writes
both — so there is nothing to read. Browsing history or a recording means
the cursor sits somewhere in the past, and then the cards must read that
point out of the source: a replay used to show a chart at 7.5e5 raw counts
next to cards reading the live 0.0, which reads as "the replay is not
open". Availability is decided per sample here as well, so a cursor on a
row with no value in this unit shows a dash, never a stale number.
"""
if self._mode != "browse" and not self.is_recording_source():
return
if not self._source_ready:
return
first, last = self._source.extent()
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
if not (t1 >= t0):
return
window = self._source.window(t0, t1, unit=self._unit)
if window is None or len(window) == 0:
return
times = np.asarray(window.times, dtype=float)
index = int(np.argmin(np.abs(times - self._cursor_t)))
values = np.asarray(window.values, dtype=float)[index]
valid = bool(np.asarray(window.valid, dtype=bool)[index])
self._last_values = tuple(float(value) for value in values)
self._available = tuple(
bool(valid and np.isfinite(value)) for value in values
)
self._render_value_cards()
def _update_selection_readout(self) -> None: def _update_selection_readout(self) -> None:
if self._store is None: if not self._source_ready:
self.stats_label.setText("") self.stats_label.setText("")
return return
t0, t1 = self._window t0, t1 = self._window
@@ -680,7 +866,7 @@ class ForcePlotsPanel(QFrame):
) )
def _sync_overview(self) -> None: def _sync_overview(self) -> None:
if self._store is None: if not self._source_ready:
return return
first, last = self._source.extent() first, last = self._source.extent()
if last <= first: if last <= first:
@@ -738,7 +924,7 @@ class ForcePlotsPanel(QFrame):
The region is the timeline control, so a drag is a pan: it enters The region is the timeline control, so a drag is a pan: it enters
history browsing exactly like dragging the charts themselves. history browsing exactly like dragging the charts themselves.
""" """
if self._syncing or self._store is None: if self._syncing or not self._source_ready:
return return
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):
@@ -752,13 +938,20 @@ class ForcePlotsPanel(QFrame):
return 1200 return 1200
def _refresh_mode_label(self) -> None: def _refresh_mode_label(self) -> None:
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow" if self.is_recording_source():
key = "chart.mode_replay"
else:
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
self.mode_label.setText(t(key)) self.mode_label.setText(t(key))
self.btn_latest.setEnabled(self._mode == "browse") # "Back to latest" only means something while reading a live session; a
self.btn_overview.setEnabled(self._store is not None) # recording has no latest sample to return to.
self.btn_latest.setEnabled(
self._mode == "browse" and not self.is_recording_source()
)
self.btn_overview.setEnabled(self._source_ready)
def _reload_from_store(self, store) -> None: def _reload_from_store(self) -> None:
"""Re-populate the visible buffer from the session source.""" """Re-populate the visible buffer from the attached source."""
window = self.history.window_sec window = self.history.window_sec
first, last = self._source.extent() first, last = self._source.extent()
span = window if window > 0 else max(0.0, last - first) span = window if window > 0 else max(0.0, last - first)
@@ -874,12 +1067,14 @@ class ForcePlotsPanel(QFrame):
badge.setText(label) badge.setText(label)
for name_label, key in zip(self.channel_names, ("fn", "fx", "fy")): for name_label, key in zip(self.channel_names, ("fn", "fx", "fy")):
name_label.setText(t(f"chart.{key}_name")) name_label.setText(t(f"chart.{key}_name"))
self.btn_latest.setText(t("chart.return_latest"))
self.btn_latest.setToolTip(t("chart.return_latest_tip")) self.btn_latest.setToolTip(t("chart.return_latest_tip"))
self.btn_overview.setText(t("chart.overview")) self.btn_latest.setAccessibleName(t("chart.return_latest"))
self.btn_overview.setToolTip(t("chart.overview_tip")) self.btn_overview.setToolTip(t("chart.overview_tip"))
self.btn_box_zoom.setText(t("chart.box_zoom")) 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"))
self.btn_box_zoom.setAccessibleName(t("chart.box_zoom"))
self.unit_badge.setToolTip(t("chart.unit_toggle_tip"))
self.unit_badge.setAccessibleName(t("chart.unit_toggle"))
self.window_caption.setText(t("chart.overview")) self.window_caption.setText(t("chart.overview"))
self.window_caption.setToolTip(t("chart.window_tip")) self.window_caption.setToolTip(t("chart.window_tip"))
self._refresh_mode_label() self._refresh_mode_label()
+117
View File
@@ -0,0 +1,117 @@
"""Small monochrome icons, painted at runtime from the theme tokens.
The app ships no icon assets beyond its logo, and Qt's built-in icons follow the
platform's own look. These three chart-header actions are therefore drawn here:
a transparent background and a pen colour taken from the theme, so the header
stays readable in both themes without a single hex literal in widget code.
The cache is keyed on the *resolved colour*, not on the theme name: a theme
switch therefore produces new pixmaps, and two themes that share a colour share
the pixmap.
"""
from __future__ import annotations
from functools import lru_cache
from PyQt5.QtCore import QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from orisys_app_shared.theme import color
#: Names of the icons this module can paint.
NAMES = ("zoom_fit", "box_zoom", "latest")
ICON_SIZE = 16
_PEN_WIDTH = 1.4
def _paint_zoom_fit(painter: QPainter, size: int) -> None:
"""Four corner brackets around a small frame: "fit this to the screen"."""
inset = 2.0
arm = size * 0.28
far = size - inset
for x, y, dx, dy in (
(inset, inset, 1, 1),
(far, inset, -1, 1),
(inset, far, 1, -1),
(far, far, -1, -1),
):
painter.drawLine(
int(round(x)), int(round(y)), int(round(x + dx * arm)), int(round(y))
)
painter.drawLine(
int(round(x)), int(round(y)), int(round(x)), int(round(y + dy * arm))
)
side = size * 0.375
painter.drawRect(QRectF((size - side) / 2.0, (size - side) / 2.0, side, side))
def _paint_box_zoom(painter: QPainter, size: int) -> None:
"""A dashed frame: drag a region to zoom into it."""
pen = painter.pen()
pen.setStyle(Qt.DashLine)
pen.setDashPattern([2.2, 1.6])
painter.setPen(pen)
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
def _paint_latest(painter: QPainter, size: int) -> None:
"""An arrow running into a bar: jump to the newest sample."""
top = 3.0
bottom = size - 3.0
mid = size / 2.0
bar_x = size - 3.0
draw_to = bar_x - 2.5
painter.drawLine(int(round(bar_x)), int(round(top)), int(round(bar_x)), int(round(bottom)))
painter.drawLine(2, int(round(mid)), int(round(draw_to)), int(round(mid)))
head = size * 0.26
painter.drawLine(
int(round(draw_to)), int(round(mid)),
int(round(draw_to - head)), int(round(mid - head)),
)
painter.drawLine(
int(round(draw_to)), int(round(mid)),
int(round(draw_to - head)), int(round(mid + head)),
)
_PAINTERS = {
"zoom_fit": _paint_zoom_fit,
"box_zoom": _paint_box_zoom,
"latest": _paint_latest,
}
@lru_cache(maxsize=32)
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
canvas = QPixmap(size, size)
canvas.fill(Qt.transparent)
paint = _PAINTERS.get(str(name))
if paint is None:
return canvas
painter = QPainter(canvas)
painter.setRenderHint(QPainter.Antialiasing, True)
pen = QPen()
pen.setWidthF(_PEN_WIDTH)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
pen.setColor(QColor(tint))
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
try:
paint(painter, size)
finally:
painter.end()
return canvas
def icon(name: str, theme: str, *, token: str = "text_secondary") -> QIcon:
"""The named icon in the theme's colour for ``token``."""
return QIcon(pixmap(name, color(theme, token)))
def clear() -> None:
"""Drop the cached pixmaps (used by tests and by a theme rebuild)."""
pixmap.cache_clear()
@@ -0,0 +1,158 @@
"""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"))
@@ -0,0 +1,203 @@
"""Sensor and calibration dialog.
The status rail shows one summary button (``BOX · box/sensor.json``): which
product and config the window is configured for is worth reading at a glance,
but the pickers themselves — and the calibration that belongs to them — need
more room than a 26 px rail can give.
The calibration half is read-only on purpose: it states which calibration the
*device* confirmed, which axes it covers, and what the last exported
coefficients file says it belongs to. Exporting and applying stay single-entry
actions (they are the calibration panel's own methods), so there is exactly one
implementation of each.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from PyQt5.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
from orisys_studio.ui.fit_text import ShrinkableLabel
class SensorDialog(QDialog):
"""Sensor type / config picker plus the calibration facts that follow it."""
def __init__(self, *, window, picker, theme: str = "dark") -> None:
super().__init__(window)
self._window = window
self._theme = str(theme)
self.picker = picker
self.setObjectName("sensorDialog")
self.setModal(False)
root = QVBoxLayout(self)
root.setSpacing(10)
self.picker_row = QHBoxLayout()
self.picker_row.addWidget(self.picker)
self.picker_row.addStretch(1)
root.addLayout(self.picker_row)
root.addWidget(self._section_rule())
self.calib_title = QLabel()
self.calib_title.setObjectName("panelTitle")
root.addWidget(self.calib_title)
self.version_label = ShrinkableLabel()
self.version_label.setObjectName("panelMeta")
self.axes_label = ShrinkableLabel()
self.axes_label.setObjectName("panelMeta")
self.config_label = ShrinkableLabel()
self.config_label.setObjectName("panelMeta")
for label in (self.version_label, self.axes_label):
root.addWidget(label)
config_row = QHBoxLayout()
config_row.addWidget(self.config_label)
self.btn_reveal = QPushButton()
self.btn_reveal.setProperty("compact", "true")
self.btn_reveal.clicked.connect(self._window._on_reveal_config)
config_row.addWidget(self.btn_reveal)
config_row.addStretch(1)
root.addLayout(config_row)
root.addWidget(self._section_rule())
self.export_title = QLabel()
self.export_title.setObjectName("panelTitle")
root.addWidget(self.export_title)
self.export_label = ShrinkableLabel()
self.export_label.setObjectName("panelMeta")
self.identity_label = ShrinkableLabel()
self.identity_label.setObjectName("panelMeta")
self.export_label.setWordWrap(True)
self.identity_label.setWordWrap(True)
root.addWidget(self.export_label)
root.addWidget(self.identity_label)
actions = QHBoxLayout()
actions.setSpacing(6)
self.btn_export = QPushButton()
self.btn_export.clicked.connect(self._on_export)
self.btn_apply = QPushButton()
self.btn_apply.clicked.connect(self._on_apply)
self.btn_close = QPushButton()
self.btn_close.setProperty("compact", "true")
self.btn_close.clicked.connect(self.close)
actions.addWidget(self.btn_export)
actions.addWidget(self.btn_apply)
actions.addStretch(1)
actions.addWidget(self.btn_close)
root.addLayout(actions)
# The picker's own changes change the calibration context, so the dialog
# follows them while it is open.
self.picker.sensor_type_changed.connect(lambda _value: self.refresh())
self.picker.config_changed.connect(lambda _value: self.refresh())
self.retranslate()
self.refresh()
@staticmethod
def _section_rule() -> QFrame:
rule = QFrame()
rule.setObjectName("sectionSeparator")
rule.setFrameShape(QFrame.HLine)
rule.setFixedHeight(1)
return rule
# ------------------------------------------------------------------ actions
def _on_export(self) -> None:
self._window.cal_panel.export_coefficients()
self.refresh()
def _on_apply(self) -> None:
self._window.cal_panel.apply_coefficients()
self.refresh()
# ------------------------------------------------------------------ content
def refresh(self) -> None:
"""Re-read the shell's calibration state. Called on open and on change."""
window = self._window
version = str(getattr(window, "_active_calibration_id", "") or "")
self.version_label.setText(
f"{t('sensor.calib_version')}: {version or t('sensor.calib_none')}"
)
axes = ""
summary = getattr(window, "_calibration_summary", None)
if callable(summary):
axes = str(summary())
self.axes_label.setText(f"{t('sensor.calib_axes')}: {axes or t('sensor.calib_none')}")
config_ref = ""
resolve = getattr(window, "_resolve_config_ref", None)
if callable(resolve):
config_ref = str(resolve() or "")
self.config_label.setText(
f"{t('sensor.calib_config')}: "
f"{Path(config_ref).name if config_ref else t('status.config_none')}"
)
self.btn_reveal.setEnabled(bool(config_ref))
export = str(getattr(window, "_last_calibration_export", "") or "")
if not export:
self.export_label.setText(t("sensor.export_none"))
self.identity_label.setText("")
self.btn_apply.setEnabled(False)
return
path = Path(export)
self.export_label.setText(
t(
"sensor.export_file",
name=path.name,
size=f"{path.stat().st_size / 1024:.1f}" if path.is_file() else "?",
time=(
datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
if path.is_file()
else "?"
),
)
)
identity = {}
read_identity = getattr(window, "calibration_identity", None)
if callable(read_identity):
identity = dict(read_identity(export) or {})
self.identity_label.setText(
t(
"sensor.export_identity",
sensor_type=identity.get("sensor_type") or "?",
sn=identity.get("sn") or "?",
config=Path(str(identity.get("config_ref") or "?")).name,
)
)
self.btn_apply.setEnabled(True)
def retranslate(self) -> None:
self.setWindowTitle(t("sensor.dialog_title"))
self.calib_title.setText(t("sensor.calib_section"))
self.export_title.setText(t("sensor.export_section"))
self.btn_reveal.setText(t("menu.reveal_config"))
self.btn_export.setText(t("cal.export"))
self.btn_export.setToolTip(t("cal.export_tip"))
self.btn_apply.setText(t("cal.apply"))
self.btn_apply.setToolTip(t("cal.apply_tip"))
self.btn_close.setText(t("dialog.cancel"))
self.picker.retranslate()
self.refresh()
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
def showEvent(self, event) -> None: # noqa: N802 - Qt naming
# Every open re-reads the shell: the device may have confirmed a
# calibration since the dialog was last shown.
self.refresh()
super().showEvent(event)
+127 -117
View File
@@ -12,20 +12,16 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QComboBox, QComboBox,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit,
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QSizePolicy, QSizePolicy,
QSpinBox, QSpinBox,
QStackedWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@@ -77,8 +73,10 @@ class ToolbarWidget(QWidget):
search_clicked = pyqtSignal() search_clicked = pyqtSignal()
preview_toggled = pyqtSignal(bool) preview_toggled = pyqtSignal(bool)
camera_id_changed = pyqtSignal(int) camera_id_changed = pyqtSignal(int)
source_mode_changed = pyqtSignal(str) #: The sensor summary button: opens the sensor and calibration dialog.
browse_video_clicked = pyqtSignal() sensor_clicked = pyqtSignal()
#: "Back to the camera" while a video file is the run source.
use_camera_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool) run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal() reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str) units_changed = pyqtSignal(str)
@@ -91,7 +89,6 @@ class ToolbarWidget(QWidget):
parent=None, parent=None,
*, *,
theme: str = "dark", theme: str = "dark",
sensor_picker: QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._theme = str(theme) self._theme = str(theme)
@@ -99,6 +96,13 @@ class ToolbarWidget(QWidget):
self._run_available = True self._run_available = True
self._run_phase = "idle" self._run_phase = "idle"
self._settings_expanded = False self._settings_expanded = False
#: "camera" (the default) or "video": an offline acquisition run fed from
#: a video file. There is no source *combobox* any more — the view
#: decides which picker is on screen, and the file menu switches modes.
self._source_mode = "camera"
self._source_view = "live"
self._source_controls_enabled = True
self._video_path = ""
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2) outer.setContentsMargins(2, 2, 2, 2)
@@ -107,7 +111,7 @@ class ToolbarWidget(QWidget):
# The rail is always visible and never scrolls: it is one instrument # The rail is always visible and never scrolls: it is one instrument
# line, and a scroll area around it would reserve a scrollbar's height # line, and a scroll area around it would reserve a scrollbar's height
# (30 px in the Fusion metrics) even when nothing scrolls. # (30 px in the Fusion metrics) even when nothing scrolls.
outer.addLayout(self._build_rail(sensor_picker)) outer.addLayout(self._build_rail())
# Only the settings area scrolls; it is the part that can outgrow a # Only the settings area scrolls; it is the part that can outgrow a
# narrow window. # narrow window.
@@ -120,7 +124,6 @@ class ToolbarWidget(QWidget):
self._settings_scroll.setWidget(self.settings_page) self._settings_scroll.setWidget(self.settings_page)
outer.addWidget(self._settings_scroll) outer.addWidget(self._settings_scroll)
self._populate_source_combo()
self._populate_units_combo() self._populate_units_combo()
self._populate_record_modes() self._populate_record_modes()
self.retranslate() self.retranslate()
@@ -128,25 +131,28 @@ class ToolbarWidget(QWidget):
self.set_settings_expanded(False) self.set_settings_expanded(False)
# --------------------------------------------------------------- building # --------------------------------------------------------------- building
def _build_rail(self, sensor_picker: QWidget | None) -> QHBoxLayout: def _build_rail(self) -> QHBoxLayout:
"""Sensor choice, phase, device, calibration, validity, record, actions. """Sensor, phase, capture source, calibration, validity, record, actions.
The sensor picker leads the line: which product and config the window is The sensor choice is one summary button (the pickers themselves live in
configured for is the first thing to read, and it is what everything the sensor dialog), and the capture source is the picker the *current
else (units, plots, calibration) depends on. view* needs: a camera id while acquiring, a video file while running
from a file. Neither is a "source mode" combobox to be discovered.
""" """
rail = QHBoxLayout() rail = QHBoxLayout()
rail.setSpacing(8) rail.setSpacing(8)
if sensor_picker is not None: self.btn_sensor = QPushButton()
rail.addWidget(sensor_picker) self.btn_sensor.setObjectName("ghostButton")
rail.addWidget(_separator()) self.btn_sensor.setProperty("compact", "true")
self.btn_sensor.setMinimumHeight(BUTTON_HEIGHT)
self.btn_sensor.clicked.connect(self.sensor_clicked.emit)
self.status_chip = QLabel() self.status_chip = QLabel()
self.status_chip.setObjectName("statusChip") self.status_chip.setObjectName("statusChip")
self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred) self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
self.sensor_summary = ShrinkableLabel() self.source_summary = ShrinkableLabel()
self.sensor_summary.setObjectName("panelMeta") self.source_summary.setObjectName("panelMeta")
self.calibration_label = ShrinkableLabel() self.calibration_label = ShrinkableLabel()
self.calibration_label.setObjectName("panelMeta") self.calibration_label.setObjectName("panelMeta")
self.data_state = ShrinkableLabel() self.data_state = ShrinkableLabel()
@@ -154,6 +160,42 @@ class ToolbarWidget(QWidget):
self.data_state.setVisible(False) self.data_state.setVisible(False)
self.record_chip = RecordStatusChip(theme=self._theme) self.record_chip = RecordStatusChip(theme=self._theme)
# --- capture source (one of the two groups is visible) -------------
self.source_slot = QWidget()
slot = QHBoxLayout(self.source_slot)
slot.setContentsMargins(0, 0, 0, 0)
slot.setSpacing(6)
self.camera_group = QWidget()
cam_layout = QHBoxLayout(self.camera_group)
cam_layout.setContentsMargins(0, 0, 0, 0)
cam_layout.setSpacing(6)
self.cam_id_label = QLabel()
self.cam_id_label.setObjectName("fieldLabel")
self.cam_id_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0)
self.cam_id_spin.setFixedWidth(64)
self.cam_id_spin.setProperty("rail", "true")
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
self.btn_search = QPushButton()
self.btn_search.setProperty("compact", "true")
self.btn_search.setMinimumHeight(BUTTON_HEIGHT)
self.btn_search.clicked.connect(self.search_clicked.emit)
cam_layout.addWidget(self.cam_id_label)
cam_layout.addWidget(self.cam_id_spin)
cam_layout.addWidget(self.btn_search)
self.video_group = QWidget()
video_layout = QHBoxLayout(self.video_group)
video_layout.setContentsMargins(0, 0, 0, 0)
video_layout.setSpacing(6)
self.btn_use_camera = QPushButton()
self.btn_use_camera.setProperty("compact", "true")
self.btn_use_camera.setMinimumHeight(BUTTON_HEIGHT)
self.btn_use_camera.clicked.connect(self.use_camera_clicked.emit)
video_layout.addWidget(self.btn_use_camera)
slot.addWidget(self.camera_group)
slot.addWidget(self.video_group)
self.btn_record = QPushButton() self.btn_record = QPushButton()
self.btn_record.setObjectName("primaryButton") self.btn_record.setObjectName("primaryButton")
self.btn_record.setMinimumHeight(BUTTON_HEIGHT) self.btn_record.setMinimumHeight(BUTTON_HEIGHT)
@@ -181,9 +223,12 @@ class ToolbarWidget(QWidget):
self.btn_settings.setCheckable(True) self.btn_settings.setCheckable(True)
self.btn_settings.toggled.connect(self._on_settings_toggled) self.btn_settings.toggled.connect(self._on_settings_toggled)
rail.addWidget(_rail(self.btn_sensor))
rail.addWidget(_separator())
rail.addWidget(self.status_chip) rail.addWidget(self.status_chip)
rail.addWidget(_separator()) rail.addWidget(_separator())
rail.addWidget(self.sensor_summary) rail.addWidget(self.source_slot)
rail.addWidget(self.source_summary)
rail.addWidget(self.calibration_label) rail.addWidget(self.calibration_label)
rail.addWidget(self.data_state) rail.addWidget(self.data_state)
rail.addStretch(1) rail.addStretch(1)
@@ -195,62 +240,16 @@ class ToolbarWidget(QWidget):
return rail return rail
def _build_settings(self) -> QWidget: def _build_settings(self) -> QWidget:
"""Source, camera/video, search, preview and units. """Units, record mode and preview.
The sensor picker is *not* here: it leads the status rail, because the The capture source is on the rail (see ``_build_rail``) because it is
product choice is session state, not a setting to dig for. what the current view is *about*, not a setting to dig for.
""" """
self.settings_page = QWidget() self.settings_page = QWidget()
page = QVBoxLayout(self.settings_page) page = QVBoxLayout(self.settings_page)
page.setContentsMargins(0, 2, 0, 0) page.setContentsMargins(0, 2, 0, 0)
page.setSpacing(6) page.setSpacing(6)
# --- source group -------------------------------------------------
self.source_label = QLabel()
self.source_label.setObjectName("fieldLabel")
self.source_combo = QComboBox()
self.source_combo.setMinimumWidth(96)
self.source_combo.setMinimumHeight(CONTROL_HEIGHT)
self.source_combo.currentIndexChanged.connect(self._on_source_index_changed)
self.source_stack = QStackedWidget()
cam_page = QWidget()
cam_layout = QHBoxLayout(cam_page)
cam_layout.setContentsMargins(0, 0, 0, 0)
cam_layout.setSpacing(8)
self.cam_id_label = QLabel()
self.cam_id_label.setObjectName("fieldLabel")
self.cam_id_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0)
self.cam_id_spin.setFixedWidth(72)
self.cam_id_spin.setMinimumHeight(CONTROL_HEIGHT)
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
cam_layout.addWidget(self.cam_id_label)
cam_layout.addWidget(self.cam_id_spin)
video_page = QWidget()
video_layout = QHBoxLayout(video_page)
video_layout.setContentsMargins(0, 0, 0, 0)
video_layout.setSpacing(8)
self.video_edit = QLineEdit()
self.video_edit.setMinimumWidth(180)
self.video_edit.setMinimumHeight(CONTROL_HEIGHT)
self.video_edit.setReadOnly(True)
self.btn_browse_video = QPushButton()
self.btn_browse_video.setMinimumHeight(BUTTON_HEIGHT)
self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit)
video_layout.addWidget(self.video_edit, 1)
video_layout.addWidget(self.btn_browse_video)
self.source_stack.addWidget(cam_page)
self.source_stack.addWidget(video_page)
# --- action group -------------------------------------------------
self.btn_search = QPushButton()
self.btn_search.setMinimumHeight(BUTTON_HEIGHT)
self.btn_search.clicked.connect(self.search_clicked.emit)
self.btn_preview = QPushButton() self.btn_preview = QPushButton()
self.btn_preview.setMinimumHeight(BUTTON_HEIGHT) self.btn_preview.setMinimumHeight(BUTTON_HEIGHT)
self.btn_preview.setCheckable(True) self.btn_preview.setCheckable(True)
@@ -276,11 +275,7 @@ class ToolbarWidget(QWidget):
top_row = QHBoxLayout() top_row = QHBoxLayout()
top_row.setSpacing(10) top_row.setSpacing(10)
top_row.addWidget( top_row.addWidget(self.btn_preview)
_group(self.source_label, self.source_combo, self.source_stack)
)
top_row.addWidget(_separator())
top_row.addWidget(_group(self.btn_search, self.btn_preview))
top_row.addWidget(_separator()) top_row.addWidget(_separator())
top_row.addWidget(_group(self.units_label, self.units_combo)) top_row.addWidget(_group(self.units_label, self.units_combo))
top_row.addWidget(_separator()) top_row.addWidget(_separator())
@@ -307,10 +302,16 @@ class ToolbarWidget(QWidget):
self.record_chip.set_theme(self._theme) self.record_chip.set_theme(self._theme)
def set_sensor_summary(self, text: str) -> None: def set_sensor_summary(self, text: str) -> None:
self.sensor_summary.setText(str(text or "")) """The rail's sensor button text (``BOX · box/sensor.json``)."""
self.btn_sensor.setText(str(text or ""))
self.btn_sensor.setToolTip(t("sensor.dialog_tip"))
def set_source_summary(self, text: str) -> None:
"""Extra detail about the capture source (fps, video/recording name)."""
self.source_summary.setText(str(text or ""))
# The rail may squeeze this label; the tooltip always carries it whole. # The rail may squeeze this label; the tooltip always carries it whole.
self.sensor_summary.setToolTip(self.sensor_summary.text()) self.source_summary.setToolTip(self.source_summary.text())
self.sensor_summary.setVisible(bool(text)) self.source_summary.setVisible(bool(text))
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 ""))
@@ -334,19 +335,24 @@ class ToolbarWidget(QWidget):
self.record_chip.set_state(*args, **kwargs) self.record_chip.set_state(*args, **kwargs)
def source_mode(self) -> str: def source_mode(self) -> str:
data = self.source_combo.currentData() """``camera`` or ``video``: what a run would acquire from."""
return str(data or "camera") return self._source_mode
def set_source_mode(self, mode: str) -> None: def set_source_mode(self, mode: str) -> None:
wanted = "video" if str(mode).lower().startswith("v") else "camera" self._source_mode = "video" if str(mode).lower().startswith("v") else "camera"
idx = self.source_combo.findData(wanted)
if idx < 0:
idx = 0
self.source_combo.blockSignals(True)
self.source_combo.setCurrentIndex(idx)
self.source_combo.blockSignals(False)
self._apply_source_mode_ui() self._apply_source_mode_ui()
def set_source_view(self, view: str) -> None:
"""Which picker the current view needs (``live`` or ``replay``)."""
wanted = "replay" if str(view) == "replay" else "live"
if wanted == self._source_view:
return
self._source_view = wanted
self._apply_source_mode_ui()
def source_view(self) -> str:
return self._source_view
def camera_id(self) -> int: def camera_id(self) -> int:
return int(self.cam_id_spin.value()) return int(self.cam_id_spin.value())
@@ -356,12 +362,11 @@ class ToolbarWidget(QWidget):
self.cam_id_spin.blockSignals(False) self.cam_id_spin.blockSignals(False)
def video_path(self) -> str: def video_path(self) -> str:
return self.video_edit.text().strip() return str(self._video_path or "")
def set_video_path(self, path: str) -> None: def set_video_path(self, path: str) -> None:
text = str(path or "").strip() self._video_path = str(path or "").strip()
self.video_edit.setText(text) self._apply_source_mode_ui()
self.video_edit.setToolTip(text)
def units(self) -> str: def units(self) -> str:
data = self.units_combo.currentData() data = self.units_combo.currentData()
@@ -394,14 +399,21 @@ class ToolbarWidget(QWidget):
self.btn_reset.setEnabled(bool(enabled)) self.btn_reset.setEnabled(bool(enabled))
def set_camera_controls_enabled(self, enabled: bool) -> None: def set_camera_controls_enabled(self, enabled: bool) -> None:
self.btn_search.setEnabled(enabled and self.source_mode() == "camera") live = self._source_view == "live"
self.cam_id_spin.setEnabled(enabled and self.source_mode() == "camera") camera = self.source_mode() == "camera"
allowed = bool(enabled) and live
self.btn_search.setEnabled(allowed and camera)
self.cam_id_spin.setEnabled(allowed and camera)
self.btn_use_camera.setEnabled(allowed and not camera)
def set_source_controls_enabled(self, enabled: bool) -> None: def set_source_controls_enabled(self, enabled: bool) -> None:
self.source_combo.setEnabled(enabled) self._source_controls_enabled = bool(enabled)
self.btn_browse_video.setEnabled(enabled and self.source_mode() == "video") self.set_camera_controls_enabled(self._source_controls_enabled)
self.set_camera_controls_enabled(enabled) self.btn_preview.setEnabled(
self.btn_preview.setEnabled(enabled and self.source_mode() == "camera") self._source_controls_enabled
and self._source_view == "live"
and self.source_mode() == "camera"
)
def set_run_enabled(self, enabled: bool) -> None: def set_run_enabled(self, enabled: bool) -> None:
self._run_available = bool(enabled) self._run_available = bool(enabled)
@@ -430,13 +442,6 @@ class ToolbarWidget(QWidget):
self._refresh_run_label() self._refresh_run_label()
# ------------------------------------------------------------- internals # ------------------------------------------------------------- internals
def _populate_source_combo(self) -> None:
self.source_combo.blockSignals(True)
self.source_combo.clear()
self.source_combo.addItem("", "camera")
self.source_combo.addItem("", "video")
self.source_combo.blockSignals(False)
def _populate_units_combo(self) -> None: def _populate_units_combo(self) -> None:
current = self.units_combo.currentData() current = self.units_combo.currentData()
self.units_combo.blockSignals(True) self.units_combo.blockSignals(True)
@@ -510,16 +515,27 @@ class ToolbarWidget(QWidget):
self._refresh_emphasis() self._refresh_emphasis()
def _apply_source_mode_ui(self) -> None: def _apply_source_mode_ui(self) -> None:
"""Show the picker the current view needs, and nothing else.
Live + camera: the camera id and its search button. Live + video: a
one-click way back to the camera (the file itself is chosen from the
file menu and named by the rail's summary). Replay: neither — the replay
bar owns the recording picker.
"""
live = self._source_view == "live"
video = self.source_mode() == "video" video = self.source_mode() == "video"
self.source_stack.setCurrentIndex(1 if video else 0) self.camera_group.setVisible(live and not video)
self.btn_search.setEnabled(not video) self.video_group.setVisible(live and video)
self.btn_preview.setEnabled(not video) self.source_slot.setVisible(live)
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)
self._refresh_source_tooltips()
def _on_source_index_changed(self, _index: int) -> None: def _refresh_source_tooltips(self) -> None:
self._apply_source_mode_ui() path = self.video_path()
self.source_mode_changed.emit(self.source_mode()) self.source_summary.setToolTip(path or self.source_summary.text())
self.btn_use_camera.setToolTip(t("toolbar.use_camera_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()
@@ -600,21 +616,15 @@ class ToolbarWidget(QWidget):
self.btn_run.setToolTip(tip) self.btn_run.setToolTip(tip)
def retranslate(self) -> None: def retranslate(self) -> None:
self.source_label.setText(t("toolbar.source"))
self.source_combo.setItemText(0, t("toolbar.source_camera"))
self.source_combo.setItemText(1, t("toolbar.source_video"))
self.cam_id_label.setText(t("toolbar.cam_id")) self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_browse_video.setText(t("toolbar.browse_video"))
path = self.video_path()
self.video_edit.setPlaceholderText(
Path(path).name if path else t("toolbar.video_placeholder")
)
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.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"))
self.units_combo.setItemText(0, t("toolbar.units_newton")) self.units_combo.setItemText(0, t("toolbar.units_newton"))
self.units_combo.setItemText(1, t("toolbar.units_raw")) self.units_combo.setItemText(1, t("toolbar.units_raw"))
self._refresh_source_tooltips()
for index, (value, key) in enumerate(RECORD_MODES): for index, (value, key) in enumerate(RECORD_MODES):
if index < self.record_mode_combo.count(): if index < self.record_mode_combo.count():
self.record_mode_combo.setItemText(index, t(key)) self.record_mode_combo.setItemText(index, t(key))
+532 -41
View File
@@ -856,11 +856,12 @@ def test_the_depth_request_belongs_to_the_recording_it_started_in(make_window, r
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from orisys_app_shared.record.depth import DepthResult from orisys_app_shared.record.depth import DepthResult
from orisys_studio.ui.playback_dialog import PlaybackDialog from orisys_studio.replay import ReplayController
window = _show(make_window(), (1200, 700)) window = _show(make_window(), (1200, 700))
dialog = PlaybackDialog(theme="dark", directory=Path(record_root)) replay = ReplayController()
first_serial = dialog._record_serial replay.open_recording(Path(record_root))
first_serial = replay._record_serial
started = _threading.Event() started = _threading.Event()
release = _threading.Event() release = _threading.Event()
@@ -881,14 +882,14 @@ def test_the_depth_request_belongs_to_the_recording_it_started_in(make_window, r
closed.append("closed") closed.append("closed")
blocking = _Blocking() blocking = _Blocking()
dialog._recomputer = blocking replay._recomputer = blocking
dialog._on_recompute_depth() replay.request_depth()
assert started.wait(5.0), "the depth request never started" assert started.wait(5.0), "the depth request never started"
# Another recording is opened while the request is in flight. # Another recording is opened while the request is in flight.
dialog.open_recording(Path(record_root)) replay.open_recording(Path(record_root))
assert dialog._record_serial == first_serial + 1 assert replay._record_serial == first_serial + 1
assert dialog._recomputer is not blocking, "the processor was not replaced" assert replay._recomputer is not blocking, "the processor was not replaced"
assert closed == [], "the UI thread closed a processor a worker was reading" assert closed == [], "the UI thread closed a processor a worker was reading"
release.set() release.set()
@@ -897,15 +898,14 @@ def test_the_depth_request_belongs_to_the_recording_it_started_in(make_window, r
QApplication.processEvents() QApplication.processEvents()
_time.sleep(0.01) _time.sleep(0.01)
assert closed == ["closed"], "the retired processor was never closed" assert closed == ["closed"], "the retired processor was never closed"
assert dialog._depth_thread is not None and not dialog._depth_thread.is_alive() assert replay._depth_thread is not None and not replay._depth_thread.is_alive()
# The answer belongs to the previous recording, so it is dropped, not drawn. # The answer belongs to the previous recording, so it is dropped, not drawn.
dialog._poll_depth() replay._poll_depth()
QApplication.processEvents() QApplication.processEvents()
assert dialog._depth_ok is False assert replay._depth_ok is False
pixmap = dialog.depth_label.pixmap() assert replay.depth_image() is None, "an answer for another recording was drawn"
assert pixmap is None or pixmap.isNull(), "an answer for another recording was drawn" replay.close()
dialog.close()
window.close() window.close()
@@ -916,10 +916,11 @@ def test_a_busy_depth_processor_is_not_closed_by_the_ui_thread(make_window, reco
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from orisys_app_shared.record.depth import DepthResult from orisys_app_shared.record.depth import DepthResult
from orisys_studio.ui.playback_dialog import PlaybackDialog from orisys_studio.replay import ReplayController
window = _show(make_window(), (1200, 700)) window = _show(make_window(), (1200, 700))
dialog = PlaybackDialog(theme="dark", directory=Path(record_root)) replay = ReplayController()
replay.open_recording(Path(record_root))
started = _threading.Event() started = _threading.Event()
release = _threading.Event() release = _threading.Event()
closed: list[str] = [] closed: list[str] = []
@@ -936,12 +937,12 @@ def test_a_busy_depth_processor_is_not_closed_by_the_ui_thread(make_window, reco
def close(self) -> None: def close(self) -> None:
closed.append("closed") closed.append("closed")
dialog._recomputer = _Blocking() replay._recomputer = _Blocking()
dialog._on_recompute_depth() replay.request_depth()
assert started.wait(5.0) assert started.wait(5.0)
dialog.close() # closing the window must not free a processor in use replay.close() # closing must not free a processor in use
QApplication.processEvents() QApplication.processEvents()
assert closed == [], "the window closed a processor that was still reading" assert closed == [], "the controller closed a processor that was still reading"
release.set() release.set()
deadline = _time.monotonic() + 10.0 deadline = _time.monotonic() + 10.0
while not closed and _time.monotonic() < deadline: while not closed and _time.monotonic() < deadline:
@@ -1084,6 +1085,31 @@ def record_root():
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True) shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
@pytest.fixture
def replay_recording(record_root):
"""One short finished recording (video + forces + frames + session.json)."""
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=30.0))
recording = recorder.start(
root=record_root,
session_id=1,
metadata={
"device": {
"sensor_type": "box",
"config_ref": str(record_root / "sensor.json"),
"config_sha256": "recorded",
},
"versions": {"sdk": "0.5.1"},
"depth": {"stored": False, "replay": "recompute"},
},
)
for index in range(60):
recorder.on_packet(ui_check._fake_packet(index))
recorder.stop()
yield Path(recording)
def test_recording_writes_the_documented_experiment_layout(make_window, record_root): def test_recording_writes_the_documented_experiment_layout(make_window, record_root):
import csv import csv
import json import json
@@ -1171,8 +1197,8 @@ def test_top_area_stays_compact(make_window):
QApplication.processEvents() QApplication.processEvents()
assert window.toolbar.settings_expanded() assert window.toolbar.settings_expanded()
assert window.toolbar.source_combo.isVisible() assert window.toolbar.units_combo.isVisible()
assert window.sensor_picker.isVisible() assert window.toolbar.btn_preview.isVisible()
expanded = window.brand_bar.height() + window.toolbar.height() expanded = window.brand_bar.height() + window.toolbar.height()
assert expanded > collapsed, "opening the settings area changed nothing" assert expanded > collapsed, "opening the settings area changed nothing"
assert window.act_settings.isChecked(), "the View menu does not follow the toggle" assert window.act_settings.isChecked(), "the View menu does not follow the toggle"
@@ -1353,7 +1379,79 @@ def test_overview_region_is_the_timeline_control(make_window):
_finish(window, stuck) _finish(window, stuck)
def test_arrow_legend_follows_the_view_mode(make_window): def test_the_charts_start_in_follow_mode(make_window):
"""Every live entry point resumes following; only browsing is a gesture."""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window, stuck = _running_window(make_window)
panel = window.force_plots
assert panel.mode() == "follow"
assert panel.mode_label.text() == t("chart.mode_follow")
for index in range(40):
stuck.emit(ui_check._fake_packet(index))
window._on_run_tick()
QApplication.processEvents()
assert panel.mode() == "follow", "live samples pushed the charts into browsing"
panel._unit = "raw"
window._on_units_changed("newton")
assert panel.mode() == "follow", "a unit switch left the charts browsing"
window._on_units_changed("raw")
assert panel.mode() == "follow"
# A new session is a live session again.
panel.show_all()
assert panel.mode() == "browse"
window._start_run()
window._run_timer.stop()
assert panel.mode() == "follow", "a new run did not resume following"
_finish(window, stuck)
def test_the_charts_zoom_and_pan_in_every_view(make_window):
"""Zoom/pan must be reachable from the live view, not only while browsing."""
from PyQt5.QtWidgets import QApplication
window, stuck = _running_window(make_window)
panel = window.force_plots
for index in range(60):
stuck.emit(ui_check._fake_packet(index))
window._on_run_tick()
QApplication.processEvents()
view_box = panel.plots[0].getViewBox()
assert panel.mode() == "follow"
assert view_box.state["mouseEnabled"][0], "the live chart ignores the mouse"
# The gesture signal is what pyqtgraph emits for a wheel or drag.
view_box.setXRange(0.5, 1.5, padding=0.0)
view_box.sigRangeChangedManually.emit([True, False])
QApplication.processEvents()
assert panel.mode() == "browse", "a wheel/drag gesture did not start browsing"
assert panel.window() == (0.5, 1.5), f"the gesture was not applied: {panel.window()}"
assert all(cursor.movable for cursor in panel.cursors)
# A programmatic change is not a gesture and must not switch the view.
panel.follow_latest()
view_box.setXRange(1.0, 1.5, padding=0.0)
QApplication.processEvents()
assert panel.mode() == "follow", "a programmatic range change started browsing"
# While browsing, dragging the shared cursor moves the playhead.
panel.show_all()
moved: list[float] = []
panel.cursor_moved.connect(moved.append)
panel.cursors[0].setValue(1.25)
QApplication.processEvents()
assert [round(cursor.value(), 2) for cursor in panel.cursors] == [1.25] * 3
assert moved and moved[-1] == 1.25, "the cursor drag did not notify the replay"
_finish(window, stuck)
def test_the_arrow_legend_follows_the_view_mode(make_window):
from orisys_app_shared.acquisition.arrows import arrow_display_scale from orisys_app_shared.acquisition.arrows import arrow_display_scale
assert arrow_display_scale("box") == 2.0 assert arrow_display_scale("box") == 2.0
@@ -1381,22 +1479,72 @@ def test_arrow_legend_follows_the_view_mode(make_window):
# ------------------------------------------------- top rail: sensor and views # ------------------------------------------------- top rail: sensor and views
def test_the_sensor_picker_leads_the_rail(make_window): def test_the_sensor_summary_opens_the_dialog(make_window):
"""Which product/config is selected must be readable, not buried.""" """The rail states the sensor; the pickers live in the dialog it opens."""
from PyQt5.QtWidgets import QApplication
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))
assert window.toolbar.btn_sensor.text() == t(
"sensor.summary", type="BOX", config="sensor.json"
)
assert window.toolbar.btn_sensor.toolTip() == t("sensor.dialog_tip")
assert not window.sensor_picker.isVisible(), "the pickers are still on the rail"
window.toolbar.btn_sensor.click()
QApplication.processEvents()
dialog = window.sensor_dialog
assert dialog is not None and dialog.isVisible()
picker = window.sensor_picker picker = window.sensor_picker
assert picker.isVisible(), "the sensor picker is not on the status rail" assert picker.isVisible(), "the sensor pickers are not in the dialog"
assert picker.type_label.text() == t("sensor.type") assert picker.type_label.text() == t("sensor.type")
assert picker.config_label.text() == t("sensor.config") assert picker.config_label.text() == t("sensor.config")
assert picker.thumb_label.text() == "BOX" assert dialog.calib_title.text() == t("sensor.calib_section")
assert picker.type_label.toolTip() and picker.config_label.toolTip() assert dialog.version_label.text() and dialog.axes_label.text()
assert dialog.export_label.text() == t("sensor.export_none")
assert not dialog.btn_apply.isEnabled(), "applying with no export"
# The two combos stay wired: switching the product reloads its configs. # Changing the product in the dialog reaches the window and the rail.
picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger")) picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger"))
assert picker.sensor_type() == "finger" QApplication.processEvents()
assert picker.config_name() assert window.prefs["sensor_type"] == "finger"
assert "FINGER" in window.toolbar.btn_sensor.text()
assert picker.sensor_type() == "finger" and picker.config_name()
picker.type_combo.setCurrentIndex(picker.type_combo.findData("box"))
QApplication.processEvents()
dialog.close()
def test_the_source_picker_follows_the_view(make_window):
"""Live offers a camera, a video-file run offers the way back, replay neither."""
from PyQt5.QtWidgets import QApplication
window, stuck = _running_window(make_window)
assert window.toolbar.camera_group.isVisible()
assert not window.toolbar.video_group.isVisible()
assert not hasattr(window.toolbar, "source_combo"), "the source is still a combobox"
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert not window.toolbar.camera_group.isVisible(), "the replay view shows a camera picker"
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert window.toolbar.camera_group.isVisible()
window.toolbar.set_video_path("C:/tmp/demo.mp4")
window._set_source_mode("video")
QApplication.processEvents()
assert window.toolbar.video_group.isVisible()
assert "demo.mp4" in window.toolbar.source_summary.text()
assert not window.toolbar.btn_preview.isEnabled(), "preview is offered for a video file"
window.toolbar.use_camera_clicked.emit()
QApplication.processEvents()
assert window.toolbar.source_mode() == "camera"
assert window.toolbar.video_path() == ""
_finish(window, stuck)
def test_the_nav_items_switch_the_main_window_layout(make_window): def test_the_nav_items_switch_the_main_window_layout(make_window):
@@ -1417,7 +1565,10 @@ 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()
assert window.force_plots.mode() == "browse", "the replay view did not open the session" # No recording is open yet: the bar says so, and the charts keep following
# the live session instead of pretending to replay something.
assert window.replay_bar.isVisible() and not window.replay.is_loaded()
assert window.force_plots.mode() == "follow", "browsing starts with no recording"
window.brand_bar.btn_live.click() window.brand_bar.btn_live.click()
assert window.view_preset() == "live" assert window.view_preset() == "live"
@@ -1461,21 +1612,361 @@ def test_the_2d_area_keeps_the_output_aspect_ratio(make_window):
) )
def test_the_playback_dialog_labels_its_buttons(make_window): def test_the_replay_bar_labels_its_controls(make_window):
"""The dialog's three controls had no text at all: it never retranslated.""" """The old replay window's controls had no text: it never retranslated."""
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_studio.ui.playback_dialog import PlaybackDialog window = _show(make_window(), (1440, 880))
bar = window.replay_bar
assert bar.btn_open.text() == t("playback.open")
assert bar.btn_open.toolTip() == t("playback.open_tip")
assert bar.btn_depth.text() == t("playback.recompute_depth")
assert bar.btn_depth.toolTip() == t("playback.recompute_depth_tip")
assert bar.btn_unit.text(), "the unit switch is unlabelled"
assert bar.slider.toolTip() == t("playback.scrub_tip")
assert bar.info_label.text() == t("playback.empty")
assert not bar.slider.isEnabled(), "the progress bar is live with no recording"
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."""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
dialog = PlaybackDialog(theme="dark", parent=window) window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window.replay_bar.isVisible(), "the replay view has no progress bar"
assert window._load_recording(replay_recording)
QApplication.processEvents()
panel = window.force_plots
assert panel.is_recording_source(), "the charts did not switch to the recording"
assert panel.mode() == "browse", "opening a recording did not browse it"
assert panel.mode_label.text() == t("chart.mode_replay")
assert not panel.btn_latest.isEnabled(), "a recording offers 'back to latest'"
drawn = [int(curve.getData()[0].size) for curve in panel.curves]
assert all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}"
assert window.arrow_panel.view_modes() == ("recorded", "depth")
assert window.replay.video_image() is not None, "no frame at the cursor"
assert window.replay_bar.slider.isEnabled()
# The progress bar is the playhead.
window.replay_bar.slider.setValue(window.replay_bar.slider.maximum() // 2)
QApplication.processEvents()
assert 0.0 < window.replay.position() < window.replay.duration_s()
assert abs(panel.cursors[0].value() - window.replay.position()) < 0.05
# Dragging the chart cursor scrubs the recording too.
panel.cursors[0].setValue(0.25)
QApplication.processEvents()
assert abs(window.replay.position() - 0.25) < 0.05
# Leaving the view returns the charts to the live session.
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert not panel.is_recording_source()
assert panel.mode() == "follow"
assert not window.replay_bar.isVisible()
assert window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth")
# ------------------------------------------------- correctness regressions
def test_a_late_calibration_does_not_plot_raw_counts_on_the_newton_axis(make_window):
"""A device confirms its calibration a frame or two after the first packets.
Those frames carry raw counts. ``push`` used to record the new unit and keep
the old samples, so the first thing drawn on the newton axis was a spike of
raw counts — the transient at the start of every session. Switching units is
a switch, never a reinterpretation: the raw samples are dropped from the
buffer (the store still holds them) and the sample that triggered the switch
is read out but not appended twice.
"""
from orisys_app_shared.i18n import t
window, stuck = _running_window(make_window)
plots = window.force_plots
# The preference is newton; an uncalibrated device falls back to raw.
assert window.toolbar.units() == "newton"
plots.reset()
for index in range(3):
packet = ui_check._fake_packet(index, calibrated_axes=())
packet.force_calibrated = False
stuck.emit(packet)
window._on_run_tick()
assert plots.unit() == "raw", f"an uncalibrated device is not shown raw: {plots.unit()}"
for index in range(3, 40):
stuck.emit(ui_check._fake_packet(index))
window._on_run_tick()
state = plots.history.state()
assert plots.unit() == "N", f"the unit did not follow the calibration: {plots.unit()}"
assert state is not None
assert plots.history.sample_count == 37, (
f"the raw frames stayed in the newton buffer: {plots.history.sample_count} samples"
)
peak = float(np.nanmax(np.abs(state.values[:, 0])))
assert peak < 5.0, f"a raw count is still drawn on the newton axis (peak {peak})"
# The sample that caused the switch still reaches the readouts; the early
# return used to leave the cards showing the previous sample.
assert abs(plots._last_values[0] - (2.0 + 39 * 0.01)) < 1e-9
assert plots.value_labels[0].text() == "2.390", plots.value_labels[0].text()
assert plots.value_labels[0].text() != t("chart.value_unavailable")
_finish(window, stuck)
def test_the_unit_chip_switches_the_charts_and_the_toolbar(make_window):
"""One click on the unit chip changes the unit everywhere, not just locally."""
from orisys_app_shared.i18n import t
from orisys_studio.ui.force_plots import unit_display
from PyQt5.QtWidgets import QPushButton
window = _show(make_window(), (1440, 880))
plots = window.force_plots
assert isinstance(plots.unit_badge, QPushButton), "the unit chip is not clickable"
assert plots.unit_badge.toolTip() == t("chart.unit_toggle_tip")
assert plots.unit_badge.accessibleName() == t("chart.unit_toggle")
before = plots.unit()
plots.unit_badge.click()
assert plots.unit() != before, "the unit chip did not switch the charts"
assert plots.unit_badge.text() == unit_display(plots.unit())
assert ("newton" if plots.unit() == "N" else "raw") == window.toolbar.units(), (
"the unit chip and the toolbar disagree about the unit"
)
plots.unit_badge.click()
assert plots.unit() == before, "the unit chip does not switch back"
def test_the_chart_actions_are_icons_that_still_say_what_they_do(make_window):
"""Symbols instead of words — but every one keeps its meaning in words.
The header actions (zoom to fit, box zoom, back to latest) are drawn at
runtime in the theme's text colour; a symbol with no tooltip or accessible
name would be unreadable, so both are part of the contract.
"""
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
plots = window.force_plots
for button, key in (
(plots.btn_overview, "chart.overview"),
(plots.btn_box_zoom, "chart.box_zoom"),
(plots.btn_latest, "chart.return_latest"),
):
assert button.text() == "", f"{key} still shows text: {button.text()!r}"
assert not button.icon().isNull(), f"{key} has no icon"
assert button.toolTip() == t(f"{key}_tip")
assert button.accessibleName() == t(key)
# The overview strip keeps its word: it labels a region, not an action.
assert plots.window_caption.text() == t("chart.overview")
# The arrow footer says what the factor multiplies.
footer = window.arrow_panel.scale_label.text()
assert "×" in footer, footer
assert "位移" in footer or "displacement" in footer, footer
# A theme switch re-tints the icons instead of leaving stale pixels.
window._set_theme("light")
for button in (plots.btn_overview, plots.btn_box_zoom, plots.btn_latest):
assert not button.icon().isNull(), "an icon vanished across a theme switch"
def test_a_recording_counts_up_while_it_is_running(make_window, record_root):
"""The elapsed time is the span captured so far, not just the final number."""
import orisys_studio.app_window as app_module
from orisys_app_shared.i18n import t
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: record_root
window.toolbar.btn_record.setChecked(True)
for index in range(96):
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
window._on_run_tick()
stats = window.recorder.stats()
assert stats.duration_s > 2.5, f"the live duration stayed at {stats.duration_s}"
assert window.toolbar.record_chip.text() != t(
"record.state_recording", duration="00:00.0"
), "the record strip does not count up while recording"
window.toolbar.btn_record.setChecked(False)
_finish(window, stuck)
def test_the_video_follows_the_sensor_rate_and_keeps_every_row(make_window, record_root):
"""The container rate is the sensor's, and pacing never thins the data.
The packet's ``fps`` is how fast the pipeline *finished* frames, not how fast
the sensor produces them: a burst at open (or a file source) reads 48 on a
30 fps sensor, and writing every delivered frame into a 30 fps container made
playback run at the wrong speed.
"""
import csv
import cv2
import orisys_studio.app_window as app_module
window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: record_root
assert abs(window._video_fps() - 30.0) < 0.01, window._video_fps()
window.toolbar.btn_record.setChecked(True)
for index in range(96):
# 48 frames per second of acquisition time, above the configured 30.
packet = ui_check._fake_packet(index, mono_t=index / 48.0)
packet.fps = 48.0
stuck.emit(packet)
window._on_run_tick()
window.toolbar.btn_record.setChecked(False)
report = window._record_last_report
assert report is not None and report.completed
assert report.stats.resampled_video > 0, "the extra frames were not reported"
assert any("faster than" in problem for problem in report.problems), report.problems
with (report.directory / "frames.csv").open(encoding="utf-8") as handle:
rows = list(csv.DictReader(handle))
assert len(rows) == 96, f"the video pacing thinned the data rows: {len(rows)}"
segments = sorted(report.directory.glob("raw_*.mp4"))
assert segments, "the recording wrote no video segment"
capture = cv2.VideoCapture(str(segments[0]))
try: try:
assert dialog.open_button.text() == t("playback.open") written = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
assert dialog.unit_button.text() container_fps = float(capture.get(cv2.CAP_PROP_FPS))
assert dialog.depth_button.text() == t("playback.recompute_depth")
assert dialog.depth_button.toolTip() == t("playback.recompute_depth_tip")
finally: finally:
dialog.close() capture.release()
assert abs(container_fps - 30.0) < 1.0, f"the video declares {container_fps} fps"
assert 55 <= written <= 65, f"the video holds {written} frames for 2 s at 30 fps"
_finish(window, stuck)
def test_a_replay_keeps_the_2d_pane_from_the_live_camera(
make_window, replay_recording
):
"""The pane belongs to the source the view is showing.
While the replay view is up, its frames own the 2D panel; a live packet used
to paint over the recording. Leaving the view hands the pane back.
"""
from PyQt5.QtWidgets import QApplication
window, stuck = _running_window(make_window)
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording), "the replay could not open it"
QApplication.processEvents()
assert window._replay_owns_image(), "the replay does not own the 2D pane"
label = window.arrow_panel.image_label
# Park the playhead at the end of the recording and remember what the pane and
# the cards show there, so a live packet that overwrote either would show up.
window.replay_bar.slider.setValue(window.replay_bar.slider.maximum())
QApplication.processEvents()
before_key = label.pixmap().cacheKey()
before_text = label.text()
before_card = window.force_plots.value_labels[0].text()
live = np.full((24, 32, 3), 200, np.uint8)
for index in range(3):
packet = ui_check._fake_packet(index, mono_t=index / 30.0, frame=live)
packet.normal_force = 111.0
packet.normal_force_raw = 999.0
stuck.emit(packet)
window._on_run_tick()
QApplication.processEvents()
assert label.pixmap().cacheKey() == before_key and label.text() == before_text, (
"a live frame overwrote the recording in the 2D pane"
)
assert window.force_plots.value_labels[0].text() == before_card, (
"a live sample overwrote the recording's readout"
)
# ...and the live view takes it back, which is what makes the check above
# meaningful rather than a dead pane.
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert not window._replay_owns_image()
stuck.emit(ui_check._fake_packet(9, mono_t=9 / 30.0, frame=live))
window._on_run_tick()
QApplication.processEvents()
assert label.pixmap().cacheKey() != before_key, "the live view stopped drawing"
_finish(window, stuck)
def test_the_readout_describes_the_point_the_cursor_is_on(
make_window, replay_recording
):
"""The three numbers are the cursor's sample, not the last live packet.
A replay used to state the live acquisition in cards sitting next to a chart
of 7.5e5 raw counts, which reads as "the replay never opened".
"""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording)
QApplication.processEvents()
plots = window.force_plots
assert plots.mode() == "browse", "opening a recording did not browse it"
slider = window.replay_bar.slider
slider.setValue(slider.minimum())
QApplication.processEvents()
first = plots.value_labels[0].text()
assert first not in ("", t("chart.value_unavailable")), first
slider.setValue(slider.maximum())
QApplication.processEvents()
last = plots.value_labels[0].text()
# The fixture's forces rise monotonically, so the readout must rise too: the
# cards are following the playhead rather than a constant.
assert float(last) > float(first), f"the readout ignores the cursor: {first} -> {last}"
assert float(first) != 0.0, "a recording is read out as a live zero"
assert abs(plots.cursor_time() - window.replay.position()) < 0.05
def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recording):
"""A newton preference must not empty a recording that has no calibration.
The recording's raw counts are always there; its newton triplet only exists
for the axes it calibrated. Asking for newton anyway drew three blank charts
and a dash in every card, which looks exactly like "the replay did not load".
"""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
window.toolbar.set_units("newton")
QApplication.processEvents()
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording)
QApplication.processEvents()
panel = window.force_plots
assert panel.unit() == "raw", "a raw-only recording was shown in newton"
assert window.toolbar.units() == "raw", "the toolbar disagrees with the charts"
assert window.replay_bar.btn_unit.text() == t("playback.show_newton"), (
"the replay bar's unit switch disagrees with the charts"
)
drawn = [int(curve.getData()[0].size) for curve in panel.curves]
assert all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}"
assert panel.value_labels[0].text() not in ("", t("chart.value_unavailable"))
+694 -98
View File
@@ -175,14 +175,19 @@ def _fake_packet(
timestamp: float | None = None, timestamp: float | None = None,
mono_t: float | None = None, mono_t: float | None = None,
shear_n: float | None = None, shear_n: float | None = None,
frame=None,
): ):
"""A packet shaped like the real worker's, for the record-channel checks.""" """A packet shaped like the real worker's, for the record-channel checks.
``frame`` replaces the blank 8x8 image, so a synthetic recording can carry
something visible (see ``ui_snapshot --scene replay``).
"""
import numpy as np import numpy as np
from orisys_app_shared.acquisition.sdk_worker import SdkPacket from orisys_app_shared.acquisition.sdk_worker import SdkPacket
nan = float("nan") nan = float("nan")
blank = np.zeros((8, 8, 3), np.uint8) blank = np.zeros((8, 8, 3), np.uint8) if frame is None else frame
# A single tall sample makes it easy to prove decimation keeps peaks. # A single tall sample makes it easy to prove decimation keeps peaks.
fn_raw = 99.0 if frame_id == 700 else (9.0 + frame_id * 0.01) fn_raw = 99.0 if frame_id == 700 else (9.0 + frame_id * 0.01)
return SdkPacket( return SdkPacket(
@@ -863,29 +868,26 @@ def main() -> int:
f"{theme}: the settings area starts expanded", f"{theme}: the settings area starts expanded",
) )
check(window.toolbar.status_chip.text().strip(), f"{theme}: the phase chip is empty") check(window.toolbar.status_chip.text().strip(), f"{theme}: the phase chip is empty")
# The sensor is one summary button on the rail; the pickers (and the
# calibration they belong to) live in the dialog it opens.
check( check(
window.sensor_picker.isVisible(), window.toolbar.btn_sensor.text() == t(
f"{theme}: the sensor picker is not on the status rail", "sensor.summary", type="BOX", config="sensor.json"
),
f"{theme}: the sensor summary is wrong: "
f"{window.toolbar.btn_sensor.text()!r}",
) )
check( check(
window.sensor_picker.type_label.text() == t("sensor.type") window.toolbar.btn_sensor.isVisible(),
and window.sensor_picker.config_label.text() == t("sensor.config"), f"{theme}: the sensor button is not on the status rail",
f"{theme}: the sensor picker labels are not the documented ones: "
f"{window.sensor_picker.type_label.text()!r} / "
f"{window.sensor_picker.config_label.text()!r}",
) )
check( check(
window.sensor_picker.thumb_label.text() == "BOX", window.toolbar.camera_group.isVisible(),
f"{theme}: the product badge does not name the sensor: " f"{theme}: the live view does not offer the camera picker",
f"{window.sensor_picker.thumb_label.text()!r}",
) )
check( check(
window.sensor_picker.config_label.toolTip() != "", not window.toolbar.video_group.isVisible(),
f"{theme}: the sensor config picker has no explanation", f"{theme}: the live camera view also shows the video-file group",
)
check(
window.toolbar.sensor_summary.text() != "",
f"{theme}: the rail does not name the capture source",
) )
check( check(
window.toolbar.calibration_label.text().strip(), window.toolbar.calibration_label.text().strip(),
@@ -913,6 +915,60 @@ def main() -> int:
f"{theme}: the status bar does not name the config file: " f"{theme}: the status bar does not name the config file: "
f"{window.device_label.text()!r}", f"{window.device_label.text()!r}",
) )
check(
not hasattr(window.toolbar, "source_combo"),
f"{theme}: the source mode is still a combobox",
)
# The sensor dialog owns the pickers and the calibration facts.
window._open_sensor_dialog()
for _ in range(3):
app.processEvents()
dialog = window.sensor_dialog
check(dialog is not None and dialog.isVisible(), f"{theme}: the sensor dialog did not open")
check(
window.sensor_picker.isVisible(),
f"{theme}: the sensor pickers are not reachable in the dialog",
)
check(
window.sensor_picker.type_label.text() == "传感器"
and window.sensor_picker.config_label.text() == "传感器配置",
f"{theme}: the sensor picker labels are not the documented ones: "
f"{window.sensor_picker.type_label.text()!r} / "
f"{window.sensor_picker.config_label.text()!r}",
)
check(
dialog.version_label.text() != "" and dialog.axes_label.text() != "",
f"{theme}: the dialog states no calibration",
)
check(
t("sensor.export_none") in dialog.export_label.text()
or dialog.btn_apply.isEnabled(),
f"{theme}: the export row states neither a file nor its absence: "
f"{dialog.export_label.text()!r}",
)
# Changing the product in the dialog reaches the window.
window.sensor_picker.type_combo.setCurrentIndex(
window.sensor_picker.type_combo.findData("finger")
)
for _ in range(3):
app.processEvents()
check(
window.prefs.get("sensor_type") == "finger",
f"{theme}: the dialog's product change did not reach the window",
)
check(
"FINGER" in window.toolbar.btn_sensor.text(),
f"{theme}: the rail summary did not follow the product change: "
f"{window.toolbar.btn_sensor.text()!r}",
)
window.sensor_picker.type_combo.setCurrentIndex(
window.sensor_picker.type_combo.findData("box")
)
for _ in range(3):
app.processEvents()
dialog.close()
app.processEvents()
# The settings area is one click away, and remembers that it was opened. # The settings area is one click away, and remembers that it was opened.
window.toolbar.btn_settings.setChecked(True) window.toolbar.btn_settings.setChecked(True)
@@ -925,12 +981,8 @@ def main() -> int:
f"({expanded_height} vs {rail_height})", f"({expanded_height} vs {rail_height})",
) )
check( check(
window.toolbar.source_combo.isVisible(), window.toolbar.units_combo.isVisible() and window.toolbar.btn_preview.isVisible(),
f"{theme}: the source control is unreachable in the settings area", f"{theme}: the unit and preview controls are unreachable",
)
check(
window.sensor_picker.isVisible(),
f"{theme}: the sensor picker is unreachable in the settings area",
) )
check( check(
window.prefs.get("settings_expanded") is True, window.prefs.get("settings_expanded") is True,
@@ -966,9 +1018,10 @@ def main() -> int:
f"en_US at 1280x720: the status rail squeezes {rail_squeezed(english_window)}", f"en_US at 1280x720: the status rail squeezes {rail_squeezed(english_window)}",
) )
check( check(
"FINGER" == english_window.sensor_picker.thumb_label.text(), t("sensor.summary", type="FINGER", config="sensor.json")
f"the product badge is truncated: " in english_window.toolbar.btn_sensor.text(),
f"{english_window.sensor_picker.thumb_label.text()!r}", f"the sensor summary is not stated in full: "
f"{english_window.toolbar.btn_sensor.text()!r}",
) )
english_window.close() english_window.close()
app.processEvents() app.processEvents()
@@ -1432,9 +1485,15 @@ 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
# live session instead of pretending to replay something.
check( check(
window.force_plots.mode() == "browse", window.replay_bar.isVisible() and not window.replay.is_loaded(),
"the replay view did not open on the whole session", "the replay view is not showing an empty progress bar",
)
check(
window.force_plots.mode() == "follow",
"the replay view browses before a recording is open",
) )
window.brand_bar.btn_live.click() window.brand_bar.btn_live.click()
@@ -1496,6 +1555,315 @@ def main() -> int:
window.close() window.close()
app.processEvents() app.processEvents()
section("chart header and unit chip")
from orisys_studio.ui.force_plots import unit_display
header_window = StudioMainWindow(
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
)
header_window.resize(1440, 880)
header_window.show()
for _ in range(3):
app.processEvents()
header_plots = header_window.force_plots
for button, name in (
(header_plots.btn_overview, "zoom to fit"),
(header_plots.btn_box_zoom, "box zoom"),
(header_plots.btn_latest, "back to latest"),
):
check(button.text() == "", f"the {name} action still shows text")
check(not button.icon().isNull(), f"the {name} action has no icon")
check(button.toolTip() != "", f"the {name} action has no tooltip")
check(button.accessibleName() != "", f"the {name} action has no accessible name")
check(
isinstance(header_plots.unit_badge, type(header_plots.btn_overview)),
"the unit chip is not a button",
)
check(
header_plots.unit_badge.toolTip() != "",
"the unit chip does not say what it does",
)
# The chip switches the same unit everywhere: the charts and the toolbar.
before_unit = header_plots.unit()
header_plots.unit_badge.click()
app.processEvents()
check(
header_plots.unit() != before_unit,
f"the unit chip did not switch the charts: {header_plots.unit()}",
)
check(
("newton" if header_plots.unit() == "N" else "raw")
== header_window.toolbar.units(),
"the unit chip and the toolbar disagree about the unit",
)
check(
header_plots.unit_badge.text() == unit_display(header_plots.unit()),
f"the chip does not state the active unit: {header_plots.unit_badge.text()!r}",
)
header_plots.unit_badge.click()
app.processEvents()
check(
header_plots.unit() == before_unit,
"the unit chip does not switch back",
)
# The arrow legend says what the factor multiplies.
check(
"×" in header_window.arrow_panel.scale_label.text(),
"the arrow footer lost its magnification",
)
check(
"位移" in header_window.arrow_panel.scale_label.text()
or "displacement" in header_window.arrow_panel.scale_label.text(),
f"the arrow footer does not say what is magnified: "
f"{header_window.arrow_panel.scale_label.text()!r}",
)
header_window.close()
app.processEvents()
section("acquisition correctness")
import csv as _csv
import shutil as _shutil
import numpy as np
# A device that only confirms its calibration a moment after the first
# frames reports raw counts for those frames. They must never be drawn on the
# newton axis: that was the tall transient at the start of every session.
stuck = _StuckWorker()
window = StudioMainWindow(
prefs={
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
)
window.resize(1440, 880)
window.show()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
for _ in range(3):
app.processEvents()
window._start_run()
window._run_timer.stop()
window._set_phase("running")
plots = window.force_plots
for index in range(3):
packet = _fake_packet(index, calibrated_axes=())
packet.force_calibrated = False
stuck.emit(packet)
window._on_run_tick()
check(
plots.unit() == "raw",
f"an uncalibrated device is not shown in raw units: {plots.unit()}",
)
for index in range(3, 40):
stuck.emit(_fake_packet(index))
window._on_run_tick()
state = plots.history.state()
check(plots.unit() == "N", f"the unit did not follow the calibration: {plots.unit()}")
check(
plots.history.sample_count == 37,
f"the raw frames were kept in the newton buffer: "
f"{plots.history.sample_count} samples",
)
peak = (
float(np.nanmax(np.abs(state.values[:, 0]))) if state is not None else float("inf")
)
check(
state is not None and peak < 5.0,
f"a raw count is still drawn on the newton axis (peak {peak})",
)
# A new session must not inherit the previous one's samples.
window._stop_run(reason="user_stop")
window._start_run()
window._run_timer.stop()
window._set_phase("running")
check(
plots.history.sample_count == 0 and plots.mode() == "follow",
f"a new session started with {plots.history.sample_count} old samples",
)
# While recording, the reported duration is the span captured so far: the
# record strip used to sit at 00:00.0 for the whole recording.
app_module = sys.modules["orisys_studio.app_window"]
scratch = APP_ROOT / "outputs" / "_correctness_check"
_shutil.rmtree(scratch, ignore_errors=True)
scratch.mkdir(parents=True, exist_ok=True)
original_root = app_module.recordings_dir
app_module.recordings_dir = lambda: scratch
try:
window.toolbar.btn_record.setChecked(True)
app.processEvents()
check(window.recorder.is_recording, "the recording did not start")
for index in range(96):
# 48 frames per second of acquisition time: above the 30 fps the
# sensor config declares, which is what the container must follow.
packet = _fake_packet(index, mono_t=index / 48.0)
packet.fps = 48.0
stuck.emit(packet)
window._on_run_tick()
live_stats = window.recorder.stats()
check(
live_stats.duration_s > 1.5,
f"the live recording duration stayed at {live_stats.duration_s}",
)
check(
"00:0" not in window.toolbar.record_chip.text()
or window.toolbar.record_chip.text()
!= t("record.state_recording", duration="00:00.0"),
f"the record strip does not count up: "
f"{window.toolbar.record_chip.text()!r}",
)
check(
abs(window._video_fps() - 30.0) < 0.01,
f"the video rate is not the sensor config's: {window._video_fps()}",
)
window.toolbar.btn_record.setChecked(False)
app.processEvents()
report = window._record_last_report
check(report is not None, "no record report was published")
if report is not None:
check(
report.stats.resampled_video > 0,
"the faster-than-configured frames were not reported",
)
with (report.directory / "frames.csv").open(encoding="utf-8") as handle:
rows = list(_csv.DictReader(handle))
check(
len(rows) == 96,
f"the frame rows were thinned by the video pacing: {len(rows)}",
)
check(
any("faster than" in problem for problem in report.problems),
f"the report does not explain the resampled frames: {report.problems}",
)
import cv2 as _cv2
segments = sorted(report.directory.glob("raw_*.mp4"))
check(bool(segments), "the recording wrote no video segment")
if segments:
capture = _cv2.VideoCapture(str(segments[0]))
try:
written = int(capture.get(_cv2.CAP_PROP_FRAME_COUNT))
container_fps = float(capture.get(_cv2.CAP_PROP_FPS))
finally:
capture.release()
check(
abs(container_fps - 30.0) < 1.0,
f"the video declares {container_fps} fps, not the sensor's 30",
)
check(
55 <= written <= 65,
f"the video holds {written} frames for 2 s at 30 fps",
)
finally:
app_module.recordings_dir = original_root
_shutil.rmtree(scratch, ignore_errors=True)
window._stop_run(reason="user_stop")
stuck.release()
app.processEvents()
section("replay owns the 2D pane")
# A replay running next to a live session must not be overwritten by the
# camera: the pane belongs to whichever source the view is showing.
replay_root = APP_ROOT / "outputs" / "_pane_check"
_shutil.rmtree(replay_root, ignore_errors=True)
replay_root.mkdir(parents=True, exist_ok=True)
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0))
recording = recorder.start(root=replay_root, session_id=1, metadata={})
for index in range(20):
# A raw-only recording: no calibration curve, so the calibrated columns
# stay empty. Showing that in newton mode would draw three blank charts.
packet = _fake_packet(index)
packet.force_calibrated = False
recorder.on_packet(packet)
recorder.stop()
stuck = _StuckWorker()
pane_window = StudioMainWindow(
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
)
pane_window.resize(1440, 880)
pane_window.show()
pane_window._ensure_workers = lambda: None
pane_window._box_worker = stuck
pane_window._finger_worker = stuck
pane_window._start_run()
pane_window._run_timer.stop()
pane_window._set_phase("running")
pane_window.toolbar.set_units("newton")
app.processEvents()
pane_window.brand_bar.btn_playback.click()
app.processEvents()
check(pane_window._load_recording(recording), "the replay could not open the recording")
app.processEvents()
check(pane_window._replay_owns_image(), "the replay does not own the 2D pane")
# The recording has no calibrated axis, so newton would be an empty chart.
check(
pane_window.force_plots.unit() == "raw",
f"a raw-only recording was shown in {pane_window.force_plots.unit()}",
)
check(
pane_window.toolbar.units() == "raw",
"the toolbar and the charts disagree about a recording's unit",
)
check(
pane_window.replay_bar.btn_unit.text() == t("playback.show_newton"),
"the replay bar's unit switch disagrees with the charts",
)
drawn = [int(curve.getData()[0].size) for curve in pane_window.force_plots.curves]
check(all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}")
# The cards read the point under the playhead, not the live acquisition.
pane_window.replay_bar.slider.setValue(pane_window.replay_bar.slider.maximum())
app.processEvents()
cursor_card = pane_window.force_plots.value_labels[0].text()
check(
cursor_card not in ("", t("chart.value_unavailable")),
f"the replay readout is empty: {cursor_card!r}",
)
check(
float(cursor_card) > 9.0,
f"the readout is not the sample at the cursor: {cursor_card}",
)
before_key = pane_window.arrow_panel.image_label.pixmap().cacheKey()
before_text = pane_window.arrow_panel.image_label.text()
for index in range(3):
packet = _fake_packet(index, mono_t=index / 30.0)
packet.raw_frame = np.full((24, 32, 3), 200, np.uint8)
stuck.emit(packet)
pane_window._on_run_tick()
app.processEvents()
check(
pane_window.arrow_panel.image_label.pixmap().cacheKey() == before_key
and pane_window.arrow_panel.image_label.text() == before_text,
"a live frame overwrote the replay's picture",
)
check(
pane_window.force_plots.unit() == "raw",
"a live sample switched the unit of the recording being replayed",
)
check(
pane_window.force_plots.value_labels[0].text() == cursor_card,
"a live sample overwrote the replay's readout",
)
pane_window.brand_bar.btn_live.click()
app.processEvents()
check(
not pane_window._replay_owns_image(),
"the live view still hands the 2D pane to the replay",
)
pane_window._on_run_tick()
app.processEvents()
pane_window.replay.close()
pane_window._stop_run(reason="user_stop")
stuck.release()
pane_window.close()
app.processEvents()
_shutil.rmtree(replay_root, ignore_errors=True)
section("clean shutdown") section("clean shutdown")
# A camera SDK leaves native threads behind; the entry point must still come # A camera SDK leaves native threads behind; the entry point must still come
# back to the console. This runs the real ``main()`` in a child process and # back to the console. This runs the real ``main()`` in a child process and
@@ -2072,6 +2440,16 @@ def main() -> int:
plots.stats_label.text() == "", plots.stats_label.text() == "",
"the selection readout is not blank while following live", "the selection readout is not blank while following live",
) )
# Zoom and pan are available while following too: a chart that ignored the
# mouse could never be dragged into browsing in the first place.
check(
all(plot.getViewBox().state["mouseEnabled"][0] for plot in plots.plots),
"panning and zooming are disabled while following live",
)
check(
not any(cursor.movable for cursor in plots.cursors),
"the cursor is draggable while following live",
)
first, last = window.session_store.extent() first, last = window.session_store.extent()
check(last > first, "the session store has no extent after recording") check(last > first, "the session store has no extent after recording")
@@ -2099,13 +2477,36 @@ def main() -> int:
"the whole-session overview strip is empty", "the whole-session overview strip is empty",
) )
# A user gesture enters browse; "back to latest" must actually leave it. # A user gesture (the signal pyqtgraph emits for a wheel/drag) enters browse;
# "back to latest" must actually leave it.
plots._syncing = False plots._syncing = False
plots.plots[0].setXRange(5.0, 8.0, padding=0.0) view_box = plots.plots[0].getViewBox()
view_box.setXRange(5.0, 8.0, padding=0.0)
view_box.sigRangeChangedManually.emit([True, False])
app.processEvents() app.processEvents()
check(plots.mode() == "browse", "a user pan did not enter browse mode") check(plots.mode() == "browse", "a user pan did not enter browse mode")
check(
plots.window() == (5.0, 8.0),
f"the dragged window was not applied: {plots.window()}",
)
check(
all(cursor.movable for cursor in plots.cursors),
"the cursor is not draggable while browsing",
)
plots.follow_latest() plots.follow_latest()
check(plots.mode() == "follow", "back-to-latest did not restore live mode") check(plots.mode() == "follow", "back-to-latest did not restore live mode")
check(
not any(cursor.movable for cursor in plots.cursors),
"the cursor stayed draggable after leaving browsing",
)
# A programmatic (or X-link propagated) range change is not a gesture.
plots._syncing = False
view_box.setXRange(1.0, 2.0, padding=0.0)
app.processEvents()
check(
plots.mode() == "follow",
"a programmatic range change switched the view into browsing",
)
plots.set_cursor(3.5, emit=True) plots.set_cursor(3.5, emit=True)
check( check(
@@ -2113,6 +2514,22 @@ def main() -> int:
"the time cursor is not shared across the three channels", "the time cursor is not shared across the three channels",
) )
# Dragging the shared cursor moves the playhead and notifies the listeners.
plots.show_all()
app.processEvents()
dragged_cursor: list[float] = []
plots.cursor_moved.connect(dragged_cursor.append)
plots.cursors[0].setValue(2.5)
app.processEvents()
check(
[round(cursor.value(), 3) for cursor in plots.cursors] == [2.5, 2.5, 2.5],
"dragging one cursor did not move the shared cursor",
)
check(
dragged_cursor and dragged_cursor[-1] == 2.5,
f"the cursor drag did not notify the other views: {dragged_cursor}",
)
# History browsing is a display pause: capture and recording keep going. # History browsing is a display pause: capture and recording keep going.
plots.show_all() plots.show_all()
before = window.session_store.count before = window.session_store.count
@@ -2122,12 +2539,23 @@ def main() -> int:
window.session_store.count > before, window.session_store.count > before,
"browsing history stopped the record channel", "browsing history stopped the record channel",
) )
# Unit switches rebuild from the attached source without leaving live mode.
plots.follow_latest()
plots._unit = "raw"
window._on_units_changed("newton")
app.processEvents()
check(
plots.mode() == "follow",
f"switching units left the charts in browsing: {plots.mode()}",
)
check(plots._source_ready, "switching units detached the curve source")
check(plots._unit == "N", f"the unit switch did not reach the charts: {plots._unit}")
window.close() window.close()
app.processEvents() app.processEvents()
stuck.release() stuck.release()
section("layout preset") section("views and their source pickers")
stuck = _StuckWorker() stuck = _StuckWorker()
window = StudioMainWindow( window = StudioMainWindow(
prefs={ prefs={
@@ -2145,27 +2573,61 @@ def main() -> int:
window._finger_worker = stuck window._finger_worker = stuck
app.processEvents() app.processEvents()
check(window._layout_preset == "default", "the layout preset did not load") # The 3D panel needs a finger sensor and its own toggle, and no layout preset
check(window.mesh3d_panel.isVisible(), "the 3D panel is hidden in the free layout") # is involved any more.
check(window.mesh3d_panel.isVisible(), "the finger 3D panel is hidden in the live view")
window._on_layout_preset("2d") check(window.act_finger_3d.isEnabled(), "the 3D toggle is disabled for a finger sensor")
app.processEvents()
check(window._layout_preset == "2d", "the 2D preset did not apply")
check(not window.mesh3d_panel.isVisible(), "the 2D preset keeps the 3D panel")
check( check(
window.arrow_panel.view_mode() == "depth", not hasattr(window, "_layout_preset"),
"the 2D preset does not show the depth map", "the removed layout preset is still part of the window",
)
check(
not hasattr(window, "act_layout_2d"),
"the removed layout menu entry is still part of the window",
) )
check(not window.cal_panel.isVisible(), "the 2D preset keeps calibration expanded")
check(not window.act_finger_3d.isEnabled(), "the 3D toggle is live in the 2D preset")
check(window.act_layout_2d.isChecked(), "the 2D menu entry is not checked")
window._on_layout_preset("default") # Live: the camera picker; replay: the recording picker and no camera.
app.processEvents() check(
check(window.mesh3d_panel.isVisible(), "the free layout did not restore the 3D panel") window.toolbar.camera_group.isVisible(),
check(window.act_finger_3d.isEnabled(), "the 3D toggle stayed disabled") "the live view does not show the camera picker",
window._persist_prefs() )
check(window.prefs.get("layout_preset") == "default", "the preset was not persisted") window.brand_bar.btn_playback.click()
for _ in range(3):
app.processEvents()
check(
not window.toolbar.camera_group.isVisible()
and not window.toolbar.video_group.isVisible(),
"the replay view still shows the live capture pickers",
)
window.brand_bar.btn_live.click()
for _ in range(3):
app.processEvents()
check(
window.toolbar.camera_group.isVisible(),
"returning to the live view did not restore the camera picker",
)
# A video file is an offline acquisition run: the rail states it and offers
# the way back, with no source combobox anywhere.
window.toolbar.set_video_path("C:/tmp/demo.mp4")
window._set_source_mode("video")
for _ in range(3):
app.processEvents()
check(
window.toolbar.video_group.isVisible() and not window.toolbar.camera_group.isVisible(),
"video mode still offers the camera group",
)
check(
"demo.mp4" in window.toolbar.source_summary.text(),
f"the rail does not name the video file: {window.toolbar.source_summary.text()!r}",
)
window.toolbar.use_camera_clicked.emit()
for _ in range(3):
app.processEvents()
check(
window.toolbar.source_mode() == "camera" and window.toolbar.video_path() == "",
"the way back to the camera did not take effect",
)
window.close() window.close()
app.processEvents() app.processEvents()
@@ -2198,21 +2660,86 @@ def main() -> int:
recorder.on_packet(_fake_packet(index)) recorder.on_packet(_fake_packet(index))
recorder.stop() recorder.stop()
from orisys_studio.ui.playback_dialog import PlaybackDialog from orisys_studio.replay import ReplayController
playback = PlaybackDialog(theme="dark", directory=recording) # The replay lives in the main window: one engine, one progress bar, and the
playback.resize(1000, 620) # charts read the recording itself.
playback.show() replay_window = StudioMainWindow(
prefs={
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
)
replay_window.resize(1440, 880)
replay_window.show()
app.processEvents()
replay_window.brand_bar.btn_playback.click()
app.processEvents() app.processEvents()
check( check(
playback.position() == 0.0, replay_window.replay_bar.isVisible(),
f"replay did not start at the beginning: {playback.position()}", "the replay view has no progress bar",
) )
check( check(
all(int(curve.getData()[0].size) == 40 for curve in playback.curves), not replay_window.toolbar.camera_group.isVisible(),
"the replay view still offers the camera picker",
)
check(
replay_window._load_recording(recording),
"the replay view could not open the recording",
)
app.processEvents()
replay = replay_window.replay
replay_plots = replay_window.force_plots
check(
replay.position() == 0.0,
f"replay did not start at the beginning: {replay.position()}",
)
check(
all(int(curve.getData()[0].size) == 40 for curve in replay_plots.curves),
"replay did not draw every force sample", "replay did not draw every force sample",
) )
check(
replay_plots.is_recording_source() and replay_plots.mode() == "browse",
"the charts do not browse the recording",
)
check(
replay_plots.mode_label.text() == t("chart.mode_replay"),
f"the charts do not say they are replaying: {replay_plots.mode_label.text()!r}",
)
check(
not replay_plots.btn_latest.isEnabled(),
"a recording offers 'back to latest'",
)
# The progress bar is the playhead, and the chart cursor is a second one.
check(replay_window.replay_bar.slider.isEnabled(), "the progress bar is disabled")
replay_window.replay_bar.slider.setValue(replay_window.replay_bar.slider.maximum() // 2)
app.processEvents()
check(
0.0 < replay.position() < replay.duration_s(),
f"dragging the progress bar did not move the playhead: {replay.position()}",
)
check(
abs(float(replay_plots.cursors[0].value()) - replay.position()) < 0.05,
"the chart cursor did not follow the progress bar",
)
replay_plots.cursors[0].setValue(0.25)
app.processEvents()
check(
abs(replay.position() - 0.25) < 0.05,
f"dragging the chart cursor did not scrub the replay: {replay.position()}",
)
check(
replay.video_image() is not None,
"no video frame was rendered at the cursor position",
)
check(
replay_window.arrow_panel.view_modes() == ("recorded", "depth"),
"the 2D panel still offers the live-only views while replaying",
)
# Long-history browsing: an interval read is bounded by the interval, and the # Long-history browsing: an interval read is bounded by the interval, and the
# whole-session envelope is a band rather than every row. # whole-session envelope is a band rather than every row.
@@ -2238,26 +2765,44 @@ def main() -> int:
reader.info().total_rows == 40, reader.info().total_rows == 40,
f"the recording reports {reader.info().total_rows} rows", f"the recording reports {reader.info().total_rows} rows",
) )
check(playback.slider.isEnabled(), "the replay position slider is disabled")
check( check(
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4] t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
in playback.warning_label.text(), in replay.warnings_text(),
f"the replay notes are missing: {playback.warning_label.text()!r}", f"the replay notes are missing: {replay.warnings_text()!r}",
)
check(
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
in replay_window.replay_bar.warning_label.text(),
f"the warnings are not shown in the bar: "
f"{replay_window.replay_bar.warning_label.text()!r}",
) )
moved: list[float] = [] moved: list[float] = []
playback.cursor_moved.connect(moved.append) replay.position_changed.connect(moved.append)
playback.set_position(1.0) replay.set_position(1.0)
app.processEvents() app.processEvents()
check( check(
[round(line.value(), 2) for line in playback.cursor_lines] == [1.0, 1.0, 1.0], [round(line.value(), 2) for line in replay_plots.cursors] == [1.0, 1.0, 1.0],
"the replay cursor is not shared across the three charts", "the replay cursor is not shared across the three charts",
) )
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(
playback.video_label.pixmap() is not None "1.00" in replay_window.replay_bar.position_label.text(),
and not playback.video_label.pixmap().isNull(), f"the bar does not show the playhead: "
"no video frame was rendered at the cursor position", f"{replay_window.replay_bar.position_label.text()!r}",
)
check(
replay_window.replay_bar.slider.value()
== int(replay_window.replay_bar.slider.maximum() * 1.0 / replay.duration_s()),
"the progress bar did not follow a programmatic position",
)
# The unit switch rebuilds the recording's curves, not the live ones.
replay_window.replay_bar.unit_toggled.emit()
app.processEvents()
check(
replay.unit() == "N" and replay_plots._unit == "N",
f"the replay unit switch did not reach the charts: {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
@@ -2278,43 +2823,53 @@ def main() -> int:
def close(self) -> None: def close(self) -> None:
return None return None
playback._recomputer = _Blocking() # noqa: SLF001 - exercising the contract replay._recomputer = _Blocking() # noqa: SLF001 - exercising the contract
started = _time.perf_counter() started = _time.perf_counter()
playback._on_recompute_depth() replay.request_depth()
elapsed = _time.perf_counter() - started elapsed = _time.perf_counter() - started
check(elapsed < 0.5, f"the depth recompute blocked the UI for {elapsed:.2f}s") check(elapsed < 0.5, f"the depth recompute blocked the UI for {elapsed:.2f}s")
check( check(
playback._depth_thread is not None and playback._depth_thread.is_alive(), replay._depth_thread is not None and replay._depth_thread.is_alive(),
"the depth recompute did not run on a worker thread", "the depth recompute did not run on a worker thread",
) )
check( check(
playback._depth_thread.daemon, replay._depth_thread.daemon,
"the depth worker is not a daemon, so a blocked SDK call would keep the " "the depth worker is not a daemon, so a blocked SDK call would keep the "
"process alive at exit", "process alive at exit",
) )
playback._on_recompute_depth() # a second request is refused, not queued replay.request_depth() # a second request is refused, not queued
check( check(
playback.depth_label.text() == t("playback.depth_busy"), replay.depth_message() == t("playback.depth_busy"),
f"a concurrent recompute was not refused: {playback.depth_label.text()!r}", f"a concurrent recompute was not refused: {replay.depth_message()!r}",
) )
_Blocking.released.set() _Blocking.released.set()
for _ in range(40): for _ in range(40):
app.processEvents() app.processEvents()
_time.sleep(0.05) _time.sleep(0.05)
if not playback._depth_busy(): # noqa: SLF001 if not replay._depth_busy(): # noqa: SLF001
break break
check(not playback._depth_busy(), "the depth worker never finished") check(not replay._depth_busy(), "the depth worker never finished")
# Deliver the worker result deterministically instead of waiting for the # Deliver the worker result deterministically instead of waiting for the
# 120 ms poll timer. # 120 ms poll timer.
playback._poll_depth() replay._poll_depth()
app.processEvents() app.processEvents()
check( check(
t("playback.depth_failed").split("{")[0].strip()[:4] t("playback.depth_failed").split("{")[0].strip()[:4] in replay.depth_message(),
in playback.depth_label.text(), f"a failed recomputation was not reported: {replay.depth_message()!r}",
f"a failed recomputation was not reported: {playback.depth_label.text()!r}",
) )
check(
playback.close() replay.depth_image() is None,
"a failed recomputation left an image in the depth pane",
)
# The 2D panel shows the depth pane's explanation, not the raw frame.
replay_window.arrow_panel.set_view_mode("depth")
app.processEvents()
check(
replay_window.arrow_panel.depth_scale_label.text() == ""
and replay_window.arrow_panel.colorbar.isVisible(),
"the replay depth view does not show its colour bar",
)
replay_window.arrow_panel.set_view_mode("recorded")
app.processEvents() 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
@@ -2364,42 +2919,83 @@ def main() -> int:
# A reset boundary is re-applied, and a request is bound to its recording. # A reset boundary is re-applied, and a request is bound to its recording.
_Blocking.released.clear() _Blocking.released.clear()
second = PlaybackDialog(theme="dark", directory=recording) second_controller = ReplayController()
second.show() second_controller.open_recording(recording)
app.processEvents() serial_before = second_controller._record_serial
serial_before = second._record_serial
blocking = _Blocking() blocking = _Blocking()
second._recomputer = blocking second_controller._recomputer = blocking
second._on_recompute_depth() second_controller.request_depth()
check(second._depth_pending_record == serial_before, "the request has no recording id")
check( check(
second._depth_pending_seq is not None, second_controller._depth_pending_record == serial_before,
"the request has no recording id",
)
check(
second_controller._depth_pending_seq is not None,
"the request carries no sequence number", "the request carries no sequence number",
) )
# Opening another recording invalidates the running request... # Opening another recording invalidates the running request...
second.open_recording(recording) second_controller.open_recording(recording)
check( check(
second._record_serial == serial_before + 1, second_controller._record_serial == serial_before + 1,
"opening a recording did not invalidate the running request", "opening a recording did not invalidate the running request",
) )
check( check(
second._recomputer is not blocking, second_controller._recomputer is not blocking,
"the invalidated processor was not replaced", "the invalidated processor was not replaced",
) )
_Blocking.released.set() _Blocking.released.set()
for _ in range(40): for _ in range(40):
app.processEvents() app.processEvents()
_time.sleep(0.05) _time.sleep(0.05)
if not second._depth_busy(): if not second_controller._depth_busy():
break break
second._poll_depth() second_controller._poll_depth()
app.processEvents() app.processEvents()
pixmap = second.depth_label.pixmap()
check( check(
pixmap is None or pixmap.isNull(), second_controller.depth_image() is None,
"an answer for a previous recording was drawn into the new one", "an answer for a previous recording was drawn into the new one",
) )
second.close() second_controller.close()
app.processEvents()
# Leaving the replay view returns the charts to the live session and hides
# the bar: the replay stays available, it just stops being on screen.
check(
replay_window._load_recording(recording),
"the replay view could not be re-opened",
)
app.processEvents()
replay_window.brand_bar.btn_live.click()
app.processEvents()
check(
not replay_plots.is_recording_source() and replay_plots.mode() == "follow",
"leaving the replay view did not restore the live charts",
)
check(
not replay_window.replay_bar.isVisible(),
"the progress bar is still on screen in the live view",
)
check(
replay_window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth"),
"the 2D panel did not return to its live views",
)
check(
replay_window.replay.is_loaded(),
"leaving the view dropped the open recording",
)
# The replay engine is inside the shell now: there is no second window.
check(
not hasattr(replay_window, "_playback"),
"the window still keeps a replay dialog reference",
)
check(
not hasattr(replay_window, "_open_playback"),
"the window still opens a separate replay window",
)
replay_window.replay.close()
replay_window.close()
app.processEvents() app.processEvents()
_shutil.rmtree(replay_root, ignore_errors=True) _shutil.rmtree(replay_root, ignore_errors=True)
+68 -3
View File
@@ -35,15 +35,72 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
os.chdir(APP_ROOT) os.chdir(APP_ROOT)
def _replay_data(window, app) -> Path:
"""Synthesize one short recording (with visible frames) and open it."""
import numpy as np
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from ui_check import _fake_packet
root = APP_ROOT / "outputs" / "_snapshot_replay"
if root.exists():
import shutil
shutil.rmtree(root, ignore_errors=True)
root.mkdir(parents=True, exist_ok=True)
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=30.0))
recording = Path(
recorder.start(
root=root,
session_id=1,
metadata={
"device": {
"sensor_type": "box",
"config_ref": str(root / "sensor.json"),
"config_sha256": "snapshot",
},
"versions": {"sdk": "snapshot"},
"depth": {"stored": False, "replay": "recompute"},
},
)
)
# A recognisable frame: a horizontal gradient with a bright bar, so the
# replay's "recorded image" view is reviewable instead of black.
height, width = 108, 192
frame = np.zeros((height, width, 3), np.uint8)
frame[:, :, 1] = np.linspace(20, 200, width, dtype=np.uint8)[None, :]
frame[height // 3 : 2 * height // 3, :, 2] = 180
for index in range(150):
recorder.on_packet(_fake_packet(index, frame=frame))
recorder.stop()
return recording
def _run_scene(window, app, scene: str): def _run_scene(window, app, scene: str):
"""Drive the window into a scene that would otherwise need hardware. """Drive the window into a scene that would otherwise need hardware.
``recording`` mocks the record channel's stats through the same single seam ``recording`` mocks the record channel's stats through the same single seam
the app reads them from (``_record_stats``), so nothing is written to disk the app reads them from (``_record_stats``), so nothing is written to disk
and no device is opened: the render is of the real widgets, not of a mockup. and no device is opened: the render is of the real widgets, not of a mockup.
``replay`` writes one short synthetic recording and opens it in the replay
view, which is the only way to review the progress bar without a session.
""" """
from ui_check import _fake_packet, _StuckWorker from ui_check import _fake_packet, _StuckWorker
if scene == "replay":
window.brand_bar.btn_playback.click()
for _ in range(4):
app.processEvents()
recording = _replay_data(window, app)
window._load_recording(recording)
# Park the playhead where the bar and the charts show a real position.
window.replay.set_position(window.replay.duration_s() * 0.4)
window.arrow_panel.set_view_mode("recorded")
for _ in range(6):
app.processEvents()
return
stuck = _StuckWorker() stuck = _StuckWorker()
window._ensure_workers = lambda: None window._ensure_workers = lambda: None
window._box_worker = stuck window._box_worker = stuck
@@ -160,12 +217,14 @@ def main() -> int:
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data") parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
parser.add_argument( parser.add_argument(
"--scene", "--scene",
choices=("idle", "connecting", "recording"), choices=("idle", "connecting", "recording", "replay"),
default="idle", default="idle",
help=( help=(
"connecting renders the Stop/cancel state of a hung device open; " "connecting renders the Stop/cancel state of a hung device open; "
"recording renders the record strip of a running recording " "recording renders the record strip of a running recording "
"(mocked stats: no file is written, no device is opened)" "(mocked stats: no file is written, no device is opened); "
"replay writes one short synthetic recording and opens it in the "
"replay view so the progress bar can be reviewed"
), ),
) )
parser.add_argument("--out", required=True, help="output PNG path") parser.add_argument("--out", required=True, help="output PNG path")
@@ -203,6 +262,9 @@ def main() -> int:
window.show() window.show()
app.processEvents() app.processEvents()
if args.demo and args.scene == "replay":
pass # the recording is the demo data
if args.scene == "connecting": if args.scene == "connecting":
# A device open that never returns: the state the Stop button must # A device open that never returns: the state the Stop button must
# survive. ``ui_check._StuckWorker`` drives the same window state. # survive. ``ui_check._StuckWorker`` drives the same window state.
@@ -211,7 +273,10 @@ def main() -> int:
if args.scene == "recording": if args.scene == "recording":
_run_scene(window, app, "recording") _run_scene(window, app, "recording")
if args.demo: if args.scene == "replay":
_run_scene(window, app, "replay")
if args.demo and args.scene != "replay":
_demo_data(window) _demo_data(window)
for _ in range(4): for _ in range(4):
app.processEvents() app.processEvents()
@@ -92,6 +92,9 @@ GAP_BACKLOG = 4096
#: Hard cap on the ordered stream. Past this the producer has to give up a row, #: Hard cap on the ordered stream. Past this the producer has to give up a row,
#: and it says so by writing a gap marker instead. #: and it says so by writing a gap marker instead.
ROW_BACKLOG = 8192 ROW_BACKLOG = 8192
#: Video pacing tolerance, in fractions of the target frame period: a frame that
#: arrives slightly early is still the frame for this slot.
VIDEO_PACING_TOLERANCE = 0.5
def format_value(value: float) -> str: def format_value(value: float) -> str:
@@ -162,6 +165,9 @@ class RecordStats:
dropped_gaps: int = 0 dropped_gaps: int = 0
#: Lost rows that could not even be marked: the gap backlog was full. #: Lost rows that could not even be marked: the gap backlog was full.
unrecorded_gaps: int = 0 unrecorded_gaps: int = 0
#: Frames the device delivered faster than the recording rate, so they were
#: not written to the video. Their data rows are still complete.
resampled_video: int = 0
write_errors: int = 0 write_errors: int = 0
failed: bool = False failed: bool = False
last_error: str = "" last_error: str = ""
@@ -178,6 +184,7 @@ class RecordStats:
"dropped_video": self.dropped_video, "dropped_video": self.dropped_video,
"dropped_gaps": self.dropped_gaps, "dropped_gaps": self.dropped_gaps,
"unrecorded_gaps": self.unrecorded_gaps, "unrecorded_gaps": self.unrecorded_gaps,
"resampled_video": self.resampled_video,
"write_errors": self.write_errors, "write_errors": self.write_errors,
"failed": self.failed, "failed": self.failed,
"last_error": self.last_error, "last_error": self.last_error,
@@ -239,6 +246,8 @@ class ExperimentRecorder:
self._events: list[dict] = [] self._events: list[dict] = []
self._t0: Optional[float] = None self._t0: Optional[float] = None
self._last_t: Optional[float] = None self._last_t: Optional[float] = None
#: When the next video frame is due, in the same acquisition clock.
self._next_video_t: Optional[float] = None
self._started_at = "" self._started_at = ""
self._consecutive_errors = 0 self._consecutive_errors = 0
self._on_alarm = None self._on_alarm = None
@@ -296,7 +305,18 @@ class ExperimentRecorder:
def stats(self) -> RecordStats: def stats(self) -> RecordStats:
with self._lock: with self._lock:
return RecordStats(**self._stats.as_dict()) snapshot = RecordStats(**self._stats.as_dict())
# While a recording runs, the duration is the span captured so far.
# It is written into the stats at the end of the flush, so a *live*
# reading used to report 0 s for the whole recording.
if (
snapshot.duration_s <= 0.0
and self._t0 is not None
and self._last_t is not None
and self._last_t >= self._t0
):
snapshot.duration_s = round(float(self._last_t - self._t0), 6)
return snapshot
def set_alarm_handler(self, handler) -> None: def set_alarm_handler(self, handler) -> None:
"""``handler(message)`` is called from the writer thread on a problem.""" """``handler(message)`` is called from the writer thread on a problem."""
@@ -356,6 +376,7 @@ class ExperimentRecorder:
self._events = [] self._events = []
self._t0 = None self._t0 = None
self._last_t = None self._last_t = None
self._next_video_t = None
self._consecutive_errors = 0 self._consecutive_errors = 0
self._started_at = datetime.now(timezone.utc).isoformat(timespec="seconds") self._started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
self._report = None self._report = None
@@ -528,21 +549,53 @@ class ExperimentRecorder:
frame = None frame = None
if self.config.video or self.config.lossless_frames: if self.config.video or self.config.lossless_frames:
candidate = self._capture_frame(packet) # A frame that arrives faster than the recording rate keeps its data
if candidate is not None: # row but gets no picture (see ``_video_frame_due``).
size = int(candidate.nbytes) if self._video_frame_due(sample.mono_t):
if depth >= max(16, int(self.config.max_queue)): candidate = self._capture_frame(packet)
self._shed_video("record queue overflow") if candidate is not None:
elif not self._affordable(size): size = int(candidate.nbytes)
self._shed_video("video byte budget reached") if depth >= max(16, int(self.config.max_queue)):
else: self._shed_video("record queue overflow")
frame = candidate elif not self._affordable(size):
# Reserved here and released exactly once, by the writer that self._shed_video("video byte budget reached")
# consumes it (see ``_handle_frame``). else:
with self._lock: frame = candidate
self._queued_bytes += size # Reserved here and released exactly once, by the writer
# that consumes it (see ``_handle_frame``).
with self._lock:
self._queued_bytes += size
self._enqueue(("frame", self._next_seq(), sample, frame)) self._enqueue(("frame", self._next_seq(), sample, frame))
def _video_frame_due(self, mono_t) -> bool:
"""Whether this frame belongs in the video at the configured rate.
The video is written on the target rate's own schedule, so the container's
fps describes the spacing of the pictures it holds. Writing *every*
delivered frame into a 30 fps container makes playback run fast as soon
as the pipeline delivers more than 30 frames per second (a burst of
buffered frames at open, or a file source), which is exactly the
"recorded 48 fps but the sensor is 30" mismatch.
A skipped frame is not lost data: every row is still in
``frames.csv``/``forces.csv``, and the count is reported separately.
"""
try:
stamp = float(mono_t)
except (TypeError, ValueError):
return True
period = 1.0 / max(1.0, float(self.config.fps))
with self._lock:
if self._next_video_t is None:
self._next_video_t = stamp
if stamp + period * VIDEO_PACING_TOLERANCE < self._next_video_t:
self._stats.resampled_video += 1
return False
# Schedule from the later of the two: a slow patch must not let the
# schedule fall behind and then emit a catch-up burst.
self._next_video_t = max(self._next_video_t + period, stamp)
return True
def _next_seq(self) -> int: def _next_seq(self) -> int:
with self._lock: with self._lock:
self._seq += 1 self._seq += 1
@@ -844,6 +897,15 @@ class ExperimentRecorder:
problems.append( problems.append(
f"{stats.dropped_gaps} frame(s) were recorded as gaps (no data row)" f"{stats.dropped_gaps} frame(s) were recorded as gaps (no data row)"
) )
if stats.resampled_video:
# Not data loss, but the operator has to know the video is not one
# picture per delivered frame: the device ran faster than the
# recording rate.
problems.append(
f"{stats.resampled_video} frame(s) were delivered faster than the "
f"{float(self.config.fps):g} fps recording rate, so they are in "
"frames.csv but not in the video"
)
if stats.unrecorded_gaps: if stats.unrecorded_gaps:
problems.append( problems.append(
f"{stats.unrecorded_gaps} lost frame(s) could not be marked: " f"{stats.unrecorded_gaps} lost frame(s) could not be marked: "
+31 -1
View File
@@ -196,7 +196,10 @@ QFrame#brandBar QPushButton#navButton[navActive="true"] {{
font-weight: 600; font-weight: 600;
}} }}
QFrame#brandBar QPushButton#navButton:focus {{ QFrame#brandBar QPushButton#navButton:focus {{
border-color: {c['brand_bar_fg']}; /* A focus ring must not read as "this view is the current one": the active
view is marked by colour + weight, so the ring stays muted. */
border-color: {c['brand_bar_fg_muted']};
color: {c['brand_bar_fg']};
}} }}
/* ---------- Buttons ---------- */ /* ---------- Buttons ---------- */
QPushButton {{ QPushButton {{
@@ -314,6 +317,33 @@ QPushButton[compact="true"] {{
padding: 6px 9px; padding: 6px 9px;
font-size: 12px; font-size: 12px;
}} }}
/* An icon-only action: the tooltip carries the meaning, the icon the shape. */
QPushButton[iconOnly="true"] {{
min-width: 24px;
padding: 4px 6px;
}}
/* The unit chip is a button (it switches newton <-> raw), so it needs the
badge's look plus a hover that says it can be pressed. */
QPushButton#unitBadge {{
color: {c['text_secondary']};
background-color: {c['card_bg']};
border: 1px solid {c['card_border']};
border-radius: 6px;
padding: 1px 7px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.4px;
min-height: 18px;
}}
QPushButton#unitBadge:hover {{
color: {c['text_primary']};
border-color: {c['accent']};
}}
QPushButton#unitBadge:disabled {{
color: {c['text_muted']};
background-color: {c['card_bg']};
border-color: {c['separator']};
}}
/* Status-rail controls are one instrument line: they must not carry the /* Status-rail controls are one instrument line: they must not carry the
padding a dialog button needs, or the top of the window grows to twice the padding a dialog button needs, or the top of the window grows to twice the
height of the rail's own content. */ height of the rail's own content. */