11from __future__ import annotations
22
33from contextlib import suppress
4- from typing import TYPE_CHECKING , Any , Literal , cast
4+ from functools import lru_cache
5+ from typing import TYPE_CHECKING , Any , Literal , TypeVar , cast
56from weakref import ReferenceType , WeakValueDictionary , ref
67
78import cmap as _cmap
1819)
1920from ndv .models ._viewer_model import ArrayViewerModel , InteractionMode
2021from ndv .views ._app import filter_mouse_events
22+ from ndv .views ._util import downsample_data
2123from ndv .views .bases import ArrayCanvas , CanvasElement , ImageHandle
2224from ndv .views .bases ._graphics ._canvas_elements import RectangularROIHandle , ROIMoveMode
2325
@@ -63,22 +65,33 @@ def __init__(self, image: pygfx.Image | pygfx.Volume, render: Callable) -> None:
6365 self ._render = render
6466 self ._grid = cast ("Texture" , image .geometry .grid )
6567 self ._material = cast ("ImageBasicMaterial" , image .material )
68+ # per-axis downsample strides applied to fit GPU texture limits
69+ self ._downsample_factors : tuple [int , ...] = ()
6670
6771 def data (self ) -> np .ndarray :
6872 return self ._grid .data # type: ignore [no-any-return]
6973
7074 def set_data (self , data : np .ndarray ) -> None :
75+ is_three_d = isinstance (self ._image , pygfx .Volume )
76+ data , self ._downsample_factors = _downcast_and_downsample (
77+ data ,
78+ three_d = is_three_d ,
79+ warn = False ,
80+ copy = False ,
81+ )
7182 # If dimensions are unchanged, reuse the buffer
7283 if data .shape == self ._grid .data .shape :
7384 self ._grid .data [:] = data # pyright: ignore[reportOptionalSubscript]
7485 self ._grid .update_range ((0 , 0 , 0 ), self ._grid .size )
7586 # Otherwise, the size (and maybe number of dimensions) changed
7687 # - we need a new buffer
7788 else :
78- self ._grid = pygfx .Texture (data , dim = 2 )
89+ dim = 3 if is_three_d else 2
90+ self ._grid = pygfx .Texture (data , dim = dim )
7991 self ._image .geometry = pygfx .Geometry (grid = self ._grid )
8092 # RGB images (i.e. 3D datasets) cannot have a colormap
81- self ._material .map = None if self ._is_rgb () else self ._cmap .to_pygfx ()
93+ if not is_three_d :
94+ self ._material .map = None if self ._is_rgb () else self ._cmap .to_pygfx ()
8295
8396 def visible (self ) -> bool :
8497 return bool (self ._image .visible )
@@ -465,11 +478,7 @@ def set_ndim(self, ndim: Literal[2, 3]) -> None:
465478
466479 def add_image (self , data : np .ndarray | None = None ) -> PyGFXImageHandle :
467480 """Add a new Image node to the scene."""
468- if data is not None :
469- # pygfx uses a view of the data without copy, so if we don't
470- # copy it here, the original data will be modified when the
471- # texture changes.
472- data = data .copy ()
481+ data , downsample_factors = _downcast_and_downsample (data , three_d = False )
473482 tex = pygfx .Texture (data , dim = 2 )
474483 image = pygfx .Image (
475484 pygfx .Geometry (grid = tex ),
@@ -485,15 +494,12 @@ def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle:
485494 # FIXME: I suspect there are more performant ways to refresh the canvas
486495 # look into it.
487496 handle = PyGFXImageHandle (image , self .refresh )
497+ handle ._downsample_factors = downsample_factors
488498 self ._elements [image ] = handle
489499 return handle
490500
491501 def add_volume (self , data : np .ndarray | None = None ) -> PyGFXImageHandle :
492- if data is not None :
493- # pygfx uses a view of the data without copy, so if we don't
494- # copy it here, the original data will be modified when the
495- # texture changes.
496- data = data .copy ()
502+ data , downsample_factors = _downcast_and_downsample (data , three_d = True )
497503 tex = pygfx .Texture (data , dim = 3 )
498504 vol = pygfx .Volume (
499505 pygfx .Geometry (grid = tex ),
@@ -512,6 +518,7 @@ def add_volume(self, data: np.ndarray | None = None) -> PyGFXImageHandle:
512518 # FIXME: I suspect there are more performant ways to refresh the canvas
513519 # look into it.
514520 handle = PyGFXImageHandle (vol , self .refresh )
521+ handle ._downsample_factors = downsample_factors
515522 self ._elements [vol ] = handle
516523 return handle
517524
@@ -539,10 +546,23 @@ def set_scales(self, scales: tuple[float, ...]) -> None:
539546 gfx_scales .append (1.0 )
540547 sx , sy , sz = gfx_scales [0 ], gfx_scales [1 ], gfx_scales [2 ]
541548 has_visuals = False
542- for child in self ._scene .children :
543- if isinstance (child , (pygfx .Image , pygfx .Volume )):
544- child .local .scale = (sx , sy , sz )
545- has_visuals = True
549+ for handle in self ._elements .values ():
550+ if not isinstance (handle , PyGFXImageHandle ):
551+ continue
552+ child = handle ._image
553+ if not isinstance (child , (pygfx .Image , pygfx .Volume )):
554+ continue
555+ _sx , _sy , _sz = sx , sy , sz
556+ # compensate for downsampling so coordinates stay correct
557+ # factors are in data order; pygfx order is (x, y, z) = reversed
558+ factors = handle ._downsample_factors
559+ if factors and any (f > 1 for f in factors ):
560+ rev = list (reversed (factors ))
561+ _sx *= rev [0 ]
562+ _sy *= rev [1 ] if len (rev ) > 1 else 1
563+ _sz *= rev [2 ] if len (rev ) > 2 else 1
564+ child .local .scale = (_sx , _sy , _sz )
565+ has_visuals = True
546566 if has_visuals :
547567 self .set_range ()
548568
@@ -710,3 +730,37 @@ def get_cursor(self, event: MouseMoveEvent) -> CursorType:
710730 if cursor := vis .get_cursor (event ):
711731 return cursor
712732 return CursorType .DEFAULT
733+
734+
735+ T = TypeVar ("T" , bound = np .ndarray | None )
736+
737+
738+ @lru_cache (maxsize = 1 )
739+ def _get_max_texture_sizes () -> tuple [int | None , int | None ]:
740+ """Return (max_2d, max_3d) texture dimensions from the wgpu adapter."""
741+ try :
742+ import wgpu
743+
744+ adapter = wgpu .gpu .request_adapter_sync ()
745+ limits = adapter .limits
746+ max_2d = limits .get ("max-texture-dimension-2d" )
747+ max_3d = limits .get ("max-texture-dimension-3d" )
748+ return max_2d , max_3d
749+ except Exception :
750+ return None , None
751+
752+
753+ def _downcast_and_downsample (
754+ data : T , three_d : bool , * , warn : bool = True , copy : bool = True
755+ ) -> tuple [T , tuple [int , ...]]:
756+ downsample_factors : tuple [int , ...] = ()
757+ if data is not None :
758+ if copy :
759+ # pygfx uses a view of the data without copy, so if we don't
760+ # copy it here, the original data will be modified when the
761+ # texture changes.
762+ data = data .copy ()
763+ maxd = _get_max_texture_sizes ()[1 if three_d else 0 ]
764+ if maxd is not None :
765+ data , downsample_factors = downsample_data (data , maxd , warn = warn ) # type: ignore[assignment]
766+ return data , downsample_factors # pyright: ignore[reportReturnType]
0 commit comments