From c2293d4d419ff290c69adb0735a82d12c8dccec1 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Tue, 15 Sep 2026 21:23:18 +0800 Subject: [PATCH] =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=9A=E4=B8=89=E7=BB=B4?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E3=80=8C=E6=B7=B1=E5=BA=A6=E5=93=8D=E5=BA=94?= =?UTF-8?q?=20/=20=E5=85=89=E6=B5=81=E5=B9=85=E5=80=BC=E3=80=8D=E4=B8=8A?= =?UTF-8?q?=E8=89=B2=EF=BC=88=E9=BB=98=E8=AE=A4=E6=B7=B1=E5=BA=A6=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=8A=A0=E4=B8=80=E4=B8=AA=E3=80=8C=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E4=BF=A1=E6=81=AF=E3=80=8D=E5=BC=80=E5=85=B3=E6=94=B6?= =?UTF-8?q?=E6=8B=BE=E9=9D=A2=E6=9D=BF=E8=84=9A=E4=B8=8B=E7=9A=84=E6=96=87?= =?UTF-8?q?=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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(若不是,面板会写明并回退到光流)。 --- apps/studio/config/locales/en_US.json | 8 +- apps/studio/config/locales/zh_CN.json | 6 + apps/studio/src/orisys_studio/app_window.py | 13 ++ .../studio/src/orisys_studio/display_prefs.py | 36 ++++ .../src/orisys_studio/scene3d/adapters.py | 185 +++++++++++++++--- .../studio/src/orisys_studio/scene3d/panel.py | 148 ++++++++++++-- .../studio/src/orisys_studio/scene3d/prefs.py | 11 ++ apps/studio/src/orisys_studio/scene3d/view.py | 5 + .../src/orisys_studio/ui/arrow_panel.py | 27 ++- .../src/orisys_studio/ui/mesh3d_panel.py | 24 +++ apps/studio/tests/test_display_prefs.py | 35 +++- apps/studio/tests/test_scene3d_adapters.py | 78 ++++++++ apps/studio/tests/test_scene3d_pane_ui.py | 95 +++++++++ apps/studio/tests/test_scene3d_view.py | 14 ++ apps/studio/tests/test_ui_smoke.py | 9 + apps/studio/tools/ui_check.py | 24 +++ apps/studio/tools/ui_snapshot.py | 9 +- docs/architecture/studio-3d-stages.md | 16 +- .../orisys_app_shared/scene3d/contracts.py | 11 ++ 19 files changed, 702 insertions(+), 52 deletions(-) diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index c3144a0..f2139b5 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -32,6 +32,7 @@ "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°", @@ -405,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", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 0465fe7..e20fa8c 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -32,6 +32,7 @@ "finger_3d": "手指 3D 视图", "scene3d_settings": "三维显示设置…", "show_fps": "显示 FPS", + "show_diagnostics": "诊断信息", "settings": "设置区", "plot_settings": "绘图设置…", "rotate_arrows": "旋转箭头图 90°", @@ -406,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": "曲面着色", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 8eb1d54..589689e 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -278,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={ @@ -914,6 +915,16 @@ class StudioMainWindow(QMainWindow): 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()) @@ -1156,6 +1167,7 @@ class StudioMainWindow(QMainWindow): 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")) @@ -2304,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() diff --git a/apps/studio/src/orisys_studio/display_prefs.py b/apps/studio/src/orisys_studio/display_prefs.py index 4e98d14..217b833 100644 --- a/apps/studio/src/orisys_studio/display_prefs.py +++ b/apps/studio/src/orisys_studio/display_prefs.py @@ -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) diff --git a/apps/studio/src/orisys_studio/scene3d/adapters.py b/apps/studio/src/orisys_studio/scene3d/adapters.py index 884d13e..890d765 100644 --- a/apps/studio/src/orisys_studio/scene3d/adapters.py +++ b/apps/studio/src/orisys_studio/scene3d/adapters.py @@ -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), ) diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index bf96944..2eda914 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -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,6 +141,9 @@ 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, 6) @@ -191,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") @@ -260,20 +291,21 @@ class ThreeDPanel(QFrame): legend_row = QHBoxLayout() legend_row.setContentsMargins(0, 0, 0, 0) legend_row.setSpacing(8) - self.legend_label = QLabel() + # ``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(True) - self.legend_label.setMinimumWidth(1) + 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) + self.source_label.setWordWrap(False) self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) legend_row.addWidget(self.source_label, 2) root.addLayout(legend_row) @@ -314,6 +346,7 @@ class ThreeDPanel(QFrame): def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None: """The measurement currently on screen — or none, which hides the bar.""" 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")) @@ -369,7 +402,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, @@ -383,18 +416,25 @@ class ThreeDPanel(QFrame): 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, ) ) + 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}")) @@ -421,14 +461,70 @@ class ThreeDPanel(QFrame): self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip")) 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: the pane states what the picture *is* — the colour ramp, the quantity and + its range, where the field came from, and any warning about the measurement — + and stops explaining the pipeline around it. On: the same picture plus those + explanations, which is what an engineer asking "why is that frame blank" wants. + """ + self._show_diagnostics = bool(visible) + self._render_field_note() + 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 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 @@ -465,6 +561,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. @@ -478,6 +578,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)) @@ -520,6 +623,7 @@ class ThreeDPanel(QFrame): ), housing_visible=self.housing_check.isChecked(), max_fps=int(self.fps_combo.currentData()), + field=self.field(), ) # ------------------------------------------------------------------ signals @@ -546,6 +650,11 @@ 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 @@ -561,6 +670,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")) diff --git a/apps/studio/src/orisys_studio/scene3d/prefs.py b/apps/studio/src/orisys_studio/scene3d/prefs.py index aff68cf..0501706 100644 --- a/apps/studio/src/orisys_studio/scene3d/prefs.py +++ b/apps/studio/src/orisys_studio/scene3d/prefs.py @@ -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, ) diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index 4e3f3bf..51d7ed0 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -363,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: diff --git a/apps/studio/src/orisys_studio/ui/arrow_panel.py b/apps/studio/src/orisys_studio/ui/arrow_panel.py index fef006a..2e59db7 100644 --- a/apps/studio/src/orisys_studio/ui/arrow_panel.py +++ b/apps/studio/src/orisys_studio/ui/arrow_panel.py @@ -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) diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index 8a721c1..a813b73 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -108,6 +108,9 @@ 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) @@ -167,6 +170,24 @@ class Mesh3dPanel(QFrame): 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) @@ -251,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) diff --git a/apps/studio/tests/test_display_prefs.py b/apps/studio/tests/test_display_prefs.py index 34cbce0..45ea533 100644 --- a/apps/studio/tests/test_display_prefs.py +++ b/apps/studio/tests/test_display_prefs.py @@ -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 diff --git a/apps/studio/tests/test_scene3d_adapters.py b/apps/studio/tests/test_scene3d_adapters.py index 46a0d34..f72b4a8 100644 --- a/apps/studio/tests/test_scene3d_adapters.py +++ b/apps/studio/tests/test_scene3d_adapters.py @@ -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( diff --git a/apps/studio/tests/test_scene3d_pane_ui.py b/apps/studio/tests/test_scene3d_pane_ui.py index e0a52bb..20921cf 100644 --- a/apps/studio/tests/test_scene3d_pane_ui.py +++ b/apps/studio/tests/test_scene3d_pane_ui.py @@ -145,6 +145,101 @@ def test_the_reset_view_icon_is_painted_from_the_theme(): assert not icons.icon("reset_view", theme).isNull() +# --------------------------------------------------------------- which quantity + + +def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(qt_app, 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_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(qt_app, panel): + """Warnings are claims about the measurement and always show; info is explanation.""" + from orisys_app_shared.scene3d.contracts import diagnostic + + panel.set_descriptor(None) + panel.set_diagnostics_visible(False) + 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" + ) + + panel.set_diagnostics_visible(True) + 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(qt_app, 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 diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index a2aae4b..3d8077d 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -496,6 +496,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 +528,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" diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 36a06f6..abe9557 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -1356,6 +1356,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 +1378,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 diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 0d0d0b4..c674de9 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -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() diff --git a/apps/studio/tools/ui_snapshot.py b/apps/studio/tools/ui_snapshot.py index 1785c43..86a74ae 100644 --- a/apps/studio/tools/ui_snapshot.py +++ b/apps/studio/tools/ui_snapshot.py @@ -182,6 +182,10 @@ def _flow_scene(window, app, *, frames: int = 240) -> None: 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) @@ -201,6 +205,7 @@ def _flow_scene(window, app, *, frames: int = 240) -> None: 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, @@ -221,8 +226,8 @@ def _flow_scene(window, app, *, frames: int = 240) -> None: calibrated_axes=("fn", "fx", "fy"), flow_valid=True, flow_frame_id=index, - depth_valid=None, - depth_frame_id=None, + depth_valid=True, + depth_frame_id=index, forces_valid=True, ) ) diff --git a/docs/architecture/studio-3d-stages.md b/docs/architecture/studio-3d-stages.md index 2f3dc1a..d4eca6d 100644 --- a/docs/architecture/studio-3d-stages.md +++ b/docs/architecture/studio-3d-stages.md @@ -638,13 +638,21 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3 1. **阶段 8 第二批(画布布局)——部分已做(2026-09-15)**: *已做*:三维面板本身只留画面 + 一行紧凑图例(色条 + 来源/状态)+ 两个图标(复位视角、齿轮), - 全部**配置**(总开关、观察方向、显示外壳、放大形变与增益、色标模式与范围、显示帧率、显示统计) - 搬进 `视图 ▸ 三维显示设置…` 和面板齿轮打开的非模态对话框(`scene3d/settings_dialog.py`, - 它承载的还是面板自己那套控件,所以设置通路没有分叉);live/replay 预设把三维设为**最宽**面板 - (620 : 300 : 520),三维最小宽度 260 → 200(没有表单要放,画面才是重点)。 + 全部**配置**(总开关、观察方向、显示外壳、放大形变与增益、颜色来源、色标模式与范围、显示帧率、 + 显示统计)搬进 `视图 ▸ 三维显示设置…` 和面板齿轮打开的非模态对话框 + (`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`、 diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index a7ee819..5bc5a04 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -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)