From dd502e4a9cc0226c7ef60b4cf59a8b5cbb0fb6b5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 28 Jun 2025 21:11:20 -0400 Subject: [PATCH 01/81] start separating iw plotting and array logic --- fastplotlib/widgets/image_widget/__init__.py | 1 + fastplotlib/widgets/image_widget/_array.py | 79 ++++++++++++++++++++ fastplotlib/widgets/image_widget/_widget.py | 3 +- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 fastplotlib/widgets/image_widget/_array.py diff --git a/fastplotlib/widgets/image_widget/__init__.py b/fastplotlib/widgets/image_widget/__init__.py index 70a1aa8ae..2c217038e 100644 --- a/fastplotlib/widgets/image_widget/__init__.py +++ b/fastplotlib/widgets/image_widget/__init__.py @@ -2,6 +2,7 @@ if IMGUI: from ._widget import ImageWidget + from ._array import ImageWidgetArray else: diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py new file mode 100644 index 000000000..bfc8c8c92 --- /dev/null +++ b/fastplotlib/widgets/image_widget/_array.py @@ -0,0 +1,79 @@ +import numpy as np +from numpy.typing import NDArray +from typing import Literal, Callable + + +class ImageWidgetArray: + def __init__( + self, + data: NDArray, + window_functions: dict = None, + frame_apply: Callable = None, + display_dims: Literal[2, 3] = 2, + dim_names: str = "tzxy", + ): + self._data = data + self._window_functions = window_functions + self._frame_apply = frame_apply + self._dim_names = dim_names + + for k in self._window_functions: + if k not in dim_names: + raise KeyError + + self._display_dims = display_dims + + @property + def data(self) -> NDArray: + return self._data + + @data.setter + def data(self, data: NDArray): + self._data = data + + @property + def window_functions(self) -> dict | None: + return self._window_functions + + @window_functions.setter + def window_functions(self, wf: dict | None): + self._window_functions = wf + + @property + def frame_apply(self, fa: Callable | None): + self._frame_apply = fa + + @frame_apply.setter + def frame_apply(self) -> Callable | None: + return self._frame_apply + + def _apply_window_functions(self, array: NDArray, key): + if self.window_functions is not None: + for dim_name in self._window_functions.keys(): + dim_index = self._dim_names.index(dim_name) + + window_size = self.window_functions[dim_name][1] + half_window_size = int((window_size - 1) / 2) + + max_bound = self._data.shape[dim_index] + + window_indices = range() + + else: + array = array[key] + + return array + + def __getitem__(self, key): + data = self._data + + + data = self._apply_window_functions(data, key) + + if self.frame_apply is not None: + data = self.frame_apply(data) + + if data.ndim != self._display_dims: + raise ValueError + + return data diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 650097951..479e45914 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -1,4 +1,3 @@ -from copy import deepcopy from typing import Callable from warnings import warn @@ -11,6 +10,7 @@ from ...utils import calculate_figure_shape, quick_min_max from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders +from ._array import ImageWidgetArray # Number of dimensions that represent one image/one frame @@ -289,6 +289,7 @@ def _get_n_scrollable_dims(self, curr_arr: np.ndarray, rgb: bool) -> list[int]: def __init__( self, data: np.ndarray | list[np.ndarray], + array_types: ImageWidgetArray | list[ImageWidgetArray] = ImageWidgetArray, window_funcs: dict[str, tuple[Callable, int]] = None, frame_apply: Callable | dict[int, Callable] = None, figure_shape: tuple[int, int] = None, From 4f1fcd9a963d5e0e63c610958989a229a4fc6f8f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 Aug 2025 02:24:04 -0400 Subject: [PATCH 02/81] some more basics down --- fastplotlib/widgets/image_widget/_array.py | 220 +++++++++++++++++---- 1 file changed, 185 insertions(+), 35 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index bfc8c8c92..54d26fa57 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -1,27 +1,75 @@ import numpy as np from numpy.typing import NDArray from typing import Literal, Callable +from warnings import warn class ImageWidgetArray: def __init__( self, data: NDArray, - window_functions: dict = None, - frame_apply: Callable = None, - display_dims: Literal[2, 3] = 2, - dim_names: str = "tzxy", + rgb: bool = False, + window_function: Callable = None, + window_size: dict[str, int] = None, + frame_function: Callable = None, + n_display_dims: Literal[2, 3] = 2, + dim_names: tuple[str] = None, ): + """ + + Parameters + ---------- + data: NDArray + array-like data, must have 2 or more dimensions + + window_function: Callable, optional + function to apply to a window of data around the current index. + The callable must take an `axis` kwarg. + + window_size: dict[str, int] + dict of window sizes for each dim, maps dim names -> window size. + Example: {"t": 5, "z": 3}. + + If a dim is not provided the window size is 0 for that dim, i.e. no window is taken along that dimension + + frame_function + n_display_dims + dim_names + """ self._data = data - self._window_functions = window_functions - self._frame_apply = frame_apply + + self._window_size = window_function + self._window_size = window_size + + self._frame_function = frame_function + + self._rgb = rgb + + # default dim names for mn, tmn, and tzmn, ignore rgb dim if present + if dim_names is None: + if data.ndim == (2 + int(self.rgb)): + dim_names = ("m", "n") + + elif data.ndim == (3 + int(self.rgb)): + dim_names = ("t", "m", "n") + + elif data.ndim == (4 + int(self.rgb)): + dim_names = ("t", "z", "m", "n") + + else: + # create a tuple of str numbers for each time, ex: ("0", "1", "2", "3", "4", "5", "6") + dim_names = tuple(map(str, range(data.ndim))) + self._dim_names = dim_names - for k in self._window_functions: + for k in self._window_size: if k not in dim_names: raise KeyError - self._display_dims = display_dims + if n_display_dims not in (2, 3): + raise ValueError("`n_display_dims` must be an with a value of 2 or 3") + + self._n_display_dims = n_display_dims @property def data(self) -> NDArray: @@ -32,48 +80,150 @@ def data(self, data: NDArray): self._data = data @property - def window_functions(self) -> dict | None: - return self._window_functions + def rgb(self) -> bool: + return self._rgb + + @property + def ndim(self) -> int: + return self.data.ndim + + @property + def n_scrollable_dims(self) -> int: + return self.ndim - 2 - int(self.rgb) + + @property + def n_display_dims(self) -> int: + return self._n_display_dims + + @property + def dim_names(self) -> tuple[str]: + return self._dim_names + + @property + def window_function(self) -> Callable | None: + return self._window_size - @window_functions.setter - def window_functions(self, wf: dict | None): - self._window_functions = wf + @window_function.setter + def window_function(self, func: Callable | None): + self._window_size = func @property - def frame_apply(self, fa: Callable | None): - self._frame_apply = fa + def window_size(self) -> dict | None: + """dict of window sizes for each dim""" + return self._window_size - @frame_apply.setter - def frame_apply(self) -> Callable | None: - return self._frame_apply + @window_size.setter + def window_size(self, size: dict): + for k in list(size.keys()): + if k not in self.dim_names: + raise ValueError(f"specified window key: `k` not present in array with dim names: {self.dim_names}") - def _apply_window_functions(self, array: NDArray, key): - if self.window_functions is not None: - for dim_name in self._window_functions.keys(): - dim_index = self._dim_names.index(dim_name) + if not isinstance(size[k], int): + raise TypeError("window size values must be integers") - window_size = self.window_functions[dim_name][1] - half_window_size = int((window_size - 1) / 2) + if size[k] < 0: + raise ValueError(f"window size values must be greater than 2 and odd numbers") - max_bound = self._data.shape[dim_index] + if size[k] == 0: + # remove key + warn(f"specified window size of 0 for dim: {k}, removing dim from windows") + size.pop(k) - window_indices = range() + elif size[k] % 2 != 0: + # odd number, add 1 + warn(f"specified even number for window size of dim: {k}, adding one to make it even") + size[k] += 1 + self._window_size = size + + @property + def frame_function(self) -> Callable | None: + return self._frame_function + + @frame_function.setter + def frame_function(self, fa: Callable | None): + self._frame_function = fa + + def _apply_window_function(self, index: dict[str, int]): + if self.n_scrollable_dims == 0: + # 2D image, return full data + # TODO: would be smart to handle this in ImageWidget so + # that Texture buffer is not updated when it doesn't change!! + return self.data + + if self.window_size is None: + window_size = dict() else: - array = array[key] + window_size = self.window_size + + # create a slice object for every dim except the last 2, or 3 (if rgb) + multi_slice = list() + axes = list() + + for dim_number in range(self.n_scrollable_dims): + # get str name + dim_name = self.dim_names[dim_number] + + # don't go beyond max bound + max_bound = self.data.shape[dim_number] - return array + # check if a window is specific for this dim + if dim_name in window_size.keys(): + size = window_size[dim_name] + half_size = int((size - 1) / 2) - def __getitem__(self, key): - data = self._data + # create slice obj for this dim using this window + start = max(0, index[dim_name] - half_size) # start index, min allowed value is 0 + stop = min(max_bound, index[dim_name] + half_size) + + s = slice(start, stop) + multi_slice.append(s) + # add to axes list for window function + axes.append(dim_number) + else: + # no window size is specified for this scrollable dim, directly use integer index + multi_slice.append(index[dim_name]) - data = self._apply_window_functions(data, key) + # get sliced array + array_sliced = self.data[tuple(multi_slice)] - if self.frame_apply is not None: - data = self.frame_apply(data) + if self.window_function is not None: + # apply window function + return self.window_function(array_sliced, axis=axes) + + # not window function, return sliced array + return array_sliced + + def get(self, index: dict[str, int]): + """ + Get the data at the given index, process data through the window function and frame function. + + Note that we do not use __getitem__ here since the index is a dict specifying a single integer + index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. + + Parameters + ---------- + index: dict[str, int] + Get the processed data at this index. + Example: get({"t": 1000, "z" 3}) + + """ + + if set(index.keys()) != set(self.dim_names): + raise ValueError( + f"Must specify index for every dim, you have specified an index: {index}\n" + f"All dim names are: {self.dim_names}" + ) + + window_output = self._apply_window_function(index) + + if self.frame_function is not None: + frame_output = self.frame_function(window_output) + else: + frame_output = window_output - if data.ndim != self._display_dims: + if frame_output.ndim != self.n_display_dims: raise ValueError - return data + return frame_output From 20f1878533de8dc09f42d0cebe6a2de6675bc126 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 Aug 2025 02:29:06 -0400 Subject: [PATCH 03/81] comment --- fastplotlib/widgets/image_widget/_array.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index 54d26fa57..fb4f4ae3a 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -152,6 +152,8 @@ def _apply_window_function(self, index: dict[str, int]): return self.data if self.window_size is None: + # for simplicity, so we can use the same for loop below to slice the array + # regardless of whether window_functions are specified or not window_size = dict() else: window_size = self.window_size @@ -167,7 +169,7 @@ def _apply_window_function(self, index: dict[str, int]): # don't go beyond max bound max_bound = self.data.shape[dim_number] - # check if a window is specific for this dim + # check if a window is specified for this dim if dim_name in window_size.keys(): size = window_size[dim_name] half_size = int((size - 1) / 2) @@ -175,7 +177,7 @@ def _apply_window_function(self, index: dict[str, int]): # create slice obj for this dim using this window start = max(0, index[dim_name] - half_size) # start index, min allowed value is 0 stop = min(max_bound, index[dim_name] + half_size) - + s = slice(start, stop) multi_slice.append(s) From 330f7f03349464810d6451687ee61ad1f1008ee3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 17 Aug 2025 01:11:21 -0400 Subject: [PATCH 04/81] collapse into just having a window function, no frame_function --- fastplotlib/widgets/image_widget/_array.py | 40 ++++++++-------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index fb4f4ae3a..ad70548e6 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -9,9 +9,8 @@ def __init__( self, data: NDArray, rgb: bool = False, - window_function: Callable = None, + process_function: Callable = None, window_size: dict[str, int] = None, - frame_function: Callable = None, n_display_dims: Literal[2, 3] = 2, dim_names: tuple[str] = None, ): @@ -22,7 +21,7 @@ def __init__( data: NDArray array-like data, must have 2 or more dimensions - window_function: Callable, optional + process_function: Callable, optional function to apply to a window of data around the current index. The callable must take an `axis` kwarg. @@ -32,17 +31,17 @@ def __init__( If a dim is not provided the window size is 0 for that dim, i.e. no window is taken along that dimension - frame_function - n_display_dims - dim_names + n_display_dims: int, 2 or 3, default 2 + number of display dimensions + + dim_names: tuple[str], optional + dimension names as a tuple of strings, ex: ("t", "z", "x", "y") """ self._data = data - self._window_size = window_function + self._window_size = process_function self._window_size = window_size - self._frame_function = frame_function - self._rgb = rgb # default dim names for mn, tmn, and tzmn, ignore rgb dim if present @@ -136,14 +135,6 @@ def window_size(self, size: dict): self._window_size = size - @property - def frame_function(self) -> Callable | None: - return self._frame_function - - @frame_function.setter - def frame_function(self, fa: Callable | None): - self._frame_function = fa - def _apply_window_function(self, index: dict[str, int]): if self.n_scrollable_dims == 0: # 2D image, return full data @@ -220,12 +211,11 @@ def get(self, index: dict[str, int]): window_output = self._apply_window_function(index) - if self.frame_function is not None: - frame_output = self.frame_function(window_output) - else: - frame_output = window_output - - if frame_output.ndim != self.n_display_dims: - raise ValueError + if window_output.ndim != self.n_display_dims: + raise ValueError( + f"Output of the `process_function` must match the number of display dims." + f"`process_function` returned an array with {window_output.ndim} dims, " + f"expected {self.n_display_dims} dims" + ) - return frame_output + return window_output From 7aa90b9d8806dbcd18a2f1aaf8c563f51c4c1e8e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 5 Nov 2025 03:24:26 -0500 Subject: [PATCH 05/81] progress --- fastplotlib/widgets/image_widget/_array.py | 406 +++++++++++++-------- 1 file changed, 258 insertions(+), 148 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index ad70548e6..9e35eff69 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -1,221 +1,331 @@ import numpy as np -from numpy.typing import NDArray +from numpy.typing import ArrayLike from typing import Literal, Callable from warnings import warn +from ...utils import subsample_array -class ImageWidgetArray: + +WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] + + +class NDImageView: def __init__( self, - data: NDArray, - rgb: bool = False, - process_function: Callable = None, - window_size: dict[str, int] = None, + data: ArrayLike, n_display_dims: Literal[2, 3] = 2, - dim_names: tuple[str] = None, + rgb: bool = False, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_sizes: tuple[int | None, ...] = None, + window_order: tuple[int, ...] = None, + finalizer_func: Callable[[ArrayLike], ArrayLike] = None, ): """ + A dynamic view of an ND image that supports computing window functions, and functions over spatial dimensions. Parameters ---------- - data: NDArray + data: ArrayLike array-like data, must have 2 or more dimensions - process_function: Callable, optional - function to apply to a window of data around the current index. - The callable must take an `axis` kwarg. - - window_size: dict[str, int] - dict of window sizes for each dim, maps dim names -> window size. - Example: {"t": 5, "z": 3}. - - If a dim is not provided the window size is 0 for that dim, i.e. no window is taken along that dimension - n_display_dims: int, 2 or 3, default 2 number of display dimensions - dim_names: tuple[str], optional - dimension names as a tuple of strings, ex: ("t", "z", "x", "y") - """ - self._data = data - - self._window_size = process_function - self._window_size = window_size + rgb: bool, default False + whether the image data is RGB(A) or not - self._rgb = rgb + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable, optional + A function or a ``tuple`` of functions that are applied to a rolling window of the data. - # default dim names for mn, tmn, and tzmn, ignore rgb dim if present - if dim_names is None: - if data.ndim == (2 + int(self.rgb)): - dim_names = ("m", "n") + You can provide unique window functions for each dimension. If you want to apply a window function + only to a subset of the dimensions, put ``None`` to indicate no window function for a given dimension. - elif data.ndim == (3 + int(self.rgb)): - dim_names = ("t", "m", "n") + A "window function" must take ``axis`` argument, which is an ``int`` that specifies the axis along which + the window function is applied. It must also take a ``keepdims`` argument which is a ``bool``. The window + function **must** return an array that has the same number of dimensions as the original ``data`` array, + therefore the size of the dimension along which the window was applied will reduce to ``1``. - elif data.ndim == (4 + int(self.rgb)): - dim_names = ("t", "z", "m", "n") + The output array-like type from a window function **must** support a ``.squeeze()`` method, but the + function itself should NOT squeeze the output array. - else: - # create a tuple of str numbers for each time, ex: ("0", "1", "2", "3", "4", "5", "6") - dim_names = tuple(map(str, range(data.ndim))) + window_sizes: tuple[int | None, ...], optional + ``tuple`` of ``int`` that specifies the window size for each dimension. - self._dim_names = dim_names + window_order: tuple[int, ...] | None, optional + order in which to apply the window functions, by default just applies it from the left-most dim to the + right-most slider dim. - for k in self._window_size: - if k not in dim_names: - raise KeyError + finalizer_func: Callable[[ArrayLike], ArrayLike] | None, optional + A function that the data is put through after the window functions (if present) before being displayed. - if n_display_dims not in (2, 3): - raise ValueError("`n_display_dims` must be an with a value of 2 or 3") + """ + self._data = data self._n_display_dims = n_display_dims + self._rgb = rgb + + self._window_funcs = window_funcs + self._window_sizes = window_sizes + self._window_order = window_order + + self._finalizer_func = finalizer_func @property - def data(self) -> NDArray: + def data(self) -> ArrayLike: + """get or set the data array""" return self._data @data.setter - def data(self, data: NDArray): + def data(self, data: ArrayLike): + # check that all array-like attributes are present + required_attrs = ["shape", "ndim", "__getitem__"] + for attr in required_attrs: + if not hasattr(data, attr): + raise TypeError( + f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" + f"{required_attrs}" + ) self._data = data @property def rgb(self) -> bool: + """whether or not the data is rgb(a)""" return self._rgb @property - def ndim(self) -> int: - return self.data.ndim + def n_slider_dims(self) -> int: + """number of slider dimensions""" + return self.data.ndim - self.n_display_dims - int(self.rgb) @property - def n_scrollable_dims(self) -> int: - return self.ndim - 2 - int(self.rgb) + def slider_dims(self) -> tuple[int, ...] | None: + """tuple indicating the slider dimension indices""" + if self.n_slider_dims == 0: + return None + + return tuple(range(self.n_slider_dims)) @property - def n_display_dims(self) -> int: + def n_display_dims(self) -> Literal[2 , 3]: + """get or set the number of display dimensions, `2` for 2D image and `3` for volume images""" return self._n_display_dims + @n_display_dims.setter + def n_display_dims(self, n: Literal[2, 3]): + if n not in (2, 3): + raise ValueError("`n_display_dims` must be an with a value of 2 or 3") + self._n_display_dims = n + @property - def dim_names(self) -> tuple[str]: - return self._dim_names + def display_dims(self) -> tuple[int, int] | tuple[int, int, int]: + """tuple indicating the diplay dimension indices""" + return tuple(range(self.data.ndim))[self.n_slider_dims:] @property - def window_function(self) -> Callable | None: - return self._window_size + def window_funcs(self) -> tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None: + """get or set window functions, see docstring for details""" + return self._window_funcs + + @window_funcs.setter + def window_funcs(self, window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None): + if window_funcs is None: + self._window_funcs = None + return + + # if all are None + if all([f is None for f in window_funcs]): + self._window_funcs = None + return + + if not all([callable(f) or f is None for f in funcs]): + raise TypeError( + f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {window_funcs}" + ) + + if not len(window_funcs) == self.n_slider_dims: + raise IndexError( + f"number of `window_funcs` must be the same as the number of slider dims, " + f"i.e. `data.ndim` - n_display_dims, your data array has {data.ndim} dimensions " + f"and you passed {len(window_funcs)} `window_funcs`: {window_funcs}" + ) - @window_function.setter - def window_function(self, func: Callable | None): - self._window_size = func + self._window_funcs = window_funcs @property - def window_size(self) -> dict | None: - """dict of window sizes for each dim""" - return self._window_size - - @window_size.setter - def window_size(self, size: dict): - for k in list(size.keys()): - if k not in self.dim_names: - raise ValueError(f"specified window key: `k` not present in array with dim names: {self.dim_names}") - - if not isinstance(size[k], int): - raise TypeError("window size values must be integers") - - if size[k] < 0: - raise ValueError(f"window size values must be greater than 2 and odd numbers") - - if size[k] == 0: - # remove key - warn(f"specified window size of 0 for dim: {k}, removing dim from windows") - size.pop(k) - - elif size[k] % 2 != 0: - # odd number, add 1 - warn(f"specified even number for window size of dim: {k}, adding one to make it even") - size[k] += 1 - - self._window_size = size - - def _apply_window_function(self, index: dict[str, int]): - if self.n_scrollable_dims == 0: - # 2D image, return full data - # TODO: would be smart to handle this in ImageWidget so - # that Texture buffer is not updated when it doesn't change!! - return self.data - - if self.window_size is None: - # for simplicity, so we can use the same for loop below to slice the array - # regardless of whether window_functions are specified or not - window_size = dict() - else: - window_size = self.window_size + def window_sizes(self) -> tuple[int | None, ...] | None: + """get or set window sizes used for the corresponding window functions, see docstring for details""" + return self._window_sizes + + @window_sizes.setter + def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): + if window_sizes is None: + self._window_sizes = None + return + + # if all are None + if all([w is None for w in window_sizes]): + self._window_sizes = None + return + + if not all([isinstance(w, (int)) or w is None for w in window_sizes]): + raise TypeError( + f"`window_sizes` must be of type: tuple[int | None, ...] | int | None, you have passed: {window_sizes}" + ) + + if not len(window_sizes) == self.n_slider_dims: + raise window_sizes( + f"number of `window_sizes` must be the same as the number of slider dims, " + f"i.e. `data.ndim` - n_display_dims, your data array has {data.ndim} dimensions " + f"and you passed {len(window_sizes)} `window_sizes`: {window_sizes}" + ) + + # make all window sizes are valid numbers + _window_sizes = list() + for i, w in enumerate(window_sizes): + if w is None: + _window_sizes.append(None) + continue + + if w < 0: + raise ValueError(f"negative window size passed, all `window_sizes` must be positive " + f"integers or `None`, you passed: {_window_sizes}") + + if w in (0, 1): + # this is not a real window, set as None + w = None + + if w % 2 == 0: + # odd window sizes makes most sense + warn(f"provided even window size: {w} in dim: {i}, adding `1` to make it odd") + w += 1 - # create a slice object for every dim except the last 2, or 3 (if rgb) - multi_slice = list() - axes = list() + _window_sizes.append(w) - for dim_number in range(self.n_scrollable_dims): - # get str name - dim_name = self.dim_names[dim_number] + self._window_sizes = tuple(window_sizes) - # don't go beyond max bound - max_bound = self.data.shape[dim_number] + @property + def window_order(self) -> tuple[int, ...] | None: + """get or set dimension order in which window functions are applied""" + return self._window_order - # check if a window is specified for this dim - if dim_name in window_size.keys(): - size = window_size[dim_name] - half_size = int((size - 1) / 2) + @window_order.setter + def window_order(self, order: tuple[int] | None): + if order is not None: + if not all([d <= self.n_slider_dims for d in order]): + raise IndexError( + f"all `window_order` entries must be <= n_slider_dims\n" + f"`n_slider_dims` is: {self.n_slider_dims}, you have passed `window_order`: {order}" + ) - # create slice obj for this dim using this window - start = max(0, index[dim_name] - half_size) # start index, min allowed value is 0 - stop = min(max_bound, index[dim_name] + half_size) + if not all([d >= 0 for d in order]): + raise IndexError(f"all `window_order` entires must be >= 0, you have passed: {order}") - s = slice(start, stop) - multi_slice.append(s) + self._window_order = order - # add to axes list for window function - axes.append(dim_number) + @property + def finalizer_func(self) -> Callable[[ArrayLike], ArrayLike] | None: + """get or set a finalizer function, see docstring for details""" + return self._finalizer_func + + @finalizer_func.setter + def finalizer_func(self, func: Callable[[ArrayLike], ArrayLike] | None): + self._finalizer_func = func + + def _apply_window_function(self, index: tuple[int, ...]) -> ArrayLike: + """applies the window functions for each dimension specified""" + # window size for each dim + winds = self._window_sizes + # window function for each dim + funcs = self._window_funcs + + if winds is None or funcs is None: + # no window funcs or window sizes, just slice data and return + return self.data[index] + + # order in which window funcs are applied + order = self._window_order + + if order is not None: + # remove any entries in `window_order` where the specified dim + # has a window function or window size specified as `None` + # example: + # window_sizes = (3, 2) + # window_funcs = (np.mean, None) + # order = (0, 1) + # `1` is removed from the order since that window_func is `None` + order = tuple(d for d in order if windows[d] is not None and funcs[d] is not None) + else: + # sequential order + order = tuple(range(self.n_slider_dims)) + + # the final indexer which will be used on the data array + indexer = list() + + for i, w, f in zip(index, winds, funcs): + if (w is not None) and (f is not None): + # specify slice window if both window size and function for this dim are not None + hw = int((w - 1) / 2) # half window + # start, stop, step + s = slice(i - hw, i + hw, 1) else: - # no window size is specified for this scrollable dim, directly use integer index - multi_slice.append(index[dim_name]) + s = slice(i, i + 1, 1) + indexer.append(s) - # get sliced array - array_sliced = self.data[tuple(multi_slice)] + # apply indexer to slice data with the specified windows + data_sliced = self.data[tuple(indexer)] - if self.window_function is not None: - # apply window function - return self.window_function(array_sliced, axis=axes) + # finally apply the window functions in the specified order + for dim in order: + f = funcs[dim] - # not window function, return sliced array - return array_sliced + data_sliced = f(data_sliced, axis=dim, keepdims=True) - def get(self, index: dict[str, int]): + return data_sliced + + def get(self, index: tuple[int, ...]): """ - Get the data at the given index, process data through the window function and frame function. + Get the data at the given index, process data through the window functions. - Note that we do not use __getitem__ here since the index is a dict specifying a single integer + 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. Parameters ---------- - index: dict[str, int] + index: tuple[int, ...] Get the processed data at this index. - Example: get({"t": 1000, "z" 3}) + Example: get((100, 5)) """ - - if set(index.keys()) != set(self.dim_names): - raise ValueError( - f"Must specify index for every dim, you have specified an index: {index}\n" - f"All dim names are: {self.dim_names}" - ) - - window_output = self._apply_window_function(index) - - if window_output.ndim != self.n_display_dims: - raise ValueError( - f"Output of the `process_function` must match the number of display dims." - f"`process_function` returned an array with {window_output.ndim} dims, " - f"expected {self.n_display_dims} dims" - ) - - return window_output + if self.n_slider_dims != 0: + if len(index) != len(self.n_slider_dims): + raise IndexError( + f"Must specify index for every slider dim, you have specified an index: {index}\n" + f"But there are: {self.n_slider_dims} slider dims." + ) + # get output after processing through all window funcs + # squeeze to remove all dims of size 1 + window_output = self._apply_window_function(index).squeeze() + + # apply finalizer func + if self.finalizer_func is not None: + final_output = self.finalizer_func(window_output) + if final_output.ndim != self.n_display_dims: + raise IndexError( + f"Final output after of the `finalizer_func` must match the number of display dims." + f"Output after `finalizer_func` returned an array with {final_output.ndim} dims and " + f"of shape: {final_output.shape}, expected {self.n_display_dims} dims" + ) + else: + # check that output ndim after window functions matches display dims + final_output = window_output + if final_output.ndim != self.n_display_dims: + raise IndexError( + f"Final output after of the `window_funcs` must match the number of display dims." + f"Output after `window_funcs` returned an array with {window_output.ndim} dims and " + f"of shape: {window_output.shape}, expected {self.n_display_dims} dims" + ) + + return final_output + + def compute_histogram(self) -> tuple[np.ndarray, np.ndarray]: + pass From 62a8b53ca4a8c2cc58b44dedb3b74cdfc2fe4803 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 5 Nov 2025 03:39:35 -0500 Subject: [PATCH 06/81] placeholder for computing histogram --- fastplotlib/widgets/image_widget/_array.py | 52 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index 9e35eff69..3d8d6e702 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -19,6 +19,7 @@ def __init__( window_sizes: tuple[int | None, ...] = None, window_order: tuple[int, ...] = None, finalizer_func: Callable[[ArrayLike], ArrayLike] = None, + compute_histogram: bool = True, ): """ A dynamic view of an ND image that supports computing window functions, and functions over spatial dimensions. @@ -57,6 +58,10 @@ def __init__( finalizer_func: Callable[[ArrayLike], ArrayLike] | None, optional A function that the data is put through after the window functions (if present) before being displayed. + + compute_histogram: bool, default True + Compute a histogram of the data, auto re-computes if window function propties or finalizer_func changes. + Disable if slow. """ @@ -70,6 +75,9 @@ def __init__( self._finalizer_func = finalizer_func + self._compute_histogram = compute_histogram + self._histogram = self._compute_histogram() + @property def data(self) -> ArrayLike: """get or set the data array""" @@ -86,6 +94,7 @@ def data(self, data: ArrayLike): f"{required_attrs}" ) self._data = data + self._recompute_histogram() @property def rgb(self) -> bool: @@ -115,6 +124,7 @@ def n_display_dims(self, n: Literal[2, 3]): if n not in (2, 3): raise ValueError("`n_display_dims` must be an with a value of 2 or 3") self._n_display_dims = n + self._recompute_histogram() @property def display_dims(self) -> tuple[int, int] | tuple[int, int, int]: @@ -150,6 +160,7 @@ def window_funcs(self, window_funcs: tuple[WindowFuncCallable | None, ...] | Win ) self._window_funcs = window_funcs + self._recompute_histogram() @property def window_sizes(self) -> tuple[int | None, ...] | None: @@ -202,6 +213,7 @@ def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): _window_sizes.append(w) self._window_sizes = tuple(window_sizes) + self._recompute_histogram() @property def window_order(self) -> tuple[int, ...] | None: @@ -221,6 +233,7 @@ def window_order(self, order: tuple[int] | None): raise IndexError(f"all `window_order` entires must be >= 0, you have passed: {order}") self._window_order = order + self._recompute_histogram() @property def finalizer_func(self) -> Callable[[ArrayLike], ArrayLike] | None: @@ -230,6 +243,31 @@ def finalizer_func(self) -> Callable[[ArrayLike], ArrayLike] | None: @finalizer_func.setter def finalizer_func(self, func: Callable[[ArrayLike], ArrayLike] | None): self._finalizer_func = func + self._recompute_histogram() + + @property + def compute_histogram(self) -> bool: + return self._compute_histogram + + @compute_histogram.setter + def compute_histogram(self, compute: bool): + if compute: + if self._compute_histogram is False: + # compute a histogram + self._recompute_histogram() + self._compute_histogram = True + else: + self._compute_histogram = False + self._histogram = None + + @property + def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: + """ + an estimate of the histogram of the data, (histogram_values, bin_edges). + + returns `None` if `compute_histogram` is `False` + """ + return self._histogram def _apply_window_function(self, index: tuple[int, ...]) -> ArrayLike: """applies the window functions for each dimension specified""" @@ -327,5 +365,15 @@ def get(self, index: tuple[int, ...]): return final_output - def compute_histogram(self) -> tuple[np.ndarray, np.ndarray]: - pass + def _recompute_histogram(self): + """ + + Returns + ------- + (histogram_values, bin_edges) + + """ + if not self._compute_histogram: + return + + self._histogram = None From 8f48b01ec975dcb8fe2f9857c52b76c0919c0f3c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 5 Nov 2025 03:55:47 -0500 Subject: [PATCH 07/81] formatting --- fastplotlib/widgets/image_widget/_array.py | 54 ++++++++++++++-------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index 3d8d6e702..72a0b63a9 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -6,20 +6,21 @@ from ...utils import subsample_array +# must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] class NDImageView: def __init__( - self, - data: ArrayLike, - n_display_dims: Literal[2, 3] = 2, - rgb: bool = False, - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, - window_sizes: tuple[int | None, ...] = None, - window_order: tuple[int, ...] = None, - finalizer_func: Callable[[ArrayLike], ArrayLike] = None, - compute_histogram: bool = True, + self, + data: ArrayLike, + n_display_dims: Literal[2, 3] = 2, + rgb: bool = False, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_sizes: tuple[int | None, ...] = None, + window_order: tuple[int, ...] = None, + finalizer_func: Callable[[ArrayLike], ArrayLike] = None, + compute_histogram: bool = True, ): """ A dynamic view of an ND image that supports computing window functions, and functions over spatial dimensions. @@ -58,7 +59,7 @@ def __init__( finalizer_func: Callable[[ArrayLike], ArrayLike] | None, optional A function that the data is put through after the window functions (if present) before being displayed. - + compute_histogram: bool, default True Compute a histogram of the data, auto re-computes if window function propties or finalizer_func changes. Disable if slow. @@ -115,7 +116,7 @@ def slider_dims(self) -> tuple[int, ...] | None: return tuple(range(self.n_slider_dims)) @property - def n_display_dims(self) -> Literal[2 , 3]: + def n_display_dims(self) -> Literal[2, 3]: """get or set the number of display dimensions, `2` for 2D image and `3` for volume images""" return self._n_display_dims @@ -128,16 +129,21 @@ def n_display_dims(self, n: Literal[2, 3]): @property def display_dims(self) -> tuple[int, int] | tuple[int, int, int]: - """tuple indicating the diplay dimension indices""" - return tuple(range(self.data.ndim))[self.n_slider_dims:] + """tuple indicating the display dimension indices""" + return tuple(range(self.data.ndim))[self.n_slider_dims :] @property - def window_funcs(self) -> tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None: + def window_funcs( + self, + ) -> tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None: """get or set window functions, see docstring for details""" return self._window_funcs @window_funcs.setter - def window_funcs(self, window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None): + def window_funcs( + self, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None, + ): if window_funcs is None: self._window_funcs = None return @@ -198,8 +204,10 @@ def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): continue if w < 0: - raise ValueError(f"negative window size passed, all `window_sizes` must be positive " - f"integers or `None`, you passed: {_window_sizes}") + raise ValueError( + f"negative window size passed, all `window_sizes` must be positive " + f"integers or `None`, you passed: {_window_sizes}" + ) if w in (0, 1): # this is not a real window, set as None @@ -207,7 +215,9 @@ def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): if w % 2 == 0: # odd window sizes makes most sense - warn(f"provided even window size: {w} in dim: {i}, adding `1` to make it odd") + warn( + f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" + ) w += 1 _window_sizes.append(w) @@ -230,7 +240,9 @@ def window_order(self, order: tuple[int] | None): ) if not all([d >= 0 for d in order]): - raise IndexError(f"all `window_order` entires must be >= 0, you have passed: {order}") + raise IndexError( + f"all `window_order` entires must be >= 0, you have passed: {order}" + ) self._window_order = order self._recompute_histogram() @@ -291,7 +303,9 @@ def _apply_window_function(self, index: tuple[int, ...]) -> ArrayLike: # window_funcs = (np.mean, None) # order = (0, 1) # `1` is removed from the order since that window_func is `None` - order = tuple(d for d in order if windows[d] is not None and funcs[d] is not None) + order = tuple( + d for d in order if windows[d] is not None and funcs[d] is not None + ) else: # sequential order order = tuple(range(self.n_slider_dims)) From 7770ee05a71092ca53c4927613f2c58d8c4ef2ce Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 5 Nov 2025 04:56:24 -0500 Subject: [PATCH 08/81] remove spaghetti --- fastplotlib/widgets/image_widget/_widget.py | 444 +------------------- 1 file changed, 1 insertion(+), 443 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index a95405bd3..17eef2c16 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -10,96 +10,7 @@ from ...utils import calculate_figure_shape, quick_min_max from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders -from ._array import ImageWidgetArray - - -# Number of dimensions that represent one image/one frame -# For grayscale shape will be [n_rows, n_cols], i.e. 2 dims -# For RGB(A) shape will be [n_rows, n_cols, c] where c is of size 3 (RGB) or 4 (RGBA) -IMAGE_DIM_COUNTS = {"gray": 2, "rgb": 3} - -# Map boolean (indicating whether we use RGB or grayscale) to the string. Used to index RGB_DIM_MAP -RGB_BOOL_MAP = {False: "gray", True: "rgb"} - -# Dimensions that can be scrolled from a given data array -SCROLLABLE_DIMS_ORDER = { - 0: "", - 1: "t", - 2: "tz", -} - -ALLOWED_SLIDER_DIMS = {0: "t", 1: "z"} - -ALLOWED_WINDOW_DIMS = {"t", "z"} - - -def _is_arraylike(obj) -> bool: - """ - Checks if the object is array-like. - For now just checks if obj has `__getitem__()` - """ - for attr in ["__getitem__", "shape", "ndim"]: - if not hasattr(obj, attr): - return False - - return True - - -class _WindowFunctions: - """Stores window function and window size""" - - def __init__(self, image_widget, func: callable, window_size: int): - self._image_widget = image_widget - self._func = None - self.func = func - - self._window_size = 0 - self.window_size = window_size - - @property - def func(self) -> callable: - """Get or set the function""" - return self._func - - @func.setter - def func(self, func: callable): - self._func = func - - # force update - self._image_widget.current_index = self._image_widget.current_index - - @property - def window_size(self) -> int: - """Get or set window size""" - return self._window_size - - @window_size.setter - def window_size(self, ws: int): - if ws is None: - self._window_size = None - return - - if not isinstance(ws, int): - raise TypeError("window size must be an int") - - if ws < 3: - warn( - f"Invalid 'window size' value for function: {self.func}, " - f"setting 'window size' = None for this function. " - f"Valid values are integers >= 3." - ) - self.window_size = None - return - - if ws % 2 == 0: - ws += 1 - - self._window_size = ws - - self._image_widget.current_index = self._image_widget.current_index - - def __repr__(self): - return f"func: {self.func}, window_size: {self.window_size}" +from ._array import NDImageView class ImageWidget: @@ -155,24 +66,6 @@ def data(self) -> list[np.ndarray]: """data currently displayed in the widget""" return self._data - @property - def ndim(self) -> int: - """Number of dimensions of grayscale data displayed in the widget (it will be 1 more for RGB(A) data)""" - return self._ndim - - @property - def n_scrollable_dims(self) -> list[int]: - """ - list indicating the number of dimenensions that are scrollable for each data array - All other dimensions are frame/image data, i.e. [rows, cols] or [rows, cols, rgb(a)] - """ - return self._n_scrollable_dims - - @property - def slider_dims(self) -> list[str]: - """the dimensions that the sliders index""" - return self._slider_dims - @property def current_index(self) -> dict[str, int]: """ @@ -236,56 +129,6 @@ def current_index(self, index: dict[str, int]): # set_value has finished executing, now allow future executions self._reentrant_block = False - @property - def n_img_dims(self) -> list[int]: - """ - list indicating the number of dimensions that contain image/single frame data for each data array. - if 2: data are grayscale, i.e. [x, y] dims, if 3: data are [x, y, c] where c is RGB or RGBA, - this is the complement of `n_scrollable_dims` - """ - return self._n_img_dims - - def _get_n_scrollable_dims(self, curr_arr: np.ndarray, rgb: bool) -> list[int]: - """ - For a given ``array`` displayed in the ImageWidget, this function infers how many of the dimensions are - supported by sliders (aka scrollable). Ex: "xy" data has 0 scrollable dims, "txy" has 1, "tzxy" has 2. - - Parameters - ---------- - curr_arr: np.ndarray - np.ndarray or a list of array-like - - rgb: bool - True if we view this as RGB(A) and False if grayscale - - Returns - ------- - int - Number of scrollable dimensions for each ``array`` in the dataset. - """ - - n_img_dims = IMAGE_DIM_COUNTS[RGB_BOOL_MAP[rgb]] - # Make sure each image stack at least ``n_img_dims`` dimensions - if len(curr_arr.shape) < n_img_dims: - raise ValueError( - f"Your array has shape {curr_arr.shape} " - f"but you specified that each image in your array is {n_img_dims}D " - ) - - # If RGB(A), last dim must be 3 or 4 - if n_img_dims == 3: - if not (curr_arr.shape[-1] == 3 or curr_arr.shape[-1] == 4): - raise ValueError( - f"Expected size 3 or 4 for last dimension of RGB(A) array, got: {curr_arr.shape[-1]}." - ) - - n_scrollable_dims = len(curr_arr.shape) - n_img_dims - - if n_scrollable_dims not in SCROLLABLE_DIMS_ORDER.keys(): - raise ValueError(f"Array had shape {curr_arr.shape} which is not supported") - - return n_scrollable_dims - def __init__( self, data: np.ndarray | list[np.ndarray], @@ -308,14 +151,6 @@ def __init__( Allowed dimensions orders for each image stack: Note that each has a an optional (c) channel which refers to RGB(A) a channel. So this channel should be either 3 or 4. - ======= ========== - n_dims dims order - ======= ========== - 2 "xy(c)" - 3 "txy(c)" - 4 "tzxy(c)" - ======= ========== - Parameters ---------- data: Union[np.ndarray, List[np.ndarray] @@ -404,29 +239,6 @@ def __init__( f"len(rgb) != len(data), {len(rgb)} != {len(self.data)}. These must be equal" ) - self._rgb = rgb - - self._n_img_dims = [ - IMAGE_DIM_COUNTS[RGB_BOOL_MAP[self._rgb[i]]] - for i in range(len(self.data)) - ] - - self._n_scrollable_dims = [ - self._get_n_scrollable_dims(self.data[i], self._rgb[i]) - for i in range(len(self.data)) - ] - - # Define ndim of ImageWidget instance as largest number of scrollable dims + 2 (grayscale dimensions) - self._ndim = ( - max( - [ - self.n_scrollable_dims[i] - for i in range(len(self.n_scrollable_dims)) - ] - ) - + IMAGE_DIM_COUNTS[RGB_BOOL_MAP[False]] - ) - if names is not None: if not all([isinstance(n, str) for n in names]): raise TypeError( @@ -451,62 +263,9 @@ def __init__( f"You have passed the following type {type(data)}" ) - # Sliders are made for all dimensions except the image dimensions - self._slider_dims = list() - max_scrollable = max( - [self.n_scrollable_dims[i] for i in range(len(self.n_scrollable_dims))] - ) - for dim in range(max_scrollable): - if dim in ALLOWED_SLIDER_DIMS.keys(): - self.slider_dims.append(ALLOWED_SLIDER_DIMS[dim]) - - self._frame_apply: dict[int, callable] = dict() - - if frame_apply is not None: - if callable(frame_apply): - self._frame_apply = frame_apply - - elif isinstance(frame_apply, dict): - self._frame_apply: dict[int, callable] = dict.fromkeys( - list(range(len(self.data))) - ) - - # dict of {array: dims_order_str} - for data_ix in list(frame_apply.keys()): - if not isinstance(data_ix, int): - raise TypeError("`frame_apply` dict keys must be ") - try: - self._frame_apply[data_ix] = frame_apply[data_ix] - except Exception: - raise IndexError( - f"key index {data_ix} out of bounds for `frame_apply`, the bounds are 0 - {len(self.data)}" - ) - else: - raise TypeError( - f"`frame_apply` must be a callable or , " - f"you have passed a: <{type(frame_apply)}>" - ) - # current_index stores {dimension_index: slice_index} for every dimension self._current_index: dict[str, int] = {sax: 0 for sax in self.slider_dims} - self._window_funcs = None - self.window_funcs = window_funcs - - # get max bound for all data arrays for all slider dimensions and ensure compatibility across slider dims - self._dims_max_bounds: dict[str, int] = {k: 0 for k in self.slider_dims} - for i, _dim in enumerate(list(self._dims_max_bounds.keys())): - for array, partition in zip(self.data, self.n_scrollable_dims): - if partition <= i: - continue - else: - if 0 < self._dims_max_bounds[_dim] != array.shape[i]: - raise ValueError(f"Two arrays differ along dimension {_dim}") - else: - self._dims_max_bounds[_dim] = max( - self._dims_max_bounds[_dim], array.shape[i] - ) - figure_kwargs_default = {"controller_ids": "sync", "names": names} # update the default kwargs with any user-specified kwargs @@ -594,207 +353,6 @@ def __init__( self._initialized = True - @property - def frame_apply(self) -> dict | None: - return self._frame_apply - - @frame_apply.setter - def frame_apply(self, frame_apply: dict[int, callable]): - if frame_apply is None: - frame_apply = dict() - - self._frame_apply = frame_apply - # force update image graphic - self.current_index = self.current_index - - @property - def window_funcs(self) -> dict[str, _WindowFunctions]: - """ - Get or set the window functions - - Returns - ------- - Dict[str, _WindowFunctions] - - """ - return self._window_funcs - - @window_funcs.setter - def window_funcs(self, callable_dict: dict[str, int]): - if callable_dict is None: - self._window_funcs = None - # force frame to update - self.current_index = self.current_index - return - - elif isinstance(callable_dict, dict): - if not set(callable_dict.keys()).issubset(ALLOWED_WINDOW_DIMS): - raise ValueError( - f"The only allowed keys to window funcs are {list(ALLOWED_WINDOW_DIMS)} " - f"Your window func passed in these keys: {list(callable_dict.keys())}" - ) - if not all( - [ - isinstance(_callable_dict, tuple) - for _callable_dict in callable_dict.values() - ] - ): - raise TypeError( - "dict argument to `window_funcs` must be in the form of: " - "`{dimension: (func, window_size)}`. " - "See the docstring." - ) - for v in callable_dict.values(): - if not callable(v[0]): - raise TypeError( - "dict argument to `window_funcs` must be in the form of: " - "`{dimension: (func, window_size)}`. " - "See the docstring." - ) - if not isinstance(v[1], int): - raise TypeError( - f"dict argument to `window_funcs` must be in the form of: " - "`{dimension: (func, window_size)}`. " - f"where window_size is integer. you passed in {v[1]} for window_size" - ) - - if not isinstance(self._window_funcs, dict): - self._window_funcs = dict() - - for k in list(callable_dict.keys()): - self._window_funcs[k] = _WindowFunctions(self, *callable_dict[k]) - - else: - raise TypeError( - f"`window_funcs` must be either Nonetype or dict." - f"You have passed a {type(callable_dict)}. See the docstring." - ) - - # force frame to update - self.current_index = self.current_index - - def _process_indices( - self, array: np.ndarray, slice_indices: dict[str, int] - ) -> np.ndarray: - """ - Get the 2D array from the given slice indices. If not returning a 2D slice (such as due to window_funcs) - then `frame_apply` must take this output and return a 2D array - - Parameters - ---------- - array: np.ndarray - array-like to get a 2D slice from - - slice_indices: Dict[str, int] - dict in form of {dimension_index: current_index} - For example if an array has shape [1000, 30, 512, 512] corresponding to [t, z, x, y]: - To get the 100th timepoint and 3rd z-plane pass: - {"t": 100, "z": 3} - - Returns - ------- - np.ndarray - array-like, 2D slice - - """ - - data_ix = None - for i in range(len(self.data)): - if self.data[i] is array: - data_ix = i - break - - numerical_dims = list() - - # Totally number of dimensions for this specific array - curr_ndim = self.data[data_ix].ndim - - # Initialize slices for each dimension of array - indexer = [slice(None)] * curr_ndim - - # Maps from n_scrollable_dims to one of "", "t", "tz", etc. - curr_scrollable_format = SCROLLABLE_DIMS_ORDER[self.n_scrollable_dims[data_ix]] - for dim in list(slice_indices.keys()): - if dim not in curr_scrollable_format: - continue - # get axes order for that specific array - numerical_dim = curr_scrollable_format.index(dim) - - indices_dim = slice_indices[dim] - - # takes care of index selection (window slicing) for this specific axis - indices_dim = self._get_window_indices(data_ix, numerical_dim, indices_dim) - - # set the indices for this dimension - indexer[numerical_dim] = indices_dim - - numerical_dims.append(numerical_dim) - - # apply indexing to the array - # use window function is given for this dimension - if self.window_funcs is not None: - a = array - for i, dim in enumerate(sorted(numerical_dims)): - dim_str = curr_scrollable_format[dim] - dim = dim - i # since we loose a dimension every iteration - _indexer = [slice(None)] * (curr_ndim - i) - _indexer[dim] = indexer[dim + i] - - # if the indexer is an int, this dim has no window func - if isinstance(_indexer[dim], int): - a = a[tuple(_indexer)] - else: - # if the indices are from `self._get_window_indices` - func = self.window_funcs[dim_str].func - window = a[tuple(_indexer)] - a = func(window, axis=dim) - return a - else: - return array[tuple(indexer)] - - def _get_window_indices(self, data_ix, dim, indices_dim): - if self.window_funcs is None: - return indices_dim - - else: - ix = indices_dim - - dim_str = SCROLLABLE_DIMS_ORDER[self.n_scrollable_dims[data_ix]][dim] - - # if no window stuff specified for this dim - if dim_str not in self.window_funcs.keys(): - return indices_dim - - # if window stuff is set to None for this dim - # example: {"t": None} - if self.window_funcs[dim_str] is None: - return indices_dim - - window_size = self.window_funcs[dim_str].window_size - - if (window_size == 0) or (window_size is None): - return indices_dim - - half_window = int((window_size - 1) / 2) # half-window size - # get the max bound for that dimension - max_bound = self._dims_max_bounds[dim_str] - indices_dim = range( - max(0, ix - half_window), min(max_bound, ix + half_window) - ) - return indices_dim - - def _process_frame_apply(self, array, data_ix) -> np.ndarray: - if callable(self._frame_apply): - return self._frame_apply(array) - - if data_ix not in self._frame_apply.keys(): - return array - - elif self._frame_apply[data_ix] is not None: - return self._frame_apply[data_ix](array) - - return array - def add_event_handler(self, handler: callable, event: str = "current_index"): """ Register an event handler. From b31f549204974ec67bcb5d428739e99219060d4c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 6 Nov 2025 01:20:12 -0500 Subject: [PATCH 09/81] more progress --- .github/workflows/docs-deploy.yml | 2 +- fastplotlib/layouts/_figure.py | 4 +- fastplotlib/tools/_histogram_lut.py | 15 +- fastplotlib/widgets/image_widget/_array.py | 60 ++- fastplotlib/widgets/image_widget/_sliders.py | 43 +- fastplotlib/widgets/image_widget/_widget.py | 518 +++++++++++-------- 6 files changed, 398 insertions(+), 244 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 470e2e5a5..f17941405 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -49,7 +49,7 @@ jobs: - name: build docs run: | cd docs - RTD_BUILD=1 make html SPHINXOPTS="-W --keep-going" + DOCS_BUILD=1 make html SPHINXOPTS="-W --keep-going" # set environment variable `DOCS_VERSION_DIR` to either the pr-branch name, "dev", or the release version tag - name: set output pr diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 8fd5dc666..e65c0c132 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -686,8 +686,8 @@ def show( # but not for rtd build, this is a workaround # for CI tests, the render call works if it's in test_examples # but it is necessary for the gallery images too so that's why this check is here - if "RTD_BUILD" in os.environ.keys(): - if os.environ["RTD_BUILD"] == "1": + if "DOCS_BUILD" in os.environ.keys(): + if os.environ["DOCS_BUILD"] == "1": self._render() else: # assume GLFW diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index 7507a7ff2..1a31235c1 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -37,6 +37,7 @@ def __init__( ), nbins: int = 100, flank_divisor: float = 5.0, + histogram: np.ndarray = None, **kwargs, ): """ @@ -87,7 +88,7 @@ def __init__( self._scale_factor: float = 1.0 - hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data) + hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data, histogram) line_data = np.column_stack([hist_scaled, edges_flanked]) @@ -228,11 +229,13 @@ def _fpl_add_plot_area_hook(self, plot_area): self._plot_area.auto_scale() self._plot_area.controller.enabled = True - def _calculate_histogram(self, data): - - # get a subsampled view of this array - data_ss = subsample_array(data, max_size=int(1e6)) # 1e6 is default - hist, edges = np.histogram(data_ss, bins=self._nbins) + def _calculate_histogram(self, data, histogram = None): + if histogram is None: + # get a subsampled view of this array + data_ss = subsample_array(data, max_size=int(1e6)) # 1e6 is default + hist, edges = np.histogram(data_ss, bins=self._nbins) + else: + hist, edges = histogram # used if data ptp <= 10 because event things get weird # with tiny world objects due to floating point error diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index 72a0b63a9..c4f73b33c 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -1,8 +1,10 @@ -import numpy as np -from numpy.typing import ArrayLike +import inspect from typing import Literal, Callable from warnings import warn +import numpy as np +from numpy.typing import ArrayLike + from ...utils import subsample_array @@ -10,7 +12,7 @@ WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] -class NDImageView: +class NDImageArray: def __init__( self, data: ArrayLike, @@ -23,7 +25,7 @@ def __init__( compute_histogram: bool = True, ): """ - A dynamic view of an ND image that supports computing window functions, and functions over spatial dimensions. + An ND image that supports computing window functions, and functions over spatial dimensions. Parameters ---------- @@ -70,6 +72,9 @@ def __init__( self._n_display_dims = n_display_dims self._rgb = rgb + # set as False until window funcs stuff and finalizer func is all set + self._compute_histogram = False + self._window_funcs = window_funcs self._window_sizes = window_sizes self._window_order = window_order @@ -77,7 +82,7 @@ def __init__( self._finalizer_func = finalizer_func self._compute_histogram = compute_histogram - self._histogram = self._compute_histogram() + self._compute_histogram() @property def data(self) -> ArrayLike: @@ -97,6 +102,14 @@ def data(self, data: ArrayLike): self._data = data self._recompute_histogram() + @property + def ndim(self) -> int: + return self.data.ndim + + @property + def shape(self) -> tuple[int, ...]: + return self.data.shape + @property def rgb(self) -> bool: """whether or not the data is rgb(a)""" @@ -153,10 +166,26 @@ def window_funcs( self._window_funcs = None return - if not all([callable(f) or f is None for f in funcs]): - raise TypeError( - f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {window_funcs}" - ) + self._validate_window_func(window_funcs) + + self._window_funcs = window_funcs + self._recompute_histogram() + + def _validate_window_func(self, funcs): + if isinstance(funcs, (tuple, list)): + for f in funcs: + if not callable(f): + raise TypeError( + f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {window_funcs}" + ) + + sig = inspect.signature(f) + + 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, you passed: {f} with the " + f"following function signature: {sig}" + ) if not len(window_funcs) == self.n_slider_dims: raise IndexError( @@ -165,9 +194,6 @@ def window_funcs( f"and you passed {len(window_funcs)} `window_funcs`: {window_funcs}" ) - self._window_funcs = window_funcs - self._recompute_histogram() - @property def window_sizes(self) -> tuple[int | None, ...] | None: """get or set window sizes used for the corresponding window functions, see docstring for details""" @@ -388,6 +414,14 @@ def _recompute_histogram(self): """ if not self._compute_histogram: + self._histogram = None return - self._histogram = None + if self.finalizer_func is not None: + ignore_dims = self.display_dims + else: + ignore_dims = None + + sub = subsample_array(self.data, ignore_dims=ignore_dims) + + self._histogram = np.histogram(sub, bins=100) diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 393b13273..3519c2d7d 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -11,40 +11,47 @@ def __init__(self, figure, size, location, title, image_widget): super().__init__(figure=figure, size=size, location=location, title=title) self._image_widget = image_widget + n_sliders = self._image_widget.n_sliders + # whether or not a dimension is in play mode - self._playing: dict[str, bool] = {"t": False, "z": False} + self._playing: tuple[int, ...] = [False] * n_sliders # approximate framerate for playing - self._fps: dict[str, int] = {"t": 20, "z": 20} + self._fps: tuple[int, ...] = [20] * n_sliders + # framerate converted to frame time - self._frame_time: dict[str, float] = {"t": 1 / 20, "z": 1 / 20} + self._frame_time: tuple[int, ...] = [1 / 20] * n_sliders # last timepoint that a frame was displayed from a given dimension - self._last_frame_time: dict[str, float] = {"t": 0, "z": 0} + self._last_frame_time: tuple[int, ...] = [20] * n_sliders + # loop playback self._loop = False - if "RTD_BUILD" in os.environ.keys(): - if os.environ["RTD_BUILD"] == "1": - self._playing["t"] = True + # auto-plays the ImageWidget's left-most dimension in docs galleries + if "DOCS_BUILD" in os.environ.keys(): + if os.environ["DOCS_BUILD"] == "1": + self._playing[0] = True self._loop = True - def set_index(self, dim: str, index: int): - """set the current_index of the ImageWidget""" + def set_index(self, dim: int, new_index: int): + """set the index of the ImageWidget""" # make sure the max index for this dim is not exceeded - max_index = self._image_widget._dims_max_bounds[dim] - 1 - if index > max_index: + max_index = self._image_widget.bounds[dim] - 1 + if new_index > max_index: if self._loop: # loop back to index zero if looping is enabled - index = 0 + new_index = 0 else: # if looping not enabled, stop playing this dimension self._playing[dim] = False return # set current_index - self._image_widget.current_index = {dim: min(index, max_index)} + index = list(self._image_widget.index) + index[dim] = new_index + self._image_widget.index = index def update(self): """called on every render cycle to update the GUI elements""" @@ -83,7 +90,7 @@ def update(self): # if in play mode and enough time has elapsed w.r.t. the desired framerate, increment the index if now - self._last_frame_time[dim] >= self._frame_time[dim]: - self.set_index(dim, self._image_widget.current_index[dim] + 1) + self.set_index(dim, self._image_widget.index[dim] + 1) self._last_frame_time[dim] = now else: @@ -97,12 +104,12 @@ def update(self): imgui.same_line() # step back one frame button if imgui.button(label=fa.ICON_FA_BACKWARD_STEP) and not self._playing[dim]: - self.set_index(dim, self._image_widget.current_index[dim] - 1) + self.set_index(dim, self._image_widget.index[dim] - 1) imgui.same_line() # step forward one frame button if imgui.button(label=fa.ICON_FA_FORWARD_STEP) and not self._playing[dim]: - self.set_index(dim, self._image_widget.current_index[dim] + 1) + self.set_index(dim, self._image_widget.index[dim] + 1) imgui.same_line() # stop button @@ -137,7 +144,7 @@ def update(self): self._fps[dim] = value self._frame_time[dim] = 1 / value - val = self._image_widget.current_index[dim] + val = self._image_widget.index[dim] vmax = self._image_widget._dims_max_bounds[dim] - 1 imgui.text(f"{dim}: ") @@ -166,6 +173,6 @@ def update(self): if flag_index_changed: # if any slider dim changed set the new index of the image widget - self._image_widget.current_index = new_index + self._image_widget.index = new_index self.size = int(imgui.get_window_height()) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 17eef2c16..3995b5244 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -1,4 +1,4 @@ -from typing import Callable +from typing import Callable, Sequence, Literal from warnings import warn import numpy as np @@ -6,142 +6,31 @@ from rendercanvas import BaseRenderCanvas from ...layouts import ImguiFigure as Figure -from ...graphics import ImageGraphic +from ...graphics import ImageGraphic, ImageVolumeGraphic from ...utils import calculate_figure_shape, quick_min_max from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders -from ._array import NDImageView +from ._array import NDImageArray class ImageWidget: - @property - def figure(self) -> Figure: - """ - ``Figure`` used by `ImageWidget`. - """ - return self._figure - - @property - def managed_graphics(self) -> list[ImageGraphic]: - """List of ``ImageWidget`` managed graphics.""" - iw_managed = list() - for subplot in self.figure: - # empty subplots will not have any image widget data - if len(subplot.graphics) > 0: - iw_managed.append(subplot["image_widget_managed"]) - return iw_managed - - @property - def cmap(self) -> list[str]: - cmaps = list() - for g in self.managed_graphics: - cmaps.append(g.cmap) - - return cmaps - - @cmap.setter - def cmap(self, names: str | list[str]): - if isinstance(names, list): - if not all([isinstance(n, str) for n in names]): - raise TypeError( - f"Must pass cmap name as a `str` of list of `str`, you have passed:\n{names}" - ) - - if not len(names) == len(self.managed_graphics): - raise IndexError( - f"If passing a list of cmap names, the length of the list must be the same as the number of " - f"image widget subplots. You have passed: {len(names)} cmap names and have " - f"{len(self.managed_graphics)} image widget subplots" - ) - - for name, g in zip(names, self.managed_graphics): - g.cmap = name - - elif isinstance(names, str): - for g in self.managed_graphics: - g.cmap = names - - @property - def data(self) -> list[np.ndarray]: - """data currently displayed in the widget""" - return self._data - - @property - def current_index(self) -> dict[str, int]: - """ - Get or set the current index - - Returns - ------- - index: Dict[str, int] - | ``dict`` for indexing each dimension, provide a ``dict`` with indices for all dimensions used by sliders - or only a subset of dimensions used by the sliders. - | example: if you have sliders for dims "t" and "z", you can pass either ``{"t": 10}`` to index to position - 10 on dimension "t" or ``{"t": 5, "z": 20}`` to index to position 5 on dimension "t" and position 20 on - dimension "z" simultaneously. - - """ - return self._current_index - - @current_index.setter - def current_index(self, index: dict[str, int]): - if not self._initialized: - return - - if self._reentrant_block: - return - - try: - self._reentrant_block = True # block re-execution until current_index has *fully* completed execution - if not set(index.keys()).issubset(set(self._current_index.keys())): - raise KeyError( - f"All dimension keys for setting `current_index` must be present in the widget sliders. " - f"The dimensions currently used for sliders are: {list(self.current_index.keys())}" - ) - - for k, val in index.items(): - if not isinstance(val, int): - raise TypeError("Indices for all dimensions must be int") - if val < 0: - raise IndexError( - "negative indexing is not supported for ImageWidget" - ) - if val > self._dims_max_bounds[k]: - raise IndexError( - f"index {val} is out of bounds for dimension '{k}' " - f"which has a max bound of: {self._dims_max_bounds[k]}" - ) - - self._current_index.update(index) - - for i, (ig, data) in enumerate(zip(self.managed_graphics, self.data)): - frame = self._process_indices(data, self._current_index) - frame = self._process_frame_apply(frame, i) - ig.data = frame - - # call any event handlers - for handler in self._current_index_changed_handlers: - handler(self.current_index) - except Exception as exc: - # raise original exception - raise exc # current_index setter has raised. The lines above below are probably more relevant! - finally: - # set_value has finished executing, now allow future executions - self._reentrant_block = False - def __init__( self, data: np.ndarray | list[np.ndarray], - array_types: ImageWidgetArray | list[ImageWidgetArray] = ImageWidgetArray, - window_funcs: dict[str, tuple[Callable, int]] = None, - frame_apply: Callable | dict[int, Callable] = None, + array_types: NDImageArray | list[NDImageArray] = NDImageArray, + n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, + rgb: bool | Sequence[bool] = None, + cmap: str = "plasma", + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None | Sequence[tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None ]= None, + window_sizes: tuple[int | None, ...] | Sequence[tuple[int | None, ...] | None] = None, + window_order: tuple[int, ...] | Sequence[tuple[int, ...] | None] = None, + finalizer_funcs: Callable[[ArrayLike], ArrayLike] | Sequence[Callable[[ArrayLike], ArrayLike]] | None = None, + sliders_dim_order: Literal["right", "left"] = "right", figure_shape: tuple[int, int] = None, - names: list[str] = None, + names: Sequence[str] = None, figure_kwargs: dict = None, histogram_widget: bool = True, - rgb: bool | list[bool] = None, - cmap: str = "plasma", - graphic_kwargs: dict = None, + graphic_kwargs: dict | Sequence[dict] = None, ): """ This widget facilitates high-level navigation through image stacks, which are arrays containing one or more @@ -153,7 +42,7 @@ def __init__( Parameters ---------- - data: Union[np.ndarray, List[np.ndarray] + data: np.ndarray | List[np.ndarray] array-like or a list of array-like window_funcs: dict[str, tuple[Callable, int]], i.e. {"t" or "z": (callable, int)} @@ -204,51 +93,56 @@ def __init__( if isinstance(data, list): # verify that it's a list of np.ndarray - if all([_is_arraylike(d) for d in data]): - # Grid computations - if figure_shape is None: - if "shape" in figure_kwargs: - figure_shape = figure_kwargs["shape"] - else: - figure_shape = calculate_figure_shape(len(data)) - - # Regardless of how figure_shape is computed, below code - # verifies that figure shape is large enough for the number of image arrays passed - if figure_shape[0] * figure_shape[1] < len(data): - original_shape = (figure_shape[0], figure_shape[1]) - figure_shape = calculate_figure_shape(len(data)) - warn( - f"Original `figure_shape` was: {original_shape} " - f" but data length is {len(data)}" - f" Resetting figure shape to: {figure_shape}" - ) - - self._data: list[np.ndarray] = data - - # Establish number of image dimensions and number of scrollable dimensions for each array - if rgb is None: - rgb = [False] * len(self.data) - if isinstance(rgb, bool): - rgb = [rgb] * len(self.data) - if not isinstance(rgb, list): - raise TypeError( - f"`rgb` parameter must be a bool or list of bool, a <{type(rgb)}> was provided" - ) - if not len(rgb) == len(self.data): - raise ValueError( - f"len(rgb) != len(data), {len(rgb)} != {len(self.data)}. These must be equal" - ) - - if names is not None: - if not all([isinstance(n, str) for n in names]): - raise TypeError( - "optional argument `names` must be a list of str" - ) + if not all([_is_arraylike(d) for d in data]): + raise TypeError( + f"`data` must be an array-like type or a list of array-like." + f"You have passed the following type {type(data)}" + ) - if len(names) != len(self.data): - raise ValueError( - "number of `names` for subplots must be same as the number of data arrays" - ) + # subplot layout + if figure_shape is None: + if "shape" in figure_kwargs: + figure_shape = figure_kwargs["shape"] + else: + figure_shape = calculate_figure_shape(len(data)) + + # Regardless of how figure_shape is computed, below code + # verifies that figure shape is large enough for the number of image arrays passed + if figure_shape[0] * figure_shape[1] < len(data): + original_shape = (figure_shape[0], figure_shape[1]) + figure_shape = calculate_figure_shape(len(data)) + warn( + f"Original `figure_shape` was: {original_shape} " + f" but data length is {len(data)}" + f" Resetting figure shape to: {figure_shape}" + ) + + if rgb is None: + rgb = [False] * len(data) + + elif isinstance(rgb, bool): + rgb = [rgb] * len(data) + + if not all([isinstance(v, bool) for v in rgb]): + raise TypeError( + f"`rgb` parameter must be a bool or a Sequence of bool, <{rgb}> was provided" + ) + + if not len(rgb) == len(data): + raise ValueError( + f"len(rgb) != len(data), {len(rgb)} != {len(self.data)}. These must be equal" + ) + + if names is not None: + if not all([isinstance(n, str) for n in names]): + raise TypeError( + "optional argument `names` must be a Sequence of str" + ) + + if len(names) != len(data): + raise ValueError( + "number of `names` for subplots must be same as the number of data arrays" + ) else: raise TypeError( @@ -257,14 +151,89 @@ def __init__( f"You have passed the following types:\n" f"{[type(a) for a in data]}" ) + + # verify window funcs + if window_funcs is None: + win_funcs = [None] * len(data) + + elif callable(window_funcs) or all([callable(f) or f is None for f in window_funcs]): + # across all data arrays + # one window function defined for all dims, or window functions defined per-dim + win_funcs = [window_funcs] * len(data) + + # if the above two clauses didn't trigger, then window_funcs defined per-dim, per data array + elif len(window_funcs) != len(data): + raise IndexError + + # verify window sizes + if window_sizes is None: + win_sizes = [window_sizes] * len(data) + + elif all([isinstance(size, int) or size is None for size in window_sizes]): + # window sizes defined per-dim across all data arrays + win_sizes = [window_sizes] * len(data) + + elif len(window_sizes) != len(data): + # window sizes defined per-dim, per data array + raise IndexError + + # verify window orders + if window_order is None: + win_order = [None] * len(data) + + elif all([isinstance(o, int) for o in order]): + # window order defined per-dim across all data arrays + win_order = [window_order] * len(data) + + elif len(window_order) != len(data): + raise IndexError + + # verify finalizer function + if finalizer_funcs is None: + final_funcs = [None] * len(data) + + elif callable(finalizer_funcs): + # same finalizer func for all data arrays + finalizer_funcs = [finalizer_funcs] * len(data) + + elif len(finalizer_funcs) != len(data): + raise IndexError + + # verify number of display dims + if isinstance(n_display_dims, int): + if n_display_dims not in (2, 3): + raise ValueError + n_display_dims = [n_display_dims] * len(data) + + elif isinstance(n_display_dims, (tuple, list)): + if not all([n in (2, 3) for n in n_display_dims]): + raise ValueError + if len(n_display_dims) != len(data): + raise IndexError else: - raise TypeError( - f"`data` must be an array-like type or a list of array-like." - f"You have passed the following type {type(data)}" + raise TypeError + + self._n_display_dims = n_display_dims + + if sliders_dim_order not in ("left", "right"): + raise ValueError + self._sliders_dim_order = sliders_dim_order + + # make NDImageArrays + self._image_arrays: list[NDImageArray] = list() + for i in range(len(data)): + image_array = NDImageArray( + data=data[i], + rgb=rgb[i], + n_display_dims=n_display_dims[i], + window_funcs=win_funcs[i], + window_sizes=win_sizes[i], + window_order=win_order[i], + finalizer_func=finalizer_funcs[i], + compute_histogram=histogram_widget, ) - # current_index stores {dimension_index: slice_index} for every dimension - self._current_index: dict[str, int] = {sax: 0 for sax in self.slider_dims} + self._image_arrays.append(image_array) figure_kwargs_default = {"controller_ids": "sync", "names": names} @@ -274,27 +243,32 @@ def __init__( figure_kwargs_default["shape"] = figure_shape if graphic_kwargs is None: - graphic_kwargs = dict() + graphic_kwargs = [dict()] * len(data) - graphic_kwargs.update({"cmap": cmap}) + elif isinstance(graphic_kwargs, dict): + graphic_kwargs = [graphic_kwargs] * len(data) - vmin_specified, vmax_specified = None, None - if "vmin" in graphic_kwargs.keys(): - vmin_specified = graphic_kwargs.pop("vmin") - if "vmax" in graphic_kwargs.keys(): - vmax_specified = graphic_kwargs.pop("vmax") + elif len(graphic_kwargs) != len(data): + raise IndexError self._figure: Figure = Figure(**figure_kwargs_default) self._histogram_widget = histogram_widget - for data_ix, (d, subplot) in enumerate(zip(self.data, self.figure)): - frame = self._process_indices(d, slice_indices=self._current_index) - frame = self._process_frame_apply(frame, data_ix) + self._index = tuple(0 for i in range(self.n_sliders)) + + for i, subplot in zip(range(len(self._image_arrays)), figure): + image_data = self._get_image(self._index, self._image_arrays[i]) + + vmin_specified, vmax_specified = None, None + if "vmin" in graphic_kwargs[i].keys(): + vmin_specified = graphic_kwargs[i].pop("vmin") + if "vmax" in graphic_kwargs[i].keys(): + vmax_specified = graphic_kwargs[i].pop("vmax") if (vmin_specified is None) or (vmax_specified is None): # if either vmin or vmax are not specified, calculate an estimate by subsampling - vmin_estimate, vmax_estimate = quick_min_max(d) + vmin_estimate, vmax_estimate = quick_min_max(self._image_arrays[i]) # decide vmin, vmax passed to ImageGraphic constructor based on whether it's user specified or now if vmin_specified is None: @@ -312,17 +286,29 @@ def __init__( # both vmin and vmax are specified vmin, vmax = vmin_specified, vmax_specified - ig = ImageGraphic( - frame, - name="image_widget_managed", - vmin=vmin, - vmax=vmax, - **graphic_kwargs, - ) - subplot.add_graphic(ig) + if self._n_display_dims[i] == 2: + graphic = ImageGraphic( + data=image_data, + name="image_widget_managed", + vmin=vmin, + vmax=vmax, + **graphic_kwargs[i] + ) + elif self._n_display_dims[i] == 3: + graphic = ImageVolumeGraphic( + data=image_data, + name="image_widget_managed", + vmin=vmin, + vmax=vmax, + **graphic_kwargs[i] + ) + + subplot.add_graphic(graphic) if self._histogram_widget: - hlut = HistogramLUTTool(data=d, images=ig, name="histogram_lut") + hlut = HistogramLUTTool( + data=d, images=ig, name="histogram_lut", histogram=self._image_arrays[i].histogram + ) subplot.docks["right"].add_graphic(hlut) subplot.docks["right"].size = 80 @@ -330,12 +316,7 @@ def __init__( subplot.docks["right"].controller.enabled = False # hard code the expected height so that the first render looks right in tests, docs etc. - if len(self.slider_dims) == 0: - ui_size = 57 - if len(self.slider_dims) == 1: - ui_size = 106 - elif len(self.slider_dims) == 2: - ui_size = 155 + ui_size = 57 + (self.n_sliders * 55) self._image_widget_sliders = ImageWidgetSliders( figure=self.figure, @@ -353,6 +334,131 @@ def __init__( self._initialized = True + @property + def figure(self) -> Figure: + """ + ``Figure`` used by `ImageWidget`. + """ + return self._figure + + @property + def graphics(self) -> list[ImageGraphic]: + """List of ``ImageWidget`` managed graphics.""" + iw_managed = list() + for subplot in self.figure: + # empty subplots will not have any image widget data + if len(subplot.graphics) > 0: + iw_managed.append(subplot["image_widget_managed"]) + return iw_managed + + @property + def cmap(self) -> list[str]: + cmaps = list() + for g in self.graphics: + cmaps.append(g.cmap) + + return cmaps + + @cmap.setter + def cmap(self, names: str | list[str]): + if isinstance(names, list): + if not all([isinstance(n, str) for n in names]): + raise TypeError( + f"Must pass cmap name as a `str` of list of `str`, you have passed:\n{names}" + ) + + if not len(names) == len(self.graphics): + raise IndexError( + f"If passing a list of cmap names, the length of the list must be the same as the number of " + f"image widget subplots. You have passed: {len(names)} cmap names and have " + f"{len(self.graphics)} image widget subplots" + ) + + for name, g in zip(names, self.graphics): + g.cmap = name + + elif isinstance(names, str): + for g in self.graphics: + g.cmap = names + + @property + def data(self) -> list[np.ndarray]: + """data currently displayed in the widget""" + return self._data + + @property + def index(self) -> tuple[int, ...]: + """ + Get or set the current index + + Returns + ------- + index: tuple[int, ...] + integer index for each slider dimension + + """ + return self._current_index + + @index.setter + def index(self, new_index: Sequence[int, ...]): + if not self._initialized: + return + + if self._reentrant_block: + return + + try: + self._reentrant_block = True # block re-execution until current_index has *fully* completed execution + + if len(new_index) != self.n_sliders: + raise IndexError( + f"len(index) != ImageWidget.n_sliders, {len(new_index)} != {self.n_sliders}. " + f"The length of the index must be the same as the number of sliders" + ) + + for image_array, graphic in zip(self._image_arrays, self.graphics): + new_data = self._get_image(new_index, image_array) + graphic.data = new_data + + self._index = new_index + + # call any event handlers + for handler in self._current_index_changed_handlers: + handler(self.index) + except Exception as exc: + # raise original exception + raise exc # current_index setter has raised. The lines above below are probably more relevant! + finally: + # set_value has finished executing, now allow future executions + self._reentrant_block = False + + def _get_image(self, slider_indices: tuple[int, ...], array_index: int): + a = self._image_arrays[array_index] + n = a.n_slider_dims + + if self._sliders_dim_order == "right": + return a.get(self.index[-n:]) + elif self._sliders_dim_order == "left": + return a.get(self.index[:n]) + + @property + def n_sliders(self) -> int: + return max([a.n_slider_dims for a in self._image_arrays]) + + @property + def bounds(self) -> tuple[int, ...]: + """The max bound across all dimensions across all data arrays""" + # initialize with 0 + bounds = [0] * len(self.n_sliders) + + for dim in range(self.n_sliders): + # across each dim + for array in self._image_arrays: + # across each data array + bounds[dim] = max(array.shape[dim], bounds[dim]) + + return bounds + def add_event_handler(self, handler: callable, event: str = "current_index"): """ Register an event handler. @@ -428,6 +534,10 @@ def reset_vmin_vmax_frame(self): # set the data using the current image graphic data hlut.set_data(subplot["image_widget_managed"].data.value) + @property + def data(self) -> tuple[np.ndarray, ...]: + return tuple(array.data for array in self._image_arrays) + def set_data( self, new_data: np.ndarray | list[np.ndarray], @@ -451,8 +561,8 @@ def set_data( """ if reset_indices: - for key in self.current_index: - self.current_index[key] = 0 + for key in self.index: + self.index[key] = 0 # set slider max according to new data max_lengths = dict() @@ -539,7 +649,7 @@ def set_data( ) # force graphics to update - self.current_index = self.current_index + self.index = self.index def show(self, **kwargs): """ From 62599a543a483612a6f6eaa320f662bcd3df0a58 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 6 Nov 2025 02:30:13 -0500 Subject: [PATCH 10/81] basics working :D --- fastplotlib/widgets/image_widget/__init__.py | 2 +- fastplotlib/widgets/image_widget/_array.py | 94 ++++++++++----- fastplotlib/widgets/image_widget/_sliders.py | 26 ++-- fastplotlib/widgets/image_widget/_widget.py | 120 ++++++++++--------- 4 files changed, 141 insertions(+), 101 deletions(-) diff --git a/fastplotlib/widgets/image_widget/__init__.py b/fastplotlib/widgets/image_widget/__init__.py index 2c217038e..9197b4928 100644 --- a/fastplotlib/widgets/image_widget/__init__.py +++ b/fastplotlib/widgets/image_widget/__init__.py @@ -2,7 +2,7 @@ if IMGUI: from ._widget import ImageWidget - from ._array import ImageWidgetArray + from ._array import NDImageArray else: diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index c4f73b33c..ccf75749a 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -12,6 +12,17 @@ WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] +ARRAY_LIKE_ATTRS = ["shape", "ndim", "__getitem__"] + +def is_arraylike(obj) -> bool: + """checks if the array is sufficiently array-like for ImageWidget""" + for attr in ARRAY_LIKE_ATTRS: + if not hasattr(obj, attr): + return False + + return True + + class NDImageArray: def __init__( self, @@ -19,7 +30,7 @@ def __init__( n_display_dims: Literal[2, 3] = 2, rgb: bool = False, window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, - window_sizes: tuple[int | None, ...] = None, + window_sizes: tuple[int | None, ...] | int = None, window_order: tuple[int, ...] = None, finalizer_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, @@ -75,14 +86,14 @@ def __init__( # set as False until window funcs stuff and finalizer func is all set self._compute_histogram = False - self._window_funcs = window_funcs - self._window_sizes = window_sizes - self._window_order = window_order + self.window_funcs = window_funcs + self.window_sizes = window_sizes + self.window_order = window_order self._finalizer_func = finalizer_func self._compute_histogram = compute_histogram - self._compute_histogram() + self._recompute_histogram() @property def data(self) -> ArrayLike: @@ -92,13 +103,12 @@ def data(self) -> ArrayLike: @data.setter def data(self, data: ArrayLike): # check that all array-like attributes are present - required_attrs = ["shape", "ndim", "__getitem__"] - for attr in required_attrs: - if not hasattr(data, attr): - raise TypeError( - f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" - f"{required_attrs}" - ) + if not is_arraylike(data): + raise TypeError( + f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" + f"{ARRAY_LIKE_ATTRS}" + ) + self._data = data self._recompute_histogram() @@ -148,7 +158,7 @@ def display_dims(self) -> tuple[int, int] | tuple[int, int, int]: @property def window_funcs( self, - ) -> tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None: + ) -> tuple[WindowFuncCallable | None, ...] | None: """get or set window functions, see docstring for details""" return self._window_funcs @@ -161,6 +171,9 @@ def window_funcs( self._window_funcs = None return + if callable(window_funcs): + window_funcs = (window_funcs,) + # if all are None if all([f is None for f in window_funcs]): self._window_funcs = None @@ -187,11 +200,11 @@ def _validate_window_func(self, funcs): f"following function signature: {sig}" ) - if not len(window_funcs) == self.n_slider_dims: + if not len(funcs) == self.n_slider_dims: raise IndexError( f"number of `window_funcs` must be the same as the number of slider dims, " f"i.e. `data.ndim` - n_display_dims, your data array has {data.ndim} dimensions " - f"and you passed {len(window_funcs)} `window_funcs`: {window_funcs}" + f"and you passed {len(funcs)} `window_funcs`: {funcs}" ) @property @@ -205,6 +218,9 @@ def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): self._window_sizes = None return + if isinstance(window_sizes, int): + window_sizes = (window_sizes,) + # if all are None if all([w is None for w in window_sizes]): self._window_sizes = None @@ -307,7 +323,7 @@ def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: """ return self._histogram - def _apply_window_function(self, index: tuple[int, ...]) -> ArrayLike: + def _apply_window_function(self, indices: tuple[int, ...]) -> ArrayLike: """applies the window functions for each dimension specified""" # window size for each dim winds = self._window_sizes @@ -316,7 +332,13 @@ def _apply_window_function(self, index: tuple[int, ...]) -> ArrayLike: if winds is None or funcs is None: # no window funcs or window sizes, just slice data and return - return self.data[index] + # clamp to max bounds + indexer = list() + for dim, i in enumerate(indices): + i = min(self.shape[dim] - 1, i) + indexer.append(i) + + return self.data[tuple(indexer)] # order in which window funcs are applied order = self._window_order @@ -339,14 +361,24 @@ def _apply_window_function(self, index: tuple[int, ...]) -> ArrayLike: # the final indexer which will be used on the data array indexer = list() - for i, w, f in zip(index, winds, funcs): + for dim_index, (i, w, f) in enumerate(zip(indices, winds, funcs)): + # clamp i within the max bounds + i = min(self.shape[dim_index] - 1, i) + if (w is not None) and (f is not None): # specify slice window if both window size and function for this dim are not None hw = int((w - 1) / 2) # half window - # start, stop, step - s = slice(i - hw, i + hw, 1) + + # start index cannot be less than 0 + start = max(0, i - hw) + + # stop index cannot exceed the bounds of this dimension + stop = min(self.shape[dim_index] - 1, i + hw) + + s = slice(start, stop, 1) else: s = slice(i, i + 1, 1) + indexer.append(s) # apply indexer to slice data with the specified windows @@ -360,7 +392,7 @@ def _apply_window_function(self, index: tuple[int, ...]) -> ArrayLike: return data_sliced - def get(self, index: tuple[int, ...]): + def get(self, indices: tuple[int, ...]): """ Get the data at the given index, process data through the window functions. @@ -369,25 +401,28 @@ def get(self, index: tuple[int, ...]): Parameters ---------- - index: tuple[int, ...] - Get the processed data at this index. + indices: tuple[int, ...] + Get the processed data at this index. Must provide a value for each dimension. Example: get((100, 5)) """ if self.n_slider_dims != 0: - if len(index) != len(self.n_slider_dims): + if len(indices) != self.n_slider_dims: raise IndexError( - f"Must specify index for every slider dim, you have specified an index: {index}\n" + f"Must specify index for every slider dim, you have specified an index: {indices}\n" f"But there are: {self.n_slider_dims} slider dims." ) # get output after processing through all window funcs # squeeze to remove all dims of size 1 - window_output = self._apply_window_function(index).squeeze() + window_output = self._apply_window_function(indices).squeeze() + else: + # data is a static image or volume + window_output = self.data # apply finalizer func if self.finalizer_func is not None: final_output = self.finalizer_func(window_output) - if final_output.ndim != self.n_display_dims: + if final_output.ndim != (self.n_display_dims + int(self.rgb)): raise IndexError( f"Final output after of the `finalizer_func` must match the number of display dims." f"Output after `finalizer_func` returned an array with {final_output.ndim} dims and " @@ -396,11 +431,12 @@ def get(self, index: tuple[int, ...]): else: # check that output ndim after window functions matches display dims final_output = window_output - if final_output.ndim != self.n_display_dims: + if final_output.ndim != (self.n_display_dims + int(self.rgb)): raise IndexError( f"Final output after of the `window_funcs` must match the number of display dims." f"Output after `window_funcs` returned an array with {window_output.ndim} dims and " - f"of shape: {window_output.shape}, expected {self.n_display_dims} dims" + f"of shape: {window_output.shape}{' with rgb(a) channels' if self.rgb else ''}, " + f"expected {self.n_display_dims} dims" ) return final_output diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 3519c2d7d..499aaab4c 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -49,15 +49,15 @@ def set_index(self, dim: int, new_index: int): return # set current_index - index = list(self._image_widget.index) + index = list(self._image_widget.indices) index[dim] = new_index - self._image_widget.index = index + self._image_widget.indices = index def update(self): """called on every render cycle to update the GUI elements""" # store the new index of the image widget ("t" and "z") - new_index = dict() + new_index = list() # flag if the index changed flag_index_changed = False @@ -79,7 +79,7 @@ def update(self): now = perf_counter() # buttons and slider UI elements for each dim - for dim in self._image_widget.slider_dims: + for dim in range(self._image_widget.n_sliders): imgui.push_id(f"{self._id_counter}_{dim}") if self._playing[dim]: @@ -90,7 +90,7 @@ def update(self): # if in play mode and enough time has elapsed w.r.t. the desired framerate, increment the index if now - self._last_frame_time[dim] >= self._frame_time[dim]: - self.set_index(dim, self._image_widget.index[dim] + 1) + self.set_index(dim, self._image_widget.indices[dim] + 1) self._last_frame_time[dim] = now else: @@ -104,12 +104,12 @@ def update(self): imgui.same_line() # step back one frame button if imgui.button(label=fa.ICON_FA_BACKWARD_STEP) and not self._playing[dim]: - self.set_index(dim, self._image_widget.index[dim] - 1) + self.set_index(dim, self._image_widget.indices[dim] - 1) imgui.same_line() # step forward one frame button if imgui.button(label=fa.ICON_FA_FORWARD_STEP) and not self._playing[dim]: - self.set_index(dim, self._image_widget.index[dim] + 1) + self.set_index(dim, self._image_widget.indices[dim] + 1) imgui.same_line() # stop button @@ -144,10 +144,10 @@ def update(self): self._fps[dim] = value self._frame_time[dim] = 1 / value - val = self._image_widget.index[dim] - vmax = self._image_widget._dims_max_bounds[dim] - 1 + val = self._image_widget.indices[dim] + vmax = self._image_widget.bounds[dim] - 1 - imgui.text(f"{dim}: ") + imgui.text(f"dim {dim}: ") imgui.same_line() # so that slider occupies full width imgui.set_next_item_width(self.width * 0.85) @@ -160,11 +160,11 @@ def update(self): flags = imgui.SliderFlags_.always_clamp # slider for this dimension - changed, index = imgui.slider_int( + changed, dim_index = imgui.slider_int( f"{dim}", v=val, v_min=0, v_max=vmax, flags=flags ) - new_index[dim] = index + new_index.append(dim_index) # if the slider value changed for this dimension flag_index_changed |= changed @@ -173,6 +173,6 @@ def update(self): if flag_index_changed: # if any slider dim changed set the new index of the image widget - self._image_widget.index = new_index + self._image_widget.indices = new_index self.size = int(imgui.get_window_height()) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 3995b5244..e0810fdd5 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -10,7 +10,7 @@ from ...utils import calculate_figure_shape, quick_min_max from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders -from ._array import NDImageArray +from ._array import NDImageArray, WindowFuncCallable, ArrayLike, is_arraylike class ImageWidget: @@ -88,12 +88,12 @@ def __init__( if figure_kwargs is None: figure_kwargs = dict() - if _is_arraylike(data): + if is_arraylike(data): data = [data] if isinstance(data, list): # verify that it's a list of np.ndarray - if not all([_is_arraylike(d) for d in data]): + if not all([is_arraylike(d) for d in data]): raise TypeError( f"`data` must be an array-like type or a list of array-like." f"You have passed the following type {type(data)}" @@ -144,14 +144,6 @@ def __init__( "number of `names` for subplots must be same as the number of data arrays" ) - else: - raise TypeError( - f"If passing a list to `data` all elements must be an " - f"array-like type representing an n-dimensional image. " - f"You have passed the following types:\n" - f"{[type(a) for a in data]}" - ) - # verify window funcs if window_funcs is None: win_funcs = [None] * len(data) @@ -169,6 +161,9 @@ def __init__( if window_sizes is None: win_sizes = [window_sizes] * len(data) + elif isinstance(window_sizes, int): + win_sizes = [window_sizes] * len(data) + elif all([isinstance(size, int) or size is None for size in window_sizes]): # window sizes defined per-dim across all data arrays win_sizes = [window_sizes] * len(data) @@ -194,7 +189,7 @@ def __init__( elif callable(finalizer_funcs): # same finalizer func for all data arrays - finalizer_funcs = [finalizer_funcs] * len(data) + final_funcs = [finalizer_funcs] * len(data) elif len(finalizer_funcs) != len(data): raise IndexError @@ -229,7 +224,7 @@ def __init__( window_funcs=win_funcs[i], window_sizes=win_sizes[i], window_order=win_order[i], - finalizer_func=finalizer_funcs[i], + finalizer_func=final_funcs[i], compute_histogram=histogram_widget, ) @@ -255,11 +250,12 @@ def __init__( self._histogram_widget = histogram_widget - self._index = tuple(0 for i in range(self.n_sliders)) + self._indices = tuple(0 for i in range(self.n_sliders)) - for i, subplot in zip(range(len(self._image_arrays)), figure): - image_data = self._get_image(self._index, self._image_arrays[i]) + for i, subplot in zip(range(len(self._image_arrays)), self.figure): + image_data = self._get_image(self._indices, self._image_arrays[i]) + # next 20 lines are just vmin, vmax parsing vmin_specified, vmax_specified = None, None if "vmin" in graphic_kwargs[i].keys(): vmin_specified = graphic_kwargs[i].pop("vmin") @@ -268,7 +264,7 @@ def __init__( if (vmin_specified is None) or (vmax_specified is None): # if either vmin or vmax are not specified, calculate an estimate by subsampling - vmin_estimate, vmax_estimate = quick_min_max(self._image_arrays[i]) + vmin_estimate, vmax_estimate = quick_min_max(self._image_arrays[i].data) # decide vmin, vmax passed to ImageGraphic constructor based on whether it's user specified or now if vmin_specified is None: @@ -287,6 +283,7 @@ def __init__( vmin, vmax = vmin_specified, vmax_specified if self._n_display_dims[i] == 2: + # create an Image graphic = ImageGraphic( data=image_data, name="image_widget_managed", @@ -295,6 +292,7 @@ def __init__( **graphic_kwargs[i] ) elif self._n_display_dims[i] == 3: + # create an ImageVolume graphic = ImageVolumeGraphic( data=image_data, name="image_widget_managed", @@ -307,7 +305,10 @@ def __init__( if self._histogram_widget: hlut = HistogramLUTTool( - data=d, images=ig, name="histogram_lut", histogram=self._image_arrays[i].histogram + data=self._image_arrays[i].data, + images=graphic, + name="histogram_lut", + histogram=self._image_arrays[i].histogram ) subplot.docks["right"].add_graphic(hlut) @@ -328,7 +329,7 @@ def __init__( self.figure.add_gui(self._image_widget_sliders) - self._current_index_changed_handlers = set() + self._indices_changed_handlers = set() self._reentrant_block = False @@ -387,20 +388,20 @@ def data(self) -> list[np.ndarray]: return self._data @property - def index(self) -> tuple[int, ...]: + def indices(self) -> tuple[int, ...]: """ - Get or set the current index + Get or set the current indices Returns ------- - index: tuple[int, ...] + indices: tuple[int, ...] integer index for each slider dimension """ - return self._current_index + return self._indices - @index.setter - def index(self, new_index: Sequence[int, ...]): + @indices.setter + def indices(self, new_indices: Sequence[int]): if not self._initialized: return @@ -408,38 +409,42 @@ def index(self, new_index: Sequence[int, ...]): return try: - self._reentrant_block = True # block re-execution until current_index has *fully* completed execution + self._reentrant_block = True # block re-execution until new_indices has *fully* completed execution - if len(new_index) != self.n_sliders: + if len(new_indices) != self.n_sliders: raise IndexError( - f"len(index) != ImageWidget.n_sliders, {len(new_index)} != {self.n_sliders}. " - f"The length of the index must be the same as the number of sliders" + f"len(new_indices) != ImageWidget.n_sliders, {len(new_indices)} != {self.n_sliders}. " + f"The length of the new_indices must be the same as the number of sliders" ) + if any([i < 0 for i in new_indices]): + raise IndexError(f"only positive index values are supported, you have passed: {new_indices}") + for image_array, graphic in zip(self._image_arrays, self.graphics): - new_data = self._get_image(new_index, image_array) + new_data = self._get_image(new_indices, image_array) graphic.data = new_data - self._index = new_index + self._indices = new_indices # call any event handlers - for handler in self._current_index_changed_handlers: - handler(self.index) + for handler in self._indices_changed_handlers: + handler(self.indices) + except Exception as exc: # raise original exception - raise exc # current_index setter has raised. The lines above below are probably more relevant! + raise exc # indices setter has raised. The lines above below are probably more relevant! finally: # set_value has finished executing, now allow future executions self._reentrant_block = False - def _get_image(self, slider_indices: tuple[int, ...], array_index: int): - a = self._image_arrays[array_index] - n = a.n_slider_dims + def _get_image(self, slider_indices: tuple[int, ...], image_array: NDImageArray): + n = image_array.n_slider_dims if self._sliders_dim_order == "right": - return a.get(self.index[-n:]) + return image_array.get(self.indices[-n:]) + elif self._sliders_dim_order == "left": - return a.get(self.index[:n]) + return image_array.get(self.indices[:n]) @property def n_sliders(self) -> int: @@ -449,7 +454,7 @@ def n_sliders(self) -> int: def bounds(self) -> tuple[int, ...]: """The max bound across all dimensions across all data arrays""" # initialize with 0 - bounds = [0] * len(self.n_sliders) + bounds = [0] * self.n_sliders for dim in range(self.n_sliders): # across each dim @@ -459,30 +464,29 @@ def bounds(self) -> tuple[int, ...]: return bounds - def add_event_handler(self, handler: callable, event: str = "current_index"): + def add_event_handler(self, handler: callable, event: str = "indices"): """ Register an event handler. - Currently the only event that ImageWidget supports is "current_index". This event is - emitted whenever the index of the ImageWidget changes. + Currently the only event that ImageWidget supports is "indices". This event is + emitted whenever the indices of the ImageWidget changes. Parameters ---------- handler: callable - callback function, must take a dict as the only argument. This dict will be the `current_index` + callback function, must take a tuple of int as the only argument. This tuple will be the `indices` - event: str, "current_index" - the only supported event is "current_index" + event: str, "indices" + the only supported event is "indices" Example ------- .. code-block:: py - def my_handler(index): - print(index) - # example prints: {"t": 100} if data has only time dimension - # "z" index will be another key if present in the data, ex: {"t": 100, "z": 5} + def my_handler(indices): + print(indices) + # example prints: (100, 15) if the data has 2 slider dimensions with sliders at positions 100, 15 # create an image widget iw = ImageWidget(...) @@ -491,20 +495,20 @@ def my_handler(index): iw.add_event_handler(my_handler) """ - if event != "current_index": + if event != "indices": raise ValueError( - "`current_index` is the only event supported by `ImageWidget`" + "`indices` is the only event supported by `ImageWidget`" ) - self._current_index_changed_handlers.add(handler) + self._indices_changed_handlers.add(handler) def remove_event_handler(self, handler: callable): """Remove a registered event handler""" - self._current_index_changed_handlers.remove(handler) + self._indices_changed_handlers.remove(handler) def clear_event_handlers(self): """Clear all registered event handlers""" - self._current_index_changed_handlers.clear() + self._indices_changed_handlers.clear() def reset_vmin_vmax(self): """ @@ -561,8 +565,8 @@ def set_data( """ if reset_indices: - for key in self.index: - self.index[key] = 0 + for key in self.indices: + self.indices[key] = 0 # set slider max according to new data max_lengths = dict() @@ -649,7 +653,7 @@ def set_data( ) # force graphics to update - self.index = self.index + self.indices = self.indices def show(self, **kwargs): """ From a5877bc53f3d5953d577eb353168a89fdc5c3fd7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 6 Nov 2025 02:31:20 -0500 Subject: [PATCH 11/81] black --- fastplotlib/tools/_histogram_lut.py | 6 ++- fastplotlib/widgets/image_widget/_array.py | 1 + fastplotlib/widgets/image_widget/_widget.py | 41 ++++++++++++++------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index 1a31235c1..98ec4f4fa 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -88,7 +88,9 @@ def __init__( self._scale_factor: float = 1.0 - hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data, histogram) + hist, edges, hist_scaled, edges_flanked = self._calculate_histogram( + data, histogram + ) line_data = np.column_stack([hist_scaled, edges_flanked]) @@ -229,7 +231,7 @@ def _fpl_add_plot_area_hook(self, plot_area): self._plot_area.auto_scale() self._plot_area.controller.enabled = True - def _calculate_histogram(self, data, histogram = None): + def _calculate_histogram(self, data, histogram=None): if histogram is None: # get a subsampled view of this array data_ss = subsample_array(data, max_size=int(1e6)) # 1e6 is default diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index ccf75749a..92d74bc23 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -14,6 +14,7 @@ ARRAY_LIKE_ATTRS = ["shape", "ndim", "__getitem__"] + def is_arraylike(obj) -> bool: """checks if the array is sufficiently array-like for ImageWidget""" for attr in ARRAY_LIKE_ATTRS: diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index e0810fdd5..9cfa8d9b2 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -21,10 +21,23 @@ def __init__( n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, rgb: bool | Sequence[bool] = None, cmap: str = "plasma", - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None | Sequence[tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None ]= None, - window_sizes: tuple[int | None, ...] | Sequence[tuple[int | None, ...] | None] = None, + window_funcs: ( + tuple[WindowFuncCallable | None, ...] + | WindowFuncCallable + | None + | Sequence[ + tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None + ] + ) = None, + window_sizes: ( + tuple[int | None, ...] | Sequence[tuple[int | None, ...] | None] + ) = None, window_order: tuple[int, ...] | Sequence[tuple[int, ...] | None] = None, - finalizer_funcs: Callable[[ArrayLike], ArrayLike] | Sequence[Callable[[ArrayLike], ArrayLike]] | None = None, + finalizer_funcs: ( + Callable[[ArrayLike], ArrayLike] + | Sequence[Callable[[ArrayLike], ArrayLike]] + | None + ) = None, sliders_dim_order: Literal["right", "left"] = "right", figure_shape: tuple[int, int] = None, names: Sequence[str] = None, @@ -135,9 +148,7 @@ def __init__( if names is not None: if not all([isinstance(n, str) for n in names]): - raise TypeError( - "optional argument `names` must be a Sequence of str" - ) + raise TypeError("optional argument `names` must be a Sequence of str") if len(names) != len(data): raise ValueError( @@ -148,7 +159,9 @@ def __init__( if window_funcs is None: win_funcs = [None] * len(data) - elif callable(window_funcs) or all([callable(f) or f is None for f in window_funcs]): + elif callable(window_funcs) or all( + [callable(f) or f is None for f in window_funcs] + ): # across all data arrays # one window function defined for all dims, or window functions defined per-dim win_funcs = [window_funcs] * len(data) @@ -289,7 +302,7 @@ def __init__( name="image_widget_managed", vmin=vmin, vmax=vmax, - **graphic_kwargs[i] + **graphic_kwargs[i], ) elif self._n_display_dims[i] == 3: # create an ImageVolume @@ -298,7 +311,7 @@ def __init__( name="image_widget_managed", vmin=vmin, vmax=vmax, - **graphic_kwargs[i] + **graphic_kwargs[i], ) subplot.add_graphic(graphic) @@ -308,7 +321,7 @@ def __init__( data=self._image_arrays[i].data, images=graphic, name="histogram_lut", - histogram=self._image_arrays[i].histogram + histogram=self._image_arrays[i].histogram, ) subplot.docks["right"].add_graphic(hlut) @@ -418,7 +431,9 @@ def indices(self, new_indices: Sequence[int]): ) if any([i < 0 for i in new_indices]): - raise IndexError(f"only positive index values are supported, you have passed: {new_indices}") + raise IndexError( + f"only positive index values are supported, you have passed: {new_indices}" + ) for image_array, graphic in zip(self._image_arrays, self.graphics): new_data = self._get_image(new_indices, image_array) @@ -496,9 +511,7 @@ def my_handler(indices): """ if event != "indices": - raise ValueError( - "`indices` is the only event supported by `ImageWidget`" - ) + raise ValueError("`indices` is the only event supported by `ImageWidget`") self._indices_changed_handlers.add(handler) From 43f0e58a6cd77dc9ac89fb0c046362f4394c30f3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 6 Nov 2025 02:50:58 -0500 Subject: [PATCH 12/81] most of the basics work in iw --- fastplotlib/widgets/image_widget/_array.py | 30 ++++++++++++--------- fastplotlib/widgets/image_widget/_widget.py | 15 +++++++++-- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index 92d74bc23..10b86a31e 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -188,23 +188,24 @@ def window_funcs( def _validate_window_func(self, funcs): if isinstance(funcs, (tuple, list)): for f in funcs: - if not callable(f): + if f is None: + pass + elif callable(f): + sig = inspect.signature(f) + + 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, you passed: {f} with the " + f"following function signature: {sig}" + ) + else: raise TypeError( - f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {window_funcs}" - ) - - sig = inspect.signature(f) - - 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, you passed: {f} with the " - f"following function signature: {sig}" + f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {funcs}" ) if not len(funcs) == self.n_slider_dims: raise IndexError( f"number of `window_funcs` must be the same as the number of slider dims, " - f"i.e. `data.ndim` - n_display_dims, your data array has {data.ndim} dimensions " f"and you passed {len(funcs)} `window_funcs`: {funcs}" ) @@ -353,11 +354,14 @@ def _apply_window_function(self, indices: tuple[int, ...]) -> ArrayLike: # order = (0, 1) # `1` is removed from the order since that window_func is `None` order = tuple( - d for d in order if windows[d] is not None and funcs[d] is not None + d for d in order if winds[d] is not None and funcs[d] is not None ) else: # sequential order - order = tuple(range(self.n_slider_dims)) + order = list() + for d in range(self.n_slider_dims): + if winds[d] is not None and funcs[d] is not None: + order.append(d) # the final indexer which will be used on the data array indexer = list() diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 9cfa8d9b2..0958f692d 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -20,7 +20,7 @@ def __init__( array_types: NDImageArray | list[NDImageArray] = NDImageArray, n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, rgb: bool | Sequence[bool] = None, - cmap: str = "plasma", + cmap: str | Sequence[str]= "plasma", window_funcs: ( tuple[WindowFuncCallable | None, ...] | WindowFuncCallable @@ -259,6 +259,15 @@ def __init__( elif len(graphic_kwargs) != len(data): raise IndexError + if cmap is None: + cmap = [None] * len(data) + + elif isinstance(cmap, str): + cmap = [cmap] * len(data) + + elif not all([isinstance(c, str) for c in cmap]): + raise TypeError(f"`cmap` must be a or a list/tuple of ") + self._figure: Figure = Figure(**figure_kwargs_default) self._histogram_widget = histogram_widget @@ -295,6 +304,8 @@ def __init__( # both vmin and vmax are specified vmin, vmax = vmin_specified, vmax_specified + graphic_kwargs[i]["cmap"] = cmap[i] + if self._n_display_dims[i] == 2: # create an Image graphic = ImageGraphic( @@ -330,7 +341,7 @@ def __init__( subplot.docks["right"].controller.enabled = False # hard code the expected height so that the first render looks right in tests, docs etc. - ui_size = 57 + (self.n_sliders * 55) + ui_size = 57 + (self.n_sliders * 50) self._image_widget_sliders = ImageWidgetSliders( figure=self.figure, From 43f5423536f9ef9245709db9e5d68d4c19df7b1b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 6 Nov 2025 03:17:32 -0500 Subject: [PATCH 13/81] fix --- fastplotlib/widgets/image_widget/_widget.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 0958f692d..a7230d07b 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -169,6 +169,8 @@ def __init__( # if the above two clauses didn't trigger, then window_funcs defined per-dim, per data array elif len(window_funcs) != len(data): raise IndexError + else: + win_funcs = window_funcs # verify window sizes if window_sizes is None: @@ -184,6 +186,8 @@ def __init__( elif len(window_sizes) != len(data): # window sizes defined per-dim, per data array raise IndexError + else: + win_sizes = window_sizes # verify window orders if window_order is None: @@ -196,6 +200,9 @@ def __init__( elif len(window_order) != len(data): raise IndexError + else: + win_order = window_order + # verify finalizer function if finalizer_funcs is None: final_funcs = [None] * len(data) @@ -207,6 +214,9 @@ def __init__( elif len(finalizer_funcs) != len(data): raise IndexError + else: + final_funcs = finalizer_funcs + # verify number of display dims if isinstance(n_display_dims, int): if n_display_dims not in (2, 3): @@ -485,6 +495,8 @@ def bounds(self) -> tuple[int, ...]: for dim in range(self.n_sliders): # across each dim for array in self._image_arrays: + if dim > array.n_slider_dims - 1: + continue # across each data array bounds[dim] = max(array.shape[dim], bounds[dim]) From 9dc1998a02c389b95b64fe74526766875cc1916e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 7 Nov 2025 01:58:46 -0500 Subject: [PATCH 14/81] progress --- fastplotlib/widgets/image_widget/_array.py | 72 ++++++------ .../widgets/image_widget/_properties.py | 88 ++++++++++++++ fastplotlib/widgets/image_widget/_sliders.py | 32 ++--- fastplotlib/widgets/image_widget/_widget.py | 110 ++++++++++++------ 4 files changed, 208 insertions(+), 94 deletions(-) create mode 100644 fastplotlib/widgets/image_widget/_properties.py diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index 10b86a31e..114820bb0 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -129,7 +129,7 @@ def rgb(self) -> bool: @property def n_slider_dims(self) -> int: """number of slider dimensions""" - return self.data.ndim - self.n_display_dims - int(self.rgb) + return self.ndim - self.n_display_dims - int(self.rgb) @property def slider_dims(self) -> tuple[int, ...] | None: @@ -144,12 +144,18 @@ def n_display_dims(self) -> Literal[2, 3]: """get or set the number of display dimensions, `2` for 2D image and `3` for volume images""" return self._n_display_dims - @n_display_dims.setter - def n_display_dims(self, n: Literal[2, 3]): - if n not in (2, 3): - raise ValueError("`n_display_dims` must be an with a value of 2 or 3") - self._n_display_dims = n - self._recompute_histogram() + # TODO: make n_display_dims settable, requires thinking about inserting and poping indices in ImageWidget + # @n_display_dims.setter + # def n_display_dims(self, n: Literal[2, 3]): + # if n not in (2, 3): + # raise ValueError("`n_display_dims` must be an with a value of 2 or 3") + # self._n_display_dims = n + # self._recompute_histogram() + # + # @property + # def max_n_display_dims(self) -> int: + # """maximum number of possible display dims""" + # return min(3, self.ndim - int(self.rgb)) @property def display_dims(self) -> tuple[int, int] | tuple[int, int, int]: @@ -195,8 +201,8 @@ def _validate_window_func(self, funcs): 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, you passed: {f} with the " - f"following function signature: {sig}" + f"Each window function must take an `axis` and `keepdims` argument, " + f"you passed: {f} with the following function signature: {sig}" ) else: raise TypeError( @@ -234,37 +240,37 @@ def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): ) if not len(window_sizes) == self.n_slider_dims: - raise window_sizes( + raise IndexError( f"number of `window_sizes` must be the same as the number of slider dims, " - f"i.e. `data.ndim` - n_display_dims, your data array has {data.ndim} dimensions " + f"i.e. `data.ndim` - n_display_dims, your data array has {self.ndim} dimensions " f"and you passed {len(window_sizes)} `window_sizes`: {window_sizes}" ) - # make all window sizes are valid numbers - _window_sizes = list() - for i, w in enumerate(window_sizes): - if w is None: - _window_sizes.append(None) - continue - - if w < 0: - raise ValueError( - f"negative window size passed, all `window_sizes` must be positive " - f"integers or `None`, you passed: {_window_sizes}" - ) + # make all window sizes are valid numbers + _window_sizes = list() + for i, w in enumerate(window_sizes): + if w is None: + _window_sizes.append(None) + continue + + if w < 0: + raise ValueError( + f"negative window size passed, all `window_sizes` must be positive " + f"integers or `None`, you passed: {_window_sizes}" + ) - if w in (0, 1): - # this is not a real window, set as None - w = None + if w in (0, 1): + # this is not a real window, set as None + w = None - if w % 2 == 0: - # odd window sizes makes most sense - warn( - f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" - ) - w += 1 + if w % 2 == 0: + # odd window sizes makes most sense + warn( + f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" + ) + w += 1 - _window_sizes.append(w) + _window_sizes.append(w) self._window_sizes = tuple(window_sizes) self._recompute_histogram() diff --git a/fastplotlib/widgets/image_widget/_properties.py b/fastplotlib/widgets/image_widget/_properties.py new file mode 100644 index 000000000..09ca5f8e3 --- /dev/null +++ b/fastplotlib/widgets/image_widget/_properties.py @@ -0,0 +1,88 @@ +from typing import Iterable + +import numpy as np + + +class BaseProperty: + """A list that allows only in-place modifications and updates the ImageWidget""" + + def __init__( + self, + data: Iterable | None, + image_widget, + attribute: str, + key_types: type | tuple[type, ...], + value_types: type | tuple[type, ...], + ): + if data is not None: + data = list(data) + + self._data = data + + self._image_widget = image_widget + self._attribute = attribute + + self._key_types = key_types + self._value_types = value_types + + @property + def data(self): + raise NotImplementedError + + def __getitem__(self, item): + if self.data is None: + return getattr(self._image_widget, self._attribute)[item] + + return self.data[item] + + def __setitem__(self, key, value): + if not isinstance(key, self._key_types): + raise TypeError + + if isinstance(key, str): + # subplot name, find the numerical index + for i, subplot in enumerate(self._image_widget.figure): + if subplot.name == key: + key = i + break + else: + raise IndexError(f"No subplot with given name: {key}") + + if not isinstance(value, self._value_types): + raise TypeError + + new_list = list(self.data) + + new_list[key] = value + + setattr(self._image_widget, self._attribute, new_list) + + def __repr__(self): + return str(self.data) + + +class ImageWidgetData(BaseProperty): + pass + + +class Indices(BaseProperty): + def __init__( + self, + data: Iterable, + image_widget, + ): + super().__init__( + data, + image_widget, + attribute="indices", + key_types=(int, np.integer), + value_types=(int, np.integer), + ) + + @property + def data(self) -> list[int]: + return self._data + + @data.setter + def data(self, new_data): + self._data[:] = new_data diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 499aaab4c..a24c48d69 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -14,16 +14,16 @@ def __init__(self, figure, size, location, title, image_widget): n_sliders = self._image_widget.n_sliders # whether or not a dimension is in play mode - self._playing: tuple[int, ...] = [False] * n_sliders + self._playing: list[bool] = [False] * n_sliders # approximate framerate for playing - self._fps: tuple[int, ...] = [20] * n_sliders + self._fps: list[int] = [20] * n_sliders # framerate converted to frame time - self._frame_time: tuple[int, ...] = [1 / 20] * n_sliders + self._frame_time: list[float] = [1 / 20] * n_sliders # last timepoint that a frame was displayed from a given dimension - self._last_frame_time: tuple[int, ...] = [20] * n_sliders + self._last_frame_time: list[float] = [perf_counter()] * n_sliders # loop playback self._loop = False @@ -48,20 +48,12 @@ def set_index(self, dim: int, new_index: int): self._playing[dim] = False return - # set current_index - index = list(self._image_widget.indices) - index[dim] = new_index - self._image_widget.indices = index + # set new index + self._image_widget.indices[dim] = new_index def update(self): """called on every render cycle to update the GUI elements""" - # store the new index of the image widget ("t" and "z") - new_index = list() - - # flag if the index changed - flag_index_changed = False - # reset vmin-vmax using full orig data if imgui.button(label=fa.ICON_FA_CIRCLE_HALF_STROKE + fa.ICON_FA_FILM): self._image_widget.reset_vmin_vmax() @@ -160,19 +152,13 @@ def update(self): flags = imgui.SliderFlags_.always_clamp # slider for this dimension - changed, dim_index = imgui.slider_int( + changed, index = imgui.slider_int( f"{dim}", v=val, v_min=0, v_max=vmax, flags=flags ) - new_index.append(dim_index) - - # if the slider value changed for this dimension - flag_index_changed |= changed + if changed: + self._image_widget.indices[dim] = index imgui.pop_id() - if flag_index_changed: - # if any slider dim changed set the new index of the image widget - self._image_widget.indices = new_index - self.size = int(imgui.get_window_height()) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index a7230d07b..a89f0fb1b 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -11,6 +11,7 @@ from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders from ._array import NDImageArray, WindowFuncCallable, ArrayLike, is_arraylike +from ._properties import Indices class ImageWidget: @@ -231,7 +232,7 @@ def __init__( else: raise TypeError - self._n_display_dims = n_display_dims + self._n_display_dims = tuple(n_display_dims) if sliders_dim_order not in ("left", "right"): raise ValueError @@ -243,7 +244,7 @@ def __init__( image_array = NDImageArray( data=data[i], rgb=rgb[i], - n_display_dims=n_display_dims[i], + n_display_dims=self._n_display_dims[i], window_funcs=win_funcs[i], window_sizes=win_sizes[i], window_order=win_order[i], @@ -282,10 +283,13 @@ def __init__( self._histogram_widget = histogram_widget - self._indices = tuple(0 for i in range(self.n_sliders)) + self._indices = Indices( + data=[0 for i in range(self.n_sliders)], + image_widget=self, + ) for i, subplot in zip(range(len(self._image_arrays)), self.figure): - image_data = self._get_image(self._indices, self._image_arrays[i]) + image_data = self._get_image(self._image_arrays[i]) # next 20 lines are just vmin, vmax parsing vmin_specified, vmax_specified = None, None @@ -387,34 +391,14 @@ def graphics(self) -> list[ImageGraphic]: return iw_managed @property - def cmap(self) -> list[str]: - cmaps = list() - for g in self.graphics: - cmaps.append(g.cmap) - - return cmaps + def cmap(self) -> tuple[str, ...]: + """get the cmaps, or set the cmap across all images""" + return tuple(g.cmap for g in self.graphics) @cmap.setter - def cmap(self, names: str | list[str]): - if isinstance(names, list): - if not all([isinstance(n, str) for n in names]): - raise TypeError( - f"Must pass cmap name as a `str` of list of `str`, you have passed:\n{names}" - ) - - if not len(names) == len(self.graphics): - raise IndexError( - f"If passing a list of cmap names, the length of the list must be the same as the number of " - f"image widget subplots. You have passed: {len(names)} cmap names and have " - f"{len(self.graphics)} image widget subplots" - ) - - for name, g in zip(names, self.graphics): - g.cmap = name - - elif isinstance(names, str): - for g in self.graphics: - g.cmap = names + def cmap(self, name: str): + for g in self.graphics: + g.cmap = name @property def data(self) -> list[np.ndarray]: @@ -422,13 +406,13 @@ def data(self) -> list[np.ndarray]: return self._data @property - def indices(self) -> tuple[int, ...]: + def indices(self) -> Indices: """ - Get or set the current indices + Get or set the current indices. Returns ------- - indices: tuple[int, ...] + indices: Indices integer index for each slider dimension """ @@ -457,10 +441,10 @@ def indices(self, new_indices: Sequence[int]): ) for image_array, graphic in zip(self._image_arrays, self.graphics): - new_data = self._get_image(new_indices, image_array) + new_data = self._get_image(image_array) graphic.data = new_data - self._indices = new_indices + self._indices.data = new_indices # call any event handlers for handler in self._indices_changed_handlers: @@ -473,7 +457,7 @@ def indices(self, new_indices: Sequence[int]): # set_value has finished executing, now allow future executions self._reentrant_block = False - def _get_image(self, slider_indices: tuple[int, ...], image_array: NDImageArray): + def _get_image(self, image_array: NDImageArray): n = image_array.n_slider_dims if self._sliders_dim_order == "right": @@ -482,6 +466,58 @@ def _get_image(self, slider_indices: tuple[int, ...], image_array: NDImageArray) elif self._sliders_dim_order == "left": return image_array.get(self.indices[:n]) + @property + def n_display_dims(self) -> tuple[int]: + return self._n_display_dims + + # TODO: make n_display_dims settable, requires thinking about how to pop or insert dims into indices + # @n_display_dims.setter + # def n_display_dims(self, new_n_display_dims: Sequence[int]): + # if len(new_n_display_dims) != len(self.data): + # raise IndexError + # + # if not all([n in (2, 3) for n in new_n_display_dims]): + # raise ValueError + # + # for i, (new, old, subplot) in enumerate(zip(new_n_display_dims, self.n_display_dims, self.figure)): + # if new == old: + # continue + # + # image_array = self._image_arrays[i] + # + # if new > image_array.max_n_display_dims: + # raise IndexError( + # f"number of display dims exceeds maximum number of possible " + # f"display dimensions: {image_array.max_n_display_dims}, for array at index: " + # f"{i} with shape: {image_array.shape}, and rgb set to: {image_array.rgb}" + # ) + # + # image_array.n_display_dims = new + # + # image_data = self._get_image(image_array) + # cmap = self.cmap[i] + # + # subplot.delete_graphic(subplot["image_widget_managed"]) + # + # if new == 2: + # g = subplot.add_image( + # data=image_data, + # cmap=cmap, + # name="image_widget_managed" + # ) + # subplot.camera.fov = 50 + # subplot.camera.show_object(g) + # subplot.controller = "panzoom" + # + # elif new == 3: + # subplot.add_image_volume( + # data=image_data, + # cmap=cmap, + # name="image_widget_managed" + # ) + # subplot.camera.fov = 50 + # subplot.controller = "orbit" + @property def n_sliders(self) -> int: return max([a.n_slider_dims for a in self._image_arrays]) @@ -562,8 +598,6 @@ def reset_vmin_vmax_frame(self): ImageGraphic instead of the data in the full data array. For example, if a post-processing function is used, the range of values in the ImageGraphic can be very different from the range of values in the full data array. - - TODO: We could think of applying the frame_apply funcs to a subsample of the entire array to get a better estimate of vmin vmax? """ for subplot in self.figure: From bcdd9b7ac0cfb5828771b521c8feeccc567c6f5d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 7 Nov 2025 18:14:59 -0500 Subject: [PATCH 15/81] progress but still broken --- fastplotlib/widgets/image_widget/_array.py | 22 +-- fastplotlib/widgets/image_widget/_sliders.py | 8 +- fastplotlib/widgets/image_widget/_widget.py | 141 +++++++++++-------- 3 files changed, 97 insertions(+), 74 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index 114820bb0..67160445e 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -145,17 +145,17 @@ def n_display_dims(self) -> Literal[2, 3]: return self._n_display_dims # TODO: make n_display_dims settable, requires thinking about inserting and poping indices in ImageWidget - # @n_display_dims.setter - # def n_display_dims(self, n: Literal[2, 3]): - # if n not in (2, 3): - # raise ValueError("`n_display_dims` must be an with a value of 2 or 3") - # self._n_display_dims = n - # self._recompute_histogram() - # - # @property - # def max_n_display_dims(self) -> int: - # """maximum number of possible display dims""" - # return min(3, self.ndim - int(self.rgb)) + @n_display_dims.setter + def n_display_dims(self, n: Literal[2, 3]): + if n not in (2, 3): + raise ValueError("`n_display_dims` must be an with a value of 2 or 3") + self._n_display_dims = n + self._recompute_histogram() + + @property + def max_n_display_dims(self) -> int: + """maximum number of possible display dims""" + return min(3, self.ndim - int(self.rgb)) @property def display_dims(self) -> tuple[int, int] | tuple[int, int, int]: diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index a24c48d69..8cf1cee79 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -49,7 +49,9 @@ def set_index(self, dim: int, new_index: int): return # set new index - self._image_widget.indices[dim] = new_index + new_indices = list(self._image_widget.indices) + new_indices[dim] = new_index + self._image_widget.indices = new_indices def update(self): """called on every render cycle to update the GUI elements""" @@ -157,7 +159,9 @@ def update(self): ) if changed: - self._image_widget.indices[dim] = index + new_indices = list(self._image_widget.indices) + new_indices[dim] = index + self._image_widget.indices = new_indices imgui.pop_id() diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index a89f0fb1b..4234fb024 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -11,7 +11,6 @@ from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders from ._array import NDImageArray, WindowFuncCallable, ArrayLike, is_arraylike -from ._properties import Indices class ImageWidget: @@ -232,7 +231,7 @@ def __init__( else: raise TypeError - self._n_display_dims = tuple(n_display_dims) + n_display_dims = tuple(n_display_dims) if sliders_dim_order not in ("left", "right"): raise ValueError @@ -244,7 +243,7 @@ def __init__( image_array = NDImageArray( data=data[i], rgb=rgb[i], - n_display_dims=self._n_display_dims[i], + n_display_dims=n_display_dims[i], window_funcs=win_funcs[i], window_sizes=win_sizes[i], window_order=win_order[i], @@ -283,10 +282,7 @@ def __init__( self._histogram_widget = histogram_widget - self._indices = Indices( - data=[0 for i in range(self.n_sliders)], - image_widget=self, - ) + self._indices = [0 for i in range(self.n_sliders)] for i, subplot in zip(range(len(self._image_arrays)), self.figure): image_data = self._get_image(self._image_arrays[i]) @@ -320,7 +316,7 @@ def __init__( graphic_kwargs[i]["cmap"] = cmap[i] - if self._n_display_dims[i] == 2: + if self._image_arrays[i].n_display_dims == 2: # create an Image graphic = ImageGraphic( data=image_data, @@ -329,7 +325,7 @@ def __init__( vmax=vmax, **graphic_kwargs[i], ) - elif self._n_display_dims[i] == 3: + elif self._image_arrays[i].n_display_dims == 3: # create an ImageVolume graphic = ImageVolumeGraphic( data=image_data, @@ -406,17 +402,17 @@ def data(self) -> list[np.ndarray]: return self._data @property - def indices(self) -> Indices: + def indices(self) -> tuple[int, ...]: """ Get or set the current indices. Returns ------- - indices: Indices + indices: tuple[int, ...] integer index for each slider dimension """ - return self._indices + return tuple(self._indices) @indices.setter def indices(self, new_indices: Sequence[int]): @@ -444,7 +440,7 @@ def indices(self, new_indices: Sequence[int]): new_data = self._get_image(image_array) graphic.data = new_data - self._indices.data = new_indices + self._indices[:] = new_indices # call any event handlers for handler in self._indices_changed_handlers: @@ -468,55 +464,78 @@ def _get_image(self, image_array: NDImageArray): @property def n_display_dims(self) -> tuple[int]: - return self._n_display_dims + return tuple(img.n_display_dims for img in self._image_arrays) # TODO: make n_display_dims settable, requires thinking about how to pop or insert dims into indices - # @n_display_dims.setter - # def n_display_dims(self, new_n_display_dims: Sequence[int]): - # if len(new_n_display_dims) != len(self.data): - # raise IndexError - # - # if not all([n in (2, 3) for n in new_n_display_dims]): - # raise ValueError - # - # for i, (new, old, subplot) in enumerate(zip(new_n_display_dims, self.n_display_dims, self.figure)): - # if new == old: - # continue - # - # image_array = self._image_arrays[i] - # - # if new > image_array.max_n_display_dims: - # raise IndexError( - # f"number of display dims exceeds maximum number of possible " - # f"display dimensions: {image_array.max_n_display_dims}, for array at index: " - # f"{i} with shape: {image_array.shape}, and rgb set to: {image_array.rgb}" - # ) - # - # image_array.n_display_dims = new - # - # image_data = self._get_image(image_array) - # cmap = self.cmap[i] - # - # subplot.delete_graphic(subplot["image_widget_managed"]) - # - # if new == 2: - # g = subplot.add_image( - # data=image_data, - # cmap=cmap, - # name="image_widget_managed" - # ) - # subplot.camera.fov = 50 - # subplot.camera.show_object(g) - # subplot.controller = "panzoom" - # - # elif new == 3: - # subplot.add_image_volume( - # data=image_data, - # cmap=cmap, - # name="image_widget_managed" - # ) - # subplot.camera.fov = 50 - # subplot.controller = "orbit" + @n_display_dims.setter + def n_display_dims(self, new_ndd: Sequence[int]): + if len(new_ndd) != len(self.data): + raise IndexError + + if not all([n in (2, 3) for n in new_ndd]): + raise ValueError + + # old n_display_dims + old_ndd = tuple(self.n_display_dims) + + # first update image arrays + for image_array, new, old in zip(self._image_arrays, new_ndd, old_ndd): + if new == old: + continue + + if new > image_array.max_n_display_dims: + raise IndexError( + f"number of display dims exceeds maximum number of possible " + f"display dmight beimensions: {image_array.max_n_display_dims}, for array at index: " + f"{i} with shape: {image_array.shape}, and rgb set to: {image_array.rgb}" + ) + + image_array.n_display_dims = new + + # add or remove dims from indices + # trim any excess dimensions + while len(self._indices) > self.n_sliders: + # pop from: left <- right + self._indices.pop(len(self._indices) - 1) + + # add any new dimensions that aren't present + while len(self.indices) < self.n_sliders: + # insert from: left <- right + self._indices.append(0) + + # update graphics where display dims have changed accordings to indices + + for image_array, subplot, new, old in zip(self._image_arrays, self.figure, new_ndd, old_ndd): + if new == old: + continue + + image_data = self._get_image(image_array) + cmap = subplot["image_widget_managed"].cmap + + subplot.delete_graphic(subplot["image_widget_managed"]) + + if new == 2: + g = subplot.add_image( + data=image_data, + cmap=cmap, + name="image_widget_managed" + ) + subplot.camera.fov = 50 + subplot.camera.show_object(g.world_object) + subplot.controller = "panzoom" + + elif new == 3: + g = subplot.add_image_volume( + data=image_data, + cmap=cmap, + name="image_widget_managed" + ) + subplot.camera.fov = 50 + subplot.controller = "orbit" + subplot.camera.show_object(g.world_object) + + # force an update + # self.indices = self.indices @property def n_sliders(self) -> int: @@ -733,7 +752,7 @@ def show(self, **kwargs): ---------- kwargs: Any - passed to `Figure.show()` + passed to `Figure.show()`t Returns ------- From cb4b6f59f151df720cf131544d9b4df2f8c7ddfa Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 7 Nov 2025 18:41:30 -0500 Subject: [PATCH 16/81] flippin display dims works --- fastplotlib/widgets/image_widget/_sliders.py | 18 ++++++++++++++++++ fastplotlib/widgets/image_widget/_widget.py | 7 +++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 8cf1cee79..a3b9ae66c 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -34,6 +34,19 @@ def __init__(self, figure, size, location, title, image_widget): self._playing[0] = True self._loop = True + self.pause = False + + def push_dim(self): + self._playing.append(False) + self._fps.append(20) + self._frame_time.append(1 / 20) + self._last_frame_time.append(perf_counter()) + + def pop_dim(self): + i = len(self._image_widget.indices) - 1 + for l in [self._playing, self._fps, self._frame_time, self._last_frame_time]: + l.pop(i) + def set_index(self, dim: int, new_index: int): """set the index of the ImageWidget""" @@ -72,8 +85,13 @@ def update(self): # time now now = perf_counter() + # self._size = 300#57 + (self._image_widget.n_sliders * 50) + # buttons and slider UI elements for each dim for dim in range(self._image_widget.n_sliders): + if self.pause: + continue + imgui.push_id(f"{self._id_counter}_{dim}") if self._playing[dim]: diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 4234fb024..bf5a61417 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -350,12 +350,9 @@ def __init__( subplot.docks["right"].auto_scale(maintain_aspect=False) subplot.docks["right"].controller.enabled = False - # hard code the expected height so that the first render looks right in tests, docs etc. - ui_size = 57 + (self.n_sliders * 50) - self._image_widget_sliders = ImageWidgetSliders( figure=self.figure, - size=ui_size, + size=180, location="bottom", title="ImageWidget Controls", image_widget=self, @@ -497,11 +494,13 @@ def n_display_dims(self, new_ndd: Sequence[int]): while len(self._indices) > self.n_sliders: # pop from: left <- right self._indices.pop(len(self._indices) - 1) + self._image_widget_sliders.pop_dim() # add any new dimensions that aren't present while len(self.indices) < self.n_sliders: # insert from: left <- right self._indices.append(0) + self._image_widget_sliders.push_dim() # update graphics where display dims have changed accordings to indices From 304868222c7021868baa8a9e21b919b3a456d63a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 00:32:54 -0500 Subject: [PATCH 17/81] camera scale must be positive for MIP rendering --- fastplotlib/widgets/image_widget/_array.py | 2 +- fastplotlib/widgets/image_widget/_widget.py | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index 67160445e..7179a23ac 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -24,7 +24,7 @@ def is_arraylike(obj) -> bool: return True -class NDImageArray: +class NDImageProcessor: def __init__( self, data: ArrayLike, diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index bf5a61417..525114d66 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -10,14 +10,14 @@ from ...utils import calculate_figure_shape, quick_min_max from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders -from ._array import NDImageArray, WindowFuncCallable, ArrayLike, is_arraylike +from ._array import NDImageProcessor, WindowFuncCallable, ArrayLike, is_arraylike class ImageWidget: def __init__( self, data: np.ndarray | list[np.ndarray], - array_types: NDImageArray | list[NDImageArray] = NDImageArray, + array_types: NDImageProcessor | list[NDImageProcessor] = NDImageProcessor, n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, rgb: bool | Sequence[bool] = None, cmap: str | Sequence[str]= "plasma", @@ -238,9 +238,9 @@ def __init__( self._sliders_dim_order = sliders_dim_order # make NDImageArrays - self._image_arrays: list[NDImageArray] = list() + self._image_arrays: list[NDImageProcessor] = list() for i in range(len(data)): - image_array = NDImageArray( + image_array = NDImageProcessor( data=data[i], rgb=rgb[i], n_display_dims=n_display_dims[i], @@ -450,7 +450,7 @@ def indices(self, new_indices: Sequence[int]): # set_value has finished executing, now allow future executions self._reentrant_block = False - def _get_image(self, image_array: NDImageArray): + def _get_image(self, image_array: NDImageProcessor): n = image_array.n_slider_dims if self._sliders_dim_order == "right": @@ -520,7 +520,6 @@ def n_display_dims(self, new_ndd: Sequence[int]): name="image_widget_managed" ) subplot.camera.fov = 50 - subplot.camera.show_object(g.world_object) subplot.controller = "panzoom" elif new == 3: @@ -531,10 +530,16 @@ def n_display_dims(self, new_ndd: Sequence[int]): ) subplot.camera.fov = 50 subplot.controller = "orbit" - subplot.camera.show_object(g.world_object) + + # make sure all 3D dimension scales are positive + for dim in ["x", "y", "z"]: + if getattr(subplot.camera.world, f"scale_{dim}") < 0: + setattr(subplot.camera.world, f"scale_{dim}", 1) + + subplot.camera.show_object(g.world_object) # force an update - # self.indices = self.indices + self.indices = self.indices @property def n_sliders(self) -> int: From dd9bc8470c7f4022a1ecaab712252d803680cf84 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 02:53:15 -0500 Subject: [PATCH 18/81] a very difficult to encounter iterator bug! --- fastplotlib/layouts/_plot_area.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 3c5027caf..17473372c 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -218,7 +218,7 @@ def controller(self, new_controller: str | pygfx.Controller): # pygfx plans on refactoring viewports anyways if self.parent is not None: if self.parent.__class__.__name__.endswith("Figure"): - for subplot in self.parent: + for subplot in self.parent._subplots.ravel(): if subplot.camera in cameras_list: new_controller.register_events(subplot.viewport) subplot._controller = new_controller From 52f09722edf3f76bb617bb9fba67279544e05c18 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 02:55:47 -0500 Subject: [PATCH 19/81] patch iterator caveats --- fastplotlib/layouts/_figure.py | 24 +++++++++++++++--------- fastplotlib/layouts/_plot_area.py | 3 +++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index e65c0c132..74bd14129 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -554,7 +554,7 @@ def show_tooltips(self, val: bool): if val: # register all graphics - for subplot in self: + for subplot in self._subplots.ravel(): for graphic in subplot.graphics: self._tooltip_manager.register(graphic) @@ -572,7 +572,7 @@ def _render(self, draw=True): # call the animation functions before render self._call_animate_functions(self._animate_funcs_pre) - for subplot in self: + for subplot in self._subplots.ravel(): subplot._render() # overlay render pass @@ -639,14 +639,14 @@ def show( sidecar_kwargs = dict() # flip y-axis if ImageGraphics are present - for subplot in self: + for subplot in self._subplots.ravel(): for g in subplot.graphics: if isinstance(g, ImageGraphic): subplot.camera.local.scale_y *= -1 break if autoscale: - for subplot in self: + for subplot in self._subplots.ravel(): if maintain_aspect is None: _maintain_aspect = subplot.camera.maintain_aspect else: @@ -655,7 +655,7 @@ def show( # set axes visibility if False if not axes_visible: - for subplot in self: + for subplot in self._subplots.ravel(): subplot.axes.visible = False # parse based on canvas type @@ -679,7 +679,7 @@ def show( elif self.canvas.__class__.__name__ == "OffscreenRenderCanvas": # for test and docs gallery screenshots self._fpl_reset_layout() - for subplot in self: + for subplot in self._subplots.ravel(): subplot.axes.update_using_camera() # render call is blocking only on github actions for some reason, @@ -803,7 +803,7 @@ def clear_animations(self, removal: str = None): def clear(self): """Clear all Subplots""" - for subplot in self: + for subplot in self._subplots.ravel(): subplot.clear() def export_numpy(self, rgb: bool = False) -> np.ndarray: @@ -962,10 +962,16 @@ def __getitem__(self, index: str | int | tuple[int, int]) -> Subplot: return subplot raise IndexError(f"no subplot with given name: {index}") + if isinstance(index, (int, np.integer)): + return self._subplots.ravel()[index] + if isinstance(self.layout, GridLayout): return self._subplots[index[0], index[1]] - return self._subplots[index] + raise TypeError( + f"Can index figure using subplot name, numerical subplot index, or a " + f"tuple[int, int] if the layout is a grid" + ) def __iter__(self): self._current_iter = iter(range(len(self))) @@ -988,6 +994,6 @@ def __repr__(self): return ( f"fastplotlib.{self.__class__.__name__}" f" Subplots:\n" - f"\t{newline.join(subplot.__str__() for subplot in self)}" + f"\t{newline.join(subplot.__str__() for subplot in self._subplots.ravel())}" f"\n" ) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 17473372c..ef360a7b9 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -218,6 +218,9 @@ def controller(self, new_controller: str | pygfx.Controller): # pygfx plans on refactoring viewports anyways if self.parent is not None: if self.parent.__class__.__name__.endswith("Figure"): + # always use figure._subplots.ravel() in internal fastplotlib code + # otherwise if we use `for subplot in figure`, this could conflict + # with a user's iterator where they are doing `for subplot in figure` !!! for subplot in self.parent._subplots.ravel(): if subplot.camera in cameras_list: new_controller.register_events(subplot.viewport) From 4df72b9dc4661bdebb7665bb364bdffa98f947bc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 04:29:45 -0500 Subject: [PATCH 20/81] mostly worksgit status --- fastplotlib/widgets/image_widget/_array.py | 64 +++++++---- fastplotlib/widgets/image_widget/_widget.py | 117 +++++++++++++------- 2 files changed, 116 insertions(+), 65 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_array.py index 7179a23ac..0f734cfe2 100644 --- a/fastplotlib/widgets/image_widget/_array.py +++ b/fastplotlib/widgets/image_widget/_array.py @@ -5,29 +5,17 @@ import numpy as np from numpy.typing import ArrayLike -from ...utils import subsample_array +from ...utils import subsample_array, ArrayProtocol, ARRAY_LIKE_ATTRS # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] -ARRAY_LIKE_ATTRS = ["shape", "ndim", "__getitem__"] - - -def is_arraylike(obj) -> bool: - """checks if the array is sufficiently array-like for ImageWidget""" - for attr in ARRAY_LIKE_ATTRS: - if not hasattr(obj, attr): - return False - - return True - - class NDImageProcessor: def __init__( self, - data: ArrayLike, + data: ArrayLike | None, n_display_dims: Literal[2, 3] = 2, rgb: bool = False, window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, @@ -79,14 +67,13 @@ def __init__( Disable if slow. """ + # set as False until data, window funcs stuff and finalizer func is all set + self._compute_histogram = False - self._data = data + self.data = data self._n_display_dims = n_display_dims self._rgb = rgb - # set as False until window funcs stuff and finalizer func is all set - self._compute_histogram = False - self.window_funcs = window_funcs self.window_sizes = window_sizes self.window_order = window_order @@ -97,17 +84,26 @@ def __init__( self._recompute_histogram() @property - def data(self) -> ArrayLike: + def data(self) -> ArrayLike | None: """get or set the data array""" return self._data @data.setter def data(self, data: ArrayLike): # check that all array-like attributes are present - if not is_arraylike(data): + if data is None: + self._data = None + return + + if not isinstance(data, ArrayProtocol): raise TypeError( f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" - f"{ARRAY_LIKE_ATTRS}" + f"{ARRAY_LIKE_ATTRS}, or they must be `None`" + ) + + if data.ndim < 2: + raise IndexError( + f"Image data must have a minimum of 2 dimensions, you have passed an array of shape: {data.shape}" ) self._data = data @@ -115,10 +111,16 @@ def data(self, data: ArrayLike): @property def ndim(self) -> int: + if self.data is None: + return 0 + return self.data.ndim @property def shape(self) -> tuple[int, ...]: + if self._data is None: + return tuple() + return self.data.shape @property @@ -129,6 +131,9 @@ def rgb(self) -> bool: @property def n_slider_dims(self) -> int: """number of slider dimensions""" + if self._data is None: + return 0 + return self.ndim - self.n_display_dims - int(self.rgb) @property @@ -139,6 +144,13 @@ def slider_dims(self) -> tuple[int, ...] | None: return tuple(range(self.n_slider_dims)) + @property + def slider_dims_shape(self) -> tuple[int, ...] | None: + if self.n_slider_dims == 0: + return None + + return tuple(self.shape[i] for i in self.slider_dims) + @property def n_display_dims(self) -> Literal[2, 3]: """get or set the number of display dimensions, `2` for 2D image and `3` for volume images""" @@ -155,7 +167,8 @@ def n_display_dims(self, n: Literal[2, 3]): @property def max_n_display_dims(self) -> int: """maximum number of possible display dims""" - return min(3, self.ndim - int(self.rgb)) + # min 2, max 3, accounts for if data is None and ndim is 0 + return max(2, min(3, self.ndim - int(self.rgb))) @property def display_dims(self) -> tuple[int, int] | tuple[int, int, int]: @@ -403,7 +416,7 @@ def _apply_window_function(self, indices: tuple[int, ...]) -> ArrayLike: return data_sliced - def get(self, indices: tuple[int, ...]): + def get(self, indices: tuple[int, ...]) -> ArrayLike | None: """ Get the data at the given index, process data through the window functions. @@ -417,6 +430,9 @@ def get(self, indices: tuple[int, ...]): Example: get((100, 5)) """ + if self.data is None: + return None + if self.n_slider_dims != 0: if len(indices) != self.n_slider_dims: raise IndexError( @@ -460,7 +476,7 @@ def _recompute_histogram(self): (histogram_values, bin_edges) """ - if not self._compute_histogram: + if not self._compute_histogram or self.data is None: self._histogram = None return diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 525114d66..3f2df831b 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -7,16 +7,16 @@ from ...layouts import ImguiFigure as Figure from ...graphics import ImageGraphic, ImageVolumeGraphic -from ...utils import calculate_figure_shape, quick_min_max +from ...utils import calculate_figure_shape, quick_min_max, ArrayProtocol, ARRAY_LIKE_ATTRS from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders -from ._array import NDImageProcessor, WindowFuncCallable, ArrayLike, is_arraylike +from ._array import NDImageProcessor, WindowFuncCallable class ImageWidget: def __init__( self, - data: np.ndarray | list[np.ndarray], + data: ArrayProtocol | list[ArrayProtocol] | None | list[None], array_types: NDImageProcessor | list[NDImageProcessor] = NDImageProcessor, n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, rgb: bool | Sequence[bool] = None, @@ -34,8 +34,8 @@ def __init__( ) = None, window_order: tuple[int, ...] | Sequence[tuple[int, ...] | None] = None, finalizer_funcs: ( - Callable[[ArrayLike], ArrayLike] - | Sequence[Callable[[ArrayLike], ArrayLike]] + Callable[[ArrayProtocol], ArrayProtocol] + | Sequence[Callable[[ArrayProtocol], ArrayProtocol]] | None ) = None, sliders_dim_order: Literal["right", "left"] = "right", @@ -101,14 +101,14 @@ def __init__( if figure_kwargs is None: figure_kwargs = dict() - if is_arraylike(data): + if isinstance(data, ArrayProtocol) or (data is None): data = [data] if isinstance(data, list): # verify that it's a list of np.ndarray - if not all([is_arraylike(d) for d in data]): + if not all([isinstance(d, ArrayProtocol) or d is None for d in data]): raise TypeError( - f"`data` must be an array-like type or a list of array-like." + f"`data` must be an array-like type or a list of array-like or None." f"You have passed the following type {type(data)}" ) @@ -285,7 +285,11 @@ def __init__( self._indices = [0 for i in range(self.n_sliders)] for i, subplot in zip(range(len(self._image_arrays)), self.figure): - image_data = self._get_image(self._image_arrays[i]) + image_data = self._get_image(self._image_arrays[i], self._indices) + + if image_data is None: + # this subplot/data array is blank, skip + continue # next 20 lines are just vmin, vmax parsing vmin_specified, vmax_specified = None, None @@ -434,8 +438,13 @@ def indices(self, new_indices: Sequence[int]): ) for image_array, graphic in zip(self._image_arrays, self.graphics): - new_data = self._get_image(image_array) - graphic.data = new_data + new_data = self._get_image(image_array, indices=new_indices) + if new_data is None: + continue + + graphic.data.buffer[0, 0].data[:] = new_data + graphic.data.buffer[0, 0].update_full() + # print("set data", new_indices) self._indices[:] = new_indices @@ -450,14 +459,14 @@ def indices(self, new_indices: Sequence[int]): # set_value has finished executing, now allow future executions self._reentrant_block = False - def _get_image(self, image_array: NDImageProcessor): + def _get_image(self, image_array: NDImageProcessor, indices: Sequence[int]) -> ArrayProtocol: n = image_array.n_slider_dims if self._sliders_dim_order == "right": - return image_array.get(self.indices[-n:]) + return image_array.get(indices[-n:]) elif self._sliders_dim_order == "left": - return image_array.get(self.indices[:n]) + return image_array.get(indices[:n]) @property def n_display_dims(self) -> tuple[int]: @@ -472,23 +481,20 @@ def n_display_dims(self, new_ndd: Sequence[int]): if not all([n in (2, 3) for n in new_ndd]): raise ValueError - # old n_display_dims - old_ndd = tuple(self.n_display_dims) - # first update image arrays - for image_array, new, old in zip(self._image_arrays, new_ndd, old_ndd): - if new == old: - continue - + for i, (image_array, new) in enumerate(zip(self._image_arrays, new_ndd)): if new > image_array.max_n_display_dims: raise IndexError( f"number of display dims exceeds maximum number of possible " - f"display dmight beimensions: {image_array.max_n_display_dims}, for array at index: " + f"display dimensions: {image_array.max_n_display_dims}, for array at index: " f"{i} with shape: {image_array.shape}, and rgb set to: {image_array.rgb}" ) image_array.n_display_dims = new + self._reset_config() + + def _reset_sliders(self): # add or remove dims from indices # trim any excess dimensions while len(self._indices) > self.n_sliders: @@ -502,27 +508,33 @@ def n_display_dims(self, new_ndd: Sequence[int]): self._indices.append(0) self._image_widget_sliders.push_dim() - # update graphics where display dims have changed accordings to indices - - for image_array, subplot, new, old in zip(self._image_arrays, self.figure, new_ndd, old_ndd): - if new == old: - continue - - image_data = self._get_image(image_array) - cmap = subplot["image_widget_managed"].cmap + def _reset_graphic(self): + for subplot, image_array in zip(self.figure, self._image_arrays): + image_data = self._get_image(image_array, indices=self.indices) + # check if a graphic exists + if "image_widget_managed" in subplot: + # create a new graphic only if the buffer shape doesn't match + if subplot["image_widget_managed"].data.value.shape == image_data.shape: + continue - subplot.delete_graphic(subplot["image_widget_managed"]) + # keep cmap + cmap = subplot["image_widget_managed"].cmap + # delete graphic since it will be replaced + subplot.delete_graphic(subplot["image_widget_managed"]) + else: + # default cmap + cmap = "plasma" - if new == 2: + if image_array.n_display_dims == 2: g = subplot.add_image( data=image_data, cmap=cmap, name="image_widget_managed" ) - subplot.camera.fov = 50 + subplot.camera.fov = 0 subplot.controller = "panzoom" - elif new == 3: + elif image_array.n_display_dims == 3: g = subplot.add_image_volume( data=image_data, cmap=cmap, @@ -531,13 +543,19 @@ def n_display_dims(self, new_ndd: Sequence[int]): subplot.camera.fov = 50 subplot.controller = "orbit" - # make sure all 3D dimension scales are positive + # make sure all 3D dimension camera scales are positive + # MIP rendering doesn't work with negative camera scales for dim in ["x", "y", "z"]: - if getattr(subplot.camera.world, f"scale_{dim}") < 0: - setattr(subplot.camera.world, f"scale_{dim}", 1) + if getattr(subplot.camera.local, f"scale_{dim}") < 0: + setattr(subplot.camera.local, f"scale_{dim}", 1) subplot.camera.show_object(g.world_object) + def _reset_config(self): + # reset the slider indices according to the new collection of dimensions + self._reset_sliders() + # update graphics where display dims have changed accordings to indices + self._reset_graphic() # force an update self.indices = self.indices @@ -551,13 +569,15 @@ def bounds(self) -> tuple[int, ...]: # initialize with 0 bounds = [0] * self.n_sliders - for dim in range(self.n_sliders): + # in reverse because dims go left <- right + for i, dim in enumerate(range(-1, -self.n_sliders - 1, -1)): # across each dim for array in self._image_arrays: - if dim > array.n_slider_dims - 1: + if i > array.n_slider_dims - 1: continue # across each data array - bounds[dim] = max(array.shape[dim], bounds[dim]) + # dims go left <- right + bounds[dim] = max(array.slider_dims_shape[dim], bounds[dim]) return bounds @@ -632,9 +652,24 @@ def reset_vmin_vmax_frame(self): hlut.set_data(subplot["image_widget_managed"].data.value) @property - def data(self) -> tuple[np.ndarray, ...]: + def data(self) -> tuple[ArrayProtocol, ...]: return tuple(array.data for array in self._image_arrays) + @data.setter + def data(self, new_data: Sequence[ArrayProtocol]): + if len(new_data) != len(self.data): + raise IndexError + + old_ndd = tuple(self.n_display_dims) + + for new_data, image_array in zip(new_data, self._image_arrays): + if new_data is image_array.data: + continue + + image_array.data = new_data + + self._reset_config() + def set_data( self, new_data: np.ndarray | list[np.ndarray], From 66ab13088bb409beb14f3a7578e9496f0ee2571c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 04:30:17 -0500 Subject: [PATCH 21/81] add ArrayProtocol --- fastplotlib/utils/__init__.py | 1 + fastplotlib/utils/_protocols.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 fastplotlib/utils/_protocols.py diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index dd527ca67..a513c791a 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -6,6 +6,7 @@ from .gpu import enumerate_adapters, select_adapter, print_wgpu_report from ._plot_helpers import * from .enums import * +from ._protocols import * @dataclass diff --git a/fastplotlib/utils/_protocols.py b/fastplotlib/utils/_protocols.py new file mode 100644 index 000000000..386df137a --- /dev/null +++ b/fastplotlib/utils/_protocols.py @@ -0,0 +1,18 @@ +from typing import Protocol, runtime_checkable + + +ARRAY_LIKE_ATTRS = ["shape", "ndim", "__getitem__"] + + +@runtime_checkable +class ArrayProtocol(Protocol): + @property + def ndim(self) -> int: + ... + + @property + def shape(self) -> tuple[int, ...]: + ... + + def __getitem__(self, key): + ... From ec8b0cc4a114534a18f56a44cc98927526568502 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 04:30:27 -0500 Subject: [PATCH 22/81] rename --- fastplotlib/widgets/image_widget/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/image_widget/__init__.py b/fastplotlib/widgets/image_widget/__init__.py index 9197b4928..7e142efeb 100644 --- a/fastplotlib/widgets/image_widget/__init__.py +++ b/fastplotlib/widgets/image_widget/__init__.py @@ -2,7 +2,7 @@ if IMGUI: from ._widget import ImageWidget - from ._array import NDImageArray + from ._array import NDImageProcessor else: From d00ebc082f693bbfe0b92b4b843bdbfb9853e5cd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 04:38:40 -0500 Subject: [PATCH 23/81] fixes --- fastplotlib/widgets/image_widget/_widget.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 3f2df831b..c8f5e62b8 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -442,9 +442,7 @@ def indices(self, new_indices: Sequence[int]): if new_data is None: continue - graphic.data.buffer[0, 0].data[:] = new_data - graphic.data.buffer[0, 0].update_full() - # print("set data", new_indices) + graphic.data = new_data self._indices[:] = new_indices @@ -508,9 +506,15 @@ def _reset_sliders(self): self._indices.append(0) self._image_widget_sliders.push_dim() - def _reset_graphic(self): + def _reset_graphics(self): for subplot, image_array in zip(self.figure, self._image_arrays): image_data = self._get_image(image_array, indices=self.indices) + if image_data is None: + # just delete graphic from this subplot + if "image_widget_managed" in subplot: + subplot.delete_graphic(subplot["image_widget_managed"]) + continue + # check if a graphic exists if "image_widget_managed" in subplot: # create a new graphic only if the buffer shape doesn't match @@ -555,7 +559,7 @@ def _reset_config(self): # reset the slider indices according to the new collection of dimensions self._reset_sliders() # update graphics where display dims have changed accordings to indices - self._reset_graphic() + self._reset_graphics() # force an update self.indices = self.indices From 85cf6e6ee3617b6ada968e89f7897804f1e1f55f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 05:02:19 -0500 Subject: [PATCH 24/81] set camera orthogonal to xy plane when going from 3d -> 2d --- fastplotlib/widgets/image_widget/_widget.py | 72 +++++++++++++-------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index c8f5e62b8..24fcd058d 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -12,12 +12,13 @@ from ._sliders import ImageWidgetSliders from ._array import NDImageProcessor, WindowFuncCallable - +import pygfx +pygfx.Camera class ImageWidget: def __init__( self, - data: ArrayProtocol | list[ArrayProtocol] | None | list[None], - array_types: NDImageProcessor | list[NDImageProcessor] = NDImageProcessor, + data: ArrayProtocol | Sequence[ArrayProtocol] | None | list[None], + array_types: NDImageProcessor | Sequence[NDImageProcessor] = NDImageProcessor, n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, rgb: bool | Sequence[bool] = None, cmap: str | Sequence[str]= "plasma", @@ -238,7 +239,7 @@ def __init__( self._sliders_dim_order = sliders_dim_order # make NDImageArrays - self._image_arrays: list[NDImageProcessor] = list() + self._image_processor: list[NDImageProcessor] = list() for i in range(len(data)): image_array = NDImageProcessor( data=data[i], @@ -251,7 +252,7 @@ def __init__( compute_histogram=histogram_widget, ) - self._image_arrays.append(image_array) + self._image_processor.append(image_array) figure_kwargs_default = {"controller_ids": "sync", "names": names} @@ -284,8 +285,8 @@ def __init__( self._indices = [0 for i in range(self.n_sliders)] - for i, subplot in zip(range(len(self._image_arrays)), self.figure): - image_data = self._get_image(self._image_arrays[i], self._indices) + for i, subplot in zip(range(len(self._image_processor)), self.figure): + image_data = self._get_image(self._image_processor[i], self._indices) if image_data is None: # this subplot/data array is blank, skip @@ -300,7 +301,7 @@ def __init__( if (vmin_specified is None) or (vmax_specified is None): # if either vmin or vmax are not specified, calculate an estimate by subsampling - vmin_estimate, vmax_estimate = quick_min_max(self._image_arrays[i].data) + vmin_estimate, vmax_estimate = quick_min_max(self._image_processor[i].data) # decide vmin, vmax passed to ImageGraphic constructor based on whether it's user specified or now if vmin_specified is None: @@ -320,7 +321,7 @@ def __init__( graphic_kwargs[i]["cmap"] = cmap[i] - if self._image_arrays[i].n_display_dims == 2: + if self._image_processor[i].n_display_dims == 2: # create an Image graphic = ImageGraphic( data=image_data, @@ -329,7 +330,7 @@ def __init__( vmax=vmax, **graphic_kwargs[i], ) - elif self._image_arrays[i].n_display_dims == 3: + elif self._image_processor[i].n_display_dims == 3: # create an ImageVolume graphic = ImageVolumeGraphic( data=image_data, @@ -343,10 +344,10 @@ def __init__( if self._histogram_widget: hlut = HistogramLUTTool( - data=self._image_arrays[i].data, + data=self._image_processor[i].data, images=graphic, name="histogram_lut", - histogram=self._image_arrays[i].histogram, + histogram=self._image_processor[i].histogram, ) subplot.docks["right"].add_graphic(hlut) @@ -437,7 +438,7 @@ def indices(self, new_indices: Sequence[int]): f"only positive index values are supported, you have passed: {new_indices}" ) - for image_array, graphic in zip(self._image_arrays, self.graphics): + for image_array, graphic in zip(self._image_processor, self.graphics): new_data = self._get_image(image_array, indices=new_indices) if new_data is None: continue @@ -457,22 +458,23 @@ def indices(self, new_indices: Sequence[int]): # set_value has finished executing, now allow future executions self._reentrant_block = False - def _get_image(self, image_array: NDImageProcessor, indices: Sequence[int]) -> ArrayProtocol: - n = image_array.n_slider_dims + def _get_image(self, image_processor: NDImageProcessor, indices: Sequence[int]) -> ArrayProtocol: + """Get a processed 2d or 3d image from the NDImage at the given indices""" + n = image_processor.n_slider_dims if self._sliders_dim_order == "right": - return image_array.get(indices[-n:]) + return image_processor.get(indices[-n:]) elif self._sliders_dim_order == "left": - return image_array.get(indices[:n]) + return image_processor.get(indices[:n]) @property - def n_display_dims(self) -> tuple[int]: - return tuple(img.n_display_dims for img in self._image_arrays) + def n_display_dims(self) -> tuple[Literal[2, 3]]: + """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" + return tuple(img.n_display_dims for img in self._image_processor) - # TODO: make n_display_dims settable, requires thinking about how to pop or insert dims into indices @n_display_dims.setter - def n_display_dims(self, new_ndd: Sequence[int]): + def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]]): if len(new_ndd) != len(self.data): raise IndexError @@ -480,7 +482,7 @@ def n_display_dims(self, new_ndd: Sequence[int]): raise ValueError # first update image arrays - for i, (image_array, new) in enumerate(zip(self._image_arrays, new_ndd)): + for i, (image_array, new) in enumerate(zip(self._image_processor, new_ndd)): if new > image_array.max_n_display_dims: raise IndexError( f"number of display dims exceeds maximum number of possible " @@ -493,6 +495,7 @@ def n_display_dims(self, new_ndd: Sequence[int]): self._reset_config() def _reset_sliders(self): + """reset the """ # add or remove dims from indices # trim any excess dimensions while len(self._indices) > self.n_sliders: @@ -507,7 +510,7 @@ def _reset_sliders(self): self._image_widget_sliders.push_dim() def _reset_graphics(self): - for subplot, image_array in zip(self.figure, self._image_arrays): + for subplot, image_array in zip(self.figure, self._image_processor): image_data = self._get_image(image_array, indices=self.indices) if image_data is None: # just delete graphic from this subplot @@ -535,8 +538,21 @@ def _reset_graphics(self): cmap=cmap, name="image_widget_managed" ) - subplot.camera.fov = 0 + + # set camera orthogonal to the xy plane, flip y axis + subplot.camera.set_state( + { + "position": [0, 0, -1], + "rotation": [0, 0, 0, 1], + "scale": [1, -1, 1], + "reference_up": [0, 1, 0], + "fov": 0, + "depth_range": None + } + ) + subplot.controller = "panzoom" + subplot.axes.intersection = None elif image_array.n_display_dims == 3: g = subplot.add_image_volume( @@ -565,7 +581,7 @@ def _reset_config(self): @property def n_sliders(self) -> int: - return max([a.n_slider_dims for a in self._image_arrays]) + return max([a.n_slider_dims for a in self._image_processor]) @property def bounds(self) -> tuple[int, ...]: @@ -576,7 +592,7 @@ def bounds(self) -> tuple[int, ...]: # in reverse because dims go left <- right for i, dim in enumerate(range(-1, -self.n_sliders - 1, -1)): # across each dim - for array in self._image_arrays: + for array in self._image_processor: if i > array.n_slider_dims - 1: continue # across each data array @@ -657,7 +673,7 @@ def reset_vmin_vmax_frame(self): @property def data(self) -> tuple[ArrayProtocol, ...]: - return tuple(array.data for array in self._image_arrays) + return tuple(array.data for array in self._image_processor) @data.setter def data(self, new_data: Sequence[ArrayProtocol]): @@ -666,7 +682,7 @@ def data(self, new_data: Sequence[ArrayProtocol]): old_ndd = tuple(self.n_display_dims) - for new_data, image_array in zip(new_data, self._image_arrays): + for new_data, image_array in zip(new_data, self._image_processor): if new_data is image_array.data: continue From 6cb6643d3079edbcb11ea465b7721969d04fb2aa Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 05:15:41 -0500 Subject: [PATCH 25/81] naming, cleaning --- fastplotlib/widgets/image_widget/_widget.py | 80 +++++++++++++-------- 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 24fcd058d..87234364d 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -17,8 +17,8 @@ class ImageWidget: def __init__( self, - data: ArrayProtocol | Sequence[ArrayProtocol] | None | list[None], - array_types: NDImageProcessor | Sequence[NDImageProcessor] = NDImageProcessor, + data: ArrayProtocol | list[ArrayProtocol | None] | None, + processor: NDImageProcessor | Sequence[NDImageProcessor] = NDImageProcessor, n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, rgb: bool | Sequence[bool] = None, cmap: str | Sequence[str]= "plasma", @@ -105,14 +105,36 @@ def __init__( if isinstance(data, ArrayProtocol) or (data is None): data = [data] - if isinstance(data, list): + elif isinstance(data, (list, tuple)): # verify that it's a list of np.ndarray if not all([isinstance(d, ArrayProtocol) or d is None for d in data]): raise TypeError( - f"`data` must be an array-like type or a list of array-like or None." + f"`data` must be an array-like type or a list/tuple of array-like or None. " f"You have passed the following type {type(data)}" ) + else: + raise TypeError( + f"`data` must be an array-like type or a list/tuple of array-like or None. " + f"You have passed the following type {type(data)}" + ) + + if issubclass(processor, NDImageProcessor): + processor = [processor] * len(data) + + elif isinstance(processor, (tuple, list)): + if not all([issubclass(p, NDImageProcessor) for p in processor]): + raise TypeError( + f"`processor` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " + f"list/tuple of `NDImageProcess` subclasses. You have passed: {processor}" + ) + + else: + raise TypeError( + f"`processor` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " + f"list/tuple of `NDImageProcess` subclasses. You have passed: {processor}" + ) + # subplot layout if figure_shape is None: if "shape" in figure_kwargs: @@ -239,9 +261,10 @@ def __init__( self._sliders_dim_order = sliders_dim_order # make NDImageArrays - self._image_processor: list[NDImageProcessor] = list() + self._image_processors: list[NDImageProcessor] = list() for i in range(len(data)): - image_array = NDImageProcessor( + cls = processor[i] + image_array = cls( data=data[i], rgb=rgb[i], n_display_dims=n_display_dims[i], @@ -252,7 +275,7 @@ def __init__( compute_histogram=histogram_widget, ) - self._image_processor.append(image_array) + self._image_processors.append(image_array) figure_kwargs_default = {"controller_ids": "sync", "names": names} @@ -285,8 +308,8 @@ def __init__( self._indices = [0 for i in range(self.n_sliders)] - for i, subplot in zip(range(len(self._image_processor)), self.figure): - image_data = self._get_image(self._image_processor[i], self._indices) + for i, subplot in zip(range(len(self._image_processors)), self.figure): + image_data = self._get_image(self._image_processors[i], self._indices) if image_data is None: # this subplot/data array is blank, skip @@ -301,7 +324,7 @@ def __init__( if (vmin_specified is None) or (vmax_specified is None): # if either vmin or vmax are not specified, calculate an estimate by subsampling - vmin_estimate, vmax_estimate = quick_min_max(self._image_processor[i].data) + vmin_estimate, vmax_estimate = quick_min_max(self._image_processors[i].data) # decide vmin, vmax passed to ImageGraphic constructor based on whether it's user specified or now if vmin_specified is None: @@ -321,7 +344,7 @@ def __init__( graphic_kwargs[i]["cmap"] = cmap[i] - if self._image_processor[i].n_display_dims == 2: + if self._image_processors[i].n_display_dims == 2: # create an Image graphic = ImageGraphic( data=image_data, @@ -330,7 +353,7 @@ def __init__( vmax=vmax, **graphic_kwargs[i], ) - elif self._image_processor[i].n_display_dims == 3: + elif self._image_processors[i].n_display_dims == 3: # create an ImageVolume graphic = ImageVolumeGraphic( data=image_data, @@ -344,10 +367,10 @@ def __init__( if self._histogram_widget: hlut = HistogramLUTTool( - data=self._image_processor[i].data, + data=self._image_processors[i].data, images=graphic, name="histogram_lut", - histogram=self._image_processor[i].histogram, + histogram=self._image_processors[i].histogram, ) subplot.docks["right"].add_graphic(hlut) @@ -355,7 +378,7 @@ def __init__( subplot.docks["right"].auto_scale(maintain_aspect=False) subplot.docks["right"].controller.enabled = False - self._image_widget_sliders = ImageWidgetSliders( + self._sliders_ui = ImageWidgetSliders( figure=self.figure, size=180, location="bottom", @@ -363,7 +386,7 @@ def __init__( image_widget=self, ) - self.figure.add_gui(self._image_widget_sliders) + self.figure.add_gui(self._sliders_ui) self._indices_changed_handlers = set() @@ -438,7 +461,7 @@ def indices(self, new_indices: Sequence[int]): f"only positive index values are supported, you have passed: {new_indices}" ) - for image_array, graphic in zip(self._image_processor, self.graphics): + for image_array, graphic in zip(self._image_processors, self.graphics): new_data = self._get_image(image_array, indices=new_indices) if new_data is None: continue @@ -471,7 +494,7 @@ def _get_image(self, image_processor: NDImageProcessor, indices: Sequence[int]) @property def n_display_dims(self) -> tuple[Literal[2, 3]]: """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" - return tuple(img.n_display_dims for img in self._image_processor) + return tuple(img.n_display_dims for img in self._image_processors) @n_display_dims.setter def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]]): @@ -482,7 +505,7 @@ def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]]): raise ValueError # first update image arrays - for i, (image_array, new) in enumerate(zip(self._image_processor, new_ndd)): + for i, (image_array, new) in enumerate(zip(self._image_processors, new_ndd)): if new > image_array.max_n_display_dims: raise IndexError( f"number of display dims exceeds maximum number of possible " @@ -501,16 +524,16 @@ def _reset_sliders(self): while len(self._indices) > self.n_sliders: # pop from: left <- right self._indices.pop(len(self._indices) - 1) - self._image_widget_sliders.pop_dim() + self._sliders_ui.pop_dim() # add any new dimensions that aren't present while len(self.indices) < self.n_sliders: # insert from: left <- right self._indices.append(0) - self._image_widget_sliders.push_dim() + self._sliders_ui.push_dim() def _reset_graphics(self): - for subplot, image_array in zip(self.figure, self._image_processor): + for subplot, image_array in zip(self.figure, self._image_processors): image_data = self._get_image(image_array, indices=self.indices) if image_data is None: # just delete graphic from this subplot @@ -581,7 +604,7 @@ def _reset_config(self): @property def n_sliders(self) -> int: - return max([a.n_slider_dims for a in self._image_processor]) + return max([a.n_slider_dims for a in self._image_processors]) @property def bounds(self) -> tuple[int, ...]: @@ -592,7 +615,7 @@ def bounds(self) -> tuple[int, ...]: # in reverse because dims go left <- right for i, dim in enumerate(range(-1, -self.n_sliders - 1, -1)): # across each dim - for array in self._image_processor: + for array in self._image_processors: if i > array.n_slider_dims - 1: continue # across each data array @@ -672,17 +695,18 @@ def reset_vmin_vmax_frame(self): hlut.set_data(subplot["image_widget_managed"].data.value) @property - def data(self) -> tuple[ArrayProtocol, ...]: - return tuple(array.data for array in self._image_processor) + def data(self) -> tuple[ArrayProtocol | None]: + """get or set the data arrays""" + return tuple(array.data for array in self._image_processors) @data.setter - def data(self, new_data: Sequence[ArrayProtocol]): + def data(self, new_data: Sequence[ArrayProtocol | None]): if len(new_data) != len(self.data): raise IndexError old_ndd = tuple(self.n_display_dims) - for new_data, image_array in zip(new_data, self._image_processor): + for new_data, image_array in zip(new_data, self._image_processors): if new_data is image_array.data: continue From 5be03b64b9a19ee37ba663e2a9f4ec7eb5e81e40 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 06:05:30 -0500 Subject: [PATCH 26/81] cleanup, correct way to push and pop dims --- fastplotlib/widgets/image_widget/_sliders.py | 26 +-- fastplotlib/widgets/image_widget/_widget.py | 183 ++++++++++--------- 2 files changed, 108 insertions(+), 101 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index a3b9ae66c..1e0340979 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -36,17 +36,19 @@ def __init__(self, figure, size, location, title, image_widget): self.pause = False - def push_dim(self): - self._playing.append(False) - self._fps.append(20) - self._frame_time.append(1 / 20) - self._last_frame_time.append(perf_counter()) - def pop_dim(self): - i = len(self._image_widget.indices) - 1 + """pop right most dim""" + i = 0 # len(self._image_widget.indices) - 1 for l in [self._playing, self._fps, self._frame_time, self._last_frame_time]: l.pop(i) + def push_dim(self): + """push a new dim""" + self._playing.insert(0, False) + self._fps.insert(0, 20) + self._frame_time.insert(0, 1 / 20) + self._last_frame_time.insert(0, perf_counter()) + def set_index(self, dim: int, new_index: int): """set the index of the ImageWidget""" @@ -89,9 +91,6 @@ def update(self): # buttons and slider UI elements for each dim for dim in range(self._image_widget.n_sliders): - if self.pause: - continue - imgui.push_id(f"{self._id_counter}_{dim}") if self._playing[dim]: @@ -159,7 +158,12 @@ def update(self): val = self._image_widget.indices[dim] vmax = self._image_widget.bounds[dim] - 1 - imgui.text(f"dim {dim}: ") + dim_name = dim + if self._image_widget._slider_dim_names is not None: + if dim < len(self._image_widget._slider_dim_names): + dim_name = self._image_widget._slider_dim_names[dim] + + imgui.text(f"dim {dim_name}: ") imgui.same_line() # so that slider occupies full width imgui.set_next_item_width(self.width * 0.85) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 87234364d..8051db541 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -20,6 +20,7 @@ def __init__( data: ArrayProtocol | list[ArrayProtocol | None] | None, processor: NDImageProcessor | Sequence[NDImageProcessor] = NDImageProcessor, n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, + slider_dim_names: Sequence[str] | None = None, # dim names left -> right rgb: bool | Sequence[bool] = None, cmap: str | Sequence[str]= "plasma", window_funcs: ( @@ -256,8 +257,8 @@ def __init__( n_display_dims = tuple(n_display_dims) - if sliders_dim_order not in ("left", "right"): - raise ValueError + if sliders_dim_order not in ("right",): + raise ValueError(f"Only 'right' slider dims order is currently supported, you passed: {sliders_dim_order}") self._sliders_dim_order = sliders_dim_order # make NDImageArrays @@ -390,41 +391,32 @@ def __init__( self._indices_changed_handlers = set() - self._reentrant_block = False + if slider_dim_names is not None: + self._slider_dim_names = tuple(slider_dim_names) + else: + self._slider_dim_names = None - self._initialized = True + self._reentrant_block = False @property - def figure(self) -> Figure: - """ - ``Figure`` used by `ImageWidget`. - """ - return self._figure + def data(self) -> tuple[ArrayProtocol | None]: + """get or set the nd-image data arrays""" + return tuple(array.data for array in self._image_processors) - @property - def graphics(self) -> list[ImageGraphic]: - """List of ``ImageWidget`` managed graphics.""" - iw_managed = list() - for subplot in self.figure: - # empty subplots will not have any image widget data - if len(subplot.graphics) > 0: - iw_managed.append(subplot["image_widget_managed"]) - return iw_managed + @data.setter + def data(self, new_data: Sequence[ArrayProtocol | None]): + if len(new_data) != len(self.data): + raise IndexError - @property - def cmap(self) -> tuple[str, ...]: - """get the cmaps, or set the cmap across all images""" - return tuple(g.cmap for g in self.graphics) + old_ndd = tuple(self.n_display_dims) - @cmap.setter - def cmap(self, name: str): - for g in self.graphics: - g.cmap = name + for new_data, image_array in zip(new_data, self._image_processors): + if new_data is image_array.data: + continue - @property - def data(self) -> list[np.ndarray]: - """data currently displayed in the widget""" - return self._data + image_array.data = new_data + + self._reset() @property def indices(self) -> tuple[int, ...]: @@ -441,9 +433,6 @@ def indices(self) -> tuple[int, ...]: @indices.setter def indices(self, new_indices: Sequence[int]): - if not self._initialized: - return - if self._reentrant_block: return @@ -481,16 +470,6 @@ def indices(self, new_indices: Sequence[int]): # set_value has finished executing, now allow future executions self._reentrant_block = False - def _get_image(self, image_processor: NDImageProcessor, indices: Sequence[int]) -> ArrayProtocol: - """Get a processed 2d or 3d image from the NDImage at the given indices""" - n = image_processor.n_slider_dims - - if self._sliders_dim_order == "right": - return image_processor.get(indices[-n:]) - - elif self._sliders_dim_order == "left": - return image_processor.get(indices[:n]) - @property def n_display_dims(self) -> tuple[Literal[2, 3]]: """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" @@ -515,31 +494,69 @@ def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]]): image_array.n_display_dims = new - self._reset_config() + self._reset() + + @property + def n_sliders(self) -> int: + """number of sliders""" + return max([a.n_slider_dims for a in self._image_processors]) + + @property + def bounds(self) -> tuple[int, ...]: + """The max bound across all dimensions across all data arrays""" + # initialize with 0 + bounds = [0] * self.n_sliders + + # TODO: implement left -> right slider dims ordering, right now it's only right -> left + # in reverse because dims go left <- right + for i, dim in enumerate(range(-1, -self.n_sliders - 1, -1)): + # across each dim + for array in self._image_processors: + if i > array.n_slider_dims - 1: + continue + # across each data array + # dims go left <- right + bounds[dim] = max(array.slider_dims_shape[dim], bounds[dim]) + + return bounds + + def _get_image(self, image_processor: NDImageProcessor, indices: Sequence[int]) -> ArrayProtocol: + """Get a processed 2d or 3d image from the NDImage at the given indices""" + n = image_processor.n_slider_dims + + if self._sliders_dim_order == "right": + return image_processor.get(indices[-n:]) - def _reset_sliders(self): - """reset the """ + elif self._sliders_dim_order == "left": + # TODO: left -> right is not fully implemented yet in ImageWidget + return image_processor.get(indices[:n]) + + def _reset_dimensions(self): + """reset the dimensions w.r.t. current collection of NDImageProcessors""" + # TODO: implement left -> right slider dims ordering, right now it's only right -> left # add or remove dims from indices # trim any excess dimensions while len(self._indices) > self.n_sliders: - # pop from: left <- right - self._indices.pop(len(self._indices) - 1) + # pop from right -> left + self._indices.pop(0) self._sliders_ui.pop_dim() # add any new dimensions that aren't present while len(self.indices) < self.n_sliders: - # insert from: left <- right - self._indices.append(0) + # insert right -> left + self._indices.insert(0, 0) self._sliders_ui.push_dim() def _reset_graphics(self): + """delete and create new graphics if necessary""" for subplot, image_array in zip(self.figure, self._image_processors): image_data = self._get_image(image_array, indices=self.indices) if image_data is None: - # just delete graphic from this subplot if "image_widget_managed" in subplot: + # delete graphic from this subplot if present subplot.delete_graphic(subplot["image_widget_managed"]) - continue + # skip this subplot + continue # check if a graphic exists if "image_widget_managed" in subplot: @@ -594,35 +611,41 @@ def _reset_graphics(self): subplot.camera.show_object(g.world_object) - def _reset_config(self): + def _reset(self): # reset the slider indices according to the new collection of dimensions - self._reset_sliders() + self._reset_dimensions() # update graphics where display dims have changed accordings to indices self._reset_graphics() # force an update self.indices = self.indices @property - def n_sliders(self) -> int: - return max([a.n_slider_dims for a in self._image_processors]) + def figure(self) -> Figure: + """ + ``Figure`` used by `ImageWidget`. + """ + return self._figure @property - def bounds(self) -> tuple[int, ...]: - """The max bound across all dimensions across all data arrays""" - # initialize with 0 - bounds = [0] * self.n_sliders + def graphics(self) -> list[ImageGraphic]: + """List of ``ImageWidget`` managed graphics.""" + iw_managed = list() + for subplot in self.figure: + if "image_widget_managed" in subplot: + iw_managed.append(subplot["image_widget_managed"]) + else: + iw_managed.append(None) + return tuple(iw_managed) - # in reverse because dims go left <- right - for i, dim in enumerate(range(-1, -self.n_sliders - 1, -1)): - # across each dim - for array in self._image_processors: - if i > array.n_slider_dims - 1: - continue - # across each data array - # dims go left <- right - bounds[dim] = max(array.slider_dims_shape[dim], bounds[dim]) + @property + def cmap(self) -> tuple[str, ...]: + """get the cmaps, or set the cmap across all images""" + return tuple(g.cmap for g in self.graphics) - return bounds + @cmap.setter + def cmap(self, name: str): + for g in self.graphics: + g.cmap = name def add_event_handler(self, handler: callable, event: str = "indices"): """ @@ -694,26 +717,6 @@ def reset_vmin_vmax_frame(self): # set the data using the current image graphic data hlut.set_data(subplot["image_widget_managed"].data.value) - @property - def data(self) -> tuple[ArrayProtocol | None]: - """get or set the data arrays""" - return tuple(array.data for array in self._image_processors) - - @data.setter - def data(self, new_data: Sequence[ArrayProtocol | None]): - if len(new_data) != len(self.data): - raise IndexError - - old_ndd = tuple(self.n_display_dims) - - for new_data, image_array in zip(new_data, self._image_processors): - if new_data is image_array.data: - continue - - image_array.data = new_data - - self._reset_config() - def set_data( self, new_data: np.ndarray | list[np.ndarray], From 51ed6b25d69d5aaec9794c4744eb94a6f1b83174 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Nov 2025 07:04:30 -0500 Subject: [PATCH 27/81] quality of life improvements --- fastplotlib/ui/_base.py | 4 ++- fastplotlib/widgets/image_widget/_sliders.py | 2 -- fastplotlib/widgets/image_widget/_widget.py | 37 +++++++++++++++++--- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index 3e763e08c..9767cf76f 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -123,8 +123,9 @@ def size(self) -> int | None: @size.setter def size(self, value): if not isinstance(value, int): - raise TypeError + raise TypeError(f"{self.__class__.__name__}.size must be an ") self._size = value + self._set_rect() @property def location(self) -> str: @@ -153,6 +154,7 @@ def height(self) -> int: def _set_rect(self, *args): self._x, self._y, self._width, self._height = self.get_rect() + self._figure._fpl_reset_layout() def get_rect(self) -> tuple[int, int, int, int]: """ diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 1e0340979..8ac920a7d 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -186,5 +186,3 @@ def update(self): self._image_widget.indices = new_indices imgui.pop_id() - - self.size = int(imgui.get_window_height()) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 8051db541..87b4b5259 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -12,8 +12,10 @@ from ._sliders import ImageWidgetSliders from ._array import NDImageProcessor, WindowFuncCallable -import pygfx -pygfx.Camera + +IMGUI_SLIDER_HEIGHT = 49 + + class ImageWidget: def __init__( self, @@ -278,7 +280,31 @@ def __init__( self._image_processors.append(image_array) - figure_kwargs_default = {"controller_ids": "sync", "names": names} + if len(set(n_display_dims)) > 1: + # assume user wants one controller for 2D images and another for 3D image volumes + n_subplots = np.prod(figure_shape) + controller_ids = [0] * n_subplots + controller_types = ["panzoom"] * n_subplots + + for i in range(len(data)): + if n_display_dims[i] == 2: + controller_ids[i] = 1 + else: + controller_ids[i] = 2 + controller_types[i] = "orbit" + + # needs to be a list of list + controller_ids = [controller_ids] + + else: + controller_ids = "sync" + controller_types = None + + figure_kwargs_default = { + "controller_ids": controller_ids, + "controller_types": controller_types , + "names": names + } # update the default kwargs with any user-specified kwargs # user specified kwargs will overwrite the defaults @@ -363,6 +389,7 @@ def __init__( vmax=vmax, **graphic_kwargs[i], ) + subplot.fov = 50 subplot.add_graphic(graphic) @@ -381,7 +408,7 @@ def __init__( self._sliders_ui = ImageWidgetSliders( figure=self.figure, - size=180, + size=57 + (IMGUI_SLIDER_HEIGHT * self.n_sliders), location="bottom", title="ImageWidget Controls", image_widget=self, @@ -547,6 +574,8 @@ def _reset_dimensions(self): self._indices.insert(0, 0) self._sliders_ui.push_dim() + self._sliders_ui.size = 55 + (IMGUI_SLIDER_HEIGHT * self.n_sliders) + def _reset_graphics(self): """delete and create new graphics if necessary""" for subplot, image_array in zip(self.figure, self._image_processors): From 6db17142a8948d304dcc406f403c8b07ca0de01a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 9 Nov 2025 05:01:17 -0500 Subject: [PATCH 28/81] new histogram lut tool --- fastplotlib/tools/_histogram_lut.py | 542 ++++++++---------- .../image_widget/{_array.py => _processor.py} | 0 2 files changed, 226 insertions(+), 316 deletions(-) rename fastplotlib/widgets/image_widget/{_array.py => _processor.py} (100%) diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index 98ec4f4fa..6f406120a 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -6,425 +6,332 @@ import pygfx -from ..utils import subsample_array +from ..utils import subsample_array, RenderQueue from ..graphics import LineGraphic, ImageGraphic, ImageVolumeGraphic, TextGraphic from ..graphics.utils import pause_events from ..graphics._base import Graphic +from ..graphics.features import GraphicFeatureEvent from ..graphics.selectors import LinearRegionSelector -def _get_image_graphic_events(image_graphic: ImageGraphic) -> list[str]: - """Small helper function to return the relevant events for an ImageGraphic""" - events = ["vmin", "vmax"] +def _format_value(value: float): + abs_val = abs(value) + if abs_val < 0.01 or abs_val > 9_999: + return f"{value:.2e}" + else: + return f"{value:.2f}" - if not image_graphic.data.value.ndim > 2: - events.append("cmap") - # if RGB(A), do not add cmap - - return events - - -# TODO: This is a widget, we can think about a BaseWidget class later if necessary class HistogramLUTTool(Graphic): def __init__( - self, - data: np.ndarray, - images: ( - ImageGraphic - | ImageVolumeGraphic - | Sequence[ImageGraphic | ImageVolumeGraphic] - ), - nbins: int = 100, - flank_divisor: float = 5.0, - histogram: np.ndarray = None, - **kwargs, + self, + histogram: tuple[np.ndarray, np.ndarray], + images: Sequence[ImageGraphic | ImageVolumeGraphic] | None = None, + **kwargs, ): - """ - HistogramLUT tool that can be used to control the vmin, vmax of ImageGraphics or ImageVolumeGraphics. - If used to control multiple images or image volumes it is assumed that they share a representation of - the same data, and that their histogram, vmin, and vmax are identical. For example, displaying a - ImageVolumeGraphic and several images that represent slices of the same volume data. - - Parameters - ---------- - data: np.ndarray - - images: ImageGraphic | ImageVolumeGraphic | tuple[ImageGraphic | ImageVolumeGraphic] + super().__init__(**kwargs) - nbins: int, defaut 100. - Total number of bins used in the histogram + if len(histogram) != 2: + raise TypeError - flank_divisor: float, default 5.0. - Fraction of empty histogram bins on the tails of the distribution set `np.inf` for no flanks + self._block_reentrance = False + self._images = list() - kwargs: passed to ``Graphic`` + self._bin_centers_flanked = np.zeros(120, dtype=np.float64) + self._freq_flanked = np.zeros(120, dtype=np.float32) - """ - super().__init__(**kwargs) + # 100 points for the histogram, 10 points on each side for the flank + line_data = np.column_stack( + [np.zeros(120, dtype=np.float32), np.arange(0, 120)] + ) - self._nbins = nbins - self._flank_divisor = flank_divisor - - if isinstance(images, (ImageGraphic, ImageVolumeGraphic)): - images = (images,) - elif isinstance(images, Sequence): - if not all( - [isinstance(ig, (ImageGraphic, ImageVolumeGraphic)) for ig in images] - ): - raise TypeError( - f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a " - f"tuple or list or ImageGraphic | ImageVolumeGraphic" - ) - else: - raise TypeError( - f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a " - f"tuple or list or ImageGraphic | ImageVolumeGraphic" - ) + self._line = LineGraphic(line_data, colors=(0.8, 0.8, 0.8), alpha_mode="solid", offset=(1, 0, 0)) + self._line.world_object.local.scale_x = -1 - self._images = images + self._selector = LinearRegionSelector( + selection=(10, 110), + limits=(0, 119), + size=1.5, + center=0.5, # frequency data are normalized between 0-1 + axis="y", + parent=self._line, + ) - self._data = weakref.proxy(data) + self._selector.add_event_handler(self._selector_event_handler, "selection") - self._scale_factor: float = 1.0 + colorbar_visible = False + if images is not None: + if isinstance(images, (ImageGraphic, ImageVolumeGraphic)): + images = [images] - hist, edges, hist_scaled, edges_flanked = self._calculate_histogram( - data, histogram - ) + for image in images: + if image.cmap is not None: + colorbar_visible = True - line_data = np.column_stack([hist_scaled, edges_flanked]) + if not isinstance(image, (ImageGraphic, ImageVolumeGraphic)): + raise TypeError( + f"`images` must be a tuple/list of ImageGraphic or ImageVolumeGraphic. " + f"You have passed: {images}" + ) - self._histogram_line = LineGraphic( - line_data, colors=(0.8, 0.8, 0.8), alpha_mode="solid", offset=(0, 0, -1) + self._colorbar = ImageGraphic( + data=np.zeros([120, 2]), + interpolation="linear", + offset=(1.5, 0, 0) ) - bounds = (edges[0] * self._scale_factor, edges[-1] * self._scale_factor) - limits = (edges_flanked[0], edges_flanked[-1]) - size = 120 # since it's scaled to 100 - origin = (hist_scaled.max() / 2, 0) - - self._linear_region_selector = LinearRegionSelector( - selection=bounds, - limits=limits, - size=size, - center=origin[0], - axis="y", - parent=self._histogram_line, - ) + self._colorbar.world_object.local.scale_x = 0.15 - self._vmin = self.images[0].vmin - self._vmax = self.images[0].vmax + if not colorbar_visible: + self._colorbar.visible = False - # there will be a small difference with the histogram edges so this makes them both line up exactly - self._linear_region_selector.selection = ( - self._vmin * self._scale_factor, - self._vmax * self._scale_factor, + self._ruler = pygfx.Ruler( + end_pos=(0, 119, 0), + alpha_mode="solid", + render_queue=RenderQueue.axes, + tick_side="right", + tick_marker="tick_right", + tick_format=self._ruler_tick_map, + min_tick_distance=10, ) + self._ruler.local.x = 1.75 - vmin_str, vmax_str = self._get_vmin_vmax_str() + # TODO: need to auto-scale using the text so it appears nicely, will do later + self._ruler.visible = False self._text_vmin = TextGraphic( - text=vmin_str, + text="", font_size=16, - offset=(0, 0, 0), anchor="top-left", outline_color="black", outline_thickness=0.5, alpha_mode="solid", ) - + # need to make sure text object doesn't conflict with selector tool self._text_vmin.world_object.material.pick_write = False self._text_vmax = TextGraphic( - text=vmax_str, + text="", font_size=16, - offset=(0, 0, 0), anchor="bottom-left", outline_color="black", outline_thickness=0.5, alpha_mode="solid", ) - self._text_vmax.world_object.material.pick_write = False - widget_wo = pygfx.Group() - widget_wo.add( - self._histogram_line.world_object, - self._linear_region_selector.world_object, + wo = pygfx.Group() + wo.add( + self._line.world_object, + self._selector.world_object, + self._colorbar.world_object, + self._ruler, self._text_vmin.world_object, - self._text_vmax.world_object, + self._text_vmax.world_object ) + self._set_world_object(wo) - self._set_world_object(widget_wo) + self._children = [self._line, self._selector, self._colorbar, self._text_vmin, self._text_vmax] - self.world_object.local.scale_x *= -1 + # set histogram + self.histogram = histogram - self._text_vmin.offset = (-120, self._linear_region_selector.selection[0], 0) + # set the images + self.images = images - self._text_vmax.offset = (-120, self._linear_region_selector.selection[1], 0) - self._linear_region_selector.add_event_handler( - self._linear_region_handler, "selection" - ) + def _fpl_add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + for child in self._children: + child._fpl_add_plot_area_hook(plot_area) - ig_events = _get_image_graphic_events(self.images[0]) + if hasattr(self._plot_area, "size"): + # if it's in a dock area + self._plot_area.size = 80 - for ig in self.images: - ig.add_event_handler(self._image_cmap_handler, *ig_events) + self._plot_area.controller.enabled = False + self._plot_area.auto_scale(maintain_aspect=False) + self._ruler.update(plot_area.camera, plot_area.canvas.get_logical_size()) - # colorbar for grayscale images - if self.images[0].cmap is not None: - self._colorbar: ImageGraphic = self._make_colorbar(edges_flanked) - self._colorbar.add_event_handler(self._open_cmap_picker, "click") + def _ruler_tick_map(self, bin_index, *args): + return f"{self._bin_centers_flanked[int(bin_index)]:.2f}" - self.world_object.add(self._colorbar.world_object) - else: - self._colorbar = None - self._cmap = None - - def _make_colorbar(self, edges_flanked) -> ImageGraphic: - # use the histogram edge values as data for an - # image with 2 columns, this will be our colorbar! - colorbar_data = np.column_stack( - [ - np.linspace( - edges_flanked[0], edges_flanked[-1], ceil(np.ptp(edges_flanked)) - ) - ] - * 2 - ).astype(np.float32) - - colorbar_data /= self._scale_factor - - cbar = ImageGraphic( - data=colorbar_data, - vmin=self.vmin, - vmax=self.vmax, - cmap=self.images[0].cmap, - interpolation="linear", - offset=(-55, edges_flanked[0], -1), - ) + @property + def histogram(self) -> tuple[np.ndarray, np.ndarray]: + """histogram [frequency, bin_centers]. Frequency is flanked by 10 zeros on both sides""" + return self._freq_flanked, self._bin_centers_flanked - cbar.world_object.world.scale_x = 20 - self._cmap = self.images[0].cmap + @histogram.setter + def histogram(self, histogram: tuple[np.ndarray, np.ndarray], limits: tuple[int, int] = None): + freq, edges = histogram - return cbar + freq = (freq / freq.max()) - def _get_vmin_vmax_str(self) -> tuple[str, str]: - if self.vmin < 0.001 or self.vmin > 99_999: - vmin_str = f"{self.vmin:.2e}" - else: - vmin_str = f"{self.vmin:.2f}" + bin_centers = 0.5 * (edges[1:] + edges[:-1]) - if self.vmax < 0.001 or self.vmax > 99_999: - vmax_str = f"{self.vmax:.2e}" - else: - vmax_str = f"{self.vmax:.2f}" + step = bin_centers[1] - bin_centers[0] - return vmin_str, vmax_str + under_flank = np.linspace(bin_centers[0] - step * 10, bin_centers[0] - step, 10) + over_flank = np.linspace(bin_centers[-1] + step, bin_centers[-1] + step * 10, 10) + self._bin_centers_flanked[:] = np.concatenate([under_flank, bin_centers, over_flank]) - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - self._linear_region_selector._fpl_add_plot_area_hook(plot_area) - self._histogram_line._fpl_add_plot_area_hook(plot_area) + self._freq_flanked[10:110] = freq - self._plot_area.auto_scale() - self._plot_area.controller.enabled = True + self._line.data[:, 0] = self._freq_flanked + self._colorbar.data = np.column_stack([self._bin_centers_flanked, self._bin_centers_flanked]) - def _calculate_histogram(self, data, histogram=None): - if histogram is None: - # get a subsampled view of this array - data_ss = subsample_array(data, max_size=int(1e6)) # 1e6 is default - hist, edges = np.histogram(data_ss, bins=self._nbins) - else: - hist, edges = histogram + # self.vmin, self.vmax = bin_centers[0], bin_centers[-1] - # used if data ptp <= 10 because event things get weird - # with tiny world objects due to floating point error - # so if ptp <= 10, scale up by a factor - data_interval = edges[-1] - edges[0] - self._scale_factor: int = max(1, 100 * int(10 / data_interval)) + if hasattr(self, "plot_area"): + self._ruler.update(self._plot_area.camera, self._plot_area.canvas.get_logical_size()) - edges = edges * self._scale_factor + @property + def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic, ...] | None: + return tuple(self._images) - bin_width = edges[1] - edges[0] + @images.setter + def images(self, new_images: tuple[ImageGraphic | ImageVolumeGraphic] | None): + self._disconnect_images() + self._images.clear() - flank_nbins = int(self._nbins / self._flank_divisor) - flank_size = flank_nbins * bin_width + if new_images is None: + return - flank_left = np.arange(edges[0] - flank_size, edges[0], bin_width) - flank_right = np.arange( - edges[-1] + bin_width, edges[-1] + flank_size, bin_width - ) + if not all([isinstance(image, (ImageGraphic, ImageVolumeGraphic)) for image in new_images]): + raise TypeError - edges_flanked = np.concatenate((flank_left, edges, flank_right)) + for image in new_images: + if image.cmap is not None: + self._colorbar.visible = True + break + else: + self._colorbar.visible = False - hist_flanked = np.concatenate( - (np.zeros(flank_nbins), hist, np.zeros(flank_nbins)) - ) + self._images = new_images - # scale 0-100 to make it easier to see - # float32 data can produce unnecessarily high values - hist_scale_value = hist_flanked.max() - if np.allclose(hist_scale_value, 0): - hist_scale_value = 1 - hist_scaled = hist_flanked / (hist_scale_value / 100) + # reset vmin, vmax using first image + self.vmin = self._images[0].vmin + self.vmax = self._images[0].vmax - if edges_flanked.size > hist_scaled.size: - # we don't care about accuracy here so if it's off by 1-2 bins that's fine - edges_flanked = edges_flanked[: hist_scaled.size] + if self._images[0].cmap is not None: + self._colorbar.cmap = self._images[0].cmap - return hist, edges, hist_scaled, edges_flanked + # connect event handlers + for image in self._images: + image.add_event_handler(self._image_event_handler, "vmin", "vmax", "cmap") - def _linear_region_handler(self, ev): - # must use world coordinate values directly from selection() - # otherwise the linear region bounds jump to the closest bin edges - selected_ixs = self._linear_region_selector.selection - vmin, vmax = selected_ixs[0], selected_ixs[1] - vmin, vmax = vmin / self._scale_factor, vmax / self._scale_factor - self.vmin, self.vmax = vmin, vmax + def _disconnect_images(self): + for image in self._images: + image.remove_event_handler(self._image_event_handler, "vmin", "vmax", "cmap") - def _image_cmap_handler(self, ev): - setattr(self, ev.type, ev.info["value"]) + def _image_event_handler(self, ev): + new_value = ev.info["value"] + setattr(self, ev.type, new_value) @property - def cmap(self) -> str: - return self._cmap + def cmap(self) -> str | None: + return self._colorbar.cmap @cmap.setter def cmap(self, name: str): - if self._colorbar is None: + if self._block_reentrance: return - with pause_events(*self.images): - for ig in self.images: - ig.cmap = name + if name is None: + return - self._cmap = name + self._block_reentrance = True + try: self._colorbar.cmap = name + with pause_events(*self._images, event_handlers=[self._image_event_handler]): + for image in self._images: + image.cmap = name + except Exception as exc: + # raise original exception + raise exc # vmax setter has raised. The lines above below are probably more relevant! + finally: + # set_value has finished executing, now allow future executions + self._block_reentrance = False + @property def vmin(self) -> float: - return self._vmin + # no offset or rotation so we can directly use the world space selection value + index = int(self._selector.selection[0]) + return self._bin_centers_flanked[index] @vmin.setter def vmin(self, value: float): - with pause_events(self._linear_region_selector, *self.images): - # must use world coordinate values directly from selection() - # otherwise the linear region bounds jump to the closest bin edges - self._linear_region_selector.selection = ( - value * self._scale_factor, - self._linear_region_selector.selection[1], - ) - for ig in self.images: - ig.vmin = value - - self._vmin = value - if self._colorbar is not None: - self._colorbar.vmin = value - - vmin_str, vmax_str = self._get_vmin_vmax_str() - self._text_vmin.offset = (-120, self._linear_region_selector.selection[0], 0) - self._text_vmin.text = vmin_str - - @property - def vmax(self) -> float: - return self._vmax - - @vmax.setter - def vmax(self, value: float): - with pause_events(self._linear_region_selector, *self.images): - # must use world coordinate values directly from selection() - # otherwise the linear region bounds jump to the closest bin edges - self._linear_region_selector.selection = ( - self._linear_region_selector.selection[0], - value * self._scale_factor, - ) - - for ig in self.images: - ig.vmax = value - - self._vmax = value - if self._colorbar is not None: - self._colorbar.vmax = value - - vmin_str, vmax_str = self._get_vmin_vmax_str() - self._text_vmax.offset = (-120, self._linear_region_selector.selection[1], 0) - self._text_vmax.text = vmax_str - - def set_data(self, data, reset_vmin_vmax: bool = True): - hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data) - - line_data = np.column_stack([hist_scaled, edges_flanked]) + if self._block_reentrance: + return + self._block_reentrance = True + try: + index_min = np.searchsorted(self._bin_centers_flanked, value) + with pause_events(self._selector, *self._images, event_handlers=[self._selector_event_handler, self._image_event_handler]): + self._selector.selection = (index_min, self._selector.selection[1]) - # set x and y vals - self._histogram_line.data[:, :2] = line_data + self._colorbar.vmin = value - bounds = (edges[0], edges[-1]) - limits = (edges_flanked[0], edges_flanked[-11]) - origin = (hist_scaled.max() / 2, 0) + self._text_vmin.text = _format_value(value) + self._text_vmin.offset = (-0.45, self._selector.selection[0], 0) - if reset_vmin_vmax: - # reset according to the new data - self._linear_region_selector.limits = limits - self._linear_region_selector.selection = bounds - else: - with pause_events(self._linear_region_selector, *self.images): - # don't change the current selection - self._linear_region_selector.limits = limits + for image in self._images: + image.vmin = value - self._data = weakref.proxy(data) + except Exception as exc: + # raise original exception + raise exc # vmax setter has raised. The lines above below are probably more relevant! + finally: + # set_value has finished executing, now allow future executions + self._block_reentrance = False - if self._colorbar is not None: - self._colorbar.clear_event_handlers() - self.world_object.remove(self._colorbar.world_object) + @property + def vmax(self) -> float: + # no offset or rotation so we can directly use the world space selection value + index = int(self._selector.selection[1]) + return self._bin_centers_flanked[index] - if self.images[0].cmap is not None: - self._colorbar: ImageGraphic = self._make_colorbar(edges_flanked) - self._colorbar.add_event_handler(self._open_cmap_picker, "click") + @vmax.setter + def vmax(self, value: float): + if self._block_reentrance: + return - self.world_object.add(self._colorbar.world_object) - else: - self._colorbar = None - self._cmap = None + self._block_reentrance = True + try: + index_max = np.searchsorted(self._bin_centers_flanked, value) + with pause_events(self._selector, *self._images, event_handlers=[self._selector_event_handler, self._image_event_handler]): + self._selector.selection = (self._selector.selection[0], index_max) - # reset plotarea dims - self._plot_area.auto_scale() + self._colorbar.vmax = value - @property - def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic]: - return self._images + self._text_vmax.text = _format_value(value) + self._text_vmax.offset = (-0.45, self._selector.selection[1], 0) - @images.setter - def images(self, images): - if isinstance(images, (ImageGraphic, ImageVolumeGraphic)): - images = (images,) - elif isinstance(images, Sequence): - if not all( - [isinstance(ig, (ImageGraphic, ImageVolumeGraphic)) for ig in images] - ): - raise TypeError( - f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a " - f"tuple or list or ImageGraphic | ImageVolumeGraphic" - ) - else: - raise TypeError( - f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a " - f"tuple or list or ImageGraphic | ImageVolumeGraphic" - ) + for image in self._images: + image.vmax = value - if self._images is not None: - for ig in self._images: - # cleanup events from current image graphics - ig_events = _get_image_graphic_events(ig) - ig.remove_event_handler(self._image_cmap_handler, *ig_events) + except Exception as exc: + # raise original exception + raise exc # vmax setter has raised. The lines above below are probably more relevant! + finally: + # set_value has finished executing, now allow future executions + self._block_reentrance = False - self._images = images + def _selector_event_handler(self, ev: GraphicFeatureEvent): + selection = ev.info["value"] + index_min = int(selection[0]) + vmin = self._bin_centers_flanked[index_min] - ig_events = _get_image_graphic_events(self._images[0]) + index_max = int(selection[1]) + vmax = self._bin_centers_flanked[index_max] - for ig in self.images: - ig.add_event_handler(self._image_cmap_handler, *ig_events) + match ev.info["change"]: + case "min": + self.vmin = vmin + case "max": + self.vmax = vmax + case _: + self.vmin, self.vmax = vmin, vmax def _open_cmap_picker(self, ev): # check if right click @@ -436,7 +343,10 @@ def _open_cmap_picker(self, ev): self._plot_area.get_figure().open_popup("colormap-picker", pos, lut_tool=self) def _fpl_prepare_del(self): - self._linear_region_selector._fpl_prepare_del() - self._histogram_line._fpl_prepare_del() - del self._histogram_line - del self._linear_region_selector + self._disconnect_images() + self._images.clear() + + for i in range(len(self._children)): + g = self._children.pop(0) + g._fpl_prepare_del() + del g diff --git a/fastplotlib/widgets/image_widget/_array.py b/fastplotlib/widgets/image_widget/_processor.py similarity index 100% rename from fastplotlib/widgets/image_widget/_array.py rename to fastplotlib/widgets/image_widget/_processor.py From 50d8e87a8751d63b455dd67c4595ec9af50566a5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 9 Nov 2025 05:40:22 -0500 Subject: [PATCH 29/81] new hlut tool --- fastplotlib/graphics/_base.py | 9 + fastplotlib/graphics/features/_base.py | 4 +- .../graphics/features/_selection_features.py | 4 +- .../graphics/selectors/_linear_region.py | 4 +- fastplotlib/graphics/utils.py | 15 +- fastplotlib/tools/_histogram_lut.py | 29 +-- .../ui/right_click_menus/_colormap_picker.py | 3 +- fastplotlib/widgets/image_widget/__init__.py | 2 +- .../widgets/image_widget/_processor.py | 4 + fastplotlib/widgets/image_widget/_widget.py | 205 +++++++++++------- 10 files changed, 163 insertions(+), 116 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index a4f3e9a67..6d369782d 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -160,6 +160,7 @@ def __init__( self._alpha_mode = AlphaMode(alpha_mode) self._visible = Visible(visible) self._block_events = False + self._block_handlers = list() self._axes: Axes = None @@ -242,6 +243,11 @@ def block_events(self) -> bool: def block_events(self, value: bool): self._block_events = value + @property + def block_handlers(self) -> list: + """Used to block event handlers for a graphic and prevent recursion.""" + return self._block_handlers + @property def world_object(self) -> pygfx.WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" @@ -370,6 +376,9 @@ def _handle_event(self, callback, event: pygfx.Event): if self.block_events: return + if callback in self._block_handlers: + return + if event.type in self._features: # for feature events event._target = self.world_object diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 5dec9f1e5..cb900e7d2 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -314,7 +314,7 @@ def __repr__(self): def block_reentrance(set_value): # decorator to block re-entrant set_value methods # useful when creating complex, circular, bidirectional event graphs - def set_value_wrapper(self: GraphicFeature, graphic_or_key, value): + def set_value_wrapper(self: GraphicFeature, graphic_or_key, value, **kwargs): """ wraps GraphicFeature.set_value @@ -330,7 +330,7 @@ def set_value_wrapper(self: GraphicFeature, graphic_or_key, value): try: # block re-execution of set_value until it has *fully* finished executing self._reentrant_block = True - set_value(self, graphic_or_key, value) + set_value(self, graphic_or_key, value, **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/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 654b3d4c6..da7ca89e0 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -118,7 +118,7 @@ def axis(self) -> str: return self._axis @block_reentrance - def set_value(self, selector, value: Sequence[float]): + def set_value(self, selector, value: Sequence[float], *, change: str = "full"): """ Set start, stop range of selector @@ -182,7 +182,7 @@ def set_value(self, selector, value: Sequence[float]): if len(self._event_handlers) < 1: return - event = GraphicFeatureEvent(self._property_name, {"value": self.value}) + event = GraphicFeatureEvent(self._property_name, {"value": self.value, "change": change}) event.get_selected_indices = selector.get_selected_indices event.get_selected_data = selector.get_selected_data diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 9f5803c93..5d9df4ace 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -469,9 +469,9 @@ def _move_graphic(self, move_info: MoveInfo): if move_info.source == self._edges[0]: # change only left or bottom bound new_min = min(cur_min + delta, cur_max) - self._selection.set_value(self, (new_min, cur_max)) + self._selection.set_value(self, (new_min, cur_max), change="min") elif move_info.source == self._edges[1]: # change only right or top bound new_max = max(cur_max + delta, cur_min) - self._selection.set_value(self, (cur_min, new_max)) + self._selection.set_value(self, (cur_min, new_max), change="max") diff --git a/fastplotlib/graphics/utils.py b/fastplotlib/graphics/utils.py index 6be5aefc4..f32d80809 100644 --- a/fastplotlib/graphics/utils.py +++ b/fastplotlib/graphics/utils.py @@ -1,13 +1,16 @@ from contextlib import contextmanager +from typing import Callable, Iterable from ._base import Graphic @contextmanager -def pause_events(*graphics: Graphic): +def pause_events(*graphics: Graphic, event_handlers: Iterable[Callable] = None): """ Context manager for pausing Graphic events. + Optionally pass in only specific event handlers which are blocked. Other events for the graphic will not be blocked. + Examples -------- @@ -30,8 +33,14 @@ def pause_events(*graphics: Graphic): original_vals = [g.block_events for g in graphics] for g in graphics: - g.block_events = True + if event_handlers is not None: + g.block_handlers.extend([e for e in event_handlers]) + else: + g.block_events = True yield for g, value in zip(graphics, original_vals): - g.block_events = value + if event_handlers is not None: + g.block_handlers.clear() + else: + g.block_events = value diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index 6f406120a..2313f9385 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -59,21 +59,6 @@ def __init__( self._selector.add_event_handler(self._selector_event_handler, "selection") - colorbar_visible = False - if images is not None: - if isinstance(images, (ImageGraphic, ImageVolumeGraphic)): - images = [images] - - for image in images: - if image.cmap is not None: - colorbar_visible = True - - if not isinstance(image, (ImageGraphic, ImageVolumeGraphic)): - raise TypeError( - f"`images` must be a tuple/list of ImageGraphic or ImageVolumeGraphic. " - f"You have passed: {images}" - ) - self._colorbar = ImageGraphic( data=np.zeros([120, 2]), interpolation="linear", @@ -82,9 +67,6 @@ def __init__( self._colorbar.world_object.local.scale_x = 0.15 - if not colorbar_visible: - self._colorbar.visible = False - self._ruler = pygfx.Ruler( end_pos=(0, 119, 0), alpha_mode="solid", @@ -139,7 +121,6 @@ def __init__( # set the images self.images = images - def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area for child in self._children: @@ -197,6 +178,9 @@ def images(self, new_images: tuple[ImageGraphic | ImageVolumeGraphic] | None): if new_images is None: return + if isinstance(new_images, (ImageGraphic, ImageVolumeGraphic)): + new_images = [new_images] + if not all([isinstance(image, (ImageGraphic, ImageVolumeGraphic)) for image in new_images]): raise TypeError @@ -219,10 +203,13 @@ def images(self, new_images: tuple[ImageGraphic | ImageVolumeGraphic] | None): # connect event handlers for image in self._images: image.add_event_handler(self._image_event_handler, "vmin", "vmax", "cmap") + image.add_event_handler(self._disconnect_images, "deleted") - def _disconnect_images(self): + def _disconnect_images(self, *args): for image in self._images: - image.remove_event_handler(self._image_event_handler, "vmin", "vmax", "cmap") + for ev, handlers in image.event_handlers: + if self._image_event_handler in handlers: + image.remove_event_handler(self._image_event_handler, ev) def _image_event_handler(self, ev): new_value = ev.info["value"] diff --git a/fastplotlib/ui/right_click_menus/_colormap_picker.py b/fastplotlib/ui/right_click_menus/_colormap_picker.py index a80e5b2aa..9df26dcdc 100644 --- a/fastplotlib/ui/right_click_menus/_colormap_picker.py +++ b/fastplotlib/ui/right_click_menus/_colormap_picker.py @@ -154,7 +154,8 @@ def update(self): self._texture_height = (imgui.get_font_size()) - 2 if imgui.menu_item("Reset vmin-vmax", "", False)[0]: - self._lut_tool.images[0].reset_vmin_vmax() + for image in self._lut_tool.images: + image.reset_vmin_vmax() # add all the cmap options for cmap_type in COLORMAP_NAMES.keys(): diff --git a/fastplotlib/widgets/image_widget/__init__.py b/fastplotlib/widgets/image_widget/__init__.py index 7e142efeb..dc5daea55 100644 --- a/fastplotlib/widgets/image_widget/__init__.py +++ b/fastplotlib/widgets/image_widget/__init__.py @@ -2,7 +2,7 @@ if IMGUI: from ._widget import ImageWidget - from ._array import NDImageProcessor + from ._processor import NDImageProcessor else: diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index 0f734cfe2..4eb1eb27b 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -481,6 +481,10 @@ def _recompute_histogram(self): return if self.finalizer_func is not None: + # don't subsample spatial dims if a finalizer function is used + # finalizer functions often operate on the spatial dims, ex: a gaussian kernel + # so their results require the full spatial resolution, the histogram of a + # spatially subsampled image will be very different ignore_dims = self.display_dims else: ignore_dims = None diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 87b4b5259..9385e7388 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -10,7 +10,7 @@ from ...utils import calculate_figure_shape, quick_min_max, ArrayProtocol, ARRAY_LIKE_ATTRS from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders -from ._array import NDImageProcessor, WindowFuncCallable +from ._processor import NDImageProcessor, WindowFuncCallable IMGUI_SLIDER_HEIGHT = 49 @@ -100,7 +100,6 @@ def __init__( passed to each ImageGraphic in the ImageWidget figure subplots """ - self._initialized = False if figure_kwargs is None: figure_kwargs = dict() @@ -263,6 +262,8 @@ def __init__( raise ValueError(f"Only 'right' slider dims order is currently supported, you passed: {sliders_dim_order}") self._sliders_dim_order = sliders_dim_order + self._histogram_widget = histogram_widget + # make NDImageArrays self._image_processors: list[NDImageProcessor] = list() for i in range(len(data)): @@ -275,7 +276,7 @@ def __init__( window_sizes=win_sizes[i], window_order=win_order[i], finalizer_func=final_funcs[i], - compute_histogram=histogram_widget, + compute_histogram=self._histogram_widget, ) self._image_processors.append(image_array) @@ -331,8 +332,6 @@ def __init__( self._figure: Figure = Figure(**figure_kwargs_default) - self._histogram_widget = histogram_widget - self._indices = [0 for i in range(self.n_sliders)] for i, subplot in zip(range(len(self._image_processors)), self.figure): @@ -393,18 +392,7 @@ def __init__( subplot.add_graphic(graphic) - if self._histogram_widget: - hlut = HistogramLUTTool( - data=self._image_processors[i].data, - images=graphic, - name="histogram_lut", - histogram=self._image_processors[i].histogram, - ) - - subplot.docks["right"].add_graphic(hlut) - subplot.docks["right"].size = 80 - subplot.docks["right"].auto_scale(maintain_aspect=False) - subplot.docks["right"].controller.enabled = False + self._reset_histograms(subplot, self._image_processors[i]) self._sliders_ui = ImageWidgetSliders( figure=self.figure, @@ -435,15 +423,18 @@ def data(self, new_data: Sequence[ArrayProtocol | None]): if len(new_data) != len(self.data): raise IndexError - old_ndd = tuple(self.n_display_dims) + # if the data array hasn't been changed + # graphics will not be reset for this data index + skip_indices = list() - for new_data, image_array in zip(new_data, self._image_processors): - if new_data is image_array.data: + for i, (new_data, image_processor) in enumerate(zip(new_data, self._image_processors)): + if new_data is image_processor.data: + skip_indices.append(i) continue - image_array.data = new_data + image_processor.data = new_data - self._reset() + self._reset(skip_indices) @property def indices(self) -> tuple[int, ...]: @@ -510,18 +501,25 @@ def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]]): if not all([n in (2, 3) for n in new_ndd]): raise ValueError + # if the n_display_dims hasn't been changed for this data array + # graphics will not be reset for this data array index + skip_indices = list() + # first update image arrays - for i, (image_array, new) in enumerate(zip(self._image_processors, new_ndd)): - if new > image_array.max_n_display_dims: + for i, (image_processor, new) in enumerate(zip(self._image_processors, new_ndd)): + if new > image_processor.max_n_display_dims: raise IndexError( f"number of display dims exceeds maximum number of possible " - f"display dimensions: {image_array.max_n_display_dims}, for array at index: " - f"{i} with shape: {image_array.shape}, and rgb set to: {image_array.rgb}" + f"display dimensions: {image_processor.max_n_display_dims}, for array at index: " + f"{i} with shape: {image_processor.shape}, and rgb set to: {image_processor.rgb}" ) - image_array.n_display_dims = new + if image_processor.n_display_dims == new: + skip_indices.append(i) + else: + image_processor.n_display_dims = new - self._reset() + self._reset(skip_indices) @property def n_sliders(self) -> int: @@ -576,75 +574,114 @@ def _reset_dimensions(self): self._sliders_ui.size = 55 + (IMGUI_SLIDER_HEIGHT * self.n_sliders) - def _reset_graphics(self): + def _reset_graphics(self, subplot, image_processor): """delete and create new graphics if necessary""" - for subplot, image_array in zip(self.figure, self._image_processors): - image_data = self._get_image(image_array, indices=self.indices) - if image_data is None: - if "image_widget_managed" in subplot: - # delete graphic from this subplot if present - subplot.delete_graphic(subplot["image_widget_managed"]) - # skip this subplot - continue - - # check if a graphic exists + new_image = self._get_image(image_processor, indices=self.indices) + if new_image is None: if "image_widget_managed" in subplot: - # create a new graphic only if the buffer shape doesn't match - if subplot["image_widget_managed"].data.value.shape == image_data.shape: - continue - - # keep cmap - cmap = subplot["image_widget_managed"].cmap - # delete graphic since it will be replaced + # delete graphic from this subplot if present subplot.delete_graphic(subplot["image_widget_managed"]) - else: - # default cmap - cmap = "plasma" + # skip this subplot + return - if image_array.n_display_dims == 2: - g = subplot.add_image( - data=image_data, - cmap=cmap, - name="image_widget_managed" - ) + # check if a graphic exists + if "image_widget_managed" in subplot: + # create a new graphic only if the Texture buffer shape doesn't match + if subplot["image_widget_managed"].data.value.shape == new_image.shape: + return - # set camera orthogonal to the xy plane, flip y axis - subplot.camera.set_state( - { - "position": [0, 0, -1], - "rotation": [0, 0, 0, 1], - "scale": [1, -1, 1], - "reference_up": [0, 1, 0], - "fov": 0, - "depth_range": None - } - ) + # keep cmap + cmap = subplot["image_widget_managed"].cmap + # delete graphic since it will be replaced + subplot.delete_graphic(subplot["image_widget_managed"]) + else: + # default cmap + cmap = "plasma" + + if image_processor.n_display_dims == 2: + g = subplot.add_image( + data=new_image, + cmap=cmap, + name="image_widget_managed" + ) - subplot.controller = "panzoom" - subplot.axes.intersection = None + # set camera orthogonal to the xy plane, flip y axis + subplot.camera.set_state( + { + "position": [0, 0, -1], + "rotation": [0, 0, 0, 1], + "scale": [1, -1, 1], + "reference_up": [0, 1, 0], + "fov": 0, + "depth_range": None + } + ) - elif image_array.n_display_dims == 3: - g = subplot.add_image_volume( - data=image_data, - cmap=cmap, - name="image_widget_managed" - ) - subplot.camera.fov = 50 - subplot.controller = "orbit" + subplot.controller = "panzoom" + subplot.axes.intersection = None + + elif image_processor.n_display_dims == 3: + g = subplot.add_image_volume( + data=new_image, + cmap=cmap, + name="image_widget_managed" + ) + subplot.camera.fov = 50 + subplot.controller = "orbit" + + # make sure all 3D dimension camera scales are positive + # MIP rendering doesn't work with negative camera scales + for dim in ["x", "y", "z"]: + if getattr(subplot.camera.local, f"scale_{dim}") < 0: + setattr(subplot.camera.local, f"scale_{dim}", 1) + + subplot.camera.show_object(g.world_object) + + def _reset_histograms(self, subplot, image_processor): + """reset the histograms""" + if not self._histogram_widget: + subplot.docks["right"].size = 0 + return - # make sure all 3D dimension camera scales are positive - # MIP rendering doesn't work with negative camera scales - for dim in ["x", "y", "z"]: - if getattr(subplot.camera.local, f"scale_{dim}") < 0: - setattr(subplot.camera.local, f"scale_{dim}", 1) + if image_processor.histogram is None: + # no histogram available for this processor + # either there is no data array in this subplot, + # or a histogram routine does not exist for this processor + subplot.docks["right"].size = 0 + return + + image = subplot["image_widget_managed"] + + if "histogram_lut" in subplot.docks["right"]: + hlut: HistogramLUTTool = subplot.docks["right"]["histogram_lut"] + hlut.histogram = image_processor.histogram + hlut.images = image + + else: + # need to make one + hlut = HistogramLUTTool( + histogram=image_processor.histogram, + images=image, + name="histogram_lut", + ) + + subplot.docks["right"].add_graphic(hlut) + subplot.docks["right"].size = 80 - subplot.camera.show_object(g.world_object) + def _reset(self, skip_data_indices: tuple[int, ...] = None): + if skip_data_indices is None: + skip_data_indices = tuple() - def _reset(self): # reset the slider indices according to the new collection of dimensions self._reset_dimensions() # update graphics where display dims have changed accordings to indices - self._reset_graphics() + for i, (subplot, image_processor) in enumerate(zip(self.figure, self._image_processors)): + if i in skip_data_indices: + continue + + self._reset_graphics(subplot, image_processor) + self._reset_histograms(subplot, image_processor) + # force an update self.indices = self.indices From d9f06e6fe8b29b1845a95292fe9886ab4e52602b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 9 Nov 2025 05:59:41 -0500 Subject: [PATCH 30/81] imagewidget rgb toggle works --- fastplotlib/tools/_histogram_lut.py | 7 +++-- .../widgets/image_widget/_processor.py | 12 ++++++++- fastplotlib/widgets/image_widget/_widget.py | 26 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index 2313f9385..0c2c9a6bb 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -66,6 +66,7 @@ def __init__( ) self._colorbar.world_object.local.scale_x = 0.15 + self._colorbar.add_event_handler(self._open_cmap_picker, "click") self._ruler = pygfx.Ruler( end_pos=(0, 119, 0), @@ -202,8 +203,10 @@ def images(self, new_images: tuple[ImageGraphic | ImageVolumeGraphic] | None): # connect event handlers for image in self._images: - image.add_event_handler(self._image_event_handler, "vmin", "vmax", "cmap") + image.add_event_handler(self._image_event_handler, "vmin", "vmax") image.add_event_handler(self._disconnect_images, "deleted") + if image.cmap is not None: + image.add_event_handler(self._image_event_handler, "vmin", "vmax", "cmap") def _disconnect_images(self, *args): for image in self._images: @@ -216,7 +219,7 @@ def _image_event_handler(self, ev): setattr(self, ev.type, new_value) @property - def cmap(self) -> str | None: + def cmap(self) -> str: return self._colorbar.cmap @cmap.setter diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index 4eb1eb27b..b896d4ad4 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -72,7 +72,7 @@ def __init__( self.data = data self._n_display_dims = n_display_dims - self._rgb = rgb + self.rgb = rgb self.window_funcs = window_funcs self.window_sizes = window_sizes @@ -128,6 +128,16 @@ def rgb(self) -> bool: """whether or not the data is rgb(a)""" return self._rgb + @rgb.setter + def rgb(self, rgb: bool): + if not isinstance(rgb, bool): + raise TypeError + + if rgb and self.ndim < 3: + raise IndexError(f"require 3 or more dims for RGB, you have: {self.ndim} dims") + + self._rgb = rgb + @property def n_slider_dims(self) -> int: """number of slider dimensions""" diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 9385e7388..2d354c507 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -436,6 +436,29 @@ def data(self, new_data: Sequence[ArrayProtocol | None]): self._reset(skip_indices) + @property + def rgb(self): + """get or set the rgb toggle for each data array""" + return tuple(p.rgb for p in self._image_processors) + + @rgb.setter + def rgb(self, rgb: Sequence[bool]): + if len(rgb) != len(self.data): + raise IndexError + + # if the rgb option hasn't been changed + # graphics will not be reset for this data index + skip_indices = list() + + for i, (new, image_processor) in enumerate(zip(rgb, self._image_processors)): + if image_processor.rgb == new: + skip_indices.append(i) + continue + + image_processor.rgb = new + + self._reset(skip_indices) + @property def indices(self) -> tuple[int, ...]: """ @@ -592,6 +615,9 @@ def _reset_graphics(self, subplot, image_processor): # keep cmap cmap = subplot["image_widget_managed"].cmap + if cmap is None: + # ex: going from rgb -> grayscale + cmap = "plasma" # delete graphic since it will be replaced subplot.delete_graphic(subplot["image_widget_managed"]) else: From 97f7064c82fb49bf52048194c72ee4a2a5b790b7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 00:01:17 -0500 Subject: [PATCH 31/81] more progress --- .../widgets/image_widget/_processor.py | 17 +- fastplotlib/widgets/image_widget/_sliders.py | 2 - fastplotlib/widgets/image_widget/_widget.py | 286 ++++++++---------- 3 files changed, 146 insertions(+), 159 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index b896d4ad4..e5762f91f 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -71,7 +71,7 @@ def __init__( self._compute_histogram = False self.data = data - self._n_display_dims = n_display_dims + self.n_display_dims = n_display_dims self.rgb = rgb self.window_funcs = window_funcs @@ -169,7 +169,7 @@ def n_display_dims(self) -> Literal[2, 3]: # TODO: make n_display_dims settable, requires thinking about inserting and poping indices in ImageWidget @n_display_dims.setter def n_display_dims(self, n: Literal[2, 3]): - if n not in (2, 3): + if n != 2 or n != 3: raise ValueError("`n_display_dims` must be an with a value of 2 or 3") self._n_display_dims = n self._recompute_histogram() @@ -211,7 +211,7 @@ def window_funcs( self._validate_window_func(window_funcs) - self._window_funcs = window_funcs + self._window_funcs = tuple(window_funcs) self._recompute_histogram() def _validate_window_func(self, funcs): @@ -305,6 +305,10 @@ def window_order(self) -> tuple[int, ...] | None: @window_order.setter def window_order(self, order: tuple[int] | None): + if order is None: + self._window_order = None + return + if order is not None: if not all([d <= self.n_slider_dims for d in order]): raise IndexError( @@ -317,7 +321,7 @@ def window_order(self, order: tuple[int] | None): f"all `window_order` entires must be >= 0, you have passed: {order}" ) - self._window_order = order + self._window_order = tuple(order) self._recompute_histogram() @property @@ -327,6 +331,11 @@ def finalizer_func(self) -> Callable[[ArrayLike], ArrayLike] | None: @finalizer_func.setter def finalizer_func(self, func: Callable[[ArrayLike], ArrayLike] | None): + if not callable(func) or func is not None: + raise TypeError( + f"`finalizer_func` must be a callable or `None`, you have passed: {func}" + ) + self._finalizer_func = func self._recompute_histogram() diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 8ac920a7d..04cd269fa 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -87,8 +87,6 @@ def update(self): # time now now = perf_counter() - # self._size = 300#57 + (self._image_widget.n_sliders * 50) - # buttons and slider UI elements for each dim for dim in range(self._image_widget.n_sliders): imgui.push_id(f"{self._id_counter}_{dim}") diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 2d354c507..995f95dd6 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -7,7 +7,7 @@ from ...layouts import ImguiFigure as Figure from ...graphics import ImageGraphic, ImageVolumeGraphic -from ...utils import calculate_figure_shape, quick_min_max, ArrayProtocol, ARRAY_LIKE_ATTRS +from ...utils import calculate_figure_shape, quick_min_max, ArrayProtocol from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders from ._processor import NDImageProcessor, WindowFuncCallable @@ -168,7 +168,7 @@ def __init__( if not len(rgb) == len(data): raise ValueError( - f"len(rgb) != len(data), {len(rgb)} != {len(self.data)}. These must be equal" + f"len(rgb) != len(data), {len(rgb)} != {len(data)}. These must be equal" ) if names is not None: @@ -420,7 +420,10 @@ def data(self) -> tuple[ArrayProtocol | None]: @data.setter def data(self, new_data: Sequence[ArrayProtocol | None]): - if len(new_data) != len(self.data): + if isinstance(new_data, ArrayProtocol) or new_data is None: + new_data = [new_data] * len(self._image_processors) + + if len(new_data) != len(self._image_processors): raise IndexError # if the data array hasn't been changed @@ -443,7 +446,10 @@ def rgb(self): @rgb.setter def rgb(self, rgb: Sequence[bool]): - if len(rgb) != len(self.data): + if isinstance(rgb, bool): + rgb = [rgb] * len(self._image_processors) + + if len(rgb) != len(self._image_processors): raise IndexError # if the rgb option hasn't been changed @@ -459,6 +465,122 @@ def rgb(self, rgb: Sequence[bool]): self._reset(skip_indices) + @property + def n_display_dims(self) -> tuple[Literal[2, 3]]: + """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" + return tuple(img.n_display_dims for img in self._image_processors) + + @n_display_dims.setter + def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[[2, 3]]): + if isinstance(new_ndd, (int, np.integer)): + if new_ndd == 2 or new_ndd == 3: + new_ndd = [new_ndd] * len(self._image_processors) + else: + raise ValueError + + if len(new_ndd) != len(self._image_processors): + raise IndexError + + if not all([(n == 2) or (n == 3) for n in new_ndd]): + raise ValueError + + # if the n_display_dims hasn't been changed for this data array + # graphics will not be reset for this data array index + skip_indices = list() + + # first update image arrays + for i, (image_processor, new) in enumerate(zip(self._image_processors, new_ndd)): + if new > image_processor.max_n_display_dims: + raise IndexError( + f"number of display dims exceeds maximum number of possible " + f"display dimensions: {image_processor.max_n_display_dims}, for array at index: " + f"{i} with shape: {image_processor.shape}, and rgb set to: {image_processor.rgb}" + ) + + if image_processor.n_display_dims == new: + skip_indices.append(i) + else: + image_processor.n_display_dims = new + + self._reset(skip_indices) + + @property + def window_funcs(self) -> tuple[tuple[WindowFuncCallable | None, ...] | None]: + """get or set the window functions""" + return tuple(p.window_funcs for p in self._image_processors) + + @window_funcs.setter + def window_funcs(self, new_funcs: Sequence[WindowFuncCallable | None, ...] | None): + if callable(new_funcs) or new_funcs is None: + new_funcs = [new_funcs] * len(self._image_processors) + + if len(new_funcs) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("window_funcs", new_funcs) + + @property + def window_sizes(self) -> tuple[tuple[int | None, ...] | None]: + """get or set the window sizes""" + return tuple(p.window_sizes for p in self._image_processors) + + @window_sizes.setter + def window_sizes(self, new_sizes: Sequence[tuple[int | None, ...] | int | None] | int | None): + if isinstance(new_sizes, int) or new_sizes is None: + # same window for all data arrays + new_sizes = [new_sizes] * len(self._image_processors) + + if len(new_sizes) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("window_sizes", new_sizes) + + @property + def window_order(self) -> tuple[tuple[int, ...] | None]: + """get or set order in which window functions are applied over dimensions""" + return tuple(p.window_order for p in self._image_processors) + + @window_order.setter + def window_order(self, new_order: Sequence[tuple[int, ...]]): + if new_order is None: + new_order = [new_order] * len(self._image_processors) + + if all([isinstance(order, (int, np.integer))] for order in new_order): + # same order specified across all data arrays + new_order = [new_order] * len(self._image_processors) + + if len(new_order) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("window_order", new_order) + + @property + def finalizer_funcs(self) -> tuple[Callable | None]: + """Get or set a finalizer function that operates on the spatial dimensions of the 2D or 3D image""" + return tuple(p.finalizer_func for p in self._image_processors) + + @finalizer_funcs.setter + def finalizer_funcs(self, funcs: Callable | Sequence[Callable] | None): + if callable(funcs) or funcs is None: + funcs = [funcs] * len(self._image_processors) + + if len(funcs) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("finalizer_func", funcs) + + def _set_image_processor_funcs(self, attr, new_values): + """sets window_funcs, window_sizes, window_order, or finalizer_func and updates displayed data and histograms""" + for new, image_processor, subplot in zip(new_values, self._image_processors, self.figure): + if getattr(image_processor, attr) == new: + continue + + setattr(image_processor, attr, new) + + self._reset_histograms(subplot, image_processor) + + self.indices = self.indices + @property def indices(self) -> tuple[int, ...]: """ @@ -491,8 +613,8 @@ def indices(self, new_indices: Sequence[int]): f"only positive index values are supported, you have passed: {new_indices}" ) - for image_array, graphic in zip(self._image_processors, self.graphics): - new_data = self._get_image(image_array, indices=new_indices) + for image_processor, graphic in zip(self._image_processors, self.graphics): + new_data = self._get_image(image_processor, indices=new_indices) if new_data is None: continue @@ -511,39 +633,6 @@ def indices(self, new_indices: Sequence[int]): # set_value has finished executing, now allow future executions self._reentrant_block = False - @property - def n_display_dims(self) -> tuple[Literal[2, 3]]: - """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" - return tuple(img.n_display_dims for img in self._image_processors) - - @n_display_dims.setter - def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]]): - if len(new_ndd) != len(self.data): - raise IndexError - - if not all([n in (2, 3) for n in new_ndd]): - raise ValueError - - # if the n_display_dims hasn't been changed for this data array - # graphics will not be reset for this data array index - skip_indices = list() - - # first update image arrays - for i, (image_processor, new) in enumerate(zip(self._image_processors, new_ndd)): - if new > image_processor.max_n_display_dims: - raise IndexError( - f"number of display dims exceeds maximum number of possible " - f"display dimensions: {image_processor.max_n_display_dims}, for array at index: " - f"{i} with shape: {image_processor.shape}, and rgb set to: {image_processor.rgb}" - ) - - if image_processor.n_display_dims == new: - skip_indices.append(i) - else: - image_processor.n_display_dims = new - - self._reset(skip_indices) - @property def n_sliders(self) -> int: """number of sliders""" @@ -585,7 +674,7 @@ def _reset_dimensions(self): # add or remove dims from indices # trim any excess dimensions while len(self._indices) > self.n_sliders: - # pop from right -> left + # remove outer most dims first self._indices.pop(0) self._sliders_ui.pop_dim() @@ -676,6 +765,10 @@ def _reset_histograms(self, subplot, image_processor): subplot.docks["right"].size = 0 return + if "image_widget_managed" not in subplot: + # no image in this subplot + return + image = subplot["image_widget_managed"] if "histogram_lut" in subplot.docks["right"]: @@ -809,119 +902,6 @@ def reset_vmin_vmax_frame(self): # set the data using the current image graphic data hlut.set_data(subplot["image_widget_managed"].data.value) - def set_data( - self, - new_data: np.ndarray | list[np.ndarray], - reset_vmin_vmax: bool = True, - reset_indices: bool = True, - ): - """ - Change data of widget. Note: sliders max currently update only for ``txy`` and ``tzxy`` data. - - Parameters - ---------- - new_data: array-like or list of array-like - The new data to display in the widget - - reset_vmin_vmax: bool, default ``True`` - reset the vmin vmax levels based on the new data - - reset_indices: bool, default ``True`` - reset the current index for all dimensions to 0 - - """ - - if reset_indices: - for key in self.indices: - self.indices[key] = 0 - - # set slider max according to new data - max_lengths = dict() - for scroll_dim in self.slider_dims: - max_lengths[scroll_dim] = np.inf - - if _is_arraylike(new_data): - new_data = [new_data] - - if len(self._data) != len(new_data): - raise ValueError( - f"number of new data arrays {len(new_data)} must match" - f" current number of data arrays {len(self._data)}" - ) - # check all arrays - for i, (new_array, current_array) in enumerate(zip(new_data, self._data)): - if new_array.ndim != current_array.ndim: - raise ValueError( - f"new data ndim {new_array.ndim} at index {i} " - f"does not equal current data ndim {current_array.ndim}" - ) - - # Computes the number of scrollable dims and also validates new_array - new_scrollable_dims = self._get_n_scrollable_dims(new_array, self._rgb[i]) - - if self.n_scrollable_dims[i] != new_scrollable_dims: - raise ValueError( - f"number of dimensions of data arrays must match number of dimensions of " - f"existing data arrays" - ) - - # if checks pass, update with new data - for i, (new_array, current_array, subplot) in enumerate( - zip(new_data, self._data, self.figure) - ): - # if the new array is the same as the existing array, skip - # this allows setting just a subset of the arrays in the ImageWidget - if new_data is self._data[i]: - continue - - # check last two dims (x and y) to see if data shape is changing - old_data_shape = self._data[i].shape[-self.n_img_dims[i] :] - self._data[i] = new_array - - if old_data_shape != new_array.shape[-self.n_img_dims[i] :]: - frame = self._process_indices( - new_array, slice_indices=self._current_index - ) - frame = self._process_frame_apply(frame, i) - - # make new graphic first - new_graphic = ImageGraphic(data=frame, name="image_widget_managed") - - if self._histogram_widget: - # set hlut tool to use new graphic - subplot.docks["right"]["histogram_lut"].images = new_graphic - - # delete old graphic after setting hlut tool to new graphic - # this ensures gc - subplot.delete_graphic(graphic=subplot["image_widget_managed"]) - subplot.insert_graphic(graphic=new_graphic) - - # Returns "", "t", or "tz" - curr_scrollable_format = SCROLLABLE_DIMS_ORDER[self.n_scrollable_dims[i]] - - for scroll_dim in self.slider_dims: - if scroll_dim in curr_scrollable_format: - new_length = new_array.shape[ - curr_scrollable_format.index(scroll_dim) - ] - if max_lengths[scroll_dim] == np.inf: - max_lengths[scroll_dim] = new_length - elif max_lengths[scroll_dim] != new_length: - raise ValueError( - f"New arrays have differing values along dim {scroll_dim}" - ) - - self._dims_max_bounds[scroll_dim] = max_lengths[scroll_dim] - - # set histogram widget - if self._histogram_widget: - subplot.docks["right"]["histogram_lut"].set_data( - new_array, reset_vmin_vmax=reset_vmin_vmax - ) - - # force graphics to update - self.indices = self.indices - def show(self, **kwargs): """ Show the widget. From bf2226daaaca95fadfeb833870c13db6ff91cd49 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 02:35:10 -0500 Subject: [PATCH 32/81] support rgb(a) image volumes --- fastplotlib/graphics/image_volume.py | 31 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index db616b30d..e6e06a76e 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -211,16 +211,27 @@ def __init__( self._interpolation = ImageInterpolation(interpolation) - # TODO: I'm assuming RGB volume images aren't supported??? - # use TextureMap for grayscale images - self._cmap = ImageCmap(cmap) - self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) - - self._texture_map = pygfx.TextureMap( - self._cmap.texture, - filter=self._cmap_interpolation.value, - wrap="clamp-to-edge", - ) + if self._data.value.ndim == 4: + # set map to None for RGB image volumes + self._cmap = None + self._texture_map = None + + elif self._data.value.ndim == 3: + # use TextureMap for grayscale images + self._cmap = ImageCmap(cmap) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) + self._texture_map = pygfx.TextureMap( + self._cmap.texture, + filter=self._cmap_interpolation.value, + wrap="clamp-to-edge", + ) + else: + raise ValueError( + f"ImageVolumeGraphic `data` must have 3 dimensions for grayscale images, " + f"or 4 dimensions for RGB(A) images.\n" + f"You have passed a a data array with: {self._data.value.ndim} dimensions, " + f"and of shape: {self._data.value.shape}" + ) self._plane = VolumeSlicePlane(plane) self._threshold = VolumeIsoThreshold(threshold) From db11abf08e685ef857caee7f60b7d7be558e4c79 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 02:35:42 -0500 Subject: [PATCH 33/81] ImageGraphic cleanup --- fastplotlib/graphics/image.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 1eaf54bb6..9a62af2bc 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -158,19 +158,26 @@ def __init__( self._interpolation = ImageInterpolation(interpolation) # set map to None for RGB images - if self._data.value.ndim > 2: + if self._data.value.ndim == 3: self._cmap = None + self._cmap_interpolation = None _map = None - else: + + elif self._data.value.ndim == 2: # use TextureMap for grayscale images self._cmap = ImageCmap(cmap) self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) - _map = pygfx.TextureMap( self._cmap.texture, filter=self._cmap_interpolation.value, wrap="clamp-to-edge", ) + else: + raise ValueError( + f"ImageGraphic `data` must have 2 dimensions for grayscale images, or 3 dimensions for RGB(A) images.\n" + f"You have passed a a data array with: {self._data.value.ndim} dimensions, " + f"and of shape: {self._data.value.shape}" + ) # one common material is used for every Texture chunk self._material = pygfx.ImageBasicMaterial( @@ -223,8 +230,6 @@ def cmap(self) -> str | None: if self._cmap is not None: return self._cmap.value - return None - @cmap.setter def cmap(self, name: str): if self.data.value.ndim > 2: @@ -259,9 +264,10 @@ def interpolation(self, value: str): self._interpolation.set_value(self, value) @property - def cmap_interpolation(self) -> str: - """cmap interpolation method""" - return self._cmap_interpolation.value + def cmap_interpolation(self) -> str | None: + """cmap interpolation method, 'linear' or 'nearest'. `None` if image is RGB(A)""" + if self._cmap_interpolation is not None: + return self._cmap_interpolation.value @cmap_interpolation.setter def cmap_interpolation(self, value: str): From a156410911b66cf852e01aa6e89ad7dba2f8f153 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 02:37:51 -0500 Subject: [PATCH 34/81] cleanup, docs --- fastplotlib/widgets/image_widget/_widget.py | 107 +++++++++++--------- 1 file changed, 58 insertions(+), 49 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 995f95dd6..6d5c31f76 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -19,11 +19,11 @@ class ImageWidget: def __init__( self, - data: ArrayProtocol | list[ArrayProtocol | None] | None, - processor: NDImageProcessor | Sequence[NDImageProcessor] = NDImageProcessor, + data: ArrayProtocol | Sequence[ArrayProtocol | None] | None, + processors: NDImageProcessor | Sequence[NDImageProcessor] = NDImageProcessor, n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, slider_dim_names: Sequence[str] | None = None, # dim names left -> right - rgb: bool | Sequence[bool] = None, + rgb: bool | Sequence[bool] = False, cmap: str | Sequence[str]= "plasma", window_funcs: ( tuple[WindowFuncCallable | None, ...] @@ -59,26 +59,20 @@ def __init__( Parameters ---------- - data: np.ndarray | List[np.ndarray] - array-like or a list of array-like - - window_funcs: dict[str, tuple[Callable, int]], i.e. {"t" or "z": (callable, int)} - | Apply function(s) with rolling windows along "t" and/or "z" dimensions of the `data` arrays. - | Pass a dict in the form: {dimension: (func, window_size)}, `func` must take a slice of the data array as - | the first argument and must take `axis` as a kwarg. - | Ex: mean along "t" dimension: {"t": (np.mean, 11)}, if `current_index` of "t" is 50, it will pass frames - | 45 to 55 to `np.mean` with `axis=0`. - | Ex: max along z dim: {"z": (np.max, 3)}, passes current, previous & next frame to `np.max` with `axis=1` - - frame_apply: Union[callable, Dict[int, callable]] - | Apply function(s) to `data` arrays before to generate final 2D image that is displayed. - | Ex: apply a spatial gaussian filter - | Pass a single function or a dict of functions to apply to each array individually - | examples: ``{array_index: to_grayscale}``, ``{0: to_grayscale, 2: threshold_img}`` - | "array_index" is the position of the corresponding array in the data list. - | if `window_funcs` is used, then this function is applied after `window_funcs` - | this function must be a callable that returns a 2D array - | example use case: converting an RGB frame from video to a 2D grayscale frame + data: ArrayProtocol | Sequence[ArrayProtocol | None] | None + array-like or a list of array-like, each array must have a minimum of 2 dimensions + + processors: NDImageProcessor | Sequence[NDImageProcessor], default NDImageProcessor + The image processors used for each n-dimensional data array + + n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]], default 2 + number of display dimensions + + slider_dim_names: Sequence[str], optional + optional list/tuple of names for each slider dim + + rgb: bool | Sequence[bool], default + whether or not each data array represents RGB(A) images figure_shape: Optional[Tuple[int, int]] manually provide the shape for the Figure, otherwise the number of rows and columns is estimated @@ -121,20 +115,20 @@ def __init__( f"You have passed the following type {type(data)}" ) - if issubclass(processor, NDImageProcessor): - processor = [processor] * len(data) + if issubclass(processors, NDImageProcessor): + processors = [processors] * len(data) - elif isinstance(processor, (tuple, list)): - if not all([issubclass(p, NDImageProcessor) for p in processor]): + elif isinstance(processors, (tuple, list)): + if not all([issubclass(p, NDImageProcessor) for p in processors]): raise TypeError( - f"`processor` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " - f"list/tuple of `NDImageProcess` subclasses. You have passed: {processor}" + f"`processors` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " + f"list/tuple of `NDImageProcess` subclasses. You have passed: {processors}" ) else: raise TypeError( - f"`processor` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " - f"list/tuple of `NDImageProcess` subclasses. You have passed: {processor}" + f"`processors` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " + f"list/tuple of `NDImageProcess` subclasses. You have passed: {processors}" ) # subplot layout @@ -155,15 +149,12 @@ def __init__( f" Resetting figure shape to: {figure_shape}" ) - if rgb is None: - rgb = [False] * len(data) - elif isinstance(rgb, bool): rgb = [rgb] * len(data) if not all([isinstance(v, bool) for v in rgb]): raise TypeError( - f"`rgb` parameter must be a bool or a Sequence of bool, <{rgb}> was provided" + f"`rgb` parameter must be a bool or a Sequence of bool, you have passed: {rgb}" ) if not len(rgb) == len(data): @@ -267,8 +258,8 @@ def __init__( # make NDImageArrays self._image_processors: list[NDImageProcessor] = list() for i in range(len(data)): - cls = processor[i] - image_array = cls( + cls = processors[i] + image_processor = cls( data=data[i], rgb=rgb[i], n_display_dims=n_display_dims[i], @@ -279,7 +270,7 @@ def __init__( compute_histogram=self._histogram_widget, ) - self._image_processors.append(image_array) + self._image_processors.append(image_processor) if len(set(n_display_dims)) > 1: # assume user wants one controller for 2D images and another for 3D image volumes @@ -392,7 +383,7 @@ def __init__( subplot.add_graphic(graphic) - self._reset_histograms(subplot, self._image_processors[i]) + self._reset_histogram(subplot, self._image_processors[i]) self._sliders_ui = ImageWidgetSliders( figure=self.figure, @@ -471,7 +462,7 @@ def n_display_dims(self) -> tuple[Literal[2, 3]]: return tuple(img.n_display_dims for img in self._image_processors) @n_display_dims.setter - def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[[2, 3]]): + def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[2, 3]): if isinstance(new_ndd, (int, np.integer)): if new_ndd == 2 or new_ndd == 3: new_ndd = [new_ndd] * len(self._image_processors) @@ -505,12 +496,12 @@ def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[[2, 3]]): self._reset(skip_indices) @property - def window_funcs(self) -> tuple[tuple[WindowFuncCallable | None, ...] | None]: + def window_funcs(self) -> tuple[tuple[WindowFuncCallable | None] | None]: """get or set the window functions""" return tuple(p.window_funcs for p in self._image_processors) @window_funcs.setter - def window_funcs(self, new_funcs: Sequence[WindowFuncCallable | None, ...] | None): + def window_funcs(self, new_funcs: Sequence[WindowFuncCallable | None] | None): if callable(new_funcs) or new_funcs is None: new_funcs = [new_funcs] * len(self._image_processors) @@ -577,8 +568,12 @@ def _set_image_processor_funcs(self, attr, new_values): setattr(image_processor, attr, new) - self._reset_histograms(subplot, image_processor) + # window functions and finalizer functions will only change the histogram + # they do not change the collections of dimensions, so we don't need to call _reset_dimensions + # they also do not change the image graphic, so we do not need to call _reset_image_graphics + self._reset_histogram(subplot, image_processor) + # update the displayed image data in the graphics self.indices = self.indices @property @@ -633,6 +628,20 @@ def indices(self, new_indices: Sequence[int]): # set_value has finished executing, now allow future executions self._reentrant_block = False + @property + def histogram_widget(self) -> bool: + """show or hide the histograms""" + return self._histogram_widget + + @histogram_widget.setter + def histogram_widget(self, show_histogram: bool): + if not isinstance(show_histogram, bool): + raise TypeError(f"`histogram_widget` can be set with a bool, you have passed: {show_histogram}") + + for subplot, image_processor in zip(self.figure, self._image_processors): + image_processor.compute_histogram = show_histogram + self._reset_histogram(subplot, image_processor) + @property def n_sliders(self) -> int: """number of sliders""" @@ -686,8 +695,8 @@ def _reset_dimensions(self): self._sliders_ui.size = 55 + (IMGUI_SLIDER_HEIGHT * self.n_sliders) - def _reset_graphics(self, subplot, image_processor): - """delete and create new graphics if necessary""" + def _reset_image_graphics(self, subplot, image_processor): + """delete and create a new image graphic if necessary""" new_image = self._get_image(image_processor, indices=self.indices) if new_image is None: if "image_widget_managed" in subplot: @@ -752,8 +761,8 @@ def _reset_graphics(self, subplot, image_processor): subplot.camera.show_object(g.world_object) - def _reset_histograms(self, subplot, image_processor): - """reset the histograms""" + def _reset_histogram(self, subplot, image_processor): + """reset the histogram""" if not self._histogram_widget: subplot.docks["right"].size = 0 return @@ -798,8 +807,8 @@ def _reset(self, skip_data_indices: tuple[int, ...] = None): if i in skip_data_indices: continue - self._reset_graphics(subplot, image_processor) - self._reset_histograms(subplot, image_processor) + self._reset_image_graphics(subplot, image_processor) + self._reset_histogram(subplot, image_processor) # force an update self.indices = self.indices From bebec04d5f3206471827cc63faa2eed95f479afb Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 03:41:58 -0500 Subject: [PATCH 35/81] fix --- fastplotlib/graphics/image_volume.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index e6e06a76e..dda720eed 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -215,6 +215,7 @@ def __init__( # set map to None for RGB image volumes self._cmap = None self._texture_map = None + self._cmap_interpolation = None elif self._data.value.ndim == 3: # use TextureMap for grayscale images @@ -293,9 +294,10 @@ def mode(self, mode: str): self._mode.set_value(self, mode) @property - def cmap(self) -> str: + def cmap(self) -> str | None: """Get or set colormap name""" - return self._cmap.value + if self._cmap is not None: + return self._cmap.value @cmap.setter def cmap(self, name: str): @@ -329,9 +331,10 @@ def interpolation(self, value: str): self._interpolation.set_value(self, value) @property - def cmap_interpolation(self) -> str: + def cmap_interpolation(self) -> str | None: """Get or set the cmap interpolation method""" - return self._cmap_interpolation.value + if self._cmap_interpolation is not None: + return self._cmap_interpolation.value @cmap_interpolation.setter def cmap_interpolation(self, value: str): From cc9b0270ce76261f5649d6fef0f3e689f94b606c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 03:46:23 -0500 Subject: [PATCH 36/81] updates --- .../widgets/image_widget/_processor.py | 4 +-- fastplotlib/widgets/image_widget/_widget.py | 30 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index e5762f91f..bc1b37bf2 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -169,8 +169,8 @@ def n_display_dims(self) -> Literal[2, 3]: # TODO: make n_display_dims settable, requires thinking about inserting and poping indices in ImageWidget @n_display_dims.setter def n_display_dims(self, n: Literal[2, 3]): - if n != 2 or n != 3: - raise ValueError("`n_display_dims` must be an with a value of 2 or 3") + if not (n == 2 or n == 3): + raise ValueError(f"`n_display_dims` must be an with a value of 2 or 3, you have passed: {n}") self._n_display_dims = n self._recompute_histogram() diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 6d5c31f76..9ea13b2e7 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -11,6 +11,7 @@ from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders from ._processor import NDImageProcessor, WindowFuncCallable +from ._properties import ImageProcessorProperty, Indices IMGUI_SLIDER_HEIGHT = 49 @@ -37,7 +38,7 @@ def __init__( tuple[int | None, ...] | Sequence[tuple[int | None, ...] | None] ) = None, window_order: tuple[int, ...] | Sequence[tuple[int, ...] | None] = None, - finalizer_funcs: ( + finalizer_func: ( Callable[[ArrayProtocol], ArrayProtocol] | Sequence[Callable[[ArrayProtocol], ArrayProtocol]] | None @@ -220,28 +221,27 @@ def __init__( win_order = window_order # verify finalizer function - if finalizer_funcs is None: + if finalizer_func is None: final_funcs = [None] * len(data) - elif callable(finalizer_funcs): + elif callable(finalizer_func): # same finalizer func for all data arrays - final_funcs = [finalizer_funcs] * len(data) + final_funcs = [finalizer_func] * len(data) - elif len(finalizer_funcs) != len(data): + elif len(finalizer_func) != len(data): raise IndexError else: - final_funcs = finalizer_funcs + final_funcs = finalizer_func # verify number of display dims - if isinstance(n_display_dims, int): - if n_display_dims not in (2, 3): - raise ValueError + if isinstance(n_display_dims, (int, np.integer)): n_display_dims = [n_display_dims] * len(data) elif isinstance(n_display_dims, (tuple, list)): - if not all([n in (2, 3) for n in n_display_dims]): - raise ValueError + if not all([isinstance(n, (int, np.integer)) for n in n_display_dims]): + raise TypeError + if len(n_display_dims) != len(data): raise IndexError else: @@ -379,7 +379,7 @@ def __init__( vmax=vmax, **graphic_kwargs[i], ) - subplot.fov = 50 + subplot.camera.fov = 50 subplot.add_graphic(graphic) @@ -546,12 +546,12 @@ def window_order(self, new_order: Sequence[tuple[int, ...]]): self._set_image_processor_funcs("window_order", new_order) @property - def finalizer_funcs(self) -> tuple[Callable | None]: + def finalizer_func(self) -> tuple[Callable | None]: """Get or set a finalizer function that operates on the spatial dimensions of the 2D or 3D image""" return tuple(p.finalizer_func for p in self._image_processors) - @finalizer_funcs.setter - def finalizer_funcs(self, funcs: Callable | Sequence[Callable] | None): + @finalizer_func.setter + def finalizer_func(self, funcs: Callable | Sequence[Callable] | None): if callable(funcs) or funcs is None: funcs = [funcs] * len(self._image_processors) From 52fc7157970572a17347769a4a767b91754d3775 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 04:40:39 -0500 Subject: [PATCH 37/81] new per-data array properties work --- .../widgets/image_widget/_properties.py | 137 +++++++++++------- fastplotlib/widgets/image_widget/_widget.py | 59 +++++--- 2 files changed, 118 insertions(+), 78 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_properties.py b/fastplotlib/widgets/image_widget/_properties.py index 09ca5f8e3..af71a87e6 100644 --- a/fastplotlib/widgets/image_widget/_properties.py +++ b/fastplotlib/widgets/image_widget/_properties.py @@ -1,46 +1,29 @@ +from pprint import pformat from typing import Iterable import numpy as np +from ._processor import NDImageProcessor -class BaseProperty: - """A list that allows only in-place modifications and updates the ImageWidget""" +class ImageProcessorProperty: def __init__( - self, - data: Iterable | None, - image_widget, - attribute: str, - key_types: type | tuple[type, ...], - value_types: type | tuple[type, ...], + self, + image_widget, + attribute: str, ): - if data is not None: - data = list(data) - - self._data = data - self._image_widget = image_widget + self._image_processors: list[NDImageProcessor] = image_widget._image_processors self._attribute = attribute - self._key_types = key_types - self._value_types = value_types - - @property - def data(self): - raise NotImplementedError - - def __getitem__(self, item): - if self.data is None: - return getattr(self._image_widget, self._attribute)[item] - - return self.data[item] - - def __setitem__(self, key, value): - if not isinstance(key, self._key_types): - raise TypeError + def _get_key(self, key: slice | int | np.integer | str) -> int | slice: + if not isinstance(key, (slice | int, np.integer, str)): + raise TypeError( + f"can index `{self._attribute}` only with a , , or a indicating the subplot name." + f"You tried to index with: {key}" + ) if isinstance(key, str): - # subplot name, find the numerical index for i, subplot in enumerate(self._image_widget.figure): if subplot.name == key: key = i @@ -48,41 +31,87 @@ def __setitem__(self, key, value): else: raise IndexError(f"No subplot with given name: {key}") - if not isinstance(value, self._value_types): - raise TypeError + return key - new_list = list(self.data) + def __getitem__(self, key): + key = self._get_key(key) + # return image processor attribute at this index + if isinstance(key, (int, np.integer)): + return getattr(self._image_processors[key], self._attribute) - new_list[key] = value + # if it's a slice + processors = self._image_processors[key] - setattr(self._image_widget, self._attribute, new_list) + return tuple( + getattr(p, self._attribute) for p in processors + ) - def __repr__(self): - return str(self.data) + def __setitem__(self, key, value): + key = self._get_key(key) + + # get the values from the ImageWidget property + new_values = list(getattr(p, self._attribute) for p in self._image_processors) + + # set the new value at this slice + new_values[key] = value + + # call the setter + setattr(self._image_widget, self._attribute, new_values) + + def __iter__(self): + for image_processor in self._image_processors: + yield getattr(image_processor, self._attribute) + def __repr__(self): + return f"{self._attribute}: {pformat(self[:])}" -class ImageWidgetData(BaseProperty): - pass + def __eq__(self, other): + return self[:] == other -class Indices(BaseProperty): +class Indices: def __init__( self, - data: Iterable, + indices: list[int], image_widget, ): - super().__init__( - data, - image_widget, - attribute="indices", - key_types=(int, np.integer), - value_types=(int, np.integer), - ) + self._data = indices + + self._image_widget = image_widget + + def __iter__(self): + for i in self._data: + yield i + + def __getitem__(self, item) -> int | tuple[int]: + return self._data[item] + + def __setitem__(self, key, value): + if not isinstance(key, (int, np.integer, slice)): + raise TypeError(f"indices can only be indexed with types, you have used: {key}") - @property - def data(self) -> list[int]: - return self._data + if not isinstance(value, (int, np.integer)): + raise TypeError(f"indices values can only be set with integers, you have tried to set the value: {value}") - @data.setter - def data(self, new_data): - self._data[:] = new_data + new_indices = list(self._data) + new_indices[key] = value + + self._image_widget.indices = new_indices + + def _fpl_set(self, values): + self._data[:] = values + + def pop_dim(self): + self._data.pop(0) + + def push_dim(self): + self._data.insert(0, 0) + + def __len__(self): + return len(self._data) + + def __eq__(self, other): + return self._data == other + + def __repr__(self): + return f"indices: {self._data}" diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 9ea13b2e7..d108bf4da 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -1,4 +1,4 @@ -from typing import Callable, Sequence, Literal +from typing import Callable, Sequence, Literal, Iterable from warnings import warn import numpy as np @@ -272,6 +272,14 @@ def __init__( self._image_processors.append(image_processor) + self._data = ImageProcessorProperty(self, "data") + self._rgb = ImageProcessorProperty(self, "rgb") + self._n_display_dims = ImageProcessorProperty(self, "n_display_dims") + self._window_funcs = ImageProcessorProperty(self, "window_funcs") + self._window_sizes = ImageProcessorProperty(self, "window_sizes") + self._window_order = ImageProcessorProperty(self, "window_order") + self._finalizer_func = ImageProcessorProperty(self, "finalizer_func") + if len(set(n_display_dims)) > 1: # assume user wants one controller for 2D images and another for 3D image volumes n_subplots = np.prod(figure_shape) @@ -323,10 +331,10 @@ def __init__( self._figure: Figure = Figure(**figure_kwargs_default) - self._indices = [0 for i in range(self.n_sliders)] + self._indices = Indices(list(0 for i in range(self.n_sliders)), self) for i, subplot in zip(range(len(self._image_processors)), self.figure): - image_data = self._get_image(self._image_processors[i], self._indices) + image_data = self._get_image(self._image_processors[i], tuple(self._indices)) if image_data is None: # this subplot/data array is blank, skip @@ -405,9 +413,9 @@ def __init__( self._reentrant_block = False @property - def data(self) -> tuple[ArrayProtocol | None]: + def data(self) -> Iterable[ArrayProtocol | None]: """get or set the nd-image data arrays""" - return tuple(array.data for array in self._image_processors) + return self._data @data.setter def data(self, new_data: Sequence[ArrayProtocol | None]): @@ -431,9 +439,9 @@ def data(self, new_data: Sequence[ArrayProtocol | None]): self._reset(skip_indices) @property - def rgb(self): + def rgb(self) -> Iterable[bool]: """get or set the rgb toggle for each data array""" - return tuple(p.rgb for p in self._image_processors) + return self._rgb @rgb.setter def rgb(self, rgb: Sequence[bool]): @@ -457,9 +465,9 @@ def rgb(self, rgb: Sequence[bool]): self._reset(skip_indices) @property - def n_display_dims(self) -> tuple[Literal[2, 3]]: + def n_display_dims(self) -> Iterable[Literal[2, 3]]: """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" - return tuple(img.n_display_dims for img in self._image_processors) + return self._n_display_dims @n_display_dims.setter def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[2, 3]): @@ -496,9 +504,9 @@ def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[2, 3]): self._reset(skip_indices) @property - def window_funcs(self) -> tuple[tuple[WindowFuncCallable | None] | None]: + def window_funcs(self) -> Iterable[tuple[WindowFuncCallable | None] | None]: """get or set the window functions""" - return tuple(p.window_funcs for p in self._image_processors) + return self._window_funcs @window_funcs.setter def window_funcs(self, new_funcs: Sequence[WindowFuncCallable | None] | None): @@ -511,9 +519,9 @@ def window_funcs(self, new_funcs: Sequence[WindowFuncCallable | None] | None): self._set_image_processor_funcs("window_funcs", new_funcs) @property - def window_sizes(self) -> tuple[tuple[int | None, ...] | None]: + def window_sizes(self) -> Iterable[tuple[int | None, ...] | None]: """get or set the window sizes""" - return tuple(p.window_sizes for p in self._image_processors) + return self._window_sizes @window_sizes.setter def window_sizes(self, new_sizes: Sequence[tuple[int | None, ...] | int | None] | int | None): @@ -527,9 +535,9 @@ def window_sizes(self, new_sizes: Sequence[tuple[int | None, ...] | int | None] self._set_image_processor_funcs("window_sizes", new_sizes) @property - def window_order(self) -> tuple[tuple[int, ...] | None]: + def window_order(self) -> Iterable[tuple[int, ...] | None]: """get or set order in which window functions are applied over dimensions""" - return tuple(p.window_order for p in self._image_processors) + return self._window_order @window_order.setter def window_order(self, new_order: Sequence[tuple[int, ...]]): @@ -546,9 +554,9 @@ def window_order(self, new_order: Sequence[tuple[int, ...]]): self._set_image_processor_funcs("window_order", new_order) @property - def finalizer_func(self) -> tuple[Callable | None]: + def finalizer_func(self) -> Iterable[Callable | None]: """Get or set a finalizer function that operates on the spatial dimensions of the 2D or 3D image""" - return tuple(p.finalizer_func for p in self._image_processors) + return self._finalizer_func @finalizer_func.setter def finalizer_func(self, funcs: Callable | Sequence[Callable] | None): @@ -577,17 +585,17 @@ def _set_image_processor_funcs(self, attr, new_values): self.indices = self.indices @property - def indices(self) -> tuple[int, ...]: + def indices(self) -> Iterable[int]: """ Get or set the current indices. Returns ------- - indices: tuple[int, ...] + indices: Iterable[int] integer index for each slider dimension """ - return tuple(self._indices) + return self._indices @indices.setter def indices(self, new_indices: Sequence[int]): @@ -615,7 +623,7 @@ def indices(self, new_indices: Sequence[int]): graphic.data = new_data - self._indices[:] = new_indices + self._indices._fpl_set(new_indices) # call any event handlers for handler in self._indices_changed_handlers: @@ -684,20 +692,20 @@ def _reset_dimensions(self): # trim any excess dimensions while len(self._indices) > self.n_sliders: # remove outer most dims first - self._indices.pop(0) + self._indices.pop_dim() self._sliders_ui.pop_dim() # add any new dimensions that aren't present while len(self.indices) < self.n_sliders: # insert right -> left - self._indices.insert(0, 0) + self._indices.push_dim() self._sliders_ui.push_dim() self._sliders_ui.size = 55 + (IMGUI_SLIDER_HEIGHT * self.n_sliders) def _reset_image_graphics(self, subplot, image_processor): """delete and create a new image graphic if necessary""" - new_image = self._get_image(image_processor, indices=self.indices) + new_image = self._get_image(image_processor, indices=tuple(self.indices)) if new_image is None: if "image_widget_managed" in subplot: # delete graphic from this subplot if present @@ -776,6 +784,7 @@ def _reset_histogram(self, subplot, image_processor): if "image_widget_managed" not in subplot: # no image in this subplot + subplot.docks["right"].size = 0 return image = subplot["image_widget_managed"] @@ -784,6 +793,8 @@ def _reset_histogram(self, subplot, image_processor): hlut: HistogramLUTTool = subplot.docks["right"]["histogram_lut"] hlut.histogram = image_processor.histogram hlut.images = image + if subplot.docks["right"].size < 1: + subplot.docks["right"].size = 80 else: # need to make one From 48c8c1ab12334bec79ba67f89d790b7d597bcc6f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 04:45:45 -0500 Subject: [PATCH 38/81] black formatting --- .../graphics/features/_selection_features.py | 4 +- fastplotlib/graphics/image_volume.py | 2 +- fastplotlib/tools/_histogram_lut.py | 81 ++++++++++++++----- fastplotlib/utils/_protocols.py | 9 +-- .../widgets/image_widget/_processor.py | 8 +- .../widgets/image_widget/_properties.py | 19 ++--- fastplotlib/widgets/image_widget/_sliders.py | 2 +- fastplotlib/widgets/image_widget/_widget.py | 56 ++++++++----- 8 files changed, 120 insertions(+), 61 deletions(-) diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index da7ca89e0..b05b8f347 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -182,7 +182,9 @@ def set_value(self, selector, value: Sequence[float], *, change: str = "full"): if len(self._event_handlers) < 1: return - event = GraphicFeatureEvent(self._property_name, {"value": self.value, "change": change}) + event = GraphicFeatureEvent( + self._property_name, {"value": self.value, "change": change} + ) event.get_selected_indices = selector.get_selected_indices event.get_selected_data = selector.get_selected_data diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index dda720eed..b8bed454e 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -211,7 +211,7 @@ def __init__( self._interpolation = ImageInterpolation(interpolation) - if self._data.value.ndim == 4: + if self._data.value.ndim == 4: # set map to None for RGB image volumes self._cmap = None self._texture_map = None diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index 0c2c9a6bb..c8c658be5 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -24,10 +24,10 @@ def _format_value(value: float): class HistogramLUTTool(Graphic): def __init__( - self, - histogram: tuple[np.ndarray, np.ndarray], - images: Sequence[ImageGraphic | ImageVolumeGraphic] | None = None, - **kwargs, + self, + histogram: tuple[np.ndarray, np.ndarray], + images: Sequence[ImageGraphic | ImageVolumeGraphic] | None = None, + **kwargs, ): super().__init__(**kwargs) @@ -45,7 +45,9 @@ def __init__( [np.zeros(120, dtype=np.float32), np.arange(0, 120)] ) - self._line = LineGraphic(line_data, colors=(0.8, 0.8, 0.8), alpha_mode="solid", offset=(1, 0, 0)) + self._line = LineGraphic( + line_data, colors=(0.8, 0.8, 0.8), alpha_mode="solid", offset=(1, 0, 0) + ) self._line.world_object.local.scale_x = -1 self._selector = LinearRegionSelector( @@ -60,9 +62,7 @@ def __init__( self._selector.add_event_handler(self._selector_event_handler, "selection") self._colorbar = ImageGraphic( - data=np.zeros([120, 2]), - interpolation="linear", - offset=(1.5, 0, 0) + data=np.zeros([120, 2]), interpolation="linear", offset=(1.5, 0, 0) ) self._colorbar.world_object.local.scale_x = 0.15 @@ -110,11 +110,17 @@ def __init__( self._colorbar.world_object, self._ruler, self._text_vmin.world_object, - self._text_vmax.world_object + self._text_vmax.world_object, ) self._set_world_object(wo) - self._children = [self._line, self._selector, self._colorbar, self._text_vmin, self._text_vmax] + self._children = [ + self._line, + self._selector, + self._colorbar, + self._text_vmin, + self._text_vmax, + ] # set histogram self.histogram = histogram @@ -144,28 +150,38 @@ def histogram(self) -> tuple[np.ndarray, np.ndarray]: return self._freq_flanked, self._bin_centers_flanked @histogram.setter - def histogram(self, histogram: tuple[np.ndarray, np.ndarray], limits: tuple[int, int] = None): + def histogram( + self, histogram: tuple[np.ndarray, np.ndarray], limits: tuple[int, int] = None + ): freq, edges = histogram - freq = (freq / freq.max()) + freq = freq / freq.max() bin_centers = 0.5 * (edges[1:] + edges[:-1]) step = bin_centers[1] - bin_centers[0] under_flank = np.linspace(bin_centers[0] - step * 10, bin_centers[0] - step, 10) - over_flank = np.linspace(bin_centers[-1] + step, bin_centers[-1] + step * 10, 10) - self._bin_centers_flanked[:] = np.concatenate([under_flank, bin_centers, over_flank]) + over_flank = np.linspace( + bin_centers[-1] + step, bin_centers[-1] + step * 10, 10 + ) + self._bin_centers_flanked[:] = np.concatenate( + [under_flank, bin_centers, over_flank] + ) self._freq_flanked[10:110] = freq self._line.data[:, 0] = self._freq_flanked - self._colorbar.data = np.column_stack([self._bin_centers_flanked, self._bin_centers_flanked]) + self._colorbar.data = np.column_stack( + [self._bin_centers_flanked, self._bin_centers_flanked] + ) # self.vmin, self.vmax = bin_centers[0], bin_centers[-1] if hasattr(self, "plot_area"): - self._ruler.update(self._plot_area.camera, self._plot_area.canvas.get_logical_size()) + self._ruler.update( + self._plot_area.camera, self._plot_area.canvas.get_logical_size() + ) @property def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic, ...] | None: @@ -182,7 +198,12 @@ def images(self, new_images: tuple[ImageGraphic | ImageVolumeGraphic] | None): if isinstance(new_images, (ImageGraphic, ImageVolumeGraphic)): new_images = [new_images] - if not all([isinstance(image, (ImageGraphic, ImageVolumeGraphic)) for image in new_images]): + if not all( + [ + isinstance(image, (ImageGraphic, ImageVolumeGraphic)) + for image in new_images + ] + ): raise TypeError for image in new_images: @@ -206,7 +227,9 @@ def images(self, new_images: tuple[ImageGraphic | ImageVolumeGraphic] | None): image.add_event_handler(self._image_event_handler, "vmin", "vmax") image.add_event_handler(self._disconnect_images, "deleted") if image.cmap is not None: - image.add_event_handler(self._image_event_handler, "vmin", "vmax", "cmap") + image.add_event_handler( + self._image_event_handler, "vmin", "vmax", "cmap" + ) def _disconnect_images(self, *args): for image in self._images: @@ -234,7 +257,9 @@ def cmap(self, name: str): try: self._colorbar.cmap = name - with pause_events(*self._images, event_handlers=[self._image_event_handler]): + with pause_events( + *self._images, event_handlers=[self._image_event_handler] + ): for image in self._images: image.cmap = name except Exception as exc: @@ -257,7 +282,14 @@ def vmin(self, value: float): self._block_reentrance = True try: index_min = np.searchsorted(self._bin_centers_flanked, value) - with pause_events(self._selector, *self._images, event_handlers=[self._selector_event_handler, self._image_event_handler]): + with pause_events( + self._selector, + *self._images, + event_handlers=[ + self._selector_event_handler, + self._image_event_handler, + ], + ): self._selector.selection = (index_min, self._selector.selection[1]) self._colorbar.vmin = value @@ -289,7 +321,14 @@ def vmax(self, value: float): self._block_reentrance = True try: index_max = np.searchsorted(self._bin_centers_flanked, value) - with pause_events(self._selector, *self._images, event_handlers=[self._selector_event_handler, self._image_event_handler]): + with pause_events( + self._selector, + *self._images, + event_handlers=[ + self._selector_event_handler, + self._image_event_handler, + ], + ): self._selector.selection = (self._selector.selection[0], index_max) self._colorbar.vmax = value diff --git a/fastplotlib/utils/_protocols.py b/fastplotlib/utils/_protocols.py index 386df137a..7ae63ed67 100644 --- a/fastplotlib/utils/_protocols.py +++ b/fastplotlib/utils/_protocols.py @@ -7,12 +7,9 @@ @runtime_checkable class ArrayProtocol(Protocol): @property - def ndim(self) -> int: - ... + def ndim(self) -> int: ... @property - def shape(self) -> tuple[int, ...]: - ... + def shape(self) -> tuple[int, ...]: ... - def __getitem__(self, key): - ... + def __getitem__(self, key): ... diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index bc1b37bf2..846b90da8 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -134,7 +134,9 @@ def rgb(self, rgb: bool): raise TypeError if rgb and self.ndim < 3: - raise IndexError(f"require 3 or more dims for RGB, you have: {self.ndim} dims") + raise IndexError( + f"require 3 or more dims for RGB, you have: {self.ndim} dims" + ) self._rgb = rgb @@ -170,7 +172,9 @@ def n_display_dims(self) -> Literal[2, 3]: @n_display_dims.setter def n_display_dims(self, n: Literal[2, 3]): if not (n == 2 or n == 3): - raise ValueError(f"`n_display_dims` must be an with a value of 2 or 3, you have passed: {n}") + raise ValueError( + f"`n_display_dims` must be an with a value of 2 or 3, you have passed: {n}" + ) self._n_display_dims = n self._recompute_histogram() diff --git a/fastplotlib/widgets/image_widget/_properties.py b/fastplotlib/widgets/image_widget/_properties.py index af71a87e6..c27923fc3 100644 --- a/fastplotlib/widgets/image_widget/_properties.py +++ b/fastplotlib/widgets/image_widget/_properties.py @@ -1,5 +1,4 @@ from pprint import pformat -from typing import Iterable import numpy as np @@ -8,9 +7,9 @@ class ImageProcessorProperty: def __init__( - self, - image_widget, - attribute: str, + self, + image_widget, + attribute: str, ): self._image_widget = image_widget self._image_processors: list[NDImageProcessor] = image_widget._image_processors @@ -42,9 +41,7 @@ def __getitem__(self, key): # if it's a slice processors = self._image_processors[key] - return tuple( - getattr(p, self._attribute) for p in processors - ) + return tuple(getattr(p, self._attribute) for p in processors) def __setitem__(self, key, value): key = self._get_key(key) @@ -88,10 +85,14 @@ def __getitem__(self, item) -> int | tuple[int]: def __setitem__(self, key, value): if not isinstance(key, (int, np.integer, slice)): - raise TypeError(f"indices can only be indexed with types, you have used: {key}") + raise TypeError( + f"indices can only be indexed with types, you have used: {key}" + ) if not isinstance(value, (int, np.integer)): - raise TypeError(f"indices values can only be set with integers, you have tried to set the value: {value}") + raise TypeError( + f"indices values can only be set with integers, you have tried to set the value: {value}" + ) new_indices = list(self._data) new_indices[key] = value diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 04cd269fa..9cd0fe5c5 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -38,7 +38,7 @@ def __init__(self, figure, size, location, title, image_widget): def pop_dim(self): """pop right most dim""" - i = 0 # len(self._image_widget.indices) - 1 + i = 0 # len(self._image_widget.indices) - 1 for l in [self._playing, self._fps, self._frame_time, self._last_frame_time]: l.pop(i) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index d108bf4da..1b6acf2a3 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -25,7 +25,7 @@ def __init__( n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, slider_dim_names: Sequence[str] | None = None, # dim names left -> right rgb: bool | Sequence[bool] = False, - cmap: str | Sequence[str]= "plasma", + cmap: str | Sequence[str] = "plasma", window_funcs: ( tuple[WindowFuncCallable | None, ...] | WindowFuncCallable @@ -250,7 +250,9 @@ def __init__( n_display_dims = tuple(n_display_dims) if sliders_dim_order not in ("right",): - raise ValueError(f"Only 'right' slider dims order is currently supported, you passed: {sliders_dim_order}") + raise ValueError( + f"Only 'right' slider dims order is currently supported, you passed: {sliders_dim_order}" + ) self._sliders_dim_order = sliders_dim_order self._histogram_widget = histogram_widget @@ -302,8 +304,8 @@ def __init__( figure_kwargs_default = { "controller_ids": controller_ids, - "controller_types": controller_types , - "names": names + "controller_types": controller_types, + "names": names, } # update the default kwargs with any user-specified kwargs @@ -334,7 +336,9 @@ def __init__( self._indices = Indices(list(0 for i in range(self.n_sliders)), self) for i, subplot in zip(range(len(self._image_processors)), self.figure): - image_data = self._get_image(self._image_processors[i], tuple(self._indices)) + image_data = self._get_image( + self._image_processors[i], tuple(self._indices) + ) if image_data is None: # this subplot/data array is blank, skip @@ -349,7 +353,9 @@ def __init__( if (vmin_specified is None) or (vmax_specified is None): # if either vmin or vmax are not specified, calculate an estimate by subsampling - vmin_estimate, vmax_estimate = quick_min_max(self._image_processors[i].data) + vmin_estimate, vmax_estimate = quick_min_max( + self._image_processors[i].data + ) # decide vmin, vmax passed to ImageGraphic constructor based on whether it's user specified or now if vmin_specified is None: @@ -429,7 +435,9 @@ def data(self, new_data: Sequence[ArrayProtocol | None]): # graphics will not be reset for this data index skip_indices = list() - for i, (new_data, image_processor) in enumerate(zip(new_data, self._image_processors)): + for i, (new_data, image_processor) in enumerate( + zip(new_data, self._image_processors) + ): if new_data is image_processor.data: skip_indices.append(i) continue @@ -488,7 +496,9 @@ def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[2, 3]): skip_indices = list() # first update image arrays - for i, (image_processor, new) in enumerate(zip(self._image_processors, new_ndd)): + for i, (image_processor, new) in enumerate( + zip(self._image_processors, new_ndd) + ): if new > image_processor.max_n_display_dims: raise IndexError( f"number of display dims exceeds maximum number of possible " @@ -524,7 +534,9 @@ def window_sizes(self) -> Iterable[tuple[int | None, ...] | None]: return self._window_sizes @window_sizes.setter - def window_sizes(self, new_sizes: Sequence[tuple[int | None, ...] | int | None] | int | None): + def window_sizes( + self, new_sizes: Sequence[tuple[int | None, ...] | int | None] | int | None + ): if isinstance(new_sizes, int) or new_sizes is None: # same window for all data arrays new_sizes = [new_sizes] * len(self._image_processors) @@ -570,7 +582,9 @@ def finalizer_func(self, funcs: Callable | Sequence[Callable] | None): def _set_image_processor_funcs(self, attr, new_values): """sets window_funcs, window_sizes, window_order, or finalizer_func and updates displayed data and histograms""" - for new, image_processor, subplot in zip(new_values, self._image_processors, self.figure): + for new, image_processor, subplot in zip( + new_values, self._image_processors, self.figure + ): if getattr(image_processor, attr) == new: continue @@ -644,7 +658,9 @@ def histogram_widget(self) -> bool: @histogram_widget.setter def histogram_widget(self, show_histogram: bool): if not isinstance(show_histogram, bool): - raise TypeError(f"`histogram_widget` can be set with a bool, you have passed: {show_histogram}") + raise TypeError( + f"`histogram_widget` can be set with a bool, you have passed: {show_histogram}" + ) for subplot, image_processor in zip(self.figure, self._image_processors): image_processor.compute_histogram = show_histogram @@ -674,7 +690,9 @@ def bounds(self) -> tuple[int, ...]: return bounds - def _get_image(self, image_processor: NDImageProcessor, indices: Sequence[int]) -> ArrayProtocol: + def _get_image( + self, image_processor: NDImageProcessor, indices: Sequence[int] + ) -> ArrayProtocol: """Get a processed 2d or 3d image from the NDImage at the given indices""" n = image_processor.n_slider_dims @@ -732,9 +750,7 @@ def _reset_image_graphics(self, subplot, image_processor): if image_processor.n_display_dims == 2: g = subplot.add_image( - data=new_image, - cmap=cmap, - name="image_widget_managed" + data=new_image, cmap=cmap, name="image_widget_managed" ) # set camera orthogonal to the xy plane, flip y axis @@ -745,7 +761,7 @@ def _reset_image_graphics(self, subplot, image_processor): "scale": [1, -1, 1], "reference_up": [0, 1, 0], "fov": 0, - "depth_range": None + "depth_range": None, } ) @@ -754,9 +770,7 @@ def _reset_image_graphics(self, subplot, image_processor): elif image_processor.n_display_dims == 3: g = subplot.add_image_volume( - data=new_image, - cmap=cmap, - name="image_widget_managed" + data=new_image, cmap=cmap, name="image_widget_managed" ) subplot.camera.fov = 50 subplot.controller = "orbit" @@ -814,7 +828,9 @@ def _reset(self, skip_data_indices: tuple[int, ...] = None): # reset the slider indices according to the new collection of dimensions self._reset_dimensions() # update graphics where display dims have changed accordings to indices - for i, (subplot, image_processor) in enumerate(zip(self.figure, self._image_processors)): + for i, (subplot, image_processor) in enumerate( + zip(self.figure, self._image_processors) + ): if i in skip_data_indices: continue From c0b870d849ec847bad7297b930d443e1fd9e8fcb Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 05:28:52 -0500 Subject: [PATCH 39/81] fixes and other things --- .../widgets/image_widget/_processor.py | 6 ++-- .../widgets/image_widget/_properties.py | 30 +++++++++++++++---- fastplotlib/widgets/image_widget/_sliders.py | 4 +-- fastplotlib/widgets/image_widget/_widget.py | 28 +++++++++++++---- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index 846b90da8..331f5eb1b 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -286,11 +286,11 @@ def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): f"integers or `None`, you passed: {_window_sizes}" ) - if w in (0, 1): + if w == 0 or w == 1: # this is not a real window, set as None w = None - if w % 2 == 0: + elif w % 2 == 0: # odd window sizes makes most sense warn( f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" @@ -299,7 +299,7 @@ def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): _window_sizes.append(w) - self._window_sizes = tuple(window_sizes) + self._window_sizes = tuple(_window_sizes) self._recompute_histogram() @property diff --git a/fastplotlib/widgets/image_widget/_properties.py b/fastplotlib/widgets/image_widget/_properties.py index c27923fc3..c794e4227 100644 --- a/fastplotlib/widgets/image_widget/_properties.py +++ b/fastplotlib/widgets/image_widget/_properties.py @@ -80,15 +80,33 @@ def __iter__(self): for i in self._data: yield i - def __getitem__(self, item) -> int | tuple[int]: - return self._data[item] - - def __setitem__(self, key, value): - if not isinstance(key, (int, np.integer, slice)): + def _parse_key(self, key: int | np.integer | str) -> int: + if not isinstance(key, (int, np.integer, str)): raise TypeError( - f"indices can only be indexed with types, you have used: {key}" + f"indices can only be indexed with or types, you have used: {key}" ) + if isinstance(key, str): + # get integer index from user's names + names = self._image_widget._slider_dim_names + if key not in names: + raise KeyError( + f"dim with name: {key} not found in slider_dim_names, current names are: {names}" + ) + + key = names.index(key) + + return key + + def __getitem__(self, key: int | np.integer | str) -> int | tuple[int]: + if isinstance(key, str): + key = self._parse_key(key) + + return self._data[key] + + def __setitem__(self, key, value): + key = self._parse_key(key) + if not isinstance(value, (int, np.integer)): raise TypeError( f"indices values can only be set with integers, you have tried to set the value: {value}" diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 9cd0fe5c5..1945b8cfb 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -161,7 +161,7 @@ def update(self): if dim < len(self._image_widget._slider_dim_names): dim_name = self._image_widget._slider_dim_names[dim] - imgui.text(f"dim {dim_name}: ") + imgui.text(f"dim '{dim_name}:' ") imgui.same_line() # so that slider occupies full width imgui.set_next_item_width(self.width * 0.85) @@ -175,7 +175,7 @@ def update(self): # slider for this dimension changed, index = imgui.slider_int( - f"{dim}", v=val, v_min=0, v_max=vmax, flags=flags + f"d: {dim}", v=val, v_min=0, v_max=vmax, flags=flags ) if changed: diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 1b6acf2a3..a2b09971d 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -255,6 +255,9 @@ def __init__( ) self._sliders_dim_order = sliders_dim_order + self._slider_dim_names = None + self.slider_dim_names = slider_dim_names + self._histogram_widget = histogram_widget # make NDImageArrays @@ -411,11 +414,6 @@ def __init__( self._indices_changed_handlers = set() - if slider_dim_names is not None: - self._slider_dim_names = tuple(slider_dim_names) - else: - self._slider_dim_names = None - self._reentrant_block = False @property @@ -690,6 +688,26 @@ def bounds(self) -> tuple[int, ...]: return bounds + @property + def slider_dim_names(self) -> tuple[str, ...]: + return self._slider_dim_names + + @slider_dim_names.setter + def slider_dim_names(self, names: Sequence[str]): + if names is None: + self._slider_dim_names = None + return + + if not all([isinstance(n, str) for n in names]): + raise TypeError(f"`slider_dim_names` must be set with a list/tuple of , you passed: {names}") + + if len(set(names)) != len(names): + raise ValueError( + f"`slider_dim_names` must be unique, you passed: {names}" + ) + + self._slider_dim_names = tuple(names) + def _get_image( self, image_processor: NDImageProcessor, indices: Sequence[int] ) -> ArrayProtocol: From 6d0d5dd1627ad84acbde3de46406dc056c9ae3a2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 05:50:30 -0500 Subject: [PATCH 40/81] typing tweaks --- .../widgets/image_widget/_properties.py | 5 ++- fastplotlib/widgets/image_widget/_widget.py | 39 ++++++++++--------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_properties.py b/fastplotlib/widgets/image_widget/_properties.py index c794e4227..060314439 100644 --- a/fastplotlib/widgets/image_widget/_properties.py +++ b/fastplotlib/widgets/image_widget/_properties.py @@ -1,11 +1,14 @@ from pprint import pformat +from typing import Iterable import numpy as np from ._processor import NDImageProcessor -class ImageProcessorProperty: +class ImageWidgetProperty: + __class_getitem__ = classmethod(type(list[int])) + def __init__( self, image_widget, diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index a2b09971d..92d23a1a4 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -1,4 +1,4 @@ -from typing import Callable, Sequence, Literal, Iterable +from typing import Callable, Sequence, Literal from warnings import warn import numpy as np @@ -11,7 +11,7 @@ from ...tools import HistogramLUTTool from ._sliders import ImageWidgetSliders from ._processor import NDImageProcessor, WindowFuncCallable -from ._properties import ImageProcessorProperty, Indices +from ._properties import ImageWidgetProperty, Indices IMGUI_SLIDER_HEIGHT = 49 @@ -277,13 +277,13 @@ def __init__( self._image_processors.append(image_processor) - self._data = ImageProcessorProperty(self, "data") - self._rgb = ImageProcessorProperty(self, "rgb") - self._n_display_dims = ImageProcessorProperty(self, "n_display_dims") - self._window_funcs = ImageProcessorProperty(self, "window_funcs") - self._window_sizes = ImageProcessorProperty(self, "window_sizes") - self._window_order = ImageProcessorProperty(self, "window_order") - self._finalizer_func = ImageProcessorProperty(self, "finalizer_func") + self._data = ImageWidgetProperty(self, "data") + self._rgb = ImageWidgetProperty(self, "rgb") + self._n_display_dims = ImageWidgetProperty(self, "n_display_dims") + self._window_funcs = ImageWidgetProperty(self, "window_funcs") + self._window_sizes = ImageWidgetProperty(self, "window_sizes") + self._window_order = ImageWidgetProperty(self, "window_order") + self._finalizer_func = ImageWidgetProperty(self, "finalizer_func") if len(set(n_display_dims)) > 1: # assume user wants one controller for 2D images and another for 3D image volumes @@ -417,7 +417,7 @@ def __init__( self._reentrant_block = False @property - def data(self) -> Iterable[ArrayProtocol | None]: + def data(self) -> ImageWidgetProperty[ArrayProtocol | None]: """get or set the nd-image data arrays""" return self._data @@ -445,7 +445,7 @@ def data(self, new_data: Sequence[ArrayProtocol | None]): self._reset(skip_indices) @property - def rgb(self) -> Iterable[bool]: + def rgb(self) -> ImageWidgetProperty[bool]: """get or set the rgb toggle for each data array""" return self._rgb @@ -471,7 +471,7 @@ def rgb(self, rgb: Sequence[bool]): self._reset(skip_indices) @property - def n_display_dims(self) -> Iterable[Literal[2, 3]]: + def n_display_dims(self) -> ImageWidgetProperty[Literal[2, 3]]: """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" return self._n_display_dims @@ -512,7 +512,7 @@ def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[2, 3]): self._reset(skip_indices) @property - def window_funcs(self) -> Iterable[tuple[WindowFuncCallable | None] | None]: + def window_funcs(self) -> ImageWidgetProperty[tuple[WindowFuncCallable | None] | None]: """get or set the window functions""" return self._window_funcs @@ -527,7 +527,7 @@ def window_funcs(self, new_funcs: Sequence[WindowFuncCallable | None] | None): self._set_image_processor_funcs("window_funcs", new_funcs) @property - def window_sizes(self) -> Iterable[tuple[int | None, ...] | None]: + def window_sizes(self) -> ImageWidgetProperty[tuple[int | None, ...] | None]: """get or set the window sizes""" return self._window_sizes @@ -545,7 +545,7 @@ def window_sizes( self._set_image_processor_funcs("window_sizes", new_sizes) @property - def window_order(self) -> Iterable[tuple[int, ...] | None]: + def window_order(self) -> ImageWidgetProperty[tuple[int, ...] | None]: """get or set order in which window functions are applied over dimensions""" return self._window_order @@ -564,7 +564,7 @@ def window_order(self, new_order: Sequence[tuple[int, ...]]): self._set_image_processor_funcs("window_order", new_order) @property - def finalizer_func(self) -> Iterable[Callable | None]: + def finalizer_func(self) -> ImageWidgetProperty[Callable | None]: """Get or set a finalizer function that operates on the spatial dimensions of the 2D or 3D image""" return self._finalizer_func @@ -597,13 +597,13 @@ def _set_image_processor_funcs(self, attr, new_values): self.indices = self.indices @property - def indices(self) -> Iterable[int]: + def indices(self) -> ImageWidgetProperty[int]: """ Get or set the current indices. Returns ------- - indices: Iterable[int] + indices: ImageWidgetProperty[int] integer index for each slider dimension """ @@ -785,6 +785,7 @@ def _reset_image_graphics(self, subplot, image_processor): subplot.controller = "panzoom" subplot.axes.intersection = None + subplot.auto_scale() elif image_processor.n_display_dims == 3: g = subplot.add_image_volume( @@ -799,7 +800,7 @@ def _reset_image_graphics(self, subplot, image_processor): if getattr(subplot.camera.local, f"scale_{dim}") < 0: setattr(subplot.camera.local, f"scale_{dim}", 1) - subplot.camera.show_object(g.world_object) + subplot.auto_scale() def _reset_histogram(self, subplot, image_processor): """reset the histogram""" From a46e3f55c86058b843277a9b763d441c5090606c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 06:33:25 -0500 Subject: [PATCH 41/81] better iterator, fix bugs --- fastplotlib/layouts/_figure.py | 8 ++------ fastplotlib/widgets/image_widget/_widget.py | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 74bd14129..59f93b15e 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -974,12 +974,8 @@ def __getitem__(self, index: str | int | tuple[int, int]) -> Subplot: ) def __iter__(self): - self._current_iter = iter(range(len(self))) - return self - - def __next__(self) -> Subplot: - pos = self._current_iter.__next__() - return self._subplots.ravel()[pos] + for subplot in self._subplots.ravel(): + yield subplot def __len__(self): """number of subplots""" diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 92d23a1a4..e07e0e175 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -840,6 +840,8 @@ def _reset_histogram(self, subplot, image_processor): subplot.docks["right"].add_graphic(hlut) subplot.docks["right"].size = 80 + self.reset_vmin_vmax() + def _reset(self, skip_data_indices: tuple[int, ...] = None): if skip_data_indices is None: skip_data_indices = tuple() @@ -935,11 +937,15 @@ def reset_vmin_vmax(self): """ Reset the vmin and vmax w.r.t. the full data """ - for data, subplot in zip(self.data, self.figure): + for image_processor, subplot in zip(self._image_processors, self.figure): if "histogram_lut" not in subplot.docks["right"]: continue + hlut = subplot.docks["right"]["histogram_lut"] - hlut.set_data(data, reset_vmin_vmax=True) + hlut.histogram = image_processor.histogram + + edges = image_processor.histogram[1] + hlut.vmin, hlut.vmax = edges[0], edges[-1] def reset_vmin_vmax_frame(self): """ @@ -955,7 +961,10 @@ def reset_vmin_vmax_frame(self): hlut = subplot.docks["right"]["histogram_lut"] # set the data using the current image graphic data - hlut.set_data(subplot["image_widget_managed"].data.value) + image = subplot["image_widget_managed"] + freqs, edges = np.histogram(image.data.value, bins=100) + hlut.histogram = (freqs, edges) + hlut.vmin, hlut.vmax = edges[0], edges[-1] def show(self, **kwargs): """ From d86093b70116e5e33a4e541670fe80f693a912bc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 06:44:06 -0500 Subject: [PATCH 42/81] fixes --- fastplotlib/widgets/image_widget/_processor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index 331f5eb1b..aebd27e2b 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -236,9 +236,9 @@ def _validate_window_func(self, funcs): f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {funcs}" ) - if not len(funcs) == self.n_slider_dims: + if not (len(funcs) == self.n_slider_dims or self.n_slider_dims == 0): raise IndexError( - f"number of `window_funcs` must be the same as the number of slider dims, " + f"number of `window_funcs` must be the same as the number of slider dims: {self.n_slider_dims}, " f"and you passed {len(funcs)} `window_funcs`: {funcs}" ) @@ -266,7 +266,7 @@ def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): f"`window_sizes` must be of type: tuple[int | None, ...] | int | None, you have passed: {window_sizes}" ) - if not len(window_sizes) == self.n_slider_dims: + if not (len(window_sizes) == self.n_slider_dims or self.n_slider_dims == 0): raise IndexError( f"number of `window_sizes` must be the same as the number of slider dims, " f"i.e. `data.ndim` - n_display_dims, your data array has {self.ndim} dimensions " From db0fcf9364d14e192295d66cb5b3b4527496fe44 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Nov 2025 06:44:18 -0500 Subject: [PATCH 43/81] show tooltips in right clck menu --- fastplotlib/ui/right_click_menus/_standard_menu.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py index bb9e5bdef..33ab509d1 100644 --- a/fastplotlib/ui/right_click_menus/_standard_menu.py +++ b/fastplotlib/ui/right_click_menus/_standard_menu.py @@ -100,6 +100,12 @@ def update(self): ) self.get_subplot().camera.maintain_aspect = maintain_aspect + change, show_tooltips = imgui.menu_item( + "Show tooltips", "", self._figure.show_tooltips + ) + if change: + self._figure.show_tooltips = show_tooltips + imgui.separator() # toggles to flip axes cameras From 263def9717e5d07795b3c1a69e62a98a510e55b2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Nov 2025 00:46:23 -0500 Subject: [PATCH 44/81] ignore nans and inf for histogram --- fastplotlib/widgets/image_widget/_processor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index aebd27e2b..df8fa7700 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -513,5 +513,6 @@ def _recompute_histogram(self): ignore_dims = None sub = subsample_array(self.data, ignore_dims=ignore_dims) + sub_real = sub[~(np.isnan(sub) | np.isinf(sub))] - self._histogram = np.histogram(sub, bins=100) + self._histogram = np.histogram(sub_real, bins=100) From cb26b3ad917d90c2f56e09853665801cb093dc50 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Nov 2025 00:58:42 -0500 Subject: [PATCH 45/81] histogram of zeros --- fastplotlib/tools/_histogram_lut.py | 30 +++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index c8c658be5..8da7a295c 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -26,9 +26,26 @@ class HistogramLUTTool(Graphic): def __init__( self, histogram: tuple[np.ndarray, np.ndarray], - images: Sequence[ImageGraphic | ImageVolumeGraphic] | None = None, + images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageGraphic | ImageVolumeGraphic] | None = None, **kwargs, ): + """ + A histogram tool that allows adjusting the vmin, vmax of images. + Also allows changing the cmap LUT for grayscale images and displays a colorbar. + + Parameters + ---------- + histogram: tuple[np.ndarray, np.ndarray] + [frequency, bin_edges], must be 100 bins + + images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageGraphic | ImageVolumeGraphic] + the images that are managed by the histogram tool + + kwargs: + passed to ``Graphic`` + + """ + super().__init__(**kwargs) if len(histogram) != 2: @@ -153,9 +170,14 @@ def histogram(self) -> tuple[np.ndarray, np.ndarray]: def histogram( self, histogram: tuple[np.ndarray, np.ndarray], limits: tuple[int, int] = None ): + """set histogram with pre-compuated [frequency, edges], must have exactly 100 bins""" + freq, edges = histogram - freq = freq / freq.max() + if freq.max() > 0: + # if the histogram is made from an empty array, then the max freq will be 0 + # we don't want to divide by 0 because then we just get nans + freq = freq / freq.max() bin_centers = 0.5 * (edges[1:] + edges[:-1]) @@ -188,7 +210,7 @@ def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic, ...] | None: return tuple(self._images) @images.setter - def images(self, new_images: tuple[ImageGraphic | ImageVolumeGraphic] | None): + def images(self, new_images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageGraphic | ImageVolumeGraphic] | None): self._disconnect_images() self._images.clear() @@ -213,7 +235,7 @@ def images(self, new_images: tuple[ImageGraphic | ImageVolumeGraphic] | None): else: self._colorbar.visible = False - self._images = new_images + self._images = list(new_images) # reset vmin, vmax using first image self.vmin = self._images[0].vmin From a1affbf0d6ec453838d8c3e246b8a04d5031176c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Nov 2025 01:10:08 -0500 Subject: [PATCH 46/81] docstrings --- fastplotlib/tools/_histogram_lut.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index 8da7a295c..36f840970 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -62,11 +62,13 @@ def __init__( [np.zeros(120, dtype=np.float32), np.arange(0, 120)] ) + # line that displays the histogram self._line = LineGraphic( line_data, colors=(0.8, 0.8, 0.8), alpha_mode="solid", offset=(1, 0, 0) ) self._line.world_object.local.scale_x = -1 + # vmin, vmax selector self._selector = LinearRegionSelector( selection=(10, 110), limits=(0, 119), @@ -82,9 +84,11 @@ def __init__( data=np.zeros([120, 2]), interpolation="linear", offset=(1.5, 0, 0) ) + # make the colorbar thin self._colorbar.world_object.local.scale_x = 0.15 self._colorbar.add_event_handler(self._open_cmap_picker, "click") + # colorbar ruler self._ruler = pygfx.Ruler( end_pos=(0, 119, 0), alpha_mode="solid", @@ -107,7 +111,8 @@ def __init__( outline_thickness=0.5, alpha_mode="solid", ) - # need to make sure text object doesn't conflict with selector tool + # this is to make sure clicking text doesn't conflict with the selector tool + # since the text appears near the selector tool self._text_vmin.world_object.material.pick_write = False self._text_vmax = TextGraphic( @@ -120,6 +125,7 @@ def __init__( ) self._text_vmax.world_object.material.pick_write = False + # add all the world objects to a pygfx.Group wo = pygfx.Group() wo.add( self._line.world_object, @@ -131,6 +137,7 @@ def __init__( ) self._set_world_object(wo) + # for convenience, a list that stores all the graphics managed by the histogram LUT tool self._children = [ self._line, self._selector, @@ -147,15 +154,21 @@ def __init__( def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area + for child in self._children: + # need all of them to call the add_plot_area_hook so that events are connected correctly + # example, the linear region selector needs all the canvas events to be connected child._fpl_add_plot_area_hook(plot_area) if hasattr(self._plot_area, "size"): # if it's in a dock area self._plot_area.size = 80 + # disable the controller in this plot area self._plot_area.controller.enabled = False self._plot_area.auto_scale(maintain_aspect=False) + + # tick text for colorbar ruler doesn't show without this call self._ruler.update(plot_area.camera, plot_area.canvas.get_logical_size()) def _ruler_tick_map(self, bin_index, *args): @@ -207,6 +220,7 @@ def histogram( @property def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic, ...] | None: + """get or set the managed images""" return tuple(self._images) @images.setter @@ -254,17 +268,20 @@ def images(self, new_images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageG ) def _disconnect_images(self, *args): + """disconnect event handlers of the managed images""" for image in self._images: for ev, handlers in image.event_handlers: if self._image_event_handler in handlers: image.remove_event_handler(self._image_event_handler, ev) def _image_event_handler(self, ev): + """when the image vmin, vmax, or cmap changes it will update the HistogramLUTTool""" new_value = ev.info["value"] setattr(self, ev.type, new_value) @property def cmap(self) -> str: + """get or set the colormap, only for grayscale images""" return self._colorbar.cmap @cmap.setter @@ -283,6 +300,10 @@ def cmap(self, name: str): *self._images, event_handlers=[self._image_event_handler] ): for image in self._images: + if image.cmap is None: + # rgb(a) images have no cmap + continue + image.cmap = name except Exception as exc: # raise original exception @@ -293,6 +314,7 @@ def cmap(self, name: str): @property def vmin(self) -> float: + """get or set the vmin, the lower contrast limit""" # no offset or rotation so we can directly use the world space selection value index = int(self._selector.selection[0]) return self._bin_centers_flanked[index] @@ -331,6 +353,7 @@ def vmin(self, value: float): @property def vmax(self) -> float: + """get or set the vmax, the upper contrast limit""" # no offset or rotation so we can directly use the world space selection value index = int(self._selector.selection[1]) return self._bin_centers_flanked[index] @@ -369,6 +392,7 @@ def vmax(self, value: float): self._block_reentrance = False def _selector_event_handler(self, ev: GraphicFeatureEvent): + """when the selector's selctor has changed, it will update the vmin, vmax, or both""" selection = ev.info["value"] index_min = int(selection[0]) vmin = self._bin_centers_flanked[index_min] @@ -385,6 +409,7 @@ def _selector_event_handler(self, ev: GraphicFeatureEvent): self.vmin, self.vmax = vmin, vmax def _open_cmap_picker(self, ev): + """open imgui cmap picker""" # check if right click if ev.button != 2: return @@ -394,6 +419,7 @@ def _open_cmap_picker(self, ev): self._plot_area.get_figure().open_popup("colormap-picker", pos, lut_tool=self) def _fpl_prepare_del(self): + """cleanup, need to disconnect events and remove image references for proper garbage collection""" self._disconnect_images() self._images.clear() From 81cd5bedf84ec3abb3e33bff441c186fa28789b0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Nov 2025 01:21:40 -0500 Subject: [PATCH 47/81] fix imgui pixels --- fastplotlib/widgets/image_widget/_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index e07e0e175..4b58bbe56 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -737,7 +737,7 @@ def _reset_dimensions(self): self._indices.push_dim() self._sliders_ui.push_dim() - self._sliders_ui.size = 55 + (IMGUI_SLIDER_HEIGHT * self.n_sliders) + self._sliders_ui.size = 57 + (IMGUI_SLIDER_HEIGHT * self.n_sliders) def _reset_image_graphics(self, subplot, image_processor): """delete and create a new image graphic if necessary""" From 9cf6b6e270ccee6cfde35a4a558e1ee6330435eb Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Nov 2025 01:57:38 -0500 Subject: [PATCH 48/81] iw indices event handlers only get a tuple of the indices --- fastplotlib/widgets/image_widget/_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 4b58bbe56..98abe7c84 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -639,7 +639,7 @@ def indices(self, new_indices: Sequence[int]): # call any event handlers for handler in self._indices_changed_handlers: - handler(self.indices) + handler(tuple(self.indices)) except Exception as exc: # raise original exception From 89f527520ed1156f2ed364d96981b70c60bd72b2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Nov 2025 16:16:44 -0500 Subject: [PATCH 49/81] bugfix --- fastplotlib/widgets/image_widget/_widget.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 98abe7c84..3edf816cd 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -941,6 +941,9 @@ def reset_vmin_vmax(self): if "histogram_lut" not in subplot.docks["right"]: continue + if image_processor.histogram is None: + continue + hlut = subplot.docks["right"]["histogram_lut"] hlut.histogram = image_processor.histogram @@ -955,10 +958,13 @@ def reset_vmin_vmax_frame(self): range of values in the full data array. """ - for subplot in self.figure: + for subplot, image_processor in zip(self.figure, self._image_processors): if "histogram_lut" not in subplot.docks["right"]: continue + if image_processor.histogram is None: + continue + hlut = subplot.docks["right"]["histogram_lut"] # set the data using the current image graphic data image = subplot["image_widget_managed"] From e1cc9b084766d447e6474dcb2f7eb78927878198 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Nov 2025 21:48:52 -0500 Subject: [PATCH 50/81] fix cmap setter --- fastplotlib/widgets/image_widget/_widget.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 3edf816cd..d592ecc9d 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -880,13 +880,21 @@ def graphics(self) -> list[ImageGraphic]: return tuple(iw_managed) @property - def cmap(self) -> tuple[str, ...]: + def cmap(self) -> tuple[str | None, ...]: """get the cmaps, or set the cmap across all images""" return tuple(g.cmap for g in self.graphics) @cmap.setter def cmap(self, name: str): for g in self.graphics: + if g is None: + # no data at this index + continue + + if g.cmap is None: + # if rgb + continue + g.cmap = name def add_event_handler(self, handler: callable, event: str = "indices"): From dc1dbd268b7b59f06e1b1b8aca9d5b57f943f290 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Nov 2025 05:50:29 -0500 Subject: [PATCH 51/81] spatial_func better name --- .../widgets/image_widget/_processor.py | 43 ++++++++++--------- fastplotlib/widgets/image_widget/_widget.py | 38 ++++++++-------- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index df8fa7700..d3524c4b3 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -21,7 +21,7 @@ def __init__( window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, window_sizes: tuple[int | None, ...] | int = None, window_order: tuple[int, ...] = None, - finalizer_func: Callable[[ArrayLike], ArrayLike] = None, + spatial_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, ): """ @@ -59,15 +59,16 @@ def __init__( order in which to apply the window functions, by default just applies it from the left-most dim to the right-most slider dim. - finalizer_func: Callable[[ArrayLike], ArrayLike] | None, optional - A function that the data is put through after the window functions (if present) before being displayed. + spatial_func: Callable[[ArrayLike], ArrayLike] | None, optional + A function that is applied on the _spatial_ dimensions of the data array, i.e. the last 2 or 3 dimensions. + This function is applied after the window functions (if present). compute_histogram: bool, default True - Compute a histogram of the data, auto re-computes if window function propties or finalizer_func changes. + Compute a histogram of the data, auto re-computes if window function propties or spatial_func changes. Disable if slow. """ - # set as False until data, window funcs stuff and finalizer func is all set + # set as False until data, window funcs stuff and spatial func is all set self._compute_histogram = False self.data = data @@ -78,7 +79,7 @@ def __init__( self.window_sizes = window_sizes self.window_order = window_order - self._finalizer_func = finalizer_func + self._spatial_func = spatial_func self._compute_histogram = compute_histogram self._recompute_histogram() @@ -329,18 +330,18 @@ def window_order(self, order: tuple[int] | None): self._recompute_histogram() @property - def finalizer_func(self) -> Callable[[ArrayLike], ArrayLike] | None: - """get or set a finalizer function, see docstring for details""" - return self._finalizer_func + def spatial_func(self) -> Callable[[ArrayLike], ArrayLike] | None: + """get or set a spatial_func function, see docstring for details""" + return self._spatial_func - @finalizer_func.setter - def finalizer_func(self, func: Callable[[ArrayLike], ArrayLike] | None): + @spatial_func.setter + def spatial_func(self, func: Callable[[ArrayLike], ArrayLike] | None): if not callable(func) or func is not None: raise TypeError( - f"`finalizer_func` must be a callable or `None`, you have passed: {func}" + f"`spatial_func` must be a callable or `None`, you have passed: {func}" ) - self._finalizer_func = func + self._spatial_func = func self._recompute_histogram() @property @@ -469,13 +470,13 @@ def get(self, indices: tuple[int, ...]) -> ArrayLike | None: # data is a static image or volume window_output = self.data - # apply finalizer func - if self.finalizer_func is not None: - final_output = self.finalizer_func(window_output) + # apply spatial_func + if self.spatial_func is not None: + final_output = self.spatial_func(window_output) if final_output.ndim != (self.n_display_dims + int(self.rgb)): raise IndexError( - f"Final output after of the `finalizer_func` must match the number of display dims." - f"Output after `finalizer_func` returned an array with {final_output.ndim} dims and " + f"Final output after of the `spatial_func` must match the number of display dims." + f"Output after `spatial_func` returned an array with {final_output.ndim} dims and " f"of shape: {final_output.shape}, expected {self.n_display_dims} dims" ) else: @@ -503,9 +504,9 @@ def _recompute_histogram(self): self._histogram = None return - if self.finalizer_func is not None: - # don't subsample spatial dims if a finalizer function is used - # finalizer functions often operate on the spatial dims, ex: a gaussian kernel + if self.spatial_func is not None: + # don't subsample spatial dims if a spatial function is used + # spatial functions often operate on the spatial dims, ex: a gaussian kernel # so their results require the full spatial resolution, the histogram of a # spatially subsampled image will be very different ignore_dims = self.display_dims diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index d592ecc9d..20e11574d 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -38,7 +38,7 @@ def __init__( tuple[int | None, ...] | Sequence[tuple[int | None, ...] | None] ) = None, window_order: tuple[int, ...] | Sequence[tuple[int, ...] | None] = None, - finalizer_func: ( + spatial_func: ( Callable[[ArrayProtocol], ArrayProtocol] | Sequence[Callable[[ArrayProtocol], ArrayProtocol]] | None @@ -220,19 +220,19 @@ def __init__( else: win_order = window_order - # verify finalizer function - if finalizer_func is None: - final_funcs = [None] * len(data) + # verify spatial_func + if spatial_func is None: + spatial_func = [None] * len(data) - elif callable(finalizer_func): - # same finalizer func for all data arrays - final_funcs = [finalizer_func] * len(data) + elif callable(spatial_func): + # same spatial_func for all data arrays + spatial_func = [spatial_func] * len(data) - elif len(finalizer_func) != len(data): + elif len(spatial_func) != len(data): raise IndexError else: - final_funcs = finalizer_func + spatial_func = spatial_func # verify number of display dims if isinstance(n_display_dims, (int, np.integer)): @@ -271,7 +271,7 @@ def __init__( window_funcs=win_funcs[i], window_sizes=win_sizes[i], window_order=win_order[i], - finalizer_func=final_funcs[i], + spatial_func=spatial_func[i], compute_histogram=self._histogram_widget, ) @@ -283,7 +283,7 @@ def __init__( self._window_funcs = ImageWidgetProperty(self, "window_funcs") self._window_sizes = ImageWidgetProperty(self, "window_sizes") self._window_order = ImageWidgetProperty(self, "window_order") - self._finalizer_func = ImageWidgetProperty(self, "finalizer_func") + self._spatial_func = ImageWidgetProperty(self, "spatial_func") if len(set(n_display_dims)) > 1: # assume user wants one controller for 2D images and another for 3D image volumes @@ -564,22 +564,22 @@ def window_order(self, new_order: Sequence[tuple[int, ...]]): self._set_image_processor_funcs("window_order", new_order) @property - def finalizer_func(self) -> ImageWidgetProperty[Callable | None]: - """Get or set a finalizer function that operates on the spatial dimensions of the 2D or 3D image""" - return self._finalizer_func + def spatial_func(self) -> ImageWidgetProperty[Callable | None]: + """Get or set a spatial_func that operates on the spatial dimensions of the 2D or 3D image""" + return self._spatial_func - @finalizer_func.setter - def finalizer_func(self, funcs: Callable | Sequence[Callable] | None): + @spatial_func.setter + def spatial_func(self, funcs: Callable | Sequence[Callable] | None): if callable(funcs) or funcs is None: funcs = [funcs] * len(self._image_processors) if len(funcs) != len(self._image_processors): raise IndexError - self._set_image_processor_funcs("finalizer_func", funcs) + self._set_image_processor_funcs("spatial_func", funcs) def _set_image_processor_funcs(self, attr, new_values): - """sets window_funcs, window_sizes, window_order, or finalizer_func and updates displayed data and histograms""" + """sets window_funcs, window_sizes, window_order, or spatial_func and updates displayed data and histograms""" for new, image_processor, subplot in zip( new_values, self._image_processors, self.figure ): @@ -588,7 +588,7 @@ def _set_image_processor_funcs(self, attr, new_values): setattr(image_processor, attr, new) - # window functions and finalizer functions will only change the histogram + # window functions and spatial functions will only change the histogram # they do not change the collections of dimensions, so we don't need to call _reset_dimensions # they also do not change the image graphic, so we do not need to call _reset_image_graphics self._reset_histogram(subplot, image_processor) From 219ea3cc2c45b5dce8ecdfbdb2b39fe2afaa7e82 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Nov 2025 17:17:11 -0500 Subject: [PATCH 52/81] bugfix --- fastplotlib/widgets/image_widget/_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py index d3524c4b3..0dce84a5e 100644 --- a/fastplotlib/widgets/image_widget/_processor.py +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -336,7 +336,7 @@ def spatial_func(self) -> Callable[[ArrayLike], ArrayLike] | None: @spatial_func.setter def spatial_func(self, func: Callable[[ArrayLike], ArrayLike] | None): - if not callable(func) or func is not None: + if not (callable(func) or func is not None): raise TypeError( f"`spatial_func` must be a callable or `None`, you have passed: {func}" ) From 08ee98f13bb6eb93c8dbfe10ad54f12e7bc95cc1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 8 Dec 2025 18:05:16 -0500 Subject: [PATCH 53/81] hist specify quantile --- fastplotlib/widgets/image_widget/_widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 20e11574d..7db265c0c 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -48,6 +48,7 @@ def __init__( names: Sequence[str] = None, figure_kwargs: dict = None, histogram_widget: bool = True, + histogram_init_quantile: int = (0, 100), graphic_kwargs: dict | Sequence[dict] = None, ): """ @@ -956,6 +957,7 @@ def reset_vmin_vmax(self): hlut.histogram = image_processor.histogram edges = image_processor.histogram[1] + hlut.vmin, hlut.vmax = edges[0], edges[-1] def reset_vmin_vmax_frame(self): From 9a01cd5ee7fda8e4dd923670f0466d1233bc6de0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 25 Dec 2025 01:04:06 -0800 Subject: [PATCH 54/81] start ndprocessors --- fastplotlib/utils/__init__.py | 1 + fastplotlib/utils/_protocols.py | 12 ++ fastplotlib/widgets/nd_widget/_processor.py | 141 ++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 fastplotlib/utils/_protocols.py create mode 100644 fastplotlib/widgets/nd_widget/_processor.py diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index dd527ca67..8001ae375 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -6,6 +6,7 @@ from .gpu import enumerate_adapters, select_adapter, print_wgpu_report from ._plot_helpers import * from .enums import * +from ._protocols import ArrayProtocol @dataclass diff --git a/fastplotlib/utils/_protocols.py b/fastplotlib/utils/_protocols.py new file mode 100644 index 000000000..c168ecfa4 --- /dev/null +++ b/fastplotlib/utils/_protocols.py @@ -0,0 +1,12 @@ +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ArrayProtocol(Protocol): + @property + def ndim(self) -> int: ... + + @property + def shape(self) -> tuple[int, ...]: ... + + def __getitem__(self, key): ... diff --git a/fastplotlib/widgets/nd_widget/_processor.py b/fastplotlib/widgets/nd_widget/_processor.py new file mode 100644 index 000000000..9e5299118 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_processor.py @@ -0,0 +1,141 @@ +import inspect +from typing import Literal, Callable, Any +from warnings import warn + +import numpy as np +from numpy.typing import ArrayLike + +from ...utils import subsample_array, ArrayProtocol + + +# must take arguments: array-like, `axis`: int, `keepdims`: bool +WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] + + +class NDProcessor: + def __init__( + self, + data: ArrayProtocol, + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + ): + self._data = self._validate_data(data) + self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) + + @property + def data(self) -> ArrayProtocol: + return self._data + + @data.setter + def data(self, data: ArrayProtocol): + self._data = self._validate_data(data) + + def _validate_data(self, data: ArrayProtocol): + if not isinstance(data, ArrayProtocol): + raise TypeError("`data` must implement the ArrayProtocol") + + return data + + @property + def window_funcs(self) -> tuple[WindowFuncCallable | None] | None: + pass + + @property + def window_sizes(self) -> tuple[int | None] | None: + pass + + @property + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + pass + + @property + def slider_dims(self) -> tuple[int, ...] | None: + pass + + @property + def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: + return self._slider_index_maps + + @slider_index_maps.setter + def slider_index_maps(self, maps): + self._maps = self._validate_slider_index_maps(maps) + + def _validate_slider_index_maps(self, maps): + if maps is not None: + if not all([callable(m) or m is None for m in maps]): + raise TypeError + + return maps + + def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: + pass + + +class NDImageProcessor(NDProcessor): + @property + def n_display_dims(self) -> Literal[2, 3]: + pass + + def _validate_n_display_dims(self, n_display_dims): + if n_display_dims not in (2, 3): + raise ValueError("`n_display_dims` must be") + + +class NDTimeSeriesProcessor(NDProcessor): + def __init__( + self, + data: ArrayProtocol, + graphic: Literal["line", "heatmap"] = "line", + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + display_window: int | float | None = None, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + ): + super().__init__( + data=data, + n_display_dims=n_display_dims, + slider_index_maps=slider_index_maps, + ) + + self._display_window = display_window + + def _validate_data(self, data: ArrayProtocol): + data = super()._validate_data(data) + + # need to make shape be [n_lines, n_datapoints, 2] + # this will work for displaying a linestack and heatmap + # for heatmap just slice: [..., 1] + # TODO: Think about how to allow n-dimensional lines, + # maybe [d1, d2, ..., d(n - 1), n_lines, n_datapoint, 2] + # and dn is the x-axis values?? + if data.ndim == 1: + pass + + @property + def display_window(self) -> int | float | None: + """display window in the reference units along the x-axis""" + return self._display_window + + def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: + if self.display_window is not None: + # map reference units -> array int indices if necessary + if self.slider_index_maps is not None: + indices_window = self.slider_index_maps(self.display_window) + else: + indices_window = self.display_window + + # half window size + hw = indices_window // 2 + + # for now assume just a single index provided that indicates x axis value + start = max(indices - hw, 0) + stop = indices + hw + + # slice dim would be ndim - 1 + + return self.data[start:stop] From c46455ff71e460772148bf629dd906beffaf3cca Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 27 Dec 2025 03:52:32 -0800 Subject: [PATCH 55/81] basic timeseries --- fastplotlib/widgets/nd_widget/_processor.py | 187 +++++++++++++++++--- 1 file changed, 159 insertions(+), 28 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_processor.py b/fastplotlib/widgets/nd_widget/_processor.py index 9e5299118..d0a8e66ab 100644 --- a/fastplotlib/widgets/nd_widget/_processor.py +++ b/fastplotlib/widgets/nd_widget/_processor.py @@ -5,6 +5,7 @@ import numpy as np from numpy.typing import ArrayLike +from ...graphics import ImageGraphic, LineStack, LineCollection, ScatterGraphic from ...utils import subsample_array, ArrayProtocol @@ -14,13 +15,13 @@ class NDProcessor: def __init__( - self, - data: ArrayProtocol, - n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + self, + data, + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): self._data = self._validate_data(data) self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) @@ -84,17 +85,30 @@ def _validate_n_display_dims(self, n_display_dims): raise ValueError("`n_display_dims` must be") +VALID_TIMESERIES_Y_DATA_SHAPES = ( + "[n_datapoints] for 1D array of y-values, [n_datapoints, 2] " + "for a 1D array of y and z-values, [n_lines, n_datapoints] for a 2D stack of lines with y-values, " + "or [n_lines, n_datapoints, 2] for a stack of lines with y and z-values." +) + + +# Limitation, no heatmap if z-values present, I don't think you can visualize that class NDTimeSeriesProcessor(NDProcessor): def __init__( - self, - data: ArrayProtocol, - graphic: Literal["line", "heatmap"] = "line", - n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, - display_window: int | float | None = None, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + self, + data: list[ + ArrayProtocol, ArrayProtocol + ], # list: [x_vals_array, y_vals_and_z_vals_array] + x_values: ArrayProtocol = None, + cmap: str = None, + cmap_transform: ArrayProtocol = None, + display_graphic: Literal["line", "heatmap"] = "line", + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + display_window: int | float | None = 100, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): super().__init__( data=data, @@ -104,23 +118,73 @@ def __init__( self._display_window = display_window - def _validate_data(self, data: ArrayProtocol): - data = super()._validate_data(data) + self._display_graphic = None + self.display_graphic = display_graphic - # need to make shape be [n_lines, n_datapoints, 2] - # this will work for displaying a linestack and heatmap - # for heatmap just slice: [..., 1] - # TODO: Think about how to allow n-dimensional lines, - # maybe [d1, d2, ..., d(n - 1), n_lines, n_datapoint, 2] - # and dn is the x-axis values?? - if data.ndim == 1: - pass + self._uniform_x_values: ArrayProtocol | None = None + self._interp_yz: ArrayProtocol | None = None + + @property + def data(self) -> list[ArrayProtocol, ArrayProtocol]: + return self._data + + @data.setter + def data(self, data: list[ArrayProtocol, ArrayProtocol]): + self._data = self._validate_data(data) + + def _validate_data(self, data: list[ArrayProtocol, ArrayProtocol]): + x_vals, yz_vals = data + + if x_vals.ndim != 1: + raise ("data x values must be 1D") + + if data[1].ndim > 3: + raise ValueError( + f"data yz values must be of shape: {VALID_TIMESERIES_Y_DATA_SHAPES}. You passed data of shape: {yz_vals.shape}" + ) + + return data + + @property + def display_graphic(self) -> Literal["line", "heatmap"]: + return self._display_graphic + + @display_graphic.setter + def display_graphic(self, dg: Literal["line", "heatmap"]): + dg = self._validate_display_graphic(dg) + + if dg == "heatmap": + # check if x-vals uniformly spaced + norm = np.linalg.norm(np.diff(np.diff(self.x_values))) / len(self.x_values) + if norm > 10 ** -12: + # need to create evenly spaced x-values + x0 = self.data[0][0] + xn = self.data[0][-1] + self._uniform_x_values = np.linspace(x0, xn, num=len(self.data[0])) + + # TODO: interpolate yz values on the fly only when within the display window + + def _validate_display_graphic(self, dg): + if dg not in ("line", "heatmap"): + raise ValueError + + return dg @property def display_window(self) -> int | float | None: """display window in the reference units along the x-axis""" return self._display_window + @display_window.setter + def display_window(self, dw: int | float | None): + if dw is None: + self._display_window = None + + elif not isinstance(dw, (int, float)): + raise TypeError + + self._display_window = dw + def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: if self.display_window is not None: # map reference units -> array int indices if necessary @@ -134,8 +198,75 @@ def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: # for now assume just a single index provided that indicates x axis value start = max(indices - hw, 0) - stop = indices + hw + stop = start + indices_window # slice dim would be ndim - 1 + return self.data[0][start:stop], self.data[1][:, start:stop] + + +class NDTimeSeries: + def __init__(self, processor: NDTimeSeriesProcessor, display_graphic): + self._processor = processor + + self._indices = 0 + + if display_graphic == "line": + self._create_line_stack() + + @property + def processor(self) -> NDTimeSeriesProcessor: + return self._processor + + @property + def graphic(self) -> LineStack | ImageGraphic: + """LineStack or ImageGraphic for heatmaps""" + return self._graphic + + @property + def display_window(self) -> int | float | None: + return self.processor.display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + # create new graphic if it changed + if dw != self.display_window: + create_new_graphic = True + else: + create_new_graphic = False + + self.processor.display_window = dw + + if create_new_graphic: + if isinstance(self.graphic, LineStack): + self.set_index(self._indices) + + def set_index(self, indices: tuple[Any, ...]): + # set the graphic at the given data indices + data_slice = self.processor[indices] + + if isinstance(self.graphic, LineStack): + line_stack_data = self._create_line_stack_data(data_slice) + + for g, line_data in zip(self.graphic.graphics, line_stack_data): + if line_data.shape[1] == 2: + # only x and y values + g.data[:, :-1] = line_data + else: + # has z values too + g.data[:] = line_data + + self._indices = indices + + def _create_line_stack_data(self, data_slice): + xs = data_slice[0] # 1D + yz = data_slice[1] # [n_lines, n_datapoints] for y-vals or [n_lines, n_datapoints, 2] for yz-vals + + # need to go from x_vals and yz_vals arrays to an array of shape: [n_lines, n_datapoints, 2 | 3] + return np.dstack([np.repeat(xs[None], repeats=yz.shape[0], axis=0), yz]) + + def _create_line_stack(self): + data_slice = self.processor[self._indices] + + ls_data = self._create_line_stack_data(data_slice) - return self.data[start:stop] + self._graphic = LineStack(ls_data) From d93fa5d5fdc685b8d7f2b7bc38a95abb100f31da Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 27 Dec 2025 03:52:52 -0800 Subject: [PATCH 56/81] add __init__ --- fastplotlib/widgets/nd_widget/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/__init__.py diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py new file mode 100644 index 000000000..e69de29bb From fddefb826f44f443c2504557c6d5e76b2e50c05f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 27 Dec 2025 17:46:54 -0800 Subject: [PATCH 57/81] heatmap for timeseries works! --- fastplotlib/widgets/nd_widget/_processor.py | 55 ++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_processor.py b/fastplotlib/widgets/nd_widget/_processor.py index d0a8e66ab..0add36594 100644 --- a/fastplotlib/widgets/nd_widget/_processor.py +++ b/fastplotlib/widgets/nd_widget/_processor.py @@ -155,6 +155,7 @@ def display_graphic(self, dg: Literal["line", "heatmap"]): if dg == "heatmap": # check if x-vals uniformly spaced + # this is very fast to do on the fly, especially for typical small display windows norm = np.linalg.norm(np.diff(np.diff(self.x_values))) / len(self.x_values) if norm > 10 ** -12: # need to create evenly spaced x-values @@ -205,13 +206,17 @@ def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: class NDTimeSeries: - def __init__(self, processor: NDTimeSeriesProcessor, display_graphic): + def __init__(self, processor: NDTimeSeriesProcessor, graphic): self._processor = processor self._indices = 0 - if display_graphic == "line": + if graphic == "line": self._create_line_stack() + elif graphic == "heatmap": + self._create_heatmap() + else: + raise ValueError @property def processor(self) -> NDTimeSeriesProcessor: @@ -222,6 +227,19 @@ def graphic(self) -> LineStack | ImageGraphic: """LineStack or ImageGraphic for heatmaps""" return self._graphic + @graphic.setter + def graphic(self, g: Literal["line", "heatmap"]): + if g == "line": + # TODO: remove existing graphic + self._create_line_stack() + + elif g == "heatmap": + # make sure "yz" data is only ys and no z values + # can't represent y and z vals in a heatmap + if self.processor.data[1].ndim > 2: + raise ValueError("Only y-values are supported for heatmaps, not yz-values") + self._create_heatmap() + @property def display_window(self) -> int | float | None: return self.processor.display_window @@ -255,6 +273,10 @@ def set_index(self, indices: tuple[Any, ...]): # has z values too g.data[:] = line_data + elif isinstance(self.graphic, ImageGraphic): + hm_data, scale = self._create_heatmap_data(data_slice) + self.graphic.data = hm_data + self._indices = indices def _create_line_stack_data(self, data_slice): @@ -270,3 +292,32 @@ def _create_line_stack(self): ls_data = self._create_line_stack_data(data_slice) self._graphic = LineStack(ls_data) + + def _create_heatmap_data(self, data_slice) -> tuple[ArrayProtocol, float]: + """Returns [n_lines, y_values] array and scale factor for x dimension""" + # check if x-vals uniformly spaced + # this is very fast to do on the fly, especially for typical small display windows + x, y = data_slice + norm = np.linalg.norm(np.diff(np.diff(x))) / x.size + if norm > 10 ** -12: + # need to create evenly spaced x-values + x_uniform = np.linspace(x[0], x[-1], num=x.size) + # yz is [n_lines, n_datapoints] + y_interp = np.zeros(shape=y.shape, dtype=np.float32) + for i in range(y.shape[0]): + y_interp[i] = np.interp(x_uniform, x, y[i]) + + else: + y_interp = y + + x_scale = x[-1] / x.size + + return y_interp, x_scale + + def _create_heatmap(self): + data_slice = self.processor[self._indices] + + hm_data, x_scale = self._create_heatmap_data(data_slice) + + self._graphic = ImageGraphic(hm_data) + self._graphic.world_object.world.scale_x = x_scale \ No newline at end of file From d5e4c7d45901b1f5f2de89e68ef4d416d0ea7dde Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 29 Dec 2025 01:44:11 -0800 Subject: [PATCH 58/81] NDPositions, basics work, reorganize, increase default scatter size --- fastplotlib/graphics/scatter.py | 2 +- fastplotlib/widgets/nd_widget/_nd_image.py | 13 ++ .../widgets/nd_widget/_nd_positions.py | 137 ++++++++++++++++++ .../{_processor.py => _nd_timeseries.py} | 104 +------------ .../widgets/nd_widget/_processor_base.py | 74 ++++++++++ 5 files changed, 227 insertions(+), 103 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/_nd_image.py create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions.py rename fastplotlib/widgets/nd_widget/{_processor.py => _nd_timeseries.py} (70%) create mode 100644 fastplotlib/widgets/nd_widget/_processor_base.py diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index a2e696a82..5268dcc51 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -53,7 +53,7 @@ def __init__( image: np.ndarray = None, point_rotations: float | np.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", - sizes: float | np.ndarray | Sequence[float] = 1, + sizes: float | np.ndarray | Sequence[float] = 5, uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py new file mode 100644 index 000000000..f115e146e --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -0,0 +1,13 @@ +from typing import Literal + +from ._processor_base import NDProcessor + + +class NDImageProcessor(NDProcessor): + @property + def n_display_dims(self) -> Literal[2, 3]: + pass + + def _validate_n_display_dims(self, n_display_dims): + if n_display_dims not in (2, 3): + raise ValueError("`n_display_dims` must be") diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py new file mode 100644 index 000000000..db8c80e72 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -0,0 +1,137 @@ +import inspect +from typing import Literal, Callable, Any, Type +from warnings import warn + +import numpy as np +from numpy.typing import ArrayLike + +from ...utils import subsample_array, ArrayProtocol + +from ...graphics import ImageGraphic, LineGraphic, LineStack, LineCollection, ScatterGraphic +from ._processor_base import NDProcessor + +# TODO: Maybe get rid of n_display_dims in NDProcessor, +# we will know the display dims automatically here from the last dim +# so maybe we only need it for images? +class NDPositionsProcessor(NDProcessor): + def __init__( + self, + data: ArrayProtocol, + multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points + display_window: int | float | None = 100, # window for n_datapoints dim only + ): + super().__init__(data=data) + + self._display_window = display_window + + self.multi = multi + + def _validate_data(self, data: ArrayProtocol): + # TODO: determine right validation shape etc. + return data + + @property + def display_window(self) -> int | float | None: + """display window in the reference units for the n_datapoints dim""" + return self._display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + if dw is None: + self._display_window = None + + elif not isinstance(dw, (int, float)): + raise TypeError + + self._display_window = dw + + @property + def multi(self) -> bool: + return self._multi + + @multi.setter + def multi(self, m: bool): + if m and self.data.ndim < 3: + # p is p-datapoints, n is how many lines/scatter to show simultaneously + raise ValueError("ndim must be >= 3 for multi, shape must be [s1..., sn, n, p, 2 | 3]") + + self._multi = m + + def __getitem__(self, indices: tuple[Any, ...]): + """sliders through all slider dims and outputs an array that can be used to set graphic data""" + if self.display_window is not None: + indices_window = self.display_window + + # half window size + hw = indices_window // 2 + + # for now assume just a single index provided that indicates x axis value + start = max(indices - hw, 0) + stop = start + indices_window + + slices = [slice(start, stop)] + + # TODO: implement slicing for multiple slider dims, i.e. [s1, s2, ... n_datapoints, 2 | 3] + # this currently assumes the shape is: [n_datapoints, 2 | 3] + if self.multi: + # n - 2 dim is n_lines or n_scatters + slices.insert(0, slice(None)) + + return self.data[tuple(slices)] + + +class NDPositions: + def __init__(self, data, graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], multi: bool = False): + self._indices = 0 + + if issubclass(graphic, LineCollection): + multi = True + + self._processor = NDPositionsProcessor(data, multi=multi) + + self._create_graphic(graphic) + + @property + def processor(self) -> NDPositionsProcessor: + return self._processor + + @property + def graphic(self) -> LineGraphic | LineCollection | LineStack | ScatterGraphic | list[ScatterGraphic]: + """LineStack or ImageGraphic for heatmaps""" + return self._graphic + + @property + def indices(self) -> tuple: + return self._indices + + @indices.setter + def indices(self, indices): + data_slice = self.processor[indices] + + if isinstance(self.graphic, list): + # list of scatter + for i in range(len(self.graphic)): + # data_slice shape is [n_scatters, n_datapoints, 2 | 3] + # by using data_slice.shape[-1] it will auto-select if the data is only xy or has xyz + self.graphic[i].data[:, :data_slice.shape[-1]] = data_slice[i] + + elif isinstance(self.graphic, (LineGraphic, ScatterGraphic)): + self.graphic.data[:, :data_slice.shape[-1]] = data_slice + + elif isinstance(self.graphic, LineCollection): + for i in range(len(self.graphic)): + # data_slice shape is [n_lines, n_datapoints, 2 | 3] + self.graphic[i].data[:, :data_slice.shape[-1]] = data_slice[i] + + def _create_graphic(self, graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic]): + if self.processor.multi and issubclass(graphic_cls, ScatterGraphic): + # make list of scatters + self._graphic = list() + data_slice = self.processor[self.indices] + for d in data_slice: + scatter = graphic_cls(d) + self._graphic.append(scatter) + + else: + data_slice = self.processor[self.indices] + self._graphic = graphic_cls(data_slice) diff --git a/fastplotlib/widgets/nd_widget/_processor.py b/fastplotlib/widgets/nd_widget/_nd_timeseries.py similarity index 70% rename from fastplotlib/widgets/nd_widget/_processor.py rename to fastplotlib/widgets/nd_widget/_nd_timeseries.py index 0add36594..8630044cf 100644 --- a/fastplotlib/widgets/nd_widget/_processor.py +++ b/fastplotlib/widgets/nd_widget/_nd_timeseries.py @@ -5,84 +5,10 @@ import numpy as np from numpy.typing import ArrayLike -from ...graphics import ImageGraphic, LineStack, LineCollection, ScatterGraphic from ...utils import subsample_array, ArrayProtocol - -# must take arguments: array-like, `axis`: int, `keepdims`: bool -WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] - - -class NDProcessor: - def __init__( - self, - data, - n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, - ): - self._data = self._validate_data(data) - self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) - - @property - def data(self) -> ArrayProtocol: - return self._data - - @data.setter - def data(self, data: ArrayProtocol): - self._data = self._validate_data(data) - - def _validate_data(self, data: ArrayProtocol): - if not isinstance(data, ArrayProtocol): - raise TypeError("`data` must implement the ArrayProtocol") - - return data - - @property - def window_funcs(self) -> tuple[WindowFuncCallable | None] | None: - pass - - @property - def window_sizes(self) -> tuple[int | None] | None: - pass - - @property - def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: - pass - - @property - def slider_dims(self) -> tuple[int, ...] | None: - pass - - @property - def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: - return self._slider_index_maps - - @slider_index_maps.setter - def slider_index_maps(self, maps): - self._maps = self._validate_slider_index_maps(maps) - - def _validate_slider_index_maps(self, maps): - if maps is not None: - if not all([callable(m) or m is None for m in maps]): - raise TypeError - - return maps - - def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: - pass - - -class NDImageProcessor(NDProcessor): - @property - def n_display_dims(self) -> Literal[2, 3]: - pass - - def _validate_n_display_dims(self, n_display_dims): - if n_display_dims not in (2, 3): - raise ValueError("`n_display_dims` must be") +from ...graphics import ImageGraphic, LineStack, LineCollection, ScatterGraphic +from ._processor_base import NDProcessor, WindowFuncCallable VALID_TIMESERIES_Y_DATA_SHAPES = ( @@ -145,32 +71,6 @@ def _validate_data(self, data: list[ArrayProtocol, ArrayProtocol]): return data - @property - def display_graphic(self) -> Literal["line", "heatmap"]: - return self._display_graphic - - @display_graphic.setter - def display_graphic(self, dg: Literal["line", "heatmap"]): - dg = self._validate_display_graphic(dg) - - if dg == "heatmap": - # check if x-vals uniformly spaced - # this is very fast to do on the fly, especially for typical small display windows - norm = np.linalg.norm(np.diff(np.diff(self.x_values))) / len(self.x_values) - if norm > 10 ** -12: - # need to create evenly spaced x-values - x0 = self.data[0][0] - xn = self.data[0][-1] - self._uniform_x_values = np.linspace(x0, xn, num=len(self.data[0])) - - # TODO: interpolate yz values on the fly only when within the display window - - def _validate_display_graphic(self, dg): - if dg not in ("line", "heatmap"): - raise ValueError - - return dg - @property def display_window(self) -> int | float | None: """display window in the reference units along the x-axis""" diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/_processor_base.py new file mode 100644 index 000000000..fa56e4b52 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_processor_base.py @@ -0,0 +1,74 @@ +import inspect +from typing import Literal, Callable, Any +from warnings import warn + +import numpy as np +from numpy.typing import ArrayLike + +from ...utils import subsample_array, ArrayProtocol + + +# must take arguments: array-like, `axis`: int, `keepdims`: bool +WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] + + +class NDProcessor: + def __init__( + self, + data, + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + ): + self._data = self._validate_data(data) + self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) + + @property + def data(self) -> ArrayProtocol: + return self._data + + @data.setter + def data(self, data: ArrayProtocol): + self._data = self._validate_data(data) + + def _validate_data(self, data: ArrayProtocol): + if not isinstance(data, ArrayProtocol): + raise TypeError("`data` must implement the ArrayProtocol") + + return data + + @property + def window_funcs(self) -> tuple[WindowFuncCallable | None] | None: + pass + + @property + def window_sizes(self) -> tuple[int | None] | None: + pass + + @property + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + pass + + @property + def slider_dims(self) -> tuple[int, ...] | None: + pass + + @property + def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: + return self._slider_index_maps + + @slider_index_maps.setter + def slider_index_maps(self, maps): + self._maps = self._validate_slider_index_maps(maps) + + def _validate_slider_index_maps(self, maps): + if maps is not None: + if not all([callable(m) or m is None for m in maps]): + raise TypeError + + return maps + + def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: + pass From 074669b084068784bed7a5f54e6cfe014ea0abf5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 25 Jan 2026 23:58:14 -0500 Subject: [PATCH 59/81] black --- .../widgets/nd_widget/_nd_positions.py | 47 ++++++++++++++----- .../widgets/nd_widget/_nd_timeseries.py | 16 ++++--- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index db8c80e72..10215d351 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -7,18 +7,25 @@ from ...utils import subsample_array, ArrayProtocol -from ...graphics import ImageGraphic, LineGraphic, LineStack, LineCollection, ScatterGraphic +from ...graphics import ( + ImageGraphic, + LineGraphic, + LineStack, + LineCollection, + ScatterGraphic, +) from ._processor_base import NDProcessor + # TODO: Maybe get rid of n_display_dims in NDProcessor, # we will know the display dims automatically here from the last dim # so maybe we only need it for images? class NDPositionsProcessor(NDProcessor): def __init__( - self, - data: ArrayProtocol, - multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points - display_window: int | float | None = 100, # window for n_datapoints dim only + self, + data: ArrayProtocol, + multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points + display_window: int | float | None = 100, # window for n_datapoints dim only ): super().__init__(data=data) @@ -52,8 +59,10 @@ def multi(self) -> bool: @multi.setter def multi(self, m: bool): if m and self.data.ndim < 3: - # p is p-datapoints, n is how many lines/scatter to show simultaneously - raise ValueError("ndim must be >= 3 for multi, shape must be [s1..., sn, n, p, 2 | 3]") + # p is p-datapoints, n is how many lines to show simultaneously (for line collection/stack) + raise ValueError( + "ndim must be >= 3 for multi, shape must be [s1..., sn, n, p, 2 | 3]" + ) self._multi = m @@ -81,7 +90,12 @@ def __getitem__(self, indices: tuple[Any, ...]): class NDPositions: - def __init__(self, data, graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], multi: bool = False): + def __init__( + self, + data, + graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], + multi: bool = False, + ): self._indices = 0 if issubclass(graphic, LineCollection): @@ -96,7 +110,11 @@ def processor(self) -> NDPositionsProcessor: return self._processor @property - def graphic(self) -> LineGraphic | LineCollection | LineStack | ScatterGraphic | list[ScatterGraphic]: + def graphic( + self, + ) -> ( + LineGraphic | LineCollection | LineStack | ScatterGraphic + ): """LineStack or ImageGraphic for heatmaps""" return self._graphic @@ -113,17 +131,20 @@ def indices(self, indices): for i in range(len(self.graphic)): # data_slice shape is [n_scatters, n_datapoints, 2 | 3] # by using data_slice.shape[-1] it will auto-select if the data is only xy or has xyz - self.graphic[i].data[:, :data_slice.shape[-1]] = data_slice[i] + self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] elif isinstance(self.graphic, (LineGraphic, ScatterGraphic)): - self.graphic.data[:, :data_slice.shape[-1]] = data_slice + self.graphic.data[:, : data_slice.shape[-1]] = data_slice elif isinstance(self.graphic, LineCollection): for i in range(len(self.graphic)): # data_slice shape is [n_lines, n_datapoints, 2 | 3] - self.graphic[i].data[:, :data_slice.shape[-1]] = data_slice[i] + self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] - def _create_graphic(self, graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic]): + def _create_graphic( + self, + graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], + ): if self.processor.multi and issubclass(graphic_cls, ScatterGraphic): # make list of scatters self._graphic = list() diff --git a/fastplotlib/widgets/nd_widget/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_timeseries.py index 8630044cf..49b9231c3 100644 --- a/fastplotlib/widgets/nd_widget/_nd_timeseries.py +++ b/fastplotlib/widgets/nd_widget/_nd_timeseries.py @@ -137,15 +137,17 @@ def graphic(self, g: Literal["line", "heatmap"]): # make sure "yz" data is only ys and no z values # can't represent y and z vals in a heatmap if self.processor.data[1].ndim > 2: - raise ValueError("Only y-values are supported for heatmaps, not yz-values") + raise ValueError( + "Only y-values are supported for heatmaps, not yz-values" + ) self._create_heatmap() @property - def display_window(self) -> int | float | None: + def display_window(self) -> int | float | None: return self.processor.display_window @display_window.setter - def display_window(self, dw: int | float | None): + def display_window(self, dw: int | float | None): # create new graphic if it changed if dw != self.display_window: create_new_graphic = True @@ -181,7 +183,9 @@ def set_index(self, indices: tuple[Any, ...]): def _create_line_stack_data(self, data_slice): xs = data_slice[0] # 1D - yz = data_slice[1] # [n_lines, n_datapoints] for y-vals or [n_lines, n_datapoints, 2] for yz-vals + yz = data_slice[ + 1 + ] # [n_lines, n_datapoints] for y-vals or [n_lines, n_datapoints, 2] for yz-vals # need to go from x_vals and yz_vals arrays to an array of shape: [n_lines, n_datapoints, 2 | 3] return np.dstack([np.repeat(xs[None], repeats=yz.shape[0], axis=0), yz]) @@ -199,7 +203,7 @@ def _create_heatmap_data(self, data_slice) -> tuple[ArrayProtocol, float]: # this is very fast to do on the fly, especially for typical small display windows x, y = data_slice norm = np.linalg.norm(np.diff(np.diff(x))) / x.size - if norm > 10 ** -12: + if norm > 10**-12: # need to create evenly spaced x-values x_uniform = np.linspace(x[0], x[-1], num=x.size) # yz is [n_lines, n_datapoints] @@ -220,4 +224,4 @@ def _create_heatmap(self): hm_data, x_scale = self._create_heatmap_data(data_slice) self._graphic = ImageGraphic(hm_data) - self._graphic.world_object.world.scale_x = x_scale \ No newline at end of file + self._graphic.world_object.world.scale_x = x_scale From ff5c5783c235376a049732f889cc477cdfc5ca9a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 29 Jan 2026 20:27:59 -0500 Subject: [PATCH 60/81] NDPositions working with multi-dim stack of lines, need to test window funcs --- .../widgets/nd_widget/_nd_positions.py | 113 +++++++++++-- .../widgets/nd_widget/_processor_base.py | 157 +++++++++++++++++- 2 files changed, 247 insertions(+), 23 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 10215d351..dfcb263c5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -26,6 +26,7 @@ def __init__( data: ArrayProtocol, multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points display_window: int | float | None = 100, # window for n_datapoints dim only + n_slider_dims: int = 0, ): super().__init__(data=data) @@ -33,6 +34,8 @@ def __init__( self.multi = multi + self.n_slider_dims = n_slider_dims + def _validate_data(self, data: ArrayProtocol): # TODO: determine right validation shape etc. return data @@ -66,27 +69,108 @@ def multi(self, m: bool): self._multi = m - def __getitem__(self, indices: tuple[Any, ...]): - """sliders through all slider dims and outputs an array that can be used to set graphic data""" + def _apply_window_functions(self, indices: tuple[int, ...]): + """applies the window functions for each dimension specified""" + # window size for each dim + winds = self._window_sizes + # window function for each dim + funcs = self._window_funcs + + if winds is None or funcs is None: + # no window funcs or window sizes, just slice data and return + # clamp to max bounds + indexer = list() + for dim, i in enumerate(indices): + i = min(self.shape[dim] - 1, i) + indexer.append(i) + + return self.data[tuple(indexer)] + + # order in which window funcs are applied + order = self._window_order + + if order is not None: + # remove any entries in `window_order` where the specified dim + # has a window function or window size specified as `None` + # example: + # window_sizes = (3, 2) + # window_funcs = (np.mean, None) + # order = (0, 1) + # `1` is removed from the order since that window_func is `None` + order = tuple( + d for d in order if winds[d] is not None and funcs[d] is not None + ) + else: + # sequential order + order = list() + for d in range(self.n_slider_dims): + if winds[d] is not None and funcs[d] is not None: + order.append(d) + + # the final indexer which will be used on the data array + indexer = list() + + for dim_index, (i, w, f) in enumerate(zip(indices, winds, funcs)): + # clamp i within the max bounds + i = min(self.shape[dim_index] - 1, i) + + if (w is not None) and (f is not None): + # specify slice window if both window size and function for this dim are not None + hw = int((w - 1) / 2) # half window + + # start index cannot be less than 0 + start = max(0, i - hw) + + # stop index cannot exceed the bounds of this dimension + stop = min(self.shape[dim_index] - 1, i + hw) + + s = slice(start, stop, 1) + else: + s = slice(i, i + 1, 1) + + indexer.append(s) + + # apply indexer to slice data with the specified windows + data_sliced = self.data[tuple(indexer)] + + # finally apply the window functions in the specified order + for dim in order: + f = funcs[dim] + + data_sliced = f(data_sliced, axis=dim, keepdims=True) + + return data_sliced + + def get(self, indices: tuple[Any, ...]): + """ + 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. + """ + # apply window funcs + # this array should be of shape [n_datapoints, 2 | 3] + window_output = self._apply_window_functions(indices[:-1]).squeeze() + + # TODO: window function on the `p` n_datapoints dimension + if self.display_window is not None: - indices_window = self.display_window + dw = self.display_window # half window size - hw = indices_window // 2 + hw = dw // 2 # for now assume just a single index provided that indicates x axis value - start = max(indices - hw, 0) - stop = start + indices_window + start = max(indices[-1] - hw, 0) + stop = start + dw slices = [slice(start, stop)] - # TODO: implement slicing for multiple slider dims, i.e. [s1, s2, ... n_datapoints, 2 | 3] - # this currently assumes the shape is: [n_datapoints, 2 | 3] if self.multi: # n - 2 dim is n_lines or n_scatters slices.insert(0, slice(None)) - return self.data[tuple(slices)] + return window_output[tuple(slices)] class NDPositions: @@ -96,12 +180,11 @@ def __init__( graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], multi: bool = False, ): - self._indices = 0 - if issubclass(graphic, LineCollection): multi = True - self._processor = NDPositionsProcessor(data, multi=multi) + self._processor = NDPositionsProcessor(data, multi=multi, display_window=100, n_slider_dims=2) + self._indices = tuple([0] * (2 + 1)) self._create_graphic(graphic) @@ -124,7 +207,7 @@ def indices(self) -> tuple: @indices.setter def indices(self, indices): - data_slice = self.processor[indices] + data_slice = self.processor.get(indices) if isinstance(self.graphic, list): # list of scatter @@ -148,11 +231,11 @@ def _create_graphic( if self.processor.multi and issubclass(graphic_cls, ScatterGraphic): # make list of scatters self._graphic = list() - data_slice = self.processor[self.indices] + data_slice = self.processor.get(self.indices) for d in data_slice: scatter = graphic_cls(d) self._graphic.append(scatter) else: - data_slice = self.processor[self.indices] + data_slice = self.processor.get(self.indices) self._graphic = graphic_cls(data_slice) diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/_processor_base.py index fa56e4b52..3350fff8f 100644 --- a/fastplotlib/widgets/nd_widget/_processor_base.py +++ b/fastplotlib/widgets/nd_widget/_processor_base.py @@ -7,7 +7,6 @@ from ...utils import subsample_array, ArrayProtocol - # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] @@ -20,11 +19,16 @@ def __init__( slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, window_funcs: tuple[WindowFuncCallable | None] | None = None, window_sizes: tuple[int | None] | None = None, + window_order: tuple[int, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): self._data = self._validate_data(data) self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) + self.window_funcs = window_funcs + self.window_sizes = window_sizes + self.window_order = window_order + @property def data(self) -> ArrayProtocol: return self._data @@ -33,6 +37,14 @@ def data(self) -> ArrayProtocol: def data(self, data: ArrayProtocol): self._data = self._validate_data(data) + @property + def shape(self) -> tuple[int, ...]: + return self.data.shape + + @property + def ndim(self) -> int: + return int(np.prod(self.shape)) + def _validate_data(self, data: ArrayProtocol): if not isinstance(data, ArrayProtocol): raise TypeError("`data` must implement the ArrayProtocol") @@ -40,21 +52,150 @@ def _validate_data(self, data: ArrayProtocol): return data @property - def window_funcs(self) -> tuple[WindowFuncCallable | None] | None: - pass + def window_funcs( + self, + ) -> tuple[WindowFuncCallable | None, ...] | None: + """get or set window functions, see docstring for details""" + return self._window_funcs + + @window_funcs.setter + def window_funcs( + self, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None, + ): + if window_funcs is None: + self._window_funcs = None + return + + if callable(window_funcs): + window_funcs = (window_funcs,) + + # if all are None + if all([f is None for f in window_funcs]): + self._window_funcs = None + return + + self._validate_window_func(window_funcs) + + self._window_funcs = tuple(window_funcs) + self._recompute_histogram() + + def _validate_window_func(self, funcs): + if isinstance(funcs, (tuple, list)): + for f in funcs: + if f is None: + pass + elif callable(f): + sig = inspect.signature(f) + + 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: {f} with the following function signature: {sig}" + ) + else: + raise TypeError( + f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {funcs}" + ) + + if not (len(funcs) == self.n_slider_dims or self.n_slider_dims == 0): + raise IndexError( + f"number of `window_funcs` must be the same as the number of slider dims: {self.n_slider_dims}, " + f"and you passed {len(funcs)} `window_funcs`: {funcs}" + ) @property - def window_sizes(self) -> tuple[int | None] | None: - pass + def window_sizes(self) -> tuple[int | None, ...] | None: + """get or set window sizes used for the corresponding window functions, see docstring for details""" + return self._window_sizes + + @window_sizes.setter + def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): + if window_sizes is None: + self._window_sizes = None + return + + if isinstance(window_sizes, int): + window_sizes = (window_sizes,) + + # if all are None + if all([w is None for w in window_sizes]): + self._window_sizes = None + return + + if not all([isinstance(w, (int)) or w is None for w in window_sizes]): + raise TypeError( + f"`window_sizes` must be of type: tuple[int | None, ...] | int | None, you have passed: {window_sizes}" + ) + + # if not (len(window_sizes) == self.n_slider_dims or self.n_slider_dims == 0): + # raise IndexError( + # f"number of `window_sizes` must be the same as the number of slider dims, " + # f"i.e. `data.ndim` - n_display_dims, your data array has {self.ndim} dimensions " + # f"and you passed {len(window_sizes)} `window_sizes`: {window_sizes}" + # ) + + # make all window sizes are valid numbers + _window_sizes = list() + for i, w in enumerate(window_sizes): + if w is None: + _window_sizes.append(None) + continue + + if w < 0: + raise ValueError( + f"negative window size passed, all `window_sizes` must be positive " + f"integers or `None`, you passed: {_window_sizes}" + ) + + if w == 0 or w == 1: + # this is not a real window, set as None + w = None + + elif w % 2 == 0: + # odd window sizes makes most sense + warn( + f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" + ) + w += 1 + + _window_sizes.append(w) + + self._window_sizes = tuple(_window_sizes) @property - def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: - pass + def window_order(self) -> tuple[int, ...] | None: + """get or set dimension order in which window functions are applied""" + return self._window_order + + @window_order.setter + def window_order(self, order: tuple[int] | None): + if order is None: + self._window_order = None + return + + if order is not None: + if not all([d <= self.n_slider_dims for d in order]): + raise IndexError( + f"all `window_order` entries must be <= n_slider_dims\n" + f"`n_slider_dims` is: {self.n_slider_dims}, you have passed `window_order`: {order}" + ) + + if not all([d >= 0 for d in order]): + raise IndexError( + f"all `window_order` entires must be >= 0, you have passed: {order}" + ) + + self._window_order = tuple(order) @property - def slider_dims(self) -> tuple[int, ...] | None: + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: pass + # @property + # def slider_dims(self) -> tuple[int, ...] | None: + # pass + @property def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: return self._slider_index_maps From 3f412c514e204279b70dad1bbb0c7c2b06796405 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 29 Jan 2026 20:48:25 -0500 Subject: [PATCH 61/81] scatter collection --- fastplotlib/graphics/__init__.py | 3 +- fastplotlib/graphics/scatter_collection.py | 517 ++++++++++++++++++ fastplotlib/layouts/_graphic_methods_mixin.py | 84 ++- 3 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 fastplotlib/graphics/scatter_collection.py diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 3d01e4a35..8734a5e72 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -7,7 +7,7 @@ from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack - +from .scatter_collection import ScatterCollection __all__ = [ "Graphic", @@ -22,4 +22,5 @@ "TextGraphic", "LineCollection", "LineStack", + "ScatterCollection", ] diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py new file mode 100644 index 000000000..4d671b0ac --- /dev/null +++ b/fastplotlib/graphics/scatter_collection.py @@ -0,0 +1,517 @@ +from typing import * + +import numpy as np + +import pygfx + +from ..utils import parse_cmap_values +from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature +from .scatter import ScatterGraphic +from .selectors import ( + LinearRegionSelector, + LinearSelector, + RectangleSelector, + PolygonSelector, +) + + +class _ScatterCollectionProperties: + """Mix-in class for ScatterCollection properties""" + + @property + def colors(self) -> CollectionFeature: + """get or set colors of scatters in the collection""" + return CollectionFeature(self.graphics, "colors") + + @colors.setter + def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(values, str): + # set colors of all scatter to one str color + for g in self: + g.colors = values + return + + elif all(isinstance(v, str) for v in values): + # individual str colors for each scatter + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.colors = v + + return + + if isinstance(values, np.ndarray): + if values.ndim == 2: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + return + + elif len(values) == 4: + # assume RGBA + self.colors[:] = values + + else: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + + @property + def data(self) -> CollectionFeature: + """get or set data of lines in the collection""" + return CollectionFeature(self.graphics, "data") + + @data.setter + def data(self, values): + for g, v in zip(self, values): + g.data = v + + @property + def cmap(self) -> CollectionFeature: + """ + Get or set a cmap along the scatter collection. + + Optionally set using a tuple ("cmap", ) to set the transform. + Example: + + scatter_collection.cmap = ("jet", sine_transform_vals, 0.7) + + """ + return CollectionFeature(self.graphics, "cmap") + + @cmap.setter + def cmap(self, args): + if isinstance(args, str): + name = args + transform = None + elif len(args) == 1: + name = args[0] + transform = None + elif len(args) == 2: + name, transform = args + else: + raise ValueError( + "Too many values for cmap (note that alpha is deprecated, set alpha on the graphic instead)" + ) + + self.colors = parse_cmap_values( + n_colors=len(self), cmap_name=name, transform=transform + ) + + +class ScatterCollectionIndexer(CollectionIndexer, _ScatterCollectionProperties): + """Indexer for scatter collections""" + + pass + + +class ScatterCollection(GraphicCollection, _ScatterCollectionProperties): + _child_type = ScatterGraphic + _indexer = ScatterCollectionIndexer + + def __init__( + self, + data: np.ndarray | List[np.ndarray], + colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", + uniform_colors: bool = False, + cmap: Sequence[str] | str = None, + cmap_transform: np.ndarray | List = None, + sizes: float | Sequence[float] = 2.0, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Sequence[Any] | np.ndarray = None, + isolated_buffer: bool = True, + kwargs_lines: list[dict] = None, + **kwargs, + ): + """ + Create a collection of :class:`.ScatterGraphic` + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + meatadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + """ + + super().__init__(name=name, metadata=metadata, **kwargs) + + if names is not None: + if len(names) != len(data): + raise ValueError( + f"len(names) != len(data)\n{len(names)} != {len(data)}" + ) + + if metadatas is not None: + if len(metadatas) != len(data): + raise ValueError( + f"len(metadata) != len(data)\n{len(metadatas)} != {len(data)}" + ) + + if kwargs_lines is not None: + if len(kwargs_lines) != len(data): + raise ValueError( + f"len(kwargs_lines) != len(data)\n" + f"{len(kwargs_lines)} != {len(data)}" + ) + + self._cmap_transform = cmap_transform + self._cmap_str = cmap + + # cmap takes priority over colors + if cmap is not None: + # cmap across lines + if isinstance(cmap, str): + colors = parse_cmap_values( + n_colors=len(data), cmap_name=cmap, transform=cmap_transform + ) + single_color = False + cmap = None + + elif isinstance(cmap, (tuple, list)): + if len(cmap) != len(data): + raise ValueError( + "cmap argument must be a single cmap or a list of cmaps " + "with the same length as the data" + ) + single_color = False + else: + raise ValueError( + "cmap argument must be a single cmap or a list of cmaps " + "with the same length as the data" + ) + else: + if isinstance(colors, np.ndarray): + # single color for all lines in the collection as RGBA + if colors.shape in [(3,), (4,)]: + single_color = True + + # colors specified for each line as array of shape [n_lines, RGBA] + elif colors.shape == (len(data), 4): + single_color = False + + else: + raise ValueError( + f"numpy array colors argument must be of shape (4,) or (n_lines, 4)." + f"You have pass the following shape: {colors.shape}" + ) + + elif isinstance(colors, str): + if colors == "random": + colors = np.random.rand(len(data), 3) + single_color = False + else: + # parse string color + single_color = True + colors = pygfx.Color(colors) + + elif isinstance(colors, (tuple, list)): + if len(colors) == 4: + # single color specified as (R, G, B, A) tuple or list + if all([isinstance(c, (float, int)) for c in colors]): + single_color = True + + elif len(colors) == len(data): + # colors passed as list/tuple of colors, such as list of string + single_color = False + + else: + raise ValueError( + "tuple or list colors argument must be a single color represented as [R, G, B, A], " + "or must be a tuple/list of colors represented by a string with the same length as the data" + ) + + if kwargs_lines is None: + kwargs_lines = dict() + + self._set_world_object(pygfx.Group()) + + for i, d in enumerate(data): + if cmap is None: + _cmap = None + + if single_color: + _c = colors + else: + _c = colors[i] + else: + _cmap = cmap[i] + _c = None + + if metadatas is not None: + _m = metadatas[i] + else: + _m = None + + if names is not None: + _name = names[i] + else: + _name = None + + lg = ScatterGraphic( + data=d, + colors=_c, + uniform_color=uniform_colors, + sizes=sizes, + cmap=_cmap, + name=_name, + metadata=_m, + isolated_buffer=isolated_buffer, + **kwargs_lines, + ) + + self.add_graphic(lg) + + def __getitem__(self, item) -> ScatterCollectionIndexer: + return super().__getitem__(item) + + def add_linear_selector( + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs + ) -> LinearSelector: + """ + Adds a linear selector. + + Parameters + ---------- + Parameters + ---------- + selection: float, optional + selected point on the linear selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear selector along the orthogonal axis to make it easier to interact with. + + kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init[0] + + selector = LinearSelector( + selection=selection, + limits=limits, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_linear_region_selector( + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, + ) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float), optional + the starting bounds of the linear region selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init + + # create selector + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + # PlotArea manages this for garbage collection etc. just like all other Graphics + # so we should only work with a proxy on the user-end + return selector + + def add_rectangle_selector( + self, + selection: tuple[float, float, float] = None, + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + bbox = self.world_object.get_world_bounding_box() + + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + value_25px = (xmax - xmin) / 4 + + ydata = np.array(self.data[:, 1]) + ymin = np.floor(ydata.min()).astype(int) + + ymax = np.ptp(bbox[:, 1]) + + if selection is None: + selection = (xmin, value_25px, ymin, ymax) + + limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) + + selector = RectangleSelector( + selection=selection, + limits=limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: List of positions, optional + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). + """ + bbox = self.world_object.get_world_bounding_box() + + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + + ydata = np.array(self.data[:, 1]) + ymin = np.floor(ydata.min()).astype(int) + + ymax = np.ptp(bbox[:, 1]) + + limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) + + selector = PolygonSelector( + selection, + limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def _get_linear_selector_init_args(self, axis, padding): + # use bbox to get size and center + bbox = self.world_object.get_world_bounding_box() + + if axis == "x": + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + value_25p = (xmax - xmin) / 4 + + bounds = (xmin, value_25p) + limits = (xmin, xmax) + # size from orthogonal axis + size = np.ptp(bbox[:, 1]) * 1.5 + # center on orthogonal axis + center = bbox[:, 1].mean() + + elif axis == "y": + ydata = np.array(self.data[:, 1]) + xmin, xmax = (np.nanmin(ydata), np.nanmax(ydata)) + value_25p = (xmax - xmin) / 4 + + bounds = (xmin, value_25p) + limits = (xmin, xmax) + + size = np.ptp(bbox[:, 0]) * 1.5 + # center on orthogonal axis + center = bbox[:, 0].mean() + + return bounds, limits, size, center diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 06a4c7517..3eb018f55 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -570,6 +570,88 @@ def add_polygon( PolygonGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs ) + def add_scatter_collection( + self, + data: Union[numpy.ndarray, List[numpy.ndarray]], + colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", + uniform_colors: bool = False, + cmap: Union[Sequence[str], str] = None, + cmap_transform: Union[numpy.ndarray, List] = None, + sizes: Union[float, Sequence[float]] = 2.0, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Union[Sequence[Any], numpy.ndarray] = None, + isolated_buffer: bool = True, + kwargs_lines: list[dict] = None, + **kwargs, + ) -> ScatterCollection: + """ + + Create a collection of :class:`.ScatterGraphic` + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + meatadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + + """ + return self._create_graphic( + ScatterCollection, + data, + colors, + uniform_colors, + cmap, + cmap_transform, + sizes, + name, + names, + metadata, + metadatas, + isolated_buffer, + kwargs_lines, + **kwargs, + ) + def add_scatter( self, data: Any, @@ -589,7 +671,7 @@ def add_scatter( image: numpy.ndarray = None, point_rotations: float | numpy.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", - sizes: Union[float, numpy.ndarray, Sequence[float]] = 1, + sizes: Union[float, numpy.ndarray, Sequence[float]] = 5, uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, From dc30151740ea77414a1b4e8d26009092c3aa4ff0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 30 Jan 2026 00:06:37 -0500 Subject: [PATCH 62/81] progress, need to change to other branch so committing --- fastplotlib/graphics/scatter_collection.py | 2 +- .../widgets/nd_widget/_nd_positions.py | 99 +++++++++++++------ 2 files changed, 68 insertions(+), 33 deletions(-) diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py index 4d671b0ac..b1569cacc 100644 --- a/fastplotlib/graphics/scatter_collection.py +++ b/fastplotlib/graphics/scatter_collection.py @@ -117,7 +117,7 @@ def __init__( uniform_colors: bool = False, cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, - sizes: float | Sequence[float] = 2.0, + sizes: float | Sequence[float] = 5.0, name: str = None, names: list[str] = None, metadata: Any = None, diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index dfcb263c5..decd3ec6c 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -13,6 +13,7 @@ LineStack, LineCollection, ScatterGraphic, + ScatterCollection, ) from ._processor_base import NDProcessor @@ -122,7 +123,7 @@ def _apply_window_functions(self, indices: tuple[int, ...]): start = max(0, i - hw) # stop index cannot exceed the bounds of this dimension - stop = min(self.shape[dim_index] - 1, i + hw) + stop = min(self.shape[dim_index], i + hw) s = slice(start, stop, 1) else: @@ -148,23 +149,34 @@ def get(self, indices: tuple[Any, ...]): 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. """ - # apply window funcs - # this array should be of shape [n_datapoints, 2 | 3] - window_output = self._apply_window_functions(indices[:-1]).squeeze() + if len(indices) > 1: + # there are dims in addition to the n_datapoints dim + # apply window funcs + # window_output array should be of shape [n_datapoints, 2 | 3] + window_output = self._apply_window_functions(indices[:-1]).squeeze() + else: + window_output = self.data # TODO: window function on the `p` n_datapoints dimension if self.display_window is not None: dw = self.display_window - # half window size - hw = dw // 2 + if dw == 1: + slices = [slice(indices[-1], indices[-1] + 1)] + + else: + # half window size + hw = dw // 2 - # for now assume just a single index provided that indicates x axis value - start = max(indices[-1] - hw, 0) - stop = start + dw + # for now assume just a single index provided that indicates x axis value + start = max(indices[-1] - hw, 0) + stop = start + dw - slices = [slice(start, stop)] + # TODO: uncomment this once we have resizeable buffers!! + # stop = min(indices[-1] + hw, self.shape[-2]) + + slices = [slice(start, stop)] if self.multi: # n - 2 dim is n_lines or n_scatters @@ -177,14 +189,15 @@ class NDPositions: def __init__( self, data, - graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], + graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], multi: bool = False, + display_window: int = 10, ): if issubclass(graphic, LineCollection): multi = True - self._processor = NDPositionsProcessor(data, multi=multi, display_window=100, n_slider_dims=2) - self._indices = tuple([0] * (2 + 1)) + self._processor = NDPositionsProcessor(data, multi=multi, display_window=display_window, n_slider_dims=0) + self._indices = tuple([0] * (0 + 1)) self._create_graphic(graphic) @@ -196,11 +209,19 @@ def processor(self) -> NDPositionsProcessor: def graphic( self, ) -> ( - LineGraphic | LineCollection | LineStack | ScatterGraphic + LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic ): """LineStack or ImageGraphic for heatmaps""" return self._graphic + @graphic.setter + def graphic(self, graphic_type): + plot_area = self._graphic._plot_area + plot_area.delete_graphic(self._graphic) + + self._create_graphic(graphic_type) + plot_area.add_graphic(self._graphic) + @property def indices(self) -> tuple: return self._indices @@ -209,33 +230,47 @@ def indices(self) -> tuple: def indices(self, indices): data_slice = self.processor.get(indices) - if isinstance(self.graphic, list): - # list of scatter - for i in range(len(self.graphic)): - # data_slice shape is [n_scatters, n_datapoints, 2 | 3] - # by using data_slice.shape[-1] it will auto-select if the data is only xy or has xyz - self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] - - elif isinstance(self.graphic, (LineGraphic, ScatterGraphic)): + if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): self.graphic.data[:, : data_slice.shape[-1]] = data_slice - elif isinstance(self.graphic, LineCollection): + elif isinstance(self.graphic, (LineCollection, ScatterCollection)): for i in range(len(self.graphic)): # data_slice shape is [n_lines, n_datapoints, 2 | 3] self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] + elif isinstance(self.graphic, ImageGraphic): + image_data, x0, x_scale = self._create_heatmap_data(data_slice) + self.graphic.data = image_data + self.graphic.offset = (x0, *self.graphic.offset[1:]) + def _create_graphic( self, - graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], + graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], ): - if self.processor.multi and issubclass(graphic_cls, ScatterGraphic): - # make list of scatters - self._graphic = list() - data_slice = self.processor.get(self.indices) - for d in data_slice: - scatter = graphic_cls(d) - self._graphic.append(scatter) + + data_slice = self.processor.get(self.indices) + + if issubclass(graphic_cls, ImageGraphic): + image_data, x0, x_scale = self._create_heatmap_data(data_slice) + self._graphic = graphic_cls(image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1)) else: - data_slice = self.processor.get(self.indices) self._graphic = graphic_cls(data_slice) + + def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: + if not self.processor.multi: + raise ValueError + + if self.processor.data.shape[-1] != 2: + raise ValueError + + # return [n_rows, n_cols] shape data + + image_data = data_slice[..., 1] + + # assume all x values are the same + x_scale = data_slice[:, -1, 0][0] / data_slice.shape[1] + + x0 = data_slice[0, 0, 0] + + return image_data, x0, x_scale From db98bde60f5b7b8b2bfc6288634616efccd529c0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 30 Jan 2026 00:34:37 -0500 Subject: [PATCH 63/81] better --- fastplotlib/widgets/nd_widget/_nd_positions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index decd3ec6c..bc7b5c242 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -216,6 +216,9 @@ def graphic( @graphic.setter def graphic(self, graphic_type): + if isinstance(self.graphic, graphic_type): + return + plot_area = self._graphic._plot_area plot_area.delete_graphic(self._graphic) From 3629f70f8c351feffe8208a0132f9463e63dd146 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 30 Jan 2026 20:45:47 -0500 Subject: [PATCH 64/81] interpolation for heatmap --- .../widgets/nd_widget/_nd_positions.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index bc7b5c242..f5b13a361 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -261,19 +261,35 @@ def _create_graphic( self._graphic = graphic_cls(data_slice) def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: + """return [n_rows, n_cols] shape data""" if not self.processor.multi: raise ValueError if self.processor.data.shape[-1] != 2: raise ValueError - # return [n_rows, n_cols] shape data + # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense + x = data_slice[0, :, 0] # get x from just the first row - image_data = data_slice[..., 1] + # check if we need to interpolate + norm = np.linalg.norm(np.diff(np.diff(x))) / x.size + + if norm > 1e-6: + # x is not uniform upto float32 precision, must interpolate + x_uniform = np.linspace(x[0], x[-1], num=x.size) + y_interp = np.zeros(shape=data_slice[..., 1].shape, dtype=np.float32) + + # this for loop is actually slightly faster than numpy.apply_along_axis() + for i in range(data_slice.shape[0]): + y_interp[i] = np.interp(x_uniform, x, data_slice[i, :, 1]) + + else: + # x is sufficiently uniform + y_interp = data_slice[..., 1] # assume all x values are the same x_scale = data_slice[:, -1, 0][0] / data_slice.shape[1] x0 = data_slice[0, 0, 0] - return image_data, x0, x_scale + return y_interp, x0, x_scale From 87ea418121114b1bf0617893d804c19baaf70a45 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 30 Jan 2026 20:47:08 -0500 Subject: [PATCH 65/81] better place for check --- fastplotlib/widgets/nd_widget/_nd_positions.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index f5b13a361..201bbb800 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -250,10 +250,15 @@ def _create_graphic( self, graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], ): - data_slice = self.processor.get(self.indices) if issubclass(graphic_cls, ImageGraphic): + if not self.processor.multi: + raise ValueError + + if self.processor.data.shape[-1] != 2: + raise ValueError + image_data, x0, x_scale = self._create_heatmap_data(data_slice) self._graphic = graphic_cls(image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1)) @@ -262,12 +267,6 @@ def _create_graphic( def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: """return [n_rows, n_cols] shape data""" - if not self.processor.multi: - raise ValueError - - if self.processor.data.shape[-1] != 2: - raise ValueError - # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense x = data_slice[0, :, 0] # get x from just the first row From e5a8d40e7f2a17f6c0effe5fd577f912cf968211 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 03:43:12 -0500 Subject: [PATCH 66/81] window functions working on n_datapoints dim --- .../widgets/nd_widget/_nd_positions.py | 111 +++++++++++++++--- .../widgets/nd_widget/_processor_base.py | 38 +++--- 2 files changed, 118 insertions(+), 31 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 201bbb800..ec64d4b9f 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -4,6 +4,7 @@ import numpy as np from numpy.typing import ArrayLike +from numpy.lib.stride_tricks import sliding_window_view from ...utils import subsample_array, ArrayProtocol @@ -15,7 +16,7 @@ ScatterGraphic, ScatterCollection, ) -from ._processor_base import NDProcessor +from ._processor_base import NDProcessor, WindowFuncCallable # TODO: Maybe get rid of n_display_dims in NDProcessor, @@ -27,15 +28,21 @@ def __init__( data: ArrayProtocol, multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points display_window: int | float | None = 100, # window for n_datapoints dim only - n_slider_dims: int = 0, + datapoints_window_func: Callable | None = None, + datapoints_window_size: int | None = None, + **kwargs ): - super().__init__(data=data) self._display_window = display_window + # TOOD: this does data validation twice and is a bit messy, cleanup + self._data = self._validate_data(data) self.multi = multi - self.n_slider_dims = n_slider_dims + super().__init__(data=data, **kwargs) + + self._datapoints_window_func = datapoints_window_func + self._datapoints_window_size = datapoints_window_size def _validate_data(self, data: ArrayProtocol): # TODO: determine right validation shape etc. @@ -70,6 +77,28 @@ def multi(self, m: bool): self._multi = m + @property + def slider_dims(self) -> tuple[int, ...]: + """slider dimensions""" + return tuple(range(self.ndim - 2 - int(self.multi))) + (self.ndim - 2,) + + @property + def n_slider_dims(self) -> int: + return self.ndim - 1 - int(self.multi) + + # TODO: validation for datapoints_window_func and size + @property + def datapoints_window_func(self) -> tuple[Callable, str] | None: + """ + Callable and str indicating which dims to apply window function along: + 'all', 'x', 'y', 'z', 'xyz', 'xy', 'xz', 'yz' + '""" + return self._datapoints_window_func + + @property + def datapoints_window_size(self) -> Callable | None: + return self._datapoints_window_size + def _apply_window_functions(self, indices: tuple[int, ...]): """applies the window functions for each dimension specified""" # window size for each dim @@ -77,15 +106,21 @@ def _apply_window_functions(self, indices: tuple[int, ...]): # window function for each dim funcs = self._window_funcs - if winds is None or funcs is None: - # no window funcs or window sizes, just slice data and return - # clamp to max bounds - indexer = list() - for dim, i in enumerate(indices): - i = min(self.shape[dim] - 1, i) - indexer.append(i) - - return self.data[tuple(indexer)] + # TODO: use tuple of None for window funcs and sizes to indicate all None, instead of just None + # print(winds) + # print(funcs) + # + # if winds is None or funcs is None: + # # no window funcs or window sizes, just slice data and return + # # clamp to max bounds + # indexer = list() + # print(indices) + # print(self.shape) + # for dim, i in enumerate(indices): + # i = min(self.shape[dim] - 1, i) + # indexer.append(i) + # + # return self.data[tuple(indexer)] # order in which window funcs are applied order = self._window_order @@ -172,6 +207,10 @@ def get(self, indices: tuple[Any, ...]): # for now assume just a single index provided that indicates x axis value start = max(indices[-1] - hw, 0) stop = start + dw + # also add window size of `p` dim so window_func output has the same number of datapoints + if self.datapoints_window_func is not None and self.datapoints_window_size is not None: + stop += self.datapoints_window_size - 1 + # TODO: pad with constant if we're using a window func and the index is near the end # TODO: uncomment this once we have resizeable buffers!! # stop = min(indices[-1] + hw, self.shape[-2]) @@ -182,7 +221,38 @@ def get(self, indices: tuple[Any, ...]): # n - 2 dim is n_lines or n_scatters slices.insert(0, slice(None)) - return window_output[tuple(slices)] + # 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 + graphic_data = window_output[tuple(slices)].copy() + + # apply window function on the `p` n_datapoints dim + if self.datapoints_window_func is not None and self.datapoints_window_size is not None: + # get windows + + # graphic_data will be of shape: [n, p + (ws - 1), 2 | 3] + # where: + # n - number of lines, scatters, heatmap rows + # p - number of datapoints/samples + + # windows will be of shape [n, p, 1 | 2 | 3, ws] + wf = self.datapoints_window_func[0] + apply_dims = self.datapoints_window_func[1] + ws = self.datapoints_window_size + + # apply user's window func and return + # result will be of shape [n, p, 2 | 3] + if apply_dims == "all": + windows = sliding_window_view(graphic_data, ws, axis=-2) + return wf(windows, axis=-1) + + # map user dims str to tuple of numerical dims + dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) + windows = sliding_window_view(graphic_data[..., dims], ws, axis=-2).squeeze() + graphic_data[..., :self.display_window, dims] = wf(windows, axis=-1)[..., None] + + return graphic_data[..., :self.display_window, :] + + return graphic_data class NDPositions: @@ -192,12 +262,21 @@ def __init__( graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], multi: bool = False, display_window: int = 10, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, ): if issubclass(graphic, LineCollection): multi = True - self._processor = NDPositionsProcessor(data, multi=multi, display_window=display_window, n_slider_dims=0) - self._indices = tuple([0] * (0 + 1)) + self._processor = NDPositionsProcessor( + data, + multi=multi, + display_window=display_window, + window_funcs=window_funcs, + window_sizes=window_sizes, + ) + + self._indices = tuple([0] * self._processor.n_slider_dims) self._create_graphic(graphic) diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/_processor_base.py index 3350fff8f..974677144 100644 --- a/fastplotlib/widgets/nd_widget/_processor_base.py +++ b/fastplotlib/widgets/nd_widget/_processor_base.py @@ -16,14 +16,14 @@ def __init__( self, data, n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + index_mappings: tuple[Callable[[Any], int] | None, ...] | None = None, window_funcs: tuple[WindowFuncCallable | None] | None = None, window_sizes: tuple[int | None] | None = None, window_order: tuple[int, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): self._data = self._validate_data(data) - self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) + self._index_mappings = self._validate_index_mappings(index_mappings) self.window_funcs = window_funcs self.window_sizes = window_sizes @@ -43,7 +43,7 @@ def shape(self) -> tuple[int, ...]: @property def ndim(self) -> int: - return int(np.prod(self.shape)) + return len(self.shape) def _validate_data(self, data: ArrayProtocol): if not isinstance(data, ArrayProtocol): @@ -51,6 +51,14 @@ def _validate_data(self, data: ArrayProtocol): return data + @property + def slider_dims(self): + raise NotImplementedError + + @property + def n_slider_dims(self): + raise NotImplementedError + @property def window_funcs( self, @@ -64,21 +72,21 @@ def window_funcs( window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None, ): if window_funcs is None: - self._window_funcs = None + self._window_funcs = tuple([None] * self.n_slider_dims) return if callable(window_funcs): window_funcs = (window_funcs,) # if all are None - if all([f is None for f in window_funcs]): - self._window_funcs = None - return + # if all([f is None for f in window_funcs]): + # self._window_funcs = tuple(window_funcs) + # return self._validate_window_func(window_funcs) self._window_funcs = tuple(window_funcs) - self._recompute_histogram() + # self._recompute_histogram() def _validate_window_func(self, funcs): if isinstance(funcs, (tuple, list)): @@ -112,7 +120,7 @@ def window_sizes(self) -> tuple[int | None, ...] | None: @window_sizes.setter def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): if window_sizes is None: - self._window_sizes = None + self._window_sizes = tuple([None] * self.n_slider_dims) return if isinstance(window_sizes, int): @@ -197,14 +205,14 @@ def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: # pass @property - def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: - return self._slider_index_maps + def index_mappings(self) -> tuple[Callable[[Any], int] | None, ...]: + return self._index_mappings - @slider_index_maps.setter - def slider_index_maps(self, maps): - self._maps = self._validate_slider_index_maps(maps) + @index_mappings.setter + def index_mappings(self, maps): + self._index_mappings = self._validate_index_mappings(maps) - def _validate_slider_index_maps(self, maps): + def _validate_index_mappings(self, maps): if maps is not None: if not all([callable(m) or m is None for m in maps]): raise TypeError From 8d050a76a215c1fa78b764a8eb5e80c38a938c76 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 04:00:44 -0500 Subject: [PATCH 67/81] p dim window funcs working for single and multiple dims I think --- fastplotlib/widgets/nd_widget/_nd_positions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index ec64d4b9f..b20eabb96 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -234,7 +234,6 @@ def get(self, indices: tuple[Any, ...]): # n - number of lines, scatters, heatmap rows # p - number of datapoints/samples - # windows will be of shape [n, p, 1 | 2 | 3, ws] wf = self.datapoints_window_func[0] apply_dims = self.datapoints_window_func[1] ws = self.datapoints_window_size @@ -242,13 +241,18 @@ def get(self, indices: tuple[Any, ...]): # apply user's window func and return # result will be of shape [n, p, 2 | 3] if apply_dims == "all": + # windows will be of shape [n, p, 1 | 2 | 3, ws] windows = sliding_window_view(graphic_data, ws, axis=-2) return wf(windows, axis=-1) # map user dims str to tuple of numerical dims dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) + + # windows will be of shape [n, p, 1 | 2 | 3, ws] windows = sliding_window_view(graphic_data[..., dims], ws, axis=-2).squeeze() - graphic_data[..., :self.display_window, dims] = wf(windows, axis=-1)[..., None] + + # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary + graphic_data[..., :self.display_window, dims] = wf(windows, axis=-1).reshape(graphic_data.shape[0], self.display_window, len(dims)) return graphic_data[..., :self.display_window, :] From 373199786a7126f2759b59afef03fef6980eb3ba Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 18:20:43 -0500 Subject: [PATCH 68/81] black --- .../widgets/nd_widget/_nd_positions.py | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index b20eabb96..c39304996 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -30,7 +30,7 @@ def __init__( display_window: int | float | None = 100, # window for n_datapoints dim only datapoints_window_func: Callable | None = None, datapoints_window_size: int | None = None, - **kwargs + **kwargs, ): self._display_window = display_window @@ -208,7 +208,10 @@ def get(self, indices: tuple[Any, ...]): start = max(indices[-1] - hw, 0) stop = start + dw # also add window size of `p` dim so window_func output has the same number of datapoints - if self.datapoints_window_func is not None and self.datapoints_window_size is not None: + if ( + self.datapoints_window_func is not None + and self.datapoints_window_size is not None + ): stop += self.datapoints_window_size - 1 # TODO: pad with constant if we're using a window func and the index is near the end @@ -226,7 +229,10 @@ def get(self, indices: tuple[Any, ...]): graphic_data = window_output[tuple(slices)].copy() # apply window function on the `p` n_datapoints dim - if self.datapoints_window_func is not None and self.datapoints_window_size is not None: + if ( + self.datapoints_window_func is not None + and self.datapoints_window_size is not None + ): # get windows # graphic_data will be of shape: [n, p + (ws - 1), 2 | 3] @@ -249,12 +255,16 @@ def get(self, indices: tuple[Any, ...]): dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) # windows will be of shape [n, p, 1 | 2 | 3, ws] - windows = sliding_window_view(graphic_data[..., dims], ws, axis=-2).squeeze() + windows = sliding_window_view( + graphic_data[..., dims], ws, axis=-2 + ).squeeze() # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary - graphic_data[..., :self.display_window, dims] = wf(windows, axis=-1).reshape(graphic_data.shape[0], self.display_window, len(dims)) + graphic_data[..., : self.display_window, dims] = wf( + windows, axis=-1 + ).reshape(graphic_data.shape[0], self.display_window, len(dims)) - return graphic_data[..., :self.display_window, :] + return graphic_data[..., : self.display_window, :] return graphic_data @@ -263,7 +273,14 @@ class NDPositions: def __init__( self, data, - graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], + graphic: Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ImageGraphic + ], multi: bool = False, display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, @@ -292,7 +309,12 @@ def processor(self) -> NDPositionsProcessor: def graphic( self, ) -> ( - LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ImageGraphic ): """LineStack or ImageGraphic for heatmaps""" return self._graphic @@ -331,7 +353,14 @@ def indices(self, indices): def _create_graphic( self, - graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], + graphic_cls: Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ImageGraphic + ], ): data_slice = self.processor.get(self.indices) @@ -343,7 +372,9 @@ def _create_graphic( raise ValueError image_data, x0, x_scale = self._create_heatmap_data(data_slice) - self._graphic = graphic_cls(image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1)) + self._graphic = graphic_cls( + image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1) + ) else: self._graphic = graphic_cls(data_slice) From 7d4e42024796bc673a5accf575ae469ee1148dc3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 19:12:43 -0500 Subject: [PATCH 69/81] index_mappings is working I think, lightly tested on p dim --- .../widgets/nd_widget/_nd_positions.py | 16 ++++++---- .../widgets/nd_widget/_processor_base.py | 29 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index c39304996..1871e027e 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -184,6 +184,9 @@ def get(self, indices: tuple[Any, ...]): 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. """ + # apply any slider index mappings + indices = tuple([m(i) for m, i in zip(self.index_mappings, indices)]) + if len(indices) > 1: # there are dims in addition to the n_datapoints dim # apply window funcs @@ -195,7 +198,8 @@ def get(self, indices: tuple[Any, ...]): # TODO: window function on the `p` n_datapoints dimension if self.display_window is not None: - dw = self.display_window + # display window is interpreted using the index mapping for the `p` dim + dw = self.index_mappings[-1](self.display_window) if dw == 1: slices = [slice(indices[-1], indices[-1] + 1)] @@ -244,7 +248,7 @@ def get(self, indices: tuple[Any, ...]): apply_dims = self.datapoints_window_func[1] ws = self.datapoints_window_size - # apply user's window func and return + # apply user's window func # result will be of shape [n, p, 2 | 3] if apply_dims == "all": # windows will be of shape [n, p, 1 | 2 | 3, ws] @@ -260,11 +264,11 @@ def get(self, indices: tuple[Any, ...]): ).squeeze() # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary - graphic_data[..., : self.display_window, dims] = wf( + graphic_data[..., : dw, dims] = wf( windows, axis=-1 - ).reshape(graphic_data.shape[0], self.display_window, len(dims)) + ).reshape(graphic_data.shape[0], dw, len(dims)) - return graphic_data[..., : self.display_window, :] + return graphic_data[..., : dw, :] return graphic_data @@ -285,6 +289,7 @@ def __init__( display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, window_sizes: tuple[int | None] | None = None, + index_mappings: tuple[Callable[[Any], int] | None] | None = None, ): if issubclass(graphic, LineCollection): multi = True @@ -295,6 +300,7 @@ def __init__( display_window=display_window, window_funcs=window_funcs, window_sizes=window_sizes, + index_mappings=index_mappings, ) self._indices = tuple([0] * self._processor.n_slider_dims) diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/_processor_base.py index 974677144..225608cca 100644 --- a/fastplotlib/widgets/nd_widget/_processor_base.py +++ b/fastplotlib/widgets/nd_widget/_processor_base.py @@ -11,6 +11,10 @@ WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] +def identity(index: int) -> int: + return index + + class NDProcessor: def __init__( self, @@ -23,7 +27,7 @@ def __init__( spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): self._data = self._validate_data(data) - self._index_mappings = self._validate_index_mappings(index_mappings) + self._index_mappings = tuple(self._validate_index_mappings(index_mappings)) self.window_funcs = window_funcs self.window_sizes = window_sizes @@ -205,19 +209,30 @@ def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: # pass @property - def index_mappings(self) -> tuple[Callable[[Any], int] | None, ...]: + def index_mappings(self) -> tuple[Callable[[Any], int]]: return self._index_mappings @index_mappings.setter - def index_mappings(self, maps): - self._index_mappings = self._validate_index_mappings(maps) + def index_mappings(self, maps: tuple[Callable[[Any], int] | None] | None): + self._index_mappings = tuple(self._validate_index_mappings(maps)) def _validate_index_mappings(self, maps): - if maps is not None: - if not all([callable(m) or m is None for m in maps]): + if maps is None: + return tuple([identity] * self.n_slider_dims) + + if len(maps) != self.n_slider_dims: + raise IndexError + + _maps = list() + for m in maps: + if m is None: + _maps.append(identity) + elif callable(m): + _maps.append(identity) + else: raise TypeError - return maps + return tuple(maps) def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: pass From 6cdcb178913874482dd55ef20daf3113879fb3cf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 21:07:08 -0500 Subject: [PATCH 70/81] remove nd_timeseries since nd_positions is sufficient --- .../widgets/nd_widget/_nd_timeseries.py | 227 ------------------ 1 file changed, 227 deletions(-) delete mode 100644 fastplotlib/widgets/nd_widget/_nd_timeseries.py diff --git a/fastplotlib/widgets/nd_widget/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_timeseries.py deleted file mode 100644 index 49b9231c3..000000000 --- a/fastplotlib/widgets/nd_widget/_nd_timeseries.py +++ /dev/null @@ -1,227 +0,0 @@ -import inspect -from typing import Literal, Callable, Any -from warnings import warn - -import numpy as np -from numpy.typing import ArrayLike - -from ...utils import subsample_array, ArrayProtocol - -from ...graphics import ImageGraphic, LineStack, LineCollection, ScatterGraphic -from ._processor_base import NDProcessor, WindowFuncCallable - - -VALID_TIMESERIES_Y_DATA_SHAPES = ( - "[n_datapoints] for 1D array of y-values, [n_datapoints, 2] " - "for a 1D array of y and z-values, [n_lines, n_datapoints] for a 2D stack of lines with y-values, " - "or [n_lines, n_datapoints, 2] for a stack of lines with y and z-values." -) - - -# Limitation, no heatmap if z-values present, I don't think you can visualize that -class NDTimeSeriesProcessor(NDProcessor): - def __init__( - self, - data: list[ - ArrayProtocol, ArrayProtocol - ], # list: [x_vals_array, y_vals_and_z_vals_array] - x_values: ArrayProtocol = None, - cmap: str = None, - cmap_transform: ArrayProtocol = None, - display_graphic: Literal["line", "heatmap"] = "line", - n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, - display_window: int | float | None = 100, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, - ): - super().__init__( - data=data, - n_display_dims=n_display_dims, - slider_index_maps=slider_index_maps, - ) - - self._display_window = display_window - - self._display_graphic = None - self.display_graphic = display_graphic - - self._uniform_x_values: ArrayProtocol | None = None - self._interp_yz: ArrayProtocol | None = None - - @property - def data(self) -> list[ArrayProtocol, ArrayProtocol]: - return self._data - - @data.setter - def data(self, data: list[ArrayProtocol, ArrayProtocol]): - self._data = self._validate_data(data) - - def _validate_data(self, data: list[ArrayProtocol, ArrayProtocol]): - x_vals, yz_vals = data - - if x_vals.ndim != 1: - raise ("data x values must be 1D") - - if data[1].ndim > 3: - raise ValueError( - f"data yz values must be of shape: {VALID_TIMESERIES_Y_DATA_SHAPES}. You passed data of shape: {yz_vals.shape}" - ) - - return data - - @property - def display_window(self) -> int | float | None: - """display window in the reference units along the x-axis""" - return self._display_window - - @display_window.setter - def display_window(self, dw: int | float | None): - if dw is None: - self._display_window = None - - elif not isinstance(dw, (int, float)): - raise TypeError - - self._display_window = dw - - def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: - if self.display_window is not None: - # map reference units -> array int indices if necessary - if self.slider_index_maps is not None: - indices_window = self.slider_index_maps(self.display_window) - else: - indices_window = self.display_window - - # half window size - hw = indices_window // 2 - - # for now assume just a single index provided that indicates x axis value - start = max(indices - hw, 0) - stop = start + indices_window - - # slice dim would be ndim - 1 - return self.data[0][start:stop], self.data[1][:, start:stop] - - -class NDTimeSeries: - def __init__(self, processor: NDTimeSeriesProcessor, graphic): - self._processor = processor - - self._indices = 0 - - if graphic == "line": - self._create_line_stack() - elif graphic == "heatmap": - self._create_heatmap() - else: - raise ValueError - - @property - def processor(self) -> NDTimeSeriesProcessor: - return self._processor - - @property - def graphic(self) -> LineStack | ImageGraphic: - """LineStack or ImageGraphic for heatmaps""" - return self._graphic - - @graphic.setter - def graphic(self, g: Literal["line", "heatmap"]): - if g == "line": - # TODO: remove existing graphic - self._create_line_stack() - - elif g == "heatmap": - # make sure "yz" data is only ys and no z values - # can't represent y and z vals in a heatmap - if self.processor.data[1].ndim > 2: - raise ValueError( - "Only y-values are supported for heatmaps, not yz-values" - ) - self._create_heatmap() - - @property - def display_window(self) -> int | float | None: - return self.processor.display_window - - @display_window.setter - def display_window(self, dw: int | float | None): - # create new graphic if it changed - if dw != self.display_window: - create_new_graphic = True - else: - create_new_graphic = False - - self.processor.display_window = dw - - if create_new_graphic: - if isinstance(self.graphic, LineStack): - self.set_index(self._indices) - - def set_index(self, indices: tuple[Any, ...]): - # set the graphic at the given data indices - data_slice = self.processor[indices] - - if isinstance(self.graphic, LineStack): - line_stack_data = self._create_line_stack_data(data_slice) - - for g, line_data in zip(self.graphic.graphics, line_stack_data): - if line_data.shape[1] == 2: - # only x and y values - g.data[:, :-1] = line_data - else: - # has z values too - g.data[:] = line_data - - elif isinstance(self.graphic, ImageGraphic): - hm_data, scale = self._create_heatmap_data(data_slice) - self.graphic.data = hm_data - - self._indices = indices - - def _create_line_stack_data(self, data_slice): - xs = data_slice[0] # 1D - yz = data_slice[ - 1 - ] # [n_lines, n_datapoints] for y-vals or [n_lines, n_datapoints, 2] for yz-vals - - # need to go from x_vals and yz_vals arrays to an array of shape: [n_lines, n_datapoints, 2 | 3] - return np.dstack([np.repeat(xs[None], repeats=yz.shape[0], axis=0), yz]) - - def _create_line_stack(self): - data_slice = self.processor[self._indices] - - ls_data = self._create_line_stack_data(data_slice) - - self._graphic = LineStack(ls_data) - - def _create_heatmap_data(self, data_slice) -> tuple[ArrayProtocol, float]: - """Returns [n_lines, y_values] array and scale factor for x dimension""" - # check if x-vals uniformly spaced - # this is very fast to do on the fly, especially for typical small display windows - x, y = data_slice - norm = np.linalg.norm(np.diff(np.diff(x))) / x.size - if norm > 10**-12: - # need to create evenly spaced x-values - x_uniform = np.linspace(x[0], x[-1], num=x.size) - # yz is [n_lines, n_datapoints] - y_interp = np.zeros(shape=y.shape, dtype=np.float32) - for i in range(y.shape[0]): - y_interp[i] = np.interp(x_uniform, x, y[i]) - - else: - y_interp = y - - x_scale = x[-1] / x.size - - return y_interp, x_scale - - def _create_heatmap(self): - data_slice = self.processor[self._indices] - - hm_data, x_scale = self._create_heatmap_data(data_slice) - - self._graphic = ImageGraphic(hm_data) - self._graphic.world_object.world.scale_x = x_scale From 4748e5939350c9bdf12c8312448c9ce9106dcd68 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 4 Feb 2026 12:08:55 -0500 Subject: [PATCH 71/81] auto-replace buffers (#974) * remove isolated_buffer * remove isolated_buffer from mixin * basics works for positions data * replaceable buffers for all positions related features * image data buffer can change * resizeable buffers for volume * black * buffer resize condition checked only if new value is an array * gc for buffer managers * uniform colors WIP * switching color modes works! * typo * balck * update tests for color_mode * update examples * backend tests passing * default for all uniforms is True * update examples * forgot * update test * example tests passing * dereferencing test and fixes * simplify texture array tests a bit * image replace buffer tests pass yay * forgot a file * comments, check image graphic * add image reshaping example * add buffer replace imgui thing for manual testing * black * dont call wgpu_obj.destroy(), seems to work and clear VRAM with normal dereferencing * slower changes * update * update example * fixes and tweaks for test * remove unecessary stuff * update * docstrings * fix example * update example * update example * update docs --- docs/source/api/graphics/LineGraphic.rst | 1 + docs/source/api/graphics/ScatterGraphic.rst | 1 + examples/events/cmap_event.py | 2 +- examples/gridplot/multigraphic_gridplot.py | 2 +- examples/guis/imgui_basic.py | 4 +- examples/image/image_reshaping.py | 50 +++++ examples/line/line_cmap.py | 4 +- examples/line/line_cmap_more.py | 25 ++- examples/line/line_colorslice.py | 4 +- .../line_collection_slicing.py | 1 + examples/machine_learning/kmeans.py | 1 + examples/misc/buffer_replace_gc.py | 91 +++++++++ examples/misc/lorenz_animation.py | 7 +- examples/misc/reshape_lines_scatters.py | 92 +++++++++ examples/misc/scatter_animation.py | 2 +- examples/misc/scatter_sizes_animation.py | 2 +- examples/notebooks/quickstart.ipynb | 4 +- examples/scatter/scatter_iris.py | 1 + examples/scatter/scatter_size.py | 2 +- examples/scatter/scatter_validate.py | 2 + examples/scatter/spinning_spiral.py | 9 +- fastplotlib/graphics/_positions_base.py | 175 ++++++++++++++---- fastplotlib/graphics/features/_base.py | 54 +++--- fastplotlib/graphics/features/_image.py | 12 +- fastplotlib/graphics/features/_mesh.py | 8 +- fastplotlib/graphics/features/_positions.py | 99 ++++++++-- fastplotlib/graphics/features/_scatter.py | 134 +++++++++----- fastplotlib/graphics/features/_vectors.py | 2 - fastplotlib/graphics/features/_volume.py | 12 +- fastplotlib/graphics/image.py | 69 +++++-- fastplotlib/graphics/image_volume.py | 38 +++- fastplotlib/graphics/line.py | 25 +-- fastplotlib/graphics/line_collection.py | 11 +- fastplotlib/graphics/mesh.py | 17 +- fastplotlib/graphics/scatter.py | 88 ++++----- fastplotlib/layouts/_graphic_methods_mixin.py | 141 ++++++-------- tests/test_colors_buffer_manager.py | 12 +- tests/test_markers_buffer_manager.py | 8 +- tests/test_point_rotations_buffer_manager.py | 2 +- tests/test_positions_data_buffer_manager.py | 2 +- tests/test_positions_graphics.py | 55 +++--- tests/test_replace_buffer.py | 155 ++++++++++++++++ tests/test_scatter_graphic.py | 2 +- tests/test_texture_array.py | 134 ++++++-------- tests/utils_textures.py | 64 +++++++ 45 files changed, 1160 insertions(+), 466 deletions(-) create mode 100644 examples/image/image_reshaping.py create mode 100644 examples/misc/buffer_replace_gc.py create mode 100644 examples/misc/reshape_lines_scatters.py create mode 100644 tests/test_replace_buffer.py create mode 100644 tests/utils_textures.py diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 428e8ef56..867f1bfbb 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -25,6 +25,7 @@ Properties LineGraphic.axes LineGraphic.block_events LineGraphic.cmap + LineGraphic.color_mode LineGraphic.colors LineGraphic.data LineGraphic.deleted diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index cf8e1224d..f9dcd2487 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -25,6 +25,7 @@ Properties ScatterGraphic.axes ScatterGraphic.block_events ScatterGraphic.cmap + ScatterGraphic.color_mode ScatterGraphic.colors ScatterGraphic.data ScatterGraphic.deleted diff --git a/examples/events/cmap_event.py b/examples/events/cmap_event.py index 62913cb29..f01f06d6a 100644 --- a/examples/events/cmap_event.py +++ b/examples/events/cmap_event.py @@ -34,7 +34,7 @@ xs = np.linspace(0, 4 * np.pi, 100) ys = np.sin(xs) -figure["sine"].add_line(np.column_stack([xs, ys])) +figure["sine"].add_line(np.column_stack([xs, ys]), color_mode="vertex") # make a 2D gaussian cloud cloud_data = np.random.normal(0, scale=3, size=1000).reshape(500, 2) diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py index cbf546e2a..0e89efcdc 100644 --- a/examples/gridplot/multigraphic_gridplot.py +++ b/examples/gridplot/multigraphic_gridplot.py @@ -106,7 +106,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: gaussian_cloud2 = np.random.multivariate_normal(mean, covariance, n_points) # add the scatter graphics to the figure -figure["scatter"].add_scatter(data=gaussian_cloud, sizes=2, cmap="jet") +figure["scatter"].add_scatter(data=gaussian_cloud, sizes=2, cmap="jet", color_mode="vertex") figure["scatter"].add_scatter(data=gaussian_cloud2, colors="r", sizes=2) figure.show() diff --git a/examples/guis/imgui_basic.py b/examples/guis/imgui_basic.py index 26b5603c0..26c2c0fca 100644 --- a/examples/guis/imgui_basic.py +++ b/examples/guis/imgui_basic.py @@ -29,10 +29,10 @@ figure = fpl.Figure(size=(700, 560)) # make some scatter points at every 10th point -figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", uniform_color=True) +figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter") # place a line above the scatter -figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", uniform_color=True) +figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave") class ImguiExample(EdgeWindow): diff --git a/examples/image/image_reshaping.py b/examples/image/image_reshaping.py new file mode 100644 index 000000000..23264bda1 --- /dev/null +++ b/examples/image/image_reshaping.py @@ -0,0 +1,50 @@ +""" +Image reshaping +=============== + +An example that shows replacement of the image data with new data of a different shape. Under the hood, this creates a +new buffer and a new array of Textures on the GPU that replace the older Textures. Creating a new buffer and textures +has a performance cost, so you should do this only if you need to or if the performance drawback is not a concern for +your use case. + +Note that the vmin-vmax is reset when you replace the buffers. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + + +import numpy as np +import fastplotlib as fpl + +# create some data, diagonal sinusoidal bands +xs = np.linspace(0, 2300, 2300, dtype=np.float16) +full_data = np.vstack([np.cos(np.sqrt(xs + (np.pi / 2) * i)) * i for i in range(2_300)]) + +figure = fpl.Figure() + +image = figure[0, 0].add_image(full_data) + +figure.show() + +i, j = 1, 1 + + +def update(): + global i, j + # set the new image data as a subset of the full data + row = np.abs(np.sin(i)) * 2300 + col = np.abs(np.cos(i)) * 2300 + image.data = full_data[: int(row), : int(col)] + + i += 0.01 + j += 0.01 + + +figure.add_animations(update) + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/line/line_cmap.py b/examples/line/line_cmap.py index 3d2b5e8c9..6dfc1fe23 100644 --- a/examples/line/line_cmap.py +++ b/examples/line/line_cmap.py @@ -27,7 +27,7 @@ data=sine_data, thickness=10, cmap="plasma", - cmap_transform=sine_data[:, 1] + cmap_transform=sine_data[:, 1], ) # qualitative colormaps, useful for cluster labels or other types of categorical labels @@ -36,7 +36,7 @@ data=cosine_data, thickness=10, cmap="tab10", - cmap_transform=labels + cmap_transform=labels, ) figure.show() diff --git a/examples/line/line_cmap_more.py b/examples/line/line_cmap_more.py index c7c0d80f4..c6e811fb2 100644 --- a/examples/line/line_cmap_more.py +++ b/examples/line/line_cmap_more.py @@ -31,16 +31,35 @@ # set colormap by mapping data using a transform # here we map the color using the y-values of the sine data # i.e., the color is a function of sine(x) -line2 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", cmap_transform=sine[:, 1], offset=(0, 4, 0)) +line2 = figure[0, 0].add_line( + sine, + thickness=10, + cmap="jet", + cmap_transform=sine[:, 1], + offset=(0, 4, 0), +) # make a line and change the cmap afterward, here we are using the cosine instead fot the transform -line3 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", cmap_transform=cosine[:, 1], offset=(0, 6, 0)) +line3 = figure[0, 0].add_line( + sine, + thickness=10, + cmap="jet", + cmap_transform=cosine[:, 1], + offset=(0, 6, 0) +) + # change the cmap line3.cmap = "bwr" # use quantitative colormaps with categorical cmap_transforms labels = [0] * 25 + [1] * 5 + [2] * 50 + [3] * 20 -line4 = figure[0, 0].add_line(sine, thickness=10, cmap="tab10", cmap_transform=labels, offset=(0, 8, 0)) +line4 = figure[0, 0].add_line( + sine, + thickness=10, + cmap="tab10", + cmap_transform=labels, + offset=(0, 8, 0), +) # some text labels for i in range(5): diff --git a/examples/line/line_colorslice.py b/examples/line/line_colorslice.py index b6865eadb..264f944f3 100644 --- a/examples/line/line_colorslice.py +++ b/examples/line/line_colorslice.py @@ -30,7 +30,8 @@ sine = figure[0, 0].add_line( data=sine_data, thickness=5, - colors="magenta" + colors="magenta", + color_mode="vertex", # initialize with same color across vertices, but we will change the per-vertex colors later ) # you can also use colormaps for lines! @@ -56,6 +57,7 @@ data=zeros_data, thickness=8, colors="w", + color_mode="vertex", # initialize with same color across vertices, but we will change the per-vertex colors later offset=(0, 10, 0) ) diff --git a/examples/line_collection/line_collection_slicing.py b/examples/line_collection/line_collection_slicing.py index f829a53c6..98ad97056 100644 --- a/examples/line_collection/line_collection_slicing.py +++ b/examples/line_collection/line_collection_slicing.py @@ -26,6 +26,7 @@ multi_data, thickness=[2, 10, 2, 5, 5, 5, 8, 8, 8, 9, 3, 3, 3, 4, 4], separation=4, + color_mode="vertex", # this will allow us to set per-vertex colors on each line metadatas=list(range(15)), # some metadata names=list("abcdefghijklmno"), # unique name for each line ) diff --git a/examples/machine_learning/kmeans.py b/examples/machine_learning/kmeans.py index f571882ce..4c49844f0 100644 --- a/examples/machine_learning/kmeans.py +++ b/examples/machine_learning/kmeans.py @@ -80,6 +80,7 @@ sizes=5, cmap="tab10", # use a qualitative cmap cmap_transform=kmeans.labels_, # color by the predicted cluster + uniform_size=False, ) # initial index diff --git a/examples/misc/buffer_replace_gc.py b/examples/misc/buffer_replace_gc.py new file mode 100644 index 000000000..e3b0ac104 --- /dev/null +++ b/examples/misc/buffer_replace_gc.py @@ -0,0 +1,91 @@ +""" +Buffer replacement garbage collection test +========================================== + +This is an example that used for a manual test to ensure that GPU VRAM is free when buffers are replaced. + +Use while monitoring VRAM usage with nvidia-smi +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'code' + + +from typing import Literal +import numpy as np +import fastplotlib as fpl +from fastplotlib.ui import EdgeWindow +from imgui_bundle import imgui + + +def generate_dataset(size: int) -> dict[str, np.ndarray]: + return { + "data": np.random.rand(size, 3), + "colors": np.random.rand(size, 4), + # TODO: there's a wgpu bind group issue with edge_colors, will figure out later + # "edge_colors": np.random.rand(size, 4), + "markers": np.random.choice(list("osD+x^v<>*"), size=size), + "sizes": np.random.rand(size) * 5, + "point_rotations": np.random.rand(size) * 180, + } + + +datasets = { + "init": generate_dataset(50_000), + "small": generate_dataset(100), + "large": generate_dataset(5_000_000), +} + + +class UI(EdgeWindow): + def __init__(self, figure): + super().__init__(figure=figure, size=200, location="right", title="UI") + init_data = datasets["init"] + self._figure["line"].add_line( + data=init_data["data"], colors=init_data["colors"], name="line" + ) + self._figure["scatter"].add_scatter( + **init_data, + uniform_size=False, + uniform_marker=False, + uniform_edge_color=False, + point_rotation_mode="vertex", + name="scatter", + ) + + def update(self): + for graphic in ["line", "scatter"]: + if graphic == "line": + features = ["data", "colors"] + + elif graphic == "scatter": + features = list(datasets["init"].keys()) + + for size in ["small", "large"]: + for fea in features: + if imgui.button(f"{size} - {graphic} - {fea}"): + self._replace(graphic, fea, size) + + def _replace( + self, + graphic: Literal["line", "scatter", "image"], + feature: Literal["data", "colors", "markers", "sizes", "point_rotations"], + size: Literal["small", "large"], + ): + new_value = datasets[size][feature] + + setattr(self._figure[graphic][graphic], feature, new_value) + + +figure = fpl.Figure(shape=(3, 1), size=(700, 1600), names=["line", "scatter", "image"]) +ui = UI(figure) +figure.add_gui(ui) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/misc/lorenz_animation.py b/examples/misc/lorenz_animation.py index 20aee5d83..52a77a243 100644 --- a/examples/misc/lorenz_animation.py +++ b/examples/misc/lorenz_animation.py @@ -60,7 +60,12 @@ def lorenz(xyz, *, s=10, r=28, b=2.667): scatter_markers = list() for graphic in lorenz_line: - marker = figure[0, 0].add_scatter(graphic.data.value[0], sizes=16, colors=graphic.colors[0]) + marker = figure[0, 0].add_scatter( + graphic.data.value[0], + sizes=16, + colors=graphic.colors, + edge_colors="w", + ) scatter_markers.append(marker) # initialize time diff --git a/examples/misc/reshape_lines_scatters.py b/examples/misc/reshape_lines_scatters.py new file mode 100644 index 000000000..db8adb29e --- /dev/null +++ b/examples/misc/reshape_lines_scatters.py @@ -0,0 +1,92 @@ +""" +Change number of points in lines and scatters +============================================= + +This example sets lines and scatters with new data of a different shape, i.e. new data with more or fewer datapoints. +Internally, this creates new buffers for the feature that is being set (data, colors, markers, etc.). Note that there +are performance drawbacks to doing this, so it is recommended to maintain the same number of datapoints in a graphic +when possible. You only want to change the number of datapoints when it's really necessary, and you don't want to do +it constantly (such as tens or hundreds of times per second). + +This example is also useful for manually checking that GPU buffers are freed when they're no longer in use. Run this +example while monitoring VRAM usage with `nvidia-smi` +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + + +import numpy as np +import fastplotlib as fpl + +# create some data to start with +xs = np.linspace(0, 10 * np.pi, 100) +ys = np.sin(xs) + +data = np.column_stack([xs, ys]) + +# create a figure, add a line, scatter and line_stack +figure = fpl.Figure(shape=(3, 1), size=(700, 700)) + +line = figure[0, 0].add_line(data) + +scatter = figure[1, 0].add_scatter( + np.random.rand(100, 3), + colors=np.random.rand(100, 4), + markers=np.random.choice(list("osD+x^v<>*"), size=100), + sizes=(np.random.rand(100) + 1) * 3, + edge_colors=np.random.rand(100, 4), + point_rotations=np.random.rand(100) * 180, + uniform_marker=False, + uniform_size=False, + uniform_edge_color=False, + point_rotation_mode="vertex", +) + +line_stack = figure[2, 0].add_line_stack(np.stack([data] * 10), cmap="viridis") + +text = figure[0, 0].add_text(f"n_points: {100}", offset=(0, 1.5, 0), anchor="middle-left") + +figure.show(maintain_aspect=False) + +i = 0 + + +def update(): + # set a new larger or smaller data array on every render + global i + + # create new data + freq = np.abs(np.sin(i)) * 10 + n_points = int((freq * 20_000) + 10) + + xs = np.linspace(0, 10 * np.pi, n_points) + ys = np.sin(xs * freq) + + new_data = np.column_stack([xs, ys]) + + # update line data + line.data = new_data + + # update scatter data, colors, markers, etc. + scatter.data = np.random.rand(n_points, 3) + scatter.colors = np.random.rand(n_points, 4) + scatter.markers = np.random.choice(list("osD+x^v<>*"), size=n_points) + scatter.edge_colors = np.random.rand(n_points, 4) + scatter.point_rotations = np.random.rand(n_points) * 180 + + # update line stack data + line_stack.data = np.stack([new_data] * 10) + + text.text = f"n_points: {n_points}" + + i += 0.01 + + +figure.add_animations(update) + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/misc/scatter_animation.py b/examples/misc/scatter_animation.py index d37aea976..549059b65 100644 --- a/examples/misc/scatter_animation.py +++ b/examples/misc/scatter_animation.py @@ -37,7 +37,7 @@ figure = fpl.Figure(size=(700, 560)) subplot_scatter = figure[0, 0] # use an alpha value since this will be a lot of points -scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) +scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, uniform_size=False, colors=colors, alpha=0.6) def update_points(subplot): diff --git a/examples/misc/scatter_sizes_animation.py b/examples/misc/scatter_sizes_animation.py index 53a616a68..2092787f3 100644 --- a/examples/misc/scatter_sizes_animation.py +++ b/examples/misc/scatter_sizes_animation.py @@ -20,7 +20,7 @@ figure = fpl.Figure(size=(700, 560)) -figure[0, 0].add_scatter(data, sizes=sizes, name="sine") +figure[0, 0].add_scatter(data, sizes=sizes, uniform_size=False, name="sine") i = 0 diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb index 7b7551588..61bcb6b06 100644 --- a/examples/notebooks/quickstart.ipynb +++ b/examples/notebooks/quickstart.ipynb @@ -719,8 +719,8 @@ "# we will add all the lines to the same subplot\n", "subplot = fig_lines[0, 0]\n", "\n", - "# plot sine wave, use a single color\n", - "sine = subplot.add_line(data=sine_data, thickness=5, colors=\"magenta\")\n", + "# plot sine wave, use a single color for now, but we will set per-vertex colors later\n", + "sine = subplot.add_line(data=sine_data, thickness=5, colors=\"magenta\", color_mode=\"vertex\")\n", "\n", "# you can also use colormaps for lines!\n", "cosine = subplot.add_line(data=cosine_data, thickness=12, cmap=\"autumn\")\n", diff --git a/examples/scatter/scatter_iris.py b/examples/scatter/scatter_iris.py index b9df16026..fc228e5bf 100644 --- a/examples/scatter/scatter_iris.py +++ b/examples/scatter/scatter_iris.py @@ -35,6 +35,7 @@ cmap="tab10", cmap_transform=clusters_labels, markers=markers, + uniform_marker=False, ) figure.show() diff --git a/examples/scatter/scatter_size.py b/examples/scatter/scatter_size.py index 30d3e6ea3..2b3899dbe 100644 --- a/examples/scatter/scatter_size.py +++ b/examples/scatter/scatter_size.py @@ -35,7 +35,7 @@ ) # add a set of scalar sizes non_scalar_sizes = np.abs((y_values / np.pi)) # ensure minimum size of 5 -figure["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, colors="red") +figure["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, uniform_size=False, colors="red") for graph in figure: graph.auto_scale(maintain_aspect=True) diff --git a/examples/scatter/scatter_validate.py b/examples/scatter/scatter_validate.py index abddffee0..45f0a177c 100644 --- a/examples/scatter/scatter_validate.py +++ b/examples/scatter/scatter_validate.py @@ -41,6 +41,7 @@ uniform_edge_color=False, edge_colors=["w"] * 3 + ["orange"] * 3 + ["blue"] * 3 + ["green"], markers=list("osD+x^v<>*"), + uniform_marker=False, edge_width=2.0, sizes=20, uniform_size=True, @@ -64,6 +65,7 @@ sine, markers="s", sizes=xs * 5, + uniform_size=False, offset=(0, 2, 0) ) diff --git a/examples/scatter/spinning_spiral.py b/examples/scatter/spinning_spiral.py index 89e74eaec..4f947970a 100644 --- a/examples/scatter/spinning_spiral.py +++ b/examples/scatter/spinning_spiral.py @@ -34,7 +34,14 @@ canvas_kwargs={"max_fps": 500, "vsync": False} ) -spiral = figure[0, 0].add_scatter(data, cmap="viridis_r", edge_colors=None, alpha=0.5, sizes=sizes) +spiral = figure[0, 0].add_scatter( + data, + cmap="viridis_r", + edge_colors=None, + alpha=0.5, + sizes=sizes, + uniform_size=False, +) # pre-generate normally distributed data to jitter the points before each render jitter = np.random.normal(scale=0.001, size=n * 3).reshape((n, 3)) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index af7d7badb..763f5e775 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -1,4 +1,6 @@ -from typing import Any, Sequence +from numbers import Real +from typing import Any, Sequence, Literal +from warnings import warn import numpy as np @@ -18,12 +20,20 @@ class PositionsGraphic(Graphic): @property def data(self) -> VertexPositions: - """Get or set the graphic's data""" + """ + Get or set the graphic's data. + + Note that if the number of datapoints does not match the number of + current datapoints a new buffer is automatically allocated. This can + have performance drawbacks when you have a very large number of datapoints. + This is usually fine as long as you don't need to do it hundreds of times + per second. + """ return self._data @data.setter def data(self, value): - self._data[:] = value + self._data.set_value(self, value) @property def colors(self) -> VertexColors | pygfx.Color: @@ -36,11 +46,59 @@ def colors(self) -> VertexColors | pygfx.Color: @colors.setter def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): + self._colors.set_value(self, value) + + @property + def color_mode(self) -> Literal["uniform", "vertex"]: + """ + Get or set the color mode. Note that after setting the color_mode, you will have to set the `colors` + as well for switching between 'uniform' and 'vertex' modes. + """ + return self.world_object.material.color_mode + + @color_mode.setter + def color_mode(self, mode: Literal["uniform", "vertex"]): + valid = ("uniform", "vertex") + if mode not in valid: + raise ValueError(f"`color_mode` must be one of : {valid}") + if mode == "vertex" and isinstance(self._colors, UniformColor): + # uniform -> vertex + # need to make a new vertex buffer and get rid of uniform buffer + new_colors = self._create_colors_buffer(self._colors.value, "vertex") + # we can't clear world_object.material.color so just set the colors buffer on the geometry + # this doesn't really matter anyways since the lingering uniform color takes up just a few bytes + self.world_object.geometry.colors = new_colors._fpl_buffer + + elif mode == "uniform" and isinstance(self._colors, VertexColors): + # vertex -> uniform + # use first vertex color and spit out a warning + warn( + "changing `color_mode` from vertex -> uniform, will use first vertex color " + "for the uniform and discard the remaining color values" + ) + new_colors = self._create_colors_buffer(self._colors.value[0], "uniform") + self.world_object.geometry.colors = None + self.world_object.material.color = new_colors.value + + # clear out cmap + self._cmap.clear_event_handlers() + self._cmap = None + + else: + # no change, return + return + + # restore event handlers onto the new colors feature + new_colors._event_handlers[:] = self._colors._event_handlers + self._colors.clear_event_handlers() + # this should trigger gc + self._colors = new_colors + + # this is created so that cmap can be set later if isinstance(self._colors, VertexColors): - self._colors[:] = value + self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) - elif isinstance(self._colors, UniformColor): - self._colors.set_value(self, value) + self.world_object.material.color_mode = mode @property def cmap(self) -> VertexCmap: @@ -53,8 +111,8 @@ def cmap(self) -> VertexCmap: @cmap.setter def cmap(self, name: str): - if self._cmap is None: - raise BufferError("Cannot use cmap with uniform_colors=True") + if self.color_mode == "uniform": + raise ValueError("cannot use `cmap` with `color_mode` = 'uniform'") self._cmap[:] = name @@ -71,14 +129,68 @@ def size_space(self): def size_space(self, value: str): self._size_space.set_value(self, value) + def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColors: + # creates either a UniformColor or VertexColors based on the given `colors` and `color_mode` + # if `color_mode` = "auto", returns {UniformColor | VertexColor} based on what the `colors` arg represents + # if `color_mode` = "uniform", it verifies that the user `colors` input represents just 1 color + # if `color_mode` = "vertex", always returns VertexColors regardless of whether `colors` represents >= 1 colors + + if isinstance(colors, VertexColors): + if color_mode == "uniform": + raise ValueError( + "if a `VertexColors` instance is provided for `colors`, " + "`color_mode` must be 'vertex' or 'auto', not 'uniform'" + ) + # share buffer with existing colors instance + new_colors = colors + # blank colormap instance + self._cmap = VertexCmap(new_colors, cmap_name=None, transform=None) + + else: + # determine if a single or multiple colors were passed and decide color mode + if isinstance(colors, (pygfx.Color, str)) or ( + len(colors) in [3, 4] and all(isinstance(v, Real) for v in colors) + ): + # one color specified as a str or pygfx.Color, or one color specified with RGB(A) values + if color_mode in ("auto", "uniform"): + new_colors = UniformColor(colors) + else: + new_colors = VertexColors( + colors, n_colors=self._data.value.shape[0] + ) + + elif all(isinstance(c, (str, pygfx.Color)) for c in colors): + # sequence of colors + if color_mode == "uniform": + raise ValueError( + "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " + "`color_mode` = 'auto' or 'vertex' for multiple colors." + ) + new_colors = VertexColors(colors, n_colors=self._data.value.shape[0]) + + elif len(colors) > 4: + # sequence of multiple colors, must again ensure color_mode is not uniform + if color_mode == "uniform": + raise ValueError( + "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " + "`color_mode` = 'auto' or 'vertex' for multiple colors." + ) + new_colors = VertexColors(colors, n_colors=self._data.value.shape[0]) + else: + raise ValueError( + "`colors` must be a str, pygfx.Color, array, list or tuple indicating an RGB(A) color, or a " + "sequence of str, pygfx.Color, or array of shape [n_datapoints, 3 | 4]" + ) + + return new_colors + def __init__( self, data: Any, colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", - uniform_color: bool = False, cmap: str | VertexCmap = None, cmap_transform: np.ndarray = None, - isolated_buffer: bool = True, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", *args, **kwargs, @@ -86,22 +198,31 @@ def __init__( if isinstance(data, VertexPositions): self._data = data else: - self._data = VertexPositions(data, isolated_buffer=isolated_buffer) + self._data = VertexPositions(data) if cmap_transform is not None and cmap is None: raise ValueError("must pass `cmap` if passing `cmap_transform`") + valid = ("auto", "uniform", "vertex") + + # default _cmap is None + self._cmap = None + + if color_mode not in valid: + raise ValueError(f"`color_mode` must be one of {valid}") + if cmap is not None: # if a cmap is specified it overrides colors argument - if uniform_color: - raise TypeError("Cannot use cmap if uniform_color=True") + if color_mode == "uniform": + raise ValueError( + "if a `cmap` is provided, `color_mode` must be 'vertex' or 'auto', not 'uniform'" + ) if isinstance(cmap, str): # make colors from cmap if isinstance(colors, VertexColors): # share buffer with existing colors instance for the cmap self._colors = colors - self._colors._shared += 1 else: # create vertex colors buffer self._colors = VertexColors("w", n_colors=self._data.value.shape[0]) @@ -115,34 +236,18 @@ def __init__( # use existing cmap instance self._cmap = cmap self._colors = cmap._vertex_colors + else: raise TypeError( "`cmap` argument must be a cmap name or an existing `VertexCmap` instance" ) else: # no cmap given - if isinstance(colors, VertexColors): - # share buffer with existing colors instance - self._colors = colors - self._colors._shared += 1 - # blank colormap instance + self._colors = self._create_colors_buffer(colors, color_mode) + + # this is created so that cmap can be set later + if isinstance(self._colors, VertexColors): self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) - else: - if uniform_color: - if not isinstance(colors, str): # not a single color - if not len(colors) in [3, 4]: # not an RGB(A) array - raise TypeError( - "must pass a single color if using `uniform_colors=True`" - ) - self._colors = UniformColor(colors) - self._cmap = None - else: - self._colors = VertexColors( - colors, n_colors=self._data.value.shape[0] - ) - self._cmap = VertexCmap( - self._colors, cmap_name=None, transform=None - ) self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 779310476..76352b4ef 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -1,5 +1,6 @@ +import weakref from warnings import warn -from typing import Literal +from typing import Callable import numpy as np from numpy.typing import NDArray @@ -78,7 +79,7 @@ def block_events(self, val: bool): """ self._block_events = val - def add_event_handler(self, handler: callable): + def add_event_handler(self, handler: Callable): """ Add an event handler. All added event handlers are called when this feature changes. @@ -89,7 +90,7 @@ def add_event_handler(self, handler: callable): Parameters ---------- - handler: callable + handler: Callable a function to call when this feature changes """ @@ -102,7 +103,7 @@ def add_event_handler(self, handler: callable): self._event_handlers.append(handler) - def remove_event_handler(self, handler: callable): + def remove_event_handler(self, handler: Callable): """ Remove a registered event ``handler``. @@ -137,32 +138,28 @@ class BufferManager(GraphicFeature): def __init__( self, - data: NDArray | pygfx.Buffer, - buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", - isolated_buffer: bool = True, + data: NDArray | pygfx.Buffer | None, **kwargs, ): super().__init__(**kwargs) - if isolated_buffer and not isinstance(data, pygfx.Resource): - # useful if data is read-only, example: memmaps - bdata = np.zeros(data.shape, dtype=data.dtype) - bdata[:] = data[:] - else: - # user's input array is used as the buffer - bdata = data - - if isinstance(data, pygfx.Resource): - # already a buffer, probably used for - # managing another BufferManager, example: VertexCmap manages VertexColors - self._buffer = data - elif buffer_type == "buffer": - self._buffer = pygfx.Buffer(bdata) + + # if data is None, then the BufferManager just provides a view into an existing buffer + # example: VertexCmap is basically a view into VertexColors + if data is not None: + if isinstance(data, pygfx.Resource): + # already a buffer, probably used for + # managing another BufferManager, example: VertexCmap manages VertexColors + self._fpl_buffer = data + else: + # create a buffer + bdata = np.empty(data.shape, dtype=data.dtype) + bdata[:] = data[:] + + self._fpl_buffer = pygfx.Buffer(bdata) else: - raise ValueError( - "`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'" - ) + self._fpl_buffer = None - self._event_handlers: list[callable] = list() + self._event_handlers: list[Callable] = list() @property def value(self) -> np.ndarray: @@ -174,9 +171,10 @@ def set_value(self, graphic, value): self[:] = value @property - def buffer(self) -> pygfx.Buffer | pygfx.Texture: - """managed buffer""" - return self._buffer + def buffer(self) -> pygfx.Buffer: + """managed buffer, returns a weakref proxy""" + # the user should never create their own references to the buffer + return weakref.proxy(self._fpl_buffer) @property def __array_interface__(self): diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 648f79bc8..cb66bb1ef 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -33,7 +33,7 @@ class TextureArray(GraphicFeature): }, ] - def __init__(self, data, isolated_buffer: bool = True, property_name: str = "data"): + def __init__(self, data, property_name: str = "data"): super().__init__(property_name=property_name) data = self._fix_data(data) @@ -41,13 +41,9 @@ def __init__(self, data, isolated_buffer: bool = True, property_name: str = "dat shared = pygfx.renderers.wgpu.get_shared() self._texture_limit_2d = shared.device.limits["max-texture-dimension-2d"] - if isolated_buffer: - # useful if data is read-only, example: memmaps - self._value = np.zeros(data.shape, dtype=data.dtype) - self.value[:] = data[:] - else: - # user's input array is used as the buffer - self._value = data + # create a new buffer + self._value = np.zeros(data.shape, dtype=data.dtype) + self.value[:] = data[:] # data start indices for each Texture self._row_indices = np.arange( diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 7355acb4e..776d77ce4 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -51,18 +51,14 @@ class MeshIndices(VertexPositions): }, ] - def __init__( - self, data: Any, isolated_buffer: bool = True, property_name: str = "indices" - ): + def __init__(self, data: Any, property_name: str = "indices"): """ Manages the vertex indices buffer shown in the graphic. Supports fancy indexing if the data array also supports it. """ data = self._fix_data(data) - super().__init__( - data, isolated_buffer=isolated_buffer, property_name=property_name - ) + super().__init__(data, property_name=property_name) def _fix_data(self, data): if data.ndim != 2 or data.shape[1] not in (3, 4): diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 295d22417..7b67e6bd7 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -39,7 +39,6 @@ def __init__( self, colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], n_colors: int, - isolated_buffer: bool = True, property_name: str = "colors", ): """ @@ -57,9 +56,56 @@ def __init__( """ data = parse_colors(colors, n_colors) - super().__init__( - data=data, isolated_buffer=isolated_buffer, property_name=property_name - ) + super().__init__(data=data, property_name=property_name) + + def set_value( + self, + graphic, + value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], + ): + """set the entire array, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + # TODO: Refactor this triage so it's more elegant + + # first make sure it's not representing one color + skip = False + if isinstance(value, np.ndarray): + if (value.shape in ((3,), (4,))) and ( + np.issubdtype(value.dtype, np.floating) + or np.issubdtype(value.dtype, np.integer) + ): + # represents one color + skip = True + elif isinstance(value, (list, tuple)): + if len(value) in (3, 4) and all( + [isinstance(v, (float, int)) for v in value] + ): + # represents one color + skip = True + + # check if the number of elements matches current buffer size + if not skip and self.buffer.data.shape[0] != len(value): + # parse the new colors + new_colors = parse_colors(value, len(value)) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(new_colors) + graphic.world_object.geometry.colors = self._fpl_buffer + + if len(self._event_handlers) < 1: + return + + event_info = { + "key": slice(None), + "value": new_colors, + "user_value": value, + } + + event = GraphicFeatureEvent(self._property_name, info=event_info) + self._call_event_handlers(event) + return + + self[:] = value @block_reentrance def __setitem__( @@ -231,18 +277,14 @@ class VertexPositions(BufferManager): }, ] - def __init__( - self, data: Any, isolated_buffer: bool = True, property_name: str = "data" - ): + def __init__(self, data: Any, property_name: str = "data"): """ Manages the vertex positions buffer shown in the graphic. Supports fancy indexing if the data array also supports it. """ data = self._fix_data(data) - super().__init__( - data, isolated_buffer=isolated_buffer, property_name=property_name - ) + super().__init__(data, property_name=property_name) def _fix_data(self, data): if data.ndim == 1: @@ -261,13 +303,42 @@ def _fix_data(self, data): return to_gpu_supported_dtype(data) + def set_value(self, graphic, value): + """Sets the entire array, creates new buffer if necessary""" + if isinstance(value, np.ndarray): + if self.buffer.data.shape[0] != value.shape[0]: + # number of items doesn't match, create a new buffer + + # if data is not 3D + if value.ndim == 1: + # _fix_data creates a new array so we don't need to re-allocate with np.zeros + bdata = self._fix_data(value) + + elif value.shape[1] == 2: + # _fix_data creates a new array so we don't need to re-allocate with np.zeros + bdata = self._fix_data(value) + + elif value.shape[1] == 3: + # need to allocate a buffer to use here + bdata = np.empty(value.shape, dtype=np.float32) + bdata[:] = value[:] + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(bdata) + graphic.world_object.geometry.positions = self._fpl_buffer + + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value + @block_reentrance def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value: np.ndarray | float | list[float], ): - # directly use the key to slice the buffer + # directly use the key to slice the buffer and set the values self.buffer.data[key] = value # _update_range handles parsing the key to @@ -306,7 +377,7 @@ def __init__( provides a way to set colormaps with arbitrary transforms """ - super().__init__(data=vertex_colors.buffer, property_name=property_name) + super().__init__(data=None, property_name=property_name) self._vertex_colors = vertex_colors self._cmap_name = cmap_name @@ -331,6 +402,10 @@ def __init__( # set vertex colors from cmap self._vertex_colors[:] = colors + @property + def buffer(self) -> pygfx.Buffer: + return self._vertex_colors.buffer + @block_reentrance def __setitem__(self, key: slice, cmap_name): if not isinstance(key, slice): diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index 16671ef89..36c8527be 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -100,6 +100,37 @@ def searchsorted_markers_to_int_array(markers_str_array: np.ndarray[str]): return marker_int_searchsorted_vals[indices] +def parse_markers_init(markers: str | Sequence[str] | np.ndarray, n_datapoints: int): + # first validate then allocate buffers + + if isinstance(markers, str): + markers = user_input_to_marker(markers) + + elif isinstance(markers, (tuple, list, np.ndarray)): + validate_user_markers_array(markers) + + # allocate buffers + markers_int_array = np.zeros(n_datapoints, dtype=np.int32) + + marker_str_length = max(map(len, list(pygfx.MarkerShape))) + + markers_readable_array = np.empty(n_datapoints, dtype=f" np.ndarray[str]: @@ -200,6 +200,25 @@ def _set_markers_arrays(self, key, value, n_markers): "new markers value must be a str, Sequence or np.ndarray of new marker values" ) + def set_value(self, graphic, value): + """set all the markers, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + if self.buffer.data.shape[0] != len(value): + # need to create a new buffer + markers_int_array, self._markers_readable_array = parse_markers_init( + value, len(value) + ) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(markers_int_array) + graphic.world_object.geometry.markers = self._fpl_buffer + + self._emit_event(self._property_name, key=slice(None), value=value) + + return + + self[:] = value + @block_reentrance def __setitem__( self, @@ -414,18 +433,15 @@ def __init__( self, rotations: int | float | np.ndarray | Sequence[int | float], n_datapoints: int, - isolated_buffer: bool = True, property_name: str = "point_rotations", ): """ Manages rotations buffer of scatter points. """ - sizes = self._fix_sizes(rotations, n_datapoints) - super().__init__( - data=sizes, isolated_buffer=isolated_buffer, property_name=property_name - ) + sizes = self._fix_rotations(rotations, n_datapoints) + super().__init__(data=sizes, property_name=property_name) - def _fix_sizes( + def _fix_rotations( self, sizes: int | float | np.ndarray | Sequence[int | float], n_datapoints: int, @@ -454,6 +470,22 @@ def _fix_sizes( return sizes + def set_value(self, graphic, value): + """set all rotations, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + if self.buffer.data.shape[0] != value.shape[0]: + # need to create a new buffer + value = self._fix_rotations(value, len(value)) + data = np.empty(shape=(len(value),), dtype=np.float32) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(data) + graphic.world_object.geometry.rotations = self._fpl_buffer + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value + @block_reentrance def __setitem__( self, @@ -488,16 +520,13 @@ def __init__( self, sizes: int | float | np.ndarray | Sequence[int | float], n_datapoints: int, - isolated_buffer: bool = True, property_name: str = "sizes", ): """ Manages sizes buffer of scatter points. """ sizes = self._fix_sizes(sizes, n_datapoints) - super().__init__( - data=sizes, isolated_buffer=isolated_buffer, property_name=property_name - ) + super().__init__(data=sizes, property_name=property_name) def _fix_sizes( self, @@ -533,6 +562,23 @@ def _fix_sizes( return sizes + def set_value(self, graphic, value): + """set all sizes, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + if self.buffer.data.shape[0] != len(value): + # create new buffer + value = self._fix_sizes(value, len(value)) + data = np.empty(shape=(len(value),), dtype=np.float32) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(data) + graphic.world_object.geometry.sizes = self._fpl_buffer + + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value + @block_reentrance def __setitem__( self, diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 9c86d25fc..729562b06 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -22,7 +22,6 @@ class VectorPositions(GraphicFeature): def __init__( self, positions: np.ndarray, - isolated_buffer: bool = True, property_name: str = "positions", ): """ @@ -111,7 +110,6 @@ class VectorDirections(GraphicFeature): def __init__( self, directions: np.ndarray, - isolated_buffer: bool = True, property_name: str = "directions", ): """Manages vector field positions by managing the mesh instance buffer's full transform matrix""" diff --git a/fastplotlib/graphics/features/_volume.py b/fastplotlib/graphics/features/_volume.py index ec4c4052a..532065fb7 100644 --- a/fastplotlib/graphics/features/_volume.py +++ b/fastplotlib/graphics/features/_volume.py @@ -34,7 +34,7 @@ class TextureArrayVolume(GraphicFeature): }, ] - def __init__(self, data, isolated_buffer: bool = True): + def __init__(self, data): super().__init__(property_name="data") data = self._fix_data(data) @@ -43,13 +43,9 @@ def __init__(self, data, isolated_buffer: bool = True): self._texture_size_limit = shared.device.limits["max-texture-dimension-3d"] - if isolated_buffer: - # useful if data is read-only, example: memmaps - self._value = np.zeros(data.shape, dtype=data.dtype) - self.value[:] = data[:] - else: - # user's input array is used as the buffer - self._value = data + # create a new buffer that will be used for the texture data + self._value = np.zeros(data.shape, dtype=data.dtype) + self.value[:] = data[:] # data start indices for each Texture self._row_indices = np.arange( diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 44bffcedc..760b856d2 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,6 +1,7 @@ import math from typing import * +import numpy as np import pygfx from ..utils import quick_min_max @@ -102,7 +103,6 @@ def __init__( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - isolated_buffer: bool = True, **kwargs, ): """ @@ -130,12 +130,6 @@ def __init__( cmap_interpolation: str, optional, default "linear" colormap interpolation method, one of "nearest" or "linear" - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. - kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -143,7 +137,7 @@ def __init__( super().__init__(**kwargs) - world_object = pygfx.Group() + group = pygfx.Group() if isinstance(data, TextureArray): # share buffer @@ -151,7 +145,7 @@ def __init__( else: # create new texture array to manage buffer # texture array that manages the multiple textures on the GPU that represent this image - self._data = TextureArray(data, isolated_buffer=isolated_buffer) + self._data = TextureArray(data) if (vmin is None) or (vmax is None): _vmin, _vmax = quick_min_max(self.data.value) @@ -165,6 +159,7 @@ def __init__( self._vmax = ImageVmax(vmax) self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) # set map to None for RGB images if self._data.value.ndim > 2: @@ -173,7 +168,6 @@ def __init__( else: # use TextureMap for grayscale images self._cmap = ImageCmap(cmap) - self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) _map = pygfx.TextureMap( self._cmap.texture, @@ -189,6 +183,14 @@ def __init__( pick_write=True, ) + # create the _ImageTile world objects, add to group + for tile in self._create_tiles(): + group.add(tile) + + self._set_world_object(group) + + def _create_tiles(self) -> list[_ImageTile]: + tiles = list() # iterate through each texture chunk and create # an _ImageTile, offset the tile using the data indices for texture, chunk_index, data_slice in self._data: @@ -209,17 +211,58 @@ def __init__( img.world.x = data_col_start img.world.y = data_row_start - world_object.add(img) + tiles.append(img) - self._set_world_object(world_object) + return tiles @property def data(self) -> TextureArray: - """Get or set the image data""" + """ + Get or set the image data. + + Note that if the shape of the new data array does not equal the shape of + current data array, a new set of GPU Textures are automatically created. + This can have performance drawbacks when you have a ver large images. + This is usually fine as long as you don't need to do it hundreds of times + per second. + """ return self._data @data.setter def data(self, data): + if isinstance(data, np.ndarray): + # check if a new buffer is required + if self._data.value.shape != data.shape: + # create new TextureArray + self._data = TextureArray(data) + + # cmap based on if rgb or grayscale + if self._data.value.ndim > 2: + self._cmap = None + + # must be None if RGB(A) + self._material.map = None + else: + if self.cmap is None: # have switched from RGBA -> grayscale image + # create default cmap + self._cmap = ImageCmap("plasma") + self._material.map = pygfx.TextureMap( + self._cmap.texture, + filter=self._cmap_interpolation.value, + wrap="clamp-to-edge", + ) + + self._material.clim = quick_min_max(self.data.value) + + # clear image tiles + self.world_object.clear() + + # create new tiles + for tile in self._create_tiles(): + self.world_object.add(tile) + + return + self._data[:] = data @property diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index db8f29eaa..a3b379492 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -113,7 +113,6 @@ def __init__( substep_size: float = 0.1, emissive: str | tuple | np.ndarray = (0, 0, 0), shininess: int = 30, - isolated_buffer: bool = True, **kwargs, ): """ @@ -170,11 +169,6 @@ def __init__( How shiny the specular highlight is; a higher value gives a sharper highlight. Used only if `mode` = "iso" - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then set the data, useful if the - data arrays are ready-only such as memmaps. If False, the input array is itself used as the - buffer - useful if the array is large. - kwargs additional keyword arguments passed to :class:`.Graphic` @@ -188,7 +182,7 @@ def __init__( super().__init__(**kwargs) - world_object = pygfx.Group() + group = pygfx.Group() if isinstance(data, TextureArrayVolume): # share existing buffer @@ -196,7 +190,7 @@ def __init__( else: # create new texture array to manage buffer # texture array that manages the textures on the GPU that represent this image volume - self._data = TextureArrayVolume(data, isolated_buffer=isolated_buffer) + self._data = TextureArrayVolume(data) if (vmin is None) or (vmax is None): _vmin, _vmax = quick_min_max(self.data.value) @@ -237,6 +231,15 @@ def __init__( self._mode = VolumeRenderMode(mode) + # create tiles + for tile in self._create_tiles(): + group.add(tile) + + self._set_world_object(group) + + def _create_tiles(self) -> list[_VolumeTile]: + tiles = list() + # iterate through each texture chunk and create # a _VolumeTile, offset the tile using the data indices for texture, chunk_index, data_slice in self._data: @@ -259,9 +262,9 @@ def __init__( vol.world.x = data_col_start vol.world.y = data_row_start - world_object.add(vol) + tiles.append(vol) - self._set_world_object(world_object) + return tiles @property def data(self) -> TextureArrayVolume: @@ -270,6 +273,21 @@ def data(self) -> TextureArrayVolume: @data.setter def data(self, data): + if isinstance(data, np.ndarray): + # check if a new buffer is required + if self._data.value.shape != data.shape: + # create new TextureArray + self._data = TextureArrayVolume(data) + + # clear image tiles + self.world_object.clear() + + # create new tiles + for tile in self._create_tiles(): + self.world_object.add(tile) + + return + self._data[:] = data @property diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index a4f42704f..bba10b10f 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -18,6 +18,7 @@ UniformColor, VertexCmap, SizeSpace, + UniformRotations, ) from ..utils import quick_min_max @@ -36,10 +37,9 @@ def __init__( data: Any, thickness: float = 2.0, colors: str | np.ndarray | Sequence = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: np.ndarray | Sequence = None, - isolated_buffer: bool = True, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", **kwargs, ): @@ -61,15 +61,19 @@ def __init__( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default ``False`` - if True, uses a uniform buffer for the line color, - basically saves GPU VRAM when the entire line has a single color - cmap: str, optional Apply a colormap to the line instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap @@ -84,10 +88,9 @@ def __init__( super().__init__( data=data, colors=colors, - uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, - isolated_buffer=isolated_buffer, + color_mode=color_mode, size_space=size_space, **kwargs, ) @@ -102,8 +105,8 @@ def __init__( aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") - if uniform_color: - geometry = pygfx.Geometry(positions=self._data.buffer) + if isinstance(self._colors, UniformColor): + geometry = pygfx.Geometry(positions=self._data._fpl_buffer) material = MaterialCls( aa=aa, thickness=self.thickness, @@ -123,7 +126,7 @@ def __init__( depth_compare="<=", ) geometry = pygfx.Geometry( - positions=self._data.buffer, colors=self._colors.buffer + positions=self._data._fpl_buffer, colors=self._colors._fpl_buffer ) world_object: pygfx.Line = pygfx.Line(geometry=geometry, material=material) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index d08231f7d..5ec56777e 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -128,14 +128,13 @@ def __init__( data: np.ndarray | List[np.ndarray], thickness: float | Sequence[float] = 2.0, colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", - uniform_colors: bool = False, cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, **kwargs, ): @@ -170,6 +169,9 @@ def __init__( cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + The color mode for each line in the collection. See `color_mode` in :class:`.LineGraphic` for details. + name: str, optional name of the line collection as a whole @@ -320,11 +322,10 @@ def __init__( data=d, thickness=_s, colors=_c, - uniform_color=uniform_colors, cmap=_cmap, + color_mode=color_mode, name=_name, metadata=_m, - isolated_buffer=isolated_buffer, **kwargs_lines, ) @@ -560,7 +561,6 @@ def __init__( names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - isolated_buffer: bool = True, separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, @@ -634,7 +634,6 @@ def __init__( names=names, metadata=metadata, metadatas=metadatas, - isolated_buffer=isolated_buffer, kwargs_lines=kwargs_lines, **kwargs, ) diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 0e1ac42a3..efe03c57b 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -38,7 +38,6 @@ def __init__( mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] = None, - isolated_buffer: bool = True, **kwargs, ): """ @@ -77,12 +76,6 @@ def __init__( Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. An image can also be used, this is basically a 2D colormap. - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. In almost all cases this should be ``True``. - **kwargs passed to :class:`.Graphic` @@ -93,16 +86,12 @@ def __init__( if isinstance(positions, VertexPositions): self._positions = positions else: - self._positions = VertexPositions( - positions, isolated_buffer=isolated_buffer, property_name="positions" - ) + self._positions = VertexPositions(positions, property_name="positions") if isinstance(positions, MeshIndices): self._indices = indices else: - self._indices = MeshIndices( - indices, isolated_buffer=isolated_buffer, property_name="indices" - ) + self._indices = MeshIndices(indices, property_name="indices") self._cmap = MeshCmap(cmap) @@ -139,7 +128,7 @@ def __init__( ) geometry = pygfx.Geometry( - positions=self._positions.buffer, indices=self._indices._buffer + positions=self._positions.buffer, indices=self._indices._fpl_buffer ) valid_modes = ["basic", "phong", "slice"] diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 5268dcc51..b9cacf908 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -40,12 +40,12 @@ def __init__( self, data: Any, colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: np.ndarray = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: str | np.ndarray | Sequence[str] = "o", - uniform_marker: bool = False, + uniform_marker: bool = True, custom_sdf: str = None, edge_colors: str | np.ndarray | pygfx.Color | Sequence[float] = "black", uniform_edge_color: bool = True, @@ -54,9 +54,8 @@ def __init__( point_rotations: float | np.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: float | np.ndarray | Sequence[float] = 5, - uniform_size: bool = False, + uniform_size: bool = True, size_space: str = "screen", - isolated_buffer: bool = True, **kwargs, ): """ @@ -72,18 +71,23 @@ def __init__( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default False - if True, uses a uniform buffer for the scatter point colors. Useful if you need to - save GPU VRAM when all points have the same color. - cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this - overrides any argument passed to "colors". For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" The scatter points mode, cannot be changed after the graphic has been created. @@ -103,9 +107,10 @@ def __init__( * Emojis: "❤️♠️♣️♦️💎💍✳️📍". * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - uniform_marker: bool, default False - Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use - the same marker for all points and want to save GPU RAM. + uniform_marker: bool, default ``True`` + If ``True``, use the same marker for all points. Only valid when `mode` is "markers". + Useful if you need to use the same marker for all points and want to save GPU RAM. If ``False``, you can + set per-vertex markers. custom_sdf: str = None, The SDF code for the marker shape when the marker is set to custom. @@ -125,8 +130,9 @@ def __init__( edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" edge color of the markers, used when `mode` is "markers" - uniform_edge_color: bool, default True - Set the same edge color for all markers. Useful for saving GPU RAM. + uniform_edge_color: bool, default ``True`` + Set the same edge color for all markers. Useful for saving GPU RAM. Set to ``False`` for per-vertex edge + colors edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". @@ -147,17 +153,13 @@ def __init__( sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points - uniform_size: bool, default False - if True, uses a uniform buffer for the scatter point sizes. Useful if you need to - save GPU VRAM when all points have the same size. + uniform_size: bool, default ``False`` + if ``True``, uses a uniform buffer for the scatter point sizes. Useful if you need to + save GPU VRAM when all points have the same size. Set to ``False`` if you need per-vertex sizes. size_space: str, default "screen" coordinate space in which the size is expressed, one of ("screen", "world", "model") - isolated_buffer: bool, default True - whether the buffers should be isolated from the user input array. - Generally always ``True``, ``False`` is for rare advanced use if you have large arrays. - kwargs passed to :class:`.Graphic` @@ -166,17 +168,16 @@ def __init__( super().__init__( data=data, colors=colors, - uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, - isolated_buffer=isolated_buffer, + color_mode=color_mode, size_space=size_space, **kwargs, ) n_datapoints = self.data.value.shape[0] - geo_kwargs = {"positions": self._data.buffer} + geo_kwargs = {"positions": self._data._fpl_buffer} aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") @@ -214,7 +215,7 @@ def __init__( self._markers = VertexMarkers(markers, n_datapoints) - geo_kwargs["markers"] = self._markers.buffer + geo_kwargs["markers"] = self._markers._fpl_buffer if edge_colors is None: # interpret as no edge color @@ -237,7 +238,7 @@ def __init__( edge_colors, n_datapoints, property_name="edge_colors" ) material_kwargs["edge_color_mode"] = pygfx.ColorMode.vertex - geo_kwargs["edge_colors"] = self._edge_colors.buffer + geo_kwargs["edge_colors"] = self._edge_colors._fpl_buffer self._edge_width = EdgeWidth(edge_width) material_kwargs["edge_width"] = self._edge_width.value @@ -274,12 +275,12 @@ def __init__( self._size_space = SizeSpace(size_space) - if uniform_color: + if isinstance(self._colors, UniformColor): material_kwargs["color_mode"] = pygfx.ColorMode.uniform material_kwargs["color"] = self.colors else: material_kwargs["color_mode"] = pygfx.ColorMode.vertex - geo_kwargs["colors"] = self.colors.buffer + geo_kwargs["colors"] = self.colors._fpl_buffer if uniform_size: material_kwargs["size_mode"] = pygfx.SizeMode.uniform @@ -288,14 +289,14 @@ def __init__( else: material_kwargs["size_mode"] = pygfx.SizeMode.vertex self._sizes = VertexPointSizes(sizes, n_datapoints=n_datapoints) - geo_kwargs["sizes"] = self.sizes.buffer + geo_kwargs["sizes"] = self.sizes._fpl_buffer match point_rotation_mode: case pygfx.enums.RotationMode.vertex: self._point_rotations = VertexRotations( point_rotations, n_datapoints=n_datapoints ) - geo_kwargs["rotations"] = self._point_rotations.buffer + geo_kwargs["rotations"] = self._point_rotations._fpl_buffer case pygfx.enums.RotationMode.uniform: self._point_rotations = UniformRotations(point_rotations) @@ -338,10 +339,8 @@ def markers(self, value: str | np.ndarray[str] | Sequence[str]): raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'markers' to set the markers" ) - if isinstance(self._markers, VertexMarkers): - self._markers[:] = value - elif isinstance(self._markers, UniformMarker): - self._markers.set_value(self, value) + + self._markers.set_value(self, value) @property def edge_colors(self) -> str | pygfx.Color | VertexColors | None: @@ -359,12 +358,7 @@ def edge_colors(self, value: str | np.ndarray | Sequence[str] | Sequence[float]) raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_colors" ) - - if isinstance(self._edge_colors, VertexColors): - self._edge_colors[:] = value - - elif isinstance(self._edge_colors, UniformEdgeColor): - self._edge_colors.set_value(self, value) + self._edge_colors.set_value(self, value) @property def edge_width(self) -> float | None: @@ -406,11 +400,7 @@ def point_rotations(self, value: float | np.ndarray[float]): f"it be 'uniform' or 'vertex' to set the `point_rotations`" ) - if isinstance(self._point_rotations, VertexRotations): - self._point_rotations[:] = value - - elif isinstance(self._point_rotations, UniformRotations): - self._point_rotations.set_value(self, value) + self._point_rotations.set_value(self, value) @property def image(self) -> TextureArray | None: @@ -437,8 +427,4 @@ def sizes(self) -> VertexPointSizes | float: @sizes.setter def sizes(self, value): - if isinstance(self._sizes, VertexPointSizes): - self._sizes[:] = value - - elif isinstance(self._sizes, UniformSize): - self._sizes.set_value(self, value) + self._sizes.set_value(self, value) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 3eb018f55..eda7b1492 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -33,8 +33,7 @@ def add_image( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -62,12 +61,6 @@ def add_image( cmap_interpolation: str, optional, default "linear" colormap interpolation method, one of "nearest" or "linear" - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. - kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -81,8 +74,7 @@ def add_image( cmap, interpolation, cmap_interpolation, - isolated_buffer, - **kwargs, + **kwargs ) def add_image_volume( @@ -100,8 +92,7 @@ def add_image_volume( substep_size: float = 0.1, emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ @@ -158,11 +149,6 @@ def add_image_volume( How shiny the specular highlight is; a higher value gives a sharper highlight. Used only if `mode` = "iso" - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then set the data, useful if the - data arrays are ready-only such as memmaps. If False, the input array is itself used as the - buffer - useful if the array is large. - kwargs additional keyword arguments passed to :class:`.Graphic` @@ -183,8 +169,7 @@ def add_image_volume( substep_size, emissive, shininess, - isolated_buffer, - **kwargs, + **kwargs ) def add_line_collection( @@ -192,16 +177,15 @@ def add_line_collection( data: Union[numpy.ndarray, List[numpy.ndarray]], thickness: Union[float, Sequence[float]] = 2.0, colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", - uniform_colors: bool = False, cmap: Union[Sequence[str], str] = None, cmap_transform: Union[numpy.ndarray, List] = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -235,6 +219,9 @@ def add_line_collection( cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + The color mode for each line in the collection. See `color_mode` in :class:`.LineGraphic` for details. + name: str, optional name of the line collection as a whole @@ -261,16 +248,15 @@ def add_line_collection( data, thickness, colors, - uniform_colors, cmap, cmap_transform, + color_mode, name, names, metadata, metadatas, - isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -278,12 +264,11 @@ def add_line( data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Sequence] = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: Union[numpy.ndarray, Sequence] = None, - isolated_buffer: bool = True, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -304,15 +289,19 @@ def add_line( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default ``False`` - if True, uses a uniform buffer for the line color, - basically saves GPU VRAM when the entire line has a single color - cmap: str, optional Apply a colormap to the line instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap @@ -329,12 +318,11 @@ def add_line( data, thickness, colors, - uniform_color, cmap, cmap_transform, - isolated_buffer, + color_mode, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -348,11 +336,10 @@ def add_line_stack( names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - isolated_buffer: bool = True, separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -425,11 +412,10 @@ def add_line_stack( names, metadata, metadatas, - isolated_buffer, separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_mesh( @@ -448,8 +434,7 @@ def add_mesh( | numpy.ndarray ) = None, clim: tuple[float, float] = None, - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> MeshGraphic: """ @@ -488,12 +473,6 @@ def add_mesh( Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. An image can also be used, this is basically a 2D colormap. - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. In almost all cases this should be ``True``. - **kwargs passed to :class:`.Graphic` @@ -509,8 +488,7 @@ def add_mesh( mapcoords, cmap, clim, - isolated_buffer, - **kwargs, + **kwargs ) def add_polygon( @@ -527,7 +505,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> PolygonGraphic: """ @@ -656,12 +634,12 @@ def add_scatter( self, data: Any, colors: Union[str, numpy.ndarray, Sequence[float], Sequence[str]] = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: numpy.ndarray = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: Union[str, numpy.ndarray, Sequence[str]] = "o", - uniform_marker: bool = False, + uniform_marker: bool = True, custom_sdf: str = None, edge_colors: Union[ str, pygfx.utils.color.Color, numpy.ndarray, Sequence[float] @@ -672,10 +650,9 @@ def add_scatter( point_rotations: float | numpy.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: Union[float, numpy.ndarray, Sequence[float]] = 5, - uniform_size: bool = False, + uniform_size: bool = True, size_space: str = "screen", - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -691,18 +668,23 @@ def add_scatter( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default False - if True, uses a uniform buffer for the scatter point colors. Useful if you need to - save GPU VRAM when all points have the same color. - cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this - overrides any argument passed to "colors". For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" The scatter points mode, cannot be changed after the graphic has been created. @@ -722,9 +704,10 @@ def add_scatter( * Emojis: "❤️♠️♣️♦️💎💍✳️📍". * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - uniform_marker: bool, default False - Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use - the same marker for all points and want to save GPU RAM. + uniform_marker: bool, default ``True`` + If ``True``, use the same marker for all points. Only valid when `mode` is "markers". + Useful if you need to use the same marker for all points and want to save GPU RAM. If ``False``, you can + set per-vertex markers. custom_sdf: str = None, The SDF code for the marker shape when the marker is set to custom. @@ -744,8 +727,9 @@ def add_scatter( edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" edge color of the markers, used when `mode` is "markers" - uniform_edge_color: bool, default True - Set the same edge color for all markers. Useful for saving GPU RAM. + uniform_edge_color: bool, default ``True`` + Set the same edge color for all markers. Useful for saving GPU RAM. Set to ``False`` for per-vertex edge + colors edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". @@ -766,17 +750,13 @@ def add_scatter( sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points - uniform_size: bool, default False - if True, uses a uniform buffer for the scatter point sizes. Useful if you need to - save GPU VRAM when all points have the same size. + uniform_size: bool, default ``False`` + if ``True``, uses a uniform buffer for the scatter point sizes. Useful if you need to + save GPU VRAM when all points have the same size. Set to ``False`` if you need per-vertex sizes. size_space: str, default "screen" coordinate space in which the size is expressed, one of ("screen", "world", "model") - isolated_buffer: bool, default True - whether the buffers should be isolated from the user input array. - Generally always ``True``, ``False`` is for rare advanced use if you have large arrays. - kwargs passed to :class:`.Graphic` @@ -786,9 +766,9 @@ def add_scatter( ScatterGraphic, data, colors, - uniform_color, cmap, cmap_transform, + color_mode, mode, markers, uniform_marker, @@ -802,8 +782,7 @@ def add_scatter( sizes, uniform_size, size_space, - isolated_buffer, - **kwargs, + **kwargs ) def add_surface( @@ -820,7 +799,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> SurfaceGraphic: """ @@ -874,7 +853,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -925,7 +904,7 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) def add_vectors( @@ -935,7 +914,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, + **kwargs ) -> VectorsGraphic: """ @@ -980,5 +959,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs, + **kwargs ) diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py index 7b1aef16a..f9d56189e 100644 --- a/tests/test_colors_buffer_manager.py +++ b/tests/test_colors_buffer_manager.py @@ -48,10 +48,10 @@ def test_int(test_graphic): data = generate_positions_spiral_data("xyz") if test_graphic == "line": - graphic = fig[0, 0].add_line(data=data) + graphic = fig[0, 0].add_line(data=data, color_mode="vertex") elif test_graphic == "scatter": - graphic = fig[0, 0].add_scatter(data=data) + graphic = fig[0, 0].add_scatter(data=data, color_mode="vertex") colors = graphic.colors global EVENT_RETURN_VALUE @@ -98,10 +98,10 @@ def test_tuple(test_graphic, slice_method): data = generate_positions_spiral_data("xyz") if test_graphic == "line": - graphic = fig[0, 0].add_line(data=data) + graphic = fig[0, 0].add_line(data=data, color_mode="vertex") elif test_graphic == "scatter": - graphic = fig[0, 0].add_scatter(data=data) + graphic = fig[0, 0].add_scatter(data=data, color_mode="vertex") colors = graphic.colors global EVENT_RETURN_VALUE @@ -190,10 +190,10 @@ def test_slice(color_input, slice_method: dict, test_graphic: bool): data = generate_positions_spiral_data("xyz") if test_graphic == "line": - graphic = fig[0, 0].add_line(data=data) + graphic = fig[0, 0].add_line(data=data, color_mode="vertex") elif test_graphic == "scatter": - graphic = fig[0, 0].add_scatter(data=data) + graphic = fig[0, 0].add_scatter(data=data, color_mode="vertex") colors = graphic.colors diff --git a/tests/test_markers_buffer_manager.py b/tests/test_markers_buffer_manager.py index 65ead392e..488bed194 100644 --- a/tests/test_markers_buffer_manager.py +++ b/tests/test_markers_buffer_manager.py @@ -46,10 +46,10 @@ def test_create_buffer(test_graphic): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, markers=MARKERS1) + scatter = fig[0, 0].add_scatter(data, markers=MARKERS1, uniform_marker=False) vertex_markers = scatter.markers assert isinstance(vertex_markers, VertexMarkers) - assert vertex_markers.buffer is scatter.world_object.geometry.markers + assert vertex_markers._fpl_buffer is scatter.world_object.geometry.markers else: vertex_markers = VertexMarkers(MARKERS1, len(data)) @@ -68,7 +68,7 @@ def test_int(test_graphic, index: int): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, markers=MARKERS1) + scatter = fig[0, 0].add_scatter(data, markers=MARKERS1, uniform_marker=False) scatter.add_event_handler(event_handler, "markers") vertex_markers = scatter.markers else: @@ -108,7 +108,7 @@ def test_slice(test_graphic, slice_method): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, markers=MARKERS1) + scatter = fig[0, 0].add_scatter(data, markers=MARKERS1, uniform_marker=False) scatter.add_event_handler(event_handler, "markers") vertex_markers = scatter.markers diff --git a/tests/test_point_rotations_buffer_manager.py b/tests/test_point_rotations_buffer_manager.py index ec5fdbe0f..50ee88984 100644 --- a/tests/test_point_rotations_buffer_manager.py +++ b/tests/test_point_rotations_buffer_manager.py @@ -35,7 +35,7 @@ def test_create_buffer(test_graphic): scatter = fig[0, 0].add_scatter(data, point_rotation_mode="vertex", point_rotations=ROTATIONS1) vertex_rotations = scatter.point_rotations assert isinstance(vertex_rotations, VertexRotations) - assert vertex_rotations.buffer is scatter.world_object.geometry.rotations + assert vertex_rotations._fpl_buffer is scatter.world_object.geometry.rotations else: vertex_rotations = VertexRotations(ROTATIONS1, len(data)) diff --git a/tests/test_positions_data_buffer_manager.py b/tests/test_positions_data_buffer_manager.py index e2582d4ba..cc550abf0 100644 --- a/tests/test_positions_data_buffer_manager.py +++ b/tests/test_positions_data_buffer_manager.py @@ -57,7 +57,7 @@ def test_int(test_graphic): graphic = fig[0, 0].add_scatter(data=data) points = graphic.data - assert graphic.data.buffer is graphic.world_object.geometry.positions + assert graphic.data._fpl_buffer is graphic.world_object.geometry.positions global EVENT_RETURN_VALUE graphic.add_event_handler(event_handler, "data") else: diff --git a/tests/test_positions_graphics.py b/tests/test_positions_graphics.py index 31c001888..4bc93b626 100644 --- a/tests/test_positions_graphics.py +++ b/tests/test_positions_graphics.py @@ -37,12 +37,12 @@ def test_sizes_slice(): @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) @pytest.mark.parametrize("colors", [None, *generate_color_inputs("b")]) -@pytest.mark.parametrize("uniform_color", [True, False]) -def test_uniform_color(graphic_type, colors, uniform_color): +@pytest.mark.parametrize("color_mode", ["uniform", "vertex"]) +def test_color_mode(graphic_type, colors, color_mode): fig = fpl.Figure() kwargs = dict() - for kwarg in ["colors", "uniform_color"]: + for kwarg in ["colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -54,7 +54,7 @@ def test_uniform_color(graphic_type, colors, uniform_color): elif graphic_type == "scatter": graphic = fig[0, 0].add_scatter(data=data, **kwargs) - if uniform_color: + if color_mode == "uniform": assert isinstance(graphic._colors, UniformColor) assert isinstance(graphic.colors, pygfx.Color) if colors is None: @@ -130,17 +130,17 @@ def test_positions_graphics_data( @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) @pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) -@pytest.mark.parametrize("uniform_color", [None, False]) +@pytest.mark.parametrize("color_mode", ["vertex"]) def test_positions_graphic_vertex_colors( graphic_type, colors, - uniform_color, + color_mode, ): # test different ways of passing vertex colors fig = fpl.Figure() kwargs = dict() - for kwarg in ["colors", "uniform_color"]: + for kwarg in ["colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -153,10 +153,9 @@ def test_positions_graphic_vertex_colors( graphic = fig[0, 0].add_scatter(data=data, **kwargs) # color per vertex - # uniform colors is default False, or set to False - assert isinstance(graphic._colors, VertexColors) - assert isinstance(graphic.colors, VertexColors) - assert len(graphic.colors) == len(graphic.data) + assert isinstance(graphic._colors, VertexColors) + assert isinstance(graphic.colors, VertexColors) + assert len(graphic.colors) == len(graphic.data) if colors is None: # default @@ -179,7 +178,7 @@ def test_positions_graphic_vertex_colors( @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) @pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) -@pytest.mark.parametrize("uniform_color", [None, False]) +@pytest.mark.parametrize("color_mode", ["auto", "vertex"]) @pytest.mark.parametrize("cmap", ["jet"]) @pytest.mark.parametrize( "cmap_transform", [None, [3, 5, 2, 1, 0, 6, 9, 7, 4, 8], np.arange(9, -1, -1)] @@ -187,7 +186,7 @@ def test_positions_graphic_vertex_colors( def test_cmap( graphic_type, colors, - uniform_color, + color_mode, cmap, cmap_transform, ): @@ -195,7 +194,7 @@ def test_cmap( fig = fpl.Figure() kwargs = dict() - for kwarg in ["cmap", "cmap_transform", "colors", "uniform_color"]: + for kwarg in ["cmap", "cmap_transform", "colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -220,7 +219,8 @@ def test_cmap( # make sure buffer is identical # cmap overrides colors argument - assert graphic.colors.buffer is graphic.cmap.buffer + # use __repr__.__self__ to get the real reference from the cmap feature instead of the weakref proxy + assert graphic.colors._fpl_buffer is graphic.cmap.buffer.__repr__.__self__ npt.assert_almost_equal(graphic.cmap.value, truth) npt.assert_almost_equal(graphic.colors.value, truth) @@ -261,14 +261,14 @@ def test_cmap( "colors", [None, *generate_color_inputs("multi")] ) # cmap arg overrides colors @pytest.mark.parametrize( - "uniform_color", [True] # none of these will work with a uniform buffer + "color_mode", ["uniform"] # none of these will work with a uniform buffer ) -def test_incompatible_cmap_color_args(graphic_type, cmap, colors, uniform_color): +def test_incompatible_cmap_color_args(graphic_type, cmap, colors, color_mode): # test incompatible cmap args fig = fpl.Figure() kwargs = dict() - for kwarg in ["cmap", "colors", "uniform_color"]: + for kwarg in ["cmap", "colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -276,24 +276,24 @@ def test_incompatible_cmap_color_args(graphic_type, cmap, colors, uniform_color) data = generate_positions_spiral_data("xy") if graphic_type == "line": - with pytest.raises(TypeError): + with pytest.raises(ValueError): graphic = fig[0, 0].add_line(data=data, **kwargs) elif graphic_type == "scatter": - with pytest.raises(TypeError): + with pytest.raises(ValueError): graphic = fig[0, 0].add_scatter(data=data, **kwargs) @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) @pytest.mark.parametrize("colors", [*generate_color_inputs("multi")]) @pytest.mark.parametrize( - "uniform_color", [True] # none of these will work with a uniform buffer + "color_mode", ["uniform"] # none of these will work with a uniform buffer ) -def test_incompatible_color_args(graphic_type, colors, uniform_color): +def test_incompatible_color_args(graphic_type, colors, color_mode): # test incompatible color args fig = fpl.Figure() kwargs = dict() - for kwarg in ["colors", "uniform_color"]: + for kwarg in ["colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -301,16 +301,15 @@ def test_incompatible_color_args(graphic_type, colors, uniform_color): data = generate_positions_spiral_data("xy") if graphic_type == "line": - with pytest.raises(TypeError): + with pytest.raises(ValueError): graphic = fig[0, 0].add_line(data=data, **kwargs) elif graphic_type == "scatter": - with pytest.raises(TypeError): + with pytest.raises(ValueError): graphic = fig[0, 0].add_scatter(data=data, **kwargs) @pytest.mark.parametrize("sizes", [None, 5.0, np.linspace(3, 8, 10, dtype=np.float32)]) -@pytest.mark.parametrize("uniform_size", [None, False]) -def test_sizes(sizes, uniform_size): +def test_sizes(sizes): # test scatter sizes fig = fpl.Figure() @@ -322,7 +321,7 @@ def test_sizes(sizes, uniform_size): data = generate_positions_spiral_data("xy") - graphic = fig[0, 0].add_scatter(data=data, **kwargs) + graphic = fig[0, 0].add_scatter(data=data, uniform_size=False, **kwargs) assert isinstance(graphic.sizes, VertexPointSizes) assert isinstance(graphic._sizes, VertexPointSizes) diff --git a/tests/test_replace_buffer.py b/tests/test_replace_buffer.py new file mode 100644 index 000000000..a9d0ffe41 --- /dev/null +++ b/tests/test_replace_buffer.py @@ -0,0 +1,155 @@ +import gc +import weakref + +import pytest +import numpy as np +from itertools import product + +import fastplotlib as fpl +from .utils_textures import MAX_TEXTURE_SIZE, check_texture_array, check_image_graphic + +# These are only de-referencing tests for positions graphics, and ImageGraphic +# they do not test that VRAM gets free, for now this can only be checked manually +# with the tests in examples/misc/buffer_replace_gc.py + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("new_buffer_size", [50, 150]) +def test_replace_positions_buffer(graphic_type, new_buffer_size): + fig = fpl.Figure() + + # create some data with an initial shape + orig_datapoints = 100 + + xs = np.linspace(0, 2 * np.pi, orig_datapoints) + ys = np.sin(xs) + zs = np.cos(xs) + + data = np.column_stack([xs, ys, zs]) + + # add add_line or add_scatter method + adder = getattr(fig[0, 0], f"add_{graphic_type}") + + if graphic_type == "scatter": + kwargs = { + "markers": np.random.choice(list("osD+x^v<>*"), size=orig_datapoints), + "uniform_marker": False, + "sizes": np.abs(ys), + "uniform_size": False, + # TODO: skipping edge_colors for now since that causes a WGPU bind group error that we will figure out later + # anyways I think changing buffer sizes in combination with per-vertex edge colors is a literal edge-case + "point_rotations": zs * 180, + "point_rotation_mode": "vertex", + } + else: + kwargs = dict() + + # add a line or scatter graphic + graphic = adder(data=data, colors=np.random.rand(orig_datapoints, 4), **kwargs) + + fig.show() + + # weakrefs to the original buffers + # these should raise a ReferenceError when the corresponding feature is replaced with data of a different shape + orig_data_buffer = weakref.proxy(graphic.data._fpl_buffer) + orig_colors_buffer = weakref.proxy(graphic.colors._fpl_buffer) + + buffers = [orig_data_buffer, orig_colors_buffer] + + # extra buffers for the scatters + if graphic_type == "scatter": + for attr in ["markers", "sizes", "point_rotations"]: + buffers.append(weakref.proxy(getattr(graphic, attr)._fpl_buffer)) + + # create some new data that requires a different buffer shape + xs = np.linspace(0, 15 * np.pi, new_buffer_size) + ys = np.sin(xs) + zs = np.cos(xs) + + new_data = np.column_stack([xs, ys, zs]) + + # set data that requires a larger buffer and check that old buffer is no longer referenced + graphic.data = new_data + graphic.colors = np.random.rand(new_buffer_size, 4) + + if graphic_type == "scatter": + # changes values so that new larger buffers must be allocated + graphic.markers = np.random.choice(list("osD+x^v<>*"), size=new_buffer_size) + graphic.sizes = np.abs(zs) + graphic.point_rotations = ys * 180 + + # make sure old original buffers are de-referenced + for i in range(len(buffers)): + with pytest.raises(ReferenceError) as fail: + buffers[i] + pytest.fail( + f"GC failed for buffer: {buffers[i]}, " + f"with referrers: {gc.get_referrers(buffers[i].__repr__.__self__)}" + ) + + +# test all combination of dims that require TextureArrays of shapes 1x1, 1x2, 1x3, 2x3, 3x3 etc. +@pytest.mark.parametrize( + "new_buffer_size", list(product(*[[(500, 1), (1200, 2), (2200, 3)]] * 2)) +) +def test_replace_image_buffer(new_buffer_size): + # make an image with some starting shape + orig_size = (1_500, 1_500) + + data = np.random.rand(*orig_size) + + fig = fpl.Figure() + image = fig[0, 0].add_image(data) + + # the original Texture buffers that represent the individual image tiles + orig_buffers = [ + weakref.proxy(image.data.buffer.ravel()[i]) + for i in range(image.data.buffer.size) + ] + orig_shape = image.data.buffer.shape + + fig.show() + + # dimensions for a new image + new_dims = [v[0] for v in new_buffer_size] + + # the number of tiles required in each dim/shape of the TextureArray + new_shape = tuple(v[1] for v in new_buffer_size) + + # make the new data and set the image + new_data = np.random.rand(*new_dims) + image.data = new_data + + # test that old Texture buffers are de-referenced + for i in range(len(orig_buffers)): + with pytest.raises(ReferenceError) as fail: + orig_buffers[i] + pytest.fail( + f"GC failed for buffer: {orig_buffers[i]}, of shape: {orig_shape}" + f"with referrers: {gc.get_referrers(orig_buffers[i].__repr__.__self__)}" + ) + + # check new texture array + check_texture_array( + data=new_data, + ta=image.data, + buffer_size=np.prod(new_shape), + buffer_shape=new_shape, + row_indices_size=new_shape[0], + col_indices_size=new_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (new_data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (new_data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), + ) + + # check that new image tiles are arranged correctly + check_image_graphic(image.data, image) diff --git a/tests/test_scatter_graphic.py b/tests/test_scatter_graphic.py index a61681f24..930d8c495 100644 --- a/tests/test_scatter_graphic.py +++ b/tests/test_scatter_graphic.py @@ -133,7 +133,7 @@ def test_edge_colors(edge_colors): npt.assert_almost_equal(scatter.edge_colors.value, MULTI_COLORS_TRUTH) assert ( - scatter.edge_colors.buffer is scatter.world_object.geometry.edge_colors + scatter.edge_colors._fpl_buffer is scatter.world_object.geometry.edge_colors ) # test changes, don't need to test extensively here since it's tested in the main VertexColors test diff --git a/tests/test_texture_array.py b/tests/test_texture_array.py index 6220f2fe5..01abb9a97 100644 --- a/tests/test_texture_array.py +++ b/tests/test_texture_array.py @@ -2,14 +2,9 @@ from numpy import testing as npt import pytest -import pygfx - import fastplotlib as fpl from fastplotlib.graphics.features import TextureArray -from fastplotlib.graphics.image import _ImageTile - - -MAX_TEXTURE_SIZE = 1024 +from .utils_textures import MAX_TEXTURE_SIZE, check_texture_array, check_image_graphic def make_data(n_rows: int, n_cols: int) -> np.ndarray: @@ -25,50 +20,6 @@ def make_data(n_rows: int, n_cols: int) -> np.ndarray: return np.vstack([sine * i for i in range(n_rows)]).astype(np.float32) -def check_texture_array( - data: np.ndarray, - ta: TextureArray, - buffer_size: int, - buffer_shape: tuple[int, int], - row_indices_size: int, - col_indices_size: int, - row_indices_values: np.ndarray, - col_indices_values: np.ndarray, -): - - npt.assert_almost_equal(ta.value, data) - - assert ta.buffer.size == buffer_size - assert ta.buffer.shape == buffer_shape - - assert all([isinstance(texture, pygfx.Texture) for texture in ta.buffer.ravel()]) - - assert ta.row_indices.size == row_indices_size - assert ta.col_indices.size == col_indices_size - npt.assert_array_equal(ta.row_indices, row_indices_values) - npt.assert_array_equal(ta.col_indices, col_indices_values) - - # make sure chunking is correct - for texture, chunk_index, data_slice in ta: - assert ta.buffer[chunk_index] is texture - chunk_row, chunk_col = chunk_index - - data_row_start_index = chunk_row * MAX_TEXTURE_SIZE - data_col_start_index = chunk_col * MAX_TEXTURE_SIZE - - data_row_stop_index = min( - data.shape[0], data_row_start_index + MAX_TEXTURE_SIZE - ) - data_col_stop_index = min( - data.shape[1], data_col_start_index + MAX_TEXTURE_SIZE - ) - - row_slice = slice(data_row_start_index, data_row_stop_index) - col_slice = slice(data_col_start_index, data_col_stop_index) - - assert data_slice == (row_slice, col_slice) - - def check_set_slice(data, ta, row_slice, col_slice): ta[row_slice, col_slice] = 1 npt.assert_almost_equal(ta[row_slice, col_slice], 1) @@ -85,17 +36,6 @@ def make_image_graphic(data) -> fpl.ImageGraphic: return fig[0, 0].add_image(data) -def check_image_graphic(texture_array, graphic): - # make sure each ImageTile has the right texture - for (texture, chunk_index, data_slice), img in zip( - texture_array, graphic.world_object.children - ): - assert isinstance(img, _ImageTile) - assert img.geometry.grid is texture - assert img.world.x == data_slice[1].start - assert img.world.y == data_slice[0].start - - @pytest.mark.parametrize("test_graphic", [False, True]) def test_small_texture(test_graphic): # tests TextureArray with dims that requires only 1 texture @@ -162,15 +102,27 @@ def test_wide(test_graphic): else: ta = TextureArray(data) + ta_shape = (2, 3) + check_texture_array( data, ta=ta, - buffer_size=6, - buffer_shape=(2, 3), - row_indices_size=2, - col_indices_size=3, - row_indices_values=np.array([0, MAX_TEXTURE_SIZE]), - col_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), + buffer_size=np.prod(ta_shape), + buffer_shape=ta_shape, + row_indices_size=ta_shape[0], + col_indices_size=ta_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), ) if test_graphic: @@ -189,15 +141,27 @@ def test_tall(test_graphic): else: ta = TextureArray(data) + ta_shape = (3, 2) + check_texture_array( data, ta=ta, - buffer_size=6, - buffer_shape=(3, 2), - row_indices_size=3, - col_indices_size=2, - row_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), - col_indices_values=np.array([0, MAX_TEXTURE_SIZE]), + buffer_size=np.prod(ta_shape), + buffer_shape=ta_shape, + row_indices_size=ta_shape[0], + col_indices_size=ta_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), ) if test_graphic: @@ -216,15 +180,27 @@ def test_square(test_graphic): else: ta = TextureArray(data) + ta_shape = (3, 3) + check_texture_array( data, ta=ta, - buffer_size=9, - buffer_shape=(3, 3), - row_indices_size=3, - col_indices_size=3, - row_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), - col_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), + buffer_size=np.prod(ta_shape), + buffer_shape=ta_shape, + row_indices_size=ta_shape[0], + col_indices_size=ta_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), ) if test_graphic: diff --git a/tests/utils_textures.py b/tests/utils_textures.py new file mode 100644 index 000000000..f40a7371c --- /dev/null +++ b/tests/utils_textures.py @@ -0,0 +1,64 @@ +import numpy as np +import pygfx +from numpy import testing as npt + +from fastplotlib.graphics.features import TextureArray +from fastplotlib.graphics.image import _ImageTile + + +MAX_TEXTURE_SIZE = 1024 + + +def check_texture_array( + data: np.ndarray, + ta: TextureArray, + buffer_size: int, + buffer_shape: tuple[int, int], + row_indices_size: int, + col_indices_size: int, + row_indices_values: np.ndarray, + col_indices_values: np.ndarray, +): + + npt.assert_almost_equal(ta.value, data) + + assert ta.buffer.size == buffer_size + assert ta.buffer.shape == buffer_shape + + assert all([isinstance(texture, pygfx.Texture) for texture in ta.buffer.ravel()]) + + assert ta.row_indices.size == row_indices_size + assert ta.col_indices.size == col_indices_size + npt.assert_array_equal(ta.row_indices, row_indices_values) + npt.assert_array_equal(ta.col_indices, col_indices_values) + + # make sure chunking is correct + for texture, chunk_index, data_slice in ta: + assert ta.buffer[chunk_index] is texture + chunk_row, chunk_col = chunk_index + + data_row_start_index = chunk_row * MAX_TEXTURE_SIZE + data_col_start_index = chunk_col * MAX_TEXTURE_SIZE + + data_row_stop_index = min( + data.shape[0], data_row_start_index + MAX_TEXTURE_SIZE + ) + data_col_stop_index = min( + data.shape[1], data_col_start_index + MAX_TEXTURE_SIZE + ) + + row_slice = slice(data_row_start_index, data_row_stop_index) + col_slice = slice(data_col_start_index, data_col_stop_index) + + assert data_slice == (row_slice, col_slice) + + +def check_image_graphic(texture_array, graphic): + # make sure each ImageTile has the right texture + for (texture, chunk_index, data_slice), img in zip( + texture_array, graphic.world_object.children + ): + assert isinstance(img, _ImageTile) + assert img.geometry.grid is texture + assert img.world.x == data_slice[1].start + assert img.world.y == data_slice[0].start From aefe418192709223a6850ec37932f176664e24a8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 18:44:42 -0500 Subject: [PATCH 72/81] some basic OOC working --- .../widgets/nd_widget/_nd_positions.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 1871e027e..65d1f59c5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -9,6 +9,7 @@ from ...utils import subsample_array, ArrayProtocol from ...graphics import ( + Graphic, ImageGraphic, LineGraphic, LineStack, @@ -264,13 +265,14 @@ def get(self, indices: tuple[Any, ...]): ).squeeze() # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary + # we need to slice upto dw since we add the `datapoints_window_size` above graphic_data[..., : dw, dims] = wf( windows, axis=-1 ).reshape(graphic_data.shape[0], dw, len(dims)) - return graphic_data[..., : dw, :] + return graphic_data[..., : dw : max(1, dw // self.p_max), :] - return graphic_data + return graphic_data[..., : graphic_data.shape[-2] : max(1, graphic_data.shape[-2] // self.p_max), :] class NDPositions: @@ -303,6 +305,8 @@ def __init__( index_mappings=index_mappings, ) + self._processor.p_max = 1_000 + self._indices = tuple([0] * self._processor.n_slider_dims) self._create_graphic(graphic) @@ -348,15 +352,21 @@ def indices(self, indices): self.graphic.data[:, : data_slice.shape[-1]] = data_slice elif isinstance(self.graphic, (LineCollection, ScatterCollection)): - for i in range(len(self.graphic)): - # data_slice shape is [n_lines, n_datapoints, 2 | 3] - self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] + for g, new_data in zip(self.graphic.graphics, data_slice): + if g.data.value.shape[0] != new_data.shape[0]: + # will replace buffer internally + g.data = new_data + else: + # if data are only xy, set only xy + g.data[:, :new_data.shape[1]] = new_data elif isinstance(self.graphic, ImageGraphic): image_data, x0, x_scale = self._create_heatmap_data(data_slice) self.graphic.data = image_data self.graphic.offset = (x0, *self.graphic.offset[1:]) + self._indices = indices + def _create_graphic( self, graphic_cls: Type[ @@ -368,6 +378,9 @@ def _create_graphic( | ImageGraphic ], ): + if not issubclass(graphic_cls, Graphic): + raise TypeError + data_slice = self.processor.get(self.indices) if issubclass(graphic_cls, ImageGraphic): @@ -412,3 +425,13 @@ def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: x0 = data_slice[0, 0, 0] return y_interp, x0, x_scale + + @property + def display_window(self) -> int | float | None: + """display window in the reference units for the n_datapoints dim""" + return self.processor.display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + self.processor.display_window = dw + self.indices = self.indices From b6d6e62d0f2a55ff4152c29db23acd6e578d391f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 6 Feb 2026 12:27:51 -0500 Subject: [PATCH 73/81] max num of dipslay datapoints --- .../widgets/nd_widget/_nd_positions.py | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 65d1f59c5..6cc29d92a 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -29,12 +29,14 @@ def __init__( data: ArrayProtocol, multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points display_window: int | float | None = 100, # window for n_datapoints dim only + max_display_datapoints: int = 1_000, datapoints_window_func: Callable | None = None, datapoints_window_size: int | None = None, **kwargs, ): self._display_window = display_window + self._max_display_datapoints = max_display_datapoints # TOOD: this does data validation twice and is a bit messy, cleanup self._data = self._validate_data(data) @@ -64,6 +66,19 @@ def display_window(self, dw: int | float | None): self._display_window = dw + @property + def max_display_datapoints(self) -> int: + return self._max_display_datapoints + + @max_display_datapoints.setter + def max_display_datapoints(self, n: int): + if not isinstance(n, (int, np.integer)): + raise TypeError + if n < 2: + raise ValueError + + self._max_display_datapoints = n + @property def multi(self) -> bool: return self._multi @@ -231,12 +246,15 @@ def get(self, indices: tuple[Any, ...]): # 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 - graphic_data = window_output[tuple(slices)].copy() + graphic_data = window_output[tuple(slices)] # apply window function on the `p` n_datapoints dim if ( self.datapoints_window_func is not None and self.datapoints_window_size is not None + # if there are too many points to efficiently compute the window func + # applying a window func also requires making a copy so that's a further performance hit + and (dw < self.max_display_datapoints * 2) ): # get windows @@ -264,18 +282,30 @@ def get(self, indices: tuple[Any, ...]): graphic_data[..., dims], ws, axis=-2 ).squeeze() + # make a copy because we need to modify it + graphic_data = graphic_data.copy() + # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary # we need to slice upto dw since we add the `datapoints_window_size` above - graphic_data[..., : dw, dims] = wf( - windows, axis=-1 - ).reshape(graphic_data.shape[0], dw, len(dims)) + graphic_data[..., :dw, dims] = wf(windows, axis=-1).reshape( + graphic_data.shape[0], dw, len(dims) + ) - return graphic_data[..., : dw : max(1, dw // self.p_max), :] + return graphic_data[ + ..., : dw : max(1, dw // self.max_display_datapoints), : + ] - return graphic_data[..., : graphic_data.shape[-2] : max(1, graphic_data.shape[-2] // self.p_max), :] + return graphic_data[ + ..., + : graphic_data.shape[-2] : max( + 1, graphic_data.shape[-2] // self.max_display_datapoints + ), + :, + ] class NDPositions: + def __init__( self, data, @@ -292,6 +322,8 @@ def __init__( window_funcs: tuple[WindowFuncCallable | None] | None = None, window_sizes: tuple[int | None] | None = None, index_mappings: tuple[Callable[[Any], int] | None] | None = None, + max_display_datapoints: int = 1_000, + graphic_kwargs: dict = None, ): if issubclass(graphic, LineCollection): multi = True @@ -300,6 +332,7 @@ def __init__( data, multi=multi, display_window=display_window, + max_display_datapoints=max_display_datapoints, window_funcs=window_funcs, window_sizes=window_sizes, index_mappings=index_mappings, @@ -358,7 +391,7 @@ def indices(self, indices): g.data = new_data else: # if data are only xy, set only xy - g.data[:, :new_data.shape[1]] = new_data + g.data[:, : new_data.shape[1]] = new_data elif isinstance(self.graphic, ImageGraphic): image_data, x0, x_scale = self._create_heatmap_data(data_slice) @@ -396,7 +429,11 @@ def _create_graphic( ) else: - self._graphic = graphic_cls(data_slice) + if issubclass(graphic_cls, LineStack): + kwargs = {"separation": 0.0} + else: + kwargs = dict() + self._graphic = graphic_cls(data_slice, **kwargs) def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: """return [n_rows, n_cols] shape data""" From 976459b662d6a2d133009721e1c8c3bf1457fef5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 11 Feb 2026 03:40:12 -0500 Subject: [PATCH 74/81] scatter stack, not tested --- fastplotlib/graphics/scatter_collection.py | 123 +++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py index b1569cacc..ac8cc307e 100644 --- a/fastplotlib/graphics/scatter_collection.py +++ b/fastplotlib/graphics/scatter_collection.py @@ -515,3 +515,126 @@ def _get_linear_selector_init_args(self, axis, padding): center = bbox[:, 0].mean() return bounds, limits, size, center + + +axes = {"x": 0, "y": 1, "z": 2} + + +class ScatterStack(ScatterCollection): + def __init__( + self, + data: List[np.ndarray], + thickness: float | Iterable[float] = 2.0, + colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", + cmap: Iterable[str] | str = None, + cmap_transform: np.ndarray | List = None, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Sequence[Any] | np.ndarray = None, + isolated_buffer: bool = True, + separation: float = 0.0, + separation_axis: str = "y", + kwargs_lines: list[dict] = None, + **kwargs, + ): + """ + Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + thickness: float or Iterable of float, default 2.0 + | if ``float``, single thickness will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + metadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + separation: float, default 0.0 + space in between each line graphic in the stack + + separation_axis: str, default "y" + axis in which the line graphics in the stack should be separated + + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + """ + super().__init__( + data=data, + thickness=thickness, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + name=name, + names=names, + metadata=metadata, + metadatas=metadatas, + isolated_buffer=isolated_buffer, + kwargs_lines=kwargs_lines, + **kwargs, + ) + + self._sepration_axis = separation_axis + self._separation = separation + + self.separation = separation + + @property + def separation(self) -> float: + """distance between each line in the stack, in world space""" + return self._separation + + @separation.setter + def separation(self, value: float): + separation = float(value) + + axis_zero = 0 + for i, line in enumerate(self.graphics): + if self._sepration_axis == "x": + line.offset = (axis_zero, *line.offset[1:]) + + elif self._sepration_axis == "y": + line.offset = (line.offset[0], axis_zero, line.offset[2]) + + axis_zero = ( + axis_zero + line.data.value[:, axes[self._sepration_axis]].max() + separation + ) + + self._separation = value From a9bfa4480bc385a32223644d29c3564ee5aef6ac Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 11 Feb 2026 16:47:02 -0500 Subject: [PATCH 75/81] progress --- .../widgets/nd_widget/_nd_positions.py | 131 +++++++++++++----- 1 file changed, 98 insertions(+), 33 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 6cc29d92a..8d30fe37a 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -26,7 +26,7 @@ class NDPositionsProcessor(NDProcessor): def __init__( self, - data: ArrayProtocol, + data: Any, multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points display_window: int | float | None = 100, # window for n_datapoints dim only max_display_datapoints: int = 1_000, @@ -34,7 +34,6 @@ def __init__( datapoints_window_size: int | None = None, **kwargs, ): - self._display_window = display_window self._max_display_datapoints = max_display_datapoints @@ -193,6 +192,66 @@ def _apply_window_functions(self, indices: tuple[int, ...]): return data_sliced + def _get_dw_slices(self, indices) -> tuple[slice] | tuple[slice, slice]: + # given indices, return slice using display window + + # display window is interpreted using the index mapping for the `p` dim + dw = self.display_window + + if dw is None: + # just map p dimension at this index and return + index_p = self.index_mappings[-1](indices[-1]) + return (slice(index_p, index_p + 1),) + + # display window is in reference units, apply display window and then map to array indices + # clamp w.r.t. 0 and processor shape `p` dim + hw = dw / 2 + index_p_start = max(self.index_mappings[-1](indices[-1] - hw), 0) + index_p_stop = min(self.index_mappings[-1](indices[-1] + hw), self.shape[-2]) + if index_p_start >= index_p_stop: + index_p_stop = index_p_start + 1 + + slices = [slice(index_p_start, index_p_stop)] + + if self.multi: + slices.insert(0, slice(None)) + + return tuple(slices) + + # + # # clamp w.r.t. processor shape + # + # dw = self.index_mappings[-1](self.display_window) + # + # if dw == 1: + # slices = [slice(index_p, index_p + 1)] + # + # else: + # # half window size + # hw = dw // 2 + # + # # for now assume just a single index provided that indicates x axis value + # start = max(index_p - hw, 0) + # stop = start + dw + # # also add window size of `p` dim so window_func output has the same number of datapoints + # if ( + # self.datapoints_window_func is not None + # and self.datapoints_window_size is not None + # ): + # stop += self.datapoints_window_size - 1 + # # TODO: pad with constant if we're using a window func and the index is near the end + # + # # TODO: uncomment this once we have resizeable buffers!! + # # stop = min(index_p + hw, self.shape[-2]) + # + # slices = [slice(start, stop)] + # + # if self.multi: + # # n - 2 dim is n_lines or n_scatters + # slices.insert(0, slice(None)) + # + # return tuple(slices) + def get(self, indices: tuple[Any, ...]): """ slices through all slider dims and outputs an array that can be used to set graphic data @@ -214,40 +273,45 @@ def get(self, indices: tuple[Any, ...]): # TODO: window function on the `p` n_datapoints dimension if self.display_window is not None: - # display window is interpreted using the index mapping for the `p` dim - dw = self.index_mappings[-1](self.display_window) - - if dw == 1: - slices = [slice(indices[-1], indices[-1] + 1)] - - else: - # half window size - hw = dw // 2 - - # for now assume just a single index provided that indicates x axis value - start = max(indices[-1] - hw, 0) - stop = start + dw - # also add window size of `p` dim so window_func output has the same number of datapoints - if ( - self.datapoints_window_func is not None - and self.datapoints_window_size is not None - ): - stop += self.datapoints_window_size - 1 - # TODO: pad with constant if we're using a window func and the index is near the end - - # TODO: uncomment this once we have resizeable buffers!! - # stop = min(indices[-1] + hw, self.shape[-2]) + slices = self._get_dw_slices(indices) - slices = [slice(start, stop)] - - if self.multi: - # n - 2 dim is n_lines or n_scatters - slices.insert(0, slice(None)) + # if self.display_window is not None: + # # display window is interpreted using the index mapping for the `p` dim + # dw = self.index_mappings[-1](self.display_window) + # + # if dw == 1: + # slices = [slice(indices[-1], indices[-1] + 1)] + # + # else: + # # half window size + # hw = dw // 2 + # + # # for now assume just a single index provided that indicates x axis value + # start = max(indices[-1] - hw, 0) + # stop = start + dw + # # also add window size of `p` dim so window_func output has the same number of datapoints + # if ( + # self.datapoints_window_func is not None + # and self.datapoints_window_size is not None + # ): + # stop += self.datapoints_window_size - 1 + # # TODO: pad with constant if we're using a window func and the index is near the end + # + # # TODO: uncomment this once we have resizeable buffers!! + # # stop = min(indices[-1] + hw, self.shape[-2]) + # + # slices = [slice(start, stop)] + # + # if self.multi: + # # n - 2 dim is n_lines or n_scatters + # slices.insert(0, slice(None)) # 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 graphic_data = window_output[tuple(slices)] + dw = self.index_mappings[-1](self.display_window) + # apply window function on the `p` n_datapoints dim if ( self.datapoints_window_func is not None @@ -308,7 +372,7 @@ class NDPositions: def __init__( self, - data, + data: Any, graphic: Type[ LineGraphic | LineCollection @@ -317,6 +381,7 @@ def __init__( | ScatterCollection | ImageGraphic ], + processor: type[NDPositionsProcessor] = NDPositionsProcessor, multi: bool = False, display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, @@ -328,7 +393,7 @@ def __init__( if issubclass(graphic, LineCollection): multi = True - self._processor = NDPositionsProcessor( + self._processor = processor( data, multi=multi, display_window=display_window, @@ -420,7 +485,7 @@ def _create_graphic( if not self.processor.multi: raise ValueError - if self.processor.data.shape[-1] != 2: + if self.processor.shape[-1] != 2: raise ValueError image_data, x0, x_scale = self._create_heatmap_data(data_slice) From 596b8e76227eb7cb80cdb8364b0041f9f6b7a83c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 13 Feb 2026 23:15:33 -0500 Subject: [PATCH 76/81] scatter collection updates --- fastplotlib/graphics/scatter_collection.py | 4 -- fastplotlib/layouts/_graphic_methods_mixin.py | 46 +++++++++---------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py index ac8cc307e..b8e7556ad 100644 --- a/fastplotlib/graphics/scatter_collection.py +++ b/fastplotlib/graphics/scatter_collection.py @@ -114,7 +114,6 @@ def __init__( self, data: np.ndarray | List[np.ndarray], colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", - uniform_colors: bool = False, cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, sizes: float | Sequence[float] = 5.0, @@ -122,7 +121,6 @@ def __init__( names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, **kwargs, ): @@ -291,12 +289,10 @@ def __init__( lg = ScatterGraphic( data=d, colors=_c, - uniform_color=uniform_colors, sizes=sizes, cmap=_cmap, name=_name, metadata=_m, - isolated_buffer=isolated_buffer, **kwargs_lines, ) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index eda7b1492..bd01855bd 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -33,7 +33,7 @@ def add_image( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - **kwargs + **kwargs, ) -> ImageGraphic: """ @@ -74,7 +74,7 @@ def add_image( cmap, interpolation, cmap_interpolation, - **kwargs + **kwargs, ) def add_image_volume( @@ -92,7 +92,7 @@ def add_image_volume( substep_size: float = 0.1, emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, - **kwargs + **kwargs, ) -> ImageVolumeGraphic: """ @@ -169,7 +169,7 @@ def add_image_volume( substep_size, emissive, shininess, - **kwargs + **kwargs, ) def add_line_collection( @@ -185,7 +185,7 @@ def add_line_collection( metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineCollection: """ @@ -256,7 +256,7 @@ def add_line_collection( metadata, metadatas, kwargs_lines, - **kwargs + **kwargs, ) def add_line( @@ -268,7 +268,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", - **kwargs + **kwargs, ) -> LineGraphic: """ @@ -322,7 +322,7 @@ def add_line( cmap_transform, color_mode, size_space, - **kwargs + **kwargs, ) def add_line_stack( @@ -339,7 +339,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineStack: """ @@ -415,7 +415,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs + **kwargs, ) def add_mesh( @@ -434,7 +434,7 @@ def add_mesh( | numpy.ndarray ) = None, clim: tuple[float, float] = None, - **kwargs + **kwargs, ) -> MeshGraphic: """ @@ -488,7 +488,7 @@ def add_mesh( mapcoords, cmap, clim, - **kwargs + **kwargs, ) def add_polygon( @@ -505,7 +505,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> PolygonGraphic: """ @@ -552,15 +552,13 @@ def add_scatter_collection( self, data: Union[numpy.ndarray, List[numpy.ndarray]], colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", - uniform_colors: bool = False, cmap: Union[Sequence[str], str] = None, cmap_transform: Union[numpy.ndarray, List] = None, - sizes: Union[float, Sequence[float]] = 2.0, + sizes: Union[float, Sequence[float]] = 5.0, name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, **kwargs, ) -> ScatterCollection: @@ -617,7 +615,6 @@ def add_scatter_collection( ScatterCollection, data, colors, - uniform_colors, cmap, cmap_transform, sizes, @@ -625,7 +622,6 @@ def add_scatter_collection( names, metadata, metadatas, - isolated_buffer, kwargs_lines, **kwargs, ) @@ -652,7 +648,7 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Sequence[float]] = 5, uniform_size: bool = True, size_space: str = "screen", - **kwargs + **kwargs, ) -> ScatterGraphic: """ @@ -782,7 +778,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs + **kwargs, ) def add_surface( @@ -799,7 +795,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> SurfaceGraphic: """ @@ -853,7 +849,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs + **kwargs, ) -> TextGraphic: """ @@ -904,7 +900,7 @@ def add_text( screen_space, offset, anchor, - **kwargs + **kwargs, ) def add_vectors( @@ -914,7 +910,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs + **kwargs, ) -> VectorsGraphic: """ @@ -959,5 +955,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs + **kwargs, ) From db2431f47b5a2cdb21b917c36967aa0e9b7bacbe Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 13 Feb 2026 23:16:03 -0500 Subject: [PATCH 77/81] tootip handlers for ndpositions --- fastplotlib/widgets/nd_widget/_nd_positions.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 8d30fe37a..9a2d25048 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -1,3 +1,4 @@ +from functools import partial import inspect from typing import Literal, Callable, Any, Type from warnings import warn @@ -465,6 +466,13 @@ def indices(self, indices): self._indices = indices + def _tooltip_handler(self, graphic, pick_info): + if isinstance(self.graphic, (LineCollection, ScatterCollection)): + # get graphic within the collection + n_index = np.argwhere(self.graphic.graphics == graphic).item() + p_index = pick_info["vertex_index"] + return self.processor.format_tooltip(n_index, p_index) + def _create_graphic( self, graphic_cls: Type[ @@ -500,6 +508,11 @@ def _create_graphic( kwargs = dict() self._graphic = graphic_cls(data_slice, **kwargs) + if hasattr(self.processor, "format_tooltip"): + if isinstance(self._graphic, (LineCollection, ScatterCollection)): + for g in self._graphic.graphics: + g.tooltip_format = partial(self._tooltip_handler, g) + def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: """return [n_rows, n_cols] shape data""" # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense From 57d9a6ba1e3cfacdac5b2055e12b05b05ccac957 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 14 Feb 2026 03:34:03 -0500 Subject: [PATCH 78/81] refactoring, general NDPP_Pandas processor for any dataframe data --- fastplotlib/widgets/nd_widget/__init__.py | 2 + .../nd_widget/_nd_positions/__init__.py | 23 +++++ .../nd_widget/_nd_positions/_pandas.py | 94 +++++++++++++++++++ .../widgets/nd_widget/_nd_positions/_zarr.py | 4 + .../core.py} | 54 +++-------- .../nd_widget/{_nd_image.py => nd_image.py} | 2 +- .../{_processor_base.py => processor_base.py} | 13 +++ 7 files changed, 149 insertions(+), 43 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/__init__.py create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py rename fastplotlib/widgets/nd_widget/{_nd_positions.py => _nd_positions/core.py} (92%) rename fastplotlib/widgets/nd_widget/{_nd_image.py => nd_image.py} (87%) rename fastplotlib/widgets/nd_widget/{_processor_base.py => processor_base.py} (96%) diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index e69de29bb..70c2e7621 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -0,0 +1,2 @@ +from .processor_base import NDProcessor +from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py new file mode 100644 index 000000000..03bb0e8f7 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py @@ -0,0 +1,23 @@ +import importlib + +from .core import NDPositions, NDPositionsProcessor + +class Extras: + pass + +ndp_extras = Extras() + + +for optional in ["pandas", "zarr"]: + try: + importlib.import_module(optional) + except ImportError: + pass + else: + module = importlib.import_module(f"._{optional}", "fastplotlib.widgets.nd_widget._nd_positions") + cls = getattr(module, f"NDPP_{optional.capitalize()}") + setattr( + ndp_extras, + f"NDPP_{optional.capitalize()}", + cls + ) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py new file mode 100644 index 000000000..de26c8a9d --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -0,0 +1,94 @@ +import numpy as np +import pandas as pd + +from .core import NDPositionsProcessor + + +class NDPP_Pandas(NDPositionsProcessor): + def __init__( + self, + data: pd.DataFrame, + columns: list[tuple[str, str] | tuple[str, str, str]], + tooltip_columns: list[str] = None, + max_display_datapoints: int = 1_000, + **kwargs, + ): + data = data + + self._columns = columns + + if tooltip_columns is not None: + if len(tooltip_columns) != len(self.columns): + raise ValueError + self._tooltip_columns = tooltip_columns + self._tooltip = True + else: + self._tooltip_columns = None + self._tooltip = False + + super().__init__( + data=data, + max_display_datapoints=max_display_datapoints, + **kwargs, + ) + + @property + def data(self) -> pd.DataFrame: + return self._data + + def _validate_data(self, data: pd.DataFrame): + if not isinstance(data, pd.DataFrame): + raise TypeError + + return data + + @property + def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: + return self._columns + + @property + def multi(self) -> bool: + return True + + @multi.setter + def multi(self, v): + pass + + @property + def shape(self) -> tuple[int, ...]: + # n_graphical_elements, n_timepoints, 2 + return len(self.columns), self.data.index.size, 2 + + @property + def ndim(self) -> int: + return len(self.shape) + + @property + def n_slider_dims(self) -> int: + return 1 + + @property + def tooltip(self) -> bool: + return self._tooltip + + def tooltip_format(self, n: int, p: int): + # datapoint index w.r.t. full data + p += self._slices[-1].start + return str(self.data[self._tooltip_columns[n]][p]) + + def get(self, indices: tuple[float | int, ...]) -> np.ndarray: + if not isinstance(indices, tuple): + raise TypeError(".get() must receive a tuple of float | int indices") + # assume no additional slider dims, only time slider dim + self._slices = self._get_dw_slices(indices) + + + gdata_shape = len(self.columns), self._slices[-1].stop - self._slices[-1].start, 3 + gdata = np.zeros(shape=gdata_shape, dtype=np.float32) + + for i, col in enumerate(self.columns): + gdata[i, :, :len(col)] = np.column_stack( + [self.data[c][self._slices[-1]] for c in col] + ) + + return gdata diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py b/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py new file mode 100644 index 000000000..fb3bb7015 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py @@ -0,0 +1,4 @@ +# placeholder + +class NDPP_Zarr: + pass diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py similarity index 92% rename from fastplotlib/widgets/nd_widget/_nd_positions.py rename to fastplotlib/widgets/nd_widget/_nd_positions/core.py index 9a2d25048..b95916ce8 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -1,15 +1,13 @@ from functools import partial -import inspect from typing import Literal, Callable, Any, Type from warnings import warn import numpy as np -from numpy.typing import ArrayLike from numpy.lib.stride_tricks import sliding_window_view -from ...utils import subsample_array, ArrayProtocol +from ....utils import subsample_array, ArrayProtocol -from ...graphics import ( +from ....graphics import ( Graphic, ImageGraphic, LineGraphic, @@ -18,7 +16,7 @@ ScatterGraphic, ScatterCollection, ) -from ._processor_base import NDProcessor, WindowFuncCallable +from ..processor_base import NDProcessor, WindowFuncCallable # TODO: Maybe get rid of n_display_dims in NDProcessor, @@ -219,40 +217,6 @@ def _get_dw_slices(self, indices) -> tuple[slice] | tuple[slice, slice]: return tuple(slices) - # - # # clamp w.r.t. processor shape - # - # dw = self.index_mappings[-1](self.display_window) - # - # if dw == 1: - # slices = [slice(index_p, index_p + 1)] - # - # else: - # # half window size - # hw = dw // 2 - # - # # for now assume just a single index provided that indicates x axis value - # start = max(index_p - hw, 0) - # stop = start + dw - # # also add window size of `p` dim so window_func output has the same number of datapoints - # if ( - # self.datapoints_window_func is not None - # and self.datapoints_window_size is not None - # ): - # stop += self.datapoints_window_size - 1 - # # TODO: pad with constant if we're using a window func and the index is near the end - # - # # TODO: uncomment this once we have resizeable buffers!! - # # stop = min(index_p + hw, self.shape[-2]) - # - # slices = [slice(start, stop)] - # - # if self.multi: - # # n - 2 dim is n_lines or n_scatters - # slices.insert(0, slice(None)) - # - # return tuple(slices) - def get(self, indices: tuple[Any, ...]): """ slices through all slider dims and outputs an array that can be used to set graphic data @@ -370,10 +334,10 @@ def get(self, indices: tuple[Any, ...]): class NDPositions: - def __init__( self, data: Any, + *args, graphic: Type[ LineGraphic | LineCollection @@ -390,18 +354,24 @@ def __init__( index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, graphic_kwargs: dict = None, + processor_kwargs: dict = None, ): if issubclass(graphic, LineCollection): multi = True + if processor_kwargs is None: + processor_kwargs = dict() + self._processor = processor( data, + *args, multi=multi, display_window=display_window, max_display_datapoints=max_display_datapoints, window_funcs=window_funcs, window_sizes=window_sizes, index_mappings=index_mappings, + **processor_kwargs, ) self._processor.p_max = 1_000 @@ -471,7 +441,7 @@ def _tooltip_handler(self, graphic, pick_info): # get graphic within the collection n_index = np.argwhere(self.graphic.graphics == graphic).item() p_index = pick_info["vertex_index"] - return self.processor.format_tooltip(n_index, p_index) + return self.processor.tooltip_format(n_index, p_index) def _create_graphic( self, @@ -508,7 +478,7 @@ def _create_graphic( kwargs = dict() self._graphic = graphic_cls(data_slice, **kwargs) - if hasattr(self.processor, "format_tooltip"): + if self.processor.tooltip: if isinstance(self._graphic, (LineCollection, ScatterCollection)): for g in self._graphic.graphics: g.tooltip_format = partial(self._tooltip_handler, g) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/nd_image.py similarity index 87% rename from fastplotlib/widgets/nd_widget/_nd_image.py rename to fastplotlib/widgets/nd_widget/nd_image.py index f115e146e..4972db9d5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/nd_image.py @@ -1,6 +1,6 @@ from typing import Literal -from ._processor_base import NDProcessor +from .processor_base import NDProcessor class NDImageProcessor(NDProcessor): diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/processor_base.py similarity index 96% rename from fastplotlib/widgets/nd_widget/_processor_base.py rename to fastplotlib/widgets/nd_widget/processor_base.py index 225608cca..a1cd5311c 100644 --- a/fastplotlib/widgets/nd_widget/_processor_base.py +++ b/fastplotlib/widgets/nd_widget/processor_base.py @@ -55,6 +55,19 @@ def _validate_data(self, data: ArrayProtocol): return data + @property + def tooltip(self) -> bool: + """ + whether or not a custom tooltip formatter method exists + """ + return False + + def tooltip_format(self, *args) -> str | None: + """ + Override in subclass to format custom tooltips + """ + return None + @property def slider_dims(self): raise NotImplementedError From 3a52d0175a01f9f3871f76af3d1a8f6b004f5b6b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 14 Feb 2026 04:01:02 -0500 Subject: [PATCH 79/81] nd-iw backup --- .../widgets/image_widget/_nd_iw_backup.py | 1007 +++++++++++++++++ 1 file changed, 1007 insertions(+) create mode 100644 fastplotlib/widgets/image_widget/_nd_iw_backup.py diff --git a/fastplotlib/widgets/image_widget/_nd_iw_backup.py b/fastplotlib/widgets/image_widget/_nd_iw_backup.py new file mode 100644 index 000000000..7db265c0c --- /dev/null +++ b/fastplotlib/widgets/image_widget/_nd_iw_backup.py @@ -0,0 +1,1007 @@ +from typing import Callable, Sequence, Literal +from warnings import warn + +import numpy as np + +from rendercanvas import BaseRenderCanvas + +from ...layouts import ImguiFigure as Figure +from ...graphics import ImageGraphic, ImageVolumeGraphic +from ...utils import calculate_figure_shape, quick_min_max, ArrayProtocol +from ...tools import HistogramLUTTool +from ._sliders import ImageWidgetSliders +from ._processor import NDImageProcessor, WindowFuncCallable +from ._properties import ImageWidgetProperty, Indices + + +IMGUI_SLIDER_HEIGHT = 49 + + +class ImageWidget: + def __init__( + self, + data: ArrayProtocol | Sequence[ArrayProtocol | None] | None, + processors: NDImageProcessor | Sequence[NDImageProcessor] = NDImageProcessor, + n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, + slider_dim_names: Sequence[str] | None = None, # dim names left -> right + rgb: bool | Sequence[bool] = False, + cmap: str | Sequence[str] = "plasma", + window_funcs: ( + tuple[WindowFuncCallable | None, ...] + | WindowFuncCallable + | None + | Sequence[ + tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None + ] + ) = None, + window_sizes: ( + tuple[int | None, ...] | Sequence[tuple[int | None, ...] | None] + ) = None, + window_order: tuple[int, ...] | Sequence[tuple[int, ...] | None] = None, + spatial_func: ( + Callable[[ArrayProtocol], ArrayProtocol] + | Sequence[Callable[[ArrayProtocol], ArrayProtocol]] + | None + ) = None, + sliders_dim_order: Literal["right", "left"] = "right", + figure_shape: tuple[int, int] = None, + names: Sequence[str] = None, + figure_kwargs: dict = None, + histogram_widget: bool = True, + histogram_init_quantile: int = (0, 100), + graphic_kwargs: dict | Sequence[dict] = None, + ): + """ + This widget facilitates high-level navigation through image stacks, which are arrays containing one or more + images. It includes sliders for key dimensions such as "t" (time) and "z", enabling users to smoothly navigate + through one or multiple image stacks simultaneously. + + Allowed dimensions orders for each image stack: Note that each has a an optional (c) channel which refers to + RGB(A) a channel. So this channel should be either 3 or 4. + + Parameters + ---------- + data: ArrayProtocol | Sequence[ArrayProtocol | None] | None + array-like or a list of array-like, each array must have a minimum of 2 dimensions + + processors: NDImageProcessor | Sequence[NDImageProcessor], default NDImageProcessor + The image processors used for each n-dimensional data array + + n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]], default 2 + number of display dimensions + + slider_dim_names: Sequence[str], optional + optional list/tuple of names for each slider dim + + rgb: bool | Sequence[bool], default + whether or not each data array represents RGB(A) images + + figure_shape: Optional[Tuple[int, int]] + manually provide the shape for the Figure, otherwise the number of rows and columns is estimated + + figure_kwargs: dict, optional + passed to ``Figure`` + + names: Optional[str] + gives names to the subplots + + histogram_widget: bool, default False + make histogram LUT widget for each subplot + + rgb: bool | list[bool], default None + bool or list of bool for each input data array in the ImageWidget, indicating whether the corresponding + data arrays are grayscale or RGB(A). + + graphic_kwargs: Any + passed to each ImageGraphic in the ImageWidget figure subplots + + """ + + if figure_kwargs is None: + figure_kwargs = dict() + + if isinstance(data, ArrayProtocol) or (data is None): + data = [data] + + elif isinstance(data, (list, tuple)): + # verify that it's a list of np.ndarray + if not all([isinstance(d, ArrayProtocol) or d is None for d in data]): + raise TypeError( + f"`data` must be an array-like type or a list/tuple of array-like or None. " + f"You have passed the following type {type(data)}" + ) + + else: + raise TypeError( + f"`data` must be an array-like type or a list/tuple of array-like or None. " + f"You have passed the following type {type(data)}" + ) + + if issubclass(processors, NDImageProcessor): + processors = [processors] * len(data) + + elif isinstance(processors, (tuple, list)): + if not all([issubclass(p, NDImageProcessor) for p in processors]): + raise TypeError( + f"`processors` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " + f"list/tuple of `NDImageProcess` subclasses. You have passed: {processors}" + ) + + else: + raise TypeError( + f"`processors` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " + f"list/tuple of `NDImageProcess` subclasses. You have passed: {processors}" + ) + + # subplot layout + if figure_shape is None: + if "shape" in figure_kwargs: + figure_shape = figure_kwargs["shape"] + else: + figure_shape = calculate_figure_shape(len(data)) + + # Regardless of how figure_shape is computed, below code + # verifies that figure shape is large enough for the number of image arrays passed + if figure_shape[0] * figure_shape[1] < len(data): + original_shape = (figure_shape[0], figure_shape[1]) + figure_shape = calculate_figure_shape(len(data)) + warn( + f"Original `figure_shape` was: {original_shape} " + f" but data length is {len(data)}" + f" Resetting figure shape to: {figure_shape}" + ) + + elif isinstance(rgb, bool): + rgb = [rgb] * len(data) + + if not all([isinstance(v, bool) for v in rgb]): + raise TypeError( + f"`rgb` parameter must be a bool or a Sequence of bool, you have passed: {rgb}" + ) + + if not len(rgb) == len(data): + raise ValueError( + f"len(rgb) != len(data), {len(rgb)} != {len(data)}. These must be equal" + ) + + if names is not None: + if not all([isinstance(n, str) for n in names]): + raise TypeError("optional argument `names` must be a Sequence of str") + + if len(names) != len(data): + raise ValueError( + "number of `names` for subplots must be same as the number of data arrays" + ) + + # verify window funcs + if window_funcs is None: + win_funcs = [None] * len(data) + + elif callable(window_funcs) or all( + [callable(f) or f is None for f in window_funcs] + ): + # across all data arrays + # one window function defined for all dims, or window functions defined per-dim + win_funcs = [window_funcs] * len(data) + + # if the above two clauses didn't trigger, then window_funcs defined per-dim, per data array + elif len(window_funcs) != len(data): + raise IndexError + else: + win_funcs = window_funcs + + # verify window sizes + if window_sizes is None: + win_sizes = [window_sizes] * len(data) + + elif isinstance(window_sizes, int): + win_sizes = [window_sizes] * len(data) + + elif all([isinstance(size, int) or size is None for size in window_sizes]): + # window sizes defined per-dim across all data arrays + win_sizes = [window_sizes] * len(data) + + elif len(window_sizes) != len(data): + # window sizes defined per-dim, per data array + raise IndexError + else: + win_sizes = window_sizes + + # verify window orders + if window_order is None: + win_order = [None] * len(data) + + elif all([isinstance(o, int) for o in order]): + # window order defined per-dim across all data arrays + win_order = [window_order] * len(data) + + elif len(window_order) != len(data): + raise IndexError + + else: + win_order = window_order + + # verify spatial_func + if spatial_func is None: + spatial_func = [None] * len(data) + + elif callable(spatial_func): + # same spatial_func for all data arrays + spatial_func = [spatial_func] * len(data) + + elif len(spatial_func) != len(data): + raise IndexError + + else: + spatial_func = spatial_func + + # verify number of display dims + if isinstance(n_display_dims, (int, np.integer)): + n_display_dims = [n_display_dims] * len(data) + + elif isinstance(n_display_dims, (tuple, list)): + if not all([isinstance(n, (int, np.integer)) for n in n_display_dims]): + raise TypeError + + if len(n_display_dims) != len(data): + raise IndexError + else: + raise TypeError + + n_display_dims = tuple(n_display_dims) + + if sliders_dim_order not in ("right",): + raise ValueError( + f"Only 'right' slider dims order is currently supported, you passed: {sliders_dim_order}" + ) + self._sliders_dim_order = sliders_dim_order + + self._slider_dim_names = None + self.slider_dim_names = slider_dim_names + + self._histogram_widget = histogram_widget + + # make NDImageArrays + self._image_processors: list[NDImageProcessor] = list() + for i in range(len(data)): + cls = processors[i] + image_processor = cls( + data=data[i], + rgb=rgb[i], + n_display_dims=n_display_dims[i], + window_funcs=win_funcs[i], + window_sizes=win_sizes[i], + window_order=win_order[i], + spatial_func=spatial_func[i], + compute_histogram=self._histogram_widget, + ) + + self._image_processors.append(image_processor) + + self._data = ImageWidgetProperty(self, "data") + self._rgb = ImageWidgetProperty(self, "rgb") + self._n_display_dims = ImageWidgetProperty(self, "n_display_dims") + self._window_funcs = ImageWidgetProperty(self, "window_funcs") + self._window_sizes = ImageWidgetProperty(self, "window_sizes") + self._window_order = ImageWidgetProperty(self, "window_order") + self._spatial_func = ImageWidgetProperty(self, "spatial_func") + + if len(set(n_display_dims)) > 1: + # assume user wants one controller for 2D images and another for 3D image volumes + n_subplots = np.prod(figure_shape) + controller_ids = [0] * n_subplots + controller_types = ["panzoom"] * n_subplots + + for i in range(len(data)): + if n_display_dims[i] == 2: + controller_ids[i] = 1 + else: + controller_ids[i] = 2 + controller_types[i] = "orbit" + + # needs to be a list of list + controller_ids = [controller_ids] + + else: + controller_ids = "sync" + controller_types = None + + figure_kwargs_default = { + "controller_ids": controller_ids, + "controller_types": controller_types, + "names": names, + } + + # update the default kwargs with any user-specified kwargs + # user specified kwargs will overwrite the defaults + figure_kwargs_default.update(figure_kwargs) + figure_kwargs_default["shape"] = figure_shape + + if graphic_kwargs is None: + graphic_kwargs = [dict()] * len(data) + + elif isinstance(graphic_kwargs, dict): + graphic_kwargs = [graphic_kwargs] * len(data) + + elif len(graphic_kwargs) != len(data): + raise IndexError + + if cmap is None: + cmap = [None] * len(data) + + elif isinstance(cmap, str): + cmap = [cmap] * len(data) + + elif not all([isinstance(c, str) for c in cmap]): + raise TypeError(f"`cmap` must be a or a list/tuple of ") + + self._figure: Figure = Figure(**figure_kwargs_default) + + self._indices = Indices(list(0 for i in range(self.n_sliders)), self) + + for i, subplot in zip(range(len(self._image_processors)), self.figure): + image_data = self._get_image( + self._image_processors[i], tuple(self._indices) + ) + + if image_data is None: + # this subplot/data array is blank, skip + continue + + # next 20 lines are just vmin, vmax parsing + vmin_specified, vmax_specified = None, None + if "vmin" in graphic_kwargs[i].keys(): + vmin_specified = graphic_kwargs[i].pop("vmin") + if "vmax" in graphic_kwargs[i].keys(): + vmax_specified = graphic_kwargs[i].pop("vmax") + + if (vmin_specified is None) or (vmax_specified is None): + # if either vmin or vmax are not specified, calculate an estimate by subsampling + vmin_estimate, vmax_estimate = quick_min_max( + self._image_processors[i].data + ) + + # decide vmin, vmax passed to ImageGraphic constructor based on whether it's user specified or now + if vmin_specified is None: + # user hasn't specified vmin, use estimated value + vmin = vmin_estimate + else: + # user has provided a specific value, use that + vmin = vmin_specified + + if vmax_specified is None: + vmax = vmax_estimate + else: + vmax = vmax_specified + else: + # both vmin and vmax are specified + vmin, vmax = vmin_specified, vmax_specified + + graphic_kwargs[i]["cmap"] = cmap[i] + + if self._image_processors[i].n_display_dims == 2: + # create an Image + graphic = ImageGraphic( + data=image_data, + name="image_widget_managed", + vmin=vmin, + vmax=vmax, + **graphic_kwargs[i], + ) + elif self._image_processors[i].n_display_dims == 3: + # create an ImageVolume + graphic = ImageVolumeGraphic( + data=image_data, + name="image_widget_managed", + vmin=vmin, + vmax=vmax, + **graphic_kwargs[i], + ) + subplot.camera.fov = 50 + + subplot.add_graphic(graphic) + + self._reset_histogram(subplot, self._image_processors[i]) + + self._sliders_ui = ImageWidgetSliders( + figure=self.figure, + size=57 + (IMGUI_SLIDER_HEIGHT * self.n_sliders), + location="bottom", + title="ImageWidget Controls", + image_widget=self, + ) + + self.figure.add_gui(self._sliders_ui) + + self._indices_changed_handlers = set() + + self._reentrant_block = False + + @property + def data(self) -> ImageWidgetProperty[ArrayProtocol | None]: + """get or set the nd-image data arrays""" + return self._data + + @data.setter + def data(self, new_data: Sequence[ArrayProtocol | None]): + if isinstance(new_data, ArrayProtocol) or new_data is None: + new_data = [new_data] * len(self._image_processors) + + if len(new_data) != len(self._image_processors): + raise IndexError + + # if the data array hasn't been changed + # graphics will not be reset for this data index + skip_indices = list() + + for i, (new_data, image_processor) in enumerate( + zip(new_data, self._image_processors) + ): + if new_data is image_processor.data: + skip_indices.append(i) + continue + + image_processor.data = new_data + + self._reset(skip_indices) + + @property + def rgb(self) -> ImageWidgetProperty[bool]: + """get or set the rgb toggle for each data array""" + return self._rgb + + @rgb.setter + def rgb(self, rgb: Sequence[bool]): + if isinstance(rgb, bool): + rgb = [rgb] * len(self._image_processors) + + if len(rgb) != len(self._image_processors): + raise IndexError + + # if the rgb option hasn't been changed + # graphics will not be reset for this data index + skip_indices = list() + + for i, (new, image_processor) in enumerate(zip(rgb, self._image_processors)): + if image_processor.rgb == new: + skip_indices.append(i) + continue + + image_processor.rgb = new + + self._reset(skip_indices) + + @property + def n_display_dims(self) -> ImageWidgetProperty[Literal[2, 3]]: + """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" + return self._n_display_dims + + @n_display_dims.setter + def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[2, 3]): + if isinstance(new_ndd, (int, np.integer)): + if new_ndd == 2 or new_ndd == 3: + new_ndd = [new_ndd] * len(self._image_processors) + else: + raise ValueError + + if len(new_ndd) != len(self._image_processors): + raise IndexError + + if not all([(n == 2) or (n == 3) for n in new_ndd]): + raise ValueError + + # if the n_display_dims hasn't been changed for this data array + # graphics will not be reset for this data array index + skip_indices = list() + + # first update image arrays + for i, (image_processor, new) in enumerate( + zip(self._image_processors, new_ndd) + ): + if new > image_processor.max_n_display_dims: + raise IndexError( + f"number of display dims exceeds maximum number of possible " + f"display dimensions: {image_processor.max_n_display_dims}, for array at index: " + f"{i} with shape: {image_processor.shape}, and rgb set to: {image_processor.rgb}" + ) + + if image_processor.n_display_dims == new: + skip_indices.append(i) + else: + image_processor.n_display_dims = new + + self._reset(skip_indices) + + @property + def window_funcs(self) -> ImageWidgetProperty[tuple[WindowFuncCallable | None] | None]: + """get or set the window functions""" + return self._window_funcs + + @window_funcs.setter + def window_funcs(self, new_funcs: Sequence[WindowFuncCallable | None] | None): + if callable(new_funcs) or new_funcs is None: + new_funcs = [new_funcs] * len(self._image_processors) + + if len(new_funcs) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("window_funcs", new_funcs) + + @property + def window_sizes(self) -> ImageWidgetProperty[tuple[int | None, ...] | None]: + """get or set the window sizes""" + return self._window_sizes + + @window_sizes.setter + def window_sizes( + self, new_sizes: Sequence[tuple[int | None, ...] | int | None] | int | None + ): + if isinstance(new_sizes, int) or new_sizes is None: + # same window for all data arrays + new_sizes = [new_sizes] * len(self._image_processors) + + if len(new_sizes) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("window_sizes", new_sizes) + + @property + def window_order(self) -> ImageWidgetProperty[tuple[int, ...] | None]: + """get or set order in which window functions are applied over dimensions""" + return self._window_order + + @window_order.setter + def window_order(self, new_order: Sequence[tuple[int, ...]]): + if new_order is None: + new_order = [new_order] * len(self._image_processors) + + if all([isinstance(order, (int, np.integer))] for order in new_order): + # same order specified across all data arrays + new_order = [new_order] * len(self._image_processors) + + if len(new_order) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("window_order", new_order) + + @property + def spatial_func(self) -> ImageWidgetProperty[Callable | None]: + """Get or set a spatial_func that operates on the spatial dimensions of the 2D or 3D image""" + return self._spatial_func + + @spatial_func.setter + def spatial_func(self, funcs: Callable | Sequence[Callable] | None): + if callable(funcs) or funcs is None: + funcs = [funcs] * len(self._image_processors) + + if len(funcs) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("spatial_func", funcs) + + def _set_image_processor_funcs(self, attr, new_values): + """sets window_funcs, window_sizes, window_order, or spatial_func and updates displayed data and histograms""" + for new, image_processor, subplot in zip( + new_values, self._image_processors, self.figure + ): + if getattr(image_processor, attr) == new: + continue + + setattr(image_processor, attr, new) + + # window functions and spatial functions will only change the histogram + # they do not change the collections of dimensions, so we don't need to call _reset_dimensions + # they also do not change the image graphic, so we do not need to call _reset_image_graphics + self._reset_histogram(subplot, image_processor) + + # update the displayed image data in the graphics + self.indices = self.indices + + @property + def indices(self) -> ImageWidgetProperty[int]: + """ + Get or set the current indices. + + Returns + ------- + indices: ImageWidgetProperty[int] + integer index for each slider dimension + + """ + return self._indices + + @indices.setter + def indices(self, new_indices: Sequence[int]): + if self._reentrant_block: + return + + try: + self._reentrant_block = True # block re-execution until new_indices has *fully* completed execution + + if len(new_indices) != self.n_sliders: + raise IndexError( + f"len(new_indices) != ImageWidget.n_sliders, {len(new_indices)} != {self.n_sliders}. " + f"The length of the new_indices must be the same as the number of sliders" + ) + + if any([i < 0 for i in new_indices]): + raise IndexError( + f"only positive index values are supported, you have passed: {new_indices}" + ) + + for image_processor, graphic in zip(self._image_processors, self.graphics): + new_data = self._get_image(image_processor, indices=new_indices) + if new_data is None: + continue + + graphic.data = new_data + + self._indices._fpl_set(new_indices) + + # call any event handlers + for handler in self._indices_changed_handlers: + handler(tuple(self.indices)) + + except Exception as exc: + # raise original exception + raise exc # indices setter has raised. The lines above below are probably more relevant! + finally: + # set_value has finished executing, now allow future executions + self._reentrant_block = False + + @property + def histogram_widget(self) -> bool: + """show or hide the histograms""" + return self._histogram_widget + + @histogram_widget.setter + def histogram_widget(self, show_histogram: bool): + if not isinstance(show_histogram, bool): + raise TypeError( + f"`histogram_widget` can be set with a bool, you have passed: {show_histogram}" + ) + + for subplot, image_processor in zip(self.figure, self._image_processors): + image_processor.compute_histogram = show_histogram + self._reset_histogram(subplot, image_processor) + + @property + def n_sliders(self) -> int: + """number of sliders""" + return max([a.n_slider_dims for a in self._image_processors]) + + @property + def bounds(self) -> tuple[int, ...]: + """The max bound across all dimensions across all data arrays""" + # initialize with 0 + bounds = [0] * self.n_sliders + + # TODO: implement left -> right slider dims ordering, right now it's only right -> left + # in reverse because dims go left <- right + for i, dim in enumerate(range(-1, -self.n_sliders - 1, -1)): + # across each dim + for array in self._image_processors: + if i > array.n_slider_dims - 1: + continue + # across each data array + # dims go left <- right + bounds[dim] = max(array.slider_dims_shape[dim], bounds[dim]) + + return bounds + + @property + def slider_dim_names(self) -> tuple[str, ...]: + return self._slider_dim_names + + @slider_dim_names.setter + def slider_dim_names(self, names: Sequence[str]): + if names is None: + self._slider_dim_names = None + return + + if not all([isinstance(n, str) for n in names]): + raise TypeError(f"`slider_dim_names` must be set with a list/tuple of , you passed: {names}") + + if len(set(names)) != len(names): + raise ValueError( + f"`slider_dim_names` must be unique, you passed: {names}" + ) + + self._slider_dim_names = tuple(names) + + def _get_image( + self, image_processor: NDImageProcessor, indices: Sequence[int] + ) -> ArrayProtocol: + """Get a processed 2d or 3d image from the NDImage at the given indices""" + n = image_processor.n_slider_dims + + if self._sliders_dim_order == "right": + return image_processor.get(indices[-n:]) + + elif self._sliders_dim_order == "left": + # TODO: left -> right is not fully implemented yet in ImageWidget + return image_processor.get(indices[:n]) + + def _reset_dimensions(self): + """reset the dimensions w.r.t. current collection of NDImageProcessors""" + # TODO: implement left -> right slider dims ordering, right now it's only right -> left + # add or remove dims from indices + # trim any excess dimensions + while len(self._indices) > self.n_sliders: + # remove outer most dims first + self._indices.pop_dim() + self._sliders_ui.pop_dim() + + # add any new dimensions that aren't present + while len(self.indices) < self.n_sliders: + # insert right -> left + self._indices.push_dim() + self._sliders_ui.push_dim() + + self._sliders_ui.size = 57 + (IMGUI_SLIDER_HEIGHT * self.n_sliders) + + def _reset_image_graphics(self, subplot, image_processor): + """delete and create a new image graphic if necessary""" + new_image = self._get_image(image_processor, indices=tuple(self.indices)) + if new_image is None: + if "image_widget_managed" in subplot: + # delete graphic from this subplot if present + subplot.delete_graphic(subplot["image_widget_managed"]) + # skip this subplot + return + + # check if a graphic exists + if "image_widget_managed" in subplot: + # create a new graphic only if the Texture buffer shape doesn't match + if subplot["image_widget_managed"].data.value.shape == new_image.shape: + return + + # keep cmap + cmap = subplot["image_widget_managed"].cmap + if cmap is None: + # ex: going from rgb -> grayscale + cmap = "plasma" + # delete graphic since it will be replaced + subplot.delete_graphic(subplot["image_widget_managed"]) + else: + # default cmap + cmap = "plasma" + + if image_processor.n_display_dims == 2: + g = subplot.add_image( + data=new_image, cmap=cmap, name="image_widget_managed" + ) + + # set camera orthogonal to the xy plane, flip y axis + subplot.camera.set_state( + { + "position": [0, 0, -1], + "rotation": [0, 0, 0, 1], + "scale": [1, -1, 1], + "reference_up": [0, 1, 0], + "fov": 0, + "depth_range": None, + } + ) + + subplot.controller = "panzoom" + subplot.axes.intersection = None + subplot.auto_scale() + + elif image_processor.n_display_dims == 3: + g = subplot.add_image_volume( + data=new_image, cmap=cmap, name="image_widget_managed" + ) + subplot.camera.fov = 50 + subplot.controller = "orbit" + + # make sure all 3D dimension camera scales are positive + # MIP rendering doesn't work with negative camera scales + for dim in ["x", "y", "z"]: + if getattr(subplot.camera.local, f"scale_{dim}") < 0: + setattr(subplot.camera.local, f"scale_{dim}", 1) + + subplot.auto_scale() + + def _reset_histogram(self, subplot, image_processor): + """reset the histogram""" + if not self._histogram_widget: + subplot.docks["right"].size = 0 + return + + if image_processor.histogram is None: + # no histogram available for this processor + # either there is no data array in this subplot, + # or a histogram routine does not exist for this processor + subplot.docks["right"].size = 0 + return + + if "image_widget_managed" not in subplot: + # no image in this subplot + subplot.docks["right"].size = 0 + return + + image = subplot["image_widget_managed"] + + if "histogram_lut" in subplot.docks["right"]: + hlut: HistogramLUTTool = subplot.docks["right"]["histogram_lut"] + hlut.histogram = image_processor.histogram + hlut.images = image + if subplot.docks["right"].size < 1: + subplot.docks["right"].size = 80 + + else: + # need to make one + hlut = HistogramLUTTool( + histogram=image_processor.histogram, + images=image, + name="histogram_lut", + ) + + subplot.docks["right"].add_graphic(hlut) + subplot.docks["right"].size = 80 + + self.reset_vmin_vmax() + + def _reset(self, skip_data_indices: tuple[int, ...] = None): + if skip_data_indices is None: + skip_data_indices = tuple() + + # reset the slider indices according to the new collection of dimensions + self._reset_dimensions() + # update graphics where display dims have changed accordings to indices + for i, (subplot, image_processor) in enumerate( + zip(self.figure, self._image_processors) + ): + if i in skip_data_indices: + continue + + self._reset_image_graphics(subplot, image_processor) + self._reset_histogram(subplot, image_processor) + + # force an update + self.indices = self.indices + + @property + def figure(self) -> Figure: + """ + ``Figure`` used by `ImageWidget`. + """ + return self._figure + + @property + def graphics(self) -> list[ImageGraphic]: + """List of ``ImageWidget`` managed graphics.""" + iw_managed = list() + for subplot in self.figure: + if "image_widget_managed" in subplot: + iw_managed.append(subplot["image_widget_managed"]) + else: + iw_managed.append(None) + return tuple(iw_managed) + + @property + def cmap(self) -> tuple[str | None, ...]: + """get the cmaps, or set the cmap across all images""" + return tuple(g.cmap for g in self.graphics) + + @cmap.setter + def cmap(self, name: str): + for g in self.graphics: + if g is None: + # no data at this index + continue + + if g.cmap is None: + # if rgb + continue + + g.cmap = name + + def add_event_handler(self, handler: callable, event: str = "indices"): + """ + Register an event handler. + + Currently the only event that ImageWidget supports is "indices". This event is + emitted whenever the indices of the ImageWidget changes. + + Parameters + ---------- + handler: callable + callback function, must take a tuple of int as the only argument. This tuple will be the `indices` + + event: str, "indices" + the only supported event is "indices" + + Example + ------- + + .. code-block:: py + + def my_handler(indices): + print(indices) + # example prints: (100, 15) if the data has 2 slider dimensions with sliders at positions 100, 15 + + # create an image widget + iw = ImageWidget(...) + + # add event handler + iw.add_event_handler(my_handler) + + """ + if event != "indices": + raise ValueError("`indices` is the only event supported by `ImageWidget`") + + self._indices_changed_handlers.add(handler) + + def remove_event_handler(self, handler: callable): + """Remove a registered event handler""" + self._indices_changed_handlers.remove(handler) + + def clear_event_handlers(self): + """Clear all registered event handlers""" + self._indices_changed_handlers.clear() + + def reset_vmin_vmax(self): + """ + Reset the vmin and vmax w.r.t. the full data + """ + for image_processor, subplot in zip(self._image_processors, self.figure): + if "histogram_lut" not in subplot.docks["right"]: + continue + + if image_processor.histogram is None: + continue + + hlut = subplot.docks["right"]["histogram_lut"] + hlut.histogram = image_processor.histogram + + edges = image_processor.histogram[1] + + hlut.vmin, hlut.vmax = edges[0], edges[-1] + + def reset_vmin_vmax_frame(self): + """ + Resets the vmin vmax and HistogramLUT widgets w.r.t. the current data shown in the + ImageGraphic instead of the data in the full data array. For example, if a post-processing + function is used, the range of values in the ImageGraphic can be very different from the + range of values in the full data array. + """ + + for subplot, image_processor in zip(self.figure, self._image_processors): + if "histogram_lut" not in subplot.docks["right"]: + continue + + if image_processor.histogram is None: + continue + + hlut = subplot.docks["right"]["histogram_lut"] + # set the data using the current image graphic data + image = subplot["image_widget_managed"] + freqs, edges = np.histogram(image.data.value, bins=100) + hlut.histogram = (freqs, edges) + hlut.vmin, hlut.vmax = edges[0], edges[-1] + + def show(self, **kwargs): + """ + Show the widget. + + Parameters + ---------- + + kwargs: Any + passed to `Figure.show()`t + + Returns + ------- + BaseRenderCanvas + In Qt or GLFW, the canvas window containing the Figure will be shown. + In jupyter, it will display the plot in the output cell or sidecar. + + """ + + return self.figure.show(**kwargs) + + def close(self): + """Close Widget""" + self.figure.close() From 83e6f12f2f35ed1a1c8303f23eddc8136248b449 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 14 Feb 2026 21:23:21 -0500 Subject: [PATCH 80/81] correct ImageGraphic w.r.t. ndw --- fastplotlib/graphics/image.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 3c0205fe6..7b670d531 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -164,7 +164,6 @@ def __init__( # set map to None for RGB images if self._data.value.ndim == 3: self._cmap = None - self._cmap_interpolation = None _map = None elif self._data.value.ndim == 2: @@ -317,10 +316,9 @@ def interpolation(self, value: str): self._interpolation.set_value(self, value) @property - def cmap_interpolation(self) -> str | None: - """cmap interpolation method, 'linear' or 'nearest'. `None` if image is RGB(A)""" - if self._cmap_interpolation is not None: - return self._cmap_interpolation.value + def cmap_interpolation(self) -> str: + """cmap interpolation method, 'linear' or 'nearest'. Used only for grayscale images""" + return self._cmap_interpolation.value @cmap_interpolation.setter def cmap_interpolation(self, value: str): From 4cc82eaca65ca93db72e140d78be7e31cd514616 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 16 Feb 2026 05:44:09 -0500 Subject: [PATCH 81/81] last fixes in ndi --- fastplotlib/graphics/image_volume.py | 38 ++++++++----------- .../ui/right_click_menus/_standard_menu.py | 6 --- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 0cad6fba2..3d2d064e8 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -204,23 +204,17 @@ def __init__( self._vmax = ImageVmax(vmax) self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) + + # use TextureMap for grayscale images + self._cmap = ImageCmap(cmap) + self._texture_map = pygfx.TextureMap( + self._cmap.texture, + filter=self._cmap_interpolation.value, + wrap="clamp-to-edge", + ) - if self._data.value.ndim == 4: - # set map to None for RGB image volumes - self._cmap = None - self._texture_map = None - self._cmap_interpolation = None - - elif self._data.value.ndim == 3: - # use TextureMap for grayscale images - self._cmap = ImageCmap(cmap) - self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) - self._texture_map = pygfx.TextureMap( - self._cmap.texture, - filter=self._cmap_interpolation.value, - wrap="clamp-to-edge", - ) - else: + if self._data.value.ndim not in (3, 4): raise ValueError( f"ImageVolumeGraphic `data` must have 3 dimensions for grayscale images, " f"or 4 dimensions for RGB(A) images.\n" @@ -312,10 +306,9 @@ def mode(self, mode: str): self._mode.set_value(self, mode) @property - def cmap(self) -> str | None: - """Get or set colormap name""" - if self._cmap is not None: - return self._cmap.value + def cmap(self) -> str: + """Get or set colormap name, only used for grayscale images""" + return self._cmap.value @cmap.setter def cmap(self, name: str): @@ -349,10 +342,9 @@ def interpolation(self, value: str): self._interpolation.set_value(self, value) @property - def cmap_interpolation(self) -> str | None: + def cmap_interpolation(self) -> str: """Get or set the cmap interpolation method""" - if self._cmap_interpolation is not None: - return self._cmap_interpolation.value + return self._cmap_interpolation.value @cmap_interpolation.setter def cmap_interpolation(self, value: str): diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py index 33ab509d1..bb9e5bdef 100644 --- a/fastplotlib/ui/right_click_menus/_standard_menu.py +++ b/fastplotlib/ui/right_click_menus/_standard_menu.py @@ -100,12 +100,6 @@ def update(self): ) self.get_subplot().camera.maintain_aspect = maintain_aspect - change, show_tooltips = imgui.menu_item( - "Show tooltips", "", self._figure.show_tooltips - ) - if change: - self._figure.show_tooltips = show_tooltips - imgui.separator() # toggles to flip axes cameras