Compare commits

..

4 Commits

Author SHA1 Message Date
ykcheungab c538dbf074 fixed label under 3d view 2026-09-15 21:50:37 +08:00
ykcheungab c2293d4d41 界面:三维可选「深度响应 / 光流幅值」上色(默认深度),并加一个「诊断信息」开关收拾面板脚下的文字
- What(颜色来源): `DisplaySettings` 增 `field`(`"depth"` 默认 / `"flow"`),随显示设置持久化
  (`scene3d/prefs.py`:新增 `FIELD_CHOICES`/`DEFAULT_FIELD`,手改文件里的非法值修回默认)。
  `FingerFrameAdapter` 按所选字段先试、失败再用另一个,并把"换了量"这件事记在
  `last_substitution`(新诊断码 `field_substituted`,info 级);两个字段都拿不到时,
  `last_diagnostic` 取**信息量最大**的那条(域不符/过期/未声明 > 没这个字段),而不是"所选字段"的
  那条空话。设置对话框新增「颜色来源」下拉,下面一行 `field_note` 直接写"这一帧画的是 X,不是所选的 Y";
  图例(`legend_label`)永远标注**实际**画出来的量,所以设定与画面不一致时两处都能看出来。
  深度响应 `[1]`、光流幅值 `[px]`,两者都是 `approximate`——代码与文案都不把它们说成毫米。
- What(诊断信息开关): 视图菜单新增 `诊断信息`(可勾选、持久化 `show_diagnostics`、**默认关**,
  由 `DisplayPrefsController` 持有并推给两个面板)。关掉后:2D 面板只留"画面 + 计划说明 + 深度色标",
  三条解释流水线的标签(箭头按位移放大、原始图像 → 算法输出、全部帧)隐藏;3D 面板只留
  色条 + 一行图例 + 来源,info 级诊断隐藏。**永不隐藏**:warning/error 级诊断(测量是否过期、
  字段是否本帧的、是否与设备核对过——`geometry_unverified` 虽然是 info 也白名单保留)与
  深度色标。3D 图例改成 `ShrinkableLabel`(一行、裁切 + tooltip,不再折行),色标数值从 4 位有效数字
  收到 3 位("0.0334 – 0.899")。设置对话框控件宽度 88 → 108 px(不再需要迁就 240 px 面板)。
- What(取景脚本): `tools/ui_snapshot.py --scene flow3d` 现在同时给 flow 与 depth(各带 provenance),
  所以「颜色来源」开关有东西可切;再加 `.repro/shot_variants.py` 一次出四张图
  (深度版 / 光流版 / 诊断开 / 设置对话框)。
- What(测试): `test_scene3d_adapters.py` 增 3 项(默认深度、换量时给出 `field_substituted` 并在恢复后
  清掉、depth 不在场景栅格时按 `field_domain_mismatch` 拒绝而不是缩放);`test_scene3d_view.py` 增
  1 项(field 的持久化与修复,并把 `field in FIELD_CHOICES` 加进"手改文件会被修复"的参数化);
  `test_scene3d_pane_ui.py` 增 3 项(两种量可选 + 说明行 + 恢复的偏好会移动下拉;诊断开关只隐藏 info、
  保留 warning 与"未核对";2D 脚注三标签的显隐与窗口开关一致);`test_display_prefs.py` 增 1 项
  (开关推到两个面板、写偏好)。`test_ui_smoke.py` 与 `tools/ui_check.py` 里"箭头图例可见"的四条断言
  改为按新规则检查(默认隐藏、开诊断后可见、关掉再隐藏)。
- Why: ①"深度"比"光流"更贴近操作员口中的"压得多深",但两者都只是近似量,所以做成开关而不是替换——
  并且换量必须说出来(否则画面会被读成所选的量)。②面板脚下的解释文字是"内部管线"而不是"画面是什么",
  拍视频与日常看都只需要后者;用一个开关收起,比做一个滚动日志面板诚实(这些是每帧状态,不是事件)。
- Verification: pytest shared/tests apps/studio/tests(offscreen)658 passed / 0 failed / 15 skipped;
  真 GL 组(gl_smoke + view + pane_ui)70 passed / 0 failed / 1 skipped;
  `tools/ui_check.py` 590 项检查 0 失败;`.repro/locale_check.py` 0 缺失(新增 7 个 key 双语齐备)。
  目视验收:同一批合成数据渲染的四张图(深色主题)——深度版(默认)、光流版、诊断打开、设置对话框。
- Not in this change: 不改采集/记录/契约之外的三维渲染;不做页签容器与窄窗口回退;曲线量程仍是出厂 ±20 N。
  真机待验证:手指路径上 `packet.depth` 的栅格是否就是场景的 300×280(若不是,面板会写明并回退到光流)。
2026-09-15 21:23:18 +08:00
ykcheungab 0b87e3e36d 界面:三维显示设置对话框的关闭按钮说中文(Qt 自带翻译没有被安装)
- What: `Scene3dSettingsDialog.retranslate()` 把 `QDialogButtonBox` 的 Close 按钮文字设为
  `t("dialog.close")`;两个 locale 各加一个 close 键。
- Why: 应用没有安装 Qt 自带的翻译文件,于是按钮框在满屏中文里显示一个英文 Close;对话框是这个
  面板唯一的出口按钮,不该是外来语。
- Verification: `.repro/locale_check.py` 0 缺失(新键双语齐备);对话框截图目视确认
  (266x531,结尾按钮为「关闭」)。
- Not in this change: 不动其它对话框(它们用自己的按钮,本来就有中文文案)。
2026-09-15 20:57:21 +08:00
ykcheungab 650578d738 界面:三维面板只留画面与图例,全部配置进「三维显示设置」对话框;三维占比提为最宽
- What(隐藏配置): `ThreeDPanel` 把**所有设置**收进一个块
  (`controls_widget`:总开关、观察方向、显示外壳、放大形变+增益、色标模式与范围、显示帧率、
  复位按钮、显示统计),面板本身只保留读图需要的东西:色条 + 一行紧凑图例(数量/自动色标范围/
  来源)+ 状态行。新增 `scene3d/settings_dialog.py` 的 `Scene3dSettingsDialog`:非模态
  (调参时画面还在动),承载的就是面板那套控件本身(同一批 widget,信号通路不变),
  关闭只是隐藏(控件必须活着,下一次读设置还要用)。
  两个入口:视图菜单新增 `三维显示设置…`(`act_scene3d_settings` → `Mesh3dPanel.open_settings()`)
  与面板标题栏的齿轮;无三维可配时(box 传感器 / 无 GL / 无场景包)菜单项会把面板上的原因
  原样提示出来,而不是弹一个空对话框。标题栏另有「复位视角」图标(新画的 `reset_view` 图标,
  环形箭头,随主题着色)。
- What(提高占比): live/replay 预设把三维面板设为最宽:`VIEW_SPLITTER_SIZES` (620, 300, 520)、
  `DEFAULT_SPLITTER_SIZES` (620, 320, 500)、`DEFAULT_3D_PANE_WIDTH` 340 → 620、
  `MIN_PANEL_WIDTHS["mesh3d"]` 260 → 200(面板里没有表单要放了,画面才是重点)、
  `ViewLayoutController.apply_panel_visibility` 的拉伸权重 2 → 5(2D 3、曲线 4)。
  1440×880 实测:三维约 430 px(原约 280)、2D 约 265、曲线约 320。
- What(拍视频/评审用): `tools/ui_snapshot.py` 新增 `--scene flow3d`:驱动一批**带真实光流场**的
  packets(适配器只接受声明了 provenance 的字段),所以三维面板真的有颜色、曲线也真的在动;
  这是本次界面评审与演示视频的取景脚本。配 `--sensor finger --panels 3d --preset live`。
- What(测试): 新增 `apps/studio/tests/test_scene3d_pane_ui.py`(5 项):面板只有图例没有设置、
  对话框承载的是面板自己的控件且设置信号照旧、关闭只隐藏不销毁、标题栏两个动作可用、
  无场景时 `open_settings()` 返回 False、`reset_view` 转发、`reset_view` 图标两种主题都能画、
  视图菜单项能打开对话框(无 GL 时给原因)。`test_view_layout.py` 增一条"出厂 live 布局里
  三维最宽"的断言(并更正我自己那条 340 的旧期望)。
- 文档: `studio-3d-stages.md` 的「尚未完成」第 1 条(阶段 8 第二批)改写为"部分已做",
  写清已完成的部分与仍留后的四项(页签容器/窄窗口回退/曲线量程开关/独立诊断抽屉)。
- Why: 三维面板原来下半屏全是表单,画面被挤成一小块——拍视频要的是"三维是主角"。
  配置不是不需要,而是不该和画面抢地方;放进对话框后画面变大、参数反而更好找(一处看全)。
- Verification: pytest shared/tests apps/studio/tests(offscreen)650 passed / **2 failed** / 15 skipped,
  两个失败都是 `shared/tests/test_worker_frame_rate.py` 的**墙钟计时**用例(30 fps 设备被测成
  24–25 fps):已在 `d539ce9` 的干净 worktree 上复现同样的失败,属本机负载(DSH/Cursor/飞书 占 CPU)
  而非本次改动;真 GL 用例 `test_scene3d_gl_smoke` + `test_scene3d_view` 61 项 0 失败(1 跳过);
  `tools/ui_check.py` 587 项检查 0 失败;`.repro/locale_check.py` 0 缺失(新增 6 个 key 双语齐备)。
  目视验收:`--scene flow3d` 渲染的 1440×880 截图(拆分前后各一张)见提交说明附言。
- Not in this change: 不改三维渲染/契约/记录;不做页签容器与窄窗口回退;
  曲线量程仍是出厂 ±20 N(演示取景脚本会临时调窄,产品默认值另议)。
