统一 2D/3D 深度色条为同一量程。

将 2D 闩锁的深度色彩区间推给 3D 自动着色,固定量程仍跟旋钮。用 test_shared_depth_scale 与深度闩锁冒烟用例验证。
This commit is contained in:
ykcheungab
2026-10-06 21:31:53 +08:00
parent 829d2c1b53
commit 425f0dc9e2
8 changed files with 180 additions and 6 deletions
+15 -2
View File
@@ -2331,6 +2331,7 @@ class StudioMainWindow(QMainWindow):
self._active_calibration_id = ""
self._depth_range_value = None
self._last_depth_frame = None
self._sync_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).
@@ -2652,7 +2653,8 @@ class StudioMainWindow(QMainWindow):
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.
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
@@ -2668,8 +2670,15 @@ class StudioMainWindow(QMainWindow):
if hi <= lo:
hi = lo + 1.0
self._depth_range_value = (lo, hi)
self._sync_shared_depth_scale(self._depth_range_value)
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)."""
panel = getattr(self, "mesh3d_panel", None)
if panel is not None and hasattr(panel, "set_shared_depth_scale"):
panel.set_shared_depth_scale(span)
def _depth_scale_text(self, frame: ImageFrame | None = None) -> str:
"""The colour scale, plus — when the frame's depth is exactly zero — why it is flat.
@@ -2795,8 +2804,12 @@ 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)
if mode == DEPTH:
# 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:
self._last_depth_frame = frame.depth
self._depth_range()
if mode == DEPTH:
self.arrow_panel.set_depth_scale(self._depth_scale_text(frame))
else:
self.arrow_panel.set_depth_scale(None)
@@ -506,6 +506,12 @@ 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.
@@ -359,6 +359,33 @@ def _scale_bounds(scale, automatic: tuple[float, float] | None) -> tuple[float,
return lower, upper
def _field_scalar_range(
field: Any,
*,
previous: tuple[float, float] | None,
shared_depth_scale: tuple[float, float] | None,
) -> 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.
"""
descriptor = getattr(field, "descriptor", None)
quantity = str(getattr(descriptor, "quantity", "") or "")
if quantity == "depth_response" and shared_depth_scale is not None:
lo = float(shared_depth_scale[0])
hi = float(shared_depth_scale[1])
if hi > lo and not (math.isnan(lo) or math.isnan(hi)):
return lo, hi
measured = _valid_scalar_range(
field.scalar,
field.valid,
signed=bool(getattr(descriptor, "signed", False)),
)
return _blend_scale_range(previous, measured)
def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
"""A 256x1 RGBA8 lookup table, built from the **one** ramp both views share.
@@ -667,6 +694,9 @@ class GLRenderer:
#: Range of the valid samples of the field currently uploaded, for the
#: automatic colour scale. ``None`` until a frame carries valid data.
self._scalar_range: tuple[float, float] | None = None
#: The 2D canvas's latched depth ``(lo, hi)``, when the window has one.
#: Depth automatic colouring uses this so both legends name one interval.
self._shared_depth_scale: tuple[float, float] | None = None
#: The colour bounds of the last draw and how they were chosen.
self._last_scale: tuple[float, float] | None = None
self._last_scale_mode = ""
@@ -789,10 +819,11 @@ class GLRenderer:
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
self._upload_field_textures(field)
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
measured = _valid_scalar_range(
field.scalar, field.valid, signed=bool(field.descriptor.signed)
self._scalar_range = _field_scalar_range(
field,
previous=self._scalar_range,
shared_depth_scale=self._shared_depth_scale,
)
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
self._upload_overlays(frame)
def _upload_field_textures(self, field: Any) -> None:
@@ -987,7 +1018,16 @@ 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
lower, upper = _scale_bounds(scale, self._scalar_range)
# 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.
automatic = self._scalar_range
descriptor = getattr(scale, "descriptor", None) if scale is not None else None
if (
self._shared_depth_scale is not None
and str(getattr(descriptor, "quantity", "") or "") == "depth_response"
):
automatic = self._shared_depth_scale
lower, upper = _scale_bounds(scale, automatic)
# What the shader was actually given, for the legend: in automatic mode the
# operator's spin boxes are disabled and hold values nobody is using, so the
# panel has to be able to ask what the picture means.
@@ -1061,6 +1101,17 @@ class GLRenderer:
"""The colour bounds the last draw actually used (``None`` before any draw)."""
return self._last_scale
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
"""Adopt the 2D canvas's latched depth colour span, or clear it."""
if span is None:
self._shared_depth_scale = None
return
lo, hi = float(span[0]), float(span[1])
if hi <= lo or math.isnan(lo) or math.isnan(hi):
self._shared_depth_scale = None
return
self._shared_depth_scale = (lo, hi)
def set_ramp(self, ramp: str) -> None:
"""Adopt the theme's ramp. Rebuilds the LUT at the next draw, once.
@@ -107,6 +107,8 @@ 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,
@@ -148,6 +150,8 @@ 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.
@@ -313,6 +317,19 @@ class Scene3DView(QWidget):
if self._adapter is not None and hasattr(self._adapter, "domain_latch"):
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)
def set_theme(self, theme: str) -> None:
"""Adopt the application theme's colour ramp.
@@ -94,6 +94,13 @@ class Sensor3DWidget(QOpenGLWidget):
self._rejected_frames = 0
# ------------------------------------------------------------- CPU-side API
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
"""Hand the 2D canvas's latched depth colour span to the GL renderer."""
setter = getattr(self._renderer, "set_shared_depth_scale", None)
if callable(setter):
setter(span)
self.update()
def set_scene(self, assets: SceneAssets) -> None:
"""Retain the asset and schedule its upload for the next current context."""
self._assets = assets
@@ -114,6 +114,8 @@ 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``).
self._shared_depth_scale: tuple[float, float] | None = None
# Reserve this top-level window's native handle *now*, before the window is
# shown and while no OpenGL widget exists yet.
@@ -227,6 +229,12 @@ class Mesh3dPanel(QFrame):
if self._view is not None and hasattr(self._view, "set_theme"):
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]))
if self._view is not None and hasattr(self._view, "set_shared_depth_scale"):
self._view.set_shared_depth_scale(self._shared_depth_scale)
def set_domain_latch(self, latch) -> None:
"""Share the 2D canvas's island latch with the 3D pane (see ``Scene3DView``).
@@ -321,6 +329,8 @@ 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"):
view.set_shared_depth_scale(self._shared_depth_scale)
if self._pending_settings is not None:
view.restore_settings(self._pending_settings)
# The kind was decided before the view existed (the window sets it when the
@@ -0,0 +1,62 @@
"""2D and 3D depth colour bars name one interval."""
from __future__ import annotations
from types import SimpleNamespace
import numpy as np
from orisys_app_shared.scene3d.contracts import FieldDescriptor
from orisys_studio.scene3d.gl_backend import _field_scalar_range
def _field(quantity: str, values, *, signed: bool = False) -> SimpleNamespace:
arr = np.asarray(values, dtype=np.float32)
return SimpleNamespace(
descriptor=FieldDescriptor(quantity, "1", "approximate", signed),
scalar=arr,
valid=np.isfinite(arr),
)
def test_depth_automatic_scale_uses_the_shared_2d_span() -> None:
field = _field("depth_response", np.linspace(0.0, 9.0, 16).reshape(4, 4))
assert _field_scalar_range(
field,
previous=(0.0, 2.0),
shared_depth_scale=(0.0, 1.0),
) == (0.0, 1.0)
def test_flow_ignores_the_shared_depth_span() -> None:
field = _field("flow_magnitude", np.linspace(0.0, 9.0, 16).reshape(4, 4))
span = _field_scalar_range(
field,
previous=None,
shared_depth_scale=(0.0, 1.0),
)
assert span is not None
assert span != (0.0, 1.0)
assert span[1] > span[0]
def test_depth_without_a_shared_span_still_measures_the_field() -> None:
field = _field("depth_response", np.linspace(0.0, 9.0, 16).reshape(4, 4))
span = _field_scalar_range(
field,
previous=None,
shared_depth_scale=None,
)
assert span is not None
assert span[0] == 0.0
assert span[1] > 1.0
def test_a_degenerate_shared_span_falls_back_to_the_measurement() -> None:
field = _field("depth_response", np.linspace(0.0, 9.0, 16).reshape(4, 4))
span = _field_scalar_range(
field,
previous=None,
shared_depth_scale=(1.0, 1.0),
)
assert span is not None
assert span != (1.0, 1.0)
+8
View File
@@ -920,6 +920,11 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
window._on_run_tick()
latched = window.arrow_panel.depth_scale_label.text()
assert latched
shared = window.mesh3d_panel._shared_depth_scale
assert shared == window._depth_range_value
assert shared is not None
assert shared[0] == pytest.approx(0.0)
assert shared[1] == pytest.approx(1.0)
window._last_rendered_view_mode = None
stuck.emit(ui_check._fake_packet(2, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8)))
@@ -927,6 +932,9 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
assert window.arrow_panel.depth_scale_label.text() == latched, (
"the depth colour scale re-fitted on every frame"
)
assert window.mesh3d_panel._shared_depth_scale == shared, (
"the 3D depth colour span must stay locked to the 2D latch"
)
_finish(window, stuck)