Source code for fastplotlib.widgets.nd_widget._base

from __future__ import annotations

from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from functools import partial
import inspect
from numbers import Real
from pprint import pformat
import textwrap
from typing import Any, TYPE_CHECKING
import weakref

import numpy as np
from numpy.typing import ArrayLike

from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol
from ...graphics import Graphic
from ._async import run_in_thread_pool, run_sync, wait_for_future

if TYPE_CHECKING:
    from ._ndw_subplot import NDWSubplot

# must take arguments: array-like, `axis`: int, `keepdims`: bool
WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike]


def identity(index: int) -> int:
    return round(index)


def get_init_args(graphic_type: type[Graphic]) -> set[str]:
    """
    Named arguments of every ``__init__`` in the MRO of ``graphic_type``.

    Graphics take ``**kwargs`` and pass them up, so the arguments a type accepts are spread
    over its whole MRO: ``vmin`` is defined by ``ImageGraphic`` and ``rotation`` by ``Graphic``.
    """
    args = set()

    for klass in graphic_type.__mro__:
        init = klass.__dict__.get("__init__")

        if init is None:
            continue

        for name, param in inspect.signature(init).parameters.items():
            if name == "self" or param.kind in (
                param.VAR_KEYWORD,
                param.VAR_POSITIONAL,
            ):
                continue

            args.add(name)

    return args


def get_supported_kwargs(graphic_type: type[Graphic], **kwargs) -> dict[str, Any]:
    """
    Keep only the kwargs that ``graphic_type`` accepts.

    The graphic type can change at runtime, and passing ``vmin`` to a line, or ``thickness``
    to an image, raises.
    """
    accepted = get_init_args(graphic_type)

    # a collection forwards its kwargs to the graphics it holds
    child_type = getattr(graphic_type, "_child_type", None)
    if child_type is not None:
        accepted |= get_init_args(child_type)

    return {name: value for name, value in kwargs.items() if name in accepted}


