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

- 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
@@ -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()
@@ -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),
)
+131 -17
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,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"))
@@ -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,
)
@@ -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:
@@ -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)