From 9a01cd5ee7fda8e4dd923670f0466d1233bc6de0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 25 Dec 2025 01:04:06 -0800 Subject: [PATCH 001/163] start ndprocessors --- fastplotlib/utils/__init__.py | 1 + fastplotlib/utils/_protocols.py | 12 ++ fastplotlib/widgets/nd_widget/_processor.py | 141 ++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 fastplotlib/utils/_protocols.py create mode 100644 fastplotlib/widgets/nd_widget/_processor.py diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index dd527ca67..8001ae375 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -6,6 +6,7 @@ from .gpu import enumerate_adapters, select_adapter, print_wgpu_report from ._plot_helpers import * from .enums import * +from ._protocols import ArrayProtocol @dataclass diff --git a/fastplotlib/utils/_protocols.py b/fastplotlib/utils/_protocols.py new file mode 100644 index 000000000..c168ecfa4 --- /dev/null +++ b/fastplotlib/utils/_protocols.py @@ -0,0 +1,12 @@ +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ArrayProtocol(Protocol): + @property + def ndim(self) -> int: ... + + @property + def shape(self) -> tuple[int, ...]: ... + + def __getitem__(self, key): ... diff --git a/fastplotlib/widgets/nd_widget/_processor.py b/fastplotlib/widgets/nd_widget/_processor.py new file mode 100644 index 000000000..9e5299118 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_processor.py @@ -0,0 +1,141 @@ +import inspect +from typing import Literal, Callable, Any +from warnings import warn + +import numpy as np +from numpy.typing import ArrayLike + +from ...utils import subsample_array, ArrayProtocol + + +# must take arguments: array-like, `axis`: int, `keepdims`: bool +WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] + + +class NDProcessor: + def __init__( + self, + data: ArrayProtocol, + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + ): + self._data = self._validate_data(data) + self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) + + @property + def data(self) -> ArrayProtocol: + return self._data + + @data.setter + def data(self, data: ArrayProtocol): + self._data = self._validate_data(data) + + def _validate_data(self, data: ArrayProtocol): + if not isinstance(data, ArrayProtocol): + raise TypeError("`data` must implement the ArrayProtocol") + + return data + + @property + def window_funcs(self) -> tuple[WindowFuncCallable | None] | None: + pass + + @property + def window_sizes(self) -> tuple[int | None] | None: + pass + + @property + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + pass + + @property + def slider_dims(self) -> tuple[int, ...] | None: + pass + + @property + def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: + return self._slider_index_maps + + @slider_index_maps.setter + def slider_index_maps(self, maps): + self._maps = self._validate_slider_index_maps(maps) + + def _validate_slider_index_maps(self, maps): + if maps is not None: + if not all([callable(m) or m is None for m in maps]): + raise TypeError + + return maps + + def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: + pass + + +class NDImageProcessor(NDProcessor): + @property + def n_display_dims(self) -> Literal[2, 3]: + pass + + def _validate_n_display_dims(self, n_display_dims): + if n_display_dims not in (2, 3): + raise ValueError("`n_display_dims` must be") + + +class NDTimeSeriesProcessor(NDProcessor): + def __init__( + self, + data: ArrayProtocol, + graphic: Literal["line", "heatmap"] = "line", + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + display_window: int | float | None = None, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + ): + super().__init__( + data=data, + n_display_dims=n_display_dims, + slider_index_maps=slider_index_maps, + ) + + self._display_window = display_window + + def _validate_data(self, data: ArrayProtocol): + data = super()._validate_data(data) + + # need to make shape be [n_lines, n_datapoints, 2] + # this will work for displaying a linestack and heatmap + # for heatmap just slice: [..., 1] + # TODO: Think about how to allow n-dimensional lines, + # maybe [d1, d2, ..., d(n - 1), n_lines, n_datapoint, 2] + # and dn is the x-axis values?? + if data.ndim == 1: + pass + + @property + def display_window(self) -> int | float | None: + """display window in the reference units along the x-axis""" + return self._display_window + + def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: + if self.display_window is not None: + # map reference units -> array int indices if necessary + if self.slider_index_maps is not None: + indices_window = self.slider_index_maps(self.display_window) + else: + indices_window = self.display_window + + # half window size + hw = indices_window // 2 + + # for now assume just a single index provided that indicates x axis value + start = max(indices - hw, 0) + stop = indices + hw + + # slice dim would be ndim - 1 + + return self.data[start:stop] From c46455ff71e460772148bf629dd906beffaf3cca Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 27 Dec 2025 03:52:32 -0800 Subject: [PATCH 002/163] basic timeseries --- fastplotlib/widgets/nd_widget/_processor.py | 187 +++++++++++++++++--- 1 file changed, 159 insertions(+), 28 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_processor.py b/fastplotlib/widgets/nd_widget/_processor.py index 9e5299118..d0a8e66ab 100644 --- a/fastplotlib/widgets/nd_widget/_processor.py +++ b/fastplotlib/widgets/nd_widget/_processor.py @@ -5,6 +5,7 @@ import numpy as np from numpy.typing import ArrayLike +from ...graphics import ImageGraphic, LineStack, LineCollection, ScatterGraphic from ...utils import subsample_array, ArrayProtocol @@ -14,13 +15,13 @@ class NDProcessor: def __init__( - self, - data: ArrayProtocol, - n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + self, + data, + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): self._data = self._validate_data(data) self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) @@ -84,17 +85,30 @@ def _validate_n_display_dims(self, n_display_dims): raise ValueError("`n_display_dims` must be") +VALID_TIMESERIES_Y_DATA_SHAPES = ( + "[n_datapoints] for 1D array of y-values, [n_datapoints, 2] " + "for a 1D array of y and z-values, [n_lines, n_datapoints] for a 2D stack of lines with y-values, " + "or [n_lines, n_datapoints, 2] for a stack of lines with y and z-values." +) + + +# Limitation, no heatmap if z-values present, I don't think you can visualize that class NDTimeSeriesProcessor(NDProcessor): def __init__( - self, - data: ArrayProtocol, - graphic: Literal["line", "heatmap"] = "line", - n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, - display_window: int | float | None = None, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + self, + data: list[ + ArrayProtocol, ArrayProtocol + ], # list: [x_vals_array, y_vals_and_z_vals_array] + x_values: ArrayProtocol = None, + cmap: str = None, + cmap_transform: ArrayProtocol = None, + display_graphic: Literal["line", "heatmap"] = "line", + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + display_window: int | float | None = 100, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): super().__init__( data=data, @@ -104,23 +118,73 @@ def __init__( self._display_window = display_window - def _validate_data(self, data: ArrayProtocol): - data = super()._validate_data(data) + self._display_graphic = None + self.display_graphic = display_graphic - # need to make shape be [n_lines, n_datapoints, 2] - # this will work for displaying a linestack and heatmap - # for heatmap just slice: [..., 1] - # TODO: Think about how to allow n-dimensional lines, - # maybe [d1, d2, ..., d(n - 1), n_lines, n_datapoint, 2] - # and dn is the x-axis values?? - if data.ndim == 1: - pass + self._uniform_x_values: ArrayProtocol | None = None + self._interp_yz: ArrayProtocol | None = None + + @property + def data(self) -> list[ArrayProtocol, ArrayProtocol]: + return self._data + + @data.setter + def data(self, data: list[ArrayProtocol, ArrayProtocol]): + self._data = self._validate_data(data) + + def _validate_data(self, data: list[ArrayProtocol, ArrayProtocol]): + x_vals, yz_vals = data + + if x_vals.ndim != 1: + raise ("data x values must be 1D") + + if data[1].ndim > 3: + raise ValueError( + f"data yz values must be of shape: {VALID_TIMESERIES_Y_DATA_SHAPES}. You passed data of shape: {yz_vals.shape}" + ) + + return data + + @property + def display_graphic(self) -> Literal["line", "heatmap"]: + return self._display_graphic + + @display_graphic.setter + def display_graphic(self, dg: Literal["line", "heatmap"]): + dg = self._validate_display_graphic(dg) + + if dg == "heatmap": + # check if x-vals uniformly spaced + norm = np.linalg.norm(np.diff(np.diff(self.x_values))) / len(self.x_values) + if norm > 10 ** -12: + # need to create evenly spaced x-values + x0 = self.data[0][0] + xn = self.data[0][-1] + self._uniform_x_values = np.linspace(x0, xn, num=len(self.data[0])) + + # TODO: interpolate yz values on the fly only when within the display window + + def _validate_display_graphic(self, dg): + if dg not in ("line", "heatmap"): + raise ValueError + + return dg @property def display_window(self) -> int | float | None: """display window in the reference units along the x-axis""" return self._display_window + @display_window.setter + def display_window(self, dw: int | float | None): + if dw is None: + self._display_window = None + + elif not isinstance(dw, (int, float)): + raise TypeError + + self._display_window = dw + def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: if self.display_window is not None: # map reference units -> array int indices if necessary @@ -134,8 +198,75 @@ def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: # for now assume just a single index provided that indicates x axis value start = max(indices - hw, 0) - stop = indices + hw + stop = start + indices_window # slice dim would be ndim - 1 + return self.data[0][start:stop], self.data[1][:, start:stop] + + +class NDTimeSeries: + def __init__(self, processor: NDTimeSeriesProcessor, display_graphic): + self._processor = processor + + self._indices = 0 + + if display_graphic == "line": + self._create_line_stack() + + @property + def processor(self) -> NDTimeSeriesProcessor: + return self._processor + + @property + def graphic(self) -> LineStack | ImageGraphic: + """LineStack or ImageGraphic for heatmaps""" + return self._graphic + + @property + def display_window(self) -> int | float | None: + return self.processor.display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + # create new graphic if it changed + if dw != self.display_window: + create_new_graphic = True + else: + create_new_graphic = False + + self.processor.display_window = dw + + if create_new_graphic: + if isinstance(self.graphic, LineStack): + self.set_index(self._indices) + + def set_index(self, indices: tuple[Any, ...]): + # set the graphic at the given data indices + data_slice = self.processor[indices] + + if isinstance(self.graphic, LineStack): + line_stack_data = self._create_line_stack_data(data_slice) + + for g, line_data in zip(self.graphic.graphics, line_stack_data): + if line_data.shape[1] == 2: + # only x and y values + g.data[:, :-1] = line_data + else: + # has z values too + g.data[:] = line_data + + self._indices = indices + + def _create_line_stack_data(self, data_slice): + xs = data_slice[0] # 1D + yz = data_slice[1] # [n_lines, n_datapoints] for y-vals or [n_lines, n_datapoints, 2] for yz-vals + + # need to go from x_vals and yz_vals arrays to an array of shape: [n_lines, n_datapoints, 2 | 3] + return np.dstack([np.repeat(xs[None], repeats=yz.shape[0], axis=0), yz]) + + def _create_line_stack(self): + data_slice = self.processor[self._indices] + + ls_data = self._create_line_stack_data(data_slice) - return self.data[start:stop] + self._graphic = LineStack(ls_data) From d93fa5d5fdc685b8d7f2b7bc38a95abb100f31da Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 27 Dec 2025 03:52:52 -0800 Subject: [PATCH 003/163] add __init__ --- fastplotlib/widgets/nd_widget/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/__init__.py diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py new file mode 100644 index 000000000..e69de29bb From fddefb826f44f443c2504557c6d5e76b2e50c05f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 27 Dec 2025 17:46:54 -0800 Subject: [PATCH 004/163] heatmap for timeseries works! --- fastplotlib/widgets/nd_widget/_processor.py | 55 ++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_processor.py b/fastplotlib/widgets/nd_widget/_processor.py index d0a8e66ab..0add36594 100644 --- a/fastplotlib/widgets/nd_widget/_processor.py +++ b/fastplotlib/widgets/nd_widget/_processor.py @@ -155,6 +155,7 @@ def display_graphic(self, dg: Literal["line", "heatmap"]): if dg == "heatmap": # check if x-vals uniformly spaced + # this is very fast to do on the fly, especially for typical small display windows norm = np.linalg.norm(np.diff(np.diff(self.x_values))) / len(self.x_values) if norm > 10 ** -12: # need to create evenly spaced x-values @@ -205,13 +206,17 @@ def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: class NDTimeSeries: - def __init__(self, processor: NDTimeSeriesProcessor, display_graphic): + def __init__(self, processor: NDTimeSeriesProcessor, graphic): self._processor = processor self._indices = 0 - if display_graphic == "line": + if graphic == "line": self._create_line_stack() + elif graphic == "heatmap": + self._create_heatmap() + else: + raise ValueError @property def processor(self) -> NDTimeSeriesProcessor: @@ -222,6 +227,19 @@ def graphic(self) -> LineStack | ImageGraphic: """LineStack or ImageGraphic for heatmaps""" return self._graphic + @graphic.setter + def graphic(self, g: Literal["line", "heatmap"]): + if g == "line": + # TODO: remove existing graphic + self._create_line_stack() + + elif g == "heatmap": + # make sure "yz" data is only ys and no z values + # can't represent y and z vals in a heatmap + if self.processor.data[1].ndim > 2: + raise ValueError("Only y-values are supported for heatmaps, not yz-values") + self._create_heatmap() + @property def display_window(self) -> int | float | None: return self.processor.display_window @@ -255,6 +273,10 @@ def set_index(self, indices: tuple[Any, ...]): # has z values too g.data[:] = line_data + elif isinstance(self.graphic, ImageGraphic): + hm_data, scale = self._create_heatmap_data(data_slice) + self.graphic.data = hm_data + self._indices = indices def _create_line_stack_data(self, data_slice): @@ -270,3 +292,32 @@ def _create_line_stack(self): ls_data = self._create_line_stack_data(data_slice) self._graphic = LineStack(ls_data) + + def _create_heatmap_data(self, data_slice) -> tuple[ArrayProtocol, float]: + """Returns [n_lines, y_values] array and scale factor for x dimension""" + # check if x-vals uniformly spaced + # this is very fast to do on the fly, especially for typical small display windows + x, y = data_slice + norm = np.linalg.norm(np.diff(np.diff(x))) / x.size + if norm > 10 ** -12: + # need to create evenly spaced x-values + x_uniform = np.linspace(x[0], x[-1], num=x.size) + # yz is [n_lines, n_datapoints] + y_interp = np.zeros(shape=y.shape, dtype=np.float32) + for i in range(y.shape[0]): + y_interp[i] = np.interp(x_uniform, x, y[i]) + + else: + y_interp = y + + x_scale = x[-1] / x.size + + return y_interp, x_scale + + def _create_heatmap(self): + data_slice = self.processor[self._indices] + + hm_data, x_scale = self._create_heatmap_data(data_slice) + + self._graphic = ImageGraphic(hm_data) + self._graphic.world_object.world.scale_x = x_scale \ No newline at end of file From d5e4c7d45901b1f5f2de89e68ef4d416d0ea7dde Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 29 Dec 2025 01:44:11 -0800 Subject: [PATCH 005/163] NDPositions, basics work, reorganize, increase default scatter size --- fastplotlib/graphics/scatter.py | 2 +- fastplotlib/widgets/nd_widget/_nd_image.py | 13 ++ .../widgets/nd_widget/_nd_positions.py | 137 ++++++++++++++++++ .../{_processor.py => _nd_timeseries.py} | 104 +------------ .../widgets/nd_widget/_processor_base.py | 74 ++++++++++ 5 files changed, 227 insertions(+), 103 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/_nd_image.py create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions.py rename fastplotlib/widgets/nd_widget/{_processor.py => _nd_timeseries.py} (70%) create mode 100644 fastplotlib/widgets/nd_widget/_processor_base.py diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index a2e696a82..5268dcc51 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -53,7 +53,7 @@ def __init__( image: np.ndarray = None, point_rotations: float | np.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", - sizes: float | np.ndarray | Sequence[float] = 1, + sizes: float | np.ndarray | Sequence[float] = 5, uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py new file mode 100644 index 000000000..f115e146e --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -0,0 +1,13 @@ +from typing import Literal + +from ._processor_base import NDProcessor + + +class NDImageProcessor(NDProcessor): + @property + def n_display_dims(self) -> Literal[2, 3]: + pass + + def _validate_n_display_dims(self, n_display_dims): + if n_display_dims not in (2, 3): + raise ValueError("`n_display_dims` must be") diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py new file mode 100644 index 000000000..db8c80e72 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -0,0 +1,137 @@ +import inspect +from typing import Literal, Callable, Any, Type +from warnings import warn + +import numpy as np +from numpy.typing import ArrayLike + +from ...utils import subsample_array, ArrayProtocol + +from ...graphics import ImageGraphic, LineGraphic, LineStack, LineCollection, ScatterGraphic +from ._processor_base import NDProcessor + +# TODO: Maybe get rid of n_display_dims in NDProcessor, +# we will know the display dims automatically here from the last dim +# so maybe we only need it for images? +class NDPositionsProcessor(NDProcessor): + def __init__( + self, + data: ArrayProtocol, + multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points + display_window: int | float | None = 100, # window for n_datapoints dim only + ): + super().__init__(data=data) + + self._display_window = display_window + + self.multi = multi + + def _validate_data(self, data: ArrayProtocol): + # TODO: determine right validation shape etc. + return data + + @property + def display_window(self) -> int | float | None: + """display window in the reference units for the n_datapoints dim""" + return self._display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + if dw is None: + self._display_window = None + + elif not isinstance(dw, (int, float)): + raise TypeError + + self._display_window = dw + + @property + def multi(self) -> bool: + return self._multi + + @multi.setter + def multi(self, m: bool): + if m and self.data.ndim < 3: + # p is p-datapoints, n is how many lines/scatter to show simultaneously + raise ValueError("ndim must be >= 3 for multi, shape must be [s1..., sn, n, p, 2 | 3]") + + self._multi = m + + def __getitem__(self, indices: tuple[Any, ...]): + """sliders through all slider dims and outputs an array that can be used to set graphic data""" + if self.display_window is not None: + indices_window = self.display_window + + # half window size + hw = indices_window // 2 + + # for now assume just a single index provided that indicates x axis value + start = max(indices - hw, 0) + stop = start + indices_window + + slices = [slice(start, stop)] + + # TODO: implement slicing for multiple slider dims, i.e. [s1, s2, ... n_datapoints, 2 | 3] + # this currently assumes the shape is: [n_datapoints, 2 | 3] + if self.multi: + # n - 2 dim is n_lines or n_scatters + slices.insert(0, slice(None)) + + return self.data[tuple(slices)] + + +class NDPositions: + def __init__(self, data, graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], multi: bool = False): + self._indices = 0 + + if issubclass(graphic, LineCollection): + multi = True + + self._processor = NDPositionsProcessor(data, multi=multi) + + self._create_graphic(graphic) + + @property + def processor(self) -> NDPositionsProcessor: + return self._processor + + @property + def graphic(self) -> LineGraphic | LineCollection | LineStack | ScatterGraphic | list[ScatterGraphic]: + """LineStack or ImageGraphic for heatmaps""" + return self._graphic + + @property + def indices(self) -> tuple: + return self._indices + + @indices.setter + def indices(self, indices): + data_slice = self.processor[indices] + + if isinstance(self.graphic, list): + # list of scatter + for i in range(len(self.graphic)): + # data_slice shape is [n_scatters, n_datapoints, 2 | 3] + # by using data_slice.shape[-1] it will auto-select if the data is only xy or has xyz + self.graphic[i].data[:, :data_slice.shape[-1]] = data_slice[i] + + elif isinstance(self.graphic, (LineGraphic, ScatterGraphic)): + self.graphic.data[:, :data_slice.shape[-1]] = data_slice + + elif isinstance(self.graphic, LineCollection): + for i in range(len(self.graphic)): + # data_slice shape is [n_lines, n_datapoints, 2 | 3] + self.graphic[i].data[:, :data_slice.shape[-1]] = data_slice[i] + + def _create_graphic(self, graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic]): + if self.processor.multi and issubclass(graphic_cls, ScatterGraphic): + # make list of scatters + self._graphic = list() + data_slice = self.processor[self.indices] + for d in data_slice: + scatter = graphic_cls(d) + self._graphic.append(scatter) + + else: + data_slice = self.processor[self.indices] + self._graphic = graphic_cls(data_slice) diff --git a/fastplotlib/widgets/nd_widget/_processor.py b/fastplotlib/widgets/nd_widget/_nd_timeseries.py similarity index 70% rename from fastplotlib/widgets/nd_widget/_processor.py rename to fastplotlib/widgets/nd_widget/_nd_timeseries.py index 0add36594..8630044cf 100644 --- a/fastplotlib/widgets/nd_widget/_processor.py +++ b/fastplotlib/widgets/nd_widget/_nd_timeseries.py @@ -5,84 +5,10 @@ import numpy as np from numpy.typing import ArrayLike -from ...graphics import ImageGraphic, LineStack, LineCollection, ScatterGraphic from ...utils import subsample_array, ArrayProtocol - -# must take arguments: array-like, `axis`: int, `keepdims`: bool -WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] - - -class NDProcessor: - def __init__( - self, - data, - n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, - ): - self._data = self._validate_data(data) - self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) - - @property - def data(self) -> ArrayProtocol: - return self._data - - @data.setter - def data(self, data: ArrayProtocol): - self._data = self._validate_data(data) - - def _validate_data(self, data: ArrayProtocol): - if not isinstance(data, ArrayProtocol): - raise TypeError("`data` must implement the ArrayProtocol") - - return data - - @property - def window_funcs(self) -> tuple[WindowFuncCallable | None] | None: - pass - - @property - def window_sizes(self) -> tuple[int | None] | None: - pass - - @property - def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: - pass - - @property - def slider_dims(self) -> tuple[int, ...] | None: - pass - - @property - def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: - return self._slider_index_maps - - @slider_index_maps.setter - def slider_index_maps(self, maps): - self._maps = self._validate_slider_index_maps(maps) - - def _validate_slider_index_maps(self, maps): - if maps is not None: - if not all([callable(m) or m is None for m in maps]): - raise TypeError - - return maps - - def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: - pass - - -class NDImageProcessor(NDProcessor): - @property - def n_display_dims(self) -> Literal[2, 3]: - pass - - def _validate_n_display_dims(self, n_display_dims): - if n_display_dims not in (2, 3): - raise ValueError("`n_display_dims` must be") +from ...graphics import ImageGraphic, LineStack, LineCollection, ScatterGraphic +from ._processor_base import NDProcessor, WindowFuncCallable VALID_TIMESERIES_Y_DATA_SHAPES = ( @@ -145,32 +71,6 @@ def _validate_data(self, data: list[ArrayProtocol, ArrayProtocol]): return data - @property - def display_graphic(self) -> Literal["line", "heatmap"]: - return self._display_graphic - - @display_graphic.setter - def display_graphic(self, dg: Literal["line", "heatmap"]): - dg = self._validate_display_graphic(dg) - - if dg == "heatmap": - # check if x-vals uniformly spaced - # this is very fast to do on the fly, especially for typical small display windows - norm = np.linalg.norm(np.diff(np.diff(self.x_values))) / len(self.x_values) - if norm > 10 ** -12: - # need to create evenly spaced x-values - x0 = self.data[0][0] - xn = self.data[0][-1] - self._uniform_x_values = np.linspace(x0, xn, num=len(self.data[0])) - - # TODO: interpolate yz values on the fly only when within the display window - - def _validate_display_graphic(self, dg): - if dg not in ("line", "heatmap"): - raise ValueError - - return dg - @property def display_window(self) -> int | float | None: """display window in the reference units along the x-axis""" diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/_processor_base.py new file mode 100644 index 000000000..fa56e4b52 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_processor_base.py @@ -0,0 +1,74 @@ +import inspect +from typing import Literal, Callable, Any +from warnings import warn + +import numpy as np +from numpy.typing import ArrayLike + +from ...utils import subsample_array, ArrayProtocol + + +# must take arguments: array-like, `axis`: int, `keepdims`: bool +WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] + + +class NDProcessor: + def __init__( + self, + data, + n_display_dims: Literal[2, 3] = 2, + slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + ): + self._data = self._validate_data(data) + self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) + + @property + def data(self) -> ArrayProtocol: + return self._data + + @data.setter + def data(self, data: ArrayProtocol): + self._data = self._validate_data(data) + + def _validate_data(self, data: ArrayProtocol): + if not isinstance(data, ArrayProtocol): + raise TypeError("`data` must implement the ArrayProtocol") + + return data + + @property + def window_funcs(self) -> tuple[WindowFuncCallable | None] | None: + pass + + @property + def window_sizes(self) -> tuple[int | None] | None: + pass + + @property + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + pass + + @property + def slider_dims(self) -> tuple[int, ...] | None: + pass + + @property + def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: + return self._slider_index_maps + + @slider_index_maps.setter + def slider_index_maps(self, maps): + self._maps = self._validate_slider_index_maps(maps) + + def _validate_slider_index_maps(self, maps): + if maps is not None: + if not all([callable(m) or m is None for m in maps]): + raise TypeError + + return maps + + def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: + pass From 074669b084068784bed7a5f54e6cfe014ea0abf5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 25 Jan 2026 23:58:14 -0500 Subject: [PATCH 006/163] black --- .../widgets/nd_widget/_nd_positions.py | 47 ++++++++++++++----- .../widgets/nd_widget/_nd_timeseries.py | 16 ++++--- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index db8c80e72..10215d351 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -7,18 +7,25 @@ from ...utils import subsample_array, ArrayProtocol -from ...graphics import ImageGraphic, LineGraphic, LineStack, LineCollection, ScatterGraphic +from ...graphics import ( + ImageGraphic, + LineGraphic, + LineStack, + LineCollection, + ScatterGraphic, +) from ._processor_base import NDProcessor + # TODO: Maybe get rid of n_display_dims in NDProcessor, # we will know the display dims automatically here from the last dim # so maybe we only need it for images? class NDPositionsProcessor(NDProcessor): def __init__( - self, - data: ArrayProtocol, - multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points - display_window: int | float | None = 100, # window for n_datapoints dim only + self, + data: ArrayProtocol, + multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points + display_window: int | float | None = 100, # window for n_datapoints dim only ): super().__init__(data=data) @@ -52,8 +59,10 @@ def multi(self) -> bool: @multi.setter def multi(self, m: bool): if m and self.data.ndim < 3: - # p is p-datapoints, n is how many lines/scatter to show simultaneously - raise ValueError("ndim must be >= 3 for multi, shape must be [s1..., sn, n, p, 2 | 3]") + # p is p-datapoints, n is how many lines to show simultaneously (for line collection/stack) + raise ValueError( + "ndim must be >= 3 for multi, shape must be [s1..., sn, n, p, 2 | 3]" + ) self._multi = m @@ -81,7 +90,12 @@ def __getitem__(self, indices: tuple[Any, ...]): class NDPositions: - def __init__(self, data, graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], multi: bool = False): + def __init__( + self, + data, + graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], + multi: bool = False, + ): self._indices = 0 if issubclass(graphic, LineCollection): @@ -96,7 +110,11 @@ def processor(self) -> NDPositionsProcessor: return self._processor @property - def graphic(self) -> LineGraphic | LineCollection | LineStack | ScatterGraphic | list[ScatterGraphic]: + def graphic( + self, + ) -> ( + LineGraphic | LineCollection | LineStack | ScatterGraphic + ): """LineStack or ImageGraphic for heatmaps""" return self._graphic @@ -113,17 +131,20 @@ def indices(self, indices): for i in range(len(self.graphic)): # data_slice shape is [n_scatters, n_datapoints, 2 | 3] # by using data_slice.shape[-1] it will auto-select if the data is only xy or has xyz - self.graphic[i].data[:, :data_slice.shape[-1]] = data_slice[i] + self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] elif isinstance(self.graphic, (LineGraphic, ScatterGraphic)): - self.graphic.data[:, :data_slice.shape[-1]] = data_slice + self.graphic.data[:, : data_slice.shape[-1]] = data_slice elif isinstance(self.graphic, LineCollection): for i in range(len(self.graphic)): # data_slice shape is [n_lines, n_datapoints, 2 | 3] - self.graphic[i].data[:, :data_slice.shape[-1]] = data_slice[i] + self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] - def _create_graphic(self, graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic]): + def _create_graphic( + self, + graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], + ): if self.processor.multi and issubclass(graphic_cls, ScatterGraphic): # make list of scatters self._graphic = list() diff --git a/fastplotlib/widgets/nd_widget/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_timeseries.py index 8630044cf..49b9231c3 100644 --- a/fastplotlib/widgets/nd_widget/_nd_timeseries.py +++ b/fastplotlib/widgets/nd_widget/_nd_timeseries.py @@ -137,15 +137,17 @@ def graphic(self, g: Literal["line", "heatmap"]): # make sure "yz" data is only ys and no z values # can't represent y and z vals in a heatmap if self.processor.data[1].ndim > 2: - raise ValueError("Only y-values are supported for heatmaps, not yz-values") + raise ValueError( + "Only y-values are supported for heatmaps, not yz-values" + ) self._create_heatmap() @property - def display_window(self) -> int | float | None: + def display_window(self) -> int | float | None: return self.processor.display_window @display_window.setter - def display_window(self, dw: int | float | None): + def display_window(self, dw: int | float | None): # create new graphic if it changed if dw != self.display_window: create_new_graphic = True @@ -181,7 +183,9 @@ def set_index(self, indices: tuple[Any, ...]): def _create_line_stack_data(self, data_slice): xs = data_slice[0] # 1D - yz = data_slice[1] # [n_lines, n_datapoints] for y-vals or [n_lines, n_datapoints, 2] for yz-vals + yz = data_slice[ + 1 + ] # [n_lines, n_datapoints] for y-vals or [n_lines, n_datapoints, 2] for yz-vals # need to go from x_vals and yz_vals arrays to an array of shape: [n_lines, n_datapoints, 2 | 3] return np.dstack([np.repeat(xs[None], repeats=yz.shape[0], axis=0), yz]) @@ -199,7 +203,7 @@ def _create_heatmap_data(self, data_slice) -> tuple[ArrayProtocol, float]: # this is very fast to do on the fly, especially for typical small display windows x, y = data_slice norm = np.linalg.norm(np.diff(np.diff(x))) / x.size - if norm > 10 ** -12: + if norm > 10**-12: # need to create evenly spaced x-values x_uniform = np.linspace(x[0], x[-1], num=x.size) # yz is [n_lines, n_datapoints] @@ -220,4 +224,4 @@ def _create_heatmap(self): hm_data, x_scale = self._create_heatmap_data(data_slice) self._graphic = ImageGraphic(hm_data) - self._graphic.world_object.world.scale_x = x_scale \ No newline at end of file + self._graphic.world_object.world.scale_x = x_scale From ff5c5783c235376a049732f889cc477cdfc5ca9a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 29 Jan 2026 20:27:59 -0500 Subject: [PATCH 007/163] NDPositions working with multi-dim stack of lines, need to test window funcs --- .../widgets/nd_widget/_nd_positions.py | 113 +++++++++++-- .../widgets/nd_widget/_processor_base.py | 157 +++++++++++++++++- 2 files changed, 247 insertions(+), 23 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 10215d351..dfcb263c5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -26,6 +26,7 @@ def __init__( data: ArrayProtocol, multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points display_window: int | float | None = 100, # window for n_datapoints dim only + n_slider_dims: int = 0, ): super().__init__(data=data) @@ -33,6 +34,8 @@ def __init__( self.multi = multi + self.n_slider_dims = n_slider_dims + def _validate_data(self, data: ArrayProtocol): # TODO: determine right validation shape etc. return data @@ -66,27 +69,108 @@ def multi(self, m: bool): self._multi = m - def __getitem__(self, indices: tuple[Any, ...]): - """sliders through all slider dims and outputs an array that can be used to set graphic data""" + def _apply_window_functions(self, indices: tuple[int, ...]): + """applies the window functions for each dimension specified""" + # window size for each dim + winds = self._window_sizes + # window function for each dim + funcs = self._window_funcs + + if winds is None or funcs is None: + # no window funcs or window sizes, just slice data and return + # clamp to max bounds + indexer = list() + for dim, i in enumerate(indices): + i = min(self.shape[dim] - 1, i) + indexer.append(i) + + return self.data[tuple(indexer)] + + # order in which window funcs are applied + order = self._window_order + + if order is not None: + # remove any entries in `window_order` where the specified dim + # has a window function or window size specified as `None` + # example: + # window_sizes = (3, 2) + # window_funcs = (np.mean, None) + # order = (0, 1) + # `1` is removed from the order since that window_func is `None` + order = tuple( + d for d in order if winds[d] is not None and funcs[d] is not None + ) + else: + # sequential order + order = list() + for d in range(self.n_slider_dims): + if winds[d] is not None and funcs[d] is not None: + order.append(d) + + # the final indexer which will be used on the data array + indexer = list() + + for dim_index, (i, w, f) in enumerate(zip(indices, winds, funcs)): + # clamp i within the max bounds + i = min(self.shape[dim_index] - 1, i) + + if (w is not None) and (f is not None): + # specify slice window if both window size and function for this dim are not None + hw = int((w - 1) / 2) # half window + + # start index cannot be less than 0 + start = max(0, i - hw) + + # stop index cannot exceed the bounds of this dimension + stop = min(self.shape[dim_index] - 1, i + hw) + + s = slice(start, stop, 1) + else: + s = slice(i, i + 1, 1) + + indexer.append(s) + + # apply indexer to slice data with the specified windows + data_sliced = self.data[tuple(indexer)] + + # finally apply the window functions in the specified order + for dim in order: + f = funcs[dim] + + data_sliced = f(data_sliced, axis=dim, keepdims=True) + + return data_sliced + + def get(self, indices: tuple[Any, ...]): + """ + slices through all slider dims and outputs an array that can be used to set graphic data + + Note that we do not use __getitem__ here since the index is a tuple specifying a single integer + index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. + """ + # apply window funcs + # this array should be of shape [n_datapoints, 2 | 3] + window_output = self._apply_window_functions(indices[:-1]).squeeze() + + # TODO: window function on the `p` n_datapoints dimension + if self.display_window is not None: - indices_window = self.display_window + dw = self.display_window # half window size - hw = indices_window // 2 + hw = dw // 2 # for now assume just a single index provided that indicates x axis value - start = max(indices - hw, 0) - stop = start + indices_window + start = max(indices[-1] - hw, 0) + stop = start + dw slices = [slice(start, stop)] - # TODO: implement slicing for multiple slider dims, i.e. [s1, s2, ... n_datapoints, 2 | 3] - # this currently assumes the shape is: [n_datapoints, 2 | 3] if self.multi: # n - 2 dim is n_lines or n_scatters slices.insert(0, slice(None)) - return self.data[tuple(slices)] + return window_output[tuple(slices)] class NDPositions: @@ -96,12 +180,11 @@ def __init__( graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], multi: bool = False, ): - self._indices = 0 - if issubclass(graphic, LineCollection): multi = True - self._processor = NDPositionsProcessor(data, multi=multi) + self._processor = NDPositionsProcessor(data, multi=multi, display_window=100, n_slider_dims=2) + self._indices = tuple([0] * (2 + 1)) self._create_graphic(graphic) @@ -124,7 +207,7 @@ def indices(self) -> tuple: @indices.setter def indices(self, indices): - data_slice = self.processor[indices] + data_slice = self.processor.get(indices) if isinstance(self.graphic, list): # list of scatter @@ -148,11 +231,11 @@ def _create_graphic( if self.processor.multi and issubclass(graphic_cls, ScatterGraphic): # make list of scatters self._graphic = list() - data_slice = self.processor[self.indices] + data_slice = self.processor.get(self.indices) for d in data_slice: scatter = graphic_cls(d) self._graphic.append(scatter) else: - data_slice = self.processor[self.indices] + data_slice = self.processor.get(self.indices) self._graphic = graphic_cls(data_slice) diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/_processor_base.py index fa56e4b52..3350fff8f 100644 --- a/fastplotlib/widgets/nd_widget/_processor_base.py +++ b/fastplotlib/widgets/nd_widget/_processor_base.py @@ -7,7 +7,6 @@ from ...utils import subsample_array, ArrayProtocol - # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] @@ -20,11 +19,16 @@ def __init__( slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, window_funcs: tuple[WindowFuncCallable | None] | None = None, window_sizes: tuple[int | None] | None = None, + window_order: tuple[int, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): self._data = self._validate_data(data) self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) + self.window_funcs = window_funcs + self.window_sizes = window_sizes + self.window_order = window_order + @property def data(self) -> ArrayProtocol: return self._data @@ -33,6 +37,14 @@ def data(self) -> ArrayProtocol: def data(self, data: ArrayProtocol): self._data = self._validate_data(data) + @property + def shape(self) -> tuple[int, ...]: + return self.data.shape + + @property + def ndim(self) -> int: + return int(np.prod(self.shape)) + def _validate_data(self, data: ArrayProtocol): if not isinstance(data, ArrayProtocol): raise TypeError("`data` must implement the ArrayProtocol") @@ -40,21 +52,150 @@ def _validate_data(self, data: ArrayProtocol): return data @property - def window_funcs(self) -> tuple[WindowFuncCallable | None] | None: - pass + def window_funcs( + self, + ) -> tuple[WindowFuncCallable | None, ...] | None: + """get or set window functions, see docstring for details""" + return self._window_funcs + + @window_funcs.setter + def window_funcs( + self, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None, + ): + if window_funcs is None: + self._window_funcs = None + return + + if callable(window_funcs): + window_funcs = (window_funcs,) + + # if all are None + if all([f is None for f in window_funcs]): + self._window_funcs = None + return + + self._validate_window_func(window_funcs) + + self._window_funcs = tuple(window_funcs) + self._recompute_histogram() + + def _validate_window_func(self, funcs): + if isinstance(funcs, (tuple, list)): + for f in funcs: + if f is None: + pass + elif callable(f): + sig = inspect.signature(f) + + if "axis" not in sig.parameters or "keepdims" not in sig.parameters: + raise TypeError( + f"Each window function must take an `axis` and `keepdims` argument, " + f"you passed: {f} with the following function signature: {sig}" + ) + else: + raise TypeError( + f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {funcs}" + ) + + if not (len(funcs) == self.n_slider_dims or self.n_slider_dims == 0): + raise IndexError( + f"number of `window_funcs` must be the same as the number of slider dims: {self.n_slider_dims}, " + f"and you passed {len(funcs)} `window_funcs`: {funcs}" + ) @property - def window_sizes(self) -> tuple[int | None] | None: - pass + def window_sizes(self) -> tuple[int | None, ...] | None: + """get or set window sizes used for the corresponding window functions, see docstring for details""" + return self._window_sizes + + @window_sizes.setter + def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): + if window_sizes is None: + self._window_sizes = None + return + + if isinstance(window_sizes, int): + window_sizes = (window_sizes,) + + # if all are None + if all([w is None for w in window_sizes]): + self._window_sizes = None + return + + if not all([isinstance(w, (int)) or w is None for w in window_sizes]): + raise TypeError( + f"`window_sizes` must be of type: tuple[int | None, ...] | int | None, you have passed: {window_sizes}" + ) + + # if not (len(window_sizes) == self.n_slider_dims or self.n_slider_dims == 0): + # raise IndexError( + # f"number of `window_sizes` must be the same as the number of slider dims, " + # f"i.e. `data.ndim` - n_display_dims, your data array has {self.ndim} dimensions " + # f"and you passed {len(window_sizes)} `window_sizes`: {window_sizes}" + # ) + + # make all window sizes are valid numbers + _window_sizes = list() + for i, w in enumerate(window_sizes): + if w is None: + _window_sizes.append(None) + continue + + if w < 0: + raise ValueError( + f"negative window size passed, all `window_sizes` must be positive " + f"integers or `None`, you passed: {_window_sizes}" + ) + + if w == 0 or w == 1: + # this is not a real window, set as None + w = None + + elif w % 2 == 0: + # odd window sizes makes most sense + warn( + f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" + ) + w += 1 + + _window_sizes.append(w) + + self._window_sizes = tuple(_window_sizes) @property - def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: - pass + def window_order(self) -> tuple[int, ...] | None: + """get or set dimension order in which window functions are applied""" + return self._window_order + + @window_order.setter + def window_order(self, order: tuple[int] | None): + if order is None: + self._window_order = None + return + + if order is not None: + if not all([d <= self.n_slider_dims for d in order]): + raise IndexError( + f"all `window_order` entries must be <= n_slider_dims\n" + f"`n_slider_dims` is: {self.n_slider_dims}, you have passed `window_order`: {order}" + ) + + if not all([d >= 0 for d in order]): + raise IndexError( + f"all `window_order` entires must be >= 0, you have passed: {order}" + ) + + self._window_order = tuple(order) @property - def slider_dims(self) -> tuple[int, ...] | None: + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: pass + # @property + # def slider_dims(self) -> tuple[int, ...] | None: + # pass + @property def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: return self._slider_index_maps From 3f412c514e204279b70dad1bbb0c7c2b06796405 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 29 Jan 2026 20:48:25 -0500 Subject: [PATCH 008/163] scatter collection --- fastplotlib/graphics/__init__.py | 3 +- fastplotlib/graphics/scatter_collection.py | 517 ++++++++++++++++++ fastplotlib/layouts/_graphic_methods_mixin.py | 84 ++- 3 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 fastplotlib/graphics/scatter_collection.py diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 3d01e4a35..8734a5e72 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -7,7 +7,7 @@ from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack - +from .scatter_collection import ScatterCollection __all__ = [ "Graphic", @@ -22,4 +22,5 @@ "TextGraphic", "LineCollection", "LineStack", + "ScatterCollection", ] diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py new file mode 100644 index 000000000..4d671b0ac --- /dev/null +++ b/fastplotlib/graphics/scatter_collection.py @@ -0,0 +1,517 @@ +from typing import * + +import numpy as np + +import pygfx + +from ..utils import parse_cmap_values +from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature +from .scatter import ScatterGraphic +from .selectors import ( + LinearRegionSelector, + LinearSelector, + RectangleSelector, + PolygonSelector, +) + + +class _ScatterCollectionProperties: + """Mix-in class for ScatterCollection properties""" + + @property + def colors(self) -> CollectionFeature: + """get or set colors of scatters in the collection""" + return CollectionFeature(self.graphics, "colors") + + @colors.setter + def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(values, str): + # set colors of all scatter to one str color + for g in self: + g.colors = values + return + + elif all(isinstance(v, str) for v in values): + # individual str colors for each scatter + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.colors = v + + return + + if isinstance(values, np.ndarray): + if values.ndim == 2: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + return + + elif len(values) == 4: + # assume RGBA + self.colors[:] = values + + else: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + + @property + def data(self) -> CollectionFeature: + """get or set data of lines in the collection""" + return CollectionFeature(self.graphics, "data") + + @data.setter + def data(self, values): + for g, v in zip(self, values): + g.data = v + + @property + def cmap(self) -> CollectionFeature: + """ + Get or set a cmap along the scatter collection. + + Optionally set using a tuple ("cmap", ) to set the transform. + Example: + + scatter_collection.cmap = ("jet", sine_transform_vals, 0.7) + + """ + return CollectionFeature(self.graphics, "cmap") + + @cmap.setter + def cmap(self, args): + if isinstance(args, str): + name = args + transform = None + elif len(args) == 1: + name = args[0] + transform = None + elif len(args) == 2: + name, transform = args + else: + raise ValueError( + "Too many values for cmap (note that alpha is deprecated, set alpha on the graphic instead)" + ) + + self.colors = parse_cmap_values( + n_colors=len(self), cmap_name=name, transform=transform + ) + + +class ScatterCollectionIndexer(CollectionIndexer, _ScatterCollectionProperties): + """Indexer for scatter collections""" + + pass + + +class ScatterCollection(GraphicCollection, _ScatterCollectionProperties): + _child_type = ScatterGraphic + _indexer = ScatterCollectionIndexer + + def __init__( + self, + data: np.ndarray | List[np.ndarray], + colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", + uniform_colors: bool = False, + cmap: Sequence[str] | str = None, + cmap_transform: np.ndarray | List = None, + sizes: float | Sequence[float] = 2.0, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Sequence[Any] | np.ndarray = None, + isolated_buffer: bool = True, + kwargs_lines: list[dict] = None, + **kwargs, + ): + """ + Create a collection of :class:`.ScatterGraphic` + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + meatadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + """ + + super().__init__(name=name, metadata=metadata, **kwargs) + + if names is not None: + if len(names) != len(data): + raise ValueError( + f"len(names) != len(data)\n{len(names)} != {len(data)}" + ) + + if metadatas is not None: + if len(metadatas) != len(data): + raise ValueError( + f"len(metadata) != len(data)\n{len(metadatas)} != {len(data)}" + ) + + if kwargs_lines is not None: + if len(kwargs_lines) != len(data): + raise ValueError( + f"len(kwargs_lines) != len(data)\n" + f"{len(kwargs_lines)} != {len(data)}" + ) + + self._cmap_transform = cmap_transform + self._cmap_str = cmap + + # cmap takes priority over colors + if cmap is not None: + # cmap across lines + if isinstance(cmap, str): + colors = parse_cmap_values( + n_colors=len(data), cmap_name=cmap, transform=cmap_transform + ) + single_color = False + cmap = None + + elif isinstance(cmap, (tuple, list)): + if len(cmap) != len(data): + raise ValueError( + "cmap argument must be a single cmap or a list of cmaps " + "with the same length as the data" + ) + single_color = False + else: + raise ValueError( + "cmap argument must be a single cmap or a list of cmaps " + "with the same length as the data" + ) + else: + if isinstance(colors, np.ndarray): + # single color for all lines in the collection as RGBA + if colors.shape in [(3,), (4,)]: + single_color = True + + # colors specified for each line as array of shape [n_lines, RGBA] + elif colors.shape == (len(data), 4): + single_color = False + + else: + raise ValueError( + f"numpy array colors argument must be of shape (4,) or (n_lines, 4)." + f"You have pass the following shape: {colors.shape}" + ) + + elif isinstance(colors, str): + if colors == "random": + colors = np.random.rand(len(data), 3) + single_color = False + else: + # parse string color + single_color = True + colors = pygfx.Color(colors) + + elif isinstance(colors, (tuple, list)): + if len(colors) == 4: + # single color specified as (R, G, B, A) tuple or list + if all([isinstance(c, (float, int)) for c in colors]): + single_color = True + + elif len(colors) == len(data): + # colors passed as list/tuple of colors, such as list of string + single_color = False + + else: + raise ValueError( + "tuple or list colors argument must be a single color represented as [R, G, B, A], " + "or must be a tuple/list of colors represented by a string with the same length as the data" + ) + + if kwargs_lines is None: + kwargs_lines = dict() + + self._set_world_object(pygfx.Group()) + + for i, d in enumerate(data): + if cmap is None: + _cmap = None + + if single_color: + _c = colors + else: + _c = colors[i] + else: + _cmap = cmap[i] + _c = None + + if metadatas is not None: + _m = metadatas[i] + else: + _m = None + + if names is not None: + _name = names[i] + else: + _name = None + + lg = ScatterGraphic( + data=d, + colors=_c, + uniform_color=uniform_colors, + sizes=sizes, + cmap=_cmap, + name=_name, + metadata=_m, + isolated_buffer=isolated_buffer, + **kwargs_lines, + ) + + self.add_graphic(lg) + + def __getitem__(self, item) -> ScatterCollectionIndexer: + return super().__getitem__(item) + + def add_linear_selector( + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs + ) -> LinearSelector: + """ + Adds a linear selector. + + Parameters + ---------- + Parameters + ---------- + selection: float, optional + selected point on the linear selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear selector along the orthogonal axis to make it easier to interact with. + + kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init[0] + + selector = LinearSelector( + selection=selection, + limits=limits, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_linear_region_selector( + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, + ) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float), optional + the starting bounds of the linear region selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init + + # create selector + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + # PlotArea manages this for garbage collection etc. just like all other Graphics + # so we should only work with a proxy on the user-end + return selector + + def add_rectangle_selector( + self, + selection: tuple[float, float, float] = None, + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + bbox = self.world_object.get_world_bounding_box() + + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + value_25px = (xmax - xmin) / 4 + + ydata = np.array(self.data[:, 1]) + ymin = np.floor(ydata.min()).astype(int) + + ymax = np.ptp(bbox[:, 1]) + + if selection is None: + selection = (xmin, value_25px, ymin, ymax) + + limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) + + selector = RectangleSelector( + selection=selection, + limits=limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: List of positions, optional + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). + """ + bbox = self.world_object.get_world_bounding_box() + + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + + ydata = np.array(self.data[:, 1]) + ymin = np.floor(ydata.min()).astype(int) + + ymax = np.ptp(bbox[:, 1]) + + limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) + + selector = PolygonSelector( + selection, + limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def _get_linear_selector_init_args(self, axis, padding): + # use bbox to get size and center + bbox = self.world_object.get_world_bounding_box() + + if axis == "x": + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + value_25p = (xmax - xmin) / 4 + + bounds = (xmin, value_25p) + limits = (xmin, xmax) + # size from orthogonal axis + size = np.ptp(bbox[:, 1]) * 1.5 + # center on orthogonal axis + center = bbox[:, 1].mean() + + elif axis == "y": + ydata = np.array(self.data[:, 1]) + xmin, xmax = (np.nanmin(ydata), np.nanmax(ydata)) + value_25p = (xmax - xmin) / 4 + + bounds = (xmin, value_25p) + limits = (xmin, xmax) + + size = np.ptp(bbox[:, 0]) * 1.5 + # center on orthogonal axis + center = bbox[:, 0].mean() + + return bounds, limits, size, center diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 06a4c7517..3eb018f55 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -570,6 +570,88 @@ def add_polygon( PolygonGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs ) + def add_scatter_collection( + self, + data: Union[numpy.ndarray, List[numpy.ndarray]], + colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", + uniform_colors: bool = False, + cmap: Union[Sequence[str], str] = None, + cmap_transform: Union[numpy.ndarray, List] = None, + sizes: Union[float, Sequence[float]] = 2.0, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Union[Sequence[Any], numpy.ndarray] = None, + isolated_buffer: bool = True, + kwargs_lines: list[dict] = None, + **kwargs, + ) -> ScatterCollection: + """ + + Create a collection of :class:`.ScatterGraphic` + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + meatadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + + """ + return self._create_graphic( + ScatterCollection, + data, + colors, + uniform_colors, + cmap, + cmap_transform, + sizes, + name, + names, + metadata, + metadatas, + isolated_buffer, + kwargs_lines, + **kwargs, + ) + def add_scatter( self, data: Any, @@ -589,7 +671,7 @@ def add_scatter( image: numpy.ndarray = None, point_rotations: float | numpy.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", - sizes: Union[float, numpy.ndarray, Sequence[float]] = 1, + sizes: Union[float, numpy.ndarray, Sequence[float]] = 5, uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, From dc30151740ea77414a1b4e8d26009092c3aa4ff0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 30 Jan 2026 00:06:37 -0500 Subject: [PATCH 009/163] progress, need to change to other branch so committing --- fastplotlib/graphics/scatter_collection.py | 2 +- .../widgets/nd_widget/_nd_positions.py | 99 +++++++++++++------ 2 files changed, 68 insertions(+), 33 deletions(-) diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py index 4d671b0ac..b1569cacc 100644 --- a/fastplotlib/graphics/scatter_collection.py +++ b/fastplotlib/graphics/scatter_collection.py @@ -117,7 +117,7 @@ def __init__( uniform_colors: bool = False, cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, - sizes: float | Sequence[float] = 2.0, + sizes: float | Sequence[float] = 5.0, name: str = None, names: list[str] = None, metadata: Any = None, diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index dfcb263c5..decd3ec6c 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -13,6 +13,7 @@ LineStack, LineCollection, ScatterGraphic, + ScatterCollection, ) from ._processor_base import NDProcessor @@ -122,7 +123,7 @@ def _apply_window_functions(self, indices: tuple[int, ...]): start = max(0, i - hw) # stop index cannot exceed the bounds of this dimension - stop = min(self.shape[dim_index] - 1, i + hw) + stop = min(self.shape[dim_index], i + hw) s = slice(start, stop, 1) else: @@ -148,23 +149,34 @@ def get(self, indices: tuple[Any, ...]): Note that we do not use __getitem__ here since the index is a tuple specifying a single integer index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. """ - # apply window funcs - # this array should be of shape [n_datapoints, 2 | 3] - window_output = self._apply_window_functions(indices[:-1]).squeeze() + if len(indices) > 1: + # there are dims in addition to the n_datapoints dim + # apply window funcs + # window_output array should be of shape [n_datapoints, 2 | 3] + window_output = self._apply_window_functions(indices[:-1]).squeeze() + else: + window_output = self.data # TODO: window function on the `p` n_datapoints dimension if self.display_window is not None: dw = self.display_window - # half window size - hw = dw // 2 + if dw == 1: + slices = [slice(indices[-1], indices[-1] + 1)] + + else: + # half window size + hw = dw // 2 - # for now assume just a single index provided that indicates x axis value - start = max(indices[-1] - hw, 0) - stop = start + dw + # for now assume just a single index provided that indicates x axis value + start = max(indices[-1] - hw, 0) + stop = start + dw - slices = [slice(start, stop)] + # TODO: uncomment this once we have resizeable buffers!! + # stop = min(indices[-1] + hw, self.shape[-2]) + + slices = [slice(start, stop)] if self.multi: # n - 2 dim is n_lines or n_scatters @@ -177,14 +189,15 @@ class NDPositions: def __init__( self, data, - graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], + graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], multi: bool = False, + display_window: int = 10, ): if issubclass(graphic, LineCollection): multi = True - self._processor = NDPositionsProcessor(data, multi=multi, display_window=100, n_slider_dims=2) - self._indices = tuple([0] * (2 + 1)) + self._processor = NDPositionsProcessor(data, multi=multi, display_window=display_window, n_slider_dims=0) + self._indices = tuple([0] * (0 + 1)) self._create_graphic(graphic) @@ -196,11 +209,19 @@ def processor(self) -> NDPositionsProcessor: def graphic( self, ) -> ( - LineGraphic | LineCollection | LineStack | ScatterGraphic + LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic ): """LineStack or ImageGraphic for heatmaps""" return self._graphic + @graphic.setter + def graphic(self, graphic_type): + plot_area = self._graphic._plot_area + plot_area.delete_graphic(self._graphic) + + self._create_graphic(graphic_type) + plot_area.add_graphic(self._graphic) + @property def indices(self) -> tuple: return self._indices @@ -209,33 +230,47 @@ def indices(self) -> tuple: def indices(self, indices): data_slice = self.processor.get(indices) - if isinstance(self.graphic, list): - # list of scatter - for i in range(len(self.graphic)): - # data_slice shape is [n_scatters, n_datapoints, 2 | 3] - # by using data_slice.shape[-1] it will auto-select if the data is only xy or has xyz - self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] - - elif isinstance(self.graphic, (LineGraphic, ScatterGraphic)): + if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): self.graphic.data[:, : data_slice.shape[-1]] = data_slice - elif isinstance(self.graphic, LineCollection): + elif isinstance(self.graphic, (LineCollection, ScatterCollection)): for i in range(len(self.graphic)): # data_slice shape is [n_lines, n_datapoints, 2 | 3] self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] + elif isinstance(self.graphic, ImageGraphic): + image_data, x0, x_scale = self._create_heatmap_data(data_slice) + self.graphic.data = image_data + self.graphic.offset = (x0, *self.graphic.offset[1:]) + def _create_graphic( self, - graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic], + graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], ): - if self.processor.multi and issubclass(graphic_cls, ScatterGraphic): - # make list of scatters - self._graphic = list() - data_slice = self.processor.get(self.indices) - for d in data_slice: - scatter = graphic_cls(d) - self._graphic.append(scatter) + + data_slice = self.processor.get(self.indices) + + if issubclass(graphic_cls, ImageGraphic): + image_data, x0, x_scale = self._create_heatmap_data(data_slice) + self._graphic = graphic_cls(image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1)) else: - data_slice = self.processor.get(self.indices) self._graphic = graphic_cls(data_slice) + + def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: + if not self.processor.multi: + raise ValueError + + if self.processor.data.shape[-1] != 2: + raise ValueError + + # return [n_rows, n_cols] shape data + + image_data = data_slice[..., 1] + + # assume all x values are the same + x_scale = data_slice[:, -1, 0][0] / data_slice.shape[1] + + x0 = data_slice[0, 0, 0] + + return image_data, x0, x_scale From db98bde60f5b7b8b2bfc6288634616efccd529c0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 30 Jan 2026 00:34:37 -0500 Subject: [PATCH 010/163] better --- fastplotlib/widgets/nd_widget/_nd_positions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index decd3ec6c..bc7b5c242 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -216,6 +216,9 @@ def graphic( @graphic.setter def graphic(self, graphic_type): + if isinstance(self.graphic, graphic_type): + return + plot_area = self._graphic._plot_area plot_area.delete_graphic(self._graphic) From 3629f70f8c351feffe8208a0132f9463e63dd146 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 30 Jan 2026 20:45:47 -0500 Subject: [PATCH 011/163] interpolation for heatmap --- .../widgets/nd_widget/_nd_positions.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index bc7b5c242..f5b13a361 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -261,19 +261,35 @@ def _create_graphic( self._graphic = graphic_cls(data_slice) def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: + """return [n_rows, n_cols] shape data""" if not self.processor.multi: raise ValueError if self.processor.data.shape[-1] != 2: raise ValueError - # return [n_rows, n_cols] shape data + # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense + x = data_slice[0, :, 0] # get x from just the first row - image_data = data_slice[..., 1] + # check if we need to interpolate + norm = np.linalg.norm(np.diff(np.diff(x))) / x.size + + if norm > 1e-6: + # x is not uniform upto float32 precision, must interpolate + x_uniform = np.linspace(x[0], x[-1], num=x.size) + y_interp = np.zeros(shape=data_slice[..., 1].shape, dtype=np.float32) + + # this for loop is actually slightly faster than numpy.apply_along_axis() + for i in range(data_slice.shape[0]): + y_interp[i] = np.interp(x_uniform, x, data_slice[i, :, 1]) + + else: + # x is sufficiently uniform + y_interp = data_slice[..., 1] # assume all x values are the same x_scale = data_slice[:, -1, 0][0] / data_slice.shape[1] x0 = data_slice[0, 0, 0] - return image_data, x0, x_scale + return y_interp, x0, x_scale From 87ea418121114b1bf0617893d804c19baaf70a45 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 30 Jan 2026 20:47:08 -0500 Subject: [PATCH 012/163] better place for check --- fastplotlib/widgets/nd_widget/_nd_positions.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index f5b13a361..201bbb800 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -250,10 +250,15 @@ def _create_graphic( self, graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], ): - data_slice = self.processor.get(self.indices) if issubclass(graphic_cls, ImageGraphic): + if not self.processor.multi: + raise ValueError + + if self.processor.data.shape[-1] != 2: + raise ValueError + image_data, x0, x_scale = self._create_heatmap_data(data_slice) self._graphic = graphic_cls(image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1)) @@ -262,12 +267,6 @@ def _create_graphic( def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: """return [n_rows, n_cols] shape data""" - if not self.processor.multi: - raise ValueError - - if self.processor.data.shape[-1] != 2: - raise ValueError - # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense x = data_slice[0, :, 0] # get x from just the first row From e5a8d40e7f2a17f6c0effe5fd577f912cf968211 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 03:43:12 -0500 Subject: [PATCH 013/163] window functions working on n_datapoints dim --- .../widgets/nd_widget/_nd_positions.py | 111 +++++++++++++++--- .../widgets/nd_widget/_processor_base.py | 38 +++--- 2 files changed, 118 insertions(+), 31 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 201bbb800..ec64d4b9f 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -4,6 +4,7 @@ import numpy as np from numpy.typing import ArrayLike +from numpy.lib.stride_tricks import sliding_window_view from ...utils import subsample_array, ArrayProtocol @@ -15,7 +16,7 @@ ScatterGraphic, ScatterCollection, ) -from ._processor_base import NDProcessor +from ._processor_base import NDProcessor, WindowFuncCallable # TODO: Maybe get rid of n_display_dims in NDProcessor, @@ -27,15 +28,21 @@ def __init__( data: ArrayProtocol, multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points display_window: int | float | None = 100, # window for n_datapoints dim only - n_slider_dims: int = 0, + datapoints_window_func: Callable | None = None, + datapoints_window_size: int | None = None, + **kwargs ): - super().__init__(data=data) self._display_window = display_window + # TOOD: this does data validation twice and is a bit messy, cleanup + self._data = self._validate_data(data) self.multi = multi - self.n_slider_dims = n_slider_dims + super().__init__(data=data, **kwargs) + + self._datapoints_window_func = datapoints_window_func + self._datapoints_window_size = datapoints_window_size def _validate_data(self, data: ArrayProtocol): # TODO: determine right validation shape etc. @@ -70,6 +77,28 @@ def multi(self, m: bool): self._multi = m + @property + def slider_dims(self) -> tuple[int, ...]: + """slider dimensions""" + return tuple(range(self.ndim - 2 - int(self.multi))) + (self.ndim - 2,) + + @property + def n_slider_dims(self) -> int: + return self.ndim - 1 - int(self.multi) + + # TODO: validation for datapoints_window_func and size + @property + def datapoints_window_func(self) -> tuple[Callable, str] | None: + """ + Callable and str indicating which dims to apply window function along: + 'all', 'x', 'y', 'z', 'xyz', 'xy', 'xz', 'yz' + '""" + return self._datapoints_window_func + + @property + def datapoints_window_size(self) -> Callable | None: + return self._datapoints_window_size + def _apply_window_functions(self, indices: tuple[int, ...]): """applies the window functions for each dimension specified""" # window size for each dim @@ -77,15 +106,21 @@ def _apply_window_functions(self, indices: tuple[int, ...]): # window function for each dim funcs = self._window_funcs - if winds is None or funcs is None: - # no window funcs or window sizes, just slice data and return - # clamp to max bounds - indexer = list() - for dim, i in enumerate(indices): - i = min(self.shape[dim] - 1, i) - indexer.append(i) - - return self.data[tuple(indexer)] + # TODO: use tuple of None for window funcs and sizes to indicate all None, instead of just None + # print(winds) + # print(funcs) + # + # if winds is None or funcs is None: + # # no window funcs or window sizes, just slice data and return + # # clamp to max bounds + # indexer = list() + # print(indices) + # print(self.shape) + # for dim, i in enumerate(indices): + # i = min(self.shape[dim] - 1, i) + # indexer.append(i) + # + # return self.data[tuple(indexer)] # order in which window funcs are applied order = self._window_order @@ -172,6 +207,10 @@ def get(self, indices: tuple[Any, ...]): # for now assume just a single index provided that indicates x axis value start = max(indices[-1] - hw, 0) stop = start + dw + # also add window size of `p` dim so window_func output has the same number of datapoints + if self.datapoints_window_func is not None and self.datapoints_window_size is not None: + stop += self.datapoints_window_size - 1 + # TODO: pad with constant if we're using a window func and the index is near the end # TODO: uncomment this once we have resizeable buffers!! # stop = min(indices[-1] + hw, self.shape[-2]) @@ -182,7 +221,38 @@ def get(self, indices: tuple[Any, ...]): # n - 2 dim is n_lines or n_scatters slices.insert(0, slice(None)) - return window_output[tuple(slices)] + # data that will be used for the graphical representation + # a copy is made, if there were no window functions then this is a view of the original data + graphic_data = window_output[tuple(slices)].copy() + + # apply window function on the `p` n_datapoints dim + if self.datapoints_window_func is not None and self.datapoints_window_size is not None: + # get windows + + # graphic_data will be of shape: [n, p + (ws - 1), 2 | 3] + # where: + # n - number of lines, scatters, heatmap rows + # p - number of datapoints/samples + + # windows will be of shape [n, p, 1 | 2 | 3, ws] + wf = self.datapoints_window_func[0] + apply_dims = self.datapoints_window_func[1] + ws = self.datapoints_window_size + + # apply user's window func and return + # result will be of shape [n, p, 2 | 3] + if apply_dims == "all": + windows = sliding_window_view(graphic_data, ws, axis=-2) + return wf(windows, axis=-1) + + # map user dims str to tuple of numerical dims + dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) + windows = sliding_window_view(graphic_data[..., dims], ws, axis=-2).squeeze() + graphic_data[..., :self.display_window, dims] = wf(windows, axis=-1)[..., None] + + return graphic_data[..., :self.display_window, :] + + return graphic_data class NDPositions: @@ -192,12 +262,21 @@ def __init__( graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], multi: bool = False, display_window: int = 10, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, ): if issubclass(graphic, LineCollection): multi = True - self._processor = NDPositionsProcessor(data, multi=multi, display_window=display_window, n_slider_dims=0) - self._indices = tuple([0] * (0 + 1)) + self._processor = NDPositionsProcessor( + data, + multi=multi, + display_window=display_window, + window_funcs=window_funcs, + window_sizes=window_sizes, + ) + + self._indices = tuple([0] * self._processor.n_slider_dims) self._create_graphic(graphic) diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/_processor_base.py index 3350fff8f..974677144 100644 --- a/fastplotlib/widgets/nd_widget/_processor_base.py +++ b/fastplotlib/widgets/nd_widget/_processor_base.py @@ -16,14 +16,14 @@ def __init__( self, data, n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, + index_mappings: tuple[Callable[[Any], int] | None, ...] | None = None, window_funcs: tuple[WindowFuncCallable | None] | None = None, window_sizes: tuple[int | None] | None = None, window_order: tuple[int, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): self._data = self._validate_data(data) - self._slider_index_maps = self._validate_slider_index_maps(slider_index_maps) + self._index_mappings = self._validate_index_mappings(index_mappings) self.window_funcs = window_funcs self.window_sizes = window_sizes @@ -43,7 +43,7 @@ def shape(self) -> tuple[int, ...]: @property def ndim(self) -> int: - return int(np.prod(self.shape)) + return len(self.shape) def _validate_data(self, data: ArrayProtocol): if not isinstance(data, ArrayProtocol): @@ -51,6 +51,14 @@ def _validate_data(self, data: ArrayProtocol): return data + @property + def slider_dims(self): + raise NotImplementedError + + @property + def n_slider_dims(self): + raise NotImplementedError + @property def window_funcs( self, @@ -64,21 +72,21 @@ def window_funcs( window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None, ): if window_funcs is None: - self._window_funcs = None + self._window_funcs = tuple([None] * self.n_slider_dims) return if callable(window_funcs): window_funcs = (window_funcs,) # if all are None - if all([f is None for f in window_funcs]): - self._window_funcs = None - return + # if all([f is None for f in window_funcs]): + # self._window_funcs = tuple(window_funcs) + # return self._validate_window_func(window_funcs) self._window_funcs = tuple(window_funcs) - self._recompute_histogram() + # self._recompute_histogram() def _validate_window_func(self, funcs): if isinstance(funcs, (tuple, list)): @@ -112,7 +120,7 @@ def window_sizes(self) -> tuple[int | None, ...] | None: @window_sizes.setter def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): if window_sizes is None: - self._window_sizes = None + self._window_sizes = tuple([None] * self.n_slider_dims) return if isinstance(window_sizes, int): @@ -197,14 +205,14 @@ def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: # pass @property - def slider_index_maps(self) -> tuple[Callable[[Any], int] | None, ...]: - return self._slider_index_maps + def index_mappings(self) -> tuple[Callable[[Any], int] | None, ...]: + return self._index_mappings - @slider_index_maps.setter - def slider_index_maps(self, maps): - self._maps = self._validate_slider_index_maps(maps) + @index_mappings.setter + def index_mappings(self, maps): + self._index_mappings = self._validate_index_mappings(maps) - def _validate_slider_index_maps(self, maps): + def _validate_index_mappings(self, maps): if maps is not None: if not all([callable(m) or m is None for m in maps]): raise TypeError From 8d050a76a215c1fa78b764a8eb5e80c38a938c76 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 04:00:44 -0500 Subject: [PATCH 014/163] p dim window funcs working for single and multiple dims I think --- fastplotlib/widgets/nd_widget/_nd_positions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index ec64d4b9f..b20eabb96 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -234,7 +234,6 @@ def get(self, indices: tuple[Any, ...]): # n - number of lines, scatters, heatmap rows # p - number of datapoints/samples - # windows will be of shape [n, p, 1 | 2 | 3, ws] wf = self.datapoints_window_func[0] apply_dims = self.datapoints_window_func[1] ws = self.datapoints_window_size @@ -242,13 +241,18 @@ def get(self, indices: tuple[Any, ...]): # apply user's window func and return # result will be of shape [n, p, 2 | 3] if apply_dims == "all": + # windows will be of shape [n, p, 1 | 2 | 3, ws] windows = sliding_window_view(graphic_data, ws, axis=-2) return wf(windows, axis=-1) # map user dims str to tuple of numerical dims dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) + + # windows will be of shape [n, p, 1 | 2 | 3, ws] windows = sliding_window_view(graphic_data[..., dims], ws, axis=-2).squeeze() - graphic_data[..., :self.display_window, dims] = wf(windows, axis=-1)[..., None] + + # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary + graphic_data[..., :self.display_window, dims] = wf(windows, axis=-1).reshape(graphic_data.shape[0], self.display_window, len(dims)) return graphic_data[..., :self.display_window, :] From 373199786a7126f2759b59afef03fef6980eb3ba Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 18:20:43 -0500 Subject: [PATCH 015/163] black --- .../widgets/nd_widget/_nd_positions.py | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index b20eabb96..c39304996 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -30,7 +30,7 @@ def __init__( display_window: int | float | None = 100, # window for n_datapoints dim only datapoints_window_func: Callable | None = None, datapoints_window_size: int | None = None, - **kwargs + **kwargs, ): self._display_window = display_window @@ -208,7 +208,10 @@ def get(self, indices: tuple[Any, ...]): start = max(indices[-1] - hw, 0) stop = start + dw # also add window size of `p` dim so window_func output has the same number of datapoints - if self.datapoints_window_func is not None and self.datapoints_window_size is not None: + if ( + self.datapoints_window_func is not None + and self.datapoints_window_size is not None + ): stop += self.datapoints_window_size - 1 # TODO: pad with constant if we're using a window func and the index is near the end @@ -226,7 +229,10 @@ def get(self, indices: tuple[Any, ...]): graphic_data = window_output[tuple(slices)].copy() # apply window function on the `p` n_datapoints dim - if self.datapoints_window_func is not None and self.datapoints_window_size is not None: + if ( + self.datapoints_window_func is not None + and self.datapoints_window_size is not None + ): # get windows # graphic_data will be of shape: [n, p + (ws - 1), 2 | 3] @@ -249,12 +255,16 @@ def get(self, indices: tuple[Any, ...]): dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) # windows will be of shape [n, p, 1 | 2 | 3, ws] - windows = sliding_window_view(graphic_data[..., dims], ws, axis=-2).squeeze() + windows = sliding_window_view( + graphic_data[..., dims], ws, axis=-2 + ).squeeze() # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary - graphic_data[..., :self.display_window, dims] = wf(windows, axis=-1).reshape(graphic_data.shape[0], self.display_window, len(dims)) + graphic_data[..., : self.display_window, dims] = wf( + windows, axis=-1 + ).reshape(graphic_data.shape[0], self.display_window, len(dims)) - return graphic_data[..., :self.display_window, :] + return graphic_data[..., : self.display_window, :] return graphic_data @@ -263,7 +273,14 @@ class NDPositions: def __init__( self, data, - graphic: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], + graphic: Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ImageGraphic + ], multi: bool = False, display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, @@ -292,7 +309,12 @@ def processor(self) -> NDPositionsProcessor: def graphic( self, ) -> ( - LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ImageGraphic ): """LineStack or ImageGraphic for heatmaps""" return self._graphic @@ -331,7 +353,14 @@ def indices(self, indices): def _create_graphic( self, - graphic_cls: Type[LineGraphic | LineCollection | LineStack | ScatterGraphic | ScatterCollection | ImageGraphic], + graphic_cls: Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ImageGraphic + ], ): data_slice = self.processor.get(self.indices) @@ -343,7 +372,9 @@ def _create_graphic( raise ValueError image_data, x0, x_scale = self._create_heatmap_data(data_slice) - self._graphic = graphic_cls(image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1)) + self._graphic = graphic_cls( + image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1) + ) else: self._graphic = graphic_cls(data_slice) From 7d4e42024796bc673a5accf575ae469ee1148dc3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 19:12:43 -0500 Subject: [PATCH 016/163] index_mappings is working I think, lightly tested on p dim --- .../widgets/nd_widget/_nd_positions.py | 16 ++++++---- .../widgets/nd_widget/_processor_base.py | 29 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index c39304996..1871e027e 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -184,6 +184,9 @@ def get(self, indices: tuple[Any, ...]): Note that we do not use __getitem__ here since the index is a tuple specifying a single integer index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. """ + # apply any slider index mappings + indices = tuple([m(i) for m, i in zip(self.index_mappings, indices)]) + if len(indices) > 1: # there are dims in addition to the n_datapoints dim # apply window funcs @@ -195,7 +198,8 @@ def get(self, indices: tuple[Any, ...]): # TODO: window function on the `p` n_datapoints dimension if self.display_window is not None: - dw = self.display_window + # display window is interpreted using the index mapping for the `p` dim + dw = self.index_mappings[-1](self.display_window) if dw == 1: slices = [slice(indices[-1], indices[-1] + 1)] @@ -244,7 +248,7 @@ def get(self, indices: tuple[Any, ...]): apply_dims = self.datapoints_window_func[1] ws = self.datapoints_window_size - # apply user's window func and return + # apply user's window func # result will be of shape [n, p, 2 | 3] if apply_dims == "all": # windows will be of shape [n, p, 1 | 2 | 3, ws] @@ -260,11 +264,11 @@ def get(self, indices: tuple[Any, ...]): ).squeeze() # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary - graphic_data[..., : self.display_window, dims] = wf( + graphic_data[..., : dw, dims] = wf( windows, axis=-1 - ).reshape(graphic_data.shape[0], self.display_window, len(dims)) + ).reshape(graphic_data.shape[0], dw, len(dims)) - return graphic_data[..., : self.display_window, :] + return graphic_data[..., : dw, :] return graphic_data @@ -285,6 +289,7 @@ def __init__( display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, window_sizes: tuple[int | None] | None = None, + index_mappings: tuple[Callable[[Any], int] | None] | None = None, ): if issubclass(graphic, LineCollection): multi = True @@ -295,6 +300,7 @@ def __init__( display_window=display_window, window_funcs=window_funcs, window_sizes=window_sizes, + index_mappings=index_mappings, ) self._indices = tuple([0] * self._processor.n_slider_dims) diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/_processor_base.py index 974677144..225608cca 100644 --- a/fastplotlib/widgets/nd_widget/_processor_base.py +++ b/fastplotlib/widgets/nd_widget/_processor_base.py @@ -11,6 +11,10 @@ WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] +def identity(index: int) -> int: + return index + + class NDProcessor: def __init__( self, @@ -23,7 +27,7 @@ def __init__( spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): self._data = self._validate_data(data) - self._index_mappings = self._validate_index_mappings(index_mappings) + self._index_mappings = tuple(self._validate_index_mappings(index_mappings)) self.window_funcs = window_funcs self.window_sizes = window_sizes @@ -205,19 +209,30 @@ def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: # pass @property - def index_mappings(self) -> tuple[Callable[[Any], int] | None, ...]: + def index_mappings(self) -> tuple[Callable[[Any], int]]: return self._index_mappings @index_mappings.setter - def index_mappings(self, maps): - self._index_mappings = self._validate_index_mappings(maps) + def index_mappings(self, maps: tuple[Callable[[Any], int] | None] | None): + self._index_mappings = tuple(self._validate_index_mappings(maps)) def _validate_index_mappings(self, maps): - if maps is not None: - if not all([callable(m) or m is None for m in maps]): + if maps is None: + return tuple([identity] * self.n_slider_dims) + + if len(maps) != self.n_slider_dims: + raise IndexError + + _maps = list() + for m in maps: + if m is None: + _maps.append(identity) + elif callable(m): + _maps.append(identity) + else: raise TypeError - return maps + return tuple(maps) def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: pass From 6cdcb178913874482dd55ef20daf3113879fb3cf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Feb 2026 21:07:08 -0500 Subject: [PATCH 017/163] remove nd_timeseries since nd_positions is sufficient --- .../widgets/nd_widget/_nd_timeseries.py | 227 ------------------ 1 file changed, 227 deletions(-) delete mode 100644 fastplotlib/widgets/nd_widget/_nd_timeseries.py diff --git a/fastplotlib/widgets/nd_widget/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_timeseries.py deleted file mode 100644 index 49b9231c3..000000000 --- a/fastplotlib/widgets/nd_widget/_nd_timeseries.py +++ /dev/null @@ -1,227 +0,0 @@ -import inspect -from typing import Literal, Callable, Any -from warnings import warn - -import numpy as np -from numpy.typing import ArrayLike - -from ...utils import subsample_array, ArrayProtocol - -from ...graphics import ImageGraphic, LineStack, LineCollection, ScatterGraphic -from ._processor_base import NDProcessor, WindowFuncCallable - - -VALID_TIMESERIES_Y_DATA_SHAPES = ( - "[n_datapoints] for 1D array of y-values, [n_datapoints, 2] " - "for a 1D array of y and z-values, [n_lines, n_datapoints] for a 2D stack of lines with y-values, " - "or [n_lines, n_datapoints, 2] for a stack of lines with y and z-values." -) - - -# Limitation, no heatmap if z-values present, I don't think you can visualize that -class NDTimeSeriesProcessor(NDProcessor): - def __init__( - self, - data: list[ - ArrayProtocol, ArrayProtocol - ], # list: [x_vals_array, y_vals_and_z_vals_array] - x_values: ArrayProtocol = None, - cmap: str = None, - cmap_transform: ArrayProtocol = None, - display_graphic: Literal["line", "heatmap"] = "line", - n_display_dims: Literal[2, 3] = 2, - slider_index_maps: tuple[Callable[[Any], int] | None, ...] | None = None, - display_window: int | float | None = 100, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, - ): - super().__init__( - data=data, - n_display_dims=n_display_dims, - slider_index_maps=slider_index_maps, - ) - - self._display_window = display_window - - self._display_graphic = None - self.display_graphic = display_graphic - - self._uniform_x_values: ArrayProtocol | None = None - self._interp_yz: ArrayProtocol | None = None - - @property - def data(self) -> list[ArrayProtocol, ArrayProtocol]: - return self._data - - @data.setter - def data(self, data: list[ArrayProtocol, ArrayProtocol]): - self._data = self._validate_data(data) - - def _validate_data(self, data: list[ArrayProtocol, ArrayProtocol]): - x_vals, yz_vals = data - - if x_vals.ndim != 1: - raise ("data x values must be 1D") - - if data[1].ndim > 3: - raise ValueError( - f"data yz values must be of shape: {VALID_TIMESERIES_Y_DATA_SHAPES}. You passed data of shape: {yz_vals.shape}" - ) - - return data - - @property - def display_window(self) -> int | float | None: - """display window in the reference units along the x-axis""" - return self._display_window - - @display_window.setter - def display_window(self, dw: int | float | None): - if dw is None: - self._display_window = None - - elif not isinstance(dw, (int, float)): - raise TypeError - - self._display_window = dw - - def __getitem__(self, indices: tuple[Any, ...]) -> ArrayProtocol: - if self.display_window is not None: - # map reference units -> array int indices if necessary - if self.slider_index_maps is not None: - indices_window = self.slider_index_maps(self.display_window) - else: - indices_window = self.display_window - - # half window size - hw = indices_window // 2 - - # for now assume just a single index provided that indicates x axis value - start = max(indices - hw, 0) - stop = start + indices_window - - # slice dim would be ndim - 1 - return self.data[0][start:stop], self.data[1][:, start:stop] - - -class NDTimeSeries: - def __init__(self, processor: NDTimeSeriesProcessor, graphic): - self._processor = processor - - self._indices = 0 - - if graphic == "line": - self._create_line_stack() - elif graphic == "heatmap": - self._create_heatmap() - else: - raise ValueError - - @property - def processor(self) -> NDTimeSeriesProcessor: - return self._processor - - @property - def graphic(self) -> LineStack | ImageGraphic: - """LineStack or ImageGraphic for heatmaps""" - return self._graphic - - @graphic.setter - def graphic(self, g: Literal["line", "heatmap"]): - if g == "line": - # TODO: remove existing graphic - self._create_line_stack() - - elif g == "heatmap": - # make sure "yz" data is only ys and no z values - # can't represent y and z vals in a heatmap - if self.processor.data[1].ndim > 2: - raise ValueError( - "Only y-values are supported for heatmaps, not yz-values" - ) - self._create_heatmap() - - @property - def display_window(self) -> int | float | None: - return self.processor.display_window - - @display_window.setter - def display_window(self, dw: int | float | None): - # create new graphic if it changed - if dw != self.display_window: - create_new_graphic = True - else: - create_new_graphic = False - - self.processor.display_window = dw - - if create_new_graphic: - if isinstance(self.graphic, LineStack): - self.set_index(self._indices) - - def set_index(self, indices: tuple[Any, ...]): - # set the graphic at the given data indices - data_slice = self.processor[indices] - - if isinstance(self.graphic, LineStack): - line_stack_data = self._create_line_stack_data(data_slice) - - for g, line_data in zip(self.graphic.graphics, line_stack_data): - if line_data.shape[1] == 2: - # only x and y values - g.data[:, :-1] = line_data - else: - # has z values too - g.data[:] = line_data - - elif isinstance(self.graphic, ImageGraphic): - hm_data, scale = self._create_heatmap_data(data_slice) - self.graphic.data = hm_data - - self._indices = indices - - def _create_line_stack_data(self, data_slice): - xs = data_slice[0] # 1D - yz = data_slice[ - 1 - ] # [n_lines, n_datapoints] for y-vals or [n_lines, n_datapoints, 2] for yz-vals - - # need to go from x_vals and yz_vals arrays to an array of shape: [n_lines, n_datapoints, 2 | 3] - return np.dstack([np.repeat(xs[None], repeats=yz.shape[0], axis=0), yz]) - - def _create_line_stack(self): - data_slice = self.processor[self._indices] - - ls_data = self._create_line_stack_data(data_slice) - - self._graphic = LineStack(ls_data) - - def _create_heatmap_data(self, data_slice) -> tuple[ArrayProtocol, float]: - """Returns [n_lines, y_values] array and scale factor for x dimension""" - # check if x-vals uniformly spaced - # this is very fast to do on the fly, especially for typical small display windows - x, y = data_slice - norm = np.linalg.norm(np.diff(np.diff(x))) / x.size - if norm > 10**-12: - # need to create evenly spaced x-values - x_uniform = np.linspace(x[0], x[-1], num=x.size) - # yz is [n_lines, n_datapoints] - y_interp = np.zeros(shape=y.shape, dtype=np.float32) - for i in range(y.shape[0]): - y_interp[i] = np.interp(x_uniform, x, y[i]) - - else: - y_interp = y - - x_scale = x[-1] / x.size - - return y_interp, x_scale - - def _create_heatmap(self): - data_slice = self.processor[self._indices] - - hm_data, x_scale = self._create_heatmap_data(data_slice) - - self._graphic = ImageGraphic(hm_data) - self._graphic.world_object.world.scale_x = x_scale From 4748e5939350c9bdf12c8312448c9ce9106dcd68 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 4 Feb 2026 12:08:55 -0500 Subject: [PATCH 018/163] auto-replace buffers (#974) * remove isolated_buffer * remove isolated_buffer from mixin * basics works for positions data * replaceable buffers for all positions related features * image data buffer can change * resizeable buffers for volume * black * buffer resize condition checked only if new value is an array * gc for buffer managers * uniform colors WIP * switching color modes works! * typo * balck * update tests for color_mode * update examples * backend tests passing * default for all uniforms is True * update examples * forgot * update test * example tests passing * dereferencing test and fixes * simplify texture array tests a bit * image replace buffer tests pass yay * forgot a file * comments, check image graphic * add image reshaping example * add buffer replace imgui thing for manual testing * black * dont call wgpu_obj.destroy(), seems to work and clear VRAM with normal dereferencing * slower changes * update * update example * fixes and tweaks for test * remove unecessary stuff * update * docstrings * fix example * update example * update example * update docs --- docs/source/api/graphics/LineGraphic.rst | 1 + docs/source/api/graphics/ScatterGraphic.rst | 1 + examples/events/cmap_event.py | 2 +- examples/gridplot/multigraphic_gridplot.py | 2 +- examples/guis/imgui_basic.py | 4 +- examples/image/image_reshaping.py | 50 +++++ examples/line/line_cmap.py | 4 +- examples/line/line_cmap_more.py | 25 ++- examples/line/line_colorslice.py | 4 +- .../line_collection_slicing.py | 1 + examples/machine_learning/kmeans.py | 1 + examples/misc/buffer_replace_gc.py | 91 +++++++++ examples/misc/lorenz_animation.py | 7 +- examples/misc/reshape_lines_scatters.py | 92 +++++++++ examples/misc/scatter_animation.py | 2 +- examples/misc/scatter_sizes_animation.py | 2 +- examples/notebooks/quickstart.ipynb | 4 +- examples/scatter/scatter_iris.py | 1 + examples/scatter/scatter_size.py | 2 +- examples/scatter/scatter_validate.py | 2 + examples/scatter/spinning_spiral.py | 9 +- fastplotlib/graphics/_positions_base.py | 175 ++++++++++++++---- fastplotlib/graphics/features/_base.py | 54 +++--- fastplotlib/graphics/features/_image.py | 12 +- fastplotlib/graphics/features/_mesh.py | 8 +- fastplotlib/graphics/features/_positions.py | 99 ++++++++-- fastplotlib/graphics/features/_scatter.py | 134 +++++++++----- fastplotlib/graphics/features/_vectors.py | 2 - fastplotlib/graphics/features/_volume.py | 12 +- fastplotlib/graphics/image.py | 69 +++++-- fastplotlib/graphics/image_volume.py | 38 +++- fastplotlib/graphics/line.py | 25 +-- fastplotlib/graphics/line_collection.py | 11 +- fastplotlib/graphics/mesh.py | 17 +- fastplotlib/graphics/scatter.py | 88 ++++----- fastplotlib/layouts/_graphic_methods_mixin.py | 141 ++++++-------- tests/test_colors_buffer_manager.py | 12 +- tests/test_markers_buffer_manager.py | 8 +- tests/test_point_rotations_buffer_manager.py | 2 +- tests/test_positions_data_buffer_manager.py | 2 +- tests/test_positions_graphics.py | 55 +++--- tests/test_replace_buffer.py | 155 ++++++++++++++++ tests/test_scatter_graphic.py | 2 +- tests/test_texture_array.py | 134 ++++++-------- tests/utils_textures.py | 64 +++++++ 45 files changed, 1160 insertions(+), 466 deletions(-) create mode 100644 examples/image/image_reshaping.py create mode 100644 examples/misc/buffer_replace_gc.py create mode 100644 examples/misc/reshape_lines_scatters.py create mode 100644 tests/test_replace_buffer.py create mode 100644 tests/utils_textures.py diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 428e8ef56..867f1bfbb 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -25,6 +25,7 @@ Properties LineGraphic.axes LineGraphic.block_events LineGraphic.cmap + LineGraphic.color_mode LineGraphic.colors LineGraphic.data LineGraphic.deleted diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index cf8e1224d..f9dcd2487 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -25,6 +25,7 @@ Properties ScatterGraphic.axes ScatterGraphic.block_events ScatterGraphic.cmap + ScatterGraphic.color_mode ScatterGraphic.colors ScatterGraphic.data ScatterGraphic.deleted diff --git a/examples/events/cmap_event.py b/examples/events/cmap_event.py index 62913cb29..f01f06d6a 100644 --- a/examples/events/cmap_event.py +++ b/examples/events/cmap_event.py @@ -34,7 +34,7 @@ xs = np.linspace(0, 4 * np.pi, 100) ys = np.sin(xs) -figure["sine"].add_line(np.column_stack([xs, ys])) +figure["sine"].add_line(np.column_stack([xs, ys]), color_mode="vertex") # make a 2D gaussian cloud cloud_data = np.random.normal(0, scale=3, size=1000).reshape(500, 2) diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py index cbf546e2a..0e89efcdc 100644 --- a/examples/gridplot/multigraphic_gridplot.py +++ b/examples/gridplot/multigraphic_gridplot.py @@ -106,7 +106,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: gaussian_cloud2 = np.random.multivariate_normal(mean, covariance, n_points) # add the scatter graphics to the figure -figure["scatter"].add_scatter(data=gaussian_cloud, sizes=2, cmap="jet") +figure["scatter"].add_scatter(data=gaussian_cloud, sizes=2, cmap="jet", color_mode="vertex") figure["scatter"].add_scatter(data=gaussian_cloud2, colors="r", sizes=2) figure.show() diff --git a/examples/guis/imgui_basic.py b/examples/guis/imgui_basic.py index 26b5603c0..26c2c0fca 100644 --- a/examples/guis/imgui_basic.py +++ b/examples/guis/imgui_basic.py @@ -29,10 +29,10 @@ figure = fpl.Figure(size=(700, 560)) # make some scatter points at every 10th point -figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", uniform_color=True) +figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter") # place a line above the scatter -figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", uniform_color=True) +figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave") class ImguiExample(EdgeWindow): diff --git a/examples/image/image_reshaping.py b/examples/image/image_reshaping.py new file mode 100644 index 000000000..23264bda1 --- /dev/null +++ b/examples/image/image_reshaping.py @@ -0,0 +1,50 @@ +""" +Image reshaping +=============== + +An example that shows replacement of the image data with new data of a different shape. Under the hood, this creates a +new buffer and a new array of Textures on the GPU that replace the older Textures. Creating a new buffer and textures +has a performance cost, so you should do this only if you need to or if the performance drawback is not a concern for +your use case. + +Note that the vmin-vmax is reset when you replace the buffers. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + + +import numpy as np +import fastplotlib as fpl + +# create some data, diagonal sinusoidal bands +xs = np.linspace(0, 2300, 2300, dtype=np.float16) +full_data = np.vstack([np.cos(np.sqrt(xs + (np.pi / 2) * i)) * i for i in range(2_300)]) + +figure = fpl.Figure() + +image = figure[0, 0].add_image(full_data) + +figure.show() + +i, j = 1, 1 + + +def update(): + global i, j + # set the new image data as a subset of the full data + row = np.abs(np.sin(i)) * 2300 + col = np.abs(np.cos(i)) * 2300 + image.data = full_data[: int(row), : int(col)] + + i += 0.01 + j += 0.01 + + +figure.add_animations(update) + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/line/line_cmap.py b/examples/line/line_cmap.py index 3d2b5e8c9..6dfc1fe23 100644 --- a/examples/line/line_cmap.py +++ b/examples/line/line_cmap.py @@ -27,7 +27,7 @@ data=sine_data, thickness=10, cmap="plasma", - cmap_transform=sine_data[:, 1] + cmap_transform=sine_data[:, 1], ) # qualitative colormaps, useful for cluster labels or other types of categorical labels @@ -36,7 +36,7 @@ data=cosine_data, thickness=10, cmap="tab10", - cmap_transform=labels + cmap_transform=labels, ) figure.show() diff --git a/examples/line/line_cmap_more.py b/examples/line/line_cmap_more.py index c7c0d80f4..c6e811fb2 100644 --- a/examples/line/line_cmap_more.py +++ b/examples/line/line_cmap_more.py @@ -31,16 +31,35 @@ # set colormap by mapping data using a transform # here we map the color using the y-values of the sine data # i.e., the color is a function of sine(x) -line2 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", cmap_transform=sine[:, 1], offset=(0, 4, 0)) +line2 = figure[0, 0].add_line( + sine, + thickness=10, + cmap="jet", + cmap_transform=sine[:, 1], + offset=(0, 4, 0), +) # make a line and change the cmap afterward, here we are using the cosine instead fot the transform -line3 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", cmap_transform=cosine[:, 1], offset=(0, 6, 0)) +line3 = figure[0, 0].add_line( + sine, + thickness=10, + cmap="jet", + cmap_transform=cosine[:, 1], + offset=(0, 6, 0) +) + # change the cmap line3.cmap = "bwr" # use quantitative colormaps with categorical cmap_transforms labels = [0] * 25 + [1] * 5 + [2] * 50 + [3] * 20 -line4 = figure[0, 0].add_line(sine, thickness=10, cmap="tab10", cmap_transform=labels, offset=(0, 8, 0)) +line4 = figure[0, 0].add_line( + sine, + thickness=10, + cmap="tab10", + cmap_transform=labels, + offset=(0, 8, 0), +) # some text labels for i in range(5): diff --git a/examples/line/line_colorslice.py b/examples/line/line_colorslice.py index b6865eadb..264f944f3 100644 --- a/examples/line/line_colorslice.py +++ b/examples/line/line_colorslice.py @@ -30,7 +30,8 @@ sine = figure[0, 0].add_line( data=sine_data, thickness=5, - colors="magenta" + colors="magenta", + color_mode="vertex", # initialize with same color across vertices, but we will change the per-vertex colors later ) # you can also use colormaps for lines! @@ -56,6 +57,7 @@ data=zeros_data, thickness=8, colors="w", + color_mode="vertex", # initialize with same color across vertices, but we will change the per-vertex colors later offset=(0, 10, 0) ) diff --git a/examples/line_collection/line_collection_slicing.py b/examples/line_collection/line_collection_slicing.py index f829a53c6..98ad97056 100644 --- a/examples/line_collection/line_collection_slicing.py +++ b/examples/line_collection/line_collection_slicing.py @@ -26,6 +26,7 @@ multi_data, thickness=[2, 10, 2, 5, 5, 5, 8, 8, 8, 9, 3, 3, 3, 4, 4], separation=4, + color_mode="vertex", # this will allow us to set per-vertex colors on each line metadatas=list(range(15)), # some metadata names=list("abcdefghijklmno"), # unique name for each line ) diff --git a/examples/machine_learning/kmeans.py b/examples/machine_learning/kmeans.py index f571882ce..4c49844f0 100644 --- a/examples/machine_learning/kmeans.py +++ b/examples/machine_learning/kmeans.py @@ -80,6 +80,7 @@ sizes=5, cmap="tab10", # use a qualitative cmap cmap_transform=kmeans.labels_, # color by the predicted cluster + uniform_size=False, ) # initial index diff --git a/examples/misc/buffer_replace_gc.py b/examples/misc/buffer_replace_gc.py new file mode 100644 index 000000000..e3b0ac104 --- /dev/null +++ b/examples/misc/buffer_replace_gc.py @@ -0,0 +1,91 @@ +""" +Buffer replacement garbage collection test +========================================== + +This is an example that used for a manual test to ensure that GPU VRAM is free when buffers are replaced. + +Use while monitoring VRAM usage with nvidia-smi +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'code' + + +from typing import Literal +import numpy as np +import fastplotlib as fpl +from fastplotlib.ui import EdgeWindow +from imgui_bundle import imgui + + +def generate_dataset(size: int) -> dict[str, np.ndarray]: + return { + "data": np.random.rand(size, 3), + "colors": np.random.rand(size, 4), + # TODO: there's a wgpu bind group issue with edge_colors, will figure out later + # "edge_colors": np.random.rand(size, 4), + "markers": np.random.choice(list("osD+x^v<>*"), size=size), + "sizes": np.random.rand(size) * 5, + "point_rotations": np.random.rand(size) * 180, + } + + +datasets = { + "init": generate_dataset(50_000), + "small": generate_dataset(100), + "large": generate_dataset(5_000_000), +} + + +class UI(EdgeWindow): + def __init__(self, figure): + super().__init__(figure=figure, size=200, location="right", title="UI") + init_data = datasets["init"] + self._figure["line"].add_line( + data=init_data["data"], colors=init_data["colors"], name="line" + ) + self._figure["scatter"].add_scatter( + **init_data, + uniform_size=False, + uniform_marker=False, + uniform_edge_color=False, + point_rotation_mode="vertex", + name="scatter", + ) + + def update(self): + for graphic in ["line", "scatter"]: + if graphic == "line": + features = ["data", "colors"] + + elif graphic == "scatter": + features = list(datasets["init"].keys()) + + for size in ["small", "large"]: + for fea in features: + if imgui.button(f"{size} - {graphic} - {fea}"): + self._replace(graphic, fea, size) + + def _replace( + self, + graphic: Literal["line", "scatter", "image"], + feature: Literal["data", "colors", "markers", "sizes", "point_rotations"], + size: Literal["small", "large"], + ): + new_value = datasets[size][feature] + + setattr(self._figure[graphic][graphic], feature, new_value) + + +figure = fpl.Figure(shape=(3, 1), size=(700, 1600), names=["line", "scatter", "image"]) +ui = UI(figure) +figure.add_gui(ui) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/misc/lorenz_animation.py b/examples/misc/lorenz_animation.py index 20aee5d83..52a77a243 100644 --- a/examples/misc/lorenz_animation.py +++ b/examples/misc/lorenz_animation.py @@ -60,7 +60,12 @@ def lorenz(xyz, *, s=10, r=28, b=2.667): scatter_markers = list() for graphic in lorenz_line: - marker = figure[0, 0].add_scatter(graphic.data.value[0], sizes=16, colors=graphic.colors[0]) + marker = figure[0, 0].add_scatter( + graphic.data.value[0], + sizes=16, + colors=graphic.colors, + edge_colors="w", + ) scatter_markers.append(marker) # initialize time diff --git a/examples/misc/reshape_lines_scatters.py b/examples/misc/reshape_lines_scatters.py new file mode 100644 index 000000000..db8adb29e --- /dev/null +++ b/examples/misc/reshape_lines_scatters.py @@ -0,0 +1,92 @@ +""" +Change number of points in lines and scatters +============================================= + +This example sets lines and scatters with new data of a different shape, i.e. new data with more or fewer datapoints. +Internally, this creates new buffers for the feature that is being set (data, colors, markers, etc.). Note that there +are performance drawbacks to doing this, so it is recommended to maintain the same number of datapoints in a graphic +when possible. You only want to change the number of datapoints when it's really necessary, and you don't want to do +it constantly (such as tens or hundreds of times per second). + +This example is also useful for manually checking that GPU buffers are freed when they're no longer in use. Run this +example while monitoring VRAM usage with `nvidia-smi` +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + + +import numpy as np +import fastplotlib as fpl + +# create some data to start with +xs = np.linspace(0, 10 * np.pi, 100) +ys = np.sin(xs) + +data = np.column_stack([xs, ys]) + +# create a figure, add a line, scatter and line_stack +figure = fpl.Figure(shape=(3, 1), size=(700, 700)) + +line = figure[0, 0].add_line(data) + +scatter = figure[1, 0].add_scatter( + np.random.rand(100, 3), + colors=np.random.rand(100, 4), + markers=np.random.choice(list("osD+x^v<>*"), size=100), + sizes=(np.random.rand(100) + 1) * 3, + edge_colors=np.random.rand(100, 4), + point_rotations=np.random.rand(100) * 180, + uniform_marker=False, + uniform_size=False, + uniform_edge_color=False, + point_rotation_mode="vertex", +) + +line_stack = figure[2, 0].add_line_stack(np.stack([data] * 10), cmap="viridis") + +text = figure[0, 0].add_text(f"n_points: {100}", offset=(0, 1.5, 0), anchor="middle-left") + +figure.show(maintain_aspect=False) + +i = 0 + + +def update(): + # set a new larger or smaller data array on every render + global i + + # create new data + freq = np.abs(np.sin(i)) * 10 + n_points = int((freq * 20_000) + 10) + + xs = np.linspace(0, 10 * np.pi, n_points) + ys = np.sin(xs * freq) + + new_data = np.column_stack([xs, ys]) + + # update line data + line.data = new_data + + # update scatter data, colors, markers, etc. + scatter.data = np.random.rand(n_points, 3) + scatter.colors = np.random.rand(n_points, 4) + scatter.markers = np.random.choice(list("osD+x^v<>*"), size=n_points) + scatter.edge_colors = np.random.rand(n_points, 4) + scatter.point_rotations = np.random.rand(n_points) * 180 + + # update line stack data + line_stack.data = np.stack([new_data] * 10) + + text.text = f"n_points: {n_points}" + + i += 0.01 + + +figure.add_animations(update) + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/misc/scatter_animation.py b/examples/misc/scatter_animation.py index d37aea976..549059b65 100644 --- a/examples/misc/scatter_animation.py +++ b/examples/misc/scatter_animation.py @@ -37,7 +37,7 @@ figure = fpl.Figure(size=(700, 560)) subplot_scatter = figure[0, 0] # use an alpha value since this will be a lot of points -scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) +scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, uniform_size=False, colors=colors, alpha=0.6) def update_points(subplot): diff --git a/examples/misc/scatter_sizes_animation.py b/examples/misc/scatter_sizes_animation.py index 53a616a68..2092787f3 100644 --- a/examples/misc/scatter_sizes_animation.py +++ b/examples/misc/scatter_sizes_animation.py @@ -20,7 +20,7 @@ figure = fpl.Figure(size=(700, 560)) -figure[0, 0].add_scatter(data, sizes=sizes, name="sine") +figure[0, 0].add_scatter(data, sizes=sizes, uniform_size=False, name="sine") i = 0 diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb index 7b7551588..61bcb6b06 100644 --- a/examples/notebooks/quickstart.ipynb +++ b/examples/notebooks/quickstart.ipynb @@ -719,8 +719,8 @@ "# we will add all the lines to the same subplot\n", "subplot = fig_lines[0, 0]\n", "\n", - "# plot sine wave, use a single color\n", - "sine = subplot.add_line(data=sine_data, thickness=5, colors=\"magenta\")\n", + "# plot sine wave, use a single color for now, but we will set per-vertex colors later\n", + "sine = subplot.add_line(data=sine_data, thickness=5, colors=\"magenta\", color_mode=\"vertex\")\n", "\n", "# you can also use colormaps for lines!\n", "cosine = subplot.add_line(data=cosine_data, thickness=12, cmap=\"autumn\")\n", diff --git a/examples/scatter/scatter_iris.py b/examples/scatter/scatter_iris.py index b9df16026..fc228e5bf 100644 --- a/examples/scatter/scatter_iris.py +++ b/examples/scatter/scatter_iris.py @@ -35,6 +35,7 @@ cmap="tab10", cmap_transform=clusters_labels, markers=markers, + uniform_marker=False, ) figure.show() diff --git a/examples/scatter/scatter_size.py b/examples/scatter/scatter_size.py index 30d3e6ea3..2b3899dbe 100644 --- a/examples/scatter/scatter_size.py +++ b/examples/scatter/scatter_size.py @@ -35,7 +35,7 @@ ) # add a set of scalar sizes non_scalar_sizes = np.abs((y_values / np.pi)) # ensure minimum size of 5 -figure["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, colors="red") +figure["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, uniform_size=False, colors="red") for graph in figure: graph.auto_scale(maintain_aspect=True) diff --git a/examples/scatter/scatter_validate.py b/examples/scatter/scatter_validate.py index abddffee0..45f0a177c 100644 --- a/examples/scatter/scatter_validate.py +++ b/examples/scatter/scatter_validate.py @@ -41,6 +41,7 @@ uniform_edge_color=False, edge_colors=["w"] * 3 + ["orange"] * 3 + ["blue"] * 3 + ["green"], markers=list("osD+x^v<>*"), + uniform_marker=False, edge_width=2.0, sizes=20, uniform_size=True, @@ -64,6 +65,7 @@ sine, markers="s", sizes=xs * 5, + uniform_size=False, offset=(0, 2, 0) ) diff --git a/examples/scatter/spinning_spiral.py b/examples/scatter/spinning_spiral.py index 89e74eaec..4f947970a 100644 --- a/examples/scatter/spinning_spiral.py +++ b/examples/scatter/spinning_spiral.py @@ -34,7 +34,14 @@ canvas_kwargs={"max_fps": 500, "vsync": False} ) -spiral = figure[0, 0].add_scatter(data, cmap="viridis_r", edge_colors=None, alpha=0.5, sizes=sizes) +spiral = figure[0, 0].add_scatter( + data, + cmap="viridis_r", + edge_colors=None, + alpha=0.5, + sizes=sizes, + uniform_size=False, +) # pre-generate normally distributed data to jitter the points before each render jitter = np.random.normal(scale=0.001, size=n * 3).reshape((n, 3)) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index af7d7badb..763f5e775 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -1,4 +1,6 @@ -from typing import Any, Sequence +from numbers import Real +from typing import Any, Sequence, Literal +from warnings import warn import numpy as np @@ -18,12 +20,20 @@ class PositionsGraphic(Graphic): @property def data(self) -> VertexPositions: - """Get or set the graphic's data""" + """ + Get or set the graphic's data. + + Note that if the number of datapoints does not match the number of + current datapoints a new buffer is automatically allocated. This can + have performance drawbacks when you have a very large number of datapoints. + This is usually fine as long as you don't need to do it hundreds of times + per second. + """ return self._data @data.setter def data(self, value): - self._data[:] = value + self._data.set_value(self, value) @property def colors(self) -> VertexColors | pygfx.Color: @@ -36,11 +46,59 @@ def colors(self) -> VertexColors | pygfx.Color: @colors.setter def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): + self._colors.set_value(self, value) + + @property + def color_mode(self) -> Literal["uniform", "vertex"]: + """ + Get or set the color mode. Note that after setting the color_mode, you will have to set the `colors` + as well for switching between 'uniform' and 'vertex' modes. + """ + return self.world_object.material.color_mode + + @color_mode.setter + def color_mode(self, mode: Literal["uniform", "vertex"]): + valid = ("uniform", "vertex") + if mode not in valid: + raise ValueError(f"`color_mode` must be one of : {valid}") + if mode == "vertex" and isinstance(self._colors, UniformColor): + # uniform -> vertex + # need to make a new vertex buffer and get rid of uniform buffer + new_colors = self._create_colors_buffer(self._colors.value, "vertex") + # we can't clear world_object.material.color so just set the colors buffer on the geometry + # this doesn't really matter anyways since the lingering uniform color takes up just a few bytes + self.world_object.geometry.colors = new_colors._fpl_buffer + + elif mode == "uniform" and isinstance(self._colors, VertexColors): + # vertex -> uniform + # use first vertex color and spit out a warning + warn( + "changing `color_mode` from vertex -> uniform, will use first vertex color " + "for the uniform and discard the remaining color values" + ) + new_colors = self._create_colors_buffer(self._colors.value[0], "uniform") + self.world_object.geometry.colors = None + self.world_object.material.color = new_colors.value + + # clear out cmap + self._cmap.clear_event_handlers() + self._cmap = None + + else: + # no change, return + return + + # restore event handlers onto the new colors feature + new_colors._event_handlers[:] = self._colors._event_handlers + self._colors.clear_event_handlers() + # this should trigger gc + self._colors = new_colors + + # this is created so that cmap can be set later if isinstance(self._colors, VertexColors): - self._colors[:] = value + self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) - elif isinstance(self._colors, UniformColor): - self._colors.set_value(self, value) + self.world_object.material.color_mode = mode @property def cmap(self) -> VertexCmap: @@ -53,8 +111,8 @@ def cmap(self) -> VertexCmap: @cmap.setter def cmap(self, name: str): - if self._cmap is None: - raise BufferError("Cannot use cmap with uniform_colors=True") + if self.color_mode == "uniform": + raise ValueError("cannot use `cmap` with `color_mode` = 'uniform'") self._cmap[:] = name @@ -71,14 +129,68 @@ def size_space(self): def size_space(self, value: str): self._size_space.set_value(self, value) + def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColors: + # creates either a UniformColor or VertexColors based on the given `colors` and `color_mode` + # if `color_mode` = "auto", returns {UniformColor | VertexColor} based on what the `colors` arg represents + # if `color_mode` = "uniform", it verifies that the user `colors` input represents just 1 color + # if `color_mode` = "vertex", always returns VertexColors regardless of whether `colors` represents >= 1 colors + + if isinstance(colors, VertexColors): + if color_mode == "uniform": + raise ValueError( + "if a `VertexColors` instance is provided for `colors`, " + "`color_mode` must be 'vertex' or 'auto', not 'uniform'" + ) + # share buffer with existing colors instance + new_colors = colors + # blank colormap instance + self._cmap = VertexCmap(new_colors, cmap_name=None, transform=None) + + else: + # determine if a single or multiple colors were passed and decide color mode + if isinstance(colors, (pygfx.Color, str)) or ( + len(colors) in [3, 4] and all(isinstance(v, Real) for v in colors) + ): + # one color specified as a str or pygfx.Color, or one color specified with RGB(A) values + if color_mode in ("auto", "uniform"): + new_colors = UniformColor(colors) + else: + new_colors = VertexColors( + colors, n_colors=self._data.value.shape[0] + ) + + elif all(isinstance(c, (str, pygfx.Color)) for c in colors): + # sequence of colors + if color_mode == "uniform": + raise ValueError( + "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " + "`color_mode` = 'auto' or 'vertex' for multiple colors." + ) + new_colors = VertexColors(colors, n_colors=self._data.value.shape[0]) + + elif len(colors) > 4: + # sequence of multiple colors, must again ensure color_mode is not uniform + if color_mode == "uniform": + raise ValueError( + "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " + "`color_mode` = 'auto' or 'vertex' for multiple colors." + ) + new_colors = VertexColors(colors, n_colors=self._data.value.shape[0]) + else: + raise ValueError( + "`colors` must be a str, pygfx.Color, array, list or tuple indicating an RGB(A) color, or a " + "sequence of str, pygfx.Color, or array of shape [n_datapoints, 3 | 4]" + ) + + return new_colors + def __init__( self, data: Any, colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", - uniform_color: bool = False, cmap: str | VertexCmap = None, cmap_transform: np.ndarray = None, - isolated_buffer: bool = True, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", *args, **kwargs, @@ -86,22 +198,31 @@ def __init__( if isinstance(data, VertexPositions): self._data = data else: - self._data = VertexPositions(data, isolated_buffer=isolated_buffer) + self._data = VertexPositions(data) if cmap_transform is not None and cmap is None: raise ValueError("must pass `cmap` if passing `cmap_transform`") + valid = ("auto", "uniform", "vertex") + + # default _cmap is None + self._cmap = None + + if color_mode not in valid: + raise ValueError(f"`color_mode` must be one of {valid}") + if cmap is not None: # if a cmap is specified it overrides colors argument - if uniform_color: - raise TypeError("Cannot use cmap if uniform_color=True") + if color_mode == "uniform": + raise ValueError( + "if a `cmap` is provided, `color_mode` must be 'vertex' or 'auto', not 'uniform'" + ) if isinstance(cmap, str): # make colors from cmap if isinstance(colors, VertexColors): # share buffer with existing colors instance for the cmap self._colors = colors - self._colors._shared += 1 else: # create vertex colors buffer self._colors = VertexColors("w", n_colors=self._data.value.shape[0]) @@ -115,34 +236,18 @@ def __init__( # use existing cmap instance self._cmap = cmap self._colors = cmap._vertex_colors + else: raise TypeError( "`cmap` argument must be a cmap name or an existing `VertexCmap` instance" ) else: # no cmap given - if isinstance(colors, VertexColors): - # share buffer with existing colors instance - self._colors = colors - self._colors._shared += 1 - # blank colormap instance + self._colors = self._create_colors_buffer(colors, color_mode) + + # this is created so that cmap can be set later + if isinstance(self._colors, VertexColors): self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) - else: - if uniform_color: - if not isinstance(colors, str): # not a single color - if not len(colors) in [3, 4]: # not an RGB(A) array - raise TypeError( - "must pass a single color if using `uniform_colors=True`" - ) - self._colors = UniformColor(colors) - self._cmap = None - else: - self._colors = VertexColors( - colors, n_colors=self._data.value.shape[0] - ) - self._cmap = VertexCmap( - self._colors, cmap_name=None, transform=None - ) self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 779310476..76352b4ef 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -1,5 +1,6 @@ +import weakref from warnings import warn -from typing import Literal +from typing import Callable import numpy as np from numpy.typing import NDArray @@ -78,7 +79,7 @@ def block_events(self, val: bool): """ self._block_events = val - def add_event_handler(self, handler: callable): + def add_event_handler(self, handler: Callable): """ Add an event handler. All added event handlers are called when this feature changes. @@ -89,7 +90,7 @@ def add_event_handler(self, handler: callable): Parameters ---------- - handler: callable + handler: Callable a function to call when this feature changes """ @@ -102,7 +103,7 @@ def add_event_handler(self, handler: callable): self._event_handlers.append(handler) - def remove_event_handler(self, handler: callable): + def remove_event_handler(self, handler: Callable): """ Remove a registered event ``handler``. @@ -137,32 +138,28 @@ class BufferManager(GraphicFeature): def __init__( self, - data: NDArray | pygfx.Buffer, - buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", - isolated_buffer: bool = True, + data: NDArray | pygfx.Buffer | None, **kwargs, ): super().__init__(**kwargs) - if isolated_buffer and not isinstance(data, pygfx.Resource): - # useful if data is read-only, example: memmaps - bdata = np.zeros(data.shape, dtype=data.dtype) - bdata[:] = data[:] - else: - # user's input array is used as the buffer - bdata = data - - if isinstance(data, pygfx.Resource): - # already a buffer, probably used for - # managing another BufferManager, example: VertexCmap manages VertexColors - self._buffer = data - elif buffer_type == "buffer": - self._buffer = pygfx.Buffer(bdata) + + # if data is None, then the BufferManager just provides a view into an existing buffer + # example: VertexCmap is basically a view into VertexColors + if data is not None: + if isinstance(data, pygfx.Resource): + # already a buffer, probably used for + # managing another BufferManager, example: VertexCmap manages VertexColors + self._fpl_buffer = data + else: + # create a buffer + bdata = np.empty(data.shape, dtype=data.dtype) + bdata[:] = data[:] + + self._fpl_buffer = pygfx.Buffer(bdata) else: - raise ValueError( - "`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'" - ) + self._fpl_buffer = None - self._event_handlers: list[callable] = list() + self._event_handlers: list[Callable] = list() @property def value(self) -> np.ndarray: @@ -174,9 +171,10 @@ def set_value(self, graphic, value): self[:] = value @property - def buffer(self) -> pygfx.Buffer | pygfx.Texture: - """managed buffer""" - return self._buffer + def buffer(self) -> pygfx.Buffer: + """managed buffer, returns a weakref proxy""" + # the user should never create their own references to the buffer + return weakref.proxy(self._fpl_buffer) @property def __array_interface__(self): diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 648f79bc8..cb66bb1ef 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -33,7 +33,7 @@ class TextureArray(GraphicFeature): }, ] - def __init__(self, data, isolated_buffer: bool = True, property_name: str = "data"): + def __init__(self, data, property_name: str = "data"): super().__init__(property_name=property_name) data = self._fix_data(data) @@ -41,13 +41,9 @@ def __init__(self, data, isolated_buffer: bool = True, property_name: str = "dat shared = pygfx.renderers.wgpu.get_shared() self._texture_limit_2d = shared.device.limits["max-texture-dimension-2d"] - if isolated_buffer: - # useful if data is read-only, example: memmaps - self._value = np.zeros(data.shape, dtype=data.dtype) - self.value[:] = data[:] - else: - # user's input array is used as the buffer - self._value = data + # create a new buffer + self._value = np.zeros(data.shape, dtype=data.dtype) + self.value[:] = data[:] # data start indices for each Texture self._row_indices = np.arange( diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 7355acb4e..776d77ce4 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -51,18 +51,14 @@ class MeshIndices(VertexPositions): }, ] - def __init__( - self, data: Any, isolated_buffer: bool = True, property_name: str = "indices" - ): + def __init__(self, data: Any, property_name: str = "indices"): """ Manages the vertex indices buffer shown in the graphic. Supports fancy indexing if the data array also supports it. """ data = self._fix_data(data) - super().__init__( - data, isolated_buffer=isolated_buffer, property_name=property_name - ) + super().__init__(data, property_name=property_name) def _fix_data(self, data): if data.ndim != 2 or data.shape[1] not in (3, 4): diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 295d22417..7b67e6bd7 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -39,7 +39,6 @@ def __init__( self, colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], n_colors: int, - isolated_buffer: bool = True, property_name: str = "colors", ): """ @@ -57,9 +56,56 @@ def __init__( """ data = parse_colors(colors, n_colors) - super().__init__( - data=data, isolated_buffer=isolated_buffer, property_name=property_name - ) + super().__init__(data=data, property_name=property_name) + + def set_value( + self, + graphic, + value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], + ): + """set the entire array, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + # TODO: Refactor this triage so it's more elegant + + # first make sure it's not representing one color + skip = False + if isinstance(value, np.ndarray): + if (value.shape in ((3,), (4,))) and ( + np.issubdtype(value.dtype, np.floating) + or np.issubdtype(value.dtype, np.integer) + ): + # represents one color + skip = True + elif isinstance(value, (list, tuple)): + if len(value) in (3, 4) and all( + [isinstance(v, (float, int)) for v in value] + ): + # represents one color + skip = True + + # check if the number of elements matches current buffer size + if not skip and self.buffer.data.shape[0] != len(value): + # parse the new colors + new_colors = parse_colors(value, len(value)) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(new_colors) + graphic.world_object.geometry.colors = self._fpl_buffer + + if len(self._event_handlers) < 1: + return + + event_info = { + "key": slice(None), + "value": new_colors, + "user_value": value, + } + + event = GraphicFeatureEvent(self._property_name, info=event_info) + self._call_event_handlers(event) + return + + self[:] = value @block_reentrance def __setitem__( @@ -231,18 +277,14 @@ class VertexPositions(BufferManager): }, ] - def __init__( - self, data: Any, isolated_buffer: bool = True, property_name: str = "data" - ): + def __init__(self, data: Any, property_name: str = "data"): """ Manages the vertex positions buffer shown in the graphic. Supports fancy indexing if the data array also supports it. """ data = self._fix_data(data) - super().__init__( - data, isolated_buffer=isolated_buffer, property_name=property_name - ) + super().__init__(data, property_name=property_name) def _fix_data(self, data): if data.ndim == 1: @@ -261,13 +303,42 @@ def _fix_data(self, data): return to_gpu_supported_dtype(data) + def set_value(self, graphic, value): + """Sets the entire array, creates new buffer if necessary""" + if isinstance(value, np.ndarray): + if self.buffer.data.shape[0] != value.shape[0]: + # number of items doesn't match, create a new buffer + + # if data is not 3D + if value.ndim == 1: + # _fix_data creates a new array so we don't need to re-allocate with np.zeros + bdata = self._fix_data(value) + + elif value.shape[1] == 2: + # _fix_data creates a new array so we don't need to re-allocate with np.zeros + bdata = self._fix_data(value) + + elif value.shape[1] == 3: + # need to allocate a buffer to use here + bdata = np.empty(value.shape, dtype=np.float32) + bdata[:] = value[:] + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(bdata) + graphic.world_object.geometry.positions = self._fpl_buffer + + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value + @block_reentrance def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value: np.ndarray | float | list[float], ): - # directly use the key to slice the buffer + # directly use the key to slice the buffer and set the values self.buffer.data[key] = value # _update_range handles parsing the key to @@ -306,7 +377,7 @@ def __init__( provides a way to set colormaps with arbitrary transforms """ - super().__init__(data=vertex_colors.buffer, property_name=property_name) + super().__init__(data=None, property_name=property_name) self._vertex_colors = vertex_colors self._cmap_name = cmap_name @@ -331,6 +402,10 @@ def __init__( # set vertex colors from cmap self._vertex_colors[:] = colors + @property + def buffer(self) -> pygfx.Buffer: + return self._vertex_colors.buffer + @block_reentrance def __setitem__(self, key: slice, cmap_name): if not isinstance(key, slice): diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index 16671ef89..36c8527be 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -100,6 +100,37 @@ def searchsorted_markers_to_int_array(markers_str_array: np.ndarray[str]): return marker_int_searchsorted_vals[indices] +def parse_markers_init(markers: str | Sequence[str] | np.ndarray, n_datapoints: int): + # first validate then allocate buffers + + if isinstance(markers, str): + markers = user_input_to_marker(markers) + + elif isinstance(markers, (tuple, list, np.ndarray)): + validate_user_markers_array(markers) + + # allocate buffers + markers_int_array = np.zeros(n_datapoints, dtype=np.int32) + + marker_str_length = max(map(len, list(pygfx.MarkerShape))) + + markers_readable_array = np.empty(n_datapoints, dtype=f" np.ndarray[str]: @@ -200,6 +200,25 @@ def _set_markers_arrays(self, key, value, n_markers): "new markers value must be a str, Sequence or np.ndarray of new marker values" ) + def set_value(self, graphic, value): + """set all the markers, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + if self.buffer.data.shape[0] != len(value): + # need to create a new buffer + markers_int_array, self._markers_readable_array = parse_markers_init( + value, len(value) + ) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(markers_int_array) + graphic.world_object.geometry.markers = self._fpl_buffer + + self._emit_event(self._property_name, key=slice(None), value=value) + + return + + self[:] = value + @block_reentrance def __setitem__( self, @@ -414,18 +433,15 @@ def __init__( self, rotations: int | float | np.ndarray | Sequence[int | float], n_datapoints: int, - isolated_buffer: bool = True, property_name: str = "point_rotations", ): """ Manages rotations buffer of scatter points. """ - sizes = self._fix_sizes(rotations, n_datapoints) - super().__init__( - data=sizes, isolated_buffer=isolated_buffer, property_name=property_name - ) + sizes = self._fix_rotations(rotations, n_datapoints) + super().__init__(data=sizes, property_name=property_name) - def _fix_sizes( + def _fix_rotations( self, sizes: int | float | np.ndarray | Sequence[int | float], n_datapoints: int, @@ -454,6 +470,22 @@ def _fix_sizes( return sizes + def set_value(self, graphic, value): + """set all rotations, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + if self.buffer.data.shape[0] != value.shape[0]: + # need to create a new buffer + value = self._fix_rotations(value, len(value)) + data = np.empty(shape=(len(value),), dtype=np.float32) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(data) + graphic.world_object.geometry.rotations = self._fpl_buffer + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value + @block_reentrance def __setitem__( self, @@ -488,16 +520,13 @@ def __init__( self, sizes: int | float | np.ndarray | Sequence[int | float], n_datapoints: int, - isolated_buffer: bool = True, property_name: str = "sizes", ): """ Manages sizes buffer of scatter points. """ sizes = self._fix_sizes(sizes, n_datapoints) - super().__init__( - data=sizes, isolated_buffer=isolated_buffer, property_name=property_name - ) + super().__init__(data=sizes, property_name=property_name) def _fix_sizes( self, @@ -533,6 +562,23 @@ def _fix_sizes( return sizes + def set_value(self, graphic, value): + """set all sizes, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + if self.buffer.data.shape[0] != len(value): + # create new buffer + value = self._fix_sizes(value, len(value)) + data = np.empty(shape=(len(value),), dtype=np.float32) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(data) + graphic.world_object.geometry.sizes = self._fpl_buffer + + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value + @block_reentrance def __setitem__( self, diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 9c86d25fc..729562b06 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -22,7 +22,6 @@ class VectorPositions(GraphicFeature): def __init__( self, positions: np.ndarray, - isolated_buffer: bool = True, property_name: str = "positions", ): """ @@ -111,7 +110,6 @@ class VectorDirections(GraphicFeature): def __init__( self, directions: np.ndarray, - isolated_buffer: bool = True, property_name: str = "directions", ): """Manages vector field positions by managing the mesh instance buffer's full transform matrix""" diff --git a/fastplotlib/graphics/features/_volume.py b/fastplotlib/graphics/features/_volume.py index ec4c4052a..532065fb7 100644 --- a/fastplotlib/graphics/features/_volume.py +++ b/fastplotlib/graphics/features/_volume.py @@ -34,7 +34,7 @@ class TextureArrayVolume(GraphicFeature): }, ] - def __init__(self, data, isolated_buffer: bool = True): + def __init__(self, data): super().__init__(property_name="data") data = self._fix_data(data) @@ -43,13 +43,9 @@ def __init__(self, data, isolated_buffer: bool = True): self._texture_size_limit = shared.device.limits["max-texture-dimension-3d"] - if isolated_buffer: - # useful if data is read-only, example: memmaps - self._value = np.zeros(data.shape, dtype=data.dtype) - self.value[:] = data[:] - else: - # user's input array is used as the buffer - self._value = data + # create a new buffer that will be used for the texture data + self._value = np.zeros(data.shape, dtype=data.dtype) + self.value[:] = data[:] # data start indices for each Texture self._row_indices = np.arange( diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 44bffcedc..760b856d2 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,6 +1,7 @@ import math from typing import * +import numpy as np import pygfx from ..utils import quick_min_max @@ -102,7 +103,6 @@ def __init__( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - isolated_buffer: bool = True, **kwargs, ): """ @@ -130,12 +130,6 @@ def __init__( cmap_interpolation: str, optional, default "linear" colormap interpolation method, one of "nearest" or "linear" - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. - kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -143,7 +137,7 @@ def __init__( super().__init__(**kwargs) - world_object = pygfx.Group() + group = pygfx.Group() if isinstance(data, TextureArray): # share buffer @@ -151,7 +145,7 @@ def __init__( else: # create new texture array to manage buffer # texture array that manages the multiple textures on the GPU that represent this image - self._data = TextureArray(data, isolated_buffer=isolated_buffer) + self._data = TextureArray(data) if (vmin is None) or (vmax is None): _vmin, _vmax = quick_min_max(self.data.value) @@ -165,6 +159,7 @@ def __init__( self._vmax = ImageVmax(vmax) self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) # set map to None for RGB images if self._data.value.ndim > 2: @@ -173,7 +168,6 @@ def __init__( else: # use TextureMap for grayscale images self._cmap = ImageCmap(cmap) - self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) _map = pygfx.TextureMap( self._cmap.texture, @@ -189,6 +183,14 @@ def __init__( pick_write=True, ) + # create the _ImageTile world objects, add to group + for tile in self._create_tiles(): + group.add(tile) + + self._set_world_object(group) + + def _create_tiles(self) -> list[_ImageTile]: + tiles = list() # iterate through each texture chunk and create # an _ImageTile, offset the tile using the data indices for texture, chunk_index, data_slice in self._data: @@ -209,17 +211,58 @@ def __init__( img.world.x = data_col_start img.world.y = data_row_start - world_object.add(img) + tiles.append(img) - self._set_world_object(world_object) + return tiles @property def data(self) -> TextureArray: - """Get or set the image data""" + """ + Get or set the image data. + + Note that if the shape of the new data array does not equal the shape of + current data array, a new set of GPU Textures are automatically created. + This can have performance drawbacks when you have a ver large images. + This is usually fine as long as you don't need to do it hundreds of times + per second. + """ return self._data @data.setter def data(self, data): + if isinstance(data, np.ndarray): + # check if a new buffer is required + if self._data.value.shape != data.shape: + # create new TextureArray + self._data = TextureArray(data) + + # cmap based on if rgb or grayscale + if self._data.value.ndim > 2: + self._cmap = None + + # must be None if RGB(A) + self._material.map = None + else: + if self.cmap is None: # have switched from RGBA -> grayscale image + # create default cmap + self._cmap = ImageCmap("plasma") + self._material.map = pygfx.TextureMap( + self._cmap.texture, + filter=self._cmap_interpolation.value, + wrap="clamp-to-edge", + ) + + self._material.clim = quick_min_max(self.data.value) + + # clear image tiles + self.world_object.clear() + + # create new tiles + for tile in self._create_tiles(): + self.world_object.add(tile) + + return + self._data[:] = data @property diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index db8f29eaa..a3b379492 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -113,7 +113,6 @@ def __init__( substep_size: float = 0.1, emissive: str | tuple | np.ndarray = (0, 0, 0), shininess: int = 30, - isolated_buffer: bool = True, **kwargs, ): """ @@ -170,11 +169,6 @@ def __init__( How shiny the specular highlight is; a higher value gives a sharper highlight. Used only if `mode` = "iso" - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then set the data, useful if the - data arrays are ready-only such as memmaps. If False, the input array is itself used as the - buffer - useful if the array is large. - kwargs additional keyword arguments passed to :class:`.Graphic` @@ -188,7 +182,7 @@ def __init__( super().__init__(**kwargs) - world_object = pygfx.Group() + group = pygfx.Group() if isinstance(data, TextureArrayVolume): # share existing buffer @@ -196,7 +190,7 @@ def __init__( else: # create new texture array to manage buffer # texture array that manages the textures on the GPU that represent this image volume - self._data = TextureArrayVolume(data, isolated_buffer=isolated_buffer) + self._data = TextureArrayVolume(data) if (vmin is None) or (vmax is None): _vmin, _vmax = quick_min_max(self.data.value) @@ -237,6 +231,15 @@ def __init__( self._mode = VolumeRenderMode(mode) + # create tiles + for tile in self._create_tiles(): + group.add(tile) + + self._set_world_object(group) + + def _create_tiles(self) -> list[_VolumeTile]: + tiles = list() + # iterate through each texture chunk and create # a _VolumeTile, offset the tile using the data indices for texture, chunk_index, data_slice in self._data: @@ -259,9 +262,9 @@ def __init__( vol.world.x = data_col_start vol.world.y = data_row_start - world_object.add(vol) + tiles.append(vol) - self._set_world_object(world_object) + return tiles @property def data(self) -> TextureArrayVolume: @@ -270,6 +273,21 @@ def data(self) -> TextureArrayVolume: @data.setter def data(self, data): + if isinstance(data, np.ndarray): + # check if a new buffer is required + if self._data.value.shape != data.shape: + # create new TextureArray + self._data = TextureArrayVolume(data) + + # clear image tiles + self.world_object.clear() + + # create new tiles + for tile in self._create_tiles(): + self.world_object.add(tile) + + return + self._data[:] = data @property diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index a4f42704f..bba10b10f 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -18,6 +18,7 @@ UniformColor, VertexCmap, SizeSpace, + UniformRotations, ) from ..utils import quick_min_max @@ -36,10 +37,9 @@ def __init__( data: Any, thickness: float = 2.0, colors: str | np.ndarray | Sequence = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: np.ndarray | Sequence = None, - isolated_buffer: bool = True, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", **kwargs, ): @@ -61,15 +61,19 @@ def __init__( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default ``False`` - if True, uses a uniform buffer for the line color, - basically saves GPU VRAM when the entire line has a single color - cmap: str, optional Apply a colormap to the line instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap @@ -84,10 +88,9 @@ def __init__( super().__init__( data=data, colors=colors, - uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, - isolated_buffer=isolated_buffer, + color_mode=color_mode, size_space=size_space, **kwargs, ) @@ -102,8 +105,8 @@ def __init__( aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") - if uniform_color: - geometry = pygfx.Geometry(positions=self._data.buffer) + if isinstance(self._colors, UniformColor): + geometry = pygfx.Geometry(positions=self._data._fpl_buffer) material = MaterialCls( aa=aa, thickness=self.thickness, @@ -123,7 +126,7 @@ def __init__( depth_compare="<=", ) geometry = pygfx.Geometry( - positions=self._data.buffer, colors=self._colors.buffer + positions=self._data._fpl_buffer, colors=self._colors._fpl_buffer ) world_object: pygfx.Line = pygfx.Line(geometry=geometry, material=material) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index d08231f7d..5ec56777e 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -128,14 +128,13 @@ def __init__( data: np.ndarray | List[np.ndarray], thickness: float | Sequence[float] = 2.0, colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", - uniform_colors: bool = False, cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, **kwargs, ): @@ -170,6 +169,9 @@ def __init__( cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + The color mode for each line in the collection. See `color_mode` in :class:`.LineGraphic` for details. + name: str, optional name of the line collection as a whole @@ -320,11 +322,10 @@ def __init__( data=d, thickness=_s, colors=_c, - uniform_color=uniform_colors, cmap=_cmap, + color_mode=color_mode, name=_name, metadata=_m, - isolated_buffer=isolated_buffer, **kwargs_lines, ) @@ -560,7 +561,6 @@ def __init__( names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - isolated_buffer: bool = True, separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, @@ -634,7 +634,6 @@ def __init__( names=names, metadata=metadata, metadatas=metadatas, - isolated_buffer=isolated_buffer, kwargs_lines=kwargs_lines, **kwargs, ) diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 0e1ac42a3..efe03c57b 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -38,7 +38,6 @@ def __init__( mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] = None, - isolated_buffer: bool = True, **kwargs, ): """ @@ -77,12 +76,6 @@ def __init__( Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. An image can also be used, this is basically a 2D colormap. - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. In almost all cases this should be ``True``. - **kwargs passed to :class:`.Graphic` @@ -93,16 +86,12 @@ def __init__( if isinstance(positions, VertexPositions): self._positions = positions else: - self._positions = VertexPositions( - positions, isolated_buffer=isolated_buffer, property_name="positions" - ) + self._positions = VertexPositions(positions, property_name="positions") if isinstance(positions, MeshIndices): self._indices = indices else: - self._indices = MeshIndices( - indices, isolated_buffer=isolated_buffer, property_name="indices" - ) + self._indices = MeshIndices(indices, property_name="indices") self._cmap = MeshCmap(cmap) @@ -139,7 +128,7 @@ def __init__( ) geometry = pygfx.Geometry( - positions=self._positions.buffer, indices=self._indices._buffer + positions=self._positions.buffer, indices=self._indices._fpl_buffer ) valid_modes = ["basic", "phong", "slice"] diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 5268dcc51..b9cacf908 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -40,12 +40,12 @@ def __init__( self, data: Any, colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: np.ndarray = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: str | np.ndarray | Sequence[str] = "o", - uniform_marker: bool = False, + uniform_marker: bool = True, custom_sdf: str = None, edge_colors: str | np.ndarray | pygfx.Color | Sequence[float] = "black", uniform_edge_color: bool = True, @@ -54,9 +54,8 @@ def __init__( point_rotations: float | np.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: float | np.ndarray | Sequence[float] = 5, - uniform_size: bool = False, + uniform_size: bool = True, size_space: str = "screen", - isolated_buffer: bool = True, **kwargs, ): """ @@ -72,18 +71,23 @@ def __init__( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default False - if True, uses a uniform buffer for the scatter point colors. Useful if you need to - save GPU VRAM when all points have the same color. - cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this - overrides any argument passed to "colors". For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" The scatter points mode, cannot be changed after the graphic has been created. @@ -103,9 +107,10 @@ def __init__( * Emojis: "❤️♠️♣️♦️💎💍✳️📍". * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - uniform_marker: bool, default False - Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use - the same marker for all points and want to save GPU RAM. + uniform_marker: bool, default ``True`` + If ``True``, use the same marker for all points. Only valid when `mode` is "markers". + Useful if you need to use the same marker for all points and want to save GPU RAM. If ``False``, you can + set per-vertex markers. custom_sdf: str = None, The SDF code for the marker shape when the marker is set to custom. @@ -125,8 +130,9 @@ def __init__( edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" edge color of the markers, used when `mode` is "markers" - uniform_edge_color: bool, default True - Set the same edge color for all markers. Useful for saving GPU RAM. + uniform_edge_color: bool, default ``True`` + Set the same edge color for all markers. Useful for saving GPU RAM. Set to ``False`` for per-vertex edge + colors edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". @@ -147,17 +153,13 @@ def __init__( sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points - uniform_size: bool, default False - if True, uses a uniform buffer for the scatter point sizes. Useful if you need to - save GPU VRAM when all points have the same size. + uniform_size: bool, default ``False`` + if ``True``, uses a uniform buffer for the scatter point sizes. Useful if you need to + save GPU VRAM when all points have the same size. Set to ``False`` if you need per-vertex sizes. size_space: str, default "screen" coordinate space in which the size is expressed, one of ("screen", "world", "model") - isolated_buffer: bool, default True - whether the buffers should be isolated from the user input array. - Generally always ``True``, ``False`` is for rare advanced use if you have large arrays. - kwargs passed to :class:`.Graphic` @@ -166,17 +168,16 @@ def __init__( super().__init__( data=data, colors=colors, - uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, - isolated_buffer=isolated_buffer, + color_mode=color_mode, size_space=size_space, **kwargs, ) n_datapoints = self.data.value.shape[0] - geo_kwargs = {"positions": self._data.buffer} + geo_kwargs = {"positions": self._data._fpl_buffer} aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") @@ -214,7 +215,7 @@ def __init__( self._markers = VertexMarkers(markers, n_datapoints) - geo_kwargs["markers"] = self._markers.buffer + geo_kwargs["markers"] = self._markers._fpl_buffer if edge_colors is None: # interpret as no edge color @@ -237,7 +238,7 @@ def __init__( edge_colors, n_datapoints, property_name="edge_colors" ) material_kwargs["edge_color_mode"] = pygfx.ColorMode.vertex - geo_kwargs["edge_colors"] = self._edge_colors.buffer + geo_kwargs["edge_colors"] = self._edge_colors._fpl_buffer self._edge_width = EdgeWidth(edge_width) material_kwargs["edge_width"] = self._edge_width.value @@ -274,12 +275,12 @@ def __init__( self._size_space = SizeSpace(size_space) - if uniform_color: + if isinstance(self._colors, UniformColor): material_kwargs["color_mode"] = pygfx.ColorMode.uniform material_kwargs["color"] = self.colors else: material_kwargs["color_mode"] = pygfx.ColorMode.vertex - geo_kwargs["colors"] = self.colors.buffer + geo_kwargs["colors"] = self.colors._fpl_buffer if uniform_size: material_kwargs["size_mode"] = pygfx.SizeMode.uniform @@ -288,14 +289,14 @@ def __init__( else: material_kwargs["size_mode"] = pygfx.SizeMode.vertex self._sizes = VertexPointSizes(sizes, n_datapoints=n_datapoints) - geo_kwargs["sizes"] = self.sizes.buffer + geo_kwargs["sizes"] = self.sizes._fpl_buffer match point_rotation_mode: case pygfx.enums.RotationMode.vertex: self._point_rotations = VertexRotations( point_rotations, n_datapoints=n_datapoints ) - geo_kwargs["rotations"] = self._point_rotations.buffer + geo_kwargs["rotations"] = self._point_rotations._fpl_buffer case pygfx.enums.RotationMode.uniform: self._point_rotations = UniformRotations(point_rotations) @@ -338,10 +339,8 @@ def markers(self, value: str | np.ndarray[str] | Sequence[str]): raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'markers' to set the markers" ) - if isinstance(self._markers, VertexMarkers): - self._markers[:] = value - elif isinstance(self._markers, UniformMarker): - self._markers.set_value(self, value) + + self._markers.set_value(self, value) @property def edge_colors(self) -> str | pygfx.Color | VertexColors | None: @@ -359,12 +358,7 @@ def edge_colors(self, value: str | np.ndarray | Sequence[str] | Sequence[float]) raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_colors" ) - - if isinstance(self._edge_colors, VertexColors): - self._edge_colors[:] = value - - elif isinstance(self._edge_colors, UniformEdgeColor): - self._edge_colors.set_value(self, value) + self._edge_colors.set_value(self, value) @property def edge_width(self) -> float | None: @@ -406,11 +400,7 @@ def point_rotations(self, value: float | np.ndarray[float]): f"it be 'uniform' or 'vertex' to set the `point_rotations`" ) - if isinstance(self._point_rotations, VertexRotations): - self._point_rotations[:] = value - - elif isinstance(self._point_rotations, UniformRotations): - self._point_rotations.set_value(self, value) + self._point_rotations.set_value(self, value) @property def image(self) -> TextureArray | None: @@ -437,8 +427,4 @@ def sizes(self) -> VertexPointSizes | float: @sizes.setter def sizes(self, value): - if isinstance(self._sizes, VertexPointSizes): - self._sizes[:] = value - - elif isinstance(self._sizes, UniformSize): - self._sizes.set_value(self, value) + self._sizes.set_value(self, value) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 3eb018f55..eda7b1492 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -33,8 +33,7 @@ def add_image( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -62,12 +61,6 @@ def add_image( cmap_interpolation: str, optional, default "linear" colormap interpolation method, one of "nearest" or "linear" - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. - kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -81,8 +74,7 @@ def add_image( cmap, interpolation, cmap_interpolation, - isolated_buffer, - **kwargs, + **kwargs ) def add_image_volume( @@ -100,8 +92,7 @@ def add_image_volume( substep_size: float = 0.1, emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ @@ -158,11 +149,6 @@ def add_image_volume( How shiny the specular highlight is; a higher value gives a sharper highlight. Used only if `mode` = "iso" - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then set the data, useful if the - data arrays are ready-only such as memmaps. If False, the input array is itself used as the - buffer - useful if the array is large. - kwargs additional keyword arguments passed to :class:`.Graphic` @@ -183,8 +169,7 @@ def add_image_volume( substep_size, emissive, shininess, - isolated_buffer, - **kwargs, + **kwargs ) def add_line_collection( @@ -192,16 +177,15 @@ def add_line_collection( data: Union[numpy.ndarray, List[numpy.ndarray]], thickness: Union[float, Sequence[float]] = 2.0, colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", - uniform_colors: bool = False, cmap: Union[Sequence[str], str] = None, cmap_transform: Union[numpy.ndarray, List] = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -235,6 +219,9 @@ def add_line_collection( cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + The color mode for each line in the collection. See `color_mode` in :class:`.LineGraphic` for details. + name: str, optional name of the line collection as a whole @@ -261,16 +248,15 @@ def add_line_collection( data, thickness, colors, - uniform_colors, cmap, cmap_transform, + color_mode, name, names, metadata, metadatas, - isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -278,12 +264,11 @@ def add_line( data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Sequence] = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: Union[numpy.ndarray, Sequence] = None, - isolated_buffer: bool = True, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -304,15 +289,19 @@ def add_line( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default ``False`` - if True, uses a uniform buffer for the line color, - basically saves GPU VRAM when the entire line has a single color - cmap: str, optional Apply a colormap to the line instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap @@ -329,12 +318,11 @@ def add_line( data, thickness, colors, - uniform_color, cmap, cmap_transform, - isolated_buffer, + color_mode, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -348,11 +336,10 @@ def add_line_stack( names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - isolated_buffer: bool = True, separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -425,11 +412,10 @@ def add_line_stack( names, metadata, metadatas, - isolated_buffer, separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_mesh( @@ -448,8 +434,7 @@ def add_mesh( | numpy.ndarray ) = None, clim: tuple[float, float] = None, - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> MeshGraphic: """ @@ -488,12 +473,6 @@ def add_mesh( Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. An image can also be used, this is basically a 2D colormap. - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. In almost all cases this should be ``True``. - **kwargs passed to :class:`.Graphic` @@ -509,8 +488,7 @@ def add_mesh( mapcoords, cmap, clim, - isolated_buffer, - **kwargs, + **kwargs ) def add_polygon( @@ -527,7 +505,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> PolygonGraphic: """ @@ -656,12 +634,12 @@ def add_scatter( self, data: Any, colors: Union[str, numpy.ndarray, Sequence[float], Sequence[str]] = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: numpy.ndarray = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: Union[str, numpy.ndarray, Sequence[str]] = "o", - uniform_marker: bool = False, + uniform_marker: bool = True, custom_sdf: str = None, edge_colors: Union[ str, pygfx.utils.color.Color, numpy.ndarray, Sequence[float] @@ -672,10 +650,9 @@ def add_scatter( point_rotations: float | numpy.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: Union[float, numpy.ndarray, Sequence[float]] = 5, - uniform_size: bool = False, + uniform_size: bool = True, size_space: str = "screen", - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -691,18 +668,23 @@ def add_scatter( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default False - if True, uses a uniform buffer for the scatter point colors. Useful if you need to - save GPU VRAM when all points have the same color. - cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this - overrides any argument passed to "colors". For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" The scatter points mode, cannot be changed after the graphic has been created. @@ -722,9 +704,10 @@ def add_scatter( * Emojis: "❤️♠️♣️♦️💎💍✳️📍". * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - uniform_marker: bool, default False - Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use - the same marker for all points and want to save GPU RAM. + uniform_marker: bool, default ``True`` + If ``True``, use the same marker for all points. Only valid when `mode` is "markers". + Useful if you need to use the same marker for all points and want to save GPU RAM. If ``False``, you can + set per-vertex markers. custom_sdf: str = None, The SDF code for the marker shape when the marker is set to custom. @@ -744,8 +727,9 @@ def add_scatter( edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" edge color of the markers, used when `mode` is "markers" - uniform_edge_color: bool, default True - Set the same edge color for all markers. Useful for saving GPU RAM. + uniform_edge_color: bool, default ``True`` + Set the same edge color for all markers. Useful for saving GPU RAM. Set to ``False`` for per-vertex edge + colors edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". @@ -766,17 +750,13 @@ def add_scatter( sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points - uniform_size: bool, default False - if True, uses a uniform buffer for the scatter point sizes. Useful if you need to - save GPU VRAM when all points have the same size. + uniform_size: bool, default ``False`` + if ``True``, uses a uniform buffer for the scatter point sizes. Useful if you need to + save GPU VRAM when all points have the same size. Set to ``False`` if you need per-vertex sizes. size_space: str, default "screen" coordinate space in which the size is expressed, one of ("screen", "world", "model") - isolated_buffer: bool, default True - whether the buffers should be isolated from the user input array. - Generally always ``True``, ``False`` is for rare advanced use if you have large arrays. - kwargs passed to :class:`.Graphic` @@ -786,9 +766,9 @@ def add_scatter( ScatterGraphic, data, colors, - uniform_color, cmap, cmap_transform, + color_mode, mode, markers, uniform_marker, @@ -802,8 +782,7 @@ def add_scatter( sizes, uniform_size, size_space, - isolated_buffer, - **kwargs, + **kwargs ) def add_surface( @@ -820,7 +799,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> SurfaceGraphic: """ @@ -874,7 +853,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -925,7 +904,7 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) def add_vectors( @@ -935,7 +914,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, + **kwargs ) -> VectorsGraphic: """ @@ -980,5 +959,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs, + **kwargs ) diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py index 7b1aef16a..f9d56189e 100644 --- a/tests/test_colors_buffer_manager.py +++ b/tests/test_colors_buffer_manager.py @@ -48,10 +48,10 @@ def test_int(test_graphic): data = generate_positions_spiral_data("xyz") if test_graphic == "line": - graphic = fig[0, 0].add_line(data=data) + graphic = fig[0, 0].add_line(data=data, color_mode="vertex") elif test_graphic == "scatter": - graphic = fig[0, 0].add_scatter(data=data) + graphic = fig[0, 0].add_scatter(data=data, color_mode="vertex") colors = graphic.colors global EVENT_RETURN_VALUE @@ -98,10 +98,10 @@ def test_tuple(test_graphic, slice_method): data = generate_positions_spiral_data("xyz") if test_graphic == "line": - graphic = fig[0, 0].add_line(data=data) + graphic = fig[0, 0].add_line(data=data, color_mode="vertex") elif test_graphic == "scatter": - graphic = fig[0, 0].add_scatter(data=data) + graphic = fig[0, 0].add_scatter(data=data, color_mode="vertex") colors = graphic.colors global EVENT_RETURN_VALUE @@ -190,10 +190,10 @@ def test_slice(color_input, slice_method: dict, test_graphic: bool): data = generate_positions_spiral_data("xyz") if test_graphic == "line": - graphic = fig[0, 0].add_line(data=data) + graphic = fig[0, 0].add_line(data=data, color_mode="vertex") elif test_graphic == "scatter": - graphic = fig[0, 0].add_scatter(data=data) + graphic = fig[0, 0].add_scatter(data=data, color_mode="vertex") colors = graphic.colors diff --git a/tests/test_markers_buffer_manager.py b/tests/test_markers_buffer_manager.py index 65ead392e..488bed194 100644 --- a/tests/test_markers_buffer_manager.py +++ b/tests/test_markers_buffer_manager.py @@ -46,10 +46,10 @@ def test_create_buffer(test_graphic): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, markers=MARKERS1) + scatter = fig[0, 0].add_scatter(data, markers=MARKERS1, uniform_marker=False) vertex_markers = scatter.markers assert isinstance(vertex_markers, VertexMarkers) - assert vertex_markers.buffer is scatter.world_object.geometry.markers + assert vertex_markers._fpl_buffer is scatter.world_object.geometry.markers else: vertex_markers = VertexMarkers(MARKERS1, len(data)) @@ -68,7 +68,7 @@ def test_int(test_graphic, index: int): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, markers=MARKERS1) + scatter = fig[0, 0].add_scatter(data, markers=MARKERS1, uniform_marker=False) scatter.add_event_handler(event_handler, "markers") vertex_markers = scatter.markers else: @@ -108,7 +108,7 @@ def test_slice(test_graphic, slice_method): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, markers=MARKERS1) + scatter = fig[0, 0].add_scatter(data, markers=MARKERS1, uniform_marker=False) scatter.add_event_handler(event_handler, "markers") vertex_markers = scatter.markers diff --git a/tests/test_point_rotations_buffer_manager.py b/tests/test_point_rotations_buffer_manager.py index ec5fdbe0f..50ee88984 100644 --- a/tests/test_point_rotations_buffer_manager.py +++ b/tests/test_point_rotations_buffer_manager.py @@ -35,7 +35,7 @@ def test_create_buffer(test_graphic): scatter = fig[0, 0].add_scatter(data, point_rotation_mode="vertex", point_rotations=ROTATIONS1) vertex_rotations = scatter.point_rotations assert isinstance(vertex_rotations, VertexRotations) - assert vertex_rotations.buffer is scatter.world_object.geometry.rotations + assert vertex_rotations._fpl_buffer is scatter.world_object.geometry.rotations else: vertex_rotations = VertexRotations(ROTATIONS1, len(data)) diff --git a/tests/test_positions_data_buffer_manager.py b/tests/test_positions_data_buffer_manager.py index e2582d4ba..cc550abf0 100644 --- a/tests/test_positions_data_buffer_manager.py +++ b/tests/test_positions_data_buffer_manager.py @@ -57,7 +57,7 @@ def test_int(test_graphic): graphic = fig[0, 0].add_scatter(data=data) points = graphic.data - assert graphic.data.buffer is graphic.world_object.geometry.positions + assert graphic.data._fpl_buffer is graphic.world_object.geometry.positions global EVENT_RETURN_VALUE graphic.add_event_handler(event_handler, "data") else: diff --git a/tests/test_positions_graphics.py b/tests/test_positions_graphics.py index 31c001888..4bc93b626 100644 --- a/tests/test_positions_graphics.py +++ b/tests/test_positions_graphics.py @@ -37,12 +37,12 @@ def test_sizes_slice(): @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) @pytest.mark.parametrize("colors", [None, *generate_color_inputs("b")]) -@pytest.mark.parametrize("uniform_color", [True, False]) -def test_uniform_color(graphic_type, colors, uniform_color): +@pytest.mark.parametrize("color_mode", ["uniform", "vertex"]) +def test_color_mode(graphic_type, colors, color_mode): fig = fpl.Figure() kwargs = dict() - for kwarg in ["colors", "uniform_color"]: + for kwarg in ["colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -54,7 +54,7 @@ def test_uniform_color(graphic_type, colors, uniform_color): elif graphic_type == "scatter": graphic = fig[0, 0].add_scatter(data=data, **kwargs) - if uniform_color: + if color_mode == "uniform": assert isinstance(graphic._colors, UniformColor) assert isinstance(graphic.colors, pygfx.Color) if colors is None: @@ -130,17 +130,17 @@ def test_positions_graphics_data( @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) @pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) -@pytest.mark.parametrize("uniform_color", [None, False]) +@pytest.mark.parametrize("color_mode", ["vertex"]) def test_positions_graphic_vertex_colors( graphic_type, colors, - uniform_color, + color_mode, ): # test different ways of passing vertex colors fig = fpl.Figure() kwargs = dict() - for kwarg in ["colors", "uniform_color"]: + for kwarg in ["colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -153,10 +153,9 @@ def test_positions_graphic_vertex_colors( graphic = fig[0, 0].add_scatter(data=data, **kwargs) # color per vertex - # uniform colors is default False, or set to False - assert isinstance(graphic._colors, VertexColors) - assert isinstance(graphic.colors, VertexColors) - assert len(graphic.colors) == len(graphic.data) + assert isinstance(graphic._colors, VertexColors) + assert isinstance(graphic.colors, VertexColors) + assert len(graphic.colors) == len(graphic.data) if colors is None: # default @@ -179,7 +178,7 @@ def test_positions_graphic_vertex_colors( @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) @pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) -@pytest.mark.parametrize("uniform_color", [None, False]) +@pytest.mark.parametrize("color_mode", ["auto", "vertex"]) @pytest.mark.parametrize("cmap", ["jet"]) @pytest.mark.parametrize( "cmap_transform", [None, [3, 5, 2, 1, 0, 6, 9, 7, 4, 8], np.arange(9, -1, -1)] @@ -187,7 +186,7 @@ def test_positions_graphic_vertex_colors( def test_cmap( graphic_type, colors, - uniform_color, + color_mode, cmap, cmap_transform, ): @@ -195,7 +194,7 @@ def test_cmap( fig = fpl.Figure() kwargs = dict() - for kwarg in ["cmap", "cmap_transform", "colors", "uniform_color"]: + for kwarg in ["cmap", "cmap_transform", "colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -220,7 +219,8 @@ def test_cmap( # make sure buffer is identical # cmap overrides colors argument - assert graphic.colors.buffer is graphic.cmap.buffer + # use __repr__.__self__ to get the real reference from the cmap feature instead of the weakref proxy + assert graphic.colors._fpl_buffer is graphic.cmap.buffer.__repr__.__self__ npt.assert_almost_equal(graphic.cmap.value, truth) npt.assert_almost_equal(graphic.colors.value, truth) @@ -261,14 +261,14 @@ def test_cmap( "colors", [None, *generate_color_inputs("multi")] ) # cmap arg overrides colors @pytest.mark.parametrize( - "uniform_color", [True] # none of these will work with a uniform buffer + "color_mode", ["uniform"] # none of these will work with a uniform buffer ) -def test_incompatible_cmap_color_args(graphic_type, cmap, colors, uniform_color): +def test_incompatible_cmap_color_args(graphic_type, cmap, colors, color_mode): # test incompatible cmap args fig = fpl.Figure() kwargs = dict() - for kwarg in ["cmap", "colors", "uniform_color"]: + for kwarg in ["cmap", "colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -276,24 +276,24 @@ def test_incompatible_cmap_color_args(graphic_type, cmap, colors, uniform_color) data = generate_positions_spiral_data("xy") if graphic_type == "line": - with pytest.raises(TypeError): + with pytest.raises(ValueError): graphic = fig[0, 0].add_line(data=data, **kwargs) elif graphic_type == "scatter": - with pytest.raises(TypeError): + with pytest.raises(ValueError): graphic = fig[0, 0].add_scatter(data=data, **kwargs) @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) @pytest.mark.parametrize("colors", [*generate_color_inputs("multi")]) @pytest.mark.parametrize( - "uniform_color", [True] # none of these will work with a uniform buffer + "color_mode", ["uniform"] # none of these will work with a uniform buffer ) -def test_incompatible_color_args(graphic_type, colors, uniform_color): +def test_incompatible_color_args(graphic_type, colors, color_mode): # test incompatible color args fig = fpl.Figure() kwargs = dict() - for kwarg in ["colors", "uniform_color"]: + for kwarg in ["colors", "color_mode"]: if locals()[kwarg] is not None: # add to dict of arguments that will be passed kwargs[kwarg] = locals()[kwarg] @@ -301,16 +301,15 @@ def test_incompatible_color_args(graphic_type, colors, uniform_color): data = generate_positions_spiral_data("xy") if graphic_type == "line": - with pytest.raises(TypeError): + with pytest.raises(ValueError): graphic = fig[0, 0].add_line(data=data, **kwargs) elif graphic_type == "scatter": - with pytest.raises(TypeError): + with pytest.raises(ValueError): graphic = fig[0, 0].add_scatter(data=data, **kwargs) @pytest.mark.parametrize("sizes", [None, 5.0, np.linspace(3, 8, 10, dtype=np.float32)]) -@pytest.mark.parametrize("uniform_size", [None, False]) -def test_sizes(sizes, uniform_size): +def test_sizes(sizes): # test scatter sizes fig = fpl.Figure() @@ -322,7 +321,7 @@ def test_sizes(sizes, uniform_size): data = generate_positions_spiral_data("xy") - graphic = fig[0, 0].add_scatter(data=data, **kwargs) + graphic = fig[0, 0].add_scatter(data=data, uniform_size=False, **kwargs) assert isinstance(graphic.sizes, VertexPointSizes) assert isinstance(graphic._sizes, VertexPointSizes) diff --git a/tests/test_replace_buffer.py b/tests/test_replace_buffer.py new file mode 100644 index 000000000..a9d0ffe41 --- /dev/null +++ b/tests/test_replace_buffer.py @@ -0,0 +1,155 @@ +import gc +import weakref + +import pytest +import numpy as np +from itertools import product + +import fastplotlib as fpl +from .utils_textures import MAX_TEXTURE_SIZE, check_texture_array, check_image_graphic + +# These are only de-referencing tests for positions graphics, and ImageGraphic +# they do not test that VRAM gets free, for now this can only be checked manually +# with the tests in examples/misc/buffer_replace_gc.py + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("new_buffer_size", [50, 150]) +def test_replace_positions_buffer(graphic_type, new_buffer_size): + fig = fpl.Figure() + + # create some data with an initial shape + orig_datapoints = 100 + + xs = np.linspace(0, 2 * np.pi, orig_datapoints) + ys = np.sin(xs) + zs = np.cos(xs) + + data = np.column_stack([xs, ys, zs]) + + # add add_line or add_scatter method + adder = getattr(fig[0, 0], f"add_{graphic_type}") + + if graphic_type == "scatter": + kwargs = { + "markers": np.random.choice(list("osD+x^v<>*"), size=orig_datapoints), + "uniform_marker": False, + "sizes": np.abs(ys), + "uniform_size": False, + # TODO: skipping edge_colors for now since that causes a WGPU bind group error that we will figure out later + # anyways I think changing buffer sizes in combination with per-vertex edge colors is a literal edge-case + "point_rotations": zs * 180, + "point_rotation_mode": "vertex", + } + else: + kwargs = dict() + + # add a line or scatter graphic + graphic = adder(data=data, colors=np.random.rand(orig_datapoints, 4), **kwargs) + + fig.show() + + # weakrefs to the original buffers + # these should raise a ReferenceError when the corresponding feature is replaced with data of a different shape + orig_data_buffer = weakref.proxy(graphic.data._fpl_buffer) + orig_colors_buffer = weakref.proxy(graphic.colors._fpl_buffer) + + buffers = [orig_data_buffer, orig_colors_buffer] + + # extra buffers for the scatters + if graphic_type == "scatter": + for attr in ["markers", "sizes", "point_rotations"]: + buffers.append(weakref.proxy(getattr(graphic, attr)._fpl_buffer)) + + # create some new data that requires a different buffer shape + xs = np.linspace(0, 15 * np.pi, new_buffer_size) + ys = np.sin(xs) + zs = np.cos(xs) + + new_data = np.column_stack([xs, ys, zs]) + + # set data that requires a larger buffer and check that old buffer is no longer referenced + graphic.data = new_data + graphic.colors = np.random.rand(new_buffer_size, 4) + + if graphic_type == "scatter": + # changes values so that new larger buffers must be allocated + graphic.markers = np.random.choice(list("osD+x^v<>*"), size=new_buffer_size) + graphic.sizes = np.abs(zs) + graphic.point_rotations = ys * 180 + + # make sure old original buffers are de-referenced + for i in range(len(buffers)): + with pytest.raises(ReferenceError) as fail: + buffers[i] + pytest.fail( + f"GC failed for buffer: {buffers[i]}, " + f"with referrers: {gc.get_referrers(buffers[i].__repr__.__self__)}" + ) + + +# test all combination of dims that require TextureArrays of shapes 1x1, 1x2, 1x3, 2x3, 3x3 etc. +@pytest.mark.parametrize( + "new_buffer_size", list(product(*[[(500, 1), (1200, 2), (2200, 3)]] * 2)) +) +def test_replace_image_buffer(new_buffer_size): + # make an image with some starting shape + orig_size = (1_500, 1_500) + + data = np.random.rand(*orig_size) + + fig = fpl.Figure() + image = fig[0, 0].add_image(data) + + # the original Texture buffers that represent the individual image tiles + orig_buffers = [ + weakref.proxy(image.data.buffer.ravel()[i]) + for i in range(image.data.buffer.size) + ] + orig_shape = image.data.buffer.shape + + fig.show() + + # dimensions for a new image + new_dims = [v[0] for v in new_buffer_size] + + # the number of tiles required in each dim/shape of the TextureArray + new_shape = tuple(v[1] for v in new_buffer_size) + + # make the new data and set the image + new_data = np.random.rand(*new_dims) + image.data = new_data + + # test that old Texture buffers are de-referenced + for i in range(len(orig_buffers)): + with pytest.raises(ReferenceError) as fail: + orig_buffers[i] + pytest.fail( + f"GC failed for buffer: {orig_buffers[i]}, of shape: {orig_shape}" + f"with referrers: {gc.get_referrers(orig_buffers[i].__repr__.__self__)}" + ) + + # check new texture array + check_texture_array( + data=new_data, + ta=image.data, + buffer_size=np.prod(new_shape), + buffer_shape=new_shape, + row_indices_size=new_shape[0], + col_indices_size=new_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (new_data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (new_data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), + ) + + # check that new image tiles are arranged correctly + check_image_graphic(image.data, image) diff --git a/tests/test_scatter_graphic.py b/tests/test_scatter_graphic.py index a61681f24..930d8c495 100644 --- a/tests/test_scatter_graphic.py +++ b/tests/test_scatter_graphic.py @@ -133,7 +133,7 @@ def test_edge_colors(edge_colors): npt.assert_almost_equal(scatter.edge_colors.value, MULTI_COLORS_TRUTH) assert ( - scatter.edge_colors.buffer is scatter.world_object.geometry.edge_colors + scatter.edge_colors._fpl_buffer is scatter.world_object.geometry.edge_colors ) # test changes, don't need to test extensively here since it's tested in the main VertexColors test diff --git a/tests/test_texture_array.py b/tests/test_texture_array.py index 6220f2fe5..01abb9a97 100644 --- a/tests/test_texture_array.py +++ b/tests/test_texture_array.py @@ -2,14 +2,9 @@ from numpy import testing as npt import pytest -import pygfx - import fastplotlib as fpl from fastplotlib.graphics.features import TextureArray -from fastplotlib.graphics.image import _ImageTile - - -MAX_TEXTURE_SIZE = 1024 +from .utils_textures import MAX_TEXTURE_SIZE, check_texture_array, check_image_graphic def make_data(n_rows: int, n_cols: int) -> np.ndarray: @@ -25,50 +20,6 @@ def make_data(n_rows: int, n_cols: int) -> np.ndarray: return np.vstack([sine * i for i in range(n_rows)]).astype(np.float32) -def check_texture_array( - data: np.ndarray, - ta: TextureArray, - buffer_size: int, - buffer_shape: tuple[int, int], - row_indices_size: int, - col_indices_size: int, - row_indices_values: np.ndarray, - col_indices_values: np.ndarray, -): - - npt.assert_almost_equal(ta.value, data) - - assert ta.buffer.size == buffer_size - assert ta.buffer.shape == buffer_shape - - assert all([isinstance(texture, pygfx.Texture) for texture in ta.buffer.ravel()]) - - assert ta.row_indices.size == row_indices_size - assert ta.col_indices.size == col_indices_size - npt.assert_array_equal(ta.row_indices, row_indices_values) - npt.assert_array_equal(ta.col_indices, col_indices_values) - - # make sure chunking is correct - for texture, chunk_index, data_slice in ta: - assert ta.buffer[chunk_index] is texture - chunk_row, chunk_col = chunk_index - - data_row_start_index = chunk_row * MAX_TEXTURE_SIZE - data_col_start_index = chunk_col * MAX_TEXTURE_SIZE - - data_row_stop_index = min( - data.shape[0], data_row_start_index + MAX_TEXTURE_SIZE - ) - data_col_stop_index = min( - data.shape[1], data_col_start_index + MAX_TEXTURE_SIZE - ) - - row_slice = slice(data_row_start_index, data_row_stop_index) - col_slice = slice(data_col_start_index, data_col_stop_index) - - assert data_slice == (row_slice, col_slice) - - def check_set_slice(data, ta, row_slice, col_slice): ta[row_slice, col_slice] = 1 npt.assert_almost_equal(ta[row_slice, col_slice], 1) @@ -85,17 +36,6 @@ def make_image_graphic(data) -> fpl.ImageGraphic: return fig[0, 0].add_image(data) -def check_image_graphic(texture_array, graphic): - # make sure each ImageTile has the right texture - for (texture, chunk_index, data_slice), img in zip( - texture_array, graphic.world_object.children - ): - assert isinstance(img, _ImageTile) - assert img.geometry.grid is texture - assert img.world.x == data_slice[1].start - assert img.world.y == data_slice[0].start - - @pytest.mark.parametrize("test_graphic", [False, True]) def test_small_texture(test_graphic): # tests TextureArray with dims that requires only 1 texture @@ -162,15 +102,27 @@ def test_wide(test_graphic): else: ta = TextureArray(data) + ta_shape = (2, 3) + check_texture_array( data, ta=ta, - buffer_size=6, - buffer_shape=(2, 3), - row_indices_size=2, - col_indices_size=3, - row_indices_values=np.array([0, MAX_TEXTURE_SIZE]), - col_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), + buffer_size=np.prod(ta_shape), + buffer_shape=ta_shape, + row_indices_size=ta_shape[0], + col_indices_size=ta_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), ) if test_graphic: @@ -189,15 +141,27 @@ def test_tall(test_graphic): else: ta = TextureArray(data) + ta_shape = (3, 2) + check_texture_array( data, ta=ta, - buffer_size=6, - buffer_shape=(3, 2), - row_indices_size=3, - col_indices_size=2, - row_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), - col_indices_values=np.array([0, MAX_TEXTURE_SIZE]), + buffer_size=np.prod(ta_shape), + buffer_shape=ta_shape, + row_indices_size=ta_shape[0], + col_indices_size=ta_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), ) if test_graphic: @@ -216,15 +180,27 @@ def test_square(test_graphic): else: ta = TextureArray(data) + ta_shape = (3, 3) + check_texture_array( data, ta=ta, - buffer_size=9, - buffer_shape=(3, 3), - row_indices_size=3, - col_indices_size=3, - row_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), - col_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), + buffer_size=np.prod(ta_shape), + buffer_shape=ta_shape, + row_indices_size=ta_shape[0], + col_indices_size=ta_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), ) if test_graphic: diff --git a/tests/utils_textures.py b/tests/utils_textures.py new file mode 100644 index 000000000..f40a7371c --- /dev/null +++ b/tests/utils_textures.py @@ -0,0 +1,64 @@ +import numpy as np +import pygfx +from numpy import testing as npt + +from fastplotlib.graphics.features import TextureArray +from fastplotlib.graphics.image import _ImageTile + + +MAX_TEXTURE_SIZE = 1024 + + +def check_texture_array( + data: np.ndarray, + ta: TextureArray, + buffer_size: int, + buffer_shape: tuple[int, int], + row_indices_size: int, + col_indices_size: int, + row_indices_values: np.ndarray, + col_indices_values: np.ndarray, +): + + npt.assert_almost_equal(ta.value, data) + + assert ta.buffer.size == buffer_size + assert ta.buffer.shape == buffer_shape + + assert all([isinstance(texture, pygfx.Texture) for texture in ta.buffer.ravel()]) + + assert ta.row_indices.size == row_indices_size + assert ta.col_indices.size == col_indices_size + npt.assert_array_equal(ta.row_indices, row_indices_values) + npt.assert_array_equal(ta.col_indices, col_indices_values) + + # make sure chunking is correct + for texture, chunk_index, data_slice in ta: + assert ta.buffer[chunk_index] is texture + chunk_row, chunk_col = chunk_index + + data_row_start_index = chunk_row * MAX_TEXTURE_SIZE + data_col_start_index = chunk_col * MAX_TEXTURE_SIZE + + data_row_stop_index = min( + data.shape[0], data_row_start_index + MAX_TEXTURE_SIZE + ) + data_col_stop_index = min( + data.shape[1], data_col_start_index + MAX_TEXTURE_SIZE + ) + + row_slice = slice(data_row_start_index, data_row_stop_index) + col_slice = slice(data_col_start_index, data_col_stop_index) + + assert data_slice == (row_slice, col_slice) + + +def check_image_graphic(texture_array, graphic): + # make sure each ImageTile has the right texture + for (texture, chunk_index, data_slice), img in zip( + texture_array, graphic.world_object.children + ): + assert isinstance(img, _ImageTile) + assert img.geometry.grid is texture + assert img.world.x == data_slice[1].start + assert img.world.y == data_slice[0].start From aefe418192709223a6850ec37932f176664e24a8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 18:44:42 -0500 Subject: [PATCH 019/163] some basic OOC working --- .../widgets/nd_widget/_nd_positions.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 1871e027e..65d1f59c5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -9,6 +9,7 @@ from ...utils import subsample_array, ArrayProtocol from ...graphics import ( + Graphic, ImageGraphic, LineGraphic, LineStack, @@ -264,13 +265,14 @@ def get(self, indices: tuple[Any, ...]): ).squeeze() # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary + # we need to slice upto dw since we add the `datapoints_window_size` above graphic_data[..., : dw, dims] = wf( windows, axis=-1 ).reshape(graphic_data.shape[0], dw, len(dims)) - return graphic_data[..., : dw, :] + return graphic_data[..., : dw : max(1, dw // self.p_max), :] - return graphic_data + return graphic_data[..., : graphic_data.shape[-2] : max(1, graphic_data.shape[-2] // self.p_max), :] class NDPositions: @@ -303,6 +305,8 @@ def __init__( index_mappings=index_mappings, ) + self._processor.p_max = 1_000 + self._indices = tuple([0] * self._processor.n_slider_dims) self._create_graphic(graphic) @@ -348,15 +352,21 @@ def indices(self, indices): self.graphic.data[:, : data_slice.shape[-1]] = data_slice elif isinstance(self.graphic, (LineCollection, ScatterCollection)): - for i in range(len(self.graphic)): - # data_slice shape is [n_lines, n_datapoints, 2 | 3] - self.graphic[i].data[:, : data_slice.shape[-1]] = data_slice[i] + for g, new_data in zip(self.graphic.graphics, data_slice): + if g.data.value.shape[0] != new_data.shape[0]: + # will replace buffer internally + g.data = new_data + else: + # if data are only xy, set only xy + g.data[:, :new_data.shape[1]] = new_data elif isinstance(self.graphic, ImageGraphic): image_data, x0, x_scale = self._create_heatmap_data(data_slice) self.graphic.data = image_data self.graphic.offset = (x0, *self.graphic.offset[1:]) + self._indices = indices + def _create_graphic( self, graphic_cls: Type[ @@ -368,6 +378,9 @@ def _create_graphic( | ImageGraphic ], ): + if not issubclass(graphic_cls, Graphic): + raise TypeError + data_slice = self.processor.get(self.indices) if issubclass(graphic_cls, ImageGraphic): @@ -412,3 +425,13 @@ def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: x0 = data_slice[0, 0, 0] return y_interp, x0, x_scale + + @property + def display_window(self) -> int | float | None: + """display window in the reference units for the n_datapoints dim""" + return self.processor.display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + self.processor.display_window = dw + self.indices = self.indices From b6d6e62d0f2a55ff4152c29db23acd6e578d391f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 6 Feb 2026 12:27:51 -0500 Subject: [PATCH 020/163] max num of dipslay datapoints --- .../widgets/nd_widget/_nd_positions.py | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 65d1f59c5..6cc29d92a 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -29,12 +29,14 @@ def __init__( data: ArrayProtocol, multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points display_window: int | float | None = 100, # window for n_datapoints dim only + max_display_datapoints: int = 1_000, datapoints_window_func: Callable | None = None, datapoints_window_size: int | None = None, **kwargs, ): self._display_window = display_window + self._max_display_datapoints = max_display_datapoints # TOOD: this does data validation twice and is a bit messy, cleanup self._data = self._validate_data(data) @@ -64,6 +66,19 @@ def display_window(self, dw: int | float | None): self._display_window = dw + @property + def max_display_datapoints(self) -> int: + return self._max_display_datapoints + + @max_display_datapoints.setter + def max_display_datapoints(self, n: int): + if not isinstance(n, (int, np.integer)): + raise TypeError + if n < 2: + raise ValueError + + self._max_display_datapoints = n + @property def multi(self) -> bool: return self._multi @@ -231,12 +246,15 @@ def get(self, indices: tuple[Any, ...]): # data that will be used for the graphical representation # a copy is made, if there were no window functions then this is a view of the original data - graphic_data = window_output[tuple(slices)].copy() + graphic_data = window_output[tuple(slices)] # apply window function on the `p` n_datapoints dim if ( self.datapoints_window_func is not None and self.datapoints_window_size is not None + # if there are too many points to efficiently compute the window func + # applying a window func also requires making a copy so that's a further performance hit + and (dw < self.max_display_datapoints * 2) ): # get windows @@ -264,18 +282,30 @@ def get(self, indices: tuple[Any, ...]): graphic_data[..., dims], ws, axis=-2 ).squeeze() + # make a copy because we need to modify it + graphic_data = graphic_data.copy() + # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary # we need to slice upto dw since we add the `datapoints_window_size` above - graphic_data[..., : dw, dims] = wf( - windows, axis=-1 - ).reshape(graphic_data.shape[0], dw, len(dims)) + graphic_data[..., :dw, dims] = wf(windows, axis=-1).reshape( + graphic_data.shape[0], dw, len(dims) + ) - return graphic_data[..., : dw : max(1, dw // self.p_max), :] + return graphic_data[ + ..., : dw : max(1, dw // self.max_display_datapoints), : + ] - return graphic_data[..., : graphic_data.shape[-2] : max(1, graphic_data.shape[-2] // self.p_max), :] + return graphic_data[ + ..., + : graphic_data.shape[-2] : max( + 1, graphic_data.shape[-2] // self.max_display_datapoints + ), + :, + ] class NDPositions: + def __init__( self, data, @@ -292,6 +322,8 @@ def __init__( window_funcs: tuple[WindowFuncCallable | None] | None = None, window_sizes: tuple[int | None] | None = None, index_mappings: tuple[Callable[[Any], int] | None] | None = None, + max_display_datapoints: int = 1_000, + graphic_kwargs: dict = None, ): if issubclass(graphic, LineCollection): multi = True @@ -300,6 +332,7 @@ def __init__( data, multi=multi, display_window=display_window, + max_display_datapoints=max_display_datapoints, window_funcs=window_funcs, window_sizes=window_sizes, index_mappings=index_mappings, @@ -358,7 +391,7 @@ def indices(self, indices): g.data = new_data else: # if data are only xy, set only xy - g.data[:, :new_data.shape[1]] = new_data + g.data[:, : new_data.shape[1]] = new_data elif isinstance(self.graphic, ImageGraphic): image_data, x0, x_scale = self._create_heatmap_data(data_slice) @@ -396,7 +429,11 @@ def _create_graphic( ) else: - self._graphic = graphic_cls(data_slice) + if issubclass(graphic_cls, LineStack): + kwargs = {"separation": 0.0} + else: + kwargs = dict() + self._graphic = graphic_cls(data_slice, **kwargs) def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: """return [n_rows, n_cols] shape data""" From 976459b662d6a2d133009721e1c8c3bf1457fef5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 11 Feb 2026 03:40:12 -0500 Subject: [PATCH 021/163] scatter stack, not tested --- fastplotlib/graphics/scatter_collection.py | 123 +++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py index b1569cacc..ac8cc307e 100644 --- a/fastplotlib/graphics/scatter_collection.py +++ b/fastplotlib/graphics/scatter_collection.py @@ -515,3 +515,126 @@ def _get_linear_selector_init_args(self, axis, padding): center = bbox[:, 0].mean() return bounds, limits, size, center + + +axes = {"x": 0, "y": 1, "z": 2} + + +class ScatterStack(ScatterCollection): + def __init__( + self, + data: List[np.ndarray], + thickness: float | Iterable[float] = 2.0, + colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", + cmap: Iterable[str] | str = None, + cmap_transform: np.ndarray | List = None, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Sequence[Any] | np.ndarray = None, + isolated_buffer: bool = True, + separation: float = 0.0, + separation_axis: str = "y", + kwargs_lines: list[dict] = None, + **kwargs, + ): + """ + Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + thickness: float or Iterable of float, default 2.0 + | if ``float``, single thickness will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + metadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + separation: float, default 0.0 + space in between each line graphic in the stack + + separation_axis: str, default "y" + axis in which the line graphics in the stack should be separated + + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + """ + super().__init__( + data=data, + thickness=thickness, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + name=name, + names=names, + metadata=metadata, + metadatas=metadatas, + isolated_buffer=isolated_buffer, + kwargs_lines=kwargs_lines, + **kwargs, + ) + + self._sepration_axis = separation_axis + self._separation = separation + + self.separation = separation + + @property + def separation(self) -> float: + """distance between each line in the stack, in world space""" + return self._separation + + @separation.setter + def separation(self, value: float): + separation = float(value) + + axis_zero = 0 + for i, line in enumerate(self.graphics): + if self._sepration_axis == "x": + line.offset = (axis_zero, *line.offset[1:]) + + elif self._sepration_axis == "y": + line.offset = (line.offset[0], axis_zero, line.offset[2]) + + axis_zero = ( + axis_zero + line.data.value[:, axes[self._sepration_axis]].max() + separation + ) + + self._separation = value From a9bfa4480bc385a32223644d29c3564ee5aef6ac Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 11 Feb 2026 16:47:02 -0500 Subject: [PATCH 022/163] progress --- .../widgets/nd_widget/_nd_positions.py | 131 +++++++++++++----- 1 file changed, 98 insertions(+), 33 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 6cc29d92a..8d30fe37a 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -26,7 +26,7 @@ class NDPositionsProcessor(NDProcessor): def __init__( self, - data: ArrayProtocol, + data: Any, multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points display_window: int | float | None = 100, # window for n_datapoints dim only max_display_datapoints: int = 1_000, @@ -34,7 +34,6 @@ def __init__( datapoints_window_size: int | None = None, **kwargs, ): - self._display_window = display_window self._max_display_datapoints = max_display_datapoints @@ -193,6 +192,66 @@ def _apply_window_functions(self, indices: tuple[int, ...]): return data_sliced + def _get_dw_slices(self, indices) -> tuple[slice] | tuple[slice, slice]: + # given indices, return slice using display window + + # display window is interpreted using the index mapping for the `p` dim + dw = self.display_window + + if dw is None: + # just map p dimension at this index and return + index_p = self.index_mappings[-1](indices[-1]) + return (slice(index_p, index_p + 1),) + + # display window is in reference units, apply display window and then map to array indices + # clamp w.r.t. 0 and processor shape `p` dim + hw = dw / 2 + index_p_start = max(self.index_mappings[-1](indices[-1] - hw), 0) + index_p_stop = min(self.index_mappings[-1](indices[-1] + hw), self.shape[-2]) + if index_p_start >= index_p_stop: + index_p_stop = index_p_start + 1 + + slices = [slice(index_p_start, index_p_stop)] + + if self.multi: + slices.insert(0, slice(None)) + + return tuple(slices) + + # + # # clamp w.r.t. processor shape + # + # dw = self.index_mappings[-1](self.display_window) + # + # if dw == 1: + # slices = [slice(index_p, index_p + 1)] + # + # else: + # # half window size + # hw = dw // 2 + # + # # for now assume just a single index provided that indicates x axis value + # start = max(index_p - hw, 0) + # stop = start + dw + # # also add window size of `p` dim so window_func output has the same number of datapoints + # if ( + # self.datapoints_window_func is not None + # and self.datapoints_window_size is not None + # ): + # stop += self.datapoints_window_size - 1 + # # TODO: pad with constant if we're using a window func and the index is near the end + # + # # TODO: uncomment this once we have resizeable buffers!! + # # stop = min(index_p + hw, self.shape[-2]) + # + # slices = [slice(start, stop)] + # + # if self.multi: + # # n - 2 dim is n_lines or n_scatters + # slices.insert(0, slice(None)) + # + # return tuple(slices) + def get(self, indices: tuple[Any, ...]): """ slices through all slider dims and outputs an array that can be used to set graphic data @@ -214,40 +273,45 @@ def get(self, indices: tuple[Any, ...]): # TODO: window function on the `p` n_datapoints dimension if self.display_window is not None: - # display window is interpreted using the index mapping for the `p` dim - dw = self.index_mappings[-1](self.display_window) - - if dw == 1: - slices = [slice(indices[-1], indices[-1] + 1)] - - else: - # half window size - hw = dw // 2 - - # for now assume just a single index provided that indicates x axis value - start = max(indices[-1] - hw, 0) - stop = start + dw - # also add window size of `p` dim so window_func output has the same number of datapoints - if ( - self.datapoints_window_func is not None - and self.datapoints_window_size is not None - ): - stop += self.datapoints_window_size - 1 - # TODO: pad with constant if we're using a window func and the index is near the end - - # TODO: uncomment this once we have resizeable buffers!! - # stop = min(indices[-1] + hw, self.shape[-2]) + slices = self._get_dw_slices(indices) - slices = [slice(start, stop)] - - if self.multi: - # n - 2 dim is n_lines or n_scatters - slices.insert(0, slice(None)) + # if self.display_window is not None: + # # display window is interpreted using the index mapping for the `p` dim + # dw = self.index_mappings[-1](self.display_window) + # + # if dw == 1: + # slices = [slice(indices[-1], indices[-1] + 1)] + # + # else: + # # half window size + # hw = dw // 2 + # + # # for now assume just a single index provided that indicates x axis value + # start = max(indices[-1] - hw, 0) + # stop = start + dw + # # also add window size of `p` dim so window_func output has the same number of datapoints + # if ( + # self.datapoints_window_func is not None + # and self.datapoints_window_size is not None + # ): + # stop += self.datapoints_window_size - 1 + # # TODO: pad with constant if we're using a window func and the index is near the end + # + # # TODO: uncomment this once we have resizeable buffers!! + # # stop = min(indices[-1] + hw, self.shape[-2]) + # + # slices = [slice(start, stop)] + # + # if self.multi: + # # n - 2 dim is n_lines or n_scatters + # slices.insert(0, slice(None)) # data that will be used for the graphical representation # a copy is made, if there were no window functions then this is a view of the original data graphic_data = window_output[tuple(slices)] + dw = self.index_mappings[-1](self.display_window) + # apply window function on the `p` n_datapoints dim if ( self.datapoints_window_func is not None @@ -308,7 +372,7 @@ class NDPositions: def __init__( self, - data, + data: Any, graphic: Type[ LineGraphic | LineCollection @@ -317,6 +381,7 @@ def __init__( | ScatterCollection | ImageGraphic ], + processor: type[NDPositionsProcessor] = NDPositionsProcessor, multi: bool = False, display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, @@ -328,7 +393,7 @@ def __init__( if issubclass(graphic, LineCollection): multi = True - self._processor = NDPositionsProcessor( + self._processor = processor( data, multi=multi, display_window=display_window, @@ -420,7 +485,7 @@ def _create_graphic( if not self.processor.multi: raise ValueError - if self.processor.data.shape[-1] != 2: + if self.processor.shape[-1] != 2: raise ValueError image_data, x0, x_scale = self._create_heatmap_data(data_slice) From 596b8e76227eb7cb80cdb8364b0041f9f6b7a83c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 13 Feb 2026 23:15:33 -0500 Subject: [PATCH 023/163] scatter collection updates --- fastplotlib/graphics/scatter_collection.py | 4 -- fastplotlib/layouts/_graphic_methods_mixin.py | 46 +++++++++---------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py index ac8cc307e..b8e7556ad 100644 --- a/fastplotlib/graphics/scatter_collection.py +++ b/fastplotlib/graphics/scatter_collection.py @@ -114,7 +114,6 @@ def __init__( self, data: np.ndarray | List[np.ndarray], colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", - uniform_colors: bool = False, cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, sizes: float | Sequence[float] = 5.0, @@ -122,7 +121,6 @@ def __init__( names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, **kwargs, ): @@ -291,12 +289,10 @@ def __init__( lg = ScatterGraphic( data=d, colors=_c, - uniform_color=uniform_colors, sizes=sizes, cmap=_cmap, name=_name, metadata=_m, - isolated_buffer=isolated_buffer, **kwargs_lines, ) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index eda7b1492..bd01855bd 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -33,7 +33,7 @@ def add_image( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - **kwargs + **kwargs, ) -> ImageGraphic: """ @@ -74,7 +74,7 @@ def add_image( cmap, interpolation, cmap_interpolation, - **kwargs + **kwargs, ) def add_image_volume( @@ -92,7 +92,7 @@ def add_image_volume( substep_size: float = 0.1, emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, - **kwargs + **kwargs, ) -> ImageVolumeGraphic: """ @@ -169,7 +169,7 @@ def add_image_volume( substep_size, emissive, shininess, - **kwargs + **kwargs, ) def add_line_collection( @@ -185,7 +185,7 @@ def add_line_collection( metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineCollection: """ @@ -256,7 +256,7 @@ def add_line_collection( metadata, metadatas, kwargs_lines, - **kwargs + **kwargs, ) def add_line( @@ -268,7 +268,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", - **kwargs + **kwargs, ) -> LineGraphic: """ @@ -322,7 +322,7 @@ def add_line( cmap_transform, color_mode, size_space, - **kwargs + **kwargs, ) def add_line_stack( @@ -339,7 +339,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineStack: """ @@ -415,7 +415,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs + **kwargs, ) def add_mesh( @@ -434,7 +434,7 @@ def add_mesh( | numpy.ndarray ) = None, clim: tuple[float, float] = None, - **kwargs + **kwargs, ) -> MeshGraphic: """ @@ -488,7 +488,7 @@ def add_mesh( mapcoords, cmap, clim, - **kwargs + **kwargs, ) def add_polygon( @@ -505,7 +505,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> PolygonGraphic: """ @@ -552,15 +552,13 @@ def add_scatter_collection( self, data: Union[numpy.ndarray, List[numpy.ndarray]], colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", - uniform_colors: bool = False, cmap: Union[Sequence[str], str] = None, cmap_transform: Union[numpy.ndarray, List] = None, - sizes: Union[float, Sequence[float]] = 2.0, + sizes: Union[float, Sequence[float]] = 5.0, name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, **kwargs, ) -> ScatterCollection: @@ -617,7 +615,6 @@ def add_scatter_collection( ScatterCollection, data, colors, - uniform_colors, cmap, cmap_transform, sizes, @@ -625,7 +622,6 @@ def add_scatter_collection( names, metadata, metadatas, - isolated_buffer, kwargs_lines, **kwargs, ) @@ -652,7 +648,7 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Sequence[float]] = 5, uniform_size: bool = True, size_space: str = "screen", - **kwargs + **kwargs, ) -> ScatterGraphic: """ @@ -782,7 +778,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs + **kwargs, ) def add_surface( @@ -799,7 +795,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> SurfaceGraphic: """ @@ -853,7 +849,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs + **kwargs, ) -> TextGraphic: """ @@ -904,7 +900,7 @@ def add_text( screen_space, offset, anchor, - **kwargs + **kwargs, ) def add_vectors( @@ -914,7 +910,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs + **kwargs, ) -> VectorsGraphic: """ @@ -959,5 +955,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs + **kwargs, ) From db2431f47b5a2cdb21b917c36967aa0e9b7bacbe Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 13 Feb 2026 23:16:03 -0500 Subject: [PATCH 024/163] tootip handlers for ndpositions --- fastplotlib/widgets/nd_widget/_nd_positions.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions.py index 8d30fe37a..9a2d25048 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions.py @@ -1,3 +1,4 @@ +from functools import partial import inspect from typing import Literal, Callable, Any, Type from warnings import warn @@ -465,6 +466,13 @@ def indices(self, indices): self._indices = indices + def _tooltip_handler(self, graphic, pick_info): + if isinstance(self.graphic, (LineCollection, ScatterCollection)): + # get graphic within the collection + n_index = np.argwhere(self.graphic.graphics == graphic).item() + p_index = pick_info["vertex_index"] + return self.processor.format_tooltip(n_index, p_index) + def _create_graphic( self, graphic_cls: Type[ @@ -500,6 +508,11 @@ def _create_graphic( kwargs = dict() self._graphic = graphic_cls(data_slice, **kwargs) + if hasattr(self.processor, "format_tooltip"): + if isinstance(self._graphic, (LineCollection, ScatterCollection)): + for g in self._graphic.graphics: + g.tooltip_format = partial(self._tooltip_handler, g) + def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: """return [n_rows, n_cols] shape data""" # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense From 57d9a6ba1e3cfacdac5b2055e12b05b05ccac957 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 14 Feb 2026 03:34:03 -0500 Subject: [PATCH 025/163] refactoring, general NDPP_Pandas processor for any dataframe data --- fastplotlib/widgets/nd_widget/__init__.py | 2 + .../nd_widget/_nd_positions/__init__.py | 23 +++++ .../nd_widget/_nd_positions/_pandas.py | 94 +++++++++++++++++++ .../widgets/nd_widget/_nd_positions/_zarr.py | 4 + .../core.py} | 54 +++-------- .../nd_widget/{_nd_image.py => nd_image.py} | 2 +- .../{_processor_base.py => processor_base.py} | 13 +++ 7 files changed, 149 insertions(+), 43 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/__init__.py create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py rename fastplotlib/widgets/nd_widget/{_nd_positions.py => _nd_positions/core.py} (92%) rename fastplotlib/widgets/nd_widget/{_nd_image.py => nd_image.py} (87%) rename fastplotlib/widgets/nd_widget/{_processor_base.py => processor_base.py} (96%) diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index e69de29bb..70c2e7621 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -0,0 +1,2 @@ +from .processor_base import NDProcessor +from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py new file mode 100644 index 000000000..03bb0e8f7 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py @@ -0,0 +1,23 @@ +import importlib + +from .core import NDPositions, NDPositionsProcessor + +class Extras: + pass + +ndp_extras = Extras() + + +for optional in ["pandas", "zarr"]: + try: + importlib.import_module(optional) + except ImportError: + pass + else: + module = importlib.import_module(f"._{optional}", "fastplotlib.widgets.nd_widget._nd_positions") + cls = getattr(module, f"NDPP_{optional.capitalize()}") + setattr( + ndp_extras, + f"NDPP_{optional.capitalize()}", + cls + ) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py new file mode 100644 index 000000000..de26c8a9d --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -0,0 +1,94 @@ +import numpy as np +import pandas as pd + +from .core import NDPositionsProcessor + + +class NDPP_Pandas(NDPositionsProcessor): + def __init__( + self, + data: pd.DataFrame, + columns: list[tuple[str, str] | tuple[str, str, str]], + tooltip_columns: list[str] = None, + max_display_datapoints: int = 1_000, + **kwargs, + ): + data = data + + self._columns = columns + + if tooltip_columns is not None: + if len(tooltip_columns) != len(self.columns): + raise ValueError + self._tooltip_columns = tooltip_columns + self._tooltip = True + else: + self._tooltip_columns = None + self._tooltip = False + + super().__init__( + data=data, + max_display_datapoints=max_display_datapoints, + **kwargs, + ) + + @property + def data(self) -> pd.DataFrame: + return self._data + + def _validate_data(self, data: pd.DataFrame): + if not isinstance(data, pd.DataFrame): + raise TypeError + + return data + + @property + def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: + return self._columns + + @property + def multi(self) -> bool: + return True + + @multi.setter + def multi(self, v): + pass + + @property + def shape(self) -> tuple[int, ...]: + # n_graphical_elements, n_timepoints, 2 + return len(self.columns), self.data.index.size, 2 + + @property + def ndim(self) -> int: + return len(self.shape) + + @property + def n_slider_dims(self) -> int: + return 1 + + @property + def tooltip(self) -> bool: + return self._tooltip + + def tooltip_format(self, n: int, p: int): + # datapoint index w.r.t. full data + p += self._slices[-1].start + return str(self.data[self._tooltip_columns[n]][p]) + + def get(self, indices: tuple[float | int, ...]) -> np.ndarray: + if not isinstance(indices, tuple): + raise TypeError(".get() must receive a tuple of float | int indices") + # assume no additional slider dims, only time slider dim + self._slices = self._get_dw_slices(indices) + + + gdata_shape = len(self.columns), self._slices[-1].stop - self._slices[-1].start, 3 + gdata = np.zeros(shape=gdata_shape, dtype=np.float32) + + for i, col in enumerate(self.columns): + gdata[i, :, :len(col)] = np.column_stack( + [self.data[c][self._slices[-1]] for c in col] + ) + + return gdata diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py b/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py new file mode 100644 index 000000000..fb3bb7015 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py @@ -0,0 +1,4 @@ +# placeholder + +class NDPP_Zarr: + pass diff --git a/fastplotlib/widgets/nd_widget/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py similarity index 92% rename from fastplotlib/widgets/nd_widget/_nd_positions.py rename to fastplotlib/widgets/nd_widget/_nd_positions/core.py index 9a2d25048..b95916ce8 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -1,15 +1,13 @@ from functools import partial -import inspect from typing import Literal, Callable, Any, Type from warnings import warn import numpy as np -from numpy.typing import ArrayLike from numpy.lib.stride_tricks import sliding_window_view -from ...utils import subsample_array, ArrayProtocol +from ....utils import subsample_array, ArrayProtocol -from ...graphics import ( +from ....graphics import ( Graphic, ImageGraphic, LineGraphic, @@ -18,7 +16,7 @@ ScatterGraphic, ScatterCollection, ) -from ._processor_base import NDProcessor, WindowFuncCallable +from ..processor_base import NDProcessor, WindowFuncCallable # TODO: Maybe get rid of n_display_dims in NDProcessor, @@ -219,40 +217,6 @@ def _get_dw_slices(self, indices) -> tuple[slice] | tuple[slice, slice]: return tuple(slices) - # - # # clamp w.r.t. processor shape - # - # dw = self.index_mappings[-1](self.display_window) - # - # if dw == 1: - # slices = [slice(index_p, index_p + 1)] - # - # else: - # # half window size - # hw = dw // 2 - # - # # for now assume just a single index provided that indicates x axis value - # start = max(index_p - hw, 0) - # stop = start + dw - # # also add window size of `p` dim so window_func output has the same number of datapoints - # if ( - # self.datapoints_window_func is not None - # and self.datapoints_window_size is not None - # ): - # stop += self.datapoints_window_size - 1 - # # TODO: pad with constant if we're using a window func and the index is near the end - # - # # TODO: uncomment this once we have resizeable buffers!! - # # stop = min(index_p + hw, self.shape[-2]) - # - # slices = [slice(start, stop)] - # - # if self.multi: - # # n - 2 dim is n_lines or n_scatters - # slices.insert(0, slice(None)) - # - # return tuple(slices) - def get(self, indices: tuple[Any, ...]): """ slices through all slider dims and outputs an array that can be used to set graphic data @@ -370,10 +334,10 @@ def get(self, indices: tuple[Any, ...]): class NDPositions: - def __init__( self, data: Any, + *args, graphic: Type[ LineGraphic | LineCollection @@ -390,18 +354,24 @@ def __init__( index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, graphic_kwargs: dict = None, + processor_kwargs: dict = None, ): if issubclass(graphic, LineCollection): multi = True + if processor_kwargs is None: + processor_kwargs = dict() + self._processor = processor( data, + *args, multi=multi, display_window=display_window, max_display_datapoints=max_display_datapoints, window_funcs=window_funcs, window_sizes=window_sizes, index_mappings=index_mappings, + **processor_kwargs, ) self._processor.p_max = 1_000 @@ -471,7 +441,7 @@ def _tooltip_handler(self, graphic, pick_info): # get graphic within the collection n_index = np.argwhere(self.graphic.graphics == graphic).item() p_index = pick_info["vertex_index"] - return self.processor.format_tooltip(n_index, p_index) + return self.processor.tooltip_format(n_index, p_index) def _create_graphic( self, @@ -508,7 +478,7 @@ def _create_graphic( kwargs = dict() self._graphic = graphic_cls(data_slice, **kwargs) - if hasattr(self.processor, "format_tooltip"): + if self.processor.tooltip: if isinstance(self._graphic, (LineCollection, ScatterCollection)): for g in self._graphic.graphics: g.tooltip_format = partial(self._tooltip_handler, g) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/nd_image.py similarity index 87% rename from fastplotlib/widgets/nd_widget/_nd_image.py rename to fastplotlib/widgets/nd_widget/nd_image.py index f115e146e..4972db9d5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/nd_image.py @@ -1,6 +1,6 @@ from typing import Literal -from ._processor_base import NDProcessor +from .processor_base import NDProcessor class NDImageProcessor(NDProcessor): diff --git a/fastplotlib/widgets/nd_widget/_processor_base.py b/fastplotlib/widgets/nd_widget/processor_base.py similarity index 96% rename from fastplotlib/widgets/nd_widget/_processor_base.py rename to fastplotlib/widgets/nd_widget/processor_base.py index 225608cca..a1cd5311c 100644 --- a/fastplotlib/widgets/nd_widget/_processor_base.py +++ b/fastplotlib/widgets/nd_widget/processor_base.py @@ -55,6 +55,19 @@ def _validate_data(self, data: ArrayProtocol): return data + @property + def tooltip(self) -> bool: + """ + whether or not a custom tooltip formatter method exists + """ + return False + + def tooltip_format(self, *args) -> str | None: + """ + Override in subclass to format custom tooltips + """ + return None + @property def slider_dims(self): raise NotImplementedError From 47ec02add5b7904add70cfc4fbaa618a702e6a0b Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Mon, 16 Feb 2026 05:45:18 -0500 Subject: [PATCH 026/163] separate array logic and graphic logic in `ImageWidget` (#868) * start separating iw plotting and array logic * some more basics down * comment * collapse into just having a window function, no frame_function * progress * placeholder for computing histogram * formatting * remove spaghetti * more progress * basics working :D * black * most of the basics work in iw * fix * progress * progress but still broken * flippin display dims works * camera scale must be positive for MIP rendering * a very difficult to encounter iterator bug! * patch iterator caveats * mostly worksgit status * add ArrayProtocol * rename * fixes * set camera orthogonal to xy plane when going from 3d -> 2d * naming, cleaning * cleanup, correct way to push and pop dims * quality of life improvements * new histogram lut tool * new hlut tool * imagewidget rgb toggle works * more progress * support rgb(a) image volumes * ImageGraphic cleanup * cleanup, docs * fix * updates * new per-data array properties work * black formatting * fixes and other things * typing tweaks * better iterator, fix bugs * fixes * show tooltips in right clck menu * ignore nans and inf for histogram * histogram of zeros * docstrings * fix imgui pixels * iw indices event handlers only get a tuple of the indices * bugfix * fix cmap setter * spatial_func better name * bugfix * hist specify quantile * fix typos (#991) * fix typos * add rendercanvas to intersphinx_mapping * nd-iw backup * correct ImageGraphic w.r.t. ndw * last fixes in ndi --- .github/workflows/docs-deploy.yml | 2 +- fastplotlib/graphics/_base.py | 9 + fastplotlib/graphics/features/_base.py | 4 +- .../graphics/features/_selection_features.py | 6 +- fastplotlib/graphics/image.py | 15 +- fastplotlib/graphics/image_volume.py | 14 +- .../graphics/selectors/_linear_region.py | 4 +- fastplotlib/graphics/utils.py | 15 +- fastplotlib/layouts/_figure.py | 34 +- fastplotlib/layouts/_plot_area.py | 5 +- fastplotlib/tools/_histogram_lut.py | 588 +++++----- fastplotlib/ui/_base.py | 4 +- .../ui/right_click_menus/_colormap_picker.py | 3 +- fastplotlib/utils/_protocols.py | 3 + fastplotlib/widgets/image_widget/__init__.py | 1 + .../widgets/image_widget/_nd_iw_backup.py | 1007 +++++++++++++++++ .../widgets/image_widget/_processor.py | 519 +++++++++ .../widgets/image_widget/_properties.py | 139 +++ fastplotlib/widgets/image_widget/_sliders.py | 91 +- 19 files changed, 2089 insertions(+), 374 deletions(-) create mode 100644 fastplotlib/widgets/image_widget/_nd_iw_backup.py create mode 100644 fastplotlib/widgets/image_widget/_processor.py create mode 100644 fastplotlib/widgets/image_widget/_properties.py 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/graphics/_base.py b/fastplotlib/graphics/_base.py index 47673cbc0..e0602e4e3 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -178,6 +178,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 @@ -274,6 +275,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.""" @@ -440,6 +446,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 76352b4ef..68fe54c33 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -318,7 +318,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 @@ -334,7 +334,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 9b30dd70c..1f049f0cb 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,9 @@ 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/image.py b/fastplotlib/graphics/image.py index 760b856d2..7b670d531 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -162,10 +162,11 @@ def __init__( self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) # set map to None for RGB images - if self._data.value.ndim > 2: + if self._data.value.ndim == 3: self._cmap = None _map = None - else: + + elif self._data.value.ndim == 2: # use TextureMap for grayscale images self._cmap = ImageCmap(cmap) @@ -174,6 +175,12 @@ def __init__( 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( @@ -275,8 +282,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: @@ -312,7 +317,7 @@ def interpolation(self, value: str): @property def cmap_interpolation(self) -> str: - """cmap interpolation method""" + """cmap interpolation method, 'linear' or 'nearest'. Used only for grayscale images""" return self._cmap_interpolation.value @cmap_interpolation.setter diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index a3b379492..3d2d064e8 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -204,18 +204,24 @@ def __init__( self._vmax = ImageVmax(vmax) self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_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 not in (3, 4): + raise ValueError( + f"ImageVolumeGraphic `data` must have 3 dimensions for grayscale images, " + f"or 4 dimensions for RGB(A) images.\n" + 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) self._step_size = VolumeIsoStepSize(step_size) @@ -301,7 +307,7 @@ def mode(self, mode: str): @property def cmap(self) -> str: - """Get or set colormap name""" + """Get or set colormap name, only used for grayscale images""" return self._cmap.value @cmap.setter diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 70a8dffa8..8a8583ae9 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -472,9 +472,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/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 79b5be3a8..00b915b1f 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -539,7 +539,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 @@ -606,14 +606,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: @@ -622,7 +622,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 @@ -646,15 +646,15 @@ 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, # 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 @@ -770,7 +770,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: @@ -929,18 +929,20 @@ 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))) - 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""" @@ -955,6 +957,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 f83dcfbcb..405a01546 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -233,7 +233,10 @@ 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: + # 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) subplot._controller = new_controller diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index d651137da..8edfb046b 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -6,424 +6,412 @@ 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): _fpl_support_tooltip = False def __init__( self, - data: np.ndarray, - images: ( - ImageGraphic - | ImageVolumeGraphic - | Sequence[ImageGraphic | ImageVolumeGraphic] - ), - nbins: int = 100, - flank_divisor: float = 5.0, + histogram: tuple[np.ndarray, np.ndarray], + images: ImageGraphic | ImageVolumeGraphic | 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. + 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 ---------- - data: np.ndarray - - images: ImageGraphic | ImageVolumeGraphic | tuple[ImageGraphic | ImageVolumeGraphic] - - nbins: int, defaut 100. - Total number of bins used in the histogram + histogram: tuple[np.ndarray, np.ndarray] + [frequency, bin_edges], must be 100 bins - flank_divisor: float, default 5.0. - Fraction of empty histogram bins on the tails of the distribution set `np.inf` for no flanks + images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageGraphic | ImageVolumeGraphic] + the images that are managed by the histogram tool - kwargs: passed to ``Graphic`` + kwargs: + passed to ``Graphic`` """ - super().__init__(**kwargs) - - 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._images = images + super().__init__(**kwargs) - self._data = weakref.proxy(data) + if len(histogram) != 2: + raise TypeError - self._scale_factor: float = 1.0 + self._block_reentrance = False + self._images = list() - hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data) + self._bin_centers_flanked = np.zeros(120, dtype=np.float64) + self._freq_flanked = np.zeros(120, dtype=np.float32) - line_data = np.column_stack([hist_scaled, edges_flanked]) + # 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._histogram_line = LineGraphic( - line_data, colors=(0.8, 0.8, 0.8), alpha_mode="solid", offset=(0, 0, -1) + # 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), + size=1.5, + center=0.5, # frequency data are normalized between 0-1 + axis="y", + parent=self._line, ) - 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._selector.add_event_handler(self._selector_event_handler, "selection") - self._linear_region_selector = LinearRegionSelector( - selection=bounds, - limits=limits, - size=size, - center=origin[0], - axis="y", - parent=self._histogram_line, + self._colorbar = ImageGraphic( + data=np.zeros([120, 2]), interpolation="linear", offset=(1.5, 0, 0) ) - self._vmin = self.images[0].vmin - self._vmax = self.images[0].vmax + # make the colorbar thin + self._colorbar.world_object.local.scale_x = 0.15 + self._colorbar.add_event_handler(self._open_cmap_picker, "click") - # 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, + # colorbar ruler + 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", ) - + # 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( - 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, + # add all the world objects to a pygfx.Group + 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._set_world_object(wo) - self._set_world_object(widget_wo) + # for convenience, a list that stores all the graphics managed by the histogram LUT tool + 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) + def _fpl_add_plot_area_hook(self, plot_area): + self._plot_area = plot_area - self._linear_region_selector.add_event_handler( - self._linear_region_handler, "selection" - ) + 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) - 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) + # disable the controller in this plot area + self._plot_area.controller.enabled = False + self._plot_area.auto_scale(maintain_aspect=False) - # 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") + # tick text for colorbar ruler doesn't show without this call + self._ruler.update(plot_area.camera, plot_area.canvas.get_logical_size()) - self.world_object.add(self._colorbar.world_object) - else: - self._colorbar = None - self._cmap = None + def _ruler_tick_map(self, bin_index, *args): + return f"{self._bin_centers_flanked[int(bin_index)]:.2f}" - 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 + ): + """set histogram with pre-compuated [frequency, edges], must have exactly 100 bins""" - return cbar + freq, edges = histogram - 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}" + 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() - if self.vmax < 0.001 or self.vmax > 99_999: - vmax_str = f"{self.vmax:.2e}" - else: - vmax_str = f"{self.vmax:.2f}" + bin_centers = 0.5 * (edges[1:] + edges[:-1]) - return vmin_str, vmax_str + step = bin_centers[1] - bin_centers[0] - 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) + 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] + ) + + 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): + # self.vmin, self.vmax = bin_centers[0], bin_centers[-1] - # 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) + if hasattr(self, "plot_area"): + self._ruler.update( + self._plot_area.camera, self._plot_area.canvas.get_logical_size() + ) - # 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)) + @property + def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic, ...] | None: + """get or set the managed images""" + return tuple(self._images) - edges = edges * self._scale_factor + @images.setter + def images(self, new_images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageGraphic | ImageVolumeGraphic] | None): + self._disconnect_images() + self._images.clear() - bin_width = edges[1] - edges[0] + if new_images is None: + return - flank_nbins = int(self._nbins / self._flank_divisor) - flank_size = flank_nbins * bin_width + if isinstance(new_images, (ImageGraphic, ImageVolumeGraphic)): + new_images = [new_images] - 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 = list(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") + 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 _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, *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_cmap_handler(self, ev): - setattr(self, ev.type, ev.info["value"]) + 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: - return self._cmap + """get or set the colormap, only for grayscale images""" + 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: + if image.cmap is None: + # rgb(a) images have no cmap + continue + + 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 + """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] @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 + 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]) - self._vmin = value - if self._colorbar is not None: - self._colorbar.vmin = value + 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 + self._text_vmin.text = _format_value(value) + self._text_vmin.offset = (-0.45, self._selector.selection[0], 0) + + for image in self._images: + image.vmin = value + + 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 vmax(self) -> float: - return self._vmax + """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] @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]) - - # set x and y vals - self._histogram_line.data[:, :2] = line_data - - bounds = (edges[0], edges[-1]) - limits = (edges_flanked[0], edges_flanked[-11]) - origin = (hist_scaled.max() / 2, 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 - - self._data = weakref.proxy(data) - - if self._colorbar is not None: - self._colorbar.clear_event_handlers() - self.world_object.remove(self._colorbar.world_object) - - 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") + 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): + """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] - 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): + """open imgui cmap picker""" # check if right click if ev.button != 2: return @@ -433,7 +421,11 @@ 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 + """cleanup, need to disconnect events and remove image references for proper garbage collection""" + 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/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/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/utils/_protocols.py b/fastplotlib/utils/_protocols.py index c168ecfa4..7ae63ed67 100644 --- a/fastplotlib/utils/_protocols.py +++ b/fastplotlib/utils/_protocols.py @@ -1,6 +1,9 @@ from typing import Protocol, runtime_checkable +ARRAY_LIKE_ATTRS = ["shape", "ndim", "__getitem__"] + + @runtime_checkable class ArrayProtocol(Protocol): @property diff --git a/fastplotlib/widgets/image_widget/__init__.py b/fastplotlib/widgets/image_widget/__init__.py index 70a1aa8ae..dc5daea55 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 ._processor import NDImageProcessor else: diff --git a/fastplotlib/widgets/image_widget/_nd_iw_backup.py b/fastplotlib/widgets/image_widget/_nd_iw_backup.py new file mode 100644 index 000000000..7db265c0c --- /dev/null +++ b/fastplotlib/widgets/image_widget/_nd_iw_backup.py @@ -0,0 +1,1007 @@ +from typing import Callable, Sequence, Literal +from warnings import warn + +import numpy as np + +from rendercanvas import BaseRenderCanvas + +from ...layouts import ImguiFigure as Figure +from ...graphics import ImageGraphic, ImageVolumeGraphic +from ...utils import calculate_figure_shape, quick_min_max, ArrayProtocol +from ...tools import HistogramLUTTool +from ._sliders import ImageWidgetSliders +from ._processor import NDImageProcessor, WindowFuncCallable +from ._properties import ImageWidgetProperty, Indices + + +IMGUI_SLIDER_HEIGHT = 49 + + +class ImageWidget: + def __init__( + self, + data: ArrayProtocol | Sequence[ArrayProtocol | None] | None, + processors: NDImageProcessor | Sequence[NDImageProcessor] = NDImageProcessor, + n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, + slider_dim_names: Sequence[str] | None = None, # dim names left -> right + rgb: bool | Sequence[bool] = False, + cmap: str | Sequence[str] = "plasma", + window_funcs: ( + tuple[WindowFuncCallable | None, ...] + | WindowFuncCallable + | None + | Sequence[ + tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None + ] + ) = None, + window_sizes: ( + tuple[int | None, ...] | Sequence[tuple[int | None, ...] | None] + ) = None, + window_order: tuple[int, ...] | Sequence[tuple[int, ...] | None] = None, + spatial_func: ( + Callable[[ArrayProtocol], ArrayProtocol] + | Sequence[Callable[[ArrayProtocol], ArrayProtocol]] + | None + ) = None, + sliders_dim_order: Literal["right", "left"] = "right", + figure_shape: tuple[int, int] = None, + names: Sequence[str] = None, + figure_kwargs: dict = None, + histogram_widget: bool = True, + histogram_init_quantile: int = (0, 100), + graphic_kwargs: dict | Sequence[dict] = None, + ): + """ + This widget facilitates high-level navigation through image stacks, which are arrays containing one or more + images. It includes sliders for key dimensions such as "t" (time) and "z", enabling users to smoothly navigate + through one or multiple image stacks simultaneously. + + Allowed dimensions orders for each image stack: Note that each has a an optional (c) channel which refers to + RGB(A) a channel. So this channel should be either 3 or 4. + + Parameters + ---------- + data: ArrayProtocol | Sequence[ArrayProtocol | None] | None + array-like or a list of array-like, each array must have a minimum of 2 dimensions + + processors: NDImageProcessor | Sequence[NDImageProcessor], default NDImageProcessor + The image processors used for each n-dimensional data array + + n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]], default 2 + number of display dimensions + + slider_dim_names: Sequence[str], optional + optional list/tuple of names for each slider dim + + rgb: bool | Sequence[bool], default + whether or not each data array represents RGB(A) images + + figure_shape: Optional[Tuple[int, int]] + manually provide the shape for the Figure, otherwise the number of rows and columns is estimated + + figure_kwargs: dict, optional + passed to ``Figure`` + + names: Optional[str] + gives names to the subplots + + histogram_widget: bool, default False + make histogram LUT widget for each subplot + + rgb: bool | list[bool], default None + bool or list of bool for each input data array in the ImageWidget, indicating whether the corresponding + data arrays are grayscale or RGB(A). + + graphic_kwargs: Any + passed to each ImageGraphic in the ImageWidget figure subplots + + """ + + if figure_kwargs is None: + figure_kwargs = dict() + + if isinstance(data, ArrayProtocol) or (data is None): + data = [data] + + elif isinstance(data, (list, tuple)): + # verify that it's a list of np.ndarray + if not all([isinstance(d, ArrayProtocol) or d is None for d in data]): + raise TypeError( + f"`data` must be an array-like type or a list/tuple of array-like or None. " + f"You have passed the following type {type(data)}" + ) + + else: + raise TypeError( + f"`data` must be an array-like type or a list/tuple of array-like or None. " + f"You have passed the following type {type(data)}" + ) + + if issubclass(processors, NDImageProcessor): + processors = [processors] * len(data) + + elif isinstance(processors, (tuple, list)): + if not all([issubclass(p, NDImageProcessor) for p in processors]): + raise TypeError( + f"`processors` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " + f"list/tuple of `NDImageProcess` subclasses. You have passed: {processors}" + ) + + else: + raise TypeError( + f"`processors` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " + f"list/tuple of `NDImageProcess` subclasses. You have passed: {processors}" + ) + + # subplot layout + if figure_shape is None: + if "shape" in figure_kwargs: + figure_shape = figure_kwargs["shape"] + else: + figure_shape = calculate_figure_shape(len(data)) + + # Regardless of how figure_shape is computed, below code + # verifies that figure shape is large enough for the number of image arrays passed + if figure_shape[0] * figure_shape[1] < len(data): + original_shape = (figure_shape[0], figure_shape[1]) + figure_shape = calculate_figure_shape(len(data)) + warn( + f"Original `figure_shape` was: {original_shape} " + f" but data length is {len(data)}" + f" Resetting figure shape to: {figure_shape}" + ) + + elif isinstance(rgb, bool): + rgb = [rgb] * len(data) + + if not all([isinstance(v, bool) for v in rgb]): + raise TypeError( + f"`rgb` parameter must be a bool or a Sequence of bool, you have passed: {rgb}" + ) + + if not len(rgb) == len(data): + raise ValueError( + f"len(rgb) != len(data), {len(rgb)} != {len(data)}. These must be equal" + ) + + if names is not None: + if not all([isinstance(n, str) for n in names]): + raise TypeError("optional argument `names` must be a Sequence of str") + + if len(names) != len(data): + raise ValueError( + "number of `names` for subplots must be same as the number of data arrays" + ) + + # verify window funcs + if window_funcs is None: + win_funcs = [None] * len(data) + + elif callable(window_funcs) or all( + [callable(f) or f is None for f in window_funcs] + ): + # across all data arrays + # one window function defined for all dims, or window functions defined per-dim + win_funcs = [window_funcs] * len(data) + + # if the above two clauses didn't trigger, then window_funcs defined per-dim, per data array + elif len(window_funcs) != len(data): + raise IndexError + else: + win_funcs = window_funcs + + # verify window sizes + if window_sizes is None: + win_sizes = [window_sizes] * len(data) + + elif isinstance(window_sizes, int): + win_sizes = [window_sizes] * len(data) + + elif all([isinstance(size, int) or size is None for size in window_sizes]): + # window sizes defined per-dim across all data arrays + win_sizes = [window_sizes] * len(data) + + elif len(window_sizes) != len(data): + # window sizes defined per-dim, per data array + raise IndexError + else: + win_sizes = window_sizes + + # verify window orders + if window_order is None: + win_order = [None] * len(data) + + elif all([isinstance(o, int) for o in order]): + # window order defined per-dim across all data arrays + win_order = [window_order] * len(data) + + elif len(window_order) != len(data): + raise IndexError + + else: + win_order = window_order + + # verify spatial_func + if spatial_func is None: + spatial_func = [None] * len(data) + + elif callable(spatial_func): + # same spatial_func for all data arrays + spatial_func = [spatial_func] * len(data) + + elif len(spatial_func) != len(data): + raise IndexError + + else: + spatial_func = spatial_func + + # verify number of display dims + if isinstance(n_display_dims, (int, np.integer)): + n_display_dims = [n_display_dims] * len(data) + + elif isinstance(n_display_dims, (tuple, list)): + if not all([isinstance(n, (int, np.integer)) for n in n_display_dims]): + raise TypeError + + if len(n_display_dims) != len(data): + raise IndexError + else: + raise TypeError + + n_display_dims = tuple(n_display_dims) + + if sliders_dim_order not in ("right",): + raise ValueError( + f"Only 'right' slider dims order is currently supported, you passed: {sliders_dim_order}" + ) + self._sliders_dim_order = sliders_dim_order + + self._slider_dim_names = None + self.slider_dim_names = slider_dim_names + + self._histogram_widget = histogram_widget + + # make NDImageArrays + self._image_processors: list[NDImageProcessor] = list() + for i in range(len(data)): + cls = processors[i] + image_processor = cls( + data=data[i], + rgb=rgb[i], + n_display_dims=n_display_dims[i], + window_funcs=win_funcs[i], + window_sizes=win_sizes[i], + window_order=win_order[i], + spatial_func=spatial_func[i], + compute_histogram=self._histogram_widget, + ) + + self._image_processors.append(image_processor) + + self._data = ImageWidgetProperty(self, "data") + self._rgb = ImageWidgetProperty(self, "rgb") + self._n_display_dims = ImageWidgetProperty(self, "n_display_dims") + self._window_funcs = ImageWidgetProperty(self, "window_funcs") + self._window_sizes = ImageWidgetProperty(self, "window_sizes") + self._window_order = ImageWidgetProperty(self, "window_order") + self._spatial_func = ImageWidgetProperty(self, "spatial_func") + + if len(set(n_display_dims)) > 1: + # assume user wants one controller for 2D images and another for 3D image volumes + n_subplots = np.prod(figure_shape) + controller_ids = [0] * n_subplots + controller_types = ["panzoom"] * n_subplots + + for i in range(len(data)): + if n_display_dims[i] == 2: + controller_ids[i] = 1 + else: + controller_ids[i] = 2 + controller_types[i] = "orbit" + + # needs to be a list of list + controller_ids = [controller_ids] + + else: + controller_ids = "sync" + controller_types = None + + figure_kwargs_default = { + "controller_ids": controller_ids, + "controller_types": controller_types, + "names": names, + } + + # update the default kwargs with any user-specified kwargs + # user specified kwargs will overwrite the defaults + figure_kwargs_default.update(figure_kwargs) + figure_kwargs_default["shape"] = figure_shape + + if graphic_kwargs is None: + graphic_kwargs = [dict()] * len(data) + + elif isinstance(graphic_kwargs, dict): + graphic_kwargs = [graphic_kwargs] * len(data) + + elif len(graphic_kwargs) != len(data): + raise IndexError + + if cmap is None: + cmap = [None] * len(data) + + elif isinstance(cmap, str): + cmap = [cmap] * len(data) + + elif not all([isinstance(c, str) for c in cmap]): + raise TypeError(f"`cmap` must be a or a list/tuple of ") + + self._figure: Figure = Figure(**figure_kwargs_default) + + self._indices = Indices(list(0 for i in range(self.n_sliders)), self) + + for i, subplot in zip(range(len(self._image_processors)), self.figure): + image_data = self._get_image( + self._image_processors[i], tuple(self._indices) + ) + + if image_data is None: + # this subplot/data array is blank, skip + continue + + # next 20 lines are just vmin, vmax parsing + vmin_specified, vmax_specified = None, None + if "vmin" in graphic_kwargs[i].keys(): + vmin_specified = graphic_kwargs[i].pop("vmin") + if "vmax" in graphic_kwargs[i].keys(): + vmax_specified = graphic_kwargs[i].pop("vmax") + + if (vmin_specified is None) or (vmax_specified is None): + # if either vmin or vmax are not specified, calculate an estimate by subsampling + vmin_estimate, vmax_estimate = quick_min_max( + self._image_processors[i].data + ) + + # decide vmin, vmax passed to ImageGraphic constructor based on whether it's user specified or now + if vmin_specified is None: + # user hasn't specified vmin, use estimated value + vmin = vmin_estimate + else: + # user has provided a specific value, use that + vmin = vmin_specified + + if vmax_specified is None: + vmax = vmax_estimate + else: + vmax = vmax_specified + else: + # both vmin and vmax are specified + vmin, vmax = vmin_specified, vmax_specified + + graphic_kwargs[i]["cmap"] = cmap[i] + + if self._image_processors[i].n_display_dims == 2: + # create an Image + graphic = ImageGraphic( + data=image_data, + name="image_widget_managed", + vmin=vmin, + vmax=vmax, + **graphic_kwargs[i], + ) + elif self._image_processors[i].n_display_dims == 3: + # create an ImageVolume + graphic = ImageVolumeGraphic( + data=image_data, + name="image_widget_managed", + vmin=vmin, + vmax=vmax, + **graphic_kwargs[i], + ) + subplot.camera.fov = 50 + + subplot.add_graphic(graphic) + + self._reset_histogram(subplot, self._image_processors[i]) + + self._sliders_ui = ImageWidgetSliders( + figure=self.figure, + size=57 + (IMGUI_SLIDER_HEIGHT * self.n_sliders), + location="bottom", + title="ImageWidget Controls", + image_widget=self, + ) + + self.figure.add_gui(self._sliders_ui) + + self._indices_changed_handlers = set() + + self._reentrant_block = False + + @property + def data(self) -> ImageWidgetProperty[ArrayProtocol | None]: + """get or set the nd-image data arrays""" + return self._data + + @data.setter + def data(self, new_data: Sequence[ArrayProtocol | None]): + if isinstance(new_data, ArrayProtocol) or new_data is None: + new_data = [new_data] * len(self._image_processors) + + if len(new_data) != len(self._image_processors): + raise IndexError + + # if the data array hasn't been changed + # graphics will not be reset for this data index + skip_indices = list() + + for i, (new_data, image_processor) in enumerate( + zip(new_data, self._image_processors) + ): + if new_data is image_processor.data: + skip_indices.append(i) + continue + + image_processor.data = new_data + + self._reset(skip_indices) + + @property + def rgb(self) -> ImageWidgetProperty[bool]: + """get or set the rgb toggle for each data array""" + return self._rgb + + @rgb.setter + def rgb(self, rgb: Sequence[bool]): + if isinstance(rgb, bool): + rgb = [rgb] * len(self._image_processors) + + if len(rgb) != len(self._image_processors): + raise IndexError + + # if the rgb option hasn't been changed + # graphics will not be reset for this data index + skip_indices = list() + + for i, (new, image_processor) in enumerate(zip(rgb, self._image_processors)): + if image_processor.rgb == new: + skip_indices.append(i) + continue + + image_processor.rgb = new + + self._reset(skip_indices) + + @property + def n_display_dims(self) -> ImageWidgetProperty[Literal[2, 3]]: + """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" + return self._n_display_dims + + @n_display_dims.setter + def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[2, 3]): + if isinstance(new_ndd, (int, np.integer)): + if new_ndd == 2 or new_ndd == 3: + new_ndd = [new_ndd] * len(self._image_processors) + else: + raise ValueError + + if len(new_ndd) != len(self._image_processors): + raise IndexError + + if not all([(n == 2) or (n == 3) for n in new_ndd]): + raise ValueError + + # if the n_display_dims hasn't been changed for this data array + # graphics will not be reset for this data array index + skip_indices = list() + + # first update image arrays + for i, (image_processor, new) in enumerate( + zip(self._image_processors, new_ndd) + ): + if new > image_processor.max_n_display_dims: + raise IndexError( + f"number of display dims exceeds maximum number of possible " + f"display dimensions: {image_processor.max_n_display_dims}, for array at index: " + f"{i} with shape: {image_processor.shape}, and rgb set to: {image_processor.rgb}" + ) + + if image_processor.n_display_dims == new: + skip_indices.append(i) + else: + image_processor.n_display_dims = new + + self._reset(skip_indices) + + @property + def window_funcs(self) -> ImageWidgetProperty[tuple[WindowFuncCallable | None] | None]: + """get or set the window functions""" + return self._window_funcs + + @window_funcs.setter + def window_funcs(self, new_funcs: Sequence[WindowFuncCallable | None] | None): + if callable(new_funcs) or new_funcs is None: + new_funcs = [new_funcs] * len(self._image_processors) + + if len(new_funcs) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("window_funcs", new_funcs) + + @property + def window_sizes(self) -> ImageWidgetProperty[tuple[int | None, ...] | None]: + """get or set the window sizes""" + return self._window_sizes + + @window_sizes.setter + def window_sizes( + self, new_sizes: Sequence[tuple[int | None, ...] | int | None] | int | None + ): + if isinstance(new_sizes, int) or new_sizes is None: + # same window for all data arrays + new_sizes = [new_sizes] * len(self._image_processors) + + if len(new_sizes) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("window_sizes", new_sizes) + + @property + def window_order(self) -> ImageWidgetProperty[tuple[int, ...] | None]: + """get or set order in which window functions are applied over dimensions""" + return self._window_order + + @window_order.setter + def window_order(self, new_order: Sequence[tuple[int, ...]]): + if new_order is None: + new_order = [new_order] * len(self._image_processors) + + if all([isinstance(order, (int, np.integer))] for order in new_order): + # same order specified across all data arrays + new_order = [new_order] * len(self._image_processors) + + if len(new_order) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("window_order", new_order) + + @property + def spatial_func(self) -> ImageWidgetProperty[Callable | None]: + """Get or set a spatial_func that operates on the spatial dimensions of the 2D or 3D image""" + return self._spatial_func + + @spatial_func.setter + def spatial_func(self, funcs: Callable | Sequence[Callable] | None): + if callable(funcs) or funcs is None: + funcs = [funcs] * len(self._image_processors) + + if len(funcs) != len(self._image_processors): + raise IndexError + + self._set_image_processor_funcs("spatial_func", funcs) + + def _set_image_processor_funcs(self, attr, new_values): + """sets window_funcs, window_sizes, window_order, or spatial_func and updates displayed data and histograms""" + for new, image_processor, subplot in zip( + new_values, self._image_processors, self.figure + ): + if getattr(image_processor, attr) == new: + continue + + setattr(image_processor, attr, new) + + # window functions and spatial functions will only change the histogram + # they do not change the collections of dimensions, so we don't need to call _reset_dimensions + # they also do not change the image graphic, so we do not need to call _reset_image_graphics + self._reset_histogram(subplot, image_processor) + + # update the displayed image data in the graphics + self.indices = self.indices + + @property + def indices(self) -> ImageWidgetProperty[int]: + """ + Get or set the current indices. + + Returns + ------- + indices: ImageWidgetProperty[int] + integer index for each slider dimension + + """ + return self._indices + + @indices.setter + def indices(self, new_indices: Sequence[int]): + if self._reentrant_block: + return + + try: + self._reentrant_block = True # block re-execution until new_indices has *fully* completed execution + + if len(new_indices) != self.n_sliders: + raise IndexError( + f"len(new_indices) != ImageWidget.n_sliders, {len(new_indices)} != {self.n_sliders}. " + f"The length of the new_indices must be the same as the number of sliders" + ) + + if any([i < 0 for i in new_indices]): + raise IndexError( + f"only positive index values are supported, you have passed: {new_indices}" + ) + + for image_processor, graphic in zip(self._image_processors, self.graphics): + new_data = self._get_image(image_processor, indices=new_indices) + if new_data is None: + continue + + graphic.data = new_data + + self._indices._fpl_set(new_indices) + + # call any event handlers + for handler in self._indices_changed_handlers: + handler(tuple(self.indices)) + + except Exception as exc: + # raise original exception + raise exc # indices setter has raised. The lines above below are probably more relevant! + finally: + # set_value has finished executing, now allow future executions + self._reentrant_block = False + + @property + def histogram_widget(self) -> bool: + """show or hide the histograms""" + return self._histogram_widget + + @histogram_widget.setter + def histogram_widget(self, show_histogram: bool): + if not isinstance(show_histogram, bool): + raise TypeError( + f"`histogram_widget` can be set with a bool, you have passed: {show_histogram}" + ) + + for subplot, image_processor in zip(self.figure, self._image_processors): + image_processor.compute_histogram = show_histogram + self._reset_histogram(subplot, image_processor) + + @property + def n_sliders(self) -> int: + """number of sliders""" + return max([a.n_slider_dims for a in self._image_processors]) + + @property + def bounds(self) -> tuple[int, ...]: + """The max bound across all dimensions across all data arrays""" + # initialize with 0 + bounds = [0] * self.n_sliders + + # TODO: implement left -> right slider dims ordering, right now it's only right -> left + # in reverse because dims go left <- right + for i, dim in enumerate(range(-1, -self.n_sliders - 1, -1)): + # across each dim + for array in self._image_processors: + if i > array.n_slider_dims - 1: + continue + # across each data array + # dims go left <- right + bounds[dim] = max(array.slider_dims_shape[dim], bounds[dim]) + + return bounds + + @property + def slider_dim_names(self) -> tuple[str, ...]: + return self._slider_dim_names + + @slider_dim_names.setter + def slider_dim_names(self, names: Sequence[str]): + if names is None: + self._slider_dim_names = None + return + + if not all([isinstance(n, str) for n in names]): + raise TypeError(f"`slider_dim_names` must be set with a list/tuple of , you passed: {names}") + + if len(set(names)) != len(names): + raise ValueError( + f"`slider_dim_names` must be unique, you passed: {names}" + ) + + self._slider_dim_names = tuple(names) + + def _get_image( + self, image_processor: NDImageProcessor, indices: Sequence[int] + ) -> ArrayProtocol: + """Get a processed 2d or 3d image from the NDImage at the given indices""" + n = image_processor.n_slider_dims + + if self._sliders_dim_order == "right": + return image_processor.get(indices[-n:]) + + elif self._sliders_dim_order == "left": + # TODO: left -> right is not fully implemented yet in ImageWidget + return image_processor.get(indices[:n]) + + def _reset_dimensions(self): + """reset the dimensions w.r.t. current collection of NDImageProcessors""" + # TODO: implement left -> right slider dims ordering, right now it's only right -> left + # add or remove dims from indices + # trim any excess dimensions + while len(self._indices) > self.n_sliders: + # remove outer most dims first + self._indices.pop_dim() + self._sliders_ui.pop_dim() + + # add any new dimensions that aren't present + while len(self.indices) < self.n_sliders: + # insert right -> left + self._indices.push_dim() + self._sliders_ui.push_dim() + + self._sliders_ui.size = 57 + (IMGUI_SLIDER_HEIGHT * self.n_sliders) + + def _reset_image_graphics(self, subplot, image_processor): + """delete and create a new image graphic if necessary""" + new_image = self._get_image(image_processor, indices=tuple(self.indices)) + if new_image is None: + if "image_widget_managed" in subplot: + # delete graphic from this subplot if present + subplot.delete_graphic(subplot["image_widget_managed"]) + # skip this subplot + return + + # check if a graphic exists + if "image_widget_managed" in subplot: + # create a new graphic only if the Texture buffer shape doesn't match + if subplot["image_widget_managed"].data.value.shape == new_image.shape: + return + + # keep cmap + cmap = subplot["image_widget_managed"].cmap + if cmap is None: + # ex: going from rgb -> grayscale + cmap = "plasma" + # delete graphic since it will be replaced + subplot.delete_graphic(subplot["image_widget_managed"]) + else: + # default cmap + cmap = "plasma" + + if image_processor.n_display_dims == 2: + g = subplot.add_image( + data=new_image, cmap=cmap, name="image_widget_managed" + ) + + # set camera orthogonal to the xy plane, flip y axis + subplot.camera.set_state( + { + "position": [0, 0, -1], + "rotation": [0, 0, 0, 1], + "scale": [1, -1, 1], + "reference_up": [0, 1, 0], + "fov": 0, + "depth_range": None, + } + ) + + subplot.controller = "panzoom" + subplot.axes.intersection = None + subplot.auto_scale() + + elif image_processor.n_display_dims == 3: + g = subplot.add_image_volume( + data=new_image, cmap=cmap, name="image_widget_managed" + ) + subplot.camera.fov = 50 + subplot.controller = "orbit" + + # make sure all 3D dimension camera scales are positive + # MIP rendering doesn't work with negative camera scales + for dim in ["x", "y", "z"]: + if getattr(subplot.camera.local, f"scale_{dim}") < 0: + setattr(subplot.camera.local, f"scale_{dim}", 1) + + subplot.auto_scale() + + def _reset_histogram(self, subplot, image_processor): + """reset the histogram""" + if not self._histogram_widget: + subplot.docks["right"].size = 0 + return + + if image_processor.histogram is None: + # no histogram available for this processor + # either there is no data array in this subplot, + # or a histogram routine does not exist for this processor + subplot.docks["right"].size = 0 + return + + if "image_widget_managed" not in subplot: + # no image in this subplot + subplot.docks["right"].size = 0 + return + + image = subplot["image_widget_managed"] + + if "histogram_lut" in subplot.docks["right"]: + hlut: HistogramLUTTool = subplot.docks["right"]["histogram_lut"] + hlut.histogram = image_processor.histogram + hlut.images = image + if subplot.docks["right"].size < 1: + subplot.docks["right"].size = 80 + + else: + # need to make one + hlut = HistogramLUTTool( + histogram=image_processor.histogram, + images=image, + name="histogram_lut", + ) + + subplot.docks["right"].add_graphic(hlut) + subplot.docks["right"].size = 80 + + self.reset_vmin_vmax() + + def _reset(self, skip_data_indices: tuple[int, ...] = None): + if skip_data_indices is None: + skip_data_indices = tuple() + + # reset the slider indices according to the new collection of dimensions + self._reset_dimensions() + # update graphics where display dims have changed accordings to indices + for i, (subplot, image_processor) in enumerate( + zip(self.figure, self._image_processors) + ): + if i in skip_data_indices: + continue + + self._reset_image_graphics(subplot, image_processor) + self._reset_histogram(subplot, image_processor) + + # force an update + self.indices = self.indices + + @property + def figure(self) -> Figure: + """ + ``Figure`` used by `ImageWidget`. + """ + return self._figure + + @property + def graphics(self) -> list[ImageGraphic]: + """List of ``ImageWidget`` managed graphics.""" + iw_managed = list() + for subplot in self.figure: + if "image_widget_managed" in subplot: + iw_managed.append(subplot["image_widget_managed"]) + else: + iw_managed.append(None) + return tuple(iw_managed) + + @property + def cmap(self) -> tuple[str | None, ...]: + """get the cmaps, or set the cmap across all images""" + return tuple(g.cmap for g in self.graphics) + + @cmap.setter + def cmap(self, name: str): + for g in self.graphics: + if g is None: + # no data at this index + continue + + if g.cmap is None: + # if rgb + continue + + g.cmap = name + + def add_event_handler(self, handler: callable, event: str = "indices"): + """ + Register an event handler. + + Currently the only event that ImageWidget supports is "indices". This event is + emitted whenever the indices of the ImageWidget changes. + + Parameters + ---------- + handler: callable + callback function, must take a tuple of int as the only argument. This tuple will be the `indices` + + event: str, "indices" + the only supported event is "indices" + + Example + ------- + + .. code-block:: py + + def my_handler(indices): + print(indices) + # example prints: (100, 15) if the data has 2 slider dimensions with sliders at positions 100, 15 + + # create an image widget + iw = ImageWidget(...) + + # add event handler + iw.add_event_handler(my_handler) + + """ + if event != "indices": + raise ValueError("`indices` is the only event supported by `ImageWidget`") + + self._indices_changed_handlers.add(handler) + + def remove_event_handler(self, handler: callable): + """Remove a registered event handler""" + self._indices_changed_handlers.remove(handler) + + def clear_event_handlers(self): + """Clear all registered event handlers""" + self._indices_changed_handlers.clear() + + def reset_vmin_vmax(self): + """ + Reset the vmin and vmax w.r.t. the full data + """ + for image_processor, subplot in zip(self._image_processors, self.figure): + if "histogram_lut" not in subplot.docks["right"]: + continue + + if image_processor.histogram is None: + continue + + hlut = subplot.docks["right"]["histogram_lut"] + hlut.histogram = image_processor.histogram + + edges = image_processor.histogram[1] + + hlut.vmin, hlut.vmax = edges[0], edges[-1] + + def reset_vmin_vmax_frame(self): + """ + Resets the vmin vmax and HistogramLUT widgets w.r.t. the current data shown in the + ImageGraphic instead of the data in the full data array. For example, if a post-processing + function is used, the range of values in the ImageGraphic can be very different from the + range of values in the full data array. + """ + + for subplot, image_processor in zip(self.figure, self._image_processors): + if "histogram_lut" not in subplot.docks["right"]: + continue + + if image_processor.histogram is None: + continue + + hlut = subplot.docks["right"]["histogram_lut"] + # set the data using the current image graphic data + image = subplot["image_widget_managed"] + freqs, edges = np.histogram(image.data.value, bins=100) + hlut.histogram = (freqs, edges) + hlut.vmin, hlut.vmax = edges[0], edges[-1] + + def show(self, **kwargs): + """ + Show the widget. + + Parameters + ---------- + + kwargs: Any + passed to `Figure.show()`t + + Returns + ------- + BaseRenderCanvas + In Qt or GLFW, the canvas window containing the Figure will be shown. + In jupyter, it will display the plot in the output cell or sidecar. + + """ + + return self.figure.show(**kwargs) + + def close(self): + """Close Widget""" + self.figure.close() diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py new file mode 100644 index 000000000..0dce84a5e --- /dev/null +++ b/fastplotlib/widgets/image_widget/_processor.py @@ -0,0 +1,519 @@ +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, ArrayProtocol, ARRAY_LIKE_ATTRS + + +# must take arguments: array-like, `axis`: int, `keepdims`: bool +WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] + + +class NDImageProcessor: + def __init__( + self, + data: ArrayLike | None, + n_display_dims: Literal[2, 3] = 2, + rgb: bool = False, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_sizes: tuple[int | None, ...] | int = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayLike], ArrayLike] = None, + compute_histogram: bool = True, + ): + """ + An ND image that supports computing window functions, and functions over spatial dimensions. + + Parameters + ---------- + data: ArrayLike + array-like data, must have 2 or more dimensions + + n_display_dims: int, 2 or 3, default 2 + number of display dimensions + + rgb: bool, default False + whether the image data is RGB(A) or not + + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable, optional + A function or a ``tuple`` of functions that are applied to a rolling window of the data. + + 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. + + 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``. + + The output array-like type from a window function **must** support a ``.squeeze()`` method, but the + function itself should NOT squeeze the output array. + + window_sizes: tuple[int | None, ...], optional + ``tuple`` of ``int`` that specifies the window size for each dimension. + + 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. + + 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 spatial_func changes. + Disable if slow. + + """ + # set as False until data, window funcs stuff and spatial func is all set + self._compute_histogram = False + + 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._spatial_func = spatial_func + + self._compute_histogram = compute_histogram + self._recompute_histogram() + + @property + 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 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}, 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 + self._recompute_histogram() + + @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 + 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""" + if self._data is None: + return 0 + + return self.ndim - self.n_display_dims - int(self.rgb) + + @property + 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 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""" + 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 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() + + @property + def max_n_display_dims(self) -> int: + """maximum number of possible display dims""" + # 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]: + """tuple indicating the display dimension indices""" + return tuple(range(self.data.ndim))[self.n_slider_dims :] + + @property + def window_funcs( + self, + ) -> tuple[WindowFuncCallable | None, ...] | None: + """get or set window functions, see docstring for details""" + return self._window_funcs + + @window_funcs.setter + def window_funcs( + self, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None, + ): + if window_funcs is None: + self._window_funcs = None + return + + if callable(window_funcs): + window_funcs = (window_funcs,) + + # if all are None + if all([f is None for f in window_funcs]): + self._window_funcs = None + return + + self._validate_window_func(window_funcs) + + self._window_funcs = tuple(window_funcs) + self._recompute_histogram() + + def _validate_window_func(self, funcs): + if isinstance(funcs, (tuple, list)): + for f in funcs: + if f is None: + pass + elif callable(f): + sig = inspect.signature(f) + + if "axis" not in sig.parameters or "keepdims" not in sig.parameters: + raise TypeError( + f"Each window function must take an `axis` and `keepdims` argument, " + f"you passed: {f} with the following function signature: {sig}" + ) + else: + raise TypeError( + f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {funcs}" + ) + + if not (len(funcs) == self.n_slider_dims or self.n_slider_dims == 0): + raise IndexError( + f"number of `window_funcs` must be the same as the number of slider dims: {self.n_slider_dims}, " + f"and you passed {len(funcs)} `window_funcs`: {funcs}" + ) + + @property + def window_sizes(self) -> tuple[int | None, ...] | None: + """get or set window sizes used for the corresponding window functions, see docstring for details""" + return self._window_sizes + + @window_sizes.setter + def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): + if window_sizes is None: + self._window_sizes = None + return + + if isinstance(window_sizes, int): + window_sizes = (window_sizes,) + + # if all are None + if all([w is None for w in window_sizes]): + self._window_sizes = None + return + + if not all([isinstance(w, (int)) or w is None for w in window_sizes]): + raise TypeError( + f"`window_sizes` must be of type: tuple[int | None, ...] | int | None, you have passed: {window_sizes}" + ) + + if not (len(window_sizes) == self.n_slider_dims or self.n_slider_dims == 0): + raise IndexError( + f"number of `window_sizes` must be the same as the number of slider dims, " + f"i.e. `data.ndim` - n_display_dims, your data array has {self.ndim} dimensions " + f"and you passed {len(window_sizes)} `window_sizes`: {window_sizes}" + ) + + # make all window sizes are valid numbers + _window_sizes = list() + for i, w in enumerate(window_sizes): + if w is None: + _window_sizes.append(None) + continue + + if w < 0: + raise ValueError( + f"negative window size passed, all `window_sizes` must be positive " + f"integers or `None`, you passed: {_window_sizes}" + ) + + if w == 0 or w == 1: + # this is not a real window, set as None + w = None + + elif w % 2 == 0: + # odd window sizes makes most sense + warn( + f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" + ) + w += 1 + + _window_sizes.append(w) + + self._window_sizes = tuple(_window_sizes) + self._recompute_histogram() + + @property + def window_order(self) -> tuple[int, ...] | None: + """get or set dimension order in which window functions are applied""" + return self._window_order + + @window_order.setter + def window_order(self, order: tuple[int] | None): + if order is None: + self._window_order = None + return + + if order is not None: + if not all([d <= self.n_slider_dims for d in order]): + raise IndexError( + f"all `window_order` entries must be <= n_slider_dims\n" + f"`n_slider_dims` is: {self.n_slider_dims}, you have passed `window_order`: {order}" + ) + + if not all([d >= 0 for d in order]): + raise IndexError( + f"all `window_order` entires must be >= 0, you have passed: {order}" + ) + + self._window_order = tuple(order) + self._recompute_histogram() + + @property + def spatial_func(self) -> Callable[[ArrayLike], ArrayLike] | None: + """get or set a spatial_func function, see docstring for details""" + return self._spatial_func + + @spatial_func.setter + def spatial_func(self, func: Callable[[ArrayLike], ArrayLike] | 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}" + ) + + self._spatial_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, indices: 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 + # clamp to max bounds + indexer = list() + for dim, i in enumerate(indices): + i = min(self.shape[dim] - 1, i) + indexer.append(i) + + return self.data[tuple(indexer)] + + # order in which window funcs are applied + order = self._window_order + + if order is not None: + # remove any entries in `window_order` where the specified dim + # has a window function or window size specified as `None` + # example: + # window_sizes = (3, 2) + # window_funcs = (np.mean, None) + # order = (0, 1) + # `1` is removed from the order since that window_func is `None` + order = tuple( + d for d in order if winds[d] is not None and funcs[d] is not None + ) + else: + # sequential order + order = list() + for d in range(self.n_slider_dims): + if winds[d] is not None and funcs[d] is not None: + order.append(d) + + # the final indexer which will be used on the data array + indexer = list() + + for dim_index, (i, w, f) in enumerate(zip(indices, winds, funcs)): + # clamp i within the max bounds + i = min(self.shape[dim_index] - 1, i) + + if (w is not None) and (f is not None): + # specify slice window if both window size and function for this dim are not None + hw = int((w - 1) / 2) # half window + + # start index cannot be less than 0 + start = max(0, i - hw) + + # stop index cannot exceed the bounds of this dimension + stop = min(self.shape[dim_index] - 1, i + hw) + + s = slice(start, stop, 1) + else: + s = slice(i, i + 1, 1) + + indexer.append(s) + + # apply indexer to slice data with the specified windows + data_sliced = self.data[tuple(indexer)] + + # finally apply the window functions in the specified order + for dim in order: + f = funcs[dim] + + data_sliced = f(data_sliced, axis=dim, keepdims=True) + + return data_sliced + + def get(self, indices: tuple[int, ...]) -> ArrayLike | None: + """ + 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 tuple specifying a single integer + index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. + + Parameters + ---------- + indices: tuple[int, ...] + Get the processed data at this index. Must provide a value for each dimension. + 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( + 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(indices).squeeze() + else: + # data is a static image or volume + window_output = self.data + + # 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 `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: + # check that output ndim after window functions matches display dims + final_output = window_output + 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}{' with rgb(a) channels' if self.rgb else ''}, " + f"expected {self.n_display_dims} dims" + ) + + return final_output + + def _recompute_histogram(self): + """ + + Returns + ------- + (histogram_values, bin_edges) + + """ + if not self._compute_histogram or self.data is None: + self._histogram = None + return + + 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 + else: + 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_real, bins=100) diff --git a/fastplotlib/widgets/image_widget/_properties.py b/fastplotlib/widgets/image_widget/_properties.py new file mode 100644 index 000000000..060314439 --- /dev/null +++ b/fastplotlib/widgets/image_widget/_properties.py @@ -0,0 +1,139 @@ +from pprint import pformat +from typing import Iterable + +import numpy as np + +from ._processor import NDImageProcessor + + +class ImageWidgetProperty: + __class_getitem__ = classmethod(type(list[int])) + + def __init__( + self, + image_widget, + attribute: str, + ): + self._image_widget = image_widget + self._image_processors: list[NDImageProcessor] = image_widget._image_processors + self._attribute = attribute + + 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): + 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}") + + return key + + 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) + + # if it's a slice + processors = self._image_processors[key] + + return tuple(getattr(p, self._attribute) for p in processors) + + 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[:])}" + + def __eq__(self, other): + return self[:] == other + + +class Indices: + def __init__( + self, + indices: list[int], + image_widget, + ): + self._data = indices + + self._image_widget = image_widget + + def __iter__(self): + for i in self._data: + yield i + + 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 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}" + ) + + 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/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 393b13273..1945b8cfb 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -11,50 +11,66 @@ 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: list[bool] = [False] * n_sliders # approximate framerate for playing - self._fps: dict[str, int] = {"t": 20, "z": 20} + self._fps: list[int] = [20] * n_sliders + # framerate converted to frame time - self._frame_time: dict[str, float] = {"t": 1 / 20, "z": 1 / 20} + self._frame_time: list[float] = [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: list[float] = [perf_counter()] * 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""" + self.pause = False + + def pop_dim(self): + """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""" # 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)} + # set 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""" - # store the new index of the image widget ("t" and "z") - new_index = dict() - - # 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() @@ -72,7 +88,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]: @@ -83,7 +99,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.indices[dim] + 1) self._last_frame_time[dim] = now else: @@ -97,12 +113,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.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.current_index[dim] + 1) + self.set_index(dim, self._image_widget.indices[dim] + 1) imgui.same_line() # stop button @@ -137,10 +153,15 @@ def update(self): self._fps[dim] = value self._frame_time[dim] = 1 / value - val = self._image_widget.current_index[dim] - vmax = self._image_widget._dims_max_bounds[dim] - 1 + val = self._image_widget.indices[dim] + vmax = self._image_widget.bounds[dim] - 1 + + 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}: ") + imgui.text(f"dim '{dim_name}:' ") imgui.same_line() # so that slider occupies full width imgui.set_next_item_width(self.width * 0.85) @@ -154,18 +175,12 @@ 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 ) - new_index[dim] = index - - # if the slider value changed for this dimension - flag_index_changed |= changed + if changed: + new_indices = list(self._image_widget.indices) + new_indices[dim] = index + self._image_widget.indices = new_indices imgui.pop_id() - - 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.size = int(imgui.get_window_height()) From 777a1d507e1995b4aaa603fc7eaa5166f5bebb1d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 16 Feb 2026 08:42:03 -0500 Subject: [PATCH 027/163] update --- fastplotlib/utils/__init__.py | 2 +- fastplotlib/widgets/nd_widget/__init__.py | 1 + fastplotlib/widgets/nd_widget/_nd_image.py | 624 +++++++++++++++++++++ fastplotlib/widgets/nd_widget/nd_image.py | 13 - 4 files changed, 626 insertions(+), 14 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/_nd_image.py delete mode 100644 fastplotlib/widgets/nd_widget/nd_image.py diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index 8001ae375..6f0059f6a 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -6,7 +6,7 @@ from .gpu import enumerate_adapters, select_adapter, print_wgpu_report from ._plot_helpers import * from .enums import * -from ._protocols import ArrayProtocol +from ._protocols import ArrayProtocol, ARRAY_LIKE_ATTRS @dataclass diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index 70c2e7621..352df09a8 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -1,2 +1,3 @@ from .processor_base import NDProcessor from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras +from ._nd_image import NDImageProcessor, NDImage diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py new file mode 100644 index 000000000..e3a3a4f80 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -0,0 +1,624 @@ +import inspect +from typing import Literal, Callable, Type, Any +from warnings import warn + +import numpy as np +from numpy.typing import ArrayLike + +from ...utils import subsample_array, ArrayProtocol, ARRAY_LIKE_ATTRS +from ...graphics import ImageGraphic, ImageVolumeGraphic +from .processor_base import NDProcessor + +# must take arguments: array-like, `axis`: int, `keepdims`: bool +WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] + + +class NDImageProcessor(NDProcessor): + def __init__( + self, + data: ArrayLike | None, + n_display_dims: Literal[2, 3] = 2, + rgb: bool = False, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_sizes: tuple[int | None, ...] | int = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayLike], ArrayLike] = None, + compute_histogram: bool = True, + index_mappings = None, + ): + """ + An ND image that supports computing window functions, and functions over spatial dimensions. + + Parameters + ---------- + data: ArrayLike + array-like data, must have 2 or more dimensions + + n_display_dims: int, 2 or 3, default 2 + number of display dimensions + + rgb: bool, default False + whether the image data is RGB(A) or not + + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable, optional + A function or a ``tuple`` of functions that are applied to a rolling window of the data. + + 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. + + 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``. + + The output array-like type from a window function **must** support a ``.squeeze()`` method, but the + function itself should NOT squeeze the output array. + + window_sizes: tuple[int | None, ...], optional + ``tuple`` of ``int`` that specifies the window size for each dimension. + + 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. + + 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 spatial_func changes. + Disable if slow. + + """ + # set as False until data, window funcs stuff and spatial func is all set + self._compute_histogram = False + + 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._spatial_func = spatial_func + + self._compute_histogram = compute_histogram + self._recompute_histogram() + + self._index_mappings = self._validate_index_mappings(index_mappings) + + @property + 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 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}, 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 + self._recompute_histogram() + + @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 + 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""" + if self._data is None: + return 0 + + return self.ndim - self.n_display_dims - int(self.rgb) + + @property + 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 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""" + 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 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() + + @property + def max_n_display_dims(self) -> int: + """maximum number of possible display dims""" + # 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]: + """tuple indicating the display dimension indices""" + return tuple(range(self.data.ndim))[self.n_slider_dims :] + + @property + def window_funcs( + self, + ) -> tuple[WindowFuncCallable | None, ...] | None: + """get or set window functions, see docstring for details""" + return self._window_funcs + + @window_funcs.setter + def window_funcs( + self, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None, + ): + if window_funcs is None: + self._window_funcs = None + return + + if callable(window_funcs): + window_funcs = (window_funcs,) + + # if all are None + if all([f is None for f in window_funcs]): + self._window_funcs = None + return + + self._validate_window_func(window_funcs) + + self._window_funcs = tuple(window_funcs) + self._recompute_histogram() + + def _validate_window_func(self, funcs): + if isinstance(funcs, (tuple, list)): + for f in funcs: + if f is None: + pass + elif callable(f): + sig = inspect.signature(f) + + if "axis" not in sig.parameters or "keepdims" not in sig.parameters: + raise TypeError( + f"Each window function must take an `axis` and `keepdims` argument, " + f"you passed: {f} with the following function signature: {sig}" + ) + else: + raise TypeError( + f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {funcs}" + ) + + if not (len(funcs) == self.n_slider_dims or self.n_slider_dims == 0): + raise IndexError( + f"number of `window_funcs` must be the same as the number of slider dims: {self.n_slider_dims}, " + f"and you passed {len(funcs)} `window_funcs`: {funcs}" + ) + + @property + def window_sizes(self) -> tuple[int | None, ...] | None: + """get or set window sizes used for the corresponding window functions, see docstring for details""" + return self._window_sizes + + @window_sizes.setter + def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): + if window_sizes is None: + self._window_sizes = None + return + + if isinstance(window_sizes, int): + window_sizes = (window_sizes,) + + # if all are None + if all([w is None for w in window_sizes]): + self._window_sizes = None + return + + if not all([isinstance(w, (int)) or w is None for w in window_sizes]): + raise TypeError( + f"`window_sizes` must be of type: tuple[int | None, ...] | int | None, you have passed: {window_sizes}" + ) + + if not (len(window_sizes) == self.n_slider_dims or self.n_slider_dims == 0): + raise IndexError( + f"number of `window_sizes` must be the same as the number of slider dims, " + f"i.e. `data.ndim` - n_display_dims, your data array has {self.ndim} dimensions " + f"and you passed {len(window_sizes)} `window_sizes`: {window_sizes}" + ) + + # make all window sizes are valid numbers + _window_sizes = list() + for i, w in enumerate(window_sizes): + if w is None: + _window_sizes.append(None) + continue + + if w < 0: + raise ValueError( + f"negative window size passed, all `window_sizes` must be positive " + f"integers or `None`, you passed: {_window_sizes}" + ) + + if w == 0 or w == 1: + # this is not a real window, set as None + w = None + + elif w % 2 == 0: + # odd window sizes makes most sense + warn( + f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" + ) + w += 1 + + _window_sizes.append(w) + + self._window_sizes = tuple(_window_sizes) + self._recompute_histogram() + + @property + def window_order(self) -> tuple[int, ...] | None: + """get or set dimension order in which window functions are applied""" + return self._window_order + + @window_order.setter + def window_order(self, order: tuple[int] | None): + if order is None: + self._window_order = None + return + + if order is not None: + if not all([d <= self.n_slider_dims for d in order]): + raise IndexError( + f"all `window_order` entries must be <= n_slider_dims\n" + f"`n_slider_dims` is: {self.n_slider_dims}, you have passed `window_order`: {order}" + ) + + if not all([d >= 0 for d in order]): + raise IndexError( + f"all `window_order` entires must be >= 0, you have passed: {order}" + ) + + self._window_order = tuple(order) + self._recompute_histogram() + + @property + def spatial_func(self) -> Callable[[ArrayLike], ArrayLike] | None: + """get or set a spatial_func function, see docstring for details""" + return self._spatial_func + + @spatial_func.setter + def spatial_func(self, func: Callable[[ArrayLike], ArrayLike] | 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}" + ) + + self._spatial_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, indices: 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 + # clamp to max bounds + indexer = list() + for dim, i in enumerate(indices): + i = min(self.shape[dim] - 1, i) + indexer.append(i) + + return self.data[tuple(indexer)] + + # order in which window funcs are applied + order = self._window_order + + if order is not None: + # remove any entries in `window_order` where the specified dim + # has a window function or window size specified as `None` + # example: + # window_sizes = (3, 2) + # window_funcs = (np.mean, None) + # order = (0, 1) + # `1` is removed from the order since that window_func is `None` + order = tuple( + d for d in order if winds[d] is not None and funcs[d] is not None + ) + else: + # sequential order + order = list() + for d in range(self.n_slider_dims): + if winds[d] is not None and funcs[d] is not None: + order.append(d) + + # the final indexer which will be used on the data array + indexer = list() + + for dim_index, (i, w, f) in enumerate(zip(indices, winds, funcs)): + # clamp i within the max bounds + i = min(self.shape[dim_index] - 1, i) + + if (w is not None) and (f is not None): + # specify slice window if both window size and function for this dim are not None + hw = int((w - 1) / 2) # half window + + # start index cannot be less than 0 + start = max(0, i - hw) + + # stop index cannot exceed the bounds of this dimension + stop = min(self.shape[dim_index] - 1, i + hw) + + s = slice(start, stop, 1) + else: + s = slice(i, i + 1, 1) + + indexer.append(s) + + # apply indexer to slice data with the specified windows + data_sliced = self.data[tuple(indexer)] + + # finally apply the window functions in the specified order + for dim in order: + f = funcs[dim] + + data_sliced = f(data_sliced, axis=dim, keepdims=True) + + return data_sliced + + def get(self, indices: tuple[int, ...]) -> ArrayLike | None: + """ + 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 tuple specifying a single integer + index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. + + Parameters + ---------- + indices: tuple[int, ...] + Get the processed data at this index. Must provide a value for each dimension. + Example: get((100, 5)) + + """ + if self.data is None: + return None + + # apply any slider index mappings + indices = tuple([m(i) for m, i in zip(self.index_mappings, indices)]) + + if self.n_slider_dims != 0: + if len(indices) != self.n_slider_dims: + raise IndexError( + 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(indices).squeeze() + else: + # data is a static image or volume + window_output = self.data + + # 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 `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: + # check that output ndim after window functions matches display dims + final_output = window_output + 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}{' with rgb(a) channels' if self.rgb else ''}, " + f"expected {self.n_display_dims} dims" + ) + + return final_output + + def _recompute_histogram(self): + """ + + Returns + ------- + (histogram_values, bin_edges) + + """ + if not self._compute_histogram or self.data is None: + self._histogram = None + return + + 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 + else: + 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_real, bins=100) + + +class NDImage: + def __init__( + self, + data: Any, + *args, + graphic: type[ImageGraphic, ImageVolumeGraphic] = None, + processor: type[NDImageProcessor] = NDImageProcessor, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + window_sizes: tuple[int | None] | None = None, + index_mappings: tuple[Callable[[Any], int] | None] | None = None, + graphic_kwargs: dict = None, + processor_kwargs: dict = None, + ): + if processor_kwargs is None: + processor_kwargs = dict() + + self._processor = processor( + data, + *args, + window_funcs=window_funcs, + window_sizes=window_sizes, + index_mappings=index_mappings, + **processor_kwargs, + ) + + self._indices = tuple([0] * self._processor.n_slider_dims) + + self._graphic = None + + self._create_graphic() + + @property + def processor(self) -> NDImageProcessor: + return self._processor + + @property + def graphic( + self, + ) -> ( + ImageGraphic | ImageVolumeGraphic + ): + """LineStack or ImageGraphic for heatmaps""" + return self._graphic + + @graphic.setter + def graphic(self, graphic_type): + # TODO implement if graphic type changes to custom user subclass + pass + + def _create_graphic(self): + match self.processor.n_display_dims: + case 2: + cls = ImageGraphic + case 3: + cls = ImageVolumeGraphic + + data_slice = self.processor.get(self.indices) + + old_graphic = self._graphic + new_graphic = cls(data_slice) + + if old_graphic is not None: + g = self._graphic + plot_area = g._plot_area + self._graphic._plot_area.delete_graphic(g) + plot_area.add_graphic(self._graphic) + + self._graphic = new_graphic + + @property + def n_display_dims(self) -> Literal[2, 3]: + return self.processor.n_display_dims + + @n_display_dims.setter + def n_display_dims(self, n: Literal[2 , 3]): + self.processor.n_display_dims = n + + self._create_graphic() + + @property + def indices(self) -> tuple: + return self._indices + + @indices.setter + def indices(self, indices): + data_slice = self.processor.get(indices) + + self.graphic.data = data_slice + + self._indices = indices + + def _tooltip_handler(self, graphic, pick_info): + # get graphic within the collection + n_index = np.argwhere(self.graphic.graphics == graphic).item() + p_index = pick_info["vertex_index"] + return self.processor.tooltip_format(n_index, p_index) diff --git a/fastplotlib/widgets/nd_widget/nd_image.py b/fastplotlib/widgets/nd_widget/nd_image.py deleted file mode 100644 index 4972db9d5..000000000 --- a/fastplotlib/widgets/nd_widget/nd_image.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Literal - -from .processor_base import NDProcessor - - -class NDImageProcessor(NDProcessor): - @property - def n_display_dims(self) -> Literal[2, 3]: - pass - - def _validate_n_display_dims(self, n_display_dims): - if n_display_dims not in (2, 3): - raise ValueError("`n_display_dims` must be") From 13557336a13d6f0ec23cc0342644192f2ad04d99 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 17 Feb 2026 01:29:19 -0500 Subject: [PATCH 028/163] basic minimal ndw orchestration working --- fastplotlib/__init__.py | 2 +- fastplotlib/widgets/__init__.py | 3 +- fastplotlib/widgets/nd_widget/__init__.py | 17 +- fastplotlib/widgets/nd_widget/_nd_image.py | 10 +- .../widgets/nd_widget/_nd_positions/core.py | 14 +- .../nd_widget/{processor_base.py => base.py} | 23 ++ fastplotlib/widgets/nd_widget/ndwidget.py | 214 ++++++++++++++++++ 7 files changed, 266 insertions(+), 17 deletions(-) rename fastplotlib/widgets/nd_widget/{processor_base.py => base.py} (94%) create mode 100644 fastplotlib/widgets/nd_widget/ndwidget.py diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 6dab91605..bde2c89e3 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -19,7 +19,7 @@ else: from .layouts import Figure -from .widgets import ImageWidget +from .widgets import NDWidget, ImageWidget from .utils import config, enumerate_adapters, select_adapter, print_wgpu_report diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index 766620ea6..04102dbdf 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -1,3 +1,4 @@ +from .nd_widget import NDWidget from .image_widget import ImageWidget -__all__ = ["ImageWidget"] +__all__ = ["NDWidget", "ImageWidget"] diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index 352df09a8..7855327d9 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -1,3 +1,14 @@ -from .processor_base import NDProcessor -from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras -from ._nd_image import NDImageProcessor, NDImage +from ...layouts import IMGUI + +if IMGUI: + from .base import NDProcessor + from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras + from ._nd_image import NDImageProcessor, NDImage + from .ndwidget import NDWidget +else: + class NDWidget: + def __init__(self, *args, **kwargs): + raise ModuleNotFoundError( + "NDWidget requires `imgui-bundle` to be installed.\n" + "pip install imgui-bundle" + ) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index e3a3a4f80..3e54814b2 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -7,10 +7,7 @@ from ...utils import subsample_array, ArrayProtocol, ARRAY_LIKE_ATTRS from ...graphics import ImageGraphic, ImageVolumeGraphic -from .processor_base import NDProcessor - -# must take arguments: array-like, `axis`: int, `keepdims`: bool -WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] +from .base import NDProcessor, NDGraphic, WindowFuncCallable class NDImageProcessor(NDProcessor): @@ -526,7 +523,7 @@ def _recompute_histogram(self): self._histogram = np.histogram(sub_real, bins=100) -class NDImage: +class NDImage(NDGraphic): def __init__( self, data: Any, @@ -538,6 +535,7 @@ def __init__( index_mappings: tuple[Callable[[Any], int] | None] | None = None, graphic_kwargs: dict = None, processor_kwargs: dict = None, + name: str = None, ): if processor_kwargs is None: processor_kwargs = dict() @@ -557,6 +555,8 @@ def __init__( self._create_graphic() + self._name = name + @property def processor(self) -> NDImageProcessor: return self._processor diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index b95916ce8..cd19bf2a5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -16,7 +16,7 @@ ScatterGraphic, ScatterCollection, ) -from ..processor_base import NDProcessor, WindowFuncCallable +from ..base import NDProcessor, NDGraphic, WindowFuncCallable # TODO: Maybe get rid of n_display_dims in NDProcessor, @@ -210,7 +210,8 @@ def _get_dw_slices(self, indices) -> tuple[slice] | tuple[slice, slice]: if index_p_start >= index_p_stop: index_p_stop = index_p_start + 1 - slices = [slice(index_p_start, index_p_stop)] + # round to the nearest integer since to use as arra indices + slices = [slice(round(index_p_start), round(index_p_stop))] if self.multi: slices.insert(0, slice(None)) @@ -225,19 +226,18 @@ def get(self, indices: tuple[Any, ...]): index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. """ # apply any slider index mappings - indices = tuple([m(i) for m, i in zip(self.index_mappings, indices)]) + array_indices = tuple([m(i) for m, i in zip(self.index_mappings, indices)]) - if len(indices) > 1: + if len(array_indices) > 1: # there are dims in addition to the n_datapoints dim # apply window funcs # window_output array should be of shape [n_datapoints, 2 | 3] - window_output = self._apply_window_functions(indices[:-1]).squeeze() + window_output = self._apply_window_functions(array_indices[:-1]).squeeze() else: window_output = self.data - # TODO: window function on the `p` n_datapoints dimension - if self.display_window is not None: + # display_window is in reference units slices = self._get_dw_slices(indices) # if self.display_window is not None: diff --git a/fastplotlib/widgets/nd_widget/processor_base.py b/fastplotlib/widgets/nd_widget/base.py similarity index 94% rename from fastplotlib/widgets/nd_widget/processor_base.py rename to fastplotlib/widgets/nd_widget/base.py index a1cd5311c..e46386e93 100644 --- a/fastplotlib/widgets/nd_widget/processor_base.py +++ b/fastplotlib/widgets/nd_widget/base.py @@ -6,6 +6,7 @@ from numpy.typing import ArrayLike from ...utils import subsample_array, ArrayProtocol +from ...graphics import Graphic # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] @@ -249,3 +250,25 @@ def _validate_index_mappings(self, maps): def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: pass + + +class NDGraphic: + @property + def name(self) -> str: + return self._name + + @property + def processor(self) -> NDProcessor: + raise NotImplementedError + + @property + def graphic(self) -> Graphic: + raise NotImplementedError + + @property + def indices(self) -> tuple[Any]: + raise NotImplementedError + + @indices.setter + def indices(self, new: tuple): + raise NotImplementedError diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py new file mode 100644 index 000000000..2932fa18d --- /dev/null +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -0,0 +1,214 @@ +from dataclasses import dataclass +import os +from time import perf_counter +from typing import Any, Sequence + +from imgui_bundle import imgui, icons_fontawesome_6 as fa +import numpy as np + +from ...layouts import ImguiFigure, Subplot +from ...graphics import ScatterCollection, LineCollection, LineStack, ImageGraphic +from ...ui import EdgeWindow +from .base import NDGraphic, NDProcessor +from ._nd_image import NDImage, NDImageProcessor +from ._nd_positions import NDPositions, NDPositionsProcessor + + +@dataclass +class ReferenceRangeContinuous: + start: int | float + stop: int | float + step: int | float + unit: str + + def __getitem__(self, index: int): + """return the value at the index w.r.t. the step size""" + # if index is negative, turn to positive index + if index < 0: + raise ValueError("negative indexing not supported") + + val = self.start + (self.step * index) + if not self.start <= val <= self.stop: + raise IndexError(f"index: {index} value: {val} out of bounds: [{self.start}, {self.stop}]") + + return val + + +@dataclass +class ReferenceRangeDiscrete: + options: Sequence[Any] + unit: str + + def __getitem__(self, index: int): + if index > len(self.options): + raise IndexError + + return self.options[index] + + def __len__(self): + return len(self.options) + + +class NDWSubplot: + def __init__(self, ndw, subplot: Subplot): + self.ndw = ndw + self._subplot = subplot + + self._nd_graphics = list() + + @property + def nd_graphics(self) -> list[NDGraphic]: + return self._nd_graphics + + def __getitem__(self, key): + if isinstance(key, (int, np.integer)): + return self.nd_graphics[key] + + for g in self.nd_graphics: + if g.name == key: + return g + + else: + raise KeyError(f"NDGraphc with given key not found: {key}") + + def add_nd_image(self, *args, **kwargs): + nd = NDImage(*args, **kwargs) + self._nd_graphics.append(nd) + self._subplot.add_graphic(nd.graphic) + return nd + + def add_nd_scatter(self, *args, **kwargs): + nd = NDPositions(*args, graphic=ScatterCollection, multi=True, **kwargs) + self._nd_graphics.append(nd) + self._subplot.add_graphic(nd.graphic) + + return nd + + def add_nd_timeseries(self, *args, graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, **kwargs): + nd = NDPositions(*args, graphic=LineStack, multi=True, **kwargs) + self._nd_graphics.append(nd) + self._subplot.add_graphic(nd.graphic) + # TODO: think about auto-xrange for subplot camera + return nd + + def add_nd_lines(self, *args, **kwargs): + nd = NDPositions(*args, graphic=LineCollection, multi=True, **kwargs) + self._nd_graphics.append(nd) + self._subplot.add_graphic(nd.graphic) + return nd + + # def __repr__(self): + # return "NDWidget Subplot" + # + # def __str__(self): + # return "NDWidget Subplot" + + +class NDWSliders(EdgeWindow): + def __init__(self, figure, size, ndwidget): + super().__init__(figure=figure, size=size, title="NDWidget controls", location="bottom") + self._ndwidget = ndwidget + + # n_sliders = self._image_widget.n_sliders + # + # # whether or not a dimension is in play mode + # self._playing: list[bool] = [False] * n_sliders + # + # # approximate framerate for playing + # self._fps: list[int] = [20] * n_sliders + # + # # framerate converted to frame time + # self._frame_time: list[float] = [1 / 20] * n_sliders + # + # # last timepoint that a frame was displayed from a given dimension + # self._last_frame_time: list[float] = [perf_counter()] * n_sliders + # + # # loop playback + # self._loop = False + # + # # 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 + # + # self.pause = False + + def update(self): + indices_changed = False + + for dim_index, (current_index, refr) in enumerate(zip(self._ndwidget.indices, self._ndwidget.ref_ranges)): + if isinstance(refr, ReferenceRangeContinuous): + changed, val = imgui.slider_float( + v=current_index, + v_min=refr.start, + v_max=refr.stop, + label=refr.unit + ) + + if changed: + new_indices = list(self._ndwidget.indices) + new_indices[dim_index] = val + + indices_changed = True + + if indices_changed: + self._ndwidget.indices = tuple(new_indices) + + +class NDWidget: + def __init__(self, ref_ranges: list[tuple], **kwargs): + self._ref_ranges = list() + + for r in ref_ranges: + if len(r) == 4: + # assume start, stop, step, unit + refr = ReferenceRangeContinuous(*r) + elif len(r) == 2: + refr = ReferenceRangeDiscrete(*r) + else: + raise ValueError + + self._ref_ranges.append(refr) + + self._figure = ImguiFigure(**kwargs) + + self._subplots: dict[Subplot, NDWSubplot] = dict() + for subplot in self.figure: + self._subplots[subplot] = NDWSubplot(self, subplot) + + # starting index for all dims + self._indices = tuple(refr[0] for refr in self.ref_ranges) + + # hard code the expected height so that the first render looks right in tests, docs etc. + ui_size = 57 + (50 * len(self.indices)) + + self._sliders_ui = NDWSliders(self.figure, ui_size, self) + self.figure.add_gui(self._sliders_ui) + + @property + def figure(self) -> ImguiFigure: + return self._figure + + @property + def ref_ranges(self) -> tuple[ReferenceRangeContinuous | ReferenceRangeDiscrete]: + return tuple(self._ref_ranges) + + @property + def indices(self) -> tuple: + return self._indices + + @indices.setter + def indices(self, new_indices: tuple[Any]): + for subplot in self._subplots.values(): + for ndg in subplot.nd_graphics: + ndg.indices = new_indices + + self._indices = new_indices + + def __getitem__(self, key): + subplot = self.figure[key] + return self._subplots[subplot] + + def show(self, **kwargs): + return self.figure.show(**kwargs) \ No newline at end of file From 78878b1b072d37d4e87695d24c17546a91f9225a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 17 Feb 2026 18:10:26 -0500 Subject: [PATCH 029/163] implement auto-x for timeseries --- fastplotlib/layouts/_plot_area.py | 36 +++++++++++++++++++ .../widgets/nd_widget/_nd_positions/core.py | 17 +++++++-- fastplotlib/widgets/nd_widget/ndwidget.py | 2 +- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 405a01546..8ca914717 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -860,6 +860,42 @@ def _auto_scale_scene( camera.zoom = zoom + @property + def x_range(self) -> tuple[float, float]: + """ + Get or set the x-range currently in view. + Only valid for orthographic projections of the xy plane. + Use camera.set_state() to set the camera position for arbitrary projections. + """ + hw = self.camera.width / 2 + x = self.camera.local.x + return x - hw, x + hw + + @x_range.setter + def x_range(self, xr: tuple[float, float]): + width = xr[1] - xr[0] + x_mid = xr[0] + (width / 2) + self.camera.width = width + self.camera.local.x = x_mid + + @property + def y_range(self) -> tuple[float, float]: + """ + Get or set the y-range currently in view. + Only valid for orthographic projections of the xy plane. + Use camera.set_state() to set the camera position for arbitrary projections. + """ + hh = self.camera.width / 2 + y = self.camera.local.y + return y - hh, y + hh + + @y_range.setter + def y_range(self, yr: tuple[float, float]): + width = yr[1] - yr[0] + y_mid = yr[0] + (width / 2) + self.camera.width = width + self.camera.local.y = y_mid + def remove_graphic(self, graphic: Graphic): """ Remove a ``Graphic`` from the scene. Note: This does not garbage collect the graphic, diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index cd19bf2a5..6717bccd2 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -353,6 +353,7 @@ def __init__( window_sizes: tuple[int | None] | None = None, index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, + auto_x_range: bool = False, graphic_kwargs: dict = None, processor_kwargs: dict = None, ): @@ -378,6 +379,8 @@ def __init__( self._indices = tuple([0] * self._processor.n_slider_dims) + self._auto_x_range = auto_x_range + self._create_graphic(graphic) @property @@ -433,6 +436,12 @@ def indices(self, indices): image_data, x0, x_scale = self._create_heatmap_data(data_slice) self.graphic.data = image_data self.graphic.offset = (x0, *self.graphic.offset[1:]) + self.graphic.scale = (x_scale, *self.graphic.scale[1:]) + + # x range of the data + xr = data_slice[0, 0, 0], data_slice[0, -1, 0] + if self._auto_x_range: + self.graphic._plot_area.x_range = xr self._indices = indices @@ -504,11 +513,13 @@ def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: # x is sufficiently uniform y_interp = data_slice[..., 1] - # assume all x values are the same - x_scale = data_slice[:, -1, 0][0] / data_slice.shape[1] - x0 = data_slice[0, 0, 0] + # assume all x values are the same across all lines + # otherwise a heatmap representation makes no sense anyways + x_stop = data_slice[:, -1, 0][0] + x_scale = (x_stop - x0) / data_slice.shape[1] + return y_interp, x0, x_scale @property diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index 2932fa18d..dd8610849 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -85,7 +85,7 @@ def add_nd_scatter(self, *args, **kwargs): return nd def add_nd_timeseries(self, *args, graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, **kwargs): - nd = NDPositions(*args, graphic=LineStack, multi=True, **kwargs) + nd = NDPositions(*args, graphic=graphic, multi=True, auto_x_range=True,**kwargs) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) # TODO: think about auto-xrange for subplot camera From 3ad64fa8908e219e6ad885fb6b1113226dd707cc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Feb 2026 02:12:09 -0500 Subject: [PATCH 030/163] bugfix update worldobject -> graphic map for image tiles --- fastplotlib/graphics/_base.py | 36 ++++++++++++++++++++++++++--------- fastplotlib/graphics/image.py | 6 ++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index e0602e4e3..abc3c4cad 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -291,15 +291,8 @@ def _set_world_object(self, wo: pygfx.WorldObject): # add to world object -> graphic mapping if isinstance(wo, pygfx.Group): - for child in wo.children: - if isinstance( - child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line) - ): - # unique 32 bit integer id for each world object - global_id = child.id - WORLD_OBJECT_TO_GRAPHIC[global_id] = self - # store id to pop from dict when graphic is deleted - self._world_object_ids.append(global_id) + # for Graphics which use a pygfx.Group, ImageGraphic and graphic collections + self._add_group_graphic_map(wo) else: global_id = wo.id WORLD_OBJECT_TO_GRAPHIC[global_id] = self @@ -324,6 +317,31 @@ def _set_world_object(self, wo: pygfx.WorldObject): if not all(wo.world.rotation == self.rotation): self.rotation = self.rotation + def _add_group_graphic_map(self, wo: pygfx.Group): + # add the children of the group to the WorldObject -> Graphic map + # used by images since they create new WorldObject ImageTiles when a different buffer size is required + # also used by GraphicCollections inititally, but not used for reseting like images + for child in wo.children: + if isinstance( + child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line) + ): + # unique 32 bit integer id for each world object + global_id = child.id + WORLD_OBJECT_TO_GRAPHIC[global_id] = self + # store id to pop from dict when graphic is deleted + self._world_object_ids.append(global_id) + + def _remove_group_graphic_map(self, wo: pygfx.Group): + # remove the children of the group to the WorldObject -> Graphic map + for child in wo.children: + if isinstance( + child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line) + ): + # unique 32 bit integer id for each world object + global_id = child.id + WORLD_OBJECT_TO_GRAPHIC.pop(global_id) + self._world_object_ids.remove(global_id) + @property def tooltip_format(self) -> Callable[[dict], str] | None: """ diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 7b670d531..6dfb52238 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -261,6 +261,9 @@ def data(self, data): self._material.clim = quick_min_max(self.data.value) + # remove tiles from the WorldObject -> Graphic map + self._remove_group_graphic_map(self.world_object) + # clear image tiles self.world_object.clear() @@ -268,6 +271,9 @@ def data(self, data): for tile in self._create_tiles(): self.world_object.add(tile) + # add new tiles to WorldObject -> Graphic map + self._add_group_graphic_map(self.world_object) + return self._data[:] = data From 75361c0de940e88388113ade492f87053a73d499 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Feb 2026 02:12:26 -0500 Subject: [PATCH 031/163] bugfix linear selector set limits --- fastplotlib/graphics/selectors/_linear.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 0c956d57b..4ea454ee8 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -45,10 +45,8 @@ def limits(self, values: tuple[float, float]): # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)): raise TypeError("limits must be an iterable of two numeric values") - self._limits = tuple( - map(round, values) - ) # if values are close to zero things get weird so round them - self.selection._limits = self._limits + self._limits = np.asarray(values) # if values are close to zero things get weird so round them + self._selection._limits = self._limits @property def edge_color(self) -> pygfx.Color: From 38481c0ff783eec95d49a8a989cfb458d64b822c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Feb 2026 02:13:33 -0500 Subject: [PATCH 032/163] linear selector for timeseries --- .../widgets/nd_widget/_nd_positions/core.py | 23 +++++++++++++++ fastplotlib/widgets/nd_widget/ndwidget.py | 29 ++++++++++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index 6717bccd2..c763f9100 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -16,6 +16,8 @@ ScatterGraphic, ScatterCollection, ) +from ....graphics.utils import pause_events +from ....graphics.selectors import LinearSelector from ..base import NDProcessor, NDGraphic, WindowFuncCallable @@ -354,6 +356,7 @@ def __init__( index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, auto_x_range: bool = False, + linear_selector: bool = False, graphic_kwargs: dict = None, processor_kwargs: dict = None, ): @@ -383,6 +386,13 @@ def __init__( self._create_graphic(graphic) + if linear_selector: + self._linear_selector = LinearSelector(0, limits=(-np.inf, np.inf), edge_color="cyan") + else: + self._linear_selector = None + + self._pause = False + @property def processor(self) -> NDPositionsProcessor: return self._processor @@ -418,6 +428,9 @@ def indices(self) -> tuple: @indices.setter def indices(self, indices): + if self._pause: + return + data_slice = self.processor.get(indices) if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): @@ -443,8 +456,18 @@ def indices(self, indices): if self._auto_x_range: self.graphic._plot_area.x_range = xr + if self._linear_selector is not None: + with pause_events(self._linear_selector):#, event_handlers=[self._set_indices_from_selector]): + self._linear_selector.limits = xr + self._linear_selector.selection = indices[-1] + # self._set_linear_selector(x_mid, limits=xr) + self._indices = indices + # def _set_linear_selector(self, x_mid, limits): + # self._linear_selector.selection = x_mid + # self._linear_selector.limits = limits + def _tooltip_handler(self, graphic, pick_info): if isinstance(self.graphic, (LineCollection, ScatterCollection)): # get graphic within the collection diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index dd8610849..fd2491be7 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from functools import partial import os from time import perf_counter from typing import Any, Sequence @@ -84,11 +85,18 @@ def add_nd_scatter(self, *args, **kwargs): return nd - def add_nd_timeseries(self, *args, graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, **kwargs): - nd = NDPositions(*args, graphic=graphic, multi=True, auto_x_range=True,**kwargs) + def add_nd_timeseries( + self, + *args, + graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, + **kwargs + ): + nd = NDPositions(*args, graphic=graphic, multi=True, auto_x_range=True, linear_selector=True, **kwargs) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) - # TODO: think about auto-xrange for subplot camera + self._subplot.add_graphic(nd._linear_selector) + nd._linear_selector.add_event_handler(partial(self._set_indices_from_selector, nd), "selection") + return nd def add_nd_lines(self, *args, **kwargs): @@ -97,6 +105,19 @@ def add_nd_lines(self, *args, **kwargs): self._subplot.add_graphic(nd.graphic) return nd + def _set_indices_from_selector(self, skip_graphic: NDGraphic, ev): + # skip the NDPosition object which has the linear selector that triggered this event + skip_graphic._pause = True + + x = ev.info["value"] + indices_new = list(self.ndw.indices) + # linear selector for NDPositions always acts on the `p` dim + indices_new[-1] = x + self.ndw.indices = tuple(indices_new) + + # restore + skip_graphic._pause = False + # def __repr__(self): # return "NDWidget Subplot" # @@ -211,4 +232,4 @@ def __getitem__(self, key): return self._subplots[subplot] def show(self, **kwargs): - return self.figure.show(**kwargs) \ No newline at end of file + return self.figure.show(**kwargs) From 5e67318285d42de557ef2367f138dd72eb573aac Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Feb 2026 02:39:58 -0500 Subject: [PATCH 033/163] return full data if display_window is Noen --- .../widgets/nd_widget/_nd_positions/_pandas.py | 14 +++++++++++--- .../widgets/nd_widget/_nd_positions/core.py | 4 ++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index de26c8a9d..3e03b9c2d 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -79,11 +79,19 @@ def tooltip_format(self, n: int, p: int): def get(self, indices: tuple[float | int, ...]) -> np.ndarray: if not isinstance(indices, tuple): raise TypeError(".get() must receive a tuple of float | int indices") - # assume no additional slider dims, only time slider dim - self._slices = self._get_dw_slices(indices) + # TODO: LOD by using a step size according to max_p + # TODO: Also what to do if display_window is None and data + # hasn't changed when indices keeps getting set, cache? + + # assume no additional slider dims, only time slider dim + if self.display_window is not None: + self._slices = self._get_dw_slices(indices) + gdata_shape = len(self.columns), self._slices[-1].stop - self._slices[-1].start, 3 + else: + gdata_shape = len(self.columns), self.data.shape[0], 3 + self._slices = (slice(None),) - gdata_shape = len(self.columns), self._slices[-1].stop - self._slices[-1].start, 3 gdata = np.zeros(shape=gdata_shape, dtype=np.float32) for i, col in enumerate(self.columns): diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index c763f9100..b83b4dd4c 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -200,6 +200,10 @@ def _get_dw_slices(self, indices) -> tuple[slice] | tuple[slice, slice]: dw = self.display_window if dw is None: + # just return everything + return (slice(None),) + + if dw == 0: # just map p dimension at this index and return index_p = self.index_mappings[-1](indices[-1]) return (slice(index_p, index_p + 1),) From 9d7328a891b5581d7a29ad30afc1196232acb3da Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Feb 2026 23:09:43 -0500 Subject: [PATCH 034/163] arrow key to step indices --- fastplotlib/widgets/nd_widget/ndwidget.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index fd2491be7..475987e0f 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -160,19 +160,35 @@ def update(self): for dim_index, (current_index, refr) in enumerate(zip(self._ndwidget.indices, self._ndwidget.ref_ranges)): if isinstance(refr, ReferenceRangeContinuous): - changed, val = imgui.slider_float( + changed, new_index = imgui.slider_float( v=current_index, v_min=refr.start, v_max=refr.stop, label=refr.unit ) + # TODO: refactor all this stuff, make fully fledged UI if changed: new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = val + new_indices[dim_index] = new_index indices_changed = True + elif imgui.is_item_hovered(): + if imgui.is_key_pressed(imgui.Key.right_arrow): + new_index = current_index + refr.step + new_indices = list(self._ndwidget.indices) + new_indices[dim_index] = new_index + + indices_changed = True + + if imgui.is_key_pressed(imgui.Key.left_arrow): + new_index = current_index - refr.step + new_indices = list(self._ndwidget.indices) + new_indices[dim_index] = new_index + + indices_changed = True + if indices_changed: self._ndwidget.indices = tuple(new_indices) From 05a38ec65215514b509222d956a5a5a1c63e48ae Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 23 Feb 2026 02:53:34 -0500 Subject: [PATCH 035/163] imgui separator --- fastplotlib/ui/_base.py | 193 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 3 deletions(-) diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index 9767cf76f..bc0280a4a 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -44,7 +44,7 @@ def __init__( location: Literal["bottom", "right"], title: str, window_flags: enum.IntFlag = imgui.WindowFlags_.no_collapse - | imgui.WindowFlags_.no_resize, + | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar, *args, **kwargs, ): @@ -111,6 +111,15 @@ def __init__( self._title = title self._window_flags = window_flags + self._resize_cursor_set = False + self._resize_blocked = False + self._right_gui_resizing = False + + self._separator_thickness = 14.0 + + self._collapsed = False + self._old_size = self.size + self._x, self._y, self._width, self._height = self.get_rect() self._figure.canvas.add_event_handler(self._set_rect, "resize") @@ -184,25 +193,203 @@ def get_rect(self) -> tuple[int, int, int, int]: return x_pos, y_pos, width, height + def _draw_resize_handle(self): + if self._location == "bottom": + imgui.set_cursor_pos((0, 0)) + imgui.invisible_button("##resize_handle", imgui.ImVec2(imgui.get_window_width(), self._separator_thickness)) + + hovered = imgui.is_item_hovered() + active = imgui.is_item_active() + + # Get the actual screen rect of the button after it's been laid out + rect_min = imgui.get_item_rect_min() + rect_max = imgui.get_item_rect_max() + + elif self._location == "right": + imgui.set_cursor_pos((0, 0)) + screen_pos = imgui.get_cursor_screen_pos() + win_height = imgui.get_window_height() + mouse_pos = imgui.get_mouse_pos() + + rect_min = imgui.ImVec2(screen_pos.x, screen_pos.y) + rect_max = imgui.ImVec2(screen_pos.x + self._separator_thickness, screen_pos.y + win_height) + + hovered = ( + rect_min.x <= mouse_pos.x <= rect_max.x + and rect_min.y <= mouse_pos.y <= rect_max.y + ) + + if hovered and imgui.is_mouse_clicked(0): + self._right_gui_resizing = True + + if not imgui.is_mouse_down(0): + self._right_gui_resizing = False + + active = self._right_gui_resizing + + imgui.set_cursor_pos((self._separator_thickness, 0)) + + if hovered and imgui.is_mouse_double_clicked(0): + if not self._collapsed: + self._old_size = self.size + if self._location == "bottom": + self.size = int(self._separator_thickness) + elif self._location == "right": + self.size = int(self._separator_thickness) + self._collapsed = True + else: + self.size = self._old_size + self._collapsed = False + + if hovered or active: + if not self._resize_cursor_set: + if self._location == "bottom": + self._figure.canvas.set_cursor("ns_resize") + + elif self._location == "right": + self._figure.canvas.set_cursor("ew_resize") + + self._resize_cursor_set = True + imgui.set_tooltip("Drag to resize, double click to expand/collapse") + + elif self._resize_cursor_set: + self._figure.canvas.set_cursor("default") + self._resize_cursor_set = False + + if active and imgui.is_mouse_dragging(0): + if self._location == "bottom": + delta = imgui.get_mouse_drag_delta(0).y + + elif self._location == "right": + delta = imgui.get_mouse_drag_delta(0).x + + imgui.reset_mouse_drag_delta(0) + px, py, pw, ph = self._figure.get_pygfx_render_area() + + if self._location == "bottom": + new_render_size = ph + delta + elif self._location == "right": + new_render_size = pw + delta + + # check if the new size would make the pygfx render area too small + if (delta < 0) and (new_render_size < 150): + print("not enough render area") + self._resize_blocked = True + + if self._resize_blocked: + # check if cursor has returned + if self._location == "bottom": + _min, pos, _max = rect_min.y, imgui.get_mouse_pos().y, rect_max.y + + elif self._location == "right": + _min, pos, _max = rect_min.x, imgui.get_mouse_pos().x, rect_max.x + + if ((_min - 5) <= pos <= (_max + 5)) and delta > 0: + # if the mouse cursor is back on the bar and the delta > 0, i.e. render area increasing + self._resize_blocked = False + + if not self._resize_blocked: + self.size = max(30, round(self.size - delta)) + self._collapsed = False + + draw_list = imgui.get_window_draw_list() + + line_color = ( + imgui.get_color_u32(imgui.ImVec4(0.9, 0.9, 0.9, 1.0)) + if (hovered or active) + else imgui.get_color_u32(imgui.ImVec4(0.5, 0.5, 0.5, 0.8)) + ) + bg_color = ( + imgui.get_color_u32(imgui.ImVec4(0.2, 0.2, 0.2, 0.8)) + if (hovered or active) + else imgui.get_color_u32(imgui.ImVec4(0.15, 0.15, 0.15, 0.6)) + ) + + # Background bar + draw_list.add_rect_filled( + imgui.ImVec2(rect_min.x, rect_min.y), + imgui.ImVec2(rect_max.x, rect_max.y), + bg_color, + ) + + # Three grip dots centered on the line + dot_spacing = 7.0 + dot_radius = 2 + if self._location == "bottom": + mid_y = (rect_min.y + rect_max.y) * 0.5 + center_x = (rect_min.x + rect_max.x) * 0.5 + for i in (-1, 0, 1): + cx = center_x + i * dot_spacing + draw_list.add_circle_filled(imgui.ImVec2(cx, mid_y), dot_radius, line_color) + + imgui.set_cursor_pos((0, imgui.get_cursor_pos_y() - imgui.get_style().item_spacing.y)) + + elif self._location == "right": + mid_x = (rect_min.x + rect_max.x) * 0.5 + center_y = (rect_min.y + rect_max.y) * 0.5 + for i in (-1, 0, 1): + cy = center_y + i * dot_spacing + draw_list.add_circle_filled( + imgui.ImVec2(mid_x, cy), dot_radius, line_color + ) + + def _draw_title(self, title: str): + padding = imgui.ImVec2(10, 4) + text_size = imgui.calc_text_size(title) + win_width = imgui.get_window_width() + box_size = imgui.ImVec2(win_width, text_size.y + padding.y * 2) + + box_screen_pos = imgui.get_cursor_screen_pos() + + draw_list = imgui.get_window_draw_list() + + # Background — use imgui's default title bar color + draw_list.add_rect_filled( + imgui.ImVec2(box_screen_pos.x, box_screen_pos.y), + imgui.ImVec2(box_screen_pos.x + box_size.x, box_screen_pos.y + box_size.y), + imgui.get_color_u32(imgui.Col_.title_bg_active), + ) + + # Centered text + text_pos = imgui.ImVec2( + box_screen_pos.x + (win_width - text_size.x) * 0.5, + box_screen_pos.y + padding.y, + ) + draw_list.add_text( + text_pos, imgui.get_color_u32(imgui.ImVec4(1, 1, 1, 1)), title + ) + + imgui.dummy(imgui.ImVec2(win_width, box_size.y)) + def draw_window(self): """helps simplify using imgui by managing window creation & position, and pushing/popping the ID""" # window position & size x, y, w, h = self.get_rect() imgui.set_next_window_size((self.width, self.height)) imgui.set_next_window_pos((self.x, self.y)) - # imgui.set_next_window_pos((x, y)) - # imgui.set_next_window_size((w, h)) flags = self._window_flags # begin window imgui.begin(self._title, p_open=None, flags=flags) + self._draw_resize_handle() + # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) + # collapse the UI if the separator state is collapsed + # otherwise the UI renders partially on the separator for "right" guis and it looks weird + main_height = 1.0 if self._collapsed else 0.0 + imgui.begin_child("##main_ui", imgui.ImVec2(0, main_height)) + + self._draw_title(self._title) + + imgui.indent(6.0) # draw stuff from subclass into window self.update() + imgui.end_child() + # pop ID imgui.pop_id() From 0433058bd447f25afc4a09dcb9318477d275fa00 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 23 Feb 2026 03:24:56 -0500 Subject: [PATCH 036/163] fix and ui stuff --- .../widgets/nd_widget/_nd_positions/core.py | 2 +- fastplotlib/widgets/nd_widget/ndwidget.py | 166 +++++++++++++----- 2 files changed, 126 insertions(+), 42 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index b83b4dd4c..3a43c5a03 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -417,7 +417,7 @@ def graphic( @graphic.setter def graphic(self, graphic_type): - if isinstance(self.graphic, graphic_type): + if type(self.graphic) is graphic_type: return plot_area = self._graphic._plot_area diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index 475987e0f..313781cd5 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -8,13 +8,17 @@ import numpy as np from ...layouts import ImguiFigure, Subplot -from ...graphics import ScatterCollection, LineCollection, LineStack, ImageGraphic +from ...graphics import ScatterCollection, LineCollection, LineStack, ImageGraphic, ImageVolumeGraphic from ...ui import EdgeWindow from .base import NDGraphic, NDProcessor from ._nd_image import NDImage, NDImageProcessor from ._nd_positions import NDPositions, NDPositionsProcessor +position_graphics = [ScatterCollection, LineCollection, LineStack, ImageGraphic] +image_graphics = [ImageGraphic, ImageVolumeGraphic] + + @dataclass class ReferenceRangeContinuous: start: int | float @@ -30,7 +34,9 @@ def __getitem__(self, index: int): val = self.start + (self.step * index) if not self.start <= val <= self.stop: - raise IndexError(f"index: {index} value: {val} out of bounds: [{self.start}, {self.stop}]") + raise IndexError( + f"index: {index} value: {val} out of bounds: [{self.start}, {self.stop}]" + ) return val @@ -86,16 +92,25 @@ def add_nd_scatter(self, *args, **kwargs): return nd def add_nd_timeseries( - self, - *args, - graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, - **kwargs + self, + *args, + graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, + **kwargs, ): - nd = NDPositions(*args, graphic=graphic, multi=True, auto_x_range=True, linear_selector=True, **kwargs) + nd = NDPositions( + *args, + graphic=graphic, + multi=True, + auto_x_range=True, + linear_selector=True, + **kwargs, + ) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) self._subplot.add_graphic(nd._linear_selector) - nd._linear_selector.add_event_handler(partial(self._set_indices_from_selector, nd), "selection") + nd._linear_selector.add_event_handler( + partial(self._set_indices_from_selector, nd), "selection" + ) return nd @@ -127,7 +142,11 @@ def _set_indices_from_selector(self, skip_graphic: NDGraphic, ev): class NDWSliders(EdgeWindow): def __init__(self, figure, size, ndwidget): - super().__init__(figure=figure, size=size, title="NDWidget controls", location="bottom") + super().__init__( + figure=figure, size=size, title="NDWidget controls", location="bottom", + window_flags=imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar + ) self._ndwidget = ndwidget # n_sliders = self._image_widget.n_sliders @@ -155,42 +174,106 @@ def __init__(self, figure, size, ndwidget): # # self.pause = False + self._selected_subplot = self._ndwidget.figure[0, 0].name + self._selected_nd_graphic = 0 + + self._max_display_windows: dict[NDGraphic, float | int] = dict() + def update(self): indices_changed = False - for dim_index, (current_index, refr) in enumerate(zip(self._ndwidget.indices, self._ndwidget.ref_ranges)): - if isinstance(refr, ReferenceRangeContinuous): - changed, new_index = imgui.slider_float( - v=current_index, - v_min=refr.start, - v_max=refr.stop, - label=refr.unit - ) + if imgui.begin_tab_bar("NDWidget Controls"): + + if imgui.begin_tab_item("Indices")[0]: + for dim_index, (current_index, refr) in enumerate( + zip(self._ndwidget.indices, self._ndwidget.ref_ranges) + ): + if isinstance(refr, ReferenceRangeContinuous): + changed, new_index = imgui.slider_float( + v=current_index, + v_min=refr.start, + v_max=refr.stop, + label=refr.unit, + ) + + # TODO: refactor all this stuff, make fully fledged UI + if changed: + new_indices = list(self._ndwidget.indices) + new_indices[dim_index] = new_index + + indices_changed = True + + elif imgui.is_item_hovered(): + if imgui.is_key_pressed(imgui.Key.right_arrow): + new_index = current_index + refr.step + new_indices = list(self._ndwidget.indices) + new_indices[dim_index] = new_index + + indices_changed = True - # TODO: refactor all this stuff, make fully fledged UI - if changed: - new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = new_index + if imgui.is_key_pressed(imgui.Key.left_arrow): + new_index = current_index - refr.step + new_indices = list(self._ndwidget.indices) + new_indices[dim_index] = new_index - indices_changed = True + indices_changed = True - elif imgui.is_item_hovered(): - if imgui.is_key_pressed(imgui.Key.right_arrow): - new_index = current_index + refr.step - new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = new_index + if indices_changed: + self._ndwidget.indices = tuple(new_indices) - indices_changed = True + imgui.end_tab_item() - if imgui.is_key_pressed(imgui.Key.left_arrow): - new_index = current_index - refr.step - new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = new_index + if imgui.begin_tab_item("NDGraphic properties")[0]: + imgui.text("Subplots:") - indices_changed = True + self._draw_nd_graphics_props_tab() - if indices_changed: - self._ndwidget.indices = tuple(new_indices) + imgui.end_tab_item() + + imgui.end_tab_bar() + + def _draw_nd_graphics_props_tab(self): + for subplot in self._ndwidget.figure: + if imgui.tree_node(subplot.name): + self._draw_ndgraphics_node(subplot) + imgui.tree_pop() + + def _draw_ndgraphics_node(self, subplot: Subplot): + for ng in self._ndwidget[subplot].nd_graphics: + if imgui.tree_node(str(ng)): + if isinstance(ng, NDPositions): + self._draw_nd_pos_ui(subplot, ng) + imgui.tree_pop() + + def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): + for i, cls in enumerate(position_graphics): + if imgui.radio_button(cls.__name__, type(nd_graphic.graphic) is cls): + nd_graphic.graphic = cls + subplot.auto_scale() + if i < len(position_graphics) - 1: + imgui.same_line() + + + if isinstance( + nd_graphic.display_window, (int, np.integer) + ): + slider = imgui.slider_int + input_ = imgui.input_int + type_ = int + else: + slider = imgui.slider_float + input_ = imgui.input_float + type_ = float + + changed, new = slider( + "display window", + v=nd_graphic.display_window, + v_min=type_(0), + v_max=type_(self._ndwidget.ref_ranges[0].stop * 0.25), + ) + + if changed: + nd_graphic.display_window = new class NDWidget: @@ -210,9 +293,9 @@ def __init__(self, ref_ranges: list[tuple], **kwargs): self._figure = ImguiFigure(**kwargs) - self._subplots: dict[Subplot, NDWSubplot] = dict() + self._subplots_nd: dict[Subplot, NDWSubplot] = dict() for subplot in self.figure: - self._subplots[subplot] = NDWSubplot(self, subplot) + self._subplots_nd[subplot] = NDWSubplot(self, subplot) # starting index for all dims self._indices = tuple(refr[0] for refr in self.ref_ranges) @@ -237,15 +320,16 @@ def indices(self) -> tuple: @indices.setter def indices(self, new_indices: tuple[Any]): - for subplot in self._subplots.values(): + for subplot in self._subplots_nd.values(): for ndg in subplot.nd_graphics: ndg.indices = new_indices self._indices = new_indices - def __getitem__(self, key): - subplot = self.figure[key] - return self._subplots[subplot] + def __getitem__(self, key: str | tuple[int, int] | Subplot): + if not isinstance(key, Subplot): + key = self.figure[key] + return self._subplots_nd[key] def show(self, **kwargs): return self.figure.show(**kwargs) From f6322eea168b50a89c5f92c19a952477d92a410b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 23 Feb 2026 04:31:47 -0500 Subject: [PATCH 037/163] both auto x range modes working --- .../widgets/nd_widget/_nd_positions/core.py | 42 +++++++++++-- fastplotlib/widgets/nd_widget/ndwidget.py | 59 +++++++++++++------ 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index 3a43c5a03..6a62a939b 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -359,7 +359,7 @@ def __init__( window_sizes: tuple[int | None] | None = None, index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, - auto_x_range: bool = False, + x_range_mode: Literal[None, "fixed-window", "view-range"] = None, linear_selector: bool = False, graphic_kwargs: dict = None, processor_kwargs: dict = None, @@ -386,10 +386,12 @@ def __init__( self._indices = tuple([0] * self._processor.n_slider_dims) - self._auto_x_range = auto_x_range - self._create_graphic(graphic) + self._x_range_mode = None + self._last_x_range = [0, 0] + self._block_auto_x = False + if linear_selector: self._linear_selector = LinearSelector(0, limits=(-np.inf, np.inf), edge_color="cyan") else: @@ -457,8 +459,9 @@ def indices(self, indices): # x range of the data xr = data_slice[0, 0, 0], data_slice[0, -1, 0] - if self._auto_x_range: + if self._x_range_mode is not None: self.graphic._plot_area.x_range = xr + self._last_x_range = xr # if the update_from_view is polling, prevents it if self._linear_selector is not None: with pause_events(self._linear_selector):#, event_handlers=[self._set_indices_from_selector]): @@ -558,3 +561,34 @@ def display_window(self) -> int | float | None: def display_window(self, dw: int | float | None): self.processor.display_window = dw self.indices = self.indices + + @property + def x_range_mode(self) -> Literal[None, "fixed-window", "view-range"]: + """x-range using a fixed window from the display window, or by polling the camera (view-range)""" + return self._x_range_mode + + @x_range_mode.setter + def x_range_mode(self, mode: Literal[None, "fixed-window", "view-range"]): + if self._x_range_mode == "view-range": + # old mode was view-range + self.graphic._plot_area.remove_animation( + self._update_from_view_range + ) + + if mode == "view-range": + self.graphic._plot_area.add_animations(self._update_from_view_range) + + self._x_range_mode = mode + + def _update_from_view_range(self): + xr = self.graphic._plot_area.x_range + if xr == self._last_x_range: + return + + self._last_x_range = self.graphic._plot_area.x_range + + self.display_window = xr[1] - xr[0] + indices = list(self.indices) + indices[-1] = (xr[0] + xr[1]) / 2 + + self.indices = indices diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index 313781cd5..982d120b0 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -40,6 +40,10 @@ def __getitem__(self, index: int): return val + @property + def range(self) -> int | float: + return self.stop - self.start + @dataclass class ReferenceRangeDiscrete: @@ -95,13 +99,14 @@ def add_nd_timeseries( self, *args, graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, + x_range_mode="fixed-window", **kwargs, ): nd = NDPositions( *args, graphic=graphic, multi=True, - auto_x_range=True, + x_range_mode=x_range_mode, linear_selector=True, **kwargs, ) @@ -112,6 +117,8 @@ def add_nd_timeseries( partial(self._set_indices_from_selector, nd), "selection" ) + nd.x_range_mode = x_range_mode + return nd def add_nd_lines(self, *args, **kwargs): @@ -122,6 +129,7 @@ def add_nd_lines(self, *args, **kwargs): def _set_indices_from_selector(self, skip_graphic: NDGraphic, ev): # skip the NDPosition object which has the linear selector that triggered this event + print("setting from selector") skip_graphic._pause = True x = ev.info["value"] @@ -253,27 +261,40 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): if i < len(position_graphics) - 1: imgui.same_line() + changed, val = imgui.checkbox("use display window", nd_graphic.display_window is not None) + if changed: + if not val: + nd_graphic.display_window = None + else: + # pick a value 10% of the reference range + nd_graphic.display_window = self._ndwidget.ref_ranges[0].range * 0.1 + + if nd_graphic.display_window is not None: + if isinstance( + nd_graphic.display_window, (int, np.integer) + ): + slider = imgui.slider_int + input_ = imgui.input_int + type_ = int + else: + slider = imgui.slider_float + input_ = imgui.input_float + type_ = float + + changed, new = slider( + "display window", + v=nd_graphic.display_window, + v_min=type_(0), + v_max=type_(self._ndwidget.ref_ranges[0].stop * 0.25), + ) - if isinstance( - nd_graphic.display_window, (int, np.integer) - ): - slider = imgui.slider_int - input_ = imgui.input_int - type_ = int - else: - slider = imgui.slider_float - input_ = imgui.input_float - type_ = float - - changed, new = slider( - "display window", - v=nd_graphic.display_window, - v_min=type_(0), - v_max=type_(self._ndwidget.ref_ranges[0].stop * 0.25), - ) + if changed: + nd_graphic.display_window = new + options = [None, "fixed-window", "view-range"] + changed, option = imgui.combo("x-range mode", options.index(nd_graphic.x_range_mode), [str(o) for o in options]) if changed: - nd_graphic.display_window = new + nd_graphic.x_range_mode = options[option] class NDWidget: From a71c9318c9af529ebf51e681aa4b01568caa4211 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 23 Feb 2026 05:08:19 -0500 Subject: [PATCH 038/163] progress --- .../widgets/nd_widget/_nd_positions/core.py | 30 ++++++++++++++----- fastplotlib/widgets/nd_widget/ndwidget.py | 5 ++-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index 6a62a939b..77871e71b 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -389,8 +389,8 @@ def __init__( self._create_graphic(graphic) self._x_range_mode = None - self._last_x_range = [0, 0] - self._block_auto_x = False + self._last_x_range = np.array([0.0, 0.0], dtype=np.float32) + self._block_update_indices = False if linear_selector: self._linear_selector = LinearSelector(0, limits=(-np.inf, np.inf), edge_color="cyan") @@ -434,9 +434,12 @@ def indices(self) -> tuple: @indices.setter def indices(self, indices): - if self._pause: + if self._block_update_indices: return + # this update must be non-reentrant + self._block_update_indices = True + data_slice = self.processor.get(indices) if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): @@ -461,16 +464,19 @@ def indices(self, indices): xr = data_slice[0, 0, 0], data_slice[0, -1, 0] if self._x_range_mode is not None: self.graphic._plot_area.x_range = xr - self._last_x_range = xr # if the update_from_view is polling, prevents it + + self._last_x_range[:] = xr # if the update_from_view is polling, prevents it if self._linear_selector is not None: - with pause_events(self._linear_selector):#, event_handlers=[self._set_indices_from_selector]): + with pause_events(self._linear_selector): self._linear_selector.limits = xr self._linear_selector.selection = indices[-1] # self._set_linear_selector(x_mid, limits=xr) self._indices = indices + self._block_update_indices = False + # def _set_linear_selector(self, x_mid, limits): # self._linear_selector.selection = x_mid # self._linear_selector.limits = limits @@ -582,13 +588,21 @@ def x_range_mode(self, mode: Literal[None, "fixed-window", "view-range"]): def _update_from_view_range(self): xr = self.graphic._plot_area.x_range - if xr == self._last_x_range: + + # the floating point error near zero gets nasty here + if np.allclose(xr, self._last_x_range, atol=1e-14): return - self._last_x_range = self.graphic._plot_area.x_range + self._last_x_range[:] = xr self.display_window = xr[1] - xr[0] + new_index = (xr[0] + xr[1]) / 2 + indices = list(self.indices) - indices[-1] = (xr[0] + xr[1]) / 2 + if indices[-1] == new_index: + return + + indices[-1] = new_index self.indices = indices + diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index 982d120b0..dd99a7c72 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -129,8 +129,7 @@ def add_nd_lines(self, *args, **kwargs): def _set_indices_from_selector(self, skip_graphic: NDGraphic, ev): # skip the NDPosition object which has the linear selector that triggered this event - print("setting from selector") - skip_graphic._pause = True + skip_graphic._block_update_indices = True x = ev.info["value"] indices_new = list(self.ndw.indices) @@ -139,7 +138,7 @@ def _set_indices_from_selector(self, skip_graphic: NDGraphic, ev): self.ndw.indices = tuple(indices_new) # restore - skip_graphic._pause = False + skip_graphic._block_update_indices = False # def __repr__(self): # return "NDWidget Subplot" From a2529cc83be2a56036071029b1aa5f187d6fa466 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 24 Feb 2026 01:58:35 -0500 Subject: [PATCH 039/163] moving stuff --- fastplotlib/widgets/nd_widget/_index.py | 113 +++++++ fastplotlib/widgets/nd_widget/_ndw_subplot.py | 95 ++++++ fastplotlib/widgets/nd_widget/_ui.py | 161 ++++++++++ fastplotlib/widgets/nd_widget/ndwidget.py | 301 +----------------- 4 files changed, 375 insertions(+), 295 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/_index.py create mode 100644 fastplotlib/widgets/nd_widget/_ndw_subplot.py create mode 100644 fastplotlib/widgets/nd_widget/_ui.py diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py new file mode 100644 index 000000000..dc3bdfec5 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -0,0 +1,113 @@ +from dataclasses import dataclass +from typing import Sequence, Any + + +@dataclass +class ReferenceRangeContinuous: + start: int | float + stop: int | float + step: int | float + unit: str + + def __getitem__(self, index: int): + """return the value at the index w.r.t. the step size""" + # if index is negative, turn to positive index + if index < 0: + raise ValueError("negative indexing not supported") + + val = self.start + (self.step * index) + if not self.start <= val <= self.stop: + raise IndexError( + f"index: {index} value: {val} out of bounds: [{self.start}, {self.stop}]" + ) + + return val + + @property + def range(self) -> int | float: + return self.stop - self.start + + +@dataclass +class ReferenceRangeDiscrete: + options: Sequence[Any] + unit: str + + def __getitem__(self, index: int): + if index > len(self.options): + raise IndexError + + return self.options[index] + + def __len__(self): + return len(self.options) + + +class GlobalIndexVector: + def __init__(self): + self._ndgraphics = list() + self._index = list() + self._ref_ranges = list() + + @property + def ndgraphics(self): + return tuple(self._ndgraphics) + + @property + def index(self) -> tuple[Any]: + # TODO: clamp index to given range here + # graphics will clamp according to their own array sizes? + pass + + @property + def dims(self) -> tuple[str]: + return tuple(ref.unit for ref in self.ref_ranges) + + @property + def ref_ranges(self) -> tuple[ReferenceRangeContinuous]: + pass + + def __getitem__(self, item): + if isinstance(item, int): + # integer index in the ordered dict + return self.ref_ranges[item] + + for rr in self.ref_ranges: + if rr.unit == item: + return rr + + raise KeyError + + def __setitem__(self, key, value): + # TODO: set the index for the given dimension only + if isinstance(key, str): + for i, rr in enumerate(self.ref_ranges): + if rr.unit == key: + key = i + break + else: + raise KeyError + + index = list(self.index) + + # set index for given dim + index[key] = value + + def __repr__(self): + return "\n".join([f"{d}: {i}" for d, i in zip(self.dims, self.index)]) + + +class SelectionVector: + @property + def selection(self): + pass + + @property + def graphics(self): + pass + + def add_graphic(self): + pass + + def remove_graphic(self): + pass diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py new file mode 100644 index 000000000..f28c88a50 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -0,0 +1,95 @@ +from functools import partial + +import numpy as np + +from ... import ScatterCollection, LineCollection, LineStack, ImageGraphic +from ...layouts import Subplot +from . import NDImage, NDPositions +from .base import NDGraphic + + +class NDWSubplot: + def __init__(self, ndw, subplot: Subplot): + self.ndw = ndw + self._subplot = subplot + + self._nd_graphics = list() + + @property + def nd_graphics(self) -> list[NDGraphic]: + return self._nd_graphics + + def __getitem__(self, key): + if isinstance(key, (int, np.integer)): + return self.nd_graphics[key] + + for g in self.nd_graphics: + if g.name == key: + return g + + else: + raise KeyError(f"NDGraphc with given key not found: {key}") + + def add_nd_image(self, *args, **kwargs): + nd = NDImage(*args, **kwargs) + self._nd_graphics.append(nd) + self._subplot.add_graphic(nd.graphic) + return nd + + def add_nd_scatter(self, *args, **kwargs): + nd = NDPositions(*args, graphic=ScatterCollection, multi=True, **kwargs) + self._nd_graphics.append(nd) + self._subplot.add_graphic(nd.graphic) + + return nd + + def add_nd_timeseries( + self, + *args, + graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, + x_range_mode="fixed-window", + **kwargs, + ): + nd = NDPositions( + *args, + graphic=graphic, + multi=True, + x_range_mode=x_range_mode, + linear_selector=True, + **kwargs, + ) + self._nd_graphics.append(nd) + self._subplot.add_graphic(nd.graphic) + self._subplot.add_graphic(nd._linear_selector) + nd._linear_selector.add_event_handler( + partial(self._set_indices_from_selector, nd), "selection" + ) + + nd.x_range_mode = x_range_mode + + return nd + + def add_nd_lines(self, *args, **kwargs): + nd = NDPositions(*args, graphic=LineCollection, multi=True, **kwargs) + self._nd_graphics.append(nd) + self._subplot.add_graphic(nd.graphic) + return nd + + def _set_indices_from_selector(self, skip_graphic: NDGraphic, ev): + # skip the NDPosition object which has the linear selector that triggered this event + skip_graphic._block_update_indices = True + + x = ev.info["value"] + indices_new = list(self.ndw.indices) + # linear selector for NDPositions always acts on the `p` dim + indices_new[-1] = x + self.ndw.indices = tuple(indices_new) + + # restore + skip_graphic._block_update_indices = False + + # def __repr__(self): + # return "NDWidget Subplot" + # + # def __str__(self): + # return "NDWidget Subplot" diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py new file mode 100644 index 000000000..a9777e87f --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -0,0 +1,161 @@ +import numpy as np +from imgui_bundle import imgui + +from ...graphics import ScatterCollection, LineCollection, LineStack, ImageGraphic, ImageVolumeGraphic +from ...layouts import Subplot +from ...ui import EdgeWindow +from . import NDPositions +from ._index import ReferenceRangeContinuous +from .base import NDGraphic + +position_graphics = [ScatterCollection, LineCollection, LineStack, ImageGraphic] +image_graphics = [ImageGraphic, ImageVolumeGraphic] + + +class NDWidgetUI(EdgeWindow): + def __init__(self, figure, size, ndwidget): + super().__init__( + figure=figure, size=size, title="NDWidget controls", location="bottom", + window_flags=imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar + ) + self._ndwidget = ndwidget + + # n_sliders = self._image_widget.n_sliders + # + # # whether or not a dimension is in play mode + # self._playing: list[bool] = [False] * n_sliders + # + # # approximate framerate for playing + # self._fps: list[int] = [20] * n_sliders + # + # # framerate converted to frame time + # self._frame_time: list[float] = [1 / 20] * n_sliders + # + # # last timepoint that a frame was displayed from a given dimension + # self._last_frame_time: list[float] = [perf_counter()] * n_sliders + # + # # loop playback + # self._loop = False + # + # # 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 + # + # self.pause = False + + self._selected_subplot = self._ndwidget.figure[0, 0].name + self._selected_nd_graphic = 0 + + self._max_display_windows: dict[NDGraphic, float | int] = dict() + + def update(self): + indices_changed = False + + if imgui.begin_tab_bar("NDWidget Controls"): + + if imgui.begin_tab_item("Indices")[0]: + for dim_index, (current_index, refr) in enumerate( + zip(self._ndwidget.indices, self._ndwidget.ref_ranges) + ): + if isinstance(refr, ReferenceRangeContinuous): + changed, new_index = imgui.slider_float( + v=current_index, + v_min=refr.start, + v_max=refr.stop, + label=refr.unit, + ) + + # TODO: refactor all this stuff, make fully fledged UI + if changed: + new_indices = list(self._ndwidget.indices) + new_indices[dim_index] = new_index + + indices_changed = True + + elif imgui.is_item_hovered(): + if imgui.is_key_pressed(imgui.Key.right_arrow): + new_index = current_index + refr.step + new_indices = list(self._ndwidget.indices) + new_indices[dim_index] = new_index + + indices_changed = True + + if imgui.is_key_pressed(imgui.Key.left_arrow): + new_index = current_index - refr.step + new_indices = list(self._ndwidget.indices) + new_indices[dim_index] = new_index + + indices_changed = True + + if indices_changed: + self._ndwidget.indices = tuple(new_indices) + + imgui.end_tab_item() + + if imgui.begin_tab_item("NDGraphic properties")[0]: + imgui.text("Subplots:") + + self._draw_nd_graphics_props_tab() + + imgui.end_tab_item() + + imgui.end_tab_bar() + + def _draw_nd_graphics_props_tab(self): + for subplot in self._ndwidget.figure: + if imgui.tree_node(subplot.name): + self._draw_ndgraphics_node(subplot) + imgui.tree_pop() + + def _draw_ndgraphics_node(self, subplot: Subplot): + for ng in self._ndwidget[subplot].nd_graphics: + if imgui.tree_node(str(ng)): + if isinstance(ng, NDPositions): + self._draw_nd_pos_ui(subplot, ng) + imgui.tree_pop() + + def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): + for i, cls in enumerate(position_graphics): + if imgui.radio_button(cls.__name__, type(nd_graphic.graphic) is cls): + nd_graphic.graphic = cls + subplot.auto_scale() + if i < len(position_graphics) - 1: + imgui.same_line() + + changed, val = imgui.checkbox("use display window", nd_graphic.display_window is not None) + if changed: + if not val: + nd_graphic.display_window = None + else: + # pick a value 10% of the reference range + nd_graphic.display_window = self._ndwidget.ref_ranges[0].range * 0.1 + + if nd_graphic.display_window is not None: + if isinstance( + nd_graphic.display_window, (int, np.integer) + ): + slider = imgui.slider_int + input_ = imgui.input_int + type_ = int + else: + slider = imgui.slider_float + input_ = imgui.input_float + type_ = float + + changed, new = slider( + "display window", + v=nd_graphic.display_window, + v_min=type_(0), + v_max=type_(self._ndwidget.ref_ranges[0].stop * 0.25), + ) + + if changed: + nd_graphic.display_window = new + + options = [None, "fixed-window", "view-range"] + changed, option = imgui.combo("x-range mode", options.index(nd_graphic.x_range_mode), [str(o) for o in options]) + if changed: + nd_graphic.x_range_mode = options[option] diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index dd99a7c72..427abba4e 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -1,303 +1,14 @@ -from dataclasses import dataclass -from functools import partial -import os -from time import perf_counter -from typing import Any, Sequence - -from imgui_bundle import imgui, icons_fontawesome_6 as fa -import numpy as np +from typing import Any +from ._index import ReferenceRangeContinuous, ReferenceRangeDiscrete +from ._ndw_subplot import NDWSubplot +from ._ui import NDWidgetUI from ...layouts import ImguiFigure, Subplot -from ...graphics import ScatterCollection, LineCollection, LineStack, ImageGraphic, ImageVolumeGraphic -from ...ui import EdgeWindow -from .base import NDGraphic, NDProcessor -from ._nd_image import NDImage, NDImageProcessor -from ._nd_positions import NDPositions, NDPositionsProcessor - - -position_graphics = [ScatterCollection, LineCollection, LineStack, ImageGraphic] -image_graphics = [ImageGraphic, ImageVolumeGraphic] - - -@dataclass -class ReferenceRangeContinuous: - start: int | float - stop: int | float - step: int | float - unit: str - - def __getitem__(self, index: int): - """return the value at the index w.r.t. the step size""" - # if index is negative, turn to positive index - if index < 0: - raise ValueError("negative indexing not supported") - - val = self.start + (self.step * index) - if not self.start <= val <= self.stop: - raise IndexError( - f"index: {index} value: {val} out of bounds: [{self.start}, {self.stop}]" - ) - - return val - - @property - def range(self) -> int | float: - return self.stop - self.start - - -@dataclass -class ReferenceRangeDiscrete: - options: Sequence[Any] - unit: str - - def __getitem__(self, index: int): - if index > len(self.options): - raise IndexError - - return self.options[index] - - def __len__(self): - return len(self.options) - - -class NDWSubplot: - def __init__(self, ndw, subplot: Subplot): - self.ndw = ndw - self._subplot = subplot - - self._nd_graphics = list() - - @property - def nd_graphics(self) -> list[NDGraphic]: - return self._nd_graphics - - def __getitem__(self, key): - if isinstance(key, (int, np.integer)): - return self.nd_graphics[key] - - for g in self.nd_graphics: - if g.name == key: - return g - - else: - raise KeyError(f"NDGraphc with given key not found: {key}") - - def add_nd_image(self, *args, **kwargs): - nd = NDImage(*args, **kwargs) - self._nd_graphics.append(nd) - self._subplot.add_graphic(nd.graphic) - return nd - - def add_nd_scatter(self, *args, **kwargs): - nd = NDPositions(*args, graphic=ScatterCollection, multi=True, **kwargs) - self._nd_graphics.append(nd) - self._subplot.add_graphic(nd.graphic) - - return nd - - def add_nd_timeseries( - self, - *args, - graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, - x_range_mode="fixed-window", - **kwargs, - ): - nd = NDPositions( - *args, - graphic=graphic, - multi=True, - x_range_mode=x_range_mode, - linear_selector=True, - **kwargs, - ) - self._nd_graphics.append(nd) - self._subplot.add_graphic(nd.graphic) - self._subplot.add_graphic(nd._linear_selector) - nd._linear_selector.add_event_handler( - partial(self._set_indices_from_selector, nd), "selection" - ) - - nd.x_range_mode = x_range_mode - - return nd - - def add_nd_lines(self, *args, **kwargs): - nd = NDPositions(*args, graphic=LineCollection, multi=True, **kwargs) - self._nd_graphics.append(nd) - self._subplot.add_graphic(nd.graphic) - return nd - - def _set_indices_from_selector(self, skip_graphic: NDGraphic, ev): - # skip the NDPosition object which has the linear selector that triggered this event - skip_graphic._block_update_indices = True - - x = ev.info["value"] - indices_new = list(self.ndw.indices) - # linear selector for NDPositions always acts on the `p` dim - indices_new[-1] = x - self.ndw.indices = tuple(indices_new) - - # restore - skip_graphic._block_update_indices = False - - # def __repr__(self): - # return "NDWidget Subplot" - # - # def __str__(self): - # return "NDWidget Subplot" - - -class NDWSliders(EdgeWindow): - def __init__(self, figure, size, ndwidget): - super().__init__( - figure=figure, size=size, title="NDWidget controls", location="bottom", - window_flags=imgui.WindowFlags_.no_collapse - | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar - ) - self._ndwidget = ndwidget - - # n_sliders = self._image_widget.n_sliders - # - # # whether or not a dimension is in play mode - # self._playing: list[bool] = [False] * n_sliders - # - # # approximate framerate for playing - # self._fps: list[int] = [20] * n_sliders - # - # # framerate converted to frame time - # self._frame_time: list[float] = [1 / 20] * n_sliders - # - # # last timepoint that a frame was displayed from a given dimension - # self._last_frame_time: list[float] = [perf_counter()] * n_sliders - # - # # loop playback - # self._loop = False - # - # # 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 - # - # self.pause = False - - self._selected_subplot = self._ndwidget.figure[0, 0].name - self._selected_nd_graphic = 0 - - self._max_display_windows: dict[NDGraphic, float | int] = dict() - - def update(self): - indices_changed = False - - if imgui.begin_tab_bar("NDWidget Controls"): - - if imgui.begin_tab_item("Indices")[0]: - for dim_index, (current_index, refr) in enumerate( - zip(self._ndwidget.indices, self._ndwidget.ref_ranges) - ): - if isinstance(refr, ReferenceRangeContinuous): - changed, new_index = imgui.slider_float( - v=current_index, - v_min=refr.start, - v_max=refr.stop, - label=refr.unit, - ) - - # TODO: refactor all this stuff, make fully fledged UI - if changed: - new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = new_index - - indices_changed = True - - elif imgui.is_item_hovered(): - if imgui.is_key_pressed(imgui.Key.right_arrow): - new_index = current_index + refr.step - new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = new_index - - indices_changed = True - - if imgui.is_key_pressed(imgui.Key.left_arrow): - new_index = current_index - refr.step - new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = new_index - - indices_changed = True - - if indices_changed: - self._ndwidget.indices = tuple(new_indices) - - imgui.end_tab_item() - - if imgui.begin_tab_item("NDGraphic properties")[0]: - imgui.text("Subplots:") - - self._draw_nd_graphics_props_tab() - - imgui.end_tab_item() - - imgui.end_tab_bar() - - def _draw_nd_graphics_props_tab(self): - for subplot in self._ndwidget.figure: - if imgui.tree_node(subplot.name): - self._draw_ndgraphics_node(subplot) - imgui.tree_pop() - - def _draw_ndgraphics_node(self, subplot: Subplot): - for ng in self._ndwidget[subplot].nd_graphics: - if imgui.tree_node(str(ng)): - if isinstance(ng, NDPositions): - self._draw_nd_pos_ui(subplot, ng) - imgui.tree_pop() - - def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): - for i, cls in enumerate(position_graphics): - if imgui.radio_button(cls.__name__, type(nd_graphic.graphic) is cls): - nd_graphic.graphic = cls - subplot.auto_scale() - if i < len(position_graphics) - 1: - imgui.same_line() - - changed, val = imgui.checkbox("use display window", nd_graphic.display_window is not None) - if changed: - if not val: - nd_graphic.display_window = None - else: - # pick a value 10% of the reference range - nd_graphic.display_window = self._ndwidget.ref_ranges[0].range * 0.1 - - if nd_graphic.display_window is not None: - if isinstance( - nd_graphic.display_window, (int, np.integer) - ): - slider = imgui.slider_int - input_ = imgui.input_int - type_ = int - else: - slider = imgui.slider_float - input_ = imgui.input_float - type_ = float - - changed, new = slider( - "display window", - v=nd_graphic.display_window, - v_min=type_(0), - v_max=type_(self._ndwidget.ref_ranges[0].stop * 0.25), - ) - - if changed: - nd_graphic.display_window = new - - options = [None, "fixed-window", "view-range"] - changed, option = imgui.combo("x-range mode", options.index(nd_graphic.x_range_mode), [str(o) for o in options]) - if changed: - nd_graphic.x_range_mode = options[option] class NDWidget: def __init__(self, ref_ranges: list[tuple], **kwargs): + # TODO: this should maybe be an ordered dict?? self._ref_ranges = list() for r in ref_ranges: @@ -323,7 +34,7 @@ def __init__(self, ref_ranges: list[tuple], **kwargs): # hard code the expected height so that the first render looks right in tests, docs etc. ui_size = 57 + (50 * len(self.indices)) - self._sliders_ui = NDWSliders(self.figure, ui_size, self) + self._sliders_ui = NDWidgetUI(self.figure, ui_size, self) self.figure.add_gui(self._sliders_ui) @property From d53cb8f08479b5f4aba0739bf3c70ad28d74352d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 24 Feb 2026 02:57:44 -0500 Subject: [PATCH 040/163] much much better organization of things --- fastplotlib/widgets/nd_widget/_index.py | 76 ++++++++++++++----- .../widgets/nd_widget/_nd_positions/core.py | 49 ++++++------ fastplotlib/widgets/nd_widget/_ndw_subplot.py | 30 +++----- fastplotlib/widgets/nd_widget/_ui.py | 30 ++++++-- fastplotlib/widgets/nd_widget/base.py | 64 +++++++++++++++- fastplotlib/widgets/nd_widget/ndwidget.py | 41 ++++------ 6 files changed, 192 insertions(+), 98 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index dc3bdfec5..4cf1e0bd6 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -1,5 +1,7 @@ from dataclasses import dataclass -from typing import Sequence, Any +from typing import Sequence, Any, Callable + +from .base import NDGraphic @dataclass @@ -44,20 +46,39 @@ def __len__(self): class GlobalIndexVector: - def __init__(self): - self._ndgraphics = list() - self._index = list() + def __init__(self, ref_ranges: list, get_ndgraphics: Callable): self._ref_ranges = list() - @property - def ndgraphics(self): - return tuple(self._ndgraphics) + for r in ref_ranges: + if len(r) == 4: + # assume start, stop, step, unit + refr = ReferenceRangeContinuous(*r) + elif len(r) == 2: + refr = ReferenceRangeDiscrete(*r) + else: + raise ValueError + + self._ref_ranges.append(refr) + + self._get_ndgraphics = get_ndgraphics + + # starting index for all dims + self._indices = [refr[0] for refr in self.ref_ranges] @property - def index(self) -> tuple[Any]: - # TODO: clamp index to given range here + def indices(self) -> tuple[Any]: + # TODO: clamp index to given ref range here # graphics will clamp according to their own array sizes? - pass + return tuple(self._indices) + + @indices.setter + def indices(self, new_indices: tuple[Any]): + self._indices[:] = new_indices + self._render_indices() + + def _render_indices(self): + for g in self._get_ndgraphics(): + g.indices = self.indices @property def dims(self) -> tuple[str]: @@ -65,16 +86,16 @@ def dims(self) -> tuple[str]: @property def ref_ranges(self) -> tuple[ReferenceRangeContinuous]: - pass + return tuple(self._ref_ranges) def __getitem__(self, item): if isinstance(item, int): - # integer index in the ordered dict - return self.ref_ranges[item] + # integer index in the list + return self._indices[item] - for rr in self.ref_ranges: + for i, rr in enumerate(self.ref_ranges): if rr.unit == item: - return rr + return self._indices[i] raise KeyError @@ -88,13 +109,30 @@ def __setitem__(self, key, value): else: raise KeyError - index = list(self.index) - # set index for given dim - index[key] = value + self._indices[key] = value + self._render_indices() + + def pop_dim(self): + pass + + def push_dim(self, ref_range: ReferenceRangeContinuous): + # TODO: implement pushing and popping dims + pass + + def __iter__(self): + for index in self.indices: + yield index + + def __len__(self): + return len(self._indices) + + def __eq__(self, other): + return self._indices == other def __repr__(self): - return "\n".join([f"{d}: {i}" for d, i in zip(self.dims, self.index)]) + named = ", ".join([f"{d}: {i}" for d, i in zip(self.dims, self.index)]) + return f"Indices: {named}" class SelectionVector: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index 77871e71b..59a240687 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -18,7 +18,8 @@ ) from ....graphics.utils import pause_events from ....graphics.selectors import LinearSelector -from ..base import NDProcessor, NDGraphic, WindowFuncCallable +from ..base import NDProcessor, NDGraphic, WindowFuncCallable, block_reentrance, block_indices +from .._index import GlobalIndexVector # TODO: Maybe get rid of n_display_dims in NDProcessor, @@ -339,9 +340,10 @@ def get(self, indices: tuple[Any, ...]): ] -class NDPositions: +class NDPositions(NDGraphic): def __init__( self, + global_index: GlobalIndexVector, data: Any, *args, graphic: Type[ @@ -359,8 +361,8 @@ def __init__( window_sizes: tuple[int | None] | None = None, index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, - x_range_mode: Literal[None, "fixed-window", "view-range"] = None, linear_selector: bool = False, + name: str = None, graphic_kwargs: dict = None, processor_kwargs: dict = None, ): @@ -390,15 +392,19 @@ def __init__( self._x_range_mode = None self._last_x_range = np.array([0.0, 0.0], dtype=np.float32) - self._block_update_indices = False if linear_selector: self._linear_selector = LinearSelector(0, limits=(-np.inf, np.inf), edge_color="cyan") + self._linear_selector.add_event_handler(self._linear_selector_handler, "selection") else: self._linear_selector = None self._pause = False + self._global_index = global_index + + super().__init__(name) + @property def processor(self) -> NDPositionsProcessor: return self._processor @@ -433,13 +439,8 @@ def indices(self) -> tuple: return self._indices @indices.setter + @block_reentrance def indices(self, indices): - if self._block_update_indices: - return - - # this update must be non-reentrant - self._block_update_indices = True - data_slice = self.processor.get(indices) if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): @@ -465,7 +466,7 @@ def indices(self, indices): if self._x_range_mode is not None: self.graphic._plot_area.x_range = xr - self._last_x_range[:] = xr # if the update_from_view is polling, prevents it + self._last_x_range[:] = xr # if the update_from_view is polling, prevents it from being called if self._linear_selector is not None: with pause_events(self._linear_selector): @@ -475,11 +476,10 @@ def indices(self, indices): self._indices = indices - self._block_update_indices = False - - # def _set_linear_selector(self, x_mid, limits): - # self._linear_selector.selection = x_mid - # self._linear_selector.limits = limits + def _linear_selector_handler(self, ev): + with block_indices(self): + # linear selector always acts on the `p` dim + self._global_index[-1] = ev.info["value"] def _tooltip_handler(self, graphic, pick_info): if isinstance(self.graphic, (LineCollection, ScatterCollection)): @@ -598,11 +598,14 @@ def _update_from_view_range(self): self.display_window = xr[1] - xr[0] new_index = (xr[0] + xr[1]) / 2 - indices = list(self.indices) - if indices[-1] == new_index: - return - - indices[-1] = new_index - - self.indices = indices + # set the `p` dim on the global index vector + self._global_index[-1] = new_index + # indices = list(self.indices) + # if indices[-1] == new_index: + # return + # + # indices[-1] = new_index + # + # self.indices = indices + # diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index f28c88a50..39975741f 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -16,8 +16,8 @@ def __init__(self, ndw, subplot: Subplot): self._nd_graphics = list() @property - def nd_graphics(self) -> list[NDGraphic]: - return self._nd_graphics + def nd_graphics(self) -> tuple[NDGraphic]: + return tuple(self._nd_graphics) def __getitem__(self, key): if isinstance(key, (int, np.integer)): @@ -37,7 +37,9 @@ def add_nd_image(self, *args, **kwargs): return nd def add_nd_scatter(self, *args, **kwargs): - nd = NDPositions(*args, graphic=ScatterCollection, multi=True, **kwargs) + nd = NDPositions( + self.ndw.indices, *args, graphic=ScatterCollection, multi=True, **kwargs + ) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) @@ -51,19 +53,20 @@ def add_nd_timeseries( **kwargs, ): nd = NDPositions( + self.ndw.indices, *args, graphic=graphic, multi=True, - x_range_mode=x_range_mode, + # x_range_mode=x_range_mode, linear_selector=True, **kwargs, ) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) self._subplot.add_graphic(nd._linear_selector) - nd._linear_selector.add_event_handler( - partial(self._set_indices_from_selector, nd), "selection" - ) + # nd._linear_selector.add_event_handler( + # partial(self._set_indices_from_selector, nd), "selection" + # ) nd.x_range_mode = x_range_mode @@ -75,19 +78,6 @@ def add_nd_lines(self, *args, **kwargs): self._subplot.add_graphic(nd.graphic) return nd - def _set_indices_from_selector(self, skip_graphic: NDGraphic, ev): - # skip the NDPosition object which has the linear selector that triggered this event - skip_graphic._block_update_indices = True - - x = ev.info["value"] - indices_new = list(self.ndw.indices) - # linear selector for NDPositions always acts on the `p` dim - indices_new[-1] = x - self.ndw.indices = tuple(indices_new) - - # restore - skip_graphic._block_update_indices = False - # def __repr__(self): # return "NDWidget Subplot" # diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index a9777e87f..a2198d6c9 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -1,7 +1,13 @@ import numpy as np from imgui_bundle import imgui -from ...graphics import ScatterCollection, LineCollection, LineStack, ImageGraphic, ImageVolumeGraphic +from ...graphics import ( + ScatterCollection, + LineCollection, + LineStack, + ImageGraphic, + ImageVolumeGraphic, +) from ...layouts import Subplot from ...ui import EdgeWindow from . import NDPositions @@ -15,9 +21,13 @@ class NDWidgetUI(EdgeWindow): def __init__(self, figure, size, ndwidget): super().__init__( - figure=figure, size=size, title="NDWidget controls", location="bottom", + figure=figure, + size=size, + title="NDWidget controls", + location="bottom", window_flags=imgui.WindowFlags_.no_collapse - | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar, ) self._ndwidget = ndwidget @@ -125,7 +135,9 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): if i < len(position_graphics) - 1: imgui.same_line() - changed, val = imgui.checkbox("use display window", nd_graphic.display_window is not None) + changed, val = imgui.checkbox( + "use display window", nd_graphic.display_window is not None + ) if changed: if not val: nd_graphic.display_window = None @@ -134,9 +146,7 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): nd_graphic.display_window = self._ndwidget.ref_ranges[0].range * 0.1 if nd_graphic.display_window is not None: - if isinstance( - nd_graphic.display_window, (int, np.integer) - ): + if isinstance(nd_graphic.display_window, (int, np.integer)): slider = imgui.slider_int input_ = imgui.input_int type_ = int @@ -156,6 +166,10 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): nd_graphic.display_window = new options = [None, "fixed-window", "view-range"] - changed, option = imgui.combo("x-range mode", options.index(nd_graphic.x_range_mode), [str(o) for o in options]) + changed, option = imgui.combo( + "x-range mode", + options.index(nd_graphic.x_range_mode), + [str(o) for o in options], + ) if changed: nd_graphic.x_range_mode = options[option] diff --git a/fastplotlib/widgets/nd_widget/base.py b/fastplotlib/widgets/nd_widget/base.py index e46386e93..b78dbcbbb 100644 --- a/fastplotlib/widgets/nd_widget/base.py +++ b/fastplotlib/widgets/nd_widget/base.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager import inspect from typing import Literal, Callable, Any from warnings import warn @@ -252,9 +253,40 @@ def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: pass +def block_reentrance(setter): + # decorator to block re-entrant indices setter + def set_indices_wrapper(self: NDGraphic, new_indices): + """ + wraps NDGraphic.indices + + self: NDGraphic instance + + new_indices: new indices to set + """ + # set_value is already in the middle of an execution, block re-entrance + if self._block_indices: + return + try: + # block re-execution of set_value until it has *fully* finished executing + self._block_indices = True + setter(self, new_indices) + 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! + finally: + # set_value has finished executing, now allow future executions + self._block_indices = False + + return set_indices_wrapper + + class NDGraphic: + def __init__(self, name: str | None): + self._name = name + self._block_indices = False + @property - def name(self) -> str: + def name(self) -> str | None: return self._name @property @@ -272,3 +304,33 @@ def indices(self) -> tuple[Any]: @indices.setter def indices(self, new: tuple): raise NotImplementedError + + +@contextmanager +def block_indices(ndgraphic: NDGraphic): + """ + 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 + -------- + + .. code-block:: + + # pass in any number of graphics + with fpl.pause_events(graphic1, graphic2, graphic3): + # enter context manager + # all events are blocked from graphic1, graphic2, graphic3 + + # context manager exited, event states restored. + + """ + ndgraphic._block_indices = True + + try: + yield + except Exception as e: + raise e from None # indices setter has raised, the line above and the lines below are probably more relevant! + finally: + ndgraphic._block_indices = False diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index 427abba4e..0caf9b9c0 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -1,6 +1,6 @@ from typing import Any -from ._index import ReferenceRangeContinuous, ReferenceRangeDiscrete +from ._index import ReferenceRangeContinuous, ReferenceRangeDiscrete, GlobalIndexVector from ._ndw_subplot import NDWSubplot from ._ui import NDWidgetUI from ...layouts import ImguiFigure, Subplot @@ -8,29 +8,13 @@ class NDWidget: def __init__(self, ref_ranges: list[tuple], **kwargs): - # TODO: this should maybe be an ordered dict?? - self._ref_ranges = list() - - for r in ref_ranges: - if len(r) == 4: - # assume start, stop, step, unit - refr = ReferenceRangeContinuous(*r) - elif len(r) == 2: - refr = ReferenceRangeDiscrete(*r) - else: - raise ValueError - - self._ref_ranges.append(refr) - + self._indices = GlobalIndexVector(ref_ranges, self._get_ndgraphics) self._figure = ImguiFigure(**kwargs) self._subplots_nd: dict[Subplot, NDWSubplot] = dict() for subplot in self.figure: self._subplots_nd[subplot] = NDWSubplot(self, subplot) - # starting index for all dims - self._indices = tuple(refr[0] for refr in self.ref_ranges) - # hard code the expected height so that the first render looks right in tests, docs etc. ui_size = 57 + (50 * len(self.indices)) @@ -42,25 +26,28 @@ def figure(self) -> ImguiFigure: return self._figure @property - def ref_ranges(self) -> tuple[ReferenceRangeContinuous | ReferenceRangeDiscrete]: - return tuple(self._ref_ranges) - - @property - def indices(self) -> tuple: + def indices(self) -> GlobalIndexVector: return self._indices @indices.setter def indices(self, new_indices: tuple[Any]): - for subplot in self._subplots_nd.values(): - for ndg in subplot.nd_graphics: - ndg.indices = new_indices + self._indices.indices = new_indices - self._indices = new_indices + @property + def ref_ranges(self) -> tuple[ReferenceRangeContinuous | ReferenceRangeDiscrete]: + return tuple(self._indices.ref_ranges) def __getitem__(self, key: str | tuple[int, int] | Subplot): if not isinstance(key, Subplot): key = self.figure[key] return self._subplots_nd[key] + def _get_ndgraphics(self): + gs = list() + for subplot in self._subplots_nd.values(): + gs.extend(subplot.nd_graphics) + + return tuple(gs) + def show(self, **kwargs): return self.figure.show(**kwargs) From fb42ae04185981ba79a4d10ad08b22b33e803d5e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 24 Feb 2026 04:23:06 -0500 Subject: [PATCH 041/163] GlobalIndexVector working with ndpostions and ndimage --- fastplotlib/layouts/_plot_area.py | 2 +- fastplotlib/widgets/nd_widget/_index.py | 14 ++++---- fastplotlib/widgets/nd_widget/_nd_image.py | 11 +++---- .../widgets/nd_widget/_nd_positions/core.py | 33 ++++++++----------- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 4 +-- fastplotlib/widgets/nd_widget/ndwidget.py | 6 ++-- 6 files changed, 31 insertions(+), 39 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 27ec75eef..513a7ad47 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -874,7 +874,7 @@ def x_range(self) -> tuple[float, float]: @x_range.setter def x_range(self, xr: tuple[float, float]): width = xr[1] - xr[0] - x_mid = xr[0] + (width / 2) + x_mid = (xr[0] + xr[1]) / 2 self.camera.width = width self.camera.local.x = x_mid diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 4cf1e0bd6..ff2edd6f6 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -63,16 +63,16 @@ def __init__(self, ref_ranges: list, get_ndgraphics: Callable): self._get_ndgraphics = get_ndgraphics # starting index for all dims - self._indices = [refr[0] for refr in self.ref_ranges] + self._indices: list[int | float | Any] = [refr[0] for refr in self.ref_ranges] @property - def indices(self) -> tuple[Any]: + def indices(self) -> tuple[int | float | Any, ...]: # TODO: clamp index to given ref range here # graphics will clamp according to their own array sizes? return tuple(self._indices) @indices.setter - def indices(self, new_indices: tuple[Any]): + def indices(self, new_indices: tuple[int | float | Any, ...]): self._indices[:] = new_indices self._render_indices() @@ -81,11 +81,11 @@ def _render_indices(self): g.indices = self.indices @property - def dims(self) -> tuple[str]: - return tuple(ref.unit for ref in self.ref_ranges) + def dims(self) -> tuple[str, ...]: + return tuple([ref.unit for ref in self.ref_ranges]) @property - def ref_ranges(self) -> tuple[ReferenceRangeContinuous]: + def ref_ranges(self) -> tuple[ReferenceRangeContinuous, ...]: return tuple(self._ref_ranges) def __getitem__(self, item): @@ -131,7 +131,7 @@ def __eq__(self, other): return self._indices == other def __repr__(self): - named = ", ".join([f"{d}: {i}" for d, i in zip(self.dims, self.index)]) + named = ", ".join([f"{d}: {i}" for d, i in zip(self.dims, self._indices)]) return f"Indices: {named}" diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 3e54814b2..398e48dee 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -526,6 +526,7 @@ def _recompute_histogram(self): class NDImage(NDGraphic): def __init__( self, + global_index, data: Any, *args, graphic: type[ImageGraphic, ImageVolumeGraphic] = None, @@ -540,6 +541,8 @@ def __init__( if processor_kwargs is None: processor_kwargs = dict() + self._global_index = global_index + self._processor = processor( data, *args, @@ -549,8 +552,6 @@ def __init__( **processor_kwargs, ) - self._indices = tuple([0] * self._processor.n_slider_dims) - self._graphic = None self._create_graphic() @@ -582,7 +583,7 @@ def _create_graphic(self): case 3: cls = ImageVolumeGraphic - data_slice = self.processor.get(self.indices) + data_slice = self.processor.get(self._global_index.indices) old_graphic = self._graphic new_graphic = cls(data_slice) @@ -607,7 +608,7 @@ def n_display_dims(self, n: Literal[2 , 3]): @property def indices(self) -> tuple: - return self._indices + return self._global_index.indices @indices.setter def indices(self, indices): @@ -615,8 +616,6 @@ def indices(self, indices): self.graphic.data = data_slice - self._indices = indices - def _tooltip_handler(self, graphic, pick_info): # get graphic within the collection n_index = np.argwhere(self.graphic.graphics == graphic).item() diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index 59a240687..e9be48368 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -366,6 +366,8 @@ def __init__( graphic_kwargs: dict = None, processor_kwargs: dict = None, ): + self._global_index = global_index + if issubclass(graphic, LineCollection): multi = True @@ -386,8 +388,6 @@ def __init__( self._processor.p_max = 1_000 - self._indices = tuple([0] * self._processor.n_slider_dims) - self._create_graphic(graphic) self._x_range_mode = None @@ -401,8 +401,6 @@ def __init__( self._pause = False - self._global_index = global_index - super().__init__(name) @property @@ -436,7 +434,7 @@ def graphic(self, graphic_type): @property def indices(self) -> tuple: - return self._indices + return self._global_index.indices @indices.setter @block_reentrance @@ -466,15 +464,14 @@ def indices(self, indices): if self._x_range_mode is not None: self.graphic._plot_area.x_range = xr - self._last_x_range[:] = xr # if the update_from_view is polling, prevents it from being called + # if the update_from_view is polling, this prevents it from being called by setting the new last xrange + # in theory, but this doesn't seem to fully work yet, not a big deal right now can check later + self._last_x_range[:] = self.graphic._plot_area.x_range if self._linear_selector is not None: with pause_events(self._linear_selector): self._linear_selector.limits = xr self._linear_selector.selection = indices[-1] - # self._set_linear_selector(x_mid, limits=xr) - - self._indices = indices def _linear_selector_handler(self, ev): with block_indices(self): @@ -566,6 +563,8 @@ def display_window(self) -> int | float | None: @display_window.setter def display_window(self, dw: int | float | None): self.processor.display_window = dw + + # force re-render self.indices = self.indices @property @@ -593,19 +592,15 @@ def _update_from_view_range(self): if np.allclose(xr, self._last_x_range, atol=1e-14): return + last_width = abs(self._last_x_range[1] - self._last_x_range[0]) self._last_x_range[:] = xr - self.display_window = xr[1] - xr[0] + new_width = abs(xr[1] - xr[0]) new_index = (xr[0] + xr[1]) / 2 + if (new_index == self._global_index[-1]) and (last_width == new_width): + return + + self.processor.display_window = new_width # set the `p` dim on the global index vector self._global_index[-1] = new_index - - # indices = list(self.indices) - # if indices[-1] == new_index: - # return - # - # indices[-1] = new_index - # - # self.indices = indices - # diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 39975741f..92ec69d74 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -1,5 +1,3 @@ -from functools import partial - import numpy as np from ... import ScatterCollection, LineCollection, LineStack, ImageGraphic @@ -31,7 +29,7 @@ def __getitem__(self, key): raise KeyError(f"NDGraphc with given key not found: {key}") def add_nd_image(self, *args, **kwargs): - nd = NDImage(*args, **kwargs) + nd = NDImage(self.ndw.indices, *args, **kwargs) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) return nd diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index 0caf9b9c0..c4bd8fb1e 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -30,12 +30,12 @@ def indices(self) -> GlobalIndexVector: return self._indices @indices.setter - def indices(self, new_indices: tuple[Any]): + def indices(self, new_indices: tuple[int | float | Any, ...]): self._indices.indices = new_indices @property - def ref_ranges(self) -> tuple[ReferenceRangeContinuous | ReferenceRangeDiscrete]: - return tuple(self._indices.ref_ranges) + def ref_ranges(self) -> tuple[ReferenceRangeContinuous | ReferenceRangeDiscrete, ...]: + return self._indices.ref_ranges def __getitem__(self, key: str | tuple[int, int] | Subplot): if not isinstance(key, Subplot): From fabb65a6678235088c3ede8fa1983c8bf2c2300f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 24 Feb 2026 05:33:57 -0500 Subject: [PATCH 042/163] examples --- examples/ndwidget/ndimage.py | 25 +++++++++++++++++++++ examples/ndwidget/timeseries.py | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 examples/ndwidget/ndimage.py create mode 100644 examples/ndwidget/timeseries.py diff --git a/examples/ndwidget/ndimage.py b/examples/ndwidget/ndimage.py new file mode 100644 index 000000000..9fb6dd422 --- /dev/null +++ b/examples/ndwidget/ndimage.py @@ -0,0 +1,25 @@ +""" +NDWidget image +============== + +NDWidget image example +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + + +a = np.random.rand(30, 1000, 64, 64) + + +ndw = fpl.NDWidget(ref_ranges=[(0, 30, 1, "um"), (0, 1000, 1, "t")], size=(800, 800)) +ndw.show() + +ndi = ndw[0, 0].add_nd_image(a, index_mappings=(int, int)) +# TODO: need to think about how to "auto ignore" reference range for a dim when switching between 2 & 3 dim images +# ndi.n_display_dims = 3 + +fpl.loop.run() diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py new file mode 100644 index 000000000..1dac31326 --- /dev/null +++ b/examples/ndwidget/timeseries.py @@ -0,0 +1,39 @@ +""" +NDWidget Timeseries +=================== + +NDWidget timeseries example +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +# generate some toy timeseries data +n_datapoints = 50_000 # number of datapoints per line +xs = np.linspace(0, 1000 * np.pi, n_datapoints) + +lines = list() +for i in range(1, 11): + l = np.column_stack( + [ + xs, + np.sin(xs * i) + ] + ) + lines.append(l) + +# timeseries data of shape [n_lines, n_datapoint, 2] +data = np.stack(lines) + +# must define a reference range, this would often be your time dimension and corresponds to your x-dimension +ref = [(0, xs[-1], 0.1, "angle")] + +ndw = fpl.NDWidget(ref_ranges=ref, size=(700, 560)) + +ndw[0, 0].add_nd_timeseries(data, index_mappings=(lambda xval: xs.searchsorted(xval),), x_range_mode="view-range") + +ndw.show(maintain_aspect=False) +fpl.loop.run() From 1642978aa6b1acea8e0b2a709f8e570eca29555c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 24 Feb 2026 20:28:04 -0500 Subject: [PATCH 043/163] progress --- examples/ndwidget/ndimage.py | 6 +++--- fastplotlib/widgets/nd_widget/_index.py | 2 +- fastplotlib/widgets/nd_widget/_nd_image.py | 12 ++++++------ fastplotlib/widgets/nd_widget/_nd_positions/core.py | 4 +++- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/examples/ndwidget/ndimage.py b/examples/ndwidget/ndimage.py index 9fb6dd422..0e44cd1b5 100644 --- a/examples/ndwidget/ndimage.py +++ b/examples/ndwidget/ndimage.py @@ -12,14 +12,14 @@ import fastplotlib as fpl -a = np.random.rand(30, 1000, 64, 64) +a = np.random.rand(1000, 30, 64, 64) -ndw = fpl.NDWidget(ref_ranges=[(0, 30, 1, "um"), (0, 1000, 1, "t")], size=(800, 800)) +ndw = fpl.NDWidget(ref_ranges=[(0, 1000, 1, "t"), (0, 30, 1, "um")], size=(800, 800)) ndw.show() ndi = ndw[0, 0].add_nd_image(a, index_mappings=(int, int)) # TODO: need to think about how to "auto ignore" reference range for a dim when switching between 2 & 3 dim images -# ndi.n_display_dims = 3 +ndi.n_display_dims = 3 fpl.loop.run() diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index ff2edd6f6..f30f93b94 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -46,7 +46,7 @@ def __len__(self): class GlobalIndexVector: - def __init__(self, ref_ranges: list, get_ndgraphics: Callable): + def __init__(self, ref_ranges: list, get_ndgraphics: Callable[[], tuple[NDGraphic]]): self._ref_ranges = list() for r in ref_ranges: diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 398e48dee..535927a03 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -583,16 +583,15 @@ def _create_graphic(self): case 3: cls = ImageVolumeGraphic - data_slice = self.processor.get(self._global_index.indices) + data_slice = self.processor.get(self.indices) old_graphic = self._graphic new_graphic = cls(data_slice) if old_graphic is not None: - g = self._graphic - plot_area = g._plot_area - self._graphic._plot_area.delete_graphic(g) - plot_area.add_graphic(self._graphic) + plot_area = old_graphic._plot_area + plot_area.delete_graphic(old_graphic) + plot_area.add_graphic(new_graphic) self._graphic = new_graphic @@ -608,10 +607,11 @@ def n_display_dims(self, n: Literal[2 , 3]): @property def indices(self) -> tuple: - return self._global_index.indices + return self._global_index.indices[-self.processor.n_slider_dims:] @indices.setter def indices(self, indices): + indices = indices[-self.processor.n_slider_dims:] data_slice = self.processor.get(indices) self.graphic.data = data_slice diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index e9be48368..d772e11e9 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -434,11 +434,13 @@ def graphic(self, graphic_type): @property def indices(self) -> tuple: - return self._global_index.indices + return self._global_index.indices[-self.processor.n_slider_dims:] @indices.setter @block_reentrance def indices(self, indices): + # upto the number of slider dims in this data + indices = indices[-self.processor.n_slider_dims:] data_slice = self.processor.get(indices) if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): From 8df9fb39e7c01fb42aa64e166571400fed31f7d2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 26 Feb 2026 17:04:55 -0500 Subject: [PATCH 044/163] do not reset vmin vmax when replacing Image buffer --- fastplotlib/graphics/image.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 6dfb52238..8e11f4751 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -259,8 +259,6 @@ def data(self, data): wrap="clamp-to-edge", ) - self._material.clim = quick_min_max(self.data.value) - # remove tiles from the WorldObject -> Graphic map self._remove_group_graphic_map(self.world_object) From 7e832629e859a5dc01b3edc2b877fe136cba4aa5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 28 Feb 2026 03:13:46 -0500 Subject: [PATCH 045/163] WIP migrate to xarray --- fastplotlib/widgets/nd_widget/base.py | 289 ++++++++++++-------------- 1 file changed, 136 insertions(+), 153 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/base.py b/fastplotlib/widgets/nd_widget/base.py index b78dbcbbb..bd32023c4 100644 --- a/fastplotlib/widgets/nd_widget/base.py +++ b/fastplotlib/widgets/nd_widget/base.py @@ -1,8 +1,11 @@ +from collections.abc import Callable, Hashable, Sequence from contextlib import contextmanager import inspect -from typing import Literal, Callable, Any +from numbers import Real +from typing import Literal, Any from warnings import warn +import xarray as xr import numpy as np from numpy.typing import ArrayLike @@ -17,45 +20,107 @@ def identity(index: int) -> int: return index +class BaseNDProcessor: + @property + def data(self) -> Any: + pass + + @property + def shape(self) -> dict[Hashable, int]: + pass + + @property + def ndim(self): + pass + + @property + def spatial_dims(self) -> tuple[Hashable, ...]: + pass + + @property + def slider_dims(self): + pass + + @property + def window_funcs( + self, + ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]]: + # {dim: (func, size)} + pass + + @property + def window_funcs_order(self) -> tuple[Hashable]: + pass + + @property + def index_mappings(self) -> dict[Hashable, Callable[[Any], int] | ArrayLike]: + pass + + def get(self, **indices): + raise NotImplementedError + + class NDProcessor: def __init__( self, data, - n_display_dims: Literal[2, 3] = 2, - index_mappings: tuple[Callable[[Any], int] | None, ...] | None = None, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - window_order: tuple[int, ...] = None, + dims: Sequence[Hashable], + spatial_dims: Sequence[Hashable] | None, + index_mappings: dict[Hashable, Callable[[Any], int] | ArrayLike] = None, + window_funcs: dict[ + Hashable, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_funcs_order: tuple[Hashable, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): - self._data = self._validate_data(data) + self._data = self._validate_data(data, dims) + self.spatial_dims = spatial_dims + self._index_mappings = tuple(self._validate_index_mappings(index_mappings)) self.window_funcs = window_funcs - self.window_sizes = window_sizes - self.window_order = window_order + self.window_order = window_funcs_order @property - def data(self) -> ArrayProtocol: + def data(self) -> xr.DataArray: return self._data @data.setter def data(self, data: ArrayProtocol): - self._data = self._validate_data(data) + self._data = self._validate_data(data, self.dims) @property - def shape(self) -> tuple[int, ...]: - return self.data.shape + def shape(self) -> dict[Hashable, int]: + """interpreted shape of the data""" + return {d: n for d, n in zip(self.dims, self.data.shape)} @property def ndim(self) -> int: - return len(self.shape) + """number of dims""" + return self.data.ndim - def _validate_data(self, data: ArrayProtocol): + @property + def dims(self) -> tuple[Hashable, ...]: + """dim names""" + return self.data.dims + + @property + def spatial_dims(self) -> tuple[Hashable, ...]: + return self._spatial_dims + + @spatial_dims.setter + def spatial_dims(self, sdims: Sequence[Hashable]): + for dim in tuple(sdims): + if dim not in self.dims: + raise KeyError + + self._spatial_dims = tuple(sdims) + + def _validate_data(self, data: ArrayProtocol, dims): if not isinstance(data, ArrayProtocol): raise TypeError("`data` must implement the ArrayProtocol") - return data + return xr.DataArray(data, dims=dims) @property def tooltip(self) -> bool: @@ -72,146 +137,74 @@ def tooltip_format(self, *args) -> str | None: @property def slider_dims(self): - raise NotImplementedError + return set(self.dims) - set(self.spatial_dims) @property def n_slider_dims(self): - raise NotImplementedError + return len(self.slider_dims) @property def window_funcs( self, - ) -> tuple[WindowFuncCallable | None, ...] | None: + ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | 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, + window_funcs: ( + dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]] | None + ), ): if window_funcs is None: - self._window_funcs = tuple([None] * self.n_slider_dims) - return - - if callable(window_funcs): - window_funcs = (window_funcs,) - - # if all are None - # if all([f is None for f in window_funcs]): - # self._window_funcs = tuple(window_funcs) - # return - - self._validate_window_func(window_funcs) - - self._window_funcs = tuple(window_funcs) - # self._recompute_histogram() - - def _validate_window_func(self, funcs): - if isinstance(funcs, (tuple, list)): - for f in funcs: - if f is None: - pass - elif callable(f): - sig = inspect.signature(f) - - if "axis" not in sig.parameters or "keepdims" not in sig.parameters: - raise TypeError( - f"Each window function must take an `axis` and `keepdims` argument, " - f"you passed: {f} with the following function signature: {sig}" - ) - else: - raise TypeError( - f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {funcs}" - ) - - if not (len(funcs) == self.n_slider_dims or self.n_slider_dims == 0): - raise IndexError( - f"number of `window_funcs` must be the same as the number of slider dims: {self.n_slider_dims}, " - f"and you passed {len(funcs)} `window_funcs`: {funcs}" - ) - - @property - def window_sizes(self) -> tuple[int | None, ...] | None: - """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 = tuple([None] * self.n_slider_dims) + self._window_funcs = {d: None for d in self.data.dims} return - if isinstance(window_sizes, int): - window_sizes = (window_sizes,) + for k in window_funcs.keys(): + if k not in self.dims: + raise KeyError + if k in self.spatial_dims: + raise KeyError - # if all are None - if all([w is None for w in window_sizes]): - self._window_sizes = None - return + func = window_funcs[k][0] + size = window_funcs[k][1] - if not all([isinstance(w, (int)) or w is None for w in window_sizes]): - raise TypeError( - f"`window_sizes` must be of type: tuple[int | None, ...] | int | None, you have passed: {window_sizes}" - ) - - # if not (len(window_sizes) == self.n_slider_dims or self.n_slider_dims == 0): - # raise IndexError( - # f"number of `window_sizes` must be the same as the number of slider dims, " - # f"i.e. `data.ndim` - n_display_dims, your data array has {self.ndim} dimensions " - # f"and you passed {len(window_sizes)} `window_sizes`: {window_sizes}" - # ) - - # make all window sizes are valid numbers - _window_sizes = list() - for i, w in enumerate(window_sizes): - if w is None: - _window_sizes.append(None) - continue - - if w < 0: - raise ValueError( - f"negative window size passed, all `window_sizes` must be positive " - f"integers or `None`, you passed: {_window_sizes}" - ) + if func is None: + pass + elif callable(func): + sig = inspect.signature(func) - if w == 0 or w == 1: - # this is not a real window, set as None - w = None - - elif w % 2 == 0: - # odd window sizes makes most sense - warn( - f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" + if "axis" not in sig.parameters or "keepdims" not in sig.parameters: + raise TypeError( + f"Each window function must take an `axis` and `keepdims` argument, " + f"you passed: {func} with the following function signature: {sig}" + ) + else: + raise TypeError( + f"`window_funcs` must be a dict mapping dim names to a tuple of the window function callable and " + f"window size, {'name': (func, size), ...}.\nYou have passed: {window_funcs}" ) - w += 1 - _window_sizes.append(w) + if not isinstance(size, Real): + raise TypeError + elif size < 0: + raise ValueError - self._window_sizes = tuple(_window_sizes) + self._window_funcs = window_funcs @property - def window_order(self) -> tuple[int, ...] | None: + def window_order(self) -> tuple[Hashable, ...] | None: """get or set dimension order in which window functions are applied""" return self._window_order @window_order.setter - def window_order(self, order: tuple[int] | None): - if order is None: - self._window_order = None - return - - if order is not None: - if not all([d <= self.n_slider_dims for d in order]): - raise IndexError( - f"all `window_order` entries must be <= n_slider_dims\n" - f"`n_slider_dims` is: {self.n_slider_dims}, you have passed `window_order`: {order}" - ) - - if not all([d >= 0 for d in order]): - raise IndexError( - f"all `window_order` entires must be >= 0, you have passed: {order}" - ) + def window_order(self, order: tuple[Hashable] | None): + for d in order: + if d not in self.dims: + raise KeyError + if d in self.spatial_dims: + raise KeyError self._window_order = tuple(order) @@ -219,37 +212,27 @@ def window_order(self, order: tuple[int] | None): def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: pass - # @property - # def slider_dims(self) -> tuple[int, ...] | None: - # pass - @property def index_mappings(self) -> tuple[Callable[[Any], int]]: return self._index_mappings @index_mappings.setter - def index_mappings(self, maps: tuple[Callable[[Any], int] | None] | None): - self._index_mappings = tuple(self._validate_index_mappings(maps)) - - def _validate_index_mappings(self, maps): - if maps is None: - return tuple([identity] * self.n_slider_dims) - - if len(maps) != self.n_slider_dims: - raise IndexError - - _maps = list() - for m in maps: - if m is None: - _maps.append(identity) - elif callable(m): - _maps.append(identity) - else: - raise TypeError - - return tuple(maps) - - def __getitem__(self, item: tuple[Any, ...]) -> ArrayProtocol: + def index_mappings(self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike]): + for d in maps.keys(): + if d not in self.dims: + raise KeyError + if d in self.spatial_dims: + raise KeyError + if isinstance(maps[d], ArrayProtocol): + # create a searchsorted mapping function automatically + maps[d] = maps[d].searchsorted + elif maps[d] is None: + # assign identity mapping + maps[d] = identity + + self._index_mappings = maps + + def get(self, indices: dict[Hashable, Any]): pass From b220f94a5a6942708062e1311ec51f8a238a2dd0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 28 Feb 2026 03:53:49 -0500 Subject: [PATCH 046/163] window funcs in NDProcessor class using xarray, WIP, not tested --- fastplotlib/widgets/nd_widget/base.py | 85 +++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/base.py b/fastplotlib/widgets/nd_widget/base.py index bd32023c4..468d57d7d 100644 --- a/fastplotlib/widgets/nd_widget/base.py +++ b/fastplotlib/widgets/nd_widget/base.py @@ -76,7 +76,7 @@ def __init__( self._data = self._validate_data(data, dims) self.spatial_dims = spatial_dims - self._index_mappings = tuple(self._validate_index_mappings(index_mappings)) + self.index_mappings = index_mappings self.window_funcs = window_funcs self.window_order = window_funcs_order @@ -136,7 +136,7 @@ def tooltip_format(self, *args) -> str | None: return None @property - def slider_dims(self): + def slider_dims(self) -> set[Hashable]: return set(self.dims) - set(self.spatial_dims) @property @@ -146,7 +146,7 @@ def n_slider_dims(self): @property def window_funcs( self, - ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]]: + ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None]: """get or set window functions, see docstring for details""" return self._window_funcs @@ -154,7 +154,7 @@ def window_funcs( def window_funcs( self, window_funcs: ( - dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]] | None + dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None] | None ), ): if window_funcs is None: @@ -186,11 +186,20 @@ def window_funcs( f"window size, {'name': (func, size), ...}.\nYou have passed: {window_funcs}" ) - if not isinstance(size, Real): + if size is None: + pass + + elif not isinstance(size, Real): raise TypeError + elif size < 0: raise ValueError + # fill in rest with None + for d in self.slider_dims: + if d not in window_funcs.keys(): + window_funcs[d] = None + self._window_funcs = window_funcs @property @@ -200,11 +209,8 @@ def window_order(self) -> tuple[Hashable, ...] | None: @window_order.setter def window_order(self, order: tuple[Hashable] | None): - for d in order: - if d not in self.dims: - raise KeyError - if d in self.spatial_dims: - raise KeyError + if set(order) != self.slider_dims: + raise ValueError("Order must specify all dims") self._window_order = tuple(order) @@ -213,7 +219,7 @@ def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: pass @property - def index_mappings(self) -> tuple[Callable[[Any], int]]: + def index_mappings(self) -> dict[Hashable, Callable[[Any], int]]: return self._index_mappings @index_mappings.setter @@ -232,8 +238,63 @@ def index_mappings(self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike]) self._index_mappings = maps + def _apply_window_functions(self, indices: dict[Hashable, Any]) -> np.ndarray: + if set(indices.keys()) != set(self.slider_dims): + raise IndexError( + f"Must provide an index for all slider dims: {self.slider_dims}, you have provided: {indices.keys()}" + ) + + indexer = dict() + # go through each slider dim and accumulate slice objects + for dim in self.slider_dims: + # index for this dim in reference space + index_ref = indices[dim] + + # if a window function exists for this dim + if self.window_funcs[dim] is not None: + # window size in reference units + w = self.window_funcs[dim][1] + + # half window in reference units + hw = w / 2 + + # start in reference units + start_ref = index_ref - hw + # stop in ref units + stop_ref = index_ref + hw + + # map start and stop ref to array indices + start = self.index_mappings[dim](start_ref) + stop = self.index_mappings[dim](stop_ref) + + # cmap within array bounds + start = max(min(self.shape[dim] - 1, start), 0) + stop = max(min(self.shape[dim] - 1, stop), 0) + indexer[dim] = slice(start, stop, 1) + else: + # no window func for this dim, direct indexing + # index mapped to array index + index = self.index_mappings[dim](index_ref) + + # clamp within the bounds + start = max(min(self.shape[dim] - 1, index), 0) + + # stop index is just the start index + 1 + indexer[dim] = slice(start, start + 1, 1) + + # apply indexer with any specified windows, return the underlying numpy array + data_sliced = self.data.isel(indexer).values + + # apply window funcs in the specified order + for dim in self.window_order: + func, _ = self.window_funcs[dim] + + data_sliced = func(data_sliced, axis=self.dims.index(dim), keepdims=True) + + return data_sliced + def get(self, indices: dict[Hashable, Any]): - pass + window_output = self._apply_window_functions(indices) def block_reentrance(setter): From 6f09b5d58f30dda463ea9938c706ac013770ed5b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 28 Feb 2026 04:03:21 -0500 Subject: [PATCH 047/163] typo --- fastplotlib/widgets/nd_widget/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/nd_widget/base.py b/fastplotlib/widgets/nd_widget/base.py index 468d57d7d..3b64de487 100644 --- a/fastplotlib/widgets/nd_widget/base.py +++ b/fastplotlib/widgets/nd_widget/base.py @@ -267,7 +267,7 @@ def _apply_window_functions(self, indices: dict[Hashable, Any]) -> np.ndarray: start = self.index_mappings[dim](start_ref) stop = self.index_mappings[dim](stop_ref) - # cmap within array bounds + # clamp within array bounds start = max(min(self.shape[dim] - 1, start), 0) stop = max(min(self.shape[dim] - 1, stop), 0) indexer[dim] = slice(start, stop, 1) From be437fa1d13d55d211d63adfd5041f9e19a0e405 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 1 Mar 2026 21:55:43 -0500 Subject: [PATCH 048/163] basic single index slicing working with xarray --- fastplotlib/widgets/nd_widget/base.py | 70 ++++++++++++++++++--------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/base.py b/fastplotlib/widgets/nd_widget/base.py index 3b64de487..973b18331 100644 --- a/fastplotlib/widgets/nd_widget/base.py +++ b/fastplotlib/widgets/nd_widget/base.py @@ -17,7 +17,7 @@ def identity(index: int) -> int: - return index + return round(index) class BaseNDProcessor: @@ -89,6 +89,12 @@ def data(self) -> xr.DataArray: def data(self, data: ArrayProtocol): self._data = self._validate_data(data, self.dims) + def _validate_data(self, data: ArrayProtocol, dims): + if not isinstance(data, ArrayProtocol): + raise TypeError("`data` must implement the ArrayProtocol") + + return xr.DataArray(data, dims=dims) + @property def shape(self) -> dict[Hashable, int]: """interpreted shape of the data""" @@ -110,18 +116,12 @@ def spatial_dims(self) -> tuple[Hashable, ...]: @spatial_dims.setter def spatial_dims(self, sdims: Sequence[Hashable]): - for dim in tuple(sdims): + for dim in sdims: if dim not in self.dims: raise KeyError self._spatial_dims = tuple(sdims) - def _validate_data(self, data: ArrayProtocol, dims): - if not isinstance(data, ArrayProtocol): - raise TypeError("`data` must implement the ArrayProtocol") - - return xr.DataArray(data, dims=dims) - @property def tooltip(self) -> bool: """ @@ -146,7 +146,7 @@ def n_slider_dims(self): @property def window_funcs( self, - ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None]: + ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]]: """get or set window functions, see docstring for details""" return self._window_funcs @@ -154,11 +154,13 @@ def window_funcs( def window_funcs( self, window_funcs: ( - dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None] | None + dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None] + | None ), ): if window_funcs is None: - self._window_funcs = {d: None for d in self.data.dims} + # tuple of (None, None) makes the checks easier in _apply_window_funcs + self._window_funcs = {d: (None, None) for d in self.data.dims} return for k in window_funcs.keys(): @@ -198,19 +200,26 @@ def window_funcs( # fill in rest with None for d in self.slider_dims: if d not in window_funcs.keys(): - window_funcs[d] = None + window_funcs[d] = (None, None) self._window_funcs = window_funcs @property - def window_order(self) -> tuple[Hashable, ...] | None: + def window_order(self) -> tuple[Hashable, ...]: """get or set dimension order in which window functions are applied""" return self._window_order @window_order.setter def window_order(self, order: tuple[Hashable] | None): - if set(order) != self.slider_dims: - raise ValueError("Order must specify all dims") + if order is None: + self._window_order = tuple() + return + + if not set(order).issubset(self.slider_dims): + raise ValueError( + f"each dimension in `window_order` must be a slider dim. You passed order: {order} " + f"and the slider dims are: {self.slider_dims}" + ) self._window_order = tuple(order) @@ -223,19 +232,31 @@ def index_mappings(self) -> dict[Hashable, Callable[[Any], int]]: return self._index_mappings @index_mappings.setter - def index_mappings(self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike]): + def index_mappings(self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None): + if maps is None: + self._index_mappings = {d: identity for d in self.dims} + return + for d in maps.keys(): if d not in self.dims: raise KeyError + if d in self.spatial_dims: - raise KeyError + raise KeyError("index mappings only apply to slider dims, not spatial dims") + if isinstance(maps[d], ArrayProtocol): # create a searchsorted mapping function automatically maps[d] = maps[d].searchsorted + elif maps[d] is None: # assign identity mapping maps[d] = identity + for d in self.dims: + # fill in any unspecified maps with identity + if d not in maps.keys(): + maps[d] = identity + self._index_mappings = maps def _apply_window_functions(self, indices: dict[Hashable, Any]) -> np.ndarray: @@ -250,13 +271,13 @@ def _apply_window_functions(self, indices: dict[Hashable, Any]) -> np.ndarray: # index for this dim in reference space index_ref = indices[dim] - # if a window function exists for this dim - if self.window_funcs[dim] is not None: - # window size in reference units - w = self.window_funcs[dim][1] + # get window func and size in reference units + wf, ws = self.window_funcs[dim] + # if a window function exists for this dim, and it's specified in the window order + if (wf is not None) and (ws is not None) and (dim in self.window_order): # half window in reference units - hw = w / 2 + hw = ws / 2 # start in reference units start_ref = index_ref - hw @@ -287,6 +308,9 @@ def _apply_window_functions(self, indices: dict[Hashable, Any]) -> np.ndarray: # apply window funcs in the specified order for dim in self.window_order: + if self.window_funcs[dim] is None: + continue + func, _ = self.window_funcs[dim] data_sliced = func(data_sliced, axis=self.dims.index(dim), keepdims=True) @@ -294,7 +318,7 @@ def _apply_window_functions(self, indices: dict[Hashable, Any]) -> np.ndarray: return data_sliced def get(self, indices: dict[Hashable, Any]): - window_output = self._apply_window_functions(indices) + raise NotImplementedError def block_reentrance(setter): From 404bb7bc192d3181cdfe140674cef52e6f64801c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Mar 2026 04:49:28 -0500 Subject: [PATCH 049/163] window funcs working for NDPositions and NDPP_Pands --- fastplotlib/widgets/nd_widget/_nd_image.py | 17 +- .../nd_widget/_nd_positions/_pandas.py | 56 +-- .../widgets/nd_widget/_nd_positions/core.py | 406 ++++++++---------- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 5 +- fastplotlib/widgets/nd_widget/base.py | 43 +- 5 files changed, 234 insertions(+), 293 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 535927a03..262693004 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -1,3 +1,4 @@ +from collections.abc import Hashable import inspect from typing import Literal, Callable, Type, Any from warnings import warn @@ -143,20 +144,12 @@ def rgb(self, rgb: bool): self._rgb = rgb @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) + def slider_dims(self) -> set[Hashable]: + return set(self.dims) - set(self.spatial_dims) @property - 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)) + def n_slider_dims(self): + return len(self.slider_dims) @property def slider_dims_shape(self) -> tuple[int, ...] | None: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 3e03b9c2d..296787d56 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -1,3 +1,5 @@ +from typing import Any + import numpy as np import pandas as pd @@ -8,13 +10,11 @@ class NDPP_Pandas(NDPositionsProcessor): def __init__( self, data: pd.DataFrame, + spatial_dims: tuple[str, str, str], # [l, p, d] dims in order columns: list[tuple[str, str] | tuple[str, str, str]], tooltip_columns: list[str] = None, - max_display_datapoints: int = 1_000, **kwargs, ): - data = data - self._columns = columns if tooltip_columns is not None: @@ -26,17 +26,22 @@ def __init__( self._tooltip_columns = None self._tooltip = False + self._dims = spatial_dims + super().__init__( data=data, - max_display_datapoints=max_display_datapoints, + dims=spatial_dims, + spatial_dims=spatial_dims, **kwargs, ) + self._dw_slice = None + @property def data(self) -> pd.DataFrame: return self._data - def _validate_data(self, data: pd.DataFrame): + def _validate_data(self, data: pd.DataFrame, dims): if not isinstance(data, pd.DataFrame): raise TypeError @@ -47,56 +52,41 @@ def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: return self._columns @property - def multi(self) -> bool: - return True - - @multi.setter - def multi(self, v): - pass + def dims(self) -> tuple[str, str, str]: + return self._dims @property - def shape(self) -> tuple[int, ...]: + def shape(self) -> dict[str, int]: # n_graphical_elements, n_timepoints, 2 - return len(self.columns), self.data.index.size, 2 + return {self.dims[0]: len(self.columns), self.dims[1]: self.data.index.size, self.dims[2]: 2} @property def ndim(self) -> int: return len(self.shape) - @property - def n_slider_dims(self) -> int: - return 1 - @property def tooltip(self) -> bool: return self._tooltip def tooltip_format(self, n: int, p: int): # datapoint index w.r.t. full data - p += self._slices[-1].start + p += self._dw_slice.start return str(self.data[self._tooltip_columns[n]][p]) - def get(self, indices: tuple[float | int, ...]) -> np.ndarray: - if not isinstance(indices, tuple): - raise TypeError(".get() must receive a tuple of float | int indices") - + def get(self, indices: dict[str, Any]) -> np.ndarray: # TODO: LOD by using a step size according to max_p # TODO: Also what to do if display_window is None and data # hasn't changed when indices keeps getting set, cache? - # assume no additional slider dims, only time slider dim - if self.display_window is not None: - self._slices = self._get_dw_slices(indices) - gdata_shape = len(self.columns), self._slices[-1].stop - self._slices[-1].start, 3 - else: - gdata_shape = len(self.columns), self.data.shape[0], 3 - self._slices = (slice(None),) + # assume no additional slider dims + self._dw_slice = self._get_dw_slice(indices) + gdata_shape = len(self.columns), self._dw_slice.stop - self._dw_slice.start, 3 - gdata = np.zeros(shape=gdata_shape, dtype=np.float32) + graphic_data = np.zeros(shape=gdata_shape, dtype=np.float32) for i, col in enumerate(self.columns): - gdata[i, :, :len(col)] = np.column_stack( - [self.data[c][self._slices[-1]] for c in col] + graphic_data[i, :, :len(col)] = np.column_stack( + [self.data[c][self._dw_slice] for c in col] ) - return gdata + return self._apply_dw_window_func(graphic_data) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index d772e11e9..42577e387 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -1,9 +1,12 @@ +from collections.abc import Callable, Hashable, Sequence, Iterable from functools import partial -from typing import Literal, Callable, Any, Type +from typing import Literal, Any, Type from warnings import warn import numpy as np from numpy.lib.stride_tricks import sliding_window_view +from numpy.typing import ArrayLike +import xarray as xr from ....utils import subsample_array, ArrayProtocol @@ -18,7 +21,13 @@ ) from ....graphics.utils import pause_events from ....graphics.selectors import LinearSelector -from ..base import NDProcessor, NDGraphic, WindowFuncCallable, block_reentrance, block_indices +from ..base import ( + NDProcessor, + NDGraphic, + WindowFuncCallable, + block_reentrance, + block_indices, +) from .._index import GlobalIndexVector @@ -29,28 +38,62 @@ class NDPositionsProcessor(NDProcessor): def __init__( self, data: Any, - multi: bool = False, # TODO: interpret [n - 2] dimension as n_lines or n_points + dims: Sequence[str], + # TODO: allow stack_dim to be None and auto-add new dim of size 1 in get logic + spatial_dims: tuple[ + str | None, str, str + ], # [stack_dim, n_datapoints, spatial_dim], IN ORDER!! + index_mappings: dict[str, Callable[[Any], int] | ArrayLike] = None, display_window: int | float | None = 100, # window for n_datapoints dim only max_display_datapoints: int = 1_000, - datapoints_window_func: Callable | None = None, - datapoints_window_size: int | None = None, + datapoints_window_func: tuple[Callable, str, int | float] | None = None, **kwargs, ): + """ + + Parameters + ---------- + data + dims + spatial_dims + index_mappings + display_window + max_display_datapoints + datapoints_window_func: + Important note: if used, display_window is approximate and not exact due to padding from the window size + kwargs + """ self._display_window = display_window self._max_display_datapoints = max_display_datapoints - # TOOD: this does data validation twice and is a bit messy, cleanup - self._data = self._validate_data(data) - self.multi = multi - - super().__init__(data=data, **kwargs) + super().__init__( + data=data, + dims=dims, + spatial_dims=spatial_dims, + index_mappings=index_mappings, + **kwargs, + ) self._datapoints_window_func = datapoints_window_func - self._datapoints_window_size = datapoints_window_size - def _validate_data(self, data: ArrayProtocol): - # TODO: determine right validation shape etc. - return data + @property + def spatial_dims(self) -> tuple[str, str, str]: + return self._spatial_dims + + @spatial_dims.setter + def spatial_dims(self, sdims: tuple[str, str, str]): + if len(sdims) != 3: + raise IndexError + + if not all([d in self.dims for d in sdims]): + raise KeyError + + self._spatial_dims = tuple(sdims) + + @property + def slider_dims(self) -> set[Hashable]: + # append `p` dim to slider dims + return tuple([*super().slider_dims, self.spatial_dims[1]]) @property def display_window(self) -> int | float | None: @@ -80,264 +123,169 @@ def max_display_datapoints(self, n: int): self._max_display_datapoints = n - @property - def multi(self) -> bool: - return self._multi - - @multi.setter - def multi(self, m: bool): - if m and self.data.ndim < 3: - # p is p-datapoints, n is how many lines to show simultaneously (for line collection/stack) - raise ValueError( - "ndim must be >= 3 for multi, shape must be [s1..., sn, n, p, 2 | 3]" - ) - - self._multi = m - - @property - def slider_dims(self) -> tuple[int, ...]: - """slider dimensions""" - return tuple(range(self.ndim - 2 - int(self.multi))) + (self.ndim - 2,) - - @property - def n_slider_dims(self) -> int: - return self.ndim - 1 - int(self.multi) - # TODO: validation for datapoints_window_func and size @property - def datapoints_window_func(self) -> tuple[Callable, str] | None: + def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: """ Callable and str indicating which dims to apply window function along: 'all', 'x', 'y', 'z', 'xyz', 'xy', 'xz', 'yz' '""" return self._datapoints_window_func - @property - def datapoints_window_size(self) -> Callable | None: - return self._datapoints_window_size - - def _apply_window_functions(self, indices: tuple[int, ...]): - """applies the window functions for each dimension specified""" - # window size for each dim - winds = self._window_sizes - # window function for each dim - funcs = self._window_funcs - - # TODO: use tuple of None for window funcs and sizes to indicate all None, instead of just None - # print(winds) - # print(funcs) - # - # if winds is None or funcs is None: - # # no window funcs or window sizes, just slice data and return - # # clamp to max bounds - # indexer = list() - # print(indices) - # print(self.shape) - # for dim, i in enumerate(indices): - # i = min(self.shape[dim] - 1, i) - # indexer.append(i) - # - # return self.data[tuple(indexer)] - - # order in which window funcs are applied - order = self._window_order - - if order is not None: - # remove any entries in `window_order` where the specified dim - # has a window function or window size specified as `None` - # example: - # window_sizes = (3, 2) - # window_funcs = (np.mean, None) - # order = (0, 1) - # `1` is removed from the order since that window_func is `None` - order = tuple( - d for d in order if winds[d] is not None and funcs[d] is not None - ) - else: - # sequential order - order = list() - for d in range(self.n_slider_dims): - if winds[d] is not None and funcs[d] is not None: - order.append(d) - - # the final indexer which will be used on the data array - indexer = list() - - for dim_index, (i, w, f) in enumerate(zip(indices, winds, funcs)): - # clamp i within the max bounds - i = min(self.shape[dim_index] - 1, i) - - if (w is not None) and (f is not None): - # specify slice window if both window size and function for this dim are not None - hw = int((w - 1) / 2) # half window + def _get_dw_slice(self, indices: dict[str, Any]) -> slice: + # given indices, return slice required to obtain display window - # start index cannot be less than 0 - start = max(0, i - hw) + # n_datapoints dim name + # display_window acts on this dim + p_dim = self.spatial_dims[1] - # stop index cannot exceed the bounds of this dimension - stop = min(self.shape[dim_index], i + hw) - - s = slice(start, stop, 1) - else: - s = slice(i, i + 1, 1) - - indexer.append(s) - - # apply indexer to slice data with the specified windows - data_sliced = self.data[tuple(indexer)] - - # finally apply the window functions in the specified order - for dim in order: - f = funcs[dim] - - data_sliced = f(data_sliced, axis=dim, keepdims=True) - - return data_sliced - - def _get_dw_slices(self, indices) -> tuple[slice] | tuple[slice, slice]: - # given indices, return slice using display window - - # display window is interpreted using the index mapping for the `p` dim - dw = self.display_window - - if dw is None: + if self.display_window is None: # just return everything - return (slice(None),) + return slice(0, self.shape[p_dim] - 1) - if dw == 0: + if self.display_window == 0: # just map p dimension at this index and return - index_p = self.index_mappings[-1](indices[-1]) - return (slice(index_p, index_p + 1),) + index = self._ref_index_to_array_index(p_dim, indices[p_dim]) + return slice(index, index + 1) + + # half window size, in reference units + hw = self.display_window / 2 + + if self.datapoints_window_func is not None: + # add half datapoints_window_func size here, assumes the reference space is somewhat continuous + # and the display_window and datapoints window size map to their actual size values + hw += self._ref_index_to_array_index(p_dim, self.datapoints_window_func[2] / 2) # display window is in reference units, apply display window and then map to array indices - # clamp w.r.t. 0 and processor shape `p` dim - hw = dw / 2 - index_p_start = max(self.index_mappings[-1](indices[-1] - hw), 0) - index_p_stop = min(self.index_mappings[-1](indices[-1] + hw), self.shape[-2]) - if index_p_start >= index_p_stop: - index_p_stop = index_p_start + 1 + # start in reference units + start_ref = indices[p_dim] - hw + # stop in reference units + stop_ref = indices[p_dim] + hw - # round to the nearest integer since to use as arra indices - slices = [slice(round(index_p_start), round(index_p_stop))] + # map to array indices + start = self._ref_index_to_array_index(p_dim, start_ref) + stop = self._ref_index_to_array_index(p_dim, stop_ref) - if self.multi: - slices.insert(0, slice(None)) + if start >= stop: + stop = start + 1 - return tuple(slices) + return slice(start, stop) - def get(self, indices: tuple[Any, ...]): + def _apply_dw_window_func(self, array: np.ndarray) -> np.ndarray: """ - slices through all slider dims and outputs an array that can be used to set graphic data + Takes array where display window has already been applied and applies window functions on the `p` dim. - 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 + ---------- + array: np.ndarray + array of shape: [l, display_window, 2 | 3] + + Returns + ------- + np.ndarray + array with window functions applied along `p` dim """ - # apply any slider index mappings - array_indices = tuple([m(i) for m, i in zip(self.index_mappings, indices)]) - - if len(array_indices) > 1: - # there are dims in addition to the n_datapoints dim - # apply window funcs - # window_output array should be of shape [n_datapoints, 2 | 3] - window_output = self._apply_window_functions(array_indices[:-1]).squeeze() - else: - window_output = self.data + if self.display_window == 0: + # can't apply window func when there is only 1 datapoint + return array - if self.display_window is not None: - # display_window is in reference units - slices = self._get_dw_slices(indices) - - # if self.display_window is not None: - # # display window is interpreted using the index mapping for the `p` dim - # dw = self.index_mappings[-1](self.display_window) - # - # if dw == 1: - # slices = [slice(indices[-1], indices[-1] + 1)] - # - # else: - # # half window size - # hw = dw // 2 - # - # # for now assume just a single index provided that indicates x axis value - # start = max(indices[-1] - hw, 0) - # stop = start + dw - # # also add window size of `p` dim so window_func output has the same number of datapoints - # if ( - # self.datapoints_window_func is not None - # and self.datapoints_window_size is not None - # ): - # stop += self.datapoints_window_size - 1 - # # TODO: pad with constant if we're using a window func and the index is near the end - # - # # TODO: uncomment this once we have resizeable buffers!! - # # stop = min(indices[-1] + hw, self.shape[-2]) - # - # slices = [slice(start, stop)] - # - # if self.multi: - # # n - 2 dim is n_lines or n_scatters - # slices.insert(0, slice(None)) + p_dim = self.spatial_dims[1] - # data that will be used for the graphical representation - # a copy is made, if there were no window functions then this is a view of the original data - graphic_data = window_output[tuple(slices)] + # display window in array index space + dw = self.index_mappings[p_dim](self.display_window) - dw = self.index_mappings[-1](self.display_window) + # step size based on max number of datapoints to render + step = max(1, dw // self.max_display_datapoints) # apply window function on the `p` n_datapoints dim if ( self.datapoints_window_func is not None - and self.datapoints_window_size is not None - # if there are too many points to efficiently compute the window func + # if there are too many points to efficiently compute the window func, skip # applying a window func also requires making a copy so that's a further performance hit and (dw < self.max_display_datapoints * 2) ): # get windows - # graphic_data will be of shape: [n, p + (ws - 1), 2 | 3] + # graphic_data will be of shape: [n, p, 2 | 3] # where: # n - number of lines, scatters, heatmap rows # p - number of datapoints/samples - wf = self.datapoints_window_func[0] - apply_dims = self.datapoints_window_func[1] - ws = self.datapoints_window_size + # ws is in ref units + wf, apply_dims, ws = self.datapoints_window_func + + # map ws in ref units to array index + ws = self._ref_index_to_array_index(p_dim, ws) + + if ws % 2 == 0: + # odd size windows are easier to handle + ws += 1 + + hw = ws // 2 + start, stop = hw, array.shape[1] - hw # apply user's window func # result will be of shape [n, p, 2 | 3] if apply_dims == "all": # windows will be of shape [n, p, 1 | 2 | 3, ws] - windows = sliding_window_view(graphic_data, ws, axis=-2) - return wf(windows, axis=-1) + windows = sliding_window_view(array, ws, axis=-2) + return wf(windows, axis=-1)[:, ::step] # map user dims str to tuple of numerical dims dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) - # windows will be of shape [n, p, 1 | 2 | 3, ws] + # windows will be of shape [n, (p - ws + 1), 1 | 2 | 3, ws] windows = sliding_window_view( - graphic_data[..., dims], ws, axis=-2 + array[..., dims], ws, axis=-2 ).squeeze() # make a copy because we need to modify it - graphic_data = graphic_data.copy() + array = array[:, start:stop].copy() # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary - # we need to slice upto dw since we add the `datapoints_window_size` above - graphic_data[..., :dw, dims] = wf(windows, axis=-1).reshape( - graphic_data.shape[0], dw, len(dims) + array[..., dims] = wf(windows, axis=-1).reshape( + *array.shape[:-1], len(dims) ) - return graphic_data[ - ..., : dw : max(1, dw // self.max_display_datapoints), : - ] + return array[:, ::step] + + return array[:, ::step] + + def get(self, indices: dict[str, Any]): + """ + slices through all slider dims and outputs an array that can be used to set graphic data + + Note that we do not use __getitem__ here since the index is a tuple specifying a single integer + index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. + """ + # # map slider dim indices to array indices + # array_indices = tuple([m(i) for m, i in zip(self.index_mappings, indices)]) + + if len(self.slider_dims) > 0: + # there are dims in addition to the spatial dims + window_output = self._apply_window_functions(indices).squeeze() + else: + # no slider dims, use all the data + window_output = self.data + + # verify window output only has the spatial dims + if not set(window_output.dims) == set(self.spatial_dims): + raise IndexError - return graphic_data[ - ..., - : graphic_data.shape[-2] : max( - 1, graphic_data.shape[-2] // self.max_display_datapoints - ), - :, - ] + # get slice obj for display window + dw_slice = self._get_dw_slice(indices) + + # data that will be used for the graphical representation + # a copy is made, if there were no window functions then this is a view of the original data + p_dim = self.spatial_dims[1] + + # slice the datapoints to be displayed in the graphic using the display window slice + # transpose to match spatial dims order, get numpy array, this is a view + graphic_data = ( + window_output.isel({p_dim: dw_slice}).transpose(*self.spatial_dims).values + ) + + return self._apply_dw_window_func(graphic_data) class NDPositions(NDGraphic): @@ -355,7 +303,6 @@ def __init__( | ImageGraphic ], processor: type[NDPositionsProcessor] = NDPositionsProcessor, - multi: bool = False, display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, window_sizes: tuple[int | None] | None = None, @@ -368,16 +315,12 @@ def __init__( ): self._global_index = global_index - if issubclass(graphic, LineCollection): - multi = True - if processor_kwargs is None: processor_kwargs = dict() self._processor = processor( data, *args, - multi=multi, display_window=display_window, max_display_datapoints=max_display_datapoints, window_funcs=window_funcs, @@ -394,8 +337,12 @@ def __init__( self._last_x_range = np.array([0.0, 0.0], dtype=np.float32) if linear_selector: - self._linear_selector = LinearSelector(0, limits=(-np.inf, np.inf), edge_color="cyan") - self._linear_selector.add_event_handler(self._linear_selector_handler, "selection") + self._linear_selector = LinearSelector( + 0, limits=(-np.inf, np.inf), edge_color="cyan" + ) + self._linear_selector.add_event_handler( + self._linear_selector_handler, "selection" + ) else: self._linear_selector = None @@ -434,15 +381,17 @@ def graphic(self, graphic_type): @property def indices(self) -> tuple: - return self._global_index.indices[-self.processor.n_slider_dims:] + return self._global_index.indices[-self.processor.n_slider_dims :] @indices.setter @block_reentrance def indices(self, indices): # upto the number of slider dims in this data - indices = indices[-self.processor.n_slider_dims:] + indices = indices[-self.processor.n_slider_dims :] data_slice = self.processor.get(indices) + # TODO: set other graphic features, colors, sizes, markers, etc. + if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): self.graphic.data[:, : data_slice.shape[-1]] = data_slice @@ -504,9 +453,6 @@ def _create_graphic( data_slice = self.processor.get(self.indices) if issubclass(graphic_cls, ImageGraphic): - if not self.processor.multi: - raise ValueError - if self.processor.shape[-1] != 2: raise ValueError @@ -578,9 +524,7 @@ def x_range_mode(self) -> Literal[None, "fixed-window", "view-range"]: def x_range_mode(self, mode: Literal[None, "fixed-window", "view-range"]): if self._x_range_mode == "view-range": # old mode was view-range - self.graphic._plot_area.remove_animation( - self._update_from_view_range - ) + self.graphic._plot_area.remove_animation(self._update_from_view_range) if mode == "view-range": self.graphic._plot_area.add_animations(self._update_from_view_range) diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 92ec69d74..4503c59ae 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -36,7 +36,7 @@ def add_nd_image(self, *args, **kwargs): def add_nd_scatter(self, *args, **kwargs): nd = NDPositions( - self.ndw.indices, *args, graphic=ScatterCollection, multi=True, **kwargs + self.ndw.indices, *args, graphic=ScatterCollection, **kwargs ) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) @@ -54,7 +54,6 @@ def add_nd_timeseries( self.ndw.indices, *args, graphic=graphic, - multi=True, # x_range_mode=x_range_mode, linear_selector=True, **kwargs, @@ -71,7 +70,7 @@ def add_nd_timeseries( return nd def add_nd_lines(self, *args, **kwargs): - nd = NDPositions(*args, graphic=LineCollection, multi=True, **kwargs) + nd = NDPositions(*args, graphic=LineCollection, **kwargs) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) return nd diff --git a/fastplotlib/widgets/nd_widget/base.py b/fastplotlib/widgets/nd_widget/base.py index 973b18331..c81053a62 100644 --- a/fastplotlib/widgets/nd_widget/base.py +++ b/fastplotlib/widgets/nd_widget/base.py @@ -93,6 +93,9 @@ def _validate_data(self, data: ArrayProtocol, dims): if not isinstance(data, ArrayProtocol): raise TypeError("`data` must implement the ArrayProtocol") + if data.ndim != len(dims): + raise IndexError("must specify a dim for every dimension in the data array") + return xr.DataArray(data, dims=dims) @property @@ -160,13 +163,11 @@ def window_funcs( ): if window_funcs is None: # tuple of (None, None) makes the checks easier in _apply_window_funcs - self._window_funcs = {d: (None, None) for d in self.data.dims} + self._window_funcs = {d: (None, None) for d in self.slider_dims} return for k in window_funcs.keys(): - if k not in self.dims: - raise KeyError - if k in self.spatial_dims: + if k not in self.slider_dims: raise KeyError func = window_funcs[k][0] @@ -238,12 +239,9 @@ def index_mappings(self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | return for d in maps.keys(): - if d not in self.dims: + if d not in self.slider_dims: raise KeyError - if d in self.spatial_dims: - raise KeyError("index mappings only apply to slider dims, not spatial dims") - if isinstance(maps[d], ArrayProtocol): # create a searchsorted mapping function automatically maps[d] = maps[d].searchsorted @@ -259,15 +257,24 @@ def index_mappings(self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | self._index_mappings = maps - def _apply_window_functions(self, indices: dict[Hashable, Any]) -> np.ndarray: + def _ref_index_to_array_index(self, dim: str, ref_index: Any) -> int: + # wraps index mappings, clamps between 0 and max array index for this dimension + index = self.index_mappings[dim](ref_index) + + return max(min(index, self.shape[dim] - 1), 0) + + def _get_slider_dims_indexer(self, indices) -> dict: if set(indices.keys()) != set(self.slider_dims): raise IndexError( f"Must provide an index for all slider dims: {self.slider_dims}, you have provided: {indices.keys()}" ) indexer = dict() + # get only slider dims which are not also spatial dims (example: p dim for positional data) + # since that is dealt with separately + slider_dims = set(self.slider_dims) - set(self.spatial_dims) # go through each slider dim and accumulate slice objects - for dim in self.slider_dims: + for dim in slider_dims: # index for this dim in reference space index_ref = indices[dim] @@ -303,8 +310,16 @@ def _apply_window_functions(self, indices: dict[Hashable, Any]) -> np.ndarray: # stop index is just the start index + 1 indexer[dim] = slice(start, start + 1, 1) - # apply indexer with any specified windows, return the underlying numpy array - data_sliced = self.data.isel(indexer).values + return indexer + + def _apply_window_functions(self, indices) -> xr.DataArray: + """slice with windows at given indices and apply window functions""" + indexer = self._get_slider_dims_indexer(indices) + + # there is significant overhead with passing xarray objects to numpy for things like np.mean() + # so convert to numpy, apply window functions, then convert back to xarray + # creating an xarray object from a numpy array has very little overhead, ~10 microseconds + array = self.data.isel(indexer).values # apply window funcs in the specified order for dim in self.window_order: @@ -313,9 +328,9 @@ def _apply_window_functions(self, indices: dict[Hashable, Any]) -> np.ndarray: func, _ = self.window_funcs[dim] - data_sliced = func(data_sliced, axis=self.dims.index(dim), keepdims=True) + array = func(array, axis=self.dims.index(dim), keepdims=True) - return data_sliced + return xr.DataArray(array, dims=self.dims) def get(self, indices: dict[Hashable, Any]): raise NotImplementedError From 42eb3f9df9e92d9813ba327e950487e9c2950ca3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Mar 2026 06:05:52 -0500 Subject: [PATCH 050/163] display_window window funcs working for NDPositions --- fastplotlib/widgets/nd_widget/_index.py | 91 ++++++++----------- .../widgets/nd_widget/_nd_positions/core.py | 33 ++++--- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 1 + fastplotlib/widgets/nd_widget/ndwidget.py | 14 +-- 4 files changed, 63 insertions(+), 76 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index f30f93b94..9d96626a7 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -6,10 +6,11 @@ @dataclass class ReferenceRangeContinuous: + name: str + unit: str start: int | float stop: int | float step: int | float - unit: str def __getitem__(self, index: int): """return the value at the index w.r.t. the step size""" @@ -32,8 +33,9 @@ def range(self) -> int | float: @dataclass class ReferenceRangeDiscrete: - options: Sequence[Any] + name: str unit: str + options: Sequence[Any] def __getitem__(self, index: int): if index > len(self.options): @@ -45,72 +47,51 @@ def __len__(self): return len(self.options) -class GlobalIndexVector: - def __init__(self, ref_ranges: list, get_ndgraphics: Callable[[], tuple[NDGraphic]]): - self._ref_ranges = list() +class GlobalIndex: + def __init__(self, ref_ranges: dict[str, tuple], get_ndgraphics: Callable[[], tuple[NDGraphic]]): + self._ref_ranges = dict() - for r in ref_ranges: - if len(r) == 4: - # assume start, stop, step, unit - refr = ReferenceRangeContinuous(*r) - elif len(r) == 2: - refr = ReferenceRangeDiscrete(*r) + for r in ref_ranges.values(): + if len(r) == 5: + # assume name, unit, start, stop, step + rr = ReferenceRangeContinuous(*r) + elif len(r) == 3: + rr = ReferenceRangeDiscrete(*r) else: raise ValueError - self._ref_ranges.append(refr) + self._ref_ranges[rr.name] = rr self._get_ndgraphics = get_ndgraphics # starting index for all dims - self._indices: list[int | float | Any] = [refr[0] for refr in self.ref_ranges] + self._indices: dict[str, int | float | Any] = {rr.name: rr.start for rr in self._ref_ranges.values()} + + def set(self, indices: dict[str, Any]): + for k in self._indices: + self._indices[k] = indices[k] - @property - def indices(self) -> tuple[int | float | Any, ...]: - # TODO: clamp index to given ref range here - # graphics will clamp according to their own array sizes? - return tuple(self._indices) - - @indices.setter - def indices(self, new_indices: tuple[int | float | Any, ...]): - self._indices[:] = new_indices self._render_indices() def _render_indices(self): for g in self._get_ndgraphics(): - g.indices = self.indices + g.indices = {d: self._indices[d] for d in g.processor.slider_dims} @property - def dims(self) -> tuple[str, ...]: - return tuple([ref.unit for ref in self.ref_ranges]) + def ref_ranges(self) -> dict[str, ReferenceRangeContinuous | ReferenceRangeDiscrete]: + return self._ref_ranges - @property - def ref_ranges(self) -> tuple[ReferenceRangeContinuous, ...]: - return tuple(self._ref_ranges) - - def __getitem__(self, item): - if isinstance(item, int): - # integer index in the list - return self._indices[item] - - for i, rr in enumerate(self.ref_ranges): - if rr.unit == item: - return self._indices[i] - - raise KeyError - - def __setitem__(self, key, value): - # TODO: set the index for the given dimension only - if isinstance(key, str): - for i, rr in enumerate(self.ref_ranges): - if rr.unit == key: - key = i - break - else: - raise KeyError + def __getitem__(self, dim): + return self._indices[dim] + + def __setitem__(self, dim, value): + # set index for given dim and render - # set index for given dim - self._indices[key] = value + # clamp within reference range + if isinstance(self.ref_ranges[dim], ReferenceRangeContinuous): + value = max(min(value, self.ref_ranges[dim].stop - self.ref_ranges[dim].step), self.ref_ranges[dim].start) + + self._indices[dim] = value self._render_indices() def pop_dim(self): @@ -121,7 +102,7 @@ def push_dim(self, ref_range: ReferenceRangeContinuous): pass def __iter__(self): - for index in self.indices: + for index in self._indices: yield index def __len__(self): @@ -131,8 +112,10 @@ def __eq__(self, other): return self._indices == other def __repr__(self): - named = ", ".join([f"{d}: {i}" for d, i in zip(self.dims, self._indices)]) - return f"Indices: {named}" + return f"Global Index: {self._indices}" + + def __str__(self): + return str(self._indices) class SelectionVector: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index 42577e387..35183b031 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -28,7 +28,7 @@ block_reentrance, block_indices, ) -from .._index import GlobalIndexVector +from .._index import GlobalIndex # TODO: Maybe get rid of n_display_dims in NDProcessor, @@ -154,7 +154,7 @@ def _get_dw_slice(self, indices: dict[str, Any]) -> slice: if self.datapoints_window_func is not None: # add half datapoints_window_func size here, assumes the reference space is somewhat continuous # and the display_window and datapoints window size map to their actual size values - hw += self._ref_index_to_array_index(p_dim, self.datapoints_window_func[2] / 2) + hw += self.datapoints_window_func[2] / 2 # display window is in reference units, apply display window and then map to array indices # start in reference units @@ -215,7 +215,8 @@ def _apply_dw_window_func(self, array: np.ndarray) -> np.ndarray: wf, apply_dims, ws = self.datapoints_window_func # map ws in ref units to array index - ws = self._ref_index_to_array_index(p_dim, ws) + # min window size is 3 + ws = max(self._ref_index_to_array_index(p_dim, ws), 3) if ws % 2 == 0: # odd size windows are easier to handle @@ -291,8 +292,10 @@ def get(self, indices: dict[str, Any]): class NDPositions(NDGraphic): def __init__( self, - global_index: GlobalIndexVector, + global_index: GlobalIndex, data: Any, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], *args, graphic: Type[ LineGraphic @@ -305,7 +308,6 @@ def __init__( processor: type[NDPositionsProcessor] = NDPositionsProcessor, display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, linear_selector: bool = False, @@ -320,11 +322,12 @@ def __init__( self._processor = processor( data, + dims, + spatial_dims, *args, display_window=display_window, max_display_datapoints=max_display_datapoints, window_funcs=window_funcs, - window_sizes=window_sizes, index_mappings=index_mappings, **processor_kwargs, ) @@ -380,14 +383,12 @@ def graphic(self, graphic_type): plot_area.add_graphic(self._graphic) @property - def indices(self) -> tuple: - return self._global_index.indices[-self.processor.n_slider_dims :] + def indices(self) -> dict[Hashable, Any]: + return {d: self._global_index[d] for d in self.processor.slider_dims} @indices.setter @block_reentrance def indices(self, indices): - # upto the number of slider dims in this data - indices = indices[-self.processor.n_slider_dims :] data_slice = self.processor.get(indices) # TODO: set other graphic features, colors, sizes, markers, etc. @@ -422,12 +423,13 @@ def indices(self, indices): if self._linear_selector is not None: with pause_events(self._linear_selector): self._linear_selector.limits = xr - self._linear_selector.selection = indices[-1] + # linear selector acts on `p` dim + self._linear_selector.selection = indices[self.processor.spatial_dims[1]] def _linear_selector_handler(self, ev): with block_indices(self): # linear selector always acts on the `p` dim - self._global_index[-1] = ev.info["value"] + self._global_index[self.processor.spatial_dims[1]] = ev.info["value"] def _tooltip_handler(self, graphic, pick_info): if isinstance(self.graphic, (LineCollection, ScatterCollection)): @@ -453,7 +455,8 @@ def _create_graphic( data_slice = self.processor.get(self.indices) if issubclass(graphic_cls, ImageGraphic): - if self.processor.shape[-1] != 2: + # `d` dim must only have xy data to be interpreted as a heatmap, xyz can't become a timeseries heatmap + if self.processor.shape[self.processor.spatial_dims[-1]] != 2: raise ValueError image_data, x0, x_scale = self._create_heatmap_data(data_slice) @@ -544,9 +547,9 @@ def _update_from_view_range(self): new_width = abs(xr[1] - xr[0]) new_index = (xr[0] + xr[1]) / 2 - if (new_index == self._global_index[-1]) and (last_width == new_width): + if (new_index == self._global_index[self.processor.spatial_dims[1]]) and (last_width == new_width): return self.processor.display_window = new_width # set the `p` dim on the global index vector - self._global_index[-1] = new_index + self._global_index[self.processor.spatial_dims[1]] = new_index diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 4503c59ae..677661b9b 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -35,6 +35,7 @@ def add_nd_image(self, *args, **kwargs): return nd def add_nd_scatter(self, *args, **kwargs): + # TODO: better func signature here, send all kwargs to processor_kwargs nd = NDPositions( self.ndw.indices, *args, graphic=ScatterCollection, **kwargs ) diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index c4bd8fb1e..b755296ee 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -1,14 +1,14 @@ from typing import Any -from ._index import ReferenceRangeContinuous, ReferenceRangeDiscrete, GlobalIndexVector +from ._index import ReferenceRangeContinuous, ReferenceRangeDiscrete, GlobalIndex from ._ndw_subplot import NDWSubplot from ._ui import NDWidgetUI from ...layouts import ImguiFigure, Subplot class NDWidget: - def __init__(self, ref_ranges: list[tuple], **kwargs): - self._indices = GlobalIndexVector(ref_ranges, self._get_ndgraphics) + def __init__(self, ref_ranges: dict[str, tuple], **kwargs): + self._indices = GlobalIndex(ref_ranges, self._get_ndgraphics) self._figure = ImguiFigure(**kwargs) self._subplots_nd: dict[Subplot, NDWSubplot] = dict() @@ -26,15 +26,15 @@ def figure(self) -> ImguiFigure: return self._figure @property - def indices(self) -> GlobalIndexVector: + def indices(self) -> GlobalIndex: return self._indices @indices.setter - def indices(self, new_indices: tuple[int | float | Any, ...]): - self._indices.indices = new_indices + def indices(self, new_indices: dict[str, int | float | Any]): + self._indices.set = new_indices @property - def ref_ranges(self) -> tuple[ReferenceRangeContinuous | ReferenceRangeDiscrete, ...]: + def ref_ranges(self) -> dict[str, ReferenceRangeContinuous | ReferenceRangeDiscrete]: return self._indices.ref_ranges def __getitem__(self, key: str | tuple[int, int] | Subplot): From c251a6d438ac74f614f504b8dbfd3fefa8dcaad3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Mar 2026 06:24:53 -0500 Subject: [PATCH 051/163] imgui stuff --- fastplotlib/widgets/nd_widget/_index.py | 2 +- fastplotlib/widgets/nd_widget/_ui.py | 32 ++++++----------------- fastplotlib/widgets/nd_widget/ndwidget.py | 2 +- 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 9d96626a7..20d9abc7d 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -102,7 +102,7 @@ def push_dim(self, ref_range: ReferenceRangeContinuous): pass def __iter__(self): - for index in self._indices: + for index in self._indices.items(): yield index def __len__(self): diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index a2198d6c9..17f908384 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -62,46 +62,30 @@ def __init__(self, figure, size, ndwidget): self._max_display_windows: dict[NDGraphic, float | int] = dict() def update(self): - indices_changed = False - if imgui.begin_tab_bar("NDWidget Controls"): if imgui.begin_tab_item("Indices")[0]: - for dim_index, (current_index, refr) in enumerate( - zip(self._ndwidget.indices, self._ndwidget.ref_ranges) - ): + for dim, current_index in self._ndwidget.indices: + refr = self._ndwidget.ref_ranges[dim] + if isinstance(refr, ReferenceRangeContinuous): changed, new_index = imgui.slider_float( v=current_index, v_min=refr.start, v_max=refr.stop, - label=refr.unit, + label=dim, ) # TODO: refactor all this stuff, make fully fledged UI if changed: - new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = new_index - - indices_changed = True + self._ndwidget.indices[dim] = new_index elif imgui.is_item_hovered(): if imgui.is_key_pressed(imgui.Key.right_arrow): - new_index = current_index + refr.step - new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = new_index - - indices_changed = True - - if imgui.is_key_pressed(imgui.Key.left_arrow): - new_index = current_index - refr.step - new_indices = list(self._ndwidget.indices) - new_indices[dim_index] = new_index - - indices_changed = True + self._ndwidget.indices[dim] = current_index + refr.step - if indices_changed: - self._ndwidget.indices = tuple(new_indices) + elif imgui.is_key_pressed(imgui.Key.left_arrow): + self._ndwidget.indices[dim] = current_index - refr.step imgui.end_tab_item() diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/ndwidget.py index b755296ee..534c1a922 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/ndwidget.py @@ -31,7 +31,7 @@ def indices(self) -> GlobalIndex: @indices.setter def indices(self, new_indices: dict[str, int | float | Any]): - self._indices.set = new_indices + self._indices.set(new_indices) @property def ref_ranges(self) -> dict[str, ReferenceRangeContinuous | ReferenceRangeDiscrete]: From 4ba98078b688eea2ce986fb2cfe448a9b9e29e99 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Mar 2026 08:04:52 -0500 Subject: [PATCH 052/163] finish migrate NDImage to xarray, basics work --- fastplotlib/widgets/nd_widget/_index.py | 1 + fastplotlib/widgets/nd_widget/_nd_image.py | 494 ++++-------------- .../nd_widget/_nd_positions/_pandas.py | 2 + .../widgets/nd_widget/_nd_positions/core.py | 9 +- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 7 +- fastplotlib/widgets/nd_widget/base.py | 24 +- 6 files changed, 144 insertions(+), 393 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 20d9abc7d..d7f60ba7e 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -75,6 +75,7 @@ def set(self, indices: dict[str, Any]): def _render_indices(self): for g in self._get_ndgraphics(): + # only provide slider indices to the graphic g.indices = {d: self._indices[d] for d in g.processor.slider_dims} @property diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 262693004..16a4686ec 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -1,10 +1,11 @@ -from collections.abc import Hashable +from collections.abc import Hashable, Sequence import inspect from typing import Literal, Callable, Type, Any from warnings import warn import numpy as np from numpy.typing import ArrayLike +import xarray as xr from ...utils import subsample_array, ArrayProtocol, ARRAY_LIKE_ATTRS from ...graphics import ImageGraphic, ImageVolumeGraphic @@ -15,14 +16,16 @@ class NDImageProcessor(NDProcessor): def __init__( self, data: ArrayLike | None, - n_display_dims: Literal[2, 3] = 2, - rgb: bool = False, + dims: Sequence[Hashable], + spatial_dims: ( + tuple[str, str] | tuple[str, str, str] + ), # must be in order! [rows, cols] | [z, rows, cols] + rgb_dim: str | None = None, window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, - window_sizes: tuple[int | None, ...] | int = None, window_order: tuple[int, ...] = None, spatial_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, - index_mappings = None, + index_mappings=None, ): """ An ND image that supports computing window functions, and functions over spatial dimensions. @@ -71,33 +74,32 @@ def __init__( # set as False until data, window funcs stuff and spatial func is all set self._compute_histogram = False - 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._spatial_func = spatial_func + super().__init__( + data=data, + dims=dims, + spatial_dims=spatial_dims, + index_mappings=index_mappings, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + ) + self.rgb_dim = rgb_dim self._compute_histogram = compute_histogram self._recompute_histogram() - self._index_mappings = self._validate_index_mappings(index_mappings) - @property - def data(self) -> ArrayLike | None: + def data(self) -> xr.DataArray | 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 data is None: - self._data = None - return + self._data = self._validate_data(data, self.dims) + self._recompute_histogram() + def _validate_data(self, data: ArrayProtocol, dims): if not isinstance(data, ArrayProtocol): raise TypeError( f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" @@ -109,235 +111,21 @@ def data(self, data: ArrayLike): f"Image data must have a minimum of 2 dimensions, you have passed an array of shape: {data.shape}" ) - self._data = data - self._recompute_histogram() + return xr.DataArray(data, dims=dims) @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 - def rgb(self) -> bool: - """whether or not the data is rgb(a)""" + def rgb_dim(self) -> str | None: + """indicates the rgb dim if one exists""" 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" - ) + @rgb_dim.setter + def rgb_dim(self, rgb: str | None): + if rgb is not None: + if rgb not in self.dims: + raise KeyError self._rgb = rgb - @property - def slider_dims(self) -> set[Hashable]: - return set(self.dims) - set(self.spatial_dims) - - @property - def n_slider_dims(self): - return len(self.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""" - 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 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() - - @property - def max_n_display_dims(self) -> int: - """maximum number of possible display dims""" - # 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]: - """tuple indicating the display dimension indices""" - return tuple(range(self.data.ndim))[self.n_slider_dims :] - - @property - def window_funcs( - self, - ) -> tuple[WindowFuncCallable | None, ...] | None: - """get or set window functions, see docstring for details""" - return self._window_funcs - - @window_funcs.setter - def window_funcs( - self, - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None, - ): - if window_funcs is None: - self._window_funcs = None - return - - if callable(window_funcs): - window_funcs = (window_funcs,) - - # if all are None - if all([f is None for f in window_funcs]): - self._window_funcs = None - return - - self._validate_window_func(window_funcs) - - self._window_funcs = tuple(window_funcs) - self._recompute_histogram() - - def _validate_window_func(self, funcs): - if isinstance(funcs, (tuple, list)): - for f in funcs: - if f is None: - pass - elif callable(f): - sig = inspect.signature(f) - - if "axis" not in sig.parameters or "keepdims" not in sig.parameters: - raise TypeError( - f"Each window function must take an `axis` and `keepdims` argument, " - f"you passed: {f} with the following function signature: {sig}" - ) - else: - raise TypeError( - f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {funcs}" - ) - - if not (len(funcs) == self.n_slider_dims or self.n_slider_dims == 0): - raise IndexError( - f"number of `window_funcs` must be the same as the number of slider dims: {self.n_slider_dims}, " - f"and you passed {len(funcs)} `window_funcs`: {funcs}" - ) - - @property - def window_sizes(self) -> tuple[int | None, ...] | None: - """get or set window sizes used for the corresponding window functions, see docstring for details""" - return self._window_sizes - - @window_sizes.setter - def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): - if window_sizes is None: - self._window_sizes = None - return - - if isinstance(window_sizes, int): - window_sizes = (window_sizes,) - - # if all are None - if all([w is None for w in window_sizes]): - self._window_sizes = None - return - - if not all([isinstance(w, (int)) or w is None for w in window_sizes]): - raise TypeError( - f"`window_sizes` must be of type: tuple[int | None, ...] | int | None, you have passed: {window_sizes}" - ) - - if not (len(window_sizes) == self.n_slider_dims or self.n_slider_dims == 0): - raise IndexError( - f"number of `window_sizes` must be the same as the number of slider dims, " - f"i.e. `data.ndim` - n_display_dims, your data array has {self.ndim} dimensions " - f"and you passed {len(window_sizes)} `window_sizes`: {window_sizes}" - ) - - # make all window sizes are valid numbers - _window_sizes = list() - for i, w in enumerate(window_sizes): - if w is None: - _window_sizes.append(None) - continue - - if w < 0: - raise ValueError( - f"negative window size passed, all `window_sizes` must be positive " - f"integers or `None`, you passed: {_window_sizes}" - ) - - if w == 0 or w == 1: - # this is not a real window, set as None - w = None - - elif w % 2 == 0: - # odd window sizes makes most sense - warn( - f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" - ) - w += 1 - - _window_sizes.append(w) - - self._window_sizes = tuple(_window_sizes) - self._recompute_histogram() - - @property - def window_order(self) -> tuple[int, ...] | None: - """get or set dimension order in which window functions are applied""" - return self._window_order - - @window_order.setter - def window_order(self, order: tuple[int] | None): - if order is None: - self._window_order = None - return - - if order is not None: - if not all([d <= self.n_slider_dims for d in order]): - raise IndexError( - f"all `window_order` entries must be <= n_slider_dims\n" - f"`n_slider_dims` is: {self.n_slider_dims}, you have passed `window_order`: {order}" - ) - - if not all([d >= 0 for d in order]): - raise IndexError( - f"all `window_order` entires must be >= 0, you have passed: {order}" - ) - - self._window_order = tuple(order) - self._recompute_histogram() - - @property - def spatial_func(self) -> Callable[[ArrayLike], ArrayLike] | None: - """get or set a spatial_func function, see docstring for details""" - return self._spatial_func - - @spatial_func.setter - def spatial_func(self, func: Callable[[ArrayLike], ArrayLike] | 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}" - ) - - self._spatial_func = func - self._recompute_histogram() - @property def compute_histogram(self) -> bool: return self._compute_histogram @@ -345,7 +133,7 @@ def compute_histogram(self) -> bool: @compute_histogram.setter def compute_histogram(self, compute: bool): if compute: - if self._compute_histogram is False: + if not self._compute_histogram: # compute a histogram self._recompute_histogram() self._compute_histogram = True @@ -362,79 +150,7 @@ def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: """ return self._histogram - 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 - # window function for each dim - funcs = self._window_funcs - - if winds is None or funcs is None: - # no window funcs or window sizes, just slice data and return - # clamp to max bounds - indexer = list() - for dim, i in enumerate(indices): - i = min(self.shape[dim] - 1, i) - indexer.append(i) - - return self.data[tuple(indexer)] - - # order in which window funcs are applied - order = self._window_order - - if order is not None: - # remove any entries in `window_order` where the specified dim - # has a window function or window size specified as `None` - # example: - # window_sizes = (3, 2) - # window_funcs = (np.mean, None) - # order = (0, 1) - # `1` is removed from the order since that window_func is `None` - order = tuple( - d for d in order if winds[d] is not None and funcs[d] is not None - ) - else: - # sequential order - order = list() - for d in range(self.n_slider_dims): - if winds[d] is not None and funcs[d] is not None: - order.append(d) - - # the final indexer which will be used on the data array - indexer = list() - - for dim_index, (i, w, f) in enumerate(zip(indices, winds, funcs)): - # clamp i within the max bounds - i = min(self.shape[dim_index] - 1, i) - - if (w is not None) and (f is not None): - # specify slice window if both window size and function for this dim are not None - hw = int((w - 1) / 2) # half window - - # start index cannot be less than 0 - start = max(0, i - hw) - - # stop index cannot exceed the bounds of this dimension - stop = min(self.shape[dim_index] - 1, i + hw) - - s = slice(start, stop, 1) - else: - s = slice(i, i + 1, 1) - - indexer.append(s) - - # apply indexer to slice data with the specified windows - data_sliced = self.data[tuple(indexer)] - - # finally apply the window functions in the specified order - for dim in order: - f = funcs[dim] - - data_sliced = f(data_sliced, axis=dim, keepdims=True) - - return data_sliced - - def get(self, indices: tuple[int, ...]) -> ArrayLike | None: + def get(self, indices: dict[str, Any]) -> ArrayLike | None: """ Get the data at the given index, process data through the window functions. @@ -448,46 +164,25 @@ def get(self, indices: tuple[int, ...]) -> ArrayLike | None: Example: get((100, 5)) """ - if self.data is None: - return None - - # apply any slider index mappings - indices = tuple([m(i) for m, i in zip(self.index_mappings, indices)]) - - if self.n_slider_dims != 0: - if len(indices) != self.n_slider_dims: - raise IndexError( - 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(indices).squeeze() + if len(self.slider_dims) > 0: + # there are dims in addition to the spatial dims + window_output = self._apply_window_functions(indices).squeeze() else: - # data is a static image or volume + # no slider dims, use all the data window_output = self.data + if window_output.ndim != len(self.spatial_dims): + raise ValueError + # 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 `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: - # check that output ndim after window functions matches display dims - final_output = window_output - 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}{' with rgb(a) channels' if self.rgb else ''}, " - f"expected {self.n_display_dims} dims" - ) - - return final_output + spatial_out = self._spatial_func(window_output) + if spatial_out.ndim != len(self.spatial_dims): + raise ValueError + + return spatial_out.transpose(*self.spatial_dims).values + + return window_output.transpose(*self.spatial_dims).values def _recompute_histogram(self): """ @@ -506,11 +201,11 @@ def _recompute_histogram(self): # 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 + ignore_dims = [self.dims.index(dim) for dim in self.spatial_dims] else: ignore_dims = None - sub = subsample_array(self.data, ignore_dims=ignore_dims) + sub = subsample_array(self.data.values, ignore_dims=ignore_dims) sub_real = sub[~(np.isnan(sub) | np.isinf(sub))] self._histogram = np.histogram(sub_real, bins=100) @@ -520,36 +215,38 @@ class NDImage(NDGraphic): def __init__( self, global_index, - data: Any, - *args, - graphic: type[ImageGraphic, ImageVolumeGraphic] = None, - processor: type[NDImageProcessor] = NDImageProcessor, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - window_sizes: tuple[int | None] | None = None, - index_mappings: tuple[Callable[[Any], int] | None] | None = None, - graphic_kwargs: dict = None, - processor_kwargs: dict = None, + data: ArrayLike | None, + dims: Sequence[Hashable], + spatial_dims: ( + tuple[str, str] | tuple[str, str, str] + ), # must be in order! [rows, cols] | [z, rows, cols] + rgb_dim: str | None = None, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayLike], ArrayLike] = None, + compute_histogram: bool = True, + index_mappings=None, name: str = None, ): - if processor_kwargs is None: - processor_kwargs = dict() self._global_index = global_index - self._processor = processor( + self._processor = NDImageProcessor( data, - *args, + dims=dims, + spatial_dims=spatial_dims, + rgb_dim=rgb_dim, window_funcs=window_funcs, - window_sizes=window_sizes, + window_order=window_order, + spatial_func=spatial_func, + compute_histogram=compute_histogram, index_mappings=index_mappings, - **processor_kwargs, ) self._graphic = None self._create_graphic() - - self._name = name + super().__init__(name) @property def processor(self) -> NDImageProcessor: @@ -558,9 +255,7 @@ def processor(self) -> NDImageProcessor: @property def graphic( self, - ) -> ( - ImageGraphic | ImageVolumeGraphic - ): + ) -> ImageGraphic | ImageVolumeGraphic: """LineStack or ImageGraphic for heatmaps""" return self._graphic @@ -570,7 +265,7 @@ def graphic(self, graphic_type): pass def _create_graphic(self): - match self.processor.n_display_dims: + match len(self.processor.spatial_dims): case 2: cls = ImageGraphic case 3: @@ -587,24 +282,59 @@ def _create_graphic(self): plot_area.add_graphic(new_graphic) self._graphic = new_graphic + if self._graphic._plot_area is not None: + self._reset_camera() + + def _reset_camera(self): + plot_area = self._graphic._plot_area + + # set camera to a nice position for 2D or 3D + if isinstance(self._graphic, ImageGraphic): + # set camera orthogonal to the xy plane, flip y axis + plot_area.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, + } + ) + + plot_area.controller = "panzoom" + plot_area.axes.intersection = None + plot_area.auto_scale() + + else: + plot_area.camera.fov = 50 + plot_area.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(plot_area.camera.local, f"scale_{dim}") < 0: + setattr(plot_area.camera.local, f"scale_{dim}", 1) + + plot_area.auto_scale() @property - def n_display_dims(self) -> Literal[2, 3]: - return self.processor.n_display_dims + def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: + return self.processor.spatial_dims - @n_display_dims.setter - def n_display_dims(self, n: Literal[2 , 3]): - self.processor.n_display_dims = n + @spatial_dims.setter + def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): + self.processor.spatial_dims = dims + # shape has probably changed, recreate graphic self._create_graphic() @property - def indices(self) -> tuple: - return self._global_index.indices[-self.processor.n_slider_dims:] + def indices(self) -> dict[Hashable, Any]: + return {d: self._global_index[d] for d in self.processor.slider_dims} @indices.setter def indices(self, indices): - indices = indices[-self.processor.n_slider_dims:] data_slice = self.processor.get(indices) self.graphic.data = data_slice diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 296787d56..acfc84630 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -89,4 +89,6 @@ def get(self, indices: dict[str, Any]) -> np.ndarray: [self.data[c][self._dw_slice] for c in col] ) + fin + return self._apply_dw_window_func(graphic_data) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index 35183b031..fe3068757 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -252,6 +252,13 @@ def _apply_dw_window_func(self, array: np.ndarray) -> np.ndarray: return array[:, ::step] + def _apply_spatial_func(self, array: np.ndarray): + if self.spatial_func is not None: + return self.spatial_func(array) + + def _finalize_(self, array): + return self._apply_spatial_func(self._apply_dw_window_func(array)) + def get(self, indices: dict[str, Any]): """ slices through all slider dims and outputs an array that can be used to set graphic data @@ -286,7 +293,7 @@ def get(self, indices: dict[str, Any]): window_output.isel({p_dim: dw_slice}).transpose(*self.spatial_dims).values ) - return self._apply_dw_window_func(graphic_data) + return self._finalize_(graphic_data) class NDPositions(NDGraphic): diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 677661b9b..5e625cc99 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -32,13 +32,12 @@ def add_nd_image(self, *args, **kwargs): nd = NDImage(self.ndw.indices, *args, **kwargs) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) + nd._reset_camera() return nd def add_nd_scatter(self, *args, **kwargs): # TODO: better func signature here, send all kwargs to processor_kwargs - nd = NDPositions( - self.ndw.indices, *args, graphic=ScatterCollection, **kwargs - ) + nd = NDPositions(self.ndw.indices, *args, graphic=ScatterCollection, **kwargs) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) @@ -71,7 +70,7 @@ def add_nd_timeseries( return nd def add_nd_lines(self, *args, **kwargs): - nd = NDPositions(*args, graphic=LineCollection, **kwargs) + nd = NDPositions(self.ndw.indices, *args, graphic=LineCollection, **kwargs) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) return nd diff --git a/fastplotlib/widgets/nd_widget/base.py b/fastplotlib/widgets/nd_widget/base.py index c81053a62..4d55a3514 100644 --- a/fastplotlib/widgets/nd_widget/base.py +++ b/fastplotlib/widgets/nd_widget/base.py @@ -70,7 +70,7 @@ def __init__( window_funcs: dict[ Hashable, tuple[WindowFuncCallable | None, int | float | None] ] = None, - window_funcs_order: tuple[Hashable, ...] = None, + window_order: tuple[Hashable, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): self._data = self._validate_data(data, dims) @@ -79,7 +79,8 @@ def __init__( self.index_mappings = index_mappings self.window_funcs = window_funcs - self.window_order = window_funcs_order + self.window_order = window_order + self.spatial_func = spatial_func @property def data(self) -> xr.DataArray: @@ -115,6 +116,7 @@ def dims(self) -> tuple[Hashable, ...]: @property def spatial_dims(self) -> tuple[Hashable, ...]: + """Spatial dims, **in order**)""" return self._spatial_dims @spatial_dims.setter @@ -225,8 +227,15 @@ def window_order(self, order: tuple[Hashable] | None): self._window_order = tuple(order) @property - def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: - pass + def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + return self._spatial_func + + @spatial_func.setter + def spatial_func(self, func: Callable[[xr.DataArray], xr.DataArray]) -> Callable | None: + if not callable(func) and func is not None: + raise TypeError + + self._spatial_func = func @property def index_mappings(self) -> dict[Hashable, Callable[[Any], int]]: @@ -278,8 +287,11 @@ def _get_slider_dims_indexer(self, indices) -> dict: # index for this dim in reference space index_ref = indices[dim] - # get window func and size in reference units - wf, ws = self.window_funcs[dim] + if dim not in self.window_funcs.keys(): + wf, ws = None, None + else: + # get window func and size in reference units + wf, ws = self.window_funcs[dim] # if a window function exists for this dim, and it's specified in the window order if (wf is not None) and (ws is not None) and (dim in self.window_order): From 4f005ed5fd04097c3dc17aa951ad722b07759f4d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Mar 2026 09:19:46 -0500 Subject: [PATCH 053/163] NDImage working mostly, behavior viz is back --- fastplotlib/widgets/nd_widget/_nd_image.py | 3 ++- .../widgets/nd_widget/_nd_positions/_pandas.py | 2 -- .../widgets/nd_widget/_nd_positions/core.py | 14 +++++++------- fastplotlib/widgets/nd_widget/_ui.py | 7 +++++-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 16a4686ec..152f59379 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -265,7 +265,7 @@ def graphic(self, graphic_type): pass def _create_graphic(self): - match len(self.processor.spatial_dims): + match len(self.processor.spatial_dims) - int(bool(self.processor.rgb_dim)): case 2: cls = ImageGraphic case 3: @@ -282,6 +282,7 @@ def _create_graphic(self): plot_area.add_graphic(new_graphic) self._graphic = new_graphic + if self._graphic._plot_area is not None: self._reset_camera() diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index acfc84630..296787d56 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -89,6 +89,4 @@ def get(self, indices: dict[str, Any]) -> np.ndarray: [self.data[c][self._dw_slice] for c in col] ) - fin - return self._apply_dw_window_func(graphic_data) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/core.py index fe3068757..fd2914079 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/core.py @@ -252,10 +252,12 @@ def _apply_dw_window_func(self, array: np.ndarray) -> np.ndarray: return array[:, ::step] - def _apply_spatial_func(self, array: np.ndarray): + def _apply_spatial_func(self, array: xr.DataArray) -> xr.DataArray: if self.spatial_func is not None: return self.spatial_func(array) + return array + def _finalize_(self, array): return self._apply_spatial_func(self._apply_dw_window_func(array)) @@ -266,11 +268,9 @@ def get(self, indices: dict[str, Any]): Note that we do not use __getitem__ here since the index is a tuple specifying a single integer index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. """ - # # map slider dim indices to array indices - # array_indices = tuple([m(i) for m, i in zip(self.index_mappings, indices)]) - if len(self.slider_dims) > 0: - # there are dims in addition to the spatial dims + if len(self.slider_dims) > 1: + # there are slider dims in addition to the datapoints_dim window_output = self._apply_window_functions(indices).squeeze() else: # no slider dims, use all the data @@ -290,10 +290,10 @@ def get(self, indices: dict[str, Any]): # slice the datapoints to be displayed in the graphic using the display window slice # transpose to match spatial dims order, get numpy array, this is a view graphic_data = ( - window_output.isel({p_dim: dw_slice}).transpose(*self.spatial_dims).values + window_output.isel({p_dim: dw_slice}).transpose(*self.spatial_dims) ) - return self._finalize_(graphic_data) + return self._finalize_(graphic_data).values class NDPositions(NDGraphic): diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 17f908384..147202e69 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -122,12 +122,15 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): changed, val = imgui.checkbox( "use display window", nd_graphic.display_window is not None ) + + p_dim = nd_graphic.processor.spatial_dims[1] + if changed: if not val: nd_graphic.display_window = None else: # pick a value 10% of the reference range - nd_graphic.display_window = self._ndwidget.ref_ranges[0].range * 0.1 + nd_graphic.display_window = self._ndwidget.ref_ranges[p_dim].range * 0.1 if nd_graphic.display_window is not None: if isinstance(nd_graphic.display_window, (int, np.integer)): @@ -143,7 +146,7 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): "display window", v=nd_graphic.display_window, v_min=type_(0), - v_max=type_(self._ndwidget.ref_ranges[0].stop * 0.25), + v_max=type_(self._ndwidget.ref_ranges[p_dim].stop * 0.25), ) if changed: From 597c48b1c0060179d4749224c4099e9414ec2cdc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Mar 2026 09:32:00 -0500 Subject: [PATCH 054/163] better flipping logic --- fastplotlib/layouts/_figure.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 00b915b1f..2b22cbd23 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -609,7 +609,9 @@ def show( for subplot in self._subplots.ravel(): for g in subplot.graphics: if isinstance(g, ImageGraphic): - subplot.camera.local.scale_y *= -1 + if subplot.camera.local.scale_y == 1: + # if it's 1 it's likely not been touched manually before show was called + subplot.camera.local.scale_y = -1 break if autoscale: From 68571b75e53f9533bf96a8bc12038d8a64219ed7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Mar 2026 10:13:35 -0500 Subject: [PATCH 055/163] update examples --- examples/ndwidget/README.rst | 2 ++ examples/ndwidget/ndimage.py | 21 +++++++++---- examples/ndwidget/timeseries.py | 53 ++++++++++++++++++++++++--------- 3 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 examples/ndwidget/README.rst diff --git a/examples/ndwidget/README.rst b/examples/ndwidget/README.rst new file mode 100644 index 000000000..28ed4d752 --- /dev/null +++ b/examples/ndwidget/README.rst @@ -0,0 +1,2 @@ +NDWidget Examples +================= diff --git a/examples/ndwidget/ndimage.py b/examples/ndwidget/ndimage.py index 0e44cd1b5..7400f12e3 100644 --- a/examples/ndwidget/ndimage.py +++ b/examples/ndwidget/ndimage.py @@ -12,14 +12,25 @@ import fastplotlib as fpl -a = np.random.rand(1000, 30, 64, 64) +data = np.random.rand(1000, 30, 64, 64) +# must define a reference range for each dim +ref = { + "time": ("time", "s", 0, 1000, 1), + "depth": ("depth", "um", 0, 30, 1), +} -ndw = fpl.NDWidget(ref_ranges=[(0, 1000, 1, "t"), (0, 30, 1, "um")], size=(800, 800)) + +ndw = fpl.NDWidget(ref_ranges=ref, size=(700, 560)) ndw.show() -ndi = ndw[0, 0].add_nd_image(a, index_mappings=(int, int)) -# TODO: need to think about how to "auto ignore" reference range for a dim when switching between 2 & 3 dim images -ndi.n_display_dims = 3 +ndi = ndw[0, 0].add_nd_image( + data, + ("time", "depth", "m", "n"), # specify all dim names + ("m", "n"), # specify spatial dims IN ORDER, rest are auto slider dims +) + +# change spatial dims on the fly +ndi.spatial_dims = ("depth", "m", "n") fpl.loop.run() diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py index 1dac31326..fefc385df 100644 --- a/examples/ndwidget/timeseries.py +++ b/examples/ndwidget/timeseries.py @@ -12,28 +12,53 @@ import fastplotlib as fpl # generate some toy timeseries data -n_datapoints = 50_000 # number of datapoints per line +n_datapoints = 100_000 # number of datapoints per line +n_freqs = 20 # number of frequencies +n_ampls = 15 # number of amplitudes +n_lines = 8 + xs = np.linspace(0, 1000 * np.pi, n_datapoints) -lines = list() -for i in range(1, 11): - l = np.column_stack( - [ - xs, - np.sin(xs * i) - ] - ) - lines.append(l) +data = np.zeros(shape=(n_freqs, n_ampls, n_lines, n_datapoints, 2), dtype=np.float32) + +for freq in range(data.shape[0]): + for ampl in range(data.shape[1]): + ys = np.sin(xs * (freq + 1)) * (ampl + 1) + np.random.normal(0, 0.1, size=n_datapoints) + line = np.column_stack([xs, ys]) + data[freq, ampl] = np.stack([line] * n_lines) -# timeseries data of shape [n_lines, n_datapoint, 2] -data = np.stack(lines) # must define a reference range, this would often be your time dimension and corresponds to your x-dimension -ref = [(0, xs[-1], 0.1, "angle")] +ref = { + "freq": ("freq", "Hz", 1, n_freqs + 1, 1), + "ampl": ("ampl", "arbitrary", 1, n_ampls + 1, 1), + "angle": ( + "angle", + "rad", + 0, + xs[-1], + 0.1, + ), +} ndw = fpl.NDWidget(ref_ranges=ref, size=(700, 560)) -ndw[0, 0].add_nd_timeseries(data, index_mappings=(lambda xval: xs.searchsorted(xval),), x_range_mode="view-range") +nd_lines = ndw[0, 0].add_nd_timeseries( + data, + ("freq", "ampl", "n_lines", "angle", "d"), + ("n_lines", "angle", "d"), + index_mappings={ + "angle": xs, + "ampl": lambda x: int(x + 1), + "freq": lambda x: int(x + 1), + }, + x_range_mode="view-range", +) + +nd_lines.graphic.cmap = "tab10" + +subplot = ndw.figure[0, 0] +subplot.controller.add_camera(subplot.camera, include_state={"x", "width"}) ndw.show(maintain_aspect=False) fpl.loop.run() From cad17c844fe524a140c863fca3dc3a1279678f41 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 00:08:13 -0500 Subject: [PATCH 056/163] more progress --- fastplotlib/widgets/nd_widget/__init__.py | 4 +- .../widgets/nd_widget/{base.py => _base.py} | 40 ------- fastplotlib/widgets/nd_widget/_index.py | 67 ++++++----- fastplotlib/widgets/nd_widget/_nd_image.py | 5 +- .../nd_widget/_nd_positions/__init__.py | 2 +- .../{core.py => _nd_positions.py} | 112 +++++++++--------- .../nd_widget/_nd_positions/_pandas.py | 2 +- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 2 +- .../nd_widget/{ndwidget.py => _ndwidget.py} | 4 +- fastplotlib/widgets/nd_widget/_ui.py | 9 +- 10 files changed, 109 insertions(+), 138 deletions(-) rename fastplotlib/widgets/nd_widget/{base.py => _base.py} (94%) rename fastplotlib/widgets/nd_widget/_nd_positions/{core.py => _nd_positions.py} (85%) rename fastplotlib/widgets/nd_widget/{ndwidget.py => _ndwidget.py} (89%) diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index 7855327d9..0617a729d 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -1,10 +1,10 @@ from ...layouts import IMGUI if IMGUI: - from .base import NDProcessor + from ._base import NDProcessor from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras from ._nd_image import NDImageProcessor, NDImage - from .ndwidget import NDWidget + from ._ndwidget import NDWidget else: class NDWidget: def __init__(self, *args, **kwargs): diff --git a/fastplotlib/widgets/nd_widget/base.py b/fastplotlib/widgets/nd_widget/_base.py similarity index 94% rename from fastplotlib/widgets/nd_widget/base.py rename to fastplotlib/widgets/nd_widget/_base.py index 4d55a3514..ea4844fdb 100644 --- a/fastplotlib/widgets/nd_widget/base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -20,46 +20,6 @@ def identity(index: int) -> int: return round(index) -class BaseNDProcessor: - @property - def data(self) -> Any: - pass - - @property - def shape(self) -> dict[Hashable, int]: - pass - - @property - def ndim(self): - pass - - @property - def spatial_dims(self) -> tuple[Hashable, ...]: - pass - - @property - def slider_dims(self): - pass - - @property - def window_funcs( - self, - ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]]: - # {dim: (func, size)} - pass - - @property - def window_funcs_order(self) -> tuple[Hashable]: - pass - - @property - def index_mappings(self) -> dict[Hashable, Callable[[Any], int] | ArrayLike]: - pass - - def get(self, **indices): - raise NotImplementedError - - class NDProcessor: def __init__( self, diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index d7f60ba7e..31d026beb 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -1,17 +1,21 @@ from dataclasses import dataclass from typing import Sequence, Any, Callable -from .base import NDGraphic +from ._base import NDGraphic @dataclass -class ReferenceRangeContinuous: - name: str - unit: str +class RangeContinuous: start: int | float stop: int | float step: int | float + def __post_init__(self): + if self.start >= self.stop: + raise IndexError( + f"start must be less than stop, {self.start} !< {self.stop}" + ) + def __getitem__(self, index: int): """return the value at the index w.r.t. the step size""" # if index is negative, turn to positive index @@ -32,9 +36,7 @@ def range(self) -> int | float: @dataclass -class ReferenceRangeDiscrete: - name: str - unit: str +class RangeDiscrete: options: Sequence[Any] def __getitem__(self, index: int): @@ -48,38 +50,54 @@ def __len__(self): class GlobalIndex: - def __init__(self, ref_ranges: dict[str, tuple], get_ndgraphics: Callable[[], tuple[NDGraphic]]): + def __init__( + self, + ref_ranges: dict[str, tuple], + get_ndgraphics: Callable[[], tuple[NDGraphic]], + ): self._ref_ranges = dict() - for r in ref_ranges.values(): - if len(r) == 5: - # assume name, unit, start, stop, step - rr = ReferenceRangeContinuous(*r) - elif len(r) == 3: - rr = ReferenceRangeDiscrete(*r) + for name, r in ref_ranges.items(): + if len(r) == 3: + # assume start, stop, step + self._ref_ranges[name] = RangeContinuous(*r) + + elif len(r) == 1: + # assume just options + self._ref_ranges[name] = RangeDiscrete(*r) + else: raise ValueError - self._ref_ranges[rr.name] = rr - self._get_ndgraphics = get_ndgraphics # starting index for all dims - self._indices: dict[str, int | float | Any] = {rr.name: rr.start for rr in self._ref_ranges.values()} + self._indices: dict[str, int | float | Any] = { + name: rr.start for name, rr in self._ref_ranges.items() + } def set(self, indices: dict[str, Any]): - for k in self._indices: - self._indices[k] = indices[k] + for dim, value in indices.items(): + self._indices[dim] = self._clamp(value) self._render_indices() + def _clamp(self, dim, value): + if isinstance(self.ref_ranges[dim], RangeContinuous): + return max( + min(value, self.ref_ranges[dim].stop - self.ref_ranges[dim].step), + self.ref_ranges[dim].start, + ) + + return value + def _render_indices(self): for g in self._get_ndgraphics(): # only provide slider indices to the graphic g.indices = {d: self._indices[d] for d in g.processor.slider_dims} @property - def ref_ranges(self) -> dict[str, ReferenceRangeContinuous | ReferenceRangeDiscrete]: + def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: return self._ref_ranges def __getitem__(self, dim): @@ -87,18 +105,13 @@ def __getitem__(self, dim): def __setitem__(self, dim, value): # set index for given dim and render - - # clamp within reference range - if isinstance(self.ref_ranges[dim], ReferenceRangeContinuous): - value = max(min(value, self.ref_ranges[dim].stop - self.ref_ranges[dim].step), self.ref_ranges[dim].start) - - self._indices[dim] = value + self._indices[dim] = self._clamp(dim, value) self._render_indices() def pop_dim(self): pass - def push_dim(self, ref_range: ReferenceRangeContinuous): + def push_dim(self, ref_range: RangeContinuous): # TODO: implement pushing and popping dims pass diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 152f59379..f6a41cd4f 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -1,7 +1,6 @@ from collections.abc import Hashable, Sequence import inspect -from typing import Literal, Callable, Type, Any -from warnings import warn +from typing import Callable, Any import numpy as np from numpy.typing import ArrayLike @@ -9,7 +8,7 @@ from ...utils import subsample_array, ArrayProtocol, ARRAY_LIKE_ATTRS from ...graphics import ImageGraphic, ImageVolumeGraphic -from .base import NDProcessor, NDGraphic, WindowFuncCallable +from ._base import NDProcessor, NDGraphic, WindowFuncCallable class NDImageProcessor(NDProcessor): diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py index 03bb0e8f7..60703f8c2 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py @@ -1,6 +1,6 @@ import importlib -from .core import NDPositions, NDPositionsProcessor +from ._nd_positions import NDPositions, NDPositionsProcessor class Extras: pass diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/core.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py similarity index 85% rename from fastplotlib/widgets/nd_widget/_nd_positions/core.py rename to fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index fd2914079..08b5406ba 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/core.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -1,15 +1,12 @@ -from collections.abc import Callable, Hashable, Sequence, Iterable +from collections.abc import Callable, Hashable, Sequence from functools import partial from typing import Literal, Any, Type -from warnings import warn import numpy as np from numpy.lib.stride_tricks import sliding_window_view from numpy.typing import ArrayLike import xarray as xr -from ....utils import subsample_array, ArrayProtocol - from ....graphics import ( Graphic, ImageGraphic, @@ -21,7 +18,7 @@ ) from ....graphics.utils import pause_events from ....graphics.selectors import LinearSelector -from ..base import ( +from .._base import ( NDProcessor, NDGraphic, WindowFuncCallable, @@ -38,10 +35,10 @@ class NDPositionsProcessor(NDProcessor): def __init__( self, data: Any, - dims: Sequence[str], + dims: Sequence[Hashable], # TODO: allow stack_dim to be None and auto-add new dim of size 1 in get logic spatial_dims: tuple[ - str | None, str, str + Hashable | None, Hashable, Hashable ], # [stack_dim, n_datapoints, spatial_dim], IN ORDER!! index_mappings: dict[str, Callable[[Any], int] | ArrayLike] = None, display_window: int | float | None = 100, # window for n_datapoints dim only @@ -192,63 +189,64 @@ def _apply_dw_window_func(self, array: np.ndarray) -> np.ndarray: p_dim = self.spatial_dims[1] # display window in array index space - dw = self.index_mappings[p_dim](self.display_window) + if self.display_window is not None: + dw = self.index_mappings[p_dim](self.display_window) - # step size based on max number of datapoints to render - step = max(1, dw // self.max_display_datapoints) + # step size based on max number of datapoints to render + step = max(1, dw // self.max_display_datapoints) - # apply window function on the `p` n_datapoints dim - if ( - self.datapoints_window_func is not None - # if there are too many points to efficiently compute the window func, skip - # applying a window func also requires making a copy so that's a further performance hit - and (dw < self.max_display_datapoints * 2) - ): - # get windows + # apply window function on the `p` n_datapoints dim + if ( + self.datapoints_window_func is not None + # if there are too many points to efficiently compute the window func, skip + # applying a window func also requires making a copy so that's a further performance hit + and (dw < self.max_display_datapoints * 2) + ): + # get windows - # graphic_data will be of shape: [n, p, 2 | 3] - # where: - # n - number of lines, scatters, heatmap rows - # p - number of datapoints/samples + # graphic_data will be of shape: [n, p, 2 | 3] + # where: + # n - number of lines, scatters, heatmap rows + # p - number of datapoints/samples - # ws is in ref units - wf, apply_dims, ws = self.datapoints_window_func + # ws is in ref units + wf, apply_dims, ws = self.datapoints_window_func - # map ws in ref units to array index - # min window size is 3 - ws = max(self._ref_index_to_array_index(p_dim, ws), 3) + # map ws in ref units to array index + # min window size is 3 + ws = max(self._ref_index_to_array_index(p_dim, ws), 3) - if ws % 2 == 0: - # odd size windows are easier to handle - ws += 1 + if ws % 2 == 0: + # odd size windows are easier to handle + ws += 1 - hw = ws // 2 - start, stop = hw, array.shape[1] - hw + hw = ws // 2 + start, stop = hw, array.shape[1] - hw - # apply user's window func - # result will be of shape [n, p, 2 | 3] - if apply_dims == "all": - # windows will be of shape [n, p, 1 | 2 | 3, ws] - windows = sliding_window_view(array, ws, axis=-2) - return wf(windows, axis=-1)[:, ::step] + # apply user's window func + # result will be of shape [n, p, 2 | 3] + if apply_dims == "all": + # windows will be of shape [n, p, 1 | 2 | 3, ws] + windows = sliding_window_view(array, ws, axis=-2) + return wf(windows, axis=-1)[:, ::step] - # map user dims str to tuple of numerical dims - dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) + # map user dims str to tuple of numerical dims + dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) - # windows will be of shape [n, (p - ws + 1), 1 | 2 | 3, ws] - windows = sliding_window_view( - array[..., dims], ws, axis=-2 - ).squeeze() + # windows will be of shape [n, (p - ws + 1), 1 | 2 | 3, ws] + windows = sliding_window_view(array[..., dims], ws, axis=-2).squeeze() - # make a copy because we need to modify it - array = array[:, start:stop].copy() + # make a copy because we need to modify it + array = array[:, start:stop].copy() - # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary - array[..., dims] = wf(windows, axis=-1).reshape( - *array.shape[:-1], len(dims) - ) + # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary + array[..., dims] = wf(windows, axis=-1).reshape( + *array.shape[:-1], len(dims) + ) + + return array[:, ::step] - return array[:, ::step] + step = max(1, array.shape[1] // self.max_display_datapoints) return array[:, ::step] @@ -289,8 +287,8 @@ def get(self, indices: dict[str, Any]): # slice the datapoints to be displayed in the graphic using the display window slice # transpose to match spatial dims order, get numpy array, this is a view - graphic_data = ( - window_output.isel({p_dim: dw_slice}).transpose(*self.spatial_dims) + graphic_data = window_output.isel({p_dim: dw_slice}).transpose( + *self.spatial_dims ) return self._finalize_(graphic_data).values @@ -431,7 +429,9 @@ def indices(self, indices): with pause_events(self._linear_selector): self._linear_selector.limits = xr # linear selector acts on `p` dim - self._linear_selector.selection = indices[self.processor.spatial_dims[1]] + self._linear_selector.selection = indices[ + self.processor.spatial_dims[1] + ] def _linear_selector_handler(self, ev): with block_indices(self): @@ -554,7 +554,9 @@ def _update_from_view_range(self): new_width = abs(xr[1] - xr[0]) new_index = (xr[0] + xr[1]) / 2 - if (new_index == self._global_index[self.processor.spatial_dims[1]]) and (last_width == new_width): + if (new_index == self._global_index[self.processor.spatial_dims[1]]) and ( + last_width == new_width + ): return self.processor.display_window = new_width diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 296787d56..26acfd73d 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -3,7 +3,7 @@ import numpy as np import pandas as pd -from .core import NDPositionsProcessor +from ._nd_positions import NDPositionsProcessor class NDPP_Pandas(NDPositionsProcessor): diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 5e625cc99..ef42e65bb 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -3,7 +3,7 @@ from ... import ScatterCollection, LineCollection, LineStack, ImageGraphic from ...layouts import Subplot from . import NDImage, NDPositions -from .base import NDGraphic +from ._base import NDGraphic class NDWSubplot: diff --git a/fastplotlib/widgets/nd_widget/ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py similarity index 89% rename from fastplotlib/widgets/nd_widget/ndwidget.py rename to fastplotlib/widgets/nd_widget/_ndwidget.py index 534c1a922..20f09ba55 100644 --- a/fastplotlib/widgets/nd_widget/ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -1,6 +1,6 @@ from typing import Any -from ._index import ReferenceRangeContinuous, ReferenceRangeDiscrete, GlobalIndex +from ._index import RangeContinuous, RangeDiscrete, GlobalIndex from ._ndw_subplot import NDWSubplot from ._ui import NDWidgetUI from ...layouts import ImguiFigure, Subplot @@ -34,7 +34,7 @@ def indices(self, new_indices: dict[str, int | float | Any]): self._indices.set(new_indices) @property - def ref_ranges(self) -> dict[str, ReferenceRangeContinuous | ReferenceRangeDiscrete]: + def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: return self._indices.ref_ranges def __getitem__(self, key: str | tuple[int, int] | Subplot): diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 147202e69..3223fe595 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -11,8 +11,8 @@ from ...layouts import Subplot from ...ui import EdgeWindow from . import NDPositions -from ._index import ReferenceRangeContinuous -from .base import NDGraphic +from ._index import RangeContinuous +from ._base import NDGraphic position_graphics = [ScatterCollection, LineCollection, LineStack, ImageGraphic] image_graphics = [ImageGraphic, ImageVolumeGraphic] @@ -56,9 +56,6 @@ def __init__(self, figure, size, ndwidget): # # self.pause = False - self._selected_subplot = self._ndwidget.figure[0, 0].name - self._selected_nd_graphic = 0 - self._max_display_windows: dict[NDGraphic, float | int] = dict() def update(self): @@ -68,7 +65,7 @@ def update(self): for dim, current_index in self._ndwidget.indices: refr = self._ndwidget.ref_ranges[dim] - if isinstance(refr, ReferenceRangeContinuous): + if isinstance(refr, RangeContinuous): changed, new_index = imgui.slider_float( v=current_index, v_min=refr.start, From 4c902ba0234f7a09ba7674e248efddc377423328 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 00:08:36 -0500 Subject: [PATCH 057/163] update example --- examples/ndwidget/ndimage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/ndwidget/ndimage.py b/examples/ndwidget/ndimage.py index 7400f12e3..4212f46b6 100644 --- a/examples/ndwidget/ndimage.py +++ b/examples/ndwidget/ndimage.py @@ -16,8 +16,8 @@ # must define a reference range for each dim ref = { - "time": ("time", "s", 0, 1000, 1), - "depth": ("depth", "um", 0, 30, 1), + "time": (0, 1000, 1), + "depth": (0, 30, 1), } From 1248e8e4acc2cf211619981aad0f9f269693d608 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 00:58:44 -0500 Subject: [PATCH 058/163] histogram working for images --- fastplotlib/widgets/nd_widget/_base.py | 49 +++++++++++-- fastplotlib/widgets/nd_widget/_nd_image.py | 69 ++++++++++++++++++- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 12 ++-- 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index ea4844fdb..421b43360 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -191,7 +191,9 @@ def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: return self._spatial_func @spatial_func.setter - def spatial_func(self, func: Callable[[xr.DataArray], xr.DataArray]) -> Callable | None: + def spatial_func( + self, func: Callable[[xr.DataArray], xr.DataArray] + ) -> Callable | None: if not callable(func) and func is not None: raise TypeError @@ -202,7 +204,9 @@ def index_mappings(self) -> dict[Hashable, Callable[[Any], int]]: return self._index_mappings @index_mappings.setter - def index_mappings(self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None): + def index_mappings( + self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None + ): if maps is None: self._index_mappings = {d: identity for d in self.dims} return @@ -353,13 +357,50 @@ def graphic(self) -> Graphic: raise NotImplementedError @property - def indices(self) -> tuple[Any]: + def indices(self) -> dict[Hashable, Any]: raise NotImplementedError @indices.setter - def indices(self, new: tuple): + def indices(self, new: dict[Hashable, Any]): raise NotImplementedError + # aliases for easier access to processor properties + @property + def window_funcs( + self, + ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]]: + """get or set window functions, see docstring for details""" + return self.processor.window_funcs + + @window_funcs.setter + def window_funcs( + self, + window_funcs: ( + dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None] + | None + ), + ): + self.processor.window_funcs = window_funcs + + @property + def window_order(self) -> tuple[Hashable, ...]: + """get or set dimension order in which window functions are applied""" + return self.processor.window_order + + @window_order.setter + def window_order(self, order: tuple[Hashable] | None): + self.processor.window_order = order + + @property + def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + return self.processor.spatial_func + + @spatial_func.setter + def spatial_func( + self, func: Callable[[xr.DataArray], xr.DataArray] + ) -> Callable | None: + self.processor.spatial_func = func + @contextmanager def block_indices(ndgraphic: NDGraphic): diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index f6a41cd4f..12a2b791e 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -8,6 +8,7 @@ from ...utils import subsample_array, ArrayProtocol, ARRAY_LIKE_ATTRS from ...graphics import ImageGraphic, ImageVolumeGraphic +from ...tools import HistogramLUTTool from ._base import NDProcessor, NDGraphic, WindowFuncCallable @@ -204,7 +205,9 @@ def _recompute_histogram(self): else: ignore_dims = None - sub = subsample_array(self.data.values, ignore_dims=ignore_dims) + # TODO: account for window funcs + + sub = subsample_array(self.data, ignore_dims=ignore_dims) sub_real = sub[~(np.isnan(sub) | np.isinf(sub))] self._histogram = np.histogram(sub_real, bins=100) @@ -242,7 +245,8 @@ def __init__( index_mappings=index_mappings, ) - self._graphic = None + self._graphic: ImageGraphic | None = None + self._histogram_widget: HistogramLUTTool | None = None self._create_graphic() super().__init__(name) @@ -276,15 +280,55 @@ def _create_graphic(self): new_graphic = cls(data_slice) if old_graphic is not None: + # carry over some attributes from old graphic + attrs = dict.fromkeys(["cmap", "interpolation", "cmap_interpolation"]) + for k in attrs: + attrs[k] = getattr(old_graphic, k) + plot_area = old_graphic._plot_area plot_area.delete_graphic(old_graphic) plot_area.add_graphic(new_graphic) + # set cmap and interpolation + for attr, val in attrs.keys(): + setattr(new_graphic, attr, val) + self._graphic = new_graphic if self._graphic._plot_area is not None: self._reset_camera() + self._reset_histogram() + + def _reset_histogram(self): + # reset histogram + if self._graphic._plot_area is None: + return + + if not self.processor.compute_histogram: + # hide right dock if histogram not desired + self._graphic._plot_area.docks["right"].size = 0 + return + + if self.processor.histogram: + if self._histogram_widget: + # histogram widget exists, update it + self._histogram_widget.histogram = self.processor.histogram + self._histogram_widget.images = self.graphic + if self.graphic._plot_area.docks["right"].size < 1: + self.graphic._plot_area.docks["right"].size = 80 + else: + # make hist tool + self._histogram_widget = HistogramLUTTool( + histogram=self.processor.histogram, + images=self.graphic, + name=f"hist-{hex(id(self.graphic))}", + ) + self.graphic._plot_area.docks["right"].add_graphic(self._histogram_widget) + self.graphic._plot_area.docks["right"].size = 80 + + self.graphic.reset_vmin_vmax() + def _reset_camera(self): plot_area = self._graphic._plot_area @@ -339,6 +383,27 @@ def indices(self, indices): self.graphic.data = data_slice + @property + def compute_histogram(self) -> bool: + return self.processor.compute_histogram + + @compute_histogram.setter + def compute_histogram(self, v: bool): + self.processor.compute_histogram = v + self._reset_histogram() + + @property + def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + return self.processor.spatial_func + + @spatial_func.setter + def spatial_func( + self, func: Callable[[xr.DataArray], xr.DataArray] + ) -> Callable | None: + self.processor.spatial_func = func + self.processor._recompute_histogram() + self._reset_histogram() + def _tooltip_handler(self, graphic, pick_info): # get graphic within the collection n_index = np.argwhere(self.graphic.graphics == graphic).item() diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index ef42e65bb..5a0b00da2 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -33,6 +33,10 @@ def add_nd_image(self, *args, **kwargs): self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) nd._reset_camera() + + # graphic._plot_area must exist before this is called + nd._reset_histogram() + return nd def add_nd_scatter(self, *args, **kwargs): @@ -54,19 +58,19 @@ def add_nd_timeseries( self.ndw.indices, *args, graphic=graphic, - # x_range_mode=x_range_mode, linear_selector=True, **kwargs, ) self._nd_graphics.append(nd) self._subplot.add_graphic(nd.graphic) self._subplot.add_graphic(nd._linear_selector) - # nd._linear_selector.add_event_handler( - # partial(self._set_indices_from_selector, nd), "selection" - # ) + # need plot_area to exist before these this can be called nd.x_range_mode = x_range_mode + # probably don't want to maintain aspect + self._subplot.camera.maintain_aspect = False + return nd def add_nd_lines(self, *args, **kwargs): From 2ca3fbfb7063a8fca6eeb819430dde1918b09c94 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 01:13:59 -0500 Subject: [PATCH 059/163] NDProcessor property aliases --- fastplotlib/widgets/nd_widget/_base.py | 43 +++++++++++++++++ fastplotlib/widgets/nd_widget/_index.py | 46 +++++++++++++++++++ .../nd_widget/_nd_positions/_nd_positions.py | 10 ++++ fastplotlib/widgets/nd_widget/_ndwidget.py | 3 ++ 4 files changed, 102 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 421b43360..6a997b206 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -365,6 +365,43 @@ def indices(self, new: dict[Hashable, Any]): raise NotImplementedError # aliases for easier access to processor properties + @property + def data(self) -> Any: + return self.processor.data + + @data.setter + def data(self, data: Any): + self.processor.data = data + # force a re-render + self.indices = self.indices + + @property + def shape(self) -> dict[Hashable, int]: + """interpreted shape of the data""" + self.processor.shape + + @property + def ndim(self) -> int: + """number of dims""" + return self.processor.ndim + + @property + def dims(self) -> tuple[Hashable, ...]: + """dim names""" + return self.processor.dims + + @property + def index_mappings(self) -> dict[Hashable, Callable[[Any], int]]: + return self.processor.index_mappings + + @index_mappings.setter + def index_mappings( + self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None + ): + self.processor.index_mappings = maps + # force a re-render + self.indices = self.indices + @property def window_funcs( self, @@ -381,6 +418,8 @@ def window_funcs( ), ): self.processor.window_funcs = window_funcs + # force a re-render + self.indices = self.indices @property def window_order(self) -> tuple[Hashable, ...]: @@ -390,6 +429,8 @@ def window_order(self) -> tuple[Hashable, ...]: @window_order.setter def window_order(self, order: tuple[Hashable] | None): self.processor.window_order = order + # force a re-render + self.indices = self.indices @property def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: @@ -400,6 +441,8 @@ def spatial_func( self, func: Callable[[xr.DataArray], xr.DataArray] ) -> Callable | None: self.processor.spatial_func = func + # force a re-render + self.indices = self.indices @contextmanager diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 31d026beb..b4cca34fe 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -76,6 +76,8 @@ def __init__( name: rr.start for name, rr in self._ref_ranges.items() } + self._indices_changed_handlers + def set(self, indices: dict[str, Any]): for dim, value in indices.items(): self._indices[dim] = self._clamp(value) @@ -115,6 +117,50 @@ def push_dim(self, ref_range: RangeContinuous): # TODO: implement pushing and popping dims pass + def add_event_handler(self, handler: callable, event: str = "indices"): + """ + Register an event handler. + + Currently the only event that ImageWidget supports is "indices". This event is + emitted whenever the indices of the ImageWidget changes. + + Parameters + ---------- + handler: callable + callback function, must take a tuple of int as the only argument. This tuple will be the `indices` + + event: str, "indices" + the only supported event is "indices" + + Example + ------- + + .. code-block:: py + + def my_handler(indices): + print(indices) + # example prints: {"t": 100, "z": 15} if the index has 2 slider dimensions "t" and "z" + + # create an NDWidget + ndw = NDWidget(...) + + # add event handler + ndw.indices.add_event_handler(my_handler) + + """ + if event != "indices": + raise ValueError("`indices` is the only event supported by `GlobalIndex`") + + self._indices_changed_handlers.add(handler) + + def remove_event_handler(self, handler: callable): + """Remove a registered event handler""" + self._indices_changed_handlers.remove(handler) + + def clear_event_handlers(self): + """Clear all registered event handlers""" + self._indices_changed_handlers.clear() + def __iter__(self): for index in self._indices.items(): yield index diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 08b5406ba..843953d67 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -387,6 +387,16 @@ def graphic(self, graphic_type): self._create_graphic(graphic_type) plot_area.add_graphic(self._graphic) + @property + def spatial_dims(self) -> tuple[str, str, str]: + return self.processor.spatial_dims + + @spatial_dims.setter + def spatial_dims(self, dims: tuple[str, str, str]): + self.processor.spatial_dims = dims + # force re-render + self.indices = self.indices + @property def indices(self) -> dict[Hashable, Any]: return {d: self._global_index[d] for d in self.processor.slider_dims} diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py index 20f09ba55..a67c9d18d 100644 --- a/fastplotlib/widgets/nd_widget/_ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -51,3 +51,6 @@ def _get_ndgraphics(self): def show(self, **kwargs): return self.figure.show(**kwargs) + + def close(self): + self.figure.close() From 782951f529f1da0c9fe13a22ecc863132069cbe9 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 01:25:54 -0500 Subject: [PATCH 060/163] more aliasing --- fastplotlib/widgets/nd_widget/_base.py | 10 ++++++++ fastplotlib/widgets/nd_widget/_nd_image.py | 2 +- .../nd_widget/_nd_positions/_nd_positions.py | 25 ++++++++++++++++--- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 6a997b206..de9826030 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -390,6 +390,16 @@ def dims(self) -> tuple[Hashable, ...]: """dim names""" return self.processor.dims + @property + def spatial_dims(self) -> tuple[str, ...]: + # number of spatial dims for positional data is always 3 + # for image is 2 or 3, so it must be implemented in subclass + raise NotImplementedError + + @property + def slider_dims(self) -> set[Hashable]: + return self.processor.slider_dims + @property def index_mappings(self) -> dict[Hashable, Callable[[Any], int]]: return self.processor.index_mappings diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 12a2b791e..3b363027d 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -265,7 +265,7 @@ def graphic( @graphic.setter def graphic(self, graphic_type): # TODO implement if graphic type changes to custom user subclass - pass + raise NotImplementedError def _create_graphic(self): match len(self.processor.spatial_dims) - int(bool(self.processor.rgb_dim)): diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 843953d67..20fec1fbc 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -124,11 +124,18 @@ def max_display_datapoints(self, n: int): @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: """ - Callable and str indicating which dims to apply window function along: + Callable, str indicating which dims to apply window function along, window_size in reference space: 'all', 'x', 'y', 'z', 'xyz', 'xy', 'xz', 'yz' '""" return self._datapoints_window_func + @datapoints_window_func.setter + def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]): + if len(funcs) != 3: + raise TypeError + + self._datapoints_window_func = tuple(funcs) + def _get_dw_slice(self, indices: dict[str, Any]) -> slice: # given indices, return slice required to obtain display window @@ -168,7 +175,7 @@ def _get_dw_slice(self, indices: dict[str, Any]) -> slice: return slice(start, stop) - def _apply_dw_window_func(self, array: np.ndarray) -> np.ndarray: + def _apply_dw_window_func(self, array: xr.DataArray) -> xr.DataArray: """ Takes array where display window has already been applied and applies window functions on the `p` dim. @@ -256,7 +263,7 @@ def _apply_spatial_func(self, array: xr.DataArray) -> xr.DataArray: return array - def _finalize_(self, array): + def _finalize_(self, array: xr.DataArray) -> xr.DataArray: return self._apply_spatial_func(self._apply_dw_window_func(array)) def get(self, indices: dict[str, Any]): @@ -535,6 +542,18 @@ def display_window(self, dw: int | float | None): # force re-render self.indices = self.indices + @property + def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: + """ + Callable, str indicating which dims to apply window function along, window_size in reference space: + 'all', 'x', 'y', 'z', 'xyz', 'xy', 'xz', 'yz' + '""" + return self.processor.datapoints_window_func + + @datapoints_window_func.setter + def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]): + self.processor.datapoints_window_func = funcs + @property def x_range_mode(self) -> Literal[None, "fixed-window", "view-range"]: """x-range using a fixed window from the display window, or by polling the camera (view-range)""" From 5c6c360a72c53a32e5de1df685de417b8b96d539 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 01:58:13 -0500 Subject: [PATCH 061/163] fix --- fastplotlib/widgets/nd_widget/_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index b4cca34fe..9ba9d03eb 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -76,7 +76,7 @@ def __init__( name: rr.start for name, rr in self._ref_ranges.items() } - self._indices_changed_handlers + self._indices_changed_handlers = set() def set(self, indices: dict[str, Any]): for dim, value in indices.items(): From ca44b94f7bec3bcd29c1841dc0d088a87ab85d9e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 02:07:27 -0500 Subject: [PATCH 062/163] update example --- examples/ndwidget/timeseries.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py index fefc385df..a0a3074ff 100644 --- a/examples/ndwidget/timeseries.py +++ b/examples/ndwidget/timeseries.py @@ -23,22 +23,18 @@ for freq in range(data.shape[0]): for ampl in range(data.shape[1]): - ys = np.sin(xs * (freq + 1)) * (ampl + 1) + np.random.normal(0, 0.1, size=n_datapoints) + ys = np.sin(xs * (freq + 1)) * (ampl + 1) + np.random.normal( + 0, 0.1, size=n_datapoints + ) line = np.column_stack([xs, ys]) data[freq, ampl] = np.stack([line] * n_lines) # must define a reference range, this would often be your time dimension and corresponds to your x-dimension ref = { - "freq": ("freq", "Hz", 1, n_freqs + 1, 1), - "ampl": ("ampl", "arbitrary", 1, n_ampls + 1, 1), - "angle": ( - "angle", - "rad", - 0, - xs[-1], - 0.1, - ), + "freq": (1, n_freqs + 1, 1), + "ampl": (1, n_ampls + 1, 1), + "angle": (0, xs[-1], 0.1), } ndw = fpl.NDWidget(ref_ranges=ref, size=(700, 560)) From 2f680077d545690704c34d2462c03f9c19b3b56c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 02:17:13 -0500 Subject: [PATCH 063/163] fix --- fastplotlib/widgets/nd_widget/_nd_image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 3b363027d..038e7d82f 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -290,7 +290,7 @@ def _create_graphic(self): plot_area.add_graphic(new_graphic) # set cmap and interpolation - for attr, val in attrs.keys(): + for attr, val in attrs.items(): setattr(new_graphic, attr, val) self._graphic = new_graphic From 7f2bcad312f900625a6b1ddd80cc5b4f4c0fb511 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 02:36:59 -0500 Subject: [PATCH 064/163] ui --- fastplotlib/widgets/nd_widget/_ui.py | 175 +++++++++++++++++++-------- 1 file changed, 123 insertions(+), 52 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 3223fe595..be0999fe6 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -1,5 +1,8 @@ +import os +from time import perf_counter + import numpy as np -from imgui_bundle import imgui +from imgui_bundle import imgui, icons_fontawesome_6 as fa from ...graphics import ( ScatterCollection, @@ -31,69 +34,137 @@ def __init__(self, figure, size, ndwidget): ) self._ndwidget = ndwidget - # n_sliders = self._image_widget.n_sliders - # - # # whether or not a dimension is in play mode - # self._playing: list[bool] = [False] * n_sliders - # - # # approximate framerate for playing - # self._fps: list[int] = [20] * n_sliders - # - # # framerate converted to frame time - # self._frame_time: list[float] = [1 / 20] * n_sliders - # - # # last timepoint that a frame was displayed from a given dimension - # self._last_frame_time: list[float] = [perf_counter()] * n_sliders - # - # # loop playback - # self._loop = False - # - # # 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 - # - # self.pause = False + ref_ranges = self._ndwidget.ref_ranges - self._max_display_windows: dict[NDGraphic, float | int] = dict() + # whether or not a dimension is in play mode + self._playing = {dim: False for dim in ref_ranges.keys()} - def update(self): - if imgui.begin_tab_bar("NDWidget Controls"): + # approximate framerate for playing + self._fps = {dim: 20 for dim in ref_ranges.keys()} - if imgui.begin_tab_item("Indices")[0]: - for dim, current_index in self._ndwidget.indices: - refr = self._ndwidget.ref_ranges[dim] + # framerate converted to frame time + self._frame_time = {dim: 1 / 20 for dim in ref_ranges.keys()} - if isinstance(refr, RangeContinuous): - changed, new_index = imgui.slider_float( - v=current_index, - v_min=refr.start, - v_max=refr.stop, - label=dim, - ) + # last timepoint that a frame was displayed from a given dimension + self._last_frame_time = {dim: perf_counter() for dim in ref_ranges.keys()} - # TODO: refactor all this stuff, make fully fledged UI - if changed: - self._ndwidget.indices[dim] = new_index + # loop playback + self._loop ={dim: False for dim in ref_ranges.keys()} - elif imgui.is_item_hovered(): - if imgui.is_key_pressed(imgui.Key.right_arrow): - self._ndwidget.indices[dim] = current_index + refr.step + # 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 - elif imgui.is_key_pressed(imgui.Key.left_arrow): - self._ndwidget.indices[dim] = current_index - refr.step + self._max_display_windows: dict[NDGraphic, float | int] = dict() - imgui.end_tab_item() + def _set_index(self, dim, index): + if index >= self._ndwidget.ref_ranges[dim].stop: + if self._loop[dim]: + index = self._ndwidget.ref_ranges[dim].start + else: + index = self._ndwidget.ref_ranges[dim].stop + self._playing[dim] = False - if imgui.begin_tab_item("NDGraphic properties")[0]: - imgui.text("Subplots:") + self._ndwidget.indices[dim] = index - self._draw_nd_graphics_props_tab() + def update(self): + now = perf_counter() - imgui.end_tab_item() + for dim, current_index in self._ndwidget.indices: + # push id since we have the same buttons for each dim + imgui.push_id(f"{self._id_counter}_{dim}") - imgui.end_tab_bar() + rr = self._ndwidget.ref_ranges[dim] + + if self._playing[dim]: + # show pause button if playing + if imgui.button(label=fa.ICON_FA_PAUSE): + # if pause button clicked, then set playing to false + self._playing[dim] = False + + # 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, current_index + rr.step) + self._last_frame_time[dim] = now + + else: + # we are not playing, so display play button + if imgui.button(label=fa.ICON_FA_PLAY): + # if play button is clicked, set last frame time to 0 so that index increments on next render + self._last_frame_time[dim] = 0 + # set playing to True since play button was clicked + self._playing[dim] = True + + 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, current_index - rr.step) + + 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, current_index + rr.step) + + imgui.same_line() + # stop button + if imgui.button(label=fa.ICON_FA_STOP): + self._playing[dim] = False + self._last_frame_time[dim] = 0 + self._ndwidget.indices[dim] = rr.start + + imgui.same_line() + # loop checkbox + _, self._loop[dim] = imgui.checkbox(label=fa.ICON_FA_ROTATE, v=self._loop[dim]) + if imgui.is_item_hovered(0): + imgui.set_tooltip("loop playback") + + imgui.same_line() + imgui.text("framerate :") + imgui.same_line() + imgui.set_next_item_width(100) + # framerate int entry + fps_changed, value = imgui.input_int( + label="fps", v=self._fps[dim], step_fast=5 + ) + if imgui.is_item_hovered(0): + imgui.set_tooltip( + "framerate is approximate and less reliable as it approaches your monitor refresh rate" + ) + if fps_changed: + if value < 1: + value = 1 + if value > 50: + value = 50 + self._fps[dim] = value + self._frame_time[dim] = 1 / value + + imgui.text(str(dim)) + imgui.same_line() + # so that slider occupies full width + imgui.set_next_item_width(self.width * 0.85) + + if isinstance(rr, RangeContinuous): + changed, new_index = imgui.slider_float( + v=current_index, + v_min=rr.start, + v_max=rr.stop - rr.step, + label=f"##{dim}", + ) + + # TODO: refactor all this stuff, make fully fledged UI + if changed: + self._ndwidget.indices[dim] = new_index + + elif imgui.is_item_hovered(): + if imgui.is_key_pressed(imgui.Key.right_arrow): + self._set_index(dim, current_index + rr.step) + + elif imgui.is_key_pressed(imgui.Key.left_arrow): + self._set_index(dim, current_index - rr.step) + + imgui.pop_id() def _draw_nd_graphics_props_tab(self): for subplot in self._ndwidget.figure: From c79c2835aecaea52ddeff20ae50ca3ff697818ad Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 02:56:10 -0500 Subject: [PATCH 065/163] cleanup old iw-array, imports, add deprecation warning on old iw --- fastplotlib/widgets/__init__.py | 10 +- fastplotlib/widgets/image_widget/__init__.py | 1 - .../widgets/image_widget/_nd_iw_backup.py | 1007 ----------------- .../widgets/image_widget/_processor.py | 519 --------- .../widgets/image_widget/_properties.py | 139 --- fastplotlib/widgets/image_widget/_sliders.py | 91 +- fastplotlib/widgets/image_widget/_widget.py | 5 + fastplotlib/widgets/nd_widget/__init__.py | 16 +- 8 files changed, 65 insertions(+), 1723 deletions(-) delete mode 100644 fastplotlib/widgets/image_widget/_nd_iw_backup.py delete mode 100644 fastplotlib/widgets/image_widget/_processor.py delete mode 100644 fastplotlib/widgets/image_widget/_properties.py diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index 04102dbdf..4347f6c80 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -1,4 +1,12 @@ -from .nd_widget import NDWidget +from .nd_widget import ( + NDWidget, + NDProcessor, + NDGraphic, + NDPositionsProcessor, + NDPositions, + NDImageProcessor, + NDImage, +) from .image_widget import ImageWidget __all__ = ["NDWidget", "ImageWidget"] diff --git a/fastplotlib/widgets/image_widget/__init__.py b/fastplotlib/widgets/image_widget/__init__.py index dc5daea55..70a1aa8ae 100644 --- a/fastplotlib/widgets/image_widget/__init__.py +++ b/fastplotlib/widgets/image_widget/__init__.py @@ -2,7 +2,6 @@ if IMGUI: from ._widget import ImageWidget - from ._processor import NDImageProcessor else: diff --git a/fastplotlib/widgets/image_widget/_nd_iw_backup.py b/fastplotlib/widgets/image_widget/_nd_iw_backup.py deleted file mode 100644 index 7db265c0c..000000000 --- a/fastplotlib/widgets/image_widget/_nd_iw_backup.py +++ /dev/null @@ -1,1007 +0,0 @@ -from typing import Callable, Sequence, Literal -from warnings import warn - -import numpy as np - -from rendercanvas import BaseRenderCanvas - -from ...layouts import ImguiFigure as Figure -from ...graphics import ImageGraphic, ImageVolumeGraphic -from ...utils import calculate_figure_shape, quick_min_max, ArrayProtocol -from ...tools import HistogramLUTTool -from ._sliders import ImageWidgetSliders -from ._processor import NDImageProcessor, WindowFuncCallable -from ._properties import ImageWidgetProperty, Indices - - -IMGUI_SLIDER_HEIGHT = 49 - - -class ImageWidget: - def __init__( - self, - data: ArrayProtocol | Sequence[ArrayProtocol | None] | None, - processors: NDImageProcessor | Sequence[NDImageProcessor] = NDImageProcessor, - n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]] = 2, - slider_dim_names: Sequence[str] | None = None, # dim names left -> right - rgb: bool | Sequence[bool] = False, - cmap: str | Sequence[str] = "plasma", - window_funcs: ( - tuple[WindowFuncCallable | None, ...] - | WindowFuncCallable - | None - | Sequence[ - tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None - ] - ) = None, - window_sizes: ( - tuple[int | None, ...] | Sequence[tuple[int | None, ...] | None] - ) = None, - window_order: tuple[int, ...] | Sequence[tuple[int, ...] | None] = None, - spatial_func: ( - Callable[[ArrayProtocol], ArrayProtocol] - | Sequence[Callable[[ArrayProtocol], ArrayProtocol]] - | None - ) = None, - sliders_dim_order: Literal["right", "left"] = "right", - figure_shape: tuple[int, int] = None, - names: Sequence[str] = None, - figure_kwargs: dict = None, - histogram_widget: bool = True, - histogram_init_quantile: int = (0, 100), - graphic_kwargs: dict | Sequence[dict] = None, - ): - """ - This widget facilitates high-level navigation through image stacks, which are arrays containing one or more - images. It includes sliders for key dimensions such as "t" (time) and "z", enabling users to smoothly navigate - through one or multiple image stacks simultaneously. - - Allowed dimensions orders for each image stack: Note that each has a an optional (c) channel which refers to - RGB(A) a channel. So this channel should be either 3 or 4. - - Parameters - ---------- - data: ArrayProtocol | Sequence[ArrayProtocol | None] | None - array-like or a list of array-like, each array must have a minimum of 2 dimensions - - processors: NDImageProcessor | Sequence[NDImageProcessor], default NDImageProcessor - The image processors used for each n-dimensional data array - - n_display_dims: Literal[2, 3] | Sequence[Literal[2, 3]], default 2 - number of display dimensions - - slider_dim_names: Sequence[str], optional - optional list/tuple of names for each slider dim - - rgb: bool | Sequence[bool], default - whether or not each data array represents RGB(A) images - - figure_shape: Optional[Tuple[int, int]] - manually provide the shape for the Figure, otherwise the number of rows and columns is estimated - - figure_kwargs: dict, optional - passed to ``Figure`` - - names: Optional[str] - gives names to the subplots - - histogram_widget: bool, default False - make histogram LUT widget for each subplot - - rgb: bool | list[bool], default None - bool or list of bool for each input data array in the ImageWidget, indicating whether the corresponding - data arrays are grayscale or RGB(A). - - graphic_kwargs: Any - passed to each ImageGraphic in the ImageWidget figure subplots - - """ - - if figure_kwargs is None: - figure_kwargs = dict() - - if isinstance(data, ArrayProtocol) or (data is None): - data = [data] - - elif isinstance(data, (list, tuple)): - # verify that it's a list of np.ndarray - if not all([isinstance(d, ArrayProtocol) or d is None for d in data]): - raise TypeError( - f"`data` must be an array-like type or a list/tuple of array-like or None. " - f"You have passed the following type {type(data)}" - ) - - else: - raise TypeError( - f"`data` must be an array-like type or a list/tuple of array-like or None. " - f"You have passed the following type {type(data)}" - ) - - if issubclass(processors, NDImageProcessor): - processors = [processors] * len(data) - - elif isinstance(processors, (tuple, list)): - if not all([issubclass(p, NDImageProcessor) for p in processors]): - raise TypeError( - f"`processors` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " - f"list/tuple of `NDImageProcess` subclasses. You have passed: {processors}" - ) - - else: - raise TypeError( - f"`processors` must be a `NDImageProcess` class, a subclass of `NDImageProcessor`, or a " - f"list/tuple of `NDImageProcess` subclasses. You have passed: {processors}" - ) - - # subplot layout - if figure_shape is None: - if "shape" in figure_kwargs: - figure_shape = figure_kwargs["shape"] - else: - figure_shape = calculate_figure_shape(len(data)) - - # Regardless of how figure_shape is computed, below code - # verifies that figure shape is large enough for the number of image arrays passed - if figure_shape[0] * figure_shape[1] < len(data): - original_shape = (figure_shape[0], figure_shape[1]) - figure_shape = calculate_figure_shape(len(data)) - warn( - f"Original `figure_shape` was: {original_shape} " - f" but data length is {len(data)}" - f" Resetting figure shape to: {figure_shape}" - ) - - elif isinstance(rgb, bool): - rgb = [rgb] * len(data) - - if not all([isinstance(v, bool) for v in rgb]): - raise TypeError( - f"`rgb` parameter must be a bool or a Sequence of bool, you have passed: {rgb}" - ) - - if not len(rgb) == len(data): - raise ValueError( - f"len(rgb) != len(data), {len(rgb)} != {len(data)}. These must be equal" - ) - - if names is not None: - if not all([isinstance(n, str) for n in names]): - raise TypeError("optional argument `names` must be a Sequence of str") - - if len(names) != len(data): - raise ValueError( - "number of `names` for subplots must be same as the number of data arrays" - ) - - # verify window funcs - if window_funcs is None: - win_funcs = [None] * len(data) - - elif callable(window_funcs) or all( - [callable(f) or f is None for f in window_funcs] - ): - # across all data arrays - # one window function defined for all dims, or window functions defined per-dim - win_funcs = [window_funcs] * len(data) - - # if the above two clauses didn't trigger, then window_funcs defined per-dim, per data array - elif len(window_funcs) != len(data): - raise IndexError - else: - win_funcs = window_funcs - - # verify window sizes - if window_sizes is None: - win_sizes = [window_sizes] * len(data) - - elif isinstance(window_sizes, int): - win_sizes = [window_sizes] * len(data) - - elif all([isinstance(size, int) or size is None for size in window_sizes]): - # window sizes defined per-dim across all data arrays - win_sizes = [window_sizes] * len(data) - - elif len(window_sizes) != len(data): - # window sizes defined per-dim, per data array - raise IndexError - else: - win_sizes = window_sizes - - # verify window orders - if window_order is None: - win_order = [None] * len(data) - - elif all([isinstance(o, int) for o in order]): - # window order defined per-dim across all data arrays - win_order = [window_order] * len(data) - - elif len(window_order) != len(data): - raise IndexError - - else: - win_order = window_order - - # verify spatial_func - if spatial_func is None: - spatial_func = [None] * len(data) - - elif callable(spatial_func): - # same spatial_func for all data arrays - spatial_func = [spatial_func] * len(data) - - elif len(spatial_func) != len(data): - raise IndexError - - else: - spatial_func = spatial_func - - # verify number of display dims - if isinstance(n_display_dims, (int, np.integer)): - n_display_dims = [n_display_dims] * len(data) - - elif isinstance(n_display_dims, (tuple, list)): - if not all([isinstance(n, (int, np.integer)) for n in n_display_dims]): - raise TypeError - - if len(n_display_dims) != len(data): - raise IndexError - else: - raise TypeError - - n_display_dims = tuple(n_display_dims) - - if sliders_dim_order not in ("right",): - raise ValueError( - f"Only 'right' slider dims order is currently supported, you passed: {sliders_dim_order}" - ) - self._sliders_dim_order = sliders_dim_order - - self._slider_dim_names = None - self.slider_dim_names = slider_dim_names - - self._histogram_widget = histogram_widget - - # make NDImageArrays - self._image_processors: list[NDImageProcessor] = list() - for i in range(len(data)): - cls = processors[i] - image_processor = cls( - data=data[i], - rgb=rgb[i], - n_display_dims=n_display_dims[i], - window_funcs=win_funcs[i], - window_sizes=win_sizes[i], - window_order=win_order[i], - spatial_func=spatial_func[i], - compute_histogram=self._histogram_widget, - ) - - self._image_processors.append(image_processor) - - self._data = ImageWidgetProperty(self, "data") - self._rgb = ImageWidgetProperty(self, "rgb") - self._n_display_dims = ImageWidgetProperty(self, "n_display_dims") - self._window_funcs = ImageWidgetProperty(self, "window_funcs") - self._window_sizes = ImageWidgetProperty(self, "window_sizes") - self._window_order = ImageWidgetProperty(self, "window_order") - self._spatial_func = ImageWidgetProperty(self, "spatial_func") - - if len(set(n_display_dims)) > 1: - # assume user wants one controller for 2D images and another for 3D image volumes - n_subplots = np.prod(figure_shape) - controller_ids = [0] * n_subplots - controller_types = ["panzoom"] * n_subplots - - for i in range(len(data)): - if n_display_dims[i] == 2: - controller_ids[i] = 1 - else: - controller_ids[i] = 2 - controller_types[i] = "orbit" - - # needs to be a list of list - controller_ids = [controller_ids] - - else: - controller_ids = "sync" - controller_types = None - - figure_kwargs_default = { - "controller_ids": controller_ids, - "controller_types": controller_types, - "names": names, - } - - # update the default kwargs with any user-specified kwargs - # user specified kwargs will overwrite the defaults - figure_kwargs_default.update(figure_kwargs) - figure_kwargs_default["shape"] = figure_shape - - if graphic_kwargs is None: - graphic_kwargs = [dict()] * len(data) - - elif isinstance(graphic_kwargs, dict): - graphic_kwargs = [graphic_kwargs] * len(data) - - elif len(graphic_kwargs) != len(data): - raise IndexError - - if cmap is None: - cmap = [None] * len(data) - - elif isinstance(cmap, str): - cmap = [cmap] * len(data) - - elif not all([isinstance(c, str) for c in cmap]): - raise TypeError(f"`cmap` must be a or a list/tuple of ") - - self._figure: Figure = Figure(**figure_kwargs_default) - - self._indices = Indices(list(0 for i in range(self.n_sliders)), self) - - for i, subplot in zip(range(len(self._image_processors)), self.figure): - image_data = self._get_image( - self._image_processors[i], tuple(self._indices) - ) - - if image_data is None: - # this subplot/data array is blank, skip - continue - - # next 20 lines are just vmin, vmax parsing - vmin_specified, vmax_specified = None, None - if "vmin" in graphic_kwargs[i].keys(): - vmin_specified = graphic_kwargs[i].pop("vmin") - if "vmax" in graphic_kwargs[i].keys(): - vmax_specified = graphic_kwargs[i].pop("vmax") - - if (vmin_specified is None) or (vmax_specified is None): - # if either vmin or vmax are not specified, calculate an estimate by subsampling - vmin_estimate, vmax_estimate = quick_min_max( - self._image_processors[i].data - ) - - # decide vmin, vmax passed to ImageGraphic constructor based on whether it's user specified or now - if vmin_specified is None: - # user hasn't specified vmin, use estimated value - vmin = vmin_estimate - else: - # user has provided a specific value, use that - vmin = vmin_specified - - if vmax_specified is None: - vmax = vmax_estimate - else: - vmax = vmax_specified - else: - # both vmin and vmax are specified - vmin, vmax = vmin_specified, vmax_specified - - graphic_kwargs[i]["cmap"] = cmap[i] - - if self._image_processors[i].n_display_dims == 2: - # create an Image - graphic = ImageGraphic( - data=image_data, - name="image_widget_managed", - vmin=vmin, - vmax=vmax, - **graphic_kwargs[i], - ) - elif self._image_processors[i].n_display_dims == 3: - # create an ImageVolume - graphic = ImageVolumeGraphic( - data=image_data, - name="image_widget_managed", - vmin=vmin, - vmax=vmax, - **graphic_kwargs[i], - ) - subplot.camera.fov = 50 - - subplot.add_graphic(graphic) - - self._reset_histogram(subplot, self._image_processors[i]) - - self._sliders_ui = ImageWidgetSliders( - figure=self.figure, - size=57 + (IMGUI_SLIDER_HEIGHT * self.n_sliders), - location="bottom", - title="ImageWidget Controls", - image_widget=self, - ) - - self.figure.add_gui(self._sliders_ui) - - self._indices_changed_handlers = set() - - self._reentrant_block = False - - @property - def data(self) -> ImageWidgetProperty[ArrayProtocol | None]: - """get or set the nd-image data arrays""" - return self._data - - @data.setter - def data(self, new_data: Sequence[ArrayProtocol | None]): - if isinstance(new_data, ArrayProtocol) or new_data is None: - new_data = [new_data] * len(self._image_processors) - - if len(new_data) != len(self._image_processors): - raise IndexError - - # if the data array hasn't been changed - # graphics will not be reset for this data index - skip_indices = list() - - for i, (new_data, image_processor) in enumerate( - zip(new_data, self._image_processors) - ): - if new_data is image_processor.data: - skip_indices.append(i) - continue - - image_processor.data = new_data - - self._reset(skip_indices) - - @property - def rgb(self) -> ImageWidgetProperty[bool]: - """get or set the rgb toggle for each data array""" - return self._rgb - - @rgb.setter - def rgb(self, rgb: Sequence[bool]): - if isinstance(rgb, bool): - rgb = [rgb] * len(self._image_processors) - - if len(rgb) != len(self._image_processors): - raise IndexError - - # if the rgb option hasn't been changed - # graphics will not be reset for this data index - skip_indices = list() - - for i, (new, image_processor) in enumerate(zip(rgb, self._image_processors)): - if image_processor.rgb == new: - skip_indices.append(i) - continue - - image_processor.rgb = new - - self._reset(skip_indices) - - @property - def n_display_dims(self) -> ImageWidgetProperty[Literal[2, 3]]: - """Get or set the number of display dimensions for each data array, 2 is a 2D image, 3 is a 3D volume image""" - return self._n_display_dims - - @n_display_dims.setter - def n_display_dims(self, new_ndd: Sequence[Literal[2, 3]] | Literal[2, 3]): - if isinstance(new_ndd, (int, np.integer)): - if new_ndd == 2 or new_ndd == 3: - new_ndd = [new_ndd] * len(self._image_processors) - else: - raise ValueError - - if len(new_ndd) != len(self._image_processors): - raise IndexError - - if not all([(n == 2) or (n == 3) for n in new_ndd]): - raise ValueError - - # if the n_display_dims hasn't been changed for this data array - # graphics will not be reset for this data array index - skip_indices = list() - - # first update image arrays - for i, (image_processor, new) in enumerate( - zip(self._image_processors, new_ndd) - ): - if new > image_processor.max_n_display_dims: - raise IndexError( - f"number of display dims exceeds maximum number of possible " - f"display dimensions: {image_processor.max_n_display_dims}, for array at index: " - f"{i} with shape: {image_processor.shape}, and rgb set to: {image_processor.rgb}" - ) - - if image_processor.n_display_dims == new: - skip_indices.append(i) - else: - image_processor.n_display_dims = new - - self._reset(skip_indices) - - @property - def window_funcs(self) -> ImageWidgetProperty[tuple[WindowFuncCallable | None] | None]: - """get or set the window functions""" - return self._window_funcs - - @window_funcs.setter - def window_funcs(self, new_funcs: Sequence[WindowFuncCallable | None] | None): - if callable(new_funcs) or new_funcs is None: - new_funcs = [new_funcs] * len(self._image_processors) - - if len(new_funcs) != len(self._image_processors): - raise IndexError - - self._set_image_processor_funcs("window_funcs", new_funcs) - - @property - def window_sizes(self) -> ImageWidgetProperty[tuple[int | None, ...] | None]: - """get or set the window sizes""" - return self._window_sizes - - @window_sizes.setter - def window_sizes( - self, new_sizes: Sequence[tuple[int | None, ...] | int | None] | int | None - ): - if isinstance(new_sizes, int) or new_sizes is None: - # same window for all data arrays - new_sizes = [new_sizes] * len(self._image_processors) - - if len(new_sizes) != len(self._image_processors): - raise IndexError - - self._set_image_processor_funcs("window_sizes", new_sizes) - - @property - def window_order(self) -> ImageWidgetProperty[tuple[int, ...] | None]: - """get or set order in which window functions are applied over dimensions""" - return self._window_order - - @window_order.setter - def window_order(self, new_order: Sequence[tuple[int, ...]]): - if new_order is None: - new_order = [new_order] * len(self._image_processors) - - if all([isinstance(order, (int, np.integer))] for order in new_order): - # same order specified across all data arrays - new_order = [new_order] * len(self._image_processors) - - if len(new_order) != len(self._image_processors): - raise IndexError - - self._set_image_processor_funcs("window_order", new_order) - - @property - def spatial_func(self) -> ImageWidgetProperty[Callable | None]: - """Get or set a spatial_func that operates on the spatial dimensions of the 2D or 3D image""" - return self._spatial_func - - @spatial_func.setter - def spatial_func(self, funcs: Callable | Sequence[Callable] | None): - if callable(funcs) or funcs is None: - funcs = [funcs] * len(self._image_processors) - - if len(funcs) != len(self._image_processors): - raise IndexError - - self._set_image_processor_funcs("spatial_func", funcs) - - def _set_image_processor_funcs(self, attr, new_values): - """sets window_funcs, window_sizes, window_order, or spatial_func and updates displayed data and histograms""" - for new, image_processor, subplot in zip( - new_values, self._image_processors, self.figure - ): - if getattr(image_processor, attr) == new: - continue - - setattr(image_processor, attr, new) - - # window functions and spatial functions will only change the histogram - # they do not change the collections of dimensions, so we don't need to call _reset_dimensions - # they also do not change the image graphic, so we do not need to call _reset_image_graphics - self._reset_histogram(subplot, image_processor) - - # update the displayed image data in the graphics - self.indices = self.indices - - @property - def indices(self) -> ImageWidgetProperty[int]: - """ - Get or set the current indices. - - Returns - ------- - indices: ImageWidgetProperty[int] - integer index for each slider dimension - - """ - return self._indices - - @indices.setter - def indices(self, new_indices: Sequence[int]): - if self._reentrant_block: - return - - try: - self._reentrant_block = True # block re-execution until new_indices has *fully* completed execution - - if len(new_indices) != self.n_sliders: - raise IndexError( - f"len(new_indices) != ImageWidget.n_sliders, {len(new_indices)} != {self.n_sliders}. " - f"The length of the new_indices must be the same as the number of sliders" - ) - - if any([i < 0 for i in new_indices]): - raise IndexError( - f"only positive index values are supported, you have passed: {new_indices}" - ) - - for image_processor, graphic in zip(self._image_processors, self.graphics): - new_data = self._get_image(image_processor, indices=new_indices) - if new_data is None: - continue - - graphic.data = new_data - - self._indices._fpl_set(new_indices) - - # call any event handlers - for handler in self._indices_changed_handlers: - handler(tuple(self.indices)) - - except Exception as exc: - # raise original exception - raise exc # indices setter has raised. The lines above below are probably more relevant! - finally: - # set_value has finished executing, now allow future executions - self._reentrant_block = False - - @property - def histogram_widget(self) -> bool: - """show or hide the histograms""" - return self._histogram_widget - - @histogram_widget.setter - def histogram_widget(self, show_histogram: bool): - if not isinstance(show_histogram, bool): - raise TypeError( - f"`histogram_widget` can be set with a bool, you have passed: {show_histogram}" - ) - - for subplot, image_processor in zip(self.figure, self._image_processors): - image_processor.compute_histogram = show_histogram - self._reset_histogram(subplot, image_processor) - - @property - def n_sliders(self) -> int: - """number of sliders""" - return max([a.n_slider_dims for a in self._image_processors]) - - @property - def bounds(self) -> tuple[int, ...]: - """The max bound across all dimensions across all data arrays""" - # initialize with 0 - bounds = [0] * self.n_sliders - - # TODO: implement left -> right slider dims ordering, right now it's only right -> left - # in reverse because dims go left <- right - for i, dim in enumerate(range(-1, -self.n_sliders - 1, -1)): - # across each dim - for array in self._image_processors: - if i > array.n_slider_dims - 1: - continue - # across each data array - # dims go left <- right - bounds[dim] = max(array.slider_dims_shape[dim], bounds[dim]) - - return bounds - - @property - def slider_dim_names(self) -> tuple[str, ...]: - return self._slider_dim_names - - @slider_dim_names.setter - def slider_dim_names(self, names: Sequence[str]): - if names is None: - self._slider_dim_names = None - return - - if not all([isinstance(n, str) for n in names]): - raise TypeError(f"`slider_dim_names` must be set with a list/tuple of , you passed: {names}") - - if len(set(names)) != len(names): - raise ValueError( - f"`slider_dim_names` must be unique, you passed: {names}" - ) - - self._slider_dim_names = tuple(names) - - def _get_image( - self, image_processor: NDImageProcessor, indices: Sequence[int] - ) -> ArrayProtocol: - """Get a processed 2d or 3d image from the NDImage at the given indices""" - n = image_processor.n_slider_dims - - if self._sliders_dim_order == "right": - return image_processor.get(indices[-n:]) - - elif self._sliders_dim_order == "left": - # TODO: left -> right is not fully implemented yet in ImageWidget - return image_processor.get(indices[:n]) - - def _reset_dimensions(self): - """reset the dimensions w.r.t. current collection of NDImageProcessors""" - # TODO: implement left -> right slider dims ordering, right now it's only right -> left - # add or remove dims from indices - # trim any excess dimensions - while len(self._indices) > self.n_sliders: - # remove outer most dims first - self._indices.pop_dim() - self._sliders_ui.pop_dim() - - # add any new dimensions that aren't present - while len(self.indices) < self.n_sliders: - # insert right -> left - self._indices.push_dim() - self._sliders_ui.push_dim() - - self._sliders_ui.size = 57 + (IMGUI_SLIDER_HEIGHT * self.n_sliders) - - def _reset_image_graphics(self, subplot, image_processor): - """delete and create a new image graphic if necessary""" - new_image = self._get_image(image_processor, indices=tuple(self.indices)) - if new_image is None: - if "image_widget_managed" in subplot: - # delete graphic from this subplot if present - subplot.delete_graphic(subplot["image_widget_managed"]) - # skip this subplot - return - - # check if a graphic exists - if "image_widget_managed" in subplot: - # create a new graphic only if the Texture buffer shape doesn't match - if subplot["image_widget_managed"].data.value.shape == new_image.shape: - return - - # keep cmap - cmap = subplot["image_widget_managed"].cmap - if cmap is None: - # ex: going from rgb -> grayscale - cmap = "plasma" - # delete graphic since it will be replaced - subplot.delete_graphic(subplot["image_widget_managed"]) - else: - # default cmap - cmap = "plasma" - - if image_processor.n_display_dims == 2: - g = subplot.add_image( - data=new_image, cmap=cmap, name="image_widget_managed" - ) - - # set camera orthogonal to the xy plane, flip y axis - subplot.camera.set_state( - { - "position": [0, 0, -1], - "rotation": [0, 0, 0, 1], - "scale": [1, -1, 1], - "reference_up": [0, 1, 0], - "fov": 0, - "depth_range": None, - } - ) - - subplot.controller = "panzoom" - subplot.axes.intersection = None - subplot.auto_scale() - - elif image_processor.n_display_dims == 3: - g = subplot.add_image_volume( - data=new_image, cmap=cmap, name="image_widget_managed" - ) - subplot.camera.fov = 50 - subplot.controller = "orbit" - - # make sure all 3D dimension camera scales are positive - # MIP rendering doesn't work with negative camera scales - for dim in ["x", "y", "z"]: - if getattr(subplot.camera.local, f"scale_{dim}") < 0: - setattr(subplot.camera.local, f"scale_{dim}", 1) - - subplot.auto_scale() - - def _reset_histogram(self, subplot, image_processor): - """reset the histogram""" - if not self._histogram_widget: - subplot.docks["right"].size = 0 - return - - if image_processor.histogram is None: - # no histogram available for this processor - # either there is no data array in this subplot, - # or a histogram routine does not exist for this processor - subplot.docks["right"].size = 0 - return - - if "image_widget_managed" not in subplot: - # no image in this subplot - subplot.docks["right"].size = 0 - return - - image = subplot["image_widget_managed"] - - if "histogram_lut" in subplot.docks["right"]: - hlut: HistogramLUTTool = subplot.docks["right"]["histogram_lut"] - hlut.histogram = image_processor.histogram - hlut.images = image - if subplot.docks["right"].size < 1: - subplot.docks["right"].size = 80 - - else: - # need to make one - hlut = HistogramLUTTool( - histogram=image_processor.histogram, - images=image, - name="histogram_lut", - ) - - subplot.docks["right"].add_graphic(hlut) - subplot.docks["right"].size = 80 - - self.reset_vmin_vmax() - - def _reset(self, skip_data_indices: tuple[int, ...] = None): - if skip_data_indices is None: - skip_data_indices = tuple() - - # reset the slider indices according to the new collection of dimensions - self._reset_dimensions() - # update graphics where display dims have changed accordings to indices - for i, (subplot, image_processor) in enumerate( - zip(self.figure, self._image_processors) - ): - if i in skip_data_indices: - continue - - self._reset_image_graphics(subplot, image_processor) - self._reset_histogram(subplot, image_processor) - - # force an update - self.indices = self.indices - - @property - def figure(self) -> Figure: - """ - ``Figure`` used by `ImageWidget`. - """ - return self._figure - - @property - def graphics(self) -> list[ImageGraphic]: - """List of ``ImageWidget`` managed graphics.""" - iw_managed = list() - for subplot in self.figure: - if "image_widget_managed" in subplot: - iw_managed.append(subplot["image_widget_managed"]) - else: - iw_managed.append(None) - return tuple(iw_managed) - - @property - def cmap(self) -> tuple[str | None, ...]: - """get the cmaps, or set the cmap across all images""" - return tuple(g.cmap for g in self.graphics) - - @cmap.setter - def cmap(self, name: str): - for g in self.graphics: - if g is None: - # no data at this index - continue - - if g.cmap is None: - # if rgb - continue - - g.cmap = name - - def add_event_handler(self, handler: callable, event: str = "indices"): - """ - Register an event handler. - - Currently the only event that ImageWidget supports is "indices". This event is - emitted whenever the indices of the ImageWidget changes. - - Parameters - ---------- - handler: callable - callback function, must take a tuple of int as the only argument. This tuple will be the `indices` - - event: str, "indices" - the only supported event is "indices" - - Example - ------- - - .. code-block:: py - - def my_handler(indices): - print(indices) - # example prints: (100, 15) if the data has 2 slider dimensions with sliders at positions 100, 15 - - # create an image widget - iw = ImageWidget(...) - - # add event handler - iw.add_event_handler(my_handler) - - """ - if event != "indices": - raise ValueError("`indices` is the only event supported by `ImageWidget`") - - self._indices_changed_handlers.add(handler) - - def remove_event_handler(self, handler: callable): - """Remove a registered event handler""" - self._indices_changed_handlers.remove(handler) - - def clear_event_handlers(self): - """Clear all registered event handlers""" - self._indices_changed_handlers.clear() - - def reset_vmin_vmax(self): - """ - Reset the vmin and vmax w.r.t. the full data - """ - for image_processor, subplot in zip(self._image_processors, self.figure): - if "histogram_lut" not in subplot.docks["right"]: - continue - - if image_processor.histogram is None: - continue - - hlut = subplot.docks["right"]["histogram_lut"] - hlut.histogram = image_processor.histogram - - edges = image_processor.histogram[1] - - hlut.vmin, hlut.vmax = edges[0], edges[-1] - - def reset_vmin_vmax_frame(self): - """ - Resets the vmin vmax and HistogramLUT widgets w.r.t. the current data shown in the - ImageGraphic instead of the data in the full data array. For example, if a post-processing - function is used, the range of values in the ImageGraphic can be very different from the - range of values in the full data array. - """ - - for subplot, image_processor in zip(self.figure, self._image_processors): - if "histogram_lut" not in subplot.docks["right"]: - continue - - if image_processor.histogram is None: - continue - - hlut = subplot.docks["right"]["histogram_lut"] - # set the data using the current image graphic data - image = subplot["image_widget_managed"] - freqs, edges = np.histogram(image.data.value, bins=100) - hlut.histogram = (freqs, edges) - hlut.vmin, hlut.vmax = edges[0], edges[-1] - - def show(self, **kwargs): - """ - Show the widget. - - Parameters - ---------- - - kwargs: Any - passed to `Figure.show()`t - - Returns - ------- - BaseRenderCanvas - In Qt or GLFW, the canvas window containing the Figure will be shown. - In jupyter, it will display the plot in the output cell or sidecar. - - """ - - return self.figure.show(**kwargs) - - def close(self): - """Close Widget""" - self.figure.close() diff --git a/fastplotlib/widgets/image_widget/_processor.py b/fastplotlib/widgets/image_widget/_processor.py deleted file mode 100644 index 0dce84a5e..000000000 --- a/fastplotlib/widgets/image_widget/_processor.py +++ /dev/null @@ -1,519 +0,0 @@ -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, ArrayProtocol, ARRAY_LIKE_ATTRS - - -# must take arguments: array-like, `axis`: int, `keepdims`: bool -WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] - - -class NDImageProcessor: - def __init__( - self, - data: ArrayLike | None, - n_display_dims: Literal[2, 3] = 2, - rgb: bool = False, - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, - window_sizes: tuple[int | None, ...] | int = None, - window_order: tuple[int, ...] = None, - spatial_func: Callable[[ArrayLike], ArrayLike] = None, - compute_histogram: bool = True, - ): - """ - An ND image that supports computing window functions, and functions over spatial dimensions. - - Parameters - ---------- - data: ArrayLike - array-like data, must have 2 or more dimensions - - n_display_dims: int, 2 or 3, default 2 - number of display dimensions - - rgb: bool, default False - whether the image data is RGB(A) or not - - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable, optional - A function or a ``tuple`` of functions that are applied to a rolling window of the data. - - 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. - - 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``. - - The output array-like type from a window function **must** support a ``.squeeze()`` method, but the - function itself should NOT squeeze the output array. - - window_sizes: tuple[int | None, ...], optional - ``tuple`` of ``int`` that specifies the window size for each dimension. - - 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. - - 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 spatial_func changes. - Disable if slow. - - """ - # set as False until data, window funcs stuff and spatial func is all set - self._compute_histogram = False - - 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._spatial_func = spatial_func - - self._compute_histogram = compute_histogram - self._recompute_histogram() - - @property - 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 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}, 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 - self._recompute_histogram() - - @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 - 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""" - if self._data is None: - return 0 - - return self.ndim - self.n_display_dims - int(self.rgb) - - @property - 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 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""" - 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 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() - - @property - def max_n_display_dims(self) -> int: - """maximum number of possible display dims""" - # 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]: - """tuple indicating the display dimension indices""" - return tuple(range(self.data.ndim))[self.n_slider_dims :] - - @property - def window_funcs( - self, - ) -> tuple[WindowFuncCallable | None, ...] | None: - """get or set window functions, see docstring for details""" - return self._window_funcs - - @window_funcs.setter - def window_funcs( - self, - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable | None, - ): - if window_funcs is None: - self._window_funcs = None - return - - if callable(window_funcs): - window_funcs = (window_funcs,) - - # if all are None - if all([f is None for f in window_funcs]): - self._window_funcs = None - return - - self._validate_window_func(window_funcs) - - self._window_funcs = tuple(window_funcs) - self._recompute_histogram() - - def _validate_window_func(self, funcs): - if isinstance(funcs, (tuple, list)): - for f in funcs: - if f is None: - pass - elif callable(f): - sig = inspect.signature(f) - - if "axis" not in sig.parameters or "keepdims" not in sig.parameters: - raise TypeError( - f"Each window function must take an `axis` and `keepdims` argument, " - f"you passed: {f} with the following function signature: {sig}" - ) - else: - raise TypeError( - f"`window_funcs` must be of type: tuple[Callable | None, ...], you have passed: {funcs}" - ) - - if not (len(funcs) == self.n_slider_dims or self.n_slider_dims == 0): - raise IndexError( - f"number of `window_funcs` must be the same as the number of slider dims: {self.n_slider_dims}, " - f"and you passed {len(funcs)} `window_funcs`: {funcs}" - ) - - @property - def window_sizes(self) -> tuple[int | None, ...] | None: - """get or set window sizes used for the corresponding window functions, see docstring for details""" - return self._window_sizes - - @window_sizes.setter - def window_sizes(self, window_sizes: tuple[int | None, ...] | int | None): - if window_sizes is None: - self._window_sizes = None - return - - if isinstance(window_sizes, int): - window_sizes = (window_sizes,) - - # if all are None - if all([w is None for w in window_sizes]): - self._window_sizes = None - return - - if not all([isinstance(w, (int)) or w is None for w in window_sizes]): - raise TypeError( - f"`window_sizes` must be of type: tuple[int | None, ...] | int | None, you have passed: {window_sizes}" - ) - - if not (len(window_sizes) == self.n_slider_dims or self.n_slider_dims == 0): - raise IndexError( - f"number of `window_sizes` must be the same as the number of slider dims, " - f"i.e. `data.ndim` - n_display_dims, your data array has {self.ndim} dimensions " - f"and you passed {len(window_sizes)} `window_sizes`: {window_sizes}" - ) - - # make all window sizes are valid numbers - _window_sizes = list() - for i, w in enumerate(window_sizes): - if w is None: - _window_sizes.append(None) - continue - - if w < 0: - raise ValueError( - f"negative window size passed, all `window_sizes` must be positive " - f"integers or `None`, you passed: {_window_sizes}" - ) - - if w == 0 or w == 1: - # this is not a real window, set as None - w = None - - elif w % 2 == 0: - # odd window sizes makes most sense - warn( - f"provided even window size: {w} in dim: {i}, adding `1` to make it odd" - ) - w += 1 - - _window_sizes.append(w) - - self._window_sizes = tuple(_window_sizes) - self._recompute_histogram() - - @property - def window_order(self) -> tuple[int, ...] | None: - """get or set dimension order in which window functions are applied""" - return self._window_order - - @window_order.setter - def window_order(self, order: tuple[int] | None): - if order is None: - self._window_order = None - return - - if order is not None: - if not all([d <= self.n_slider_dims for d in order]): - raise IndexError( - f"all `window_order` entries must be <= n_slider_dims\n" - f"`n_slider_dims` is: {self.n_slider_dims}, you have passed `window_order`: {order}" - ) - - if not all([d >= 0 for d in order]): - raise IndexError( - f"all `window_order` entires must be >= 0, you have passed: {order}" - ) - - self._window_order = tuple(order) - self._recompute_histogram() - - @property - def spatial_func(self) -> Callable[[ArrayLike], ArrayLike] | None: - """get or set a spatial_func function, see docstring for details""" - return self._spatial_func - - @spatial_func.setter - def spatial_func(self, func: Callable[[ArrayLike], ArrayLike] | 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}" - ) - - self._spatial_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, indices: 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 - # clamp to max bounds - indexer = list() - for dim, i in enumerate(indices): - i = min(self.shape[dim] - 1, i) - indexer.append(i) - - return self.data[tuple(indexer)] - - # order in which window funcs are applied - order = self._window_order - - if order is not None: - # remove any entries in `window_order` where the specified dim - # has a window function or window size specified as `None` - # example: - # window_sizes = (3, 2) - # window_funcs = (np.mean, None) - # order = (0, 1) - # `1` is removed from the order since that window_func is `None` - order = tuple( - d for d in order if winds[d] is not None and funcs[d] is not None - ) - else: - # sequential order - order = list() - for d in range(self.n_slider_dims): - if winds[d] is not None and funcs[d] is not None: - order.append(d) - - # the final indexer which will be used on the data array - indexer = list() - - for dim_index, (i, w, f) in enumerate(zip(indices, winds, funcs)): - # clamp i within the max bounds - i = min(self.shape[dim_index] - 1, i) - - if (w is not None) and (f is not None): - # specify slice window if both window size and function for this dim are not None - hw = int((w - 1) / 2) # half window - - # start index cannot be less than 0 - start = max(0, i - hw) - - # stop index cannot exceed the bounds of this dimension - stop = min(self.shape[dim_index] - 1, i + hw) - - s = slice(start, stop, 1) - else: - s = slice(i, i + 1, 1) - - indexer.append(s) - - # apply indexer to slice data with the specified windows - data_sliced = self.data[tuple(indexer)] - - # finally apply the window functions in the specified order - for dim in order: - f = funcs[dim] - - data_sliced = f(data_sliced, axis=dim, keepdims=True) - - return data_sliced - - def get(self, indices: tuple[int, ...]) -> ArrayLike | None: - """ - 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 tuple specifying a single integer - index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. - - Parameters - ---------- - indices: tuple[int, ...] - Get the processed data at this index. Must provide a value for each dimension. - 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( - 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(indices).squeeze() - else: - # data is a static image or volume - window_output = self.data - - # 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 `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: - # check that output ndim after window functions matches display dims - final_output = window_output - 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}{' with rgb(a) channels' if self.rgb else ''}, " - f"expected {self.n_display_dims} dims" - ) - - return final_output - - def _recompute_histogram(self): - """ - - Returns - ------- - (histogram_values, bin_edges) - - """ - if not self._compute_histogram or self.data is None: - self._histogram = None - return - - 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 - else: - 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_real, bins=100) diff --git a/fastplotlib/widgets/image_widget/_properties.py b/fastplotlib/widgets/image_widget/_properties.py deleted file mode 100644 index 060314439..000000000 --- a/fastplotlib/widgets/image_widget/_properties.py +++ /dev/null @@ -1,139 +0,0 @@ -from pprint import pformat -from typing import Iterable - -import numpy as np - -from ._processor import NDImageProcessor - - -class ImageWidgetProperty: - __class_getitem__ = classmethod(type(list[int])) - - def __init__( - self, - image_widget, - attribute: str, - ): - self._image_widget = image_widget - self._image_processors: list[NDImageProcessor] = image_widget._image_processors - self._attribute = attribute - - 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): - 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}") - - return key - - 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) - - # if it's a slice - processors = self._image_processors[key] - - return tuple(getattr(p, self._attribute) for p in processors) - - 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[:])}" - - def __eq__(self, other): - return self[:] == other - - -class Indices: - def __init__( - self, - indices: list[int], - image_widget, - ): - self._data = indices - - self._image_widget = image_widget - - def __iter__(self): - for i in self._data: - yield i - - 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 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}" - ) - - 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/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index 1945b8cfb..393b13273 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -11,66 +11,50 @@ 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: list[bool] = [False] * n_sliders + self._playing: dict[str, bool] = {"t": False, "z": False} # approximate framerate for playing - self._fps: list[int] = [20] * n_sliders - + self._fps: dict[str, int] = {"t": 20, "z": 20} # framerate converted to frame time - self._frame_time: list[float] = [1 / 20] * n_sliders + self._frame_time: dict[str, float] = {"t": 1 / 20, "z": 1 / 20} # last timepoint that a frame was displayed from a given dimension - self._last_frame_time: list[float] = [perf_counter()] * n_sliders + self._last_frame_time: dict[str, float] = {"t": 0, "z": 0} - # loop playback self._loop = False - # 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 + if "RTD_BUILD" in os.environ.keys(): + if os.environ["RTD_BUILD"] == "1": + self._playing["t"] = True self._loop = True - self.pause = False - - def pop_dim(self): - """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""" + def set_index(self, dim: str, index: int): + """set the current_index of the ImageWidget""" # make sure the max index for this dim is not exceeded - max_index = self._image_widget.bounds[dim] - 1 - if new_index > max_index: + max_index = self._image_widget._dims_max_bounds[dim] - 1 + if index > max_index: if self._loop: # loop back to index zero if looping is enabled - new_index = 0 + index = 0 else: # if looping not enabled, stop playing this dimension self._playing[dim] = False return - # set new index - new_indices = list(self._image_widget.indices) - new_indices[dim] = new_index - self._image_widget.indices = new_indices + # set current_index + self._image_widget.current_index = {dim: min(index, max_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() + + # 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() @@ -88,7 +72,7 @@ def update(self): now = perf_counter() # buttons and slider UI elements for each dim - for dim in range(self._image_widget.n_sliders): + for dim in self._image_widget.slider_dims: imgui.push_id(f"{self._id_counter}_{dim}") if self._playing[dim]: @@ -99,7 +83,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.indices[dim] + 1) + self.set_index(dim, self._image_widget.current_index[dim] + 1) self._last_frame_time[dim] = now else: @@ -113,12 +97,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.indices[dim] - 1) + self.set_index(dim, self._image_widget.current_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.indices[dim] + 1) + self.set_index(dim, self._image_widget.current_index[dim] + 1) imgui.same_line() # stop button @@ -153,15 +137,10 @@ def update(self): self._fps[dim] = value self._frame_time[dim] = 1 / value - val = self._image_widget.indices[dim] - vmax = self._image_widget.bounds[dim] - 1 - - 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] + val = self._image_widget.current_index[dim] + vmax = self._image_widget._dims_max_bounds[dim] - 1 - imgui.text(f"dim '{dim_name}:' ") + imgui.text(f"{dim}: ") imgui.same_line() # so that slider occupies full width imgui.set_next_item_width(self.width * 0.85) @@ -175,12 +154,18 @@ def update(self): # slider for this dimension changed, index = imgui.slider_int( - f"d: {dim}", v=val, v_min=0, v_max=vmax, flags=flags + f"{dim}", v=val, v_min=0, v_max=vmax, flags=flags ) - if changed: - new_indices = list(self._image_widget.indices) - new_indices[dim] = index - self._image_widget.indices = new_indices + new_index[dim] = index + + # if the slider value changed for this dimension + flag_index_changed |= changed imgui.pop_id() + + 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.size = int(imgui.get_window_height()) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 86a01b083..0b0f25164 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -358,6 +358,11 @@ def __init__( passed to each ImageGraphic in the ImageWidget figure subplots """ + warnings.warn( + "`ImageWidget` is deprecated and will be removed in a" + " future release, please migrate to NDWidget", + DeprecationWarning + ) self._initialized = False if figure_kwargs is None: diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index 0617a729d..378f7dfcd 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -1,14 +1,24 @@ from ...layouts import IMGUI -if IMGUI: - from ._base import NDProcessor +try: + import imgui_bundle +except ImportError: + HAS_XARRAY = False +else: + HAS_XARRAY = True + + +if IMGUI and HAS_XARRAY: + from ._base import NDProcessor, NDGraphic from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras from ._nd_image import NDImageProcessor, NDImage from ._ndwidget import NDWidget + else: + class NDWidget: def __init__(self, *args, **kwargs): raise ModuleNotFoundError( - "NDWidget requires `imgui-bundle` to be installed.\n" + "NDWidget requires `imgui-bundle` and `xarray` to be installed.\n" "pip install imgui-bundle" ) From 611f8979e114e7231c56c4b7311464106b03d759 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 02:58:25 -0500 Subject: [PATCH 066/163] add ndwidget section to deps with xarray --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 73dfd7ee3..30d194e79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,8 @@ tests = [ "ome-zarr", ] imgui = ["wgpu[imgui]"] -dev = ["fastplotlib[docs,notebook,tests,imgui]"] +ndwidget = ["wgpu[imgui]", "xarray"] +dev = ["fastplotlib[docs,notebook,tests,imgui,ndwidget]"] [project.urls] Homepage = "https://www.fastplotlib.org/" From 05907ed83d7bcaf11fb323702e0b20e3a167f238 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Mar 2026 21:11:24 -0500 Subject: [PATCH 067/163] nice repr for NDProcessor --- fastplotlib/widgets/nd_widget/_base.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index de9826030..00c190418 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -2,6 +2,8 @@ from contextlib import contextmanager import inspect from numbers import Real +from pprint import pformat +import textwrap from typing import Literal, Any from warnings import warn @@ -311,6 +313,20 @@ def _apply_window_functions(self, indices) -> xr.DataArray: def get(self, indices: dict[Hashable, Any]): raise NotImplementedError + def __repr__(self): + tab = "\t" + return ( + f"{self.__class__.__name__}\n" + f"shape:\n\t{self.shape}\n" + f"dims:\n\t{self.dims}\n" + f"spatial_dims:\n\t{self.spatial_dims}\n" + f"slider_dims:\n\t{self.slider_dims}\n" + f"index_mappings:\n{textwrap.indent(pformat(self.index_mappings, width=120), prefix=tab)}\n" + f"window_funcs:\n{textwrap.indent(pformat(self.window_funcs, width=120), prefix=tab)}\n" + f"window_order:\n\t{self.window_order}\n" + f"spatial_func:\n\t{self.spatial_func}\n" + ) + def block_reentrance(setter): # decorator to block re-entrant indices setter @@ -454,6 +470,12 @@ def spatial_func( # force a re-render self.indices = self.indices + def __repr__(self): + return ( + f"graphic: {self.graphic}\n" + f"processor:\n{self.processor}" + ) + @contextmanager def block_indices(ndgraphic: NDGraphic): From 04718f8794bf155cb05e07af8adc204920cb2944 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Mar 2026 01:15:22 -0500 Subject: [PATCH 068/163] imgui right click menu for ndgraphics --- examples/ndwidget/ndimage.py | 3 +- examples/ndwidget/timeseries.py | 1 + fastplotlib/layouts/_imgui_figure.py | 9 ++- .../ui/right_click_menus/_standard_menu.py | 6 ++ fastplotlib/widgets/nd_widget/_ndwidget.py | 5 +- fastplotlib/widgets/nd_widget/_ui.py | 72 ++++++++++++++----- 6 files changed, 72 insertions(+), 24 deletions(-) diff --git a/examples/ndwidget/ndimage.py b/examples/ndwidget/ndimage.py index 4212f46b6..80c010ea1 100644 --- a/examples/ndwidget/ndimage.py +++ b/examples/ndwidget/ndimage.py @@ -28,9 +28,10 @@ data, ("time", "depth", "m", "n"), # specify all dim names ("m", "n"), # specify spatial dims IN ORDER, rest are auto slider dims + name="4d-image", ) # change spatial dims on the fly -ndi.spatial_dims = ("depth", "m", "n") +# ndi.spatial_dims = ("depth", "m", "n") fpl.loop.run() diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py index a0a3074ff..e506182e3 100644 --- a/examples/ndwidget/timeseries.py +++ b/examples/ndwidget/timeseries.py @@ -49,6 +49,7 @@ "freq": lambda x: int(x + 1), }, x_range_mode="view-range", + name="nd-sine" ) nd_lines.graphic.cmap = "tab10" diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index 33cc6d925..15b3d7c45 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -44,6 +44,7 @@ def __init__( canvas_kwargs: dict = None, size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, + std_right_click_menu: type[Popup] = StandardRightClickMenu, ): self._guis: dict[str, EdgeWindow] = {k: None for k in GUI_EDGES} @@ -105,7 +106,7 @@ def __init__( toolbar = SubplotToolbar(subplot=subplot) self._subplot_toolbars[i] = toolbar - self._right_click_menu = StandardRightClickMenu(figure=self) + self._std_right_click_menu = std_right_click_menu(figure=self) self._popups: dict[str, Popup] = {} @@ -118,6 +119,10 @@ def __init__( def default_imgui_font(self) -> imgui.ImFont: return self._default_imgui_font + @property + def std_right_click_menu(self) -> Popup: + return self._std_right_click_menu + @property def guis(self) -> dict[str, EdgeWindow]: """GUI windows added to the Figure""" @@ -158,7 +163,7 @@ def _draw_imgui(self) -> imgui.ImDrawData: for popup in self._popups.values(): popup.update() - self._right_click_menu.update() + self._std_right_click_menu.update() # imgui.end_frame() diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py index bb9e5bdef..78b5f4c9f 100644 --- a/fastplotlib/ui/right_click_menus/_standard_menu.py +++ b/fastplotlib/ui/right_click_menus/_standard_menu.py @@ -47,6 +47,10 @@ def cleanup(self): """called when the popup disappears""" self.is_open = False + def _extra_menu(self): + # extra menu items, optional, implement in subclass + pass + def update(self): if imgui.is_mouse_down(1) and not self._mouse_down: # mouse button was pressed down, store this position @@ -182,4 +186,6 @@ def update(self): imgui.end_menu() + self._extra_menu() + imgui.end_popup() diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py index a67c9d18d..8449a2c70 100644 --- a/fastplotlib/widgets/nd_widget/_ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -2,14 +2,15 @@ from ._index import RangeContinuous, RangeDiscrete, GlobalIndex from ._ndw_subplot import NDWSubplot -from ._ui import NDWidgetUI +from ._ui import NDWidgetUI, RightClickMenu from ...layouts import ImguiFigure, Subplot class NDWidget: def __init__(self, ref_ranges: dict[str, tuple], **kwargs): self._indices = GlobalIndex(ref_ranges, self._get_ndgraphics) - self._figure = ImguiFigure(**kwargs) + self._figure = ImguiFigure(std_right_click_menu=RightClickMenu, **kwargs) + self._figure.std_right_click_menu.set_nd_widget(self) self._subplots_nd: dict[Subplot, NDWSubplot] = dict() for subplot in self.figure: diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index be0999fe6..eba5a97d3 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -11,14 +11,15 @@ ImageGraphic, ImageVolumeGraphic, ) +from ...utils import quick_min_max from ...layouts import Subplot -from ...ui import EdgeWindow -from . import NDPositions +from ...ui import EdgeWindow, StandardRightClickMenu from ._index import RangeContinuous from ._base import NDGraphic +from ._nd_positions import NDPositions +from ._nd_image import NDImage position_graphics = [ScatterCollection, LineCollection, LineStack, ImageGraphic] -image_graphics = [ImageGraphic, ImageVolumeGraphic] class NDWidgetUI(EdgeWindow): @@ -49,7 +50,7 @@ def __init__(self, figure, size, ndwidget): self._last_frame_time = {dim: perf_counter() for dim in ref_ranges.keys()} # loop playback - self._loop ={dim: False for dim in ref_ranges.keys()} + self._loop = {dim: False for dim in ref_ranges.keys()} # auto-plays the ImageWidget's left-most dimension in docs galleries if "DOCS_BUILD" in os.environ.keys(): @@ -116,7 +117,9 @@ def update(self): imgui.same_line() # loop checkbox - _, self._loop[dim] = imgui.checkbox(label=fa.ICON_FA_ROTATE, v=self._loop[dim]) + _, self._loop[dim] = imgui.checkbox( + label=fa.ICON_FA_ROTATE, v=self._loop[dim] + ) if imgui.is_item_hovered(0): imgui.set_tooltip("loop playback") @@ -166,26 +169,57 @@ def update(self): imgui.pop_id() - def _draw_nd_graphics_props_tab(self): - for subplot in self._ndwidget.figure: - if imgui.tree_node(subplot.name): - self._draw_ndgraphics_node(subplot) - imgui.tree_pop() - def _draw_ndgraphics_node(self, subplot: Subplot): - for ng in self._ndwidget[subplot].nd_graphics: - if imgui.tree_node(str(ng)): - if isinstance(ng, NDPositions): - self._draw_nd_pos_ui(subplot, ng) - imgui.tree_pop() +class RightClickMenu(StandardRightClickMenu): + def __init__(self, figure): + self._ndwidget = None + super().__init__(figure=figure) + + def set_nd_widget(self, ndw): + self._ndwidget = ndw + + def _extra_menu(self): + if self._ndwidget is None: + return + + if imgui.begin_menu("ND Graphics"): + subplot = self.get_subplot() + for ndg in self._ndwidget[subplot].nd_graphics: + if imgui.begin_menu( + f"{ndg.name if ndg.name is not None else hex(id(ndg))}" + ): + if isinstance(ndg, NDPositions): + self._draw_nd_pos_ui(subplot, ndg) + elif isinstance(ndg, NDImage): + self._draw_nd_image_ui(subplot, ndg) + imgui.end_menu() + imgui.end_menu() + + def _draw_nd_image_ui(self, subplot, nd_image: NDImage): + _min, _max = quick_min_max(nd_image.graphic.data.value) + changed, vmin = imgui.slider_float( + "vmin", nd_image.graphic.vmin, v_min=_min, v_max=_max + ) + if changed: + nd_image.graphic.vmin = vmin + + changed, vmax = imgui.slider_float( + "vmax", nd_image.graphic.vmax, v_min=_min, v_max=_max + ) + if changed: + nd_image.graphic.vmax = vmax + + changed, new_gamma = imgui.slider_float( + "gamma", nd_image.graphic._material.gamma, 0.01, 5 + ) + if changed: + nd_image.graphic._material.gamma = new_gamma def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): for i, cls in enumerate(position_graphics): if imgui.radio_button(cls.__name__, type(nd_graphic.graphic) is cls): nd_graphic.graphic = cls subplot.auto_scale() - if i < len(position_graphics) - 1: - imgui.same_line() changed, val = imgui.checkbox( "use display window", nd_graphic.display_window is not None @@ -214,7 +248,7 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): "display window", v=nd_graphic.display_window, v_min=type_(0), - v_max=type_(self._ndwidget.ref_ranges[p_dim].stop * 0.25), + v_max=type_(self._ndwidget.ref_ranges[p_dim].stop * 0.1), ) if changed: From 0f03bd2d60432dd23e40bcb6a04e3bca66c49796 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Mar 2026 01:47:45 -0500 Subject: [PATCH 069/163] better --- fastplotlib/widgets/nd_widget/_ui.py | 38 +++++++++++++++++++++------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index eba5a97d3..843d93961 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -2,7 +2,7 @@ from time import perf_counter import numpy as np -from imgui_bundle import imgui, icons_fontawesome_6 as fa +from imgui_bundle import imgui, imgui_ctx, icons_fontawesome_6 as fa from ...graphics import ( ScatterCollection, @@ -173,6 +173,8 @@ def update(self): class RightClickMenu(StandardRightClickMenu): def __init__(self, figure): self._ndwidget = None + self._ndgraphic_windows = set() + super().__init__(figure=figure) def set_nd_widget(self, ndw): @@ -185,16 +187,34 @@ def _extra_menu(self): if imgui.begin_menu("ND Graphics"): subplot = self.get_subplot() for ndg in self._ndwidget[subplot].nd_graphics: - if imgui.begin_menu( - f"{ndg.name if ndg.name is not None else hex(id(ndg))}" - ): - if isinstance(ndg, NDPositions): - self._draw_nd_pos_ui(subplot, ndg) - elif isinstance(ndg, NDImage): - self._draw_nd_image_ui(subplot, ndg) - imgui.end_menu() + name = ndg.name if ndg.name is not None else hex(id(ndg)) + if imgui.menu_item( + f"{name}", "", False + )[0]: + self._ndgraphic_windows.add(ndg) + imgui.end_menu() + def update(self): + super().update() + subplot = self.get_subplot() + + for ndg in list(self._ndgraphic_windows): # set -> list so we can change size during iteration + name = ndg.name if ndg.name is not None else hex(id(ndg)) + imgui.set_next_window_size((0, 0)) + _, open = imgui.begin(name, True) + + if isinstance(ndg, NDPositions): + self._draw_nd_pos_ui(subplot, ndg) + + elif isinstance(ndg, NDImage): + self._draw_nd_image_ui(subplot, ndg) + + if not open: + self._ndgraphic_windows.remove(ndg) + + imgui.end() + def _draw_nd_image_ui(self, subplot, nd_image: NDImage): _min, _max = quick_min_max(nd_image.graphic.data.value) changed, vmin = imgui.slider_float( From 057308d8ef290bd0ab61bc822b33cfdb4c29d899 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Mar 2026 01:59:28 -0500 Subject: [PATCH 070/163] controller options separate window --- .../ui/right_click_menus/_standard_menu.py | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py index 78b5f4c9f..9c659f4a7 100644 --- a/fastplotlib/ui/right_click_menus/_standard_menu.py +++ b/fastplotlib/ui/right_click_menus/_standard_menu.py @@ -31,6 +31,8 @@ def __init__(self, figure): # whether the right click menu is currently open or not self.is_open: bool = False + self._controller_window_open: bool | PlotArea = False + def get_subplot(self) -> PlotArea | bool | None: """get the subplot that a click occurred in""" if self._last_right_click_pos is None: @@ -151,41 +153,55 @@ def update(self): imgui.separator() # controller options - if imgui.begin_menu("Controller"): - _, enabled = imgui.menu_item( - "Enabled", "", self.get_subplot().controller.enabled - ) - - self.get_subplot().controller.enabled = enabled - - changed, damping = imgui.slider_float( - "Damping", - v=self.get_subplot().controller.damping, - v_min=0.0, - v_max=10.0, - ) - - if changed: - self.get_subplot().controller.damping = damping + if imgui.menu_item("Controller Options", "", False)[0]: + self._controller_window_open = self.get_subplot() - imgui.separator() - imgui.text("Controller type:") - # switching between different controllers - for name, controller_type_iter in controller_types.items(): - current_type = type(self.get_subplot().controller) + self._extra_menu() - clicked, _ = imgui.menu_item( - label=name, - shortcut="", - p_selected=current_type is controller_type_iter, - ) + imgui.end_popup() - if clicked and (current_type is not controller_type_iter): - # menu item was clicked and the desired controller isn't the current one - self.get_subplot().controller = name + if self._controller_window_open: + self._draw_controller_window() + + def _draw_controller_window(self): + subplot = self._controller_window_open + + imgui.set_next_window_size((0, 0)) + _, keep_open = imgui.begin(f"Controller", True) + imgui.text(f"subplot: {subplot.name}") + _, enabled = imgui.menu_item( + "Enabled", "", subplot.controller.enabled + ) + + subplot.controller.enabled = enabled + + changed, damping = imgui.slider_float( + "Damping", + v=subplot.controller.damping, + v_min=0.0, + v_max=10.0, + ) + + if changed: + subplot.controller.damping = damping + + imgui.separator() + imgui.text("Controller type:") + # switching between different controllers + for name, controller_type_iter in controller_types.items(): + current_type = type(subplot.controller) + + clicked, _ = imgui.menu_item( + label=name, + shortcut="", + p_selected=current_type is controller_type_iter, + ) - imgui.end_menu() + if clicked and (current_type is not controller_type_iter): + # menu item was clicked and the desired controller isn't the current one + subplot.controller = name - self._extra_menu() + if not keep_open: + self._controller_window_open = False - imgui.end_popup() + imgui.end() From 0335af787ebd7768c341a92aa8e3f4bc77208ee1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Mar 2026 02:35:04 -0500 Subject: [PATCH 071/163] update imgui --- fastplotlib/widgets/nd_widget/_base.py | 59 ++++++++++++-------------- fastplotlib/widgets/nd_widget/_ui.py | 4 +- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 00c190418..4541640a6 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -328,33 +328,6 @@ def __repr__(self): ) -def block_reentrance(setter): - # decorator to block re-entrant indices setter - def set_indices_wrapper(self: NDGraphic, new_indices): - """ - wraps NDGraphic.indices - - self: NDGraphic instance - - new_indices: new indices to set - """ - # set_value is already in the middle of an execution, block re-entrance - if self._block_indices: - return - try: - # block re-execution of set_value until it has *fully* finished executing - self._block_indices = True - setter(self, new_indices) - 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! - finally: - # set_value has finished executing, now allow future executions - self._block_indices = False - - return set_indices_wrapper - - class NDGraphic: def __init__(self, name: str | None): self._name = name @@ -471,10 +444,7 @@ def spatial_func( self.indices = self.indices def __repr__(self): - return ( - f"graphic: {self.graphic}\n" - f"processor:\n{self.processor}" - ) + return f"graphic: {self.graphic}\n" f"processor:\n{self.processor}" @contextmanager @@ -505,3 +475,30 @@ def block_indices(ndgraphic: NDGraphic): raise e from None # indices setter has raised, the line above and the lines below are probably more relevant! finally: ndgraphic._block_indices = False + + +def block_reentrance(setter): + # decorator to block re-entrant indices setter + def set_indices_wrapper(self: NDGraphic, new_indices): + """ + wraps NDGraphic.indices + + self: NDGraphic instance + + new_indices: new indices to set + """ + # set_value is already in the middle of an execution, block re-entrance + if self._block_indices: + return + try: + # block re-execution of set_value until it has *fully* finished executing + self._block_indices = True + setter(self, new_indices) + 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! + finally: + # set_value has finished executing, now allow future executions + self._block_indices = False + + return set_indices_wrapper diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 843d93961..a75d99e00 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -197,12 +197,12 @@ def _extra_menu(self): def update(self): super().update() - subplot = self.get_subplot() for ndg in list(self._ndgraphic_windows): # set -> list so we can change size during iteration name = ndg.name if ndg.name is not None else hex(id(ndg)) + subplot = ndg.graphic._plot_area imgui.set_next_window_size((0, 0)) - _, open = imgui.begin(name, True) + _, open = imgui.begin(f"subplot: {subplot.name}, {name}", True) if isinstance(ndg, NDPositions): self._draw_nd_pos_ui(subplot, ndg) From 5ee11eca9224c3b7b0338d31f4be144f384229cf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Mar 2026 20:47:20 -0500 Subject: [PATCH 072/163] fix --- 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 0b0f25164..6d262678d 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -358,7 +358,7 @@ def __init__( passed to each ImageGraphic in the ImageWidget figure subplots """ - warnings.warn( + warn( "`ImageWidget` is deprecated and will be removed in a" " future release, please migrate to NDWidget", DeprecationWarning From eb918461cb7598ea34098e0775b46bbbbc0f7247 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 7 Mar 2026 19:41:37 -0500 Subject: [PATCH 073/163] fix compute histogram --- fastplotlib/widgets/nd_widget/_nd_image.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 038e7d82f..5589cd221 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -208,6 +208,11 @@ def _recompute_histogram(self): # TODO: account for window funcs sub = subsample_array(self.data, ignore_dims=ignore_dims) + + if isinstance(sub, xr.DataArray): + # can't do the isnan and isinf boolean indexing below on xarray + sub = sub.values + sub_real = sub[~(np.isnan(sub) | np.isinf(sub))] self._histogram = np.histogram(sub_real, bins=100) From 64d61508ef6c80466080e978fd4e3e0a0686f7dc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 14 Mar 2026 02:24:16 -0400 Subject: [PATCH 074/163] other features WIP --- .../nd_widget/_nd_positions/_nd_positions.py | 233 +++++++++++++++++- .../nd_widget/_nd_positions/_pandas.py | 10 +- 2 files changed, 232 insertions(+), 11 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 20fec1fbc..476f22920 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -16,6 +16,7 @@ ScatterGraphic, ScatterCollection, ) +from ....graphics.features.utils import parse_colors from ....graphics.utils import pause_events from ....graphics.selectors import LinearSelector from .._base import ( @@ -32,6 +33,8 @@ # we will know the display dims automatically here from the last dim # so maybe we only need it for images? class NDPositionsProcessor(NDProcessor): + _other_features = ["colors", "markers", "cmaps_transforms", "alphas", "sizes"] + def __init__( self, data: Any, @@ -44,6 +47,11 @@ def __init__( display_window: int | float | None = 100, # window for n_datapoints dim only max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, + colors: Sequence[str] | np.ndarray = None, + markers: Sequence[str] | np.ndarray = None, + cmaps_transforms: np.ndarray = None, + alpha: np.ndarray = None, + sizes: Sequence[float] = None, **kwargs, ): """ @@ -73,6 +81,100 @@ def __init__( self._datapoints_window_func = datapoints_window_func + self.colors = colors + self.markers = markers + self.cmaps_transforms = cmaps_transforms + self.alphas = alpha + self.sizes = sizes + + def _check_get_datapoints_dim_size(self, check_prop: str, check_shape: int) -> tuple[int, int]: + # this function exists because it's used repeatedly for colors, markers, etc. + # shape for [l, p] dims must match, or l must be 1 + shape = tuple([self.shape[dim] for dim in self.spatial_dims[:2]]) + + if check_shape[0] != 1 and check_shape != shape: + raise IndexError( + f"Number of {check_prop} must match the size of the datapoints dim in the data" + ) + + return shape + + @property + def colors(self) -> np.ndarray | None | Callable: + return self._colors + + @colors.setter + def colors(self, new): + if callable(new): + self._colors = new + return + + if new is None: + self._colors = None + return + + n = self._check_get_datapoints_dim_size("colors", new.shape) + self._colors = parse_colors(new, n_colors=n) + + @property + def markers(self) -> np.ndarray | None: + return self._markers + + @markers.setter + def markers(self, new: Sequence[str] | None): + if new is None: + self._markers = None + return + + self._check_get_datapoints_dim_size("markers", len(new)) + self._markers = np.asarray(new) + + @property + def cmaps_transforms(self) -> Sequence[str] | None: + return self._cmaps_transforms + + @cmaps_transforms.setter + def cmaps_transforms(self, new: Sequence[str] | None): + if new is None: + self._cmaps_transforms = None + return + + self._check_get_datapoints_dim_size("markers", len(new)) + self._cmap_transforms = np.asarray(new) + + @property + def alphas(self) -> np.ndarray | None: + return self._alphas + + @alphas.setter + def alphas(self, new: Sequence[float] | None): + if new is None: + self._alphas = None + return + + self._check_get_datapoints_dim_size("alphas", len(new)) + alphas = np.asarray(new) + + self._alphas = alphas + + @property + def sizes(self) -> np.ndarray | None: + return self._sizes + + @sizes.setter + def sizes(self, new: Sequence[float] | None): + if new is None: + self._sizes = None + return + + self._check_get_datapoints_dim_size("alphas", len(new)) + new = np.array(new) + + if new.ndim != 1: + raise ValueError + + self._sizes = new + @property def spatial_dims(self) -> tuple[str, str, str]: return self._spatial_dims @@ -173,9 +275,14 @@ def _get_dw_slice(self, indices: dict[str, Any]) -> slice: if start >= stop: stop = start + 1 - return slice(start, stop) + w = stop - start + + # get step size + step = max(1, w // self.max_display_datapoints) - def _apply_dw_window_func(self, array: xr.DataArray) -> xr.DataArray: + return slice(start, stop, step) + + def _apply_dw_window_func(self, array: xr.DataArray | np.ndarray) -> xr.DataArray | np.ndarray: """ Takes array where display window has already been applied and applies window functions on the `p` dim. @@ -257,16 +364,35 @@ def _apply_dw_window_func(self, array: xr.DataArray) -> xr.DataArray: return array[:, ::step] - def _apply_spatial_func(self, array: xr.DataArray) -> xr.DataArray: + def _apply_spatial_func(self, array: xr.DataArray | np.ndarray) -> xr.DataArray | np.ndarray: if self.spatial_func is not None: return self.spatial_func(array) return array - def _finalize_(self, array: xr.DataArray) -> xr.DataArray: + def _finalize_(self, array: xr.DataArray | np.ndarray) -> xr.DataArray | np.ndarray: return self._apply_spatial_func(self._apply_dw_window_func(array)) - def get(self, indices: dict[str, Any]): + def _get_other_features(self, data_slice: np.ndarray, dw_slice: slice) -> dict[str, np.ndarray]: + other = dict.fromkeys(self._other_features) + for attr in self._other_features: + val = getattr(self, attr) + + if callable(val): + # if it's a callable, give it the data and display window slice, it must return the appropriate + # type of array for that graphic feature + val = val(data_slice, dw_slice) + + match val: + case None: + other[attr] = None + + case _: + other[attr] = val[dw_slice] + + return other + + def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: """ slices through all slider dims and outputs an array that can be used to set graphic data @@ -298,7 +424,13 @@ def get(self, indices: dict[str, Any]): *self.spatial_dims ) - return self._finalize_(graphic_data).values + data = self._finalize_(graphic_data).values + other = self._get_other_features(data, dw_slice) + + return { + "data": data, + **other, + } class NDPositions(NDGraphic): @@ -323,6 +455,15 @@ def __init__( index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, linear_selector: bool = False, + colors: Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] = None, + # TODO: cleanup how this cmap stuff works, require a cmap to be set per-graphic + # before allowing cmaps_transform, validate that stuff makes sense etc. + cmap: str = None, # across the line/scatter collection + cmaps: Sequence[str] = None, # for each individual line/scatter + cmaps_transforms: np.ndarray = None, # for each individual line/scatter + markers: Sequence[str] = None, + sizes: Sequence[float] = None, + alpha: Sequence[float] = None, name: str = None, graphic_kwargs: dict = None, processor_kwargs: dict = None, @@ -332,6 +473,9 @@ def __init__( if processor_kwargs is None: processor_kwargs = dict() + if graphic_kwargs is None: + self._graphic_kwargs = dict() + self._processor = processor( data, dims, @@ -341,6 +485,11 @@ def __init__( max_display_datapoints=max_display_datapoints, window_funcs=window_funcs, index_mappings=index_mappings, + colors=colors, + markers=markers, + cmaps_transforms=cmaps_transforms, + alpha=alpha, + sizes=sizes, **processor_kwargs, ) @@ -394,6 +543,21 @@ def graphic(self, graphic_type): self._create_graphic(graphic_type) plot_area.add_graphic(self._graphic) + @property + def cmap(self) -> str | None: + # across all lines/scatters, or heatmap cmap + pass + + @property + def cmaps(self) -> np.ndarray[str] | None: + # per-line/scatter + pass + + @property + def cmaps_transforms(self) -> np.ndarray | None: + # PER line/scatter, only allowed after `cmaps` is set. + pass + @property def spatial_dims(self) -> tuple[str, str, str]: return self.processor.spatial_dims @@ -411,7 +575,8 @@ def indices(self) -> dict[Hashable, Any]: @indices.setter @block_reentrance def indices(self, indices): - data_slice = self.processor.get(indices) + new_features = self.processor.get(indices) + data_slice = new_features["data"] # TODO: set other graphic features, colors, sizes, markers, etc. @@ -419,7 +584,8 @@ def indices(self, indices): self.graphic.data[:, : data_slice.shape[-1]] = data_slice elif isinstance(self.graphic, (LineCollection, ScatterCollection)): - for g, new_data in zip(self.graphic.graphics, data_slice): + for l, g in enumerate(self.graphic.graphics): + new_data = data_slice[l] if g.data.value.shape[0] != new_data.shape[0]: # will replace buffer internally g.data = new_data @@ -427,6 +593,29 @@ def indices(self, indices): # if data are only xy, set only xy g.data[:, : new_data.shape[1]] = new_data + for feature in ["colors", "sizes", "markers"]: + value = new_features[feature] + + match value: + case None: + pass + case _: + if feature == "colors": + g.color_mode = "vertex" + + setattr(g, feature, value[l]) + + if self.cmaps is not None: + match new_features["cmaps_transforms"]: + case None: + pass + case _: + setattr( + getattr(g, "cmap"), # indv_graphic.cmap + "transform", + new_features["cmaps_transforms"], + ) + elif isinstance(self.graphic, ImageGraphic): image_data, x0, x_scale = self._create_heatmap_data(data_slice) self.graphic.data = image_data @@ -476,7 +665,8 @@ def _create_graphic( if not issubclass(graphic_cls, Graphic): raise TypeError - data_slice = self.processor.get(self.indices) + new_features = self.processor.get(self.indices) + data_slice = new_features["data"] if issubclass(graphic_cls, ImageGraphic): # `d` dim must only have xy data to be interpreted as a heatmap, xyz can't become a timeseries heatmap @@ -495,6 +685,31 @@ def _create_graphic( kwargs = dict() self._graphic = graphic_cls(data_slice, **kwargs) + if isinstance(self._graphic, (LineCollection, ScatterCollection)): + for l, g in enumerate(self.graphic.graphics): + for feature in ["colors", "sizes", "markers"]: + value = new_features[feature] + + match value: + case None: + pass + case _: + if feature == "colors": + g.color_mode = "vertex" + + setattr(g, feature, value[l]) + + if self.cmaps is not None: + match new_features["cmaps_transforms"]: + case None: + pass + case _: + setattr( + getattr(g, "cmap"), # indv_graphic.cmap + "transform", + new_features["cmaps_transforms"], + ) + if self.processor.tooltip: if isinstance(self._graphic, (LineCollection, ScatterCollection)): for g in self._graphic.graphics: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 26acfd73d..740dfe21e 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -73,7 +73,7 @@ def tooltip_format(self, n: int, p: int): p += self._dw_slice.start return str(self.data[self._tooltip_columns[n]][p]) - def get(self, indices: dict[str, Any]) -> np.ndarray: + def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: # TODO: LOD by using a step size according to max_p # TODO: Also what to do if display_window is None and data # hasn't changed when indices keeps getting set, cache? @@ -89,4 +89,10 @@ def get(self, indices: dict[str, Any]) -> np.ndarray: [self.data[c][self._dw_slice] for c in col] ) - return self._apply_dw_window_func(graphic_data) + data = self._finalize_(graphic_data) + other = self._get_other_features(data, self._dw_slice) + + return { + "data": data, + **other, + } From 8b8626d7dc582e88cb387899402528458600312d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 15 Mar 2026 04:25:23 -0400 Subject: [PATCH 075/163] basics of other features works with ScatterStack for colors, markers, sizes, need to keep testing --- fastplotlib/graphics/__init__.py | 3 +- fastplotlib/graphics/features/_scatter.py | 6 +- fastplotlib/graphics/scatter_collection.py | 56 ++--- .../nd_widget/_nd_positions/_nd_positions.py | 225 ++++++++++++------ fastplotlib/widgets/nd_widget/_ndw_subplot.py | 2 +- 5 files changed, 192 insertions(+), 100 deletions(-) diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 8734a5e72..cca2afc21 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -7,7 +7,7 @@ from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack -from .scatter_collection import ScatterCollection +from .scatter_collection import ScatterCollection, ScatterStack __all__ = [ "Graphic", @@ -23,4 +23,5 @@ "LineCollection", "LineStack", "ScatterCollection", + "ScatterStack", ] diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index 36c8527be..685bbe6ec 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -100,7 +100,7 @@ def searchsorted_markers_to_int_array(markers_str_array: np.ndarray[str]): return marker_int_searchsorted_vals[indices] -def parse_markers_init(markers: str | Sequence[str] | np.ndarray, n_datapoints: int): +def parse_markers(markers: str | Sequence[str] | np.ndarray, n_datapoints: int): # first validate then allocate buffers if isinstance(markers, str): @@ -155,7 +155,7 @@ def __init__( Manages the markers buffer for the scatter points. Supports fancy indexing. """ - markers_int_array, self._markers_readable_array = parse_markers_init( + markers_int_array, self._markers_readable_array = parse_markers( markers, n_datapoints ) @@ -205,7 +205,7 @@ def set_value(self, graphic, value): if isinstance(value, (np.ndarray, list, tuple)): if self.buffer.data.shape[0] != len(value): # need to create a new buffer - markers_int_array, self._markers_readable_array = parse_markers_init( + markers_int_array, self._markers_readable_array = parse_markers( value, len(value) ) diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py index b8e7556ad..8762a9fb3 100644 --- a/fastplotlib/graphics/scatter_collection.py +++ b/fastplotlib/graphics/scatter_collection.py @@ -102,7 +102,6 @@ def cmap(self, args): class ScatterCollectionIndexer(CollectionIndexer, _ScatterCollectionProperties): """Indexer for scatter collections""" - pass @@ -117,11 +116,14 @@ def __init__( cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, sizes: float | Sequence[float] = 5.0, + uniform_size: bool = True, + markers: np.ndarray | Sequence[str] = None, + uniform_marker: bool = True, + edge_width: float = 1.0, name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - kwargs_lines: list[dict] = None, **kwargs, ): """ @@ -186,13 +188,6 @@ def __init__( f"len(metadata) != len(data)\n{len(metadatas)} != {len(data)}" ) - if kwargs_lines is not None: - if len(kwargs_lines) != len(data): - raise ValueError( - f"len(kwargs_lines) != len(data)\n" - f"{len(kwargs_lines)} != {len(data)}" - ) - self._cmap_transform = cmap_transform self._cmap_str = cmap @@ -259,9 +254,6 @@ def __init__( "or must be a tuple/list of colors represented by a string with the same length as the data" ) - if kwargs_lines is None: - kwargs_lines = dict() - self._set_world_object(pygfx.Group()) for i, d in enumerate(data): @@ -286,14 +278,34 @@ def __init__( else: _name = None + if markers is not None: + if isinstance(markers, (tuple, list, np.ndarray)): + markers_ = markers[i] + else: + markers_ = markers + else: + markers_ = "o" + + if sizes is not None: + if isinstance(sizes, (tuple, list, np.ndarray)): + sizes_ = sizes[i] + else: + sizes_ = sizes + else: + sizes_ = 5 + lg = ScatterGraphic( data=d, colors=_c, - sizes=sizes, + sizes=sizes_, + markers=markers_, cmap=_cmap, name=_name, metadata=_m, - **kwargs_lines, + uniform_marker=uniform_marker, + uniform_size=uniform_size, + edge_width=edge_width, + **kwargs, ) self.add_graphic(lg) @@ -519,19 +531,16 @@ def _get_linear_selector_init_args(self, axis, padding): class ScatterStack(ScatterCollection): def __init__( self, - data: List[np.ndarray], - thickness: float | Iterable[float] = 2.0, - colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", - cmap: Iterable[str] | str = None, + data: np.ndarray | List[np.ndarray], + colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", + cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - isolated_buffer: bool = True, separation: float = 0.0, separation_axis: str = "y", - kwargs_lines: list[dict] = None, **kwargs, ): """ @@ -584,17 +593,12 @@ def __init__( separation_axis: str, default "y" axis in which the line graphics in the stack should be separated - - kwargs_lines: list[dict], optional - list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - kwargs_collection kwargs for the collection, passed to GraphicCollection """ super().__init__( data=data, - thickness=thickness, colors=colors, cmap=cmap, cmap_transform=cmap_transform, @@ -602,8 +606,6 @@ def __init__( names=names, metadata=metadata, metadatas=metadatas, - isolated_buffer=isolated_buffer, - kwargs_lines=kwargs_lines, **kwargs, ) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 476f22920..a0636c31f 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -15,6 +15,7 @@ LineCollection, ScatterGraphic, ScatterCollection, + ScatterStack, ) from ....graphics.features.utils import parse_colors from ....graphics.utils import pause_events @@ -28,12 +29,15 @@ ) from .._index import GlobalIndex +# types for the other features +FeatureCallable = Callable[[np.ndarray, slice], np.ndarray] +ColorsType = np.ndarray | FeatureCallable | None +MarkersType = Sequence[str] | np.ndarray | FeatureCallable | None +SizesType = Sequence[float] | np.ndarray | FeatureCallable | None + -# TODO: Maybe get rid of n_display_dims in NDProcessor, -# we will know the display dims automatically here from the last dim -# so maybe we only need it for images? class NDPositionsProcessor(NDProcessor): - _other_features = ["colors", "markers", "cmaps_transforms", "alphas", "sizes"] + _other_features = ["colors", "markers", "cmap_transform_each", "sizes"] def __init__( self, @@ -47,11 +51,10 @@ def __init__( display_window: int | float | None = 100, # window for n_datapoints dim only max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, - colors: Sequence[str] | np.ndarray = None, - markers: Sequence[str] | np.ndarray = None, - cmaps_transforms: np.ndarray = None, - alpha: np.ndarray = None, - sizes: Sequence[float] = None, + colors: ColorsType = None, + markers: MarkersType = None, + cmap_transform_each: np.ndarray = None, + sizes: SizesType = None, **kwargs, ): """ @@ -63,7 +66,8 @@ def __init__( spatial_dims index_mappings display_window - max_display_datapoints + max_display_datapoints: int, default 1_000 + this is approximate since floor division is used to determine the step size of the current display window slice datapoints_window_func: Important note: if used, display_window is approximate and not exact due to padding from the window size kwargs @@ -83,29 +87,48 @@ def __init__( self.colors = colors self.markers = markers - self.cmaps_transforms = cmaps_transforms - self.alphas = alpha + self.cmap_transform_each = cmap_transform_each self.sizes = sizes - def _check_get_datapoints_dim_size(self, check_prop: str, check_shape: int) -> tuple[int, int]: + def _check_shape_feature( + self, prop: str, check_shape: tuple[int, int] + ) -> tuple[int, int]: # this function exists because it's used repeatedly for colors, markers, etc. # shape for [l, p] dims must match, or l must be 1 shape = tuple([self.shape[dim] for dim in self.spatial_dims[:2]]) - if check_shape[0] != 1 and check_shape != shape: + if check_shape[1] != shape[1]: + raise IndexError( + f"shape of first two dims of {prop} must must be [l, p] or [1, p].\n" + f"required `p` dim shape is: {shape[1]}, {check_shape[1]} was provided" + ) + + if check_shape[0] != 1 and check_shape[0] != shape[0]: raise IndexError( - f"Number of {check_prop} must match the size of the datapoints dim in the data" + f"shape of first two dims of {prop} must must be [l, p] or [1, p]\n" + f"required `l` dim shape is {shape[0]} | 1, {check_shape[0]} was provided" ) return shape @property - def colors(self) -> np.ndarray | None | Callable: + def colors(self) -> ColorsType: + """ + A callable that dynamically creates colors for the current display window, or array of colors per-datapoint. + + Array must be of shape [l, p, 4] for unique colors per line/scatter, or [1, p, 4] for identical colors per + line/scatter. + + Callable must return an array of shape [l, pw, 4] or [1, pw, 4], where pw is the number of currently displayed + datapoints given the current display window. The callable receives the current data slice array, as well as the + slice object that corresponds to the current display window. + """ return self._colors @colors.setter def colors(self, new): if callable(new): + # custom callable that creates the colors self._colors = new return @@ -113,65 +136,106 @@ def colors(self, new): self._colors = None return - n = self._check_get_datapoints_dim_size("colors", new.shape) - self._colors = parse_colors(new, n_colors=n) + # as array so we can check shape + new = np.asarray(new) + if new.ndim == 2: + # only [p, 4] provided, broadcast to [1, p, 4] + new = new[None] + + shape = self._check_shape_feature("colors", new.shape[:2]) + + if new.shape[0] == 1: + # same colors across all graphical elements + self._colors = parse_colors(new[0], n_colors=shape[1])[None] + + else: + # colors specified for each individual line/scatter + new_ = np.zeros(shape=(*self.data.shape[:2], 4), dtype=np.float32) + for i in range(shape[0]): + new_[i] = parse_colors(new[i], n_colors=shape[1]) + + self._colors = new_ @property - def markers(self) -> np.ndarray | None: + def markers(self) -> MarkersType: + """ + A callable that dynamically creates markers for the current display window, or array of markers per-datapoint. + + Array must be of shape [l, p] for unique markers per line/scatter, or [p,] or [1, p] for identical markers per + line/scatter. + + Callable must return an array of shape [l, pw], [1, pw], or [pw,] where pw is the number of currently displayed + datapoints given the current display window. The callable receives the current data slice array, as well as the + slice object that corresponds to the current display window. + """ return self._markers @markers.setter - def markers(self, new: Sequence[str] | None): + def markers(self, new: MarkersType): + if callable(new): + # custom callable that creates the markers dynamically + self._markers = new + return + if new is None: self._markers = None return - self._check_get_datapoints_dim_size("markers", len(new)) - self._markers = np.asarray(new) + # as array so we can check shape + new = np.asarray(new) - @property - def cmaps_transforms(self) -> Sequence[str] | None: - return self._cmaps_transforms + # if 1-dim, assume it's specifying markers over `p` dim, so set `l` dim to 1 + if new.ndim == 1: + new = new[None] - @cmaps_transforms.setter - def cmaps_transforms(self, new: Sequence[str] | None): - if new is None: - self._cmaps_transforms = None - return + self._check_shape_feature("markers", new.shape[:2]) - self._check_get_datapoints_dim_size("markers", len(new)) - self._cmap_transforms = np.asarray(new) + self._markers = np.asarray(new) @property - def alphas(self) -> np.ndarray | None: - return self._alphas + def cmap_transform_each(self) -> Sequence[str] | None: + return self._cmap_transform_each - @alphas.setter - def alphas(self, new: Sequence[float] | None): + @cmap_transform_each.setter + def cmap_transform_each(self, new: Sequence[str] | None): if new is None: - self._alphas = None + self._cmap_transform_each = None return - self._check_get_datapoints_dim_size("alphas", len(new)) - alphas = np.asarray(new) - - self._alphas = alphas + self._check_shape_feature("markers", len(new)) + self._cmap_transforms = np.asarray(new) @property - def sizes(self) -> np.ndarray | None: + def sizes(self) -> SizesType: return self._sizes @sizes.setter - def sizes(self, new: Sequence[float] | None): + def sizes(self, new: SizesType): + """ + A callable that dynamically creates sizes for the current display window, or array of sizes per-datapoint. + + Array must be of shape [l, p] for unique sizes per line/scatter, or [p,] or [1, p] for identical markers per + line/scatter. + + Callable must return an array of shape [l, pw], [1, pw], or [pw,] where pw is the number of currently displayed + datapoints given the current display window. The callable receives the current data slice array, as well as the + slice object that corresponds to the current display window. + """ + if callable(new): + # custom callable + self._sizes = new + return + if new is None: self._sizes = None return - self._check_get_datapoints_dim_size("alphas", len(new)) new = np.array(new) + # if 1-dim, assume it's specifying sizes over `p` dim, set `l` dim to 1 + if new.ndim == 1: + new = new[None] - if new.ndim != 1: - raise ValueError + self._check_shape_feature("sizes", new.shape) self._sizes = new @@ -247,7 +311,7 @@ def _get_dw_slice(self, indices: dict[str, Any]) -> slice: if self.display_window is None: # just return everything - return slice(0, self.shape[p_dim] - 1) + return slice(0, self.shape[p_dim]) if self.display_window == 0: # just map p dimension at this index and return @@ -282,7 +346,9 @@ def _get_dw_slice(self, indices: dict[str, Any]) -> slice: return slice(start, stop, step) - def _apply_dw_window_func(self, array: xr.DataArray | np.ndarray) -> xr.DataArray | np.ndarray: + def _apply_dw_window_func( + self, array: xr.DataArray | np.ndarray + ) -> xr.DataArray | np.ndarray: """ Takes array where display window has already been applied and applies window functions on the `p` dim. @@ -364,7 +430,9 @@ def _apply_dw_window_func(self, array: xr.DataArray | np.ndarray) -> xr.DataArra return array[:, ::step] - def _apply_spatial_func(self, array: xr.DataArray | np.ndarray) -> xr.DataArray | np.ndarray: + def _apply_spatial_func( + self, array: xr.DataArray | np.ndarray + ) -> xr.DataArray | np.ndarray: if self.spatial_func is not None: return self.spatial_func(array) @@ -373,22 +441,38 @@ def _apply_spatial_func(self, array: xr.DataArray | np.ndarray) -> xr.DataArray def _finalize_(self, array: xr.DataArray | np.ndarray) -> xr.DataArray | np.ndarray: return self._apply_spatial_func(self._apply_dw_window_func(array)) - def _get_other_features(self, data_slice: np.ndarray, dw_slice: slice) -> dict[str, np.ndarray]: + def _get_other_features( + self, data_slice: np.ndarray, dw_slice: slice + ) -> dict[str, np.ndarray]: other = dict.fromkeys(self._other_features) for attr in self._other_features: val = getattr(self, attr) + if val is None: + other[attr] = None + continue + if callable(val): # if it's a callable, give it the data and display window slice, it must return the appropriate # type of array for that graphic feature - val = val(data_slice, dw_slice) + val_sliced = val(data_slice, dw_slice) + + else: + # if no l dim, broadcast to [1, p] + if val.ndim == 1: + val = val[None] - match val: - case None: - other[attr] = None + # apply current display window slice + val_sliced = val[:, dw_slice] - case _: - other[attr] = val[dw_slice] + # check if l dim size is 1 + if val_sliced.shape[0] == 1: + # broadcast across all graphical elements + n_graphics = self.shape[self.spatial_dims[0]] + print(val_sliced.shape, n_graphics) + val_sliced = np.broadcast_to(val_sliced, shape=(n_graphics, *val_sliced.shape[1:])) + + other[attr] = val_sliced return other @@ -447,6 +531,7 @@ def __init__( | LineStack | ScatterGraphic | ScatterCollection + | ScatterStack | ImageGraphic ], processor: type[NDPositionsProcessor] = NDPositionsProcessor, @@ -455,15 +540,16 @@ def __init__( index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, linear_selector: bool = False, - colors: Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] = None, + colors: ( + Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] + ) = None, # TODO: cleanup how this cmap stuff works, require a cmap to be set per-graphic # before allowing cmaps_transform, validate that stuff makes sense etc. cmap: str = None, # across the line/scatter collection - cmaps: Sequence[str] = None, # for each individual line/scatter - cmaps_transforms: np.ndarray = None, # for each individual line/scatter + cmap_each: Sequence[str] = None, # for each individual line/scatter + cmap_transform_each: np.ndarray = None, # for each individual line/scatter markers: Sequence[str] = None, sizes: Sequence[float] = None, - alpha: Sequence[float] = None, name: str = None, graphic_kwargs: dict = None, processor_kwargs: dict = None, @@ -475,6 +561,8 @@ def __init__( if graphic_kwargs is None: self._graphic_kwargs = dict() + else: + self._graphic_kwargs = graphic_kwargs self._processor = processor( data, @@ -487,8 +575,7 @@ def __init__( index_mappings=index_mappings, colors=colors, markers=markers, - cmaps_transforms=cmaps_transforms, - alpha=alpha, + cmap_transform_each=cmap_transform_each, sizes=sizes, **processor_kwargs, ) @@ -527,6 +614,7 @@ def graphic( | LineStack | ScatterGraphic | ScatterCollection + | ScatterStack | ImageGraphic ): """LineStack or ImageGraphic for heatmaps""" @@ -606,14 +694,14 @@ def indices(self, indices): setattr(g, feature, value[l]) if self.cmaps is not None: - match new_features["cmaps_transforms"]: + match new_features["cmap_transform_each"]: case None: pass case _: setattr( - getattr(g, "cmap"), # indv_graphic.cmap + getattr(g, "cmap"), # ind_graphic.cmap "transform", - new_features["cmaps_transforms"], + new_features["cmap_transform_each"], ) elif isinstance(self.graphic, ImageGraphic): @@ -659,6 +747,7 @@ def _create_graphic( | LineStack | ScatterGraphic | ScatterCollection + | ScatterStack | ImageGraphic ], ): @@ -679,10 +768,10 @@ def _create_graphic( ) else: - if issubclass(graphic_cls, LineStack): - kwargs = {"separation": 0.0} + if issubclass(graphic_cls, (LineStack, ScatterStack)): + kwargs = {"separation": 0.0, **self._graphic_kwargs} else: - kwargs = dict() + kwargs = self._graphic_kwargs self._graphic = graphic_cls(data_slice, **kwargs) if isinstance(self._graphic, (LineCollection, ScatterCollection)): diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 5a0b00da2..0783379ec 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -1,6 +1,6 @@ import numpy as np -from ... import ScatterCollection, LineCollection, LineStack, ImageGraphic +from ... import ScatterCollection, ScatterStack, LineCollection, LineStack, ImageGraphic from ...layouts import Subplot from . import NDImage, NDPositions from ._base import NDGraphic From 6727f45f8a0e13fa8420db4fb9367d550e981991 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 15 Mar 2026 04:40:24 -0400 Subject: [PATCH 076/163] require min pygfx v0.16.0 due to gc hash fix necessary for NDWidget --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 30d194e79..0a9371891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ keywords = [ requires-python = ">= 3.10" dependencies = [ "numpy>=1.23.0", - "pygfx==0.15.3", + "pygfx==0.16.0", "wgpu", # Let pygfx constrain the wgpu version "cmap>=0.1.3", # (this comment keeps this list multiline in VSCode) From f8b1ea41ef31f970ee3f9e092ad11ef9941f8b78 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 15 Mar 2026 05:16:38 -0400 Subject: [PATCH 077/163] fix PlotArea.y_range --- fastplotlib/layouts/_plot_area.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 513a7ad47..974b6f653 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -885,15 +885,15 @@ def y_range(self) -> tuple[float, float]: Only valid for orthographic projections of the xy plane. Use camera.set_state() to set the camera position for arbitrary projections. """ - hh = self.camera.width / 2 + hh = self.camera.height / 2 y = self.camera.local.y return y - hh, y + hh @y_range.setter def y_range(self, yr: tuple[float, float]): - width = yr[1] - yr[0] - y_mid = yr[0] + (width / 2) - self.camera.width = width + height = yr[1] - yr[0] + y_mid = yr[0] + (height / 2) + self.camera.height = height self.camera.local.y = y_mid def remove_graphic(self, graphic: Graphic): From 9374820645481f57d182c65987cf9a1090904099 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 15 Mar 2026 05:17:09 -0400 Subject: [PATCH 078/163] fix to create isolated buffer for colors when buffer replaced --- fastplotlib/graphics/features/_positions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 7b67e6bd7..71767e3ec 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -89,7 +89,9 @@ def set_value( new_colors = parse_colors(value, len(value)) # create the new buffer, old buffer should get dereferenced - self._fpl_buffer = pygfx.Buffer(new_colors) + # make sure new buffer is isolated (i.e. allocate a buffer, then set the values) + self._fpl_buffer = pygfx.Buffer(np.zeros(new_colors.shape, dtype=np.float32)) + self._fpl_buffer.data[:] = new_colors graphic.world_object.geometry.colors = self._fpl_buffer if len(self._event_handlers) < 1: From ef7c29cbee5eac1baa0c3abc9b4ee29da82535de Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 15 Mar 2026 05:44:31 -0400 Subject: [PATCH 079/163] np.empty --- fastplotlib/graphics/features/_positions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 71767e3ec..507fc1ee0 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -90,8 +90,9 @@ def set_value( # create the new buffer, old buffer should get dereferenced # make sure new buffer is isolated (i.e. allocate a buffer, then set the values) - self._fpl_buffer = pygfx.Buffer(np.zeros(new_colors.shape, dtype=np.float32)) - self._fpl_buffer.data[:] = new_colors + buff = np.empty(new_colors.shape, dtype=np.float32) + buff[:] = new_colors + self._fpl_buffer = pygfx.Buffer(buff) graphic.world_object.geometry.colors = self._fpl_buffer if len(self._event_handlers) < 1: From c591d5cbe9b100fb54bc8a45ebdd4cdc33f7c1de Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 15 Mar 2026 07:24:54 -0400 Subject: [PATCH 080/163] cmap_transform_each WIP --- fastplotlib/widgets/nd_widget/_base.py | 2 +- fastplotlib/widgets/nd_widget/_nd_image.py | 3 +- .../nd_widget/_nd_positions/_nd_positions.py | 112 +++++++++++++++--- 3 files changed, 98 insertions(+), 19 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 4541640a6..1cfa2c42c 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -367,7 +367,7 @@ def data(self, data: Any): @property def shape(self) -> dict[Hashable, int]: """interpreted shape of the data""" - self.processor.shape + return self.processor.shape @property def ndim(self) -> int: diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 5589cd221..f78bf7ce9 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -236,6 +236,8 @@ def __init__( name: str = None, ): + super().__init__(name) + self._global_index = global_index self._processor = NDImageProcessor( @@ -254,7 +256,6 @@ def __init__( self._histogram_widget: HistogramLUTTool | None = None self._create_graphic() - super().__init__(name) @property def processor(self) -> NDImageProcessor: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index a0636c31f..6d1054746 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -1,6 +1,7 @@ from collections.abc import Callable, Hashable, Sequence from functools import partial from typing import Literal, Any, Type +from warnings import warn import numpy as np from numpy.lib.stride_tricks import sliding_window_view @@ -36,6 +37,18 @@ SizesType = Sequence[float] | np.ndarray | FeatureCallable | None +def default_cmap_transform_each(p: int, data_slice: np.ndarray, s: slice): + # create a cmap transform based on the `p` dim size + n_displayed = data_slice.shape[1] + + # linspace that's just normalized 0 - 1 within `p` dim size + return np.linspace( + start=s.start / p, + stop=s.stop / p, + num=n_displayed, + endpoint=False # since we use a slice object for the displayed data, the last point isn't included + ) + class NDPositionsProcessor(NDProcessor): _other_features = ["colors", "markers", "cmap_transform_each", "sizes"] @@ -193,17 +206,40 @@ def markers(self, new: MarkersType): self._markers = np.asarray(new) @property - def cmap_transform_each(self) -> Sequence[str] | None: + def cmap_transform_each(self) -> np.ndarray | FeatureCallable | None: return self._cmap_transform_each @cmap_transform_each.setter - def cmap_transform_each(self, new: Sequence[str] | None): + def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): + """ + A callable that dynamically creates cmap transforms for the current display window, or array + of transforms per-datapoint. + + Array must be of shape [l, p] for unique transforms per line/scatter, or [p,] or [1, p] for identical markers + per line/scatter. + + Callable must return an array of shape [l, pw], [1, pw], or [pw,] where pw is the number of currently displayed + datapoints given the current display window. The callable receives the current data slice array, as well as the + slice object that corresponds to the current display window. + """ + if callable(new): + self._cmap_transform_each = new + return + if new is None: + # default transform is just a transform based on the `p` dim size self._cmap_transform_each = None return - self._check_shape_feature("markers", len(new)) - self._cmap_transforms = np.asarray(new) + new = np.asarray(new) + + # if 1-dim, assume it's specifying sizes over `p` dim, set `l` dim to 1 + if new.ndim == 1: + new = new[None] + + self._check_shape_feature("cmap_transform_each", new.shape) + + self._cmap_transform_each = new @property def sizes(self) -> SizesType: @@ -470,7 +506,9 @@ def _get_other_features( # broadcast across all graphical elements n_graphics = self.shape[self.spatial_dims[0]] print(val_sliced.shape, n_graphics) - val_sliced = np.broadcast_to(val_sliced, shape=(n_graphics, *val_sliced.shape[1:])) + val_sliced = np.broadcast_to( + val_sliced, shape=(n_graphics, *val_sliced.shape[1:]) + ) other[attr] = val_sliced @@ -554,6 +592,8 @@ def __init__( graphic_kwargs: dict = None, processor_kwargs: dict = None, ): + super().__init__(name) + self._global_index = global_index if processor_kwargs is None: @@ -580,7 +620,9 @@ def __init__( **processor_kwargs, ) - self._processor.p_max = 1_000 + self.cmap = cmap + self.cmap_each = cmap_each + self.cmap_transform_each = cmap_transform_each self._create_graphic(graphic) @@ -599,7 +641,6 @@ def __init__( self._pause = False - super().__init__(name) @property def processor(self) -> NDPositionsProcessor: @@ -633,18 +674,53 @@ def graphic(self, graphic_type): @property def cmap(self) -> str | None: - # across all lines/scatters, or heatmap cmap - pass + return self._cmap + + @cmap.setter + def cmap(self, new: str | None): + self._cmap = new @property - def cmaps(self) -> np.ndarray[str] | None: + def cmap_each(self) -> np.ndarray[str] | None: # per-line/scatter - pass + return self._cmap_each + + @cmap_each.setter + def cmap_each(self, new: Sequence[str] | None): + if isinstance(new, str): + new = [new] + if new is None: + self._cmap_each = None + + new = np.asarray(new) + + if new.ndim != 1: + raise ValueError + + l_dim_size = self.processor.shape[self.processor.spatial_dims[0]] + # same cmap for all if size == 1, or specific cmap for each in `l` dim + if new.size != 1 and new.size != l_dim_size: + raise ValueError + + self._cmap_each = np.broadcast_to(new, shape=(l_dim_size,)) @property - def cmaps_transforms(self) -> np.ndarray | None: + def cmap_transform_each(self) -> np.ndarray | None: # PER line/scatter, only allowed after `cmaps` is set. - pass + return self.processor.cmap_transform_each + + @cmap_transform_each.setter + def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): + if self.cmap_each is None: + self.processor.cmap_transform_each = None + warn("must set `cmap_each` before `cmap_transform_each`") + if new is None and self.cmap_each is not None: + # default transform is just a transform based on the `p` dim size + new = partial( + default_cmap_transform_each, self.shape[self.spatial_dims[1]] + ) + + self.processor.cmap_transform_each = new @property def spatial_dims(self) -> tuple[str, str, str]: @@ -693,7 +769,7 @@ def indices(self, indices): setattr(g, feature, value[l]) - if self.cmaps is not None: + if self.cmap_each is not None: match new_features["cmap_transform_each"]: case None: pass @@ -788,15 +864,17 @@ def _create_graphic( setattr(g, feature, value[l]) - if self.cmaps is not None: - match new_features["cmaps_transforms"]: + if self.cmap_each is not None: + g.color_mode = "vertex" + g.cmap = self.cmap_each[l] + match new_features["cmap_transform_each"]: case None: pass case _: setattr( getattr(g, "cmap"), # indv_graphic.cmap "transform", - new_features["cmaps_transforms"], + new_features["cmap_transform_each"], ) if self.processor.tooltip: From 7f5e5e523bdcfb73e3549a5e6044251e8a439ce3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 15 Mar 2026 08:12:14 -0400 Subject: [PATCH 081/163] progress --- .../widgets/nd_widget/_nd_positions/_nd_positions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 6d1054746..f1e170745 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -687,10 +687,12 @@ def cmap_each(self) -> np.ndarray[str] | None: @cmap_each.setter def cmap_each(self, new: Sequence[str] | None): - if isinstance(new, str): - new = [new] if new is None: self._cmap_each = None + return + + if isinstance(new, str): + new = [new] new = np.asarray(new) @@ -714,6 +716,8 @@ def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): if self.cmap_each is None: self.processor.cmap_transform_each = None warn("must set `cmap_each` before `cmap_transform_each`") + return + if new is None and self.cmap_each is not None: # default transform is just a transform based on the `p` dim size new = partial( From 62ed9390c6b0932edbd78d75f41d3f6767584cc9 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 15 Mar 2026 19:11:53 -0400 Subject: [PATCH 082/163] fix --- fastplotlib/graphics/features/_scatter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index 685bbe6ec..e41115ae3 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -569,6 +569,7 @@ def set_value(self, graphic, value): # create new buffer value = self._fix_sizes(value, len(value)) data = np.empty(shape=(len(value),), dtype=np.float32) + data[:] = value # create the new buffer, old buffer should get dereferenced self._fpl_buffer = pygfx.Buffer(data) From 148c6c38f06509138df59240463a8f2b90d3a4d3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 17 Mar 2026 05:05:07 -0400 Subject: [PATCH 083/163] replace graphic when data changed, tweak index_mappings --- fastplotlib/widgets/nd_widget/_base.py | 18 +++++++++++++++--- .../nd_widget/_nd_positions/_nd_positions.py | 3 +++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 1cfa2c42c..707480e58 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -35,7 +35,7 @@ def __init__( window_order: tuple[Hashable, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): - self._data = self._validate_data(data, dims) + self._data = self._validate_data(data, tuple(dims)) self.spatial_dims = spatial_dims self.index_mappings = index_mappings @@ -214,8 +214,10 @@ def index_mappings( return for d in maps.keys(): - if d not in self.slider_dims: - raise KeyError + if d not in self.dims: + raise KeyError( + f"`index_mapping` provided for non-existent dimension: {d}, existing dims are: {self.dims}" + ) if isinstance(maps[d], ArrayProtocol): # create a searchsorted mapping function automatically @@ -333,6 +335,9 @@ def __init__(self, name: str | None): self._name = name self._block_indices = False + def _create_graphic(self, graphic_cls: type): + raise NotImplementedError + @property def name(self) -> str | None: return self._name @@ -361,6 +366,13 @@ def data(self) -> Any: @data.setter def data(self, data: Any): self.processor.data = data + # create a new graphic when data has changed + plot_area = self._graphic._plot_area + plot_area.delete_graphic(self._graphic) + + self._create_graphic(self.graphic.__class__) + plot_area.add_graphic(self._graphic) + # force a re-render self.indices = self.indices diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index f1e170745..e5800d538 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -957,6 +957,9 @@ def x_range_mode(self, mode: Literal[None, "fixed-window", "view-range"]): self._x_range_mode = mode def _update_from_view_range(self): + if self._graphic is None: + return + xr = self.graphic._plot_area.x_range # the floating point error near zero gets nasty here From db492190dc18f716e8444097be816e524c398fb2 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 17 Mar 2026 07:23:13 -0400 Subject: [PATCH 084/163] Update installation docs (#1013) * add simplejpeg to notebook deps * Update guide.rst * Update guide.rst * Update README.md --- README.md | 33 ++++++++++++++---------------- docs/source/user_guide/guide.rst | 35 +++++++++++++++++++------------- pyproject.toml | 1 + 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index da5ed64f8..c8e64e65e 100644 --- a/README.md +++ b/README.md @@ -63,31 +63,28 @@ Questions, issues, ideas? You are welcome to post an [issue](https://github.com/ To install use pip: -```bash -# with imgui and jupyterlab -pip install -U "fastplotlib[notebook,imgui]" +### With imgui support (recommended) -# minimal install, install glfw, pyqt6 or pyside6 separately -pip install -U fastplotlib +Without jupyterlab support, install desired GUI framework such as glfw, PyQt6, or PySide6 separately. -# with imgui -pip install -U "fastplotlib[imgui]" + pip install -U "fastplotlib[imgui]" -# to use in jupyterlab without imgui -pip install -U "fastplotlib[notebook]" -``` +With jupyterlab support. -We strongly recommend installing ``simplejpeg`` for use in notebooks, you must first install [libjpeg-turbo](https://libjpeg-turbo.org/) + pip install -U "fastplotlib[notebook,imgui]" -- If you use ``conda``, you can get ``libjpeg-turbo`` through conda. -- If you are on linux, you can get it through your distro's package manager. -- For Windows and Mac compiled binaries are available on their release page: https://github.com/libjpeg-turbo/libjpeg-turbo/releases +### Without imgui -Once you have ``libjpeg-turbo``: +Minimal, install desired GUI library such as PyQt6, PySide6, or glfw separately. + + pip install fastplotlib + +With jupyterlab support only. + + pip install -U "fastplotlib[notebook]" + +Fastplotlib is also available on conda-forge. For imgui support you will need to separately install `imgui-bundle`, and for jupyterlab you will need to install `jupyter-rfb` and `simplejpeg` which are all available on conda-forge. -```bash -pip install simplejpeg -``` > **Note:** > `fastplotlib` and `pygfx` are fast evolving projects, the version available through pip might be outdated, you will need to follow the "For developers" instructions below if you want the latest features. You can find the release history here: https://github.com/fastplotlib/fastplotlib/releases diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index bd0352aa7..c3487de2e 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -6,31 +6,38 @@ Installation To install use pip: -.. code-block:: +With imgui support (recommended) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - # with imgui and jupyterlab - pip install -U "fastplotlib[notebook,imgui]" +Without jupyterlab support, install desired GUI framework such as glfw, PyQt6, or PySide6 separately. - # minimal install, install glfw, pyqt6 or pyside6 separately - pip install -U fastplotlib +.. code-block:: - # with imgui pip install -U "fastplotlib[imgui]" - # to use in jupyterlab, no imgui - pip install -U "fastplotlib[notebook]" +With jupyterlab support. -We strongly recommend installing ``simplejpeg`` for use in notebooks, you must first install `libjpeg-turbo `_. +.. code-block:: + + pip install -U "fastplotlib[notebook,imgui]" -- If you use ``conda``, you can get ``libjpeg-turbo`` through conda. -- If you are on linux you can get it through your distro's package manager. -- For Windows and Mac compiled binaries are available on their release page: https://github.com/libjpeg-turbo/libjpeg-turbo/releases +Without imgui +^^^^^^^^^^^^^ -Once you have ``libjpeg-turbo``: +Minimal, install desired GUI library such as PyQt6, PySide6, or glfw separately. .. code-block:: - pip install simplejpeg + pip install fastplotlib + +With jupyterlab support only. + +.. code-block:: + + pip install -U "fastplotlib[notebook]" + +Fastplotlib is also available on conda-forge. For imgui support you will need to separately install ``imgui-bundle``, and for jupyterlab you will need to install ``jupyter-rfb`` and ``simplejpeg`` which are all available on conda-forge. + What is ``fastplotlib``? ------------------------ diff --git a/pyproject.toml b/pyproject.toml index 0a9371891..b91b168c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ notebook = [ "jupyter-rfb>=0.5.1", "ipywidgets>=8.0.0,<9", "sidecar", + "simplejpeg", ] tests = [ "pytest", From f6f81412c52db29b2607e857f005af4d77e7f91b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 17 Mar 2026 19:07:32 -0400 Subject: [PATCH 085/163] cmap lib handles image colormaps now --- fastplotlib/graphics/features/_image.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index cb66bb1ef..af0783c71 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -1,14 +1,13 @@ from itertools import product - from math import ceil +import cmap as cmap_lib import numpy as np import pygfx from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance from ...utils import ( - make_colors, get_cmap_texture, ) @@ -239,8 +238,8 @@ def value(self) -> str: @block_reentrance def set_value(self, graphic, value: str): - new_colors = make_colors(256, value) - graphic._material.map.texture.data[:] = new_colors + colormap = pygfx.cm.create_colormap(cmap_lib.Colormap(value).lut()) + graphic._material.map = colormap graphic._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1)) self._value = value From c2dff8691d1985404d904d30b768431ebb8f8d6d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 17 Mar 2026 19:10:29 -0400 Subject: [PATCH 086/163] multi-windows ndwidget, maintain features like cmap when switching graphics --- fastplotlib/graphics/line_collection.py | 9 +- fastplotlib/graphics/scatter_collection.py | 36 ++- fastplotlib/layouts/_graphic_methods_mixin.py | 139 +++++++++-- fastplotlib/widgets/nd_widget/_index.py | 39 ++- .../nd_widget/_nd_positions/_nd_positions.py | 232 ++++++++++++------ fastplotlib/widgets/nd_widget/_ndwidget.py | 33 ++- fastplotlib/widgets/nd_widget/_ui.py | 17 +- 7 files changed, 376 insertions(+), 129 deletions(-) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 5ec56777e..351f3368e 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -1,3 +1,5 @@ +from itertools import repeat +from numbers import Number from typing import * import numpy as np @@ -105,8 +107,11 @@ def thickness(self) -> np.ndarray: return np.asarray([g.thickness for g in self]) @thickness.setter - def thickness(self, values: np.ndarray | list[float]): - if not len(values) == len(self): + def thickness(self, values: float | Sequence[float]): + if isinstance(values, Number): + values = repeat(values, len(self)) + + elif not len(values) == len(self): raise IndexError for g, v in zip(self, values): diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py index 8762a9fb3..f0993dd46 100644 --- a/fastplotlib/graphics/scatter_collection.py +++ b/fastplotlib/graphics/scatter_collection.py @@ -1,3 +1,5 @@ +from itertools import repeat +from numbers import Number from typing import * import numpy as np @@ -59,7 +61,7 @@ def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[st @property def data(self) -> CollectionFeature: - """get or set data of lines in the collection""" + """get or set data of scatters in the collection""" return CollectionFeature(self.graphics, "data") @data.setter @@ -99,6 +101,38 @@ def cmap(self, args): n_colors=len(self), cmap_name=name, transform=transform ) + @property + def markers(self) -> CollectionFeature: + """get or set markers of scatters in the collection""" + return CollectionFeature(self.graphics, "markers") + + @markers.setter + def markers(self, values: str | Sequence[str]): + if isinstance(values, str): + values = repeat(values, len(self)) + + elif len(values) != len(self): + raise IndexError("len(markers) must be the same as the number of ScatterGraphics in the collection") + + for g, v in zip(self, values): + g.markers = v + + @property + def sizes(self) -> CollectionFeature: + """get or set sizes of scatter points in the collection""" + return CollectionFeature(self.graphics, "sizes") + + @sizes.setter + def sizes(self, values): + if isinstance(values, Number): + values = repeat(values, len(self)) + + elif len(values) != len(self): + raise IndexError("len(sizes) must be the same as the number of ScatterGraphics in the collection") + + for g, v in zip(self, values): + g.sizes = v + class ScatterCollectionIndexer(CollectionIndexer, _ScatterCollectionProperties): """Indexer for scatter collections""" diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index bd01855bd..1fbf337e2 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -33,7 +33,7 @@ def add_image( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -74,7 +74,7 @@ def add_image( cmap, interpolation, cmap_interpolation, - **kwargs, + **kwargs ) def add_image_volume( @@ -92,7 +92,7 @@ def add_image_volume( substep_size: float = 0.1, emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ @@ -169,7 +169,7 @@ def add_image_volume( substep_size, emissive, shininess, - **kwargs, + **kwargs ) def add_line_collection( @@ -185,7 +185,7 @@ def add_line_collection( metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -256,7 +256,7 @@ def add_line_collection( metadata, metadatas, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -268,7 +268,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -322,7 +322,7 @@ def add_line( cmap_transform, color_mode, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -339,7 +339,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -415,7 +415,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_mesh( @@ -434,7 +434,7 @@ def add_mesh( | numpy.ndarray ) = None, clim: tuple[float, float] = None, - **kwargs, + **kwargs ) -> MeshGraphic: """ @@ -488,7 +488,7 @@ def add_mesh( mapcoords, cmap, clim, - **kwargs, + **kwargs ) def add_polygon( @@ -505,7 +505,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> PolygonGraphic: """ @@ -555,12 +555,15 @@ def add_scatter_collection( cmap: Union[Sequence[str], str] = None, cmap_transform: Union[numpy.ndarray, List] = None, sizes: Union[float, Sequence[float]] = 5.0, + uniform_size: bool = True, + markers: Union[numpy.ndarray, Sequence[str]] = None, + uniform_marker: bool = True, + edge_width: float = 1.0, name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> ScatterCollection: """ @@ -618,12 +621,15 @@ def add_scatter_collection( cmap, cmap_transform, sizes, + uniform_size, + markers, + uniform_marker, + edge_width, name, names, metadata, metadatas, - kwargs_lines, - **kwargs, + **kwargs ) def add_scatter( @@ -648,7 +654,7 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Sequence[float]] = 5, uniform_size: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -778,7 +784,92 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs, + **kwargs + ) + + def add_scatter_stack( + self, + data: Union[numpy.ndarray, List[numpy.ndarray]], + colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", + cmap: Union[Sequence[str], str] = None, + cmap_transform: Union[numpy.ndarray, List] = None, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Union[Sequence[Any], numpy.ndarray] = None, + separation: float = 0.0, + separation_axis: str = "y", + **kwargs + ) -> ScatterStack: + """ + + Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + thickness: float or Iterable of float, default 2.0 + | if ``float``, single thickness will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + metadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + separation: float, default 0.0 + space in between each line graphic in the stack + + separation_axis: str, default "y" + axis in which the line graphics in the stack should be separated + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + + """ + return self._create_graphic( + ScatterStack, + data, + colors, + cmap, + cmap_transform, + name, + names, + metadata, + metadatas, + separation, + separation_axis, + **kwargs ) def add_surface( @@ -795,7 +886,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> SurfaceGraphic: """ @@ -849,7 +940,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -900,7 +991,7 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) def add_vectors( @@ -910,7 +1001,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, + **kwargs ) -> VectorsGraphic: """ @@ -955,5 +1046,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs, + **kwargs ) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 9ba9d03eb..3cb8a71f9 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -1,7 +1,11 @@ +from __future__ import annotations + from dataclasses import dataclass from typing import Sequence, Any, Callable -from ._base import NDGraphic +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from ._ndwidget import NDWidget @dataclass @@ -49,11 +53,10 @@ def __len__(self): return len(self.options) -class GlobalIndex: +class ReferenceIndex: def __init__( self, ref_ranges: dict[str, tuple], - get_ndgraphics: Callable[[], tuple[NDGraphic]], ): self._ref_ranges = dict() @@ -69,8 +72,6 @@ def __init__( else: raise ValueError - self._get_ndgraphics = get_ndgraphics - # starting index for all dims self._indices: dict[str, int | float | Any] = { name: rr.start for name, rr in self._ref_ranges.items() @@ -78,9 +79,18 @@ def __init__( self._indices_changed_handlers = set() + self._ndwidgets: list[NDWidget] = list() + + def _add_ndwidget_(self, ndw: NDWidget): + from ._ndwidget import NDWidget + if not isinstance(ndw, NDWidget): + raise TypeError + + self._ndwidgets.append(ndw) + def set(self, indices: dict[str, Any]): for dim, value in indices.items(): - self._indices[dim] = self._clamp(value) + self._indices[dim] = self._clamp(dim, value) self._render_indices() @@ -94,9 +104,13 @@ def _clamp(self, dim, value): return value def _render_indices(self): - for g in self._get_ndgraphics(): - # only provide slider indices to the graphic - g.indices = {d: self._indices[d] for d in g.processor.slider_dims} + for ndw in self._ndwidgets: + for g in ndw.ndgraphics: + if g.data is None: + continue + # only provide slider indices to the graphic + g.indices = {d: self._indices[d] for d in g.processor.slider_dims} + print(g) @property def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: @@ -117,7 +131,7 @@ def push_dim(self, ref_range: RangeContinuous): # TODO: implement pushing and popping dims pass - def add_event_handler(self, handler: callable, event: str = "indices"): + def add_event_handler(self, handler: Callable, event: str = "indices"): """ Register an event handler. @@ -126,7 +140,7 @@ def add_event_handler(self, handler: callable, event: str = "indices"): Parameters ---------- - handler: callable + handler: Callable callback function, must take a tuple of int as the only argument. This tuple will be the `indices` event: str, "indices" @@ -153,7 +167,7 @@ def my_handler(indices): self._indices_changed_handlers.add(handler) - def remove_event_handler(self, handler: callable): + def remove_event_handler(self, handler: Callable): """Remove a registered event handler""" self._indices_changed_handlers.remove(handler) @@ -178,6 +192,7 @@ def __str__(self): return str(self._indices) +# TODO: Not sure if we'll actually do this here, just a placeholder for now class SelectionVector: @property def selection(self): diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index e5800d538..638722716 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -28,7 +28,7 @@ block_reentrance, block_indices, ) -from .._index import GlobalIndex +from .._index import ReferenceIndex # types for the other features FeatureCallable = Callable[[np.ndarray, slice], np.ndarray] @@ -46,9 +46,10 @@ def default_cmap_transform_each(p: int, data_slice: np.ndarray, s: slice): start=s.start / p, stop=s.stop / p, num=n_displayed, - endpoint=False # since we use a slice object for the displayed data, the last point isn't included + endpoint=False, # since we use a slice object for the displayed data, the last point isn't included ) + class NDPositionsProcessor(NDProcessor): _other_features = ["colors", "markers", "cmap_transform_each", "sizes"] @@ -227,7 +228,6 @@ def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): return if new is None: - # default transform is just a transform based on the `p` dim size self._cmap_transform_each = None return @@ -485,7 +485,6 @@ def _get_other_features( val = getattr(self, attr) if val is None: - other[attr] = None continue if callable(val): @@ -558,7 +557,7 @@ def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: class NDPositions(NDGraphic): def __init__( self, - global_index: GlobalIndex, + ref_index: ReferenceIndex, data: Any, dims: Sequence[str], spatial_dims: tuple[str, str, str], @@ -586,15 +585,18 @@ def __init__( cmap: str = None, # across the line/scatter collection cmap_each: Sequence[str] = None, # for each individual line/scatter cmap_transform_each: np.ndarray = None, # for each individual line/scatter - markers: Sequence[str] = None, - sizes: Sequence[float] = None, + markers: np.ndarray = None, # across the scatter collection, shape [l,] + markers_each: Sequence[str] = None, # for each individual scatter, shape [l, p] + sizes: np.ndarray = None, # across the scatter collection, shape [l,] + sizes_each: Sequence[float] = None, # for each individual scatter, shape [l, p] + thickness: np.ndarray = None, # for each line, shape [l,] name: str = None, graphic_kwargs: dict = None, processor_kwargs: dict = None, ): super().__init__(name) - self._global_index = global_index + self._ref_index = ref_index if processor_kwargs is None: processor_kwargs = dict() @@ -614,13 +616,17 @@ def __init__( window_funcs=window_funcs, index_mappings=index_mappings, colors=colors, - markers=markers, + markers=markers_each, cmap_transform_each=cmap_transform_each, - sizes=sizes, + sizes=sizes_each, **processor_kwargs, ) - self.cmap = cmap + self._cmap = cmap + self._sizes = sizes + self._markers = markers + self._thickness = thickness + self.cmap_each = cmap_each self.cmap_transform_each = cmap_transform_each @@ -641,7 +647,6 @@ def __init__( self._pause = False - @property def processor(self) -> NDPositionsProcessor: return self._processor @@ -672,60 +677,6 @@ def graphic(self, graphic_type): self._create_graphic(graphic_type) plot_area.add_graphic(self._graphic) - @property - def cmap(self) -> str | None: - return self._cmap - - @cmap.setter - def cmap(self, new: str | None): - self._cmap = new - - @property - def cmap_each(self) -> np.ndarray[str] | None: - # per-line/scatter - return self._cmap_each - - @cmap_each.setter - def cmap_each(self, new: Sequence[str] | None): - if new is None: - self._cmap_each = None - return - - if isinstance(new, str): - new = [new] - - new = np.asarray(new) - - if new.ndim != 1: - raise ValueError - - l_dim_size = self.processor.shape[self.processor.spatial_dims[0]] - # same cmap for all if size == 1, or specific cmap for each in `l` dim - if new.size != 1 and new.size != l_dim_size: - raise ValueError - - self._cmap_each = np.broadcast_to(new, shape=(l_dim_size,)) - - @property - def cmap_transform_each(self) -> np.ndarray | None: - # PER line/scatter, only allowed after `cmaps` is set. - return self.processor.cmap_transform_each - - @cmap_transform_each.setter - def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): - if self.cmap_each is None: - self.processor.cmap_transform_each = None - warn("must set `cmap_each` before `cmap_transform_each`") - return - - if new is None and self.cmap_each is not None: - # default transform is just a transform based on the `p` dim size - new = partial( - default_cmap_transform_each, self.shape[self.spatial_dims[1]] - ) - - self.processor.cmap_transform_each = new - @property def spatial_dims(self) -> tuple[str, str, str]: return self.processor.spatial_dims @@ -738,7 +689,7 @@ def spatial_dims(self, dims: tuple[str, str, str]): @property def indices(self) -> dict[Hashable, Any]: - return {d: self._global_index[d] for d in self.processor.slider_dims} + return {d: self._ref_index[d] for d in self.processor.slider_dims} @indices.setter @block_reentrance @@ -810,7 +761,7 @@ def indices(self, indices): def _linear_selector_handler(self, ev): with block_indices(self): # linear selector always acts on the `p` dim - self._global_index[self.processor.spatial_dims[1]] = ev.info["value"] + self._ref_index[self.processor.spatial_dims[1]] = ev.info["value"] def _tooltip_handler(self, graphic, pick_info): if isinstance(self.graphic, (LineCollection, ScatterCollection)): @@ -837,6 +788,18 @@ def _create_graphic( new_features = self.processor.get(self.indices) data_slice = new_features["data"] + # store any cmap, sizes, thickness, etc. to assign to new graphic + graphic_attrs = dict() + for attr in ["cmap", "markers", "sizes", "thickness"]: + if attr in new_features.keys(): + if new_features[attr] is not None: + # markers and sizes defined for each line via processor takes priority + continue + + val = getattr(self, attr) + if val is not None: + graphic_attrs[attr] = val + if issubclass(graphic_cls, ImageGraphic): # `d` dim must only have xy data to be interpreted as a heatmap, xyz can't become a timeseries heatmap if self.processor.shape[self.processor.spatial_dims[-1]] != 2: @@ -854,6 +817,10 @@ def _create_graphic( kwargs = self._graphic_kwargs self._graphic = graphic_cls(data_slice, **kwargs) + for attr in graphic_attrs.keys(): + if hasattr(self._graphic, attr): + setattr(self._graphic, attr, graphic_attrs[attr]) + if isinstance(self._graphic, (LineCollection, ScatterCollection)): for l, g in enumerate(self.graphic.graphics): for feature in ["colors", "sizes", "markers"]: @@ -972,11 +939,136 @@ def _update_from_view_range(self): new_width = abs(xr[1] - xr[0]) new_index = (xr[0] + xr[1]) / 2 - if (new_index == self._global_index[self.processor.spatial_dims[1]]) and ( + if (new_index == self._ref_index[self.processor.spatial_dims[1]]) and ( last_width == new_width ): return self.processor.display_window = new_width # set the `p` dim on the global index vector - self._global_index[self.processor.spatial_dims[1]] = new_index + self._ref_index[self.processor.spatial_dims[1]] = new_index + + @property + def cmap(self) -> str | None: + return self._cmap + + @cmap.setter + def cmap(self, new: str | None): + if new is None: + # just set a default + if isinstance(self.graphic, (LineCollection, ScatterCollection)): + self.graphic.colors = "w" + else: + self.graphic.cmap = "plasma" + + self._cmap = None + return + + self._graphic.cmap = new + self._cmap = new + # force a re-render + self.indices = self.indices + + @property + def cmap_each(self) -> np.ndarray[str] | None: + # per-line/scatter + return self._cmap_each + + @cmap_each.setter + def cmap_each(self, new: Sequence[str] | None): + if new is None: + self._cmap_each = None + return + + if isinstance(new, str): + new = [new] + + new = np.asarray(new) + + if new.ndim != 1: + raise ValueError + + l_dim_size = self.processor.shape[self.processor.spatial_dims[0]] + # same cmap for all if size == 1, or specific cmap for each in `l` dim + if new.size != 1 and new.size != l_dim_size: + raise ValueError + + self._cmap_each = np.broadcast_to(new, shape=(l_dim_size,)) + + @property + def cmap_transform_each(self) -> np.ndarray | None: + # PER line/scatter, only allowed after `cmaps` is set. + return self.processor.cmap_transform_each + + @cmap_transform_each.setter + def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): + if new is None: + self.processor.cmap_transform_each = None + + if self.cmap_each is None: + self.processor.cmap_transform_each = None + warn("must set `cmap_each` before `cmap_transform_each`") + return + + if new is None and self.cmap_each is not None: + # default transform is just a transform based on the `p` dim size + new = partial(default_cmap_transform_each, self.shape[self.spatial_dims[1]]) + + self.processor.cmap_transform_each = new + + @property + def markers(self) -> str | Sequence[str] | None: + return self._markers + + @markers.setter + def markers(self, new: str | None): + if not isinstance(self.graphic, ScatterCollection): + self._markers = None + return + + if new is None: + # just set a default + new = "circle" + + self.graphic.markers = new + self._markers = new + # force a re-render + self.indices = self.indices + + @property + def sizes(self) -> float | Sequence[float] | None: + return self._sizes + + @sizes.setter + def sizes(self, new: float | Sequence[float] | None): + if not isinstance(self.graphic, ScatterCollection): + self._sizes = None + return + + if new is None: + # just set a default + new = 5.0 + + self.graphic.sizes = new + self._sizes = new + # force a re-render + self.indices = self.indices + + @property + def thickness(self) -> float | Sequence[float] | None: + return self._thickness + + @thickness.setter + def thickness(self, new: float | Sequence[float] | None): + if not isinstance(self.graphic, LineCollection): + self._thickness = None + return + + if new is None: + # just set a default + new = 2.0 + + self.graphic.thickness = new + self._thickness = new + # force a re-render + self.indices = self.indices \ No newline at end of file diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py index 8449a2c70..9ddfa8986 100644 --- a/fastplotlib/widgets/nd_widget/_ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -1,14 +1,22 @@ -from typing import Any +from __future__ import annotations -from ._index import RangeContinuous, RangeDiscrete, GlobalIndex +from typing import Any, Optional + +from ._index import RangeContinuous, RangeDiscrete, ReferenceIndex from ._ndw_subplot import NDWSubplot from ._ui import NDWidgetUI, RightClickMenu from ...layouts import ImguiFigure, Subplot class NDWidget: - def __init__(self, ref_ranges: dict[str, tuple], **kwargs): - self._indices = GlobalIndex(ref_ranges, self._get_ndgraphics) + def __init__(self, ref_ranges: dict[str, tuple], ref_index: Optional[ReferenceIndex] = None, **kwargs): + if ref_index is None: + self._indices = ReferenceIndex(ref_ranges) + else: + self._indices = ref_index + + self._indices._add_ndwidget_(self) + self._figure = ImguiFigure(std_right_click_menu=RightClickMenu, **kwargs) self._figure.std_right_click_menu.set_nd_widget(self) @@ -27,7 +35,7 @@ def figure(self) -> ImguiFigure: return self._figure @property - def indices(self) -> GlobalIndex: + def indices(self) -> ReferenceIndex: return self._indices @indices.setter @@ -35,21 +43,22 @@ def indices(self, new_indices: dict[str, int | float | Any]): self._indices.set(new_indices) @property - def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: + def ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: return self._indices.ref_ranges - def __getitem__(self, key: str | tuple[int, int] | Subplot): - if not isinstance(key, Subplot): - key = self.figure[key] - return self._subplots_nd[key] - - def _get_ndgraphics(self): + @property + def ndgraphics(self): gs = list() for subplot in self._subplots_nd.values(): gs.extend(subplot.nd_graphics) return tuple(gs) + def __getitem__(self, key: str | tuple[int, int] | Subplot): + if not isinstance(key, Subplot): + key = self.figure[key] + return self._subplots_nd[key] + def show(self, **kwargs): return self.figure.show(**kwargs) diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index a75d99e00..0e73f524f 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -6,6 +6,7 @@ from ...graphics import ( ScatterCollection, + ScatterStack, LineCollection, LineStack, ImageGraphic, @@ -19,7 +20,7 @@ from ._nd_positions import NDPositions from ._nd_image import NDImage -position_graphics = [ScatterCollection, LineCollection, LineStack, ImageGraphic] +position_graphics = [ScatterCollection, ScatterStack, LineCollection, LineStack, ImageGraphic] class NDWidgetUI(EdgeWindow): @@ -35,7 +36,7 @@ def __init__(self, figure, size, ndwidget): ) self._ndwidget = ndwidget - ref_ranges = self._ndwidget.ref_ranges + ref_ranges = self._ndwidget.ranges # whether or not a dimension is in play mode self._playing = {dim: False for dim in ref_ranges.keys()} @@ -61,11 +62,11 @@ def __init__(self, figure, size, ndwidget): self._max_display_windows: dict[NDGraphic, float | int] = dict() def _set_index(self, dim, index): - if index >= self._ndwidget.ref_ranges[dim].stop: + if index >= self._ndwidget.ranges[dim].stop: if self._loop[dim]: - index = self._ndwidget.ref_ranges[dim].start + index = self._ndwidget.ranges[dim].start else: - index = self._ndwidget.ref_ranges[dim].stop + index = self._ndwidget.ranges[dim].stop self._playing[dim] = False self._ndwidget.indices[dim] = index @@ -77,7 +78,7 @@ def update(self): # push id since we have the same buttons for each dim imgui.push_id(f"{self._id_counter}_{dim}") - rr = self._ndwidget.ref_ranges[dim] + rr = self._ndwidget.ranges[dim] if self._playing[dim]: # show pause button if playing @@ -252,7 +253,7 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): nd_graphic.display_window = None else: # pick a value 10% of the reference range - nd_graphic.display_window = self._ndwidget.ref_ranges[p_dim].range * 0.1 + nd_graphic.display_window = self._ndwidget.ranges[p_dim].range * 0.1 if nd_graphic.display_window is not None: if isinstance(nd_graphic.display_window, (int, np.integer)): @@ -268,7 +269,7 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): "display window", v=nd_graphic.display_window, v_min=type_(0), - v_max=type_(self._ndwidget.ref_ranges[p_dim].stop * 0.1), + v_max=type_(self._ndwidget.ranges[p_dim].stop * 0.1), ) if changed: From 0b603e91d3bfca7a14c01063765ef7fe2de5a385 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 17 Mar 2026 23:27:31 -0400 Subject: [PATCH 087/163] progress --- fastplotlib/widgets/nd_widget/_base.py | 64 +++++++++---- fastplotlib/widgets/nd_widget/_index.py | 1 - fastplotlib/widgets/nd_widget/_nd_image.py | 62 ++++++------- .../nd_widget/_nd_positions/_nd_positions.py | 90 +++++++++++-------- .../nd_widget/_nd_positions/_pandas.py | 2 +- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 29 ++---- fastplotlib/widgets/nd_widget/_ui.py | 8 +- 7 files changed, 139 insertions(+), 117 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 707480e58..397f6bd37 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -4,13 +4,14 @@ from numbers import Real from pprint import pformat import textwrap -from typing import Literal, Any +from typing import Literal, Any, Type from warnings import warn import xarray as xr import numpy as np from numpy.typing import ArrayLike +from ...layouts import Subplot from ...utils import subsample_array, ArrayProtocol from ...graphics import Graphic @@ -35,7 +36,8 @@ def __init__( window_order: tuple[Hashable, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): - self._data = self._validate_data(data, tuple(dims)) + self._dims = tuple(dims) + self._data = self._validate_data(data) self.spatial_dims = spatial_dims self.index_mappings = index_mappings @@ -50,16 +52,19 @@ def data(self) -> xr.DataArray: @data.setter def data(self, data: ArrayProtocol): - self._data = self._validate_data(data, self.dims) + self._data = self._validate_data(data) + + def _validate_data(self, data: ArrayProtocol): + if data is None: + return None - def _validate_data(self, data: ArrayProtocol, dims): if not isinstance(data, ArrayProtocol): raise TypeError("`data` must implement the ArrayProtocol") - if data.ndim != len(dims): + if data.ndim != len(self.dims): raise IndexError("must specify a dim for every dimension in the data array") - return xr.DataArray(data, dims=dims) + return xr.DataArray(data, dims=self.dims) @property def shape(self) -> dict[Hashable, int]: @@ -74,7 +79,7 @@ def ndim(self) -> int: @property def dims(self) -> tuple[Hashable, ...]: """dim names""" - return self.data.dims + return self._dims @property def spatial_dims(self) -> tuple[Hashable, ...]: @@ -316,26 +321,48 @@ def get(self, indices: dict[Hashable, Any]): raise NotImplementedError def __repr__(self): + if self.data is None: + return ( + f"{self.__class__.__name__}\n" + f"data is None, dims: {self.dims}" + ) tab = "\t" - return ( + + wf = {k: v for k, v in self.window_funcs.items() if v != (None, None)} + + r = ( f"{self.__class__.__name__}\n" f"shape:\n\t{self.shape}\n" f"dims:\n\t{self.dims}\n" f"spatial_dims:\n\t{self.spatial_dims}\n" f"slider_dims:\n\t{self.slider_dims}\n" f"index_mappings:\n{textwrap.indent(pformat(self.index_mappings, width=120), prefix=tab)}\n" - f"window_funcs:\n{textwrap.indent(pformat(self.window_funcs, width=120), prefix=tab)}\n" - f"window_order:\n\t{self.window_order}\n" - f"spatial_func:\n\t{self.spatial_func}\n" ) + if len(wf) > 0: + r += ( + f"window_funcs:\n{textwrap.indent(pformat(wf, width=120), prefix=tab)}\n" + f"window_order:\n\t{self.window_order}\n" + ) + + if self.spatial_func is not None: + r += f"spatial_func:\n\t{self.spatial_func}\n" + + return r + class NDGraphic: - def __init__(self, name: str | None): + def __init__( + self, + subplot: Subplot, + name: str | None, + ): + self._subplot = subplot self._name = name self._block_indices = False + self._graphic: Graphic | None = None - def _create_graphic(self, graphic_cls: type): + def _create_graphic(self): raise NotImplementedError @property @@ -367,11 +394,12 @@ def data(self) -> Any: def data(self, data: Any): self.processor.data = data # create a new graphic when data has changed - plot_area = self._graphic._plot_area - plot_area.delete_graphic(self._graphic) + if self.graphic is not None: + # it is already None is it was initialized with no data + self._subplot.delete_graphic(self.graphic) + self._graphic = None - self._create_graphic(self.graphic.__class__) - plot_area.add_graphic(self._graphic) + self._create_graphic() # force a re-render self.indices = self.indices @@ -456,7 +484,7 @@ def spatial_func( self.indices = self.indices def __repr__(self): - return f"graphic: {self.graphic}\n" f"processor:\n{self.processor}" + return f"graphic: {self.graphic.__class__.__name__}\n" f"processor:\n{self.processor}" @contextmanager diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 3cb8a71f9..e91319893 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -110,7 +110,6 @@ def _render_indices(self): continue # only provide slider indices to the graphic g.indices = {d: self._indices[d] for d in g.processor.slider_dims} - print(g) @property def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index f78bf7ce9..0261a64e5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -6,6 +6,7 @@ from numpy.typing import ArrayLike import xarray as xr +from ...layouts import Subplot from ...utils import subsample_array, ArrayProtocol, ARRAY_LIKE_ATTRS from ...graphics import ImageGraphic, ImageVolumeGraphic from ...tools import HistogramLUTTool @@ -96,10 +97,10 @@ def data(self) -> xr.DataArray | None: @data.setter def data(self, data: ArrayLike): # check that all array-like attributes are present - self._data = self._validate_data(data, self.dims) + self._data = self._validate_data(data) self._recompute_histogram() - def _validate_data(self, data: ArrayProtocol, dims): + def _validate_data(self, data: ArrayProtocol): if not isinstance(data, ArrayProtocol): raise TypeError( f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" @@ -111,7 +112,7 @@ def _validate_data(self, data: ArrayProtocol, dims): f"Image data must have a minimum of 2 dimensions, you have passed an array of shape: {data.shape}" ) - return xr.DataArray(data, dims=dims) + return xr.DataArray(data, dims=self.dims) @property def rgb_dim(self) -> str | None: @@ -221,7 +222,8 @@ def _recompute_histogram(self): class NDImage(NDGraphic): def __init__( self, - global_index, + ref_index, + subplot: Subplot, data: ArrayLike | None, dims: Sequence[Hashable], spatial_dims: ( @@ -236,9 +238,9 @@ def __init__( name: str = None, ): - super().__init__(name) + super().__init__(subplot, name) - self._global_index = global_index + self._ref_index = ref_index self._processor = NDImageProcessor( data, @@ -268,11 +270,6 @@ def graphic( """LineStack or ImageGraphic for heatmaps""" return self._graphic - @graphic.setter - def graphic(self, graphic_type): - # TODO implement if graphic type changes to custom user subclass - raise NotImplementedError - def _create_graphic(self): match len(self.processor.spatial_dims) - int(bool(self.processor.rgb_dim)): case 2: @@ -291,9 +288,8 @@ def _create_graphic(self): for k in attrs: attrs[k] = getattr(old_graphic, k) - plot_area = old_graphic._plot_area - plot_area.delete_graphic(old_graphic) - plot_area.add_graphic(new_graphic) + self._subplot.delete_graphic(old_graphic) + self._subplot.add_graphic(new_graphic) # set cmap and interpolation for attr, val in attrs.items(): @@ -301,19 +297,19 @@ def _create_graphic(self): self._graphic = new_graphic - if self._graphic._plot_area is not None: - self._reset_camera() + self._subplot.add_graphic(self._graphic) + self._reset_camera() self._reset_histogram() def _reset_histogram(self): # reset histogram - if self._graphic._plot_area is None: + if self.graphic is None: return if not self.processor.compute_histogram: # hide right dock if histogram not desired - self._graphic._plot_area.docks["right"].size = 0 + self._subplot.docks["right"].size = 0 return if self.processor.histogram: @@ -321,8 +317,8 @@ def _reset_histogram(self): # histogram widget exists, update it self._histogram_widget.histogram = self.processor.histogram self._histogram_widget.images = self.graphic - if self.graphic._plot_area.docks["right"].size < 1: - self.graphic._plot_area.docks["right"].size = 80 + if self._subplot.docks["right"].size < 1: + self._subplot.docks["right"].size = 80 else: # make hist tool self._histogram_widget = HistogramLUTTool( @@ -330,18 +326,16 @@ def _reset_histogram(self): images=self.graphic, name=f"hist-{hex(id(self.graphic))}", ) - self.graphic._plot_area.docks["right"].add_graphic(self._histogram_widget) - self.graphic._plot_area.docks["right"].size = 80 + self._subplot.docks["right"].add_graphic(self._histogram_widget) + self._subplot.docks["right"].size = 80 self.graphic.reset_vmin_vmax() def _reset_camera(self): - plot_area = self._graphic._plot_area - # set camera to a nice position for 2D or 3D if isinstance(self._graphic, ImageGraphic): # set camera orthogonal to the xy plane, flip y axis - plot_area.camera.set_state( + self._subplot.camera.set_state( { "position": [0, 0, -1], "rotation": [0, 0, 0, 1], @@ -352,21 +346,21 @@ def _reset_camera(self): } ) - plot_area.controller = "panzoom" - plot_area.axes.intersection = None - plot_area.auto_scale() + self._subplot.controller = "panzoom" + self._subplot.axes.intersection = None + self._subplot.auto_scale() else: - plot_area.camera.fov = 50 - plot_area.controller = "orbit" + self._subplot.camera.fov = 50 + self._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(plot_area.camera.local, f"scale_{dim}") < 0: - setattr(plot_area.camera.local, f"scale_{dim}", 1) + if getattr(self._subplot.camera.local, f"scale_{dim}") < 0: + setattr(self._subplot.camera.local, f"scale_{dim}", 1) - plot_area.auto_scale() + self._subplot.auto_scale() @property def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: @@ -381,7 +375,7 @@ def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): @property def indices(self) -> dict[Hashable, Any]: - return {d: self._global_index[d] for d in self.processor.slider_dims} + return {d: self._ref_index[d] for d in self.processor.slider_dims} @indices.setter def indices(self, indices): diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 638722716..ebfe3d476 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -8,6 +8,7 @@ from numpy.typing import ArrayLike import xarray as xr +from ....layouts import Subplot from ....graphics import ( Graphic, ImageGraphic, @@ -558,11 +559,12 @@ class NDPositions(NDGraphic): def __init__( self, ref_index: ReferenceIndex, + subplot: Subplot, data: Any, dims: Sequence[str], spatial_dims: tuple[str, str, str], *args, - graphic: Type[ + graphic_type: Type[ LineGraphic | LineCollection | LineStack @@ -577,6 +579,7 @@ def __init__( index_mappings: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, linear_selector: bool = False, + x_range_mode: Literal["fixed", "auto"] | None = None, colors: ( Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] ) = None, @@ -594,7 +597,7 @@ def __init__( graphic_kwargs: dict = None, processor_kwargs: dict = None, ): - super().__init__(name) + super().__init__(subplot, name) self._ref_index = ref_index @@ -630,9 +633,11 @@ def __init__( self.cmap_each = cmap_each self.cmap_transform_each = cmap_transform_each - self._create_graphic(graphic) + self._graphic_type = graphic_type + self._create_graphic() self._x_range_mode = None + self.x_range_mode = x_range_mode self._last_x_range = np.array([0.0, 0.0], dtype=np.float32) if linear_selector: @@ -662,20 +667,33 @@ def graphic( | ScatterCollection | ScatterStack | ImageGraphic + | None ): """LineStack or ImageGraphic for heatmaps""" return self._graphic - @graphic.setter - def graphic(self, graphic_type): + @property + def graphic_type( + self, + ) -> Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ScatterStack + | ImageGraphic + ]: + return self._graphic_type + + @graphic_type.setter + def graphic_type(self, graphic_type): if type(self.graphic) is graphic_type: return - plot_area = self._graphic._plot_area - plot_area.delete_graphic(self._graphic) - - self._create_graphic(graphic_type) - plot_area.add_graphic(self._graphic) + self._subplot.delete_graphic(self._graphic) + self._graphic_type = graphic_type + self._create_graphic() @property def spatial_dims(self) -> tuple[str, str, str]: @@ -694,6 +712,9 @@ def indices(self) -> dict[Hashable, Any]: @indices.setter @block_reentrance def indices(self, indices): + if self.data is None: + return + new_features = self.processor.get(indices) data_slice = new_features["data"] @@ -743,7 +764,7 @@ def indices(self, indices): # x range of the data xr = data_slice[0, 0, 0], data_slice[0, -1, 0] - if self._x_range_mode is not None: + if self.x_range_mode is not None: self.graphic._plot_area.x_range = xr # if the update_from_view is polling, this prevents it from being called by setting the new last xrange @@ -770,20 +791,9 @@ def _tooltip_handler(self, graphic, pick_info): p_index = pick_info["vertex_index"] return self.processor.tooltip_format(n_index, p_index) - def _create_graphic( - self, - graphic_cls: Type[ - LineGraphic - | LineCollection - | LineStack - | ScatterGraphic - | ScatterCollection - | ScatterStack - | ImageGraphic - ], - ): - if not issubclass(graphic_cls, Graphic): - raise TypeError + def _create_graphic(self): + if self.data is None: + return new_features = self.processor.get(self.indices) data_slice = new_features["data"] @@ -800,22 +810,22 @@ def _create_graphic( if val is not None: graphic_attrs[attr] = val - if issubclass(graphic_cls, ImageGraphic): + if issubclass(self._graphic_type, ImageGraphic): # `d` dim must only have xy data to be interpreted as a heatmap, xyz can't become a timeseries heatmap if self.processor.shape[self.processor.spatial_dims[-1]] != 2: raise ValueError image_data, x0, x_scale = self._create_heatmap_data(data_slice) - self._graphic = graphic_cls( + self._graphic = self._graphic_type( image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1) ) else: - if issubclass(graphic_cls, (LineStack, ScatterStack)): + if issubclass(self._graphic_type, (LineStack, ScatterStack)): kwargs = {"separation": 0.0, **self._graphic_kwargs} else: kwargs = self._graphic_kwargs - self._graphic = graphic_cls(data_slice, **kwargs) + self._graphic = self._graphic_type(data_slice, **kwargs) for attr in graphic_attrs.keys(): if hasattr(self._graphic, attr): @@ -853,6 +863,8 @@ def _create_graphic( for g in self._graphic.graphics: g.tooltip_format = partial(self._tooltip_handler, g) + self._subplot.add_graphic(self._graphic) + def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: """return [n_rows, n_cols] shape data""" # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense @@ -908,18 +920,18 @@ def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]): self.processor.datapoints_window_func = funcs @property - def x_range_mode(self) -> Literal[None, "fixed-window", "view-range"]: - """x-range using a fixed window from the display window, or by polling the camera (view-range)""" + def x_range_mode(self) -> Literal["fixed", "auto"] | None: + """x-range using a fixed window from the display window, or by polling the camera (auto)""" return self._x_range_mode @x_range_mode.setter - def x_range_mode(self, mode: Literal[None, "fixed-window", "view-range"]): - if self._x_range_mode == "view-range": - # old mode was view-range - self.graphic._plot_area.remove_animation(self._update_from_view_range) + def x_range_mode(self, mode: Literal[None, "fixed", "auto"]): + if self._x_range_mode == "auto": + # old mode was auto + self._subplot.remove_animation(self._update_from_view_range) - if mode == "view-range": - self.graphic._plot_area.add_animations(self._update_from_view_range) + if mode == "auto": + self._subplot.add_animations(self._update_from_view_range) self._x_range_mode = mode @@ -927,7 +939,7 @@ def _update_from_view_range(self): if self._graphic is None: return - xr = self.graphic._plot_area.x_range + xr = self._subplot.x_range # the floating point error near zero gets nasty here if np.allclose(xr, self._last_x_range, atol=1e-14): @@ -1071,4 +1083,4 @@ def thickness(self, new: float | Sequence[float] | None): self.graphic.thickness = new self._thickness = new # force a re-render - self.indices = self.indices \ No newline at end of file + self.indices = self.indices diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 740dfe21e..1b94e1cbc 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -41,7 +41,7 @@ def __init__( def data(self) -> pd.DataFrame: return self._data - def _validate_data(self, data: pd.DataFrame, dims): + def _validate_data(self, data: pd.DataFrame): if not isinstance(data, pd.DataFrame): raise TypeError diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 0783379ec..0f53951bd 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -1,3 +1,4 @@ +from typing import Literal import numpy as np from ... import ScatterCollection, ScatterStack, LineCollection, LineStack, ImageGraphic @@ -29,40 +30,35 @@ def __getitem__(self, key): raise KeyError(f"NDGraphc with given key not found: {key}") def add_nd_image(self, *args, **kwargs): - nd = NDImage(self.ndw.indices, *args, **kwargs) + nd = NDImage(self.ndw.indices, self._subplot, *args, **kwargs) self._nd_graphics.append(nd) - self._subplot.add_graphic(nd.graphic) - nd._reset_camera() - - # graphic._plot_area must exist before this is called - nd._reset_histogram() return nd def add_nd_scatter(self, *args, **kwargs): # TODO: better func signature here, send all kwargs to processor_kwargs - nd = NDPositions(self.ndw.indices, *args, graphic=ScatterCollection, **kwargs) + nd = NDPositions(self.ndw.indices, self._subplot, *args, graphic_type=ScatterCollection, **kwargs) self._nd_graphics.append(nd) - self._subplot.add_graphic(nd.graphic) return nd def add_nd_timeseries( self, *args, - graphic: type[LineCollection | LineStack | ImageGraphic] = LineStack, - x_range_mode="fixed-window", + graphic_type: type[LineCollection | LineStack | ScatterStack | ImageGraphic] = LineStack, + x_range_mode: Literal["fixed", "auto"] | None = "auto", **kwargs, ): nd = NDPositions( self.ndw.indices, + self._subplot, *args, - graphic=graphic, + graphic_type=graphic_type, linear_selector=True, + x_range_mode=x_range_mode, **kwargs, ) self._nd_graphics.append(nd) - self._subplot.add_graphic(nd.graphic) self._subplot.add_graphic(nd._linear_selector) # need plot_area to exist before these this can be called @@ -74,13 +70,6 @@ def add_nd_timeseries( return nd def add_nd_lines(self, *args, **kwargs): - nd = NDPositions(self.ndw.indices, *args, graphic=LineCollection, **kwargs) + nd = NDPositions(self.ndw.indices, self._subplot, *args, graphic_type=LineCollection, **kwargs) self._nd_graphics.append(nd) - self._subplot.add_graphic(nd.graphic) return nd - - # def __repr__(self): - # return "NDWidget Subplot" - # - # def __str__(self): - # return "NDWidget Subplot" diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 0e73f524f..e5ba7daf8 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -20,7 +20,7 @@ from ._nd_positions import NDPositions from ._nd_image import NDImage -position_graphics = [ScatterCollection, ScatterStack, LineCollection, LineStack, ImageGraphic] +position_graphic_types = [ScatterCollection, ScatterStack, LineCollection, LineStack, ImageGraphic] class NDWidgetUI(EdgeWindow): @@ -237,9 +237,9 @@ def _draw_nd_image_ui(self, subplot, nd_image: NDImage): nd_image.graphic._material.gamma = new_gamma def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): - for i, cls in enumerate(position_graphics): + for i, cls in enumerate(position_graphic_types): if imgui.radio_button(cls.__name__, type(nd_graphic.graphic) is cls): - nd_graphic.graphic = cls + nd_graphic.graphic_type = cls subplot.auto_scale() changed, val = imgui.checkbox( @@ -275,7 +275,7 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): if changed: nd_graphic.display_window = new - options = [None, "fixed-window", "view-range"] + options = [None, "fixed", "auto"] changed, option = imgui.combo( "x-range mode", options.index(nd_graphic.x_range_mode), From 7e862ee6fac597a9ec681fa6b121cc1b49cd81d0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Mar 2026 00:03:03 -0400 Subject: [PATCH 088/163] lighting objects only when a mesh is added --- fastplotlib/layouts/_plot_area.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 974b6f653..ac1d8dc3d 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -10,7 +10,7 @@ from ._utils import create_controller from ..graphics._base import Graphic, WORLD_OBJECT_TO_GRAPHIC -from ..graphics import ImageGraphic +from ..graphics import ImageGraphic, MeshGraphic from ..graphics.selectors._base_selector import BaseSelector from ._graphic_methods_mixin import GraphicMethodsMixin from ..legends import Legend @@ -120,11 +120,8 @@ def __init__( self._background = pygfx.Background(None, self._background_material) self.scene.add(self._background) - self._ambient_light = pygfx.AmbientLight() - self._directional_light = pygfx.DirectionalLight() - - self.scene.add(self._ambient_light) - self.scene.add(self._camera.add(self._directional_light)) + self._ambient_light = None + self._directional_light = None self._tooltip = Tooltip() self.get_figure()._fpl_overlay_scene.add(self._tooltip._fpl_world_object) @@ -293,12 +290,12 @@ def background_color(self, colors: str | tuple[float]): self._background_material.set_colors(*colors) @property - def ambient_light(self) -> pygfx.AmbientLight: + def ambient_light(self) -> pygfx.AmbientLight | None: """the ambient lighting in the scene""" return self._ambient_light @property - def directional_light(self) -> pygfx.DirectionalLight: + def directional_light(self) -> pygfx.DirectionalLight | None: """the directional lighting on the camera in the scene""" return self._directional_light @@ -631,6 +628,13 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if isinstance(graphic, ImageGraphic): self._sort_images_by_depth() + if isinstance(graphic, MeshGraphic): + self._ambient_light = pygfx.AmbientLight() + self._directional_light = pygfx.DirectionalLight() + + self.scene.add(self._ambient_light) + self.scene.add(self._camera.add(self._directional_light)) + def insert_graphic( self, graphic: Graphic, From 4a6643867e9eed4f80a664c7d510b1bbe0830dbe Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Mar 2026 01:19:29 -0400 Subject: [PATCH 089/163] fix --- fastplotlib/layouts/_plot_area.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index ac1d8dc3d..f90cdcf87 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -176,8 +176,9 @@ def camera(self, new_camera: str | pygfx.PerspectiveCamera): # user wants to set completely new camera, remove current camera from controller if isinstance(new_camera, pygfx.PerspectiveCamera): self.controller.remove_camera(self._camera) - # add directional light to new camera - new_camera.add(self._directional_light) + if self._directional_light is not None: + # add directional light to new camera + new_camera.add(self._directional_light) # add new camera to controller self.controller.add_camera(new_camera) From 06d526620ab0cde515195369b969fe023e4d805f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Mar 2026 01:20:15 -0400 Subject: [PATCH 090/163] update axes only when camera or view changes --- fastplotlib/graphics/_axes.py | 37 +++++++++++++++-------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/fastplotlib/graphics/_axes.py b/fastplotlib/graphics/_axes.py index 5b4c21682..56ca792a4 100644 --- a/fastplotlib/graphics/_axes.py +++ b/fastplotlib/graphics/_axes.py @@ -301,6 +301,8 @@ def __init__( self._basis = None self.basis = basis + self._last_state = self._get_view_state() + @property def world_object(self) -> pygfx.WorldObject: return self._world_object @@ -402,6 +404,14 @@ def intersection(self, intersection: tuple[float, float, float] | None): self._intersection = tuple(float(v) for v in intersection) + def _get_view_state(self) -> tuple[bytes, tuple[int, int], tuple[int, int], bytes]: + viewport = self._plot_area.viewport + cam_matrix = self._plot_area.camera.camera_matrix.tobytes() + scale = self._plot_area.camera.local.scale.tobytes() + + return (cam_matrix, viewport.rect, viewport.logical_size, scale) + + def update_using_bbox(self, bbox): """ Update the w.r.t. the given bbox @@ -444,6 +454,10 @@ def update_using_camera(self): if not self.visible: return + state = self._get_view_state() + if state == self._last_state: + # no changes in the camera or viewport rect + return if self._plot_area.camera.fov == 0: xpos, ypos, width, height = self._plot_area.viewport.rect @@ -453,27 +467,6 @@ def update_using_camera(self): xmin, xmax = xpos, xpos + width ymin, ymax = ypos + height, ypos - # apply quaternion to account for rotation of axes - # xmin, _, _ = vec_transform_quat( - # [xmin, ypos + height / 2, 0], - # self.x.local.rotation - # ) - # - # xmax, _, _ = vec_transform_quat( - # [xmax, ypos + height / 2, 0], - # self.x.local.rotation, - # ) - # - # _, ymin, _ = vec_transform_quat( - # [xpos + width / 2, ymin, 0], - # self.y.local.rotation - # ) - # - # _, ymax, _ = vec_transform_quat( - # [xpos + width / 2, ymax, 0], - # self.y.local.rotation - # ) - min_vals = self._plot_area.map_screen_to_world((xmin, ymin)) max_vals = self._plot_area.map_screen_to_world((xmax, ymax)) @@ -515,6 +508,8 @@ def update_using_camera(self): self.update(bbox, intersection) + self._last_state = state + def update(self, bbox, intersection): """ Update the axes using the given bbox and ruler intersection point From 066094ad22bc04bbe28d4ec08b784413836e6376 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Mar 2026 01:57:14 -0400 Subject: [PATCH 091/163] clean heatmap func --- .../widgets/nd_widget/_nd_positions/_nd_positions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index ebfe3d476..81aa535c4 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -866,17 +866,19 @@ def _create_graphic(self): self._subplot.add_graphic(self._graphic) def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: - """return [n_rows, n_cols] shape data""" + """return [n_rows, n_cols] shape data from [n_timeseries, n_timepoints, xy] data""" # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense + # data slice is of shape [n_timeseries, n_timepoints, xy], where xy is x-y coordinates of each timeseries x = data_slice[0, :, 0] # get x from just the first row # check if we need to interpolate norm = np.linalg.norm(np.diff(np.diff(x))) / x.size if norm > 1e-6: + print(norm) # x is not uniform upto float32 precision, must interpolate x_uniform = np.linspace(x[0], x[-1], num=x.size) - y_interp = np.zeros(shape=data_slice[..., 1].shape, dtype=np.float32) + y_interp = np.empty(shape=data_slice[..., 1].shape, dtype=np.float32) # this for loop is actually slightly faster than numpy.apply_along_axis() for i in range(data_slice.shape[0]): @@ -890,7 +892,7 @@ def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: # assume all x values are the same across all lines # otherwise a heatmap representation makes no sense anyways - x_stop = data_slice[:, -1, 0][0] + x_stop = x[-1] x_scale = (x_stop - x0) / data_slice.shape[1] return y_interp, x0, x_scale From d1d1f6c6e52108d012bd62215524627387983738 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 18 Mar 2026 02:05:57 -0400 Subject: [PATCH 092/163] stupid print --- fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 81aa535c4..b0eca548d 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -875,7 +875,6 @@ def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: norm = np.linalg.norm(np.diff(np.diff(x))) / x.size if norm > 1e-6: - print(norm) # x is not uniform upto float32 precision, must interpolate x_uniform = np.linspace(x[0], x[-1], num=x.size) y_interp = np.empty(shape=data_slice[..., 1].shape, dtype=np.float32) From 9372ec6fdd1bb00abcaa6946f6535c97c50bddcc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 19 Mar 2026 04:41:25 -0400 Subject: [PATCH 093/163] docstrings, comments --- examples/ndwidget/ndimage.py | 21 +- examples/ndwidget/timeseries.py | 7 +- fastplotlib/utils/_protocols.py | 22 +- fastplotlib/widgets/nd_widget/_base.py | 271 ++++++-- fastplotlib/widgets/nd_widget/_index.py | 163 ++++- fastplotlib/widgets/nd_widget/_nd_image.py | 237 +++++-- .../nd_widget/_nd_positions/_nd_positions.py | 86 ++- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 81 ++- .../widgets/nd_widget/_repr_formatter.py | 599 ++++++++++++++++++ 9 files changed, 1326 insertions(+), 161 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/_repr_formatter.py diff --git a/examples/ndwidget/ndimage.py b/examples/ndwidget/ndimage.py index 80c010ea1..eafd3c3c3 100644 --- a/examples/ndwidget/ndimage.py +++ b/examples/ndwidget/ndimage.py @@ -13,6 +13,7 @@ data = np.random.rand(1000, 30, 64, 64) +data2 = np.random.rand(1000, 30, 128, 128) # must define a reference range for each dim ref = { @@ -21,8 +22,15 @@ } -ndw = fpl.NDWidget(ref_ranges=ref, size=(700, 560)) -ndw.show() +ndw = fpl.NDWidget( + ref_ranges=ref, + size=(700, 560) +) +ndw2 = fpl.NDWidget( + ref_ranges=ref, + ref_index=ndw.indices, # can create another NDWidget that shared the reference index! So multiple windows are possible + size=(700, 560) +) ndi = ndw[0, 0].add_nd_image( data, @@ -31,7 +39,16 @@ name="4d-image", ) +ndi2 = ndw2[0, 0].add_nd_image( + data2, + ("time", "depth", "m", "n"), # specify all dim names + ("m", "n"), # specify spatial dims IN ORDER, rest are auto slider dims + name="4d-image", +) + # change spatial dims on the fly # ndi.spatial_dims = ("depth", "m", "n") +ndw.show() +ndw2.show() fpl.loop.run() diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py index e506182e3..9d7ba851f 100644 --- a/examples/ndwidget/timeseries.py +++ b/examples/ndwidget/timeseries.py @@ -43,16 +43,17 @@ data, ("freq", "ampl", "n_lines", "angle", "d"), ("n_lines", "angle", "d"), - index_mappings={ + slider_dim_transforms={ "angle": xs, "ampl": lambda x: int(x + 1), "freq": lambda x: int(x + 1), }, - x_range_mode="view-range", + cmap="jet", + x_range_mode="auto", name="nd-sine" ) -nd_lines.graphic.cmap = "tab10" +nd_lines.cmap = "tab10" subplot = ndw.figure[0, 0] subplot.controller.add_camera(subplot.camera, include_state={"x", "width"}) diff --git a/fastplotlib/utils/_protocols.py b/fastplotlib/utils/_protocols.py index 7ae63ed67..95d7d2763 100644 --- a/fastplotlib/utils/_protocols.py +++ b/fastplotlib/utils/_protocols.py @@ -1,11 +1,29 @@ -from typing import Protocol, runtime_checkable +from __future__ import annotations +from typing import Any, Protocol, runtime_checkable -ARRAY_LIKE_ATTRS = ["shape", "ndim", "__getitem__"] + +ARRAY_LIKE_ATTRS = [ + "__array__", + "__array_ufunc__", + "dtype", + "shape", + "ndim", + "__getitem__", +] @runtime_checkable class ArrayProtocol(Protocol): + def __array__(self) -> ArrayProtocol: ... + + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): ... + + def __array_function__(self, func, types, *args, **kwargs): ... + + @property + def dtype(self) -> Any: ... + @property def ndim(self) -> int: ... diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 397f6bd37..2fa60a5ed 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -14,6 +14,8 @@ from ...layouts import Subplot from ...utils import subsample_array, ArrayProtocol from ...graphics import Graphic +from ._repr_formatter import ndp_fmt_text, ndg_fmt_text, ndp_fmt_html, ndg_fmt_html +from ._index import ReferenceIndex # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] @@ -26,21 +28,94 @@ def identity(index: int) -> int: class NDProcessor: def __init__( self, - data, + data: Any, dims: Sequence[Hashable], spatial_dims: Sequence[Hashable] | None, - index_mappings: dict[Hashable, Callable[[Any], int] | ArrayLike] = None, + slider_dim_transforms: dict[Hashable, Callable[[Any], int] | ArrayLike] = None, window_funcs: dict[ Hashable, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[Hashable, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): + """ + Base class for managing n-dimensional data and producing array slices. + + By default, wraps input data into an ``xarray.DataArray`` and provides an interface + for indexing slider dimensions, applying window functions, spatial functions, and mapping + reference-space values to local array indices. Subclasses must implement + :meth:`get`, which is called whenever the :class:`ReferenceIndex` updates. + + Subclasses can implement any type of data representation, they do not necessarily need to be compatible with + (they dot not have to be xarray compatible). However their ``get()`` method must still return a data slice that + corresponds to the graphical representation they map to. + + Every dimension that is *not* listed in ``spatial_dims`` becomes a slider + dimension. Each slider dim must have a ``ReferenceRange`` defined in the + ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct + a change in the ``ReferenceIndex`` and update the graphics. + + Parameters + ---------- + data: Any + data object that is managed, usually uses the ArrayProtocol. Custom subclasses can manage any kind of data + object but the corresponding :meth:`get` must return an array-like that maps to a graphical representation. + + dims: Sequence[str] + names for each dimension in ``data``. Dimensions not listed in + ``spatial_dims`` are treated as slider dimensions and **must** appear as + keys in the parent ``NDWidget``'s ``ref_ranges`` + Examples:: + ``("time", "depth", "row", "col")`` + ``("channels", "time", "xy")`` + ``("keypoints", "time", "xyz")`` + + A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method + must operate as if these dimensions exist and return an array that matches the spatial dimensions. + + spatial_dims: Sequence[str] + Subset of ``dims`` that are spatial (rendered) dimensions **in order**. All remaining dims are treated as + slider dims. See subclass for specific info. + + slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None + Per-slider-dim mapping from reference-space values to local array indices. + + You may also provide an array of reference values for the slider dims, ``searchsorted`` is then used + as the transform (ex: a timestamps array). + + If ``None`` and identity mapping is used, i.e. rounds the current reference index value to the nearest + integer for array indexing. + + If a transform is not provided for a dim then the identity mapping is used. + + window_funcs: dict[ + Hashable, tuple[WindowFuncCallable | None, int | float | None] + ] + Per-slider-dim window functions applied around the current slider position. Ex: {"time": (np.mean, 2.5)}. + Each value is a ``(func, window_size)`` pair where: + + * *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs + (ex: ``np.mean``, ``np.max``). The window function **must** return an array that has the same dimensions + as specified in the NDProcessor, therefore the size of any dim along which a window_func was applied + should reduce to ``1``. These dims must not be removed by the window_func. + + * *window_size* is in reference-space units (ex: 2.5 seconds). + + + window_order: tuple[Hashable, ...] + Order in which window functions are applied across dims. Only dims listed + here have their window function applied. window_funcs are ignored for any + dims not specified in ``window_order`` + + spatial_func: + A function applied to the spatial slice *after* window_funcs right before rendering. + + """ self._dims = tuple(dims) self._data = self._validate_data(data) self.spatial_dims = spatial_dims - self.index_mappings = index_mappings + self.slider_dim_transforms = slider_dim_transforms self.window_funcs = window_funcs self.window_order = window_order @@ -48,6 +123,10 @@ def __init__( @property def data(self) -> xr.DataArray: + """ + get or set managed data. If setting with new data, the new data is interpreted + to have the same dims (i.e. same dim names and ordering of dims). + """ return self._data @data.setter @@ -55,15 +134,21 @@ def data(self, data: ArrayProtocol): self._data = self._validate_data(data) def _validate_data(self, data: ArrayProtocol): + # does some basic validation if data is None: + # we allow data to be None, in this case no ndgraphic is rendered + # useful when we want to initialize an NDWidget with no traces for example + # and populate it as components/channels are selected return None if not isinstance(data, ArrayProtocol): + # This is required for xarray compatibility and general array-like requirements raise TypeError("`data` must implement the ArrayProtocol") if data.ndim != len(self.dims): raise IndexError("must specify a dim for every dimension in the data array") + # data can be set, but the dims must still match/have the same meaning return xr.DataArray(data, dims=self.dims) @property @@ -79,11 +164,15 @@ def ndim(self) -> int: @property def dims(self) -> tuple[Hashable, ...]: """dim names""" + # these are read-only and cannot be set after it's created + # the user should create a new NDGraphic if they need different dims + # I can't think of a usecase where we'd want to change the dims, and + # I think that would be complicated and probably and anti-pattern return self._dims @property def spatial_dims(self) -> tuple[Hashable, ...]: - """Spatial dims, **in order**)""" + """Spatial dims, **in order**""" return self._spatial_dims @spatial_dims.setter @@ -109,10 +198,12 @@ def tooltip_format(self, *args) -> str | None: @property def slider_dims(self) -> set[Hashable]: + """Slider dim names, ``set(dims) - set(spatial_dims)""" return set(self.dims) - set(self.spatial_dims) @property def n_slider_dims(self): + """number of slider dims, i.e. len(slider_dims)""" return len(self.slider_dims) @property @@ -195,6 +286,7 @@ def window_order(self, order: tuple[Hashable] | None): @property def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + """get or set the spatial function which is applied on the data slice after the window functions""" return self._spatial_func @spatial_func.setter @@ -207,11 +299,12 @@ def spatial_func( self._spatial_func = func @property - def index_mappings(self) -> dict[Hashable, Callable[[Any], int]]: + def slider_dim_transforms(self) -> dict[Hashable, Callable[[Any], int]]: + """get or set the slider_dim_transforms, see docstring for details""" return self._index_mappings - @index_mappings.setter - def index_mappings( + @slider_dim_transforms.setter + def slider_dim_transforms( self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None ): if maps is None: @@ -240,20 +333,64 @@ def index_mappings( self._index_mappings = maps def _ref_index_to_array_index(self, dim: str, ref_index: Any) -> int: - # wraps index mappings, clamps between 0 and max array index for this dimension - index = self.index_mappings[dim](ref_index) + # wraps slider_dim_transforms, clamps between 0 and the array size in this dim + # ref-space -> local-array-index transform + index = self.slider_dim_transforms[dim](ref_index) + + # clamp between 0 and array size in this dim return max(min(index, self.shape[dim] - 1), 0) - def _get_slider_dims_indexer(self, indices) -> dict: + def _get_slider_dims_indexer(self, indices: dict[Hashable, Any]) -> dict[Hashable, slice]: + """ + Creates an xarray-compatible indexer dict mapping each slider_dim -> slice object. + + - If a window_func is defined for a dim and the dim appears in ``window_order``, + the slice is defined as: + start: index - half_window + stop: index + half_window + step: 1 + + It then applies the slider_dim_transform to the start and stop to map these values from reference-space to + the local array index, and then finally produces the slice object in local array indices. + + ex: if we have indices = {"time": 50.0}, a window size of 5.0s and the ``slider_dim_transform`` + for time is based on a sampling rate of 10Hz, the window in ref units is [45.0, 55.0], and the final + slice object would be ``slice(450, 550, 1)``. + + - If no window func is specified, the final slice just corresponds to that index as an int array-index. + + This exists separate from ``_apply_window_functions()`` because it is useful for debugging purposes. + + Parameters + ---------- + indices : dict[Hashable, Any], {dim: ref_value} + Reference-space values for each slider dim. Must contain an entry + for every slider dim; raises ``IndexError`` otherwise. + ex: {"time": 46.397, "depth": 23.24} + + Returns + ------- + dict[Hashable, slice] + Indexer compatible for ``xr.DataArray.isel()``, with one ``slice`` per + slider dim. These are array indices mapped from the reference space using + the given ``slider_dim_transform``. + + Raises + ------ + IndexError + If ``indices`` are not provided for every ``slider_dim`` + """ + if set(indices.keys()) != set(self.slider_dims): raise IndexError( f"Must provide an index for all slider dims: {self.slider_dims}, you have provided: {indices.keys()}" ) indexer = dict() + # get only slider dims which are not also spatial dims (example: p dim for positional data) - # since that is dealt with separately + # since `p` dim windowing is dealt with separately for positional data slider_dims = set(self.slider_dims) - set(self.spatial_dims) # go through each slider dim and accumulate slice objects for dim in slider_dims: @@ -277,8 +414,8 @@ def _get_slider_dims_indexer(self, indices) -> dict: stop_ref = index_ref + hw # map start and stop ref to array indices - start = self.index_mappings[dim](start_ref) - stop = self.index_mappings[dim](stop_ref) + start = self.slider_dim_transforms[dim](start_ref) + stop = self.slider_dim_transforms[dim](stop_ref) # clamp within array bounds start = max(min(self.shape[dim] - 1, start), 0) @@ -287,7 +424,7 @@ def _get_slider_dims_indexer(self, indices) -> dict: else: # no window func for this dim, direct indexing # index mapped to array index - index = self.index_mappings[dim](index_ref) + index = self.slider_dim_transforms[dim](index_ref) # clamp within the bounds start = max(min(self.shape[dim] - 1, index), 0) @@ -297,10 +434,29 @@ def _get_slider_dims_indexer(self, indices) -> dict: return indexer - def _apply_window_functions(self, indices) -> xr.DataArray: - """slice with windows at given indices and apply window functions""" + def _apply_window_functions(self, indices: dict[Hashable, Any]) -> xr.DataArray: + """ + Slice the data at the given indices and apply window functions in the order specified by + ``window_order``. + + Parameters + ---------- + indices : dict[Hashable, Any], {dim: ref_value} + Reference-space values for each slider dim. + ex: {"time": 46.397, "depth": 23.24} + + Returns + ------- + xr.DataArray + Data slice after windowed indexing and window function application, + with the same dims as the original data. Dims of size ``1`` are not + squeezed. + + """ indexer = self._get_slider_dims_indexer(indices) + # get the data slice w.r.t. the desired windows, and get the underlying numpy array + # ``.values`` gives the numpy array # there is significant overhead with passing xarray objects to numpy for things like np.mean() # so convert to numpy, apply window functions, then convert back to xarray # creating an xarray object from a numpy array has very little overhead, ~10 microseconds @@ -312,7 +468,11 @@ def _apply_window_functions(self, indices) -> xr.DataArray: continue func, _ = self.window_funcs[dim] - + # ``keepdims=True`` is critical, any "collapsed" dims will be of size ``1``. + # Ex: if `array` is of shape [10, 512, 512] and we applied the np.mean() window func on the first dim + # ``keepdims`` means the resultant shape is [1, 512, 512] and NOT [512, 512] + # this is necessary for applying window functions on multiple dims separately and so that the + # dims names correspond after all the window funcs are applied. array = func(array, axis=self.dims.index(dim), keepdims=True) return xr.DataArray(array, dims=self.dims) @@ -320,7 +480,17 @@ def _apply_window_functions(self, indices) -> xr.DataArray: def get(self, indices: dict[Hashable, Any]): raise NotImplementedError - def __repr__(self): + # TODO: html and pretty text repr # + # def _repr_html_(self) -> str: + # return ndp_fmt_html(self) + # + # def _repr_mimebundle_(self, **kwargs) -> dict: + # return { + # "text/plain": self._repr_text_(), + # "text/html": self._repr_html_(), + # } + + def _repr_text_(self): if self.data is None: return ( f"{self.__class__.__name__}\n" @@ -336,7 +506,7 @@ def __repr__(self): f"dims:\n\t{self.dims}\n" f"spatial_dims:\n\t{self.spatial_dims}\n" f"slider_dims:\n\t{self.slider_dims}\n" - f"index_mappings:\n{textwrap.indent(pformat(self.index_mappings, width=120), prefix=tab)}\n" + f"slider_dim_transforms:\n{textwrap.indent(pformat(self.slider_dim_transforms, width=120), prefix=tab)}\n" ) if len(wf) > 0: @@ -367,6 +537,7 @@ def _create_graphic(self): @property def name(self) -> str | None: + """name given to the NDGraphic""" return self._name @property @@ -388,6 +559,10 @@ def indices(self, new: dict[Hashable, Any]): # aliases for easier access to processor properties @property def data(self) -> Any: + """ + get or set managed data. If setting with new data, the new data is interpreted + to have the same dims (i.e. same dim names and ordering of dims). + """ return self.processor.data @data.setter @@ -395,13 +570,13 @@ def data(self, data: Any): self.processor.data = data # create a new graphic when data has changed if self.graphic is not None: - # it is already None is it was initialized with no data + # it is already None if NDGraphic was initialized with no data self._subplot.delete_graphic(self.graphic) self._graphic = None self._create_graphic() - # force a re-render + # force a render self.indices = self.indices @property @@ -427,18 +602,20 @@ def spatial_dims(self) -> tuple[str, ...]: @property def slider_dims(self) -> set[Hashable]: + """the slider dims""" return self.processor.slider_dims @property - def index_mappings(self) -> dict[Hashable, Callable[[Any], int]]: - return self.processor.index_mappings + def slider_dim_transforms(self) -> dict[Hashable, Callable[[Any], int]]: + return self.processor.slider_dim_transforms - @index_mappings.setter - def index_mappings( + @slider_dim_transforms.setter + def slider_dim_transforms( self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None ): - self.processor.index_mappings = maps - # force a re-render + """get or set the slider_dim_transforms, see docstring for details""" + self.processor.slider_dim_transforms = maps + # force a render self.indices = self.indices @property @@ -457,7 +634,7 @@ def window_funcs( ), ): self.processor.window_funcs = window_funcs - # force a re-render + # force a render self.indices = self.indices @property @@ -468,7 +645,7 @@ def window_order(self) -> tuple[Hashable, ...]: @window_order.setter def window_order(self, order: tuple[Hashable] | None): self.processor.window_order = order - # force a re-render + # force a render self.indices = self.indices @property @@ -479,33 +656,31 @@ def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: def spatial_func( self, func: Callable[[xr.DataArray], xr.DataArray] ) -> Callable | None: + """get or set the spatial_func, see docstring for details""" self.processor.spatial_func = func - # force a re-render + # force a render self.indices = self.indices - def __repr__(self): + # def _repr_text_(self) -> str: + # return ndg_fmt_text(self) + # + # def _repr_html_(self) -> str: + # return ndg_fmt_html(self) + # + # def _repr_mimebundle_(self, **kwargs) -> dict: + # return { + # "text/plain": self._repr_text_(), + # "text/html": self._repr_html_(), + # } + + def _repr_text_(self): return f"graphic: {self.graphic.__class__.__name__}\n" f"processor:\n{self.processor}" @contextmanager def block_indices(ndgraphic: NDGraphic): """ - 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 - -------- - - .. code-block:: - - # pass in any number of graphics - with fpl.pause_events(graphic1, graphic2, graphic3): - # enter context manager - # all events are blocked from graphic1, graphic2, graphic3 - - # context manager exited, event states restored. - + Context manager for pausing an NDGraphic from updating indices """ ndgraphic._block_indices = True @@ -518,7 +693,7 @@ def block_indices(ndgraphic: NDGraphic): def block_reentrance(setter): - # decorator to block re-entrant indices setter + # decorator to block re-entrance of indices setter def set_indices_wrapper(self: NDGraphic, new_indices): """ wraps NDGraphic.indices diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index e91319893..6d7b17445 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -1,15 +1,51 @@ from __future__ import annotations from dataclasses import dataclass +from numbers import Number from typing import Sequence, Any, Callable from typing import TYPE_CHECKING + if TYPE_CHECKING: from ._ndwidget import NDWidget @dataclass class RangeContinuous: + """ + A continuous reference range for a single slider dimension. + + Stores the (start, stop, step) in scientific units (ex: seconds, micrometers, + Hz). The imgui slider for this dimension uses these values to determine its + minimum and maximum bounds. The step size is used for the "next" and "previous" buttons. + + Parameters + ---------- + start : int or float + Minimum value of the range, inclusive. + + stop : int or float + Maximum value of the range, exclusive upper bound. + + step : int or float + Step size used for imgui step next/previous buttons + + Raises + ------ + IndexError + If ``start >= stop``. + + Examples + -------- + A time axis sampled at 1 ms resolution over 10 seconds: + + RangeContinuous(start=0, stop=10_000, step=1) + + A depth axis in micrometers with 0.5 µm steps: + + RangeContinuous(start=0.0, stop=500.0, step=0.5) + """ + start: int | float stop: int | float step: int | float @@ -41,6 +77,7 @@ def range(self) -> int | float: @dataclass class RangeDiscrete: + # TODO: not implemented yet, placeholder until we have a clear usecase options: Sequence[Any] def __getitem__(self, index: int): @@ -56,21 +93,70 @@ def __len__(self): class ReferenceIndex: def __init__( self, - ref_ranges: dict[str, tuple], + ref_ranges: dict[ + str, + tuple[Number, Number, Number] | tuple[Any] | RangeContinuous, + ], ): - self._ref_ranges = dict() + """ + Manages the shared reference index for one or more ``NDWidget`` instances. - for name, r in ref_ranges.items(): - if len(r) == 3: - # assume start, stop, step - self._ref_ranges[name] = RangeContinuous(*r) + Stores the current index for each named slider dimension in reference-space + units (ex: seconds, depth in µm, Hz). Whenever an index is updated, every + ``NDGraphic`` in the manged ``NDWidgets`` are requested to render data at + the new indices. - elif len(r) == 1: - # assume just options - self._ref_ranges[name] = RangeDiscrete(*r) + Each key in ``ref_ranges`` defines a slider dimension. When adding an + ``NDGraphic``, every dimension listed in ``dims`` must be either a spatial + dimension (listed in ``spatial_dims``) or a key in ``ref_ranges``. + If a dim is not spatial, it must have a corresponding reference range, + otherwise an error will be raised. - else: - raise ValueError + You can also define conceptually identical but *independent* reference spaces + by using distinct names, ex: ``"time-1"`` and ``"time-2"`` for two recordings + that should be sycned independently. Each ``NDGraphic`` then declares the + specific "time-n" space that corresponds to its data, so the widget keeps the + two timelines decoupled. + + Parameters + ---------- + ref_ranges : dict[str, tuple], or a RangeContinuous + Mapping of dimension names to range specifications. A 3-tuple + ``(start, stop, step)`` creates a :class:`RangeContinuous`. A 1-tuple + ``(options,)`` creates a :class:`RangeDiscrete`. + + Attributes + ---------- + ref_ranges : dict[str, RangeContinuous | RangeDiscrete] + The reference range for each registered slider dimension. + + dims: set[str] + the set of "slider dims" + + Examples + -------- + Single shared time axis: + + ri = ReferenceIndex(ref_ranges={"time": (0, 1000, 1), "depth": (15, 35, 0.5)}) + ri["time"] = 500 # update one dim and re-render + ri.set({"time": 500, "depth": 10}) # update several dims atomically + + Two independent time axes for data from two different recording sessions: + + ri = ReferenceIndex({ + "time-1": (0, 3600, 1), # session 1 — 1 h at 1 s resolution + "time-s": (0, 1800, 1), # session 2 — 30 min at 1 s resolution + }) + + Each ``NDGraphic`` declares matching names for slider dims to indicate that these should be + synced across graphics. + + ndw[0, 0].add_nd_image(data_s1, ("time-s1", "row", "col"), ("row", "col")) + ndw[0, 1].add_nd_image(data_s2, ("time-s2", "row", "col"), ("row", "col")) + + """ + self._ref_ranges = dict() + self.push_dims(ref_ranges) # starting index for all dims self._indices: dict[str, int | float | Any] = { @@ -81,8 +167,17 @@ def __init__( self._ndwidgets: list[NDWidget] = list() + @property + def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: + return self._ref_ranges + + @property + def dims(self) -> set[str]: + return set(self.ref_ranges.keys()) + def _add_ndwidget_(self, ndw: NDWidget): from ._ndwidget import NDWidget + if not isinstance(ndw, NDWidget): raise TypeError @@ -111,31 +206,51 @@ def _render_indices(self): # only provide slider indices to the graphic g.indices = {d: self._indices[d] for d in g.processor.slider_dims} - @property - def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: - return self._ref_ranges - def __getitem__(self, dim): + self._check_has_dim(dim) return self._indices[dim] def __setitem__(self, dim, value): + self._check_has_dim(dim) # set index for given dim and render self._indices[dim] = self._clamp(dim, value) self._render_indices() + def _check_has_dim(self, dim): + if dim not in self.dims: + raise KeyError( + f"provided dimension: {dim} has no associated ReferenceRange in this ReferenceIndex, valid dims in this ReferenceIndex are: {self.dims}" + ) + def pop_dim(self): pass - def push_dim(self, ref_range: RangeContinuous): - # TODO: implement pushing and popping dims - pass + def push_dims(self, ref_ranges: dict[ + str, + tuple[Number, Number, Number] | tuple[Any] | RangeContinuous, + ],): + + for name, r in ref_ranges.items(): + if isinstance(r, (RangeContinuous, RangeDiscrete)): + self._ref_ranges[name] = r + + elif len(r) == 3: + # assume start, stop, step + self._ref_ranges[name] = RangeContinuous(*r) + + elif len(r) == 1: + # assume just options + self._ref_ranges[name] = RangeDiscrete(*r) + + else: + raise ValueError( + f"ref_ranges must be a mapping of dimension names to range specifications, " + f"see the docstring, you have passed: {ref_ranges}" + ) def add_event_handler(self, handler: Callable, event: str = "indices"): """ - Register an event handler. - - Currently the only event that ImageWidget supports is "indices". This event is - emitted whenever the indices of the ImageWidget changes. + Register an event handler that is called whenever the indices change. Parameters ---------- @@ -143,7 +258,7 @@ def add_event_handler(self, handler: Callable, event: str = "indices"): callback function, must take a tuple of int as the only argument. This tuple will be the `indices` event: str, "indices" - the only supported event is "indices" + the only supported valid is "indices" Example ------- @@ -152,7 +267,7 @@ def add_event_handler(self, handler: Callable, event: str = "indices"): def my_handler(indices): print(indices) - # example prints: {"t": 100, "z": 15} if the index has 2 slider dimensions "t" and "z" + # example prints: {"t": 100, "z": 15} if the index has 2 reference spaces "t" and "z" # create an NDWidget ndw = NDWidget(...) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 0261a64e5..c6292b68c 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -11,12 +11,13 @@ from ...graphics import ImageGraphic, ImageVolumeGraphic from ...tools import HistogramLUTTool from ._base import NDProcessor, NDGraphic, WindowFuncCallable +from ._index import ReferenceIndex class NDImageProcessor(NDProcessor): def __init__( self, - data: ArrayLike | None, + data: ArrayProtocol | None, dims: Sequence[Hashable], spatial_dims: ( tuple[str, str] | tuple[str, str, str] @@ -26,60 +27,93 @@ def __init__( window_order: tuple[int, ...] = None, spatial_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, - index_mappings=None, + slider_dim_transforms=None, ): """ - An ND image that supports computing window functions, and functions over spatial dimensions. + ``NDProcessor`` subclass for n-dimensional image data. + + Produces 2-D or 3-D spatial slices for an ``ImageGraphic`` or ``ImageVolumeGraphic``. Parameters ---------- - data: ArrayLike + data: ArrayProtocol array-like data, must have 2 or more dimensions - n_display_dims: int, 2 or 3, default 2 - number of display dimensions - - rgb: bool, default False - whether the image data is RGB(A) or not - - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable, optional - A function or a ``tuple`` of functions that are applied to a rolling window of the data. + dims: Sequence[str] + names for each dimension in ``data``. Dimensions not listed in + ``spatial_dims`` are treated as slider dimensions and **must** appear as + keys in the parent ``NDWidget``'s ``ref_ranges`` + Examples:: + ``("time", "depth", "row", "col")`` + ``("channels", "time", "xy")`` + ``("keypoints", "time", "xyz")`` + + A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method + must operate as if these dimensions exist and return an array that matches the spatial dimensions. + + dims: Sequence[str] + names for each dimension in ``data``. Dimensions not listed in + ``spatial_dims`` are treated as slider dimensions and **must** appear as + keys in the parent ``NDWidget``'s ``ref_ranges`` + Examples:: + ``("time", "depth", "row", "col")`` + ``("row", "col")`` + ``("other_dim", "depth", "time", "row", "col")`` + + dims in the array do not need to be in order, for example you can have a weird array where the dims are + interpreted as: ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")`` + thanks to xarray magic =D. + + spatial_dims : tuple[str, str] | tuple[str, str, str] + The 2 or 3 spatial dimensions **in order**: ``(rows, cols)`` or ``(z, rows, cols)``. + This also determines whether an ``ImageGraphic`` or ``ImageVolumeGraphic`` is used for rendering. + The ordering determines how the Image/Volume is rendered. For example, if + you specify ``spatial_dims = ("rows", "cols")`` and then change it to ``("cols", "rows")``, it will display + the transpose. + + rgb_dim : str, optional + Name of an RGB(A) dimension, if present. - 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. - - 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``. - - The output array-like type from a window function **must** support a ``.squeeze()`` method, but the - function itself should NOT squeeze the output array. + compute_histogram: bool, default True + Compute a histogram of the data, disable if random-access of data is not blazing-fast (ex: data that uses + video codecs), or if histograms are not useful for this data. - window_sizes: tuple[int | None, ...], optional - ``tuple`` of ``int`` that specifies the window size for each dimension. + slider_dim_transforms : dict, optional + See :class:`NDProcessor`. - 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. + window_funcs : dict, optional + See :class:`NDProcessor`. - 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). + window_order : tuple, optional + See :class:`NDProcessor`. - compute_histogram: bool, default True - Compute a histogram of the data, auto re-computes if window function propties or spatial_func changes. - Disable if slow. + spatial_func : callable, optional + See :class:`NDProcessor`. + See Also + -------- + NDProcessor : Base class with full parameter documentation. + NDImage : The ``NDGraphic`` that wraps this processor. """ + # set as False until data, window funcs stuff and spatial func is all set self._compute_histogram = False + # make sure rgb dim is size 3 or 4 + if rgb_dim is not None: + dim_index = dims.index(rgb_dim) + if data.shape[dim_index] not in (3, 4): + raise IndexError( + f"The size of the RGB(A) dim must be 3 | 4. You have specified an array of shape: {data.shape}, " + f"with dims: {dims}, and specified the ``rgb_dim`` name as: {rgb_dim} which has size " + f"{data.shape[dim_index]} != 3 | 4" + ) + super().__init__( data=data, dims=dims, spatial_dims=spatial_dims, - index_mappings=index_mappings, + slider_dim_transforms=slider_dim_transforms, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, @@ -91,23 +125,27 @@ def __init__( @property def data(self) -> xr.DataArray | None: - """get or set the data array""" + """ + get or set managed data. If setting with new data, the new data is interpreted + to have the same dims (i.e. same dim names and ordering of dims). + """ return self._data @data.setter - def data(self, data: ArrayLike): - # check that all array-like attributes are present + def data(self, data: ArrayProtocol): self._data = self._validate_data(data) self._recompute_histogram() def _validate_data(self, data: ArrayProtocol): if not isinstance(data, ArrayProtocol): + # check that it's compatible with array and generally array-like raise TypeError( f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" f"{ARRAY_LIKE_ATTRS}, or they must be `None`" ) if data.ndim < 2: + # ndim < 2 makes no sense for image data raise IndexError( f"Image data must have a minimum of 2 dimensions, you have passed an array of shape: {data.shape}" ) @@ -116,7 +154,9 @@ def _validate_data(self, data: ArrayProtocol): @property def rgb_dim(self) -> str | None: - """indicates the rgb dim if one exists""" + """ + get or set the RGB(A) dim name, ``None`` if no RGB(A) dim exists + """ return self._rgb @rgb_dim.setter @@ -129,6 +169,7 @@ def rgb_dim(self, rgb: str | None): @property def compute_histogram(self) -> bool: + """get or set whether or not to compute the histogram""" return self._compute_histogram @compute_histogram.setter @@ -213,7 +254,7 @@ def _recompute_histogram(self): if isinstance(sub, xr.DataArray): # can't do the isnan and isinf boolean indexing below on xarray sub = sub.values - + sub_real = sub[~(np.isnan(sub) | np.isinf(sub))] self._histogram = np.histogram(sub_real, bins=100) @@ -222,10 +263,10 @@ def _recompute_histogram(self): class NDImage(NDGraphic): def __init__( self, - ref_index, + ref_index: ReferenceIndex, subplot: Subplot, - data: ArrayLike | None, - dims: Sequence[Hashable], + data: ArrayProtocol | None, + dims: Sequence[str], spatial_dims: ( tuple[str, str] | tuple[str, str, str] ), # must be in order! [rows, cols] | [z, rows, cols] @@ -234,9 +275,77 @@ def __init__( window_order: tuple[int, ...] = None, spatial_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, - index_mappings=None, + slider_dim_transforms=None, name: str = None, ): + """ + ``NDGraphic`` subclass for n-dimensional image rendering. + + Wraps an :class:`NDImageProcessor` and manages either an ``ImageGraphic`` or``ImageVolumeGraphic``. + swaps automatically when :attr:`spatial_dims` is reassigned at runtime. Also + owns a ``HistogramLUTTool`` for interactive vmin, vmax adjustment. + + Every dimension that is *not* listed in ``spatial_dims`` becomes a slider + dimension. Each slider dim must have a ``ReferenceRange`` defined in the + ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct + a change in the ``ReferenceIndex`` and update the graphics. + + Parameters + ---------- + ref_index : ReferenceIndex + The shared reference index that delivers slider updates to this graphic. + + subplot : Subplot + parent subplot the NDGraphic is in + + data : array-like or None + n-dimension image data array + + dims : sequence of hashable + Name for every dimension of ``data``, in order. Non-spatial dims must + match keys in ``ref_index``. + + ex: ``("time", "depth", "row", "col")`` — ``"time"`` and ``"depth"`` must + be present in ``ref_index``. + + spatial_dims : tuple[str, str] | tuple[str, str, str] + Spatial dimensions **in order**: ``(rows, cols)`` for 2-D images or + ``(z, rows, cols)`` for volumes. Controls whether an ``ImageGraphic`` or + ``ImageVolumeGraphic`` is used. + + rgb_dim : str, optional + Name of the RGB or channel dimension, if present. + + window_funcs : dict, optional + See :class:`NDProcessor`. + + window_order : tuple, optional + See :class:`NDProcessor`. + + spatial_func : callable, optional + See :class:`NDProcessor`. + + compute_histogram : bool, default ``True`` + Whether to initialize the ``HistogramLUTTool``. + + slider_dim_transforms : dict, optional + See :class:`NDProcessor`. + + name : str, optional + Name for the underlying graphic. + + See Also + -------- + NDImageProcessor : The processor that backs this graphic. + + """ + + if not (set(dims) - set(spatial_dims)).issubset(ref_index.dims): + raise IndexError( + f"all specified `dims` must either be a spatial dim or a slider dim " + f"specified in the NDWidget ref_ranges, provided dims: {dims}, " + f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" + ) super().__init__(subplot, name) @@ -251,47 +360,65 @@ def __init__( window_order=window_order, spatial_func=spatial_func, compute_histogram=compute_histogram, - index_mappings=index_mappings, + slider_dim_transforms=slider_dim_transforms, ) self._graphic: ImageGraphic | None = None self._histogram_widget: HistogramLUTTool | None = None + # create a graphic self._create_graphic() @property def processor(self) -> NDImageProcessor: + """NDProcessor that manages the data and produces data slices to display""" return self._processor @property def graphic( self, ) -> ImageGraphic | ImageVolumeGraphic: - """LineStack or ImageGraphic for heatmaps""" + """Underlying Graphic object used to display the current data slice""" return self._graphic def _create_graphic(self): + # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, + # adds it to the subplot, and resets the camera and histogram. + + if self.processor.data is None: + # no graphic if data is None, useful for initializing in null states when we want to set data later + return + + # determine if we need a 2d image or 3d volume + # remove RGB spatial dim, ex: if we have an RGBA image of shape [512, 512, 4] we want to interpet this as + # 2D for images + # [30, 512, 512, 4] with an rgb dim is an RGBA volume which is also supported match len(self.processor.spatial_dims) - int(bool(self.processor.rgb_dim)): case 2: cls = ImageGraphic case 3: cls = ImageVolumeGraphic + # get the data slice for this index + # this will only have the dims specified by ``spatial_dims`` data_slice = self.processor.get(self.indices) - old_graphic = self._graphic + # create the new graphic new_graphic = cls(data_slice) + old_graphic = self._graphic + # check if we are replacing a graphic + # ex: swapping from 2D <-> 3D representation after ``spatial_dims`` was changed if old_graphic is not None: # carry over some attributes from old graphic attrs = dict.fromkeys(["cmap", "interpolation", "cmap_interpolation"]) for k in attrs: attrs[k] = getattr(old_graphic, k) + # delete the old graphic self._subplot.delete_graphic(old_graphic) - self._subplot.add_graphic(new_graphic) - # set cmap and interpolation + # set any attributes that we're carrying over like cmap for attr, val in attrs.items(): setattr(new_graphic, attr, val) @@ -332,7 +459,7 @@ def _reset_histogram(self): self.graphic.reset_vmin_vmax() def _reset_camera(self): - # set camera to a nice position for 2D or 3D + # set camera to a nice position based on whether it's a 2D ImageGraphic or 3D ImageVolumeGraphic if isinstance(self._graphic, ImageGraphic): # set camera orthogonal to the xy plane, flip y axis self._subplot.camera.set_state( @@ -341,7 +468,7 @@ def _reset_camera(self): "rotation": [0, 0, 0, 1], "scale": [1, -1, 1], "reference_up": [0, 1, 0], - "fov": 0, + "fov": 0, # orthographic projection "depth_range": None, } ) @@ -351,11 +478,12 @@ def _reset_camera(self): self._subplot.auto_scale() else: + # It's not an ImageGraphic, set perspective projection self._subplot.camera.fov = 50 self._subplot.controller = "orbit" - # make sure all 3D dimension camera scales are positive - # MIP rendering doesn't work with negative camera scales + # set all 3D dimension camera scales to positive since positive scales + # are typically used for looking at volumes for dim in ["x", "y", "z"]: if getattr(self._subplot.camera.local, f"scale_{dim}") < 0: setattr(self._subplot.camera.local, f"scale_{dim}", 1) @@ -364,6 +492,7 @@ def _reset_camera(self): @property def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: + """get or set the spatial dims, see docstring for details""" return self.processor.spatial_dims @spatial_dims.setter @@ -375,6 +504,7 @@ def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): @property def indices(self) -> dict[Hashable, Any]: + """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} @indices.setter @@ -385,6 +515,7 @@ def indices(self, indices): @property def compute_histogram(self) -> bool: + """whether or not to compute the histogram and display the HistogramLUTTool""" return self.processor.compute_histogram @compute_histogram.setter @@ -394,6 +525,7 @@ def compute_histogram(self, v: bool): @property def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + """get or set the spatial_func, see docstring for details""" return self.processor.spatial_func @spatial_func.setter @@ -405,6 +537,7 @@ def spatial_func( self._reset_histogram() def _tooltip_handler(self, graphic, pick_info): + # TODO: need to do this better # get graphic within the collection n_index = np.argwhere(self.graphic.graphics == graphic).item() p_index = pick_info["vertex_index"] diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index b0eca548d..6cb69a83d 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -62,7 +62,7 @@ def __init__( spatial_dims: tuple[ Hashable | None, Hashable, Hashable ], # [stack_dim, n_datapoints, spatial_dim], IN ORDER!! - index_mappings: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, display_window: int | float | None = 100, # window for n_datapoints dim only max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, @@ -73,13 +73,21 @@ def __init__( **kwargs, ): """ + ``NDProcessor`` subclass for n-dimensional positional and timeseries data. + + + The *datapoints* dimension is + simultaneously a slider dim and a spatial dim and is handled by a dedicated + :attr:`datapoints_window_func` rather than the general ``window_funcs`` + mechanism. + Parameters ---------- data dims spatial_dims - index_mappings + slider_dim_transforms display_window max_display_datapoints: int, default 1_000 this is approximate since floor division is used to determine the step size of the current display window slice @@ -94,7 +102,7 @@ def __init__( data=data, dims=dims, spatial_dims=spatial_dims, - index_mappings=index_mappings, + slider_dim_transforms=slider_dim_transforms, **kwargs, ) @@ -407,7 +415,7 @@ def _apply_dw_window_func( # display window in array index space if self.display_window is not None: - dw = self.index_mappings[p_dim](self.display_window) + dw = self.slider_dim_transforms[p_dim](self.display_window) # step size based on max number of datapoints to render step = max(1, dw // self.max_display_datapoints) @@ -576,7 +584,7 @@ def __init__( processor: type[NDPositionsProcessor] = NDPositionsProcessor, display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, - index_mappings: tuple[Callable[[Any], int] | None] | None = None, + slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, linear_selector: bool = False, x_range_mode: Literal["fixed", "auto"] | None = None, @@ -594,9 +602,47 @@ def __init__( sizes_each: Sequence[float] = None, # for each individual scatter, shape [l, p] thickness: np.ndarray = None, # for each line, shape [l,] name: str = None, + timeseries: bool = False, graphic_kwargs: dict = None, processor_kwargs: dict = None, ): + """ + Wraps an :class:`NDPositionsProcessor` and supports four interchangeable + graphical representations: ``LineStack``, ``LineCollection``, ``ScatterStack``, + and ``ScatterCollection``, as well as a heatmap view. For timeseries use-cases + it also manages a linear selector and automatically adjusts the view according + to the current x-range of the displayed data. + + Parameters + ---------- + ref_index + subplot + data + dims + spatial_dims + args + graphic_type + processor + display_window + window_funcs + slider_dim_transforms + max_display_datapoints + linear_selector + x_range_mode + colors + cmap + cmap_each + cmap_transform_each + markers + markers_each + sizes + sizes_each + thickness + name + graphic_kwargs + processor_kwargs + """ + super().__init__(subplot, name) self._ref_index = ref_index @@ -617,7 +663,7 @@ def __init__( display_window=display_window, max_display_datapoints=max_display_datapoints, window_funcs=window_funcs, - index_mappings=index_mappings, + slider_dim_transforms=slider_dim_transforms, colors=colors, markers=markers_each, cmap_transform_each=cmap_transform_each, @@ -640,13 +686,26 @@ def __init__( self.x_range_mode = x_range_mode self._last_x_range = np.array([0.0, 0.0], dtype=np.float32) - if linear_selector: - self._linear_selector = LinearSelector( - 0, limits=(-np.inf, np.inf), edge_color="cyan" - ) - self._linear_selector.add_event_handler( - self._linear_selector_handler, "selection" - ) + self._timeseries = timeseries + # TODO: I think this is messy af, NDTimeseriesSubclass??? + if self._timeseries: + # makes some assumptions about positional data that apply only to timeseries representations + # probably don't want to maintain aspect + self._subplot.camera.maintain_aspect = False + + # auto x range modes make no sense for non-timeseries data + self.x_range_mode = x_range_mode + + if linear_selector: + self._linear_selector = LinearSelector( + 0, limits=(-np.inf, np.inf), edge_color="cyan" + ) + self._linear_selector.add_event_handler( + self._linear_selector_handler, "selection" + ) + self._subplot.add_graphic(self._linear_selector) + else: + self._linear_selector = None else: self._linear_selector = None @@ -762,6 +821,7 @@ def indices(self, indices): self.graphic.offset = (x0, *self.graphic.offset[1:]) self.graphic.scale = (x_scale, *self.graphic.scale[1:]) + # TODO: I think this is messy af, NDTimeseriesSubclass??? # x range of the data xr = data_slice[0, 0, 0], data_slice[0, -1, 0] if self.x_range_mode is not None: diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 0f53951bd..6666b3fc1 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -1,13 +1,25 @@ -from typing import Literal +from collections.abc import Callable +from typing import Literal, Sequence, Hashable + import numpy as np from ... import ScatterCollection, ScatterStack, LineCollection, LineStack, ImageGraphic from ...layouts import Subplot +from ...utils import ArrayProtocol from . import NDImage, NDPositions -from ._base import NDGraphic +from ._base import NDGraphic, WindowFuncCallable class NDWSubplot: + """ + Entry point for adding ``NDGraphic`` objects to a subplot of an ``NDWidget``. + + Accessed via ``ndw[row, col]`` or ``ndw["subplot_name"]``. + Each ``add_nd_<...>`` method constructs the appropriate ``NDGraphic``, registers it with the parent + ``ReferenceIndex``, appends it to this subplot and returns the ``NDGraphic`` instance to the user. + + Note: ``NDWSubplot`` is not meant to be constructed directly, it only exists as part of an ``NDWidget`` + """ def __init__(self, ndw, subplot: Subplot): self.ndw = ndw self._subplot = subplot @@ -16,9 +28,11 @@ def __init__(self, ndw, subplot: Subplot): @property def nd_graphics(self) -> tuple[NDGraphic]: + """all the NDGraphic instance in this subplot""" return tuple(self._nd_graphics) def __getitem__(self, key): + # get a specific NDGraphic by index or name if isinstance(key, (int, np.integer)): return self.nd_graphics[key] @@ -29,23 +43,55 @@ def __getitem__(self, key): else: raise KeyError(f"NDGraphc with given key not found: {key}") - def add_nd_image(self, *args, **kwargs): - nd = NDImage(self.ndw.indices, self._subplot, *args, **kwargs) - self._nd_graphics.append(nd) + def add_nd_image( + self, + data: ArrayProtocol | None, + dims: Sequence[Hashable], + spatial_dims: ( + tuple[str, str] | tuple[str, str, str] + ), # must be in order! [rows, cols] | [z, rows, cols] + rgb_dim: str | None = None, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + compute_histogram: bool = True, + slider_dim_transforms=None, + name: str = None, + ): + nd = NDImage(self.ndw.indices, self._subplot, data=data, + dims=dims, + spatial_dims=spatial_dims, + rgb_dim=rgb_dim, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + compute_histogram=compute_histogram, + slider_dim_transforms=slider_dim_transforms, + name=name, + ) + self._nd_graphics.append(nd) return nd def add_nd_scatter(self, *args, **kwargs): # TODO: better func signature here, send all kwargs to processor_kwargs - nd = NDPositions(self.ndw.indices, self._subplot, *args, graphic_type=ScatterCollection, **kwargs) - self._nd_graphics.append(nd) + nd = NDPositions( + self.ndw.indices, + self._subplot, + *args, + graphic_type=ScatterCollection, + **kwargs, + ) + self._nd_graphics.append(nd) return nd def add_nd_timeseries( self, *args, - graphic_type: type[LineCollection | LineStack | ScatterStack | ImageGraphic] = LineStack, + graphic_type: type[ + LineCollection | LineStack | ScatterStack | ImageGraphic + ] = LineStack, x_range_mode: Literal["fixed", "auto"] | None = "auto", **kwargs, ): @@ -56,20 +102,21 @@ def add_nd_timeseries( graphic_type=graphic_type, linear_selector=True, x_range_mode=x_range_mode, + timeseries=True, **kwargs, ) - self._nd_graphics.append(nd) - self._subplot.add_graphic(nd._linear_selector) - - # need plot_area to exist before these this can be called - nd.x_range_mode = x_range_mode - - # probably don't want to maintain aspect - self._subplot.camera.maintain_aspect = False + self._nd_graphics.append(nd) return nd def add_nd_lines(self, *args, **kwargs): - nd = NDPositions(self.ndw.indices, self._subplot, *args, graphic_type=LineCollection, **kwargs) + nd = NDPositions( + self.ndw.indices, + self._subplot, + *args, + graphic_type=LineCollection, + **kwargs, + ) + self._nd_graphics.append(nd) return nd diff --git a/fastplotlib/widgets/nd_widget/_repr_formatter.py b/fastplotlib/widgets/nd_widget/_repr_formatter.py new file mode 100644 index 000000000..0569f1004 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_repr_formatter.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +import html +from collections.abc import Callable +from typing import Any + + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" + +_C = { + "title": "\033[38;5;75m", # sky-blue + "spatial": "\033[38;5;114m", # sage-green + "slider": "\033[38;5;215m", # soft-orange + "label": "\033[38;5;246m", # mid-grey + "value": "\033[38;5;252m", # near-white + "section": "\033[38;5;68m", # steel-blue + "muted": "\033[38;5;240m", # dark-grey + "warn": "\033[38;5;222m", # amber +} + + +def _c(key: str, text: str) -> str: + return f"{_C[key]}{text}{_RESET}" + + +def _callable_name(f: Callable | None) -> str: + if f is None: + return "—" + module = getattr(f, "__module__", "") or "" + qname = getattr(f, "__qualname__", None) or getattr(f, "__name__", repr(f)) + if module and not module.startswith("__"): + short = module.split(".")[-1] + return f"{short}.{qname}" + return qname + + +def ndprocessor_fmt_txt(processor) -> str: + """ + Returns a colored, ascii box + """ + lines: list[str] = [] + + cls = type(processor).__name__ + lines.append(_c("title", _BOLD + cls) + _RESET) + lines.append(_c("muted", "─" * 72)) + + lines.append(_c("section", " Dimensions")) + + header = ( + f" {'dim':<14}{'size':>6} {'role':<10} {'window_func size':<26} index_mapping" + ) + lines.append(_c("label", header)) + lines.append(_c("muted", " " + "─" * 70)) + + for dim in processor.dims: + size = processor.shape[dim] + is_sp = dim in processor.spatial_dims + role_s = (_c("spatial", f"{'spatial':<10}") if is_sp + else _c("slider", f"{'slider':<10}")) + + # window_func - size column + if not is_sp: + wf, ws = processor.window_funcs.get(dim, (None, None)) + if wf is not None and ws is not None: + win_s = _c("value", f"{_callable_name(wf)}") + _c("muted", f" - {ws}") + else: + win_s = _c("muted", "—") + else: + win_s = "" + + # index_mapping column (slider dims only; skip identity) + if not is_sp: + imap = processor.index_mappings.get(dim) + iname = getattr(imap, "__name__", "") if imap is not None else "" + if iname != "identity" and imap is not None: + idx_s = _c("value", _callable_name(imap)) + else: + idx_s = _c("muted", "—") + else: + idx_s = "" + + # pad win_s to fixed visible width (strip ANSI for measuring) + import re + _ansi_re = re.compile(r"\033\[[^m]*m") + win_visible = len(_ansi_re.sub("", win_s)) + win_pad = win_s + " " * max(0, 26 - win_visible) + + line = ( + f" {_c('value', f'{str(dim):<14}')}" + f"{_c('label', f'{size:>6}')} " + f"{role_s} {win_pad} {idx_s}" + ) + lines.append(line) + + # window order + if processor.window_order: + lines.append("") + order_s = " → ".join(str(d) for d in processor.window_order) + lines.append(f" {_c('section', 'Window order')} {_c('value', order_s)}") + + # spatial func + if processor.spatial_func is not None: + lines.append("") + lines.append( + f" {_c('section', 'Spatial func')} " + f"{_c('value', _callable_name(processor.spatial_func))}" + ) + + lines.append(_c("muted", "─" * 72)) + return "\n".join(lines) + + +def ndgraphic_fmt_txt(ndg) -> str: + """Text repr for NDGraphic.""" + cls = type(ndg).__name__ + gcls = type(ndg.graphic).__name__ if ndg.graphic is not None else "—" + name = ndg.name or "—" + + header = ( + f"{_c('title', _BOLD + cls)}{_RESET} " + f"{_c('muted', '·')} " + f"{_c('section', 'graphic')} {_c('value', gcls)} " + f"{_c('muted', '·')} " + f"{_c('section', 'name')} {_c('value', name)}\n" + ) + + proc_block = ndprocessor_fmt_txt(ndg.processor) + # indent processor block + indented = "\n".join(" " + l for l in proc_block.splitlines()) + return header + indented + +_CSS = """ + +""" + + +def _h(s: Any) -> str: + """html-escape a stringified value""" + return html.escape(str(s)) + + +def _badge(role: str) -> str: + cls = "fpl-badge-spatial" if role == "spatial" else "fpl-badge-slider" + return f'{role}' + + +def _code(s: str) -> str: + return f"{_h(s)}" + + +def _section(title: str, content_html: str, count: str = "", open_: bool = True) -> str: + open_attr = " open" if open_ else "" + count_badge = ( + f'{_h(count)}' if count else "" + ) + return ( + f'
' + f'' + f'{_h(title)}' + f'{count_badge}' + f'' + f'{content_html}' + f'
' + ) + + +def _dim_rows_html(proc) -> str: + rows = [] + for dim in proc.dims: + size = proc.shape[dim] + is_sp = dim in proc.spatial_dims + badge = _badge("spatial" if is_sp else "slider") + + # window_func - size column + if not is_sp: + wf, ws = proc.window_funcs.get(dim, (None, None)) + if wf is not None and ws is not None: + win_td = ( + f'' + f'{_code(_callable_name(wf))}' + f'-' + f'{_code(str(ws))}' + f'' + ) + else: + win_td = '—' + else: + win_td = '' + + # index_mapping column (slider dims only; hide identity) + if not is_sp: + imap = proc.index_mappings.get(dim) + if imap is not None: + idx_td = f'{_code(_callable_name(imap))}' + else: + idx_td = '—' + else: + idx_td = '' + + rows.append( + f'' + f'{_h(str(dim))}' + f'{size:,}' + f'{badge}' + f'{win_td}' + f'{idx_td}' + f'' + ) + + # column header row + header = ( + f'' + f'dim' + f'size' + f'role' + f'window_func - size' + f'index_mapping' + f'' + ) + + table = ( + '' + '' + '' + '' + '' + + header + + "".join(rows) + + "
" + ) + return table + + +def _footer_kv(pairs: list[tuple[str, str]]) -> str: + """Always-visible key/value rows rendered below the dim table.""" + inner = "" + for k, v in pairs: + inner += ( + f'' + f'' + ) + return f'' + + +def _kv_list_html(pairs: list[tuple[str, str]]) -> str: + inner = "" + for k, v in pairs: + inner += ( + f'
{_h(k)}
' + f'
{v}
' + ) + return f'
{inner}
' + + +def _html_processor(proc) -> str: + cls = _h(type(proc).__name__) + + # header + ndim_pill = ( + f'' + f'{proc.ndim}D' + ) + header = ( + f'
' + f'{cls}' + f'{ndim_pill}' + f'
' + ) + + # dims section (always open) + dim_content = _dim_rows_html(proc) + sections = _section("Dimensions", dim_content, + count=str(proc.ndim), open_=True) + + # always-visible footer rows + footer_pairs: list[tuple[str, str]] = [] + + if proc.window_order: + chain = " → ".join( + f'{_h(str(d))}' + if i > 0 else _h(str(d)) + for i, d in enumerate(proc.window_order) + ) + footer_pairs.append(("window order", f'{chain}')) + + if proc.spatial_func is not None: + footer_pairs.append(("spatial func", _code(_callable_name(proc.spatial_func)))) + + if footer_pairs: + sections += _footer_kv(footer_pairs) + + body = f'
{sections}
' + return f'{_CSS}
{header}{body}
' + + +def ndgraphic_fmt_html(ndg) -> str: + cls = _h(type(ndg).__name__) + gcls = _h(type(ndg.graphic).__name__) if ndg.graphic is not None else "—" + name = _h(ndg.name or "—") + + graphic_pill = f'graphic: {gcls}' + name_pill = f'name: {name}' + + header = ( + f'
' + f'{cls}' + f'·' + f'{graphic_pill}{name_pill}' + f'
' + ) + + # embed processor repr (without its own outer box) inside a section + proc_inner = _dim_rows_html(ndg.processor) + sections = _section("Processor · Dimensions", proc_inner, open_=True) + + footer_pairs: list[tuple[str, str]] = [] + + if ndg.processor.window_order: + chain = " → ".join( + f'{_h(str(d))}' + if i > 0 else _h(str(d)) + for i, d in enumerate(ndg.processor.window_order) + ) + footer_pairs.append(("window order", f'{chain}')) + + if ndg.processor.spatial_func is not None: + footer_pairs.append(("spatial func", _code(_callable_name(ndg.processor.spatial_func)))) + + if footer_pairs: + sections += _footer_kv(footer_pairs) + + body = f'
{sections}
' + return f'{_CSS}
{header}{body}
' + +class ReprMixin: + """ + Mixin that provides: + • __repr__ → coloured ANSI text (terminal / plain REPL) + • _repr_html_ → rich HTML (Jupyter) + • _repr_mimebundle_ → both, so Jupyter picks the richest format + + Subclasses must implement _repr_text_() and _repr_html_() themselves OR + rely on the dispatch below which checks the concrete type. + """ + + def _repr_text_(self) -> str: + # lazy import avoids circular; swap for a direct call in your module + if _is_ndgraphic(self): + return ndgraphic_fmt_txt(self) + return ndprocessor_fmt_txt(self) + + def _repr_html_(self) -> str: + return ndgraphic_fmt_html(self) + return _html_processor(self) + + def __repr__(self) -> str: + return self._repr_text_() + + def _repr_mimebundle_(self, **kwargs) -> dict: + return { + "text/plain": self._repr_text_(), + "text/html": self._repr_html_(), + } + + +def _is_ndgraphic(obj) -> bool: + """duck-type check: does this object have a .graphic and .processor?""" + return hasattr(obj, "graphic") and hasattr(obj, "processor") \ No newline at end of file From c354c674e210e3b340df6a5c4f78f0dfd06c08e7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 19 Mar 2026 04:52:09 -0400 Subject: [PATCH 094/163] remove unused attr, comments --- fastplotlib/widgets/nd_widget/_base.py | 12 ++++++++++-- .../widgets/nd_widget/_nd_positions/_nd_positions.py | 8 +++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 2fa60a5ed..bfbabb2f5 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -529,9 +529,17 @@ def __init__( ): self._subplot = subplot self._name = name - self._block_indices = False self._graphic: Graphic | None = None + # used to indicate that the NDGraphic should ignore any requests to update the indices + # used by block_indices_ctx context manager, usecase is when the LinearSelector on timeseries + # NDGraphic changes the selection, it shouldn't change the graphic that it is on top of! Would + # also cause recursion + # It is also used by the @block_reentrance decorator which is on the ``NDGraphic.indices`` property setter + # this is also to block recursion + self._block_indices = False + + def _create_graphic(self): raise NotImplementedError @@ -678,7 +686,7 @@ def _repr_text_(self): @contextmanager -def block_indices(ndgraphic: NDGraphic): +def block_indices_ctx(ndgraphic: NDGraphic): """ Context manager for pausing an NDGraphic from updating indices """ diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 6cb69a83d..f1699d1a4 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -27,7 +27,7 @@ NDGraphic, WindowFuncCallable, block_reentrance, - block_indices, + block_indices_ctx, ) from .._index import ReferenceIndex @@ -709,8 +709,6 @@ def __init__( else: self._linear_selector = None - self._pause = False - @property def processor(self) -> NDPositionsProcessor: return self._processor @@ -832,7 +830,7 @@ def indices(self, indices): self._last_x_range[:] = self.graphic._plot_area.x_range if self._linear_selector is not None: - with pause_events(self._linear_selector): + with pause_events(self._linear_selector): # we don't want the linear selector change to update the indices self._linear_selector.limits = xr # linear selector acts on `p` dim self._linear_selector.selection = indices[ @@ -840,7 +838,7 @@ def indices(self, indices): ] def _linear_selector_handler(self, ev): - with block_indices(self): + with block_indices_ctx(self): # linear selector always acts on the `p` dim self._ref_index[self.processor.spatial_dims[1]] = ev.info["value"] From 4e8b8f5d9750779c21fd5b9df5fccd3dc11230b3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 19 Mar 2026 18:04:27 -0400 Subject: [PATCH 095/163] remove print --- fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index f1699d1a4..2d3ff2b9a 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -513,7 +513,6 @@ def _get_other_features( if val_sliced.shape[0] == 1: # broadcast across all graphical elements n_graphics = self.shape[self.spatial_dims[0]] - print(val_sliced.shape, n_graphics) val_sliced = np.broadcast_to( val_sliced, shape=(n_graphics, *val_sliced.shape[1:]) ) From 0b1bc0bc31e7174f4c084ff0d4989c5968e6c58b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 20 Mar 2026 03:26:17 -0400 Subject: [PATCH 096/163] add NDGraphic.pause, expose histogram widget --- fastplotlib/widgets/nd_widget/_base.py | 12 ++++++++++++ fastplotlib/widgets/nd_widget/_index.py | 2 +- fastplotlib/widgets/nd_widget/_nd_image.py | 5 +++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index bfbabb2f5..04d8fc745 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -539,10 +539,22 @@ def __init__( # this is also to block recursion self._block_indices = False + # user settable bool to make the graphic unresponsive to change in the ReferenceIndex + self._pause = False + def _create_graphic(self): raise NotImplementedError + @property + def pause(self) -> bool: + """if True, changes in the reference until it is set back to False""" + return self._pause + + @pause.setter + def pause(self, val: bool): + self._pause = bool(val) + @property def name(self) -> str | None: """name given to the NDGraphic""" diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 6d7b17445..9d45c844c 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -201,7 +201,7 @@ def _clamp(self, dim, value): def _render_indices(self): for ndw in self._ndwidgets: for g in ndw.ndgraphics: - if g.data is None: + if g.data is None or g.pause: continue # only provide slider indices to the graphic g.indices = {d: self._indices[d] for d in g.processor.slider_dims} diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index c6292b68c..be319942d 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -523,6 +523,11 @@ def compute_histogram(self, v: bool): self.processor.compute_histogram = v self._reset_histogram() + @property + def histogram_widget(self) -> HistogramLUTTool: + """The histogram lut tool associated with this NDGraphic""" + return self._histogram_widget + @property def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: """get or set the spatial_func, see docstring for details""" From 3e755f67a8b54e029c2e620f8cd138ecd33b8b1b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 20 Mar 2026 03:26:35 -0400 Subject: [PATCH 097/163] ndg pause in imgui --- fastplotlib/widgets/nd_widget/_ui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index e5ba7daf8..2855c7063 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -211,6 +211,8 @@ def update(self): elif isinstance(ndg, NDImage): self._draw_nd_image_ui(subplot, ndg) + _, ndg.pause = imgui.checkbox("pause", ndg.pause) + if not open: self._ndgraphic_windows.remove(ndg) From 894de5524f1c8844f21058ed3b5422d164fc3173 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 20 Mar 2026 03:27:03 -0400 Subject: [PATCH 098/163] add helper function to convert heatmap timeseries to postional data shape --- fastplotlib/utils/functions.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index a839ed9d0..34062824a 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -477,3 +477,38 @@ def subsample_array( slices = tuple(slices) return np.asarray(arr[slices]) + + +def heatmap_to_positions(heatmap: np.ndarray, xvals: np.ndarray) -> np.ndarray: + """ + + Convert a heatmap of shape [n_rows, n_datapoints] to timeseries x-y data of shape [n_rows, n_datapoints, xy] + + Parameters + ---------- + heatmap: np.ndarray, shape [n_rows, n_datapoints] + timeseries data with a heatmap representation, where each column represents a timepoint. + + xvals: np.ndarray, shape: [n_datapoints,] + x-values for the columns in the heatmap + + Returns + ------- + np.ndarray, shape [n_rows, n_datapoints, 2] + timeseries data where the xy data are explicitly stored for every row + + """ + if heatmap.ndim != 2: + raise ValueError + + if xvals.ndim != 1: + raise ValueError + + if xvals.size != heatmap.shape[1]: + raise ValueError + + ts = np.empty((*heatmap.shape, 2), dtype=np.float32) + ts[..., 0] = xvals + ts[..., 1] = heatmap + + return ts From ef28878f103a3e910415337c1e85bf4e3cbdf1f6 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 20 Mar 2026 23:19:37 -0400 Subject: [PATCH 099/163] index wans't calling handlers --- fastplotlib/widgets/nd_widget/_index.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 9d45c844c..fc51c345c 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -188,6 +188,7 @@ def set(self, indices: dict[str, Any]): self._indices[dim] = self._clamp(dim, value) self._render_indices() + self._indices_changed() def _clamp(self, dim, value): if isinstance(self.ref_ranges[dim], RangeContinuous): @@ -215,6 +216,7 @@ def __setitem__(self, dim, value): # set index for given dim and render self._indices[dim] = self._clamp(dim, value) self._render_indices() + self._indices_changed() def _check_has_dim(self, dim): if dim not in self.dims: @@ -289,6 +291,10 @@ def clear_event_handlers(self): """Clear all registered event handlers""" self._indices_changed_handlers.clear() + def _indices_changed(self): + for f in self._indices_changed_handlers: + f(self._indices) + def __iter__(self): for index in self._indices.items(): yield index From 7778403a634d1b1cfa78b4d9eaa0b7beb56e64ab Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 22 Mar 2026 22:46:43 -0400 Subject: [PATCH 100/163] remove --- fastplotlib/widgets/nd_widget/_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 04d8fc745..932018f6e 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -14,7 +14,6 @@ from ...layouts import Subplot from ...utils import subsample_array, ArrayProtocol from ...graphics import Graphic -from ._repr_formatter import ndp_fmt_text, ndg_fmt_text, ndp_fmt_html, ndg_fmt_html from ._index import ReferenceIndex # must take arguments: array-like, `axis`: int, `keepdims`: bool From 3de9b23fa38342169d9623247924aa869fc10c5a Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 8 Apr 2026 04:22:02 -0400 Subject: [PATCH 101/163] allow image types other than float32 (#1027) --- fastplotlib/graphics/features/_image.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index af0783c71..681075ef2 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -1,5 +1,6 @@ from itertools import product from math import ceil +from warnings import warn import cmap as cmap_lib import numpy as np @@ -104,8 +105,11 @@ def _fix_data(self, data): "it must be of shape [rows, cols], [rows, cols, 3] or [rows, cols, 4]" ) - # let's just cast to float32 always - return data.astype(np.float32) + if data.itemsize == 8: + warn(f"casting {array.dtype} array to float32") + return data.astype(np.float32) + + return data def __iter__(self): self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices)) From bbd03f6850ccd43e6878391b2c9d129dd5cd57e0 Mon Sep 17 00:00:00 2001 From: Amol Pasarkar Date: Thu, 9 Apr 2026 12:59:17 -0400 Subject: [PATCH 102/163] Includes code for doing batched transforms in both directions (#1025) * Includes code for doing batched transforms in both directions * Streamlined parsing logic and updated docstrings for model to world code * Fixes inconsistent error messages * More streamlined type checking * Gets rid of checks and uses np asarray * Some more aesthetic updates to the syntax * Update fastplotlib/graphics/_base.py --------- Co-authored-by: Kushal Kolar --- fastplotlib/graphics/_base.py | 65 +++++++++++++++++------------------ 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index f2648dd8c..edccf2e8d 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -67,7 +67,6 @@ class Graphic: _fpl_support_tooltip: bool = True def __init_subclass__(cls, **kwargs): - # set of all features cls._features = { **cls._features, @@ -326,9 +325,7 @@ def _add_group_graphic_map(self, wo: pygfx.Group): # used by images since they create new WorldObject ImageTiles when a different buffer size is required # also used by GraphicCollections inititally, but not used for reseting like images for child in wo.children: - if isinstance( - child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line) - ): + if isinstance(child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line)): # unique 32 bit integer id for each world object global_id = child.id WORLD_OBJECT_TO_GRAPHIC[global_id] = self @@ -338,9 +335,7 @@ def _add_group_graphic_map(self, wo: pygfx.Group): def _remove_group_graphic_map(self, wo: pygfx.Group): # remove the children of the group to the WorldObject -> Graphic map for child in wo.children: - if isinstance( - child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line) - ): + if isinstance(child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line)): # unique 32 bit integer id for each world object global_id = child.id WORLD_OBJECT_TO_GRAPHIC.pop(global_id) @@ -528,6 +523,23 @@ def my_handler(event): feature = getattr(self, f"_{t}") feature.remove_event_handler(wrapper) + def _parse_positions(self, position: tuple | np.ndarray) -> np.ndarray: + """ + Converts position data (in the form of tuple or np.ndarray) into a (num_points, 3)-shaped np.ndarray for processing + """ + position = np.asarray(position) + + if position.ndim not in (1,2): + raise ValueError(f"position must be of shape (num_points, 3) or (3,)") + + if position.ndim == 1: + position = position[None, :] + + if position.shape[-1] != 3: + raise ValueError(f"position must be of shape (num_points, 3) or (3,)") + + return position + def map_model_to_world( self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray ) -> np.ndarray: @@ -536,27 +548,18 @@ def map_model_to_world( Parameters ---------- - position: (float, float, float) or (float, float) - (x, y, z) or (x, y) position. If z is not provided then the graphic's offset z is used. + position: tuple of (x, y, z) or np.ndarray of shape (num_points, 3) + The xyz positions we wish to map to world space Returns ------- np.ndarray - (x, y, z) position in world space - + either shape (3,) or (num_points, 3), specifying position in world space """ - - if len(position) == 2: - # use z of the graphic - position = [*position, self.offset[-1]] - - if len(position) != 3: - raise ValueError( - f"position must be tuple or array indicating (x, y, z) position in *model space*" - ) + position = self._parse_positions(position) # apply world transform to project from model space to world space - return la.vec_transform(position, self.world_object.world.matrix) + return la.vec_transform(position, self.world_object.world.matrix).squeeze() def map_world_to_model( self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray @@ -566,26 +569,20 @@ def map_world_to_model( Parameters ---------- - position: (float, float, float) or (float, float) - (x, y, z) or (x, y) position. If z is not provided then 0 is used. + position: tuple of (x, y, z) or np.ndarray of shape (num_points, 3) + The xyz positions we wish to map to model space Returns ------- np.ndarray - (x, y, z) position in world space + either shape (3,) or (num_points, 3), specifying position in model space """ + position = self._parse_positions(position) - if len(position) == 2: - # use z of the graphic - position = [*position, self.offset[-1]] - - if len(position) != 3: - raise ValueError( - f"position must be tuple or array indicating (x, y, z) position in *model space*" - ) - - return la.vec_transform(position, self.world_object.world.inverse_matrix) + return la.vec_transform( + position, self.world_object.world.inverse_matrix + ).squeeze() def format_pick_info(self, ev: pygfx.PointerEvent) -> str: """ From b918626c0fbd3a80036fc249b2bf4f9dab05d399 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Thu, 9 Apr 2026 15:39:03 -0400 Subject: [PATCH 103/163] async NDProcessor (#1026) * async NDProcessor established, NOT TESTED * ASYNC NDPROC IS WORKING :D :D CELEBRATE * comments * type annot * fix * fix * polish async integration, cuda also integrated * no longer using xarray, allow simpler ArrayProtocol * comments * comments * docs --- docs/source/user_guide/guide.rst | 2 + fastplotlib/__init__.py | 8 +- fastplotlib/graphics/features/_image.py | 2 +- fastplotlib/utils/__init__.py | 2 +- fastplotlib/utils/_protocols.py | 33 --- fastplotlib/utils/functions.py | 26 +- fastplotlib/utils/protocols.py | 62 ++++ fastplotlib/widgets/nd_widget/__init__.py | 11 +- fastplotlib/widgets/nd_widget/_async.py | 100 +++++++ fastplotlib/widgets/nd_widget/_base.py | 266 +++++++++++------- fastplotlib/widgets/nd_widget/_index.py | 38 ++- fastplotlib/widgets/nd_widget/_nd_image.py | 101 ++++--- .../nd_widget/_nd_positions/_nd_positions.py | 100 ++++--- .../nd_widget/_nd_positions/_pandas.py | 9 +- .../widgets/nd_widget/_nd_positions/utils.py | 0 fastplotlib/widgets/nd_widget/_ui.py | 4 +- 16 files changed, 515 insertions(+), 249 deletions(-) delete mode 100644 fastplotlib/utils/_protocols.py create mode 100644 fastplotlib/utils/protocols.py create mode 100644 fastplotlib/widgets/nd_widget/_async.py create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/utils.py diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index c3487de2e..5b6bbc7d5 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -21,6 +21,8 @@ With jupyterlab support. pip install -U "fastplotlib[notebook,imgui]" +.. note:: ``imgui-bundle`` is required for the ``NDWidget`` + Without imgui ^^^^^^^^^^^^^ diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index bde2c89e3..d975f4d0a 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -4,6 +4,13 @@ # this must be the first import for auto-canvas detection from .utils import loop # noqa +from .utils import ( + config, + enumerate_adapters, + select_adapter, + print_wgpu_report, + protocols, +) from .graphics import * from .graphics.features import GraphicFeatureEvent from .graphics.selectors import * @@ -20,7 +27,6 @@ from .layouts import Figure from .widgets import NDWidget, ImageWidget -from .utils import config, enumerate_adapters, select_adapter, print_wgpu_report if len(enumerate_adapters()) < 1: diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 681075ef2..27fd74196 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -106,7 +106,7 @@ def _fix_data(self, data): ) if data.itemsize == 8: - warn(f"casting {array.dtype} array to float32") + warn(f"casting {data.dtype} array to float32") return data.astype(np.float32) return data diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index 6f0059f6a..f2eed65b6 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -6,7 +6,7 @@ from .gpu import enumerate_adapters, select_adapter, print_wgpu_report from ._plot_helpers import * from .enums import * -from ._protocols import ArrayProtocol, ARRAY_LIKE_ATTRS +from .protocols import ARRAY_LIKE_ATTRS, ArrayProtocol, FutureProtocol, CudaArrayProtocol @dataclass diff --git a/fastplotlib/utils/_protocols.py b/fastplotlib/utils/_protocols.py deleted file mode 100644 index 95d7d2763..000000000 --- a/fastplotlib/utils/_protocols.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from typing import Any, Protocol, runtime_checkable - - -ARRAY_LIKE_ATTRS = [ - "__array__", - "__array_ufunc__", - "dtype", - "shape", - "ndim", - "__getitem__", -] - - -@runtime_checkable -class ArrayProtocol(Protocol): - def __array__(self) -> ArrayProtocol: ... - - def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): ... - - def __array_function__(self, func, types, *args, **kwargs): ... - - @property - def dtype(self) -> Any: ... - - @property - def ndim(self) -> int: ... - - @property - def shape(self) -> tuple[int, ...]: ... - - def __getitem__(self, key): ... diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 34062824a..97a3df742 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -6,6 +6,8 @@ from pygfx import Texture, Color +from .protocols import CudaArrayProtocol + cmap_catalog = cmap_lib.Catalog() @@ -405,9 +407,22 @@ def parse_cmap_values( return colors +def cuda_to_numpy(arr: CudaArrayProtocol) -> np.ndarray: + try: + import cupy + except ImportError: + raise ImportError( + "`cupy` is required to work with GPU arrays\npip install cupy" + ) + + return cupy.asnumpy(arr) + + def subsample_array( - arr: np.ndarray, max_size: int = 1e6, ignore_dims: Sequence[int] | None = None -): + arr: CudaArrayProtocol, + max_size: int = 1e6, + ignore_dims: Sequence[int] | None = None, +) -> np.ndarray: """ Subsamples an input array while preserving its relative dimensional proportions. @@ -476,7 +491,12 @@ def subsample_array( slices = tuple(slices) - return np.asarray(arr[slices]) + arr_sliced = arr[slices] + + if isinstance(arr_sliced, CudaArrayProtocol): + return cuda_to_numpy(arr_sliced) + + return arr_sliced def heatmap_to_positions(heatmap: np.ndarray, xvals: np.ndarray) -> np.ndarray: diff --git a/fastplotlib/utils/protocols.py b/fastplotlib/utils/protocols.py new file mode 100644 index 000000000..66df15ddd --- /dev/null +++ b/fastplotlib/utils/protocols.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Protocol, runtime_checkable + + +ARRAY_LIKE_ATTRS = [ + "__array__", + "__array_ufunc__", + "dtype", + "shape", + "ndim", + "__getitem__", +] + + +@runtime_checkable +class ArrayProtocol(Protocol): + """an object that is sufficiently array-like""" + + def __array__(self) -> ArrayProtocol: ... + + @property + def dtype(self) -> Any: ... + + @property + def ndim(self) -> int: ... + + @property + def shape(self) -> tuple[int, ...]: ... + + def __getitem__(self, key) -> ArrayProtocol: ... + + +@runtime_checkable +class CudaArrayProtocol(Protocol): + """an object that can be converted to a cupy array""" + + def __cuda_array_interface__(self) -> CudaArrayProtocol: ... + + +@runtime_checkable +class FutureProtocol(Protocol): + """An object that is sufficiently Future-like""" + + def cancel(self): ... + + def cancelled(self): ... + + def running(self): ... + + def done(self): ... + + def add_done_callback(self, fn: Callable): ... + + def result(self, timeout: float | None): ... + + def exception(self, timeout: float | None): ... + + def set_result(self, array: ArrayProtocol): ... + + def set_exception(self, exception): ... diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index 378f7dfcd..65f448b54 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -1,14 +1,7 @@ from ...layouts import IMGUI -try: - import imgui_bundle -except ImportError: - HAS_XARRAY = False -else: - HAS_XARRAY = True - -if IMGUI and HAS_XARRAY: +if IMGUI: from ._base import NDProcessor, NDGraphic from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras from ._nd_image import NDImageProcessor, NDImage @@ -19,6 +12,6 @@ class NDWidget: def __init__(self, *args, **kwargs): raise ModuleNotFoundError( - "NDWidget requires `imgui-bundle` and `xarray` to be installed.\n" + "NDWidget requires `imgui-bundle` to be installed.\n" "pip install imgui-bundle" ) diff --git a/fastplotlib/widgets/nd_widget/_async.py b/fastplotlib/widgets/nd_widget/_async.py new file mode 100644 index 000000000..5aa24a65f --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_async.py @@ -0,0 +1,100 @@ +from collections.abc import Generator +from concurrent.futures import Future + +from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol, cuda_to_numpy + + +class FutureArray(Future): + def __init__(self, shape, dtype, timeout: float = 1.0): + self._shape = shape + self._dtype = dtype + self._timeout = timeout + + super().__init__() + + @property + def shape(self) -> tuple[int, ...]: + return self._shape + + @property + def ndim(self) -> int: + return len(self.shape) + + @property + def dtype(self) -> str: + return self._dtype + + def __getitem__(self, item) -> ArrayProtocol: + return self.result(self._timeout)[item] + + def __array__(self) -> ArrayProtocol: + return self.result(self._timeout) + + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + raise NotImplementedError + + def __array_function__(self, func, types, *args, **kwargs): + raise NotImplementedError + + +# inspired by https://www.dabeaz.com/coroutines/ +def start_coroutine(func): + """ + Starts coroutines for async arrays wrapped by NDProcessor. + Used by all NDGraphic.set_indices and NDGraphic._create_graphic. + + It also immediately starts coroutines unless block=False is provided. It handles all the triage of possible + sync vs. async (Future-like) objects. + + The only time when block=False is when ReferenceIndex._render_indices uses it to loop through setting all + indices, and then collect and send the results back down to NDProcessor.get(). + """ + + def start( + self, *args, **kwargs + ) -> tuple[Generator, ArrayProtocol | CudaArrayProtocol | FutureProtocol] | None: + cr = func(self, *args, **kwargs) + try: + # begin coroutine + to_resolve: FutureProtocol | ArrayProtocol | CudaArrayProtocol = cr.send( + None + ) + except StopIteration: + # NDProcessor.get() has no `yield` expression, not async, nothing to return + return None + + block = kwargs.get("block", True) + timeout = kwargs.get("timeout", 1.0) + + if block: # resolve Future immediately + try: + if isinstance(to_resolve, FutureProtocol): + # array is async, resolve future and send + cr.send(to_resolve.result(timeout=timeout)) + elif isinstance(to_resolve, CudaArrayProtocol): + # array is on GPU, it is technically and on GPU, convert to numpy array on CPU + cr.send(cuda_to_numpy(to_resolve)) + else: + # not async, just send the array + cr.send(to_resolve) + except StopIteration: + pass + + else: # no block, probably resolving multiple futures simultaneously + if isinstance(to_resolve, FutureProtocol): + # data is async, return coroutine generator and future + # ReferenceIndex._render_indices() will manage them and wait to gather all futures + return cr, to_resolve + elif isinstance(to_resolve, CudaArrayProtocol): + # it is async technically, but it's a GPU array, ReferenceIndex._render_indices will manage it + return cr, to_resolve + else: + # not async, just send the array + try: + cr.send(to_resolve) + except ( + StopIteration + ): # has to be here because of the yield expression, i.e. it's a generator + pass + + return start diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 932018f6e..5c2747d2b 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -1,23 +1,24 @@ -from collections.abc import Callable, Hashable, Sequence +from collections.abc import Callable, Sequence, Generator from contextlib import contextmanager import inspect from numbers import Real from pprint import pformat import textwrap -from typing import Literal, Any, Type -from warnings import warn +from typing import Any -import xarray as xr import numpy as np from numpy.typing import ArrayLike from ...layouts import Subplot -from ...utils import subsample_array, ArrayProtocol +from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol from ...graphics import Graphic -from ._index import ReferenceIndex # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] +# [YieldType, SendType, ReturnType] +AwaitedArray = Generator[ + FutureProtocol | ArrayProtocol | CudaArrayProtocol, ArrayProtocol, ArrayProtocol +] def identity(index: int) -> int: @@ -27,27 +28,26 @@ def identity(index: int) -> int: class NDProcessor: def __init__( self, - data: Any, - dims: Sequence[Hashable], - spatial_dims: Sequence[Hashable] | None, - slider_dim_transforms: dict[Hashable, Callable[[Any], int] | ArrayLike] = None, + data: ArrayProtocol, + dims: Sequence[str], + spatial_dims: Sequence[str] | None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, window_funcs: dict[ - Hashable, tuple[WindowFuncCallable | None, int | float | None] + str, tuple[WindowFuncCallable | None, int | float | None] ] = None, - window_order: tuple[Hashable, ...] = None, + window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, ): """ Base class for managing n-dimensional data and producing array slices. - By default, wraps input data into an ``xarray.DataArray`` and provides an interface - for indexing slider dimensions, applying window functions, spatial functions, and mapping - reference-space values to local array indices. Subclasses must implement - :meth:`get`, which is called whenever the :class:`ReferenceIndex` updates. + Wraps array-like ``data`` and provides an interface for indexing slider dimensions, applying window functions, + spatial functions, and mapping reference-space values to local array indices. Subclasses must implement + :meth:`get`, which is called when the :class:`ReferenceIndex` updates. - Subclasses can implement any type of data representation, they do not necessarily need to be compatible with - (they dot not have to be xarray compatible). However their ``get()`` method must still return a data slice that - corresponds to the graphical representation they map to. + Subclasses can implement any type of data representation, they do not necessarily need to be array-like. + However their ``get()`` method must still return a data slice that corresponds to the graphical representation + they map to. Every dimension that is *not* listed in ``spatial_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the @@ -56,7 +56,7 @@ def __init__( Parameters ---------- - data: Any + data: ArrayProtocol data object that is managed, usually uses the ArrayProtocol. Custom subclasses can manage any kind of data object but the corresponding :meth:`get` must return an array-like that maps to a graphical representation. @@ -73,8 +73,8 @@ def __init__( must operate as if these dimensions exist and return an array that matches the spatial dimensions. spatial_dims: Sequence[str] - Subset of ``dims`` that are spatial (rendered) dimensions **in order**. All remaining dims are treated as - slider dims. See subclass for specific info. + Subset of ``dims`` that are spatial (rendered) dimensions **in display order**. All remaining dims are + treated as slider dims. See subclass for specific info. slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None Per-slider-dim mapping from reference-space values to local array indices. @@ -88,7 +88,7 @@ def __init__( If a transform is not provided for a dim then the identity mapping is used. window_funcs: dict[ - Hashable, tuple[WindowFuncCallable | None, int | float | None] + str, tuple[WindowFuncCallable | None, int | float | None] ] Per-slider-dim window functions applied around the current slider position. Ex: {"time": (np.mean, 2.5)}. Each value is a ``(func, window_size)`` pair where: @@ -101,7 +101,7 @@ def __init__( * *window_size* is in reference-space units (ex: 2.5 seconds). - window_order: tuple[Hashable, ...] + window_order: tuple[str, ...] Order in which window functions are applied across dims. Only dims listed here have their window function applied. window_funcs are ignored for any dims not specified in ``window_order`` @@ -110,8 +110,13 @@ def __init__( A function applied to the spatial slice *after* window_funcs right before rendering. """ - self._dims = tuple(dims) - self._data = self._validate_data(data) + dims = tuple(dims) + if not all([isinstance(d, str) for d in dims]): + raise TypeError + + self._dims = dims + + self.data = data self.spatial_dims = spatial_dims self.slider_dim_transforms = slider_dim_transforms @@ -121,7 +126,7 @@ def __init__( self.spatial_func = spatial_func @property - def data(self) -> xr.DataArray: + def data(self) -> ArrayProtocol: """ get or set managed data. If setting with new data, the new data is interpreted to have the same dims (i.e. same dim names and ordering of dims). @@ -130,28 +135,26 @@ def data(self) -> xr.DataArray: @data.setter def data(self, data: ArrayProtocol): - self._data = self._validate_data(data) + # data can be set, but the dims must still match/have the same meaning - def _validate_data(self, data: ArrayProtocol): - # does some basic validation if data is None: # we allow data to be None, in this case no ndgraphic is rendered # useful when we want to initialize an NDWidget with no traces for example # and populate it as components/channels are selected - return None + self._data = None + return if not isinstance(data, ArrayProtocol): - # This is required for xarray compatibility and general array-like requirements + # check for general array-like requirements raise TypeError("`data` must implement the ArrayProtocol") if data.ndim != len(self.dims): raise IndexError("must specify a dim for every dimension in the data array") - # data can be set, but the dims must still match/have the same meaning - return xr.DataArray(data, dims=self.dims) + self._data = data @property - def shape(self) -> dict[Hashable, int]: + def shape(self) -> dict[str, int]: """interpreted shape of the data""" return {d: n for d, n in zip(self.dims, self.data.shape)} @@ -161,21 +164,21 @@ def ndim(self) -> int: return self.data.ndim @property - def dims(self) -> tuple[Hashable, ...]: - """dim names""" + def dims(self) -> tuple[str, ...]: + """dim names, **ordered as laid out in the array**""" # these are read-only and cannot be set after it's created # the user should create a new NDGraphic if they need different dims - # I can't think of a usecase where we'd want to change the dims, and + # I can't think of a use case where we'd want to change the dims, and # I think that would be complicated and probably and anti-pattern return self._dims @property - def spatial_dims(self) -> tuple[Hashable, ...]: - """Spatial dims, **in order**""" + def spatial_dims(self) -> tuple[str, ...]: + """Spatial dims, **in display order**""" return self._spatial_dims @spatial_dims.setter - def spatial_dims(self, sdims: Sequence[Hashable]): + def spatial_dims(self, sdims: Sequence[str]): for dim in sdims: if dim not in self.dims: raise KeyError @@ -196,8 +199,8 @@ def tooltip_format(self, *args) -> str | None: return None @property - def slider_dims(self) -> set[Hashable]: - """Slider dim names, ``set(dims) - set(spatial_dims)""" + def slider_dims(self) -> set[str]: + """Slider dim names, ``set(dims) - set(spatial_dims), **unordered**""" return set(self.dims) - set(self.spatial_dims) @property @@ -208,7 +211,7 @@ def n_slider_dims(self): @property def window_funcs( self, - ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]]: + ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: """get or set window functions, see docstring for details""" return self._window_funcs @@ -216,7 +219,7 @@ def window_funcs( def window_funcs( self, window_funcs: ( - dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None] + dict[str, tuple[WindowFuncCallable | None, int | float | None] | None] | None ), ): @@ -265,12 +268,12 @@ def window_funcs( self._window_funcs = window_funcs @property - def window_order(self) -> tuple[Hashable, ...]: + def window_order(self) -> tuple[str, ...]: """get or set dimension order in which window functions are applied""" return self._window_order @window_order.setter - def window_order(self, order: tuple[Hashable] | None): + def window_order(self, order: tuple[str] | None): if order is None: self._window_order = tuple() return @@ -284,13 +287,13 @@ def window_order(self, order: tuple[Hashable] | None): self._window_order = tuple(order) @property - def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: """get or set the spatial function which is applied on the data slice after the window functions""" return self._spatial_func @spatial_func.setter def spatial_func( - self, func: Callable[[xr.DataArray], xr.DataArray] + self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: if not callable(func) and func is not None: raise TypeError @@ -298,13 +301,13 @@ def spatial_func( self._spatial_func = func @property - def slider_dim_transforms(self) -> dict[Hashable, Callable[[Any], int]]: + def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: """get or set the slider_dim_transforms, see docstring for details""" return self._index_mappings @slider_dim_transforms.setter def slider_dim_transforms( - self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None + self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None ): if maps is None: self._index_mappings = {d: identity for d in self.dims} @@ -340,9 +343,9 @@ def _ref_index_to_array_index(self, dim: str, ref_index: Any) -> int: # clamp between 0 and array size in this dim return max(min(index, self.shape[dim] - 1), 0) - def _get_slider_dims_indexer(self, indices: dict[Hashable, Any]) -> dict[Hashable, slice]: + def _get_slider_dims_indexer(self, indices: dict[str, Any]) -> dict[str, slice]: """ - Creates an xarray-compatible indexer dict mapping each slider_dim -> slice object. + Creates an indexer dict mapping each slider_dim -> slice object. - If a window_func is defined for a dim and the dim appears in ``window_order``, the slice is defined as: @@ -363,14 +366,14 @@ def _get_slider_dims_indexer(self, indices: dict[Hashable, Any]) -> dict[Hashabl Parameters ---------- - indices : dict[Hashable, Any], {dim: ref_value} + indices : dict[str, Any], {dim: ref_value} Reference-space values for each slider dim. Must contain an entry for every slider dim; raises ``IndexError`` otherwise. ex: {"time": 46.397, "depth": 23.24} Returns ------- - dict[Hashable, slice] + dict[str, slice] Indexer compatible for ``xr.DataArray.isel()``, with one ``slice`` per slider dim. These are array indices mapped from the reference space using the given ``slider_dim_transform``. @@ -433,34 +436,24 @@ def _get_slider_dims_indexer(self, indices: dict[Hashable, Any]) -> dict[Hashabl return indexer - def _apply_window_functions(self, indices: dict[Hashable, Any]) -> xr.DataArray: + def _apply_window_functions(self, windowed_array: ArrayProtocol) -> ArrayProtocol: """ - Slice the data at the given indices and apply window functions in the order specified by + apply window functions in the order specified by ``window_order``. Parameters ---------- - indices : dict[Hashable, Any], {dim: ref_value} - Reference-space values for each slider dim. - ex: {"time": 46.397, "depth": 23.24} + windowed_array: ArrayProtocol + array that has been sliced with the desired windows at an index Returns ------- - xr.DataArray + ArrayProtocol Data slice after windowed indexing and window function application, with the same dims as the original data. Dims of size ``1`` are not squeezed. """ - indexer = self._get_slider_dims_indexer(indices) - - # get the data slice w.r.t. the desired windows, and get the underlying numpy array - # ``.values`` gives the numpy array - # there is significant overhead with passing xarray objects to numpy for things like np.mean() - # so convert to numpy, apply window functions, then convert back to xarray - # creating an xarray object from a numpy array has very little overhead, ~10 microseconds - array = self.data.isel(indexer).values - # apply window funcs in the specified order for dim in self.window_order: if self.window_funcs[dim] is None: @@ -472,11 +465,72 @@ def _apply_window_functions(self, indices: dict[Hashable, Any]) -> xr.DataArray: # ``keepdims`` means the resultant shape is [1, 512, 512] and NOT [512, 512] # this is necessary for applying window functions on multiple dims separately and so that the # dims names correspond after all the window funcs are applied. - array = func(array, axis=self.dims.index(dim), keepdims=True) + windowed_array = func( + windowed_array, axis=self.dims.index(dim), keepdims=True + ) + + return windowed_array + + def get_window_output(self, indices: dict[str, Any]) -> AwaitedArray: + """ + Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims + + Parameters + ---------- + indices - return xr.DataArray(array, dims=self.dims) + Returns + ------- - def get(self, indices: dict[Hashable, Any]): + """ + # windowed slice if user set any window funcs + windowed_slice = yield from self._get_raw_data_slice(indices) + + # convert to numpy array + windowed_slice = np.asarray(windowed_slice) + + # apply window funcs + if len(self.slider_dims) > 0: + windowed_slice = self._apply_window_functions(windowed_slice) + + # squeeze out all slider dims which should now be size 1 + # set(dims) - set(spatial_dims) since some spatial dims can also be slider, so get only pure non-spatial dims + slider_dims_int = tuple( + self.dims.index(d) for d in set(self.dims) - set(self.spatial_dims) + ) + windowed_slice = windowed_slice.squeeze(axis=slider_dims_int) + + if windowed_slice.ndim != len(self.spatial_dims): + raise ValueError + + # transpose to spatial dims + spatial_dims_int = tuple( + self.spatial_dims.index(d) for d in self.dims if d in self.spatial_dims + ) + + return windowed_slice.transpose(spatial_dims_int) + + def _get_raw_data_slice(self, indices: dict[str, Any]) -> AwaitedArray: + """ + Base implementation to get the raw data slice from the wrapped array. + Always yields to support async getters. + """ + if len(self.slider_dims) > 0: + indexer = self._get_slider_dims_indexer(indices) + # get the data slice w.r.t. the desired windows + # yield so this is async if the underlying array returns a FutureArray-like + # we convert to a numpy array outside, not here, since that resolves the Future + index_tuple = tuple(indexer.get(dim, slice(None)) for dim in self.dims) + raw_slice = yield self.data[index_tuple] + + else: + # return everything directly + # request a slice of everything with [:] so that any data fetching, compute, etc. is actually done + raw_slice = yield self.data[:] + + return raw_slice + + def get(self, indices: dict[str, Any]) -> AwaitedArray | ArrayProtocol: raise NotImplementedError # TODO: html and pretty text repr # @@ -491,10 +545,7 @@ def get(self, indices: dict[Hashable, Any]): def _repr_text_(self): if self.data is None: - return ( - f"{self.__class__.__name__}\n" - f"data is None, dims: {self.dims}" - ) + return f"{self.__class__.__name__}\n" f"data is None, dims: {self.dims}" tab = "\t" wf = {k: v for k, v in self.window_funcs.items() if v != (None, None)} @@ -541,7 +592,6 @@ def __init__( # user settable bool to make the graphic unresponsive to change in the ReferenceIndex self._pause = False - def _create_graphic(self): raise NotImplementedError @@ -568,12 +618,22 @@ def graphic(self) -> Graphic: raise NotImplementedError @property - def indices(self) -> dict[Hashable, Any]: + def indices(self) -> dict[str, Any]: raise NotImplementedError - @indices.setter - def indices(self, new: dict[Hashable, Any]): - raise NotImplementedError + def set_indices( + self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 + ): + pass + + def _get_data_slice(self, indices): + """gets current data slice from NDProcessor, resolves Futures if necessary""" + data_slice = self.processor.get(indices) + + if isinstance(data_slice, Generator): + data_slice = yield from data_slice + + return data_slice # aliases for easier access to processor properties @property @@ -596,10 +656,10 @@ def data(self, data: Any): self._create_graphic() # force a render - self.indices = self.indices + self.set_indices(self.indices) @property - def shape(self) -> dict[Hashable, int]: + def shape(self) -> dict[str, int]: """interpreted shape of the data""" return self.processor.shape @@ -609,7 +669,7 @@ def ndim(self) -> int: return self.processor.ndim @property - def dims(self) -> tuple[Hashable, ...]: + def dims(self) -> tuple[str, ...]: """dim names""" return self.processor.dims @@ -620,27 +680,27 @@ def spatial_dims(self) -> tuple[str, ...]: raise NotImplementedError @property - def slider_dims(self) -> set[Hashable]: + def slider_dims(self) -> set[str]: """the slider dims""" return self.processor.slider_dims @property - def slider_dim_transforms(self) -> dict[Hashable, Callable[[Any], int]]: + def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: return self.processor.slider_dim_transforms @slider_dim_transforms.setter def slider_dim_transforms( - self, maps: dict[Hashable, Callable[[Any], int] | ArrayLike | None] | None + self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None ): """get or set the slider_dim_transforms, see docstring for details""" self.processor.slider_dim_transforms = maps # force a render - self.indices = self.indices + self.set_indices(self.indices) @property def window_funcs( self, - ) -> dict[Hashable, tuple[WindowFuncCallable | None, int | float | None]]: + ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: """get or set window functions, see docstring for details""" return self.processor.window_funcs @@ -648,37 +708,38 @@ def window_funcs( def window_funcs( self, window_funcs: ( - dict[Hashable, tuple[WindowFuncCallable | None, int | float | None] | None] + dict[str, tuple[WindowFuncCallable | None, int | float | None] | None] | None ), ): self.processor.window_funcs = window_funcs # force a render - self.indices = self.indices + self.set_indices(self.indices) @property - def window_order(self) -> tuple[Hashable, ...]: + def window_order(self) -> tuple[str, ...]: """get or set dimension order in which window functions are applied""" return self.processor.window_order @window_order.setter - def window_order(self, order: tuple[Hashable] | None): + def window_order(self, order: tuple[str] | None): self.processor.window_order = order # force a render - self.indices = self.indices + self.set_indices(self.indices) @property - def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + """get or set the spatial_func, see docstring for details""" return self.processor.spatial_func @spatial_func.setter def spatial_func( - self, func: Callable[[xr.DataArray], xr.DataArray] + self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: """get or set the spatial_func, see docstring for details""" self.processor.spatial_func = func # force a render - self.indices = self.indices + self.set_indices(self.indices) # def _repr_text_(self) -> str: # return ndg_fmt_text(self) @@ -693,7 +754,10 @@ def spatial_func( # } def _repr_text_(self): - return f"graphic: {self.graphic.__class__.__name__}\n" f"processor:\n{self.processor}" + return ( + f"graphic: {self.graphic.__class__.__name__}\n" + f"processor:\n{self.processor}" + ) @contextmanager @@ -713,7 +777,7 @@ def block_indices_ctx(ndgraphic: NDGraphic): def block_reentrance(setter): # decorator to block re-entrance of indices setter - def set_indices_wrapper(self: NDGraphic, new_indices): + def set_indices_wrapper(self: NDGraphic, *args, **kwargs): """ wraps NDGraphic.indices @@ -727,7 +791,7 @@ def set_indices_wrapper(self: NDGraphic, new_indices): try: # block re-execution of set_value until it has *fully* finished executing self._block_indices = True - setter(self, new_indices) + return setter(self, *args, **kwargs) except Exception as exc: # raise original exception raise exc # set_value has raised. The line above and the lines 2+ steps below are probably more relevant! diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index fc51c345c..24a42999c 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Generator +from concurrent.futures import wait from dataclasses import dataclass from numbers import Number from typing import Sequence, Any, Callable @@ -9,6 +11,8 @@ if TYPE_CHECKING: from ._ndwidget import NDWidget +from ...utils import FutureProtocol, CudaArrayProtocol, cuda_to_numpy + @dataclass class RangeContinuous: @@ -200,12 +204,44 @@ def _clamp(self, dim, value): return value def _render_indices(self): + pending_futures = list() + pending_cuda = list() + for ndw in self._ndwidgets: for g in ndw.ndgraphics: if g.data is None or g.pause: continue # only provide slider indices to the graphic - g.indices = {d: self._indices[d] for d in g.processor.slider_dims} + indices = {d: self._indices[d] for d in g.processor.slider_dims} + to_resolve: None | tuple[Generator, FutureProtocol] = g.set_indices(indices, block=True) + + if to_resolve is not None: + if isinstance(to_resolve[1], FutureProtocol): + # it's a future that we need to resolve + pending_futures.append(to_resolve) + elif isinstance(to_resolve[1], CudaArrayProtocol): + pending_cuda.append(to_resolve) + + if not pending_futures and not pending_cuda: + # no futures or gpu arrays to resolve, everything is sync + return + + # resolve futures + wait([future for cr, future in pending_futures], timeout=2) + + for cr, future in pending_futures: + try: + cr.send(future.result()) + except StopIteration: + pass + + # resolve GPU arrays + for cr, gpu_arr in pending_cuda: + try: + arr = cuda_to_numpy(gpu_arr) + cr.send(arr) + except StopIteration: + pass def __getitem__(self, dim): self._check_has_dim(dim) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index be319942d..9fa39606d 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -1,24 +1,23 @@ -from collections.abc import Hashable, Sequence -import inspect +from collections.abc import Sequence, Generator from typing import Callable, Any import numpy as np from numpy.typing import ArrayLike -import xarray as xr from ...layouts import Subplot -from ...utils import subsample_array, ArrayProtocol, ARRAY_LIKE_ATTRS +from ...utils import subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol from ...graphics import ImageGraphic, ImageVolumeGraphic from ...tools import HistogramLUTTool -from ._base import NDProcessor, NDGraphic, WindowFuncCallable +from ._base import NDProcessor, NDGraphic, WindowFuncCallable, block_reentrance, AwaitedArray from ._index import ReferenceIndex +from ._async import start_coroutine class NDImageProcessor(NDProcessor): def __init__( self, data: ArrayProtocol | None, - dims: Sequence[Hashable], + dims: Sequence[str], spatial_dims: ( tuple[str, str] | tuple[str, str, str] ), # must be in order! [rows, cols] | [z, rows, cols] @@ -60,12 +59,12 @@ def __init__( ``("row", "col")`` ``("other_dim", "depth", "time", "row", "col")`` - dims in the array do not need to be in order, for example you can have a weird array where the dims are - interpreted as: ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")`` - thanks to xarray magic =D. + dims in the array do not need to be in the order that you want to display them, for example you can have a + weird array where the dims are interpreted as: + ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")``. spatial_dims : tuple[str, str] | tuple[str, str, str] - The 2 or 3 spatial dimensions **in order**: ``(rows, cols)`` or ``(z, rows, cols)``. + The 2 or 3 spatial dimensions **in display order**: ``(rows, cols)`` or ``(z, rows, cols)``. This also determines whether an ``ImageGraphic`` or ``ImageVolumeGraphic`` is used for rendering. The ordering determines how the Image/Volume is rendered. For example, if you specify ``spatial_dims = ("rows", "cols")`` and then change it to ``("cols", "rows")``, it will display @@ -124,7 +123,7 @@ def __init__( self._recompute_histogram() @property - def data(self) -> xr.DataArray | None: + def data(self) -> ArrayProtocol | None: """ get or set managed data. If setting with new data, the new data is interpreted to have the same dims (i.e. same dim names and ordering of dims). @@ -133,12 +132,8 @@ def data(self) -> xr.DataArray | None: @data.setter def data(self, data: ArrayProtocol): - self._data = self._validate_data(data) - self._recompute_histogram() - - def _validate_data(self, data: ArrayProtocol): if not isinstance(data, ArrayProtocol): - # check that it's compatible with array and generally array-like + # check that it's generally array-like raise TypeError( f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" f"{ARRAY_LIKE_ATTRS}, or they must be `None`" @@ -150,7 +145,31 @@ def _validate_data(self, data: ArrayProtocol): f"Image data must have a minimum of 2 dimensions, you have passed an array of shape: {data.shape}" ) - return xr.DataArray(data, dims=self.dims) + self._data = data + self._recompute_histogram() + + @property + def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: + """ + Spatial dims, **in display order**. + + [row_dim, col_dim] or [row_dim, col_dim, rgb(a) dim] + """ + return self._spatial_dims + + @spatial_dims.setter + def spatial_dims(self, sdims: tuple[str, str] | tuple[str, str, str]): + for dim in sdims: + if dim not in self.dims: + raise KeyError + + if len(sdims) not in (2, 3): + raise ValueError( + f"There must be 2 or 3 spatial dims for images indicating [row_dim, col_dim] or " + f"[row_dims, col_dim, rgb(a) dim]. You passed: {sdims}" + ) + + self._spatial_dims = tuple(sdims) @property def rgb_dim(self) -> str | None: @@ -192,7 +211,7 @@ def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: """ return self._histogram - def get(self, indices: dict[str, Any]) -> ArrayLike | None: + def get(self, indices: dict[str, Any]) -> AwaitedArray: """ Get the data at the given index, process data through the window functions. @@ -206,15 +225,8 @@ def get(self, indices: dict[str, Any]) -> ArrayLike | None: Example: get((100, 5)) """ - if len(self.slider_dims) > 0: - # there are dims in addition to the spatial dims - window_output = self._apply_window_functions(indices).squeeze() - else: - # no slider dims, use all the data - window_output = self.data - - if window_output.ndim != len(self.spatial_dims): - raise ValueError + # this will be squeezed output, with dims in the order of the user set spatial dims + window_output = yield from self.get_window_output(indices) # apply spatial_func if self.spatial_func is not None: @@ -222,9 +234,9 @@ def get(self, indices: dict[str, Any]) -> ArrayLike | None: if spatial_out.ndim != len(self.spatial_dims): raise ValueError - return spatial_out.transpose(*self.spatial_dims).values + return spatial_out - return window_output.transpose(*self.spatial_dims).values + return window_output def _recompute_histogram(self): """ @@ -251,10 +263,6 @@ def _recompute_histogram(self): sub = subsample_array(self.data, ignore_dims=ignore_dims) - if isinstance(sub, xr.DataArray): - # can't do the isnan and isinf boolean indexing below on xarray - sub = sub.values - sub_real = sub[~(np.isnan(sub) | np.isinf(sub))] self._histogram = np.histogram(sub_real, bins=100) @@ -381,6 +389,7 @@ def graphic( """Underlying Graphic object used to display the current data slice""" return self._graphic + @start_coroutine def _create_graphic(self): # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, # adds it to the subplot, and resets the camera and histogram. @@ -401,7 +410,8 @@ def _create_graphic(self): # get the data slice for this index # this will only have the dims specified by ``spatial_dims`` - data_slice = self.processor.get(self.indices) + + data_slice = yield from self._get_data_slice(self.indices) # create the new graphic new_graphic = cls(data_slice) @@ -492,7 +502,11 @@ def _reset_camera(self): @property def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: - """get or set the spatial dims, see docstring for details""" + """ + get or set the spatial dims **in order** + + [row_dim, col_dim] or [row_dim, col_dim, rgb(a) dim] + """ return self.processor.spatial_dims @spatial_dims.setter @@ -503,13 +517,16 @@ def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): self._create_graphic() @property - def indices(self) -> dict[Hashable, Any]: + def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - @indices.setter - def indices(self, indices): - data_slice = self.processor.get(indices) + @block_reentrance + @start_coroutine + def set_indices( + self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 + ): + data_slice = yield from self._get_data_slice(indices) self.graphic.data = data_slice @@ -529,13 +546,15 @@ def histogram_widget(self) -> HistogramLUTTool: return self._histogram_widget @property - def spatial_func(self) -> Callable[[xr.DataArray], xr.DataArray] | None: + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: """get or set the spatial_func, see docstring for details""" + # this is here even though it's the same in the base class since we can't create the image specific setter + # without also defining the property in this subclass. return self.processor.spatial_func @spatial_func.setter def spatial_func( - self, func: Callable[[xr.DataArray], xr.DataArray] + self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: self.processor.spatial_func = func self.processor._recompute_histogram() diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 2d3ff2b9a..3941e2d02 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -1,4 +1,4 @@ -from collections.abc import Callable, Hashable, Sequence +from collections.abc import Callable, Hashable, Sequence, Generator from functools import partial from typing import Literal, Any, Type from warnings import warn @@ -6,11 +6,9 @@ import numpy as np from numpy.lib.stride_tricks import sliding_window_view from numpy.typing import ArrayLike -import xarray as xr from ....layouts import Subplot from ....graphics import ( - Graphic, ImageGraphic, LineGraphic, LineStack, @@ -29,7 +27,9 @@ block_reentrance, block_indices_ctx, ) +from ....utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol from .._index import ReferenceIndex +from .._async import start_coroutine # types for the other features FeatureCallable = Callable[[np.ndarray, slice], np.ndarray] @@ -37,6 +37,12 @@ MarkersType = Sequence[str] | np.ndarray | FeatureCallable | None SizesType = Sequence[float] | np.ndarray | FeatureCallable | None +AwaitedPositionData = Generator[ + FutureProtocol | ArrayProtocol | CudaArrayProtocol, + ArrayProtocol, + dict[str, ArrayProtocol], +] + def default_cmap_transform_each(p: int, data_slice: np.ndarray, s: slice): # create a cmap transform based on the `p` dim size @@ -57,10 +63,10 @@ class NDPositionsProcessor(NDProcessor): def __init__( self, data: Any, - dims: Sequence[Hashable], + dims: Sequence[str], # TODO: allow stack_dim to be None and auto-add new dim of size 1 in get logic spatial_dims: tuple[ - Hashable | None, Hashable, Hashable + str | None, str, str ], # [stack_dim, n_datapoints, spatial_dim], IN ORDER!! slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, display_window: int | float | None = 100, # window for n_datapoints dim only @@ -286,6 +292,7 @@ def sizes(self, new: SizesType): @property def spatial_dims(self) -> tuple[str, str, str]: + """get or set the spatial dims, **in display order**""" return self._spatial_dims @spatial_dims.setter @@ -391,20 +398,18 @@ def _get_dw_slice(self, indices: dict[str, Any]) -> slice: return slice(start, stop, step) - def _apply_dw_window_func( - self, array: xr.DataArray | np.ndarray - ) -> xr.DataArray | np.ndarray: + def _apply_dw_window_func(self, array: ArrayProtocol) -> ArrayProtocol: """ Takes array where display window has already been applied and applies window functions on the `p` dim. Parameters ---------- - array: np.ndarray + array: ArrayProtocol array of shape: [l, display_window, 2 | 3] Returns ------- - np.ndarray + ArrayProtocol array with window functions applied along `p` dim """ if self.display_window == 0: @@ -456,17 +461,19 @@ def _apply_dw_window_func( return wf(windows, axis=-1)[:, ::step] # map user dims str to tuple of numerical dims - dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) + coor_dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) # windows will be of shape [n, (p - ws + 1), 1 | 2 | 3, ws] - windows = sliding_window_view(array[..., dims], ws, axis=-2).squeeze() + windows = sliding_window_view( + array[..., coor_dims], ws, axis=-2 + ).squeeze() # make a copy because we need to modify it array = array[:, start:stop].copy() # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary - array[..., dims] = wf(windows, axis=-1).reshape( - *array.shape[:-1], len(dims) + array[..., coor_dims] = wf(windows, axis=-1).reshape( + *array.shape[:-1], len(coor_dims) ) return array[:, ::step] @@ -475,20 +482,18 @@ def _apply_dw_window_func( return array[:, ::step] - def _apply_spatial_func( - self, array: xr.DataArray | np.ndarray - ) -> xr.DataArray | np.ndarray: + def _apply_spatial_func(self, array: ArrayProtocol) -> ArrayProtocol: if self.spatial_func is not None: return self.spatial_func(array) return array - def _finalize_(self, array: xr.DataArray | np.ndarray) -> xr.DataArray | np.ndarray: + def _finalize(self, array: ArrayProtocol) -> ArrayProtocol: return self._apply_spatial_func(self._apply_dw_window_func(array)) def _get_other_features( - self, data_slice: np.ndarray, dw_slice: slice - ) -> dict[str, np.ndarray]: + self, data_slice: ArrayProtocol, dw_slice: slice + ) -> dict[str, ArrayProtocol]: other = dict.fromkeys(self._other_features) for attr in self._other_features: val = getattr(self, attr) @@ -521,39 +526,26 @@ def _get_other_features( return other - def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + def get(self, indices: dict[str, Any]) -> AwaitedPositionData: """ slices through all slider dims and outputs an array that can be used to set graphic data Note that we do not use __getitem__ here since the index is a tuple specifying a single integer index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. """ - - if len(self.slider_dims) > 1: - # there are slider dims in addition to the datapoints_dim - window_output = self._apply_window_functions(indices).squeeze() - else: - # no slider dims, use all the data - window_output = self.data - - # verify window output only has the spatial dims - if not set(window_output.dims) == set(self.spatial_dims): - raise IndexError + # already squeezed and in the correct spatial_dims order + window_output = yield from self.get_window_output(indices) # get slice obj for display window dw_slice = self._get_dw_slice(indices) # data that will be used for the graphical representation - # a copy is made, if there were no window functions then this is a view of the original data - p_dim = self.spatial_dims[1] - # slice the datapoints to be displayed in the graphic using the display window slice - # transpose to match spatial dims order, get numpy array, this is a view - graphic_data = window_output.isel({p_dim: dw_slice}).transpose( - *self.spatial_dims - ) + # data are already squeezed & transposed w.r.t the spatial_dims order after get_window_output() + # p_dims is dim 1 + graphic_data = window_output[:, dw_slice] - data = self._finalize_(graphic_data).values + data = self._finalize(graphic_data) other = self._get_other_features(data, dw_slice) return { @@ -759,19 +751,22 @@ def spatial_dims(self) -> tuple[str, str, str]: def spatial_dims(self, dims: tuple[str, str, str]): self.processor.spatial_dims = dims # force re-render - self.indices = self.indices + self.set_indices(self.indices) @property def indices(self) -> dict[Hashable, Any]: return {d: self._ref_index[d] for d in self.processor.slider_dims} - @indices.setter @block_reentrance - def indices(self, indices): + @start_coroutine + def set_indices( + self, indices: dict[Hashable, Any], block: bool = True, timeout: float = 1.0 + ): if self.data is None: return - new_features = self.processor.get(indices) + new_features = yield from self._get_data_slice(indices) + data_slice = new_features["data"] # TODO: set other graphic features, colors, sizes, markers, etc. @@ -829,7 +824,9 @@ def indices(self, indices): self._last_x_range[:] = self.graphic._plot_area.x_range if self._linear_selector is not None: - with pause_events(self._linear_selector): # we don't want the linear selector change to update the indices + with pause_events( + self._linear_selector + ): # we don't want the linear selector change to update the indices self._linear_selector.limits = xr # linear selector acts on `p` dim self._linear_selector.selection = indices[ @@ -848,11 +845,12 @@ def _tooltip_handler(self, graphic, pick_info): p_index = pick_info["vertex_index"] return self.processor.tooltip_format(n_index, p_index) + @start_coroutine def _create_graphic(self): if self.data is None: return - new_features = self.processor.get(self.indices) + new_features = yield from self._get_data_slice(self.indices) data_slice = new_features["data"] # store any cmap, sizes, thickness, etc. to assign to new graphic @@ -963,7 +961,7 @@ def display_window(self, dw: int | float | None): self.processor.display_window = dw # force re-render - self.indices = self.indices + self.set_indices(self.indices) @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: @@ -1037,7 +1035,7 @@ def cmap(self, new: str | None): self._graphic.cmap = new self._cmap = new # force a re-render - self.indices = self.indices + self.set_indices(self.indices) @property def cmap_each(self) -> np.ndarray[str] | None: @@ -1103,7 +1101,7 @@ def markers(self, new: str | None): self.graphic.markers = new self._markers = new # force a re-render - self.indices = self.indices + self.set_indices(self.indices) @property def sizes(self) -> float | Sequence[float] | None: @@ -1122,7 +1120,7 @@ def sizes(self, new: float | Sequence[float] | None): self.graphic.sizes = new self._sizes = new # force a re-render - self.indices = self.indices + self.set_indices(self.indices) @property def thickness(self) -> float | Sequence[float] | None: @@ -1141,4 +1139,4 @@ def thickness(self, new: float | Sequence[float] | None): self.graphic.thickness = new self._thickness = new # force a re-render - self.indices = self.indices + self.set_indices(self.indices) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 1b94e1cbc..9278312fc 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -26,8 +26,6 @@ def __init__( self._tooltip_columns = None self._tooltip = False - self._dims = spatial_dims - super().__init__( data=data, dims=spatial_dims, @@ -41,11 +39,12 @@ def __init__( def data(self) -> pd.DataFrame: return self._data - def _validate_data(self, data: pd.DataFrame): + @data.setter + def data(self, data: pd.DataFrame): if not isinstance(data, pd.DataFrame): raise TypeError - return data + self._data= data @property def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: @@ -89,7 +88,7 @@ def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: [self.data[c][self._dw_slice] for c in col] ) - data = self._finalize_(graphic_data) + data = self._finalize(graphic_data) other = self._get_other_features(data, self._dw_slice) return { diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/utils.py b/fastplotlib/widgets/nd_widget/_nd_positions/utils.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 2855c7063..88d4ccd4b 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -139,8 +139,8 @@ def update(self): if fps_changed: if value < 1: value = 1 - if value > 50: - value = 50 + if value > 100: + value = 100 self._fps[dim] = value self._frame_time[dim] = 1 / value From 29d4790636ea654b684c4dfbfe71cef152d781ab Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 9 Apr 2026 22:29:19 -0400 Subject: [PATCH 104/163] forgot to set back to False --- fastplotlib/widgets/nd_widget/_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 24a42999c..f10ec3c44 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -213,7 +213,7 @@ def _render_indices(self): continue # only provide slider indices to the graphic indices = {d: self._indices[d] for d in g.processor.slider_dims} - to_resolve: None | tuple[Generator, FutureProtocol] = g.set_indices(indices, block=True) + to_resolve: None | tuple[Generator, FutureProtocol] = g.set_indices(indices, block=False) if to_resolve is not None: if isinstance(to_resolve[1], FutureProtocol): From e8a21a79d3897be3a2a8940e0ce8add251c76a18 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 9 Apr 2026 22:29:57 -0400 Subject: [PATCH 105/163] remove xarray from pyproject.toml --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b91b168c2..0352cf27c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,8 +59,7 @@ tests = [ "ome-zarr", ] imgui = ["wgpu[imgui]"] -ndwidget = ["wgpu[imgui]", "xarray"] -dev = ["fastplotlib[docs,notebook,tests,imgui,ndwidget]"] +dev = ["fastplotlib[docs,notebook,tests,imgui]"] [project.urls] Homepage = "https://www.fastplotlib.org/" From 61f39bfbf092645962f2bcfba203d1876bc87da7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 11 Apr 2026 00:10:28 -0400 Subject: [PATCH 106/163] throttling --- fastplotlib/widgets/nd_widget/_index.py | 60 +++++++++++++++++++------ fastplotlib/widgets/nd_widget/_ui.py | 13 +++++- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index f10ec3c44..228f3a73f 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -14,7 +14,6 @@ from ...utils import FutureProtocol, CudaArrayProtocol, cuda_to_numpy -@dataclass class RangeContinuous: """ A continuous reference range for a single slider dimension. @@ -49,20 +48,59 @@ class RangeContinuous: RangeContinuous(start=0.0, stop=500.0, step=0.5) """ - - start: int | float - stop: int | float - step: int | float - - def __post_init__(self): - if self.start >= self.stop: + def __init__(self, start: int | float, stop: int | float, step: int | float): + if start >= stop: raise IndexError( f"start must be less than stop, {self.start} !< {self.stop}" ) + self._start = start + self._stop = stop + self._step = step + + self._throttle = 0.2 + + @property + def start(self) -> int | float: + """get or set the start boundary of the reference range""" + return self._start + + @start.setter + def start(self, val: int | float): + self._start = val + + @property + def stop(self) -> int | float: + """get or set the stop boundary of the reference range""" + return self._stop + + @stop.setter + def stop(self, val: int | float): + self._stop = val + + @property + def step(self) -> int | float: + """get or set the step size of the range, only used for UI elements""" + return self._step + + @property + def throttle(self) -> float: + """get or set throttle value in seconds. Used for throttling UI sliders""" + return self._throttle + + @throttle.setter + def throttle(self, val: float): + if val < 0: + raise ValueError("throttle value must be >= 0.0") + self._throttle = val + + @property + def size(self) -> int | float: + """the size of the reference range""" + return self.stop - self.start + def __getitem__(self, index: int): """return the value at the index w.r.t. the step size""" - # if index is negative, turn to positive index if index < 0: raise ValueError("negative indexing not supported") @@ -74,10 +112,6 @@ def __getitem__(self, index: int): return val - @property - def range(self) -> int | float: - return self.stop - self.start - @dataclass class RangeDiscrete: diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 88d4ccd4b..7f3a5f98e 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -53,6 +53,9 @@ def __init__(self, figure, size, ndwidget): # loop playback self._loop = {dim: False for dim in ref_ranges.keys()} + # last time the slider was moved, used for throttling + self._last_slider_movement: dict[str, float] = dict() + # auto-plays the ImageWidget's left-most dimension in docs galleries if "DOCS_BUILD" in os.environ.keys(): if os.environ["DOCS_BUILD"] == "1": @@ -159,7 +162,13 @@ def update(self): # TODO: refactor all this stuff, make fully fledged UI if changed: - self._ndwidget.indices[dim] = new_index + # apply throttling + if not dim in self._last_slider_movement: + self._last_slider_movement[dim] = 0.0 + + if now - self._last_slider_movement[dim] > rr.throttle: + self._ndwidget.indices[dim] = new_index + self._last_slider_movement[dim] = now elif imgui.is_item_hovered(): if imgui.is_key_pressed(imgui.Key.right_arrow): @@ -255,7 +264,7 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): nd_graphic.display_window = None else: # pick a value 10% of the reference range - nd_graphic.display_window = self._ndwidget.ranges[p_dim].range * 0.1 + nd_graphic.display_window = self._ndwidget.ranges[p_dim].size * 0.1 if nd_graphic.display_window is not None: if isinstance(nd_graphic.display_window, (int, np.integer)): From 4c6769e3c0f7683cc6d8d314e533250b2b218361 Mon Sep 17 00:00:00 2001 From: Amol Pasarkar Date: Wed, 15 Apr 2026 14:33:09 -0400 Subject: [PATCH 107/163] Adds nd vector graphic (#1034) * Includes nd vector code that works * Faster position assignment, no more for loop * Batched computations for vector set function * Formatting updates * Includes improved annotations and changes ordering of the data slice from the vectors graphic * Some further improvements to the pylinalg code * Fixes remaining formatting and naming issues * Apply suggestions from code review Co-authored-by: Kushal Kolar --------- Co-authored-by: Kushal Kolar --- fastplotlib/graphics/features/_vectors.py | 168 ++++++++- fastplotlib/widgets/nd_widget/__init__.py | 1 + fastplotlib/widgets/nd_widget/_nd_vectors.py | 346 ++++++++++++++++++ fastplotlib/widgets/nd_widget/_ndw_subplot.py | 46 ++- 4 files changed, 545 insertions(+), 16 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/_nd_vectors.py diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 729562b06..82767ca21 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -82,11 +82,8 @@ def set_value(self, graphic, value: np.ndarray): else: self._positions[:] = value - for i in range(self._positions.shape[0]): - # only need to update the translation vector - graphic.world_object.instance_buffer.data["matrix"][i][3, 0:3] = ( - self._positions[i] - ) + # Only need to update the translation vector + graphic.world_object.instance_buffer.data["matrix"][:, 3, 0:3] = self._positions[:] graphic.world_object.instance_buffer.update_full() @@ -171,15 +168,162 @@ def set_value(self, graphic, value: np.ndarray): # vector determines the size of the vector magnitudes = np.linalg.norm(self._directions, axis=1, ord=2) - for i in range(self._directions.shape[0]): - # get quaternion to rotate vector to new direction - rotation = la.quat_from_vecs(self.init_direction, self._directions[i]) - # get the new transform - transform = la.mat_compose(graphic.positions[i], rotation, magnitudes[i]) - # set the buffer - graphic.world_object.instance_buffer.data["matrix"][i] = transform.T + rotation = quat_from_vecs(self.init_direction, self._directions[:]) + # get the new transform + transform = mat_compose(graphic.positions[:], rotation, magnitudes[:]) + # set the buffer + graphic.world_object.instance_buffer.data["matrix"][:] = transform.transpose(0, 2, 1) graphic.world_object.instance_buffer.update_full() event = GraphicFeatureEvent(type="directions", info={"value": value}) self._call_event_handlers(event) + + + +def quat_from_vecs(source, target, out=None, dtype=None) -> np.ndarray: + source = np.asarray(source, dtype=float) + if source.ndim == 1: + source = source[None, :] + target = np.asarray(target, dtype=float) + if target.ndim == 1: + target = target[None, :] + + num_vecs = target.shape[0] + result_shape = (num_vecs, 4) + if out is None: + out = np.empty(result_shape, dtype=dtype) + + axis = np.cross(source, target) # (num_pts, 3) + axis_norm = np.linalg.norm(axis, axis=-1) # (num_pts,) + angle = np.arctan2(axis_norm, (target @ source.T).squeeze(1)) # (num_pts,) + + # Handle degenerate case: source and target are parallel (axis is zero vector). + # Pick any axis orthogonal to source as a replacement. + use_fallback = axis_norm == 0 + if np.any(use_fallback): + t = np.broadcast_to(source, (num_vecs, 3))[use_fallback] + + # Better case split: + y_zero = t[:, 1] == 0 + z_zero = t[:, 2] == 0 + neither_zero = ~y_zero & ~z_zero + + fb = np.empty((y_zero.shape[0], 3), dtype=float) + fb[y_zero] = (0., 1., 0.) + fb[~y_zero & z_zero] = (0., 0., 1.) + fb[neither_zero, 0] = 0. + fb[neither_zero, 1] = -t[neither_zero, 2] + fb[neither_zero, 2] = t[neither_zero, 1] + + axis[use_fallback] = fb + + return quat_from_axis_angle(axis, angle, out=out) + + +def quat_from_axis_angle(axis, angle, out=None, dtype=None) -> np.ndarray: + """Quaternion from axis-angle pair. + + Create a quaternion representing the rotation of an given angle + about a given unit vector + + Parameters + ---------- + axis : ndarray, [num_vectors, 3] or [3] + Unit vector + angle : number or np.ndarray of shape [num_pts,] + The angle (in radians) to rotate about axis + out : ndarray, optional + A location into which the result is stored. If provided, it + must have a shape that the inputs broadcast to. If not provided or + None, a freshly-allocated array is returned. A tuple must have + length equal to the number of outputs. + dtype : data-type, optional + Overrides the data type of the result. + + Returns + ------- + ndarray, [num_pts, 4] or [4] + Quaternion. + """ + + axis = np.asarray(axis, dtype=float) + angle = np.asarray(angle, dtype=float) + + if out is None: + out_shape = np.broadcast_shapes(axis.shape[:-1], angle.shape) + out = np.empty((*out_shape, 4), dtype=dtype) + + # result should be independent of the length of the given axis + lengths_shape = (*axis.shape[:-1], 1) + axis = axis / np.linalg.norm(axis, axis=-1).reshape(lengths_shape) + + out[..., :3] = axis * np.sin(angle / 2).reshape(lengths_shape) + out[..., 3] = np.cos(angle / 2) + + return out.squeeze(0) if out.shape[0] == 1 else out + + +def mat_compose(translation, rotation, scaling, /, *, out=None, dtype=None) -> np.ndarray: + """ + Compose transformation matrices given translation vectors, quaternions, + and scaling vectors. + + Parameters + ---------- + translation : ndarray, [3] or [num_vectors, 3] + rotation : ndarray, [4] or [num_vectors, 4] + scaling : ndarray, [3] or [num_vectors, 3] + + Returns + ------- + ndarray, [num_vectors, 4, 4] or [4, 4] + """ + rotation = np.asarray(rotation, dtype=float) + translation = np.asarray(translation, dtype=float) + scaling = np.asarray(scaling, dtype=float) + + if rotation.ndim == 1: + rotation = rotation[None, :] + if translation.ndim == 1: + translation = translation[None, :] + if scaling.ndim == 0: + scaling = np.full((1, 3), scaling) + elif scaling.ndim == 1 and scaling.shape[0] == 3: + scaling = scaling[None, :] + elif scaling.ndim == 1: + scaling = scaling[:, None] * np.ones(3) + + num_vectors = max(rotation.shape[0], translation.shape[0], scaling.shape[0]) + + if out is None: + out = np.zeros((num_vectors, 4, 4), dtype=dtype) + else: + out[..., :, :] = 0 + + x, y, z, w = rotation[:, 0], rotation[:, 1], rotation[:, 2], rotation[:, 3] + + x2, y2, z2 = x + x, y + y, z + z + xx, xy, xz = x * x2, x * y2, x * z2 + yy, yz, zz = y * y2, y * z2, z * z2 + wx, wy, wz = w * x2, w * y2, w * z2 + + sx, sy, sz = scaling[:, 0], scaling[:, 1], scaling[:, 2] + + + out[:, 0, 0] = (1 - (yy + zz)) * sx + out[:, 1, 0] = (xy + wz) * sx + out[:, 2, 0] = (xz - wy) * sx + + out[:, 0, 1] = (xy - wz) * sy + out[:, 1, 1] = (1 - (xx + zz)) * sy + out[:, 2, 1] = (yz + wx) * sy + + out[:, 0, 2] = (xz + wy) * sz + out[:, 1, 2] = (yz - wx) * sz + out[:, 2, 2] = (1 - (xx + yy)) * sz + + out[:, 0:3, 3] = translation + out[:, 3, 3] = 1 + + return out.squeeze(0) if out.shape[0] == 1 else out \ No newline at end of file diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index 65f448b54..8416288c7 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -5,6 +5,7 @@ from ._base import NDProcessor, NDGraphic from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras from ._nd_image import NDImageProcessor, NDImage + from ._nd_vectors import NDVectorsProcessor, NDVectors from ._ndwidget import NDWidget else: diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py new file mode 100644 index 000000000..85ff19e13 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -0,0 +1,346 @@ +from collections.abc import Sequence, Generator, Callable +from typing import Any + +import numpy as np +from numpy.typing import ArrayLike + +from ...layouts import Subplot +from ...utils import subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol +from ...graphics import VectorsGraphic +from ._base import ( + NDProcessor, + NDGraphic, + WindowFuncCallable, + block_reentrance, + AwaitedArray, +) +from ._index import ReferenceIndex +from ._async import start_coroutine + + +class NDVectorsProcessor(NDProcessor): + def __init__( + self, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], # must be in order, last dim must be 4 + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayLike], ArrayLike] = None, + slider_dim_transforms=None, + ): + """ + ``NDProcessor`` subclass for n-dimensional vector data + + Produces (num_vectors, 2, [2 or 3]) slices for a ``VectorsGraphic``. The last two dimensions describe the + position/direction and the 2D/3D spatial coordinate, respectively. + + Parameters + ---------- + data: ArrayProtocol + Shape [..., num_vectors, 2, 2] or [..., num_vectors, 2, 3]. data[..., 0, :] gives the positions, data[..., 1, :] gives directions + + dims: Sequence[str] + names for each dimension in ``data``. Dimensions not listed in + ``spatial_dims`` are treated as slider dimensions and **must** appear as + keys in the parent ``NDWidget``'s ``ref_ranges`` + Examples:: + + A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method + must operate as if these dimensions exist and return an array that matches the spatial dimensions. + + + dims in the array do not need to be in the order that you want to display them, for example you can have a + weird array where the dims are interpreted as: + ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")``. + + spatial_dims : tuple[str, str] | tuple[str, str, str] + The dim names that indicate [n_vectors, positions & directions, xy(z)], **in that order** + + slider_dim_transforms : dict, optional + See :class:`NDProcessor`. + + window_funcs : dict, optional + See :class:`NDProcessor`. + + window_order : tuple, optional + See :class:`NDProcessor`. + + spatial_func : callable, optional + See :class:`NDProcessor`. + + See Also + -------- + NDProcessor : Base class with full parameter documentation. + NDVectors : The ``NDGraphic`` that uses this processor by default. + """ + + super().__init__( + data=data, + dims=dims, + spatial_dims=spatial_dims, + slider_dim_transforms=slider_dim_transforms, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + ) + + @property + def data(self) -> ArrayProtocol | None: + """ + get or set managed data. If setting with new data, the new data is interpreted + to have the same dims (i.e. same dim names and ordering of dims). + """ + return self._data + + @data.setter + def data(self, data: ArrayProtocol): + if not isinstance(data, ArrayProtocol): + # check that it's generally array-like + raise TypeError( + f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" + f"{ARRAY_LIKE_ATTRS}, or they must be `None`" + ) + + if data.ndim < 3: + raise ValueError( + f"Shape must be (..., num_vecs, 2, [2 or 3]) you passed an array of shape {data.shape}" + ) + + self._data = data + + @property + def spatial_dims(self) -> tuple[str, str]: + """ + Spatial dims, **in order** + Dimensions in order are num_vectors, position/direction, xy[z], so the shape is [num_vectors, 2, 2 or 3] + """ + return self._spatial_dims + + @spatial_dims.setter + def spatial_dims(self, sdims: tuple[str, str, str]): + for dim in sdims: + if dim not in self.dims: + raise KeyError + + if len(sdims) != 3: + raise ValueError( + f"There must be exactly 3 spatial dims for vectors indicating [num_vectors, 2, 2] or [num_vectors, 2, 3] " + ) + + self._spatial_dims = tuple(sdims) + + if self.shape[self.spatial_dims[-2]] != 2 or self.shape[ + self.spatial_dims[-1] + ] not in (2, 3): + raise ValueError( + f"Spatial dimensions must haves shape (num_vecs, 2, [2 or 3]) you passed an array of shape {data.shape}" + ) + + def get(self, indices: dict[str, Any]) -> AwaitedArray: + """ + 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 tuple specifying a single integer + index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. + + Parameters + ---------- + indices: tuple[int, ...] + Get the processed data at this index. Must provide a value for each dimension. + Example: get((100, 5)) + + """ + # this will be squeezed output, with dims in the order of the user set spatial dims + window_output = yield from self.get_window_output(indices) + + # apply spatial_func + if self.spatial_func is not None: + spatial_out = self._spatial_func(window_output) + if spatial_out.ndim != len(self.spatial_dims): + raise ValueError + + return spatial_out + + return window_output + + +class NDVectors(NDGraphic): + def __init__( + self, + ref_index: ReferenceIndex, + subplot: Subplot, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[ + str, str, str + ], # must be in order! + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms=None, + name: str = None, + ): + """ + ``NDGraphic`` subclass for n-dimensional vector rendering + + Wraps an :class:`VectorGraphic` + + Every dimension that is *not* listed in ``spatial_dims`` becomes a slider + dimension. Each slider dim must have a ``ReferenceRange`` defined in the + ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct + a change in the ``ReferenceIndex`` and update the graphics. + + Parameters + ---------- + ref_index : ReferenceIndex + The shared reference index that delivers slider updates to this graphic. + + subplot : Subplot + parent subplot the NDGraphic is in + + data : array-like or None + Shape [num_vectors, 2, 2] or [num_vectors, 3, 2]. data[:, :, 0] gives the positions, data[:, :, 1] gives directions + n-dimension image data array + + dims : sequence of hashable + Name for every dimension of ``data``, in order. Non-spatial dims must + match keys in ``ref_index``. + + ex: ``("time", "depth", "row", "col")`` — ``"time"`` and ``"depth"`` must + be present in ``ref_index``. + + spatial_dims : tuple[str, str] | tuple[str, str, str] + Spatial dimensions **in order**: These dims are either [n_vectors, 2, 2] or [n_vectors, 2, 3], indicating [n_vectors, positions & directions, xy(z)] + + window_funcs : dict, optional + See :class:`NDProcessor`. + + window_order : tuple, optional + See :class:`NDProcessor`. + + spatial_func : callable, optional + See :class:`NDProcessor`. + + slider_dim_transforms : dict, optional + See :class:`NDProcessor`. + + name : str, optional + Name for the underlying graphic. + + See Also + -------- + NDImageProcessor : The processor that backs this graphic. + + """ + + if not (set(dims) - set(spatial_dims)).issubset(ref_index.dims): + raise IndexError( + f"all specified `dims` must either be a spatial dim or a slider dim " + f"specified in the NDWidget ref_ranges, provided dims: {dims}, " + f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" + ) + + super().__init__(subplot, name) + + self._ref_index = ref_index + + self._processor = NDVectorsProcessor( + data, + dims=dims, + spatial_dims=spatial_dims, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + slider_dim_transforms=slider_dim_transforms, + ) + + self._graphic: VectorsGraphic | None = None + + # create a graphic + self._create_graphic() + + @property + def processor(self) -> NDVectorsProcessor: + """NDProcessor that manages the data and produces data slices to display""" + return self._processor + + @property + def graphic( + self, + ) -> VectorsGraphic: + """Underlying Graphic object used to display the current data slice""" + return self._graphic + + @start_coroutine + def _create_graphic(self): + # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, + # adds it to the subplot, and resets the camera and histogram. + + if self.processor.data is None: + # no graphic if data is None, useful for initializing in null states when we want to set data later + return + + # get the data slice for this index + # this will only have the dims specified by ``spatial_dims`` + + data_slice = yield from self._get_data_slice(self.indices) + + old_graphic = self._graphic + # check if we are replacing a graphic + if old_graphic is not None: + # delete the old graphic + self._subplot.delete_graphic(old_graphic) + + # create the new graphic + self._graphic = self._subplot.add_vectors( + positions=data_slice[:, 0], directions=data_slice[:, 1] + ) + + self._subplot.add_graphic(self._graphic) + + @property + def spatial_dims(self) -> tuple[str, str, str]: + """ + get or set the spatial dims **in order**. + Spatial dim shape here is [num_vectors, position/dimension (2), xy[z] (2 or 3)] + """ + return self.processor.spatial_dims + + @spatial_dims.setter + def spatial_dims(self, dims: tuple[str, str, str]): + self.processor.spatial_dims = dims + + # shape has probably changed, recreate graphic + self._create_graphic() + + @property + def indices(self) -> dict[str, Any]: + """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" + return {d: self._ref_index[d] for d in self.processor.slider_dims} + + @block_reentrance + @start_coroutine + def set_indices( + self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 + ): + data_slice = yield from self._get_data_slice(indices) + + positions = data_slice[:, 0] + directions = data_slice[:, 1] + + self.graphic.positions = positions + self.graphic.directions = directions + + @property + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + """get or set the spatial_func, see docstring for details""" + # this is here even though it's the same in the base class since we can't create the image specific setter + # without also defining the property in this subclass. + return self.processor.spatial_func + + @spatial_func.setter + def spatial_func( + self, func: Callable[[ArrayProtocol], ArrayProtocol] + ) -> Callable | None: + self.processor.spatial_func = func diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 6666b3fc1..a684c32cc 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -3,10 +3,17 @@ import numpy as np -from ... import ScatterCollection, ScatterStack, LineCollection, LineStack, ImageGraphic +from ... import ( + ScatterCollection, + ScatterStack, + LineCollection, + LineStack, + ImageGraphic, + VectorsGraphic, +) from ...layouts import Subplot from ...utils import ArrayProtocol -from . import NDImage, NDPositions +from . import NDImage, NDPositions, NDVectors from ._base import NDGraphic, WindowFuncCallable @@ -20,6 +27,7 @@ class NDWSubplot: Note: ``NDWSubplot`` is not meant to be constructed directly, it only exists as part of an ``NDWidget`` """ + def __init__(self, ndw, subplot: Subplot): self.ndw = ndw self._subplot = subplot @@ -58,7 +66,10 @@ def add_nd_image( slider_dim_transforms=None, name: str = None, ): - nd = NDImage(self.ndw.indices, self._subplot, data=data, + nd = NDImage( + self.ndw.indices, + self._subplot, + data=data, dims=dims, spatial_dims=spatial_dims, rgb_dim=rgb_dim, @@ -68,7 +79,34 @@ def add_nd_image( compute_histogram=compute_histogram, slider_dim_transforms=slider_dim_transforms, name=name, - ) + ) + + self._nd_graphics.append(nd) + return nd + + def add_nd_vectors( + self, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms=None, + name: str = None, + ) -> NDVectors: + nd = NDVectors( + self.ndw.indices, + self._subplot, + data=data, + dims=dims, + spatial_dims=spatial_dims, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + slider_dim_transforms=slider_dim_transforms, + name=name, + ) self._nd_graphics.append(nd) return nd From b7c15decdd2413dc731a19d7cbc4e0dc86c2a570 Mon Sep 17 00:00:00 2001 From: Amol Pasarkar Date: Thu, 16 Apr 2026 15:49:15 -0400 Subject: [PATCH 108/163] Adds kwargs so user can customize vector field estimates (#1036) * Adds kwargs so user can customize vector field estimates * Does the kwarg organization the way ndpositions does * fixes graphic constructor --- fastplotlib/widgets/nd_widget/_nd_vectors.py | 12 ++++++++++-- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 85ff19e13..0b8e04725 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -180,6 +180,7 @@ def __init__( spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, slider_dim_transforms=None, name: str = None, + graphic_kwargs: dict = None, ): """ ``NDGraphic`` subclass for n-dimensional vector rendering @@ -257,6 +258,11 @@ def __init__( self._graphic: VectorsGraphic | None = None + if graphic_kwargs is None: + self._graphic_kwargs = dict() + else: + self._graphic_kwargs = graphic_kwargs + # create a graphic self._create_graphic() @@ -293,8 +299,10 @@ def _create_graphic(self): self._subplot.delete_graphic(old_graphic) # create the new graphic - self._graphic = self._subplot.add_vectors( - positions=data_slice[:, 0], directions=data_slice[:, 1] + self._graphic = VectorsGraphic( + positions=data_slice[:, 0], + directions=data_slice[:, 1], + **self._graphic_kwargs ) self._subplot.add_graphic(self._graphic) diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index a684c32cc..5bee2dc30 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -94,6 +94,7 @@ def add_nd_vectors( spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, slider_dim_transforms=None, name: str = None, + **kwargs ) -> NDVectors: nd = NDVectors( self.ndw.indices, @@ -106,6 +107,7 @@ def add_nd_vectors( spatial_func=spatial_func, slider_dim_transforms=slider_dim_transforms, name=name, + **kwargs ) self._nd_graphics.append(nd) From 32f656f1a042c442beb95a9894f27c79902dde41 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Thu, 16 Apr 2026 20:45:58 -0400 Subject: [PATCH 109/163] implement yuv and other colorspaces and "bufferless" `TextureArray` (#1033) * implement yuv and 'bufferless' TextureArraY * unbuffered and yuv420 works * warning on tooltip * NDImage always uses unbuffered, support colorspaces in NDIMage * update docstrings * by default disable AA and set pixel_scale=1.0 for performance * unpacked yuv support * independent graphics and texture features for rgb and yuv * docstrings * docstrings * import order * yuv graphic working nicely * add enum to top level namespace * update script to produce add graphics mixin * add yuv example * update ndimage with yuv stuff * fixes * yuv video working well with NDWidget --- examples/image/image_yuv.py | 56 ++ fastplotlib/__init__.py | 1 + fastplotlib/graphics/__init__.py | 3 +- fastplotlib/graphics/features/__init__.py | 4 + fastplotlib/graphics/features/_image.py | 304 +++++++- fastplotlib/graphics/features/utils.py | 23 + fastplotlib/graphics/image.py | 650 ++++++++++++------ .../graphics/selectors/_linear_region.py | 6 + fastplotlib/graphics/selectors/_polygon.py | 6 + fastplotlib/graphics/selectors/_rectangle.py | 6 + fastplotlib/layouts/_figure.py | 4 +- fastplotlib/layouts/_graphic_methods_mixin.py | 132 +++- fastplotlib/layouts/_utils.py | 12 +- fastplotlib/utils/__init__.py | 2 +- fastplotlib/utils/enums.py | 18 +- fastplotlib/widgets/nd_widget/__init__.py | 1 + fastplotlib/widgets/nd_widget/_base.py | 4 +- fastplotlib/widgets/nd_widget/_nd_image.py | 62 +- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 31 +- fastplotlib/widgets/nd_widget/_video.py | 27 + scripts/generate_add_graphic_methods.py | 12 +- 21 files changed, 1086 insertions(+), 278 deletions(-) create mode 100644 examples/image/image_yuv.py create mode 100644 fastplotlib/widgets/nd_widget/_video.py diff --git a/examples/image/image_yuv.py b/examples/image/image_yuv.py new file mode 100644 index 000000000..dfb7cad47 --- /dev/null +++ b/examples/image/image_yuv.py @@ -0,0 +1,56 @@ +""" +YUV Image +========= + +Example that shows how to use YUV images. Most videos are stored in this colorspace. +Y stores luma at full resolution, UV stores chroma values. +In yuv420p UV channels are stored at half the resolution of Y. In yuv444p, UV channels are stored +at full resolution. + +YUV is also called YCbCr for digital images. + +For more info: https://en.wikipedia.org/wiki/Y%E2%80%B2UV + +You can see the slight differences between yuv420 and yuv444 if you zoom into parts of the image where colors change +rapidly over space, such as the astronaut's patch. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np +from skimage.color import rgb2ycbcr +import imageio.v3 as iio + +# convert an rgb image to ycbcr for example purposes +img = iio.imread("imageio:astronaut.png") +img_yuv = rgb2ycbcr(img).astype(np.uint8) + +y = img_yuv[..., 0] +u = img_yuv[..., 1] +v = img_yuv[..., 2] + +figure = fpl.Figure( + shape=(1, 2), names=["yuv420p", "yuv444p"], controller_ids="sync", size=(700, 400) +) + +image1 = figure[0, 0].add_image_yuv( + data=(y, u[::2, ::2], v[::2, ::2]), colorspace="yuv420p" +) + +image2 = figure[0, 1].add_image_yuv(data=(y, u, v), colorspace="yuv444p") + +cursor = fpl.Cursor() + +for subplot in figure: + cursor.add_subplot(subplot) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index d975f4d0a..c4626a041 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -6,6 +6,7 @@ from .utils import loop # noqa from .utils import ( config, + enums, enumerate_adapters, select_adapter, print_wgpu_report, diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index cca2afc21..baf8151be 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,7 +1,7 @@ from ._base import Graphic from .line import LineGraphic from .scatter import ScatterGraphic -from .image import ImageGraphic +from .image import ImageGraphic, ImageYUVGraphic from .image_volume import ImageVolumeGraphic from ._vectors import VectorsGraphic from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic @@ -14,6 +14,7 @@ "LineGraphic", "ScatterGraphic", "ImageGraphic", + "ImageYUVGraphic", "ImageVolumeGraphic", "VectorsGraphic", "MeshGraphic", diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index 7f7410cf7..a04b1c991 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -27,6 +27,8 @@ ) from ._image import ( TextureArray, + TextureYUV, + TupleYUV, ImageCmap, ImageVmin, ImageVmax, @@ -93,6 +95,8 @@ "VertexPointSizes", "UniformSize", "TextureArray", + "TextureYUV", + "TupleYUV", "ImageCmap", "ImageVmin", "ImageVmax", diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 27fd74196..a2c6f1183 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -1,16 +1,21 @@ from itertools import product from math import ceil +from typing import Literal, TypeAlias from warnings import warn import cmap as cmap_lib import numpy as np +from numpy.typing import NDArray +import wgpu import pygfx + from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance -from ...utils import ( - get_cmap_texture, -) +from .utils import get_element_format_from_numpy_array +from ...utils import get_cmap_texture, ColorspacesRGB, ColorspacesYUV, ColorRange + +TupleYUV: TypeAlias = tuple[NDArray[np.uint8], NDArray[np.uint8], NDArray[np.uint8]] class TextureArray(GraphicFeature): @@ -33,32 +38,59 @@ class TextureArray(GraphicFeature): }, ] - def __init__(self, data, property_name: str = "data"): + def __init__( + self, + data, + property_name: str = "data", + cpu_buffer: bool = True, + colorspace: ColorspacesRGB = ColorspacesRGB.srgb, + ): super().__init__(property_name=property_name) - data = self._fix_data(data) + self._colorspace = ColorspacesRGB(colorspace) + data = self._check_data(data, colorspace, cpu_buffer) + + self._shape = data.shape shared = pygfx.renderers.wgpu.get_shared() self._texture_limit_2d = shared.device.limits["max-texture-dimension-2d"] - # create a new buffer - self._value = np.zeros(data.shape, dtype=data.dtype) - self.value[:] = data[:] + if cpu_buffer: + # create a local buffer + self._value = np.empty(data.shape, dtype=data.dtype) + self.value[:] = data[:] + else: + self._value = None + usage = wgpu.TextureUsage.COPY_DST + # auto-determine format, adapted from pygfx.Texture + element_format = get_element_format_from_numpy_array(data) + if element_format is None: + raise ValueError( + f"Unsupported dtype/format for texture data: {data.dtype}" + ) + + if data.ndim == 3: + nchannels = data.shape[-1] + else: + nchannels = 1 + format_ = (f"{nchannels}x" + element_format).lstrip("1x") + + self._shape = data.shape # data start indices for each Texture self._row_indices = np.arange( 0, - ceil(self.value.shape[0] / self._texture_limit_2d) * self._texture_limit_2d, + ceil(self.shape[0] / self._texture_limit_2d) * self._texture_limit_2d, self._texture_limit_2d, ) self._col_indices = np.arange( 0, - ceil(self.value.shape[1] / self._texture_limit_2d) * self._texture_limit_2d, + ceil(self.shape[1] / self._texture_limit_2d) * self._texture_limit_2d, self._texture_limit_2d, ) # buffer will be an array of textures - self._buffer: np.ndarray[pygfx.Texture] = np.empty( + self._buffer: NDArray[pygfx.Texture] = np.empty( shape=(self.row_indices.size, self.col_indices.size), dtype=object ) @@ -66,20 +98,77 @@ def __init__(self, data, property_name: str = "data"): # iterate through each chunk of passed `data` # create a pygfx.Texture from this chunk - for _, buffer_index, data_slice in self: - texture = pygfx.Texture(self.value[data_slice], dim=2) + for _, buffer_index, slicer in self: + if cpu_buffer: + # texture gets the data directly + texture = pygfx.Texture( + self.value[slicer], + dim=2, + colorspace=colorspace, + ) + else: + # we only supply the size + w, h = data[slicer].shape[1], data[slicer].shape[0] + + texture = pygfx.Texture( + size=(w, h, 1), + dim=2, + colorspace=colorspace, + format=format_, + usage=usage, + ) + + # send the initial data + texture.send_data((0, 0, 0), data[slicer]) self.buffer[buffer_index] = texture + self._colorspace = colorspace + self._cpu_buffer = cpu_buffer + + @property + def colorspace( + self, + ) -> ColorspacesRGB: + """Colorspace, read only""" + return self._colorspace + + @property + def cpu_buffer(self) -> bool: + """whether or not a cpu buffer exists for this TextureArray""" + return self._cpu_buffer + + @property + def shape(self) -> tuple[int, int] | tuple[int, int, int]: + """ + the shape of the represented data, [n_rows, n_cols] or [n_rows, n_cols, 3 | 4] + """ + return self._shape + @property - def value(self) -> np.ndarray: + def value(self) -> np.ndarray | None: + """array buffer if Texture has a cpu buffer, otherwise None""" return self._value - def set_value(self, graphic, value): - self[:] = value + def set_value(self, graphic, value: np.ndarray): + if not self.cpu_buffer: + if isinstance(value, np.ndarray): + # if cpu_buffer is False, we directly send data to the GPU + if value.shape != self.shape: + raise ValueError( + f"new data shape must be the same as the original data array if `cpu_buffer=False`" + f"original data shape was: {self.shape}, data passed is of shape: {value.shape}" + ) + for texture, buffer_index, slicer in self: + chunk = value[slicer] + texture.send_data((0, 0, 0), chunk) + + else: + # set the cpu buffer, it will be marked for upload + self[:] = value @property - def buffer(self) -> np.ndarray[pygfx.Texture]: + def buffer(self) -> NDArray[pygfx.Texture]: return self._buffer @property @@ -98,13 +187,25 @@ def col_indices(self) -> np.ndarray: """ return self._col_indices - def _fix_data(self, data): + def _check_data(self, data, colorspace, cpu_buffer): + # make sure data ndim is valid for the given colorspace + if data.ndim not in (2, 3): raise ValueError( - "image data must be 2D with or without an RGB(A) dimension, i.e. " + "the image data must be 2D with or without an RGB(A) dimension, i.e. " "it must be of shape [rows, cols], [rows, cols, 3] or [rows, cols, 4]" ) + if data.ndim == 3 and not cpu_buffer: + # wgpu only supports rgba, it does not support rgb + if data.shape[-1] != 4: + raise ValueError( + "if the colorspace is 'srgb', 'tex-srgb', or 'physical' and `cpu_buffer=False`" + "the image data MUST be RGBA, with shape [rows, cols, 4]. WGPU does not support " + "rgb textures. You must either supply full a RGBA array with `cpu_buffer=False` or " + "use `cpu_buffer=True` which supports RGB arrays." + ) + if data.itemsize == 8: warn(f"casting {data.dtype} array to float32") return data.astype(np.float32) @@ -132,22 +233,33 @@ def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, slice]] chunk_index = (chunk_row, chunk_col) # stop indices of big data array for this chunk - row_stop = min(self.value.shape[0], data_row_start + self._texture_limit_2d) - col_stop = min(self.value.shape[1], data_col_start + self._texture_limit_2d) + row_stop = min(self.shape[0], data_row_start + self._texture_limit_2d) + col_stop = min(self.shape[1], data_col_start + self._texture_limit_2d) # row and column slices that slice the data for this chunk from the big data array - data_slice = (slice(data_row_start, row_stop), slice(data_col_start, col_stop)) + slicer = (slice(data_row_start, row_stop), slice(data_col_start, col_stop)) # texture for this chunk texture = self.buffer[chunk_index] - return texture, chunk_index, data_slice + return texture, chunk_index, slicer def __getitem__(self, item): + if not self.cpu_buffer: + return None + return self.value[item] @block_reentrance def __setitem__(self, key, value): + if not self.cpu_buffer: + raise BufferError( + f"setting slices or specific elements of texture data is only supported when `cpu_buffer=True`." + f"'unbuffered' textures only support setting the full data entirely, " + f"i.e. you must do: graphic.data = new_arr, you cannot do: graphic.data[indices] = new_arr, unless " + f"`cpu_buffer=True`" + ) + self.value[key] = value for texture in self.buffer.ravel(): @@ -162,6 +274,152 @@ def __len__(self): return self.buffer.size +class TextureYUV(GraphicFeature): + """ + Manages a YUV texture, no chunking, no local buffer + """ + + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index, numpy-like fancy index", + "description": "key at which image data was sliced/fancy indexed", + }, + { + "dict key": "value", + "type": "np.ndarray | float", + "description": "new data values", + }, + ] + + def __init__( + self, + data: TupleYUV, + property_name: str = "data", + colorspace: ColorspacesYUV = ColorspacesYUV.yuv420p, + colorrrange: ColorRange = ColorRange.limited, + ): + super().__init__(property_name=property_name) + + self._colorspace = ColorspacesYUV(colorspace) + self._colorrange = ColorRange(colorrrange) + + self._check_data(data) + + self._data = data + + shared = pygfx.renderers.wgpu.get_shared() + limit = shared.device.limits["max-texture-dimension-2d"] + if data[0].shape[0] > limit or data[0].shape[1] > limit: + raise ValueError( + f"YUV colorspaces Images currently don't support dimensions that exceed the device's " + f"max-texture-dimension-2d. For now you must manually tile individual Images to use a YUV colorspace." + ) + + self._allocate_texture(data) + self._send_data(data) + + @property + def cpu_buffer(self) -> Literal[False]: + return False + + @property + def texture(self) -> pygfx.Texture: + return self._texture + + @property + def colorspace(self) -> ColorspacesYUV: + return self._colorspace + + @property + def colorrange(self) -> ColorRange: + return self._colorrange + + def _allocate_texture(self, data: TupleYUV): + """Create a new pygfx.Texture""" + + self._h, self._w = data[0].shape + if self.colorspace == ColorspacesYUV.yuv420p: + depth = 2 + else: + depth = 3 + + self._texture = pygfx.Texture( + size=(self._w, self._h, depth), + dim=2, + colorspace=self.colorspace.value, + colorrange=self.colorrange.value, + format="r8unorm", + usage=wgpu.TextureUsage.COPY_DST, + ) + + def _send_data(self, data): + """send the data to the GPU""" + y, u, v = data + + self._texture.send_data((0, 0, 0), y) + + if self.colorspace == ColorspacesYUV.yuv420p: + self._texture.send_data((0, 0, 1), u) + self._texture.send_data((self._w // 2, 0, 1), v) + else: + self._texture.send_data((0, 0, 1), u) + self._texture.send_data((0, 0, 2), v) + + @property + def value(self) -> None: + """this is bufferless""" + return None + + def set_value(self, graphic, value: TupleYUV): + self._check_data(value) + + y, u, v = value + + if y.shape[0] != self._h or y.shape[1] != self._w: + self._allocate_texture(value) + graphic.geometry.grid = self._texture + + self._send_data(value) + + def _check_data(self, data: TupleYUV): + err = f"must provide a tuple/list of np.ndarray of type np.uint8 representing YUV components." + + if not isinstance(data, (tuple, list)): + raise TypeError(err + f"\nYou provided: {data}") + + if not len(data) == 3: + raise TypeError(err + f"\nYou provided data of len: {len(data)}") + + if not all([isinstance(a, np.ndarray) for a in data]): + raise TypeError(err + f"\nYou provided types: {[type(d) for d in data]}") + + types = [a.dtype for a in data] + if not all([t == np.uint8 for t in types]): + raise TypeError(err + f"\nYou provided data of types: {types}") + + if self.colorspace == ColorspacesYUV.yuv420p: + err += ( + f"For {self.colorspace} UV channels must be 4x smaller than Y. " + f"You provided shapes: {tuple(d.shape for d in data)}" + ) + shapes = tuple(np.asarray(d.shape) for d in data) + expected_uv_shape = shapes[0] // 2 + if (shapes[1] != expected_uv_shape).all() or ( + shapes[2] != expected_uv_shape + ).all(): + raise ValueError(err) + + else: + err += ( + f"For {self.colorspace} UV channels must be the same size as Y" + f"You provided shapes: {tuple(d.shape for d in data)}" + ) + + if data[0].shape != data[1].shape or data[0].shape != data[2].shape: + raise ValueError(err) + + class ImageVmin(GraphicFeature): """lower contrast limit""" diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py index aa4022052..ef67297ce 100644 --- a/fastplotlib/graphics/features/utils.py +++ b/fastplotlib/graphics/features/utils.py @@ -77,3 +77,26 @@ def parse_colors( data = make_pygfx_colors(colors, n_colors) return to_gpu_supported_dtype(data) + + +def get_element_format_from_numpy_array(array): + """Get the per-element format specifier from a numpy array. + Returns None if the format appears to be a structured array. + Raises an error if GPU-incompatible dtypes are used (64 bit). + """ + + # Uniform buffers are scalars with a structured dtype. + # But can also create storage buffers with complex formats. + if array.dtype.kind not in "iuf": + return None + + # GPUs generally don't support 64-bit buffers or textures. + # Note: the Python docs say that l and L are 32 bit, but converting + # a int64 numpy array to a memoryview gives a format of 'l' instead + # of 'q' on some systems/configs? So we need to check the itemsize. + if array.itemsize == 8: + raise ValueError( + f"A dtype of {array.dtype.name} is not supported for buffers, use a 32-bit variant instead." + ) + + return array.dtype.str.lstrip("<>=|") \ No newline at end of file diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 8e11f4751..3d17d44a2 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -3,8 +3,9 @@ import numpy as np import pygfx +from pygfx import Texture -from ..utils import quick_min_max +from ..utils import quick_min_max, ColorspacesRGB, ColorspacesYUV, ColorRange from ._base import Graphic from .selectors import ( LinearSelector, @@ -14,6 +15,8 @@ ) from .features import ( TextureArray, + TextureYUV, + TupleYUV, ImageCmap, ImageVmin, ImageVmax, @@ -85,7 +88,274 @@ def chunk_index(self) -> tuple[int, int]: return self._chunk_index -class ImageGraphic(Graphic): +class ImageBase(Graphic): + @property + def cpu_buffer(self) -> bool: + """whether or not a cpu buffer is used for the image data. If ``False``, then the data only exist on the GPU""" + return self.data.cpu_buffer + + @property + def vmin(self) -> float: + """lower contrast limit""" + return self._vmin.value + + @vmin.setter + def vmin(self, value: float): + self._vmin.set_value(self, value) + + @property + def vmax(self) -> float: + """upper contrast limit""" + return self._vmax.value + + @vmax.setter + def vmax(self, value: float): + self._vmax.set_value(self, value) + + @property + def interpolation(self) -> str: + """Data interpolation method""" + return self._interpolation.value + + @interpolation.setter + def interpolation(self, value: str): + self._interpolation.set_value(self, value) + + def add_linear_selector( + self, selection: int = None, axis: str = "x", **kwargs + ) -> LinearSelector: + """ + Adds a :class:`.LinearSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them + from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: int, optional + initial position of the selector + + kwargs: + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + + if axis == "x": + limits = (0, self._data.value.shape[1]) + elif axis == "y": + limits = (0, self._data.value.shape[0]) + else: + raise ValueError("`axis` must be one of 'x' | 'y'") + + if selection is None: + selection = limits[0] + + if selection < limits[0] or selection > limits[1]: + raise ValueError( + f"the passed selection: {selection} is beyond the limits: {limits}" + ) + + selector = LinearSelector( + selection=selection, + limits=limits, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_linear_region_selector( + self, + selection: tuple[float, float] = None, + axis: str = "x", + padding: float = 0.0, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, + ) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them + from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float) + initial (min, max) of the selection + + axis: "x" | "y" + axis the selector can move along + + padding: float, default 100.0 + Extends the linear selector along the perpendicular axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + + """ + + if axis == "x": + size = self._data.value.shape[0] + center = size / 2 + limits = (0, self._data.value.shape[1]) + elif axis == "y": + size = self._data.value.shape[1] + center = size / 2 + limits = (0, self._data.value.shape[0]) + else: + raise ValueError("`axis` must be one of 'x' | 'y'") + + # default padding is 25% the height or width of the image + if padding is None: + size *= 1.25 + else: + size += padding + + if selection is None: + selection = limits[0], int(limits[1] * 0.25) + + if padding is None: + size *= 1.25 + + else: + size += padding + + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + fill_color=fill_color, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_rectangle_selector( + self, + selection: tuple[float, float, float, float] = None, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them + from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + + """ + # default selection is 25% of the diagonal + if selection is None: + diagonal = math.sqrt( + self._data.value.shape[0] ** 2 + self._data.value.shape[1] ** 2 + ) + + selection = (0, int(diagonal / 4), 0, int(diagonal / 4)) + + # min/max limits are image shape + # rows are ys, columns are xs + limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0]) + + selector = RectangleSelector( + selection=selection, + limits=limits, + fill_color=fill_color, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them + from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: list[tuple[float, float]], optional + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). + + """ + + # min/max limits are image shape + # rows are ys, columns are xs + limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0]) + + selector = PolygonSelector( + selection, + limits, + fill_color=fill_color, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def format_pick_info(self, pick_info: dict) -> str: + if not self.cpu_buffer: + if self.colorspace not in ColorspacesYUV and len(self.data.shape) == 2: + # inverse map from rgb pixel value to grayscale value using the colormap + # we can only perform a guess + lut = self._material.map.texture.data + rgb = pick_info["rgba"][:3] + closest = np.argmin(np.linalg.norm(lut[:, :3] - rgb, axis=1)) + scalar = closest / (lut.shape[0] - 1) + val = self.vmin + scalar * (self.vmax - self.vmin) + return f"{val:.4g}\n!!estimate!!, cpu_buffer=False" + else: + # direct rgba vals + rgba_val = pick_info["rgba"] + info = "\n".join( + f"{channel}: {val: .4g}" for channel, val in zip("rgba", rgba_val) + ) + return info + + col, row = pick_info["index"] + if self.data.value.ndim == 2: + val = self.data[row, col] + info = f"{val:.4g}" + else: + info = "\n".join( + f"{channel}: {val:.4g}" + for channel, val in zip("rgba", self.data[row, col]) + ) + + return info + + +class ImageGraphic(ImageBase): _features = { "data": TextureArray, "cmap": ImageCmap, @@ -103,10 +373,12 @@ def __init__( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", + colorspace: ColorspacesRGB = "srgb", + cpu_buffer: bool = True, **kwargs, ): """ - Create an Image Graphic + Create an ImageGraphic Parameters ---------- @@ -130,6 +402,39 @@ def __init__( cmap_interpolation: str, optional, default "linear" colormap interpolation method, one of "nearest" or "linear" + colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" + colorspace in which to interpret the provided data. + + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. + + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. + + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection + kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -145,9 +450,16 @@ def __init__( else: # create new texture array to manage buffer # texture array that manages the multiple textures on the GPU that represent this image - self._data = TextureArray(data) + self._data = TextureArray( + data, colorspace=colorspace, cpu_buffer=cpu_buffer + ) if (vmin is None) or (vmax is None): + if self.data.value is None: + raise ValueError( + "must provide vmin, vmax if sharing a buffer that does not exist locally" + ) + _vmin, _vmax = quick_min_max(self.data.value) if vmin is None: vmin = _vmin @@ -162,11 +474,11 @@ def __init__( self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) # set map to None for RGB images - if self._data.value.ndim == 3: + if len(self.data.shape) == 3: self._cmap = None _map = None - elif self._data.value.ndim == 2: + else: # use TextureMap for grayscale images self._cmap = ImageCmap(cmap) @@ -175,12 +487,6 @@ def __init__( 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( @@ -276,6 +582,12 @@ def data(self, data): self._data[:] = data + + @property + def colorspace(self) -> ColorspacesRGB: + """The image's colorspace""" + return self.data.colorspace + @property def cmap(self) -> str | None: """ @@ -292,33 +604,6 @@ def cmap(self, name: str): raise AttributeError("RGB(A) images do not have a colormap property") self._cmap.set_value(self, name) - @property - def vmin(self) -> float: - """lower contrast limit""" - return self._vmin.value - - @vmin.setter - def vmin(self, value: float): - self._vmin.set_value(self, value) - - @property - def vmax(self) -> float: - """upper contrast limit""" - return self._vmax.value - - @vmax.setter - def vmax(self, value: float): - self._vmax.set_value(self, value) - - @property - def interpolation(self) -> str: - """Data interpolation method""" - return self._interpolation.value - - @interpolation.setter - def interpolation(self, value: str): - self._interpolation.set_value(self, value) - @property def cmap_interpolation(self) -> str: """cmap interpolation method, 'linear' or 'nearest'. Used only for grayscale images""" @@ -332,222 +617,147 @@ def reset_vmin_vmax(self): """ Reset the vmin, vmax by estimating it from the data by subsampling. """ + if self.data.value is None: + raise NotImplemented("Cannot reset vmin, vmax if `cpu_buffer=False`") vmin, vmax = quick_min_max(self._data.value) self.vmin = vmin self.vmax = vmax - def add_linear_selector( - self, selection: int = None, axis: str = "x", **kwargs - ) -> LinearSelector: - """ - Adds a :class:`.LinearSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them - from a plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: int, optional - initial position of the selector - - kwargs: - passed to :class:`.LinearSelector` - - Returns - ------- - LinearSelector - - """ - - if axis == "x": - limits = (0, self._data.value.shape[1]) - elif axis == "y": - limits = (0, self._data.value.shape[0]) - else: - raise ValueError("`axis` must be one of 'x' | 'y'") - - if selection is None: - selection = limits[0] - - if selection < limits[0] or selection > limits[1]: - raise ValueError( - f"the passed selection: {selection} is beyond the limits: {limits}" - ) - selector = LinearSelector( - selection=selection, - limits=limits, - axis=axis, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector +class ImageYUVGraphic(ImageBase): + _features = { + "data": TextureYUV, + "vmin": ImageVmin, + "vmax": ImageVmax, + "interpolation": ImageInterpolation, + } - def add_linear_region_selector( + def __init__( self, - selection: tuple[float, float] = None, - axis: str = "x", - padding: float = 0.0, - fill_color=(0, 0, 0.35, 0.2), + data: TupleYUV | TextureYUV, + vmin: float = 0, + vmax: float = 255, + interpolation: str = "nearest", + colorspace: ColorspacesYUV = "yuv420p", + colorrange: ColorRange = "limited", **kwargs, - ) -> LinearRegionSelector: + ): """ - Add a :class:`.LinearRegionSelector`. + Create an ImageYUVGraphic. Similar to ImageGraphic but handles data that is in yuv42p or yuv444p colorspace. - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them - from a plot area just like any other ``Graphic``. + Note that the buffers for YUV Images only exist on the GPU. When setting the image data, the new values are + directly sent to the GPU. + + ``reset_vmin_vmax()`` just sets (vmin, vmax) to (0, 255) Parameters ---------- - selection: (float, float) - initial (min, max) of the selection + data: TupleYUV + tuple of arrays that represent YUV channels. If the colorspace is yuv420p, the U and V array dims + must be 4 times smaller than the Y array dims. - axis: "x" | "y" - axis the selector can move along + vmin: float, optional, default 0 + minimum value for color scaling - padding: float, default 100.0 - Extends the linear selector along the perpendicular axis to make it easier to interact with. + vmax: float, optional, default 255 + maximum value for color scaling - kwargs - passed to ``LinearRegionSelector`` + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" - Returns - ------- - LinearRegionSelector + colorspace: "yuv42p" | "yuv444p" + colorspace in which to interpret the provided data. + + * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). + The y represents intensity, and is at full resolution. The u and v planes are a + quarter of the size. + + * "yuv444p": A lesser common video format. The data is represented as 3 planes + (y, u, and v) similar to yuv420p however the u and v planes are stored + at full resolution. + + colorrange: Literal["full", "limited"] = "limited", + Relevant for yuv colorspaces. Most videos use "limited". + + * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. + The chroma planes (U and V) are limited to the range of 16-240 for 8 bits + * "full": The luma plane and chroma plane use the full range of the storage format. + + See the following links from the FFMPEG documentation for more details: + https://trac.ffmpeg.org/wiki/colorspace + https://ffmpeg.org/doxygen/7.0/pixfmt_8h_source.html#l00609 + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. - """ + kwargs: + additional keyword arguments passed to :class:`.Graphic` - if axis == "x": - size = self._data.value.shape[0] - center = size / 2 - limits = (0, self._data.value.shape[1]) - elif axis == "y": - size = self._data.value.shape[1] - center = size / 2 - limits = (0, self._data.value.shape[0]) - else: - raise ValueError("`axis` must be one of 'x' | 'y'") + """ + super().__init__(**kwargs) - # default padding is 25% the height or width of the image - if padding is None: - size *= 1.25 + if isinstance(data, TextureYUV): + # share buffer + self._data = data else: - size += padding + self._data = TextureYUV(data, colorspace=colorspace) - if selection is None: - selection = limits[0], int(limits[1] * 0.25) - - if padding is None: - size *= 1.25 + self._vmin = ImageVmin(vmin) + self._vmax = ImageVmax(vmax) - else: - size += padding + self._interpolation = ImageInterpolation(interpolation) - selector = LinearRegionSelector( - selection=selection, - limits=limits, - size=size, - center=center, - axis=axis, - fill_color=fill_color, - parent=self, - **kwargs, + self._material = pygfx.ImageBasicMaterial( + clim=(vmin, vmax), interpolation=self.interpolation, pick_write=True ) - self._plot_area.add_graphic(selector, center=False) - - return selector - - def add_rectangle_selector( - self, - selection: tuple[float, float, float, float] = None, - fill_color=(0, 0, 0.35, 0.2), - **kwargs, - ) -> RectangleSelector: - """ - Add a :class:`.RectangleSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them - from a plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float, float, float), optional - initial (xmin, xmax, ymin, ymax) of the selection - - """ - # default selection is 25% of the diagonal - if selection is None: - diagonal = math.sqrt( - self._data.value.shape[0] ** 2 + self._data.value.shape[1] ** 2 - ) - - selection = (0, int(diagonal / 4), 0, int(diagonal / 4)) - - # min/max limits are image shape - # rows are ys, columns are xs - limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0]) - - selector = RectangleSelector( - selection=selection, - limits=limits, - fill_color=fill_color, - parent=self, - **kwargs, + wo = pygfx.Image( + geometry=pygfx.Geometry(grid=self.data._texture), + material=self._material, ) - self._plot_area.add_graphic(selector, center=False) - - return selector + self._set_world_object(wo) - def add_polygon_selector( - self, - selection: List[tuple[float, float]] = None, - fill_color=(0, 0, 0.35, 0.2), - **kwargs, - ) -> PolygonSelector: + @property + def data(self) -> TextureYUV: """ - Add a :class:`.PolygonSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them - from a plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: list[tuple[float, float]], optional - Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). - + YUV Texture data, note that no local buffer exists for YUV images, you can only set values but not get them """ + return self._data - # min/max limits are image shape - # rows are ys, columns are xs - limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0]) + @data.setter + def data(self, data): + self.data.set_value(self, data) - selector = PolygonSelector( - selection, - limits, - fill_color=fill_color, - parent=self, - **kwargs, - ) + @property + def colorspace(self) -> ColorspacesYUV: + """image's colorspace""" + return self.data.colorspace - self._plot_area.add_graphic(selector, center=False) + @property + def colorrange(self) -> ColorRange: + """the color range, see docstring for details""" + return self.data.colorrange - return selector + @property + def cmap(self): + raise NotImplemented("YUV images don't have a cmap") - def format_pick_info(self, pick_info: dict) -> str: - col, row = pick_info["index"] - if self.data.value.ndim == 2: - val = self.data[row, col] - info = f"{val:.4g}" - else: - info = "\n".join( - f"{channel}: {val:.4g}" - for channel, val in zip("rgba", self.data[row, col]) - ) + @property + def cmap_interpolation(self): + raise NotRequired("YUV images don't have a cmap") - return info + def reset_vmin_vmax(self): + """reset vmin, vmax to (0, 255)""" + self.vmin, self.vmax = 0, 255 diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 8a8583ae9..10dcfdc3e 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -341,6 +341,12 @@ def get_selected_data( """ source = self._get_source(graphic) + + if source.data.value is None: + raise ValueError( + "Cannot get selected data. The graphic has no local buffer, `cpu_buffer` is probably `False`." + ) + ixs = self.get_selected_indices(source) if "Line" in source.__class__.__name__: diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index e02c627ac..5a05bc886 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -200,6 +200,12 @@ def get_selected_data( view or list of views of the full array, returns empty array if selection is empty """ source = self._get_source(graphic) + + if source.data.value is None: + raise ValueError( + "Cannot get selected data. The graphic has no local buffer, `cpu_buffer` is probably `False`." + ) + ixs = self.get_selected_indices(source) # do not need to check for mode for images, because the selector is bounded by the image shape diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index e30165dae..f15f292f8 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -381,6 +381,12 @@ def get_selected_data( view or list of views of the full array, returns empty array if selection is empty """ source = self._get_source(graphic) + + if source.data.value is None: + raise ValueError( + "Cannot get selected data. The graphic has no local buffer, `cpu_buffer` is probably `False`." + ) + ixs = self.get_selected_indices(source) # do not need to check for mode for images, because the selector is bounded by the image shape diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 013ce847c..f166c18ae 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -19,7 +19,7 @@ from ._utils import controller_types as valid_controller_types from ._subplot import Subplot from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera -from .. import ImageGraphic +from .. import ImageGraphic, ImageYUVGraphic class Figure: @@ -617,7 +617,7 @@ def show( # flip y-axis if ImageGraphics are present for subplot in self._subplots.ravel(): for g in subplot.graphics: - if isinstance(g, ImageGraphic): + if isinstance(g, (ImageGraphic, ImageYUVGraphic)): if subplot.camera.local.scale_y == 1: # if it's 1 it's likely not been touched manually before show was called subplot.camera.local.scale_y = -1 diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 1fbf337e2..9eae4dd12 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -8,6 +8,8 @@ from ..graphics import * from ..graphics._base import Graphic +import typing +import fastplotlib class GraphicMethodsMixin: @@ -33,11 +35,13 @@ def add_image( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", + colorspace: fastplotlib.utils.enums.ColorspacesRGB = "srgb", + cpu_buffer: bool = True, **kwargs ) -> ImageGraphic: """ - Create an Image Graphic + Create an ImageGraphic Parameters ---------- @@ -61,6 +65,38 @@ def add_image( cmap_interpolation: str, optional, default "linear" colormap interpolation method, one of "nearest" or "linear" + colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" + colorspace in which to interpret the provided data. + + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. + + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. + + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -74,6 +110,8 @@ def add_image( cmap, interpolation, cmap_interpolation, + colorspace, + cpu_buffer, **kwargs ) @@ -172,6 +210,98 @@ def add_image_volume( **kwargs ) + def add_image_yuv( + self, + data: ( + tuple[ + numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.uint8]], + numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.uint8]], + numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.uint8]], + ] + | fastplotlib.graphics.features._image.TextureYUV + ), + vmin: float = 0, + vmax: float = 255, + interpolation: str = "nearest", + colorspace: fastplotlib.utils.enums.ColorspacesYUV = "yuv420p", + colorrange: fastplotlib.utils.enums.ColorRange = "limited", + **kwargs + ) -> ImageYUVGraphic: + """ + + Create an ImageYUVGraphic. Similar to ImageGraphic but handles data that is in yuv42p or yuv444p colorspace. + + Note that the buffers for YUV Images only exist on the GPU. When setting the image data, the new values are + directly sent to the GPU. + + ``reset_vmin_vmax()`` just sets (vmin, vmax) to (0, 255) + + Parameters + ---------- + data: TupleYUV + tuple of arrays that represent YUV channels. If the colorspace is yuv420p, the U and V array dims + must be 4 times smaller than the Y array dims. + + vmin: float, optional, default 0 + minimum value for color scaling + + vmax: float, optional, default 255 + maximum value for color scaling + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + colorspace: "yuv42p" | "yuv444p" + colorspace in which to interpret the provided data. + + * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). + The y represents intensity, and is at full resolution. The u and v planes are a + quarter of the size. + + * "yuv444p": A lesser common video format. The data is represented as 3 planes + (y, u, and v) similar to yuv420p however the u and v planes are stored + at full resolution. + + colorrange: Literal["full", "limited"] = "limited", + Relevant for yuv colorspaces. Most videos use "limited". + + * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. + The chroma planes (U and V) are limited to the range of 16-240 for 8 bits + * "full": The luma plane and chroma plane use the full range of the storage format. + + See the following links from the FFMPEG documentation for more details: + https://trac.ffmpeg.org/wiki/colorspace + https://ffmpeg.org/doxygen/7.0/pixfmt_8h_source.html#l00609 + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + + """ + return self._create_graphic( + ImageYUVGraphic, + data, + vmin, + vmax, + interpolation, + colorspace, + colorrange, + **kwargs + ) + def add_line_collection( self, data: Union[numpy.ndarray, List[numpy.ndarray]], diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py index 49120c71a..453b1ce11 100644 --- a/fastplotlib/layouts/_utils.py +++ b/fastplotlib/layouts/_utils.py @@ -4,7 +4,7 @@ import numpy as np import pygfx -from pygfx import WgpuRenderer, Texture, Renderer +from pygfx import WgpuRenderer, Texture from ..utils.gui import BaseRenderCanvas, RenderCanvas @@ -22,7 +22,7 @@ def make_canvas_and_renderer( canvas: str | BaseRenderCanvas | Texture | None, - renderer: Renderer | None, + renderer: WgpuRenderer | None, canvas_kwargs: dict, ): """ @@ -45,9 +45,13 @@ def make_canvas_and_renderer( if renderer is None: renderer = WgpuRenderer(canvas) - elif not isinstance(renderer, Renderer): + + # disable AA and set pixel_scale = 1.0 for performance + renderer.ppaa = "none" + renderer.pixel_scale = 1.0 + elif not isinstance(renderer, WgpuRenderer): raise TypeError( - f"renderer option must be a pygfx.Renderer instance such as pygfx.WgpuRenderer" + f"renderer option must be a pygfx.WgpuRenderer instance" ) return canvas, renderer diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index f2eed65b6..cb6a240d1 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -2,10 +2,10 @@ # this MUST be imported as early as possible in fpl.__init__ before any other wgpu stuff from .gui import loop +from .enums import * from .functions import * from .gpu import enumerate_adapters, select_adapter, print_wgpu_report from ._plot_helpers import * -from .enums import * from .protocols import ARRAY_LIKE_ATTRS, ArrayProtocol, FutureProtocol, CudaArrayProtocol diff --git a/fastplotlib/utils/enums.py b/fastplotlib/utils/enums.py index 3901b082c..44601350d 100644 --- a/fastplotlib/utils/enums.py +++ b/fastplotlib/utils/enums.py @@ -1,4 +1,4 @@ -from enum import IntEnum +from enum import IntEnum, StrEnum class RenderQueue(IntEnum): @@ -13,3 +13,19 @@ class RenderQueue(IntEnum): # the graphics. Axes (rulers) have depth_compare '<=' and selectors don't compare depth. axes = 3400 # still in 'object' group selector = 3600 # considered in 'overlay' group + + +class ColorspacesRGB(StrEnum): + srgb = "srgb" + tex_srgb = "tex-srgb" + physical = "physical" + + +class ColorspacesYUV(StrEnum): + yuv420p = "yuv420p" + yuv444p = "yuv444p" + + +class ColorRange(StrEnum): + full = "full" + limited = "limited" diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index 8416288c7..d3e92f053 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -5,6 +5,7 @@ from ._base import NDProcessor, NDGraphic from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras from ._nd_image import NDImageProcessor, NDImage + from ._video import VideoProcessor from ._nd_vectors import NDVectorsProcessor, NDVectors from ._ndwidget import NDWidget diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 5c2747d2b..724f5fdd6 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -501,7 +501,9 @@ def get_window_output(self, indices: dict[str, Any]) -> AwaitedArray: windowed_slice = windowed_slice.squeeze(axis=slider_dims_int) if windowed_slice.ndim != len(self.spatial_dims): - raise ValueError + raise ValueError( + f"windowed_slice.ndim != len(self.spatial_dims): {windowed_slice.ndim} != {len(self.spatial_dims)}" + ) # transpose to spatial dims spatial_dims_int = tuple( diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 9fa39606d..6463eba88 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -1,14 +1,20 @@ from collections.abc import Sequence, Generator -from typing import Callable, Any +from typing import Callable, Any, Literal import numpy as np from numpy.typing import ArrayLike from ...layouts import Subplot -from ...utils import subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol -from ...graphics import ImageGraphic, ImageVolumeGraphic +from ...utils import subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol, enums +from ...graphics import ImageGraphic, ImageYUVGraphic, ImageVolumeGraphic from ...tools import HistogramLUTTool -from ._base import NDProcessor, NDGraphic, WindowFuncCallable, block_reentrance, AwaitedArray +from ._base import ( + NDProcessor, + NDGraphic, + WindowFuncCallable, + block_reentrance, + AwaitedArray, +) from ._index import ReferenceIndex from ._async import start_coroutine @@ -284,6 +290,11 @@ def __init__( spatial_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, slider_dim_transforms=None, + processor_type: type[NDImageProcessor] = NDImageProcessor, + colorspace: Literal[ + "srgb", "tex-srgb", "physical", "yuv420p", "yuv444p" + ] = "srgb", + colorrange: Literal["full", "limited"] = "full", name: str = None, ): """ @@ -359,7 +370,7 @@ def __init__( self._ref_index = ref_index - self._processor = NDImageProcessor( + self._processor = processor_type( data, dims=dims, spatial_dims=spatial_dims, @@ -371,7 +382,10 @@ def __init__( slider_dim_transforms=slider_dim_transforms, ) - self._graphic: ImageGraphic | None = None + self._colorspace = colorspace + self._colorrange = colorrange + + self._graphic: ImageGraphic | ImageYUVGraphic | None = None self._histogram_widget: HistogramLUTTool | None = None # create a graphic @@ -385,7 +399,7 @@ def processor(self) -> NDImageProcessor: @property def graphic( self, - ) -> ImageGraphic | ImageVolumeGraphic: + ) -> ImageGraphic | ImageYUVGraphic | ImageVolumeGraphic: """Underlying Graphic object used to display the current data slice""" return self._graphic @@ -398,15 +412,23 @@ def _create_graphic(self): # no graphic if data is None, useful for initializing in null states when we want to set data later return - # determine if we need a 2d image or 3d volume - # remove RGB spatial dim, ex: if we have an RGBA image of shape [512, 512, 4] we want to interpet this as - # 2D for images - # [30, 512, 512, 4] with an rgb dim is an RGBA volume which is also supported - match len(self.processor.spatial_dims) - int(bool(self.processor.rgb_dim)): - case 2: - cls = ImageGraphic - case 3: - cls = ImageVolumeGraphic + kwargs = { + "colorspace": self._colorspace, + } + + if self._colorspace in {cs.value for cs in enums.ColorspacesYUV}: + cls = ImageYUVGraphic + kwargs["colorrange"] = self._colorrange + else: + # determine if we need a 2d image or 3d volume + # remove RGB spatial dim, ex: if we have an RGBA image of shape [512, 512, 4] we want to interpet this as + # 2D for images + # [30, 512, 512, 4] with an rgb dim is an RGBA volume which is also supported + match len(self.processor.spatial_dims) - int(bool(self.processor.rgb_dim)): + case 2: + cls = ImageGraphic + case 3: + cls = ImageVolumeGraphic # get the data slice for this index # this will only have the dims specified by ``spatial_dims`` @@ -414,7 +436,11 @@ def _create_graphic(self): data_slice = yield from self._get_data_slice(self.indices) # create the new graphic - new_graphic = cls(data_slice) + new_graphic = cls( + data_slice, + # cpu_buffer=False, # faster, we usually don't need a cpu buffer for NDWidget use cases + **kwargs, + ) old_graphic = self._graphic # check if we are replacing a graphic @@ -470,7 +496,7 @@ def _reset_histogram(self): def _reset_camera(self): # set camera to a nice position based on whether it's a 2D ImageGraphic or 3D ImageVolumeGraphic - if isinstance(self._graphic, ImageGraphic): + if isinstance(self._graphic, (ImageGraphic, ImageYUVGraphic)): # set camera orthogonal to the xy plane, flip y axis self._subplot.camera.set_state( { diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 5bee2dc30..3c655d662 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -12,9 +12,10 @@ VectorsGraphic, ) from ...layouts import Subplot -from ...utils import ArrayProtocol -from . import NDImage, NDPositions, NDVectors -from ._base import NDGraphic, WindowFuncCallable +from ...utils import ArrayProtocol, enums +from . import NDImageProcessor, NDImage, NDPositions, NDVectors +from ._video import VideoProcessor +from ._base import NDProcessor, NDGraphic, WindowFuncCallable class NDWSubplot: @@ -65,6 +66,7 @@ def add_nd_image( compute_histogram: bool = True, slider_dim_transforms=None, name: str = None, + **kwargs, ): nd = NDImage( self.ndw.indices, @@ -79,11 +81,34 @@ def add_nd_image( compute_histogram=compute_histogram, slider_dim_transforms=slider_dim_transforms, name=name, + **kwargs, ) self._nd_graphics.append(nd) return nd + def add_video( + self, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[str, str] | tuple[str, str, str], + rgb_dim: str | None = None, + colorspace: enums.ColorspacesYUV | enums.ColorspacesRGB = "yuv420p", + colorrange: enums.ColorRange = "limited", + processor_type: NDImageProcessor = VideoProcessor, + **kwargs, + ): + return self.add_nd_image( + data=data, + dims=dims, + spatial_dims=spatial_dims, + rgb_dim=rgb_dim, + colorspace=colorspace, + colorrange=colorrange, + processor_type=processor_type, + **kwargs, + ) + def add_nd_vectors( self, data: ArrayProtocol | None, diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py new file mode 100644 index 000000000..f1dab6f9e --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -0,0 +1,27 @@ +from ._nd_image import NDImageProcessor, NDImage +from typing import Callable, Any, Literal + +import numpy as np + + +class VideoProcessor(NDImageProcessor): + def get_window_output(self, indices: dict[str, Any]): + """ + Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims + + Parameters + ---------- + indices + + Returns + ------- + + """ + # windowed slice if user set any window funcs + windowed_slice = yield from self._get_raw_data_slice(indices) + + if isinstance(windowed_slice, (tuple, list)): + return tuple(a.squeeze() for a in windowed_slice) + + # convert to numpy array + return np.asarray(windowed_slice) diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 865eab27f..c5a526e93 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -35,7 +35,10 @@ def generate_add_graphics_methods(): f.write("import numpy\n\n") f.write("import pygfx\n\n") f.write("from ..graphics import *\n") - f.write("from ..graphics._base import Graphic\n\n") + f.write("from ..graphics._base import Graphic\n") + f.write("from ..utils import enums\n") + f.write("import typing\n") + f.write("import fastplotlib\n\n") f.write("\nclass GraphicMethodsMixin:\n") @@ -52,11 +55,14 @@ def generate_add_graphics_methods(): f.write(" self.add_graphic(graphic, center=center)\n\n") f.write(" return graphic\n\n") + # from https://stackoverflow.com/a/1176023 + camel_to_snake = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") + for m in modules: cls = m cls_name = cls.__name__.replace("Graphic", "") - # from https://stackoverflow.com/a/1176023 - method_name = re.sub(r"(? Date: Thu, 16 Apr 2026 23:45:44 -0400 Subject: [PATCH 110/163] cleanup --- fastplotlib/graphics/features/_image.py | 4 +--- fastplotlib/widgets/nd_widget/_video.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index a2c6f1183..b47fc41ea 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -48,6 +48,7 @@ def __init__( super().__init__(property_name=property_name) self._colorspace = ColorspacesRGB(colorspace) + self._cpu_buffer = cpu_buffer data = self._check_data(data, colorspace, cpu_buffer) self._shape = data.shape @@ -123,9 +124,6 @@ def __init__( self.buffer[buffer_index] = texture - self._colorspace = colorspace - self._cpu_buffer = cpu_buffer - @property def colorspace( self, diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py index f1dab6f9e..d1db1d7d8 100644 --- a/fastplotlib/widgets/nd_widget/_video.py +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -24,4 +24,4 @@ def get_window_output(self, indices: dict[str, Any]): return tuple(a.squeeze() for a in windowed_slice) # convert to numpy array - return np.asarray(windowed_slice) + return np.asarray(windowed_slice).squeeze() From 7dbbd316b66f9f946c7c94dcbe81f14b291f743e Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Fri, 24 Apr 2026 23:09:40 -0400 Subject: [PATCH 111/163] `SelectionVector`, `HighlightSelector`, `VisibilitySelector`, `SelectorCollection` (#1038) * start selection vector stuff, protocol * highlight selector WIP * fixes * progress * lut repeat * highlight and visibility selectors working, selection vector working * PlotArea checks for SelectorProtocol instead, move some stuff around * selector collection, not yet tested * cleanup * bugfix for linear selector limits * add example * better append, remove * cleanup * basically rewrote entire ImageHighlightSelector * black * happy with iamge selectors * toy multi session example * correct mapping stuff * better scalar/vector handling * comments --- .../selection_tools/highlight_selector.py | 89 ++ .../selection_tools/visibility_selector.py | 186 ++++ fastplotlib/graphics/image.py | 17 +- fastplotlib/graphics/line_collection.py | 1 + fastplotlib/graphics/scatter_collection.py | 13 +- fastplotlib/graphics/selectors/__init__.py | 35 +- .../graphics/selectors/_base_selector.py | 3 +- .../graphics/selectors/_highlight_selector.py | 978 ++++++++++++++++++ fastplotlib/graphics/selectors/_protocols.py | 30 + .../graphics/selectors/_selection_vector.py | 89 ++ .../selectors/_selector_collection.py | 389 +++++++ .../selectors/_visibility_selector.py | 427 ++++++++ fastplotlib/graphics/shaders/__init__.py | 15 + .../graphics/shaders/_highlight_materials.py | 102 ++ .../graphics/shaders/_highlight_shaders.py | 414 ++++++++ fastplotlib/layouts/_plot_area.py | 50 +- .../nd_widget/_nd_positions/_nd_positions.py | 16 +- 17 files changed, 2832 insertions(+), 22 deletions(-) create mode 100644 examples/selection_tools/highlight_selector.py create mode 100644 examples/selection_tools/visibility_selector.py create mode 100644 fastplotlib/graphics/selectors/_highlight_selector.py create mode 100644 fastplotlib/graphics/selectors/_protocols.py create mode 100644 fastplotlib/graphics/selectors/_selection_vector.py create mode 100644 fastplotlib/graphics/selectors/_selector_collection.py create mode 100644 fastplotlib/graphics/selectors/_visibility_selector.py create mode 100644 fastplotlib/graphics/shaders/__init__.py create mode 100644 fastplotlib/graphics/shaders/_highlight_materials.py create mode 100644 fastplotlib/graphics/shaders/_highlight_shaders.py diff --git a/examples/selection_tools/highlight_selector.py b/examples/selection_tools/highlight_selector.py new file mode 100644 index 000000000..e4c9dde91 --- /dev/null +++ b/examples/selection_tools/highlight_selector.py @@ -0,0 +1,89 @@ +""" +Highlight Selector +================== + +NDWidget with a time-varying 100x100 image (two circles driven by sine/cosine) +and a heatmap of all pixel timeseries. Clicking a row of the heatmap highlights +that row and the corresponding pixel on the image. +Shift-click appends; plain click replaces the selection. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from fastplotlib.graphics import ImageGraphic +from fastplotlib.graphics.selectors import ImageHighlightSelector +from fastplotlib.utils.functions import heatmap_to_positions + +# --- synthetic data --- +n_t = 100 +n_y, n_x = 100, 100 + +rng = np.random.default_rng(0) +vol = np.zeros((n_t, n_y, n_x), dtype=np.float32) + +yy, xx = np.ogrid[:n_y, :n_x] +mask1 = (yy - 30) ** 2 + (xx - 30) ** 2 < 15**2 +mask2 = (yy - 70) ** 2 + (xx - 70) ** 2 < 15**2 + +t = np.linspace(0, 2 * np.pi, n_t) +for i in range(n_t): + vol[i, mask1] = np.sin(t[i]) + rng.normal(0, 0.05, mask1.sum()) + vol[i, mask2] = np.cos(t[i]) + rng.normal(0, 0.05, mask2.sum()) + +# heatmap: (n_pixels, n_t), then convert to positions for add_nd_timeseries +heatmap = vol.reshape(n_t, n_y * n_x).T.astype(np.float32) # (n_pixels, n_t) +xvals = np.arange(n_t, dtype=np.float32) +heatmap_pos = heatmap_to_positions(heatmap, xvals) # (n_pixels, n_t, 2) + +# --- layout --- +ndw = fpl.NDWidget(ref_ranges={"t": (0, n_t, 1)}, shape=(1, 2), size=(1400, 560)) + +nd_img = ndw[0, 0].add_nd_image(vol, ("t", "y", "x"), ("y", "x"), name="image") + +nd_hm = ndw[0, 1].add_nd_timeseries( + heatmap_pos, + dims=("pixel", "t", "xy"), + spatial_dims=("pixel", "t", "xy"), + graphic_type=ImageGraphic, + x_range_mode="fixed", + display_window=None, + name="heatmap", +) + +# --- highlight selectors --- +img_sel = ImageHighlightSelector(color="w", alpha=0.4) +img_sel.add_graphic(nd_img.graphic) + +hm_sel = ImageHighlightSelector(color="w", alpha=0.4) +hm_sel.add_graphic(nd_hm.graphic) + + +@nd_hm.graphic.add_event_handler("double_click") +def on_heatmap_click(ev): + idx = ev.pick_info.get("index") + if idx is None: + return + # index = (col, row) = (timepoint, pixel_idx) + pixel_idx = idx[1] + row = pixel_idx // n_x + col = pixel_idx % n_x + + if "Shift" in ev.modifiers: + hm_sel.append("rows", pixel_idx) + img_sel.append("pixels", np.array([[row, col]])) + print(hm_sel.selection) + else: + hm_sel.selection = {"rows": [pixel_idx]} + img_sel.selection = {"pixels": [np.array([[row, col]])]} + + +ndw.show(maintain_aspect=False) + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/selection_tools/visibility_selector.py b/examples/selection_tools/visibility_selector.py new file mode 100644 index 000000000..996204536 --- /dev/null +++ b/examples/selection_tools/visibility_selector.py @@ -0,0 +1,186 @@ +""" +Visibility and Highlight Selector +================================= + +Example with an image that contains time-varying signals. An ``ImageHighlightSelector`` is created with pre-loaded +options for either contour outlines or filled masks that spatially denote a unique signal in the image. A +``VisiblitySelector`` is used on a LineCollection. When the image is clicked, the closest spatial signal is highlighted +and the corresponding line is made visible. Shift + click to multi-select signals. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +from functools import partial +import numpy as np +from scipy.ndimage import binary_erosion +import fastplotlib as fpl +import cmap as cmap_lib + +n_t = 500 +n_y, n_x = 128, 128 +n_circles = 32 +radius = 4 # diameter 5 + +rng = np.random.default_rng(0) + +# Random circle centers +centers = rng.integers(0, [n_y, n_x], size=(n_circles, 2)) + +yy, xx = np.ogrid[:n_y, :n_x] + +movies_sessions = list() +contours_sessions = list() +signals_sessions = list() +centers_per_session = list() +indices_per_session = list() + +# just generate multi-session toy data +for session_index in range(3): + masks = [] + contours = [] # perimeter pixel coordinates per circle + + for cy, cx in centers: + mask = (yy - cy) ** 2 + (xx - cx) ** 2 <= radius**2 + masks.append(mask) + # Perimeter = filled mask minus its erosion + perimeter = mask # & ~binary_erosion(mask) + contours.append(np.argwhere(perimeter)) # shape (K, 2), columns are [y, x] + + images = np.zeros((n_t, n_y, n_x), dtype=np.float32) + t = np.linspace(0, 10 * np.pi, n_t) + phases = 2 * np.pi * np.arange(n_circles) / n_circles + + signals = list() + for j, mask in enumerate(masks): + signal = np.sin(t + phases[j]).astype(np.float32) # (n_t,) + noise = rng.normal(0, 0.05, (n_t, mask.sum())).astype(np.float32) # (n_t, K) + signal = signal[:, None] + noise + images[:, mask] += signal + signals.append(signal.mean(axis=1)) + + signals = np.stack(signals) + + # just to create diff indices per session + local_indices = np.roll(np.arange(n_circles), shift=session_index) + + indices_per_session.append(local_indices) + + movies_sessions.append(images) + + # re-order stuff in local index order + centers_per_session.append(centers[local_indices]) + contours_sessions.append([contours[i] for i in local_indices]) + signals_sessions.append(signals[local_indices]) + + +# Just NDWidget & figure stuff +extents = { + "images-0": (0, 0.33, 0, 0.33), + "signals-0": (0.33, 1, 0, 0.33), + "images-1": (0, 0.33, 0.33, 0.67), + "signals-1": (0.33, 1, 0.33, 0.67), + "images-2": (0, 0.33, 0.67, 1), + "signals-2": (0.33, 1, 0.67, 1), +} + +ref_range = {"time": (0, n_t, 1)} +ndw = fpl.NDWidget( + ref_range, + extents=extents, + controller_ids=[ + ("images-0", "images-1", "images-2"), + ], + size=(1300, 1000) +) + +# create selection vector +sv = fpl.SelectionVector() + +# mapping to go from master index -> per session index for a given session +# this must be a vector -> vector mapping since multiple things can be selected +def master_to_local_index(session_id: int, selection_indices: list[int]) -> list[int]: + return [i + session_id for i in selection_indices] + + +# image click changes the selection, can change the selection vector in any other way too +def image_clicked(session, ev): + col, row = ev.pick_info["index"] + + local_index = np.argmin( + np.linalg.norm(centers_per_session[session] - np.array([row, col]), axis=1) + ) + + # inverse transform, local scalar index -> master index + master_index = local_index - session + + print(local_index, master_index) + + global sv + + if "Shift" in ev.modifiers: + sv.append(master_index) + else: + # just one item selected + sv.selection = [master_index] + + for subplot in ndw.figure: + if "signals" in subplot.name: + subplot.auto_scale() + + +# iterate through all the toy data, create NDGraphics and selectors +for session_index, (indices, movie, contours, signals) in enumerate( + zip(indices_per_session, movies_sessions, contours_sessions, signals_sessions) +): + # create NDImage, nothing special here + ndi = ndw[f"images-{session_index}"].add_nd_image( + movie, + dims=("time", "m", "n"), + spatial_dims=list("mn"), + ) + ndi.graphic.cmap = "gray" + # create ND Timeseries, again nothing special + ndt = ndw[f"signals-{session_index}"].add_nd_timeseries( + fpl.utils.heatmap_to_positions(signals, xvals=np.arange(0, n_t)), + dims=("l", "time", "d"), + spatial_dims=("l", "time", "d"), + x_range_mode="fixed", + display_window=None, + ) + + # Create selectors + # image highlight selector for this session + image_selector = fpl.ImageHighlightSelector( + ndi.graphic, # target graphic, you can also add more target graphics later + # as long as they are in the same "selection space", ex: each movie for single-session + # each selector manages ONE buffer, so the same pixels will be highlighted on all graphics + # targetted by a selector. + lut="tab10", + selection_options={"pixels": contours}, # pre-loaded selection options + options_alpha=0.1, # unselected contours shown with low alpha + options_color="w", # unselected contours shown this color + lut_wrap="repeat", # cycles through tab10 colormap if you select > 10 items + alpha=0.7, # highlight alpha + ) + + # selector that toggles visibility of lines in the line stack + # use same lut as the image highlight + traces_visible_selector = fpl.VisibilitySelector( + ndt.graphic, lut="tab10", lut_wrap="repeat" + ) + + # image selector targets the image graphic for this session + image_selector.add_graphic(ndi.graphic) + # when image is double clicked, calls the handler + ndi.graphic.add_event_handler(partial(image_clicked, session_index), "double_click") + + # add selectors to SelectionVector + # with mapping that defines how to map from master index to local index for this session + mapping = partial(master_to_local_index, session_index) + sv.add_selector((image_selector, mapping)) + sv.add_selector((traces_visible_selector, mapping)) + +ndw.show() + +fpl.loop.run() diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 3d17d44a2..2452733d3 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -5,6 +5,7 @@ import pygfx from pygfx import Texture +from .shaders import HighlightableImageMaterial from ..utils import quick_min_max, ColorspacesRGB, ColorspacesYUV, ColorRange from ._base import Graphic from .selectors import ( @@ -48,11 +49,23 @@ def __init__( chunk_index: tuple[int, int], **kwargs, ): + self._vis_scale = None # (axis_index, scale) set by ImageVisibilitySelector super().__init__(geometry, material, **kwargs) self._data_slice = data_slice self._chunk_index = chunk_index + def get_bounding_box(self): + aabb = super().get_bounding_box() + if aabb is None or self._vis_scale is None: + return aabb + ax_i, scale = self._vis_scale + if scale == 0.0: + return None + aabb = aabb.copy() + aabb[1, ax_i] = aabb[0, ax_i] + (aabb[1, ax_i] - aabb[0, ax_i]) * scale + return aabb + def _wgpu_get_pick_info(self, pick_value): pick_info = super()._wgpu_get_pick_info(pick_value) @@ -489,7 +502,7 @@ def __init__( ) # one common material is used for every Texture chunk - self._material = pygfx.ImageBasicMaterial( + self._material = HighlightableImageMaterial( clim=(vmin, vmax), map=_map, interpolation=self._interpolation.value, @@ -718,7 +731,7 @@ def __init__( self._interpolation = ImageInterpolation(interpolation) - self._material = pygfx.ImageBasicMaterial( + self._material = HighlightableImageMaterial( clim=(vmin, vmax), interpolation=self.interpolation, pick_write=True ) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 351f3368e..3656b5d39 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -655,4 +655,5 @@ def __init__( axis_zero + line.data.value[:, axes[separation_axis]].max() + separation ) + self.separation_axis = separation_axis self.separation = separation diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py index f0993dd46..b2d150d23 100644 --- a/fastplotlib/graphics/scatter_collection.py +++ b/fastplotlib/graphics/scatter_collection.py @@ -643,11 +643,16 @@ def __init__( **kwargs, ) - self._sepration_axis = separation_axis + self._separation_axis = separation_axis self._separation = separation self.separation = separation + @property + def separation_axis(self) -> str: + """axis along which the graphics are separated: ``'x'`` or ``'y'``""" + return self._separation_axis + @property def separation(self) -> float: """distance between each line in the stack, in world space""" @@ -659,14 +664,14 @@ def separation(self, value: float): axis_zero = 0 for i, line in enumerate(self.graphics): - if self._sepration_axis == "x": + if self._separation_axis == "x": line.offset = (axis_zero, *line.offset[1:]) - elif self._sepration_axis == "y": + elif self._separation_axis == "y": line.offset = (line.offset[0], axis_zero, line.offset[2]) axis_zero = ( - axis_zero + line.data.value[:, axes[self._sepration_axis]].max() + separation + axis_zero + line.data.value[:, axes[self._separation_axis]].max() + separation ) self._separation = value diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 9133192e9..8b2c109fe 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,7 +1,38 @@ +from ._protocols import SelectorProtocol, MultiSelectorProtocol from ._linear import LinearSelector from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector from ._rectangle import RectangleSelector +from ._highlight_selector import ( + HighlightSelector, + PositionsHighlightSelector, + CollectionHighlightSelector, + ImageHighlightSelector, +) +from ._visibility_selector import VisibilitySelector, ImageVisibilitySelector +from ._selector_collection import ( + SelectorCollection, + LinearSelectors, + LinearRegionSelectors, + RectangleSelectors, + PolygonSelectors, +) +from ._selection_vector import SelectionVector - -__all__ = ["LinearSelector", "LinearRegionSelector", "RectangleSelector"] +__all__ = [ + "LinearSelector", + "LinearRegionSelector", + "RectangleSelector", + "HighlightSelector", + "PositionsHighlightSelector", + "CollectionHighlightSelector", + "ImageHighlightSelector", + "VisibilitySelector", + "ImageVisibilitySelector", + "SelectorCollection", + "LinearSelectors", + "LinearRegionSelectors", + "RectangleSelectors", + "PolygonSelectors", + "SelectionVector", +] diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index 28c6534a7..b73e36a5f 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -7,6 +7,7 @@ from pygfx import WorldObject, Line, Mesh, Points +from ._protocols import SelectorProtocol from .._base import Graphic @@ -39,7 +40,7 @@ class MoveInfo: # Selector base class -class BaseSelector(Graphic): +class BaseSelector(Graphic, SelectorProtocol): _fpl_support_tooltip = False @property diff --git a/fastplotlib/graphics/selectors/_highlight_selector.py b/fastplotlib/graphics/selectors/_highlight_selector.py new file mode 100644 index 000000000..ac1de45e7 --- /dev/null +++ b/fastplotlib/graphics/selectors/_highlight_selector.py @@ -0,0 +1,978 @@ +from __future__ import annotations + +from typing import Iterable +from numbers import Integral +from typing import Callable, Literal +from warnings import warn + +import cmap as cmap_lib +import numpy as np +import pygfx +import wgpu + +from .._collection_base import GraphicCollection +from ..shaders._highlight_materials import ( + HighlightableLineMaterial, + HighlightableLineThinMaterial, + HighlightablePointsMaterial, + HighlightablePointsMarkerMaterial, + HighlightablePointsSpriteMaterial, + HighlightablePointsGaussianBlobMaterial, + HighlightableImageMaterial, +) + +_POSITIONS_MATERIAL_TYPES = ( + HighlightableLineMaterial, + HighlightableLineThinMaterial, + HighlightablePointsMaterial, + HighlightablePointsMarkerMaterial, + HighlightablePointsSpriteMaterial, + HighlightablePointsGaussianBlobMaterial, +) + +cmap_lib.Colormap("tab10").lut() + + +def _build_lut( + color: str | np.ndarray = "red", + lut: str | np.ndarray | None = None, + n: int = 1, + lut_wrap: Literal["fixed", "repeat"] = "fixed", +) -> np.ndarray: + """ + Return an (n, 4) float32 RGBA array for n selected items. + """ + + if n == 0: + return np.zeros((1, 4), dtype=np.float32) + + if lut is not None: + if isinstance(lut, str): + lut = cmap_lib.Colormap(lut).lut(n) + + lut = np.asarray(lut, dtype=np.float32) + + if lut.ndim != 2 or lut.shape[1] != 4: + raise ValueError("`lut` must have shape (n, 4) for n selected items") + + if lut_wrap == "repeat": + return lut[np.arange(n) % len(lut)] + + if lut.shape[0] < n: + raise ValueError( + f"`lut` has only {lut.shape[0]} entries but {n} are selected" + ) + + return lut[:n] + + return np.repeat([pygfx.Color(color)], n, axis=0) + + +class HighlightSelector: + """ + Base class managing highlight state on one or more graphics. + + Highlights selected vertices or image regions by blending a color into the + rendered output. Does not create extra world objects, so ``pick_info`` + is unaffected. + + Use the subclasses: + + * :class:`PositionsHighlightSelector`: highlight individual vertices on a + LineGraphic or ScatterGraphic + * :class:`CollectionHighlightSelector`: highlight whole lines/scatters in + a collection + * :class:`ImageHighlightSelector`: highlight pixel regions of an ImageGraphic + """ + + def __init__( + self, + color: str | np.ndarray = "red", + lut: str | np.ndarray | None = None, + lut_wrap: Literal["fixed", "repeat"] = "fixed", + alpha: float = 0.7, + ): + if lut_wrap not in ("fixed", "repeat"): + raise ValueError(f"lut_wrap must be 'fixed' or 'repeat', got {lut_wrap!r}") + + self._color = color + self._lut = lut + self._alpha = float(alpha) + self._lut_wrap = lut_wrap + self._graphics = list() + self._event_handlers: list[Callable] = list() + + @property + def selection(self): + raise NotImplementedError + + @selection.setter + def selection(self, value): + raise NotImplementedError + + def append(self, item) -> None: + raise NotImplementedError + + def remove(self, item) -> None: + raise NotImplementedError + + def clear(self) -> None: + raise NotImplementedError + + @property + def color(self) -> str | np.ndarray: + """ + Get or set color applied to all selected items, used if ``lut`` is ``None``. + + Accepts any value that ``pygfx.Color`` understands (color name string, + RGBA tuple, hex string, etc.). + """ + return self._color + + @color.setter + def color(self, value): + self._color = value + self._update_all_graphics() + + @property + def lut(self) -> str | np.ndarray | None: + """ + Get or set per-item color lookup table, shape ``(n, 4)`` float32 RGBA, or a str + that defines a colormap. + + When set, ``lut[i]`` is the highlight color for the i-th selected item. + Must have at least as many rows as the number of selected items. + Set to ``None`` to fall back to ``color``. + """ + return self._lut + + @lut.setter + def lut(self, value: np.ndarray | None): + self._lut = value + self._update_all_graphics() + + @property + def lut_wrap(self) -> str: + """ + Get or set LUT wrap mode. + - "fixed": no wrapping, fixed to size of the given LUT + - "repeat": cycles through the colormap when n_selections > lut_size""" + return self._lut_wrap + + @property + def alpha(self) -> float: + """Get or set alpha value, 0 - 1.0""" + return self._alpha + + @alpha.setter + def alpha(self, value: float): + self._alpha = float(value) + self._update_all_graphics() + + @property + def graphics(self) -> list: + """Get graphics the highlight selector is operating on.""" + return list(self._graphics) + + def add_graphic(self, graphic) -> None: + """Add ``graphic`` and apply the current highlight selection to it.""" + if graphic in self._graphics: + warn(f"{graphic!r} is already attached to this selector.") + return + + self._check_graphic(graphic) + self._graphics.append(graphic) + self._update_highlight_buffers(graphic) + + def remove_graphic(self, graphic) -> None: + """remove ``graphic`` and clear its highlight buffer.""" + if graphic not in self._graphics: + raise KeyError(f"{graphic!r} is not attached to this selector.") + self._graphics.remove(graphic) + self._clear_highlight_buffers(graphic) + + def _check_graphic(self, graphic) -> None: + raise NotImplementedError + + def _update_highlight_buffers(self, graphic) -> None: + raise NotImplementedError + + def _clear_highlight_buffers(self, graphic) -> None: + raise NotImplementedError + + def add_event_handler(self, handler: Callable) -> None: + """Add a callback that is called when the selection changes.""" + if not callable(handler): + raise TypeError("event handler must be callable") + + if handler in self._event_handlers: + warn(f"{handler} is already registered.") + return + + self._event_handlers.append(handler) + + def remove_event_handler(self, handler: Callable) -> None: + """Remove an event handler.""" + if handler not in self._event_handlers: + raise KeyError(f"{handler} is not registered.") + + self._event_handlers.remove(handler) + + def _emit(self, info: dict) -> None: + for h in self._event_handlers: + h({"selector": self, **info}) + + def _update_all_graphics(self) -> None: + for g in self._graphics: + self._update_highlight_buffers(g) + + @staticmethod + def _write_ids(material, ids: np.ndarray) -> None: + # replace buffer if size changed (GPU binding must point to new object) + if material._highlight_ids_buffer.data.shape[0] != ids.shape[0]: + material._highlight_ids_buffer = pygfx.Buffer(ids.copy()) + else: + material._highlight_ids_buffer.data[:] = ids + material._highlight_ids_buffer.update_range() + + @staticmethod + def _write_lut(material, lut: np.ndarray) -> None: + # replace buffer if size changed (GPU binding must point to new object) + if material._highlight_lut_buffer.data.shape[0] != lut.shape[0]: + material._highlight_lut_buffer = pygfx.Buffer(lut.copy()) + else: + material._highlight_lut_buffer.data[:] = lut + material._highlight_lut_buffer.update_range() + + def __len__(self) -> int: + raise NotImplementedError + + def __contains__(self, item) -> bool: + raise NotImplementedError + + def __iter__(self): + raise NotImplementedError + + def __repr__(self) -> str: + return f"{self.__class__.__name__}\n" f"selection: {self.selection}" + + +class PositionsHighlightSelector(HighlightSelector): + """ + Highlights individual data points on a LineGraphic or ScatterGraphic. + + Parameters + ---------- + color : str or array-like, default "cyan" + Color applied to all selected vertices when no ``lut`` is set. + + lut : np.ndarray of shape (n, 4), optional + Per-vertex RGBA colors; ``lut[i]`` applies to the i-th selected vertex. + + lut_wrap: "fixed" or "repeat" + - "fixed": no wrapping, fixed to size of the given LUT + - "repeat": cycles through the colormap when n_selections > lut_size + + alpha : float, default 1.0 + Highlight blend strength in [0, 1]. + + """ + + def __init__( + self, + color: str | np.ndarray = "red", + lut: str | np.ndarray | None = None, + lut_wrap: Literal["fixed", "repeat"] = "fixed", + alpha: float = 1.0, + ): + super().__init__(color=color, lut=lut, lut_wrap=lut_wrap, alpha=alpha) + self._selection: list[int] = list() + + @property + def selection(self) -> tuple[int, ...]: + """ + Get or set selected vertex indices. + """ + return tuple(self._selection) + + @selection.setter + def selection(self, value) -> None: + if value is None or len(value) == 0: + self._selection = list() + else: + if isinstance(value, Integral): + value = [value] + + if not all([isinstance(i, Integral) for i in value]): + raise TypeError(f"selection must be an iterable of \ngot: {value}") + + # convert to list + self._selection = list(map(int, value)) + + self._update_all_graphics() + self._emit({"value": tuple(self._selection)}) + + # TODO: need to review the rest of these method + def append(self, item) -> None: + """ + Append one or more vertex indices to the selection. + + Indices already in the selection are silently skipped. + """ + new = [int(i) for i in np.asarray(item).ravel()] + novel = [i for i in new if i not in self._selection] + if novel: + self._selection.extend(novel) + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def remove(self, item) -> None: + """Remove one or more vertex indices from the selection.""" + to_remove = set(int(i) for i in np.asarray(item).ravel()) + self._selection = [i for i in self._selection if i not in to_remove] + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def clear(self) -> None: + """Remove all highlights.""" + self._selection = [] + self._update_all_graphics() + self._emit({"value": []}) + + def _check_graphic(self, graphic) -> None: + mat = graphic.world_object.material + if not isinstance(mat, _POSITIONS_MATERIAL_TYPES): + raise TypeError( + f"PositionsHighlightSelector requires a graphic using one of " + f"{[t.__name__ for t in _POSITIONS_MATERIAL_TYPES]}, " + f"got {type(mat).__name__}." + ) + + def _update_highlight_buffers(self, graphic) -> None: + mat = graphic.world_object.material + mat.uniform_buffer.data["highlight_alpha"] = self._alpha + mat.uniform_buffer.update_range() + + n_vertices = graphic.data.value.shape[0] + ids = np.zeros(n_vertices, dtype=np.uint32) + for rank, idx in enumerate(self._selection): + if 0 <= idx < n_vertices: + ids[idx] = rank + 1 + + self._write_ids(mat, ids) + self._write_lut( + mat, + _build_lut(self._color, self._lut, len(self._selection), self._lut_wrap), + ) + + def _clear_highlight_buffers(self, graphic) -> None: + mat = graphic.world_object.material + n_vertices = graphic.data.value.shape[0] + self._write_ids(mat, np.zeros(n_vertices, dtype=np.uint32)) + self._write_lut(mat, np.zeros((1, 4), dtype=np.float32)) + + def __len__(self) -> int: + return len(self._selection) + + def __contains__(self, item) -> bool: + return int(item) in self._selection + + def __iter__(self): + return iter(self._selection) + + def __repr__(self) -> str: + return ( + f"PositionsHighlightSelector(" + f"selection={self._selection}, " + f"n_graphics={len(self._graphics)})" + ) + + +# TODO: review +class CollectionHighlightSelector(HighlightSelector): + """ + Highlights entire graphics within a LineCollection or ScatterCollection. + + Each selected collection item is highlighted with a single color across + all of its vertices. + + Parameters + ---------- + color : str or array-like, default "cyan" + Color applied to all selected items when no ``lut`` is set. + lut : np.ndarray of shape (k, 4), optional + Per-item RGBA colors; ``lut[i]`` applies to the i-th selected item. + Must have at least as many rows as the number of selected items. + alpha : float, default 1.0 + Highlight blend strength in [0, 1]. + """ + + def __init__( + self, + color: str | np.ndarray = "cyan", + lut: np.ndarray | None = None, + alpha: float = 1.0, + ): + super().__init__(color=color, lut=lut, alpha=alpha) + self._selection: list[int] = [] + + @property + def selection(self) -> list[int]: + """ + Selected collection indices. + + Assign a list or array of integer indices to set the selection. + Empty selection is represented as ``[]``. + """ + return list(self._selection) + + @selection.setter + def selection(self, value) -> None: + if value is None or len(value) == 0: + self._selection = [] + else: + self._selection = [int(i) for i in np.asarray(value).ravel()] + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def append(self, item) -> None: + """ + Append one or more collection indices to the selection. + + Indices already in the selection are silently skipped. + """ + new = [int(i) for i in np.asarray(item).ravel()] + novel = [i for i in new if i not in self._selection] + if novel: + self._selection.extend(novel) + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def remove(self, item) -> None: + """Remove one or more collection indices from the selection.""" + to_remove = set(int(i) for i in np.asarray(item).ravel()) + self._selection = [i for i in self._selection if i not in to_remove] + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def clear(self) -> None: + """Remove all highlights.""" + self._selection = [] + self._update_all_graphics() + self._emit({"value": []}) + + def _check_graphic(self, graphic) -> None: + if not isinstance(graphic, GraphicCollection): + raise TypeError( + f"CollectionHighlightSelector requires a GraphicCollection, " + f"got {type(graphic).__name__}." + ) + + def _update_highlight_buffers(self, graphic) -> None: + n_items = len(graphic) + sel = self._selection + lut = _build_lut(self._color, self._lut, len(sel), self._lut_wrap) + rank_map = {idx: rank + 1 for rank, idx in enumerate(sel) if 0 <= idx < n_items} + for i, sub_graphic in enumerate(graphic): + sub_mat = sub_graphic.world_object.material + if not isinstance(sub_mat, _POSITIONS_MATERIAL_TYPES): + continue + sub_mat.uniform_buffer.data["highlight_alpha"] = self._alpha + sub_mat.uniform_buffer.update_range() + n_vertices = sub_graphic.data.value.shape[0] + id_val = np.uint32(rank_map.get(i, 0)) + self._write_ids(sub_mat, np.full(n_vertices, id_val, dtype=np.uint32)) + self._write_lut(sub_mat, lut) + + def _clear_highlight_buffers(self, graphic) -> None: + for sub_graphic in graphic: + sub_mat = sub_graphic.world_object.material + if not isinstance(sub_mat, _POSITIONS_MATERIAL_TYPES): + continue + n_vertices = sub_graphic.data.value.shape[0] + self._write_ids(sub_mat, np.zeros(n_vertices, dtype=np.uint32)) + self._write_lut(sub_mat, np.zeros((1, 4), dtype=np.float32)) + + def __len__(self) -> int: + return len(self._selection) + + def __contains__(self, item) -> bool: + return int(item) in self._selection + + def __iter__(self): + return iter(self._selection) + + def __repr__(self) -> str: + return ( + f"CollectionHighlightSelector(" + f"selection={self._selection}, " + f"n_graphics={len(self._graphics)})" + ) + + +class ImageHighlightSelector(HighlightSelector): + """ + Highlights pixel regions of an ImageGraphic. + + Can be used in two modes: + + **Free-selection mode**, if ``selection_options`` is ``None``: + + ``selection`` is a dict with keys: + + - "rows": list of row specs (int, list[int], or slice); selects those rows across all cols. + - "cols": list of col specs (int, list[int], or slice); selects those cols across all rows. + - "pixels": list of ``(n, 2)`` arrays of ``[[row, col], ...]`` coordinates. + + When both "rows" and "cols" are given they must have the same length each pair defines a rectangle. + + **Options mode**, if ``selection_options`` is set: + + All options are shown with ``options_color`` & ``options_alpha``. + ``selection`` is an ``int`` or ``list[int]`` indexing into the options, selected items are shown + with the highlight ``color`` or ``lut`` & ``alpha``. + Only the LUT is rewritten on selection change, not the mask. + + Parameters + ---------- + color : str or array-like, default "red" + Highlight color for selected items. + + lut : np.ndarray of shape (n, 4), optional + RGBA colors for each selected item + + alpha : float, default 1.0 + alpha blending value + + options_color : str or array-like, default "w" + Color shown for unselected option items. + + options_alpha : float, default 0.1 + alpha blend value for unselected items + + selection_options : dict or None, optional + Pool of selectable options (same dict format as ``selection`` in free-selection mode). + + """ + + _VALID_KEYS = frozenset(("rows", "cols", "pixels")) + + def __init__( + self, + color: str | np.ndarray = "red", + lut: str | np.ndarray | None = None, + alpha: float = 0.7, + lut_wrap: str = "fixed", + options_color: str | np.ndarray = "w", + options_alpha: float = 0.1, + selection_options: dict | None = None, + ): + super().__init__(color=color, lut=lut, alpha=alpha, lut_wrap=lut_wrap) + + self._selection: dict[str, list] = dict() + self._selected_indices: list[int] = list() + self._options_color = options_color + self._options_alpha = float(options_alpha) + + # 65535 is the highest number that uint16 can represent. + # We make a LUT of this (65535 - 1) since the highlight mask Texture is uint16 + # and 0 is uesd to indicate the placeholder locations for "selection_options" + self._lut_buffer = pygfx.Buffer(np.zeros((65534, 4), dtype=np.float32)) + self._mask_texture: pygfx.Texture | None = None + + # validate and store selection_options without triggering _update_all_graphics + # no graphics are targeted yet + if selection_options is not None: + for k in selection_options: + if k not in self._VALID_KEYS: + raise ValueError( + f"Unknown key {k!r}. Must be one of {self._VALID_KEYS}" + ) + self._selection_options: dict[str, list] | None = { + k: list(v) for k, v in selection_options.items() + } + else: + self._selection_options = None + + def _len_dict(self, sel: dict) -> int: + if "rows" in sel: + # covers the case for a selection of rows, as well as row & col pairs + return len(sel["rows"]) + + if "cols" in sel: + return len(sel["cols"]) + + if "pixels" in sel: + return len(sel["pixels"]) + + return 0 + + @staticmethod + def _rgba(color, alpha: float) -> np.ndarray: + c = np.array(pygfx.Color(color), dtype=np.float32) + c[3] = float(alpha) + return c + + @property + def selection_options(self) -> dict[str, tuple] | None: + """ + Get or set a pool of selectable items (same dict format as ``selection`` in free mode). + When set, all options highlighted using ``options_color`` and ``options_alpha``. + ``selection`` indexes into this pool. + Setting to ``None`` reverts to free-selection mode and clears the selection. + """ + if self._selection_options is None: + return None + + # return a new dict with a tuple of the selections so the user can't modify the objects + return {k: tuple(v) for k, v in self._selection_options.items()} + + @selection_options.setter + def selection_options(self, value: dict | None) -> None: + if value is None: + self._selection_options = None + else: + for k in value: + if k not in self._VALID_KEYS: + raise ValueError( + f"Unknown key {k!r}. Must be one of {self._VALID_KEYS}" + ) + self._selection_options = {k: list(v) for k, v in value.items()} + + self._selected_indices = list() + self._selection = dict() + self._update_all_graphics() + self._emit({"value": self.selection}) + + @property + def options_color(self) -> str | np.ndarray: + """Get or set color for unselected option items (options mode only).""" + return self._options_color + + @options_color.setter + def options_color(self, value: str | np.ndarray) -> None: + self._options_color = value + + if self._selection_options is not None: + self._update_all_graphics() + + @property + def options_alpha(self) -> float: + """Get or set alpha blend value of unselected option items (options mode only).""" + return self._options_alpha + + @options_alpha.setter + def options_alpha(self, value: float) -> None: + self._options_alpha = float(value) + + if self._selection_options is not None: + self._update_all_graphics() + + @property + def selection(self) -> tuple[int, ...] | dict[str, tuple]: + """ + In options mode: tuple of selection option indices. + In free mode: dict of selection items. + """ + if self._selection_options is not None: + return tuple(self._selected_indices) + + # return a new dict with a tuple of the selections so the user can't modify the objects + return {k: tuple(v) for k, v in self._selection.items()} + + @selection.setter + def selection(self, value: Iterable[int] | dict[Literal["rows", "cols", "pixels"], list]) -> None: + if self._selection_options is not None: + if value is None: + self._selected_indices = list() + + elif isinstance(value, int): + self._selected_indices = [value] + + else: + self._selected_indices = [int(i) for i in value] + + else: + if not value: + self._selection = {} + + else: + for k in value: + if k not in self._VALID_KEYS: + raise ValueError( + f"Unknown key {k!r}. Must be one of {self._VALID_KEYS}" + ) + + self._selection = {k: list(v) for k, v in value.items()} + + self._update_all_graphics() + self._emit({"value": self.selection}) + + def append(self, dict_or_index: dict | int) -> None: + """ + append to the current selection + """ + if self._selection_options is not None: + # options mode + index = dict_or_index + if not isinstance(index, Integral): + raise TypeError( + f"must provide integer index to append to selection " + f"in 'options' mode, you passed: {dict_or_index!r}" + ) + if index not in self._selected_indices: + self._selected_indices.append(index) + self._update_all_graphics() + self._emit({"value": self.selection}) + else: + d = dict_or_index + # check that dict is valid + keys = list(d.keys()) + err = f"must provide a dict of only rows, cols, rows & cols, or pixels, you passed a dict with keys: {keys}" + + if any([k not in self._VALID_KEYS for k in keys]): + raise KeyError(err) + + if "pixels" in keys and len(keys) > 1: + raise KeyError(err) + + if "rows" in keys and "cols" in keys: + if len(d["rows"]) != len(d["cols"]): + raise ValueError( + f"if appending pairs of rows & cols, they must be of the same length" + ) + rows, cols = d["rows"], d["cols"] + if not all( + [ + isinstance(r, slice) and isinstance(c, slice) + for r, c in zip(rows, cols) + ] + ): + raise ValueError( + f"if appending pairs of rows & cols, each row and column pair must be a slice, you passed: {d}" + ) + for k in keys: + self._selection.setdefault(k, list()).append(d[k]) + + self._update_all_graphics() + self._emit({"value": self.selection}) + + def remove(self, dict_or_index: dict | int) -> None: + """ + In options mode: ``remove(index)``: remove an option index from the selection. + In free mode: ``remove(key, list_index=-1)``: remove one item from the selection dict. + """ + if self._selection_options is not None: + # options mode + index = dict_or_index + if not isinstance(index, Integral): + raise TypeError( + f"must provide integer index to append to selection " + f"in 'options' mode, you passed: {dict_or_index!r}" + ) + if index in self._selected_indices: + self._selected_indices.remove(index) + self._update_all_graphics() + self._emit({"value": self.selection}) + else: + d = dict_or_index + keys = list(d.keys()) + if any([k not in self._selection for k in keys]): + raise KeyError( + f"You provided keys that are not in the selection.\nkeys: {keys}\nselection: {self._selection}" + ) + + for k in keys: + for item in d[k]: + self._selection[k].remove(item) + if len(self._selection[k]) < 1: + del self._selection[k] + + self._update_all_graphics() + self._emit({"value": self.selection}) + + def clear(self) -> None: + """Clear the selection (options mode: deselects all, free mode: clears all regions).""" + if self._selection_options is not None: + # options mode + self._selected_indices = list() + else: + self._selection = dict() + self._update_all_graphics() + self._emit({"value": self.selection}) + + def _check_graphic(self, graphic) -> None: + mat = getattr(graphic, "_material", None) + if not isinstance(mat, HighlightableImageMaterial): + raise TypeError( + f"ImageHighlightSelector requires HighlightableImageMaterial, " + f"got {type(mat).__name__}." + ) + + def _create_mask_texture(self, mask: np.ndarray) -> pygfx.Texture: + rows, cols = mask.shape + texture = pygfx.Texture( + size=(cols, rows, 1), # initialize with size, no local cpu buffer + dim=2, + format="r16uint", + usage=wgpu.TextureUsage.COPY_DST, + ) + # send initialized data directly to GPU + texture.send_data((0, 0, 0), mask) + return texture + + def _create_mask(self, n_rows: int, n_cols: int) -> np.ndarray: + """create uint16 mask array for the current selection""" + mask = np.zeros((n_rows, n_cols), dtype=np.uint16) + sel = ( + self._selection_options + if self._selection_options is not None + else self._selection + ) + if "rows" in sel and "cols" in sel: + if len(sel["rows"]) != len(sel["cols"]): + raise ValueError( + f"'rows' and 'cols' must have the same length when both given " + f"({len(sel['rows'])} vs {len(sel['cols'])})" + ) + # start=1 since 0 indicates unselected placeholder value + for i, (rs, cs) in enumerate(zip(sel["rows"], sel["cols"]), start=1): + mask[rs, cs] = i + elif "rows" in sel: + for i, rs in enumerate(sel["rows"], start=1): + mask[rs, :] = i + elif "cols" in sel: + for i, cs in enumerate(sel["cols"], start=1): + mask[:, cs] = i + elif "pixels" in sel: + for i, px in enumerate(sel["pixels"], start=1): + arr = np.asarray(px) + mask[arr[:, 0], arr[:, 1]] = i + return mask + + def _fill_lut(self) -> None: + """Write current highlight colors into the LUT buffer.""" + lut_buffer = self._lut_buffer.data + lut_buffer[:] = 0.0 + + if self._selection_options is not None: + n_placeholder = self._len_dict(self._selection_options) + # reset all the options to the unselected placeholder color + lut_buffer[:n_placeholder] = self._rgba( + self._options_color, self._options_alpha + ) + n_sel = len(self._selected_indices) + if n_sel > 0: + current_lut = _build_lut( + color=self._color, lut=self._lut, n=n_sel, lut_wrap=self._lut_wrap + ) + current_lut[:, -1] *= self._alpha + for i, sel in enumerate(self._selected_indices): + lut_buffer[sel] = current_lut[i] + else: + n = self._len_dict(self._selection) + if n > 0: + current_lut = _build_lut( + color=self._color, lut=self._lut, n=n, lut_wrap=self._lut_wrap + ) + current_lut[:, 3] *= self._alpha + lut_buffer[:n] = current_lut + + self._lut_buffer.update_full() + + def _update_highlight_buffers(self, graphic) -> None: + # Called once per graphic on add_graphic. Set selector buffers onto + # the material. Subsequent graphics just get references to the same objects. + material = graphic._material + material._highlight_lut_buffer = self._lut_buffer + + if self._mask_texture is None: + n_rows, n_cols = graphic.data.value.shape[:2] + self._mask_texture = self._create_mask_texture( + self._create_mask(n_rows, n_cols) + ) + self._fill_lut() + + material._highlight_mask_texture = self._mask_texture + material.uniform_buffer.data["highlight_alpha"] = 1.0 + material.uniform_buffer.update_range() + + def _update_all_graphics(self) -> None: + if not self._graphics: + return + + shapes = {g.data.value.shape[:2] for g in self._graphics} + if len(shapes) > 1: + raise ValueError( + f"All targeted Image data must have the same shape, your images have shapes: {shapes}" + ) + + n_rows, n_cols = self._graphics[0].data.value.shape[:2] + mask = self._create_mask(n_rows, n_cols) + + # Re-create GPU texture if shape changed + if self._mask_texture is None or self._mask_texture.size != (n_cols, n_rows, 1): + self._mask_texture = self._create_mask_texture(mask) + for g in self._graphics: + g._material._highlight_mask_texture = self._mask_texture + else: + # just send the new data + self._mask_texture.send_data((0, 0, 0), mask) + + self._fill_lut() + # uniform_buffer is per-material and cannot be shared + for g in self._graphics: + g._material.uniform_buffer.data["highlight_alpha"] = 1.0 + g._material.uniform_buffer.update_range() + + def _clear_highlight_buffers(self, graphic) -> None: + # Restore the detached material to minimal self-owned placeholders + # this is done when a graphic is removed from the selector + mat = graphic._material + mat._highlight_mask_texture = pygfx.Texture( + np.zeros((1, 1), dtype=np.uint16), dim=2 + ) + mat._highlight_lut_buffer = pygfx.Buffer(np.zeros((1, 4), dtype=np.float32)) + + def __len__(self) -> int: + if self._selection_options is not None: + return len(self._selected_indices) + + return self._len_dict(self._selection) + + def __contains__(self, item: int | dict) -> bool: + if self._selection_options is not None: + return int(item) in self._selected_indices + + # check if a single row-col pair is in the selection + if "rows" in item and "cols" in item: + if ( + item["rows"] in self._selection["rows"] + and item["cols"] in self._selection["cols"] + ): + return True + + # check for basic membership + if "rows" in item: + return item["rows"] in self._selection["rows"] + if "cols" in item: + return item["cols"] in self._selection["col"] + if "pixels" in item: + return item["pixels"] in self._selection["pixels"] + + def __iter__(self): + if self._selection_options is not None: + return iter(self._selected_indices) + + return iter(self._selection.values()) + + def __repr__(self) -> str: + if self._selection_options is not None: + # options mode + return ( + f"ImageHighlightSelector\n" + f"selected: {self._selected_indices}\n" + f"options: {self._selection_options}\n" + ) + + return f"ImageHighlightSelector\n" f"selection: {self._selection}, " diff --git a/fastplotlib/graphics/selectors/_protocols.py b/fastplotlib/graphics/selectors/_protocols.py new file mode 100644 index 000000000..f6fc375df --- /dev/null +++ b/fastplotlib/graphics/selectors/_protocols.py @@ -0,0 +1,30 @@ +from collections.abc import Callable +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class SelectorProtocol(Protocol): + @property + def selection(self): ... + + @selection.setter + def selection(self, new): ... + + def add_event_handler(self, handler: Callable): ... + + def remove_event_handler(self, handler: Callable): ... + + +@runtime_checkable +class MultiSelectorProtocol(SelectorProtocol, Protocol): + def append(self, item): ... + + def remove(self, item): ... + + def clear(self): ... + + def __len__(self): ... + + def __contains__(self, item): ... + + def __iter__(self): ... diff --git a/fastplotlib/graphics/selectors/_selection_vector.py b/fastplotlib/graphics/selectors/_selection_vector.py new file mode 100644 index 000000000..a1e0bed10 --- /dev/null +++ b/fastplotlib/graphics/selectors/_selection_vector.py @@ -0,0 +1,89 @@ +from collections.abc import Callable +from functools import partial +from typing import Any, Sequence + +from ._protocols import SelectorProtocol, MultiSelectorProtocol + + +def identity(val: Any) -> Any: + return val + + +class SelectionVector: + def __init__(self, max_size: int = None): + # selector -> (map, map_inv) + self._selectors: dict[ + SelectorProtocol | MultiSelectorProtocol, tuple[Callable, Callable] + ] = dict() + self._selection: list[Any] = list() + + @property + def selection(self) -> tuple[Any]: + return tuple(self._selection) + + @selection.setter + def selection(self, new: Sequence[Any]): + # iterate through each selector that operates in its own "local" space + for selector_local, (map_, map_inv) in self._selectors.items(): + indices_local = map_(new) + selector_local.selection = indices_local + + def append(self, index): + self._selection.append(index) + for selector, (map_, map_inv) in self._selectors.items(): + if not isinstance(selector, MultiSelectorProtocol): + continue + + index_local = map_([index]) + selector.append(index_local[0]) + + def clear(self): + self._selection.clear() + # TODO: clear selectors + + def add_selector( + self, + new: ( + SelectorProtocol + | tuple[SelectorProtocol, Callable] + | tuple[SelectorProtocol, Callable, Callable] + ), + ): + selector: SelectorProtocol + map_: Callable + map_inv: Callable + + if isinstance(new, (tuple, list)): + if not isinstance(new[0], SelectorProtocol): + raise TypeError + + if len(new) not in (2, 3): + raise TypeError + + if not all(callable(c) for c in new[1:]): + raise TypeError + + selector = new[0] + map_ = new[1] + map_inv = new[2] if len(new) == 3 else identity + + elif isinstance(new, SelectorProtocol): + selector, map_, map_inv = new, identity, identity + + else: + raise ValueError + + selector.add_event_handler(partial(self._inv_handler, map_inv)) + + self._selectors[selector] = (map_, map_inv) + + def _inv_handler(self, map_inv: Callable, local_selection): + return + # when a selectable changes its selection, set global index change using map inverse + # self._selection = map_inv(local_selection) + + def remove(self): + pass + + def clear_selectables(self): + self._selectors.clear() diff --git a/fastplotlib/graphics/selectors/_selector_collection.py b/fastplotlib/graphics/selectors/_selector_collection.py new file mode 100644 index 000000000..0386cd7bd --- /dev/null +++ b/fastplotlib/graphics/selectors/_selector_collection.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +from numbers import Integral +from typing import Callable +from warnings import warn + +import pygfx + +from .._base import Graphic +from ._base_selector import BaseSelector +from ._linear import LinearSelector +from ._linear_region import LinearRegionSelector +from ._polygon import PolygonSelector +from ._rectangle import RectangleSelector + + +_SELECTOR_TYPES = (LinearSelector, LinearRegionSelector, RectangleSelector, PolygonSelector) + + +class SelectorCollection(Graphic): + """ + Dynamically-sized collection of same-type selectors on a shared parent graphic. + + Do not instantiate directly; use a concrete subclass such as + ``RectangleSelectors``. + + ``selection`` is a list of each child selector's ``selection`` value in + append order. Assigning to it resizes the collection as needed. shorter + lists remove tail selectors, longer lists create new ones. + + Parameters + ---------- + parent : Graphic + Parent graphic forwarded to every child selector. + selection : list, optional + Initial selection values. + name : str, optional + **selector_kwargs + Forwarded verbatim to each child selector on creation. + """ + + _selector_type: type = None + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + t = getattr(cls, "_selector_type", None) + if t is not None and t not in _SELECTOR_TYPES: + raise TypeError( + f"{cls.__name__}._selector_type must be one of " + f"{[c.__name__ for c in _SELECTOR_TYPES]}, got {t!r}" + ) + + def __init__( + self, + parent: Graphic, + selection: list | None = None, + name: str = None, + **selector_kwargs, + ): + if type(self)._selector_type is None: + raise TypeError( + f"{type(self).__name__} cannot be instantiated directly; " + "use a concrete subclass." + ) + super().__init__(name=name) + self._set_world_object(pygfx.Group()) + self._parent_graphic = parent + self._selector_kwargs = selector_kwargs + self._selectors: list[BaseSelector] = [] + self._event_handlers: list[Callable] = [] + + if selection is not None: + self.selection = selection + + # ------------------------------------------------------------------ hooks + + def _fpl_add_plot_area_hook(self, plot_area): + super()._fpl_add_plot_area_hook(plot_area) + for sel in self._selectors: + sel._fpl_add_plot_area_hook(plot_area) + + def _fpl_prepare_del(self): + for sel in list(self._selectors): + sel._fpl_prepare_del() + self.world_object.remove(sel.world_object) + self._selectors.clear() + super()._fpl_prepare_del() + + # ------------------------------------------------------------------ selection + + @property + def selection(self) -> list: + """Child selector selections in append order.""" + return [s.selection for s in self._selectors] + + @selection.setter + def selection(self, values: list) -> None: + n_old = len(self._selectors) + for sel, val in zip(self._selectors, values): + sel.selection = val + while len(self._selectors) > len(values): + self._remove_selector(-1) + for val in values[n_old:]: + self._append_selector(val) + self._emit({"value": self.selection}) + + # ------------------------------------------------------------------ public + + def append(self, selection) -> BaseSelector: + """Create a new child selector and return it.""" + sel = self._append_selector(selection) + self._emit({"value": self.selection}) + return sel + + def remove(self, item: int | BaseSelector) -> None: + """Remove a child selector by index or reference.""" + self._remove_selector(item) + self._emit({"value": self.selection}) + + def clear(self) -> None: + """Remove all child selectors.""" + while self._selectors: + self._remove_selector(-1) + self._emit({"value": []}) + + # ------------------------------------------------------------------ internal + + def _append_selector(self, selection) -> BaseSelector: + sel = self._selector_type( + selection=selection, + parent=self._parent_graphic, + **self._selector_kwargs, + ) + self.world_object.add(sel.world_object) + self._selectors.append(sel) + if self._plot_area is not None: + sel._fpl_add_plot_area_hook(self._plot_area) + return sel + + def _remove_selector(self, item: int | BaseSelector) -> None: + sel = self._selectors[item] if isinstance(item, Integral) else item + sel._fpl_prepare_del() + self.world_object.remove(sel.world_object) + self._selectors.remove(sel) + + # ------------------------------------------------------------------ events + + def add_event_handler(self, handler: Callable) -> None: + """Register a callback fired on any selection change.""" + if not callable(handler): + raise TypeError("event handler must be callable") + if handler in self._event_handlers: + warn(f"{handler} is already registered.") + return + self._event_handlers.append(handler) + + def remove_event_handler(self, handler: Callable) -> None: + if handler not in self._event_handlers: + raise KeyError(f"{handler} is not registered.") + self._event_handlers.remove(handler) + + def _emit(self, info: dict) -> None: + for h in self._event_handlers: + h({"selector": self, **info}) + + # ------------------------------------------------------------------ dunder + + def __getitem__(self, index: int) -> BaseSelector: + return self._selectors[index] + + def __len__(self) -> int: + return len(self._selectors) + + def __contains__(self, item) -> bool: + return item in self._selectors + + def __iter__(self): + return iter(self._selectors) + + def __repr__(self) -> str: + n = len(self._selectors) + s = f"{self.__class__.__name__}(n={n})" + if self.name: + s = f"'{self.name}': {s}" + return s + + +class LinearSelectors(SelectorCollection): + """ + Collection of :class:`.LinearSelector` instances on a shared parent graphic. + + Parameters + ---------- + parent : Graphic + limits : tuple[float, float] + ``(min, max)`` bounds on the selector axis. + selection : list[float], optional + Initial selector positions. + axis : "x" or "y" + edge_color : color + thickness : float + arrow_keys_modifier : str + extra_width : float + name : str, optional + """ + + _selector_type = LinearSelector + + def __init__( + self, + parent: Graphic, + limits: tuple[float, float], + selection: list[float] | None = None, + *, + axis: str = "x", + edge_color="yellow", + thickness: float = 1.0, + arrow_keys_modifier: str = "Shift", + extra_width: float = 14.0, + name: str = None, + ): + super().__init__( + parent, selection, name=name, + limits=limits, + axis=axis, + edge_color=edge_color, + thickness=thickness, + arrow_keys_modifier=arrow_keys_modifier, + extra_width=extra_width, + ) + + +class LinearRegionSelectors(SelectorCollection): + """ + Collection of :class:`.LinearRegionSelector` instances on a shared parent graphic. + + Parameters + ---------- + parent : Graphic + limits : tuple[float, float] + ``(min, max)`` range the selector can occupy. + size : float + Extent of each region box along the axis orthogonal to ``axis``. + center : float + Centre of each box along the orthogonal axis. + selection : list[tuple[float, float]], optional + Initial ``(min, max)`` pairs. + axis : "x" or "y" + resizable : bool + fill_color : color + edge_color : color + edge_thickness : float + arrow_keys_modifier : str + extra_width : float + name : str, optional + """ + + _selector_type = LinearRegionSelector + + def __init__( + self, + parent: Graphic, + limits: tuple[float, float], + size: float, + center: float, + selection: list | None = None, + *, + axis: str = "x", + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color="yellow", + edge_thickness: float = 1.0, + arrow_keys_modifier: str = "Shift", + extra_width: float = 14.0, + name: str = None, + ): + super().__init__( + parent, selection, name=name, + limits=limits, + size=size, + center=center, + axis=axis, + resizable=resizable, + fill_color=fill_color, + edge_color=edge_color, + edge_thickness=edge_thickness, + arrow_keys_modifier=arrow_keys_modifier, + extra_width=extra_width, + ) + + +class RectangleSelectors(SelectorCollection): + """ + Collection of :class:`.RectangleSelector` instances on a shared parent graphic. + + Parameters + ---------- + parent : Graphic + limits : tuple[float, float, float, float] + ``(xmin, xmax, ymin, ymax)`` bounds. + selection : list[tuple[float, float, float, float]], optional + Initial ``(xmin, xmax, ymin, ymax)`` rectangles. + resizable : bool + fill_color : color + edge_color : color + edge_thickness : float + vertex_color : color + vertex_size : float + arrow_keys_modifier : str + name : str, optional + """ + + _selector_type = RectangleSelector + + def __init__( + self, + parent: Graphic, + limits: tuple[float, float, float, float], + selection: list | None = None, + *, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 8, + vertex_color=(0.7, 0.4, 0), + vertex_size: float = 8, + arrow_keys_modifier: str = "Shift", + name: str = None, + ): + super().__init__( + parent, selection, name=name, + limits=limits, + resizable=resizable, + fill_color=fill_color, + edge_color=edge_color, + edge_thickness=edge_thickness, + vertex_color=vertex_color, + vertex_size=vertex_size, + arrow_keys_modifier=arrow_keys_modifier, + ) + + +class PolygonSelectors(SelectorCollection): + """ + Collection of :class:`.PolygonSelector` instances on a shared parent graphic. + + Parameters + ---------- + parent : Graphic + limits : tuple[float, float, float, float] + ``(xmin, xmax, ymin, ymax)`` bounds. + selection : list, optional + Initial polygon vertex lists; each element is a sequence of + ``(x, y)`` or ``(x, y, 0)`` points, or ``None`` for an empty polygon. + resizable : bool + fill_color : color + edge_color : color + edge_thickness : float + vertex_color : color + vertex_size : float + name : str, optional + """ + + _selector_type = PolygonSelector + + def __init__( + self, + parent: Graphic, + limits: tuple[float, float, float, float], + selection: list | None = None, + *, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 4, + vertex_color=(0.7, 0.4, 0), + vertex_size: float = 12, + name: str = None, + ): + super().__init__( + parent, selection, name=name, + limits=limits, + resizable=resizable, + fill_color=fill_color, + edge_color=edge_color, + edge_thickness=edge_thickness, + vertex_color=vertex_color, + vertex_size=vertex_size, + ) diff --git a/fastplotlib/graphics/selectors/_visibility_selector.py b/fastplotlib/graphics/selectors/_visibility_selector.py new file mode 100644 index 000000000..91af3abb6 --- /dev/null +++ b/fastplotlib/graphics/selectors/_visibility_selector.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +from collections.abc import Iterable +from numbers import Integral +from typing import Callable +from warnings import warn + +import cmap as cmap_lib +import numpy as np + +from .._collection_base import GraphicCollection +from ..shaders._highlight_materials import HighlightableImageMaterial +from ._highlight_selector import _build_lut + +_AXES = {"x": 0, "y": 1, "z": 2} + + +def _validate_int_collection(value, name: str) -> set | int: + if isinstance(value, Integral): + return int(value) + + s = set(value) + + if not all(isinstance(i, Integral) for i in s): + raise TypeError(f"{name} must contain only integers, got: {s!r}") + + return value + + +class VisibilitySelector: + """ + Shows a subset of graphics in a GraphicCollection by toggling their visibility. + + ``selection = list()`` or ``None``: all invisible. + ``selection = [s1, s2, ..., s_n]``: only these indices visible + + For ``LineStack`` and ``ScatterStack``, visible graphics are re-stacked + along the stack axis when the selection changes. + + If a ``lut`` is provided, each visible graphic is colored by its position in + the selection + + Parameters + ---------- + collection : GraphicCollection + selection : list[int] or None + Initial selection. + + lut : str or array-like of shape (n, 4), optional + color or stack of RGBA arrays + + lut_wrap : "fixed" or "repeat" + How to handle selection indices beyond the end of the lut. + """ + + def __init__( + self, + collection: GraphicCollection, + selection: list[int] | None = None, + lut: str | np.ndarray | None = None, + lut_wrap: str = "fixed", + ): + if not isinstance(collection, GraphicCollection): + raise TypeError( + f"VisibilitySelector requires a GraphicCollection, " + f"got {type(collection).__name__}." + ) + if lut_wrap not in ("fixed", "repeat"): + raise ValueError(f"lut_wrap must be 'fixed' or 'repeat', got {lut_wrap!r}") + + self._collection = collection + self._selection: list[int] = [] + self._event_handlers: list[Callable] = [] + self._lut_wrap = lut_wrap + + self._lut = lut + + # save original colors so they can be restored when this selector is deleted + self._original_colors: dict[int, np.ndarray] = {} + for i, g in enumerate(collection.graphics): + c = g.colors + if hasattr(c, "value"): + self._original_colors[i] = np.asarray(c.value, dtype=np.float32).copy() + else: + self._original_colors[i] = np.asarray(c, dtype=np.float32).copy() + + self._is_stack = hasattr(collection, "separation") + if self._is_stack: + self._sep_axis = collection.separation_axis + ax_i = _AXES[self._sep_axis] + self._data_ranges = np.array( + [float(g.data.value[:, ax_i].max()) for g in collection.graphics] + ) + + for g in collection.graphics: + g.visible = False + + if selection is not None and len(selection) > 0: + self.selection = selection + + def __del__(self): + for g in self._collection.graphics: + g.visible = True + + if self._lut is None: + return + + for i, g in enumerate(self._collection.graphics): + g.colors = self._original_colors[i] + + @property + def selection(self) -> tuple[int, ...]: + """Get or set the selection""" + return tuple(self._selection) + + @selection.setter + def selection(self, new_selection: Iterable[int] | int): + if new_selection: + _validate_int_collection(new_selection, "selection") + + for index in self._selection: + # set any selected things to be invisible + self._collection.graphics[index].visible = False + + if isinstance(new_selection, Integral): + new_selection = [new_selection] + + self._selection = list(new_selection) if new_selection else list() + + for index in self._selection: + # set the new selection to be visible + self._collection.graphics[index].visible = True + + if self._is_stack: + self._restack() + + self._apply_lut() + self._emit({"value": tuple(self._selection)}) + + def append(self, item: int): + """Add an index to the selection. Already-selected indices are skipped.""" + if not isinstance(item, Integral): + raise TypeError(f"item must be an integer, got {type(item)}") + + if item in self._selection: + return + + self._collection.graphics[item].visible = True + self._selection.append(item) + + if self._is_stack: + self._restack() + + self._apply_lut() + self._emit({"value": tuple(self._selection)}) + + def remove(self, item: int): + """Remove an index from the selection.""" + if not isinstance(item, Integral): + raise TypeError(f"item must be an integer, got {type(item).__name__}") + + if item not in self._selection: + return + + self._collection.graphics[item].visible = False + self._selection.remove(item) + + if self._is_stack: + self._restack() + + self._apply_lut() + self._emit({"value": list(self._selection)}) + + def clear(self) -> None: + """Hide all graphics. Stack offsets are left as-is.""" + for idx in self._selection: + self._collection.graphics[idx].visible = False + + self._selection = list() + self._emit({"value": []}) + @property + def lut(self) -> np.ndarray | None: + """Optional per-item colors, shape ``(n, 4)`` float32 RGBA""" + return self._lut + + @lut.setter + def lut(self, value: str | np.ndarray | None) -> None: + self._lut = value + self._apply_lut() + + @property + def lut_wrap(self) -> str: + """LUT wrap mode: ``'fixed'`` or ``'repeat'``.""" + return self._lut_wrap + + def _apply_lut(self) -> None: + if self._lut is None or not self._selection: + return + + colors = _build_lut( + color=None, lut=self._lut, n=len(self._selection), lut_wrap=self._lut_wrap + ) + for sel_index, graphic_index in enumerate(self._selection): + self._collection.graphics[graphic_index].colors = colors[sel_index] + + def _restack(self) -> None: + sep = self._collection.separation + ax_i = _AXES[self._sep_axis] + + distance = 0.0 + for index in self._selection: + g = self._collection.graphics[index] + offset = list(g.offset) + offset[ax_i] = distance + g.offset = tuple(offset) + distance += self._data_ranges[index] + sep + + def add_event_handler(self, handler: Callable) -> None: + """Register a callback fired when the selection changes.""" + if not callable(handler): + raise TypeError("event handler must be callable") + if handler in self._event_handlers: + warn(f"{handler} is already registered.") + return + self._event_handlers.append(handler) + + def remove_event_handler(self, handler: Callable) -> None: + if handler not in self._event_handlers: + raise KeyError(f"{handler} is not registered.") + self._event_handlers.remove(handler) + + def _emit(self, info: dict) -> None: + for h in self._event_handlers: + h({"selector": self, **info}) + + def __len__(self) -> int: + return len(self._selection) + + def __contains__(self, item) -> bool: + return item in self._selection + + def __iter__(self): + return iter(self._selection) + + def __repr__(self) -> str: + return ( + f"VisibilitySelector\n" + f"selection: {self._selection}" + ) + + +class ImageVisibilitySelector: + """ + Shows a subset of rows or columns of an ``ImageGraphic`` via GPU shader remapping. + + Selected rows/columns are rendered as a compact stack with no gaps. Non-selected + rows/columns are discarded in the fragment shader. + + Requires ``HighlightableImageMaterial`` and ``interpolation='nearest'``. + + Can be combined with ``ImageHighlightSelector`` on the same graphic; highlight + indices always refer to original source coordinates regardless of visibility state. + + Parameters + ---------- + graphic : ImageGraphic + axis : "rows" or "cols" + Axis to subset. + selection : list[int] or None + Initial selection. + """ + + def __init__(self, graphic, axis: str = "rows", selection: list[int] | None = None): + if axis not in ("rows", "cols"): + raise ValueError(f"axis must be 'rows' or 'cols', got {axis!r}") + + mat = getattr(graphic, "_material", None) + if not isinstance(mat, HighlightableImageMaterial): + raise TypeError( + "ImageVisibilitySelector requires HighlightableImageMaterial, " + f"got {type(mat).__name__}." + ) + + if graphic.interpolation != "nearest": + raise ValueError( + "ImageVisibilitySelector requires interpolation='nearest'; " + f"got {graphic.interpolation!r}. Set graphic.interpolation = 'nearest' first." + ) + + tiles = list(graphic.world_object.children) + if len(tiles) != 1: + raise ValueError( + f"ImageVisibilitySelector only supports single-tile images, " + f"got {len(tiles)} tiles." + ) + + self._graphic = graphic + self._tile = tiles[0] + self._axis = axis + self._selection: list[int] = list() + self._event_handlers: list[Callable] = list() + + mat.uniform_buffer.data["fpl_vis_axis_y"] = np.uint32( + 1 if axis == "rows" else 0 + ) + mat.uniform_buffer.data["fpl_n_visible"] = np.uint32(0) + mat.uniform_buffer.update_range() + + if selection is not None and len(selection) > 0: + self.selection = selection + + @property + def axis(self) -> str: + """ + 'rows' or 'cols' + """ + return self._axis + + @property + def selection(self) -> tuple[int, ...]: + """Get or set row/col selection indices""" + return tuple(self._selection) + + @selection.setter + def selection(self, value: Iterable[int]): + if value: + _validate_int_collection(value, "selection") + + if isinstance(value, Integral): + value = [value] + + self._selection = list(value) if value else list() + + self._update_material() + self._emit({"value": tuple(self._selection)}) + + def append(self, item) -> None: + """add a row/col index to the selection""" + if not isinstance(item, Integral): + raise TypeError(f"item must be an integer, got {type(item)}") + + if item in self._selection: + return + + self._selection.append(item) + + self._update_material() + self._emit({"value": list(self._selection)}) + + def remove(self, item) -> None: + """Remove a row/col index from the selection.""" + if not isinstance(item, Integral): + raise TypeError(f"item must be an integer, got {type(item)}") + + if item not in self._selection: + return + + self._selection.remove(item) + self._update_material() + self._emit({"value": list(self._selection)}) + + def clear(self) -> None: + """Clear the selection (all invisible, fpl_n_visible=0).""" + self._selection = list() + self._update_material() + self._emit({"value": list()}) + + def _update_material(self) -> None: + mat = self._graphic._material + n = len(self._selection) + if n > 0: + mat._vis_lut_buffer.data[:n] = np.array(self._selection, dtype=np.uint32) + + mat._vis_lut_buffer.update_range() + mat.uniform_buffer.data["fpl_n_visible"] = np.uint32(n) + mat.uniform_buffer.update_range() + + self._update_bbox() + + def _update_bbox(self) -> None: + data = self._graphic.data.value + n_total = data.shape[0] if self._axis == "rows" else data.shape[1] + + n_visible = len(self._selection) + ax_i = 1 if self._axis == "rows" else 0 + self._graphic.world_object.children[0]._vis_scale = ( + ax_i, + n_visible / n_total if n_total > 0 else 0.0, + ) + + def add_event_handler(self, handler: Callable) -> None: + """register an event handler that is called when the selection changes""" + if not callable(handler): + raise TypeError("event handler must be callable") + + if handler in self._event_handlers: + warn(f"{handler} is already registered.") + return + + self._event_handlers.append(handler) + + def remove_event_handler(self, handler: Callable) -> None: + if handler not in self._event_handlers: + raise KeyError(f"{handler} is not registered.") + + self._event_handlers.remove(handler) + + def _emit(self, info: dict) -> None: + for h in self._event_handlers: + h({"selector": self, **info}) + + def __len__(self) -> int: + return len(self._selection) + + def __contains__(self, item) -> bool: + return item in self._selection + + def __iter__(self): + return iter(self._selection) + + def __repr__(self) -> str: + data = self._graphic.data.value + return ( + f"ImageVisibilitySelector\n" + f"axis: {self._axis}\n" + f"selection: {self._selection}\n" + ) diff --git a/fastplotlib/graphics/shaders/__init__.py b/fastplotlib/graphics/shaders/__init__.py new file mode 100644 index 000000000..43b13147a --- /dev/null +++ b/fastplotlib/graphics/shaders/__init__.py @@ -0,0 +1,15 @@ +from ._highlight_shaders import ( + HighlightableLineShader, + HighlightableThinLineShader, + HighlightablePointsShader, + HighlightableImageShader, +) +from ._highlight_materials import ( + HighlightableLineMaterial, + HighlightableLineThinMaterial, + HighlightablePointsMaterial, + HighlightablePointsMarkerMaterial, + HighlightablePointsSpriteMaterial, + HighlightablePointsGaussianBlobMaterial, + HighlightableImageMaterial, +) diff --git a/fastplotlib/graphics/shaders/_highlight_materials.py b/fastplotlib/graphics/shaders/_highlight_materials.py new file mode 100644 index 000000000..b90f16a3a --- /dev/null +++ b/fastplotlib/graphics/shaders/_highlight_materials.py @@ -0,0 +1,102 @@ +import numpy as np +import pygfx +from pygfx.resources import Buffer, Texture + +_HIGHLIGHT_UNIFORM_FIELDS = dict(highlight_alpha="f4") + +_IMAGE_HIGHLIGHT_UNIFORM_FIELDS = dict( + highlight_alpha="f4", + fpl_n_visible="u4", # 0 = visibility disabled; >0 = number of visible rows/cols + fpl_vis_axis_y="u4", # 1 = rows (y-axis), 0 = cols (x-axis) +) + + +class HighlightableLineMaterial(pygfx.LineMaterial): + uniform_type = dict(pygfx.LineMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightableLineThinMaterial(pygfx.LineThinMaterial): + uniform_type = dict(pygfx.LineThinMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightablePointsMaterial(pygfx.PointsMaterial): + uniform_type = dict(pygfx.PointsMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightablePointsMarkerMaterial(pygfx.PointsMarkerMaterial): + uniform_type = dict(pygfx.PointsMarkerMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightablePointsSpriteMaterial(pygfx.PointsSpriteMaterial): + uniform_type = dict(pygfx.PointsSpriteMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightablePointsGaussianBlobMaterial(pygfx.PointsGaussianBlobMaterial): + uniform_type = dict( + pygfx.PointsGaussianBlobMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightableImageMaterial(pygfx.ImageBasicMaterial): + uniform_type = dict(pygfx.ImageBasicMaterial.uniform_type, **_IMAGE_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Store through _store so the PropTracker detects replacement and re-calls get_bindings(). + self._store.highlight_mask_texture = Texture(np.zeros((1, 1), dtype=np.uint16), dim=2) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self._vis_lut_buffer = Buffer(np.zeros(65535, dtype=np.uint32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.data["fpl_n_visible"] = np.uint32(0) + self.uniform_buffer.data["fpl_vis_axis_y"] = np.uint32(1) + self.uniform_buffer.update_range() + + @property + def _highlight_mask_texture(self): + return self._store.highlight_mask_texture + + @_highlight_mask_texture.setter + def _highlight_mask_texture(self, texture): + self._store.highlight_mask_texture = texture diff --git a/fastplotlib/graphics/shaders/_highlight_shaders.py b/fastplotlib/graphics/shaders/_highlight_shaders.py new file mode 100644 index 000000000..7ed1abbcf --- /dev/null +++ b/fastplotlib/graphics/shaders/_highlight_shaders.py @@ -0,0 +1,414 @@ +""" +Highlightable shader subclasses for fastplotlib. + +Each shader subclass: + 1. Adds two extra bindings: s_highlight_ids (u32 storage) and s_highlight_lut (vec4 storage), + or t_highlight_mask (R8Uint texture) + s_highlight_lut for images. + 2. Patches the compiled WGSL to mix a highlight color into out.color before returning, + leaving out.pick completely untouched. + +Anchor strings are validated at runtime; a warning is emitted and highlighting falls back +to a no-op if a pygfx version change has moved them. +""" + +import warnings + +from pygfx.objects import Points, Line, Image +from pygfx.renderers.wgpu.shaders.pointsshader import PointsShader +from pygfx.renderers.wgpu.shaders.lineshader import LineShader, ThinLineShader +from pygfx.renderers.wgpu.shaders.imageshader import ImageShader +from pygfx.renderers.wgpu import ( + register_wgpu_render_function, + Binding, + GfxTextureView, +) + +from ._highlight_materials import ( + HighlightableLineMaterial, + HighlightableLineThinMaterial, + HighlightablePointsMaterial, + HighlightablePointsMarkerMaterial, + HighlightablePointsSpriteMaterial, + HighlightablePointsGaussianBlobMaterial, + HighlightableImageMaterial, +) + +# --------------------------------------------------------------------------- +# WGSL helper functions (prepended before the fragment entry point) +# --------------------------------------------------------------------------- + +# Points: vertex_idx is a u32, looked up directly in the ids storage buffer. +_POINTS_HELPER = """\ +fn fpl_apply_highlight(base_color: vec4, vertex_idx: u32) -> vec4 { + if (vertex_idx >= arrayLength(&s_highlight_ids)) { return base_color; } + let id = s_highlight_ids[vertex_idx]; + if (id == 0u) { return base_color; } + let h = s_highlight_lut[id - 1u]; + return vec4(mix(base_color.rgb, h.rgb, h.a * u_material.highlight_alpha), base_color.a); +} + +""" + +# Thin lines: highlight color is pre-resolved in the VS as an interpolated vec4 varying. +# The GPU interpolates it across the line_strip for free. +_THIN_LINE_HELPER = """\ +fn fpl_apply_highlight(base_color: vec4, hl: vec4) -> vec4 { + if (hl.a <= 0.0) { return base_color; } + return vec4(mix(base_color.rgb, hl.rgb, hl.a * u_material.highlight_alpha), base_color.a); +} + +""" + +# Thick lines: two interpolated vec4 varyings carry the highlight at each segment endpoint. +# The FS mixes them using the same logic pygfx uses for per-vertex colors at joins. +_LINE_HELPER = """\ +fn fpl_apply_highlight_line( + base_color: vec4, + hl_node: vec4, + hl_vert: vec4, + is_join: bool, + join_coord_lin: f32, + join_coord_fan: f32, +) -> vec4 { + var hl: vec4 = hl_vert; + if (is_join) { + let hl_seg = hl_node - (hl_node - hl_vert) / (1.0 - abs(join_coord_lin)); + hl = mix(hl_seg, hl_node, abs(join_coord_fan)); + } + if (hl.a <= 0.0) { return base_color; } + return vec4(mix(base_color.rgb, hl.rgb, hl.a * u_material.highlight_alpha), base_color.a); +} + +""" + +# Image: mask texture is R16Uint so textureLoad returns u32 directly. +_IMAGE_HELPER = """\ +fn fpl_apply_highlight_img(base_color: vec4, mask_id: u32) -> vec4 { + if (mask_id == 0u) { return base_color; } + let h = s_highlight_lut[mask_id - 1u]; + return vec4(mix(base_color.rgb, h.rgb, h.a * u_material.highlight_alpha), base_color.a); +} + +""" + +# Visibility pre-sample injection: LUT-based row/col remapping. +# fpl_texcoord is always declared so the highlight block can safely reference it. +_IMAGE_SAMPLE_ANCHOR = " let value = sample_im(varyings.texcoord.xy, sizef);" + +_IMAGE_VIS_PRE_SAMPLE = """\ + var fpl_texcoord = varyings.texcoord; + if (u_material.fpl_n_visible > 0u) { + let fpl_vis_px = vec2(varyings.texcoord * sizef); + let fpl_vis_idx = select(fpl_vis_px.x, fpl_vis_px.y, u_material.fpl_vis_axis_y == 1u); + if (fpl_vis_idx >= u_material.fpl_n_visible) { discard; } + let fpl_src_f = f32(s_vis_lut[fpl_vis_idx]); + if (u_material.fpl_vis_axis_y == 1u) { + fpl_texcoord.y = (fpl_src_f + 0.5) / sizef.y; + } else { + fpl_texcoord.x = (fpl_src_f + 0.5) / sizef.x; + } + } + let value = sample_im(fpl_texcoord.xy, sizef);\ +""" + +# --------------------------------------------------------------------------- +# Patch anchors +# External .wgsl files (points.wgsl, line.wgsl, image.wgsl) use 4-space indent. +# ThinLineShader's inline WGSL string uses 12-space indent. +# --------------------------------------------------------------------------- + +# Shared FS patch anchor for external .wgsl files +_FS_COLOR_ANCHOR = " out.color = out_color;" + +# ThinLineShader inline WGSL anchors (12-space indent inside the method string) +_THIN_VS_ANCHOR = " return varyings;\n }" +_THIN_FS_COLOR_ANCHOR = " out.color = out_color;" +_THIN_FRAGMENT_ENTRY = " @fragment\n fn fs_main" + + +def _warn_anchor_missing(label: str, anchor: str) -> None: + warnings.warn( + f"fpl highlight: anchor {anchor!r} not found in {label} WGSL. " + "Highlighting disabled for this graphic type. " + "This is likely caused by a pygfx version change, update the anchor string.", + stacklevel=3, + ) + + +def _check(wgsl: str, anchor: str, label: str) -> bool: + if wgsl.count(anchor) != 1: + _warn_anchor_missing(label, anchor) + return False + return True + + +# --------------------------------------------------------------------------- +# Binding helpers +# --------------------------------------------------------------------------- + +def _add_ids_bindings(shader, group0: dict, material) -> None: + """Append s_highlight_ids and s_highlight_lut bindings to group0.""" + next_idx = max(group0.keys()) + 1 + new = { + next_idx: Binding( + "s_highlight_ids", + "buffer/read_only_storage", + material._highlight_ids_buffer, + "FRAGMENT", + ), + next_idx + 1: Binding( + "s_highlight_lut", + "buffer/read_only_storage", + material._highlight_lut_buffer, + "FRAGMENT", + ), + } + shader.define_bindings(0, new) + group0.update(new) + + +def _add_ids_bindings_vs_fs(shader, group0: dict, material) -> None: + """Append s_highlight_ids (VERTEX+FRAGMENT) and s_highlight_lut bindings to group0.""" + import wgpu as _wgpu + vs_fs = _wgpu.ShaderStage.VERTEX | _wgpu.ShaderStage.FRAGMENT + next_idx = max(group0.keys()) + 1 + new = { + next_idx: Binding( + "s_highlight_ids", + "buffer/read_only_storage", + material._highlight_ids_buffer, + vs_fs, + ), + next_idx + 1: Binding( + "s_highlight_lut", + "buffer/read_only_storage", + material._highlight_lut_buffer, + vs_fs, + ), + } + shader.define_bindings(0, new) + group0.update(new) + + +def _add_mask_bindings(shader, group0: dict, material) -> None: + """Append t_highlight_mask, s_highlight_lut, and s_vis_lut bindings to group0.""" + next_idx = max(group0.keys()) + 1 + mask_view = GfxTextureView(material._highlight_mask_texture) + new = { + next_idx: Binding( + "t_highlight_mask", + "texture/auto", + mask_view, + "FRAGMENT", + ), + next_idx + 1: Binding( + "s_highlight_lut", + "buffer/read_only_storage", + material._highlight_lut_buffer, + "FRAGMENT", + ), + next_idx + 2: Binding( + "s_vis_lut", + "buffer/read_only_storage", + material._vis_lut_buffer, + "FRAGMENT", + ), + } + shader.define_bindings(0, new) + group0.update(new) + + +# --------------------------------------------------------------------------- +# Points shader (pure FS patch, pick_idx is available as a flat u32 varying) +# --------------------------------------------------------------------------- + +@register_wgpu_render_function(Points, HighlightablePointsMaterial) +@register_wgpu_render_function(Points, HighlightablePointsMarkerMaterial) +@register_wgpu_render_function(Points, HighlightablePointsSpriteMaterial) +@register_wgpu_render_function(Points, HighlightablePointsGaussianBlobMaterial) +class HighlightablePointsShader(PointsShader): + + def get_bindings(self, wobject, shared, scene): + result = super().get_bindings(wobject, shared, scene) + group0 = result[0] + _add_ids_bindings(self, group0, wobject.material) + return {0: group0} + + def get_code(self): + wgsl = super().get_code() + if not _check(wgsl, _FS_COLOR_ANCHOR, "points.wgsl"): + return wgsl + if not _check(wgsl, "@fragment\nfn fs_main", "points.wgsl"): + return wgsl + + wgsl = wgsl.replace( + _FS_COLOR_ANCHOR, + " out.color = fpl_apply_highlight(out_color, varyings.pick_idx);", + 1, + ) + wgsl = wgsl.replace( + "@fragment\nfn fs_main", + _POINTS_HELPER + "@fragment\nfn fs_main", + 1, + ) + return wgsl + + +# --------------------------------------------------------------------------- +# Image shader (FS patch using world_pos for global pixel coordinates) +# --------------------------------------------------------------------------- + +@register_wgpu_render_function(Image, HighlightableImageMaterial) +class HighlightableImageShader(ImageShader): + + def get_bindings(self, wobject, shared, scene): + result = super().get_bindings(wobject, shared, scene) + group0 = result[0] + _add_mask_bindings(self, group0, wobject.material) + return {0: group0} + + def get_code(self): + wgsl = super().get_code() + + # Pre-sample: inject visibility LUT remapping. fpl_texcoord is always + # declared here so the highlight block below can safely reference it + # regardless of whether visibility is active (fpl_n_visible == 0 is a no-op). + if not _check(wgsl, _IMAGE_SAMPLE_ANCHOR, "image.wgsl sample"): + return wgsl + wgsl = wgsl.replace(_IMAGE_SAMPLE_ANCHOR, _IMAGE_VIS_PRE_SAMPLE, 1) + + # Post-sample: highlight blend. Uses fpl_texcoord (source coords) so that + # highlight indices always refer to original data positions whether or not + # visibility remapping is active. + if not _check(wgsl, _FS_COLOR_ANCHOR, "image.wgsl"): + return wgsl + mask_lines = ( + " let fpl_px = vec2(fpl_texcoord * vec2(textureDimensions(t_highlight_mask)));\n" + " let fpl_mask_id = textureLoad(t_highlight_mask, fpl_px, 0).r;\n" + " out.color = fpl_apply_highlight_img(out_color, fpl_mask_id);" + ) + wgsl = wgsl.replace(_FS_COLOR_ANCHOR, mask_lines, 1) + + if not _check(wgsl, "@fragment\nfn fs_main", "image.wgsl FS entry"): + return wgsl + wgsl = wgsl.replace( + "@fragment\nfn fs_main", + _IMAGE_HELPER + "@fragment\nfn fs_main", + 1, + ) + return wgsl + + +# --------------------------------------------------------------------------- +# Thin line shader (line_strip: GPU interpolates varyings between vertices) +# The VS looks up s_highlight_ids[i0] and stores the resolved LUT color in a +# vec4 varying; the GPU linearly interpolates it across each segment for free. +# --------------------------------------------------------------------------- + +_THIN_VS_INJECTION = ( + " let fpl_hl_id = select(0u, s_highlight_ids[u32(i0)],\n" + " u32(i0) < arrayLength(&s_highlight_ids));\n" + " varyings.fpl_hl_color = select(\n" + " vec4(0.0), s_highlight_lut[fpl_hl_id - 1u], fpl_hl_id != 0u);\n" + " return varyings;\n" + " }" +) + + +@register_wgpu_render_function(Line, HighlightableLineThinMaterial) +class HighlightableThinLineShader(ThinLineShader): + + def get_bindings(self, wobject, shared, scene): + result = super().get_bindings(wobject, shared, scene) + group0 = result[0] + # Needs VERTEX stage so the VS can read the ids buffer + _add_ids_bindings_vs_fs(self, group0, wobject.material) + return {0: group0} + + def get_code(self): + wgsl = super().get_code() + + if not _check(wgsl, _THIN_VS_ANCHOR, "ThinLineShader VS"): + return wgsl + wgsl = wgsl.replace(_THIN_VS_ANCHOR, _THIN_VS_INJECTION, 1) + + if not _check(wgsl, _THIN_FS_COLOR_ANCHOR, "ThinLineShader FS"): + return wgsl + wgsl = wgsl.replace( + _THIN_FS_COLOR_ANCHOR, + " out.color = fpl_apply_highlight(out_color, varyings.fpl_hl_color);", + 1, + ) + + if not _check(wgsl, _THIN_FRAGMENT_ENTRY, "ThinLineShader FS entry"): + return wgsl + wgsl = wgsl.replace( + _THIN_FRAGMENT_ENTRY, + _THIN_LINE_HELPER + _THIN_FRAGMENT_ENTRY, + 1, + ) + return wgsl + + +# --------------------------------------------------------------------------- +# Thick line shader (triangle geometry: smooth interpolation via two varyings) +# +# VS injection: placed just before varyings.pick_idx assignment so that +# node_index, node_index_prev, node_index_next, node_index_is_even and +# ratio_interp are all already in scope. +# +# The two varyings (fpl_hl_color_node, fpl_hl_color_vert) mirror the pattern +# pygfx uses for color_node / color_vert in vertex-color mode. The FS mixes +# them at joins using the same join_coord logic pygfx uses for vertex colors. +# --------------------------------------------------------------------------- + +_LINE_VS_ANCHOR = " varyings.pick_idx = u32(node_index);" + +_LINE_VS_INJECTION = """\ + let fpl_other_idx = select(node_index_prev, node_index_next, node_index_is_even); + let fpl_hl_id_node = select(0u, s_highlight_ids[u32(node_index)], + u32(node_index) < arrayLength(&s_highlight_ids)); + let fpl_hl_id_other = select(0u, s_highlight_ids[u32(fpl_other_idx)], + u32(fpl_other_idx) < arrayLength(&s_highlight_ids)); + let fpl_hl_raw_node = select(vec4(0.0), s_highlight_lut[fpl_hl_id_node - 1u], fpl_hl_id_node != 0u); + let fpl_hl_raw_other = select(vec4(0.0), s_highlight_lut[fpl_hl_id_other - 1u], fpl_hl_id_other != 0u); + varyings.fpl_hl_color_node = fpl_hl_raw_node; + varyings.fpl_hl_color_vert = mix(fpl_hl_raw_node, fpl_hl_raw_other, ratio_interp); + varyings.pick_idx = u32(node_index);\ +""" + +_LINE_FS_REPLACEMENT = ( + " out.color = fpl_apply_highlight_line(\n" + " out_color, varyings.fpl_hl_color_node, varyings.fpl_hl_color_vert,\n" + " is_join, join_coord_lin, join_coord_fan);" +) + + +@register_wgpu_render_function(Line, HighlightableLineMaterial) +class HighlightableLineShader(LineShader): + + def get_bindings(self, wobject, shared, scene): + result = super().get_bindings(wobject, shared, scene) + group0 = result[0] + _add_ids_bindings_vs_fs(self, group0, wobject.material) + return {0: group0} + + def get_code(self): + wgsl = super().get_code() + + if not _check(wgsl, _LINE_VS_ANCHOR, "line.wgsl VS"): + return wgsl + wgsl = wgsl.replace(_LINE_VS_ANCHOR, _LINE_VS_INJECTION, 1) + + if not _check(wgsl, _FS_COLOR_ANCHOR, "line.wgsl FS"): + return wgsl + wgsl = wgsl.replace(_FS_COLOR_ANCHOR, _LINE_FS_REPLACEMENT, 1) + + if not _check(wgsl, "@fragment\nfn fs_main", "line.wgsl FS entry"): + return wgsl + wgsl = wgsl.replace( + "@fragment\nfn fs_main", + _LINE_HELPER + "@fragment\nfn fs_main", + 1, + ) + return wgsl diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index f90cdcf87..030927540 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -5,13 +5,13 @@ import numpy as np import pygfx -from pylinalg import vec_transform, vec_unproject +from pylinalg import vec_transform, vec_unproject, aabb_to_sphere from rendercanvas import BaseRenderCanvas from ._utils import create_controller from ..graphics._base import Graphic, WORLD_OBJECT_TO_GRAPHIC from ..graphics import ImageGraphic, MeshGraphic -from ..graphics.selectors._base_selector import BaseSelector +from ..graphics.selectors import SelectorProtocol from ._graphic_methods_mixin import GraphicMethodsMixin from ..legends import Legend from ..tools import Tooltip @@ -27,6 +27,26 @@ IPYTHON = get_ipython() +def _get_visible_bounding_box(obj: pygfx.Scene | pygfx.Group | pygfx.WorldObject): + """Recursively compute world bounding box of only visible objects, down to leaf nodes.""" + if not obj.visible: + return None + children = list(obj.children) + + if not children: + return obj.get_world_bounding_box() + + bboxes = [] + for child in children: + bbox = _get_visible_bounding_box(child) + if bbox is not None: + bboxes.append(bbox) + if not bboxes: + return None + bboxes = np.array(bboxes) + return np.array([bboxes[:, 0, :].min(axis=0), bboxes[:, 1, :].max(axis=0)]) + + class PlotArea(GraphicMethodsMixin): def __init__( self, @@ -95,7 +115,7 @@ def __init__( self._graphics: list[Graphic] = list() # selectors are in their own list so they can be excluded from scene bbox calculations - self._selectors: list[BaseSelector] = list() + self._selectors: list[SelectorProtocol] = list() # legends, managed just like other graphics as explained above self._legends: list[Legend] = list() @@ -247,7 +267,7 @@ def graphics(self) -> tuple[Graphic, ...]: return tuple(self._graphics) @property - def selectors(self) -> tuple[BaseSelector, ...]: + def selectors(self) -> tuple[SelectorProtocol, ...]: """Selectors in the plot area.""" return tuple(self._selectors) @@ -257,7 +277,7 @@ def legends(self) -> tuple[Legend, ...]: return tuple(self._legends) @property - def objects(self) -> tuple[Graphic | BaseSelector | Legend, ...]: + def objects(self) -> tuple[Graphic | SelectorProtocol | Legend, ...]: return *self.graphics, *self.selectors, *self.legends @property @@ -692,7 +712,7 @@ def _add_or_insert_graphic( if graphic.name is not None: # skip for those that have no name self._check_graphic_name_exists(graphic.name) - if isinstance(graphic, BaseSelector): + if isinstance(graphic, SelectorProtocol): obj_list = self._selectors self.scene.add(graphic.world_object) @@ -705,7 +725,7 @@ def _add_or_insert_graphic( self._fpl_graphics_scene.add(graphic.world_object) else: - raise TypeError("graphic must be of type Graphic | BaseSelector | Legend") + raise TypeError("graphic must be of type Graphic | SelectorProtocol | Legend") if action == "insert": obj_list.insert(index, graphic) @@ -783,7 +803,12 @@ def center_scene(self, *, zoom: float = 1.0): def _auto_center_scene( self, camera: pygfx.PerspectiveCamera, scene: pygfx.Scene, zoom: float ): - camera.show_object(scene) + bb = _get_visible_bounding_box(scene) + if bb is not None: + sphere = aabb_to_sphere(bb) + camera.show_object(sphere) + else: + camera.show_object(scene) # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate # probably because camera.show_object uses bounding sphere camera.zoom = zoom @@ -849,8 +874,9 @@ def _auto_scale_scene( ): camera.maintain_aspect = maintain_aspect - if len(scene.children) > 0: - width, height, depth = np.ptp(scene.get_world_bounding_box(), axis=0) + bb = _get_visible_bounding_box(scene) + if bb is not None: + width, height, depth = np.ptp(bb, axis=0) else: width, height, depth = (1, 1, 1) @@ -914,7 +940,7 @@ def remove_graphic(self, graphic: Graphic): """ - if isinstance(graphic, (BaseSelector, Legend)): + if isinstance(graphic, (SelectorProtocol, Legend)): self.scene.remove(graphic.world_object) elif isinstance(graphic, Graphic): @@ -933,7 +959,7 @@ def delete_graphic(self, graphic: Graphic): if graphic not in self: raise KeyError(f"Graphic not found in plot area: {graphic}") - if isinstance(graphic, BaseSelector): + if isinstance(graphic, SelectorProtocol): self._selectors.remove(graphic) elif isinstance(graphic, Legend): diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 3941e2d02..61e3c97d2 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -671,7 +671,6 @@ def __init__( self.cmap_transform_each = cmap_transform_each self._graphic_type = graphic_type - self._create_graphic() self._x_range_mode = None self.x_range_mode = x_range_mode @@ -700,6 +699,8 @@ def __init__( else: self._linear_selector = None + self._create_graphic() + @property def processor(self) -> NDPositionsProcessor: return self._processor @@ -920,6 +921,19 @@ def _create_graphic(self): self._subplot.add_graphic(self._graphic) + # set the initial position and limits of the linear selector + # x range of the data + xr = data_slice[0, 0, 0], data_slice[0, -1, 0] + if self._linear_selector is not None: + with pause_events( + self._linear_selector + ): # we don't want the linear selector change to update the indices + self._linear_selector.limits = xr + # linear selector acts on `p` dim + self._linear_selector.selection = self.indices[ + self.processor.spatial_dims[1] + ] + def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: """return [n_rows, n_cols] shape data from [n_timeseries, n_timepoints, xy] data""" # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense From 427baaeb9b95a94c9ba98d5fe1181a47e1fa1ce7 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Sat, 2 May 2026 09:29:16 -0400 Subject: [PATCH 112/163] compute capabilities (#1040) * allow setting texture usage directly * ImageHighlightSelector, VisibilitySelector and ImageVisibilitySelector can handle 'None' as placeholders in the selection * fix example --- .../selection_tools/visibility_selector.py | 9 +- fastplotlib/graphics/features/_image.py | 5 +- .../graphics/selectors/_highlight_selector.py | 16 ++- .../selectors/_visibility_selector.py | 97 +++++++++++++++---- .../graphics/shaders/_highlight_shaders.py | 7 +- 5 files changed, 106 insertions(+), 28 deletions(-) diff --git a/examples/selection_tools/visibility_selector.py b/examples/selection_tools/visibility_selector.py index 996204536..b81009389 100644 --- a/examples/selection_tools/visibility_selector.py +++ b/examples/selection_tools/visibility_selector.py @@ -152,10 +152,6 @@ def image_clicked(session, ev): # Create selectors # image highlight selector for this session image_selector = fpl.ImageHighlightSelector( - ndi.graphic, # target graphic, you can also add more target graphics later - # as long as they are in the same "selection space", ex: each movie for single-session - # each selector manages ONE buffer, so the same pixels will be highlighted on all graphics - # targetted by a selector. lut="tab10", selection_options={"pixels": contours}, # pre-loaded selection options options_alpha=0.1, # unselected contours shown with low alpha @@ -170,7 +166,10 @@ def image_clicked(session, ev): ndt.graphic, lut="tab10", lut_wrap="repeat" ) - # image selector targets the image graphic for this session + # target graphic, you can also add more target graphics later + # as long as they are in the same "selection space", ex: each movie for single-session + # each selector manages ONE buffer, so the same pixels will be highlighted on all graphics + # targetted by a selector. image_selector.add_graphic(ndi.graphic) # when image is double clicked, calls the handler ndi.graphic.add_event_handler(partial(image_clicked, session_index), "double_click") diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index b47fc41ea..12df0b6b7 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -43,6 +43,7 @@ def __init__( data, property_name: str = "data", cpu_buffer: bool = True, + usage: wgpu.TextureUsage = 0, colorspace: ColorspacesRGB = ColorspacesRGB.srgb, ): super().__init__(property_name=property_name) @@ -60,9 +61,10 @@ def __init__( # create a local buffer self._value = np.empty(data.shape, dtype=data.dtype) self.value[:] = data[:] + usage = usage else: self._value = None - usage = wgpu.TextureUsage.COPY_DST + usage = wgpu.TextureUsage.COPY_DST | usage # auto-determine format, adapted from pygfx.Texture element_format = get_element_format_from_numpy_array(data) if element_format is None: @@ -106,6 +108,7 @@ def __init__( self.value[slicer], dim=2, colorspace=colorspace, + usage=usage ) else: # we only supply the size diff --git a/fastplotlib/graphics/selectors/_highlight_selector.py b/fastplotlib/graphics/selectors/_highlight_selector.py index ac1de45e7..b509022e9 100644 --- a/fastplotlib/graphics/selectors/_highlight_selector.py +++ b/fastplotlib/graphics/selectors/_highlight_selector.py @@ -570,7 +570,7 @@ def __init__( super().__init__(color=color, lut=lut, alpha=alpha, lut_wrap=lut_wrap) self._selection: dict[str, list] = dict() - self._selected_indices: list[int] = list() + self._selected_indices: list[int | None] = list() self._options_color = options_color self._options_alpha = float(options_alpha) @@ -669,7 +669,7 @@ def options_alpha(self, value: float) -> None: self._update_all_graphics() @property - def selection(self) -> tuple[int, ...] | dict[str, tuple]: + def selection(self) -> tuple[int | None, ...] | dict[str, tuple]: """ In options mode: tuple of selection option indices. In free mode: dict of selection items. @@ -690,7 +690,7 @@ def selection(self, value: Iterable[int] | dict[Literal["rows", "cols", "pixels" self._selected_indices = [value] else: - self._selected_indices = [int(i) for i in value] + self._selected_indices = [int(i) if i is not None else None for i in value] else: if not value: @@ -837,15 +837,23 @@ def _create_mask(self, n_rows: int, n_cols: int) -> np.ndarray: ) # start=1 since 0 indicates unselected placeholder value for i, (rs, cs) in enumerate(zip(sel["rows"], sel["cols"]), start=1): + if rs is None or cs in None: + continue mask[rs, cs] = i elif "rows" in sel: for i, rs in enumerate(sel["rows"], start=1): + if rs is None: + continue mask[rs, :] = i elif "cols" in sel: for i, cs in enumerate(sel["cols"], start=1): + if cs in None: + continue mask[:, cs] = i elif "pixels" in sel: for i, px in enumerate(sel["pixels"], start=1): + if px is None: + continue arr = np.asarray(px) mask[arr[:, 0], arr[:, 1]] = i return mask @@ -868,6 +876,8 @@ def _fill_lut(self) -> None: ) current_lut[:, -1] *= self._alpha for i, sel in enumerate(self._selected_indices): + if sel is None: + continue lut_buffer[sel] = current_lut[i] else: n = self._len_dict(self._selection) diff --git a/fastplotlib/graphics/selectors/_visibility_selector.py b/fastplotlib/graphics/selectors/_visibility_selector.py index 91af3abb6..d54f5ba90 100644 --- a/fastplotlib/graphics/selectors/_visibility_selector.py +++ b/fastplotlib/graphics/selectors/_visibility_selector.py @@ -21,8 +21,8 @@ def _validate_int_collection(value, name: str) -> set | int: s = set(value) - if not all(isinstance(i, Integral) for i in s): - raise TypeError(f"{name} must contain only integers, got: {s!r}") + if not all(isinstance(i, Integral) or i is None for i in s): + raise TypeError(f"{name} must contain only integers or None, got: {s!r}") return value @@ -69,7 +69,7 @@ def __init__( raise ValueError(f"lut_wrap must be 'fixed' or 'repeat', got {lut_wrap!r}") self._collection = collection - self._selection: list[int] = [] + self._selection: list[int | None] = [] self._event_handlers: list[Callable] = [] self._lut_wrap = lut_wrap @@ -109,16 +109,18 @@ def __del__(self): g.colors = self._original_colors[i] @property - def selection(self) -> tuple[int, ...]: + def selection(self) -> tuple[int | None, ...]: """Get or set the selection""" return tuple(self._selection) @selection.setter - def selection(self, new_selection: Iterable[int] | int): + def selection(self, new_selection: Iterable[int | None] | int): if new_selection: _validate_int_collection(new_selection, "selection") for index in self._selection: + if index is None: + continue # set any selected things to be invisible self._collection.graphics[index].visible = False @@ -128,6 +130,8 @@ def selection(self, new_selection: Iterable[int] | int): self._selection = list(new_selection) if new_selection else list() for index in self._selection: + if index is None: + continue # set the new selection to be visible self._collection.graphics[index].visible = True @@ -139,13 +143,15 @@ def selection(self, new_selection: Iterable[int] | int): def append(self, item: int): """Add an index to the selection. Already-selected indices are skipped.""" - if not isinstance(item, Integral): - raise TypeError(f"item must be an integer, got {type(item)}") + if not isinstance(item, Integral) and item is not None: + raise TypeError(f"item must be an integer or None, got {type(item)}") - if item in self._selection: + if item in self._selection and item is not None: return - self._collection.graphics[item].visible = True + if item is not None: + self._collection.graphics[item].visible = True + self._selection.append(item) if self._is_stack: @@ -171,13 +177,41 @@ def remove(self, item: int): self._apply_lut() self._emit({"value": list(self._selection)}) + def pop(self, index: int): + """pop item at the given index""" + + if not isinstance(index, Integral): + raise TypeError( + f"pop argument must be an integer, got: {type(index).__name__}" + ) + + if index >= len(self): + raise IndexError( + f"index: {index} out of bounds for {self.__class__.__name__} with length: {len(self)}" + ) + + item = self._selection[index] + if item is not None: + self._collection.graphics[item].visible = False + + self._selection.pop(index) + + if self._is_stack: + self._restack() + + self._apply_lut() + self._emit({"value": list(self._selection)}) + def clear(self) -> None: """Hide all graphics. Stack offsets are left as-is.""" for idx in self._selection: + if idx is None: + continue self._collection.graphics[idx].visible = False self._selection = list() self._emit({"value": []}) + @property def lut(self) -> np.ndarray | None: """Optional per-item colors, shape ``(n, 4)`` float32 RGBA""" @@ -201,6 +235,8 @@ def _apply_lut(self) -> None: color=None, lut=self._lut, n=len(self._selection), lut_wrap=self._lut_wrap ) for sel_index, graphic_index in enumerate(self._selection): + if graphic_index is None: + continue self._collection.graphics[graphic_index].colors = colors[sel_index] def _restack(self) -> None: @@ -209,6 +245,9 @@ def _restack(self) -> None: distance = 0.0 for index in self._selection: + if index is None: + continue + g = self._collection.graphics[index] offset = list(g.offset) offset[ax_i] = distance @@ -243,10 +282,7 @@ def __iter__(self): return iter(self._selection) def __repr__(self) -> str: - return ( - f"VisibilitySelector\n" - f"selection: {self._selection}" - ) + return f"VisibilitySelector\n" f"selection: {self._selection}" class ImageVisibilitySelector: @@ -334,12 +370,12 @@ def selection(self, value: Iterable[int]): self._update_material() self._emit({"value": tuple(self._selection)}) - def append(self, item) -> None: + def append(self, item: int | None): """add a row/col index to the selection""" - if not isinstance(item, Integral): - raise TypeError(f"item must be an integer, got {type(item)}") + if not isinstance(item, Integral) and item is not None: + raise TypeError(f"item must be an integer or None, got {type(item)}") - if item in self._selection: + if item in self._selection and item is not None: return self._selection.append(item) @@ -359,6 +395,22 @@ def remove(self, item) -> None: self._update_material() self._emit({"value": list(self._selection)}) + def pop(self, index: int): + """pop item at the given index""" + if not isinstance(index, Integral): + raise TypeError( + f"pop argument must be an integer, got: {type(index).__name__}" + ) + + if index >= len(self): + raise IndexError( + f"index: {index} out of bounds for {self.__class__.__name__} with length: {len(self)}" + ) + + self._selection.pop(index) + self._update_material() + self._emit({"value": list(self._selection)}) + def clear(self) -> None: """Clear the selection (all invisible, fpl_n_visible=0).""" self._selection = list() @@ -369,7 +421,16 @@ def _update_material(self) -> None: mat = self._graphic._material n = len(self._selection) if n > 0: - mat._vis_lut_buffer.data[:n] = np.array(self._selection, dtype=np.uint32) + mat._vis_lut_buffer.data[:n] = np.array( + list( + map( + # 0xFFFFFFFF, 2^32 - 1, indicates None vals and shader discard + lambda x: x if x is not None else np.uint32(0xFFFFFFFF), + self._selection, + ) + ), + dtype=np.uint32, + ) mat._vis_lut_buffer.update_range() mat.uniform_buffer.data["fpl_n_visible"] = np.uint32(n) diff --git a/fastplotlib/graphics/shaders/_highlight_shaders.py b/fastplotlib/graphics/shaders/_highlight_shaders.py index 7ed1abbcf..4db568e35 100644 --- a/fastplotlib/graphics/shaders/_highlight_shaders.py +++ b/fastplotlib/graphics/shaders/_highlight_shaders.py @@ -101,7 +101,12 @@ let fpl_vis_px = vec2(varyings.texcoord * sizef); let fpl_vis_idx = select(fpl_vis_px.x, fpl_vis_px.y, u_material.fpl_vis_axis_y == 1u); if (fpl_vis_idx >= u_material.fpl_n_visible) { discard; } - let fpl_src_f = f32(s_vis_lut[fpl_vis_idx]); + + // discard Nones which we map to 0xFFFFFFFF + let fpl_src_u = s_vis_lut[fpl_vis_idx]; + if (fpl_src_u == 0xFFFFFFFFu) { discard; } + let fpl_src_f = f32(fpl_src_u); + if (u_material.fpl_vis_axis_y == 1u) { fpl_texcoord.y = (fpl_src_f + 0.5) / sizef.y; } else { From 35de1916f86f68937babe842e6f924056d9cf12e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 3 May 2026 23:19:29 -0400 Subject: [PATCH 113/163] comments --- .../graphics/shaders/_highlight_shaders.py | 72 +++++-------------- 1 file changed, 17 insertions(+), 55 deletions(-) diff --git a/fastplotlib/graphics/shaders/_highlight_shaders.py b/fastplotlib/graphics/shaders/_highlight_shaders.py index 7ed1abbcf..6b114bf7a 100644 --- a/fastplotlib/graphics/shaders/_highlight_shaders.py +++ b/fastplotlib/graphics/shaders/_highlight_shaders.py @@ -33,11 +33,6 @@ HighlightableImageMaterial, ) -# --------------------------------------------------------------------------- -# WGSL helper functions (prepended before the fragment entry point) -# --------------------------------------------------------------------------- - -# Points: vertex_idx is a u32, looked up directly in the ids storage buffer. _POINTS_HELPER = """\ fn fpl_apply_highlight(base_color: vec4, vertex_idx: u32) -> vec4 { if (vertex_idx >= arrayLength(&s_highlight_ids)) { return base_color; } @@ -49,8 +44,6 @@ """ -# Thin lines: highlight color is pre-resolved in the VS as an interpolated vec4 varying. -# The GPU interpolates it across the line_strip for free. _THIN_LINE_HELPER = """\ fn fpl_apply_highlight(base_color: vec4, hl: vec4) -> vec4 { if (hl.a <= 0.0) { return base_color; } @@ -59,8 +52,6 @@ """ -# Thick lines: two interpolated vec4 varyings carry the highlight at each segment endpoint. -# The FS mixes them using the same logic pygfx uses for per-vertex colors at joins. _LINE_HELPER = """\ fn fpl_apply_highlight_line( base_color: vec4, @@ -81,7 +72,6 @@ """ -# Image: mask texture is R16Uint so textureLoad returns u32 directly. _IMAGE_HELPER = """\ fn fpl_apply_highlight_img(base_color: vec4, mask_id: u32) -> vec4 { if (mask_id == 0u) { return base_color; } @@ -91,8 +81,6 @@ """ -# Visibility pre-sample injection: LUT-based row/col remapping. -# fpl_texcoord is always declared so the highlight block can safely reference it. _IMAGE_SAMPLE_ANCHOR = " let value = sample_im(varyings.texcoord.xy, sizef);" _IMAGE_VIS_PRE_SAMPLE = """\ @@ -101,7 +89,12 @@ let fpl_vis_px = vec2(varyings.texcoord * sizef); let fpl_vis_idx = select(fpl_vis_px.x, fpl_vis_px.y, u_material.fpl_vis_axis_y == 1u); if (fpl_vis_idx >= u_material.fpl_n_visible) { discard; } - let fpl_src_f = f32(s_vis_lut[fpl_vis_idx]); + + // discard Nones which we map to 0xFFFFFFFF + let fpl_src_u = s_vis_lut[fpl_vis_idx]; + if (fpl_src_u == 0xFFFFFFFFu) { discard; } + let fpl_src_f = f32(fpl_src_u); + if (u_material.fpl_vis_axis_y == 1u) { fpl_texcoord.y = (fpl_src_f + 0.5) / sizef.y; } else { @@ -111,16 +104,10 @@ let value = sample_im(fpl_texcoord.xy, sizef);\ """ -# --------------------------------------------------------------------------- -# Patch anchors -# External .wgsl files (points.wgsl, line.wgsl, image.wgsl) use 4-space indent. -# ThinLineShader's inline WGSL string uses 12-space indent. -# --------------------------------------------------------------------------- -# Shared FS patch anchor for external .wgsl files +# fragment shader replacement position, same for most shaders _FS_COLOR_ANCHOR = " out.color = out_color;" -# ThinLineShader inline WGSL anchors (12-space indent inside the method string) _THIN_VS_ANCHOR = " return varyings;\n }" _THIN_FS_COLOR_ANCHOR = " out.color = out_color;" _THIN_FRAGMENT_ENTRY = " @fragment\n fn fs_main" @@ -142,10 +129,6 @@ def _check(wgsl: str, anchor: str, label: str) -> bool: return True -# --------------------------------------------------------------------------- -# Binding helpers -# --------------------------------------------------------------------------- - def _add_ids_bindings(shader, group0: dict, material) -> None: """Append s_highlight_ids and s_highlight_lut bindings to group0.""" next_idx = max(group0.keys()) + 1 @@ -156,7 +139,8 @@ def _add_ids_bindings(shader, group0: dict, material) -> None: material._highlight_ids_buffer, "FRAGMENT", ), - next_idx + 1: Binding( + next_idx + + 1: Binding( "s_highlight_lut", "buffer/read_only_storage", material._highlight_lut_buffer, @@ -170,6 +154,7 @@ def _add_ids_bindings(shader, group0: dict, material) -> None: def _add_ids_bindings_vs_fs(shader, group0: dict, material) -> None: """Append s_highlight_ids (VERTEX+FRAGMENT) and s_highlight_lut bindings to group0.""" import wgpu as _wgpu + vs_fs = _wgpu.ShaderStage.VERTEX | _wgpu.ShaderStage.FRAGMENT next_idx = max(group0.keys()) + 1 new = { @@ -179,7 +164,8 @@ def _add_ids_bindings_vs_fs(shader, group0: dict, material) -> None: material._highlight_ids_buffer, vs_fs, ), - next_idx + 1: Binding( + next_idx + + 1: Binding( "s_highlight_lut", "buffer/read_only_storage", material._highlight_lut_buffer, @@ -201,13 +187,15 @@ def _add_mask_bindings(shader, group0: dict, material) -> None: mask_view, "FRAGMENT", ), - next_idx + 1: Binding( + next_idx + + 1: Binding( "s_highlight_lut", "buffer/read_only_storage", material._highlight_lut_buffer, "FRAGMENT", ), - next_idx + 2: Binding( + next_idx + + 2: Binding( "s_vis_lut", "buffer/read_only_storage", material._vis_lut_buffer, @@ -218,10 +206,6 @@ def _add_mask_bindings(shader, group0: dict, material) -> None: group0.update(new) -# --------------------------------------------------------------------------- -# Points shader (pure FS patch, pick_idx is available as a flat u32 varying) -# --------------------------------------------------------------------------- - @register_wgpu_render_function(Points, HighlightablePointsMaterial) @register_wgpu_render_function(Points, HighlightablePointsMarkerMaterial) @register_wgpu_render_function(Points, HighlightablePointsSpriteMaterial) @@ -254,10 +238,6 @@ def get_code(self): return wgsl -# --------------------------------------------------------------------------- -# Image shader (FS patch using world_pos for global pixel coordinates) -# --------------------------------------------------------------------------- - @register_wgpu_render_function(Image, HighlightableImageMaterial) class HighlightableImageShader(ImageShader): @@ -272,7 +252,7 @@ def get_code(self): # Pre-sample: inject visibility LUT remapping. fpl_texcoord is always # declared here so the highlight block below can safely reference it - # regardless of whether visibility is active (fpl_n_visible == 0 is a no-op). + # regardless of whether visibility is active if not _check(wgsl, _IMAGE_SAMPLE_ANCHOR, "image.wgsl sample"): return wgsl wgsl = wgsl.replace(_IMAGE_SAMPLE_ANCHOR, _IMAGE_VIS_PRE_SAMPLE, 1) @@ -299,12 +279,6 @@ def get_code(self): return wgsl -# --------------------------------------------------------------------------- -# Thin line shader (line_strip: GPU interpolates varyings between vertices) -# The VS looks up s_highlight_ids[i0] and stores the resolved LUT color in a -# vec4 varying; the GPU linearly interpolates it across each segment for free. -# --------------------------------------------------------------------------- - _THIN_VS_INJECTION = ( " let fpl_hl_id = select(0u, s_highlight_ids[u32(i0)],\n" " u32(i0) < arrayLength(&s_highlight_ids));\n" @@ -350,18 +324,6 @@ def get_code(self): return wgsl -# --------------------------------------------------------------------------- -# Thick line shader (triangle geometry: smooth interpolation via two varyings) -# -# VS injection: placed just before varyings.pick_idx assignment so that -# node_index, node_index_prev, node_index_next, node_index_is_even and -# ratio_interp are all already in scope. -# -# The two varyings (fpl_hl_color_node, fpl_hl_color_vert) mirror the pattern -# pygfx uses for color_node / color_vert in vertex-color mode. The FS mixes -# them at joins using the same join_coord logic pygfx uses for vertex colors. -# --------------------------------------------------------------------------- - _LINE_VS_ANCHOR = " varyings.pick_idx = u32(node_index);" _LINE_VS_INJECTION = """\ From 71cf24ff7d21d56410b652f0cbde46919974fe31 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Sat, 9 May 2026 04:26:32 -0400 Subject: [PATCH 114/163] better axes padding for tick labels, settable axis label (#1049) * better axes padding for tick labels, settable axis label * cleanup * move axes to tools * update * move stuff around * update docs * update * update --- docs/source/api/axes/Axes.rst | 44 + docs/source/api/axes/Grid.rst | 66 + docs/source/api/axes/Grids.rst | 59 + docs/source/api/axes/Ruler.rst | 74 + docs/source/api/axes/index.rst | 10 + .../api/graphic_features/TextureArray.rst | 3 + .../api/graphic_features/TextureYUV.rst | 39 + docs/source/api/graphic_features/index.rst | 2 + docs/source/api/graphic_features/tuple.rst | 31 + docs/source/api/graphics/Graphic.rst | 1 + docs/source/api/graphics/ImageGraphic.rst | 3 + .../api/graphics/ImageVolumeGraphic.rst | 1 + docs/source/api/graphics/ImageYUVGraphic.rst | 67 + docs/source/api/graphics/LineCollection.rst | 1 + docs/source/api/graphics/LineGraphic.rst | 1 + docs/source/api/graphics/LineStack.rst | 1 + docs/source/api/graphics/MeshGraphic.rst | 1 + docs/source/api/graphics/PolygonGraphic.rst | 1 + .../source/api/graphics/ScatterCollection.rst | 70 + docs/source/api/graphics/ScatterGraphic.rst | 1 + docs/source/api/graphics/ScatterStack.rst | 72 + docs/source/api/graphics/SurfaceGraphic.rst | 1 + docs/source/api/graphics/TextGraphic.rst | 1 + docs/source/api/graphics/VectorsGraphic.rst | 1 + docs/source/api/graphics/index.rst | 3 + docs/source/api/layouts/imgui_figure.rst | 1 + docs/source/api/layouts/subplot.rst | 5 + .../selectors/CollectionHighlightSelector.rst | 42 + .../api/selectors/HighlightSelector.rst | 42 + .../api/selectors/ImageHighlightSelector.rst | 45 + .../api/selectors/ImageVisibilitySelector.rst | 37 + .../api/selectors/LinearRegionSelector.rst | 1 + .../api/selectors/LinearRegionSelectors.rst | 57 + docs/source/api/selectors/LinearSelector.rst | 1 + docs/source/api/selectors/LinearSelectors.rst | 57 + .../source/api/selectors/PolygonSelectors.rst | 57 + .../selectors/PositionsHighlightSelector.rst | 42 + .../api/selectors/RectangleSelector.rst | 1 + .../api/selectors/RectangleSelectors.rst | 57 + docs/source/api/selectors/SelectionVector.rst | 35 + .../api/selectors/SelectorCollection.rst | 57 + .../api/selectors/VisibilitySelector.rst | 38 + docs/source/api/selectors/index.rst | 12 + docs/source/api/tools/HistogramLUTTool.rst | 3 +- docs/source/api/utils.rst | 4 - docs/source/api/widgets/NDWidget.rst | 35 + docs/source/api/widgets/index.rst | 1 + docs/source/generate_api.py | 48 +- docs/source/user_guide/event_tables.rst | 1345 +++++++++++++++-- fastplotlib/__init__.py | 4 +- fastplotlib/axes/__init__.py | 8 + fastplotlib/{graphics => axes}/_axes.py | 176 ++- fastplotlib/graphics/_base.py | 3 +- fastplotlib/graphics/utils.py | 81 +- fastplotlib/layouts/_subplot.py | 2 +- fastplotlib/utils/__init__.py | 1 - fastplotlib/utils/_plot_helpers.py | 82 - tests/test_plot_helpers.py | 2 +- 58 files changed, 2719 insertions(+), 217 deletions(-) create mode 100644 docs/source/api/axes/Axes.rst create mode 100644 docs/source/api/axes/Grid.rst create mode 100644 docs/source/api/axes/Grids.rst create mode 100644 docs/source/api/axes/Ruler.rst create mode 100644 docs/source/api/axes/index.rst create mode 100644 docs/source/api/graphic_features/TextureYUV.rst create mode 100644 docs/source/api/graphic_features/tuple.rst create mode 100644 docs/source/api/graphics/ImageYUVGraphic.rst create mode 100644 docs/source/api/graphics/ScatterCollection.rst create mode 100644 docs/source/api/graphics/ScatterStack.rst create mode 100644 docs/source/api/selectors/CollectionHighlightSelector.rst create mode 100644 docs/source/api/selectors/HighlightSelector.rst create mode 100644 docs/source/api/selectors/ImageHighlightSelector.rst create mode 100644 docs/source/api/selectors/ImageVisibilitySelector.rst create mode 100644 docs/source/api/selectors/LinearRegionSelectors.rst create mode 100644 docs/source/api/selectors/LinearSelectors.rst create mode 100644 docs/source/api/selectors/PolygonSelectors.rst create mode 100644 docs/source/api/selectors/PositionsHighlightSelector.rst create mode 100644 docs/source/api/selectors/RectangleSelectors.rst create mode 100644 docs/source/api/selectors/SelectionVector.rst create mode 100644 docs/source/api/selectors/SelectorCollection.rst create mode 100644 docs/source/api/selectors/VisibilitySelector.rst create mode 100644 docs/source/api/widgets/NDWidget.rst create mode 100644 fastplotlib/axes/__init__.py rename fastplotlib/{graphics => axes}/_axes.py (76%) delete mode 100644 fastplotlib/utils/_plot_helpers.py diff --git a/docs/source/api/axes/Axes.rst b/docs/source/api/axes/Axes.rst new file mode 100644 index 000000000..7a98ce384 --- /dev/null +++ b/docs/source/api/axes/Axes.rst @@ -0,0 +1,44 @@ +.. _api.Axes: + +Axes +**** + +==== +Axes +==== +.. currentmodule:: fastplotlib.axes + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Axes_api + + Axes + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Axes_api + + Axes.auto_grid + Axes.basis + Axes.color + Axes.colors + Axes.grids + Axes.intersection + Axes.offset + Axes.visible + Axes.world_object + Axes.x + Axes.y + Axes.z + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Axes_api + + Axes.update + Axes.update_using_bbox + Axes.update_using_camera + diff --git a/docs/source/api/axes/Grid.rst b/docs/source/api/axes/Grid.rst new file mode 100644 index 000000000..e40ecb907 --- /dev/null +++ b/docs/source/api/axes/Grid.rst @@ -0,0 +1,66 @@ +.. _api.Grid: + +Grid +**** + +==== +Grid +==== +.. currentmodule:: fastplotlib.axes + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Grid_api + + Grid + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Grid_api + + Grid.axis_color + Grid.axis_thickness + Grid.cast_shadow + Grid.children + Grid.geometry + Grid.id + Grid.infinite + Grid.major_color + Grid.major_step + Grid.major_thickness + Grid.material + Grid.minor_color + Grid.minor_step + Grid.minor_thickness + Grid.parent + Grid.receive_shadow + Grid.render_mask + Grid.render_order + Grid.thickness_space + Grid.up + Grid.visible + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Grid_api + + Grid.add + Grid.add_event_handler + Grid.clear + Grid.get_bounding_box + Grid.get_bounding_sphere + Grid.get_geometry_bounding_box + Grid.get_world_bounding_box + Grid.get_world_bounding_sphere + Grid.handle_event + Grid.iter + Grid.look_at + Grid.release_pointer_capture + Grid.remove + Grid.remove_event_handler + Grid.set_pointer_capture + Grid.traverse + diff --git a/docs/source/api/axes/Grids.rst b/docs/source/api/axes/Grids.rst new file mode 100644 index 000000000..d6af4d408 --- /dev/null +++ b/docs/source/api/axes/Grids.rst @@ -0,0 +1,59 @@ +.. _api.Grids: + +Grids +***** + +===== +Grids +===== +.. currentmodule:: fastplotlib.axes + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Grids_api + + Grids + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Grids_api + + Grids.cast_shadow + Grids.children + Grids.geometry + Grids.id + Grids.material + Grids.parent + Grids.receive_shadow + Grids.render_mask + Grids.render_order + Grids.up + Grids.visible + Grids.xy + Grids.xz + Grids.yz + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Grids_api + + Grids.add + Grids.add_event_handler + Grids.clear + Grids.get_bounding_box + Grids.get_bounding_sphere + Grids.get_geometry_bounding_box + Grids.get_world_bounding_box + Grids.get_world_bounding_sphere + Grids.handle_event + Grids.iter + Grids.look_at + Grids.release_pointer_capture + Grids.remove + Grids.remove_event_handler + Grids.set_pointer_capture + Grids.traverse + diff --git a/docs/source/api/axes/Ruler.rst b/docs/source/api/axes/Ruler.rst new file mode 100644 index 000000000..e0641b821 --- /dev/null +++ b/docs/source/api/axes/Ruler.rst @@ -0,0 +1,74 @@ +.. _api.Ruler: + +Ruler +***** + +===== +Ruler +===== +.. currentmodule:: fastplotlib.axes + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Ruler_api + + Ruler + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Ruler_api + + Ruler.cast_shadow + Ruler.children + Ruler.color + Ruler.end_pos + Ruler.end_value + Ruler.geometry + Ruler.id + Ruler.label + Ruler.line + Ruler.line_width + Ruler.material + Ruler.min_tick_distance + Ruler.parent + Ruler.points + Ruler.receive_shadow + Ruler.render_mask + Ruler.render_order + Ruler.start_pos + Ruler.start_value + Ruler.text + Ruler.tick_format + Ruler.tick_marker + Ruler.tick_side + Ruler.tick_size + Ruler.ticks + Ruler.ticks_at_end_points + Ruler.up + Ruler.visible + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Ruler_api + + Ruler.add + Ruler.add_event_handler + Ruler.clear + Ruler.get_bounding_box + Ruler.get_bounding_sphere + Ruler.get_geometry_bounding_box + Ruler.get_world_bounding_box + Ruler.get_world_bounding_sphere + Ruler.handle_event + Ruler.iter + Ruler.look_at + Ruler.release_pointer_capture + Ruler.remove + Ruler.remove_event_handler + Ruler.set_pointer_capture + Ruler.traverse + Ruler.update + diff --git a/docs/source/api/axes/index.rst b/docs/source/api/axes/index.rst new file mode 100644 index 000000000..92703eff6 --- /dev/null +++ b/docs/source/api/axes/index.rst @@ -0,0 +1,10 @@ +Axes +**** + +.. toctree:: + :maxdepth: 1 + + Grid + Grids + Ruler + Axes diff --git a/docs/source/api/graphic_features/TextureArray.rst b/docs/source/api/graphic_features/TextureArray.rst index 004881282..e57431ca7 100644 --- a/docs/source/api/graphic_features/TextureArray.rst +++ b/docs/source/api/graphic_features/TextureArray.rst @@ -22,7 +22,10 @@ Properties TextureArray.buffer TextureArray.col_indices + TextureArray.colorspace + TextureArray.cpu_buffer TextureArray.row_indices + TextureArray.shape TextureArray.value Methods diff --git a/docs/source/api/graphic_features/TextureYUV.rst b/docs/source/api/graphic_features/TextureYUV.rst new file mode 100644 index 000000000..485de904b --- /dev/null +++ b/docs/source/api/graphic_features/TextureYUV.rst @@ -0,0 +1,39 @@ +.. _api.TextureYUV: + +TextureYUV +********** + +========== +TextureYUV +========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextureYUV_api + + TextureYUV + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextureYUV_api + + TextureYUV.colorrange + TextureYUV.colorspace + TextureYUV.cpu_buffer + TextureYUV.texture + TextureYUV.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextureYUV_api + + TextureYUV.add_event_handler + TextureYUV.block_events + TextureYUV.clear_event_handlers + TextureYUV.remove_event_handler + TextureYUV.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index 71268ddab..db0b52103 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -22,6 +22,8 @@ Graphic Features VertexPointSizes UniformSize TextureArray + TextureYUV + tuple ImageCmap ImageVmin ImageVmax diff --git a/docs/source/api/graphic_features/tuple.rst b/docs/source/api/graphic_features/tuple.rst new file mode 100644 index 000000000..2c0c9c662 --- /dev/null +++ b/docs/source/api/graphic_features/tuple.rst @@ -0,0 +1,31 @@ +.. _api.tuple: + +tuple +***** + +===== +tuple +===== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: tuple_api + + tuple + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: tuple_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: tuple_api + + tuple.count + tuple.index + diff --git a/docs/source/api/graphics/Graphic.rst b/docs/source/api/graphics/Graphic.rst index f94892949..b2bf0ddd0 100644 --- a/docs/source/api/graphics/Graphic.rst +++ b/docs/source/api/graphics/Graphic.rst @@ -24,6 +24,7 @@ Properties Graphic.alpha_mode Graphic.axes Graphic.block_events + Graphic.block_handlers Graphic.deleted Graphic.event_handlers Graphic.name diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index e6d02c54b..b95b47907 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -24,8 +24,11 @@ Properties ImageGraphic.alpha_mode ImageGraphic.axes ImageGraphic.block_events + ImageGraphic.block_handlers ImageGraphic.cmap ImageGraphic.cmap_interpolation + ImageGraphic.colorspace + ImageGraphic.cpu_buffer ImageGraphic.data ImageGraphic.deleted ImageGraphic.event_handlers diff --git a/docs/source/api/graphics/ImageVolumeGraphic.rst b/docs/source/api/graphics/ImageVolumeGraphic.rst index 8031f12f1..c0465944d 100644 --- a/docs/source/api/graphics/ImageVolumeGraphic.rst +++ b/docs/source/api/graphics/ImageVolumeGraphic.rst @@ -24,6 +24,7 @@ Properties ImageVolumeGraphic.alpha_mode ImageVolumeGraphic.axes ImageVolumeGraphic.block_events + ImageVolumeGraphic.block_handlers ImageVolumeGraphic.cmap ImageVolumeGraphic.cmap_interpolation ImageVolumeGraphic.data diff --git a/docs/source/api/graphics/ImageYUVGraphic.rst b/docs/source/api/graphics/ImageYUVGraphic.rst new file mode 100644 index 000000000..54c7c3c1a --- /dev/null +++ b/docs/source/api/graphics/ImageYUVGraphic.rst @@ -0,0 +1,67 @@ +.. _api.ImageYUVGraphic: + +ImageYUVGraphic +*************** + +=============== +ImageYUVGraphic +=============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageYUVGraphic_api + + ImageYUVGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageYUVGraphic_api + + ImageYUVGraphic.alpha + ImageYUVGraphic.alpha_mode + ImageYUVGraphic.axes + ImageYUVGraphic.block_events + ImageYUVGraphic.block_handlers + ImageYUVGraphic.cmap + ImageYUVGraphic.cmap_interpolation + ImageYUVGraphic.colorrange + ImageYUVGraphic.colorspace + ImageYUVGraphic.cpu_buffer + ImageYUVGraphic.data + ImageYUVGraphic.deleted + ImageYUVGraphic.event_handlers + ImageYUVGraphic.interpolation + ImageYUVGraphic.name + ImageYUVGraphic.offset + ImageYUVGraphic.right_click_menu + ImageYUVGraphic.rotation + ImageYUVGraphic.scale + ImageYUVGraphic.supported_events + ImageYUVGraphic.tooltip_format + ImageYUVGraphic.visible + ImageYUVGraphic.vmax + ImageYUVGraphic.vmin + ImageYUVGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageYUVGraphic_api + + ImageYUVGraphic.add_axes + ImageYUVGraphic.add_event_handler + ImageYUVGraphic.add_linear_region_selector + ImageYUVGraphic.add_linear_selector + ImageYUVGraphic.add_polygon_selector + ImageYUVGraphic.add_rectangle_selector + ImageYUVGraphic.clear_event_handlers + ImageYUVGraphic.format_pick_info + ImageYUVGraphic.map_model_to_world + ImageYUVGraphic.map_world_to_model + ImageYUVGraphic.remove_event_handler + ImageYUVGraphic.reset_vmin_vmax + ImageYUVGraphic.rotate + diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index 5d0603ab7..de0a8330c 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -24,6 +24,7 @@ Properties LineCollection.alpha_mode LineCollection.axes LineCollection.block_events + LineCollection.block_handlers LineCollection.cmap LineCollection.colors LineCollection.data diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 867f1bfbb..834bce0a9 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -24,6 +24,7 @@ Properties LineGraphic.alpha_mode LineGraphic.axes LineGraphic.block_events + LineGraphic.block_handlers LineGraphic.cmap LineGraphic.color_mode LineGraphic.colors diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index e7ac21343..a922b9edc 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -24,6 +24,7 @@ Properties LineStack.alpha_mode LineStack.axes LineStack.block_events + LineStack.block_handlers LineStack.cmap LineStack.colors LineStack.data diff --git a/docs/source/api/graphics/MeshGraphic.rst b/docs/source/api/graphics/MeshGraphic.rst index ec27f1e4e..c2cf895e1 100644 --- a/docs/source/api/graphics/MeshGraphic.rst +++ b/docs/source/api/graphics/MeshGraphic.rst @@ -24,6 +24,7 @@ Properties MeshGraphic.alpha_mode MeshGraphic.axes MeshGraphic.block_events + MeshGraphic.block_handlers MeshGraphic.clim MeshGraphic.cmap MeshGraphic.colors diff --git a/docs/source/api/graphics/PolygonGraphic.rst b/docs/source/api/graphics/PolygonGraphic.rst index 94c75f999..c52031d67 100644 --- a/docs/source/api/graphics/PolygonGraphic.rst +++ b/docs/source/api/graphics/PolygonGraphic.rst @@ -24,6 +24,7 @@ Properties PolygonGraphic.alpha_mode PolygonGraphic.axes PolygonGraphic.block_events + PolygonGraphic.block_handlers PolygonGraphic.clim PolygonGraphic.cmap PolygonGraphic.colors diff --git a/docs/source/api/graphics/ScatterCollection.rst b/docs/source/api/graphics/ScatterCollection.rst new file mode 100644 index 000000000..92fa92a78 --- /dev/null +++ b/docs/source/api/graphics/ScatterCollection.rst @@ -0,0 +1,70 @@ +.. _api.ScatterCollection: + +ScatterCollection +***************** + +================= +ScatterCollection +================= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterCollection_api + + ScatterCollection + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterCollection_api + + ScatterCollection.alpha + ScatterCollection.alpha_mode + ScatterCollection.axes + ScatterCollection.block_events + ScatterCollection.block_handlers + ScatterCollection.cmap + ScatterCollection.colors + ScatterCollection.data + ScatterCollection.deleted + ScatterCollection.event_handlers + ScatterCollection.graphics + ScatterCollection.markers + ScatterCollection.metadatas + ScatterCollection.name + ScatterCollection.names + ScatterCollection.offset + ScatterCollection.offsets + ScatterCollection.right_click_menu + ScatterCollection.rotation + ScatterCollection.rotations + ScatterCollection.scale + ScatterCollection.sizes + ScatterCollection.supported_events + ScatterCollection.tooltip_format + ScatterCollection.visible + ScatterCollection.visibles + ScatterCollection.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ScatterCollection_api + + ScatterCollection.add_axes + ScatterCollection.add_event_handler + ScatterCollection.add_graphic + ScatterCollection.add_linear_region_selector + ScatterCollection.add_linear_selector + ScatterCollection.add_polygon_selector + ScatterCollection.add_rectangle_selector + ScatterCollection.clear_event_handlers + ScatterCollection.format_pick_info + ScatterCollection.map_model_to_world + ScatterCollection.map_world_to_model + ScatterCollection.remove_event_handler + ScatterCollection.remove_graphic + ScatterCollection.rotate + diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index f9dcd2487..0406fa8cc 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -24,6 +24,7 @@ Properties ScatterGraphic.alpha_mode ScatterGraphic.axes ScatterGraphic.block_events + ScatterGraphic.block_handlers ScatterGraphic.cmap ScatterGraphic.color_mode ScatterGraphic.colors diff --git a/docs/source/api/graphics/ScatterStack.rst b/docs/source/api/graphics/ScatterStack.rst new file mode 100644 index 000000000..22aaa4d5d --- /dev/null +++ b/docs/source/api/graphics/ScatterStack.rst @@ -0,0 +1,72 @@ +.. _api.ScatterStack: + +ScatterStack +************ + +============ +ScatterStack +============ +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterStack_api + + ScatterStack + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterStack_api + + ScatterStack.alpha + ScatterStack.alpha_mode + ScatterStack.axes + ScatterStack.block_events + ScatterStack.block_handlers + ScatterStack.cmap + ScatterStack.colors + ScatterStack.data + ScatterStack.deleted + ScatterStack.event_handlers + ScatterStack.graphics + ScatterStack.markers + ScatterStack.metadatas + ScatterStack.name + ScatterStack.names + ScatterStack.offset + ScatterStack.offsets + ScatterStack.right_click_menu + ScatterStack.rotation + ScatterStack.rotations + ScatterStack.scale + ScatterStack.separation + ScatterStack.separation_axis + ScatterStack.sizes + ScatterStack.supported_events + ScatterStack.tooltip_format + ScatterStack.visible + ScatterStack.visibles + ScatterStack.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ScatterStack_api + + ScatterStack.add_axes + ScatterStack.add_event_handler + ScatterStack.add_graphic + ScatterStack.add_linear_region_selector + ScatterStack.add_linear_selector + ScatterStack.add_polygon_selector + ScatterStack.add_rectangle_selector + ScatterStack.clear_event_handlers + ScatterStack.format_pick_info + ScatterStack.map_model_to_world + ScatterStack.map_world_to_model + ScatterStack.remove_event_handler + ScatterStack.remove_graphic + ScatterStack.rotate + diff --git a/docs/source/api/graphics/SurfaceGraphic.rst b/docs/source/api/graphics/SurfaceGraphic.rst index 228dbede1..2eb32500b 100644 --- a/docs/source/api/graphics/SurfaceGraphic.rst +++ b/docs/source/api/graphics/SurfaceGraphic.rst @@ -24,6 +24,7 @@ Properties SurfaceGraphic.alpha_mode SurfaceGraphic.axes SurfaceGraphic.block_events + SurfaceGraphic.block_handlers SurfaceGraphic.clim SurfaceGraphic.cmap SurfaceGraphic.colors diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst index da4909686..e4deb0113 100644 --- a/docs/source/api/graphics/TextGraphic.rst +++ b/docs/source/api/graphics/TextGraphic.rst @@ -24,6 +24,7 @@ Properties TextGraphic.alpha_mode TextGraphic.axes TextGraphic.block_events + TextGraphic.block_handlers TextGraphic.deleted TextGraphic.event_handlers TextGraphic.face_color diff --git a/docs/source/api/graphics/VectorsGraphic.rst b/docs/source/api/graphics/VectorsGraphic.rst index ec7d891c0..728029851 100644 --- a/docs/source/api/graphics/VectorsGraphic.rst +++ b/docs/source/api/graphics/VectorsGraphic.rst @@ -24,6 +24,7 @@ Properties VectorsGraphic.alpha_mode VectorsGraphic.axes VectorsGraphic.block_events + VectorsGraphic.block_handlers VectorsGraphic.deleted VectorsGraphic.directions VectorsGraphic.event_handlers diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index bac85e6c1..6253b68a7 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -8,6 +8,7 @@ Graphics LineGraphic ScatterGraphic ImageGraphic + ImageYUVGraphic ImageVolumeGraphic VectorsGraphic MeshGraphic @@ -16,3 +17,5 @@ Graphics TextGraphic LineCollection LineStack + ScatterCollection + ScatterStack diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst index 46e0c6ed3..fc3471afc 100644 --- a/docs/source/api/layouts/imgui_figure.rst +++ b/docs/source/api/layouts/imgui_figure.rst @@ -31,6 +31,7 @@ Properties ImguiFigure.names ImguiFigure.renderer ImguiFigure.shape + ImguiFigure.std_right_click_menu Methods ~~~~~~~ diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 0916859b9..994a252fd 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -42,6 +42,8 @@ Properties Subplot.toolbar Subplot.tooltip Subplot.viewport + Subplot.x_range + Subplot.y_range Methods ~~~~~~~ @@ -52,12 +54,15 @@ Methods Subplot.add_graphic Subplot.add_image Subplot.add_image_volume + Subplot.add_image_yuv Subplot.add_line Subplot.add_line_collection Subplot.add_line_stack Subplot.add_mesh Subplot.add_polygon Subplot.add_scatter + Subplot.add_scatter_collection + Subplot.add_scatter_stack Subplot.add_surface Subplot.add_text Subplot.add_vectors diff --git a/docs/source/api/selectors/CollectionHighlightSelector.rst b/docs/source/api/selectors/CollectionHighlightSelector.rst new file mode 100644 index 000000000..4ebd29fce --- /dev/null +++ b/docs/source/api/selectors/CollectionHighlightSelector.rst @@ -0,0 +1,42 @@ +.. _api.CollectionHighlightSelector: + +CollectionHighlightSelector +*************************** + +=========================== +CollectionHighlightSelector +=========================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: CollectionHighlightSelector_api + + CollectionHighlightSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: CollectionHighlightSelector_api + + CollectionHighlightSelector.alpha + CollectionHighlightSelector.color + CollectionHighlightSelector.graphics + CollectionHighlightSelector.lut + CollectionHighlightSelector.lut_wrap + CollectionHighlightSelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: CollectionHighlightSelector_api + + CollectionHighlightSelector.add_event_handler + CollectionHighlightSelector.add_graphic + CollectionHighlightSelector.append + CollectionHighlightSelector.clear + CollectionHighlightSelector.remove + CollectionHighlightSelector.remove_event_handler + CollectionHighlightSelector.remove_graphic + diff --git a/docs/source/api/selectors/HighlightSelector.rst b/docs/source/api/selectors/HighlightSelector.rst new file mode 100644 index 000000000..82b09e86c --- /dev/null +++ b/docs/source/api/selectors/HighlightSelector.rst @@ -0,0 +1,42 @@ +.. _api.HighlightSelector: + +HighlightSelector +***************** + +================= +HighlightSelector +================= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: HighlightSelector_api + + HighlightSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: HighlightSelector_api + + HighlightSelector.alpha + HighlightSelector.color + HighlightSelector.graphics + HighlightSelector.lut + HighlightSelector.lut_wrap + HighlightSelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: HighlightSelector_api + + HighlightSelector.add_event_handler + HighlightSelector.add_graphic + HighlightSelector.append + HighlightSelector.clear + HighlightSelector.remove + HighlightSelector.remove_event_handler + HighlightSelector.remove_graphic + diff --git a/docs/source/api/selectors/ImageHighlightSelector.rst b/docs/source/api/selectors/ImageHighlightSelector.rst new file mode 100644 index 000000000..2c2a0a23e --- /dev/null +++ b/docs/source/api/selectors/ImageHighlightSelector.rst @@ -0,0 +1,45 @@ +.. _api.ImageHighlightSelector: + +ImageHighlightSelector +********************** + +====================== +ImageHighlightSelector +====================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageHighlightSelector_api + + ImageHighlightSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageHighlightSelector_api + + ImageHighlightSelector.alpha + ImageHighlightSelector.color + ImageHighlightSelector.graphics + ImageHighlightSelector.lut + ImageHighlightSelector.lut_wrap + ImageHighlightSelector.options_alpha + ImageHighlightSelector.options_color + ImageHighlightSelector.selection + ImageHighlightSelector.selection_options + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageHighlightSelector_api + + ImageHighlightSelector.add_event_handler + ImageHighlightSelector.add_graphic + ImageHighlightSelector.append + ImageHighlightSelector.clear + ImageHighlightSelector.remove + ImageHighlightSelector.remove_event_handler + ImageHighlightSelector.remove_graphic + diff --git a/docs/source/api/selectors/ImageVisibilitySelector.rst b/docs/source/api/selectors/ImageVisibilitySelector.rst new file mode 100644 index 000000000..89f59a701 --- /dev/null +++ b/docs/source/api/selectors/ImageVisibilitySelector.rst @@ -0,0 +1,37 @@ +.. _api.ImageVisibilitySelector: + +ImageVisibilitySelector +*********************** + +======================= +ImageVisibilitySelector +======================= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVisibilitySelector_api + + ImageVisibilitySelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVisibilitySelector_api + + ImageVisibilitySelector.axis + ImageVisibilitySelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageVisibilitySelector_api + + ImageVisibilitySelector.add_event_handler + ImageVisibilitySelector.append + ImageVisibilitySelector.clear + ImageVisibilitySelector.pop + ImageVisibilitySelector.remove + ImageVisibilitySelector.remove_event_handler + diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index eb48497cd..07baa200f 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -25,6 +25,7 @@ Properties LinearRegionSelector.axes LinearRegionSelector.axis LinearRegionSelector.block_events + LinearRegionSelector.block_handlers LinearRegionSelector.deleted LinearRegionSelector.edge_color LinearRegionSelector.event_handlers diff --git a/docs/source/api/selectors/LinearRegionSelectors.rst b/docs/source/api/selectors/LinearRegionSelectors.rst new file mode 100644 index 000000000..64e5675d4 --- /dev/null +++ b/docs/source/api/selectors/LinearRegionSelectors.rst @@ -0,0 +1,57 @@ +.. _api.LinearRegionSelectors: + +LinearRegionSelectors +********************* + +===================== +LinearRegionSelectors +===================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelectors_api + + LinearRegionSelectors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelectors_api + + LinearRegionSelectors.alpha + LinearRegionSelectors.alpha_mode + LinearRegionSelectors.axes + LinearRegionSelectors.block_events + LinearRegionSelectors.block_handlers + LinearRegionSelectors.deleted + LinearRegionSelectors.event_handlers + LinearRegionSelectors.name + LinearRegionSelectors.offset + LinearRegionSelectors.right_click_menu + LinearRegionSelectors.rotation + LinearRegionSelectors.scale + LinearRegionSelectors.selection + LinearRegionSelectors.supported_events + LinearRegionSelectors.tooltip_format + LinearRegionSelectors.visible + LinearRegionSelectors.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelectors_api + + LinearRegionSelectors.add_axes + LinearRegionSelectors.add_event_handler + LinearRegionSelectors.append + LinearRegionSelectors.clear + LinearRegionSelectors.clear_event_handlers + LinearRegionSelectors.format_pick_info + LinearRegionSelectors.map_model_to_world + LinearRegionSelectors.map_world_to_model + LinearRegionSelectors.remove + LinearRegionSelectors.remove_event_handler + LinearRegionSelectors.rotate + diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index 2aa334748..e0e98bc13 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -25,6 +25,7 @@ Properties LinearSelector.axes LinearSelector.axis LinearSelector.block_events + LinearSelector.block_handlers LinearSelector.deleted LinearSelector.edge_color LinearSelector.event_handlers diff --git a/docs/source/api/selectors/LinearSelectors.rst b/docs/source/api/selectors/LinearSelectors.rst new file mode 100644 index 000000000..87204d070 --- /dev/null +++ b/docs/source/api/selectors/LinearSelectors.rst @@ -0,0 +1,57 @@ +.. _api.LinearSelectors: + +LinearSelectors +*************** + +=============== +LinearSelectors +=============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LinearSelectors_api + + LinearSelectors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LinearSelectors_api + + LinearSelectors.alpha + LinearSelectors.alpha_mode + LinearSelectors.axes + LinearSelectors.block_events + LinearSelectors.block_handlers + LinearSelectors.deleted + LinearSelectors.event_handlers + LinearSelectors.name + LinearSelectors.offset + LinearSelectors.right_click_menu + LinearSelectors.rotation + LinearSelectors.scale + LinearSelectors.selection + LinearSelectors.supported_events + LinearSelectors.tooltip_format + LinearSelectors.visible + LinearSelectors.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LinearSelectors_api + + LinearSelectors.add_axes + LinearSelectors.add_event_handler + LinearSelectors.append + LinearSelectors.clear + LinearSelectors.clear_event_handlers + LinearSelectors.format_pick_info + LinearSelectors.map_model_to_world + LinearSelectors.map_world_to_model + LinearSelectors.remove + LinearSelectors.remove_event_handler + LinearSelectors.rotate + diff --git a/docs/source/api/selectors/PolygonSelectors.rst b/docs/source/api/selectors/PolygonSelectors.rst new file mode 100644 index 000000000..b670e8bfd --- /dev/null +++ b/docs/source/api/selectors/PolygonSelectors.rst @@ -0,0 +1,57 @@ +.. _api.PolygonSelectors: + +PolygonSelectors +**************** + +================ +PolygonSelectors +================ +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: PolygonSelectors_api + + PolygonSelectors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: PolygonSelectors_api + + PolygonSelectors.alpha + PolygonSelectors.alpha_mode + PolygonSelectors.axes + PolygonSelectors.block_events + PolygonSelectors.block_handlers + PolygonSelectors.deleted + PolygonSelectors.event_handlers + PolygonSelectors.name + PolygonSelectors.offset + PolygonSelectors.right_click_menu + PolygonSelectors.rotation + PolygonSelectors.scale + PolygonSelectors.selection + PolygonSelectors.supported_events + PolygonSelectors.tooltip_format + PolygonSelectors.visible + PolygonSelectors.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: PolygonSelectors_api + + PolygonSelectors.add_axes + PolygonSelectors.add_event_handler + PolygonSelectors.append + PolygonSelectors.clear + PolygonSelectors.clear_event_handlers + PolygonSelectors.format_pick_info + PolygonSelectors.map_model_to_world + PolygonSelectors.map_world_to_model + PolygonSelectors.remove + PolygonSelectors.remove_event_handler + PolygonSelectors.rotate + diff --git a/docs/source/api/selectors/PositionsHighlightSelector.rst b/docs/source/api/selectors/PositionsHighlightSelector.rst new file mode 100644 index 000000000..6c2722b60 --- /dev/null +++ b/docs/source/api/selectors/PositionsHighlightSelector.rst @@ -0,0 +1,42 @@ +.. _api.PositionsHighlightSelector: + +PositionsHighlightSelector +************************** + +========================== +PositionsHighlightSelector +========================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: PositionsHighlightSelector_api + + PositionsHighlightSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: PositionsHighlightSelector_api + + PositionsHighlightSelector.alpha + PositionsHighlightSelector.color + PositionsHighlightSelector.graphics + PositionsHighlightSelector.lut + PositionsHighlightSelector.lut_wrap + PositionsHighlightSelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: PositionsHighlightSelector_api + + PositionsHighlightSelector.add_event_handler + PositionsHighlightSelector.add_graphic + PositionsHighlightSelector.append + PositionsHighlightSelector.clear + PositionsHighlightSelector.remove + PositionsHighlightSelector.remove_event_handler + PositionsHighlightSelector.remove_graphic + diff --git a/docs/source/api/selectors/RectangleSelector.rst b/docs/source/api/selectors/RectangleSelector.rst index 51f6801a4..a9a8d9fd5 100644 --- a/docs/source/api/selectors/RectangleSelector.rst +++ b/docs/source/api/selectors/RectangleSelector.rst @@ -25,6 +25,7 @@ Properties RectangleSelector.axes RectangleSelector.axis RectangleSelector.block_events + RectangleSelector.block_handlers RectangleSelector.deleted RectangleSelector.edge_color RectangleSelector.event_handlers diff --git a/docs/source/api/selectors/RectangleSelectors.rst b/docs/source/api/selectors/RectangleSelectors.rst new file mode 100644 index 000000000..ae9d562c3 --- /dev/null +++ b/docs/source/api/selectors/RectangleSelectors.rst @@ -0,0 +1,57 @@ +.. _api.RectangleSelectors: + +RectangleSelectors +****************** + +================== +RectangleSelectors +================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectors_api + + RectangleSelectors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectors_api + + RectangleSelectors.alpha + RectangleSelectors.alpha_mode + RectangleSelectors.axes + RectangleSelectors.block_events + RectangleSelectors.block_handlers + RectangleSelectors.deleted + RectangleSelectors.event_handlers + RectangleSelectors.name + RectangleSelectors.offset + RectangleSelectors.right_click_menu + RectangleSelectors.rotation + RectangleSelectors.scale + RectangleSelectors.selection + RectangleSelectors.supported_events + RectangleSelectors.tooltip_format + RectangleSelectors.visible + RectangleSelectors.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectors_api + + RectangleSelectors.add_axes + RectangleSelectors.add_event_handler + RectangleSelectors.append + RectangleSelectors.clear + RectangleSelectors.clear_event_handlers + RectangleSelectors.format_pick_info + RectangleSelectors.map_model_to_world + RectangleSelectors.map_world_to_model + RectangleSelectors.remove + RectangleSelectors.remove_event_handler + RectangleSelectors.rotate + diff --git a/docs/source/api/selectors/SelectionVector.rst b/docs/source/api/selectors/SelectionVector.rst new file mode 100644 index 000000000..10acf180e --- /dev/null +++ b/docs/source/api/selectors/SelectionVector.rst @@ -0,0 +1,35 @@ +.. _api.SelectionVector: + +SelectionVector +*************** + +=============== +SelectionVector +=============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: SelectionVector_api + + SelectionVector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: SelectionVector_api + + SelectionVector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: SelectionVector_api + + SelectionVector.add_selector + SelectionVector.append + SelectionVector.clear + SelectionVector.clear_selectables + SelectionVector.remove + diff --git a/docs/source/api/selectors/SelectorCollection.rst b/docs/source/api/selectors/SelectorCollection.rst new file mode 100644 index 000000000..9b4d24929 --- /dev/null +++ b/docs/source/api/selectors/SelectorCollection.rst @@ -0,0 +1,57 @@ +.. _api.SelectorCollection: + +SelectorCollection +****************** + +================== +SelectorCollection +================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: SelectorCollection_api + + SelectorCollection + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: SelectorCollection_api + + SelectorCollection.alpha + SelectorCollection.alpha_mode + SelectorCollection.axes + SelectorCollection.block_events + SelectorCollection.block_handlers + SelectorCollection.deleted + SelectorCollection.event_handlers + SelectorCollection.name + SelectorCollection.offset + SelectorCollection.right_click_menu + SelectorCollection.rotation + SelectorCollection.scale + SelectorCollection.selection + SelectorCollection.supported_events + SelectorCollection.tooltip_format + SelectorCollection.visible + SelectorCollection.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: SelectorCollection_api + + SelectorCollection.add_axes + SelectorCollection.add_event_handler + SelectorCollection.append + SelectorCollection.clear + SelectorCollection.clear_event_handlers + SelectorCollection.format_pick_info + SelectorCollection.map_model_to_world + SelectorCollection.map_world_to_model + SelectorCollection.remove + SelectorCollection.remove_event_handler + SelectorCollection.rotate + diff --git a/docs/source/api/selectors/VisibilitySelector.rst b/docs/source/api/selectors/VisibilitySelector.rst new file mode 100644 index 000000000..2b03c5914 --- /dev/null +++ b/docs/source/api/selectors/VisibilitySelector.rst @@ -0,0 +1,38 @@ +.. _api.VisibilitySelector: + +VisibilitySelector +****************** + +================== +VisibilitySelector +================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VisibilitySelector_api + + VisibilitySelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VisibilitySelector_api + + VisibilitySelector.lut + VisibilitySelector.lut_wrap + VisibilitySelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VisibilitySelector_api + + VisibilitySelector.add_event_handler + VisibilitySelector.append + VisibilitySelector.clear + VisibilitySelector.pop + VisibilitySelector.remove + VisibilitySelector.remove_event_handler + diff --git a/docs/source/api/selectors/index.rst b/docs/source/api/selectors/index.rst index 4a0caf8af..b33c5216e 100644 --- a/docs/source/api/selectors/index.rst +++ b/docs/source/api/selectors/index.rst @@ -7,3 +7,15 @@ Selectors LinearSelector LinearRegionSelector RectangleSelector + HighlightSelector + PositionsHighlightSelector + CollectionHighlightSelector + ImageHighlightSelector + VisibilitySelector + ImageVisibilitySelector + SelectorCollection + LinearSelectors + LinearRegionSelectors + RectangleSelectors + PolygonSelectors + SelectionVector diff --git a/docs/source/api/tools/HistogramLUTTool.rst b/docs/source/api/tools/HistogramLUTTool.rst index b3498dd68..d22ca3900 100644 --- a/docs/source/api/tools/HistogramLUTTool.rst +++ b/docs/source/api/tools/HistogramLUTTool.rst @@ -24,9 +24,11 @@ Properties HistogramLUTTool.alpha_mode HistogramLUTTool.axes HistogramLUTTool.block_events + HistogramLUTTool.block_handlers HistogramLUTTool.cmap HistogramLUTTool.deleted HistogramLUTTool.event_handlers + HistogramLUTTool.histogram HistogramLUTTool.images HistogramLUTTool.name HistogramLUTTool.offset @@ -53,5 +55,4 @@ Methods HistogramLUTTool.map_world_to_model HistogramLUTTool.remove_event_handler HistogramLUTTool.rotate - HistogramLUTTool.set_data diff --git a/docs/source/api/utils.rst b/docs/source/api/utils.rst index be7b1a049..6222e22c6 100644 --- a/docs/source/api/utils.rst +++ b/docs/source/api/utils.rst @@ -4,7 +4,3 @@ fastplotlib.utils .. currentmodule:: fastplotlib.utils .. automodule:: fastplotlib.utils.functions :members: - -.. currentmodule:: fastplotlib.utils -.. automodule:: fastplotlib.utils._plot_helpers - :members: diff --git a/docs/source/api/widgets/NDWidget.rst b/docs/source/api/widgets/NDWidget.rst new file mode 100644 index 000000000..7a09f3bbb --- /dev/null +++ b/docs/source/api/widgets/NDWidget.rst @@ -0,0 +1,35 @@ +.. _api.NDWidget: + +NDWidget +******** + +======== +NDWidget +======== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: NDWidget_api + + NDWidget + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: NDWidget_api + + NDWidget.figure + NDWidget.indices + NDWidget.ndgraphics + NDWidget.ranges + +Methods +~~~~~~~ +.. autosummary:: + :toctree: NDWidget_api + + NDWidget.close + NDWidget.show + diff --git a/docs/source/api/widgets/index.rst b/docs/source/api/widgets/index.rst index 5cb5299f6..c60b3c485 100644 --- a/docs/source/api/widgets/index.rst +++ b/docs/source/api/widgets/index.rst @@ -4,4 +4,5 @@ Widgets .. toctree:: :maxdepth: 1 + NDWidget ImageWidget diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py index 0be967a36..5ca237f57 100644 --- a/docs/source/generate_api.py +++ b/docs/source/generate_api.py @@ -9,6 +9,7 @@ from fastplotlib.layouts import Subplot from fastplotlib import graphics from fastplotlib.graphics import features, selectors +from fastplotlib import axes from fastplotlib import tools from fastplotlib import widgets from fastplotlib import utils @@ -22,6 +23,7 @@ GRAPHICS_DIR = API_DIR.joinpath("graphics") GRAPHIC_FEATURES_DIR = API_DIR.joinpath("graphic_features") SELECTORS_DIR = API_DIR.joinpath("selectors") +AXES_DIR = API_DIR.joinpath("axes") TOOLS_DIR = API_DIR.joinpath("tools") WIDGETS_DIR = API_DIR.joinpath("widgets") UI_DIR = API_DIR.joinpath("ui") @@ -33,6 +35,7 @@ GRAPHICS_DIR, GRAPHIC_FEATURES_DIR, SELECTORS_DIR, + AXES_DIR, TOOLS_DIR, WIDGETS_DIR, UI_DIR, @@ -370,6 +373,33 @@ def main(): source_path=TOOLS_DIR.joinpath(f"{tool_cls.__name__}.rst"), ) + ############################################################################## + # ** Aes classes ** # + axes_classes = [getattr(axes, obj) for obj in axes.__all__] + + axes_class_names = [a.__name__ for a in axes_classes] + + axes_class_names_str = "\n ".join([""] + axes_class_names) + + with open(AXES_DIR.joinpath("index.rst"), "w") as f: + f.write( + f"Axes\n" + f"****\n" + f"\n" + f".. toctree::\n" + f" :maxdepth: 1\n" + f"{axes_class_names_str}\n" + ) + + for axes_cls in axes_classes: + generate_page( + page_name=axes_cls.__name__, + classes=[axes_cls], + modules=["fastplotlib.axes"], + source_path=AXES_DIR.joinpath(f"{axes_cls.__name__}.rst"), + ) + + ############################################################################## # ** Widget classes ** # widget_classes = [getattr(widgets, w) for w in widgets.__all__] @@ -424,7 +454,6 @@ def main(): ############################################################################## utils_str = generate_functions_module(utils.functions, "fastplotlib.utils") - utils_str += generate_functions_module(utils._plot_helpers, "fastplotlib.utils", generate_header=False) with open(API_DIR.joinpath("utils.rst"), "w") as f: f.write(utils_str) @@ -475,14 +504,15 @@ def write_table(name, feature_cls): continue f.write(f"{graphic_cls.__name__}\n") f.write("-" * len(graphic_cls.__name__) + "\n\n") - for name, type_ in graphic_cls._features.items(): - if isinstance(type_, tuple): - for t in type_: - if t is None: - continue - f.write(write_table(name, t)) - else: - f.write(write_table(name, type_)) + if hasattr(graphic_cls, "_features"): # some selectors like Highlight etc. don't have "graphic features" + for name, type_ in graphic_cls._features.items(): + if isinstance(type_, tuple): + for t in type_: + if t is None: + continue + f.write(write_table(name, t)) + else: + f.write(write_table(name, type_)) if __name__ == "__main__": diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index 42f168bea..0342807e1 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -603,6 +603,143 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ +ImageYUVGraphic +--------------- + +data +^^^^ + +**event info dict** + ++----------+--------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+======================================+==================================================+ +| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | ++----------+--------------------------------------+--------------------------------------------------+ +| value | np.ndarray | float | new data values | ++----------+--------------------------------------+--------------------------------------------------+ + +vmin +^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new vmin value | ++----------+-------+----------------+ + +vmax +^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new vmax value | ++----------+-------+----------------+ + +interpolation +^^^^^^^^^^^^^ + +**event info dict** + ++----------+------+--------------------------------------------+ +| dict key | type | description | ++==========+======+============================================+ +| value | str | new interpolation method, nearest | linear | ++----------+------+--------------------------------------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + ImageVolumeGraphic ------------------ @@ -1860,93 +1997,978 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -LinearSelector --------------- +ScatterCollection +----------------- -selection -^^^^^^^^^ +data +^^^^ -**extra attributes** +**event info dict** -+--------------------+----------+----------------------------------+ -| attribute | type | description | -+====================+==========+==================================+ -| get_selected_index | callable | returns index under the selector | -+--------------------+----------+----------------------------------+ ++----------+----------------------------------------------+--------------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+========================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------------+ +| value | int | float | array-like | new data values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------------+ + +sizes +^^^^^ **event info dict** -+----------+-------+-------------------------------+ -| dict key | type | description | -+==========+=======+===============================+ -| value | float | new x or y value of selection | -+----------+-------+-------------------------------+ ++----------+----------------------------------------------+----------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==============================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced | ++----------+----------------------------------------------+----------------------------------------------+ +| value | int | float | array-like | new size values for points that were changed | ++----------+----------------------------------------------+----------------------------------------------+ -name -^^^^ +sizes +^^^^^ **event info dict** -+----------+------+--------------------+ -| dict key | type | description | -+==========+======+====================+ -| value | str | user provided name | -+----------+------+--------------------+ ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new size value | ++----------+-------+----------------+ -offset +colors ^^^^^^ **event info dict** -+----------+---------------------------------+----------------------+ -| dict key | type | description | -+==========+=================================+======================+ -| value | np.ndarray[float, float, float] | new offset (x, y, z) | -+----------+---------------------------------+----------------------+ ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ -rotation -^^^^^^^^ +colors +^^^^^^ **event info dict** -+----------+----------------------------------------+-------------------------+ -| dict key | type | description | -+==========+========================================+=========================+ -| value | np.ndarray[float, float, float, float] | new rotation quaternion | -+----------+----------------------------------------+-------------------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ -scale -^^^^^ +cmap +^^^^ **event info dict** -+----------+----------------------------------------+-------------+ -| dict key | type | description | -+==========+========================================+=============+ -| value | np.ndarray[float, float, float, float] | new scale | -+----------+----------------------------------------+-------------+ ++----------+-------+--------------------------------+ +| dict key | type | description | ++==========+=======+================================+ +| key | slice | key at cmap colors were sliced | ++----------+-------+--------------------------------+ +| value | str | new cmap to set at given slice | ++----------+-------+--------------------------------+ -alpha -^^^^^ +markers +^^^^^^^ **event info dict** -+----------+-------+-----------------+ -| dict key | type | description | -+==========+=======+=================+ -| value | float | new alpha value | -+----------+-------+-----------------+ ++----------+----------------------------------------------+------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which markers were indexed/sliced | ++----------+----------------------------------------------+------------------------------------------------+ +| value | str | np.ndarray[str] | new marker values for points that were changed | ++----------+----------------------------------------------+------------------------------------------------+ -alpha_mode -^^^^^^^^^^ +markers +^^^^^^^ **event info dict** -+----------+------+----------------+ -| dict key | type | description | -+==========+======+================+ -| value | str | new alpha mode | -+----------+------+----------------+ ++----------+------------+------------------+ +| dict key | type | description | ++==========+============+==================+ +| value | str | None | new marker value | ++----------+------------+------------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+----------------+ +| dict key | type | description | ++==========+==================================================+================+ +| value | str | np.ndarray | pygfx.Color | Sequence[float] | new edge_color | ++----------+--------------------------------------------------+----------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +edge_width +^^^^^^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new edge_width | ++----------+-------+----------------+ + +image +^^^^^ + +**event info dict** + ++----------+--------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+======================================+==================================================+ +| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | ++----------+--------------------------------------+--------------------------------------------------+ +| value | np.ndarray | float | new data values | ++----------+--------------------------------------+--------------------------------------------------+ + +size_space +^^^^^^^^^^ + +**event info dict** + ++----------+------+------------------------------+ +| dict key | type | description | ++==========+======+==============================+ +| value | str | 'screen' | 'world' | 'model' | ++----------+------+------------------------------+ + +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new edge_width | ++----------+-------+----------------+ + +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point rotations were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------+ +| value | int | float | array-like | new rotation values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +ScatterStack +------------ + +data +^^^^ + +**event info dict** + ++----------+----------------------------------------------+--------------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+========================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------------+ +| value | int | float | array-like | new data values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------------+ + +sizes +^^^^^ + +**event info dict** + ++----------+----------------------------------------------+----------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==============================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced | ++----------+----------------------------------------------+----------------------------------------------+ +| value | int | float | array-like | new size values for points that were changed | ++----------+----------------------------------------------+----------------------------------------------+ + +sizes +^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new size value | ++----------+-------+----------------+ + +colors +^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ + +cmap +^^^^ + +**event info dict** + ++----------+-------+--------------------------------+ +| dict key | type | description | ++==========+=======+================================+ +| key | slice | key at cmap colors were sliced | ++----------+-------+--------------------------------+ +| value | str | new cmap to set at given slice | ++----------+-------+--------------------------------+ + +markers +^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which markers were indexed/sliced | ++----------+----------------------------------------------+------------------------------------------------+ +| value | str | np.ndarray[str] | new marker values for points that were changed | ++----------+----------------------------------------------+------------------------------------------------+ + +markers +^^^^^^^ + +**event info dict** + ++----------+------------+------------------+ +| dict key | type | description | ++==========+============+==================+ +| value | str | None | new marker value | ++----------+------------+------------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+----------------+ +| dict key | type | description | ++==========+==================================================+================+ +| value | str | np.ndarray | pygfx.Color | Sequence[float] | new edge_color | ++----------+--------------------------------------------------+----------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +edge_width +^^^^^^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new edge_width | ++----------+-------+----------------+ + +image +^^^^^ + +**event info dict** + ++----------+--------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+======================================+==================================================+ +| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | ++----------+--------------------------------------+--------------------------------------------------+ +| value | np.ndarray | float | new data values | ++----------+--------------------------------------+--------------------------------------------------+ + +size_space +^^^^^^^^^^ + +**event info dict** + ++----------+------+------------------------------+ +| dict key | type | description | ++==========+======+==============================+ +| value | str | 'screen' | 'world' | 'model' | ++----------+------+------------------------------+ + +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new edge_width | ++----------+-------+----------------+ + +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point rotations were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------+ +| value | int | float | array-like | new rotation values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +LinearSelector +-------------- + +selection +^^^^^^^^^ + +**extra attributes** + ++--------------------+----------+----------------------------------+ +| attribute | type | description | ++====================+==========+==================================+ +| get_selected_index | callable | returns index under the selector | ++--------------------+----------+----------------------------------+ + +**event info dict** + ++----------+-------+-------------------------------+ +| dict key | type | description | ++==========+=======+===============================+ +| value | float | new x or y value of selection | ++----------+-------+-------------------------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +LinearRegionSelector +-------------------- + +selection +^^^^^^^^^ + +**extra attributes** + ++----------------------+----------+------------------------------------+ +| attribute | type | description | ++======================+==========+====================================+ +| get_selected_indices | callable | returns indices under the selector | ++----------------------+----------+------------------------------------+ +| get_selected_data | callable | returns data under the selector | ++----------------------+----------+------------------------------------+ + +**event info dict** + ++----------+------------+-----------------------------+ +| dict key | type | description | ++==========+============+=============================+ +| value | np.ndarray | new [min, max] of selection | ++----------+------------+-----------------------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +RectangleSelector +----------------- + +selection +^^^^^^^^^ + +**extra attributes** + ++----------------------+----------+------------------------------------+ +| attribute | type | description | ++======================+==========+====================================+ +| get_selected_indices | callable | returns indices under the selector | ++----------------------+----------+------------------------------------+ +| get_selected_data | callable | returns data under the selector | ++----------------------+----------+------------------------------------+ + +**event info dict** + ++----------+------------+-------------------------------------------+ +| dict key | type | description | ++==========+============+===========================================+ +| value | np.ndarray | new [xmin, xmax, ymin, ymax] of selection | ++----------+------------+-------------------------------------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +HighlightSelector +----------------- + +PositionsHighlightSelector +-------------------------- + +CollectionHighlightSelector +--------------------------- + +ImageHighlightSelector +---------------------- + +VisibilitySelector +------------------ + +ImageVisibilitySelector +----------------------- + +SelectorCollection +------------------ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ visible ^^^^^^^ @@ -1970,29 +2992,99 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -LinearRegionSelector --------------------- +LinearSelectors +--------------- -selection -^^^^^^^^^ +name +^^^^ -**extra attributes** +**event info dict** -+----------------------+----------+------------------------------------+ -| attribute | type | description | -+======================+==========+====================================+ -| get_selected_indices | callable | returns indices under the selector | -+----------------------+----------+------------------------------------+ -| get_selected_data | callable | returns data under the selector | -+----------------------+----------+------------------------------------+ ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ **event info dict** -+----------+------------+-----------------------------+ -| dict key | type | description | -+==========+============+=============================+ -| value | np.ndarray | new [min, max] of selection | -+----------+------------+-----------------------------+ ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +LinearRegionSelectors +--------------------- name ^^^^ @@ -2082,29 +3174,99 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -RectangleSelector ------------------ +RectangleSelectors +------------------ -selection -^^^^^^^^^ +name +^^^^ -**extra attributes** +**event info dict** -+----------------------+----------+------------------------------------+ -| attribute | type | description | -+======================+==========+====================================+ -| get_selected_indices | callable | returns indices under the selector | -+----------------------+----------+------------------------------------+ -| get_selected_data | callable | returns data under the selector | -+----------------------+----------+------------------------------------+ ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ **event info dict** -+----------+------------+-------------------------------------------+ -| dict key | type | description | -+==========+============+===========================================+ -| value | np.ndarray | new [xmin, xmax, ymin, ymax] of selection | -+----------+------------+-------------------------------------------+ ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +PolygonSelectors +---------------- name ^^^^ @@ -2194,3 +3356,6 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ +SelectionVector +--------------- + diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index c4626a041..00e31c977 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -1,5 +1,3 @@ -from pathlib import Path - from ._version import __version__, version_info # this must be the first import for auto-canvas detection @@ -15,7 +13,7 @@ from .graphics import * from .graphics.features import GraphicFeatureEvent from .graphics.selectors import * -from .graphics.utils import pause_events +from .graphics.utils import pause_events, get_nearest_graphics, get_nearest_graphics_indices from .legends import * from .tools import * diff --git a/fastplotlib/axes/__init__.py b/fastplotlib/axes/__init__.py new file mode 100644 index 000000000..bf9f72e04 --- /dev/null +++ b/fastplotlib/axes/__init__.py @@ -0,0 +1,8 @@ +from ._axes import Grid, Grids, Ruler, Axes + +__all__ = [ + "Grid", + "Grids", + "Ruler", + "Axes", +] diff --git a/fastplotlib/graphics/_axes.py b/fastplotlib/axes/_axes.py similarity index 76% rename from fastplotlib/graphics/_axes.py rename to fastplotlib/axes/_axes.py index 56ca792a4..dfd488f86 100644 --- a/fastplotlib/graphics/_axes.py +++ b/fastplotlib/axes/_axes.py @@ -1,3 +1,5 @@ +import math + import numpy as np import pygfx @@ -5,7 +7,6 @@ from ..utils.enums import RenderQueue - GRID_PLANES = ["xy", "xz", "yz"] CANONICAL_BAIS = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) @@ -143,6 +144,110 @@ def yz(self) -> Grid: return self._yz +class Ruler(pygfx.Ruler): + """pygfx.Ruler subclass that adds a rotated axis label.""" + + def __init__(self, *, color="#fff", alpha_mode=None, render_queue=None, **kwargs): + super().__init__( + color=color, alpha_mode=alpha_mode, render_queue=render_queue, **kwargs + ) + self._label = pygfx.Text( + screen_space=True, + anchor="middle-center", + font_size=16, + material=pygfx.TextMaterial( + color=color, + alpha_mode="auto", + render_queue=RenderQueue.overlay + 50, + aa=True, + ), + ) + self._label.visible = False + self.add(self._label) + + @property + def label(self) -> pygfx.Text: + """Axis label. Set text via ``label.set_text('label text')``""" + return self._label + + @property + def color(self): + return self._text.material.color + + @color.setter + def color(self, color): + self._text.material.color = color + self._line.material.color = color + self._points.material.edge_color = color + self._label.material.color = color + + def update(self, camera, canvas_size): + stats = super().update(camera, canvas_size) + self._update_label() + return stats + + def _update_label(self): + # update the label position + t1, t2 = self._visible_part_coords + if t1 == t2: + self._label.visible = False + return + self._label.visible = True + + mid_t = 0.5 * (t1 + t2) + mid_pos = self._start_pos * (1 - mid_t) + self._end_pos * mid_t + + world_vec = self._end_pos - self._start_pos + world_len = np.linalg.norm(world_vec) + screen_len = np.linalg.norm(self._screen_vec) + + if world_len > 0 and screen_len > 0: + world_dir = world_vec / world_len + # perpendicular in the xy plane: CCW = "left", CW = "right" + if self.tick_side == "left": + perp_world = np.array([-world_dir[1], world_dir[0], 0.0]) + else: + perp_world = np.array([world_dir[1], -world_dir[0], 0.0]) + + # same perpendicular in screen space, for projecting tick label rects + screen_dir = self._screen_vec / screen_len + if self.tick_side == "left": + px, py = -screen_dir[1], screen_dir[0] + else: + px, py = screen_dir[1], -screen_dir[0] + + # max extent of tick labels in the perpendicular direction. + # tick labels are unrotated screen-space text, so we project their + # axis-aligned _rect onto (px, py) directly. + visible_blocks = [ + b + for b in self.text._text_blocks + if b._rect.width > 0 or b._rect.height > 0 + ] + if visible_blocks: + tick_extent_px = max( + max(px, 0) * b._rect.right + + min(px, 0) * b._rect.left + + max(py, 0) * b._rect.top + + min(py, 0) * b._rect.bottom + for b in visible_blocks + ) + else: + tick_extent_px = 0.0 + + offset_px = max(tick_extent_px, 0.0) + self._label.font_size + mid_pos = mid_pos + (offset_px / (screen_len / world_len)) * perp_world + + self._label.local.position = mid_pos + + vec = self._visible_part_screen_vec + angle = math.atan2(vec[1], vec[0]) + # pylinalg uses [x, y, z, w] quaternion format + self._label.local.rotation = np.array( + [0.0, 0.0, math.sin(angle / 2), math.cos(angle / 2)] + ) + + class Axes: def __init__( self, @@ -191,15 +296,9 @@ def __init__( ) # create ruler for each dim - self._x = pygfx.Ruler( - alpha_mode="solid", render_queue=RenderQueue.axes, **x_kwargs - ) - self._y = pygfx.Ruler( - alpha_mode="solid", render_queue=RenderQueue.axes, **y_kwargs - ) - self._z = pygfx.Ruler( - alpha_mode="solid", render_queue=RenderQueue.axes, **z_kwargs - ) + self._x = Ruler(alpha_mode="solid", render_queue=RenderQueue.axes, **x_kwargs) + self._y = Ruler(alpha_mode="solid", render_queue=RenderQueue.axes, **y_kwargs) + self._z = Ruler(alpha_mode="solid", render_queue=RenderQueue.axes, **z_kwargs) # We render the lines and ticks as solid, but enable aa for text for prettier glyphs for ruler in self._x, self._y, self._z: @@ -208,6 +307,7 @@ def __init__( ruler.text.material.depth_compare = "<=" ruler.text.material.alpha_mode = "auto" ruler.text.material.aa = True + ruler.label.material.depth_compare = "<=" self._offset = offset @@ -319,7 +419,7 @@ def basis(self, basis: np.ndarray): # apply quaternion to each of x, y, z rulers for dim, cbasis, new_basis in zip(["x", "y", "z"], CANONICAL_BAIS, basis): - ruler: pygfx.Ruler = getattr(self, dim) + ruler: Ruler = getattr(self, dim) ruler.local.rotation = quat_from_vecs(cbasis, new_basis) @property @@ -332,17 +432,17 @@ def offset(self, value: np.ndarray): self._offset = value @property - def x(self) -> pygfx.Ruler: + def x(self) -> Ruler: """x axis ruler""" return self._x @property - def y(self) -> pygfx.Ruler: + def y(self) -> Ruler: """y axis ruler""" return self._y @property - def z(self) -> pygfx.Ruler: + def z(self) -> Ruler: """z axis ruler""" return self._z @@ -364,6 +464,16 @@ def colors(self, colors: tuple[pygfx.Color | str]): for dim, color in zip(["x", "y", "z"], colors): getattr(self, dim).line.material.color = color + @property + def color(self) -> pygfx.Color: + """get or set a single color for all rulers""" + return self._x.color + + @color.setter + def color(self, color: pygfx.Color | str): + for ruler in (self._x, self._y, self._z): + ruler.color = color + @property def auto_grid(self) -> bool: """auto adjust the grid on each render cycle""" @@ -390,6 +500,7 @@ def intersection(self) -> tuple[float, float, float] | None: def intersection(self, intersection: tuple[float, float, float] | None): """ intersection point of [x, y, z] rulers. + Set (0, 0, 0) for origin Set to `None` to follow when panning through the scene with orthographic projection """ @@ -411,10 +522,9 @@ def _get_view_state(self) -> tuple[bytes, tuple[int, int], tuple[int, int], byte return (cam_matrix, viewport.rect, viewport.logical_size, scale) - def update_using_bbox(self, bbox): """ - Update the w.r.t. the given bbox + Update the axes w.r.t. the given bbox Parameters ---------- @@ -440,6 +550,33 @@ def update_using_bbox(self, bbox): self.update(bbox, intersection) + def _auto_intersection_pos(self, xpos, ypos, width, height): + # returns the intersection position for the axis so they are placed in the bottom left corner + margin = 4 + + y_blocks = [b for b in self.y.text._text_blocks if b._rect.width > 0] + y_extent = ( + max(abs(b._rect.left) for b in y_blocks) + if y_blocks + else 6 * self.y.text.font_size + ) + if self.y._label._text_blocks: + # label center is tick_extent + font_size from ruler; body adds font_size/2 more + y_extent += 1.5 * self.y._label.font_size + + x_blocks = [b for b in self.x.text._text_blocks if b._rect.height > 0] + x_extent = ( + max(abs(b._rect.bottom) for b in x_blocks) + if x_blocks + else 1.5 * self.x.text.font_size + ) + if self.x._label._text_blocks: + x_extent += 1.5 * self.x._label.font_size + + return self._plot_area.map_screen_to_world( + (xpos + y_extent + margin, ypos + height - x_extent - margin) + ) + def update_using_camera(self): """ Update the axes w.r.t the current camera state @@ -491,12 +628,7 @@ def update_using_camera(self): if self.intersection is None: if self._plot_area.camera.fov == 0: - # place the ruler close to the left and bottom edges of the viewport - # TODO: determine this for perspective projections - xscreen_10, yscreen_10 = xpos + (width * 0.1), ypos + (height * 0.9) - intersection = self._plot_area.map_screen_to_world( - (xscreen_10, yscreen_10) - ) + intersection = self._auto_intersection_pos(xpos, ypos, width, height) else: # force origin since None is not supported for Persepctive projections self._intersection = (0, 0, 0) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index edccf2e8d..95a941f8b 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -18,7 +18,6 @@ import pygfx from .features import ( - BufferManager, Deleted, Name, Offset, @@ -28,7 +27,7 @@ AlphaMode, Visible, ) -from ._axes import Axes +from ..axes import Axes HexStr: TypeAlias = str WorldObjectID: TypeAlias = int diff --git a/fastplotlib/graphics/utils.py b/fastplotlib/graphics/utils.py index f32d80809..0fc1aa088 100644 --- a/fastplotlib/graphics/utils.py +++ b/fastplotlib/graphics/utils.py @@ -1,6 +1,9 @@ from contextlib import contextmanager -from typing import Callable, Iterable +from typing import Callable, Iterable, Sequence +import numpy as np + +from ._collection_base import GraphicCollection from ._base import Graphic @@ -44,3 +47,79 @@ def pause_events(*graphics: Graphic, event_handlers: Iterable[Callable] = None): g.block_handlers.clear() else: g.block_events = value + + +def get_nearest_graphics_indices( + pos: tuple[float, float] | tuple[float, float, float], + graphics: Sequence[Graphic] | GraphicCollection, +) -> np.ndarray[int]: + """ + Returns indices of the nearest ``graphics`` to the passed position ``pos`` in world space + in order of closest to furtherst. Uses the distance between ``pos`` and the center of the + bounding sphere for each graphic. + + Parameters + ---------- + pos: (x, y) | (x, y, z) + position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D + + graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection + the graphics from which to return a sorted array of graphics in order of closest + to furthest graphic + + Returns + ------- + ndarray[int] + indices of the nearest nearest graphics to ``pos`` in order + + """ + if isinstance(graphics, GraphicCollection): + graphics = graphics.graphics + + if not all(isinstance(g, Graphic) for g in graphics): + raise TypeError("all elements of `graphics` must be Graphic objects") + + pos = np.asarray(pos).ravel() + + if pos.shape != (2,) and pos.shape != (3,): + raise TypeError( + f"pos.shape must be (2,) or (3,), the shape of pos you have passed is: {pos.shape}" + ) + + # get centers + centers = np.empty(shape=(len(graphics), len(pos))) + for i in range(centers.shape[0]): + centers[i] = graphics[i].world_object.get_world_bounding_sphere()[: len(pos)] + + # l2 + distances = np.linalg.norm(centers[:, : len(pos)] - pos, ord=2, axis=1) + + sort_indices = np.argsort(distances) + return sort_indices + + +def get_nearest_graphics( + pos: tuple[float, float] | tuple[float, float, float], + graphics: Sequence[Graphic] | GraphicCollection, +) -> np.ndarray[Graphic]: + """ + Returns the nearest ``graphics`` to the passed position ``pos`` in world space. + Uses the distance between ``pos`` and the center of the bounding sphere for each graphic. + + Parameters + ---------- + pos: (x, y) | (x, y, z) + position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D + + graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection + the graphics from which to return a sorted array of graphics in order of closest + to furthest graphic + + Returns + ------- + ndarray[Graphic] + nearest graphics to ``pos`` in order + + """ + sort_indices = get_nearest_graphics_indices(pos, graphics) + return np.asarray(graphics)[sort_indices] diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 73f669fe5..f9534b683 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -9,7 +9,7 @@ from ._utils import create_camera, create_controller from ._plot_area import PlotArea from ._frame import Frame -from ..graphics._axes import Axes +from ..axes import Axes class Subplot(PlotArea): diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index cb6a240d1..f454c7930 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -5,7 +5,6 @@ from .enums import * from .functions import * from .gpu import enumerate_adapters, select_adapter, print_wgpu_report -from ._plot_helpers import * from .protocols import ARRAY_LIKE_ATTRS, ArrayProtocol, FutureProtocol, CudaArrayProtocol diff --git a/fastplotlib/utils/_plot_helpers.py b/fastplotlib/utils/_plot_helpers.py deleted file mode 100644 index 12afe1cb2..000000000 --- a/fastplotlib/utils/_plot_helpers.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import Sequence - -import numpy as np - -from ..graphics._base import Graphic -from ..graphics._collection_base import GraphicCollection - - -def get_nearest_graphics_indices( - pos: tuple[float, float] | tuple[float, float, float], - graphics: Sequence[Graphic] | GraphicCollection, -) -> np.ndarray[int]: - """ - Returns indices of the nearest ``graphics`` to the passed position ``pos`` in world space - in order of closest to furtherst. Uses the distance between ``pos`` and the center of the - bounding sphere for each graphic. - - Parameters - ---------- - pos: (x, y) | (x, y, z) - position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D - - graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection - the graphics from which to return a sorted array of graphics in order of closest - to furthest graphic - - Returns - ------- - ndarray[int] - indices of the nearest nearest graphics to ``pos`` in order - - """ - if isinstance(graphics, GraphicCollection): - graphics = graphics.graphics - - if not all(isinstance(g, Graphic) for g in graphics): - raise TypeError("all elements of `graphics` must be Graphic objects") - - pos = np.asarray(pos).ravel() - - if pos.shape != (2,) and pos.shape != (3,): - raise TypeError( - f"pos.shape must be (2,) or (3,), the shape of pos you have passed is: {pos.shape}" - ) - - # get centers - centers = np.empty(shape=(len(graphics), len(pos))) - for i in range(centers.shape[0]): - centers[i] = graphics[i].world_object.get_world_bounding_sphere()[: len(pos)] - - # l2 - distances = np.linalg.norm(centers[:, : len(pos)] - pos, ord=2, axis=1) - - sort_indices = np.argsort(distances) - return sort_indices - - -def get_nearest_graphics( - pos: tuple[float, float] | tuple[float, float, float], - graphics: Sequence[Graphic] | GraphicCollection, -) -> np.ndarray[Graphic]: - """ - Returns the nearest ``graphics`` to the passed position ``pos`` in world space. - Uses the distance between ``pos`` and the center of the bounding sphere for each graphic. - - Parameters - ---------- - pos: (x, y) | (x, y, z) - position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D - - graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection - the graphics from which to return a sorted array of graphics in order of closest - to furthest graphic - - Returns - ------- - ndarray[Graphic] - nearest graphics to ``pos`` in order - - """ - sort_indices = get_nearest_graphics_indices(pos, graphics) - return np.asarray(graphics)[sort_indices] diff --git a/tests/test_plot_helpers.py b/tests/test_plot_helpers.py index b4abe55fc..bc2bb663f 100644 --- a/tests/test_plot_helpers.py +++ b/tests/test_plot_helpers.py @@ -25,7 +25,7 @@ def test_get_nearest_graphics(): fig[0, 0].add_scatter(np.array([[0, 12, 0]])) # check distances - nearest = fpl.utils.get_nearest_graphics((0, 12), lines) + nearest = fpl.get_nearest_graphics((0, 12), lines) assert nearest[0] is lines[1] # closest assert nearest[1] is lines[0] assert nearest[2] is lines[3] From 6cb1cd0b8eb1fec8c8a477e5859cf6ad559a7f08 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Fri, 29 May 2026 05:14:17 -0700 Subject: [PATCH 115/163] use fully fledged async, `NDPositions` improvements (#1050) * use fully fledged async * better throttling * torch.Tensor.tranpose() doesn't like tuples * we need time-based throttling, but it can be gentler * improvements * improvements * fixes * x_range fix * fix NDPandasProcessor * fix * HighlightSelector fix to append None * cleanup better * know ndg current dipslayed indices * improve example * docstrings, cleanup --- examples/ndwidget/timeseries.py | 1 + .../graphics/selectors/_highlight_selector.py | 4 +- fastplotlib/layouts/_plot_area.py | 31 ++- fastplotlib/widgets/nd_widget/_async.py | 123 +++------ fastplotlib/widgets/nd_widget/_base.py | 165 ++++++------ fastplotlib/widgets/nd_widget/_index.py | 251 ++++++++++++++---- fastplotlib/widgets/nd_widget/_nd_image.py | 105 ++++---- .../nd_widget/_nd_positions/_nd_positions.py | 207 ++++++++++----- .../nd_widget/_nd_positions/_pandas.py | 23 +- fastplotlib/widgets/nd_widget/_nd_vectors.py | 83 +++--- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 14 +- fastplotlib/widgets/nd_widget/_ui.py | 15 +- fastplotlib/widgets/nd_widget/_video.py | 4 +- 13 files changed, 606 insertions(+), 420 deletions(-) diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py index 9d7ba851f..b2fd6ff6e 100644 --- a/examples/ndwidget/timeseries.py +++ b/examples/ndwidget/timeseries.py @@ -50,6 +50,7 @@ }, cmap="jet", x_range_mode="auto", + display_window=np.pi * 10, name="nd-sine" ) diff --git a/fastplotlib/graphics/selectors/_highlight_selector.py b/fastplotlib/graphics/selectors/_highlight_selector.py index b509022e9..3ba08a676 100644 --- a/fastplotlib/graphics/selectors/_highlight_selector.py +++ b/fastplotlib/graphics/selectors/_highlight_selector.py @@ -715,12 +715,12 @@ def append(self, dict_or_index: dict | int) -> None: if self._selection_options is not None: # options mode index = dict_or_index - if not isinstance(index, Integral): + if not isinstance(index, Integral) and index is not None: raise TypeError( f"must provide integer index to append to selection " f"in 'options' mode, you passed: {dict_or_index!r}" ) - if index not in self._selected_indices: + if index not in self._selected_indices or index is None: self._selected_indices.append(index) self._update_all_graphics() self._emit({"value": self.selection}) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 030927540..0c07fbb4b 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -895,37 +895,44 @@ def _auto_scale_scene( def x_range(self) -> tuple[float, float]: """ Get or set the x-range currently in view. - Only valid for orthographic projections of the xy plane. + Really only valid for orthographic projections of the xy plane. Use camera.set_state() to set the camera position for arbitrary projections. """ - hw = self.camera.width / 2 + hw = self.camera.projection_matrix_inverse[0, 0] x = self.camera.local.x return x - hw, x + hw @x_range.setter def x_range(self, xr: tuple[float, float]): - width = xr[1] - xr[0] - x_mid = (xr[0] + xr[1]) / 2 - self.camera.width = width - self.camera.local.x = x_mid + hw = (xr[1] - xr[0]) / 2 + if self.camera.fov > 0: + # really shouldn't use this for fov > 0 but ¯\_(ツ)_/¯ + self.camera.zoom *= self.camera.projection_matrix_inverse[0, 0] / hw + else: + # sets correct x_range for orthographic projection of xy plane + self.camera.width = (xr[1] - xr[0]) * self.camera.zoom + self.camera.local.x = (xr[0] + xr[1]) / 2 @property def y_range(self) -> tuple[float, float]: """ Get or set the y-range currently in view. - Only valid for orthographic projections of the xy plane. + Really only valid for orthographic projections of the xy plane. Use camera.set_state() to set the camera position for arbitrary projections. """ - hh = self.camera.height / 2 + hh = self.camera.projection_matrix_inverse[1, 1] y = self.camera.local.y return y - hh, y + hh @y_range.setter def y_range(self, yr: tuple[float, float]): - height = yr[1] - yr[0] - y_mid = yr[0] + (height / 2) - self.camera.height = height - self.camera.local.y = y_mid + hh = (yr[1] - yr[0]) / 2 + if self.camera.fov > 0: + # shouldn't really do this but ¯\_(ツ)_/¯ + self.camera.zoom *= self.camera.projection_matrix_inverse[1, 1] / hh + else: + self.camera.height = (yr[1] - yr[0]) * self.camera.zoom + self.camera.local.y = (yr[0] + yr[1]) / 2 def remove_graphic(self, graphic: Graphic): """ diff --git a/fastplotlib/widgets/nd_widget/_async.py b/fastplotlib/widgets/nd_widget/_async.py index 5aa24a65f..2cd43b671 100644 --- a/fastplotlib/widgets/nd_widget/_async.py +++ b/fastplotlib/widgets/nd_widget/_async.py @@ -1,100 +1,43 @@ -from collections.abc import Generator -from concurrent.futures import Future +import asyncio +from concurrent.futures import Executor, Future, ThreadPoolExecutor +from typing import Any, Callable, Coroutine -from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol, cuda_to_numpy +from rendercanvas.utils.asyncs import Event, detect_current_call_soon_threadsafe -class FutureArray(Future): - def __init__(self, shape, dtype, timeout: float = 1.0): - self._shape = shape - self._dtype = dtype - self._timeout = timeout - - super().__init__() - - @property - def shape(self) -> tuple[int, ...]: - return self._shape - - @property - def ndim(self) -> int: - return len(self.shape) - - @property - def dtype(self) -> str: - return self._dtype - - def __getitem__(self, item) -> ArrayProtocol: - return self.result(self._timeout)[item] - - def __array__(self) -> ArrayProtocol: - return self.result(self._timeout) - - def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): - raise NotImplementedError - - def __array_function__(self, func, types, *args, **kwargs): - raise NotImplementedError - - -# inspired by https://www.dabeaz.com/coroutines/ -def start_coroutine(func): +async def wait_for_future(future: Future) -> Any: """ - Starts coroutines for async arrays wrapped by NDProcessor. - Used by all NDGraphic.set_indices and NDGraphic._create_graphic. - - It also immediately starts coroutines unless block=False is provided. It handles all the triage of possible - sync vs. async (Future-like) objects. - - The only time when block=False is when ReferenceIndex._render_indices uses it to loop through setting all - indices, and then collect and send the results back down to NDProcessor.get(). + Await a ``concurrent.futures.Future`` from any rendercanvas-supported async + backend (asyncio for glfw/jupyter, the rendercanvas asyncadapter for qt/wx). + + ``asyncio.wrap_future`` cannot be used because the asyncadapter only + understands its own awaitables. We instead build the same + primitive on top of rendercanvas's cross-framework :class:`Event`, + signaled via the active loop's ``call_soon_threadsafe`` so the future's + done-callback (which runs on the executor thread) hands control back to + the event loop safely. """ + event = Event() + call_soon_threadsafe = detect_current_call_soon_threadsafe() + future.add_done_callback(lambda f: call_soon_threadsafe(event.set)) + await event.wait() + return future.result() - def start( - self, *args, **kwargs - ) -> tuple[Generator, ArrayProtocol | CudaArrayProtocol | FutureProtocol] | None: - cr = func(self, *args, **kwargs) - try: - # begin coroutine - to_resolve: FutureProtocol | ArrayProtocol | CudaArrayProtocol = cr.send( - None - ) - except StopIteration: - # NDProcessor.get() has no `yield` expression, not async, nothing to return - return None - block = kwargs.get("block", True) - timeout = kwargs.get("timeout", 1.0) +async def run_in_thread_pool( + executor: Executor, fn: Callable, *args, **kwargs +) -> Any: + """Submit ``fn(*args, **kwargs)`` to ``executor`` and await the result.""" + return await wait_for_future(executor.submit(fn, *args, **kwargs)) - if block: # resolve Future immediately - try: - if isinstance(to_resolve, FutureProtocol): - # array is async, resolve future and send - cr.send(to_resolve.result(timeout=timeout)) - elif isinstance(to_resolve, CudaArrayProtocol): - # array is on GPU, it is technically and on GPU, convert to numpy array on CPU - cr.send(cuda_to_numpy(to_resolve)) - else: - # not async, just send the array - cr.send(to_resolve) - except StopIteration: - pass - else: # no block, probably resolving multiple futures simultaneously - if isinstance(to_resolve, FutureProtocol): - # data is async, return coroutine generator and future - # ReferenceIndex._render_indices() will manage them and wait to gather all futures - return cr, to_resolve - elif isinstance(to_resolve, CudaArrayProtocol): - # it is async technically, but it's a GPU array, ReferenceIndex._render_indices will manage it - return cr, to_resolve - else: - # not async, just send the array - try: - cr.send(to_resolve) - except ( - StopIteration - ): # has to be here because of the yield expression, i.e. it's a generator - pass +def run_sync(coro: Coroutine) -> Any: + """ + Drive an ``async def`` coroutine to completion synchronously, in a helper thread. - return start + Used by constructor calls (NDGraphic.__init__, data setter, other property setters). + ``asyncio.run`` is dispatched to a helper thread so this doesn't interfere with a + loop already running on the calling thread (the rendercanvas loop, jupyter, ipython etc.). + """ + with ThreadPoolExecutor(max_workers=1) as ex: + return ex.submit(asyncio.run, coro).result() diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 724f5fdd6..5ca15889d 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -1,24 +1,26 @@ -from collections.abc import Callable, Sequence, Generator +from __future__ import annotations + +from collections.abc import Callable, Sequence +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager import inspect from numbers import Real from pprint import pformat import textwrap -from typing import Any +from typing import Any, TYPE_CHECKING import numpy as np from numpy.typing import ArrayLike -from ...layouts import Subplot from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol from ...graphics import Graphic +from ._async import run_in_thread_pool, run_sync, wait_for_future + +if TYPE_CHECKING: + from ._ndw_subplot import NDWSubplot # must take arguments: array-like, `axis`: int, `keepdims`: bool WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] -# [YieldType, SendType, ReturnType] -AwaitedArray = Generator[ - FutureProtocol | ArrayProtocol | CudaArrayProtocol, ArrayProtocol, ArrayProtocol -] def identity(index: int) -> int: @@ -125,6 +127,17 @@ def __init__( self.window_order = window_order self.spatial_func = spatial_func + # window_funcs and spatial_func are dispatched with an executor so they don't block the rendercanvas loop. + # CUDA arrays run directly since they are inherently async already, the user is expected to provide CUDA + # functions if the data arrays are CUDA (ex: torch functions, not numpy functions) + self._executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix=f"ndp-{id(self):x}" + ) + + def close(self): + """Shut down the thread pool.""" + self._executor.shutdown(wait=False, cancel_futures=True) + @property def data(self) -> ArrayProtocol: """ @@ -436,11 +449,17 @@ def _get_slider_dims_indexer(self, indices: dict[str, Any]) -> dict[str, slice]: return indexer - def _apply_window_functions(self, windowed_array: ArrayProtocol) -> ArrayProtocol: + async def _apply_window_functions( + self, windowed_array: ArrayProtocol + ) -> ArrayProtocol: """ apply window functions in the order specified by ``window_order``. + For numpy arrays each func is dispatched to the per-processor thread pool so it + does not block the rendercanvas event loop. CUDA arrays are run directly since + cuda functions (ex: torch) are already async. + Parameters ---------- windowed_array: ArrayProtocol @@ -460,18 +479,22 @@ def _apply_window_functions(self, windowed_array: ArrayProtocol) -> ArrayProtoco continue func, _ = self.window_funcs[dim] + axis = self.dims.index(dim) # ``keepdims=True`` is critical, any "collapsed" dims will be of size ``1``. # Ex: if `array` is of shape [10, 512, 512] and we applied the np.mean() window func on the first dim # ``keepdims`` means the resultant shape is [1, 512, 512] and NOT [512, 512] # this is necessary for applying window functions on multiple dims separately and so that the # dims names correspond after all the window funcs are applied. - windowed_array = func( - windowed_array, axis=self.dims.index(dim), keepdims=True - ) + if isinstance(windowed_array, CudaArrayProtocol): + windowed_array = func(windowed_array, axis=axis, keepdims=True) + else: + windowed_array = await run_in_thread_pool( + self._executor, func, windowed_array, axis=axis, keepdims=True + ) return windowed_array - def get_window_output(self, indices: dict[str, Any]) -> AwaitedArray: + async def get_window_output(self, indices: dict[str, Any]) -> ArrayProtocol: """ Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims @@ -484,14 +507,15 @@ def get_window_output(self, indices: dict[str, Any]) -> AwaitedArray: """ # windowed slice if user set any window funcs - windowed_slice = yield from self._get_raw_data_slice(indices) + windowed_slice = await self._get_raw_data_slice(indices) - # convert to numpy array - windowed_slice = np.asarray(windowed_slice) + # convert to numpy array; CUDA arrays pass through and are converted at the end of the pipeline + if not isinstance(windowed_slice, CudaArrayProtocol): + windowed_slice = np.asarray(windowed_slice) # apply window funcs if len(self.slider_dims) > 0: - windowed_slice = self._apply_window_functions(windowed_slice) + windowed_slice = await self._apply_window_functions(windowed_slice) # squeeze out all slider dims which should now be size 1 # set(dims) - set(spatial_dims) since some spatial dims can also be slider, so get only pure non-spatial dims @@ -510,29 +534,31 @@ def get_window_output(self, indices: dict[str, Any]) -> AwaitedArray: self.spatial_dims.index(d) for d in self.dims if d in self.spatial_dims ) - return windowed_slice.transpose(spatial_dims_int) + return windowed_slice.transpose(*spatial_dims_int) - def _get_raw_data_slice(self, indices: dict[str, Any]) -> AwaitedArray: + async def _get_raw_data_slice(self, indices: dict[str, Any]) -> ArrayProtocol: """ Base implementation to get the raw data slice from the wrapped array. - Always yields to support async getters. + + Awaits any ``FutureProtocol`` returned by the underlying loader. CUDA arrays + are returned as-is and converted to numpy at the end of the pipeline. """ if len(self.slider_dims) > 0: indexer = self._get_slider_dims_indexer(indices) # get the data slice w.r.t. the desired windows - # yield so this is async if the underlying array returns a FutureArray-like - # we convert to a numpy array outside, not here, since that resolves the Future index_tuple = tuple(indexer.get(dim, slice(None)) for dim in self.dims) - raw_slice = yield self.data[index_tuple] + raw_slice = self.data[index_tuple] else: # return everything directly # request a slice of everything with [:] so that any data fetching, compute, etc. is actually done - raw_slice = yield self.data[:] + raw_slice = self.data[:] + if isinstance(raw_slice, FutureProtocol): + return await wait_for_future(raw_slice) return raw_slice - def get(self, indices: dict[str, Any]) -> AwaitedArray | ArrayProtocol: + async def get(self, indices: dict[str, Any]) -> ArrayProtocol: raise NotImplementedError # TODO: html and pretty text repr # @@ -576,25 +602,26 @@ def _repr_text_(self): class NDGraphic: def __init__( self, - subplot: Subplot, + nd_subplot: NDWSubplot, name: str | None, ): - self._subplot = subplot + self._nd_subplot = nd_subplot self._name = name self._graphic: Graphic | None = None - # used to indicate that the NDGraphic should ignore any requests to update the indices + # used to indicate that the NDGraphic should ignore any requests to update the indices. # used by block_indices_ctx context manager, usecase is when the LinearSelector on timeseries # NDGraphic changes the selection, it shouldn't change the graphic that it is on top of! Would - # also cause recursion - # It is also used by the @block_reentrance decorator which is on the ``NDGraphic.indices`` property setter - # this is also to block recursion + # also cause recursion. ReferenceIndex._render_indices checks this flag at scheduling time. self._block_indices = False # user settable bool to make the graphic unresponsive to change in the ReferenceIndex self._pause = False - def _create_graphic(self): + # the indices that current graphic data reflects + self._last_indices = None + + async def _create_graphic(self): raise NotImplementedError @property @@ -619,23 +646,26 @@ def processor(self) -> NDProcessor: def graphic(self) -> Graphic: raise NotImplementedError + @property + def indices_displayed(self) -> dict[str, Any]: + """the indices that the graphic currently represents""" + return self._last_indices + @property def indices(self) -> dict[str, Any]: raise NotImplementedError - def set_indices( - self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 - ): - pass - - def _get_data_slice(self, indices): - """gets current data slice from NDProcessor, resolves Futures if necessary""" - data_slice = self.processor.get(indices) + async def _set_indices_(self, indices: dict[str, Any] = None): + """ + Get the data slice for the index from the processor and write it to the graphic. - if isinstance(data_slice, Generator): - data_slice = yield from data_slice + If indices is None, it uses the latest indices from the ReferenceIndex. Otherwise it uses the + indices passed when the update was scheduled. - return data_slice + Semi-private: only ``ReferenceIndex`` should call this. _create_graphic uses `run_sync` + to run it sync + """ + pass # aliases for easier access to processor properties @property @@ -652,13 +682,13 @@ def data(self, data: Any): # create a new graphic when data has changed if self.graphic is not None: # it is already None if NDGraphic was initialized with no data - self._subplot.delete_graphic(self.graphic) + self._nd_subplot.subplot.delete_graphic(self.graphic) self._graphic = None - self._create_graphic() + run_sync(self._create_graphic()) # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_()) @property def shape(self) -> dict[str, int]: @@ -697,7 +727,7 @@ def slider_dim_transforms( """get or set the slider_dim_transforms, see docstring for details""" self.processor.slider_dim_transforms = maps # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_()) @property def window_funcs( @@ -716,7 +746,7 @@ def window_funcs( ): self.processor.window_funcs = window_funcs # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_()) @property def window_order(self) -> tuple[str, ...]: @@ -727,7 +757,7 @@ def window_order(self) -> tuple[str, ...]: def window_order(self, order: tuple[str] | None): self.processor.window_order = order # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_()) @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: @@ -741,7 +771,7 @@ def spatial_func( """get or set the spatial_func, see docstring for details""" self.processor.spatial_func = func # force a render - self.set_indices(self.indices) + run_sync(self._set_indices_()) # def _repr_text_(self) -> str: # return ndg_fmt_text(self) @@ -763,42 +793,17 @@ def _repr_text_(self): @contextmanager -def block_indices_ctx(ndgraphic: NDGraphic): +def block_indices_ctx(*ndgraphics: NDGraphic): """ - Context manager for pausing an NDGraphic from updating indices + Context manager for pausing NDGraphics from updating indices """ - ndgraphic._block_indices = True + for ndg in ndgraphics: + ndg._block_indices = True try: yield except Exception as e: raise e from None # indices setter has raised, the line above and the lines below are probably more relevant! finally: - ndgraphic._block_indices = False - - -def block_reentrance(setter): - # decorator to block re-entrance of indices setter - def set_indices_wrapper(self: NDGraphic, *args, **kwargs): - """ - wraps NDGraphic.indices - - self: NDGraphic instance - - new_indices: new indices to set - """ - # set_value is already in the middle of an execution, block re-entrance - if self._block_indices: - return - try: - # block re-execution of set_value until it has *fully* finished executing - self._block_indices = True - return setter(self, *args, **kwargs) - except Exception as exc: - # raise original exception - raise exc # set_value has raised. The line above and the lines 2+ steps below are probably more relevant! - finally: - # set_value has finished executing, now allow future executions - self._block_indices = False - - return set_indices_wrapper + for ndg in ndgraphics: + ndg._block_indices = False diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 228f3a73f..4f997c1a5 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -1,17 +1,18 @@ from __future__ import annotations -from collections.abc import Generator -from concurrent.futures import wait +from collections import deque +from concurrent.futures import CancelledError from dataclasses import dataclass from numbers import Number -from typing import Sequence, Any, Callable +from typing import Sequence, Any, Callable, Iterator from typing import TYPE_CHECKING if TYPE_CHECKING: from ._ndwidget import NDWidget + from ._base import NDGraphic -from ...utils import FutureProtocol, CudaArrayProtocol, cuda_to_numpy +from ...utils import loop class RangeContinuous: @@ -48,6 +49,7 @@ class RangeContinuous: RangeContinuous(start=0.0, stop=500.0, step=0.5) """ + def __init__(self, start: int | float, stop: int | float, step: int | float): if start >= stop: raise IndexError( @@ -57,8 +59,7 @@ def __init__(self, start: int | float, stop: int | float, step: int | float): self._start = start self._stop = stop self._step = step - - self._throttle = 0.2 + self._throttle = 0.05 @property def start(self) -> int | float: @@ -85,7 +86,7 @@ def step(self) -> int | float: @property def throttle(self) -> float: - """get or set throttle value in seconds. Used for throttling UI sliders""" + """get or set the minimum time in seconds between slider-drag renders""" return self._throttle @throttle.setter @@ -151,14 +152,14 @@ def __init__( otherwise an error will be raised. You can also define conceptually identical but *independent* reference spaces - by using distinct names, ex: ``"time-1"`` and ``"time-2"`` for two recordings + by using distinct names, ex: ``"time-1"`` and ``"time-2"`` for two subsets of data that should be sycned independently. Each ``NDGraphic`` then declares the - specific "time-n" space that corresponds to its data, so the widget keeps the + specific ``"time-n"`` space that corresponds to its data, so the widget keeps the two timelines decoupled. Parameters ---------- - ref_ranges : dict[str, tuple], or a RangeContinuous + ref_ranges : dict[str, tuple | RangeContinuous] Mapping of dimension names to range specifications. A 3-tuple ``(start, stop, step)`` creates a :class:`RangeContinuous`. A 1-tuple ``(options,)`` creates a :class:`RangeDiscrete`. @@ -176,8 +177,8 @@ def __init__( Single shared time axis: ri = ReferenceIndex(ref_ranges={"time": (0, 1000, 1), "depth": (15, 35, 0.5)}) - ri["time"] = 500 # update one dim and re-render - ri.set({"time": 500, "depth": 10}) # update several dims atomically + ri.set_dim_index("time", 500) # update one dim and re-render + ri.set({"time": 500, "depth": 10}) # update several dims atomically Two independent time axes for data from two different recording sessions: @@ -205,15 +206,38 @@ def __init__( self._ndwidgets: list[NDWidget] = list() + # per-NDGraphic fetch update revision. Bumped on every ``cancel_awaiting=True`` + # call (display only latest fetch, used during slider drag). A scheduled fetch + # carries the revision it was created under and skips setting graphic data + # if a newer revision has been requested + self._fetch_rev: dict[NDGraphic, int] = dict() + + # per-graphic queue of pending fetch requests for the serial + # path (i.e. ``cancel_awaiting=False``). Used for play, step, programmatic updates, + # and LinearSelector. Each entry is ``(indices, rev)``. Emptied by + # :meth:`_fetch_request` + self._fetch_request_queue: dict[ + NDGraphic, deque[tuple[dict[str, Any], int]] + ] = dict() + + # per-graphic flag, indicates whether :meth:`_fetch_request` is currently emptying + # ``_fetch_request_queue[ndg]``? Ensures only one coroutine is + # alive per graphic. Subsequent ``cancel_awaiting=False`` calls just + # append to the queue. + self._fetch_request_active: dict[NDGraphic, bool] = dict() + @property def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: + """current reference ranges""" return self._ref_ranges @property def dims(self) -> set[str]: + """reference dimensions""" return set(self.ref_ranges.keys()) def _add_ndwidget_(self, ndw: NDWidget): + """add an NDWidget instance to be managed by this ReferenceIndex""" from ._ndwidget import NDWidget if not isinstance(ndw, NDWidget): @@ -221,14 +245,69 @@ def _add_ndwidget_(self, ndw: NDWidget): self._ndwidgets.append(ndw) - def set(self, indices: dict[str, Any]): + def set(self, indices: dict[str, Any], cancel_awaiting: bool = False): + """ + Set the index for each dimension in indices + + Parameters + ---------- + indices: dict[str, Any] + indices to set, {dim: index} + + cancel_awaiting: bool, default ``False`` + cancel in-progress fetches, i.e. only display the latest fetch request + + Returns + ------- + + """ for dim, value in indices.items(): self._indices[dim] = self._clamp(dim, value) - self._render_indices() + self._fetch_indices(cancel_awaiting=cancel_awaiting) + self._indices_changed() + + @property + def ndgraphics(self) -> Iterator[NDGraphic]: + """All the NDGraphics that this ReferenceIndex instance manages""" + + for ndw in self._ndwidgets: + yield from ndw.ndgraphics + + def set_dim_index(self, dim: str, index: int | float, cancel_awaiting: bool = False): + """ + Set the index for a single dimension and trigger an update. + + Parameters + ---------- + dim : str + Dimension name. + + index : int or float + New reference-space value for this dimension. + + cancel_awaiting : bool, default False + If True, cancel any in-progress fetch tasks before scheduling a new one. + Used only for fast inputs, currently only for the imgui slider so every single + intermediate position during a slider drag isn't fetched & rendered. + All other methods of fetching data (play, step buttons, LinearSelector, + programmatic updates) use cancel_awaiting=False to display every data fetch. + + """ + + self._check_has_dim(dim) + self._indices[dim] = self._clamp(dim, index) + + for ndg in self.ndgraphics: + # set only for NDGraphics that have this dim + if dim in ndg.dims: + self._schedule_fetch(ndg, cancel_awaiting=cancel_awaiting) + self._indices_changed() - def _clamp(self, dim, value): + def _clamp(self, dim: str, value: int | float): + """clamp the given index value within the valid range for this dimension""" + if isinstance(self.ref_ranges[dim], RangeContinuous): return max( min(value, self.ref_ranges[dim].stop - self.ref_ranges[dim].step), @@ -237,57 +316,109 @@ def _clamp(self, dim, value): return value - def _render_indices(self): - pending_futures = list() - pending_cuda = list() + def _fetch_indices(self, cancel_awaiting: bool = False): + """ + Schedule a fetch for every NDGraphic. + """ - for ndw in self._ndwidgets: - for g in ndw.ndgraphics: - if g.data is None or g.pause: - continue - # only provide slider indices to the graphic - indices = {d: self._indices[d] for d in g.processor.slider_dims} - to_resolve: None | tuple[Generator, FutureProtocol] = g.set_indices(indices, block=False) - - if to_resolve is not None: - if isinstance(to_resolve[1], FutureProtocol): - # it's a future that we need to resolve - pending_futures.append(to_resolve) - elif isinstance(to_resolve[1], CudaArrayProtocol): - pending_cuda.append(to_resolve) - - if not pending_futures and not pending_cuda: - # no futures or gpu arrays to resolve, everything is sync + for g in self.ndgraphics: + self._schedule_fetch(g, cancel_awaiting=cancel_awaiting) + + def _schedule_fetch(self, ndg: NDGraphic, cancel_awaiting: bool = False): + """ + Schedule fetch for an NDGraphic + + This entry point has 2 paths: + + * ``cancel_awaiting=True`` used for fast inputs, currently only for the imgui slider where + we don't want to fetch & render every intermediate position during a slider drag. Schedules a new + ``_set_indices_`` task via :meth:`_render_request_latest`. Any in-progress tasks skip + setting graphic data. ``_fetch_rev`` is used so only the latest revision is rendered. + + - ``cancel_awaiting=False`` used by play, step button, LinearSelector, programmatic updates. + Every request will fetch & render. Requests are queued per graphic and processed in sequence + by :meth:`_render_request`. + """ + + if ndg.data is None or ndg.pause or ndg._block_indices: + # skip fetch for this graphic return - # resolve futures - wait([future for cr, future in pending_futures], timeout=2) + task_name = f"ndw-fetch:{type(ndg).__name__}" + if ndg.name is not None: + task_name = f"{task_name}:{ndg.name}" - for cr, future in pending_futures: - try: - cr.send(future.result()) - except StopIteration: - pass + if cancel_awaiting: + # bump revision so older in-progress fetches skip setting graphic data + self._fetch_rev[ndg] = self._fetch_rev.get(ndg, 0) + 1 + rev = self._fetch_rev[ndg] - # resolve GPU arrays - for cr, gpu_arr in pending_cuda: - try: - arr = cuda_to_numpy(gpu_arr) - cr.send(arr) - except StopIteration: - pass + # add to rendercanvas scheduler + loop.add_task( + self._fetch_request_latest, ndg, rev, name=task_name + ) + else: + rev = self._fetch_rev.get(ndg, 0) + # provide index at schedule time so all data is played back sequentially + indices = {d: self._indices[d] for d in ndg.processor.slider_dims} + self._fetch_request_queue.setdefault(ndg, deque()).append( + (indices, rev) + ) + # one queue per graphic + # if one is already running the appended entry will be picked up by it + if not self._fetch_request_active.get(ndg, False): + self._fetch_request_active[ndg] = True + loop.add_task(self._fetch_request, ndg, name=task_name) + + async def _fetch_request(self, graphic: "NDGraphic"): + """ + Process ``_fetch_request_queue[graphic]`` one entry at a time. Each + ``_set_indices_`` is awaited fully before the next entry is popped, + so only one ``_set_indices_`` is in-progress per graphic from this + path. + A concurrent :meth:`_fetch_request_latest` for the same + graphic can still cancel an in-progress fetch; the resulting + :class:`CancelledError` is dropped. + """ + try: + queue = self._fetch_request_queue[graphic] + while queue: + indices, rev = queue.popleft() + if rev < self._fetch_rev.get(graphic, 0): + # a rapid-fire request superseded this queued entry; skip + continue + try: + await graphic._set_indices_(indices) + except CancelledError: + # concurrent _fetch_request_latest canceled our read on ``data`` + pass + del self._fetch_request_queue[graphic] + finally: + self._fetch_request_active[graphic] = False + + async def _fetch_request_latest( + self, graphic: "NDGraphic", rev: int + ): + """ + Schedule one ``_set_indices_`` task. Older still-running tasks skip + their graphic data write when ``rev < current``. + Some ``data`` objects cancel the + previous in-flight read when a new index is requested; the resulting + :class:`CancelledError` is dropped. + """ + if rev < self._fetch_rev.get(graphic, 0): + # a newer rapid-fire request superseded us; drop the write + return + try: + await graphic._set_indices_() + except CancelledError: + # ``data`` cancelled this read in favour of a newer one + pass def __getitem__(self, dim): self._check_has_dim(dim) return self._indices[dim] - def __setitem__(self, dim, value): - self._check_has_dim(dim) - # set index for given dim and render - self._indices[dim] = self._clamp(dim, value) - self._render_indices() - self._indices_changed() - def _check_has_dim(self, dim): if dim not in self.dims: raise KeyError( @@ -297,10 +428,13 @@ def _check_has_dim(self, dim): def pop_dim(self): pass - def push_dims(self, ref_ranges: dict[ + def push_dims( + self, + ref_ranges: dict[ str, tuple[Number, Number, Number] | tuple[Any] | RangeContinuous, - ],): + ], + ): for name, r in ref_ranges.items(): if isinstance(r, (RangeContinuous, RangeDiscrete)): @@ -362,6 +496,7 @@ def clear_event_handlers(self): self._indices_changed_handlers.clear() def _indices_changed(self): + # calls indices changed handlers for f in self._indices_changed_handlers: f(self._indices) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 6463eba88..951fc5a55 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -1,22 +1,31 @@ -from collections.abc import Sequence, Generator -from typing import Callable, Any, Literal +from __future__ import annotations + +from collections.abc import Sequence +from typing import Callable, Any, Literal, TYPE_CHECKING import numpy as np from numpy.typing import ArrayLike -from ...layouts import Subplot -from ...utils import subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol, enums +from ...utils import ( + subsample_array, + ARRAY_LIKE_ATTRS, + ArrayProtocol, + CudaArrayProtocol, + cuda_to_numpy, + enums, +) from ...graphics import ImageGraphic, ImageYUVGraphic, ImageVolumeGraphic from ...tools import HistogramLUTTool from ._base import ( NDProcessor, NDGraphic, WindowFuncCallable, - block_reentrance, - AwaitedArray, ) from ._index import ReferenceIndex -from ._async import start_coroutine +from ._async import run_in_thread_pool, run_sync + +if TYPE_CHECKING: + from ._ndw_subplot import NDWSubplot class NDImageProcessor(NDProcessor): @@ -217,7 +226,7 @@ def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: """ return self._histogram - def get(self, indices: dict[str, Any]) -> AwaitedArray: + async def get(self, indices: dict[str, Any]) -> ArrayProtocol: """ Get the data at the given index, process data through the window functions. @@ -232,15 +241,22 @@ def get(self, indices: dict[str, Any]) -> AwaitedArray: """ # this will be squeezed output, with dims in the order of the user set spatial dims - window_output = yield from self.get_window_output(indices) + window_output = await self.get_window_output(indices) - # apply spatial_func + # apply spatial_func; CUDA arrays run inline, numpy goes through the thread pool if self.spatial_func is not None: - spatial_out = self._spatial_func(window_output) - if spatial_out.ndim != len(self.spatial_dims): + if isinstance(window_output, CudaArrayProtocol): + window_output = self._spatial_func(window_output) + else: + window_output = await run_in_thread_pool( + self._executor, self._spatial_func, window_output + ) + if window_output.ndim != len(self.spatial_dims): raise ValueError - return spatial_out + # final CUDA -> numpy conversion at the end of the pipeline + if isinstance(window_output, CudaArrayProtocol): + window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) return window_output @@ -278,7 +294,7 @@ class NDImage(NDGraphic): def __init__( self, ref_index: ReferenceIndex, - subplot: Subplot, + nd_subplot: NDWSubplot, data: ArrayProtocol | None, dims: Sequence[str], spatial_dims: ( @@ -314,8 +330,8 @@ def __init__( ref_index : ReferenceIndex The shared reference index that delivers slider updates to this graphic. - subplot : Subplot - parent subplot the NDGraphic is in + nd_subplot : NDWSubplot + parent NDWSubplot the NDGraphic is in data : array-like or None n-dimension image data array @@ -366,7 +382,7 @@ def __init__( f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" ) - super().__init__(subplot, name) + super().__init__(nd_subplot, name) self._ref_index = ref_index @@ -389,7 +405,7 @@ def __init__( self._histogram_widget: HistogramLUTTool | None = None # create a graphic - self._create_graphic() + run_sync(self._create_graphic()) @property def processor(self) -> NDImageProcessor: @@ -403,8 +419,7 @@ def graphic( """Underlying Graphic object used to display the current data slice""" return self._graphic - @start_coroutine - def _create_graphic(self): + async def _create_graphic(self): # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, # adds it to the subplot, and resets the camera and histogram. @@ -432,8 +447,7 @@ def _create_graphic(self): # get the data slice for this index # this will only have the dims specified by ``spatial_dims`` - - data_slice = yield from self._get_data_slice(self.indices) + data_slice = await self.processor.get(self.indices) # create the new graphic new_graphic = cls( @@ -452,7 +466,7 @@ def _create_graphic(self): attrs[k] = getattr(old_graphic, k) # delete the old graphic - self._subplot.delete_graphic(old_graphic) + self._nd_subplot.subplot.delete_graphic(old_graphic) # set any attributes that we're carrying over like cmap for attr, val in attrs.items(): @@ -460,7 +474,7 @@ def _create_graphic(self): self._graphic = new_graphic - self._subplot.add_graphic(self._graphic) + self._nd_subplot.subplot.add_graphic(self._graphic) self._reset_camera() self._reset_histogram() @@ -472,7 +486,7 @@ def _reset_histogram(self): if not self.processor.compute_histogram: # hide right dock if histogram not desired - self._subplot.docks["right"].size = 0 + self._nd_subplot.subplot.docks["right"].size = 0 return if self.processor.histogram: @@ -480,8 +494,8 @@ def _reset_histogram(self): # histogram widget exists, update it self._histogram_widget.histogram = self.processor.histogram self._histogram_widget.images = self.graphic - if self._subplot.docks["right"].size < 1: - self._subplot.docks["right"].size = 80 + if self._nd_subplot.subplot.docks["right"].size < 1: + self._nd_subplot.subplot.docks["right"].size = 80 else: # make hist tool self._histogram_widget = HistogramLUTTool( @@ -489,8 +503,8 @@ def _reset_histogram(self): images=self.graphic, name=f"hist-{hex(id(self.graphic))}", ) - self._subplot.docks["right"].add_graphic(self._histogram_widget) - self._subplot.docks["right"].size = 80 + self._nd_subplot.subplot.docks["right"].add_graphic(self._histogram_widget) + self._nd_subplot.subplot.docks["right"].size = 80 self.graphic.reset_vmin_vmax() @@ -498,7 +512,7 @@ def _reset_camera(self): # set camera to a nice position based on whether it's a 2D ImageGraphic or 3D ImageVolumeGraphic if isinstance(self._graphic, (ImageGraphic, ImageYUVGraphic)): # set camera orthogonal to the xy plane, flip y axis - self._subplot.camera.set_state( + self._nd_subplot.subplot.camera.set_state( { "position": [0, 0, -1], "rotation": [0, 0, 0, 1], @@ -509,22 +523,22 @@ def _reset_camera(self): } ) - self._subplot.controller = "panzoom" - self._subplot.axes.intersection = None - self._subplot.auto_scale() + self._nd_subplot.controller = "panzoom" + self._nd_subplot.subplot.axes.intersection = None + self._nd_subplot.subplot.auto_scale() else: # It's not an ImageGraphic, set perspective projection - self._subplot.camera.fov = 50 - self._subplot.controller = "orbit" + self._nd_subplot.subplot.camera.fov = 50 + self._nd_subplot.controller = "orbit" # set all 3D dimension camera scales to positive since positive scales # are typically used for looking at volumes for dim in ["x", "y", "z"]: - if getattr(self._subplot.camera.local, f"scale_{dim}") < 0: - setattr(self._subplot.camera.local, f"scale_{dim}", 1) + if getattr(self._nd_subplot.subplot.camera.local, f"scale_{dim}") < 0: + setattr(self._nd_subplot.subplot.camera.local, f"scale_{dim}", 1) - self._subplot.auto_scale() + self._nd_subplot.subplot.auto_scale() @property def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: @@ -540,21 +554,20 @@ def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): self.processor.spatial_dims = dims # shape has probably changed, recreate graphic - self._create_graphic() + run_sync(self._create_graphic()) @property def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - @block_reentrance - @start_coroutine - def set_indices( - self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 - ): - data_slice = yield from self._get_data_slice(indices) + async def _set_indices_(self, indices: dict[str, Any] = None): + if indices is None: + # current indices, else use the indices passed at schedule time + indices = self.indices - self.graphic.data = data_slice + self.graphic.data = await self.processor.get(indices) + self._last_indices = indices @property def compute_histogram(self) -> bool: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 61e3c97d2..9f4108bef 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -1,13 +1,14 @@ -from collections.abc import Callable, Hashable, Sequence, Generator +from __future__ import annotations + +from collections.abc import Callable, Hashable, Sequence from functools import partial -from typing import Literal, Any, Type +from typing import Literal, Any, Type, TYPE_CHECKING from warnings import warn import numpy as np from numpy.lib.stride_tricks import sliding_window_view from numpy.typing import ArrayLike -from ....layouts import Subplot from ....graphics import ( ImageGraphic, LineGraphic, @@ -24,12 +25,14 @@ NDProcessor, NDGraphic, WindowFuncCallable, - block_reentrance, block_indices_ctx, ) -from ....utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol +from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy from .._index import ReferenceIndex -from .._async import start_coroutine +from .._async import run_in_thread_pool, run_sync + +if TYPE_CHECKING: + from .._ndw_subplot import NDWSubplot # types for the other features FeatureCallable = Callable[[np.ndarray, slice], np.ndarray] @@ -37,12 +40,6 @@ MarkersType = Sequence[str] | np.ndarray | FeatureCallable | None SizesType = Sequence[float] | np.ndarray | FeatureCallable | None -AwaitedPositionData = Generator[ - FutureProtocol | ArrayProtocol | CudaArrayProtocol, - ArrayProtocol, - dict[str, ArrayProtocol], -] - def default_cmap_transform_each(p: int, data_slice: np.ndarray, s: slice): # create a cmap transform based on the `p` dim size @@ -526,7 +523,7 @@ def _get_other_features( return other - def get(self, indices: dict[str, Any]) -> AwaitedPositionData: + async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]: """ slices through all slider dims and outputs an array that can be used to set graphic data @@ -534,7 +531,7 @@ def get(self, indices: dict[str, Any]) -> AwaitedPositionData: index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. """ # already squeezed and in the correct spatial_dims order - window_output = yield from self.get_window_output(indices) + window_output = await self.get_window_output(indices) # get slice obj for display window dw_slice = self._get_dw_slice(indices) @@ -545,9 +542,23 @@ def get(self, indices: dict[str, Any]) -> AwaitedPositionData: # p_dims is dim 1 graphic_data = window_output[:, dw_slice] - data = self._finalize(graphic_data) + # _finalize runs the user's datapoints_window_func and spatial_func. + if isinstance(graphic_data, CudaArrayProtocol): + # the datapoints_window_func and spatial_func should be direct on-cuda functions + # ex: torch functions that can take cuda arrays directly + data = self._finalize(graphic_data) + else: + # run CPU functions, probably numpy-based, in a thread pool + data = await run_in_thread_pool( + self._executor, self._finalize, graphic_data + ) + other = self._get_other_features(data, dw_slice) + # final CUDA -> numpy conversion at the end of the pipeline + if isinstance(data, CudaArrayProtocol): + data = await run_in_thread_pool(self._executor, cuda_to_numpy, data) + return { "data": data, **other, @@ -558,7 +569,7 @@ class NDPositions(NDGraphic): def __init__( self, ref_index: ReferenceIndex, - subplot: Subplot, + nd_subplot: NDWSubplot, data: Any, dims: Sequence[str], spatial_dims: tuple[str, str, str], @@ -607,7 +618,7 @@ def __init__( Parameters ---------- ref_index - subplot + nd_subplot data dims spatial_dims @@ -634,7 +645,7 @@ def __init__( processor_kwargs """ - super().__init__(subplot, name) + super().__init__(nd_subplot, name) self._ref_index = ref_index @@ -672,34 +683,52 @@ def __init__( self._graphic_type = graphic_type + # TODO: I think this is messy af, NDTimeseriesSubclass??? + # display_window = None overrides x_range_mode + if display_window is None: + x_range_mode = None + self._x_range_mode = None + self._last_x_range: tuple[float, float] | None = None self.x_range_mode = x_range_mode - self._last_x_range = np.array([0.0, 0.0], dtype=np.float32) + + # determine a min display_window for x_range_mode = "auto" + # determines required world space range for 3 datapoints + p_dim = self.processor.spatial_dims[1] + p_range = self._ref_index.ref_ranges[p_dim] + p_map = self.processor.slider_dim_transforms[p_dim] + p_span = p_range.stop - p_range.start + p_mid = p_range.start + p_span / 2 + i = p_map(p_mid) + i_increment = p_map(p_mid + p_range.step) + delta_p = p_range.step / max(1, i_increment - i) + self._min_display_window = 3 * delta_p self._timeseries = timeseries # TODO: I think this is messy af, NDTimeseriesSubclass??? if self._timeseries: # makes some assumptions about positional data that apply only to timeseries representations # probably don't want to maintain aspect - self._subplot.camera.maintain_aspect = False + self._nd_subplot.subplot.camera.maintain_aspect = False # auto x range modes make no sense for non-timeseries data self.x_range_mode = x_range_mode - if linear_selector: + # make a linear selector only if one does not already exist in this subplot + if linear_selector and "__ndw_manged_linear_selector" not in self._nd_subplot.subplot: self._linear_selector = LinearSelector( - 0, limits=(-np.inf, np.inf), edge_color="cyan" + 0, limits=(-np.inf, np.inf), edge_color="cyan", name="__ndw_manged_linear_selector" ) self._linear_selector.add_event_handler( self._linear_selector_handler, "selection" ) - self._subplot.add_graphic(self._linear_selector) + self._nd_subplot.subplot.add_graphic(self._linear_selector) else: self._linear_selector = None else: self._linear_selector = None - self._create_graphic() + run_sync(self._create_graphic()) @property def processor(self) -> NDPositionsProcessor: @@ -740,9 +769,9 @@ def graphic_type(self, graphic_type): if type(self.graphic) is graphic_type: return - self._subplot.delete_graphic(self._graphic) + self._nd_subplot.subplot.delete_graphic(self._graphic) self._graphic_type = graphic_type - self._create_graphic() + run_sync(self._create_graphic()) @property def spatial_dims(self) -> tuple[str, str, str]: @@ -752,22 +781,22 @@ def spatial_dims(self) -> tuple[str, str, str]: def spatial_dims(self, dims: tuple[str, str, str]): self.processor.spatial_dims = dims # force re-render - self.set_indices(self.indices) + run_sync(self._set_indices_()) @property def indices(self) -> dict[Hashable, Any]: return {d: self._ref_index[d] for d in self.processor.slider_dims} - @block_reentrance - @start_coroutine - def set_indices( - self, indices: dict[Hashable, Any], block: bool = True, timeout: float = 1.0 - ): + async def _set_indices_(self, indices: dict[str, Any] = None): if self.data is None: return - new_features = yield from self._get_data_slice(indices) + if indices is None: + # fetch the latest indices from the ReferenceIndex + # else used passed indices from schedule time + indices = self.indices + new_features = await self.processor.get(indices) data_slice = new_features["data"] # TODO: set other graphic features, colors, sizes, markers, etc. @@ -786,7 +815,7 @@ def set_indices( g.data[:, : new_data.shape[1]] = new_data for feature in ["colors", "sizes", "markers"]: - value = new_features[feature] + value = new_features.get(feature, None) match value: case None: @@ -816,28 +845,38 @@ def set_indices( # TODO: I think this is messy af, NDTimeseriesSubclass??? # x range of the data - xr = data_slice[0, 0, 0], data_slice[0, -1, 0] - if self.x_range_mode is not None: - self.graphic._plot_area.x_range = xr + xr_data = data_slice[0, 0, 0], data_slice[0, -1, 0] - # if the update_from_view is polling, this prevents it from being called by setting the new last xrange - # in theory, but this doesn't seem to fully work yet, not a big deal right now can check later - self._last_x_range[:] = self.graphic._plot_area.x_range + if self.x_range_mode is not None: + # set x_range directly from the display_window, NOT from the xr_data + # this way it doesn't fight with the update_from_view_range() polling + dw = self.processor.display_window + hw = dw / 2 + center = indices[self.processor.spatial_dims[1]] + xr_view = center - hw, center + hw + self._nd_subplot.subplot.x_range = xr_view + # record post-write camera state so the polling animation does not + # mistake our own write for a user pan/zoom on the next tick + self._last_x_range = self._nd_subplot.subplot.x_range if self._linear_selector is not None: with pause_events( self._linear_selector ): # we don't want the linear selector change to update the indices - self._linear_selector.limits = xr + self._linear_selector.limits = xr_data # linear selector acts on `p` dim self._linear_selector.selection = indices[ self.processor.spatial_dims[1] ] + self._last_indices = indices + def _linear_selector_handler(self, ev): - with block_indices_ctx(self): - # linear selector always acts on the `p` dim - self._ref_index[self.processor.spatial_dims[1]] = ev.info["value"] + with block_indices_ctx(*self._nd_subplot.nd_graphics): + # block index change in all NDGraphics that are not in the same subplot + self._ref_index.set_dim_index( + self.processor.spatial_dims[1], ev.info["value"] + ) def _tooltip_handler(self, graphic, pick_info): if isinstance(self.graphic, (LineCollection, ScatterCollection)): @@ -846,12 +885,11 @@ def _tooltip_handler(self, graphic, pick_info): p_index = pick_info["vertex_index"] return self.processor.tooltip_format(n_index, p_index) - @start_coroutine - def _create_graphic(self): + async def _create_graphic(self): if self.data is None: return - new_features = yield from self._get_data_slice(self.indices) + new_features = await self.processor.get(self.indices) data_slice = new_features["data"] # store any cmap, sizes, thickness, etc. to assign to new graphic @@ -890,7 +928,7 @@ def _create_graphic(self): if isinstance(self._graphic, (LineCollection, ScatterCollection)): for l, g in enumerate(self.graphic.graphics): for feature in ["colors", "sizes", "markers"]: - value = new_features[feature] + value = new_features.get(feature, None) match value: case None: @@ -919,16 +957,26 @@ def _create_graphic(self): for g in self._graphic.graphics: g.tooltip_format = partial(self._tooltip_handler, g) - self._subplot.add_graphic(self._graphic) + self._nd_subplot.subplot.add_graphic(self._graphic) # set the initial position and limits of the linear selector # x range of the data - xr = data_slice[0, 0, 0], data_slice[0, -1, 0] + xr_data = data_slice[0, 0, 0], data_slice[0, -1, 0] + + if self.x_range_mode is not None: + # set the intended view range before figure.show()'s autoscale runs + dw = self.processor.display_window + hw = dw / 2 + center = self.indices[self.processor.spatial_dims[1]] + xr_view = center - hw, center + hw + self.graphic._plot_area.x_range = xr_view + self._last_x_range = self.graphic._plot_area.x_range + if self._linear_selector is not None: with pause_events( self._linear_selector ): # we don't want the linear selector change to update the indices - self._linear_selector.limits = xr + self._linear_selector.limits = xr_data # linear selector acts on `p` dim self._linear_selector.selection = self.indices[ self.processor.spatial_dims[1] @@ -973,9 +1021,11 @@ def display_window(self) -> int | float | None: @display_window.setter def display_window(self, dw: int | float | None): self.processor.display_window = dw + if dw is None: + self.x_range_mode = None # force re-render - self.set_indices(self.indices) + run_sync(self._set_indices_()) @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: @@ -996,39 +1046,54 @@ def x_range_mode(self) -> Literal["fixed", "auto"] | None: @x_range_mode.setter def x_range_mode(self, mode: Literal[None, "fixed", "auto"]): + if mode not in (None, "fixed", "auto"): + raise ValueError( + f"x_range_mode must be None, 'fixed', or 'auto', got: {mode!r}" + ) + if mode == self._x_range_mode: + return + if self._x_range_mode == "auto": # old mode was auto - self._subplot.remove_animation(self._update_from_view_range) + self._nd_subplot.subplot.remove_animation(self._update_from_view_range) + self._last_x_range = None if mode == "auto": - self._subplot.add_animations(self._update_from_view_range) + # seed so the first tick does not fire spuriously + self._last_x_range = self._nd_subplot.subplot.x_range + self._nd_subplot.subplot.add_animations(self._update_from_view_range) self._x_range_mode = mode def _update_from_view_range(self): + # update from current x_range if it has changed if self._graphic is None: return - xr = self._subplot.x_range - - # the floating point error near zero gets nasty here - if np.allclose(xr, self._last_x_range, atol=1e-14): + xr = self._nd_subplot.subplot.x_range + if xr == self._last_x_range: + # x_range hasn't changed return - last_width = abs(self._last_x_range[1] - self._last_x_range[0]) - self._last_x_range[:] = xr + self._last_x_range = xr new_width = abs(xr[1] - xr[0]) - new_index = (xr[0] + xr[1]) / 2 + # make sure width is sufficient for >= 3 datapoints + if new_width < self._min_display_window: + new_width = self._min_display_window - if (new_index == self._ref_index[self.processor.spatial_dims[1]]) and ( - last_width == new_width - ): - return + new_index = (xr[0] + xr[1]) / 2 self.processor.display_window = new_width - # set the `p` dim on the global index vector - self._ref_index[self.processor.spatial_dims[1]] = new_index + + # block scheduling an additional async _set_indices_ for ndgraphics in this subplot + with block_indices_ctx(*self._nd_subplot.nd_graphics): + p_dim = self.processor.spatial_dims[1] + self._ref_index.set_dim_index(p_dim, new_index) + + # run this ndgraphic update immediately so graphic data and linear selector are in sync with the + # camera, otherwise you get laggy movement + run_sync(self._set_indices_()) @property def cmap(self) -> str | None: @@ -1049,7 +1114,7 @@ def cmap(self, new: str | None): self._graphic.cmap = new self._cmap = new # force a re-render - self.set_indices(self.indices) + run_sync(self._set_indices_()) @property def cmap_each(self) -> np.ndarray[str] | None: @@ -1115,7 +1180,7 @@ def markers(self, new: str | None): self.graphic.markers = new self._markers = new # force a re-render - self.set_indices(self.indices) + run_sync(self._set_indices_()) @property def sizes(self) -> float | Sequence[float] | None: @@ -1134,7 +1199,7 @@ def sizes(self, new: float | Sequence[float] | None): self.graphic.sizes = new self._sizes = new # force a re-render - self.set_indices(self.indices) + run_sync(self._set_indices_()) @property def thickness(self) -> float | Sequence[float] | None: @@ -1153,4 +1218,4 @@ def thickness(self, new: float | Sequence[float] | None): self.graphic.thickness = new self._thickness = new # force a re-render - self.set_indices(self.indices) + run_sync(self._set_indices_()) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 9278312fc..fc15277a0 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -44,7 +44,7 @@ def data(self, data: pd.DataFrame): if not isinstance(data, pd.DataFrame): raise TypeError - self._data= data + self._data = data @property def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: @@ -72,21 +72,30 @@ def tooltip_format(self, n: int, p: int): p += self._dw_slice.start return str(self.data[self._tooltip_columns[n]][p]) - def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + async def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: # TODO: LOD by using a step size according to max_p # TODO: Also what to do if display_window is None and data # hasn't changed when indices keeps getting set, cache? # assume no additional slider dims self._dw_slice = self._get_dw_slice(indices) - gdata_shape = len(self.columns), self._dw_slice.stop - self._dw_slice.start, 3 + + column_stacks = [ + np.column_stack( + [self.data[c][self._dw_slice] for c in col] + ) for col in self.columns + ] + if len(column_stacks) > 0: + n_samples = column_stacks[0].shape[0] + else: + n_samples = 0 + + gdata_shape = len(self.columns), n_samples, 3 graphic_data = np.zeros(shape=gdata_shape, dtype=np.float32) - for i, col in enumerate(self.columns): - graphic_data[i, :, :len(col)] = np.column_stack( - [self.data[c][self._dw_slice] for c in col] - ) + for i, (col, column_stack) in enumerate(zip(self.columns, column_stacks)): + graphic_data[i, :, :len(col)] = column_stack data = self._finalize(graphic_data) other = self._get_other_features(data, self._dw_slice) diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 0b8e04725..138ddee95 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -1,21 +1,27 @@ -from collections.abc import Sequence, Generator, Callable -from typing import Any +from __future__ import annotations + +from collections.abc import Sequence, Callable +from typing import Any, TYPE_CHECKING -import numpy as np from numpy.typing import ArrayLike -from ...layouts import Subplot -from ...utils import subsample_array, ARRAY_LIKE_ATTRS, ArrayProtocol +from ...utils import ( + ARRAY_LIKE_ATTRS, + ArrayProtocol, + CudaArrayProtocol, + cuda_to_numpy, +) from ...graphics import VectorsGraphic from ._base import ( NDProcessor, NDGraphic, WindowFuncCallable, - block_reentrance, - AwaitedArray, ) from ._index import ReferenceIndex -from ._async import start_coroutine +from ._async import run_in_thread_pool, run_sync + +if TYPE_CHECKING: + from ._ndw_subplot import NDWSubplot class NDVectorsProcessor(NDProcessor): @@ -134,10 +140,10 @@ def spatial_dims(self, sdims: tuple[str, str, str]): self.spatial_dims[-1] ] not in (2, 3): raise ValueError( - f"Spatial dimensions must haves shape (num_vecs, 2, [2 or 3]) you passed an array of shape {data.shape}" + f"Spatial dimensions must haves shape (num_vecs, 2, [2 or 3]) you passed {sdims}" ) - def get(self, indices: dict[str, Any]) -> AwaitedArray: + async def get(self, indices: dict[str, Any]) -> ArrayProtocol: """ Get the data at the given index, process data through the window functions. @@ -152,15 +158,22 @@ def get(self, indices: dict[str, Any]) -> AwaitedArray: """ # this will be squeezed output, with dims in the order of the user set spatial dims - window_output = yield from self.get_window_output(indices) + window_output = await self.get_window_output(indices) - # apply spatial_func + # apply spatial_func; CUDA arrays run inline, numpy goes through the thread pool if self.spatial_func is not None: - spatial_out = self._spatial_func(window_output) - if spatial_out.ndim != len(self.spatial_dims): + if isinstance(window_output, CudaArrayProtocol): + window_output = self._spatial_func(window_output) + else: + window_output = await run_in_thread_pool( + self._executor, self._spatial_func, window_output + ) + if window_output.ndim != len(self.spatial_dims): raise ValueError - return spatial_out + # final CUDA -> numpy conversion at the end of the pipeline + if isinstance(window_output, CudaArrayProtocol): + window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) return window_output @@ -169,7 +182,7 @@ class NDVectors(NDGraphic): def __init__( self, ref_index: ReferenceIndex, - subplot: Subplot, + nd_subplot: NDWSubplot, data: ArrayProtocol | None, dims: Sequence[str], spatial_dims: tuple[ @@ -197,8 +210,8 @@ def __init__( ref_index : ReferenceIndex The shared reference index that delivers slider updates to this graphic. - subplot : Subplot - parent subplot the NDGraphic is in + nd_subplot : NDWSubplot + parent ndsubplot the NDGraphic is in data : array-like or None Shape [num_vectors, 2, 2] or [num_vectors, 3, 2]. data[:, :, 0] gives the positions, data[:, :, 1] gives directions @@ -242,7 +255,7 @@ def __init__( f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" ) - super().__init__(subplot, name) + super().__init__(nd_subplot, name) self._ref_index = ref_index @@ -264,7 +277,7 @@ def __init__( self._graphic_kwargs = graphic_kwargs # create a graphic - self._create_graphic() + run_sync(self._create_graphic()) @property def processor(self) -> NDVectorsProcessor: @@ -278,8 +291,7 @@ def graphic( """Underlying Graphic object used to display the current data slice""" return self._graphic - @start_coroutine - def _create_graphic(self): + async def _create_graphic(self): # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, # adds it to the subplot, and resets the camera and histogram. @@ -289,14 +301,13 @@ def _create_graphic(self): # get the data slice for this index # this will only have the dims specified by ``spatial_dims`` - - data_slice = yield from self._get_data_slice(self.indices) + data_slice = await self.processor.get(self.indices) old_graphic = self._graphic # check if we are replacing a graphic if old_graphic is not None: # delete the old graphic - self._subplot.delete_graphic(old_graphic) + self._nd_subplot.subplot.delete_graphic(old_graphic) # create the new graphic self._graphic = VectorsGraphic( @@ -305,7 +316,7 @@ def _create_graphic(self): **self._graphic_kwargs ) - self._subplot.add_graphic(self._graphic) + self._nd_subplot.subplot.add_graphic(self._graphic) @property def spatial_dims(self) -> tuple[str, str, str]: @@ -320,25 +331,23 @@ def spatial_dims(self, dims: tuple[str, str, str]): self.processor.spatial_dims = dims # shape has probably changed, recreate graphic - self._create_graphic() + run_sync(self._create_graphic()) @property def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" return {d: self._ref_index[d] for d in self.processor.slider_dims} - @block_reentrance - @start_coroutine - def set_indices( - self, indices: dict[str, Any], block: bool = True, timeout: float = 1.0 - ): - data_slice = yield from self._get_data_slice(indices) + async def _set_indices_(self, indices: dict[str, Any] = None): + if indices is None: + # use latest indices if None, else use passed indices from schedule time + indices = self.indices - positions = data_slice[:, 0] - directions = data_slice[:, 1] + data_slice = await self.processor.get(indices) + self.graphic.positions = data_slice[:, 0] + self.graphic.directions = data_slice[:, 1] - self.graphic.positions = positions - self.graphic.directions = directions + self._last_indices = indices @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 3c655d662..469b110b6 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -35,6 +35,10 @@ def __init__(self, ndw, subplot: Subplot): self._nd_graphics = list() + @property + def subplot(self) -> Subplot: + return self._subplot + @property def nd_graphics(self) -> tuple[NDGraphic]: """all the NDGraphic instance in this subplot""" @@ -70,7 +74,7 @@ def add_nd_image( ): nd = NDImage( self.ndw.indices, - self._subplot, + nd_subplot=self, data=data, dims=dims, spatial_dims=spatial_dims, @@ -123,7 +127,7 @@ def add_nd_vectors( ) -> NDVectors: nd = NDVectors( self.ndw.indices, - self._subplot, + nd_subplot=self, data=data, dims=dims, spatial_dims=spatial_dims, @@ -142,7 +146,7 @@ def add_nd_scatter(self, *args, **kwargs): # TODO: better func signature here, send all kwargs to processor_kwargs nd = NDPositions( self.ndw.indices, - self._subplot, + self, *args, graphic_type=ScatterCollection, **kwargs, @@ -162,7 +166,7 @@ def add_nd_timeseries( ): nd = NDPositions( self.ndw.indices, - self._subplot, + self, *args, graphic_type=graphic_type, linear_selector=True, @@ -177,7 +181,7 @@ def add_nd_timeseries( def add_nd_lines(self, *args, **kwargs): nd = NDPositions( self.ndw.indices, - self._subplot, + self, *args, graphic_type=LineCollection, **kwargs, diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 7f3a5f98e..e63aa564f 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -53,8 +53,8 @@ def __init__(self, figure, size, ndwidget): # loop playback self._loop = {dim: False for dim in ref_ranges.keys()} - # last time the slider was moved, used for throttling - self._last_slider_movement: dict[str, float] = dict() + # last time the slider was moved per dim, used for time-based throttling + self._last_slider_movement: dict[str, float] = {dim: 0.0 for dim in ref_ranges.keys()} # auto-plays the ImageWidget's left-most dimension in docs galleries if "DOCS_BUILD" in os.environ.keys(): @@ -72,7 +72,7 @@ def _set_index(self, dim, index): index = self._ndwidget.ranges[dim].stop self._playing[dim] = False - self._ndwidget.indices[dim] = index + self._ndwidget.indices.set_dim_index(dim, index) def update(self): now = perf_counter() @@ -117,7 +117,7 @@ def update(self): if imgui.button(label=fa.ICON_FA_STOP): self._playing[dim] = False self._last_frame_time[dim] = 0 - self._ndwidget.indices[dim] = rr.start + self._ndwidget.indices.set_dim_index(dim, rr.start) imgui.same_line() # loop checkbox @@ -160,14 +160,9 @@ def update(self): label=f"##{dim}", ) - # TODO: refactor all this stuff, make fully fledged UI if changed: - # apply throttling - if not dim in self._last_slider_movement: - self._last_slider_movement[dim] = 0.0 - if now - self._last_slider_movement[dim] > rr.throttle: - self._ndwidget.indices[dim] = new_index + self._ndwidget.indices.set_dim_index(dim, new_index, cancel_awaiting=True) self._last_slider_movement[dim] = now elif imgui.is_item_hovered(): diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py index d1db1d7d8..23e8cd6e9 100644 --- a/fastplotlib/widgets/nd_widget/_video.py +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -5,7 +5,7 @@ class VideoProcessor(NDImageProcessor): - def get_window_output(self, indices: dict[str, Any]): + async def get_window_output(self, indices: dict[str, Any]): """ Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims @@ -18,7 +18,7 @@ def get_window_output(self, indices: dict[str, Any]): """ # windowed slice if user set any window funcs - windowed_slice = yield from self._get_raw_data_slice(indices) + windowed_slice = await self._get_raw_data_slice(indices) if isinstance(windowed_slice, (tuple, list)): return tuple(a.squeeze() for a in windowed_slice) From c3fe667b575524a60fae372411f0c7e88d718bd0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 30 May 2026 03:47:15 -0700 Subject: [PATCH 116/163] remove __array_ufunc__ from required attrs --- fastplotlib/utils/protocols.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastplotlib/utils/protocols.py b/fastplotlib/utils/protocols.py index 66df15ddd..b13aa5d7e 100644 --- a/fastplotlib/utils/protocols.py +++ b/fastplotlib/utils/protocols.py @@ -6,7 +6,6 @@ ARRAY_LIKE_ATTRS = [ "__array__", - "__array_ufunc__", "dtype", "shape", "ndim", From 9169f433145842f1660527b7d8f601a48176f645 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 30 May 2026 04:07:45 -0700 Subject: [PATCH 117/163] do not require __array__() to be implemented for ndwidget --- fastplotlib/utils/protocols.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/fastplotlib/utils/protocols.py b/fastplotlib/utils/protocols.py index b13aa5d7e..a2bd6c1c0 100644 --- a/fastplotlib/utils/protocols.py +++ b/fastplotlib/utils/protocols.py @@ -5,7 +5,6 @@ ARRAY_LIKE_ATTRS = [ - "__array__", "dtype", "shape", "ndim", @@ -15,10 +14,7 @@ @runtime_checkable class ArrayProtocol(Protocol): - """an object that is sufficiently array-like""" - - def __array__(self) -> ArrayProtocol: ... - + """an object that is sufficiently array-like for lazy loading""" @property def dtype(self) -> Any: ... From 6c9e2b91520aa56b08e5534251367067a79f9c03 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 31 May 2026 04:34:25 -0700 Subject: [PATCH 118/163] auto ref ranges --- fastplotlib/widgets/nd_widget/_index.py | 43 +++++++++--- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 67 ++++++++++++++++++- fastplotlib/widgets/nd_widget/_ndwidget.py | 4 +- fastplotlib/widgets/nd_widget/_ui.py | 43 +++++++++--- 4 files changed, 135 insertions(+), 22 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 4f997c1a5..bba85fa6d 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -114,6 +114,13 @@ def __getitem__(self, index: int): return val +class AutoRangeContinuous(RangeContinuous): + """ + A continuous reference range that was auto-generated for a slider dimension + which had no explicit ``RangeContinuous``. + """ + + @dataclass class RangeDiscrete: # TODO: not implemented yet, placeholder until we have a clear usecase @@ -146,10 +153,12 @@ def __init__( the new indices. Each key in ``ref_ranges`` defines a slider dimension. When adding an - ``NDGraphic``, every dimension listed in ``dims`` must be either a spatial - dimension (listed in ``spatial_dims``) or a key in ``ref_ranges``. - If a dim is not spatial, it must have a corresponding reference range, - otherwise an error will be raised. + ``NDGraphic``, every dimension listed in ``dims`` is either a spatial + dimension (listed in ``spatial_dims``) or a slider dimension. A slider + dim without a reference range gets an ``AutoRangeContinuous`` sized to the + data, so an explicit range is only needed when the slider should map + reference-space units to array indices rather than use a one-to-one + (identity) mapping. You can also define conceptually identical but *independent* reference spaces by using distinct names, ex: ``"time-1"`` and ``"time-2"`` for two subsets of data @@ -195,17 +204,16 @@ def __init__( """ self._ref_ranges = dict() - self.push_dims(ref_ranges) - - # starting index for all dims - self._indices: dict[str, int | float | Any] = { - name: rr.start for name, rr in self._ref_ranges.items() - } - self._indices_changed_handlers = set() + # current index for each dim + self._indices: dict[str, int | float | Any] = dict() self._ndwidgets: list[NDWidget] = list() + self.push_dims(ref_ranges) + + self._indices_changed_handlers = set() + # per-NDGraphic fetch update revision. Bumped on every ``cancel_awaiting=True`` # call (display only latest fetch, used during slider drag). A scheduled fetch # carries the revision it was created under and skips setting graphic data @@ -454,6 +462,19 @@ def push_dims( f"see the docstring, you have passed: {ref_ranges}" ) + rr = self._ref_ranges[name] + if isinstance(rr, AutoRangeContinuous): + self._indices[name] = 0 + elif isinstance(rr, RangeContinuous): + self._indices[name] = rr.start + elif isinstance(rr, RangeDiscrete): + # start at the first option + self._indices[name] = rr.options[0] + + # set imgui UI for each NDWidget window + for ndw in self._ndwidgets: + ndw._sliders_ui.push_dim(name) + def add_event_handler(self, handler: Callable, event: str = "indices"): """ Register an event handler that is called whenever the indices change. diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 469b110b6..6f22daa47 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -1,3 +1,4 @@ +import warnings from collections.abc import Callable from typing import Literal, Sequence, Hashable @@ -14,6 +15,7 @@ from ...layouts import Subplot from ...utils import ArrayProtocol, enums from . import NDImageProcessor, NDImage, NDPositions, NDVectors +from ._index import AutoRangeContinuous from ._video import VideoProcessor from ._base import NDProcessor, NDGraphic, WindowFuncCallable @@ -56,6 +58,45 @@ def __getitem__(self, key): else: raise KeyError(f"NDGraphc with given key not found: {key}") + def _check_slider_dims( + self, + dims: Sequence[Hashable], + spatial_dims: Sequence[Hashable], + data: ArrayProtocol | None, + positions: bool = False, + ): + """ + Make sure every slider (non-spatial) dim of a graphic being added has a + reference range. A dim without one gets an ``AutoRangeContinuous`` sized to + the data, an existing ``AutoRangeContinuous`` is grown to fit, and an + explicit range is left untouched. + """ + if data is None: + # size is unknown, an explicit range is still required + return + + dims = tuple(dims) + slider_dims = set(dims) - set(spatial_dims) + if positions: + # the datapoints `p` axis is a spatial dim that also needs a reference range + slider_dims.add(spatial_dims[1]) + + for dim in slider_dims: + size = data.shape[dims.index(dim)] + + if dim not in self.ndw.indices.dims: + warnings.warn( + f"No reference range specified for non-spatial dim '{dim}', " + f"auto-generating an `AutoRangeContinuous(0, {size}, 1)`." + ) + self.ndw.indices.push_dims({dim: AutoRangeContinuous(0, size, 1)}) + + elif isinstance(self.ndw.indices.ref_ranges[dim], AutoRangeContinuous): + # grow the existing auto range to fit this array + self.ndw.indices.ref_ranges[dim].stop = max( + self.ndw.indices.ref_ranges[dim].stop, size + ) + def add_nd_image( self, data: ArrayProtocol | None, @@ -72,6 +113,8 @@ def add_nd_image( name: str = None, **kwargs, ): + self._check_slider_dims(dims, spatial_dims, data) + nd = NDImage( self.ndw.indices, nd_subplot=self, @@ -125,6 +168,8 @@ def add_nd_vectors( name: str = None, **kwargs ) -> NDVectors: + self._check_slider_dims(dims, spatial_dims, data) + nd = NDVectors( self.ndw.indices, nd_subplot=self, @@ -142,11 +187,16 @@ def add_nd_vectors( self._nd_graphics.append(nd) return nd - def add_nd_scatter(self, *args, **kwargs): + def add_nd_scatter(self, data, dims, spatial_dims, *args, **kwargs): # TODO: better func signature here, send all kwargs to processor_kwargs + self._check_slider_dims(dims, spatial_dims, data, positions=True) + nd = NDPositions( self.ndw.indices, self, + data, + dims, + spatial_dims, *args, graphic_type=ScatterCollection, **kwargs, @@ -157,6 +207,9 @@ def add_nd_scatter(self, *args, **kwargs): def add_nd_timeseries( self, + data, + dims, + spatial_dims, *args, graphic_type: type[ LineCollection | LineStack | ScatterStack | ImageGraphic @@ -164,9 +217,14 @@ def add_nd_timeseries( x_range_mode: Literal["fixed", "auto"] | None = "auto", **kwargs, ): + self._check_slider_dims(dims, spatial_dims, data, positions=True) + nd = NDPositions( self.ndw.indices, self, + data, + dims, + spatial_dims, *args, graphic_type=graphic_type, linear_selector=True, @@ -178,10 +236,15 @@ def add_nd_timeseries( self._nd_graphics.append(nd) return nd - def add_nd_lines(self, *args, **kwargs): + def add_nd_lines(self, data, dims, spatial_dims, *args, **kwargs): + self._check_slider_dims(dims, spatial_dims, data, positions=True) + nd = NDPositions( self.ndw.indices, self, + data, + dims, + spatial_dims, *args, graphic_type=LineCollection, **kwargs, diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py index 9ddfa8986..1804986a1 100644 --- a/fastplotlib/widgets/nd_widget/_ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -9,8 +9,10 @@ class NDWidget: - def __init__(self, ref_ranges: dict[str, tuple], ref_index: Optional[ReferenceIndex] = None, **kwargs): + def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[ReferenceIndex] = None, **kwargs): if ref_index is None: + if ref_ranges is None: + ref_ranges = dict() self._indices = ReferenceIndex(ref_ranges) else: self._indices = ref_index diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index e63aa564f..227aeb5b1 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -36,25 +36,26 @@ def __init__(self, figure, size, ndwidget): ) self._ndwidget = ndwidget - ref_ranges = self._ndwidget.ranges - # whether or not a dimension is in play mode - self._playing = {dim: False for dim in ref_ranges.keys()} + self._playing = dict() # approximate framerate for playing - self._fps = {dim: 20 for dim in ref_ranges.keys()} + self._fps = dict() # framerate converted to frame time - self._frame_time = {dim: 1 / 20 for dim in ref_ranges.keys()} + self._frame_time = dict() # last timepoint that a frame was displayed from a given dimension - self._last_frame_time = {dim: perf_counter() for dim in ref_ranges.keys()} + self._last_frame_time = dict() # loop playback - self._loop = {dim: False for dim in ref_ranges.keys()} + self._loop = dict() # last time the slider was moved per dim, used for time-based throttling - self._last_slider_movement: dict[str, float] = {dim: 0.0 for dim in ref_ranges.keys()} + self._last_slider_movement: dict[str, float] = dict() + + for dim in self._ndwidget.ranges: + self.push_dim(dim) # auto-plays the ImageWidget's left-most dimension in docs galleries if "DOCS_BUILD" in os.environ.keys(): @@ -64,6 +65,24 @@ def __init__(self, figure, size, ndwidget): self._max_display_windows: dict[NDGraphic, float | int] = dict() + def push_dim(self, dim): + """initialize the playback & slider UI state for a newly added dim""" + self._playing[dim] = False + self._fps[dim] = 20 + self._frame_time[dim] = 1 / 20 + self._last_frame_time[dim] = perf_counter() + self._loop[dim] = False + self._last_slider_movement[dim] = 0.0 + + def pop_dim(self, dim): + """remove the playback & slider UI state for a removed dim""" + self._playing.pop(dim) + self._fps.pop(dim) + self._frame_time.pop(dim) + self._last_frame_time.pop(dim) + self._loop.pop(dim) + self._last_slider_movement.pop(dim) + def _set_index(self, dim, index): if index >= self._ndwidget.ranges[dim].stop: if self._loop[dim]: @@ -174,6 +193,14 @@ def update(self): imgui.pop_id() + # auto set imgui window height + if not self._collapsed: + height = round( + imgui.get_cursor_screen_pos().y - self.y + imgui.get_style().window_padding.y + ) + if height != self.size: + self.size = height + class RightClickMenu(StandardRightClickMenu): def __init__(self, figure): From a80884a3b14324407b323b5b6d2b3c80d27ba218 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 14 Jun 2026 19:35:38 -0700 Subject: [PATCH 119/163] fix imgui --- fastplotlib/ui/_base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index eefac02f7..058ee71f3 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -204,6 +204,9 @@ def get_rect(self) -> tuple[int, int, int, int]: return x_pos, y_pos, width, height def _draw_resize_handle(self): + if self._location not in ("bottom", "right"): + return + if self._location == "bottom": imgui.set_cursor_pos((0, 0)) imgui.invisible_button("##resize_handle", imgui.ImVec2(imgui.get_window_width(), self._separator_thickness)) @@ -382,6 +385,7 @@ def draw_window(self): # begin window imgui.begin(self._title, p_open=None, flags=flags) + # resize handle for right and bottom windows self._draw_resize_handle() # push ID to prevent conflict between multiple figs with same UI From 494eebc402eba49bb34c9264ffb3627e905175bd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 Jul 2026 00:13:18 -0400 Subject: [PATCH 120/163] tear out NDTimeseries from NDPositions --- fastplotlib/widgets/__init__.py | 1 + fastplotlib/widgets/nd_widget/__init__.py | 2 +- .../nd_widget/_nd_positions/__init__.py | 1 + .../nd_widget/_nd_positions/_nd_positions.py | 325 +++++------------- .../nd_widget/_nd_positions/_nd_timeseries.py | 309 +++++++++++++++++ fastplotlib/widgets/nd_widget/_ndw_subplot.py | 8 +- fastplotlib/widgets/nd_widget/_ui.py | 27 +- 7 files changed, 418 insertions(+), 255 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index 4347f6c80..d404decf9 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -4,6 +4,7 @@ NDGraphic, NDPositionsProcessor, NDPositions, + NDTimeseries, NDImageProcessor, NDImage, ) diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index d3e92f053..46245d62b 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -3,7 +3,7 @@ if IMGUI: from ._base import NDProcessor, NDGraphic - from ._nd_positions import NDPositions, NDPositionsProcessor, ndp_extras + from ._nd_positions import NDPositions, NDPositionsProcessor, NDTimeseries, ndp_extras from ._nd_image import NDImageProcessor, NDImage from ._video import VideoProcessor from ._nd_vectors import NDVectorsProcessor, NDVectors diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py index 60703f8c2..978a082c6 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py @@ -1,6 +1,7 @@ import importlib from ._nd_positions import NDPositions, NDPositionsProcessor +from ._nd_timeseries import NDTimeseries class Extras: pass diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 9f4108bef..2cc768f52 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Hashable, Sequence from functools import partial -from typing import Literal, Any, Type, TYPE_CHECKING +from typing import Any, Type, TYPE_CHECKING from warnings import warn import numpy as np @@ -10,7 +10,6 @@ from numpy.typing import ArrayLike from ....graphics import ( - ImageGraphic, LineGraphic, LineStack, LineCollection, @@ -19,13 +18,10 @@ ScatterStack, ) from ....graphics.features.utils import parse_colors -from ....graphics.utils import pause_events -from ....graphics.selectors import LinearSelector from .._base import ( NDProcessor, NDGraphic, WindowFuncCallable, - block_indices_ctx, ) from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy from .._index import ReferenceIndex @@ -581,15 +577,12 @@ def __init__( | ScatterGraphic | ScatterCollection | ScatterStack - | ImageGraphic ], processor: type[NDPositionsProcessor] = NDPositionsProcessor, display_window: int = 10, window_funcs: tuple[WindowFuncCallable | None] | None = None, slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, max_display_datapoints: int = 1_000, - linear_selector: bool = False, - x_range_mode: Literal["fixed", "auto"] | None = None, colors: ( Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] ) = None, @@ -604,16 +597,13 @@ def __init__( sizes_each: Sequence[float] = None, # for each individual scatter, shape [l, p] thickness: np.ndarray = None, # for each line, shape [l,] name: str = None, - timeseries: bool = False, graphic_kwargs: dict = None, processor_kwargs: dict = None, ): """ Wraps an :class:`NDPositionsProcessor` and supports four interchangeable graphical representations: ``LineStack``, ``LineCollection``, ``ScatterStack``, - and ``ScatterCollection``, as well as a heatmap view. For timeseries use-cases - it also manages a linear selector and automatically adjusts the view according - to the current x-range of the displayed data. + and ``ScatterCollection``. Parameters ---------- @@ -629,8 +619,6 @@ def __init__( window_funcs slider_dim_transforms max_display_datapoints - linear_selector - x_range_mode colors cmap cmap_each @@ -647,6 +635,73 @@ def __init__( super().__init__(nd_subplot, name) + self.init( + ref_index, + data, + dims, + spatial_dims, + *args, + graphic_type=graphic_type, + processor=processor, + display_window=display_window, + window_funcs=window_funcs, + slider_dim_transforms=slider_dim_transforms, + max_display_datapoints=max_display_datapoints, + colors=colors, + cmap=cmap, + cmap_each=cmap_each, + cmap_transform_each=cmap_transform_each, + markers=markers, + markers_each=markers_each, + sizes=sizes, + sizes_each=sizes_each, + thickness=thickness, + graphic_kwargs=graphic_kwargs, + processor_kwargs=processor_kwargs, + ) + + run_sync(self._create_graphic()) + + def init( + self, + ref_index: ReferenceIndex, + data: Any, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], + *args, + graphic_type: Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ScatterStack + ], + processor: type[NDPositionsProcessor] = NDPositionsProcessor, + display_window: int = 10, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, + max_display_datapoints: int = 1_000, + colors: ( + Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] + ) = None, + cmap: str = None, + cmap_each: Sequence[str] = None, + cmap_transform_each: np.ndarray = None, + markers: np.ndarray = None, + markers_each: Sequence[str] = None, + sizes: np.ndarray = None, + sizes_each: Sequence[float] = None, + thickness: np.ndarray = None, + graphic_kwargs: dict = None, + processor_kwargs: dict = None, + ): + """ + Set up the processor and per-graphic state, i.e. everything except creating the graphic. + + Separated from ``__init__`` so ``NDTimeseries`` can run its own one-time setup + between this and graphic creation. + """ self._ref_index = ref_index if processor_kwargs is None: @@ -683,53 +738,6 @@ def __init__( self._graphic_type = graphic_type - # TODO: I think this is messy af, NDTimeseriesSubclass??? - # display_window = None overrides x_range_mode - if display_window is None: - x_range_mode = None - - self._x_range_mode = None - self._last_x_range: tuple[float, float] | None = None - self.x_range_mode = x_range_mode - - # determine a min display_window for x_range_mode = "auto" - # determines required world space range for 3 datapoints - p_dim = self.processor.spatial_dims[1] - p_range = self._ref_index.ref_ranges[p_dim] - p_map = self.processor.slider_dim_transforms[p_dim] - p_span = p_range.stop - p_range.start - p_mid = p_range.start + p_span / 2 - i = p_map(p_mid) - i_increment = p_map(p_mid + p_range.step) - delta_p = p_range.step / max(1, i_increment - i) - self._min_display_window = 3 * delta_p - - self._timeseries = timeseries - # TODO: I think this is messy af, NDTimeseriesSubclass??? - if self._timeseries: - # makes some assumptions about positional data that apply only to timeseries representations - # probably don't want to maintain aspect - self._nd_subplot.subplot.camera.maintain_aspect = False - - # auto x range modes make no sense for non-timeseries data - self.x_range_mode = x_range_mode - - # make a linear selector only if one does not already exist in this subplot - if linear_selector and "__ndw_manged_linear_selector" not in self._nd_subplot.subplot: - self._linear_selector = LinearSelector( - 0, limits=(-np.inf, np.inf), edge_color="cyan", name="__ndw_manged_linear_selector" - ) - self._linear_selector.add_event_handler( - self._linear_selector_handler, "selection" - ) - self._nd_subplot.subplot.add_graphic(self._linear_selector) - else: - self._linear_selector = None - else: - self._linear_selector = None - - run_sync(self._create_graphic()) - @property def processor(self) -> NDPositionsProcessor: return self._processor @@ -744,10 +752,8 @@ def graphic( | ScatterGraphic | ScatterCollection | ScatterStack - | ImageGraphic | None ): - """LineStack or ImageGraphic for heatmaps""" return self._graphic @property @@ -760,7 +766,6 @@ def graphic_type( | ScatterGraphic | ScatterCollection | ScatterStack - | ImageGraphic ]: return self._graphic_type @@ -787,19 +792,24 @@ def spatial_dims(self, dims: tuple[str, str, str]): def indices(self) -> dict[Hashable, Any]: return {d: self._ref_index[d] for d in self.processor.slider_dims} + async def _get_data_slice(self, indices: dict[str, Any]) -> dict[str, Any]: + return await self.processor.get(indices) + async def _set_indices_(self, indices: dict[str, Any] = None): if self.data is None: return if indices is None: # fetch the latest indices from the ReferenceIndex - # else used passed indices from schedule time + # else use passed indices from schedule time indices = self.indices - new_features = await self.processor.get(indices) - data_slice = new_features["data"] + new_features = await self._get_data_slice(indices) + self._update_graphic(new_features, indices) + self._last_indices = indices - # TODO: set other graphic features, colors, sizes, markers, etc. + def _update_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): + data_slice = new_features["data"] if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): self.graphic.data[:, : data_slice.shape[-1]] = data_slice @@ -837,47 +847,6 @@ async def _set_indices_(self, indices: dict[str, Any] = None): new_features["cmap_transform_each"], ) - elif isinstance(self.graphic, ImageGraphic): - image_data, x0, x_scale = self._create_heatmap_data(data_slice) - self.graphic.data = image_data - self.graphic.offset = (x0, *self.graphic.offset[1:]) - self.graphic.scale = (x_scale, *self.graphic.scale[1:]) - - # TODO: I think this is messy af, NDTimeseriesSubclass??? - # x range of the data - xr_data = data_slice[0, 0, 0], data_slice[0, -1, 0] - - if self.x_range_mode is not None: - # set x_range directly from the display_window, NOT from the xr_data - # this way it doesn't fight with the update_from_view_range() polling - dw = self.processor.display_window - hw = dw / 2 - center = indices[self.processor.spatial_dims[1]] - xr_view = center - hw, center + hw - self._nd_subplot.subplot.x_range = xr_view - # record post-write camera state so the polling animation does not - # mistake our own write for a user pan/zoom on the next tick - self._last_x_range = self._nd_subplot.subplot.x_range - - if self._linear_selector is not None: - with pause_events( - self._linear_selector - ): # we don't want the linear selector change to update the indices - self._linear_selector.limits = xr_data - # linear selector acts on `p` dim - self._linear_selector.selection = indices[ - self.processor.spatial_dims[1] - ] - - self._last_indices = indices - - def _linear_selector_handler(self, ev): - with block_indices_ctx(*self._nd_subplot.nd_graphics): - # block index change in all NDGraphics that are not in the same subplot - self._ref_index.set_dim_index( - self.processor.spatial_dims[1], ev.info["value"] - ) - def _tooltip_handler(self, graphic, pick_info): if isinstance(self.graphic, (LineCollection, ScatterCollection)): # get graphic within the collection @@ -889,7 +858,11 @@ async def _create_graphic(self): if self.data is None: return - new_features = await self.processor.get(self.indices) + new_features = await self._get_data_slice(self.indices) + self._setup_graphic(new_features, self.indices) + + def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): + """Build, configure, and add the graphic for the current slice.""" data_slice = new_features["data"] # store any cmap, sizes, thickness, etc. to assign to new graphic @@ -904,22 +877,11 @@ async def _create_graphic(self): if val is not None: graphic_attrs[attr] = val - if issubclass(self._graphic_type, ImageGraphic): - # `d` dim must only have xy data to be interpreted as a heatmap, xyz can't become a timeseries heatmap - if self.processor.shape[self.processor.spatial_dims[-1]] != 2: - raise ValueError - - image_data, x0, x_scale = self._create_heatmap_data(data_slice) - self._graphic = self._graphic_type( - image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1) - ) - + if issubclass(self._graphic_type, (LineStack, ScatterStack)): + kwargs = {"separation": 0.0, **self._graphic_kwargs} else: - if issubclass(self._graphic_type, (LineStack, ScatterStack)): - kwargs = {"separation": 0.0, **self._graphic_kwargs} - else: - kwargs = self._graphic_kwargs - self._graphic = self._graphic_type(data_slice, **kwargs) + kwargs = self._graphic_kwargs + self._graphic = self._graphic_type(data_slice, **kwargs) for attr in graphic_attrs.keys(): if hasattr(self._graphic, attr): @@ -959,60 +921,6 @@ async def _create_graphic(self): self._nd_subplot.subplot.add_graphic(self._graphic) - # set the initial position and limits of the linear selector - # x range of the data - xr_data = data_slice[0, 0, 0], data_slice[0, -1, 0] - - if self.x_range_mode is not None: - # set the intended view range before figure.show()'s autoscale runs - dw = self.processor.display_window - hw = dw / 2 - center = self.indices[self.processor.spatial_dims[1]] - xr_view = center - hw, center + hw - self.graphic._plot_area.x_range = xr_view - self._last_x_range = self.graphic._plot_area.x_range - - if self._linear_selector is not None: - with pause_events( - self._linear_selector - ): # we don't want the linear selector change to update the indices - self._linear_selector.limits = xr_data - # linear selector acts on `p` dim - self._linear_selector.selection = self.indices[ - self.processor.spatial_dims[1] - ] - - def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: - """return [n_rows, n_cols] shape data from [n_timeseries, n_timepoints, xy] data""" - # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense - # data slice is of shape [n_timeseries, n_timepoints, xy], where xy is x-y coordinates of each timeseries - x = data_slice[0, :, 0] # get x from just the first row - - # check if we need to interpolate - norm = np.linalg.norm(np.diff(np.diff(x))) / x.size - - if norm > 1e-6: - # x is not uniform upto float32 precision, must interpolate - x_uniform = np.linspace(x[0], x[-1], num=x.size) - y_interp = np.empty(shape=data_slice[..., 1].shape, dtype=np.float32) - - # this for loop is actually slightly faster than numpy.apply_along_axis() - for i in range(data_slice.shape[0]): - y_interp[i] = np.interp(x_uniform, x, data_slice[i, :, 1]) - - else: - # x is sufficiently uniform - y_interp = data_slice[..., 1] - - x0 = data_slice[0, 0, 0] - - # assume all x values are the same across all lines - # otherwise a heatmap representation makes no sense anyways - x_stop = x[-1] - x_scale = (x_stop - x0) / data_slice.shape[1] - - return y_interp, x0, x_scale - @property def display_window(self) -> int | float | None: """display window in the reference units for the n_datapoints dim""" @@ -1021,9 +929,6 @@ def display_window(self) -> int | float | None: @display_window.setter def display_window(self, dw: int | float | None): self.processor.display_window = dw - if dw is None: - self.x_range_mode = None - # force re-render run_sync(self._set_indices_()) @@ -1039,62 +944,6 @@ def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]): self.processor.datapoints_window_func = funcs - @property - def x_range_mode(self) -> Literal["fixed", "auto"] | None: - """x-range using a fixed window from the display window, or by polling the camera (auto)""" - return self._x_range_mode - - @x_range_mode.setter - def x_range_mode(self, mode: Literal[None, "fixed", "auto"]): - if mode not in (None, "fixed", "auto"): - raise ValueError( - f"x_range_mode must be None, 'fixed', or 'auto', got: {mode!r}" - ) - if mode == self._x_range_mode: - return - - if self._x_range_mode == "auto": - # old mode was auto - self._nd_subplot.subplot.remove_animation(self._update_from_view_range) - self._last_x_range = None - - if mode == "auto": - # seed so the first tick does not fire spuriously - self._last_x_range = self._nd_subplot.subplot.x_range - self._nd_subplot.subplot.add_animations(self._update_from_view_range) - - self._x_range_mode = mode - - def _update_from_view_range(self): - # update from current x_range if it has changed - if self._graphic is None: - return - - xr = self._nd_subplot.subplot.x_range - if xr == self._last_x_range: - # x_range hasn't changed - return - - self._last_x_range = xr - - new_width = abs(xr[1] - xr[0]) - # make sure width is sufficient for >= 3 datapoints - if new_width < self._min_display_window: - new_width = self._min_display_window - - new_index = (xr[0] + xr[1]) / 2 - - self.processor.display_window = new_width - - # block scheduling an additional async _set_indices_ for ndgraphics in this subplot - with block_indices_ctx(*self._nd_subplot.nd_graphics): - p_dim = self.processor.spatial_dims[1] - self._ref_index.set_dim_index(p_dim, new_index) - - # run this ndgraphic update immediately so graphic data and linear selector are in sync with the - # camera, otherwise you get laggy movement - run_sync(self._set_indices_()) - @property def cmap(self) -> str | None: return self._cmap diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py new file mode 100644 index 000000000..875474174 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Literal, Any, Type, TYPE_CHECKING + +import numpy as np + +from ....graphics import ( + ImageGraphic, + LineGraphic, + LineStack, + LineCollection, + ScatterGraphic, + ScatterCollection, + ScatterStack, +) +from ....graphics.utils import pause_events +from ....graphics.selectors import LinearSelector +from .._base import NDGraphic, WindowFuncCallable, block_indices_ctx +from .._index import ReferenceIndex +from .._async import run_sync +from ._nd_positions import NDPositions, NDPositionsProcessor + +if TYPE_CHECKING: + from .._ndw_subplot import NDWSubplot + + +class NDTimeseries(NDPositions): + def __init__( + self, + ref_index: ReferenceIndex, + nd_subplot: NDWSubplot, + data: Any, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], + *args, + graphic_type: Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ScatterStack + | ImageGraphic + ] = LineStack, + processor: type[NDPositionsProcessor] = NDPositionsProcessor, + display_window: int = 10, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, + max_display_datapoints: int = 1_000, + linear_selector: bool = False, + x_range_mode: Literal["fixed", "auto"] | None = None, + colors: ( + Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] + ) = None, + cmap: str = None, + cmap_each: Sequence[str] = None, + cmap_transform_each: np.ndarray = None, + markers: np.ndarray = None, + markers_each: Sequence[str] = None, + sizes: np.ndarray = None, + sizes_each: Sequence[float] = None, + thickness: np.ndarray = None, + name: str = None, + graphic_kwargs: dict = None, + processor_kwargs: dict = None, + ): + """ + ``NDPositions`` for timeseries data, where the datapoints dim is a time-like x-axis. + + Supports the same ``LineStack`` / ``LineCollection`` / ``ScatterStack`` / + ``ScatterCollection`` representations plus a heatmap (``ImageGraphic``) view, and + additionally manages a linear selector and couples the camera x-range to the current + datapoints position via :attr:`x_range_mode`. + + Parameters are the same as :class:`NDPositions`, plus ``linear_selector`` and + ``x_range_mode``. + """ + # NDGraphic base init, then the shared positional setup. We deliberately do not call + # NDPositions.__init__, since it would create the graphic before the timeseries state + # (linear selector, x_range_mode) exists. + NDGraphic.__init__(self, nd_subplot, name) + + self.init( + ref_index, + data, + dims, + spatial_dims, + *args, + graphic_type=graphic_type, + processor=processor, + display_window=display_window, + window_funcs=window_funcs, + slider_dim_transforms=slider_dim_transforms, + max_display_datapoints=max_display_datapoints, + colors=colors, + cmap=cmap, + cmap_each=cmap_each, + cmap_transform_each=cmap_transform_each, + markers=markers, + markers_each=markers_each, + sizes=sizes, + sizes_each=sizes_each, + thickness=thickness, + graphic_kwargs=graphic_kwargs, + processor_kwargs=processor_kwargs, + ) + + # makes some assumptions about positional data that apply only to timeseries representations + # probably don't want to maintain aspect + self._nd_subplot.subplot.camera.maintain_aspect = False + + # determine a min display_window for x_range_mode = "auto" + # determines required world space range for 3 datapoints + p_dim = self.processor.spatial_dims[1] + p_range = self._ref_index.ref_ranges[p_dim] + p_map = self.processor.slider_dim_transforms[p_dim] + p_span = p_range.stop - p_range.start + p_mid = p_range.start + p_span / 2 + i = p_map(p_mid) + i_increment = p_map(p_mid + p_range.step) + delta_p = p_range.step / max(1, i_increment - i) + self._min_display_window = 3 * delta_p + + # display_window = None overrides x_range_mode + if self.processor.display_window is None: + x_range_mode = None + + self._x_range_mode = None + self._last_x_range: tuple[float, float] | None = None + self.x_range_mode = x_range_mode + + # make a linear selector only if one does not already exist in this subplot + if linear_selector and "__ndw_manged_linear_selector" not in self._nd_subplot.subplot: + self._linear_selector = LinearSelector( + 0, limits=(-np.inf, np.inf), edge_color="cyan", name="__ndw_manged_linear_selector" + ) + self._linear_selector.add_event_handler( + self._linear_selector_handler, "selection" + ) + self._nd_subplot.subplot.add_graphic(self._linear_selector) + else: + self._linear_selector = None + + run_sync(self._create_graphic()) + + def _update_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): + if isinstance(self.graphic, ImageGraphic): + data_slice = new_features["data"] + image_data, x0, x_scale = self._create_heatmap_data(data_slice) + self.graphic.data = image_data + self.graphic.offset = (x0, *self.graphic.offset[1:]) + self.graphic.scale = (x_scale, *self.graphic.scale[1:]) + else: + super()._update_graphic(new_features, indices) + + self._update_view(indices, new_features["data"]) + + def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): + if issubclass(self._graphic_type, ImageGraphic): + data_slice = new_features["data"] + # `d` dim must only have xy data to be interpreted as a heatmap, xyz can't become a timeseries heatmap + if self.processor.shape[self.processor.spatial_dims[-1]] != 2: + raise ValueError + + image_data, x0, x_scale = self._create_heatmap_data(data_slice) + self._graphic = self._graphic_type( + image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1) + ) + if self._cmap is not None: + self._graphic.cmap = self._cmap + self._nd_subplot.subplot.add_graphic(self._graphic) + else: + super()._setup_graphic(new_features, indices) + + self._update_view(indices, new_features["data"]) + + def _update_view(self, indices: dict[str, Any], data_slice: np.ndarray): + """update the camera x-range and linear selector to the current datapoints position.""" + + p_dim = self.processor.spatial_dims[1] + + if self.x_range_mode is not None: + # set x_range directly from the display_window, NOT from the data_slice x-range, + # this way it doesn't fight with the _update_from_view_range() polling + hw = self.processor.display_window / 2 + center = indices[p_dim] + self._nd_subplot.subplot.x_range = center - hw, center + hw + # store new x_range so the auto-polling does not trigger + # an x_range update and yet another view update resulting in jitter + self._last_x_range = self._nd_subplot.subplot.x_range + + if self._linear_selector is not None: + # x range of the data + xr_data = data_slice[0, 0, 0], data_slice[0, -1, 0] + with pause_events( + self._linear_selector + ): # we don't want the linear selector change to update the indices + self._linear_selector.limits = xr_data + # linear selector acts on `p` dim + self._linear_selector.selection = indices[p_dim] + + def _linear_selector_handler(self, ev): + with block_indices_ctx(*self._nd_subplot.nd_graphics): + # block index change in all NDGraphics that are not in the same subplot + self._ref_index.set_dim_index( + self.processor.spatial_dims[1], ev.info["value"] + ) + + def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: + """return [n_rows, n_cols] shape data from [n_timeseries, n_timepoints, xy] data""" + # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense + # data slice is of shape [n_timeseries, n_timepoints, xy], where xy is x-y coordinates of each timeseries + x = data_slice[0, :, 0] # get x from just the first row + + # check if we need to interpolate + norm = np.linalg.norm(np.diff(np.diff(x))) / x.size + + if norm > 1e-6: + # x is not uniform upto float32 precision, must interpolate + x_uniform = np.linspace(x[0], x[-1], num=x.size) + y_interp = np.empty(shape=data_slice[..., 1].shape, dtype=np.float32) + + # this for loop is actually slightly faster than numpy.apply_along_axis() + for i in range(data_slice.shape[0]): + y_interp[i] = np.interp(x_uniform, x, data_slice[i, :, 1]) + + else: + # x is sufficiently uniform + y_interp = data_slice[..., 1] + + x0 = data_slice[0, 0, 0] + + # assume all x values are the same across all lines + # otherwise a heatmap representation makes no sense anyways + x_stop = x[-1] + x_scale = (x_stop - x0) / data_slice.shape[1] + + return y_interp, x0, x_scale + + @property + def display_window(self) -> int | float | None: + """display window in the reference units for the n_datapoints dim""" + return self.processor.display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + self.processor.display_window = dw + if dw is None: + self.x_range_mode = None + + # force re-render + run_sync(self._set_indices_()) + + @property + def x_range_mode(self) -> Literal["fixed", "auto"] | None: + """x-range using a fixed window from the display window, or by polling the camera (auto)""" + return self._x_range_mode + + @x_range_mode.setter + def x_range_mode(self, mode: Literal[None, "fixed", "auto"]): + if mode not in (None, "fixed", "auto"): + raise ValueError( + f"x_range_mode must be None, 'fixed', or 'auto', got: {mode!r}" + ) + if mode == self._x_range_mode: + return + + if self._x_range_mode == "auto": + # old mode was auto + self._nd_subplot.subplot.remove_animation(self._update_from_view_range) + self._last_x_range = None + + if mode == "auto": + # seed so the first tick does not fire spuriously + self._last_x_range = self._nd_subplot.subplot.x_range + self._nd_subplot.subplot.add_animations(self._update_from_view_range) + + self._x_range_mode = mode + + def _update_from_view_range(self): + # update from current x_range if it has changed + if self._graphic is None: + return + + xr = self._nd_subplot.subplot.x_range + if xr == self._last_x_range: + # x_range hasn't changed + return + + self._last_x_range = xr + + new_width = abs(xr[1] - xr[0]) + # make sure width is sufficient for >= 3 datapoints + if new_width < self._min_display_window: + new_width = self._min_display_window + + new_index = (xr[0] + xr[1]) / 2 + + self.processor.display_window = new_width + + # block scheduling an additional async _set_indices_ for ndgraphics in this subplot + with block_indices_ctx(*self._nd_subplot.nd_graphics): + p_dim = self.processor.spatial_dims[1] + self._ref_index.set_dim_index(p_dim, new_index) + + # run this ndgraphic update immediately so graphic data and linear selector are in sync with the + # camera, otherwise you get laggy movement + run_sync(self._set_indices_()) diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 6f22daa47..da3473698 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -10,14 +10,13 @@ LineCollection, LineStack, ImageGraphic, - VectorsGraphic, ) from ...layouts import Subplot from ...utils import ArrayProtocol, enums -from . import NDImageProcessor, NDImage, NDPositions, NDVectors +from . import NDImageProcessor, NDImage, NDPositions, NDTimeseries, NDVectors from ._index import AutoRangeContinuous from ._video import VideoProcessor -from ._base import NDProcessor, NDGraphic, WindowFuncCallable +from ._base import NDGraphic, WindowFuncCallable class NDWSubplot: @@ -219,7 +218,7 @@ def add_nd_timeseries( ): self._check_slider_dims(dims, spatial_dims, data, positions=True) - nd = NDPositions( + nd = NDTimeseries( self.ndw.indices, self, data, @@ -229,7 +228,6 @@ def add_nd_timeseries( graphic_type=graphic_type, linear_selector=True, x_range_mode=x_range_mode, - timeseries=True, **kwargs, ) diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 227aeb5b1..ae9296567 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -17,10 +17,10 @@ from ...ui import EdgeWindow, StandardRightClickMenu from ._index import RangeContinuous from ._base import NDGraphic -from ._nd_positions import NDPositions +from ._nd_positions import NDPositions, NDTimeseries from ._nd_image import NDImage -position_graphic_types = [ScatterCollection, ScatterStack, LineCollection, LineStack, ImageGraphic] +position_graphic_types = [ScatterCollection, ScatterStack, LineCollection, LineStack] class NDWidgetUI(EdgeWindow): @@ -270,7 +270,11 @@ def _draw_nd_image_ui(self, subplot, nd_image: NDImage): nd_image.graphic._material.gamma = new_gamma def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): - for i, cls in enumerate(position_graphic_types): + graphic_types = position_graphic_types + if isinstance(nd_graphic, NDTimeseries): + # heatmap only makes sense for timeseries data + graphic_types = position_graphic_types + [ImageGraphic] + for i, cls in enumerate(graphic_types): if imgui.radio_button(cls.__name__, type(nd_graphic.graphic) is cls): nd_graphic.graphic_type = cls subplot.auto_scale() @@ -308,11 +312,12 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): if changed: nd_graphic.display_window = new - options = [None, "fixed", "auto"] - changed, option = imgui.combo( - "x-range mode", - options.index(nd_graphic.x_range_mode), - [str(o) for o in options], - ) - if changed: - nd_graphic.x_range_mode = options[option] + if isinstance(nd_graphic, NDTimeseries): + options = [None, "fixed", "auto"] + changed, option = imgui.combo( + "x-range mode", + options.index(nd_graphic.x_range_mode), + [str(o) for o in options], + ) + if changed: + nd_graphic.x_range_mode = options[option] From 91faf5b16244c7e44bd1d07828744a71716d3458 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Mon, 27 Jul 2026 15:02:30 -0400 Subject: [PATCH 121/163] imgui refactor (#1066) * start imgui window refactor * update w.r.t. imgui changes * imgui hlut colorbar stuff * better focus logic * imgui popup stuff * update docs * update examples, remove HistogramLUTTool * update examples * imgui menubar example * update docs, tweaks * docs * doc fixes * docs * fix * right click anywhere in colorbar/hlut window --- .gitignore | 1 + .../api/graphic_features/ImageGamma.rst | 35 + docs/source/api/graphic_features/index.rst | 2 +- docs/source/api/graphic_features/tuple.rst | 31 - docs/source/api/graphics/Graphic.rst | 5 +- docs/source/api/graphics/ImageGraphic.rst | 6 +- .../api/graphics/ImageVolumeGraphic.rst | 6 +- docs/source/api/graphics/ImageYUVGraphic.rst | 6 +- docs/source/api/graphics/LineCollection.rst | 5 +- docs/source/api/graphics/LineGraphic.rst | 5 +- docs/source/api/graphics/LineStack.rst | 5 +- docs/source/api/graphics/MeshGraphic.rst | 5 +- docs/source/api/graphics/PolygonGraphic.rst | 5 +- .../source/api/graphics/ScatterCollection.rst | 5 +- docs/source/api/graphics/ScatterGraphic.rst | 5 +- docs/source/api/graphics/ScatterStack.rst | 5 +- docs/source/api/graphics/SurfaceGraphic.rst | 5 +- docs/source/api/graphics/TextGraphic.rst | 5 +- docs/source/api/graphics/VectorsGraphic.rst | 5 +- docs/source/api/layouts/figure.rst | 1 - docs/source/api/layouts/imgui_figure.rst | 13 +- docs/source/api/layouts/subplot.rst | 8 + .../api/selectors/LinearRegionSelector.rst | 5 +- .../api/selectors/LinearRegionSelectors.rst | 5 +- docs/source/api/selectors/LinearSelector.rst | 5 +- docs/source/api/selectors/LinearSelectors.rst | 5 +- .../source/api/selectors/PolygonSelectors.rst | 5 +- .../api/selectors/RectangleSelector.rst | 5 +- .../api/selectors/RectangleSelectors.rst | 5 +- .../api/selectors/SelectorCollection.rst | 5 +- docs/source/api/tools/HistogramLUTTool.rst | 58 - docs/source/api/tools/index.rst | 1 - docs/source/api/ui/BaseGUI.rst | 30 - docs/source/api/ui/EdgeWindow.rst | 38 - docs/source/api/ui/ImguiBase.rst | 30 + docs/source/api/ui/ImguiPopup.rst | 37 + docs/source/api/ui/ImguiWindow.rst | 38 + docs/source/api/ui/Popup.rst | 31 - docs/source/api/ui/Window.rst | 30 - docs/source/api/ui/index.rst | 7 +- docs/source/api/widgets/ImageWidget.rst | 48 - docs/source/api/widgets/index.rst | 1 - docs/source/conf.py | 5 +- docs/source/generate_api.py | 9 +- docs/source/imgui/guide.rst | 270 ++ docs/source/imgui/index.rst | 11 + docs/source/imgui/reference/elements.rst | 3284 +++++++++++++++++ docs/source/imgui/reference/flags.rst | 154 + docs/source/imgui/reference/index.rst | 8 + docs/source/index.rst | 11 + docs/source/user_guide/event_tables.rst | 33 + docs/source/user_guide/guide.rst | 17 +- examples/guis/imgui_append.py | 49 + examples/guis/imgui_basic.py | 26 +- examples/guis/imgui_colorbar.py | 51 + examples/guis/imgui_decorator.py | 43 + examples/guis/imgui_floating.py | 39 + examples/guis/imgui_menu_bar.py | 138 + examples/guis/imgui_right_click.py | 91 + examples/guis/imgui_top.py | 30 +- examples/guis/sine_cosine_funcs.py | 17 +- examples/image_volume/image_volume_4d.py | 10 +- .../image_volume/image_volume_render_modes.py | 62 +- .../image_volume/image_volume_share_buffer.py | 12 +- examples/image_widget/README.rst | 2 - examples/image_widget/image_widget.py | 34 - examples/image_widget/image_widget_grid.py | 41 - .../image_widget/image_widget_single_video.py | 47 - examples/image_widget/image_widget_videos.py | 43 - .../image_widget_viewports_check.py | 35 - examples/misc/buffer_replace_gc.py | 12 +- fastplotlib/__init__.py | 2 +- fastplotlib/graphics/_base.py | 114 +- fastplotlib/graphics/features/__init__.py | 2 + fastplotlib/graphics/features/_image.py | 28 + fastplotlib/graphics/image.py | 85 +- fastplotlib/graphics/image_volume.py | 17 + fastplotlib/layouts/_figure.py | 3 - fastplotlib/layouts/_frame.py | 56 +- fastplotlib/layouts/_graphic_methods_mixin.py | 84 +- fastplotlib/layouts/_imgui_figure.py | 476 ++- fastplotlib/layouts/_subplot.py | 256 +- fastplotlib/tools/__init__.py | 2 - fastplotlib/tools/_histogram_lut.py | 431 --- fastplotlib/ui/__init__.py | 6 +- fastplotlib/ui/_base.py | 479 ++- fastplotlib/ui/_colorbar.py | 635 ++++ fastplotlib/ui/_subplot_toolbar.py | 34 +- fastplotlib/ui/_utils.py | 46 + fastplotlib/ui/right_click_menus/__init__.py | 1 - .../ui/right_click_menus/_colormap_picker.py | 176 - .../ui/right_click_menus/_image_adjust.py | 0 .../ui/right_click_menus/_standard_menu.py | 171 +- fastplotlib/widgets/__init__.py | 3 +- fastplotlib/widgets/nd_widget/_nd_image.py | 40 +- fastplotlib/widgets/nd_widget/_ndwidget.py | 10 +- fastplotlib/widgets/nd_widget/_ui.py | 40 +- scripts/generate_add_graphic_methods.py | 1 + 98 files changed, 6627 insertions(+), 1713 deletions(-) create mode 100644 docs/source/api/graphic_features/ImageGamma.rst delete mode 100644 docs/source/api/graphic_features/tuple.rst delete mode 100644 docs/source/api/tools/HistogramLUTTool.rst delete mode 100644 docs/source/api/ui/BaseGUI.rst delete mode 100644 docs/source/api/ui/EdgeWindow.rst create mode 100644 docs/source/api/ui/ImguiBase.rst create mode 100644 docs/source/api/ui/ImguiPopup.rst create mode 100644 docs/source/api/ui/ImguiWindow.rst delete mode 100644 docs/source/api/ui/Popup.rst delete mode 100644 docs/source/api/ui/Window.rst delete mode 100644 docs/source/api/widgets/ImageWidget.rst create mode 100644 docs/source/imgui/guide.rst create mode 100644 docs/source/imgui/index.rst create mode 100644 docs/source/imgui/reference/elements.rst create mode 100644 docs/source/imgui/reference/flags.rst create mode 100644 docs/source/imgui/reference/index.rst create mode 100644 examples/guis/imgui_append.py create mode 100644 examples/guis/imgui_colorbar.py create mode 100644 examples/guis/imgui_decorator.py create mode 100644 examples/guis/imgui_floating.py create mode 100644 examples/guis/imgui_menu_bar.py create mode 100644 examples/guis/imgui_right_click.py delete mode 100644 examples/image_widget/README.rst delete mode 100644 examples/image_widget/image_widget.py delete mode 100644 examples/image_widget/image_widget_grid.py delete mode 100644 examples/image_widget/image_widget_single_video.py delete mode 100644 examples/image_widget/image_widget_videos.py delete mode 100644 examples/image_widget/image_widget_viewports_check.py delete mode 100644 fastplotlib/tools/_histogram_lut.py create mode 100644 fastplotlib/ui/_colorbar.py create mode 100644 fastplotlib/ui/_utils.py delete mode 100644 fastplotlib/ui/right_click_menus/_colormap_picker.py create mode 100644 fastplotlib/ui/right_click_menus/_image_adjust.py diff --git a/.gitignore b/.gitignore index 950f261c0..e6316728d 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ dmypy.json # diffs from visual regression tests examples/desktop/diffs/*.png docs/source/_gallery/ +docs/source/_imgui_images/ diff --git a/docs/source/api/graphic_features/ImageGamma.rst b/docs/source/api/graphic_features/ImageGamma.rst new file mode 100644 index 000000000..d49347e87 --- /dev/null +++ b/docs/source/api/graphic_features/ImageGamma.rst @@ -0,0 +1,35 @@ +.. _api.ImageGamma: + +ImageGamma +********** + +========== +ImageGamma +========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGamma_api + + ImageGamma + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGamma_api + + ImageGamma.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageGamma_api + + ImageGamma.add_event_handler + ImageGamma.block_events + ImageGamma.clear_event_handlers + ImageGamma.remove_event_handler + ImageGamma.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index db0b52103..b73f4f17c 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -23,8 +23,8 @@ Graphic Features UniformSize TextureArray TextureYUV - tuple ImageCmap + ImageGamma ImageVmin ImageVmax ImageInterpolation diff --git a/docs/source/api/graphic_features/tuple.rst b/docs/source/api/graphic_features/tuple.rst deleted file mode 100644 index 2c0c9c662..000000000 --- a/docs/source/api/graphic_features/tuple.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _api.tuple: - -tuple -***** - -===== -tuple -===== -.. currentmodule:: fastplotlib.graphics.features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: tuple_api - - tuple - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: tuple_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: tuple_api - - tuple.count - tuple.index - diff --git a/docs/source/api/graphics/Graphic.rst b/docs/source/api/graphics/Graphic.rst index b2bf0ddd0..c6f393507 100644 --- a/docs/source/api/graphics/Graphic.rst +++ b/docs/source/api/graphics/Graphic.rst @@ -27,9 +27,9 @@ Properties Graphic.block_handlers Graphic.deleted Graphic.event_handlers + Graphic.imgui_right_click Graphic.name Graphic.offset - Graphic.right_click_menu Graphic.rotation Graphic.scale Graphic.supported_events @@ -44,10 +44,13 @@ Methods Graphic.add_axes Graphic.add_event_handler + Graphic.append_imgui_right_click Graphic.clear_event_handlers Graphic.format_pick_info Graphic.map_model_to_world Graphic.map_world_to_model Graphic.remove_event_handler + Graphic.remove_imgui_right_click Graphic.rotate + Graphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index b95b47907..6190343b8 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -32,10 +32,11 @@ Properties ImageGraphic.data ImageGraphic.deleted ImageGraphic.event_handlers + ImageGraphic.gamma + ImageGraphic.imgui_right_click ImageGraphic.interpolation ImageGraphic.name ImageGraphic.offset - ImageGraphic.right_click_menu ImageGraphic.rotation ImageGraphic.scale ImageGraphic.supported_events @@ -56,11 +57,14 @@ Methods ImageGraphic.add_linear_selector ImageGraphic.add_polygon_selector ImageGraphic.add_rectangle_selector + ImageGraphic.append_imgui_right_click ImageGraphic.clear_event_handlers ImageGraphic.format_pick_info ImageGraphic.map_model_to_world ImageGraphic.map_world_to_model ImageGraphic.remove_event_handler + ImageGraphic.remove_imgui_right_click ImageGraphic.reset_vmin_vmax ImageGraphic.rotate + ImageGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ImageVolumeGraphic.rst b/docs/source/api/graphics/ImageVolumeGraphic.rst index c0465944d..b1f8a8dfb 100644 --- a/docs/source/api/graphics/ImageVolumeGraphic.rst +++ b/docs/source/api/graphics/ImageVolumeGraphic.rst @@ -31,12 +31,13 @@ Properties ImageVolumeGraphic.deleted ImageVolumeGraphic.emissive ImageVolumeGraphic.event_handlers + ImageVolumeGraphic.gamma + ImageVolumeGraphic.imgui_right_click ImageVolumeGraphic.interpolation ImageVolumeGraphic.mode ImageVolumeGraphic.name ImageVolumeGraphic.offset ImageVolumeGraphic.plane - ImageVolumeGraphic.right_click_menu ImageVolumeGraphic.rotation ImageVolumeGraphic.scale ImageVolumeGraphic.shininess @@ -57,11 +58,14 @@ Methods ImageVolumeGraphic.add_axes ImageVolumeGraphic.add_event_handler + ImageVolumeGraphic.append_imgui_right_click ImageVolumeGraphic.clear_event_handlers ImageVolumeGraphic.format_pick_info ImageVolumeGraphic.map_model_to_world ImageVolumeGraphic.map_world_to_model ImageVolumeGraphic.remove_event_handler + ImageVolumeGraphic.remove_imgui_right_click ImageVolumeGraphic.reset_vmin_vmax ImageVolumeGraphic.rotate + ImageVolumeGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ImageYUVGraphic.rst b/docs/source/api/graphics/ImageYUVGraphic.rst index 54c7c3c1a..6db01387c 100644 --- a/docs/source/api/graphics/ImageYUVGraphic.rst +++ b/docs/source/api/graphics/ImageYUVGraphic.rst @@ -33,10 +33,11 @@ Properties ImageYUVGraphic.data ImageYUVGraphic.deleted ImageYUVGraphic.event_handlers + ImageYUVGraphic.gamma + ImageYUVGraphic.imgui_right_click ImageYUVGraphic.interpolation ImageYUVGraphic.name ImageYUVGraphic.offset - ImageYUVGraphic.right_click_menu ImageYUVGraphic.rotation ImageYUVGraphic.scale ImageYUVGraphic.supported_events @@ -57,11 +58,14 @@ Methods ImageYUVGraphic.add_linear_selector ImageYUVGraphic.add_polygon_selector ImageYUVGraphic.add_rectangle_selector + ImageYUVGraphic.append_imgui_right_click ImageYUVGraphic.clear_event_handlers ImageYUVGraphic.format_pick_info ImageYUVGraphic.map_model_to_world ImageYUVGraphic.map_world_to_model ImageYUVGraphic.remove_event_handler + ImageYUVGraphic.remove_imgui_right_click ImageYUVGraphic.reset_vmin_vmax ImageYUVGraphic.rotate + ImageYUVGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index de0a8330c..c9f145d38 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -31,12 +31,12 @@ Properties LineCollection.deleted LineCollection.event_handlers LineCollection.graphics + LineCollection.imgui_right_click LineCollection.metadatas LineCollection.name LineCollection.names LineCollection.offset LineCollection.offsets - LineCollection.right_click_menu LineCollection.rotation LineCollection.rotations LineCollection.scale @@ -59,11 +59,14 @@ Methods LineCollection.add_linear_selector LineCollection.add_polygon_selector LineCollection.add_rectangle_selector + LineCollection.append_imgui_right_click LineCollection.clear_event_handlers LineCollection.format_pick_info LineCollection.map_model_to_world LineCollection.map_world_to_model LineCollection.remove_event_handler LineCollection.remove_graphic + LineCollection.remove_imgui_right_click LineCollection.rotate + LineCollection.set_imgui_right_click diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 834bce0a9..4faf77c5c 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -31,9 +31,9 @@ Properties LineGraphic.data LineGraphic.deleted LineGraphic.event_handlers + LineGraphic.imgui_right_click LineGraphic.name LineGraphic.offset - LineGraphic.right_click_menu LineGraphic.rotation LineGraphic.scale LineGraphic.size_space @@ -54,10 +54,13 @@ Methods LineGraphic.add_linear_selector LineGraphic.add_polygon_selector LineGraphic.add_rectangle_selector + LineGraphic.append_imgui_right_click LineGraphic.clear_event_handlers LineGraphic.format_pick_info LineGraphic.map_model_to_world LineGraphic.map_world_to_model LineGraphic.remove_event_handler + LineGraphic.remove_imgui_right_click LineGraphic.rotate + LineGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index a922b9edc..f2a3f9958 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -31,12 +31,12 @@ Properties LineStack.deleted LineStack.event_handlers LineStack.graphics + LineStack.imgui_right_click LineStack.metadatas LineStack.name LineStack.names LineStack.offset LineStack.offsets - LineStack.right_click_menu LineStack.rotation LineStack.rotations LineStack.scale @@ -59,11 +59,14 @@ Methods LineStack.add_linear_selector LineStack.add_polygon_selector LineStack.add_rectangle_selector + LineStack.append_imgui_right_click LineStack.clear_event_handlers LineStack.format_pick_info LineStack.map_model_to_world LineStack.map_world_to_model LineStack.remove_event_handler LineStack.remove_graphic + LineStack.remove_imgui_right_click LineStack.rotate + LineStack.set_imgui_right_click diff --git a/docs/source/api/graphics/MeshGraphic.rst b/docs/source/api/graphics/MeshGraphic.rst index c2cf895e1..4ed70bf37 100644 --- a/docs/source/api/graphics/MeshGraphic.rst +++ b/docs/source/api/graphics/MeshGraphic.rst @@ -30,6 +30,7 @@ Properties MeshGraphic.colors MeshGraphic.deleted MeshGraphic.event_handlers + MeshGraphic.imgui_right_click MeshGraphic.indices MeshGraphic.mapcoords MeshGraphic.mode @@ -37,7 +38,6 @@ Properties MeshGraphic.offset MeshGraphic.plane MeshGraphic.positions - MeshGraphic.right_click_menu MeshGraphic.rotation MeshGraphic.scale MeshGraphic.supported_events @@ -52,10 +52,13 @@ Methods MeshGraphic.add_axes MeshGraphic.add_event_handler + MeshGraphic.append_imgui_right_click MeshGraphic.clear_event_handlers MeshGraphic.format_pick_info MeshGraphic.map_model_to_world MeshGraphic.map_world_to_model MeshGraphic.remove_event_handler + MeshGraphic.remove_imgui_right_click MeshGraphic.rotate + MeshGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/PolygonGraphic.rst b/docs/source/api/graphics/PolygonGraphic.rst index c52031d67..9045a3e10 100644 --- a/docs/source/api/graphics/PolygonGraphic.rst +++ b/docs/source/api/graphics/PolygonGraphic.rst @@ -31,6 +31,7 @@ Properties PolygonGraphic.data PolygonGraphic.deleted PolygonGraphic.event_handlers + PolygonGraphic.imgui_right_click PolygonGraphic.indices PolygonGraphic.mapcoords PolygonGraphic.mode @@ -38,7 +39,6 @@ Properties PolygonGraphic.offset PolygonGraphic.plane PolygonGraphic.positions - PolygonGraphic.right_click_menu PolygonGraphic.rotation PolygonGraphic.scale PolygonGraphic.supported_events @@ -53,10 +53,13 @@ Methods PolygonGraphic.add_axes PolygonGraphic.add_event_handler + PolygonGraphic.append_imgui_right_click PolygonGraphic.clear_event_handlers PolygonGraphic.format_pick_info PolygonGraphic.map_model_to_world PolygonGraphic.map_world_to_model PolygonGraphic.remove_event_handler + PolygonGraphic.remove_imgui_right_click PolygonGraphic.rotate + PolygonGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ScatterCollection.rst b/docs/source/api/graphics/ScatterCollection.rst index 92fa92a78..f71116948 100644 --- a/docs/source/api/graphics/ScatterCollection.rst +++ b/docs/source/api/graphics/ScatterCollection.rst @@ -31,13 +31,13 @@ Properties ScatterCollection.deleted ScatterCollection.event_handlers ScatterCollection.graphics + ScatterCollection.imgui_right_click ScatterCollection.markers ScatterCollection.metadatas ScatterCollection.name ScatterCollection.names ScatterCollection.offset ScatterCollection.offsets - ScatterCollection.right_click_menu ScatterCollection.rotation ScatterCollection.rotations ScatterCollection.scale @@ -60,11 +60,14 @@ Methods ScatterCollection.add_linear_selector ScatterCollection.add_polygon_selector ScatterCollection.add_rectangle_selector + ScatterCollection.append_imgui_right_click ScatterCollection.clear_event_handlers ScatterCollection.format_pick_info ScatterCollection.map_model_to_world ScatterCollection.map_world_to_model ScatterCollection.remove_event_handler ScatterCollection.remove_graphic + ScatterCollection.remove_imgui_right_click ScatterCollection.rotate + ScatterCollection.set_imgui_right_click diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index 0406fa8cc..c9f988820 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -34,13 +34,13 @@ Properties ScatterGraphic.edge_width ScatterGraphic.event_handlers ScatterGraphic.image + ScatterGraphic.imgui_right_click ScatterGraphic.markers ScatterGraphic.mode ScatterGraphic.name ScatterGraphic.offset ScatterGraphic.point_rotation_mode ScatterGraphic.point_rotations - ScatterGraphic.right_click_menu ScatterGraphic.rotation ScatterGraphic.scale ScatterGraphic.size_space @@ -57,10 +57,13 @@ Methods ScatterGraphic.add_axes ScatterGraphic.add_event_handler + ScatterGraphic.append_imgui_right_click ScatterGraphic.clear_event_handlers ScatterGraphic.format_pick_info ScatterGraphic.map_model_to_world ScatterGraphic.map_world_to_model ScatterGraphic.remove_event_handler + ScatterGraphic.remove_imgui_right_click ScatterGraphic.rotate + ScatterGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ScatterStack.rst b/docs/source/api/graphics/ScatterStack.rst index 22aaa4d5d..ee0d7d679 100644 --- a/docs/source/api/graphics/ScatterStack.rst +++ b/docs/source/api/graphics/ScatterStack.rst @@ -31,13 +31,13 @@ Properties ScatterStack.deleted ScatterStack.event_handlers ScatterStack.graphics + ScatterStack.imgui_right_click ScatterStack.markers ScatterStack.metadatas ScatterStack.name ScatterStack.names ScatterStack.offset ScatterStack.offsets - ScatterStack.right_click_menu ScatterStack.rotation ScatterStack.rotations ScatterStack.scale @@ -62,11 +62,14 @@ Methods ScatterStack.add_linear_selector ScatterStack.add_polygon_selector ScatterStack.add_rectangle_selector + ScatterStack.append_imgui_right_click ScatterStack.clear_event_handlers ScatterStack.format_pick_info ScatterStack.map_model_to_world ScatterStack.map_world_to_model ScatterStack.remove_event_handler ScatterStack.remove_graphic + ScatterStack.remove_imgui_right_click ScatterStack.rotate + ScatterStack.set_imgui_right_click diff --git a/docs/source/api/graphics/SurfaceGraphic.rst b/docs/source/api/graphics/SurfaceGraphic.rst index 2eb32500b..a1088fa81 100644 --- a/docs/source/api/graphics/SurfaceGraphic.rst +++ b/docs/source/api/graphics/SurfaceGraphic.rst @@ -31,6 +31,7 @@ Properties SurfaceGraphic.data SurfaceGraphic.deleted SurfaceGraphic.event_handlers + SurfaceGraphic.imgui_right_click SurfaceGraphic.indices SurfaceGraphic.mapcoords SurfaceGraphic.mode @@ -38,7 +39,6 @@ Properties SurfaceGraphic.offset SurfaceGraphic.plane SurfaceGraphic.positions - SurfaceGraphic.right_click_menu SurfaceGraphic.rotation SurfaceGraphic.scale SurfaceGraphic.supported_events @@ -53,10 +53,13 @@ Methods SurfaceGraphic.add_axes SurfaceGraphic.add_event_handler + SurfaceGraphic.append_imgui_right_click SurfaceGraphic.clear_event_handlers SurfaceGraphic.format_pick_info SurfaceGraphic.map_model_to_world SurfaceGraphic.map_world_to_model SurfaceGraphic.remove_event_handler + SurfaceGraphic.remove_imgui_right_click SurfaceGraphic.rotate + SurfaceGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst index e4deb0113..2260306a7 100644 --- a/docs/source/api/graphics/TextGraphic.rst +++ b/docs/source/api/graphics/TextGraphic.rst @@ -29,11 +29,11 @@ Properties TextGraphic.event_handlers TextGraphic.face_color TextGraphic.font_size + TextGraphic.imgui_right_click TextGraphic.name TextGraphic.offset TextGraphic.outline_color TextGraphic.outline_thickness - TextGraphic.right_click_menu TextGraphic.rotation TextGraphic.scale TextGraphic.supported_events @@ -49,10 +49,13 @@ Methods TextGraphic.add_axes TextGraphic.add_event_handler + TextGraphic.append_imgui_right_click TextGraphic.clear_event_handlers TextGraphic.format_pick_info TextGraphic.map_model_to_world TextGraphic.map_world_to_model TextGraphic.remove_event_handler + TextGraphic.remove_imgui_right_click TextGraphic.rotate + TextGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/VectorsGraphic.rst b/docs/source/api/graphics/VectorsGraphic.rst index 728029851..353e42ada 100644 --- a/docs/source/api/graphics/VectorsGraphic.rst +++ b/docs/source/api/graphics/VectorsGraphic.rst @@ -28,10 +28,10 @@ Properties VectorsGraphic.deleted VectorsGraphic.directions VectorsGraphic.event_handlers + VectorsGraphic.imgui_right_click VectorsGraphic.name VectorsGraphic.offset VectorsGraphic.positions - VectorsGraphic.right_click_menu VectorsGraphic.rotation VectorsGraphic.scale VectorsGraphic.supported_events @@ -46,10 +46,13 @@ Methods VectorsGraphic.add_axes VectorsGraphic.add_event_handler + VectorsGraphic.append_imgui_right_click VectorsGraphic.clear_event_handlers VectorsGraphic.format_pick_info VectorsGraphic.map_model_to_world VectorsGraphic.map_world_to_model VectorsGraphic.remove_event_handler + VectorsGraphic.remove_imgui_right_click VectorsGraphic.rotate + VectorsGraphic.set_imgui_right_click diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst index 54e91b24f..ee7f16eb0 100644 --- a/docs/source/api/layouts/figure.rst +++ b/docs/source/api/layouts/figure.rst @@ -42,7 +42,6 @@ Methods Figure.export Figure.export_numpy Figure.get_pygfx_render_area - Figure.open_popup Figure.remove_animation Figure.remove_subplot Figure.show diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst index fc3471afc..ace763861 100644 --- a/docs/source/api/layouts/imgui_figure.rst +++ b/docs/source/api/layouts/imgui_figure.rst @@ -25,13 +25,13 @@ Properties ImguiFigure.canvas ImguiFigure.controllers ImguiFigure.default_imgui_font - ImguiFigure.guis ImguiFigure.imgui_renderer + ImguiFigure.imgui_right_click + ImguiFigure.imgui_windows ImguiFigure.layout ImguiFigure.names ImguiFigure.renderer ImguiFigure.shape - ImguiFigure.std_right_click_menu Methods ~~~~~~~ @@ -39,17 +39,20 @@ Methods :toctree: ImguiFigure_api ImguiFigure.add_animations - ImguiFigure.add_gui + ImguiFigure.add_imgui_window ImguiFigure.add_subplot + ImguiFigure.append_imgui_right_click + ImguiFigure.append_imgui_window ImguiFigure.clear ImguiFigure.clear_animations ImguiFigure.close ImguiFigure.export ImguiFigure.export_numpy ImguiFigure.get_pygfx_render_area - ImguiFigure.open_popup - ImguiFigure.register_popup ImguiFigure.remove_animation + ImguiFigure.remove_imgui_right_click + ImguiFigure.remove_imgui_window ImguiFigure.remove_subplot + ImguiFigure.set_imgui_right_click ImguiFigure.show diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 994a252fd..09bd14e39 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -31,6 +31,8 @@ Properties Subplot.docks Subplot.frame Subplot.graphics + Subplot.imgui_right_click + Subplot.imgui_windows Subplot.legends Subplot.name Subplot.objects @@ -55,6 +57,7 @@ Methods Subplot.add_image Subplot.add_image_volume Subplot.add_image_yuv + Subplot.add_imgui_window Subplot.add_line Subplot.add_line_collection Subplot.add_line_stack @@ -66,6 +69,8 @@ Methods Subplot.add_surface Subplot.add_text Subplot.add_vectors + Subplot.append_imgui_right_click + Subplot.append_imgui_window Subplot.auto_scale Subplot.center_graphic Subplot.center_scene @@ -79,4 +84,7 @@ Methods Subplot.map_world_to_screen Subplot.remove_animation Subplot.remove_graphic + Subplot.remove_imgui_right_click + Subplot.remove_imgui_window + Subplot.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index 07baa200f..2b781a886 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -30,11 +30,11 @@ Properties LinearRegionSelector.edge_color LinearRegionSelector.event_handlers LinearRegionSelector.fill_color + LinearRegionSelector.imgui_right_click LinearRegionSelector.limits LinearRegionSelector.name LinearRegionSelector.offset LinearRegionSelector.parent - LinearRegionSelector.right_click_menu LinearRegionSelector.rotation LinearRegionSelector.scale LinearRegionSelector.selection @@ -51,6 +51,7 @@ Methods LinearRegionSelector.add_axes LinearRegionSelector.add_event_handler + LinearRegionSelector.append_imgui_right_click LinearRegionSelector.clear_event_handlers LinearRegionSelector.format_pick_info LinearRegionSelector.get_selected_data @@ -59,5 +60,7 @@ Methods LinearRegionSelector.map_model_to_world LinearRegionSelector.map_world_to_model LinearRegionSelector.remove_event_handler + LinearRegionSelector.remove_imgui_right_click LinearRegionSelector.rotate + LinearRegionSelector.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearRegionSelectors.rst b/docs/source/api/selectors/LinearRegionSelectors.rst index 64e5675d4..867e43b9f 100644 --- a/docs/source/api/selectors/LinearRegionSelectors.rst +++ b/docs/source/api/selectors/LinearRegionSelectors.rst @@ -27,9 +27,9 @@ Properties LinearRegionSelectors.block_handlers LinearRegionSelectors.deleted LinearRegionSelectors.event_handlers + LinearRegionSelectors.imgui_right_click LinearRegionSelectors.name LinearRegionSelectors.offset - LinearRegionSelectors.right_click_menu LinearRegionSelectors.rotation LinearRegionSelectors.scale LinearRegionSelectors.selection @@ -46,6 +46,7 @@ Methods LinearRegionSelectors.add_axes LinearRegionSelectors.add_event_handler LinearRegionSelectors.append + LinearRegionSelectors.append_imgui_right_click LinearRegionSelectors.clear LinearRegionSelectors.clear_event_handlers LinearRegionSelectors.format_pick_info @@ -53,5 +54,7 @@ Methods LinearRegionSelectors.map_world_to_model LinearRegionSelectors.remove LinearRegionSelectors.remove_event_handler + LinearRegionSelectors.remove_imgui_right_click LinearRegionSelectors.rotate + LinearRegionSelectors.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index e0e98bc13..eef5a5175 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -30,11 +30,11 @@ Properties LinearSelector.edge_color LinearSelector.event_handlers LinearSelector.fill_color + LinearSelector.imgui_right_click LinearSelector.limits LinearSelector.name LinearSelector.offset LinearSelector.parent - LinearSelector.right_click_menu LinearSelector.rotation LinearSelector.scale LinearSelector.selection @@ -51,6 +51,7 @@ Methods LinearSelector.add_axes LinearSelector.add_event_handler + LinearSelector.append_imgui_right_click LinearSelector.clear_event_handlers LinearSelector.format_pick_info LinearSelector.get_selected_data @@ -59,5 +60,7 @@ Methods LinearSelector.map_model_to_world LinearSelector.map_world_to_model LinearSelector.remove_event_handler + LinearSelector.remove_imgui_right_click LinearSelector.rotate + LinearSelector.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearSelectors.rst b/docs/source/api/selectors/LinearSelectors.rst index 87204d070..f01de0e7c 100644 --- a/docs/source/api/selectors/LinearSelectors.rst +++ b/docs/source/api/selectors/LinearSelectors.rst @@ -27,9 +27,9 @@ Properties LinearSelectors.block_handlers LinearSelectors.deleted LinearSelectors.event_handlers + LinearSelectors.imgui_right_click LinearSelectors.name LinearSelectors.offset - LinearSelectors.right_click_menu LinearSelectors.rotation LinearSelectors.scale LinearSelectors.selection @@ -46,6 +46,7 @@ Methods LinearSelectors.add_axes LinearSelectors.add_event_handler LinearSelectors.append + LinearSelectors.append_imgui_right_click LinearSelectors.clear LinearSelectors.clear_event_handlers LinearSelectors.format_pick_info @@ -53,5 +54,7 @@ Methods LinearSelectors.map_world_to_model LinearSelectors.remove LinearSelectors.remove_event_handler + LinearSelectors.remove_imgui_right_click LinearSelectors.rotate + LinearSelectors.set_imgui_right_click diff --git a/docs/source/api/selectors/PolygonSelectors.rst b/docs/source/api/selectors/PolygonSelectors.rst index b670e8bfd..f0855e78a 100644 --- a/docs/source/api/selectors/PolygonSelectors.rst +++ b/docs/source/api/selectors/PolygonSelectors.rst @@ -27,9 +27,9 @@ Properties PolygonSelectors.block_handlers PolygonSelectors.deleted PolygonSelectors.event_handlers + PolygonSelectors.imgui_right_click PolygonSelectors.name PolygonSelectors.offset - PolygonSelectors.right_click_menu PolygonSelectors.rotation PolygonSelectors.scale PolygonSelectors.selection @@ -46,6 +46,7 @@ Methods PolygonSelectors.add_axes PolygonSelectors.add_event_handler PolygonSelectors.append + PolygonSelectors.append_imgui_right_click PolygonSelectors.clear PolygonSelectors.clear_event_handlers PolygonSelectors.format_pick_info @@ -53,5 +54,7 @@ Methods PolygonSelectors.map_world_to_model PolygonSelectors.remove PolygonSelectors.remove_event_handler + PolygonSelectors.remove_imgui_right_click PolygonSelectors.rotate + PolygonSelectors.set_imgui_right_click diff --git a/docs/source/api/selectors/RectangleSelector.rst b/docs/source/api/selectors/RectangleSelector.rst index a9a8d9fd5..bf75fa7e2 100644 --- a/docs/source/api/selectors/RectangleSelector.rst +++ b/docs/source/api/selectors/RectangleSelector.rst @@ -30,11 +30,11 @@ Properties RectangleSelector.edge_color RectangleSelector.event_handlers RectangleSelector.fill_color + RectangleSelector.imgui_right_click RectangleSelector.limits RectangleSelector.name RectangleSelector.offset RectangleSelector.parent - RectangleSelector.right_click_menu RectangleSelector.rotation RectangleSelector.scale RectangleSelector.selection @@ -51,6 +51,7 @@ Methods RectangleSelector.add_axes RectangleSelector.add_event_handler + RectangleSelector.append_imgui_right_click RectangleSelector.clear_event_handlers RectangleSelector.format_pick_info RectangleSelector.get_selected_data @@ -59,5 +60,7 @@ Methods RectangleSelector.map_model_to_world RectangleSelector.map_world_to_model RectangleSelector.remove_event_handler + RectangleSelector.remove_imgui_right_click RectangleSelector.rotate + RectangleSelector.set_imgui_right_click diff --git a/docs/source/api/selectors/RectangleSelectors.rst b/docs/source/api/selectors/RectangleSelectors.rst index ae9d562c3..b1a7e4e78 100644 --- a/docs/source/api/selectors/RectangleSelectors.rst +++ b/docs/source/api/selectors/RectangleSelectors.rst @@ -27,9 +27,9 @@ Properties RectangleSelectors.block_handlers RectangleSelectors.deleted RectangleSelectors.event_handlers + RectangleSelectors.imgui_right_click RectangleSelectors.name RectangleSelectors.offset - RectangleSelectors.right_click_menu RectangleSelectors.rotation RectangleSelectors.scale RectangleSelectors.selection @@ -46,6 +46,7 @@ Methods RectangleSelectors.add_axes RectangleSelectors.add_event_handler RectangleSelectors.append + RectangleSelectors.append_imgui_right_click RectangleSelectors.clear RectangleSelectors.clear_event_handlers RectangleSelectors.format_pick_info @@ -53,5 +54,7 @@ Methods RectangleSelectors.map_world_to_model RectangleSelectors.remove RectangleSelectors.remove_event_handler + RectangleSelectors.remove_imgui_right_click RectangleSelectors.rotate + RectangleSelectors.set_imgui_right_click diff --git a/docs/source/api/selectors/SelectorCollection.rst b/docs/source/api/selectors/SelectorCollection.rst index 9b4d24929..2b6495ad5 100644 --- a/docs/source/api/selectors/SelectorCollection.rst +++ b/docs/source/api/selectors/SelectorCollection.rst @@ -27,9 +27,9 @@ Properties SelectorCollection.block_handlers SelectorCollection.deleted SelectorCollection.event_handlers + SelectorCollection.imgui_right_click SelectorCollection.name SelectorCollection.offset - SelectorCollection.right_click_menu SelectorCollection.rotation SelectorCollection.scale SelectorCollection.selection @@ -46,6 +46,7 @@ Methods SelectorCollection.add_axes SelectorCollection.add_event_handler SelectorCollection.append + SelectorCollection.append_imgui_right_click SelectorCollection.clear SelectorCollection.clear_event_handlers SelectorCollection.format_pick_info @@ -53,5 +54,7 @@ Methods SelectorCollection.map_world_to_model SelectorCollection.remove SelectorCollection.remove_event_handler + SelectorCollection.remove_imgui_right_click SelectorCollection.rotate + SelectorCollection.set_imgui_right_click diff --git a/docs/source/api/tools/HistogramLUTTool.rst b/docs/source/api/tools/HistogramLUTTool.rst deleted file mode 100644 index d22ca3900..000000000 --- a/docs/source/api/tools/HistogramLUTTool.rst +++ /dev/null @@ -1,58 +0,0 @@ -.. _api.HistogramLUTTool: - -HistogramLUTTool -**************** - -================ -HistogramLUTTool -================ -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HistogramLUTTool_api - - HistogramLUTTool - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HistogramLUTTool_api - - HistogramLUTTool.alpha - HistogramLUTTool.alpha_mode - HistogramLUTTool.axes - HistogramLUTTool.block_events - HistogramLUTTool.block_handlers - HistogramLUTTool.cmap - HistogramLUTTool.deleted - HistogramLUTTool.event_handlers - HistogramLUTTool.histogram - HistogramLUTTool.images - HistogramLUTTool.name - HistogramLUTTool.offset - HistogramLUTTool.right_click_menu - HistogramLUTTool.rotation - HistogramLUTTool.scale - HistogramLUTTool.supported_events - HistogramLUTTool.tooltip_format - HistogramLUTTool.visible - HistogramLUTTool.vmax - HistogramLUTTool.vmin - HistogramLUTTool.world_object - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HistogramLUTTool_api - - HistogramLUTTool.add_axes - HistogramLUTTool.add_event_handler - HistogramLUTTool.clear_event_handlers - HistogramLUTTool.format_pick_info - HistogramLUTTool.map_model_to_world - HistogramLUTTool.map_world_to_model - HistogramLUTTool.remove_event_handler - HistogramLUTTool.rotate - diff --git a/docs/source/api/tools/index.rst b/docs/source/api/tools/index.rst index 2bff8fb50..7a06fd5a0 100644 --- a/docs/source/api/tools/index.rst +++ b/docs/source/api/tools/index.rst @@ -4,7 +4,6 @@ Tools .. toctree:: :maxdepth: 1 - HistogramLUTTool TextBox Tooltip Cursor diff --git a/docs/source/api/ui/BaseGUI.rst b/docs/source/api/ui/BaseGUI.rst deleted file mode 100644 index 788e1414a..000000000 --- a/docs/source/api/ui/BaseGUI.rst +++ /dev/null @@ -1,30 +0,0 @@ -.. _api.BaseGUI: - -BaseGUI -******* - -======= -BaseGUI -======= -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: BaseGUI_api - - BaseGUI - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: BaseGUI_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: BaseGUI_api - - BaseGUI.update - diff --git a/docs/source/api/ui/EdgeWindow.rst b/docs/source/api/ui/EdgeWindow.rst deleted file mode 100644 index 5835ab847..000000000 --- a/docs/source/api/ui/EdgeWindow.rst +++ /dev/null @@ -1,38 +0,0 @@ -.. _api.EdgeWindow: - -EdgeWindow -********** - -========== -EdgeWindow -========== -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: EdgeWindow_api - - EdgeWindow - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: EdgeWindow_api - - EdgeWindow.height - EdgeWindow.location - EdgeWindow.size - EdgeWindow.width - EdgeWindow.x - EdgeWindow.y - -Methods -~~~~~~~ -.. autosummary:: - :toctree: EdgeWindow_api - - EdgeWindow.draw_window - EdgeWindow.get_rect - EdgeWindow.update - diff --git a/docs/source/api/ui/ImguiBase.rst b/docs/source/api/ui/ImguiBase.rst new file mode 100644 index 000000000..078ca67c6 --- /dev/null +++ b/docs/source/api/ui/ImguiBase.rst @@ -0,0 +1,30 @@ +.. _api.ImguiBase: + +ImguiBase +********* + +========= +ImguiBase +========= +.. currentmodule:: fastplotlib.ui + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiBase_api + + ImguiBase + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiBase_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImguiBase_api + + ImguiBase.draw + diff --git a/docs/source/api/ui/ImguiPopup.rst b/docs/source/api/ui/ImguiPopup.rst new file mode 100644 index 000000000..481bccc6a --- /dev/null +++ b/docs/source/api/ui/ImguiPopup.rst @@ -0,0 +1,37 @@ +.. _api.ImguiPopup: + +ImguiPopup +********** + +========== +ImguiPopup +========== +.. currentmodule:: fastplotlib.ui + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiPopup_api + + ImguiPopup + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiPopup_api + + ImguiPopup.graphic + ImguiPopup.is_open + ImguiPopup.parent + ImguiPopup.subplot + ImguiPopup.window_flags + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImguiPopup_api + + ImguiPopup.draw + ImguiPopup.open + ImguiPopup.update + diff --git a/docs/source/api/ui/ImguiWindow.rst b/docs/source/api/ui/ImguiWindow.rst new file mode 100644 index 000000000..b921d299d --- /dev/null +++ b/docs/source/api/ui/ImguiWindow.rst @@ -0,0 +1,38 @@ +.. _api.ImguiWindow: + +ImguiWindow +*********** + +=========== +ImguiWindow +=========== +.. currentmodule:: fastplotlib.ui + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiWindow_api + + ImguiWindow + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiWindow_api + + ImguiWindow.height + ImguiWindow.location + ImguiWindow.size + ImguiWindow.width + ImguiWindow.window_flags + ImguiWindow.x + ImguiWindow.y + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImguiWindow_api + + ImguiWindow.draw + ImguiWindow.update + diff --git a/docs/source/api/ui/Popup.rst b/docs/source/api/ui/Popup.rst deleted file mode 100644 index 5e924db94..000000000 --- a/docs/source/api/ui/Popup.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _api.Popup: - -Popup -***** - -===== -Popup -===== -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Popup_api - - Popup - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Popup_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Popup_api - - Popup.open - Popup.update - diff --git a/docs/source/api/ui/Window.rst b/docs/source/api/ui/Window.rst deleted file mode 100644 index 63c384261..000000000 --- a/docs/source/api/ui/Window.rst +++ /dev/null @@ -1,30 +0,0 @@ -.. _api.Window: - -Window -****** - -====== -Window -====== -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Window_api - - Window - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Window_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Window_api - - Window.update - diff --git a/docs/source/api/ui/index.rst b/docs/source/api/ui/index.rst index 4f31e651a..471d05ad3 100644 --- a/docs/source/api/ui/index.rst +++ b/docs/source/api/ui/index.rst @@ -4,7 +4,6 @@ UI Bases .. toctree:: :maxdepth: 1 - BaseGUI - Window - EdgeWindow - Popup + ImguiBase + ImguiWindow + ImguiPopup diff --git a/docs/source/api/widgets/ImageWidget.rst b/docs/source/api/widgets/ImageWidget.rst deleted file mode 100644 index fbafd4723..000000000 --- a/docs/source/api/widgets/ImageWidget.rst +++ /dev/null @@ -1,48 +0,0 @@ -.. _api.ImageWidget: - -ImageWidget -*********** - -=========== -ImageWidget -=========== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ImageWidget_api - - ImageWidget - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ImageWidget_api - - ImageWidget.cmap - ImageWidget.current_index - ImageWidget.data - ImageWidget.figure - ImageWidget.frame_apply - ImageWidget.managed_graphics - ImageWidget.n_img_dims - ImageWidget.n_scrollable_dims - ImageWidget.ndim - ImageWidget.slider_dims - ImageWidget.window_funcs - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ImageWidget_api - - ImageWidget.add_event_handler - ImageWidget.clear_event_handlers - ImageWidget.close - ImageWidget.remove_event_handler - ImageWidget.reset_vmin_vmax - ImageWidget.reset_vmin_vmax_frame - ImageWidget.set_data - ImageWidget.show - diff --git a/docs/source/api/widgets/index.rst b/docs/source/api/widgets/index.rst index c60b3c485..fbebc87ec 100644 --- a/docs/source/api/widgets/index.rst +++ b/docs/source/api/widgets/index.rst @@ -5,4 +5,3 @@ Widgets :maxdepth: 1 NDWidget - ImageWidget diff --git a/docs/source/conf.py b/docs/source/conf.py index ead9f05c4..0ffecdcc3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,6 +21,7 @@ EXAMPLES_DIR = Path.joinpath(ROOT_DIR, "examples") sys.path.insert(0, str(ROOT_DIR)) +sys.path.insert(0, str(Path(__file__).parent.joinpath("_ext"))) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -42,6 +43,7 @@ "sphinx_copybutton", "sphinx_design", "sphinx_gallery.gen_gallery", + "imgui_docs", ] sphinx_gallery_conf = { @@ -56,7 +58,7 @@ "../../examples/image", "../../examples/image_volume", "../../examples/heatmap", - "../../examples/image_widget", + # "../../examples/image_widget", "../../examples/gridplot", "../../examples/window_layouts", "../../examples/controllers", @@ -69,6 +71,7 @@ "../../examples/events", "../../examples/selection_tools", "../../examples/spaces_transforms", + "../../examples/ndwidget", "../../examples/machine_learning", "../../examples/guis", "../../examples/ipywidgets", diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py index 5ca237f57..5ad6dbb04 100644 --- a/docs/source/generate_api.py +++ b/docs/source/generate_api.py @@ -298,7 +298,12 @@ def main(): ) ############################################################################## # ** GraphicFeature classes ** # - feature_classes = [getattr(features, f) for f in features.__all__] + # `features.__all__` also exports type aliases, such as TupleYUV, which has no docs page + feature_classes = [ + getattr(features, f) + for f in features.__all__ + if inspect.isclass(getattr(features, f)) + ] feature_class_names = [f.__name__ for f in feature_classes] @@ -427,7 +432,7 @@ def main(): ) ############################################################################## # ** UI classes ** # - ui_classes = [ui.BaseGUI, ui.Window, ui.EdgeWindow, ui.Popup] + ui_classes = [ui.ImguiBase, ui.ImguiWindow, ui.ImguiPopup] ui_class_names = [cls.__name__ for cls in ui_classes] diff --git a/docs/source/imgui/guide.rst b/docs/source/imgui/guide.rst new file mode 100644 index 000000000..4ba55bf0d --- /dev/null +++ b/docs/source/imgui/guide.rst @@ -0,0 +1,270 @@ +imgui UIs +========= + +`imgui `_ UIs are rendered directly onto the same canvas as the ``Figure``, so +the same UI code runs on every GUI backend: glfw, Qt, wx, and jupyter. + +imgui support requires ``imgui-bundle``, see the installation section of the user guide. When ``imgui-bundle`` is +installed ``fastplotlib.Figure`` is an ``ImguiFigure``, and every subplot gets a toolbar and a standard right-click +menu. + +There are two things you can add to a ``Figure``: + +* ``ImguiWindow`` - a window drawn within the Figure. It can float over the plots, be fixed to a rect, or occupy space + on an edge of the Figure or of a Subplot. +* ``ImguiPopup`` - a popup opened by a right-click on the Figure, a Subplot, or a Graphic. + +Both are written in the same way, either as a function or as a subclass. + +Floating and fixed windows +-------------------------- + +A floating window is drawn over the plots. imgui sizes it to fit its contents, it appears at the top left of the +canvas, and the user can move, resize, and collapse it. The function draws the imgui elements and is called on every +render, the object it is added to is an optional argument:: + + import numpy as np + import fastplotlib as fpl + from imgui_bundle import imgui + + figure = fpl.Figure(size=(700, 560)) + figure[0, 0].add_line(np.random.rand(100), name="line") + + @figure.add_imgui_window(location="floating") + def gui(fig): + line = fig[0, 0]["line"] + + changed, thickness = imgui.slider_float("thickness", v=line.thickness, v_min=2.0, v_max=50.0) + if changed: + line.thickness = thickness + + if imgui.button("randomize"): + line.data[:, 1] = np.random.rand(100) + +``add_imgui_window`` can also be given the function directly instead of decorating it, which is useful when the same +function is used more than once:: + + figure.add_imgui_window(gui, location="floating") + +A window can instead be fixed to a ``rect`` of the canvas, ``(x, y, width, height)``, or to an ``extent``, +``(xmin, xmax, ymin, ymax)``. These are fractional if the width and height are ``<= 1``, and in pixels otherwise. A +fixed window cannot be moved, resized, or collapsed:: + + @figure.add_imgui_window(extent=(0.6, 0.98, 0.05, 0.25)) + def gui(): + imgui.text("fixed to a fractional extent") + +Figure edge windows +------------------- + +An edge window occupies canvas space along one edge of the Figure, so it never covers the plots. ``location`` is one of +``"left"``, ``"right"``, ``"top"``, ``"bottom"``, and ``size`` is the thickness in pixels, which is required:: + + @figure.add_imgui_window(location="right", size=200, title="controls") + def gui(fig): + ... + +If ``title`` is not given no title bar is drawn. The "bottom" and "right" Figure edge windows can be resized by +dragging their inner border, and collapsed by double-clicking it. + +Subplot edge windows +-------------------- + +You can add imgui windows that are confined to a subplot edge:: + + @figure[0, 0].add_imgui_window(location="right", size=130, title="image") + def gui(subplot): + if imgui.button("noise"): + subplot["image"].data = np.random.rand(128, 128) + +Each subplot also has a toolbar, an imgui window at the ``"toolbar"`` location that you can append elements to:: + + from imgui_bundle import icons_fontawesome_6 as fa + + @figure[0, 0].append_imgui_window(location="toolbar") + def toolbar_extra(subplot): + imgui.same_line() + _, subplot.axes.visible = imgui.checkbox(fa.ICON_FA_RULER_COMBINED, subplot.axes.visible) + +``subplot.toolbar = False`` hides it, and ``add_imgui_window(location="toolbar")`` replaces it. + +Appending, replacing, and removing +---------------------------------- + +Windows are keyed by location, and ``add_imgui_window`` replaces the window at that location. +``append_imgui_window`` adds more UI elements to the window that is already there, it raises if there is none:: + + @figure.append_imgui_window(location="right") + def more(fig): + imgui.text("appended below the elements of the existing window") + +``remove_imgui_window`` removes and returns the window at a location, which can be added again later:: + + window = figure.remove_imgui_window("right") + +``figure.imgui_windows`` and ``subplot.imgui_windows`` return the windows keyed by location. + +Subclassing ``ImguiWindow`` +--------------------------- + +Subclass ``ImguiWindow`` and implement ``update()`` when you need something more complex, such as a UI that keeps +state. Pass what the UI needs into ``__init__``, an instance is not bound to a Figure until it is added:: + + from fastplotlib.ui import ImguiWindow + + class Controls(ImguiWindow): + def __init__(self, line): + super().__init__() + + self._line = line + self._ys = line.data[:, 1].copy() + self._amplitude = 1.0 + + def update(self): + changed, self._amplitude = imgui.slider_float( + "amplitude", v=self._amplitude, v_min=0.1, v_max=10.0 + ) + if changed: + self._line.data[:, 1] = self._ys * self._amplitude + + figure.add_imgui_window(Controls(line), location="right", size=200, title="controls") + +Within ``update()`` the window's pixel rect is available as ``x``, ``y``, ``width``, and ``height``. ``size`` is +settable, and setting it on an edge or toolbar window triggers a re-layout of the Figure. + +``fastplotlib.ui.ChangeFlag`` is useful when several elements modify the same thing. It is a bool that stays ``True`` +once it has been set to ``True``:: + + from fastplotlib.ui import ChangeFlag + + changed = ChangeFlag(False) + changed.value, vmin = imgui.slider_float("vmin", v=image.vmin, v_min=0, v_max=255) + changed.value, vmax = imgui.slider_float("vmax", v=image.vmax, v_min=0, v_max=255) + + if changed: + image.vmin, image.vmax = vmin, vmax + +For full control of the imgui window, override ``draw()`` instead of ``update()``. You are then responsible for +creating the window with ``imgui.begin()`` and ``imgui.end()``, and ``update()`` is not used. This is how you use +window flags that must be set when the window is created, such as ``imgui.WindowFlags_.menu_bar`` for a menu bar, +see :ref:`imgui.WindowFlags_ `. The examples gallery has a menu bar example. + +Right-click popups +------------------ + +A popup is opened by a right-click. It is not restricted to menu items, any imgui elements can be used. + +A popup can be set on the Figure, where it replaces the standard right-click menu, on a Subplot, or on a Graphic. The +most specific one wins: the popup of the graphic under the pointer, else the popup of the subplot that was clicked, +else the popup of the Figure:: + + @figure.set_imgui_right_click() + def popup(fig): + if imgui.menu_item("autoscale all", "", False)[0]: + for subplot in fig: + subplot.auto_scale() + + @figure[0, 1].set_imgui_right_click() + def subplot_popup(subplot): + imgui.text(f"subplot: {subplot.name}") + +A popup takes the object it is set on as an optional argument, and the function can be passed directly instead of +decorating. Each call wraps the function in its own popup, so the same function can be set on any number of graphics:: + + def contrast(image): + changed, vals = imgui.slider_float2("vmin / vmax", (image.vmin, image.vmax), 0, 255) + if changed: + image.vmin, image.vmax = vals + + img1.set_imgui_right_click(contrast) + img2.set_imgui_right_click(contrast) + +Only one popup can be set on an object. A graphic must be added to a subplot of an ``ImguiFigure`` before a popup can +be set on it. ``append_imgui_right_click`` adds more UI elements to the popup that is set, +``remove_imgui_right_click`` removes and returns it, and ``imgui_right_click`` returns the popup that is set. + +Extending the standard right-click menu +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The Figure's popup is a ``StandardRightClickMenu``. Append to it to keep its items and add your own:: + + @figure.append_imgui_right_click() + def extra_items(fig): + imgui.separator() + _, fig.imgui_show_fps = imgui.checkbox("show fps", fig.imgui_show_fps) + +Subclassing ``ImguiPopup`` +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Subclass ``ImguiPopup`` and implement ``update()``, which contains only the imgui elements. ``subplot`` and ``graphic`` +are what the popup was opened on, ``graphic`` is ``None`` if the click was not on a graphic, and ``parent`` is the +object the popup is set on:: + + from fastplotlib.ui import ImguiPopup + + class MyPopup(ImguiPopup): + def update(self): + imgui.text(f"subplot: {self.subplot.name}") + + if imgui.menu_item("autoscale", "", False)[0]: + self.subplot.auto_scale() + + figure.set_imgui_right_click(MyPopup()) + +To keep the standard items, subclass ``StandardRightClickMenu`` and call ``super().update()``:: + + from fastplotlib.ui import StandardRightClickMenu + + class MyMenu(StandardRightClickMenu): + def update(self): + super().update() + + imgui.separator() + if imgui.menu_item("my item", "", False)[0]: + ... + +``window_flags`` can be passed to ``set_imgui_right_click`` and is a settable property, see +:ref:`imgui.WindowFlags_ `. ``is_open`` tells you whether the popup is currently open. + +A window that must stay open after the popup closes cannot be drawn in ``update()``, which only runs while the popup is +open. Override ``draw()`` and draw it after the popup:: + + class MyPopup(ImguiPopup): + def __init__(self): + super().__init__() + self._window_open = False + + def update(self): + if imgui.menu_item("Open window", "", False)[0]: + self._window_open = True + + def draw(self): + super().draw() + + if self._window_open: + _, self._window_open = imgui.begin("my window", True) + imgui.text("stays open after the popup closes") + imgui.end() + +Built-in imgui UIs +------------------ + +* ``SubplotToolbar`` - the toolbar of each subplot. +* ``StandardRightClickMenu`` - the Figure's default right-click popup: fps, autoscale, center, maintain aspect, flip + axes, grids, FOV, and controller options. +* ``ImguiColorbar`` - an ``ImguiWindow`` that shows a colorbar for one or more images, with draggable vmin and vmax, a + colormap picker, gamma, and an optional precomputed histogram:: + + from fastplotlib.ui import ImguiColorbar + + colorbar = ImguiColorbar(images=image, histogram=np.histogram(data, bins=100)) + figure[0, 0].add_imgui_window(colorbar, location="right", size=100) + +Writing imgui elements +---------------------- + +fastplotlib does not wrap imgui, you call ``imgui_bundle`` directly, so any imgui element can be used. The +:doc:`imgui element reference ` documents each element as it exists in ``imgui_bundle``, with +its signature, its arguments, its flags, and an example of what it looks like. + +The ImGUI section of the examples gallery has complete examples. diff --git a/docs/source/imgui/index.rst b/docs/source/imgui/index.rst new file mode 100644 index 000000000..f29f86dbf --- /dev/null +++ b/docs/source/imgui/index.rst @@ -0,0 +1,11 @@ +imgui +***** + +The guide walks you through how to use and integrate imgui with fastplotlib. The reference covers the imgui +elements themselves. + +.. toctree:: + :maxdepth: 2 + + guide + reference/index diff --git a/docs/source/imgui/reference/elements.rst b/docs/source/imgui/reference/elements.rst new file mode 100644 index 000000000..b5c17e1d0 --- /dev/null +++ b/docs/source/imgui/reference/elements.rst @@ -0,0 +1,3284 @@ +Elements +======== + +The imgui elements as they exist in ``imgui_bundle``. Each element is shown with the code that produced its +image, which runs as it is written. See the :doc:`imgui guide ` for adding a UI to a Figure. + +An argument typed ``ImVec2`` or ``ImVec4`` also takes a tuple or a list. + +The examples use ``imgui``, ``icons_fontawesome_6 as fa`` and ``numpy as np``. + +Text +---- + +Text elements are read-only, they display a value that the user cannot edit. + +text +^^^^ + +.. imgui-signature:: text + +**Parameters** + +* ``fmt`` - the text to draw + +.. imgui-example:: + + n_peaks = 137 + + imgui.text(f"peaks found: {n_peaks}") + +text_colored +^^^^^^^^^^^^ + +.. imgui-signature:: text_colored + +**Parameters** + +* ``col`` - text color, ``(r, g, b, a)`` in ``0.0`` to ``1.0`` +* ``fmt`` - the text to draw + +.. imgui-example:: + + vmin, vmax = 180.0, 60.0 + + if vmin > vmax: + imgui.text_colored((1.0, 0.3, 0.3, 1.0), f"{fa.ICON_FA_TRIANGLE_EXCLAMATION} vmin > vmax") + +text_disabled +^^^^^^^^^^^^^ + +.. imgui-signature:: text_disabled + +**Parameters** + +* ``fmt`` - the text to draw + +.. imgui-example:: + + selected = None + + imgui.text("selection:") + imgui.same_line() + + if selected is None: + imgui.text_disabled("none") + else: + imgui.text(selected) + +text_wrapped +^^^^^^^^^^^^ + +.. imgui-signature:: text_wrapped + +**Parameters** + +* ``fmt`` - the text to draw, wrapped at the right edge of the window + +.. imgui-example:: + :width: 220 + + imgui.text_wrapped("the filter runs on the full frame, it can take a few seconds for large images") + +label_text +^^^^^^^^^^ + +.. imgui-signature:: label_text + +**Parameters** + +* ``label`` - drawn to the right of the value, aligned the same way as the label of a slider or an input +* ``fmt`` - the value to draw + +.. imgui-example:: + + data = np.random.randint(0, 4096, (512, 512), dtype=np.uint16) + + imgui.label_text("shape", str(data.shape)) + imgui.label_text("dtype", str(data.dtype)) + imgui.label_text("range", f"{data.min()} - {data.max()}") + +bullet_text +^^^^^^^^^^^ + +.. imgui-signature:: bullet_text + +**Parameters** + +* ``fmt`` - the text to draw after the bullet + +.. imgui-example:: + + imgui.text("controller:") + imgui.bullet_text("left click drag to pan") + imgui.bullet_text("right click drag to zoom") + imgui.bullet_text("scroll to zoom about the cursor") + +separator_text +^^^^^^^^^^^^^^ + +.. imgui-signature:: separator_text + +**Parameters** + +* ``label`` - the text to draw in the separator + +.. imgui-example:: + + thickness, sigma = 4.0, 1.0 + + imgui.separator_text("line") + changed, thickness = imgui.slider_float("thickness", v=thickness, v_min=1.0, v_max=20.0) + + imgui.separator_text("image") + changed, sigma = imgui.slider_float("gaussian sigma", v=sigma, v_min=0.1, v_max=10.0) + +Widgets +------- + +button +^^^^^^ + +.. imgui-signature:: button + +**Parameters** + +* ``label`` - drawn on the button, ``"##hidden"`` suppresses it +* ``size`` - ``(width, height)``, a zero component is sized to the label, a negative one fills the available space + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + if imgui.button("autoscale"): + print("autoscale clicked") + + if imgui.button(fa.ICON_FA_TRASH): + print("trash clicked") + if imgui.is_item_hovered(): + imgui.set_tooltip("remove all graphics") + +small_button +^^^^^^^^^^^^ + +.. imgui-signature:: small_button + +**Parameters** + +* ``label`` - drawn on the button + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + vmin, vmax = 12.0, 208.0 + + imgui.text(f"vmin {vmin:.0f}, vmax {vmax:.0f}") + imgui.same_line() + + if imgui.small_button("reset"): + vmin, vmax = 0.0, 255.0 + +arrow_button +^^^^^^^^^^^^ + +.. imgui-signature:: arrow_button + +**Parameters** + +* ``str_id`` - identifies the button, it is not drawn +* ``dir`` - ``imgui.Dir.left``, ``right``, ``up`` or ``down`` + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + channel, n_channels = 1, 4 + + if imgui.arrow_button("previous", imgui.Dir.left): + channel = max(0, channel - 1) + + imgui.same_line() + imgui.text(f"channel {channel}") + + imgui.same_line() + if imgui.arrow_button("next", imgui.Dir.right): + channel = min(n_channels - 1, channel + 1) + +invisible_button +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: invisible_button + +**Parameters** + +* ``str_id`` - identifies the button, nothing is drawn +* ``size`` - ``(width, height)`` of the area that responds to the pointer + +**Returns:** ``True`` on the frame the button is clicked + +An invisible button gives the pointer behavior of a button to an area that you draw yourself. The pointer is over the +button in the image below, so the bar is drawn in its highlighted color. + +.. imgui-example:: + :interact: hover 40 20 + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + imgui.invisible_button("threshold-bar", (120, 24)) + + color = (1.0, 0.8, 0.2, 1.0) if imgui.is_item_hovered() else (0.4, 0.4, 0.4, 1.0) + draw_list.add_rect_filled( + position, (position.x + 120, position.y + 24), imgui.color_convert_float4_to_u32(color) + ) + +checkbox +^^^^^^^^ + +.. imgui-signature:: checkbox + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``v`` - the current state + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + axes_visible, grid_visible = True, False + + changed, axes_visible = imgui.checkbox("axes", axes_visible) + changed, grid_visible = imgui.checkbox("grid", grid_visible) + +checkbox_flags +^^^^^^^^^^^^^^ + +.. imgui-signature:: checkbox_flags + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``flags`` - the ``int`` that holds the bits +* ``flags_value`` - the bit that this checkbox sets and clears + +**Returns:** ``(changed, flags)`` + +The box is checked when the bit is set, and is drawn filled when ``flags_value`` holds several bits and only some of +them are set. + +.. imgui-example:: + + slider_flags = int(imgui.SliderFlags_.logarithmic) + + changed, slider_flags = imgui.checkbox_flags( + "logarithmic", slider_flags, int(imgui.SliderFlags_.logarithmic) + ) + changed, slider_flags = imgui.checkbox_flags( + "no input", slider_flags, int(imgui.SliderFlags_.no_input) + ) + +radio_button +^^^^^^^^^^^^ + +.. imgui-signature:: radio_button + +**Parameters** + +* ``label`` - drawn to the right of the button +* ``active`` - whether this button is the selected one +* ``v``, ``v_button`` - the variable that holds the selection, and the value of this button + +**Returns:** ``True`` on the frame the button is clicked, or ``(changed, v)`` for the second form + +Use radio buttons for a small number of options that are all worth showing, a combo box is better for a long list. + +.. imgui-example:: + + mode = 1 + + for i, label in enumerate(["line", "scatter", "heatmap"]): + if imgui.radio_button(label, mode == i): + mode = i + +progress_bar +^^^^^^^^^^^^ + +.. imgui-signature:: progress_bar + +**Parameters** + +* ``fraction`` - ``0.0`` to ``1.0`` +* ``size_arg`` - ``(width, height)``, the default fills the available width +* ``overlay`` - text drawn on the bar, the percentage is drawn if it is not given + +.. imgui-example:: + :width: 280 + + n_done, n_frames = 317, 500 + + imgui.progress_bar(n_done / n_frames, overlay=f"{n_done} / {n_frames} frames") + +bullet +^^^^^^ + +.. imgui-signature:: bullet + +**Parameters** + +none + +.. imgui-example:: + + shape = (500, 512, 512) + + imgui.bullet() + imgui.text(f"{shape[0]} frames") + + imgui.bullet() + imgui.text(f"{shape[1]} x {shape[2]} pixels") + +Sliders +------- + +A slider is dragged between a lower and an upper bound. A drag has no bound by default and changes its value by how +far the pointer moves, which suits a value with no natural range. Ctrl+click either of them to type a value instead. + +``format`` is a printf format, it is applied to the value drawn on the element, e.g. ``"%.1f px"``. + +slider_float +^^^^^^^^^^^^ + +.. imgui-signature:: slider_float + +**Parameters** + +* ``label`` - drawn to the right of the slider, ``"##hidden"`` suppresses it +* ``v`` - the current value +* ``v_min``, ``v_max`` - the bounds, the value is clamped to them +* ``format`` - printf format of the value drawn on the slider + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + thickness = 4.0 + + changed, thickness = imgui.slider_float("thickness", v=thickness, v_min=1.0, v_max=20.0) + +slider_float2 +^^^^^^^^^^^^^ + +.. imgui-signature:: slider_float2 + +Two values on one row, sharing one pair of bounds. Pass a list and use the list that comes back. + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to both components +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + vmin_vmax = [12.0, 208.0] + + changed, vmin_vmax = imgui.slider_float2("vmin / vmax", vmin_vmax, 0.0, 255.0, format="%.0f") + +slider_float3 +^^^^^^^^^^^^^ + +.. imgui-signature:: slider_float3 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + spacing = [1.0, 1.0, 3.0] + + changed, spacing = imgui.slider_float3("voxel spacing", spacing, 0.1, 10.0, format="%.2f") + +slider_float4 +^^^^^^^^^^^^^ + +.. imgui-signature:: slider_float4 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + extent = [0.1, 0.9, 0.1, 0.9] + + changed, extent = imgui.slider_float4("extent", extent, 0.0, 1.0, format="%.2f") + +slider_int +^^^^^^^^^^ + +.. imgui-signature:: slider_int + +**Parameters** + +* ``label`` - drawn to the right of the slider +* ``v`` - the current value +* ``v_min``, ``v_max`` - the bounds, the value is clamped to them +* ``format`` - printf format of the value drawn on the slider + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + n_bins = 100 + + changed, n_bins = imgui.slider_int("bins", v=n_bins, v_min=10, v_max=500) + +slider_int2 +^^^^^^^^^^^ + +.. imgui-signature:: slider_int2 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to both components +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + crop = [64, 448] + + changed, crop = imgui.slider_int2("crop rows", crop, 0, 512) + +slider_int3 +^^^^^^^^^^^ + +.. imgui-signature:: slider_int3 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + stride = [1, 2, 2] + + changed, stride = imgui.slider_int3("stride", stride, 1, 8) + +slider_int4 +^^^^^^^^^^^ + +.. imgui-signature:: slider_int4 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + roi = [64, 64, 256, 256] + + changed, roi = imgui.slider_int4("roi", roi, 0, 512) + +slider_angle +^^^^^^^^^^^^ + +.. imgui-signature:: slider_angle + +The value is in radians, the bounds and the value drawn on the slider are in degrees. + +**Parameters** + +* ``label`` - drawn to the right of the slider +* ``v_rad`` - the current angle, in radians +* ``v_degrees_min``, ``v_degrees_max`` - the bounds, in degrees +* ``format`` - printf format of the angle drawn on the slider + +**Returns:** ``(changed, v_rad)`` + +.. imgui-example:: + + rotation = 0.6 + + changed, rotation = imgui.slider_angle("rotation", v_rad=rotation, v_degrees_min=-180, v_degrees_max=180) + +drag_float +^^^^^^^^^^ + +.. imgui-signature:: drag_float + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v`` - the current value +* ``v_speed`` - how much the value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the value drawn on the element + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + sigma = 1.4 + + changed, sigma = imgui.drag_float("gaussian sigma", v=sigma, v_speed=0.05, v_min=0.1, v_max=20.0) + +drag_float2 +^^^^^^^^^^^ + +.. imgui-signature:: drag_float2 + +**Parameters** + +* ``label`` - drawn to the right of the elements +* ``v`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, applied to both components, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the values drawn on the elements + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + origin = [0.0, 0.0] + + changed, origin = imgui.drag_float2("origin", origin, v_speed=0.5) + +drag_float3 +^^^^^^^^^^^ + +.. imgui-signature:: drag_float3 + +**Parameters** + +* ``label`` - drawn to the right of the elements +* ``v`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, applied to every component, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the values drawn on the elements + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + offset = [0.0, 0.0, 0.0] + + changed, offset = imgui.drag_float3("offset", offset, v_speed=0.5) + +drag_float4 +^^^^^^^^^^^ + +.. imgui-signature:: drag_float4 + +**Parameters** + +* ``label`` - drawn to the right of the elements +* ``v`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, applied to every component, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the values drawn on the elements + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + bounds = [0.0, 512.0, 0.0, 512.0] + + changed, bounds = imgui.drag_float4("bounds", bounds, v_speed=1.0, format="%.0f") + +drag_float_range2 +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: drag_float_range2 + +Two values that cannot cross, the lower one is dragged from the left half and the upper one from the right half. + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v_current_min``, ``v_current_max`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the lower value +* ``format_max`` - printf format of the upper value, ``format`` is used for both if it is not given + +**Returns:** ``(changed, v_current_min, v_current_max)`` + +.. imgui-example:: + + vmin, vmax = 12.0, 208.0 + + changed, vmin, vmax = imgui.drag_float_range2( + "vmin / vmax", vmin, vmax, v_speed=1.0, v_min=0.0, v_max=255.0, format="%.0f" + ) + +drag_int +^^^^^^^^ + +.. imgui-signature:: drag_int + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v`` - the current value +* ``v_speed`` - how much the value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the value drawn on the element + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + window = 30 + + changed, window = imgui.drag_int("window size", v=window, v_speed=1.0, v_min=1, v_max=500) + +drag_int_range2 +^^^^^^^^^^^^^^^ + +.. imgui-signature:: drag_int_range2 + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v_current_min``, ``v_current_max`` - the current values, they cannot cross +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the lower value +* ``format_max`` - printf format of the upper value, ``format`` is used for both if it is not given + +**Returns:** ``(changed, v_current_min, v_current_max)`` + +.. imgui-example:: + + first, last = 40, 260 + + changed, first, last = imgui.drag_int_range2("frames", first, last, v_min=0, v_max=500) + +Input +----- + +Input elements are typed into. A slider or a drag is better for a value that is explored by eye, an input is better +for a value that is known. + +input_text +^^^^^^^^^^ + +.. imgui-signature:: input_text + +**Parameters** + +* ``label`` - drawn to the right of the field, ``"##hidden"`` suppresses it +* ``str`` - the current text +* ``callback``, ``user_data`` - an imgui input callback, for completion or filtering + +**Returns:** ``(changed, str)`` - ``changed`` is ``True`` on every keystroke unless +:ref:`imgui.InputTextFlags_ ` asks otherwise + +.. imgui-example:: + :interact: click 60 18; type "a" + + name = "roi-1" + + changed, name = imgui.input_text("graphic name", name) + +input_text_multiline +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: input_text_multiline + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``str`` - the current text +* ``size`` - ``(width, height)`` of the field, a zero component is a default size +* ``callback``, ``user_data`` - an imgui input callback + +**Returns:** ``(changed, str)`` + +.. imgui-example:: + + notes = "frame 42\nsaturated pixels\nrecheck vmax" + + changed, notes = imgui.input_text_multiline("notes", notes, (220, 70)) + +input_text_with_hint +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: input_text_with_hint + +The hint is drawn in the field while it is empty, use it instead of a label when there is no room for one. + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``hint`` - drawn in the field while ``str`` is empty +* ``str`` - the current text +* ``callback``, ``user_data`` - an imgui input callback + +**Returns:** ``(changed, str)`` + +.. imgui-example:: + + pattern = "" + + changed, pattern = imgui.input_text_with_hint("##filter", "filter graphics", pattern) + +input_float +^^^^^^^^^^^ + +.. imgui-signature:: input_float + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``v`` - the current value +* ``step`` - amount the ``-`` and ``+`` buttons change the value by, they are not drawn while it is ``0.0`` +* ``step_fast`` - amount used while ctrl is held +* ``format`` - printf format of the value in the field + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + threshold = 0.75 + + changed, threshold = imgui.input_float("threshold", v=threshold, step=0.05, step_fast=0.5) + +input_float2 +^^^^^^^^^^^^ + +.. imgui-signature:: input_float2 + +Two, three, and four fields on one row. Pass a list and use the list that comes back. + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values +* ``format`` - printf format of the values in the fields + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + pixel_size = [0.325, 0.325] + + changed, pixel_size = imgui.input_float2("pixel size (um)", pixel_size, format="%.3f") + +input_float3 +^^^^^^^^^^^^ + +.. imgui-signature:: input_float3 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values +* ``format`` - printf format of the values in the fields + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + origin = [0.0, 0.0, 0.0] + + changed, origin = imgui.input_float3("origin", origin, format="%.1f") + +input_float4 +^^^^^^^^^^^^ + +.. imgui-signature:: input_float4 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values +* ``format`` - printf format of the values in the fields + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + bounds = [0.0, 512.0, 0.0, 512.0] + + changed, bounds = imgui.input_float4("bounds", bounds, format="%.0f") + +input_int +^^^^^^^^^ + +.. imgui-signature:: input_int + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``v`` - the current value +* ``step`` - amount the ``-`` and ``+`` buttons change the value by +* ``step_fast`` - amount used while ctrl is held + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + n_components = 8 + + changed, n_components = imgui.input_int("components", v=n_components, step=1, step_fast=10) + +input_int2 +^^^^^^^^^^ + +.. imgui-signature:: input_int2 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + shape = [512, 512] + + changed, shape = imgui.input_int2("output shape", shape) + +input_int3 +^^^^^^^^^^ + +.. imgui-signature:: input_int3 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + chunks = [1, 256, 256] + + changed, chunks = imgui.input_int3("chunks", chunks) + +input_int4 +^^^^^^^^^^ + +.. imgui-signature:: input_int4 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + roi = [64, 64, 256, 256] + + changed, roi = imgui.input_int4("roi", roi) + +input_double +^^^^^^^^^^^^ + +.. imgui-signature:: input_double + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``v`` - the current value +* ``step`` - amount the ``-`` and ``+`` buttons change the value by, they are not drawn while it is ``0.0`` +* ``step_fast`` - amount used while ctrl is held +* ``format`` - printf format of the value in the field + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + :width: 260 + + exposure = 0.008 + + changed, exposure = imgui.input_double("exposure (s)", v=exposure, step=0.001, format="%.4f") + +Selection +--------- + +combo +^^^^^ + +.. imgui-signature:: combo + +**Parameters** + +* ``label`` - drawn to the right of the box, ``"##hidden"`` suppresses it +* ``current_item`` - index of the selected item +* ``items`` - the items, as a sequence of strings +* ``popup_max_height_in_items`` - how many items the open list shows before it scrolls + +**Returns:** ``(changed, current_item)`` + +.. imgui-example:: + + mode, modes = 1, ["mip", "minip", "iso", "slice"] + + changed, mode = imgui.combo("render mode", mode, modes) + +The list is drawn while the box is open: + +.. imgui-example:: + :name: combo_open + :interact: click 60 18 + + mode, modes = 1, ["mip", "minip", "iso", "slice"] + + changed, mode = imgui.combo("render mode", mode, modes) + +begin_combo +^^^^^^^^^^^ + +.. imgui-signature:: begin_combo + +Use these instead of ``combo`` when the items are not plain strings, the body draws whatever it likes. Call +``end_combo`` only when ``begin_combo`` returned ``True``. + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``preview_value`` - drawn in the box while it is closed + +.. imgui-example:: + :name: begin_combo + :interact: click 60 18 + + selected, graphics = "line-1", ["line-1", "line-2", "scatter-1"] + + if imgui.begin_combo("graphic", selected): + for name in graphics: + clicked, _ = imgui.selectable(name, name == selected) + if clicked: + selected = name + + imgui.end_combo() + +end_combo +^^^^^^^^^ + +.. imgui-signature:: end_combo + +Call it only when the matching ``begin_combo`` returned ``True``. + +**Parameters** + +none + +list_box +^^^^^^^^ + +.. imgui-signature:: list_box + +A list box shows several items at once, a combo box hides them until it is opened. + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``current_item`` - index of the selected item +* ``items`` - the items, as a sequence of strings +* ``height_in_items`` - how many items are visible before the box scrolls + +**Returns:** ``(changed, current_item)`` + +.. imgui-example:: + + selected, graphics = 0, ["line-1", "line-2", "scatter-1", "image-1"] + + changed, selected = imgui.list_box("graphics", selected, graphics, height_in_items=4) + +begin_list_box +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_list_box + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``size`` - ``(width, height)``, a zero component is a default size + +.. imgui-example:: + :name: begin_list_box + + selected, graphics = "line-1", ["line-1", "line-2", "scatter-1"] + + if imgui.begin_list_box("graphics", (160, 70)): + for name in graphics: + clicked, _ = imgui.selectable(name, name == selected) + if clicked: + selected = name + + imgui.end_list_box() + +end_list_box +^^^^^^^^^^^^ + +.. imgui-signature:: end_list_box + +Call it only when the matching ``begin_list_box`` returned ``True``. + +**Parameters** + +none + +selectable +^^^^^^^^^^ + +.. imgui-signature:: selectable + +A row of text that can be selected, and the item to build lists out of. + +**Parameters** + +* ``label`` - drawn in the row +* ``p_selected`` - whether this row is drawn as selected +* ``size`` - ``(width, height)``, a zero component fills the available width + +**Returns:** ``(clicked, p_selected)`` + +.. imgui-example:: + + selected = "scatter-1" + + for name in ["line-1", "line-2", "scatter-1"]: + clicked, _ = imgui.selectable(name, name == selected) + if clicked: + selected = name + +Color +----- + +A color is a list of floats in ``0.0`` to ``1.0``, three of them for RGB and four for RGBA. The ``3`` and ``4`` +variants differ only in whether they include alpha. + +color_edit3 +^^^^^^^^^^^ + +.. imgui-signature:: color_edit3 + +A row of numeric fields with a color square at its right end. Clicking the square opens a picker, right-clicking it +opens a menu of display options. + +**Parameters** + +* ``label`` - drawn to the right of the fields, ``"##hidden"`` suppresses it +* ``col`` - the current color + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.9, 0.3, 0.2] + + changed, color = imgui.color_edit3("line color", color) + +color_edit4 +^^^^^^^^^^^ + +.. imgui-signature:: color_edit4 + +``color_edit3`` with an alpha field. + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``col`` - the current color + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.9, 0.3, 0.2, 0.5] + + changed, color = imgui.color_edit4("fill color", color) + +color_picker3 +^^^^^^^^^^^^^ + +.. imgui-signature:: color_picker3 + +The full picker, drawn inline. ``color_edit3`` is the compact element and opens this in a popup when its square is +clicked. + +**Parameters** + +* ``label`` - drawn above the picker +* ``col`` - the current color + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.2, 0.6, 0.95] + + changed, color = imgui.color_picker3("##picker", color) + +color_picker4 +^^^^^^^^^^^^^ + +.. imgui-signature:: color_picker4 + +``color_picker3`` with an alpha bar. + +**Parameters** + +* ``label`` - drawn to the right of the picker +* ``col`` - the current color +* ``ref_col`` - a second color drawn beside the current one, to compare against + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.2, 0.6, 0.95, 0.7] + + changed, color = imgui.color_picker4("##picker4", color) + +color_button +^^^^^^^^^^^^ + +.. imgui-signature:: color_button + +**Parameters** + +* ``desc_id`` - identifies the button, and is shown in its tooltip +* ``col`` - the color to draw, ``(r, g, b, a)`` +* ``size`` - ``(width, height)``, a zero component is a square the height of one row + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + for name, color in [("magenta", (1.0, 0.0, 1.0, 1.0)), ("cyan", (0.0, 1.0, 1.0, 1.0))]: + if imgui.color_button(name, color, size=(40, 20)): + print(f"{name} clicked") + + imgui.same_line() + imgui.text(name) + +set_color_edit_options +^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_color_edit_options + +Sets the defaults for every color element that follows, so each one does not have to pass the same flags. Call it once +when the UI is created. + +**Parameters** + +* ``flags`` - the options to apply + +.. imgui-example:: + + imgui.set_color_edit_options(int(imgui.ColorEditFlags_.float) | int(imgui.ColorEditFlags_.display_hsv)) + + color = [0.9, 0.3, 0.2] + changed, color = imgui.color_edit3("line color", color) + +Trees and tabs +-------------- + +tree_node +^^^^^^^^^ + +.. imgui-signature:: tree_node + +Returns ``True`` while the node is open, in which case its contents are drawn and ``tree_pop`` must be called. The +node is opened and closed by the user, clicking the arrow. + +**Parameters** + +* ``label`` - drawn next to the arrow, and used as the id +* ``str_id``, ``ptr_id`` - an id given separately, for when the label is not unique or changes between frames +* ``fmt`` - the text to draw when an id is given separately + +.. imgui-example:: + :interact: click 20 18 + + if imgui.tree_node("image-1"): + imgui.text("512 x 512, uint16") + imgui.text("vmin 12, vmax 208") + imgui.tree_pop() + +tree_node_ex +^^^^^^^^^^^^ + +.. imgui-signature:: tree_node_ex + +``tree_node`` with flags, e.g. to have the node start open, or to draw it without an arrow. + +**Parameters** + +* ``label`` - drawn next to the arrow, and used as the id +* ``str_id``, ``ptr_id`` - an id given separately +* ``fmt`` - the text to draw when an id is given separately + +.. imgui-example:: + + if imgui.tree_node_ex("image-1", flags=imgui.TreeNodeFlags_.default_open): + imgui.text("512 x 512, uint16") + imgui.tree_pop() + +tree_pop +^^^^^^^^ + +.. imgui-signature:: tree_pop + +**Parameters** + +none + +collapsing_header +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: collapsing_header + +A header that shows and hides a section. Unlike a tree node it does not indent its contents and needs no +``tree_pop``, which makes it the element for grouping controls. + +**Parameters** + +* ``label`` - drawn in the header +* ``p_visible`` - when given, a close button is drawn and this is set to ``False`` when it is clicked + +**Returns:** ``True`` while the header is open, or ``(open, p_visible)`` for the second form + +.. imgui-example:: + + sigma = 1.4 + + if imgui.collapsing_header("filter", flags=imgui.TreeNodeFlags_.default_open): + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + + if imgui.collapsing_header("export"): + imgui.text("not shown while the header is closed") + +set_next_item_open +^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_item_open + +Opens or closes the next tree node or collapsing header from code, rather than waiting for the user to click it. + +**Parameters** + +* ``is_open`` - the state to set +* ``cond`` - an ``imgui.Cond_`` value, e.g. ``once`` to set it only the first time + +.. imgui-example:: + + imgui.set_next_item_open(True, imgui.Cond_.once) + + if imgui.tree_node("image-1"): + imgui.text("open because set_next_item_open was called") + imgui.tree_pop() + +begin_tab_bar +^^^^^^^^^^^^^ + +.. imgui-signature:: begin_tab_bar + +**Parameters** + +* ``str_id`` - identifies the tab bar, it is not drawn + +.. imgui-example:: + :name: begin_tab_bar + + if imgui.begin_tab_bar("panels"): + if imgui.begin_tab_item("image")[0]: + imgui.text("512 x 512, uint16") + imgui.end_tab_item() + + if imgui.begin_tab_item("filter")[0]: + imgui.text("gaussian, sigma 1.4") + imgui.end_tab_item() + + imgui.end_tab_bar() + +end_tab_bar +^^^^^^^^^^^ + +.. imgui-signature:: end_tab_bar + +Call it only when the matching ``begin_tab_bar`` returned ``True``. + +**Parameters** + +none + +begin_tab_item +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_tab_item + +**Parameters** + +* ``label`` - drawn on the tab +* ``p_open`` - when given, a close button is drawn on the tab and this is set to ``False`` when it is clicked + +**Returns:** ``(selected, p_open)``, draw the contents and call ``end_tab_item`` while ``selected`` + +.. imgui-example:: + :name: begin_tab_item + :interact: click 90 22 + + if imgui.begin_tab_bar("panels"): + for label in ["image", "filter", "export"]: + selected, _ = imgui.begin_tab_item(label) + if selected: + imgui.text(f"{label} panel") + imgui.end_tab_item() + + imgui.end_tab_bar() + +end_tab_item +^^^^^^^^^^^^ + +.. imgui-signature:: end_tab_item + +Call it only when the matching ``begin_tab_item`` returned ``True``. + +**Parameters** + +none + +tab_item_button +^^^^^^^^^^^^^^^ + +.. imgui-signature:: tab_item_button + +**Parameters** + +* ``label`` - drawn on the tab + +**Returns:** ``True`` on the frame the tab is clicked + +.. imgui-example:: + + if imgui.begin_tab_bar("panels"): + if imgui.begin_tab_item("image")[0]: + imgui.end_tab_item() + + if imgui.tab_item_button("+"): + print("add panel") + + imgui.end_tab_bar() + +Menus +----- + +A menu bar belongs to a window, so the window has to be created with ``imgui.WindowFlags_.menu_bar``. + +begin_menu_bar +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_menu_bar + +**Parameters** + +none + +.. imgui-example:: + :name: begin_menu_bar + :window: none + :interact: click 30 22 + + imgui.set_next_window_pos((0, 0)) + imgui.set_next_window_size((220, 120)) + imgui.begin("controls", flags=imgui.WindowFlags_.menu_bar) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("File"): + imgui.menu_item("Open", "Ctrl+O", False) + imgui.menu_item("Save", "Ctrl+S", False) + imgui.end_menu() + + imgui.end_menu_bar() + + imgui.end() + +end_menu_bar +^^^^^^^^^^^^ + +.. imgui-signature:: end_menu_bar + +Call it only when the matching ``begin_menu_bar`` returned ``True``. + +**Parameters** + +none + +begin_main_menu_bar +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_main_menu_bar + +A bar pinned across the top of the canvas, it is not part of any window. + +**Parameters** + +none + +.. imgui-example:: + :name: begin_main_menu_bar + :window: none + :size: 260, 90 + :interact: click 60 10 + + if imgui.begin_main_menu_bar(): + if imgui.begin_menu("File"): + imgui.menu_item("Open", "Ctrl+O", False) + imgui.end_menu() + + if imgui.begin_menu("Help"): + imgui.menu_item("Version", "", False) + imgui.end_menu() + + imgui.end_main_menu_bar() + +end_main_menu_bar +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: end_main_menu_bar + +Call it only when the matching ``begin_main_menu_bar`` returned ``True``. + +**Parameters** + +none + +begin_menu +^^^^^^^^^^ + +.. imgui-signature:: begin_menu + +Returns ``True`` while the menu is open, in which case its items are drawn and ``end_menu`` must be called. A +``begin_menu`` inside another one is a submenu. + +**Parameters** + +* ``label`` - drawn on the menu +* ``enabled`` - a disabled menu is drawn greyed out and cannot be opened + +.. imgui-example:: + :name: begin_menu + :window: none + :size: 300, 140 + :interact: click 30 22; hover 45 66 + + imgui.set_next_window_pos((0, 0)) + imgui.set_next_window_size((240, 130)) + imgui.begin("controls", flags=imgui.WindowFlags_.menu_bar) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("Graphics"): + imgui.menu_item("Add line", "", False) + + if imgui.begin_menu("Add image"): + imgui.menu_item("from file", "", False) + imgui.menu_item("from array", "", False) + imgui.end_menu() + + imgui.end_menu() + + imgui.end_menu_bar() + + imgui.end() + +end_menu +^^^^^^^^ + +.. imgui-signature:: end_menu + +Call it only when the matching ``begin_menu`` returned ``True``. + +**Parameters** + +none + +menu_item +^^^^^^^^^ + +.. imgui-signature:: menu_item + +**Parameters** + +* ``label`` - drawn on the item +* ``shortcut`` - drawn right-aligned on the item, it is a label only and does not bind the key +* ``p_selected`` - when ``True`` a check mark is drawn, pass it a bool to make the item a toggle +* ``enabled`` - a disabled item is drawn greyed out and cannot be clicked + +**Returns:** ``(clicked, p_selected)`` + +.. imgui-example:: + :window: none + :interact: click 30 22 + + show_fps = True + + imgui.set_next_window_pos((0, 0)) + imgui.set_next_window_size((230, 120)) + imgui.begin("controls", flags=imgui.WindowFlags_.menu_bar) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("View"): + clicked, show_fps = imgui.menu_item("Show fps", "", show_fps) + imgui.menu_item("Autoscale", "A", False) + imgui.menu_item("Reset camera", "", False, enabled=False) + imgui.end_menu() + + imgui.end_menu_bar() + + imgui.end() + +Popups and tooltips +------------------- + +A popup is opened by ``open_popup`` and drawn by ``begin_popup``, which returns ``True`` only while it is open. Both +have to be called for the same window, so calling ``open_popup`` from inside a menu does not open a popup that +``begin_popup`` draws outside of it. + +open_popup +^^^^^^^^^^ + +.. imgui-signature:: open_popup + +**Parameters** + +* ``str_id`` - identifies the popup, ``begin_popup`` is called with the same id +* ``id_`` - an integer id instead of a string one +* ``popup_flags`` - options such as not opening over a popup that is already open + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("options"): + imgui.open_popup("options") + + if imgui.begin_popup("options"): + imgui.menu_item("reset vmin / vmax", "", False) + imgui.menu_item("reset gamma", "", False) + imgui.end_popup() + +begin_popup +^^^^^^^^^^^ + +.. imgui-signature:: begin_popup + +Call ``end_popup`` only when ``begin_popup`` returned ``True``. The popup closes when the user clicks outside it, or +when a menu item inside it is clicked. + +**Parameters** + +* ``str_id`` - the id that ``open_popup`` was called with + +.. imgui-example:: + :name: begin_popup + :interact: click 30 18 + + sigma = 1.4 + + if imgui.button("filter"): + imgui.open_popup("filter") + + if imgui.begin_popup("filter"): + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.end_popup() + +end_popup +^^^^^^^^^ + +.. imgui-signature:: end_popup + +Call it only when the matching ``begin_popup`` returned ``True``. + +**Parameters** + +none + +begin_popup_modal +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_popup_modal + +A modal has a title bar and blocks everything behind it until it is closed. Passing ``p_open`` draws a close button in +its title bar. + +**Parameters** + +* ``name`` - the id that ``open_popup`` was called with, and the title +* ``p_open`` - when given, a close button is drawn and imgui closes the modal when it is clicked + +**Returns:** ``(open, p_open)`` + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("about"): + imgui.open_popup("About") + + if imgui.begin_popup_modal("About", True)[0]: + imgui.text("fastplotlib") + imgui.end_popup() + +close_current_popup +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: close_current_popup + +Closes the popup being drawn, for a control that should dismiss it. A menu item already does this on its own. + +**Parameters** + +none + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("options"): + imgui.open_popup("options") + + if imgui.begin_popup("options"): + imgui.text("apply the filter to every frame?") + + if imgui.button("cancel"): + imgui.close_current_popup() + + imgui.end_popup() + +begin_popup_context_item +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_popup_context_item + +Opens on a right-click on the element that precedes it, so a right-click menu needs no ``open_popup`` of its own. + +**Parameters** + +* ``str_id`` - identifies the popup, the preceding element is used when it is not given +* ``popup_flags`` - which mouse button opens it, right by default + +.. imgui-example:: + :interact: right_click 40 18 + + imgui.button("line-1") + + if imgui.begin_popup_context_item(): + imgui.menu_item("hide", "", False) + imgui.menu_item("delete", "", False) + imgui.end_popup() + +begin_popup_context_window +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_popup_context_window + +Opens on a right-click anywhere in the window that is not over an element. + +**Parameters** + +* ``str_id`` - identifies the popup +* ``popup_flags`` - which mouse button opens it, right by default + +.. imgui-example:: + :width: 180 + :interact: right_click 120 40 + + imgui.text("right click the window") + + if imgui.begin_popup_context_window(): + imgui.menu_item("add line", "", False) + imgui.menu_item("add image", "", False) + imgui.end_popup() + +is_popup_open +^^^^^^^^^^^^^ + +.. imgui-signature:: is_popup_open + +**Parameters** + +* ``str_id`` - the id the popup was opened with +* ``flags`` - use ``imgui.PopupFlags_.any_popup_id`` to ask about any popup + +**Returns:** ``True`` while the popup is open + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("options"): + imgui.open_popup("options") + + imgui.same_line() + imgui.text(f"open: {imgui.is_popup_open('options')}") + + if imgui.begin_popup("options"): + imgui.menu_item("reset", "", False) + imgui.end_popup() + +set_tooltip +^^^^^^^^^^^ + +.. imgui-signature:: set_tooltip + +**Parameters** + +* ``fmt`` - the text to draw in the tooltip + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button(fa.ICON_FA_MAXIMIZE) + + if imgui.is_item_hovered(): + imgui.set_tooltip("autoscale scene") + +set_item_tooltip +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_item_tooltip + +The same as ``set_tooltip`` behind an ``is_item_hovered`` check, for the common case of a tooltip on the element that +precedes it. + +**Parameters** + +* ``fmt`` - the text to draw in the tooltip + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button(fa.ICON_FA_ALIGN_CENTER) + imgui.set_item_tooltip("center scene") + +begin_tooltip +^^^^^^^^^^^^^ + +.. imgui-signature:: begin_tooltip + +A tooltip that holds any elements, not only text. Call ``end_tooltip`` only when ``begin_tooltip`` returned ``True``. + +**Parameters** + +none + +.. imgui-example:: + :name: begin_tooltip + :interact: hover 30 18 + + imgui.button("image-1") + + if imgui.is_item_hovered() and imgui.begin_tooltip(): + imgui.text("image-1") + imgui.separator() + imgui.label_text("shape", "(512, 512)") + imgui.label_text("dtype", "uint16") + imgui.end_tooltip() + +end_tooltip +^^^^^^^^^^^ + +.. imgui-signature:: end_tooltip + +Call it only when the matching ``begin_tooltip`` returned ``True``. + +**Parameters** + +none + +Layout +------ + +Elements are stacked vertically in the order they are called. These change where the next element goes, so most of them +draw nothing by themselves and are shown here between elements that do. + +same_line +^^^^^^^^^ + +.. imgui-signature:: same_line + +**Parameters** + +* ``offset_from_start_x`` - x position in window coordinates, the default continues after the previous element +* ``spacing`` - gap in pixels, the default uses the style spacing + +.. imgui-example:: + + imgui.button("apply") + imgui.same_line() + imgui.button("reset") + +new_line +^^^^^^^^ + +.. imgui-signature:: new_line + +**Parameters** + +none + +.. imgui-example:: + + imgui.button("apply") + imgui.same_line() + imgui.new_line() + imgui.button("reset") + +separator +^^^^^^^^^ + +.. imgui-signature:: separator + +**Parameters** + +none + +.. imgui-example:: + + imgui.text("filter") + imgui.separator() + imgui.text("export") + +spacing +^^^^^^^ + +.. imgui-signature:: spacing + +**Parameters** + +none + +.. imgui-example:: + + imgui.button("apply") + imgui.spacing() + imgui.spacing() + imgui.button("reset") + +dummy +^^^^^ + +.. imgui-signature:: dummy + +An empty element of a given size, to leave a gap that spacing cannot make. It takes no pointer input, unlike +``invisible_button``. + +**Parameters** + +* ``size`` - ``(width, height)`` of the gap + +.. imgui-example:: + + imgui.button("apply") + imgui.same_line() + imgui.dummy((40, 0)) + imgui.same_line() + imgui.button("delete") + +indent +^^^^^^ + +.. imgui-signature:: indent + +**Parameters** + +* ``indent_w`` - width in pixels, the default uses the style indent + +.. imgui-example:: + :name: indent + + imgui.text("filter") + imgui.indent() + imgui.text("gaussian, sigma 1.4") + imgui.text("applied to every frame") + imgui.unindent() + imgui.text("export") + +unindent +^^^^^^^^ + +.. imgui-signature:: unindent + +**Parameters** + +* ``indent_w`` - width in pixels, the default uses the style indent + +begin_group +^^^^^^^^^^^ + +.. imgui-signature:: begin_group + +Everything between them becomes one item, so ``same_line`` places the whole group and ``is_item_hovered`` covers all of +it. + +**Parameters** + +none + +.. imgui-example:: + :name: begin_group + + imgui.begin_group() + imgui.text("vmin") + imgui.text("12") + imgui.end_group() + + imgui.same_line() + imgui.dummy((20, 0)) + imgui.same_line() + + imgui.begin_group() + imgui.text("vmax") + imgui.text("208") + imgui.end_group() + +end_group +^^^^^^^^^ + +.. imgui-signature:: end_group + +Ends the group, and makes everything in it one item for ``same_line`` and the item queries. + +**Parameters** + +none + +align_text_to_frame_padding +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: align_text_to_frame_padding + +Text is drawn without a frame, so on a row shared with a slider or a button it sits too high. Call this before the text +to line them up. + +**Parameters** + +none + +.. imgui-example:: + + sigma = 1.4 + + imgui.align_text_to_frame_padding() + imgui.text("sigma") + imgui.same_line() + changed, sigma = imgui.slider_float("##sigma", v=sigma, v_min=0.1, v_max=10.0) + +set_next_item_width +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_item_width + +**Parameters** + +* ``item_width`` - width in pixels, a negative value leaves that many pixels between the element and the right edge + +.. imgui-example:: + + vmin, vmax = 12.0, 208.0 + + imgui.set_next_item_width(80) + changed, vmin = imgui.slider_float("vmin", v=vmin, v_min=0.0, v_max=255.0, format="%.0f") + + imgui.set_next_item_width(80) + changed, vmax = imgui.slider_float("vmax", v=vmax, v_min=0.0, v_max=255.0, format="%.0f") + +push_item_width +^^^^^^^^^^^^^^^ + +.. imgui-signature:: push_item_width + +The same as ``set_next_item_width`` but for every element until ``pop_item_width``. + +**Parameters** + +* ``item_width`` - width in pixels, a negative value leaves that many pixels between the element and the right edge + +.. imgui-example:: + :name: push_item_width + + vmin, vmax = 12.0, 208.0 + + imgui.push_item_width(80) + changed, vmin = imgui.slider_float("vmin", v=vmin, v_min=0.0, v_max=255.0, format="%.0f") + changed, vmax = imgui.slider_float("vmax", v=vmax, v_min=0.0, v_max=255.0, format="%.0f") + imgui.pop_item_width() + +pop_item_width +^^^^^^^^^^^^^^ + +.. imgui-signature:: pop_item_width + +Pops the width that ``push_item_width`` pushed. + +**Parameters** + +none + +calc_text_size +^^^^^^^^^^^^^^ + +.. imgui-signature:: calc_text_size + +**Parameters** + +* ``text`` - the text to measure +* ``text_end`` - measure up to this substring +* ``hide_text_after_double_hash`` - ignore everything after ``##``, as the elements do with their labels +* ``wrap_width`` - measure as if the text were wrapped at this width + +**Returns:** the size, use ``.x`` and ``.y`` + +.. imgui-example:: + + label = "vmin / vmax" + size = imgui.calc_text_size(label) + + imgui.text(label) + imgui.text(f"that text is {size.x:.0f} x {size.y:.0f} px") + +get_content_region_avail +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_content_region_avail + +The space left in the window from the current position, which is how an element is sized to fill the window. + +**Parameters** + +none + +**Returns:** the available size, use ``.x`` and ``.y`` + +.. imgui-example:: + :width: 200 + + available = imgui.get_content_region_avail() + + imgui.text(f"{available.x:.0f} x {available.y:.0f} px left") + imgui.button("fill the width", (available.x, 0)) + +get_cursor_pos +^^^^^^^^^^^^^^ + +.. imgui-signature:: get_cursor_pos + +Where the next element goes, in window coordinates. + +**Parameters** + +* ``local_pos`` - ``(x, y)`` in window coordinates + +.. imgui-example:: + :name: set_cursor_pos + + imgui.set_cursor_pos((60, 30)) + imgui.button("moved") + +set_cursor_pos +^^^^^^^^^^^^^^ + +.. imgui-signature:: set_cursor_pos + +Moves the position of the next element, in window coordinates. + +**Parameters** + +* ``local_pos`` - ``(x, y)`` in window coordinates + +.. imgui-example:: + + imgui.set_cursor_pos((60, 30)) + imgui.button("moved") + +get_cursor_screen_pos +^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_cursor_screen_pos + +The same position in canvas coordinates, which is what a draw list takes. + +**Parameters** + +* ``pos`` - ``(x, y)`` in canvas coordinates + +.. imgui-example:: + :name: get_cursor_screen_pos + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + draw_list.add_rect_filled( + position, + (position.x + 60, position.y + 20), + imgui.color_convert_float4_to_u32((0.2, 0.6, 0.95, 1.0)), + ) + imgui.dummy((60, 20)) + +set_cursor_screen_pos +^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_cursor_screen_pos + +Moves the position of the next element, in canvas coordinates. + +**Parameters** + +* ``pos`` - ``(x, y)`` in canvas coordinates + +get_text_line_height +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_text_line_height + +The height of a line of text, and the height of an element that has a frame such as a button or a slider. Use them to +size something you draw yourself so that it lines up with the elements around it. + +**Parameters** + +none + +.. imgui-example:: + :name: get_frame_height + + imgui.text(f"text line: {imgui.get_text_line_height():.0f} px") + imgui.text(f"framed element: {imgui.get_frame_height():.0f} px") + +get_frame_height +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_frame_height + +The height of an element that has a frame, such as a button or a slider. + +**Parameters** + +none + +**Returns:** the height in pixels + +.. imgui-example:: + + imgui.text(f"framed element: {imgui.get_frame_height():.0f} px") + +Windows +------- + +In fastplotlib the window is created for you, ``ImguiWindow.update()`` draws into it. These are for a window you create +yourself, inside an overridden ``ImguiWindow.draw()``. + +begin +^^^^^ + +.. imgui-signature:: begin + +``end`` is called whether or not ``begin`` returned ``True``. ``begin`` returns ``False`` when the window is collapsed, +in which case its contents can be skipped. + +**Parameters** + +* ``name`` - the title, and the id of the window, ``"title##id"`` separates the two +* ``p_open`` - when given, a close button is drawn in the title bar and this is set to ``False`` when it is clicked + +**Returns:** ``(expanded, p_open)`` + +.. imgui-example:: + :window: none + :size: 240, 120 + + expanded, open_ = imgui.begin("filter", True) + + if expanded: + imgui.text("gaussian") + + imgui.end() + +end +^^^ + +.. imgui-signature:: end + +Called whether or not ``begin`` returned ``True``. + +**Parameters** + +none + +begin_child +^^^^^^^^^^^ + +.. imgui-signature:: begin_child + +A region within a window, with its own scrolling and clipping. Use it for a list that should scroll on its own. + +**Parameters** + +* ``str_id``, ``id_`` - identifies the region +* ``size`` - ``(width, height)``, a zero component fills the available space, a negative one leaves that many pixels + +.. imgui-example:: + :name: begin_child + + if imgui.begin_child("graphics", (160, 80), child_flags=imgui.ChildFlags_.borders): + for i in range(8): + imgui.text(f"line-{i}") + + imgui.end_child() + +end_child +^^^^^^^^^ + +.. imgui-signature:: end_child + +Call it only when the matching ``begin_child`` returned ``True``. + +**Parameters** + +none + +set_next_window_pos +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_window_pos + +**Parameters** + +* ``pos`` - ``(x, y)`` in canvas coordinates +* ``cond`` - an ``imgui.Cond_`` value, e.g. ``appearing`` to place it only when it first appears so the user can move it +* ``pivot`` - which point of the window lands on ``pos``, ``(0.5, 0.5)`` centers it there + +.. imgui-example:: + :window: none + :size: 260, 130 + + imgui.set_next_window_pos((40, 30)) + imgui.set_next_window_size((160, 60)) + imgui.begin("filter") + imgui.text("placed at 40, 30") + imgui.end() + +set_next_window_size +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_window_size + +**Parameters** + +* ``size`` - ``(width, height)``, a zero component makes that axis fit its contents +* ``cond`` - an ``imgui.Cond_`` value + +.. imgui-example:: + :window: none + :size: 240, 120 + + imgui.set_next_window_size((150, 0)) + imgui.begin("filter") + imgui.text("fixed width, auto height") + imgui.end() + +set_next_window_collapsed +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_window_collapsed + +**Parameters** + +* ``collapsed`` - the state to set +* ``cond`` - an ``imgui.Cond_`` value + +.. imgui-example:: + :window: none + :size: 240, 90 + + imgui.set_next_window_collapsed(True) + imgui.begin("filter") + imgui.text("not drawn while collapsed") + imgui.end() + +get_window_pos +^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_pos + +The position and size of the window being drawn. For laying out contents, ``get_content_region_avail`` is what you +want, since it accounts for padding and for the position within the window. + +**Parameters** + +none + +.. imgui-example:: + :name: get_window_size + :width: 200 + + size = imgui.get_window_size() + + imgui.text(f"window: {size.x:.0f} x {size.y:.0f} px") + +get_window_size +^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_size + +**Parameters** + +none + +**Returns:** the size, use ``.x`` and ``.y`` + +.. imgui-example:: + :width: 200 + + size = imgui.get_window_size() + + imgui.text(f"window: {size.x:.0f} x {size.y:.0f} px") + +get_window_width +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_width + +**Parameters** + +none + +**Returns:** the width in pixels + +get_window_height +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_height + +**Parameters** + +none + +**Returns:** the height in pixels + +get_window_draw_list +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_draw_list + +The draw list of the window, for drawing shapes and text yourself. Positions are in canvas coordinates, so they start +from ``get_cursor_screen_pos``. + +**Parameters** + +none + +**Returns:** an ``imgui.ImDrawList`` + +.. imgui-example:: + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + blue = imgui.color_convert_float4_to_u32((0.2, 0.6, 0.95, 1.0)) + + draw_list.add_rect_filled(position, (position.x + 120, position.y + 8), blue) + draw_list.add_circle_filled((position.x + 30, position.y + 30), 8, white) + draw_list.add_text((position.x + 50, position.y + 22), white, "drawn by hand") + + imgui.dummy((120, 45)) + +set_scroll_here_y +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_scroll_here_y + +``set_scroll_here_y`` scrolls to the element that was just drawn, which is how a list follows a selection. + +**Parameters** + +* ``center_y_ratio`` - where the element ends up, ``0.0`` top, ``0.5`` center, ``1.0`` bottom +* ``scroll_y`` - the scroll amount in pixels + +.. imgui-example:: + :name: set_scroll_here_y + + if imgui.begin_child("graphics", (160, 70), child_flags=imgui.ChildFlags_.borders): + for i in range(10): + imgui.text(f"line-{i}") + + if i == 6: + imgui.set_scroll_here_y(0.5) + + imgui.end_child() + +get_scroll_y +^^^^^^^^^^^^ + +.. imgui-signature:: get_scroll_y + +**Parameters** + +none + +**Returns:** the scroll amount in pixels + +set_scroll_y +^^^^^^^^^^^^ + +.. imgui-signature:: set_scroll_y + +**Parameters** + +* ``scroll_y`` - the scroll amount in pixels + +Style and ids +------------- + +Every push has a matching pop. A push that is not popped leaks into everything drawn afterwards, including elements +that fastplotlib draws. + +push_id +^^^^^^^ + +.. imgui-signature:: push_id + +imgui identifies an element by its label, so two elements with the same label are the same element and share their +state. Push an id around them to keep them apart, which is what a loop over graphics needs. + +**Parameters** + +* ``str_id``, ``int_id``, ``ptr_id`` - the value to push, it is hashed and is not drawn +* ``str_id_begin``, ``str_id_end`` - a substring to push + +.. imgui-example:: + :name: push_id + + thickness = {"line-1": 4.0, "line-2": 9.0} + + for name in thickness: + imgui.push_id(name) + + imgui.text(name) + imgui.same_line() + changed, thickness[name] = imgui.slider_float("##thickness", v=thickness[name], v_min=1.0, v_max=20.0) + + imgui.pop_id() + +pop_id +^^^^^^ + +.. imgui-signature:: pop_id + +Pops the id that ``push_id`` pushed. + +**Parameters** + +none + +push_style_color +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: push_style_color + +**Parameters** + +* ``idx`` - which color, an ``imgui.Col_`` value +* ``col`` - the color, ``(r, g, b, a)`` or a packed ``int`` +* ``count`` - how many pushes to pop + +.. imgui-example:: + :name: push_style_color + + imgui.push_style_color(imgui.Col_.button, (0.6, 0.15, 0.15, 1.0)) + imgui.push_style_color(imgui.Col_.button_hovered, (0.75, 0.2, 0.2, 1.0)) + + imgui.button("delete graphic") + + imgui.pop_style_color(2) + + imgui.button("keep graphic") + +pop_style_color +^^^^^^^^^^^^^^^ + +.. imgui-signature:: pop_style_color + +**Parameters** + +* ``count`` - how many pushed colors to pop + +push_style_var +^^^^^^^^^^^^^^ + +.. imgui-signature:: push_style_var + +**Parameters** + +* ``idx`` - which variable, an ``imgui.StyleVar_`` value +* ``val`` - a float, or ``(x, y)`` for the variables that are a pair +* ``count`` - how many pushes to pop + +.. imgui-example:: + :name: push_style_var + + imgui.push_style_var(imgui.StyleVar_.frame_rounding, 10.0) + imgui.button("rounded") + imgui.pop_style_var() + + imgui.button("default") + +pop_style_var +^^^^^^^^^^^^^ + +.. imgui-signature:: pop_style_var + +**Parameters** + +* ``count`` - how many pushed variables to pop + +get_style_color_vec4 +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_style_color_vec4 + +**Parameters** + +* ``idx`` - which color, an ``imgui.Col_`` value + +**Returns:** the color, use ``.x``, ``.y``, ``.z``, ``.w`` for r, g, b, a + +.. imgui-example:: + + color = imgui.get_style_color_vec4(imgui.Col_.text) + + imgui.text(f"text color: {color.x:.2f}, {color.y:.2f}, {color.z:.2f}") + +get_color_u32 +^^^^^^^^^^^^^ + +.. imgui-signature:: get_color_u32 + +A draw list takes a packed 32-bit color, not a tuple. ``get_color_u32`` packs a style color or your own color and +applies the global style alpha, ``color_convert_float4_to_u32`` packs a color as it is. + +**Parameters** + +* ``idx`` - which style color, an ``imgui.Col_`` value +* ``col`` - a color, ``(r, g, b, a)`` or a packed ``int`` +* ``alpha_mul`` - multiplies the alpha +* ``in_`` - the color to pack, ``(r, g, b, a)`` + +**Returns:** the packed color + +.. imgui-example:: + :name: get_color_u32 + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + draw_list.add_rect_filled( + position, (position.x + 60, position.y + 20), imgui.get_color_u32(imgui.Col_.button) + ) + draw_list.add_rect_filled( + (position.x + 70, position.y), + (position.x + 130, position.y + 20), + imgui.color_convert_float4_to_u32((1.0, 0.8, 0.2, 1.0)), + ) + + imgui.dummy((130, 20)) + +color_convert_float4_to_u32 +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: color_convert_float4_to_u32 + +Packs a color as it is, without applying the style alpha. + +**Parameters** + +* ``in_`` - the color to pack, ``(r, g, b, a)`` + +**Returns:** the packed color + +get_font_size +^^^^^^^^^^^^^ + +.. imgui-signature:: get_font_size + +**Parameters** + +none + +**Returns:** the height of the font in pixels + +.. imgui-example:: + + imgui.text(f"font size: {imgui.get_font_size():.0f} px") + +begin_disabled +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_disabled + +Everything between them is greyed out and takes no input, for a control that does not apply yet. + +**Parameters** + +* ``disabled`` - pass ``False`` to leave the elements enabled, so the call can be made unconditionally + +.. imgui-example:: + :name: begin_disabled + + apply_filter, sigma = False, 1.4 + + changed, apply_filter = imgui.checkbox("gaussian filter", apply_filter) + + imgui.begin_disabled(not apply_filter) + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.end_disabled() + +end_disabled +^^^^^^^^^^^^ + +.. imgui-signature:: end_disabled + +Ends the block that ``begin_disabled`` started. + +**Parameters** + +none + +Queries +------- + +These ask about the element that was drawn last, about the window, or about the mouse and keyboard. The item queries +refer to the element immediately above them, so they go straight after the element they ask about. + +The examples below print what they return, and the images were captured with the pointer over the element or a button +held down, which is why they read ``True``. + +is_item_hovered +^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_hovered + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button("autoscale") + imgui.text(f"hovered: {imgui.is_item_hovered()}") + +is_item_active +^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_active + +.. imgui-example:: + :interact: press 30 18 + + imgui.button("autoscale") + imgui.text(f"active: {imgui.is_item_active()}") + +is_item_clicked +^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_clicked + +**Parameters** + +* ``mouse_button`` - ``0`` left, ``1`` right, ``2`` middle + +.. imgui-example:: + :interact: press 30 18 + + imgui.button("autoscale") + imgui.text(f"clicked: {imgui.is_item_clicked()}") + +is_item_edited +^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_edited + +``is_item_deactivated_after_edit`` is the one to use for work that is too expensive to run while a slider is being +dragged, since it is ``True`` only on the frame the drag ends. + +.. imgui-example:: + :name: is_item_deactivated_after_edit + :interact: drag 60 18 100 18 + + sigma = 1.4 + + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + + imgui.text(f"edited: {imgui.is_item_edited()}") + imgui.text(f"activated: {imgui.is_item_activated()}") + imgui.text(f"finished: {imgui.is_item_deactivated_after_edit()}") + +is_item_activated +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_activated + +``True`` on the frame the element became active, e.g. the frame a drag started. + +**Parameters** + +none + +.. imgui-example:: + :interact: press 60 18 + + sigma = 1.4 + + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.text(f"activated: {imgui.is_item_activated()}") + +is_item_deactivated_after_edit +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_deactivated_after_edit + +``True`` only on the frame an edit ends, which is what to use for work that is too expensive to run while a +slider is being dragged. + +**Parameters** + +none + +.. imgui-example:: + :interact: drag 60 18 100 18; release + + sigma = 1.4 + + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.text(f"finished: {imgui.is_item_deactivated_after_edit()}") + +is_any_item_hovered +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_any_item_hovered + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button("autoscale") + imgui.button("center") + + imgui.text(f"any hovered: {imgui.is_any_item_hovered()}") + +is_window_hovered +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_window_hovered + +.. imgui-example:: + :interact: hover 60 40 + + imgui.text(f"window hovered: {imgui.is_window_hovered()}") + +is_window_focused +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_window_focused + +.. imgui-example:: + :interact: click 60 40 + + imgui.text(f"window focused: {imgui.is_window_focused()}") + +is_window_appearing +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_window_appearing + +``True`` on the first frame the window is drawn, for setup that should happen once, such as sizing a table column. + +**Parameters** + +none + +.. imgui-example:: + + imgui.text(f"appearing: {imgui.is_window_appearing()}") + +is_mouse_down +^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_down + +These ask about the mouse anywhere, not about an element. A right-click that should open something belongs in +``begin_popup_context_item`` instead. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``repeat`` - report repeats while the button is held + +.. imgui-example:: + :name: is_mouse_down + :interact: press 60 40 + + imgui.text(f"left down: {imgui.is_mouse_down(0)}") + imgui.text(f"left clicked: {imgui.is_mouse_clicked(0)}") + imgui.text(f"right down: {imgui.is_mouse_down(1)}") + +is_mouse_clicked +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_clicked + +``True`` on the frame the button goes down. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``repeat`` - report repeats while the button is held + +.. imgui-example:: + :interact: press 60 30 + + imgui.text(f"left clicked: {imgui.is_mouse_clicked(0)}") + +is_mouse_released +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_released + +``True`` on the frame the button goes up. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle + +.. imgui-example:: + :interact: click 60 30 + + imgui.text(f"left released: {imgui.is_mouse_released(0)}") + +is_mouse_double_clicked +^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_double_clicked + +``True`` on the frame of the second click of a double click. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle + +.. imgui-example:: + :interact: double_click 60 30 + + imgui.text(f"double clicked: {imgui.is_mouse_double_clicked(0)}") + +is_mouse_dragging +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_dragging + +The delta is measured from where the button went down. Reset it each frame to get the movement since the last frame, +which is what a drag handle needs. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``lock_threshold`` - how far the pointer must move before it counts as a drag, the default uses the style threshold + +.. imgui-example:: + :name: is_mouse_dragging + :interact: drag 40 30 90 45 + + delta = imgui.get_mouse_drag_delta(0) + + imgui.text(f"dragging: {imgui.is_mouse_dragging(0)}") + imgui.text(f"delta: {delta.x:.0f}, {delta.y:.0f}") + +get_mouse_drag_delta +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_mouse_drag_delta + +The movement since the button went down, in pixels. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``lock_threshold`` - how far the pointer must move before it counts as a drag + +**Returns:** the delta, use ``.x`` and ``.y`` + +.. imgui-example:: + :interact: drag 40 30 90 45 + + delta = imgui.get_mouse_drag_delta(0) + + imgui.text(f"delta: {delta.x:.0f}, {delta.y:.0f}") + +reset_mouse_drag_delta +^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: reset_mouse_drag_delta + +Sets the delta back to zero, call it each frame to get the movement since the last frame rather than since the +button went down. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle + +get_mouse_pos +^^^^^^^^^^^^^ + +.. imgui-signature:: get_mouse_pos + +**Parameters** + +none + +**Returns:** the pointer position in canvas coordinates, use ``.x`` and ``.y`` + +.. imgui-example:: + :interact: hover 70 30 + + position = imgui.get_mouse_pos() + + imgui.text(f"pointer: {position.x:.0f}, {position.y:.0f}") + +is_key_pressed +^^^^^^^^^^^^^^ + +.. imgui-signature:: is_key_pressed + +**Parameters** + +* ``key`` - an ``imgui.Key`` member, e.g. ``imgui.Key.right_arrow`` +* ``repeat`` - report repeats while the key is held + +.. imgui-example:: + :name: is_key_pressed + :interact: hover 60 30; key right_arrow + + index = 42 + + if imgui.is_key_pressed(imgui.Key.right_arrow): + index += 1 + + if imgui.is_key_pressed(imgui.Key.left_arrow): + index -= 1 + + imgui.text(f"index: {index}") + +is_key_down +^^^^^^^^^^^ + +.. imgui-signature:: is_key_down + +``True`` while the key is held, rather than only on the frame it goes down. + +**Parameters** + +* ``key`` - an ``imgui.Key`` member + +.. imgui-example:: + :interact: hover 60 30; key left_shift + + imgui.text(f"shift held: {imgui.is_key_down(imgui.Key.left_shift)}") + +get_io +^^^^^^ + +.. imgui-signature:: get_io + +The imgui io structure. ``want_capture_mouse`` is the field to know about: it is ``True`` while imgui is using the +pointer, and fastplotlib relies on it to keep clicks on a UI from reaching the plot. + +**Parameters** + +none + +**Returns:** an ``imgui.IO`` + +.. imgui-example:: + :interact: hover 60 30 + + io = imgui.get_io() + + imgui.text(f"framerate: {io.framerate:.0f}") + imgui.text(f"capture mouse: {io.want_capture_mouse}") + +Plots +----- + +These draw a small line plot or histogram from an array of values, for a preview next to the controls. They are not a +plotting library, a fastplotlib subplot is. + +``values`` must be a contiguous ``float32`` array. + +plot_lines +^^^^^^^^^^ + +.. imgui-signature:: plot_lines + +**Parameters** + +* ``label`` - drawn to the right of the plot, ``"##hidden"`` suppresses it +* ``values`` - the values to plot +* ``values_offset`` - index to start from, for a ring buffer +* ``overlay_text`` - text drawn over the plot +* ``scale_min``, ``scale_max`` - the y range, the default fits the values +* ``graph_size`` - ``(width, height)``, a zero component is a default size +* ``stride`` - byte stride between values, for a column of a 2d array + +.. imgui-example:: + + values = np.sin(np.linspace(0, 4 * np.pi, 100)).astype(np.float32) + + imgui.plot_lines("##trace", values, graph_size=(220, 60), overlay_text="channel 0") + +plot_histogram +^^^^^^^^^^^^^^ + +.. imgui-signature:: plot_histogram + +**Parameters** + +* ``label`` - drawn to the right of the plot +* ``values`` - the bin counts +* ``values_offset`` - index to start from +* ``overlay_text`` - text drawn over the plot +* ``scale_min``, ``scale_max`` - the y range, the default fits the values +* ``graph_size`` - ``(width, height)``, a zero component is a default size +* ``stride`` - byte stride between values + +.. imgui-example:: + + data = np.random.normal(loc=120, scale=30, size=100_000) + counts = np.histogram(data, bins=64)[0].astype(np.float32) + + imgui.plot_histogram("##histogram", counts, graph_size=(220, 60)) + +image +^^^^^ + +.. imgui-signature:: image + +Draws a texture that you have uploaded to the GPU and registered with the imgui renderer, which is how +``ImguiColorbar`` draws its colormap bar. There is no example here because the texture has to come from the wgpu +device of the Figure:: + + texture_ref = figure.imgui_renderer.backend.register_texture(texture.create_view()) + imgui.image(texture_ref, (24, 200)) + +**Parameters** + +* ``tex_ref`` - an ``imgui.ImTextureRef`` from ``register_texture`` +* ``image_size`` - ``(width, height)`` to draw it at +* ``uv0``, ``uv1`` - the region of the texture to draw, ``(0, 0)`` to ``(1, 1)`` by default + +image_button +^^^^^^^^^^^^ + +.. imgui-signature:: image_button + +``image`` that responds to a click. + +**Parameters** + +* ``str_id`` - identifies the button +* ``tex_ref`` - an ``imgui.ImTextureRef`` from ``register_texture`` +* ``image_size`` - ``(width, height)`` to draw it at +* ``uv0``, ``uv1`` - the region of the texture to draw +* ``bg_col``, ``tint_col`` - background drawn behind the image, and a color the image is multiplied by + +**Returns:** ``True`` on the frame the button is clicked + +Tables +------ + +A table is opened with ``begin_table``, and ``end_table`` is called only when it returned ``True``. Cells are filled by +walking rows and columns, either with ``table_next_column`` or by setting the column index. + +begin_table +^^^^^^^^^^^ + +.. imgui-signature:: begin_table + +**Parameters** + +* ``str_id`` - identifies the table +* ``columns`` - how many columns +* ``outer_size`` - ``(width, height)`` of the table, a zero height fits the rows +* ``inner_width`` - width of the scrolling region when the table scrolls horizontally + +.. imgui-example:: + :name: begin_table + + graphics = [("line-1", "LineGraphic", True), ("image-1", "ImageGraphic", False)] + + if imgui.begin_table("graphics", 3, flags=imgui.TableFlags_.borders): + for name, kind, visible in graphics: + imgui.table_next_row() + + imgui.table_next_column() + imgui.text(name) + + imgui.table_next_column() + imgui.text(kind) + + imgui.table_next_column() + imgui.text("visible" if visible else "hidden") + + imgui.end_table() + +end_table +^^^^^^^^^ + +.. imgui-signature:: end_table + +Call it only when the matching ``begin_table`` returned ``True``. + +**Parameters** + +none + +table_next_row +^^^^^^^^^^^^^^ + +.. imgui-signature:: table_next_row + +**Parameters** + +* ``min_row_height`` - minimum height of the row in pixels + +.. imgui-example:: + + if imgui.begin_table("frames", 2, flags=imgui.TableFlags_.borders): + for index in range(3): + imgui.table_next_row(min_row_height=24) + + imgui.table_next_column() + imgui.text(f"frame {index}") + + imgui.table_next_column() + imgui.text(f"{index * 40} ms") + + imgui.end_table() + +table_next_column +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_next_column + +``table_next_column`` moves to the next cell, wrapping to the first column of the next row. Use +``table_set_column_index`` to fill cells out of order. + +**Parameters** + +* ``column_n`` - the column to move to + +**Returns:** ``True`` when the column is visible, a clipped or hidden column can be skipped + +.. imgui-example:: + :name: table_set_column_index + + if imgui.begin_table("stats", 2, flags=imgui.TableFlags_.borders): + for label, value in [("vmin", "12"), ("vmax", "208")]: + imgui.table_next_row() + + imgui.table_set_column_index(0) + imgui.text(label) + + imgui.table_set_column_index(1) + imgui.text(value) + + imgui.end_table() + +table_set_column_index +^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_set_column_index + +Fills a cell out of order, rather than moving to the next one. + +**Parameters** + +* ``column_n`` - the column to move to + +**Returns:** ``True`` when the column is visible + +table_setup_column +^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_setup_column + +Declare the columns before any row, then ``table_headers_row`` draws one row with their labels. + +**Parameters** + +* ``label`` - the column header +* ``init_width_or_weight`` - a starting width in pixels, or a share of the table width for a stretched column. + imgui rejects it unless the sizing policy is explicit, so pass ``imgui.TableColumnFlags_.width_fixed`` or + ``width_stretch`` with it +* ``user_id`` - an id you can read back when sorting + +.. imgui-example:: + :name: table_headers_row + + if imgui.begin_table("graphics", 2, flags=imgui.TableFlags_.borders): + imgui.table_setup_column("name", flags=imgui.TableColumnFlags_.width_fixed, init_width_or_weight=90) + imgui.table_setup_column("type") + imgui.table_headers_row() + + for name, kind in [("line-1", "LineGraphic"), ("image-1", "ImageGraphic")]: + imgui.table_next_row() + + imgui.table_next_column() + imgui.text(name) + + imgui.table_next_column() + imgui.text(kind) + + imgui.end_table() + +table_headers_row +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_headers_row + +Draws one row of headers from the labels given to ``table_setup_column``. + +**Parameters** + +none diff --git a/docs/source/imgui/reference/flags.rst b/docs/source/imgui/reference/flags.rst new file mode 100644 index 000000000..dffd47bb0 --- /dev/null +++ b/docs/source/imgui/reference/flags.rst @@ -0,0 +1,154 @@ +Flags +===== + +Flags are passed as ``int``. The values are ``enum.IntFlag`` members of the classes below and can be +combined with ``|``:: + + imgui.slider_float( + "gamma", v=gamma, v_min=0.1, v_max=5.0, + flags=imgui.SliderFlags_.logarithmic | imgui.SliderFlags_.no_input, + ) + +``Col_``, ``Cond_``, ``StyleVar_`` hold single values rather than flags, they are listed here because the +elements take them. + +.. _imgui.ButtonFlags_: + +imgui.ButtonFlags\_ +------------------- + +.. imgui-flags:: ButtonFlags_ + +.. _imgui.ChildFlags_: + +imgui.ChildFlags\_ +------------------ + +.. imgui-flags:: ChildFlags_ + +.. _imgui.Col_: + +imgui.Col\_ +----------- + +.. imgui-flags:: Col_ + +.. _imgui.ColorEditFlags_: + +imgui.ColorEditFlags\_ +---------------------- + +.. imgui-flags:: ColorEditFlags_ + +.. _imgui.ComboFlags_: + +imgui.ComboFlags\_ +------------------ + +.. imgui-flags:: ComboFlags_ + +.. _imgui.Cond_: + +imgui.Cond\_ +------------ + +.. imgui-flags:: Cond_ + +.. _imgui.FocusedFlags_: + +imgui.FocusedFlags\_ +-------------------- + +.. imgui-flags:: FocusedFlags_ + +.. _imgui.HoveredFlags_: + +imgui.HoveredFlags\_ +-------------------- + +.. imgui-flags:: HoveredFlags_ + +.. _imgui.InputTextFlags_: + +imgui.InputTextFlags\_ +---------------------- + +.. imgui-flags:: InputTextFlags_ + +.. _imgui.PopupFlags_: + +imgui.PopupFlags\_ +------------------ + +.. imgui-flags:: PopupFlags_ + +.. _imgui.SelectableFlags_: + +imgui.SelectableFlags\_ +----------------------- + +.. imgui-flags:: SelectableFlags_ + +.. _imgui.SliderFlags_: + +imgui.SliderFlags\_ +------------------- + +.. imgui-flags:: SliderFlags_ + +.. _imgui.StyleVar_: + +imgui.StyleVar\_ +---------------- + +.. imgui-flags:: StyleVar_ + +.. _imgui.TabBarFlags_: + +imgui.TabBarFlags\_ +------------------- + +.. imgui-flags:: TabBarFlags_ + +.. _imgui.TabItemFlags_: + +imgui.TabItemFlags\_ +-------------------- + +.. imgui-flags:: TabItemFlags_ + +.. _imgui.TableColumnFlags_: + +imgui.TableColumnFlags\_ +------------------------ + +.. imgui-flags:: TableColumnFlags_ + +.. _imgui.TableFlags_: + +imgui.TableFlags\_ +------------------ + +.. imgui-flags:: TableFlags_ + +.. _imgui.TableRowFlags_: + +imgui.TableRowFlags\_ +--------------------- + +.. imgui-flags:: TableRowFlags_ + +.. _imgui.TreeNodeFlags_: + +imgui.TreeNodeFlags\_ +--------------------- + +.. imgui-flags:: TreeNodeFlags_ + +.. _imgui.WindowFlags_: + +imgui.WindowFlags\_ +------------------- + +.. imgui-flags:: WindowFlags_ + diff --git a/docs/source/imgui/reference/index.rst b/docs/source/imgui/reference/index.rst new file mode 100644 index 000000000..981a247dd --- /dev/null +++ b/docs/source/imgui/reference/index.rst @@ -0,0 +1,8 @@ +imgui reference +*************** + +.. toctree:: + :maxdepth: 3 + + elements + flags diff --git a/docs/source/index.rst b/docs/source/index.rst index c44f4e3a8..68c28a577 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,6 +6,17 @@ Welcome to fastplotlib's documentation! :maxdepth: 2 user_guide/index + +.. toctree:: + :caption: imgui + :maxdepth: 2 + + imgui/index + +.. toctree:: + :caption: Developer notes + :maxdepth: 2 + developer_notes/index .. toctree:: diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index 0342807e1..c55c71722 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -471,6 +471,17 @@ cmap | value | str | new cmap name | +----------+------+---------------+ +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + vmin ^^^^ @@ -619,6 +630,17 @@ data | value | np.ndarray | float | new data values | +----------+--------------------------------------+--------------------------------------------------+ +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + vmin ^^^^ @@ -767,6 +789,17 @@ cmap | value | str | new cmap name | +----------+------+---------------+ +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + vmin ^^^^ diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index 5b6bbc7d5..c857ebb9c 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -562,23 +562,16 @@ are no callbacks, but it is easy to learn if you see a few examples. .. image:: ../_static/guide_imgui.png We specifically use `imgui-bundle `_ for the python bindings in fastplotlib. -There is large community and many resources out there on building UIs using imgui. To install ``fastplotlib`` with ``imgui`` use the ``imgui`` extras option, i.e. ``pip install fastplotlib[imgui]``, or ``pip install imgui_bundle`` if you've already installed fastplotlib. Fastplotlib comes built-in with imgui UIs for subplot toolbars and a standard right-click menu with a number of options. -You can also make custom GUIs and embed them within the canvas, see the examples gallery for detailed examples. +The standard right-click menu can be extended or replaced, and a right-click popup can also be set on a ``Subplot`` or +a ``Graphic``. You can also make custom GUIs and embed them within the canvas. -**Some tips:** - -The ``imgui-bundle`` docs as of March 2025 don't have a nice API list (as far as I know), here is how we go about developing UIs with imgui: - -1. Use the ``pyimgui`` API docs to locate the type of UI element we want, for example if we want a ``slider_int``: https://pyimgui.readthedocs.io/en/latest/reference/imgui.core.html#imgui.core.slider_int - -2. Look at the function signature in the ``imgui-bundle`` sources. You can usually access this easily with your IDE: https://github.com/pthom/imgui_bundle/blob/a5e7d46555832c40e9be277d4747eac5a303dbfc/bindings/imgui_bundle/imgui/__init__.pyi#L1693-L1696 - -3. ``pyimgui`` and ``imgui-bundle`` sometimes don't have the same function signature, so we use a combination of the pyimgui docs and -imgui-bundle function signature to understand and implement the UI element. +The :doc:`imgui guide ` covers adding UIs to a Figure, and the +:doc:`imgui element reference ` documents every element with its signature, its arguments, and +an image of what it draws. ImageWidget ----------- diff --git a/examples/guis/imgui_append.py b/examples/guis/imgui_append.py new file mode 100644 index 000000000..cd8b0e958 --- /dev/null +++ b/examples/guis/imgui_append.py @@ -0,0 +1,49 @@ +""" +ImGUI append to windows +======================= + +You can append imgui elements to an existing window, including the subplot toolbar. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from imgui_bundle import imgui, icons_fontawesome_6 as fa + +figure = fpl.Figure(size=(700, 560)) +figure[0, 0].add_line(np.random.rand(100), colors="r", name="line") + + +# create an edge window +@figure.add_imgui_window(location="right", size=200, title="controls") +def gui(fig): + if imgui.button("randomize"): + fig[0, 0]["line"].data[:, 1] = np.random.rand(100) + + +# append more elements to the same window +@figure.append_imgui_window(location="right") +def more(fig): + line = fig[0, 0]["line"] + _, line.thickness = imgui.slider_float("thickness", v=line.thickness, v_min=2.0, v_max=50.0) + + +# append a button to the subplot toolbar that toggles axes visibility +@figure[0, 0].append_imgui_window(location="toolbar") +def toolbar_extra(subplot): + imgui.same_line() + _, subplot.axes.visible = imgui.checkbox(fa.ICON_FA_RULER_COMBINED, subplot.axes.visible) + if imgui.is_item_hovered(0): + imgui.set_tooltip("Axes visibility") + + +figure.show(maintain_aspect=False) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_basic.py b/examples/guis/imgui_basic.py index 7f42eadd6..11af54eac 100644 --- a/examples/guis/imgui_basic.py +++ b/examples/guis/imgui_basic.py @@ -13,8 +13,8 @@ import numpy as np import fastplotlib as fpl -# subclass from EdgeWindow to make a custom ImGUI Window to place inside the figure! -from fastplotlib.ui import EdgeWindow +# subclass from ImguiWindow to make a custom ImGUI Window to place inside the figure! +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui # make some initial data @@ -35,12 +35,9 @@ figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave") -class ImguiExample(EdgeWindow): - def __init__(self, figure, size, location, title): - super().__init__(figure=figure, size=size, location=location, title=title) - # this UI will modify the line - self._line = self._figure[0, 0]["sine-wave"] - +class ImguiExample(ImguiWindow): + def __init__(self): + super().__init__() # set the default values # wave amplitude self._amplitude = 1 @@ -104,15 +101,10 @@ def _set_data(self): # make GUI instance -gui = ImguiExample( - figure, # the figure this GUI instance should live inside - size=275, # width or height of the GUI window within the figure - location="right", # the edge to place this window at - title="Imgui Window", # window title -) - -# add it to the figure -figure.add_gui(gui) +gui = ImguiExample() + +# add it to the right edge of the figure, 275px wide +figure.add_imgui_window(gui, location="right", size=275, title="Imgui Window") figure.show() diff --git a/examples/guis/imgui_colorbar.py b/examples/guis/imgui_colorbar.py new file mode 100644 index 000000000..947bd5735 --- /dev/null +++ b/examples/guis/imgui_colorbar.py @@ -0,0 +1,51 @@ +""" +ImGUI Colorbar +============== + +Create an ImguiColorbar manually and add it to the right edge of each subplot. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio +from fastplotlib.ui import ImguiColorbar + +# a grayscale image and an RGB image +camera = iio.imread("imageio:camera.png") +astronaut = iio.imread("imageio:astronaut.png") + +figure = fpl.Figure(shape=(2, 2), size=(900, 900), canvas_kwargs={"max_fps": 999, "vsync": False}) + +# top row: a plain colorbar for each image +# grayscale image displayed with a colormap +camera_image = figure[0, 0].add_image(camera, cmap="viridis", name="camera") +figure[0, 0].add_imgui_window(ImguiColorbar(images=camera_image), location="right", size=80) + +# RGB image, it has no colormap so its colorbar is drawn with "gray" +astronaut_image = figure[0, 1].add_image(astronaut, name="astronaut") +figure[0, 1].add_imgui_window(ImguiColorbar(images=astronaut_image), location="right", size=80) + +# bottom row: the same images, but with a precomputed 100-bin histogram on the colorbar +camera_image2 = figure[1, 0].add_image(camera, cmap="viridis", name="camera") +camera_histogram = np.histogram(camera, bins=100) +figure[1, 0].add_imgui_window( + ImguiColorbar(images=camera_image2, histogram=camera_histogram), location="right", size=100 +) + +astronaut_image2 = figure[1, 1].add_image(astronaut, name="astronaut") +astronaut_histogram = np.histogram(astronaut, bins=100) +figure[1, 1].add_imgui_window( + ImguiColorbar(images=astronaut_image2, histogram=astronaut_histogram), location="right", size=100 +) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_decorator.py b/examples/guis/imgui_decorator.py new file mode 100644 index 000000000..e08f7a926 --- /dev/null +++ b/examples/guis/imgui_decorator.py @@ -0,0 +1,43 @@ +""" +ImGUI decorator +=============== + +You can quickly create imgui UIs using a decorator. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from imgui_bundle import imgui + +np.random.seed(0) +xs = np.linspace(0, 2 * np.pi, 100) + +figure = fpl.Figure(size=(700, 560)) +figure[0, 0].add_line(np.column_stack([xs, np.sin(xs)]), thickness=3, name="sine") + + +# the decorated function draws the imgui elements +# it optionally takes the figure as its only argument +@figure.add_imgui_window(location="right", size=200, title="controls") +def gui(fig): + line = fig[0, 0]["sine"] + + changed, thickness = imgui.slider_float("thickness", v=line.thickness, v_min=2.0, v_max=50.0) + if changed: + line.thickness = thickness + + if imgui.button("randomize"): + line.data[:, 1] = np.random.rand(100) + + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_floating.py b/examples/guis/imgui_floating.py new file mode 100644 index 000000000..51beb012d --- /dev/null +++ b/examples/guis/imgui_floating.py @@ -0,0 +1,39 @@ +""" +ImGUI floating windows +====================== + +You can add floating and fixed-extent imgui windows that are overlaid on the Figure. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from imgui_bundle import imgui + +figure = fpl.Figure(size=(700, 560)) +figure[0, 0].add_image(np.random.rand(128, 128), name="image") + + +# a floating window is auto-sized by imgui and can be dragged by the user +@figure.add_imgui_window(location="floating", title="floating", window_flags=imgui.WindowFlags_.none) +def floating_gui(fig): + if imgui.button("randomize"): + fig[0, 0]["image"].data = np.random.rand(128, 128) + + +# a window fixed to a fractional extent (xmin, xmax, ymin, ymax) of the canvas +@figure.add_imgui_window(extent=(0.6, 0.98, 0.05, 0.25), title="fixed") +def fixed_gui(): + imgui.text("fixed to a\nfractional extent") + + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_menu_bar.py b/examples/guis/imgui_menu_bar.py new file mode 100644 index 000000000..e792f0206 --- /dev/null +++ b/examples/guis/imgui_menu_bar.py @@ -0,0 +1,138 @@ +""" +ImGUI menu bar +============== + +You can override ``ImguiWindow.draw()`` to create a window with a menu bar. You can override the `draw()` call when +you need full control of the imgui window. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import imageio.v3 as iio +import fastplotlib as fpl +from fastplotlib.ui import ImguiWindow +from imgui_bundle import imgui + +# the imageio standard images +IMAGES = [ + "camera.png", + "astronaut.png", + "checkerboard.png", + "chelsea.png", + "clock.png", + "coffee.png", + "coins.png", + "horse.png", + "hubble_deep_field.png", + "immunohistochemistry.png", + "moon.png", + "page.png", + "text.png", + "wikkie.png", + "bricks.jpg", + "wood.jpg", +] + +figure = fpl.Figure(size=(700, 560)) +image = figure[0, 0].add_image(iio.imread(f"imageio:{IMAGES[0]}"), name="image") + + +class ImagePicker(ImguiWindow): + """floating window that replaces the image in the subplot with the one that is picked""" + + def __init__(self): + super().__init__() + + self.visible = False + self.picked = IMAGES[0] + + def draw(self): + if not self.visible: + return + + # a height of zero makes imgui auto-size the window to fit the list + imgui.set_next_window_size((220, 0), imgui.Cond_.appearing) + expanded, self.visible = imgui.begin("Open image", True) + + if expanded: + for name in IMAGES: + if imgui.selectable(name, name == self.picked)[0]: + self.picked = name + image.data = iio.imread(f"imageio:{name}") + figure[0, 0].auto_scale() + self.visible = False + + imgui.end() + + +class MenuBar(ImguiWindow): + """menu bar at the top of the Figure, ``update()`` is unused since ``draw()`` is fully overridden""" + + def __init__(self, picker: ImagePicker): + super().__init__() + + self._picker = picker + self._show_version = False + + def draw(self): + imgui.set_next_window_size((self.width, self.height)) + imgui.set_next_window_pos((self.x, self.y)) + + imgui.begin( + f"menu-bar##{self._id_counter}", + p_open=None, + flags=imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar + | imgui.WindowFlags_.no_scrollbar + | imgui.WindowFlags_.no_bring_to_front_on_focus + | imgui.WindowFlags_.menu_bar, + ) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("File"): + if imgui.menu_item("Open", "", False)[0]: + self._picker.visible = True + + imgui.end_menu() + + if imgui.begin_menu("Help"): + if imgui.menu_item("Version", "", False)[0]: + self._show_version = True + + imgui.end_menu() + + imgui.end_menu_bar() + + # the popup is opened here and not within the menu, imgui requires that open_popup() and + # begin_popup_modal() are called for the same window + if self._show_version: + self._show_version = False + imgui.open_popup("Version") + + # center the modal on the canvas + imgui.set_next_window_pos( + imgui.get_main_viewport().get_center(), imgui.Cond_.appearing, (0.5, 0.5) + ) + + # p_open draws a close button in the title bar, imgui closes the modal when it is clicked + if imgui.begin_popup_modal("Version", True, imgui.WindowFlags_.always_auto_resize)[0]: + imgui.text(f"fastplotlib version: {fpl.__version__}") + imgui.end_popup() + + imgui.end() + + +picker = ImagePicker() +figure.add_imgui_window(picker, location="floating") +figure.add_imgui_window(MenuBar(picker), location="top", size=30) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_right_click.py b/examples/guis/imgui_right_click.py new file mode 100644 index 000000000..c83ea3759 --- /dev/null +++ b/examples/guis/imgui_right_click.py @@ -0,0 +1,91 @@ +""" +ImGUI right-click popups +======================== + +You can set an imgui popup that is opened by a right-click on a Figure, Subplot or Graphic. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import imageio.v3 as iio +from scipy.ndimage import gaussian_filter +import fastplotlib as fpl +from imgui_bundle import imgui + +data1 = iio.imread("imageio:camera.png").astype(np.float32) +data2 = iio.imread("imageio:moon.png").astype(np.float32) + +figure = fpl.Figure(shape=(1, 2), size=(900, 560), names=["images", "line"]) + +# the popup keeps its state in the graphic's metadata, so one function can be used for both images +state = {"noise": 0.0, "sigma": 1.0, "filter": False} + +img1 = figure[0, 0].add_image(data1, name="img1", metadata=state.copy()) +img2 = figure[0, 0].add_image(data2, name="img2", offset=(550, 0, 0), metadata=state.copy()) + +line = figure[0, 1].add_line(np.sin(np.linspace(0, 4 * np.pi, 100)), name="line") + +raw = {img1: data1, img2: data2} + + +# append elements to the standard right-click menu +@figure.append_imgui_right_click() +def more_items(fig): + imgui.separator() + if imgui.menu_item("Autoscale all subplots", "", False)[0]: + for subplot in fig: + subplot.auto_scale() + + +# a popup set on a subplot replaces the standard menu within that subplot +@figure[0, 1].set_imgui_right_click() +def line_popup(subplot): + imgui.text(f"subplot: {subplot.name}") + imgui.separator() + _, line.thickness = imgui.slider_float("thickness", line.thickness, 1.0, 20.0) + changed, color = imgui.color_edit3("color", tuple(float(c) for c in line.colors)[:3]) + if changed: + line.colors = (*color, 1.0) + + +# a popup can contain any imgui elements, it is not restricted to menu items +def image_processing(image): + ui = image.metadata + + imgui.text(image.name) + imgui.separator() + + changed_noise, ui["noise"] = imgui.slider_float("noise sigma", ui["noise"], 0.0, 100.0) + changed_filter, ui["filter"] = imgui.checkbox("gaussian filter", ui["filter"]) + + imgui.begin_disabled(not ui["filter"]) + changed_sigma, ui["sigma"] = imgui.slider_float("filter sigma", ui["sigma"], 0.1, 10.0) + imgui.end_disabled() + + if imgui.button("reset"): + ui.update(noise=0.0, sigma=1.0, filter=False) + changed_noise = True + + if changed_noise or changed_filter or changed_sigma: + data = raw[image] + np.random.normal(scale=ui["noise"], size=raw[image].shape) + + if ui["filter"]: + data = gaussian_filter(data, sigma=ui["sigma"]) + + image.data = data + + +# the same function on both images, each graphic gets its own popup and is passed to the function +img1.set_imgui_right_click(image_processing) +img2.set_imgui_right_click(image_processing) + +figure.show() +figure[0, 1].camera.maintain_aspect = False + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/guis/imgui_top.py b/examples/guis/imgui_top.py index e1f865fe0..5a29534c8 100644 --- a/examples/guis/imgui_top.py +++ b/examples/guis/imgui_top.py @@ -11,8 +11,8 @@ import numpy as np import fastplotlib as fpl -# subclass from EdgeWindow to make a custom ImGUI Window to place inside the figure! -from fastplotlib.ui import EdgeWindow +# subclass from ImguiWindow to make a custom ImGUI Window to place inside the figure! +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui # make some initial data @@ -27,31 +27,29 @@ figure = fpl.Figure(size=(700, 560)) # make some scatter points at every 10th point -figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", uniform_color=True) +figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", color_mode="uniform") # place a line above the scatter -figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", uniform_color=True) +figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", color_mode="uniform") -class ImguiExample(EdgeWindow): - def __init__(self, figure, size, location, title): - super().__init__(figure=figure, size=size, location=location, title=title, window_flags=imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_resize) - +class ImguiExample(ImguiWindow): def update(self): imgui.text("This is a top window") # make GUI instance -gui = ImguiExample( - figure, # the figure this GUI instance should live inside - size=30, # width or height of the GUI window within the figure - location="top", # the edge to place this window at - title=" ", # window title +gui = ImguiExample() + +# add it to the top edge of the figure +figure.add_imgui_window( + gui, + location="top", + size=60, + title="top window", + window_flags=imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_resize, ) -# add it to the figure -figure.add_gui(gui) - figure.show() # NOTE: fpl.loop.run() should not be used for interactive sessions diff --git a/examples/guis/sine_cosine_funcs.py b/examples/guis/sine_cosine_funcs.py index 935f9a5a1..be260d782 100644 --- a/examples/guis/sine_cosine_funcs.py +++ b/examples/guis/sine_cosine_funcs.py @@ -11,7 +11,7 @@ import numpy as np import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui @@ -129,9 +129,9 @@ def set_x_val(ev): sine_selector.selection = 50 -class GUIWindow(EdgeWindow): - def __init__(self, figure, size, location, title): - super().__init__(figure=figure, size=size, location=location, title=title) +class GUIWindow(ImguiWindow): + def __init__(self): + super().__init__() self._p = 1 self._q = 1 @@ -166,14 +166,9 @@ def update(self): self._set_data() -gui = GUIWindow( - figure=figure, - size=100, - location="right", - title="Freq. coeffs" -) +gui = GUIWindow() -figure.add_gui(gui) +figure.add_imgui_window(gui, location="right", size=150, title="Freq. coeffs") figure.show() diff --git a/examples/image_volume/image_volume_4d.py b/examples/image_volume/image_volume_4d.py index 34bf9b903..9782fabdc 100644 --- a/examples/image_volume/image_volume_4d.py +++ b/examples/image_volume/image_volume_4d.py @@ -11,6 +11,7 @@ import numpy as np from scipy.ndimage import gaussian_filter import fastplotlib as fpl +from fastplotlib.ui import ImguiColorbar def generate_data( @@ -67,12 +68,9 @@ def generate_data( alpha_mode="add", ) -hlut = fpl.HistogramLUTTool(voldata, volume) - -figure[0, 0].docks["right"].size = 100 -figure[0, 0].docks["right"].controller.enabled = False -figure[0, 0].docks["right"].add_graphic(hlut) -figure[0, 0].docks["right"].auto_scale(maintain_aspect=False) +# a colorbar with a histogram of the entire 4D dataset +colorbar = ImguiColorbar(images=volume, histogram=np.histogram(voldata, bins=100)) +figure[0, 0].add_imgui_window(colorbar, location="right", size=100) figure.show() diff --git a/examples/image_volume/image_volume_render_modes.py b/examples/image_volume/image_volume_render_modes.py index 36705d17d..943612887 100644 --- a/examples/image_volume/image_volume_render_modes.py +++ b/examples/image_volume/image_volume_render_modes.py @@ -10,7 +10,7 @@ import numpy as np import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiColorbar, ImguiWindow from fastplotlib.graphics.features import VOLUME_RENDER_MODES import imageio.v3 as iio from imgui_bundle import imgui @@ -25,58 +25,52 @@ figure[0, 0].add_image_volume(voldata, name="vol-img") -# add an hlut tool -hlut = fpl.HistogramLUTTool(voldata, figure[0, 0]["vol-img"]) - -figure[0, 0].docks["right"].size = 80 -figure[0, 0].docks["right"].controller.enabled = False -figure[0, 0].docks["right"].add_graphic(hlut) -figure[0, 0].docks["right"].auto_scale(maintain_aspect=False) - - -class GUI(EdgeWindow): - def __init__(self, figure, title="Render options", location="right", size=300): - super().__init__(figure, title=title, location=location, size=size) +# add a colorbar with a histogram of the volume data +colorbar = ImguiColorbar( + images=figure[0, 0]["vol-img"], histogram=np.histogram(voldata, bins=100) +) +figure[0, 0].add_imgui_window(colorbar, location="right", size=100) - # reference to the graphic for convenience - self.graphic: fpl.ImageVolumeGraphic = self._figure[0, 0]["vol-img"] +class GUI(ImguiWindow): def update(self): + graphic: fpl.ImageVolumeGraphic = self._figure[0, 0]["vol-img"] + imgui.text("Switch render mode:") # add buttons to switch between modes for mode in VOLUME_RENDER_MODES.keys(): if imgui.button(mode): - self.graphic.mode = mode + graphic.mode = mode # add sliders to change iso rendering properties - if self.graphic.mode == "iso": - _, self.graphic.threshold = imgui.slider_float( - "threshold", v=self.graphic.threshold, v_max=255, v_min=1, + if graphic.mode == "iso": + _, graphic.threshold = imgui.slider_float( + "threshold", v=graphic.threshold, v_max=255, v_min=1, ) - _, self.graphic.step_size = imgui.slider_float( - "step_size", v=self.graphic.step_size, v_max=10.0, v_min=0.1, + _, graphic.step_size = imgui.slider_float( + "step_size", v=graphic.step_size, v_max=10.0, v_min=0.1, ) - _, self.graphic.substep_size = imgui.slider_float( - "substep_size", v=self.graphic.substep_size, v_max=10.0, v_min=0.1, + _, graphic.substep_size = imgui.slider_float( + "substep_size", v=graphic.substep_size, v_max=10.0, v_min=0.1, ) - col = imgui.ImVec4((*self.graphic.emissive.rgb, 1)) - _, self.graphic.emissive = imgui.color_picker3("emissive color", col=col) + col = imgui.ImVec4((*graphic.emissive.rgb, 1)) + _, graphic.emissive = imgui.color_picker3("emissive color", col=col) - if self.graphic.mode == "slice": + if graphic.mode == "slice": imgui.text("Select plane defined by:\nax + by + cz + d = 0") - _, a = imgui.slider_float("a", v=self.graphic.plane[0], v_min=-1, v_max=1.0) - _, b = imgui.slider_float("b", v=self.graphic.plane[1], v_min=-1, v_max=1.0) - _, c = imgui.slider_float("c", v=self.graphic.plane[2], v_min=-1, v_max=1.0) + _, a = imgui.slider_float("a", v=graphic.plane[0], v_min=-1, v_max=1.0) + _, b = imgui.slider_float("b", v=graphic.plane[1], v_min=-1, v_max=1.0) + _, c = imgui.slider_float("c", v=graphic.plane[2], v_min=-1, v_max=1.0) - largest_dim = max(self.graphic.data.value.shape) - _, d = imgui.slider_float("d", v=self.graphic.plane[3], v_min=0, v_max=largest_dim * 2) + largest_dim = max(graphic.data.value.shape) + _, d = imgui.slider_float("d", v=graphic.plane[3], v_min=0, v_max=largest_dim * 2) - self.graphic.plane = (a, b, c, d) + graphic.plane = (a, b, c, d) -gui = GUI(figure=figure) -figure.add_gui(gui) +gui = GUI() +figure.add_imgui_window(gui, location="right", size=300, title="Render options") figure.show() diff --git a/examples/image_volume/image_volume_share_buffer.py b/examples/image_volume/image_volume_share_buffer.py index cc9f07915..86bb372e8 100644 --- a/examples/image_volume/image_volume_share_buffer.py +++ b/examples/image_volume/image_volume_share_buffer.py @@ -11,7 +11,7 @@ from imgui_bundle import imgui import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiWindow import imageio.v3 as iio from skimage.filters import gaussian @@ -37,9 +37,9 @@ ) -class GUI(EdgeWindow): - def __init__(self, figure, title="change data buffer", location="right", size=200): - super().__init__(figure, title=title, location=location, size=size) +class GUI(ImguiWindow): + def __init__(self): + super().__init__() self._sigma = 2 def update(self): @@ -62,8 +62,8 @@ def update(self): vol_slice.plane = (a, b, c, d) -gui = GUI(figure) -figure.add_gui(gui) +gui = GUI() +figure.add_imgui_window(gui, location="right", size=200, title="change data buffer") figure.show() diff --git a/examples/image_widget/README.rst b/examples/image_widget/README.rst deleted file mode 100644 index f445f7390..000000000 --- a/examples/image_widget/README.rst +++ /dev/null @@ -1,2 +0,0 @@ -ImageWidget Examples -==================== diff --git a/examples/image_widget/image_widget.py b/examples/image_widget/image_widget.py deleted file mode 100644 index a3c332182..000000000 --- a/examples/image_widget/image_widget.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Image widget -============ - -Example showing the image widget in action. - -Every image in an `ImageWidget` is associated with an interactive Histogram LUT tool and colorbar. Right-click the -colorbar to pick colormaps. -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'screenshot' - -import fastplotlib as fpl -import imageio.v3 as iio # not a fastplotlib dependency, only used for examples - -a = iio.imread("imageio:camera.png") -iw = fpl.ImageWidget(data=a, cmap="viridis", figure_kwargs={"size": (700, 560)}) -iw.show() - -# Access ImageGraphics managed by the image widget -iw.figure[0, 0]["image_widget_managed"].data[:50, :50] = 0 -iw.figure[0, 0]["image_widget_managed"].cmap = "gnuplot2" - -# another way to access the image widget managed ImageGraphics -iw.managed_graphics[0].data[450:, 450:] = 255 - -figure = iw.figure - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/image_widget/image_widget_grid.py b/examples/image_widget/image_widget_grid.py deleted file mode 100644 index 41e964e95..000000000 --- a/examples/image_widget/image_widget_grid.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Image widget grid -================= - -Example showing how to view multiple images in an ImageWidget -""" - -import fastplotlib as fpl -import imageio.v3 as iio - -# test_example = true -# sphinx_gallery_pygfx_docs = 'screenshot' - -img1 = iio.imread("imageio:camera.png") -img2 = iio.imread("imageio:astronaut.png") -img3 = iio.imread("imageio:chelsea.png") -img4 = iio.imread("imageio:wikkie.png") - -iw = fpl.ImageWidget( - data=[img1, img2, img3, img4], - rgb=[False, True, True, True], # mix of grayscale and RGB images - names=["cameraman", "astronaut", "chelsea", "Almar's cat"], - # ImageWidget will sync controllers by default - # by setting `controller_ids=None` we can have independent controllers for each subplot - # this is useful when the images have different dimensions - figure_kwargs={"size": (700, 560), "controller_ids": None}, -) -iw.show() - -figure = iw.figure - -for subplot in figure: - # sometimes the toolbar adds clutter - subplot.toolbar = False - - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/image_widget/image_widget_single_video.py b/examples/image_widget/image_widget_single_video.py deleted file mode 100644 index 86ca642fa..000000000 --- a/examples/image_widget/image_widget_single_video.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Image widget Video -================== - -Example showing how to scroll through one or more videos using the ImageWidget -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'animate 6s 20fps' - -import fastplotlib as fpl -import imageio.v3 as iio -import numpy as np - - -movie = iio.imread("imageio:cockatoo.mp4") - -# Ignore and do not use the next 2 lines -# for the purposes of docs gallery generation we subsample and only use 15 frames -movie_sub = movie[:15, ::12, ::12].copy() -del movie - -iw = fpl.ImageWidget(movie_sub, rgb=True, figure_kwargs={"size": (700, 560)}) - -# ImageWidget supports setting window functions the `time` "t" or `volume` "z" dimension -# These can also be given as kwargs to `ImageWidget` during instantiation -# to set a window function, give a dict in the form of {dim: (func, window_size)} -iw.window_funcs = {"t": (np.mean, 13)} - -# change the window size -iw.window_funcs["t"].window_size = 33 - -# change the function -iw.window_funcs["t"].func = np.max - -# or reset it -iw.window_funcs = None - -iw.show() - -figure = iw.figure - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/image_widget/image_widget_videos.py b/examples/image_widget/image_widget_videos.py deleted file mode 100644 index 399abbcff..000000000 --- a/examples/image_widget/image_widget_videos.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Image widget videos side by side -================================ - -Example showing how to scroll through one or more videos using the ImageWidget -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'animate 6s 20fps' - -import fastplotlib as fpl -import imageio.v3 as iio -import numpy as np - - -# load the standard cockatoo video -cockatoo = iio.imread("imageio:cockatoo.mp4") - -# Ignore and do not use the next 2 lines -# for the purposes of docs gallery generation we subsample and only use 15 frames -cockatoo_sub = cockatoo[:15, ::12, ::12].copy() -del cockatoo - -# make a random grayscale video, shape is [t, rows, cols] -np.random.seed(0) -random_data = np.random.rand(*cockatoo_sub.shape[:-1]) - -iw = fpl.ImageWidget( - [random_data, cockatoo_sub], - rgb=[False, True], - figure_shape=(2, 1), # 2 rows, 1 column - figure_kwargs={"size": (700, 940)} -) - -iw.show() - -figure = iw.figure - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/image_widget/image_widget_viewports_check.py b/examples/image_widget/image_widget_viewports_check.py deleted file mode 100644 index a4c0aea03..000000000 --- a/examples/image_widget/image_widget_viewports_check.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -ImageWidget test viewport rects -=============================== - -Test Figure to test that viewport rects are positioned correctly in an image widget -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'hidden' - -import fastplotlib as fpl -import numpy as np - -np.random.seed(0) -a = np.random.rand(6, 15, 10, 10) - -iw = fpl.ImageWidget( - data=[img for img in a], - names=list(map(str, range(6))), - figure_kwargs={"size": (700, 560)}, -) - -for subplot in iw.figure: - subplot.docks["left"].size = 10 - subplot.docks["bottom"].size = 40 - -iw.show() - -figure = iw.figure - -# NOTE: fpl.loop.run() should not be used for interactive sessions -# See the "JupyterLab and IPython" section in the user guide -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/misc/buffer_replace_gc.py b/examples/misc/buffer_replace_gc.py index e3b0ac104..2f6ec992b 100644 --- a/examples/misc/buffer_replace_gc.py +++ b/examples/misc/buffer_replace_gc.py @@ -14,7 +14,7 @@ from typing import Literal import numpy as np import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui @@ -37,14 +37,14 @@ def generate_dataset(size: int) -> dict[str, np.ndarray]: } -class UI(EdgeWindow): +class UI(ImguiWindow): def __init__(self, figure): - super().__init__(figure=figure, size=200, location="right", title="UI") + super().__init__() init_data = datasets["init"] - self._figure["line"].add_line( + figure["line"].add_line( data=init_data["data"], colors=init_data["colors"], name="line" ) - self._figure["scatter"].add_scatter( + figure["scatter"].add_scatter( **init_data, uniform_size=False, uniform_marker=False, @@ -79,7 +79,7 @@ def _replace( figure = fpl.Figure(shape=(3, 1), size=(700, 1600), names=["line", "scatter", "image"]) ui = UI(figure) -figure.add_gui(ui) +figure.add_imgui_window(ui, location="right", size=200, title="UI") figure.show() diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 00e31c977..1e7b30854 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -25,7 +25,7 @@ else: from .layouts import Figure -from .widgets import NDWidget, ImageWidget +from .widgets import NDWidget if len(enumerate_adapters()) < 1: diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 95a941f8b..24a59a7e4 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -180,7 +180,7 @@ def __init__( self._axes: Axes = None - self._right_click_menu = None + self._imgui_right_click = None # store ids of all the WorldObjects that this Graphic manages/uses self._world_object_ids = list() @@ -692,21 +692,111 @@ def add_axes(self): self._axes.update_using_bbox(self.world_object.get_world_bounding_box()) @property - def right_click_menu(self): - return self._right_click_menu + def imgui_right_click(self): + """ + The imgui popup that is opened by a right-click on this graphic. + + Returns + ------- + ImguiPopup | None + + """ + return self._imgui_right_click + + def set_imgui_right_click(self, popup=None, *, window_flags=None): + """ + Set the imgui popup that is opened by a right-click on this graphic, replaces the popup of the subplot or + Figure for this graphic. Can also be used as a decorator, see the + ``ImguiFigure.set_imgui_right_click`` examples. + + Parameters + ---------- + popup: ImguiPopup | callable, optional + an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags for the popup - @right_click_menu.setter - def right_click_menu(self, menu): + """ if not IMGUI: raise ImportError( - "imgui is required to set right-click menus:\npip install imgui_bundle" + "imgui is required to set right-click popups:\npip install imgui_bundle" + ) + + from ..layouts._subplot import Subplot + from ..ui._base import ImguiPopup, _wrap_update_call + + if not isinstance(self._plot_area, Subplot): + raise TypeError( + "graphic must be added to a subplot before setting an imgui right-click popup on it" + ) + + figure = self._plot_area.get_figure() + if "Imgui" not in figure.__class__.__name__: + raise TypeError( + "imgui right-click popups can only be set on a graphic in an ImguiFigure" + ) + + def decorator(_popup): + if isinstance(_popup, ImguiPopup): + p = _popup + elif callable(_popup): + p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) + else: + raise TypeError( + "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " + "function that draws imgui elements" + ) + + p._fpl_add_hook(figure=figure, parent=self, window_flags=window_flags) + self._imgui_right_click = p + return _popup + + if popup is None: + return decorator + + decorator(popup) + return popup + + def append_imgui_right_click(self, gui=None): + """ + Append imgui elements to the right-click popup of this graphic. Can also be used as a decorator. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + """ + from ..ui._base import _wrap_update_call + + popup = self._imgui_right_click + if popup is None: + raise ValueError( + "no imgui right-click popup set on this graphic to append to, set one using " + "`graphic.set_imgui_right_click()`" ) - self._right_click_menu = menu - menu.owner = self + def decorator(_gui): + popup._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) - def _fpl_request_right_click_menu(self): - pass + def remove_imgui_right_click(self): + """ + Remove and return the right-click popup of this graphic + + Returns + ------- + ImguiPopup + the removed popup, it can be set again later + + """ + popup = self._imgui_right_click + self._imgui_right_click = None - def _fpl_close_right_click_menu(self): - pass + return popup diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index a04b1c991..1d2359f96 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -30,6 +30,7 @@ TextureYUV, TupleYUV, ImageCmap, + ImageGamma, ImageVmin, ImageVmax, ImageInterpolation, @@ -98,6 +99,7 @@ "TextureYUV", "TupleYUV", "ImageCmap", + "ImageGamma", "ImageVmin", "ImageVmax", "ImageInterpolation", diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 12df0b6b7..1d9092de5 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -479,6 +479,34 @@ def set_value(self, graphic, value: float): self._call_event_handlers(event) +class ImageGamma(GraphicFeature): + """gamma correction applied to the image""" + + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new gamma value", + }, + ] + + def __init__(self, value: float, property_name: str = "gamma"): + self._value = value + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.gamma = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + class ImageCmap(GraphicFeature): """colormap for texture""" diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 2452733d3..908f92347 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -19,6 +19,7 @@ TextureYUV, TupleYUV, ImageCmap, + ImageGamma, ImageVmin, ImageVmax, ImageInterpolation, @@ -125,6 +126,15 @@ def vmax(self) -> float: def vmax(self, value: float): self._vmax.set_value(self, value) + @property + def gamma(self) -> float: + """gamma correction applied to the image""" + return self._gamma.value + + @gamma.setter + def gamma(self, value: float): + self._gamma.set_value(self, value) + @property def interpolation(self) -> str: """Data interpolation method""" @@ -372,6 +382,7 @@ class ImageGraphic(ImageBase): _features = { "data": TextureArray, "cmap": ImageCmap, + "gamma": ImageGamma, "vmin": ImageVmin, "vmax": ImageVmax, "interpolation": ImageInterpolation, @@ -384,6 +395,7 @@ def __init__( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "nearest", cmap_interpolation: str = "linear", colorspace: ColorspacesRGB = "srgb", @@ -409,6 +421,9 @@ def __init__( colormap to use to display the data. For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" @@ -418,35 +433,39 @@ def __init__( colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" colorspace in which to interpret the provided data. - * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. - sRGB is a standard color space designed for consistent representation of colors - across devices like monitors. Most images store colors in this space. - The shader convers sRGB colors to physical in the shader before doing color computations. + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. - * "tex-srgb": the underlying texture will be of an sRGB format. This means the data - is automatically converted to sRGB when it is sampled. This results in better glTF - compliance (because interpolation in the sampling happens in linear space). - Note that sampling *always* results in the sRGB values, also when not interpreted as color. - Only supported for rgb and rgba data. + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. - * "physical": the colors are (already) in the physical / linear space, where lighting - calculations can be applied. Shader code that interprets the data as color will use it as-is. + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. cpu_buffer: bool, default True If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer on the GPU. If ``False``, setting the graphic data will send the new data directly to the GPU, we also call this "bufferless". This is much faster but lacks the following features: - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you cannot perform partial updates such as ``image.data[indices] = ``. - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. - * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic - * ``reset_vmin_vmax()`` is not supported - * selector tools will not be able to return the data under the selection + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -482,6 +501,7 @@ def __init__( # other graphic features self._vmin = ImageVmin(vmin) self._vmax = ImageVmax(vmax) + self._gamma = ImageGamma(gamma) self._interpolation = ImageInterpolation(interpolation) self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) @@ -508,6 +528,7 @@ def __init__( interpolation=self._interpolation.value, pick_write=True, ) + self._material.gamma = gamma # create the _ImageTile world objects, add to group for tile in self._create_tiles(): @@ -641,6 +662,7 @@ def reset_vmin_vmax(self): class ImageYUVGraphic(ImageBase): _features = { "data": TextureYUV, + "gamma": ImageGamma, "vmin": ImageVmin, "vmax": ImageVmax, "interpolation": ImageInterpolation, @@ -651,6 +673,7 @@ def __init__( data: TupleYUV | TextureYUV, vmin: float = 0, vmax: float = 255, + gamma: float = 1.0, interpolation: str = "nearest", colorspace: ColorspacesYUV = "yuv420p", colorrange: ColorRange = "limited", @@ -676,25 +699,28 @@ def __init__( vmax: float, optional, default 255 maximum value for color scaling + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" colorspace: "yuv42p" | "yuv444p" colorspace in which to interpret the provided data. - * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). - The y represents intensity, and is at full resolution. The u and v planes are a - quarter of the size. + * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). + The y represents intensity, and is at full resolution. The u and v planes are a + quarter of the size. - * "yuv444p": A lesser common video format. The data is represented as 3 planes - (y, u, and v) similar to yuv420p however the u and v planes are stored - at full resolution. + * "yuv444p": A lesser common video format. The data is represented as 3 planes + (y, u, and v) similar to yuv420p however the u and v planes are stored + at full resolution. colorrange: Literal["full", "limited"] = "limited", Relevant for yuv colorspaces. Most videos use "limited". * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. - The chroma planes (U and V) are limited to the range of 16-240 for 8 bits + The chroma planes (U and V) are limited to the range of 16-240 for 8 bits * "full": The luma plane and chroma plane use the full range of the storage format. See the following links from the FFMPEG documentation for more details: @@ -706,11 +732,14 @@ def __init__( on the GPU. If ``False``, setting the graphic data will send the new data directly to the GPU, we also call this "bufferless". This is much faster but lacks the following features: - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you cannot perform partial updates such as ``image.data[indices] = ``. - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. @@ -728,12 +757,14 @@ def __init__( self._vmin = ImageVmin(vmin) self._vmax = ImageVmax(vmax) + self._gamma = ImageGamma(gamma) self._interpolation = ImageInterpolation(interpolation) self._material = HighlightableImageMaterial( clim=(vmin, vmax), interpolation=self.interpolation, pick_write=True ) + self._material.gamma = gamma wo = pygfx.Image( geometry=pygfx.Geometry(grid=self.data._texture), diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 3d2d064e8..2154acdb8 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -8,6 +8,7 @@ from .features import ( TextureArrayVolume, ImageCmap, + ImageGamma, ImageVmin, ImageVmax, ImageInterpolation, @@ -85,6 +86,7 @@ class ImageVolumeGraphic(Graphic): _features = { "data": TextureArrayVolume, "cmap": ImageCmap, + "gamma": ImageGamma, "vmin": ImageVmin, "vmax": ImageVmax, "interpolation": ImageInterpolation, @@ -105,6 +107,7 @@ def __init__( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "linear", cmap_interpolation: str = "linear", plane: tuple[float, float, float, float] = (0, 0, -1, 0), @@ -136,6 +139,9 @@ def __init__( cmap: str, default "plasma" colormap for grayscale volumes + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, default "linear" interpolation method for sampling pixels @@ -202,6 +208,7 @@ def __init__( # other graphic features self._vmin = ImageVmin(vmin) self._vmax = ImageVmax(vmax) + self._gamma = ImageGamma(gamma) self._interpolation = ImageInterpolation(interpolation) self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) @@ -234,6 +241,7 @@ def __init__( VolumeMaterialCls = VOLUME_RENDER_MODES[mode] self._material = VolumeMaterialCls(**material_kwargs) + self._material.gamma = gamma self._mode = VolumeRenderMode(mode) @@ -332,6 +340,15 @@ def vmax(self) -> float: def vmax(self, value: float): self._vmax.set_value(self, value) + @property + def gamma(self) -> float: + """gamma correction applied to the image""" + return self._gamma.value + + @gamma.setter + def gamma(self, value: float): + self._gamma.set_value(self, value) + @property def interpolation(self) -> str: """Get or set the image data interpolation method""" diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index f166c18ae..edb01f482 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -855,9 +855,6 @@ def export(self, uri: str | Path | bytes, **kwargs): return iio.imwrite(uri, snapshot, **kwargs) - def open_popup(self, *args, **kwargs): - warn("popups only supported by ImguiFigure") - def _fpl_reset_layout(self, *ev): """set the viewport rects for all subplots, *ev argument is not used, exists because of renderer resize event""" self.layout.canvas_resized(self.get_pygfx_render_area()) diff --git a/fastplotlib/layouts/_frame.py b/fastplotlib/layouts/_frame.py index 1c308590f..3b3fab12e 100644 --- a/fastplotlib/layouts/_frame.py +++ b/fastplotlib/layouts/_frame.py @@ -115,6 +115,7 @@ def __init__( resizeable, title, docks, + imgui_windows, toolbar_visible, canvas_rect, ): @@ -144,6 +145,9 @@ def __init__( docks: dict[str, PlotArea] subplot dock + imgui_windows: dict[str, ImguiWindow] + imgui windows confined to this subplot, keyed by location + toolbar_visible: bool toolbar visibility @@ -154,6 +158,7 @@ def __init__( self.viewport = viewport self.docks = docks + self._imgui_windows = imgui_windows self._toolbar_visible = toolbar_visible # create rect manager to handle all the backend rect calculations @@ -254,11 +259,35 @@ def rect(self, rect: np.ndarray): self.reset_viewport() def reset_viewport(self): - """reset the viewport rect for the subplot and docks""" + """reset the viewport rect for the subplot, docks, and imgui windows""" # get rect of the render area x, y, w, h = self.get_render_rect() + # imgui edge windows reserve space outboard of the docks + g_left = self._imgui_size("left") + g_top = self._imgui_size("top") + g_right = self._imgui_size("right") + g_bottom = self._imgui_size("bottom") + + # top and bottom imgui windows are inset by the left and right imgui windows + w_g_top_bottom = w - g_left - g_right + x_g_top_bottom = x + g_left + + # set imgui edge window rects + self._set_imgui_rect("left", (x, y, g_left, h)) + self._set_imgui_rect("top", (x_g_top_bottom, y, w_g_top_bottom, g_top)) + self._set_imgui_rect( + "bottom", (x_g_top_bottom, y + h - g_bottom, w_g_top_bottom, g_bottom) + ) + self._set_imgui_rect("right", (x + w - g_right, y, g_right, h)) + + # shrink the render area to fit inside the imgui edge windows + x += g_left + y += g_top + w -= g_left + g_right + h -= g_top + g_bottom + # dock sizes s_left = self.docks["left"].size s_top = self.docks["top"].size @@ -291,6 +320,31 @@ def reset_viewport(self): # set subplot rect self.viewport.rect = x, y, w, h + # toolbar occupies the reserved bottom band of the frame + self._set_toolbar_rect() + + def _imgui_size(self, location: str) -> int: + """thickness in pixels reserved by the imgui edge window at ``location``, 0 if none""" + window = self._imgui_windows.get(location) + return window.size if window is not None else 0 + + def _set_imgui_rect(self, location: str, rect: tuple): + """set the pixel rect of the imgui edge window at ``location``, if present""" + window = self._imgui_windows.get(location) + if window is not None: + window._fpl_set_rect(*(round(v) for v in rect)) + + def _set_toolbar_rect(self): + """set the pixel rect of the subplot toolbar window, if present""" + window = self._imgui_windows.get("toolbar") + if window is None: + return + + x, y, w, h = self.rect + window._fpl_set_rect( + round(x + 1), round(y + h - IMGUI_TOOLBAR_HEIGHT), round(w - 2), IMGUI_TOOLBAR_HEIGHT + ) + def get_render_rect(self) -> tuple[float, float, float, float]: """ Get the actual render area of the subplot, including the docks. diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 9eae4dd12..ac1e81414 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -4,10 +4,13 @@ import numpy +from numpy.typing import NDArray + import pygfx from ..graphics import * from ..graphics._base import Graphic +from ..utils import enums import typing import fastplotlib @@ -33,6 +36,7 @@ def add_image( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "nearest", cmap_interpolation: str = "linear", colorspace: fastplotlib.utils.enums.ColorspacesRGB = "srgb", @@ -59,6 +63,9 @@ def add_image( colormap to use to display the data. For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" @@ -68,34 +75,39 @@ def add_image( colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" colorspace in which to interpret the provided data. - * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. - sRGB is a standard color space designed for consistent representation of colors - across devices like monitors. Most images store colors in this space. - The shader convers sRGB colors to physical in the shader before doing color computations. + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. - * "tex-srgb": the underlying texture will be of an sRGB format. This means the data - is automatically converted to sRGB when it is sampled. This results in better glTF - compliance (because interpolation in the sampling happens in linear space). - Note that sampling *always* results in the sRGB values, also when not interpreted as color. - Only supported for rgb and rgba data. + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. - * "physical": the colors are (already) in the physical / linear space, where lighting - calculations can be applied. Shader code that interprets the data as color will use it as-is. + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. cpu_buffer: bool, default True If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer on the GPU. If ``False``, setting the graphic data will send the new data directly to the GPU, we also call this "bufferless". This is much faster but lacks the following features: - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you cannot perform partial updates such as ``image.data[indices] = ``. - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. - * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic - * ``reset_vmin_vmax()`` is not supported + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -108,6 +120,7 @@ def add_image( vmin, vmax, cmap, + gamma, interpolation, cmap_interpolation, colorspace, @@ -122,6 +135,7 @@ def add_image_volume( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "linear", cmap_interpolation: str = "linear", plane: tuple[float, float, float, float] = (0, 0, -1, 0), @@ -154,6 +168,9 @@ def add_image_volume( cmap: str, default "plasma" colormap for grayscale volumes + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, default "linear" interpolation method for sampling pixels @@ -199,6 +216,7 @@ def add_image_volume( vmin, vmax, cmap, + gamma, interpolation, cmap_interpolation, plane, @@ -213,15 +231,12 @@ def add_image_volume( def add_image_yuv( self, data: ( - tuple[ - numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.uint8]], - numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.uint8]], - numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.uint8]], - ] + tuple[NDArray[numpy.uint8], NDArray[numpy.uint8], NDArray[numpy.uint8]] | fastplotlib.graphics.features._image.TextureYUV ), vmin: float = 0, vmax: float = 255, + gamma: float = 1.0, interpolation: str = "nearest", colorspace: fastplotlib.utils.enums.ColorspacesYUV = "yuv420p", colorrange: fastplotlib.utils.enums.ColorRange = "limited", @@ -248,25 +263,28 @@ def add_image_yuv( vmax: float, optional, default 255 maximum value for color scaling + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" colorspace: "yuv42p" | "yuv444p" colorspace in which to interpret the provided data. - * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). - The y represents intensity, and is at full resolution. The u and v planes are a - quarter of the size. + * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). + The y represents intensity, and is at full resolution. The u and v planes are a + quarter of the size. - * "yuv444p": A lesser common video format. The data is represented as 3 planes - (y, u, and v) similar to yuv420p however the u and v planes are stored - at full resolution. + * "yuv444p": A lesser common video format. The data is represented as 3 planes + (y, u, and v) similar to yuv420p however the u and v planes are stored + at full resolution. colorrange: Literal["full", "limited"] = "limited", Relevant for yuv colorspaces. Most videos use "limited". * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. - The chroma planes (U and V) are limited to the range of 16-240 for 8 bits + The chroma planes (U and V) are limited to the range of 16-240 for 8 bits * "full": The luma plane and chroma plane use the full range of the storage format. See the following links from the FFMPEG documentation for more details: @@ -278,11 +296,14 @@ def add_image_yuv( on the GPU. If ``False``, setting the graphic data will send the new data directly to the GPU, we also call this "bufferless". This is much faster but lacks the following features: - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you cannot perform partial updates such as ``image.data[indices] = ``. - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. @@ -296,6 +317,7 @@ def add_image_yuv( data, vmin, vmax, + gamma, interpolation, colorspace, colorrange, diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index 15b3d7c45..ae7102524 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from collections.abc import Callable from pathlib import Path from typing import Literal, Iterable @@ -12,8 +14,10 @@ import pygfx from ._figure import Figure -from ..ui import EdgeWindow, SubplotToolbar, StandardRightClickMenu, Popup, GUI_EDGES -from ..ui import ColormapPicker +from ._rect import RectManager +from ._utils import IMGUI_TOOLBAR_HEIGHT +from ..ui import ImguiWindow, ImguiPopup, SubplotToolbar, StandardRightClickMenu, EDGES +from ..ui._base import _wrap_update_call class ImguiFigure(Figure): @@ -44,9 +48,16 @@ def __init__( canvas_kwargs: dict = None, size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, - std_right_click_menu: type[Popup] = StandardRightClickMenu, ): - self._guis: dict[str, EdgeWindow] = {k: None for k in GUI_EDGES} + # edge windows reserve canvas space, keyed by location; floating windows draw over the plots + self._edge_windows: dict[str, ImguiWindow] = {loc: None for loc in EDGES} + self._floating_windows: list[ImguiWindow] = [] + + # figure level right-click popup, and the popup opened by the most recent right-click + self._imgui_right_click: ImguiPopup = None + self._currently_open_imgui_right_click: ImguiPopup = None + + self._right_click_press_pos: imgui.ImVec2 = None super().__init__( shape=shape, @@ -98,35 +109,24 @@ def __init__( self.imgui_renderer.set_gui(self._draw_imgui) - self._subplot_toolbars: np.ndarray[SubplotToolbar] = np.empty( - shape=self._subplots.size, dtype=object - ) - - for i, subplot in enumerate(self._subplots.ravel()): - toolbar = SubplotToolbar(subplot=subplot) - self._subplot_toolbars[i] = toolbar - - self._std_right_click_menu = std_right_click_menu(figure=self) + for subplot in self._subplots.ravel(): + subplot.add_imgui_window( + SubplotToolbar(), location="toolbar", size=IMGUI_TOOLBAR_HEIGHT + ) - self._popups: dict[str, Popup] = {} + self.set_imgui_right_click(StandardRightClickMenu()) self.imgui_show_fps = False self._stats = Stats(self.renderer.device, self.canvas) - self.register_popup(ColormapPicker) - @property def default_imgui_font(self) -> imgui.ImFont: return self._default_imgui_font @property - def std_right_click_menu(self) -> Popup: - return self._std_right_click_menu - - @property - def guis(self) -> dict[str, EdgeWindow]: - """GUI windows added to the Figure""" - return self._guis + def imgui_windows(self) -> dict[str, ImguiWindow]: + """edge imgui windows added to the Figure, keyed by location""" + return self._edge_windows @property def imgui_renderer(self) -> ImguiRenderer: @@ -146,60 +146,236 @@ def _render(self, draw=False): self.canvas.request_draw() def _draw_imgui(self) -> imgui.ImDrawData: - # imgui.new_frame() - - for subplot, toolbar in zip( - self._subplots.ravel(), self._subplot_toolbars.ravel() - ): - if not subplot.toolbar: - # if subplot.toolbar is False + # figure-level windows: edge windows then floating windows + for window in (*self._edge_windows.values(), *self._floating_windows): + if window is None: continue - toolbar.update() + self._layout_imgui_window(window) + window.draw() + + # subplot windows, edge window rects are set by Frame.reset_viewport + for subplot in self._subplots.ravel(): + for location, window in subplot.imgui_windows.items(): + if window is None: + continue + if location == "toolbar" and not subplot.toolbar: + continue + window.draw() + + self._fpl_handle_right_click() + + # the currently open popup is drawn first, opening it closes any other popup that is still open. + # it keeps being drawn after it closes so that it can also draw its own windows + popup = self._currently_open_imgui_right_click + if popup is not None: + popup.draw() + + if self._imgui_right_click is not None and self._imgui_right_click is not popup: + self._imgui_right_click.draw() + + def add_imgui_window( + self, + window: ImguiWindow = None, + *, + location: Literal["left", "right", "top", "bottom", "floating"] = None, + size: int = None, + rect: tuple | np.ndarray = None, + extent: tuple | np.ndarray = None, + title: str = None, + window_flags: imgui.WindowFlags_ = None, + ): + """ + Add an imgui window to the Figure. Can also be used as a decorator, see examples. + + A window can be placed on an edge ("left", "right", "top", "bottom") where it reserves canvas space so it + does not cover the subplots, "floating" for an auto-sized draggable window, or at a fixed fractional or pixel + ``rect`` or ``extent`` of the canvas. An existing window at an edge ``location`` is replaced. - for gui in self.guis.values(): - if gui is not None: - gui.draw_window() + For a list of imgui elements see the imgui docs and the "imgui" section in the fastplotlib user guide. - for popup in self._popups.values(): - popup.update() + Parameters + ---------- + window: ImguiWindow, optional + an ``ImguiWindow`` instance, omit when decorating - self._std_right_click_menu.update() + location: str, "left" | "right" | "top" | "bottom" | "floating" + edge windows reserve canvas space, "floating" is auto-sized and draggable - # imgui.end_frame() + size: int + edge window thickness in pixels, required for edge windows - # imgui.render() + rect: (x, y, w, h), optional + fractional or pixel rect for a fixed floating window - # return imgui.get_draw_data() + extent: (xmin, xmax, ymin, ymax), optional + fractional or pixel extent for a fixed floating window - def add_gui(self, gui: EdgeWindow): - """ - Add a GUI to the Figure. GUIs can be added to the left or bottom edge. + title: str, optional + window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn. - Parameters - ---------- - gui: EdgeWindow - A GUI EdgeWindow instance + window_flags: ``imgui.WindowFlags_`` + imgui window flags, used when decorating; if not provided, the default depends on placement — edge + windows use ``no_collapse | no_resize | no_title_bar | no_bring_to_front_on_focus`` (custom title bar, + stays behind overlays), floating windows use ``none`` (native title bar, collapsible and movable), + fixed rect/extent windows use ``no_collapse | no_move | no_resize`` (native title bar) + + Examples + -------- + + As a decorator:: + + import numpy as np + import fastplotlib as fpl + from imgui_bundle import imgui + + figure = fpl.Figure() + figure[0, 0].add_line(np.random.rand(100)) + + @figure.add_imgui_window(location="right", title="controls", size=200) + def gui(fig): # the figure is passed if the function takes an argument + if imgui.button("reset data"): + fig[0, 0].graphics[0].data[:, 1] = np.random.rand(100) + + Instance:: + + figure.add_imgui_window(MyWindow(), location="bottom", size=100) """ - if not isinstance(gui, EdgeWindow): - raise TypeError( - f"GUI must be of type: {EdgeWindow} you have passed a {type(gui)}" + + def decorator(_window): + if isinstance(_window, ImguiWindow): + win = _window + elif callable(_window): + win = ImguiWindow(update_call=_wrap_update_call(_window, self)) + else: + raise TypeError( + "add_imgui_window() must be used as a decorator on a function, or given an `ImguiWindow` instance" + ) + + win._fpl_add_hook( + figure=self, + subplot=None, + location=location, + size=size, + rect=rect, + extent=extent, + title=title, + window_flags=window_flags, ) + self._register_imgui_window(win) + return _window + + if window is None: + return decorator - location = gui.location + decorator(window) + return window - if location not in GUI_EDGES: + def _register_imgui_window(self, window: ImguiWindow): + """store a figure-level window and reset the layout if it reserves canvas space""" + location = window.location + + if location in EDGES: + if window.size is None: + raise ValueError(f"must provide `size` for an edge window, location: {location}") + self._edge_windows[location] = window + self._fpl_reset_layout() + + elif window._floating or window._rect_manager is not None: + self._floating_windows.append(window) + + else: raise ValueError( - f"GUI does not have a valid location, valid locations are: {GUI_EDGES}, you have passed: {location}" + "imgui window must have a valid `location` (an edge or 'floating'), or a `rect` or `extent`" ) - if self.guis[location] is not None: - raise ValueError(f"GUI already exists in the desired location: {location}") + def append_imgui_window(self, gui: Callable = None, *, location: str = None): + """ + Append imgui elements to an existing edge window. Can also be used as a decorator. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + location: str, "left" | "right" | "top" | "bottom" + location of the existing window to append to + + """ + if location not in EDGES: + raise ValueError(f"valid locations to append to are: {EDGES}, you have passed: {location}") + + window = self._edge_windows[location] + if window is None: + raise ValueError(f"no imgui window at location to append to: {location}") + + def decorator(_gui): + window._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator - self.guis[location] = gui + return decorator(gui) + def remove_imgui_window(self, location: str) -> ImguiWindow: + """ + Remove and return the edge imgui window at the given location + + Parameters + ---------- + location: str + "left" | "right" | "top" | "bottom" + + Returns + ------- + ImguiWindow + the removed window, it can be added again later + + """ + if location not in EDGES: + raise ValueError(f"valid locations are: {EDGES}, you have passed: {location}") + + window = self._edge_windows[location] + self._edge_windows[location] = None self._fpl_reset_layout() + return window + + def _edge_size(self, edge: str) -> int: + """thickness in pixels reserved by the edge window at ``edge``, 0 if none""" + window = self._edge_windows[edge] + return window.size if window is not None else 0 + + def _layout_imgui_window(self, window: ImguiWindow): + """compute and set the pixel rect of a figure-level imgui window""" + if window._floating: + # imgui auto-sizes a floating window from its content, nothing to compute + return + + width, height = self.canvas.get_logical_size() + + if window._rect_manager is not None: + window._rect_manager.canvas_resized((0, 0, width, height)) + window._fpl_set_rect(*(round(v) for v in window._rect_manager.rect)) + return + + # edge window, spans the full edge minus any perpendicular edge windows + sl, sr = self._edge_size("left"), self._edge_size("right") + st, sb = self._edge_size("top"), self._edge_size("bottom") + mid_y, mid_h = st, height - st - sb + + match window.location: + case "top": + rect = (0, 0, width, st) + case "bottom": + rect = (0, height - sb, width, sb) + case "left": + rect = (0, mid_y, sl, mid_h) + case "right": + rect = (width - sr, mid_y, sr, mid_h) + + window._fpl_set_rect(*(round(v) for v in rect)) def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ @@ -214,53 +390,187 @@ def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ width, height = self.canvas.get_logical_size() - x = 0 - y = 0 - - for edge in ["right"]: - if self.guis[edge]: - width -= self._guis[edge].size - for edge in ["bottom"]: - if self.guis[edge]: - height -= self._guis[edge].size + sl, sr = self._edge_size("left"), self._edge_size("right") + st, sb = self._edge_size("top"), self._edge_size("bottom") - for edge in ["top"]: - if self.guis[edge]: - y += self._guis[edge].size - height -= self._guis[edge].size + x = sl + y = st + width = width - sl - sr + height = height - st - sb return x, y, max(1, width), max(1, height) - def register_popup(self, popup: Popup.__class__): + @property + def imgui_right_click(self) -> ImguiPopup | None: + """ + The imgui popup that is opened by a right-click within a subplot, a ``StandardRightClickMenu`` by default. + A popup set on a subplot or graphic replaces it for that subplot or graphic. """ - Register a popup class. Note that this takes the class, not an instance + return self._imgui_right_click + + def set_imgui_right_click( + self, + popup: ImguiPopup | Callable = None, + *, + window_flags: imgui.WindowFlags_ = None, + ): + """ + Set the imgui popup that is opened by a right-click within a subplot, replaces the standard right-click + menu. Can also be used as a decorator, see examples. + + For a list of imgui elements see the imgui docs and the "imgui" section in the fastplotlib user guide. Parameters ---------- - popup: Popup subclass + popup: ImguiPopup | callable, optional + an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags for the popup + + Examples + -------- + + As a decorator:: + + import numpy as np + import fastplotlib as fpl + from imgui_bundle import imgui + + figure = fpl.Figure() + figure[0, 0].add_line(np.random.rand(100)) + + @figure.set_imgui_right_click() + def popup(fig): # the figure is passed if the function takes an argument + if imgui.menu_item("autoscale", "", False)[0]: + fig.imgui_right_click.subplot.auto_scale() + + Function, the same function can be set on any number of figures, subplots or graphics:: + + def popup(subplot): + imgui.text(f"subplot: {subplot.name}") + + figure[0, 0].set_imgui_right_click(popup) + figure[0, 1].set_imgui_right_click(popup) + + Instance:: + + figure.set_imgui_right_click(MyPopup()) """ - self._popups[popup.name] = popup(self) - def open_popup(self, name: str, pos: tuple[int, int], **kwargs): + def decorator(_popup): + if isinstance(_popup, ImguiPopup): + p = _popup + elif callable(_popup): + p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) + else: + raise TypeError( + "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " + "function that draws imgui elements" + ) + + p._fpl_add_hook(figure=self, parent=self, window_flags=window_flags) + self._imgui_right_click = p + return _popup + + if popup is None: + return decorator + + decorator(popup) + return popup + + def append_imgui_right_click(self, gui: Callable = None): """ - Open a registered popup + Append imgui elements to the Figure's right-click popup, the standard right-click menu by default. Can also + be used as a decorator. Parameters ---------- - name: str - The registered name of the popup + gui: callable, optional + function that draws imgui elements, omit when decorating + + """ + popup = self._imgui_right_click + if popup is None: + raise ValueError( + "no imgui right-click popup set on this figure to append to, set one using " + "`figure.set_imgui_right_click()`" + ) + + def decorator(_gui): + popup._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator - pos: int, int - x_pos, y_pos for the popup + return decorator(gui) - kwargs - any additional kwargs to pass to the Popup's open() method + def remove_imgui_right_click(self) -> ImguiPopup: + """ + Remove and return the Figure's right-click popup + + Returns + ------- + ImguiPopup + the removed popup, it can be set again later """ + popup = self._imgui_right_click + self._imgui_right_click = None + + return popup + + def _fpl_handle_right_click(self): + """open the popup of the graphic, subplot or Figure that was right-clicked""" + if imgui.is_mouse_down(1): + if self._right_click_press_pos is None: + self._right_click_press_pos = imgui.get_mouse_pos() + return - if self._popups[name].is_open: + press_pos = self._right_click_press_pos + self._right_click_press_pos = None + + if press_pos is None or not imgui.is_mouse_released(1): + return + + pos = imgui.get_mouse_pos() + + if press_pos != pos: + # right-drag zooms the camera + return + + if imgui.is_window_hovered(imgui.HoveredFlags_.any_window): + # pointer is over an imgui window, not the pygfx render area + return + + for subplot in self._subplots.ravel(): + if subplot.viewport.is_inside(pos.x, pos.y): + break + else: return - self._popups[name].open(pos, **kwargs) + pick_info = subplot.get_pick_info((pos.x, pos.y)) + graphic = pick_info["graphic"] if pick_info is not None else None + + # the most specific popup wins + if graphic is not None and graphic.imgui_right_click is not None: + popup = graphic.imgui_right_click + elif subplot.imgui_right_click is not None: + popup = subplot.imgui_right_click + else: + popup = self._imgui_right_click + + if popup is not None: + self._fpl_open_imgui_right_click(popup, subplot=subplot, graphic=graphic) + + def _fpl_open_imgui_right_click(self, popup: ImguiPopup, subplot, graphic): + """set the popup that is drawn as the open popup, and open it""" + previous = self._currently_open_imgui_right_click + if previous is not None and previous is not popup: + previous._fpl_close() + + self._currently_open_imgui_right_click = popup + popup._fpl_open(subplot=subplot, graphic=graphic) diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index f9534b683..89329a3db 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -62,10 +62,7 @@ def __init__( self._docks = dict() - if "Imgui" in parent.__class__.__name__: - toolbar_visible = True - else: - toolbar_visible = False + toolbar_visible = "Imgui" in parent.__class__.__name__ super().__init__( parent=parent, @@ -83,6 +80,11 @@ def __init__( self.docks[pos] = dv self.children.append(dv) + # imgui windows confined to this subplot, keyed by location + self._imgui_windows = {loc: None for loc in ["left", "right", "top", "bottom", "toolbar"]} + + self._imgui_right_click = None + self._axes = Axes(self) self.scene.add(self.axes.world_object) @@ -93,6 +95,7 @@ def __init__( resizeable=resizeable, title=name, docks=self.docks, + imgui_windows=self._imgui_windows, toolbar_visible=toolbar_visible, canvas_rect=parent.get_pygfx_render_area(), ) @@ -165,6 +168,251 @@ def frame(self) -> Frame: """Frame that the subplot lives in""" return self._frame + @property + def imgui_windows(self) -> dict: + """ + The imgui windows of this subplot, keyed by location. + + The locations are the four edges ["left", "right", "top", "bottom"] and "toolbar" + + Returns + ------- + dict[str, ImguiWindow] + {location: ImguiWindow} + + """ + return self._imgui_windows + + def add_imgui_window( + self, + window=None, + *, + location: str = None, + size: int = None, + title: str = None, + window_flags=None, + ): + """ + Add an imgui window confined to this subplot. Can also be used as a decorator, see the + ``Figure.add_imgui_window`` examples. + + Edge windows ("left", "right", "top", "bottom") reserve space outboard of the subplot dock on that edge. + The "toolbar" location replaces the subplot toolbar. An existing window at a ``location`` is replaced. + + Parameters + ---------- + window: ImguiWindow, optional + an ``ImguiWindow`` instance, omit when decorating + + location: str, "left" | "right" | "top" | "bottom" | "toolbar" + edge windows reserve canvas space, "toolbar" replaces the subplot toolbar + + size: int + edge or toolbar thickness in pixels, required for edge windows + + title: str, optional + window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags, used when decorating, uses the ``ImguiWindow`` default flags if not provided + + """ + figure = self.get_figure() + if "Imgui" not in figure.__class__.__name__: + raise TypeError("imgui windows can only be added to a subplot of an ImguiFigure") + + from ..ui._base import ImguiWindow, EDGES, _wrap_update_call + + valid = EDGES + ["toolbar"] + if location not in valid: + raise ValueError( + f"subplot imgui window location must be one of: {valid}, you have passed: {location}" + ) + if location in EDGES and size is None: + raise ValueError(f"must provide `size` for an edge window, location: {location}") + + hook_kwargs = dict(figure=figure, subplot=self, location=location, size=size, title=title) + if window_flags is not None: + hook_kwargs["window_flags"] = window_flags + + def decorator(_window): + if isinstance(_window, ImguiWindow): + win = _window + elif callable(_window): + win = ImguiWindow(update_call=_wrap_update_call(_window, self)) + else: + raise TypeError( + "add_imgui_window() must be used as a decorator on a function, or given an `ImguiWindow` instance" + ) + + win._fpl_add_hook(**hook_kwargs) + self._imgui_windows[location] = win + + # edge windows reserve space, reset the layout + if location in EDGES: + figure._fpl_reset_layout() + + return _window + + if window is None: + return decorator + + decorator(window) + return window + + def append_imgui_window(self, gui=None, *, location: str = None): + """ + Append imgui elements to an existing window of this subplot. Can also be used as a decorator. Useful for + appending elements to the subplot toolbar with ``location="toolbar"``. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + location: str, "left" | "right" | "top" | "bottom" | "toolbar" + location of the existing window to append to + + """ + from ..ui._base import _wrap_update_call + + window = self._imgui_windows.get(location) + if window is None: + raise ValueError(f"no imgui window at location to append to: {location}") + + def decorator(_gui): + window._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) + + def remove_imgui_window(self, location: str): + """ + Remove and return the imgui window at the given location + + Parameters + ---------- + location: str + "left" | "right" | "top" | "bottom" | "toolbar" + + Returns + ------- + ImguiWindow + the removed window, it can be added again later + + """ + from ..ui._base import EDGES + + window = self._imgui_windows.get(location) + self._imgui_windows[location] = None + + # edge windows reserve space, reset the layout + if location in EDGES: + self.get_figure()._fpl_reset_layout() + + return window + + @property + def imgui_right_click(self): + """ + The imgui popup that is opened by a right-click within this subplot. + + Returns + ------- + ImguiPopup | None + + """ + return self._imgui_right_click + + def set_imgui_right_click(self, popup=None, *, window_flags=None): + """ + Set the imgui popup that is opened by a right-click within this subplot, replaces the Figure's popup within + this subplot. Can also be used as a decorator, see the ``ImguiFigure.set_imgui_right_click`` examples. + + Parameters + ---------- + popup: ImguiPopup | callable, optional + an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags for the popup + + """ + figure = self.get_figure() + if "Imgui" not in figure.__class__.__name__: + raise TypeError( + "imgui right-click popups can only be set on a subplot of an ImguiFigure" + ) + + from ..ui._base import ImguiPopup, _wrap_update_call + + def decorator(_popup): + if isinstance(_popup, ImguiPopup): + p = _popup + elif callable(_popup): + p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) + else: + raise TypeError( + "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " + "function that draws imgui elements" + ) + + p._fpl_add_hook(figure=figure, parent=self, window_flags=window_flags) + self._imgui_right_click = p + return _popup + + if popup is None: + return decorator + + decorator(popup) + return popup + + def append_imgui_right_click(self, gui=None): + """ + Append imgui elements to the right-click popup of this subplot. Can also be used as a decorator. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + """ + from ..ui._base import _wrap_update_call + + popup = self._imgui_right_click + if popup is None: + raise ValueError( + "no imgui right-click popup set on this subplot to append to, set one using " + "`subplot.set_imgui_right_click()`" + ) + + def decorator(_gui): + popup._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) + + def remove_imgui_right_click(self): + """ + Remove and return the right-click popup of this subplot + + Returns + ------- + ImguiPopup + the removed popup, it can be set again later + + """ + popup = self._imgui_right_click + self._imgui_right_click = None + + return popup + class Dock(PlotArea): def __init__( diff --git a/fastplotlib/tools/__init__.py b/fastplotlib/tools/__init__.py index 761183f76..9c5492d80 100644 --- a/fastplotlib/tools/__init__.py +++ b/fastplotlib/tools/__init__.py @@ -1,9 +1,7 @@ -from ._histogram_lut import HistogramLUTTool from ._textbox import TextBox, Tooltip from ._cursor import Cursor __all__ = [ - "HistogramLUTTool", "TextBox", "Tooltip", "Cursor", diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py deleted file mode 100644 index 8edfb046b..000000000 --- a/fastplotlib/tools/_histogram_lut.py +++ /dev/null @@ -1,431 +0,0 @@ -from math import ceil -from typing import Sequence -import weakref - -import numpy as np - -import pygfx - -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 _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}" - - -class HistogramLUTTool(Graphic): - _fpl_support_tooltip = False - - def __init__( - self, - histogram: tuple[np.ndarray, np.ndarray], - 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: - raise TypeError - - self._block_reentrance = False - self._images = list() - - self._bin_centers_flanked = np.zeros(120, dtype=np.float64) - self._freq_flanked = np.zeros(120, dtype=np.float32) - - # 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)] - ) - - # 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), - size=1.5, - center=0.5, # frequency data are normalized between 0-1 - axis="y", - parent=self._line, - ) - - 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) - ) - - # 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", - 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 - - # TODO: need to auto-scale using the text so it appears nicely, will do later - self._ruler.visible = False - - self._text_vmin = TextGraphic( - text="", - font_size=16, - anchor="top-left", - outline_color="black", - outline_thickness=0.5, - alpha_mode="solid", - ) - # 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( - text="", - font_size=16, - anchor="bottom-left", - outline_color="black", - outline_thickness=0.5, - alpha_mode="solid", - ) - 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, - self._selector.world_object, - self._colorbar.world_object, - self._ruler, - self._text_vmin.world_object, - self._text_vmax.world_object, - ) - 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, - self._colorbar, - self._text_vmin, - self._text_vmax, - ] - - # set histogram - self.histogram = histogram - - # 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: - # 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): - return f"{self._bin_centers_flanked[int(bin_index)]:.2f}" - - @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 - - @histogram.setter - 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 - - 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]) - - 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] - ) - - 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.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() - ) - - @property - def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic, ...] | None: - """get or set the managed images""" - return tuple(self._images) - - @images.setter - def images(self, new_images: ImageGraphic | ImageVolumeGraphic | Sequence[ImageGraphic | ImageVolumeGraphic] | None): - self._disconnect_images() - self._images.clear() - - 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 - - for image in new_images: - if image.cmap is not None: - self._colorbar.visible = True - break - else: - self._colorbar.visible = False - - self._images = list(new_images) - - # reset vmin, vmax using first image - self.vmin = self._images[0].vmin - self.vmax = self._images[0].vmax - - if self._images[0].cmap is not None: - self._colorbar.cmap = self._images[0].cmap - - # connect event handlers - for image in self._images: - 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): - """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 - def cmap(self, name: str): - if self._block_reentrance: - return - - if name is None: - return - - 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: - if image.cmap is None: - # rgb(a) images have no cmap - continue - - 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: - """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] - - @vmin.setter - def vmin(self, value: float): - 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]) - - self._colorbar.vmin = value - - self._text_vmin.text = _format_value(value) - self._text_vmin.offset = (-0.45, self._selector.selection[0], 0) - - for image in self._images: - image.vmin = value - - 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 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] - - @vmax.setter - def vmax(self, value: float): - if self._block_reentrance: - return - - 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) - - self._colorbar.vmax = value - - self._text_vmax.text = _format_value(value) - self._text_vmax.offset = (-0.45, self._selector.selection[1], 0) - - for image in self._images: - image.vmax = value - - 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 - - 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] - - index_max = int(selection[1]) - vmax = self._bin_centers_flanked[index_max] - - 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): - """open imgui cmap picker""" - # check if right click - if ev.button != 2: - return - - pos = ev.x, ev.y - - 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() - - for i in range(len(self._children)): - g = self._children.pop(0) - g._fpl_prepare_del() - del g diff --git a/fastplotlib/ui/__init__.py b/fastplotlib/ui/__init__.py index a1e57a9c5..7f6a6ae3d 100644 --- a/fastplotlib/ui/__init__.py +++ b/fastplotlib/ui/__init__.py @@ -1,3 +1,5 @@ -from ._base import BaseGUI, Window, EdgeWindow, Popup, GUI_EDGES +from ._base import ImguiBase, ImguiWindow, ImguiPopup, EDGES, LOCATIONS +from ._utils import ChangeFlag from ._subplot_toolbar import SubplotToolbar -from .right_click_menus import StandardRightClickMenu, ColormapPicker +from ._colorbar import ImguiColorbar +from .right_click_menus import StandardRightClickMenu diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index 058ee71f3..47f828c1c 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -1,16 +1,38 @@ -import enum +from __future__ import annotations +import inspect +from collections.abc import Callable +from functools import partial from typing import Literal -import numpy as np from imgui_bundle import imgui -from ..layouts._figure import Figure +from ..layouts._rect import RectManager -GUI_EDGES = ["right", "bottom", "top"] +# edges that reserve space, ordered as they are carved from the render area +EDGES = ["left", "right", "top", "bottom"] +# all valid keyed locations, "toolbar" is subplot only, "floating" uses auto-placement +LOCATIONS = EDGES + ["toolbar", "floating"] -class BaseGUI: + +def _wrap_update_call(func: Callable, parent) -> Callable: + """ + Wrap an imgui draw function for use as a window or popup update call. The parent, a ``Figure``, ``Subplot`` or + ``Graphic``, is passed as the only positional arg if the function accepts one, otherwise the function is called + with no args. + """ + params = inspect.signature(func).parameters.values() + takes_arg = any( + p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD, p.VAR_POSITIONAL) + for p in params + ) + if takes_arg: + return partial(func, parent) + return func + + +class ImguiBase: """ Base class for all ImGUI based GUIs, windows and popups @@ -22,51 +44,106 @@ class BaseGUI: ID_COUNTER: int = 0 def __init__(self): - BaseGUI.ID_COUNTER += 1 - self._id_counter = BaseGUI.ID_COUNTER + ImguiBase.ID_COUNTER += 1 + self._id_counter = ImguiBase.ID_COUNTER - def update(self): + def draw(self): """must be implemented in subclass""" raise NotImplementedError -class Window(BaseGUI): - """Base class for imgui windows drawn within Figures""" +class ImguiWindow(ImguiBase): + def __init__(self, update_call: Callable = None): + """ + An imgui window drawn within a Figure. Subclass and implement ``update()`` to draw imgui elements, or pass a + callable as ``update_call`` (this is what the ``add_imgui_window()`` decorator does). + + Windows are not added directly, use ``Figure.add_imgui_window()`` or ``Subplot.add_imgui_window()`` which + provide the host and placement, i.e. location, size, window flags, etc., via ``_fpl_add_hook()``. - pass + Parameters + ---------- + update_call: callable + a callable that draws imgui elements, used instead of ``update()`` when decorating, see ``add_imgui_window`` + """ + super().__init__() + + # imgui element draw calls, run in order within the window on each render + if update_call is None: + self._update_calls = [self.update] + else: + self._update_calls = [update_call] + + # host and placement, set by the host in add_imgui_window() via _fpl_add_hook() + self._figure = None + self._subplot = None + self._location = None + self._size = None + self._rect_manager = None + self._floating = False + self._title = None + self._window_flags = ( + imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar + ) -class EdgeWindow(Window): - def __init__( + # pixel rect, set by the host on each layout pass + self._x, self._y, self._width, self._height = 0, 0, 0, 0 + + # resize and collapse state, only used by figure-level resizeable edge windows + self._resize_cursor_set = False + self._resize_blocked = False + self._right_gui_resizing = False + self._separator_thickness = 14.0 + self._collapsed = False + self._old_size = None + + def _fpl_add_hook( self, - figure: Figure, - size: int, - location: Literal["bottom", "right", "top"], - title: str, - window_flags: enum.IntFlag = imgui.WindowFlags_.no_collapse - | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar, - *args, - **kwargs, + figure, + subplot=None, + location: Literal["left", "right", "top", "bottom", "toolbar", "floating"] = None, + size: int = None, + rect: tuple = None, + extent: tuple = None, + title: str = None, + window_flags: imgui.WindowFlags_ = None, ): """ - A base class for imgui windows displayed at the bottom or top edge of a Figure + Set the host and placement of this window, called by ``Figure.add_imgui_window()`` or + ``Subplot.add_imgui_window()``. Parameters ---------- - figure: Figure - Figure instance that this window will be placed in + figure: ImguiFigure + the figure this window is drawn in + + subplot: Subplot, optional + the subplot this window is confined to, ``None`` for figure-level windows + + location: str, "left" | "right" | "top" | "bottom" | "toolbar" | "floating" + edge and toolbar windows reserve canvas space, "floating" is auto-sized and draggable size: int - width or height of the window, depending on its location + edge or toolbar thickness in pixels + + rect: (x, y, w, h), optional + fractional or pixel rect for a fixed floating window - location: str, "bottom" | "right" - location of the window + extent: (xmin, xmax, ymin, ymax), optional + fractional or pixel extent for a fixed floating window - title: str - window title + title: str, optional + window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn. - window_flags: enum.IntFlag - Window flag enum, can be compared with ``|`` operator. Valid flags are: + window_flags: ``imgui.WindowFlags_`` + window flag enum, can be combined with the ``|`` operator. If not provided, the default depends on the + placement: edge and toolbar windows use ``no_collapse | no_resize | no_title_bar | + no_bring_to_front_on_focus`` (custom title bar, and they stay behind floating and fixed overlays); + floating windows use ``none`` (native imgui title bar, collapsible and movable); fixed rect/extent + windows use ``no_collapse | no_move | no_resize`` (native imgui title bar). Valid flags are: .. code-block:: py @@ -94,52 +171,71 @@ def __init__( imgui.WindowFlags_.no_decoration imgui.WindowFlags_.no_inputs - *args - additional args for the GUI - - **kwargs - additional kwargs for teh GUI """ - super().__init__() - - if location not in GUI_EDGES: - f"GUI does not have a valid location, valid locations are: {GUI_EDGES}, you have passed: {location}" - self._figure = figure - self._size = size + self._subplot = subplot self._location = location + self._size = int(size) if size is not None else None self._title = title + self._floating = location == "floating" + + if rect is not None: + width, height = figure.canvas.get_logical_size() + self._rect_manager = RectManager(*rect, (0, 0, width, height)) + elif extent is not None: + width, height = figure.canvas.get_logical_size() + self._rect_manager = RectManager.from_extent(extent, (0, 0, width, height)) + + if window_flags is None: + # edge and toolbar windows draw their own title bar; floating and fixed windows use the native + # imgui title bar so they can be collapsed, and floating windows can also be moved + if location in EDGES or location == "toolbar": + # reserved windows never come to front on focus, otherwise clicking one would bury a + # floating or fixed overlay drawn over it and make the overlay inaccessible + window_flags = ( + imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar + | imgui.WindowFlags_.no_bring_to_front_on_focus + ) + elif location == "floating": + window_flags = imgui.WindowFlags_.none + else: + # fixed rect or extent window + window_flags = ( + imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_move + | imgui.WindowFlags_.no_resize + ) self._window_flags = window_flags - self._resize_cursor_set = False - self._resize_blocked = False - self._right_gui_resizing = False - - self._separator_thickness = 14.0 - - self._collapsed = False - self._old_size = self.size - - self._x, self._y, self._width, self._height = self.get_rect() - - self._figure.canvas.add_event_handler(self._set_rect, "resize") + @property + def location(self) -> str: + """location of the window""" + return self._location @property def size(self) -> int | None: - """width or height of the edge window""" + """edge or toolbar thickness in pixels, ``None`` for floating and fractional windows""" return self._size @size.setter - def size(self, value): + def size(self, value: int): if not isinstance(value, int): raise TypeError(f"{self.__class__.__name__}.size must be an ") self._size = value - self._set_rect() + # reserving windows change the layout when resized + if self._reserves and self._figure is not None: + self._figure._fpl_reset_layout() @property - def location(self) -> str: - """location of the window""" - return self._location + def window_flags(self) -> imgui.WindowFlags_: + """imgui window flags""" + return self._window_flags + + @window_flags.setter + def window_flags(self, flags: imgui.WindowFlags_): + self._window_flags = flags @property def x(self) -> int: @@ -153,7 +249,7 @@ def y(self) -> int: @property def width(self) -> int: - """with the window""" + """width of the window""" return self._width @property @@ -161,47 +257,14 @@ def height(self) -> int: """height of the window""" return self._height - 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]: - """ - Compute the rect that defines the area this GUI is drawn to - - Returns - ------- - int, int, int, int - x_pos, y_pos, width, height - - """ - - width_canvas, height_canvas = self._figure.canvas.get_logical_size() - - match self._location: - case "bottom": - x_pos = 0 - y_pos = height_canvas - self.size - width, height = (width_canvas, self.size) - - case "right": - x_pos, y_pos = (width_canvas - self.size, 0) - width, height = (self.size, height_canvas) - - if self._figure.guis["bottom"] is not None: - height -= self._figure.guis["bottom"].size - - if self._figure.guis["top"] is not None: - # decrease the height - height -= self._figure.guis["top"].size - # increase the y start - y_pos += self._figure.guis["top"].size - - case "top": - x_pos, y_pos = (0, 0) - width, height = (width_canvas, self.size) + @property + def _reserves(self) -> bool: + """whether this window reserves canvas space, i.e. edge or toolbar windows""" + return self._location in EDGES or self._location == "toolbar" - return x_pos, y_pos, width, height + def _fpl_set_rect(self, x: int, y: int, width: int, height: int): + """set the pixel rect, called by the host on each layout pass""" + self._x, self._y, self._width, self._height = x, y, width, height def _draw_resize_handle(self): if self._location not in ("bottom", "right"): @@ -374,69 +437,201 @@ def _draw_title(self, title: str): imgui.dummy(imgui.ImVec2(win_width, box_size.y)) - def draw_window(self): + def draw(self): """helps simplify using imgui by managing window creation & position, and pushing/popping the ID""" # window position & size - x, y, w, h = self.get_rect() - imgui.set_next_window_size((self.width, self.height)) - imgui.set_next_window_pos((self.x, self.y)) - flags = self._window_flags + if self._floating: + # floating windows are auto-sized by imgui, only set the initial position + imgui.set_next_window_pos((self.x, self.y), imgui.Cond_.appearing) + else: + imgui.set_next_window_size((self.width, self.height)) + imgui.set_next_window_pos((self.x, self.y)) + + # append the id to keep the window unique without changing the visible title + expanded = imgui.begin(f"{self._title or ''}##{self._id_counter}", p_open=None, flags=self._window_flags) + + if self._reserves: + # edge and toolbar windows draw a custom title bar and collapse via the resize handle + # resize handle for right and bottom edge windows on the figure + if self._subplot is None and self._location in ("bottom", "right"): + self._draw_resize_handle() + + # push ID to prevent conflict between multiple figs with same UI + imgui.push_id(self._id_counter) + + # collapse the UI if the separator state is collapsed + # otherwise the UI renders partially on the separator for "right" guis and it looks weird + main_height = 1.0 if self._collapsed else 0.0 + imgui.begin_child("##main_ui", imgui.ImVec2(0, main_height)) + + if self._title is not None: + self._draw_title(self._title) + + imgui.indent(6.0) + # draw imgui elements from the subclass or decorated function(s) + for update_call in self._update_calls: + update_call() + + imgui.end_child() + imgui.pop_id() + + elif expanded: + # floating and fixed windows use the native imgui title bar; only draw when not collapsed + imgui.push_id(self._id_counter) + for update_call in self._update_calls: + update_call() + imgui.pop_id() - # begin window - imgui.begin(self._title, p_open=None, flags=flags) + # end the window + imgui.end() - # resize handle for right and bottom windows - self._draw_resize_handle() + def update(self): + """Implement your GUI here and it will be drawn within the window. See the GUI examples""" + raise NotImplementedError - # push ID to prevent conflict between multiple figs with same UI - imgui.push_id(self._id_counter) - # collapse the UI if the separator state is collapsed - # otherwise the UI renders partially on the separator for "right" guis and it looks weird - main_height = 1.0 if self._collapsed else 0.0 - imgui.begin_child("##main_ui", imgui.ImVec2(0, main_height)) +class ImguiPopup(ImguiBase): + def __init__(self, update_call: Callable = None): + """ + An imgui popup drawn within a Figure, opened by a right-click. Subclass and implement ``update()`` to draw + imgui elements, or pass a callable as ``update_call``. - self._draw_title(self._title) + Popups are not added directly, use ``ImguiFigure.set_imgui_right_click()``, + ``Subplot.set_imgui_right_click()`` or ``Graphic.set_imgui_right_click()`` which provide the parent and + window flags via ``_fpl_add_hook()``. - imgui.indent(6.0) - # draw stuff from subclass into window - self.update() + Parameters + ---------- + update_call: callable + a callable that draws imgui elements, used instead of ``update()``, see ``set_imgui_right_click`` - imgui.end_child() + """ + super().__init__() - # pop ID - imgui.pop_id() + if update_call is None: + self._update_calls = [self.update] + else: + self._update_calls = [update_call] - # end the window - imgui.end() + # parent, set by the parent in set_imgui_right_click() via _fpl_add_hook() + self._figure = None + self._parent = None + self._window_flags = imgui.WindowFlags_.none - def update(self): - """Implement your GUI here and it will be drawn within the window. See the GUI examples""" - raise NotImplementedError + # popups are identified by a str id, the counter keeps it unique between popups + self._popup_id = f"popup##{self._id_counter}" + # what this popup was opened on, set by the right-click dispatch in Subplot + self._subplot = None + self._graphic = None -class Popup(BaseGUI): - def __init__(self, figure: Figure, *args, **kwargs): + self._open_requested = False + self._pos = None + self._is_open = False + + def _fpl_add_hook( + self, + figure, + parent, + window_flags: imgui.WindowFlags_ = None, + ): """ - Base class for creating ImGUI popups within Figures + Set the parent of this popup, called by ``set_imgui_right_click()``. Parameters ---------- - figure: Figure - Figure instance - *args - any args to pass to subclass constructor + figure: ImguiFigure + the figure this popup is drawn in - **kwargs - any kwargs to pass to subclass constructor - """ + parent: ImguiFigure | Subplot | Graphic + the object this popup is set on - super().__init__() + window_flags: ``imgui.WindowFlags_`` + window flag enum, can be combined with the ``|`` operator, see ``ImguiWindow._fpl_add_hook`` for the + valid flags + """ self._figure = figure + self._parent = parent - self.is_open = False + if window_flags is not None: + self._window_flags = window_flags - def open(self, pos: tuple[int, int], *args, **kwargs): - """implement in subclass""" + @property + def parent(self): + """the object this popup is set on, an ``ImguiFigure``, ``Subplot`` or ``Graphic``""" + return self._parent + + @property + def subplot(self): + """the subplot this popup was opened in""" + return self._subplot + + @property + def graphic(self): + """the graphic this popup was opened on, ``None`` if it was not opened on a graphic""" + return self._graphic + + @property + def is_open(self) -> bool: + """whether the popup is currently open""" + return self._is_open + + @property + def window_flags(self) -> imgui.WindowFlags_: + """imgui window flags""" + return self._window_flags + + @window_flags.setter + def window_flags(self, flags: imgui.WindowFlags_): + self._window_flags = flags + + def open(self, pos: tuple[int, int] = None): + """ + Request that this popup is opened on the next render. + + Parameters + ---------- + pos: (int, int), optional + canvas position of the popup, imgui uses the current mouse position if not provided + + """ + self._pos = pos + self._open_requested = True + + def _fpl_open(self, subplot, graphic): + """set what the popup is opened on and open it, called by the right-click dispatch in ``Subplot``""" + self._subplot = subplot + self._graphic = graphic + self.open() + + def _fpl_close(self): + """called when another popup replaces this one as the open popup""" + self._is_open = False + + def draw(self): + """helps simplify using imgui by managing the popup open state, and pushing/popping the ID""" + if self._open_requested: + self._open_requested = False + if self._pos is not None: + imgui.set_next_window_pos(self._pos) + imgui.open_popup(self._popup_id) + + if imgui.begin_popup(self._popup_id, self._window_flags): + self._is_open = True + + # push ID to prevent conflict between multiple figs with same UI + imgui.push_id(self._id_counter) + + for update_call in self._update_calls: + update_call() + + imgui.pop_id() + imgui.end_popup() + + else: + self._is_open = False + + def update(self): + """Implement your GUI here and it will be drawn within the popup. See the GUI examples""" raise NotImplementedError diff --git a/fastplotlib/ui/_colorbar.py b/fastplotlib/ui/_colorbar.py new file mode 100644 index 000000000..7de048af9 --- /dev/null +++ b/fastplotlib/ui/_colorbar.py @@ -0,0 +1,635 @@ +import numpy as np +import wgpu +from cmap import Colormap +from imgui_bundle import imgui + +from ..graphics import ImageGraphic, ImageVolumeGraphic +from ..utils.functions import COLORMAP_NAMES, quick_min_max +from ._base import ImguiWindow + + +class ImguiColorbar(ImguiWindow): + LUT_HEIGHT = 256 + TEX_WIDTH = 2 + HANDLE_HEIGHT = 8 + HANDLE_OVERHANG = 3 # how far a handle extends past the bar on each side + BAR_BORDER = 1.0 # width of the outline drawn around the bar image + HIST_WIDTH = 50 # width in pixels of the optional histogram drawn left of the bar + HIST_GAP = 4 # gap in pixels between the histogram and the bar + FILL_OVERHANG = 4 # how far the vmin/vmax fill and lines extend past the histogram line-plot + + def __init__( + self, + images: ImageGraphic | ImageVolumeGraphic | list, + histogram: tuple[np.ndarray, np.ndarray] | None = None, + data_range: tuple[float, float] | None = None, + bar_width: int = 16, + region_drag: bool = True, + ): + """ + An imgui colorbar with draggable vmin/vmax handles, an optional histogram, a gamma slider, and a + right-click colormap picker. + + Parameters + ---------- + images: ImageGraphic | ImageVolumeGraphic | list + the image(s) whose vmin, vmax and cmap this colorbar controls + + histogram: tuple[np.ndarray, np.ndarray], optional + a precomputed ``(counts, edges)`` histogram drawn to the left of the bar. It is not recomputed when the + image data changes, set the ``histogram`` property to update it. + + data_range: (min, max), optional + the value range spanned by the bar. Defaults to the histogram edges if a histogram is provided, + otherwise to the data range of the first image. + + bar_width: int + width of the colored bar in pixels + + region_drag: bool + if ``True``, dragging between the handles shifts the vmin/vmax window without changing its width + """ + super().__init__() + + if isinstance(images, (ImageGraphic, ImageVolumeGraphic)): + images = [images] + self._images = list(images) + if len(self._images) == 0: + raise ValueError("must provide at least one image") + + image = self._images[0] + self._vmin = float(image.vmin) + self._vmax = float(image.vmax) + # rgb(a) images have no cmap, display the bar with "gray" so vmin, vmax are still adjustable + self._cmap_name = image.cmap if image.cmap is not None else "gray" + + self._gamma = 1.0 + self._bar_width = int(bar_width) + self._region_drag = bool(region_drag) + + # offset in data units between the grabbed value and the value under the cursor, captured when a drag + # starts so the handle tracks the cursor without jumping + self._grab_offset = 0.0 + + # prevents feedback loops when syncing vmin, vmax, cmap between this colorbar and the images + self._block_reentrance = False + + # GPU resources, created in _fpl_add_hook() once the figure and its device are known + self._device = None + self._bar_texture = None + self._bar_tex_id = None + self._picker_tex_ids = dict() + + # setting the histogram also sets the value axis to the histogram edges + self._histogram = None + self.histogram = histogram + + # data_range defaults to the histogram edges, otherwise the data range of the first image + if data_range is None: + if self._histogram is not None: + counts, edges = self._histogram + data_range = (float(edges[0]), float(edges[-1])) + else: + data_range = quick_min_max(image.data.value) + self._data_min, self._data_max = self._validate_range(data_range) + + def _fpl_add_hook( + self, + figure, + subplot=None, + location: str = None, + size: int = None, + rect: tuple = None, + extent: tuple = None, + title: str = "", + window_flags=None, + ): + super()._fpl_add_hook( + figure, + subplot=subplot, + location=location, + size=size, + rect=rect, + extent=extent, + title=title, + window_flags=window_flags, + ) + + # the colorbar manages its own layout and should never show a scrollbar + self.window_flags = self._window_flags | imgui.WindowFlags_.no_scrollbar + + self._device = figure.renderer.device + + # a preview texture for each non-qualitative colormap, used in the picker + for category, names in COLORMAP_NAMES.items(): + if category == "qualitative": + continue + for name in names: + self._picker_tex_ids[name] = self._make_picker_texture(name) + + self._bar_texture = self._device.create_texture( + size=(self.TEX_WIDTH, self.LUT_HEIGHT, 1), + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + dimension=wgpu.TextureDimension.d2, + format=wgpu.TextureFormat.rgba8unorm, + mip_level_count=1, + sample_count=1, + ) + self._bar_tex_id = figure.imgui_renderer.backend.register_texture( + self._bar_texture.create_view() + ) + self._update_bar_texture() + + # sync the colorbar when an image's vmin, vmax, cmap, or gamma is changed elsewhere + for image in self._images: + self._connect_image(image) + + @property + def images(self) -> tuple: + """get or set the images managed by this colorbar""" + return tuple(self._images) + + @images.setter + def images(self, new_images): + self._disconnect_images() + if isinstance(new_images, (ImageGraphic, ImageVolumeGraphic)): + new_images = [new_images] + self._images = list(new_images) + + # adopt the vmin, vmax, and cmap of the new first image + image = self._images[0] + self._vmin = float(image.vmin) + self._vmax = float(image.vmax) + self._cmap_name = image.cmap if image.cmap is not None else "gray" + self._update_bar_texture() + + for img in self._images: + self._connect_image(img) + + @property + def cmap(self) -> str: + """get or set the colormap""" + return self._cmap_name + + @cmap.setter + def cmap(self, name: str): + if self._block_reentrance or name is None or name == self._cmap_name: + return + self._block_reentrance = True + try: + self._cmap_name = name + self._update_bar_texture() + for image in self._images: + if image.cmap is None: + # rgb(a) images have no cmap + continue + image.cmap = name + finally: + self._block_reentrance = False + + @property + def vmin(self) -> float: + """get or set the lower contrast limit""" + return self._vmin + + @vmin.setter + def vmin(self, value: float): + value = float(value) + if self._block_reentrance or value == self._vmin: + return + self._block_reentrance = True + try: + self._vmin = value + self._update_bar_texture() + for image in self._images: + image.vmin = value + finally: + self._block_reentrance = False + + @property + def vmax(self) -> float: + """get or set the upper contrast limit""" + return self._vmax + + @vmax.setter + def vmax(self, value: float): + value = float(value) + if self._block_reentrance or value == self._vmax: + return + self._block_reentrance = True + try: + self._vmax = value + self._update_bar_texture() + for image in self._images: + image.vmax = value + finally: + self._block_reentrance = False + + @property + def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: + """the histogram as a precomputed (counts, edges) tuple, or ``None`` for no histogram""" + return self._histogram + + @histogram.setter + def histogram(self, value): + if value is None: + self._histogram = None + return + counts, edges = value + counts = np.asarray(counts, dtype=np.float32) + edges = np.asarray(edges, dtype=np.float64) + if edges.shape[0] != counts.shape[0] + 1: + raise ValueError( + "histogram edges must have one more element than counts, you have passed " + f"counts: {counts.shape[0]} and edges: {edges.shape[0]}" + ) + self._histogram = (counts, edges) + + # the histogram defines the value axis + self._data_min = float(edges[0]) + self._data_max = float(edges[-1]) + self._update_bar_texture() + + @property + def data_range(self) -> tuple[float, float]: + """the value range spanned by the bar""" + return (self._data_min, self._data_max) + + @data_range.setter + def data_range(self, value): + self._data_min, self._data_max = self._validate_range(value) + self._update_bar_texture() + + @property + def gamma(self) -> float: + """get or set the gamma, applied to the images and the bar""" + return self._gamma + + @gamma.setter + def gamma(self, value: float): + value = float(value) + if self._block_reentrance or value == self._gamma: + return + self._block_reentrance = True + try: + self._gamma = value + self._update_bar_texture() + for image in self._images: + image.gamma = value + finally: + self._block_reentrance = False + + @property + def bar_width(self) -> int: + """get or set the width of the colored bar in pixels""" + return self._bar_width + + @bar_width.setter + def bar_width(self, value: int): + self._bar_width = int(value) + + @staticmethod + def _validate_range(data_range): + data_min, data_max = float(data_range[0]), float(data_range[1]) + if data_max <= data_min: + raise ValueError( + f"data_range max ({data_max}) must be greater than min ({data_min})" + ) + return data_min, data_max + + def _image_event_handler(self, ev): + """when an image's vmin, vmax, or cmap changes, update this colorbar to match""" + setattr(self, ev.type, ev.info["value"]) + + def _connect_image(self, image): + """subscribe to an image's vmin, vmax and gamma events, and its cmap if it is grayscale""" + events = ["vmin", "vmax", "gamma"] + # rgb(a) images have no cmap feature to listen to + if image.cmap is not None: + events.append("cmap") + image.add_event_handler(self._image_event_handler, *events) + + def _disconnect_images(self, *args): + """disconnect the 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 _make_picker_texture(self, name): + lut = (Colormap(name)(np.linspace(0, 1, 256)) * 255).astype(np.uint8) + data = np.ascontiguousarray(np.tile(lut[None, :, :], (2, 1, 1))) + h, w = data.shape[:2] + texture = self._device.create_texture( + size=(w, h, 1), + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + dimension=wgpu.TextureDimension.d2, + format=wgpu.TextureFormat.rgba8unorm, + mip_level_count=1, + sample_count=1, + ) + self._device.queue.write_texture( + {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, + data, + {"offset": 0, "bytes_per_row": w * 4}, + (w, h, 1), + ) + return self._renderer.backend.register_texture(texture.create_view()) + + def _update_bar_texture(self): + if self._bar_texture is None: + # not added to a figure yet, no device + return + # the bar spans the flanked axis so it aligns with the histogram and the handles + axis_min, axis_max = self._axis_range() + span = axis_max - axis_min + lo = (self._vmin - axis_min) / span + hi = (self._vmax - axis_min) / span + t = np.linspace(1.0, 0.0, self.LUT_HEIGHT) + norm = np.clip((t - lo) / (hi - lo), 0.0, 1.0) + norm = norm ** self._gamma + colors = (Colormap(self._cmap_name)(norm) * 255).astype(np.uint8) + data = np.ascontiguousarray(np.tile(colors[:, None, :], (1, self.TEX_WIDTH, 1))) + self._device.queue.write_texture( + {"texture": self._bar_texture, "mip_level": 0, "origin": (0, 0, 0)}, + data, + {"offset": 0, "bytes_per_row": self.TEX_WIDTH * 4}, + (self.TEX_WIDTH, self.LUT_HEIGHT, 1), + ) + + @property + def _renderer(self): + return self._figure.imgui_renderer + + def _axis_range(self) -> tuple[float, float]: + """the value axis: the data range flanked on each side so handles can move past the data extremes""" + flank = 0.1 * (self._data_max - self._data_min) + return self._data_min - flank, self._data_max + flank + + def update(self): + draw_list = imgui.get_window_draw_list() + avail = imgui.get_content_region_avail() + line_h = imgui.get_text_line_height_with_spacing() + + p0 = imgui.get_cursor_screen_pos() + total_h = avail.y + + bar_w = self._bar_width + # the value axis spans the height minus a line of padding at the top and bottom + bar_y = p0.y + line_h + bar_h = max(50.0, total_h - 2 * line_h) + + # accumulated across the region lines and bar handles to drive the resize cursor + self._hovering_handle = False + + # anchor the bar to the right edge of the window; the histogram and value text sit to its left, + # the handle overhang stays within the window padding + bar_x = p0.x + avail.x - self.HANDLE_OVERHANG - bar_w + + has_hist = self._histogram is not None + if has_hist: + # the histogram has a fixed width (HIST_WIDTH), drawn to the left of the bar + hist_x_right = bar_x - self.HIST_GAP + hist_x_left = hist_x_right - self.HIST_WIDTH + + # histogram line profile, inset so the vmin/vmax fill and lines extend beyond it + self._draw_histogram( + draw_list, + hist_x_left + self.FILL_OVERHANG, + hist_x_right - self.FILL_OVERHANG, + bar_y, + bar_h, + ) + # draggable vmin, vmax lines, shaded region, and value text drawn over the histogram + self._draw_region(hist_x_left, hist_x_right, bar_y, bar_h) + + # the colorbar bar + imgui.set_cursor_screen_pos((bar_x, bar_y)) + imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0)) + imgui.push_style_var(imgui.StyleVar_.image_border_size, self.BAR_BORDER) + imgui.image(self._bar_tex_id, image_size=(bar_w - 2 * self.BAR_BORDER, bar_h)) + imgui.pop_style_var() + imgui.pop_style_color() + + # right-click for the gamma slider and colormap picker + if imgui.begin_popup_context_window("##colorbar_popup"): + self._draw_popup() + imgui.end_popup() + + # without a histogram the vmin, vmax handles live on the bar itself + if not has_hist: + self._draw_bar_handles(bar_x, bar_y, bar_w, bar_h) + + # show a vertical-resize cursor while hovering any handle + if self._hovering_handle and not self._resize_cursor_set: + self._figure.canvas.set_cursor("ns_resize") + self._resize_cursor_set = True + elif not self._hovering_handle and self._resize_cursor_set: + self._figure.canvas.set_cursor("default") + self._resize_cursor_set = False + + def _value_to_y(self, v, y0, bar_h): + axis_min, axis_max = self._axis_range() + return y0 + (1.0 - (v - axis_min) / (axis_max - axis_min)) * bar_h + + def _y_to_value(self, y, y0, bar_h): + axis_min, axis_max = self._axis_range() + return axis_min + (1.0 - (y - y0) / bar_h) * (axis_max - axis_min) + + def _draw_histogram(self, draw_list, x_left, x_right, bar_y, bar_h): + counts, edges = self._histogram + cmin = counts.min() + cmax = counts.max() + span = cmax - cmin + if span <= 0: + return + + color = imgui.color_convert_float4_to_u32((0.7, 0.7, 0.7, 1.0)) + hist_w = x_right - x_left + if hist_w <= 0: + return + # min count maps to the right edge next to the bar, max count to the left edge, filling the width + norm = (counts - cmin) / span + centers = 0.5 * (edges[:-1] + edges[1:]) + + # frequency increases to the left, away from the bar, value maps to y, drawn as a line profile + points = [ + imgui.ImVec2(x_right - frac * hist_w, self._value_to_y(c, bar_y, bar_h)) + for frac, c in zip(norm, centers) + ] + draw_list.add_polyline(points, color, 1.5, 0) + + def _draw_region(self, x_left, x_right, bar_y, bar_h): + draw_list = imgui.get_window_draw_list() + white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + # yellow highlight when a line is hovered/dragged, like the HistogramLUTTool + yellow = imgui.color_convert_float4_to_u32((1.0, 1.0, 0.0, 1.0)) + # dark blue fill, the same color as the HistogramLUTTool LinearRegionSelector + fill_color = imgui.color_convert_float4_to_u32((0.0, 0.0, 0.35, 0.4)) + + axis_min, axis_max = self._axis_range() + span = axis_max - axis_min + width = x_right - x_left + grab = self.HANDLE_HEIGHT + min_sep = (grab / bar_h) * span + + def cursor_value(): + # the data value under the cursor. Lines track this absolute position (plus the grab offset) + # rather than accumulating per-frame deltas, so a fast drag past an edge pins the line to the extreme + return self._y_to_value(imgui.get_io().mouse_pos.y, bar_y, bar_h) + + # shaded fill between the vmin and vmax lines + y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + draw_list.add_rect_filled((x_left, y_vmax), (x_right, y_vmin), fill_color) + + # drag the region between the lines to move both together + if self._region_drag: + top = y_vmax + grab / 2 + bottom = y_vmin - grab / 2 + if bottom > top: + imgui.set_cursor_screen_pos((x_left, top)) + imgui.invisible_button("##region", (width, bottom - top)) + if imgui.is_item_activated(): + self._grab_offset = 0.5 * (self._vmin + self._vmax) - cursor_value() + if imgui.is_item_active(): + half = 0.5 * (self._vmax - self._vmin) + center = cursor_value() + self._grab_offset + center = max(axis_min + half, min(axis_max - half, center)) + self.vmin = center - half + self.vmax = center + half + + # each line has a hit-window for hovering/dragging; the line turns yellow when hovered or dragged + for label, attr, lo_fn, hi_fn in ( + ("##vmax_line", "vmax", lambda: self._vmin + min_sep, lambda: axis_max), + ("##vmin_line", "vmin", lambda: axis_min, lambda: self._vmax - min_sep), + ): + cur = getattr(self, attr) + y = self._value_to_y(cur, bar_y, bar_h) + imgui.set_cursor_screen_pos((x_left, y - grab / 2)) + imgui.invisible_button(label, (width, grab)) + hovered = imgui.is_item_hovered() or imgui.is_item_active() + self._hovering_handle = self._hovering_handle or hovered + if imgui.is_item_activated(): + self._grab_offset = cur - cursor_value() + if imgui.is_item_active(): + setattr(self, attr, max(lo_fn(), min(hi_fn(), cursor_value() + self._grab_offset))) + y = self._value_to_y(getattr(self, attr), bar_y, bar_h) + draw_list.add_line((x_left, y), (x_right, y), yellow if hovered else white, 2.0) + + # current vmax above its line, vmin below its line + y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + self._text_right(draw_list, f"{self._vmax:.4g}", x_right, y_vmax - imgui.get_text_line_height()) + self._text_right(draw_list, f"{self._vmin:.4g}", x_right, y_vmin) + + def _text_right(self, draw_list, text: str, x_right: float, y: float): + """draw text right-aligned so it ends at x_right""" + tw = imgui.calc_text_size(text).x + draw_list.add_text((x_right - tw, y), imgui.get_color_u32(imgui.Col_.text), text) + + def _draw_bar_handles(self, bar_x, bar_y, bar_w, bar_h): + draw_list = imgui.get_window_draw_list() + white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + # yellow highlight when a handle is hovered/dragged, like the region lines + yellow = imgui.color_convert_float4_to_u32((1.0, 1.0, 0.0, 1.0)) + outline = imgui.color_convert_float4_to_u32((0.0, 0.0, 0.0, 1.0)) + text_color = imgui.get_color_u32(imgui.Col_.text) + + axis_min, axis_max = self._axis_range() + span = axis_max - axis_min + h = self.HANDLE_HEIGHT + # the handles extend past the bar on each side + x_left = bar_x - self.HANDLE_OVERHANG + x_right = bar_x + bar_w + self.HANDLE_OVERHANG + min_sep = (h / bar_h) * span + + def cursor_value(): + return self._y_to_value(imgui.get_io().mouse_pos.y, bar_y, bar_h) + + # thin reference lines at the data min and max, so the flank beyond the data range is visible + ref = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + for v in (self._data_min, self._data_max): + y = self._value_to_y(v, bar_y, bar_h) + draw_list.add_line((x_left, y), (x_right, y), ref, 1.0) + + # drag the region between the handles to move vmin and vmax together + if self._region_drag: + y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + top = y_vmax + h / 2 + bottom = y_vmin - h / 2 + if bottom > top: + imgui.set_cursor_screen_pos((x_left, top)) + imgui.invisible_button("##bar_region", (x_right - x_left, bottom - top)) + if imgui.is_item_activated(): + self._grab_offset = 0.5 * (self._vmin + self._vmax) - cursor_value() + if imgui.is_item_active(): + half = 0.5 * (self._vmax - self._vmin) + center = cursor_value() + self._grab_offset + center = max(axis_min + half, min(axis_max - half, center)) + self.vmin = center - half + self.vmax = center + half + + for label, attr, lo_fn, hi_fn in ( + ("##bar_vmax", "vmax", lambda: self._vmin + min_sep, lambda: axis_max), + ("##bar_vmin", "vmin", lambda: axis_min, lambda: self._vmax - min_sep), + ): + cur = getattr(self, attr) + y = self._value_to_y(cur, bar_y, bar_h) + imgui.set_cursor_screen_pos((x_left, y - h / 2)) + imgui.invisible_button(label, (x_right - x_left, h)) + hovered = imgui.is_item_hovered() or imgui.is_item_active() + self._hovering_handle = self._hovering_handle or hovered + if imgui.is_item_activated(): + self._grab_offset = cur - cursor_value() + if imgui.is_item_active(): + setattr(self, attr, max(lo_fn(), min(hi_fn(), cursor_value() + self._grab_offset))) + y = self._value_to_y(getattr(self, attr), bar_y, bar_h) + + draw_list.add_rect_filled((x_left, y - h / 2), (x_right, y + h / 2), yellow if hovered else white) + draw_list.add_rect((x_left, y - h / 2), (x_right, y + h / 2), outline, thickness=1.0) + + # current value to the left of the bar, vmax above its handle and vmin below + text = f"{getattr(self, attr):.4g}" + ty = y - imgui.get_text_line_height() if attr == "vmax" else y + tw = imgui.calc_text_size(text).x + draw_list.add_text((x_left - 3 - tw, ty), text_color, text) + + def _draw_popup(self): + imgui.set_next_item_width(150) + changed, gamma = imgui.slider_float("gamma", self._gamma, 0.1, 5.0) + if changed: + self.gamma = gamma + + # reset vmin, vmax using the data of each image + if imgui.menu_item("Reset vmin-vmax", "", False)[0]: + for image in self._images: + image.reset_vmin_vmax() + + # reset gamma to 1.0 + if imgui.menu_item("Reset gamma", "", False)[0]: + self.gamma = 1.0 + + texture_height = imgui.get_font_size() - 2 + + # colormaps grouped by category, qualitative colormaps are not useful for a continuous colorbar + for category, names in COLORMAP_NAMES.items(): + if category == "qualitative": + continue + + imgui.separator() + imgui.text(category.capitalize()) + + for name in names: + imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0)) + imgui.push_style_var(imgui.StyleVar_.image_border_size, 1.0) + imgui.image(self._picker_tex_ids[name], image_size=(75, texture_height)) + imgui.pop_style_var() + imgui.pop_style_color() + + imgui.same_line() + + clicked, selected = imgui.selectable(name, p_selected=(name == self._cmap_name)) + if clicked and selected: + self.cmap = name diff --git a/fastplotlib/ui/_subplot_toolbar.py b/fastplotlib/ui/_subplot_toolbar.py index 435de4206..4c1bd289a 100644 --- a/fastplotlib/ui/_subplot_toolbar.py +++ b/fastplotlib/ui/_subplot_toolbar.py @@ -1,20 +1,18 @@ from imgui_bundle import imgui, icons_fontawesome_6 as fa, imgui_ctx -from ..layouts._subplot import Subplot -from ._base import Window +from ._base import ImguiWindow from ..layouts._utils import IMGUI_TOOLBAR_HEIGHT -class SubplotToolbar(Window): - def __init__(self, subplot: Subplot): +class SubplotToolbar(ImguiWindow): + def __init__(self): """ - Subplot toolbar shown below all subplots + Subplot toolbar shown below all subplots. The subplot is provided via ``_fpl_add_hook()`` when the + toolbar is added to the subplot. """ super().__init__() - self._subplot = subplot - - def update(self): + def draw(self): # get subplot rect x, y, width, height = self._subplot.frame.rect @@ -27,12 +25,26 @@ def update(self): imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_background + # stay behind floating and fixed overlays so they remain accessible when drawn over the toolbar + | imgui.WindowFlags_.no_bring_to_front_on_focus ) imgui.begin(f"Toolbar-{hex(id(self._subplot))}", p_open=None, flags=flags) # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) + + # draw the toolbar and any appended imgui elements + for update_call in self._update_calls: + update_call() + + # pop id when all UI has been written to window + imgui.pop_id() + + # end window + imgui.end() + + def update(self): with imgui_ctx.begin_horizontal(f"toolbar-{hex(id(self._subplot))}"): # autoscale button if imgui.button(fa.ICON_FA_MAXIMIZE): @@ -59,9 +71,3 @@ def update(self): ) if imgui.is_item_hovered(0): imgui.set_tooltip("maintain aspect") - - # pop id when all UI has been written to window - imgui.pop_id() - - # end window - imgui.end() diff --git a/fastplotlib/ui/_utils.py b/fastplotlib/ui/_utils.py new file mode 100644 index 000000000..32c6f9f68 --- /dev/null +++ b/fastplotlib/ui/_utils.py @@ -0,0 +1,46 @@ +class ChangeFlag: + """ + A flag that helps detect whether an imgui UI has been changed by the user. + Basically, once True, always True. + + Example:: + + changed = ChangeFlag(False) + + changed.value, bah = (False, False) + + print(changed.value) + + changed.value, bah = (True, False) + + print(changed.value) + + changed.value, bah = (False, False) + + print(changed.value) + + """ + + def __init__(self, value: bool): + self._value = bool(value) + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool): + if value: + self._value = True + + def __bool__(self): + return self.value + + def __or__(self, other): + return self._value | other + + def __eq__(self, other): + return self.value == other + + def force_value(self, value): + self._value = value diff --git a/fastplotlib/ui/right_click_menus/__init__.py b/fastplotlib/ui/right_click_menus/__init__.py index 6ccc50646..a32b87263 100644 --- a/fastplotlib/ui/right_click_menus/__init__.py +++ b/fastplotlib/ui/right_click_menus/__init__.py @@ -1,2 +1 @@ -from ._colormap_picker import ColormapPicker from ._standard_menu import StandardRightClickMenu diff --git a/fastplotlib/ui/right_click_menus/_colormap_picker.py b/fastplotlib/ui/right_click_menus/_colormap_picker.py deleted file mode 100644 index 9df26dcdc..000000000 --- a/fastplotlib/ui/right_click_menus/_colormap_picker.py +++ /dev/null @@ -1,176 +0,0 @@ -import ctypes - -import numpy as np -import cmap - -import wgpu -from imgui_bundle import imgui -from wgpu import GPUTexture - -from .. import Popup -from ...utils.functions import ( - COLORMAP_NAMES, - SEQUENTIAL_CMAPS, - CYCLIC_CMAPS, - DIVERGING_CMAPS, - MISC_CMAPS, -) - -all_cmaps = [*SEQUENTIAL_CMAPS, *CYCLIC_CMAPS, *DIVERGING_CMAPS, *MISC_CMAPS] - - -class ColormapPicker(Popup): - """Colormap picker menu popup tool""" - - # name used to trigger this popup after it has been registered with a Figure - name = "colormap-picker" - - def __init__(self, figure): - super().__init__(figure=figure) - - self.renderer = self._figure.renderer - self.imgui_renderer = self._figure.imgui_renderer - - # maps str cmap names -> int texture IDs - self._cmap_texture_refs: dict[str, imgui.ImTextureRef] = dict() - - # make all colormaps and upload representative texture for each cmap to the GPU - for name in all_cmaps: - # get data that represents cmap - colormap = cmap.Colormap(name) - data = colormap(np.linspace(0, 1)) * 255 - - # needs to be 2D to create a texture - data = np.vstack([[data]] * 2).astype(np.uint8) - - # upload the texture to the GPU, get the texture ID and texture - self._cmap_texture_refs[name] = self._create_texture_and_upload(data) - - # used to set the states of the UI - self._lut_tool = None - self._pos: tuple[int, int] = -1, -1 - self._open_new: bool = False - - self.is_open = False - - self._popup_state = "never-opened" - - self._texture_height = None - - def _create_texture_and_upload(self, data: np.ndarray) -> tuple[int, GPUTexture]: - """crates a GPUTexture from the 2D data and uploads it""" - - # create a GPUTexture - texture = self.renderer.device.create_texture( - size=(data.shape[1], data.shape[0], 4), - usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, - dimension=wgpu.TextureDimension.d2, - format=wgpu.TextureFormat.rgba8unorm, - mip_level_count=1, - sample_count=1, - ) - - # upload to the GPU - self.renderer.device.queue.write_texture( - {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, - data, - {"offset": 0, "bytes_per_row": data.shape[1] * 4}, - (data.shape[1], data.shape[0], 1), - ) - - # get a view - texture_view = texture.create_view() - - # return texture ref - return self.imgui_renderer.backend.register_texture(texture_view) - - def open(self, pos: tuple[int, int], lut_tool): - """ - Request that the popup be opened on the next render cycle - - Parameters - ---------- - pos: int, int - (x, y) position - - lut_tool: HistogramLUTTool - instance of the LUT tool - - Returns - ------- - - """ - self._lut_tool = lut_tool - - self._pos = pos - - self._open_new = True - - def close(self): - """cleanup after popup has closed""" - self._lut_tool = None - self._open_new = False - self._pos = -1, -1 - - self.is_open = False - - def _add_cmap_menu_item(self, cmap_name: str): - # white border around cmap image - imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0)) - imgui.push_style_var(imgui.StyleVar_.image_border_size, 1.0) - - # cmap image - texture_ref = self._cmap_texture_refs[cmap_name] - imgui.image( - texture_ref, - image_size=(50, self._texture_height), - ) - # pop white border - imgui.pop_style_var() - imgui.pop_style_color() - - imgui.same_line() - - clicked, selected = imgui.selectable( - label=cmap_name, - p_selected=cmap_name == self._lut_tool.cmap, - ) - - if clicked and selected: - self._lut_tool.cmap = cmap_name - - def update(self): - if self._open_new: - # new popup has been triggered by a LUT tool - self._open_new = False - - imgui.set_next_window_pos(self._pos) - imgui.open_popup("cmap-picker") - - if imgui.begin_popup("cmap-picker"): - self.is_open = True - - # make the cmap image height the same as the text height - self._texture_height = (imgui.get_font_size()) - 2 - - if imgui.menu_item("Reset vmin-vmax", "", False)[0]: - for image in self._lut_tool.images: - image.reset_vmin_vmax() - - # add all the cmap options - for cmap_type in COLORMAP_NAMES.keys(): - if cmap_type == "qualitative": - continue - - imgui.separator() - imgui.text(cmap_type.capitalize()) - - for cmap_name in COLORMAP_NAMES[cmap_type]: - self._add_cmap_menu_item(cmap_name) - - imgui.end_popup() - - else: - # popup went from open to closed - if self.is_open == True: - self.close() diff --git a/fastplotlib/ui/right_click_menus/_image_adjust.py b/fastplotlib/ui/right_click_menus/_image_adjust.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py index 9c659f4a7..d5a25bca4 100644 --- a/fastplotlib/ui/right_click_menus/_standard_menu.py +++ b/fastplotlib/ui/right_click_menus/_standard_menu.py @@ -2,7 +2,7 @@ from ...layouts._utils import controller_types from ...layouts._plot_area import PlotArea -from ...ui import Popup +from ...ui import ImguiPopup def flip_axis(subplot: PlotArea, axis: str, flip: bool): @@ -19,147 +19,84 @@ def flip_axis(subplot: PlotArea, axis: str, flip: bool): setattr(camera.local, axis_attr, scale * -1) -class StandardRightClickMenu(Popup): +class StandardRightClickMenu(ImguiPopup): """Right click menu that is shown on subplots""" - def __init__(self, figure): - super().__init__(figure=figure) - - self._last_right_click_pos = None - self._mouse_down: bool = False - - # whether the right click menu is currently open or not - self.is_open: bool = False + def __init__(self): + super().__init__() + # the subplot whose controller window is open, False if no controller window is open self._controller_window_open: bool | PlotArea = False - def get_subplot(self) -> PlotArea | bool | None: - """get the subplot that a click occurred in""" - if self._last_right_click_pos is None: - return False - - for subplot in self._figure: - if subplot.viewport.is_inside(*self._last_right_click_pos): - return subplot - - # not inside a subplot - return False - - def cleanup(self): - """called when the popup disappears""" - self.is_open = False - - def _extra_menu(self): - # extra menu items, optional, implement in subclass - pass - def update(self): - if imgui.is_mouse_down(1) and not self._mouse_down: - # mouse button was pressed down, store this position - self._mouse_down = True - self._last_right_click_pos = imgui.get_mouse_pos() - - if imgui.is_mouse_released(1) and self._mouse_down: - self._mouse_down = False - - # open popup only if mouse was not moved between mouse_down and mouse_up events - if self._last_right_click_pos == imgui.get_mouse_pos(): - if self.get_subplot() is not False: # must explicitly check for False - # open only if right click was inside a subplot - imgui.open_popup(f"right-click-menu") - - # TODO: call this just once when going from open -> closed state - if not imgui.is_popup_open("right-click-menu"): - self.cleanup() - - if imgui.begin_popup(f"right-click-menu"): - if self.get_subplot() is False: # must explicitly check for False - # for some reason it will still trigger at certain locations - # despite open_popup() only being called when an actual - # subplot is returned - imgui.end_popup() - imgui.close_current_popup() - self.cleanup() - return - - name = self.get_subplot().name - - if name is not None: - # text label at the top of the menu - imgui.text(f"subplot: {name}") - imgui.separator() - - _, show_fps = imgui.menu_item( - "Show fps", "", self.get_subplot().get_figure().imgui_show_fps - ) - self.get_subplot().get_figure().imgui_show_fps = show_fps + subplot = self.subplot - # autoscale, center, maintain aspect - if imgui.menu_item(f"Autoscale", "", False)[0]: - self.get_subplot().auto_scale() + if subplot.name is not None: + # text label at the top of the menu + imgui.text(f"subplot: {subplot.name}") + imgui.separator() - if imgui.menu_item(f"Center", "", False)[0]: - self.get_subplot().center_scene() + _, show_fps = imgui.menu_item("Show fps", "", self._figure.imgui_show_fps) + self._figure.imgui_show_fps = show_fps - _, maintain_aspect = imgui.menu_item( - "Maintain Aspect", "", self.get_subplot().camera.maintain_aspect - ) - self.get_subplot().camera.maintain_aspect = maintain_aspect + # autoscale, center, maintain aspect + if imgui.menu_item("Autoscale", "", False)[0]: + subplot.auto_scale() - imgui.separator() + if imgui.menu_item("Center", "", False)[0]: + subplot.center_scene() - # toggles to flip axes cameras - for axis in ["x", "y", "z"]: - scale = getattr(self.get_subplot().camera.local, f"scale_{axis}") - changed, flip = imgui.menu_item( - f"Flip {axis} axis", "", bool(scale < 0) - ) + _, maintain_aspect = imgui.menu_item( + "Maintain Aspect", "", subplot.camera.maintain_aspect + ) + subplot.camera.maintain_aspect = maintain_aspect - if changed: - flip_axis(self.get_subplot(), axis, flip) + imgui.separator() - imgui.separator() + # toggles to flip axes cameras + for axis in ["x", "y", "z"]: + scale = getattr(subplot.camera.local, f"scale_{axis}") + changed, flip = imgui.menu_item(f"Flip {axis} axis", "", bool(scale < 0)) - # toggles to show/hide the grid - for plane in ["xy", "xz", "yz"]: - grid = getattr(self.get_subplot().axes.grids, plane) - visible = grid.visible - changed, new_visible = imgui.menu_item(f"Grid {plane}", "", visible) + if changed: + flip_axis(subplot, axis, flip) - if changed: - grid.visible = new_visible + imgui.separator() - imgui.separator() + # toggles to show/hide the grid + for plane in ["xy", "xz", "yz"]: + grid = getattr(subplot.axes.grids, plane) + changed, visible = imgui.menu_item(f"Grid {plane}", "", grid.visible) - # camera FOV - changed, fov = imgui.slider_float( - "FOV", v=self.get_subplot().camera.fov, v_min=0.0, v_max=180.0 - ) + if changed: + grid.visible = visible - imgui.separator() + imgui.separator() - if changed: - # FOV between 0 and 1 is numerically unstable - if 0 < fov < 1: - fov = 1 + # camera FOV + changed, fov = imgui.slider_float( + "FOV", v=subplot.camera.fov, v_min=0.0, v_max=180.0 + ) - # need to update FOV via controller, if FOV is directly set - # on the camera the controller will immediately set it back - self.get_subplot().controller.update_fov( - fov - self.get_subplot().camera.fov, - animate=False, - ) + if changed: + # FOV between 0 and 1 is numerically unstable + if 0 < fov < 1: + fov = 1 - imgui.separator() + # need to update FOV via controller, if FOV is directly set + # on the camera the controller will immediately set it back + subplot.controller.update_fov(fov - subplot.camera.fov, animate=False) - # controller options - if imgui.menu_item("Controller Options", "", False)[0]: - self._controller_window_open = self.get_subplot() + imgui.separator() - self._extra_menu() + # controller options + if imgui.menu_item("Controller Options", "", False)[0]: + self._controller_window_open = subplot - imgui.end_popup() + def draw(self): + super().draw() + # the controller window is not part of the popup, it stays open after the popup closes if self._controller_window_open: self._draw_controller_window() diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index d404decf9..c5caa3845 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -8,6 +8,5 @@ NDImageProcessor, NDImage, ) -from .image_widget import ImageWidget -__all__ = ["NDWidget", "ImageWidget"] +__all__ = ["NDWidget"] diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 951fc5a55..3090e14c7 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -15,7 +15,7 @@ enums, ) from ...graphics import ImageGraphic, ImageYUVGraphic, ImageVolumeGraphic -from ...tools import HistogramLUTTool +from ...ui import ImguiColorbar from ._base import ( NDProcessor, NDGraphic, @@ -318,7 +318,7 @@ def __init__( Wraps an :class:`NDImageProcessor` and manages either an ``ImageGraphic`` or``ImageVolumeGraphic``. swaps automatically when :attr:`spatial_dims` is reassigned at runtime. Also - owns a ``HistogramLUTTool`` for interactive vmin, vmax adjustment. + owns an ``ImguiColorbar`` for interactive vmin, vmax adjustment. Every dimension that is *not* listed in ``spatial_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the @@ -361,7 +361,7 @@ def __init__( See :class:`NDProcessor`. compute_histogram : bool, default ``True`` - Whether to initialize the ``HistogramLUTTool``. + Whether to initialize the ``ImguiColorbar``. slider_dim_transforms : dict, optional See :class:`NDProcessor`. @@ -402,7 +402,7 @@ def __init__( self._colorrange = colorrange self._graphic: ImageGraphic | ImageYUVGraphic | None = None - self._histogram_widget: HistogramLUTTool | None = None + self._histogram_widget: ImguiColorbar | None = None # create a graphic run_sync(self._create_graphic()) @@ -484,27 +484,29 @@ def _reset_histogram(self): if self.graphic is None: return + subplot = self._nd_subplot.subplot + if not self.processor.compute_histogram: - # hide right dock if histogram not desired - self._nd_subplot.subplot.docks["right"].size = 0 + # remove the colorbar from the right edge if a histogram is not desired + if self._histogram_widget is not None: + subplot.remove_imgui_window("right") + self._histogram_widget = None return if self.processor.histogram: - if self._histogram_widget: - # histogram widget exists, update it + if self._histogram_widget is not None: + # colorbar widget exists, update it and rebind to the current graphic self._histogram_widget.histogram = self.processor.histogram self._histogram_widget.images = self.graphic - if self._nd_subplot.subplot.docks["right"].size < 1: - self._nd_subplot.subplot.docks["right"].size = 80 else: - # make hist tool - self._histogram_widget = HistogramLUTTool( - histogram=self.processor.histogram, + # make the colorbar, it reserves space on the subplot's right edge + self._histogram_widget = ImguiColorbar( images=self.graphic, - name=f"hist-{hex(id(self.graphic))}", + histogram=self.processor.histogram, + ) + subplot.add_imgui_window( + self._histogram_widget, location="right", size=100 ) - self._nd_subplot.subplot.docks["right"].add_graphic(self._histogram_widget) - self._nd_subplot.subplot.docks["right"].size = 80 self.graphic.reset_vmin_vmax() @@ -571,7 +573,7 @@ async def _set_indices_(self, indices: dict[str, Any] = None): @property def compute_histogram(self) -> bool: - """whether or not to compute the histogram and display the HistogramLUTTool""" + """whether or not to compute the histogram and display the ImguiColorbar""" return self.processor.compute_histogram @compute_histogram.setter @@ -580,8 +582,8 @@ def compute_histogram(self, v: bool): self._reset_histogram() @property - def histogram_widget(self) -> HistogramLUTTool: - """The histogram lut tool associated with this NDGraphic""" + def histogram_widget(self) -> ImguiColorbar: + """The colorbar associated with this NDGraphic""" return self._histogram_widget @property diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py index 1804986a1..c5b6f58c0 100644 --- a/fastplotlib/widgets/nd_widget/_ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -19,8 +19,8 @@ def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[Refe self._indices._add_ndwidget_(self) - self._figure = ImguiFigure(std_right_click_menu=RightClickMenu, **kwargs) - self._figure.std_right_click_menu.set_nd_widget(self) + self._figure = ImguiFigure(**kwargs) + self._figure.set_imgui_right_click(RightClickMenu(self)) self._subplots_nd: dict[Subplot, NDWSubplot] = dict() for subplot in self.figure: @@ -29,8 +29,10 @@ def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[Refe # hard code the expected height so that the first render looks right in tests, docs etc. ui_size = 57 + (50 * len(self.indices)) - self._sliders_ui = NDWidgetUI(self.figure, ui_size, self) - self.figure.add_gui(self._sliders_ui) + self._sliders_ui = NDWidgetUI(self) + self.figure.add_imgui_window( + self._sliders_ui, location="bottom", size=ui_size, title="NDWidget controls" + ) @property def figure(self) -> ImguiFigure: diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index ae9296567..3a54d327a 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -14,7 +14,7 @@ ) from ...utils import quick_min_max from ...layouts import Subplot -from ...ui import EdgeWindow, StandardRightClickMenu +from ...ui import ImguiWindow, StandardRightClickMenu from ._index import RangeContinuous from ._base import NDGraphic from ._nd_positions import NDPositions, NDTimeseries @@ -23,17 +23,9 @@ position_graphic_types = [ScatterCollection, ScatterStack, LineCollection, LineStack] -class NDWidgetUI(EdgeWindow): - def __init__(self, figure, size, ndwidget): - super().__init__( - figure=figure, - size=size, - title="NDWidget controls", - location="bottom", - window_flags=imgui.WindowFlags_.no_collapse - | imgui.WindowFlags_.no_resize - | imgui.WindowFlags_.no_title_bar, - ) +class NDWidgetUI(ImguiWindow): + def __init__(self, ndwidget): + super().__init__() self._ndwidget = ndwidget # whether or not a dimension is in play mode @@ -203,22 +195,17 @@ def update(self): class RightClickMenu(StandardRightClickMenu): - def __init__(self, figure): - self._ndwidget = None - self._ndgraphic_windows = set() - - super().__init__(figure=figure) + def __init__(self, ndwidget): + super().__init__() - def set_nd_widget(self, ndw): - self._ndwidget = ndw + self._ndwidget = ndwidget + self._ndgraphic_windows = set() - def _extra_menu(self): - if self._ndwidget is None: - return + def update(self): + super().update() if imgui.begin_menu("ND Graphics"): - subplot = self.get_subplot() - for ndg in self._ndwidget[subplot].nd_graphics: + for ndg in self._ndwidget[self.subplot].nd_graphics: name = ndg.name if ndg.name is not None else hex(id(ndg)) if imgui.menu_item( f"{name}", "", False @@ -227,9 +214,10 @@ def _extra_menu(self): imgui.end_menu() - def update(self): - super().update() + def draw(self): + super().draw() + # the ND graphic windows are not part of the popup, they stay open after the popup closes for ndg in list(self._ndgraphic_windows): # set -> list so we can change size during iteration name = ndg.name if ndg.name is not None else hex(id(ndg)) subplot = ndg.graphic._plot_area diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index c5a526e93..336d82b25 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -33,6 +33,7 @@ def generate_add_graphics_methods(): f.write("from typing import *\n\n") f.write("import numpy\n\n") + f.write("from numpy.typing import NDArray\n\n") f.write("import pygfx\n\n") f.write("from ..graphics import *\n") f.write("from ..graphics._base import Graphic\n") From 4b2d0e8abec76862db645aef79735f6c2f1b0d45 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 4 Aug 2026 12:08:27 -0400 Subject: [PATCH 122/163] inf line, dashing, thin bool (#1064) * Create partial_camera_linking.py (#1020) * inf line, dashing, thin bool * tests * new screenshots --- .../controllers/partial_camera_linking.py | 55 ++++ examples/line/inf_line.py | 46 +++ examples/line/inf_line_cmap.py | 27 ++ examples/line/inf_line_cmap_transform.py | 33 ++ examples/line/inf_line_pairs.py | 34 +++ examples/line/line_dash.py | 35 +++ examples/screenshots/inf_line.png | 3 + examples/screenshots/inf_line_cmap.png | 3 + .../screenshots/inf_line_cmap_transform.png | 3 + examples/screenshots/inf_line_pairs.png | 3 + examples/screenshots/line_dash.png | 3 + fastplotlib/graphics/__init__.py | 2 + fastplotlib/graphics/_positions_base.py | 17 +- fastplotlib/graphics/features/__init__.py | 7 +- fastplotlib/graphics/features/_line.py | 60 ++++ fastplotlib/graphics/features/_positions.py | 283 +++++++++++++++--- fastplotlib/graphics/features/utils.py | 16 + fastplotlib/graphics/inf_line.py | 182 +++++++++++ fastplotlib/graphics/line.py | 133 ++++++-- fastplotlib/layouts/_graphic_methods_mixin.py | 102 +++++++ scripts/generate_add_graphic_methods.py | 2 +- tests/test_common_features.py | 3 + tests/test_positions_graphics.py | 71 ++++- 23 files changed, 1041 insertions(+), 82 deletions(-) create mode 100644 examples/controllers/partial_camera_linking.py create mode 100644 examples/line/inf_line.py create mode 100644 examples/line/inf_line_cmap.py create mode 100644 examples/line/inf_line_cmap_transform.py create mode 100644 examples/line/inf_line_pairs.py create mode 100644 examples/line/line_dash.py create mode 100644 examples/screenshots/inf_line.png create mode 100644 examples/screenshots/inf_line_cmap.png create mode 100644 examples/screenshots/inf_line_cmap_transform.png create mode 100644 examples/screenshots/inf_line_pairs.png create mode 100644 examples/screenshots/line_dash.png create mode 100644 fastplotlib/graphics/inf_line.py diff --git a/examples/controllers/partial_camera_linking.py b/examples/controllers/partial_camera_linking.py new file mode 100644 index 000000000..5cebe66ce --- /dev/null +++ b/examples/controllers/partial_camera_linking.py @@ -0,0 +1,55 @@ +""" +Partial camera linking +====================== + +You can customize the camera axes that a controller acts on. In this example with two subplots you can pan and zoom +in x-y in each individual subplot, but only the x-axis panning is linked between the two subplots. The y-axis pan +and zoom in independent on each subplot. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import pygfx + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +ys_big = np.random.rand(100) * 10 + +# create cameras, fov=0 means Orthographic projection +camera1 = pygfx.PerspectiveCamera(fov=0) +camera2 = pygfx.PerspectiveCamera(fov=0) + +# create controllers, first add the "main" camera for the subplot +controller1 = pygfx.PanZoomController(camera1) +controller2 = pygfx.PanZoomController(camera2) + +# add the other camera to each controller, but only include the 'x' state, i.e. 'y' for height is not included +# this must be done only after adding the "main" cameras to the controller as done above +controller1.add_camera(camera2, include_state={"x", "width"}) +controller2.add_camera(camera1, include_state={"x", "width"}) + +# create figure using these cameras and controllers +figure = fpl.Figure( + shape=(2, 1), + cameras=[camera1, camera2], + controllers=[controller1, controller2], + size=(700, 560) +) + +figure[0, 0].add_line(np.column_stack([xs, ys_big])) +figure[1, 0].add_line(np.column_stack([xs, ys])) + +for subplot in figure: + subplot.camera.zoom = 1.0 + +figure.show(maintain_aspect=False, autoscale=True) + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/line/inf_line.py b/examples/line/inf_line.py new file mode 100644 index 000000000..5d03eda3a --- /dev/null +++ b/examples/line/inf_line.py @@ -0,0 +1,46 @@ +""" +Infinite Lines +============== + +Draw infinite vertical and horizontal lines to mark positions on a plot. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 4 * np.pi, 100) +ys = np.sin(xs) +data = np.column_stack([xs, ys]) + +figure[0, 0].add_line(data, thickness=2, colors="w") + +# vertical lines at the zero-crossings, one color per line by passing a list of colors +zero_crossings = np.array([0, np.pi, 2 * np.pi, 3 * np.pi, 4 * np.pi]) +figure[0, 0].add_inf_line( + zero_crossings, axis="x", colors=["r", "g", "b", "c", "m"], thickness=2 +) + +# dashed horizontal lines at the sine bounds, provided as a 1D array of y-values +figure[0, 0].add_inf_line( + np.array([-1.0, 1.0]), + axis="y", + colors="gray", + thickness=2, + dash_pattern="--", +) + +figure[0, 0].axes.intersection = (0, 0, 0) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/line/inf_line_cmap.py b/examples/line/inf_line_cmap.py new file mode 100644 index 000000000..a2a067de6 --- /dev/null +++ b/examples/line/inf_line_cmap.py @@ -0,0 +1,27 @@ +""" +Infinite Lines Colormap +======================= + +Apply a colormap across a set of infinite lines, one color per line. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +# vertical lines colored by a colormap, one color per line +positions = np.arange(10) +figure[0, 0].add_inf_line(positions, axis="x", cmap="viridis", thickness=3) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/line/inf_line_cmap_transform.py b/examples/line/inf_line_cmap_transform.py new file mode 100644 index 000000000..d42833cbb --- /dev/null +++ b/examples/line/inf_line_cmap_transform.py @@ -0,0 +1,33 @@ +""" +Infinite Lines Colormap Transform +================================= + +Use a ``cmap_transform`` to color infinite lines by an associated value rather than by their sequential +order. Here each line at an x-position is colored according to the sine value at that x-axis position. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +# evenly spaced vertical lines +positions = np.linspace(0, 6 * np.pi, 32) + +# color each line by an associated value using the colormap transform +values = np.sin(positions) +figure[0, 0].add_inf_line( + positions, axis="x", cmap="plasma", cmap_transform=values, thickness=3 +) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/line/inf_line_pairs.py b/examples/line/inf_line_pairs.py new file mode 100644 index 000000000..040085220 --- /dev/null +++ b/examples/line/inf_line_pairs.py @@ -0,0 +1,34 @@ +""" +Infinite Lines from Point Pairs +=============================== + +Define infinite lines directly from pairs of points using ``axis=None``. Each two consecutive +points define one line. Here pairs of points sampled around the unit circle are used to produce +lines that are roughly tangent to the circle. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +# an even number of points sampled around a circle; each consecutive pair of points defines an infinite line +t = np.linspace(0, 2 * np.pi, 64, endpoint=False) +xs = np.sin(t) +ys = np.cos(t) +positions = np.column_stack([xs, ys, np.zeros_like(xs)]) + +figure[0, 0].add_inf_line(positions, axis=None, cmap="hsv", thickness=2) +figure[0, 0].axes.intersection = (0, 0, 0) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/line/line_dash.py b/examples/line/line_dash.py new file mode 100644 index 000000000..d0c3d3912 --- /dev/null +++ b/examples/line/line_dash.py @@ -0,0 +1,35 @@ +""" +Line Dash Patterns +================== + +Draw lines with different dash patterns using matplotlib-style strings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 4 * np.pi, 100) + +# a matplotlib-style string, or a sequence of floats, sets the dash pattern +patterns = ["-", "--", "-.", ":"] + +for i, pattern in enumerate(patterns): + ys = np.sin(xs) + i * 3 + data = np.column_stack([xs, ys]) + figure[0, 0].add_line( + data, thickness=5, dash_pattern=pattern, name=pattern + ) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/screenshots/inf_line.png b/examples/screenshots/inf_line.png new file mode 100644 index 000000000..65e3cf42b --- /dev/null +++ b/examples/screenshots/inf_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b4d50c4d7b48e9efc41416f95c04c6c6d58f56e9281bcc59344c50cf8329ae3 +size 11196 diff --git a/examples/screenshots/inf_line_cmap.png b/examples/screenshots/inf_line_cmap.png new file mode 100644 index 000000000..97a52776e --- /dev/null +++ b/examples/screenshots/inf_line_cmap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7df196e26f1fbca8d080bf2d7d212710dc50ee228a6a6578092e8b3db049eba +size 10406 diff --git a/examples/screenshots/inf_line_cmap_transform.png b/examples/screenshots/inf_line_cmap_transform.png new file mode 100644 index 000000000..518e03e88 --- /dev/null +++ b/examples/screenshots/inf_line_cmap_transform.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81ccedda34424e484d93dc518e30d4213f39540e18b7884f046eb6e422331952 +size 12268 diff --git a/examples/screenshots/inf_line_pairs.png b/examples/screenshots/inf_line_pairs.png new file mode 100644 index 000000000..3300ddfd6 --- /dev/null +++ b/examples/screenshots/inf_line_pairs.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1e4bc41a719d49215904316c536e71520035404c6e0d1e424f4c0317f194fe8 +size 37153 diff --git a/examples/screenshots/line_dash.png b/examples/screenshots/line_dash.png new file mode 100644 index 000000000..fe26f3819 --- /dev/null +++ b/examples/screenshots/line_dash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c0d25e65af29f7ffb29e9882907b385ecdbca7f897be7b2e3762d00310e640d +size 14045 diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index baf8151be..3a0c56077 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,5 +1,6 @@ from ._base import Graphic from .line import LineGraphic +from .inf_line import InfLineGraphic from .scatter import ScatterGraphic from .image import ImageGraphic, ImageYUVGraphic from .image_volume import ImageVolumeGraphic @@ -12,6 +13,7 @@ __all__ = [ "Graphic", "LineGraphic", + "InfLineGraphic", "ScatterGraphic", "ImageGraphic", "ImageYUVGraphic", diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 763f5e775..426079730 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -18,6 +18,9 @@ class PositionsGraphic(Graphic): """Base class for LineGraphic and ScatterGraphic""" + # the feature used to manage a per-vertex color buffer, subclasses may override + _VertexColorsCls = VertexColors + @property def data(self) -> VertexPositions: """ @@ -155,7 +158,7 @@ def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColo if color_mode in ("auto", "uniform"): new_colors = UniformColor(colors) else: - new_colors = VertexColors( + new_colors = self._VertexColorsCls( colors, n_colors=self._data.value.shape[0] ) @@ -166,7 +169,9 @@ def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColo "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " "`color_mode` = 'auto' or 'vertex' for multiple colors." ) - new_colors = VertexColors(colors, n_colors=self._data.value.shape[0]) + new_colors = self._VertexColorsCls( + colors, n_colors=self._data.value.shape[0] + ) elif len(colors) > 4: # sequence of multiple colors, must again ensure color_mode is not uniform @@ -175,7 +180,9 @@ def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColo "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " "`color_mode` = 'auto' or 'vertex' for multiple colors." ) - new_colors = VertexColors(colors, n_colors=self._data.value.shape[0]) + new_colors = self._VertexColorsCls( + colors, n_colors=self._data.value.shape[0] + ) else: raise ValueError( "`colors` must be a str, pygfx.Color, array, list or tuple indicating an RGB(A) color, or a " @@ -225,7 +232,9 @@ def __init__( self._colors = colors else: # create vertex colors buffer - self._colors = VertexColors("w", n_colors=self._data.value.shape[0]) + self._colors = self._VertexColorsCls( + "w", n_colors=self._data.value.shape[0] + ) # make cmap using vertex colors buffer self._cmap = VertexCmap( self._colors, diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index 1d2359f96..cc1840a56 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -4,6 +4,8 @@ SizeSpace, VertexPositions, VertexCmap, + InfLineAxisData, + InfLineColors, ) from ._mesh import ( MeshIndices, @@ -14,7 +16,7 @@ surface_data_to_mesh, triangulate_polygon, ) -from ._line import Thickness +from ._line import Thickness, DashPattern, parse_dash_pattern from ._scatter import ( VertexMarkers, UniformMarker, @@ -83,10 +85,13 @@ "SizeSpace", "VertexPositions", "VertexCmap", + "InfLineAxisData", + "InfLineColors", "MeshIndices", "MeshCmap", "SurfaceData", "Thickness", + "DashPattern", "VertexMarkers", "UniformMarker", "UniformEdgeColor", diff --git a/fastplotlib/graphics/features/_line.py b/fastplotlib/graphics/features/_line.py index 792cb7832..a29e0ec97 100644 --- a/fastplotlib/graphics/features/_line.py +++ b/fastplotlib/graphics/features/_line.py @@ -5,6 +5,38 @@ ) +# matplotlib-style dash pattern presets, expressed in units relative to the line thickness +DASH_PATTERNS: dict[str, tuple] = { + "-": (), + "solid": (), + "--": (5, 5), + "dashed": (5, 5), + "-.": (5, 2, 1, 2), + "dashdot": (5, 2, 1, 2), + ":": (0, 2), + "dotted": (0, 2), +} + + +def parse_dash_pattern(value: str | tuple | list) -> tuple: + """ + Parse a ``dash_pattern`` into a pygfx dash tuple. + + ``value`` can be a matplotlib-style string, one of + ``"-", "--", "-.", ":"`` or ``"solid", "dashed", "dashdot", "dotted"``, or a + sequence of floats describing the length of strokes and gaps. + """ + if isinstance(value, str): + if value not in DASH_PATTERNS: + raise ValueError( + f"`dash_pattern` string must be one of {sorted(DASH_PATTERNS.keys())}, " + f"you have passed: {value!r}" + ) + return DASH_PATTERNS[value] + + return tuple(value) + + class Thickness(GraphicFeature): event_info_spec = [ {"dict key": "value", "type": "float", "description": "new thickness value"}, @@ -26,3 +58,31 @@ def set_value(self, graphic, value: float): event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) + + +class DashPattern(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "str | tuple", + "description": "new dash pattern", + }, + ] + + def __init__(self, value: str | tuple | list = (), property_name: str = "dash_pattern"): + # parse to validate, but store the user's original value so it stays readable + parse_dash_pattern(value) + self._value = value + super().__init__(property_name=property_name) + + @property + def value(self) -> str | tuple: + return self._value + + @block_reentrance + def set_value(self, graphic, value: str | tuple | list): + graphic.world_object.material.dash_pattern = parse_dash_pattern(value) + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 507fc1ee0..2ede10b8b 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -13,7 +13,7 @@ to_gpu_supported_dtype, block_reentrance, ) -from .utils import parse_colors +from .utils import parse_colors, is_single_color class VertexColors(BufferManager): @@ -64,50 +64,35 @@ def set_value( value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], ): """set the entire array, create new buffer if necessary""" - if isinstance(value, (np.ndarray, list, tuple)): - # TODO: Refactor this triage so it's more elegant - - # first make sure it's not representing one color - skip = False - if isinstance(value, np.ndarray): - if (value.shape in ((3,), (4,))) and ( - np.issubdtype(value.dtype, np.floating) - or np.issubdtype(value.dtype, np.integer) - ): - # represents one color - skip = True - elif isinstance(value, (list, tuple)): - if len(value) in (3, 4) and all( - [isinstance(v, (float, int)) for v in value] - ): - # represents one color - skip = True - - # check if the number of elements matches current buffer size - if not skip and self.buffer.data.shape[0] != len(value): - # parse the new colors - new_colors = parse_colors(value, len(value)) - - # create the new buffer, old buffer should get dereferenced - # make sure new buffer is isolated (i.e. allocate a buffer, then set the values) - buff = np.empty(new_colors.shape, dtype=np.float32) - buff[:] = new_colors - self._fpl_buffer = pygfx.Buffer(buff) - graphic.world_object.geometry.colors = self._fpl_buffer - - if len(self._event_handlers) < 1: - return - - event_info = { - "key": slice(None), - "value": new_colors, - "user_value": value, - } - - event = GraphicFeatureEvent(self._property_name, info=event_info) - self._call_event_handlers(event) + # a sequence of colors whose length differs from the current buffer requires a new buffer + if ( + isinstance(value, (np.ndarray, list, tuple)) + and not is_single_color(value) + and self.buffer.data.shape[0] != len(value) + ): + # parse the new colors + new_colors = parse_colors(value, len(value)) + + # create the new buffer, old buffer should get dereferenced + # make sure new buffer is isolated (i.e. allocate a buffer, then set the values) + buff = np.empty(new_colors.shape, dtype=np.float32) + buff[:] = new_colors + self._fpl_buffer = pygfx.Buffer(buff) + graphic.world_object.geometry.colors = self._fpl_buffer + + if len(self._event_handlers) < 1: return + event_info = { + "key": slice(None), + "value": new_colors, + "user_value": value, + } + + event = GraphicFeatureEvent(self._property_name, info=event_info) + self._call_event_handlers(event) + return + self[:] = value @block_reentrance @@ -409,6 +394,12 @@ def __init__( def buffer(self) -> pygfx.Buffer: return self._vertex_colors.buffer + @property + def value(self) -> np.ndarray: + # mirror the managed colors feature, whose length is the number of color entries + # (this is per-line, not per-vertex, for an InfLineColors) + return self._vertex_colors.value + @block_reentrance def __setitem__(self, key: slice, cmap_name): if not isinstance(key, slice): @@ -480,3 +471,209 @@ def __len__(self): def __repr__(self): return f"{self.__class__.__name__} | cmap: {self.name}\ntransform: {self.transform}" + + +class InfLineAxisData(VertexPositions): + """ + Manages the positions buffer for :class:`InfLineGraphic`. + + Each infinite line is stored as a two-point segment, so the buffer has two vertices per + line. When ``axis`` is one of ``"x", "y", "z"`` the data is a 1D array of positions along + that axis and one infinite line is drawn at each position. When ``axis`` is ``None`` the + data is used directly as the segment endpoints (2 points per line). + + Indexing and ``value`` operate per-line: ``value`` is a 1D array of ``n_lines`` axis + positions, or an ``[n_lines, 2, 3]`` array of segment endpoints when ``axis`` is ``None``. + """ + + _AXIS_INDICES = {"x": 0, "y": 1, "z": 2} + + def __init__(self, data: Any, axis: str | None = None, property_name: str = "data"): + if axis is not None and axis not in self._AXIS_INDICES: + raise ValueError( + f"`axis` must be one of 'x', 'y', 'z', or None, you have passed: {axis!r}" + ) + self._axis = axis + super().__init__(data, property_name=property_name) + + @property + def axis(self) -> str | None: + return self._axis + + def _fix_data(self, data): + data = np.asarray(data) + + if self._axis is None: + # data is used directly as the segment endpoints, 2 points per line; + # accept the grouped [n_lines, 2, 3] form as well as a flat [n_points, 3] buffer + if data.ndim == 3: + data = data.reshape(-1, data.shape[-1]) + data = super()._fix_data(data) + if data.shape[0] % 2 != 0: + raise ValueError( + "when `axis` is None, `data` is used directly as the infinite line segment " + "endpoints and must contain an even number of points (2 per line)" + ) + return data + + # axis is 'x', 'y', or 'z': `data` is a 1D array of positions along that axis + if data.ndim != 1: + raise ValueError( + f"when `axis` is '{self._axis}', `data` must be a 1D array of positions along that " + f"axis, you have passed an array with {data.ndim} dimensions" + ) + + axis_index = self._AXIS_INDICES[self._axis] + # the two points of a line share the axis position; they differ along another axis + # so the segment has a direction along which it is extended to infinity + run_index = 1 if axis_index == 0 else 0 + + buffer = np.zeros((2 * data.size, 3), dtype=np.float32) + buffer[:, axis_index] = np.repeat(data, 2) + buffer[1::2, run_index] = 1.0 + + return buffer + + def __len__(self) -> int: + return len(self.buffer.data) // 2 + + @property + def value(self) -> np.ndarray: + if self._axis is None: + # one [2, 3] pair of endpoints per line + return self.buffer.data.reshape(len(self), 2, 3) + # both endpoints of a line share the axis position, return one value per line + return self.buffer.data[::2, self._AXIS_INDICES[self._axis]] + + def __getitem__(self, key): + return self.value[key] + + def set_value(self, graphic, value): + """set the line positions, allocating a new buffer if the number of lines changed""" + value = np.asarray(value) + + if self._axis is None: + fixed = self._fix_data(value) + if fixed.shape[0] != len(self.buffer.data): + # number of lines changed, allocate a new buffer + self._fpl_buffer = pygfx.Buffer(fixed) + graphic.world_object.geometry.positions = self._fpl_buffer + # emit the [n_lines, 2, 3] form to match `value` and the in-place path + self._emit_event( + self._property_name, slice(None), fixed.reshape(-1, 2, 3) + ) + return + self[:] = fixed.reshape(len(self), 2, 3) + return + + if value.ndim != 1: + raise ValueError( + f"when `axis` is '{self._axis}', data must be set with a 1D array of axis positions" + ) + if value.size != len(self): + # number of lines changed, allocate a new buffer + self._fpl_buffer = pygfx.Buffer(self._fix_data(value)) + graphic.world_object.geometry.positions = self._fpl_buffer + self._emit_event(self._property_name, slice(None), value) + return + + self[:] = value + + @block_reentrance + def __setitem__(self, key, value): + # for axis=None, `value` is [n_lines, 2, 3] so the line index is the first + # element of a multi-dimensional endpoint/coordinate key + line_key = key[0] if (self._axis is None and isinstance(key, tuple)) else key + line_indices = np.atleast_1d(np.arange(len(self))[line_key]) + if line_indices.size == 0: + return + + if self._axis is None: + self.buffer.data.reshape(len(self), 2, 3)[key] = value + else: + axis_index = self._AXIS_INDICES[self._axis] + # write the axis position to both endpoints of each line + self.buffer.data[2 * line_indices, axis_index] = value + self.buffer.data[2 * line_indices + 1, axis_index] = value + + offset = 2 * int(line_indices.min()) + size = 2 * (int(line_indices.max()) - int(line_indices.min()) + 1) + self.buffer.update_range(offset=offset, size=size) + + self._emit_event(self._property_name, key, value) + + +class InfLineColors(VertexColors): + """ + Manages per-line colors for :class:`InfLineGraphic`. + + One color is stored per infinite line; internally each color is written to both + endpoints of the line's segment so that the segment renders as a single solid color. + """ + + def __init__(self, colors, n_colors: int, property_name: str = "colors"): + # n_colors is the number of infinite lines; each line spans two vertices + data = np.repeat(parse_colors(colors, n_colors), 2, axis=0) + # bypass VertexColors.__init__, which would parse the (already parsed) colors again + BufferManager.__init__(self, data=data, property_name=property_name) + + @property + def value(self) -> np.ndarray: + # both vertices of a line share its color, return one color per line + return self.buffer.data[::2] + + def __getitem__(self, key): + return self.value[key] + + def __len__(self) -> int: + return len(self.buffer.data) // 2 + + def set_value(self, graphic, value): + """set the per-line colors, allocating a new buffer if the number of lines changed""" + if not is_single_color(value) and len(value) != len(self): + data = np.repeat(parse_colors(value, len(value)), 2, axis=0) + buff = np.empty(data.shape, dtype=np.float32) + buff[:] = data + self._fpl_buffer = pygfx.Buffer(buff) + graphic.world_object.geometry.colors = self._fpl_buffer + + if len(self._event_handlers) < 1: + return + + event_info = {"key": slice(None), "value": data, "user_value": value} + event = GraphicFeatureEvent(self._property_name, info=event_info) + self._call_event_handlers(event) + return + + self[:] = value + + @block_reentrance + def __setitem__(self, key, value): + # the line index is the first element of a multi-dimensional (per-channel) key + line_key = key[0] if isinstance(key, tuple) else key + line_indices = np.atleast_1d(np.arange(len(self))[line_key]) + if line_indices.size == 0: + return + + if isinstance(key, tuple): + # channel-level write, e.g. colors[i, :3]; set the value directly, no color parsing + colors = value + rest = key[1:] + self.buffer.data[(2 * line_indices, *rest)] = value + self.buffer.data[(2 * line_indices + 1, *rest)] = value + else: + # one color per selected line, written to both of the line's vertices + colors = parse_colors(value, line_indices.size) + self.buffer.data[2 * line_indices] = colors + self.buffer.data[2 * line_indices + 1] = colors + + offset = 2 * int(line_indices.min()) + size = 2 * (int(line_indices.max()) - int(line_indices.min()) + 1) + self.buffer.update_range(offset=offset, size=size) + + if len(self._event_handlers) < 1: + return + + event_info = {"key": key, "value": colors, "user_value": value} + event = GraphicFeatureEvent(self._property_name, info=event_info) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py index ef67297ce..59c62f354 100644 --- a/fastplotlib/graphics/features/utils.py +++ b/fastplotlib/graphics/features/utils.py @@ -5,6 +5,22 @@ from ...utils import make_pygfx_colors +def is_single_color(value) -> bool: + """ + Whether ``value`` represents a single RGB(A) color rather than a sequence of colors. + + A single color is a str, ``pygfx.Color``, or an RGB(A) array/list/tuple of 3-4 numbers. + """ + if isinstance(value, np.ndarray): + return value.shape in ((3,), (4,)) and value.dtype.kind in "fiu" + + if isinstance(value, (list, tuple)): + return len(value) in (3, 4) and all(isinstance(v, (float, int)) for v in value) + + # str, pygfx.Color, or any other scalar color specifier + return True + + def parse_colors( colors: str | np.ndarray | list[str] | tuple[str], n_colors: int | None ): diff --git a/fastplotlib/graphics/inf_line.py b/fastplotlib/graphics/inf_line.py new file mode 100644 index 000000000..6d92d4b3b --- /dev/null +++ b/fastplotlib/graphics/inf_line.py @@ -0,0 +1,182 @@ +from typing import * + +import numpy as np + +import pygfx + +from .line import LineGraphic +from .features import ( + InfLineAxisData, + InfLineColors, + UniformColor, + VertexCmap, + Thickness, + SizeSpace, + DashPattern, +) + + +class InfLineGraphic(LineGraphic): + _features = { + "data": InfLineAxisData, + "colors": (InfLineColors, UniformColor), + "cmap": (VertexCmap, None), # none if UniformColor + "thickness": Thickness, + "size_space": SizeSpace, + "dash_pattern": DashPattern, + } + + # one color per line, each broadcast to the two vertices of the line's segment + _VertexColorsCls = InfLineColors + + def __init__( + self, + data: Any, + axis: Literal["x", "y", "z"] | None = None, + thickness: float = 2.0, + colors: str | np.ndarray | Sequence = "w", + cmap: str = None, + cmap_transform: np.ndarray | Sequence = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", + start_is_infinite: bool = True, + end_is_infinite: bool = True, + dash_pattern: str | tuple | list = (), + size_space: str = "screen", + **kwargs, + ): + """ + Create a collection of infinite lines. + + Parameters + ---------- + data: array-like + The line positions. If ``axis`` is "x", "y", or "z", a 1D array of positions along + that axis; one infinite line is drawn at each position. If ``axis`` is None, ``data`` + is used directly as the segment endpoints, of shape [n_points, 2 | 3], where every two + consecutive points define one line. + + axis: "x", "y", "z", or None, default None + The axis along which the line positions are given. If None, ``data`` is interpreted + directly as the segment endpoints. + + thickness: float, optional, default 2.0 + thickness of the lines + + colors: str, array, or iterable, default "w" + specify colors as a single human-readable string, a single RGBA array, or a Sequence + (array, tuple, or list) of strings or RGBA arrays. A sequence of colors provides one + color per line. + + cmap: str, optional + Apply a colormap to the lines instead of assigning colors manually, one color per line. + This overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all lines. + "vertex" allows an independent color per line. + For most cases you can keep it as "auto" and the `color_mode` is determined automatically + based on the argument passed to `colors`. + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + start_is_infinite: bool, default True + whether the start of each line is extended to infinity + + end_is_infinite: bool, default True + whether the end of each line is extended to infinity + + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. + + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") + + **kwargs + passed to :class:`.Graphic` + + """ + + self._start_is_infinite = bool(start_is_infinite) + self._end_is_infinite = bool(end_is_infinite) + + data = InfLineAxisData(data, axis=axis) + + super().__init__( + data=data, + thickness=thickness, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + color_mode=color_mode, + size_space=size_space, + dash_pattern=dash_pattern, + thin=False, + **kwargs, + ) + + def _make_material(self) -> pygfx.LineInfiniteSegmentMaterial: + return pygfx.LineInfiniteSegmentMaterial( + start_is_infinite=self._start_is_infinite, + end_is_infinite=self._end_is_infinite, + **self._material_kwargs(), + ) + + @property + def axis(self) -> str | None: + """the axis the lines are defined along ("x", "y", "z"), or None if set from endpoints""" + return self._data.axis + + @property + def start_is_infinite(self) -> bool: + """Get or set whether the start of each line is extended to infinity""" + return self._start_is_infinite + + @start_is_infinite.setter + def start_is_infinite(self, value: bool): + self._start_is_infinite = bool(value) + self.world_object.material.start_is_infinite = self._start_is_infinite + + @property + def end_is_infinite(self) -> bool: + """Get or set whether the end of each line is extended to infinity""" + return self._end_is_infinite + + @end_is_infinite.setter + def end_is_infinite(self, value: bool): + self._end_is_infinite = bool(value) + self.world_object.material.end_is_infinite = self._end_is_infinite + + @property + def thin(self) -> bool: + """infinite lines do not support the thin line material""" + return False + + @thin.setter + def thin(self, value: bool): + if value: + raise NotImplementedError( + "`InfLineGraphic` does not support the thin line material" + ) + + def _selectors_not_supported(self, *args, **kwargs): + raise NotImplementedError("selectors are not supported on `InfLineGraphic`") + + add_linear_selector = _selectors_not_supported + add_linear_region_selector = _selectors_not_supported + add_rectangle_selector = _selectors_not_supported + add_polygon_selector = _selectors_not_supported + + def format_pick_info(self, pick_info: dict) -> str: + # two vertices per line + index = pick_info["vertex_index"] // 2 + + if self.axis is not None: + return f"{self.axis}: {self.data.value[index]:.4g}" + + # for axis=None, show the first endpoint of the picked line + point = self.data.value[index][0] + return "\n".join(f"{dim}: {val:.4g}" for dim, val in zip("xyz", point)) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index bba10b10f..0b325df71 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -1,4 +1,5 @@ from typing import * +from warnings import warn import numpy as np @@ -13,6 +14,8 @@ ) from .features import ( Thickness, + DashPattern, + parse_dash_pattern, VertexPositions, VertexColors, UniformColor, @@ -30,6 +33,7 @@ class LineGraphic(PositionsGraphic): "cmap": (VertexCmap, None), # none if UniformColor "thickness": Thickness, "size_space": SizeSpace, + "dash_pattern": DashPattern, } def __init__( @@ -41,6 +45,8 @@ def __init__( cmap_transform: np.ndarray | Sequence = None, color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", + dash_pattern: str | tuple | list = (), + thin: bool = False, **kwargs, ): """ @@ -80,6 +86,15 @@ def __init__( size_space: str, default "screen" coordinate space in which the thickness is expressed ("screen", "world", "model") + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. + + thin: bool, default False + Use the more performant thin line material, which is always one physical pixel wide. + Thickness, dashing, and anti-aliasing are ignored when True. + **kwargs passed to :class:`.Graphic` @@ -96,42 +111,52 @@ def __init__( ) self._thickness = Thickness(thickness) + self._dash_pattern = DashPattern(dash_pattern) + self._thin = bool(thin) - if thickness < 1.1: - MaterialCls = pygfx.LineThinMaterial - aa = True - else: - MaterialCls = pygfx.LineMaterial + if self._thin and parse_dash_pattern(dash_pattern): + warn( + "`dash_pattern` is ignored when `thin=True`; the thin line material does not " + "support dashing" + ) - aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") + world_object = pygfx.Line( + geometry=self._create_geometry(), + material=self._make_material(), + ) + + self._set_world_object(world_object) + + def _material_kwargs(self) -> dict: + # pygfx line material kwargs assembled from the current feature state + kwargs = dict( + thickness=self.thickness, + thickness_space=self.size_space, + dash_pattern=parse_dash_pattern(self._dash_pattern.value), + aa=self.alpha_mode in ("blend", "weighted_blend"), + pick_write=True, + depth_compare="<=", + ) if isinstance(self._colors, UniformColor): - geometry = pygfx.Geometry(positions=self._data._fpl_buffer) - material = MaterialCls( - aa=aa, - thickness=self.thickness, - color_mode="uniform", - color=self.colors, - pick_write=True, - thickness_space=self.size_space, - depth_compare="<=", - ) + kwargs["color_mode"] = "uniform" + kwargs["color"] = self.colors else: - material = MaterialCls( - aa=aa, - thickness=self.thickness, - color_mode="vertex", - pick_write=True, - thickness_space=self.size_space, - depth_compare="<=", - ) - geometry = pygfx.Geometry( - positions=self._data._fpl_buffer, colors=self._colors._fpl_buffer - ) + kwargs["color_mode"] = "vertex" - world_object: pygfx.Line = pygfx.Line(geometry=geometry, material=material) + return kwargs - self._set_world_object(world_object) + def _make_material(self) -> pygfx.LineMaterial: + # create the pygfx material, subclasses override to use a different line material + material_cls = pygfx.LineThinMaterial if self._thin else pygfx.LineMaterial + return material_cls(**self._material_kwargs()) + + def _create_geometry(self) -> pygfx.Geometry: + if isinstance(self._colors, UniformColor): + return pygfx.Geometry(positions=self._data._fpl_buffer) + return pygfx.Geometry( + positions=self._data._fpl_buffer, colors=self._colors._fpl_buffer + ) @property def thickness(self) -> float: @@ -142,6 +167,56 @@ def thickness(self) -> float: def thickness(self, value: float): self._thickness.set_value(self, value) + @property + def dash_pattern(self) -> str | tuple | list: + """ + Get or set the dash pattern. + + May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` or + ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. + """ + return self._dash_pattern.value + + @dash_pattern.setter + def dash_pattern(self, value: str | tuple | list): + if self._thin and parse_dash_pattern(value): + warn( + "`dash_pattern` is ignored when `thin=True`; the thin line material does not " + "support dashing" + ) + self._dash_pattern.set_value(self, value) + + @property + def thin(self) -> bool: + """ + Get or set whether the line uses the more performant thin line material, which is + always one physical pixel wide. Thickness, dashing, and anti-aliasing are ignored + when True. + """ + return self._thin + + @thin.setter + def thin(self, value: bool): + value = bool(value) + if value == self._thin: + return + + if value and parse_dash_pattern(self._dash_pattern.value): + warn( + "`dash_pattern` is ignored when `thin=True`; the thin line material does not " + "support dashing" + ) + + self._thin = value + + # thin vs. non-thin is a different pygfx material, so rebuild and swap it in place, + # keeping the same geometry + material = self._make_material() + material.opacity = self.alpha + material.alpha_mode = self.alpha_mode + self.world_object.material = material + def add_linear_selector( self, selection: float = None, axis: str = "x", **kwargs ) -> LinearSelector: diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index ac1e81414..d6189c4bd 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -3,6 +3,7 @@ from typing import * import numpy +from numpy.typing import NDArray from numpy.typing import NDArray @@ -324,6 +325,94 @@ def add_image_yuv( **kwargs ) + def add_inf_line( + self, + data: Any, + axis: Optional[Literal["x", "y", "z"]] = None, + thickness: float = 2.0, + colors: Union[str, numpy.ndarray, Sequence] = "w", + cmap: str = None, + cmap_transform: Union[numpy.ndarray, Sequence] = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", + start_is_infinite: bool = True, + end_is_infinite: bool = True, + dash_pattern: str | tuple | list = (), + size_space: str = "screen", + **kwargs + ) -> InfLineGraphic: + """ + + Create a collection of infinite lines. + + Parameters + ---------- + data: array-like + The line positions. If ``axis`` is "x", "y", or "z", a 1D array of positions along + that axis; one infinite line is drawn at each position. If ``axis`` is None, ``data`` + is used directly as the segment endpoints, of shape [n_points, 2 | 3], where every two + consecutive points define one line. + + axis: "x", "y", "z", or None, default None + The axis along which the line positions are given. If None, ``data`` is interpreted + directly as the segment endpoints. + + thickness: float, optional, default 2.0 + thickness of the lines + + colors: str, array, or iterable, default "w" + specify colors as a single human-readable string, a single RGBA array, or a Sequence + (array, tuple, or list) of strings or RGBA arrays. A sequence of colors provides one + color per line. + + cmap: str, optional + Apply a colormap to the lines instead of assigning colors manually, one color per line. + This overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all lines. + "vertex" allows an independent color per line. + For most cases you can keep it as "auto" and the `color_mode` is determined automatically + based on the argument passed to `colors`. + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + start_is_infinite: bool, default True + whether the start of each line is extended to infinity + + end_is_infinite: bool, default True + whether the end of each line is extended to infinity + + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. + + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") + + **kwargs + passed to :class:`.Graphic` + + + """ + return self._create_graphic( + InfLineGraphic, + data, + axis, + thickness, + colors, + cmap, + cmap_transform, + color_mode, + start_is_infinite, + end_is_infinite, + dash_pattern, + size_space, + **kwargs + ) + def add_line_collection( self, data: Union[numpy.ndarray, List[numpy.ndarray]], @@ -420,6 +509,8 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", + dash_pattern: str | tuple | list = (), + thin: bool = False, **kwargs ) -> LineGraphic: """ @@ -460,6 +551,15 @@ def add_line( size_space: str, default "screen" coordinate space in which the thickness is expressed ("screen", "world", "model") + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. + + thin: bool, default False + Use the more performant thin line material, which is always one physical pixel wide. + Thickness, dashing, and anti-aliasing are ignored when True. + **kwargs passed to :class:`.Graphic` @@ -474,6 +574,8 @@ def add_line( cmap_transform, color_mode, size_space, + dash_pattern, + thin, **kwargs ) diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 336d82b25..aba780ac8 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -32,7 +32,7 @@ def generate_add_graphics_methods(): f.write("# This is an auto-generated file and should not be modified directly\n\n") f.write("from typing import *\n\n") - f.write("import numpy\n\n") + f.write("import numpy\n") f.write("from numpy.typing import NDArray\n\n") f.write("import pygfx\n\n") f.write("from ..graphics import *\n") diff --git a/tests/test_common_features.py b/tests/test_common_features.py index aea016aae..996a18789 100644 --- a/tests/test_common_features.py +++ b/tests/test_common_features.py @@ -19,6 +19,8 @@ def make_graphic(kind: str, **kwargs): return fpl.ImageGraphic(np.random.rand(10, 10), **kwargs) case "line": return fpl.LineGraphic(np.random.rand(10), **kwargs) + case "inf_line": + return fpl.InfLineGraphic(np.random.rand(10), axis="x", **kwargs) case "scatter": return fpl.ScatterGraphic( np.column_stack([np.random.rand(10), np.random.rand(10)]), **kwargs @@ -30,6 +32,7 @@ def make_graphic(kind: str, **kwargs): graphic_kinds = [ "image", "line", + "inf_line", "scatter", "text", ] diff --git a/tests/test_positions_graphics.py b/tests/test_positions_graphics.py index 4bc93b626..a875b1416 100644 --- a/tests/test_positions_graphics.py +++ b/tests/test_positions_graphics.py @@ -389,11 +389,74 @@ def test_thickness(thickness): assert graphic.thickness == thickness assert graphic.world_object.material.thickness == thickness - if thickness == 0.5: - assert isinstance(graphic.world_object.material, pygfx.LineThinMaterial) + # the thin line material is selected via the `thin` flag, not the thickness value + assert not graphic.thin + assert isinstance(graphic.world_object.material, pygfx.LineMaterial) + assert not isinstance(graphic.world_object.material, pygfx.LineThinMaterial) - else: - assert isinstance(graphic.world_object.material, pygfx.LineMaterial) + +@pytest.mark.parametrize( + "pattern,expected", + [ + ("--", (5, 5)), + ("dashed", (5, 5)), + (":", (0, 2)), + ("-.", (5, 2, 1, 2)), + ((2, 3), (2, 3)), + ], +) +def test_dash_pattern(pattern, expected): + fig = fpl.Figure() + data = generate_positions_spiral_data("xy") + + graphic = fig[0, 0].add_line(data=data, dash_pattern=pattern) + + # value returns the user input verbatim, the material receives the parsed tuple + assert graphic.dash_pattern == pattern + assert tuple(graphic.world_object.material.dash_pattern) == expected + + # can be changed after creation + graphic.dash_pattern = "solid" + assert tuple(graphic.world_object.material.dash_pattern) == () + + +def test_thin(): + fig = fpl.Figure() + data = generate_positions_spiral_data("xy") + + # non-thin by default + graphic = fig[0, 0].add_line(data=data, thickness=5.0) + assert graphic.thin is False + assert not isinstance(graphic.world_object.material, pygfx.LineThinMaterial) + + # the material is swapped when toggling `thin` after creation, keeping the geometry + geometry = graphic.world_object.geometry + graphic.thin = True + assert graphic.thin is True + assert isinstance(graphic.world_object.material, pygfx.LineThinMaterial) + assert graphic.world_object.geometry is geometry + + graphic.thin = False + assert not isinstance(graphic.world_object.material, pygfx.LineThinMaterial) + assert isinstance(graphic.world_object.material, pygfx.LineMaterial) + + # can also be set at construction + thin_graphic = fig[0, 0].add_line(data=data, thin=True) + assert isinstance(thin_graphic.world_object.material, pygfx.LineThinMaterial) + + +def test_thin_ignores_dash_pattern_warns(): + fig = fpl.Figure() + data = generate_positions_spiral_data("xy") + + # constructing a thin line with a dash pattern warns that dashing is ignored + with pytest.warns(UserWarning, match="dash_pattern.*ignored"): + fig[0, 0].add_line(data=data, thin=True, dash_pattern="--") + + # setting the dash pattern on a thin line also warns + graphic = fig[0, 0].add_line(data=data, thin=True) + with pytest.warns(UserWarning, match="dash_pattern.*ignored"): + graphic.dash_pattern = "--" @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) From 852c0a2d341dca01c1f992719ecfd1ce4bc79a83 Mon Sep 17 00:00:00 2001 From: Amol Pasarkar Date: Thu, 6 Aug 2026 16:57:52 -0400 Subject: [PATCH 123/163] Selection vector improve (#1061) * Full selection vector implementation * Includes some documentation at top of SelectionVector * Minor typing fix in linear selector selection setter * First working version with selection vector * Reworks the logic for adding selectors, improves some documentation, adds partial instead of lambda functions, improves typing in highlight selector * Fixes casting bug in the integer version of the code * Update fastplotlib/graphics/selectors/_selection_vector.py * Update fastplotlib/graphics/selectors/_selection_vector.py * Removes unused init time parameter and also updates the typing on the default inverse mapping function to always return an integer * Adds a nonneg check in selection vector selection setter * Adds nonneg test for inverse handler --------- Co-authored-by: Kushal Kolar --- .../graphics/selectors/_highlight_selector.py | 2 +- fastplotlib/graphics/selectors/_linear.py | 2 +- .../graphics/selectors/_selection_vector.py | 165 +++++++++++++----- 3 files changed, 124 insertions(+), 45 deletions(-) diff --git a/fastplotlib/graphics/selectors/_highlight_selector.py b/fastplotlib/graphics/selectors/_highlight_selector.py index 3ba08a676..dc757c07a 100644 --- a/fastplotlib/graphics/selectors/_highlight_selector.py +++ b/fastplotlib/graphics/selectors/_highlight_selector.py @@ -681,7 +681,7 @@ def selection(self) -> tuple[int | None, ...] | dict[str, tuple]: return {k: tuple(v) for k, v in self._selection.items()} @selection.setter - def selection(self, value: Iterable[int] | dict[Literal["rows", "cols", "pixels"], list]) -> None: + def selection(self, value: Iterable[int | None] | dict[Literal["rows", "cols", "pixels"], list] | None) -> None: if self._selection_options is not None: if value is None: self._selected_indices = list() diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 4ea454ee8..f652a3d9e 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -27,7 +27,7 @@ def selection(self) -> float: return self._selection.value @selection.setter - def selection(self, value: int): + def selection(self, value: float): graphic = self._parent if isinstance(graphic, GraphicCollection): diff --git a/fastplotlib/graphics/selectors/_selection_vector.py b/fastplotlib/graphics/selectors/_selection_vector.py index a1e0bed10..2c9b0aaed 100644 --- a/fastplotlib/graphics/selectors/_selection_vector.py +++ b/fastplotlib/graphics/selectors/_selection_vector.py @@ -1,89 +1,168 @@ from collections.abc import Callable from functools import partial -from typing import Any, Sequence +from typing import Any, Sequence, TypeAlias +from numbers import Integral + +import numpy as np from ._protocols import SelectorProtocol, MultiSelectorProtocol +Mapping = np.ndarray | dict[int, int] | Callable def identity(val: Any) -> Any: return val +def array_map(arr: np.ndarray, index: Integral): + """ + Used to map local to global indices + """ + return None if np.isnan(arr[index]) else int(arr[index]) + +def inv_array_map(arr: np.ndarray, + value: int) -> None | int: + """ + arr[i] gives the global index + """ + x = np.flatnonzero(arr == value) + return None if x.size == 0 else int(x[0]) + +def dict_map(my_dict: dict, key: Integral): + if key is None: + return None + elif int(key) not in my_dict: + return None + else: + return my_dict[key] + class SelectionVector: - def __init__(self, max_size: int = None): + """ + A class for performing coordinated selections across multiple selectors. + For each selector in the selection vector, the user specifies how the global indices (shared across selectors) + maps to the local indices (each selector has its own local index space). + + The SelectionVector coordinates across individual selectors, including the coordinated updating of indices whenever a selection changes + """ + def __init__(self): # selector -> (map, map_inv) + + ## Key is a selector, value is a (1) local to global index map (2) global to local index map (3) list of event handlers self._selectors: dict[ - SelectorProtocol | MultiSelectorProtocol, tuple[Callable, Callable] + SelectorProtocol | MultiSelectorProtocol, tuple[Callable, Callable, list[Callable]] ] = dict() self._selection: list[Any] = list() + self._block_reentrance = False @property def selection(self) -> tuple[Any]: return tuple(self._selection) @selection.setter - def selection(self, new: Sequence[Any]): - # iterate through each selector that operates in its own "local" space - for selector_local, (map_, map_inv) in self._selectors.items(): - indices_local = map_(new) - selector_local.selection = indices_local + def selection(self, new: Integral | Sequence[Any]): + if self._block_reentrance: + return + else: + self._block_reentrance = True + if isinstance(new, Integral): + new = [new] + self._selection = list(new) + for value in new: + if value < 0: + raise ValueError("Only nonnegative selection indices are allowed") + # iterate through each selector that operates in its own "local" space + for selector_local, (map_, map_inv, handler) in self._selectors.items(): + local_indices = [] + for value in new: + curr_indices = map_(value) + local_indices.append(curr_indices) + selector_local.selection = local_indices + self._block_reentrance = False def append(self, index): self._selection.append(index) - for selector, (map_, map_inv) in self._selectors.items(): + for selector, (map_, map_inv, handler_list) in self._selectors.items(): if not isinstance(selector, MultiSelectorProtocol): continue - index_local = map_([index]) - selector.append(index_local[0]) - - def clear(self): - self._selection.clear() - # TODO: clear selectors + index_local = map_(index) + selector.append(index_local) def add_selector( self, new: ( SelectorProtocol - | tuple[SelectorProtocol, Callable] - | tuple[SelectorProtocol, Callable, Callable] + | tuple[SelectorProtocol, dict] + | tuple[SelectorProtocol, np.ndarray] + |tuple[SelectorProtocol, Callable, Callable] ), ): - selector: SelectorProtocol - map_: Callable - map_inv: Callable - + """ + User specifies (1) the selector and (2) The master --> local index mapping. This + mapping is given either as: + - A 1D np.ndarray of integers. The array index is the global index, and the array value is the local index + - A dictionary where keys (master indices) and values (local indices) are both integers + - Two callables. The first callable defines the global index --> local index map, the second specifies the local index --> global index map. + All callables take as input nonnegative integers and output nonnegative integers. + """ if isinstance(new, (tuple, list)): if not isinstance(new[0], SelectorProtocol): raise TypeError - if len(new) not in (2, 3): - raise TypeError - - if not all(callable(c) for c in new[1:]): - raise TypeError + if len(new) == 3: + if isinstance(new[1], Callable) and isinstance(new[2], Callable): + master_to_local = new[1] + local_to_master = new[2] + else: + raise ValueError(f"Both index mappings must be Callables, you provided {type(new[1])} and {type(new[2])}") + elif len(new) == 2: + if isinstance(new[1], dict): + ## Construct inverse mapping + inverse_dict = dict() + for key, val in new[1].items(): + inverse_dict[int(val)] = int(key) + master_to_local = partial(dict_map, new[1]) + local_to_master = partial(dict_map, inverse_dict) + + elif isinstance(new[1], np.ndarray): + if not new[1].ndim == 1: + raise ValueError("If you pass in an array mapping, it must be 1-D") + master_to_local = partial(array_map, new[1]) + local_to_master = partial(inv_array_map, new[1]) + else: + raise ValueError(f"Must either provide a single dict or numpy array specifying the local to global index mapping, or two callables" + f"specifying the mapping in both directions") selector = new[0] - map_ = new[1] - map_inv = new[2] if len(new) == 3 else identity elif isinstance(new, SelectorProtocol): - selector, map_, map_inv = new, identity, identity + selector, master_to_local, local_to_master = new, identity, identity else: raise ValueError - selector.add_event_handler(partial(self._inv_handler, map_inv)) - - self._selectors[selector] = (map_, map_inv) - - def _inv_handler(self, map_inv: Callable, local_selection): - return - # when a selectable changes its selection, set global index change using map inverse - # self._selection = map_inv(local_selection) - - def remove(self): - pass - - def clear_selectables(self): - self._selectors.clear() + handler = selector.add_event_handler(partial(self._inv_handler, local_to_master)) + self._selectors[selector] = (master_to_local, local_to_master, [handler]) + + def _inv_handler(self, map_inv: Callable, local_selection: dict): + """ + HighlightSelector and VisibilitySelector emit a dictionary with keys selector and value + """ + input_to_map = local_selection['value'] + for i in range(len(input_to_map)): + if input_to_map[i] < 0: + raise ValueError("You can only provide nonnegative values as local indices to a selector") + + self.selection = [map_inv(input_to_map[i]) for i in range(len(input_to_map))] + + def remove_selector(self, selector: SelectorProtocol | MultiSelectorProtocol): + if selector in self._selectors: + map, map_inv, handler_list = self._selectors.pop(selector) + for handler in handler_list: + selector.remove_event_handler(handler) + if isinstance(selector, MultiSelectorProtocol): + selector.clear() + + def clear_selectors(self): + for selector in self._selectors.keys(): + if isinstance(selector, MultiSelectorProtocol): + selector.clear() \ No newline at end of file From a2b9d93072ee3fb53327290fd078b6c6c44a58fa Mon Sep 17 00:00:00 2001 From: Amol Pasarkar Date: Thu, 6 Aug 2026 20:19:29 -0400 Subject: [PATCH 124/163] =?UTF-8?q?Includes=20basic=20np=20dlpack=20functi?= =?UTF-8?q?on=20that=20is=20tested=20to=20work=20across=20jax=20a=E2=80=A6?= =?UTF-8?q?=20(#1067)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Includes basic np dlpack function that is tested to work across jax and torch * delays dimension permutation for all code that uses ndprocessor * Eliminates old comment * Updates the numpy dep to be the min version that allows dlpack conversion across devices * Includes code to update spatial dims indices whenever spatial dims is changed * Moves the spatial dims indices computation to the read only property in the base class, updates docs, uses public property in the ndprocessor subclasses --- fastplotlib/utils/functions.py | 11 +++-------- fastplotlib/widgets/nd_widget/_base.py | 14 ++++++++------ fastplotlib/widgets/nd_widget/_nd_image.py | 2 +- .../nd_widget/_nd_positions/_nd_positions.py | 3 +++ fastplotlib/widgets/nd_widget/_nd_vectors.py | 4 ++-- pyproject.toml | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 97a3df742..9b6c83c6c 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -408,14 +408,9 @@ def parse_cmap_values( def cuda_to_numpy(arr: CudaArrayProtocol) -> np.ndarray: - try: - import cupy - except ImportError: - raise ImportError( - "`cupy` is required to work with GPU arrays\npip install cupy" - ) - - return cupy.asnumpy(arr) + + data = np.from_dlpack(arr, device='cpu') + return data def subsample_array( diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 5ca15889d..ce17baef7 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -198,6 +198,13 @@ def spatial_dims(self, sdims: Sequence[str]): self._spatial_dims = tuple(sdims) + @property + def spatial_dims_indices(self) -> tuple[int, ...]: + """ + The ordered spatial dim indices that correspond to the named spatial dims + """ + return tuple(self.spatial_dims.index(d) for d in self.dims if d in self.spatial_dims) + @property def tooltip(self) -> bool: """ @@ -529,12 +536,7 @@ async def get_window_output(self, indices: dict[str, Any]) -> ArrayProtocol: f"windowed_slice.ndim != len(self.spatial_dims): {windowed_slice.ndim} != {len(self.spatial_dims)}" ) - # transpose to spatial dims - spatial_dims_int = tuple( - self.spatial_dims.index(d) for d in self.dims if d in self.spatial_dims - ) - - return windowed_slice.transpose(*spatial_dims_int) + return windowed_slice async def _get_raw_data_slice(self, indices: dict[str, Any]) -> ArrayProtocol: """ diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 3090e14c7..40dd510f9 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -258,7 +258,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: if isinstance(window_output, CudaArrayProtocol): window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) - return window_output + return window_output.transpose(*self.spatial_dims_indices) def _recompute_histogram(self): """ diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 2cc768f52..9ca5eee93 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -112,6 +112,7 @@ def __init__( self.cmap_transform_each = cmap_transform_each self.sizes = sizes + def _check_shape_feature( self, prop: str, check_shape: tuple[int, int] ) -> tuple[int, int]: @@ -555,6 +556,8 @@ async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]: if isinstance(data, CudaArrayProtocol): data = await run_in_thread_pool(self._executor, cuda_to_numpy, data) + data = data.transpose(*self.spatial_dims_indices) + return { "data": data, **other, diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 138ddee95..1a4d1b8e5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -157,7 +157,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: Example: get((100, 5)) """ - # this will be squeezed output, with dims in the order of the user set spatial dims + # this will be squeezed output, with dims in the order of self.dims window_output = await self.get_window_output(indices) # apply spatial_func; CUDA arrays run inline, numpy goes through the thread pool @@ -175,7 +175,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: if isinstance(window_output, CudaArrayProtocol): window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) - return window_output + return window_output.transpose(*self.spatial_dims_indices) class NDVectors(NDGraphic): diff --git a/pyproject.toml b/pyproject.toml index 0352cf27c..9c914bd79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ keywords = [ ] requires-python = ">= 3.10" dependencies = [ - "numpy>=1.23.0", + "numpy>=2.1.0", "pygfx==0.16.0", "wgpu", # Let pygfx constrain the wgpu version "cmap>=0.1.3", From e6b685029bc2e7fd4ad2974316bab3272222a19f Mon Sep 17 00:00:00 2001 From: Amol Pasarkar Date: Mon, 10 Aug 2026 17:37:27 -0400 Subject: [PATCH 125/163] Filters for None values in the inv handler (#1073) --- fastplotlib/graphics/selectors/_selection_vector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/selectors/_selection_vector.py b/fastplotlib/graphics/selectors/_selection_vector.py index 2c9b0aaed..c03de019b 100644 --- a/fastplotlib/graphics/selectors/_selection_vector.py +++ b/fastplotlib/graphics/selectors/_selection_vector.py @@ -149,7 +149,7 @@ def _inv_handler(self, map_inv: Callable, local_selection: dict): """ input_to_map = local_selection['value'] for i in range(len(input_to_map)): - if input_to_map[i] < 0: + if isinstance(input_to_map[i], Integral) and input_to_map[i] < 0: raise ValueError("You can only provide nonnegative values as local indices to a selector") self.selection = [map_inv(input_to_map[i]) for i in range(len(input_to_map))] From 1ec8e4b095666fd491b9ab64c582ec449e4e546f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 22 Aug 2026 12:06:56 -0400 Subject: [PATCH 126/163] bugfix after last PR --- fastplotlib/widgets/nd_widget/_video.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py index 23e8cd6e9..8d150153c 100644 --- a/fastplotlib/widgets/nd_widget/_video.py +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -1,11 +1,14 @@ -from ._nd_image import NDImageProcessor, NDImage from typing import Callable, Any, Literal import numpy as np +from ...graphics.image import TupleYUV +from ._nd_image import NDImageProcessor +from ._async import run_in_thread_pool + class VideoProcessor(NDImageProcessor): - async def get_window_output(self, indices: dict[str, Any]): + async def get_window_output(self, indices: dict[str, Any]) -> TupleYUV | np.ndarray: """ Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims @@ -25,3 +28,20 @@ async def get_window_output(self, indices: dict[str, Any]): # convert to numpy array return np.asarray(windowed_slice).squeeze() + + async def get(self, indices: dict[str, Any]) -> TupleYUV | np.ndarray: + """ + Similar to NDImage.get() but accounts for TupleYUV output. + """ + # this will be squeezed output, with dims in the order of the user set spatial dims + window_output = await self.get_window_output(indices) + + if self.spatial_func is not None: + window_output = await run_in_thread_pool( + self._executor, self._spatial_func, window_output + ) + + if isinstance(window_output, tuple): + return tuple(a.transpose(*self.spatial_dims_indices) for a in window_output) + + return window_output.transpose(*self.spatial_dims_indices) From b2132e3d11b9e2bd641e0bbfc0bbee3d413d1d88 Mon Sep 17 00:00:00 2001 From: Flynn <75346097+FlynnOConnell@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:22:01 -0500 Subject: [PATCH 127/163] alpha is material.opacity (#1074) --- fastplotlib/graphics/features/_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/features/_common.py b/fastplotlib/graphics/features/_common.py index 6ce167075..3b3e0be7d 100644 --- a/fastplotlib/graphics/features/_common.py +++ b/fastplotlib/graphics/features/_common.py @@ -202,7 +202,7 @@ def set_value(self, graphic, value: float): if "Image" in graphic.__class__.__name__: # Image and ImageVolume use tiling and share one material - graphic._material.alpha = value + graphic._material.opacity = value self._value = value From 197b759e10bbac55710eadb1effb5da10561846f Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 1 Sep 2026 20:15:00 -0400 Subject: [PATCH 128/163] clamp vmin, vmax in colorbar histogram LUT tool (#1076) --- fastplotlib/ui/_colorbar.py | 40 ++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/fastplotlib/ui/_colorbar.py b/fastplotlib/ui/_colorbar.py index 7de048af9..35b80602f 100644 --- a/fastplotlib/ui/_colorbar.py +++ b/fastplotlib/ui/_colorbar.py @@ -190,7 +190,7 @@ def cmap(self, name: str): @property def vmin(self) -> float: """get or set the lower contrast limit""" - return self._vmin + return max(self._vmin, self.histogram[1][0]) @vmin.setter def vmin(self, value: float): @@ -209,7 +209,7 @@ def vmin(self, value: float): @property def vmax(self) -> float: """get or set the upper contrast limit""" - return self._vmax + return min(self._vmax, self.histogram[1][-1]) @vmax.setter def vmax(self, value: float): @@ -343,8 +343,8 @@ def _update_bar_texture(self): # the bar spans the flanked axis so it aligns with the histogram and the handles axis_min, axis_max = self._axis_range() span = axis_max - axis_min - lo = (self._vmin - axis_min) / span - hi = (self._vmax - axis_min) / span + lo = (self.vmin - axis_min) / span + hi = (self.vmax - axis_min) / span t = np.linspace(1.0, 0.0, self.LUT_HEIGHT) norm = np.clip((t - lo) / (hi - lo), 0.0, 1.0) norm = norm ** self._gamma @@ -479,8 +479,8 @@ def cursor_value(): return self._y_to_value(imgui.get_io().mouse_pos.y, bar_y, bar_h) # shaded fill between the vmin and vmax lines - y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) - y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + y_vmax = self._value_to_y(self.vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self.vmin, bar_y, bar_h) draw_list.add_rect_filled((x_left, y_vmax), (x_right, y_vmin), fill_color) # drag the region between the lines to move both together @@ -491,9 +491,9 @@ def cursor_value(): imgui.set_cursor_screen_pos((x_left, top)) imgui.invisible_button("##region", (width, bottom - top)) if imgui.is_item_activated(): - self._grab_offset = 0.5 * (self._vmin + self._vmax) - cursor_value() + self._grab_offset = 0.5 * (self.vmin + self.vmax) - cursor_value() if imgui.is_item_active(): - half = 0.5 * (self._vmax - self._vmin) + half = 0.5 * (self.vmax - self.vmin) center = cursor_value() + self._grab_offset center = max(axis_min + half, min(axis_max - half, center)) self.vmin = center - half @@ -501,8 +501,8 @@ def cursor_value(): # each line has a hit-window for hovering/dragging; the line turns yellow when hovered or dragged for label, attr, lo_fn, hi_fn in ( - ("##vmax_line", "vmax", lambda: self._vmin + min_sep, lambda: axis_max), - ("##vmin_line", "vmin", lambda: axis_min, lambda: self._vmax - min_sep), + ("##vmax_line", "vmax", lambda: self.vmin + min_sep, lambda: axis_max), + ("##vmin_line", "vmin", lambda: axis_min, lambda: self.vmax - min_sep), ): cur = getattr(self, attr) y = self._value_to_y(cur, bar_y, bar_h) @@ -518,10 +518,10 @@ def cursor_value(): draw_list.add_line((x_left, y), (x_right, y), yellow if hovered else white, 2.0) # current vmax above its line, vmin below its line - y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) - y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) - self._text_right(draw_list, f"{self._vmax:.4g}", x_right, y_vmax - imgui.get_text_line_height()) - self._text_right(draw_list, f"{self._vmin:.4g}", x_right, y_vmin) + y_vmax = self._value_to_y(self.vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self.vmin, bar_y, bar_h) + self._text_right(draw_list, f"{self.vmax:.4g}", x_right, y_vmax - imgui.get_text_line_height()) + self._text_right(draw_list, f"{self.vmin:.4g}", x_right, y_vmin) def _text_right(self, draw_list, text: str, x_right: float, y: float): """draw text right-aligned so it ends at x_right""" @@ -555,25 +555,25 @@ def cursor_value(): # drag the region between the handles to move vmin and vmax together if self._region_drag: - y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) - y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + y_vmax = self._value_to_y(self.vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self.vmin, bar_y, bar_h) top = y_vmax + h / 2 bottom = y_vmin - h / 2 if bottom > top: imgui.set_cursor_screen_pos((x_left, top)) imgui.invisible_button("##bar_region", (x_right - x_left, bottom - top)) if imgui.is_item_activated(): - self._grab_offset = 0.5 * (self._vmin + self._vmax) - cursor_value() + self._grab_offset = 0.5 * (self.vmin + self.vmax) - cursor_value() if imgui.is_item_active(): - half = 0.5 * (self._vmax - self._vmin) + half = 0.5 * (self.vmax - self.vmin) center = cursor_value() + self._grab_offset center = max(axis_min + half, min(axis_max - half, center)) self.vmin = center - half self.vmax = center + half for label, attr, lo_fn, hi_fn in ( - ("##bar_vmax", "vmax", lambda: self._vmin + min_sep, lambda: axis_max), - ("##bar_vmin", "vmin", lambda: axis_min, lambda: self._vmax - min_sep), + ("##bar_vmax", "vmax", lambda: self.vmin + min_sep, lambda: axis_max), + ("##bar_vmin", "vmin", lambda: axis_min, lambda: self.vmax - min_sep), ): cur = getattr(self, attr) y = self._value_to_y(cur, bar_y, bar_h) From 57aa47350ba9551a4fd84262eb174f0ebb6a704b Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 9 Sep 2026 06:34:22 -0400 Subject: [PATCH 129/163] Flat graphic features 2nd edition (#1072) * refactor cmap and cmap_transform for positional graphics * color mode stuff * WIP * more WIP * better add_graphic autogen * more progress * mostly done refactoring positions graphics cmap stuff * forgot to update mixin * cmap_transform tweaks * wip, JaggedArray * mostly works, writing tests * remove a print * image collection examples * fix * feature inheritance in PositionsGraphic and cmap_range * cmap handling * cmap tweaks * remove parse_cmap_values, rely on cmap lib * fix example, remove unused line of code * Image just uses cmap lib now too * delete_ndgraphic() * ndtimeseries cmap example * steps for stacks * ndtimeseries sets stack steps * docstrings * docstrings * comments * renames * docstring * fixes * update tests/test_colors_buffer_manager.py * update test_markers_buffer_manager.py * update plot_helpers test * update test_point_rotations_buffer_manager.py * update test_positions_graphics.py * update another test * more tests * update yet more tests * remove unused * infline tests * collections tests * updating examples, wip * more examples updates * more examples updates * more example updates * more example updates * example and docstring * docstring * remove complex line collection slicing example, stick with real usecases * better line stack example * update example * update example * update link * update more examples * examples and fixes * fix * more fixes * fix * updates * Fix * update docs * docs * add image collection to docs conf.py * update docs --- docs/source/api/axes/Grid.rst | 1 + docs/source/api/axes/Grids.rst | 1 + docs/source/api/axes/Ruler.rst | 1 + .../api/graphic_features/BufferManager.rst | 36 + .../api/graphic_features/DashPattern.rst | 35 + .../api/graphic_features/GraphicFeature.rst | 35 + .../api/graphic_features/InfLineAxisData.rst | 37 + .../api/graphic_features/InfLineColors.rst | 36 + .../api/graphic_features/VertexCmap.rst | 3 - .../api/graphic_features/VertexCmapRange.rst | 35 + .../graphic_features/VertexCmapTransform.rst | 35 + docs/source/api/graphic_features/index.rst | 7 + .../source/api/graphics/GraphicCollection.rst | 59 + docs/source/api/graphics/ImageCollection.rst | 74 + docs/source/api/graphics/ImageGrid.rst | 74 + docs/source/api/graphics/InfLineGraphic.rst | 72 + docs/source/api/graphics/LineCollection.rst | 7 + docs/source/api/graphics/LineGraphic.rst | 5 +- docs/source/api/graphics/LineStack.rst | 10 + .../source/api/graphics/ScatterCollection.rst | 10 + docs/source/api/graphics/ScatterGraphic.rst | 3 +- docs/source/api/graphics/ScatterStack.rst | 11 + docs/source/api/graphics/index.rst | 4 + docs/source/api/layouts/subplot.rst | 4 + docs/source/api/selectors/SelectionVector.rst | 5 +- docs/source/conf.py | 1 + docs/source/generate_api.py | 4 + docs/source/user_guide/event_tables.rst | 1729 ++++++----------- docs/source/user_guide/guide.rst | 223 ++- examples/events/cmap_event.py | 2 +- examples/events/drag_points.py | 2 +- examples/events/key_events.py | 19 +- examples/events/lines_mouse_nearest.py | 2 +- examples/events/scatter_click.py | 16 +- examples/events/scatter_hover.py | 23 +- examples/events/scatter_hover_transforms.py | 22 +- examples/gridplot/multigraphic_gridplot.py | 4 +- examples/guis/imgui_top.py | 6 +- examples/image/image_cmap.py | 6 +- examples/image_collection/README.rst | 2 + examples/image_collection/image_collection.py | 32 + examples/image_collection/image_grid.py | 30 + .../image_volume/image_volume_toy_data.py | 5 +- examples/line/inf_line.py | 2 - examples/line/line_colorslice.py | 14 +- .../line_collection_slicing.py | 79 - examples/line_collection/line_stack.py | 29 +- examples/line_collection/line_stack_3d.py | 5 +- examples/machine_learning/kmeans.py | 12 +- examples/mesh/surface_earth.py | 2 +- examples/misc/reshape_lines_scatters.py | 4 - examples/misc/scatter_animation.py | 2 +- examples/misc/scatter_sizes_animation.py | 2 +- examples/misc/tooltips_custom.py | 5 +- examples/ndwidget/ndimage.py | 10 +- examples/ndwidget/timeseries.py | 8 +- examples/ndwidget/timeseries_cmaps.py | 60 + examples/scatter/scatter_cmap_iris.py | 4 + examples/scatter/scatter_image_as_points.py | 1 + examples/scatter/scatter_iris.py | 6 +- examples/scatter/scatter_size.py | 2 +- examples/scatter/scatter_validate.py | 10 +- examples/scatter/spinning_spiral.py | 1 - .../selection_tools/highlight_selector.py | 6 +- .../linear_region_line_collection.py | 2 +- examples/selection_tools/linear_selector.py | 18 +- examples/selection_tools/polygon_selector.py | 9 +- .../selection_tools/rectangle_selector.py | 2 +- .../vectors_interact_electric_charges.py | 2 +- fastplotlib/graphics/__init__.py | 7 +- fastplotlib/graphics/_base.py | 6 +- fastplotlib/graphics/_collection_base.py | 597 +++--- fastplotlib/graphics/_collections.py | 471 +++++ fastplotlib/graphics/_jagged_array.py | 378 ++++ fastplotlib/graphics/_positions_base.py | 399 ++-- fastplotlib/graphics/features/__init__.py | 9 +- fastplotlib/graphics/features/_base.py | 11 +- fastplotlib/graphics/features/_common.py | 12 +- fastplotlib/graphics/features/_image.py | 34 +- fastplotlib/graphics/features/_line.py | 3 + fastplotlib/graphics/features/_positions.py | 252 +-- fastplotlib/graphics/features/_scatter.py | 18 +- fastplotlib/graphics/features/types.py | 22 + fastplotlib/graphics/features/utils.py | 17 +- fastplotlib/graphics/image.py | 14 +- fastplotlib/graphics/image_volume.py | 21 +- fastplotlib/graphics/inf_line.py | 34 +- fastplotlib/graphics/line.py | 73 +- fastplotlib/graphics/line_collection.py | 659 ------- fastplotlib/graphics/scatter.py | 440 +++-- fastplotlib/graphics/scatter_collection.py | 677 ------- .../graphics/selectors/_highlight_selector.py | 2 - fastplotlib/graphics/selectors/_linear.py | 3 +- .../graphics/selectors/_linear_region.py | 12 +- fastplotlib/graphics/selectors/_polygon.py | 9 +- fastplotlib/graphics/selectors/_rectangle.py | 6 +- fastplotlib/graphics/utils.py | 3 + fastplotlib/layouts/_graphic_methods_mixin.py | 1081 +++++++---- fastplotlib/utils/functions.py | 72 - fastplotlib/utils/gui.py | 4 +- fastplotlib/widgets/nd_widget/_base.py | 120 +- fastplotlib/widgets/nd_widget/_index.py | 43 +- fastplotlib/widgets/nd_widget/_nd_image.py | 92 +- .../nd_widget/_nd_positions/_nd_positions.py | 1007 +++++----- .../nd_widget/_nd_positions/_nd_timeseries.py | 273 ++- .../nd_widget/_nd_positions/_pandas.py | 75 + fastplotlib/widgets/nd_widget/_nd_vectors.py | 158 +- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 895 ++++++++- fastplotlib/widgets/nd_widget/_ndwidget.py | 91 + fastplotlib/widgets/nd_widget/_ui.py | 7 + fastplotlib/widgets/nd_widget/_video.py | 16 +- scripts/generate_add_graphic_methods.py | 85 +- tests/test_collections.py | 884 +++++++++ tests/test_colors_buffer_manager.py | 15 +- tests/test_inf_line.py | 319 +++ tests/test_markers_buffer_manager.py | 6 +- tests/test_plot_helpers.py | 10 +- tests/test_point_rotations_buffer_manager.py | 6 +- tests/test_positions_graphics.py | 320 +-- tests/test_replace_buffer.py | 3 - tests/test_scatter_graphic.py | 49 +- tests/utils.py | 38 +- 122 files changed, 7733 insertions(+), 5150 deletions(-) create mode 100644 docs/source/api/graphic_features/BufferManager.rst create mode 100644 docs/source/api/graphic_features/DashPattern.rst create mode 100644 docs/source/api/graphic_features/GraphicFeature.rst create mode 100644 docs/source/api/graphic_features/InfLineAxisData.rst create mode 100644 docs/source/api/graphic_features/InfLineColors.rst create mode 100644 docs/source/api/graphic_features/VertexCmapRange.rst create mode 100644 docs/source/api/graphic_features/VertexCmapTransform.rst create mode 100644 docs/source/api/graphics/GraphicCollection.rst create mode 100644 docs/source/api/graphics/ImageCollection.rst create mode 100644 docs/source/api/graphics/ImageGrid.rst create mode 100644 docs/source/api/graphics/InfLineGraphic.rst create mode 100644 examples/image_collection/README.rst create mode 100644 examples/image_collection/image_collection.py create mode 100644 examples/image_collection/image_grid.py delete mode 100644 examples/line_collection/line_collection_slicing.py create mode 100644 examples/ndwidget/timeseries_cmaps.py create mode 100644 fastplotlib/graphics/_collections.py create mode 100644 fastplotlib/graphics/_jagged_array.py create mode 100644 fastplotlib/graphics/features/types.py delete mode 100644 fastplotlib/graphics/line_collection.py delete mode 100644 fastplotlib/graphics/scatter_collection.py create mode 100644 tests/test_collections.py create mode 100644 tests/test_inf_line.py diff --git a/docs/source/api/axes/Grid.rst b/docs/source/api/axes/Grid.rst index e40ecb907..2fd8c60ba 100644 --- a/docs/source/api/axes/Grid.rst +++ b/docs/source/api/axes/Grid.rst @@ -34,6 +34,7 @@ Properties Grid.minor_color Grid.minor_step Grid.minor_thickness + Grid.nonlinear_transform Grid.parent Grid.receive_shadow Grid.render_mask diff --git a/docs/source/api/axes/Grids.rst b/docs/source/api/axes/Grids.rst index d6af4d408..50f6e5fa0 100644 --- a/docs/source/api/axes/Grids.rst +++ b/docs/source/api/axes/Grids.rst @@ -25,6 +25,7 @@ Properties Grids.geometry Grids.id Grids.material + Grids.nonlinear_transform Grids.parent Grids.receive_shadow Grids.render_mask diff --git a/docs/source/api/axes/Ruler.rst b/docs/source/api/axes/Ruler.rst index e0641b821..4b0c6ca54 100644 --- a/docs/source/api/axes/Ruler.rst +++ b/docs/source/api/axes/Ruler.rst @@ -32,6 +32,7 @@ Properties Ruler.line_width Ruler.material Ruler.min_tick_distance + Ruler.nonlinear_transform Ruler.parent Ruler.points Ruler.receive_shadow diff --git a/docs/source/api/graphic_features/BufferManager.rst b/docs/source/api/graphic_features/BufferManager.rst new file mode 100644 index 000000000..d7f39c0d0 --- /dev/null +++ b/docs/source/api/graphic_features/BufferManager.rst @@ -0,0 +1,36 @@ +.. _api.BufferManager: + +BufferManager +************* + +============= +BufferManager +============= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: BufferManager_api + + BufferManager + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: BufferManager_api + + BufferManager.buffer + BufferManager.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: BufferManager_api + + BufferManager.add_event_handler + BufferManager.block_events + BufferManager.clear_event_handlers + BufferManager.remove_event_handler + BufferManager.set_value + diff --git a/docs/source/api/graphic_features/DashPattern.rst b/docs/source/api/graphic_features/DashPattern.rst new file mode 100644 index 000000000..f34f1f707 --- /dev/null +++ b/docs/source/api/graphic_features/DashPattern.rst @@ -0,0 +1,35 @@ +.. _api.DashPattern: + +DashPattern +*********** + +=========== +DashPattern +=========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: DashPattern_api + + DashPattern + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: DashPattern_api + + DashPattern.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: DashPattern_api + + DashPattern.add_event_handler + DashPattern.block_events + DashPattern.clear_event_handlers + DashPattern.remove_event_handler + DashPattern.set_value + diff --git a/docs/source/api/graphic_features/GraphicFeature.rst b/docs/source/api/graphic_features/GraphicFeature.rst new file mode 100644 index 000000000..d0bfae7bf --- /dev/null +++ b/docs/source/api/graphic_features/GraphicFeature.rst @@ -0,0 +1,35 @@ +.. _api.GraphicFeature: + +GraphicFeature +************** + +============== +GraphicFeature +============== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicFeature_api + + GraphicFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicFeature_api + + GraphicFeature.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: GraphicFeature_api + + GraphicFeature.add_event_handler + GraphicFeature.block_events + GraphicFeature.clear_event_handlers + GraphicFeature.remove_event_handler + GraphicFeature.set_value + diff --git a/docs/source/api/graphic_features/InfLineAxisData.rst b/docs/source/api/graphic_features/InfLineAxisData.rst new file mode 100644 index 000000000..babd7c554 --- /dev/null +++ b/docs/source/api/graphic_features/InfLineAxisData.rst @@ -0,0 +1,37 @@ +.. _api.InfLineAxisData: + +InfLineAxisData +*************** + +=============== +InfLineAxisData +=============== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: InfLineAxisData_api + + InfLineAxisData + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: InfLineAxisData_api + + InfLineAxisData.axis + InfLineAxisData.buffer + InfLineAxisData.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: InfLineAxisData_api + + InfLineAxisData.add_event_handler + InfLineAxisData.block_events + InfLineAxisData.clear_event_handlers + InfLineAxisData.remove_event_handler + InfLineAxisData.set_value + diff --git a/docs/source/api/graphic_features/InfLineColors.rst b/docs/source/api/graphic_features/InfLineColors.rst new file mode 100644 index 000000000..f73eb90bf --- /dev/null +++ b/docs/source/api/graphic_features/InfLineColors.rst @@ -0,0 +1,36 @@ +.. _api.InfLineColors: + +InfLineColors +************* + +============= +InfLineColors +============= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: InfLineColors_api + + InfLineColors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: InfLineColors_api + + InfLineColors.buffer + InfLineColors.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: InfLineColors_api + + InfLineColors.add_event_handler + InfLineColors.block_events + InfLineColors.clear_event_handlers + InfLineColors.remove_event_handler + InfLineColors.set_value + diff --git a/docs/source/api/graphic_features/VertexCmap.rst b/docs/source/api/graphic_features/VertexCmap.rst index 57b9d6311..038f51d7f 100644 --- a/docs/source/api/graphic_features/VertexCmap.rst +++ b/docs/source/api/graphic_features/VertexCmap.rst @@ -20,9 +20,6 @@ Properties .. autosummary:: :toctree: VertexCmap_api - VertexCmap.buffer - VertexCmap.name - VertexCmap.transform VertexCmap.value Methods diff --git a/docs/source/api/graphic_features/VertexCmapRange.rst b/docs/source/api/graphic_features/VertexCmapRange.rst new file mode 100644 index 000000000..6c99dd8d6 --- /dev/null +++ b/docs/source/api/graphic_features/VertexCmapRange.rst @@ -0,0 +1,35 @@ +.. _api.VertexCmapRange: + +VertexCmapRange +*************** + +=============== +VertexCmapRange +=============== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexCmapRange_api + + VertexCmapRange + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexCmapRange_api + + VertexCmapRange.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexCmapRange_api + + VertexCmapRange.add_event_handler + VertexCmapRange.block_events + VertexCmapRange.clear_event_handlers + VertexCmapRange.remove_event_handler + VertexCmapRange.set_value + diff --git a/docs/source/api/graphic_features/VertexCmapTransform.rst b/docs/source/api/graphic_features/VertexCmapTransform.rst new file mode 100644 index 000000000..bb54d6cde --- /dev/null +++ b/docs/source/api/graphic_features/VertexCmapTransform.rst @@ -0,0 +1,35 @@ +.. _api.VertexCmapTransform: + +VertexCmapTransform +******************* + +=================== +VertexCmapTransform +=================== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexCmapTransform_api + + VertexCmapTransform + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexCmapTransform_api + + VertexCmapTransform.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexCmapTransform_api + + VertexCmapTransform.add_event_handler + VertexCmapTransform.block_events + VertexCmapTransform.clear_event_handlers + VertexCmapTransform.remove_event_handler + VertexCmapTransform.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index b73f4f17c..2924255a1 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -9,10 +9,15 @@ Graphic Features SizeSpace VertexPositions VertexCmap + VertexCmapTransform + VertexCmapRange + InfLineAxisData + InfLineColors MeshIndices MeshCmap SurfaceData Thickness + DashPattern VertexMarkers UniformMarker UniformEdgeColor @@ -55,4 +60,6 @@ Graphic Features AlphaMode Visible Deleted + GraphicFeature + BufferManager GraphicFeatureEvent diff --git a/docs/source/api/graphics/GraphicCollection.rst b/docs/source/api/graphics/GraphicCollection.rst new file mode 100644 index 000000000..2422ee9f7 --- /dev/null +++ b/docs/source/api/graphics/GraphicCollection.rst @@ -0,0 +1,59 @@ +.. _api.GraphicCollection: + +GraphicCollection +***************** + +================= +GraphicCollection +================= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicCollection_api + + GraphicCollection + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicCollection_api + + GraphicCollection.alpha + GraphicCollection.alpha_mode + GraphicCollection.axes + GraphicCollection.block_events + GraphicCollection.block_handlers + GraphicCollection.deleted + GraphicCollection.event_handlers + GraphicCollection.graphics + GraphicCollection.imgui_right_click + GraphicCollection.name + GraphicCollection.offset + GraphicCollection.rotation + GraphicCollection.scale + GraphicCollection.supported_events + GraphicCollection.tooltip_format + GraphicCollection.visible + GraphicCollection.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: GraphicCollection_api + + GraphicCollection.add_axes + GraphicCollection.add_event_handler + GraphicCollection.add_graphic + GraphicCollection.append_imgui_right_click + GraphicCollection.clear_event_handlers + GraphicCollection.format_pick_info + GraphicCollection.map_model_to_world + GraphicCollection.map_world_to_model + GraphicCollection.remove_event_handler + GraphicCollection.remove_graphic + GraphicCollection.remove_imgui_right_click + GraphicCollection.rotate + GraphicCollection.set_imgui_right_click + diff --git a/docs/source/api/graphics/ImageCollection.rst b/docs/source/api/graphics/ImageCollection.rst new file mode 100644 index 000000000..eeb47f2c7 --- /dev/null +++ b/docs/source/api/graphics/ImageCollection.rst @@ -0,0 +1,74 @@ +.. _api.ImageCollection: + +ImageCollection +*************** + +=============== +ImageCollection +=============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCollection_api + + ImageCollection + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCollection_api + + ImageCollection.alpha + ImageCollection.alpha_mode + ImageCollection.alpha_modes + ImageCollection.alphas + ImageCollection.axes + ImageCollection.block_events + ImageCollection.block_handlers + ImageCollection.cmap + ImageCollection.cmap_interpolation + ImageCollection.data + ImageCollection.deleted + ImageCollection.event_handlers + ImageCollection.gamma + ImageCollection.graphics + ImageCollection.imgui_right_click + ImageCollection.interpolation + ImageCollection.metadatas + ImageCollection.name + ImageCollection.names + ImageCollection.offset + ImageCollection.offsets + ImageCollection.rotation + ImageCollection.rotations + ImageCollection.scale + ImageCollection.scales + ImageCollection.supported_events + ImageCollection.tooltip_format + ImageCollection.visible + ImageCollection.visibles + ImageCollection.vmax + ImageCollection.vmin + ImageCollection.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageCollection_api + + ImageCollection.add_axes + ImageCollection.add_event_handler + ImageCollection.add_graphic + ImageCollection.append_imgui_right_click + ImageCollection.clear_event_handlers + ImageCollection.format_pick_info + ImageCollection.map_model_to_world + ImageCollection.map_world_to_model + ImageCollection.remove_event_handler + ImageCollection.remove_graphic + ImageCollection.remove_imgui_right_click + ImageCollection.rotate + ImageCollection.set_imgui_right_click + diff --git a/docs/source/api/graphics/ImageGrid.rst b/docs/source/api/graphics/ImageGrid.rst new file mode 100644 index 000000000..5061b726d --- /dev/null +++ b/docs/source/api/graphics/ImageGrid.rst @@ -0,0 +1,74 @@ +.. _api.ImageGrid: + +ImageGrid +********* + +========= +ImageGrid +========= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGrid_api + + ImageGrid + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGrid_api + + ImageGrid.alpha + ImageGrid.alpha_mode + ImageGrid.alpha_modes + ImageGrid.alphas + ImageGrid.axes + ImageGrid.block_events + ImageGrid.block_handlers + ImageGrid.cmap + ImageGrid.cmap_interpolation + ImageGrid.data + ImageGrid.deleted + ImageGrid.event_handlers + ImageGrid.gamma + ImageGrid.graphics + ImageGrid.imgui_right_click + ImageGrid.interpolation + ImageGrid.metadatas + ImageGrid.name + ImageGrid.names + ImageGrid.offset + ImageGrid.offsets + ImageGrid.rotation + ImageGrid.rotations + ImageGrid.scale + ImageGrid.scales + ImageGrid.supported_events + ImageGrid.tooltip_format + ImageGrid.visible + ImageGrid.visibles + ImageGrid.vmax + ImageGrid.vmin + ImageGrid.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageGrid_api + + ImageGrid.add_axes + ImageGrid.add_event_handler + ImageGrid.add_graphic + ImageGrid.append_imgui_right_click + ImageGrid.clear_event_handlers + ImageGrid.format_pick_info + ImageGrid.map_model_to_world + ImageGrid.map_world_to_model + ImageGrid.remove_event_handler + ImageGrid.remove_graphic + ImageGrid.remove_imgui_right_click + ImageGrid.rotate + ImageGrid.set_imgui_right_click + diff --git a/docs/source/api/graphics/InfLineGraphic.rst b/docs/source/api/graphics/InfLineGraphic.rst new file mode 100644 index 000000000..3c86881a0 --- /dev/null +++ b/docs/source/api/graphics/InfLineGraphic.rst @@ -0,0 +1,72 @@ +.. _api.InfLineGraphic: + +InfLineGraphic +************** + +============== +InfLineGraphic +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: InfLineGraphic_api + + InfLineGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: InfLineGraphic_api + + InfLineGraphic.alpha + InfLineGraphic.alpha_mode + InfLineGraphic.axes + InfLineGraphic.axis + InfLineGraphic.block_events + InfLineGraphic.block_handlers + InfLineGraphic.cmap + InfLineGraphic.cmap_range + InfLineGraphic.cmap_transform + InfLineGraphic.colors + InfLineGraphic.dash_pattern + InfLineGraphic.data + InfLineGraphic.deleted + InfLineGraphic.end_is_infinite + InfLineGraphic.event_handlers + InfLineGraphic.imgui_right_click + InfLineGraphic.name + InfLineGraphic.offset + InfLineGraphic.rotation + InfLineGraphic.scale + InfLineGraphic.size_space + InfLineGraphic.start_is_infinite + InfLineGraphic.supported_events + InfLineGraphic.thickness + InfLineGraphic.thin + InfLineGraphic.tooltip_format + InfLineGraphic.visible + InfLineGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: InfLineGraphic_api + + InfLineGraphic.add_axes + InfLineGraphic.add_event_handler + InfLineGraphic.add_linear_region_selector + InfLineGraphic.add_linear_selector + InfLineGraphic.add_polygon_selector + InfLineGraphic.add_rectangle_selector + InfLineGraphic.append_imgui_right_click + InfLineGraphic.clear_event_handlers + InfLineGraphic.format_pick_info + InfLineGraphic.map_model_to_world + InfLineGraphic.map_world_to_model + InfLineGraphic.remove_event_handler + InfLineGraphic.remove_imgui_right_click + InfLineGraphic.rotate + InfLineGraphic.set_imgui_right_click + diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index c9f145d38..bd2b88d93 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -22,11 +22,16 @@ Properties LineCollection.alpha LineCollection.alpha_mode + LineCollection.alpha_modes + LineCollection.alphas LineCollection.axes LineCollection.block_events LineCollection.block_handlers LineCollection.cmap + LineCollection.cmap_range + LineCollection.cmap_transform LineCollection.colors + LineCollection.dash_pattern LineCollection.data LineCollection.deleted LineCollection.event_handlers @@ -40,6 +45,8 @@ Properties LineCollection.rotation LineCollection.rotations LineCollection.scale + LineCollection.scales + LineCollection.size_space LineCollection.supported_events LineCollection.thickness LineCollection.tooltip_format diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 4faf77c5c..50b299d71 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -26,8 +26,10 @@ Properties LineGraphic.block_events LineGraphic.block_handlers LineGraphic.cmap - LineGraphic.color_mode + LineGraphic.cmap_range + LineGraphic.cmap_transform LineGraphic.colors + LineGraphic.dash_pattern LineGraphic.data LineGraphic.deleted LineGraphic.event_handlers @@ -39,6 +41,7 @@ Properties LineGraphic.size_space LineGraphic.supported_events LineGraphic.thickness + LineGraphic.thin LineGraphic.tooltip_format LineGraphic.visible LineGraphic.world_object diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index f2a3f9958..f2e39079b 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -22,11 +22,16 @@ Properties LineStack.alpha LineStack.alpha_mode + LineStack.alpha_modes + LineStack.alphas LineStack.axes LineStack.block_events LineStack.block_handlers LineStack.cmap + LineStack.cmap_range + LineStack.cmap_transform LineStack.colors + LineStack.dash_pattern LineStack.data LineStack.deleted LineStack.event_handlers @@ -40,6 +45,11 @@ Properties LineStack.rotation LineStack.rotations LineStack.scale + LineStack.scales + LineStack.separation + LineStack.separation_axis + LineStack.size_space + LineStack.steps LineStack.supported_events LineStack.thickness LineStack.tooltip_format diff --git a/docs/source/api/graphics/ScatterCollection.rst b/docs/source/api/graphics/ScatterCollection.rst index f71116948..b057c0458 100644 --- a/docs/source/api/graphics/ScatterCollection.rst +++ b/docs/source/api/graphics/ScatterCollection.rst @@ -22,15 +22,22 @@ Properties ScatterCollection.alpha ScatterCollection.alpha_mode + ScatterCollection.alpha_modes + ScatterCollection.alphas ScatterCollection.axes ScatterCollection.block_events ScatterCollection.block_handlers ScatterCollection.cmap + ScatterCollection.cmap_range + ScatterCollection.cmap_transform ScatterCollection.colors ScatterCollection.data ScatterCollection.deleted + ScatterCollection.edge_colors + ScatterCollection.edge_width ScatterCollection.event_handlers ScatterCollection.graphics + ScatterCollection.image ScatterCollection.imgui_right_click ScatterCollection.markers ScatterCollection.metadatas @@ -38,9 +45,12 @@ Properties ScatterCollection.names ScatterCollection.offset ScatterCollection.offsets + ScatterCollection.point_rotations ScatterCollection.rotation ScatterCollection.rotations ScatterCollection.scale + ScatterCollection.scales + ScatterCollection.size_space ScatterCollection.sizes ScatterCollection.supported_events ScatterCollection.tooltip_format diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index c9f988820..5675aee22 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -26,7 +26,8 @@ Properties ScatterGraphic.block_events ScatterGraphic.block_handlers ScatterGraphic.cmap - ScatterGraphic.color_mode + ScatterGraphic.cmap_range + ScatterGraphic.cmap_transform ScatterGraphic.colors ScatterGraphic.data ScatterGraphic.deleted diff --git a/docs/source/api/graphics/ScatterStack.rst b/docs/source/api/graphics/ScatterStack.rst index ee0d7d679..1852af62d 100644 --- a/docs/source/api/graphics/ScatterStack.rst +++ b/docs/source/api/graphics/ScatterStack.rst @@ -22,15 +22,22 @@ Properties ScatterStack.alpha ScatterStack.alpha_mode + ScatterStack.alpha_modes + ScatterStack.alphas ScatterStack.axes ScatterStack.block_events ScatterStack.block_handlers ScatterStack.cmap + ScatterStack.cmap_range + ScatterStack.cmap_transform ScatterStack.colors ScatterStack.data ScatterStack.deleted + ScatterStack.edge_colors + ScatterStack.edge_width ScatterStack.event_handlers ScatterStack.graphics + ScatterStack.image ScatterStack.imgui_right_click ScatterStack.markers ScatterStack.metadatas @@ -38,12 +45,16 @@ Properties ScatterStack.names ScatterStack.offset ScatterStack.offsets + ScatterStack.point_rotations ScatterStack.rotation ScatterStack.rotations ScatterStack.scale + ScatterStack.scales ScatterStack.separation ScatterStack.separation_axis + ScatterStack.size_space ScatterStack.sizes + ScatterStack.steps ScatterStack.supported_events ScatterStack.tooltip_format ScatterStack.visible diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index 6253b68a7..8e095b098 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -6,6 +6,7 @@ Graphics Graphic LineGraphic + InfLineGraphic ScatterGraphic ImageGraphic ImageYUVGraphic @@ -15,7 +16,10 @@ Graphics SurfaceGraphic PolygonGraphic TextGraphic + GraphicCollection LineCollection LineStack ScatterCollection ScatterStack + ImageCollection + ImageGrid diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 09bd14e39..daa490b94 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -53,11 +53,15 @@ Methods :toctree: Subplot_api Subplot.add_animations + Subplot.add_collection Subplot.add_graphic Subplot.add_image + Subplot.add_image_collection + Subplot.add_image_grid Subplot.add_image_volume Subplot.add_image_yuv Subplot.add_imgui_window + Subplot.add_inf_line Subplot.add_line Subplot.add_line_collection Subplot.add_line_stack diff --git a/docs/source/api/selectors/SelectionVector.rst b/docs/source/api/selectors/SelectionVector.rst index 10acf180e..4a8183c58 100644 --- a/docs/source/api/selectors/SelectionVector.rst +++ b/docs/source/api/selectors/SelectionVector.rst @@ -29,7 +29,6 @@ Methods SelectionVector.add_selector SelectionVector.append - SelectionVector.clear - SelectionVector.clear_selectables - SelectionVector.remove + SelectionVector.clear_selectors + SelectionVector.remove_selector diff --git a/docs/source/conf.py b/docs/source/conf.py index 0ffecdcc3..1871ecadd 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -56,6 +56,7 @@ "subsection_order": ExplicitOrder( [ "../../examples/image", + "../../examples/image_collection", "../../examples/image_volume", "../../examples/heatmap", # "../../examples/image_widget", diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py index 5ad6dbb04..37abb1acb 100644 --- a/docs/source/generate_api.py +++ b/docs/source/generate_api.py @@ -507,6 +507,10 @@ def write_table(name, feature_cls): if graphic_cls is graphics.Graphic: # skip Graphic base class continue + if issubclass(graphic_cls, graphics.GraphicCollection): + # a collection exposes the features of its graphics through accessors, which do + # not have an event info spec + continue f.write(f"{graphic_cls.__name__}\n") f.write("-" * len(graphic_cls.__name__) + "\n\n") if hasattr(graphic_cls, "_features"): # some selectors like Highlight etc. don't have "graphic features" diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index c55c71722..34599cf39 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -50,24 +50,33 @@ cmap **event info dict** -+----------+-------+--------------------------------+ -| dict key | type | description | -+==========+=======+================================+ -| key | slice | key at cmap colors were sliced | -+----------+-------+--------------------------------+ -| value | str | new cmap to set at given slice | -+----------+-------+--------------------------------+ ++----------+---------------+--------------+ +| dict key | type | description | ++==========+===============+==============+ +| value | cmap.Colormap | new colormap | ++----------+---------------+--------------+ -thickness -^^^^^^^^^ +cmap_transform +^^^^^^^^^^^^^^ **event info dict** -+----------+-------+---------------------+ -| dict key | type | description | -+==========+=======+=====================+ -| value | float | new thickness value | -+----------+-------+---------------------+ ++----------+------------+--------------------+ +| dict key | type | description | ++==========+============+====================+ +| value | np.ndarray | colormap transform | ++----------+------------+--------------------+ + +cmap_range +^^^^^^^^^^ + +**event info dict** + ++----------+---------------------+-------------+ +| dict key | type | description | ++==========+=====================+=============+ +| value | tuple[float, float] | new range | ++----------+---------------------+-------------+ size_space ^^^^^^^^^^ @@ -168,7 +177,29 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -ScatterGraphic +thickness +^^^^^^^^^ + +**event info dict** + ++----------+-------+---------------------+ +| dict key | type | description | ++==========+=======+=====================+ +| value | float | new thickness value | ++----------+-------+---------------------+ + +dash_pattern +^^^^^^^^^^^^ + +**event info dict** + ++----------+-------------+------------------+ +| dict key | type | description | ++==========+=============+==================+ +| value | str | tuple | new dash pattern | ++----------+-------------+------------------+ + +InfLineGraphic -------------- data @@ -184,30 +215,6 @@ data | value | int | float | array-like | new data values for points that were changed | +----------+----------------------------------------------+--------------------------------------------------------+ -sizes -^^^^^ - -**event info dict** - -+----------+----------------------------------------------+----------------------------------------------+ -| dict key | type | description | -+==========+==============================================+==============================================+ -| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced | -+----------+----------------------------------------------+----------------------------------------------+ -| value | int | float | array-like | new size values for points that were changed | -+----------+----------------------------------------------+----------------------------------------------+ - -sizes -^^^^^ - -**event info dict** - -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new size value | -+----------+-------+----------------+ - colors ^^^^^^ @@ -239,87 +246,33 @@ cmap **event info dict** -+----------+-------+--------------------------------+ -| dict key | type | description | -+==========+=======+================================+ -| key | slice | key at cmap colors were sliced | -+----------+-------+--------------------------------+ -| value | str | new cmap to set at given slice | -+----------+-------+--------------------------------+ - -markers -^^^^^^^ - -**event info dict** - -+----------+----------------------------------------------+------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which markers were indexed/sliced | -+----------+----------------------------------------------+------------------------------------------------+ -| value | str | np.ndarray[str] | new marker values for points that were changed | -+----------+----------------------------------------------+------------------------------------------------+ - -markers -^^^^^^^ - -**event info dict** - -+----------+------------+------------------+ -| dict key | type | description | -+==========+============+==================+ -| value | str | None | new marker value | -+----------+------------+------------------+ - -edge_colors -^^^^^^^^^^^ - -**event info dict** - -+----------+--------------------------------------------------+----------------+ -| dict key | type | description | -+==========+==================================================+================+ -| value | str | np.ndarray | pygfx.Color | Sequence[float] | new edge_color | -+----------+--------------------------------------------------+----------------+ ++----------+---------------+--------------+ +| dict key | type | description | ++==========+===============+==============+ +| value | cmap.Colormap | new colormap | ++----------+---------------+--------------+ -edge_colors -^^^^^^^^^^^ +cmap_transform +^^^^^^^^^^^^^^ **event info dict** -+------------+--------------------------------------+------------------------------------------------------+ -| dict key | type | description | -+============+======================================+======================================================+ -| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | -+------------+--------------------------------------+------------------------------------------------------+ -| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | -+------------+--------------------------------------+------------------------------------------------------+ -| user_value | str or array-like | user input value that was parsed into the RGBA array | -+------------+--------------------------------------+------------------------------------------------------+ ++----------+------------+--------------------+ +| dict key | type | description | ++==========+============+====================+ +| value | np.ndarray | colormap transform | ++----------+------------+--------------------+ -edge_width +cmap_range ^^^^^^^^^^ **event info dict** -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new edge_width | -+----------+-------+----------------+ - -image -^^^^^ - -**event info dict** - -+----------+--------------------------------------+--------------------------------------------------+ -| dict key | type | description | -+==========+======================================+==================================================+ -| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | -+----------+--------------------------------------+--------------------------------------------------+ -| value | np.ndarray | float | new data values | -+----------+--------------------------------------+--------------------------------------------------+ ++----------+---------------------+-------------+ +| dict key | type | description | ++==========+=====================+=============+ +| value | tuple[float, float] | new range | ++----------+---------------------+-------------+ size_space ^^^^^^^^^^ @@ -332,30 +285,6 @@ size_space | value | str | 'screen' | 'world' | 'model' | +----------+------+------------------------------+ -point_rotations -^^^^^^^^^^^^^^^ - -**event info dict** - -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new edge_width | -+----------+-------+----------------+ - -point_rotations -^^^^^^^^^^^^^^^ - -**event info dict** - -+----------+----------------------------------------------+--------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+==================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which point rotations were indexed/sliced | -+----------+----------------------------------------------+--------------------------------------------------+ -| value | int | float | array-like | new rotation values for points that were changed | -+----------+----------------------------------------------+--------------------------------------------------+ - name ^^^^ @@ -444,87 +373,113 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -ImageGraphic ------------- +thickness +^^^^^^^^^ + +**event info dict** + ++----------+-------+---------------------+ +| dict key | type | description | ++==========+=======+=====================+ +| value | float | new thickness value | ++----------+-------+---------------------+ + +dash_pattern +^^^^^^^^^^^^ + +**event info dict** + ++----------+-------------+------------------+ +| dict key | type | description | ++==========+=============+==================+ +| value | str | tuple | new dash pattern | ++----------+-------------+------------------+ + +ScatterGraphic +-------------- data ^^^^ **event info dict** -+----------+--------------------------------------+--------------------------------------------------+ -| dict key | type | description | -+==========+======================================+==================================================+ -| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | -+----------+--------------------------------------+--------------------------------------------------+ -| value | np.ndarray | float | new data values | -+----------+--------------------------------------+--------------------------------------------------+ ++----------+----------------------------------------------+--------------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+========================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------------+ +| value | int | float | array-like | new data values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------------+ -cmap -^^^^ +colors +^^^^^^ **event info dict** -+----------+------+---------------+ -| dict key | type | description | -+==========+======+===============+ -| value | str | new cmap name | -+----------+------+---------------+ ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ -gamma -^^^^^ +colors +^^^^^^ **event info dict** -+----------+-------+-----------------+ -| dict key | type | description | -+==========+=======+=================+ -| value | float | new gamma value | -+----------+-------+-----------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ -vmin +cmap ^^^^ **event info dict** -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new vmin value | -+----------+-------+----------------+ ++----------+---------------+--------------+ +| dict key | type | description | ++==========+===============+==============+ +| value | cmap.Colormap | new colormap | ++----------+---------------+--------------+ -vmax -^^^^ +cmap_transform +^^^^^^^^^^^^^^ **event info dict** -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new vmax value | -+----------+-------+----------------+ ++----------+------------+--------------------+ +| dict key | type | description | ++==========+============+====================+ +| value | np.ndarray | colormap transform | ++----------+------------+--------------------+ -interpolation -^^^^^^^^^^^^^ +cmap_range +^^^^^^^^^^ **event info dict** -+----------+------+--------------------------------------------+ -| dict key | type | description | -+==========+======+============================================+ -| value | str | new interpolation method, nearest | linear | -+----------+------+--------------------------------------------+ ++----------+---------------------+-------------+ +| dict key | type | description | ++==========+=====================+=============+ +| value | tuple[float, float] | new range | ++----------+---------------------+-------------+ -cmap_interpolation -^^^^^^^^^^^^^^^^^^ +size_space +^^^^^^^^^^ **event info dict** -+----------+------+------------------------------------------------+ -| dict key | type | description | -+==========+======+================================================+ -| value | str | new cmap interpolatio method, nearest | linear | -+----------+------+------------------------------------------------+ ++----------+------+------------------------------+ +| dict key | type | description | ++==========+======+==============================+ +| value | str | 'screen' | 'world' | 'model' | ++----------+------+------------------------------+ name ^^^^ @@ -614,22 +569,155 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -ImageYUVGraphic ---------------- - -data -^^^^ +sizes +^^^^^ **event info dict** -+----------+--------------------------------------+--------------------------------------------------+ -| dict key | type | description | -+==========+======================================+==================================================+ -| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | -+----------+--------------------------------------+--------------------------------------------------+ ++----------+----------------------------------------------+----------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==============================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced | ++----------+----------------------------------------------+----------------------------------------------+ +| value | int | float | array-like | new size values for points that were changed | ++----------+----------------------------------------------+----------------------------------------------+ + +sizes +^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new size value | ++----------+-------+----------------+ + +markers +^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which markers were indexed/sliced | ++----------+----------------------------------------------+------------------------------------------------+ +| value | str | np.ndarray[str] | new marker values for points that were changed | ++----------+----------------------------------------------+------------------------------------------------+ + +markers +^^^^^^^ + +**event info dict** + ++----------+------------+------------------+ +| dict key | type | description | ++==========+============+==================+ +| value | str | None | new marker value | ++----------+------------+------------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+----------------+ +| dict key | type | description | ++==========+==================================================+================+ +| value | str | np.ndarray | pygfx.Color | Sequence[float] | new edge_color | ++----------+--------------------------------------------------+----------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +edge_width +^^^^^^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new edge_width | ++----------+-------+----------------+ + +image +^^^^^ + +**event info dict** + ++----------+--------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+======================================+==================================================+ +| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | ++----------+--------------------------------------+--------------------------------------------------+ +| value | np.ndarray | float | new data values | ++----------+--------------------------------------+--------------------------------------------------+ + +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+-------+--------------------+ +| dict key | type | description | ++==========+=======+====================+ +| value | float | new rotation value | ++----------+-------+--------------------+ + +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point rotations were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------+ +| value | int | float | array-like | new rotation values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------+ + +ImageGraphic +------------ + +data +^^^^ + +**event info dict** + ++----------+--------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+======================================+==================================================+ +| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | ++----------+--------------------------------------+--------------------------------------------------+ | value | np.ndarray | float | new data values | +----------+--------------------------------------+--------------------------------------------------+ +cmap +^^^^ + +**event info dict** + ++----------+------+---------------+ +| dict key | type | description | ++==========+======+===============+ +| value | str | new cmap name | ++----------+------+---------------+ + gamma ^^^^^ @@ -674,6 +762,17 @@ interpolation | value | str | new interpolation method, nearest | linear | +----------+------+--------------------------------------------+ +cmap_interpolation +^^^^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+------+------------------------------------------------+ +| dict key | type | description | ++==========+======+================================================+ +| value | str | new cmap interpolatio method, nearest | linear | ++----------+------+------------------------------------------------+ + name ^^^^ @@ -762,8 +861,8 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -ImageVolumeGraphic ------------------- +ImageYUVGraphic +--------------- data ^^^^ @@ -778,17 +877,6 @@ data | value | np.ndarray | float | new data values | +----------+--------------------------------------+--------------------------------------------------+ -cmap -^^^^ - -**event info dict** - -+----------+------+---------------+ -| dict key | type | description | -+==========+======+===============+ -| value | str | new cmap name | -+----------+------+---------------+ - gamma ^^^^^ @@ -833,94 +921,6 @@ interpolation | value | str | new interpolation method, nearest | linear | +----------+------+--------------------------------------------+ -cmap_interpolation -^^^^^^^^^^^^^^^^^^ - -**event info dict** - -+----------+------+------------------------------------------------+ -| dict key | type | description | -+==========+======+================================================+ -| value | str | new cmap interpolatio method, nearest | linear | -+----------+------+------------------------------------------------+ - -mode -^^^^ - -**event info dict** - -+----------+------+-----------------------------------------+ -| dict key | type | description | -+==========+======+=========================================+ -| value | str | volume rendering mode that has been set | -+----------+------+-----------------------------------------+ - -threshold -^^^^^^^^^ - -**event info dict** - -+----------+-------+--------------------------+ -| dict key | type | description | -+==========+=======+==========================+ -| value | float | new isosurface threshold | -+----------+-------+--------------------------+ - -step_size -^^^^^^^^^ - -**event info dict** - -+----------+-------+--------------------------+ -| dict key | type | description | -+==========+=======+==========================+ -| value | float | new isosurface step_size | -+----------+-------+--------------------------+ - -substep_size -^^^^^^^^^^^^ - -**event info dict** - -+----------+-------+--------------------------+ -| dict key | type | description | -+==========+=======+==========================+ -| value | float | new isosurface step_size | -+----------+-------+--------------------------+ - -emissive -^^^^^^^^ - -**event info dict** - -+----------+-------------+-------------------------------+ -| dict key | type | description | -+==========+=============+===============================+ -| value | pygfx.Color | new isosurface emissive color | -+----------+-------------+-------------------------------+ - -shininess -^^^^^^^^^ - -**event info dict** - -+----------+------+--------------------------+ -| dict key | type | description | -+==========+======+==========================+ -| value | int | new isosurface shininess | -+----------+------+--------------------------+ - -plane -^^^^^ - -**event info dict** - -+----------+-----------------------------------+-----------------+ -| dict key | type | description | -+==========+===================================+=================+ -| value | tuple[float, float, float, float] | new plane slice | -+----------+-----------------------------------+-----------------+ - name ^^^^ @@ -1009,11 +1009,258 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -VectorsGraphic --------------- +ImageVolumeGraphic +------------------ -positions -^^^^^^^^^ +data +^^^^ + +**event info dict** + ++----------+--------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+======================================+==================================================+ +| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | ++----------+--------------------------------------+--------------------------------------------------+ +| value | np.ndarray | float | new data values | ++----------+--------------------------------------+--------------------------------------------------+ + +cmap +^^^^ + +**event info dict** + ++----------+------+---------------+ +| dict key | type | description | ++==========+======+===============+ +| value | str | new cmap name | ++----------+------+---------------+ + +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + +vmin +^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new vmin value | ++----------+-------+----------------+ + +vmax +^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new vmax value | ++----------+-------+----------------+ + +interpolation +^^^^^^^^^^^^^ + +**event info dict** + ++----------+------+--------------------------------------------+ +| dict key | type | description | ++==========+======+============================================+ +| value | str | new interpolation method, nearest | linear | ++----------+------+--------------------------------------------+ + +cmap_interpolation +^^^^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+------+------------------------------------------------+ +| dict key | type | description | ++==========+======+================================================+ +| value | str | new cmap interpolatio method, nearest | linear | ++----------+------+------------------------------------------------+ + +mode +^^^^ + +**event info dict** + ++----------+------+-----------------------------------------+ +| dict key | type | description | ++==========+======+=========================================+ +| value | str | volume rendering mode that has been set | ++----------+------+-----------------------------------------+ + +threshold +^^^^^^^^^ + +**event info dict** + ++----------+-------+--------------------------+ +| dict key | type | description | ++==========+=======+==========================+ +| value | float | new isosurface threshold | ++----------+-------+--------------------------+ + +step_size +^^^^^^^^^ + +**event info dict** + ++----------+-------+--------------------------+ +| dict key | type | description | ++==========+=======+==========================+ +| value | float | new isosurface step_size | ++----------+-------+--------------------------+ + +substep_size +^^^^^^^^^^^^ + +**event info dict** + ++----------+-------+--------------------------+ +| dict key | type | description | ++==========+=======+==========================+ +| value | float | new isosurface step_size | ++----------+-------+--------------------------+ + +emissive +^^^^^^^^ + +**event info dict** + ++----------+-------------+-------------------------------+ +| dict key | type | description | ++==========+=============+===============================+ +| value | pygfx.Color | new isosurface emissive color | ++----------+-------------+-------------------------------+ + +shininess +^^^^^^^^^ + +**event info dict** + ++----------+------+--------------------------+ +| dict key | type | description | ++==========+======+==========================+ +| value | int | new isosurface shininess | ++----------+------+--------------------------+ + +plane +^^^^^ + +**event info dict** + ++----------+-----------------------------------+-----------------+ +| dict key | type | description | ++==========+===================================+=================+ +| value | tuple[float, float, float, float] | new plane slice | ++----------+-----------------------------------+-----------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +VectorsGraphic +-------------- + +positions +^^^^^^^^^ **event info dict** @@ -1700,888 +1947,6 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -LineCollection --------------- - -data -^^^^ - -**event info dict** - -+----------+----------------------------------------------+--------------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+========================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | -+----------+----------------------------------------------+--------------------------------------------------------+ -| value | int | float | array-like | new data values for points that were changed | -+----------+----------------------------------------------+--------------------------------------------------------+ - -colors -^^^^^^ - -**event info dict** - -+------------+--------------------------------------+------------------------------------------------------+ -| dict key | type | description | -+============+======================================+======================================================+ -| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | -+------------+--------------------------------------+------------------------------------------------------+ -| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | -+------------+--------------------------------------+------------------------------------------------------+ -| user_value | str or array-like | user input value that was parsed into the RGBA array | -+------------+--------------------------------------+------------------------------------------------------+ - -colors -^^^^^^ - -**event info dict** - -+----------+--------------------------------------------------+-----------------+ -| dict key | type | description | -+==========+==================================================+=================+ -| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | -+----------+--------------------------------------------------+-----------------+ - -cmap -^^^^ - -**event info dict** - -+----------+-------+--------------------------------+ -| dict key | type | description | -+==========+=======+================================+ -| key | slice | key at cmap colors were sliced | -+----------+-------+--------------------------------+ -| value | str | new cmap to set at given slice | -+----------+-------+--------------------------------+ - -thickness -^^^^^^^^^ - -**event info dict** - -+----------+-------+---------------------+ -| dict key | type | description | -+==========+=======+=====================+ -| value | float | new thickness value | -+----------+-------+---------------------+ - -size_space -^^^^^^^^^^ - -**event info dict** - -+----------+------+------------------------------+ -| dict key | type | description | -+==========+======+==============================+ -| value | str | 'screen' | 'world' | 'model' | -+----------+------+------------------------------+ - -name -^^^^ - -**event info dict** - -+----------+------+--------------------+ -| dict key | type | description | -+==========+======+====================+ -| value | str | user provided name | -+----------+------+--------------------+ - -offset -^^^^^^ - -**event info dict** - -+----------+---------------------------------+----------------------+ -| dict key | type | description | -+==========+=================================+======================+ -| value | np.ndarray[float, float, float] | new offset (x, y, z) | -+----------+---------------------------------+----------------------+ - -rotation -^^^^^^^^ - -**event info dict** - -+----------+----------------------------------------+-------------------------+ -| dict key | type | description | -+==========+========================================+=========================+ -| value | np.ndarray[float, float, float, float] | new rotation quaternion | -+----------+----------------------------------------+-------------------------+ - -scale -^^^^^ - -**event info dict** - -+----------+----------------------------------------+-------------+ -| dict key | type | description | -+==========+========================================+=============+ -| value | np.ndarray[float, float, float, float] | new scale | -+----------+----------------------------------------+-------------+ - -alpha -^^^^^ - -**event info dict** - -+----------+-------+-----------------+ -| dict key | type | description | -+==========+=======+=================+ -| value | float | new alpha value | -+----------+-------+-----------------+ - -alpha_mode -^^^^^^^^^^ - -**event info dict** - -+----------+------+----------------+ -| dict key | type | description | -+==========+======+================+ -| value | str | new alpha mode | -+----------+------+----------------+ - -visible -^^^^^^^ - -**event info dict** - -+----------+------+---------------------+ -| dict key | type | description | -+==========+======+=====================+ -| value | bool | new visibility bool | -+----------+------+---------------------+ - -deleted -^^^^^^^ - -**event info dict** - -+----------+------+-------------------------------+ -| dict key | type | description | -+==========+======+===============================+ -| value | bool | True when graphic was deleted | -+----------+------+-------------------------------+ - -LineStack ---------- - -data -^^^^ - -**event info dict** - -+----------+----------------------------------------------+--------------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+========================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | -+----------+----------------------------------------------+--------------------------------------------------------+ -| value | int | float | array-like | new data values for points that were changed | -+----------+----------------------------------------------+--------------------------------------------------------+ - -colors -^^^^^^ - -**event info dict** - -+------------+--------------------------------------+------------------------------------------------------+ -| dict key | type | description | -+============+======================================+======================================================+ -| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | -+------------+--------------------------------------+------------------------------------------------------+ -| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | -+------------+--------------------------------------+------------------------------------------------------+ -| user_value | str or array-like | user input value that was parsed into the RGBA array | -+------------+--------------------------------------+------------------------------------------------------+ - -colors -^^^^^^ - -**event info dict** - -+----------+--------------------------------------------------+-----------------+ -| dict key | type | description | -+==========+==================================================+=================+ -| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | -+----------+--------------------------------------------------+-----------------+ - -cmap -^^^^ - -**event info dict** - -+----------+-------+--------------------------------+ -| dict key | type | description | -+==========+=======+================================+ -| key | slice | key at cmap colors were sliced | -+----------+-------+--------------------------------+ -| value | str | new cmap to set at given slice | -+----------+-------+--------------------------------+ - -thickness -^^^^^^^^^ - -**event info dict** - -+----------+-------+---------------------+ -| dict key | type | description | -+==========+=======+=====================+ -| value | float | new thickness value | -+----------+-------+---------------------+ - -size_space -^^^^^^^^^^ - -**event info dict** - -+----------+------+------------------------------+ -| dict key | type | description | -+==========+======+==============================+ -| value | str | 'screen' | 'world' | 'model' | -+----------+------+------------------------------+ - -name -^^^^ - -**event info dict** - -+----------+------+--------------------+ -| dict key | type | description | -+==========+======+====================+ -| value | str | user provided name | -+----------+------+--------------------+ - -offset -^^^^^^ - -**event info dict** - -+----------+---------------------------------+----------------------+ -| dict key | type | description | -+==========+=================================+======================+ -| value | np.ndarray[float, float, float] | new offset (x, y, z) | -+----------+---------------------------------+----------------------+ - -rotation -^^^^^^^^ - -**event info dict** - -+----------+----------------------------------------+-------------------------+ -| dict key | type | description | -+==========+========================================+=========================+ -| value | np.ndarray[float, float, float, float] | new rotation quaternion | -+----------+----------------------------------------+-------------------------+ - -scale -^^^^^ - -**event info dict** - -+----------+----------------------------------------+-------------+ -| dict key | type | description | -+==========+========================================+=============+ -| value | np.ndarray[float, float, float, float] | new scale | -+----------+----------------------------------------+-------------+ - -alpha -^^^^^ - -**event info dict** - -+----------+-------+-----------------+ -| dict key | type | description | -+==========+=======+=================+ -| value | float | new alpha value | -+----------+-------+-----------------+ - -alpha_mode -^^^^^^^^^^ - -**event info dict** - -+----------+------+----------------+ -| dict key | type | description | -+==========+======+================+ -| value | str | new alpha mode | -+----------+------+----------------+ - -visible -^^^^^^^ - -**event info dict** - -+----------+------+---------------------+ -| dict key | type | description | -+==========+======+=====================+ -| value | bool | new visibility bool | -+----------+------+---------------------+ - -deleted -^^^^^^^ - -**event info dict** - -+----------+------+-------------------------------+ -| dict key | type | description | -+==========+======+===============================+ -| value | bool | True when graphic was deleted | -+----------+------+-------------------------------+ - -ScatterCollection ------------------ - -data -^^^^ - -**event info dict** - -+----------+----------------------------------------------+--------------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+========================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | -+----------+----------------------------------------------+--------------------------------------------------------+ -| value | int | float | array-like | new data values for points that were changed | -+----------+----------------------------------------------+--------------------------------------------------------+ - -sizes -^^^^^ - -**event info dict** - -+----------+----------------------------------------------+----------------------------------------------+ -| dict key | type | description | -+==========+==============================================+==============================================+ -| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced | -+----------+----------------------------------------------+----------------------------------------------+ -| value | int | float | array-like | new size values for points that were changed | -+----------+----------------------------------------------+----------------------------------------------+ - -sizes -^^^^^ - -**event info dict** - -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new size value | -+----------+-------+----------------+ - -colors -^^^^^^ - -**event info dict** - -+------------+--------------------------------------+------------------------------------------------------+ -| dict key | type | description | -+============+======================================+======================================================+ -| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | -+------------+--------------------------------------+------------------------------------------------------+ -| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | -+------------+--------------------------------------+------------------------------------------------------+ -| user_value | str or array-like | user input value that was parsed into the RGBA array | -+------------+--------------------------------------+------------------------------------------------------+ - -colors -^^^^^^ - -**event info dict** - -+----------+--------------------------------------------------+-----------------+ -| dict key | type | description | -+==========+==================================================+=================+ -| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | -+----------+--------------------------------------------------+-----------------+ - -cmap -^^^^ - -**event info dict** - -+----------+-------+--------------------------------+ -| dict key | type | description | -+==========+=======+================================+ -| key | slice | key at cmap colors were sliced | -+----------+-------+--------------------------------+ -| value | str | new cmap to set at given slice | -+----------+-------+--------------------------------+ - -markers -^^^^^^^ - -**event info dict** - -+----------+----------------------------------------------+------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which markers were indexed/sliced | -+----------+----------------------------------------------+------------------------------------------------+ -| value | str | np.ndarray[str] | new marker values for points that were changed | -+----------+----------------------------------------------+------------------------------------------------+ - -markers -^^^^^^^ - -**event info dict** - -+----------+------------+------------------+ -| dict key | type | description | -+==========+============+==================+ -| value | str | None | new marker value | -+----------+------------+------------------+ - -edge_colors -^^^^^^^^^^^ - -**event info dict** - -+----------+--------------------------------------------------+----------------+ -| dict key | type | description | -+==========+==================================================+================+ -| value | str | np.ndarray | pygfx.Color | Sequence[float] | new edge_color | -+----------+--------------------------------------------------+----------------+ - -edge_colors -^^^^^^^^^^^ - -**event info dict** - -+------------+--------------------------------------+------------------------------------------------------+ -| dict key | type | description | -+============+======================================+======================================================+ -| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | -+------------+--------------------------------------+------------------------------------------------------+ -| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | -+------------+--------------------------------------+------------------------------------------------------+ -| user_value | str or array-like | user input value that was parsed into the RGBA array | -+------------+--------------------------------------+------------------------------------------------------+ - -edge_width -^^^^^^^^^^ - -**event info dict** - -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new edge_width | -+----------+-------+----------------+ - -image -^^^^^ - -**event info dict** - -+----------+--------------------------------------+--------------------------------------------------+ -| dict key | type | description | -+==========+======================================+==================================================+ -| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | -+----------+--------------------------------------+--------------------------------------------------+ -| value | np.ndarray | float | new data values | -+----------+--------------------------------------+--------------------------------------------------+ - -size_space -^^^^^^^^^^ - -**event info dict** - -+----------+------+------------------------------+ -| dict key | type | description | -+==========+======+==============================+ -| value | str | 'screen' | 'world' | 'model' | -+----------+------+------------------------------+ - -point_rotations -^^^^^^^^^^^^^^^ - -**event info dict** - -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new edge_width | -+----------+-------+----------------+ - -point_rotations -^^^^^^^^^^^^^^^ - -**event info dict** - -+----------+----------------------------------------------+--------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+==================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which point rotations were indexed/sliced | -+----------+----------------------------------------------+--------------------------------------------------+ -| value | int | float | array-like | new rotation values for points that were changed | -+----------+----------------------------------------------+--------------------------------------------------+ - -name -^^^^ - -**event info dict** - -+----------+------+--------------------+ -| dict key | type | description | -+==========+======+====================+ -| value | str | user provided name | -+----------+------+--------------------+ - -offset -^^^^^^ - -**event info dict** - -+----------+---------------------------------+----------------------+ -| dict key | type | description | -+==========+=================================+======================+ -| value | np.ndarray[float, float, float] | new offset (x, y, z) | -+----------+---------------------------------+----------------------+ - -rotation -^^^^^^^^ - -**event info dict** - -+----------+----------------------------------------+-------------------------+ -| dict key | type | description | -+==========+========================================+=========================+ -| value | np.ndarray[float, float, float, float] | new rotation quaternion | -+----------+----------------------------------------+-------------------------+ - -scale -^^^^^ - -**event info dict** - -+----------+----------------------------------------+-------------+ -| dict key | type | description | -+==========+========================================+=============+ -| value | np.ndarray[float, float, float, float] | new scale | -+----------+----------------------------------------+-------------+ - -alpha -^^^^^ - -**event info dict** - -+----------+-------+-----------------+ -| dict key | type | description | -+==========+=======+=================+ -| value | float | new alpha value | -+----------+-------+-----------------+ - -alpha_mode -^^^^^^^^^^ - -**event info dict** - -+----------+------+----------------+ -| dict key | type | description | -+==========+======+================+ -| value | str | new alpha mode | -+----------+------+----------------+ - -visible -^^^^^^^ - -**event info dict** - -+----------+------+---------------------+ -| dict key | type | description | -+==========+======+=====================+ -| value | bool | new visibility bool | -+----------+------+---------------------+ - -deleted -^^^^^^^ - -**event info dict** - -+----------+------+-------------------------------+ -| dict key | type | description | -+==========+======+===============================+ -| value | bool | True when graphic was deleted | -+----------+------+-------------------------------+ - -ScatterStack ------------- - -data -^^^^ - -**event info dict** - -+----------+----------------------------------------------+--------------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+========================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | -+----------+----------------------------------------------+--------------------------------------------------------+ -| value | int | float | array-like | new data values for points that were changed | -+----------+----------------------------------------------+--------------------------------------------------------+ - -sizes -^^^^^ - -**event info dict** - -+----------+----------------------------------------------+----------------------------------------------+ -| dict key | type | description | -+==========+==============================================+==============================================+ -| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced | -+----------+----------------------------------------------+----------------------------------------------+ -| value | int | float | array-like | new size values for points that were changed | -+----------+----------------------------------------------+----------------------------------------------+ - -sizes -^^^^^ - -**event info dict** - -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new size value | -+----------+-------+----------------+ - -colors -^^^^^^ - -**event info dict** - -+------------+--------------------------------------+------------------------------------------------------+ -| dict key | type | description | -+============+======================================+======================================================+ -| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | -+------------+--------------------------------------+------------------------------------------------------+ -| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | -+------------+--------------------------------------+------------------------------------------------------+ -| user_value | str or array-like | user input value that was parsed into the RGBA array | -+------------+--------------------------------------+------------------------------------------------------+ - -colors -^^^^^^ - -**event info dict** - -+----------+--------------------------------------------------+-----------------+ -| dict key | type | description | -+==========+==================================================+=================+ -| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | -+----------+--------------------------------------------------+-----------------+ - -cmap -^^^^ - -**event info dict** - -+----------+-------+--------------------------------+ -| dict key | type | description | -+==========+=======+================================+ -| key | slice | key at cmap colors were sliced | -+----------+-------+--------------------------------+ -| value | str | new cmap to set at given slice | -+----------+-------+--------------------------------+ - -markers -^^^^^^^ - -**event info dict** - -+----------+----------------------------------------------+------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which markers were indexed/sliced | -+----------+----------------------------------------------+------------------------------------------------+ -| value | str | np.ndarray[str] | new marker values for points that were changed | -+----------+----------------------------------------------+------------------------------------------------+ - -markers -^^^^^^^ - -**event info dict** - -+----------+------------+------------------+ -| dict key | type | description | -+==========+============+==================+ -| value | str | None | new marker value | -+----------+------------+------------------+ - -edge_colors -^^^^^^^^^^^ - -**event info dict** - -+----------+--------------------------------------------------+----------------+ -| dict key | type | description | -+==========+==================================================+================+ -| value | str | np.ndarray | pygfx.Color | Sequence[float] | new edge_color | -+----------+--------------------------------------------------+----------------+ - -edge_colors -^^^^^^^^^^^ - -**event info dict** - -+------------+--------------------------------------+------------------------------------------------------+ -| dict key | type | description | -+============+======================================+======================================================+ -| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | -+------------+--------------------------------------+------------------------------------------------------+ -| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | -+------------+--------------------------------------+------------------------------------------------------+ -| user_value | str or array-like | user input value that was parsed into the RGBA array | -+------------+--------------------------------------+------------------------------------------------------+ - -edge_width -^^^^^^^^^^ - -**event info dict** - -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new edge_width | -+----------+-------+----------------+ - -image -^^^^^ - -**event info dict** - -+----------+--------------------------------------+--------------------------------------------------+ -| dict key | type | description | -+==========+======================================+==================================================+ -| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | -+----------+--------------------------------------+--------------------------------------------------+ -| value | np.ndarray | float | new data values | -+----------+--------------------------------------+--------------------------------------------------+ - -size_space -^^^^^^^^^^ - -**event info dict** - -+----------+------+------------------------------+ -| dict key | type | description | -+==========+======+==============================+ -| value | str | 'screen' | 'world' | 'model' | -+----------+------+------------------------------+ - -point_rotations -^^^^^^^^^^^^^^^ - -**event info dict** - -+----------+-------+----------------+ -| dict key | type | description | -+==========+=======+================+ -| value | float | new edge_width | -+----------+-------+----------------+ - -point_rotations -^^^^^^^^^^^^^^^ - -**event info dict** - -+----------+----------------------------------------------+--------------------------------------------------+ -| dict key | type | description | -+==========+==============================================+==================================================+ -| key | slice, index (int) or numpy-like fancy index | key at which point rotations were indexed/sliced | -+----------+----------------------------------------------+--------------------------------------------------+ -| value | int | float | array-like | new rotation values for points that were changed | -+----------+----------------------------------------------+--------------------------------------------------+ - -name -^^^^ - -**event info dict** - -+----------+------+--------------------+ -| dict key | type | description | -+==========+======+====================+ -| value | str | user provided name | -+----------+------+--------------------+ - -offset -^^^^^^ - -**event info dict** - -+----------+---------------------------------+----------------------+ -| dict key | type | description | -+==========+=================================+======================+ -| value | np.ndarray[float, float, float] | new offset (x, y, z) | -+----------+---------------------------------+----------------------+ - -rotation -^^^^^^^^ - -**event info dict** - -+----------+----------------------------------------+-------------------------+ -| dict key | type | description | -+==========+========================================+=========================+ -| value | np.ndarray[float, float, float, float] | new rotation quaternion | -+----------+----------------------------------------+-------------------------+ - -scale -^^^^^ - -**event info dict** - -+----------+----------------------------------------+-------------+ -| dict key | type | description | -+==========+========================================+=============+ -| value | np.ndarray[float, float, float, float] | new scale | -+----------+----------------------------------------+-------------+ - -alpha -^^^^^ - -**event info dict** - -+----------+-------+-----------------+ -| dict key | type | description | -+==========+=======+=================+ -| value | float | new alpha value | -+----------+-------+-----------------+ - -alpha_mode -^^^^^^^^^^ - -**event info dict** - -+----------+------+----------------+ -| dict key | type | description | -+==========+======+================+ -| value | str | new alpha mode | -+----------+------+----------------+ - -visible -^^^^^^^ - -**event info dict** - -+----------+------+---------------------+ -| dict key | type | description | -+==========+======+=====================+ -| value | bool | new visibility bool | -+----------+------+---------------------+ - -deleted -^^^^^^^ - -**event info dict** - -+----------+------+-------------------------------+ -| dict key | type | description | -+==========+======+===============================+ -| value | bool | True when graphic was deleted | -+----------+------+-------------------------------+ - LinearSelector -------------- diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index c857ebb9c..8f7b8d3bf 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -143,6 +143,12 @@ any of these properties. +--------------+--------------------------------------------------------------------------------------------------------------+ | rotation | Graphic rotation quaternion | +--------------+--------------------------------------------------------------------------------------------------------------+ +| scale | Scale factors of the graphic, [x, y, z] | ++--------------+--------------------------------------------------------------------------------------------------------------+ +| alpha | Opacity of the graphic | ++--------------+--------------------------------------------------------------------------------------------------------------+ +| alpha_mode | How the renderer handles the alpha, ex: "blend", "dither", "add" | ++--------------+--------------------------------------------------------------------------------------------------------------+ | visible | Access or change the visibility | +--------------+--------------------------------------------------------------------------------------------------------------+ | deleted | Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted | @@ -152,45 +158,81 @@ any of these properties. (a) ``ImageGraphic`` - +------------------------+---------------------------------------------------+ - | Feature Name | Description | - +========================+===================================================+ - | data | Underlying image data | - +------------------------+---------------------------------------------------+ - | vmin | Lower contrast limit of an image | - +------------------------+---------------------------------------------------+ - | vmax | Upper contrast limit of an image | - +------------------------+---------------------------------------------------+ - | cmap | Colormap for a grayscale image, ignored if RGB(A) | - +------------------------+---------------------------------------------------+ - - (b) ``LineGraphic``, ``LineCollection``, ``LineStack`` - - +--------------+--------------------------------+ - | Feature Name | Description | - +==============+================================+ - | data | underlying data of the line(s) | - +--------------+--------------------------------+ - | colors | colors of the line(s) | - +--------------+--------------------------------+ - | cmap | colormap of the line(s) | - +--------------+--------------------------------+ - | thickness | thickness of the line(s) | - +--------------+--------------------------------+ + +--------------------+------------------------------------------------------------+ + | Feature Name | Description | + +====================+============================================================+ + | data | Underlying image data | + +--------------------+------------------------------------------------------------+ + | vmin | Lower contrast limit of an image | + +--------------------+------------------------------------------------------------+ + | vmax | Upper contrast limit of an image | + +--------------------+------------------------------------------------------------+ + | gamma | Gamma correction applied to the value scaled by vmin, vmax | + +--------------------+------------------------------------------------------------+ + | cmap | Colormap for a grayscale image, ignored if RGB(A) | + +--------------------+------------------------------------------------------------+ + | interpolation | Data interpolation method, "nearest" or "linear" | + +--------------------+------------------------------------------------------------+ + | cmap_interpolation | Colormap interpolation method, "nearest" or "linear" | + +--------------------+------------------------------------------------------------+ + + (b) ``LineGraphic`` + + +----------------+---------------------------------------------------------------+ + | Feature Name | Description | + +================+===============================================================+ + | data | underlying data of the line | + +----------------+---------------------------------------------------------------+ + | colors | color(s) of the line | + +----------------+---------------------------------------------------------------+ + | cmap | colormap of the line, overrides colors | + +----------------+---------------------------------------------------------------+ + | cmap_transform | values used to map the colors from the cmap | + +----------------+---------------------------------------------------------------+ + | cmap_range | the (min, max) of the cmap_transform mapped onto the colormap | + +----------------+---------------------------------------------------------------+ + | thickness | thickness of the line | + +----------------+---------------------------------------------------------------+ + | dash_pattern | dash pattern of the line | + +----------------+---------------------------------------------------------------+ + | size_space | coordinate space in which the thickness is expressed | + +----------------+---------------------------------------------------------------+ (c) ``ScatterGraphic`` - +--------------+---------------------------------------+ - | Feature Name | Description | - +==============+=======================================+ - | data | underlying data of the scatter points | - +--------------+---------------------------------------+ - | colors | colors of the scatter points | - +--------------+---------------------------------------+ - | cmap | colormap of the scatter points | - +--------------+---------------------------------------+ - | sizes | size of the scatter points | - +--------------+---------------------------------------+ + +-----------------+----------------------------------------------------------------+ + | Feature Name | Description | + +=================+================================================================+ + | data | underlying data of the scatter points | + +-----------------+----------------------------------------------------------------+ + | colors | color(s) of the scatter points | + +-----------------+----------------------------------------------------------------+ + | cmap | colormap of the scatter points, overrides colors | + +-----------------+----------------------------------------------------------------+ + | cmap_transform | values used to map the colors from the cmap | + +-----------------+----------------------------------------------------------------+ + | cmap_range | the (min, max) of the cmap_transform mapped onto the colormap | + +-----------------+----------------------------------------------------------------+ + | sizes | size(s) of the scatter points | + +-----------------+----------------------------------------------------------------+ + | markers | marker shape(s), when mode is "markers" | + +-----------------+----------------------------------------------------------------+ + | edge_colors | marker edge color(s), when mode is "markers" | + +-----------------+----------------------------------------------------------------+ + | edge_width | width of the marker edges, when mode is "markers" | + +-----------------+----------------------------------------------------------------+ + | point_rotations | rotation of the points in radians, None follows the data curve | + +-----------------+----------------------------------------------------------------+ + | image | image rendered at each point, when mode is "image" | + +-----------------+----------------------------------------------------------------+ + | size_space | coordinate space in which the sizes are expressed | + +-----------------+----------------------------------------------------------------+ + + For ``colors``, ``sizes``, ``markers``, ``edge_colors``, and ``point_rotations`` the buffer mode is + determined by the value: pass one value to use a uniform buffer, or pass a sequence with one + value per datapoint. Setting a sequence (e.g. an array array) on a property that currently holds one + value switches it to per-datapoint; setting one value on a per-datapoint property broadcasts + it and stays per-datapoint. (d) ``TextGraphic`` @@ -208,6 +250,113 @@ any of these properties. | outline_thickness | thickness of the text | +-------------------+---------------------------+ + + (e) ``InfLineGraphic`` + + +----------------+---------------------------------------------------------------------------------------------+ + | Feature Name | Description | + +================+=============================================================================================+ + | data | position of each infinite line along ``axis``, or the segment endpoints if ``axis`` is None | + +----------------+---------------------------------------------------------------------------------------------+ + | colors | color(s) of the lines, one color per line or a uniform color for all lines | + +----------------+---------------------------------------------------------------------------------------------+ + | cmap | colormap across lines, overrides colors | + +----------------+---------------------------------------------------------------------------------------------+ + | cmap_transform | values used to map the colors from the cmap | + +----------------+---------------------------------------------------------------------------------------------+ + | cmap_range | the (min, max) of the cmap_transform mapped onto the colormap | + +----------------+---------------------------------------------------------------------------------------------+ + | thickness | thickness of the lines | + +----------------+---------------------------------------------------------------------------------------------+ + | dash_pattern | dash pattern of the lines | + +----------------+---------------------------------------------------------------------------------------------+ + | size_space | coordinate space in which the thickness is expressed | + +----------------+---------------------------------------------------------------------------------------------+ + + (f) ``MeshGraphic`` + + +--------------+-----------------------------------------------------------------------------------+ + | Feature Name | Description | + +==============+===================================================================================+ + | positions | 3D positions of the vertices | + +--------------+-----------------------------------------------------------------------------------+ + | indices | indices into the positions that form the triangles, every three form one triangle | + +--------------+-----------------------------------------------------------------------------------+ + | colors | a uniform color, or the per-position colors | + +--------------+-----------------------------------------------------------------------------------+ + | cmap | colormap of the mesh, overrides colors | + +--------------+-----------------------------------------------------------------------------------+ + + (g) ``SurfaceGraphic`` + + +--------------+--------------------------------------------------------+ + | Feature Name | Description | + +==============+========================================================+ + | data | a height-map, or an [m, n, 3] grid of (x, y, z) values | + +--------------+--------------------------------------------------------+ + | colors | a uniform color, or the per-position colors | + +--------------+--------------------------------------------------------+ + | cmap | colormap of the surface, overrides colors | + +--------------+--------------------------------------------------------+ + + (h) ``PolygonGraphic`` + + +--------------+------------------------------------------------+ + | Feature Name | Description | + +==============+================================================+ + | data | the polygon vertices, of shape [n_vertices, 2] | + +--------------+------------------------------------------------+ + | colors | a uniform color, or the per-position colors | + +--------------+------------------------------------------------+ + | cmap | colormap of the polygon, overrides colors | + +--------------+------------------------------------------------+ + + (i) ``VectorsGraphic`` + + +--------------+------------------------------------------------------+ + | Feature Name | Description | + +==============+======================================================+ + | positions | positions of the vectors, of shape [n, 2] or [n, 3] | + +--------------+------------------------------------------------------+ + | directions | directions of the vectors, of shape [n, 2] or [n, 3] | + +--------------+------------------------------------------------------+ + + +(3) Graphic collections + +A collection, such as a ``LineCollection``, ``LineStack``, ``ScatterCollection``, ``ImageCollection``, or +``ImageGrid``, exposes each property of the graphics it contains. Indexing a property indexes it across +the graphics, ex: ``line_collection.colors[:10] = "r"`` sets the color of the first ten lines and +``line_collection.data[5, :, 1] = ys`` sets the y-values of the sixth line. Fully numpy-style fancy slicing +is supported for properties of a graphic collection. + +A property that the collection also has itself is exposed under a plural name, so ``collection.offset`` +is the offset of the collection and ``collection.offsets`` is the offset of each graphic in it: + ++--------------+--------------------------------------------------+ +| Feature Name | Description | ++==============+==================================================+ +| names | ``name`` of each graphic in the collection | ++--------------+--------------------------------------------------+ +| offsets | ``offset`` of each graphic in the collection | ++--------------+--------------------------------------------------+ +| rotations | ``rotation`` of each graphic in the collection | ++--------------+--------------------------------------------------+ +| scales | ``scale`` of each graphic in the collection | ++--------------+--------------------------------------------------+ +| alphas | ``alpha`` of each graphic in the collection | ++--------------+--------------------------------------------------+ +| alpha_modes | ``alpha_mode`` of each graphic in the collection | ++--------------+--------------------------------------------------+ +| visibles | ``visible`` of each graphic in the collection | ++--------------+--------------------------------------------------+ +| metadatas | ``metadata`` of each graphic in the collection | ++--------------+--------------------------------------------------+ + +The graphics themselves are available as an array, ex: ``line_collection.graphics[0]``. + + + Using our example from above: once we add a ``Graphic`` to the figure, we can then begin to change its properties. :: image_graphic.vmax = 150 @@ -514,7 +663,7 @@ For example: :: xy = fig[0, 0].map_screen_to_world(ev)[:-1] # get the nearest graphic to the position - nearest = fpl.utils.get_nearest_graphics(xy, circles_graphic)[0] + nearest = fpl.get_nearest_graphics(xy, circles_graphic)[0] # change the closest graphic color to white nearest.colors = "w" diff --git a/examples/events/cmap_event.py b/examples/events/cmap_event.py index f01f06d6a..98502eb92 100644 --- a/examples/events/cmap_event.py +++ b/examples/events/cmap_event.py @@ -34,7 +34,7 @@ xs = np.linspace(0, 4 * np.pi, 100) ys = np.sin(xs) -figure["sine"].add_line(np.column_stack([xs, ys]), color_mode="vertex") +figure["sine"].add_line(np.column_stack([xs, ys]), cmap="viridis") # make a 2D gaussian cloud cloud_data = np.random.normal(0, scale=3, size=1000).reshape(500, 2) diff --git a/examples/events/drag_points.py b/examples/events/drag_points.py index 5a679a996..e0c21a301 100644 --- a/examples/events/drag_points.py +++ b/examples/events/drag_points.py @@ -26,7 +26,7 @@ line = figure[0, 0].add_line(data) # add a scatter, share the line graphic buffer! -scatter = figure[0, 0].add_scatter(data=line.data, sizes=25, colors="r") +scatter = figure[0, 0].add_scatter(data=line.data, sizes=25, colors=["r"] * len(data)) is_moving = False vertex_index = None diff --git a/examples/events/key_events.py b/examples/events/key_events.py index f8cf2f3df..4fc2fe0bd 100644 --- a/examples/events/key_events.py +++ b/examples/events/key_events.py @@ -28,12 +28,12 @@ data = iio.imread("imageio:camera.png") -iw = fpl.ImageWidget(data, figure_kwargs={"size": (700, 560)}) +figure = fpl.Figure(size=(700, 560)) -image = iw.managed_graphics[0] +image = figure[0, 0].add_image(data) -@iw.figure.renderer.add_event_handler("key_down") +@figure.renderer.add_event_handler("key_down") def handle_event(ev: pygfx.KeyboardEvent): match ev.key: # change the cmap @@ -46,13 +46,13 @@ def handle_event(ev: pygfx.KeyboardEvent): # keys to change vmin/vmax case "-": - image.vmin -= 1 + image.vmin -= 10 case "=": - image.vmin += 1 + image.vmin += 10 case "_": - image.vmax -= 1 + image.vmax -= 10 case "+": - image.vmax += 1 + image.vmax += 10 # rotate case "r": @@ -71,10 +71,7 @@ def handle_event(ev: pygfx.KeyboardEvent): image.offset = image.offset + [10, 0, 0] -iw.show() - - -figure = iw.figure # ignore, this is just so the docs gallery scraper picks up the figure +figure.show() # NOTE: fpl.loop.run() should not be used for interactive sessions diff --git a/examples/events/lines_mouse_nearest.py b/examples/events/lines_mouse_nearest.py index 8d38e9f53..a31059ead 100644 --- a/examples/events/lines_mouse_nearest.py +++ b/examples/events/lines_mouse_nearest.py @@ -45,7 +45,7 @@ def highlight_nearest(ev: pygfx.PointerEvent): # get_nearest_graphics() is a helper function # sorted the passed array or collection of graphics from nearest to furthest from the passed `pos` - nearest = fpl.utils.get_nearest_graphics(pos, line_collection)[0] + nearest = fpl.get_nearest_graphics(pos, line_collection)[0] nearest.colors = "r" diff --git a/examples/events/scatter_click.py b/examples/events/scatter_click.py index 3bf85558a..da5f001d5 100644 --- a/examples/events/scatter_click.py +++ b/examples/events/scatter_click.py @@ -2,8 +2,8 @@ Scatter click ============= -Add an event handler to click on scatter points and highlight them, i.e. change the color and size of the clicked point. -Fly around the 3D scatter using WASD keys and click on points to highlight them +Add an event handler to click on scatter points and highlight them, i.e. change the edge color and size of the +clicked point. Fly around the 3D scatter using WASD keys and click on points to highlight them. """ # test_example = false @@ -20,14 +20,16 @@ scatter = figure[0, 0].add_scatter( data, # the gaussian cloud - sizes=10, # some big points that are easy to click + sizes=np.repeat(10, len(data)), # some big points that are easy to click + edge_colors=np.zeros((len(data), 4)), # per-point edge colors that we will change + edge_width=5, cmap="viridis", cmap_transform=np.linalg.norm(data, axis=1) # color points using distance from origin ) # simple dict to restore the original scatter color and size # of the previously clicked point upon clicking a new point -old_props = {"index": None, "size": None, "color": None} +old_props = {"index": None, "size": None, "edge_colors": None} @scatter.add_event_handler("click") @@ -43,16 +45,16 @@ def highlight_point(ev: pygfx.PointerEvent): if new_index == old_index: # same point was clicked, ignore return - scatter.colors[old_index] = old_props["color"] + scatter.edge_colors[old_index] = old_props["edge_colors"] scatter.sizes[old_index] = old_props["size"] # store the current property values of this new point old_props["index"] = new_index - old_props["color"] = scatter.colors[new_index].copy() # if you do not copy you will just get a view of the array! + old_props["edge_colors"] = scatter.edge_colors[new_index].copy() # if you do not copy you will just get a view of the array! old_props["size"] = scatter.sizes[new_index] # highlight this new point - scatter.colors[new_index] = "magenta" + scatter.edge_colors[new_index] = "magenta" scatter.sizes[new_index] = 20 diff --git a/examples/events/scatter_hover.py b/examples/events/scatter_hover.py index c297223d2..312d15960 100644 --- a/examples/events/scatter_hover.py +++ b/examples/events/scatter_hover.py @@ -2,8 +2,8 @@ Scatter hover ============= -Add an event handler to hover on scatter points and highlight them, i.e. change the color and size of the clicked point. -Fly around the 3D scatter using WASD keys and click on points to highlight them. +Add an event handler to hover on scatter points and highlight them, i.e. change the edge color and size of the +clicked point. Fly around the 3D scatter using WASD keys and click on points to highlight them. There is no "hover" event, you can create a hover effect by using "pointer_move" events. """ @@ -22,40 +22,41 @@ scatter = figure[0, 0].add_scatter( data, # the gaussian cloud - sizes=10, # some big points that are easy to click + sizes=np.repeat(10, len(data)), # some big points that are easy to click + edge_colors=np.zeros((len(data), 4)), # per-point edge colors that we will change + edge_width=5, cmap="viridis", cmap_transform=np.linalg.norm(data, axis=1) # color points using distance from origin ) # simple dict to restore the original scatter color and size # of the previously clicked point upon clicking a new point -old_props = {"index": None, "size": None, "color": None} +old_props = {"index": None, "size": None, "edge_colors": None} @scatter.add_event_handler("pointer_move") def highlight_point(ev: pygfx.PointerEvent): global old_props - # the index of the point that was just entered + # the index of the point that was just clicked new_index = ev.pick_info["vertex_index"] - # if a new point has been entered, but we have not yet had - # a leave event for the previous point, then reset this old point + # restore old point's properties if old_props["index"] is not None: old_index = old_props["index"] if new_index == old_index: - # same point, ignore + # same point was clicked, ignore return - scatter.colors[old_index] = old_props["color"] + scatter.edge_colors[old_index] = old_props["edge_colors"] scatter.sizes[old_index] = old_props["size"] # store the current property values of this new point old_props["index"] = new_index - old_props["color"] = scatter.colors[new_index].copy() # if you do not copy you will just get a view of the array! + old_props["edge_colors"] = scatter.edge_colors[new_index].copy() # if you do not copy you will just get a view of the array! old_props["size"] = scatter.sizes[new_index] # highlight this new point - scatter.colors[new_index] = "magenta" + scatter.edge_colors[new_index] = "magenta" scatter.sizes[new_index] = 20 diff --git a/examples/events/scatter_hover_transforms.py b/examples/events/scatter_hover_transforms.py index f7b733109..d4dac1e10 100644 --- a/examples/events/scatter_hover_transforms.py +++ b/examples/events/scatter_hover_transforms.py @@ -22,6 +22,7 @@ import fastplotlib as fpl import pygfx +import numpy as np # get the dataset dataset = load_diabetes(scaled=False) @@ -50,7 +51,9 @@ data=X, cmap="viridis", cmap_transform=y, - sizes=3, + sizes=np.repeat(5, len(X)), + edge_colors=np.zeros((len(X), 4)), # per-point edge colors that we will change + edge_width=5, ) # append to list of scatters @@ -59,13 +62,20 @@ # add the scaled data as scatter graphics for scaler in scalers: name = scaler.__name__ - s = figure[name].add_scatter(scaler().fit_transform(X), cmap="viridis", cmap_transform=y, sizes=3) + s = figure[name].add_scatter( + scaler().fit_transform(X), + cmap="viridis", + cmap_transform=y, + sizes=np.repeat(5, len(X)), + edge_colors=np.zeros((len(X), 4)), # per-point edge colors that we will change + edge_width=5, + ) scatters.append(s) # simple dict to restore the original scatter color and size # of the previously clicked point upon clicking a new point -old_props = {"index": None, "size": None, "color": None} +old_props = {"index": None, "size": None, "edge_colors": None} def highlight_point(ev: pygfx.PointerEvent): @@ -82,19 +92,19 @@ def highlight_point(ev: pygfx.PointerEvent): # same point was clicked, ignore return for s in scatters: - s.colors[old_index] = old_props["color"] + s.edge_colors[old_index] = old_props["edge_colors"] s.sizes[old_index] = old_props["size"] # store the current property values of this new point old_props["index"] = new_index # all the scatters have the same colors and size for the corresponding index # so we can just use the first scatter's original color and size - old_props["color"] = scatters[0].colors[new_index].copy() # if you do not copy you will just get a view of the array! + old_props["edge_colors"] = scatters[0].edge_colors[new_index].copy() # if you do not copy you will just get a view of the array! old_props["size"] = scatters[0].sizes[new_index] # highlight this new point for s in scatters: - s.colors[new_index] = "magenta" + s.edge_colors[new_index] = "magenta" s.sizes[new_index] = 15 diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py index 0e89efcdc..c81a81669 100644 --- a/examples/gridplot/multigraphic_gridplot.py +++ b/examples/gridplot/multigraphic_gridplot.py @@ -91,7 +91,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: sine_waves = 10 * [sine] # add the line stack to the figure -figure["line-stack"].add_line_stack(data=sine_waves, cmap="Wistia", separation=1) +figure["line-stack"].add_line_stack(data=sine_waves, cmap="Wistia", separation=(0, 1, 0)) figure["line-stack"].auto_scale(maintain_aspect=True) @@ -106,7 +106,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: gaussian_cloud2 = np.random.multivariate_normal(mean, covariance, n_points) # add the scatter graphics to the figure -figure["scatter"].add_scatter(data=gaussian_cloud, sizes=2, cmap="jet", color_mode="vertex") +figure["scatter"].add_scatter(data=gaussian_cloud, sizes=2, cmap="jet") figure["scatter"].add_scatter(data=gaussian_cloud2, colors="r", sizes=2) figure.show() diff --git a/examples/guis/imgui_top.py b/examples/guis/imgui_top.py index 5a29534c8..0f1eba778 100644 --- a/examples/guis/imgui_top.py +++ b/examples/guis/imgui_top.py @@ -27,10 +27,10 @@ figure = fpl.Figure(size=(700, 560)) # make some scatter points at every 10th point -figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", color_mode="uniform") +figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter") # place a line above the scatter -figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", color_mode="uniform") +figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave") class ImguiExample(ImguiWindow): @@ -56,4 +56,4 @@ def update(self): # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": print(__doc__) - fpl.loop.run() \ No newline at end of file + fpl.loop.run() diff --git a/examples/image/image_cmap.py b/examples/image/image_cmap.py index 8c94c6f17..49a7cdf3a 100644 --- a/examples/image/image_cmap.py +++ b/examples/image/image_cmap.py @@ -2,7 +2,7 @@ Image Colormap ============== -Example showing simple plot creation and subsequent cmap change with Standard image from imageio. +Example showing simple plot creation and subsequent cmap change with a standard image from imageio. """ # test_example = true @@ -11,6 +11,7 @@ import imageio.v3 as iio import fastplotlib as fpl +import cmap as cmap_lib im = iio.imread("imageio:camera.png") @@ -23,6 +24,9 @@ image.cmap = "viridis" +# create your own colormap using the cmap lib +image.cmap = cmap_lib.Colormap(["orange", "purple", "green"]) + # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": diff --git a/examples/image_collection/README.rst b/examples/image_collection/README.rst new file mode 100644 index 000000000..e5be5ec69 --- /dev/null +++ b/examples/image_collection/README.rst @@ -0,0 +1,2 @@ +ImageCollection Examples +======================== diff --git a/examples/image_collection/image_collection.py b/examples/image_collection/image_collection.py new file mode 100644 index 000000000..45c9836e6 --- /dev/null +++ b/examples/image_collection/image_collection.py @@ -0,0 +1,32 @@ +""" +Image Collection +================ + +Position a collection of images individually using per-image ``offsets``. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +rng = np.random.default_rng(0) + +# a collection of random 100 x 100 images +images = [rng.random((100, 100), dtype=np.float32) for _ in range(5)] + +figure = fpl.Figure(size=(700, 560)) + +# stagger the images diagonally with one (x, y, z) offset per image +offsets = np.array([[i * 60, -i * 60, 0] for i in range(len(images))]) + +figure[0, 0].add_image_collection(images, offsets=offsets, cmap="viridis") + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_collection/image_grid.py b/examples/image_collection/image_grid.py new file mode 100644 index 000000000..a602d72f9 --- /dev/null +++ b/examples/image_collection/image_grid.py @@ -0,0 +1,30 @@ +""" +Image Grid +========== + +Arrange a collection of images in a grid using ``shape`` and ``separation``. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +rng = np.random.default_rng(0) + +# a collection of random 100 x 100 images +images = [rng.random((100, 100), dtype=np.float32) for _ in range(6)] + +figure = fpl.Figure(size=(700, 560)) + +# lay the images out in a 2 x 3 grid with a gap between rows and columns +figure[0, 0].add_image_grid(images, shape=(2, 3), separation=(10, 10), cmap="plasma") + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_toy_data.py b/examples/image_volume/image_volume_toy_data.py index 5c081542d..c89ce02e2 100644 --- a/examples/image_volume/image_volume_toy_data.py +++ b/examples/image_volume/image_volume_toy_data.py @@ -5,6 +5,9 @@ Volume rendering of toy trig data """ +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + import fastplotlib as fpl import numpy as np @@ -18,7 +21,7 @@ data = np.dstack([np.vstack([sine * i for i in range(n_rows)]).astype(np.float32) * j for j in range(z)]) -figure = fpl.Figure(cameras="3d", controller_types="orbit") +figure = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 560)) volume = figure[0, 0].add_image_volume(data) diff --git a/examples/line/inf_line.py b/examples/line/inf_line.py index 5d03eda3a..8c96c1892 100644 --- a/examples/line/inf_line.py +++ b/examples/line/inf_line.py @@ -34,8 +34,6 @@ dash_pattern="--", ) -figure[0, 0].axes.intersection = (0, 0, 0) - figure.show() diff --git a/examples/line/line_colorslice.py b/examples/line/line_colorslice.py index 264f944f3..1abdabb16 100644 --- a/examples/line/line_colorslice.py +++ b/examples/line/line_colorslice.py @@ -31,14 +31,13 @@ data=sine_data, thickness=5, colors="magenta", - color_mode="vertex", # initialize with same color across vertices, but we will change the per-vertex colors later ) -# you can also use colormaps for lines! +# per-vertex colors (same color initially) so we can slice into them below cosine = figure[0, 0].add_line( data=cosine_data, thickness=12, - cmap="autumn", + colors=["orange"] * cosine_data.shape[0], offset=(0, 3, 0) # places the graphic at a y-axis offset of 3, offsets don't affect data ) @@ -56,8 +55,8 @@ zeros = figure[0, 0].add_line( data=zeros_data, thickness=8, - colors="w", - color_mode="vertex", # initialize with same color across vertices, but we will change the per-vertex colors later + # per-vertex colors (same color initially) so we can change them individually later + colors=["w"] * xs.size, offset=(0, 10, 0) ) @@ -78,11 +77,8 @@ # boolean fancy indexing zeros.colors[xs < -5] = "green" -# assign colormap to an entire line +# assign a colormap to an entire line sine.cmap = "seismic" -# or to segments of a line -zeros.cmap[50:75] = "jet" -zeros.cmap[75:] = "viridis" # NOTE: fpl.loop.run() should not be used for interactive sessions diff --git a/examples/line_collection/line_collection_slicing.py b/examples/line_collection/line_collection_slicing.py deleted file mode 100644 index 98ad97056..000000000 --- a/examples/line_collection/line_collection_slicing.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Line collection slicing -======================= - -Example showing how to slice a line collection -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'screenshot' - -import numpy as np -import fastplotlib as fpl - - -xs = np.linspace(0, np.pi * 10, 100) -# sine wave -ys = np.sin(xs) - -data = np.column_stack([xs, ys]) -multi_data = np.stack([data] * 15) - - -figure = fpl.Figure(size=(700, 560)) - -lines = figure[0, 0].add_line_stack( - multi_data, - thickness=[2, 10, 2, 5, 5, 5, 8, 8, 8, 9, 3, 3, 3, 4, 4], - separation=4, - color_mode="vertex", # this will allow us to set per-vertex colors on each line - metadatas=list(range(15)), # some metadata - names=list("abcdefghijklmno"), # unique name for each line -) - -print("slice a collection to return a collection indexer") -print(lines[1:5]) # lines 1, 2, 3, 4 - -print("collections supports fancy indexing!") -print(lines[::3]) - -print("fancy index using properties of individual lines!") -print(lines[lines.thickness < 3]) -print(lines[lines.metadatas > 10]) - -# set line properties, such as data -# set y-values of lines 3, 4, 5 -lines[3:6].data[:, 1] = np.cos(xs) -# set these same lines to a different color -lines[3:6].colors = "cyan" - -# setting properties using fancy indexing -# set cmap along the line collection -lines[-3:].cmap = "plasma" - -# set cmap of along a single line -lines[7].cmap = "jet" - -# fancy indexing using line properties! -lines[lines.thickness > 8].colors = "r" -lines[lines.names == "a"].colors = "b" - -# fancy index at the level of lines and individual line properties! -lines[::2].colors[::5] = "magenta" # set every 5th point of every other line to magenta -lines[3:6].colors[50:, -1] = 0.6 # set half the points alpha to 0.6 - -figure.show(maintain_aspect=False) - -# individual y axis for each line -for line in lines: - line.add_axes() - line.axes.x.visible = False - line.axes.update_using_bbox(line.world_object.get_world_bounding_box()) - -# no y axis in subplot -figure[0, 0].axes.y.visible = False - - -if __name__ == "__main__": - print(__doc__) - fpl.loop.run() diff --git a/examples/line_collection/line_stack.py b/examples/line_collection/line_stack.py index 4376c18b4..30822ecfb 100644 --- a/examples/line_collection/line_stack.py +++ b/examples/line_collection/line_stack.py @@ -10,6 +10,8 @@ import numpy as np import fastplotlib as fpl +import cmap as cmap_lib +from itertools import repeat xs = np.linspace(0, np.pi * 10, 100) @@ -20,14 +22,33 @@ multi_data = np.stack([data] * 10) figure = fpl.Figure( - size=(700, 560), + shape=(3, 1), + size=(700, 1200), ) +# colormap per-line line_stack = figure[0, 0].add_line_stack( multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy] - cmap="jet", # applied along n_lines - thickness=5, - separation=1, # spacing between lines along the separation axis, default separation along "y" axis + cmap=["jet"] * 10, + separation=(0, 0, 0), # spacing between lines along each axis (x, y, z) + separation_axis="y", +) + +# colormap per-line with per-line transform +line_stack2 = figure[1, 0].add_line_stack( + multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy] + cmap=["bwr"] * 10, + cmap_transform=np.broadcast_to(ys, (10, ys.size)), + separation=(0, 0, 0), # spacing between lines along each axis (x, y, z) + separation_axis="y", +) + +# colormap across-lines +line_stack3 = figure[2, 0].add_line_stack( + multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy] + cmap="viridis", + separation=(0, 0, 0), # spacing between lines along each axis (x, y, z) + separation_axis="y", ) diff --git a/examples/line_collection/line_stack_3d.py b/examples/line_collection/line_stack_3d.py index b4548c1c6..37f67431d 100644 --- a/examples/line_collection/line_stack_3d.py +++ b/examples/line_collection/line_stack_3d.py @@ -30,7 +30,7 @@ multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy] cmap="jet", # applied along n_lines thickness=3, - separation=1, # spacing between lines along the separation axis, default separation along "y" axis + separation=(0, 1, 0), # spacing between lines along the separation axis, default separation along "y" axis name="lines", ) @@ -75,7 +75,8 @@ def animate_colors(subplot): cmap_transform = np.roll(np.arange(10), shift=int(colors_iteration / 50)) # set cmap with the transform - subplot["lines"].cmap = "jet", cmap_transform + subplot["lines"].cmap = "jet" + subplot["lines"].cmap_transform = cmap_transform colors_iteration += 1 diff --git a/examples/machine_learning/kmeans.py b/examples/machine_learning/kmeans.py index 4c49844f0..e1e5f8fb9 100644 --- a/examples/machine_learning/kmeans.py +++ b/examples/machine_learning/kmeans.py @@ -71,16 +71,16 @@ # plot the centroids figure[0, 0].add_scatter( data=np.vstack([centroids[:, 0], centroids[:, 1], centroids[:, 2]]).T, - colors="white", + colors="r", sizes=15 ) # plot the down-projected data digit_scatter = figure[0,0].add_scatter( data=np.vstack([reduced_data[:, 0], reduced_data[:, 1], reduced_data[:, 2]]).T, - sizes=5, + sizes=np.full(reduced_data.shape[0], 5), # per-point so the selected point can be enlarged cmap="tab10", # use a qualitative cmap cmap_transform=kmeans.labels_, # color by the predicted cluster - uniform_size=False, + edge_colors=np.full((reduced_data.shape[0], 4), (1, 1, 1, 0.2)) ) # initial index @@ -95,7 +95,7 @@ ) # change the color and size of the initial selected data point -digit_scatter.colors[ix] = "magenta" +digit_scatter.edge_colors[ix] = "magenta" digit_scatter.sizes[ix] = 10 @@ -103,13 +103,13 @@ @digit_scatter.add_event_handler("pointer_enter") def update(ev): # reset colors and sizes - digit_scatter.cmap = "tab10" + digit_scatter.edge_colors = (1, 1, 1, 0.2) digit_scatter.sizes = 5 # update with new seleciton ix = ev.pick_info["vertex_index"] - digit_scatter.colors[ix] = "magenta" + digit_scatter.edge_colors[ix] = "w" digit_scatter.sizes[ix] = 10 # update digit fig diff --git a/examples/mesh/surface_earth.py b/examples/mesh/surface_earth.py index c2e137bc8..c7ce30426 100644 --- a/examples/mesh/surface_earth.py +++ b/examples/mesh/surface_earth.py @@ -42,7 +42,7 @@ # get an image of the earth from nasa image = iio.imread( - "https://svs.gsfc.nasa.gov/vis/a000000/a003600/a003615/flat_earth_Largest_still.0330.jpg" + "https://assets.science.nasa.gov/content/dam/science/esd/eo/images/bmng/bmng-base/february/world.200402.3x5400x2700.jpg" ) # images coordinate systems are typically inverted in y, so flip the image image = np.ascontiguousarray(np.flipud(image)) diff --git a/examples/misc/reshape_lines_scatters.py b/examples/misc/reshape_lines_scatters.py index db8adb29e..fafa86f5c 100644 --- a/examples/misc/reshape_lines_scatters.py +++ b/examples/misc/reshape_lines_scatters.py @@ -37,10 +37,6 @@ sizes=(np.random.rand(100) + 1) * 3, edge_colors=np.random.rand(100, 4), point_rotations=np.random.rand(100) * 180, - uniform_marker=False, - uniform_size=False, - uniform_edge_color=False, - point_rotation_mode="vertex", ) line_stack = figure[2, 0].add_line_stack(np.stack([data] * 10), cmap="viridis") diff --git a/examples/misc/scatter_animation.py b/examples/misc/scatter_animation.py index 549059b65..d37aea976 100644 --- a/examples/misc/scatter_animation.py +++ b/examples/misc/scatter_animation.py @@ -37,7 +37,7 @@ figure = fpl.Figure(size=(700, 560)) subplot_scatter = figure[0, 0] # use an alpha value since this will be a lot of points -scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, uniform_size=False, colors=colors, alpha=0.6) +scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) def update_points(subplot): diff --git a/examples/misc/scatter_sizes_animation.py b/examples/misc/scatter_sizes_animation.py index 2092787f3..53a616a68 100644 --- a/examples/misc/scatter_sizes_animation.py +++ b/examples/misc/scatter_sizes_animation.py @@ -20,7 +20,7 @@ figure = fpl.Figure(size=(700, 560)) -figure[0, 0].add_scatter(data, sizes=sizes, uniform_size=False, name="sine") +figure[0, 0].add_scatter(data, sizes=sizes, name="sine") i = 0 diff --git a/examples/misc/tooltips_custom.py b/examples/misc/tooltips_custom.py index 3a54a945b..eb5d3768d 100644 --- a/examples/misc/tooltips_custom.py +++ b/examples/misc/tooltips_custom.py @@ -14,7 +14,6 @@ from sklearn.cluster import AgglomerativeClustering from sklearn import datasets - figure = fpl.Figure(size=(700, 560)) dataset = datasets.load_iris() @@ -30,6 +29,10 @@ cmap_transform=agg.labels_ # use the labels as a transform to map colors from the colormap ) +# since it's a qualitative colormap, set the cmap_range as the full range of the colormap +# otherwise it auto-sets it from the transform min, max +scatter.cmap_range = (0, scatter.cmap.num_colors) + def tooltip_info(pick_info: dict) -> str: # get index of the scatter point that is being hovered diff --git a/examples/ndwidget/ndimage.py b/examples/ndwidget/ndimage.py index eafd3c3c3..b8cc267a3 100644 --- a/examples/ndwidget/ndimage.py +++ b/examples/ndwidget/ndimage.py @@ -49,6 +49,14 @@ # change spatial dims on the fly # ndi.spatial_dims = ("depth", "m", "n") +figure = ndw.figure + + ndw.show() ndw2.show() -fpl.loop.run() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py index b2fd6ff6e..d0f8a6610 100644 --- a/examples/ndwidget/timeseries.py +++ b/examples/ndwidget/timeseries.py @@ -60,4 +60,10 @@ subplot.controller.add_camera(subplot.camera, include_state={"x", "width"}) ndw.show(maintain_aspect=False) -fpl.loop.run() +figure = ndw.figure + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/ndwidget/timeseries_cmaps.py b/examples/ndwidget/timeseries_cmaps.py new file mode 100644 index 000000000..a6e87024b --- /dev/null +++ b/examples/ndwidget/timeseries_cmaps.py @@ -0,0 +1,60 @@ +""" +NDWidget Timeseries cmaps +========================= + +NDWidget timeseries example with colormaps and transforms, can be useful for things like ethograms. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from itertools import cycle + +# generate some toy timeseries data +n_datapoints = 100_000 # number of datapoints per line +n_lines = 8 + +xs = np.linspace(0, 1000 * np.pi, n_datapoints) +ys = np.random.rand(n_datapoints) +data = np.column_stack([xs, ys]) +n_data = np.stack([data] * n_lines) +n_data[:4, 50_000:, 1] += 1 + +# must define a reference range, this would often be your time dimension and corresponds to your x-dimension +ref = { + "angle": (0, xs[-1], 0.1), +} + +ndw = fpl.NDWidget(ref_ranges=ref, size=(700, 560)) + +nd_lines = ndw[0, 0].add_nd_timeseries( + n_data, + ("n_lines", "angle", "d"), + ("n_lines", "angle", "d"), + slider_dim_transforms={ + "angle": xs, + }, + # some alternating colormaps per-line + cmap=cycle(["jet", "viridis", "winter"]), + # a transform from which we map the colormap colors + # with just a linespace, it means that low x-values get early colors in the colormap + # high x-values in the FULL data get the later colors in the colormap + cmap_transform=np.broadcast_to(np.linspace(0, 1, n_datapoints), (n_lines, n_datapoints)), + x_range_mode="auto", + display_window=np.pi * 10, +) + +ndw.show(maintain_aspect=False) +figure = ndw.figure + +subplot = ndw.figure[0, 0] +subplot.controller.add_camera(subplot.camera, include_state={"x", "width"}) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/scatter/scatter_cmap_iris.py b/examples/scatter/scatter_cmap_iris.py index 24be0c13c..84807c155 100644 --- a/examples/scatter/scatter_cmap_iris.py +++ b/examples/scatter/scatter_cmap_iris.py @@ -31,6 +31,10 @@ figure.show() scatter.cmap = "tab10" +# since it's a qualitative colormap, set the cmap_range as the full range of the colormap +# otherwise it auto-sets it from the transform min, max +scatter.cmap_range = (0, scatter.cmap.num_colors) + if __name__ == "__main__": diff --git a/examples/scatter/scatter_image_as_points.py b/examples/scatter/scatter_image_as_points.py index aeae30bd0..58e614571 100644 --- a/examples/scatter/scatter_image_as_points.py +++ b/examples/scatter/scatter_image_as_points.py @@ -44,6 +44,7 @@ mode="image", image=wikkie, # if an RGB(A) image is provided and no colors are provided, then the image is shown as-is sizes=40, + point_rotations=0, # by default scatter point rotation follows the curve ) figure.show() diff --git a/examples/scatter/scatter_iris.py b/examples/scatter/scatter_iris.py index fc228e5bf..e6d1a36e1 100644 --- a/examples/scatter/scatter_iris.py +++ b/examples/scatter/scatter_iris.py @@ -35,8 +35,12 @@ cmap="tab10", cmap_transform=clusters_labels, markers=markers, - uniform_marker=False, + point_rotations=0, + ) +# since it's a qualitative colormap, set the cmap_range as the full range of the colormap +# otherwise it auto-sets it from the transform min, max +scatter.cmap_range = (0, scatter.cmap.num_colors) figure.show() diff --git a/examples/scatter/scatter_size.py b/examples/scatter/scatter_size.py index 2b3899dbe..30d3e6ea3 100644 --- a/examples/scatter/scatter_size.py +++ b/examples/scatter/scatter_size.py @@ -35,7 +35,7 @@ ) # add a set of scalar sizes non_scalar_sizes = np.abs((y_values / np.pi)) # ensure minimum size of 5 -figure["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, uniform_size=False, colors="red") +figure["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, colors="red") for graph in figure: graph.auto_scale(maintain_aspect=True) diff --git a/examples/scatter/scatter_validate.py b/examples/scatter/scatter_validate.py index 45f0a177c..38746a75b 100644 --- a/examples/scatter/scatter_validate.py +++ b/examples/scatter/scatter_validate.py @@ -38,13 +38,11 @@ figure[0, 0].add_scatter( sine, colors=["magenta"] * 3 + ["cyan"] * 3 + ["yellow"] * 3 + ["purple"], - uniform_edge_color=False, edge_colors=["w"] * 3 + ["orange"] * 3 + ["blue"] * 3 + ["green"], markers=list("osD+x^v<>*"), - uniform_marker=False, edge_width=2.0, sizes=20, - uniform_size=True, + point_rotations=0, ) @@ -53,9 +51,7 @@ sine, markers="^", sizes=20, - point_rotation_mode="vertex", point_rotations=xs, - uniform_size=True, offset=(0, 1, 0) ) @@ -65,8 +61,8 @@ sine, markers="s", sizes=xs * 5, - uniform_size=False, - offset=(0, 2, 0) + offset=(0, 2, 0), + point_rotations=0, ) figure.show() diff --git a/examples/scatter/spinning_spiral.py b/examples/scatter/spinning_spiral.py index 4f947970a..205ced463 100644 --- a/examples/scatter/spinning_spiral.py +++ b/examples/scatter/spinning_spiral.py @@ -40,7 +40,6 @@ edge_colors=None, alpha=0.5, sizes=sizes, - uniform_size=False, ) # pre-generate normally distributed data to jitter the points before each render diff --git a/examples/selection_tools/highlight_selector.py b/examples/selection_tools/highlight_selector.py index e4c9dde91..01f6fb1c2 100644 --- a/examples/selection_tools/highlight_selector.py +++ b/examples/selection_tools/highlight_selector.py @@ -72,8 +72,8 @@ def on_heatmap_click(ev): col = pixel_idx % n_x if "Shift" in ev.modifiers: - hm_sel.append("rows", pixel_idx) - img_sel.append("pixels", np.array([[row, col]])) + hm_sel.append({"rows": pixel_idx}) + img_sel.append({"pixels": np.array([[row, col]])}) print(hm_sel.selection) else: hm_sel.selection = {"rows": [pixel_idx]} @@ -82,6 +82,8 @@ def on_heatmap_click(ev): ndw.show(maintain_aspect=False) +figure = ndw.figure + # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": diff --git a/examples/selection_tools/linear_region_line_collection.py b/examples/selection_tools/linear_region_line_collection.py index 05084df0f..7f607677f 100644 --- a/examples/selection_tools/linear_region_line_collection.py +++ b/examples/selection_tools/linear_region_line_collection.py @@ -26,7 +26,7 @@ data = [sine, cosine, sine, cosine] # make line stack -line_stack = figure[0, 0].add_line_stack(data, separation=2) +line_stack = figure[0, 0].add_line_stack(data, separation=(0, 2, 0), separation_axis="y") # make selector selector = line_stack.add_linear_region_selector() diff --git a/examples/selection_tools/linear_selector.py b/examples/selection_tools/linear_selector.py index 8b442db20..4f4b0d1ee 100644 --- a/examples/selection_tools/linear_selector.py +++ b/examples/selection_tools/linear_selector.py @@ -44,10 +44,7 @@ line_selector_text, offset=(0., 1.75, 0.), anchor="middle-left", - font_size=32, - face_color=line.colors[0], - outline_color="w", - outline_thickness=0.1, + font_size=24, ) @@ -65,18 +62,15 @@ def line_selector_changed(ev): f"y value: {line.data[index, 1]:.2f}\n" f"index: {index}") - # set text color based on line color at selection index - line_selection_label.face_color = line.colors[index] - # line stack, sine and cosine wave -line_stack = figure[0, 1].add_line_stack([sine, cosine], colors=["magenta", "cyan"], separation=1) +line_stack = figure[0, 1].add_line_stack([sine, cosine], colors=["magenta", "cyan"], separation=(0, 1, 0)) line_stack_selector = line_stack.add_linear_selector() line_stack_selector_text = (f"x value: {line_stack_selector.selection / np.pi:.2f}π\n" f"index: {line_selector.get_selected_index()}\n" - f"sine y value: {line_stack[0].data[0, 1]:.2f}\n" - f"cosine y value: {line_stack[1].data[0, 1]:.2f}\n") + f"sine y value: {line_stack.data[0, 0, 1]:.2f}\n" + f"cosine y value: {line_stack.data[1, 0, 1]:.2f}\n") # a label that will change to display line_stack data based on the linear selector line_stack_selector_label = figure[0, 1].add_text( @@ -99,8 +93,8 @@ def line_stack_selector_changed(ev): line_stack_selector_label.text = \ (f"x value: {selection / np.pi:.2f}π\n" f"index: {index}\n" - f"sine y value: {line_stack[0].data[index, 1]:.2f}\n" - f"cosine y value: {line_stack[1].data[index, 1]:.2f}\n") + f"sine y value: {line_stack.data[0, index, 1]:.2f}\n" + f"cosine y value: {line_stack.data[1, index, 1]:.2f}\n") # add an event handler, you can also use a decorator diff --git a/examples/selection_tools/polygon_selector.py b/examples/selection_tools/polygon_selector.py index b43b34811..369819346 100644 --- a/examples/selection_tools/polygon_selector.py +++ b/examples/selection_tools/polygon_selector.py @@ -33,8 +33,10 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: pos_xy = np.vstack(circles) +colors = np.random.rand(len(circles), 3) + # add image -line_collection = figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5) +line_collection = figure[0, 0].add_line_collection(circles, colors=colors, thickness=5) # add polygon selector to image graphic polygon_selector = line_collection.add_polygon_selector( @@ -45,11 +47,12 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: # add event handler to highlight selected indices @polygon_selector.add_event_handler("selection") def color_indices(ev): - line_collection.cmap = "jet" + line_collection.colors = colors ixs = ev.get_selected_indices() # iterate through each of the selected indices, if the array size > 0 that mean it's under the selection selected_line_ixs = [i for i in range(len(ixs)) if ixs[i].size > 0] - line_collection[selected_line_ixs].colors = "w" + # boolean indexing of a collection property + line_collection.colors[selected_line_ixs] = "w" # # manually move selector to make a nice gallery image :D diff --git a/examples/selection_tools/rectangle_selector.py b/examples/selection_tools/rectangle_selector.py index d0fd33aa9..c96acae31 100644 --- a/examples/selection_tools/rectangle_selector.py +++ b/examples/selection_tools/rectangle_selector.py @@ -50,7 +50,7 @@ def color_indices(ev): # iterate through each of the selected indices, if the array size > 0 that mean it's under the selection selected_line_ixs = [i for i in range(len(ixs)) if ixs[i].size > 0] - line_collection[selected_line_ixs].colors = "w" + line_collection.colors[selected_line_ixs] = "w" # manually move selector to make a nice gallery image :D diff --git a/examples/vectors/vectors_interact_electric_charges.py b/examples/vectors/vectors_interact_electric_charges.py index 4aa1d41b6..4200b034b 100644 --- a/examples/vectors/vectors_interact_electric_charges.py +++ b/examples/vectors/vectors_interact_electric_charges.py @@ -61,7 +61,7 @@ def coulombs_law(q: float, r: np.ndarray) -> np.ndarray[float, float]: colors=colors, sizes=1, edge_width=0.05, - uniform_edge_color=False, + edge_colors=["black"] * positions.shape[0], # per-point edges so they can be highlighted individually alpha=0.7, size_space="model", metadata={"charges": charges}, # you can store anything as arbitrary metadata diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 3a0c56077..3c75f7da9 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -7,8 +7,8 @@ from ._vectors import VectorsGraphic from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic -from .line_collection import LineCollection, LineStack -from .scatter_collection import ScatterCollection, ScatterStack +from ._collection_base import GraphicCollection +from ._collections import LineCollection, LineStack, ScatterCollection, ScatterStack, ImageCollection, ImageGrid __all__ = [ "Graphic", @@ -23,8 +23,11 @@ "SurfaceGraphic", "PolygonGraphic", "TextGraphic", + "GraphicCollection", "LineCollection", "LineStack", "ScatterCollection", "ScatterStack", + "ImageCollection", + "ImageGrid", ] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 24a59a7e4..1e19d6c3b 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -18,6 +18,7 @@ import pygfx from .features import ( + GraphicFeature, Deleted, Name, Offset, @@ -58,7 +59,7 @@ class Graphic: - _features: dict[str, type] = dict() + _features: dict[str, type[GraphicFeature] | tuple[type[GraphicFeature], ...]] = dict() # It also doesn't make sense to create tooltips for some graphics # ex: text, that would be very funny. @@ -298,7 +299,8 @@ def _set_world_object(self, wo: pygfx.WorldObject): self._world_object_ids.append(global_id) wo.visible = self.visible - if "Image" in self.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in self.__class__.__name__ and not hasattr(self, "graphics"): # Image and ImageVolume use tiling and share one material self._material.opacity = self.alpha self._material.alpha_mode = self.alpha_mode diff --git a/fastplotlib/graphics/_collection_base.py b/fastplotlib/graphics/_collection_base.py index 5b1fd87f1..82f585aa0 100644 --- a/fastplotlib/graphics/_collection_base.py +++ b/fastplotlib/graphics/_collection_base.py @@ -1,208 +1,301 @@ -from contextlib import suppress +from __future__ import annotations + +import inspect +from collections.abc import Iterable from typing import Any import numpy as np +import pygfx +import cmap as cmap_lib from ._base import Graphic - - -class CollectionProperties: +from ._jagged_array import CollectionFeatureAccessor, JaggedCollectionFeature, CollectionColors, CollectionCmap, ARRAY_BUFFER_FEATURES +from .features import GraphicFeature, VertexColors, UniformColor, VertexCmap + + +# a feature the collection also owns as a `Graphic` is exposed under a plural name, so +# `collection.offset` is the collection's own offset and `collection.offsets` is the per-graphic +# offsets +PLURAL = { + "name": "names", + "offset": "offsets", + "rotation": "rotations", + "scale": "scales", + "alpha": "alphas", + "alpha_mode": "alpha_modes", + "visible": "visibles", + "metadata": "metadatas", +} + +# features not exposed across the collection +EXCLUDE = {"deleted"} + + +def get_accessor_class(feature: str, feature_classes: tuple[type, ...]) -> type: + """the accessor class used to manage a feature across a collection""" + if UniformColor in feature_classes or VertexColors in feature_classes: + return CollectionColors + if VertexCmap in feature_classes: + return CollectionCmap + if any(issubclass(c, ARRAY_BUFFER_FEATURES) for c in feature_classes if isinstance(c, type)): + return JaggedCollectionFeature + if feature in ("offset", "rotation", "scale"): + return JaggedCollectionFeature + return CollectionFeatureAccessor + + +def get_value_ndim(feature_classes: tuple[type[GraphicFeature], ...]) -> int: + """number of dimensions of a value that applies to every graphic, i.e. the uniform variant""" + return min((c.ndim for c in feature_classes if isinstance(c, type)), default=0) + + +def cmap_across_graphics( + cmap_name: str, + n_graphics: int, + transform: np.ndarray = None, + cmap_range: tuple[float, float] = None, +) -> np.ndarray: """ - Properties common to all Graphic Collections + ``n_graphics`` colors from a colormap, one per graphic. - Allows getting and setting the common properties of the individual graphics in the collection + Without a transform the colors are evenly spaced along the colormap. A ``transform`` maps each + graphic into the colormap instead. A qualitative colormap indexes its colors with the transform + values directly, so a given value always gets the same color, e.g. cluster labels. Any other + colormap resamples the transform to one value per graphic and normalizes it over ``cmap_range``, + or over the transform's own (min, max) if no range is given. """ + cmap = cmap_lib.Colormap(cmap_name) - def _set_feature(self, feature, values): - if not len(values) == len(self): - raise IndexError - - for g, v in zip(self, values): - setattr(g, feature, v) - - @property - def names(self) -> np.ndarray[str | None]: - """get or set the name of the individual graphics in the collection""" - return np.asarray([g.name for g in self]) - - @names.setter - def names(self, values: np.ndarray[str] | list[str]): - self._set_feature("name", values) - - @property - def metadatas(self) -> np.ndarray[str | None]: - """get or set the metadata of the individual graphics in the collection""" - return np.asarray([g.metadata for g in self]) - - @metadatas.setter - def metadatas(self, values: np.ndarray[str] | list[str]): - self._set_feature("metadata", values) - - @property - def offsets(self) -> np.ndarray: - """get or set the offset of the individual graphics in the collection""" - return np.stack([g.offset for g in self]) - - @offsets.setter - def offsets(self, values: np.ndarray | list[np.ndarray]): - self._set_feature("offset", values) - - @property - def rotations(self) -> np.ndarray: - """get or set the rotation of the individual graphics in the collection""" - return np.stack([g.rotation for g in self]) - - @rotations.setter - def rotations(self, values: np.ndarray | list[np.ndarray]): - self._set_feature("rotation", values) - - # TODO: how to work with deleted feature in a collection - - @property - def visibles(self) -> np.ndarray[bool]: - """get or set the offsets of the individual graphics in the collection""" - return np.asarray([g.visible for g in self]) - - @visibles.setter - def visibles(self, values: np.ndarray[bool] | list[bool]): - self._set_feature("visible", values) - - -class CollectionIndexer(CollectionProperties): - """Collection Indexer""" - - def __init__(self, selection: np.ndarray[Graphic], features: set[str]): - """ - - Parameters - ---------- - - selection: np.ndarray of Graphics - array of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` + if transform is None: + if cmap_range is not None: + raise ValueError("must pass `cmap_transform` if passing `cmap_range`") + return np.asarray(cmap(np.linspace(0, 1, n_graphics))) - """ - - if isinstance(selection, Graphic): - selection = np.asarray([selection]) - - self._selection = selection - self.features = features - - @property - def graphics(self) -> np.ndarray[Graphic]: - """Returns an array of the selected graphics""" - return tuple(self._selection) - - def add_event_handler(self, *args): - """ - Register an event handler. - - Parameters - ---------- - callback: callable, the first argument - Event handler, must accept a single event argument - *types: list of strings - A list of event types, ex: "click", "data", "colors", "pointer_down" - - For the available renderer event types, see - https://jupyter-rfb.readthedocs.io/en/stable/events.html - - All feature support events, i.e. ``graphic.features`` will give a set of - all features that are evented - - Can also be used as a decorator. + transform = np.asarray(transform) - Example - ------- - - .. code-block:: py - - def my_handler(event): - print(event) + if cmap.interpolation == "nearest": + # qualitative, the transform values are indices into the colormap's colors + if not np.issubdtype(transform.dtype, np.integer): + raise TypeError( + f"a qualitative colormap requires an integer `cmap_transform`, got dtype: " + f"{transform.dtype}" + ) + if len(transform) != n_graphics: + raise IndexError( + f"len(cmap_transform) must equal the number of graphics, got {len(transform)} " + f"`cmap_transform` values for {n_graphics} graphics" + ) + if transform.min() < 0 or transform.max() >= cmap.num_colors: + raise IndexError( + f"`cmap_transform` values must be integers within the range of the number of " + f"colors in the provided colormap, `{cmap.name}` has {cmap.num_colors} colors, " + f"got range: [{transform.min()}, {transform.max()}]" + ) + if cmap_range is not None: + raise ValueError( + f"`cmap_range` must be `None` for a qualitative colormap, got: {cmap_range!r}" + ) + return np.asarray(cmap(transform / max(cmap.num_colors - 1, 1))) - graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") + transform = transform.astype(float) + # resample the transform to one value per graphic + transform = np.interp( + np.linspace(0, 1, n_graphics), np.linspace(0, 1, len(transform)), transform + ) + # normalize over the range so the values index the colormap + vmin, vmax = cmap_range if cmap_range is not None else (transform.min(), transform.max()) + spread = vmax - vmin + values = (transform - vmin) / spread if spread else np.zeros(n_graphics) - Decorator usage example: + return np.asarray(cmap(values)) - .. code-block:: py - @graphic.add_event_handler("click") - def my_handler(event): - print(event) - """ +class _AccessorProperty(property): + """marks a property as generated for a collection feature, so a subclass's own explicit + property for a feature can be told apart from a generated one""" - decorating = not callable(args[0]) - types = args if decorating else args[1:] - if decorating: +def make_feature_property(feature_name: str, accessor_class: type) -> property: + """a property that returns the feature's accessor for get/set across the collection""" - def decorator(_callback): - for g in self: - g.add_event_handler(_callback, *types) - return _callback + def getter(collection_instance): + return getattr(collection_instance, f"_{feature_name}") - return decorator + if accessor_class is CollectionCmap: + # assigning a colormap colors each graphic one color, evenly spaced across the collection + def setter(collection_instance, value): + collection_instance.colors[:] = cmap_across_graphics(value, len(collection_instance)) + else: + def setter(collection_instance, value): + getattr(collection_instance, f"_{feature_name}")[:] = value - for g in self: - g.add_event_handler(*args) + doc = f"get or set the {feature_name} of the graphics in the collection" + return _AccessorProperty(getter, setter, doc=doc) - def remove_event_handler(self, callback, *types): - for g in self: - g.remove_event_handler(callback, *types) - def clear_event_handlers(self): - for g in self: - g.clear_event_handlers() +def make_collection_signature(cls: type) -> inspect.Signature: + """ + the collection constructor's signature - def __getitem__(self, item): - return self.graphics[item] + ``data`` becomes the list of per-graphic data and each managed feature accepts one value for all + graphics or one per graphic (``Iterable``), both derived from the child graphic. The parameters + the collection itself takes are added as-is: its own ``Graphic`` parameters, and any parameter a + collection subclass declares, e.g. a stack's ``separation``. + """ + parameters = dict() + + for name, parameter in inspect.signature(cls._child_type.__init__).parameters.items(): + if name in ("self", "data") or parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD): + continue + feature_name = PLURAL.get(name, name) + annotation = parameter.annotation + if feature_name in cls._accessor_specs and annotation is not parameter.empty: + annotation = Iterable[annotation] + default = parameter.default if parameter.default is not parameter.empty else None + parameters[feature_name] = inspect.Parameter( + feature_name, inspect.Parameter.KEYWORD_ONLY, default=default, annotation=annotation + ) - def __len__(self): - return len(self._selection) + # features the collection exposes but the child takes via **kwargs, e.g. names, offsets, metadatas + for feature_name in cls._accessor_specs: + if feature_name == "data" or feature_name in parameters: + continue + parameters[feature_name] = inspect.Parameter( + feature_name, inspect.Parameter.KEYWORD_ONLY, default=None + ) - def __iter__(self): - self._iter = iter(range(len(self))) - return self + # the collection's own parameters, from `Graphic` and from each collection subclass `__init__` + for klass in reversed(cls.__mro__): + for name, parameter in inspect.signature(klass.__init__).parameters.items(): + if name in ("self", "data") or name in parameters: + continue + if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD): + continue + parameters[name] = parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY) - def __next__(self) -> Graphic: - index = next(self._iter) + return inspect.Signature( + [inspect.Parameter("data", inspect.Parameter.POSITIONAL_OR_KEYWORD), *parameters.values()] + ) - return self.graphics[index] - - def __repr__(self): - return ( - f"{self.__class__.__name__} @ {hex(id(self))}\n" - f"Selection of <{len(self._selection)}> {self._selection[0].__class__.__name__}" - ) +class GraphicCollection(Graphic): + """ + A collection of graphics of the same type. + + Subclasses set only ``_child_type``. Each feature of the child graphic is then exposed as a + property returning an accessor that gets and sets that feature across all of the graphics using + numpy broadcasting, e.g. ``collection.colors[:10, 30:50] = "r"``. Features the collection also + owns as a ``Graphic`` (``name``, ``offset``, ``rotation``, ``scale``, ``alpha``, ``alpha_mode``, + ``visible``, ``metadata``) are exposed under a plural name (``names``, ``offsets``, ...). The + constructor signature is derived from the child graphic as well. + """ -class GraphicCollection(Graphic, CollectionProperties): - """Graphic Collection base class""" + _child_type: type[Graphic] = None - _child_type: type - _indexer: type - # tooltips will come from the child graphics + # tooltips come from the child graphics _fpl_support_tooltip = False def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) - cls._features = cls._child_type._features + if cls._child_type is None: + return + + # exposed feature name -> (child feature, accessor class, value_ndim) + specs = dict() + for feature, feature_classes in cls._child_type._features.items(): + if feature in EXCLUDE: + continue + feature_classes = feature_classes if isinstance(feature_classes, tuple) else (feature_classes,) + feature_name = PLURAL.get(feature, feature) + existing = getattr(cls, feature_name, None) + if isinstance(existing, property) and not isinstance(existing, _AccessorProperty): + continue # the subclass implements this feature with its own property; no accessor + specs[feature_name] = ( + feature, + get_accessor_class(feature, feature_classes), + get_value_ndim(feature_classes), + ) + # metadata is a plain attribute, not a graphic feature, so add it explicitly + specs["metadatas"] = ("metadata", CollectionFeatureAccessor, 0) + cls._accessor_specs = specs - def __init__(self, name: str = None, metadata: Any = None, **kwargs): - super().__init__(name=name, metadata=metadata, **kwargs) + # install a property for each feature, unless the class already defines one + for feature_name, (feature, accessor_class, _) in cls._accessor_specs.items(): + if isinstance(getattr(cls, feature_name, None), property): + continue + setattr(cls, feature_name, make_feature_property(feature_name, accessor_class)) - # list of mem locations of the graphics - self._graphics: list[Graphic] = list() + # expose the feature names so `add_event_handler` routes feature events to the accessor + cls._features = {**cls._features, **{name: spec[1] for name, spec in cls._accessor_specs.items()}} - self._graphics_changed: bool = True + cls.__signature__ = make_collection_signature(cls) - self._iter = None + def __init__(self, data, **kwargs): + """ + Create a collection of graphics of the same type. + + Parameters + ---------- + data: list of array-like + one entry per graphic; its length is the number of graphics in the collection + + **kwargs + any feature of the child graphic (``colors``, ``thickness``, ``sizes``, ...), each + accepting one value for all graphics or one value per graphic. A ``Graphic`` argument + (``name``, ``offset``, ``visible``, ...) sets it on the collection itself, its plural + form (``names``, ``offsets``, ``visibles``, ...) sets it per graphic. Any argument that + is not a feature is passed unchanged to every child graphic. + """ + # the singular name sets the collection's own value, the plural form sets it per graphic + super().__init__(**{name: kwargs.pop(name) for name in PLURAL.keys() & kwargs.keys()}) + + n_graphics = len(data) + if n_graphics == 0: + raise ValueError("a collection needs at least one graphic, got an empty `data`") + + self._graphics = np.empty(n_graphics, dtype=object) + self._set_world_object(pygfx.Group()) + + self._create_accessors(data_value_ndim=int(np.ndim(data[0]))) + + feature_values = dict() # child feature -> iterator of one value per graphic + graphic_kwargs = dict() # non-feature kwargs, same for every graphic + + # split each feature into one value per graphic, other kwargs go to every graphic + for feature_name, value in kwargs.items(): + if feature_name not in self._accessor_specs: + graphic_kwargs[feature_name] = value + continue + feature = self._accessor_specs[feature_name][0] + accessor = getattr(self, f"_{feature_name}") + value = accessor._parse_feature_value(value, ()) + feature_values[feature] = iter(accessor._broadcast_over_graphics(value, n_graphics)) + + # one graphic per data entry, filled into the preallocated array + for i, graphic_data in enumerate(data): + feature_kwargs = {feature: next(values) for feature, values in feature_values.items()} + graphic = self._child_type(graphic_data, **feature_kwargs, **graphic_kwargs) + self._check_graphic_features_modes(graphic) + self._graphics[i] = graphic + self.world_object.add(graphic.world_object) + + def _create_accessors(self, data_value_ndim: int): + # one accessor per exposed feature, over the collection's graphics array + for feature_name, (feature, accessor_class, value_ndim) in self._accessor_specs.items(): + setattr( + self, + f"_{feature_name}", + accessor_class(self._graphics, feature, value_ndim, feature_name=feature_name), + ) + # data is the loop driver, so its value_ndim comes from the data, not a feature class + self._data._value_ndim = data_value_ndim @property def graphics(self) -> np.ndarray[Graphic]: - """The Graphics within this collection.""" - - return np.asarray(self._graphics) + """the graphics in the collection""" + graphics = self._graphics.view() + graphics.flags.writeable = False + return graphics def add_graphic(self, graphic: Graphic): """ @@ -211,157 +304,87 @@ def add_graphic(self, graphic: Graphic): Parameters ---------- graphic: Graphic - graphic to add, must be a real ``Graphic`` not a proxy - + the graphic to add; must be of the collection's ``_child_type`` and match the + per-vertex or uniform buffer mode of the graphics already in the collection """ - - if not type(graphic) == self._child_type: + if not isinstance(graphic, self._child_type): raise TypeError( - f"Can only add graphics of the same type to a collection.\n" - f"You can only add {self._child_type.__name__} to a {self.__class__.__name__}, " - f"you are trying to add a {graphic.__class__.__name__}." + f"cannot add a `{type(graphic).__name__}` to a collection of `{self._child_type.__name__}`" ) + self._check_graphic_features_modes(graphic) - self._graphics.append(graphic) + graphics = np.empty(self._graphics.size + 1, dtype=object) + graphics[:-1] = self._graphics + graphics[-1] = graphic + self._graphics = graphics + self._refresh_accessors() - self.world_object.add(graphic.world_object) + # a collection already in a plot area passes it on, like `_fpl_add_plot_area_hook` does + if self._plot_area is not None: + graphic._fpl_add_plot_area_hook(self._plot_area) - self._graphics_changed = True + self.world_object.add(graphic.world_object) def remove_graphic(self, graphic: Graphic): """ Remove a graphic from the collection. - Note: Only removes the graphic from the collection. Does not remove - the graphic from the scene, and does not delete the graphic. - Parameters ---------- graphic: Graphic - graphic to remove - + the graphic to remove """ + index = next((i for i, g in enumerate(self._graphics) if g is graphic), None) + if index is None: + raise KeyError("graphic is not in the collection") - self._graphics.remove(graphic) + self._graphics = np.delete(self._graphics, index) + self._refresh_accessors() self.world_object.remove(graphic.world_object) - self._graphics_changed = True - - def add_event_handler(self, *args): - """ - Register an event handler. - - Parameters - ---------- - callback: callable, the first argument - Event handler, must accept a single event argument - *types: list of strings - A list of event types, ex: "click", "data", "colors", "pointer_down" - - For the available renderer event types, see - https://jupyter-rfb.readthedocs.io/en/stable/events.html - - All feature support events, i.e. ``graphic.features`` will give a set of - all features that are evented - - Can also be used as a decorator. - - Example - ------- - - .. code-block:: py - - def my_handler(event): - print(event) - - graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") - - Decorator usage example: - - .. code-block:: py - - @graphic.add_event_handler("click") - def my_handler(event): - print(event) - """ - - return self[:].add_event_handler(*args) - - def remove_event_handler(self, callback, *types): - """remove an event handler""" - self[:].remove_event_handler(callback, *types) - - def clear_event_handlers(self): - self[:].clear_event_handlers() + def _check_graphic_features_modes(self, graphic: Graphic): + # every graphic must use the same feature types (per-vertex vs uniform) as the first one, + # so the accessors can index them all the same way + if self._graphics.size == 0 or self._graphics[0] is None: + return + reference = self._graphics[0] + for feature, _, _ in self._accessor_specs.values(): + reference_feature = getattr(reference, f"_{feature}", None) + if not isinstance(reference_feature, GraphicFeature): + continue # e.g. metadata, not a graphic feature + if not isinstance(getattr(graphic, f"_{feature}", None), type(reference_feature)): + raise TypeError( + f"graphics in a collection must use the same `{feature}` type; the collection " + f"uses `{type(reference_feature).__name__}`" + ) + + def _refresh_accessors(self): + # point each accessor at the current graphics array + for feature_name in self._accessor_specs: + getattr(self, f"_{feature_name}")._graphics = self._graphics def _fpl_add_plot_area_hook(self, plot_area): super()._fpl_add_plot_area_hook(plot_area) - - for g in self: - g._fpl_add_plot_area_hook(plot_area) + for graphic in self._graphics: + graphic._fpl_add_plot_area_hook(plot_area) def _fpl_prepare_del(self): - """ - Cleans up the graphic in preparation for __del__(), such as removing event handlers from - plot renderer, feature event handlers, etc. - - Optionally implemented in subclasses - """ - # clear any attached event handlers and animation functions - self.world_object._event_handlers.clear() + # the base clears this world object's and its children's handlers, so it runs first + super()._fpl_prepare_del() self.world_object.clear() - for g in self: - g._fpl_prepare_del() - - def __getitem__(self, key) -> CollectionIndexer: - if np.issubdtype(type(key), np.integer): - return self.graphics[key] - - return self._indexer(selection=self.graphics[key], features=self._features) + for graphic in self._graphics: + graphic._fpl_prepare_del() - def __len__(self): - return len(self._graphics) + def __len__(self) -> int: + return self._graphics.size def __iter__(self): - self._iter = iter(range(len(self))) - return self - - def __next__(self) -> Graphic: - index = next(self._iter) - - return self._graphics[index] - - def __repr__(self): - rval = super().__repr__() - return f"{rval}\nCollection of <{len(self._graphics)}> Graphics" - - -class CollectionFeature: - """Collection Feature""" - - def __init__(self, selection: np.ndarray[Graphic], feature: str): - """ - selection: list of Graphics - a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` - - feature: str - feature of Graphics in the GraphicCollection being indexed - - """ - - self._selection = selection - self._feature = feature - - self._feature_instances = [getattr(g, feature) for g in self._selection] - - def __getitem__(self, item): - return np.stack([fi[item] for fi in self._feature_instances]) + return iter(self._graphics) - def __setitem__(self, key, value): - for fi in self._feature_instances: - fi[key] = value + def __contains__(self, graphic: Graphic) -> bool: + return graphic in self._graphics - def __repr__(self): - return f"Collection feature for: <{self._feature}>" + def __repr__(self) -> str: + return f"{type(self).__name__} of <{len(self)}> {self._child_type.__name__}" diff --git a/fastplotlib/graphics/_collections.py b/fastplotlib/graphics/_collections.py new file mode 100644 index 000000000..ff8c1c356 --- /dev/null +++ b/fastplotlib/graphics/_collections.py @@ -0,0 +1,471 @@ +import itertools + +import cmap as cmap_lib +import numpy as np + +from .line import LineGraphic +from .scatter import ScatterGraphic +from .image import ImageGraphic +from ._collection_base import GraphicCollection, cmap_across_graphics +from .selectors import ( + LinearSelector, + LinearRegionSelector, + RectangleSelector, + PolygonSelector, +) +from ..utils import calculate_figure_shape + + +class PositionsCollection(GraphicCollection): + """A collection of positions-based graphics (lines, scatters); adds selectors spanning all graphics.""" + + def __init__(self, data, *, cmap=None, cmap_transform=None, cmap_range=None, **kwargs): + super().__init__(data, **kwargs) + self._set_cmap(cmap, cmap_transform, cmap_range) + + def _set_cmap(self, cmap, cmap_transform=None, cmap_range=None): + """ + A single cmap (str or ``cmap_lib.Colormap``) gives each graphic a uniform color. + An iterable of cmaps gives each graphic its own colormap. + """ + if hasattr(cmap, "__next__"): + # an iterator (itertools.repeat/cycle, a generator, ...): one cmap per graphic, + # materialized so re-applying it (e.g. each frame in NDPositions) stays stable + cmap = list(itertools.islice(cmap, len(self))) + self._cmap = cmap + self._cmap_transform = cmap_transform + self._cmap_range = cmap_range + + if cmap is None: + if cmap_transform is not None: + raise ValueError("must pass `cmap` if passing `cmap_transform`") + return + + single_cmap = isinstance(cmap, (str, cmap_lib.Colormap)) + # a single cmap needs a 1D transform (across graphics), an iterable needs a 2D transform (per-graphic) + if cmap_transform is not None and single_cmap == (np.ndim(cmap_transform[0]) >= 1): + raise ValueError( + "`cmap` and `cmap_transform` must match: a single `cmap` uses a 1D transform, " + "an iterable of cmaps uses a 2D transform" + ) + + if single_cmap: + self.colors[:] = cmap_across_graphics(cmap, len(self), cmap_transform, cmap_range) + return + + if len(cmap) != len(self): + raise IndexError( + f"len(cmap) must equal the number of graphics, got {len(cmap)} cmaps for " + f"{len(self)} graphics" + ) + if cmap_transform is not None and len(cmap_transform) != len(self): + raise IndexError( + f"len(cmap_transform) must equal the number of graphics, got " + f"{len(cmap_transform)} `cmap_transform` values for {len(self)} graphics" + ) + if np.ndim(cmap_range) == 2 and len(cmap_range) != len(self): + raise IndexError( + f"len(cmap_range) must equal the number of graphics, got {len(cmap_range)} " + f"`cmap_range` values for {len(self)} graphics" + ) + + transforms = cmap_transform if cmap_transform is not None else itertools.repeat(None) + ranges = cmap_range if np.ndim(cmap_range) == 2 else itertools.repeat(cmap_range) + for graphic, one_cmap, transform, rng in zip(self.graphics, cmap, transforms, ranges): + graphic.cmap = one_cmap + if transform is not None: + graphic.cmap_transform = transform + if rng is not None: + graphic.cmap_range = rng + + @property + def cmap(self): + """get or set the cmap of the graphics in the collection""" + return self._cmap + + @cmap.setter + def cmap(self, value): + self._set_cmap(value, self._cmap_transform, self._cmap_range) + + @property + def cmap_transform(self): + """get or set the cmap_transform of the graphics in the collection""" + return self._cmap_transform + + @cmap_transform.setter + def cmap_transform(self, value): + self._set_cmap(self._cmap, value, self._cmap_range) + + @property + def cmap_range(self): + """get or set the cmap_range of the graphics in the collection""" + return self._cmap_range + + @cmap_range.setter + def cmap_range(self, value): + self._set_cmap(self._cmap, self._cmap_transform, value) + + def add_linear_selector( + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs + ) -> LinearSelector: + """ + Add a :class:`.LinearSelector`. + + Parameters + ---------- + selection: float, optional + initial position of the selector along ``axis``, computed from the data if not given + + padding: float, default 0.0 + extra padding along the orthogonal axis to make the selector easier to grab + + axis: str, default "x" + axis the selector moves along + + **kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + """ + bounds_init, limits, size, center = self._get_linear_selector_init_args(axis, padding) + + if selection is None: + selection = bounds_init[0] + + selector = LinearSelector( + selection=selection, limits=limits, axis=axis, parent=self, **kwargs + ) + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_linear_region_selector( + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, + ) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. + + Parameters + ---------- + selection: (float, float), optional + initial bounds of the region along ``axis``, computed from the data if not given + + padding: float, default 0.0 + extra padding along the orthogonal axis to make the selector easier to grab + + axis: str, default "x" + axis the selector spans + + **kwargs + passed to :class:`.LinearRegionSelector` + + Returns + ------- + LinearRegionSelector + """ + bounds_init, limits, size, center = self._get_linear_selector_init_args(axis, padding) + + if selection is None: + selection = bounds_init + + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=self, + **kwargs, + ) + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_rectangle_selector( + self, selection: tuple[float, float, float, float] = None, **kwargs + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax), computed from the data if not given + + **kwargs + passed to :class:`.RectangleSelector` + + Returns + ------- + RectangleSelector + """ + bbox = self.world_object.get_world_bounding_box() + + xdata = np.concatenate(self.data[:, :, 0]) + xmin, xmax = np.nanmin(xdata), np.nanmax(xdata) + + # y from the world bounding box so that the graphics' offsets, e.g. a stack's, are included + ymin, ymax = bbox[0, 1], bbox[1, 1] + yspan = ymax - ymin + + if selection is None: + # the first quarter along x, the full y extent + selection = (xmin, xmin + (xmax - xmin) / 4, ymin, ymax) + + limits = (xmin, xmax, ymin - yspan / 2, ymax + yspan / 2) + + selector = RectangleSelector( + selection=selection, limits=limits, parent=self, **kwargs + ) + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_polygon_selector( + self, selection: list[tuple[float, float]] = None, **kwargs + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. + + Parameters + ---------- + selection: list of (float, float), optional + initial polygon points; if not given, you draw the polygon by clicking + + **kwargs + passed to :class:`.PolygonSelector` + + Returns + ------- + PolygonSelector + """ + bbox = self.world_object.get_world_bounding_box() + + xdata = np.concatenate(self.data[:, :, 0]) + xmin, xmax = np.nanmin(xdata), np.nanmax(xdata) + + # y from the world bounding box so that the graphics' offsets, e.g. a stack's, are included + ymin, ymax = bbox[0, 1], bbox[1, 1] + yspan = ymax - ymin + + limits = (xmin, xmax, ymin - yspan / 2, ymax + yspan / 2) + + selector = PolygonSelector(selection, limits, parent=self, **kwargs) + self._plot_area.add_graphic(selector, center=False) + + return selector + + def _get_linear_selector_init_args(self, axis: str, padding: float): + bbox = self.world_object.get_world_bounding_box() + axis_index = {"x": 0, "y": 1}[axis] + orthogonal_index = 1 - axis_index + + data = np.concatenate(self.data[:, :, axis_index]) + vmin, vmax = np.nanmin(data), np.nanmax(data) + + # the first quarter along `axis` + bounds = (vmin, vmin + (vmax - vmin) / 4) + limits = (vmin, vmax) + # size and center on the orthogonal axis, from the world bounding box so that the + # graphics' offsets, e.g. a stack's, are included + size = np.ptp(bbox[:, orthogonal_index]) * 1.5 + padding + center = bbox[:, orthogonal_index].mean() + + return bounds, limits, size, center + + +class LineCollection(PositionsCollection): + _child_type = LineGraphic + + +class ScatterCollection(PositionsCollection): + _child_type = ScatterGraphic + + +class ImageCollection(GraphicCollection): + _child_type = ImageGraphic + + +class ImageGrid(ImageCollection): + def __init__( + self, + data, + *, + shape: tuple[int, int] = None, + separation: tuple[float, float] = (0.0, 0.0), + offsets: np.ndarray = None, + **kwargs, + ): + """ + Lay out a collection of images in a grid. + + If ``offsets`` is given it is used directly as the per-image position. Otherwise the images + are placed row-major into a grid of ``shape`` (rows, columns), each cell sized to the + largest image so the rows and columns line up, with ``separation`` world-space gaps between + them. By default there is separation space between the images. + + Parameters + ---------- + data: list of array-like + one image per grid cell + + shape: (int, int), optional + grid (n_rows, n_cols); defaults to a roughly square grid that fits all the images + + separation: (float, float), default (0.0, 0.0) + world-space (row, column) gaps between the images + + offsets: array-like, optional + explicit (x, y, z) offset per image; when given, ``shape`` and ``separation`` are ignored + + **kwargs + passed to :class:`.ImageCollection`, e.g. ``cmap``, ``vmin``, ``vmax`` + """ + super().__init__(data, **kwargs) + n = len(self) + + if offsets is None: + if shape is None: + shape = calculate_figure_shape(n) # roughly square (rows, cols) + if np.prod(shape) < n: + raise ValueError(f"grid shape {shape} has fewer cells than the {n} images") + + rows, cols = np.divmod(np.arange(n), shape[1]) + # cell size = the largest image, via the data accessor, so rows and columns line up + sizes = np.array([image.shape[:2] for image in self.data[:]]) # (rows, cols) per image + cell_height, cell_width = sizes.max(axis=0) + row_sep, col_sep = separation + + offsets = np.zeros((n, 3)) + offsets[:, 0] = cols * (cell_width + col_sep) # x, left to right + offsets[:, 1] = -rows * (cell_height + row_sep) # y, top row first + + self.offsets[:] = offsets + + +class GraphicStack: + """ + Mixin that stacks a collection's graphics along the axes in ``separation_axis``. Each graphic is + offset by its index times the data max plus the ``separation`` gap, so the graphics are evenly + spaced and do not overlap; pass per-graphic ``steps`` to space them individually. Set + ``separation`` or ``separation_axis`` to (re)stack, e.g. after changing the data. + """ + + def __init__( + self, + data, + *, + separation: tuple[float, float, float] = (0.0, 0.0, 0.0), + separation_axis: str = "y", + steps: np.ndarray = None, + **kwargs, + ): + """ + Create a stack of graphics. + + Parameters + ---------- + data: list of array-like + one entry per graphic; its length is the number of graphics in the stack + + separation: (float, float, float), default (0.0, 0.0, 0.0) + (x, y, z) gap between successive graphics, added to the step along the corresponding + stacking axis + + separation_axis: str, default "y" + axes to stack along, any combination of "x", "y", "z", e.g. "y", "xy", "xyz" + + steps: [n_graphics, 3] array-like, optional + per-graphic step along each (x, y, z) axis, i.e. the max each graphic reaches. When + ``None`` (default) a single max over all the data sets one uniform step. When given, each + graphic is offset by the cumulative step of the graphics before it, plus ``separation``. + + **kwargs + passed to the collection, e.g. ``colors``, ``thickness``, ``sizes`` + """ + super().__init__(data, **kwargs) + self._separation = self._check_separation(separation) + self._steps = self._check_steps(steps) + self.separation_axis = separation_axis # (re)stacks + + def _check_separation(self, separation) -> np.ndarray: + separation = np.asarray(separation, dtype=float) + if separation.shape != (3,): + raise ValueError( + f"separation must be an (x, y, z) iterable, got shape {separation.shape}" + ) + return separation + + def _check_steps(self, steps) -> np.ndarray | None: + if steps is None: + return None + steps = np.asarray(steps, dtype=float) + if steps.shape != (len(self), 3): + raise ValueError( + f"steps must be a [n_graphics, 3] array, got shape {steps.shape} for " + f"{len(self)} graphics" + ) + return steps + + @property + def separation(self) -> np.ndarray: + """get or set the (x, y, z) gap added to the step along the stacking axes""" + return self._separation + + @separation.setter + def separation(self, value: tuple[float, float, float]): + self._separation = self._check_separation(value) + self._restack() + + @property + def steps(self) -> np.ndarray | None: + """get or set the per-graphic (x, y, z) steps used to space the stack, ``None`` to auto-determine""" + return self._steps + + @steps.setter + def steps(self, value: np.ndarray | None): + self._steps = self._check_steps(value) + self._restack() + + @property + def separation_axis(self) -> str: + """get or set the axes to stack along, e.g. "y", "xy", "xyz\"""" + return self._separation_axis + + @separation_axis.setter + def separation_axis(self, value: str): + if not set(value).issubset("xyz"): + raise ValueError( + f"separation_axis must be a combination of 'x', 'y', 'z', got {value!r}" + ) + self._separation_axis = value + self._restack() + + def _restack(self): + axes = [{"x": 0, "y": 1, "z": 2}[axis] for axis in self._separation_axis] + offsets = np.zeros((len(self), 3)) + if self._steps is None: + # one max over all the data gives the step to stack by along each stacking axis, + # reduce each graphic first so the whole dataset is never concatenated + step = np.max([view.max(axis=0) for view in self.data[:, :, axes]], axis=0) + offsets[:, axes] = np.arange(len(self))[:, np.newaxis] * (step + self._separation[axes]) + else: + # per-graphic steps: offset each graphic past the previous ones by their cumulative step + offsets[1:, axes] = np.cumsum( + self._steps[:-1, axes] + self._separation[axes], axis=0 + ) + self.offsets[:] = offsets + + +class LineStack(GraphicStack, LineCollection): + pass + + +class ScatterStack(GraphicStack, ScatterCollection): + pass diff --git a/fastplotlib/graphics/_jagged_array.py b/fastplotlib/graphics/_jagged_array.py new file mode 100644 index 000000000..bc081314f --- /dev/null +++ b/fastplotlib/graphics/_jagged_array.py @@ -0,0 +1,378 @@ +import itertools +import operator +from collections.abc import Iterable + +import numpy as np + +from .features import BufferManager, TextureArray, TextureArrayVolume +from .features._base import GraphicFeature, GraphicFeatureEvent +from .features.utils import is_single_color +from .features.types import ColorLike, MultiColorLike, ColormapLike +from ._base import Graphic + + +# array-buffer features are indexed along the datapoint axis (vertex buffers, image/volume +# textures), as opposed to uniforms which hold a single value for the whole graphic +ARRAY_BUFFER_FEATURES = (BufferManager, TextureArray, TextureArrayVolume) + + +class CollectionFeatureAccessor(GraphicFeature): + """ + Get and set a feature across the graphics of a collection, along the graphic axis. + + Manages features that are one value per graphic, such as ``thickness`` and + ``edge_width``. :class:`.JaggedCollectionFeature` and :class:`.Cmap` subclass this for the + per-datapoint features and for colormaps. + + Subclasses ``GraphicFeature`` so a collection can register handlers on the accessor; + ``__setitem__`` emits one collection-level ``GraphicFeatureEvent``. + """ + + def __init__(self, graphics: np.ndarray, feature: str, value_ndim: int = 0, feature_name: str = None): + """ + Parameters + ---------- + graphics: np.ndarray of Graphic + object array of the graphics in the collection; the ``GraphicCollection`` creates + and maintains it so it can be indexed directly by the graphic-axis key + + feature: str + name of the graphic feature on each graphic, used with ``getattr``/``setattr``, + e.g. "data", "colors", "offset" + + value_ndim: int + number of dimensions of a value that applies to every graphic: a scalar for + ``thickness``/``sizes`` -> 0, a ``[3]`` for ``offset`` -> 1, one ``[n_datapoints, 3]`` + for ``data`` -> 2. A value with more dimensions has the graphics along its first axis. + + feature_name: str, optional + name this accessor is exposed as on the collection and used for its events; + defaults to ``feature``. Differs when a per-graphic feature is renamed to avoid + clashing with the collection's own feature, e.g. ``offset`` -> ``offsets``. + + """ + feature_name = feature_name if feature_name is not None else feature + super().__init__(property_name=feature_name) + self._graphics = graphics + self._feature = feature + self._feature_name = feature_name + self._value_ndim = value_ndim + + def _parse_feature_value(self, value, buffer_key: tuple): + # base features pass the value through; subclasses parse colors, jagged arrays, etc. + return value + + def _is_single_value(self, value) -> bool: + # a single value goes to every graphic; a value with the graphics along its first + # axis does not. subclasses refine (e.g. a single color for ``colors``) + if isinstance(value, np.ndarray) and value.dtype == object: + return False + return np.ndim(value) <= self._value_ndim + + def _broadcast_over_graphics(self, value, n_graphics: int): + # one value goes to every graphic; otherwise the first axis is the graphics axis. + # used by both the setters and the collection constructor + if hasattr(value, "__next__"): + # an iterator (itertools.repeat/cycle, a generator, ...): one value per graphic + return itertools.islice(value, n_graphics) + + if self._is_single_value(value): + return itertools.repeat(value) + + if len(value) != n_graphics: + raise IndexError( + f"got {len(value)} values along the first axis for {n_graphics} graphics" + ) + return value # value[i] for graphic i, views along the first axis + + def _emit_event(self, key, value): + # one collection-level event; the collection registers handlers on this accessor + if len(self._event_handlers) < 1: + return + event = GraphicFeatureEvent(self._feature_name, info={"key": key, "value": value}) + self._call_event_handlers(event) + + def _apply_operator(self, func): + # one value per graphic, so the read is a plain array numpy operates on directly + return func(self[:]) + + def __getitem__(self, graphic_key): + if isinstance(graphic_key, (int, np.integer)): + return getattr(self._graphics[graphic_key], self._feature) + + selected = self._graphics[graphic_key] + return np.array([getattr(g, self._feature) for g in selected]) + + def __setitem__(self, graphic_key, value): + # a single graphic -> set it directly + if isinstance(graphic_key, (int, np.integer)): + setattr(self._graphics[graphic_key], self._feature, value) + self._emit_event(graphic_key, value) + return + + # broadcast the value over the selected graphics, then set it on each + selected = self._graphics[graphic_key] + for graphic, graphic_value in zip( + selected, self._broadcast_over_graphics(value, len(selected)) + ): + setattr(graphic, self._feature, graphic_value) + self._emit_event(graphic_key, value) + + def __len__(self): + return len(self._graphics) + + def __repr__(self): + return f"{self.__class__.__name__} of <{self._feature}> across {len(self)} graphics" + + +class JaggedCollectionFeature(CollectionFeatureAccessor): + """ + Get and set a per-datapoint feature (``data``, ``colors``, ``sizes``, ...) across a + collection. Indexing follows numpy broadcasting as if the feature were a rectangular + array, except each graphic may have a different number of datapoints (jagged). + + A graphic holds the feature per-vertex or uniform + """ + + def _is_array_buffer(self, feature) -> bool: + # a feature indexed along the datapoint axis, as opposed to a uniform (see ARRAY_BUFFER_FEATURES) + return isinstance(feature, ARRAY_BUFFER_FEATURES) + + def _feature_value(self, graphic: Graphic) -> np.ndarray: + # a graphic's whole feature value: the array buffer, or the uniform value + feature = getattr(graphic, self._feature) + if self._is_array_buffer(feature): + return feature.value + return np.asarray(feature) + + def _get(self, graphic, buffer_key: tuple): + feature = getattr(graphic, self._feature) + if self._is_array_buffer(feature): + # a view; the buffer needs a first axis, so `()` becomes `[:]` + return feature[buffer_key or (slice(None),)] + value = np.asarray(feature) + return value[buffer_key] if buffer_key else value + + def _set(self, graphic, buffer_key: tuple, value): + # set one graphic's feature at the buffer_key (within-graphic) key + feature: BufferManager = getattr(graphic, self._feature) + if self._is_array_buffer(feature): + # per-datapoint: write into the buffer, numpy broadcasts `value` within the graphic. + # a plain slice (not a tuple) so a whole-graphic set parses color specs + if buffer_key == (): + # was sliced with graphic.feature[:] = value + # we need it to call feature.set_value() rather than __setitem__ so buffer is resized if required + feature.set_value(graphic, value) + else: + feature[buffer_key or slice(None)] = value + return + # uniform: no datapoint axis, so set the whole value through the graphic's property + if not buffer_key: + setattr(graphic, self._feature, value) + return + # a component index into a uniform value (e.g. one channel): read it, change that + # component, write the whole value back + modified = np.array(feature, dtype=float) + modified[buffer_key] = value + setattr(graphic, self._feature, modified) + + def _feature_ndim(self) -> int: + # graphic axis + a graphic's dimensions + return self._feature_value(self._graphics[0]).ndim + 1 + + def _verify_homogenous_buffer_type(self, graphics): + # every selected graphic must use either a uniform or an array buffer, not a mix + buffer = self._is_array_buffer(getattr(graphics[0], self._feature)) + if not all(self._is_array_buffer(getattr(g, self._feature)) == buffer for g in graphics): + raise TypeError( + f"the selected graphics mix uniform and per-vertex '{self._feature}'; " + f"use either uniform or vertex for all graphics, not a mix" + ) + + def _split_key(self, key): + # peel the graphic index off axis 0, expanding a trailing/leading ellipsis first + if not isinstance(key, tuple): + return key, () + + if any(k is Ellipsis for k in key): + used = sum(k is not Ellipsis and k is not None for k in key) + fill = (slice(None),) * (self._feature_ndim() - used) + expanded = () + for k in key: + expanded += fill if k is Ellipsis else (k,) + key = expanded + + return key[0], key[1:] + + def _parse_feature_value(self, value, buffer_key: tuple): + # a sequence of per-graphic values; an object array when they are jagged (differing + # shapes), otherwise a regular array. buffer_key is used by the ColorArray subclass + if isinstance(value, (list, tuple)): + if len({np.shape(v) for v in value}) > 1: + return np.array(value, dtype=object) + return np.asarray(value) + return value + + def _apply_operator(self, func): + # per-datapoint feature: apply to each graphic's view (numpy broadcasting within the + # graphic). an object array of per-graphic results, no stacking (no copy) so it stays + # jagged-aware + views = self[:] + out = np.empty(len(views), dtype=object) + for i, view in enumerate(views): + out[i] = func(view) + return out + + def __getitem__(self, key): + # split off the graphic axis; `buffer_key` is the key applied within each graphic + graphic_key, buffer_key = self._split_key(key) + + # a single graphic -> return its value/view directly + if isinstance(graphic_key, (int, np.integer)): + return self._get(self._graphics[graphic_key], buffer_key) + + # multiple graphics -> an object array holding each graphic's view (no copy, no stacking) + selected = self._graphics[graphic_key] + out = np.empty(len(selected), dtype=object) + for i, graphic in enumerate(selected): + out[i] = self._get(graphic, buffer_key) + return out + + def __setitem__(self, key, value): + # split off the graphic axis; `buffer_key` is the key applied within each graphic + graphic_key, buffer_key = self._split_key(key) + # lists become object arrays, ColorArray subclass parses color-likes to an RGBA array + value = self._parse_feature_value(value, buffer_key) + + # a single graphic -> set it directly + if isinstance(graphic_key, (int, np.integer)): + self._set(self._graphics[graphic_key], buffer_key, value) + self._emit_event(key, value) + return + + # multiple graphics: they must all be the same mode, then split `value` along the + # graphic axis and hand each graphic its piece + selected = self._graphics[graphic_key] + + if len(selected) < 1: + # nothing to set + return + + self._verify_homogenous_buffer_type(selected) + for graphic, graphic_value in zip( + selected, self._broadcast_over_graphics(value, len(selected)) + ): + self._set(graphic, buffer_key, graphic_value) + self._emit_event(key, value) + + +class CollectionColors(JaggedCollectionFeature): + """ + :class:`.JaggedCollectionFeature` for ``colors``. Parses color specs to RGBA before the graphic-axis + split, unless the key indexes the RGBA axis, in which case the value is used as-is. + """ + + def _parse_feature_value(self, value: ColorLike | MultiColorLike, buffer_key: tuple): + # the RGBA axis is the last one; when the buffer_key reaches it the value is raw numbers + if buffer_key and len(buffer_key) >= self._feature_ndim() - 1: + return super()._parse_feature_value(value, buffer_key) + # a color spec, or a sequence of them; each graphic parses its own colors + return value + + def _is_single_value(self, value) -> bool: + # a single color, or a scalar (e.g. one raw channel value), goes to every graphic + if isinstance(value, (list, tuple)): + return is_single_color(value) + return np.ndim(value) == 0 or is_single_color(value) + + +class CollectionCmap(CollectionFeatureAccessor): + """ + :class:`.CollectionFeatureAccessor` for per-graphic colormaps: ``cmap[graphic_key]`` gets and sets each + selected graphic's colormap, broadcasting one colormap to all selected graphics or a + sequence one per graphic. + + Only for `PositionsCollection`, not for image collections. + + Indexing requires per-graphic colormaps. A single colormap across the whole collection, + which colors each graphic one color by its index, is set through the collection's + ``cmap`` property setter rather than here. + """ + + def __getitem__(self, graphic_key): + if isinstance(graphic_key, (int, np.integer)): + cmaps = [getattr(self._graphics[graphic_key], self._feature)] + self._verify_cmap_mode(cmaps) + return cmaps[0] + + cmaps = [getattr(g, self._feature) for g in self._graphics[graphic_key]] + self._verify_cmap_mode(cmaps) + out = np.empty(len(cmaps), dtype=object) + out[:] = cmaps + return out + + def __setitem__(self, graphic_key, value: ColormapLike | Iterable[ColormapLike]): + if isinstance(graphic_key, (int, np.integer)): + graphic = self._graphics[graphic_key] + self._verify_cmap_mode([getattr(graphic, self._feature)]) + setattr(graphic, self._feature, value) + self._emit_event(graphic_key, value) + return + + selected = self._graphics[graphic_key] + self._verify_cmap_mode([getattr(g, self._feature) for g in selected]) + if isinstance(value, (list, tuple, np.ndarray)): + if len(value) != len(selected): + raise ValueError(f"expected {len(selected)} colormaps, got {len(value)}") + cmaps = value + else: + # one colormap for all selected graphics + cmaps = itertools.repeat(value) + + for graphic, cmap in zip(selected, cmaps): + setattr(graphic, self._feature, cmap) + self._emit_event(graphic_key, value) + + def _verify_cmap_mode(self, cmaps): + if any(cmap is None for cmap in cmaps): + raise TypeError( + "some selected graphics have no per-graphic colormap; set colormaps per " + "graphic first (a single colormap across the whole collection is set with " + "`collection.cmap = ...`)" + ) + + +def _binary_operator(op): # collection other + def method(self, other): + return self._apply_operator(lambda view: op(view, other)) + + return method + + +def _reflected_operator(op): # other collection + def method(self, other): + return self._apply_operator(lambda view: op(other, view)) + + return method + + +def _unary_operator(op): + def method(self): + return self._apply_operator(op) + + return method + + +# comparison, arithmetic, and bitwise operators act on the values across the graphics like a numpy +# array, jagged along the datapoint axis, e.g. `collection.thickness < 3` or `collection.colors == +# "r"`; useful for masking the graphic axis, e.g. `collection.colors[collection.thickness < 3] = "r"` +for _name in ("lt", "le", "eq", "ne", "gt", "ge", "add", "sub", "mul", "truediv", "floordiv", + "mod", "pow", "matmul", "and_", "or_", "xor", "lshift", "rshift"): + setattr(CollectionFeatureAccessor, f"__{_name.rstrip('_')}__", _binary_operator(getattr(operator, _name))) + +for _name in ("add", "sub", "mul", "truediv", "floordiv", "mod", "pow", "matmul", + "and_", "or_", "xor", "lshift", "rshift"): + setattr(CollectionFeatureAccessor, f"__r{_name.rstrip('_')}__", _reflected_operator(getattr(operator, _name))) + +for _name in ("neg", "pos", "abs", "invert"): + setattr(CollectionFeatureAccessor, f"__{_name}__", _unary_operator(getattr(operator, _name))) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 426079730..74d7588ce 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -1,8 +1,8 @@ -from numbers import Real -from typing import Any, Sequence, Literal -from warnings import warn +from collections.abc import Iterable +from typing import Any, Literal import numpy as np +import cmap as cmap_lib import pygfx from ._base import Graphic @@ -11,16 +11,78 @@ VertexColors, UniformColor, VertexCmap, + VertexCmapTransform, + VertexCmapRange, SizeSpace, ) +from .features.utils import is_single_color +from .features.types import ColorLike, MultiColorLike, ColormapLike class PositionsGraphic(Graphic): """Base class for LineGraphic and ScatterGraphic""" + # features shared by all positions graphics; subclasses add their own in __init_subclass__ + _features = { + "data": VertexPositions, + "colors": (VertexColors, UniformColor), + "cmap": (VertexCmap, None), # none if UniformColor + "cmap_transform": (VertexCmapTransform, None), + "cmap_range": (VertexCmapRange, None), + "size_space": SizeSpace, + } + # the feature used to manage a per-vertex color buffer, subclasses may override _VertexColorsCls = VertexColors + def __init_subclass__(cls, **kwargs): + # accumulate the parent's features, then this subclass's own additions/overrides + inherited = {} + for base in cls.__bases__: + inherited.update(getattr(base, "_features", {})) + # cls.__dict__, not cls._features, so this is only what the subclass declares (not inherited) + own = cls.__dict__.get("_features", {}) + cls._features = {**inherited, **own} + super().__init_subclass__(**kwargs) # Graphic.__init_subclass__ adds the common features + + def __init__( + self, + data: Any, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | Iterable[int | float] | None = None, + cmap_range: tuple[float, float] | None = None, + size_space: str = "screen", + *args, + **kwargs, + ): + if isinstance(data, VertexPositions): + self._data = data + else: + self._data = VertexPositions(data) + + if cmap_transform is not None and cmap is None: + raise ValueError("must pass `cmap` if passing `cmap_transform`") + + # defaults are None + self._cmap = None + self._cmap_transform = None + self._cmap_range = None + self._colors = None + + if cmap is not None: + # if a cmap is specified it overrides colors argument + self._cmap, self._cmap_transform, self._cmap_range = self._create_cmap_buffers( + cmap, cmap_transform, cmap_range + ) + + else: + # no cmap given + self._colors = self._create_colors_buffer(colors) + + self._size_space = SizeSpace(size_space) + super().__init__(*args, **kwargs) + @property def data(self) -> VertexPositions: """ @@ -39,7 +101,7 @@ def data(self, value): self._data.set_value(self, value) @property - def colors(self) -> VertexColors | pygfx.Color: + def colors(self) -> VertexColors | pygfx.Color | None: """Get or set the colors""" if isinstance(self._colors, VertexColors): return self._colors @@ -48,76 +110,114 @@ def colors(self) -> VertexColors | pygfx.Color: return self._colors.value @colors.setter - def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): - self._colors.set_value(self, value) + def colors(self, value: ColorLike | MultiColorLike): + # currently per-vertex: stay per-vertex, broadcasting a single color or setting a sequence + if isinstance(self._colors, VertexColors): + self._colors.set_value(self, value) + return - @property - def color_mode(self) -> Literal["uniform", "vertex"]: - """ - Get or set the color mode. Note that after setting the color_mode, you will have to set the `colors` - as well for switching between 'uniform' and 'vertex' modes. - """ - return self.world_object.material.color_mode + # currently uniform: a single color stays uniform, a sequence switches to per-vertex + if isinstance(self._colors, UniformColor) and is_single_color(value): + self._colors.set_value(self, value) + return - @color_mode.setter - def color_mode(self, mode: Literal["uniform", "vertex"]): - valid = ("uniform", "vertex") - if mode not in valid: - raise ValueError(f"`color_mode` must be one of : {valid}") - if mode == "vertex" and isinstance(self._colors, UniformColor): - # uniform -> vertex - # need to make a new vertex buffer and get rid of uniform buffer - new_colors = self._create_colors_buffer(self._colors.value, "vertex") - # we can't clear world_object.material.color so just set the colors buffer on the geometry - # this doesn't really matter anyways since the lingering uniform color takes up just a few bytes - self.world_object.geometry.colors = new_colors._fpl_buffer - - elif mode == "uniform" and isinstance(self._colors, VertexColors): - # vertex -> uniform - # use first vertex color and spit out a warning - warn( - "changing `color_mode` from vertex -> uniform, will use first vertex color " - "for the uniform and discard the remaining color values" - ) - new_colors = self._create_colors_buffer(self._colors.value[0], "uniform") - self.world_object.geometry.colors = None - self.world_object.material.color = new_colors.value + # otherwise switch: from uniform to per-vertex, or away from a cmap + old_mode = self._color_mode + + if self._colors is not None: + self._colors.clear_event_handlers() - # clear out cmap + if self._cmap is not None: self._cmap.clear_event_handlers() + self._cmap_transform.clear_event_handlers() self._cmap = None + self._cmap_transform = None - else: - # no change, return - return - - # restore event handlers onto the new colors feature - new_colors._event_handlers[:] = self._colors._event_handlers - self._colors.clear_event_handlers() - # this should trigger gc - self._colors = new_colors + # create the new buffer and set + self._colors = self._create_colors_buffer(value) - # this is created so that cmap can be set later if isinstance(self._colors, VertexColors): - self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) + self.world_object.geometry.colors = self._colors._fpl_buffer + self.world_object.material.color_mode = "vertex" + self.world_object.material.color = (1, 1, 1, 1) # back to default, material.color cannot be None + else: + self.world_object.material.color = self._colors.value + self.world_object.material.color_mode = "uniform" + self.world_object.geometry.colors = None + + if old_mode == "vertex_map": + # clear cmap world object stuff: map and texcoords + self.world_object.material.map = None + self.world_object.geometry.texcoords = None - self.world_object.material.color_mode = mode + @property + def _color_mode(self) -> pygfx.enums.ColorMode: + """ + Get the current color mode. + """ + return self.world_object.material.color_mode @property - def cmap(self) -> VertexCmap: + def cmap(self) -> cmap_lib.Colormap | None: """ - Control the cmap or cmap transform + Get or set the colormap For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ """ - return self._cmap + if self._cmap is not None: + return self._cmap.value @cmap.setter - def cmap(self, name: str): - if self.color_mode == "uniform": - raise ValueError("cannot use `cmap` with `color_mode` = 'uniform'") + def cmap(self, value: cmap_lib.ColormapLike): + if self._cmap is not None: + self._cmap.set_value(self, value) + return + + # need to create cmap features + self._cmap, self._cmap_transform, self._cmap_range = self._create_cmap_buffers( + value, self.cmap_transform, self.cmap_range + ) + + # set stuff on wo + self.world_object.material.map = self._cmap.value.to_pygfx() + self.world_object.geometry.texcoords = pygfx.Buffer(self._cmap_transform.value) + self.world_object.material.color_mode = "vertex_map" + self.world_object.material.maprange = self._cmap_range.value - self._cmap[:] = name + # clear any other color info + if self._colors is not None: + self._colors.clear_event_handlers() + self.world_object.geometry.colors = None + self.world_object.material.color = (1, 1, 1, 1) # back to default, material.color cannot be None + self._colors = None + + @property + def cmap_transform(self) -> np.ndarray | None: + # TODO: if a usecase arises in the future we can make this a BufferManager instead of a simple GraphicFeature + if self._cmap_transform is not None: + return self._cmap_transform.value + + @cmap_transform.setter + def cmap_transform(self, value: np.ndarray): + if self._cmap is None: + raise AttributeError("Must set `cmap` before setting `cmap_transform`") + + self._cmap_transform.set_value(self, value) + # new default range from the new transform's (min, max) + transform = self._cmap_transform.value + self._cmap_range.set_value(self, (transform.min(), transform.max())) + + @property + def cmap_range(self) -> tuple[float, float] | None: + """Get or set the (min, max) of the cmap_transform that is mapped onto the colormap""" + if self._cmap_range is not None: + return self._cmap_range.value + + @cmap_range.setter + def cmap_range(self, value: tuple[float, float]): + if self._cmap is None: + raise AttributeError("Must set `cmap` before setting `cmap_range`") + self._cmap_range.set_value(self, value) @property def size_space(self): @@ -132,134 +232,85 @@ def size_space(self): def size_space(self, value: str): self._size_space.set_value(self, value) - def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColors: - # creates either a UniformColor or VertexColors based on the given `colors` and `color_mode` - # if `color_mode` = "auto", returns {UniformColor | VertexColor} based on what the `colors` arg represents - # if `color_mode` = "uniform", it verifies that the user `colors` input represents just 1 color - # if `color_mode` = "vertex", always returns VertexColors regardless of whether `colors` represents >= 1 colors - - if isinstance(colors, VertexColors): - if color_mode == "uniform": - raise ValueError( - "if a `VertexColors` instance is provided for `colors`, " - "`color_mode` must be 'vertex' or 'auto', not 'uniform'" - ) + def _create_colors_buffer(self, colors) -> UniformColor | VertexColors: + # creates either a UniformColor or VertexColors based on the given `colors` + + if isinstance(colors, (VertexColors, UniformColor)): # share buffer with existing colors instance - new_colors = colors - # blank colormap instance - self._cmap = VertexCmap(new_colors, cmap_name=None, transform=None) + return colors - else: - # determine if a single or multiple colors were passed and decide color mode - if isinstance(colors, (pygfx.Color, str)) or ( - len(colors) in [3, 4] and all(isinstance(v, Real) for v in colors) - ): - # one color specified as a str or pygfx.Color, or one color specified with RGB(A) values - if color_mode in ("auto", "uniform"): - new_colors = UniformColor(colors) - else: - new_colors = self._VertexColorsCls( - colors, n_colors=self._data.value.shape[0] - ) - - elif all(isinstance(c, (str, pygfx.Color)) for c in colors): - # sequence of colors - if color_mode == "uniform": - raise ValueError( - "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " - "`color_mode` = 'auto' or 'vertex' for multiple colors." - ) - new_colors = self._VertexColorsCls( - colors, n_colors=self._data.value.shape[0] - ) - - elif len(colors) > 4: - # sequence of multiple colors, must again ensure color_mode is not uniform - if color_mode == "uniform": - raise ValueError( - "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " - "`color_mode` = 'auto' or 'vertex' for multiple colors." - ) - new_colors = self._VertexColorsCls( - colors, n_colors=self._data.value.shape[0] - ) - else: - raise ValueError( - "`colors` must be a str, pygfx.Color, array, list or tuple indicating an RGB(A) color, or a " - "sequence of str, pygfx.Color, or array of shape [n_datapoints, 3 | 4]" - ) - - return new_colors + # determine if a single or multiple colors were passed and decide color mode + if is_single_color(colors): + # one color specified as a str or pygfx.Color, or one color specified with RGB(A) values + return UniformColor(colors) - def __init__( - self, - data: Any, - colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", - cmap: str | VertexCmap = None, - cmap_transform: np.ndarray = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", - size_space: str = "screen", - *args, - **kwargs, - ): - if isinstance(data, VertexPositions): - self._data = data else: - self._data = VertexPositions(data) + # sequence of colors + return self._VertexColorsCls( + colors, n_colors=self._data.value.shape[0] + ) - if cmap_transform is not None and cmap is None: - raise ValueError("must pass `cmap` if passing `cmap_transform`") + def _create_cmap_buffers( + self, cmap, cmap_transform, cmap_range + ) -> tuple[VertexCmap, VertexCmapTransform, VertexCmapRange]: + cmap = VertexCmap(cmap) + + if cmap_transform is None: + # default transform is just a linspace along the datapoints + # this gets interpolated based on the number of datapoints + cmap_transform = np.array([0, 1]) + + # the raw transform is stored as texcoords; the material's maprange maps it onto the colormap + cmap_transform = VertexCmapTransform( + cmap_transform, + # use buffer array length since len(self.data) returns half for inflines + n_datapoints=len(self.data.buffer.data) + ) - valid = ("auto", "uniform", "vertex") + if cmap_range is None: + # default range is the transform's own (min, max), like the default [0, 1] transform + cmap_range = cmap_transform.value.min(), cmap_transform.value.max() - # default _cmap is None - self._cmap = None + cmap_range = VertexCmapRange(cmap_range) - if color_mode not in valid: - raise ValueError(f"`color_mode` must be one of {valid}") + return cmap, cmap_transform, cmap_range - if cmap is not None: - # if a cmap is specified it overrides colors argument - if color_mode == "uniform": - raise ValueError( - "if a `cmap` is provided, `color_mode` must be 'vertex' or 'auto', not 'uniform'" - ) - - if isinstance(cmap, str): - # make colors from cmap - if isinstance(colors, VertexColors): - # share buffer with existing colors instance for the cmap - self._colors = colors - else: - # create vertex colors buffer - self._colors = self._VertexColorsCls( - "w", n_colors=self._data.value.shape[0] - ) - # make cmap using vertex colors buffer - self._cmap = VertexCmap( - self._colors, - cmap_name=cmap, - transform=cmap_transform, - ) - elif isinstance(cmap, VertexCmap): - # use existing cmap instance - self._cmap = cmap - self._colors = cmap._vertex_colors - - else: - raise TypeError( - "`cmap` argument must be a cmap name or an existing `VertexCmap` instance" - ) + def _get_material_kwargs(self) -> dict: + # material kwargs shared by all positions graphics; the color mode is + # determined by the current color/cmap state, subclasses add their own kwargs + kwargs = dict( + pick_write=True, + aa=self.alpha_mode in ("blend", "weighted_blend"), + depth_compare="<=", + ) + + if self._cmap is not None: + kwargs["color_mode"] = "vertex_map" + kwargs["map"] = self.cmap.to_pygfx() + kwargs["maprange"] = self._cmap_range.value + elif isinstance(self._colors, UniformColor): + kwargs["color_mode"] = "uniform" + kwargs["color"] = self.colors else: - # no cmap given - self._colors = self._create_colors_buffer(colors, color_mode) + kwargs["color_mode"] = "vertex" - # this is created so that cmap can be set later - if isinstance(self._colors, VertexColors): - self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) + return kwargs - self._size_space = SizeSpace(size_space) - super().__init__(*args, **kwargs) + def _get_geo_kwargs(self) -> dict: + # geometry kwargs shared by all positions graphics, subclasses add their own kwargs + kwargs = dict(positions=self._data._fpl_buffer) + + if self._cmap is not None: + # cmap overrides colors, uses per-vertex texcoords into the colormap + kwargs["texcoords"] = pygfx.Buffer(self._cmap_transform.value) + elif isinstance(self._colors, VertexColors): + kwargs["colors"] = self._colors._fpl_buffer + # uniform color needs no geometry buffer + + return kwargs + + def _make_geo(self) -> pygfx.Geometry: + return pygfx.Geometry(**self._get_geo_kwargs()) def format_pick_info(self, pick_info: dict) -> str: index = pick_info["vertex_index"] @@ -268,3 +319,7 @@ def format_pick_info(self, pick_info: dict) -> str: ) return info + + def __len__(self) -> int: + """number of datapoints""" + return len(self.data) diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index cc1840a56..dc274f7be 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -4,6 +4,8 @@ SizeSpace, VertexPositions, VertexCmap, + VertexCmapTransform, + VertexCmapRange, InfLineAxisData, InfLineColors, ) @@ -30,7 +32,6 @@ from ._image import ( TextureArray, TextureYUV, - TupleYUV, ImageCmap, ImageGamma, ImageVmin, @@ -60,7 +61,6 @@ GraphicFeature, BufferManager, GraphicFeatureEvent, - to_gpu_supported_dtype, ) from ._text import ( @@ -85,6 +85,8 @@ "SizeSpace", "VertexPositions", "VertexCmap", + "VertexCmapTransform", + "VertexCmapRange", "InfLineAxisData", "InfLineColors", "MeshIndices", @@ -102,7 +104,6 @@ "UniformSize", "TextureArray", "TextureYUV", - "TupleYUV", "ImageCmap", "ImageGamma", "ImageVmin", @@ -135,5 +136,7 @@ "AlphaMode", "Visible", "Deleted", + "GraphicFeature", + "BufferManager", "GraphicFeatureEvent", ] diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 68fe54c33..b6879b1b0 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -50,6 +50,11 @@ def __init__(self, type: str, info: dict): class GraphicFeature: + # number of dimensions of one graphic's value for this feature + # e.g. ``Thickness`` is 0 (a scalar), ``Offset`` is 1 ([x, y, z]), ``VertexColors`` is 2 ([n_datapoints, RGBA]) + # used by graphic collections to broadcast a value across graphics + ndim: int = 0 + def __init__(self, property_name: str, **kwargs): self._property_name = property_name self._event_handlers = list() @@ -191,7 +196,7 @@ def __setitem__(self, key, value): def _parse_offset_size( self, - key: int | slice | np.ndarray[int | bool] | list[bool | int], + key: int | slice | np.ndarray[tuple[int, ...], np.dtype[np.integer | np.bool]] | list[bool | int], upper_bound: int, ): """ @@ -270,7 +275,7 @@ def _parse_offset_size( def _update_range( self, key: ( - int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...] + int | slice | np.ndarray[tuple[int, ...], np.dtype[np.integer | np.bool]] | list[bool | int] | tuple[slice, ...] ), ): """ @@ -285,7 +290,7 @@ def _update_range( raise TypeError("ellipses not supported for indexing buffers") # if multiple dims are sliced, we only need the key for # the first dimension corresponding to n_datapoints - key: int | np.ndarray[int | bool] | slice = key[0] + key: int | np.ndarray[tuple[int, ...], np.dtype[np.integer | np.bool]] | slice = key[0] if isinstance(key, slice): if key == slice(None): diff --git a/fastplotlib/graphics/features/_common.py b/fastplotlib/graphics/features/_common.py index 3b3e0be7d..301a485b8 100644 --- a/fastplotlib/graphics/features/_common.py +++ b/fastplotlib/graphics/features/_common.py @@ -35,6 +35,8 @@ def set_value(self, graphic, value: str): class Offset(GraphicFeature): + ndim = 1 + event_info_spec = [ { "dict key": "value", @@ -82,6 +84,8 @@ def set_value(self, graphic, value: np.ndarray | Sequence[float]): class Rotation(GraphicFeature): + ndim = 1 + event_info_spec = [ { "dict key": "value", @@ -131,6 +135,8 @@ def set_value(self, graphic, value: np.ndarray | Sequence[float]): class Scale(GraphicFeature): + ndim = 1 + event_info_spec = [ { "dict key": "value", @@ -200,7 +206,8 @@ def set_value(self, graphic, value: float): if wo.material is not None: wo.material.opacity = value - if "Image" in graphic.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in graphic.__class__.__name__ and not hasattr(graphic, "graphics"): # Image and ImageVolume use tiling and share one material graphic._material.opacity = value @@ -231,7 +238,8 @@ def set_value(self, graphic, value: str): if wo.material is not None: wo.alpha_mode = value - if "Image" in graphic.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in graphic.__class__.__name__ and not hasattr(graphic, "graphics"): # Image and ImageVolume use tiling and share one material graphic._material.alpha_mode = value diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 1d9092de5..8518e8818 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -1,6 +1,6 @@ from itertools import product from math import ceil -from typing import Literal, TypeAlias +from typing import Literal from warnings import warn import cmap as cmap_lib @@ -13,10 +13,8 @@ from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance from .utils import get_element_format_from_numpy_array -from ...utils import get_cmap_texture, ColorspacesRGB, ColorspacesYUV, ColorRange - -TupleYUV: TypeAlias = tuple[NDArray[np.uint8], NDArray[np.uint8], NDArray[np.uint8]] - +from ...utils import ColorspacesRGB, ColorspacesYUV, ColorRange +from .types import TupleYUV, ColormapLike class TextureArray(GraphicFeature): """ @@ -519,21 +517,33 @@ class ImageCmap(GraphicFeature): ] def __init__(self, value: str, property_name: str = "cmap"): - self._value = value - self.texture = get_cmap_texture(value) + self._value = cmap_lib.Colormap(value) super().__init__(property_name=property_name) @property - def value(self) -> str: + def value(self) -> cmap_lib.Colormap: return self._value @block_reentrance - def set_value(self, graphic, value: str): - colormap = pygfx.cm.create_colormap(cmap_lib.Colormap(value).lut()) - graphic._material.map = colormap + def set_value(self, graphic, value: ColormapLike | cmap_lib.Colormap): + self._value = cmap_lib.Colormap(value) + + # get the new TextureMap + _map = self._value.to_pygfx() + + # set the cmap interpolation from the current value on the graphic + _map.min_filter = graphic._cmap_interpolation.value + _map.mag_filter = graphic._cmap_interpolation.value + _map.mipmap_filter = graphic._cmap_interpolation.value + + # set the wrap mode we use for images + _map.wrap_s = "clamp-to-edge" + _map.wrap_t = "clamp-to-edge" + + # set new TextureMap on the graphic + graphic._material.map = _map graphic._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1)) - self._value = value event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_line.py b/fastplotlib/graphics/features/_line.py index a29e0ec97..05b2829c5 100644 --- a/fastplotlib/graphics/features/_line.py +++ b/fastplotlib/graphics/features/_line.py @@ -61,6 +61,9 @@ def set_value(self, graphic, value: float): class DashPattern(GraphicFeature): + # a single dash pattern is a 1D sequence, e.g. ``()`` or ``(5, 2)`` + ndim = 1 + event_info_spec = [ { "dict key": "value", diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 2ede10b8b..615955341 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -2,10 +2,9 @@ import numpy as np import pygfx +import cmap as cmap_lib + -from ...utils import ( - parse_cmap_values, -) from ._base import ( GraphicFeature, BufferManager, @@ -14,9 +13,12 @@ block_reentrance, ) from .utils import parse_colors, is_single_color +from .types import ColorLike, MultiColorLike class VertexColors(BufferManager): + ndim = 2 + event_info_spec = [ { "dict key": "key", @@ -36,17 +38,17 @@ class VertexColors(BufferManager): ] def __init__( - self, - colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], - n_colors: int, - property_name: str = "colors", + self, + colors: ColorLike | MultiColorLike, + n_colors: int, + property_name: str = "colors", ): """ - Manages the vertex color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` + Manages the vertex color buffer for :class:`PositionsGraphic` Parameters ---------- - colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str] + colors: ColorLike | MultiColorLike specify colors as a single human-readable string, RGBA array, or an iterable of strings or RGBA arrays @@ -59,16 +61,16 @@ def __init__( super().__init__(data=data, property_name=property_name) def set_value( - self, - graphic, - value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], + self, + graphic, + value: ColorLike | MultiColorLike, ): """set the entire array, create new buffer if necessary""" # a sequence of colors whose length differs from the current buffer requires a new buffer if ( - isinstance(value, (np.ndarray, list, tuple)) - and not is_single_color(value) - and self.buffer.data.shape[0] != len(value) + isinstance(value, (np.ndarray, list, tuple)) + and not is_single_color(value) + and self.buffer.data.shape[0] != len(value) ): # parse the new colors new_colors = parse_colors(value, len(value)) @@ -97,9 +99,9 @@ def set_value( @block_reentrance def __setitem__( - self, - key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], - user_value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], + self, + key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], + user_value: ColorLike | MultiColorLike, ): user_key = key @@ -181,6 +183,8 @@ def __len__(self): class UniformColor(GraphicFeature): + ndim = 1 + event_info_spec = [ { "dict key": "value", @@ -190,9 +194,9 @@ class UniformColor(GraphicFeature): ] def __init__( - self, - value: str | pygfx.Color | np.ndarray | Sequence[float], - property_name: str = "colors", + self, + value: ColorLike, + property_name: str = "colors", ): """Manages uniform color for line or scatter material""" @@ -205,7 +209,7 @@ def value(self) -> pygfx.Color: @block_reentrance def set_value( - self, graphic, value: str | pygfx.Color | np.ndarray | Sequence[float] + self, graphic, value: ColorLike ): value = pygfx.Color(value) graphic.world_object.material.color = value @@ -277,14 +281,14 @@ def __init__(self, data: Any, property_name: str = "data"): def _fix_data(self, data): if data.ndim == 1: # if user provides a 1D array, assume these are y-values - data = np.column_stack([np.arange(data.size, dtype=data.dtype), data]) + data = np.column_stack([np.arange(data.size, dtype=np.float32), data]) if data.shape[1] != 3: if data.shape[1] != 2: raise ValueError(f"Must pass 1D, 2D or 3D data") # zeros for z - zs = np.zeros(data.shape[0], dtype=data.dtype) + zs = np.zeros(data.shape[0], dtype=np.float32) # column stack [x, y, z] to make data of shape [n_points, 3] data = np.column_stack([data[:, 0], data[:, 1], zs]) @@ -315,6 +319,10 @@ def set_value(self, graphic, value): self._fpl_buffer = pygfx.Buffer(bdata) graphic.world_object.geometry.positions = self._fpl_buffer + # reset the cmap transform because the number of datapoints has changed + if graphic.cmap is not None: + graphic.cmap_transform = graphic.cmap_transform + self._emit_event(self._property_name, key=slice(None), value=value) return @@ -322,9 +330,9 @@ def set_value(self, graphic, value): @block_reentrance def __setitem__( - self, - key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], - value: np.ndarray | float | list[float], + self, + key: int | slice | np.ndarray[tuple[int, ...], np.dtype[np.integer | np.bool]] | tuple[slice, ...], + value: np.ndarray | float | list[float], ): # directly use the key to slice the buffer and set the values self.buffer.data[key] = value @@ -339,138 +347,132 @@ def __len__(self): return len(self.buffer.data) -class VertexCmap(BufferManager): +class VertexCmap(GraphicFeature): event_info_spec = [ - { - "dict key": "key", - "type": "slice", - "description": "key at cmap colors were sliced", - }, { "dict key": "value", - "type": "str", - "description": "new cmap to set at given slice", + "type": "cmap.Colormap", + "description": "new colormap", }, ] def __init__( - self, - vertex_colors: VertexColors, - cmap_name: str | None, - transform: np.ndarray | None, - property_name: str = "colors", + self, + value: cmap_lib.ColormapLike, + property_name: str = "cmap", ): """ - Sliceable colormap feature, manages a VertexColors instance and - provides a way to set colormaps with arbitrary transforms + colormap feature, manages a VertexColors instance and provides a way to set colormaps. """ + self._value = cmap_lib.Colormap(value) - super().__init__(data=None, property_name=property_name) + super().__init__(property_name=property_name) - self._vertex_colors = vertex_colors - self._cmap_name = cmap_name - self._transform = transform + @property + def value(self) -> cmap_lib.Colormap: + return self._value - if self._cmap_name is not None: - if not isinstance(self._cmap_name, str): - raise TypeError( - f"cmap name must be of type , you have passed: {self._cmap_name} of type: {type(self._cmap_name)}" - ) + @block_reentrance + def set_value(self, graphic, value: cmap_lib.ColormapLike): + self._value = cmap_lib.Colormap(value) - if self._transform is not None: - self._transform = np.asarray(self._transform) + # directly set the material map using the TextureMap + graphic.world_object.material.map = self._value.to_pygfx() - n_datapoints = vertex_colors.value.shape[0] + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) - colors = parse_cmap_values( - n_colors=n_datapoints, - cmap_name=self._cmap_name, - transform=self._transform, - ) - # set vertex colors from cmap - self._vertex_colors[:] = colors + def __repr__(self): + return self.value.__repr__() - @property - def buffer(self) -> pygfx.Buffer: - return self._vertex_colors.buffer + def _repr_html_(self): + return self.value._repr_html_() - @property - def value(self) -> np.ndarray: - # mirror the managed colors feature, whose length is the number of color entries - # (this is per-line, not per-vertex, for an InfLineColors) - return self._vertex_colors.value + def _repr_png(self): + return self.value._repr_png_() - @block_reentrance - def __setitem__(self, key: slice, cmap_name): - if not isinstance(key, slice): - raise TypeError( - "fancy indexing not supported for VertexCmap, only slices " - "of a continuous range are supported for applying a cmap" - ) - if key.step is not None: - raise TypeError( - "step sized indexing not currently supported for setting VertexCmap, " - "slices must be a continuous range" - ) - # parse slice - start, stop, step = key.indices(self.value.shape[0]) - n_elements = len(range(start, stop, step)) +class VertexCmapTransform(GraphicFeature): + ndim = 1 - colors = parse_cmap_values( - n_colors=n_elements, cmap_name=cmap_name, transform=self._transform - ) + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "colormap transform", + }, + ] - self._cmap_name = cmap_name - self._vertex_colors[key] = colors + def __init__(self, value: np.ndarray, n_datapoints: int, property_name: str = "cmap_transform"): + """colormap transform""" - # TODO: should we block vertex_colors from emitting an event? - # Because currently this will result in 2 emitted events, one - # for cmap and another from the colors - self._emit_event(self._property_name, key, cmap_name) + value = np.asarray(value) + self._value = self._interpolate(value, n_datapoints) + super().__init__(property_name=property_name) @property - def name(self) -> str: - return self._cmap_name + def value(self) -> np.ndarray: + return self._value - @property - def transform(self) -> np.ndarray | None: - """Get or set the cmap transform. Maps values from the transform array to the cmap colors""" - return self._transform - - @transform.setter - def transform( - self, - values: np.ndarray | list[float | int], - indices: slice | list | np.ndarray = None, - ): - if self._cmap_name is None: - raise AttributeError( - "cmap name is not set, set the cmap name before setting the transform" - ) + def _interpolate(self, value, n_datapoints): + # interpolate so we have a transform value for every datapoint + return np.interp( + np.linspace(0, len(value) - 1, n_datapoints), np.arange(len(value)), value).astype( + np.float32 + ) - values = np.asarray(values) + @block_reentrance + def set_value(self, graphic, value: np.ndarray): + value = np.asarray(value).squeeze() - colors = parse_cmap_values( - n_colors=self.value.shape[0], cmap_name=self._cmap_name, transform=values - ) + # make sure transform value is provided for every datapoint + n_datapoints = len(graphic.world_object.geometry.positions.data) + # interpolate to n_datapoints + value = self._interpolate(value, n_datapoints) - self._transform = values + if graphic.world_object.geometry.texcoords is not None and graphic.world_object.geometry.texcoords.data.size == value.size: + graphic.world_object.geometry.texcoords.data[:] = value + graphic.world_object.geometry.texcoords.update_full() + else: + graphic.world_object.geometry.texcoords = pygfx.Buffer(value) - if indices is None: - indices = slice(None) + self._value = graphic.world_object.geometry.texcoords.data - self._vertex_colors[indices] = colors + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) - self._emit_event("cmap.transform", indices, values) - def __len__(self): - raise NotImplementedError( - "len not implemented for `cmap`, use len(colors) instead" - ) +class VertexCmapRange(GraphicFeature): + """ + The (min, max) range of the ``cmap_transform`` that is mapped onto the colormap, i.e. the + material's ``maprange``. + """ - def __repr__(self): - return f"{self.__class__.__name__} | cmap: {self.name}\ntransform: {self.transform}" + ndim = 1 + + event_info_spec = [ + { + "dict key": "value", + "type": "tuple[float, float]", + "description": "new range", + }, + ] + + def __init__(self, value: tuple[float, float], property_name: str = "cmap_range"): + self._value = (float(value[0]), float(value[1])) + super().__init__(property_name=property_name) + + @property + def value(self) -> tuple[float, float]: + return self._value + + @block_reentrance + def set_value(self, graphic, value: tuple[float, float]): + self._value = (float(value[0]), float(value[1])) + graphic.world_object.material.maprange = self._value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) class InfLineAxisData(VertexPositions): diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index e41115ae3..10385bc77 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -132,6 +132,8 @@ def parse_markers(markers: str | Sequence[str] | np.ndarray, n_datapoints: int): class VertexMarkers(BufferManager): + ndim = 1 + event_info_spec = [ { "dict key": "key", @@ -326,6 +328,8 @@ def set_value(self, graphic, value: str): class UniformEdgeColor(GraphicFeature): + ndim = 1 + event_info_spec = [ { "dict key": "value", @@ -392,14 +396,14 @@ class UniformRotations(GraphicFeature): { "dict key": "value", "type": "float", - "description": "new edge_width", + "description": "new rotation value", }, ] - def __init__(self, edge_width: float, property_name: str = "point_rotations"): - """Manages evented uniform buffer for scatter marker rotation""" + def __init__(self, value: float, property_name: str = "point_rotations"): + """Manages uniform rotation for scatter material""" - self._value = edge_width + self._value = value super().__init__(property_name=property_name) @property @@ -408,7 +412,7 @@ def value(self) -> float: @block_reentrance def set_value(self, graphic, value: float): - graphic.world_object.material.rotations = value + graphic.world_object.material.rotation = value self._value = value event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) @@ -416,6 +420,8 @@ def set_value(self, graphic, value: float): class VertexRotations(BufferManager): + ndim = 1 + event_info_spec = [ { "dict key": "key", @@ -503,6 +509,8 @@ def __len__(self): class VertexPointSizes(BufferManager): + ndim = 1 + event_info_spec = [ { "dict key": "key", diff --git a/fastplotlib/graphics/features/types.py b/fastplotlib/graphics/features/types.py new file mode 100644 index 000000000..dcfacec97 --- /dev/null +++ b/fastplotlib/graphics/features/types.py @@ -0,0 +1,22 @@ +import numpy as np +from numpy._typing import NDArray + +import pygfx +from collections.abc import Iterable + +RGB = tuple[float, float, float] | tuple[int, int, int] | list[int] | list[float] +RGBA = tuple[float, float, float, float] | tuple[int, int, int, int] | list[int] | list[float] | pygfx.Color + +ArrayRGBA = np.ndarray[tuple[int, int, int] | tuple[int, int, int, int], np.dtype[np.number]] + +ColorLike = RGB | RGBA | ArrayRGBA | pygfx.Color | str + +# [n, 3 | 4] RGBA array +MultiColorArray = np.ndarray[tuple[int, int], np.dtype[np.number]] + +MultiColorLike = tuple[ColorLike] | list[ColorLike] | MultiColorArray + +# our own ColormapLike type since if we use the cmap lib's ColormapLike it expands into a huge complex union +ColormapLike = str | Iterable[ColorLike] | MultiColorLike + +TupleYUV = tuple[NDArray[np.uint8], NDArray[np.uint8], NDArray[np.uint8]] diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py index 59c62f354..b0816de41 100644 --- a/fastplotlib/graphics/features/utils.py +++ b/fastplotlib/graphics/features/utils.py @@ -1,3 +1,5 @@ +import numbers + import pygfx import numpy as np @@ -12,13 +14,24 @@ def is_single_color(value) -> bool: A single color is a str, ``pygfx.Color``, or an RGB(A) array/list/tuple of 3-4 numbers. """ if isinstance(value, np.ndarray): + # returns True if a 1D RGB(A) array + # returns False if shape is [n, 3 | 4] return value.shape in ((3,), (4,)) and value.dtype.kind in "fiu" if isinstance(value, (list, tuple)): - return len(value) in (3, 4) and all(isinstance(v, (float, int)) for v in value) + # returns True if RGB(A) list or tuple of int/float + # returns False otherwise + return len(value) in (3, 4) and all(isinstance(v, numbers.Real) for v in value) # str, pygfx.Color, or any other scalar color specifier - return True + if isinstance(value, (pygfx.Color, str)): + return True + + raise ValueError( + "`colors` must be a str, pygfx.Color, array, list or tuple indicating an RGB(A) color, a " + "sequence of str, pygfx.Color, and array of shape [n_datapoints, 3 | 4], or an existing " + "`UniformColor` or `VertexColors` instance." + ) def parse_colors( diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 908f92347..9ef8c3609 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -4,6 +4,7 @@ import numpy as np import pygfx from pygfx import Texture +import cmap as cmap_lib from .shaders import HighlightableImageMaterial from ..utils import quick_min_max, ColorspacesRGB, ColorspacesYUV, ColorRange @@ -17,7 +18,6 @@ from .features import ( TextureArray, TextureYUV, - TupleYUV, ImageCmap, ImageGamma, ImageVmin, @@ -25,6 +25,7 @@ ImageInterpolation, ImageCmapInterpolation, ) +from .features.types import TupleYUV def _format_value(value: float): @@ -515,11 +516,12 @@ def __init__( # use TextureMap for grayscale images self._cmap = ImageCmap(cmap) - _map = pygfx.TextureMap( - self._cmap.texture, - filter=self._cmap_interpolation.value, - wrap="clamp-to-edge", - ) + _map = self._cmap.value.to_pygfx() + _map.min_filter = self._cmap_interpolation.value + _map.mag_filter = self._cmap_interpolation.value + _map.mipmap_filter = self._cmap_interpolation.value + _map.wrap_s = "clamp-to-edge" + _map.wrap_t = "clamp-to-edge" # one common material is used for every Texture chunk self._material = HighlightableImageMaterial( diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 2154acdb8..0488d0cf6 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -2,6 +2,7 @@ import numpy as np import pygfx +import cmap as cmap_lib from ..utils import quick_min_max from ._base import Graphic @@ -215,11 +216,13 @@ def __init__( # use TextureMap for grayscale images self._cmap = ImageCmap(cmap) - self._texture_map = pygfx.TextureMap( - self._cmap.texture, - filter=self._cmap_interpolation.value, - wrap="clamp-to-edge", - ) + + self._texture_map = self._cmap.value.to_pygfx() + self._texture_map.min_filter = self._cmap_interpolation.value + self._texture_map.mag_filter = self._cmap_interpolation.value + self._texture_map.mipmap_filter = self._cmap_interpolation.value + self._texture_map.wrap_s = "clamp-to-edge" + self._texture_map.wrap_t = "clamp-to-edge" if self._data.value.ndim not in (3, 4): raise ValueError( @@ -314,13 +317,13 @@ def mode(self, mode: str): self._mode.set_value(self, mode) @property - def cmap(self) -> str: - """Get or set colormap name, only used for grayscale images""" + def cmap(self) -> cmap_lib.Colormap: + """Get or set colormap, only used for grayscale images""" return self._cmap.value @cmap.setter - def cmap(self, name: str): - self._cmap.set_value(self, name) + def cmap(self, colormap: str | cmap_lib.Colormap): + self._cmap.set_value(self, colormap) @property def vmin(self) -> float: diff --git a/fastplotlib/graphics/inf_line.py b/fastplotlib/graphics/inf_line.py index 6d92d4b3b..ee9987c8a 100644 --- a/fastplotlib/graphics/inf_line.py +++ b/fastplotlib/graphics/inf_line.py @@ -9,21 +9,14 @@ InfLineAxisData, InfLineColors, UniformColor, - VertexCmap, - Thickness, - SizeSpace, - DashPattern, ) +from .features.types import ColorLike, MultiColorLike, ColormapLike class InfLineGraphic(LineGraphic): _features = { "data": InfLineAxisData, "colors": (InfLineColors, UniformColor), - "cmap": (VertexCmap, None), # none if UniformColor - "thickness": Thickness, - "size_space": SizeSpace, - "dash_pattern": DashPattern, } # one color per line, each broadcast to the two vertices of the line's segment @@ -34,10 +27,10 @@ def __init__( data: Any, axis: Literal["x", "y", "z"] | None = None, thickness: float = 2.0, - colors: str | np.ndarray | Sequence = "w", - cmap: str = None, - cmap_transform: np.ndarray | Sequence = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, start_is_infinite: bool = True, end_is_infinite: bool = True, dash_pattern: str | tuple | list = (), @@ -62,7 +55,7 @@ def __init__( thickness: float, optional, default 2.0 thickness of the lines - colors: str, array, or iterable, default "w" + colors: ColorLike or MultiColorLike, default "w" specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays. A sequence of colors provides one color per line. @@ -72,14 +65,11 @@ def __init__( This overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - color_mode: one of "auto", "uniform", "vertex", default "auto" - "uniform" restricts to a single color for all lines. - "vertex" allows an independent color per line. - For most cases you can keep it as "auto" and the `color_mode` is determined automatically - based on the argument passed to `colors`. + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range start_is_infinite: bool, default True whether the start of each line is extended to infinity @@ -111,7 +101,7 @@ def __init__( colors=colors, cmap=cmap, cmap_transform=cmap_transform, - color_mode=color_mode, + cmap_range=cmap_range, size_space=size_space, dash_pattern=dash_pattern, thin=False, @@ -122,7 +112,7 @@ def _make_material(self) -> pygfx.LineInfiniteSegmentMaterial: return pygfx.LineInfiniteSegmentMaterial( start_is_infinite=self._start_is_infinite, end_is_infinite=self._end_is_infinite, - **self._material_kwargs(), + **self._get_material_kwargs(), ) @property diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 0b325df71..7c7e10f89 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,7 +5,6 @@ import pygfx -from ._positions_base import PositionsGraphic from .selectors import ( LinearRegionSelector, LinearSelector, @@ -16,23 +15,14 @@ Thickness, DashPattern, parse_dash_pattern, - VertexPositions, - VertexColors, - UniformColor, - VertexCmap, - SizeSpace, - UniformRotations, ) from ..utils import quick_min_max - +from ._positions_base import PositionsGraphic +from .features.types import ColorLike, MultiColorLike, ColormapLike class LineGraphic(PositionsGraphic): _features = { - "data": VertexPositions, - "colors": (VertexColors, UniformColor), - "cmap": (VertexCmap, None), # none if UniformColor "thickness": Thickness, - "size_space": SizeSpace, "dash_pattern": DashPattern, } @@ -40,10 +30,10 @@ def __init__( self, data: Any, thickness: float = 2.0, - colors: str | np.ndarray | Sequence = "w", - cmap: str = None, - cmap_transform: np.ndarray | Sequence = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | Iterable[int | float] | None = None, + cmap_range: tuple[float, float] | None = None, size_space: str = "screen", dash_pattern: str | tuple | list = (), thin: bool = False, @@ -63,25 +53,20 @@ def __init__( thickness: float, optional, default 2.0 thickness of the line - colors: str, array, or iterable, default "w" + colors: ColorLike or MultiColorLike, default "w" specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - cmap: str, optional + cmap: ColormapLike, optional Apply a colormap to the line instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - color_mode: one of "auto", "uniform", "vertex", default "auto" - "uniform" restricts to a single color for all line datapoints. - "vertex" allows independent colors per vertex. - For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the - argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". - If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to - "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range size_space: str, default "screen" coordinate space in which the thickness is expressed ("screen", "world", "model") @@ -105,7 +90,7 @@ def __init__( colors=colors, cmap=cmap, cmap_transform=cmap_transform, - color_mode=color_mode, + cmap_range=cmap_range, size_space=size_space, **kwargs, ) @@ -121,42 +106,24 @@ def __init__( ) world_object = pygfx.Line( - geometry=self._create_geometry(), + geometry=self._make_geo(), material=self._make_material(), ) self._set_world_object(world_object) - def _material_kwargs(self) -> dict: + def _get_material_kwargs(self) -> dict: # pygfx line material kwargs assembled from the current feature state - kwargs = dict( - thickness=self.thickness, - thickness_space=self.size_space, - dash_pattern=parse_dash_pattern(self._dash_pattern.value), - aa=self.alpha_mode in ("blend", "weighted_blend"), - pick_write=True, - depth_compare="<=", - ) - - if isinstance(self._colors, UniformColor): - kwargs["color_mode"] = "uniform" - kwargs["color"] = self.colors - else: - kwargs["color_mode"] = "vertex" - + kwargs = super()._get_material_kwargs() + kwargs["thickness"] = self.thickness + kwargs["thickness_space"] = self.size_space + kwargs["dash_pattern"] = parse_dash_pattern(self._dash_pattern.value) return kwargs def _make_material(self) -> pygfx.LineMaterial: # create the pygfx material, subclasses override to use a different line material material_cls = pygfx.LineThinMaterial if self._thin else pygfx.LineMaterial - return material_cls(**self._material_kwargs()) - - def _create_geometry(self) -> pygfx.Geometry: - if isinstance(self._colors, UniformColor): - return pygfx.Geometry(positions=self._data._fpl_buffer) - return pygfx.Geometry( - positions=self._data._fpl_buffer, colors=self._colors._fpl_buffer - ) + return material_cls(**self._get_material_kwargs()) @property def thickness(self) -> float: diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py deleted file mode 100644 index 3656b5d39..000000000 --- a/fastplotlib/graphics/line_collection.py +++ /dev/null @@ -1,659 +0,0 @@ -from itertools import repeat -from numbers import Number -from typing import * - -import numpy as np - -import pygfx - -from ..utils import parse_cmap_values -from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature -from .line import LineGraphic -from .selectors import ( - LinearRegionSelector, - LinearSelector, - RectangleSelector, - PolygonSelector, -) - - -class _LineCollectionProperties: - """Mix-in class for LineCollection properties""" - - @property - def colors(self) -> CollectionFeature: - """get or set colors of lines in the collection""" - return CollectionFeature(self.graphics, "colors") - - @colors.setter - def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[str]): - if isinstance(values, str): - # set colors of all lines to one str color - for g in self: - g.colors = values - return - - elif all(isinstance(v, str) for v in values): - # individual str colors for each line - if not len(values) == len(self): - raise IndexError - - for g, v in zip(self.graphics, values): - g.colors = v - - return - - if isinstance(values, np.ndarray): - if values.ndim == 2: - # assume individual colors for each - for g, v in zip(self, values): - g.colors = v - return - - elif len(values) == 4: - # assume RGBA - self.colors[:] = values - - else: - # assume individual colors for each - for g, v in zip(self, values): - g.colors = v - - @property - def data(self) -> CollectionFeature: - """get or set data of lines in the collection""" - return CollectionFeature(self.graphics, "data") - - @data.setter - def data(self, values): - for g, v in zip(self, values): - g.data = v - - @property - def cmap(self) -> CollectionFeature: - """ - Get or set a cmap along the line collection. - - Optionally set using a tuple ("cmap", ) to set the transform.. - Example: - - line_collection.cmap = ("jet", sine_transform_vals, 0.7) - - """ - return CollectionFeature(self.graphics, "cmap") - - @cmap.setter - def cmap(self, args): - if isinstance(args, str): - name = args - transform = None - elif len(args) == 1: - name = args[0] - transform = None - elif len(args) == 2: - name, transform = args - else: - raise ValueError( - "Too many values for cmap (note that alpha is deprecated, set alpha on the graphic instead)" - ) - - self.colors = parse_cmap_values( - n_colors=len(self), cmap_name=name, transform=transform - ) - - @property - def thickness(self) -> np.ndarray: - """get or set the thickness of the lines""" - return np.asarray([g.thickness for g in self]) - - @thickness.setter - def thickness(self, values: float | Sequence[float]): - if isinstance(values, Number): - values = repeat(values, len(self)) - - elif not len(values) == len(self): - raise IndexError - - for g, v in zip(self, values): - g.thickness = v - - -class LineCollectionIndexer(CollectionIndexer, _LineCollectionProperties): - """Indexer for line collections""" - - pass - - -class LineCollection(GraphicCollection, _LineCollectionProperties): - _child_type = LineGraphic - _indexer = LineCollectionIndexer - - def __init__( - self, - data: np.ndarray | List[np.ndarray], - thickness: float | Sequence[float] = 2.0, - colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", - cmap: Sequence[str] | str = None, - cmap_transform: np.ndarray | List = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", - name: str = None, - names: list[str] = None, - metadata: Any = None, - metadatas: Sequence[Any] | np.ndarray = None, - kwargs_lines: list[dict] = None, - **kwargs, - ): - """ - Create a collection of :class:`.LineGraphic` - - Parameters - ---------- - data: list of array-like - List or array-like of multiple line data to plot - - | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array - | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] - - thickness: float or Iterable of float, default 2.0 - | if ``float``, single thickness will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - - colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line - - cmap: Iterable of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines - - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` - - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap - - color_mode: one of "auto", "uniform", "vertex", default "auto" - The color mode for each line in the collection. See `color_mode` in :class:`.LineGraphic` for details. - - name: str, optional - name of the line collection as a whole - - names: list[str], optional - names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` - - metadata: Any - meatadata associated with the collection as a whole - - metadatas: Iterable or array - metadata for each individual line associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` - - kwargs_lines: list[dict], optional - list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - - kwargs_collection - kwargs for the collection, passed to GraphicCollection - - """ - - super().__init__(name=name, metadata=metadata, **kwargs) - - if not isinstance(thickness, (float, int)): - if len(thickness) != len(data): - raise ValueError( - f"len(thickness) != len(data)\n{len(thickness)} != {len(data)}" - ) - - if names is not None: - if len(names) != len(data): - raise ValueError( - f"len(names) != len(data)\n{len(names)} != {len(data)}" - ) - - if metadatas is not None: - if len(metadatas) != len(data): - raise ValueError( - f"len(metadata) != len(data)\n{len(metadatas)} != {len(data)}" - ) - - if kwargs_lines is not None: - if len(kwargs_lines) != len(data): - raise ValueError( - f"len(kwargs_lines) != len(data)\n" - f"{len(kwargs_lines)} != {len(data)}" - ) - - self._cmap_transform = cmap_transform - self._cmap_str = cmap - - # cmap takes priority over colors - if cmap is not None: - # cmap across lines - if isinstance(cmap, str): - colors = parse_cmap_values( - n_colors=len(data), cmap_name=cmap, transform=cmap_transform - ) - single_color = False - cmap = None - - elif isinstance(cmap, (tuple, list)): - if len(cmap) != len(data): - raise ValueError( - "cmap argument must be a single cmap or a list of cmaps " - "with the same length as the data" - ) - single_color = False - else: - raise ValueError( - "cmap argument must be a single cmap or a list of cmaps " - "with the same length as the data" - ) - else: - if isinstance(colors, np.ndarray): - # single color for all lines in the collection as RGBA - if colors.shape in [(3,), (4,)]: - single_color = True - - # colors specified for each line as array of shape [n_lines, RGBA] - elif colors.shape == (len(data), 4): - single_color = False - - else: - raise ValueError( - f"numpy array colors argument must be of shape (4,) or (n_lines, 4)." - f"You have pass the following shape: {colors.shape}" - ) - - elif isinstance(colors, str): - if colors == "random": - colors = np.random.rand(len(data), 3) - single_color = False - else: - # parse string color - single_color = True - colors = pygfx.Color(colors) - - elif isinstance(colors, (tuple, list)): - if len(colors) == 4: - # single color specified as (R, G, B, A) tuple or list - if all([isinstance(c, (float, int)) for c in colors]): - single_color = True - - elif len(colors) == len(data): - # colors passed as list/tuple of colors, such as list of string - single_color = False - - else: - raise ValueError( - "tuple or list colors argument must be a single color represented as [R, G, B, A], " - "or must be a tuple/list of colors represented by a string with the same length as the data" - ) - - if kwargs_lines is None: - kwargs_lines = dict() - - self._set_world_object(pygfx.Group()) - - for i, d in enumerate(data): - if isinstance(thickness, list): - _s = thickness[i] - else: - _s = thickness - - if cmap is None: - _cmap = None - - if single_color: - _c = colors - else: - _c = colors[i] - else: - _cmap = cmap[i] - _c = None - - if metadatas is not None: - _m = metadatas[i] - else: - _m = None - - if names is not None: - _name = names[i] - else: - _name = None - - lg = LineGraphic( - data=d, - thickness=_s, - colors=_c, - cmap=_cmap, - color_mode=color_mode, - name=_name, - metadata=_m, - **kwargs_lines, - ) - - self.add_graphic(lg) - - def __getitem__(self, item) -> LineCollectionIndexer: - return super().__getitem__(item) - - def add_linear_selector( - self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs - ) -> LinearSelector: - """ - Adds a linear selector. - - Parameters - ---------- - Parameters - ---------- - selection: float, optional - selected point on the linear selector, computed from data if not provided - - axis: str, default "x" - axis that the selector resides on - - padding: float, default 0.0 - Extra padding to extend the linear selector along the orthogonal axis to make it easier to interact with. - - kwargs - passed to :class:`.LinearSelector` - - Returns - ------- - LinearSelector - - """ - - bounds_init, limits, size, center = self._get_linear_selector_init_args( - axis, padding - ) - - if selection is None: - selection = bounds_init[0] - - selector = LinearSelector( - selection=selection, - limits=limits, - axis=axis, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - def add_linear_region_selector( - self, - selection: tuple[float, float] = None, - padding: float = 0.0, - axis: str = "x", - **kwargs, - ) -> LinearRegionSelector: - """ - Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, - remove, or delete them from a plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float), optional - the starting bounds of the linear region selector, computed from data if not provided - - axis: str, default "x" - axis that the selector resides on - - padding: float, default 0.0 - Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. - - kwargs - passed to ``LinearRegionSelector`` - - Returns - ------- - LinearRegionSelector - linear selection graphic - - """ - - bounds_init, limits, size, center = self._get_linear_selector_init_args( - axis, padding - ) - - if selection is None: - selection = bounds_init - - # create selector - selector = LinearRegionSelector( - selection=selection, - limits=limits, - size=size, - center=center, - axis=axis, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - # PlotArea manages this for garbage collection etc. just like all other Graphics - # so we should only work with a proxy on the user-end - return selector - - def add_rectangle_selector( - self, - selection: tuple[float, float, float] = None, - **kwargs, - ) -> RectangleSelector: - """ - Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, - remove, or delete them from a plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float, float, float), optional - initial (xmin, xmax, ymin, ymax) of the selection - """ - bbox = self.world_object.get_world_bounding_box() - - xdata = np.array(self.data[:, 0]) - xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) - value_25px = (xmax - xmin) / 4 - - ydata = np.array(self.data[:, 1]) - ymin = np.floor(ydata.min()).astype(int) - - ymax = np.ptp(bbox[:, 1]) - - if selection is None: - selection = (xmin, value_25px, ymin, ymax) - - limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) - - selector = RectangleSelector( - selection=selection, - limits=limits, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - def add_polygon_selector( - self, - selection: List[tuple[float, float]] = None, - **kwargs, - ) -> PolygonSelector: - """ - Add a :class:`.PolygonSelector`. Selectors are just ``Graphic`` objects, so you can manage, - remove, or delete them from a plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: list[tuple[float, float]], optional - Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). - """ - bbox = self.world_object.get_world_bounding_box() - - xdata = np.array(self.data[:, 0]) - xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) - - ydata = np.array(self.data[:, 1]) - ymin = np.floor(ydata.min()).astype(int) - - ymax = np.ptp(bbox[:, 1]) - - limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) - - selector = PolygonSelector( - selection, - limits, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - def _get_linear_selector_init_args(self, axis, padding): - # use bbox to get size and center - bbox = self.world_object.get_world_bounding_box() - - if axis == "x": - xdata = np.array(self.data[:, 0]) - xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) - value_25p = (xmax - xmin) / 4 - - bounds = (xmin, value_25p) - limits = (xmin, xmax) - # size from orthogonal axis - size = np.ptp(bbox[:, 1]) * 1.5 - # center on orthogonal axis - center = bbox[:, 1].mean() - - elif axis == "y": - ydata = np.array(self.data[:, 1]) - xmin, xmax = (np.nanmin(ydata), np.nanmax(ydata)) - value_25p = (xmax - xmin) / 4 - - bounds = (xmin, value_25p) - limits = (xmin, xmax) - - size = np.ptp(bbox[:, 0]) * 1.5 - # center on orthogonal axis - center = bbox[:, 0].mean() - - return bounds, limits, size, center - - -axes = {"x": 0, "y": 1, "z": 2} - - -class LineStack(LineCollection): - def __init__( - self, - data: List[np.ndarray], - thickness: float | Iterable[float] = 2.0, - colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", - cmap: Iterable[str] | str = None, - cmap_transform: np.ndarray | List = None, - name: str = None, - names: list[str] = None, - metadata: Any = None, - metadatas: Sequence[Any] | np.ndarray = None, - separation: float = 10.0, - separation_axis: str = "y", - kwargs_lines: list[dict] = None, - **kwargs, - ): - """ - Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. - - Parameters - ---------- - data: list of array-like - List or array-like of multiple line data to plot - - | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array - | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] - - thickness: float or Iterable of float, default 2.0 - | if ``float``, single thickness will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - - colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line - - cmap: Iterable of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines - - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` - - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap - - name: str, optional - name of the line collection as a whole - - names: list[str], optional - names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` - - metadata: Any - metadata associated with the collection as a whole - - metadatas: Iterable or array - metadata for each individual line associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` - - separation: float, default 10 - space in between each line graphic in the stack - - separation_axis: str, default "y" - axis in which the line graphics in the stack should be separated - - - kwargs_lines: list[dict], optional - list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - - kwargs_collection - kwargs for the collection, passed to GraphicCollection - - """ - super().__init__( - data=data, - thickness=thickness, - colors=colors, - cmap=cmap, - cmap_transform=cmap_transform, - name=name, - names=names, - metadata=metadata, - metadatas=metadatas, - kwargs_lines=kwargs_lines, - **kwargs, - ) - - axis_zero = 0 - for i, line in enumerate(self.graphics): - if separation_axis == "x": - line.offset = (axis_zero, *line.offset[1:]) - - elif separation_axis == "y": - line.offset = (line.offset[0], axis_zero, line.offset[2]) - - axis_zero = ( - axis_zero + line.data.value[:, axes[separation_axis]].max() + separation - ) - - self.separation_axis = separation_axis - self.separation = separation diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index b9cacf908..624a904b4 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -7,11 +7,7 @@ from .features import ( VertexPointSizes, UniformSize, - SizeSpace, - VertexPositions, VertexColors, - UniformColor, - VertexCmap, VertexMarkers, UniformMarker, UniformEdgeColor, @@ -20,41 +16,35 @@ VertexRotations, TextureArray, ) +from .features.types import ColorLike, MultiColorLike, ColormapLike +from .features.utils import is_single_color class ScatterGraphic(PositionsGraphic): _features = { - "data": VertexPositions, "sizes": (VertexPointSizes, UniformSize), - "colors": (VertexColors, UniformColor), - "cmap": (VertexCmap, None), "markers": (VertexMarkers, UniformMarker, None), "edge_colors": (UniformEdgeColor, VertexColors, None), "edge_width": (EdgeWidth, None), "image": (TextureArray, None), - "size_space": SizeSpace, "point_rotations": (UniformRotations, VertexRotations, None), } def __init__( self, data: Any, - colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w", - cmap: str = None, - cmap_transform: np.ndarray = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: str | np.ndarray | Sequence[str] = "o", - uniform_marker: bool = True, custom_sdf: str = None, - edge_colors: str | np.ndarray | pygfx.Color | Sequence[float] = "black", - uniform_edge_color: bool = True, + edge_colors: ColorLike | MultiColorLike | None = "black", edge_width: float = 1.0, image: np.ndarray = None, - point_rotations: float | np.ndarray = 0, - point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", + point_rotations: float | np.ndarray | None = None, sizes: float | np.ndarray | Sequence[float] = 5, - uniform_size: bool = True, size_space: str = "screen", **kwargs, ): @@ -67,26 +57,21 @@ def __init__( Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] - colors: str, array, tuple, list, Sequence, default "w" + colors: ColorLike or MultiColorLike, default "w" specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - cmap: str, optional + cmap: ColormapLike, optional apply a colormap to the scatter instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - cmap_transform: 1D array-like or list of numerical values, optional - if provided, these values are used to map the colors from the cmap + cmap_transform: np.ndarray, optional + 1D array-like or list of numerical values, these values are used to map the colors from the cmap - color_mode: one of "auto", "uniform", "vertex", default "auto" - "uniform" restricts to a single color for all line datapoints. - "vertex" allows independent colors per vertex. - For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the - argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". - If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to - "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range mode: one of: "markers", "simple", "gaussian", "image", default "markers" The scatter points mode, cannot be changed after the graphic has been created. @@ -96,8 +81,9 @@ def __init__( * gaussian: each point is a gaussian blob * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites - markers: None | str | np.ndarray | Sequence[str], default "o" - The shape of the markers when `mode` is "markers" + markers: str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers". Specify a single marker to use the same + marker for all points, or a Sequence of markers for per-vertex markers. Supported values: @@ -107,11 +93,6 @@ def __init__( * Emojis: "❤️♠️♣️♦️💎💍✳️📍". * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - uniform_marker: bool, default ``True`` - If ``True``, use the same marker for all points. Only valid when `mode` is "markers". - Useful if you need to use the same marker for all points and want to save GPU RAM. If ``False``, you can - set per-vertex markers. - custom_sdf: str = None, The SDF code for the marker shape when the marker is set to custom. Can be used when `mode` is "markers". @@ -127,35 +108,27 @@ def __init__( with the `edge_color`. Other negative distances will be colored by `colors`. - edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" - edge color of the markers, used when `mode` is "markers" - - uniform_edge_color: bool, default ``True`` - Set the same edge color for all markers. Useful for saving GPU RAM. Set to ``False`` for per-vertex edge - colors + edge_colors: ColorLike, MultiColorLike, or None, default "black" + edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the + same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass + ``None`` for no edge color. edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". - image: ArrayLike, optional + image: array-like, optional renders an image at the scatter points, also known as sprites. The image color is multiplied with the point's "normal" color. - point_rotations: float | ArrayLike = 0, - The rotation of the scatter points in radians. Default 0. A single float rotation value can be set on all - points, or an array of rotation values can be used to set per-point rotations - - point_rotation_mode: one of: "uniform" | "vertex" | "curve", default "uniform" - * uniform: set the same rotation for every point, useful to save GPU RAM - * vertex: set per-vertex rotations - * curve: The rotation follows the curve of the line defined by the points (in screen space) + point_rotations: float, array-like, or None, default None + The rotation of the scatter points in radians. The rotation mode is determined automatically from + the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve + of the data (in screen space); a single float for the same rotation on every point ("uniform"); or + an array of rotation values for per-point rotations ("vertex"). - sizes: float or iterable of float, optional, default 1.0 - sizes of the scatter points - - uniform_size: bool, default ``False`` - if ``True``, uses a uniform buffer for the scatter point sizes. Useful if you need to - save GPU VRAM when all points have the same size. Set to ``False`` if you need per-vertex sizes. + sizes: float, np.ndarray, or Sequence[float], default 5 + size(s) of the scatter points. Specify a single size to use the same size for all points, or a + Sequence of sizes for per-point sizes. size_space: str, default "screen" coordinate space in which the size is expressed, one of ("screen", "world", "model") @@ -170,89 +143,30 @@ def __init__( colors=colors, cmap=cmap, cmap_transform=cmap_transform, - color_mode=color_mode, + cmap_range=cmap_range, size_space=size_space, **kwargs, ) n_datapoints = self.data.value.shape[0] - geo_kwargs = {"positions": self._data._fpl_buffer} - - aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") - - material_kwargs = dict( - pick_write=True, - aa=aa, - depth_compare="<=", - ) - self._markers: VertexMarkers | UniformMarker | None = None self._edge_colors: UniformEdgeColor | VertexColors | None = None self._edge_width: EdgeWidth | None = None self._point_rotations: VertexRotations | UniformRotations | None = None self._image: TextureArray | None = None + self._custom_sdf: str | None = None # material cannot be changed after the ScatterGraphic is created self._mode = mode - match self.mode: + match self._mode: case "markers": - # default - material = pygfx.PointsMarkerMaterial - - if uniform_marker: - if not isinstance(markers, str): - raise TypeError( - "must pass a single marker if uniform_marker is True" - ) - - self._markers = UniformMarker(markers) - - material_kwargs["marker_mode"] = pygfx.MarkerMode.uniform - material_kwargs["marker"] = self._markers.value - else: - material_kwargs["marker_mode"] = pygfx.MarkerMode.vertex - - self._markers = VertexMarkers(markers, n_datapoints) - - geo_kwargs["markers"] = self._markers._fpl_buffer - - if edge_colors is None: - # interpret as no edge color - edge_colors = (0, 0, 0, 0) - - if uniform_edge_color: - if not isinstance(edge_colors, (str, pygfx.Color)): - if len(edge_colors) not in [3, 4]: - raise TypeError( - f"if `uniform_edge_color` is True, then `edge_color` must be a str, pygfx.Color, " - f"or an RGB(A) tuple, list, array representation of a single color. You have passed: " - f"{edge_colors}" - ) - - self._edge_colors = UniformEdgeColor(edge_colors) - material_kwargs["edge_color"] = self._edge_colors.value - material_kwargs["edge_color_mode"] = pygfx.ColorMode.uniform - else: - self._edge_colors = VertexColors( - edge_colors, n_datapoints, property_name="edge_colors" - ) - material_kwargs["edge_color_mode"] = pygfx.ColorMode.vertex - geo_kwargs["edge_colors"] = self._edge_colors._fpl_buffer - + self._markers = self._create_markers_buffer(markers) + self._edge_colors = self._create_edge_colors_buffer(edge_colors) self._edge_width = EdgeWidth(edge_width) - material_kwargs["edge_width"] = self._edge_width.value - material_kwargs["custom_sdf"] = custom_sdf - - case "simple": - # basic points material - material = pygfx.PointsMaterial - - case "gaussian": - material = pygfx.PointsGaussianBlobMaterial + self._custom_sdf = custom_sdf case "image": - material = pygfx.PointsSpriteMaterial # sprites should actually only be one texture, but we don't # want to create a new buffer manager just for sprites. # If someone is creating scatter plots with images of size @@ -271,54 +185,156 @@ def __init__( image / np.nanmax(image), property_name="image" ) - material_kwargs["sprite"] = self._image.buffer[0, 0] + self._sizes = self._create_sizes_buffer(sizes) + self._point_rotations = self._create_point_rotations_buffer(point_rotations) - self._size_space = SizeSpace(size_space) + world_object = pygfx.Points( + geometry=self._make_geo(), + material=self._make_material(), + ) + + self._set_world_object(world_object) - if isinstance(self._colors, UniformColor): - material_kwargs["color_mode"] = pygfx.ColorMode.uniform - material_kwargs["color"] = self.colors + def _make_material(self) -> pygfx.PointsMaterial: + # create the pygfx material, the material class is determined by the scatter mode + material_cls = { + "markers": pygfx.PointsMarkerMaterial, + "simple": pygfx.PointsMaterial, + "gaussian": pygfx.PointsGaussianBlobMaterial, + "image": pygfx.PointsSpriteMaterial, + }[self._mode] + return material_cls(**self._get_material_kwargs()) + + def _get_material_kwargs(self) -> dict: + # pygfx points material kwargs assembled from the current feature state + kwargs = super()._get_material_kwargs() + kwargs["size_space"] = self.size_space + + if isinstance(self._sizes, UniformSize): + kwargs["size_mode"] = pygfx.SizeMode.uniform + kwargs["size"] = self.sizes else: - material_kwargs["color_mode"] = pygfx.ColorMode.vertex - geo_kwargs["colors"] = self.colors._fpl_buffer + kwargs["size_mode"] = pygfx.SizeMode.vertex - if uniform_size: - material_kwargs["size_mode"] = pygfx.SizeMode.uniform - self._sizes = UniformSize(sizes) - material_kwargs["size"] = self.sizes + if isinstance(self._point_rotations, VertexRotations): + kwargs["rotation_mode"] = pygfx.enums.RotationMode.vertex + elif isinstance(self._point_rotations, UniformRotations): + kwargs["rotation_mode"] = pygfx.enums.RotationMode.uniform + kwargs["rotation"] = self._point_rotations.value else: - material_kwargs["size_mode"] = pygfx.SizeMode.vertex - self._sizes = VertexPointSizes(sizes, n_datapoints=n_datapoints) - geo_kwargs["sizes"] = self.sizes._fpl_buffer - - match point_rotation_mode: - case pygfx.enums.RotationMode.vertex: - self._point_rotations = VertexRotations( - point_rotations, n_datapoints=n_datapoints - ) - geo_kwargs["rotations"] = self._point_rotations._fpl_buffer + kwargs["rotation_mode"] = pygfx.enums.RotationMode.curve - case pygfx.enums.RotationMode.uniform: - self._point_rotations = UniformRotations(point_rotations) + match self._mode: + case "markers": + if isinstance(self._markers, UniformMarker): + kwargs["marker_mode"] = pygfx.MarkerMode.uniform + kwargs["marker"] = self._markers.value + else: + kwargs["marker_mode"] = pygfx.MarkerMode.vertex - case pygfx.enums.RotationMode.curve: - pass # nothing special for curve rotation mode + if isinstance(self._edge_colors, UniformEdgeColor): + kwargs["edge_color_mode"] = pygfx.ColorMode.uniform + kwargs["edge_color"] = self._edge_colors.value + else: + kwargs["edge_color_mode"] = pygfx.ColorMode.vertex - case _: - raise ValueError( - f"`point_rotation_mode` must be one of: {pygfx.enums.RotationMode}, " - f"you have passed: {point_rotation_mode}" - ) + kwargs["edge_width"] = self._edge_width.value + kwargs["custom_sdf"] = self._custom_sdf - material_kwargs["rotation_mode"] = point_rotation_mode - material_kwargs["size_space"] = self.size_space + case "image": + kwargs["sprite"] = self._image.buffer[0, 0] - world_object = pygfx.Points( - pygfx.Geometry(**geo_kwargs), - material=material(**material_kwargs), - ) + return kwargs - self._set_world_object(world_object) + def _get_geo_kwargs(self) -> dict: + # pygfx points geometry kwargs assembled from the current feature state + kwargs = super()._get_geo_kwargs() + + if isinstance(self._sizes, VertexPointSizes): + kwargs["sizes"] = self._sizes._fpl_buffer + + if isinstance(self._point_rotations, VertexRotations): + kwargs["rotations"] = self._point_rotations._fpl_buffer + + if self._mode == "markers": + if isinstance(self._markers, VertexMarkers): + kwargs["markers"] = self._markers._fpl_buffer + + if isinstance(self._edge_colors, VertexColors): + kwargs["edge_colors"] = self._edge_colors._fpl_buffer + + return kwargs + + def _create_markers_buffer(self, markers) -> UniformMarker | VertexMarkers: + # creates either a UniformMarker or VertexMarkers based on the given `markers` + + if isinstance(markers, (VertexMarkers, UniformMarker)): + # share buffer with existing markers instance + return markers + + # a single marker is a str, a sequence is one marker per datapoint + if isinstance(markers, str): + return UniformMarker(markers) + + else: + return VertexMarkers(markers, n_datapoints=self._data.value.shape[0]) + + def _create_edge_colors_buffer(self, edge_colors) -> UniformEdgeColor | VertexColors: + # creates either a UniformEdgeColor or VertexColors based on the given `edge_colors` + + if edge_colors is None: + # interpret as no edge color + edge_colors = (0, 0, 0, 0) + + if isinstance(edge_colors, (VertexColors, UniformEdgeColor)): + # share buffer with existing edge_colors instance + return edge_colors + + # determine if a single or multiple colors were passed and decide edge_color_mode + if is_single_color(edge_colors): + # one color specified as a str or pygfx.Color, or one color specified with RGB(A) values + return UniformEdgeColor(edge_colors) + + else: + # sequence of colors, one edge color per datapoint + return VertexColors( + edge_colors, + n_colors=self._data.value.shape[0], + property_name="edge_colors", + ) + + def _create_sizes_buffer(self, sizes) -> UniformSize | VertexPointSizes: + # creates either a UniformSize or VertexPointSizes based on the given `sizes` + + if isinstance(sizes, (VertexPointSizes, UniformSize)): + # share buffer with existing sizes instance + return sizes + + # a single size is a scalar, a sequence is one size per datapoint + if isinstance(sizes, (np.ndarray, list, tuple)): + return VertexPointSizes(sizes, n_datapoints=self._data.value.shape[0]) + + else: + return UniformSize(sizes) + + def _create_point_rotations_buffer( + self, point_rotations + ) -> UniformRotations | VertexRotations | None: + # None -> curve mode (no feature, rotation follows the data curve), a single value -> + # uniform, a sequence -> vertex + + if isinstance(point_rotations, (VertexRotations, UniformRotations)): + # share buffer with existing point_rotations instance + return point_rotations + + if point_rotations is None: + return None + + if isinstance(point_rotations, (np.ndarray, list, tuple)): + return VertexRotations(point_rotations, n_datapoints=self._data.value.shape[0]) + + else: + return UniformRotations(point_rotations) @property def mode(self) -> str: @@ -327,7 +343,7 @@ def mode(self) -> str: @property def markers(self) -> str | VertexMarkers | None: - """markers if mode is 'marker'""" + """Get or set the markers, if mode is 'markers'""" if isinstance(self._markers, VertexMarkers): return self._markers elif isinstance(self._markers, UniformMarker): @@ -340,11 +356,25 @@ def markers(self, value: str | np.ndarray[str] | Sequence[str]): f"scatter plot is: {self.mode}. The mode must be 'markers' to set the markers" ) - self._markers.set_value(self, value) + # currently per-vertex: stay per-vertex, broadcasting a single marker or setting a sequence + if isinstance(self._markers, VertexMarkers): + self._markers.set_value(self, value) + return + + # currently uniform: a single marker stays uniform + if isinstance(value, str): + self._markers.set_value(self, value) + return + + # currently uniform and a sequence was passed: switch uniform -> vertex + self._markers.clear_event_handlers() + self._markers = self._create_markers_buffer(value) + self.world_object.geometry.markers = self._markers._fpl_buffer + self.world_object.material.marker_mode = "vertex" @property - def edge_colors(self) -> str | pygfx.Color | VertexColors | None: - """edge_colors if mode is 'marker'""" + def edge_colors(self) -> VertexColors | pygfx.Color | None: + """Get or set the marker edge colors, if mode is 'markers'""" if isinstance(self._edge_colors, VertexColors): return self._edge_colors @@ -353,12 +383,31 @@ def edge_colors(self) -> str | pygfx.Color | VertexColors | None: return self._edge_colors.value @edge_colors.setter - def edge_colors(self, value: str | np.ndarray | Sequence[str] | Sequence[float]): + def edge_colors(self, value: ColorLike | MultiColorLike | None): if self.mode != "markers": raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_colors" ) - self._edge_colors.set_value(self, value) + + if value is None: + # interpret as no edge color + value = (0, 0, 0, 0) + + # currently per-vertex: stay per-vertex, broadcasting a single color or setting a sequence + if isinstance(self._edge_colors, VertexColors): + self._edge_colors.set_value(self, value) + return + + # currently uniform: a single color stays uniform + if is_single_color(value): + self._edge_colors.set_value(self, value) + return + + # currently uniform and a sequence was passed: switch uniform -> vertex + self._edge_colors.clear_event_handlers() + self._edge_colors = self._create_edge_colors_buffer(value) + self.world_object.geometry.edge_colors = self._edge_colors._fpl_buffer + self.world_object.material.edge_color_mode = "vertex" @property def edge_width(self) -> float | None: @@ -384,7 +433,7 @@ def point_rotation_mode(self) -> str: @property def point_rotations(self) -> VertexRotations | float | None: - """rotation of each point, in radians, if `point_rotation_mode` is 'uniform' or 'vertex'""" + """Get or set the point rotations in radians; returns None in 'curve' mode""" if isinstance(self._point_rotations, VertexRotations): return self._point_rotations @@ -393,14 +442,41 @@ def point_rotations(self) -> VertexRotations | float | None: return self._point_rotations.value @point_rotations.setter - def point_rotations(self, value: float | np.ndarray[float]): - if self.point_rotation_mode not in ["uniform", "vertex"]: - raise AttributeError( - f"point_rotation_mode is: {self.point_rotation_mode}. " - f"it be 'uniform' or 'vertex' to set the `point_rotations`" - ) + def point_rotations(self, value: float | np.ndarray[tuple[int], np.dtype[np.number]] | None): + # None selects curve mode, where the rotation follows the data curve + if value is None: + if self._point_rotations is not None: + self._point_rotations.clear_event_handlers() + self._point_rotations = None + self.world_object.material.rotation_mode = "curve" + self.world_object.geometry.rotations = None + return + + # currently per-vertex: stay per-vertex, broadcasting a single value or setting a sequence + if isinstance(self._point_rotations, VertexRotations): + self._point_rotations.set_value(self, value) + return + + # currently uniform: a single value stays uniform + if isinstance(self._point_rotations, UniformRotations) and not isinstance( + value, (np.ndarray, list, tuple) + ): + self._point_rotations.set_value(self, value) + return - self._point_rotations.set_value(self, value) + # switch to the mode the value implies (from uniform, or from curve which has no feature) + if self._point_rotations is not None: + self._point_rotations.clear_event_handlers() + + self._point_rotations = self._create_point_rotations_buffer(value) + + if isinstance(self._point_rotations, VertexRotations): + self.world_object.geometry.rotations = self._point_rotations._fpl_buffer + self.world_object.material.rotation_mode = "vertex" + else: + self.world_object.material.rotation = self._point_rotations.value + self.world_object.material.rotation_mode = "uniform" + self.world_object.geometry.rotations = None @property def image(self) -> TextureArray | None: @@ -426,5 +502,19 @@ def sizes(self) -> VertexPointSizes | float: return self._sizes.value @sizes.setter - def sizes(self, value): - self._sizes.set_value(self, value) + def sizes(self, value: float | np.ndarray | Sequence[float]): + # currently per-vertex: stay per-vertex, broadcasting a single value or setting a sequence + if isinstance(self._sizes, VertexPointSizes): + self._sizes.set_value(self, value) + return + + # currently uniform: a single value stays uniform + if not isinstance(value, (np.ndarray, list, tuple)): + self._sizes.set_value(self, value) + return + + # currently uniform and a sequence was passed: switch uniform -> vertex + self._sizes.clear_event_handlers() + self._sizes = self._create_sizes_buffer(value) + self.world_object.geometry.sizes = self._sizes._fpl_buffer + self.world_object.material.size_mode = "vertex" diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py deleted file mode 100644 index b2d150d23..000000000 --- a/fastplotlib/graphics/scatter_collection.py +++ /dev/null @@ -1,677 +0,0 @@ -from itertools import repeat -from numbers import Number -from typing import * - -import numpy as np - -import pygfx - -from ..utils import parse_cmap_values -from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature -from .scatter import ScatterGraphic -from .selectors import ( - LinearRegionSelector, - LinearSelector, - RectangleSelector, - PolygonSelector, -) - - -class _ScatterCollectionProperties: - """Mix-in class for ScatterCollection properties""" - - @property - def colors(self) -> CollectionFeature: - """get or set colors of scatters in the collection""" - return CollectionFeature(self.graphics, "colors") - - @colors.setter - def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[str]): - if isinstance(values, str): - # set colors of all scatter to one str color - for g in self: - g.colors = values - return - - elif all(isinstance(v, str) for v in values): - # individual str colors for each scatter - if not len(values) == len(self): - raise IndexError - - for g, v in zip(self.graphics, values): - g.colors = v - - return - - if isinstance(values, np.ndarray): - if values.ndim == 2: - # assume individual colors for each - for g, v in zip(self, values): - g.colors = v - return - - elif len(values) == 4: - # assume RGBA - self.colors[:] = values - - else: - # assume individual colors for each - for g, v in zip(self, values): - g.colors = v - - @property - def data(self) -> CollectionFeature: - """get or set data of scatters in the collection""" - return CollectionFeature(self.graphics, "data") - - @data.setter - def data(self, values): - for g, v in zip(self, values): - g.data = v - - @property - def cmap(self) -> CollectionFeature: - """ - Get or set a cmap along the scatter collection. - - Optionally set using a tuple ("cmap", ) to set the transform. - Example: - - scatter_collection.cmap = ("jet", sine_transform_vals, 0.7) - - """ - return CollectionFeature(self.graphics, "cmap") - - @cmap.setter - def cmap(self, args): - if isinstance(args, str): - name = args - transform = None - elif len(args) == 1: - name = args[0] - transform = None - elif len(args) == 2: - name, transform = args - else: - raise ValueError( - "Too many values for cmap (note that alpha is deprecated, set alpha on the graphic instead)" - ) - - self.colors = parse_cmap_values( - n_colors=len(self), cmap_name=name, transform=transform - ) - - @property - def markers(self) -> CollectionFeature: - """get or set markers of scatters in the collection""" - return CollectionFeature(self.graphics, "markers") - - @markers.setter - def markers(self, values: str | Sequence[str]): - if isinstance(values, str): - values = repeat(values, len(self)) - - elif len(values) != len(self): - raise IndexError("len(markers) must be the same as the number of ScatterGraphics in the collection") - - for g, v in zip(self, values): - g.markers = v - - @property - def sizes(self) -> CollectionFeature: - """get or set sizes of scatter points in the collection""" - return CollectionFeature(self.graphics, "sizes") - - @sizes.setter - def sizes(self, values): - if isinstance(values, Number): - values = repeat(values, len(self)) - - elif len(values) != len(self): - raise IndexError("len(sizes) must be the same as the number of ScatterGraphics in the collection") - - for g, v in zip(self, values): - g.sizes = v - - -class ScatterCollectionIndexer(CollectionIndexer, _ScatterCollectionProperties): - """Indexer for scatter collections""" - pass - - -class ScatterCollection(GraphicCollection, _ScatterCollectionProperties): - _child_type = ScatterGraphic - _indexer = ScatterCollectionIndexer - - def __init__( - self, - data: np.ndarray | List[np.ndarray], - colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", - cmap: Sequence[str] | str = None, - cmap_transform: np.ndarray | List = None, - sizes: float | Sequence[float] = 5.0, - uniform_size: bool = True, - markers: np.ndarray | Sequence[str] = None, - uniform_marker: bool = True, - edge_width: float = 1.0, - name: str = None, - names: list[str] = None, - metadata: Any = None, - metadatas: Sequence[Any] | np.ndarray = None, - **kwargs, - ): - """ - Create a collection of :class:`.ScatterGraphic` - - Parameters - ---------- - data: list of array-like - List or array-like of multiple line data to plot - - | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array - | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] - - colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line - - cmap: Iterable of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines - - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` - - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap - - name: str, optional - name of the line collection as a whole - - names: list[str], optional - names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` - - metadata: Any - meatadata associated with the collection as a whole - - metadatas: Iterable or array - metadata for each individual line associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` - - kwargs_lines: list[dict], optional - list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - - kwargs_collection - kwargs for the collection, passed to GraphicCollection - - """ - - super().__init__(name=name, metadata=metadata, **kwargs) - - if names is not None: - if len(names) != len(data): - raise ValueError( - f"len(names) != len(data)\n{len(names)} != {len(data)}" - ) - - if metadatas is not None: - if len(metadatas) != len(data): - raise ValueError( - f"len(metadata) != len(data)\n{len(metadatas)} != {len(data)}" - ) - - self._cmap_transform = cmap_transform - self._cmap_str = cmap - - # cmap takes priority over colors - if cmap is not None: - # cmap across lines - if isinstance(cmap, str): - colors = parse_cmap_values( - n_colors=len(data), cmap_name=cmap, transform=cmap_transform - ) - single_color = False - cmap = None - - elif isinstance(cmap, (tuple, list)): - if len(cmap) != len(data): - raise ValueError( - "cmap argument must be a single cmap or a list of cmaps " - "with the same length as the data" - ) - single_color = False - else: - raise ValueError( - "cmap argument must be a single cmap or a list of cmaps " - "with the same length as the data" - ) - else: - if isinstance(colors, np.ndarray): - # single color for all lines in the collection as RGBA - if colors.shape in [(3,), (4,)]: - single_color = True - - # colors specified for each line as array of shape [n_lines, RGBA] - elif colors.shape == (len(data), 4): - single_color = False - - else: - raise ValueError( - f"numpy array colors argument must be of shape (4,) or (n_lines, 4)." - f"You have pass the following shape: {colors.shape}" - ) - - elif isinstance(colors, str): - if colors == "random": - colors = np.random.rand(len(data), 3) - single_color = False - else: - # parse string color - single_color = True - colors = pygfx.Color(colors) - - elif isinstance(colors, (tuple, list)): - if len(colors) == 4: - # single color specified as (R, G, B, A) tuple or list - if all([isinstance(c, (float, int)) for c in colors]): - single_color = True - - elif len(colors) == len(data): - # colors passed as list/tuple of colors, such as list of string - single_color = False - - else: - raise ValueError( - "tuple or list colors argument must be a single color represented as [R, G, B, A], " - "or must be a tuple/list of colors represented by a string with the same length as the data" - ) - - self._set_world_object(pygfx.Group()) - - for i, d in enumerate(data): - if cmap is None: - _cmap = None - - if single_color: - _c = colors - else: - _c = colors[i] - else: - _cmap = cmap[i] - _c = None - - if metadatas is not None: - _m = metadatas[i] - else: - _m = None - - if names is not None: - _name = names[i] - else: - _name = None - - if markers is not None: - if isinstance(markers, (tuple, list, np.ndarray)): - markers_ = markers[i] - else: - markers_ = markers - else: - markers_ = "o" - - if sizes is not None: - if isinstance(sizes, (tuple, list, np.ndarray)): - sizes_ = sizes[i] - else: - sizes_ = sizes - else: - sizes_ = 5 - - lg = ScatterGraphic( - data=d, - colors=_c, - sizes=sizes_, - markers=markers_, - cmap=_cmap, - name=_name, - metadata=_m, - uniform_marker=uniform_marker, - uniform_size=uniform_size, - edge_width=edge_width, - **kwargs, - ) - - self.add_graphic(lg) - - def __getitem__(self, item) -> ScatterCollectionIndexer: - return super().__getitem__(item) - - def add_linear_selector( - self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs - ) -> LinearSelector: - """ - Adds a linear selector. - - Parameters - ---------- - Parameters - ---------- - selection: float, optional - selected point on the linear selector, computed from data if not provided - - axis: str, default "x" - axis that the selector resides on - - padding: float, default 0.0 - Extra padding to extend the linear selector along the orthogonal axis to make it easier to interact with. - - kwargs - passed to :class:`.LinearSelector` - - Returns - ------- - LinearSelector - - """ - - bounds_init, limits, size, center = self._get_linear_selector_init_args( - axis, padding - ) - - if selection is None: - selection = bounds_init[0] - - selector = LinearSelector( - selection=selection, - limits=limits, - axis=axis, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - def add_linear_region_selector( - self, - selection: tuple[float, float] = None, - padding: float = 0.0, - axis: str = "x", - **kwargs, - ) -> LinearRegionSelector: - """ - Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, - remove, or delete them from a plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float), optional - the starting bounds of the linear region selector, computed from data if not provided - - axis: str, default "x" - axis that the selector resides on - - padding: float, default 0.0 - Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. - - kwargs - passed to ``LinearRegionSelector`` - - Returns - ------- - LinearRegionSelector - linear selection graphic - - """ - - bounds_init, limits, size, center = self._get_linear_selector_init_args( - axis, padding - ) - - if selection is None: - selection = bounds_init - - # create selector - selector = LinearRegionSelector( - selection=selection, - limits=limits, - size=size, - center=center, - axis=axis, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - # PlotArea manages this for garbage collection etc. just like all other Graphics - # so we should only work with a proxy on the user-end - return selector - - def add_rectangle_selector( - self, - selection: tuple[float, float, float] = None, - **kwargs, - ) -> RectangleSelector: - """ - Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, - remove, or delete them from a plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float, float, float), optional - initial (xmin, xmax, ymin, ymax) of the selection - """ - bbox = self.world_object.get_world_bounding_box() - - xdata = np.array(self.data[:, 0]) - xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) - value_25px = (xmax - xmin) / 4 - - ydata = np.array(self.data[:, 1]) - ymin = np.floor(ydata.min()).astype(int) - - ymax = np.ptp(bbox[:, 1]) - - if selection is None: - selection = (xmin, value_25px, ymin, ymax) - - limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) - - selector = RectangleSelector( - selection=selection, - limits=limits, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - def add_polygon_selector( - self, - selection: List[tuple[float, float]] = None, - **kwargs, - ) -> PolygonSelector: - """ - Add a :class:`.PolygonSelector`. Selectors are just ``Graphic`` objects, so you can manage, - remove, or delete them from a plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: List of positions, optional - Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). - """ - bbox = self.world_object.get_world_bounding_box() - - xdata = np.array(self.data[:, 0]) - xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) - - ydata = np.array(self.data[:, 1]) - ymin = np.floor(ydata.min()).astype(int) - - ymax = np.ptp(bbox[:, 1]) - - limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) - - selector = PolygonSelector( - selection, - limits, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - def _get_linear_selector_init_args(self, axis, padding): - # use bbox to get size and center - bbox = self.world_object.get_world_bounding_box() - - if axis == "x": - xdata = np.array(self.data[:, 0]) - xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) - value_25p = (xmax - xmin) / 4 - - bounds = (xmin, value_25p) - limits = (xmin, xmax) - # size from orthogonal axis - size = np.ptp(bbox[:, 1]) * 1.5 - # center on orthogonal axis - center = bbox[:, 1].mean() - - elif axis == "y": - ydata = np.array(self.data[:, 1]) - xmin, xmax = (np.nanmin(ydata), np.nanmax(ydata)) - value_25p = (xmax - xmin) / 4 - - bounds = (xmin, value_25p) - limits = (xmin, xmax) - - size = np.ptp(bbox[:, 0]) * 1.5 - # center on orthogonal axis - center = bbox[:, 0].mean() - - return bounds, limits, size, center - - -axes = {"x": 0, "y": 1, "z": 2} - - -class ScatterStack(ScatterCollection): - def __init__( - self, - data: np.ndarray | List[np.ndarray], - colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", - cmap: Sequence[str] | str = None, - cmap_transform: np.ndarray | List = None, - name: str = None, - names: list[str] = None, - metadata: Any = None, - metadatas: Sequence[Any] | np.ndarray = None, - separation: float = 0.0, - separation_axis: str = "y", - **kwargs, - ): - """ - Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. - - Parameters - ---------- - data: list of array-like - List or array-like of multiple line data to plot - - | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array - | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] - - thickness: float or Iterable of float, default 2.0 - | if ``float``, single thickness will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - - colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line - - cmap: Iterable of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines - - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` - - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap - - name: str, optional - name of the line collection as a whole - - names: list[str], optional - names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` - - metadata: Any - metadata associated with the collection as a whole - - metadatas: Iterable or array - metadata for each individual line associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` - - separation: float, default 0.0 - space in between each line graphic in the stack - - separation_axis: str, default "y" - axis in which the line graphics in the stack should be separated - - kwargs_collection - kwargs for the collection, passed to GraphicCollection - - """ - super().__init__( - data=data, - colors=colors, - cmap=cmap, - cmap_transform=cmap_transform, - name=name, - names=names, - metadata=metadata, - metadatas=metadatas, - **kwargs, - ) - - self._separation_axis = separation_axis - self._separation = separation - - self.separation = separation - - @property - def separation_axis(self) -> str: - """axis along which the graphics are separated: ``'x'`` or ``'y'``""" - return self._separation_axis - - @property - def separation(self) -> float: - """distance between each line in the stack, in world space""" - return self._separation - - @separation.setter - def separation(self, value: float): - separation = float(value) - - axis_zero = 0 - for i, line in enumerate(self.graphics): - if self._separation_axis == "x": - line.offset = (axis_zero, *line.offset[1:]) - - elif self._separation_axis == "y": - line.offset = (line.offset[0], axis_zero, line.offset[2]) - - axis_zero = ( - axis_zero + line.data.value[:, axes[self._separation_axis]].max() + separation - ) - - self._separation = value diff --git a/fastplotlib/graphics/selectors/_highlight_selector.py b/fastplotlib/graphics/selectors/_highlight_selector.py index dc757c07a..c0afd39b7 100644 --- a/fastplotlib/graphics/selectors/_highlight_selector.py +++ b/fastplotlib/graphics/selectors/_highlight_selector.py @@ -30,8 +30,6 @@ HighlightablePointsGaussianBlobMaterial, ) -cmap_lib.Colormap("tab10").lut() - def _build_lut( color: str | np.ndarray = "red", diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index f652a3d9e..e0914dbd4 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -268,7 +268,8 @@ def _get_selected_index(self, graphic): else: return round(idx) - if "Image" in graphic.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in graphic.__class__.__name__ and not hasattr(graphic, "graphics"): # indices map directly to grid geometry for image data buffer index = self.selection shape = graphic.data[:].shape diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 10dcfdc3e..fdc22bf80 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -342,7 +342,11 @@ def get_selected_data( source = self._get_source(graphic) - if source.data.value is None: + if hasattr(source, "graphics"): + fail = any([g.data.value is None for g in source.graphics]) + else: + fail = source.data.value is None + if fail: raise ValueError( "Cannot get selected data. The graphic has no local buffer, `cpu_buffer` is probably `False`." ) @@ -381,7 +385,8 @@ def get_selected_data( # slice with min, max is faster than using all the indices return source.data[s] - if "Image" in source.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in source.__class__.__name__ and not hasattr(source, "graphics"): s = slice(ixs[0], ixs[-1] + 1) if self.axis == "x": @@ -444,7 +449,8 @@ def get_selected_indices( return ixs - if "Image" in source.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in source.__class__.__name__ and not hasattr(source, "graphics"): # indices map directly to grid geometry for image data buffer return np.arange(*bounds, dtype=int) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 5a05bc886..e7f0fbd38 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -210,7 +210,8 @@ def get_selected_data( # do not need to check for mode for images, because the selector is bounded by the image shape # will always be `full` - if "Image" in source.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in source.__class__.__name__ and not hasattr(source, "graphics"): return source.data[ixs[:, 1], ixs[:, 0]] if mode not in ["full", "partial", "ignore"]: @@ -328,7 +329,8 @@ def get_selected_indices( # Empty ... if len(polygon) == 0: - if "Image" in source.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in source.__class__.__name__ and not hasattr(source, "graphics"): return np.zeros((0, 2), np.int32) if "Line" in source.__class__.__name__: if isinstance(source, GraphicCollection): @@ -342,7 +344,8 @@ def get_selected_indices( # image data does not need to check for mode because the selector is always bounded # to the image - if "Image" in source.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in source.__class__.__name__ and not hasattr(source, "graphics"): shape = source.data.value.shape col_ixs = np.arange(max(0, xmin), min(xmax, shape[1] - 1), dtype=int) row_ixs = np.arange(max(0, ymin), min(ymax, shape[0] - 1), dtype=int) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index f15f292f8..7428ace9d 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -391,7 +391,8 @@ def get_selected_data( # do not need to check for mode for images, because the selector is bounded by the image shape # will always be `full` - if "Image" in source.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in source.__class__.__name__ and not hasattr(source, "graphics"): row_ixs, col_ixs = ixs row_slice = slice(row_ixs[0], row_ixs[-1] + 1) col_slice = slice(col_ixs[0], col_ixs[-1] + 1) @@ -514,7 +515,8 @@ def get_selected_indices( # image data does not need to check for mode because the selector is always bounded # to the image - if "Image" in source.__class__.__name__: + # exclude collections, whose class name also contains "Image" + if "Image" in source.__class__.__name__ and not hasattr(source, "graphics"): col_ixs = np.arange(xmin, xmax, dtype=int) row_ixs = np.arange(ymin, ymax, dtype=int) return row_ixs, col_ixs diff --git a/fastplotlib/graphics/utils.py b/fastplotlib/graphics/utils.py index 0fc1aa088..d08bd9fc0 100644 --- a/fastplotlib/graphics/utils.py +++ b/fastplotlib/graphics/utils.py @@ -121,5 +121,8 @@ def get_nearest_graphics( nearest graphics to ``pos`` in order """ + if isinstance(graphics, GraphicCollection): + graphics = graphics.graphics + sort_indices = get_nearest_graphics_indices(pos, graphics) return np.asarray(graphics)[sort_indices] diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index d6189c4bd..2aa750607 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -1,19 +1,16 @@ # This is an auto-generated file and should not be modified directly -from typing import * - -import numpy -from numpy.typing import NDArray - -from numpy.typing import NDArray - -import pygfx - -from ..graphics import * -from ..graphics._base import Graphic -from ..utils import enums -import typing -import fastplotlib +from fastplotlib.graphics._collection_base import * +from fastplotlib.graphics._collections import * +from fastplotlib.graphics._vectors import * +from fastplotlib.graphics.image import * +from fastplotlib.graphics.image_volume import * +from fastplotlib.graphics.inf_line import * +from fastplotlib.graphics.line import * +from fastplotlib.graphics.mesh import * +from fastplotlib.graphics.scatter import * +from fastplotlib.graphics.text import * +from fastplotlib.graphics import Graphic class GraphicMethodsMixin: @@ -23,6 +20,9 @@ def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: else: center = False + # ignore arguments left at their default of None, i.e. not passed by the caller + kwargs = {k: v for k, v in kwargs.items() if v is not None} + if "name" in kwargs.keys(): self._check_graphic_name_exists(kwargs["name"]) @@ -31,6 +31,141 @@ def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: return graphic + def add_collection(self, data, **kwargs) -> GraphicCollection: + """ + + Create a collection of graphics of the same type. + + Parameters + ---------- + data: list of array-like + one entry per graphic; its length is the number of graphics in the collection + + **kwargs + any feature of the child graphic (``colors``, ``thickness``, ``sizes``, ...), each + accepting one value for all graphics or one value per graphic. A ``Graphic`` argument + (``name``, ``offset``, ``visible``, ...) sets it on the collection itself, its plural + form (``names``, ``offsets``, ``visibles``, ...) sets it per graphic. Any argument that + is not a feature is passed unchanged to every child graphic. + + """ + return self._create_graphic(GraphicCollection, data, **kwargs) + + def add_image_collection( + self, + data: Any, + vmin: float = None, + vmax: float = None, + cmap: str = "plasma", + gamma: float = 1.0, + interpolation: str = "nearest", + cmap_interpolation: str = "linear", + colorspace: ColorspacesRGB = "srgb", + cpu_buffer: bool = True, + *, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, + **kwargs + ) -> ImageCollection: + """ + + Create an ImageGraphic + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()`` + | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA + + vmin: float, optional + minimum value for color scaling, estimated from data if not provided + + vmax: float, optional + maximum value for color scaling, estimated from data if not provided + + cmap: str, optional, default "plasma" + colormap to use to display the data. For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" + + colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" + colorspace in which to interpret the provided data. + + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. + + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. + + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + + """ + return self._create_graphic( + ImageCollection, + data, + vmin=vmin, + vmax=vmax, + cmap=cmap, + gamma=gamma, + interpolation=interpolation, + cmap_interpolation=cmap_interpolation, + colorspace=colorspace, + cpu_buffer=cpu_buffer, + names=names, + offsets=offsets, + rotations=rotations, + scales=scales, + alphas=alphas, + alpha_modes=alpha_modes, + visibles=visibles, + metadatas=metadatas, + **kwargs + ) + def add_image( self, data: Any, @@ -40,7 +175,7 @@ def add_image( gamma: float = 1.0, interpolation: str = "nearest", cmap_interpolation: str = "linear", - colorspace: fastplotlib.utils.enums.ColorspacesRGB = "srgb", + colorspace: ColorspacesRGB = "srgb", cpu_buffer: bool = True, **kwargs ) -> ImageGraphic: @@ -129,6 +264,125 @@ def add_image( **kwargs ) + def add_image_grid( + self, + data: Any, + vmin: float = None, + vmax: float = None, + cmap: str = "plasma", + gamma: float = 1.0, + interpolation: str = "nearest", + cmap_interpolation: str = "linear", + colorspace: ColorspacesRGB = "srgb", + cpu_buffer: bool = True, + *, + shape: tuple[int, int] = None, + separation: tuple[float, float] = (0.0, 0.0), + offsets: np.ndarray = None, + names=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, + **kwargs + ) -> ImageGrid: + """ + + Create an ImageGraphic + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()`` + | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA + + vmin: float, optional + minimum value for color scaling, estimated from data if not provided + + vmax: float, optional + maximum value for color scaling, estimated from data if not provided + + cmap: str, optional, default "plasma" + colormap to use to display the data. For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" + + colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" + colorspace in which to interpret the provided data. + + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. + + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. + + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + + """ + return self._create_graphic( + ImageGrid, + data, + vmin=vmin, + vmax=vmax, + cmap=cmap, + gamma=gamma, + interpolation=interpolation, + cmap_interpolation=cmap_interpolation, + colorspace=colorspace, + cpu_buffer=cpu_buffer, + shape=shape, + separation=separation, + offsets=offsets, + names=names, + rotations=rotations, + scales=scales, + alphas=alphas, + alpha_modes=alpha_modes, + visibles=visibles, + metadatas=metadatas, + **kwargs + ) + def add_image_volume( self, data: Any, @@ -143,7 +397,7 @@ def add_image_volume( threshold: float = 0.5, step_size: float = 1.0, substep_size: float = 0.1, - emissive: str | tuple | numpy.ndarray = (0, 0, 0), + emissive: str | tuple | np.ndarray = (0, 0, 0), shininess: int = 30, **kwargs ) -> ImageVolumeGraphic: @@ -231,16 +485,13 @@ def add_image_volume( def add_image_yuv( self, - data: ( - tuple[NDArray[numpy.uint8], NDArray[numpy.uint8], NDArray[numpy.uint8]] - | fastplotlib.graphics.features._image.TextureYUV - ), + data: TupleYUV | TextureYUV, vmin: float = 0, vmax: float = 255, gamma: float = 1.0, interpolation: str = "nearest", - colorspace: fastplotlib.utils.enums.ColorspacesYUV = "yuv420p", - colorrange: fastplotlib.utils.enums.ColorRange = "limited", + colorspace: ColorspacesYUV = "yuv420p", + colorrange: ColorRange = "limited", **kwargs ) -> ImageYUVGraphic: """ @@ -328,12 +579,12 @@ def add_image_yuv( def add_inf_line( self, data: Any, - axis: Optional[Literal["x", "y", "z"]] = None, + axis: Literal["x", "y", "z"] | None = None, thickness: float = 2.0, - colors: Union[str, numpy.ndarray, Sequence] = "w", - cmap: str = None, - cmap_transform: Union[numpy.ndarray, Sequence] = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, start_is_infinite: bool = True, end_is_infinite: bool = True, dash_pattern: str | tuple | list = (), @@ -359,7 +610,7 @@ def add_inf_line( thickness: float, optional, default 2.0 thickness of the lines - colors: str, array, or iterable, default "w" + colors: ColorLike or MultiColorLike, default "w" specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays. A sequence of colors provides one color per line. @@ -369,14 +620,11 @@ def add_inf_line( This overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - color_mode: one of "auto", "uniform", "vertex", default "auto" - "uniform" restricts to a single color for all lines. - "vertex" allows an independent color per line. - For most cases you can keep it as "auto" and the `color_mode` is determined automatically - based on the argument passed to `colors`. + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range start_is_infinite: bool, default True whether the start of each line is extended to infinity @@ -405,7 +653,7 @@ def add_inf_line( colors, cmap, cmap_transform, - color_mode, + cmap_range, start_is_infinite, end_is_infinite, dash_pattern, @@ -415,88 +663,92 @@ def add_inf_line( def add_line_collection( self, - data: Union[numpy.ndarray, List[numpy.ndarray]], - thickness: Union[float, Sequence[float]] = 2.0, - colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", - cmap: Union[Sequence[str], str] = None, - cmap_transform: Union[numpy.ndarray, List] = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", - name: str = None, - names: list[str] = None, - metadata: Any = None, - metadatas: Union[Sequence[Any], numpy.ndarray] = None, - kwargs_lines: list[dict] = None, + data: Any, + thickness: float = 2.0, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | Iterable[int | float] | None = None, + cmap_range: tuple[float, float] | None = None, + size_space: str = "screen", + dash_pattern: str | tuple | list = (), + thin: bool = False, + *, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, **kwargs ) -> LineCollection: """ - Create a collection of :class:`.LineGraphic` + Create a line Graphic, 2d or 3d Parameters ---------- - data: list of array-like - List or array-like of multiple line data to plot - - | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array - | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] - - thickness: float or Iterable of float, default 2.0 - | if ``float``, single thickness will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - - colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line - - cmap: Iterable of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines + data: array-like + Line data to plot. Can provide 1D, 2D, or a 3D data. + | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range + from [0, data.size] + | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` + thickness: float, optional, default 2.0 + thickness of the line - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays - color_mode: one of "auto", "uniform", "vertex", default "auto" - The color mode for each line in the collection. See `color_mode` in :class:`.LineGraphic` for details. + cmap: ColormapLike, optional + Apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - name: str, optional - name of the line collection as a whole + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap - names: list[str], optional - names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range - metadata: Any - meatadata associated with the collection as a whole + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") - metadatas: Iterable or array - metadata for each individual line associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. - kwargs_lines: list[dict], optional - list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + thin: bool, default False + Use the more performant thin line material, which is always one physical pixel wide. + Thickness, dashing, and anti-aliasing are ignored when True. - kwargs_collection - kwargs for the collection, passed to GraphicCollection + **kwargs + passed to :class:`.Graphic` """ return self._create_graphic( LineCollection, data, - thickness, - colors, - cmap, - cmap_transform, - color_mode, - name, - names, - metadata, - metadatas, - kwargs_lines, + thickness=thickness, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + cmap_range=cmap_range, + size_space=size_space, + dash_pattern=dash_pattern, + thin=thin, + names=names, + offsets=offsets, + rotations=rotations, + scales=scales, + alphas=alphas, + alpha_modes=alpha_modes, + visibles=visibles, + metadatas=metadatas, **kwargs ) @@ -504,10 +756,10 @@ def add_line( self, data: Any, thickness: float = 2.0, - colors: Union[str, numpy.ndarray, Sequence] = "w", - cmap: str = None, - cmap_transform: Union[numpy.ndarray, Sequence] = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | Iterable[int | float] | None = None, + cmap_range: tuple[float, float] | None = None, size_space: str = "screen", dash_pattern: str | tuple | list = (), thin: bool = False, @@ -528,25 +780,20 @@ def add_line( thickness: float, optional, default 2.0 thickness of the line - colors: str, array, or iterable, default "w" + colors: ColorLike or MultiColorLike, default "w" specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - cmap: str, optional + cmap: ColormapLike, optional Apply a colormap to the line instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - color_mode: one of "auto", "uniform", "vertex", default "auto" - "uniform" restricts to a single color for all line datapoints. - "vertex" allows independent colors per vertex. - For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the - argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". - If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to - "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range size_space: str, default "screen" coordinate space in which the thickness is expressed ("screen", "world", "model") @@ -572,7 +819,7 @@ def add_line( colors, cmap, cmap_transform, - color_mode, + cmap_range, size_space, dash_pattern, thin, @@ -581,94 +828,98 @@ def add_line( def add_line_stack( self, - data: List[numpy.ndarray], - thickness: Union[float, Iterable[float]] = 2.0, - colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w", - cmap: Union[Iterable[str], str] = None, - cmap_transform: Union[numpy.ndarray, List] = None, - name: str = None, - names: list[str] = None, - metadata: Any = None, - metadatas: Union[Sequence[Any], numpy.ndarray] = None, - separation: float = 10.0, + data: Any, + thickness: float = 2.0, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | Iterable[int | float] | None = None, + cmap_range: tuple[float, float] | None = None, + size_space: str = "screen", + dash_pattern: str | tuple | list = (), + thin: bool = False, + *, + separation: tuple[float, float, float] = (0.0, 0.0, 0.0), separation_axis: str = "y", - kwargs_lines: list[dict] = None, + steps: np.ndarray = None, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, **kwargs ) -> LineStack: """ - Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. + Create a line Graphic, 2d or 3d Parameters ---------- - data: list of array-like - List or array-like of multiple line data to plot - - | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array - | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] - - thickness: float or Iterable of float, default 2.0 - | if ``float``, single thickness will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - - colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line - - cmap: Iterable of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines - - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` - - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap + data: array-like + Line data to plot. Can provide 1D, 2D, or a 3D data. + | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range + from [0, data.size] + | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] - name: str, optional - name of the line collection as a whole + thickness: float, optional, default 2.0 + thickness of the line - names: list[str], optional - names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays - metadata: Any - metadata associated with the collection as a whole + cmap: ColormapLike, optional + Apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - metadatas: Iterable or array - metadata for each individual line associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap - separation: float, default 10 - space in between each line graphic in the stack + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range - separation_axis: str, default "y" - axis in which the line graphics in the stack should be separated + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. - kwargs_lines: list[dict], optional - list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + thin: bool, default False + Use the more performant thin line material, which is always one physical pixel wide. + Thickness, dashing, and anti-aliasing are ignored when True. - kwargs_collection - kwargs for the collection, passed to GraphicCollection + **kwargs + passed to :class:`.Graphic` """ return self._create_graphic( LineStack, data, - thickness, - colors, - cmap, - cmap_transform, - name, - names, - metadata, - metadatas, - separation, - separation_axis, - kwargs_lines, + thickness=thickness, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + cmap_range=cmap_range, + size_space=size_space, + dash_pattern=dash_pattern, + thin=thin, + separation=separation, + separation_axis=separation_axis, + steps=steps, + names=names, + offsets=offsets, + rotations=rotations, + scales=scales, + alphas=alphas, + alpha_modes=alpha_modes, + visibles=visibles, + metadatas=metadatas, **kwargs ) @@ -678,15 +929,9 @@ def add_mesh( indices: Any, mode: Literal["basic", "phong", "slice"] = "phong", plane: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 0.0), - colors: Union[str, numpy.ndarray, Sequence] = "w", + colors: str | np.ndarray | Sequence = "w", mapcoords: Any = None, - cmap: ( - str - | dict - | pygfx.resources._texture.Texture - | pygfx.resources._texturemap.TextureMap - | numpy.ndarray - ) = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] = None, **kwargs ) -> MeshGraphic: @@ -747,17 +992,11 @@ def add_mesh( def add_polygon( self, - data: numpy.ndarray, + data: np.ndarray, mode: Literal["basic", "phong"] = "basic", - colors: Union[str, numpy.ndarray, Sequence] = "w", + colors: str | np.ndarray | Sequence = "w", mapcoords: Any = None, - cmap: ( - str - | dict - | pygfx.resources._texture.Texture - | pygfx.resources._texturemap.TextureMap - | numpy.ndarray - ) = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] | None = None, **kwargs ) -> PolygonGraphic: @@ -804,109 +1043,164 @@ def add_polygon( def add_scatter_collection( self, - data: Union[numpy.ndarray, List[numpy.ndarray]], - colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", - cmap: Union[Sequence[str], str] = None, - cmap_transform: Union[numpy.ndarray, List] = None, - sizes: Union[float, Sequence[float]] = 5.0, - uniform_size: bool = True, - markers: Union[numpy.ndarray, Sequence[str]] = None, - uniform_marker: bool = True, + data: Any, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, + mode: Literal["markers", "simple", "gaussian", "image"] = "markers", + markers: str | np.ndarray | Sequence[str] = "o", + custom_sdf: str = None, + edge_colors: ColorLike | MultiColorLike | None = "black", edge_width: float = 1.0, - name: str = None, - names: list[str] = None, - metadata: Any = None, - metadatas: Union[Sequence[Any], numpy.ndarray] = None, + image: np.ndarray = None, + point_rotations: float | np.ndarray | None = None, + sizes: float | np.ndarray | Sequence[float] = 5, + size_space: str = "screen", + *, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, **kwargs ) -> ScatterCollection: """ - Create a collection of :class:`.ScatterGraphic` + Create a Scatter Graphic, 2d or 3d Parameters ---------- - data: list of array-like - List or array-like of multiple line data to plot + data: array-like + Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. + 3D data must be of shape [n_points, 3] + + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays + + cmap: ColormapLike, optional + apply a colormap to the scatter instead of assigning colors manually, this + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ + + cmap_transform: np.ndarray, optional + 1D array-like or list of numerical values, these values are used to map the colors from the cmap + + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range + + mode: one of: "markers", "simple", "gaussian", "image", default "markers" + The scatter points mode, cannot be changed after the graphic has been created. + + * markers: represent points with various or custom markers, default + * simple: all scatters points are simple circles + * gaussian: each point is a gaussian blob + * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites - | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array - | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + markers: str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers". Specify a single marker to use the same + marker for all points, or a Sequence of markers for per-vertex markers. - colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + Supported values: - cmap: Iterable of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines + * A string from pygfx.MarkerShape enum + * Matplotlib compatible characters: "osD+x^v<>*". + * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". + * Emojis: "❤️♠️♣️♦️💎💍✳️📍". + * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` + custom_sdf: str = None, + The SDF code for the marker shape when the marker is set to custom. + Can be used when `mode` is "markers". - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap + Negative values are inside the shape, positive values are outside the + shape. - name: str, optional - name of the line collection as a whole + The SDF's takes in two parameters `coords: vec2` and `size: f32`. + The first is a WGSL coordinate and `size` is the overall size of + the texture. The returned value should be the signed distance from + any edge of the shape. Distances (positive and negative) that are + less than half the `edge_width` in absolute terms will be colored + with the `edge_color`. Other negative distances will be colored by + `colors`. - names: list[str], optional - names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + edge_colors: ColorLike, MultiColorLike, or None, default "black" + edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the + same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass + ``None`` for no edge color. - metadata: Any - meatadata associated with the collection as a whole + edge_width: float = 1.0, + Width of the marker edges. used when `mode` is "markers". - metadatas: Iterable or array - metadata for each individual line associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` + image: array-like, optional + renders an image at the scatter points, also known as sprites. + The image color is multiplied with the point's "normal" color. - kwargs_lines: list[dict], optional - list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + point_rotations: float, array-like, or None, default None + The rotation of the scatter points in radians. The rotation mode is determined automatically from + the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve + of the data (in screen space); a single float for the same rotation on every point ("uniform"); or + an array of rotation values for per-point rotations ("vertex"). - kwargs_collection - kwargs for the collection, passed to GraphicCollection + sizes: float, np.ndarray, or Sequence[float], default 5 + size(s) of the scatter points. Specify a single size to use the same size for all points, or a + Sequence of sizes for per-point sizes. + + size_space: str, default "screen" + coordinate space in which the size is expressed, one of ("screen", "world", "model") + + kwargs + passed to :class:`.Graphic` """ return self._create_graphic( ScatterCollection, data, - colors, - cmap, - cmap_transform, - sizes, - uniform_size, - markers, - uniform_marker, - edge_width, - name, - names, - metadata, - metadatas, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + cmap_range=cmap_range, + mode=mode, + markers=markers, + custom_sdf=custom_sdf, + edge_colors=edge_colors, + edge_width=edge_width, + image=image, + point_rotations=point_rotations, + sizes=sizes, + size_space=size_space, + names=names, + offsets=offsets, + rotations=rotations, + scales=scales, + alphas=alphas, + alpha_modes=alpha_modes, + visibles=visibles, + metadatas=metadatas, **kwargs ) def add_scatter( self, data: Any, - colors: Union[str, numpy.ndarray, Sequence[float], Sequence[str]] = "w", - cmap: str = None, - cmap_transform: numpy.ndarray = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, mode: Literal["markers", "simple", "gaussian", "image"] = "markers", - markers: Union[str, numpy.ndarray, Sequence[str]] = "o", - uniform_marker: bool = True, + markers: str | np.ndarray | Sequence[str] = "o", custom_sdf: str = None, - edge_colors: Union[ - str, pygfx.utils.color.Color, numpy.ndarray, Sequence[float] - ] = "black", - uniform_edge_color: bool = True, + edge_colors: ColorLike | MultiColorLike | None = "black", edge_width: float = 1.0, - image: numpy.ndarray = None, - point_rotations: float | numpy.ndarray = 0, - point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", - sizes: Union[float, numpy.ndarray, Sequence[float]] = 5, - uniform_size: bool = True, + image: np.ndarray = None, + point_rotations: float | np.ndarray | None = None, + sizes: float | np.ndarray | Sequence[float] = 5, size_space: str = "screen", **kwargs ) -> ScatterGraphic: @@ -920,26 +1214,21 @@ def add_scatter( Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] - colors: str, array, tuple, list, Sequence, default "w" + colors: ColorLike or MultiColorLike, default "w" specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - cmap: str, optional + cmap: ColormapLike, optional apply a colormap to the scatter instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - cmap_transform: 1D array-like or list of numerical values, optional - if provided, these values are used to map the colors from the cmap + cmap_transform: np.ndarray, optional + 1D array-like or list of numerical values, these values are used to map the colors from the cmap - color_mode: one of "auto", "uniform", "vertex", default "auto" - "uniform" restricts to a single color for all line datapoints. - "vertex" allows independent colors per vertex. - For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the - argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". - If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to - "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range mode: one of: "markers", "simple", "gaussian", "image", default "markers" The scatter points mode, cannot be changed after the graphic has been created. @@ -949,8 +1238,9 @@ def add_scatter( * gaussian: each point is a gaussian blob * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites - markers: None | str | np.ndarray | Sequence[str], default "o" - The shape of the markers when `mode` is "markers" + markers: str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers". Specify a single marker to use the same + marker for all points, or a Sequence of markers for per-vertex markers. Supported values: @@ -960,11 +1250,6 @@ def add_scatter( * Emojis: "❤️♠️♣️♦️💎💍✳️📍". * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - uniform_marker: bool, default ``True`` - If ``True``, use the same marker for all points. Only valid when `mode` is "markers". - Useful if you need to use the same marker for all points and want to save GPU RAM. If ``False``, you can - set per-vertex markers. - custom_sdf: str = None, The SDF code for the marker shape when the marker is set to custom. Can be used when `mode` is "markers". @@ -980,35 +1265,27 @@ def add_scatter( with the `edge_color`. Other negative distances will be colored by `colors`. - edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" - edge color of the markers, used when `mode` is "markers" - - uniform_edge_color: bool, default ``True`` - Set the same edge color for all markers. Useful for saving GPU RAM. Set to ``False`` for per-vertex edge - colors + edge_colors: ColorLike, MultiColorLike, or None, default "black" + edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the + same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass + ``None`` for no edge color. edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". - image: ArrayLike, optional + image: array-like, optional renders an image at the scatter points, also known as sprites. The image color is multiplied with the point's "normal" color. - point_rotations: float | ArrayLike = 0, - The rotation of the scatter points in radians. Default 0. A single float rotation value can be set on all - points, or an array of rotation values can be used to set per-point rotations - - point_rotation_mode: one of: "uniform" | "vertex" | "curve", default "uniform" - * uniform: set the same rotation for every point, useful to save GPU RAM - * vertex: set per-vertex rotations - * curve: The rotation follows the curve of the line defined by the points (in screen space) + point_rotations: float, array-like, or None, default None + The rotation of the scatter points in radians. The rotation mode is determined automatically from + the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve + of the data (in screen space); a single float for the same rotation on every point ("uniform"); or + an array of rotation values for per-point rotations ("vertex"). - sizes: float or iterable of float, optional, default 1.0 - sizes of the scatter points - - uniform_size: bool, default ``False`` - if ``True``, uses a uniform buffer for the scatter point sizes. Useful if you need to - save GPU VRAM when all points have the same size. Set to ``False`` if you need per-vertex sizes. + sizes: float, np.ndarray, or Sequence[float], default 5 + size(s) of the scatter points. Specify a single size to use the same size for all points, or a + Sequence of sizes for per-point sizes. size_space: str, default "screen" coordinate space in which the size is expressed, one of ("screen", "world", "model") @@ -1024,121 +1301,177 @@ def add_scatter( colors, cmap, cmap_transform, - color_mode, + cmap_range, mode, markers, - uniform_marker, custom_sdf, edge_colors, - uniform_edge_color, edge_width, image, point_rotations, - point_rotation_mode, sizes, - uniform_size, size_space, **kwargs ) def add_scatter_stack( self, - data: Union[numpy.ndarray, List[numpy.ndarray]], - colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", - cmap: Union[Sequence[str], str] = None, - cmap_transform: Union[numpy.ndarray, List] = None, - name: str = None, - names: list[str] = None, - metadata: Any = None, - metadatas: Union[Sequence[Any], numpy.ndarray] = None, - separation: float = 0.0, + data: Any, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, + mode: Literal["markers", "simple", "gaussian", "image"] = "markers", + markers: str | np.ndarray | Sequence[str] = "o", + custom_sdf: str = None, + edge_colors: ColorLike | MultiColorLike | None = "black", + edge_width: float = 1.0, + image: np.ndarray = None, + point_rotations: float | np.ndarray | None = None, + sizes: float | np.ndarray | Sequence[float] = 5, + size_space: str = "screen", + *, + separation: tuple[float, float, float] = (0.0, 0.0, 0.0), separation_axis: str = "y", + steps: np.ndarray = None, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, **kwargs ) -> ScatterStack: """ - Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. + Create a Scatter Graphic, 2d or 3d Parameters ---------- - data: list of array-like - List or array-like of multiple line data to plot + data: array-like + Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. + 3D data must be of shape [n_points, 3] - | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array - | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays - thickness: float or Iterable of float, default 2.0 - | if ``float``, single thickness will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines + cmap: ColormapLike, optional + apply a colormap to the scatter instead of assigning colors manually, this + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ - colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + cmap_transform: np.ndarray, optional + 1D array-like or list of numerical values, these values are used to map the colors from the cmap - cmap: Iterable of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` + mode: one of: "markers", "simple", "gaussian", "image", default "markers" + The scatter points mode, cannot be changed after the graphic has been created. - cmap_transform: 1D array-like of numerical values, optional - if provided, these values are used to map the colors from the cmap + * markers: represent points with various or custom markers, default + * simple: all scatters points are simple circles + * gaussian: each point is a gaussian blob + * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites - name: str, optional - name of the line collection as a whole + markers: str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers". Specify a single marker to use the same + marker for all points, or a Sequence of markers for per-vertex markers. - names: list[str], optional - names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + Supported values: - metadata: Any - metadata associated with the collection as a whole + * A string from pygfx.MarkerShape enum + * Matplotlib compatible characters: "osD+x^v<>*". + * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". + * Emojis: "❤️♠️♣️♦️💎💍✳️📍". + * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - metadatas: Iterable or array - metadata for each individual line associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` + custom_sdf: str = None, + The SDF code for the marker shape when the marker is set to custom. + Can be used when `mode` is "markers". - separation: float, default 0.0 - space in between each line graphic in the stack + Negative values are inside the shape, positive values are outside the + shape. - separation_axis: str, default "y" - axis in which the line graphics in the stack should be separated + The SDF's takes in two parameters `coords: vec2` and `size: f32`. + The first is a WGSL coordinate and `size` is the overall size of + the texture. The returned value should be the signed distance from + any edge of the shape. Distances (positive and negative) that are + less than half the `edge_width` in absolute terms will be colored + with the `edge_color`. Other negative distances will be colored by + `colors`. + + edge_colors: ColorLike, MultiColorLike, or None, default "black" + edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the + same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass + ``None`` for no edge color. + + edge_width: float = 1.0, + Width of the marker edges. used when `mode` is "markers". + + image: array-like, optional + renders an image at the scatter points, also known as sprites. + The image color is multiplied with the point's "normal" color. + + point_rotations: float, array-like, or None, default None + The rotation of the scatter points in radians. The rotation mode is determined automatically from + the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve + of the data (in screen space); a single float for the same rotation on every point ("uniform"); or + an array of rotation values for per-point rotations ("vertex"). + + sizes: float, np.ndarray, or Sequence[float], default 5 + size(s) of the scatter points. Specify a single size to use the same size for all points, or a + Sequence of sizes for per-point sizes. + + size_space: str, default "screen" + coordinate space in which the size is expressed, one of ("screen", "world", "model") - kwargs_collection - kwargs for the collection, passed to GraphicCollection + kwargs + passed to :class:`.Graphic` """ return self._create_graphic( ScatterStack, data, - colors, - cmap, - cmap_transform, - name, - names, - metadata, - metadatas, - separation, - separation_axis, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + cmap_range=cmap_range, + mode=mode, + markers=markers, + custom_sdf=custom_sdf, + edge_colors=edge_colors, + edge_width=edge_width, + image=image, + point_rotations=point_rotations, + sizes=sizes, + size_space=size_space, + separation=separation, + separation_axis=separation_axis, + steps=steps, + names=names, + offsets=offsets, + rotations=rotations, + scales=scales, + alphas=alphas, + alpha_modes=alpha_modes, + visibles=visibles, + metadatas=metadatas, **kwargs ) def add_surface( self, - data: numpy.ndarray, + data: np.ndarray, mode: Literal["basic", "phong", "slice"] = "phong", - colors: Union[str, numpy.ndarray, Sequence] = "w", + colors: str | np.ndarray | Sequence = "w", mapcoords: Any = None, - cmap: ( - str - | dict - | pygfx.resources._texture.Texture - | pygfx.resources._texturemap.TextureMap - | numpy.ndarray - ) = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] | None = None, **kwargs ) -> SurfaceGraphic: @@ -1188,8 +1521,8 @@ def add_text( self, text: str, font_size: float | int = 14, - face_color: str | numpy.ndarray | list[float] | tuple[float] = "w", - outline_color: str | numpy.ndarray | list[float] | tuple[float] = "w", + face_color: str | np.ndarray | list[float] | tuple[float] = "w", + outline_color: str | np.ndarray | list[float] | tuple[float] = "w", outline_thickness: float = 0.0, screen_space: bool = True, offset: tuple[float] = (0, 0, 0), @@ -1250,9 +1583,9 @@ def add_text( def add_vectors( self, - positions: Union[numpy.ndarray, Sequence[float]], - directions: Union[numpy.ndarray, Sequence[float]], - color: Union[str, Sequence[float], numpy.ndarray] = "w", + positions: np.ndarray | Sequence[float], + directions: np.ndarray | Sequence[float], + color: str | Sequence[float] | np.ndarray = "w", size: float = None, vector_shape_options: dict = None, **kwargs diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 9b6c83c6c..24d52b3c5 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -206,10 +206,6 @@ def make_colors(n_colors: int, cmap: str, alpha: float = 1.0) -> np.ndarray: return cm(cm_ixs).astype(np.float32) -def get_cmap_texture(name: str, alpha: float = 1.0) -> Texture: - return Texture(get_cmap(name, alpha), dim=1) - - def make_colors_dict(labels: Sequence, cmap: str, **kwargs) -> OrderedDict: """ Get a dict for mapping labels onto colors. @@ -339,74 +335,6 @@ def normalize_min_max(a): return (a - np.min(a)) / (np.max(a - np.min(a))) -def parse_cmap_values( - n_colors: int, - cmap_name: str, - transform: np.ndarray | list[int | float] = None, -) -> np.ndarray: - """ - - Parameters - ---------- - n_colors: int - number of graphics in collection - - cmap_name: str - colormap name - - transform: np.ndarray | List[int | float], optional - cmap transform - Returns - ------- - - """ - if transform is None: - colors = make_colors(n_colors, cmap_name) - return colors - - else: - if not isinstance(transform, np.ndarray): - transform = np.array(transform) - - # use the of the cmap_transform to set the color of the corresponding data - # each individual data[i] has its color based on the transform values - if len(transform) != n_colors: - raise ValueError( - f"len(cmap_values) != len(data): {len(transform)} != {n_colors}" - ) - - colormap = get_cmap(cmap_name) - - n_colors = colormap.shape[0] - 1 - - # can also use cm.category == "qualitative" - if cmap_lib.Colormap(cmap_name).interpolation == "nearest": - - # check that cmap_values are and within the number of colors `n_colors` - - # do not scale, use directly - if not np.issubdtype(transform.dtype, np.integer): - raise TypeError( - f" `cmap_transform` values should be used with qualitative colormaps, " - f"the dtype you have passed is {transform.dtype}" - ) - if max(transform) > n_colors: - raise IndexError( - f"You have chosen the qualitative colormap <'{cmap_name}'> which only has " - f"<{n_colors}> colors, which is lower than the max value of your `cmap_transform`." - f"Choose a cmap with more colors, or a non-quantitative colormap." - ) - norm_cmap_values = transform - else: - # scale between 0 - n_colors so we can just index the colormap as a LUT - norm_cmap_values = (normalize_min_max(transform) * n_colors).astype(int) - - # use colormap as LUT to map the cmap_values to the colormap index - colors = np.vstack([colormap[val] for val in norm_cmap_values]) - - return colors - - def cuda_to_numpy(arr: CudaArrayProtocol) -> np.ndarray: data = np.from_dlpack(arr, device='cpu') diff --git a/fastplotlib/utils/gui.py b/fastplotlib/utils/gui.py index 6a0d8dfdc..c17e11e12 100644 --- a/fastplotlib/utils/gui.py +++ b/fastplotlib/utils/gui.py @@ -38,7 +38,7 @@ # Get the name of the backend ('qt', 'glfw', 'jupyter') GUI_BACKEND = RenderCanvas.__module__.split(".")[-1] -IS_JUPYTER = GUI_BACKEND == "jupyter" +IS_JUPYTER = GUI_BACKEND == "anywidget" # --- Some backend-specific preparations @@ -120,7 +120,7 @@ def _notebook_print_banner(): display(HTML(table_str)) -if GUI_BACKEND == "jupyter": +if GUI_BACKEND == "anywidget": _notebook_print_banner() elif GUI_BACKEND == "qt": diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index ce17baef7..af727dc87 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -232,7 +232,18 @@ def n_slider_dims(self): def window_funcs( self, ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: - """get or set window functions, see docstring for details""" + """ + Get or set the per-slider-dim window functions applied around the current slider position, + ``{dim_name: (func, window_size)}``, ex: ``{"time": (np.mean, 2.5)}``. + + *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). It **must** + return an array that has the same dims as the input, therefore the size of any dim along which it was + applied should reduce to ``1``. These dims must not be removed by the window func. *window_size* is in + reference-space units (ex: 2.5 seconds). + + A window func is only applied for the dims listed in :attr:`window_order`. Any dim without an entry is + filled in with ``(None, None)``. + """ return self._window_funcs @window_funcs.setter @@ -322,7 +333,15 @@ def spatial_func( @property def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: - """get or set the slider_dim_transforms, see docstring for details""" + """ + Get or set the per-slider-dim mapping from reference-space values to local array indices, + ``{dim_name: transform}``. + + A transform may be given as a Callable that takes a reference-space value and returns an array index, or + as an array of reference values in which case its ``searchsorted`` is used as the transform (ex: a + timestamps array). Any dim given ``None``, or not given at all, uses the identity mapping, i.e. the + reference value is rounded to the nearest integer and used as the array index. + """ return self._index_mappings @slider_dim_transforms.setter @@ -503,14 +522,26 @@ async def _apply_window_functions( async def get_window_output(self, indices: dict[str, Any]) -> ArrayProtocol: """ - Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims + Take the data slice at the given indices and apply the window functions. Parameters ---------- - indices + indices: dict[str, Any] + Reference-space value for each slider dim, ex: ``{"time": 46.397, "depth": 23.24}``. Must provide a + value for every slider dim. Returns ------- + ArrayProtocol + Data slice with the window funcs applied and the slider dims, which are of size ``1`` after + windowing, squeezed out. The remaining dims are the spatial dims, in the order they appear in + ``dims``, **not** in ``spatial_dims`` display order. Subclasses transpose into display order in + :meth:`get`. + + Raises + ------ + ValueError + If the number of dims left after squeezing does not equal the number of spatial dims. """ # windowed slice if user set any window funcs @@ -561,6 +592,26 @@ async def _get_raw_data_slice(self, indices: dict[str, Any]) -> ArrayProtocol: return raw_slice async def get(self, indices: dict[str, Any]) -> ArrayProtocol: + """ + Get the data slice to display at the given indices. **Must** be implemented in a subclass. + + Called by the ``NDGraphic`` whenever the ``ReferenceIndex`` updates. Implementations usually call + :meth:`get_window_output`, apply the ``spatial_func``, and transpose into the ``spatial_dims`` display + order. + + Parameters + ---------- + indices: dict[str, Any] + Reference-space value for each slider dim, ex: ``{"time": 46.397, "depth": 23.24}``. Must provide a + value for every slider dim. + + Returns + ------- + ArrayProtocol + Data slice that maps to the graphical representation, with the dims given by ``spatial_dims`` in + display order. + + """ raise NotImplementedError # TODO: html and pretty text repr # @@ -607,6 +658,28 @@ def __init__( nd_subplot: NDWSubplot, name: str | None, ): + """ + Base class that pairs an :class:`NDProcessor` with a ``Graphic``. Subclass to support a new graphical + representation. + + The ``NDProcessor`` produces the data slice for the current index and the ``NDGraphic`` writes it to the + ``Graphic``. When the ``ReferenceIndex`` of the parent ``NDWidget`` changes, it schedules + ``_set_indices_()`` on every ``NDGraphic`` that has the dim that changed. + + Subclasses must implement :meth:`_create_graphic` and ``_set_indices_()``, and the :attr:`processor`, + :attr:`graphic`, :attr:`indices` and :attr:`spatial_dims` properties. Most of the processor properties + are aliased here so users can reach them from the ``NDGraphic``, and setting one of those aliases + re-renders the current slice. + + Parameters + ---------- + nd_subplot: NDWSubplot + parent NDWSubplot the NDGraphic is in + + name: str or None + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + """ self._nd_subplot = nd_subplot self._name = name self._graphic: Graphic | None = None @@ -628,7 +701,10 @@ async def _create_graphic(self): @property def pause(self) -> bool: - """if True, changes in the reference until it is set back to False""" + """ + Get or set whether this graphic ignores changes in the ``ReferenceIndex``. If ``True``, it stops + updating until it is set back to ``False``, the other graphics in the widget are unaffected. + """ return self._pause @pause.setter @@ -642,10 +718,12 @@ def name(self) -> str | None: @property def processor(self) -> NDProcessor: + """NDProcessor that manages the data and produces data slices to display""" raise NotImplementedError @property def graphic(self) -> Graphic: + """Underlying Graphic object used to display the current data slice""" raise NotImplementedError @property @@ -655,6 +733,7 @@ def indices_displayed(self) -> dict[str, Any]: @property def indices(self) -> dict[str, Any]: + """the current index of each slider dim in reference-space units, from the ``ReferenceIndex``""" raise NotImplementedError async def _set_indices_(self, indices: dict[str, Any] = None): @@ -709,6 +788,7 @@ def dims(self) -> tuple[str, ...]: @property def spatial_dims(self) -> tuple[str, ...]: + """get or set the spatial dims, i.e. the rendered dims, **in display order**""" # number of spatial dims for positional data is always 3 # for image is 2 or 3, so it must be implemented in subclass raise NotImplementedError @@ -720,13 +800,21 @@ def slider_dims(self) -> set[str]: @property def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: + """ + Get or set the per-slider-dim mapping from reference-space values to local array indices, + ``{dim_name: transform}``. Setting it re-renders the current data slice. + + A transform may be given as a Callable that takes a reference-space value and returns an array index, or + as an array of reference values in which case its ``searchsorted`` is used as the transform (ex: a + timestamps array). Any dim given ``None``, or not given at all, uses the identity mapping, i.e. the + reference value is rounded to the nearest integer and used as the array index. + """ return self.processor.slider_dim_transforms @slider_dim_transforms.setter def slider_dim_transforms( self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None ): - """get or set the slider_dim_transforms, see docstring for details""" self.processor.slider_dim_transforms = maps # force a render run_sync(self._set_indices_()) @@ -735,7 +823,19 @@ def slider_dim_transforms( def window_funcs( self, ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: - """get or set window functions, see docstring for details""" + """ + Get or set the per-slider-dim window functions applied around the current slider position, + ``{dim_name: (func, window_size)}``, ex: ``{"time": (np.mean, 2.5)}``. Setting it re-renders the current + data slice. + + *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). It **must** + return an array that has the same dims as the input, therefore the size of any dim along which it was + applied should reduce to ``1``. These dims must not be removed by the window func. *window_size* is in + reference-space units (ex: 2.5 seconds). + + A window func is only applied for the dims listed in :attr:`window_order`. Any dim without an entry is + filled in with ``(None, None)``. + """ return self.processor.window_funcs @window_funcs.setter @@ -763,14 +863,16 @@ def window_order(self, order: tuple[str] | None): @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: - """get or set the spatial_func, see docstring for details""" + """ + Get or set the function applied to the spatial slice *after* the window funcs, right before rendering. + Setting it re-renders the current data slice. + """ return self.processor.spatial_func @spatial_func.setter def spatial_func( self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: - """get or set the spatial_func, see docstring for details""" self.processor.spatial_func = func # force a render run_sync(self._set_indices_()) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index bba85fa6d..6b9eef680 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -123,6 +123,21 @@ class AutoRangeContinuous(RangeContinuous): @dataclass class RangeDiscrete: + """ + A discrete reference range for a single slider dimension, where the reference-space values are arbitrary + objects (ex: gene names, experimental conditions) rather than a numerical range. + + .. important:: + Not implemented yet, this is a placeholder. The imgui slider is only drawn for a + :class:`RangeContinuous`. + + Parameters + ---------- + options: Sequence[Any] + The reference-space values of this dimension, in order. + + """ + # TODO: not implemented yet, placeholder until we have a clear usecase options: Sequence[Any] @@ -260,14 +275,12 @@ def set(self, indices: dict[str, Any], cancel_awaiting: bool = False): Parameters ---------- indices: dict[str, Any] - indices to set, {dim: index} + indices to set, ``{dim: index}``, in reference-space units. Values are clamped to the reference + range of that dim. cancel_awaiting: bool, default ``False`` cancel in-progress fetches, i.e. only display the latest fetch request - Returns - ------- - """ for dim, value in indices.items(): self._indices[dim] = self._clamp(dim, value) @@ -434,6 +447,13 @@ def _check_has_dim(self, dim): ) def pop_dim(self): + """ + Remove a slider dim and its reference range. + + .. important:: + Not implemented yet, this is a placeholder that does nothing. + + """ pass def push_dims( @@ -443,6 +463,21 @@ def push_dims( tuple[Number, Number, Number] | tuple[Any] | RangeContinuous, ], ): + """ + Add reference ranges, i.e. register new slider dims. + + The index of each new dim is initialized to the start of its range, and a slider for it is added to the + UI of every ``NDWidget`` managed by this ``ReferenceIndex``. + + Parameters + ---------- + ref_ranges: dict[str, tuple | RangeContinuous | RangeDiscrete] + Mapping of dim names to range specifications. A 3-tuple ``(start, stop, step)`` creates a + :class:`RangeContinuous`, a 1-tuple ``(options,)`` creates a :class:`RangeDiscrete`, and a + ``RangeContinuous`` or ``RangeDiscrete`` instance is used as given. An existing dim of the same name + is replaced. + + """ for name, r in ref_ranges.items(): if isinstance(r, (RangeContinuous, RangeDiscrete)): diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 40dd510f9..5887eb814 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -53,18 +53,6 @@ def __init__( data: ArrayProtocol array-like data, must have 2 or more dimensions - dims: Sequence[str] - names for each dimension in ``data``. Dimensions not listed in - ``spatial_dims`` are treated as slider dimensions and **must** appear as - keys in the parent ``NDWidget``'s ``ref_ranges`` - Examples:: - ``("time", "depth", "row", "col")`` - ``("channels", "time", "xy")`` - ``("keypoints", "time", "xyz")`` - - A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method - must operate as if these dimensions exist and return an array that matches the spatial dimensions. - dims: Sequence[str] names for each dimension in ``data``. Dimensions not listed in ``spatial_dims`` are treated as slider dimensions and **must** appear as @@ -79,14 +67,18 @@ def __init__( ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")``. spatial_dims : tuple[str, str] | tuple[str, str, str] - The 2 or 3 spatial dimensions **in display order**: ``(rows, cols)`` or ``(z, rows, cols)``. - This also determines whether an ``ImageGraphic`` or ``ImageVolumeGraphic`` is used for rendering. - The ordering determines how the Image/Volume is rendered. For example, if - you specify ``spatial_dims = ("rows", "cols")`` and then change it to ``("cols", "rows")``, it will display - the transpose. + The 2 or 3 spatial dims **in display order**, which also determines the graphic used for rendering: + + * ``(rows, cols)``, a 2D grayscale ``ImageGraphic`` + * ``(rows, cols, rgb_dim)``, a 2D RGB(A) ``ImageGraphic`` + * ``(z, rows, cols)``, a 3D ``ImageVolumeGraphic`` + + The ordering determines how the image or volume is rendered. For example, if you specify + ``spatial_dims = ("rows", "cols")`` and then change it to ``("cols", "rows")``, it will display the + transpose. rgb_dim : str, optional - Name of an RGB(A) dimension, if present. + Name of the RGB(A) dim, if present. It must be listed in ``spatial_dims`` and be of size 3 or 4. compute_histogram: bool, default True Compute a histogram of the data, disable if random-access of data is not blazing-fast (ex: data that uses @@ -301,24 +293,27 @@ def __init__( tuple[str, str] | tuple[str, str, str] ), # must be in order! [rows, cols] | [z, rows, cols] rgb_dim: str | None = None, - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, - window_order: tuple[int, ...] = None, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, - slider_dim_transforms=None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, processor_type: type[NDImageProcessor] = NDImageProcessor, colorspace: Literal[ "srgb", "tex-srgb", "physical", "yuv420p", "yuv444p" ] = "srgb", colorrange: Literal["full", "limited"] = "full", name: str = None, + graphic_kwargs: dict = None, ): """ ``NDGraphic`` subclass for n-dimensional image rendering. - Wraps an :class:`NDImageProcessor` and manages either an ``ImageGraphic`` or``ImageVolumeGraphic``. - swaps automatically when :attr:`spatial_dims` is reassigned at runtime. Also - owns an ``ImguiColorbar`` for interactive vmin, vmax adjustment. + Uses an :class:`NDImageProcessor` to produce the data slices and manages an ``ImageGraphic``, + ``ImageYUVGraphic`` or ``ImageVolumeGraphic``, swapping between them when :attr:`spatial_dims` is + reassigned at runtime. It also owns an ``ImguiColorbar`` for interactive vmin, vmax adjustment. Every dimension that is *not* listed in ``spatial_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the @@ -334,9 +329,10 @@ def __init__( parent NDWSubplot the NDGraphic is in data : array-like or None - n-dimension image data array + n-dimensional image data, must have 2 or more dims. Pass ``None`` to create the ``NDImage`` without + a graphic and set the data later using :attr:`data`. - dims : sequence of hashable + dims : Sequence[str] Name for every dimension of ``data``, in order. Non-spatial dims must match keys in ``ref_index``. @@ -344,12 +340,16 @@ def __init__( be present in ``ref_index``. spatial_dims : tuple[str, str] | tuple[str, str, str] - Spatial dimensions **in order**: ``(rows, cols)`` for 2-D images or - ``(z, rows, cols)`` for volumes. Controls whether an ``ImageGraphic`` or - ``ImageVolumeGraphic`` is used. + The 2 or 3 spatial dims **in display order**, which also determines the graphic used for rendering: + + * ``(rows, cols)``, a 2D grayscale ``ImageGraphic`` + * ``(rows, cols, rgb_dim)``, a 2D RGB(A) ``ImageGraphic`` + * ``(z, rows, cols)``, a 3D ``ImageVolumeGraphic`` + + Reassigning this at runtime swaps the graphic if the number of non-RGB(A) spatial dims changes. rgb_dim : str, optional - Name of the RGB or channel dimension, if present. + Name of the RGB(A) dim, if present. It must be listed in ``spatial_dims`` and be of size 3 or 4. window_funcs : dict, optional See :class:`NDProcessor`. @@ -361,13 +361,30 @@ def __init__( See :class:`NDProcessor`. compute_histogram : bool, default ``True`` - Whether to initialize the ``ImguiColorbar``. + Estimate a histogram of the data and display an ``ImguiColorbar`` on the right edge of the subplot, + which is used to interactively set vmin, vmax. Disable if random access of the data is not + blazing-fast (ex: data that uses video codecs), or if a histogram is not useful for this data. slider_dim_transforms : dict, optional See :class:`NDProcessor`. + processor_type : type[NDImageProcessor], default ``NDImageProcessor`` + ``NDImageProcessor`` subclass that manages the data and produces the data slices, ex: + :class:`VideoProcessor`. + + colorspace : "srgb" | "tex-srgb" | "physical" | "yuv420p" | "yuv444p", default "srgb" + Colorspace in which to interpret the data. The RGB colorspaces are rendered using an ``ImageGraphic`` + or ``ImageVolumeGraphic``, see :class:`.ImageGraphic` for their meaning. The YUV colorspaces are + rendered using an ``ImageYUVGraphic``, see :class:`.ImageYUVGraphic`. + + colorrange : "full" | "limited", default "full" + Used only for the YUV colorspaces, see :class:`.ImageYUVGraphic`. Most videos use "limited". + name : str, optional - Name for the underlying graphic. + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs : dict, optional + passed to the underlying image graphic, ex: ``{"cmap": "viridis", "interpolation": "linear"}`` See Also -------- @@ -401,6 +418,11 @@ def __init__( self._colorspace = colorspace self._colorrange = colorrange + if graphic_kwargs is None: + self._graphic_kwargs = dict() + else: + self._graphic_kwargs = graphic_kwargs + self._graphic: ImageGraphic | ImageYUVGraphic | None = None self._histogram_widget: ImguiColorbar | None = None @@ -454,6 +476,7 @@ async def _create_graphic(self): data_slice, # cpu_buffer=False, # faster, we usually don't need a cpu buffer for NDWidget use cases **kwargs, + **self._graphic_kwargs, ) old_graphic = self._graphic @@ -588,7 +611,10 @@ def histogram_widget(self) -> ImguiColorbar: @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: - """get or set the spatial_func, see docstring for details""" + """ + Get or set the function applied to the spatial slice *after* the window funcs, right before rendering. + Setting it recomputes the histogram, since the function often changes the range of the values. + """ # this is here even though it's the same in the base class since we can't create the image specific setter # without also defining the property in this subclass. return self.processor.spatial_func diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 9ca5eee93..6051321f7 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -3,21 +3,17 @@ from collections.abc import Callable, Hashable, Sequence from functools import partial from typing import Any, Type, TYPE_CHECKING -from warnings import warn import numpy as np from numpy.lib.stride_tricks import sliding_window_view from numpy.typing import ArrayLike from ....graphics import ( - LineGraphic, LineStack, LineCollection, - ScatterGraphic, ScatterCollection, ScatterStack, ) -from ....graphics.features.utils import parse_colors from .._base import ( NDProcessor, NDGraphic, @@ -32,27 +28,12 @@ # types for the other features FeatureCallable = Callable[[np.ndarray, slice], np.ndarray] -ColorsType = np.ndarray | FeatureCallable | None -MarkersType = Sequence[str] | np.ndarray | FeatureCallable | None -SizesType = Sequence[float] | np.ndarray | FeatureCallable | None - - -def default_cmap_transform_each(p: int, data_slice: np.ndarray, s: slice): - # create a cmap transform based on the `p` dim size - n_displayed = data_slice.shape[1] - - # linspace that's just normalized 0 - 1 within `p` dim size - return np.linspace( - start=s.start / p, - stop=s.stop / p, - num=n_displayed, - endpoint=False, # since we use a slice object for the displayed data, the last point isn't included - ) +ColorsType = str | Sequence[str] | np.ndarray | FeatureCallable | None +MarkersType = str | Sequence[str] | np.ndarray | FeatureCallable | None +SizesType = float | Sequence[float] | np.ndarray | FeatureCallable | None class NDPositionsProcessor(NDProcessor): - _other_features = ["colors", "markers", "cmap_transform_each", "sizes"] - def __init__( self, data: Any, @@ -65,224 +46,102 @@ def __init__( display_window: int | float | None = 100, # window for n_datapoints dim only max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, - colors: ColorsType = None, - markers: MarkersType = None, - cmap_transform_each: np.ndarray = None, - sizes: SizesType = None, **kwargs, ): """ ``NDProcessor`` subclass for n-dimensional positional and timeseries data. + Produces ``[n_graphics, p, ]`` slices for a ``LineCollection``, ``LineStack``, + ``ScatterCollection``, or ``ScatterStack``, where ``p`` is the datapoints dim. - The *datapoints* dimension is - simultaneously a slider dim and a spatial dim and is handled by a dedicated - :attr:`datapoints_window_func` rather than the general ``window_funcs`` - mechanism. - + The ``p`` dim is simultaneously a slider dim and a spatial dim. Rather than the general ``window_funcs`` + mechanism, it is windowed by :attr:`display_window`, which selects the datapoints that are rendered, and + by :attr:`datapoints_window_func`, which aggregates over them. Parameters ---------- - data - dims - spatial_dims - slider_dim_transforms - display_window - max_display_datapoints: int, default 1_000 - this is approximate since floor division is used to determine the step size of the current display window slice - datapoints_window_func: - Important note: if used, display_window is approximate and not exact due to padding from the window size - kwargs - """ - self._display_window = display_window - self._max_display_datapoints = max_display_datapoints + data: ArrayProtocol + n-dimensional positional data, must have 3 or more dims. - super().__init__( - data=data, - dims=dims, - spatial_dims=spatial_dims, - slider_dim_transforms=slider_dim_transforms, - **kwargs, - ) + dims: Sequence[str] + names for each dimension in ``data``. Dimensions not listed in ``spatial_dims`` are treated as slider + dimensions and **must** appear as keys in the parent ``NDWidget``'s ``ref_ranges``. + Examples:: + ``("trial", "line", "time", "xy")`` + ``("keypoints", "time", "xyz")`` - self._datapoints_window_func = datapoints_window_func + dims in the array do not need to be in the order that you want to display them, the data slice is + transposed into the order given by ``spatial_dims``. - self.colors = colors - self.markers = markers - self.cmap_transform_each = cmap_transform_each - self.sizes = sizes + spatial_dims : tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of lines + or scatters in the collection, the number of datapoints ``p`` in each of them, and the value dim + which holds the xy or xyz coordinate and must be of size 2 or 3. + slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional + See :class:`NDProcessor`. The transform for the ``p`` dim is also used to map ``display_window`` and + the ``datapoints_window_func`` window size from reference units to array indices. - def _check_shape_feature( - self, prop: str, check_shape: tuple[int, int] - ) -> tuple[int, int]: - # this function exists because it's used repeatedly for colors, markers, etc. - # shape for [l, p] dims must match, or l must be 1 - shape = tuple([self.shape[dim] for dim in self.spatial_dims[:2]]) + display_window: int, float or None, default 100 + Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its + current index. Use ``None`` to render every datapoint, or ``0`` to render only the datapoint at the + current index. - if check_shape[1] != shape[1]: - raise IndexError( - f"shape of first two dims of {prop} must must be [l, p] or [1, p].\n" - f"required `p` dim shape is: {shape[1]}, {check_shape[1]} was provided" - ) - - if check_shape[0] != 1 and check_shape[0] != shape[0]: - raise IndexError( - f"shape of first two dims of {prop} must must be [l, p] or [1, p]\n" - f"required `l` dim shape is {shape[0]} | 1, {check_shape[0]} was provided" - ) - - return shape - - @property - def colors(self) -> ColorsType: - """ - A callable that dynamically creates colors for the current display window, or array of colors per-datapoint. - - Array must be of shape [l, p, 4] for unique colors per line/scatter, or [1, p, 4] for identical colors per - line/scatter. - - Callable must return an array of shape [l, pw, 4] or [1, pw, 4], where pw is the number of currently displayed - datapoints given the current display window. The callable receives the current data slice array, as well as the - slice object that corresponds to the current display window. - """ - return self._colors - - @colors.setter - def colors(self, new): - if callable(new): - # custom callable that creates the colors - self._colors = new - return - - if new is None: - self._colors = None - return - - # as array so we can check shape - new = np.asarray(new) - if new.ndim == 2: - # only [p, 4] provided, broadcast to [1, p, 4] - new = new[None] - - shape = self._check_shape_feature("colors", new.shape[:2]) - - if new.shape[0] == 1: - # same colors across all graphical elements - self._colors = parse_colors(new[0], n_colors=shape[1])[None] - - else: - # colors specified for each individual line/scatter - new_ = np.zeros(shape=(*self.data.shape[:2], 4), dtype=np.float32) - for i in range(shape[0]): - new_[i] = parse_colors(new[i], n_colors=shape[1]) - - self._colors = new_ - - @property - def markers(self) -> MarkersType: - """ - A callable that dynamically creates markers for the current display window, or array of markers per-datapoint. - - Array must be of shape [l, p] for unique markers per line/scatter, or [p,] or [1, p] for identical markers per - line/scatter. - - Callable must return an array of shape [l, pw], [1, pw], or [pw,] where pw is the number of currently displayed - datapoints given the current display window. The callable receives the current data slice array, as well as the - slice object that corresponds to the current display window. - """ - return self._markers - - @markers.setter - def markers(self, new: MarkersType): - if callable(new): - # custom callable that creates the markers dynamically - self._markers = new - return - - if new is None: - self._markers = None - return - - # as array so we can check shape - new = np.asarray(new) - - # if 1-dim, assume it's specifying markers over `p` dim, so set `l` dim to 1 - if new.ndim == 1: - new = new[None] - - self._check_shape_feature("markers", new.shape[:2]) - - self._markers = np.asarray(new) - - @property - def cmap_transform_each(self) -> np.ndarray | FeatureCallable | None: - return self._cmap_transform_each - - @cmap_transform_each.setter - def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): - """ - A callable that dynamically creates cmap transforms for the current display window, or array - of transforms per-datapoint. - - Array must be of shape [l, p] for unique transforms per line/scatter, or [p,] or [1, p] for identical markers - per line/scatter. - - Callable must return an array of shape [l, pw], [1, pw], or [pw,] where pw is the number of currently displayed - datapoints given the current display window. The callable receives the current data slice array, as well as the - slice object that corresponds to the current display window. - """ - if callable(new): - self._cmap_transform_each = new - return + max_display_datapoints: int, default 1_000 + Maximum number of datapoints to render per graphic. The step size of the display window slice is set + from this using floor division. - if new is None: - self._cmap_transform_each = None - return + datapoints_window_func: tuple[Callable, str, int | float], optional + Window function applied along the ``p`` dim after the display window has been taken, as + ``(func, apply_dims, window_size)`` where: - new = np.asarray(new) + * *func* must accept an ``axis: int`` kwarg (ex: ``np.mean``, ``np.max``). It is given a sliding + window view of the data and is reduced along the window axis. - # if 1-dim, assume it's specifying sizes over `p` dim, set `l` dim to 1 - if new.ndim == 1: - new = new[None] + * *apply_dims* names the coordinates of the value dim to apply it to, one of ``"all", "x", "y", + "z", "xy", "xz", "yz", "xyz"``. Coordinates that are not named are passed through unchanged. - self._check_shape_feature("cmap_transform_each", new.shape) + * *window_size* is in the reference units of the ``p`` dim. It is mapped to array indices, clamped to + a minimum of 3, and rounded up to an odd size. - self._cmap_transform_each = new + Important note: if used, ``display_window`` is approximate and not exact due to padding from the + window size. The window function is skipped when ``display_window`` is ``0``, or when the display + window spans more than ``2 * max_display_datapoints`` array indices, which would be too expensive to + compute. - @property - def sizes(self) -> SizesType: - return self._sizes + kwargs + passed to :class:`NDProcessor`, i.e. ``window_funcs``, ``window_order`` and ``spatial_func``. - @sizes.setter - def sizes(self, new: SizesType): + See Also + -------- + NDProcessor : Base class with full parameter documentation. + NDPositions : The ``NDGraphic`` that uses this processor by default. """ - A callable that dynamically creates sizes for the current display window, or array of sizes per-datapoint. - - Array must be of shape [l, p] for unique sizes per line/scatter, or [p,] or [1, p] for identical markers per - line/scatter. + self._display_window = display_window + self._max_display_datapoints = max_display_datapoints - Callable must return an array of shape [l, pw], [1, pw], or [pw,] where pw is the number of currently displayed - datapoints given the current display window. The callable receives the current data slice array, as well as the - slice object that corresponds to the current display window. - """ - if callable(new): - # custom callable - self._sizes = new - return + super().__init__( + data=data, + dims=dims, + spatial_dims=spatial_dims, + slider_dim_transforms=slider_dim_transforms, + **kwargs, + ) - if new is None: - self._sizes = None - return + self._datapoints_window_func = datapoints_window_func - new = np.array(new) - # if 1-dim, assume it's specifying sizes over `p` dim, set `l` dim to 1 - if new.ndim == 1: - new = new[None] + # other graphic features windowed per-datapoint (arrays or callables), keyed by feature name + self._other_features: dict[str, Any] = dict() - self._check_shape_feature("sizes", new.shape) - self._sizes = new + def set_other_feature(self, name: str, value): + """set, or clear if ``value`` is None, an other graphic feature to window per-datapoint""" + if value is None: + self._other_features.pop(name, None) + elif callable(value): + self._other_features[name] = value + else: + self._other_features[name] = np.asarray(value) @property def spatial_dims(self) -> tuple[str, str, str]: @@ -300,13 +159,14 @@ def spatial_dims(self, sdims: tuple[str, str, str]): self._spatial_dims = tuple(sdims) @property - def slider_dims(self) -> set[Hashable]: + def slider_dims(self) -> tuple[str, ...]: + """slider dim names, the non-spatial dims plus the ``p`` dim""" # append `p` dim to slider dims return tuple([*super().slider_dims, self.spatial_dims[1]]) @property def display_window(self) -> int | float | None: - """display window in the reference units for the n_datapoints dim""" + """get or set the display window, in the reference units of the ``p`` dim""" return self._display_window @display_window.setter @@ -321,6 +181,10 @@ def display_window(self, dw: int | float | None): @property def max_display_datapoints(self) -> int: + """ + Get or set the maximum number of datapoints to render per graphic. The step size of the display window + slice is set from this using floor division. + """ return self._max_display_datapoints @max_display_datapoints.setter @@ -336,9 +200,12 @@ def max_display_datapoints(self, n: int): @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: """ - Callable, str indicating which dims to apply window function along, window_size in reference space: - 'all', 'x', 'y', 'z', 'xyz', 'xy', 'xz', 'yz' - '""" + Get or set the window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``. + + ``apply_dims`` names the coordinates of the value dim that the window function is applied to, one of + ``"all", "x", "y", "z", "xy", "xz", "yz", "xyz"``. ``window_size`` is in the reference units of the + ``p`` dim. + """ return self._datapoints_window_func @datapoints_window_func.setter @@ -488,36 +355,13 @@ def _finalize(self, array: ArrayProtocol) -> ArrayProtocol: def _get_other_features( self, data_slice: ArrayProtocol, dw_slice: slice ) -> dict[str, ArrayProtocol]: - other = dict.fromkeys(self._other_features) - for attr in self._other_features: - val = getattr(self, attr) - - if val is None: - continue - + # window the per-graphic datapoint (`p`) axis (axis 1) of each feature + other = dict() + for name, val in self._other_features.items(): if callable(val): - # if it's a callable, give it the data and display window slice, it must return the appropriate - # type of array for that graphic feature - val_sliced = val(data_slice, dw_slice) - + other[name] = val(data_slice, dw_slice) else: - # if no l dim, broadcast to [1, p] - if val.ndim == 1: - val = val[None] - - # apply current display window slice - val_sliced = val[:, dw_slice] - - # check if l dim size is 1 - if val_sliced.shape[0] == 1: - # broadcast across all graphical elements - n_graphics = self.shape[self.spatial_dims[0]] - val_sliced = np.broadcast_to( - val_sliced, shape=(n_graphics, *val_sliced.shape[1:]) - ) - - other[attr] = val_sliced - + other[name] = val[:, dw_slice] return other async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]: @@ -574,66 +418,182 @@ def __init__( spatial_dims: tuple[str, str, str], *args, graphic_type: Type[ - LineGraphic - | LineCollection + LineCollection | LineStack - | ScatterGraphic | ScatterCollection | ScatterStack ], processor: type[NDPositionsProcessor] = NDPositionsProcessor, - display_window: int = 10, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, + display_window: int | float | None = 10, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, max_display_datapoints: int = 1_000, - colors: ( - Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] - ) = None, - # TODO: cleanup how this cmap stuff works, require a cmap to be set per-graphic - # before allowing cmaps_transform, validate that stuff makes sense etc. - cmap: str = None, # across the line/scatter collection - cmap_each: Sequence[str] = None, # for each individual line/scatter - cmap_transform_each: np.ndarray = None, # for each individual line/scatter - markers: np.ndarray = None, # across the scatter collection, shape [l,] - markers_each: Sequence[str] = None, # for each individual scatter, shape [l, p] - sizes: np.ndarray = None, # across the scatter collection, shape [l,] - sizes_each: Sequence[float] = None, # for each individual scatter, shape [l, p] - thickness: np.ndarray = None, # for each line, shape [l,] + datapoints_window_func: tuple[Callable, str, int | float] | None = None, + colors: ColorsType = None, + cmap: str | Sequence[str] = None, + cmap_transform: np.ndarray | FeatureCallable = None, + cmap_range: tuple[float, float] = None, + thickness: float | Sequence[float] = None, + sizes: SizesType = None, + markers: MarkersType = None, name: str = None, graphic_kwargs: dict = None, processor_kwargs: dict = None, ): """ - Wraps an :class:`NDPositionsProcessor` and supports four interchangeable - graphical representations: ``LineStack``, ``LineCollection``, ``ScatterStack``, - and ``ScatterCollection``. + ``NDGraphic`` subclass for n-dimensional positional data. + + Uses an :class:`NDPositionsProcessor` to produce the data slices and manages one of four interchangeable + graphical representations: ``LineStack``, ``LineCollection``, ``ScatterStack``, and ``ScatterCollection``. + The representation can be changed at runtime by setting :attr:`graphic_type`. + + Every dimension that is *not* listed in ``spatial_dims`` becomes a slider dimension. Each slider dim must + have a ``ReferenceRange`` defined in the ``ReferenceIndex`` of the parent ``NDWidget``. The datapoints + dim, ``p``, is both a spatial dim and a slider dim, it is windowed by ``display_window`` and + ``datapoints_window_func`` rather than by ``window_funcs``. Parameters ---------- - ref_index - nd_subplot - data - dims - spatial_dims + ref_index : ReferenceIndex + The shared reference index that delivers slider updates to this graphic. + + nd_subplot : NDWSubplot + parent NDWSubplot the NDGraphic is in + + data : array-like or None + n-dimensional positional data. + + Ex: an array of shape ``[n_trials, n_lines, n_timepoints, 2]`` with ``dims`` of + ``("trial", "line", "time", "xy")`` and ``spatial_dims`` of ``("line", "time", "xy")``. + + Pass ``None`` to create the ``NDPositions`` without a graphic and set the data later using + :attr:`data`. + + dims : Sequence[str] + Name for every dimension of ``data``, in order. Non-spatial dims must match keys in ``ref_index``. + + spatial_dims : tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of lines + or scatters in the collection, the number of datapoints ``p`` in each of them, and the value dim + which holds the xy or xyz coordinate and must be of size 2 or 3. The dims do not need to be in this + order in the array, the data slice is transposed into display order. + args - graphic_type - processor - display_window - window_funcs - slider_dim_transforms - max_display_datapoints - colors - cmap - cmap_each - cmap_transform_each - markers - markers_each - sizes - sizes_each - thickness - name - graphic_kwargs - processor_kwargs + extra positional arguments passed to the ``processor`` constructor. + + graphic_type : type[LineCollection | LineStack | ScatterCollection | ScatterStack] + The graphical representation used to display the data slice. + + processor : type[NDPositionsProcessor], default ``NDPositionsProcessor`` + ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + + display_window : int, float or None, default 10 + Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its + current index. Use ``None`` to render every datapoint, or ``0`` to render only the datapoint at the + current index. This is what makes out-of-core rendering possible, i.e. rendering a window of a + dataset that is larger than GPU VRAM. + + window_funcs : dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions applied around the current slider position, see + :class:`NDProcessor`. Not used for the ``p`` dim, see ``datapoints_window_func``. + + window_order : tuple[str, ...], optional + Order in which the window functions are applied across dims. Only dims listed here have their window + function applied, see :class:`NDProcessor`. + + spatial_func : Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice *after* the window funcs, right before rendering. + + slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional + Per-slider-dim mapping from reference-space values to local array indices, see + :class:`NDProcessor`. + + max_display_datapoints : int, default 1_000 + Maximum number of datapoints to render per graphic. The step size of the display window slice is set + from this using floor division. + + datapoints_window_func : tuple[Callable, str, int | float], optional + Window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``, see + :class:`NDPositionsProcessor`. + + colors : str | Sequence[str] | np.ndarray | FeatureCallable, optional + Colors of the graphics. Mutually exclusive with ``cmap``, setting one clears the other. + + * static, a single color for every graphic, ex: ``"cyan"`` or an RGBA sequence of 4 floats + * static, one color per graphic, ``[n_graphics]`` of str or ``[n_graphics, 4]`` RGBA + * windowed, one color per datapoint, ``[n_graphics, p, 4]`` RGBA + * windowed, a ``FeatureCallable`` + + cmap : str | Sequence[str], optional + Colormap applied to the graphics, always static. A single name for every graphic, or an iterable of + ``[n_graphics]`` names for a colormap per graphic. Mutually exclusive with ``colors``. + + cmap_transform : np.ndarray | FeatureCallable, optional + Values that the colormap colors are mapped from. + + * static, one value per graphic, ``[n_graphics]``, so each graphic gets a single color + * windowed, one value per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + cmap_range : (float, float) | np.ndarray, optional + The (min, max) of ``cmap_transform`` mapped onto the colormap, or ``[n_graphics, 2]`` for a range per + graphic. A windowed array ``cmap_transform`` defaults to its own (min, max) over the full ``p`` dim, + so the display window keeps its position within the colormap. A ``FeatureCallable`` transform + requires an explicit range, its full range is not knowable without evaluating it everywhere. + + thickness : float | Sequence[float], optional + Thickness of the lines, always static. A single value for every graphic, or ``[n_graphics]`` values + for a thickness per graphic. + + sizes : float | Sequence[float] | np.ndarray | FeatureCallable, optional + Size of the scatter points. + + * static, a single size for every graphic, or ``[n_graphics]`` sizes for one size per graphic + * windowed, one size per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + markers : str | Sequence[str] | np.ndarray | FeatureCallable, optional + Marker shape of the scatter points. + + * static, a single marker for every graphic, or ``[n_graphics]`` markers for one per graphic + * windowed, one marker per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + name : str, optional + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs : dict, optional + passed to the ``graphic_type`` constructor. + + processor_kwargs : dict, optional + passed to the ``processor`` constructor. + + Notes + ----- + Each of the other graphic features is either *windowed* or *static*, decided from the value itself: + + * **windowed**: a ``FeatureCallable``, or an array whose axis 1 spans the ``p`` dim. It is re-sliced with + the same display window slice as the data on every update, so the feature carries a value per + displayed datapoint. An array **must** span the **full** ``p`` dim of the data, i.e. + ``[n_graphics, p, ]``, since it is indexed with an index into the full ``p`` dim. A + ``FeatureCallable`` is passed the data slice and that display window slice, and returns the feature + values for the displayed datapoints. + + * **static**: anything else. It is set once on the collection, ex: a single value for every graphic, + ``[n_graphics]`` values for one per graphic, or an iterator of per-graphic values such as + ``itertools.cycle(["jet", "viridis"])``. + + A feature the graphic type does not have is ignored, ex: ``thickness`` for scatters, ``markers`` for + lines. + + See Also + -------- + NDPositionsProcessor : The processor that produces the data slices for this graphic. + """ super().__init__(nd_subplot, name) @@ -648,17 +608,18 @@ def __init__( processor=processor, display_window=display_window, window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, slider_dim_transforms=slider_dim_transforms, max_display_datapoints=max_display_datapoints, + datapoints_window_func=datapoints_window_func, colors=colors, cmap=cmap, - cmap_each=cmap_each, - cmap_transform_each=cmap_transform_each, - markers=markers, - markers_each=markers_each, - sizes=sizes, - sizes_each=sizes_each, + cmap_transform=cmap_transform, + cmap_range=cmap_range, thickness=thickness, + sizes=sizes, + markers=markers, graphic_kwargs=graphic_kwargs, processor_kwargs=processor_kwargs, ) @@ -673,29 +634,28 @@ def init( spatial_dims: tuple[str, str, str], *args, graphic_type: Type[ - LineGraphic - | LineCollection + LineCollection | LineStack - | ScatterGraphic | ScatterCollection | ScatterStack ], processor: type[NDPositionsProcessor] = NDPositionsProcessor, - display_window: int = 10, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, + display_window: int | float | None = 10, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, max_display_datapoints: int = 1_000, - colors: ( - Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] - ) = None, - cmap: str = None, - cmap_each: Sequence[str] = None, - cmap_transform_each: np.ndarray = None, - markers: np.ndarray = None, - markers_each: Sequence[str] = None, - sizes: np.ndarray = None, - sizes_each: Sequence[float] = None, - thickness: np.ndarray = None, + datapoints_window_func: tuple[Callable, str, int | float] | None = None, + colors: ColorsType = None, + cmap: str | Sequence[str] = None, + cmap_transform: np.ndarray | FeatureCallable = None, + cmap_range: tuple[float, float] = None, + thickness: float | Sequence[float] = None, + sizes: SizesType = None, + markers: MarkersType = None, graphic_kwargs: dict = None, processor_kwargs: dict = None, ): @@ -722,54 +682,133 @@ def init( *args, display_window=display_window, max_display_datapoints=max_display_datapoints, + datapoints_window_func=datapoints_window_func, window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, slider_dim_transforms=slider_dim_transforms, - colors=colors, - markers=markers_each, - cmap_transform_each=cmap_transform_each, - sizes=sizes_each, **processor_kwargs, ) - self._cmap = cmap - self._sizes = sizes - self._markers = markers - self._thickness = thickness + self._graphic_type = graphic_type - self.cmap_each = cmap_each - self.cmap_transform_each = cmap_transform_each + # each feature is either windowed per-datapoint (into the processor) or static (onto + # the collection); _set_feature routes and stores it for re-creation on a type switch + self._static_features: dict[str, Any] = dict() + features = { + "colors": colors, + "cmap": cmap, + "cmap_transform": cmap_transform, + "cmap_range": cmap_range, + "thickness": thickness, + "sizes": sizes, + "markers": markers, + } + for name, value in features.items(): + self._set_feature(name, value) - self._graphic_type = graphic_type + def _set_feature(self, name: str, value): + """ + Route a graphic feature to the collection. + + A callable, or an array with the datapoint dim (``p``) at axis 1, is windowed + per-datapoint by the processor and set onto the collection each frame. Anything else is + static: it is stored and set once onto the collection. + """ + if value is not None: + # explicit colors and a colormap are mutually exclusive; drop the other source + self._clear_conflicting_color_source(name) + + if self._is_windowed(value): + self._static_features.pop(name, None) + self.processor.set_other_feature(name, value) + if self._graphic is not None: + run_sync(self._set_indices_()) + return + + # static: clear any windowed version, store, and set it onto the collection + self.processor.set_other_feature(name, None) + if value is None: + self._static_features.pop(name, None) + return + self._static_features[name] = value + if self._graphic is not None: + setattr(self.graphic, name, value) + + def _get_feature(self, name: str): + # the static value, or the windowed value held by the processor + if name in self._static_features: + return self._static_features[name] + return self.processor._other_features.get(name) + + def _clear_conflicting_color_source(self, name: str): + # a graphic's color is either explicit `colors` or a colormap, never both + if name == "colors": + conflicting = ("cmap", "cmap_transform", "cmap_range") + elif name in ("cmap", "cmap_transform", "cmap_range"): + conflicting = ("colors",) + else: + return + for other in conflicting: + self._static_features.pop(other, None) + self.processor.set_other_feature(other, None) + + def _is_windowed(self, value) -> bool: + # windowed features are per-datapoint and sliced to the display window each frame: a + # callable, or a ``[n_graphics, p, ...]`` array-like carrying the datapoint (`p`) axis. + # Anything else (a single value, or a per-graphic sequence/iterator) is static + if callable(value): + return True + if isinstance(value, (list, tuple, np.ndarray)): + value = np.asarray(value) + p_size = self.processor.shape[self.processor.spatial_dims[1]] + return value.ndim >= 2 and value.shape[1] == p_size + return False + + def _cmap_range(self): + # the cmap_range over the full `p` dimension (per-graphic min/max of the stored + # cmap_transform), or the user's explicit cmap_range. A callable transform's full range + # isn't knowable without evaluating it everywhere, so that needs an explicit cmap_range + if "cmap_range" in self._static_features: + return self._static_features["cmap_range"] + transform = self.processor._other_features.get("cmap_transform") + if not isinstance(transform, np.ndarray): + return None + if transform.ndim == 1: + return (float(transform.min()), float(transform.max())) + return np.stack([transform.min(axis=1), transform.max(axis=1)], axis=1) @property def processor(self) -> NDPositionsProcessor: + """NDProcessor that manages the data and produces data slices to display""" return self._processor @property def graphic( self, ) -> ( - LineGraphic - | LineCollection + LineCollection | LineStack - | ScatterGraphic | ScatterCollection | ScatterStack | None ): + """Underlying Graphic object used to display the current data slice, ``None`` if the data is ``None``""" return self._graphic @property def graphic_type( self, ) -> Type[ - LineGraphic - | LineCollection + LineCollection | LineStack - | ScatterGraphic | ScatterCollection | ScatterStack ]: + """ + Get or set the graphical representation used to display the data slice. Setting it deletes the current + graphic and creates one of the given type using the current slice. + """ return self._graphic_type @graphic_type.setter @@ -783,6 +822,10 @@ def graphic_type(self, graphic_type): @property def spatial_dims(self) -> tuple[str, str, str]: + """ + Get or set the spatial dims **in display order**: ``(n_graphics, p, )``. Setting them + re-renders the current data slice. + """ return self.processor.spatial_dims @spatial_dims.setter @@ -793,6 +836,7 @@ def spatial_dims(self, dims: tuple[str, str, str]): @property def indices(self) -> dict[Hashable, Any]: + """the current index of each slider dim in reference-space units, from the ``ReferenceIndex``""" return {d: self._ref_index[d] for d in self.processor.slider_dims} async def _get_data_slice(self, indices: dict[str, Any]) -> dict[str, Any]: @@ -811,51 +855,41 @@ async def _set_indices_(self, indices: dict[str, Any] = None): self._update_graphic(new_features, indices) self._last_indices = indices + def _set_other_features(self, new_features: dict[str, Any]): + # set each windowed feature across the collection via its property setter (cmap-family + # have no accessor); the setter broadcasts a shared value, switches each graphic's mode, + # and resizes to the current display window + for name, value in new_features.items(): + if name == "data" or not hasattr(type(self.graphic), name): + # skip a feature the current graphic type doesn't have, e.g. sizes on lines + continue + setattr(self.graphic, name, value) + + # a windowed cmap_transform makes the graphic auto-set cmap_range to just the displayed + # datapoints; override it with the range over the full `p` dimension so the display + # window maps to its position in the colormap + if "cmap_transform" in new_features and hasattr(type(self.graphic), "cmap_range"): + cmap_range = self._cmap_range() + if cmap_range is not None: + self.graphic.cmap_range = cmap_range + def _update_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): - data_slice = new_features["data"] - - if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): - self.graphic.data[:, : data_slice.shape[-1]] = data_slice - - elif isinstance(self.graphic, (LineCollection, ScatterCollection)): - for l, g in enumerate(self.graphic.graphics): - new_data = data_slice[l] - if g.data.value.shape[0] != new_data.shape[0]: - # will replace buffer internally - g.data = new_data - else: - # if data are only xy, set only xy - g.data[:, : new_data.shape[1]] = new_data - - for feature in ["colors", "sizes", "markers"]: - value = new_features.get(feature, None) - - match value: - case None: - pass - case _: - if feature == "colors": - g.color_mode = "vertex" - - setattr(g, feature, value[l]) - - if self.cmap_each is not None: - match new_features["cmap_transform_each"]: - case None: - pass - case _: - setattr( - getattr(g, "cmap"), # ind_graphic.cmap - "transform", - new_features["cmap_transform_each"], - ) + data_slice = new_features["data"] # [n_graphics, n_datapoints, xy(z)] + + if self.graphic.data[0].shape[0] != data_slice.shape[1]: + # n_datapoints changed, create new buffer + self.graphic.data[:] = data_slice + else: + # same num datapoints + self.graphic.data[:, :, : data_slice.shape[-1]] = data_slice + + self._set_other_features(new_features) def _tooltip_handler(self, graphic, pick_info): - if isinstance(self.graphic, (LineCollection, ScatterCollection)): - # get graphic within the collection - n_index = np.argwhere(self.graphic.graphics == graphic).item() - p_index = pick_info["vertex_index"] - return self.processor.tooltip_format(n_index, p_index) + # get graphic within the collection + n_index = np.argwhere(self.graphic.graphics == graphic).item() + p_index = pick_info["vertex_index"] + return self.processor.tooltip_format(n_index, p_index) async def _create_graphic(self): if self.data is None: @@ -865,68 +899,32 @@ async def _create_graphic(self): self._setup_graphic(new_features, self.indices) def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): - """Build, configure, and add the graphic for the current slice.""" - data_slice = new_features["data"] - - # store any cmap, sizes, thickness, etc. to assign to new graphic - graphic_attrs = dict() - for attr in ["cmap", "markers", "sizes", "thickness"]: - if attr in new_features.keys(): - if new_features[attr] is not None: - # markers and sizes defined for each line via processor takes priority - continue - - val = getattr(self, attr) - if val is not None: - graphic_attrs[attr] = val - - if issubclass(self._graphic_type, (LineStack, ScatterStack)): - kwargs = {"separation": 0.0, **self._graphic_kwargs} - else: - kwargs = self._graphic_kwargs - self._graphic = self._graphic_type(data_slice, **kwargs) - - for attr in graphic_attrs.keys(): - if hasattr(self._graphic, attr): - setattr(self._graphic, attr, graphic_attrs[attr]) - - if isinstance(self._graphic, (LineCollection, ScatterCollection)): - for l, g in enumerate(self.graphic.graphics): - for feature in ["colors", "sizes", "markers"]: - value = new_features.get(feature, None) - - match value: - case None: - pass - case _: - if feature == "colors": - g.color_mode = "vertex" - - setattr(g, feature, value[l]) - - if self.cmap_each is not None: - g.color_mode = "vertex" - g.cmap = self.cmap_each[l] - match new_features["cmap_transform_each"]: - case None: - pass - case _: - setattr( - getattr(g, "cmap"), # indv_graphic.cmap - "transform", - new_features["cmap_transform_each"], - ) + """Build and add the graphic for the current slice.""" + data_slice = new_features["data"] # [n_graphics, n_datapoints, xy(z)] + + # skip any static feature the graphic type doesn't have, e.g. thickness on scatters + static = { + name: value + for name, value in self._static_features.items() + if hasattr(self._graphic_type, name) + } + self._graphic = self._graphic_type( + data_slice, **static, **self._graphic_kwargs + ) + self._set_other_features(new_features) if self.processor.tooltip: - if isinstance(self._graphic, (LineCollection, ScatterCollection)): - for g in self._graphic.graphics: - g.tooltip_format = partial(self._tooltip_handler, g) + for g in self._graphic.graphics: + g.tooltip_format = partial(self._tooltip_handler, g) self._nd_subplot.subplot.add_graphic(self._graphic) @property def display_window(self) -> int | float | None: - """display window in the reference units for the n_datapoints dim""" + """ + Get or set the display window, in the reference units of the ``p`` dim. Setting it re-renders the + current data slice. + """ return self.processor.display_window @display_window.setter @@ -938,9 +936,12 @@ def display_window(self, dw: int | float | None): @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: """ - Callable, str indicating which dims to apply window function along, window_size in reference space: - 'all', 'x', 'y', 'z', 'xyz', 'xy', 'xz', 'yz' - '""" + Get or set the window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``. + + ``apply_dims`` names the coordinates of the value dim that the window function is applied to, one of + ``"all", "x", "y", "z", "xy", "xz", "yz", "xyz"``. ``window_size`` is in the reference units of the + ``p`` dim. + """ return self.processor.datapoints_window_func @datapoints_window_func.setter @@ -948,126 +949,64 @@ def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]): self.processor.datapoints_window_func = funcs @property - def cmap(self) -> str | None: - return self._cmap + def colors(self): + """get or set the colors of the graphics""" + return self._get_feature("colors") - @cmap.setter - def cmap(self, new: str | None): - if new is None: - # just set a default - if isinstance(self.graphic, (LineCollection, ScatterCollection)): - self.graphic.colors = "w" - else: - self.graphic.cmap = "plasma" - - self._cmap = None - return - - self._graphic.cmap = new - self._cmap = new - # force a re-render - run_sync(self._set_indices_()) + @colors.setter + def colors(self, value): + self._set_feature("colors", value) @property - def cmap_each(self) -> np.ndarray[str] | None: - # per-line/scatter - return self._cmap_each - - @cmap_each.setter - def cmap_each(self, new: Sequence[str] | None): - if new is None: - self._cmap_each = None - return - - if isinstance(new, str): - new = [new] - - new = np.asarray(new) + def cmap(self): + """get or set the cmap of the graphics""" + return self._get_feature("cmap") - if new.ndim != 1: - raise ValueError - - l_dim_size = self.processor.shape[self.processor.spatial_dims[0]] - # same cmap for all if size == 1, or specific cmap for each in `l` dim - if new.size != 1 and new.size != l_dim_size: - raise ValueError - - self._cmap_each = np.broadcast_to(new, shape=(l_dim_size,)) + @cmap.setter + def cmap(self, value): + self._set_feature("cmap", value) @property - def cmap_transform_each(self) -> np.ndarray | None: - # PER line/scatter, only allowed after `cmaps` is set. - return self.processor.cmap_transform_each - - @cmap_transform_each.setter - def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): - if new is None: - self.processor.cmap_transform_each = None - - if self.cmap_each is None: - self.processor.cmap_transform_each = None - warn("must set `cmap_each` before `cmap_transform_each`") - return + def cmap_transform(self): + """get or set the cmap_transform of the graphics""" + return self._get_feature("cmap_transform") - if new is None and self.cmap_each is not None: - # default transform is just a transform based on the `p` dim size - new = partial(default_cmap_transform_each, self.shape[self.spatial_dims[1]]) - - self.processor.cmap_transform_each = new + @cmap_transform.setter + def cmap_transform(self, value): + self._set_feature("cmap_transform", value) @property - def markers(self) -> str | Sequence[str] | None: - return self._markers + def cmap_range(self): + """get or set the cmap_range of the graphics""" + return self._get_feature("cmap_range") - @markers.setter - def markers(self, new: str | None): - if not isinstance(self.graphic, ScatterCollection): - self._markers = None - return + @cmap_range.setter + def cmap_range(self, value): + self._set_feature("cmap_range", value) - if new is None: - # just set a default - new = "circle" + @property + def thickness(self): + """get or set the thickness of the graphics""" + return self._get_feature("thickness") - self.graphic.markers = new - self._markers = new - # force a re-render - run_sync(self._set_indices_()) + @thickness.setter + def thickness(self, value): + self._set_feature("thickness", value) @property - def sizes(self) -> float | Sequence[float] | None: - return self._sizes + def sizes(self): + """get or set the sizes of the graphics""" + return self._get_feature("sizes") @sizes.setter - def sizes(self, new: float | Sequence[float] | None): - if not isinstance(self.graphic, ScatterCollection): - self._sizes = None - return - - if new is None: - # just set a default - new = 5.0 - - self.graphic.sizes = new - self._sizes = new - # force a re-render - run_sync(self._set_indices_()) + def sizes(self, value): + self._set_feature("sizes", value) @property - def thickness(self) -> float | Sequence[float] | None: - return self._thickness + def markers(self): + """get or set the markers of the graphics""" + return self._get_feature("markers") - @thickness.setter - def thickness(self, new: float | Sequence[float] | None): - if not isinstance(self.graphic, LineCollection): - self._thickness = None - return - - if new is None: - # just set a default - new = 2.0 - - self.graphic.thickness = new - self._thickness = new - # force a re-render - run_sync(self._set_indices_()) + @markers.setter + def markers(self, value): + self._set_feature("markers", value) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py index 875474174..da6e8fdc8 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py @@ -4,22 +4,29 @@ from typing import Literal, Any, Type, TYPE_CHECKING import numpy as np +from numpy.typing import ArrayLike from ....graphics import ( ImageGraphic, - LineGraphic, LineStack, LineCollection, - ScatterGraphic, ScatterCollection, ScatterStack, ) from ....graphics.utils import pause_events from ....graphics.selectors import LinearSelector +from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy from .._base import NDGraphic, WindowFuncCallable, block_indices_ctx from .._index import ReferenceIndex from .._async import run_sync -from ._nd_positions import NDPositions, NDPositionsProcessor +from ._nd_positions import ( + NDPositions, + NDPositionsProcessor, + ColorsType, + SizesType, + MarkersType, + FeatureCallable, +) if TYPE_CHECKING: from .._ndw_subplot import NDWSubplot @@ -35,46 +42,200 @@ def __init__( spatial_dims: tuple[str, str, str], *args, graphic_type: Type[ - LineGraphic - | LineCollection + LineCollection | LineStack - | ScatterGraphic | ScatterCollection | ScatterStack | ImageGraphic ] = LineStack, processor: type[NDPositionsProcessor] = NDPositionsProcessor, - display_window: int = 10, - window_funcs: tuple[WindowFuncCallable | None] | None = None, - slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, + display_window: int | float | None = 10, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, max_display_datapoints: int = 1_000, + datapoints_window_func: tuple[Callable, str, int | float] | None = None, linear_selector: bool = False, x_range_mode: Literal["fixed", "auto"] | None = None, - colors: ( - Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] - ) = None, - cmap: str = None, - cmap_each: Sequence[str] = None, - cmap_transform_each: np.ndarray = None, - markers: np.ndarray = None, - markers_each: Sequence[str] = None, - sizes: np.ndarray = None, - sizes_each: Sequence[float] = None, - thickness: np.ndarray = None, + colors: ColorsType = None, + cmap: str | Sequence[str] = None, + cmap_transform: np.ndarray | FeatureCallable = None, + cmap_range: tuple[float, float] = None, + thickness: float | Sequence[float] = None, + sizes: SizesType = None, + markers: MarkersType = None, name: str = None, graphic_kwargs: dict = None, processor_kwargs: dict = None, ): """ - ``NDPositions`` for timeseries data, where the datapoints dim is a time-like x-axis. + ``NDPositions`` subclass for timeseries data, where the ``p`` dim is a time-like x-axis. + + Supports the same ``LineStack``, ``LineCollection``, ``ScatterStack`` and ``ScatterCollection`` + representations plus a heatmap (``ImageGraphic``) view. It also manages a linear selector that tracks the + current ``p`` index, and couples the camera x-range to it through :attr:`x_range_mode`. + + Parameters + ---------- + ref_index : ReferenceIndex + The shared reference index that delivers slider updates to this graphic. + + nd_subplot : NDWSubplot + parent NDWSubplot the NDGraphic is in + + data : array-like or None + n-dimensional timeseries data. The value dim holds the (x, y) of each datapoint, where x is the + time-like coordinate. + + Ex: an array of shape ``[n_trials, n_traces, n_timepoints, 2]`` with ``dims`` of + ``("trial", "trace", "time", "xy")`` and ``spatial_dims`` of ``("trace", "time", "xy")``. + + Pass ``None`` to create the ``NDTimeseries`` without a graphic and set the data later using + :attr:`data`. + + dims : Sequence[str] + Name for every dimension of ``data``, in order. Non-spatial dims must match keys in ``ref_index``. + + spatial_dims : tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of traces + in the collection, the number of datapoints ``p`` in each of them, and the value dim which holds the + xy or xyz coordinate. A heatmap requires a value dim of size exactly 2. + + args + extra positional arguments passed to the ``processor`` constructor. + + graphic_type : type[LineCollection | LineStack | ScatterCollection | ScatterStack | ImageGraphic], default ``LineStack`` + The graphical representation used to display the data slice. ``ImageGraphic`` renders the traces as a + heatmap, one row per trace, where the color represents the y coordinate. The x coordinates are + applied as the offset and scale of the image, and the y values are interpolated onto a uniform x grid + if the x sampling is not uniform. + + processor : type[NDPositionsProcessor], default ``NDPositionsProcessor`` + ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + + display_window : int, float or None, default 10 + Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its + current index. Use ``None`` to render every datapoint, which also forces ``x_range_mode`` to + ``None``. This is what makes out-of-core rendering possible, i.e. rendering a window of a dataset + that is larger than GPU VRAM. + + window_funcs : dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions applied around the current slider position, see + :class:`NDProcessor`. Not used for the ``p`` dim, see ``datapoints_window_func``. + + window_order : tuple[str, ...], optional + Order in which the window functions are applied across dims. Only dims listed here have their window + function applied, see :class:`NDProcessor`. + + spatial_func : Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice *after* the window funcs, right before rendering. + + slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional + Per-slider-dim mapping from reference-space values to local array indices, see + :class:`NDProcessor`. The transform for the ``p`` dim is typically the array of x values, ex: a + timestamps array, so the slider is in seconds rather than sample indices. + + max_display_datapoints : int, default 1_000 + Maximum number of datapoints to render per graphic. The step size of the display window slice is set + from this using floor division. + + datapoints_window_func : tuple[Callable, str, int | float], optional + Window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``, see + :class:`NDPositionsProcessor`. + + linear_selector : bool, default ``False`` + Add a ``LinearSelector`` that marks the current index of the ``p`` dim. Dragging it sets that index + in the ``ReferenceIndex``, so it drives every other graphic that uses this dim. Only one is created + per subplot, if one is already present this is ignored. + + x_range_mode : "fixed" | "auto" | None, default ``None`` + How the camera x-range is coupled to the ``p`` dim. + + * ``None``: the camera is left alone. + * ``"fixed"``: the x-range is set from ``display_window``, centered on the current ``p`` index, on + every update. + * ``"auto"``: as ``"fixed"``, and the camera x-range is also polled on every render. Panning or + zooming then sets ``display_window`` to the new width and the ``p`` index to the new center, with + a lower bound of 3 datapoints on the width. + + colors : str | Sequence[str] | np.ndarray | FeatureCallable, optional + Colors of the graphics. Mutually exclusive with ``cmap``, setting one clears the other. + + * static, a single color for every graphic, ex: ``"cyan"`` or an RGBA sequence of 4 floats + * static, one color per graphic, ``[n_graphics]`` of str or ``[n_graphics, 4]`` RGBA + * windowed, one color per datapoint, ``[n_graphics, p, 4]`` RGBA + * windowed, a ``FeatureCallable`` + + cmap : str | Sequence[str], optional + Colormap applied to the graphics, always static. A single name for every graphic, or an iterable of + ``[n_graphics]`` names for a colormap per graphic. Mutually exclusive with ``colors``. It is the only + feature that is carried over to the heatmap representation. + + cmap_transform : np.ndarray | FeatureCallable, optional + Values that the colormap colors are mapped from. - Supports the same ``LineStack`` / ``LineCollection`` / ``ScatterStack`` / - ``ScatterCollection`` representations plus a heatmap (``ImageGraphic``) view, and - additionally manages a linear selector and couples the camera x-range to the current - datapoints position via :attr:`x_range_mode`. + * static, one value per graphic, ``[n_graphics]``, so each graphic gets a single color + * windowed, one value per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + cmap_range : (float, float) | np.ndarray, optional + The (min, max) of ``cmap_transform`` mapped onto the colormap, or ``[n_graphics, 2]`` for a range per + graphic. A windowed array ``cmap_transform`` defaults to its own (min, max) over the full ``p`` dim, + so the display window keeps its position within the colormap. A ``FeatureCallable`` transform + requires an explicit range, its full range is not knowable without evaluating it everywhere. + + thickness : float | Sequence[float], optional + Thickness of the lines, always static. A single value for every graphic, or ``[n_graphics]`` values + for a thickness per graphic. + + sizes : float | Sequence[float] | np.ndarray | FeatureCallable, optional + Size of the scatter points. + + * static, a single size for every graphic, or ``[n_graphics]`` sizes for one size per graphic + * windowed, one size per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + markers : str | Sequence[str] | np.ndarray | FeatureCallable, optional + Marker shape of the scatter points. + + * static, a single marker for every graphic, or ``[n_graphics]`` markers for one per graphic + * windowed, one marker per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + name : str, optional + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs : dict, optional + passed to the ``graphic_type`` constructor. + + processor_kwargs : dict, optional + passed to the ``processor`` constructor. + + Notes + ----- + Each of the other graphic features is either *windowed* or *static*, decided from the value itself: + + * **windowed**: a ``FeatureCallable``, or an array whose axis 1 spans the ``p`` dim. It is re-sliced with + the same display window slice as the data on every update, so the feature carries a value per + displayed datapoint. An array **must** span the **full** ``p`` dim of the data, i.e. + ``[n_graphics, p, ]``, since it is indexed with an index into the full ``p`` dim. A + ``FeatureCallable`` is passed the data slice and that display window slice, and returns the feature + values for the displayed datapoints. + + * **static**: anything else. It is set once on the collection, ex: a single value for every graphic, + ``[n_graphics]`` values for one per graphic, or an iterator of per-graphic values such as + ``itertools.cycle(["jet", "viridis"])``. + + A feature the graphic type does not have is ignored, ex: ``thickness`` for scatters, ``markers`` for + lines. The heatmap representation uses only ``cmap``. + + See Also + -------- + NDPositions : Base class for n-dimensional positional data. - Parameters are the same as :class:`NDPositions`, plus ``linear_selector`` and - ``x_range_mode``. """ # NDGraphic base init, then the shared positional setup. We deliberately do not call # NDPositions.__init__, since it would create the graphic before the timeseries state @@ -91,17 +252,18 @@ def __init__( processor=processor, display_window=display_window, window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, slider_dim_transforms=slider_dim_transforms, max_display_datapoints=max_display_datapoints, + datapoints_window_func=datapoints_window_func, colors=colors, cmap=cmap, - cmap_each=cmap_each, - cmap_transform_each=cmap_transform_each, - markers=markers, - markers_each=markers_each, - sizes=sizes, - sizes_each=sizes_each, + cmap_transform=cmap_transform, + cmap_range=cmap_range, thickness=thickness, + sizes=sizes, + markers=markers, graphic_kwargs=graphic_kwargs, processor_kwargs=processor_kwargs, ) @@ -167,14 +329,40 @@ def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): self._graphic = self._graphic_type( image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1) ) - if self._cmap is not None: - self._graphic.cmap = self._cmap + cmap = self._static_features.get("cmap") + if cmap is not None: + self._graphic.cmap = cmap self._nd_subplot.subplot.add_graphic(self._graphic) else: super()._setup_graphic(new_features, indices) self._update_view(indices, new_features["data"]) + async def _create_graphic(self): + await super()._create_graphic() + # use the max over the full `p` dim to account for the y-max of each line/scatter for proper spacing + if isinstance(self._graphic, (LineStack, ScatterStack)): + steps = np.zeros((len(self._graphic), 3)) + steps[:, 1] = await self._p_y_max() + self._graphic.steps = steps + + async def _p_y_max(self) -> np.ndarray: + """per-graphic max of the y values over the full `p` dim, shape [n_graphics]""" + proc = self.processor + # the indexer leaves the spatial `p` dim unsliced, so this raw slice spans every datapoint + raw = await proc._get_raw_data_slice(self.indices) + c = proc.dims.index(proc.spatial_dims[2]) # coord dim; y is index 1 + g = proc.dims.index(proc.spatial_dims[0]) # graphics dim + y = raw[(slice(None),) * c + (1,)] # y values as a view, coord dim removed + # keep the graphics dim (shifted down if it was past the removed coord dim), max the rest; + # `.max` runs on whatever the array is (numpy/cupy/torch/jax), so a GPU array reduces on-device + g_axis = g if g < c else g - 1 + result = y.max(axis=tuple(i for i in range(y.ndim) if i != g_axis)) + if isinstance(result, CudaArrayProtocol): + # only the small [n_graphics] result crosses back to host + result = cuda_to_numpy(result) + return result + def _update_view(self, indices: dict[str, Any], data_slice: np.ndarray): """update the camera x-range and linear selector to the current datapoints position.""" @@ -240,7 +428,10 @@ def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: @property def display_window(self) -> int | float | None: - """display window in the reference units for the n_datapoints dim""" + """ + Get or set the display window, in the reference units of the ``p`` dim. Setting it re-renders the + current data slice, setting it to ``None`` also sets :attr:`x_range_mode` to ``None``. + """ return self.processor.display_window @display_window.setter @@ -254,7 +445,15 @@ def display_window(self, dw: int | float | None): @property def x_range_mode(self) -> Literal["fixed", "auto"] | None: - """x-range using a fixed window from the display window, or by polling the camera (auto)""" + """ + Get or set how the camera x-range is coupled to the ``p`` dim. + + * ``None``: the camera is left alone. + * ``"fixed"``: the x-range is set from the display window, centered on the current ``p`` index, on every + update. + * ``"auto"``: as ``"fixed"``, and the camera x-range is also polled on every render. Panning or zooming + then sets the display window to the new width and the ``p`` index to the new center. + """ return self._x_range_mode @x_range_mode.setter diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index fc15277a0..07d8913e7 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -15,6 +15,40 @@ def __init__( tooltip_columns: list[str] = None, **kwargs, ): + """ + ``NDPositionsProcessor`` subclass that reads positional data from the columns of a ``pandas.DataFrame`` + instead of an n-dimensional array. + + Each entry in ``columns`` names the columns that hold the coordinates of one graphic, so the number of + entries is the number of graphics in the collection and the number of rows is the size of the ``p`` dim. + There are no additional slider dims, ``p`` is the only one. + + Available as ``ndp_extras.NDPP_Pandas`` when ``pandas`` is installed, pass it as the ``processor`` to + ``NDWSubplot.add_nd_lines()``, ``add_nd_scatter()`` or ``add_nd_timeseries()``. + + Parameters + ---------- + data: pd.DataFrame + DataFrame holding the coordinates, one column per coordinate of each graphic. + + spatial_dims: tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``. These are also used as + the ``dims``, since a DataFrame has no other dims to name. + + columns: list[tuple[str, str] | tuple[str, str, str]] + One entry per graphic, each a tuple of 2 or 3 column names giving the (x, y) or (x, y, z) + coordinates of that graphic. Ex: ``[("nose_x", "nose_y"), ("tail_x", "tail_y")]`` for two keypoint + trajectories. + + tooltip_columns: list[str], optional + One column name per graphic. The value of that column at the hovered datapoint is shown in the + tooltip, ex: a per-keypoint likelihood column. Must be the same length as ``columns``. + + kwargs + passed to :class:`.NDPositionsProcessor`, i.e. ``display_window``, ``max_display_datapoints``, + ``slider_dim_transforms``, ``datapoints_window_func`` and ``spatial_func``. + + """ self._columns = columns if tooltip_columns is not None: @@ -37,6 +71,7 @@ def __init__( @property def data(self) -> pd.DataFrame: + """get or set the managed DataFrame, the new DataFrame must have the same ``columns``""" return self._data @data.setter @@ -48,31 +83,71 @@ def data(self, data: pd.DataFrame): @property def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: + """the columns that hold the coordinates of each graphic, one entry per graphic""" return self._columns @property def dims(self) -> tuple[str, str, str]: + """dim names, the same as :attr:`spatial_dims` since a DataFrame has no other dims""" return self._dims @property def shape(self) -> dict[str, int]: + """interpreted shape of the data, the number of graphics, the number of rows, and the value dim""" # n_graphical_elements, n_timepoints, 2 return {self.dims[0]: len(self.columns), self.dims[1]: self.data.index.size, self.dims[2]: 2} @property def ndim(self) -> int: + """number of dims, always 3""" return len(self.shape) @property def tooltip(self) -> bool: + """whether ``tooltip_columns`` were provided, i.e. whether a custom tooltip is formatted""" return self._tooltip def tooltip_format(self, n: int, p: int): + """ + Format the tooltip for a hovered datapoint using the ``tooltip_columns``. + + Parameters + ---------- + n: int + index of the graphic within the collection + + p: int + index of the datapoint within the current display window + + Returns + ------- + str + value of that graphic's tooltip column at this datapoint + + """ # datapoint index w.r.t. full data p += self._dw_slice.start return str(self.data[self._tooltip_columns[n]][p]) async def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + """ + Get the data slice to display at the given indices. + + Stacks the ``columns`` of each graphic into a ``[n_graphics, p, 3]`` array for the current display + window, then applies the ``datapoints_window_func`` and ``spatial_func``. Entries of ``columns`` that + name only (x, y) leave the z coordinate as ``0``. + + Parameters + ---------- + indices: dict[str, Any] + Reference-space value for the ``p`` dim, ex: ``{"time": 46.397}``. + + Returns + ------- + dict[str, np.ndarray] + ``"data"`` holds the data slice, the remaining keys are the windowed graphic features. + + """ # TODO: LOD by using a step size according to max_p # TODO: Also what to do if display_window is None and data # hasn't changed when indices keeps getting set, cache? diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 1a4d1b8e5..9bf3c7fa2 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -29,51 +29,56 @@ def __init__( self, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: tuple[str, str, str], # must be in order, last dim must be 4 - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, - window_order: tuple[int, ...] = None, + spatial_dims: tuple[str, str, str], # must be in order! [n_vectors, positions & directions, xy(z)] + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayLike], ArrayLike] = None, - slider_dim_transforms=None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, ): """ - ``NDProcessor`` subclass for n-dimensional vector data + ``NDProcessor`` subclass for n-dimensional vector data. - Produces (num_vectors, 2, [2 or 3]) slices for a ``VectorsGraphic``. The last two dimensions describe the + Produces ``[n_vectors, 2, 2 | 3]`` slices for a ``VectorsGraphic``. The last two dims describe the position/direction and the 2D/3D spatial coordinate, respectively. Parameters ---------- data: ArrayProtocol - Shape [..., num_vectors, 2, 2] or [..., num_vectors, 2, 3]. data[..., 0, :] gives the positions, data[..., 1, :] gives directions + n-dimensional vector data, must have 3 or more dims. Index ``0`` along the positions/directions dim + gives the vector positions and index ``1`` gives the vector directions. + + Ex: an electric field sampled over time, an array of shape ``[n_timepoints, n_vectors, 2, 2]`` with + ``dims`` of ``("time", "n_vectors", "pos_dir", "xy")`` and ``spatial_dims`` of + ``("n_vectors", "pos_dir", "xy")``. dims: Sequence[str] names for each dimension in ``data``. Dimensions not listed in ``spatial_dims`` are treated as slider dimensions and **must** appear as - keys in the parent ``NDWidget``'s ``ref_ranges`` - Examples:: - - A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method - must operate as if these dimensions exist and return an array that matches the spatial dimensions. - + keys in the parent ``NDWidget``'s ``ref_ranges``. - dims in the array do not need to be in the order that you want to display them, for example you can have a - weird array where the dims are interpreted as: - ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")``. + dims in the array do not need to be in the order that you want to display them, the data slice is + transposed into the order given by ``spatial_dims``. - spatial_dims : tuple[str, str] | tuple[str, str, str] - The dim names that indicate [n_vectors, positions & directions, xy(z)], **in that order** + spatial_dims : tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_vectors, positions & directions, xy(z))``. The + positions/directions dim must be of size 2 and the coordinate dim of size 2 or 3. - slider_dim_transforms : dict, optional - See :class:`NDProcessor`. + slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional + Per-slider-dim mapping from reference-space values to local array indices, see + :class:`NDProcessor`. - window_funcs : dict, optional - See :class:`NDProcessor`. + window_funcs : dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions applied around the current slider position, see + :class:`NDProcessor`. - window_order : tuple, optional - See :class:`NDProcessor`. + window_order : tuple[str, ...], optional + Order in which the window functions are applied across dims. Only dims listed here have their window + function applied, see :class:`NDProcessor`. - spatial_func : callable, optional - See :class:`NDProcessor`. + spatial_func : Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice *after* the window funcs, right before rendering. See Also -------- @@ -116,10 +121,10 @@ def data(self, data: ArrayProtocol): self._data = data @property - def spatial_dims(self) -> tuple[str, str]: + def spatial_dims(self) -> tuple[str, str, str]: """ - Spatial dims, **in order** - Dimensions in order are num_vectors, position/direction, xy[z], so the shape is [num_vectors, 2, 2 or 3] + Spatial dims, **in display order**: ``(n_vectors, positions & directions, xy(z))``, so the data slice is + of shape ``[n_vectors, 2, 2 | 3]`` """ return self._spatial_dims @@ -145,16 +150,21 @@ def spatial_dims(self, sdims: tuple[str, str, str]): async def get(self, indices: dict[str, Any]) -> ArrayProtocol: """ - Get the data at the given index, process data through the window functions. + Get the data slice at the given indices, applying the window functions and the spatial func. - 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. + Note that we do not use __getitem__ here since the indices are reference-space values keyed by slider dim + name, not array indices. Slices are not allowed, therefore __getitem__ is not suitable here. Parameters ---------- - indices: tuple[int, ...] - Get the processed data at this index. Must provide a value for each dimension. - Example: get((100, 5)) + indices: dict[str, Any] + Reference-space value for each slider dim, ex: ``{"time": 46.397}``. Must provide a value for every + slider dim. + + Returns + ------- + ArrayProtocol + data slice of shape ``[n_vectors, 2, 2 | 3]``, transposed into the ``spatial_dims`` display order """ # this will be squeezed output, with dims in the order of self.dims @@ -187,18 +197,20 @@ def __init__( dims: Sequence[str], spatial_dims: tuple[ str, str, str - ], # must be in order! - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, - window_order: tuple[int, ...] = None, + ], # must be in order! [n_vectors, positions & directions, xy(z)] + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms=None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, name: str = None, graphic_kwargs: dict = None, ): """ - ``NDGraphic`` subclass for n-dimensional vector rendering + ``NDGraphic`` subclass for n-dimensional vector rendering. - Wraps an :class:`VectorGraphic` + Uses an :class:`NDVectorsProcessor` to produce the data slices and manages a :class:`.VectorsGraphic`. Every dimension that is *not* listed in ``spatial_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the @@ -211,40 +223,50 @@ def __init__( The shared reference index that delivers slider updates to this graphic. nd_subplot : NDWSubplot - parent ndsubplot the NDGraphic is in + parent NDWSubplot the NDGraphic is in data : array-like or None - Shape [num_vectors, 2, 2] or [num_vectors, 3, 2]. data[:, :, 0] gives the positions, data[:, :, 1] gives directions - n-dimension image data array + n-dimensional vector data, must have 3 or more dims. Index ``0`` along the positions/directions dim + gives the vector positions and index ``1`` gives the vector directions. - dims : sequence of hashable - Name for every dimension of ``data``, in order. Non-spatial dims must - match keys in ``ref_index``. + Ex: an electric field sampled over time, an array of shape ``[n_timepoints, n_vectors, 2, 2]`` with + ``dims`` of ``("time", "n_vectors", "pos_dir", "xy")`` and ``spatial_dims`` of + ``("n_vectors", "pos_dir", "xy")``. - ex: ``("time", "depth", "row", "col")`` — ``"time"`` and ``"depth"`` must - be present in ``ref_index``. + Pass ``None`` to create the ``NDVectors`` without a graphic and set the data later using + :attr:`data`. - spatial_dims : tuple[str, str] | tuple[str, str, str] - Spatial dimensions **in order**: These dims are either [n_vectors, 2, 2] or [n_vectors, 2, 3], indicating [n_vectors, positions & directions, xy(z)] + dims : Sequence[str] + Name for every dimension of ``data``, in order. Non-spatial dims must match keys in ``ref_index``. - window_funcs : dict, optional - See :class:`NDProcessor`. + spatial_dims : tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_vectors, positions & directions, xy(z))``. The + positions/directions dim must be of size 2 and the coordinate dim of size 2 or 3. - window_order : tuple, optional - See :class:`NDProcessor`. + window_funcs : dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions applied around the current slider position, see + :class:`NDProcessor`. - spatial_func : callable, optional - See :class:`NDProcessor`. + window_order : tuple[str, ...], optional + Order in which the window functions are applied across dims. Only dims listed here have their window + function applied, see :class:`NDProcessor`. - slider_dim_transforms : dict, optional - See :class:`NDProcessor`. + spatial_func : Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice *after* the window funcs, right before rendering. + + slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional + Per-slider-dim mapping from reference-space values to local array indices, see + :class:`NDProcessor`. name : str, optional - Name for the underlying graphic. + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs : dict, optional + passed to the underlying :class:`.VectorsGraphic`, ex: ``{"color": "cyan", "size": 0.5}`` See Also -------- - NDImageProcessor : The processor that backs this graphic. + NDVectorsProcessor : The processor that produces the data slices for this graphic. """ @@ -292,8 +314,8 @@ def graphic( return self._graphic async def _create_graphic(self): - # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, - # adds it to the subplot, and resets the camera and histogram. + # Creates a ``VectorsGraphic`` from the current data slice, replacing any existing one, and adds it + # to the subplot. if self.processor.data is None: # no graphic if data is None, useful for initializing in null states when we want to set data later @@ -321,8 +343,8 @@ async def _create_graphic(self): @property def spatial_dims(self) -> tuple[str, str, str]: """ - get or set the spatial dims **in order**. - Spatial dim shape here is [num_vectors, position/dimension (2), xy[z] (2 or 3)] + Get or set the spatial dims **in display order**: ``(n_vectors, positions & directions, xy(z))``, so the + data slice is of shape ``[n_vectors, 2, 2 | 3]``. Setting them recreates the graphic. """ return self.processor.spatial_dims @@ -335,7 +357,7 @@ def spatial_dims(self, dims: tuple[str, str, str]): @property def indices(self) -> dict[str, Any]: - """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" + """the current index of each slider dim in reference-space units, from the ``ReferenceIndex``""" return {d: self._ref_index[d] for d in self.processor.slider_dims} async def _set_indices_(self, indices: dict[str, Any] = None): @@ -351,7 +373,9 @@ async def _set_indices_(self, indices: dict[str, Any] = None): @property def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: - """get or set the spatial_func, see docstring for details""" + """ + Get or set the function applied to the spatial slice *after* the window funcs, right before rendering. + """ # this is here even though it's the same in the base class since we can't create the image specific setter # without also defining the property in this subclass. return self.processor.spatial_func diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index da3473698..9e35a9c27 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -1,8 +1,9 @@ import warnings from collections.abc import Callable -from typing import Literal, Sequence, Hashable +from typing import Any, Literal, Sequence, Hashable import numpy as np +from numpy.typing import ArrayLike from ... import ( ScatterCollection, @@ -14,6 +15,13 @@ from ...layouts import Subplot from ...utils import ArrayProtocol, enums from . import NDImageProcessor, NDImage, NDPositions, NDTimeseries, NDVectors +from ._nd_positions._nd_positions import ( + NDPositionsProcessor, + ColorsType, + FeatureCallable, + MarkersType, + SizesType, +) from ._index import AutoRangeContinuous from ._video import VideoProcessor from ._base import NDGraphic, WindowFuncCallable @@ -38,6 +46,7 @@ def __init__(self, ndw, subplot: Subplot): @property def subplot(self) -> Subplot: + """The ``Subplot`` of the ``NDWidget`` figure that this ``NDWSubplot`` adds graphics to""" return self._subplot @property @@ -45,7 +54,7 @@ def nd_graphics(self) -> tuple[NDGraphic]: """all the NDGraphic instance in this subplot""" return tuple(self._nd_graphics) - def __getitem__(self, key): + def __getitem__(self, key) -> NDGraphic: # get a specific NDGraphic by index or name if isinstance(key, (int, np.integer)): return self.nd_graphics[key] @@ -57,6 +66,16 @@ def __getitem__(self, key): else: raise KeyError(f"NDGraphc with given key not found: {key}") + def delete_nd_graphic(self, ndg: NDGraphic): + """Delete an NDGraphic from the subplot""" + + # TODO: verify that this actually garbage collects + del ndg.data + self.subplot.delete_graphic(ndg.graphic) + self._nd_graphics.remove(ndg) + + del ndg + def _check_slider_dims( self, dims: Sequence[Hashable], @@ -99,19 +118,102 @@ def _check_slider_dims( def add_nd_image( self, data: ArrayProtocol | None, - dims: Sequence[Hashable], + dims: Sequence[str], spatial_dims: ( tuple[str, str] | tuple[str, str, str] ), # must be in order! [rows, cols] | [z, rows, cols] rgb_dim: str | None = None, - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, - window_order: tuple[int, ...] = None, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, compute_histogram: bool = True, - slider_dim_transforms=None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + processor_type: type[NDImageProcessor] = NDImageProcessor, + colorspace: Literal[ + "srgb", "tex-srgb", "physical", "yuv420p", "yuv444p" + ] = "srgb", + colorrange: Literal["full", "limited"] = "full", name: str = None, - **kwargs, - ): + graphic_kwargs: dict = None, + ) -> NDImage: + """ + Add an n-dimensional image or volume to this subplot. + + Every dim that is not listed in ``spatial_dims`` becomes a slider dim. + + Parameters + ---------- + data: ArrayProtocol or None + n-dimensional image data, must have 2 or more dims. Pass ``None`` to create the ``NDImage`` without a + graphic and set the data later using ``nd_image.data``, the slider dims then require an explicit + reference range in the ``NDWidget``. + + dims: Sequence[str] + name for every dim of ``data``, in order. They do not need to be in display order, ex: an array whose + dims are ``("col", "depth", "row", "time")`` with ``spatial_dims`` of ``("row", "col")``. + + spatial_dims: tuple[str, str] | tuple[str, str, str] + The 2 or 3 spatial dims **in display order**, which also determines the graphic used for rendering: + + * ``(rows, cols)``, a 2D grayscale ``ImageGraphic`` + * ``(rows, cols, rgb_dim)``, a 2D RGB(A) ``ImageGraphic`` + * ``(z, rows, cols)``, a 3D ``ImageVolumeGraphic`` + + rgb_dim: str, optional + Name of the RGB(A) dim, if present. It must be listed in ``spatial_dims`` and be of size 3 or 4. + + window_funcs: dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions applied around the current slider position, ex: + ``{"time": (np.mean, 2.5)}``. Each value is a ``(func, window_size)`` pair where: + + * *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). It + **must** return an array that has the same dims as the input, therefore the size of any dim along + which it was applied should reduce to ``1``. These dims must not be removed by the window func. + + * *window_size* is in reference-space units (ex: 2.5 seconds). + + window_order: tuple[str, ...], optional + Order in which the window functions are applied across dims. Only dims listed here have their window + function applied, ``window_funcs`` are ignored for any dim not specified in ``window_order``. + + spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice *after* the window funcs, right before rendering. + + compute_histogram: bool, default ``True`` + Estimate a histogram of the data and display an ``ImguiColorbar`` on the right edge of the subplot, + which is used to interactively set vmin, vmax. Disable if random access of the data is not + blazing-fast (ex: data that uses video codecs), or if a histogram is not useful for this data. + + slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None, optional + Per-slider-dim mapping from reference-space values to local array indices. An array of reference + values may be given instead of a callable, ``searchsorted`` is then used as the transform (ex: a + timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference + value is rounded to the nearest integer and used as the array index. + + processor_type: type[NDImageProcessor], default ``NDImageProcessor`` + ``NDImageProcessor`` subclass that manages the data and produces the data slices. + + colorspace: "srgb" | "tex-srgb" | "physical" | "yuv420p" | "yuv444p", default "srgb" + Colorspace in which to interpret the data. The RGB colorspaces are rendered using an ``ImageGraphic`` + or ``ImageVolumeGraphic``, see :class:`.ImageGraphic` for their meaning. The YUV colorspaces are + rendered using an ``ImageYUVGraphic``, see :class:`.ImageYUVGraphic`. + + colorrange: "full" | "limited", default "full" + Used only for the YUV colorspaces, see :class:`.ImageYUVGraphic`. + + name: str, optional + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs: dict, optional + passed to the underlying image graphic, ex: ``{"cmap": "viridis", "interpolation": "linear"}`` + + Returns + ------- + NDImage + + """ self._check_slider_dims(dims, spatial_dims, data) nd = NDImage( @@ -126,8 +228,11 @@ def add_nd_image( spatial_func=spatial_func, compute_histogram=compute_histogram, slider_dim_transforms=slider_dim_transforms, + processor_type=processor_type, + colorspace=colorspace, + colorrange=colorrange, name=name, - **kwargs, + graphic_kwargs=graphic_kwargs, ) self._nd_graphics.append(nd) @@ -142,8 +247,87 @@ def add_video( colorspace: enums.ColorspacesYUV | enums.ColorspacesRGB = "yuv420p", colorrange: enums.ColorRange = "limited", processor_type: NDImageProcessor = VideoProcessor, - **kwargs, - ): + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + compute_histogram: bool = True, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + name: str = None, + graphic_kwargs: dict = None, + ) -> NDImage: + """ + Add a video to this subplot. + + This is usually what you want for video data. Videos are usually stored in a YUV colorspace, and sending + the YUV planes to the GPU is much faster than converting each frame to RGB and copying it into an sRGB + texture. + + We strongly recommend using ``asyncvideo`` for the ``data`` object, it is the most efficient async video + reader that we know of for visualization purposes: https://pypi.org/project/asyncvideo/ + + Same as :meth:`add_nd_image` but uses a :class:`VideoProcessor` and YUV defaults. The ``VideoProcessor`` + reads the frame at the current index directly, it does not apply ``window_funcs``. + + Parameters + ---------- + data: ArrayProtocol or None + video data, an object that decodes frames on demand, ex: an ``asyncvideo`` reader. Pass ``None`` to + create the ``NDImage`` without a graphic and set the data later using ``nd_image.data``, the slider + dims then require an explicit reference range in the ``NDWidget``. + + dims: Sequence[str] + name for every dim of ``data``, in order. They do not need to be in display order. + + spatial_dims: tuple[str, str] | tuple[str, str, str] + The 2 or 3 spatial dims **in display order**, see :meth:`add_nd_image`. + + rgb_dim: str, optional + Name of the RGB(A) dim, if present. It must be listed in ``spatial_dims`` and be of size 3 or 4. + + colorspace: "yuv420p" | "yuv444p" | "srgb" | "tex-srgb" | "physical", default "yuv420p" + Colorspace in which to interpret the data. The YUV colorspaces are rendered using an + ``ImageYUVGraphic``, see :class:`.ImageYUVGraphic`. The RGB colorspaces are rendered using an + ``ImageGraphic`` or ``ImageVolumeGraphic``, see :class:`.ImageGraphic`. + + colorrange: "full" | "limited", default "limited" + Used only for the YUV colorspaces, see :class:`.ImageYUVGraphic`. Most videos use "limited". + + processor_type: type[NDImageProcessor], default ``VideoProcessor`` + ``NDImageProcessor`` subclass that manages the data and produces the data slices. + + window_funcs: dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions, see :meth:`add_nd_image`. Ignored by the default + ``VideoProcessor``. + + window_order: tuple[str, ...], optional + Order in which the window functions are applied across dims. Ignored by the default + ``VideoProcessor``. + + spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice right before rendering. + + compute_histogram: bool, default ``True`` + Estimate a histogram of the data and display an ``ImguiColorbar`` on the right edge of the subplot, + which is used to interactively set vmin, vmax. Usually disabled for video since it requires random + access of frames, which is slow for data that uses video codecs. + + slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None, optional + Per-slider-dim mapping from reference-space values to local array indices, ex: an array of frame + timestamps to map seconds onto frame indices. See :meth:`add_nd_image`. + + name: str, optional + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs: dict, optional + passed to the underlying image graphic + + Returns + ------- + NDImage + + """ return self.add_nd_image( data=data, dims=dims, @@ -152,7 +336,13 @@ def add_video( colorspace=colorspace, colorrange=colorrange, processor_type=processor_type, - **kwargs, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + compute_histogram=compute_histogram, + slider_dim_transforms=slider_dim_transforms, + name=name, + graphic_kwargs=graphic_kwargs, ) def add_nd_vectors( @@ -160,13 +350,69 @@ def add_nd_vectors( data: ArrayProtocol | None, dims: Sequence[str], spatial_dims: tuple[str, str, str], - window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, - window_order: tuple[int, ...] = None, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms=None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, name: str = None, - **kwargs + graphic_kwargs: dict = None, ) -> NDVectors: + """ + Add n-dimensional vectors to this subplot, similar to matplotlib quiver. + + Every dim that is not listed in ``spatial_dims`` becomes a slider dim. + + Parameters + ---------- + data: ArrayProtocol or None + n-dimensional vector data of shape ``[..., n_vectors, 2, 2]`` or ``[..., n_vectors, 2, 3]``, where + ``data[..., 0, :]`` are the vector positions and ``data[..., 1, :]`` are the vector directions. Pass + ``None`` to create the ``NDVectors`` without a graphic and set the data later using + ``nd_vectors.data``, the slider dims then require an explicit reference range in the ``NDWidget``. + + dims: Sequence[str] + name for every dim of ``data``, in order. They do not need to be in display order. + + spatial_dims: tuple[str, str, str] + The 3 spatial dims **in order**: ``(n_vectors, positions_and_directions, xy(z))``. The + positions/directions dim must be of size 2 and the coordinate dim of size 2 or 3. + + window_funcs: dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions applied around the current slider position, ex: + ``{"time": (np.mean, 2.5)}``. Each value is a ``(func, window_size)`` pair where: + + * *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). It + **must** return an array that has the same dims as the input, therefore the size of any dim along + which it was applied should reduce to ``1``. These dims must not be removed by the window func. + + * *window_size* is in reference-space units (ex: 2.5 seconds). + + window_order: tuple[str, ...], optional + Order in which the window functions are applied across dims. Only dims listed here have their window + function applied, ``window_funcs`` are ignored for any dim not specified in ``window_order``. + + spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice *after* the window funcs, right before rendering. + + slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None, optional + Per-slider-dim mapping from reference-space values to local array indices. An array of reference + values may be given instead of a callable, ``searchsorted`` is then used as the transform (ex: a + timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference + value is rounded to the nearest integer and used as the array index. + + name: str, optional + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs: dict, optional + passed to the underlying :class:`.VectorsGraphic`, ex: ``{"color": "cyan", "size": 0.5}`` + + Returns + ------- + NDVectors + + """ self._check_slider_dims(dims, spatial_dims, data) nd = NDVectors( @@ -180,14 +426,189 @@ def add_nd_vectors( spatial_func=spatial_func, slider_dim_transforms=slider_dim_transforms, name=name, - **kwargs + graphic_kwargs=graphic_kwargs, ) self._nd_graphics.append(nd) return nd - def add_nd_scatter(self, data, dims, spatial_dims, *args, **kwargs): - # TODO: better func signature here, send all kwargs to processor_kwargs + def add_nd_scatter( + self, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], + *args, + processor: type[NDPositionsProcessor] = NDPositionsProcessor, + display_window: int | float | None = 10, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + max_display_datapoints: int = 1_000, + datapoints_window_func: tuple[Callable, str, int | float] | None = None, + colors: ColorsType = None, + cmap: str | Sequence[str] = None, + cmap_transform: np.ndarray | FeatureCallable = None, + cmap_range: tuple[float, float] = None, + sizes: SizesType = None, + markers: MarkersType = None, + name: str = None, + graphic_kwargs: dict = None, + processor_kwargs: dict = None, + ) -> NDPositions: + """ + Add n-dimensional positional data to this subplot, rendered as a ``ScatterCollection``. + + Every dim that is not listed in ``spatial_dims`` becomes a slider dim. The datapoints dim, ``p``, is both + a spatial dim and a slider dim, it is windowed by ``display_window`` and ``datapoints_window_func`` + rather than by ``window_funcs``. + + Parameters + ---------- + data: ArrayProtocol or None + n-dimensional positional data. + + Ex: an array of shape ``[n_trials, n_scatters, n_points, 2]`` with ``dims`` of + ``("trial", "scatter", "point", "xy")`` and ``spatial_dims`` of ``("scatter", "point", "xy")``. + + Pass ``None`` to create the ``NDPositions`` without a graphic and set the data later using + ``nd_positions.data``, the slider dims then require an explicit reference range in the ``NDWidget``. + + dims: Sequence[str] + name for every dim of ``data``, in order. + + spatial_dims: tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of + scatters in the collection, the number of datapoints ``p`` in each of them, and the value dim which + holds the xy or xyz coordinate and must be of size 2 or 3. The dims do not need to be in this order + in the array, the data slice is transposed into display order. + + args + extra positional arguments passed to the ``processor`` constructor. + + processor: type[NDPositionsProcessor], default ``NDPositionsProcessor`` + ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + + display_window: int, float or None, default 10 + Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its + current index. Use ``None`` to render every datapoint, or ``0`` to render only the datapoint at the + current index. This is what makes out-of-core rendering possible, i.e. rendering a window of a + dataset that is larger than GPU VRAM. + + window_funcs: dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions applied around the current slider position, ex: + ``{"trial": (np.mean, 5)}``. Each value is a ``(func, window_size)`` pair where: + + * *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). It + **must** return an array that has the same dims as the input, therefore the size of any dim along + which it was applied should reduce to ``1``. These dims must not be removed by the window func. + + * *window_size* is in reference-space units. + + Not used for the ``p`` dim, see ``datapoints_window_func``. + + window_order: tuple[str, ...], optional + Order in which the window functions are applied across dims. Only dims listed here have their window + function applied, ``window_funcs`` are ignored for any dim not specified in ``window_order``. + + spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice *after* the window funcs, right before rendering. + + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike], optional + Per-slider-dim mapping from reference-space values to local array indices. An array of reference + values may be given instead of a Callable, ``searchsorted`` is then used as the transform (ex: a + timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference + value is rounded to the nearest integer and used as the array index. + + max_display_datapoints: int, default 1_000 + Maximum number of datapoints to render per graphic. The step size of the display window slice is set + from this using floor division. + + datapoints_window_func: tuple[Callable, str, int | float], optional + Window function applied along the ``p`` dim after the display window has been taken, as + ``(func, apply_dims, window_size)`` where: + + * *func* must accept an ``axis: int`` kwarg (ex: ``np.mean``, ``np.max``). It is given a sliding + window view of the data and is reduced along the window axis. + + * *apply_dims* names the coordinates of the value dim to apply it to, one of ``"all", "x", "y", + "z", "xy", "xz", "yz", "xyz"``. Coordinates that are not named are passed through unchanged. + + * *window_size* is in the reference units of the ``p`` dim. It is mapped to array indices, clamped to + a minimum of 3, and rounded up to an odd size. + + If used, ``display_window`` is approximate and not exact due to padding from the window size. + + colors: str | Sequence[str] | np.ndarray | FeatureCallable, optional + Colors of the scatters. Mutually exclusive with ``cmap``, setting one clears the other. + + * static, a single color for every graphic, ex: ``"cyan"`` or an RGBA sequence of 4 floats + * static, one color per graphic, ``[n_graphics]`` of str or ``[n_graphics, 4]`` RGBA + * windowed, one color per datapoint, ``[n_graphics, p, 4]`` RGBA + * windowed, a ``FeatureCallable`` + + cmap: str | Sequence[str], optional + Colormap applied to the scatters, always static. A single name for every graphic, or an iterable of + ``[n_graphics]`` names for a colormap per graphic. Mutually exclusive with ``colors``. + + cmap_transform: np.ndarray | FeatureCallable, optional + Values that the colormap colors are mapped from. + + * static, one value per graphic, ``[n_graphics]``, so each graphic gets a single color + * windowed, one value per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + cmap_range: (float, float) | np.ndarray, optional + The (min, max) of ``cmap_transform`` mapped onto the colormap, or ``[n_graphics, 2]`` for a range per + graphic. A windowed array ``cmap_transform`` defaults to its own (min, max) over the full ``p`` dim, + so the display window keeps its position within the colormap. A ``FeatureCallable`` transform + requires an explicit range, its full range is not knowable without evaluating it everywhere. + + sizes: float | Sequence[float] | np.ndarray | FeatureCallable, optional + Size of the scatter points. + + * static, a single size for every graphic, or ``[n_graphics]`` sizes for one size per graphic + * windowed, one size per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + markers: str | Sequence[str] | np.ndarray | FeatureCallable, optional + Marker shape of the scatter points. + + * static, a single marker for every graphic, or ``[n_graphics]`` markers for one per graphic + * windowed, one marker per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + name: str, optional + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs: dict, optional + passed to the underlying ``ScatterCollection`` + + processor_kwargs: dict, optional + passed to the ``processor`` constructor. + + Returns + ------- + NDPositions + + Notes + ----- + Each of the other graphic features is either *windowed* or *static*, decided from the value itself: + + * **windowed**: a ``FeatureCallable``, or an array whose axis 1 spans the ``p`` dim. It is re-sliced with + the same display window slice as the data on every update, so the feature carries a value per + displayed datapoint. An array **must** span the **full** ``p`` dim of the data, i.e. + ``[n_graphics, p, ]``, since it is indexed with an index into the full ``p`` dim. A + ``FeatureCallable`` is passed the data slice and that display window slice, and returns the feature + values for the displayed datapoints. + + * **static**: anything else. It is set once on the collection, ex: a single value for every graphic, + ``[n_graphics]`` values for one per graphic, or an iterator of per-graphic values such as + ``itertools.cycle(["jet", "viridis"])``. + + """ self._check_slider_dims(dims, spatial_dims, data, positions=True) nd = NDPositions( @@ -198,7 +619,23 @@ def add_nd_scatter(self, data, dims, spatial_dims, *args, **kwargs): spatial_dims, *args, graphic_type=ScatterCollection, - **kwargs, + processor=processor, + display_window=display_window, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + slider_dim_transforms=slider_dim_transforms, + max_display_datapoints=max_display_datapoints, + datapoints_window_func=datapoints_window_func, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + cmap_range=cmap_range, + sizes=sizes, + markers=markers, + name=name, + graphic_kwargs=graphic_kwargs, + processor_kwargs=processor_kwargs, ) self._nd_graphics.append(nd) @@ -206,16 +643,219 @@ def add_nd_scatter(self, data, dims, spatial_dims, *args, **kwargs): def add_nd_timeseries( self, - data, - dims, - spatial_dims, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], *args, graphic_type: type[ - LineCollection | LineStack | ScatterStack | ImageGraphic + LineCollection | LineStack | ScatterCollection | ScatterStack | ImageGraphic ] = LineStack, x_range_mode: Literal["fixed", "auto"] | None = "auto", - **kwargs, - ): + processor: type[NDPositionsProcessor] = NDPositionsProcessor, + display_window: int | float | None = 10, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + max_display_datapoints: int = 1_000, + datapoints_window_func: tuple[Callable, str, int | float] | None = None, + colors: ColorsType = None, + cmap: str | Sequence[str] = None, + cmap_transform: np.ndarray | FeatureCallable = None, + cmap_range: tuple[float, float] = None, + thickness: float | Sequence[float] = None, + sizes: SizesType = None, + markers: MarkersType = None, + name: str = None, + graphic_kwargs: dict = None, + processor_kwargs: dict = None, + ) -> NDTimeseries: + """ + Add n-dimensional timeseries data to this subplot, where the ``p`` dim is a time-like x-axis. + + Every dim that is not listed in ``spatial_dims`` becomes a slider dim. The datapoints dim, ``p``, is both + a spatial dim and a slider dim, it is windowed by ``display_window`` and ``datapoints_window_func`` + rather than by ``window_funcs``. + + A ``LinearSelector`` that marks the current index of the ``p`` dim is added to the subplot. Dragging it + sets that index in the ``ReferenceIndex``, so it drives every other graphic that uses this dim. Only one + is created per subplot. + + Parameters + ---------- + data: ArrayProtocol or None + n-dimensional timeseries data. The value dim holds the (x, y) of each datapoint, where x is the + time-like coordinate. + + Ex: an array of shape ``[n_trials, n_traces, n_timepoints, 2]`` with ``dims`` of + ``("trial", "trace", "time", "xy")`` and ``spatial_dims`` of ``("trace", "time", "xy")``. + + Pass ``None`` to create the ``NDTimeseries`` without a graphic and set the data later using + ``nd_timeseries.data``, the slider dims then require an explicit reference range in the ``NDWidget``. + + dims: Sequence[str] + name for every dim of ``data``, in order. + + spatial_dims: tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of traces + in the collection, the number of datapoints ``p`` in each of them, and the value dim which holds the + xy or xyz coordinate. A heatmap requires a value dim of size exactly 2. The dims do not need to be in + this order in the array, the data slice is transposed into display order. + + args + extra positional arguments passed to the ``processor`` constructor. + + graphic_type: type[LineCollection | LineStack | ScatterCollection | ScatterStack | ImageGraphic], default ``LineStack`` + The graphical representation used to display the data slice. ``ImageGraphic`` renders the traces as a + heatmap, one row per trace, where the color represents the y coordinate. The x coordinates are + applied as the offset and scale of the image, and the y values are interpolated onto a uniform x grid + if the x sampling is not uniform. + + x_range_mode: "fixed" | "auto" | None, default "auto" + How the camera x-range is coupled to the ``p`` dim. + + * ``None``: the camera is left alone. + * ``"fixed"``: the x-range is set from ``display_window``, centered on the current ``p`` index, on + every update. + * ``"auto"``: as ``"fixed"``, and the camera x-range is also polled on every render. Panning or + zooming then sets ``display_window`` to the new width and the ``p`` index to the new center, with + a lower bound of 3 datapoints on the width. + + Forced to ``None`` when ``display_window`` is ``None``. + + processor: type[NDPositionsProcessor], default ``NDPositionsProcessor`` + ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + + display_window: int, float or None, default 10 + Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its + current index. Use ``None`` to render every datapoint, which also forces ``x_range_mode`` to + ``None``. This is what makes out-of-core rendering possible, i.e. rendering a window of a dataset + that is larger than GPU VRAM. + + window_funcs: dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions applied around the current slider position, ex: + ``{"trial": (np.mean, 5)}``. Each value is a ``(func, window_size)`` pair where: + + * *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). It + **must** return an array that has the same dims as the input, therefore the size of any dim along + which it was applied should reduce to ``1``. These dims must not be removed by the window func. + + * *window_size* is in reference-space units. + + Not used for the ``p`` dim, see ``datapoints_window_func``. + + window_order: tuple[str, ...], optional + Order in which the window functions are applied across dims. Only dims listed here have their window + function applied, ``window_funcs`` are ignored for any dim not specified in ``window_order``. + + spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice *after* the window funcs, right before rendering. + + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike], optional + Per-slider-dim mapping from reference-space values to local array indices. An array of reference + values may be given instead of a Callable, ``searchsorted`` is then used as the transform. The + transform for the ``p`` dim is typically the array of x values, ex: a timestamps array, so the + slider is in seconds rather than sample indices. Any dim without a transform uses the identity + mapping, i.e. the current reference value is rounded to the nearest integer and used as the array + index. + + max_display_datapoints: int, default 1_000 + Maximum number of datapoints to render per graphic. The step size of the display window slice is set + from this using floor division. + + datapoints_window_func: tuple[Callable, str, int | float], optional + Window function applied along the ``p`` dim after the display window has been taken, as + ``(func, apply_dims, window_size)`` where: + + * *func* must accept an ``axis: int`` kwarg (ex: ``np.mean``, ``np.max``). It is given a sliding + window view of the data and is reduced along the window axis. + + * *apply_dims* names the coordinates of the value dim to apply it to, one of ``"all", "x", "y", + "z", "xy", "xz", "yz", "xyz"``. Coordinates that are not named are passed through unchanged. + + * *window_size* is in the reference units of the ``p`` dim. It is mapped to array indices, clamped to + a minimum of 3, and rounded up to an odd size. + + If used, ``display_window`` is approximate and not exact due to padding from the window size. + + colors: str | Sequence[str] | np.ndarray | FeatureCallable, optional + Colors of the traces. Mutually exclusive with ``cmap``, setting one clears the other. + + * static, a single color for every graphic, ex: ``"cyan"`` or an RGBA sequence of 4 floats + * static, one color per graphic, ``[n_graphics]`` of str or ``[n_graphics, 4]`` RGBA + * windowed, one color per datapoint, ``[n_graphics, p, 4]`` RGBA + * windowed, a ``FeatureCallable`` + + cmap: str | Sequence[str], optional + Colormap applied to the traces, always static. A single name for every graphic, or an iterable of + ``[n_graphics]`` names for a colormap per graphic. Mutually exclusive with ``colors``. It is the only + feature that is carried over to the heatmap representation. + + cmap_transform: np.ndarray | FeatureCallable, optional + Values that the colormap colors are mapped from. + + * static, one value per graphic, ``[n_graphics]``, so each graphic gets a single color + * windowed, one value per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + cmap_range: (float, float) | np.ndarray, optional + The (min, max) of ``cmap_transform`` mapped onto the colormap, or ``[n_graphics, 2]`` for a range per + graphic. A windowed array ``cmap_transform`` defaults to its own (min, max) over the full ``p`` dim, + so the display window keeps its position within the colormap. A ``FeatureCallable`` transform + requires an explicit range, its full range is not knowable without evaluating it everywhere. + + thickness: float | Sequence[float], optional + Thickness of the lines, always static. A single value for every graphic, or ``[n_graphics]`` values + for a thickness per graphic. + + sizes: float | Sequence[float] | np.ndarray | FeatureCallable, optional + Size of the scatter points. + + * static, a single size for every graphic, or ``[n_graphics]`` sizes for one size per graphic + * windowed, one size per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + markers: str | Sequence[str] | np.ndarray | FeatureCallable, optional + Marker shape of the scatter points. + + * static, a single marker for every graphic, or ``[n_graphics]`` markers for one per graphic + * windowed, one marker per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + name: str, optional + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs: dict, optional + passed to the ``graphic_type`` constructor. + + processor_kwargs: dict, optional + passed to the ``processor`` constructor. + + Returns + ------- + NDTimeseries + + Notes + ----- + Each of the other graphic features is either *windowed* or *static*, decided from the value itself: + + * **windowed**: a ``FeatureCallable``, or an array whose axis 1 spans the ``p`` dim. It is re-sliced with + the same display window slice as the data on every update, so the feature carries a value per + displayed datapoint. An array **must** span the **full** ``p`` dim of the data, i.e. + ``[n_graphics, p, ]``, since it is indexed with an index into the full ``p`` dim. A + ``FeatureCallable`` is passed the data slice and that display window slice, and returns the feature + values for the displayed datapoints. + + * **static**: anything else. It is set once on the collection, ex: a single value for every graphic, + ``[n_graphics]`` values for one per graphic, or an iterator of per-graphic values such as + ``itertools.cycle(["jet", "viridis"])``. + + A feature the graphic type does not have is ignored, ex: ``thickness`` for scatters, ``markers`` for + lines. The heatmap representation uses only ``cmap``. + + """ self._check_slider_dims(dims, spatial_dims, data, positions=True) nd = NDTimeseries( @@ -228,13 +868,195 @@ def add_nd_timeseries( graphic_type=graphic_type, linear_selector=True, x_range_mode=x_range_mode, - **kwargs, + processor=processor, + display_window=display_window, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + slider_dim_transforms=slider_dim_transforms, + max_display_datapoints=max_display_datapoints, + datapoints_window_func=datapoints_window_func, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + cmap_range=cmap_range, + thickness=thickness, + sizes=sizes, + markers=markers, + name=name, + graphic_kwargs=graphic_kwargs, + processor_kwargs=processor_kwargs, ) self._nd_graphics.append(nd) return nd - def add_nd_lines(self, data, dims, spatial_dims, *args, **kwargs): + def add_nd_lines( + self, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], + *args, + processor: type[NDPositionsProcessor] = NDPositionsProcessor, + display_window: int | float | None = 10, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + max_display_datapoints: int = 1_000, + datapoints_window_func: tuple[Callable, str, int | float] | None = None, + colors: ColorsType = None, + cmap: str | Sequence[str] = None, + cmap_transform: np.ndarray | FeatureCallable = None, + cmap_range: tuple[float, float] = None, + thickness: float | Sequence[float] = None, + name: str = None, + graphic_kwargs: dict = None, + processor_kwargs: dict = None, + ) -> NDPositions: + """ + Add n-dimensional positional data to this subplot, rendered as a ``LineCollection``. + + Every dim that is not listed in ``spatial_dims`` becomes a slider dim. The datapoints dim, ``p``, is both + a spatial dim and a slider dim, it is windowed by ``display_window`` and ``datapoints_window_func`` + rather than by ``window_funcs``. + + Parameters + ---------- + data: ArrayProtocol or None + n-dimensional positional data. + + Ex: an array of shape ``[n_trials, n_keypoints, n_timepoints, 2]`` with ``dims`` of + ``("trial", "keypoint", "time", "xy")`` and ``spatial_dims`` of ``("keypoint", "time", "xy")``. + + Pass ``None`` to create the ``NDPositions`` without a graphic and set the data later using + ``nd_positions.data``, the slider dims then require an explicit reference range in the ``NDWidget``. + + dims: Sequence[str] + name for every dim of ``data``, in order. + + spatial_dims: tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of lines + in the collection, the number of datapoints ``p`` in each of them, and the value dim which holds the + xy or xyz coordinate and must be of size 2 or 3. The dims do not need to be in this order in the + array, the data slice is transposed into display order. + + args + extra positional arguments passed to the ``processor`` constructor. + + processor: type[NDPositionsProcessor], default ``NDPositionsProcessor`` + ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + + display_window: int, float or None, default 10 + Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its + current index. Use ``None`` to render every datapoint, or ``0`` to render only the datapoint at the + current index. This is what makes out-of-core rendering possible, i.e. rendering a window of a + dataset that is larger than GPU VRAM. + + window_funcs: dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional + Per-slider-dim window functions applied around the current slider position, ex: + ``{"trial": (np.mean, 5)}``. Each value is a ``(func, window_size)`` pair where: + + * *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). It + **must** return an array that has the same dims as the input, therefore the size of any dim along + which it was applied should reduce to ``1``. These dims must not be removed by the window func. + + * *window_size* is in reference-space units. + + Not used for the ``p`` dim, see ``datapoints_window_func``. + + window_order: tuple[str, ...], optional + Order in which the window functions are applied across dims. Only dims listed here have their window + function applied, ``window_funcs`` are ignored for any dim not specified in ``window_order``. + + spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional + A function applied to the spatial slice *after* the window funcs, right before rendering. + + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike], optional + Per-slider-dim mapping from reference-space values to local array indices. An array of reference + values may be given instead of a Callable, ``searchsorted`` is then used as the transform (ex: a + timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference + value is rounded to the nearest integer and used as the array index. + + max_display_datapoints: int, default 1_000 + Maximum number of datapoints to render per graphic. The step size of the display window slice is set + from this using floor division. + + datapoints_window_func: tuple[Callable, str, int | float], optional + Window function applied along the ``p`` dim after the display window has been taken, as + ``(func, apply_dims, window_size)`` where: + + * *func* must accept an ``axis: int`` kwarg (ex: ``np.mean``, ``np.max``). It is given a sliding + window view of the data and is reduced along the window axis. + + * *apply_dims* names the coordinates of the value dim to apply it to, one of ``"all", "x", "y", + "z", "xy", "xz", "yz", "xyz"``. Coordinates that are not named are passed through unchanged. + + * *window_size* is in the reference units of the ``p`` dim. It is mapped to array indices, clamped to + a minimum of 3, and rounded up to an odd size. + + If used, ``display_window`` is approximate and not exact due to padding from the window size. + + colors: str | Sequence[str] | np.ndarray | FeatureCallable, optional + Colors of the lines. Mutually exclusive with ``cmap``, setting one clears the other. + + * static, a single color for every graphic, ex: ``"cyan"`` or an RGBA sequence of 4 floats + * static, one color per graphic, ``[n_graphics]`` of str or ``[n_graphics, 4]`` RGBA + * windowed, one color per datapoint, ``[n_graphics, p, 4]`` RGBA + * windowed, a ``FeatureCallable`` + + cmap: str | Sequence[str], optional + Colormap applied to the lines, always static. A single name for every graphic, or an iterable of + ``[n_graphics]`` names for a colormap per graphic. Mutually exclusive with ``colors``. + + cmap_transform: np.ndarray | FeatureCallable, optional + Values that the colormap colors are mapped from. + + * static, one value per graphic, ``[n_graphics]``, so each graphic gets a single color + * windowed, one value per datapoint, ``[n_graphics, p]`` + * windowed, a ``FeatureCallable`` + + cmap_range: (float, float) | np.ndarray, optional + The (min, max) of ``cmap_transform`` mapped onto the colormap, or ``[n_graphics, 2]`` for a range per + graphic. A windowed array ``cmap_transform`` defaults to its own (min, max) over the full ``p`` dim, + so the display window keeps its position within the colormap. A ``FeatureCallable`` transform + requires an explicit range, its full range is not knowable without evaluating it everywhere. + + thickness: float | Sequence[float], optional + Thickness of the lines, always static. A single value for every graphic, or ``[n_graphics]`` values + for a thickness per graphic. + + name: str, optional + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + graphic_kwargs: dict, optional + passed to the underlying ``LineCollection`` + + processor_kwargs: dict, optional + passed to the ``processor`` constructor. + + Returns + ------- + NDPositions + + Notes + ----- + Each of the other graphic features is either *windowed* or *static*, decided from the value itself: + + * **windowed**: a ``FeatureCallable``, or an array whose axis 1 spans the ``p`` dim. It is re-sliced with + the same display window slice as the data on every update, so the feature carries a value per + displayed datapoint. An array **must** span the **full** ``p`` dim of the data, i.e. + ``[n_graphics, p, ]``, since it is indexed with an index into the full ``p`` dim. A + ``FeatureCallable`` is passed the data slice and that display window slice, and returns the feature + values for the displayed datapoints. + + * **static**: anything else. It is set once on the collection, ex: a single value for every graphic, + ``[n_graphics]`` values for one per graphic, or an iterator of per-graphic values such as + ``itertools.cycle(["jet", "viridis"])``. + + """ self._check_slider_dims(dims, spatial_dims, data, positions=True) nd = NDPositions( @@ -245,7 +1067,22 @@ def add_nd_lines(self, data, dims, spatial_dims, *args, **kwargs): spatial_dims, *args, graphic_type=LineCollection, - **kwargs, + processor=processor, + display_window=display_window, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + slider_dim_transforms=slider_dim_transforms, + max_display_datapoints=max_display_datapoints, + datapoints_window_func=datapoints_window_func, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + cmap_range=cmap_range, + thickness=thickness, + name=name, + graphic_kwargs=graphic_kwargs, + processor_kwargs=processor_kwargs, ) self._nd_graphics.append(nd) diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py index c5b6f58c0..298011620 100644 --- a/fastplotlib/widgets/nd_widget/_ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -10,6 +10,69 @@ class NDWidget: def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[ReferenceIndex] = None, **kwargs): + """ + Explore n-dimensional multi-modal datasets through synchronized graphical representations. + + An ``NDWidget`` manages ``NDGraphic`` objects distributed across the subplots of an ``ImguiFigure``. Each + ``NDGraphic`` wraps one array-like object, names every dimension of that array, and declares which of those + dims are *spatial*, i.e. rendered. All remaining dims are *slider dims*. Every slider dim gets a slider, and + moving it re-slices every ``NDGraphic`` that has that dim and updates its ``Graphic``. Arrays of different + shapes, dim orders and sampling rates therefore stay synchronized as long as they name their shared dims + identically. + + Slider positions are stored in reference-space units (ex: seconds, µm, Hz) by a :class:`ReferenceIndex` + which is shared by every ``NDGraphic`` in the widget. Each ``NDGraphic`` maps these values onto indices of + its own array using its ``slider_dim_transforms``. + + Use ``ndw[row, col]`` or ``ndw["subplot_name"]`` to get the :class:`NDWSubplot` for a subplot, it provides + the ``add_nd_<...>`` methods. + + Parameters + ---------- + ref_ranges: dict[str, tuple[float, float, float] | RangeContinuous], optional + Reference range for each slider dim, ``{dim_name: (start, stop, step)}`` or a :class:`RangeContinuous` + instance. These are in reference-space units, ``start`` and ``stop`` bound the slider and ``step`` is + the increment used by the step and play buttons. + + A slider dim with no entry here gets an ``AutoRangeContinuous`` of ``(0, , 1)`` when + the graphic is added, along with a warning. With the default identity ``slider_dim_transform`` this is + a one-to-one mapping from reference-space units to array indices, i.e. the reference value *is* the + array index. Ex: a dim of size 1000 gets the range ``(0, 1000, 1)``, the slider spans ``[0, 999]``, and + reference value ``437`` indexes element ``437``. + + Specify a range when the reference-space units are not array indices, ex: + ``{"time": (0.0, 10.0, 0.001)}`` for 10 seconds at 1 ms resolution, together with a + ``slider_dim_transform`` that maps seconds onto the indices of that array. The size is unknown for a + graphic added with ``data=None``, so its slider dims must be given a range here. + + ref_index: ReferenceIndex, optional + Use an existing ``ReferenceIndex`` instead of creating one from ``ref_ranges``, which is then ignored. + Multiple ``NDWidget`` instances that share a ``ReferenceIndex`` are synchronized, so one set of sliders + can drive data displayed across several windows. + + kwargs + passed to :class:`.ImguiFigure` + + Examples + -------- + + A video and a set of traces that share a "time" dim, driven by one slider:: + + import numpy as np + import fastplotlib as fpl + + video = np.random.rand(1000, 512, 512) # [time, row, col] + traces = np.random.rand(50, 1000, 2) # [neuron, time, xy] + + ndw = fpl.NDWidget(ref_ranges={"time": (0, 1000, 1)}, shape=(1, 2)) + + # all dim names, then the spatial dims in display order + ndw[0, 0].add_nd_image(video, ("time", "row", "col"), ("row", "col")) + ndw[0, 1].add_nd_timeseries(traces, ("neuron", "time", "xy"), ("neuron", "time", "xy")) + + ndw.show() + + """ if ref_index is None: if ref_ranges is None: ref_ranges = dict() @@ -36,10 +99,18 @@ def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[Refe @property def figure(self) -> ImguiFigure: + """The ``ImguiFigure`` that contains the subplots of this widget""" return self._figure @property def indices(self) -> ReferenceIndex: + """ + Get or set the current index of each slider dim. + + Returns the ``ReferenceIndex`` that is shared by every ``NDGraphic`` in this widget. Set using a + ``{dim_name: index}`` mapping in reference-space units, values are clamped to the reference range of + that dim and any dim that is not given keeps its current index. + """ return self._indices @indices.setter @@ -48,10 +119,12 @@ def indices(self, new_indices: dict[str, int | float | Any]): @property def ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: + """the reference range of each slider dim, ``{dim_name: range}``""" return self._indices.ref_ranges @property def ndgraphics(self): + """all the ``NDGraphic`` instances in every subplot of this widget""" gs = list() for subplot in self._subplots_nd.values(): gs.extend(subplot.nd_graphics) @@ -64,7 +137,25 @@ def __getitem__(self, key: str | tuple[int, int] | Subplot): return self._subplots_nd[key] def show(self, **kwargs): + """ + Show the widget. + + Parameters + ---------- + + kwargs: Any + passed to ``Figure.show()`` + + Returns + ------- + BaseRenderCanvas + In Qt or GLFW, the canvas window containing the Figure will be shown. + In a notebook, it will display the plot in the output cell or sidecar. + + """ + return self.figure.show(**kwargs) def close(self): + """Close the widget""" self.figure.close() diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 3a54d327a..832b0233c 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -24,6 +24,8 @@ class NDWidgetUI(ImguiWindow): + """Playback controls and a slider for each slider dim, shown at the bottom of an ``NDWidget``""" + def __init__(self, ndwidget): super().__init__() self._ndwidget = ndwidget @@ -195,6 +197,11 @@ def update(self): class RightClickMenu(StandardRightClickMenu): + """ + Right click menu of an ``NDWidget``, adds an "ND Graphics" submenu to the standard menu. Selecting an + ``NDGraphic`` opens a window to change its settings, which stays open after the menu closes. + """ + def __init__(self, ndwidget): super().__init__() diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py index 8d150153c..6900b940b 100644 --- a/fastplotlib/widgets/nd_widget/_video.py +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -8,16 +8,28 @@ class VideoProcessor(NDImageProcessor): + """ + ``NDImageProcessor`` subclass for video data, used by ``NDWSubplot.add_video()``. + + Reads the frame at the current index directly. Window functions are not currently implemented for video. + + A YUV frame is a tuple of (Y, U, V) planes rather than a single array, so it is passed through as a tuple for + an ``ImageYUVGraphic``. + """ async def get_window_output(self, indices: dict[str, Any]) -> TupleYUV | np.ndarray: """ - Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims + Get the frame at the given indices, squeezing out the slider dims. Parameters ---------- - indices + indices: dict[str, Any] + Reference-space value for each slider dim, ex: ``{"time": 46.397}``. Must provide a value for every + slider dim. Returns ------- + np.ndarray | tuple[np.ndarray, ...] + The frame, or a tuple of the (Y, U, V) planes if the underlying data returns YUV planes. """ # windowed slice if user set any window funcs diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index aba780ac8..0d692a312 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -1,6 +1,8 @@ +import ast import inspect import pathlib import re +import textwrap import black @@ -31,15 +33,12 @@ def generate_add_graphics_methods(): f.write("# This is an auto-generated file and should not be modified directly\n\n") - f.write("from typing import *\n\n") - f.write("import numpy\n") - f.write("from numpy.typing import NDArray\n\n") - f.write("import pygfx\n\n") - f.write("from ..graphics import *\n") - f.write("from ..graphics._base import Graphic\n") - f.write("from ..utils import enums\n") - f.write("import typing\n") - f.write("import fastplotlib\n\n") + # star-import each module that defines a graphic, so every reference used in the + # graphics' __init__ annotations (aliases, np, pygfx, typing, enums) is in scope + for module in sorted({cls.__module__ for cls in modules}): + f.write(f"from {module} import *\n") + + f.write("from fastplotlib.graphics import Graphic\n\n") f.write("\nclass GraphicMethodsMixin:\n") @@ -50,6 +49,8 @@ def generate_add_graphics_methods(): f.write(" center = kwargs.pop('center')\n") f.write(" else:\n") f.write(" center = False\n\n") + f.write(" # ignore arguments left at their default of None, i.e. not passed by the caller\n") + f.write(" kwargs = {k: v for k, v in kwargs.items() if v is not None}\n\n") f.write(" if 'name' in kwargs.keys():\n") f.write(" self._check_graphic_name_exists(kwargs['name'])\n\n") f.write(" graphic = graphic_class(*args, **kwargs)\n") @@ -65,21 +66,61 @@ def generate_add_graphics_methods(): method_name = camel_to_snake.sub("_", cls_name).lower() - class_args = inspect.getfullargspec(cls)[0][1:] - class_args = [arg + ", " for arg in class_args] - s = "" - for a in class_args: - s += a - - f.write( - f" def add_{method_name}{inspect.signature(cls.__init__)} -> {cls.__name__}:\n" - ) + child = getattr(cls, "_child_type", None) + if child is not None: + # a graphic collection: take the arguments and docstring from the child graphic's + # __init__ (via ast, so the type aliases stay intact), then add the collection's own + # arguments (e.g. a stack's `separation`) and the plural per-graphic arguments + init = ast.parse(textwrap.dedent(inspect.getsource(child.__init__))).body[0] + args = init.args + child_args = {a.arg for a in args.args} | {a.arg for a in args.kwonlyargs} + + own = ast.parse(textwrap.dedent(inspect.getsource(cls.__init__))).body[0].args + # the collection's own arguments after `data`, e.g. `name`/`metadata` or `separation`; + # skip any the child already takes, e.g. PositionsCollection re-declares `cmap` + own_extra = own.args[2:] + for a, default in zip(own_extra, own.defaults[len(own.defaults) - len(own_extra):]): + if a.arg in child_args: + continue + args.kwonlyargs.append(a) + args.kw_defaults.append(default) + for a, default in zip(own.kwonlyargs, own.kw_defaults): + if a.arg in child_args: + continue + args.kwonlyargs.append(a) + args.kw_defaults.append(default) + + # the per-graphic (plural) features, e.g. `names`, `offsets`, `metadatas`; skip any the + # child or the collection already takes (e.g. an ``ImageGrid`` takes ``offsets``) + present = child_args | {a.arg for a in args.kwonlyargs} + for feature_name in cls._accessor_specs: + if feature_name not in present: + args.kwonlyargs.append(ast.arg(arg=feature_name)) + args.kw_defaults.append(ast.Constant(value=None)) + + signature = ast.unparse(args) + docstring = child.__init__.__doc__ + + # pass `data` positionally and everything else by keyword, since the collection takes + # its features as **kwargs + passed = ["data"] + passed += [f"{a.arg}={a.arg}" for a in args.args if a.arg not in ("self", "data")] + passed += [f"{a.arg}={a.arg}" for a in args.kwonlyargs] + if args.kwarg is not None: + passed.append(f"**{args.kwarg.arg}") + call = ", ".join(passed) + else: + init = ast.parse(textwrap.dedent(inspect.getsource(cls.__init__))).body[0] + signature = ast.unparse(init.args) + docstring = cls.__init__.__doc__ + class_args = inspect.getfullargspec(cls)[0][1:] + call = "".join(a + ", " for a in class_args) + "**kwargs" + + f.write(f" def add_{method_name}({signature}) -> {cls.__name__}:\n") f.write(' """\n') - f.write(f" {cls.__init__.__doc__}\n") + f.write(f" {docstring}\n") f.write(' """\n') - f.write( - f" return self._create_graphic({cls.__name__}, {s} **kwargs)\n\n" - ) + f.write(f" return self._create_graphic({cls.__name__}, {call})\n\n") f.close() diff --git a/tests/test_collections.py b/tests/test_collections.py new file mode 100644 index 000000000..3c2989f7a --- /dev/null +++ b/tests/test_collections.py @@ -0,0 +1,884 @@ +""" +Backend (non-screenshot) tests for graphic collections. + +Covers ``LineCollection``, ``ScatterCollection``, ``ImageCollection`` and the +``LineStack`` / ``ScatterStack`` / ``ImageGrid`` layout subclasses: + +* construction with every valid form of each feature, and the valid combinations +* that invalid forms and combinations raise +* the analogous setters +* get/set slicing across the ``[n_graphics, n_datapoints, xyz/RGBA]`` axes, in all valid combinations +* the numpy-like operators on accessors + +Every value is verified on the individual child graphic's underlying ``GraphicFeature`` (the +per-vertex buffer or the uniform value), at three points: after construction, through the +collection getter, and through the getter again after a setter. +""" + +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx +import cmap as cmap_lib + +import fastplotlib as fpl +from fastplotlib.graphics import ( + LineCollection, + ScatterCollection, + ImageCollection, + LineStack, + ScatterStack, + ImageGrid, + LineGraphic, + ScatterGraphic, +) +from fastplotlib.graphics.features import ( + VertexPositions, + VertexColors, + UniformColor, + VertexCmap, + Thickness, + VertexPointSizes, + UniformSize, + VertexMarkers, + UniformMarker, + UniformEdgeColor, + EdgeWidth, + VertexRotations, + UniformRotations, + TextureArray, +) + +from .utils import generate_color_inputs, MULTI_COLORS_TRUTH + + +N_GRAPHICS = 5 +N_DATAPOINTS = 10 + +# five distinct single colors, one per graphic, and their RGBA truth +PER_GRAPHIC_COLORS = ["r", "g", "b", "cyan", "magenta"] +PER_GRAPHIC_COLORS_TRUTH = np.vstack([pygfx.Color(c) for c in PER_GRAPHIC_COLORS]) + + +# --------------------------------------------------------------------------- +# data helpers +# --------------------------------------------------------------------------- +def lines_data(n_graphics=N_GRAPHICS, n_points=N_DATAPOINTS) -> list[np.ndarray]: + """deterministic list of ``[n_points, 3]`` arrays, one per graphic""" + return [ + np.column_stack( + [ + np.arange(n_points), + np.sin(np.arange(n_points) + i), + np.cos(np.arange(n_points) + i), + ] + ).astype(np.float32) + for i in range(n_graphics) + ] + + +def jagged_lines_data(lengths=(6, 9, 7, 12, 8)) -> list[np.ndarray]: + """per-graphic data with a different number of datapoints each (jagged)""" + return [ + np.column_stack([np.arange(n), np.sin(np.arange(n)), np.cos(np.arange(n))]).astype( + np.float32 + ) + for n in lengths + ] + + +def data_mirror() -> np.ndarray: + """a plain ``[n_graphics, n_datapoints, 3]`` array mirroring a rectangular collection""" + return ( + np.arange(N_GRAPHICS * N_DATAPOINTS * 3, dtype=np.float32) + .reshape(N_GRAPHICS, N_DATAPOINTS, 3) + ) + + +def colors_mirror() -> np.ndarray: + """a plain ``[n_graphics, n_datapoints, 4]`` array of valid RGBA values in [0, 1]""" + n = N_GRAPHICS * N_DATAPOINTS * 4 + return np.linspace(0, 1, n, dtype=np.float32).reshape(N_GRAPHICS, N_DATAPOINTS, 4) + + +def images_data(n=4, shape=(8, 8)) -> list[np.ndarray]: + return [(np.arange(np.prod(shape)).reshape(shape) + i).astype(np.float32) for i in range(n)] + + +# --------------------------------------------------------------------------- +# slicing keys along each axis +# --------------------------------------------------------------------------- +GRAPHIC_AXIS_KEYS = { + "int": 2, + "all": slice(None), + "range": slice(1, 4), + "step": slice(None, None, 2), + "neg": slice(-3, None), + "fancy": [0, 2, 4], + "bool": np.array([True, False, True, False, True]), +} + +DATAPOINT_AXIS_KEYS = { + "int": 3, + "all": slice(None), + "range": slice(2, 7), + "step": slice(None, None, 3), + "fancy": [1, 4, 8], + "bool": np.arange(N_DATAPOINTS) > 5, +} + +XYZ_KEYS = {"none": None, "int": 1, "slice": slice(0, 2)} +RGBA_KEYS = {"none": None, "int": 3, "slice": slice(0, 3)} + + +def as_per_graphic_list(result) -> list: + """normalize an accessor get result to a list of per-graphic arrays""" + if isinstance(result, np.ndarray) and result.dtype == object: + return list(result) + # single graphic (int graphic-key) returns one view directly + return [result] + + +def selected_graphic_indices(graphic_key) -> np.ndarray: + return np.atleast_1d(np.arange(N_GRAPHICS)[graphic_key]) + + +def expected_views(mirror: np.ndarray, graphic_key, buffer_key: tuple) -> list: + """the per-graphic views the accessor should return for ``[graphic_key, *buffer_key]``""" + return [ + mirror[i][buffer_key] if buffer_key else mirror[i] + for i in selected_graphic_indices(graphic_key) + ] + + +# =========================================================================== +# construction: data +# =========================================================================== +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +@pytest.mark.parametrize("as_array", [False, True]) +def test_construct_data_rectangular(collection_type, as_array): + data = lines_data() + collection = collection_type(np.asarray(data) if as_array else data) + + assert len(collection) == N_GRAPHICS + for i, graphic in enumerate(collection.graphics): + assert isinstance(graphic._data, VertexPositions) + # after construction + npt.assert_array_equal(graphic._data.value, data[i]) + # through the getter + npt.assert_array_equal(collection.data[i], data[i]) + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +@pytest.mark.parametrize("dims", ["y", "xy", "xyz"]) +def test_construct_data_dimensionality(collection_type, dims): + # 1D (y only), 2D (xy) and 3D (xyz) per-graphic data; the child pads to [n, 3] + base = np.column_stack( + [np.arange(N_DATAPOINTS), np.sin(np.arange(N_DATAPOINTS)), np.cos(np.arange(N_DATAPOINTS))] + ).astype(np.float32) + slices = {"y": base[:, 1], "xy": base[:, :2], "xyz": base} + per_graphic = slices[dims] + collection = collection_type([per_graphic.copy() for _ in range(N_GRAPHICS)]) + + for graphic in collection.graphics: + value = graphic._data.value + assert value.shape == (N_DATAPOINTS, 3) + if dims == "y": + npt.assert_array_equal(value[:, 1], per_graphic) + npt.assert_array_equal(value[:, 0], np.arange(N_DATAPOINTS)) # generated x + npt.assert_array_equal(value[:, 2], 0) # padded z + elif dims == "xy": + npt.assert_array_equal(value[:, :2], per_graphic) + npt.assert_array_equal(value[:, 2], 0) # padded z + else: + npt.assert_array_equal(value, per_graphic) + + +def test_construct_data_jagged(): + data = jagged_lines_data() + collection = LineCollection(data) + assert len(collection) == len(data) + for i, graphic in enumerate(collection.graphics): + assert isinstance(graphic._data, VertexPositions) + npt.assert_array_equal(graphic._data.value, data[i]) + npt.assert_array_equal(collection.data[i], data[i]) + + +# =========================================================================== +# construction: colors (mode is inferred from the value, there is no color_mode kwarg) +# =========================================================================== +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +@pytest.mark.parametrize("colors", generate_color_inputs("b")) +def test_construct_colors_uniform(collection_type, colors): + # a single color (str, RGBA array, list, or tuple) -> every graphic is uniform blue + collection = collection_type(lines_data(), colors=colors) + for i, graphic in enumerate(collection.graphics): + assert isinstance(graphic._colors, UniformColor) + npt.assert_almost_equal(np.asarray(graphic._colors.value), [0, 0, 1, 1]) + npt.assert_almost_equal(collection.colors[i], [0, 0, 1, 1]) + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +@pytest.mark.parametrize("as_array", [False, True]) +def test_construct_colors_per_graphic_uniform(collection_type, as_array): + # one single color per graphic (list of strings, or an [n_graphics, 4] array) + colors = PER_GRAPHIC_COLORS_TRUTH if as_array else PER_GRAPHIC_COLORS + collection = collection_type(lines_data(), colors=colors) + for i, graphic in enumerate(collection.graphics): + assert isinstance(graphic._colors, UniformColor) + npt.assert_almost_equal(np.asarray(graphic._colors.value), PER_GRAPHIC_COLORS_TRUTH[i]) + npt.assert_almost_equal(collection.colors[i], PER_GRAPHIC_COLORS_TRUTH[i]) + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +@pytest.mark.parametrize("as_array", [False, True]) +def test_construct_colors_vertex(collection_type, as_array): + # a sequence of per-datapoint colors per graphic -> per-vertex colors + per_graphic = [MULTI_COLORS_TRUTH.astype(np.float32).copy() for _ in range(N_GRAPHICS)] + colors = np.asarray(per_graphic) if as_array else per_graphic + collection = collection_type(lines_data(), colors=colors) + for graphic in collection.graphics: + assert isinstance(graphic._colors, VertexColors) + npt.assert_almost_equal(graphic._colors.value, MULTI_COLORS_TRUTH) + + +# =========================================================================== +# construction: cmap + cmap_transform +# =========================================================================== +def cmap_across_truth(name, n_graphics, transform=None): + if transform is None: + values = np.linspace(0, 1, n_graphics) + else: + transform = np.asarray(transform, dtype=float) + transform = np.interp( + np.linspace(0, 1, n_graphics), np.linspace(0, 1, len(transform)), transform + ) + spread = np.ptp(transform) + values = (transform - transform.min()) / spread if spread else np.zeros(n_graphics) + return np.asarray(cmap_lib.Colormap(name)(values)) + + +# cmaps to exercise: a name, a named Colormap, and a custom Colormap +CMAPS = ["jet", cmap_lib.Colormap("jet"), cmap_lib.Colormap(["r", "purple", "orange", "green"])] + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +@pytest.mark.parametrize("cmap", CMAPS) +@pytest.mark.parametrize("transform", [None, [3, 5, 2, 1, 0]]) +def test_construct_cmap_across_graphics(collection_type, cmap, transform): + # a colormap with no transform, or a 1D transform, colors each graphic one color across the map + collection = collection_type(lines_data(), cmap=cmap, cmap_transform=transform) + truth = cmap_across_truth(cmap, N_GRAPHICS, transform) + for i, graphic in enumerate(collection.graphics): + assert isinstance(graphic._colors, UniformColor) + assert graphic._cmap is None + npt.assert_almost_equal(np.asarray(graphic._colors.value), truth[i], decimal=5) + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +@pytest.mark.parametrize("cmaps", [[c] * N_GRAPHICS for c in CMAPS] + [(CMAPS * N_GRAPHICS)[:N_GRAPHICS]]) +def test_construct_cmap_per_graphic(collection_type, cmaps): + # an iterable of cmaps with a 2D [n_graphics, n_datapoints] transform colors each graphic's datapoints + transform = np.random.rand(N_GRAPHICS, N_DATAPOINTS) + collection = collection_type(lines_data(), cmap=cmaps, cmap_transform=transform) + lut = np.linspace(0, 1, 8) + for graphic, expected in zip(collection.graphics, cmaps): + assert isinstance(graphic._cmap, VertexCmap) + assert graphic._colors is None + npt.assert_almost_equal(graphic._cmap.value(lut), cmap_lib.Colormap(expected)(lut)) + + +# =========================================================================== +# construction: per-graphic scalar / vector features (single value vs one per graphic) +# =========================================================================== +def test_construct_thickness(): + single = LineCollection(lines_data(), thickness=4.0) + assert all(g._thickness.value == 4.0 for g in single.graphics) + npt.assert_array_equal(single.thickness[:], [4.0] * N_GRAPHICS) + + per_graphic = LineCollection(lines_data(), thickness=[1, 2, 3, 4, 5]) + assert [g._thickness.value for g in per_graphic.graphics] == [1, 2, 3, 4, 5] + + +def test_construct_offsets_rotations_scales(): + offsets = np.arange(N_GRAPHICS * 3).reshape(N_GRAPHICS, 3).astype(float) + collection = LineCollection(lines_data(), offsets=offsets) + for i, graphic in enumerate(collection.graphics): + npt.assert_array_equal(graphic._offset.value, offsets[i]) + npt.assert_array_equal(graphic.world_object.world.position, offsets[i]) + + # a single offset goes to every graphic + collection = LineCollection(lines_data(), offsets=(1, 2, 3)) + for graphic in collection.graphics: + npt.assert_array_equal(graphic._offset.value, [1, 2, 3]) + + +def test_construct_names_visibles(): + names = [f"line-{i}" for i in range(N_GRAPHICS)] + collection = LineCollection(lines_data(), names=names, visibles=[True, False, True, False, True]) + assert [g.name for g in collection.graphics] == names + assert [g._visible.value for g in collection.graphics] == [True, False, True, False, True] + + +@pytest.mark.parametrize("pattern", ["--", ":", (2, 3)]) +def test_construct_dash_pattern(pattern): + collection = LineCollection(lines_data(), dash_pattern=pattern) + for graphic in collection.graphics: + assert graphic._dash_pattern.value == pattern + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +def test_construct_size_space(collection_type): + collection = collection_type(lines_data(), size_space="world") + assert all(g.size_space == "world" for g in collection.graphics) + + +# =========================================================================== +# construction: invalid forms / combinations raise +# =========================================================================== +def test_construct_wrong_per_graphic_length_raises(): + # one value per graphic, but the wrong number of them + with pytest.raises(IndexError): + LineCollection(lines_data(), thickness=[1, 2, 3]) + with pytest.raises(IndexError): + LineCollection(lines_data(), colors=["r", "g", "b"]) + + +def test_construct_cmap_transform_wrong_n_graphics_raises(): + # a per-graphic (2D) transform must have one row per graphic + with pytest.raises(ValueError): + LineCollection(lines_data(), cmap="jet", cmap_transform=np.random.rand(3, N_DATAPOINTS)) + + +def test_construct_cmap_transform_without_cmap_raises(): + with pytest.raises(ValueError): + LineCollection(lines_data(), cmap_transform=[0, 1, 2, 3, 4]) + with pytest.raises(ValueError): + ScatterCollection(lines_data(), cmap_transform=[0, 1, 2, 3, 4]) + + +def test_construct_cmap_overrides_colors(): + # cmap and colors together is not an error; cmap wins, matching a single graphic + collection = LineCollection(lines_data(), cmap="jet", colors="r") + truth = cmap_across_truth("jet", N_GRAPHICS) + for i, graphic in enumerate(collection.graphics): + npt.assert_almost_equal(np.asarray(graphic._colors.value), truth[i], decimal=5) + + +# =========================================================================== +# setting: cmap / cmap_transform / cmap_range (symmetric with the constructor) +# =========================================================================== +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +@pytest.mark.parametrize("cmap", CMAPS) +@pytest.mark.parametrize("transform", [None, [3, 5, 2, 1, 0]]) +def test_set_cmap_across_graphics(collection_type, cmap, transform): + # setting a single cmap, and a 1D transform, colors each graphic one color, matching construction + collection = collection_type(lines_data()) + collection.cmap = cmap + if transform is not None: + collection.cmap_transform = transform + truth = cmap_across_truth(cmap, N_GRAPHICS, transform) + for i, graphic in enumerate(collection.graphics): + assert isinstance(graphic._colors, UniformColor) + assert graphic._cmap is None + npt.assert_almost_equal(np.asarray(graphic._colors.value), truth[i], decimal=5) + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +@pytest.mark.parametrize("cmaps", [[c] * N_GRAPHICS for c in CMAPS] + [(CMAPS * N_GRAPHICS)[:N_GRAPHICS]]) +def test_set_cmap_per_graphic(collection_type, cmaps): + # setting an iterable of cmaps with a 2D transform colors each graphic's datapoints + transform = np.random.rand(N_GRAPHICS, N_DATAPOINTS) + collection = collection_type(lines_data()) + collection.cmap = cmaps + collection.cmap_transform = transform + lut = np.linspace(0, 1, 8) + for graphic, expected in zip(collection.graphics, cmaps): + assert isinstance(graphic._cmap, VertexCmap) + assert graphic._colors is None + npt.assert_almost_equal(graphic._cmap.value(lut), cmap_lib.Colormap(expected)(lut)) + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +def test_set_cmap_range(collection_type): + # setting cmap_range on a per-graphic collection updates each graphic's range + transform = np.random.rand(N_GRAPHICS, N_DATAPOINTS) + collection = collection_type(lines_data(), cmap=["jet"] * N_GRAPHICS, cmap_transform=transform) + collection.cmap_range = (0.0, 5.0) + for graphic in collection.graphics: + assert graphic.cmap_range == (0.0, 5.0) + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +def test_set_cmap_mismatch_raises(collection_type): + # a single cmap needs a 1D transform; an iterable of cmaps needs a 2D transform + collection = collection_type(lines_data()) + collection.cmap = "jet" + with pytest.raises(ValueError): + collection.cmap_transform = np.random.rand(N_GRAPHICS, N_DATAPOINTS) + + collection = collection_type(lines_data()) + collection.cmap = ["jet"] * N_GRAPHICS + with pytest.raises(ValueError): + collection.cmap_transform = [0, 1, 2, 3, 4] + + +@pytest.mark.parametrize("collection_type", [LineCollection, ScatterCollection]) +def test_set_cmap_transform_without_cmap_raises(collection_type): + collection = collection_type(lines_data()) + with pytest.raises(ValueError): + collection.cmap_transform = [0, 1, 2, 3, 4] + + +# =========================================================================== +# get slicing matrix: data [n_graphics, n_datapoints, xyz] +# =========================================================================== +@pytest.mark.parametrize("gname", GRAPHIC_AXIS_KEYS) +@pytest.mark.parametrize("dname", DATAPOINT_AXIS_KEYS) +@pytest.mark.parametrize("cname", XYZ_KEYS) +def test_data_get_slicing(gname, dname, cname): + gkey, dkey, ckey = GRAPHIC_AXIS_KEYS[gname], DATAPOINT_AXIS_KEYS[dname], XYZ_KEYS[cname] + mirror = data_mirror() + collection = LineCollection([mirror[i].copy() for i in range(N_GRAPHICS)]) + + buffer_key = (dkey,) if ckey is None else (dkey, ckey) + key = (gkey, *buffer_key) + + got = as_per_graphic_list(collection.data[key]) + expected = expected_views(mirror, gkey, buffer_key) + assert len(got) == len(expected) + for g, e in zip(got, expected): + npt.assert_array_equal(g, e) + + +# =========================================================================== +# set slicing matrix: data [n_graphics, n_datapoints, xyz] +# =========================================================================== +@pytest.mark.parametrize("gname", GRAPHIC_AXIS_KEYS) +@pytest.mark.parametrize("dname", DATAPOINT_AXIS_KEYS) +@pytest.mark.parametrize("cname", XYZ_KEYS) +def test_data_set_slicing(gname, dname, cname): + gkey, dkey, ckey = GRAPHIC_AXIS_KEYS[gname], DATAPOINT_AXIS_KEYS[dname], XYZ_KEYS[cname] + mirror = data_mirror() + collection = LineCollection([mirror[i].copy() for i in range(N_GRAPHICS)]) + + buffer_key = (dkey,) if ckey is None else (dkey, ckey) + key = (gkey, *buffer_key) + + value = -7.0 + collection.data[key] = value + for i in selected_graphic_indices(gkey): + mirror[i][buffer_key] = value + + # every child buffer matches the mirror + for i in range(N_GRAPHICS): + npt.assert_array_equal(collection.graphics[i]._data.value, mirror[i]) + # and the getter reflects the new values + got = as_per_graphic_list(collection.data[key]) + for g, e in zip(got, expected_views(mirror, gkey, buffer_key)): + npt.assert_array_equal(g, e) + + +# =========================================================================== +# whole-graphic data set (buffer_key is empty): replaces the buffer, may resize +# =========================================================================== +@pytest.mark.parametrize("gname", ["all", "int", "range", "fancy", "bool"]) +def test_data_whole_graphic_get(gname): + gkey = GRAPHIC_AXIS_KEYS[gname] + mirror = data_mirror() + collection = LineCollection([mirror[i].copy() for i in range(N_GRAPHICS)]) + got = as_per_graphic_list(collection.data[gkey]) + for g, e in zip(got, expected_views(mirror, gkey, ())): + npt.assert_array_equal(g, e) + + +def test_data_whole_graphic_set_same_shape(): + collection = LineCollection(lines_data()) + new = np.zeros((N_DATAPOINTS, 3), dtype=np.float32) + collection.data[:] = new + for graphic in collection.graphics: + npt.assert_array_equal(graphic._data.value, new) + + +def test_data_whole_graphic_set_resizes_buffer(): + collection = LineCollection(lines_data()) # N_DATAPOINTS per graphic + smaller = np.column_stack([np.arange(4), np.arange(4), np.arange(4)]).astype(np.float32) + collection.data[:] = smaller + for graphic in collection.graphics: + assert graphic._data.value.shape == (4, 3) + npt.assert_array_equal(graphic._data.value, smaller) + + +def test_data_property_setter_resizes(): + # the `collection.data = ...` property mirrors `collection.data[:] = ...` + collection = LineCollection(lines_data()) + new = np.stack([np.column_stack([np.arange(3)] * 3)] * N_GRAPHICS).astype(np.float32) + collection.data = new + for i, graphic in enumerate(collection.graphics): + assert graphic._data.value.shape == (3, 3) + npt.assert_array_equal(graphic._data.value, new[i]) + + +# =========================================================================== +# get/set slicing matrix: colors [n_graphics, n_datapoints, RGBA] +# =========================================================================== +@pytest.mark.parametrize("gname", GRAPHIC_AXIS_KEYS) +@pytest.mark.parametrize("dname", DATAPOINT_AXIS_KEYS) +@pytest.mark.parametrize("cname", RGBA_KEYS) +def test_colors_get_slicing(gname, dname, cname): + gkey, dkey, ckey = GRAPHIC_AXIS_KEYS[gname], DATAPOINT_AXIS_KEYS[dname], RGBA_KEYS[cname] + mirror = colors_mirror() + collection = LineCollection( + lines_data(), colors=[mirror[i].copy() for i in range(N_GRAPHICS)] + ) + buffer_key = (dkey,) if ckey is None else (dkey, ckey) + key = (gkey, *buffer_key) + + got = as_per_graphic_list(collection.colors[key]) + for g, e in zip(got, expected_views(mirror, gkey, buffer_key)): + npt.assert_array_equal(g, e) + + +@pytest.mark.parametrize("gname", GRAPHIC_AXIS_KEYS) +@pytest.mark.parametrize("dname", DATAPOINT_AXIS_KEYS) +@pytest.mark.parametrize("cname", RGBA_KEYS) +def test_colors_set_slicing(gname, dname, cname): + # once a datapoint/channel key is present, colors are set with raw numeric values + gkey, dkey, ckey = GRAPHIC_AXIS_KEYS[gname], DATAPOINT_AXIS_KEYS[dname], RGBA_KEYS[cname] + mirror = colors_mirror() + collection = LineCollection( + lines_data(), colors=[mirror[i].copy() for i in range(N_GRAPHICS)] + ) + buffer_key = (dkey,) if ckey is None else (dkey, ckey) + key = (gkey, *buffer_key) + + value = 0.25 + collection.colors[key] = value + for i in selected_graphic_indices(gkey): + mirror[i][buffer_key] = value + + for i in range(N_GRAPHICS): + npt.assert_array_equal(collection.graphics[i]._colors.value, mirror[i]) + got = as_per_graphic_list(collection.colors[key]) + for g, e in zip(got, expected_views(mirror, gkey, buffer_key)): + npt.assert_array_equal(g, e) + + +@pytest.mark.parametrize("colors", generate_color_inputs("r")) +def test_colors_whole_graphic_set_spec(colors): + # a whole-graphic set (no datapoint/channel key) accepts a color spec, parsed to RGBA + collection = LineCollection( + lines_data(), colors=[np.ones((N_DATAPOINTS, 4), np.float32) for _ in range(N_GRAPHICS)] + ) + collection.colors[:] = colors + for graphic in collection.graphics: + npt.assert_almost_equal( + graphic._colors.value, np.tile([1, 0, 0, 1], (N_DATAPOINTS, 1)) + ) + + +def test_colors_datapoint_slice_rejects_color_spec(): + # indexing into the datapoints means raw numbers; a color-name string is a type error + collection = LineCollection( + lines_data(), colors=[np.ones((N_DATAPOINTS, 4), np.float32) for _ in range(N_GRAPHICS)] + ) + with pytest.raises(TypeError): + collection.colors[:, 2:5] = "r" + + +# =========================================================================== +# setters on per-graphic scalar / vector features, and the property setters +# =========================================================================== +def test_set_thickness(): + collection = LineCollection(lines_data()) + collection.thickness[:] = 6.0 + assert all(g._thickness.value == 6.0 for g in collection.graphics) + + collection.thickness[:] = [1, 2, 3, 4, 5] + assert [g._thickness.value for g in collection.graphics] == [1, 2, 3, 4, 5] + + # property setter is equivalent to `[:] =` + collection.thickness = 9.0 + assert all(g._thickness.value == 9.0 for g in collection.graphics) + + # a single graphic + collection.thickness[2] = 3.0 + assert collection.graphics[2]._thickness.value == 3.0 + + +def test_set_offsets(): + collection = LineCollection(lines_data()) + collection.offsets[:] = (1, 2, 3) + for graphic in collection.graphics: + npt.assert_array_equal(graphic._offset.value, [1, 2, 3]) + + per_graphic = np.arange(N_GRAPHICS * 3).reshape(N_GRAPHICS, 3).astype(float) + collection.offsets[:] = per_graphic + for i, graphic in enumerate(collection.graphics): + npt.assert_array_equal(graphic._offset.value, per_graphic[i]) + npt.assert_array_equal(graphic.world_object.world.position, per_graphic[i]) + + +def test_set_visibles(): + collection = LineCollection(lines_data()) + collection.visibles[:] = False + assert all(g._visible.value is False for g in collection.graphics) + assert all(g.world_object.visible is False for g in collection.graphics) + + +def test_cmap_property_setter_colors_across(): + # assigning to `collection.cmap` colors each graphic one color across the map + collection = LineCollection(lines_data()) + collection.cmap = "viridis" + truth = cmap_across_truth("viridis", N_GRAPHICS) + for i, graphic in enumerate(collection.graphics): + npt.assert_almost_equal(np.asarray(graphic._colors.value), truth[i], decimal=5) + + +# =========================================================================== +# scatter-specific features +# =========================================================================== +def test_scatter_sizes_uniform(): + collection = ScatterCollection(lines_data(), sizes=5) + assert all(isinstance(g._sizes, UniformSize) for g in collection.graphics) + assert all(g._sizes.value == 5 for g in collection.graphics) + + collection.sizes[:] = 8 + assert all(g._sizes.value == 8 for g in collection.graphics) + collection.sizes[:] = [1, 2, 3, 4, 5] + assert [g._sizes.value for g in collection.graphics] == [1, 2, 3, 4, 5] + + +def test_scatter_sizes_vertex(): + per_graphic = [np.linspace(1, 5, N_DATAPOINTS).astype(np.float32) for _ in range(N_GRAPHICS)] + collection = ScatterCollection(lines_data(), sizes=per_graphic) + for i, graphic in enumerate(collection.graphics): + assert isinstance(graphic._sizes, VertexPointSizes) + npt.assert_almost_equal(graphic._sizes.value, per_graphic[i]) + + # within-graphic slicing + collection.sizes[:, 2:5] = 7.0 + for graphic in collection.graphics: + npt.assert_almost_equal(graphic._sizes.value[2:5], 7.0) + + +def test_scatter_markers_uniform_and_vertex(): + uniform = ScatterCollection(lines_data(), markers="s") + assert all(isinstance(g._markers, UniformMarker) for g in uniform.graphics) + assert all(g._markers.value == "square" for g in uniform.graphics) + uniform.markers[:] = "o" + assert all(g._markers.value == "circle" for g in uniform.graphics) + + vertex = ScatterCollection(lines_data(), markers=[["o"] * N_DATAPOINTS for _ in range(N_GRAPHICS)]) + assert all(isinstance(g._markers, VertexMarkers) for g in vertex.graphics) + + +def test_scatter_edge_colors_and_width(): + collection = ScatterCollection(lines_data(), edge_colors="red", edge_width=2.0) + assert all(isinstance(g._edge_colors, UniformEdgeColor) for g in collection.graphics) + assert all(isinstance(g._edge_width, EdgeWidth) for g in collection.graphics) + assert all(g._edge_width.value == 2.0 for g in collection.graphics) + + collection.edge_width[:] = 3.0 + assert all(g._edge_width.value == 3.0 for g in collection.graphics) + collection.edge_colors[:] = "blue" + for graphic in collection.graphics: + # UniformEdgeColor stores the raw user input, so normalize through pygfx.Color to verify + npt.assert_almost_equal(np.asarray(pygfx.Color(graphic._edge_colors.value)), [0, 0, 1, 1]) + + +@pytest.mark.parametrize( + "value,expected_type", + [ + (None, type(None)), + (0.5, UniformRotations), + ([np.linspace(0, 1, N_DATAPOINTS).astype(np.float32)] * N_GRAPHICS, VertexRotations), + ], +) +def test_scatter_point_rotations(value, expected_type): + kwargs = {} if value is None else {"point_rotations": value} + collection = ScatterCollection(lines_data(), **kwargs) + for graphic in collection.graphics: + assert isinstance(graphic._point_rotations, expected_type) + + +# =========================================================================== +# image collection +# =========================================================================== +def test_image_construct_and_scalar_features(): + images = images_data() + collection = ImageCollection(images, vmin=0, vmax=100, cmap="gray", gamma=1.5) + for i, graphic in enumerate(collection.graphics): + assert isinstance(graphic._data, TextureArray) + npt.assert_array_equal(graphic._data.value, images[i]) + assert graphic._vmin.value == 0 + assert graphic._vmax.value == 100 + assert graphic._cmap.value == "gray" + assert graphic._gamma.value == 1.5 + + +def test_image_scalar_feature_setters(): + collection = ImageCollection(images_data(), vmin=0, vmax=100) + collection.vmin[:] = 10 + assert all(g._vmin.value == 10 for g in collection.graphics) + # one value per image + collection.vmax[:] = [1, 2, 3, 4] + assert [g._vmax.value for g in collection.graphics] == [1, 2, 3, 4] + collection.cmap[:] = "viridis" + assert all(g._cmap.value == "viridis" for g in collection.graphics) + + +def test_image_data_get_set_slicing(): + images = images_data() + collection = ImageCollection(images, vmin=0, vmax=100) + # get + for i, graphic in enumerate(collection.graphics): + npt.assert_array_equal(collection.data[i], images[i]) + # set a within-image region + collection.data[:, 0:2, 0:2] = -5.0 + for graphic in collection.graphics: + npt.assert_array_equal(graphic._data.value[0:2, 0:2], -5.0) + + +# =========================================================================== +# operators +# =========================================================================== +def test_operators_scalar_feature(): + collection = LineCollection(lines_data()) + collection.thickness[:] = [1, 2, 3, 4, 5] + + mask = collection.thickness < 3 + assert isinstance(mask, np.ndarray) and mask.dtype == bool + npt.assert_array_equal(mask, [True, True, False, False, False]) + + npt.assert_array_equal(collection.thickness + 1, [2, 3, 4, 5, 6]) # arithmetic + npt.assert_array_equal(10 - collection.thickness, [9, 8, 7, 6, 5]) # reflected + npt.assert_array_equal(abs(-collection.thickness), [1, 2, 3, 4, 5]) # unary + + +def test_operators_multicomponent_feature(): + collection = LineCollection(lines_data(), colors=["r", "r", "g", "b", "r"]) + result = collection.colors == (1, 0, 0, 1) + # object array of per-graphic [4] bool arrays, no stacking + assert isinstance(result, np.ndarray) and result.dtype == object + red_mask = np.array([np.all(x) for x in result]) + npt.assert_array_equal(red_mask, [True, True, False, False, True]) + assert len(collection.data[red_mask]) == 3 + + +def test_operators_jagged(): + collection = LineCollection(jagged_lines_data(lengths=(5, 8, 6))) + result = collection.data < 0.5 + assert isinstance(result, np.ndarray) and result.dtype == object + assert [x.shape for x in result] == [(5, 3), (8, 3), (6, 3)] + + +# =========================================================================== +# container behavior +# =========================================================================== +def test_len_iter_getitem_contains(): + collection = LineCollection(lines_data()) + assert len(collection) == N_GRAPHICS + + assert collection.graphics[0] in collection + assert list(iter(collection))[0] is collection.graphics[0] + + +def test_add_remove_graphic(): + collection = LineCollection(lines_data(n_graphics=3)) + graphic = LineGraphic(lines_data(n_graphics=1)[0]) + collection.add_graphic(graphic) + assert len(collection) == 4 + assert collection.graphics[-1] is graphic + + collection.remove_graphic(graphic) + assert len(collection) == 3 + assert graphic not in collection + + +def test_add_graphic_wrong_type_raises(): + collection = LineCollection(lines_data()) + with pytest.raises(TypeError): + collection.add_graphic(ScatterGraphic(lines_data(n_graphics=1)[0])) + + +def test_add_graphic_wrong_mode_raises(): + # a vertex-colors collection cannot take a uniform-color graphic + collection = LineCollection( + lines_data(), colors=[np.ones((N_DATAPOINTS, 4), np.float32) for _ in range(N_GRAPHICS)] + ) + with pytest.raises(TypeError): + collection.add_graphic(LineGraphic(lines_data(n_graphics=1)[0], colors="r")) + + +# =========================================================================== +# layout: stacks and image grid +# =========================================================================== +@pytest.mark.parametrize("collection_type", [LineStack, ScatterStack]) +@pytest.mark.parametrize("separation_axis", ["x", "y", "xy"]) +def test_stack_offsets(collection_type, separation_axis): + data = lines_data() + separation = np.array([3.0, 5.0, 7.0]) + stack = collection_type(data, separation=tuple(separation), separation_axis=separation_axis) + + axes = [{"x": 0, "y": 1, "z": 2}[a] for a in separation_axis] + extents = np.concatenate([d[:, axes] for d in data]).max(axis=0) + expected = np.zeros((N_GRAPHICS, 3)) + expected[:, axes] = np.arange(N_GRAPHICS)[:, None] * (extents + separation[axes]) + + offsets = np.array([g.offset for g in stack.graphics]) + npt.assert_allclose(offsets, expected, atol=1e-5) + + +def test_stack_separation_setter_restacks(): + stack = LineStack(lines_data(), separation=(0, 1, 0), separation_axis="y") + before = np.array([g.offset[1] for g in stack.graphics]) + stack.separation = (0, 10, 0) + after = np.array([g.offset[1] for g in stack.graphics]) + assert not np.allclose(before, after) + + +def test_stack_invalid_separation_axis_raises(): + with pytest.raises(ValueError): + LineStack(lines_data(), separation_axis="w") + + +def test_image_grid_offsets(): + # non-square images so the row/column steps are distinguishable + images = [np.zeros((6, 10), dtype=np.float32) for _ in range(4)] + grid = ImageGrid(images, shape=(2, 2), separation=(1, 2)) # (row_sep, col_sep) + offsets = np.array([g.offset for g in grid.graphics]) + # cell = largest image (6 rows, 10 cols); x step = 10 + 2, y step = -(6 + 1) + expected = np.array( + [[0, 0, 0], [12, 0, 0], [0, -7, 0], [12, -7, 0]], dtype=float + ) + npt.assert_allclose(offsets, expected) + + +def test_image_grid_explicit_offsets(): + images = images_data(n=3) + offsets = np.array([[0, 0, 0], [5, 0, 0], [10, 0, 0]], dtype=float) + grid = ImageGrid(images, offsets=offsets) + npt.assert_allclose([g.offset for g in grid.graphics], offsets) + + +def test_image_grid_shape_too_small_raises(): + with pytest.raises(ValueError): + ImageGrid(images_data(n=4), shape=(1, 2)) + + +# =========================================================================== +# jagged collections end-to-end +# =========================================================================== +def test_jagged_get_set(): + data = jagged_lines_data() + collection = LineCollection(data) + + views = collection.data[:] + assert [v.shape for v in views] == [d.shape for d in data] + + # a within-graphic set valid for every graphic (all have >= 6 points) + collection.data[:, 0:5, 2] = 9.0 + for graphic in collection.graphics: + npt.assert_array_equal(graphic._data.value[0:5, 2], 9.0) diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py index f9d56189e..01d84f5e8 100644 --- a/tests/test_colors_buffer_manager.py +++ b/tests/test_colors_buffer_manager.py @@ -47,11 +47,12 @@ def test_int(test_graphic): fig = fpl.Figure() data = generate_positions_spiral_data("xyz") + colors = np.ones(shape=(len(data), 4), dtype=np.float32) if test_graphic == "line": - graphic = fig[0, 0].add_line(data=data, color_mode="vertex") + graphic = fig[0, 0].add_line(data=data, colors=colors) elif test_graphic == "scatter": - graphic = fig[0, 0].add_scatter(data=data, color_mode="vertex") + graphic = fig[0, 0].add_scatter(data=data, colors=colors) colors = graphic.colors global EVENT_RETURN_VALUE @@ -97,11 +98,12 @@ def test_tuple(test_graphic, slice_method): fig = fpl.Figure() data = generate_positions_spiral_data("xyz") + colors = np.ones(shape=(len(data), 4), dtype=np.float32) if test_graphic == "line": - graphic = fig[0, 0].add_line(data=data, color_mode="vertex") + graphic = fig[0, 0].add_line(data=data, colors=colors) elif test_graphic == "scatter": - graphic = fig[0, 0].add_scatter(data=data, color_mode="vertex") + graphic = fig[0, 0].add_scatter(data=data, colors=colors) colors = graphic.colors global EVENT_RETURN_VALUE @@ -189,11 +191,12 @@ def test_slice(color_input, slice_method: dict, test_graphic: bool): fig = fpl.Figure() data = generate_positions_spiral_data("xyz") + colors = np.ones(shape=(len(data), 4), dtype=np.float32) if test_graphic == "line": - graphic = fig[0, 0].add_line(data=data, color_mode="vertex") + graphic = fig[0, 0].add_line(data=data, colors=colors) elif test_graphic == "scatter": - graphic = fig[0, 0].add_scatter(data=data, color_mode="vertex") + graphic = fig[0, 0].add_scatter(data=data, colors=colors) colors = graphic.colors diff --git a/tests/test_inf_line.py b/tests/test_inf_line.py new file mode 100644 index 000000000..d90f8155e --- /dev/null +++ b/tests/test_inf_line.py @@ -0,0 +1,319 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics.features import ( + InfLineAxisData, + InfLineColors, + UniformColor, + GraphicFeatureEvent, + VertexCmap +) + + +AXES = {"x": 0, "y": 1, "z": 2} + + +def make_inf_line(**kwargs): + fig = fpl.Figure() + return fig[0, 0].add_inf_line(**kwargs) + + +@pytest.mark.parametrize("axis", ["x", "y", "z"]) +def test_axis_construction(axis): + positions = np.array([0.0, 1.0, 2.0, 3.0]) + graphic = make_inf_line(data=positions, axis=axis) + + assert isinstance(graphic, fpl.InfLineGraphic) + assert isinstance(graphic._data, InfLineAxisData) + assert graphic.axis == axis + assert isinstance( + graphic.world_object.material, pygfx.LineInfiniteSegmentMaterial + ) + + # two vertices per line + buffer = graphic.world_object.geometry.positions.data + assert buffer.shape == (8, 3) + assert len(graphic.data) == 4 + + # value is one position per line, both endpoints share it + npt.assert_array_equal(graphic.data.value, positions) + npt.assert_array_equal(buffer[:, AXES[axis]], np.repeat(positions, 2)) + + # the two endpoints of a line differ along another axis so the segment has a direction + run_index = 1 if AXES[axis] == 0 else 0 + npt.assert_array_equal(buffer[1::2, run_index], np.ones(4)) + + +def test_axis_none_construction(): + # user example: 4 vertical lines defined directly by endpoint pairs + positions = np.array( + [ + [0, 0, 0], + [0, 1, 0], + [1, 0, 0], + [1, 1, 0], + [2, 0, 0], + [2, 1, 0], + [3, 0, 0], + [3, 1, 0], + ], + dtype=np.float32, + ) + graphic = make_inf_line(data=positions, axis=None) + + assert graphic.axis is None + assert len(graphic.data) == 4 + # value is [n_lines, 2, 3] endpoints + assert graphic.data.value.shape == (4, 2, 3) + npt.assert_array_equal(graphic.data.value[0], [[0, 0, 0], [0, 1, 0]]) + npt.assert_array_equal( + graphic.world_object.geometry.positions.data, positions + ) + + +def test_axis_none_requires_even_points(): + with pytest.raises(ValueError): + make_inf_line(data=np.random.rand(5, 3), axis=None) + + +def test_invalid_axis(): + with pytest.raises(ValueError): + make_inf_line(data=np.arange(4.0), axis="w") + + +def test_axis_requires_1d(): + with pytest.raises(ValueError): + make_inf_line(data=np.random.rand(4, 2), axis="x") + + +@pytest.mark.parametrize("axis", ["x", "y", "z"]) +def test_per_line_position_update(axis): + graphic = make_inf_line(data=np.array([0.0, 1.0, 2.0, 3.0]), axis=axis) + + graphic.data[1] = 5.0 + assert graphic.data.value[1] == 5.0 + + buffer = graphic.world_object.geometry.positions.data + # both endpoints of line 1 moved + npt.assert_array_equal(buffer[2:4, AXES[axis]], [5.0, 5.0]) + + +def test_per_line_endpoint_update(): + positions = np.array( + [[0, 0, 0], [0, 1, 0], [1, 0, 0], [1, 1, 0]], dtype=np.float32 + ) + graphic = make_inf_line(data=positions, axis=None) + + graphic.data[0] = [[9, 0, 0], [9, 1, 0]] + npt.assert_array_equal( + graphic.world_object.geometry.positions.data[0:2], [[9, 0, 0], [9, 1, 0]] + ) + + +@pytest.mark.parametrize("axis", ["x", "y"]) +def test_resize(axis): + graphic = make_inf_line(data=np.array([0.0, 1.0, 2.0]), axis=axis) + assert len(graphic.data) == 3 + + graphic.data = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + assert len(graphic.data) == 5 + assert graphic.world_object.geometry.positions.data.shape == (10, 3) + npt.assert_array_equal(graphic.data.value, [0, 1, 2, 3, 4]) + + +def test_uniform_color(): + graphic = make_inf_line(data=np.arange(4.0), axis="x", colors="gray") + assert isinstance(graphic._colors, UniformColor) + assert graphic.colors == pygfx.Color("gray") + + +def test_per_line_colors(): + graphic = make_inf_line( + data=np.arange(3.0), axis="x", colors=["r", "g", "b"] + ) + assert isinstance(graphic._colors, InfLineColors) + + # one color per line, buffer has two vertices per line + assert graphic.colors.value.shape == (3, 4) + cbuffer = graphic.world_object.geometry.colors.data + assert cbuffer.shape == (6, 4) + + # each line's two vertices share the color + for i in range(3): + npt.assert_array_equal(cbuffer[2 * i], cbuffer[2 * i + 1]) + + npt.assert_array_equal(graphic.colors.value[0], [1, 0, 0, 1]) # red + + # per-line color update + graphic.colors[2] = "yellow" + npt.assert_array_equal(cbuffer[4], cbuffer[5]) + npt.assert_array_equal(graphic.colors.value[2], [1, 1, 0, 1]) + + +def test_cmap(): + graphic = make_inf_line(data=np.arange(5.0), axis="y", cmap="jet") + assert isinstance(graphic._cmap, VertexCmap) + assert graphic.cmap.name == "matlab:jet" + + # a cmap replaces colors, the colormap is sampled through texcoords instead + assert graphic._colors is None + assert graphic.colors is None + assert graphic.world_object.material.color_mode == "vertex_map" + assert isinstance(graphic.world_object.material.map, pygfx.TextureMap) + + # two vertices per line, the default transform spans the colormap + texcoords = graphic.world_object.geometry.texcoords.data + assert texcoords.shape == (2 * len(graphic.data),) + npt.assert_almost_equal(texcoords, np.linspace(0, 1, 2 * len(graphic.data))) + + # the transform is the texcoords, and its (min, max) is mapped onto the colormap + npt.assert_almost_equal(graphic.cmap_transform, texcoords) + assert graphic.cmap_range == (0.0, 1.0) + assert graphic.world_object.material.maprange == graphic.cmap_range + + +def test_cmap_runtime(): + graphic = make_inf_line(data=np.arange(5.0), axis="x", cmap="jet") + + previous_map = graphic.world_object.material.map + graphic.cmap = "plasma" + assert graphic.cmap.name == "bids:plasma" + # the material gets the new colormap texture + assert graphic.world_object.material.map is not previous_map + + # one transform value per line is resampled over the two vertices of each line + graphic.cmap_transform = np.array([0.0, 1.0, 2.0, 1.0, 0.0]) + texcoords = graphic.world_object.geometry.texcoords.data + assert texcoords.shape == (2 * len(graphic.data),) + npt.assert_almost_equal( + texcoords, + np.interp(np.linspace(0, 1, 10), np.linspace(0, 1, 5), [0.0, 1.0, 2.0, 1.0, 0.0]), + decimal=5, + ) + # the range follows the resampled transform + npt.assert_almost_equal(graphic.cmap_range, (texcoords.min(), texcoords.max())) + assert graphic.world_object.material.maprange == graphic.cmap_range + + # one color per line needs a transform per vertex, both endpoints of a line sharing a value + graphic.cmap_transform = np.repeat([0.0, 1.0, 2.0, 1.0, 0.0], 2) + texcoords = graphic.world_object.geometry.texcoords.data + for i in range(len(graphic.data)): + npt.assert_almost_equal(texcoords[2 * i], texcoords[2 * i + 1]) + + +def test_empty_key_is_noop(): + graphic = make_inf_line(data=np.arange(4.0), axis="x", colors=["r", "g", "b", "y"]) + before = graphic.data.value.copy() + + # an all-False mask selects nothing and must be a no-op, not raise + graphic.data[np.zeros(4, dtype=bool)] = 10.0 + graphic.colors[np.zeros(4, dtype=bool)] = "cyan" + + npt.assert_array_equal(graphic.data.value, before) + + +def test_endpoint_indexing(): + positions = np.array( + [[0, 0, 0], [0, 1, 0], [1, 0, 0], [1, 1, 0]], dtype=np.float32 + ) + graphic = make_inf_line(data=positions, axis=None) + + # getter and setter are symmetric down to individual endpoints + npt.assert_array_equal(graphic.data[0, 0], [0, 0, 0]) + graphic.data[0, 0] = [9, 9, 9] + npt.assert_array_equal(graphic.world_object.geometry.positions.data[0], [9, 9, 9]) + # the other endpoint of line 0 is untouched + npt.assert_array_equal(graphic.world_object.geometry.positions.data[1], [0, 1, 0]) + + +def test_channel_color_indexing(): + graphic = make_inf_line(data=np.arange(3.0), axis="x", colors=["r", "g", "b"]) + + # set only the RGB channels of line 0, leaving alpha unchanged + graphic.colors[0, :-1] = [1.0, 1.0, 0.0] + cbuffer = graphic.world_object.geometry.colors.data + npt.assert_array_equal(cbuffer[0], [1.0, 1.0, 0.0, 1.0]) + npt.assert_array_equal(cbuffer[0], cbuffer[1]) # both vertices updated + + +@pytest.mark.parametrize( + "pattern,expected", + [ + ("--", (5, 5)), + ("dashed", (5, 5)), + (":", (0, 2)), + ("dotted", (0, 2)), + ("-.", (5, 2, 1, 2)), + ((2, 3), (2, 3)), + ], +) +def test_dash_pattern(pattern, expected): + graphic = make_inf_line(data=np.arange(3.0), axis="x", dash_pattern=pattern) + # value returns the user's input verbatim + assert graphic.dash_pattern == pattern + # the material receives the parsed tuple + assert tuple(graphic.world_object.material.dash_pattern) == expected + + +@pytest.mark.parametrize("start,end", [(True, True), (False, True), (True, False)]) +def test_start_end_is_infinite(start, end): + graphic = make_inf_line( + data=np.arange(3.0), axis="x", start_is_infinite=start, end_is_infinite=end + ) + assert graphic.start_is_infinite is start + assert graphic.end_is_infinite is end + assert graphic.world_object.material.start_is_infinite is start + assert graphic.world_object.material.end_is_infinite is end + + graphic.start_is_infinite = not start + assert graphic.world_object.material.start_is_infinite is (not start) + + +def test_thin_not_supported(): + graphic = make_inf_line(data=np.arange(3.0), axis="x") + assert graphic.thin is False + with pytest.raises(NotImplementedError): + graphic.thin = True + + +def test_selectors_not_supported(): + graphic = make_inf_line(data=np.arange(3.0), axis="x") + for method in ( + graphic.add_linear_selector, + graphic.add_linear_region_selector, + graphic.add_rectangle_selector, + graphic.add_polygon_selector, + ): + with pytest.raises(NotImplementedError): + method() + + +def test_events(): + graphic = make_inf_line( + data=np.arange(3.0), axis="x", colors=["r", "g", "b"] + ) + + events = dict() + + @graphic.add_event_handler("data") + def _on_data(ev: GraphicFeatureEvent): + events["data"] = ev + + @graphic.add_event_handler("colors") + def _on_colors(ev: GraphicFeatureEvent): + events["colors"] = ev + + @graphic.add_event_handler("dash_pattern") + def _on_dash(ev: GraphicFeatureEvent): + events["dash_pattern"] = ev + + graphic.data[0] = 10.0 + graphic.colors[1] = "cyan" + graphic.dash_pattern = "--" + + assert set(events) == {"data", "colors", "dash_pattern"} + assert events["dash_pattern"].info["value"] == "--" diff --git a/tests/test_markers_buffer_manager.py b/tests/test_markers_buffer_manager.py index 488bed194..bd79d1d78 100644 --- a/tests/test_markers_buffer_manager.py +++ b/tests/test_markers_buffer_manager.py @@ -46,7 +46,7 @@ def test_create_buffer(test_graphic): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, markers=MARKERS1, uniform_marker=False) + scatter = fig[0, 0].add_scatter(data, markers=MARKERS1) vertex_markers = scatter.markers assert isinstance(vertex_markers, VertexMarkers) assert vertex_markers._fpl_buffer is scatter.world_object.geometry.markers @@ -68,7 +68,7 @@ def test_int(test_graphic, index: int): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, markers=MARKERS1, uniform_marker=False) + scatter = fig[0, 0].add_scatter(data, markers=MARKERS1) scatter.add_event_handler(event_handler, "markers") vertex_markers = scatter.markers else: @@ -108,7 +108,7 @@ def test_slice(test_graphic, slice_method): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, markers=MARKERS1, uniform_marker=False) + scatter = fig[0, 0].add_scatter(data, markers=MARKERS1) scatter.add_event_handler(event_handler, "markers") vertex_markers = scatter.markers diff --git a/tests/test_plot_helpers.py b/tests/test_plot_helpers.py index bc2bb663f..8ce27f7d4 100644 --- a/tests/test_plot_helpers.py +++ b/tests/test_plot_helpers.py @@ -26,8 +26,8 @@ def test_get_nearest_graphics(): # check distances nearest = fpl.get_nearest_graphics((0, 12), lines) - assert nearest[0] is lines[1] # closest - assert nearest[1] is lines[0] - assert nearest[2] is lines[3] - assert nearest[3] is lines[2] # furthest - assert nearest[-1] is lines[2] + assert nearest[0] is lines.graphics[1] # closest + assert nearest[1] is lines.graphics[0] + assert nearest[2] is lines.graphics[3] + assert nearest[3] is lines.graphics[2] # furthest + assert nearest[-1] is lines.graphics[2] diff --git a/tests/test_point_rotations_buffer_manager.py b/tests/test_point_rotations_buffer_manager.py index 50ee88984..16bce62ba 100644 --- a/tests/test_point_rotations_buffer_manager.py +++ b/tests/test_point_rotations_buffer_manager.py @@ -32,7 +32,7 @@ def test_create_buffer(test_graphic): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, point_rotation_mode="vertex", point_rotations=ROTATIONS1) + scatter = fig[0, 0].add_scatter(data, point_rotations=ROTATIONS1) vertex_rotations = scatter.point_rotations assert isinstance(vertex_rotations, VertexRotations) assert vertex_rotations._fpl_buffer is scatter.world_object.geometry.rotations @@ -50,7 +50,7 @@ def test_int(test_graphic, index: int): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, point_rotation_mode="vertex", point_rotations=ROTATIONS1) + scatter = fig[0, 0].add_scatter(data, point_rotations=ROTATIONS1) vertex_rotations = scatter.point_rotations scatter.add_event_handler(event_handler, "point_rotations") @@ -88,7 +88,7 @@ def test_slice(test_graphic, slice_method): if test_graphic: fig = fpl.Figure() - scatter = fig[0, 0].add_scatter(data, point_rotation_mode="vertex", point_rotations=ROTATIONS1) + scatter = fig[0, 0].add_scatter(data, point_rotations=ROTATIONS1) vertex_rotations = scatter.point_rotations scatter.add_event_handler(event_handler, "point_rotations") diff --git a/tests/test_positions_graphics.py b/tests/test_positions_graphics.py index a875b1416..c358d7e0d 100644 --- a/tests/test_positions_graphics.py +++ b/tests/test_positions_graphics.py @@ -3,19 +3,21 @@ import pytest import pygfx +import cmap as cmap_lib import fastplotlib as fpl from fastplotlib.graphics.features import ( VertexPositions, VertexColors, VertexCmap, + VertexCmapTransform, + VertexCmapRange, UniformColor, UniformSize, VertexPointSizes, Thickness, GraphicFeatureEvent, ) -from tests.utils import TRUTH_CMAPS from .utils import ( generate_positions_spiral_data, @@ -36,59 +38,32 @@ def test_sizes_slice(): @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) -@pytest.mark.parametrize("colors", [None, *generate_color_inputs("b")]) -@pytest.mark.parametrize("color_mode", ["uniform", "vertex"]) -def test_color_mode(graphic_type, colors, color_mode): +@pytest.mark.parametrize("colors", ["w", *generate_color_inputs("b")]) +def test_uniform_colors(graphic_type, colors): fig = fpl.Figure() - kwargs = dict() - for kwarg in ["colors", "color_mode"]: - if locals()[kwarg] is not None: - # add to dict of arguments that will be passed - kwargs[kwarg] = locals()[kwarg] - data = generate_positions_spiral_data("xy") if graphic_type == "line": - graphic = fig[0, 0].add_line(data=data, **kwargs) + graphic = fig[0, 0].add_line(data=data, colors=colors) elif graphic_type == "scatter": - graphic = fig[0, 0].add_scatter(data=data, **kwargs) + graphic = fig[0, 0].add_scatter(data=data, colors=colors) - if color_mode == "uniform": - assert isinstance(graphic._colors, UniformColor) - assert isinstance(graphic.colors, pygfx.Color) - if colors is None: - # default white - assert graphic.colors == pygfx.Color([1, 1, 1]) - else: - # should be blue - assert graphic.colors == pygfx.Color([0, 0, 1]) - - # check pygfx material - npt.assert_almost_equal( - graphic.world_object.material.color, np.asarray(graphic.colors) - ) - else: - assert isinstance(graphic._colors, VertexColors) - assert isinstance(graphic.colors, VertexColors) - if colors is None: - # default white - npt.assert_almost_equal( - graphic.colors.value, - np.repeat([[1, 1, 1, 1.0]], repeats=len(graphic.data), axis=0), - ) - else: - # blue - npt.assert_almost_equal( - graphic.colors.value, - np.repeat([[0, 0, 1, 1.0]], repeats=len(graphic.data), axis=0), - ) + assert isinstance(graphic._colors, UniformColor) + assert isinstance(graphic.colors, pygfx.Color) + assert graphic.world_object.material.color_mode == pygfx.ColorMode.uniform - # check geometry - npt.assert_almost_equal( - graphic.world_object.geometry.colors.data, graphic.colors.value - ) + if isinstance(colors, str) and colors == "w": + # default white + assert graphic.colors == pygfx.Color([1, 1, 1]) + else: + # should be blue + assert graphic.colors == pygfx.Color([0, 0, 1]) + # check pygfx material + npt.assert_almost_equal( + graphic.world_object.material.color, np.asarray(graphic.colors) + ) @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) @pytest.mark.parametrize( @@ -129,242 +104,137 @@ def test_positions_graphics_data( @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) -@pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) -@pytest.mark.parametrize("color_mode", ["vertex"]) +@pytest.mark.parametrize("colors", [*generate_color_inputs("multi")]) def test_positions_graphic_vertex_colors( graphic_type, colors, - color_mode, ): # test different ways of passing vertex colors fig = fpl.Figure() - kwargs = dict() - for kwarg in ["colors", "color_mode"]: - if locals()[kwarg] is not None: - # add to dict of arguments that will be passed - kwargs[kwarg] = locals()[kwarg] - data = generate_positions_spiral_data("xy") if graphic_type == "line": - graphic = fig[0, 0].add_line(data=data, **kwargs) + graphic = fig[0, 0].add_line(data=data, colors=colors) elif graphic_type == "scatter": - graphic = fig[0, 0].add_scatter(data=data, **kwargs) + graphic = fig[0, 0].add_scatter(data=data, colors=colors) # color per vertex - assert isinstance(graphic._colors, VertexColors) - assert isinstance(graphic.colors, VertexColors) - assert len(graphic.colors) == len(graphic.data) + assert isinstance(graphic._colors, VertexColors) + assert isinstance(graphic.colors, VertexColors) + assert len(graphic.colors) == len(graphic.data) + assert graphic.world_object.material.color_mode == pygfx.ColorMode.vertex + assert graphic.world_object.geometry.colors is graphic.colors._fpl_buffer - if colors is None: - # default - npt.assert_almost_equal( - graphic.colors.value, - np.repeat([[1, 1, 1, 1.0]], repeats=len(graphic.data), axis=0), - ) - else: - if len(colors) != len(graphic.data): - # should be single red, regardless of input variant (i.e. str, array, RGBA tuple, etc. - npt.assert_almost_equal( - graphic.colors.value, - np.repeat([[1, 0, 0, 1.0]], repeats=len(graphic.data), axis=0), - ) - else: - # multi colors - # use the truth for multi colors test that is pre-set - npt.assert_almost_equal(graphic.colors.value, MULTI_COLORS_TRUTH) + # multi colors + # use the truth for multi colors test that is pre-set + npt.assert_almost_equal(graphic.colors.value, MULTI_COLORS_TRUTH) @pytest.mark.parametrize("graphic_type", ["line", "scatter"]) -@pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) -@pytest.mark.parametrize("color_mode", ["auto", "vertex"]) -@pytest.mark.parametrize("cmap", ["jet"]) +@pytest.mark.parametrize("cmap", ["jet", cmap_lib.Colormap(["orange", "purple", "green"])]) @pytest.mark.parametrize( "cmap_transform", [None, [3, 5, 2, 1, 0, 6, 9, 7, 4, 8], np.arange(9, -1, -1)] ) def test_cmap( graphic_type, - colors, - color_mode, cmap, cmap_transform, ): # test different ways of passing cmap args fig = fpl.Figure() - kwargs = dict() - for kwarg in ["cmap", "cmap_transform", "colors", "color_mode"]: - if locals()[kwarg] is not None: - # add to dict of arguments that will be passed - kwargs[kwarg] = locals()[kwarg] - data = generate_positions_spiral_data("xy") if graphic_type == "line": - graphic = fig[0, 0].add_line(data=data, **kwargs) + graphic = fig[0, 0].add_line(data=data, cmap=cmap, cmap_transform=cmap_transform) elif graphic_type == "scatter": - graphic = fig[0, 0].add_scatter(data=data, **kwargs) - - truth = TRUTH_CMAPS[cmap].copy() - - # permute if transform is provided - if cmap_transform is not None: - truth = truth[cmap_transform] - npt.assert_almost_equal(graphic.cmap.transform, cmap_transform) + graphic = fig[0, 0].add_scatter(data=data, cmap=cmap, cmap_transform=cmap_transform) + # verify types assert isinstance(graphic._cmap, VertexCmap) + assert isinstance(graphic.cmap, cmap_lib.Colormap) + assert isinstance(graphic._cmap_transform, VertexCmapTransform) + assert isinstance(graphic._cmap_range, VertexCmapRange) + assert graphic.world_object.material.color_mode == pygfx.ColorMode.vertex_map - assert graphic.cmap.name == cmap + assert isinstance(graphic.world_object.material.map, pygfx.TextureMap) + assert isinstance(graphic.world_object.geometry.texcoords, pygfx.Buffer) - # make sure buffer is identical - # cmap overrides colors argument - # use __repr__.__self__ to get the real reference from the cmap feature instead of the weakref proxy - assert graphic.colors._fpl_buffer is graphic.cmap.buffer.__repr__.__self__ + if cmap_transform is None: + transform = np.linspace(0, 1, len(data)) + npt.assert_almost_equal( + graphic.cmap_transform, transform + ) + npt.assert_almost_equal( + graphic.world_object.geometry.texcoords.data, transform + ) + else: + npt.assert_almost_equal(graphic.cmap_range, [min(cmap_transform), max(cmap_transform)]) + npt.assert_almost_equal( + graphic.world_object.geometry.texcoords.data, np.asarray(cmap_transform) + ) - npt.assert_almost_equal(graphic.cmap.value, truth) - npt.assert_almost_equal(graphic.colors.value, truth) + # verify buffer values + npt.assert_almost_equal(graphic.world_object.material.map.texture.data, cmap_lib.Colormap(cmap).to_pygfx().texture.data) # test changing cmap but not transform graphic.cmap = "viridis" - truth = TRUTH_CMAPS["viridis"].copy() - if cmap_transform is not None: - truth = truth[cmap_transform] - - assert graphic.cmap.name == "viridis" - npt.assert_almost_equal(graphic.cmap.value, truth) - npt.assert_almost_equal(graphic.colors.value, truth) + assert graphic.cmap.name == "bids:viridis" + npt.assert_almost_equal(graphic.world_object.material.map.texture.data, cmap_lib.Colormap("viridis").to_pygfx().texture.data) # test changing transform cmap_transform = np.random.rand(10) - # cmap transform is internally normalized between 0 - 1 - cmap_transform_norm = cmap_transform.copy() - cmap_transform_norm -= cmap_transform.min() - cmap_transform_norm /= cmap_transform_norm.max() - cmap_transform_norm *= 255 - - truth = fpl.utils.get_cmap("viridis", alpha=1) - truth = np.vstack([truth[val] for val in cmap_transform_norm.astype(int)]) - - graphic.cmap.transform = cmap_transform - npt.assert_almost_equal(graphic.cmap.transform, cmap_transform) - - npt.assert_almost_equal(graphic.cmap.value, truth) - npt.assert_almost_equal(graphic.colors.value, truth) - - -@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) -@pytest.mark.parametrize("cmap", ["jet"]) -@pytest.mark.parametrize( - "colors", [None, *generate_color_inputs("multi")] -) # cmap arg overrides colors -@pytest.mark.parametrize( - "color_mode", ["uniform"] # none of these will work with a uniform buffer -) -def test_incompatible_cmap_color_args(graphic_type, cmap, colors, color_mode): - # test incompatible cmap args - fig = fpl.Figure() - - kwargs = dict() - for kwarg in ["cmap", "colors", "color_mode"]: - if locals()[kwarg] is not None: - # add to dict of arguments that will be passed - kwargs[kwarg] = locals()[kwarg] - - data = generate_positions_spiral_data("xy") - - if graphic_type == "line": - with pytest.raises(ValueError): - graphic = fig[0, 0].add_line(data=data, **kwargs) - elif graphic_type == "scatter": - with pytest.raises(ValueError): - graphic = fig[0, 0].add_scatter(data=data, **kwargs) - - -@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) -@pytest.mark.parametrize("colors", [*generate_color_inputs("multi")]) -@pytest.mark.parametrize( - "color_mode", ["uniform"] # none of these will work with a uniform buffer -) -def test_incompatible_color_args(graphic_type, colors, color_mode): - # test incompatible color args - fig = fpl.Figure() - - kwargs = dict() - for kwarg in ["colors", "color_mode"]: - if locals()[kwarg] is not None: - # add to dict of arguments that will be passed - kwargs[kwarg] = locals()[kwarg] - - data = generate_positions_spiral_data("xy") + graphic.cmap_transform = cmap_transform - if graphic_type == "line": - with pytest.raises(ValueError): - graphic = fig[0, 0].add_line(data=data, **kwargs) - elif graphic_type == "scatter": - with pytest.raises(ValueError): - graphic = fig[0, 0].add_scatter(data=data, **kwargs) + npt.assert_almost_equal(graphic.cmap_transform, cmap_transform) + npt.assert_almost_equal(graphic.world_object.geometry.texcoords.data, cmap_transform) -@pytest.mark.parametrize("sizes", [None, 5.0, np.linspace(3, 8, 10, dtype=np.float32)]) +@pytest.mark.parametrize("sizes", [2, 5.0, np.linspace(3, 8, 10, dtype=np.float32)]) def test_sizes(sizes): # test scatter sizes fig = fpl.Figure() - kwargs = dict() - for kwarg in ["sizes"]: - if locals()[kwarg] is not None: - # add to dict of arguments that will be passed - kwargs[kwarg] = locals()[kwarg] - - data = generate_positions_spiral_data("xy") - - graphic = fig[0, 0].add_scatter(data=data, uniform_size=False, **kwargs) - - assert isinstance(graphic.sizes, VertexPointSizes) - assert isinstance(graphic._sizes, VertexPointSizes) - assert len(data) == len(graphic.sizes) - - if sizes is None: - sizes = 1 # default sizes - - npt.assert_almost_equal(graphic.sizes.value, sizes) - npt.assert_almost_equal( - graphic.world_object.geometry.sizes.data, graphic.sizes.value - ) - - -@pytest.mark.parametrize("sizes", [None, 5.0]) -@pytest.mark.parametrize("uniform_size", [True]) -def test_uniform_size(sizes, uniform_size): - fig = fpl.Figure() - - kwargs = dict() - for kwarg in ["sizes", "uniform_size"]: - if locals()[kwarg] is not None: - # add to dict of arguments that will be passed - kwargs[kwarg] = locals()[kwarg] - data = generate_positions_spiral_data("xy") - graphic = fig[0, 0].add_scatter(data=data, **kwargs) + graphic = fig[0, 0].add_scatter(data=data, sizes=sizes) - assert isinstance(graphic.sizes, (float, int)) - assert isinstance(graphic._sizes, UniformSize) + if isinstance(sizes, np.ndarray): + assert isinstance(graphic.sizes, VertexPointSizes) + assert isinstance(graphic._sizes, VertexPointSizes) + assert len(data) == len(graphic.sizes) + assert graphic.world_object.material.size_mode == pygfx.SizeMode.vertex - if sizes is None: - sizes = 1 # default sizes + npt.assert_almost_equal(graphic.sizes.value, sizes) + npt.assert_almost_equal( + graphic.world_object.geometry.sizes.data, graphic.sizes.value + ) + else: + assert isinstance(graphic.sizes, float) + assert isinstance(graphic._sizes, UniformSize) + assert graphic.world_object.material.size_mode == pygfx.SizeMode.uniform + + assert graphic.sizes == graphic._sizes.value == sizes + + # change sizes + new_sizes = 10 + graphic.sizes = new_sizes + if isinstance(sizes, np.ndarray): + # broadcast + assert (graphic.sizes.value == new_sizes).all() + else: + assert graphic.sizes == new_sizes - npt.assert_almost_equal(graphic.sizes, sizes) - npt.assert_almost_equal(graphic.world_object.material.size, sizes) + # also test uniform -> vertex switch + new_sizes = np.abs(np.sin(np.linspace(0, 2 * np.pi, len(data)))) + graphic.sizes = new_sizes - # test changing size - graphic.sizes = 10.0 - assert isinstance(graphic.sizes, float) - assert isinstance(graphic._sizes, UniformSize) - assert graphic.sizes == 10.0 + assert isinstance(graphic.sizes, VertexPointSizes) + assert graphic.world_object.material.size_mode == pygfx.SizeMode.vertex + assert graphic.world_object.geometry.sizes is graphic.sizes._fpl_buffer @pytest.mark.parametrize("thickness", [None, 0.5, 5.0]) @@ -499,7 +369,3 @@ def test_size_space(graphic_type, size_space): graphic.size_space = "world" assert graphic.size_space == "world" assert graphic.world_object.material.size_space == "world" - - -if __name__ == "__main__": - test_cmap("scatter", None, False, "jet", None) diff --git a/tests/test_replace_buffer.py b/tests/test_replace_buffer.py index a9d0ffe41..68e8ebed4 100644 --- a/tests/test_replace_buffer.py +++ b/tests/test_replace_buffer.py @@ -33,13 +33,10 @@ def test_replace_positions_buffer(graphic_type, new_buffer_size): if graphic_type == "scatter": kwargs = { "markers": np.random.choice(list("osD+x^v<>*"), size=orig_datapoints), - "uniform_marker": False, "sizes": np.abs(ys), - "uniform_size": False, # TODO: skipping edge_colors for now since that causes a WGPU bind group error that we will figure out later # anyways I think changing buffer sizes in combination with per-vertex edge colors is a literal edge-case "point_rotations": zs * 180, - "point_rotation_mode": "vertex", } else: kwargs = dict() diff --git a/tests/test_scatter_graphic.py b/tests/test_scatter_graphic.py index 930d8c495..1bcc5ffa5 100644 --- a/tests/test_scatter_graphic.py +++ b/tests/test_scatter_graphic.py @@ -50,7 +50,7 @@ def test_uniform_markers(marker): data = generate_positions_spiral_data("xyz") - scatter = fig[0, 0].add_scatter(data, markers=marker, uniform_marker=True) + scatter = fig[0, 0].add_scatter(data, markers=marker) marker_full_name = marker_names.get(marker) @@ -70,27 +70,6 @@ def test_uniform_markers(marker): check_event(scatter, "markers", pygfx.MarkerShape.circle) -@pytest.mark.parametrize("to_type", [list, tuple, np.array]) -@pytest.mark.parametrize("uniform_marker", [True, False]) -def test_incompatible_marker_args(to_type, uniform_marker): - markers = ["o"] * 3 + ["s"] * 3 + ["+"] * 3 + ["x"] - - markers = to_type(markers) - - data = generate_positions_spiral_data("xyz") - - fig = fpl.Figure() - - if uniform_marker: - with pytest.raises(TypeError): - scatter = fig[0, 0].add_scatter(data, markers=markers, uniform_marker=True) - - else: - scatter = fig[0, 0].add_scatter(data, markers=markers, uniform_marker=False) - assert isinstance(scatter._markers, VertexMarkers) - assert scatter.world_object.material.marker_mode == pygfx.MarkerMode.vertex - - def test_uniform_custom_sdf(): lower_right_triangle_sdf = """ // hardcode square root of 2 @@ -108,13 +87,14 @@ def test_uniform_custom_sdf(): fig = fpl.Figure() scatter = fig[0, 0].add_scatter( - data, markers="custom", uniform_marker=True, custom_sdf=lower_right_triangle_sdf + data, markers="custom", custom_sdf=lower_right_triangle_sdf ) assert scatter.markers == "custom" assert scatter.world_object.material.marker == "custom" assert scatter.world_object.material.custom_sdf == lower_right_triangle_sdf + # test with both list[str] and 2D numpy array inputs as colors @pytest.mark.parametrize("edge_colors",[generate_color_inputs("multi")[0], generate_color_inputs("multi")[1]]) def test_edge_colors(edge_colors): @@ -125,10 +105,11 @@ def test_edge_colors(edge_colors): scatter = fig[0, 0].add_scatter( data=data, edge_colors=edge_colors, - uniform_edge_color=False, ) assert isinstance(scatter._edge_colors, VertexColors) + assert scatter.world_object.material.edge_color_mode == pygfx.ColorMode.vertex + assert scatter.world_object.geometry.edge_colors is scatter.edge_colors._fpl_buffer npt.assert_almost_equal(scatter.edge_colors.value, MULTI_COLORS_TRUTH) @@ -140,6 +121,7 @@ def test_edge_colors(edge_colors): new_colors, array = generate_color_inputs("multi2") scatter.edge_colors = new_colors npt.assert_almost_equal(scatter.edge_colors.value, array) + npt.assert_almost_equal(scatter.world_object.geometry.edge_colors.data, array) @pytest.mark.parametrize("edge_color", ["r", (1, 0, 0), [1, 0, 0], np.array([1, 0, 0])]) @@ -149,11 +131,12 @@ def test_uniform_edge_colors(edge_color): data = generate_positions_spiral_data("xyz") scatter = fig[0, 0].add_scatter( - data=data, edge_colors=edge_color, uniform_edge_color=True + data=data, edge_colors=edge_color, ) assert isinstance(scatter._edge_colors, UniformEdgeColor) assert scatter.edge_colors == pygfx.Color(edge_color) + assert scatter.world_object.material.edge_color_mode == pygfx.ColorMode.uniform assert scatter.world_object.material.edge_color == pygfx.Color(edge_color) # test changes and event @@ -166,22 +149,6 @@ def test_uniform_edge_colors(edge_color): check_event(scatter, "edge_colors", pygfx.Color("g")) -@pytest.mark.parametrize("edge_colors", [generate_color_inputs("multi")[0],generate_color_inputs("multi")[1]]) -@pytest.mark.parametrize("uniform_edge_color", [False, True]) -def test_incompatible_edge_colors_args(edge_colors, uniform_edge_color): - fig = fpl.Figure() - - data = generate_positions_spiral_data("xyz") - - if uniform_edge_color: - with pytest.raises(TypeError): - scatter = fig[0, 0].add_scatter( - data=data, - edge_colors=edge_colors, - uniform_edge_color=uniform_edge_color, - ) - - @pytest.mark.parametrize("edge_width", [0.0, 0.5, 1.0, 5.0]) def test_edge_width(edge_width): fig = fpl.Figure() diff --git a/tests/utils.py b/tests/utils.py index 6da080433..6a88c912c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -145,7 +145,7 @@ def generate_color_inputs( "purple", "orange", ] - array = np.vstack([pygfx.Color(c) for c in s]) + array = np.vstack([np.asarray(pygfx.Color(c)) for c in s]) return [s, array] if name == "multi2": @@ -162,7 +162,7 @@ def generate_color_inputs( "yellow", "pink", ] - array = np.vstack([pygfx.Color(c) for c in s]) + array = np.vstack([np.asarray(pygfx.Color(c)) for c in s]) return [s, array] color = pygfx.Color(name) @@ -189,37 +189,3 @@ def generate_color_inputs( [1.0, 0.6470588445663452, 0.0, 1.0], ] ) - - -TRUTH_CMAPS = { - "jet": np.array( - [ - [0.0, 0.0, 0.5, 1.0], - [0.0, 0.0, 0.99910873, 1.0], - [0.0, 0.37843138, 1.0, 1.0], - [0.0, 0.8333333, 1.0, 1.0], - [0.30044276, 1.0, 0.66729915, 1.0], - [0.65464896, 1.0, 0.31309298, 1.0], - [1.0, 0.90123457, 0.0, 1.0], - [1.0, 0.4945534, 0.0, 1.0], - [1.0, 0.08787218, 0.0, 1.0], - [0.5, 0.0, 0.0, 1.0], - ], - dtype=np.float32, - ), - "viridis": np.array( - [ - [0.267004, 0.004874, 0.329415, 1.0], - [0.281412, 0.155834, 0.469201, 1.0], - [0.244972, 0.287675, 0.53726, 1.0], - [0.190631, 0.407061, 0.556089, 1.0], - [0.147607, 0.511733, 0.557049, 1.0], - [0.119483, 0.614817, 0.537692, 1.0], - [0.20803, 0.718701, 0.472873, 1.0], - [0.421908, 0.805774, 0.35191, 1.0], - [0.699415, 0.867117, 0.175971, 1.0], - [0.993248, 0.906157, 0.143936, 1.0], - ], - dtype=np.float32, - ), -} From 7008c79c7513081a948262992e1f394b1cfccd5d Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Fri, 11 Sep 2026 00:05:34 -0400 Subject: [PATCH 130/163] Ndw rename consistency (#1078) * rename for consistency * more consistency * more * final renames * fix --- examples/ndwidget/ndimage.py | 6 +- examples/ndwidget/timeseries.py | 4 +- fastplotlib/widgets/__init__.py | 6 +- fastplotlib/widgets/nd_widget/__init__.py | 10 +- fastplotlib/widgets/nd_widget/_base.py | 130 ++++++------ fastplotlib/widgets/nd_widget/_index.py | 4 +- fastplotlib/widgets/nd_widget/_nd_image.py | 156 +++++++------- .../nd_widget/_nd_positions/__init__.py | 6 +- .../nd_widget/_nd_positions/_nd_positions.py | 136 ++++++------- .../nd_widget/_nd_positions/_nd_timeseries.py | 76 +++---- .../nd_widget/_nd_positions/_pandas.py | 20 +- fastplotlib/widgets/nd_widget/_nd_vectors.py | 118 +++++------ fastplotlib/widgets/nd_widget/_ndw_subplot.py | 192 +++++++++--------- fastplotlib/widgets/nd_widget/_ndwidget.py | 20 +- .../widgets/nd_widget/_repr_formatter.py | 4 +- fastplotlib/widgets/nd_widget/_ui.py | 2 +- fastplotlib/widgets/nd_widget/_video.py | 6 +- 17 files changed, 448 insertions(+), 448 deletions(-) diff --git a/examples/ndwidget/ndimage.py b/examples/ndwidget/ndimage.py index b8cc267a3..0e4efe654 100644 --- a/examples/ndwidget/ndimage.py +++ b/examples/ndwidget/ndimage.py @@ -23,12 +23,12 @@ ndw = fpl.NDWidget( - ref_ranges=ref, + ranges=ref, size=(700, 560) ) ndw2 = fpl.NDWidget( - ref_ranges=ref, - ref_index=ndw.indices, # can create another NDWidget that shared the reference index! So multiple windows are possible + ranges=ref, + indices=ndw.indices, # can create another NDWidget that shared the reference index! So multiple windows are possible size=(700, 560) ) diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py index d0f8a6610..d4db36df6 100644 --- a/examples/ndwidget/timeseries.py +++ b/examples/ndwidget/timeseries.py @@ -37,13 +37,13 @@ "angle": (0, xs[-1], 0.1), } -ndw = fpl.NDWidget(ref_ranges=ref, size=(700, 560)) +ndw = fpl.NDWidget(ranges=ref, size=(700, 560)) nd_lines = ndw[0, 0].add_nd_timeseries( data, ("freq", "ampl", "n_lines", "angle", "d"), ("n_lines", "angle", "d"), - slider_dim_transforms={ + slider_maps={ "angle": xs, "ampl": lambda x: int(x + 1), "freq": lambda x: int(x + 1), diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index c5caa3845..d76eaffd4 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -1,11 +1,11 @@ from .nd_widget import ( NDWidget, - NDProcessor, + NDSlicer, NDGraphic, - NDPositionsProcessor, + NDPositionsSlicer, NDPositions, NDTimeseries, - NDImageProcessor, + NDImageSlicer, NDImage, ) diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index 46245d62b..adb9c5d6b 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -2,11 +2,11 @@ if IMGUI: - from ._base import NDProcessor, NDGraphic - from ._nd_positions import NDPositions, NDPositionsProcessor, NDTimeseries, ndp_extras - from ._nd_image import NDImageProcessor, NDImage - from ._video import VideoProcessor - from ._nd_vectors import NDVectorsProcessor, NDVectors + from ._base import NDSlicer, NDGraphic + from ._nd_positions import NDPositions, NDPositionsSlicer, NDTimeseries, ndp_extras + from ._nd_image import NDImageSlicer, NDImage + from ._video import VideoSlicer + from ._nd_vectors import NDVectorsSlicer, NDVectors from ._ndwidget import NDWidget else: diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index af727dc87..11b4d9358 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -27,13 +27,13 @@ def identity(index: int) -> int: return round(index) -class NDProcessor: +class NDSlicer: def __init__( self, data: ArrayProtocol, dims: Sequence[str], - spatial_dims: Sequence[str] | None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + display_dims: Sequence[str] | None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, @@ -51,7 +51,7 @@ def __init__( However their ``get()`` method must still return a data slice that corresponds to the graphical representation they map to. - Every dimension that is *not* listed in ``spatial_dims`` becomes a slider + Every dimension that is *not* listed in ``display_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct a change in the ``ReferenceIndex`` and update the graphics. @@ -64,7 +64,7 @@ def __init__( dims: Sequence[str] names for each dimension in ``data``. Dimensions not listed in - ``spatial_dims`` are treated as slider dimensions and **must** appear as + ``display_dims`` are treated as slider dimensions and **must** appear as keys in the parent ``NDWidget``'s ``ref_ranges`` Examples:: ``("time", "depth", "row", "col")`` @@ -74,11 +74,11 @@ def __init__( A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method must operate as if these dimensions exist and return an array that matches the spatial dimensions. - spatial_dims: Sequence[str] + display_dims: Sequence[str] Subset of ``dims`` that are spatial (rendered) dimensions **in display order**. All remaining dims are treated as slider dims. See subclass for specific info. - slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None + slider_maps: dict mapping dim_name -> Callable, an ArrayLike, or None Per-slider-dim mapping from reference-space values to local array indices. You may also provide an array of reference values for the slider dims, ``searchsorted`` is then used @@ -97,7 +97,7 @@ def __init__( * *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs (ex: ``np.mean``, ``np.max``). The window function **must** return an array that has the same dimensions - as specified in the NDProcessor, therefore the size of any dim along which a window_func was applied + as specified in the NDSlicer, therefore the size of any dim along which a window_func was applied should reduce to ``1``. These dims must not be removed by the window_func. * *window_size* is in reference-space units (ex: 2.5 seconds). @@ -119,9 +119,9 @@ def __init__( self._dims = dims self.data = data - self.spatial_dims = spatial_dims + self.display_dims = display_dims - self.slider_dim_transforms = slider_dim_transforms + self.slider_maps = slider_maps self.window_funcs = window_funcs self.window_order = window_order @@ -186,24 +186,24 @@ def dims(self) -> tuple[str, ...]: return self._dims @property - def spatial_dims(self) -> tuple[str, ...]: - """Spatial dims, **in display order**""" - return self._spatial_dims + def display_dims(self) -> tuple[str, ...]: + """Subset of ``dims`` that are spatial (rendered) dimensions **in display order**.""" + return self._display_dims - @spatial_dims.setter - def spatial_dims(self, sdims: Sequence[str]): + @display_dims.setter + def display_dims(self, sdims: Sequence[str]): for dim in sdims: if dim not in self.dims: raise KeyError - self._spatial_dims = tuple(sdims) + self._display_dims = tuple(sdims) @property def spatial_dims_indices(self) -> tuple[int, ...]: """ The ordered spatial dim indices that correspond to the named spatial dims """ - return tuple(self.spatial_dims.index(d) for d in self.dims if d in self.spatial_dims) + return tuple(self.display_dims.index(d) for d in self.dims if d in self.display_dims) @property def tooltip(self) -> bool: @@ -220,8 +220,8 @@ def tooltip_format(self, *args) -> str | None: @property def slider_dims(self) -> set[str]: - """Slider dim names, ``set(dims) - set(spatial_dims), **unordered**""" - return set(self.dims) - set(self.spatial_dims) + """Slider dim names, ``set(dims) - set(display_dims), **unordered**""" + return set(self.dims) - set(self.display_dims) @property def n_slider_dims(self): @@ -332,7 +332,7 @@ def spatial_func( self._spatial_func = func @property - def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: + def slider_maps(self) -> dict[str, Callable[[Any], int]]: """ Get or set the per-slider-dim mapping from reference-space values to local array indices, ``{dim_name: transform}``. @@ -344,8 +344,8 @@ def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: """ return self._index_mappings - @slider_dim_transforms.setter - def slider_dim_transforms( + @slider_maps.setter + def slider_maps( self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None ): if maps is None: @@ -374,10 +374,10 @@ def slider_dim_transforms( self._index_mappings = maps def _ref_index_to_array_index(self, dim: str, ref_index: Any) -> int: - # wraps slider_dim_transforms, clamps between 0 and the array size in this dim + # wraps slider_maps, clamps between 0 and the array size in this dim # ref-space -> local-array-index transform - index = self.slider_dim_transforms[dim](ref_index) + index = self.slider_maps[dim](ref_index) # clamp between 0 and array size in this dim return max(min(index, self.shape[dim] - 1), 0) @@ -432,7 +432,7 @@ def _get_slider_dims_indexer(self, indices: dict[str, Any]) -> dict[str, slice]: # get only slider dims which are not also spatial dims (example: p dim for positional data) # since `p` dim windowing is dealt with separately for positional data - slider_dims = set(self.slider_dims) - set(self.spatial_dims) + slider_dims = set(self.slider_dims) - set(self.display_dims) # go through each slider dim and accumulate slice objects for dim in slider_dims: # index for this dim in reference space @@ -455,8 +455,8 @@ def _get_slider_dims_indexer(self, indices: dict[str, Any]) -> dict[str, slice]: stop_ref = index_ref + hw # map start and stop ref to array indices - start = self.slider_dim_transforms[dim](start_ref) - stop = self.slider_dim_transforms[dim](stop_ref) + start = self.slider_maps[dim](start_ref) + stop = self.slider_maps[dim](stop_ref) # clamp within array bounds start = max(min(self.shape[dim] - 1, start), 0) @@ -465,7 +465,7 @@ def _get_slider_dims_indexer(self, indices: dict[str, Any]) -> dict[str, slice]: else: # no window func for this dim, direct indexing # index mapped to array index - index = self.slider_dim_transforms[dim](index_ref) + index = self.slider_maps[dim](index_ref) # clamp within the bounds start = max(min(self.shape[dim] - 1, index), 0) @@ -482,7 +482,7 @@ async def _apply_window_functions( apply window functions in the order specified by ``window_order``. - For numpy arrays each func is dispatched to the per-processor thread pool so it + For numpy arrays each func is dispatched to the per-slicer thread pool so it does not block the rendercanvas event loop. CUDA arrays are run directly since cuda functions (ex: torch) are already async. @@ -535,7 +535,7 @@ async def get_window_output(self, indices: dict[str, Any]) -> ArrayProtocol: ArrayProtocol Data slice with the window funcs applied and the slider dims, which are of size ``1`` after windowing, squeezed out. The remaining dims are the spatial dims, in the order they appear in - ``dims``, **not** in ``spatial_dims`` display order. Subclasses transpose into display order in + ``dims``, **not** in ``display_dims`` display order. Subclasses transpose into display order in :meth:`get`. Raises @@ -556,15 +556,15 @@ async def get_window_output(self, indices: dict[str, Any]) -> ArrayProtocol: windowed_slice = await self._apply_window_functions(windowed_slice) # squeeze out all slider dims which should now be size 1 - # set(dims) - set(spatial_dims) since some spatial dims can also be slider, so get only pure non-spatial dims + # set(dims) - set(display_dims) since some spatial dims can also be slider, so get only pure non-spatial dims slider_dims_int = tuple( - self.dims.index(d) for d in set(self.dims) - set(self.spatial_dims) + self.dims.index(d) for d in set(self.dims) - set(self.display_dims) ) windowed_slice = windowed_slice.squeeze(axis=slider_dims_int) - if windowed_slice.ndim != len(self.spatial_dims): + if windowed_slice.ndim != len(self.display_dims): raise ValueError( - f"windowed_slice.ndim != len(self.spatial_dims): {windowed_slice.ndim} != {len(self.spatial_dims)}" + f"windowed_slice.ndim != len(self.display_dims): {windowed_slice.ndim} != {len(self.display_dims)}" ) return windowed_slice @@ -596,7 +596,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: Get the data slice to display at the given indices. **Must** be implemented in a subclass. Called by the ``NDGraphic`` whenever the ``ReferenceIndex`` updates. Implementations usually call - :meth:`get_window_output`, apply the ``spatial_func``, and transpose into the ``spatial_dims`` display + :meth:`get_window_output`, apply the ``spatial_func``, and transpose into the ``display_dims`` display order. Parameters @@ -608,7 +608,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: Returns ------- ArrayProtocol - Data slice that maps to the graphical representation, with the dims given by ``spatial_dims`` in + Data slice that maps to the graphical representation, with the dims given by ``display_dims`` in display order. """ @@ -635,9 +635,9 @@ def _repr_text_(self): f"{self.__class__.__name__}\n" f"shape:\n\t{self.shape}\n" f"dims:\n\t{self.dims}\n" - f"spatial_dims:\n\t{self.spatial_dims}\n" + f"display_dims:\n\t{self.display_dims}\n" f"slider_dims:\n\t{self.slider_dims}\n" - f"slider_dim_transforms:\n{textwrap.indent(pformat(self.slider_dim_transforms, width=120), prefix=tab)}\n" + f"slider_maps:\n{textwrap.indent(pformat(self.slider_maps, width=120), prefix=tab)}\n" ) if len(wf) > 0: @@ -659,15 +659,15 @@ def __init__( name: str | None, ): """ - Base class that pairs an :class:`NDProcessor` with a ``Graphic``. Subclass to support a new graphical + Base class that pairs an :class:`NDSlicer` with a ``Graphic``. Subclass to support a new graphical representation. - The ``NDProcessor`` produces the data slice for the current index and the ``NDGraphic`` writes it to the + The ``NDSlicer`` produces the data slice for the current index and the ``NDGraphic`` writes it to the ``Graphic``. When the ``ReferenceIndex`` of the parent ``NDWidget`` changes, it schedules ``_set_indices_()`` on every ``NDGraphic`` that has the dim that changed. - Subclasses must implement :meth:`_create_graphic` and ``_set_indices_()``, and the :attr:`processor`, - :attr:`graphic`, :attr:`indices` and :attr:`spatial_dims` properties. Most of the processor properties + Subclasses must implement :meth:`_create_graphic` and ``_set_indices_()``, and the :attr:`slicer`, + :attr:`graphic`, :attr:`indices` and :attr:`display_dims` properties. Most of the slicer properties are aliased here so users can reach them from the ``NDGraphic``, and setting one of those aliases re-renders the current slice. @@ -717,8 +717,8 @@ def name(self) -> str | None: return self._name @property - def processor(self) -> NDProcessor: - """NDProcessor that manages the data and produces data slices to display""" + def slicer(self) -> NDSlicer: + """NDSlicer that manages the data and produces data slices to display""" raise NotImplementedError @property @@ -738,7 +738,7 @@ def indices(self) -> dict[str, Any]: async def _set_indices_(self, indices: dict[str, Any] = None): """ - Get the data slice for the index from the processor and write it to the graphic. + Get the data slice for the index from the slicer and write it to the graphic. If indices is None, it uses the latest indices from the ReferenceIndex. Otherwise it uses the indices passed when the update was scheduled. @@ -748,18 +748,18 @@ async def _set_indices_(self, indices: dict[str, Any] = None): """ pass - # aliases for easier access to processor properties + # aliases for easier access to slicer properties @property def data(self) -> Any: """ get or set managed data. If setting with new data, the new data is interpreted to have the same dims (i.e. same dim names and ordering of dims). """ - return self.processor.data + return self.slicer.data @data.setter def data(self, data: Any): - self.processor.data = data + self.slicer.data = data # create a new graphic when data has changed if self.graphic is not None: # it is already None if NDGraphic was initialized with no data @@ -774,20 +774,20 @@ def data(self, data: Any): @property def shape(self) -> dict[str, int]: """interpreted shape of the data""" - return self.processor.shape + return self.slicer.shape @property def ndim(self) -> int: """number of dims""" - return self.processor.ndim + return self.slicer.ndim @property def dims(self) -> tuple[str, ...]: """dim names""" - return self.processor.dims + return self.slicer.dims @property - def spatial_dims(self) -> tuple[str, ...]: + def display_dims(self) -> tuple[str, ...]: """get or set the spatial dims, i.e. the rendered dims, **in display order**""" # number of spatial dims for positional data is always 3 # for image is 2 or 3, so it must be implemented in subclass @@ -796,10 +796,10 @@ def spatial_dims(self) -> tuple[str, ...]: @property def slider_dims(self) -> set[str]: """the slider dims""" - return self.processor.slider_dims + return self.slicer.slider_dims @property - def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: + def slider_maps(self) -> dict[str, Callable[[Any], int]]: """ Get or set the per-slider-dim mapping from reference-space values to local array indices, ``{dim_name: transform}``. Setting it re-renders the current data slice. @@ -809,13 +809,13 @@ def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: timestamps array). Any dim given ``None``, or not given at all, uses the identity mapping, i.e. the reference value is rounded to the nearest integer and used as the array index. """ - return self.processor.slider_dim_transforms + return self.slicer.slider_maps - @slider_dim_transforms.setter - def slider_dim_transforms( + @slider_maps.setter + def slider_maps( self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None ): - self.processor.slider_dim_transforms = maps + self.slicer.slider_maps = maps # force a render run_sync(self._set_indices_()) @@ -836,7 +836,7 @@ def window_funcs( A window func is only applied for the dims listed in :attr:`window_order`. Any dim without an entry is filled in with ``(None, None)``. """ - return self.processor.window_funcs + return self.slicer.window_funcs @window_funcs.setter def window_funcs( @@ -846,18 +846,18 @@ def window_funcs( | None ), ): - self.processor.window_funcs = window_funcs + self.slicer.window_funcs = window_funcs # force a render run_sync(self._set_indices_()) @property def window_order(self) -> tuple[str, ...]: """get or set dimension order in which window functions are applied""" - return self.processor.window_order + return self.slicer.window_order @window_order.setter def window_order(self, order: tuple[str] | None): - self.processor.window_order = order + self.slicer.window_order = order # force a render run_sync(self._set_indices_()) @@ -867,13 +867,13 @@ def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: Get or set the function applied to the spatial slice *after* the window funcs, right before rendering. Setting it re-renders the current data slice. """ - return self.processor.spatial_func + return self.slicer.spatial_func @spatial_func.setter def spatial_func( self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: - self.processor.spatial_func = func + self.slicer.spatial_func = func # force a render run_sync(self._set_indices_()) @@ -892,7 +892,7 @@ def spatial_func( def _repr_text_(self): return ( f"graphic: {self.graphic.__class__.__name__}\n" - f"processor:\n{self.processor}" + f"slicer:\n{self.slicer}" ) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index 6b9eef680..dd1a4b828 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -151,7 +151,7 @@ def __len__(self): return len(self.options) -class ReferenceIndex: +class ReferenceIndices: def __init__( self, ref_ranges: dict[ @@ -381,7 +381,7 @@ def _schedule_fetch(self, ndg: NDGraphic, cancel_awaiting: bool = False): else: rev = self._fetch_rev.get(ndg, 0) # provide index at schedule time so all data is played back sequentially - indices = {d: self._indices[d] for d in ndg.processor.slider_dims} + indices = {d: self._indices[d] for d in ndg.slicer.slider_dims} self._fetch_request_queue.setdefault(ndg, deque()).append( (indices, rev) ) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 5887eb814..34a6bebb6 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -17,23 +17,23 @@ from ...graphics import ImageGraphic, ImageYUVGraphic, ImageVolumeGraphic from ...ui import ImguiColorbar from ._base import ( - NDProcessor, + NDSlicer, NDGraphic, WindowFuncCallable, ) -from ._index import ReferenceIndex +from ._index import ReferenceIndices from ._async import run_in_thread_pool, run_sync if TYPE_CHECKING: from ._ndw_subplot import NDWSubplot -class NDImageProcessor(NDProcessor): +class NDImageSlicer(NDSlicer): def __init__( self, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: ( + display_dims: ( tuple[str, str] | tuple[str, str, str] ), # must be in order! [rows, cols] | [z, rows, cols] rgb_dim: str | None = None, @@ -41,10 +41,10 @@ def __init__( window_order: tuple[int, ...] = None, spatial_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, - slider_dim_transforms=None, + slider_maps=None, ): """ - ``NDProcessor`` subclass for n-dimensional image data. + ``NDSlicer`` subclass for n-dimensional image data. Produces 2-D or 3-D spatial slices for an ``ImageGraphic`` or ``ImageVolumeGraphic``. @@ -55,7 +55,7 @@ def __init__( dims: Sequence[str] names for each dimension in ``data``. Dimensions not listed in - ``spatial_dims`` are treated as slider dimensions and **must** appear as + ``display_dims`` are treated as slider dimensions and **must** appear as keys in the parent ``NDWidget``'s ``ref_ranges`` Examples:: ``("time", "depth", "row", "col")`` @@ -64,9 +64,9 @@ def __init__( dims in the array do not need to be in the order that you want to display them, for example you can have a weird array where the dims are interpreted as: - ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")``. + ``("col", "depth", "row", "time")``, and then specify display_dims as ``("row", "col")``. - spatial_dims : tuple[str, str] | tuple[str, str, str] + display_dims : tuple[str, str] | tuple[str, str, str] The 2 or 3 spatial dims **in display order**, which also determines the graphic used for rendering: * ``(rows, cols)``, a 2D grayscale ``ImageGraphic`` @@ -74,32 +74,32 @@ def __init__( * ``(z, rows, cols)``, a 3D ``ImageVolumeGraphic`` The ordering determines how the image or volume is rendered. For example, if you specify - ``spatial_dims = ("rows", "cols")`` and then change it to ``("cols", "rows")``, it will display the + ``display_dims = ("rows", "cols")`` and then change it to ``("cols", "rows")``, it will display the transpose. rgb_dim : str, optional - Name of the RGB(A) dim, if present. It must be listed in ``spatial_dims`` and be of size 3 or 4. + Name of the RGB(A) dim, if present. It must be listed in ``display_dims`` and be of size 3 or 4. compute_histogram: bool, default True Compute a histogram of the data, disable if random-access of data is not blazing-fast (ex: data that uses video codecs), or if histograms are not useful for this data. - slider_dim_transforms : dict, optional - See :class:`NDProcessor`. + slider_maps : dict, optional + See :class:`NDSlicer`. window_funcs : dict, optional - See :class:`NDProcessor`. + See :class:`NDSlicer`. window_order : tuple, optional - See :class:`NDProcessor`. + See :class:`NDSlicer`. spatial_func : callable, optional - See :class:`NDProcessor`. + See :class:`NDSlicer`. See Also -------- - NDProcessor : Base class with full parameter documentation. - NDImage : The ``NDGraphic`` that wraps this processor. + NDSlicer : Base class with full parameter documentation. + NDImage : The ``NDGraphic`` that wraps this slicer. """ # set as False until data, window funcs stuff and spatial func is all set @@ -118,8 +118,8 @@ def __init__( super().__init__( data=data, dims=dims, - spatial_dims=spatial_dims, - slider_dim_transforms=slider_dim_transforms, + display_dims=display_dims, + slider_maps=slider_maps, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, @@ -156,16 +156,16 @@ def data(self, data: ArrayProtocol): self._recompute_histogram() @property - def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: + def display_dims(self) -> tuple[str, str] | tuple[str, str, str]: """ - Spatial dims, **in display order**. + Subset of ``dims`` that are spatial (rendered) dimensions **in display order**. [row_dim, col_dim] or [row_dim, col_dim, rgb(a) dim] """ - return self._spatial_dims + return self._display_dims - @spatial_dims.setter - def spatial_dims(self, sdims: tuple[str, str] | tuple[str, str, str]): + @display_dims.setter + def display_dims(self, sdims: tuple[str, str] | tuple[str, str, str]): for dim in sdims: if dim not in self.dims: raise KeyError @@ -176,7 +176,7 @@ def spatial_dims(self, sdims: tuple[str, str] | tuple[str, str, str]): f"[row_dims, col_dim, rgb(a) dim]. You passed: {sdims}" ) - self._spatial_dims = tuple(sdims) + self._display_dims = tuple(sdims) @property def rgb_dim(self) -> str | None: @@ -243,7 +243,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: window_output = await run_in_thread_pool( self._executor, self._spatial_func, window_output ) - if window_output.ndim != len(self.spatial_dims): + if window_output.ndim != len(self.display_dims): raise ValueError # final CUDA -> numpy conversion at the end of the pipeline @@ -269,7 +269,7 @@ def _recompute_histogram(self): # 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.dims.index(dim) for dim in self.spatial_dims] + ignore_dims = [self.dims.index(dim) for dim in self.display_dims] else: ignore_dims = None @@ -285,11 +285,11 @@ def _recompute_histogram(self): class NDImage(NDGraphic): def __init__( self, - ref_index: ReferenceIndex, + ref_index: ReferenceIndices, nd_subplot: NDWSubplot, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: ( + display_dims: ( tuple[str, str] | tuple[str, str, str] ), # must be in order! [rows, cols] | [z, rows, cols] rgb_dim: str | None = None, @@ -299,8 +299,8 @@ def __init__( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, - processor_type: type[NDImageProcessor] = NDImageProcessor, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, + slicer_type: type[NDImageSlicer] = NDImageSlicer, colorspace: Literal[ "srgb", "tex-srgb", "physical", "yuv420p", "yuv444p" ] = "srgb", @@ -311,18 +311,18 @@ def __init__( """ ``NDGraphic`` subclass for n-dimensional image rendering. - Uses an :class:`NDImageProcessor` to produce the data slices and manages an ``ImageGraphic``, - ``ImageYUVGraphic`` or ``ImageVolumeGraphic``, swapping between them when :attr:`spatial_dims` is + Uses an :class:`NDImageSlicer` to produce the data slices and manages an ``ImageGraphic``, + ``ImageYUVGraphic`` or ``ImageVolumeGraphic``, swapping between them when :attr:`display_dims` is reassigned at runtime. It also owns an ``ImguiColorbar`` for interactive vmin, vmax adjustment. - Every dimension that is *not* listed in ``spatial_dims`` becomes a slider + Every dimension that is *not* listed in ``display_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct a change in the ``ReferenceIndex`` and update the graphics. Parameters ---------- - ref_index : ReferenceIndex + ref_index : ReferenceIndices The shared reference index that delivers slider updates to this graphic. nd_subplot : NDWSubplot @@ -339,7 +339,7 @@ def __init__( ex: ``("time", "depth", "row", "col")`` — ``"time"`` and ``"depth"`` must be present in ``ref_index``. - spatial_dims : tuple[str, str] | tuple[str, str, str] + display_dims : tuple[str, str] | tuple[str, str, str] The 2 or 3 spatial dims **in display order**, which also determines the graphic used for rendering: * ``(rows, cols)``, a 2D grayscale ``ImageGraphic`` @@ -349,28 +349,28 @@ def __init__( Reassigning this at runtime swaps the graphic if the number of non-RGB(A) spatial dims changes. rgb_dim : str, optional - Name of the RGB(A) dim, if present. It must be listed in ``spatial_dims`` and be of size 3 or 4. + Name of the RGB(A) dim, if present. It must be listed in ``display_dims`` and be of size 3 or 4. window_funcs : dict, optional - See :class:`NDProcessor`. + See :class:`NDSlicer`. window_order : tuple, optional - See :class:`NDProcessor`. + See :class:`NDSlicer`. spatial_func : callable, optional - See :class:`NDProcessor`. + See :class:`NDSlicer`. compute_histogram : bool, default ``True`` Estimate a histogram of the data and display an ``ImguiColorbar`` on the right edge of the subplot, which is used to interactively set vmin, vmax. Disable if random access of the data is not blazing-fast (ex: data that uses video codecs), or if a histogram is not useful for this data. - slider_dim_transforms : dict, optional - See :class:`NDProcessor`. + slider_maps : dict, optional + See :class:`NDSlicer`. - processor_type : type[NDImageProcessor], default ``NDImageProcessor`` - ``NDImageProcessor`` subclass that manages the data and produces the data slices, ex: - :class:`VideoProcessor`. + slicer_type : type[NDImageSlicer], default ``NDImageSlicer`` + ``NDImageSlicer`` subclass that manages the data and produces the data slices, ex: + :class:`VideoSlicer`. colorspace : "srgb" | "tex-srgb" | "physical" | "yuv420p" | "yuv444p", default "srgb" Colorspace in which to interpret the data. The RGB colorspaces are rendered using an ``ImageGraphic`` @@ -388,31 +388,31 @@ def __init__( See Also -------- - NDImageProcessor : The processor that backs this graphic. + NDImageSlicer : The slicer that backs this graphic. """ - if not (set(dims) - set(spatial_dims)).issubset(ref_index.dims): + if not (set(dims) - set(display_dims)).issubset(ref_index.dims): raise IndexError( f"all specified `dims` must either be a spatial dim or a slider dim " f"specified in the NDWidget ref_ranges, provided dims: {dims}, " - f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" + f"display_dims: {display_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" ) super().__init__(nd_subplot, name) self._ref_index = ref_index - self._processor = processor_type( + self._slicer = slicer_type( data, dims=dims, - spatial_dims=spatial_dims, + display_dims=display_dims, rgb_dim=rgb_dim, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, compute_histogram=compute_histogram, - slider_dim_transforms=slider_dim_transforms, + slider_maps=slider_maps, ) self._colorspace = colorspace @@ -430,9 +430,9 @@ def __init__( run_sync(self._create_graphic()) @property - def processor(self) -> NDImageProcessor: - """NDProcessor that manages the data and produces data slices to display""" - return self._processor + def slicer(self) -> NDImageSlicer: + """NDSlicer that manages the data and produces data slices to display""" + return self._slicer @property def graphic( @@ -445,7 +445,7 @@ async def _create_graphic(self): # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, # adds it to the subplot, and resets the camera and histogram. - if self.processor.data is None: + if self.slicer.data is None: # no graphic if data is None, useful for initializing in null states when we want to set data later return @@ -461,15 +461,15 @@ async def _create_graphic(self): # remove RGB spatial dim, ex: if we have an RGBA image of shape [512, 512, 4] we want to interpet this as # 2D for images # [30, 512, 512, 4] with an rgb dim is an RGBA volume which is also supported - match len(self.processor.spatial_dims) - int(bool(self.processor.rgb_dim)): + match len(self.slicer.display_dims) - int(bool(self.slicer.rgb_dim)): case 2: cls = ImageGraphic case 3: cls = ImageVolumeGraphic # get the data slice for this index - # this will only have the dims specified by ``spatial_dims`` - data_slice = await self.processor.get(self.indices) + # this will only have the dims specified by ``display_dims`` + data_slice = await self.slicer.get(self.indices) # create the new graphic new_graphic = cls( @@ -481,7 +481,7 @@ async def _create_graphic(self): old_graphic = self._graphic # check if we are replacing a graphic - # ex: swapping from 2D <-> 3D representation after ``spatial_dims`` was changed + # ex: swapping from 2D <-> 3D representation after ``display_dims`` was changed if old_graphic is not None: # carry over some attributes from old graphic attrs = dict.fromkeys(["cmap", "interpolation", "cmap_interpolation"]) @@ -509,23 +509,23 @@ def _reset_histogram(self): subplot = self._nd_subplot.subplot - if not self.processor.compute_histogram: + if not self.slicer.compute_histogram: # remove the colorbar from the right edge if a histogram is not desired if self._histogram_widget is not None: subplot.remove_imgui_window("right") self._histogram_widget = None return - if self.processor.histogram: + if self.slicer.histogram: if self._histogram_widget is not None: # colorbar widget exists, update it and rebind to the current graphic - self._histogram_widget.histogram = self.processor.histogram + self._histogram_widget.histogram = self.slicer.histogram self._histogram_widget.images = self.graphic else: # make the colorbar, it reserves space on the subplot's right edge self._histogram_widget = ImguiColorbar( images=self.graphic, - histogram=self.processor.histogram, + histogram=self.slicer.histogram, ) subplot.add_imgui_window( self._histogram_widget, location="right", size=100 @@ -566,17 +566,17 @@ def _reset_camera(self): self._nd_subplot.subplot.auto_scale() @property - def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: + def display_dims(self) -> tuple[str, str] | tuple[str, str, str]: """ - get or set the spatial dims **in order** + Subset of ``dims`` that are spatial (rendered) dimensions **in display order**. [row_dim, col_dim] or [row_dim, col_dim, rgb(a) dim] """ - return self.processor.spatial_dims + return self.slicer.display_dims - @spatial_dims.setter - def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): - self.processor.spatial_dims = dims + @display_dims.setter + def display_dims(self, dims: tuple[str, str] | tuple[str, str, str]): + self.slicer.display_dims = dims # shape has probably changed, recreate graphic run_sync(self._create_graphic()) @@ -584,24 +584,24 @@ def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): @property def indices(self) -> dict[str, Any]: """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" - return {d: self._ref_index[d] for d in self.processor.slider_dims} + return {d: self._ref_index[d] for d in self.slicer.slider_dims} async def _set_indices_(self, indices: dict[str, Any] = None): if indices is None: # current indices, else use the indices passed at schedule time indices = self.indices - self.graphic.data = await self.processor.get(indices) + self.graphic.data = await self.slicer.get(indices) self._last_indices = indices @property def compute_histogram(self) -> bool: """whether or not to compute the histogram and display the ImguiColorbar""" - return self.processor.compute_histogram + return self.slicer.compute_histogram @compute_histogram.setter def compute_histogram(self, v: bool): - self.processor.compute_histogram = v + self.slicer.compute_histogram = v self._reset_histogram() @property @@ -617,14 +617,14 @@ def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: """ # this is here even though it's the same in the base class since we can't create the image specific setter # without also defining the property in this subclass. - return self.processor.spatial_func + return self.slicer.spatial_func @spatial_func.setter def spatial_func( self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: - self.processor.spatial_func = func - self.processor._recompute_histogram() + self.slicer.spatial_func = func + self.slicer._recompute_histogram() self._reset_histogram() def _tooltip_handler(self, graphic, pick_info): @@ -632,4 +632,4 @@ def _tooltip_handler(self, graphic, pick_info): # get graphic within the collection n_index = np.argwhere(self.graphic.graphics == graphic).item() p_index = pick_info["vertex_index"] - return self.processor.tooltip_format(n_index, p_index) + return self.slicer.tooltip_format(n_index, p_index) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py index 978a082c6..4f7104d5a 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py @@ -1,6 +1,6 @@ import importlib -from ._nd_positions import NDPositions, NDPositionsProcessor +from ._nd_positions import NDPositions, NDPositionsSlicer from ._nd_timeseries import NDTimeseries class Extras: @@ -16,9 +16,9 @@ class Extras: pass else: module = importlib.import_module(f"._{optional}", "fastplotlib.widgets.nd_widget._nd_positions") - cls = getattr(module, f"NDPP_{optional.capitalize()}") + cls = getattr(module, f"{optional.capitalize()}Slicer") setattr( ndp_extras, - f"NDPP_{optional.capitalize()}", + f"{optional.capitalize()}", cls ) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 6051321f7..7e65b8fff 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -15,12 +15,12 @@ ScatterStack, ) from .._base import ( - NDProcessor, + NDSlicer, NDGraphic, WindowFuncCallable, ) from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy -from .._index import ReferenceIndex +from .._index import ReferenceIndices from .._async import run_in_thread_pool, run_sync if TYPE_CHECKING: @@ -33,23 +33,23 @@ SizesType = float | Sequence[float] | np.ndarray | FeatureCallable | None -class NDPositionsProcessor(NDProcessor): +class NDPositionsSlicer(NDSlicer): def __init__( self, data: Any, dims: Sequence[str], # TODO: allow stack_dim to be None and auto-add new dim of size 1 in get logic - spatial_dims: tuple[ + display_dims: tuple[ str | None, str, str ], # [stack_dim, n_datapoints, spatial_dim], IN ORDER!! - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, display_window: int | float | None = 100, # window for n_datapoints dim only max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, **kwargs, ): """ - ``NDProcessor`` subclass for n-dimensional positional and timeseries data. + ``NDSlicer`` subclass for n-dimensional positional and timeseries data. Produces ``[n_graphics, p, ]`` slices for a ``LineCollection``, ``LineStack``, ``ScatterCollection``, or ``ScatterStack``, where ``p`` is the datapoints dim. @@ -73,13 +73,13 @@ def __init__( dims in the array do not need to be in the order that you want to display them, the data slice is transposed into the order given by ``spatial_dims``. - spatial_dims : tuple[str, str, str] + display_dims : tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of lines or scatters in the collection, the number of datapoints ``p`` in each of them, and the value dim which holds the xy or xyz coordinate and must be of size 2 or 3. - slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional - See :class:`NDProcessor`. The transform for the ``p`` dim is also used to map ``display_window`` and + slider_maps : dict[str, Callable[[Any], int] | ArrayLike], optional + See :class:`NDSlicer`. The transform for the ``p`` dim is also used to map ``display_window`` and the ``datapoints_window_func`` window size from reference units to array indices. display_window: int, float or None, default 100 @@ -110,12 +110,12 @@ def __init__( compute. kwargs - passed to :class:`NDProcessor`, i.e. ``window_funcs``, ``window_order`` and ``spatial_func``. + passed to :class:`NDSlicer`, i.e. ``window_funcs``, ``window_order`` and ``spatial_func``. See Also -------- - NDProcessor : Base class with full parameter documentation. - NDPositions : The ``NDGraphic`` that uses this processor by default. + NDSlicer : Base class with full parameter documentation. + NDPositions : The ``NDGraphic`` that uses this slicer by default. """ self._display_window = display_window self._max_display_datapoints = max_display_datapoints @@ -123,8 +123,8 @@ def __init__( super().__init__( data=data, dims=dims, - spatial_dims=spatial_dims, - slider_dim_transforms=slider_dim_transforms, + display_dims=display_dims, + slider_maps=slider_maps, **kwargs, ) @@ -149,7 +149,7 @@ def spatial_dims(self) -> tuple[str, str, str]: return self._spatial_dims @spatial_dims.setter - def spatial_dims(self, sdims: tuple[str, str, str]): + def display_dims(self, sdims: tuple[str, str, str]): if len(sdims) != 3: raise IndexError @@ -281,7 +281,7 @@ def _apply_dw_window_func(self, array: ArrayProtocol) -> ArrayProtocol: # display window in array index space if self.display_window is not None: - dw = self.slider_dim_transforms[p_dim](self.display_window) + dw = self.slider_maps[p_dim](self.display_window) # step size based on max number of datapoints to render step = max(1, dw // self.max_display_datapoints) @@ -411,7 +411,7 @@ async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]: class NDPositions(NDGraphic): def __init__( self, - ref_index: ReferenceIndex, + ref_index: ReferenceIndices, nd_subplot: NDWSubplot, data: Any, dims: Sequence[str], @@ -423,14 +423,14 @@ def __init__( | ScatterCollection | ScatterStack ], - processor: type[NDPositionsProcessor] = NDPositionsProcessor, + slicer: type[NDPositionsSlicer] = NDPositionsSlicer, display_window: int | float | None = 10, window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, @@ -442,12 +442,12 @@ def __init__( markers: MarkersType = None, name: str = None, graphic_kwargs: dict = None, - processor_kwargs: dict = None, + slicer_kwargs: dict = None, ): """ ``NDGraphic`` subclass for n-dimensional positional data. - Uses an :class:`NDPositionsProcessor` to produce the data slices and manages one of four interchangeable + Uses an :class:`NDPositionsSlicer` to produce the data slices and manages one of four interchangeable graphical representations: ``LineStack``, ``LineCollection``, ``ScatterStack``, and ``ScatterCollection``. The representation can be changed at runtime by setting :attr:`graphic_type`. @@ -458,7 +458,7 @@ def __init__( Parameters ---------- - ref_index : ReferenceIndex + ref_index : ReferenceIndices The shared reference index that delivers slider updates to this graphic. nd_subplot : NDWSubplot @@ -483,13 +483,13 @@ def __init__( order in the array, the data slice is transposed into display order. args - extra positional arguments passed to the ``processor`` constructor. + extra positional arguments passed to the ``slicer`` constructor. graphic_type : type[LineCollection | LineStack | ScatterCollection | ScatterStack] The graphical representation used to display the data slice. - processor : type[NDPositionsProcessor], default ``NDPositionsProcessor`` - ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + slicer : type[NDPositionsSlicer], default ``NDPositionsSlicer`` + ``NDPositionsSlicer`` subclass that manages the data and produces the data slices. display_window : int, float or None, default 10 Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its @@ -499,18 +499,18 @@ def __init__( window_funcs : dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional Per-slider-dim window functions applied around the current slider position, see - :class:`NDProcessor`. Not used for the ``p`` dim, see ``datapoints_window_func``. + :class:`NDSlicer`. Not used for the ``p`` dim, see ``datapoints_window_func``. window_order : tuple[str, ...], optional Order in which the window functions are applied across dims. Only dims listed here have their window - function applied, see :class:`NDProcessor`. + function applied, see :class:`NDSlicer`. spatial_func : Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice *after* the window funcs, right before rendering. - slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional + slider_maps : dict[str, Callable[[Any], int] | ArrayLike], optional Per-slider-dim mapping from reference-space values to local array indices, see - :class:`NDProcessor`. + :class:`NDSlicer`. max_display_datapoints : int, default 1_000 Maximum number of datapoints to render per graphic. The step size of the display window slice is set @@ -518,7 +518,7 @@ def __init__( datapoints_window_func : tuple[Callable, str, int | float], optional Window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``, see - :class:`NDPositionsProcessor`. + :class:`NDPositionsSlicer`. colors : str | Sequence[str] | np.ndarray | FeatureCallable, optional Colors of the graphics. Mutually exclusive with ``cmap``, setting one clears the other. @@ -569,8 +569,8 @@ def __init__( graphic_kwargs : dict, optional passed to the ``graphic_type`` constructor. - processor_kwargs : dict, optional - passed to the ``processor`` constructor. + slicer_kwargs : dict, optional + passed to the ``slicer`` constructor. Notes ----- @@ -592,7 +592,7 @@ def __init__( See Also -------- - NDPositionsProcessor : The processor that produces the data slices for this graphic. + NDPositionsSlicer : The slicer that produces the data slices for this graphic. """ @@ -605,12 +605,12 @@ def __init__( spatial_dims, *args, graphic_type=graphic_type, - processor=processor, + slicer=slicer, display_window=display_window, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, - slider_dim_transforms=slider_dim_transforms, + slider_maps=slider_maps, max_display_datapoints=max_display_datapoints, datapoints_window_func=datapoints_window_func, colors=colors, @@ -621,14 +621,14 @@ def __init__( sizes=sizes, markers=markers, graphic_kwargs=graphic_kwargs, - processor_kwargs=processor_kwargs, + slicer_kwargs=slicer_kwargs, ) run_sync(self._create_graphic()) def init( self, - ref_index: ReferenceIndex, + ref_index: ReferenceIndices, data: Any, dims: Sequence[str], spatial_dims: tuple[str, str, str], @@ -639,14 +639,14 @@ def init( | ScatterCollection | ScatterStack ], - processor: type[NDPositionsProcessor] = NDPositionsProcessor, + slicer: type[NDPositionsSlicer] = NDPositionsSlicer, display_window: int | float | None = 10, window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, @@ -657,25 +657,25 @@ def init( sizes: SizesType = None, markers: MarkersType = None, graphic_kwargs: dict = None, - processor_kwargs: dict = None, + slicer_kwargs: dict = None, ): """ - Set up the processor and per-graphic state, i.e. everything except creating the graphic. + Set up the slicer and per-graphic state, i.e. everything except creating the graphic. Separated from ``__init__`` so ``NDTimeseries`` can run its own one-time setup between this and graphic creation. """ self._ref_index = ref_index - if processor_kwargs is None: - processor_kwargs = dict() + if slicer_kwargs is None: + slicer_kwargs = dict() if graphic_kwargs is None: self._graphic_kwargs = dict() else: self._graphic_kwargs = graphic_kwargs - self._processor = processor( + self._slicer = slicer( data, dims, spatial_dims, @@ -686,13 +686,13 @@ def init( window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, - slider_dim_transforms=slider_dim_transforms, - **processor_kwargs, + slider_maps=slider_maps, + **slicer_kwargs, ) self._graphic_type = graphic_type - # each feature is either windowed per-datapoint (into the processor) or static (onto + # each feature is either windowed per-datapoint (into the slicer) or static (onto # the collection); _set_feature routes and stores it for re-creation on a type switch self._static_features: dict[str, Any] = dict() features = { @@ -712,7 +712,7 @@ def _set_feature(self, name: str, value): Route a graphic feature to the collection. A callable, or an array with the datapoint dim (``p``) at axis 1, is windowed - per-datapoint by the processor and set onto the collection each frame. Anything else is + per-datapoint by the slicer and set onto the collection each frame. Anything else is static: it is stored and set once onto the collection. """ if value is not None: @@ -721,13 +721,13 @@ def _set_feature(self, name: str, value): if self._is_windowed(value): self._static_features.pop(name, None) - self.processor.set_other_feature(name, value) + self.slicer.set_other_feature(name, value) if self._graphic is not None: run_sync(self._set_indices_()) return # static: clear any windowed version, store, and set it onto the collection - self.processor.set_other_feature(name, None) + self.slicer.set_other_feature(name, None) if value is None: self._static_features.pop(name, None) return @@ -736,10 +736,10 @@ def _set_feature(self, name: str, value): setattr(self.graphic, name, value) def _get_feature(self, name: str): - # the static value, or the windowed value held by the processor + # the static value, or the windowed value held by the slicer if name in self._static_features: return self._static_features[name] - return self.processor._other_features.get(name) + return self.slicer._other_features.get(name) def _clear_conflicting_color_source(self, name: str): # a graphic's color is either explicit `colors` or a colormap, never both @@ -751,7 +751,7 @@ def _clear_conflicting_color_source(self, name: str): return for other in conflicting: self._static_features.pop(other, None) - self.processor.set_other_feature(other, None) + self.slicer.set_other_feature(other, None) def _is_windowed(self, value) -> bool: # windowed features are per-datapoint and sliced to the display window each frame: a @@ -761,7 +761,7 @@ def _is_windowed(self, value) -> bool: return True if isinstance(value, (list, tuple, np.ndarray)): value = np.asarray(value) - p_size = self.processor.shape[self.processor.spatial_dims[1]] + p_size = self.slicer.shape[self.slicer.spatial_dims[1]] return value.ndim >= 2 and value.shape[1] == p_size return False @@ -771,7 +771,7 @@ def _cmap_range(self): # isn't knowable without evaluating it everywhere, so that needs an explicit cmap_range if "cmap_range" in self._static_features: return self._static_features["cmap_range"] - transform = self.processor._other_features.get("cmap_transform") + transform = self.slicer._other_features.get("cmap_transform") if not isinstance(transform, np.ndarray): return None if transform.ndim == 1: @@ -779,9 +779,9 @@ def _cmap_range(self): return np.stack([transform.min(axis=1), transform.max(axis=1)], axis=1) @property - def processor(self) -> NDPositionsProcessor: - """NDProcessor that manages the data and produces data slices to display""" - return self._processor + def slicer(self) -> NDPositionsSlicer: + """NDSlicer that manages the data and produces data slices to display""" + return self._slicer @property def graphic( @@ -826,21 +826,21 @@ def spatial_dims(self) -> tuple[str, str, str]: Get or set the spatial dims **in display order**: ``(n_graphics, p, )``. Setting them re-renders the current data slice. """ - return self.processor.spatial_dims + return self.slicer.spatial_dims @spatial_dims.setter def spatial_dims(self, dims: tuple[str, str, str]): - self.processor.spatial_dims = dims + self.slicer.display_dims = dims # force re-render run_sync(self._set_indices_()) @property def indices(self) -> dict[Hashable, Any]: """the current index of each slider dim in reference-space units, from the ``ReferenceIndex``""" - return {d: self._ref_index[d] for d in self.processor.slider_dims} + return {d: self._ref_index[d] for d in self.slicer.slider_dims} async def _get_data_slice(self, indices: dict[str, Any]) -> dict[str, Any]: - return await self.processor.get(indices) + return await self.slicer.get(indices) async def _set_indices_(self, indices: dict[str, Any] = None): if self.data is None: @@ -889,7 +889,7 @@ def _tooltip_handler(self, graphic, pick_info): # get graphic within the collection n_index = np.argwhere(self.graphic.graphics == graphic).item() p_index = pick_info["vertex_index"] - return self.processor.tooltip_format(n_index, p_index) + return self.slicer.tooltip_format(n_index, p_index) async def _create_graphic(self): if self.data is None: @@ -913,7 +913,7 @@ def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): ) self._set_other_features(new_features) - if self.processor.tooltip: + if self.slicer.tooltip: for g in self._graphic.graphics: g.tooltip_format = partial(self._tooltip_handler, g) @@ -925,11 +925,11 @@ def display_window(self) -> int | float | None: Get or set the display window, in the reference units of the ``p`` dim. Setting it re-renders the current data slice. """ - return self.processor.display_window + return self.slicer.display_window @display_window.setter def display_window(self, dw: int | float | None): - self.processor.display_window = dw + self.slicer.display_window = dw # force re-render run_sync(self._set_indices_()) @@ -942,11 +942,11 @@ def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: ``"all", "x", "y", "z", "xy", "xz", "yz", "xyz"``. ``window_size`` is in the reference units of the ``p`` dim. """ - return self.processor.datapoints_window_func + return self.slicer.datapoints_window_func @datapoints_window_func.setter def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]): - self.processor.datapoints_window_func = funcs + self.slicer.datapoints_window_func = funcs @property def colors(self): diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py index da6e8fdc8..a23f95bbf 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py @@ -17,11 +17,11 @@ from ....graphics.selectors import LinearSelector from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy from .._base import NDGraphic, WindowFuncCallable, block_indices_ctx -from .._index import ReferenceIndex +from .._index import ReferenceIndices from .._async import run_sync from ._nd_positions import ( NDPositions, - NDPositionsProcessor, + NDPositionsSlicer, ColorsType, SizesType, MarkersType, @@ -35,11 +35,11 @@ class NDTimeseries(NDPositions): def __init__( self, - ref_index: ReferenceIndex, + ref_index: ReferenceIndices, nd_subplot: NDWSubplot, data: Any, dims: Sequence[str], - spatial_dims: tuple[str, str, str], + display_dims: tuple[str, str, str], *args, graphic_type: Type[ LineCollection @@ -48,14 +48,14 @@ def __init__( | ScatterStack | ImageGraphic ] = LineStack, - processor: type[NDPositionsProcessor] = NDPositionsProcessor, + slicer: type[NDPositionsSlicer] = NDPositionsSlicer, display_window: int | float | None = 10, window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, linear_selector: bool = False, @@ -69,7 +69,7 @@ def __init__( markers: MarkersType = None, name: str = None, graphic_kwargs: dict = None, - processor_kwargs: dict = None, + slicer_kwargs: dict = None, ): """ ``NDPositions`` subclass for timeseries data, where the ``p`` dim is a time-like x-axis. @@ -80,7 +80,7 @@ def __init__( Parameters ---------- - ref_index : ReferenceIndex + ref_index : ReferenceIndices The shared reference index that delivers slider updates to this graphic. nd_subplot : NDWSubplot @@ -91,7 +91,7 @@ def __init__( time-like coordinate. Ex: an array of shape ``[n_trials, n_traces, n_timepoints, 2]`` with ``dims`` of - ``("trial", "trace", "time", "xy")`` and ``spatial_dims`` of ``("trace", "time", "xy")``. + ``("trial", "trace", "time", "xy")`` and ``display_dims`` of ``("trace", "time", "xy")``. Pass ``None`` to create the ``NDTimeseries`` without a graphic and set the data later using :attr:`data`. @@ -99,13 +99,13 @@ def __init__( dims : Sequence[str] Name for every dimension of ``data``, in order. Non-spatial dims must match keys in ``ref_index``. - spatial_dims : tuple[str, str, str] + display_dims : tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of traces in the collection, the number of datapoints ``p`` in each of them, and the value dim which holds the xy or xyz coordinate. A heatmap requires a value dim of size exactly 2. args - extra positional arguments passed to the ``processor`` constructor. + extra positional arguments passed to the ``slicer`` constructor. graphic_type : type[LineCollection | LineStack | ScatterCollection | ScatterStack | ImageGraphic], default ``LineStack`` The graphical representation used to display the data slice. ``ImageGraphic`` renders the traces as a @@ -113,8 +113,8 @@ def __init__( applied as the offset and scale of the image, and the y values are interpolated onto a uniform x grid if the x sampling is not uniform. - processor : type[NDPositionsProcessor], default ``NDPositionsProcessor`` - ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + slicer : type[NDPositionsSlicer], default ``NDPositionsSlicer`` + ``NDPositionsSlicer`` subclass that manages the data and produces the data slices. display_window : int, float or None, default 10 Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its @@ -124,18 +124,18 @@ def __init__( window_funcs : dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional Per-slider-dim window functions applied around the current slider position, see - :class:`NDProcessor`. Not used for the ``p`` dim, see ``datapoints_window_func``. + :class:`NDSlicer`. Not used for the ``p`` dim, see ``datapoints_window_func``. window_order : tuple[str, ...], optional Order in which the window functions are applied across dims. Only dims listed here have their window - function applied, see :class:`NDProcessor`. + function applied, see :class:`NDSlicer`. spatial_func : Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice *after* the window funcs, right before rendering. - slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional + slider_maps : dict[str, Callable[[Any], int] | ArrayLike], optional Per-slider-dim mapping from reference-space values to local array indices, see - :class:`NDProcessor`. The transform for the ``p`` dim is typically the array of x values, ex: a + :class:`NDSlicer`. The transform for the ``p`` dim is typically the array of x values, ex: a timestamps array, so the slider is in seconds rather than sample indices. max_display_datapoints : int, default 1_000 @@ -144,7 +144,7 @@ def __init__( datapoints_window_func : tuple[Callable, str, int | float], optional Window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``, see - :class:`NDPositionsProcessor`. + :class:`NDPositionsSlicer`. linear_selector : bool, default ``False`` Add a ``LinearSelector`` that marks the current index of the ``p`` dim. Dragging it sets that index @@ -211,8 +211,8 @@ def __init__( graphic_kwargs : dict, optional passed to the ``graphic_type`` constructor. - processor_kwargs : dict, optional - passed to the ``processor`` constructor. + slicer_kwargs : dict, optional + passed to the ``slicer`` constructor. Notes ----- @@ -246,15 +246,15 @@ def __init__( ref_index, data, dims, - spatial_dims, + display_dims, *args, graphic_type=graphic_type, - processor=processor, + slicer=slicer, display_window=display_window, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, - slider_dim_transforms=slider_dim_transforms, + slider_maps=slider_maps, max_display_datapoints=max_display_datapoints, datapoints_window_func=datapoints_window_func, colors=colors, @@ -265,7 +265,7 @@ def __init__( sizes=sizes, markers=markers, graphic_kwargs=graphic_kwargs, - processor_kwargs=processor_kwargs, + slicer_kwargs=slicer_kwargs, ) # makes some assumptions about positional data that apply only to timeseries representations @@ -274,9 +274,9 @@ def __init__( # determine a min display_window for x_range_mode = "auto" # determines required world space range for 3 datapoints - p_dim = self.processor.spatial_dims[1] + p_dim = self.slicer.display_dims[1] p_range = self._ref_index.ref_ranges[p_dim] - p_map = self.processor.slider_dim_transforms[p_dim] + p_map = self.slicer.slider_maps[p_dim] p_span = p_range.stop - p_range.start p_mid = p_range.start + p_span / 2 i = p_map(p_mid) @@ -285,7 +285,7 @@ def __init__( self._min_display_window = 3 * delta_p # display_window = None overrides x_range_mode - if self.processor.display_window is None: + if self.slicer.display_window is None: x_range_mode = None self._x_range_mode = None @@ -322,7 +322,7 @@ def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): if issubclass(self._graphic_type, ImageGraphic): data_slice = new_features["data"] # `d` dim must only have xy data to be interpreted as a heatmap, xyz can't become a timeseries heatmap - if self.processor.shape[self.processor.spatial_dims[-1]] != 2: + if self.slicer.shape[self.slicer.display_dims[-1]] != 2: raise ValueError image_data, x0, x_scale = self._create_heatmap_data(data_slice) @@ -348,11 +348,11 @@ async def _create_graphic(self): async def _p_y_max(self) -> np.ndarray: """per-graphic max of the y values over the full `p` dim, shape [n_graphics]""" - proc = self.processor + proc = self.slicer # the indexer leaves the spatial `p` dim unsliced, so this raw slice spans every datapoint raw = await proc._get_raw_data_slice(self.indices) - c = proc.dims.index(proc.spatial_dims[2]) # coord dim; y is index 1 - g = proc.dims.index(proc.spatial_dims[0]) # graphics dim + c = proc.dims.index(proc.display_dims[2]) # coord dim; y is index 1 + g = proc.dims.index(proc.display_dims[0]) # graphics dim y = raw[(slice(None),) * c + (1,)] # y values as a view, coord dim removed # keep the graphics dim (shifted down if it was past the removed coord dim), max the rest; # `.max` runs on whatever the array is (numpy/cupy/torch/jax), so a GPU array reduces on-device @@ -366,12 +366,12 @@ async def _p_y_max(self) -> np.ndarray: def _update_view(self, indices: dict[str, Any], data_slice: np.ndarray): """update the camera x-range and linear selector to the current datapoints position.""" - p_dim = self.processor.spatial_dims[1] + p_dim = self.slicer.display_dims[1] if self.x_range_mode is not None: # set x_range directly from the display_window, NOT from the data_slice x-range, # this way it doesn't fight with the _update_from_view_range() polling - hw = self.processor.display_window / 2 + hw = self.slicer.display_window / 2 center = indices[p_dim] self._nd_subplot.subplot.x_range = center - hw, center + hw # store new x_range so the auto-polling does not trigger @@ -392,7 +392,7 @@ def _linear_selector_handler(self, ev): with block_indices_ctx(*self._nd_subplot.nd_graphics): # block index change in all NDGraphics that are not in the same subplot self._ref_index.set_dim_index( - self.processor.spatial_dims[1], ev.info["value"] + self.slicer.display_dims[1], ev.info["value"] ) def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: @@ -432,11 +432,11 @@ def display_window(self) -> int | float | None: Get or set the display window, in the reference units of the ``p`` dim. Setting it re-renders the current data slice, setting it to ``None`` also sets :attr:`x_range_mode` to ``None``. """ - return self.processor.display_window + return self.slicer.display_window @display_window.setter def display_window(self, dw: int | float | None): - self.processor.display_window = dw + self.slicer.display_window = dw if dw is None: self.x_range_mode = None @@ -496,11 +496,11 @@ def _update_from_view_range(self): new_index = (xr[0] + xr[1]) / 2 - self.processor.display_window = new_width + self.slicer.display_window = new_width # block scheduling an additional async _set_indices_ for ndgraphics in this subplot with block_indices_ctx(*self._nd_subplot.nd_graphics): - p_dim = self.processor.spatial_dims[1] + p_dim = self.slicer.display_dims[1] self._ref_index.set_dim_index(p_dim, new_index) # run this ndgraphic update immediately so graphic data and linear selector are in sync with the diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index 07d8913e7..b7e5375c0 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -3,27 +3,27 @@ import numpy as np import pandas as pd -from ._nd_positions import NDPositionsProcessor +from ._nd_positions import NDPositionsSlicer -class NDPP_Pandas(NDPositionsProcessor): +class PandasSlicer(NDPositionsSlicer): def __init__( self, data: pd.DataFrame, - spatial_dims: tuple[str, str, str], # [l, p, d] dims in order + display_dims: tuple[str, str, str], # [l, p, d] dims in order columns: list[tuple[str, str] | tuple[str, str, str]], tooltip_columns: list[str] = None, **kwargs, ): """ - ``NDPositionsProcessor`` subclass that reads positional data from the columns of a ``pandas.DataFrame`` + ``NDPositionsSlicer`` subclass that reads positional data from the columns of a ``pandas.DataFrame`` instead of an n-dimensional array. Each entry in ``columns`` names the columns that hold the coordinates of one graphic, so the number of entries is the number of graphics in the collection and the number of rows is the size of the ``p`` dim. There are no additional slider dims, ``p`` is the only one. - Available as ``ndp_extras.NDPP_Pandas`` when ``pandas`` is installed, pass it as the ``processor`` to + Available as ``ndp_extras.NDPP_Pandas`` when ``pandas`` is installed, pass it as the ``slicer`` to ``NDWSubplot.add_nd_lines()``, ``add_nd_scatter()`` or ``add_nd_timeseries()``. Parameters @@ -31,7 +31,7 @@ def __init__( data: pd.DataFrame DataFrame holding the coordinates, one column per coordinate of each graphic. - spatial_dims: tuple[str, str, str] + display_dims: tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_graphics, p, )``. These are also used as the ``dims``, since a DataFrame has no other dims to name. @@ -45,7 +45,7 @@ def __init__( tooltip, ex: a per-keypoint likelihood column. Must be the same length as ``columns``. kwargs - passed to :class:`.NDPositionsProcessor`, i.e. ``display_window``, ``max_display_datapoints``, + passed to :class:`.NDPositionsSlicer`, i.e. ``display_window``, ``max_display_datapoints``, ``slider_dim_transforms``, ``datapoints_window_func`` and ``spatial_func``. """ @@ -62,8 +62,8 @@ def __init__( super().__init__( data=data, - dims=spatial_dims, - spatial_dims=spatial_dims, + dims=display_dims, + display_dims=display_dims, **kwargs, ) @@ -88,7 +88,7 @@ def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: @property def dims(self) -> tuple[str, str, str]: - """dim names, the same as :attr:`spatial_dims` since a DataFrame has no other dims""" + """dim names, the same as :attr:`display_dims` since a DataFrame has no other dims""" return self._dims @property diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 9bf3c7fa2..52e3a35d5 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -13,32 +13,32 @@ ) from ...graphics import VectorsGraphic from ._base import ( - NDProcessor, + NDSlicer, NDGraphic, WindowFuncCallable, ) -from ._index import ReferenceIndex +from ._index import ReferenceIndices from ._async import run_in_thread_pool, run_sync if TYPE_CHECKING: from ._ndw_subplot import NDWSubplot -class NDVectorsProcessor(NDProcessor): +class NDVectorsSlicer(NDSlicer): def __init__( self, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: tuple[str, str, str], # must be in order! [n_vectors, positions & directions, xy(z)] + display_dims: tuple[str, str, str], # must be in order! [n_vectors, positions & directions, xy(z)] window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayLike], ArrayLike] = None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, ): """ - ``NDProcessor`` subclass for n-dimensional vector data. + ``NDSlicer`` subclass for n-dimensional vector data. Produces ``[n_vectors, 2, 2 | 3]`` slices for a ``VectorsGraphic``. The last two dims describe the position/direction and the 2D/3D spatial coordinate, respectively. @@ -50,47 +50,47 @@ def __init__( gives the vector positions and index ``1`` gives the vector directions. Ex: an electric field sampled over time, an array of shape ``[n_timepoints, n_vectors, 2, 2]`` with - ``dims`` of ``("time", "n_vectors", "pos_dir", "xy")`` and ``spatial_dims`` of + ``dims`` of ``("time", "n_vectors", "pos_dir", "xy")`` and ``display_dims`` of ``("n_vectors", "pos_dir", "xy")``. dims: Sequence[str] names for each dimension in ``data``. Dimensions not listed in - ``spatial_dims`` are treated as slider dimensions and **must** appear as + ``display_dims`` are treated as slider dimensions and **must** appear as keys in the parent ``NDWidget``'s ``ref_ranges``. dims in the array do not need to be in the order that you want to display them, the data slice is - transposed into the order given by ``spatial_dims``. + transposed into the order given by ``display_dims``. - spatial_dims : tuple[str, str, str] + display_dims : tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_vectors, positions & directions, xy(z))``. The positions/directions dim must be of size 2 and the coordinate dim of size 2 or 3. - slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional + slider_maps : dict[str, Callable[[Any], int] | ArrayLike], optional Per-slider-dim mapping from reference-space values to local array indices, see - :class:`NDProcessor`. + :class:`NDSlicer`. window_funcs : dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional Per-slider-dim window functions applied around the current slider position, see - :class:`NDProcessor`. + :class:`NDSlicer`. window_order : tuple[str, ...], optional Order in which the window functions are applied across dims. Only dims listed here have their window - function applied, see :class:`NDProcessor`. + function applied, see :class:`NDSlicer`. spatial_func : Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice *after* the window funcs, right before rendering. See Also -------- - NDProcessor : Base class with full parameter documentation. + NDSlicer : Base class with full parameter documentation. NDVectors : The ``NDGraphic`` that uses this processor by default. """ super().__init__( data=data, dims=dims, - spatial_dims=spatial_dims, - slider_dim_transforms=slider_dim_transforms, + display_dims=display_dims, + slider_maps=slider_maps, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, @@ -121,15 +121,15 @@ def data(self, data: ArrayProtocol): self._data = data @property - def spatial_dims(self) -> tuple[str, str, str]: + def display_dims(self) -> tuple[str, str, str]: """ Spatial dims, **in display order**: ``(n_vectors, positions & directions, xy(z))``, so the data slice is of shape ``[n_vectors, 2, 2 | 3]`` """ - return self._spatial_dims + return self._display_dims - @spatial_dims.setter - def spatial_dims(self, sdims: tuple[str, str, str]): + @display_dims.setter + def display_dims(self, sdims: tuple[str, str, str]): for dim in sdims: if dim not in self.dims: raise KeyError @@ -139,10 +139,10 @@ def spatial_dims(self, sdims: tuple[str, str, str]): f"There must be exactly 3 spatial dims for vectors indicating [num_vectors, 2, 2] or [num_vectors, 2, 3] " ) - self._spatial_dims = tuple(sdims) + self._display_dims = tuple(sdims) - if self.shape[self.spatial_dims[-2]] != 2 or self.shape[ - self.spatial_dims[-1] + if self.shape[self.display_dims[-2]] != 2 or self.shape[ + self.display_dims[-1] ] not in (2, 3): raise ValueError( f"Spatial dimensions must haves shape (num_vecs, 2, [2 or 3]) you passed {sdims}" @@ -164,7 +164,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: Returns ------- ArrayProtocol - data slice of shape ``[n_vectors, 2, 2 | 3]``, transposed into the ``spatial_dims`` display order + data slice of shape ``[n_vectors, 2, 2 | 3]``, transposed into the ``display_dims`` display order """ # this will be squeezed output, with dims in the order of self.dims @@ -178,7 +178,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: window_output = await run_in_thread_pool( self._executor, self._spatial_func, window_output ) - if window_output.ndim != len(self.spatial_dims): + if window_output.ndim != len(self.display_dims): raise ValueError # final CUDA -> numpy conversion at the end of the pipeline @@ -191,11 +191,11 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: class NDVectors(NDGraphic): def __init__( self, - ref_index: ReferenceIndex, + ref_index: ReferenceIndices, nd_subplot: NDWSubplot, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: tuple[ + display_dims: tuple[ str, str, str ], # must be in order! [n_vectors, positions & directions, xy(z)] window_funcs: dict[ @@ -203,23 +203,23 @@ def __init__( ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, name: str = None, graphic_kwargs: dict = None, ): """ ``NDGraphic`` subclass for n-dimensional vector rendering. - Uses an :class:`NDVectorsProcessor` to produce the data slices and manages a :class:`.VectorsGraphic`. + Uses an :class:`NDVectorsSlicer` to produce the data slices and manages a :class:`.VectorsGraphic`. - Every dimension that is *not* listed in ``spatial_dims`` becomes a slider + Every dimension that is *not* listed in ``display_dims`` becomes a slider dimension. Each slider dim must have a ``ReferenceRange`` defined in the ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct a change in the ``ReferenceIndex`` and update the graphics. Parameters ---------- - ref_index : ReferenceIndex + ref_index : ReferenceIndices The shared reference index that delivers slider updates to this graphic. nd_subplot : NDWSubplot @@ -230,7 +230,7 @@ def __init__( gives the vector positions and index ``1`` gives the vector directions. Ex: an electric field sampled over time, an array of shape ``[n_timepoints, n_vectors, 2, 2]`` with - ``dims`` of ``("time", "n_vectors", "pos_dir", "xy")`` and ``spatial_dims`` of + ``dims`` of ``("time", "n_vectors", "pos_dir", "xy")`` and ``display_dims`` of ``("n_vectors", "pos_dir", "xy")``. Pass ``None`` to create the ``NDVectors`` without a graphic and set the data later using @@ -239,24 +239,24 @@ def __init__( dims : Sequence[str] Name for every dimension of ``data``, in order. Non-spatial dims must match keys in ``ref_index``. - spatial_dims : tuple[str, str, str] + display_dims : tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_vectors, positions & directions, xy(z))``. The positions/directions dim must be of size 2 and the coordinate dim of size 2 or 3. window_funcs : dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional Per-slider-dim window functions applied around the current slider position, see - :class:`NDProcessor`. + :class:`NDSlicer`. window_order : tuple[str, ...], optional Order in which the window functions are applied across dims. Only dims listed here have their window - function applied, see :class:`NDProcessor`. + function applied, see :class:`NDSlicer`. spatial_func : Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice *after* the window funcs, right before rendering. - slider_dim_transforms : dict[str, Callable[[Any], int] | ArrayLike], optional + slider_maps : dict[str, Callable[[Any], int] | ArrayLike], optional Per-slider-dim mapping from reference-space values to local array indices, see - :class:`NDProcessor`. + :class:`NDSlicer`. name : str, optional Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. @@ -266,29 +266,29 @@ def __init__( See Also -------- - NDVectorsProcessor : The processor that produces the data slices for this graphic. + NDVectorsSlicer : The slicer that produces the data slices for this graphic. """ - if not (set(dims) - set(spatial_dims)).issubset(ref_index.dims): + if not (set(dims) - set(display_dims)).issubset(ref_index.dims): raise IndexError( f"all specified `dims` must either be a spatial dim or a slider dim " f"specified in the NDWidget ref_ranges, provided dims: {dims}, " - f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" + f"display_dims: {display_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" ) super().__init__(nd_subplot, name) self._ref_index = ref_index - self._processor = NDVectorsProcessor( + self._slicer = NDVectorsSlicer( data, dims=dims, - spatial_dims=spatial_dims, + display_dims=display_dims, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, - slider_dim_transforms=slider_dim_transforms, + slider_maps=slider_maps, ) self._graphic: VectorsGraphic | None = None @@ -302,9 +302,9 @@ def __init__( run_sync(self._create_graphic()) @property - def processor(self) -> NDVectorsProcessor: - """NDProcessor that manages the data and produces data slices to display""" - return self._processor + def slicer(self) -> NDVectorsSlicer: + """NDSlicer that manages the data and produces data slices to display""" + return self._slicer @property def graphic( @@ -317,13 +317,13 @@ async def _create_graphic(self): # Creates a ``VectorsGraphic`` from the current data slice, replacing any existing one, and adds it # to the subplot. - if self.processor.data is None: + if self.slicer.data is None: # no graphic if data is None, useful for initializing in null states when we want to set data later return # get the data slice for this index - # this will only have the dims specified by ``spatial_dims`` - data_slice = await self.processor.get(self.indices) + # this will only have the dims specified by ``display_dims`` + data_slice = await self.slicer.get(self.indices) old_graphic = self._graphic # check if we are replacing a graphic @@ -341,16 +341,16 @@ async def _create_graphic(self): self._nd_subplot.subplot.add_graphic(self._graphic) @property - def spatial_dims(self) -> tuple[str, str, str]: + def display_dims(self) -> tuple[str, str, str]: """ Get or set the spatial dims **in display order**: ``(n_vectors, positions & directions, xy(z))``, so the data slice is of shape ``[n_vectors, 2, 2 | 3]``. Setting them recreates the graphic. """ - return self.processor.spatial_dims + return self.slicer.display_dims - @spatial_dims.setter - def spatial_dims(self, dims: tuple[str, str, str]): - self.processor.spatial_dims = dims + @display_dims.setter + def display_dims(self, dims: tuple[str, str, str]): + self.slicer.display_dims = dims # shape has probably changed, recreate graphic run_sync(self._create_graphic()) @@ -358,14 +358,14 @@ def spatial_dims(self, dims: tuple[str, str, str]): @property def indices(self) -> dict[str, Any]: """the current index of each slider dim in reference-space units, from the ``ReferenceIndex``""" - return {d: self._ref_index[d] for d in self.processor.slider_dims} + return {d: self._ref_index[d] for d in self.slicer.slider_dims} async def _set_indices_(self, indices: dict[str, Any] = None): if indices is None: # use latest indices if None, else use passed indices from schedule time indices = self.indices - data_slice = await self.processor.get(indices) + data_slice = await self.slicer.get(indices) self.graphic.positions = data_slice[:, 0] self.graphic.directions = data_slice[:, 1] @@ -378,10 +378,10 @@ def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: """ # this is here even though it's the same in the base class since we can't create the image specific setter # without also defining the property in this subclass. - return self.processor.spatial_func + return self.slicer.spatial_func @spatial_func.setter def spatial_func( self, func: Callable[[ArrayProtocol], ArrayProtocol] ) -> Callable | None: - self.processor.spatial_func = func + self.slicer.spatial_func = func diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 9e35a9c27..67c6e5750 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -14,16 +14,16 @@ ) from ...layouts import Subplot from ...utils import ArrayProtocol, enums -from . import NDImageProcessor, NDImage, NDPositions, NDTimeseries, NDVectors +from . import NDImageSlicer, NDImage, NDPositions, NDTimeseries, NDVectors from ._nd_positions._nd_positions import ( - NDPositionsProcessor, + NDPositionsSlicer, ColorsType, FeatureCallable, MarkersType, SizesType, ) from ._index import AutoRangeContinuous -from ._video import VideoProcessor +from ._video import VideoSlicer from ._base import NDGraphic, WindowFuncCallable @@ -79,7 +79,7 @@ def delete_nd_graphic(self, ndg: NDGraphic): def _check_slider_dims( self, dims: Sequence[Hashable], - spatial_dims: Sequence[Hashable], + display_dims: Sequence[Hashable], data: ArrayProtocol | None, positions: bool = False, ): @@ -94,10 +94,10 @@ def _check_slider_dims( return dims = tuple(dims) - slider_dims = set(dims) - set(spatial_dims) + slider_dims = set(dims) - set(display_dims) if positions: # the datapoints `p` axis is a spatial dim that also needs a reference range - slider_dims.add(spatial_dims[1]) + slider_dims.add(display_dims[1]) for dim in slider_dims: size = data.shape[dims.index(dim)] @@ -119,7 +119,7 @@ def add_nd_image( self, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: ( + display_dims: ( tuple[str, str] | tuple[str, str, str] ), # must be in order! [rows, cols] | [z, rows, cols] rgb_dim: str | None = None, @@ -129,8 +129,8 @@ def add_nd_image( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, compute_histogram: bool = True, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, - processor_type: type[NDImageProcessor] = NDImageProcessor, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, + slicer_type: type[NDImageSlicer] = NDImageSlicer, colorspace: Literal[ "srgb", "tex-srgb", "physical", "yuv420p", "yuv444p" ] = "srgb", @@ -141,7 +141,7 @@ def add_nd_image( """ Add an n-dimensional image or volume to this subplot. - Every dim that is not listed in ``spatial_dims`` becomes a slider dim. + Every dim that is not listed in ``display_dims`` becomes a slider dim. Parameters ---------- @@ -152,9 +152,9 @@ def add_nd_image( dims: Sequence[str] name for every dim of ``data``, in order. They do not need to be in display order, ex: an array whose - dims are ``("col", "depth", "row", "time")`` with ``spatial_dims`` of ``("row", "col")``. + dims are ``("col", "depth", "row", "time")`` with ``display_dims`` of ``("row", "col")``. - spatial_dims: tuple[str, str] | tuple[str, str, str] + display_dims: tuple[str, str] | tuple[str, str, str] The 2 or 3 spatial dims **in display order**, which also determines the graphic used for rendering: * ``(rows, cols)``, a 2D grayscale ``ImageGraphic`` @@ -162,7 +162,7 @@ def add_nd_image( * ``(z, rows, cols)``, a 3D ``ImageVolumeGraphic`` rgb_dim: str, optional - Name of the RGB(A) dim, if present. It must be listed in ``spatial_dims`` and be of size 3 or 4. + Name of the RGB(A) dim, if present. It must be listed in ``display_dims`` and be of size 3 or 4. window_funcs: dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional Per-slider-dim window functions applied around the current slider position, ex: @@ -186,14 +186,14 @@ def add_nd_image( which is used to interactively set vmin, vmax. Disable if random access of the data is not blazing-fast (ex: data that uses video codecs), or if a histogram is not useful for this data. - slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None, optional + slider_maps: dict mapping dim_name -> Callable, an ArrayLike, or None, optional Per-slider-dim mapping from reference-space values to local array indices. An array of reference values may be given instead of a callable, ``searchsorted`` is then used as the transform (ex: a timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference value is rounded to the nearest integer and used as the array index. - processor_type: type[NDImageProcessor], default ``NDImageProcessor`` - ``NDImageProcessor`` subclass that manages the data and produces the data slices. + slicer_type: type[NDImageSlicer], default ``NDImageSlicer`` + ``NDImageSlicer`` subclass that manages the data and produces the data slices. colorspace: "srgb" | "tex-srgb" | "physical" | "yuv420p" | "yuv444p", default "srgb" Colorspace in which to interpret the data. The RGB colorspaces are rendered using an ``ImageGraphic`` @@ -214,21 +214,21 @@ def add_nd_image( NDImage """ - self._check_slider_dims(dims, spatial_dims, data) + self._check_slider_dims(dims, display_dims, data) nd = NDImage( self.ndw.indices, nd_subplot=self, data=data, dims=dims, - spatial_dims=spatial_dims, + display_dims=display_dims, rgb_dim=rgb_dim, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, compute_histogram=compute_histogram, - slider_dim_transforms=slider_dim_transforms, - processor_type=processor_type, + slider_maps=slider_maps, + slicer_type=slicer_type, colorspace=colorspace, colorrange=colorrange, name=name, @@ -242,18 +242,18 @@ def add_video( self, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: tuple[str, str] | tuple[str, str, str], + display_dims: tuple[str, str] | tuple[str, str, str], rgb_dim: str | None = None, colorspace: enums.ColorspacesYUV | enums.ColorspacesRGB = "yuv420p", colorrange: enums.ColorRange = "limited", - processor_type: NDImageProcessor = VideoProcessor, + slicer_type: NDImageSlicer = VideoSlicer, window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, compute_histogram: bool = True, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, name: str = None, graphic_kwargs: dict = None, ) -> NDImage: @@ -267,7 +267,7 @@ def add_video( We strongly recommend using ``asyncvideo`` for the ``data`` object, it is the most efficient async video reader that we know of for visualization purposes: https://pypi.org/project/asyncvideo/ - Same as :meth:`add_nd_image` but uses a :class:`VideoProcessor` and YUV defaults. The ``VideoProcessor`` + Same as :meth:`add_nd_image` but uses a :class:`VideoSlicer` and YUV defaults. The ``VideoSlicer`` reads the frame at the current index directly, it does not apply ``window_funcs``. Parameters @@ -280,11 +280,11 @@ def add_video( dims: Sequence[str] name for every dim of ``data``, in order. They do not need to be in display order. - spatial_dims: tuple[str, str] | tuple[str, str, str] + display_dims: tuple[str, str] | tuple[str, str, str] The 2 or 3 spatial dims **in display order**, see :meth:`add_nd_image`. rgb_dim: str, optional - Name of the RGB(A) dim, if present. It must be listed in ``spatial_dims`` and be of size 3 or 4. + Name of the RGB(A) dim, if present. It must be listed in ``display_dims`` and be of size 3 or 4. colorspace: "yuv420p" | "yuv444p" | "srgb" | "tex-srgb" | "physical", default "yuv420p" Colorspace in which to interpret the data. The YUV colorspaces are rendered using an @@ -294,16 +294,16 @@ def add_video( colorrange: "full" | "limited", default "limited" Used only for the YUV colorspaces, see :class:`.ImageYUVGraphic`. Most videos use "limited". - processor_type: type[NDImageProcessor], default ``VideoProcessor`` - ``NDImageProcessor`` subclass that manages the data and produces the data slices. + slicer_type: type[NDImageSlicer], default ``VideoSlicer`` + ``NDImageSlicer`` subclass that manages the data and produces the data slices. window_funcs: dict[str, tuple[WindowFuncCallable | None, int | float | None]], optional Per-slider-dim window functions, see :meth:`add_nd_image`. Ignored by the default - ``VideoProcessor``. + ``VideoSlicer``. window_order: tuple[str, ...], optional Order in which the window functions are applied across dims. Ignored by the default - ``VideoProcessor``. + ``VideoSlicer``. spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice right before rendering. @@ -313,7 +313,7 @@ def add_video( which is used to interactively set vmin, vmax. Usually disabled for video since it requires random access of frames, which is slow for data that uses video codecs. - slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None, optional + slider_maps: dict mapping dim_name -> Callable, an ArrayLike, or None, optional Per-slider-dim mapping from reference-space values to local array indices, ex: an array of frame timestamps to map seconds onto frame indices. See :meth:`add_nd_image`. @@ -331,16 +331,16 @@ def add_video( return self.add_nd_image( data=data, dims=dims, - spatial_dims=spatial_dims, + display_dims=display_dims, rgb_dim=rgb_dim, colorspace=colorspace, colorrange=colorrange, - processor_type=processor_type, + slicer_type=slicer_type, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, compute_histogram=compute_histogram, - slider_dim_transforms=slider_dim_transforms, + slider_maps=slider_maps, name=name, graphic_kwargs=graphic_kwargs, ) @@ -349,20 +349,20 @@ def add_nd_vectors( self, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: tuple[str, str, str], + display_dims: tuple[str, str, str], window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, name: str = None, graphic_kwargs: dict = None, ) -> NDVectors: """ Add n-dimensional vectors to this subplot, similar to matplotlib quiver. - Every dim that is not listed in ``spatial_dims`` becomes a slider dim. + Every dim that is not listed in ``display_dims`` becomes a slider dim. Parameters ---------- @@ -375,7 +375,7 @@ def add_nd_vectors( dims: Sequence[str] name for every dim of ``data``, in order. They do not need to be in display order. - spatial_dims: tuple[str, str, str] + display_dims: tuple[str, str, str] The 3 spatial dims **in order**: ``(n_vectors, positions_and_directions, xy(z))``. The positions/directions dim must be of size 2 and the coordinate dim of size 2 or 3. @@ -396,7 +396,7 @@ def add_nd_vectors( spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice *after* the window funcs, right before rendering. - slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None, optional + slider_maps: dict mapping dim_name -> Callable, an ArrayLike, or None, optional Per-slider-dim mapping from reference-space values to local array indices. An array of reference values may be given instead of a callable, ``searchsorted`` is then used as the transform (ex: a timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference @@ -413,18 +413,18 @@ def add_nd_vectors( NDVectors """ - self._check_slider_dims(dims, spatial_dims, data) + self._check_slider_dims(dims, display_dims, data) nd = NDVectors( self.ndw.indices, nd_subplot=self, data=data, dims=dims, - spatial_dims=spatial_dims, + display_dims=display_dims, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, - slider_dim_transforms=slider_dim_transforms, + slider_maps=slider_maps, name=name, graphic_kwargs=graphic_kwargs, ) @@ -436,16 +436,16 @@ def add_nd_scatter( self, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: tuple[str, str, str], + display_dims: tuple[str, str, str], *args, - processor: type[NDPositionsProcessor] = NDPositionsProcessor, + slicer: type[NDPositionsSlicer] = NDPositionsSlicer, display_window: int | float | None = 10, window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, @@ -456,12 +456,12 @@ def add_nd_scatter( markers: MarkersType = None, name: str = None, graphic_kwargs: dict = None, - processor_kwargs: dict = None, + slicer_kwargs: dict = None, ) -> NDPositions: """ Add n-dimensional positional data to this subplot, rendered as a ``ScatterCollection``. - Every dim that is not listed in ``spatial_dims`` becomes a slider dim. The datapoints dim, ``p``, is both + Every dim that is not listed in ``display_dims`` becomes a slider dim. The datapoints dim, ``p``, is both a spatial dim and a slider dim, it is windowed by ``display_window`` and ``datapoints_window_func`` rather than by ``window_funcs``. @@ -471,7 +471,7 @@ def add_nd_scatter( n-dimensional positional data. Ex: an array of shape ``[n_trials, n_scatters, n_points, 2]`` with ``dims`` of - ``("trial", "scatter", "point", "xy")`` and ``spatial_dims`` of ``("scatter", "point", "xy")``. + ``("trial", "scatter", "point", "xy")`` and ``display_dims`` of ``("scatter", "point", "xy")``. Pass ``None`` to create the ``NDPositions`` without a graphic and set the data later using ``nd_positions.data``, the slider dims then require an explicit reference range in the ``NDWidget``. @@ -479,17 +479,17 @@ def add_nd_scatter( dims: Sequence[str] name for every dim of ``data``, in order. - spatial_dims: tuple[str, str, str] + display_dims: tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of scatters in the collection, the number of datapoints ``p`` in each of them, and the value dim which holds the xy or xyz coordinate and must be of size 2 or 3. The dims do not need to be in this order in the array, the data slice is transposed into display order. args - extra positional arguments passed to the ``processor`` constructor. + extra positional arguments passed to the ``slicer`` constructor. - processor: type[NDPositionsProcessor], default ``NDPositionsProcessor`` - ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + slicer: type[NDPositionsSlicer], default ``NDPositionsSlicer`` + ``NDPositionsSlicer`` subclass that manages the data and produces the data slices. display_window: int, float or None, default 10 Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its @@ -516,7 +516,7 @@ def add_nd_scatter( spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice *after* the window funcs, right before rendering. - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike], optional + slider_maps: dict[str, Callable[[Any], int] | ArrayLike], optional Per-slider-dim mapping from reference-space values to local array indices. An array of reference values may be given instead of a Callable, ``searchsorted`` is then used as the transform (ex: a timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference @@ -586,8 +586,8 @@ def add_nd_scatter( graphic_kwargs: dict, optional passed to the underlying ``ScatterCollection`` - processor_kwargs: dict, optional - passed to the ``processor`` constructor. + slicer_kwargs: dict, optional + passed to the ``slicer`` constructor. Returns ------- @@ -609,22 +609,22 @@ def add_nd_scatter( ``itertools.cycle(["jet", "viridis"])``. """ - self._check_slider_dims(dims, spatial_dims, data, positions=True) + self._check_slider_dims(dims, display_dims, data, positions=True) nd = NDPositions( self.ndw.indices, self, data, dims, - spatial_dims, + display_dims, *args, graphic_type=ScatterCollection, - processor=processor, + slicer=slicer, display_window=display_window, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, - slider_dim_transforms=slider_dim_transforms, + slider_maps=slider_maps, max_display_datapoints=max_display_datapoints, datapoints_window_func=datapoints_window_func, colors=colors, @@ -635,7 +635,7 @@ def add_nd_scatter( markers=markers, name=name, graphic_kwargs=graphic_kwargs, - processor_kwargs=processor_kwargs, + slicer_kwargs=slicer_kwargs, ) self._nd_graphics.append(nd) @@ -645,20 +645,20 @@ def add_nd_timeseries( self, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: tuple[str, str, str], + display_dims: tuple[str, str, str], *args, graphic_type: type[ LineCollection | LineStack | ScatterCollection | ScatterStack | ImageGraphic ] = LineStack, x_range_mode: Literal["fixed", "auto"] | None = "auto", - processor: type[NDPositionsProcessor] = NDPositionsProcessor, + slicer: type[NDPositionsSlicer] = NDPositionsSlicer, display_window: int | float | None = 10, window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, @@ -670,12 +670,12 @@ def add_nd_timeseries( markers: MarkersType = None, name: str = None, graphic_kwargs: dict = None, - processor_kwargs: dict = None, + slicer_kwargs: dict = None, ) -> NDTimeseries: """ Add n-dimensional timeseries data to this subplot, where the ``p`` dim is a time-like x-axis. - Every dim that is not listed in ``spatial_dims`` becomes a slider dim. The datapoints dim, ``p``, is both + Every dim that is not listed in ``display_dims`` becomes a slider dim. The datapoints dim, ``p``, is both a spatial dim and a slider dim, it is windowed by ``display_window`` and ``datapoints_window_func`` rather than by ``window_funcs``. @@ -690,7 +690,7 @@ def add_nd_timeseries( time-like coordinate. Ex: an array of shape ``[n_trials, n_traces, n_timepoints, 2]`` with ``dims`` of - ``("trial", "trace", "time", "xy")`` and ``spatial_dims`` of ``("trace", "time", "xy")``. + ``("trial", "trace", "time", "xy")`` and ``display_dims`` of ``("trace", "time", "xy")``. Pass ``None`` to create the ``NDTimeseries`` without a graphic and set the data later using ``nd_timeseries.data``, the slider dims then require an explicit reference range in the ``NDWidget``. @@ -698,14 +698,14 @@ def add_nd_timeseries( dims: Sequence[str] name for every dim of ``data``, in order. - spatial_dims: tuple[str, str, str] + display_dims: tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of traces in the collection, the number of datapoints ``p`` in each of them, and the value dim which holds the xy or xyz coordinate. A heatmap requires a value dim of size exactly 2. The dims do not need to be in this order in the array, the data slice is transposed into display order. args - extra positional arguments passed to the ``processor`` constructor. + extra positional arguments passed to the ``slicer`` constructor. graphic_type: type[LineCollection | LineStack | ScatterCollection | ScatterStack | ImageGraphic], default ``LineStack`` The graphical representation used to display the data slice. ``ImageGraphic`` renders the traces as a @@ -725,8 +725,8 @@ def add_nd_timeseries( Forced to ``None`` when ``display_window`` is ``None``. - processor: type[NDPositionsProcessor], default ``NDPositionsProcessor`` - ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + slicer: type[NDPositionsSlicer], default ``NDPositionsSlicer`` + ``NDPositionsSlicer`` subclass that manages the data and produces the data slices. display_window: int, float or None, default 10 Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its @@ -753,7 +753,7 @@ def add_nd_timeseries( spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice *after* the window funcs, right before rendering. - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike], optional + slider_maps: dict[str, Callable[[Any], int] | ArrayLike], optional Per-slider-dim mapping from reference-space values to local array indices. An array of reference values may be given instead of a Callable, ``searchsorted`` is then used as the transform. The transform for the ``p`` dim is typically the array of x values, ex: a timestamps array, so the @@ -830,8 +830,8 @@ def add_nd_timeseries( graphic_kwargs: dict, optional passed to the ``graphic_type`` constructor. - processor_kwargs: dict, optional - passed to the ``processor`` constructor. + slicer_kwargs: dict, optional + passed to the ``slicer`` constructor. Returns ------- @@ -856,24 +856,24 @@ def add_nd_timeseries( lines. The heatmap representation uses only ``cmap``. """ - self._check_slider_dims(dims, spatial_dims, data, positions=True) + self._check_slider_dims(dims, display_dims, data, positions=True) nd = NDTimeseries( self.ndw.indices, self, data, dims, - spatial_dims, + display_dims, *args, graphic_type=graphic_type, linear_selector=True, x_range_mode=x_range_mode, - processor=processor, + slicer=slicer, display_window=display_window, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, - slider_dim_transforms=slider_dim_transforms, + slider_maps=slider_maps, max_display_datapoints=max_display_datapoints, datapoints_window_func=datapoints_window_func, colors=colors, @@ -885,7 +885,7 @@ def add_nd_timeseries( markers=markers, name=name, graphic_kwargs=graphic_kwargs, - processor_kwargs=processor_kwargs, + slicer_kwargs=slicer_kwargs, ) self._nd_graphics.append(nd) @@ -895,16 +895,16 @@ def add_nd_lines( self, data: ArrayProtocol | None, dims: Sequence[str], - spatial_dims: tuple[str, str, str], + display_dims: tuple[str, str, str], *args, - processor: type[NDPositionsProcessor] = NDPositionsProcessor, + slicer: type[NDPositionsSlicer] = NDPositionsSlicer, display_window: int | float | None = 10, window_funcs: dict[ str, tuple[WindowFuncCallable | None, int | float | None] ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, max_display_datapoints: int = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, @@ -914,12 +914,12 @@ def add_nd_lines( thickness: float | Sequence[float] = None, name: str = None, graphic_kwargs: dict = None, - processor_kwargs: dict = None, + slicer_kwargs: dict = None, ) -> NDPositions: """ Add n-dimensional positional data to this subplot, rendered as a ``LineCollection``. - Every dim that is not listed in ``spatial_dims`` becomes a slider dim. The datapoints dim, ``p``, is both + Every dim that is not listed in ``display_dims`` becomes a slider dim. The datapoints dim, ``p``, is both a spatial dim and a slider dim, it is windowed by ``display_window`` and ``datapoints_window_func`` rather than by ``window_funcs``. @@ -929,7 +929,7 @@ def add_nd_lines( n-dimensional positional data. Ex: an array of shape ``[n_trials, n_keypoints, n_timepoints, 2]`` with ``dims`` of - ``("trial", "keypoint", "time", "xy")`` and ``spatial_dims`` of ``("keypoint", "time", "xy")``. + ``("trial", "keypoint", "time", "xy")`` and ``display_dims`` of ``("keypoint", "time", "xy")``. Pass ``None`` to create the ``NDPositions`` without a graphic and set the data later using ``nd_positions.data``, the slider dims then require an explicit reference range in the ``NDWidget``. @@ -937,17 +937,17 @@ def add_nd_lines( dims: Sequence[str] name for every dim of ``data``, in order. - spatial_dims: tuple[str, str, str] + display_dims: tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of lines in the collection, the number of datapoints ``p`` in each of them, and the value dim which holds the xy or xyz coordinate and must be of size 2 or 3. The dims do not need to be in this order in the array, the data slice is transposed into display order. args - extra positional arguments passed to the ``processor`` constructor. + extra positional arguments passed to the ``slicer`` constructor. - processor: type[NDPositionsProcessor], default ``NDPositionsProcessor`` - ``NDPositionsProcessor`` subclass that manages the data and produces the data slices. + slicer: type[NDPositionsSlicer], default ``NDPositionsSlicer`` + ``NDPositionsSlicer`` subclass that manages the data and produces the data slices. display_window: int, float or None, default 10 Size of the window of the ``p`` dim to render, in the reference units of that dim, centered on its @@ -974,7 +974,7 @@ def add_nd_lines( spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice *after* the window funcs, right before rendering. - slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike], optional + slider_maps: dict[str, Callable[[Any], int] | ArrayLike], optional Per-slider-dim mapping from reference-space values to local array indices. An array of reference values may be given instead of a Callable, ``searchsorted`` is then used as the transform (ex: a timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference @@ -1034,8 +1034,8 @@ def add_nd_lines( graphic_kwargs: dict, optional passed to the underlying ``LineCollection`` - processor_kwargs: dict, optional - passed to the ``processor`` constructor. + slicer_kwargs: dict, optional + passed to the ``slicer`` constructor. Returns ------- @@ -1057,22 +1057,22 @@ def add_nd_lines( ``itertools.cycle(["jet", "viridis"])``. """ - self._check_slider_dims(dims, spatial_dims, data, positions=True) + self._check_slider_dims(dims, display_dims, data, positions=True) nd = NDPositions( self.ndw.indices, self, data, dims, - spatial_dims, + display_dims, *args, graphic_type=LineCollection, - processor=processor, + slicer=slicer, display_window=display_window, window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, - slider_dim_transforms=slider_dim_transforms, + slider_maps=slider_maps, max_display_datapoints=max_display_datapoints, datapoints_window_func=datapoints_window_func, colors=colors, @@ -1082,7 +1082,7 @@ def add_nd_lines( thickness=thickness, name=name, graphic_kwargs=graphic_kwargs, - processor_kwargs=processor_kwargs, + slicer_kwargs=slicer_kwargs, ) self._nd_graphics.append(nd) diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py index 298011620..1642dc281 100644 --- a/fastplotlib/widgets/nd_widget/_ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -2,14 +2,14 @@ from typing import Any, Optional -from ._index import RangeContinuous, RangeDiscrete, ReferenceIndex +from ._index import RangeContinuous, RangeDiscrete, ReferenceIndices from ._ndw_subplot import NDWSubplot from ._ui import NDWidgetUI, RightClickMenu from ...layouts import ImguiFigure, Subplot class NDWidget: - def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[ReferenceIndex] = None, **kwargs): + def __init__(self, ranges: dict[str, tuple] = None, indices: Optional[ReferenceIndices] = None, **kwargs): """ Explore n-dimensional multi-modal datasets through synchronized graphical representations. @@ -29,7 +29,7 @@ def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[Refe Parameters ---------- - ref_ranges: dict[str, tuple[float, float, float] | RangeContinuous], optional + ranges: dict[str, tuple[float, float, float] | RangeContinuous], optional Reference range for each slider dim, ``{dim_name: (start, stop, step)}`` or a :class:`RangeContinuous` instance. These are in reference-space units, ``start`` and ``stop`` bound the slider and ``step`` is the increment used by the step and play buttons. @@ -45,7 +45,7 @@ def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[Refe ``slider_dim_transform`` that maps seconds onto the indices of that array. The size is unknown for a graphic added with ``data=None``, so its slider dims must be given a range here. - ref_index: ReferenceIndex, optional + indices: ReferenceIndex, optional Use an existing ``ReferenceIndex`` instead of creating one from ``ref_ranges``, which is then ignored. Multiple ``NDWidget`` instances that share a ``ReferenceIndex`` are synchronized, so one set of sliders can drive data displayed across several windows. @@ -73,12 +73,12 @@ def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[Refe ndw.show() """ - if ref_index is None: - if ref_ranges is None: - ref_ranges = dict() - self._indices = ReferenceIndex(ref_ranges) + if indices is None: + if ranges is None: + ranges = dict() + self._indices = ReferenceIndices(ranges) else: - self._indices = ref_index + self._indices = indices self._indices._add_ndwidget_(self) @@ -103,7 +103,7 @@ def figure(self) -> ImguiFigure: return self._figure @property - def indices(self) -> ReferenceIndex: + def indices(self) -> ReferenceIndices: """ Get or set the current index of each slider dim. diff --git a/fastplotlib/widgets/nd_widget/_repr_formatter.py b/fastplotlib/widgets/nd_widget/_repr_formatter.py index 0569f1004..de81d82fb 100644 --- a/fastplotlib/widgets/nd_widget/_repr_formatter.py +++ b/fastplotlib/widgets/nd_widget/_repr_formatter.py @@ -56,7 +56,7 @@ def ndprocessor_fmt_txt(processor) -> str: for dim in processor.dims: size = processor.shape[dim] - is_sp = dim in processor.spatial_dims + is_sp = dim in processor.display_dims role_s = (_c("spatial", f"{'spatial':<10}") if is_sp else _c("slider", f"{'slider':<10}")) @@ -398,7 +398,7 @@ def _dim_rows_html(proc) -> str: rows = [] for dim in proc.dims: size = proc.shape[dim] - is_sp = dim in proc.spatial_dims + is_sp = dim in proc.display_dims badge = _badge("spatial" if is_sp else "slider") # window_func - size column diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 832b0233c..ae15065ce 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -278,7 +278,7 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): "use display window", nd_graphic.display_window is not None ) - p_dim = nd_graphic.processor.spatial_dims[1] + p_dim = nd_graphic.slicer.spatial_dims[1] if changed: if not val: diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py index 6900b940b..2274eaf35 100644 --- a/fastplotlib/widgets/nd_widget/_video.py +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -3,13 +3,13 @@ import numpy as np from ...graphics.image import TupleYUV -from ._nd_image import NDImageProcessor +from ._nd_image import NDImageSlicer from ._async import run_in_thread_pool -class VideoProcessor(NDImageProcessor): +class VideoSlicer(NDImageSlicer): """ - ``NDImageProcessor`` subclass for video data, used by ``NDWSubplot.add_video()``. + ``NDImageSlicer`` subclass for video data, used by ``NDWSubplot.add_video()``. Reads the frame at the current index directly. Window functions are not currently implemented for video. From ca3d489cd718090e296b2a34f2ede2dc7bcfb6b8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 00:45:02 -0400 Subject: [PATCH 131/163] cursor fix --- fastplotlib/tools/_cursor.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index 21b16feef..4a55bebf0 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -88,15 +88,16 @@ def mode(self, mode: Literal["crosshair", "marker"]): return # mode has changed, clear and create new world objects - subplots = list(self._cursors.keys()) + transforms = {subplot: self._transforms[subplot] for subplot in self._cursors} self.clear() - for subplot in subplots: - self.add_subplot(subplot) - + # must be set before re-adding, `add_subplot` creates the world object for the current mode self._mode = mode + for subplot, transform in transforms.items(): + self.add_subplot(subplot, transform) + @property def size(self) -> float: """size of marker or crosshair line thickness""" @@ -147,7 +148,13 @@ def color(self, new_color): new_color = pygfx.Color(new_color) for c in self._cursors.values(): - c.material.color = new_color + if self.mode == "marker": + c.material.color = new_color + + elif self.mode == "crosshair": + h, v = c.children + h.material.color = new_color + v.material.color = new_color self._color = new_color @@ -200,7 +207,13 @@ def alpha(self) -> float: @alpha.setter def alpha(self, value: float): for c in self._cursors.values(): - c.material.opacity = value + if self.mode == "marker": + c.material.opacity = value + + elif self.mode == "crosshair": + h, v = c.children + h.material.opacity = value + v.material.opacity = value self._alpha = value @@ -321,13 +334,14 @@ def remove_subplot(self, subplot: Subplot): raise KeyError("cursor not in given supblot") subplot.scene.remove(self._cursors.pop(subplot)) + self._transforms.pop(subplot) # give back tooltip control to the subplot subplot.renderer.add_event_handler(subplot._fpl_set_tooltip, "pointer_move") def clear(self): """remove all subplots""" - for subplot in self._cursors.keys(): + for subplot in list(self._cursors.keys()): self.remove_subplot(subplot) def _create_marker(self) -> pygfx.Points: From 52c42ee7969935c3ffc71a6875c7bdb3f9d51457 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 04:14:47 -0400 Subject: [PATCH 132/163] missed renames --- .../nd_widget/_nd_positions/_nd_positions.py | 42 +++++++++---------- fastplotlib/widgets/nd_widget/_ui.py | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 7e65b8fff..b67ee5379 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -64,14 +64,14 @@ def __init__( n-dimensional positional data, must have 3 or more dims. dims: Sequence[str] - names for each dimension in ``data``. Dimensions not listed in ``spatial_dims`` are treated as slider + names for each dimension in ``data``. Dimensions not listed in ``display_dims`` are treated as slider dimensions and **must** appear as keys in the parent ``NDWidget``'s ``ref_ranges``. Examples:: ``("trial", "line", "time", "xy")`` ``("keypoints", "time", "xyz")`` dims in the array do not need to be in the order that you want to display them, the data slice is - transposed into the order given by ``spatial_dims``. + transposed into the order given by ``display_dims``. display_dims : tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of lines @@ -144,11 +144,11 @@ def set_other_feature(self, name: str, value): self._other_features[name] = np.asarray(value) @property - def spatial_dims(self) -> tuple[str, str, str]: + def display_dims(self) -> tuple[str, str, str]: """get or set the spatial dims, **in display order**""" - return self._spatial_dims + return self._display_dims - @spatial_dims.setter + @display_dims.setter def display_dims(self, sdims: tuple[str, str, str]): if len(sdims) != 3: raise IndexError @@ -156,13 +156,13 @@ def display_dims(self, sdims: tuple[str, str, str]): if not all([d in self.dims for d in sdims]): raise KeyError - self._spatial_dims = tuple(sdims) + self._display_dims = tuple(sdims) @property def slider_dims(self) -> tuple[str, ...]: """slider dim names, the non-spatial dims plus the ``p`` dim""" # append `p` dim to slider dims - return tuple([*super().slider_dims, self.spatial_dims[1]]) + return tuple([*super().slider_dims, self.display_dims[1]]) @property def display_window(self) -> int | float | None: @@ -220,7 +220,7 @@ def _get_dw_slice(self, indices: dict[str, Any]) -> slice: # n_datapoints dim name # display_window acts on this dim - p_dim = self.spatial_dims[1] + p_dim = self.display_dims[1] if self.display_window is None: # just return everything @@ -277,7 +277,7 @@ def _apply_dw_window_func(self, array: ArrayProtocol) -> ArrayProtocol: # can't apply window func when there is only 1 datapoint return array - p_dim = self.spatial_dims[1] + p_dim = self.display_dims[1] # display window in array index space if self.display_window is not None: @@ -371,7 +371,7 @@ async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]: 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. """ - # already squeezed and in the correct spatial_dims order + # already squeezed and in the correct display_dims order window_output = await self.get_window_output(indices) # get slice obj for display window @@ -415,7 +415,7 @@ def __init__( nd_subplot: NDWSubplot, data: Any, dims: Sequence[str], - spatial_dims: tuple[str, str, str], + display_dims: tuple[str, str, str], *args, graphic_type: Type[ LineCollection @@ -468,7 +468,7 @@ def __init__( n-dimensional positional data. Ex: an array of shape ``[n_trials, n_lines, n_timepoints, 2]`` with ``dims`` of - ``("trial", "line", "time", "xy")`` and ``spatial_dims`` of ``("line", "time", "xy")``. + ``("trial", "line", "time", "xy")`` and ``display_dims`` of ``("line", "time", "xy")``. Pass ``None`` to create the ``NDPositions`` without a graphic and set the data later using :attr:`data`. @@ -476,7 +476,7 @@ def __init__( dims : Sequence[str] Name for every dimension of ``data``, in order. Non-spatial dims must match keys in ``ref_index``. - spatial_dims : tuple[str, str, str] + display_dims : tuple[str, str, str] The 3 spatial dims **in display order**: ``(n_graphics, p, )``, i.e. the number of lines or scatters in the collection, the number of datapoints ``p`` in each of them, and the value dim which holds the xy or xyz coordinate and must be of size 2 or 3. The dims do not need to be in this @@ -602,7 +602,7 @@ def __init__( ref_index, data, dims, - spatial_dims, + display_dims, *args, graphic_type=graphic_type, slicer=slicer, @@ -631,7 +631,7 @@ def init( ref_index: ReferenceIndices, data: Any, dims: Sequence[str], - spatial_dims: tuple[str, str, str], + display_dims: tuple[str, str, str], *args, graphic_type: Type[ LineCollection @@ -678,7 +678,7 @@ def init( self._slicer = slicer( data, dims, - spatial_dims, + display_dims, *args, display_window=display_window, max_display_datapoints=max_display_datapoints, @@ -761,7 +761,7 @@ def _is_windowed(self, value) -> bool: return True if isinstance(value, (list, tuple, np.ndarray)): value = np.asarray(value) - p_size = self.slicer.shape[self.slicer.spatial_dims[1]] + p_size = self.slicer.shape[self.slicer.display_dims[1]] return value.ndim >= 2 and value.shape[1] == p_size return False @@ -821,15 +821,15 @@ def graphic_type(self, graphic_type): run_sync(self._create_graphic()) @property - def spatial_dims(self) -> tuple[str, str, str]: + def display_dims(self) -> tuple[str, str, str]: """ Get or set the spatial dims **in display order**: ``(n_graphics, p, )``. Setting them re-renders the current data slice. """ - return self.slicer.spatial_dims + return self.slicer.display_dims - @spatial_dims.setter - def spatial_dims(self, dims: tuple[str, str, str]): + @display_dims.setter + def display_dims(self, dims: tuple[str, str, str]): self.slicer.display_dims = dims # force re-render run_sync(self._set_indices_()) diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index ae15065ce..b247b4dc9 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -278,7 +278,7 @@ def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): "use display window", nd_graphic.display_window is not None ) - p_dim = nd_graphic.slicer.spatial_dims[1] + p_dim = nd_graphic.slicer.display_dims[1] if changed: if not val: From 83e0918fb473d86c6998c8093cbb25383d24044f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 04:36:00 -0400 Subject: [PATCH 133/163] pandas fix --- fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py index b7e5375c0..eb22c9504 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -10,6 +10,7 @@ class PandasSlicer(NDPositionsSlicer): def __init__( self, data: pd.DataFrame, + dims: tuple[str, str, str], display_dims: tuple[str, str, str], # [l, p, d] dims in order columns: list[tuple[str, str] | tuple[str, str, str]], tooltip_columns: list[str] = None, @@ -31,9 +32,12 @@ def __init__( data: pd.DataFrame DataFrame holding the coordinates, one column per coordinate of each graphic. + dims: tuple[str, str, str] + Names for the 3 dims. A DataFrame has no further dims to name, so these are the same 3 names + as ``display_dims``. + display_dims: tuple[str, str, str] - The 3 spatial dims **in display order**: ``(n_graphics, p, )``. These are also used as - the ``dims``, since a DataFrame has no other dims to name. + The 3 spatial dims **in display order**: ``(n_graphics, p, )``. columns: list[tuple[str, str] | tuple[str, str, str]] One entry per graphic, each a tuple of 2 or 3 column names giving the (x, y) or (x, y, z) @@ -62,7 +66,7 @@ def __init__( super().__init__( data=data, - dims=display_dims, + dims=dims, display_dims=display_dims, **kwargs, ) From 8bc17369eee578674dea003c633e98b42c8e369d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 05:45:29 -0400 Subject: [PATCH 134/163] restore ImageWidget, auto-collapse NDWidget sliders UI if no slider dims and no appended UIs --- examples/image_widget/README.rst | 2 + examples/image_widget/image_widget.py | 34 + examples/image_widget/image_widget_grid.py | 41 + .../image_widget/image_widget_single_video.py | 47 + examples/image_widget/image_widget_videos.py | 43 + .../image_widget_viewports_check.py | 35 + fastplotlib/widgets/image_widget/_sliders.py | 171 --- fastplotlib/widgets/image_widget/_widget.py | 1180 +++++------------ fastplotlib/widgets/nd_widget/_ndwidget.py | 4 + fastplotlib/widgets/nd_widget/_ui.py | 6 + 10 files changed, 530 insertions(+), 1033 deletions(-) create mode 100644 examples/image_widget/README.rst create mode 100644 examples/image_widget/image_widget.py create mode 100644 examples/image_widget/image_widget_grid.py create mode 100644 examples/image_widget/image_widget_single_video.py create mode 100644 examples/image_widget/image_widget_videos.py create mode 100644 examples/image_widget/image_widget_viewports_check.py delete mode 100644 fastplotlib/widgets/image_widget/_sliders.py diff --git a/examples/image_widget/README.rst b/examples/image_widget/README.rst new file mode 100644 index 000000000..f445f7390 --- /dev/null +++ b/examples/image_widget/README.rst @@ -0,0 +1,2 @@ +ImageWidget Examples +==================== diff --git a/examples/image_widget/image_widget.py b/examples/image_widget/image_widget.py new file mode 100644 index 000000000..a3c332182 --- /dev/null +++ b/examples/image_widget/image_widget.py @@ -0,0 +1,34 @@ +""" +Image widget +============ + +Example showing the image widget in action. + +Every image in an `ImageWidget` is associated with an interactive Histogram LUT tool and colorbar. Right-click the +colorbar to pick colormaps. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import imageio.v3 as iio # not a fastplotlib dependency, only used for examples + +a = iio.imread("imageio:camera.png") +iw = fpl.ImageWidget(data=a, cmap="viridis", figure_kwargs={"size": (700, 560)}) +iw.show() + +# Access ImageGraphics managed by the image widget +iw.figure[0, 0]["image_widget_managed"].data[:50, :50] = 0 +iw.figure[0, 0]["image_widget_managed"].cmap = "gnuplot2" + +# another way to access the image widget managed ImageGraphics +iw.managed_graphics[0].data[450:, 450:] = 255 + +figure = iw.figure + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_widget/image_widget_grid.py b/examples/image_widget/image_widget_grid.py new file mode 100644 index 000000000..41e964e95 --- /dev/null +++ b/examples/image_widget/image_widget_grid.py @@ -0,0 +1,41 @@ +""" +Image widget grid +================= + +Example showing how to view multiple images in an ImageWidget +""" + +import fastplotlib as fpl +import imageio.v3 as iio + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +img1 = iio.imread("imageio:camera.png") +img2 = iio.imread("imageio:astronaut.png") +img3 = iio.imread("imageio:chelsea.png") +img4 = iio.imread("imageio:wikkie.png") + +iw = fpl.ImageWidget( + data=[img1, img2, img3, img4], + rgb=[False, True, True, True], # mix of grayscale and RGB images + names=["cameraman", "astronaut", "chelsea", "Almar's cat"], + # ImageWidget will sync controllers by default + # by setting `controller_ids=None` we can have independent controllers for each subplot + # this is useful when the images have different dimensions + figure_kwargs={"size": (700, 560), "controller_ids": None}, +) +iw.show() + +figure = iw.figure + +for subplot in figure: + # sometimes the toolbar adds clutter + subplot.toolbar = False + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_widget/image_widget_single_video.py b/examples/image_widget/image_widget_single_video.py new file mode 100644 index 000000000..86ca642fa --- /dev/null +++ b/examples/image_widget/image_widget_single_video.py @@ -0,0 +1,47 @@ +""" +Image widget Video +================== + +Example showing how to scroll through one or more videos using the ImageWidget +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'animate 6s 20fps' + +import fastplotlib as fpl +import imageio.v3 as iio +import numpy as np + + +movie = iio.imread("imageio:cockatoo.mp4") + +# Ignore and do not use the next 2 lines +# for the purposes of docs gallery generation we subsample and only use 15 frames +movie_sub = movie[:15, ::12, ::12].copy() +del movie + +iw = fpl.ImageWidget(movie_sub, rgb=True, figure_kwargs={"size": (700, 560)}) + +# ImageWidget supports setting window functions the `time` "t" or `volume` "z" dimension +# These can also be given as kwargs to `ImageWidget` during instantiation +# to set a window function, give a dict in the form of {dim: (func, window_size)} +iw.window_funcs = {"t": (np.mean, 13)} + +# change the window size +iw.window_funcs["t"].window_size = 33 + +# change the function +iw.window_funcs["t"].func = np.max + +# or reset it +iw.window_funcs = None + +iw.show() + +figure = iw.figure + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_widget/image_widget_videos.py b/examples/image_widget/image_widget_videos.py new file mode 100644 index 000000000..399abbcff --- /dev/null +++ b/examples/image_widget/image_widget_videos.py @@ -0,0 +1,43 @@ +""" +Image widget videos side by side +================================ + +Example showing how to scroll through one or more videos using the ImageWidget +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'animate 6s 20fps' + +import fastplotlib as fpl +import imageio.v3 as iio +import numpy as np + + +# load the standard cockatoo video +cockatoo = iio.imread("imageio:cockatoo.mp4") + +# Ignore and do not use the next 2 lines +# for the purposes of docs gallery generation we subsample and only use 15 frames +cockatoo_sub = cockatoo[:15, ::12, ::12].copy() +del cockatoo + +# make a random grayscale video, shape is [t, rows, cols] +np.random.seed(0) +random_data = np.random.rand(*cockatoo_sub.shape[:-1]) + +iw = fpl.ImageWidget( + [random_data, cockatoo_sub], + rgb=[False, True], + figure_shape=(2, 1), # 2 rows, 1 column + figure_kwargs={"size": (700, 940)} +) + +iw.show() + +figure = iw.figure + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_widget/image_widget_viewports_check.py b/examples/image_widget/image_widget_viewports_check.py new file mode 100644 index 000000000..a4c0aea03 --- /dev/null +++ b/examples/image_widget/image_widget_viewports_check.py @@ -0,0 +1,35 @@ +""" +ImageWidget test viewport rects +=============================== + +Test Figure to test that viewport rects are positioned correctly in an image widget +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'hidden' + +import fastplotlib as fpl +import numpy as np + +np.random.seed(0) +a = np.random.rand(6, 15, 10, 10) + +iw = fpl.ImageWidget( + data=[img for img in a], + names=list(map(str, range(6))), + figure_kwargs={"size": (700, 560)}, +) + +for subplot in iw.figure: + subplot.docks["left"].size = 10 + subplot.docks["bottom"].size = 40 + +iw.show() + +figure = iw.figure + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py deleted file mode 100644 index 393b13273..000000000 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ /dev/null @@ -1,171 +0,0 @@ -import os -from time import perf_counter - -from imgui_bundle import imgui, icons_fontawesome_6 as fa - -from ...ui import EdgeWindow - - -class ImageWidgetSliders(EdgeWindow): - def __init__(self, figure, size, location, title, image_widget): - super().__init__(figure=figure, size=size, location=location, title=title) - self._image_widget = image_widget - - # whether or not a dimension is in play mode - self._playing: dict[str, bool] = {"t": False, "z": False} - - # approximate framerate for playing - self._fps: dict[str, int] = {"t": 20, "z": 20} - # framerate converted to frame time - self._frame_time: dict[str, float] = {"t": 1 / 20, "z": 1 / 20} - - # last timepoint that a frame was displayed from a given dimension - self._last_frame_time: dict[str, float] = {"t": 0, "z": 0} - - self._loop = False - - if "RTD_BUILD" in os.environ.keys(): - if os.environ["RTD_BUILD"] == "1": - self._playing["t"] = True - self._loop = True - - def set_index(self, dim: str, index: int): - """set the current_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: - if self._loop: - # loop back to index zero if looping is enabled - 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)} - - 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() - - # 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() - if imgui.is_item_hovered(0): - imgui.set_tooltip("reset contrast limits using full movie/stack") - - # reset vmin-vmax using currently displayed ImageGraphic data - imgui.same_line() - if imgui.button(label=fa.ICON_FA_CIRCLE_HALF_STROKE): - self._image_widget.reset_vmin_vmax_frame() - if imgui.is_item_hovered(0): - imgui.set_tooltip("reset contrast limits using current frame") - - # time now - now = perf_counter() - - # buttons and slider UI elements for each dim - for dim in self._image_widget.slider_dims: - imgui.push_id(f"{self._id_counter}_{dim}") - - if self._playing[dim]: - # show pause button if playing - if imgui.button(label=fa.ICON_FA_PAUSE): - # if pause button clicked, then set playing to false - self._playing[dim] = False - - # 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._last_frame_time[dim] = now - - else: - # we are not playing, so display play button - if imgui.button(label=fa.ICON_FA_PLAY): - # if play button is clicked, set last frame time to 0 so that index increments on next render - self._last_frame_time[dim] = 0 - # set playing to True since play button was clicked - self._playing[dim] = True - - 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) - - 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) - - imgui.same_line() - # stop button - if imgui.button(label=fa.ICON_FA_STOP): - self._playing[dim] = False - self._last_frame_time[dim] = 0 - self.set_index(dim, 0) - - imgui.same_line() - # loop checkbox - _, self._loop = imgui.checkbox(label=fa.ICON_FA_ROTATE, v=self._loop) - if imgui.is_item_hovered(0): - imgui.set_tooltip("loop playback") - - imgui.same_line() - imgui.text("framerate :") - imgui.same_line() - imgui.set_next_item_width(100) - # framerate int entry - fps_changed, value = imgui.input_int( - label="fps", v=self._fps[dim], step_fast=5 - ) - if imgui.is_item_hovered(0): - imgui.set_tooltip( - "framerate is approximate and less reliable as it approaches your monitor refresh rate" - ) - if fps_changed: - if value < 1: - value = 1 - if value > 50: - value = 50 - self._fps[dim] = value - self._frame_time[dim] = 1 / value - - val = self._image_widget.current_index[dim] - vmax = self._image_widget._dims_max_bounds[dim] - 1 - - imgui.text(f"{dim}: ") - imgui.same_line() - # so that slider occupies full width - imgui.set_next_item_width(self.width * 0.85) - - if "Jupyter" in self._image_widget.figure.canvas.__class__.__name__: - # until https://github.com/pygfx/wgpu-py/issues/530 - flags = imgui.SliderFlags_.no_input - else: - # clamps to min, max if user inputs value outside these bounds - flags = imgui.SliderFlags_.always_clamp - - # slider for this dimension - changed, index = imgui.slider_int( - f"{dim}", v=val, v_min=0, v_max=vmax, flags=flags - ) - - new_index[dim] = index - - # if the slider value changed for this dimension - flag_index_changed |= changed - - imgui.pop_id() - - 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.size = int(imgui.get_window_height()) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 6d262678d..b8bca242b 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -1,291 +1,27 @@ -from copy import deepcopy from typing import Callable -from warnings import warn +from warnings import warn, catch_warnings, filterwarnings import numpy as np -from rendercanvas import BaseRenderCanvas +from ...utils import ArrayProtocol, calculate_figure_shape, quick_min_max +from ..nd_widget import NDWidget -from ...layouts import ImguiFigure as Figure -from ...graphics import ImageGraphic -from ...utils import calculate_figure_shape, quick_min_max -from ...tools import HistogramLUTTool -from ._sliders import ImageWidgetSliders +# slider dims in order, "t" then "z", matching the old ImageWidget convention +SLIDER_DIMS = ("t", "z") -# 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"} +def _adapt_window_func(func: Callable) -> Callable: + # ImageWidget window functions take only `axis`, but an NDImage window function is called + # with both `axis` and `keepdims`. Wrap so the windowed dim is kept (reduced to size 1). + def wrapper(a, axis, keepdims): + out = func(a, axis=axis) + return np.expand_dims(out, axis) if keepdims else out -# 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}" + return wrapper 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 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]: - """ - 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 - - @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], @@ -300,12 +36,12 @@ def __init__( graphic_kwargs: dict = None, ): """ - This widget facilitates high-level navigation through image stacks, which are arrays containing one or more - images. It includes sliders for key dimensions such as "t" (time) and "z", enabling users to smoothly navigate - through one or multiple image stacks simultaneously. + A high-level widget for navigating through image stacks. It is a thin wrapper around an + :class:`.NDWidget`, one ``add_nd_image`` per array, with sliders for the "t" (time) and "z" + dimensions shared across every image stack. - 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. + Allowed dimension orders for each image stack, where the optional ``(c)`` is an RGB(A) channel of + size 3 or 4: ======= ========== n_dims dims order @@ -317,528 +53,325 @@ def __init__( Parameters ---------- - data: Union[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 - - figure_shape: Optional[Tuple[int, int]] - manually provide the shape for the Figure, otherwise the number of rows and columns is estimated + data: np.ndarray | list[np.ndarray] + array-like or a list of array-like, one image stack per subplot - figure_kwargs: dict, optional - passed to ``Figure`` + window_funcs: dict[str, tuple[Callable, int]], optional + Rolling window functions along the "t" and/or "z" dims, ``{dim: (func, window_size)}``, ex: + ``{"t": (np.mean, 11)}``. ``func`` must take an ``axis`` kwarg, ``window_size`` is in frames. - names: Optional[str] - gives names to the subplots + frame_apply: Callable | dict[int, Callable], optional + Function(s) applied to each array's slice before it is displayed. A single callable is applied + to every subplot, a ``{array_index: callable}`` dict applies per-array. Applied after + ``window_funcs``. - histogram_widget: bool, default False - make histogram LUT widget for each subplot + figure_shape: tuple[int, int], optional + manually provide the ``[n_rows, n_cols]`` shape for the figure, otherwise it is estimated - rgb: bool | list[bool], default None - bool or list of bool for each input data array in the ImageWidget, indicating whether the corresponding - data arrays are grayscale or RGB(A). + names: list[str], optional + names for the subplots - graphic_kwargs: Any - passed to each ImageGraphic in the ImageWidget figure subplots + figure_kwargs: dict, optional + passed to the underlying ``ImguiFigure`` - """ - warn( - "`ImageWidget` is deprecated and will be removed in a" - " future release, please migrate to NDWidget", - DeprecationWarning - ) - self._initialized = False + histogram_widget: bool, default ``True`` + make a histogram colorbar for each subplot to interactively set vmin, vmax - if figure_kwargs is None: - figure_kwargs = dict() + rgb: bool | list[bool], optional + whether each array is RGB(A), i.e. the last dim is a channel of size 3 or 4 - if _is_arraylike(data): - data = [data] + cmap: str, default "plasma" + colormap for the image graphics - 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" - ) - - 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]] - ) + graphic_kwargs: dict, optional + passed to each ``ImageGraphic`` + """ + if isinstance(data, ArrayProtocol): + data = [data] - 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 len(names) != len(self.data): - raise ValueError( - "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]}" - ) - else: + if not (isinstance(data, list) and all(isinstance(d, ArrayProtocol) 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)}" + "`data` must be an array-like or a list of array-like, you have passed: " + f"{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]) + # normalize rgb to a list of bool, one per array + if rgb is None: + rgb = [False] * len(data) + elif isinstance(rgb, bool): + rgb = [rgb] * len(data) + if not (isinstance(rgb, list) and len(rgb) == len(data)): + raise TypeError( + "`rgb` must be a bool or a list of bool with one entry per data array" + ) + self._rgb = rgb - self._frame_apply: dict[int, callable] = dict() + if names is not None: + if not all(isinstance(n, str) for n in names): + raise TypeError("`names` must be a list of str") + if len(names) != len(data): + raise ValueError("number of `names` must equal the number of data arrays") - if frame_apply is not None: - if callable(frame_apply): - self._frame_apply = frame_apply + # dims, display_dims, rgb_dim and number of slider dims for each array (validates the arrays) + image_dims = [self._dims_for(arr, is_rgb) for arr, is_rgb in zip(data, rgb)] + max_slider_dims = max(n for *_, n in image_dims) + self._slider_dims = list(SLIDER_DIMS[:max_slider_dims]) - elif isinstance(frame_apply, dict): - self._frame_apply: dict[int, callable] = dict.fromkeys( - list(range(len(self.data))) - ) + self._validate_window_funcs(window_funcs) + self._window_funcs = window_funcs - # 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)}>" - ) + self._validate_frame_apply(frame_apply) + self._frame_apply = 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 - # user specified kwargs will overwrite the defaults - figure_kwargs_default.update(figure_kwargs) - figure_kwargs_default["shape"] = figure_shape + # figure grid, large enough to hold every array + if figure_shape is None: + figure_shape = calculate_figure_shape(len(data)) + if figure_shape[0] * figure_shape[1] < len(data): + warn( + f"`figure_shape` {figure_shape} is too small for {len(data)} arrays, " + f"resetting it to {calculate_figure_shape(len(data))}" + ) + figure_shape = calculate_figure_shape(len(data)) + if figure_kwargs is None: + figure_kwargs = dict() if graphic_kwargs is None: graphic_kwargs = dict() - graphic_kwargs.update({"cmap": cmap}) - - 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") - - 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) - - 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) - - # decide vmin, vmax passed to ImageGraphic constructor based on whether it's user specified or now - if vmin_specified is None: - # user hasn't specified vmin, use estimated value - vmin = vmin_estimate - else: - # user has provided a specific value, use that - vmin = vmin_specified - - if vmax_specified is None: - vmax = vmax_estimate - else: - vmax = vmax_specified - else: - # both vmin and vmax are specified - vmin, vmax = vmin_specified, vmax_specified - - ig = ImageGraphic( - frame, - name="image_widget_managed", - vmin=vmin, - vmax=vmax, - **graphic_kwargs, - ) - subplot.add_graphic(ig) - - if self._histogram_widget: - hlut = HistogramLUTTool(data=d, images=ig, name="histogram_lut") - - 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 - - # 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 - - self._image_widget_sliders = ImageWidgetSliders( - figure=self.figure, - size=ui_size, - location="bottom", - title="ImageWidget Controls", - image_widget=self, + # each slider dim gets an auto reference range spanning the largest array along that dim, so the + # reference index is just the array index. ImageWidget syncs subplot controllers by default, + # user figure_kwargs can override. + self._ndw = NDWidget( + **{ + "controller_ids": "sync", + "names": names, + **figure_kwargs, + "shape": figure_shape, + }, ) - self.figure.add_gui(self._image_widget_sliders) + # ImageWidget uses raw array indices as reference values, so add_nd_image auto-creates the + # reference ranges; silence its per-dim "no reference range specified" warning. + self._nd_images = list() + with catch_warnings(): + filterwarnings("ignore", message="No reference range specified") + for i, ((dims, display_dims, rgb_dim, _), arr, subplot) in enumerate( + zip(image_dims, data, self._ndw.figure) + ): + window_funcs, window_order = self._translate_window_funcs( + set(dims) & set(SLIDER_DIMS) + ) + nd = self._ndw[subplot].add_nd_image( + arr, + dims, + display_dims, + rgb_dim=rgb_dim, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=self._spatial_func_for(i), + compute_histogram=histogram_widget, + graphic_kwargs={**graphic_kwargs, "cmap": cmap}, + ) + self._nd_images.append(nd) + # bridge the shared ReferenceIndices onto the "current_index" event self._current_index_changed_handlers = set() + self._ndw.indices.add_event_handler(self._indices_changed, "indices") + + def _dims_for( + self, arr: np.ndarray, rgb: bool + ) -> tuple[tuple[str, ...], tuple[str, ...], str | None, int]: + # dim names, display dims, rgb dim name and number of slider dims for one array + n_image_dims = 3 if rgb else 2 + if arr.ndim < n_image_dims: + raise ValueError( + f"Array has shape {arr.shape} but each image is {n_image_dims}D" + ) + if rgb and arr.shape[-1] not in (3, 4): + raise ValueError( + f"Expected size 3 or 4 for the last (RGB) dim, got {arr.shape[-1]}" + ) - self._reentrant_block = False + n_slider_dims = arr.ndim - n_image_dims + if n_slider_dims > len(SLIDER_DIMS): + raise ValueError( + f"Array shape {arr.shape} has too many dims, at most {len(SLIDER_DIMS)} " + f"slider dims {SLIDER_DIMS} are supported" + ) - self._initialized = True + slider_dims = SLIDER_DIMS[:n_slider_dims] + if rgb: + return (*slider_dims, "row", "col", "c"), ("row", "col", "c"), "c", n_slider_dims + return (*slider_dims, "row", "col"), ("row", "col"), None, n_slider_dims + + def _translate_window_funcs( + self, slider_dims: set[str] + ) -> tuple[dict | None, tuple[str, ...] | None]: + # translate the plain {dim: (func, size)} dict into the window_funcs and window_order + # that an NDImage with these slider dims expects + if self._window_funcs is None: + return None, None + + window_funcs = { + dim: (_adapt_window_func(func), float(size)) + for dim, (func, size) in self._window_funcs.items() + if dim in slider_dims + } + if not window_funcs: + return None, None + + return window_funcs, tuple(d for d in SLIDER_DIMS if d in window_funcs) + + def _spatial_func_for(self, index: int) -> Callable | None: + # the frame_apply function for the array at ``index`` + if self._frame_apply is None or callable(self._frame_apply): + return self._frame_apply + return self._frame_apply.get(index) + + def _indices_changed(self, indices: dict[str, float]): + current_index = self.current_index + for handler in self._current_index_changed_handlers: + handler(current_index) + + @staticmethod + def _validate_window_funcs(window_funcs): + if window_funcs is None: + return + if not isinstance(window_funcs, dict): + raise TypeError( + "`window_funcs` must be a dict `{dim: (func, window_size)}` or None" + ) + if not set(window_funcs).issubset(SLIDER_DIMS): + raise ValueError(f"`window_funcs` keys must be a subset of {SLIDER_DIMS}") + for func, size in window_funcs.values(): + if not callable(func): + raise TypeError("each window function must be callable") + if not isinstance(size, (int, np.integer)): + raise TypeError("each window size must be an int") + + @staticmethod + def _validate_frame_apply(frame_apply): + if frame_apply is None or callable(frame_apply): + return + if isinstance(frame_apply, dict): + if not all(isinstance(k, (int, np.integer)) for k in frame_apply): + raise TypeError("`frame_apply` dict keys must be an int array index") + return + raise TypeError( + "`frame_apply` must be a callable, a `{array_index: callable}` dict, or None" + ) @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 + def figure(self): + """``ImguiFigure`` used by the ``ImageWidget``""" + return self._ndw.figure @property - def window_funcs(self) -> dict[str, _WindowFunctions]: - """ - Get or set the window functions + def managed_graphics(self) -> list: + """the ``ImageGraphic`` objects managed by the ``ImageWidget``""" + return [nd.graphic for nd in self._nd_images] - Returns - ------- - Dict[str, _WindowFunctions] + @property + def data(self) -> list[np.ndarray]: + """the data arrays displayed in the widget""" + return [nd.data for nd in self._nd_images] - """ - return self._window_funcs + @property + def slider_dims(self) -> list[str]: + """the dimensions that the sliders index, ``["t"]`` or ``["t", "z"]``""" + return list(self._slider_dims) - @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 + @property + def cmap(self) -> list: + return [nd.graphic.cmap for nd in self._nd_images] - 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." + @cmap.setter + def cmap(self, names: str | list[str]): + if isinstance(names, str): + names = [names] * len(self._nd_images) + elif isinstance(names, list): + if not all(isinstance(n, str) for n in names): + raise TypeError(f"cmap names must be a str or list of str, you passed: {names}") + if len(names) != len(self._nd_images): + raise IndexError( + f"a list of cmap names must have one name per subplot, you passed " + f"{len(names)} names for {len(self._nd_images)} subplots" ) - 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." - ) + raise TypeError(f"cmap names must be a str or list of str, you passed: {names}") - # force frame to update - self.current_index = self.current_index + for name, nd in zip(names, self._nd_images): + nd.graphic.cmap = name - def _process_indices( - self, array: np.ndarray, slice_indices: dict[str, int] - ) -> np.ndarray: + @property + def current_index(self) -> dict[str, int]: """ - 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 + Get or set the current index of each slider dim. + Provide a subset or all of the slider dims, ex: ``{"t": 10}`` or ``{"t": 5, "z": 20}``. Any dim + that is not provided keeps its current index. """ + return {d: round(self._ndw.indices[d]) for d in self._slider_dims} - 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) + @current_index.setter + def current_index(self, index: dict[str, int]): + if not set(index).issubset(self._slider_dims): + raise KeyError( + f"All `current_index` keys must be slider dims: {self._slider_dims}, " + f"you passed: {list(index)}" ) - 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. - - Currently the only event that ImageWidget supports is "current_index". This event is - emitted whenever the index of the ImageWidget changes. - - Parameters - ---------- - handler: callable - callback function, must take a dict as the only argument. This dict will be the `current_index` - - event: str, "current_index" - the only supported event is "current_index" + for dim, value in index.items(): + if not isinstance(value, (int, np.integer)): + raise TypeError("indices for all dimensions must be int") + if value < 0: + raise IndexError("negative indexing is not supported for ImageWidget") + max_index = self._ndw.ranges[dim].stop - 1 + if value > max_index: + raise IndexError( + f"index {value} is out of bounds for dim '{dim}' with max index {max_index}" + ) - Example - ------- + self._ndw.indices = {dim: int(value) for dim, value in index.items()} - .. code-block:: py + @property + def window_funcs(self) -> dict[str, tuple[Callable, int]] | None: + """get or set the window functions, ``{dim: (func, window_size)}``""" + return self._window_funcs - 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} + @window_funcs.setter + def window_funcs(self, window_funcs: dict[str, tuple[Callable, int]] | None): + self._validate_window_funcs(window_funcs) + self._window_funcs = window_funcs + for nd in self._nd_images: + funcs, order = self._translate_window_funcs(nd.slider_dims) + # disable windowing before swapping the funcs, so no intermediate render applies a + # window_order dim whose function has just been cleared + nd.window_order = None + nd.window_funcs = funcs + nd.window_order = order - # create an image widget - iw = ImageWidget(...) + @property + def frame_apply(self) -> Callable | dict[int, Callable] | None: + """get or set the frame_apply function(s)""" + return self._frame_apply - # add event handler - iw.add_event_handler(my_handler) + @frame_apply.setter + def frame_apply(self, frame_apply: Callable | dict[int, Callable] | None): + self._validate_frame_apply(frame_apply) + self._frame_apply = frame_apply + for i, nd in enumerate(self._nd_images): + nd.spatial_func = self._spatial_func_for(i) + def add_event_handler(self, handler: Callable, event: str = "current_index"): + """ + Register an event handler, called whenever the ``current_index`` changes with the + ``current_index`` dict as the only argument. "current_index" is the only supported event. """ if event != "current_index": - raise ValueError( - "`current_index` is the only event supported by `ImageWidget`" - ) - + raise ValueError("`current_index` is the only event supported by `ImageWidget`") self._current_index_changed_handlers.add(handler) - def remove_event_handler(self, handler: callable): + def remove_event_handler(self, handler: Callable): """Remove a registered event handler""" self._current_index_changed_handlers.remove(handler) @@ -847,32 +380,9 @@ def clear_event_handlers(self): self._current_index_changed_handlers.clear() 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): - if "histogram_lut" not in subplot.docks["right"]: - continue - hlut = subplot.docks["right"]["histogram_lut"] - hlut.set_data(data, reset_vmin_vmax=True) - - def reset_vmin_vmax_frame(self): - """ - Resets the vmin vmax and HistogramLUT widgets w.r.t. the current data shown in the - ImageGraphic instead of the data in the full data array. For example, if a post-processing - function is used, the range of values in the ImageGraphic can be very different from the - range of values in the full data array. - - 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: - if "histogram_lut" not in subplot.docks["right"]: - continue - - hlut = subplot.docks["right"]["histogram_lut"] - # set the data using the current image graphic data - hlut.set_data(subplot["image_widget_managed"].data.value) + """Reset the vmin, vmax of each image graphic, estimated from the full data array""" + for nd in self._nd_images: + nd.graphic.vmin, nd.graphic.vmax = quick_min_max(nd.data) def set_data( self, @@ -881,107 +391,56 @@ def set_data( reset_indices: bool = True, ): """ - Change data of widget. Note: sliders max currently update only for ``txy`` and ``tzxy`` data. + Change the data displayed in the widget. Parameters ---------- - new_data: array-like or list of array-like - The new data to display in the widget + new_data: np.ndarray | list[np.ndarray] + the new data, one array per subplot, each with the same number of dims as the array it replaces reset_vmin_vmax: bool, default ``True`` - reset the vmin vmax levels based on the new data + reset the vmin, vmax using the new data reset_indices: bool, default ``True`` - reset the current index for all dimensions to 0 - + reset the current index of every slider dim to 0 """ - - if reset_indices: - for key in self.current_index: - self.current_index[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): + if isinstance(new_data, ArrayProtocol): new_data = [new_data] - - if len(self._data) != len(new_data): + if len(new_data) != len(self._nd_images): raise ValueError( - f"number of new data arrays {len(new_data)} must match" - f" current number of data arrays {len(self._data)}" + f"number of new data arrays {len(new_data)} must equal the current number " + f"{len(self._nd_images)}" ) - # 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]: + for i, (new_array, nd) in enumerate(zip(new_data, self._nd_images)): + if new_array is nd.data: + # allows setting only a subset of the arrays 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 + if new_array.ndim != nd.data.ndim: + raise ValueError( + f"new data ndim {new_array.ndim} at index {i} does not equal the current " + f"ndim {nd.data.ndim}" ) - frame = self._process_frame_apply(frame, i) + # validates the new array against its rgb setting + self._dims_for(new_array, self._rgb[i]) - # make new graphic first - new_graphic = ImageGraphic(data=frame, name="image_widget_managed") + if not reset_vmin_vmax: + vmin, vmax = nd.graphic.vmin, nd.graphic.vmax - if self._histogram_widget: - # set hlut tool to use new graphic - subplot.docks["right"]["histogram_lut"].images = new_graphic + # recreates the graphic, resets the camera, histogram and vmin, vmax + nd.data = new_array - # 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) + if not reset_vmin_vmax: + nd.graphic.vmin, nd.graphic.vmax = vmin, vmax - # 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 - - 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 + # grow the slider ranges to fit the new array + for dim in nd.slider_dims: + self._ndw.ranges[dim].stop = max( + self._ndw.ranges[dim].stop, new_array.shape[SLIDER_DIMS.index(dim)] ) - # force graphics to update - self.current_index = self.current_index + if reset_indices: + self._ndw.indices = {dim: 0 for dim in self._slider_dims} def show(self, **kwargs): """ @@ -989,20 +448,17 @@ def show(self, **kwargs): Parameters ---------- - kwargs: Any - passed to `Figure.show()` + passed to ``Figure.show()`` Returns ------- BaseRenderCanvas In Qt or GLFW, the canvas window containing the Figure will be shown. In jupyter, it will display the plot in the output cell or sidecar. - """ - - return self.figure.show(**kwargs) + return self._ndw.show(**kwargs) def close(self): - """Close Widget""" - self.figure.close() + """Close the widget""" + self._ndw.close() diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py index 1642dc281..cd487516d 100644 --- a/fastplotlib/widgets/nd_widget/_ndwidget.py +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -122,6 +122,10 @@ def ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: """the reference range of each slider dim, ``{dim_name: range}``""" return self._indices.ref_ranges + @property + def ui_sliders(self) -> NDWidgetUI: + return self._sliders_ui + @property def ndgraphics(self): """all the ``NDGraphic`` instances in every subplot of this widget""" diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index b247b4dc9..69cdaaa57 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -88,6 +88,12 @@ def _set_index(self, dim, index): self._ndwidget.indices.set_dim_index(dim, index) def update(self): + if len(self._ndwidget.indices) < 1 and len(self._update_calls) < 2: + # there are no slider dims AND there are no appended UI elements to this window + self.size = 0 + self.collapsed = True + return + now = perf_counter() for dim, current_index in self._ndwidget.indices: From a18f06a25c6df289e0e609be940caca90a7141b1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 05:45:53 -0400 Subject: [PATCH 135/163] imagewidget stuff --- fastplotlib/__init__.py | 2 +- fastplotlib/widgets/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 1e7b30854..00e31c977 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -25,7 +25,7 @@ else: from .layouts import Figure -from .widgets import NDWidget +from .widgets import NDWidget, ImageWidget if len(enumerate_adapters()) < 1: diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index d76eaffd4..fcb95cdfd 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -8,5 +8,6 @@ NDImageSlicer, NDImage, ) +from .image_widget import ImageWidget -__all__ = ["NDWidget"] +__all__ = ["NDWidget", "ImageWidget"] From 0faa5ce94897d1ab73f9da57797bff33898bcb5a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 05:46:06 -0400 Subject: [PATCH 136/163] ImageVolumeGraphic args fix --- fastplotlib/widgets/nd_widget/_nd_image.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 34a6bebb6..4d6de4931 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -466,6 +466,8 @@ async def _create_graphic(self): cls = ImageGraphic case 3: cls = ImageVolumeGraphic + # ImageVolumeGraphic takes no colorspace arg + kwargs.pop("colorspace") # get the data slice for this index # this will only have the dims specified by ``display_dims`` From 6d9c32a752a32f63bbaa038d7d6bc4f42570ee4a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 05:46:35 -0400 Subject: [PATCH 137/163] NDWSubplot dims check only for array-like --- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 67c6e5750..6bb8652bf 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -93,6 +93,9 @@ def _check_slider_dims( # size is unknown, an explicit range is still required return + if not isinstance(data, ArrayProtocol): + return + dims = tuple(dims) slider_dims = set(dims) - set(display_dims) if positions: From f714da25421a38c01d2c1f7a9632103f41a54e70 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 05:47:02 -0400 Subject: [PATCH 138/163] collapsed property on ImaguiWindoW --- fastplotlib/ui/_base.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index 47f828c1c..3cb1497d5 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -3,6 +3,7 @@ from collections.abc import Callable from functools import partial from typing import Literal +from warnings import warn from imgui_bundle import imgui @@ -257,6 +258,22 @@ def height(self) -> int: """height of the window""" return self._height + @property + def collapsed(self) -> bool: + if self._location not in ("bottom", "right"): + # TODO: for now only bottom and right UIs support collapsing due to legacy reasons, will fix later + return False + + return self._collapsed + + @collapsed.setter + def collapsed(self, val: bool): + if self._location not in ("bottom", "right"): + warn("only 'bottom' and 'right' locations support `collapsed`") + return + + self._collapsed = val + @property def _reserves(self) -> bool: """whether this window reserves canvas space, i.e. edge or toolbar windows""" From 94627484c519a2fbf11303a95938866da4c7b9b0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 06:32:07 -0400 Subject: [PATCH 139/163] update iw examples --- examples/image_widget/image_widget.py | 4 ++-- examples/image_widget/image_widget_single_video.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/image_widget/image_widget.py b/examples/image_widget/image_widget.py index a3c332182..5c441950b 100644 --- a/examples/image_widget/image_widget.py +++ b/examples/image_widget/image_widget.py @@ -19,8 +19,8 @@ iw.show() # Access ImageGraphics managed by the image widget -iw.figure[0, 0]["image_widget_managed"].data[:50, :50] = 0 -iw.figure[0, 0]["image_widget_managed"].cmap = "gnuplot2" +iw.managed_graphics[0].data[:50, :50] = 0 +iw.managed_graphics[0].cmap = "gnuplot2" # another way to access the image widget managed ImageGraphics iw.managed_graphics[0].data[450:, 450:] = 255 diff --git a/examples/image_widget/image_widget_single_video.py b/examples/image_widget/image_widget_single_video.py index 86ca642fa..9fece9ae4 100644 --- a/examples/image_widget/image_widget_single_video.py +++ b/examples/image_widget/image_widget_single_video.py @@ -25,13 +25,13 @@ # ImageWidget supports setting window functions the `time` "t" or `volume` "z" dimension # These can also be given as kwargs to `ImageWidget` during instantiation # to set a window function, give a dict in the form of {dim: (func, window_size)} -iw.window_funcs = {"t": (np.mean, 13)} +iw.window_funcs = {"t": (np.mean, 5)} # change the window size -iw.window_funcs["t"].window_size = 33 +iw.window_funcs = {"t": (np.mean, 2)} # change the function -iw.window_funcs["t"].func = np.max +iw.window_funcs = {"t": (np.max, 2)} # or reset it iw.window_funcs = None From e1d996d5f71e4eb793bc35a8a77c54ef5a83e0d5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 06:39:53 -0400 Subject: [PATCH 140/163] colorbar fix --- fastplotlib/ui/_colorbar.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/fastplotlib/ui/_colorbar.py b/fastplotlib/ui/_colorbar.py index 35b80602f..a7425f87a 100644 --- a/fastplotlib/ui/_colorbar.py +++ b/fastplotlib/ui/_colorbar.py @@ -8,6 +8,19 @@ from ._base import ImguiWindow +def colormaps_equal(a: str | Colormap, b: str | Colormap) -> bool: + """ + Whether two colormaps, given as names or as ``Colormap`` instances, are the same. + + ``Colormap.__eq__`` compares the color stops and raises if the two colormaps do not have the + same number of them, which means they are not the same colormap. + """ + try: + return a == b + except ValueError: + return False + + class ImguiColorbar(ImguiWindow): LUT_HEIGHT = 256 TEX_WIDTH = 2 @@ -173,7 +186,10 @@ def cmap(self) -> str: @cmap.setter def cmap(self, name: str): - if self._block_reentrance or name is None or name == self._cmap_name: + if self._block_reentrance or name is None: + return + + if colormaps_equal(name, self._cmap_name): return self._block_reentrance = True try: @@ -190,7 +206,7 @@ def cmap(self, name: str): @property def vmin(self) -> float: """get or set the lower contrast limit""" - return max(self._vmin, self.histogram[1][0]) + return max(self._vmin, self._axis_range()[0]) @vmin.setter def vmin(self, value: float): @@ -209,7 +225,7 @@ def vmin(self, value: float): @property def vmax(self) -> float: """get or set the upper contrast limit""" - return min(self._vmax, self.histogram[1][-1]) + return min(self._vmax, self._axis_range()[1]) @vmax.setter def vmax(self, value: float): @@ -630,6 +646,8 @@ def _draw_popup(self): imgui.same_line() - clicked, selected = imgui.selectable(name, p_selected=(name == self._cmap_name)) + clicked, selected = imgui.selectable( + name, p_selected=colormaps_equal(name, self._cmap_name) + ) if clicked and selected: self.cmap = name From d703c59142f38cbf872aec5eae62cbcf9785502e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 06:41:19 -0400 Subject: [PATCH 141/163] lingering rename --- fastplotlib/widgets/nd_widget/_base.py | 2 +- fastplotlib/widgets/nd_widget/_nd_image.py | 2 +- fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py | 2 +- fastplotlib/widgets/nd_widget/_nd_vectors.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 11b4d9358..626211e9b 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -199,7 +199,7 @@ def display_dims(self, sdims: Sequence[str]): self._display_dims = tuple(sdims) @property - def spatial_dims_indices(self) -> tuple[int, ...]: + def display_dims_indices(self) -> tuple[int, ...]: """ The ordered spatial dim indices that correspond to the named spatial dims """ diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 4d6de4931..28fb31c22 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -250,7 +250,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: if isinstance(window_output, CudaArrayProtocol): window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) - return window_output.transpose(*self.spatial_dims_indices) + return window_output.transpose(*self.display_dims_indices) def _recompute_histogram(self): """ diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index b67ee5379..be83e6c6f 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -400,7 +400,7 @@ async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]: if isinstance(data, CudaArrayProtocol): data = await run_in_thread_pool(self._executor, cuda_to_numpy, data) - data = data.transpose(*self.spatial_dims_indices) + data = data.transpose(*self.display_dims_indices) return { "data": data, diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 52e3a35d5..53c22d96d 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -185,7 +185,7 @@ async def get(self, indices: dict[str, Any]) -> ArrayProtocol: if isinstance(window_output, CudaArrayProtocol): window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) - return window_output.transpose(*self.spatial_dims_indices) + return window_output.transpose(*self.display_dims_indices) class NDVectors(NDGraphic): From 10364d6f2a27f3bee5d63a547175f9daf0a4f3a6 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 09:14:48 -0400 Subject: [PATCH 142/163] fix --- fastplotlib/widgets/nd_widget/_ui.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 69cdaaa57..909614b70 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -251,18 +251,21 @@ def draw(self): imgui.end() def _draw_nd_image_ui(self, subplot, nd_image: NDImage): - _min, _max = quick_min_max(nd_image.graphic.data.value) - changed, vmin = imgui.slider_float( - "vmin", nd_image.graphic.vmin, v_min=_min, v_max=_max - ) - if changed: - nd_image.graphic.vmin = vmin + if nd_image.graphic.data.value is not None: + # if it doesn't have a CPU buffer the value is None + # i.e. data is only on the GPU, e.g. YUV + _min, _max = quick_min_max(nd_image.graphic.data.value) + changed, vmin = imgui.slider_float( + "vmin", nd_image.graphic.vmin, v_min=_min, v_max=_max + ) + if changed: + nd_image.graphic.vmin = vmin - changed, vmax = imgui.slider_float( - "vmax", nd_image.graphic.vmax, v_min=_min, v_max=_max - ) - if changed: - nd_image.graphic.vmax = vmax + changed, vmax = imgui.slider_float( + "vmax", nd_image.graphic.vmax, v_min=_min, v_max=_max + ) + if changed: + nd_image.graphic.vmax = vmax changed, new_gamma = imgui.slider_float( "gamma", nd_image.graphic._material.gamma, 0.01, 5 From 03172083cf42480cabff88a7fb2a6ad5f9cdfed1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 09:14:59 -0400 Subject: [PATCH 143/163] type --- fastplotlib/utils/functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 24d52b3c5..ed944ca0d 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -6,7 +6,7 @@ from pygfx import Texture, Color -from .protocols import CudaArrayProtocol +from .protocols import ArrayProtocol, CudaArrayProtocol cmap_catalog = cmap_lib.Catalog() @@ -342,7 +342,7 @@ def cuda_to_numpy(arr: CudaArrayProtocol) -> np.ndarray: def subsample_array( - arr: CudaArrayProtocol, + arr: ArrayProtocol | CudaArrayProtocol, max_size: int = 1e6, ignore_dims: Sequence[int] | None = None, ) -> np.ndarray: From f079242f86c12fdebb39446a5444802ccc7f5b50 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 09:49:36 -0400 Subject: [PATCH 144/163] clim_quantile for NDImage, other fixes --- fastplotlib/widgets/nd_widget/_base.py | 44 +++++++++++ fastplotlib/widgets/nd_widget/_nd_image.py | 74 +++++++++++++++++-- .../nd_widget/_nd_positions/_nd_positions.py | 12 ++- .../nd_widget/_nd_positions/_nd_timeseries.py | 18 +++-- fastplotlib/widgets/nd_widget/_nd_vectors.py | 3 +- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 15 ++-- fastplotlib/widgets/nd_widget/_video.py | 4 +- 7 files changed, 143 insertions(+), 27 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 626211e9b..6e70a9562 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -27,6 +27,50 @@ def identity(index: int) -> int: return round(index) +def get_init_args(graphic_type: type[Graphic]) -> set[str]: + """ + Named arguments of every ``__init__`` in the MRO of ``graphic_type``. + + Graphics take ``**kwargs`` and pass them up, so the arguments a type accepts are spread + over its whole MRO: ``vmin`` is defined by ``ImageGraphic`` and ``rotation`` by ``Graphic``. + """ + args = set() + + for klass in graphic_type.__mro__: + init = klass.__dict__.get("__init__") + + if init is None: + continue + + for name, param in inspect.signature(init).parameters.items(): + if name == "self" or param.kind in ( + param.VAR_KEYWORD, + param.VAR_POSITIONAL, + ): + continue + + args.add(name) + + return args + + +def get_supported_kwargs(graphic_type: type[Graphic], **kwargs) -> dict[str, Any]: + """ + Keep only the kwargs that ``graphic_type`` accepts. + + The graphic type can change at runtime, and passing ``vmin`` to a line, or ``thickness`` + to an image, raises. + """ + accepted = get_init_args(graphic_type) + + # a collection forwards its kwargs to the graphics it holds + child_type = getattr(graphic_type, "_child_type", None) + if child_type is not None: + accepted |= get_init_args(child_type) + + return {name: value for name, value in kwargs.items() if name in accepted} + + class NDSlicer: def __init__( self, diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index 28fb31c22..cbff414b6 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -20,6 +20,7 @@ NDSlicer, NDGraphic, WindowFuncCallable, + get_supported_kwargs, ) from ._index import ReferenceIndices from ._async import run_in_thread_pool, run_sync @@ -299,6 +300,7 @@ def __init__( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayLike], ArrayLike] = None, compute_histogram: bool = True, + clim_quantiles: tuple[float, float] | None = None, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, slicer_type: type[NDImageSlicer] = NDImageSlicer, colorspace: Literal[ @@ -365,6 +367,11 @@ def __init__( which is used to interactively set vmin, vmax. Disable if random access of the data is not blazing-fast (ex: data that uses video codecs), or if a histogram is not useful for this data. + clim_quantiles : (float, float), optional + ``(low, high)`` quantiles of the histogram, within ``[0, 1]``, used as vmin, vmax. Requires + ``compute_histogram=True``, overrides any passed vmin, vmax in ``graphic_kwargs``. The limits + are recomputed whenever the histogram is, so they follow the data. + slider_maps : dict, optional See :class:`NDSlicer`. @@ -426,6 +433,10 @@ def __init__( self._graphic: ImageGraphic | ImageYUVGraphic | None = None self._histogram_widget: ImguiColorbar | None = None + # validated and stored now, applied by _create_graphic() once the graphic exists + self._clim_quantiles: tuple[float, float] | None = None + self.clim_quantiles = clim_quantiles + # create a graphic run_sync(self._create_graphic()) @@ -466,8 +477,12 @@ async def _create_graphic(self): cls = ImageGraphic case 3: cls = ImageVolumeGraphic - # ImageVolumeGraphic takes no colorspace arg - kwargs.pop("colorspace") + case _: + raise ValueError( + f"Invalid combination of data dims and display_dims for image data." + f"Your passed data object is: {self.data}\n" + f"With dims: {self.dims}, display_dims: {self.display_dims}" + ) # get the data slice for this index # this will only have the dims specified by ``display_dims`` @@ -477,8 +492,7 @@ async def _create_graphic(self): new_graphic = cls( data_slice, # cpu_buffer=False, # faster, we usually don't need a cpu buffer for NDWidget use cases - **kwargs, - **self._graphic_kwargs, + **get_supported_kwargs(cls, **kwargs, **self._graphic_kwargs), ) old_graphic = self._graphic @@ -533,7 +547,13 @@ def _reset_histogram(self): self._histogram_widget, location="right", size=100 ) - self.graphic.reset_vmin_vmax() + if self.clim_quantiles is not None: + self._set_clim_from_quantiles() + + elif {"vmin", "vmax"}.isdisjoint(self._graphic_kwargs): + # limits passed in `graphic_kwargs` are explicit, an estimate from the + # data must not replace them + self.graphic.reset_vmin_vmax() def _reset_camera(self): # set camera to a nice position based on whether it's a 2D ImageGraphic or 3D ImageVolumeGraphic @@ -606,6 +626,50 @@ def compute_histogram(self, v: bool): self.slicer.compute_histogram = v self._reset_histogram() + @property + def clim_quantiles(self) -> tuple[float, float] | None: + """get or set the ``(low, high)`` quantiles of the histogram used as ``vmin``, ``vmax``""" + return self._clim_quantiles + + @clim_quantiles.setter + def clim_quantiles(self, quantiles: tuple[float, float] | None): + if quantiles is not None: + if not self.compute_histogram: + raise ValueError( + "`clim_quantiles` are taken from the histogram, so they require " + "`compute_histogram=True`" + ) + + low, high = (float(q) for q in quantiles) + + if not 0 <= low < high <= 1: + raise ValueError( + f"`clim_quantiles` must be (low, high) within [0, 1] and low < high, " + f"you passed: {quantiles}" + ) + + quantiles = (low, high) + + self._clim_quantiles = quantiles + + if quantiles is not None and self._graphic is not None: + self._set_clim_from_quantiles() + + def _set_clim_from_quantiles(self): + """set vmin, vmax from the values at the ``clim_quantiles`` of the histogram""" + counts, edges = self.slicer.histogram + total = counts.sum() + + if total == 0: + # nothing to take a quantile of, ex: data that is entirely nan + return + + # fraction of the data at or below the right edge of each bin + cdf = np.cumsum(counts) / total + low, high = np.searchsorted(cdf, self._clim_quantiles) + + self.graphic.vmin, self.graphic.vmax = float(edges[low]), float(edges[high + 1]) + @property def histogram_widget(self) -> ImguiColorbar: """The colorbar associated with this NDGraphic""" diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index be83e6c6f..1a80c26fd 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -18,6 +18,7 @@ NDSlicer, NDGraphic, WindowFuncCallable, + get_supported_kwargs, ) from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy from .._index import ReferenceIndices @@ -902,14 +903,11 @@ def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): """Build and add the graphic for the current slice.""" data_slice = new_features["data"] # [n_graphics, n_datapoints, xy(z)] - # skip any static feature the graphic type doesn't have, e.g. thickness on scatters - static = { - name: value - for name, value in self._static_features.items() - if hasattr(self._graphic_type, name) - } self._graphic = self._graphic_type( - data_slice, **static, **self._graphic_kwargs + data_slice, + **get_supported_kwargs( + self._graphic_type, **self._static_features, **self._graphic_kwargs + ), ) self._set_other_features(new_features) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py index a23f95bbf..007bf7b06 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py @@ -16,7 +16,12 @@ from ....graphics.utils import pause_events from ....graphics.selectors import LinearSelector from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy -from .._base import NDGraphic, WindowFuncCallable, block_indices_ctx +from .._base import ( + NDGraphic, + WindowFuncCallable, + block_indices_ctx, + get_supported_kwargs, +) from .._index import ReferenceIndices from .._async import run_sync from ._nd_positions import ( @@ -326,12 +331,15 @@ def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): raise ValueError image_data, x0, x_scale = self._create_heatmap_data(data_slice) + self._graphic = self._graphic_type( - image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1) + image_data, + offset=(x0, 0, -1), + scale=(x_scale, 1, 1), + **get_supported_kwargs( + self._graphic_type, **self._static_features, **self._graphic_kwargs + ), ) - cmap = self._static_features.get("cmap") - if cmap is not None: - self._graphic.cmap = cmap self._nd_subplot.subplot.add_graphic(self._graphic) else: super()._setup_graphic(new_features, indices) diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 53c22d96d..9ffc9eb09 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -16,6 +16,7 @@ NDSlicer, NDGraphic, WindowFuncCallable, + get_supported_kwargs, ) from ._index import ReferenceIndices from ._async import run_in_thread_pool, run_sync @@ -335,7 +336,7 @@ async def _create_graphic(self): self._graphic = VectorsGraphic( positions=data_slice[:, 0], directions=data_slice[:, 1], - **self._graphic_kwargs + **get_supported_kwargs(VectorsGraphic, **self._graphic_kwargs), ) self._nd_subplot.subplot.add_graphic(self._graphic) diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 6bb8652bf..397b82778 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -132,6 +132,7 @@ def add_nd_image( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, compute_histogram: bool = True, + clim_quantiles: tuple[float, float] | None = None, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, slicer_type: type[NDImageSlicer] = NDImageSlicer, colorspace: Literal[ @@ -189,6 +190,11 @@ def add_nd_image( which is used to interactively set vmin, vmax. Disable if random access of the data is not blazing-fast (ex: data that uses video codecs), or if a histogram is not useful for this data. + clim_quantiles: (float, float), optional + ``(low, high)`` quantiles of the histogram, within ``[0, 1]``, used as vmin, vmax. Requires + ``compute_histogram=True``, overrides any passed vmin, vmax in ``graphic_kwargs``. The limits + are recomputed whenever the histogram is, so they follow the data. + slider_maps: dict mapping dim_name -> Callable, an ArrayLike, or None, optional Per-slider-dim mapping from reference-space values to local array indices. An array of reference values may be given instead of a callable, ``searchsorted`` is then used as the transform (ex: a @@ -230,6 +236,7 @@ def add_nd_image( window_order=window_order, spatial_func=spatial_func, compute_histogram=compute_histogram, + clim_quantiles=clim_quantiles, slider_maps=slider_maps, slicer_type=slicer_type, colorspace=colorspace, @@ -255,7 +262,6 @@ def add_video( ] = None, window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, - compute_histogram: bool = True, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, name: str = None, graphic_kwargs: dict = None, @@ -311,11 +317,6 @@ def add_video( spatial_func: Callable[[ArrayProtocol], ArrayProtocol], optional A function applied to the spatial slice right before rendering. - compute_histogram: bool, default ``True`` - Estimate a histogram of the data and display an ``ImguiColorbar`` on the right edge of the subplot, - which is used to interactively set vmin, vmax. Usually disabled for video since it requires random - access of frames, which is slow for data that uses video codecs. - slider_maps: dict mapping dim_name -> Callable, an ArrayLike, or None, optional Per-slider-dim mapping from reference-space values to local array indices, ex: an array of frame timestamps to map seconds onto frame indices. See :meth:`add_nd_image`. @@ -342,7 +343,7 @@ def add_video( window_funcs=window_funcs, window_order=window_order, spatial_func=spatial_func, - compute_histogram=compute_histogram, + compute_histogram=False, slider_maps=slider_maps, name=name, graphic_kwargs=graphic_kwargs, diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py index 2274eaf35..a9ca58fd3 100644 --- a/fastplotlib/widgets/nd_widget/_video.py +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -54,6 +54,6 @@ async def get(self, indices: dict[str, Any]) -> TupleYUV | np.ndarray: ) if isinstance(window_output, tuple): - return tuple(a.transpose(*self.spatial_dims_indices) for a in window_output) + return tuple(a.transpose(*self.display_dims_indices) for a in window_output) - return window_output.transpose(*self.spatial_dims_indices) + return window_output.transpose(*self.display_dims_indices) From 1a980cebc49562638be591983f744bc9cff7c480 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 10:35:57 -0400 Subject: [PATCH 145/163] public 'max_display_datapoints' property --- fastplotlib/widgets/nd_widget/CLAUDE.md | 658 ++++++++++++++++++ .../nd_widget/_nd_positions/_nd_positions.py | 94 ++- .../nd_widget/_nd_positions/_nd_timeseries.py | 8 +- fastplotlib/widgets/nd_widget/_ndw_subplot.py | 24 +- 4 files changed, 756 insertions(+), 28 deletions(-) create mode 100644 fastplotlib/widgets/nd_widget/CLAUDE.md diff --git a/fastplotlib/widgets/nd_widget/CLAUDE.md b/fastplotlib/widgets/nd_widget/CLAUDE.md new file mode 100644 index 000000000..b9d25ecac --- /dev/null +++ b/fastplotlib/widgets/nd_widget/CLAUDE.md @@ -0,0 +1,658 @@ +# NDWidget — browsing and synchronizing n-dimensional data + +`fpl.NDWidget` is the answer whenever the user wants to **scroll through** n-dimensional data (time, z, trial, +channel) or wants **several datasets locked to one shared reference index**. It gives you a slider per extra +dimension, playback controls, async data fetching, and out-of-core windowing, for free. Requires +`imgui-bundle`. + +Do not hand-roll sliders with ipywidgets or imgui, and do not write your own "current frame" state. + +## The mental model + +For each array you add, you: + +1. **name every dimension** in array order → `dims` +2. **say which dims are rendered**, in display order → `display_dims` +3. everything left over becomes a **slider dim** + +Slider positions live in **reference space** — real units, usually seconds — shared by every graphic +in the widget. Each array converts a reference value to its own index with `slider_maps`. That is +what lets a 30 Hz video and a 30 kHz recording sit on one slider. + +## Minimal example + +```python +import numpy as np +import fastplotlib as fpl + +movie = np.random.rand(1000, 30, 512, 512).astype(np.float32) # [time, z, row, col] + +ndw = fpl.NDWidget(ranges={"time": (0, 1000, 1), "depth": (0, 30, 1)}, size=(700, 560)) + +ndw[0, 0].add_nd_image( + movie, + ("time", "depth", "row", "col"), # every dim, in array order + ("row", "col"), # the rendered dims, in display order + name="movie", +) + +ndw.show() + +if __name__ == "__main__": + fpl.loop.run() +``` + +`ranges` is `{dim_name: (start, stop, step)}` in reference units. `step` is what the step button +and playback advance by. A slider dim with no entry gets an auto range of `(0, size, 1)` **and a +warning** — always pass `ranges` explicitly. + +## The dims you name decide everything + +`NDWidget` is fundamentally an n-dimensional data viewer: **an arbitrary slice of an n-dimensional +array is mapped to an arbitrarily chosen graphical representation** — image, image volume, line, +scatter, or vectors. Which dims are rendered and which become sliders is entirely your choice of +`dims`/`display_dims`, and two graphics move together **if and only if they use the same dim name**. + +Everything below follows from that one rule. None of it is a special feature. + +### Multi-plane imaging + +Several arrays each `[n_planes, n_timepoints, rows, cols]` — raw and dF/F, say. Name the dims +identically and both subplots share one plane slider and one time slider: + +```python +ndw = fpl.NDWidget( + ranges={"time": (0.0, 40.0, 1 / 10), "plane": (0, n_planes, 1)}, + shape=(1, 2), names=["raw", "dff"], size=(900, 450), +) + +for name, arr in (("raw", raw), ("dff", dff)): + ndw[name].add_nd_image( + arr, + ("plane", "time", "m", "n"), # every dim, in array order + ("m", "n"), # render one plane; `plane` and `time` become sliders + slider_maps={"time": times}, + graphic_kwargs={"cmap": "gray"}, + name=name, + ) +``` + +To scroll the two **independently**, give the plane dim a different name in each — +`("plane_raw", "time", "m", "n")` and `("plane_dff", "time", "m", "n")`, with a range for each. Same +arrays, same code, two sliders instead of one. + +To see **every plane at once**, slice the plane axis and give each its own subplot. `arr[i]` on a lazy +array is still lazy, so this stays out-of-core: + +```python +ndw = fpl.NDWidget( + ranges={"time": (0.0, 40.0, 1 / 10)}, shape=(2, 3), + names=[[f"plane-{i}" for i in range(3)], [f"plane-{i}" for i in range(3, 6)]], + size=(1000, 700), +) + +for i in range(n_planes): + ndw[f"plane-{i}"].add_nd_image( + raw[i], ("time", "m", "n"), ("m", "n"), + slider_maps={"time": times}, graphic_kwargs={"cmap": "gray"}, name=f"plane-{i}", + ) +``` + +### Multi-FOV imaging + +`[n_fovs, n_timepoints, rows, cols]` is the same shape with a different meaning, so it is the same +code. One subplot with the FOV on a slider: + +```python +ndw[0, 0].add_nd_image(fovs, ("fov", "time", "m", "n"), ("m", "n"), + slider_maps={"time": times}, graphic_kwargs={"cmap": "gray"}) +``` + +or one subplot per FOV — which is what you want when **each FOV has its own frame timestamps**, +since each then gets its own `slider_maps` entry: + +```python +for i in range(n_fovs): + ndw[f"fov-{i}"].add_nd_image( + fovs[i], ("time", "m", "n"), ("m", "n"), + slider_maps={"time": fov_times[i]}, # this FOV's own frame times + graphic_kwargs={"cmap": "gray"}, name=f"fov-{i}", + ) +``` + +### Combining them + +Nothing stops you stacking the two. `[n_fovs, n_planes, n_timepoints, rows, cols]` with +`display_dims=("m", "n")` leaves three sliders: + +```python +ndw = fpl.NDWidget( + ranges={"time": (0.0, 10.0, 0.1), "fov": (0, n_fovs, 1), "plane": (0, n_planes, 1)}, + size=(600, 500), +) +ndw[0, 0].add_nd_image(data, ("fov", "plane", "time", "m", "n"), ("m", "n"), + slider_maps={"time": times}) +ndw.indices = {"fov": 1, "plane": 2, "time": 5.0} +``` + +The same array can also appear under more than one representation. Here the movie subplot shows the +plane you are scrolled to, while the timeseries subplot draws every plane's mean at once, because +`plane` is a *spatial* dim there (`n_graphics`) rather than a slider dim: + +```python +ndw["movie"].add_nd_image(raw, ("plane", "time", "m", "n"), ("m", "n"), + slider_maps={"time": times}, graphic_kwargs={"cmap": "gray"}) + +ndw["means"].add_nd_timeseries( + fpl.utils.heatmap_to_positions(raw.mean(axis=(2, 3)), xvals=times), + ("plane", "time", "xy"), ("plane", "time", "xy"), + slider_maps={"time": times}, display_window=10.0, x_range_mode="auto", cmap="tab10", +) +``` + +Whether a dim is a slider or an axis of the drawing is the whole design decision. Make it +deliberately, and say which you chose when you hand the code over. + +## Multi-modal: one reference space, many subplots + +This is the pattern that matters. Name the subplots, lay them out with fractional `extents`, give each +modality its own `slider_maps` in seconds, and link the x axis of the time-series subplots. + +```python +extents = { + "video": (0, 0.4, 0, 1), + "traces": (0.4, 1, 0, 0.5), + "raster": (0.4, 1, 0.5, 1), +} + +ndw = fpl.NDWidget( + ranges={"time": (0.0, 600.0, 1 / 30)}, # 10 minutes, 30 Hz steps + extents=extents, + size=(1400, 800), +) + +ndw["video"].add_video( + reader, # a lazy frame-decoding object + dims=("time", "m", "n"), + display_dims=("m", "n"), + slider_maps={"time": frame_timestamps}, # seconds -> frame index + compute_histogram=False, + name="video", +) + +ndw["traces"].add_nd_timeseries( + traces, # [n_cells, n_samples, 2] + ("cell", "time", "xy"), + ("cell", "time", "xy"), + slider_maps={"time": trace_timestamps}, # seconds -> sample index + display_window=10.0, # render 10 seconds at a time + cmap="tab10", + x_range_mode="auto", + name="traces", +) + +for name in ("traces", "raster"): + subplot = ndw.figure[name] + subplot.controller.add_camera(subplot.camera, include_state={"x", "width"}) + +ndw.show(maintain_aspect=False) +``` + +To drive **separate windows** off one reference index, share the `ReferenceIndices`: + +```python +ndw_main = fpl.NDWidget(ranges={"time": (0, 600, 1 / 30)}, extents=extents, names=names) +ndw_ephys = fpl.NDWidget(indices=ndw_main.indices, names=["spikes"], size=(1400, 400)) +``` + +Only the first passes `ranges`; the rest pass `indices=`. + +### A complete multi-modal viewer + +Behavior video, an audio spectrogram, a spike raster, a hand-scored ethogram and keypoint tracking, +all in one reference space in seconds, across two windows. Five different graphical representations of five +acquisition systems; the only thing tying them together is the dim named `"time"` and each +modality's own `slider_maps`. + +```python +# 1. one reference range: the intersection of what every modality covers +start = max(vid.time[0], t_spec[0], counts.t[0]) +stop = min(vid.time[-1], t_spec[-1], counts.t[-1]) + +extents = { # fractions of the canvas, named subplots + "video": (0, 0.35, 0, 0.6), + "keypoints":(0, 0.35, 0.6, 1), + "spec": (0.35, 1, 0, 0.3), + "raster": (0.35, 1, 0.3, 0.65), + "ethogram": (0.35, 1, 0.65, 1), +} +ndw = fpl.NDWidget(ranges={"time": (start, stop, 1 / 30)}, extents=extents, size=(1500, 900)) + +# 2. video — YUV planes straight to the GPU, no per-frame RGB conversion +ndw["video"].add_video( + vid, dims=("time", "m", "n"), display_dims=("m", "n"), + slider_maps={"time": vid.time}, compute_histogram=False, name="frame", +) + +# 3. pose tracking, overlaid on the same subplot, showing a 2 second display_window of positions +ndw["video"].add_nd_scatter( + keypoints_xy, ("kp", "time", "xy"), ("kp", "time", "xy"), # [n_keypoints, n_frames, 2] + slider_maps={"time": vid.time}, display_window=2.0, cmap="tab10", sizes=8, name="keypoints", +) + +# 4. spectrogram — a heatmap that stays on the shared reference index, with a real frequency axis +spec_ndg = ndw["spec"].add_nd_timeseries( + np.dstack([np.broadcast_to(t_spec[None, :], spec.shape), spec]).astype(np.float32), + ("freq", "time", "xy"), ("freq", "time", "xy"), + graphic_type=fpl.ImageGraphic, slider_maps={"time": t_spec}, + display_window=5.0, x_range_mode="auto", + graphic_kwargs={"cmap": "viridis", "metadata": {"f": f_spec}}, name="spec", +) + +# 5. spike raster — pynapple does the binning, heatmap_to_positions does the reshape +ndw["raster"].add_nd_timeseries( + fpl.utils.heatmap_to_positions(counts.values.T, xvals=counts.t), + ("unit", "time", "xy"), ("unit", "time", "xy"), + graphic_type=fpl.ImageGraphic, slider_maps={"time": counts.t}, + display_window=5.0, x_range_mode="auto", + graphic_kwargs={"cmap": "gray_r"}, name="raster", +) + +# 6. ethogram — integer state codes with a discrete colormap, so code k is always color k +eth_ndg = ndw["ethogram"].add_nd_timeseries( + np.dstack([np.broadcast_to(eth_times[None], codes.shape), codes]).astype(np.float32), + ("behavior", "time", "xy"), ("behavior", "time", "xy"), + graphic_type=fpl.ImageGraphic, slider_maps={"time": eth_times}, + display_window=5.0, x_range_mode="auto", + graphic_kwargs={"cmap": cmap.Colormap(["white", "green", "orange", "red"]), + "vmin": 0, "vmax": 3}, + name="ethogram", +) + +# 7. link the time axis of every time-series subplot; each keeps its own y scale +for name in ("spec", "raster", "ethogram"): + subplot = ndw.figure[name] + subplot.controller.add_camera(subplot.camera, include_state={"x", "width"}) + subplot.camera.maintain_aspect = False + +# 8. a row index is not a frequency or a behavior — say so in the axis and the tooltip +ndw.figure["spec"].axes.y.tick_format = ( + lambda v, lo, hi: f"{round(f_spec[min(max(round(v), 0), f_spec.size - 1)] / 1e3)} kHz" +) +spec_ndg.graphic.tooltip_format = lambda pi: f"{f_spec[pi['index'][1]] / 1e3:.1f} kHz" +eth_ndg.graphic.tooltip_format = lambda pi: BEHAVIORS[ + round(eth_ndg.graphic.data[pi["index"][1], pi["index"][0]]) +] + +cursor = fpl.Cursor() +cursor.add_subplot(ndw.figure["video"]) + +# 9. a second window on the same reference index +ndw_traces = fpl.NDWidget(indices=ndw.indices, names=["traces"], size=(1500, 300)) +ndw_traces["traces"].add_nd_timeseries( + fpl.utils.heatmap_to_positions(dff, xvals=frame_times), + ("cell", "time", "xy"), ("cell", "time", "xy"), + slider_maps={"time": frame_times}, display_window=5.0, x_range_mode="auto", cmap="tab10", +) + +# 10. a control that re-runs the analysis, preserving the view +@ndw.figure["raster"].add_imgui_window(location="top", size=36, title=None) +def bin_size_ui(subplot): + global bin_ms + changed, new_ms = imgui.input_int("bin size (ms)", v=bin_ms, step=10) + if changed: + bin_ms = max(new_ms, 1) + state = subplot.camera.get_state() + counts = spikes.count(bin_size=bin_ms / 1000, time_units="s", ep=ep) + ndg = ndw["raster"]["raster"] + ndg.data = fpl.utils.heatmap_to_positions(counts.values.T, xvals=counts.t) + ndg.slider_maps = {"time": counts.t} + subplot.camera.set_state(state) + +for subplot in ndw.figure: + subplot.toolbar = False +ndw.figure["video"].axes.visible = False + +ndw.show(maintain_aspect=False) +ndw_traces.show(maintain_aspect=False) + +if __name__ == "__main__": + fpl.loop.run() +``` + +Five things in there generalize to any multi-modal viewer: + +- **Panels are named, laid out as fractions**, and addressed by name everywhere afterwards. +- **Each modality maps the shared reference index onto its own indices** with its own recorded timestamps. + Nothing is resampled onto a common rate. +- **Anything heatmap-shaped goes through `heatmap_to_positions` + `graphic_type=fpl.ImageGraphic`** + rather than `add_image`, so it keeps a real time axis and stays on the shared reference index. +- **A row index is never a physical quantity.** Convert it in `axes..tick_format` and in + `tooltip_format`, and carry whatever they need in `graphic_kwargs={"metadata": ...}`. +- **A UI control recomputes through the domain library** and restores the camera state, instead of + mutating an already-derived array. + +## The `add_nd_*` methods + +```python +add_nd_image(data, dims, display_dims, rgb_dim=None, window_funcs=None, window_order=None, + spatial_func=None, compute_histogram=True, slider_maps=None, + slicer_type=NDImageSlicer, colorspace="srgb", colorrange="full", + name=None, graphic_kwargs=None) + +add_video(data, dims, display_dims, rgb_dim=None, colorspace="yuv420p", colorrange="limited", + slicer_type=VideoSlicer, window_funcs=None, window_order=None, spatial_func=None, + compute_histogram=True, slider_maps=None, name=None, graphic_kwargs=None) + +add_nd_timeseries(data, dims, display_dims, *, graphic_type=LineStack, x_range_mode="auto", + slicer=NDPositionsSlicer, display_window=10, window_funcs=None, + window_order=None, spatial_func=None, slider_maps=None, + max_display_datapoints=1000, datapoints_window_func=None, + colors=None, cmap=None, cmap_transform=None, cmap_range=None, + thickness=None, sizes=None, markers=None, + name=None, graphic_kwargs=None, slicer_kwargs=None) + +add_nd_lines(...) # same, graphic_type is LineCollection, no x_range_mode +add_nd_scatter(...) # same, graphic_type is ScatterCollection, no thickness +add_nd_vectors(data, dims, display_dims, window_funcs=None, window_order=None, + spatial_func=None, slider_maps=None, name=None, graphic_kwargs=None) +``` + +`display_dims` picks the graphic for images: + +| `display_dims` | renders as | +|---|---| +| `(rows, cols)` | grayscale `ImageGraphic` | +| `(rows, cols, rgb_dim)` | RGB(A) `ImageGraphic` — you must also pass `rgb_dim=` | +| `(z, rows, cols)` | `ImageVolumeGraphic` — **currently broken, see Known issues** | +| a YUV `colorspace` | `ImageYUVGraphic` | + +For positions and timeseries, `display_dims` is always +**`(n_graphics, p, value_dim)`** — how many traces, how many datapoints each (`p`), and the +coordinate dim (size 2 for xy, 3 for xyz). The dims need not be in that order in the array; the +slice is transposed for you. + +`graphic_type` on `add_nd_timeseries` is `LineStack` (default), `LineCollection`, +`ScatterCollection`, `ScatterStack`, or **`fpl.ImageGraphic`** for a heatmap — one row per trace, +color from the y value. A heatmap needs a value dim of exactly 2. `fpl.utils.heatmap_to_positions` +converts `[n_rows, n_timepoints]` into the `[n_rows, n_timepoints, 2]` these expect. + +`add_nd_timeseries` also adds a `LinearSelector` marking the current position of `p`; dragging it +sets the index for every graphic on that dim. + +`graphic_kwargs` is passed to the underlying graphic, e.g. +`graphic_kwargs={"cmap": "gray", "interpolation": "linear", "metadata": {...}}`. + +## `slider_maps` — get the units right or the plot is wrong + +`{dim_name: array_or_callable}` mapping a reference value to that array's index. + +```python +slider_maps={"time": frame_timestamps} # array -> searchsorted for you +slider_maps={"time": lambda t: int(t * fs)} # callable +slider_maps={"time": recording.get_times()} # whatever the library gives you +``` + +- **Pass the recorded timestamps array**, not a nominal rate. Clocks drift and frames drop. +- **No entry means identity + round** — the reference value is used directly as an index. Correct + only when the reference units *are* indices. +- The result is clamped into `[0, size)`, so an out-of-range value pins to an end silently rather + than raising. +- The setter mutates the dict you pass (arrays are replaced by their bound `.searchsorted`, missing + dims filled with identity), so pass a fresh dict or `.copy()` if you reuse one. + +When several modalities cover different spans, the reference range is their **intersection**: +`start = max(all_starts)`, `stop = min(all_stops)`. + +## Out-of-core: `display_window` and `max_display_datapoints` + +On positions/timeseries, the datapoints dim `p` is both spatial and a slider dim: + +- `display_window` — how much of `p` to render, **in `p`'s reference units** (e.g. `10.0` seconds). + `None` renders everything. This is what makes a dataset bigger than VRAM viewable. +- `max_display_datapoints` (default 1000) — caps the rendered points per graphic by setting the + *step* of the window slice. Raise it deliberately; the ephys examples use `1_000_000`. +- `x_range_mode="auto"` couples the camera to the window: panning or zooming sets the window width + and centre. `"fixed"` sets the range from `display_window` only. `None` leaves the camera alone. + +## `window_funcs` — reducing over a slider dim + +```python +window_funcs={"time": (np.mean, 2.5)}, # average over 2.5 seconds around the current position +window_order=("time",), # only dims listed here actually apply +``` + +The function must accept `axis` and `keepdims` and **must not drop the dimension** — the windowed +dim reduces to size 1 but has to stay. `window_size` is in reference units. + +`spatial_func` is applied to the rendered slice afterwards, e.g. a spatial filter. + +`datapoints_window_func=(func, apply_dims, window_size)` reduces along `p` after the display window; +`func` takes only `axis`, and `apply_dims` names which coordinates it applies to (`"y"`, `"xy"`, +`"all"`, ...). + +## Colors, colormaps and sizes: windowed or static + +Decided from the value you pass, not from a keyword: + +- **static** — one value for all graphics, `[n_graphics]` values, or an iterator such as + `itertools.cycle(["jet", "viridis"])`. Set once. +- **windowed** — an array whose axis 1 spans the **full** `p` dim (`[n_graphics, p, ...]`), or a + callable `f(data_slice, dw_slice) -> values`. Re-sliced with the data on every update, so it + carries one value per *displayed* datapoint. + +A callable is how you drive appearance from another signal, e.g. tracking confidence as alpha: + +```python +def alpha_from_likelihood(data, dw_slice): + p = dw_slice.stop - dw_slice.start + colors = keypoint_colors[:, None, :].repeat(p, axis=1) # [n_graphics, p, 4] + colors[-1, :, -1] = likelihood[dw_slice] # alpha of the last keypoint + return colors + +ndw["video"].add_nd_scatter(..., colors=alpha_from_likelihood) +``` + +`colors` and `cmap` are mutually exclusive; setting one clears the other. A windowed array +`cmap_transform` takes its `cmap_range` from the transform's min/max over the **full** `p` dim, so a +point's color does not change as the window slides. A callable transform therefore needs an explicit +`cmap_range`. + +## Reading and driving the index + +```python +ndw.indices # the shared ReferenceIndices +ndw.indices = {"time": 12.5} # jump (clamped; unlisted dims keep their value) +ndw.indices["time"] # current value for one dim +ndw.ranges # {dim: range} +``` + +`ndw.indices.add_event_handler(fn, "indices")` also exists. **Do not use it to keep a graphic in +step with the sliders.** Anything that should follow the sliders belongs in an `NDGraphic`, so that +it goes through the same async fetching, windowing and index scheduling as everything else. A +handler on the `"indices"` event runs synchronously on every index change, so it blocks the render +loop and bypasses that scheduling — a slider drag goes from responsive to stuttering. + +Use it only for things that are not data: logging, syncing an external device, updating a non-fastplotlib +widget. If you reach for it to draw something, subclass instead — see **Extending: custom slicers and +graphics** below. + +## Getting at the graphic and the data + +```python +ndg = ndw["traces"].add_nd_timeseries(..., graphic_kwargs={"cmap": "gray_r", "vmin": 0, "vmax": 5}) + +ndg.graphic # the underlying LineStack / ImageGraphic / ScatterCollection +ndg.graphic.tooltip_format = lambda pick_info: "..." +ndg.data = new_array # swap the data; dims and display_dims are kept +ndg.display_window = 30.0 +ndg.graphic_type = fpl.ImageGraphic # switch representation live +ndg.pause = True # stop this graphic following the sliders + +ndw[0, 0]["traces"] # an NDGraphic by name +ndw.ndgraphics # all of them, across every subplot +ndw.figure["traces"] # the plain Subplot, for cameras/axes/imgui +``` + +Set graphic properties through the `NDPositions` wrapper (`ndg.colors`, `ndg.cmap`, `ndg.sizes`, +`ndg.thickness`, `ndg.markers`) when the value should be re-applied on every window update; set them +on `ndg.graphic` for a one-off change. + +## Anti-patterns + +| Do not | Do instead | +|---|---| +| build ipywidgets/imgui sliders for an nD array | `fpl.NDWidget` | +| keep your own `current_frame` and write `image.data = movie[i]` in a callback | `add_nd_image` / `add_video` with `slider_maps` | +| `fpl.ImageWidget` | `fpl.NDWidget` (`ImageWidget` is broken and unexported) | +| omit `ranges` and accept the auto-range warning if you're visualizing multi-modal data where each array has a different sampling rate | pass `ranges` in real units | +| convert time to indices on your own, e.g. `int(time * sampling_freq)`, when you have timestamps | `slider_maps={"time": timestamps}` | +| load a whole session and slice it in numpy | pass the lazy reader, and set `display_window` for positional data | +| `display_window=None` on a large dataset/array | a window in seconds; `None` reads everything | +| `compute_histogram=True` for video | `False` — it needs random frame access and is very slow | +| one `NDWidget` per modality with separate sliders | one `ranges`, then `indices=ndw.indices` for the rest | +| `add_nd_image` for a video file | use `add_video` and the `asyncvideo` library (https://pypi.org/project/asyncvideo/), YUV planes straight to the GPU, no per-frame RGB conversion, order of magnitude faster | +| a `for` loop over `ndg.graphic` to set a property every frame | pass the property to `add_nd_*` so it is re-applied by the window machinery | +| plotting a `[n_cells, n_timepoints]` heatmap array directly as an image | `fpl.utils.heatmap_to_positions` + `add_nd_timeseries(graphic_type=fpl.ImageGraphic)`, so it stays on the shared reference index | + +## Custom data sources + +`ndp_extras.Pandas` (available when pandas is installed) reads positional data from DataFrame +columns instead of an array — one `(x_col, y_col)` tuple per graphic, which is exactly the shape of +pose-tracking output. The third positional becomes the slicer's `columns`: + +```python +ndw[0, 0].add_nd_scatter( + df, ("l", "time", "d"), + [(f"{k}_x", f"{k}_y") for k in keypoints], # -> PandasSlicer(columns=...) + slicer=ndp_extras.Pandas, + slider_maps={"time": df["times"].values}, + display_window=5.0, +) +``` + +**This currently raises** — see Known issues. Until it is fixed, build the array yourself: + +```python +xy = np.dstack([ # [n_keypoints, n_frames, 2] + np.stack([df[f"{k}_x"] for k in keypoints]), + np.stack([df[f"{k}_y"] for k in keypoints]), +]).astype(np.float32) +ndw[0, 0].add_nd_scatter(xy, ("kp", "time", "xy"), ("kp", "time", "xy"), + slider_maps={"time": df["times"].values}, display_window=5.0) +``` + +## Extending: custom slicers and graphics + +Subclass `NDSlicer` for more customized data loading, and/or `NDGraphic` for more customized +rendering. + +### `NDSlicer` — customized data loading + +Write a slicer when you need one for your own specific objects, a lazy data loader, any other form +of accessor, or to create the data in a more customized way. `data` does not have to be an array — +the slicer only has to operate as if the `dims` exist and return a slice the graphic can render. + +Subclass the slicer whose output the graphic expects: `NDImageSlicer` for images and volumes, +`NDPositionsSlicer` for lines/scatters/timeseries, `NDVectorsSlicer` for vectors. + +This reads traces out of a `spikeinterface` recording, loading only the current display window into +RAM (adapted from `ephys_utils.py` in the neuro examples repo): + +```python +import numpy as np +from spikeinterface import BaseRecording +from fastplotlib.widgets.nd_widget import NDPositionsSlicer + + +class RecordingSlicer(NDPositionsSlicer): + @property + def data(self) -> BaseRecording: + return self._data + + @data.setter + def data(self, recording: BaseRecording): + self._data = recording + + @property + def shape(self) -> dict[str, int]: + # interpreted shape, keyed by dim name, in display order + l, p, d = self.display_dims + return { + l: self.data.get_num_channels(), # one graphic per channel + p: self.data.get_num_samples(), # the datapoints dim + d: 2, # xy + } + + async def get(self, indices) -> dict[str, np.ndarray]: + # display window as array indices; slice.step comes from max_display_datapoints + s = self._get_dw_slice(indices) + + xs = self.data.get_times()[s] + ys = self.data.get_traces(start_frame=s.start, end_frame=s.stop)[:: s.step] + + # -> [n_channels, n_datapoints, xy] + return {"data": np.stack([np.broadcast_to(xs[:, None], ys.shape), ys]).T} +``` + +```python +ndw["recording"].add_nd_timeseries( + recording, ("l", "time", "d"), ("l", "time", "d"), + slicer=RecordingSlicer, + graphic_type=fpl.ImageGraphic, # can be changed to a LineStack at runtime + slider_maps={"time": lambda t: recording.time_to_sample_index(t)}, + display_window=0.05, + x_range_mode="auto", + graphic_kwargs={"cmap": "seismic", "vmin": -5, "vmax": 5}, +) +``` + +What a subclass must provide: + +- **`async def get(self, indices)`**, the one required method. `indices` is in reference-space units. + `_get_dw_slice(indices)` returns the array slice for the current display window; + `_ref_index_to_array_index(dim, value)` maps a single dim. Return a dict whose `"data"` key holds + the array, shaped as `display_dims`. +- **`shape`**, a dict keyed by dim name, and **`data`** if the object is not an array. + `_get_dw_slice` and `NDWSubplot._check_slider_dims` both read the dim sizes from `shape`. +- Blocking reads go in `run_in_thread_pool(self._executor, fn, ...)`; a reader that returns a future + is awaited with `wait_for_future`. Doing the read inline blocks the render loop and the sliders + stutter. +- Pass it as `slicer_type=` to `add_nd_image`/`add_video`, or `slicer=` to + `add_nd_lines`/`add_nd_scatter`/`add_nd_timeseries`. Extra constructor arguments go through + `slicer_kwargs=`, or as trailing positionals — that is how `PandasSlicer` receives `columns`. + +### `NDGraphic` — customized rendering + +Needed when no existing `NDGraphic` renders your slice, i.e. you want a representation that +`NDImage`, `NDPositions`, `NDTimeseries` and `NDVectors` do not cover — a mesh, a surface, a polygon +collection. Implement `_create_graphic()` to build the `Graphic` from the first slice and add it to +`self._nd_subplot.subplot`, and `_set_indices_(indices)` to write each new slice into that graphic +in place, allocating a new buffer only when the shape changes. Then add an `add_nd_()` to +`NDWSubplot` that calls `_check_slider_dims`, constructs it, appends it to `self._nd_graphics` and +returns it. + +If you only need a different data source for an existing representation, subclass the slicer and +keep the graphic. + +## Known issues + +Verified against the current checkout. Work around them; do not try to fix them without asking. + +1. **Volumes through `add_nd_image` raise.** `display_dims=(z, rows, cols)` hits + `TypeError: Graphic.__init__() got an unexpected keyword argument 'colorspace'` — `_create_graphic` + passes `colorspace` to every image class, and `ImageVolumeGraphic` does not take it. Until it is + fixed, render `(rows, cols)` and leave `z` as a slider dim, or use a plain + `figure[0, 0].add_image_volume(...)` outside the widget. + +2. **`slicer=ndp_extras.Pandas` raises** from `NDWSubplot._check_slider_dims`, which treats the third + positional as dim names rather than columns and then indexes `data.shape` with them + (`IndexError: tuple index out of range`). Constructing `PandasSlicer(...)` directly works; only + the `add_nd_*` route is broken. Use the array workaround above. + +3. **Assigning `graphic.cmap` after construction desyncs the colorbar.** With + `compute_histogram=True` (the default for `add_nd_image`), `ndg.graphic.cmap = ...` raises inside + `ImguiColorbar._image_event_handler` (`Colormap.__eq__` on colormaps with different stop counts). + `rendercanvas` swallows it, so the image updates and the colorbar silently does not. **Pass the + colormap at construction instead**: `graphic_kwargs={"cmap": "gray_r", "vmin": 0, "vmax": 3}`. + That path is clean, and it accepts a `cmap.Colormap` object as well as a name. diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 1a80c26fd..87e2f68b8 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -45,7 +45,7 @@ def __init__( ], # [stack_dim, n_datapoints, spatial_dim], IN ORDER!! slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, display_window: int | float | None = 100, # window for n_datapoints dim only - max_display_datapoints: int = 1_000, + max_display_datapoints: int | None = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, **kwargs, ): @@ -88,9 +88,11 @@ def __init__( current index. Use ``None`` to render every datapoint, or ``0`` to render only the datapoint at the current index. - max_display_datapoints: int, default 1_000 + max_display_datapoints: int | None, default 1_000 Maximum number of datapoints to render per graphic. The step size of the display window slice is set - from this using floor division. + from this using floor division. ``None`` renders every datapoint in the window, with no decimation. + Neither ``None`` nor a very large value is recommended: the entire window is then read into RAM and + uploaded, which is slow for a large window over a large array. datapoints_window_func: tuple[Callable, str, int | float], optional Window function applied along the ``p`` dim after the display window has been taken, as @@ -181,22 +183,41 @@ def display_window(self, dw: int | float | None): self._display_window = dw @property - def max_display_datapoints(self) -> int: + def max_display_datapoints(self) -> int | None: """ Get or set the maximum number of datapoints to render per graphic. The step size of the display window slice is set from this using floor division. + + ``None`` renders every datapoint in the window, with no decimation. Neither ``None`` nor a very + large value is recommended: the entire window is then read into RAM and uploaded, which is slow + for a large window over a large array. """ return self._max_display_datapoints @max_display_datapoints.setter - def max_display_datapoints(self, n: int): - if not isinstance(n, (int, np.integer)): - raise TypeError + def max_display_datapoints(self, n: int | None): + if n is None: + self._max_display_datapoints = None + return + + if not np.issubdtype(type(n), np.integer): + raise TypeError( + f"`max_display_datapoints` must be an integer or `None`, you passed a " + f"{type(n).__name__}: {n}" + ) + if n < 2: - raise ValueError + raise ValueError(f"`max_display_datapoints` must be >= 2, you passed: {n}") self._max_display_datapoints = n + def _get_display_slice_step(self, n_datapoints: int) -> int: + """step that keeps a slice of ``n_datapoints`` within ``max_display_datapoints``""" + if self.max_display_datapoints is None: + return 1 + + return max(1, n_datapoints // self.max_display_datapoints) + # TODO: validation for datapoints_window_func and size @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: @@ -256,7 +277,7 @@ def _get_dw_slice(self, indices: dict[str, Any]) -> slice: w = stop - start # get step size - step = max(1, w // self.max_display_datapoints) + step = self._get_display_slice_step(w) return slice(start, stop, step) @@ -285,14 +306,18 @@ def _apply_dw_window_func(self, array: ArrayProtocol) -> ArrayProtocol: dw = self.slider_maps[p_dim](self.display_window) # step size based on max number of datapoints to render - step = max(1, dw // self.max_display_datapoints) + step = self._get_display_slice_step(dw) # apply window function on the `p` n_datapoints dim if ( self.datapoints_window_func is not None # if there are too many points to efficiently compute the window func, skip # applying a window func also requires making a copy so that's a further performance hit - and (dw < self.max_display_datapoints * 2) + # `max_display_datapoints = None` caps nothing, so there is no threshold to exceed + and ( + self.max_display_datapoints is None + or dw < self.max_display_datapoints * 2 + ) ): # get windows @@ -340,7 +365,7 @@ def _apply_dw_window_func(self, array: ArrayProtocol) -> ArrayProtocol: return array[:, ::step] - step = max(1, array.shape[1] // self.max_display_datapoints) + step = self._get_display_slice_step(array.shape[1]) return array[:, ::step] @@ -432,7 +457,7 @@ def __init__( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, - max_display_datapoints: int = 1_000, + max_display_datapoints: int | None = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, cmap: str | Sequence[str] = None, @@ -513,9 +538,11 @@ def __init__( Per-slider-dim mapping from reference-space values to local array indices, see :class:`NDSlicer`. - max_display_datapoints : int, default 1_000 + max_display_datapoints : int | None, default 1_000 Maximum number of datapoints to render per graphic. The step size of the display window slice is set - from this using floor division. + from this using floor division. ``None`` renders every datapoint in the window, with no decimation. + Neither ``None`` nor a very large value is recommended: the entire window is then read into RAM and + uploaded, which is slow for a large window over a large array. datapoints_window_func : tuple[Callable, str, int | float], optional Window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``, see @@ -648,7 +675,7 @@ def init( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, - max_display_datapoints: int = 1_000, + max_display_datapoints: int | None = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, cmap: str | Sequence[str] = None, @@ -931,6 +958,41 @@ def display_window(self, dw: int | float | None): # force re-render run_sync(self._set_indices_()) + @property + def display_range(self) -> tuple[float, float] | None: + """ + The current range of the display window, ``[min, max]``, in reference units of the ``p`` dim. + + The window is centered on the current ``p`` index, so this moves with the sliders. It is + ``None`` when :attr:`display_window` is, since every datapoint is then displayed. + """ + if self.display_window is None: + return None + + p_dim = self.slicer.display_dims[1] + center = self.indices[p_dim] + half_window = self.display_window / 2 + + return center - half_window, center + half_window + + @property + def max_display_datapoints(self) -> int | None: + """ + Get or set the maximum number of datapoints rendered per graphic. Setting it re-renders the + current data slice. + + ``None`` renders every datapoint in the window, with no decimation. Neither ``None`` nor a very + large value is recommended: the entire window is then read into RAM and uploaded, which is slow + for a large window over a large array. + """ + return self.slicer.max_display_datapoints + + @max_display_datapoints.setter + def max_display_datapoints(self, n: int | None): + self.slicer.max_display_datapoints = n + # force re-render + run_sync(self._set_indices_()) + @property def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: """ diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py index 007bf7b06..f60c91f27 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py @@ -61,7 +61,7 @@ def __init__( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, - max_display_datapoints: int = 1_000, + max_display_datapoints: int | None = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, linear_selector: bool = False, x_range_mode: Literal["fixed", "auto"] | None = None, @@ -143,9 +143,11 @@ def __init__( :class:`NDSlicer`. The transform for the ``p`` dim is typically the array of x values, ex: a timestamps array, so the slider is in seconds rather than sample indices. - max_display_datapoints : int, default 1_000 + max_display_datapoints : int | None, default 1_000 Maximum number of datapoints to render per graphic. The step size of the display window slice is set - from this using floor division. + from this using floor division. ``None`` renders every datapoint in the window, with no decimation. + Neither ``None`` nor a very large value is recommended: the entire window is then read into RAM and + uploaded, which is slow for a large window over a large array. datapoints_window_func : tuple[Callable, str, int | float], optional Window function applied along the ``p`` dim, as ``(func, apply_dims, window_size)``, see diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 397b82778..038c9ba58 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -450,7 +450,7 @@ def add_nd_scatter( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, - max_display_datapoints: int = 1_000, + max_display_datapoints: int | None = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, cmap: str | Sequence[str] = None, @@ -526,9 +526,11 @@ def add_nd_scatter( timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference value is rounded to the nearest integer and used as the array index. - max_display_datapoints: int, default 1_000 + max_display_datapoints: int | None, default 1_000 Maximum number of datapoints to render per graphic. The step size of the display window slice is set - from this using floor division. + from this using floor division. ``None`` renders every datapoint in the window, with no decimation. + Neither ``None`` nor a very large value is recommended: the entire window is then read into RAM and + uploaded, which is slow for a large window over a large array. datapoints_window_func: tuple[Callable, str, int | float], optional Window function applied along the ``p`` dim after the display window has been taken, as @@ -663,7 +665,7 @@ def add_nd_timeseries( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, - max_display_datapoints: int = 1_000, + max_display_datapoints: int | None = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, cmap: str | Sequence[str] = None, @@ -765,9 +767,11 @@ def add_nd_timeseries( mapping, i.e. the current reference value is rounded to the nearest integer and used as the array index. - max_display_datapoints: int, default 1_000 + max_display_datapoints: int | None, default 1_000 Maximum number of datapoints to render per graphic. The step size of the display window slice is set - from this using floor division. + from this using floor division. ``None`` renders every datapoint in the window, with no decimation. + Neither ``None`` nor a very large value is recommended: the entire window is then read into RAM and + uploaded, which is slow for a large window over a large array. datapoints_window_func: tuple[Callable, str, int | float], optional Window function applied along the ``p`` dim after the display window has been taken, as @@ -909,7 +913,7 @@ def add_nd_lines( window_order: tuple[str, ...] = None, spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, slider_maps: dict[str, Callable[[Any], int] | ArrayLike] = None, - max_display_datapoints: int = 1_000, + max_display_datapoints: int | None = 1_000, datapoints_window_func: tuple[Callable, str, int | float] | None = None, colors: ColorsType = None, cmap: str | Sequence[str] = None, @@ -984,9 +988,11 @@ def add_nd_lines( timestamps array). Any dim without a transform uses the identity mapping, i.e. the current reference value is rounded to the nearest integer and used as the array index. - max_display_datapoints: int, default 1_000 + max_display_datapoints: int | None, default 1_000 Maximum number of datapoints to render per graphic. The step size of the display window slice is set - from this using floor division. + from this using floor division. ``None`` renders every datapoint in the window, with no decimation. + Neither ``None`` nor a very large value is recommended: the entire window is then read into RAM and + uploaded, which is slow for a large window over a large array. datapoints_window_func: tuple[Callable, str, int | float], optional Window function applied along the ``p`` dim after the display window has been taken, as From c483f59a2aef7a53bd15ca4e70ae85a021c9b6a3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 10:59:24 -0400 Subject: [PATCH 146/163] dw setter --- .../widgets/nd_widget/_nd_positions/_nd_positions.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 87e2f68b8..14a1d2c6e 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -176,9 +176,15 @@ def display_window(self) -> int | float | None: def display_window(self, dw: int | float | None): if dw is None: self._display_window = None + return - elif not isinstance(dw, (int, float)): - raise TypeError + if not ( + np.issubdtype(type(dw), np.integer) or np.issubdtype(type(dw), np.floating) + ): + raise TypeError( + f"`display_window` must be an int, float, or `None`, you passed a " + f"{type(dw).__name__}: {dw}" + ) self._display_window = dw From 19e76388456e47f3fce5f57713197f859cc335fe Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 12:38:56 -0400 Subject: [PATCH 147/163] imgui right click for NDGraphics --- fastplotlib/graphics/_collection_base.py | 63 ++++++ fastplotlib/widgets/nd_widget/_base.py | 14 ++ fastplotlib/widgets/nd_widget/_nd_image.py | 1 + .../nd_widget/_nd_positions/_nd_positions.py | 1 + .../nd_widget/_nd_positions/_nd_timeseries.py | 1 + fastplotlib/widgets/nd_widget/_nd_vectors.py | 1 + fastplotlib/widgets/nd_widget/_ui.py | 197 +++++++++++------- 7 files changed, 205 insertions(+), 73 deletions(-) diff --git a/fastplotlib/graphics/_collection_base.py b/fastplotlib/graphics/_collection_base.py index 82f585aa0..5f0928eb1 100644 --- a/fastplotlib/graphics/_collection_base.py +++ b/fastplotlib/graphics/_collection_base.py @@ -364,6 +364,69 @@ def _refresh_accessors(self): for feature_name in self._accessor_specs: getattr(self, f"_{feature_name}")._graphics = self._graphics + @property + def imgui_right_click(self) -> tuple: + """ + The imgui popup of each graphic of this collection, in order. + + A right-click picks the graphic under the pointer, never the collection, so each graphic has + its own popup. Passing a function to :meth:`set_imgui_right_click` wraps it in a separate + popup per graphic, passing an ``ImguiPopup`` shares that one instance between them. + """ + return tuple(graphic.imgui_right_click for graphic in self._graphics) + + def set_imgui_right_click(self, popup=None, *, window_flags=None): + """ + Set the imgui popup opened by a right-click on any graphic of this collection. + + A right-click picks the graphic under the pointer, never the collection, so the popup is set + on each graphic. Takes the same arguments as :meth:`Graphic.set_imgui_right_click`. + """ + + def decorator(_popup): + for graphic in self._graphics: + graphic.set_imgui_right_click(_popup, window_flags=window_flags) + return _popup + + if popup is None: + return decorator + + decorator(popup) + + def append_imgui_right_click(self, gui=None): + """ + Append imgui elements to the popup of every graphic of this collection. + + Takes the same arguments as :meth:`Graphic.append_imgui_right_click`. + """ + + def decorator(_gui): + for graphic in self._graphics: + graphic.append_imgui_right_click(_gui) + return _gui + + if gui is None: + return decorator + + decorator(gui) + + def remove_imgui_right_click(self, popup): + """ + Remove ``popup`` from every graphic of this collection that has it set. + + Unlike :meth:`Graphic.remove_imgui_right_click` this takes the popup to remove, since the + graphics of a collection do not necessarily share one. + + Parameters + ---------- + popup: ImguiPopup + the popup to remove, one of those returned by :attr:`imgui_right_click` + + """ + for graphic in self._graphics: + if graphic.imgui_right_click is popup: + graphic.remove_imgui_right_click() + def _fpl_add_plot_area_hook(self, plot_area): super()._fpl_add_plot_area_hook(plot_area) for graphic in self._graphics: diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py index 6e70a9562..1e74ea3e0 100644 --- a/fastplotlib/widgets/nd_widget/_base.py +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Sequence from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager +from functools import partial import inspect from numbers import Real from pprint import pformat @@ -770,6 +771,19 @@ def graphic(self) -> Graphic: """Underlying Graphic object used to display the current data slice""" raise NotImplementedError + def _set_graphic_right_click(self): + """ + Set the popup that a right-click on the graphic opens, which shows this NDGraphic's settings. + + Called whenever the graphic is created, since switching ``graphic_type`` or changing the shape + of the data replaces the ``Graphic``, and its popup with it. To replace this popup set your own + on ``ndgraphic.graphic``, or add to it with ``ndgraphic.graphic.append_imgui_right_click()``. + """ + # `_ui` imports the NDGraphic subclasses, so it cannot be imported at the header + from ._ui import draw_nd_graphic_ui + + self.graphic.set_imgui_right_click(partial(draw_nd_graphic_ui, self)) + @property def indices_displayed(self) -> dict[str, Any]: """the indices that the graphic currently represents""" diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py index cbff414b6..747ef7a39 100644 --- a/fastplotlib/widgets/nd_widget/_nd_image.py +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -514,6 +514,7 @@ async def _create_graphic(self): self._graphic = new_graphic self._nd_subplot.subplot.add_graphic(self._graphic) + self._set_graphic_right_click() self._reset_camera() self._reset_histogram() diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py index 14a1d2c6e..12e145224 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -949,6 +949,7 @@ def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): g.tooltip_format = partial(self._tooltip_handler, g) self._nd_subplot.subplot.add_graphic(self._graphic) + self._set_graphic_right_click() @property def display_window(self) -> int | float | None: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py index f60c91f27..b20d403ce 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py @@ -343,6 +343,7 @@ def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): ), ) self._nd_subplot.subplot.add_graphic(self._graphic) + self._set_graphic_right_click() else: super()._setup_graphic(new_features, indices) diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py index 9ffc9eb09..2bc484903 100644 --- a/fastplotlib/widgets/nd_widget/_nd_vectors.py +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -340,6 +340,7 @@ async def _create_graphic(self): ) self._nd_subplot.subplot.add_graphic(self._graphic) + self._set_graphic_right_click() @property def display_dims(self) -> tuple[str, str, str]: diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py index 909614b70..9455f3ca9 100644 --- a/fastplotlib/widgets/nd_widget/_ui.py +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -237,91 +237,142 @@ def draw(self): imgui.set_next_window_size((0, 0)) _, open = imgui.begin(f"subplot: {subplot.name}, {name}", True) - if isinstance(ndg, NDPositions): - self._draw_nd_pos_ui(subplot, ndg) - - elif isinstance(ndg, NDImage): - self._draw_nd_image_ui(subplot, ndg) - - _, ndg.pause = imgui.checkbox("pause", ndg.pause) + draw_nd_graphic_ui(ndg) if not open: self._ndgraphic_windows.remove(ndg) imgui.end() - def _draw_nd_image_ui(self, subplot, nd_image: NDImage): - if nd_image.graphic.data.value is not None: - # if it doesn't have a CPU buffer the value is None - # i.e. data is only on the GPU, e.g. YUV - _min, _max = quick_min_max(nd_image.graphic.data.value) - changed, vmin = imgui.slider_float( - "vmin", nd_image.graphic.vmin, v_min=_min, v_max=_max - ) - if changed: - nd_image.graphic.vmin = vmin +def draw_nd_graphic_ui(nd_graphic: NDGraphic): + """ + Draw the settings of an ``NDGraphic``. - changed, vmax = imgui.slider_float( - "vmax", nd_image.graphic.vmax, v_min=_min, v_max=_max - ) - if changed: - nd_image.graphic.vmax = vmax + Used both by the popup that a right-click on the graphic opens, and by the window that the + "ND Graphics" submenu of the ``NDWidget`` right-click menu opens. A right-click can be hard to + land on a thin graphic, ex: a line or a scatter, which is why both exist. + """ + if isinstance(nd_graphic, NDTimeseries): + _draw_nd_timeseries_ui(nd_graphic) + + elif isinstance(nd_graphic, NDPositions): + _draw_nd_positions_ui(nd_graphic) - changed, new_gamma = imgui.slider_float( - "gamma", nd_image.graphic._material.gamma, 0.01, 5 + elif isinstance(nd_graphic, NDImage): + _draw_nd_image_ui(nd_graphic) + + _, nd_graphic.pause = imgui.checkbox("pause", nd_graphic.pause) + + +def _draw_magnitude_sliders( + label: str, value: float, exponent_min: int, exponent_max: int +) -> tuple[bool, float]: + """ + A value slider paired with an order of magnitude slider, ``value * 10 ** exponent``. + + The pair covers many decades without any one slider having to. The value is decomposed on every + frame rather than kept as UI state, so the sliders follow the property when something else moves + it, ex: a camera zoom writing ``display_window``. + """ + if value > 0: + exponent = int(np.floor(np.log10(value))) + exponent = min(max(exponent, exponent_min), exponent_max) + else: + # log10 is undefined at 0, which is a valid display window: only the current datapoint + exponent = exponent_min + + changed_value, value = imgui.slider_float( + label, v=value / 10.0**exponent, v_min=0.0, v_max=10.0 + ) + changed_exponent, exponent = imgui.slider_int( + f"{label} 10^", v=exponent, v_min=exponent_min, v_max=exponent_max + ) + + return (changed_value or changed_exponent), value * 10.0**exponent + + +def _draw_nd_image_ui(nd_image: NDImage): + if nd_image.graphic.data.value is not None: + # if it doesn't have a CPU buffer the value is None + # i.e. data is only on the GPU, e.g. YUV + _min, _max = quick_min_max(nd_image.graphic.data.value) + changed, vmin = imgui.slider_float( + "vmin", nd_image.graphic.vmin, v_min=_min, v_max=_max ) if changed: - nd_image.graphic._material.gamma = new_gamma - - def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): - graphic_types = position_graphic_types - if isinstance(nd_graphic, NDTimeseries): - # heatmap only makes sense for timeseries data - graphic_types = position_graphic_types + [ImageGraphic] - for i, cls in enumerate(graphic_types): - if imgui.radio_button(cls.__name__, type(nd_graphic.graphic) is cls): - nd_graphic.graphic_type = cls - subplot.auto_scale() - - changed, val = imgui.checkbox( - "use display window", nd_graphic.display_window is not None - ) + nd_image.graphic.vmin = vmin - p_dim = nd_graphic.slicer.display_dims[1] + changed, vmax = imgui.slider_float( + "vmax", nd_image.graphic.vmax, v_min=_min, v_max=_max + ) + if changed: + nd_image.graphic.vmax = vmax + + changed, new_gamma = imgui.slider_float( + "gamma", nd_image.graphic._material.gamma, 0.01, 5 + ) + if changed: + nd_image.graphic._material.gamma = new_gamma + + +def _draw_nd_positions_ui( + nd_graphic: NDPositions, graphic_types: list = position_graphic_types +): + subplot = nd_graphic._nd_subplot.subplot + ndwidget = nd_graphic._nd_subplot.ndw + + for cls in graphic_types: + if imgui.radio_button(cls.__name__, type(nd_graphic.graphic) is cls): + nd_graphic.graphic_type = cls + subplot.auto_scale() + + changed, val = imgui.checkbox( + "use display_window", nd_graphic.display_window is not None + ) + + p_dim = nd_graphic.slicer.display_dims[1] + + if changed: + if not val: + nd_graphic.display_window = None + else: + # pick a value 10% of the reference range + nd_graphic.display_window = ndwidget.ranges[p_dim].size * 0.1 + + if nd_graphic.display_window is not None: + changed, new = _draw_magnitude_sliders( + "display_window", nd_graphic.display_window, -10, 8 + ) if changed: - if not val: - nd_graphic.display_window = None - else: - # pick a value 10% of the reference range - nd_graphic.display_window = self._ndwidget.ranges[p_dim].size * 0.1 - - if nd_graphic.display_window is not None: - if isinstance(nd_graphic.display_window, (int, np.integer)): - slider = imgui.slider_int - input_ = imgui.input_int - type_ = int - else: - slider = imgui.slider_float - input_ = imgui.input_float - type_ = float - - changed, new = slider( - "display window", - v=nd_graphic.display_window, - v_min=type_(0), - v_max=type_(self._ndwidget.ranges[p_dim].stop * 0.1), - ) + nd_graphic.display_window = new - if changed: - nd_graphic.display_window = new + changed, limit = imgui.checkbox( + "use max_display_datapoints", nd_graphic.max_display_datapoints is not None + ) - if isinstance(nd_graphic, NDTimeseries): - options = [None, "fixed", "auto"] - changed, option = imgui.combo( - "x-range mode", - options.index(nd_graphic.x_range_mode), - [str(o) for o in options], - ) - if changed: - nd_graphic.x_range_mode = options[option] + if changed: + nd_graphic.max_display_datapoints = 1_000 if limit else None + + if nd_graphic.max_display_datapoints is not None: + changed, new = _draw_magnitude_sliders( + "max_display_datapoints", nd_graphic.max_display_datapoints, 0, 8 + ) + + if changed: + # the sliders can reach 0, the minimum is 2 + nd_graphic.max_display_datapoints = max(2, int(new)) + + +def _draw_nd_timeseries_ui(nd_graphic: NDTimeseries): + # a heatmap only makes sense for timeseries data + _draw_nd_positions_ui(nd_graphic, position_graphic_types + [ImageGraphic]) + + options = [None, "fixed", "auto"] + changed, option = imgui.combo( + "x-range mode", + options.index(nd_graphic.x_range_mode), + [str(o) for o in options], + ) + if changed: + nd_graphic.x_range_mode = options[option] From 9ec521cfac8fae462519f6ff91c26558417dd9fb Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 14:17:51 -0400 Subject: [PATCH 148/163] axes label tweaks --- fastplotlib/axes/_axes.py | 100 ++++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 42 deletions(-) diff --git a/fastplotlib/axes/_axes.py b/fastplotlib/axes/_axes.py index dfd488f86..0cffee9ca 100644 --- a/fastplotlib/axes/_axes.py +++ b/fastplotlib/axes/_axes.py @@ -160,10 +160,12 @@ def __init__(self, *, color="#fff", alpha_mode=None, render_queue=None, **kwargs alpha_mode="auto", render_queue=RenderQueue.overlay + 50, aa=True, + outline_thickness=0.05, ), ) self._label.visible = False self.add(self._label) + self.text.material.outline_thickness = 0.05 @property def label(self) -> pygfx.Text: @@ -195,57 +197,71 @@ def _update_label(self): self._label.visible = True mid_t = 0.5 * (t1 + t2) - mid_pos = self._start_pos * (1 - mid_t) + self._end_pos * mid_t + self._label.local.position = ( + self._start_pos * (1 - mid_t) + self._end_pos * mid_t + ) - world_vec = self._end_pos - self._start_pos - world_len = np.linalg.norm(world_vec) - screen_len = np.linalg.norm(self._screen_vec) + vec = self._visible_part_screen_vec + angle = math.atan2(vec[1], vec[0]) - if world_len > 0 and screen_len > 0: - world_dir = world_vec / world_len - # perpendicular in the xy plane: CCW = "left", CW = "right" - if self.tick_side == "left": - perp_world = np.array([-world_dir[1], world_dir[0], 0.0]) - else: - perp_world = np.array([world_dir[1], -world_dir[0], 0.0]) + # the side of the line that the tick labels are on, as a screen space unit vector. this is + # the same rule that pygfx uses to anchor the tick labels themselves, and the screen vector + # already carries the camera scale, the viewport aspect and the ruler's orientation, so + # none of those need a case of their own. + if self.tick_side == "left": + px, py = -math.sin(angle), math.cos(angle) + else: + px, py = math.sin(angle), -math.cos(angle) - # same perpendicular in screen space, for projecting tick label rects - screen_dir = self._screen_vec / screen_len - if self.tick_side == "left": - px, py = -screen_dir[1], screen_dir[0] - else: - px, py = screen_dir[1], -screen_dir[0] + # a ruler that runs right to left, or top to bottom, on screen would render the label + # upside down, so turn it around. that turns the label's own axes around with it + upside_down = not (-0.5 * math.pi < angle <= 0.5 * math.pi) + if upside_down: + angle -= math.copysign(math.pi, angle) + + # pylinalg uses [x, y, z, w] quaternion format + self._label.local.rotation = np.array( + [0.0, 0.0, math.sin(angle / 2), math.cos(angle / 2)] + ) - # max extent of tick labels in the perpendicular direction. - # tick labels are unrotated screen-space text, so we project their - # axis-aligned _rect onto (px, py) directly. - visible_blocks = [ - b + # the label is rotated onto the line, so in its own frame the line runs along x and the + # ticks sit on one side of it, +y or -y. anchoring it to that side offsets it in screen + # pixels, which is what keeps the camera scale and the viewport aspect out of the placement + if (self.tick_side == "left") != upside_down: + anchor = "bottom-center" + else: + anchor = "top-center" + + # max extent of the tick labels in that same perpendicular direction. + # tick labels are unrotated screen-space text, so we project their + # axis-aligned _rect onto (px, py) directly. + px_pos, px_neg = max(px, 0), min(px, 0) + py_pos, py_neg = max(py, 0), min(py, 0) + tick_extent_px = max( + ( + px_pos * b._rect.right + + px_neg * b._rect.left + + py_pos * b._rect.top + + py_neg * b._rect.bottom for b in self.text._text_blocks if b._rect.width > 0 or b._rect.height > 0 - ] - if visible_blocks: - tick_extent_px = max( - max(px, 0) * b._rect.right - + min(px, 0) * b._rect.left - + max(py, 0) * b._rect.top - + min(py, 0) * b._rect.bottom - for b in visible_blocks - ) - else: - tick_extent_px = 0.0 + ), + default=0.0, + ) - offset_px = max(tick_extent_px, 0.0) + self._label.font_size - mid_pos = mid_pos + (offset_px / (screen_len / world_len)) * perp_world + # gap between the tick labels and the label. a text rect is tight on its left and right, + # but its top and bottom are the font's ascender and descender, which neither a tick + # number nor most labels reach. that padding already separates the two where the offset is + # vertical, so only add a gap to the extent that the offset is horizontal + gap_px = abs(px) * 0.5 * self._label.font_size - self._label.local.position = mid_pos + anchor_offset = max(tick_extent_px, 0.0) + gap_px - vec = self._visible_part_screen_vec - angle = math.atan2(vec[1], vec[0]) - # pylinalg uses [x, y, z, w] quaternion format - self._label.local.rotation = np.array( - [0.0, 0.0, math.sin(angle / 2), math.cos(angle / 2)] - ) + # both of these re-run the text layout, so only set them when they actually change + if self._label._anchor != anchor: + self._label.anchor = anchor + if self._label._anchor_offset != anchor_offset: + self._label.anchor_offset = anchor_offset class Axes: From 7ccbf443050102dfa5ae33f4601f027cf7c86869 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 11 Sep 2026 16:06:28 -0400 Subject: [PATCH 149/163] remove unused zarr placeholder --- fastplotlib/widgets/nd_widget/_nd_positions/__init__.py | 2 +- fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py index 4f7104d5a..bdc029b50 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py @@ -9,7 +9,7 @@ class Extras: ndp_extras = Extras() -for optional in ["pandas", "zarr"]: +for optional in ["pandas"]: try: importlib.import_module(optional) except ImportError: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py b/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py deleted file mode 100644 index fb3bb7015..000000000 --- a/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py +++ /dev/null @@ -1,4 +0,0 @@ -# placeholder - -class NDPP_Zarr: - pass From 63bbffc604dee4823e5841bc1f4c7cbaf48f644b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 12 Sep 2026 18:50:51 -0400 Subject: [PATCH 150/163] fix docstring --- fastplotlib/graphics/features/_vectors.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 82767ca21..a38a9fbee 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -277,7 +277,8 @@ def mat_compose(translation, rotation, scaling, /, *, out=None, dtype=None) -> n Returns ------- - ndarray, [num_vectors, 4, 4] or [4, 4] + np.ndarray + [num_vectors, 4, 4] or [4, 4] """ rotation = np.asarray(rotation, dtype=float) translation = np.asarray(translation, dtype=float) From 4473382477b0c30c40ba9bcb0734496dd59526e1 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 15 Sep 2026 18:36:21 -0400 Subject: [PATCH 151/163] config system (#1080) * basic scaffold done * inheritance * done * config works! * config on graphics * axes config * full config implementation basically works * fix * mixins call Graphic construtors with kwargs nothing is positional * print * config presets * comments, docstrings * docstrings * remove ConfigValue * comments * much better add graphics mixin using descriptors, examples, fix a test * anotehr example * GlobalConfig.to_dict() * docs * reset to default config after each screenshot test * add_() stub generator, fix maintain_aspect logic w.r.t. config stuff * change so maintain_aspect can be tested better * docstring * reset config after running each docs gallery examle * better example * docs --- docs/source/api/layouts/subplot.rst | 2 +- docs/source/api/ui/ImguiWindow.rst | 1 + docs/source/api/widgets/ImageWidget.rst | 44 + docs/source/api/widgets/NDWidget.rst | 1 + docs/source/api/widgets/index.rst | 1 + docs/source/conf.py | 3 + docs/source/gallery_reset.py | 7 + docs/source/user_guide/guide.rst | 98 + examples/global_config/README.rst | 2 + examples/global_config/config_axes.py | 35 + examples/global_config/config_figure.py | 38 + examples/global_config/config_graphics.py | 62 + examples/global_config/config_style1.py | 39 + examples/global_config/config_style2.py | 33 + examples/global_config/config_subplot.py | 35 + examples/gridplot/multigraphic_gridplot.py | 4 +- examples/tests/test_examples.py | 2 + examples/tests/testutils.py | 1 + fastplotlib/__init__.py | 5 +- fastplotlib/axes/_axes.py | 83 +- fastplotlib/graphics/_base.py | 3 + fastplotlib/graphics/_vectors.py | 3 + fastplotlib/graphics/image.py | 27 +- fastplotlib/graphics/image_volume.py | 20 +- fastplotlib/graphics/inf_line.py | 13 +- fastplotlib/graphics/line.py | 9 +- fastplotlib/graphics/mesh.py | 7 + fastplotlib/graphics/scatter.py | 33 +- fastplotlib/graphics/text.py | 10 + fastplotlib/layouts/_figure.py | 31 +- fastplotlib/layouts/_frame.py | 52 +- fastplotlib/layouts/_graphic_methods_mixin.py | 1720 +---------------- .../layouts/_graphic_methods_mixin.pyi | 1327 +++++++++++++ fastplotlib/layouts/_imgui_figure.py | 4 +- fastplotlib/layouts/_plot_area.py | 20 +- fastplotlib/layouts/_subplot.py | 62 +- fastplotlib/utils/__init__.py | 11 +- fastplotlib/utils/_config.py | 399 ++++ fastplotlib/utils/_style.py | 115 ++ ...thods.py => generate_add_graphics_stub.py} | 89 +- tests/test_collections.py | 2 +- 41 files changed, 2682 insertions(+), 1771 deletions(-) create mode 100644 docs/source/api/widgets/ImageWidget.rst create mode 100644 docs/source/gallery_reset.py create mode 100644 examples/global_config/README.rst create mode 100644 examples/global_config/config_axes.py create mode 100644 examples/global_config/config_figure.py create mode 100644 examples/global_config/config_graphics.py create mode 100644 examples/global_config/config_style1.py create mode 100644 examples/global_config/config_style2.py create mode 100644 examples/global_config/config_subplot.py create mode 100644 fastplotlib/layouts/_graphic_methods_mixin.pyi create mode 100644 fastplotlib/utils/_config.py create mode 100644 fastplotlib/utils/_style.py rename scripts/{generate_add_graphic_methods.py => generate_add_graphics_stub.py} (52%) diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index daa490b94..770a1adba 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -30,6 +30,7 @@ Properties Subplot.directional_light Subplot.docks Subplot.frame + Subplot.frame_spacing Subplot.graphics Subplot.imgui_right_click Subplot.imgui_windows @@ -53,7 +54,6 @@ Methods :toctree: Subplot_api Subplot.add_animations - Subplot.add_collection Subplot.add_graphic Subplot.add_image Subplot.add_image_collection diff --git a/docs/source/api/ui/ImguiWindow.rst b/docs/source/api/ui/ImguiWindow.rst index b921d299d..74522d483 100644 --- a/docs/source/api/ui/ImguiWindow.rst +++ b/docs/source/api/ui/ImguiWindow.rst @@ -20,6 +20,7 @@ Properties .. autosummary:: :toctree: ImguiWindow_api + ImguiWindow.collapsed ImguiWindow.height ImguiWindow.location ImguiWindow.size diff --git a/docs/source/api/widgets/ImageWidget.rst b/docs/source/api/widgets/ImageWidget.rst new file mode 100644 index 000000000..ba9c8e1b1 --- /dev/null +++ b/docs/source/api/widgets/ImageWidget.rst @@ -0,0 +1,44 @@ +.. _api.ImageWidget: + +ImageWidget +*********** + +=========== +ImageWidget +=========== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageWidget_api + + ImageWidget + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageWidget_api + + ImageWidget.cmap + ImageWidget.current_index + ImageWidget.data + ImageWidget.figure + ImageWidget.frame_apply + ImageWidget.managed_graphics + ImageWidget.slider_dims + ImageWidget.window_funcs + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageWidget_api + + ImageWidget.add_event_handler + ImageWidget.clear_event_handlers + ImageWidget.close + ImageWidget.remove_event_handler + ImageWidget.reset_vmin_vmax + ImageWidget.set_data + ImageWidget.show + diff --git a/docs/source/api/widgets/NDWidget.rst b/docs/source/api/widgets/NDWidget.rst index 7a09f3bbb..64ffeae23 100644 --- a/docs/source/api/widgets/NDWidget.rst +++ b/docs/source/api/widgets/NDWidget.rst @@ -24,6 +24,7 @@ Properties NDWidget.indices NDWidget.ndgraphics NDWidget.ranges + NDWidget.ui_sliders Methods ~~~~~~~ diff --git a/docs/source/api/widgets/index.rst b/docs/source/api/widgets/index.rst index fbebc87ec..c60b3c485 100644 --- a/docs/source/api/widgets/index.rst +++ b/docs/source/api/widgets/index.rst @@ -5,3 +5,4 @@ Widgets :maxdepth: 1 NDWidget + ImageWidget diff --git a/docs/source/conf.py b/docs/source/conf.py index 1871ecadd..3e88ebc74 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -60,6 +60,7 @@ "../../examples/image_volume", "../../examples/heatmap", # "../../examples/image_widget", + "../../examples/global_config", "../../examples/gridplot", "../../examples/window_layouts", "../../examples/controllers", @@ -83,6 +84,8 @@ "ignore_pattern": r"__init__\.py", "nested_sections": False, "thumbnail_size": (250, 250), + # run before each example, must be a string since a callable is not serializable + "reset_modules": ("gallery_reset.reset_fastplotlib_style",), } extra_conf = find_examples_for_gallery(EXAMPLES_DIR) diff --git a/docs/source/gallery_reset.py b/docs/source/gallery_reset.py new file mode 100644 index 000000000..226a28d8b --- /dev/null +++ b/docs/source/gallery_reset.py @@ -0,0 +1,7 @@ +import fastplotlib as fpl + + +def reset_fastplotlib_style(gallery_conf, fname): + """run by sphinx-gallery before each example, see ``reset_modules`` in conf.py""" + # the config is global, restore the defaults that a previous example may have set + fpl.style.default() diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index 8f7b8d3bf..37002bb85 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -883,3 +883,101 @@ notebook. Note that this only works if you are using jupyterlab or ipython locally, this cannot be used for remote rendering. You can forward windows (ex: X11 forwarding) but this is much slower than the remote rendering described in the previous section. + +Global configuration +-------------------- + +You can configure global defaults for various components such as ``Figure``, ``Subplot``, ``Axes`` +and the graphics. Defaults are set on the class under ``config``, grouped by the method that takes +the argument, where ``init`` is the constructor:: + + import fastplotlib as fpl + + fpl.LineGraphic.config.init.colors = "magenta" + fpl.LineGraphic.config.init.thickness = 5.0 + fpl.ImageGraphic.config.init.cmap = "gray" + fpl.Axes.config.init.grids = False + fpl.Figure.config.init.size = (900, 700) + fpl.Figure.config.show.axes_visible = False + fpl.layouts.Subplot.config.init.toolbar = False + fpl.layouts.Subplot.config.auto_scale.zoom = 0.9 + +Configurable components: + ++----------------------------------------------------+--------------------------+ +| component | configurable methods | ++====================================================+==========================+ +| ``fastplotlib.Figure`` | ``init``, ``show`` | ++----------------------------------------------------+--------------------------+ +| ``fastplotlib.layouts.Subplot`` | ``init``, ``auto_scale`` | ++----------------------------------------------------+--------------------------+ +| ``fastplotlib.Axes`` | ``init`` | ++----------------------------------------------------+--------------------------+ +| every ``Graphic``, ex. ``fastplotlib.LineGraphic`` | ``init`` | ++----------------------------------------------------+--------------------------+ + +Print every configurable class with all of its options and their current values:: + + fpl.global_config.print_config() + +Get the same thing as a dict of ``{class: {method: {option: value}}}``:: + + import copy + + config = fpl.global_config.to_dict() + + config[fpl.LineGraphic]["init"]["colors"] # "magenta" + + # deepcopy for a snapshot since some config options are mutable, ex: dicts + snapshot = copy.deepcopy(fpl.global_config.to_dict()) + +A config value is only used if an argument value is not explicitly provided:: + + import numpy as np + + ys = np.sin(np.linspace(0, 2 * np.pi, 100)) + + fig = fpl.Figure() + + line = fig[0, 0].add_line(ys) # magenta, from the config + other = fig[0, 0].add_line(ys, colors="w") # white + +Config values are read when an object is created, so setting one affects everything created after it +and nothing that already exists. + +Options are set on the class, not on an instance:: + + fpl.LineGraphic.config.init.colors = "magenta" # this is how you set it + line.config.init.colors = "magenta" # raises AttributeError + +Setting an option that does not exist raises an ``AttributeError`` that lists the valid options. + +Graphic collections have no config of their own. The graphics in a collection are created from the +config of the graphic it holds, so ``LineGraphic.config.init.colors`` is also the color of the +lines in a ``LineCollection``. + +Options that are dicts +^^^^^^^^^^^^^^^^^^^^^^ + +Some options are themselves kwargs, such as ``Subplot.config.init.frame_kwargs`` and +``Axes.config.init.grid_kwargs``. Assigning to one of these replaces the whole dict. +``fastplotlib.global_config.update()`` merges dicts instead, recursing into nested dicts:: + + fpl.global_config.update( + fpl.layouts.Subplot.config.init, + # changes the title font size, but keeps the current title face_color config + frame_kwargs={"title_kwargs": {"font_size": 10}}, + ) + +Styles +^^^^^^ + +``fastplotlib.style`` holds preset styles, and styles can be merged with subsequent calls:: + + fpl.style.light() + fpl.style.compact() + +Available styles are: + +.. autoclass:: fastplotlib.style + :members: diff --git a/examples/global_config/README.rst b/examples/global_config/README.rst new file mode 100644 index 000000000..6d05db292 --- /dev/null +++ b/examples/global_config/README.rst @@ -0,0 +1,2 @@ +Global Config +============= diff --git a/examples/global_config/config_axes.py b/examples/global_config/config_axes.py new file mode 100644 index 000000000..7bf731a7f --- /dev/null +++ b/examples/global_config/config_axes.py @@ -0,0 +1,35 @@ +""" +Axes Config +=========== + +Configuration values are used for any argument that is not explicitly passed. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +# these can also be set simultaneously: +# fpl.global_config.update(fpl.Axes.config.init, color="red", tick_size=16, line_width=4) +fpl.Axes.config.init.color = "red" +fpl.Axes.config.init.tick_size = 16 +fpl.Axes.config.init.line_width = 4 + +xs = np.linspace(-10, 10, 100) +ys = np.sin(xs) +data = np.column_stack([xs, ys]) + +figure = fpl.Figure(size=(700, 560)) + +figure[0, 0].add_line(data) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/global_config/config_figure.py b/examples/global_config/config_figure.py new file mode 100644 index 000000000..409abcee3 --- /dev/null +++ b/examples/global_config/config_figure.py @@ -0,0 +1,38 @@ +""" +Figure Config +============= + +Configuration values are used for any argument that is not explicitly passed. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +# a tall figure to fit a stack of lines +fpl.Figure.config.init.size = (700, 1000) + +# used by Figure.show() +fpl.Figure.config.show.axes_visible = False +fpl.layouts.Subplot.config.auto_scale.maintain_aspect = False + +xs = np.linspace(0, 4 * np.pi, 100) +ys = np.sin(xs) +data = np.column_stack([xs, ys]) + +# 10 sine waves to stack +stack_data = np.stack([data] * 5) + +figure = fpl.Figure() + +figure[0, 0].add_line_stack(stack_data) + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/global_config/config_graphics.py b/examples/global_config/config_graphics.py new file mode 100644 index 000000000..1f3fa09cc --- /dev/null +++ b/examples/global_config/config_graphics.py @@ -0,0 +1,62 @@ +""" +Graphics Config +=============== + +Configuration values are used for any argument that is not explicitly passed. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import imageio.v3 as iio +import numpy as np +import fastplotlib as fpl + +fpl.LineGraphic.config.init.colors = "magenta" +fpl.LineGraphic.config.init.thickness = 4 + +fpl.ScatterGraphic.config.init.markers = "^" +fpl.ScatterGraphic.config.init.sizes = 20 +fpl.ScatterGraphic.config.init.colors = "r" + +fpl.VectorsGraphic.config.init.color = "cyan" + +fpl.ImageGraphic.config.init.cmap = "viridis" + +xs = np.linspace(0, 4 * np.pi, 100) +ys = np.sin(xs) +line_data = np.column_stack([xs, ys]) +cosine_data = np.column_stack([xs, np.cos(xs)]) + +# 5 sine waves to stack +stack_data = np.stack([line_data] * 5) + +# uniform x, y positions for the vectors and their directions +x, y = np.meshgrid(np.arange(0, 2 * np.pi, 0.4), np.arange(0, 2 * np.pi, 0.4)) +positions = np.column_stack([x.ravel(), y.ravel()]) +directions = np.column_stack([np.cos(x).ravel(), np.sin(y).ravel()]) + +image_data = iio.imread("imageio:camera.png") + +figure = fpl.Figure(shape=(2, 2), size=(700, 800)) + +figure[0, 0].add_line(cosine_data, offset=(0, -3, 0)) + +# any explicitly provided arg , e.g.`colors`, overrides the config value +figure[0, 0].add_scatter(line_data[::5], colors="green") + +# a stack creates lines, so they use the LineGraphic config too +figure[0, 1].add_line_stack(stack_data) + +figure[1, 0].add_vectors(positions=positions, directions=directions) + +figure[1, 1].add_image(image_data) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/global_config/config_style1.py b/examples/global_config/config_style1.py new file mode 100644 index 000000000..43c0003ac --- /dev/null +++ b/examples/global_config/config_style1.py @@ -0,0 +1,39 @@ +""" +Light and Compact Style +======================= + +A style is a preset of configuration values. +Once called, it effects all subsequent Figures/graphic objects that the style sets. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +# white background, black axes, dark graphic colors +fpl.style.light() + +# no subplot toolbar, thin subplot frame +# this configuration is merged with the existing light preset from above +fpl.style.compact() + +xs = np.linspace(-10, 10, 100) +ys = np.sin(xs) +data = np.column_stack([xs, ys]) + +figure = fpl.Figure(shape=(2, 1), size=(700, 560)) + +# the colors of the line and the scatter come from the style +figure[0, 0].add_line(data) +figure[1, 0].add_scatter(data) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/global_config/config_style2.py b/examples/global_config/config_style2.py new file mode 100644 index 000000000..5af71ba6e --- /dev/null +++ b/examples/global_config/config_style2.py @@ -0,0 +1,33 @@ +""" +Very Compact Style +================== + +A style is a preset of configuration values. +The very compact style hides the subplot toolbar and frame, useful for figures with many subplots. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +fpl.style.very_compact() + +xs = np.linspace(-10, 10, 100) +ys = np.sin(xs) +data = np.column_stack([xs, ys]) + +figure = fpl.Figure(shape=(2, 2), size=(700, 560)) + +for subplot in figure: + subplot.add_line(data) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/global_config/config_subplot.py b/examples/global_config/config_subplot.py new file mode 100644 index 000000000..e1441e386 --- /dev/null +++ b/examples/global_config/config_subplot.py @@ -0,0 +1,35 @@ +""" +Subplot Config +============== + +Configuration values are used for any argument that is not explicitly passed. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +from fastplotlib.layouts import Subplot +import imageio.v3 as iio + +# used to create every subplot +Subplot.config.init.toolbar = False +# a sequence of 1, 2, or 4 colors, 2 colors makes a gradient from bottom to top +Subplot.config.init.background_color = ["black", "gray"] + +# used by Subplot.auto_scale(), which Figure.show() calls for every subplot +Subplot.config.auto_scale.zoom = 0.5 + +data = iio.imread("imageio:camera.png") + +figure = fpl.Figure(size=(700, 560)) + +figure[0, 0].add_image(data) + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py index c81a81669..fa3df2f7c 100644 --- a/examples/gridplot/multigraphic_gridplot.py +++ b/examples/gridplot/multigraphic_gridplot.py @@ -88,12 +88,12 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: sine = np.column_stack([xs, ys]) # make 10 identical waves -sine_waves = 10 * [sine] +sine_waves = 15 * [sine] # add the line stack to the figure figure["line-stack"].add_line_stack(data=sine_waves, cmap="Wistia", separation=(0, 1, 0)) -figure["line-stack"].auto_scale(maintain_aspect=True) +figure["line-stack"].auto_scale(maintain_aspect=False) # generate some scatter data # create a gaussian cloud of 500 points diff --git a/examples/tests/test_examples.py b/examples/tests/test_examples.py index caf7b5e82..608c3df22 100644 --- a/examples/tests/test_examples.py +++ b/examples/tests/test_examples.py @@ -67,6 +67,8 @@ def prep_environment(): finally: del os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] del os.environ["PYGFX_DEFAULT_PPAA"] + # every example runs in this same process, so restore the config that an example has set + fpl.style.default() def test_that_we_are_on_lavapipe(): diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index e279809e3..16e71dea5 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -20,6 +20,7 @@ "image/*.py", "image_volume/*.py", "image_widget/*.py", + "global_config/*.py", "heatmap/*.py", "scatter/*.py", "line/*.py", diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 00e31c977..b517cdf87 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -3,7 +3,7 @@ # this must be the first import for auto-canvas detection from .utils import loop # noqa from .utils import ( - config, + global_config, enums, enumerate_adapters, select_adapter, @@ -16,6 +16,7 @@ from .graphics.utils import pause_events, get_nearest_graphics, get_nearest_graphics_indices from .legends import * from .tools import * +from .axes import Axes, Grids from .layouts import IMGUI @@ -27,6 +28,8 @@ from .widgets import NDWidget, ImageWidget +from .utils._style import style + if len(enumerate_adapters()) < 1: from warnings import warn diff --git a/fastplotlib/axes/_axes.py b/fastplotlib/axes/_axes.py index 0cffee9ca..8c5f6609e 100644 --- a/fastplotlib/axes/_axes.py +++ b/fastplotlib/axes/_axes.py @@ -6,6 +6,7 @@ from pylinalg import quat_from_vecs, vec_transform_quat from ..utils.enums import RenderQueue +from ..utils import global_config GRID_PLANES = ["xy", "xz", "yz"] @@ -264,11 +265,28 @@ def _update_label(self): self._label.anchor_offset = anchor_offset +@global_config.register class Axes: + config = global_config.descriptor + + @global_config.declare( + "intersection", + "tick_size", + "line_width", + "tick_marker", + "color", + "grids", + "grid_kwargs", + "auto_grid", + ) def __init__( self, plot_area, intersection: tuple[int, int, int] | None = None, + tick_size: float = 8.0, + line_width: float = 2.0, + tick_marker: str = "tick", + color: str = "#fff", x_kwargs: dict = None, y_kwargs: dict = None, z_kwargs: dict = None, @@ -287,10 +305,10 @@ def __init__( z_kwargs = z_kwargs or {} generic_kwargs = dict( - tick_size=8.0, - line_width=2.0, - tick_marker="tick", # 'tick' for both-sides, 'tick_left' or 'tick_right' for one-sided - color="#fff", + tick_size=tick_size, + line_width=line_width, + tick_marker=tick_marker, # 'tick' for both-sides, 'tick_left' or 'tick_right' for one-sided + color=color, ) x_kwargs = dict( @@ -531,12 +549,22 @@ def intersection(self, intersection: tuple[float, float, float] | None): self._intersection = tuple(float(v) for v in intersection) - def _get_view_state(self) -> tuple[bytes, tuple[int, int], tuple[int, int], bytes]: + def _get_view_state(self) -> tuple: viewport = self._plot_area.viewport cam_matrix = self._plot_area.camera.camera_matrix.tobytes() scale = self._plot_area.camera.local.scale.tobytes() - return (cam_matrix, viewport.rect, viewport.logical_size, scale) + # the label margins are the other half of what places the rulers, and they are not known + # until the text has been laid out, which only happens once it has been drawn. tracking + # them here is what redoes the placement on the frame after that, and on any later change + # in the width of a tick label + return ( + cam_matrix, + viewport.rect, + viewport.logical_size, + scale, + self._get_label_margins(), + ) def update_using_bbox(self, bbox): """ @@ -566,32 +594,34 @@ def update_using_bbox(self, bbox): self.update(bbox, intersection) - def _auto_intersection_pos(self, xpos, ypos, width, height): - # returns the intersection position for the axis so they are placed in the bottom left corner - margin = 4 + def _get_label_margins(self) -> tuple[float, float]: + """ + How far the x and y tick labels, plus their axis labels, reach from their ruler, in pixels - y_blocks = [b for b in self.y.text._text_blocks if b._rect.width > 0] - y_extent = ( - max(abs(b._rect.left) for b in y_blocks) - if y_blocks - else 6 * self.y.text.font_size - ) - if self.y._label._text_blocks: - # label center is tick_extent + font_size from ruler; body adds font_size/2 more - y_extent += 1.5 * self.y._label.font_size + The fallbacks are for text that has not been laid out yet, which is the case until it has + been drawn once. + """ x_blocks = [b for b in self.x.text._text_blocks if b._rect.height > 0] - x_extent = ( + x_margin = ( max(abs(b._rect.bottom) for b in x_blocks) if x_blocks else 1.5 * self.x.text.font_size ) if self.x._label._text_blocks: - x_extent += 1.5 * self.x._label.font_size + # the axis label starts at the tick margin, and its own body follows + x_margin += 1.5 * self.x._label.font_size - return self._plot_area.map_screen_to_world( - (xpos + y_extent + margin, ypos + height - x_extent - margin) + y_blocks = [b for b in self.y.text._text_blocks if b._rect.width > 0] + y_margin = ( + max(abs(b._rect.left) for b in y_blocks) + if y_blocks + else 6 * self.y.text.font_size ) + if self.y._label._text_blocks: + y_margin += 1.5 * self.y._label.font_size + + return x_margin, y_margin def update_using_camera(self): """ @@ -609,8 +639,9 @@ def update_using_camera(self): return state = self._get_view_state() if state == self._last_state: - # no changes in the camera or viewport rect + # no changes in the camera, the viewport rect, or the size of the labels return + *_, (x_margin, y_margin) = state if self._plot_area.camera.fov == 0: xpos, ypos, width, height = self._plot_area.viewport.rect @@ -644,7 +675,11 @@ def update_using_camera(self): if self.intersection is None: if self._plot_area.camera.fov == 0: - intersection = self._auto_intersection_pos(xpos, ypos, width, height) + # put the rulers in the bottom left corner, clear of their own labels + padding = 4 + intersection = self._plot_area.map_screen_to_world( + (xpos + y_margin + padding, ypos + height - x_margin - padding) + ) else: # force origin since None is not supported for Persepctive projections self._intersection = (0, 0, 0) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 1e19d6c3b..af56b01ae 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -29,6 +29,7 @@ Visible, ) from ..axes import Axes +from ..utils import global_config HexStr: TypeAlias = str WorldObjectID: TypeAlias = int @@ -59,6 +60,8 @@ class Graphic: + config = global_config.descriptor + _features: dict[str, type[GraphicFeature] | tuple[type[GraphicFeature], ...]] = dict() # It also doesn't make sense to create tooltips for some graphics diff --git a/fastplotlib/graphics/_vectors.py b/fastplotlib/graphics/_vectors.py index be90db538..0e69601b5 100644 --- a/fastplotlib/graphics/_vectors.py +++ b/fastplotlib/graphics/_vectors.py @@ -10,14 +10,17 @@ VectorPositions, VectorDirections, ) +from ..utils import global_config +@global_config.register class VectorsGraphic(Graphic): _features = { "positions": VectorPositions, "directions": VectorDirections, } + @global_config.declare("color") def __init__( self, positions: np.ndarray | Sequence[float], diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 9ef8c3609..ba4b80973 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -7,7 +7,13 @@ import cmap as cmap_lib from .shaders import HighlightableImageMaterial -from ..utils import quick_min_max, ColorspacesRGB, ColorspacesYUV, ColorRange +from ..utils import ( + global_config, + quick_min_max, + ColorspacesRGB, + ColorspacesYUV, + ColorRange, +) from ._base import Graphic from .selectors import ( LinearSelector, @@ -379,6 +385,7 @@ def format_pick_info(self, pick_info: dict) -> str: return info +@global_config.register class ImageGraphic(ImageBase): _features = { "data": TextureArray, @@ -390,6 +397,13 @@ class ImageGraphic(ImageBase): "cmap_interpolation": ImageCmapInterpolation, } + @global_config.declare( + "cmap", + "gamma", + "interpolation", + "cmap_interpolation", + "colorspace", + ) def __init__( self, data: Any, @@ -397,8 +411,8 @@ def __init__( vmax: float = None, cmap: str = "plasma", gamma: float = 1.0, - interpolation: str = "nearest", - cmap_interpolation: str = "linear", + interpolation: Literal["nearest", "linear"] = "nearest", + cmap_interpolation: Literal["nearest", "linear"] = "linear", colorspace: ColorspacesRGB = "srgb", cpu_buffer: bool = True, **kwargs, @@ -618,7 +632,6 @@ def data(self, data): self._data[:] = data - @property def colorspace(self) -> ColorspacesRGB: """The image's colorspace""" @@ -661,6 +674,7 @@ def reset_vmin_vmax(self): self.vmax = vmax +@global_config.register class ImageYUVGraphic(ImageBase): _features = { "data": TextureYUV, @@ -670,13 +684,16 @@ class ImageYUVGraphic(ImageBase): "interpolation": ImageInterpolation, } + @global_config.declare( + "interpolation", "colorspace", "colorrange" + ) def __init__( self, data: TupleYUV | TextureYUV, vmin: float = 0, vmax: float = 255, gamma: float = 1.0, - interpolation: str = "nearest", + interpolation: Literal["nearest", "linear"] = "nearest", colorspace: ColorspacesYUV = "yuv420p", colorrange: ColorRange = "limited", **kwargs, diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 0488d0cf6..20bfd347f 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -4,7 +4,7 @@ import pygfx import cmap as cmap_lib -from ..utils import quick_min_max +from ..utils import quick_min_max, global_config from ._base import Graphic from .features import ( TextureArrayVolume, @@ -83,6 +83,7 @@ def chunk_index(self) -> tuple[int, int, int]: return self._chunk_index +@global_config.register class ImageVolumeGraphic(Graphic): _features = { "data": TextureArrayVolume, @@ -101,6 +102,19 @@ class ImageVolumeGraphic(Graphic): "plane": VolumeSlicePlane, } + @global_config.declare( + "mode", + "cmap", + "gamma", + "interpolation", + "cmap_interpolation", + "plane", + "threshold", + "step_size", + "substep_size", + "emissive", + "shininess", + ) def __init__( self, data: Any, @@ -109,8 +123,8 @@ def __init__( vmax: float = None, cmap: str = "plasma", gamma: float = 1.0, - interpolation: str = "linear", - cmap_interpolation: str = "linear", + interpolation: Literal["nearest", "linear"] = "linear", + cmap_interpolation: Literal["nearest", "linear"] = "linear", plane: tuple[float, float, float, float] = (0, 0, -1, 0), threshold: float = 0.5, step_size: float = 1.0, diff --git a/fastplotlib/graphics/inf_line.py b/fastplotlib/graphics/inf_line.py index ee9987c8a..ded505be7 100644 --- a/fastplotlib/graphics/inf_line.py +++ b/fastplotlib/graphics/inf_line.py @@ -11,8 +11,10 @@ UniformColor, ) from .features.types import ColorLike, MultiColorLike, ColormapLike +from ..utils import global_config +@global_config.register class InfLineGraphic(LineGraphic): _features = { "data": InfLineAxisData, @@ -22,6 +24,15 @@ class InfLineGraphic(LineGraphic): # one color per line, each broadcast to the two vertices of the line's segment _VertexColorsCls = InfLineColors + @global_config.declare( + "thickness", + "colors", + "cmap", + "start_is_infinite", + "end_is_infinite", + "dash_pattern", + "size_space", + ) def __init__( self, data: Any, @@ -34,7 +45,7 @@ def __init__( start_is_infinite: bool = True, end_is_infinite: bool = True, dash_pattern: str | tuple | list = (), - size_space: str = "screen", + size_space: Literal["screen", "world", "model"] = "screen", **kwargs, ): """ diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 7c7e10f89..c55f55a43 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -16,16 +16,21 @@ DashPattern, parse_dash_pattern, ) -from ..utils import quick_min_max +from ..utils import quick_min_max, global_config from ._positions_base import PositionsGraphic from .features.types import ColorLike, MultiColorLike, ColormapLike + +@global_config.register class LineGraphic(PositionsGraphic): _features = { "thickness": Thickness, "dash_pattern": DashPattern, } + @global_config.declare( + "thickness", "colors", "cmap", "size_space", "dash_pattern", "thin" + ) def __init__( self, data: Any, @@ -34,7 +39,7 @@ def __init__( cmap: ColormapLike | None = None, cmap_transform: np.ndarray | Iterable[int | float] | None = None, cmap_range: tuple[float, float] | None = None, - size_space: str = "screen", + size_space: Literal["screen", "world", "model"] = "screen", dash_pattern: str | tuple | list = (), thin: bool = False, **kwargs, diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index efe03c57b..ae2d45b17 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -18,8 +18,10 @@ PolygonData, triangulate_polygon, ) +from ..utils import global_config +@global_config.register class MeshGraphic(Graphic): _features = { "positions": VertexPositions, @@ -28,6 +30,7 @@ class MeshGraphic(Graphic): "cmap": MeshCmap, } + @global_config.declare("mode", "plane", "colors", "cmap") def __init__( self, positions: Any, @@ -302,6 +305,7 @@ def format_pick_info(self, pick_info: dict) -> str: return info +@global_config.register class SurfaceGraphic(MeshGraphic): _features = { "data": SurfaceData, @@ -309,6 +313,7 @@ class SurfaceGraphic(MeshGraphic): "cmap": MeshCmap, } + @global_config.declare("mode", "colors", "cmap") def __init__( self, data: np.ndarray, @@ -391,6 +396,7 @@ def data(self, new_data: np.ndarray): self._data.set_value(self, new_data) +@global_config.register class PolygonGraphic(MeshGraphic): _features = { "data": SurfaceData, @@ -398,6 +404,7 @@ class PolygonGraphic(MeshGraphic): "cmap": MeshCmap, } + @global_config.declare("mode", "colors", "cmap") def __init__( self, data: np.ndarray, diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 624a904b4..120947504 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -18,8 +18,10 @@ ) from .features.types import ColorLike, MultiColorLike, ColormapLike from .features.utils import is_single_color +from ..utils import global_config +@global_config.register class ScatterGraphic(PositionsGraphic): _features = { "sizes": (VertexPointSizes, UniformSize), @@ -30,6 +32,19 @@ class ScatterGraphic(PositionsGraphic): "point_rotations": (UniformRotations, VertexRotations, None), } + @global_config.declare( + "colors", + "cmap", + "mode", + "markers", + "custom_sdf", + "edge_colors", + "edge_width", + "image", + "point_rotations", + "sizes", + "size_space", + ) def __init__( self, data: Any, @@ -43,7 +58,7 @@ def __init__( edge_colors: ColorLike | MultiColorLike | None = "black", edge_width: float = 1.0, image: np.ndarray = None, - point_rotations: float | np.ndarray | None = None, + point_rotations: float | np.ndarray | None = 0.0, sizes: float | np.ndarray | Sequence[float] = 5, size_space: str = "screen", **kwargs, @@ -120,11 +135,11 @@ def __init__( renders an image at the scatter points, also known as sprites. The image color is multiplied with the point's "normal" color. - point_rotations: float, array-like, or None, default None + point_rotations: float, array-like, or None, default 0.0 The rotation of the scatter points in radians. The rotation mode is determined automatically from the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve of the data (in screen space); a single float for the same rotation on every point ("uniform"); or - an array of rotation values for per-point rotations ("vertex"). + an array of rotation values for per-point rotations ("vertex"). Units are in radians. sizes: float, np.ndarray, or Sequence[float], default 5 size(s) of the scatter points. Specify a single size to use the same size for all points, or a @@ -279,7 +294,9 @@ def _create_markers_buffer(self, markers) -> UniformMarker | VertexMarkers: else: return VertexMarkers(markers, n_datapoints=self._data.value.shape[0]) - def _create_edge_colors_buffer(self, edge_colors) -> UniformEdgeColor | VertexColors: + def _create_edge_colors_buffer( + self, edge_colors + ) -> UniformEdgeColor | VertexColors: # creates either a UniformEdgeColor or VertexColors based on the given `edge_colors` if edge_colors is None: @@ -331,7 +348,9 @@ def _create_point_rotations_buffer( return None if isinstance(point_rotations, (np.ndarray, list, tuple)): - return VertexRotations(point_rotations, n_datapoints=self._data.value.shape[0]) + return VertexRotations( + point_rotations, n_datapoints=self._data.value.shape[0] + ) else: return UniformRotations(point_rotations) @@ -442,7 +461,9 @@ def point_rotations(self) -> VertexRotations | float | None: return self._point_rotations.value @point_rotations.setter - def point_rotations(self, value: float | np.ndarray[tuple[int], np.dtype[np.number]] | None): + def point_rotations( + self, value: float | np.ndarray[tuple[int], np.dtype[np.number]] | None + ): # None selects curve mode, where the rotation follows the data curve if value is None: if self._point_rotations is not None: diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index 37e559576..df8668ee5 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -2,6 +2,7 @@ import numpy as np from ..utils.enums import RenderQueue +from ..utils import global_config from ._base import Graphic from .features import ( TextData, @@ -12,6 +13,7 @@ ) +@global_config.register class TextGraphic(Graphic): _features = { "text": TextData, @@ -23,6 +25,14 @@ class TextGraphic(Graphic): _fpl_support_tooltip = False + @global_config.declare( + "font_size", + "face_color", + "outline_color", + "outline_thickness", + "screen_space", + "anchor", + ) def __init__( self, text: str, diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index edb01f482..bd449d314 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -19,10 +19,15 @@ from ._utils import controller_types as valid_controller_types from ._subplot import Subplot from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera -from .. import ImageGraphic, ImageYUVGraphic +from ..graphics import ImageGraphic, ImageYUVGraphic +from ..utils import global_config +@global_config.register class Figure: + config = global_config.descriptor + + @global_config.declare("size") def __init__( self, shape: tuple[int, int] = (1, 1), @@ -569,10 +574,15 @@ def _start_render(self): """start render cycle""" self.canvas.request_draw(self._render) + @global_config.declare( + "autoscale", + "maintain_aspect", + "axes_visible", + ) def show( self, autoscale: bool = True, - maintain_aspect: bool = None, + maintain_aspect: bool | None = None, axes_visible: bool = True, sidecar: bool = False, sidecar_kwargs: dict = None, @@ -585,8 +595,9 @@ def show( autoscale: bool, default ``True`` autoscale the Scene - maintain_aspect: bool, default ``True`` - maintain aspect ratio + maintain_aspect: bool, default ``None`` + maintain aspect ratio, if ``None`` the ``auto_scale`` config of the subplots is used, + which uses the existing value from the camera unless it has been configured axes_visible: bool, default ``True`` show axes @@ -624,12 +635,14 @@ def show( break if autoscale: + # only pass forward `maintain_aspect` to `auto_scale()` if it was provided, an + # explicitly passed argument would shadow the `auto_scale` config + auto_scale_kwargs = dict() + if maintain_aspect is not None: + auto_scale_kwargs["maintain_aspect"] = maintain_aspect + for subplot in self._subplots.ravel(): - if maintain_aspect is None: - _maintain_aspect = subplot.camera.maintain_aspect - else: - _maintain_aspect = maintain_aspect - subplot.auto_scale(maintain_aspect=maintain_aspect) + subplot.auto_scale(**auto_scale_kwargs) # set axes visibility if False if not axes_visible: diff --git a/fastplotlib/layouts/_frame.py b/fastplotlib/layouts/_frame.py index 3b3fab12e..1cf1eaada 100644 --- a/fastplotlib/layouts/_frame.py +++ b/fastplotlib/layouts/_frame.py @@ -7,7 +7,6 @@ from ..utils.types import SelectorColorStates from ..graphics import TextGraphic - """ Each Subplot is framed by a 2D plane mesh, a rectangle. The rectangles are viewed using the UnderlayCamera where (0, 0) is the top left corner. @@ -118,6 +117,9 @@ def __init__( imgui_windows, toolbar_visible, canvas_rect, + spacing: dict = None, + title_kwargs: dict = None, + plane_color: dict = None, ): """ Manages the plane mesh, resize handle point, and subplot title. @@ -154,6 +156,9 @@ def __init__( canvas_rect: tuple figure canvas rect, the render area excluding any areas taken by imgui edge windows + spacing: dict, optional + { + """ self.viewport = viewport @@ -161,6 +166,27 @@ def __init__( self._imgui_windows = imgui_windows self._toolbar_visible = toolbar_visible + _spacing = { + "x0": 1, + "sides": 2, + "title_flanks": 8, + "resize_handle_space": 13, + "bottom": 8, + } + if spacing is not None: + _spacing = {**_spacing, **spacing} + + self._spacing = _spacing + + _title_kwargs = {"font_size": 16, "face_color": "w"} + if title_kwargs is not None: + _title_kwargs = {**_title_kwargs, **title_kwargs} + + if plane_color is not None: + self.plane_color = SelectorColorStates( + **plane_color + ) + # create rect manager to handle all the backend rect calculations if rect is not None: self._rect_manager = RectManager(*rect, canvas_rect) @@ -176,7 +202,7 @@ def __init__( title_text = "" else: title_text = title - self._title_graphic = TextGraphic(title_text, font_size=16, face_color="white") + self._title_graphic = TextGraphic(title_text, **_title_kwargs) m = self._title_graphic.world_object.material m.alpha_mode = "blend" m.render_queue = RenderQueue.background @@ -231,6 +257,10 @@ def __init__( self._reset() self.reset_viewport() + @property + def spacing(self) -> dict: + return self._spacing + @property def rect_manager(self) -> RectManager: return self._rect_manager @@ -342,7 +372,10 @@ def _set_toolbar_rect(self): x, y, w, h = self.rect window._fpl_set_rect( - round(x + 1), round(y + h - IMGUI_TOOLBAR_HEIGHT), round(w - 2), IMGUI_TOOLBAR_HEIGHT + round(x + 1), + round(y + h - IMGUI_TOOLBAR_HEIGHT), + round(w - 2), + IMGUI_TOOLBAR_HEIGHT, ) def get_render_rect(self) -> tuple[float, float, float, float]: @@ -354,11 +387,13 @@ def get_render_rect(self) -> tuple[float, float, float, float]: # the rect of the entire Frame x, y, w, h = self.rect - x += 1 # add 1 so a 1 pixel edge is visible - w -= 2 # subtract 2, so we get a 1 pixel edge on both sides + x += self._spacing["x0"] # add 1 so a 1 pixel edge is visible + w -= self._spacing[ + "sides" + ] # subtract 2, so we get a 1 pixel edge on both sides # add 4 pixels above and below title for better spacing - y = y + 4 + self._title_graphic.font_size + 4 + y = y + self._title_graphic.font_size + self._spacing["title_flanks"] # spacing on the bottom if imgui toolbar is visible if self.toolbar_visible: @@ -367,16 +402,15 @@ def get_render_rect(self) -> tuple[float, float, float, float]: else: toolbar_space = 0 # need some space for resize handler if imgui toolbar isn't present - resize_handle_space = 13 + resize_handle_space = self._spacing["resize_handle_space"] # adjust for the 4 pixels from the line above # also give space for resize handler if imgui toolbar is not present h = ( h - - 4 - self._title_graphic.font_size - toolbar_space - - 4 + - self._spacing["bottom"] - resize_handle_space ) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 2aa750607..5f64b19a8 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -1,1637 +1,113 @@ -# This is an auto-generated file and should not be modified directly - -from fastplotlib.graphics._collection_base import * -from fastplotlib.graphics._collections import * -from fastplotlib.graphics._vectors import * -from fastplotlib.graphics.image import * -from fastplotlib.graphics.image_volume import * -from fastplotlib.graphics.inf_line import * -from fastplotlib.graphics.line import * -from fastplotlib.graphics.mesh import * -from fastplotlib.graphics.scatter import * -from fastplotlib.graphics.text import * -from fastplotlib.graphics import Graphic +from collections.abc import Callable +from inspect import Parameter, getdoc, signature + +from ..graphics import ( + Graphic, + ImageCollection, + ImageGraphic, + ImageGrid, + ImageVolumeGraphic, + ImageYUVGraphic, + InfLineGraphic, + LineCollection, + LineGraphic, + LineStack, + MeshGraphic, + PolygonGraphic, + ScatterCollection, + ScatterGraphic, + ScatterStack, + SurfaceGraphic, + TextGraphic, + VectorsGraphic, +) + + +def make_graphic_method( + graphic_cls: type[Graphic], owner: type, name: str +) -> Callable[..., Graphic]: + """the ``add_`` method for a graphic class""" + + def add_graphic(self, *args, **kwargs) -> Graphic: + return self._create_graphic(graphic_cls, *args, **kwargs) + + # the graphic's own signature, `self` included so that it is stripped again when bound + sig = signature(graphic_cls) + add_graphic.__signature__ = sig.replace( + parameters=[ + Parameter("self", Parameter.POSITIONAL_ONLY), + *sig.parameters.values(), + ], + return_annotation=graphic_cls, + ) + + # a collection's arguments come from the graphic it holds, and so does its documentation + documented = getattr(graphic_cls, "_child_type", None) or graphic_cls + add_graphic.__doc__ = getdoc(documented.__init__) + add_graphic.__name__ = name + add_graphic.__qualname__ = f"{owner.__qualname__}.{name}" + + return add_graphic + + +class GraphicMethod: + """ + Descriptor for an ``add_`` method. + + The method has the graphic constructor's signature and passes on only the arguments it was + given, so an argument that is left out is filled in from the graphic's config. + """ + + def __init__(self, graphic_cls: type[Graphic]): + self.graphic_cls = graphic_cls + + def __set_name__(self, owner: type, name: str): + self._method = make_graphic_method(self.graphic_cls, owner, name) + + def __get__(self, instance, owner: type = None) -> Callable[..., Graphic]: + # bind like a plain function: the function on class access, a bound method on an instance + return self._method.__get__(instance, owner) class GraphicMethodsMixin: - def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: - if "center" in kwargs.keys(): - center = kwargs.pop("center") - else: - center = False - - # ignore arguments left at their default of None, i.e. not passed by the caller - kwargs = {k: v for k, v in kwargs.items() if v is not None} - - if "name" in kwargs.keys(): - self._check_graphic_name_exists(kwargs["name"]) - - graphic = graphic_class(*args, **kwargs) - self.add_graphic(graphic, center=center) - - return graphic - - def add_collection(self, data, **kwargs) -> GraphicCollection: - """ - - Create a collection of graphics of the same type. - - Parameters - ---------- - data: list of array-like - one entry per graphic; its length is the number of graphics in the collection - - **kwargs - any feature of the child graphic (``colors``, ``thickness``, ``sizes``, ...), each - accepting one value for all graphics or one value per graphic. A ``Graphic`` argument - (``name``, ``offset``, ``visible``, ...) sets it on the collection itself, its plural - form (``names``, ``offsets``, ``visibles``, ...) sets it per graphic. Any argument that - is not a feature is passed unchanged to every child graphic. - - """ - return self._create_graphic(GraphicCollection, data, **kwargs) - - def add_image_collection( - self, - data: Any, - vmin: float = None, - vmax: float = None, - cmap: str = "plasma", - gamma: float = 1.0, - interpolation: str = "nearest", - cmap_interpolation: str = "linear", - colorspace: ColorspacesRGB = "srgb", - cpu_buffer: bool = True, - *, - names=None, - offsets=None, - rotations=None, - scales=None, - alphas=None, - alpha_modes=None, - visibles=None, - metadatas=None, - **kwargs - ) -> ImageCollection: - """ - - Create an ImageGraphic - - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA - - vmin: float, optional - minimum value for color scaling, estimated from data if not provided - - vmax: float, optional - maximum value for color scaling, estimated from data if not provided - - cmap: str, optional, default "plasma" - colormap to use to display the data. For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - - gamma: float, default 1.0 - gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` - - interpolation: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" - - cmap_interpolation: str, optional, default "linear" - colormap interpolation method, one of "nearest" or "linear" - - colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" - colorspace in which to interpret the provided data. - - * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. - sRGB is a standard color space designed for consistent representation of colors - across devices like monitors. Most images store colors in this space. - The shader convers sRGB colors to physical in the shader before doing color computations. - - * "tex-srgb": the underlying texture will be of an sRGB format. This means the data - is automatically converted to sRGB when it is sampled. This results in better glTF - compliance (because interpolation in the sampling happens in linear space). - Note that sampling *always* results in the sRGB values, also when not interpreted as color. - Only supported for rgb and rgba data. - - * "physical": the colors are (already) in the physical / linear space, where lighting - calculations can be applied. Shader code that interprets the data as color will use it as-is. - - cpu_buffer: bool, default True - If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer - on the GPU. - If ``False``, setting the graphic data will send the new data directly to the GPU, we also - call this "bufferless". This is much faster but lacks the following features: - - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you - cannot perform partial updates such as ``image.data[indices] = ``. - - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, - use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. - The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require - precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. - - * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic - * ``reset_vmin_vmax()`` is not supported - * selector tools will not be able to return the data under the selection - - kwargs: - additional keyword arguments passed to :class:`.Graphic` - - - """ - return self._create_graphic( - ImageCollection, - data, - vmin=vmin, - vmax=vmax, - cmap=cmap, - gamma=gamma, - interpolation=interpolation, - cmap_interpolation=cmap_interpolation, - colorspace=colorspace, - cpu_buffer=cpu_buffer, - names=names, - offsets=offsets, - rotations=rotations, - scales=scales, - alphas=alphas, - alpha_modes=alpha_modes, - visibles=visibles, - metadatas=metadatas, - **kwargs - ) - - def add_image( - self, - data: Any, - vmin: float = None, - vmax: float = None, - cmap: str = "plasma", - gamma: float = 1.0, - interpolation: str = "nearest", - cmap_interpolation: str = "linear", - colorspace: ColorspacesRGB = "srgb", - cpu_buffer: bool = True, - **kwargs - ) -> ImageGraphic: - """ - - Create an ImageGraphic - - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA - - vmin: float, optional - minimum value for color scaling, estimated from data if not provided - - vmax: float, optional - maximum value for color scaling, estimated from data if not provided - - cmap: str, optional, default "plasma" - colormap to use to display the data. For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - - gamma: float, default 1.0 - gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` - - interpolation: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" - - cmap_interpolation: str, optional, default "linear" - colormap interpolation method, one of "nearest" or "linear" - - colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" - colorspace in which to interpret the provided data. - - * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. - sRGB is a standard color space designed for consistent representation of colors - across devices like monitors. Most images store colors in this space. - The shader convers sRGB colors to physical in the shader before doing color computations. - - * "tex-srgb": the underlying texture will be of an sRGB format. This means the data - is automatically converted to sRGB when it is sampled. This results in better glTF - compliance (because interpolation in the sampling happens in linear space). - Note that sampling *always* results in the sRGB values, also when not interpreted as color. - Only supported for rgb and rgba data. - - * "physical": the colors are (already) in the physical / linear space, where lighting - calculations can be applied. Shader code that interprets the data as color will use it as-is. - - cpu_buffer: bool, default True - If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer - on the GPU. - If ``False``, setting the graphic data will send the new data directly to the GPU, we also - call this "bufferless". This is much faster but lacks the following features: - - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you - cannot perform partial updates such as ``image.data[indices] = ``. - - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, - use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. - The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require - precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. - - * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic - * ``reset_vmin_vmax()`` is not supported - * selector tools will not be able to return the data under the selection - - kwargs: - additional keyword arguments passed to :class:`.Graphic` - - - """ - return self._create_graphic( - ImageGraphic, - data, - vmin, - vmax, - cmap, - gamma, - interpolation, - cmap_interpolation, - colorspace, - cpu_buffer, - **kwargs - ) - - def add_image_grid( - self, - data: Any, - vmin: float = None, - vmax: float = None, - cmap: str = "plasma", - gamma: float = 1.0, - interpolation: str = "nearest", - cmap_interpolation: str = "linear", - colorspace: ColorspacesRGB = "srgb", - cpu_buffer: bool = True, - *, - shape: tuple[int, int] = None, - separation: tuple[float, float] = (0.0, 0.0), - offsets: np.ndarray = None, - names=None, - rotations=None, - scales=None, - alphas=None, - alpha_modes=None, - visibles=None, - metadatas=None, - **kwargs - ) -> ImageGrid: - """ - - Create an ImageGraphic - - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA - - vmin: float, optional - minimum value for color scaling, estimated from data if not provided - - vmax: float, optional - maximum value for color scaling, estimated from data if not provided - - cmap: str, optional, default "plasma" - colormap to use to display the data. For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - - gamma: float, default 1.0 - gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` - - interpolation: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" - - cmap_interpolation: str, optional, default "linear" - colormap interpolation method, one of "nearest" or "linear" - - colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" - colorspace in which to interpret the provided data. - - * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. - sRGB is a standard color space designed for consistent representation of colors - across devices like monitors. Most images store colors in this space. - The shader convers sRGB colors to physical in the shader before doing color computations. - - * "tex-srgb": the underlying texture will be of an sRGB format. This means the data - is automatically converted to sRGB when it is sampled. This results in better glTF - compliance (because interpolation in the sampling happens in linear space). - Note that sampling *always* results in the sRGB values, also when not interpreted as color. - Only supported for rgb and rgba data. - - * "physical": the colors are (already) in the physical / linear space, where lighting - calculations can be applied. Shader code that interprets the data as color will use it as-is. - - cpu_buffer: bool, default True - If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer - on the GPU. - If ``False``, setting the graphic data will send the new data directly to the GPU, we also - call this "bufferless". This is much faster but lacks the following features: - - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you - cannot perform partial updates such as ``image.data[indices] = ``. - - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, - use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. - The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require - precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. - - * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic - * ``reset_vmin_vmax()`` is not supported - * selector tools will not be able to return the data under the selection - - kwargs: - additional keyword arguments passed to :class:`.Graphic` - - - """ - return self._create_graphic( - ImageGrid, - data, - vmin=vmin, - vmax=vmax, - cmap=cmap, - gamma=gamma, - interpolation=interpolation, - cmap_interpolation=cmap_interpolation, - colorspace=colorspace, - cpu_buffer=cpu_buffer, - shape=shape, - separation=separation, - offsets=offsets, - names=names, - rotations=rotations, - scales=scales, - alphas=alphas, - alpha_modes=alpha_modes, - visibles=visibles, - metadatas=metadatas, - **kwargs - ) - - def add_image_volume( - self, - data: Any, - mode: str = "mip", - vmin: float = None, - vmax: float = None, - cmap: str = "plasma", - gamma: float = 1.0, - interpolation: str = "linear", - cmap_interpolation: str = "linear", - plane: tuple[float, float, float, float] = (0, 0, -1, 0), - threshold: float = 0.5, - step_size: float = 1.0, - substep_size: float = 0.1, - emissive: str | tuple | np.ndarray = (0, 0, 0), - shininess: int = 30, - **kwargs - ) -> ImageVolumeGraphic: - """ - - Create an ImageVolumeGraphic. - - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()``. - Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A) - - mode: str, default "mip" - render mode, one of "mip", "minip", "iso" or "slice" - - vmin: float - lower contrast limit - - vmax: float - upper contrast limit - - cmap: str, default "plasma" - colormap for grayscale volumes - - gamma: float, default 1.0 - gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` - - interpolation: str, default "linear" - interpolation method for sampling pixels - - cmap_interpolation: str, default "linear" - interpolation method for sampling from colormap - - plane: (float, float, float, float), default (0, 0, -1, 0) - Slice volume at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. - Used only if `mode` = "slice" - - threshold : float, default 0.5 - The threshold texture value at which the surface is rendered. - Used only if `mode` = "iso" - - step_size : float, default 1.0 - The size of the initial ray marching step for the initial surface finding. Smaller values will result in - more accurate surfaces but slower rendering. - Used only if `mode` = "iso" - - substep_size : float, default 0.1 - The size of the raymarching step for the refined surface finding. Smaller values will result in more - accurate surfaces but slower rendering. - Used only if `mode` = "iso" - - emissive : Color, default (0, 0, 0, 1) - The emissive color of the surface. I.e. the color that the object emits even when not lit by a light - source. This color is added to the final color and unaffected by lighting. The alpha channel is ignored. - Used only if `mode` = "iso" - - shininess : int, default 30 - How shiny the specular highlight is; a higher value gives a sharper highlight. - Used only if `mode` = "iso" - - kwargs - additional keyword arguments passed to :class:`.Graphic` - - - """ - return self._create_graphic( - ImageVolumeGraphic, - data, - mode, - vmin, - vmax, - cmap, - gamma, - interpolation, - cmap_interpolation, - plane, - threshold, - step_size, - substep_size, - emissive, - shininess, - **kwargs - ) - - def add_image_yuv( - self, - data: TupleYUV | TextureYUV, - vmin: float = 0, - vmax: float = 255, - gamma: float = 1.0, - interpolation: str = "nearest", - colorspace: ColorspacesYUV = "yuv420p", - colorrange: ColorRange = "limited", - **kwargs - ) -> ImageYUVGraphic: - """ - - Create an ImageYUVGraphic. Similar to ImageGraphic but handles data that is in yuv42p or yuv444p colorspace. - - Note that the buffers for YUV Images only exist on the GPU. When setting the image data, the new values are - directly sent to the GPU. - - ``reset_vmin_vmax()`` just sets (vmin, vmax) to (0, 255) - - Parameters - ---------- - data: TupleYUV - tuple of arrays that represent YUV channels. If the colorspace is yuv420p, the U and V array dims - must be 4 times smaller than the Y array dims. - - vmin: float, optional, default 0 - minimum value for color scaling - - vmax: float, optional, default 255 - maximum value for color scaling - - gamma: float, default 1.0 - gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` - - interpolation: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" - - colorspace: "yuv42p" | "yuv444p" - colorspace in which to interpret the provided data. - - * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). - The y represents intensity, and is at full resolution. The u and v planes are a - quarter of the size. - - * "yuv444p": A lesser common video format. The data is represented as 3 planes - (y, u, and v) similar to yuv420p however the u and v planes are stored - at full resolution. - - colorrange: Literal["full", "limited"] = "limited", - Relevant for yuv colorspaces. Most videos use "limited". - - * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. - The chroma planes (U and V) are limited to the range of 16-240 for 8 bits - * "full": The luma plane and chroma plane use the full range of the storage format. - - See the following links from the FFMPEG documentation for more details: - https://trac.ffmpeg.org/wiki/colorspace - https://ffmpeg.org/doxygen/7.0/pixfmt_8h_source.html#l00609 - - cpu_buffer: bool, default True - If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer - on the GPU. - If ``False``, setting the graphic data will send the new data directly to the GPU, we also - call this "bufferless". This is much faster but lacks the following features: - - * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you - cannot perform partial updates such as ``image.data[indices] = ``. - - * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, - use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. - - * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. - The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require - precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. - - kwargs: - additional keyword arguments passed to :class:`.Graphic` - - - """ - return self._create_graphic( - ImageYUVGraphic, - data, - vmin, - vmax, - gamma, - interpolation, - colorspace, - colorrange, - **kwargs - ) - - def add_inf_line( - self, - data: Any, - axis: Literal["x", "y", "z"] | None = None, - thickness: float = 2.0, - colors: ColorLike | MultiColorLike = "w", - cmap: ColormapLike = None, - cmap_transform: np.ndarray | None = None, - cmap_range: tuple[float, float] | None = None, - start_is_infinite: bool = True, - end_is_infinite: bool = True, - dash_pattern: str | tuple | list = (), - size_space: str = "screen", - **kwargs - ) -> InfLineGraphic: - """ - - Create a collection of infinite lines. - - Parameters - ---------- - data: array-like - The line positions. If ``axis`` is "x", "y", or "z", a 1D array of positions along - that axis; one infinite line is drawn at each position. If ``axis`` is None, ``data`` - is used directly as the segment endpoints, of shape [n_points, 2 | 3], where every two - consecutive points define one line. - - axis: "x", "y", "z", or None, default None - The axis along which the line positions are given. If None, ``data`` is interpreted - directly as the segment endpoints. - - thickness: float, optional, default 2.0 - thickness of the lines - - colors: ColorLike or MultiColorLike, default "w" - specify colors as a single human-readable string, a single RGBA array, or a Sequence - (array, tuple, or list) of strings or RGBA arrays. A sequence of colors provides one - color per line. - - cmap: str, optional - Apply a colormap to the lines instead of assigning colors manually, one color per line. - This overrides any argument passed to "colors". For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - - cmap_transform: np.ndarray, optional - 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap - - cmap_range: (float, float), optional - the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range - - start_is_infinite: bool, default True - whether the start of each line is extended to infinity - - end_is_infinite: bool, default True - whether the end of each line is extended to infinity - - dash_pattern: str, tuple, or list, default () - The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` - or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the - length of strokes and gaps. - - size_space: str, default "screen" - coordinate space in which the thickness is expressed ("screen", "world", "model") - - **kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - InfLineGraphic, - data, - axis, - thickness, - colors, - cmap, - cmap_transform, - cmap_range, - start_is_infinite, - end_is_infinite, - dash_pattern, - size_space, - **kwargs - ) - - def add_line_collection( - self, - data: Any, - thickness: float = 2.0, - colors: ColorLike | MultiColorLike = "w", - cmap: ColormapLike | None = None, - cmap_transform: np.ndarray | Iterable[int | float] | None = None, - cmap_range: tuple[float, float] | None = None, - size_space: str = "screen", - dash_pattern: str | tuple | list = (), - thin: bool = False, - *, - names=None, - offsets=None, - rotations=None, - scales=None, - alphas=None, - alpha_modes=None, - visibles=None, - metadatas=None, - **kwargs - ) -> LineCollection: - """ - - Create a line Graphic, 2d or 3d - - Parameters - ---------- - data: array-like - Line data to plot. Can provide 1D, 2D, or a 3D data. - | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range - from [0, data.size] - | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] - - thickness: float, optional, default 2.0 - thickness of the line - - colors: ColorLike or MultiColorLike, default "w" - specify colors as a single human-readable string, a single RGBA array, - or a Sequence (array, tuple, or list) of strings or RGBA arrays - - cmap: ColormapLike, optional - Apply a colormap to the line instead of assigning colors manually, this - overrides any argument passed to "colors". For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - - cmap_transform: np.ndarray, optional - 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap - - cmap_range: (float, float), optional - the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range - - size_space: str, default "screen" - coordinate space in which the thickness is expressed ("screen", "world", "model") - - dash_pattern: str, tuple, or list, default () - The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` - or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the - length of strokes and gaps. Ignored when ``thin`` is True. - - thin: bool, default False - Use the more performant thin line material, which is always one physical pixel wide. - Thickness, dashing, and anti-aliasing are ignored when True. - - **kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - LineCollection, - data, - thickness=thickness, - colors=colors, - cmap=cmap, - cmap_transform=cmap_transform, - cmap_range=cmap_range, - size_space=size_space, - dash_pattern=dash_pattern, - thin=thin, - names=names, - offsets=offsets, - rotations=rotations, - scales=scales, - alphas=alphas, - alpha_modes=alpha_modes, - visibles=visibles, - metadatas=metadatas, - **kwargs - ) - - def add_line( - self, - data: Any, - thickness: float = 2.0, - colors: ColorLike | MultiColorLike = "w", - cmap: ColormapLike | None = None, - cmap_transform: np.ndarray | Iterable[int | float] | None = None, - cmap_range: tuple[float, float] | None = None, - size_space: str = "screen", - dash_pattern: str | tuple | list = (), - thin: bool = False, - **kwargs - ) -> LineGraphic: - """ - - Create a line Graphic, 2d or 3d + # While we could have this directly in `PlotArea`, the reason it's in a separate module as a mixin + # is because the .pyi file which provides function signatures for IDEs is a module-level feature + # we cannot have a .pyi file for just a few method of a class in a module, which is why we don't have + # a _plot_area.pyi. That would require redundantly re-generating for the entire PlotArea class. + add_line = GraphicMethod(LineGraphic) + add_inf_line = GraphicMethod(InfLineGraphic) - Parameters - ---------- - data: array-like - Line data to plot. Can provide 1D, 2D, or a 3D data. - | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range - from [0, data.size] - | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] + add_line_collection = GraphicMethod(LineCollection) + add_line_stack = GraphicMethod(LineStack) - thickness: float, optional, default 2.0 - thickness of the line + add_scatter = GraphicMethod(ScatterGraphic) - colors: ColorLike or MultiColorLike, default "w" - specify colors as a single human-readable string, a single RGBA array, - or a Sequence (array, tuple, or list) of strings or RGBA arrays + add_scatter_collection = GraphicMethod(ScatterCollection) + add_scatter_stack = GraphicMethod(ScatterStack) - cmap: ColormapLike, optional - Apply a colormap to the line instead of assigning colors manually, this - overrides any argument passed to "colors". For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + add_image = GraphicMethod(ImageGraphic) + add_image_yuv = GraphicMethod(ImageYUVGraphic) + add_image_volume = GraphicMethod(ImageVolumeGraphic) - cmap_transform: np.ndarray, optional - 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap + add_image_collection = GraphicMethod(ImageCollection) + add_image_grid = GraphicMethod(ImageGrid) - cmap_range: (float, float), optional - the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range + add_mesh = GraphicMethod(MeshGraphic) + add_surface = GraphicMethod(SurfaceGraphic) + add_polygon = GraphicMethod(PolygonGraphic) - size_space: str, default "screen" - coordinate space in which the thickness is expressed ("screen", "world", "model") + add_vectors = GraphicMethod(VectorsGraphic) - dash_pattern: str, tuple, or list, default () - The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` - or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the - length of strokes and gaps. Ignored when ``thin`` is True. + add_text = GraphicMethod(TextGraphic) - thin: bool, default False - Use the more performant thin line material, which is always one physical pixel wide. - Thickness, dashing, and anti-aliasing are ignored when True. + def _create_graphic(self, graphic_cls: type[Graphic], *args, **kwargs) -> Graphic: + center = kwargs.pop("center", False) - **kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - LineGraphic, - data, - thickness, - colors, - cmap, - cmap_transform, - cmap_range, - size_space, - dash_pattern, - thin, - **kwargs - ) - - def add_line_stack( - self, - data: Any, - thickness: float = 2.0, - colors: ColorLike | MultiColorLike = "w", - cmap: ColormapLike | None = None, - cmap_transform: np.ndarray | Iterable[int | float] | None = None, - cmap_range: tuple[float, float] | None = None, - size_space: str = "screen", - dash_pattern: str | tuple | list = (), - thin: bool = False, - *, - separation: tuple[float, float, float] = (0.0, 0.0, 0.0), - separation_axis: str = "y", - steps: np.ndarray = None, - names=None, - offsets=None, - rotations=None, - scales=None, - alphas=None, - alpha_modes=None, - visibles=None, - metadatas=None, - **kwargs - ) -> LineStack: - """ - - Create a line Graphic, 2d or 3d - - Parameters - ---------- - data: array-like - Line data to plot. Can provide 1D, 2D, or a 3D data. - | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range - from [0, data.size] - | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] - - thickness: float, optional, default 2.0 - thickness of the line - - colors: ColorLike or MultiColorLike, default "w" - specify colors as a single human-readable string, a single RGBA array, - or a Sequence (array, tuple, or list) of strings or RGBA arrays - - cmap: ColormapLike, optional - Apply a colormap to the line instead of assigning colors manually, this - overrides any argument passed to "colors". For supported colormaps see the - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - - cmap_transform: np.ndarray, optional - 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap - - cmap_range: (float, float), optional - the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range - - size_space: str, default "screen" - coordinate space in which the thickness is expressed ("screen", "world", "model") - - dash_pattern: str, tuple, or list, default () - The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` - or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the - length of strokes and gaps. Ignored when ``thin`` is True. - - thin: bool, default False - Use the more performant thin line material, which is always one physical pixel wide. - Thickness, dashing, and anti-aliasing are ignored when True. - - **kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - LineStack, - data, - thickness=thickness, - colors=colors, - cmap=cmap, - cmap_transform=cmap_transform, - cmap_range=cmap_range, - size_space=size_space, - dash_pattern=dash_pattern, - thin=thin, - separation=separation, - separation_axis=separation_axis, - steps=steps, - names=names, - offsets=offsets, - rotations=rotations, - scales=scales, - alphas=alphas, - alpha_modes=alpha_modes, - visibles=visibles, - metadatas=metadatas, - **kwargs - ) - - def add_mesh( - self, - positions: Any, - indices: Any, - mode: Literal["basic", "phong", "slice"] = "phong", - plane: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 0.0), - colors: str | np.ndarray | Sequence = "w", - mapcoords: Any = None, - cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, - clim: tuple[float, float] = None, - **kwargs - ) -> MeshGraphic: - """ - - Create a mesh Graphic. - - Parameters - ---------- - positions: array-like - The 3D positions of the vertices. - - indices: array-like - The indices into the positions that make up the triangles. Each 3 - subsequent indices form a triangle. - - mode: one of "basic", "phong", "slice", default "phong" - * basic: illuminate mesh with only ambient lighting - * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading - * slice: display a slice of the mesh at the specified ``plane`` - - plane: (float, float, float, float), default (0., 0., 1., 0.) - Slice mesh at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. - Used only if `mode` = "slice". The plane is defined in world space. - - colors: str, array, or iterable, default "w" - A uniform color, or the per-position colors. - - mapcoords: array-like - The per-position coordinates to which to apply the colormap (a.k.a. texcoords). - These can e.g. be some domain-specific value, mapped to [0..1]. - If ``mapcoords`` and ``cmap`` are given, they are used instead of ``colors``. - - cmap: str, optional - Apply a colormap to the mesh, this overrides any argument passed to - "colors". For supported colormaps see the ``cmap`` library - catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. - An image can also be used, this is basically a 2D colormap. - - **kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - MeshGraphic, - positions, - indices, - mode, - plane, - colors, - mapcoords, - cmap, - clim, - **kwargs - ) - - def add_polygon( - self, - data: np.ndarray, - mode: Literal["basic", "phong"] = "basic", - colors: str | np.ndarray | Sequence = "w", - mapcoords: Any = None, - cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, - clim: tuple[float, float] | None = None, - **kwargs - ) -> PolygonGraphic: - """ - - Create a polygon mesh graphic. - - The data are always in the 'xy' plane. Set a rotation to display the polygon in another plane or in 3D space. - - Parameters - ---------- - data: array-like - The polygon vertices, must be of shape: [n_vertices, 2] - - mode: one of "basic", "phong", "slice", default "phong" - * basic: illuminate mesh with only ambient lighting - * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading - - colors: str, array, or iterable, default "w" - A uniform color, or the per-position colors. - - mapcoords: array-like - The per-position coordinates to which to apply the colormap (a.k.a. texcoords). - These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). - If not given, they will be the depth (z-coordinate) of the surface. - - cmap: str, optional - Apply a colormap to the mesh, this overrides any argument passed to - "colors". For supported colormaps see the ``cmap`` library - catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. - - clim: tuple[float, float] - The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim - to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. - - **kwargs - passed to :class:`.Graphic` - - """ - return self._create_graphic( - PolygonGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs - ) - - def add_scatter_collection( - self, - data: Any, - colors: ColorLike | MultiColorLike = "w", - cmap: ColormapLike | None = None, - cmap_transform: np.ndarray | None = None, - cmap_range: tuple[float, float] | None = None, - mode: Literal["markers", "simple", "gaussian", "image"] = "markers", - markers: str | np.ndarray | Sequence[str] = "o", - custom_sdf: str = None, - edge_colors: ColorLike | MultiColorLike | None = "black", - edge_width: float = 1.0, - image: np.ndarray = None, - point_rotations: float | np.ndarray | None = None, - sizes: float | np.ndarray | Sequence[float] = 5, - size_space: str = "screen", - *, - names=None, - offsets=None, - rotations=None, - scales=None, - alphas=None, - alpha_modes=None, - visibles=None, - metadatas=None, - **kwargs - ) -> ScatterCollection: - """ - - Create a Scatter Graphic, 2d or 3d - - Parameters - ---------- - data: array-like - Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. - 3D data must be of shape [n_points, 3] - - colors: ColorLike or MultiColorLike, default "w" - specify colors as a single human-readable string, a single RGBA array, - or a Sequence (array, tuple, or list) of strings or RGBA arrays - - cmap: ColormapLike, optional - apply a colormap to the scatter instead of assigning colors manually, this - overrides any argument passed to "colors". - For supported colormaps see the ``cmap`` library catalogue: - https://cmap-docs.readthedocs.io/en/stable/catalog/ - - cmap_transform: np.ndarray, optional - 1D array-like or list of numerical values, these values are used to map the colors from the cmap - - cmap_range: (float, float), optional - the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range - - mode: one of: "markers", "simple", "gaussian", "image", default "markers" - The scatter points mode, cannot be changed after the graphic has been created. - - * markers: represent points with various or custom markers, default - * simple: all scatters points are simple circles - * gaussian: each point is a gaussian blob - * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites - - markers: str | np.ndarray | Sequence[str], default "o" - The shape of the markers when `mode` is "markers". Specify a single marker to use the same - marker for all points, or a Sequence of markers for per-vertex markers. - - Supported values: - - * A string from pygfx.MarkerShape enum - * Matplotlib compatible characters: "osD+x^v<>*". - * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". - * Emojis: "❤️♠️♣️♦️💎💍✳️📍". - * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - - custom_sdf: str = None, - The SDF code for the marker shape when the marker is set to custom. - Can be used when `mode` is "markers". - - Negative values are inside the shape, positive values are outside the - shape. - - The SDF's takes in two parameters `coords: vec2` and `size: f32`. - The first is a WGSL coordinate and `size` is the overall size of - the texture. The returned value should be the signed distance from - any edge of the shape. Distances (positive and negative) that are - less than half the `edge_width` in absolute terms will be colored - with the `edge_color`. Other negative distances will be colored by - `colors`. - - edge_colors: ColorLike, MultiColorLike, or None, default "black" - edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the - same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass - ``None`` for no edge color. - - edge_width: float = 1.0, - Width of the marker edges. used when `mode` is "markers". - - image: array-like, optional - renders an image at the scatter points, also known as sprites. - The image color is multiplied with the point's "normal" color. - - point_rotations: float, array-like, or None, default None - The rotation of the scatter points in radians. The rotation mode is determined automatically from - the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve - of the data (in screen space); a single float for the same rotation on every point ("uniform"); or - an array of rotation values for per-point rotations ("vertex"). - - sizes: float, np.ndarray, or Sequence[float], default 5 - size(s) of the scatter points. Specify a single size to use the same size for all points, or a - Sequence of sizes for per-point sizes. - - size_space: str, default "screen" - coordinate space in which the size is expressed, one of ("screen", "world", "model") - - kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - ScatterCollection, - data, - colors=colors, - cmap=cmap, - cmap_transform=cmap_transform, - cmap_range=cmap_range, - mode=mode, - markers=markers, - custom_sdf=custom_sdf, - edge_colors=edge_colors, - edge_width=edge_width, - image=image, - point_rotations=point_rotations, - sizes=sizes, - size_space=size_space, - names=names, - offsets=offsets, - rotations=rotations, - scales=scales, - alphas=alphas, - alpha_modes=alpha_modes, - visibles=visibles, - metadatas=metadatas, - **kwargs - ) - - def add_scatter( - self, - data: Any, - colors: ColorLike | MultiColorLike = "w", - cmap: ColormapLike | None = None, - cmap_transform: np.ndarray | None = None, - cmap_range: tuple[float, float] | None = None, - mode: Literal["markers", "simple", "gaussian", "image"] = "markers", - markers: str | np.ndarray | Sequence[str] = "o", - custom_sdf: str = None, - edge_colors: ColorLike | MultiColorLike | None = "black", - edge_width: float = 1.0, - image: np.ndarray = None, - point_rotations: float | np.ndarray | None = None, - sizes: float | np.ndarray | Sequence[float] = 5, - size_space: str = "screen", - **kwargs - ) -> ScatterGraphic: - """ - - Create a Scatter Graphic, 2d or 3d - - Parameters - ---------- - data: array-like - Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. - 3D data must be of shape [n_points, 3] - - colors: ColorLike or MultiColorLike, default "w" - specify colors as a single human-readable string, a single RGBA array, - or a Sequence (array, tuple, or list) of strings or RGBA arrays - - cmap: ColormapLike, optional - apply a colormap to the scatter instead of assigning colors manually, this - overrides any argument passed to "colors". - For supported colormaps see the ``cmap`` library catalogue: - https://cmap-docs.readthedocs.io/en/stable/catalog/ - - cmap_transform: np.ndarray, optional - 1D array-like or list of numerical values, these values are used to map the colors from the cmap - - cmap_range: (float, float), optional - the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range - - mode: one of: "markers", "simple", "gaussian", "image", default "markers" - The scatter points mode, cannot be changed after the graphic has been created. - - * markers: represent points with various or custom markers, default - * simple: all scatters points are simple circles - * gaussian: each point is a gaussian blob - * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites - - markers: str | np.ndarray | Sequence[str], default "o" - The shape of the markers when `mode` is "markers". Specify a single marker to use the same - marker for all points, or a Sequence of markers for per-vertex markers. - - Supported values: - - * A string from pygfx.MarkerShape enum - * Matplotlib compatible characters: "osD+x^v<>*". - * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". - * Emojis: "❤️♠️♣️♦️💎💍✳️📍". - * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - - custom_sdf: str = None, - The SDF code for the marker shape when the marker is set to custom. - Can be used when `mode` is "markers". - - Negative values are inside the shape, positive values are outside the - shape. - - The SDF's takes in two parameters `coords: vec2` and `size: f32`. - The first is a WGSL coordinate and `size` is the overall size of - the texture. The returned value should be the signed distance from - any edge of the shape. Distances (positive and negative) that are - less than half the `edge_width` in absolute terms will be colored - with the `edge_color`. Other negative distances will be colored by - `colors`. - - edge_colors: ColorLike, MultiColorLike, or None, default "black" - edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the - same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass - ``None`` for no edge color. - - edge_width: float = 1.0, - Width of the marker edges. used when `mode` is "markers". - - image: array-like, optional - renders an image at the scatter points, also known as sprites. - The image color is multiplied with the point's "normal" color. - - point_rotations: float, array-like, or None, default None - The rotation of the scatter points in radians. The rotation mode is determined automatically from - the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve - of the data (in screen space); a single float for the same rotation on every point ("uniform"); or - an array of rotation values for per-point rotations ("vertex"). - - sizes: float, np.ndarray, or Sequence[float], default 5 - size(s) of the scatter points. Specify a single size to use the same size for all points, or a - Sequence of sizes for per-point sizes. - - size_space: str, default "screen" - coordinate space in which the size is expressed, one of ("screen", "world", "model") - - kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - ScatterGraphic, - data, - colors, - cmap, - cmap_transform, - cmap_range, - mode, - markers, - custom_sdf, - edge_colors, - edge_width, - image, - point_rotations, - sizes, - size_space, - **kwargs - ) - - def add_scatter_stack( - self, - data: Any, - colors: ColorLike | MultiColorLike = "w", - cmap: ColormapLike | None = None, - cmap_transform: np.ndarray | None = None, - cmap_range: tuple[float, float] | None = None, - mode: Literal["markers", "simple", "gaussian", "image"] = "markers", - markers: str | np.ndarray | Sequence[str] = "o", - custom_sdf: str = None, - edge_colors: ColorLike | MultiColorLike | None = "black", - edge_width: float = 1.0, - image: np.ndarray = None, - point_rotations: float | np.ndarray | None = None, - sizes: float | np.ndarray | Sequence[float] = 5, - size_space: str = "screen", - *, - separation: tuple[float, float, float] = (0.0, 0.0, 0.0), - separation_axis: str = "y", - steps: np.ndarray = None, - names=None, - offsets=None, - rotations=None, - scales=None, - alphas=None, - alpha_modes=None, - visibles=None, - metadatas=None, - **kwargs - ) -> ScatterStack: - """ - - Create a Scatter Graphic, 2d or 3d - - Parameters - ---------- - data: array-like - Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. - 3D data must be of shape [n_points, 3] - - colors: ColorLike or MultiColorLike, default "w" - specify colors as a single human-readable string, a single RGBA array, - or a Sequence (array, tuple, or list) of strings or RGBA arrays - - cmap: ColormapLike, optional - apply a colormap to the scatter instead of assigning colors manually, this - overrides any argument passed to "colors". - For supported colormaps see the ``cmap`` library catalogue: - https://cmap-docs.readthedocs.io/en/stable/catalog/ - - cmap_transform: np.ndarray, optional - 1D array-like or list of numerical values, these values are used to map the colors from the cmap - - cmap_range: (float, float), optional - the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range - - mode: one of: "markers", "simple", "gaussian", "image", default "markers" - The scatter points mode, cannot be changed after the graphic has been created. - - * markers: represent points with various or custom markers, default - * simple: all scatters points are simple circles - * gaussian: each point is a gaussian blob - * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites - - markers: str | np.ndarray | Sequence[str], default "o" - The shape of the markers when `mode` is "markers". Specify a single marker to use the same - marker for all points, or a Sequence of markers for per-vertex markers. - - Supported values: - - * A string from pygfx.MarkerShape enum - * Matplotlib compatible characters: "osD+x^v<>*". - * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". - * Emojis: "❤️♠️♣️♦️💎💍✳️📍". - * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. - - custom_sdf: str = None, - The SDF code for the marker shape when the marker is set to custom. - Can be used when `mode` is "markers". - - Negative values are inside the shape, positive values are outside the - shape. - - The SDF's takes in two parameters `coords: vec2` and `size: f32`. - The first is a WGSL coordinate and `size` is the overall size of - the texture. The returned value should be the signed distance from - any edge of the shape. Distances (positive and negative) that are - less than half the `edge_width` in absolute terms will be colored - with the `edge_color`. Other negative distances will be colored by - `colors`. - - edge_colors: ColorLike, MultiColorLike, or None, default "black" - edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the - same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass - ``None`` for no edge color. - - edge_width: float = 1.0, - Width of the marker edges. used when `mode` is "markers". - - image: array-like, optional - renders an image at the scatter points, also known as sprites. - The image color is multiplied with the point's "normal" color. - - point_rotations: float, array-like, or None, default None - The rotation of the scatter points in radians. The rotation mode is determined automatically from - the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve - of the data (in screen space); a single float for the same rotation on every point ("uniform"); or - an array of rotation values for per-point rotations ("vertex"). - - sizes: float, np.ndarray, or Sequence[float], default 5 - size(s) of the scatter points. Specify a single size to use the same size for all points, or a - Sequence of sizes for per-point sizes. - - size_space: str, default "screen" - coordinate space in which the size is expressed, one of ("screen", "world", "model") - - kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - ScatterStack, - data, - colors=colors, - cmap=cmap, - cmap_transform=cmap_transform, - cmap_range=cmap_range, - mode=mode, - markers=markers, - custom_sdf=custom_sdf, - edge_colors=edge_colors, - edge_width=edge_width, - image=image, - point_rotations=point_rotations, - sizes=sizes, - size_space=size_space, - separation=separation, - separation_axis=separation_axis, - steps=steps, - names=names, - offsets=offsets, - rotations=rotations, - scales=scales, - alphas=alphas, - alpha_modes=alpha_modes, - visibles=visibles, - metadatas=metadatas, - **kwargs - ) - - def add_surface( - self, - data: np.ndarray, - mode: Literal["basic", "phong", "slice"] = "phong", - colors: str | np.ndarray | Sequence = "w", - mapcoords: Any = None, - cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, - clim: tuple[float, float] | None = None, - **kwargs - ) -> SurfaceGraphic: - """ - - Create a Surface mesh Graphic - - Parameters - ---------- - data: array-like - A height-map (an image where the values indicate height, i.e. z values). - Can also be a [m, n, 3] to explicitly specify the x and y values in addition to the z values. - [m, n, 3] is a dstack of (x, y, z) values that form a grid on the xy plane. - - mode: one of "basic", "phong", "slice", default "phong" - * basic: illuminate mesh with only ambient lighting - * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading - - colors: str, array, or iterable, default "w" - A uniform color, or the per-position colors. - - mapcoords: array-like - The per-position coordinates to which to apply the colormap (a.k.a. texcoords). - These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). - If not given, they will be the depth (z-coordinate) of the surface. - - cmap: str, optional - Apply a colormap to the mesh, this overrides any argument passed to - "colors". For supported colormaps see the ``cmap`` library - catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. - - clim: tuple[float, float] - The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim - to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. - - **kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - SurfaceGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs - ) - - def add_text( - self, - text: str, - font_size: float | int = 14, - face_color: str | np.ndarray | list[float] | tuple[float] = "w", - outline_color: str | np.ndarray | list[float] | tuple[float] = "w", - outline_thickness: float = 0.0, - screen_space: bool = True, - offset: tuple[float] = (0, 0, 0), - anchor: str = "middle-center", - **kwargs - ) -> TextGraphic: - """ - - Create a text Graphic - - Parameters - ---------- - text: str - text to display - - font_size: float | int, default 10 - font size - - face_color: str, array, list, tuple, default "w" - str or RGBA array to set the color of the text - - outline_color: str, array, list, tuple, default "w" - str or RGBA array to set the outline color of the text - - outline_thickness: float, default 0 - relative outline thickness, value between 0.0 - 0.5 - - screen_space: bool = True - if True, text size is in screen space, if False the text size is in data space - - offset: (float, float, float), default (0, 0, 0) - places the text at this location - - anchor: str, default "middle-center" - position of the origin of the text - a string representing the vertical and horizontal anchors, separated by a dash - - * Vertical values: "top", "middle", "baseline", "bottom" - * Horizontal values: "left", "center", "right" - - **kwargs - passed to :class:`.Graphic` - - - """ - return self._create_graphic( - TextGraphic, - text, - font_size, - face_color, - outline_color, - outline_thickness, - screen_space, - offset, - anchor, - **kwargs - ) - - def add_vectors( - self, - positions: np.ndarray | Sequence[float], - directions: np.ndarray | Sequence[float], - color: str | Sequence[float] | np.ndarray = "w", - size: float = None, - vector_shape_options: dict = None, - **kwargs - ) -> VectorsGraphic: - """ - - Create graphic that draw vectors. Similar to matplotlib quiver. - - Parameters - ---------- - positions: np.ndarray | Sequence[float] - positions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. - - directions: np.ndarray | Sequence[float] - directions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. - - spacing: float - average distance between pairs of nearest-neighbor vectors, used for scaling - - color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w" - color of the vectors - - size: float or None - Size of a vector of magnitude 1 in world space for display purpose. - Estimated from density if not provided. - - vector_shape_options: dict - dict with the following fields that directly describes the shape of the vector arrows. - Overrides ``size`` argument. - - * cone_radius - * cone_height - * stalk_radius - * stalk_height - - **kwargs - passed to :class:`.Graphic` + if "name" in kwargs: + # check the name before creating the graphic + self._check_graphic_name_exists(kwargs["name"]) + graphic = graphic_cls(*args, **kwargs) + self.add_graphic(graphic, center=center) - """ - return self._create_graphic( - VectorsGraphic, - positions, - directions, - color, - size, - vector_shape_options, - **kwargs - ) + return graphic diff --git a/fastplotlib/layouts/_graphic_methods_mixin.pyi b/fastplotlib/layouts/_graphic_methods_mixin.pyi new file mode 100644 index 000000000..9e6cd20da --- /dev/null +++ b/fastplotlib/layouts/_graphic_methods_mixin.pyi @@ -0,0 +1,1327 @@ +# This is an auto-generated file and should not be modified directly +# regenerate with: python scripts/generate_add_graphics_stub.py + +from fastplotlib.graphics._collections import * +from fastplotlib.graphics._vectors import * +from fastplotlib.graphics.image import * +from fastplotlib.graphics.image_volume import * +from fastplotlib.graphics.inf_line import * +from fastplotlib.graphics.line import * +from fastplotlib.graphics.mesh import * +from fastplotlib.graphics.scatter import * +from fastplotlib.graphics.text import * + +class GraphicMethodsMixin: + def add_line( + self, + data: Any, + thickness: float = 2.0, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | Iterable[int | float] | None = None, + cmap_range: tuple[float, float] | None = None, + size_space: Literal["screen", "world", "model"] = "screen", + dash_pattern: str | tuple | list = (), + thin: bool = False, + **kwargs + ) -> LineGraphic: + """ + + Create a line Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Line data to plot. Can provide 1D, 2D, or a 3D data. + | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range + from [0, data.size] + | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] + + thickness: float, optional, default 2.0 + thickness of the line + + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays + + cmap: ColormapLike, optional + Apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap + + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range + + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") + + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. + + thin: bool, default False + Use the more performant thin line material, which is always one physical pixel wide. + Thickness, dashing, and anti-aliasing are ignored when True. + + **kwargs + passed to :class:`.Graphic` + + + """ + + def add_inf_line( + self, + data: Any, + axis: Literal["x", "y", "z"] | None = None, + thickness: float = 2.0, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, + start_is_infinite: bool = True, + end_is_infinite: bool = True, + dash_pattern: str | tuple | list = (), + size_space: Literal["screen", "world", "model"] = "screen", + **kwargs + ) -> InfLineGraphic: + """ + + Create a collection of infinite lines. + + Parameters + ---------- + data: array-like + The line positions. If ``axis`` is "x", "y", or "z", a 1D array of positions along + that axis; one infinite line is drawn at each position. If ``axis`` is None, ``data`` + is used directly as the segment endpoints, of shape [n_points, 2 | 3], where every two + consecutive points define one line. + + axis: "x", "y", "z", or None, default None + The axis along which the line positions are given. If None, ``data`` is interpreted + directly as the segment endpoints. + + thickness: float, optional, default 2.0 + thickness of the lines + + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, or a Sequence + (array, tuple, or list) of strings or RGBA arrays. A sequence of colors provides one + color per line. + + cmap: str, optional + Apply a colormap to the lines instead of assigning colors manually, one color per line. + This overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap + + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range + + start_is_infinite: bool, default True + whether the start of each line is extended to infinity + + end_is_infinite: bool, default True + whether the end of each line is extended to infinity + + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. + + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") + + **kwargs + passed to :class:`.Graphic` + + + """ + + def add_line_collection( + self, + data: Any, + thickness: float = 2.0, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | Iterable[int | float] | None = None, + cmap_range: tuple[float, float] | None = None, + size_space: Literal["screen", "world", "model"] = "screen", + dash_pattern: str | tuple | list = (), + thin: bool = False, + *, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, + **kwargs + ) -> LineCollection: + """ + + Create a line Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Line data to plot. Can provide 1D, 2D, or a 3D data. + | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range + from [0, data.size] + | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] + + thickness: float, optional, default 2.0 + thickness of the line + + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays + + cmap: ColormapLike, optional + Apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap + + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range + + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") + + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. + + thin: bool, default False + Use the more performant thin line material, which is always one physical pixel wide. + Thickness, dashing, and anti-aliasing are ignored when True. + + **kwargs + passed to :class:`.Graphic` + + + """ + + def add_line_stack( + self, + data: Any, + thickness: float = 2.0, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | Iterable[int | float] | None = None, + cmap_range: tuple[float, float] | None = None, + size_space: Literal["screen", "world", "model"] = "screen", + dash_pattern: str | tuple | list = (), + thin: bool = False, + *, + separation: tuple[float, float, float] = (0.0, 0.0, 0.0), + separation_axis: str = "y", + steps: np.ndarray = None, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, + **kwargs + ) -> LineStack: + """ + + Create a line Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Line data to plot. Can provide 1D, 2D, or a 3D data. + | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range + from [0, data.size] + | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] + + thickness: float, optional, default 2.0 + thickness of the line + + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays + + cmap: ColormapLike, optional + Apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + cmap_transform: np.ndarray, optional + 1D array-like of numerical values, if provided, these values are used to map the colors from the cmap + + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range + + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") + + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. + + thin: bool, default False + Use the more performant thin line material, which is always one physical pixel wide. + Thickness, dashing, and anti-aliasing are ignored when True. + + **kwargs + passed to :class:`.Graphic` + + + """ + + def add_scatter( + self, + data: Any, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, + mode: Literal["markers", "simple", "gaussian", "image"] = "markers", + markers: str | np.ndarray | Sequence[str] = "o", + custom_sdf: str = None, + edge_colors: ColorLike | MultiColorLike | None = "black", + edge_width: float = 1.0, + image: np.ndarray = None, + point_rotations: float | np.ndarray | None = 0.0, + sizes: float | np.ndarray | Sequence[float] = 5, + size_space: str = "screen", + **kwargs + ) -> ScatterGraphic: + """ + + Create a Scatter Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. + 3D data must be of shape [n_points, 3] + + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays + + cmap: ColormapLike, optional + apply a colormap to the scatter instead of assigning colors manually, this + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ + + cmap_transform: np.ndarray, optional + 1D array-like or list of numerical values, these values are used to map the colors from the cmap + + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range + + mode: one of: "markers", "simple", "gaussian", "image", default "markers" + The scatter points mode, cannot be changed after the graphic has been created. + + * markers: represent points with various or custom markers, default + * simple: all scatters points are simple circles + * gaussian: each point is a gaussian blob + * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites + + markers: str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers". Specify a single marker to use the same + marker for all points, or a Sequence of markers for per-vertex markers. + + Supported values: + + * A string from pygfx.MarkerShape enum + * Matplotlib compatible characters: "osD+x^v<>*". + * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". + * Emojis: "❤️♠️♣️♦️💎💍✳️📍". + * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. + + custom_sdf: str = None, + The SDF code for the marker shape when the marker is set to custom. + Can be used when `mode` is "markers". + + Negative values are inside the shape, positive values are outside the + shape. + + The SDF's takes in two parameters `coords: vec2` and `size: f32`. + The first is a WGSL coordinate and `size` is the overall size of + the texture. The returned value should be the signed distance from + any edge of the shape. Distances (positive and negative) that are + less than half the `edge_width` in absolute terms will be colored + with the `edge_color`. Other negative distances will be colored by + `colors`. + + edge_colors: ColorLike, MultiColorLike, or None, default "black" + edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the + same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass + ``None`` for no edge color. + + edge_width: float = 1.0, + Width of the marker edges. used when `mode` is "markers". + + image: array-like, optional + renders an image at the scatter points, also known as sprites. + The image color is multiplied with the point's "normal" color. + + point_rotations: float, array-like, or None, default 0.0 + The rotation of the scatter points in radians. The rotation mode is determined automatically from + the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve + of the data (in screen space); a single float for the same rotation on every point ("uniform"); or + an array of rotation values for per-point rotations ("vertex"). Units are in radians. + + sizes: float, np.ndarray, or Sequence[float], default 5 + size(s) of the scatter points. Specify a single size to use the same size for all points, or a + Sequence of sizes for per-point sizes. + + size_space: str, default "screen" + coordinate space in which the size is expressed, one of ("screen", "world", "model") + + kwargs + passed to :class:`.Graphic` + + + """ + + def add_scatter_collection( + self, + data: Any, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, + mode: Literal["markers", "simple", "gaussian", "image"] = "markers", + markers: str | np.ndarray | Sequence[str] = "o", + custom_sdf: str = None, + edge_colors: ColorLike | MultiColorLike | None = "black", + edge_width: float = 1.0, + image: np.ndarray = None, + point_rotations: float | np.ndarray | None = 0.0, + sizes: float | np.ndarray | Sequence[float] = 5, + size_space: str = "screen", + *, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, + **kwargs + ) -> ScatterCollection: + """ + + Create a Scatter Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. + 3D data must be of shape [n_points, 3] + + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays + + cmap: ColormapLike, optional + apply a colormap to the scatter instead of assigning colors manually, this + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ + + cmap_transform: np.ndarray, optional + 1D array-like or list of numerical values, these values are used to map the colors from the cmap + + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range + + mode: one of: "markers", "simple", "gaussian", "image", default "markers" + The scatter points mode, cannot be changed after the graphic has been created. + + * markers: represent points with various or custom markers, default + * simple: all scatters points are simple circles + * gaussian: each point is a gaussian blob + * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites + + markers: str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers". Specify a single marker to use the same + marker for all points, or a Sequence of markers for per-vertex markers. + + Supported values: + + * A string from pygfx.MarkerShape enum + * Matplotlib compatible characters: "osD+x^v<>*". + * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". + * Emojis: "❤️♠️♣️♦️💎💍✳️📍". + * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. + + custom_sdf: str = None, + The SDF code for the marker shape when the marker is set to custom. + Can be used when `mode` is "markers". + + Negative values are inside the shape, positive values are outside the + shape. + + The SDF's takes in two parameters `coords: vec2` and `size: f32`. + The first is a WGSL coordinate and `size` is the overall size of + the texture. The returned value should be the signed distance from + any edge of the shape. Distances (positive and negative) that are + less than half the `edge_width` in absolute terms will be colored + with the `edge_color`. Other negative distances will be colored by + `colors`. + + edge_colors: ColorLike, MultiColorLike, or None, default "black" + edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the + same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass + ``None`` for no edge color. + + edge_width: float = 1.0, + Width of the marker edges. used when `mode` is "markers". + + image: array-like, optional + renders an image at the scatter points, also known as sprites. + The image color is multiplied with the point's "normal" color. + + point_rotations: float, array-like, or None, default 0.0 + The rotation of the scatter points in radians. The rotation mode is determined automatically from + the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve + of the data (in screen space); a single float for the same rotation on every point ("uniform"); or + an array of rotation values for per-point rotations ("vertex"). Units are in radians. + + sizes: float, np.ndarray, or Sequence[float], default 5 + size(s) of the scatter points. Specify a single size to use the same size for all points, or a + Sequence of sizes for per-point sizes. + + size_space: str, default "screen" + coordinate space in which the size is expressed, one of ("screen", "world", "model") + + kwargs + passed to :class:`.Graphic` + + + """ + + def add_scatter_stack( + self, + data: Any, + colors: ColorLike | MultiColorLike = "w", + cmap: ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + cmap_range: tuple[float, float] | None = None, + mode: Literal["markers", "simple", "gaussian", "image"] = "markers", + markers: str | np.ndarray | Sequence[str] = "o", + custom_sdf: str = None, + edge_colors: ColorLike | MultiColorLike | None = "black", + edge_width: float = 1.0, + image: np.ndarray = None, + point_rotations: float | np.ndarray | None = 0.0, + sizes: float | np.ndarray | Sequence[float] = 5, + size_space: str = "screen", + *, + separation: tuple[float, float, float] = (0.0, 0.0, 0.0), + separation_axis: str = "y", + steps: np.ndarray = None, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, + **kwargs + ) -> ScatterStack: + """ + + Create a Scatter Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. + 3D data must be of shape [n_points, 3] + + colors: ColorLike or MultiColorLike, default "w" + specify colors as a single human-readable string, a single RGBA array, + or a Sequence (array, tuple, or list) of strings or RGBA arrays + + cmap: ColormapLike, optional + apply a colormap to the scatter instead of assigning colors manually, this + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ + + cmap_transform: np.ndarray, optional + 1D array-like or list of numerical values, these values are used to map the colors from the cmap + + cmap_range: (float, float), optional + the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range + + mode: one of: "markers", "simple", "gaussian", "image", default "markers" + The scatter points mode, cannot be changed after the graphic has been created. + + * markers: represent points with various or custom markers, default + * simple: all scatters points are simple circles + * gaussian: each point is a gaussian blob + * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites + + markers: str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers". Specify a single marker to use the same + marker for all points, or a Sequence of markers for per-vertex markers. + + Supported values: + + * A string from pygfx.MarkerShape enum + * Matplotlib compatible characters: "osD+x^v<>*". + * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". + * Emojis: "❤️♠️♣️♦️💎💍✳️📍". + * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. + + custom_sdf: str = None, + The SDF code for the marker shape when the marker is set to custom. + Can be used when `mode` is "markers". + + Negative values are inside the shape, positive values are outside the + shape. + + The SDF's takes in two parameters `coords: vec2` and `size: f32`. + The first is a WGSL coordinate and `size` is the overall size of + the texture. The returned value should be the signed distance from + any edge of the shape. Distances (positive and negative) that are + less than half the `edge_width` in absolute terms will be colored + with the `edge_color`. Other negative distances will be colored by + `colors`. + + edge_colors: ColorLike, MultiColorLike, or None, default "black" + edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the + same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass + ``None`` for no edge color. + + edge_width: float = 1.0, + Width of the marker edges. used when `mode` is "markers". + + image: array-like, optional + renders an image at the scatter points, also known as sprites. + The image color is multiplied with the point's "normal" color. + + point_rotations: float, array-like, or None, default 0.0 + The rotation of the scatter points in radians. The rotation mode is determined automatically from + the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve + of the data (in screen space); a single float for the same rotation on every point ("uniform"); or + an array of rotation values for per-point rotations ("vertex"). Units are in radians. + + sizes: float, np.ndarray, or Sequence[float], default 5 + size(s) of the scatter points. Specify a single size to use the same size for all points, or a + Sequence of sizes for per-point sizes. + + size_space: str, default "screen" + coordinate space in which the size is expressed, one of ("screen", "world", "model") + + kwargs + passed to :class:`.Graphic` + + + """ + + def add_image( + self, + data: Any, + vmin: float = None, + vmax: float = None, + cmap: str = "plasma", + gamma: float = 1.0, + interpolation: Literal["nearest", "linear"] = "nearest", + cmap_interpolation: Literal["nearest", "linear"] = "linear", + colorspace: ColorspacesRGB = "srgb", + cpu_buffer: bool = True, + **kwargs + ) -> ImageGraphic: + """ + + Create an ImageGraphic + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()`` + | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA + + vmin: float, optional + minimum value for color scaling, estimated from data if not provided + + vmax: float, optional + maximum value for color scaling, estimated from data if not provided + + cmap: str, optional, default "plasma" + colormap to use to display the data. For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" + + colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" + colorspace in which to interpret the provided data. + + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. + + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. + + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + + """ + + def add_image_yuv( + self, + data: TupleYUV | TextureYUV, + vmin: float = 0, + vmax: float = 255, + gamma: float = 1.0, + interpolation: Literal["nearest", "linear"] = "nearest", + colorspace: ColorspacesYUV = "yuv420p", + colorrange: ColorRange = "limited", + **kwargs + ) -> ImageYUVGraphic: + """ + + Create an ImageYUVGraphic. Similar to ImageGraphic but handles data that is in yuv42p or yuv444p colorspace. + + Note that the buffers for YUV Images only exist on the GPU. When setting the image data, the new values are + directly sent to the GPU. + + ``reset_vmin_vmax()`` just sets (vmin, vmax) to (0, 255) + + Parameters + ---------- + data: TupleYUV + tuple of arrays that represent YUV channels. If the colorspace is yuv420p, the U and V array dims + must be 4 times smaller than the Y array dims. + + vmin: float, optional, default 0 + minimum value for color scaling + + vmax: float, optional, default 255 + maximum value for color scaling + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + colorspace: "yuv42p" | "yuv444p" + colorspace in which to interpret the provided data. + + * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). + The y represents intensity, and is at full resolution. The u and v planes are a + quarter of the size. + + * "yuv444p": A lesser common video format. The data is represented as 3 planes + (y, u, and v) similar to yuv420p however the u and v planes are stored + at full resolution. + + colorrange: Literal["full", "limited"] = "limited", + Relevant for yuv colorspaces. Most videos use "limited". + + * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. + The chroma planes (U and V) are limited to the range of 16-240 for 8 bits + * "full": The luma plane and chroma plane use the full range of the storage format. + + See the following links from the FFMPEG documentation for more details: + https://trac.ffmpeg.org/wiki/colorspace + https://ffmpeg.org/doxygen/7.0/pixfmt_8h_source.html#l00609 + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + + """ + + def add_image_volume( + self, + data: Any, + mode: str = "mip", + vmin: float = None, + vmax: float = None, + cmap: str = "plasma", + gamma: float = 1.0, + interpolation: Literal["nearest", "linear"] = "linear", + cmap_interpolation: Literal["nearest", "linear"] = "linear", + plane: tuple[float, float, float, float] = (0, 0, -1, 0), + threshold: float = 0.5, + step_size: float = 1.0, + substep_size: float = 0.1, + emissive: str | tuple | np.ndarray = (0, 0, 0), + shininess: int = 30, + **kwargs + ) -> ImageVolumeGraphic: + """ + + Create an ImageVolumeGraphic. + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()``. + Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A) + + mode: str, default "mip" + render mode, one of "mip", "minip", "iso" or "slice" + + vmin: float + lower contrast limit + + vmax: float + upper contrast limit + + cmap: str, default "plasma" + colormap for grayscale volumes + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, default "linear" + interpolation method for sampling pixels + + cmap_interpolation: str, default "linear" + interpolation method for sampling from colormap + + plane: (float, float, float, float), default (0, 0, -1, 0) + Slice volume at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. + Used only if `mode` = "slice" + + threshold : float, default 0.5 + The threshold texture value at which the surface is rendered. + Used only if `mode` = "iso" + + step_size : float, default 1.0 + The size of the initial ray marching step for the initial surface finding. Smaller values will result in + more accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + substep_size : float, default 0.1 + The size of the raymarching step for the refined surface finding. Smaller values will result in more + accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + emissive : Color, default (0, 0, 0, 1) + The emissive color of the surface. I.e. the color that the object emits even when not lit by a light + source. This color is added to the final color and unaffected by lighting. The alpha channel is ignored. + Used only if `mode` = "iso" + + shininess : int, default 30 + How shiny the specular highlight is; a higher value gives a sharper highlight. + Used only if `mode` = "iso" + + kwargs + additional keyword arguments passed to :class:`.Graphic` + + + """ + + def add_image_collection( + self, + data: Any, + vmin: float = None, + vmax: float = None, + cmap: str = "plasma", + gamma: float = 1.0, + interpolation: Literal["nearest", "linear"] = "nearest", + cmap_interpolation: Literal["nearest", "linear"] = "linear", + colorspace: ColorspacesRGB = "srgb", + cpu_buffer: bool = True, + *, + names=None, + offsets=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, + **kwargs + ) -> ImageCollection: + """ + + Create an ImageGraphic + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()`` + | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA + + vmin: float, optional + minimum value for color scaling, estimated from data if not provided + + vmax: float, optional + maximum value for color scaling, estimated from data if not provided + + cmap: str, optional, default "plasma" + colormap to use to display the data. For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" + + colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" + colorspace in which to interpret the provided data. + + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. + + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. + + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + + """ + + def add_image_grid( + self, + data: Any, + vmin: float = None, + vmax: float = None, + cmap: str = "plasma", + gamma: float = 1.0, + interpolation: Literal["nearest", "linear"] = "nearest", + cmap_interpolation: Literal["nearest", "linear"] = "linear", + colorspace: ColorspacesRGB = "srgb", + cpu_buffer: bool = True, + *, + shape: tuple[int, int] = None, + separation: tuple[float, float] = (0.0, 0.0), + offsets: np.ndarray = None, + names=None, + rotations=None, + scales=None, + alphas=None, + alpha_modes=None, + visibles=None, + metadatas=None, + **kwargs + ) -> ImageGrid: + """ + + Create an ImageGraphic + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()`` + | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA + + vmin: float, optional + minimum value for color scaling, estimated from data if not provided + + vmax: float, optional + maximum value for color scaling, estimated from data if not provided + + cmap: str, optional, default "plasma" + colormap to use to display the data. For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" + + colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" + colorspace in which to interpret the provided data. + + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. + + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. + + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + + """ + + def add_mesh( + self, + positions: Any, + indices: Any, + mode: Literal["basic", "phong", "slice"] = "phong", + plane: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 0.0), + colors: str | np.ndarray | Sequence = "w", + mapcoords: Any = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, + clim: tuple[float, float] = None, + **kwargs + ) -> MeshGraphic: + """ + + Create a mesh Graphic. + + Parameters + ---------- + positions: array-like + The 3D positions of the vertices. + + indices: array-like + The indices into the positions that make up the triangles. Each 3 + subsequent indices form a triangle. + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + * slice: display a slice of the mesh at the specified ``plane`` + + plane: (float, float, float, float), default (0., 0., 1., 0.) + Slice mesh at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. + Used only if `mode` = "slice". The plane is defined in world space. + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value, mapped to [0..1]. + If ``mapcoords`` and ``cmap`` are given, they are used instead of ``colors``. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + An image can also be used, this is basically a 2D colormap. + + **kwargs + passed to :class:`.Graphic` + + + """ + + def add_surface( + self, + data: np.ndarray, + mode: Literal["basic", "phong", "slice"] = "phong", + colors: str | np.ndarray | Sequence = "w", + mapcoords: Any = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, + clim: tuple[float, float] | None = None, + **kwargs + ) -> SurfaceGraphic: + """ + + Create a Surface mesh Graphic + + Parameters + ---------- + data: array-like + A height-map (an image where the values indicate height, i.e. z values). + Can also be a [m, n, 3] to explicitly specify the x and y values in addition to the z values. + [m, n, 3] is a dstack of (x, y, z) values that form a grid on the xy plane. + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). + If not given, they will be the depth (z-coordinate) of the surface. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + + clim: tuple[float, float] + The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim + to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. + + **kwargs + passed to :class:`.Graphic` + + + """ + + def add_polygon( + self, + data: np.ndarray, + mode: Literal["basic", "phong"] = "basic", + colors: str | np.ndarray | Sequence = "w", + mapcoords: Any = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, + clim: tuple[float, float] | None = None, + **kwargs + ) -> PolygonGraphic: + """ + + Create a polygon mesh graphic. + + The data are always in the 'xy' plane. Set a rotation to display the polygon in another plane or in 3D space. + + Parameters + ---------- + data: array-like + The polygon vertices, must be of shape: [n_vertices, 2] + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). + If not given, they will be the depth (z-coordinate) of the surface. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + + clim: tuple[float, float] + The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim + to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. + + **kwargs + passed to :class:`.Graphic` + + """ + + def add_vectors( + self, + positions: np.ndarray | Sequence[float], + directions: np.ndarray | Sequence[float], + color: str | Sequence[float] | np.ndarray = "w", + size: float = None, + vector_shape_options: dict = None, + **kwargs + ) -> VectorsGraphic: + """ + + Create graphic that draw vectors. Similar to matplotlib quiver. + + Parameters + ---------- + positions: np.ndarray | Sequence[float] + positions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + directions: np.ndarray | Sequence[float] + directions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + spacing: float + average distance between pairs of nearest-neighbor vectors, used for scaling + + color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w" + color of the vectors + + size: float or None + Size of a vector of magnitude 1 in world space for display purpose. + Estimated from density if not provided. + + vector_shape_options: dict + dict with the following fields that directly describes the shape of the vector arrows. + Overrides ``size`` argument. + + * cone_radius + * cone_height + * stalk_radius + * stalk_height + + **kwargs + passed to :class:`.Graphic` + + + """ + + def add_text( + self, + text: str, + font_size: float | int = 14, + face_color: str | np.ndarray | list[float] | tuple[float] = "w", + outline_color: str | np.ndarray | list[float] | tuple[float] = "w", + outline_thickness: float = 0.0, + screen_space: bool = True, + offset: tuple[float] = (0, 0, 0), + anchor: str = "middle-center", + **kwargs + ) -> TextGraphic: + """ + + Create a text Graphic + + Parameters + ---------- + text: str + text to display + + font_size: float | int, default 10 + font size + + face_color: str, array, list, tuple, default "w" + str or RGBA array to set the color of the text + + outline_color: str, array, list, tuple, default "w" + str or RGBA array to set the outline color of the text + + outline_thickness: float, default 0 + relative outline thickness, value between 0.0 - 0.5 + + screen_space: bool = True + if True, text size is in screen space, if False the text size is in data space + + offset: (float, float, float), default (0, 0, 0) + places the text at this location + + anchor: str, default "middle-center" + position of the origin of the text + a string representing the vertical and horizontal anchors, separated by a dash + + * Vertical values: "top", "middle", "baseline", "bottom" + * Horizontal values: "left", "center", "right" + + **kwargs + passed to :class:`.Graphic` + + + """ diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index ae7102524..eedc3afd7 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -14,13 +14,15 @@ import pygfx from ._figure import Figure -from ._rect import RectManager from ._utils import IMGUI_TOOLBAR_HEIGHT from ..ui import ImguiWindow, ImguiPopup, SubplotToolbar, StandardRightClickMenu, EDGES from ..ui._base import _wrap_update_call +from ..utils import global_config +@global_config.register class ImguiFigure(Figure): + @global_config.declare("size") def __init__( self, shape: tuple[int, int] = (1, 1), diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 0c07fbb4b..420fbd09d 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -15,7 +15,7 @@ from ._graphic_methods_mixin import GraphicMethodsMixin from ..legends import Legend from ..tools import Tooltip - +from ..utils import global_config try: get_ipython() @@ -47,16 +47,21 @@ def _get_visible_bounding_box(obj: pygfx.Scene | pygfx.Group | pygfx.WorldObject return np.array([bboxes[:, 0, :].min(axis=0), bboxes[:, 1, :].max(axis=0)]) +@global_config.register class PlotArea(GraphicMethodsMixin): + config = global_config.descriptor + + @global_config.declare("background_color") def __init__( self, - parent: Union["PlotArea", "Figure"], + parent, camera: pygfx.PerspectiveCamera, controller: pygfx.Controller, scene: pygfx.Scene, canvas: BaseRenderCanvas, renderer: pygfx.WgpuRenderer, name: str = None, + background_color: tuple[str | pygfx.Color, ...] = ["black"], ): """ Base class for plot creation and management. ``PlotArea`` is not intended to be instantiated by users @@ -89,6 +94,9 @@ def __init__( name: str, optional name this plot area + background_color: tuple[str | pygfx.Color, ...], default ["black"] + background color, upto 4 colors, one for each corner + """ self._parent = parent @@ -131,10 +139,7 @@ def __init__( self.children = list() self._background_material = pygfx.BackgroundMaterial( - (0.0, 0.0, 0.0, 1.0), - (0.0, 0.0, 0.0, 1.0), - (0.0, 0.0, 0.0, 1.0), - (0.0, 0.0, 0.0, 1.0), + *background_color, alpha_mode="blend", ) self._background = pygfx.Background(None, self._background_material) @@ -813,6 +818,7 @@ def _auto_center_scene( # probably because camera.show_object uses bounding sphere camera.zoom = zoom + @global_config.declare("maintain_aspect", "zoom") def auto_scale( self, *, # since this is often used as an event handler, don't want to coerce maintain_aspect = True @@ -828,7 +834,7 @@ def auto_scale( Maintain the camera aspect ratio for all dimensions. If ``None``, the aspect is left unchanged. if ``False`` the camera is scaled to the bounding box of the current scene. - zoom: float + zoom: float, default 0.75 zoom value for the camera after auto-scaling """ diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 89329a3db..78b8d4541 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Literal, Union import numpy as np @@ -10,20 +12,26 @@ from ._plot_area import PlotArea from ._frame import Frame from ..axes import Axes +from ..utils import global_config +@global_config.register class Subplot(PlotArea): + @global_config.declare("toolbar", "background_color", "frame_kwargs") def __init__( self, - parent: Union["Figure"], + parent, camera: Literal["2d", "3d"] | pygfx.PerspectiveCamera, controller: pygfx.Controller | str, canvas: BaseRenderCanvas | pygfx.Texture, rect: np.ndarray = None, extent: np.ndarray = None, resizeable: bool = True, + toolbar: bool = True, renderer: pygfx.WgpuRenderer = None, name: str = None, + background_color: str | tuple[float, ...] | pygfx.Color = ["black"], + frame_kwargs: dict | None = None, ): """ Subplot class. @@ -33,7 +41,7 @@ def __init__( Parameters ---------- - parent: 'Figure' | None + parent: 'Figure' parent Figure instance camera: str or pygfx.PerspectiveCamera, default '2d' @@ -54,6 +62,43 @@ def __init__( name: str, optional name of the subplot, will appear as ``TextGraphic`` above the subplot + background_color: tuple[str | pygfx.Color, ...], default ["black"] + background color, upto 4 colors, one for each corner + + frame_kwargs: dict | None, default None + options for the Subplot Frame. May contain any of the keys ``"spacing"``, + ``"title_kwargs"``, and ``"plane_color"``. Each value is itself a dict that is + merged with the defaults, so only the entries you want to change need to be passed. + + **"spacing"**: dict, spacing of the frame elements in pixels + + - ``"x0"``: int, default 1, offset of the frame from the left edge + - ``"sides"``: int, default 2, padding at the left and right sides + - ``"title_flanks"``: int, default 8, space above and below the title text + - ``"resize_handle_space"``: int, default 13, space reserved for the resize handle + - ``"bottom"``: int, default 8, padding along the bottom edge + + **"title_kwargs"**: dict, options for the title ``TextGraphic`` + + - ``"font_size"``: float, default 16 + - ``"face_color"``: str | tuple[float, ...] | pygfx.Color, default "w" + + **"plane_color"**: dict, colors of the frame plane for each interaction state, + used to construct a ``SelectorColorStates``. Each value is a + str | tuple[float, ...] | pygfx.Color. + + - ``"idle"``: color when the frame is not being interacted with + - ``"highlight"``: color when the frame is hovered + - ``"action"``: color while the frame is being moved or resized + + Example:: + + frame_kwargs = { + "spacing": {"bottom": 12}, + "title_kwargs": {"font_size": 20}, + "plane_color": {"idle": "w", "highlight": "gray"}, + } + """ camera = create_camera(camera) @@ -62,7 +107,7 @@ def __init__( self._docks = dict() - toolbar_visible = "Imgui" in parent.__class__.__name__ + toolbar_visible = "Imgui" in parent.__class__.__name__ and toolbar super().__init__( parent=parent, @@ -72,6 +117,7 @@ def __init__( canvas=canvas, renderer=renderer, name=name, + background_color=background_color, ) for pos in ["left", "top", "right", "bottom"]: @@ -88,6 +134,9 @@ def __init__( self._axes = Axes(self) self.scene.add(self.axes.world_object) + if frame_kwargs is None: + frame_kwargs = {} + self._frame = Frame( viewport=self.viewport, rect=rect, @@ -98,6 +147,7 @@ def __init__( imgui_windows=self._imgui_windows, toolbar_visible=toolbar_visible, canvas_rect=parent.get_pygfx_render_area(), + **frame_kwargs, ) @property @@ -168,6 +218,10 @@ def frame(self) -> Frame: """Frame that the subplot lives in""" return self._frame + @property + def frame_spacing(self) -> dict: + return self._frame.spacing + @property def imgui_windows(self) -> dict: """ @@ -414,6 +468,7 @@ def remove_imgui_right_click(self): return popup +@global_config.register class Dock(PlotArea): def __init__( self, @@ -429,6 +484,7 @@ def __init__( scene=pygfx.Scene(), canvas=parent.canvas, renderer=parent.renderer, + background_color=parent.background_color, ) @property diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index f454c7930..96d9afca2 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -1,16 +1,7 @@ -from dataclasses import dataclass - # this MUST be imported as early as possible in fpl.__init__ before any other wgpu stuff from .gui import loop from .enums import * from .functions import * +from ._config import global_config from .gpu import enumerate_adapters, select_adapter, print_wgpu_report from .protocols import ARRAY_LIKE_ATTRS, ArrayProtocol, FutureProtocol, CudaArrayProtocol - - -@dataclass -class _Config: - party_parrot: bool - - -config = _Config(party_parrot=False) diff --git a/fastplotlib/utils/_config.py b/fastplotlib/utils/_config.py new file mode 100644 index 000000000..482e535c6 --- /dev/null +++ b/fastplotlib/utils/_config.py @@ -0,0 +1,399 @@ +from __future__ import annotations +from collections.abc import Callable +from dataclasses import make_dataclass, field, fields, dataclass, asdict +from functools import wraps, partial +import inspect +from typing import get_type_hints, Any + + +def get_method_name(method: Callable) -> str: + # we can't use __init__ as a dataclass field for the config + if method.__name__ == "__init__": + return "init" + + return method.__name__ + + +def inv_get_method_name(name: str) -> str: + # inverse of get_method_name + if name == "init": + return "__init__" + + return name + + +class ConfigDescriptor: + """Descriptor pattern so classes can access their configuration for users to set/get config options""" + # Reason this exists, we can't do: + # class A: + # config = global_config._registry[A] + # + # Because A doesn't exist yet when the python interpreter is creating the config variable! By using the + # descriptor you don't need A to exist yet, it only needs to exist when the user calls A.config + # + # The interpreter parses everything in the class and creates all declared objects in the class (methods as well) + # and the class is created only after everything in the class has been created! + # It's like filling a cup of water but the cup exists only after all the water has been poured into it. + + def __init__(self, classes): + self.__classes = classes + + def __get__(self, instance, cls: type = None): + if instance is not None: + raise AttributeError("set config options on the class, not an instance") + + if cls not in self.__classes: + raise AttributeError("Class is not registered") + + return self.__classes[cls] + + def __set__(self, obj, value): + raise AttributeError("Cannot set") + + +def identify(val): + return val + + +def merge(current, new): + # nested kwargs, e.g. frame_kwargs["title_kwargs"], merge instead of replacing + if not (isinstance(current, dict) and isinstance(new, dict)): + return new + + merged = dict(current) + for key, value in new.items(): + merged[key] = merge(current.get(key), value) + + return merged + + +def _config_setattr(self, name, value): + if name not in self.__slots__: + cls_qual, method = self._fpl_owner + raise AttributeError( + f"'{method}' config for {cls_qual} has no option '{name}'\n" + f"Valid options: {sorted(self.__slots__)}" + ) from None + object.__setattr__(self, name, value) + + +def _config_getattr(self, name): + cls_qual, method = self._fpl_owner + raise AttributeError( + f"'{method}' config for {cls_qual} has no option '{name}'\n" + f"Valid options: {sorted(self.__slots__)}" + ) + + +def _config_to_dict(self) -> dict: + """ + get the current method config as a dict + do this instead of `asdict()` from dataclasses because that creates a copy + """ + return {f.name: getattr(self, f.name) for f in fields(self)} + + +@dataclass +class Pending: + """ + A method that is 'pending', waiting for the class to be registered. + + These are created for decorated methods with `@GlobalConfig.declare()`. + They are converted to the config dataclass using the `to_config()` method + once the python interpreter reaches the `@GlobalConfig.register` for the + created class. + + """ + + method: Callable # the actual method obj + configurable: tuple[str, ...] + + @property + def name(self) -> str: + """method's name as a string, resolves __init__ -> init""" + return get_method_name(self.method) + + @property + def module(self) -> str: + """the pending module""" + return self.method.__module__ + + @property + def cls(self) -> str: + """name of class module belongs to""" + return self.method.__qualname__.rpartition(".")[0] + + @property + def cls_qual(self) -> tuple[str, str]: + """fully qualifying name of class module belongs to""" + + # use a tuple to disambiguate ("a.b_module", "Class") vs ("a", "b_class.Class") + # edge case but easy to cover + return (self.module, self.cls) + + def is_sibling(self, other: Pending) -> bool: + """check if other Pending object belongs to the same class as this one""" + return self.cls_qual == other.cls_qual + + def belongs_to(self, cls: type) -> bool: + """check if this module belongs to this fully created class object, used for @GlobalConfig.register""" + return self.cls_qual == (cls.__module__, cls.__qualname__) + + def to_config(self) -> object: + """create the config dataclass for this method""" + if "to_dict" in self.configurable: + raise ValueError( + "`to_dict` is not a valid configurable argument name " + "since this is reserved for the configuration system." + ) + + type_hints = get_type_hints(self.method) + + params = inspect.signature(self.method).parameters + + invalid_args = set(self.configurable) - set(params.keys()) + + if invalid_args: + raise LookupError( + f"{self.method.__qualname__}: `@global_config.declare` lists {invalid_args} as configurable " + f"but they are not valid arguments for this method. Valid arguments are: {params.keys()}" + ) + + missing_defaults = [ + arg + for arg in self.configurable + if params[arg].default is inspect.Parameter.empty + ] + if missing_defaults: + raise ValueError( + f"{self.method.__qualname__}: `@global_config.declare` lists {missing_defaults} as configurable " + f"arguments but they do not have a default value set in the function signature. A default value " + f"is required to initialize the default configuration" + ) + + signature = list() + for arg in self.configurable: + # if the type isn't declared in the function signature fill with Any + type_annot = type_hints.get(arg, Any) + + # get the default value + val = params[arg].default + + # for each parameter: (arg, type, default value) + if val.__class__.__hash__ is None: + # need to handle unhashable differently, i.e. mutable, objects like arrays and lists differently + f = field(default_factory=partial(identify, val)) + else: + f = field(default=val) + signature.append((arg, type_annot, f)) + + mc = make_dataclass( + self.name, + fields=signature, + slots=True, # fields are fixed, user can't do method.something_random = value + eq=False, # == operator makes no sense since values can be any object, arrays, buffers, etc. + namespace={ + "__setattr__": _config_setattr, + "__getattr__": _config_getattr, + "_fpl_owner": (self.cls, self.method.__name__), + "to_dict": _config_to_dict, + }, + ) + + return mc() + + +class GlobalConfig: + """Global config system""" + + def __init__(self): + # list of Pending + self._pending = list() + + # dict maps: {cls -> cls_dataclass_config} + self._registry: dict[type, object] = {} + # descriptor so classes can actually access their config options + self._descriptor = ConfigDescriptor(self._registry) + + @property + def descriptor(self) -> ConfigDescriptor: + return self._descriptor + + def register(self, cls): + """Register a class to the GlobalConfig""" + if self._pending and not self._pending[-1].belongs_to(cls): + raise TypeError( + f"{self._pending[-1].cls_qual} is not registered with the global config" + ) + + for parent in cls.__mro__: + # can't use getattr since that will call ConfigDescriptor.__get__ + # getting it from __dict__ provides the actual descriptor object + if isinstance(parent.__dict__.get("config"), ConfigDescriptor): + break + + else: + raise AttributeError( + f"{cls} is registered with @global_config.register but doesn't have a " + f"config descriptor class attribute." + ) + + method_configs = {p.name: p.to_config() for p in self._pending} + + # derive any un-set methods from closest parent class that has it + # this is mainly for the ImguiFigure class + # we want ImguiFigure.config.init to just use Figure.config.init + parents = cls.__mro__[1:-1] # [1:-1] skips the class itself and bare object + for parent in parents: + if parent in self._registry: + # get the names of all configurable methods on this parent + for f in fields(self._registry[parent]): + # if the parent has a configurable method that this subclass doesn't have defaults for + if f.name not in method_configs and hasattr( + cls, inv_get_method_name(f.name) + ): + # use the same method dataclass configuration object for this subclass + method_configs[f.name] = getattr(self._registry[parent], f.name) + + if not method_configs: + raise LookupError( + f"{cls} has no registered defaults nor any parent class with registered defaults to derive from" + ) + + self._register(cls, method_configs) + + self._pending.clear() + + return cls + + def _register(self, cls, method_dcs: dict[str, object]): + # actually adds the class along with all the method configurable dataclasses to the registry + dc = make_dataclass( + cls.__name__, + fields=[ + (m, type(mdc), field(default=mdc)) for m, mdc in method_dcs.items() + ], + slots=True, # fields are fixed, each class set a fixed set of methods + frozen=True, # can't change method config instances + eq=False, # == operator makes no sense here, every config class is unique anyways + ) + + self._registry[cls] = dc() + + def declare(self, *configurable): + """ + Declare configurable arguments for a method. + """ + if not configurable: + raise IndexError( + "No configurable arguments declared, this cannot be left empty. " + "Either declare configurable arguments or don't decorate this method." + ) + + def append_to_config(method): + new_pending = Pending(method, configurable) + if self._pending and not new_pending.is_sibling(self._pending[-1]): + raise TypeError( + f"{self._pending[-1].cls_qual} is not registered with the global config" + ) + + self._pending.append(new_pending) + + # keep these to use them in the injector + method_name = new_pending.name + # create signature object just once when the method is decorated instead of every time the method is called + sig = inspect.signature(method) + + # NOTE: variables within here are available in the injector because they exist in its __closure__ + # any variables from the outer function that are used in the inner function are always in the __closure__ + # source: https://stackoverflow.com/questions/14413946/what-exactly-is-contained-within-a-obj-closure + # official docs: https://docs.python.org/3/reference/datamodel.html#function.__closure__ + + @wraps(method) + def injector(instance, *args, **kwargs): + # get the method config dataclass + method_config = getattr(type(instance).config, method_name) + + # create a binding + # binding.argumetns is a dictionary mapping ONLY user-provided arguments with their values + # no default arguments and their values are in a binding, this is the key to what + # makes it possible to fill them with the config values! + try: + binding = sig.bind(instance, *args, **kwargs) + except TypeError as e: + # if *args and **kwargs don't match the signature raises a TypeError + # useful if the user passed wrong things, we need to catch and tell them what method it was + # since binding has no idea of the full namespace when we're handling it here + raise TypeError(f"{method.__qualname__}: {e}") from None + + config_dict = method_config.to_dict() + # merge binding into the config dict + # any values that the user explicitly provided will be in binding.arguments + # therefore an explicit user provided value will override the config value + binding.arguments = {**config_dict, **binding.arguments} + + # apply any missing default vals from the method signature + # this isn't actually necessary but is just a robust failsafe + # I think it should account for any weirdness with methods that have positional-only arguments + binding.apply_defaults() + + # finally call method with updated binding from config + return method(*binding.args, **binding.kwargs) + + return injector + + return append_to_config + + def update(self, method_config, **options): + """ + Set config options for a method, merging into what is already configured. + + A dict value is merged key by key, recursing into nested dicts, so keys set by an + earlier call are kept unless this call names them. Any other value replaces what is + there. This is what makes the options that are themselves kwargs composable, e.g. + with ``Subplot.config.init.frame_kwargs`` already + ``{"title_kwargs": {"face_color": "black"}}``:: + + global_config.update( + Subplot.config.init, frame_kwargs={"title_kwargs": {"font_size": 10}} + ) + # -> {"title_kwargs": {"face_color": "black", "font_size": 10}} + """ + for option, value in options.items(): + setattr(method_config, option, merge(getattr(method_config, option), value)) + + def __getitem__(self, cls: type): + if cls not in self._registry: + raise KeyError(f"{cls} not registered in global config") + + return self._registry[cls] + + def to_dict(self) -> dict[type, dict[str, dict]]: + """ + the config of every registered class, as {class: {method: {option: value}}} + + The option values are the configured objects themselves, not copies, so a mutable value + is shared with the config. Deepcopy the result for a snapshot of the current config. + """ + return { + cls: { + method.name: getattr(class_config, method.name).to_dict() + for method in fields(class_config) + } + for cls, class_config in self._registry.items() + } + + def print_config(self): + """print the config of every registered class, yaml-like""" + for cls, class_config in self._registry.items(): + print(f"{cls.__name__}:") + + for method in fields(class_config): + method_config = getattr(class_config, method.name) + print(f" {method.name}:") + + for arg in fields(method_config): + print(f" {arg.name}: {getattr(method_config, arg.name)!r}") + + +global_config = GlobalConfig() diff --git a/fastplotlib/utils/_style.py b/fastplotlib/utils/_style.py new file mode 100644 index 000000000..873f25d66 --- /dev/null +++ b/fastplotlib/utils/_style.py @@ -0,0 +1,115 @@ +from copy import deepcopy + +from ._config import global_config +from .. import graphics, layouts, axes + + +class style: + """Sets of config defaults applied together""" + + # the config as it exists on fastplotlib import i.e. the defaults from the method signatures + __default_config = deepcopy(global_config.to_dict()) + + @staticmethod + def light(): + """light color mode, white background, black axes, dark graphic colors, light subplot frame""" + axes.Axes.config.init.color = "k" + + layouts.Subplot.config.init.background_color = "w" + + graphics.LineGraphic.config.init.colors = "blue" + graphics.ScatterGraphic.config.init.colors = "blue" + graphics.VectorsGraphic.config.init.color = "k" + + # update function is useful when config options are dicts or nested dicts + global_config.update( + layouts.Subplot.config.init, + frame_kwargs=dict( + plane_color={ + "idle": (0.9, 0.9, 0.9), + "highlight": (0.8, 0.8, 0.9), + "action": (0.75, 0.75, 1.0), + }, + title_kwargs=dict(face_color="black"), + ), + ) + + @staticmethod + def dark(): + """dark color mode, black background, white axes, light graphic colors, dark subplot frame""" + axes.Axes.config.init.color = "w" + + layouts.Subplot.config.init.background_color = "k" + + graphics.LineGraphic.config.init.colors = "w" + graphics.ScatterGraphic.config.init.colors = "w" + graphics.VectorsGraphic.config.init.color = "w" + + # update function is useful when config options are dicts or nested dicts + global_config.update( + layouts.Subplot.config.init, + frame_kwargs=dict( + plane_color=None, + title_kwargs=dict(face_color="w"), + ), + ) + + @staticmethod + def spaced(): + """subplot toolbar is shown, well spaced subplot frame""" + layouts.Subplot.config.init.toolbar = True + + global_config.update( + layouts.Subplot.config.init, + frame_kwargs=dict( + spacing=dict( + x0=1, sides=2, title_flanks=8, resize_handle_space=13, bottom=8 + ), + title_kwargs=dict(font_size=16), + ), + ) + + @staticmethod + def default(): + """default configuration""" + for cls, method_configs in style.__default_config.items(): + for method, options in method_configs.items(): + # deepcopy since some config values can be mutable, e.g. dicts + global_config.update(getattr(cls.config, method), **deepcopy(options)) + + @staticmethod + def compact(): + """subplot toolbar is not shown, thin subplot frame""" + layouts.Subplot.config.init.toolbar = False + + global_config.update( + layouts.Subplot.config.init, + frame_kwargs=dict( + spacing=dict( + x0=1, sides=2, title_flanks=6, resize_handle_space=6, bottom=6 + ), + title_kwargs=dict(font_size=10), + ), + ) + + @staticmethod + def very_compact(): + """same as compact() with no visible subplot frame""" + layouts.Subplot.config.init.toolbar = False + + global_config.update( + layouts.Subplot.config.init, + frame_kwargs=dict( + spacing=dict( + x0=0, sides=0, title_flanks=0, resize_handle_space=0, bottom=0 + ), + title_kwargs=dict(font_size=0), + ), + ) + + @staticmethod + def flynn(): + """preset that optical physiology like""" + style.very_compact() + + layouts.Subplot.config.auto_scale.zoom = 0.99 diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphics_stub.py similarity index 52% rename from scripts/generate_add_graphic_methods.py rename to scripts/generate_add_graphics_stub.py index 0d692a312..4d0fac0fb 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphics_stub.py @@ -1,71 +1,44 @@ import ast import inspect import pathlib -import re import textwrap import black -root = pathlib.Path(__file__).parent.parent.resolve() -filename = root.joinpath("fastplotlib", "layouts", "_graphic_methods_mixin.py") - -# if there is an existing mixin class, replace it with an empty class -# so that fastplotlib will import -# hacky but it works -with open(filename, "w") as f: - f.write(f"class GraphicMethodsMixin:\n" f" pass") - -from fastplotlib import graphics - +from fastplotlib.layouts._graphic_methods_mixin import GraphicMethod, GraphicMethodsMixin -modules = list() +root = pathlib.Path(__file__).parent.parent.resolve() +filename = root.joinpath("fastplotlib", "layouts", "_graphic_methods_mixin.pyi") -for name, obj in inspect.getmembers(graphics): - if inspect.isclass(obj): - if obj.__name__ == "Graphic": - continue # skip the base class - modules.append(obj) +# {method name: graphic class}, the mixin defines which methods exist and what they are called +graphic_methods = { + name: attr.graphic_cls + for name, attr in vars(GraphicMethodsMixin).items() + if isinstance(attr, GraphicMethod) +} -def generate_add_graphics_methods(): +def generate_stub(): # clear file and regenerate from scratch f = open(filename, "w", encoding="utf-8") - f.write("# This is an auto-generated file and should not be modified directly\n\n") + f.write( + "# This is an auto-generated file and should not be modified directly\n" + "# regenerate with: python scripts/generate_add_graphics_stub.py\n\n" + ) # star-import each module that defines a graphic, so every reference used in the # graphics' __init__ annotations (aliases, np, pygfx, typing, enums) is in scope - for module in sorted({cls.__module__ for cls in modules}): + referenced = set(graphic_methods.values()) + referenced |= { + cls._child_type for cls in referenced if getattr(cls, "_child_type", None) + } + for module in sorted({cls.__module__ for cls in referenced}): f.write(f"from {module} import *\n") - f.write("from fastplotlib.graphics import Graphic\n\n") - - f.write("\nclass GraphicMethodsMixin:\n") - - f.write( - " def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic:\n" - ) - f.write(" if 'center' in kwargs.keys():\n") - f.write(" center = kwargs.pop('center')\n") - f.write(" else:\n") - f.write(" center = False\n\n") - f.write(" # ignore arguments left at their default of None, i.e. not passed by the caller\n") - f.write(" kwargs = {k: v for k, v in kwargs.items() if v is not None}\n\n") - f.write(" if 'name' in kwargs.keys():\n") - f.write(" self._check_graphic_name_exists(kwargs['name'])\n\n") - f.write(" graphic = graphic_class(*args, **kwargs)\n") - f.write(" self.add_graphic(graphic, center=center)\n\n") - f.write(" return graphic\n\n") - - # from https://stackoverflow.com/a/1176023 - camel_to_snake = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") - - for m in modules: - cls = m - cls_name = cls.__name__.replace("Graphic", "") - - method_name = camel_to_snake.sub("_", cls_name).lower() + f.write("\n\nclass GraphicMethodsMixin:\n") + for method_name, cls in graphic_methods.items(): child = getattr(cls, "_child_type", None) if child is not None: # a graphic collection: take the arguments and docstring from the child graphic's @@ -100,27 +73,15 @@ def generate_add_graphics_methods(): signature = ast.unparse(args) docstring = child.__init__.__doc__ - - # pass `data` positionally and everything else by keyword, since the collection takes - # its features as **kwargs - passed = ["data"] - passed += [f"{a.arg}={a.arg}" for a in args.args if a.arg not in ("self", "data")] - passed += [f"{a.arg}={a.arg}" for a in args.kwonlyargs] - if args.kwarg is not None: - passed.append(f"**{args.kwarg.arg}") - call = ", ".join(passed) else: init = ast.parse(textwrap.dedent(inspect.getsource(cls.__init__))).body[0] signature = ast.unparse(init.args) docstring = cls.__init__.__doc__ - class_args = inspect.getfullargspec(cls)[0][1:] - call = "".join(a + ", " for a in class_args) + "**kwargs" - f.write(f" def add_{method_name}({signature}) -> {cls.__name__}:\n") + f.write(f" def {method_name}({signature}) -> {cls.__name__}:\n") f.write(' """\n') f.write(f" {docstring}\n") - f.write(' """\n') - f.write(f" return self._create_graphic({cls.__name__}, {call})\n\n") + f.write(' """\n\n') f.close() @@ -129,7 +90,7 @@ def blacken(): with open(filename, "r", encoding="utf-8") as f: text = f.read() - mode = black.FileMode(line_length=88) + mode = black.FileMode(line_length=88, is_pyi=True) text = black.format_str(text, mode=mode) with open(filename, "w", encoding="utf-8") as f: @@ -137,5 +98,5 @@ def blacken(): if __name__ == "__main__": - generate_add_graphics_methods() + generate_stub() blacken() diff --git a/tests/test_collections.py b/tests/test_collections.py index 3c2989f7a..3e152749e 100644 --- a/tests/test_collections.py +++ b/tests/test_collections.py @@ -697,7 +697,7 @@ def test_scatter_edge_colors_and_width(): ], ) def test_scatter_point_rotations(value, expected_type): - kwargs = {} if value is None else {"point_rotations": value} + kwargs = {"point_rotations": value} collection = ScatterCollection(lines_data(), **kwargs) for graphic in collection.graphics: assert isinstance(graphic._point_rotations, expected_type) From d6e73f3ad049b26ba4bd9979b063486019cdcb53 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 15 Sep 2026 21:42:38 -0400 Subject: [PATCH 152/163] remove randon shit --- fastplotlib/widgets/nd_widget/_index.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py index dd1a4b828..7d22ff8f9 100644 --- a/fastplotlib/widgets/nd_widget/_index.py +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -571,20 +571,3 @@ def __repr__(self): def __str__(self): return str(self._indices) - - -# TODO: Not sure if we'll actually do this here, just a placeholder for now -class SelectionVector: - @property - def selection(self): - pass - - @property - def graphics(self): - pass - - def add_graphic(self): - pass - - def remove_graphic(self): - pass From 7e019b2fb14e6fb5d888634ee5d639e91fbfd3a2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 15 Sep 2026 21:59:21 -0400 Subject: [PATCH 153/163] ndw stuff is all top level importable, move types to utils --- fastplotlib/__init__.py | 3 +- fastplotlib/graphics/_jagged_array.py | 2 +- fastplotlib/graphics/_positions_base.py | 4 +-- fastplotlib/graphics/features/_image.py | 3 +- fastplotlib/graphics/features/_positions.py | 2 +- fastplotlib/graphics/features/types.py | 22 ------------ fastplotlib/graphics/image.py | 2 +- fastplotlib/graphics/inf_line.py | 2 +- fastplotlib/graphics/line.py | 2 +- fastplotlib/graphics/scatter.py | 2 +- .../layouts/_graphic_methods_mixin.pyi | 34 +++++++++---------- fastplotlib/utils/__init__.py | 1 + fastplotlib/utils/types.py | 26 ++++++++++++++ fastplotlib/widgets/__init__.py | 11 +----- fastplotlib/widgets/nd_widget/__init__.py | 27 +++++++++++++-- .../nd_widget/_nd_positions/__init__.py | 4 +-- fastplotlib/widgets/nd_widget/_video.py | 4 +-- 17 files changed, 86 insertions(+), 65 deletions(-) delete mode 100644 fastplotlib/graphics/features/types.py diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index b517cdf87..18958fb4d 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -5,6 +5,7 @@ from .utils import ( global_config, enums, + types, enumerate_adapters, select_adapter, print_wgpu_report, @@ -26,7 +27,7 @@ else: from .layouts import Figure -from .widgets import NDWidget, ImageWidget +from .widgets import * from .utils._style import style diff --git a/fastplotlib/graphics/_jagged_array.py b/fastplotlib/graphics/_jagged_array.py index bc081314f..11b03c629 100644 --- a/fastplotlib/graphics/_jagged_array.py +++ b/fastplotlib/graphics/_jagged_array.py @@ -7,7 +7,7 @@ from .features import BufferManager, TextureArray, TextureArrayVolume from .features._base import GraphicFeature, GraphicFeatureEvent from .features.utils import is_single_color -from .features.types import ColorLike, MultiColorLike, ColormapLike +from ..utils.types import ColorLike, MultiColorLike, ColormapLike from ._base import Graphic diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 74d7588ce..f82ca3296 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -1,5 +1,5 @@ from collections.abc import Iterable -from typing import Any, Literal +from typing import Any import numpy as np import cmap as cmap_lib @@ -16,7 +16,7 @@ SizeSpace, ) from .features.utils import is_single_color -from .features.types import ColorLike, MultiColorLike, ColormapLike +from ..utils.types import ColorLike, MultiColorLike, ColormapLike class PositionsGraphic(Graphic): diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 8518e8818..11b8ff9ad 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -14,7 +14,8 @@ from .utils import get_element_format_from_numpy_array from ...utils import ColorspacesRGB, ColorspacesYUV, ColorRange -from .types import TupleYUV, ColormapLike +from ...utils.types import ColormapLike, TupleYUV + class TextureArray(GraphicFeature): """ diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 615955341..ce7c0dc84 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -13,7 +13,7 @@ block_reentrance, ) from .utils import parse_colors, is_single_color -from .types import ColorLike, MultiColorLike +from ...utils.types import ColorLike, MultiColorLike class VertexColors(BufferManager): diff --git a/fastplotlib/graphics/features/types.py b/fastplotlib/graphics/features/types.py deleted file mode 100644 index dcfacec97..000000000 --- a/fastplotlib/graphics/features/types.py +++ /dev/null @@ -1,22 +0,0 @@ -import numpy as np -from numpy._typing import NDArray - -import pygfx -from collections.abc import Iterable - -RGB = tuple[float, float, float] | tuple[int, int, int] | list[int] | list[float] -RGBA = tuple[float, float, float, float] | tuple[int, int, int, int] | list[int] | list[float] | pygfx.Color - -ArrayRGBA = np.ndarray[tuple[int, int, int] | tuple[int, int, int, int], np.dtype[np.number]] - -ColorLike = RGB | RGBA | ArrayRGBA | pygfx.Color | str - -# [n, 3 | 4] RGBA array -MultiColorArray = np.ndarray[tuple[int, int], np.dtype[np.number]] - -MultiColorLike = tuple[ColorLike] | list[ColorLike] | MultiColorArray - -# our own ColormapLike type since if we use the cmap lib's ColormapLike it expands into a huge complex union -ColormapLike = str | Iterable[ColorLike] | MultiColorLike - -TupleYUV = tuple[NDArray[np.uint8], NDArray[np.uint8], NDArray[np.uint8]] diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index ba4b80973..563ae1c84 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -31,7 +31,7 @@ ImageInterpolation, ImageCmapInterpolation, ) -from .features.types import TupleYUV +from ..utils.types import TupleYUV def _format_value(value: float): diff --git a/fastplotlib/graphics/inf_line.py b/fastplotlib/graphics/inf_line.py index ded505be7..84343740a 100644 --- a/fastplotlib/graphics/inf_line.py +++ b/fastplotlib/graphics/inf_line.py @@ -10,7 +10,7 @@ InfLineColors, UniformColor, ) -from .features.types import ColorLike, MultiColorLike, ColormapLike +from ..utils.types import ColorLike, MultiColorLike, ColormapLike from ..utils import global_config diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index c55f55a43..a1d311297 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -18,7 +18,7 @@ ) from ..utils import quick_min_max, global_config from ._positions_base import PositionsGraphic -from .features.types import ColorLike, MultiColorLike, ColormapLike +from ..utils.types import ColorLike, MultiColorLike, ColormapLike @global_config.register diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 120947504..423c75a77 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -16,7 +16,7 @@ VertexRotations, TextureArray, ) -from .features.types import ColorLike, MultiColorLike, ColormapLike +from ..utils.types import ColorLike, MultiColorLike, ColormapLike from .features.utils import is_single_color from ..utils import global_config diff --git a/fastplotlib/layouts/_graphic_methods_mixin.pyi b/fastplotlib/layouts/_graphic_methods_mixin.pyi index 9e6cd20da..2cebe0d62 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.pyi +++ b/fastplotlib/layouts/_graphic_methods_mixin.pyi @@ -23,7 +23,7 @@ class GraphicMethodsMixin: size_space: Literal["screen", "world", "model"] = "screen", dash_pattern: str | tuple | list = (), thin: bool = False, - **kwargs + **kwargs, ) -> LineGraphic: """ @@ -86,7 +86,7 @@ class GraphicMethodsMixin: end_is_infinite: bool = True, dash_pattern: str | tuple | list = (), size_space: Literal["screen", "world", "model"] = "screen", - **kwargs + **kwargs, ) -> InfLineGraphic: """ @@ -163,7 +163,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs + **kwargs, ) -> LineCollection: """ @@ -236,7 +236,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs + **kwargs, ) -> LineStack: """ @@ -302,7 +302,7 @@ class GraphicMethodsMixin: point_rotations: float | np.ndarray | None = 0.0, sizes: float | np.ndarray | Sequence[float] = 5, size_space: str = "screen", - **kwargs + **kwargs, ) -> ScatterGraphic: """ @@ -421,7 +421,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs + **kwargs, ) -> ScatterCollection: """ @@ -543,7 +543,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs + **kwargs, ) -> ScatterStack: """ @@ -648,7 +648,7 @@ class GraphicMethodsMixin: cmap_interpolation: Literal["nearest", "linear"] = "linear", colorspace: ColorspacesRGB = "srgb", cpu_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageGraphic: """ @@ -731,7 +731,7 @@ class GraphicMethodsMixin: interpolation: Literal["nearest", "linear"] = "nearest", colorspace: ColorspacesYUV = "yuv420p", colorrange: ColorRange = "limited", - **kwargs + **kwargs, ) -> ImageYUVGraphic: """ @@ -820,7 +820,7 @@ class GraphicMethodsMixin: substep_size: float = 0.1, emissive: str | tuple | np.ndarray = (0, 0, 0), shininess: int = 30, - **kwargs + **kwargs, ) -> ImageVolumeGraphic: """ @@ -906,7 +906,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs + **kwargs, ) -> ImageCollection: """ @@ -1002,7 +1002,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs + **kwargs, ) -> ImageGrid: """ @@ -1086,7 +1086,7 @@ class GraphicMethodsMixin: mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] = None, - **kwargs + **kwargs, ) -> MeshGraphic: """ @@ -1139,7 +1139,7 @@ class GraphicMethodsMixin: mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> SurfaceGraphic: """ @@ -1188,7 +1188,7 @@ class GraphicMethodsMixin: mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] | None = None, - **kwargs + **kwargs, ) -> PolygonGraphic: """ @@ -1235,7 +1235,7 @@ class GraphicMethodsMixin: color: str | Sequence[float] | np.ndarray = "w", size: float = None, vector_shape_options: dict = None, - **kwargs + **kwargs, ) -> VectorsGraphic: """ @@ -1284,7 +1284,7 @@ class GraphicMethodsMixin: screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs + **kwargs, ) -> TextGraphic: """ diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index 96d9afca2..80863d54d 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -1,6 +1,7 @@ # this MUST be imported as early as possible in fpl.__init__ before any other wgpu stuff from .gui import loop from .enums import * +from . import types from .functions import * from ._config import global_config from .gpu import enumerate_adapters, select_adapter, print_wgpu_report diff --git a/fastplotlib/utils/types.py b/fastplotlib/utils/types.py index e99fce2fc..363be124b 100644 --- a/fastplotlib/utils/types.py +++ b/fastplotlib/utils/types.py @@ -1,4 +1,30 @@ from collections import namedtuple +from typing import Iterable +import numpy as np +from numpy._typing import NDArray + +import pygfx SelectorColorStates = namedtuple("state", ["idle", "highlight", "action"]) +RGB = tuple[float, float, float] | tuple[int, int, int] | list[int] | list[float] +RGBA = ( + tuple[float, float, float, float] + | tuple[int, int, int, int] + | list[int] + | list[float] + | pygfx.Color +) + +# [n, 3 | 4] RGBA array +ArrayRGBA = np.ndarray[ + tuple[int, int, int] | tuple[int, int, int, int], np.dtype[np.number] +] +ColorLike = RGB | RGBA | ArrayRGBA | pygfx.Color | str +MultiColorArray = np.ndarray[tuple[int, int], np.dtype[np.number]] +MultiColorLike = tuple[ColorLike] | list[ColorLike] | MultiColorArray + +# our own ColormapLike type since if we use the cmap lib's ColormapLike it expands into a huge complex union +ColormapLike = str | Iterable[ColorLike] | MultiColorLike + +TupleYUV = tuple[NDArray[np.uint8], NDArray[np.uint8], NDArray[np.uint8]] diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index fcb95cdfd..530eaeaa8 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -1,13 +1,4 @@ -from .nd_widget import ( - NDWidget, - NDSlicer, - NDGraphic, - NDPositionsSlicer, - NDPositions, - NDTimeseries, - NDImageSlicer, - NDImage, -) +from .nd_widget import * from .image_widget import ImageWidget __all__ = ["NDWidget", "ImageWidget"] diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py index adb9c5d6b..4b707f05b 100644 --- a/fastplotlib/widgets/nd_widget/__init__.py +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -1,14 +1,33 @@ from ...layouts import IMGUI - if IMGUI: + from ._index import RangeContinuous, AutoRangeContinuous, ReferenceIndices from ._base import NDSlicer, NDGraphic - from ._nd_positions import NDPositions, NDPositionsSlicer, NDTimeseries, ndp_extras + from ._nd_positions import NDPositions, NDPositionsSlicer, NDTimeseries, nds_extras from ._nd_image import NDImageSlicer, NDImage from ._video import VideoSlicer from ._nd_vectors import NDVectorsSlicer, NDVectors from ._ndwidget import NDWidget + __all__ = [ + "RangeContinuous", + "AutoRangeContinuous", + "ReferenceIndices", + "NDSlicer", + "NDGraphic", + "NDPositions", + "NDPositionsSlicer", + "NDTimeseries", + "nds_extras", + "NDImageSlicer", + "NDImage", + "VideoSlicer", + "NDVectorsSlicer", + "NDVectors", + "NDWidget", + ] + + else: class NDWidget: @@ -17,3 +36,7 @@ def __init__(self, *args, **kwargs): "NDWidget requires `imgui-bundle` to be installed.\n" "pip install imgui-bundle" ) + + __all__ = [ + "NDWidget", + ] diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py index bdc029b50..7fb169d4c 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py @@ -6,7 +6,7 @@ class Extras: pass -ndp_extras = Extras() +nds_extras = Extras() for optional in ["pandas"]: @@ -18,7 +18,7 @@ class Extras: module = importlib.import_module(f"._{optional}", "fastplotlib.widgets.nd_widget._nd_positions") cls = getattr(module, f"{optional.capitalize()}Slicer") setattr( - ndp_extras, + nds_extras, f"{optional.capitalize()}", cls ) diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py index a9ca58fd3..ac513a9d3 100644 --- a/fastplotlib/widgets/nd_widget/_video.py +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -1,8 +1,8 @@ -from typing import Callable, Any, Literal +from typing import Any import numpy as np -from ...graphics.image import TupleYUV +from ...utils.types import TupleYUV from ._nd_image import NDImageSlicer from ._async import run_in_thread_pool From 12cf6627fc5366f0b82ac3e91251188b25669bde Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 15 Sep 2026 22:01:07 -0400 Subject: [PATCH 154/163] fix black --- .../layouts/_graphic_methods_mixin.pyi | 34 +++++++++---------- scripts/generate_add_graphics_stub.py | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.pyi b/fastplotlib/layouts/_graphic_methods_mixin.pyi index 2cebe0d62..9e6cd20da 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.pyi +++ b/fastplotlib/layouts/_graphic_methods_mixin.pyi @@ -23,7 +23,7 @@ class GraphicMethodsMixin: size_space: Literal["screen", "world", "model"] = "screen", dash_pattern: str | tuple | list = (), thin: bool = False, - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -86,7 +86,7 @@ class GraphicMethodsMixin: end_is_infinite: bool = True, dash_pattern: str | tuple | list = (), size_space: Literal["screen", "world", "model"] = "screen", - **kwargs, + **kwargs ) -> InfLineGraphic: """ @@ -163,7 +163,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -236,7 +236,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -302,7 +302,7 @@ class GraphicMethodsMixin: point_rotations: float | np.ndarray | None = 0.0, sizes: float | np.ndarray | Sequence[float] = 5, size_space: str = "screen", - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -421,7 +421,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs, + **kwargs ) -> ScatterCollection: """ @@ -543,7 +543,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs, + **kwargs ) -> ScatterStack: """ @@ -648,7 +648,7 @@ class GraphicMethodsMixin: cmap_interpolation: Literal["nearest", "linear"] = "linear", colorspace: ColorspacesRGB = "srgb", cpu_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -731,7 +731,7 @@ class GraphicMethodsMixin: interpolation: Literal["nearest", "linear"] = "nearest", colorspace: ColorspacesYUV = "yuv420p", colorrange: ColorRange = "limited", - **kwargs, + **kwargs ) -> ImageYUVGraphic: """ @@ -820,7 +820,7 @@ class GraphicMethodsMixin: substep_size: float = 0.1, emissive: str | tuple | np.ndarray = (0, 0, 0), shininess: int = 30, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ @@ -906,7 +906,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs, + **kwargs ) -> ImageCollection: """ @@ -1002,7 +1002,7 @@ class GraphicMethodsMixin: alpha_modes=None, visibles=None, metadatas=None, - **kwargs, + **kwargs ) -> ImageGrid: """ @@ -1086,7 +1086,7 @@ class GraphicMethodsMixin: mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] = None, - **kwargs, + **kwargs ) -> MeshGraphic: """ @@ -1139,7 +1139,7 @@ class GraphicMethodsMixin: mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> SurfaceGraphic: """ @@ -1188,7 +1188,7 @@ class GraphicMethodsMixin: mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> PolygonGraphic: """ @@ -1235,7 +1235,7 @@ class GraphicMethodsMixin: color: str | Sequence[float] | np.ndarray = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, + **kwargs ) -> VectorsGraphic: """ @@ -1284,7 +1284,7 @@ class GraphicMethodsMixin: screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ diff --git a/scripts/generate_add_graphics_stub.py b/scripts/generate_add_graphics_stub.py index 4d0fac0fb..7bb4c4d51 100644 --- a/scripts/generate_add_graphics_stub.py +++ b/scripts/generate_add_graphics_stub.py @@ -90,7 +90,7 @@ def blacken(): with open(filename, "r", encoding="utf-8") as f: text = f.read() - mode = black.FileMode(line_length=88, is_pyi=True) + mode = black.FileMode(is_pyi=True) text = black.format_str(text, mode=mode) with open(filename, "w", encoding="utf-8") as f: From efd31044532e378ce5c222e4545750cfa1dfabe7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 15 Sep 2026 22:27:39 -0400 Subject: [PATCH 155/163] fix import --- fastplotlib/widgets/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index 530eaeaa8..fefcf101f 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -1,4 +1,5 @@ from .nd_widget import * +from .nd_widget import __all__ as _nd_widget_all from .image_widget import ImageWidget -__all__ = ["NDWidget", "ImageWidget"] +__all__ = [*_nd_widget_all, "ImageWidget"] From 190497e6c57c45b6ada35319d46a8a9ac43a9d6c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 16 Sep 2026 00:20:02 -0400 Subject: [PATCH 156/163] matplotlib migration docs --- docs/source/conf.py | 1 + docs/source/user_guide/guide.rst | 2 + docs/source/user_guide/index.rst | 1 + docs/source/user_guide/migrate_matplotlib.rst | 423 ++++++++++++++++++ 4 files changed, 427 insertions(+) create mode 100644 docs/source/user_guide/migrate_matplotlib.rst diff --git a/docs/source/conf.py b/docs/source/conf.py index 3e88ebc74..0833432a5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -140,6 +140,7 @@ "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable", None), "pygfx": ("https://docs.pygfx.org/stable", None), + "cmap": ("https://cmap-docs.readthedocs.io/en/stable", None), "wgpu": ("https://wgpu-py.readthedocs.io/en/latest", None), "rendercanvas": ("https://rendercanvas.readthedocs.io/stable/", None), # "fastplotlib": ("https://www.fastplotlib.org/", None), diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index 37002bb85..c7b28192d 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -884,6 +884,8 @@ Note that this only works if you are using jupyterlab or ipython locally, this c You can forward windows (ex: X11 forwarding) but this is much slower than the remote rendering described in the previous section. +.. _global_configuration: + Global configuration -------------------- diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index 92f0da98c..8c3aef308 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -9,3 +9,4 @@ User Guide event_tables gpu faq + migrate_matplotlib diff --git a/docs/source/user_guide/migrate_matplotlib.rst b/docs/source/user_guide/migrate_matplotlib.rst new file mode 100644 index 000000000..107e35faa --- /dev/null +++ b/docs/source/user_guide/migrate_matplotlib.rst @@ -0,0 +1,423 @@ +Migrating from matplotlib +========================= + +``fastplotlib`` and ``matplotlib`` are completely unrelated libraries with very different models. Fastplotlib uses the +GPU for realtime interactive visualization which requires very different implementation, object models, and user-APIs +to optimally leverage the underlying rendering engine. + +A ``Figure`` is a live object on a canvas that is rendered continuously, and every ``Graphic`` in it stays mutable for +as long as it exists. You change a visualization by setting the properties of the graphic that is already there. There +is nothing for you to redraw. + +This is fundamentally different from making an animation in ``matplotlib``, where each frame comes from a callback +that clears the axes and plots the data again, or that returns the artists which have to be redrawn. There is no such +step here. The canvas redraws on every rendering cycle and any changed property values are automatically updated in the +visualization. + +Use the intuition you have for creating and modifying ``numpy`` arrays, not the one you have for ``matplotlib``. Every +aspect of a ``Graphic`` is an array. Its ``data``, ``colors``, ``sizes``, ``thickness`` and the rest are indexed, +sliced, and assigned into the same way a numpy array is. Each assignment writes straight into the GPU buffer behind +that property. + +.. note:: + Bringing ``matplotlib`` habits with you will also cost you performance. Modify the properties of the graphics that + are already in the scene, do not create new ones. Creating a graphic allocates GPU buffers, and clearing a subplot + to plot into it again throws those buffers away and uploads all of the data from scratch. + +This page gives the ``fastplotlib`` equivalent of the ``matplotlib`` operations that come up most often. In the +examples below ``subplot`` is ``figure[0, 0]``. + +Figures and subplots +-------------------- + +A ``Figure`` is created with a ``shape``, and each ``Subplot`` in it is accessed by index:: + + import numpy as np + import fastplotlib as fpl + + figure = fpl.Figure(shape=(2, 3), size=(900, 600)) # plt.subplots(2, 3) + subplot = figure[0, 0] + + figure.show(maintain_aspect=False) # plt.show() + +``size`` is the size of the canvas in pixels. Pass ``names`` to access a subplot by name, ``figure["temperature"]``. + +Subplots created from a ``shape`` are laid out on a grid. To place them at arbitrary positions instead, pass one of: + +* ``rects``, an ``(x, y, width, height)`` for each subplot +* ``extents``, an ``(xmin, xmax, ymin, ymax)`` for each subplot + +Both are given either as fractions of the canvas or in absolute pixels. + +There is no ``pyplot`` equivalent: no global state, no current figure, no current subplot, and no ``gca()``. You always +call a method on the object you want to affect, so a graphic is added with ``figure[0, 0].add_line(data)``. + +How a ``Figure`` is displayed depends on where you are running it: + +* **jupyterlab**: ``figure.show()`` must be the last line of a notebook cell, or be wrapped in + ``IPython.display.display()``. +* **applications, scripts, and most other use-cases**: call ``fastplotlib.loop.run()`` after ``figure.show()`` to start + the event loop. It blocks, so it is not used in jupyterlab or IPython. +* **an interactive Qt window from jupyterlab or IPython**: run ``%gui qt`` before importing ``fastplotlib``. + +``maintain_aspect=False`` lets the x, y, and z scales change independently. Use it when the data in each dimension are +of a different magnitude, such as a timeseries, and for most large heatmaps. ``True`` is usually what you want for +images. + +Drawing data +------------ + +Each type of ``Graphic`` has its own ``add_()`` method on the ``Subplot``:: + + subplot.add_line(ys) # ax.plot(ys) + subplot.add_line(np.column_stack([xs, ys])) # ax.plot(xs, ys) + subplot.add_line(xy, thickness=3, dash_pattern="--") # ax.plot(..., lw=3, ls="--") + subplot.add_scatter(xy, sizes=8, markers="^") # ax.scatter(..., s=8, marker="^") + subplot.add_image(img, cmap="gray", vmin=0, vmax=255) # ax.imshow(...) + subplot.add_image(values) # ax.pcolormesh(values) + subplot.add_inf_line([2.5], axis="x") # ax.axvline(2.5) + subplot.add_inf_line([0.0], axis="y") # ax.axhline(0.0) + subplot.add_polygon(vertices) # ax.fill_between, ax.axvspan + subplot.add_vectors(positions, directions) # ax.quiver(...) + subplot.add_surface(heights) # ax.plot_surface(...) + subplot.add_mesh(positions, indices) # a Poly3DCollection + subplot.add_text("stim", offset=(2.5, 1.0, 0)) # ax.text(2.5, 1.0, "stim") + +Positional data is ``[n_points, 2]`` or ``[n_points, 3]``. ``add_line`` also accepts a 1D array of y-values, and +generates the x-values as an integer range. + +Everything on the GPU is 32-bit. A ``float64`` array is cast to ``float32`` with a warning on every upload. + +``vmin`` and ``vmax`` are in the image data's own units. They are estimated from a subsample of the data when they are +not provided. ``add_image`` draws row 0 at the top, like ``imshow``. + +``thickness`` and ``sizes`` are in screen pixels, so they do not change as you zoom. Pass ``size_space="world"`` to +express them in data units instead. + +The ``matplotlib`` style strings work here too: + +* ``dash_pattern``: ``"-"``, ``"--"``, ``"-."``, ``":"`` +* ``markers``: ``"o"``, ``"s"``, ``"D"``, ``"+"``, ``"x"``, ``"^"``, ``"<"``, ``">"``, ``"v"``, ``"*"`` + +Axis ranges, aspect and autoscale +--------------------------------- + +The range that a ``Subplot`` currently shows is read and set through ``x_range`` and ``y_range``:: + + subplot.x_range = (0, 100) # ax.set_xlim(0, 100) + subplot.y_range = (-1, 1) # ax.set_ylim(-1, 1) + xmin, xmax = subplot.x_range # ax.get_xlim() + + subplot.auto_scale(maintain_aspect=False, zoom=0.9) # ax.autoscale() + subplot.center_graphic(graphic) + subplot.center_scene() + +These two properties are in world space units, and they are only valid for an orthographic projection of the xy plane, +i.e. a camera with a field of view of 0. For a perspective projection, get and set the state of the camera directly:: + + state = subplot.camera.get_state() + subplot.camera.set_state({"position": (0, 0, 40), "fov": 50}) + +``subplot.camera.maintain_aspect = True`` keeps the x, y, and z scales locked to each other, which is what +``ax.set_aspect("equal")`` does. + +An axis is inverted by flipping the scale of the camera, ``subplot.camera.local.scale_y = -1``. Subplots that contain +an image have this set at ``figure.show()``, which is why image row 0 is drawn at the top. + +Title, axis labels, ticks and text +---------------------------------- + +The subplot title, the axis labels, and standalone text are set like this:: + + subplot.title = "penguin data" # ax.set_title("penguin data") + subplot.title.font_size = 20 + subplot.title.face_color = "r" + + subplot.axes.x.label.set_text("time (s)") # ax.set_xlabel("time (s)") + subplot.axes.y.label.set_text("amplitude") # ax.set_ylabel("amplitude") + + subplot.add_text("stim", offset=(2.5, 1.0, 0), font_size=14, anchor="middle-left") + +Text is drawn in screen space by default, so its size does not change as you zoom. Pass ``screen_space=False`` for text +that scales with the world instead. ``anchor`` is a vertical and a horizontal anchor separated by a dash, such as +``"top-left"`` or ``"middle-center"``. + +Ticks are set on the ruler for each axis:: + + subplot.axes.x.ticks = {0: "baseline", 30: "stim", 60: "recovery"} # values and labels + subplot.axes.x.ticks = [0, 30, 60] # values, labels from format + subplot.axes.x.ticks = None # automatic ticks + subplot.axes.y.tick_format = ".2f" # a format spec, "km" for SI suffixes, or a callable + subplot.axes.x.min_tick_distance = 100 # closest automatic ticks may get, in pixels + subplot.axes.x.tick_size = 12 + +A ``tick_format`` callable is given ``(value, min_value, max_value)`` and returns a string. + +The visibility and colors of the axes, the grids, and the subplot background are set directly on those objects:: + + subplot.axes.visible = False # ax.axis("off") + subplot.axes.grids.visible = False # ax.grid(False) + subplot.axes.color = "gray" + subplot.background_color = ["black"] # ax.set_facecolor("black") + +``background_color`` takes a sequence of 1, 2 or 4 colors: + +* one color for a flat background +* two colors for (bottom, top) +* four colors for (bottom left, bottom right, top left, top right) + +A bare string is unpacked one character at a time and raises, so pass ``["black"]`` and not ``"black"``. + +Colors and colormaps +-------------------- + +A color is given as one of: + +* a single letter, ``"r"`` +* a named color, ``"cyan"`` +* a hex string, ``"#ff0000"`` +* an RGBA sequence, ``(1.0, 0.0, 0.0, 1.0)`` + +The ``matplotlib`` ``"C0"`` and ``"tab:blue"`` forms are not colors here and will raise. + +Colormaps come from the `cmap `_ library, so every ``matplotlib`` +colormap name works, along with many more. ``graphic.cmap`` returns a :class:`cmap.Colormap`, not the string you +passed. + +There is no color cycle. Lines and scatters are white unless you pass ``colors``. To give several graphics different +colors, put them in one collection and set a colormap across it:: + + stack = subplot.add_line_stack(np.stack(traces), separation=(0, 2, 0), cmap="tab10") + +On a collection the colormap is spread across the graphics, so each line gets one color from it. Pass a list of +colormap names, ``cmap=["jet"] * n_lines``, to give every line its own colormap along its datapoints instead. + +In ``matplotlib`` a line whose color varies along its length is a ``LineCollection`` of segments. In ``fastplotlib`` +you can just set a colormap on a line, or set per-datapoint colors like any other array. ``cmap_transform`` holds the +per-datapoint values that the colors are looked up from:: + + line = subplot.add_line(np.column_stack([xs, ys]), cmap="viridis", cmap_transform=speed) + line.cmap_range = (0, 10) + +``cmap_range`` is the ``(min, max)`` of ``cmap_transform`` mapped onto the colormap. It defaults to the range of +``cmap_transform``. + +A qualitative colormap takes integer labels as its ``cmap_transform``, which is very useful for things like cluster +colors. You do not set a color per datapoint. You set a colormap and give an array that says which class each datapoint +belongs to:: + + # tab10 has 10 colors, so a cmap_range of (0, 10) makes label k always get color k + scatter = subplot.add_scatter(xy, cmap="tab10", cmap_transform=cluster_labels, cmap_range=(0, 10)) + +``cmap`` and ``colors`` are mutually exclusive. While a colormap is set ``graphic.colors`` is ``None``, and setting +``colors`` clears the colormap. + +A single color is stored as one value rather than as a buffer, so it cannot be sliced. Per-datapoint colors are an +``[n_points, 4]`` RGBA array, and a graphic that has them is indexed and sliced like any other array:: + + scatter = subplot.add_scatter(xy, colors="r") # one color for every point, not sliceable + + scatter.colors = np.random.rand(n_points, 4) # now one color per point + scatter.colors[mask] = "w" + +Passing the array to the constructor, ``add_scatter(xy, colors=np.random.rand(n_points, 4))``, initializes the graphic +with per-datapoint colors from the beginning. + +.. note:: + Use a ``cmap`` with a ``cmap_transform`` instead of an RGBA array whenever you can. An RGBA array stores four + float values for every datapoint, so it takes up far more GPU RAM than a colormap and a transform do. + +Many graphics at once +--------------------- + +In ``matplotlib`` you would call ``ax.plot()`` in a loop to draw many lines. In ``fastplotlib`` you use a collection, +such as a ``LineCollection``, ``LineStack``, ``ScatterCollection``, ``ImageCollection``, or ``ImageGrid``. These are +created with ``add_line_collection``, ``add_line_stack``, ``add_scatter_collection``, ``add_scatter_stack``, +``add_image_collection``, and ``add_image_grid``. + +Each property of the graphics it contains is exposed on the collection, and indexing that property indexes it across +the graphics:: + + stack = subplot.add_line_stack(np.stack(traces), separation=(0, 2, 0), cmap="tab10") + + stack.colors[:10] = "r" # the color of the first ten lines + stack.thickness[mask] = 5 + stack.data[3, :, 1] = ys # the y-values of the fourth line + stack.visibles = False + stack.graphics[3] # an individual graphic + +The graphics in a collection can have different numbers of datapoints, so these properties are jagged. Fully +numpy-style fancy slicing is supported. + +A property that the collection also has itself is exposed under a plural name. ``collection.offset`` is the offset of +the collection, and ``collection.offsets`` is the offset of each graphic in it. + +Changing a plot after it is drawn +--------------------------------- + +You change a plot by setting mutable properties on graphics:: + + line.data[:, 1] = new_ys # line.set_ydata(new_ys) + image.data[:] = frame # im.set_data(frame) + image.vmin, image.vmax = 0, 500 # im.set_clim(0, 500) + scatter.sizes[mask] = 20 + graphic.visible = False + subplot.delete_graphic(graphic) + +A slice write uploads only the range that it touched. Assigning a whole array of a different length allocates a new GPU +buffer and re-uploads all of the data, so do that only when the number of datapoints has actually changed. Do not clear +a subplot and add the graphics again to refresh a plot. + +An animation function is a user-defined function that gets called on every rendering cycle, and it receives the +subplot:: + + def update(subplot): + subplot["sine"].data[:, 1] = np.sin(xs + phase) + + figure[0, 0].add_animations(update) + +``figure.add_animations(fn)`` calls ``fn`` with the figure instead. Do not drive an animation from a ``while`` loop, a +``time.sleep()``, or a thread. + +Every graphic property emits an event when it changes, which you can use to drive other parts of a visualization:: + + @image.add_event_handler("vmin", "vmax") + def clim_changed(ev): + print(ev.type, ev.info["value"]) + +``graphic.supported_events`` lists every event type that a graphic supports. + +Pan, zoom and rotate +-------------------- + +A ``Figure`` is interactive and there is nothing to enable. There is no ``plt.ion()`` and no ``%matplotlib widget``, +and the same code is just as interactive in notebooks, Qt, glfw, and wx. + +Every subplot has a camera and a controller, and you pan, zoom, and rotate with the mouse. If you do not pass a +``controller_type``, the controller is chosen from the field of view of the camera: + +* **fov = 0**, an orthographic projection, gets a pan-zoom controller. Left drag pans, right drag zooms, and the wheel + zooms toward the pointer. +* **fov > 0**, a perspective projection, gets a fly controller, which works like a first-person video game. ``wasd`` + moves, space and shift move up and down, ``q`` and ``e`` roll, left drag looks around, and the wheel changes the + movement speed. + +You can ask for a different controller, either for the whole figure or for one subplot:: + + figure = fpl.Figure(controller_types="orbit") # "panzoom", "orbit", "trackball", "fly" + subplot.controller = "trackball" + subplot.controller.enabled = False # freeze the view + +An orbit or trackball controller rotates around a center point with left drag, pans with right drag, and zooms with +the wheel. + +``controller_types`` takes one value for the whole figure, or one value per subplot. Every subplot also has a toolbar +with autoscale, center, a controller toggle, and a maintain-aspect toggle, along with a right-click menu. + +There is no Axes3D +------------------ + +There is no difference between 2D and 3D plotting in ``fastplotlib``, and there is nothing that corresponds to +``Axes3D``. Everything is always in 3D. Every subplot is a 3D scene and every graphic takes ``[n_points, 3]`` data. + +The way a visualization looks depends entirely on the camera. A field of view of 0 is an orthographic projection, which +is what people commonly think of as a 2D plot. Any field of view above 0 is a perspective projection:: + + figure = fpl.Figure(cameras="3d", controller_types="orbit") # "3d" is a fov of 50 + subplot.camera.fov = 0 # orthographic projection + subplot.add_line(np.column_stack([xs, ys, zs])) + +A viewpoint is saved and restored through the state of the camera:: + + state = subplot.camera.get_state() + subplot.camera.set_state(state) + +``get_state()`` returns ``position``, ``rotation``, ``scale``, ``reference_up``, ``fov``, ``width``, ``height``, +``depth``, ``zoom``, ``maintain_aspect``, and ``depth_range``. ``set_state()`` accepts any subset of those, along with +``x``, ``y``, and ``z`` to set a single component of the position. + +Linked subplots +--------------- + +What ``sharex`` and ``sharey`` do in ``matplotlib`` is done here by sharing controllers between subplots. +``controller_ids`` links every subplot in the figure, or links them in groups:: + + figure = fpl.Figure(shape=(2, 2), controller_ids="sync") # every subplot linked + + figure = fpl.Figure( # linked in groups + shape=(1, 3), + names=["temperature", "pressure", "map"], + controller_ids=[("temperature", "pressure")], + ) + +The camera and the controller are mutable properties on a ``Subplot``, just like most other properties in +``fastplotlib``. Set the controller of one subplot as the controller of another and the two of them share it, so they +pan and zoom together:: + + figure["temperature"].controller = figure["pressure"].controller + +To link a single axis, add the camera of the other subplot to a controller and say which part of the camera state to +share. This is what you want for stacked timeseries, where x pans and zooms together while each subplot keeps its own +y scale:: + + figure[0, 0].controller.add_camera(figure[1, 0].camera, include_state={"x", "width"}) + figure[1, 0].controller.add_camera(figure[0, 0].camera, include_state={"x", "width"}) + +Colorbars +--------- + +A colorbar is an ``ImguiColorbar``, an imgui window that you add to an edge of a subplot. It has draggable vmin and +vmax handles, a gamma slider, and a right-click colormap picker, and it stays in sync with the image it controls:: + + from fastplotlib.ui import ImguiColorbar + + image = figure[0, 0].add_image(data, cmap="viridis") + figure[0, 0].add_imgui_window(ImguiColorbar(images=image), location="right", size=80) + +Pass ``histogram=np.histogram(data, bins=100)`` to draw a histogram beside the bar. One colorbar can control several +images at once, ``ImguiColorbar(images=[image1, image2])``. + +Defaults instead of rcParams +---------------------------- + +There is no ``rcParams`` and no ``style.use()``. Global defaults are set on the class that takes the argument, grouped +by the method they are passed to, so they tab-complete and a typo raises:: + + fpl.LineGraphic.config.init.colors = "magenta" + fpl.ImageGraphic.config.init.cmap = "gray" + fpl.Figure.config.init.size = (900, 700) + +A few preset styles are available in the ``fastplotlib.style`` namespace, and successive calls are merged:: + + fpl.style.light() + fpl.style.compact() + +See :ref:`global_configuration` for every configurable component, and for how a config value interacts with an +argument that you pass explicitly. + +There is no ``savefig`` +----------------------- + +Serializing a ``Figure`` is not supported yet. What you can do is explicitly export the canvas, exactly as it is on +screen, to a png:: + + figure.export("plot.png") # plt.savefig("plot.png") + array = figure.export_numpy(rgb=True) + +``export()`` goes through ``imageio``, so it writes any raster format that ``imageio`` supports. Vector formats such as +SVG are out of scope. ``fastplotlib`` is meant for live interactive visualization, not for creating publication figures +or videos. + +Things with no direct equivalent +-------------------------------- + +* **Bar charts and stem plots** are out of scope. +* **Log-scaled axes** are not implemented yet. For now, plot the transformed values and set the tick labels + explicitly, ``subplot.axes.x.ticks = {0: "1", 1: "10", 2: "100"}``. +* **Twin axes** are not implemented yet, they will come later as reference-spaces. For now, put the second signal in + its own subplot and link the x range of the two controllers. +* **Contours.** Compute them with a library that does contouring and draw the result with ``add_line_collection``, or + mark the regions on the image itself with an ``ImageHighlightSelector``. + +The `examples gallery `_ has runnable examples for every +graphic and for the things described on this page. From 5a15204807e976a0b0524d4db72978e0855511da Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 16 Sep 2026 00:22:55 -0400 Subject: [PATCH 157/163] wording --- docs/source/user_guide/migrate_matplotlib.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/user_guide/migrate_matplotlib.rst b/docs/source/user_guide/migrate_matplotlib.rst index 107e35faa..fc2809df5 100644 --- a/docs/source/user_guide/migrate_matplotlib.rst +++ b/docs/source/user_guide/migrate_matplotlib.rst @@ -2,11 +2,11 @@ Migrating from matplotlib ========================= ``fastplotlib`` and ``matplotlib`` are completely unrelated libraries with very different models. Fastplotlib uses the -GPU for realtime interactive visualization which requires very different implementation, object models, and user-APIs +GPU for realtime interactive visualization which requires a different implementation, object model, and user-API to optimally leverage the underlying rendering engine. A ``Figure`` is a live object on a canvas that is rendered continuously, and every ``Graphic`` in it stays mutable for -as long as it exists. You change a visualization by setting the properties of the graphic that is already there. There +as long as it exists. You change a visualization by setting the properties of the graphic in a subplot. There is nothing for you to redraw. This is fundamentally different from making an animation in ``matplotlib``, where each frame comes from a callback From 0c9abeb7584fe47fb99c1fe3fa7871f7deef46b5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 16 Sep 2026 00:40:09 -0400 Subject: [PATCH 158/163] uncommnet iw for docs conf.py --- docs/source/conf.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 0833432a5..d22c37219 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,6 +22,8 @@ sys.path.insert(0, str(ROOT_DIR)) sys.path.insert(0, str(Path(__file__).parent.joinpath("_ext"))) +# gallery_reset lives here; Sphinx 9 no longer adds the confdir to sys.path automatically +sys.path.insert(0, str(Path(__file__).parent)) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -59,7 +61,7 @@ "../../examples/image_collection", "../../examples/image_volume", "../../examples/heatmap", - # "../../examples/image_widget", + "../../examples/image_widget", "../../examples/global_config", "../../examples/gridplot", "../../examples/window_layouts", From cc7bda78d5ee0a0548a4d6d99a5b87cae069fbf7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 16 Sep 2026 00:50:50 -0400 Subject: [PATCH 159/163] update example --- examples/selection_tools/highlight_selector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/selection_tools/highlight_selector.py b/examples/selection_tools/highlight_selector.py index 01f6fb1c2..eb4177a03 100644 --- a/examples/selection_tools/highlight_selector.py +++ b/examples/selection_tools/highlight_selector.py @@ -39,14 +39,14 @@ heatmap_pos = heatmap_to_positions(heatmap, xvals) # (n_pixels, n_t, 2) # --- layout --- -ndw = fpl.NDWidget(ref_ranges={"t": (0, n_t, 1)}, shape=(1, 2), size=(1400, 560)) +ndw = fpl.NDWidget(ranges={"t": (0, n_t, 1)}, shape=(1, 2), size=(1400, 560)) nd_img = ndw[0, 0].add_nd_image(vol, ("t", "y", "x"), ("y", "x"), name="image") nd_hm = ndw[0, 1].add_nd_timeseries( heatmap_pos, dims=("pixel", "t", "xy"), - spatial_dims=("pixel", "t", "xy"), + display_dims=("pixel", "t", "xy"), graphic_type=ImageGraphic, x_range_mode="fixed", display_window=None, From a87d482c91b718dbfb88a552cde8fe8776e59ddd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 16 Sep 2026 00:53:13 -0400 Subject: [PATCH 160/163] update --- examples/ndwidget/timeseries_cmaps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ndwidget/timeseries_cmaps.py b/examples/ndwidget/timeseries_cmaps.py index a6e87024b..d428415d1 100644 --- a/examples/ndwidget/timeseries_cmaps.py +++ b/examples/ndwidget/timeseries_cmaps.py @@ -27,7 +27,7 @@ "angle": (0, xs[-1], 0.1), } -ndw = fpl.NDWidget(ref_ranges=ref, size=(700, 560)) +ndw = fpl.NDWidget(ranges=ref, size=(700, 560)) nd_lines = ndw[0, 0].add_nd_timeseries( n_data, From db5e3542cd0f07e7b66607c7164030e4f27d8f16 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 16 Sep 2026 00:54:17 -0400 Subject: [PATCH 161/163] update --- examples/ndwidget/timeseries_cmaps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ndwidget/timeseries_cmaps.py b/examples/ndwidget/timeseries_cmaps.py index d428415d1..88e462f50 100644 --- a/examples/ndwidget/timeseries_cmaps.py +++ b/examples/ndwidget/timeseries_cmaps.py @@ -33,7 +33,7 @@ n_data, ("n_lines", "angle", "d"), ("n_lines", "angle", "d"), - slider_dim_transforms={ + slider_maps={ "angle": xs, }, # some alternating colormaps per-line From c06ec38077d5b7b81249bdd64e148156577b57c3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 16 Sep 2026 01:41:51 -0400 Subject: [PATCH 162/163] temp skip nds_extras in docs --- docs/source/generate_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py index 37abb1acb..fc0e30fa8 100644 --- a/docs/source/generate_api.py +++ b/docs/source/generate_api.py @@ -407,7 +407,8 @@ def main(): ############################################################################## # ** Widget classes ** # - widget_classes = [getattr(widgets, w) for w in widgets.__all__] + # skip nds_extras for now + widget_classes = [getattr(widgets, w) for w in widgets.__all__ if hasattr(w, "__name__")] widget_class_names = [w.__name__ for w in widget_classes] From e8e9c9f61078125f317b0de9e6e06f684d17f27d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 16 Sep 2026 01:43:08 -0400 Subject: [PATCH 163/163] basics work --- examples/ndwidget/pynappe_neuro.py | 239 +++ .../nd_widget/_nd_positions/__init__.py | 2 +- .../nd_widget/_nd_positions/_pynapple.py | 1803 +++++++++++++++++ fastplotlib/widgets/nd_widget/_ndw_subplot.py | 193 +- 4 files changed, 2235 insertions(+), 2 deletions(-) create mode 100644 examples/ndwidget/pynappe_neuro.py create mode 100644 fastplotlib/widgets/nd_widget/_nd_positions/_pynapple.py diff --git a/examples/ndwidget/pynappe_neuro.py b/examples/ndwidget/pynappe_neuro.py new file mode 100644 index 000000000..a53d3d39c --- /dev/null +++ b/examples/ndwidget/pynappe_neuro.py @@ -0,0 +1,239 @@ +""" +Pynapple Multi-Modal Session +============================ + +Browse a pynapple session — a calcium movie, dF/F traces, spike times and scored behavior — on one +shared time axis in seconds. + +Each object keeps its own sampling rate and its own timestamps. ``add_pynapple_obj`` picks the +slicer from the type of the object and takes the timebase from the object itself, so there is no +``slider_maps`` to write and no chance of pairing an object with the wrong slicer. + +The floating window drives the properties that are backed by the metadata, so the same data can be +re-binned, re-ordered, re-colored and re-grouped without rebuilding the viewer. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import pynapple as nap +import fastplotlib as fpl +from imgui_bundle import imgui + +fpl.style.compact() +fpl.style.light() + +nap_slicers = fpl.nds_extras.Pynapple + +rng = np.random.default_rng(0) +duration = 120.0 # seconds + +# imaging at 10 Hz +frame_times = np.arange(0, duration, 1 / 10) +rows, cols = np.meshgrid(np.linspace(0, 1, 64), np.linspace(0, 1, 64)) +movie = nap.TsdTensor( + t=frame_times, + d=np.stack( + [np.sin(6 * rows + i / 8) * np.cos(6 * cols) for i in range(frame_times.size)] + ).astype(np.float32), +) + +# one dF/F trace per cell, each carrying the region it was recorded in and its depth in µm +cell_regions = ["V1", "M1", "V1", "M1", "V1", "M1", "V1", "M1"] +cell_depths = [310.0, 95.0, 260.0, 140.0, 405.0, 180.0, 220.0, 350.0] +dff = nap.TsdFrame( + t=frame_times, + d=np.stack( + [ + np.sin(frame_times * (0.4 + 0.15 * i)) + rng.normal(0, 0.1, frame_times.size) + for i in range(len(cell_regions)) + ], + axis=1, + ).astype(np.float32), + metadata={"region": cell_regions, "depth": cell_depths}, +) + +# sorted units, on their own irregular timebase rather than the imaging frames +unit_rates = [4, 7, 11, 5, 18, 9, 22, 6, 13, 8] +units = nap.TsGroup( + {i: nap.Ts(np.sort(rng.random(int(r * duration)) * duration)) for i, r in enumerate(unit_rates)}, + metadata={ + "cell_type": ["pE", "pI", "pE", "pE", "pI", "pE", "pI", "pE", "pE", "pI"], + "depth": [820.0, 240.0, 610.0, 155.0, 970.0, 430.0, 705.0, 60.0, 340.0, 520.0], + }, +) + +# hand-scored behavior, as epochs rather than a sampled signal +behavior = nap.IntervalSet( + start=[3.0, 22.0, 41.0, 58.0, 77.0, 96.0], + end=[19.0, 37.0, 55.0, 74.0, 92.0, 114.0], + metadata={ + "state": ["run", "rest", "groom", "run", "rest", "run"], + "block": ["early", "early", "early", "late", "late", "late"], + }, +) + +# the reference range is the *intersection* of what every modality covers. Past the end of the +# shortest one the sliders keep moving while that graphic is pinned to its last sample, which +# looks like real data. `step` is the playback increment, here the 10 Hz imaging period. +ranges = nap_slicers.ranges_from_time_support(movie, dff, units, behavior) + +extents = { + "movie": (0, 0.32, 0, 1), + "dff": (0.32, 1, 0, 0.28), + "rate": (0.32, 1, 0.28, 0.54), + "spikes": (0.32, 1, 0.54, 0.76), + "behavior": (0.32, 1, 0.76, 1), +} + +ndw = fpl.NDWidget(ranges=ranges, extents=extents, size=(1300, 900)) + +ndw["movie"].add_pynapple_obj( + movie, + ("time", "m", "n"), + ("m", "n"), + compute_histogram=False, + graphic_kwargs={"cmap": "gray"}, + name="movie", +) + +# ordered by depth and colored by region, and the colors follow the order +dff_ndg = ndw["dff"].add_pynapple_obj( + dff, + ("cell", "time", "xy"), + ("cell", "time", "xy"), + display_window=20.0, + sort_by="depth", + color_by="region", + name="dff", +) + +# a TsGroup defaults to firing rate in Hz, on a bin grid anchored at the start of the recording so +# the edges stay put as you scroll +rate_ndg = ndw["rate"].add_pynapple_obj( + units, + ("unit", "time", "xy"), + ("unit", "time", "xy"), + bin_size=0.05, + display_window=20.0, + sort_by="depth", + graphic_kwargs={"cmap": "gray_r"}, + name="rate", +) + +# the same units as individual spikes, placed on the probe by depth rather than by unit key +spikes_ndg = ndw["spikes"].add_pynapple_obj( + units, + ("l", "time", "xy"), + ("l", "time", "xy"), + slicer=nap_slicers.TsGroupSpikes, + y="depth", + display_window=20.0, + colors="cyan", + sizes=3, + name="spikes", +) + +# one row per behavioral state, valued by how much of each bin that state covers, so an epoch +# shorter than a bin fades in rather than disappearing +eth_ndg = ndw["behavior"].add_pynapple_obj( + behavior, + ("state", "time", "xy"), + ("state", "time", "xy"), + column="state", + display_window=20.0, + graphic_kwargs={"cmap": "magma", "vmin": 0, "vmax": 1}, + name="ethogram", +) + + +# a row index is not a state, so label the rows with the names. Read the categories on every call +# rather than capturing them, since the `column` control below changes them. +def state_label(value, lower, upper): + categories = eth_ndg.slicer.categories + return categories[min(max(round(value), 0), categories.size - 1)] + + +ndw.figure["behavior"].axes.y.tick_format = state_label + + +def metadata_combo(label, current, columns, allow_none=True): + """a combo over the metadata columns, returning ``(changed, column)``""" + options = [None, *columns] if allow_none else list(columns) + changed, index = imgui.combo( + label, options.index(current), ["—" if o is None else str(o) for o in options] + ) + + return changed, options[index] + + +@ndw.figure.add_imgui_window( + location="floating", + title="pynapple", + window_flags=imgui.WindowFlags_.always_auto_resize, +) +def controls(): + imgui.separator_text("dF/F traces") + + changed, column = metadata_combo("color by", dff_ndg.color_by, dff.metadata_columns) + if changed: + dff_ndg.color_by = column + + changed, column = metadata_combo("sort by##dff", dff_ndg.sort_by, dff.metadata_columns) + if changed: + dff_ndg.sort_by = column + + imgui.separator_text("firing rate") + + imgui.set_next_item_width(160) + changed, value = imgui.slider_float( + "bin size (s)", + rate_ndg.bin_size, + 0.001, + 2.0, + flags=imgui.SliderFlags_.logarithmic, + ) + if changed: + rate_ndg.bin_size = value + + # over a wide window the rendered bins are widened to a multiple of `bin_size` rather than + # decimated, so say which bins are actually on screen + rendered = rate_ndg.slicer.effective_bin_size(rate_ndg.display_window) + imgui.text(f"rendered: {rendered * 1e3:.1f} ms x {rate_ndg.slicer.n_bins} bins") + + changed, column = metadata_combo("sort by##rate", rate_ndg.sort_by, units.metadata_columns) + if changed: + rate_ndg.sort_by = column + + imgui.separator_text("spike raster") + + changed, column = metadata_combo("row from", spikes_ndg.y, units.metadata_columns) + if changed: + spikes_ndg.y = column + + imgui.separator_text("ethogram") + + changed, column = metadata_combo( + "rows from", eth_ndg.column, behavior.metadata_columns, allow_none=False + ) + if changed: + eth_ndg.column = column + + +for subplot_name in ("dff", "rate", "spikes", "behavior"): + subplot = ndw.figure[subplot_name] + subplot.controller.add_camera(subplot.camera, include_state={"x", "width"}) + subplot.camera.maintain_aspect = False + +ndw.figure["movie"].axes.visible = False + +ndw.show(maintain_aspect=False) +figure = ndw.figure + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py index 7fb169d4c..c17f2e59b 100644 --- a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py +++ b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py @@ -9,7 +9,7 @@ class Extras: nds_extras = Extras() -for optional in ["pandas"]: +for optional in ["pandas", "pynapple"]: try: importlib.import_module(optional) except ImportError: diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pynapple.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pynapple.py new file mode 100644 index 000000000..e7c22df67 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pynapple.py @@ -0,0 +1,1803 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any + +import cmap as cmap_lib +import numpy as np +import pygfx +import pynapple as nap + +from ....graphics import ImageGraphic, LineStack, ScatterCollection +from ....utils import ArrayProtocol, subsample_array +from .._async import run_in_thread_pool, run_sync +from .._base import NDSlicer, identity +from .._nd_image import NDImage, NDImageSlicer +from ._nd_positions import NDPositionsSlicer +from ._nd_timeseries import NDTimeseries + + +def metadata_categories(values: Sequence) -> np.ndarray: + """ + Unique values of a metadata column, **in order of first appearance** rather than sorted. + + Parameters + ---------- + values: Sequence + A column of a pynapple ``metadata`` table. + + Returns + ------- + np.ndarray + The unique values, ordered by where they first occur in ``values``. + + """ + values = np.asarray(values) + categories, first = np.unique(values, return_index=True) + + return categories[np.argsort(first)] + + +def metadata_codes(values: Sequence) -> tuple[np.ndarray, np.ndarray]: + """ + Encode a metadata column as integer codes. + + Numeric columns are returned unchanged. Anything else is encoded against + :func:`metadata_categories`, so the codes follow the order the categories occur in the object. + + Parameters + ---------- + values: Sequence + A column of a pynapple ``metadata`` table. + + Returns + ------- + (np.ndarray, np.ndarray) + ``(codes, categories)``. ``categories`` is empty for a numeric column. + + """ + values = np.asarray(values) + + if np.issubdtype(values.dtype, np.number): + return values, np.empty(0) + + categories = metadata_categories(values) + lookup = {category: code for code, category in enumerate(categories)} + + return np.array([lookup[v] for v in values]), categories + + +def sort_order(data: Any, column: str) -> np.ndarray: + """ + Permutation that orders the graphics of a pynapple object by one of its metadata columns. + + Used by the slicers for ``sort_by`` and by ``NDWSubplot.add_pynapple_obj`` to put per-graphic + colors in the same order, so the two cannot disagree. + + Parameters + ---------- + data: pynapple.TsGroup | pynapple.TsdFrame | pynapple.IntervalSet + Any pynapple object carrying metadata. + + column: str + Name of the metadata column to order by. A categorical column is ordered by its + :func:`metadata_categories`, i.e. by where each category first occurs. + + Returns + ------- + np.ndarray + Indices that order the graphics. + + """ + codes, _ = metadata_codes(data.metadata[column]) + + return np.argsort(codes, kind="stable") + + +def _is_color_column(values: np.ndarray) -> bool: + """whether every unique value of a metadata column names a color""" + if np.issubdtype(values.dtype, np.number): + return False + + try: + for value in np.unique(values): + pygfx.Color(value) + except (ValueError, TypeError): + return False + + return True + + +def _color_kwargs( + values: Sequence, cmap: str | None, vmin: float, vmax: float +) -> dict[str, Any]: + """map a metadata column onto ``cmap``/``cmap_transform``/``cmap_range``/``colors`` kwargs""" + values = np.asarray(values) + + if _is_color_column(values): + # the column already names the colors, no colormap involved + return {"colors": values} + + codes, categories = metadata_codes(values) + + if categories.size == 0: + # numeric, mapped linearly onto the colormap between the percentile bounds + return { + "cmap": cmap if cmap is not None else "viridis", + "cmap_transform": codes, + "cmap_range": ( + float(np.nanpercentile(codes, vmin, method="closest_observation")), + float(np.nanpercentile(codes, vmax, method="closest_observation")), + ), + } + + name = cmap if cmap is not None else "tab10" + colormap = cmap_lib.Colormap(name) + + if colormap.interpolation == "nearest" and categories.size > colormap.num_colors: + raise IndexError( + f"there are {categories.size} categories but the qualitative colormap " + f"'{colormap.name}' has only {colormap.num_colors} colors, pass a `cmap` with at " + f"least {categories.size} colors" + ) + + # no `cmap_range`: on a collection a qualitative transform indexes the colors directly, so + # category k is already color k, and a range raises + return {"cmap": name, "cmap_transform": codes} + + +def tsgroup_colors( + data: nap.TsGroup, + column: str, + cmap: str = None, + vmin: float = 0.0, + vmax: float = 100.0, +) -> dict[str, Any]: + """ + Graphic feature kwargs coloring the units of a ``TsGroup`` by one of its metadata columns. + + Parameters + ---------- + data: pynapple.TsGroup + The units to color. + + column: str + Name of the metadata column, ex: ``"cell_type"`` or ``"rate"``. + + cmap: str, optional + Colormap name. Defaults to ``"tab10"`` for a categorical column and ``"viridis"`` for a + numeric one. + + vmin, vmax: float, default 0.0 and 100.0 + Percentiles of a numeric column used as the ``cmap_range``. Ignored for a categorical + column, whose codes index the colormap directly. + + Returns + ------- + dict[str, Any] + Kwargs to pass to ``add_pynapple_obj`` or an ``add_nd_*`` method, in the order of the + object itself. Pass ``color_by`` to ``add_pynapple_obj`` instead to have them ordered to + match ``sort_by``. + + """ + return _color_kwargs(data.metadata[column], cmap, vmin, vmax) + + +def tsdframe_colors( + data: nap.TsdFrame, + column: str, + cmap: str = None, + vmin: float = 0.0, + vmax: float = 100.0, +) -> dict[str, Any]: + """ + Graphic feature kwargs coloring the columns of a ``TsdFrame`` by one of its metadata columns. + + Parameters + ---------- + data: pynapple.TsdFrame + The columns to color. + + column: str + Name of the metadata column, ex: ``"region"``. + + cmap: str, optional + Colormap name. Defaults to ``"tab10"`` for a categorical column and ``"viridis"`` for a + numeric one. + + vmin, vmax: float, default 0.0 and 100.0 + Percentiles of a numeric column used as the ``cmap_range``. + + Returns + ------- + dict[str, Any] + Kwargs to pass to ``add_pynapple_obj`` or an ``add_nd_*`` method. + + """ + return _color_kwargs(data.metadata[column], cmap, vmin, vmax) + + +def intervalset_colors( + data: nap.IntervalSet, column: str, cmap: str = None +) -> dict[str, Any]: + """ + Graphic feature kwargs coloring the rows of an :class:`IntervalSetSlicer` by their category. + + The rows of that slicer are the unique values of ``column``, one per category, so the colors + identify the categories rather than the individual epochs. + + .. important:: + Only applies to the line and scatter representations. A heatmap, which is the default, + colors by the coverage value instead. + + Parameters + ---------- + data: pynapple.IntervalSet + The epochs whose categories are colored. + + column: str + Name of the metadata column whose unique values are the rows. + + cmap: str, default ``"tab10"`` + Colormap name. + + Returns + ------- + dict[str, Any] + Kwargs to pass to ``add_pynapple_obj`` or an ``add_nd_*`` method. + + """ + categories = metadata_categories(data.metadata[column]) + + return _color_kwargs(np.arange(categories.size), cmap, 0.0, 100.0) + + +def ranges_from_time_support( + *data: Any, dim: str = "time", step: float = None +) -> dict[str, tuple[float, float, float]]: + """ + Reference range spanning the overlap of the ``time_support`` of every given object. + + The reference range must be the **intersection** of what each modality covers. Past the end of + the shortest one the slider keeps moving while that graphic's index is clamped to its last + sample, so it sits there showing a stale slice that looks like real data. + + Using ``time_support`` rather than ``t[0]`` and ``t[-1]`` also handles a recording with gaps, + whose support is several intervals that the first and last timestamp would span straight over. + + Parameters + ---------- + data: pynapple objects + Any objects carrying a ``time_support``. + + dim: str, default ``"time"`` + Name of the reference dim, i.e. the key of the returned mapping. + + step: float, optional + Increment used by the step buttons and playback, in seconds. Defaults to the **coarsest** + median sampling interval among the objects that have timestamps, since stepping finer than + the slowest modality only re-renders its same sample. Objects without timestamps, ex: a + ``TsGroup`` or an ``IntervalSet``, do not contribute, and it must be given explicitly if + none of them do. + + Returns + ------- + dict[str, tuple[float, float, float]] + ``{dim: (start, stop, step)}``, ready to pass as the ``ranges`` of an ``NDWidget``. + + """ + # an IntervalSet has no `time_support`, it already is one + supports = [ + obj if isinstance(obj, nap.IntervalSet) else obj.time_support for obj in data + ] + + support = supports[0] + for other in supports[1:]: + support = support.intersect(other) + + if len(support) == 0: + raise ValueError( + "the `time_support` of the given objects do not overlap, so there is no reference " + "range that covers all of them" + ) + + if step is None: + intervals = [ + float(np.median(np.diff(obj.t))) + for obj in data + if hasattr(obj, "t") and np.size(obj.t) > 1 + ] + + if not intervals: + raise ValueError( + "none of the given objects have timestamps to take a sampling interval from, " + "pass `step` explicitly" + ) + + step = max(intervals) + + return {dim: (float(support.start[0]), float(support.end[-1]), float(step))} + + +def covered_by(ep: nap.IntervalSet, times: np.ndarray) -> np.ndarray: + """ + Boolean mask of the timestamps that an epoch of ``ep`` covers. + + Parameters + ---------- + ep: pynapple.IntervalSet + The epochs to test against. + + times: np.ndarray + Timestamps in seconds. + + Returns + ------- + np.ndarray + Boolean mask, ``True`` where an epoch covers that timestamp. + + """ + # `IntervalSet.in_interval` would do this, but it takes a `Ts`, and building one on every + # index change allocates and warns about a zero-duration time_support whenever the window + # holds a single timestamp. The epochs are sorted and non-overlapping, so the first epoch + # ending at or after a timestamp is the only one that can contain it. `side="left"` keeps + # both ends inclusive, which is pynapple's convention. + index = np.searchsorted(ep.end, times, side="left") + + covered = index < len(ep) + covered[covered] = times[covered] >= ep.start[index[covered]] + + return covered + + +class _PynapplePositionsSlicer(NDPositionsSlicer): + """ + Shared timebase, ``ep`` and ``sort_by`` handling for the pynapple positional slicers. + + Not used directly. The ``p`` slider map always comes from the pynapple object itself, so a + reference value in seconds is never converted to an index by hand. + """ + + # cap on the number of samples read to estimate the per-graphic y max used for stack spacing + _max_y_samples = 1_000_000 + + #: the representation this slicer's output is normally drawn as, used by + #: ``NDWSubplot.add_pynapple_obj`` when no ``graphic_type`` is given + default_graphic_type = LineStack + + #: the ``NDGraphic`` that carries this slicer's mutable properties, assigned at the end of the + #: module since the graphics are defined after the slicers + nd_graphic_type: type = None + + def __init__( + self, + data: Any, + dims: Sequence[str], + display_dims: tuple[str, str, str], + ep: nap.IntervalSet = None, + sort_by: str = None, + **kwargs, + ): + # both are read while producing a slice, set them before the base fetches the first one + self._ep = None + self._sort_by = None + + super().__init__(data, dims, display_dims, **kwargs) + + self.ep = ep + self.sort_by = sort_by + + @property + def time_dim(self) -> str: + """name of the ``p`` dim, i.e. the time axis""" + return self.display_dims[1] + + def _time_map(self): + """reference seconds -> array index along ``p``""" + return self.data.t.searchsorted + + @property + def slider_maps(self) -> dict[str, Any]: + """ + Per-slider-dim mapping from reference-space values to local array indices. + + The map for the ``p`` dim is taken from the pynapple object's own timestamps and cannot be + given; every other dim behaves as in :class:`NDSlicer`. + """ + return self._index_mappings + + @slider_maps.setter + def slider_maps(self, maps: dict[str, Any] | None): + if maps is not None and self.time_dim in maps: + raise ValueError( + f"the map for '{self.time_dim}' comes from the timestamps of the " + f"{type(self.data).__name__} itself, remove it from `slider_maps`" + ) + + maps = dict(maps) if maps is not None else dict() + maps[self.time_dim] = self._time_map() + + NDSlicer.slider_maps.fset(self, maps) + + @property + def ep(self) -> nap.IntervalSet | None: + """ + Get or set the epochs to restrict to. ``None`` uses every epoch of the object's + ``time_support``. + """ + return self._ep + + @ep.setter + def ep(self, ep: nap.IntervalSet | None): + if ep is not None and not isinstance(ep, nap.IntervalSet): + raise TypeError( + f"`ep` must be a `pynapple.IntervalSet` or `None`, you passed a " + f"{type(ep).__name__}" + ) + + self._ep = ep + + @property + def sort_by(self) -> str | None: + """ + Get or set the name of the metadata column the graphics are ordered by. ``None`` keeps the + order of the object. + """ + return self._sort_by + + @sort_by.setter + def sort_by(self, column: str | None): + if column is not None: + if not hasattr(self.data, "metadata_columns"): + raise TypeError( + f"a {type(self.data).__name__} carries no metadata to sort by" + ) + + if column not in self.data.metadata_columns: + raise KeyError( + f"'{column}' is not a metadata column of this " + f"{type(self.data).__name__}, available columns are: " + f"{list(self.data.metadata_columns)}" + ) + + self._sort_by = column + + def _order(self) -> np.ndarray | None: + """permutation of the graphics axis for the current ``sort_by``""" + if self.sort_by is None: + return None + + return sort_order(self.data, self.sort_by) + + def _outside_ep(self, times: np.ndarray) -> np.ndarray | None: + """boolean mask of the timestamps that no epoch of ``ep`` covers""" + if self.ep is None: + return None + + return ~covered_by(self.ep, times) + + +class TsdFrameSlicer(_PynapplePositionsSlicer): + def __init__( + self, + data: nap.Tsd | nap.TsdFrame, + dims: tuple[str, str, str], + display_dims: tuple[str, str, str], + ep: nap.IntervalSet = None, + sort_by: str = None, + **kwargs, + ): + """ + ``NDPositionsSlicer`` subclass for a ``pynapple.Tsd`` or ``pynapple.TsdFrame``. + + Produces ``[n_columns, p, 2]`` slices, where the x coordinate is the object's own + timestamps and the y coordinate is its values. Only the display window is ever read, so a + lazily loaded object stays out-of-core. A ``Tsd`` is the single-column case. + + Parameters + ---------- + data: pynapple.Tsd | pynapple.TsdFrame + The object to display. Its timestamps are used as the ``p`` slider map. + + dims: tuple[str, str, str] + Names for the 3 dims. A ``TsdFrame`` has no further dims to name, so these are the same + 3 names as ``display_dims``. + + display_dims: tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``. + + ep: pynapple.IntervalSet, optional + Restrict to these epochs. Samples that no epoch covers are set to ``NaN``, which + renders as a break in a line and as nothing in a scatter. A heatmap has no ``NaN`` + handling, so there they are not distinguishable from the colormap minimum. + + sort_by: str, optional + Name of a metadata column to order the columns by. + + kwargs + passed to :class:`NDPositionsSlicer`, i.e. ``display_window``, + ``max_display_datapoints``, ``datapoints_window_func``, ``window_funcs``, + ``window_order`` and ``spatial_func``. + + See Also + -------- + NDPositionsSlicer : Base class with full parameter documentation. + + """ + super().__init__(data, dims, display_dims, ep=ep, sort_by=sort_by, **kwargs) + + @property + def data(self) -> nap.Tsd | nap.TsdFrame: + """get or set the managed object, the new object is interpreted to have the same dims""" + return self._data + + @data.setter + def data(self, data: nap.Tsd | nap.TsdFrame): + if not isinstance(data, (nap.Tsd, nap.TsdFrame)): + raise TypeError( + f"`data` must be a `pynapple.Tsd` or `pynapple.TsdFrame`, you passed a " + f"{type(data).__name__}" + ) + + self._data = data + + @property + def n_columns(self) -> int: + """number of columns, i.e. the number of graphics in the collection""" + return 1 if self.data.ndim == 1 else self.data.shape[1] + + @property + def shape(self) -> dict[str, int]: + """interpreted shape of the data, the number of columns, timestamps, and the value dim""" + n_graphics, p, d = self.display_dims + + return {n_graphics: self.n_columns, p: self.data.t.size, d: 2} + + @property + def ndim(self) -> int: + """number of dims, always 3""" + return 3 + + def _stack(self, times: np.ndarray, values: ArrayProtocol) -> np.ndarray: + """``[p]`` timestamps and ``[p, n_columns]`` values -> ``[n_columns, p, 2]``""" + values = np.asarray(values) + if values.ndim == 1: + values = values[:, None] + + order = self._order() + if order is not None: + values = values[:, order] + + out = np.empty((values.shape[1], times.size, 2), dtype=np.float32) + out[..., 0] = times + out[..., 1] = values.T + + return out + + def _read(self, dw_slice: slice) -> np.ndarray: + times = self.data.t[dw_slice] + out = self._stack(times, self.data.values[dw_slice]) + + outside = self._outside_ep(times) + if outside is not None: + out[:, outside, 1] = np.nan + + return self._finalize(out) + + async def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + """ + Get the data slice to display at the given indices. + + Reads only the display window from the object, stacks its timestamps and values into + ``[n_columns, p, 2]``, then applies the ``datapoints_window_func`` and ``spatial_func``. + + Parameters + ---------- + indices: dict[str, Any] + Reference-space value for the ``p`` dim, ex: ``{"time": 46.397}``. + + Returns + ------- + dict[str, np.ndarray] + ``"data"`` holds the data slice, the remaining keys are the windowed graphic features. + + """ + dw_slice = self._get_dw_slice(indices) + + data = await run_in_thread_pool(self._executor, self._read, dw_slice) + + return {"data": data, **self._get_other_features(data, dw_slice)} + + def _strided_read(self) -> np.ndarray: + # stride along time only so every column survives; `subsample_array` spreads its factor + # over all dims and stops honoring `max_size` once a dim is given to `ignore_dims` + n = self.data.t.size + step = max(1, n // max(1, self._max_y_samples // self.n_columns)) + + return self._stack(self.data.t[::step], self.data.values[::step]) + + async def _get_raw_data_slice(self, indices: dict[str, Any]) -> np.ndarray: + # only reached from `NDTimeseries._p_y_max`, which needs the per-graphic y max over the + # full `p` dim to space a LineStack/ScatterStack. Nothing records that max: NWB carries + # `conversion`/`offset`/`resolution`, HDF5 has no per-dataset extrema, and an h5py Dataset + # has no `.max`. So estimate it from a strided read, as vmin/vmax already are. + return await run_in_thread_pool(self._executor, self._strided_read) + + +class TsGroupRateSlicer(_PynapplePositionsSlicer): + def __init__( + self, + data: nap.TsGroup, + dims: tuple[str, str, str], + display_dims: tuple[str, str, str], + bin_size: float = 0.01, + ep: nap.IntervalSet = None, + sort_by: str = None, + **kwargs, + ): + """ + ``NDPositionsSlicer`` subclass that bins a ``pynapple.TsGroup`` into firing rates. + + Produces ``[n_units, p, 2]`` slices where the y coordinate is the firing rate in Hz, the + same quantity as ``TsGroup.rate``. + + The bins sit on a grid anchored at the start of the recording, not at the start of the + display window, so the edges stay put as you scroll rather than sliding with the view. + + Rate rather than raw counts so the values keep their meaning when :attr:`bin_size` changes, + and because ``max_display_datapoints`` may widen the rendered bins over a large window. + Once the bins are finer than the interspike interval the values collapse towards ``0`` and + ``1 / bin_size``, which is the regime for :class:`TsGroupSpikesSlicer` instead. + + Parameters + ---------- + data: pynapple.TsGroup + The spike trains to bin. + + dims: tuple[str, str, str] + Names for the 3 dims, the same 3 names as ``display_dims``. + + display_dims: tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``. + + bin_size: float, default 0.01 + Size of the bins the spikes are counted in, in seconds. Also settable afterwards as + :attr:`bin_size`. Over a window holding more than ``max_display_datapoints`` bins the + rendered bins are widened to a whole multiple of it, see :meth:`effective_bin_size`. + + ep: pynapple.IntervalSet, optional + Restrict to these epochs. Spikes outside them are not counted, so a bin that no epoch + covers is genuinely ``0`` Hz. + + sort_by: str, optional + Name of a metadata column to order the units by. + + kwargs + passed to :class:`NDPositionsSlicer`. + + Notes + ----- + The datapoints are synthesized rather than read from the object, so there is no full ``p`` + dim to index into and per-datapoint windowed graphic features do not apply. + + See Also + -------- + TsGroupSpikesSlicer : Individual spikes rather than binned rates. + + """ + self._bin_size = None + + super().__init__(data, dims, display_dims, ep=ep, sort_by=sort_by, **kwargs) + + self.bin_size = bin_size + + # a rate raster is a heatmap, one row per unit, color from the rate + default_graphic_type = ImageGraphic + + @property + def data(self) -> nap.TsGroup: + """get or set the managed object, the new object is interpreted to have the same dims""" + return self._data + + @data.setter + def data(self, data: nap.TsGroup): + if not isinstance(data, nap.TsGroup): + raise TypeError( + f"`data` must be a `pynapple.TsGroup`, you passed a {type(data).__name__}" + ) + + self._data = data + + def _time_map(self): + # the bins are synthesized from the display window, so there is no array to index into and + # reference seconds are used directly + return identity + + @property + def bin_size(self) -> float: + """ + Get or set the bin size the spikes are counted in, in seconds. Setting it re-renders the + current data slice. + """ + return self._bin_size + + @bin_size.setter + def bin_size(self, bin_size: float): + bin_size = float(bin_size) + + if bin_size <= 0: + raise ValueError( + f"`bin_size` must be > 0, a rate over zero duration is undefined, you passed: " + f"{bin_size}" + ) + + self._bin_size = bin_size + + def effective_bin_size(self, span: float) -> float: + """ + The bin size actually rendered over a window of ``span`` seconds. + + Equal to :attr:`bin_size` unless the window holds more bins than + ``max_display_datapoints``, in which case they are widened to a whole multiple of it. They + are never decimated: dropping every n-th bin would drop the spikes counted in it, and the + raster would under-report the firing rate without saying so. + + Parameters + ---------- + span: float + Width of the window in seconds. + + Returns + ------- + float + Bin size in seconds, always a whole multiple of :attr:`bin_size`. + + """ + if self.max_display_datapoints is None: + return self.bin_size + + n_bins = int(np.ceil(span / self.bin_size)) + widen = max(1, int(np.ceil(n_bins / self.max_display_datapoints))) + + return self.bin_size * widen + + @property + def n_bins(self) -> int: + """number of bins rendered per unit at the current display window, i.e. the ``p`` dim""" + if self.display_window is None: + span = float(self.data.time_support.tot_length()) + else: + span = float(self.display_window) + + return max(1, int(np.ceil(span / self.effective_bin_size(span)))) + + @property + def shape(self) -> dict[str, int]: + """interpreted shape of the data, the number of units, bins, and the value dim""" + n_graphics, p, d = self.display_dims + + return {n_graphics: len(self.data), p: self.n_bins, d: 2} + + @property + def ndim(self) -> int: + """number of dims, always 3""" + return 3 + + def _window(self, indices: dict[str, Any]) -> tuple[float, float]: + """the display window in seconds""" + if self.display_window is None: + support = self.data.time_support + return float(support.start[0]), float(support.end[-1]) + + half_window = self.display_window / 2 + centre = indices[self.time_dim] + + return centre - half_window, centre + half_window + + def _rates(self, start: float, stop: float, bin_size: float) -> np.ndarray: + # snap to a grid anchored at the start of the recording, otherwise `count` anchors the + # bins to the window and every edge slides as the window scrolls + anchor = float(self.data.time_support.start[0]) + start = anchor + np.floor((start - anchor) / bin_size) * bin_size + stop = anchor + np.ceil((stop - anchor) / bin_size) * bin_size + + window = nap.IntervalSet(start, stop) + + # restrict first so only the spikes of the selected epochs are counted, then bin over the + # whole window so the grid stays uniform and a heatmap is not interpolated across gaps + spikes = self.data + if self.ep is not None: + spikes = spikes.restrict(window.intersect(self.ep)) + + counts = spikes.count(bin_size, ep=window) + + # [n_units, n_bins] in Hz, the same quantity as TsGroup.rate + rates = np.asarray(counts.values, dtype=np.float32).T / bin_size + + order = self._order() + if order is not None: + rates = rates[order] + + out = np.empty((*rates.shape, 2), dtype=np.float32) + out[..., 0] = counts.t + out[..., 1] = rates + + return out + + def _bin(self, indices: dict[str, Any]) -> np.ndarray: + start, stop = self._window(indices) + + if stop <= start: + # a rate over zero duration is undefined, the NDGraphic hides itself on an empty slice + return np.empty((len(self.data), 0, 2), dtype=np.float32) + + return self._finalize( + self._rates(start, stop, self.effective_bin_size(stop - start)) + ) + + async def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + """ + Get the data slice to display at the given indices. + + Bins the spikes of the display window into ``max_display_datapoints`` bins and returns + their firing rates in Hz. + + Parameters + ---------- + indices: dict[str, Any] + Reference-space value for the ``p`` dim, ex: ``{"time": 46.397}``. + + Returns + ------- + dict[str, np.ndarray] + ``"data"`` holds the data slice. + + """ + return {"data": await run_in_thread_pool(self._executor, self._bin, indices)} + + def _support_rates(self) -> np.ndarray: + # for LineStack/ScatterStack spacing only. Binning the whole recording at `bin_size` can + # produce far more values than the recording has spikes, so the bins are widened until the + # total fits the sample budget. Wider bins under-estimate the peak rate, so the spacing is + # an estimate; set `steps` on the graphic directly if it matters. + support = self.data.time_support + start, stop = float(support.start[0]), float(support.end[-1]) + + budget = max(1, self._max_y_samples // max(1, len(self.data))) + widen = max(1, int(np.ceil((stop - start) / self.bin_size / budget))) + + return self._rates(start, stop, self.bin_size * widen) + + async def _get_raw_data_slice(self, indices: dict[str, Any]) -> np.ndarray: + return await run_in_thread_pool(self._executor, self._support_rates) + + +class TsGroupSpikesSlicer(_PynapplePositionsSlicer): + def __init__( + self, + data: nap.TsGroup, + dims: tuple[str, str, str], + display_dims: tuple[str, str, str], + y: str = None, + ep: nap.IntervalSet = None, + sort_by: str = None, + max_display_datapoints: int | None = 1_000_000, + **kwargs, + ): + """ + ``NDPositionsSlicer`` subclass that renders the individual spikes of a ``pynapple.TsGroup``. + + Produces ``[1, n_spikes, 2]`` slices, one graphic holding every spike of the display + window, where x is the spike time and y is the row the spike is drawn on. Usually rendered + as a ``ScatterCollection``. + + Unlike :class:`TsGroupRateSlicer` the datapoints are real, so the display window indexes a + sorted list of spike times and ``max_display_datapoints`` decimates it. Decimating spikes + drops them, so the default cap is raised to 1e6 and the cost is bounded by + ``display_window`` instead. + + Parameters + ---------- + data: pynapple.TsGroup + The spike trains to display. + + dims: tuple[str, str, str] + Names for the 3 dims, the same 3 names as ``display_dims``. + + display_dims: tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``. The first is + of size 1, since every spike is held by a single graphic. + + y: str, optional + Name of a metadata column giving the row each unit is drawn on, ex: the depth of the + unit on the probe. A categorical column is encoded as integer codes. ``None`` uses the + unit key. Mutually exclusive with ``sort_by``. + + ep: pynapple.IntervalSet, optional + Restrict to these epochs. Spikes that no epoch covers are dropped from the slice. + + sort_by: str, optional + Name of a metadata column to order the units by. The row of each unit is then its rank + in that order. Mutually exclusive with ``y``. + + max_display_datapoints: int | None, default 1e6 + Maximum number of spikes rendered. ``None`` renders every spike in the window. + + kwargs + passed to :class:`NDPositionsSlicer`. + + See Also + -------- + TsGroupRateSlicer : Binned firing rates rather than individual spikes. + + """ + if y is not None and sort_by is not None: + raise ValueError( + "`y` and `sort_by` both set the row of each unit, pass only one of them" + ) + + self._y = y + self._tsd = None + + super().__init__( + data, + dims, + display_dims, + ep=ep, + sort_by=sort_by, + max_display_datapoints=max_display_datapoints, + **kwargs, + ) + + # every spike is a point, drawn by the one graphic that holds them all + default_graphic_type = ScatterCollection + + @property + def data(self) -> nap.TsGroup: + """get or set the managed object, the new object is interpreted to have the same dims""" + return self._data + + @data.setter + def data(self, data: nap.TsGroup): + if not isinstance(data, nap.TsGroup): + raise TypeError( + f"`data` must be a `pynapple.TsGroup`, you passed a {type(data).__name__}" + ) + + self._data = data + self._tsd = None + + @property + def y(self) -> str | None: + """ + Get or set the name of the metadata column giving the row each unit is drawn on, ``None`` + uses the unit key. + """ + return self._y + + @y.setter + def y(self, column: str | None): + if column is not None and column not in self.data.metadata_columns: + raise KeyError( + f"'{column}' is not a metadata column of this TsGroup, available columns are: " + f"{list(self.data.metadata_columns)}" + ) + + self._y = column + self._tsd = None + + @_PynapplePositionsSlicer.sort_by.setter + def sort_by(self, column: str | None): + _PynapplePositionsSlicer.sort_by.fset(self, column) + self._tsd = None + + def _rows(self) -> list | None: + """the row each unit is drawn on, ``None`` uses the unit key""" + if self.sort_by is not None: + # the row of a unit is its rank in the sorted order + return np.argsort(self._order(), kind="stable").tolist() + + if self.y is not None: + codes, _ = metadata_codes(self.data.metadata[self.y]) + return codes.tolist() + + return None + + @property + def tsd(self) -> nap.Tsd: + """every spike of the group flattened into one ``Tsd``, x is the time and y is the row""" + if self._tsd is None: + rows = self._rows() + self._tsd = self.data.to_tsd() if rows is None else self.data.to_tsd(rows) + + return self._tsd + + def _time_map(self): + return self._searchsorted_spikes + + def _searchsorted_spikes(self, value: Any) -> int: + return self.tsd.t.searchsorted(value) + + @property + def shape(self) -> dict[str, int]: + """interpreted shape of the data, always 1 graphic, the number of spikes, and the value dim""" + n_graphics, p, d = self.display_dims + + return {n_graphics: 1, p: self.tsd.t.size, d: 2} + + @property + def ndim(self) -> int: + """number of dims, always 3""" + return 3 + + def _stack(self, times: np.ndarray, rows: np.ndarray) -> np.ndarray: + out = np.empty((1, times.size, 2), dtype=np.float32) + out[0, :, 0] = times + out[0, :, 1] = rows + + return out + + def _read(self, dw_slice: slice) -> np.ndarray: + times = self.tsd.t[dw_slice] + rows = self.tsd.values[dw_slice] + + outside = self._outside_ep(times) + if outside is not None: + # spikes are events with no connectivity, so an excluded one is simply dropped + times, rows = times[~outside], rows[~outside] + + return self._finalize(self._stack(times, rows)) + + async def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + """ + Get the data slice to display at the given indices. + + Returns every spike of the display window as ``[1, n_spikes, 2]``. + + Parameters + ---------- + indices: dict[str, Any] + Reference-space value for the ``p`` dim, ex: ``{"time": 46.397}``. + + Returns + ------- + dict[str, np.ndarray] + ``"data"`` holds the data slice, the remaining keys are the windowed graphic features. + + """ + dw_slice = self._get_dw_slice(indices) + + data = await run_in_thread_pool(self._executor, self._read, dw_slice) + + return {"data": data, **self._get_other_features(data, dw_slice)} + + async def _get_raw_data_slice(self, indices: dict[str, Any]) -> np.ndarray: + return self._stack(self.tsd.t, self.tsd.values) + + +class IntervalSetSlicer(_PynapplePositionsSlicer): + def __init__( + self, + data: nap.IntervalSet, + dims: tuple[str, str, str], + display_dims: tuple[str, str, str], + column: str = None, + ep: nap.IntervalSet = None, + **kwargs, + ): + """ + ``NDPositionsSlicer`` subclass that rasterizes a ``pynapple.IntervalSet`` into an ethogram. + + Produces ``[n_categories, p, 2]`` slices where each row is one unique value of ``column`` + and the y coordinate is the **fraction of that bin covered** by the epochs of that row, in + ``[0, 1]``. Usually rendered as an ``ImageGraphic``. + + Coverage rather than sampling the epoch state at each bin centre: an ``IntervalSet`` has no + timestamps, so the bins are synthesized from the display window, and point sampling drops + every epoch shorter than the bin spacing without any warning. Coverage keeps them — a brief + epoch reads as a faint column that goes solid as you zoom in, rather than blinking in and + out — and stays in ``[0, 1]`` at every zoom, so the colormap does not shift under it. + + Parameters + ---------- + data: pynapple.IntervalSet + The epochs to rasterize. + + dims: tuple[str, str, str] + Names for the 3 dims, the same 3 names as ``display_dims``. + + display_dims: tuple[str, str, str] + The 3 spatial dims **in display order**: ``(n_graphics, p, )``. + + column: str, optional + Name of the metadata column whose unique values become the rows, ex: ``"tags"`` for a + behavioural state. ``None`` puts every epoch on one row. + + ep: pynapple.IntervalSet, optional + Restrict to these epochs. Coverage is computed against the intersection, so a bin that + they do not cover is genuinely ``0``. + + kwargs + passed to :class:`NDPositionsSlicer`. + + Notes + ----- + Building contiguous bins makes pynapple warn ``Some starts and ends are equal. Removing 1 + microsecond!`` and biases each bin by 1 µs. + + The datapoints are synthesized rather than read from the object, so there is no full ``p`` + dim to index into and per-datapoint windowed graphic features do not apply. + + """ + self._column = None + + super().__init__(data, dims, display_dims, ep=ep, **kwargs) + + self.column = column + + # an ethogram is a heatmap, one row per category, color from the coverage + default_graphic_type = ImageGraphic + + @property + def data(self) -> nap.IntervalSet: + """get or set the managed object, the new object is interpreted to have the same dims""" + return self._data + + @data.setter + def data(self, data: nap.IntervalSet): + if not isinstance(data, nap.IntervalSet): + raise TypeError( + f"`data` must be a `pynapple.IntervalSet`, you passed a {type(data).__name__}" + ) + + self._data = data + + def _time_map(self): + # the bins are synthesized from the display window, so there is no array to index into and + # reference seconds are used directly + return identity + + @property + def column(self) -> str | None: + """ + Get or set the name of the metadata column whose unique values become the rows, ``None`` + puts every epoch on one row. + """ + return self._column + + @column.setter + def column(self, column: str | None): + if column is not None and column not in self.data.metadata_columns: + raise KeyError( + f"'{column}' is not a metadata column of this IntervalSet, available columns " + f"are: {list(self.data.metadata_columns)}" + ) + + self._column = column + + @_PynapplePositionsSlicer.sort_by.setter + def sort_by(self, column: str | None): + if column is not None: + raise NotImplementedError( + "the rows are the categories of `column`, not individual epochs, so there is " + "nothing to sort; set `column` instead" + ) + + _PynapplePositionsSlicer.sort_by.fset(self, None) + + @property + def categories(self) -> np.ndarray: + """the value of ``column`` that each row represents, in order of first appearance""" + if self.column is None: + return np.zeros(1) + + return metadata_categories(self.data.metadata[self.column]) + + @property + def n_bins(self) -> int: + """number of bins rendered per row, i.e. the ``p`` dim""" + return self.max_display_datapoints or 1_000 + + @property + def shape(self) -> dict[str, int]: + """interpreted shape of the data, the number of rows, bins, and the value dim""" + n_graphics, p, d = self.display_dims + + return {n_graphics: self.categories.size, p: self.n_bins, d: 2} + + @property + def ndim(self) -> int: + """number of dims, always 3""" + return 3 + + def _window(self, indices: dict[str, Any]) -> tuple[float, float]: + """the display window in seconds""" + if self.display_window is None: + return float(self.data.start[0]), float(self.data.end[-1]) + + half_window = self.display_window / 2 + centre = indices[self.time_dim] + + return centre - half_window, centre + half_window + + def _epochs_per_row(self) -> list[nap.IntervalSet]: + if self.column is None: + return [self.data] + + values = np.asarray(self.data.metadata[self.column]) + + return [self.data[values == category] for category in self.categories] + + @staticmethod + def _bin_coverage( + bins: nap.IntervalSet, epochs: nap.IntervalSet, edges: np.ndarray + ) -> np.ndarray: + """fraction of each bin that ``epochs`` covers""" + covered_time = np.zeros(edges.size - 1) + + if len(epochs) > 0: + # intersect clips the epochs at every bin boundary, so each returned piece starts + # inside exactly one bin and searchsorted identifies which + covered = bins.intersect(epochs) + + if len(covered) > 0: + index = np.searchsorted(edges, covered.start, side="right") - 1 + np.add.at( + covered_time, + np.clip(index, 0, covered_time.size - 1), + covered.end - covered.start, + ) + + return covered_time / np.diff(edges) + + def _rasterize(self, indices: dict[str, Any]) -> np.ndarray: + start, stop = self._window(indices) + + rows = self._epochs_per_row() + + if stop <= start: + # the NDGraphic hides itself on an empty slice + return np.empty((len(rows), 0, 2), dtype=np.float32) + + edges = np.linspace(start, stop, self.n_bins + 1) + bins = nap.IntervalSet(edges[:-1], edges[1:]) + + out = np.empty((len(rows), self.n_bins, 2), dtype=np.float32) + out[..., 0] = (edges[:-1] + edges[1:]) / 2 + + for i, epochs in enumerate(rows): + if self.ep is not None: + epochs = epochs.intersect(self.ep) + + out[i, :, 1] = self._bin_coverage(bins, epochs, edges) + + return self._finalize(out) + + async def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + """ + Get the data slice to display at the given indices. + + Rasterizes the epochs of the display window into ``max_display_datapoints`` bins per row + and returns the fraction of each bin they cover. + + Parameters + ---------- + indices: dict[str, Any] + Reference-space value for the ``p`` dim, ex: ``{"time": 46.397}``. + + Returns + ------- + dict[str, np.ndarray] + ``"data"`` holds the data slice. + + """ + return { + "data": await run_in_thread_pool(self._executor, self._rasterize, indices) + } + + async def _get_raw_data_slice(self, indices: dict[str, Any]) -> np.ndarray: + # coverage is always within [0, 1], so a stack needs no estimate of the y max + return np.ones((self.categories.size, 1, 2), dtype=np.float32) + + +class NDPynappleTimeseries(NDTimeseries): + """ + ``NDTimeseries`` subclass for the pynapple slicers, adding :attr:`ep` and :attr:`sort_by`. + + Both alias the slicer and re-render, the same way ``display_window`` does. The graphic is + hidden whenever the display window contains no data, rather than being left showing a stale + slice. + """ + + # per-graphic color features in the order of the object. Kept unsorted so they can be + # re-permuted whenever ``sort_by`` changes, otherwise the rows move and the colors stay put + # and stop identifying the graphic. + _unsorted_colors: dict[str, Any] = None + + _color_by: str | None = None + _color_cmap: str | None = None + + # features whose values are per-graphic and therefore follow the sort order + _ordered_features = ("colors", "cmap_transform") + + # `cmap` has to be set before `cmap_transform`, which raises without one + _apply_order = ("cmap", "cmap_range", "colors", "cmap_transform") + + def _set_unsorted_colors(self, features: dict[str, Any]): + """store the color features in the object's order and apply them in the rendered order""" + self._unsorted_colors = dict(features) + self._apply_colors() + + def _apply_colors(self): + """re-apply the stored color features, permuted to the current sort order""" + if not self._unsorted_colors: + return + + order = self.slicer._order() + + for name in self._apply_order: + if name not in self._unsorted_colors: + continue + + value = self._unsorted_colors[name] + if order is not None and name in self._ordered_features: + value = np.asarray(value)[order] + + self._set_feature(name, value) + + @property + def ep(self) -> nap.IntervalSet | None: + """ + Get or set the epochs to restrict to, ``None`` uses every epoch of the object's + ``time_support``. Setting it re-renders the current data slice. + """ + return self.slicer.ep + + @ep.setter + def ep(self, ep: nap.IntervalSet | None): + self.slicer.ep = ep + # force a render + run_sync(self._set_indices_()) + + @property + def color_by(self) -> str | None: + """ + Get or set the name of the metadata column the graphics are colored by, ``None`` stops + deriving them and leaves the current colors in place. + + A numeric column is mapped onto :attr:`color_cmap` between its percentile bounds, a + categorical one onto a qualitative colormap so that category *k* is always color *k*, and a + column that already names colors is used as-is. The colors stay in step with + :attr:`sort_by`. + """ + return self._color_by + + @color_by.setter + def color_by(self, column: str | None): + self._color_by = column + self._refresh_colors() + + @property + def color_cmap(self) -> str | None: + """ + Get or set the colormap used by :attr:`color_by`. ``None`` uses ``"tab10"`` for a + categorical column and ``"viridis"`` for a numeric one. + """ + return self._color_cmap + + @color_cmap.setter + def color_cmap(self, cmap: str | None): + self._color_cmap = cmap + self._refresh_colors() + + def _refresh_colors(self): + """re-derive the color features from the metadata and apply them in the rendered order""" + if self._color_by is None: + self._unsorted_colors = None + return + + _, colors_helper = dispatch(self.slicer.data) + + self._set_unsorted_colors( + colors_helper(self.slicer.data, self._color_by, cmap=self._color_cmap) + ) + + @property + def sort_by(self) -> str | None: + """ + Get or set the name of the metadata column the graphics are ordered by, ``None`` keeps the + order of the object. Setting it re-renders the current data slice. + """ + return self.slicer.sort_by + + @sort_by.setter + def sort_by(self, column: str | None): + self.slicer.sort_by = column + # the rows have moved, so the per-graphic colors have to move with them + self._apply_colors() + # force a render + run_sync(self._set_indices_()) + + def _fit_y(self): + """ + Frame the current slice vertically and put the x-range back. + + Used when a property changes what the y values *mean*, ex: rows from a different metadata + column, so the range the camera was framing no longer refers to anything. + """ + subplot = self._nd_subplot.subplot + + x_range = subplot.x_range + subplot.auto_scale(maintain_aspect=False) + subplot.x_range = x_range + + def _update_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): + if new_features["data"].shape[1] == 0: + # the window covers no data, ex: it falls entirely outside `ep`. `_update_view` reads + # the first and last datapoint, so there is nothing to update to. + self.graphic.visible = False + return + + self.graphic.visible = True + super()._update_graphic(new_features, indices) + + +class NDPynappleRate(NDPynappleTimeseries): + """``NDPynappleTimeseries`` for a :class:`TsGroupRateSlicer`, adding :attr:`bin_size`.""" + + @property + def bin_size(self) -> float: + """ + Get or set the bin size the spikes are counted in, in seconds. Setting it re-renders the + current data slice. + """ + return self.slicer.bin_size + + @bin_size.setter + def bin_size(self, bin_size: float): + self.slicer.bin_size = bin_size + # force a render + run_sync(self._set_indices_()) + + +class NDPynappleSpikes(NDPynappleTimeseries): + """``NDPynappleTimeseries`` for a :class:`TsGroupSpikesSlicer`, adding :attr:`y`.""" + + @property + def y(self) -> str | None: + """ + Get or set the name of the metadata column giving the row each unit is drawn on, ``None`` + uses the unit key. Setting it re-renders the current data slice. + """ + return self.slicer.y + + @y.setter + def y(self, column: str | None): + self.slicer.y = column + # there are still as many spikes, but every one is on a different row, and the old y-range + # was framing the values of the previous column + run_sync(self._set_indices_()) + self._fit_y() + + +class NDPynappleEthogram(NDPynappleTimeseries): + """``NDPynappleTimeseries`` for an :class:`IntervalSetSlicer`, adding :attr:`column`.""" + + @property + def column(self) -> str | None: + """ + Get or set the name of the metadata column whose unique values are the rows, ``None`` puts + every epoch on one row. Setting it rebuilds the graphic, since the number of rows changes. + """ + return self.slicer.column + + @column.setter + def column(self, column: str | None): + self.slicer.column = column + + # a different column means a different number of rows, so the buffers are the wrong shape + if self.graphic is not None: + self._nd_subplot.subplot.delete_graphic(self.graphic) + self._graphic = None + + run_sync(self._create_graphic()) + # the colors are per-row, so they have to be derived again for the new rows + self._refresh_colors() + run_sync(self._set_indices_()) + # the old y-range was framing a different number of rows + self._fit_y() + + +class TsdTensorSlicer(NDImageSlicer): + def __init__( + self, + data: nap.TsdTensor, + dims: Sequence[str], + display_dims: tuple[str, str] | tuple[str, str, str], + ep: nap.IntervalSet = None, + **kwargs, + ): + """ + ``NDImageSlicer`` subclass for a ``pynapple.TsdTensor``, ex: an imaging movie. + + Axis 0 of a ``TsdTensor`` is always time, so ``dims[0]`` is the time dim and its slider map + is taken from the object's own timestamps. + + Parameters + ---------- + data: pynapple.TsdTensor + The frames to display. + + dims: Sequence[str] + Name for every dim of ``data``, in order. ``dims[0]`` names the time axis. + + display_dims: tuple[str, str] | tuple[str, str, str] + The 2 or 3 spatial dims **in display order**, see :class:`NDImageSlicer`. + + ep: pynapple.IntervalSet, optional + Restrict to these epochs. A frame is a single point in time rather than a window, so + :class:`NDPynappleImage` hides the graphic while the current index falls outside them + rather than leaving a stale frame on screen. + + kwargs + passed to :class:`NDImageSlicer`. + + See Also + -------- + NDImageSlicer : Base class with full parameter documentation. + + """ + self._ep = None + + super().__init__(data, dims, display_dims, **kwargs) + + self.ep = ep + + #: the ``NDGraphic`` that carries this slicer's mutable properties, assigned at the end of the + #: module since the graphics are defined after the slicers + nd_graphic_type: type = None + + @property + def data(self) -> nap.TsdTensor: + """get or set the managed object, the new object is interpreted to have the same dims""" + return self._data + + @data.setter + def data(self, data: nap.TsdTensor): + if not isinstance(data, nap.TsdTensor): + raise TypeError( + f"`data` must be a `pynapple.TsdTensor`, you passed a {type(data).__name__}" + ) + + self._data = data + self._recompute_histogram() + + @property + def time_dim(self) -> str: + """name of the time dim, always axis 0 of a ``TsdTensor``""" + return self.dims[0] + + @property + def slider_maps(self) -> dict[str, Any]: + """ + Per-slider-dim mapping from reference-space values to local array indices. + + The map for the time dim is taken from the object's own timestamps and cannot be given; + every other dim behaves as in :class:`NDSlicer`, so a ``[time, z, m, n]`` tensor can still + map its ``z`` dim. + """ + return self._index_mappings + + @slider_maps.setter + def slider_maps(self, maps: dict[str, Any] | None): + if maps is not None and self.time_dim in maps: + raise ValueError( + f"the map for '{self.time_dim}' comes from the timestamps of the TsdTensor " + f"itself, remove it from `slider_maps`" + ) + + maps = dict(maps) if maps is not None else dict() + maps[self.time_dim] = self.data.t.searchsorted + + NDSlicer.slider_maps.fset(self, maps) + + @property + def ep(self) -> nap.IntervalSet | None: + """ + Get or set the epochs to restrict to. ``None`` uses every epoch of the object's + ``time_support``. + """ + return self._ep + + @ep.setter + def ep(self, ep: nap.IntervalSet | None): + if ep is not None and not isinstance(ep, nap.IntervalSet): + raise TypeError( + f"`ep` must be a `pynapple.IntervalSet` or `None`, you passed a " + f"{type(ep).__name__}" + ) + + self._ep = ep + + def in_ep(self, indices: dict[str, Any]) -> bool: + """whether the given reference index falls inside :attr:`ep`""" + if self.ep is None: + return True + + return bool(covered_by(self.ep, np.atleast_1d(indices[self.time_dim]))[0]) + + def _recompute_histogram(self): + # `np.isnan(tsdtensor) | np.isinf(tsdtensor)` returns NotImplemented from pynapple's + # __array_ufunc__, so the base implementation raises on a TsdTensor. Reduce over the plain + # values instead, which also keeps a lazily loaded object lazy. + if not self._compute_histogram or self.data is None: + self._histogram = None + return + + if self.spatial_func is not None: + # a spatial func often needs the full spatial resolution, see NDImageSlicer + ignore_dims = [self.dims.index(dim) for dim in self.display_dims] + else: + ignore_dims = None + + sub = np.asarray(subsample_array(self.data.values, ignore_dims=ignore_dims)) + + self._histogram = np.histogram(sub[np.isfinite(sub)], bins=100) + + +class NDPynappleImage(NDImage): + """ + ``NDImage`` subclass for :class:`TsdTensorSlicer`, adding :attr:`ep`. + + A frame is a single point in time rather than a window, so the graphic is hidden while the + current index falls outside the epochs rather than being left showing a stale frame. + """ + + def __init__( + self, + *args, + slicer_type: type[TsdTensorSlicer] = TsdTensorSlicer, + ep: nap.IntervalSet = None, + **kwargs, + ): + # `in_ep` is only defined by TsdTensorSlicer, so it is the only sensible default here + super().__init__(*args, slicer_type=slicer_type, **kwargs) + + if ep is not None: + # NDImage constructs the slicer itself with a fixed set of kwargs, so there is no + # route for `ep` other than setting it once the slicer exists + self.ep = ep + + @property + def ep(self) -> nap.IntervalSet | None: + """ + Get or set the epochs to restrict to, ``None`` uses every epoch of the object's + ``time_support``. Setting it re-renders the current frame. + """ + return self.slicer.ep + + @ep.setter + def ep(self, ep: nap.IntervalSet | None): + self.slicer.ep = ep + # force a render + run_sync(self._set_indices_()) + + async def _set_indices_(self, indices: dict[str, Any] = None): + if self.graphic is None: + return + + if indices is None: + indices = self.indices + + self.graphic.visible = self.slicer.in_ep(indices) + + if not self.graphic.visible: + # no frame exists at this time, do not read one + self._last_indices = indices + return + + await super()._set_indices_(indices) + + +# the NDGraphic each slicer pairs with, so that the slicer-specific mutable properties live on a +# class that actually has them. Assigned here rather than in the class bodies because the graphics +# are defined after the slicers they take as a default, and a subclass inherits the pairing. +TsdFrameSlicer.nd_graphic_type = NDPynappleTimeseries +TsGroupRateSlicer.nd_graphic_type = NDPynappleRate +TsGroupSpikesSlicer.nd_graphic_type = NDPynappleSpikes +IntervalSetSlicer.nd_graphic_type = NDPynappleEthogram +TsdTensorSlicer.nd_graphic_type = NDPynappleImage + + +# the slicer and color helper each pynapple type gets by default. The types are mutually exclusive +# siblings, none is a subclass of another, so an exact lookup is unambiguous. A `TsGroup` defaults +# to rates; pass ``slicer=PynappleSlicer.TsGroupSpikes`` for the individual spikes instead. +_DISPATCH: dict[type, tuple[type, Callable | None]] = { + nap.Tsd: (TsdFrameSlicer, tsdframe_colors), + nap.TsdFrame: (TsdFrameSlicer, tsdframe_colors), + nap.TsdTensor: (TsdTensorSlicer, None), + nap.TsGroup: (TsGroupRateSlicer, tsgroup_colors), + nap.IntervalSet: (IntervalSetSlicer, intervalset_colors), +} + + +def dispatch(data: Any) -> tuple[type, Callable | None]: + """ + The default slicer and color helper for a pynapple object. + + Parameters + ---------- + data: pynapple object + A ``Tsd``, ``TsdFrame``, ``TsdTensor``, ``TsGroup`` or ``IntervalSet``. + + Returns + ------- + (type, Callable | None) + ``(slicer, colors_helper)``. The helper is ``None`` for a type whose graphic has no + per-graphic colors, i.e. an image. + + """ + try: + return _DISPATCH[type(data)] + except KeyError: + pass + + if isinstance(data, nap.Ts): + raise TypeError( + "a bare `Ts` has no values to draw, wrap it as a group of one: " + "`nap.TsGroup({0: ts})`" + ) + + raise TypeError( + f"no slicer for a {type(data).__name__}, the supported pynapple types are: " + f"{', '.join(t.__name__ for t in _DISPATCH)}" + ) + + +class PynappleSlicer: + """ + The pynapple slicers, graphics and helpers, available as ``nds_extras.Pynapple`` when pynapple + is installed. + + Use ``NDWSubplot.add_pynapple_obj()`` rather than constructing these directly. It picks the + slicer and the graphic for the object it is given, so the two cannot be mismatched. + + Attributes + ---------- + TsdFrame : type + :class:`TsdFrameSlicer`, for a ``Tsd`` or a ``TsdFrame``. + + TsdTensor : type + :class:`TsdTensorSlicer`, for a ``TsdTensor``. + + TsGroupRate : type + :class:`TsGroupRateSlicer`, binned firing rates of a ``TsGroup``. + + TsGroupSpikes : type + :class:`TsGroupSpikesSlicer`, the individual spikes of a ``TsGroup``. + + IntervalSet : type + :class:`IntervalSetSlicer`, an ``IntervalSet`` rasterized into an ethogram. + + """ + + TsdFrame = TsdFrameSlicer + TsdTensor = TsdTensorSlicer + TsGroupRate = TsGroupRateSlicer + TsGroupSpikes = TsGroupSpikesSlicer + IntervalSet = IntervalSetSlicer + + NDPynappleTimeseries = NDPynappleTimeseries + NDPynappleRate = NDPynappleRate + NDPynappleSpikes = NDPynappleSpikes + NDPynappleEthogram = NDPynappleEthogram + NDPynappleImage = NDPynappleImage + + dispatch = staticmethod(dispatch) + ranges_from_time_support = staticmethod(ranges_from_time_support) + sort_order = staticmethod(sort_order) + metadata_codes = staticmethod(metadata_codes) + metadata_categories = staticmethod(metadata_categories) + tsgroup_colors = staticmethod(tsgroup_colors) + tsdframe_colors = staticmethod(tsdframe_colors) + intervalset_colors = staticmethod(intervalset_colors) diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py index 038c9ba58..910ad3250 100644 --- a/fastplotlib/widgets/nd_widget/_ndw_subplot.py +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -24,7 +24,7 @@ ) from ._index import AutoRangeContinuous from ._video import VideoSlicer -from ._base import NDGraphic, WindowFuncCallable +from ._base import NDGraphic, WindowFuncCallable, get_init_args class NDWSubplot: @@ -349,6 +349,197 @@ def add_video( graphic_kwargs=graphic_kwargs, ) + def add_pynapple_obj( + self, + data, + dims: Sequence[str], + display_dims: Sequence[str], + *, + slicer: type = None, + graphic_type: type = None, + x_range_mode: Literal["fixed", "auto"] | None = "auto", + ep=None, + sort_by: str = None, + color_by: str = None, + cmap: str = None, + name: str = None, + slicer_kwargs: dict = None, + **kwargs, + ) -> NDGraphic: + """ + Add a pynapple object to this subplot. + + Picks the slicer and the graphic from the type of ``data``, so the two cannot be + mismatched, and takes the timebase from the object itself. Requires ``pynapple``. + + ============= ================================= ========================================= + type default representation slicer + ============= ================================= ========================================= + ``Tsd`` one line ``PynappleSlicer.TsdFrame`` + ``TsdFrame`` a ``LineStack``, one per column ``PynappleSlicer.TsdFrame`` + ``TsdTensor`` an image, one frame at a time ``PynappleSlicer.TsdTensor`` + ``TsGroup`` a firing rate heatmap ``PynappleSlicer.TsGroupRate`` + ``IntervalSet`` an ethogram of coverage ``PynappleSlicer.IntervalSet`` + ============= ================================= ========================================= + + A ``TsGroup`` defaults to binned rates. Pass ``slicer=nds_extras.Pynapple.TsGroupSpikes`` + to draw the individual spikes instead. + + Parameters + ---------- + data: pynapple object + A ``Tsd``, ``TsdFrame``, ``TsdTensor``, ``TsGroup`` or ``IntervalSet``. + + dims: Sequence[str] + Name for every dim. For the positional types these are the same 3 names as + ``display_dims``, ``(n_graphics, p, )``. For a ``TsdTensor`` they name every + dim of the array, and ``dims[0]`` is the time axis. + + display_dims: Sequence[str] + The spatial dims **in display order**, see :meth:`add_nd_timeseries` for the positional + types and :meth:`add_nd_image` for a ``TsdTensor``. + + slicer: type, optional + Override the slicer chosen from the type of ``data``. + + graphic_type: type, optional + Override the representation, which otherwise comes from the slicer's + ``default_graphic_type``. Not used for a ``TsdTensor``, whose graphic follows + ``display_dims``. + + x_range_mode: "fixed" | "auto" | None, default "auto" + How the camera x-range is coupled to the time dim, see :meth:`add_nd_timeseries`. Not + used for a ``TsdTensor``. + + ep: pynapple.IntervalSet, optional + Restrict to these epochs. Also settable afterwards as ``ndgraphic.ep``. + + sort_by: str, optional + Name of a metadata column to order the graphics by. Also settable afterwards as + ``ndgraphic.sort_by``, and ``color_by`` follows it. + + color_by: str, optional + Name of a metadata column to color the graphics by. A numeric column is mapped onto + ``cmap`` between its percentile bounds, a categorical one onto a qualitative colormap + so that category *k* is always color *k*, and a column that already names colors is + used as-is. The colors are kept in step with ``sort_by``, including when it is changed + later. + + cmap: str, optional + Colormap used by ``color_by``. Defaults to ``"tab10"`` for a categorical column and + ``"viridis"`` for a numeric one. + + name: str, optional + Name for this ``NDGraphic``, used to retrieve it with ``nd_subplot[name]``. + + slicer_kwargs: dict, optional + passed to the slicer, ex: ``{"column": "behavior"}`` to choose the metadata column + whose categories become the rows of an ``IntervalSet`` ethogram, or ``{"y": "depth"}`` + to place the spikes of a ``TsGroup`` by depth rather than by unit key. + + kwargs + passed to the ``NDGraphic``, ex: ``display_window``, ``max_display_datapoints``, + ``x_range_mode`` and ``graphic_kwargs`` for the positional types, or + ``compute_histogram`` and ``graphic_kwargs`` for a ``TsdTensor``. + + Returns + ------- + NDPynappleTimeseries | NDPynappleImage + + """ + from ._nd_positions import nds_extras + + pynapple_slicer = getattr(nds_extras, "Pynapple", None) + if pynapple_slicer is None: + raise ModuleNotFoundError( + "`add_pynapple_obj` requires `pynapple` to be installed.\n" + "pip install pynapple" + ) + + default_slicer, colors_helper = pynapple_slicer.dispatch(data) + if slicer is None: + slicer = default_slicer + + is_image = issubclass(slicer, NDImageSlicer) + + # a TsdTensor is indexed along its own axis 0, the positional slicers along the `p` dim + time_dim = dims[0] if is_image else display_dims[1] + + if time_dim not in self.ndw.indices.dims: + raise KeyError( + f"'{time_dim}' has no reference range. A pynapple object is indexed in seconds, " + f"so an auto-generated range over its array indices would be wrong. Build one " + f"with `nds_extras.Pynapple.ranges_from_time_support(...)` and pass it as the " + f"`ranges` of the NDWidget." + ) + + if is_image: + for unsupported, value in (("sort_by", sort_by), ("color_by", color_by)): + if value is not None: + raise TypeError( + f"`{unsupported}` orders or colors the graphics of a collection, which a " + f"{type(data).__name__} is not" + ) + + if slicer_kwargs: + raise TypeError( + f"the only slicer argument for a {type(data).__name__} is `ep`, which is its " + f"own parameter, you passed: {sorted(slicer_kwargs)}" + ) + + # the remaining dims of a TsdTensor are real array dims, so they auto-range as usual + self._check_slider_dims(dims, display_dims, data.values) + + nd = slicer.nd_graphic_type( + self.ndw.indices, + nd_subplot=self, + data=data, + dims=dims, + display_dims=display_dims, + slicer_type=slicer, + ep=ep, + name=name, + **kwargs, + ) + self._nd_graphics.append(nd) + return nd + + slicer_kwargs = dict(slicer_kwargs) if slicer_kwargs is not None else dict() + + # route the arguments that belong to the slicer rather than the graphic, ex: `bin_size` + # for a TsGroupRateSlicer or `column` for an IntervalSetSlicer. Anything both accept, such + # as `display_window`, stays with the graphic, which passes it down itself. + slicer_only = get_init_args(slicer) - get_init_args(slicer.nd_graphic_type) + for arg in slicer_only & set(kwargs): + slicer_kwargs[arg] = kwargs.pop(arg) + + slicer_kwargs.update(ep=ep, sort_by=sort_by) + + nd = slicer.nd_graphic_type( + self.ndw.indices, + self, + data, + dims, + display_dims, + graphic_type=( + graphic_type if graphic_type is not None else slicer.default_graphic_type + ), + slicer=slicer, + linear_selector=True, + x_range_mode=x_range_mode, + name=name, + slicer_kwargs=slicer_kwargs, + **kwargs, + ) + + if color_by is not None: + nd.color_cmap = cmap + # derived on the NDGraphic so they are re-permuted whenever `sort_by` changes + nd.color_by = color_by + + self._nd_graphics.append(nd) + return nd + def add_nd_vectors( self, data: ArrayProtocol | None,