界面:三维可选「深度响应 / 光流幅值」上色(默认深度),并加一个「诊断信息」开关收拾面板脚下的文字
- 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:
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user