2026-09-15 20:55:33 +08:00
26 changed files with 1578 additions and 288 deletions
+15 -3
View File
@@ -30,7 +30,9 @@
"lang_en": "English",
"calibration_panel": "Calibration panel",
"finger_3d": "Finger 3D view",
"scene3d_settings": "3D display settings…",
"show_fps": "Show FPS",
"show_diagnostics": "Diagnostics",
"settings": "Settings area",
"plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°",
@@ -77,7 +79,8 @@
"filter_nyquist": "Note: a {cutoff} Hz cutoff is at or above the Nyquist frequency of {nyquist} Hz (sample rate {rate} Hz) — the device would not filter. Lower the cutoff or pin a higher sample rate.",
"filter_off": "Not filtering (raw readings)",
"ok": "OK",
"cancel": "Cancel"
"cancel": "Cancel",
"close": "Close"
},
"toolbar": {
"search_cameras": "Search",
@@ -179,7 +182,9 @@
"mesh3d_no_asset": "No compiled 3D scene installed.\nA scene package (*.oryscene) is looked for in:\n{directory}\nBuild one with tools/scene_assets/compile_scene.py — the names of the files it needs are in its --help. Only the 3D view is affected; measuring, recording and the charts work as usual.",
"mesh3d_loading": "Loading 3D scene {name}…",
"mesh3d_rejected": "The 3D scene was refused: {message}\nOnly the 3D view is affected; measuring, recording and the charts work as usual.",
"mesh3d_unavailable_gl_reason": "3D is not available on this machine: {reason}\nMeasuring, recording and the charts work as usual."
"mesh3d_unavailable_gl_reason": "3D is not available on this machine: {reason}\nMeasuring, recording and the charts work as usual.",
"mesh3d_settings_tip": "3D display settings (view, housing, colour scale, frame rate)",
"mesh3d_reset_tip": "Reset view: back to the framing the scene itself uses"
},
"chart": {
"fn": "FN",
@@ -401,7 +406,12 @@
"housing": "Housing",
"deform": "Exaggerate",
"deform_tip": "Display-only magnification of the measured deformation. It changes the picture and nothing else: the recorded data, the calibration and the camera are not touched.",
"deform_unavailable": "This data source provides no 3D displacement: the pane can only colour the surface with the optical-flow magnitude, so exaggeration is unavailable.",
"deform_unavailable": "This data source provides no 3D displacement: the pane can only colour the surface, so exaggeration is unavailable.",
"field": "Colour by",
"field_tip": "Which quantity colours the surface: the depth response (the chain's relative deformation response, unitless) or the optical-flow magnitude (the tracker's pixel displacement). Neither is a length in millimetres — both are approximate. When the chosen one cannot be drawn for a frame, the other is used and the line below says so.",
"field_depth": "Depth response",
"field_flow": "Optical flow magnitude",
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
"quantity_flow_magnitude": "Optical flow magnitude",
"quantity_depth_response": "Depth response",
"mode_surface": "surface colouring",
@@ -417,6 +427,8 @@
"max_fps": "Display rate",
"max_fps_tip": "How many frames per second the 3D view draws. A display budget only: the camera and the recording are unaffected, and a dropped frame here is never a dropped sample.",
"reset_view": "Reset view",
"settings_title": "3D display settings",
"settings_hint": "Everything here affects the 3D view's display only: measuring, recording and the charts are untouched, and each change applies at once.",
"legend": "{quantity} [{unit}] · {quality}",
"legend_none": "No measurement on screen — the surface model is shown uncoloured",
"quality_calibrated": "calibrated",
+14 -2
View File
@@ -30,7 +30,9 @@
"lang_en": "English",
"calibration_panel": "标定面板",
"finger_3d": "手指 3D 视图",
"scene3d_settings": "三维显示设置…",
"show_fps": "显示 FPS",
"show_diagnostics": "诊断信息",
"settings": "设置区",
"plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°",
@@ -77,7 +79,8 @@
"filter_nyquist": "注意:低通 {cutoff} Hz 已达到或超过奈奎斯特频率 {nyquist} Hz(采样率 {rate} Hz)——设备不会滤。请调低截止频率或固定更高的采样率。",
"filter_off": "当前不滤波(原始读数)",
"ok": "确定",
"cancel": "取消"
"cancel": "取消",
"close": "关闭"
},
"toolbar": {
"search_cameras": "搜索",
@@ -179,7 +182,9 @@
"mesh3d_no_asset": "本机未安装已编译的三维场景。\n场景包(*.oryscene)的查找位置:\n{directory}\n可用 tools/scene_assets/compile_scene.py 生成,它需要哪些文件见 --help。只影响三维视图:采集、记录与力曲线照常工作。",
"mesh3d_loading": "正在加载三维场景 {name}…",
"mesh3d_rejected": "三维场景被拒绝:{message}\n只影响三维视图:采集、记录与力曲线照常工作。",
"mesh3d_unavailable_gl_reason": "本机无法使用三维显示:{reason}\n采集、记录与力曲线照常工作。"
"mesh3d_unavailable_gl_reason": "本机无法使用三维显示:{reason}\n采集、记录与力曲线照常工作。",
"mesh3d_settings_tip": "三维显示设置(视角、外壳、色标、显示频率)",
"mesh3d_reset_tip": "复位视角:回到场景自带的取景"
},
"chart": {
"fn": "FN",
@@ -402,6 +407,11 @@
"deform": "放大形变",
"deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。",
"deform_unavailable": "当前数据源未提供三维位移:本面板只有光流幅值的曲面着色,形变放大不可用。",
"field": "颜色来源",
"field_tip": "三维表面用哪个量上色:深度响应(链路的相对形变响应,无量纲)或光流幅值(追踪器的像素位移大小)。两者都不是毫米,都是近似量;选中的那个若在这一帧画不出来,会自动改用另一个,并在这行下面说明。",
"field_depth": "深度响应",
"field_flow": "光流幅值",
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
"quantity_flow_magnitude": "光流幅值",
"quantity_depth_response": "深度响应",
"mode_surface": "曲面着色",
@@ -417,6 +427,8 @@
"max_fps": "显示帧率",
"max_fps_tip": "三维视图每秒绘制多少帧。这只是显示预算:摄像头与记录不受影响,这里丢的帧永远不会变成丢的样本。",
"reset_view": "复位视角",
"settings_title": "三维显示设置",
"settings_hint": "这里的每一项都只影响三维视图的显示:采集、记录与力曲线完全不受影响,改完立即生效。",
"legend": "{quantity} [{unit}] · {quality}",
"legend_none": "屏幕上没有测量值 — 仅显示不着色的表面模型",
"quality_calibrated": "已标定",
+42 -10
View File
@@ -80,13 +80,13 @@ logger = logging.getLogger(__name__)
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
DEFAULT_SPLITTER_SIZES = (340, 480, 400)
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
MIN_PANEL_WIDTHS = {
# The 3D pane is the only one whose content is a real control panel rather than a
# label or a picture, and its minimum is measured under the application
# stylesheet in both languages: the widest case is English at 241 px
# (``tests/test_scene3d_view.py`` pins it), so 240 would have been 1 px short.
"mesh3d": 260,
# The 3D pane used to be the one panel whose content was a stack of form fields, so
# its minimum was measured under the stylesheet in both languages (241 px in English)
# and could not go below 260. The controls now live in the settings dialog and what
# is left wraps, so the pane can be dragged narrow again — the picture is the point.
"mesh3d": 200,
"arrow": 300,
"plots": 340,
"cal": 300,
@@ -99,9 +99,11 @@ VIEW_PRESETS = ("custom", "live", "replay", "calibration")
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
#: Replay is the live layout on purpose: the two views differ in their source
#: (a camera, or a recorded video), not in their furniture.
#: The 3D pane is the widest of the three: it is the view's subject when it is on, and
#: the 2D panel is a reading of the same measurement rather than a second subject.
VIEW_SPLITTER_SIZES = {
"live": (0, 360, 1080, 0),
"replay": (0, 360, 1080, 0),
"live": (620, 300, 520, 0),
"replay": (620, 300, 520, 0),
"calibration": (0, 0, 560, 880),
}
#: The 3D pane belongs to the live view and to history browsing, and only for a
@@ -114,7 +116,7 @@ VIEW_SHOWS_3D = ("custom", "live", "replay")
#: Width the 3D pane takes from the widest other panel when a preset's table gives it
#: nothing. The table describes the two-dimensional layout; the pane is optional, and
#: "not in the table" must not mean "standing at its minimum width".
DEFAULT_3D_PANE_WIDTH = 340
DEFAULT_3D_PANE_WIDTH = 620
#: Kept under its historical name for ``tools/ui_snapshot.py``, which imports it from
@@ -239,7 +241,7 @@ class StudioMainWindow(QMainWindow):
self.status_chip = self.toolbar.status_chip
self.content_splitter = QSplitter(Qt.Horizontal)
self.mesh3d_panel = Mesh3dPanel()
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
self.arrow_panel = ArrowPanel(theme=self._theme)
self.force_plots = ForcePlotsPanel(theme=self._theme)
# The charts browse the whole session, not just their rolling buffer.
@@ -276,6 +278,7 @@ class StudioMainWindow(QMainWindow):
toolbar=self.toolbar,
charts=self.force_plots,
arrow_panel=self.arrow_panel,
mesh_panel=self.mesh3d_panel,
engine=self.replay,
sensor_picker=self.sensor_picker,
plot_defaults={
@@ -899,12 +902,29 @@ class StudioMainWindow(QMainWindow):
self.act_finger_3d.setChecked(self._show_finger_3d)
self.view_menu.addAction(self.act_finger_3d)
# Everything the 3D pane can be told lives in one dialog: the pane itself is for
# looking at (see ``Mesh3dPanel``). This is the menu way in; the pane's header
# has the gear, which is the same call.
self.act_scene3d_settings = QAction(self)
self.act_scene3d_settings.triggered.connect(self._on_scene3d_settings)
self.view_menu.addAction(self.act_scene3d_settings)
self.act_show_fps = QAction(self)
self.act_show_fps.setCheckable(True)
self.act_show_fps.setChecked(self._show_fps)
self.act_show_fps.toggled.connect(self.display_ctl.on_show_fps_toggled)
self.view_menu.addAction(self.act_show_fps)
# How much the panels explain themselves around their pictures. Checked *before*
# the signal is connected, so restoring the preference cannot look like a click.
self.act_show_diagnostics = QAction(self)
self.act_show_diagnostics.setCheckable(True)
self.act_show_diagnostics.setChecked(self.display_ctl.show_diagnostics)
self.act_show_diagnostics.toggled.connect(
self.display_ctl.on_show_diagnostics_toggled
)
self.view_menu.addAction(self.act_show_diagnostics)
self.act_settings = QAction(self)
self.act_settings.setCheckable(True)
self.act_settings.setChecked(self.toolbar.settings_expanded())
@@ -1145,7 +1165,9 @@ class StudioMainWindow(QMainWindow):
self.view_menu.setTitle(t("menu.view"))
self.act_cal_panel.setText(t("menu.calibration_panel"))
self.act_finger_3d.setText(t("menu.finger_3d"))
self.act_scene3d_settings.setText(t("menu.scene3d_settings"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_show_diagnostics.setText(t("menu.show_diagnostics"))
self.act_settings.setText(t("menu.settings"))
self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
@@ -1350,6 +1372,7 @@ class StudioMainWindow(QMainWindow):
self.brand_bar.set_theme(theme)
self.toolbar.set_theme(theme)
self.arrow_panel.set_theme(theme)
self.mesh3d_panel.set_theme(theme)
self._refresh_status_chip()
self._refresh_status_rail()
self._refresh_record_state()
@@ -1652,6 +1675,14 @@ class StudioMainWindow(QMainWindow):
return
view.submit_packet(packet)
def _on_scene3d_settings(self) -> None:
"""Open the 3D settings dialog — or say why there is nothing to configure."""
if not self.mesh3d_panel.open_settings():
self._notify(
self.mesh3d_panel.message.text().strip()
or t("panels.mesh3d_no_scene")
)
def _on_sensor_type_changed(self, sensor_type: str) -> None:
self.prefs["sensor_type"] = str(sensor_type)
self.prefs["config_name"] = self.sensor_picker.config_name()
@@ -2285,6 +2316,7 @@ class StudioMainWindow(QMainWindow):
self.mesh3d_panel.display_settings()
)
self.prefs["show_fps"] = bool(self._show_fps)
self.prefs["show_diagnostics"] = bool(self.display_ctl.show_diagnostics)
self.prefs["settings_expanded"] = self.toolbar.settings_expanded()
self.prefs["view_preset"] = self._view_preset
self.prefs["sensor_type"] = self.sensor_picker.sensor_type()
@@ -62,6 +62,7 @@ class DisplayPrefsController(QObject):
plot_defaults: dict,
default_sample_rate_hz: float,
arrow_rotate_types: frozenset,
mesh_panel: Any = None,
rotate_arrows_action: Any = None,
parent: Optional[QObject] = None,
) -> None:
@@ -70,6 +71,7 @@ class DisplayPrefsController(QObject):
self._toolbar = toolbar
self._charts = charts
self._arrow_panel = arrow_panel
self._mesh_panel = mesh_panel
self._engine = engine
self._picker = sensor_picker
self._rotate_action = rotate_arrows_action
@@ -79,6 +81,10 @@ class DisplayPrefsController(QObject):
#: Whether the rail shows the frame rate: a display preference, so it lives with
#: the other ones rather than with the session that produces the number.
self._show_fps = bool(host.prefs.get("show_fps", False))
#: Whether the panels explain the pipeline around the picture (see
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
self._apply_diagnostics()
def bind_actions(self, *, rotate_arrows: Any) -> None:
"""Take the rotate entry once the menus exist (it follows the sensor)."""
@@ -225,3 +231,33 @@ class DisplayPrefsController(QObject):
# FPS has one home now: the status rail.
self._host._refresh_status_rail()
self._host._persist_prefs()
# -------------------------------------------------------------- diagnostics
@property
def show_diagnostics(self) -> bool:
return self._show_diagnostics
@show_diagnostics.setter
def show_diagnostics(self, visible: bool) -> None:
self._show_diagnostics = bool(visible)
self._apply_diagnostics()
def on_show_diagnostics_toggled(self, visible: bool) -> None:
"""How much the panels explain themselves around their pictures.
On: the 2D panel states the pipeline it ran (magnification, source → output,
which frames) and the 3D pane adds its informational lines. Off: both show what
the picture *is* — quantity, range, where the field came from, and any warning
about the measurement — because that is what a viewer needs, while the rest was
reading like debug output under the canvas.
"""
self._show_diagnostics = bool(visible)
self._host.prefs["show_diagnostics"] = self._show_diagnostics
self._apply_diagnostics()
self._host._persist_prefs()
def _apply_diagnostics(self) -> None:
for panel in (self._arrow_panel, self._mesh_panel):
setter = getattr(panel, "set_diagnostics_visible", None)
if callable(setter):
setter(self._show_diagnostics)
+162 -23
View File
@@ -63,6 +63,27 @@ DEPTH_RESPONSE = FieldDescriptor(
)
def _field_name(name: Any) -> str:
"""The quantity a setting asked for, repaired to one the adapters can draw."""
wanted = str(name or "")
return wanted if wanted in ("depth", "flow") else "depth"
#: How much a refusal tells the person reading it. "The frame does not carry this field"
#: is the weakest answer (it may simply be the other quantity's turn); "the field is here
#: but is not this frame's, or not on this grid" is the strongest.
_REFUSAL_RANK = {
"field_unavailable": 0,
"field_unverified": 1,
"field_stale": 2,
"field_domain_mismatch": 3,
}
def _refusal_rank(item: Diagnostic) -> int:
return _REFUSAL_RANK.get(str(item.code), 0)
def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
"""``|flow|`` as float32, or ``None`` when the packet carries no flow."""
flow = getattr(packet, "flow", None)
@@ -186,6 +207,7 @@ class FingerFrameAdapter:
shape_hw: tuple[int, int],
map_x: Optional[np.ndarray] = None,
map_y: Optional[np.ndarray] = None,
preferred_field: str = "depth",
) -> None:
self.geometry_id = str(geometry_id)
self.field_domain_id = str(field_domain_id)
@@ -193,6 +215,18 @@ class FingerFrameAdapter:
self.map_x = map_x
self.map_y = map_y
self.last_diagnostic: Optional[Diagnostic] = None
#: Which quantity the operator asked for (``"depth"`` or ``"flow"``). The other
#: one is tried when this one cannot be coloured, and the substitution is stated
#: rather than silent — a frame that shows another quantity than the setting says
#: has to say so, or the picture reads as the deformation response when it is
#: actually the flow.
self.preferred_field = _field_name(preferred_field)
#: Set (and cleared) by every snapshot: the reason the picture is not the field
#: the operator chose. ``None`` when the preferred field was the one drawn.
self.last_substitution: Optional[Diagnostic] = None
def set_preferred_field(self, name: str) -> None:
self.preferred_field = _field_name(name)
@classmethod
def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter":
@@ -208,16 +242,102 @@ class FingerFrameAdapter:
def snapshot(
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
) -> Optional[SurfaceFieldFrame]:
# Provenance first, and before the cheap array work: a packet that cannot say
# its flow is this frame's must not be coloured, whatever the array holds.
"""Colour the surface with the field the operator chose, or say why not.
Two quantities can be drawn and the operator picks one (``DisplaySettings.field``);
the other is tried when the chosen one cannot be coloured, and the substitution is
reported through :attr:`last_substitution` so the pane can state it. Nothing here
invents a field: a value the producer did not claim for *this* frame is refused
whichever quantity it belongs to.
"""
first = self.preferred_field
order = ("depth", "flow") if first == "depth" else ("flow", "depth")
refused: list[Diagnostic] = []
for name in order:
frame = self._snapshot_field(packet, identity, name, refused)
if frame is None:
continue
if name != first:
# The operator asked for the other one: the picture is still a measurement
# (the legend names it), but the choice was not honoured, and that is a
# fact about what is on screen.
self.last_substitution = diagnostic(
"field_substituted",
f"the {first} response cannot be drawn; showing the "
f"{'optical flow magnitude' if name == 'flow' else 'deformation response'}",
wanted=first,
shown=name,
why=refused[0].message if refused else "",
)
else:
self.last_substitution = None
return frame
self.last_substitution = None
# Neither could be drawn. The *most informative* refusal leads: a field that is
# there but cannot be used (another grid, another frame's array, no statement) says
# far more than "this frame does not carry it" — and among equally informative
# ones the operator's own choice comes first, because that is what they asked for.
self.last_diagnostic = max(refused, key=_refusal_rank) if refused else diagnostic(
"field_unavailable", "this frame carries no scalar field"
)
return None
def _snapshot_field(
self,
packet: SdkPacket,
identity: FrameIdentity,
name: str,
refused: list[Diagnostic],
) -> Optional[SurfaceFieldFrame]:
"""One quantity, or ``None`` with the reason appended to ``refused``."""
if name == "depth":
depth = getattr(packet, "depth", None)
problem = _field_validity(packet, "depth", identity.frame_id)
if problem is not None:
refused.append(problem)
return None
if depth is None:
refused.append(
diagnostic("field_unavailable", "this frame carries no depth response")
)
return None
array = np.asarray(depth, dtype=np.float32)
if array.ndim != 2:
refused.append(
diagnostic(
"field_domain_mismatch",
"the depth response is not a 2-D field",
depth=_shape_of(depth),
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
)
)
return None
if tuple(array.shape) != self.shape_hw:
refused.append(
diagnostic(
"field_domain_mismatch",
"the depth response is not in the scene's UV domain and no "
"remap is configured",
depth=_shape_of(depth),
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
)
)
return None
scalar = freeze(array, dtype=np.float32)
mask = freeze(np.isfinite(array), dtype=np.bool_)
return self._finish(
packet, identity, name, scalar, mask, DEPTH_RESPONSE, refused
)
# ``flow``: the tracker's field, already unrolled onto the scene's UV domain.
problem = _field_validity(packet, "flow", identity.frame_id)
if problem is not None:
self.last_diagnostic = problem
refused.append(problem)
return None
magnitude = flow_magnitude(packet)
if magnitude is None:
self.last_diagnostic = diagnostic(
"field_unavailable", "this frame carries no optical flow"
refused.append(
diagnostic("field_unavailable", "this frame carries no optical flow")
)
return None
if self.map_x is not None and self.map_y is not None:
@@ -226,41 +346,60 @@ class FingerFrameAdapter:
magnitude, map_x=self.map_x, map_y=self.map_y, shape_hw=self.shape_hw
)
except Exception as exc: # noqa: BLE001 - a bad map is a diagnostic
self.last_diagnostic = getattr(exc, "diagnostic", None) or diagnostic(
"field_domain_mismatch", f"remap failed: {type(exc).__name__}: {exc}"
refused.append(
getattr(exc, "diagnostic", None)
or diagnostic(
"field_domain_mismatch",
f"remap failed: {type(exc).__name__}: {exc}",
)
)
return None
scalar = values
mask = valid
scalar, mask = values, valid
elif tuple(magnitude.shape[:2]) == self.shape_hw:
scalar = freeze(magnitude, dtype=np.float32)
mask = freeze(np.isfinite(magnitude), dtype=np.bool_)
else:
# Say what was seen: this is the message that tells an engineer whether
# the flow arrives in the tracker's grid or already unrolled.
self.last_diagnostic = diagnostic(
"field_domain_mismatch",
"the flow is not in the scene's UV domain and no remap is configured",
flow=_shape_of(magnitude),
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
# Say what was seen: this is the message that tells an engineer whether the
# flow arrives in the tracker's grid or already unrolled.
refused.append(
diagnostic(
"field_domain_mismatch",
"the flow is not in the scene's UV domain and no remap is configured",
flow=_shape_of(magnitude),
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
)
)
return None
# Inside the UV island is the only place a flow value means anything: the
# tracker is looking at masked black outside it.
return self._finish(
packet, identity, name, scalar, mask, FLOW_MAGNITUDE, refused
)
def _finish(
self,
packet: SdkPacket,
identity: FrameIdentity,
name: str,
scalar: np.ndarray,
mask: np.ndarray,
descriptor: FieldDescriptor,
refused: list[Diagnostic],
) -> Optional[SurfaceFieldFrame]:
"""Intersect with the device's own mask and build the frame."""
# Inside the UV island is the only place a value means anything: the tracker is
# looking at masked black outside it.
device, mask_problem = _device_mask(packet, self.shape_hw)
if mask_problem is not None:
self.last_diagnostic = mask_problem
refused.append(mask_problem)
return None
mask = _intersect(mask, device)
self.last_diagnostic = None
return SurfaceFieldFrame(
identity=identity,
geometry_id=self.geometry_id,
field_domain_id=self.field_domain_id,
scalar=scalar,
scalar_descriptor=FLOW_MAGNITUDE,
valid=mask,
computed_frame_id=_computed_frame_id(packet, "flow", identity.frame_id),
scalar_descriptor=descriptor,
valid=_intersect(mask, device),
computed_frame_id=_computed_frame_id(packet, name, identity.frame_id),
)
+236 -44
View File
@@ -49,16 +49,16 @@ from orisys_app_shared.scene3d.contracts import (
SceneCapabilities,
SceneState,
)
from orisys_studio.scene3d.prefs import FPS_CHOICES
from orisys_studio.scene3d.prefs import FPS_CHOICES, DEFAULT_FIELD
from orisys_studio.ui.fit_text import ShrinkableLabel
if TYPE_CHECKING: # the panel talks to these two by interface, not by import
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.widget import Sensor3DWidget
#: Width of one control in the pane. Chosen against
#: ``MIN_PANEL_WIDTHS["mesh3d"]`` (240), not against how it looks on a wide screen:
#: the pane is a splitter child that the operator can drag narrow.
FIELD_WIDTH = 88
#: Width of one control in the settings dialog. The pane no longer has to fit them —
#: they live in ``Scene3dSettingsDialog`` — so they are sized to read, not to squeeze.
FIELD_WIDTH = 108
SPIN_WIDTH = 68
#: Display limits offered in the panel. ``max_fps`` is a *display* budget; it never
@@ -66,6 +66,13 @@ SPIN_WIDTH = 68
#: saved-settings repair has to accept exactly the values this combo offers.
__all__ = ["FPS_CHOICES", "ThreeDPanel"]
#: Which quantity each choice of ``DisplaySettings.field`` means on the surface.
_FIELD_QUANTITY = {"depth": "depth_response", "flow": "flow_magnitude"}
#: Diagnostics that stay on screen even with 「诊断信息」 off: they are claims about the
#: *measurement* (was it compared with the device?), not explanations of the pipeline.
_ALWAYS_VISIBLE_INFO = frozenset({"geometry_unverified"})
class ColorScaleBar(QLabel):
"""The colour ramp the shader actually used, with the bounds it used them for.
@@ -134,23 +141,39 @@ class ThreeDPanel(QFrame):
self._capabilities: Optional[SceneCapabilities] = None
#: The bounds the last draw actually used (the widget's RenderStats).
self._effective_scale: Optional[RenderStats] = None
#: Whether the pane explains the pipeline around the picture (the window's
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
self._show_diagnostics = False
root = QVBoxLayout(self)
root.setContentsMargins(8, 6, 8, 8)
root.setContentsMargins(8, 6, 8, 6)
root.setSpacing(6)
# ------------------------------------------------------------------ controls
# Every *setting* lives in one block that the pane itself never shows. What the
# pane is for is the picture, and a column of form fields beside it was what left
# the 3D view a corner of its own splitter slot. The block is handed to
# ``Scene3dSettingsDialog`` — opened from 视图 ▸ 三维显示设置 or from the gear on
# the pane's header — and the widget attributes below stay on this object, so the
# rest of the application (and its tests) reach the controls the same way as
# before.
self.controls_widget = QWidget(self)
self.controls_widget.setObjectName("scene3dControls")
self.controls_widget.setVisible(False)
controls = QVBoxLayout(self.controls_widget)
controls.setContentsMargins(0, 0, 0, 0)
controls.setSpacing(6)
self.enable_check = QCheckBox()
self.enable_check.toggled.connect(self._on_enabled_toggled)
root.addWidget(self.enable_check)
controls.addWidget(self.enable_check)
form = QFormLayout()
form.setContentsMargins(0, 0, 0, 0)
self.preset_label = QLabel()
self.preset_combo = QComboBox()
# The pane is 240 px wide at its narrowest (``MIN_PANEL_WIDTHS["mesh3d"]``),
# so the fields are compact and the panel's own minimum stays below that.
# Without this the layout's minimum is set by two spin boxes side by side,
# the splitter cannot honour the panel width, and the picture is squeezed out.
# The dialog is a dialog: the fields are comfortable there, and the pane no
# longer has to be at least as wide as two spin boxes side by side.
self.preset_combo.setMaximumWidth(FIELD_WIDTH)
self.preset_combo.currentIndexChanged.connect(self._on_preset_chosen)
form.addRow(self.preset_label, self.preset_combo)
@@ -178,6 +201,27 @@ class ThreeDPanel(QFrame):
gain_row.addStretch(1)
form.addRow(gain_row)
# Which quantity colours the surface. Two are available and they answer
# different questions — "how deep" (the chain's deformation response) and "how
# fast is the picture moving" (the tracker's optical flow) — so the operator
# picks, and the line under it says which one is actually on screen.
self.field_label = QLabel()
self.field_combo = QComboBox()
self.field_combo.addItem("depth", "depth")
self.field_combo.addItem("flow", "flow")
self.field_combo.setCurrentIndex(
0 if DEFAULT_FIELD == "depth" else 1
)
self.field_combo.setMaximumWidth(FIELD_WIDTH)
self.field_combo.currentIndexChanged.connect(self._on_field_changed)
form.addRow(self.field_label, self.field_combo)
self.field_note = QLabel()
self.field_note.setObjectName("hintText")
self.field_note.setWordWrap(True)
self.field_note.setVisible(False)
form.addRow(self.field_note)
self.scale_label = QLabel()
self.scale_combo = QComboBox()
self.scale_combo.addItem("fixed", "fixed")
@@ -192,9 +236,7 @@ class ThreeDPanel(QFrame):
form.addRow(self.scale_label, self.scale_combo)
# The two bounds get their own row rather than sharing the mode's: three
# controls side by side made this row the widest thing in the pane, and a pane
# whose content demands more than ``MIN_PANEL_WIDTHS["mesh3d"]`` clips its own
# controls when the operator drags the splitter to the declared minimum.
# controls side by side made this row the widest thing in the pane.
self.range_label = QLabel()
self.lower_spin = QDoubleSpinBox()
self.lower_spin.setRange(-1e6, 1e6)
@@ -221,36 +263,63 @@ class ThreeDPanel(QFrame):
self.fps_combo.setMaximumWidth(FIELD_WIDTH)
self.fps_combo.currentIndexChanged.connect(self._emit_settings)
form.addRow(self.fps_label, self.fps_combo)
root.addLayout(form)
controls.addLayout(form)
buttons = QHBoxLayout()
self.reset_button = QPushButton()
self.reset_button.clicked.connect(self.resetRequested.emit)
buttons.addWidget(self.reset_button)
buttons.addStretch(1)
root.addLayout(buttons)
controls.addLayout(buttons)
self.legend_label = QLabel()
self.legend_label.setObjectName("panelMeta")
self.legend_label.setWordWrap(True)
self.legend_label.setMinimumWidth(1)
root.addWidget(self.legend_label)
# Display-side statistics: an engineering readout, not something to read while
# measuring, so it belongs with the settings rather than under the picture.
self.stats_label = QLabel()
self.stats_label.setObjectName("panelMeta")
self.stats_label.setWordWrap(True)
self.stats_label.setMinimumWidth(1)
controls.addWidget(self.stats_label)
controls.addStretch(1)
# The ramp the shader used, under the numbers it used it for. Text alone could
# not express an *automatic* range, and the disabled spin boxes next to it held
# values that were not being drawn with.
# -------------------------------------------------------------------- legend
# The ramp and its effective numeric bounds remain visible in normal mode.
# Everything explanatory below them follows the diagnostics switch.
scale_row = QHBoxLayout()
scale_row.setContentsMargins(0, 0, 0, 0)
scale_row.setSpacing(6)
self.scale_lower_label = QLabel()
self.scale_lower_label.setObjectName("panelMeta")
self.scale_lower_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
scale_row.addWidget(self.scale_lower_label)
self.scale_bar = ColorScaleBar()
root.addWidget(self.scale_bar)
scale_row.addWidget(self.scale_bar, 1)
self.scale_upper_label = QLabel()
self.scale_upper_label.setObjectName("panelMeta")
self.scale_upper_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
scale_row.addWidget(self.scale_upper_label)
root.addLayout(scale_row)
legend_row = QHBoxLayout()
legend_row.setContentsMargins(0, 0, 0, 0)
legend_row.setSpacing(8)
# ``ShrinkableLabel``: the legend is one line that clips (its tooltip keeps the
# whole text) instead of wrapping into a second row — a footer that grows a line
# whenever a number gets long is what made the pane look like debug output.
self.legend_label = ShrinkableLabel()
self.legend_label.setObjectName("panelMeta")
self.legend_label.setWordWrap(False)
legend_row.addWidget(self.legend_label, 3)
#: Which *source* the field on screen came from. A separate line from the
#: scene state, because they answer different questions: the state says whether
#: anything is being measured, this says whether it was acquired live or
#: recomputed from a recording — a distinction the design document insists on.
self.source_label = QLabel()
self.source_label = ShrinkableLabel()
self.source_label.setObjectName("panelMeta")
self.source_label.setWordWrap(True)
self.source_label.setMinimumWidth(1)
root.addWidget(self.source_label)
self.source_label.setWordWrap(False)
self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
legend_row.addWidget(self.source_label, 2)
root.addLayout(legend_row)
self.state_label = QLabel()
self.state_label.setObjectName("hintText")
@@ -258,13 +327,6 @@ class ThreeDPanel(QFrame):
self.state_label.setMinimumWidth(1)
root.addWidget(self.state_label)
self.stats_label = QLabel()
self.stats_label.setObjectName("panelMeta")
self.stats_label.setWordWrap(True)
self.stats_label.setMinimumWidth(1)
root.addWidget(self.stats_label)
root.addStretch(1)
self.retranslate()
self.set_scene(None)
self.set_state("disabled")
@@ -294,15 +356,20 @@ class ThreeDPanel(QFrame):
def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None:
"""The measurement currently on screen — or none, which hides the bar."""
if descriptor != self._descriptor:
self._effective_scale = None
self._descriptor = descriptor
self._render_field_note()
if descriptor is None or descriptor.quantity == "unavailable":
# No measurement: do not leave the previous range on display.
self.legend_label.setText(t("scene3d.legend_none"))
self.legend_label.setVisible(True)
self.legend_label.setVisible(self._show_diagnostics)
self.scale_combo.setEnabled(False)
self.lower_spin.setEnabled(False)
self.upper_spin.setEnabled(False)
self.scale_bar.setVisible(False)
self.scale_lower_label.setVisible(False)
self.scale_upper_label.setVisible(False)
return
self.scale_combo.setEnabled(True)
fixed = self.scale_combo.currentData() == "fixed"
@@ -312,6 +379,7 @@ class ThreeDPanel(QFrame):
"diverging" if descriptor.signed and not fixed else "sequential"
)
self.scale_bar.setVisible(True)
self._render_scale_bounds()
self._render_legend()
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
@@ -350,7 +418,7 @@ class ThreeDPanel(QFrame):
quantity = descriptor.quantity
quality = t(f"scene3d.quality_{descriptor.quality}")
if effective is None or effective.scale_mode == "":
self.legend_label.setText(
self._set_legend(
t(
"scene3d.legend",
quantity=quantity,
@@ -358,28 +426,62 @@ class ThreeDPanel(QFrame):
quality=quality,
)
)
self.legend_label.setVisible(self._show_diagnostics)
return
mode = t(
"scene3d.legend_auto"
if effective.scale_mode == "automatic"
else "scene3d.legend_fixed"
)
self.legend_label.setText(
self._set_legend(
t(
"scene3d.legend_effective",
quantity=quantity,
unit=descriptor.unit,
mode=mode,
lower=f"{effective.scale_lower:.4g}",
upper=f"{effective.scale_upper:.4g}",
# Three significant digits: the footer is one line, and "0.033 – 0.90"
# says as much as "0.03337 – 0.8993" while leaving room for the rest.
lower=f"{effective.scale_lower:.3g}",
upper=f"{effective.scale_upper:.3g}",
quality=quality,
)
)
self.legend_label.setVisible(self._show_diagnostics)
def _render_scale_bounds(self) -> None:
"""Show only the numeric bounds needed to read the color ramp."""
descriptor = self._descriptor
visible = bool(
descriptor is not None and descriptor.quantity != "unavailable"
)
lower = ""
upper = ""
mode = str(self.scale_combo.currentData() or "fixed")
effective = self._effective_scale
if visible and mode == "fixed":
lower = f"{self.lower_spin.value():.3g}"
upper = f"{self.upper_spin.value():.3g}"
elif (
visible
and effective is not None
and effective.scale_mode == "automatic"
):
lower = f"{effective.scale_lower:.3g}"
upper = f"{effective.scale_upper:.3g}"
self.scale_lower_label.setText(lower)
self.scale_upper_label.setText(upper)
self.scale_lower_label.setVisible(visible)
self.scale_upper_label.setVisible(visible)
def _set_legend(self, text: str) -> None:
"""One legend line, with the whole text in the tooltip (it may be clipped)."""
self.legend_label.setText(text)
self.legend_label.setToolTip(text)
def set_state(self, state: SceneState) -> None:
self._state = state
self.state_label.setText(t(f"scene3d.state_{state}"))
self.state_label.setVisible(state != "live")
self.state_label.setVisible(self._show_diagnostics and state != "live")
def set_source_kind(self, kind: str) -> None:
"""Say where the field comes from: ``live``, ``replay`` or ``recompute``.
@@ -400,19 +502,86 @@ class ThreeDPanel(QFrame):
self._source_kind = kind
self.source_label.setText(t(f"scene3d.source_{kind}"))
self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip"))
self.source_label.setVisible(self._show_diagnostics)
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
"""One diagnostic line. The 2D views stay usable behind it."""
if diagnostic is None:
"""One diagnostic line. The 2D views stay usable behind it.
Warnings and errors are always stated: they are claims about the *measurement*
(this frame's field is not its own, the device was never compared with the scene).
Informational lines explain the pipeline instead, so they follow the window's
「诊断信息」 switch — except the few that make a claim about the measurement too.
"""
shown = diagnostic is None or self._diagnostics_visible_for(diagnostic)
if diagnostic is None or not shown:
self._legend_reset_note()
return
prefix = t(f"scene3d.severity_{diagnostic.severity}")
self.legend_label.setText(f"{prefix}: {diagnostic.message}")
self.legend_label.setVisible(True)
def _diagnostics_visible_for(self, diagnostic: Diagnostic) -> bool:
if str(diagnostic.severity) != "info":
return True
return bool(self._show_diagnostics) or diagnostic.code in _ALWAYS_VISIBLE_INFO
def set_diagnostics_visible(self, visible: bool) -> None:
"""How much the pane explains itself (see the window's 诊断信息 switch).
Off: only the colour ramp and its numeric bounds remain, except that warnings
and errors about the measurement are still shown. On: quantity, source, state,
substitutions and pipeline explanations are restored.
"""
self._show_diagnostics = bool(visible)
self._render_field_note()
self.source_label.setVisible(self._show_diagnostics)
self.state_label.setVisible(
self._show_diagnostics and self._state != "live"
)
self.set_diagnostic(None)
def diagnostics_visible(self) -> bool:
return bool(self._show_diagnostics)
def _render_field_note(self) -> None:
"""Say it when the quantity on screen is not the one the setting asked for.
The adapter tries the chosen field first and falls back to the other when it
cannot colour it (a field the producer did not claim for this frame, or one on a
different grid). The legend names whatever *is* drawn, so this line is the other
half: the choice was not honoured, and which one is on screen instead.
"""
wanted = _FIELD_QUANTITY.get(self.field(), "")
shown = "" if self._descriptor is None else str(self._descriptor.quantity)
if (
not self._show_diagnostics
or not wanted
or not shown
or shown == wanted
or shown == "unavailable"
):
self.field_note.setText("")
self.field_note.setVisible(False)
return
self.field_note.setText(
t(
"scene3d.field_substituted",
shown=self._quantity_label(shown),
chosen=self._quantity_label(wanted),
)
)
self.field_note.setVisible(True)
@staticmethod
def _quantity_label(quantity: str) -> str:
label = t(f"scene3d.quantity_{quantity}")
return quantity if label.startswith("scene3d.") else label
def set_render_stats(self, stats: RenderStats) -> None:
"""Display-side statistics only: never shown as recording health."""
self._effective_scale = stats
self._render_scale_bounds()
self._render_legend()
self.stats_label.setText(
t(
@@ -446,6 +615,10 @@ class ThreeDPanel(QFrame):
self.set_descriptor(self._descriptor if enabled else None)
# ----------------------------------------------------------------- outputs
def field(self) -> str:
"""Which quantity the operator chose to colour the surface with."""
return str(self.field_combo.currentData() or DEFAULT_FIELD)
def apply_settings(self, settings: DisplaySettings) -> None:
"""Move the controls to these settings and tell the view about them.
@@ -459,6 +632,9 @@ class ThreeDPanel(QFrame):
self.deform_check.setChecked(bool(settings.deform_enabled))
self.gain_spin.setValue(float(settings.display_gain))
self.housing_check.setChecked(bool(settings.housing_visible))
index = self.field_combo.findData(str(settings.field))
if index >= 0:
self.field_combo.setCurrentIndex(index)
scale = settings.color_scale
if scale is not None:
index = self.scale_combo.findData(str(scale.mode))
@@ -501,6 +677,7 @@ class ThreeDPanel(QFrame):
),
housing_visible=self.housing_check.isChecked(),
max_fps=int(self.fps_combo.currentData()),
field=self.field(),
)
# ------------------------------------------------------------------ signals
@@ -527,9 +704,15 @@ class ThreeDPanel(QFrame):
self.set_descriptor(self._descriptor)
self._emit_settings()
def _on_field_changed(self, index: int) -> None:
"""A different quantity is a different colour scale as well as a different field."""
self._render_field_note()
self._emit_settings()
def _emit_settings(self) -> None:
if self._syncing:
return
self._render_scale_bounds()
self.settingsChanged.emit(self.settings())
def _legend_reset_note(self) -> None:
@@ -542,6 +725,11 @@ class ThreeDPanel(QFrame):
self.housing_check.setText(t("scene3d.housing"))
self.deform_check.setText(t("scene3d.deform"))
self.deform_check.setToolTip(t("scene3d.deform_tip"))
self.field_label.setText(t("scene3d.field"))
self.field_label.setToolTip(t("scene3d.field_tip"))
self.field_combo.setItemText(0, t("scene3d.field_depth"))
self.field_combo.setItemText(1, t("scene3d.field_flow"))
self._render_field_note()
self.scale_label.setText(t("scene3d.scale"))
self.range_label.setText(t("scene3d.scale_range"))
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
@@ -553,5 +741,9 @@ class ThreeDPanel(QFrame):
self.state_label.setText(t(f"scene3d.state_{self._state}"))
self.source_label.setText(t(f"scene3d.source_{self._source_kind}"))
self.source_label.setToolTip(t(f"scene3d.source_{self._source_kind}_tip"))
self.source_label.setVisible(self._show_diagnostics)
self.state_label.setVisible(
self._show_diagnostics and self._state != "live"
)
self.set_capabilities(self._capabilities)
self.set_descriptor(self._descriptor)
@@ -35,6 +35,12 @@ DEFAULT_MAX_FPS = 30
MIN_GAIN = 0.0
MAX_GAIN = 500.0
SCALE_MODES = ("fixed", "automatic")
#: Which scalar field may colour the surface; a saved value outside this set is
#: repaired to the default rather than handed to an adapter that cannot draw it.
FIELD_CHOICES = ("depth", "flow")
#: The deformation response rather than the optical flow: it is the quantity an
#: operator reads as "how deep", and it is what the panel offers first.
DEFAULT_FIELD = "depth"
#: The largest magnitude a colour bound may carry. Beyond this the LUT is useless
#: and the number is certainly a typo ("1e308" in a hand-edited file).
MAX_SCALE_BOUND = 1e12
@@ -73,6 +79,7 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
"display_gain": float(settings.display_gain),
"housing_visible": bool(settings.housing_visible),
"max_fps": int(settings.max_fps),
"field": str(settings.field),
}
if scale is not None:
block["color_scale"] = {
@@ -105,6 +112,9 @@ def settings_from_prefs(
max_fps = int(_number(raw.get("max_fps"), DEFAULT_MAX_FPS))
if max_fps not in FPS_CHOICES:
max_fps = DEFAULT_MAX_FPS
field = str(raw.get("field") or DEFAULT_FIELD)
if field not in FIELD_CHOICES:
field = DEFAULT_FIELD
scale: Optional[ColorScale] = None
saved = raw.get("color_scale")
if isinstance(saved, Mapping):
@@ -134,4 +144,5 @@ def settings_from_prefs(
color_scale=scale,
housing_visible=_flag(raw.get("housing_visible"), True),
max_fps=max_fps,
field=field,
)
@@ -0,0 +1,90 @@
"""``Scene3dSettingsDialog``: the 3D pane's settings, out of the picture's way.
The pane is for looking at a picture. Every *setting* the three-dimensional view has —
whether it is on, which camera preset, the housing, the exaggeration, the colour range
and the display rate — therefore lives in one dialog, opened from 视图 ▸ 三维显示设置 or
from the gear on the pane's header. What stays under the picture is only what *reading*
it needs: the colour ramp, the numbers it was drawn with, and which source produced the
field (see ``ThreeDPanel``).
The dialog owns no state of its own: it hosts the panel's control block
(``ThreeDPanel.controls_widget``) and closes with it untouched. That keeps one set of
widgets — and one set of signals — whether or not the dialog has ever been opened.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog,
QDialogButtonBox,
QLabel,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here
from orisys_studio.scene3d.panel import ThreeDPanel
class Scene3dSettingsDialog(QDialog):
"""A modeless home for the 3D pane's controls."""
def __init__(
self, panel: "ThreeDPanel", parent: Optional[QWidget] = None
) -> None:
super().__init__(parent)
self.setObjectName("scene3dSettings")
self.setWindowTitle(t("scene3d.settings_title"))
# Modeless on purpose: the operator tunes the colour range while watching the
# picture change, which a modal dialog over the pane would prevent.
self.setModal(False)
self._panel = panel
root = QVBoxLayout(self)
root.setContentsMargins(16, 14, 16, 12)
root.setSpacing(10)
self.hint = QLabel()
self.hint.setObjectName("hintText")
self.hint.setWordWrap(True)
root.addWidget(self.hint)
# Adding the block to this layout reparents it: the controls now live here, and
# the pane keeps only its legend. They are the *same* widgets the panel holds
# references to, so nothing about the settings path changes with the dialog.
root.addWidget(panel.controls_widget)
panel.controls_widget.setVisible(True)
self.buttons = QDialogButtonBox(QDialogButtonBox.Close)
self.buttons.rejected.connect(self.close)
self.buttons.accepted.connect(self.close)
root.addWidget(self.buttons)
self.retranslate()
def retranslate(self) -> None:
self.setWindowTitle(t("scene3d.settings_title"))
self.hint.setText(t("scene3d.settings_hint"))
# Qt ships its own translations for a button box, and the application installs
# none: without this the dialog ends in an English "Close" among Chinese labels.
close_button = self.buttons.button(QDialogButtonBox.Close)
if close_button is not None:
close_button.setText(t("dialog.close"))
# ------------------------------------------------------------------ Qt events
def closeEvent(self, event) -> None: # noqa: N802 - Qt naming
"""Closing *hides* the dialog: the controls have to stay alive in it."""
self.hide()
event.ignore()
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
"""Esc closes the dialog, as it does everywhere else in this application."""
if event.key() == Qt.Key_Escape:
self.hide()
return
super().keyPressEvent(event)
@@ -117,6 +117,9 @@ class Scene3DView(QWidget):
self._device_verified = False
#: Frames the producer did not even build (a frame was already waiting).
self._skipped_snapshots = 0
#: The settings dialog, built on first use. Held (never deleted) because the
#: control widgets live in it once it has been opened.
self._settings_dialog = None
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
@@ -360,6 +363,11 @@ class Scene3DView(QWidget):
def set_settings(self, settings: DisplaySettings) -> None:
if self._coordinator is not None:
self._coordinator.set_settings(settings)
# Which quantity colours the surface is an adapter choice, not a coordinator one:
# the adapter is the only thing that reads the packet's fields.
adapter = self._adapter
if adapter is not None and hasattr(adapter, "set_preferred_field"):
adapter.set_preferred_field(settings.field)
def apply_preset(self, name: str) -> None:
if self._assets is None:
@@ -425,6 +433,29 @@ class Scene3DView(QWidget):
if self._panel is not None:
self._panel.set_capabilities(self.capabilities())
# ------------------------------------------------------------------ settings
def open_settings(self) -> bool:
"""Show the settings dialog. ``False`` when there is nothing to configure.
The dialog hosts the panel's control block, so it is built here (where the panel
is) and only once: a second open reuses it, and the controls' signals were wired
at construction either way.
"""
if self._panel is None:
return False
if self._settings_dialog is None:
from orisys_studio.scene3d.settings_dialog import Scene3dSettingsDialog
self._settings_dialog = Scene3dSettingsDialog(self._panel, self)
self._settings_dialog.show()
self._settings_dialog.raise_()
self._settings_dialog.activateWindow()
return True
def settings_dialog(self):
"""The dialog, if it has been opened (for tests and for the window)."""
return self._settings_dialog
def begin_session(self) -> None:
"""The application is starting an acquisition session for this pane.
@@ -636,6 +667,10 @@ class Scene3DView(QWidget):
def close(self) -> None:
"""Release the GPU resources, stop the worker. Idempotent."""
if self._settings_dialog is not None:
# Hidden, not deleted: the control widgets live in it, and the panel keeps
# referring to them (a session's display settings are read back from there).
self._settings_dialog.hide()
if self._coordinator is not None:
self._coordinator.close()
if self._widget is not None:
@@ -175,6 +175,10 @@ class ArrowPanel(QFrame):
#: panel behaved before the plans existed.
self._modes: tuple[str, ...] = ()
self._plan_note = ""
#: Whether the footer explains the *pipeline* (magnification, source → output,
#: which frames) or only what the picture is. Off by default; the window's
#: 「诊断信息」 switch owns it.
self._show_diagnostics = False
self.view_combo = QComboBox()
self.view_combo.setMinimumWidth(140)
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
@@ -398,17 +402,34 @@ class ArrowPanel(QFrame):
What is shown follows the *modes this frame supports*: the magnification and
the valid area describe a flow drawing, so they are shown only when an arrow
mode is on offer.
The three labels that describe the *pipeline* — how much the arrows are
magnified, "raw image → algorithm output", which frames — follow the window's
「诊断信息」 switch instead: they explain the inside of the pipeline, not what the
picture is, and under the canvas they are the first thing to become noise. What
stays regardless is what the picture *is*: the plan's own line ("this recording
stores only the raw camera image"), the depth scale, and the colour bar.
"""
depth = self.view_mode() == DEPTH
modes = self.view_modes()
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes
self.scale_label.setVisible(not depth and has_arrows)
self.pipeline_label.setVisible(not depth)
self.area_label.setVisible(not depth and has_arrows)
explain = bool(self._show_diagnostics)
self.scale_label.setVisible(explain and not depth and has_arrows)
self.pipeline_label.setVisible(explain and not depth)
self.area_label.setVisible(explain and not depth and has_arrows)
self.colorbar.setVisible(depth)
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
self.note_label.setVisible(bool(self._plan_note))
def set_diagnostics_visible(self, visible: bool) -> None:
"""How much the panel explains the pipeline around the picture (see the window's
「诊断信息」 switch)."""
self._show_diagnostics = bool(visible)
self._refresh_footer_visibility()
def diagnostics_visible(self) -> bool:
return bool(self._show_diagnostics)
def _populate_view_combo(self) -> None:
current = self.view_combo.currentData()
self.view_combo.blockSignals(True)
+39 -1
View File
@@ -20,7 +20,7 @@ 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", "settings")
NAMES = ("zoom_fit", "box_zoom", "latest", "settings", "reset_view")
ICON_SIZE = 16
_PEN_WIDTH = 1.4
@@ -93,11 +93,49 @@ def _paint_settings(painter: QPainter, size: int) -> None:
)
def _paint_reset_view(painter: QPainter, size: int) -> None:
"""A circular arrow: put the view back the way the asset framed it."""
import math
centre = size / 2.0
radius = size * 0.30
# Three quarters of a circle, drawn as short chords: Qt's arc API needs a rect and
# sixteenths of a degree, and the gap is what makes it read as "again".
start = -0.30 * math.pi
end = 1.45 * math.pi
steps = 18
previous = None
for index in range(steps + 1):
angle = start + (end - start) * index / steps
point = (
centre + radius * math.cos(angle),
centre + radius * math.sin(angle),
)
if previous is not None:
painter.drawLine(
int(round(previous[0])), int(round(previous[1])),
int(round(point[0])), int(round(point[1])),
)
previous = point
# The head sits at the open end of the arc, pointing the way the arrow runs.
head = size * 0.20
tip_x, tip_y = previous
painter.drawLine(
int(round(tip_x)), int(round(tip_y)),
int(round(tip_x - head)), int(round(tip_y - head * 0.2)),
)
painter.drawLine(
int(round(tip_x)), int(round(tip_y)),
int(round(tip_x - head * 0.2)), int(round(tip_y + head)),
)
_PAINTERS = {
"zoom_fit": _paint_zoom_fit,
"box_zoom": _paint_box_zoom,
"latest": _paint_latest,
"settings": _paint_settings,
"reset_view": _paint_reset_view,
}
@@ -16,8 +16,16 @@ from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import QFrame, QLabel, QStackedLayout, QVBoxLayout, QWidget
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QStackedLayout,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import (
@@ -32,6 +40,7 @@ from orisys_studio.scene3d.asset_source import (
find_package,
no_asset_diagnostic,
)
from orisys_studio.ui import icons
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
from orisys_app_shared.acquisition.geometry import DeviceGeometry
@@ -71,9 +80,12 @@ class Mesh3dPanel(QFrame):
#: from this, so no packet is adapted while the pane is off.
enabledChanged = pyqtSignal(bool)
def __init__(self, parent: Optional[QWidget] = None) -> None:
def __init__(
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = str(theme)
self._view: Optional["Scene3DView"] = None
self._loader: Optional[SceneAssetLoader] = None
self._source: Optional[SceneSource] = None
@@ -96,12 +108,37 @@ class Mesh3dPanel(QFrame):
self._pending_settings = None
self._message_kind = _UNAVAILABLE
self._message_args: dict[str, str] = {"reason": ""}
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
#: until the view exists (see ``set_diagnostics_visible``).
self._show_diagnostics = False
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
# The header carries the title and the two actions that belong to *looking*:
# put the view back to the asset's own framing, and open the settings. Everything
# else the pane can be told lives in that dialog, so the picture is the pane.
header = QHBoxLayout()
header.setContentsMargins(8, 4, 8, 0)
header.setSpacing(4)
self.title = QLabel()
self.title.setObjectName("panelTitle")
root.addWidget(self.title)
header.addWidget(self.title)
header.addStretch(1)
self.btn_reset_view = QPushButton()
self.btn_reset_view.setProperty("compact", "true")
self.btn_reset_view.setProperty("iconOnly", "true")
self.btn_reset_view.setCursor(Qt.PointingHandCursor)
self.btn_reset_view.clicked.connect(self.reset_view)
header.addWidget(self.btn_reset_view)
self.btn_settings = QPushButton()
self.btn_settings.setProperty("compact", "true")
self.btn_settings.setProperty("iconOnly", "true")
self.btn_settings.setCursor(Qt.PointingHandCursor)
self.btn_settings.clicked.connect(self.open_settings)
header.addWidget(self.btn_settings)
root.addLayout(header)
self._stack = QStackedLayout()
root.addLayout(self._stack, 1)
@@ -112,8 +149,57 @@ class Mesh3dPanel(QFrame):
self.message.setWordWrap(True)
self._stack.addWidget(self.message)
self._apply_icons()
self.retranslate()
# ------------------------------------------------------------------ actions
def open_settings(self) -> bool:
"""Show the 3D settings dialog. ``False`` when there is nothing to configure.
The pane builds the view on first use, so pressing the gear is also what makes
the 3D view exist — the same path the View menu's 「三维网格」 takes.
"""
if self._view is None:
self.ensure_view()
if self._view is None:
return False
return bool(self._view.open_settings())
def reset_view(self) -> None:
"""Go back to the framing the asset itself calls its own."""
if self._view is not None:
self._view.reset_view()
def set_diagnostics_visible(self, visible: bool) -> None:
"""How much the 3D pane explains itself (see ``DisplayPrefsController``).
Forwarded to the view's own panel, which is where the legend and the state line
live; held here because the view is built on first use and has to inherit the
switch when it appears.
"""
self._show_diagnostics = bool(visible)
self._apply_diagnostics()
def diagnostics_visible(self) -> bool:
return bool(self._show_diagnostics)
def _apply_diagnostics(self) -> None:
panel = getattr(self._view, "panel", None) if self._view is not None else None
if panel is not None:
panel.set_diagnostics_visible(self._show_diagnostics)
def set_theme(self, theme: str) -> None:
"""Re-tint the header icons: they are painted from the theme's own colours."""
self._theme = str(theme)
self._apply_icons()
def _apply_icons(self) -> None:
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
self.btn_reset_view.setIcon(icons.icon("reset_view", self._theme))
self.btn_reset_view.setIconSize(size)
self.btn_settings.setIcon(icons.icon("settings", self._theme))
self.btn_settings.setIconSize(size)
# ------------------------------------------------------------------ scene
def set_sensor(
self,
@@ -186,6 +272,9 @@ class Mesh3dPanel(QFrame):
# The kind was decided before the view existed (the window sets it when the
# view preset changes), so it is pushed again here rather than lost.
view.set_source_kind(self._source_kind)
panel = getattr(view, "panel", None)
if panel is not None:
panel.set_diagnostics_visible(self._show_diagnostics)
if not view.available:
self._show_message(_UNAVAILABLE, reason=view.reason)
@@ -400,5 +489,7 @@ class Mesh3dPanel(QFrame):
def retranslate(self) -> None:
self.title.setText(t("panels.mesh3d"))
self.btn_reset_view.setToolTip(t("panels.mesh3d_reset_tip"))
self.btn_settings.setToolTip(t("panels.mesh3d_settings_tip"))
self.setToolTip(t("panels.mesh3d_empty_tip"))
self._render_message()
+5 -3
View File
@@ -208,14 +208,16 @@ class ViewLayoutController(QObject):
self._cal_panel.setMinimumWidth(self._min_panel_widths["cal"])
self._set_check(self._actions.finger_3d if self._actions else None, show_3d, enabled=is_finger)
self._set_check(self._actions.cal_panel if self._actions else None, show_cal)
# Panel weights: the charts are the widest panel in every view, and the 2D
# panel is deliberately the smaller one.
# Panel weights: the charts are the widest panel in a view without 3D, the 3D
# pane is the widest one when it is on (it is the subject, not an inset), and
# the 2D panel is deliberately the smaller one.
arrow_weight = 3
plots_weight = 4
mesh_weight = 5
for i in range(self._splitter.count()):
widget = self._splitter.widget(i)
if widget is self._mesh_panel:
self._splitter.setStretchFactor(i, 2 if show_3d else 0)
self._splitter.setStretchFactor(i, mesh_weight if show_3d else 0)
elif widget is self._image_panel:
self._splitter.setStretchFactor(i, arrow_weight if show_arrow else 0)
elif widget is self._charts:
+97
View File
@@ -7,7 +7,9 @@ whole session (Qt forbids creating more than one).
from __future__ import annotations
import itertools
import os
import shutil
import sys
from pathlib import Path
@@ -77,3 +79,98 @@ def visible_descendants(widget):
continue
if child.isVisible():
yield child
def show_window(window, size):
"""Show a window at ``size`` and flush pending Qt events."""
from PyQt5.QtWidgets import QApplication
window.resize(*size)
window.show()
QApplication.processEvents()
return window
def running_window(make_window):
"""Return a running window backed by a controllable test worker."""
import ui_check
window = show_window(
make_window(sensor_type="box", source_mode="camera"),
(1440, 880),
)
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window._start_run()
window._run_timer.stop()
window._set_phase("running")
return window, stuck
def finish_run(window, stuck):
"""Stop a test run and release its controllable worker."""
window._stop_run(reason="user_stop")
stuck.release()
_RECORD_SCRATCH = (
Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
)
_RECORD_3D_SCRATCH = (
Path(__file__).resolve().parents[1] / "outputs" / "_test_record_3d"
)
@pytest.fixture
def record_root():
"""A clean workspace-local directory for recording tests."""
shutil.rmtree(_RECORD_SCRATCH, ignore_errors=True)
_RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
yield _RECORD_SCRATCH
shutil.rmtree(_RECORD_SCRATCH, ignore_errors=True)
@pytest.fixture
def next_record_root():
"""Create isolated recording directories for comparisons within one test."""
shutil.rmtree(_RECORD_3D_SCRATCH, ignore_errors=True)
_RECORD_3D_SCRATCH.mkdir(parents=True, exist_ok=True)
counter = itertools.count()
def _next() -> Path:
path = _RECORD_3D_SCRATCH / f"run{next(counter)}"
path.mkdir(parents=True, exist_ok=True)
return path
yield _next
shutil.rmtree(_RECORD_3D_SCRATCH, ignore_errors=True)
@pytest.fixture
def replay_recording(record_root):
"""One short finished recording (video, forces, frames, and metadata)."""
import ui_check
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)
+33 -2
View File
@@ -124,11 +124,14 @@ class _Host:
self.repaints += 1
def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False, latest_fps=None):
def _controller(
*, prefs=None, sensor_type="box", units="newton", loaded=False, latest_fps=None,
arrow_panel=None, mesh_panel=None,
):
host = _Host(prefs or {}, latest_fps=latest_fps)
toolbar = _Toolbar(units)
charts = _Charts("N" if units == "newton" else "raw")
arrows = _ArrowPanel()
arrows = arrow_panel if arrow_panel is not None else _ArrowPanel()
engine = _Engine(loaded)
action = _Action()
controller = DisplayPrefsController(
@@ -136,6 +139,7 @@ def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False,
toolbar=toolbar,
charts=charts,
arrow_panel=arrows,
mesh_panel=mesh_panel,
engine=engine,
sensor_picker=_Picker(sensor_type),
rotate_arrows_action=action,
@@ -278,3 +282,30 @@ def test_the_fps_readout_is_a_preference_like_any_other():
assert parts["host"].prefs["show_fps"] is True
assert parts["host"].rail_updates == 1, "the rail was not told to show it"
assert parts["host"].persisted == 1
def test_the_diagnostics_switch_reaches_both_panels():
"""One preference decides how much the panels explain around their pictures."""
class _Panel:
def __init__(self) -> None:
self.visible: list[bool] = []
def set_diagnostics_visible(self, visible: bool) -> None:
self.visible.append(bool(visible))
arrows = _Panel()
mesh = _Panel()
controller, parts = _controller(prefs={}, arrow_panel=arrows, mesh_panel=mesh)
# The constructor applies the stored preference to whatever panels exist.
assert arrows.visible == [False] and mesh.visible == [False]
controller.on_show_diagnostics_toggled(True)
assert arrows.visible[-1] is True and mesh.visible[-1] is True
assert controller.show_diagnostics is True
assert parts["host"].prefs["show_diagnostics"] is True
assert parts["host"].persisted == 1
controller.on_show_diagnostics_toggled(False)
assert arrows.visible[-1] is False and mesh.visible[-1] is False
assert parts["host"].prefs["show_diagnostics"] is False
+17 -19
View File
@@ -6,6 +6,8 @@ import json
import re
from pathlib import Path
import pytest
APP_ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = APP_ROOT / "src"
LOCALES_DIR = APP_ROOT / "config" / "locales"
@@ -69,13 +71,6 @@ def test_preference_defaults_match_store_defaults():
}
def test_locale_files_are_valid_utf8_json():
for name in ("zh_CN", "en_US"):
data = _load_locale(name)
assert isinstance(data, dict)
assert data.get("app", {}).get("title")
#: Sequences that only ever appear when UTF-8 text was decoded as a single-byte
#: codepage and then re-encoded. A ``Get-Content -Raw | Set-Content`` round trip
#: in Windows PowerShell does exactly that and silently mangles every non-ASCII
@@ -92,19 +87,22 @@ _MOJIBAKE = (
)
def test_locale_files_are_utf8_without_bom_and_not_mojibake():
for name in ("zh_CN", "en_US"):
path = LOCALES_DIR / f"{name}.json"
raw = path.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM"
text = raw.decode("utf-8") # raises if it is not valid UTF-8
@pytest.mark.parametrize("name", ("zh_CN", "en_US"))
def test_locale_file_is_valid_utf8_json_without_mojibake(name):
path = LOCALES_DIR / f"{name}.json"
raw = path.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM"
text = raw.decode("utf-8") # raises if it is not valid UTF-8
data = json.loads(text)
assert isinstance(data, dict)
assert data.get("app", {}).get("title")
for sequence in _MOJIBAKE:
assert sequence not in text, (
f"{name}.json contains {sequence!r}: the file was decoded with the "
"wrong encoding. Rewrite it with an explicit UTF-8 writer instead "
"of a PowerShell Get-Content/Set-Content round trip."
)
for sequence in _MOJIBAKE:
assert sequence not in text, (
f"{name}.json contains {sequence!r}: the file was decoded with the "
"wrong encoding. Rewrite it with an explicit UTF-8 writer instead "
"of a PowerShell Get-Content/Set-Content round trip."
)
def test_chinese_locale_actually_contains_chinese():
@@ -73,6 +73,84 @@ def test_the_flow_magnitude_handles_both_shapes():
assert flow_magnitude(FakePacket(flow=np.zeros((2, 2, 3), dtype=np.float32))) is None
def test_the_finger_adapter_prefers_the_quantity_the_operator_chose(scene):
"""The choice between the two quantities is a display setting, honoured per frame.
The depth response is what an operator reads as "how deep", so it is the default —
and when the frame carries it, that is what is drawn.
"""
adapter = FingerFrameAdapter(
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=scene.shape_hw,
)
assert adapter.preferred_field == "depth"
depth = np.full(scene.shape_hw, 2.5, dtype=np.float32)
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
flow[..., 0] = 7.0 # |flow| = 7 px
frame = adapter.snapshot(FakePacket(flow=flow, depth=depth), runtime=None, identity=_identity())
assert frame is not None
assert frame.scalar_descriptor == DEPTH_RESPONSE
assert frame.scalar_descriptor.unit == "1"
assert np.allclose(np.asarray(frame.scalar), 2.5)
assert adapter.last_substitution is None, "the chosen field was drawn; nothing to say"
adapter.set_preferred_field("flow")
frame = adapter.snapshot(FakePacket(flow=flow, depth=depth), runtime=None, identity=_identity())
assert frame is not None
assert frame.scalar_descriptor == FLOW_MAGNITUDE
assert np.allclose(np.asarray(frame.scalar), 7.0)
assert adapter.last_substitution is None
def test_a_frame_that_cannot_honour_the_choice_says_which_quantity_it_drew(scene):
"""Falling back is allowed; falling back silently is not.
The frame is still a measurement and the legend names it, but the *setting* said
something else, and the pane has to be able to say so instead of letting the picture
read as the quantity the operator asked for.
"""
adapter = FingerFrameAdapter(
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=scene.shape_hw,
)
# No depth in this frame: the flow is all it has.
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
flow[..., 0] = 4.0 # |flow| = 4 px
frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity())
assert frame is not None
assert frame.scalar_descriptor == FLOW_MAGNITUDE
note = adapter.last_substitution
assert note is not None and note.code == "field_substituted"
assert note.details["wanted"] == "depth" and note.details["shown"] == "flow"
assert "no depth response" in note.details["why"]
# ...and the note goes away as soon as the chosen field can be drawn again.
depth = np.full(scene.shape_hw, 1.0, dtype=np.float32)
frame = adapter.snapshot(
FakePacket(flow=flow, depth=depth), runtime=None, identity=_identity()
)
assert frame is not None and frame.scalar_descriptor == DEPTH_RESPONSE
assert adapter.last_substitution is None
def test_a_depth_response_on_another_grid_is_refused_not_resized(scene):
"""The depth field gets the same treatment as the flow: no resizing, ever."""
adapter = FingerFrameAdapter(
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=scene.shape_hw,
)
wrong = np.zeros((7, 7), dtype=np.float32)
frame = adapter.snapshot(FakePacket(depth=wrong), runtime=None, identity=_identity())
assert frame is None, "a depth response that is not on the scene's grid was coloured"
item = adapter.last_diagnostic
assert item is not None and item.code == "field_domain_mismatch"
assert item.details["depth"] == "7x7"
# ----------------------------------------------------------------- finger adapter
def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene):
adapter = FingerFrameAdapter(
+288
View File
@@ -0,0 +1,288 @@
"""The 3D pane's interface: a picture, one legend line, and a settings dialog.
The pane used to be half form fields, and that is what left the 3D view occupying a
corner of its own splitter slot. Everything configurable now lives in
``Scene3dSettingsDialog``; what stays under the picture is what *reading* it needs — the
colour ramp, the numbers it was drawn with, and which source produced the field.
These tests pin three things: the pane shows no settings, the dialog holds the **same**
control widgets the panel has always had (so the settings path did not fork), and the
view menu / the pane's header both reach that dialog.
"""
from __future__ import annotations
import pytest
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget
from orisys_app_shared.i18n import t
from orisys_studio.ui import icons
@pytest.fixture
def panel(qt_app):
"""A ``ThreeDPanel`` with the coordinator and widget it needs, and no scene."""
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.gl_backend import GLRenderer
from orisys_studio.scene3d.panel import ThreeDPanel
from orisys_studio.scene3d.widget import Sensor3DWidget
widget = Sensor3DWidget(GLRenderer(), OrbitCamera())
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30)
created = ThreeDPanel(coordinator, widget)
yield created
coordinator.close()
created.deleteLater()
# --------------------------------------------------------------------- the pane
def test_the_pane_holds_a_legend_and_no_settings(panel):
"""What is on the pane has to be readable, not adjustable."""
# The readouts are part of the pane itself...
assert panel.legend_label.parent() is panel
assert panel.source_label.parent() is panel
assert panel.scale_bar.parent() is panel
assert panel.scale_lower_label.parent() is panel
assert panel.scale_upper_label.parent() is panel
# ...and every setting is in one block that the pane never shows.
assert panel.controls_widget.parent() is panel
assert not panel.controls_widget.isVisible()
for control in (
panel.enable_check,
panel.preset_combo,
panel.housing_check,
panel.deform_check,
panel.gain_spin,
panel.scale_combo,
panel.fps_combo,
panel.reset_button,
):
assert control.parent() is panel.controls_widget, control
# The engineering counters went with the settings rather than under the picture.
assert panel.stats_label.parent() is panel.controls_widget
def test_the_settings_dialog_hosts_the_panes_own_controls(panel):
"""One set of widgets either way: the dialog moves them, it does not copy them."""
from orisys_studio.scene3d.settings_dialog import Scene3dSettingsDialog
dialog = Scene3dSettingsDialog(panel)
try:
assert panel.controls_widget.parent() is dialog, (
"the dialog kept a second copy of the controls"
)
assert panel.controls_widget.isVisibleTo(dialog)
assert not dialog.isModal(), "the pane has to stay visible while tuning it"
assert dialog.windowTitle() == t("scene3d.settings_title")
# ...and they still work from there: the signal path is the panel's own.
seen: list[bool] = []
panel.enabledChanged.connect(seen.append)
panel.enable_check.setChecked(True)
assert seen == [True], "a control in the dialog stopped reaching the view"
# Closing hides it: the controls have to survive for the next time.
dialog.close()
assert not dialog.isVisible()
assert panel.enable_check.text(), "closing the dialog destroyed its controls"
assert panel.settings().max_fps == 30
finally:
dialog.deleteLater()
def test_the_pane_header_offers_the_two_actions(qt_app):
"""A gear and a view reset: the two things that belong to *looking*."""
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
assert "reset_view" in icons.NAMES
for theme in ("dark", "light"):
assert not icons.icon("reset_view", theme).isNull()
pane = Mesh3dPanel(theme="dark")
try:
assert pane.btn_settings.toolTip() == t("panels.mesh3d_settings_tip")
assert pane.btn_reset_view.toolTip() == t("panels.mesh3d_reset_tip")
assert not pane.btn_settings.icon().isNull()
assert not pane.btn_reset_view.icon().isNull()
# A box sensor has no scene, so there is nothing to configure and the pane says
# so instead of opening an empty dialog.
pane.set_sensor("box")
assert pane.open_settings() is False
assert pane.view is None
# Reset forwards to the view when there is one.
class _View(QWidget):
diagnosticRaised = pyqtSignal(object)
enabledChanged = pyqtSignal(bool)
def __init__(self) -> None:
super().__init__()
self.resets = 0
self.available = True
def set_source_kind(self, kind: str) -> None:
self.source_kind = kind
def set_enabled(self, enabled: bool) -> None:
self.enabled = bool(enabled)
def restore_settings(self, settings) -> None:
self.settings_value = settings
def reset_view(self) -> None:
self.resets += 1
view = _View()
pane.attach_view(view) # type: ignore[arg-type]
pane.reset_view()
assert view.resets == 1
finally:
pane.deleteLater()
# --------------------------------------------------------------- which quantity
def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(panel):
"""The choice is the operator's; a frame that cannot honour it says so."""
from orisys_app_shared.scene3d.contracts import FieldDescriptor
assert [panel.field_combo.itemData(i) for i in range(panel.field_combo.count())] == [
"depth",
"flow",
]
assert panel.field() == "depth", "the depth response is what the pane offers first"
assert panel.settings().field == "depth"
# The chosen quantity is drawn: no note.
panel.set_descriptor(FieldDescriptor("depth_response", "1", "approximate", False))
assert not panel.field_note.isVisible() or not panel.field_note.text()
# The other one is drawn instead: the note names both, and the legend still shows
# what is really on screen (the range is for the field that was drawn).
panel.set_diagnostics_visible(True)
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
note = panel.field_note.text()
assert t("scene3d.field_depth") in note and t("scene3d.field_flow") in note, note
assert "flow_magnitude" not in panel.legend_label.text()
# Choosing flow makes that frame the honoured one, and the note goes away.
index = panel.field_combo.findData("flow")
panel.field_combo.setCurrentIndex(index)
assert panel.settings().field == "flow"
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
assert not panel.field_note.isVisible() or not panel.field_note.text()
# A restored preference moves the combo (and therefore the adapter).
from orisys_app_shared.scene3d.contracts import DisplaySettings
panel.apply_settings(DisplaySettings(field="depth"))
assert panel.field() == "depth"
def test_the_diagnostics_switch_governs_what_the_pane_explains(panel):
"""Warnings are claims about the measurement and always show; info is explanation."""
from orisys_app_shared.scene3d.contracts import FieldDescriptor, RenderStats, diagnostic
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
panel.set_render_stats(
RenderStats(scale_lower=0.25, scale_upper=2.5, scale_mode="automatic")
)
panel.set_diagnostics_visible(False)
assert not panel.scale_bar.isHidden()
assert panel.scale_lower_label.text() == "0.25"
assert panel.scale_upper_label.text() == "2.5"
assert not panel.scale_lower_label.isHidden()
assert not panel.scale_upper_label.isHidden()
assert panel.legend_label.isHidden()
assert panel.source_label.isHidden()
assert panel.state_label.isHidden()
assert panel.field_note.isHidden()
panel.set_diagnostic(diagnostic("field_stale", "the array is from another frame"))
assert "another frame" in panel.legend_label.text(), (
"a warning about the measurement was hidden"
)
panel.set_diagnostic(diagnostic("geometry_unverified", "the scene was not compared"))
assert "not compared" in panel.legend_label.text(), (
"a claim about verification was hidden"
)
panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
assert "not current" not in panel.legend_label.text(), (
"an informational line survived diagnostics being off"
)
assert panel.legend_label.isHidden()
panel.set_diagnostics_visible(True)
assert not panel.legend_label.isHidden()
assert not panel.source_label.isHidden()
assert not panel.state_label.isHidden()
panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
assert "not current" in panel.legend_label.text()
def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
"""One line under the canvas: what the picture is, not how it was made."""
from PyQt5.QtWidgets import QApplication
window = make_window()
window.show()
QApplication.processEvents()
try:
arrow = window.arrow_panel
arrow.set_plan(None)
arrow.set_diagnostics_visible(False)
assert not arrow.scale_label.isVisible()
assert not arrow.pipeline_label.isVisible()
assert not arrow.area_label.isVisible()
arrow.set_diagnostics_visible(True)
assert arrow.pipeline_label.isVisible() or not arrow.view_modes(), (
"the pipeline line came back"
)
# The window's switch is the same thing, and it is remembered.
window.act_show_diagnostics.setChecked(True)
QApplication.processEvents()
assert window.display_ctl.show_diagnostics is True
assert arrow.diagnostics_visible() is True
assert window.prefs["show_diagnostics"] is True
finally:
window.close()
# --------------------------------------------------------------------- the menu
def test_the_view_menu_opens_the_3d_settings(make_window):
"""视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
from PyQt5.QtWidgets import QApplication
window = make_window(sensor_type="finger", show_finger_3d=True)
window.show()
QApplication.processEvents()
try:
assert window.act_scene3d_settings.text() == t("menu.scene3d_settings")
assert window.act_scene3d_settings in window.view_menu.actions()
view = window.mesh3d_panel.view
if view is None or not view.available:
# No GL here: the entry has to say why instead of doing nothing.
window.act_scene3d_settings.trigger()
QApplication.processEvents()
assert window.statusBar().currentMessage().strip()
return
window.mesh3d_panel.ensure_view()
window.act_scene3d_settings.trigger()
QApplication.processEvents()
dialog = window.mesh3d_panel.view.settings_dialog()
assert dialog is not None and dialog.isVisible()
dialog.hide()
finally:
window.close()
@@ -71,47 +71,40 @@ def _source(path: Path) -> SceneSource:
return SceneSource(path=path, kind="package", directory=path.parent)
def test_a_signed_package_opens_from_the_application(tmp_path):
@pytest.mark.parametrize("sealed", (False, True), ids=("signed", "sealed"))
def test_a_protected_package_opens_from_the_application(tmp_path, sealed):
"""The review's R4: the library could read it; the application could not.
With the verification key resolved from the preferences, the pane's own
``load_package`` accepts the package and the scene comes out intact.
With keys resolved from preferences, the pane's own loader accepts both signed
and sealed release packages and returns their scene intact.
"""
scene = synthetic.poc_scene()
package, verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False)
package, verify_path, key_path = _write_signed(tmp_path, scene, sealed=sealed)
prefs = {PREF_VERIFY_KEY: str(verify_path)}
if sealed:
prefs.update(
{
PREF_CONTENT_KEY_FILE: str(key_path),
PREF_CONTENT_KEY_ID: KEY_ID,
}
)
keys = resolve_scene_keys({PREF_VERIFY_KEY: str(verify_path)})
assert keys.verify_key_pem is not None
assert keys.describe()["verify_key"] is True
assets = load_package(_source(package), _expected(scene), keys)
assert assets.geometry_id == scene.geometry_id
# The manifest's own hash, recomputed when the package was written: the synthetic
# scene's ``asset_hash`` is a placeholder until it goes through ``write_package``.
assert len(assets.asset_hash) == 64
def test_a_sealed_package_opens_with_the_content_key_from_the_preferences(tmp_path):
scene = synthetic.poc_scene()
package, verify_path, key_path = _write_signed(tmp_path, scene, sealed=True)
keys = resolve_scene_keys(
{
PREF_VERIFY_KEY: str(verify_path),
PREF_CONTENT_KEY_FILE: str(key_path),
PREF_CONTENT_KEY_ID: KEY_ID,
}
)
assert keys.provider is not None
keys = resolve_scene_keys(prefs)
described = keys.describe()
assert described["content_key"] is True
assert described["content_key_id"] == KEY_ID
# The description carries paths and ids, never key material.
assert CONTENT_KEY_HEX not in json.dumps(described)
assert keys.verify_key_pem is not None
assert described["verify_key"] is True
assert CONTENT_KEY_HEX.encode() not in keys.verify_key_pem
if sealed:
assert keys.provider is not None
assert described["content_key"] is True
assert described["content_key_id"] == KEY_ID
# The description carries paths and ids, never key material.
assert CONTENT_KEY_HEX not in json.dumps(described)
assets = load_package(_source(package), _expected(scene), keys)
assert assets.geometry_id == scene.geometry_id
# The writer replaces the synthetic scene's placeholder with the manifest hash.
assert len(assets.asset_hash) == 64
def test_without_the_verification_key_a_signed_package_is_refused(tmp_path):
+18 -30
View File
@@ -20,8 +20,6 @@ mathematics (the camera, the sampler and the composer have their own files):
from __future__ import annotations
import itertools
import shutil
import time
from concurrent.futures import Future
from dataclasses import replace
@@ -51,33 +49,12 @@ from orisys_studio.scene3d.asset_source import (
load_package,
no_asset_diagnostic,
)
from tests.test_ui_smoke import ( # noqa: F401 (fixtures are imported by name)
_finish,
_running_window,
record_root,
replay_recording,
from tests.conftest import (
finish_run as _finish,
running_window as _running_window,
show_window as _show,
)
#: Scratch root for the two-session comparison. One subdirectory per run, because
#: the point of the test is that two recordings are identical, not that one file
#: can be overwritten.
RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record_3d"
@pytest.fixture
def next_record_root():
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
counter = itertools.count()
def _next() -> Path:
path = RECORD_SCRATCH / f"run{next(counter)}"
path.mkdir(parents=True, exist_ok=True)
return path
yield _next
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
@pytest.fixture
def write_poc(tmp_path):
@@ -496,6 +473,19 @@ def test_a_saved_descriptor_is_never_restored_as_a_measurement():
assert restored_with.color_scale.descriptor == given
def test_a_saved_field_choice_survives_and_a_bad_one_is_repaired():
"""Which quantity colours the surface is a preference; an unknown one is repaired."""
block = scene3d_prefs.settings_to_prefs(DisplaySettings(field="flow"))
assert block["field"] == "flow"
assert scene3d_prefs.settings_from_prefs(block).field == "flow"
# The default is the depth response (the operator chose it for the pane), and a
# hand-edited or older file that names something else gets that default.
assert DisplaySettings().field == scene3d_prefs.DEFAULT_FIELD == "depth"
for bad in ({"field": "displacement"}, {"field": 3}, {"field": None}, {}):
assert scene3d_prefs.settings_from_prefs(bad).field == "depth", bad
@pytest.mark.parametrize(
"block",
[
@@ -515,6 +505,7 @@ def test_a_hand_edited_settings_block_is_repaired(block):
assert isinstance(settings, DisplaySettings)
assert scene3d_prefs.MIN_GAIN <= settings.display_gain <= scene3d_prefs.MAX_GAIN
assert settings.max_fps in scene3d_prefs.FPS_CHOICES
assert settings.field in scene3d_prefs.FIELD_CHOICES
if settings.color_scale is not None:
lower, upper = settings.color_scale.lower, settings.color_scale.upper
assert lower == lower and upper == upper, "a NaN survived the repair"
@@ -1060,8 +1051,6 @@ def test_the_replay_view_shows_the_pane_and_says_where_the_field_comes_from(
what a recompute pass produces from the video. Those are two different claims and
the label has to distinguish them.
"""
from tests.test_ui_smoke import _show
window = _show(make_window(sensor_type="finger"), (1440, 880))
try:
assert window._load_recording(Path(replay_recording))
@@ -1124,7 +1113,6 @@ def test_every_run_gets_a_new_generation(make_window, qt_app):
so the picture stays on the first run's last frame forever.
"""
from orisys_app_shared.scene3d.contracts import FrameIdentity
from tests.test_ui_smoke import _show
import ui_check
window = _show(make_window(sensor_type="finger"), (1440, 880))
+35 -107
View File
@@ -9,20 +9,16 @@ import numpy as np
import pytest
import ui_check
from tests.conftest import visible_descendants
from tests.conftest import (
finish_run as _finish,
running_window as _running_window,
show_window as _show,
visible_descendants,
)
RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080))
def _show(window, size):
window.resize(*size)
window.show()
from PyQt5.QtWidgets import QApplication
QApplication.processEvents()
return window
@pytest.mark.parametrize(("theme", "sensor_type"), [
("dark", "box"),
("light", "box"),
@@ -211,8 +207,9 @@ def test_toolbar_run_button_is_labelled(make_window):
assert window.toolbar.btn_run.text() != label
def test_stop_cancels_a_hung_connect(make_window):
"""Stop must stay live and take effect instantly while connecting."""
@pytest.mark.parametrize("cancel_via", ("toolbar", "escape"))
def test_a_hung_connect_can_be_cancelled_immediately(make_window, cancel_via):
"""Both cancellation paths stay responsive while a device is connecting."""
import time
import ui_check
@@ -226,35 +223,24 @@ def test_stop_cancels_a_hung_connect(make_window):
window.toolbar.btn_run.setChecked(True)
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
assert window.act_stop.isEnabled()
try:
if cancel_via == "toolbar":
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
assert window.act_stop.isEnabled()
cancel = lambda: window.toolbar.btn_run.setChecked(False)
else:
assert window.act_stop.shortcut().toString() == "Esc"
cancel = window._on_stop_shortcut
started = time.perf_counter()
window.toolbar.btn_run.setChecked(False)
elapsed = time.perf_counter() - started
assert elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s"
assert not window._running
assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread"
def test_escape_cancels_a_hung_connect(make_window):
import time
import ui_check
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window.toolbar.btn_run.setChecked(True)
assert window.act_stop.shortcut().toString() == "Esc"
started = time.perf_counter()
window._on_stop_shortcut()
assert time.perf_counter() - started < 0.5
assert not window._running
started = time.perf_counter()
cancel()
elapsed = time.perf_counter() - started
assert elapsed < 0.5, f"{cancel_via} cancellation blocked for {elapsed:.2f}s"
assert not window._running
assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread"
finally:
stuck.release()
def test_theme_switch_updates_panels(make_window):
@@ -581,31 +567,6 @@ def test_force_plots_render_demo_data(make_window):
# --------------------------------------------------------------- record channel
def _running_window(make_window):
"""A window in the ``running`` phase whose worker never opens a device.
The Qt timer is stopped deliberately: these tests drive the display path by
calling ``_on_run_tick`` themselves, and a concurrent 16 ms timer would race
the assertions (and outlive the window on teardown).
"""
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window._start_run()
window._run_timer.stop()
window._set_phase("running")
return window, stuck
def _finish(window, stuck):
window._stop_run(reason="user_stop")
stuck.release()
def test_record_channel_does_not_depend_on_the_ui_timer(make_window):
window, stuck = _running_window(make_window)
for index in range(90):
@@ -965,48 +926,6 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
# ------------------------------------------------------------- experiment record
#: Recording scratch goes under the app's gitignored ``outputs`` tree rather than
#: the system temp directory, so the tests also run under a file sandbox that
#: only permits writes inside the workspace.
RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
@pytest.fixture
def record_root():
import shutil
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
yield RECORD_SCRATCH
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):
import csv
import json
@@ -1356,6 +1275,12 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
window = _show(make_window(), (1440, 880))
panel = window.arrow_panel
# The pipeline labels are diagnostics: off by default (the canvas keeps what the
# picture *is*), on when the operator asks — 视图 ▸ 诊断信息.
assert not panel.scale_label.isVisible()
assert not panel.pipeline_label.isVisible()
assert not panel.area_label.isVisible()
panel.set_diagnostics_visible(True)
assert panel.scale_label.isVisible()
assert "2" in panel.scale_label.text()
assert panel.pipeline_label.isVisible() and panel.pipeline_label.text()
@@ -1372,6 +1297,9 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
assert not panel.colorbar.isVisible()
assert panel.scale_label.isVisible()
panel.set_diagnostics_visible(False)
assert not panel.scale_label.isVisible(), "诊断信息 off still shows the pipeline"
# ------------------------------------------------- top rail: sensor and views
+14
View File
@@ -299,6 +299,20 @@ def test_a_preset_that_gives_no_width_to_the_3d_pane_takes_it_from_the_widest():
assert controller.splitter_sizes_for("live") == list(VIEW_SPLITTER_SIZES["live"])
def test_the_shipped_live_layout_makes_the_3d_pane_the_widest_panel():
"""The 3D view is the subject when it is on, not an inset beside the charts."""
from orisys_studio.app_window import DEFAULT_SPLITTER_SIZES, VIEW_SPLITTER_SIZES as LIVE
mesh, image, charts = DEFAULT_SPLITTER_SIZES
assert mesh > image, DEFAULT_SPLITTER_SIZES
assert mesh > charts, DEFAULT_SPLITTER_SIZES
live_mesh, live_image, live_charts, _cal_live = LIVE["live"]
assert live_mesh > live_image and live_mesh > live_charts, LIVE["live"]
assert LIVE["replay"] == LIVE["live"], (
"the replay view is the live layout with another source"
)
def test_the_calibration_panel_toggle_is_the_calibration_view_switch():
controller, parts = _controller(prefs={"view_preset": "live"})
+24
View File
@@ -1366,6 +1366,22 @@ def main() -> int:
)
arrow = window.arrow_panel
# The pipeline labels (magnification, source → output, valid area) are diagnostics:
# hidden by default, so the canvas keeps one line about what the picture *is*. The
# switch is the same one 视图 ▸ 诊断信息 drives.
check(
not arrow.diagnostics_visible(),
"the panel explains its pipeline before the operator asked for it",
)
check(
not arrow.scale_label.isVisible()
and not arrow.pipeline_label.isVisible()
and not arrow.area_label.isVisible(),
"the pipeline labels are shown with 诊断信息 off",
)
arrow.set_diagnostics_visible(True)
for _ in range(2):
app.processEvents()
check(
arrow.scale_label.isVisible() and arrow.scale_label.text(),
"the arrow view does not show its display magnification",
@@ -1406,6 +1422,14 @@ def main() -> int:
not arrow.colorbar.isVisible() and arrow.scale_label.isVisible(),
"leaving the depth view did not restore the arrow legend",
)
# ...and the switch takes them away again, whatever view is on screen.
arrow.set_diagnostics_visible(False)
for _ in range(2):
app.processEvents()
check(
not arrow.scale_label.isVisible() and arrow.colorbar.isVisible() is False,
"the pipeline labels survived 诊断信息 being turned off",
)
# The overview strip is the timeline control: dragging it pans the window.
stuck = _StuckWorker()
+119 -3
View File
@@ -147,6 +147,117 @@ def _run_scene(window, app, scene: str):
app.processEvents()
def _flow_scene(window, app, *, frames: int = 240) -> None:
"""Drive a synthetic session whose frames carry a real optical-flow field.
The 3D pane is coloured by the frame's *own* flow (see
``orisys_studio.scene3d.adapters``): a packet that does not state one is refused,
so a picture with colour needs packets that do. This is the scene the interface is
reviewed from — the real widgets, the real renderer, the real adapter, and a field
shaped like a pressed fingertip (strongest at the centre, fading to the rim).
"""
import time as _time
import numpy as np
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
from ui_check import _StuckWorker
stuck = _StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window.toolbar.btn_run.setChecked(True)
for _ in range(4):
app.processEvents()
# The unrolled finger domain: 280x300, which is the grid the scene's UV island
# uses. The flow is already unrolled, so the adapter is given no remap.
height, width = 300, 280
grid_y, grid_x = np.mgrid[0:height, 0:width].astype(np.float32)
centre_y, centre_x = height * 0.52, width / 2.0
dy, dx = grid_y - centre_y, grid_x - centre_x
radius = np.sqrt(dx * dx + dy * dy) + 1e-3
profile = np.exp(-((radius / (width * 0.30)) ** 2)).astype(np.float32)
flow = np.stack(
[dx / radius * profile * 7.0, dy / radius * profile * 7.0], axis=-1
).astype(np.float32)
# The other quantity the pane can colour: the chain's deformation response (a
# relative, unitless "how deep"). Both are carried so the 颜色来源 switch has
# something to switch between, and each is stated as this frame's own field.
depth = (profile * 0.9).astype(np.float32)
# A recognisable camera image as well, so the 2D panel is not black next to it.
frame = np.zeros((height, width, 3), np.uint8)
frame[..., 1] = (40.0 + profile * 150.0).astype(np.uint8)
frame[..., 2] = (profile * 90.0).astype(np.uint8)
for index in range(frames):
# The forces move: a flat line reads as "the demo is not running", and the
# charts are half of what the video has to show.
phase = index / 30.0
# A period of a couple of seconds, so the trace visibly moves inside the chart's
# rolling window instead of looking like a slow ramp.
normal = 3.4 + 2.4 * math.sin(phase * 2.4)
shear_x = 1.6 * math.sin(phase * 3.1 + 0.4)
shear_y = -1.3 * math.cos(phase * 2.7)
stuck.emit(
SdkPacket(
raw_frame=frame,
processed_frame=frame,
flow=flow,
depth=depth,
timestamp=1000.0 + index / 30.0,
mono_t=float(index) / 30.0,
fps=30.0,
process_ms=6.0,
capture_fps=30.0,
session_id=1,
frame_id=index,
valid=True,
raw_available=True,
normal_force=normal,
shear_x=shear_x,
shear_y=shear_y,
normal_force_raw=9.0 + index * 0.1,
shear_x_raw=0.4,
shear_y_raw=-0.4,
force_unit="N",
force_calibrated=True,
calibrated_axes=("fn", "fx", "fy"),
flow_valid=True,
flow_frame_id=index,
depth_valid=True,
depth_frame_id=index,
forces_valid=True,
)
)
# The window's own presenter runs *while* the frames arrive, the way it does in
# a session: the charts then hold a real time series instead of one sample.
if index % 4 == 0:
window._on_run_tick()
app.processEvents()
# The 3D picture is composed on the view's own worker and taken one finished frame
# at a time by the run timer, so pump both until something has actually been shown.
view = window.mesh3d_panel.view
deadline = _time.perf_counter() + 20.0
while _time.perf_counter() < deadline:
window._on_run_tick()
app.processEvents()
if view is not None and view.render_totals().get("shown", 0) > 0:
break
_time.sleep(0.01)
window._run_timer.stop()
# The overview strip is rebuilt at most once a second (it is a whole-session
# decimation) and this scene fills eight seconds of session in well under that, so
# it is rebuilt once here — the same thing ``_demo_data`` does for the same reason.
window.force_plots._overview_dirty = True
window.force_plots._last_overview_at = 0.0
window.force_plots._sync_overview()
for _ in range(6):
app.processEvents()
def _demo_data(window) -> None:
"""Fill the plots, the calibration table and the arrow canvas."""
from orisys_app_shared.force.session import SessionSample, SessionStore
@@ -242,14 +353,16 @@ def main() -> int:
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
parser.add_argument(
"--scene",
choices=("idle", "connecting", "recording", "replay"),
choices=("idle", "connecting", "recording", "replay", "flow3d"),
default="idle",
help=(
"connecting renders the Stop/cancel state of a hung device open; "
"recording renders the record strip of a running recording "
"(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"
"replay view so the progress bar can be reviewed; "
"flow3d drives packets that carry a real optical-flow field, which is "
"what colours the 3D pane (use with --sensor finger --panels 3d)"
),
)
parser.add_argument(
@@ -317,7 +430,10 @@ def main() -> int:
if args.scene == "replay":
_run_scene(window, app, "replay")
if args.demo and args.scene != "replay":
if args.scene == "flow3d":
_flow_scene(window, app)
if args.demo and args.scene not in ("replay", "flow3d"):
_demo_data(window)
for _ in range(4):
app.processEvents()
+17 -4
View File
@@ -636,10 +636,23 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
---
1. **阶段 8 第二批(画布布局)**:2D/3D 页签容器(默认页签切换、可选并排对照)、参数收进"显示设置"抽屉、
常驻只留视角/复位/外壳/紧凑图例、窄窗口回退、三维画布在 1210×800 下 ≥320 px 高、曲线"可见区间自动/
固定量程"开关、调试统计移入诊断抽屉。为什么留后:它要动 splitter 的槽位与 `ui_check` 的多处布局断言,
并且需要与用户一起看截图定稿(报告里的建议是"传感器视图 + 力曲线两栏")。
1. **阶段 8 第二批(画布布局)——部分已做(2026-09-15)**:
*已做*:三维面板本身只留画面 + 一行紧凑图例(色条 + 来源/状态)+ 两个图标(复位视角、齿轮),
全部**配置**(总开关、观察方向、显示外壳、放大形变与增益、颜色来源、色标模式与范围、显示帧率、
显示统计)搬进 `视图 ▸ 三维显示设置…` 和面板齿轮打开的非模态对话框
(`scene3d/settings_dialog.py`,它承载的还是面板自己那套控件,所以设置通路没有分叉);
live/replay 预设把三维设为**最宽**面板(620 : 300 : 520),三维最小宽度 260 → 200;
**颜色来源**可选「深度响应(默认)/ 光流幅值」:两者都是近似量、都不是毫米,选中的那个画不出来时
自动改用另一个,并在设置对话框里写明"这一帧画的是哪一个";图例永远标注实际画出来的量
(`scene3d/panel.py` 的 `field_note`、`adapters.FingerFrameAdapter.last_substitution`);
面板脚下的内部解释(2D 的"箭头按位移放大 / 原始图像 → 算法输出 / 全部帧"、3D 的 info 级诊断)
改由 `视图 ▸ 诊断信息` 一个开关控制,**默认关**:默认每个面板只剩"画面 + 读数 + 真话声明",
异常(过期、无可用场、未与设备核对)照旧显示。
*仍未做*:2D/3D 页签容器与"并排对照"、窄窗口回退策略、曲线"可见区间自动/固定量程"开关、
调试统计移入独立诊断抽屉(统计现在在设置对话框底部);阶段 9 的真机/板卡门禁同前。
为什么仍留后:页签容器要动 splitter 的槽位与 `ui_check` 的多处布局断言,需要与用户一起看截图定稿。
*真机待验证*:手指路径上 `packet.depth` 的栅格是否就是场景的 300×280(若不是,面板会写明并回退到
光流;这条只有接上设备才能确认)。
2. **阶段 9 门禁运行**:GUI 模式 **2 小时**长时(`soak_scene.py --minutes 120 --fps 30`)——本阶段已跑完
10 分钟的一份(见验收表格:增长 4.83 MB、尾段 0.371 MB/min,外推 2 小时约 45 MB,低于 64 MB 上限,但
**外推不是门禁**,2 小时整仍需实跑);以及 Jetson Orin Nano / QCS6490 上的 `probe_gl.py --json`、
@@ -66,6 +66,10 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
#: show the geometry, but it must not claim the measurement was checked against it.
"geometry_unverified": "info",
"stale_frame": "info",
#: The operator asked for one quantity and the frame could only be coloured with the
#: other one. Informational: the picture is still a measurement and names itself; what
#: it is not is the field the setting says.
"field_substituted": "info",
"gl_unsupported": "error",
"shader_compile_failed": "error",
"gpu_budget_exceeded": "error",
@@ -537,6 +541,13 @@ class DisplaySettings:
color_scale: Optional[ColorScale] = None
housing_visible: bool = True
max_fps: int = 30
#: Which scalar field colours the surface: the chain's deformation response
#: (``"depth"``) or the tracker's optical-flow magnitude (``"flow"``). This is a
#: *display* choice between two quantities a frame may carry, never a relabelling:
#: whichever is drawn is reported by that frame's own descriptor, and a field the
#: producer did not claim for this frame is refused before either is coloured (see
#: ``orisys_studio.scene3d.adapters``).
field: str = "depth"
@dataclass(frozen=True)