界面:三维可选「深度响应 / 光流幅值」上色(默认深度),并加一个「诊断信息」开关收拾面板脚下的文字

- 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(若不是,面板会写明并回退到光流)。
This commit is contained in:
ykcheungab
2026-09-15 21:23:18 +08:00
parent 0b87e3e36d
commit c2293d4d41
19 changed files with 702 additions and 52 deletions
@@ -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)
@@ -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)