From 567f521e947191548b9aa16c5fe3e50a03da663a Mon Sep 17 00:00:00 2001 From: Kyle Harrington Date: Thu, 20 Aug 2026 17:52:25 -0700 Subject: [PATCH 1/6] feat: expose canvas integration hooks --- src/ndv/controllers/_array_viewer.py | 17 ++++++++++++++++- tests/test_controller.py | 5 +++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/ndv/controllers/_array_viewer.py b/src/ndv/controllers/_array_viewer.py index 1910ec01..b7963e89 100644 --- a/src/ndv/controllers/_array_viewer.py +++ b/src/ndv/controllers/_array_viewer.py @@ -28,6 +28,7 @@ from ndv.views import _app if TYPE_CHECKING: + from collections.abc import Callable from typing import Any import cmap as cmap_mod @@ -37,7 +38,7 @@ from ndv._types import AxisKey, ChannelKey, KeyPressEvent, MouseMoveEvent from ndv.models._array_display_model import ArrayDisplayModelKwargs from ndv.models._viewer_model import ArrayViewerModelKwargs - from ndv.views.bases import HistogramCanvas, SharedHistogramCanvas + from ndv.views.bases import ArrayCanvas, HistogramCanvas, SharedHistogramCanvas from ndv.views.bases._graphics._canvas_elements import RectangularROIHandle @@ -190,6 +191,20 @@ def data_wrapper(self) -> Any: """Return the data wrapper object being used to interface with the data.""" return self._data_wrapper + @property + def canvas(self) -> ArrayCanvas: + """Return the renderer-independent canvas used by this viewer. + + This is the narrow integration surface for progressive data providers: + images and volumes can be added through the canvas while ndv continues + to select the concrete VisPy or pygfx implementation. + """ + return self._canvas + + def dispatch(self, callback: Callable[[], None]) -> None: + """Schedule ``callback`` on the active GUI frontend's main thread.""" + _app.ndv_app().call_in_main_thread(callback) + @property def data(self) -> Any: """Return data being displayed (the actual data, not the wrapper).""" diff --git a/tests/test_controller.py b/tests/test_controller.py index cb52cdb5..2f326d6c 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -87,6 +87,11 @@ def _patch_views(f: Callable) -> Callable: def test_controller() -> None: SHAPE = (10, 4, 10, 10) ctrl = ArrayViewer() + assert ctrl.canvas is ctrl._canvas + callback = MagicMock() + with patch.object(_app, "ndv_app") as ndv_app: + ctrl.dispatch(callback) + ndv_app.return_value.call_in_main_thread.assert_called_once_with(callback) ctrl._async = False model = ctrl.display_model mock_view = ctrl._view From d50287593174b8a9ea8d8e6026c793d02b9bc122 Mon Sep 17 00:00:00 2001 From: Kyle Harrington Date: Thu, 20 Aug 2026 20:01:37 -0700 Subject: [PATCH 2/6] feat: expose camera state and data origins --- src/ndv/views/_pygfx/_array_canvas.py | 48 +++++++++++-- src/ndv/views/_vispy/_array_canvas.py | 71 ++++++++++++++++++-- src/ndv/views/bases/_graphics/_canvas.py | 20 +++++- tests/views/_vispy/test_volume_downsample.py | 28 ++++++++ 4 files changed, 153 insertions(+), 14 deletions(-) diff --git a/src/ndv/views/_pygfx/_array_canvas.py b/src/ndv/views/_pygfx/_array_canvas.py index 5aebafe3..59ba6c0e 100755 --- a/src/ndv/views/_pygfx/_array_canvas.py +++ b/src/ndv/views/_pygfx/_array_canvas.py @@ -441,6 +441,8 @@ def __init__(self, viewer_model: ArrayViewerModel) -> None: self._last_roi_created: ReferenceType[PyGFXRectangle] | None = None # Per-axis world-space scales (x, y, z) used for coordinate conversion self._world_scales: tuple[float, float, float] = (1.0, 1.0, 1.0) + self._world_origins: tuple[float, float, float] = (0.0, 0.0, 0.0) + self._last_camera_signature: bytes | None = None def frontend_widget(self) -> Any: return self._canvas @@ -543,7 +545,9 @@ def add_bounding_box(self) -> PyGFXRectangle: self._last_roi_created = ref(roi) return roi - def set_scales(self, scales: tuple[float, ...]) -> None: + def set_scales( + self, scales: tuple[float, ...], *, reset_range: bool = True + ) -> None: """Set per-visible-axis scale factors for rendering.""" if not scales: return @@ -556,7 +560,21 @@ def set_scales(self, scales: tuple[float, ...]) -> None: (sx, sy, sz) = gfx_scales[:3] self._world_scales = (sx, sy, sz) - has_visuals = False + self._apply_world_transform() + if reset_range: + self.set_range() + + def set_origins(self, origins: tuple[float, ...]) -> None: + """Set per-visible-axis world origins in data-axis order.""" + gfx_origins = list(reversed(origins)) + while len(gfx_origins) < 3: + gfx_origins.append(0.0) + self._world_origins = tuple(gfx_origins[:3]) + self._apply_world_transform() + + def _apply_world_transform(self) -> None: + sx, sy, sz = self._world_scales + ox, oy, oz = self._world_origins for handle in self._elements.values(): if not isinstance(handle, PyGFXImageHandle): continue @@ -573,9 +591,22 @@ def set_scales(self, scales: tuple[float, ...]) -> None: _sy *= rev[1] if len(rev) > 1 else 1 _sz *= rev[2] if len(rev) > 2 else 1 child.local.scale = (_sx, _sy, _sz) - has_visuals = True - if has_visuals: - self.set_range() + child.local.position = (ox, oy, oz) + + def camera_state(self) -> tuple[tuple[int, int], np.ndarray]: + """Return viewport and data-order world-to-clip matrix.""" + if self._camera is None: + raise RuntimeError("camera dimensionality has not been initialized") + width, height = self._canvas.get_logical_size() + scene_to_clip = np.asarray(self._camera.camera_matrix, dtype=np.float64) + ndim = self._ndim or 2 + permutation = np.zeros((4, 4), dtype=np.float64) + for axis in range(ndim): + permutation[ndim - axis - 1, axis] = 1.0 + for axis in range(ndim, 4): + permutation[axis, axis] = 1.0 + matrix = scene_to_clip @ permutation + return (int(width), int(height)), matrix def set_range( self, @@ -628,6 +659,13 @@ def refresh(self) -> None: def _animate(self) -> None: if self._camera is not None: self._renderer.render(self._scene, self._camera) + signature = np.asarray(self._camera.camera_matrix).tobytes() + if ( + self._last_camera_signature is not None + and signature != self._last_camera_signature + ): + self.cameraChanged.emit() + self._last_camera_signature = signature def _canvas_to_world_raw( self, pos_xy: tuple[float, float] diff --git a/src/ndv/views/_vispy/_array_canvas.py b/src/ndv/views/_vispy/_array_canvas.py index 74c27369..896f5743 100755 --- a/src/ndv/views/_vispy/_array_canvas.py +++ b/src/ndv/views/_vispy/_array_canvas.py @@ -34,13 +34,30 @@ ) if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence turn = np.sin(np.pi / 4) DEFAULT_QUATERNION = Quaternion(turn, turn, 0, 0) +class _CameraChangeMixin: + _ndv_changed: Callable[[], None] | None = None + + def view_changed(self) -> None: + super().view_changed() # type: ignore[misc] + if self._ndv_changed is not None: + self._ndv_changed() + + +class _NDVArcballCamera(_CameraChangeMixin, scene.ArcballCamera): + pass + + +class _NDVPanZoomCamera(_CameraChangeMixin, scene.PanZoomCamera): + pass + + class VispyImageHandle(ImageHandle): def __init__(self, visual: visuals.ImageVisual | visuals.VolumeVisual) -> None: self._visual = visual @@ -316,6 +333,8 @@ def __init__(self, viewer_model: ArrayViewerModel) -> None: central_wdg: scene.Widget = self._canvas.central_widget self._view: scene.ViewBox = central_wdg.add_view() self._ndim: Literal[2, 3] | None = None + self._world_scales = (1.0, 1.0, 1.0) + self._world_origins = (0.0, 0.0, 0.0) # Maps vispy visuals (scene children) → CanvasElement handles. # Entries are added by add_image/add_volume/add_bounding_box. @@ -345,11 +364,13 @@ def set_ndim(self, ndim: Literal[2, 3]) -> None: self._ndim = ndim if ndim == 3: - cam = scene.ArcballCamera(fov=0) + cam = _NDVArcballCamera(fov=0) # this sets the initial view similar to what the panzoom view would have. cam._quaternion = DEFAULT_QUATERNION else: - cam = scene.PanZoomCamera(aspect=1, flip=(0, 1)) + cam = _NDVPanZoomCamera(aspect=1, flip=(0, 1)) + + cam._ndv_changed = self.cameraChanged.emit # restore the previous state if it exists if state := self._last_state.get(ndim): @@ -421,7 +442,9 @@ def add_bounding_box(self) -> VispyRectangle: self._last_roi_created = ReferenceType(roi) return roi - def set_scales(self, scales: tuple[float, ...]) -> None: + def set_scales( + self, scales: tuple[float, ...], *, reset_range: bool = True + ) -> None: """Set per-visible-axis scale factors for rendering.""" if not scales: return @@ -431,7 +454,22 @@ def set_scales(self, scales: tuple[float, ...]) -> None: # pad to 3 components while len(vis_scales) < 3: vis_scales.append(1.0) - sx, sy, sz = vis_scales[0], vis_scales[1], vis_scales[2] + self._world_scales = tuple(vis_scales[:3]) + self._apply_world_transform() + if reset_range: + self.set_range() + + def set_origins(self, origins: tuple[float, ...]) -> None: + """Set per-visible-axis world origins in data-axis order.""" + vis_origins = list(reversed(origins)) + while len(vis_origins) < 3: + vis_origins.append(0.0) + self._world_origins = tuple(vis_origins[:3]) + self._apply_world_transform() + + def _apply_world_transform(self) -> None: + sx, sy, sz = self._world_scales + ox, oy, oz = self._world_origins for handle in self._elements.values(): if not isinstance(handle, VispyImageHandle): continue @@ -448,9 +486,28 @@ def set_scales(self, scales: tuple[float, ...]) -> None: _sy *= rev[1] if len(rev) > 1 else 1 _sz *= rev[2] if len(rev) > 2 else 1 child.transform = vispy.visuals.transforms.STTransform( - scale=(_sx, _sy, _sz) + scale=(_sx, _sy, _sz), translate=(ox, oy, oz) ) - self.set_range() + + def camera_state(self) -> tuple[tuple[int, int], np.ndarray]: + """Return viewport and data-order world-to-clip matrix.""" + width, height = (int(value) for value in self._canvas.size) + ndim = self._ndim or 2 + points = np.zeros((ndim + 1, 4), dtype=np.float64) + points[:, 3] = 1.0 + # VisPy scene order is XYZ; public data order is YX or ZYX. + for axis in range(ndim): + points[axis + 1, ndim - axis - 1] = 1.0 + mapped = np.asarray(self._view.scene.transform.map(points), dtype=np.float64) + mapped /= mapped[:, 3, np.newaxis] + clip = mapped.copy() + clip[:, 0] = 2.0 * mapped[:, 0] / width - 1.0 + clip[:, 1] = 1.0 - 2.0 * mapped[:, 1] / height + matrix = np.eye(4, dtype=np.float64) + matrix[:ndim, -1] = clip[0, :ndim] + for axis in range(ndim): + matrix[:ndim, axis] = clip[axis + 1, :ndim] - clip[0, :ndim] + return (width, height), matrix def set_range( self, diff --git a/src/ndv/views/bases/_graphics/_canvas.py b/src/ndv/views/bases/_graphics/_canvas.py index fe6c5619..89f2d43c 100644 --- a/src/ndv/views/bases/_graphics/_canvas.py +++ b/src/ndv/views/bases/_graphics/_canvas.py @@ -55,6 +55,8 @@ def elements_at(self, pos_xy: tuple[float, float]) -> list[CanvasElement]: ... class ArrayCanvas(GraphicsCanvas): """ABC for canvases that show array data.""" + cameraChanged = Signal() + @abstractmethod def __init__(self, viewer_model: ArrayViewerModel | None = ...) -> None: ... @abstractmethod @@ -67,8 +69,22 @@ def add_volume(self, data: np.ndarray | None = ...) -> ImageHandle: ... @abstractmethod def add_bounding_box(self) -> RectangularROIHandle: ... - def set_scales(self, scales: tuple[float, ...]) -> None: - """Set per-visible-axis scale factors for rendering.""" + def set_scales( + self, scales: tuple[float, ...], *, reset_range: bool = True + ) -> None: + """Set per-visible-axis scales, optionally fitting the camera.""" + + def set_origins(self, origins: tuple[float, ...]) -> None: + """Set per-visible-axis world origins in data-axis order.""" + + def camera_state(self) -> tuple[tuple[int, int], np.ndarray]: + """Return ``(viewport, world_to_clip)`` in visible data-axis order. + + The matrix uses column-vector convention and maps world coordinates + ordered slowest-to-fastest (for example ZYX) into normalized clip + coordinates. Implementations emit :attr:`cameraChanged` when it changes. + """ + raise NotImplementedError class HistogramCanvas(GraphicsCanvas, LUTView): diff --git a/tests/views/_vispy/test_volume_downsample.py b/tests/views/_vispy/test_volume_downsample.py index 19ea0ec4..f60bdb1e 100644 --- a/tests/views/_vispy/test_volume_downsample.py +++ b/tests/views/_vispy/test_volume_downsample.py @@ -102,6 +102,34 @@ def test_set_scales_compensates_for_volume_downsample() -> None: canvas.close() +@pytest.mark.usefixtures("any_app") +def test_world_origin_and_camera_state_are_public() -> None: + canvas = VispyArrayCanvas(ArrayViewerModel()) + changed = [] + canvas.cameraChanged.connect(lambda: changed.append(True)) + canvas.set_ndim(3) + handle = canvas.add_volume(np.zeros((10, 20, 30), dtype=np.float32)) + canvas.set_scales((2.0, 3.0, 4.0)) + canvas.set_origins((100.0, 200.0, 300.0)) + canvas.set_range() + + transform = handle._visual.transform + assert isinstance(transform, vispy.visuals.transforms.STTransform) + assert transform.scale[:3] == pytest.approx((4.0, 3.0, 2.0)) + assert transform.translate[:3] == pytest.approx((300.0, 200.0, 100.0)) + viewport, world_to_clip = canvas.camera_state() + assert viewport == (600, 600) + assert world_to_clip.shape == (4, 4) + assert np.isfinite(world_to_clip).all() + + before = world_to_clip.copy() + canvas._camera.scale_factor /= 2 + canvas._camera.view_changed() + assert changed + assert not np.allclose(before, canvas.camera_state()[1]) + canvas.close() + + @pytest.mark.usefixtures("any_app") def test_set_range_correct_bounds_after_downsample() -> None: """set_range should compute world bounds as if data were full-resolution.""" From 7c4915c2c082400561bd8cc0836317f1488b13a4 Mon Sep 17 00:00:00 2001 From: Kyle Harrington Date: Thu, 20 Aug 2026 21:09:02 -0700 Subject: [PATCH 3/6] feat: support independent image world transforms --- src/ndv/views/_pygfx/_array_canvas.py | 52 +++++++++++------- src/ndv/views/_vispy/_array_canvas.py | 54 ++++++++++++------- src/ndv/views/bases/_graphics/_canvas.py | 9 ++-- .../views/bases/_graphics/_canvas_elements.py | 8 +++ tests/views/_pygfx/test_volume_downsample.py | 17 ++++++ tests/views/_vispy/test_volume_downsample.py | 16 ++++++ 6 files changed, 113 insertions(+), 43 deletions(-) diff --git a/src/ndv/views/_pygfx/_array_canvas.py b/src/ndv/views/_pygfx/_array_canvas.py index 59ba6c0e..4f7b55cc 100755 --- a/src/ndv/views/_pygfx/_array_canvas.py +++ b/src/ndv/views/_pygfx/_array_canvas.py @@ -93,6 +93,26 @@ def set_data(self, data: np.ndarray) -> None: if not is_three_d: self._material.map = None if self._is_rgb() else self._cmap.to_pygfx() + def set_world_transform( + self, + scales: tuple[float, ...], + origins: tuple[float, ...], + ) -> None: + """Set this visual's scale and translation in data-axis order.""" + scene_scales = list(reversed(scales)) + scene_origins = list(reversed(origins)) + while len(scene_scales) < 3: + scene_scales.append(1.0) + scene_origins.append(0.0) + factors = list(reversed(self._downsample_factors)) + while len(factors) < 3: + factors.append(1) + self._image.local.scale = tuple( + scale * factor + for scale, factor in zip(scene_scales, factors, strict=True) + ) + self._image.local.position = tuple(scene_origins) + def visible(self) -> bool: return bool(self._image.visible) @@ -486,7 +506,9 @@ def set_ndim(self, ndim: Literal[2, 3]) -> None: if state := self._last_state.get(ndim): cam.set_state(state) - def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle: + def add_image( + self, data: np.ndarray | None = None, *, reset_range: bool = True + ) -> PyGFXImageHandle: """Add a new Image node to the scene.""" data, downsample_factors = _downcast_and_downsample(data, three_d=False) tex = pygfx.Texture(data, dim=2) @@ -498,7 +520,7 @@ def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle: if data is not None: self._current_shape, prev_shape = data.shape, self._current_shape - if not prev_shape: + if reset_range and not prev_shape: self.set_range() # FIXME: I suspect there are more performant ways to refresh the canvas @@ -508,7 +530,9 @@ def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle: self._elements[image] = handle return handle - def add_volume(self, data: np.ndarray | None = None) -> PyGFXImageHandle: + def add_volume( + self, data: np.ndarray | None = None, *, reset_range: bool = True + ) -> PyGFXImageHandle: data, downsample_factors = _downcast_and_downsample(data, three_d=True) tex = pygfx.Texture(data, dim=3) vol = pygfx.Volume( @@ -522,7 +546,7 @@ def add_volume(self, data: np.ndarray | None = None) -> PyGFXImageHandle: if data is not None: vol.local_position = [-0.5 * i for i in data.shape[::-1]] self._current_shape, prev_shape = data.shape, self._current_shape - if len(prev_shape) != 3: + if reset_range and len(prev_shape) != 3: self.set_range() # FIXME: I suspect there are more performant ways to refresh the canvas @@ -573,25 +597,13 @@ def set_origins(self, origins: tuple[float, ...]) -> None: self._apply_world_transform() def _apply_world_transform(self) -> None: - sx, sy, sz = self._world_scales - ox, oy, oz = self._world_origins for handle in self._elements.values(): if not isinstance(handle, PyGFXImageHandle): continue - child = handle._image - if not isinstance(child, (pygfx.Image, pygfx.Volume)): - continue - _sx, _sy, _sz = sx, sy, sz - # compensate for downsampling so coordinates stay correct - # factors are in data order; pygfx order is (x, y, z) = reversed - factors = handle._downsample_factors - if factors and any(f > 1 for f in factors): - rev = list(reversed(factors)) - _sx *= rev[0] - _sy *= rev[1] if len(rev) > 1 else 1 - _sz *= rev[2] if len(rev) > 2 else 1 - child.local.scale = (_sx, _sy, _sz) - child.local.position = (ox, oy, oz) + handle.set_world_transform( + tuple(reversed(self._world_scales)), + tuple(reversed(self._world_origins)), + ) def camera_state(self) -> tuple[tuple[int, int], np.ndarray]: """Return viewport and data-order world-to-clip matrix.""" diff --git a/src/ndv/views/_vispy/_array_canvas.py b/src/ndv/views/_vispy/_array_canvas.py index 896f5743..4ecf259c 100755 --- a/src/ndv/views/_vispy/_array_canvas.py +++ b/src/ndv/views/_vispy/_array_canvas.py @@ -88,6 +88,29 @@ def set_data(self, data: np.ndarray) -> None: self._downsample_factors = downsample_factors self._visual.set_data(data) + def set_world_transform( + self, + scales: tuple[float, ...], + origins: tuple[float, ...], + ) -> None: + """Set this visual's scale and translation in data-axis order.""" + scene_scales = list(reversed(scales)) + scene_origins = list(reversed(origins)) + while len(scene_scales) < 3: + scene_scales.append(1.0) + scene_origins.append(0.0) + factors = list(reversed(self._downsample_factors)) + while len(factors) < 3: + factors.append(1) + effective = tuple( + scale * factor + for scale, factor in zip(scene_scales, factors, strict=True) + ) + self._visual.transform = vispy.visuals.transforms.STTransform( + scale=effective, + translate=tuple(scene_origins), + ) + def visible(self) -> bool: return bool(self._visual.visible) @@ -389,7 +412,9 @@ def close(self) -> None: def refresh(self) -> None: self._canvas.update() - def add_image(self, data: np.ndarray | None = None) -> VispyImageHandle: + def add_image( + self, data: np.ndarray | None = None, *, reset_range: bool = True + ) -> VispyImageHandle: """Add a new Image node to the scene.""" data, downsample_factors = _downcast_and_downsample(data, three_d=False) try: @@ -405,11 +430,13 @@ def add_image(self, data: np.ndarray | None = None) -> VispyImageHandle: handle = VispyImageHandle(img) handle._downsample_factors = downsample_factors self._elements[img] = handle - if data is not None: + if data is not None and reset_range: self.set_range() return handle - def add_volume(self, data: np.ndarray | None = None) -> VispyImageHandle: + def add_volume( + self, data: np.ndarray | None = None, *, reset_range: bool = True + ) -> VispyImageHandle: data, downsample_factors = _downcast_and_downsample(data, three_d=True) try: vol = scene.visuals.Volume( @@ -429,7 +456,7 @@ def add_volume(self, data: np.ndarray | None = None) -> VispyImageHandle: handle = VispyImageHandle(vol) handle._downsample_factors = downsample_factors self._elements[vol] = handle - if data is not None: + if data is not None and reset_range: self.set_range() return handle @@ -468,25 +495,12 @@ def set_origins(self, origins: tuple[float, ...]) -> None: self._apply_world_transform() def _apply_world_transform(self) -> None: - sx, sy, sz = self._world_scales - ox, oy, oz = self._world_origins for handle in self._elements.values(): if not isinstance(handle, VispyImageHandle): continue - child = handle._visual - if not isinstance(child, (visuals.ImageVisual, visuals.VolumeVisual)): - continue - _sx, _sy, _sz = sx, sy, sz - # compensate for downsampling so coordinates stay correct - # factors are in data order; scene order is (x, y, z) = reversed - factors = handle._downsample_factors - if factors and any(f > 1 for f in factors): - rev = list(reversed(factors)) - _sx *= rev[0] - _sy *= rev[1] if len(rev) > 1 else 1 - _sz *= rev[2] if len(rev) > 2 else 1 - child.transform = vispy.visuals.transforms.STTransform( - scale=(_sx, _sy, _sz), translate=(ox, oy, oz) + handle.set_world_transform( + tuple(reversed(self._world_scales)), + tuple(reversed(self._world_origins)), ) def camera_state(self) -> tuple[tuple[int, int], np.ndarray]: diff --git a/src/ndv/views/bases/_graphics/_canvas.py b/src/ndv/views/bases/_graphics/_canvas.py index 89f2d43c..3f6bfb4f 100644 --- a/src/ndv/views/bases/_graphics/_canvas.py +++ b/src/ndv/views/bases/_graphics/_canvas.py @@ -62,10 +62,13 @@ def __init__(self, viewer_model: ArrayViewerModel | None = ...) -> None: ... @abstractmethod def set_ndim(self, ndim: Literal[2, 3]) -> None: ... @abstractmethod + def add_image( + self, data: np.ndarray | None = ..., *, reset_range: bool = ... + ) -> ImageHandle: ... @abstractmethod - def add_image(self, data: np.ndarray | None = ...) -> ImageHandle: ... - @abstractmethod - def add_volume(self, data: np.ndarray | None = ...) -> ImageHandle: ... + def add_volume( + self, data: np.ndarray | None = ..., *, reset_range: bool = ... + ) -> ImageHandle: ... @abstractmethod def add_bounding_box(self) -> RectangularROIHandle: ... diff --git a/src/ndv/views/bases/_graphics/_canvas_elements.py b/src/ndv/views/bases/_graphics/_canvas_elements.py index 2b346eb0..b34ca77b 100644 --- a/src/ndv/views/bases/_graphics/_canvas_elements.py +++ b/src/ndv/views/bases/_graphics/_canvas_elements.py @@ -51,6 +51,14 @@ class ImageHandle(CanvasElement, LUTView): def data(self) -> np.ndarray: ... @abstractmethod def set_data(self, data: np.ndarray) -> None: ... + @abstractmethod + def set_world_transform( + self, + scales: tuple[float, ...], + origins: tuple[float, ...], + ) -> None: + """Set this element's world transform in data-axis order.""" + @abstractmethod def clims(self) -> tuple[float, float]: ... @abstractmethod diff --git a/tests/views/_pygfx/test_volume_downsample.py b/tests/views/_pygfx/test_volume_downsample.py index d4730c57..4fcf8dc9 100644 --- a/tests/views/_pygfx/test_volume_downsample.py +++ b/tests/views/_pygfx/test_volume_downsample.py @@ -110,6 +110,23 @@ def test_set_scales_compensates_for_volume_downsample() -> None: canvas.close() +@pytest.mark.usefixtures("any_app") +def test_image_handles_can_have_independent_world_transforms() -> None: + canvas = GfxArrayCanvas(ArrayViewerModel()) + _force_canvas_size(canvas) + canvas.set_ndim(3) + coarse = canvas.add_volume(np.zeros((4, 4, 4), dtype=np.float32)) + fine = canvas.add_volume(np.zeros((4, 4, 4), dtype=np.float32)) + + coarse.set_world_transform((4.0, 4.0, 4.0), (0.0, 0.0, 0.0)) + fine.set_world_transform((1.0, 1.0, 1.0), (8.0, 12.0, 16.0)) + + assert coarse._image.local.scale == pytest.approx((4.0, 4.0, 4.0)) + assert fine._image.local.scale == pytest.approx((1.0, 1.0, 1.0)) + assert fine._image.local.position == pytest.approx((16.0, 12.0, 8.0)) + canvas.close() + + @pytest.mark.usefixtures("any_app") def test_no_downsample_when_limits_none() -> None: """When GPU limits are unavailable, data should pass through unchanged.""" diff --git a/tests/views/_vispy/test_volume_downsample.py b/tests/views/_vispy/test_volume_downsample.py index f60bdb1e..d3a789a1 100644 --- a/tests/views/_vispy/test_volume_downsample.py +++ b/tests/views/_vispy/test_volume_downsample.py @@ -130,6 +130,22 @@ def test_world_origin_and_camera_state_are_public() -> None: canvas.close() +@pytest.mark.usefixtures("any_app") +def test_image_handles_can_have_independent_world_transforms() -> None: + canvas = VispyArrayCanvas(ArrayViewerModel()) + canvas.set_ndim(3) + coarse = canvas.add_volume(np.zeros((4, 4, 4), dtype=np.float32)) + fine = canvas.add_volume(np.zeros((4, 4, 4), dtype=np.float32)) + + coarse.set_world_transform((4.0, 4.0, 4.0), (0.0, 0.0, 0.0)) + fine.set_world_transform((1.0, 1.0, 1.0), (8.0, 12.0, 16.0)) + + assert coarse._visual.transform.scale[:3] == pytest.approx((4.0, 4.0, 4.0)) + assert fine._visual.transform.scale[:3] == pytest.approx((1.0, 1.0, 1.0)) + assert fine._visual.transform.translate[:3] == pytest.approx((16.0, 12.0, 8.0)) + canvas.close() + + @pytest.mark.usefixtures("any_app") def test_set_range_correct_bounds_after_downsample() -> None: """set_range should compute world bounds as if data were full-resolution.""" From a8158c6020dcce29a22598794f9c1d91c05d7d31 Mon Sep 17 00:00:00 2001 From: Kyle Harrington Date: Sun, 23 Aug 2026 08:12:16 -0700 Subject: [PATCH 4/6] style: normalize canvas transform updates --- src/ndv/views/_pygfx/_array_canvas.py | 5 ++--- src/ndv/views/_vispy/_array_canvas.py | 7 +++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/ndv/views/_pygfx/_array_canvas.py b/src/ndv/views/_pygfx/_array_canvas.py index 4f7b55cc..d7a92ae0 100755 --- a/src/ndv/views/_pygfx/_array_canvas.py +++ b/src/ndv/views/_pygfx/_array_canvas.py @@ -108,8 +108,7 @@ def set_world_transform( while len(factors) < 3: factors.append(1) self._image.local.scale = tuple( - scale * factor - for scale, factor in zip(scene_scales, factors, strict=True) + scale * factor for scale, factor in zip(scene_scales, factors, strict=True) ) self._image.local.position = tuple(scene_origins) @@ -593,7 +592,7 @@ def set_origins(self, origins: tuple[float, ...]) -> None: gfx_origins = list(reversed(origins)) while len(gfx_origins) < 3: gfx_origins.append(0.0) - self._world_origins = tuple(gfx_origins[:3]) + self._world_origins = (gfx_origins[0], gfx_origins[1], gfx_origins[2]) self._apply_world_transform() def _apply_world_transform(self) -> None: diff --git a/src/ndv/views/_vispy/_array_canvas.py b/src/ndv/views/_vispy/_array_canvas.py index 4ecf259c..d059dc63 100755 --- a/src/ndv/views/_vispy/_array_canvas.py +++ b/src/ndv/views/_vispy/_array_canvas.py @@ -103,8 +103,7 @@ def set_world_transform( while len(factors) < 3: factors.append(1) effective = tuple( - scale * factor - for scale, factor in zip(scene_scales, factors, strict=True) + scale * factor for scale, factor in zip(scene_scales, factors, strict=True) ) self._visual.transform = vispy.visuals.transforms.STTransform( scale=effective, @@ -481,7 +480,7 @@ def set_scales( # pad to 3 components while len(vis_scales) < 3: vis_scales.append(1.0) - self._world_scales = tuple(vis_scales[:3]) + self._world_scales = (vis_scales[0], vis_scales[1], vis_scales[2]) self._apply_world_transform() if reset_range: self.set_range() @@ -491,7 +490,7 @@ def set_origins(self, origins: tuple[float, ...]) -> None: vis_origins = list(reversed(origins)) while len(vis_origins) < 3: vis_origins.append(0.0) - self._world_origins = tuple(vis_origins[:3]) + self._world_origins = (vis_origins[0], vis_origins[1], vis_origins[2]) self._apply_world_transform() def _apply_world_transform(self) -> None: From eb2aa5154361d17f16c5af5ce6597cdffb07c440 Mon Sep 17 00:00:00 2001 From: Kyle Harrington Date: Sun, 23 Aug 2026 08:14:46 -0700 Subject: [PATCH 5/6] fix: tolerate pre-data canvas synchronization --- src/ndv/views/_pygfx/_array_canvas.py | 6 +++++- tests/views/_pygfx/test_array_canvas.py | 12 ++++++++++++ tests/views/_vispy/test_volume_downsample.py | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/ndv/views/_pygfx/_array_canvas.py b/src/ndv/views/_pygfx/_array_canvas.py index d7a92ae0..5184d999 100755 --- a/src/ndv/views/_pygfx/_array_canvas.py +++ b/src/ndv/views/_pygfx/_array_canvas.py @@ -630,7 +630,11 @@ def set_range( When called with no arguments, the range is set to the full extent of the data. """ - if not self._scene.children or self._camera is None: + has_images = any( + isinstance(handle, PyGFXImageHandle) and handle.data() is not None + for handle in self._elements.values() + ) + if not has_images or self._camera is None: return cam = self._camera diff --git a/tests/views/_pygfx/test_array_canvas.py b/tests/views/_pygfx/test_array_canvas.py index 476ec6ae..bf5327ae 100644 --- a/tests/views/_pygfx/test_array_canvas.py +++ b/tests/views/_pygfx/test_array_canvas.py @@ -59,6 +59,18 @@ def test_zoom_center() -> None: canvas.close() +@pytest.mark.usefixtures("any_app") +def test_set_scales_before_image_is_safe() -> None: + """Initial model synchronization may set scales before data arrives.""" + canvas = GfxArrayCanvas(ArrayViewerModel()) + _force_canvas_size(canvas) + canvas.set_ndim(2) + + canvas.set_scales((2.0, 3.0)) + + canvas.close() + + @pytest.mark.usefixtures("any_app") def test_canvas_to_world_scale_aware_offset() -> None: """canvas_to_world pixel-center offset must scale with pixel size. diff --git a/tests/views/_vispy/test_volume_downsample.py b/tests/views/_vispy/test_volume_downsample.py index d3a789a1..8d7e332d 100644 --- a/tests/views/_vispy/test_volume_downsample.py +++ b/tests/views/_vispy/test_volume_downsample.py @@ -118,7 +118,8 @@ def test_world_origin_and_camera_state_are_public() -> None: assert transform.scale[:3] == pytest.approx((4.0, 3.0, 2.0)) assert transform.translate[:3] == pytest.approx((300.0, 200.0, 100.0)) viewport, world_to_clip = canvas.camera_state() - assert viewport == (600, 600) + assert viewport == tuple(int(value) for value in canvas._canvas.size) + assert all(value > 0 for value in viewport) assert world_to_clip.shape == (4, 4) assert np.isfinite(world_to_clip).all() From 1fc51e91fb810373827930167620de5a9f91c619 Mon Sep 17 00:00:00 2001 From: Kyle Harrington Date: Sun, 23 Aug 2026 19:39:12 -0700 Subject: [PATCH 6/6] fix: preserve projective camera state --- src/ndv/views/_vispy/_array_canvas.py | 20 +++++++++------ tests/views/_vispy/test_volume_downsample.py | 27 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/ndv/views/_vispy/_array_canvas.py b/src/ndv/views/_vispy/_array_canvas.py index d059dc63..067d5dec 100755 --- a/src/ndv/views/_vispy/_array_canvas.py +++ b/src/ndv/views/_vispy/_array_canvas.py @@ -512,15 +512,19 @@ def camera_state(self) -> tuple[tuple[int, int], np.ndarray]: for axis in range(ndim): points[axis + 1, ndim - axis - 1] = 1.0 mapped = np.asarray(self._view.scene.transform.map(points), dtype=np.float64) - mapped /= mapped[:, 3, np.newaxis] - clip = mapped.copy() - clip[:, 0] = 2.0 * mapped[:, 0] / width - 1.0 - clip[:, 1] = 1.0 - 2.0 * mapped[:, 1] / height - matrix = np.eye(4, dtype=np.float64) - matrix[:ndim, -1] = clip[0, :ndim] + # Keep homogeneous coordinates intact. Dividing each basis sample by + # ``w`` before reconstructing the matrix turns a perspective camera + # into an affine approximation around the data origin. That is badly + # wrong for camera-aware LOD and chunk priority away from the origin. + framebuffer = np.eye(4, dtype=np.float64) + framebuffer[:, -1] = mapped[0] for axis in range(ndim): - matrix[:ndim, axis] = clip[axis + 1, :ndim] - clip[0, :ndim] - return (width, height), matrix + framebuffer[:, axis] = mapped[axis + 1] - mapped[0] + + framebuffer_to_clip = np.eye(4, dtype=np.float64) + framebuffer_to_clip[0] = (2.0 / width, 0.0, 0.0, -1.0) + framebuffer_to_clip[1] = (0.0, -2.0 / height, 0.0, 1.0) + return (width, height), framebuffer_to_clip @ framebuffer def set_range( self, diff --git a/tests/views/_vispy/test_volume_downsample.py b/tests/views/_vispy/test_volume_downsample.py index 8d7e332d..dd7c6035 100644 --- a/tests/views/_vispy/test_volume_downsample.py +++ b/tests/views/_vispy/test_volume_downsample.py @@ -131,6 +131,33 @@ def test_world_origin_and_camera_state_are_public() -> None: canvas.close() +@pytest.mark.usefixtures("any_app") +def test_camera_state_preserves_projective_point_mapping() -> None: + canvas = VispyArrayCanvas(ArrayViewerModel()) + canvas.set_ndim(3) + canvas.add_volume(np.zeros((10, 20, 30), dtype=np.float32)) + canvas.set_range() + + viewport, world_to_clip = canvas.camera_state() + width, height = viewport + data_points = np.asarray(((0.0, 0.0, 0.0), (3.0, 7.0, 11.0), (8.0, 17.0, 27.0))) + # VisPy scene order is XYZ while the public camera matrix consumes ZYX. + scene_points = np.column_stack((data_points[:, ::-1], np.ones(len(data_points)))) + framebuffer = np.asarray( + canvas._view.scene.transform.map(scene_points), dtype=np.float64 + ) + expected = framebuffer[:, :3] / framebuffer[:, 3, np.newaxis] + expected[:, 0] = 2.0 * expected[:, 0] / width - 1.0 + expected[:, 1] = 1.0 - 2.0 * expected[:, 1] / height + + homogeneous = np.column_stack((data_points, np.ones(len(data_points)))) + actual = (world_to_clip @ homogeneous.T).T + actual = actual[:, :3] / actual[:, 3, np.newaxis] + + assert actual == pytest.approx(expected) + canvas.close() + + @pytest.mark.usefixtures("any_app") def test_image_handles_can_have_independent_world_transforms() -> None: canvas = VispyArrayCanvas(ArrayViewerModel())