[docs] class NDSlicer: def __init__( self, data: ArrayProtocol, dims: Sequence[str], display_dims: Sequence[str] | None, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): """ Base class for managing n-dimensional data and producing array slices. 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 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 ``display_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct a change in the ``ReferenceIndex`` and update the graphics. Parameters ---------- 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. dims: Sequence[str] names for each dimension in ``data``. Dimensions not listed in ``display_dims`` are treated as slider dimensions and **must** appear as keys in the parent ``NDWidget``'s ``ref_ranges`` Examples:: ("time", "depth", "row", "col") ("channels", "time", "xy") ("keypoints", "time", "xyz") A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method must operate as if these dimensions exist and return an array that matches the spatial dimensions. display_dims: Sequence[str] 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_maps: dict mapping dim_name -> Callable, an ArrayLike, or None Per-slider-dim mapping from reference-space values to local array indices. You may also provide an array of reference values for the slider dims, ``searchsorted`` is then used as the transform (ex: a timestamps array). If ``None`` and identity mapping is used, i.e. rounds the current reference index value to the nearest integer for array indexing. If a transform is not provided for a dim then the identity mapping is used. window_funcs: dict[ 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: * *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). The window function **must** return an array that has the same dimensions as specified in the NDSlicer, therefore the size of any dim along which a window_func was applied should reduce to ``1``. These dims must not be removed by the window_func. * *window_size* is in reference-space units (ex: 2.5 seconds). 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`` spatial_func: A function applied to the spatial slice *after* window_funcs right before rendering. It is given the slice in ``display_dims`` order, i.e. the array as it is rendered, and must return an array with those same dims. """ dims = tuple(dims) if not all([isinstance(d, str) for d in dims]): raise TypeError self._dims = dims self.data = data self.display_dims = display_dims self.slider_maps = slider_maps self.window_funcs = window_funcs self.window_order = window_order self.spatial_func = spatial_func # window_funcs and spatial_func are dispatched with an executor so they don't block the rendercanvas loop. # CUDA arrays run directly since they are inherently async already, the user is expected to provide CUDA # functions if the data arrays are CUDA (ex: torch functions, not numpy functions) self._executor = ThreadPoolExecutor( max_workers=1, thread_name_prefix=f"ndp-{id(self):x}" )
[docs] def close(self): """Shut down the thread pool.""" self._executor.shutdown(wait=False, cancel_futures=True)
@property 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). """ return self._data @data.setter def data(self, data: ArrayProtocol): # data can be set, but the dims must still match/have the same meaning 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 self._data = None return if not isinstance(data, ArrayProtocol): # 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") self._data = data @property def shape(self) -> dict[str, int]: """interpreted shape of the data""" return {d: n for d, n in zip(self.dims, self.data.shape)} @property def ndim(self) -> int: """number of dims""" return self.data.ndim @property 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 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 display_dims(self) -> tuple[str, ...]: """Subset of ``dims`` that are spatial (rendered) dimensions **in display order**.""" return self._display_dims @display_dims.setter def display_dims(self, sdims: Sequence[str]): for dim in sdims: if dim not in self.dims: raise KeyError self._display_dims = tuple(sdims) @property def display_dims_indices(self) -> tuple[int, ...]: """ The ordered spatial dim indices that correspond to the named spatial dims """ # the dims after slider dims have been squeezed out in the order that they # originate from the data array squeezed_dims = tuple(filter(lambda d: d in self.display_dims, self.dims)) return tuple(squeezed_dims.index(d) for d in self.display_dims) @property def tooltip(self) -> bool: """ whether or not a custom tooltip formatter method exists """ return False
[docs] def tooltip_format(self, *args) -> str | None: """ Override in subclass to format custom tooltips """ return None
@property def slider_dims(self) -> set[str]: """Slider dim names, ``set(dims) - set(display_dims)``, **unordered**""" return set(self.dims) - set(self.display_dims) @property def n_slider_dims(self): """number of slider dims, i.e. len(slider_dims)""" return len(self.slider_dims) @property def window_funcs( self, ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: """ Get or set the per-slider-dim window functions applied around the current slider position, ``{dim_name: (func, window_size)}``, ex: ``{"time": (np.mean, 2.5)}``. *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). It **must** return an array that has the same dims as the input, therefore the size of any dim along which it was applied should reduce to ``1``. These dims must not be removed by the window func. *window_size* is in reference-space units (ex: 2.5 seconds). A window func is only applied for the dims listed in :attr:`window_order`. Any dim without an entry is filled in with ``(None, None)``. """ return self._window_funcs @window_funcs.setter def window_funcs( self, window_funcs: ( dict[str, tuple[WindowFuncCallable | None, int | float | None] | None] | None ), ): if window_funcs is None: # tuple of (None, None) makes the checks easier in _apply_window_funcs self._window_funcs = {d: (None, None) for d in self.slider_dims} return for k in window_funcs.keys(): if k not in self.slider_dims: raise KeyError func = window_funcs[k][0] size = window_funcs[k][1] if func is None: pass elif callable(func): sig = inspect.signature(func) if "axis" not in sig.parameters or "keepdims" not in sig.parameters: raise TypeError( f"Each window function must take an `axis` and `keepdims` argument, " f"you passed: {func} with the following function signature: {sig}" ) else: raise TypeError( f"`window_funcs` must be a dict mapping dim names to a tuple of the window function callable and " f"window size, {'name': (func, size), ...}.\nYou have passed: {window_funcs}" ) if size is None: pass elif not isinstance(size, Real): raise TypeError elif size < 0: raise ValueError # fill in rest with None for d in self.slider_dims: if d not in window_funcs.keys(): window_funcs[d] = (None, None) self._window_funcs = window_funcs @property 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[str] | None): if order is None: self._window_order = tuple() return if not set(order).issubset(self.slider_dims): raise ValueError( f"each dimension in `window_order` must be a slider dim. You passed order: {order} " f"and the slider dims are: {self.slider_dims}" ) self._window_order = tuple(order) @property 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. It is given the slice in ``display_dims`` order, i.e. the array as it is rendered, and must return an array with those same dims. """ return self._spatial_func @spatial_func.setter def spatial_func( self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: if not callable(func) and func is not None: raise TypeError self._spatial_func = func @property def slider_maps(self) -> dict[str, Callable[[Any], int]]: """ Get or set the per-slider-dim mapping from reference-space values to local array indices, ``{dim_name: transform}``. A transform may be given as a Callable that takes a reference-space value and returns an array index, or as an array of reference values in which case its ``searchsorted`` is used as the transform (ex: a timestamps array). Any dim given ``None``, or not given at all, uses the identity mapping, i.e. the reference value is rounded to the nearest integer and used as the array index. """ return self._index_mappings @slider_maps.setter def slider_maps( self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None ): if maps is None: self._index_mappings = {d: identity for d in self.dims} return for d in maps.keys(): if d not in self.dims: raise KeyError( f"`index_mapping` provided for non-existent dimension: {d}, existing dims are: {self.dims}" ) if isinstance(maps[d], ArrayProtocol): # create a searchsorted mapping function automatically maps[d] = maps[d].searchsorted elif maps[d] is None: # assign identity mapping maps[d] = identity for d in self.dims: # fill in any unspecified maps with identity if d not in maps.keys(): maps[d] = identity self._index_mappings = maps def _ref_index_to_array_index(self, dim: str, ref_index: Any) -> int: # wraps slider_maps, clamps between 0 and the array size in this dim # ref-space -> local-array-index transform index = self.slider_maps[dim](ref_index) # 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[str, Any]) -> dict[str, slice]: """ 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: start: index - half_window stop: index + half_window step: 1 It then applies the slider_dim_transform to the start and stop to map these values from reference-space to the local array index, and then finally produces the slice object in local array indices. ex: if we have indices = {"time": 50.0}, a window size of 5.0s and the ``slider_dim_transform`` for time is based on a sampling rate of 10Hz, the window in ref units is [45.0, 55.0], and the final slice object would be ``slice(450, 550, 1)``. - If no window func is specified, the final slice just corresponds to that index as an int array-index. This exists separate from ``_apply_window_functions()`` because it is useful for debugging purposes. Parameters ---------- 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[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``. Raises ------ IndexError If ``indices`` are not provided for every ``slider_dim`` """ if set(indices.keys()) != set(self.slider_dims): raise IndexError( f"Must provide an index for all slider dims: {self.slider_dims}, you have provided: {indices.keys()}" ) indexer = dict() # get only slider dims which are not also spatial dims (example: p dim for positional data) # since `p` dim windowing is dealt with separately for positional data slider_dims = set(self.slider_dims) - set(self.display_dims) # go through each slider dim and accumulate slice objects for dim in slider_dims: # index for this dim in reference space index_ref = indices[dim] if dim not in self.window_funcs.keys(): wf, ws = None, None else: # get window func and size in reference units wf, ws = self.window_funcs[dim] # if a window function exists for this dim, and it's specified in the window order if (wf is not None) and (ws is not None) and (dim in self.window_order): # half window in reference units hw = ws / 2 # start in reference units start_ref = index_ref - hw # stop in ref units stop_ref = index_ref + hw # map start and stop ref to array indices start = self.slider_maps[dim](start_ref) stop = self.slider_maps[dim](stop_ref) # clamp within array bounds, stop is exclusive so it goes up to the size of the dim # and is kept above start so the window always has at least one element start = max(min(self.shape[dim] - 1, start), 0) stop = max(min(self.shape[dim], stop), start + 1) indexer[dim] = slice(start, stop, 1) else: # no window func for this dim, direct indexing # index mapped to array index index = self.slider_maps[dim](index_ref) # clamp within the bounds start = max(min(self.shape[dim] - 1, index), 0) # stop index is just the start index + 1 indexer[dim] = slice(start, start + 1, 1) return indexer async def _apply_window_functions( self, windowed_array: ArrayProtocol ) -> ArrayProtocol: """ apply window functions in the order specified by ``window_order``. For numpy arrays each func is dispatched to the per-slicer thread pool so it does not block the rendercanvas event loop. CUDA arrays are run directly since cuda functions (ex: torch) are already async. Parameters ---------- windowed_array: ArrayProtocol array that has been sliced with the desired windows at an index Returns ------- 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. """ # apply window funcs in the specified order for dim in self.window_order: if self.window_funcs[dim] is None: continue func, _ = self.window_funcs[dim] axis = self.dims.index(dim) # ``keepdims=True`` is critical, any "collapsed" dims will be of size ``1``. # Ex: if `array` is of shape [10, 512, 512] and we applied the np.mean() window func on the first dim # ``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. if isinstance(windowed_array, CudaArrayProtocol): windowed_array = func(windowed_array, axis=axis, keepdims=True) else: windowed_array = await run_in_thread_pool( self._executor, func, windowed_array, axis=axis, keepdims=True ) return windowed_array
[docs] async def get_window_output(self, indices: dict[str, Any]) -> ArrayProtocol: """ Take the data slice at the given indices and apply the window functions. Parameters ---------- indices: dict[str, Any] Reference-space value for each slider dim, ex: ``{"time": 46.397, "depth": 23.24}``. Must provide a value for every slider dim. Returns ------- ArrayProtocol Data slice with the window funcs applied and the slider dims, which are of size ``1`` after windowing, squeezed out. The remaining dims are the spatial dims, in the order they appear in ``dims``, **not** in ``display_dims`` display order. Subclasses transpose into display order in :meth:`get`. Raises ------ ValueError If the number of dims left after squeezing does not equal the number of spatial dims. """ # windowed slice if user set any window funcs windowed_slice = await self._get_raw_data_slice(indices) # convert to numpy array; CUDA arrays pass through and are converted at the end of the pipeline if not isinstance(windowed_slice, CudaArrayProtocol): windowed_slice = np.asarray(windowed_slice) # apply window funcs if len(self.slider_dims) > 0: windowed_slice = await self._apply_window_functions(windowed_slice) # squeeze out all slider dims which should now be size 1 # set(dims) - set(display_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.display_dims) ) windowed_slice = windowed_slice.squeeze(axis=slider_dims_int) if windowed_slice.ndim != len(self.display_dims): raise ValueError( f"windowed_slice.ndim != len(self.display_dims): {windowed_slice.ndim} != {len(self.display_dims)}" ) return windowed_slice
async def _get_raw_data_slice(self, indices: dict[str, Any]) -> ArrayProtocol: """ Base implementation to get the raw data slice from the wrapped array. Awaits any ``FutureProtocol`` returned by the underlying loader. CUDA arrays are returned as-is and converted to numpy at the end of the pipeline. """ if len(self.slider_dims) > 0: indexer = self._get_slider_dims_indexer(indices) # get the data slice w.r.t. the desired windows index_tuple = tuple(indexer.get(dim, slice(None)) for dim in self.dims) raw_slice = 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 = self.data[:] if isinstance(raw_slice, FutureProtocol): return await wait_for_future(raw_slice) return raw_slice
[docs] async def get(self, indices: dict[str, Any]) -> ArrayProtocol: """ Get the data slice to display at the given indices. **Must** be implemented in a subclass. Called by the ``NDGraphic`` whenever the ``ReferenceIndex`` updates. Implementations usually call :meth:`get_window_output`, apply the ``spatial_func``, and transpose into the ``display_dims`` display order. Parameters ---------- indices: dict[str, Any] Reference-space value for each slider dim, ex: ``{"time": 46.397, "depth": 23.24}``. Must provide a value for every slider dim. Returns ------- ArrayProtocol Data slice that maps to the graphical representation, with the dims given by ``display_dims`` in display order. """ raise NotImplementedError
# TODO: html and pretty text repr # # def _repr_html_(self) -> str: # return ndp_fmt_html(self) # # def _repr_mimebundle_(self, **kwargs) -> dict: # return { # "text/plain": self._repr_text_(), # "text/html": self._repr_html_(), # } def _repr_text_(self): if self.data is None: 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)} r = ( f"{self.__class__.__name__}\n" f"shape:\n\t{self.shape}\n" f"dims:\n\t{self.dims}\n" f"display_dims:\n\t{self.display_dims}\n" f"slider_dims:\n\t{self.slider_dims}\n" f"slider_maps:\n{textwrap.indent(pformat(self.slider_maps, width=120), prefix=tab)}\n" ) if len(wf) > 0: r += ( f"window_funcs:\n{textwrap.indent(pformat(wf, width=120), prefix=tab)}\n" f"window_order:\n\t{self.window_order}\n" ) if self.spatial_func is not None: r += f"spatial_func:\n\t{self.spatial_func}\n" return r
[docs] class NDGraphic: def __init__( self, nd_subplot: NDWSubplot, name: str | None, ): """ Base class that pairs an :class:`NDSlicer` with a ``Graphic``. Subclass to support a new graphical representation. The ``NDSlicer`` produces the data slice for the current index and the ``NDGraphic`` writes it to the ``Graphic``. When the ``ReferenceIndex`` of the parent ``NDWidget`` changes, it schedules ``_set_indices_()`` on every ``NDGraphic`` that has the dim that changed. Subclasses must implement :meth:`_create_graphic` and ``_set_indices_()``, and the :attr:`slicer`, :attr:`graphic`, :attr:`indices` and :attr:`display_dims` properties. Most of the slicer properties are aliased here so users can reach them from the ``NDGraphic``, and setting one of those aliases re-renders the current slice. Parameters ---------- nd_subplot: NDWSubplot parent NDWSubplot the NDGraphic is in name: str or None Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. """ self._nd_subplot = nd_subplot self._name = name self._graphic: Graphic | None = None # used to indicate that the NDGraphic should ignore any requests to update the indices. # used by block_indices_ctx context manager, usecase is when the LinearSelector on timeseries # NDGraphic changes the selection, it shouldn't change the graphic that it is on top of! Would # also cause recursion. ReferenceIndex._render_indices checks this flag at scheduling time. self._block_indices = False # user settable bool to make the graphic unresponsive to change in the ReferenceIndex self._pause = False # the indices that current graphic data reflects self._last_indices = None async def _create_graphic(self): raise NotImplementedError @property def pause(self) -> bool: """ Get or set whether this graphic ignores changes in the ``ReferenceIndex``. If ``True``, it stops updating until it is set back to ``False``, the other graphics in the widget are unaffected. """ return self._pause @pause.setter def pause(self, val: bool): self._pause = bool(val) @property def name(self) -> str | None: """name given to the NDGraphic""" return self._name @property def slicer(self) -> NDSlicer: """NDSlicer that manages the data and produces data slices to display""" raise NotImplementedError @property def graphic(self) -> Graphic: """Underlying Graphic object used to display the current data slice""" raise NotImplementedError def _set_graphic_right_click(self): """ Set the popup that a right-click on the graphic opens, which shows this NDGraphic's settings. Called whenever the graphic is created, since switching ``graphic_type`` or changing the shape of the data replaces the ``Graphic``, and its popup with it. To replace this popup set your own on ``ndgraphic.graphic``, or add to it with ``ndgraphic.graphic.append_imgui_right_click()``. """ # `_ui` imports the NDGraphic subclasses, so it cannot be imported at the header from ._ui import draw_nd_graphic_ui # weak, this NDGraphic owns the graphic that owns the popup, so a strong reference here would # put them in a cycle and the graphic would outlive its last reference self_ref = weakref.ref(self) def draw(): ndg = self_ref() if ndg is not None: draw_nd_graphic_ui(ndg) self.graphic.set_imgui_right_click(draw) @property def indices_displayed(self) -> dict[str, Any]: """the indices that the graphic currently represents""" return self._last_indices @property def indices(self) -> dict[str, Any]: """the current index of each slider dim in reference-space units, from the ``ReferenceIndex``""" raise NotImplementedError async def _set_indices_(self, indices: dict[str, Any] = None): """ Get the data slice for the index from the slicer and write it to the graphic. If indices is None, it uses the latest indices from the ReferenceIndex. Otherwise it uses the indices passed when the update was scheduled. Semi-private: only ``ReferenceIndex`` should call this. _create_graphic uses `run_sync` to run it sync """ pass # aliases for easier access to slicer properties @property def data(self) -> Any: """ 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). """ return self.slicer.data @data.setter def data(self, data: Any): self.slicer.data = data # create a new graphic when data has changed if self.graphic is not None: # it is already None if NDGraphic was initialized with no data self._nd_subplot.subplot.delete_graphic(self.graphic) self._graphic = None run_sync(self._create_graphic()) # force a render run_sync(self._set_indices_()) @property def shape(self) -> dict[str, int]: """interpreted shape of the data""" return self.slicer.shape @property def ndim(self) -> int: """number of dims""" return self.slicer.ndim @property def dims(self) -> tuple[str, ...]: """dim names""" return self.slicer.dims @property def display_dims(self) -> tuple[str, ...]: """get or set the spatial dims, i.e. the rendered dims, **in display order**""" # number of spatial dims for positional data is always 3 # for image is 2 or 3, so it must be implemented in subclass raise NotImplementedError @property def slider_dims(self) -> set[str]: """the slider dims""" return self.slicer.slider_dims @property def slider_maps(self) -> dict[str, Callable[[Any], int]]: """ Get or set the per-slider-dim mapping from reference-space values to local array indices, ``{dim_name: transform}``. Setting it re-renders the current data slice. A transform may be given as a Callable that takes a reference-space value and returns an array index, or as an array of reference values in which case its ``searchsorted`` is used as the transform (ex: a timestamps array). Any dim given ``None``, or not given at all, uses the identity mapping, i.e. the reference value is rounded to the nearest integer and used as the array index. """ return self.slicer.slider_maps @slider_maps.setter def slider_maps( self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None ): self.slicer.slider_maps = maps # force a render run_sync(self._set_indices_()) @property def window_funcs( self, ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: """ Get or set the per-slider-dim window functions applied around the current slider position, ``{dim_name: (func, window_size)}``, ex: ``{"time": (np.mean, 2.5)}``. Setting it re-renders the current data slice. *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). It **must** return an array that has the same dims as the input, therefore the size of any dim along which it was applied should reduce to ``1``. These dims must not be removed by the window func. *window_size* is in reference-space units (ex: 2.5 seconds). A window func is only applied for the dims listed in :attr:`window_order`. Any dim without an entry is filled in with ``(None, None)``. """ return self.slicer.window_funcs @window_funcs.setter def window_funcs( self, window_funcs: ( dict[str, tuple[WindowFuncCallable | None, int | float | None] | None] | None ), ): self.slicer.window_funcs = window_funcs # force a render run_sync(self._set_indices_()) @property def window_order(self) -> tuple[str, ...]: """get or set dimension order in which window functions are applied""" return self.slicer.window_order @window_order.setter def window_order(self, order: tuple[str] | None): self.slicer.window_order = order # force a render run_sync(self._set_indices_()) @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: """ Get or set the function applied to the spatial slice *after* the window funcs, right before rendering. It is given the slice in ``display_dims`` order, i.e. the array as it is rendered, and must return an array with those same dims. Setting it re-renders the current data slice. """ return self.slicer.spatial_func @spatial_func.setter def spatial_func( self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: self.slicer.spatial_func = func # force a render run_sync(self._set_indices_()) # def _repr_text_(self) -> str: # return ndg_fmt_text(self) # # def _repr_html_(self) -> str: # return ndg_fmt_html(self) # # def _repr_mimebundle_(self, **kwargs) -> dict: # return { # "text/plain": self._repr_text_(), # "text/html": self._repr_html_(), # } def _repr_text_(self): return f"graphic: {self.graphic.__class__.__name__}\n" f"slicer:\n{self.slicer}"
@contextmanager def block_indices_ctx(*ndgraphics: NDGraphic): """ Context manager for pausing NDGraphics from updating indices """ for ndg in ndgraphics: ndg._block_indices = True try: yield except Exception as e: raise e from None # indices setter has raised, the line above and the lines below are probably more relevant! finally: for ndg in ndgraphics: ndg._block_indices = False