为二维与三维深度色标增加每帧自适应选项。

默认仍锁定本会话首帧,勾选后两视图按当前帧有效深度共用同一范围。已用深度色标与显示设置测试验证。
This commit is contained in:
ykcheungab
2026-10-07 09:34:57 +08:00
parent 425f0dc9e2
commit 97e80d3d38
14 changed files with 254 additions and 69 deletions
+102 -24
View File
@@ -2001,6 +2001,10 @@ class StudioMainWindow(QMainWindow):
self.mesh3d_panel.message.text().strip()
or t("panels.mesh3d_no_scene")
)
return
# The depth colour-bar switch lives in that dialog and also recolours the 2D
# canvas, so the live path has to hear the change as soon as the dialog exists.
self._connect_analysis_display(self.mesh3d_panel)
def _on_sensor_type_changed(self, sensor_type: str) -> None:
self.prefs["sensor_type"] = str(sensor_type)
@@ -2331,7 +2335,7 @@ class StudioMainWindow(QMainWindow):
self._active_calibration_id = ""
self._depth_range_value = None
self._last_depth_frame = None
self._sync_shared_depth_scale(None)
self._publish_shared_depth_scale(None)
# The new session has not established a domain boundary yet; the previous
# session's island must not survive into it (a different sensor is a different
# island, and the latch compares against what it holds).
@@ -2647,34 +2651,97 @@ class StudioMainWindow(QMainWindow):
presenter.tick_3d(display_paused=self._display_paused)
# ------------------------------------------------------------ depth scale
def _depth_range(self) -> tuple[float, float] | None:
"""The colour scale, latched to the first frame that carries depth.
def _depth_display_settings(self):
"""The display settings that decide the shared depth colour span."""
panel = getattr(self, "mesh3d_panel", None)
if panel is None or not hasattr(panel, "display_settings"):
return None
try:
return panel.display_settings()
except Exception: # noqa: BLE001 - a missing pane must not blank the 2D view
return None
Re-fitting the range on every frame (the old behaviour) made colours
drift, so two frames could look identical while holding different
depths. Latching keeps frames comparable for the whole session; the
numeric range is shown in the panel header. The same span is handed to
the 3D pane so both legends name one interval.
"""
if self._depth_range_value is not None:
return self._depth_range_value
depth = self._last_depth_frame
def _fixed_depth_span(self) -> tuple[float, float] | None:
"""Typed colour bounds, when the operator chose a fixed scale."""
settings = self._depth_display_settings()
scale = getattr(settings, "color_scale", None) if settings is not None else None
if scale is None or str(getattr(scale, "mode", "")) != "fixed":
return None
lo = float(scale.lower)
hi = float(scale.upper)
if hi <= lo:
hi = lo + 1.0
return lo, hi
def _depth_scale_adaptive(self) -> bool:
settings = self._depth_display_settings()
return bool(getattr(settings, "depth_scale_adaptive", False))
def _depth_scale_mode(self) -> str:
"""``fixed``, ``adaptive`` or ``latched`` — the rule both views follow."""
if self._fixed_depth_span() is not None:
return "fixed"
if self._depth_scale_adaptive():
return "adaptive"
return "latched"
def _frame_depth_span(self, depth) -> tuple[float, float] | None:
"""Finite minimum and maximum of one depth map."""
if depth is None:
return None
arr = np.asarray(depth, dtype=np.float64)
finite = np.isfinite(arr)
if arr.size == 0 or not np.any(finite):
return None
lo = float(np.nanmin(arr[finite]))
hi = float(np.nanmax(arr[finite]))
lo = float(np.min(arr[finite]))
hi = float(np.max(arr[finite]))
if hi <= lo:
hi = lo + 1.0
self._depth_range_value = (lo, hi)
self._sync_shared_depth_scale(self._depth_range_value)
return lo, hi
def _depth_range(self) -> tuple[float, float] | None:
"""The session latch: the first frame that carries finite depth.
Re-fitting the range on every frame made colours drift, so two frames
could look identical while holding different depths. Latching keeps
frames comparable for the whole session. The per-frame colour bar is
the adaptive setting; this method is only the latch.
"""
if self._depth_range_value is not None:
return self._depth_range_value
span = self._frame_depth_span(self._last_depth_frame)
if span is None:
return None
self._depth_range_value = span
return self._depth_range_value
def _sync_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
"""Push the latched 2D depth colour span to the 3D surface (or clear it)."""
def _active_depth_span(self) -> tuple[float, float] | None:
"""The span both views colour with for the depth now on screen.
Leaving the adaptive or fixed rule re-latches on this frame, so the
next comparison does not keep a range from the other mode.
"""
mode = self._depth_scale_mode()
previous = getattr(self, "_depth_scale_mode_applied", "latched")
if mode == "latched" and previous != "latched":
self._depth_range_value = None
self._depth_scale_mode_applied = mode
if mode == "fixed":
return self._fixed_depth_span()
if mode == "adaptive":
return self._frame_depth_span(self._last_depth_frame)
return self._depth_range()
def _depth_scale_tip_key(self) -> str:
mode = getattr(self, "_depth_scale_mode_applied", "latched")
if mode == "fixed":
return "panels.depth_scale_tip_fixed"
if mode == "adaptive":
return "panels.depth_scale_tip_adaptive"
return "panels.depth_scale_tip"
def _publish_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
"""Hand the depth colour span to the 3D pane, when one exists."""
panel = getattr(self, "mesh3d_panel", None)
if panel is not None and hasattr(panel, "set_shared_depth_scale"):
panel.set_shared_depth_scale(span)
@@ -2688,7 +2755,7 @@ class StudioMainWindow(QMainWindow):
instead of leaving the operator to wonder. It is not the same statement as
``panels.depth_unavailable``, which is for a frame that carries no depth at all.
"""
span = self._depth_range()
span = self._active_depth_span()
text = "" if span is None else t(
"panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}"
)
@@ -2804,13 +2871,17 @@ class StudioMainWindow(QMainWindow):
boundary = self._latched_domain_mask(frame)
self.arrow_panel.set_arrow_scale(arrow_display_scale(draw_style))
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
# Latch (and sync to 3D) from any depth-carrying frame, not only when the
# 2D mode is Depth — the surface may show depth while the canvas shows arrows.
if frame.depth is not None:
# Remember every depth-bearing frame, whichever 2D mode is showing,
# so a later depth view and the 3D surface name the same interval.
self._last_depth_frame = frame.depth
self._depth_range()
span = self._active_depth_span()
self._publish_shared_depth_scale(span)
if mode == DEPTH:
self.arrow_panel.set_depth_scale(self._depth_scale_text(frame))
self.arrow_panel.set_depth_scale(
self._depth_scale_text(frame),
tip_key=self._depth_scale_tip_key(),
)
else:
self.arrow_panel.set_depth_scale(None)
rendered = render_image(
@@ -2819,7 +2890,7 @@ class StudioMainWindow(QMainWindow):
mode=mode,
theme=self._theme,
draw_style=draw_style,
depth_range=self._depth_range(),
depth_range=span,
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
domain_mask=boundary,
# A frame read from a stored analysis result says which policy its *recovered*
@@ -3060,9 +3131,16 @@ class StudioMainWindow(QMainWindow):
if panel is None or not hasattr(panel, "settingsChanged"):
return False
panel.settingsChanged.connect(self.replay_ctl.on_display_settings)
panel.settingsChanged.connect(self._on_depth_scale_display)
self._analysis_display_connected = True
return True
def _on_depth_scale_display(self, _settings) -> None:
"""A colour-scale change has to repaint the frame already on screen."""
presenter = getattr(self, "acquire_presenter", None)
if presenter is not None and hasattr(presenter, "drop_render_marker"):
presenter.drop_render_marker()
def _await_analysis_display(self) -> None:
"""Wait, briefly and off the critical path, for the 3D view to exist.
@@ -506,12 +506,6 @@ class ThreeDCoordinator:
# held field frame so the operator sees the change without waiting.
self._settings_dirty = True
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
"""Adopt the 2D canvas's latched depth colour span for automatic colouring."""
setter = getattr(self._widget, "set_shared_depth_scale", None)
if callable(setter):
setter(span)
def set_ramp(self, ramp: str) -> None:
"""Hand the theme's colour ramp to the composer.
@@ -367,9 +367,10 @@ def _field_scalar_range(
) -> tuple[float, float] | None:
"""The automatic colour span for the uploaded field.
Depth shares the 2D canvas's latched ``(lo, hi)`` when the window has one, so the
two legends name the same interval. Flow (and depth before that latch exists) keep
the measured P5/P95 blend.
Depth shares the span the window published for this frame — the session latch, or
the current frame's finite minimum and maximum when the adaptive colour bar is on —
so the two legends name the same interval. Flow (and depth before that span exists)
keep the measured P5/P95 blend.
"""
descriptor = getattr(field, "descriptor", None)
quantity = str(getattr(descriptor, "quantity", "") or "")
@@ -1018,8 +1019,8 @@ class GLRenderer:
view = np.asarray(camera.view, dtype=np.float32).reshape(4, 4)
projection = np.asarray(camera.projection, dtype=np.float32).reshape(4, 4)
scale = settings.color_scale
# Prefer the 2D latched span at draw time, not only at upload: the latch can
# arrive after this field was uploaded, and a repaint must still equalise.
# Prefer the shared depth span at draw time, not only at upload: the window can
# publish a new latch or a new per-frame span after this field was uploaded.
automatic = self._scalar_range
descriptor = getattr(scale, "descriptor", None) if scale is not None else None
if (
@@ -257,6 +257,13 @@ class ThreeDPanel(QFrame):
self.scale_combo.currentIndexChanged.connect(self._on_scale_mode_changed)
form.addRow(self.scale_label, self.scale_combo)
# Depth only, and shared with the 2D canvas: off keeps the session latch,
# on restores the per-frame colour bar. Fixed bounds ignore this switch.
self.depth_adaptive_check = QCheckBox()
self.depth_adaptive_check.setChecked(False)
self.depth_adaptive_check.toggled.connect(self._emit_settings)
form.addRow(self.depth_adaptive_check)
# The two bounds get their own row rather than sharing the mode's: three
# controls side by side made this row the widest thing in the pane.
self.range_label = QLabel()
@@ -389,6 +396,7 @@ class ThreeDPanel(QFrame):
self.scale_combo.setEnabled(False)
self.lower_spin.setEnabled(False)
self.upper_spin.setEnabled(False)
self._sync_depth_adaptive_check()
self.scale_bar.setVisible(False)
self.scale_lower_label.setVisible(False)
self.scale_upper_label.setVisible(False)
@@ -397,6 +405,7 @@ class ThreeDPanel(QFrame):
fixed = self.scale_combo.currentData() == "fixed"
self.lower_spin.setEnabled(fixed)
self.upper_spin.setEnabled(fixed)
self._sync_depth_adaptive_check()
self.scale_bar.set_lut(
"diverging" if descriptor.signed and not fixed else "sequential"
)
@@ -685,6 +694,7 @@ class ThreeDPanel(QFrame):
self.field_combo.setCurrentIndex(index)
self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled))
self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled))
self.depth_adaptive_check.setChecked(bool(settings.depth_scale_adaptive))
scale = settings.color_scale
if scale is not None:
index = self.scale_combo.findData(str(scale.mode))
@@ -730,6 +740,7 @@ class ThreeDPanel(QFrame):
field=self.field(),
flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()),
contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()),
depth_scale_adaptive=bool(self.depth_adaptive_check.isChecked()),
)
# ------------------------------------------------------------------ signals
@@ -751,6 +762,21 @@ class ThreeDPanel(QFrame):
self.gain_spin.setEnabled(bool(enabled) and available and self.enable_check.isChecked())
self._emit_settings()
def _sync_depth_adaptive_check(self) -> None:
"""The per-frame switch applies only while the colour scale is automatic.
Fixed bounds are typed by the operator and already shared; leaving the box
enabled there would look as if both rules were in force.
"""
automatic = str(self.scale_combo.currentData() or "fixed") != "fixed"
measuring = (
self._descriptor is not None
and self._descriptor.quantity != "unavailable"
)
self.depth_adaptive_check.setEnabled(
automatic and measuring and self.enable_check.isChecked()
)
def _on_scale_mode_changed(self, index: int) -> None:
"""Only a fixed scale has bounds to type in; automatic ones come from data."""
self.set_descriptor(self._descriptor)
@@ -789,6 +815,8 @@ class ThreeDPanel(QFrame):
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
self.scale_combo.setItemText(0, t("scene3d.scale_fixed"))
self.scale_combo.setItemText(1, t("scene3d.scale_auto"))
self.depth_adaptive_check.setText(t("scene3d.depth_adaptive"))
self.depth_adaptive_check.setToolTip(t("scene3d.depth_adaptive_tip"))
self.fps_label.setText(t("scene3d.max_fps"))
self.fps_label.setToolTip(t("scene3d.max_fps_tip"))
self.reset_button.setText(t("scene3d.reset_view"))
@@ -88,6 +88,7 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
"flow_off_px": float(settings.flow_off_px),
"flow_grid_spacing": int(settings.flow_grid_spacing),
"max_arrows": int(settings.max_arrows),
"depth_scale_adaptive": bool(settings.depth_scale_adaptive),
}
if scale is not None:
block["color_scale"] = {
@@ -169,4 +170,5 @@ def settings_from_prefs(
flow_off_px=flow_off,
flow_grid_spacing=spacing,
max_arrows=max_arrows,
depth_scale_adaptive=_flag(raw.get("depth_scale_adaptive"), False),
)
+3 -15
View File
@@ -107,8 +107,6 @@ class Scene3DView(QWidget):
#: they cannot drift apart. ``None`` means "no session": the adapter then uses
#: each frame's own mask, which is what the tests and the PoC want.
self._domain_latch: Any = None
#: The 2D canvas's latched depth colour span, held until the coordinator exists.
self._shared_depth_scale: tuple[float, float] | None = None
self._expected: ExpectedGeometry | None = None
self._snapshot: Callable[[Any, FrameIdentity], Any] | None = None
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused,
@@ -150,8 +148,6 @@ class Scene3DView(QWidget):
self._coordinator = ThreeDCoordinator(
self._widget, self._composer, self._executor, max_fps=30
)
if self._shared_depth_scale is not None:
self._coordinator.set_shared_depth_scale(self._shared_depth_scale)
self._panel = ThreeDPanel(self._coordinator, self._widget)
# The picture must not be squeezed out of existence by the controls above it:
# a GL widget with zero height draws nothing and looks like a broken view.
@@ -318,17 +314,9 @@ class Scene3DView(QWidget):
self._adapter.domain_latch = latch
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
"""Share the 2D canvas's latched depth colour span with this pane.
Automatic depth colouring on the surface uses this interval so the 3D legend
names the same ``(lo, hi)`` as the 2D depth scale. Fixed mode still follows the
operator's spin boxes.
"""
self._shared_depth_scale = None if span is None else (float(span[0]), float(span[1]))
if self._coordinator is not None and hasattr(
self._coordinator, "set_shared_depth_scale"
):
self._coordinator.set_shared_depth_scale(self._shared_depth_scale)
"""Colour depth with the 2D canvas's latched ``(lo, hi)``."""
if self._widget is not None:
self._widget.set_shared_depth_scale(span)
def set_theme(self, theme: str) -> None:
"""Adopt the application theme's colour ramp.
@@ -383,11 +383,17 @@ class ArrowPanel(QFrame):
self._theme = str(theme)
self.colorbar.set_theme(self._theme)
def set_depth_scale(self, text: str | None) -> None:
"""Show the fixed depth colour-scale range (``None`` hides it)."""
def set_depth_scale(
self, text: str | None, *, tip_key: str = "panels.depth_scale_tip"
) -> None:
"""Show the depth colour-scale range (``None`` hides it).
``tip_key`` names which rule produced the numbers: the session latch, the
per-frame adaptive bar, or typed fixed bounds.
"""
self._depth_scale_text = str(text or "")
self.depth_scale_label.setText(self._depth_scale_text)
self.depth_scale_label.setToolTip(t("panels.depth_scale_tip"))
self.depth_scale_label.setToolTip(t(tip_key))
self._refresh_footer_visibility()
def _refresh_scale_label(self) -> None:
@@ -114,7 +114,7 @@ class Mesh3dPanel(QFrame):
#: The session's island latch, shared with the 2D canvas (see
#: ``set_domain_latch``). Held until the view exists.
self._domain_latch = None
#: The 2D canvas's latched depth colour span (see ``set_shared_depth_scale``).
#: The 2D canvas's latched depth colour span, held until the view exists.
self._shared_depth_scale: tuple[float, float] | None = None
# Reserve this top-level window's native handle *now*, before the window is
@@ -230,10 +230,25 @@ class Mesh3dPanel(QFrame):
self._view.set_theme(self._theme)
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
"""Share the 2D depth colour span with the 3D surface (and hold it until built)."""
self._shared_depth_scale = None if span is None else (float(span[0]), float(span[1]))
"""Share the 2D canvas's latched depth colour span with the 3D surface.
Held until the view exists, the same way the island latch is: the view is
built on first use, and a span decided before then still has to colour the
first depth frame.
"""
normalized: tuple[float, float] | None
if span is None:
normalized = None
else:
normalized = (float(span[0]), float(span[1]))
if normalized == self._shared_depth_scale:
return
self._shared_depth_scale = normalized
if self._view is not None and hasattr(self._view, "set_shared_depth_scale"):
self._view.set_shared_depth_scale(self._shared_depth_scale)
self._view.set_shared_depth_scale(normalized)
def shared_depth_scale(self) -> tuple[float, float] | None:
return self._shared_depth_scale
def set_domain_latch(self, latch) -> None:
"""Share the 2D canvas's island latch with the 3D pane (see ``Scene3DView``).
@@ -329,7 +344,7 @@ class Mesh3dPanel(QFrame):
view.set_theme(self._theme)
if self._domain_latch is not None and hasattr(view, "set_domain_latch"):
view.set_domain_latch(self._domain_latch)
if hasattr(view, "set_shared_depth_scale"):
if self._shared_depth_scale is not None and hasattr(view, "set_shared_depth_scale"):
view.set_shared_depth_scale(self._shared_depth_scale)
if self._pending_settings is not None:
view.restore_settings(self._pending_settings)