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

- 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
+7 -1
View File
@@ -32,6 +32,7 @@
"finger_3d": "Finger 3D view", "finger_3d": "Finger 3D view",
"scene3d_settings": "3D display settings…", "scene3d_settings": "3D display settings…",
"show_fps": "Show FPS", "show_fps": "Show FPS",
"show_diagnostics": "Diagnostics",
"settings": "Settings area", "settings": "Settings area",
"plot_settings": "Plot settings…", "plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°", "rotate_arrows": "Rotate arrows 90°",
@@ -405,7 +406,12 @@
"housing": "Housing", "housing": "Housing",
"deform": "Exaggerate", "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_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_flow_magnitude": "Optical flow magnitude",
"quantity_depth_response": "Depth response", "quantity_depth_response": "Depth response",
"mode_surface": "surface colouring", "mode_surface": "surface colouring",
+6
View File
@@ -32,6 +32,7 @@
"finger_3d": "手指 3D 视图", "finger_3d": "手指 3D 视图",
"scene3d_settings": "三维显示设置…", "scene3d_settings": "三维显示设置…",
"show_fps": "显示 FPS", "show_fps": "显示 FPS",
"show_diagnostics": "诊断信息",
"settings": "设置区", "settings": "设置区",
"plot_settings": "绘图设置…", "plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°", "rotate_arrows": "旋转箭头图 90°",
@@ -406,6 +407,11 @@
"deform": "放大形变", "deform": "放大形变",
"deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。", "deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。",
"deform_unavailable": "当前数据源未提供三维位移:本面板只有光流幅值的曲面着色,形变放大不可用。", "deform_unavailable": "当前数据源未提供三维位移:本面板只有光流幅值的曲面着色,形变放大不可用。",
"field": "颜色来源",
"field_tip": "三维表面用哪个量上色:深度响应(链路的相对形变响应,无量纲)或光流幅值(追踪器的像素位移大小)。两者都不是毫米,都是近似量;选中的那个若在这一帧画不出来,会自动改用另一个,并在这行下面说明。",
"field_depth": "深度响应",
"field_flow": "光流幅值",
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
"quantity_flow_magnitude": "光流幅值", "quantity_flow_magnitude": "光流幅值",
"quantity_depth_response": "深度响应", "quantity_depth_response": "深度响应",
"mode_surface": "曲面着色", "mode_surface": "曲面着色",
@@ -278,6 +278,7 @@ class StudioMainWindow(QMainWindow):
toolbar=self.toolbar, toolbar=self.toolbar,
charts=self.force_plots, charts=self.force_plots,
arrow_panel=self.arrow_panel, arrow_panel=self.arrow_panel,
mesh_panel=self.mesh3d_panel,
engine=self.replay, engine=self.replay,
sensor_picker=self.sensor_picker, sensor_picker=self.sensor_picker,
plot_defaults={ plot_defaults={
@@ -914,6 +915,16 @@ class StudioMainWindow(QMainWindow):
self.act_show_fps.toggled.connect(self.display_ctl.on_show_fps_toggled) self.act_show_fps.toggled.connect(self.display_ctl.on_show_fps_toggled)
self.view_menu.addAction(self.act_show_fps) 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 = QAction(self)
self.act_settings.setCheckable(True) self.act_settings.setCheckable(True)
self.act_settings.setChecked(self.toolbar.settings_expanded()) 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_finger_3d.setText(t("menu.finger_3d"))
self.act_scene3d_settings.setText(t("menu.scene3d_settings")) self.act_scene3d_settings.setText(t("menu.scene3d_settings"))
self.act_show_fps.setText(t("menu.show_fps")) 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_settings.setText(t("menu.settings"))
self.act_plot_settings.setText(t("menu.plot_settings")) self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows")) self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
@@ -2304,6 +2316,7 @@ class StudioMainWindow(QMainWindow):
self.mesh3d_panel.display_settings() self.mesh3d_panel.display_settings()
) )
self.prefs["show_fps"] = bool(self._show_fps) 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["settings_expanded"] = self.toolbar.settings_expanded()
self.prefs["view_preset"] = self._view_preset self.prefs["view_preset"] = self._view_preset
self.prefs["sensor_type"] = self.sensor_picker.sensor_type() self.prefs["sensor_type"] = self.sensor_picker.sensor_type()
@@ -62,6 +62,7 @@ class DisplayPrefsController(QObject):
plot_defaults: dict, plot_defaults: dict,
default_sample_rate_hz: float, default_sample_rate_hz: float,
arrow_rotate_types: frozenset, arrow_rotate_types: frozenset,
mesh_panel: Any = None,
rotate_arrows_action: Any = None, rotate_arrows_action: Any = None,
parent: Optional[QObject] = None, parent: Optional[QObject] = None,
) -> None: ) -> None:
@@ -70,6 +71,7 @@ class DisplayPrefsController(QObject):
self._toolbar = toolbar self._toolbar = toolbar
self._charts = charts self._charts = charts
self._arrow_panel = arrow_panel self._arrow_panel = arrow_panel
self._mesh_panel = mesh_panel
self._engine = engine self._engine = engine
self._picker = sensor_picker self._picker = sensor_picker
self._rotate_action = rotate_arrows_action 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 #: 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. #: the other ones rather than with the session that produces the number.
self._show_fps = bool(host.prefs.get("show_fps", False)) 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: def bind_actions(self, *, rotate_arrows: Any) -> None:
"""Take the rotate entry once the menus exist (it follows the sensor).""" """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. # FPS has one home now: the status rail.
self._host._refresh_status_rail() self._host._refresh_status_rail()
self._host._persist_prefs() 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)
+158 -19
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]: def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
"""``|flow|`` as float32, or ``None`` when the packet carries no flow.""" """``|flow|`` as float32, or ``None`` when the packet carries no flow."""
flow = getattr(packet, "flow", None) flow = getattr(packet, "flow", None)
@@ -186,6 +207,7 @@ class FingerFrameAdapter:
shape_hw: tuple[int, int], shape_hw: tuple[int, int],
map_x: Optional[np.ndarray] = None, map_x: Optional[np.ndarray] = None,
map_y: Optional[np.ndarray] = None, map_y: Optional[np.ndarray] = None,
preferred_field: str = "depth",
) -> None: ) -> None:
self.geometry_id = str(geometry_id) self.geometry_id = str(geometry_id)
self.field_domain_id = str(field_domain_id) self.field_domain_id = str(field_domain_id)
@@ -193,6 +215,18 @@ class FingerFrameAdapter:
self.map_x = map_x self.map_x = map_x
self.map_y = map_y self.map_y = map_y
self.last_diagnostic: Optional[Diagnostic] = None 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 @classmethod
def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter": def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter":
@@ -208,16 +242,102 @@ class FingerFrameAdapter:
def snapshot( def snapshot(
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
) -> Optional[SurfaceFieldFrame]: ) -> Optional[SurfaceFieldFrame]:
# Provenance first, and before the cheap array work: a packet that cannot say """Colour the surface with the field the operator chose, or say why not.
# its flow is this frame's must not be coloured, whatever the array holds.
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) problem = _field_validity(packet, "flow", identity.frame_id)
if problem is not None: if problem is not None:
self.last_diagnostic = problem refused.append(problem)
return None return None
magnitude = flow_magnitude(packet) magnitude = flow_magnitude(packet)
if magnitude is None: if magnitude is None:
self.last_diagnostic = diagnostic( refused.append(
"field_unavailable", "this frame carries no optical flow" diagnostic("field_unavailable", "this frame carries no optical flow")
) )
return None return None
if self.map_x is not None and self.map_y is not 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 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 except Exception as exc: # noqa: BLE001 - a bad map is a diagnostic
self.last_diagnostic = getattr(exc, "diagnostic", None) or diagnostic( refused.append(
"field_domain_mismatch", f"remap failed: {type(exc).__name__}: {exc}" getattr(exc, "diagnostic", None)
or diagnostic(
"field_domain_mismatch",
f"remap failed: {type(exc).__name__}: {exc}",
)
) )
return None return None
scalar = values scalar, mask = values, valid
mask = valid
elif tuple(magnitude.shape[:2]) == self.shape_hw: elif tuple(magnitude.shape[:2]) == self.shape_hw:
scalar = freeze(magnitude, dtype=np.float32) scalar = freeze(magnitude, dtype=np.float32)
mask = freeze(np.isfinite(magnitude), dtype=np.bool_) mask = freeze(np.isfinite(magnitude), dtype=np.bool_)
else: else:
# Say what was seen: this is the message that tells an engineer whether # Say what was seen: this is the message that tells an engineer whether the
# the flow arrives in the tracker's grid or already unrolled. # flow arrives in the tracker's grid or already unrolled.
self.last_diagnostic = diagnostic( refused.append(
diagnostic(
"field_domain_mismatch", "field_domain_mismatch",
"the flow is not in the scene's UV domain and no remap is configured", "the flow is not in the scene's UV domain and no remap is configured",
flow=_shape_of(magnitude), flow=_shape_of(magnitude),
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}", domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
) )
)
return None return None
# Inside the UV island is the only place a flow value means anything: the return self._finish(
# tracker is looking at masked black outside it. 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) device, mask_problem = _device_mask(packet, self.shape_hw)
if mask_problem is not None: if mask_problem is not None:
self.last_diagnostic = mask_problem refused.append(mask_problem)
return None return None
mask = _intersect(mask, device)
self.last_diagnostic = None self.last_diagnostic = None
return SurfaceFieldFrame( return SurfaceFieldFrame(
identity=identity, identity=identity,
geometry_id=self.geometry_id, geometry_id=self.geometry_id,
field_domain_id=self.field_domain_id, field_domain_id=self.field_domain_id,
scalar=scalar, scalar=scalar,
scalar_descriptor=FLOW_MAGNITUDE, scalar_descriptor=descriptor,
valid=mask, valid=_intersect(mask, device),
computed_frame_id=_computed_frame_id(packet, "flow", identity.frame_id), 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, SceneCapabilities,
SceneState, 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 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.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.widget import Sensor3DWidget from orisys_studio.scene3d.widget import Sensor3DWidget
#: Width of one control in the pane. Chosen against #: Width of one control in the settings dialog. The pane no longer has to fit them —
#: ``MIN_PANEL_WIDTHS["mesh3d"]`` (240), not against how it looks on a wide screen: #: they live in ``Scene3dSettingsDialog`` — so they are sized to read, not to squeeze.
#: the pane is a splitter child that the operator can drag narrow. FIELD_WIDTH = 108
FIELD_WIDTH = 88
SPIN_WIDTH = 68 SPIN_WIDTH = 68
#: Display limits offered in the panel. ``max_fps`` is a *display* budget; it never #: 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. #: saved-settings repair has to accept exactly the values this combo offers.
__all__ = ["FPS_CHOICES", "ThreeDPanel"] __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): class ColorScaleBar(QLabel):
"""The colour ramp the shader actually used, with the bounds it used them for. """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 self._capabilities: Optional[SceneCapabilities] = None
#: The bounds the last draw actually used (the widget's RenderStats). #: The bounds the last draw actually used (the widget's RenderStats).
self._effective_scale: Optional[RenderStats] = None 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 = QVBoxLayout(self)
root.setContentsMargins(8, 6, 8, 6) root.setContentsMargins(8, 6, 8, 6)
@@ -191,6 +201,27 @@ class ThreeDPanel(QFrame):
gain_row.addStretch(1) gain_row.addStretch(1)
form.addRow(gain_row) 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_label = QLabel()
self.scale_combo = QComboBox() self.scale_combo = QComboBox()
self.scale_combo.addItem("fixed", "fixed") self.scale_combo.addItem("fixed", "fixed")
@@ -260,20 +291,21 @@ class ThreeDPanel(QFrame):
legend_row = QHBoxLayout() legend_row = QHBoxLayout()
legend_row.setContentsMargins(0, 0, 0, 0) legend_row.setContentsMargins(0, 0, 0, 0)
legend_row.setSpacing(8) 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.setObjectName("panelMeta")
self.legend_label.setWordWrap(True) self.legend_label.setWordWrap(False)
self.legend_label.setMinimumWidth(1)
legend_row.addWidget(self.legend_label, 3) legend_row.addWidget(self.legend_label, 3)
#: Which *source* the field on screen came from. A separate line from the #: Which *source* the field on screen came from. A separate line from the
#: scene state, because they answer different questions: the state says whether #: scene state, because they answer different questions: the state says whether
#: anything is being measured, this says whether it was acquired live or #: anything is being measured, this says whether it was acquired live or
#: recomputed from a recording — a distinction the design document insists on. #: 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.setObjectName("panelMeta")
self.source_label.setWordWrap(True) self.source_label.setWordWrap(False)
self.source_label.setMinimumWidth(1)
self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
legend_row.addWidget(self.source_label, 2) legend_row.addWidget(self.source_label, 2)
root.addLayout(legend_row) root.addLayout(legend_row)
@@ -314,6 +346,7 @@ class ThreeDPanel(QFrame):
def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None: def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None:
"""The measurement currently on screen — or none, which hides the bar.""" """The measurement currently on screen — or none, which hides the bar."""
self._descriptor = descriptor self._descriptor = descriptor
self._render_field_note()
if descriptor is None or descriptor.quantity == "unavailable": if descriptor is None or descriptor.quantity == "unavailable":
# No measurement: do not leave the previous range on display. # No measurement: do not leave the previous range on display.
self.legend_label.setText(t("scene3d.legend_none")) self.legend_label.setText(t("scene3d.legend_none"))
@@ -369,7 +402,7 @@ class ThreeDPanel(QFrame):
quantity = descriptor.quantity quantity = descriptor.quantity
quality = t(f"scene3d.quality_{descriptor.quality}") quality = t(f"scene3d.quality_{descriptor.quality}")
if effective is None or effective.scale_mode == "": if effective is None or effective.scale_mode == "":
self.legend_label.setText( self._set_legend(
t( t(
"scene3d.legend", "scene3d.legend",
quantity=quantity, quantity=quantity,
@@ -383,18 +416,25 @@ class ThreeDPanel(QFrame):
if effective.scale_mode == "automatic" if effective.scale_mode == "automatic"
else "scene3d.legend_fixed" else "scene3d.legend_fixed"
) )
self.legend_label.setText( self._set_legend(
t( t(
"scene3d.legend_effective", "scene3d.legend_effective",
quantity=quantity, quantity=quantity,
unit=descriptor.unit, unit=descriptor.unit,
mode=mode, mode=mode,
lower=f"{effective.scale_lower:.4g}", # Three significant digits: the footer is one line, and "0.033 – 0.90"
upper=f"{effective.scale_upper:.4g}", # 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, 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: def set_state(self, state: SceneState) -> None:
self._state = state self._state = state
self.state_label.setText(t(f"scene3d.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")) self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip"))
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None: def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
"""One diagnostic line. The 2D views stay usable behind it.""" """One diagnostic line. The 2D views stay usable behind it.
if diagnostic is None:
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() self._legend_reset_note()
return return
prefix = t(f"scene3d.severity_{diagnostic.severity}") prefix = t(f"scene3d.severity_{diagnostic.severity}")
self.legend_label.setText(f"{prefix}: {diagnostic.message}") self.legend_label.setText(f"{prefix}: {diagnostic.message}")
self.legend_label.setVisible(True) 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: def set_render_stats(self, stats: RenderStats) -> None:
"""Display-side statistics only: never shown as recording health.""" """Display-side statistics only: never shown as recording health."""
self._effective_scale = stats self._effective_scale = stats
@@ -465,6 +561,10 @@ class ThreeDPanel(QFrame):
self.set_descriptor(self._descriptor if enabled else None) self.set_descriptor(self._descriptor if enabled else None)
# ----------------------------------------------------------------- outputs # ----------------------------------------------------------------- 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: def apply_settings(self, settings: DisplaySettings) -> None:
"""Move the controls to these settings and tell the view about them. """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.deform_check.setChecked(bool(settings.deform_enabled))
self.gain_spin.setValue(float(settings.display_gain)) self.gain_spin.setValue(float(settings.display_gain))
self.housing_check.setChecked(bool(settings.housing_visible)) 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 scale = settings.color_scale
if scale is not None: if scale is not None:
index = self.scale_combo.findData(str(scale.mode)) index = self.scale_combo.findData(str(scale.mode))
@@ -520,6 +623,7 @@ class ThreeDPanel(QFrame):
), ),
housing_visible=self.housing_check.isChecked(), housing_visible=self.housing_check.isChecked(),
max_fps=int(self.fps_combo.currentData()), max_fps=int(self.fps_combo.currentData()),
field=self.field(),
) )
# ------------------------------------------------------------------ signals # ------------------------------------------------------------------ signals
@@ -546,6 +650,11 @@ class ThreeDPanel(QFrame):
self.set_descriptor(self._descriptor) self.set_descriptor(self._descriptor)
self._emit_settings() 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: def _emit_settings(self) -> None:
if self._syncing: if self._syncing:
return return
@@ -561,6 +670,11 @@ class ThreeDPanel(QFrame):
self.housing_check.setText(t("scene3d.housing")) self.housing_check.setText(t("scene3d.housing"))
self.deform_check.setText(t("scene3d.deform")) self.deform_check.setText(t("scene3d.deform"))
self.deform_check.setToolTip(t("scene3d.deform_tip")) 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.scale_label.setText(t("scene3d.scale"))
self.range_label.setText(t("scene3d.scale_range")) self.range_label.setText(t("scene3d.scale_range"))
self.range_label.setToolTip(t("scene3d.scale_range_tip")) self.range_label.setToolTip(t("scene3d.scale_range_tip"))
@@ -35,6 +35,12 @@ DEFAULT_MAX_FPS = 30
MIN_GAIN = 0.0 MIN_GAIN = 0.0
MAX_GAIN = 500.0 MAX_GAIN = 500.0
SCALE_MODES = ("fixed", "automatic") 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 #: 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). #: and the number is certainly a typo ("1e308" in a hand-edited file).
MAX_SCALE_BOUND = 1e12 MAX_SCALE_BOUND = 1e12
@@ -73,6 +79,7 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
"display_gain": float(settings.display_gain), "display_gain": float(settings.display_gain),
"housing_visible": bool(settings.housing_visible), "housing_visible": bool(settings.housing_visible),
"max_fps": int(settings.max_fps), "max_fps": int(settings.max_fps),
"field": str(settings.field),
} }
if scale is not None: if scale is not None:
block["color_scale"] = { block["color_scale"] = {
@@ -105,6 +112,9 @@ def settings_from_prefs(
max_fps = int(_number(raw.get("max_fps"), DEFAULT_MAX_FPS)) max_fps = int(_number(raw.get("max_fps"), DEFAULT_MAX_FPS))
if max_fps not in FPS_CHOICES: if max_fps not in FPS_CHOICES:
max_fps = DEFAULT_MAX_FPS 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 scale: Optional[ColorScale] = None
saved = raw.get("color_scale") saved = raw.get("color_scale")
if isinstance(saved, Mapping): if isinstance(saved, Mapping):
@@ -134,4 +144,5 @@ def settings_from_prefs(
color_scale=scale, color_scale=scale,
housing_visible=_flag(raw.get("housing_visible"), True), housing_visible=_flag(raw.get("housing_visible"), True),
max_fps=max_fps, max_fps=max_fps,
field=field,
) )
@@ -363,6 +363,11 @@ class Scene3DView(QWidget):
def set_settings(self, settings: DisplaySettings) -> None: def set_settings(self, settings: DisplaySettings) -> None:
if self._coordinator is not None: if self._coordinator is not None:
self._coordinator.set_settings(settings) 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: def apply_preset(self, name: str) -> None:
if self._assets is None: if self._assets is None:
@@ -175,6 +175,10 @@ class ArrowPanel(QFrame):
#: panel behaved before the plans existed. #: panel behaved before the plans existed.
self._modes: tuple[str, ...] = () self._modes: tuple[str, ...] = ()
self._plan_note = "" 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 = QComboBox()
self.view_combo.setMinimumWidth(140) self.view_combo.setMinimumWidth(140)
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed) 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 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 the valid area describe a flow drawing, so they are shown only when an arrow
mode is on offer. 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 depth = self.view_mode() == DEPTH
modes = self.view_modes() modes = self.view_modes()
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes
self.scale_label.setVisible(not depth and has_arrows) explain = bool(self._show_diagnostics)
self.pipeline_label.setVisible(not depth) self.scale_label.setVisible(explain and not depth and has_arrows)
self.area_label.setVisible(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.colorbar.setVisible(depth)
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text)) self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
self.note_label.setVisible(bool(self._plan_note)) 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: def _populate_view_combo(self) -> None:
current = self.view_combo.currentData() current = self.view_combo.currentData()
self.view_combo.blockSignals(True) self.view_combo.blockSignals(True)
@@ -108,6 +108,9 @@ class Mesh3dPanel(QFrame):
self._pending_settings = None self._pending_settings = None
self._message_kind = _UNAVAILABLE self._message_kind = _UNAVAILABLE
self._message_args: dict[str, str] = {"reason": ""} 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 = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0) root.setContentsMargins(0, 0, 0, 0)
@@ -167,6 +170,24 @@ class Mesh3dPanel(QFrame):
if self._view is not None: if self._view is not None:
self._view.reset_view() 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: def set_theme(self, theme: str) -> None:
"""Re-tint the header icons: they are painted from the theme's own colours.""" """Re-tint the header icons: they are painted from the theme's own colours."""
self._theme = str(theme) 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 # 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 preset changes), so it is pushed again here rather than lost.
view.set_source_kind(self._source_kind) 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: if not view.available:
self._show_message(_UNAVAILABLE, reason=view.reason) self._show_message(_UNAVAILABLE, reason=view.reason)
+33 -2
View File
@@ -124,11 +124,14 @@ class _Host:
self.repaints += 1 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) host = _Host(prefs or {}, latest_fps=latest_fps)
toolbar = _Toolbar(units) toolbar = _Toolbar(units)
charts = _Charts("N" if units == "newton" else "raw") charts = _Charts("N" if units == "newton" else "raw")
arrows = _ArrowPanel() arrows = arrow_panel if arrow_panel is not None else _ArrowPanel()
engine = _Engine(loaded) engine = _Engine(loaded)
action = _Action() action = _Action()
controller = DisplayPrefsController( controller = DisplayPrefsController(
@@ -136,6 +139,7 @@ def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False,
toolbar=toolbar, toolbar=toolbar,
charts=charts, charts=charts,
arrow_panel=arrows, arrow_panel=arrows,
mesh_panel=mesh_panel,
engine=engine, engine=engine,
sensor_picker=_Picker(sensor_type), sensor_picker=_Picker(sensor_type),
rotate_arrows_action=action, 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"].prefs["show_fps"] is True
assert parts["host"].rail_updates == 1, "the rail was not told to show it" assert parts["host"].rail_updates == 1, "the rail was not told to show it"
assert parts["host"].persisted == 1 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
@@ -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 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 # ----------------------------------------------------------------- finger adapter
def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene): def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene):
adapter = FingerFrameAdapter( adapter = FingerFrameAdapter(
+95
View File
@@ -145,6 +145,101 @@ def test_the_reset_view_icon_is_painted_from_the_theme():
assert not icons.icon("reset_view", theme).isNull() 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 # --------------------------------------------------------------------- the menu
+14
View File
@@ -496,6 +496,19 @@ def test_a_saved_descriptor_is_never_restored_as_a_measurement():
assert restored_with.color_scale.descriptor == given 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( @pytest.mark.parametrize(
"block", "block",
[ [
@@ -515,6 +528,7 @@ def test_a_hand_edited_settings_block_is_repaired(block):
assert isinstance(settings, DisplaySettings) assert isinstance(settings, DisplaySettings)
assert scene3d_prefs.MIN_GAIN <= settings.display_gain <= scene3d_prefs.MAX_GAIN assert scene3d_prefs.MIN_GAIN <= settings.display_gain <= scene3d_prefs.MAX_GAIN
assert settings.max_fps in scene3d_prefs.FPS_CHOICES assert settings.max_fps in scene3d_prefs.FPS_CHOICES
assert settings.field in scene3d_prefs.FIELD_CHOICES
if settings.color_scale is not None: if settings.color_scale is not None:
lower, upper = settings.color_scale.lower, settings.color_scale.upper lower, upper = settings.color_scale.lower, settings.color_scale.upper
assert lower == lower and upper == upper, "a NaN survived the repair" assert lower == lower and upper == upper, "a NaN survived the repair"
+9
View File
@@ -1356,6 +1356,12 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
panel = window.arrow_panel 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 panel.scale_label.isVisible()
assert "2" in panel.scale_label.text() assert "2" in panel.scale_label.text()
assert panel.pipeline_label.isVisible() and panel.pipeline_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 not panel.colorbar.isVisible()
assert panel.scale_label.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 # ------------------------------------------------- top rail: sensor and views
+24
View File
@@ -1366,6 +1366,22 @@ def main() -> int:
) )
arrow = window.arrow_panel 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( check(
arrow.scale_label.isVisible() and arrow.scale_label.text(), arrow.scale_label.isVisible() and arrow.scale_label.text(),
"the arrow view does not show its display magnification", "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(), not arrow.colorbar.isVisible() and arrow.scale_label.isVisible(),
"leaving the depth view did not restore the arrow legend", "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. # The overview strip is the timeline control: dragging it pans the window.
stuck = _StuckWorker() stuck = _StuckWorker()
+7 -2
View File
@@ -182,6 +182,10 @@ def _flow_scene(window, app, *, frames: int = 240) -> None:
flow = np.stack( flow = np.stack(
[dx / radius * profile * 7.0, dy / radius * profile * 7.0], axis=-1 [dx / radius * profile * 7.0, dy / radius * profile * 7.0], axis=-1
).astype(np.float32) ).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. # 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 = np.zeros((height, width, 3), np.uint8)
frame[..., 1] = (40.0 + profile * 150.0).astype(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, raw_frame=frame,
processed_frame=frame, processed_frame=frame,
flow=flow, flow=flow,
depth=depth,
timestamp=1000.0 + index / 30.0, timestamp=1000.0 + index / 30.0,
mono_t=float(index) / 30.0, mono_t=float(index) / 30.0,
fps=30.0, fps=30.0,
@@ -221,8 +226,8 @@ def _flow_scene(window, app, *, frames: int = 240) -> None:
calibrated_axes=("fn", "fx", "fy"), calibrated_axes=("fn", "fx", "fy"),
flow_valid=True, flow_valid=True,
flow_frame_id=index, flow_frame_id=index,
depth_valid=None, depth_valid=True,
depth_frame_id=None, depth_frame_id=index,
forces_valid=True, forces_valid=True,
) )
) )
+12 -4
View File
@@ -638,13 +638,21 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
1. **阶段 8 第二批(画布布局)——部分已做(2026-09-15)**: 1. **阶段 8 第二批(画布布局)——部分已做(2026-09-15)**:
*已做*:三维面板本身只留画面 + 一行紧凑图例(色条 + 来源/状态)+ 两个图标(复位视角、齿轮), *已做*:三维面板本身只留画面 + 一行紧凑图例(色条 + 来源/状态)+ 两个图标(复位视角、齿轮),
全部**配置**(总开关、观察方向、显示外壳、放大形变与增益、色标模式与范围、显示帧率、显示统计) 全部**配置**(总开关、观察方向、显示外壳、放大形变与增益、颜色来源、色标模式与范围、显示帧率、
搬进 `视图 ▸ 三维显示设置…` 和面板齿轮打开的非模态对话框(`scene3d/settings_dialog.py`, 显示统计)搬进 `视图 ▸ 三维显示设置…` 和面板齿轮打开的非模态对话框
它承载的还是面板自己那套控件,所以设置通路没有分叉);live/replay 预设把三维设为**最宽**面板 (`scene3d/settings_dialog.py`,它承载的还是面板自己那套控件,所以设置通路没有分叉);
(620 : 300 : 520),三维最小宽度 260 → 200(没有表单要放,画面才是重点)。 live/replay 预设把三维设为**最宽**面板(620 : 300 : 520),三维最小宽度 260 → 200;
**颜色来源**可选「深度响应(默认)/ 光流幅值」:两者都是近似量、都不是毫米,选中的那个画不出来时
自动改用另一个,并在设置对话框里写明"这一帧画的是哪一个";图例永远标注实际画出来的量
(`scene3d/panel.py` 的 `field_note`、`adapters.FingerFrameAdapter.last_substitution`);
面板脚下的内部解释(2D 的"箭头按位移放大 / 原始图像 → 算法输出 / 全部帧"、3D 的 info 级诊断)
改由 `视图 ▸ 诊断信息` 一个开关控制,**默认关**:默认每个面板只剩"画面 + 读数 + 真话声明",
异常(过期、无可用场、未与设备核对)照旧显示。
*仍未做*:2D/3D 页签容器与"并排对照"、窄窗口回退策略、曲线"可见区间自动/固定量程"开关、 *仍未做*:2D/3D 页签容器与"并排对照"、窄窗口回退策略、曲线"可见区间自动/固定量程"开关、
调试统计移入独立诊断抽屉(统计现在在设置对话框底部);阶段 9 的真机/板卡门禁同前。 调试统计移入独立诊断抽屉(统计现在在设置对话框底部);阶段 9 的真机/板卡门禁同前。
为什么仍留后:页签容器要动 splitter 的槽位与 `ui_check` 的多处布局断言,需要与用户一起看截图定稿。 为什么仍留后:页签容器要动 splitter 的槽位与 `ui_check` 的多处布局断言,需要与用户一起看截图定稿。
*真机待验证*:手指路径上 `packet.depth` 的栅格是否就是场景的 300×280(若不是,面板会写明并回退到
光流;这条只有接上设备才能确认)。
2. **阶段 9 门禁运行**:GUI 模式 **2 小时**长时(`soak_scene.py --minutes 120 --fps 30`)——本阶段已跑完 2. **阶段 9 门禁运行**:GUI 模式 **2 小时**长时(`soak_scene.py --minutes 120 --fps 30`)——本阶段已跑完
10 分钟的一份(见验收表格:增长 4.83 MB、尾段 0.371 MB/min,外推 2 小时约 45 MB,低于 64 MB 上限,但 10 分钟的一份(见验收表格:增长 4.83 MB、尾段 0.371 MB/min,外推 2 小时约 45 MB,低于 64 MB 上限,但
**外推不是门禁**,2 小时整仍需实跑);以及 Jetson Orin Nano / QCS6490 上的 `probe_gl.py --json`、 **外推不是门禁**,2 小时整仍需实跑);以及 Jetson Orin Nano / QCS6490 上的 `probe_gl.py --json`、
@@ -66,6 +66,10 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
#: show the geometry, but it must not claim the measurement was checked against it. #: show the geometry, but it must not claim the measurement was checked against it.
"geometry_unverified": "info", "geometry_unverified": "info",
"stale_frame": "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", "gl_unsupported": "error",
"shader_compile_failed": "error", "shader_compile_failed": "error",
"gpu_budget_exceeded": "error", "gpu_budget_exceeded": "error",
@@ -537,6 +541,13 @@ class DisplaySettings:
color_scale: Optional[ColorScale] = None color_scale: Optional[ColorScale] = None
housing_visible: bool = True housing_visible: bool = True
max_fps: int = 30 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) @dataclass(frozen=True)