diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index c3487de2e..5b6bbc7d5 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -21,6 +21,8 @@ With jupyterlab support. pip install -U "fastplotlib[notebook,imgui]" +.. note:: ``imgui-bundle`` is required for the ``NDWidget`` + Without imgui ^^^^^^^^^^^^^ diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index bde2c89e3..d975f4d0a 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -4,6 +4,13 @@ # this must be the first import for auto-canvas detection from .utils import loop # noqa +from .utils import ( + config, + enumerate_adapters, + select_adapter, + print_wgpu_report, + protocols, +) from .graphics import * from .graphics.features import GraphicFeatureEvent from .graphics.selectors import * @@ -20,7 +27,6 @@ from .layouts import Figure from .widgets import NDWidget, ImageWidget -from .utils import config, enumerate_adapters, select_adapter, print_wgpu_report if len(enumerate_adapters()) < 1: diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 681075ef2..27fd74196 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -106,7 +106,7 @@ def _fix_data(self, data): ) if data.itemsize == 8: - warn(f"casting {array.dtype} array to float32") + warn(f"casting {data.dtype} array to float32") return data.astype(np.float32) return data diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index 6f0059f6a..f2eed65b6 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -6,7 +6,7 @@ from .gpu import enumerate_adapters, select_adapter, print_wgpu_report from ._plot_helpers import * from .enums import * -from ._protocols import ArrayProtocol, ARRAY_LIKE_ATTRS +from .protocols import ARRAY_LIKE_ATTRS, ArrayProtocol, FutureProtocol, CudaArrayProtocol @dataclass diff --git a/fastplotlib/utils/_protocols.py b/fastplotlib/utils/_protocols.py deleted file mode 100644 index 95d7d2763..000000000 --- a/fastplotlib/utils/_protocols.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from typing import Any, Protocol, runtime_checkable - - -ARRAY_LIKE_ATTRS = [ - "__array__", - "__array_ufunc__", - "dtype", - "shape", - "ndim", - "__getitem__", -] - - -@runtime_checkable -class ArrayProtocol(Protocol): - def __array__(self) -> ArrayProtocol: ... - - def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): ... - - def __array_function__(self, func, types, *args, **kwargs): ... - - @property - def dtype(self) -> Any: ... - - @property - def ndim(self) -> int: ... - - @property - def shape(self) -> tuple[int, ...]: ... - - def __getitem__(self, key): ... diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 34062824a..97a3df742 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -6,6 +6,8 @@ from pygfx import Texture, Color +from .protocols import CudaArrayProtocol + cmap_catalog = cmap_lib.Catalog() @@ -405,9 +407,22 @@ def parse_cmap_values( return colors +def cuda_to_numpy(arr: CudaArrayProtocol) -> np.ndarray: + try: + import cupy + except ImportError: + raise ImportError( + "`cupy` is required to work with GPU arrays\npip install cupy" + ) + + return cupy.asnumpy(arr) + + def subsample_array( - arr: np.ndarray, max_size: int = 1e6, ignore_dims: Sequence[int] | None = None -): + arr: CudaArrayProtocol, + max_size: int = 1e6, + ignore_dims: Sequence[int] | None = None, +) -> np.ndarray: """ Subsamples an input array while preserving its relative dimensional proportions. @@ -476,7 +491,12 @@ def subsample_array( slices = tuple(slices) - return np.asarray(arr[slices]) + arr_sliced = arr[slices] + + if isinstance(arr_sliced, CudaArrayProtocol): + return cuda_to_numpy(arr_sliced) + + return arr_sliced def heatmap_to_positions(heatmap: np.ndarray, xvals: np.ndarray) -> np.ndarray: diff --git a/fastplotlib/utils/protocols.py b/fastplotlib/utils/protocols.py new file mode 100644 index 000000000..66df15ddd --- /dev/null +++ b/fastplotlib/utils/protocols.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Protocol, runtime_checkable + + +ARRAY_LIKE_ATTRS = [ + "__array__", + "__array_ufunc__", + "dtype", + "shape", + "ndim", + "__getitem__", +] + + +@runtime_checkable +class ArrayProtocol(Protocol): + """an object that is sufficiently array-like""" + + def __array__(self) -> ArrayProtocol: ... + + @property + def dtype(self) -> Any: ... + + @property + def ndim(self) -> int: ... + + @property + def shape(self) -> tuple[int, ...]: ... + + def __getitem__(self, key) -> ArrayProtocol: ... + + +@runtime_checkable +class CudaArrayProtocol(Protocol): + """an object that can be converted to a cupy array""" + + def __cuda_array_interface__(self) -> CudaArrayProtocol: ... + + +@runtime_checkable +class FutureProtocol(Protocol): + """An object that is sufficiently Future-like""" + + def cancel(self): ... + + def cancelled(self): ... + + def running(self): ... + + def done(self): ... + + def add_done_callback(self, fn: Callable): ... + + def result(self, timeout: float | None): ... + + def exception(self, timeout: float | None): ... + + def set_result(self, array: ArrayProtocol): ... + + def set_exception(self, exception): ... diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index 378f7dfcd..65f448b54 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -1,14 +1,7 @@ from ...layouts import IMGUI -try: - import imgui_bundle -except ImportError: - HAS_XARRAY = False -else: - HAS_XARRAY = True - -if IMGUI and HAS_XARRAY: +if IMGUI: from ._base import NDProcessor, NDGraphic from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras from ._nd_image import NDImageProcessor, NDImage @@ -19,6 +12,6 @@ class NDWidget: def __init__(self, *args, **kwargs): raise ModuleNotFoundError( - "NDWidget requires `imgui-bundle` and `xarray` to be installed.\n" + "NDWidget requires `imgui-bundle` to be installed.\n" "pip install imgui-bundle" ) diff --git a/fastplotlib/widgets/nd_widget/_async.py b/fastplotlib/widgets/nd_widget/_async.py new file mode 100644 index 000000000..5aa24a65f --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_async.py @@ -0,0 +1,100 @@ +from collections.abc import Generator +from concurrent.futures import Future + +from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol, cuda_to_numpy + + +class FutureArray(Future): + def __init__(self, shape, dtype, timeout: float = 1.0): + self._shape = shape + self._dtype = dtype + self._timeout = timeout + + super().__init__() + + @property + def shape(self) -> tuple[int, ...]: + return self._shape + + @property + def ndim(self) -> int: + return len(self.shape) + + @property + def dtype(self) -> str: + return self._dtype + + def __getitem__(self, item) -> ArrayProtocol: + return self.result(self._timeout)[item] + + def __array__(self) -> ArrayProtocol: + return self.result(self._timeout) + + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + raise NotImplementedError + + def __array_function__(self, func, types, *args, **kwargs): + raise NotImplementedError + + +# inspired by https://www.dabeaz.com/coroutines/ +def start_coroutine(func): + """ + Starts coroutines for async arrays wrapped by NDProcessor. + Used by all NDGraphic.set_indices and NDGraphic._create_graphic. + + It also immediately starts coroutines unless block=False is provided. It handles all the triage of possible + sync vs. async (Future-like) objects. + + The only time when block=False is when ReferenceIndex._render_indices uses it to loop through setting all + indices, and then collect and send the results back down to NDProcessor.get(). + """ + + def start( + self, *args, **kwargs + ) -> tuple[Generator, ArrayProtocol | CudaArrayProtocol | FutureProtocol] | None: + cr = func(self, *args, **kwargs) + try: + # begin coroutine + to_resolve: FutureProtocol | ArrayProtocol | CudaArrayProtocol = cr.send( + None + ) + except StopIteration: + # NDProcessor.get() has no `yield` expression, not async, nothing to return + return None + + block = kwargs.get("block", True) + timeout = kwargs.get("timeout", 1.0) + + if block: # resolve Future immediately + try: + if isinstance(to_resolve, FutureProtocol): + # array is async, resolve future and send + cr.send(to_resolve.result(timeout=timeout)) + elif isinstance(to_resolve, CudaArrayProtocol): + # array is on GPU, it is technically and on GPU, convert to numpy array on CPU + cr.send(cuda_to_numpy(to_resolve)) + else: + # not async, just send the array + cr.send(to_resolve) + except StopIteration: + pass + + else: # no block, probably resolving multiple futures simultaneously + if isinstance(to_resolve, FutureProtocol): + # data is async, return coroutine generator and future + # ReferenceIndex._render_indices() will manage them and wait to gather all futures + return cr, to_resolve + elif isinstance(to_resolve, CudaArrayProtocol): + # it is async technically, but it's a GPU array, ReferenceIndex._render_indices will manage it + return cr, to_resolve + else: + # not async, just send the array + try: + cr.send(to_resolve) + except ( + StopIteration + ): # has to be here because of the yield expression, i.e. it's a generator + pass + + return start diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 932018f6e..5c2747d2b 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -1,23 +1,24 @@ -from collections.abc import Callable, Hashable, Sequence +from collections.abc import Callable, Sequence, Generator from contextlib import contextmanager import inspect from numbers import Real from pprint import pformat import textwrap -from typing import Literal, Any, Type -from warnings import warn +from typing import Any -import xarray as xr import numpy as np from numpy.typing import ArrayLike from ...layouts import Subplot -from ...utils import subsample_array, ArrayProtocol +from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol from ...graphics import Graphic -from ._index import ReferenceIndex # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] +# [YieldType, SendType, ReturnType] +AwaitedArray = Generator[ + FutureProtocol | ArrayProtocol | CudaArrayProtocol, ArrayProtocol, ArrayProtocol +] def identity(index: int) -> int: @@ -27,27 +28,26 @@ def identity(index: int) -> int: class NDProcessor: def __init__( self, - data: Any, - dims: Sequence[Hashable], - spatial_dims: Sequence[Hashable] | None, - slider_dim_transforms: dict[Hashable, Callable[[Any], int] | ArrayLike] = None, + data: ArrayProtocol, + dims: Sequence[str], + spatial_dims: Sequence[str] | None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, window_funcs: dict[ - Hashable, tuple[WindowFuncCallable | None, int | float | None] + str, tuple[WindowFuncCallable | None, int | float | None] ] = None, - window_order: tuple[Hashable, ...] = None, + window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): """ Base class for managing n-dimensional data and producing array slices. - By default, wraps input data into an ``xarray.DataArray`` and provides an interface - for indexing slider dimensions, applying window functions, spatial functions, and mapping - reference-space values to local array indices. Subclasses must implement - :meth:`get`, which is called whenever the :class:`ReferenceIndex` updates. + Wraps array-like ``data`` and provides an interface for indexing slider dimensions, applying window functions, + spatial functions, and mapping reference-space values to local array indices. Subclasses must implement + :meth:`get`, which is called when the :class:`ReferenceIndex` updates. - Subclasses can implement any type of data representation, they do not necessarily need to be compatible with - (they dot not have to be xarray compatible). However their ``get()`` method must still return a data slice that - corresponds to the graphical representation they map to. + Subclasses can implement any type of data representation, they do not necessarily need to be array-like. + However their ``get()`` method must still return a data slice that corresponds to the graphical representation + they map to. Every dimension that is *not* listed in ``spatial_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the @@ -56,7 +56,7 @@ def __init__( Parameters ---------- - data: Any + data: ArrayProtocol data object that is managed, usually uses the ArrayProtocol. Custom subclasses can manage any kind of data object but the corresponding :meth:`get` must return an array-like that maps to a graphical representation. @@ -73,8 +73,8 @@ def __init__( must operate as if these dimensions exist and return an array that matches the spatial dimensions. spatial_dims: Sequence[str] - Subset of ``dims`` that are spatial (rendered) dimensions **in order**. All remaining dims are treated as - slider dims. See subclass for specific info. + Subset of ``dims`` that are spatial (rendered) dimensions **in display order**. All remaining dims are + treated as slider dims. See subclass for specific info. slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None Per-slider-dim mapping from reference-space values to local array indices. @@ -88,7 +88,7 @@ def __init__( If a transform is not provided for a dim then the identity mapping is used. window_funcs: dict[ - Hashable, tuple[WindowFuncCallable | None, int | float | None] + str, tuple[WindowFuncCallable | None, int | float | None] ] Per-slider-dim window functions applied around the current slider position. Ex: {"time": (np.mean, 2.5)}. Each value is a ``(func, window_size)`` pair where: @@ -101,7 +101,7 @@ def __init__( * *window_size* is in reference-space units (ex: 2.5 seconds). - window_order: tuple[Hashable, ...] + window_order: tuple[str, ...] Order in which window functions are applied across dims. Only dims listed here have their window function applied. window_funcs are ignored for any dims not specified in ``window_order`` @@ -110,8 +110,13 @@ def __init__( A function applied to the spatial slice *after* window_funcs right before rendering. """ - self._dims = tuple(dims) - self._data = self._validate_data(data) + dims = tuple(dims) + if not all([isinstance(d, str) for d in dims]): + raise TypeError + + self._dims = dims + + self.data = data self.spatial_dims = spatial_dims self.slider_dim_transforms = slider_dim_transforms @@ -121,7 +126,7 @@ def __init__( self.spatial_func = spatial_func @property - def data(self) -> xr.DataArray: + def data(self) -> ArrayProtocol: """ get or set managed data. If setting with new data, the new data is interpreted to have the same dims (i.e. same dim names and ordering of dims). @@ -130,28 +135,26 @@ def data(self) -> xr.DataArray: @data.setter def data(self, data: ArrayProtocol): - self._data = self._validate_data(data) + # data can be set, but the dims must still match/have the same meaning - def _validate_data(self, data: ArrayProtocol): - # does some basic validation if data is None: # we allow data to be None, in this case no ndgraphic is rendered # useful when we want to initialize an NDWidget with no traces for example # and populate it as components/channels are selected - return None + self._data = None + return if not isinstance(data, ArrayProtocol): - # This is required for xarray compatibility and general array-like requirements + # check for general array-like requirements raise TypeError("`data` must implement the ArrayProtocol") if data.ndim != len(self.dims): raise IndexError("must specify a dim for every dimension in the data array") - # data can be set, but the dims must still match/have the same meaning - return xr.DataArray(data, dims=self.dims) + self._data = data @property - def shape(self) -> dict[Hashable, int]: + def shape(self) -> dict[str, int]: """interpreted shape of the data""" return {d: n for d, n in zip(self.dims, self.data.shape)} @@ -161,21 +164,21 @@ def ndim(self) -> int: return self.data.ndim @property - def dims(self) -> tuple[Hashable, ...]: - """dim names""" + def dims(self) -> tuple[str, ...]: + """dim names, **ordered as laid out in the array**""" # these are read-only and cannot be set after it's created # the user should create a new NDGraphic if they need different dims - # I can't think of a usecase where we'd want to change the dims, and + # I can't think of a use case where we'd want to change the dims, and # I think that would be complicated and probably and anti-pattern return self._dims @property - def spatial_dims(self) -> tuple[Hashable, ...]: - """Spatial dims, **in order**""" + def spatial_dims(self) -> tuple[str, ...]: + """Spatial dims, **in display order**""" return self._spatial_dims @spatial_dims.setter - def spatial_dims(self, sdims: Sequence[Hashable]): + def spatial_dims(self, sdims: Sequence[str]): for dim in sdims: if dim not in self.dims: raise KeyError @@ -196,8 +199,8 @@ def tooltip_format(self, *args) -> str | None: return None @property - def slider_dims(self) -> set[Hashable]: - """Slider dim names, ``set(dims) - set(spatial_dims)""" + def slider_dims(self) -> set[str]: + """Slider dim names, ``set(dims) - set(spatial_dims), **unordered**""" return set(self.dims) - set(self.spatial_dims) @property @@ -208,7 +211,7 @@ def n_slider_dims(self): @property def window_funcs( self, - ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]]: + ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: """get or set window functions, see docstring for details""" return self._window_funcs @@ -216,7 +219,7 @@ def window_funcs( def window_funcs( self, window_funcs: ( - dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None] + dict[str, tuple[WindowFuncCallable | None, int | float | None] | None] | None ), ): @@ -265,12 +268,12 @@ def window_funcs( self._window_funcs = window_funcs @property - def window_order(self) -> tuple[Hashable, ...]: + def window_order(self) -> tuple[str, ...]: """get or set dimension order in which window functions are applied""" return self._window_order @window_order.setter - def window_order(self, order: tuple[Hashable] | None): + def window_order(self, order: tuple[str] | None): if order is None: self._window_order = tuple() return @@ -284,13 +287,13 @@ def window_order(self, order: tuple[Hashable] | None): self._window_order = tuple(order) @property - def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: """get or set the spatial function which is applied on the data slice after the window functions""" return self._spatial_func @spatial_func.setter def spatial_func( - self, func: Callable[[xr.DataArray], xr.DataArray] + self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: if not callable(func) and func is not None: raise TypeError @@ -298,13 +301,13 @@ def spatial_func( self._spatial_func = func @property - def slider_dim_transforms(self) -> dict[Hashable, Callable[[Any], int]]: + def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: """get or set the slider_dim_transforms, see docstring for details""" return self._index_mappings @slider_dim_transforms.setter def slider_dim_transforms( - self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None + self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None ): if maps is None: self._index_mappings = {d: identity for d in self.dims} @@ -340,9 +343,9 @@ def _ref_index_to_array_index(self, dim: str, ref_index: Any) -> int: # clamp between 0 and array size in this dim return max(min(index, self.shape[dim] - 1), 0) - def _get_slider_dims_indexer(self, indices: dict[Hashable, Any]) -> dict[Hashable, slice]: + def _get_slider_dims_indexer(self, indices: dict[str, Any]) -> dict[str, slice]: """ - Creates an xarray-compatible indexer dict mapping each slider_dim -> slice object. + Creates an indexer dict mapping each slider_dim -> slice object. - If a window_func is defined for a dim and the dim appears in ``window_order``, the slice is defined as: @@ -363,14 +366,14 @@ def _get_slider_dims_indexer(self, indices: dict[Hashable, Any]) -> dict[Hashabl Parameters ---------- - indices : dict[Hashable, Any], {dim: ref_value} + indices : dict[str, Any], {dim: ref_value} Reference-space values for each slider dim. Must contain an entry for every slider dim; raises ``IndexError`` otherwise. ex: {"time": 46.397, "depth": 23.24} Returns ------- - dict[Hashable, slice] + dict[str, slice] Indexer compatible for ``xr.DataArray.isel()``, with one ``slice`` per slider dim. These are array indices mapped from the reference space using the given ``slider_dim_transform``. @@ -433,34 +436,24 @@ def _get_slider_dims_indexer(self, indices: dict[Hashable, Any]) -> dict[Hashabl return indexer - def _apply_window_functions(self, indices: dict[Hashable, Any]) -> xr.DataArray: + def _apply_window_functions(self, windowed_array: ArrayProtocol) -> ArrayProtocol: """ - Slice the data at the given indices and apply window functions in the order specified by + apply window functions in the order specified by ``window_order``. Parameters ---------- - indices : dict[Hashable, Any], {dim: ref_value} - Reference-space values for each slider dim. - ex: {"time": 46.397, "depth": 23.24} + windowed_array: ArrayProtocol + array that has been sliced with the desired windows at an index Returns ------- - xr.DataArray + ArrayProtocol Data slice after windowed indexing and window function application, with the same dims as the original data. Dims of size ``1`` are not squeezed. """ - indexer = self._get_slider_dims_indexer(indices) - - # get the data slice w.r.t. the desired windows, and get the underlying numpy array - # ``.values`` gives the numpy array - # there is significant overhead with passing xarray objects to numpy for things like np.mean() - # so convert to numpy, apply window functions, then convert back to xarray - # creating an xarray object from a numpy array has very little overhead, ~10 microseconds - array = self.data.isel(indexer).values - # apply window funcs in the specified order for dim in self.window_order: if self.window_funcs[dim] is None: @@ -472,11 +465,72 @@ def _apply_window_functions(self, indices: dict[Hashable, Any]) -> xr.DataArray: # ``keepdims`` means the resultant shape is [1, 512, 512] and NOT [512, 512] # this is necessary for applying window functions on multiple dims separately and so that the # dims names correspond after all the window funcs are applied. - array = func(array, axis=self.dims.index(dim), keepdims=True) + windowed_array = func( + windowed_array, axis=self.dims.index(dim), keepdims=True + ) + + return windowed_array + + def get_window_output(self, indices: dict[str, Any]) -> AwaitedArray: + """ + Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims + + Parameters + ---------- + indices - return xr.DataArray(array, dims=self.dims) + Returns + ------- - def get(self, indices: dict[Hashable, Any]): + """ + # windowed slice if user set any window funcs + windowed_slice = yield from self._get_raw_data_slice(indices) + + # convert to numpy array + windowed_slice = np.asarray(windowed_slice) + + # apply window funcs + if len(self.slider_dims) > 0: + windowed_slice = self._apply_window_functions(windowed_slice) + + # squeeze out all slider dims which should now be size 1 + # set(dims) - set(spatial_dims) since some spatial dims can also be slider, so get only pure non-spatial dims + slider_dims_int = tuple( + self.dims.index(d) for d in set(self.dims) - set(self.spatial_dims) + ) + windowed_slice = windowed_slice.squeeze(axis=slider_dims_int) + + if windowed_slice.ndim != len(self.spatial_dims): + raise ValueError + + # transpose to spatial dims + spatial_dims_int = tuple( + self.spatial_dims.index(d) for d in self.dims if d in self.spatial_dims + ) + + return windowed_slice.transpose(spatial_dims_int) + + def _get_raw_data_slice(self, indices: dict[str, Any]) -> AwaitedArray: + """ + Base implementation to get the raw data slice from the wrapped array. + Always yields to support async getters. + """ + if len(self.slider_dims) > 0: + indexer = self._get_slider_dims_indexer(indices) + # get the data slice w.r.t. the desired windows + # yield so this is async if the underlying array returns a FutureArray-like + # we convert to a numpy array outside, not here, since that resolves the Future + index_tuple = tuple(indexer.get(dim, slice(None)) for dim in self.dims) + raw_slice = yield self.data[index_tuple] + + else: + # return everything directly + # request a slice of everything with [:] so that any data fetching, compute, etc. is actually done + raw_slice = yield self.data[:] + + return raw_slice + + def get(self, indices: dict[str, Any]) -> AwaitedArray | ArrayProtocol: raise NotImplementedError # TODO: html and pretty text repr # @@ -491,10 +545,7 @@ def get(self, indices: dict[Hashable, Any]): def _repr_text_(self): if self.data is None: - return ( - f"{self.__class__.__name__}\n" - f"data is None, dims: {self.dims}" - ) + return f"{self.__class__.__name__}\n" f"data is None, dims: {self.dims}" tab = "\t" wf = {k: v for k, v in self.window_funcs.items() if v != (None, None)} @@ -541,7 +592,6 @@ def __init__( # user settable bool to make the graphic unresponsive to change in the ReferenceIndex self._pause = False - def _create_graphic(self): raise NotImplementedError @@ -568,12 +618,22 @@ def graphic(self) -> Graphic: raise NotImplementedError @property - def indices(self) -> dict[Hashable, Any]: + def indices(self) -> dict[str, Any]: raise NotImplementedError - @indices.setter - def indices(self, new: dict[Hashable, Any]): - raise NotImplementedError + def set_indices( + self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 + ): + pass + + def _get_data_slice(self, indices): + """gets current data slice from NDProcessor, resolves Futures if necessary""" + data_slice = self.processor.get(indices) + + if isinstance(data_slice, Generator): + data_slice = yield from data_slice + + return data_slice # aliases for easier access to processor properties @property @@ -596,10 +656,10 @@ def data(self, data: Any): self._create_graphic() # force a render - self.indices = self.indices + self.set_indices(self.indices) @property - def shape(self) -> dict[Hashable, int]: + def shape(self) -> dict[str, int]: """interpreted shape of the data""" return self.processor.shape @@ -609,7 +669,7 @@ def ndim(self) -> int: return self.processor.ndim @property - def dims(self) -> tuple[Hashable, ...]: + def dims(self) -> tuple[str, ...]: """dim names""" return self.processor.dims @@ -620,27 +680,27 @@ def spatial_dims(self) -> tuple[str, ...]: raise NotImplementedError @property - def slider_dims(self) -> set[Hashable]: + def slider_dims(self) -> set[str]: """the slider dims""" return self.processor.slider_dims @property - def slider_dim_transforms(self) -> dict[Hashable, Callable[[Any], int]]: + def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: return self.processor.slider_dim_transforms @slider_dim_transforms.setter def slider_dim_transforms( - self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None + self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None ): """get or set the slider_dim_transforms, see docstring for details""" self.processor.slider_dim_transforms = maps # force a render - self.indices = self.indices + self.set_indices(self.indices) @property def window_funcs( self, - ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]]: + ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: """get or set window functions, see docstring for details""" return self.processor.window_funcs @@ -648,37 +708,38 @@ def window_funcs( def window_funcs( self, window_funcs: ( - dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None] + dict[str, tuple[WindowFuncCallable | None, int | float | None] | None] | None ), ): self.processor.window_funcs = window_funcs # force a render - self.indices = self.indices + self.set_indices(self.indices) @property - def window_order(self) -> tuple[Hashable, ...]: + def window_order(self) -> tuple[str, ...]: """get or set dimension order in which window functions are applied""" return self.processor.window_order @window_order.setter - def window_order(self, order: tuple[Hashable] | None): + def window_order(self, order: tuple[str] | None): self.processor.window_order = order # force a render - self.indices = self.indices + self.set_indices(self.indices) @property - def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + """get or set the spatial_func, see docstring for details""" return self.processor.spatial_func @spatial_func.setter def spatial_func( - self, func: Callable[[xr.DataArray], xr.DataArray] + self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: """get or set the spatial_func, see docstring for details""" self.processor.spatial_func = func # force a render - self.indices = self.indices + self.set_indices(self.indices) # def _repr_text_(self) -> str: # return ndg_fmt_text(self) @@ -693,7 +754,10 @@ def spatial_func( # } def _repr_text_(self): - return f"graphic: {self.graphic.__class__.__name__}\n" f"processor:\n{self.processor}" + return ( + f"graphic: {self.graphic.__class__.__name__}\n" + f"processor:\n{self.processor}" + ) @contextmanager @@ -713,7 +777,7 @@ def block_indices_ctx(ndgraphic: NDGraphic): def block_reentrance(setter): # decorator to block re-entrance of indices setter - def set_indices_wrapper(self: NDGraphic, new_indices): + def set_indices_wrapper(self: NDGraphic, *args, **kwargs): """ wraps NDGraphic.indices @@ -727,7 +791,7 @@ def set_indices_wrapper(self: NDGraphic, new_indices): try: # block re-execution of set_value until it has *fully* finished executing self._block_indices = True - setter(self, new_indices) + return setter(self, *args, **kwargs) except Exception as exc: # raise original exception raise exc # set_value has raised. The line above and the lines 2+ steps below are probably more relevant! diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index fc51c345c..24a42999c 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Generator +from concurrent.futures import wait from dataclasses import dataclass from numbers import Number from typing import Sequence, Any, Callable @@ -9,6 +11,8 @@ if TYPE_CHECKING: from ._ndwidget import NDWidget +from ...utils import FutureProtocol, CudaArrayProtocol, cuda_to_numpy + @dataclass class RangeContinuous: @@ -200,12 +204,44 @@ def _clamp(self, dim, value): return value def _render_indices(self): + pending_futures = list() + pending_cuda = list() + for ndw in self._ndwidgets: for g in ndw.ndgraphics: if g.data is None or g.pause: continue # only provide slider indices to the graphic - g.indices = {d: self._indices[d] for d in g.processor.slider_dims} + indices = {d: self._indices[d] for d in g.processor.slider_dims} + to_resolve: None | tuple[Generator, FutureProtocol] = g.set_indices(indices, block=True) + + if to_resolve is not None: + if isinstance(to_resolve[1], FutureProtocol): + # it's a future that we need to resolve + pending_futures.append(to_resolve) + elif isinstance(to_resolve[1], CudaArrayProtocol): + pending_cuda.append(to_resolve) + + if not pending_futures and not pending_cuda: + # no futures or gpu arrays to resolve, everything is sync + return + + # resolve futures + wait([future for cr, future in pending_futures], timeout=2) + + for cr, future in pending_futures: + try: + cr.send(future.result()) + except StopIteration: + pass + + # resolve GPU arrays + for cr, gpu_arr in pending_cuda: + try: + arr = cuda_to_numpy(gpu_arr) + cr.send(arr) + except StopIteration: + pass def __getitem__(self, dim): self._check_has_dim(dim) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index be319942d..9fa39606d 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -1,24 +1,23 @@ -from collections.abc import Hashable, Sequence -import inspect +from collections.abc import Sequence, Generator from typing import Callable, Any import numpy as np from numpy.typing import ArrayLike -import xarray as xr from ...layouts import Subplot -from ...utils import subsample_array, ArrayProtocol, ARRAY_LIKE_ATTRS +from ...utils import subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol from ...graphics import ImageGraphic, ImageVolumeGraphic from ...tools import HistogramLUTTool -from ._base import NDProcessor, NDGraphic, WindowFuncCallable +from ._base import NDProcessor, NDGraphic, WindowFuncCallable, block_reentrance, AwaitedArray from ._index import ReferenceIndex +from ._async import start_coroutine class NDImageProcessor(NDProcessor): def __init__( self, data: ArrayProtocol | None, - dims: Sequence[Hashable], + dims: Sequence[str], spatial_dims: ( tuple[str, str] | tuple[str, str, str] ), # must be in order! [rows, cols] | [z, rows, cols] @@ -60,12 +59,12 @@ def __init__( ``("row", "col")`` ``("other_dim", "depth", "time", "row", "col")`` - dims in the array do not need to be in order, for example you can have a weird array where the dims are - interpreted as: ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")`` - thanks to xarray magic =D. + dims in the array do not need to be in the order that you want to display them, for example you can have a + weird array where the dims are interpreted as: + ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")``. spatial_dims : tuple[str, str] | tuple[str, str, str] - The 2 or 3 spatial dimensions **in order**: ``(rows, cols)`` or ``(z, rows, cols)``. + The 2 or 3 spatial dimensions **in display order**: ``(rows, cols)`` or ``(z, rows, cols)``. This also determines whether an ``ImageGraphic`` or ``ImageVolumeGraphic`` is used for rendering. The ordering determines how the Image/Volume is rendered. For example, if you specify ``spatial_dims = ("rows", "cols")`` and then change it to ``("cols", "rows")``, it will display @@ -124,7 +123,7 @@ def __init__( self._recompute_histogram() @property - def data(self) -> xr.DataArray | None: + def data(self) -> ArrayProtocol | None: """ get or set managed data. If setting with new data, the new data is interpreted to have the same dims (i.e. same dim names and ordering of dims). @@ -133,12 +132,8 @@ def data(self) -> xr.DataArray | None: @data.setter def data(self, data: ArrayProtocol): - self._data = self._validate_data(data) - self._recompute_histogram() - - def _validate_data(self, data: ArrayProtocol): if not isinstance(data, ArrayProtocol): - # check that it's compatible with array and generally array-like + # check that it's generally array-like raise TypeError( f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" f"{ARRAY_LIKE_ATTRS}, or they must be `None`" @@ -150,7 +145,31 @@ def _validate_data(self, data: ArrayProtocol): f"Image data must have a minimum of 2 dimensions, you have passed an array of shape: {data.shape}" ) - return xr.DataArray(data, dims=self.dims) + self._data = data + self._recompute_histogram() + + @property + def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: + """ + Spatial dims, **in display order**. + + [row_dim, col_dim] or [row_dim, col_dim, rgb(a) dim] + """ + return self._spatial_dims + + @spatial_dims.setter + def spatial_dims(self, sdims: tuple[str, str] | tuple[str, str, str]): + for dim in sdims: + if dim not in self.dims: + raise KeyError + + if len(sdims) not in (2, 3): + raise ValueError( + f"There must be 2 or 3 spatial dims for images indicating [row_dim, col_dim] or " + f"[row_dims, col_dim, rgb(a) dim]. You passed: {sdims}" + ) + + self._spatial_dims = tuple(sdims) @property def rgb_dim(self) -> str | None: @@ -192,7 +211,7 @@ def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: """ return self._histogram - def get(self, indices: dict[str, Any]) -> ArrayLike | None: + def get(self, indices: dict[str, Any]) -> AwaitedArray: """ Get the data at the given index, process data through the window functions. @@ -206,15 +225,8 @@ def get(self, indices: dict[str, Any]) -> ArrayLike | None: Example: get((100, 5)) """ - if len(self.slider_dims) > 0: - # there are dims in addition to the spatial dims - window_output = self._apply_window_functions(indices).squeeze() - else: - # no slider dims, use all the data - window_output = self.data - - if window_output.ndim != len(self.spatial_dims): - raise ValueError + # this will be squeezed output, with dims in the order of the user set spatial dims + window_output = yield from self.get_window_output(indices) # apply spatial_func if self.spatial_func is not None: @@ -222,9 +234,9 @@ def get(self, indices: dict[str, Any]) -> ArrayLike | None: if spatial_out.ndim != len(self.spatial_dims): raise ValueError - return spatial_out.transpose(*self.spatial_dims).values + return spatial_out - return window_output.transpose(*self.spatial_dims).values + return window_output def _recompute_histogram(self): """ @@ -251,10 +263,6 @@ def _recompute_histogram(self): sub = subsample_array(self.data, ignore_dims=ignore_dims) - if isinstance(sub, xr.DataArray): - # can't do the isnan and isinf boolean indexing below on xarray - sub = sub.values - sub_real = sub[~(np.isnan(sub) | np.isinf(sub))] self._histogram = np.histogram(sub_real, bins=100) @@ -381,6 +389,7 @@ def graphic( """Underlying Graphic object used to display the current data slice""" return self._graphic + @start_coroutine def _create_graphic(self): # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, # adds it to the subplot, and resets the camera and histogram. @@ -401,7 +410,8 @@ def _create_graphic(self): # get the data slice for this index # this will only have the dims specified by ``spatial_dims`` - data_slice = self.processor.get(self.indices) + + data_slice = yield from self._get_data_slice(self.indices) # create the new graphic new_graphic = cls(data_slice) @@ -492,7 +502,11 @@ def _reset_camera(self): @property def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: - """get or set the spatial dims, see docstring for details""" + """ + get or set the spatial dims **in order** + + [row_dim, col_dim] or [row_dim, col_dim, rgb(a) dim] + """ return self.processor.spatial_dims @spatial_dims.setter @@ -503,13 +517,16 @@ def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): self._create_graphic() @property - def indices(self) -> dict[Hashable, Any]: + def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - @indices.setter - def indices(self, indices): - data_slice = self.processor.get(indices) + @block_reentrance + @start_coroutine + def set_indices( + self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 + ): + data_slice = yield from self._get_data_slice(indices) self.graphic.data = data_slice @@ -529,13 +546,15 @@ def histogram_widget(self) -> HistogramLUTTool: return self._histogram_widget @property - def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: """get or set the spatial_func, see docstring for details""" + # this is here even though it's the same in the base class since we can't create the image specific setter + # without also defining the property in this subclass. return self.processor.spatial_func @spatial_func.setter def spatial_func( - self, func: Callable[[xr.DataArray], xr.DataArray] + self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: self.processor.spatial_func = func self.processor._recompute_histogram() diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 2d3ff2b9a..3941e2d02 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -1,4 +1,4 @@ -from collections.abc import Callable, Hashable, Sequence +from collections.abc import Callable, Hashable, Sequence, Generator from functools import partial from typing import Literal, Any, Type from warnings import warn @@ -6,11 +6,9 @@ import numpy as np from numpy.lib.stride_tricks import sliding_window_view from numpy.typing import ArrayLike -import xarray as xr from ....layouts import Subplot from ....graphics import ( - Graphic, ImageGraphic, LineGraphic, LineStack, @@ -29,7 +27,9 @@ block_reentrance, block_indices_ctx, ) +from ....utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol from .._index import ReferenceIndex +from .._async import start_coroutine # types for the other features FeatureCallable = Callable[[np.ndarray, slice], np.ndarray] @@ -37,6 +37,12 @@ MarkersType = Sequence[str] | np.ndarray | FeatureCallable | None SizesType = Sequence[float] | np.ndarray | FeatureCallable | None +AwaitedPositionData = Generator[ + FutureProtocol | ArrayProtocol | CudaArrayProtocol, + ArrayProtocol, + dict[str, ArrayProtocol], +] + def default_cmap_transform_each(p: int, data_slice: np.ndarray, s: slice): # create a cmap transform based on the `p` dim size @@ -57,10 +63,10 @@ class NDPositionsProcessor(NDProcessor): def __init__( self, data: Any, - dims: Sequence[Hashable], + dims: Sequence[str], # TODO: allow stack_dim to be None and auto-add new dim of size 1 in get logic spatial_dims: tuple[ - Hashable | None, Hashable, Hashable + str | None, str, str ], # [stack_dim, n_datapoints, spatial_dim], IN ORDER!! slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, display_window: int | float | None = 100, # window for n_datapoints dim only @@ -286,6 +292,7 @@ def sizes(self, new: SizesType): @property def spatial_dims(self) -> tuple[str, str, str]: + """get or set the spatial dims, **in display order**""" return self._spatial_dims @spatial_dims.setter @@ -391,20 +398,18 @@ def _get_dw_slice(self, indices: dict[str, Any]) -> slice: return slice(start, stop, step) - def _apply_dw_window_func( - self, array: xr.DataArray | np.ndarray - ) -> xr.DataArray | np.ndarray: + def _apply_dw_window_func(self, array: ArrayProtocol) -> ArrayProtocol: """ Takes array where display window has already been applied and applies window functions on the `p` dim. Parameters ---------- - array: np.ndarray + array: ArrayProtocol array of shape: [l, display_window, 2 | 3] Returns ------- - np.ndarray + ArrayProtocol array with window functions applied along `p` dim """ if self.display_window == 0: @@ -456,17 +461,19 @@ def _apply_dw_window_func( return wf(windows, axis=-1)[:, ::step] # map user dims str to tuple of numerical dims - dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) + coor_dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) # windows will be of shape [n, (p - ws + 1), 1 | 2 | 3, ws] - windows = sliding_window_view(array[..., dims], ws, axis=-2).squeeze() + windows = sliding_window_view( + array[..., coor_dims], ws, axis=-2 + ).squeeze() # make a copy because we need to modify it array = array[:, start:stop].copy() # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary - array[..., dims] = wf(windows, axis=-1).reshape( - *array.shape[:-1], len(dims) + array[..., coor_dims] = wf(windows, axis=-1).reshape( + *array.shape[:-1], len(coor_dims) ) return array[:, ::step] @@ -475,20 +482,18 @@ def _apply_dw_window_func( return array[:, ::step] - def _apply_spatial_func( - self, array: xr.DataArray | np.ndarray - ) -> xr.DataArray | np.ndarray: + def _apply_spatial_func(self, array: ArrayProtocol) -> ArrayProtocol: if self.spatial_func is not None: return self.spatial_func(array) return array - def _finalize_(self, array: xr.DataArray | np.ndarray) -> xr.DataArray | np.ndarray: + def _finalize(self, array: ArrayProtocol) -> ArrayProtocol: return self._apply_spatial_func(self._apply_dw_window_func(array)) def _get_other_features( - self, data_slice: np.ndarray, dw_slice: slice - ) -> dict[str, np.ndarray]: + self, data_slice: ArrayProtocol, dw_slice: slice + ) -> dict[str, ArrayProtocol]: other = dict.fromkeys(self._other_features) for attr in self._other_features: val = getattr(self, attr) @@ -521,39 +526,26 @@ def _get_other_features( return other - def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + def get(self, indices: dict[str, Any]) -> AwaitedPositionData: """ slices through all slider dims and outputs an array that can be used to set graphic data Note that we do not use __getitem__ here since the index is a tuple specifying a single integer index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. """ - - if len(self.slider_dims) > 1: - # there are slider dims in addition to the datapoints_dim - window_output = self._apply_window_functions(indices).squeeze() - else: - # no slider dims, use all the data - window_output = self.data - - # verify window output only has the spatial dims - if not set(window_output.dims) == set(self.spatial_dims): - raise IndexError + # already squeezed and in the correct spatial_dims order + window_output = yield from self.get_window_output(indices) # get slice obj for display window dw_slice = self._get_dw_slice(indices) # data that will be used for the graphical representation - # a copy is made, if there were no window functions then this is a view of the original data - p_dim = self.spatial_dims[1] - # slice the datapoints to be displayed in the graphic using the display window slice - # transpose to match spatial dims order, get numpy array, this is a view - graphic_data = window_output.isel({p_dim: dw_slice}).transpose( - *self.spatial_dims - ) + # data are already squeezed & transposed w.r.t the spatial_dims order after get_window_output() + # p_dims is dim 1 + graphic_data = window_output[:, dw_slice] - data = self._finalize_(graphic_data).values + data = self._finalize(graphic_data) other = self._get_other_features(data, dw_slice) return { @@ -759,19 +751,22 @@ def spatial_dims(self) -> tuple[str, str, str]: def spatial_dims(self, dims: tuple[str, str, str]): self.processor.spatial_dims = dims # force re-render - self.indices = self.indices + self.set_indices(self.indices) @property def indices(self) -> dict[Hashable, Any]: return {d: self._ref_index[d] for d in self.processor.slider_dims} - @indices.setter @block_reentrance - def indices(self, indices): + @start_coroutine + def set_indices( + self, indices: dict[Hashable, Any], block: bool = True, timeout: float = 1.0 + ): if self.data is None: return - new_features = self.processor.get(indices) + new_features = yield from self._get_data_slice(indices) + data_slice = new_features["data"] # TODO: set other graphic features, colors, sizes, markers, etc. @@ -829,7 +824,9 @@ def indices(self, indices): self._last_x_range[:] = self.graphic._plot_area.x_range if self._linear_selector is not None: - with pause_events(self._linear_selector): # we don't want the linear selector change to update the indices + with pause_events( + self._linear_selector + ): # we don't want the linear selector change to update the indices self._linear_selector.limits = xr # linear selector acts on `p` dim self._linear_selector.selection = indices[ @@ -848,11 +845,12 @@ def _tooltip_handler(self, graphic, pick_info): p_index = pick_info["vertex_index"] return self.processor.tooltip_format(n_index, p_index) + @start_coroutine def _create_graphic(self): if self.data is None: return - new_features = self.processor.get(self.indices) + new_features = yield from self._get_data_slice(self.indices) data_slice = new_features["data"] # store any cmap, sizes, thickness, etc. to assign to new graphic @@ -963,7 +961,7 @@ def display_window(self, dw: int | float | None): self.processor.display_window = dw # force re-render - self.indices = self.indices + self.set_indices(self.indices) @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: @@ -1037,7 +1035,7 @@ def cmap(self, new: str | None): self._graphic.cmap = new self._cmap = new # force a re-render - self.indices = self.indices + self.set_indices(self.indices) @property def cmap_each(self) -> np.ndarray[str] | None: @@ -1103,7 +1101,7 @@ def markers(self, new: str | None): self.graphic.markers = new self._markers = new # force a re-render - self.indices = self.indices + self.set_indices(self.indices) @property def sizes(self) -> float | Sequence[float] | None: @@ -1122,7 +1120,7 @@ def sizes(self, new: float | Sequence[float] | None): self.graphic.sizes = new self._sizes = new # force a re-render - self.indices = self.indices + self.set_indices(self.indices) @property def thickness(self) -> float | Sequence[float] | None: @@ -1141,4 +1139,4 @@ def thickness(self, new: float | Sequence[float] | None): self.graphic.thickness = new self._thickness = new # force a re-render - self.indices = self.indices + self.set_indices(self.indices) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 1b94e1cbc..9278312fc 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -26,8 +26,6 @@ def __init__( self._tooltip_columns = None self._tooltip = False - self._dims = spatial_dims - super().__init__( data=data, dims=spatial_dims, @@ -41,11 +39,12 @@ def __init__( def data(self) -> pd.DataFrame: return self._data - def _validate_data(self, data: pd.DataFrame): + @data.setter + def data(self, data: pd.DataFrame): if not isinstance(data, pd.DataFrame): raise TypeError - return data + self._data= data @property def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: @@ -89,7 +88,7 @@ def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: [self.data[c][self._dw_slice] for c in col] ) - data = self._finalize_(graphic_data) + data = self._finalize(graphic_data) other = self._get_other_features(data, self._dw_slice) return { diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/utils.py b/fastplotlib/widgets/nd_widget/_nd_positions/utils.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 2855c7063..88d4ccd4b 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -139,8 +139,8 @@ def update(self): if fps_changed: if value < 1: value = 1 - if value > 50: - value = 50 + if value > 100: + value = 100 self._fps[dim] = value self._frame_time[dim] = 1 / value