Skip to content
Open
34 changes: 31 additions & 3 deletions src/ndv/controllers/_array_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -239,9 +254,22 @@ def hide(self) -> None:
self._view.set_visible(False)

def close(self) -> None:
"""Close the viewer."""
"""Close the viewer and release its native rendering resources."""
self._disconnect_key_events()
self._view.set_visible(False)
for future in tuple(self._futures):
future.cancel()
self._futures.clear()
for histogram in tuple(self._histograms.values()):
histogram.close()
self._histograms.clear()
if self._shared_histogram is not None:
self._shared_histogram.close()
self._shared_histogram = None
# Renderer cleanup must precede closing the frontend ownership tree.
# Embedded rendercanvas widgets leave native child destruction to the
# frontend root (see the Qt rendercanvas adapter).
self._canvas.close()
self._view.close()

def clone(self) -> ArrayViewer:
"""Return a new ArrayViewer instance with the same data and display model.
Expand Down
103 changes: 78 additions & 25 deletions src/ndv/views/_pygfx/_array_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from ndv.views.bases import ArrayCanvas, CanvasElement, ImageHandle
from ndv.views.bases._graphics._canvas_elements import RectangularROIHandle, ROIMoveMode

from ._util import rendercanvas_class
from ._util import close_rendercanvas, rendercanvas_class

if TYPE_CHECKING:
from collections.abc import Callable, Sequence
Expand Down Expand Up @@ -93,6 +93,25 @@ 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)

Expand Down Expand Up @@ -441,6 +460,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
Expand Down Expand Up @@ -484,7 +505,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)
Expand All @@ -496,7 +519,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
Expand All @@ -506,7 +529,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(
Expand All @@ -520,7 +545,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
Expand All @@ -543,7 +568,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
Expand All @@ -556,26 +583,41 @@ 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 = (gfx_origins[0], gfx_origins[1], gfx_origins[2])
self._apply_world_transform()

def _apply_world_transform(self) -> None:
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)
has_visuals = True
if has_visuals:
self.set_range()
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."""
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,
Expand All @@ -588,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
Expand Down Expand Up @@ -628,6 +674,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]
Expand Down Expand Up @@ -719,7 +772,7 @@ def set_visible(self, visible: bool) -> None:

def close(self) -> None:
self._disconnect_mouse_events()
self._canvas.close()
close_rendercanvas(self._canvas)

def on_mouse_press(self, event: MousePressEvent) -> bool:
if self._selection:
Expand Down
4 changes: 2 additions & 2 deletions src/ndv/views/_pygfx/_histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from ndv.views._app import filter_mouse_events
from ndv.views.bases import HistogramCanvas

from ._util import rendercanvas_class
from ._util import close_rendercanvas, rendercanvas_class

if TYPE_CHECKING:
from collections.abc import Sequence
Expand Down Expand Up @@ -261,7 +261,7 @@ def refresh(self) -> None:

def close(self) -> None:
self._disconnect_mouse_events()
self._canvas.close()
close_rendercanvas(self._canvas)

def _resize(
self, x: tuple[float, float] | None = None, y: tuple[float, float] | None = None
Expand Down
4 changes: 2 additions & 2 deletions src/ndv/views/_pygfx/_shared_histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
)

from ._histogram import _Controller, _OrthographicCamera
from ._util import rendercanvas_class
from ._util import close_rendercanvas, rendercanvas_class

if TYPE_CHECKING:
from ndv._types import (
Expand Down Expand Up @@ -214,7 +214,7 @@ def set_visible(self, visible: bool) -> None: ...

def close(self) -> None:
self._disconnect_mouse_events()
self._canvas.close()
close_rendercanvas(self._canvas)

def frontend_widget(self) -> Any:
return self._canvas
Expand Down
27 changes: 27 additions & 0 deletions src/ndv/views/_pygfx/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@
from rendercanvas import BaseRenderCanvas


def close_rendercanvas(canvas: "BaseRenderCanvas") -> None:
"""Close a render canvas after resolving any asynchronous bitmap download."""
context = getattr(canvas, "_canvas_context", None)
downloader = getattr(context, "_downloader", None)
if downloader is not None:
# rendercanvas 2.3 leaves an in-flight bitmap presentation alive during
# close. Its completion can race destruction of an embedded Qt/wx
# widget. Cancel it and synchronously unmap its staging buffer first.
downloader._clear_pending_download()
canvas.close()


def rendercanvas_class() -> "type[BaseRenderCanvas]":
from ndv.views._app import GuiFrontend, gui_frontend

Expand All @@ -13,6 +25,21 @@ def rendercanvas_class() -> "type[BaseRenderCanvas]":
from qtpy.QtCore import QSize

class QRenderWidget(rendercanvas.qt.QRenderWidget):
def _rc_request_paint(self) -> None:
# An asynchronous bitmap presentation can complete after close.
# rendercanvas 2.3 otherwise calls QWidget.update() on the
# already-deleted PySide object from its completion callback.
if not self.get_closed():
super()._rc_request_paint()

def _rc_close(self) -> None:
# This widget is embedded in and owned by the frontend view.
# Base rendercanvas cleanup has already released its context and
# event queue when this hook runs. Let Qt close the native child
# with its parent; closing it here leaves queued PySide paint
# events referring to a deleted QRenderWidget.
self._is_closed = True

def sizeHint(self) -> QSize:
return QSize(self.width(), self.height())

Expand Down
17 changes: 15 additions & 2 deletions src/ndv/views/_qt/_array_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@
from typing import TYPE_CHECKING, Any, cast

import psygnal
from qtpy.QtCore import QObject, QPoint, QSize, Qt, Signal # type: ignore[attr-defined]
from qtpy.QtCore import ( # type: ignore[attr-defined]
QCoreApplication,
QEvent,
QObject,
QPoint,
QSize,
Qt,
Signal,
)
from qtpy.QtGui import QCursor, QFontDatabase, QMouseEvent, QMovie
from qtpy.QtWidgets import (
QCheckBox,
Expand Down Expand Up @@ -962,7 +970,12 @@ def set_visible(self, visible: bool) -> None:
self._qwidget.setVisible(visible)

def close(self) -> None:
self._qwidget.close()
# Defer destruction until Qt has drained callbacks queued by embedded
# render canvases. PySide can segfault when a parent's synchronous
# close destroys a QRenderWidget during event processing.
self._qwidget.hide()
self._qwidget.deleteLater()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)

def frontend_widget(self) -> QWidget:
return self._qwidget
Expand Down
Loading
Loading