diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 60bfb34..fcab083 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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) diff --git a/apps/studio/src/orisys_studio/scene3d/coordinator.py b/apps/studio/src/orisys_studio/scene3d/coordinator.py index 7efdb11..3e2c697 100644 --- a/apps/studio/src/orisys_studio/scene3d/coordinator.py +++ b/apps/studio/src/orisys_studio/scene3d/coordinator.py @@ -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. diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index ed4bfae..a539f62 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -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. diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index d333aea..472f312 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -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. diff --git a/apps/studio/src/orisys_studio/scene3d/widget.py b/apps/studio/src/orisys_studio/scene3d/widget.py index 1ed7a30..4397f53 100644 --- a/apps/studio/src/orisys_studio/scene3d/widget.py +++ b/apps/studio/src/orisys_studio/scene3d/widget.py @@ -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 diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index 777c821..e2679a1 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -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 diff --git a/apps/studio/tests/test_shared_depth_scale.py b/apps/studio/tests/test_shared_depth_scale.py new file mode 100644 index 0000000..e9b2152 --- /dev/null +++ b/apps/studio/tests/test_shared_depth_scale.py @@ -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) diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index e8951e8..15dbef9 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -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)