From dd502e4a9cc0226c7ef60b4cf59a8b5cbb0fb6b5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 28 Jun 2025 21:11:20 -0400 Subject: [PATCH 01/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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):