界面:三维可选「深度响应 / 光流幅值」上色(默认深度),并加一个「诊断信息」开关收拾面板脚下的文字
- 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:
@@ -32,6 +32,7 @@
|
||||
"finger_3d": "Finger 3D view",
|
||||
"scene3d_settings": "3D display settings…",
|
||||
"show_fps": "Show FPS",
|
||||
"show_diagnostics": "Diagnostics",
|
||||
"settings": "Settings area",
|
||||
"plot_settings": "Plot settings…",
|
||||
"rotate_arrows": "Rotate arrows 90°",
|
||||
@@ -405,7 +406,12 @@
|
||||
"housing": "Housing",
|
||||
"deform": "Exaggerate",
|
||||
"deform_tip": "Display-only magnification of the measured deformation. It changes the picture and nothing else: the recorded data, the calibration and the camera are not touched.",
|
||||
"deform_unavailable": "This data source provides no 3D displacement: the pane can only colour the surface with the optical-flow magnitude, so exaggeration is unavailable.",
|
||||
"deform_unavailable": "This data source provides no 3D displacement: the pane can only colour the surface, so exaggeration is unavailable.",
|
||||
"field": "Colour by",
|
||||
"field_tip": "Which quantity colours the surface: the depth response (the chain's relative deformation response, unitless) or the optical-flow magnitude (the tracker's pixel displacement). Neither is a length in millimetres — both are approximate. When the chosen one cannot be drawn for a frame, the other is used and the line below says so.",
|
||||
"field_depth": "Depth response",
|
||||
"field_flow": "Optical flow magnitude",
|
||||
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
|
||||
"quantity_flow_magnitude": "Optical flow magnitude",
|
||||
"quantity_depth_response": "Depth response",
|
||||
"mode_surface": "surface colouring",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"finger_3d": "手指 3D 视图",
|
||||
"scene3d_settings": "三维显示设置…",
|
||||
"show_fps": "显示 FPS",
|
||||
"show_diagnostics": "诊断信息",
|
||||
"settings": "设置区",
|
||||
"plot_settings": "绘图设置…",
|
||||
"rotate_arrows": "旋转箭头图 90°",
|
||||
@@ -406,6 +407,11 @@
|
||||
"deform": "放大形变",
|
||||
"deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。",
|
||||
"deform_unavailable": "当前数据源未提供三维位移:本面板只有光流幅值的曲面着色,形变放大不可用。",
|
||||
"field": "颜色来源",
|
||||
"field_tip": "三维表面用哪个量上色:深度响应(链路的相对形变响应,无量纲)或光流幅值(追踪器的像素位移大小)。两者都不是毫米,都是近似量;选中的那个若在这一帧画不出来,会自动改用另一个,并在这行下面说明。",
|
||||
"field_depth": "深度响应",
|
||||
"field_flow": "光流幅值",
|
||||
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
|
||||
"quantity_flow_magnitude": "光流幅值",
|
||||
"quantity_depth_response": "深度响应",
|
||||
"mode_surface": "曲面着色",
|
||||
|
||||
@@ -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(
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -124,11 +124,14 @@ class _Host:
|
||||
self.repaints += 1
|
||||
|
||||
|
||||
def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False, latest_fps=None):
|
||||
def _controller(
|
||||
*, prefs=None, sensor_type="box", units="newton", loaded=False, latest_fps=None,
|
||||
arrow_panel=None, mesh_panel=None,
|
||||
):
|
||||
host = _Host(prefs or {}, latest_fps=latest_fps)
|
||||
toolbar = _Toolbar(units)
|
||||
charts = _Charts("N" if units == "newton" else "raw")
|
||||
arrows = _ArrowPanel()
|
||||
arrows = arrow_panel if arrow_panel is not None else _ArrowPanel()
|
||||
engine = _Engine(loaded)
|
||||
action = _Action()
|
||||
controller = DisplayPrefsController(
|
||||
@@ -136,6 +139,7 @@ def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False,
|
||||
toolbar=toolbar,
|
||||
charts=charts,
|
||||
arrow_panel=arrows,
|
||||
mesh_panel=mesh_panel,
|
||||
engine=engine,
|
||||
sensor_picker=_Picker(sensor_type),
|
||||
rotate_arrows_action=action,
|
||||
@@ -278,3 +282,30 @@ def test_the_fps_readout_is_a_preference_like_any_other():
|
||||
assert parts["host"].prefs["show_fps"] is True
|
||||
assert parts["host"].rail_updates == 1, "the rail was not told to show it"
|
||||
assert parts["host"].persisted == 1
|
||||
|
||||
|
||||
def test_the_diagnostics_switch_reaches_both_panels():
|
||||
"""One preference decides how much the panels explain around their pictures."""
|
||||
|
||||
class _Panel:
|
||||
def __init__(self) -> None:
|
||||
self.visible: list[bool] = []
|
||||
|
||||
def set_diagnostics_visible(self, visible: bool) -> None:
|
||||
self.visible.append(bool(visible))
|
||||
|
||||
arrows = _Panel()
|
||||
mesh = _Panel()
|
||||
controller, parts = _controller(prefs={}, arrow_panel=arrows, mesh_panel=mesh)
|
||||
# The constructor applies the stored preference to whatever panels exist.
|
||||
assert arrows.visible == [False] and mesh.visible == [False]
|
||||
|
||||
controller.on_show_diagnostics_toggled(True)
|
||||
assert arrows.visible[-1] is True and mesh.visible[-1] is True
|
||||
assert controller.show_diagnostics is True
|
||||
assert parts["host"].prefs["show_diagnostics"] is True
|
||||
assert parts["host"].persisted == 1
|
||||
|
||||
controller.on_show_diagnostics_toggled(False)
|
||||
assert arrows.visible[-1] is False and mesh.visible[-1] is False
|
||||
assert parts["host"].prefs["show_diagnostics"] is False
|
||||
|
||||
@@ -73,6 +73,84 @@ def test_the_flow_magnitude_handles_both_shapes():
|
||||
assert flow_magnitude(FakePacket(flow=np.zeros((2, 2, 3), dtype=np.float32))) is None
|
||||
|
||||
|
||||
def test_the_finger_adapter_prefers_the_quantity_the_operator_chose(scene):
|
||||
"""The choice between the two quantities is a display setting, honoured per frame.
|
||||
|
||||
The depth response is what an operator reads as "how deep", so it is the default —
|
||||
and when the frame carries it, that is what is drawn.
|
||||
"""
|
||||
adapter = FingerFrameAdapter(
|
||||
geometry_id=scene.geometry_id,
|
||||
field_domain_id=scene.field_domain_id,
|
||||
shape_hw=scene.shape_hw,
|
||||
)
|
||||
assert adapter.preferred_field == "depth"
|
||||
depth = np.full(scene.shape_hw, 2.5, dtype=np.float32)
|
||||
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
|
||||
flow[..., 0] = 7.0 # |flow| = 7 px
|
||||
|
||||
frame = adapter.snapshot(FakePacket(flow=flow, depth=depth), runtime=None, identity=_identity())
|
||||
assert frame is not None
|
||||
assert frame.scalar_descriptor == DEPTH_RESPONSE
|
||||
assert frame.scalar_descriptor.unit == "1"
|
||||
assert np.allclose(np.asarray(frame.scalar), 2.5)
|
||||
assert adapter.last_substitution is None, "the chosen field was drawn; nothing to say"
|
||||
|
||||
adapter.set_preferred_field("flow")
|
||||
frame = adapter.snapshot(FakePacket(flow=flow, depth=depth), runtime=None, identity=_identity())
|
||||
assert frame is not None
|
||||
assert frame.scalar_descriptor == FLOW_MAGNITUDE
|
||||
assert np.allclose(np.asarray(frame.scalar), 7.0)
|
||||
assert adapter.last_substitution is None
|
||||
|
||||
|
||||
def test_a_frame_that_cannot_honour_the_choice_says_which_quantity_it_drew(scene):
|
||||
"""Falling back is allowed; falling back silently is not.
|
||||
|
||||
The frame is still a measurement and the legend names it, but the *setting* said
|
||||
something else, and the pane has to be able to say so instead of letting the picture
|
||||
read as the quantity the operator asked for.
|
||||
"""
|
||||
adapter = FingerFrameAdapter(
|
||||
geometry_id=scene.geometry_id,
|
||||
field_domain_id=scene.field_domain_id,
|
||||
shape_hw=scene.shape_hw,
|
||||
)
|
||||
# No depth in this frame: the flow is all it has.
|
||||
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
|
||||
flow[..., 0] = 4.0 # |flow| = 4 px
|
||||
frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity())
|
||||
assert frame is not None
|
||||
assert frame.scalar_descriptor == FLOW_MAGNITUDE
|
||||
note = adapter.last_substitution
|
||||
assert note is not None and note.code == "field_substituted"
|
||||
assert note.details["wanted"] == "depth" and note.details["shown"] == "flow"
|
||||
assert "no depth response" in note.details["why"]
|
||||
|
||||
# ...and the note goes away as soon as the chosen field can be drawn again.
|
||||
depth = np.full(scene.shape_hw, 1.0, dtype=np.float32)
|
||||
frame = adapter.snapshot(
|
||||
FakePacket(flow=flow, depth=depth), runtime=None, identity=_identity()
|
||||
)
|
||||
assert frame is not None and frame.scalar_descriptor == DEPTH_RESPONSE
|
||||
assert adapter.last_substitution is None
|
||||
|
||||
|
||||
def test_a_depth_response_on_another_grid_is_refused_not_resized(scene):
|
||||
"""The depth field gets the same treatment as the flow: no resizing, ever."""
|
||||
adapter = FingerFrameAdapter(
|
||||
geometry_id=scene.geometry_id,
|
||||
field_domain_id=scene.field_domain_id,
|
||||
shape_hw=scene.shape_hw,
|
||||
)
|
||||
wrong = np.zeros((7, 7), dtype=np.float32)
|
||||
frame = adapter.snapshot(FakePacket(depth=wrong), runtime=None, identity=_identity())
|
||||
assert frame is None, "a depth response that is not on the scene's grid was coloured"
|
||||
item = adapter.last_diagnostic
|
||||
assert item is not None and item.code == "field_domain_mismatch"
|
||||
assert item.details["depth"] == "7x7"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- finger adapter
|
||||
def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene):
|
||||
adapter = FingerFrameAdapter(
|
||||
|
||||
@@ -145,6 +145,101 @@ def test_the_reset_view_icon_is_painted_from_the_theme():
|
||||
assert not icons.icon("reset_view", theme).isNull()
|
||||
|
||||
|
||||
# --------------------------------------------------------------- which quantity
|
||||
|
||||
|
||||
def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(qt_app, panel):
|
||||
"""The choice is the operator's; a frame that cannot honour it says so."""
|
||||
from orisys_app_shared.scene3d.contracts import FieldDescriptor
|
||||
|
||||
assert [panel.field_combo.itemData(i) for i in range(panel.field_combo.count())] == [
|
||||
"depth",
|
||||
"flow",
|
||||
]
|
||||
assert panel.field() == "depth", "the depth response is what the pane offers first"
|
||||
assert panel.settings().field == "depth"
|
||||
|
||||
# The chosen quantity is drawn: no note.
|
||||
panel.set_descriptor(FieldDescriptor("depth_response", "1", "approximate", False))
|
||||
assert not panel.field_note.isVisible() or not panel.field_note.text()
|
||||
|
||||
# The other one is drawn instead: the note names both, and the legend still shows
|
||||
# what is really on screen (the range is for the field that was drawn).
|
||||
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
|
||||
note = panel.field_note.text()
|
||||
assert t("scene3d.field_depth") in note and t("scene3d.field_flow") in note, note
|
||||
assert "flow_magnitude" not in panel.legend_label.text()
|
||||
|
||||
# Choosing flow makes that frame the honoured one, and the note goes away.
|
||||
index = panel.field_combo.findData("flow")
|
||||
panel.field_combo.setCurrentIndex(index)
|
||||
assert panel.settings().field == "flow"
|
||||
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
|
||||
assert not panel.field_note.isVisible() or not panel.field_note.text()
|
||||
|
||||
# A restored preference moves the combo (and therefore the adapter).
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||
|
||||
panel.apply_settings(DisplaySettings(field="depth"))
|
||||
assert panel.field() == "depth"
|
||||
|
||||
|
||||
def test_the_diagnostics_switch_governs_what_the_pane_explains(qt_app, panel):
|
||||
"""Warnings are claims about the measurement and always show; info is explanation."""
|
||||
from orisys_app_shared.scene3d.contracts import diagnostic
|
||||
|
||||
panel.set_descriptor(None)
|
||||
panel.set_diagnostics_visible(False)
|
||||
panel.set_diagnostic(diagnostic("field_stale", "the array is from another frame"))
|
||||
assert "another frame" in panel.legend_label.text(), (
|
||||
"a warning about the measurement was hidden"
|
||||
)
|
||||
|
||||
panel.set_diagnostic(diagnostic("geometry_unverified", "the scene was not compared"))
|
||||
assert "not compared" in panel.legend_label.text(), (
|
||||
"a claim about verification was hidden"
|
||||
)
|
||||
|
||||
panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
|
||||
assert "not current" not in panel.legend_label.text(), (
|
||||
"an informational line survived diagnostics being off"
|
||||
)
|
||||
|
||||
panel.set_diagnostics_visible(True)
|
||||
panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
|
||||
assert "not current" in panel.legend_label.text()
|
||||
|
||||
|
||||
def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(qt_app, make_window):
|
||||
"""One line under the canvas: what the picture is, not how it was made."""
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
window = make_window()
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
try:
|
||||
arrow = window.arrow_panel
|
||||
arrow.set_plan(None)
|
||||
arrow.set_diagnostics_visible(False)
|
||||
assert not arrow.scale_label.isVisible()
|
||||
assert not arrow.pipeline_label.isVisible()
|
||||
assert not arrow.area_label.isVisible()
|
||||
|
||||
arrow.set_diagnostics_visible(True)
|
||||
assert arrow.pipeline_label.isVisible() or not arrow.view_modes(), (
|
||||
"the pipeline line came back"
|
||||
)
|
||||
|
||||
# The window's switch is the same thing, and it is remembered.
|
||||
window.act_show_diagnostics.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert window.display_ctl.show_diagnostics is True
|
||||
assert arrow.diagnostics_visible() is True
|
||||
assert window.prefs["show_diagnostics"] is True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- the menu
|
||||
|
||||
|
||||
|
||||
@@ -496,6 +496,19 @@ def test_a_saved_descriptor_is_never_restored_as_a_measurement():
|
||||
assert restored_with.color_scale.descriptor == given
|
||||
|
||||
|
||||
def test_a_saved_field_choice_survives_and_a_bad_one_is_repaired():
|
||||
"""Which quantity colours the surface is a preference; an unknown one is repaired."""
|
||||
block = scene3d_prefs.settings_to_prefs(DisplaySettings(field="flow"))
|
||||
assert block["field"] == "flow"
|
||||
assert scene3d_prefs.settings_from_prefs(block).field == "flow"
|
||||
|
||||
# The default is the depth response (the operator chose it for the pane), and a
|
||||
# hand-edited or older file that names something else gets that default.
|
||||
assert DisplaySettings().field == scene3d_prefs.DEFAULT_FIELD == "depth"
|
||||
for bad in ({"field": "displacement"}, {"field": 3}, {"field": None}, {}):
|
||||
assert scene3d_prefs.settings_from_prefs(bad).field == "depth", bad
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"block",
|
||||
[
|
||||
@@ -515,6 +528,7 @@ def test_a_hand_edited_settings_block_is_repaired(block):
|
||||
assert isinstance(settings, DisplaySettings)
|
||||
assert scene3d_prefs.MIN_GAIN <= settings.display_gain <= scene3d_prefs.MAX_GAIN
|
||||
assert settings.max_fps in scene3d_prefs.FPS_CHOICES
|
||||
assert settings.field in scene3d_prefs.FIELD_CHOICES
|
||||
if settings.color_scale is not None:
|
||||
lower, upper = settings.color_scale.lower, settings.color_scale.upper
|
||||
assert lower == lower and upper == upper, "a NaN survived the repair"
|
||||
|
||||
@@ -1356,6 +1356,12 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
panel = window.arrow_panel
|
||||
# The pipeline labels are diagnostics: off by default (the canvas keeps what the
|
||||
# picture *is*), on when the operator asks — 视图 ▸ 诊断信息.
|
||||
assert not panel.scale_label.isVisible()
|
||||
assert not panel.pipeline_label.isVisible()
|
||||
assert not panel.area_label.isVisible()
|
||||
panel.set_diagnostics_visible(True)
|
||||
assert panel.scale_label.isVisible()
|
||||
assert "2" in panel.scale_label.text()
|
||||
assert panel.pipeline_label.isVisible() and panel.pipeline_label.text()
|
||||
@@ -1372,6 +1378,9 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
|
||||
assert not panel.colorbar.isVisible()
|
||||
assert panel.scale_label.isVisible()
|
||||
|
||||
panel.set_diagnostics_visible(False)
|
||||
assert not panel.scale_label.isVisible(), "诊断信息 off still shows the pipeline"
|
||||
|
||||
|
||||
# ------------------------------------------------- top rail: sensor and views
|
||||
|
||||
|
||||
@@ -1366,6 +1366,22 @@ def main() -> int:
|
||||
)
|
||||
|
||||
arrow = window.arrow_panel
|
||||
# The pipeline labels (magnification, source → output, valid area) are diagnostics:
|
||||
# hidden by default, so the canvas keeps one line about what the picture *is*. The
|
||||
# switch is the same one 视图 ▸ 诊断信息 drives.
|
||||
check(
|
||||
not arrow.diagnostics_visible(),
|
||||
"the panel explains its pipeline before the operator asked for it",
|
||||
)
|
||||
check(
|
||||
not arrow.scale_label.isVisible()
|
||||
and not arrow.pipeline_label.isVisible()
|
||||
and not arrow.area_label.isVisible(),
|
||||
"the pipeline labels are shown with 诊断信息 off",
|
||||
)
|
||||
arrow.set_diagnostics_visible(True)
|
||||
for _ in range(2):
|
||||
app.processEvents()
|
||||
check(
|
||||
arrow.scale_label.isVisible() and arrow.scale_label.text(),
|
||||
"the arrow view does not show its display magnification",
|
||||
@@ -1406,6 +1422,14 @@ def main() -> int:
|
||||
not arrow.colorbar.isVisible() and arrow.scale_label.isVisible(),
|
||||
"leaving the depth view did not restore the arrow legend",
|
||||
)
|
||||
# ...and the switch takes them away again, whatever view is on screen.
|
||||
arrow.set_diagnostics_visible(False)
|
||||
for _ in range(2):
|
||||
app.processEvents()
|
||||
check(
|
||||
not arrow.scale_label.isVisible() and arrow.colorbar.isVisible() is False,
|
||||
"the pipeline labels survived 诊断信息 being turned off",
|
||||
)
|
||||
|
||||
# The overview strip is the timeline control: dragging it pans the window.
|
||||
stuck = _StuckWorker()
|
||||
|
||||
@@ -182,6 +182,10 @@ def _flow_scene(window, app, *, frames: int = 240) -> None:
|
||||
flow = np.stack(
|
||||
[dx / radius * profile * 7.0, dy / radius * profile * 7.0], axis=-1
|
||||
).astype(np.float32)
|
||||
# The other quantity the pane can colour: the chain's deformation response (a
|
||||
# relative, unitless "how deep"). Both are carried so the 颜色来源 switch has
|
||||
# something to switch between, and each is stated as this frame's own field.
|
||||
depth = (profile * 0.9).astype(np.float32)
|
||||
# A recognisable camera image as well, so the 2D panel is not black next to it.
|
||||
frame = np.zeros((height, width, 3), np.uint8)
|
||||
frame[..., 1] = (40.0 + profile * 150.0).astype(np.uint8)
|
||||
@@ -201,6 +205,7 @@ def _flow_scene(window, app, *, frames: int = 240) -> None:
|
||||
raw_frame=frame,
|
||||
processed_frame=frame,
|
||||
flow=flow,
|
||||
depth=depth,
|
||||
timestamp=1000.0 + index / 30.0,
|
||||
mono_t=float(index) / 30.0,
|
||||
fps=30.0,
|
||||
@@ -221,8 +226,8 @@ def _flow_scene(window, app, *, frames: int = 240) -> None:
|
||||
calibrated_axes=("fn", "fx", "fy"),
|
||||
flow_valid=True,
|
||||
flow_frame_id=index,
|
||||
depth_valid=None,
|
||||
depth_frame_id=None,
|
||||
depth_valid=True,
|
||||
depth_frame_id=index,
|
||||
forces_valid=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -638,13 +638,21 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
|
||||
|
||||
1. **阶段 8 第二批(画布布局)——部分已做(2026-09-15)**:
|
||||
*已做*:三维面板本身只留画面 + 一行紧凑图例(色条 + 来源/状态)+ 两个图标(复位视角、齿轮),
|
||||
全部**配置**(总开关、观察方向、显示外壳、放大形变与增益、色标模式与范围、显示帧率、显示统计)
|
||||
搬进 `视图 ▸ 三维显示设置…` 和面板齿轮打开的非模态对话框(`scene3d/settings_dialog.py`,
|
||||
它承载的还是面板自己那套控件,所以设置通路没有分叉);live/replay 预设把三维设为**最宽**面板
|
||||
(620 : 300 : 520),三维最小宽度 260 → 200(没有表单要放,画面才是重点)。
|
||||
全部**配置**(总开关、观察方向、显示外壳、放大形变与增益、颜色来源、色标模式与范围、显示帧率、
|
||||
显示统计)搬进 `视图 ▸ 三维显示设置…` 和面板齿轮打开的非模态对话框
|
||||
(`scene3d/settings_dialog.py`,它承载的还是面板自己那套控件,所以设置通路没有分叉);
|
||||
live/replay 预设把三维设为**最宽**面板(620 : 300 : 520),三维最小宽度 260 → 200;
|
||||
**颜色来源**可选「深度响应(默认)/ 光流幅值」:两者都是近似量、都不是毫米,选中的那个画不出来时
|
||||
自动改用另一个,并在设置对话框里写明"这一帧画的是哪一个";图例永远标注实际画出来的量
|
||||
(`scene3d/panel.py` 的 `field_note`、`adapters.FingerFrameAdapter.last_substitution`);
|
||||
面板脚下的内部解释(2D 的"箭头按位移放大 / 原始图像 → 算法输出 / 全部帧"、3D 的 info 级诊断)
|
||||
改由 `视图 ▸ 诊断信息` 一个开关控制,**默认关**:默认每个面板只剩"画面 + 读数 + 真话声明",
|
||||
异常(过期、无可用场、未与设备核对)照旧显示。
|
||||
*仍未做*:2D/3D 页签容器与"并排对照"、窄窗口回退策略、曲线"可见区间自动/固定量程"开关、
|
||||
调试统计移入独立诊断抽屉(统计现在在设置对话框底部);阶段 9 的真机/板卡门禁同前。
|
||||
为什么仍留后:页签容器要动 splitter 的槽位与 `ui_check` 的多处布局断言,需要与用户一起看截图定稿。
|
||||
*真机待验证*:手指路径上 `packet.depth` 的栅格是否就是场景的 300×280(若不是,面板会写明并回退到
|
||||
光流;这条只有接上设备才能确认)。
|
||||
2. **阶段 9 门禁运行**:GUI 模式 **2 小时**长时(`soak_scene.py --minutes 120 --fps 30`)——本阶段已跑完
|
||||
10 分钟的一份(见验收表格:增长 4.83 MB、尾段 0.371 MB/min,外推 2 小时约 45 MB,低于 64 MB 上限,但
|
||||
**外推不是门禁**,2 小时整仍需实跑);以及 Jetson Orin Nano / QCS6490 上的 `probe_gl.py --json`、
|
||||
|
||||
@@ -66,6 +66,10 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
|
||||
#: show the geometry, but it must not claim the measurement was checked against it.
|
||||
"geometry_unverified": "info",
|
||||
"stale_frame": "info",
|
||||
#: The operator asked for one quantity and the frame could only be coloured with the
|
||||
#: other one. Informational: the picture is still a measurement and names itself; what
|
||||
#: it is not is the field the setting says.
|
||||
"field_substituted": "info",
|
||||
"gl_unsupported": "error",
|
||||
"shader_compile_failed": "error",
|
||||
"gpu_budget_exceeded": "error",
|
||||
@@ -537,6 +541,13 @@ class DisplaySettings:
|
||||
color_scale: Optional[ColorScale] = None
|
||||
housing_visible: bool = True
|
||||
max_fps: int = 30
|
||||
#: Which scalar field colours the surface: the chain's deformation response
|
||||
#: (``"depth"``) or the tracker's optical-flow magnitude (``"flow"``). This is a
|
||||
#: *display* choice between two quantities a frame may carry, never a relabelling:
|
||||
#: whichever is drawn is reported by that frame's own descriptor, and a field the
|
||||
#: producer did not claim for this frame is refused before either is coloured (see
|
||||
#: ``orisys_studio.scene3d.adapters``).
|
||||
field: str = "depth"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
Reference in New Issue
Block a user