From 9a01cd5ee7fda8e4dd923670f0466d1233bc6de0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 25 Dec 2025 01:04:06 -0800 Subject: [PATCH 01/58] 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 02/58] 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 03/58] 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 04/58] 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 05/58] 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 84e6590658e53d50d661174926d8c33edefd14e4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 12 Jan 2026 23:07:42 -0500 Subject: [PATCH 06/58] remove isolated_buffer --- fastplotlib/graphics/_positions_base.py | 3 +-- fastplotlib/graphics/features/_base.py | 19 +++++-------------- fastplotlib/graphics/features/_image.py | 12 ++++-------- fastplotlib/graphics/features/_mesh.py | 4 ++-- fastplotlib/graphics/features/_positions.py | 19 +++++++++++++++---- fastplotlib/graphics/features/_scatter.py | 8 +++----- fastplotlib/graphics/features/_vectors.py | 2 -- fastplotlib/graphics/features/_volume.py | 12 ++++-------- fastplotlib/graphics/image.py | 9 +-------- fastplotlib/graphics/image_volume.py | 8 +------- fastplotlib/graphics/line.py | 2 -- fastplotlib/graphics/line_collection.py | 4 ---- fastplotlib/graphics/mesh.py | 11 ++--------- fastplotlib/graphics/scatter.py | 6 ------ 14 files changed, 38 insertions(+), 81 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 73520cc84..4754c3372 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -78,7 +78,6 @@ def __init__( uniform_color: bool = False, cmap: str | VertexCmap = None, cmap_transform: np.ndarray = None, - isolated_buffer: bool = True, size_space: str = "screen", *args, **kwargs, @@ -86,7 +85,7 @@ 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`") diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 779310476..e41414cd2 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -138,29 +138,20 @@ class BufferManager(GraphicFeature): def __init__( self, data: NDArray | pygfx.Buffer, - buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", - isolated_buffer: bool = True, **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) else: - raise ValueError( - "`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'" - ) + # create a buffer + bdata = np.zeros(data.shape, dtype=data.dtype) + bdata[:] = data[:] + + self._buffer = pygfx.Buffer(bdata) self._event_handlers: list[callable] = list() 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..586354117 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -52,7 +52,7 @@ class MeshIndices(VertexPositions): ] def __init__( - self, data: Any, isolated_buffer: bool = True, property_name: str = "indices" + self, data: Any, property_name: str = "indices" ): """ Manages the vertex indices buffer shown in the graphic. @@ -61,7 +61,7 @@ def __init__( data = self._fix_data(data) super().__init__( - data, isolated_buffer=isolated_buffer, property_name=property_name + data, property_name=property_name ) def _fix_data(self, data): diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 295d22417..6c8a47c5a 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", ): """ @@ -58,7 +57,7 @@ def __init__( data = parse_colors(colors, n_colors) super().__init__( - data=data, isolated_buffer=isolated_buffer, property_name=property_name + data=data, property_name=property_name ) @block_reentrance @@ -232,7 +231,7 @@ class VertexPositions(BufferManager): ] def __init__( - self, data: Any, isolated_buffer: bool = True, property_name: str = "data" + self, data: Any, property_name: str = "data" ): """ Manages the vertex positions buffer shown in the graphic. @@ -241,7 +240,7 @@ def __init__( data = self._fix_data(data) super().__init__( - data, isolated_buffer=isolated_buffer, property_name=property_name + data, property_name=property_name ) def _fix_data(self, data): @@ -261,6 +260,18 @@ 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 + bdata = np.zeros(value.shape, dtype=np.float32) + bdata[:] = value[:] + self._buffer = pygfx.Buffer(bdata) + graphic.world_object.geometry.position = self.buffer + else: + self[:] = value + @block_reentrance def __setitem__( self, diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index 16671ef89..1d9cb153d 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -158,7 +158,7 @@ def __init__( ) super().__init__( - markers_int_array, isolated_buffer=False, property_name=property_name + markers_int_array, property_name=property_name ) @property @@ -414,7 +414,6 @@ def __init__( self, rotations: int | float | np.ndarray | Sequence[int | float], n_datapoints: int, - isolated_buffer: bool = True, property_name: str = "point_rotations", ): """ @@ -422,7 +421,7 @@ def __init__( """ sizes = self._fix_sizes(rotations, n_datapoints) super().__init__( - data=sizes, isolated_buffer=isolated_buffer, property_name=property_name + data=sizes, property_name=property_name ) def _fix_sizes( @@ -488,7 +487,6 @@ def __init__( self, sizes: int | float | np.ndarray | Sequence[int | float], n_datapoints: int, - isolated_buffer: bool = True, property_name: str = "sizes", ): """ @@ -496,7 +494,7 @@ def __init__( """ sizes = self._fix_sizes(sizes, n_datapoints) super().__init__( - data=sizes, isolated_buffer=isolated_buffer, property_name=property_name + data=sizes, property_name=property_name ) def _fix_sizes( 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 1eaf54bb6..531c832a1 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -93,7 +93,6 @@ def __init__( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - isolated_buffer: bool = True, **kwargs, ): """ @@ -121,12 +120,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` @@ -142,7 +135,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) diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index db616b30d..0f0e5f23c 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` @@ -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) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index f2d862067..ed7a95490 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -39,7 +39,6 @@ def __init__( uniform_color: bool = False, cmap: str = None, cmap_transform: np.ndarray | Sequence = None, - isolated_buffer: bool = True, size_space: str = "screen", **kwargs, ): @@ -87,7 +86,6 @@ def __init__( uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, - isolated_buffer=isolated_buffer, size_space=size_space, **kwargs, ) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 275cc1e47..2087ada62 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -135,7 +135,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, ): @@ -324,7 +323,6 @@ def __init__( cmap=_cmap, name=_name, metadata=_m, - isolated_buffer=isolated_buffer, **kwargs_lines, ) @@ -560,7 +558,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 +631,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 2e5a11851..d569de783 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` @@ -94,14 +87,14 @@ def __init__( self._positions = positions else: self._positions = VertexPositions( - positions, isolated_buffer=isolated_buffer, property_name="positions" + positions, property_name="positions" ) if isinstance(positions, MeshIndices): self._indices = indices else: self._indices = MeshIndices( - indices, isolated_buffer=isolated_buffer, property_name="indices" + indices, property_name="indices" ) self._cmap = MeshCmap(cmap) diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index a2e696a82..3f9a530ef 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -56,7 +56,6 @@ def __init__( sizes: float | np.ndarray | Sequence[float] = 1, uniform_size: bool = False, size_space: str = "screen", - isolated_buffer: bool = True, **kwargs, ): """ @@ -154,10 +153,6 @@ def __init__( 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` @@ -169,7 +164,6 @@ def __init__( uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, - isolated_buffer=isolated_buffer, size_space=size_space, **kwargs, ) From 7163c96d192b2abecfa6aee11f3093dd4005e0d9 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 12 Jan 2026 23:44:27 -0500 Subject: [PATCH 07/58] remove isolated_buffer from mixin --- fastplotlib/layouts/_graphic_methods_mixin.py | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 06a4c7517..2d8aa2eab 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -33,7 +33,6 @@ def add_image( cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - isolated_buffer: bool = True, **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,7 +74,6 @@ def add_image( cmap, interpolation, cmap_interpolation, - isolated_buffer, **kwargs, ) @@ -100,7 +92,6 @@ 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, ) -> 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,7 +169,6 @@ def add_image_volume( substep_size, emissive, shininess, - isolated_buffer, **kwargs, ) @@ -199,7 +184,6 @@ def add_line_collection( names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, **kwargs, ) -> LineCollection: @@ -268,7 +252,6 @@ def add_line_collection( names, metadata, metadatas, - isolated_buffer, kwargs_lines, **kwargs, ) @@ -281,7 +264,6 @@ def add_line( uniform_color: bool = False, cmap: str = None, cmap_transform: Union[numpy.ndarray, Sequence] = None, - isolated_buffer: bool = True, size_space: str = "screen", **kwargs, ) -> LineGraphic: @@ -332,7 +314,6 @@ def add_line( uniform_color, cmap, cmap_transform, - isolated_buffer, size_space, **kwargs, ) @@ -348,7 +329,6 @@ 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, @@ -425,7 +405,6 @@ def add_line_stack( names, metadata, metadatas, - isolated_buffer, separation, separation_axis, kwargs_lines, @@ -448,7 +427,6 @@ def add_mesh( | numpy.ndarray ) = None, clim: tuple[float, float] = None, - isolated_buffer: bool = True, **kwargs, ) -> MeshGraphic: """ @@ -488,12 +466,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,7 +481,6 @@ def add_mesh( mapcoords, cmap, clim, - isolated_buffer, **kwargs, ) @@ -592,7 +563,6 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Sequence[float]] = 1, uniform_size: bool = False, size_space: str = "screen", - isolated_buffer: bool = True, **kwargs, ) -> ScatterGraphic: """ @@ -691,10 +661,6 @@ def add_scatter( 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` @@ -720,7 +686,6 @@ def add_scatter( sizes, uniform_size, size_space, - isolated_buffer, **kwargs, ) From 426ee5cd081ee1266a72ad880a6347401d2107cc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 13 Jan 2026 00:04:16 -0500 Subject: [PATCH 08/58] basics works for positions data --- fastplotlib/graphics/_positions_base.py | 8 ++--- fastplotlib/graphics/features/_base.py | 14 ++++---- fastplotlib/graphics/features/_positions.py | 39 +++++++++++++++++++-- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 4754c3372..a341b4077 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -23,7 +23,7 @@ def data(self) -> VertexPositions: @data.setter def data(self, value): - self._data[:] = value + self._data.set_value(self, value) @property def colors(self) -> VertexColors | pygfx.Color: @@ -36,11 +36,7 @@ def colors(self) -> VertexColors | pygfx.Color: @colors.setter def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): - if isinstance(self._colors, VertexColors): - self._colors[:] = value - - elif isinstance(self._colors, UniformColor): - self._colors.set_value(self, value) + self._colors.set_value(self, value) @property def cmap(self) -> VertexCmap: diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index e41414cd2..05b9da6d7 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -1,5 +1,5 @@ from warnings import warn -from typing import Literal +from typing import Callable import numpy as np from numpy.typing import NDArray @@ -78,7 +78,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 +89,7 @@ def add_event_handler(self, handler: callable): Parameters ---------- - handler: callable + handler: Callable a function to call when this feature changes """ @@ -102,7 +102,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``. @@ -148,12 +148,12 @@ def __init__( self._buffer = data else: # create a buffer - bdata = np.zeros(data.shape, dtype=data.dtype) + bdata = np.empty(data.shape, dtype=data.dtype) bdata[:] = data[:] self._buffer = pygfx.Buffer(bdata) - self._event_handlers: list[callable] = list() + self._event_handlers: list[Callable] = list() @property def value(self) -> np.ndarray: @@ -165,7 +165,7 @@ def set_value(self, graphic, value): self[:] = value @property - def buffer(self) -> pygfx.Buffer | pygfx.Texture: + def buffer(self) -> pygfx.Buffer: """managed buffer""" return self._buffer diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 6c8a47c5a..242d926e8 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -60,6 +60,21 @@ def __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)): + # check if the number of elements matches current buffer size + if self.buffer.data.shape[0] != len(value): + # create new buffer + new_colors = parse_colors(value, len(value)) + self._buffer = pygfx.Buffer(new_colors) + graphic.world_object.geometry.colors = self.buffer + else: + # buffer size unchanged + self[:] = value + else: + self[:] = value + @block_reentrance def __setitem__( self, @@ -265,10 +280,28 @@ def set_value(self, graphic, value): if isinstance(value, np.ndarray): if self.buffer.data.shape[0] != value.shape[0]: # number of items doesn't match, create a new buffer - bdata = np.zeros(value.shape, dtype=np.float32) - bdata[:] = value[:] + + # if data is not 3D + if value.ndim == 1: + # this is already a newly allocated buffer + # _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: + # this is already a newly allocated buffer + 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 self._buffer = pygfx.Buffer(bdata) - graphic.world_object.geometry.position = self.buffer + graphic.world_object.geometry.positions = self.buffer + else: + # buffer size unchanged + self[:] = value else: self[:] = value From 23622b0f3b50186fd7171029cb9820efcb08fb48 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 13 Jan 2026 01:04:12 -0500 Subject: [PATCH 09/58] replaceable buffers for all positions related features --- fastplotlib/graphics/features/_positions.py | 29 +++-- fastplotlib/graphics/features/_scatter.py | 117 ++++++++++++++------ 2 files changed, 102 insertions(+), 44 deletions(-) diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 242d926e8..db1836539 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -69,11 +69,21 @@ def set_value(self, graphic, value: str | pygfx.Color | np.ndarray | Sequence[fl new_colors = parse_colors(value, len(value)) self._buffer = pygfx.Buffer(new_colors) graphic.world_object.geometry.colors = self.buffer - else: - # buffer size unchanged - self[:] = value - else: - self[:] = value + + 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__( @@ -299,11 +309,10 @@ def set_value(self, graphic, value): # create the new buffer self._buffer = pygfx.Buffer(bdata) graphic.world_object.geometry.positions = self.buffer - else: - # buffer size unchanged - self[:] = value - else: - self[:] = value + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value @block_reentrance def __setitem__( diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index 1d9cb153d..faedbb9ae 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -100,6 +100,43 @@ 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" Date: Tue, 13 Jan 2026 02:14:34 -0500 Subject: [PATCH 10/58] image data buffer can change --- fastplotlib/graphics/image.py | 42 +++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 531c832a1..5895160f5 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -127,7 +127,7 @@ def __init__( super().__init__(**kwargs) - world_object = pygfx.Group() + group = pygfx.Group() if isinstance(data, TextureArray): # share buffer @@ -149,6 +149,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: @@ -157,7 +158,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, @@ -173,6 +173,14 @@ def __init__( pick_write=True, ) + # create the _ImageTile world objects, add to group + for tile in self._create_image_tiles(): + group.add(tile) + + self._set_world_object(group) + + def _create_image_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: @@ -193,9 +201,9 @@ 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: @@ -204,6 +212,32 @@ def data(self) -> TextureArray: @data.setter def data(self, data): + # 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_image_tiles(): + self.world_object.add(tile) + self._data[:] = data @property From d9f5ca9c35df287f6f7ad9ff569af3d4210db457 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 13 Jan 2026 02:23:45 -0500 Subject: [PATCH 11/58] resizeable buffers for volume --- fastplotlib/graphics/image.py | 8 +++++--- fastplotlib/graphics/image_volume.py | 29 +++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 5895160f5..c53520eac 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -174,12 +174,12 @@ def __init__( ) # create the _ImageTile world objects, add to group - for tile in self._create_image_tiles(): + for tile in self._create_tiles(): group.add(tile) self._set_world_object(group) - def _create_image_tiles(self) -> list[_ImageTile]: + def _create_tiles(self) -> list[_ImageTile]: tiles = list() # iterate through each texture chunk and create # an _ImageTile, offset the tile using the data indices @@ -235,9 +235,11 @@ def data(self, data): self.world_object.clear() # create new tiles - for tile in self._create_image_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 0f0e5f23c..2bc4c4b5e 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -182,7 +182,7 @@ def __init__( super().__init__(**kwargs) - world_object = pygfx.Group() + group = pygfx.Group() if isinstance(data, TextureArrayVolume): # share existing buffer @@ -231,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: @@ -253,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: @@ -264,6 +273,20 @@ def data(self) -> TextureArrayVolume: @data.setter def data(self, data): + # 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 From ea0d791d50243ed51454fd568492ce558780f665 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 13 Jan 2026 02:25:04 -0500 Subject: [PATCH 12/58] black --- fastplotlib/graphics/features/_mesh.py | 8 ++---- fastplotlib/graphics/features/_positions.py | 18 ++++++------ fastplotlib/graphics/features/_scatter.py | 32 ++++++++------------- fastplotlib/graphics/image.py | 6 +++- fastplotlib/graphics/mesh.py | 8 ++---- 5 files changed, 29 insertions(+), 43 deletions(-) diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 586354117..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, 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, 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 db1836539..9a6e87a73 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -56,11 +56,13 @@ def __init__( """ data = parse_colors(colors, n_colors) - super().__init__( - data=data, 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]): + 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)): # check if the number of elements matches current buffer size @@ -255,18 +257,14 @@ class VertexPositions(BufferManager): }, ] - def __init__( - self, data: Any, 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, property_name=property_name - ) + super().__init__(data, property_name=property_name) def _fix_data(self, data): if data.ndim == 1: diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index faedbb9ae..d79722ef7 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -114,9 +114,7 @@ def parse_markers_init(markers: str | Sequence[str] | np.ndarray, n_datapoints: marker_str_length = max(map(len, list(pygfx.MarkerShape))) - markers_readable_array = np.empty( - n_datapoints, dtype=f" np.ndarray[str]: """numpy array of per-vertex marker shapes in human-readable form""" @@ -211,7 +205,9 @@ 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(value, len(value)) + markers_int_array, self._markers_readable_array = parse_markers_init( + value, len(value) + ) self._buffer = pygfx.Buffer(markers_int_array) graphic.geometry.markers = self.buffer @@ -441,9 +437,7 @@ def __init__( Manages rotations buffer of scatter points. """ sizes = self._fix_rotations(rotations, n_datapoints) - super().__init__( - data=sizes, property_name=property_name - ) + super().__init__(data=sizes, property_name=property_name) def _fix_rotations( self, @@ -528,9 +522,7 @@ def __init__( Manages sizes buffer of scatter points. """ sizes = self._fix_sizes(sizes, n_datapoints) - super().__init__( - data=sizes, property_name=property_name - ) + super().__init__(data=sizes, property_name=property_name) def _fix_sizes( self, diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index c53520eac..9a1486f43 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -227,7 +227,11 @@ def data(self, data): 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.map = pygfx.TextureMap( + self._cmap.texture, + filter=self._cmap_interpolation.value, + wrap="clamp-to-edge", + ) self._material.clim = quick_min_max(self.data.value) diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index d569de783..bd4eea343 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -86,16 +86,12 @@ def __init__( if isinstance(positions, VertexPositions): self._positions = positions else: - self._positions = VertexPositions( - positions, property_name="positions" - ) + self._positions = VertexPositions(positions, property_name="positions") if isinstance(positions, MeshIndices): self._indices = indices else: - self._indices = MeshIndices( - indices, property_name="indices" - ) + self._indices = MeshIndices(indices, property_name="indices") self._cmap = MeshCmap(cmap) From 734be0ebec4e4e148feb3fd257cd1989f25f3d37 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 13 Jan 2026 02:30:35 -0500 Subject: [PATCH 13/58] buffer resize condition checked only if new value is an array --- fastplotlib/graphics/image.py | 64 ++++++++++++++-------------- fastplotlib/graphics/image_volume.py | 21 ++++----- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 9a1486f43..291128f04 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 @@ -212,37 +213,38 @@ def data(self) -> TextureArray: @data.setter def data(self, data): - # 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 + 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 diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 2bc4c4b5e..c41c19eab 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -273,19 +273,20 @@ def data(self) -> TextureArrayVolume: @data.setter def data(self, data): - # check if a new buffer is required - if self._data.value.shape != data.shape: - # create new TextureArray - self._data = TextureArrayVolume(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() + # clear image tiles + self.world_object.clear() - # create new tiles - for tile in self._create_tiles(): - self.world_object.add(tile) + # create new tiles + for tile in self._create_tiles(): + self.world_object.add(tile) - return + return self._data[:] = data From 7974436bddea58a04d2b2ce22aa2c7a88b3a732e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 13 Jan 2026 18:45:03 -0500 Subject: [PATCH 14/58] gc for buffer managers --- fastplotlib/graphics/features/_positions.py | 10 +++++++++- fastplotlib/graphics/features/_scatter.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 9a6e87a73..df26d92c2 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -67,8 +67,13 @@ def set_value( if isinstance(value, (np.ndarray, list, tuple)): # check if the number of elements matches current buffer size if self.buffer.data.shape[0] != len(value): - # create new buffer + # parse the new colors new_colors = parse_colors(value, len(value)) + + # destroy old buffer + self._buffer._wgpu_object.destroy() + + # create new buffer self._buffer = pygfx.Buffer(new_colors) graphic.world_object.geometry.colors = self.buffer @@ -304,6 +309,9 @@ def set_value(self, graphic, value): bdata = np.empty(value.shape, dtype=np.float32) bdata[:] = value[:] + # destroy old buffer + self._buffer._wgpu_object.destroy() + # create the new buffer self._buffer = pygfx.Buffer(bdata) graphic.world_object.geometry.positions = self.buffer diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index d79722ef7..aef57c69e 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -208,6 +208,11 @@ def set_value(self, graphic, value): markers_int_array, self._markers_readable_array = parse_markers_init( value, len(value) ) + + # destroy old buffer + self._buffer._wgpu_object.destroy() + + # set new buffer self._buffer = pygfx.Buffer(markers_int_array) graphic.geometry.markers = self.buffer @@ -475,6 +480,11 @@ def set_value(self, graphic, value): # need to create a new buffer value = self._fix_rotations(value, len(value)) data = np.empty(shape=(len(value),), dtype=np.float32) + + # destroy old buffer + self._buffer._wgpu_object.destroy() + + # set new buffer self._buffer = pygfx.Buffer(data) graphic.world_object.geometry.rotations = self.buffer self._emit_event(self._property_name, key=slice(None), value=value) @@ -565,8 +575,14 @@ 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) + + # destroy old buffer + self._buffer._wgpu_object.destroy() + + # set new buffer self._buffer = pygfx.Buffer(data) graphic.geometry.sizes = self.buffer + self._emit_event(self._property_name, key=slice(None), value=value) return From fe749ce3587b5ca007d84d7fd29838f7031145ad Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 14 Jan 2026 01:35:17 -0500 Subject: [PATCH 15/58] uniform colors WIP --- examples/gridplot/multigraphic_gridplot.py | 2 +- examples/line/line.py | 4 +- examples/line/line_cmap.py | 6 ++- examples/line/line_cmap_more.py | 30 +++++++++++-- examples/line/line_colorslice.py | 6 ++- examples/line/line_dataslice.py | 4 +- .../line_collection_slicing.py | 1 + examples/scatter/scatter.py | 2 +- examples/scatter/scatter_cmap.py | 2 +- fastplotlib/graphics/_positions_base.py | 6 +-- fastplotlib/graphics/features/_positions.py | 21 ++++++++-- fastplotlib/graphics/features/_scatter.py | 9 ++-- fastplotlib/graphics/line.py | 9 ++-- fastplotlib/graphics/line_collection.py | 4 +- fastplotlib/graphics/scatter.py | 33 ++++++++------- fastplotlib/layouts/_graphic_methods_mixin.py | 42 ++++++++++--------- 16 files changed, 118 insertions(+), 63 deletions(-) diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py index cbf546e2a..2ccb3a6e0 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", uniform_color=False) figure["scatter"].add_scatter(data=gaussian_cloud2, colors="r", sizes=2) figure.show() diff --git a/examples/line/line.py b/examples/line/line.py index f7839a1c4..e388adb21 100644 --- a/examples/line/line.py +++ b/examples/line/line.py @@ -30,11 +30,11 @@ sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta") # you can also use colormaps for lines! -cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn") +cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn", uniform_color=False) # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors) +sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors, uniform_color=False) figure[0, 0].axes.grids.xy.visible = True figure.show() diff --git a/examples/line/line_cmap.py b/examples/line/line_cmap.py index 3d2b5e8c9..9e07a5aeb 100644 --- a/examples/line/line_cmap.py +++ b/examples/line/line_cmap.py @@ -27,7 +27,8 @@ data=sine_data, thickness=10, cmap="plasma", - cmap_transform=sine_data[:, 1] + cmap_transform=sine_data[:, 1], + uniform_color=False, ) # qualitative colormaps, useful for cluster labels or other types of categorical labels @@ -36,7 +37,8 @@ data=cosine_data, thickness=10, cmap="tab10", - cmap_transform=labels + cmap_transform=labels, + uniform_color=False, ) figure.show() diff --git a/examples/line/line_cmap_more.py b/examples/line/line_cmap_more.py index c7c0d80f4..cb3b22808 100644 --- a/examples/line/line_cmap_more.py +++ b/examples/line/line_cmap_more.py @@ -26,21 +26,43 @@ line0 = figure[0, 0].add_line(sine, thickness=10) # set colormap along line datapoints, use an offset to place it above the previous line -line1 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", offset=(0, 2, 0)) +line1 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", uniform_color=False, offset=(0, 2, 0)) # 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], + uniform_color=False, + 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], + uniform_color=False, + 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, + uniform_color=False, + 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..2a74c73cf 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", + uniform_color=False, # initialize with same color across vertices, but we will change the per-vertex colors later ) # you can also use colormaps for lines! @@ -38,6 +39,7 @@ data=cosine_data, thickness=12, cmap="autumn", + uniform_color=False, offset=(0, 3, 0) # places the graphic at a y-axis offset of 3, offsets don't affect data ) @@ -47,6 +49,7 @@ data=sinc_data, thickness=5, colors=colors, + uniform_color=False, offset=(0, 6, 0) ) @@ -56,6 +59,7 @@ data=zeros_data, thickness=8, colors="w", + uniform_color=False, # initialize with same color across vertices, but we will change the per-vertex colors later offset=(0, 10, 0) ) diff --git a/examples/line/line_dataslice.py b/examples/line/line_dataslice.py index 6ef9d0d90..def3f678f 100644 --- a/examples/line/line_dataslice.py +++ b/examples/line/line_dataslice.py @@ -30,11 +30,11 @@ sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta") # you can also use colormaps for lines! -cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn") +cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn", uniform_color=False) # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors) +sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors, uniform_color=False) figure.show() diff --git a/examples/line_collection/line_collection_slicing.py b/examples/line_collection/line_collection_slicing.py index f829a53c6..022ca094a 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, + uniform_color=False, metadatas=list(range(15)), # some metadata names=list("abcdefghijklmno"), # unique name for each line ) diff --git a/examples/scatter/scatter.py b/examples/scatter/scatter.py index 838199ecb..973a55512 100644 --- a/examples/scatter/scatter.py +++ b/examples/scatter/scatter.py @@ -36,7 +36,7 @@ colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points # use an alpha value since this will be a lot of points -figure[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) +figure[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, uniform_color=False, alpha=0.6) figure.show() diff --git a/examples/scatter/scatter_cmap.py b/examples/scatter/scatter_cmap.py index 3c7bd0e21..24f5e00ab 100644 --- a/examples/scatter/scatter_cmap.py +++ b/examples/scatter/scatter_cmap.py @@ -36,7 +36,7 @@ colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points # use an alpha value since this will be a lot of points -figure[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) +figure[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, uniform_color=False, alpha=0.6) figure.show() diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index a341b4077..1b639a127 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -71,7 +71,7 @@ def __init__( self, data: Any, colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", - uniform_color: bool = False, + uniform_color: bool = True, cmap: str | VertexCmap = None, cmap_transform: np.ndarray = None, size_space: str = "screen", @@ -89,7 +89,7 @@ def __init__( 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") + raise TypeError("Cannot use `cmap` if `uniform_color=True`, pass `uniform_color=False` to use `cmap`.") if isinstance(cmap, str): # make colors from cmap @@ -127,7 +127,7 @@ def __init__( 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`" + "Must pass `uniform_colors=False` if using multiple colors" ) self._colors = UniformColor(colors) self._cmap = None diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index df26d92c2..1187b4c88 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -65,13 +65,27 @@ def set_value( ): """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 self.buffer.data.shape[0] != len(value): + if not skip and self.buffer.data.shape[0] != len(value): # parse the new colors new_colors = parse_colors(value, len(value)) # destroy old buffer - self._buffer._wgpu_object.destroy() + if self._buffer._wgpu_object is not None: + self._buffer._wgpu_object.destroy() # create new buffer self._buffer = pygfx.Buffer(new_colors) @@ -310,7 +324,8 @@ def set_value(self, graphic, value): bdata[:] = value[:] # destroy old buffer - self._buffer._wgpu_object.destroy() + if self._buffer._wgpu_object is not None: + self._buffer._wgpu_object.destroy() # create the new buffer self._buffer = pygfx.Buffer(bdata) diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index aef57c69e..ce10fe4a8 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -210,7 +210,8 @@ def set_value(self, graphic, value): ) # destroy old buffer - self._buffer._wgpu_object.destroy() + if self._buffer._wgpu_object is not None: + self._buffer._wgpu_object.destroy() # set new buffer self._buffer = pygfx.Buffer(markers_int_array) @@ -482,7 +483,8 @@ def set_value(self, graphic, value): data = np.empty(shape=(len(value),), dtype=np.float32) # destroy old buffer - self._buffer._wgpu_object.destroy() + if self._buffer._wgpu_object is not None: + self._buffer._wgpu_object.destroy() # set new buffer self._buffer = pygfx.Buffer(data) @@ -577,7 +579,8 @@ def set_value(self, graphic, value): data = np.empty(shape=(len(value),), dtype=np.float32) # destroy old buffer - self._buffer._wgpu_object.destroy() + if self._buffer._wgpu_object is not None: + self._buffer._wgpu_object.destroy() # set new buffer self._buffer = pygfx.Buffer(data) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index ed7a95490..93d1aa4c1 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -36,7 +36,7 @@ def __init__( data: Any, thickness: float = 2.0, colors: str | np.ndarray | Sequence = "w", - uniform_color: bool = False, + uniform_color: bool = True, cmap: str = None, cmap_transform: np.ndarray | Sequence = None, size_space: str = "screen", @@ -60,9 +60,10 @@ 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 + uniform_color: bool, default ``True`` + if ``True``, uses a uniform buffer for the line color, + basically saves GPU VRAM when the entire line has a single color. + If ``False``, you can set per-vertex colors. cmap: str, optional Apply a colormap to the line instead of assigning colors manually, this diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 2087ada62..8ef1c4d8e 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -128,7 +128,7 @@ 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, + uniform_color: bool = True, cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, name: str = None, @@ -319,7 +319,7 @@ def __init__( data=d, thickness=_s, colors=_c, - uniform_color=uniform_colors, + uniform_color=uniform_color, cmap=_cmap, name=_name, metadata=_m, diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 3f9a530ef..0464fd528 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, + uniform_color: bool = True, cmap: str = None, cmap_transform: np.ndarray = None, 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, @@ -71,14 +71,15 @@ 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. + uniform_color: bool, default ``True`` + 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. If ``False``, you can set per-vertex colors. 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 @@ -102,9 +103,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. @@ -124,8 +126,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". @@ -146,9 +149,9 @@ 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") diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 2d8aa2eab..b6610f456 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -261,7 +261,7 @@ def add_line( data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Sequence] = "w", - uniform_color: bool = False, + uniform_color: bool = True, cmap: str = None, cmap_transform: Union[numpy.ndarray, Sequence] = None, size_space: str = "screen", @@ -286,9 +286,10 @@ 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 + uniform_color: bool, default ``True`` + if ``True``, uses a uniform buffer for the line color, + basically saves GPU VRAM when the entire line has a single color. + If ``False``, you can set per-vertex colors. cmap: str, optional Apply a colormap to the line instead of assigning colors manually, this @@ -545,12 +546,12 @@ def add_scatter( self, data: Any, colors: Union[str, numpy.ndarray, Sequence[float], Sequence[str]] = "w", - uniform_color: bool = False, + uniform_color: bool = True, cmap: str = None, cmap_transform: numpy.ndarray = None, 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] @@ -579,14 +580,15 @@ 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. + uniform_color: bool, default ``True`` + 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. If ``False``, you can set per-vertex colors. 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 @@ -610,9 +612,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. @@ -632,8 +635,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". @@ -654,9 +658,9 @@ 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") From 074669b084068784bed7a5f54e6cfe014ea0abf5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 25 Jan 2026 23:58:14 -0500 Subject: [PATCH 16/58] 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 17/58] 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 18/58] 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 19/58] 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 20/58] 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 21/58] 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 22/58] 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 23/58] 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 24/58] 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 25/58] 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 26/58] 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 27/58] 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 b8f86133e3b4cd3fb34c5cb87d124e83d43f478c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 01:42:40 -0500 Subject: [PATCH 28/58] switching color modes works! --- fastplotlib/graphics/_positions_base.py | 158 ++++++++++++++---- fastplotlib/graphics/line.py | 11 +- fastplotlib/graphics/line_collection.py | 2 - fastplotlib/graphics/scatter.py | 8 +- fastplotlib/layouts/_graphic_methods_mixin.py | 15 -- 5 files changed, 132 insertions(+), 62 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 6b2602948..b1ee55fc4 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 @@ -38,6 +40,60 @@ def colors(self) -> VertexColors | pygfx.Color: 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.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._cmap = VertexCmap( + self._colors, cmap_name=None, transform=None + ) + + self.world_object.material.color_mode = mode + @property def cmap(self) -> VertexCmap: """ @@ -49,8 +105,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 @@ -67,13 +123,64 @@ 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 = True, cmap: str | VertexCmap = None, cmap_transform: np.ndarray = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", *args, **kwargs, @@ -86,17 +193,24 @@ def __init__( 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`, pass `uniform_color=False` to use `cmap`.") + 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]) @@ -110,34 +224,20 @@ 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._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 `uniform_colors=False` if using multiple colors" - ) - 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._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 + ) self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 93d1aa4c1..120997796 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -17,7 +17,7 @@ VertexColors, UniformColor, VertexCmap, - SizeSpace, + SizeSpace, UniformRotations, ) from ..utils import quick_min_max @@ -36,7 +36,6 @@ def __init__( data: Any, thickness: float = 2.0, colors: str | np.ndarray | Sequence = "w", - uniform_color: bool = True, cmap: str = None, cmap_transform: np.ndarray | Sequence = None, size_space: str = "screen", @@ -60,11 +59,6 @@ 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 ``True`` - if ``True``, uses a uniform buffer for the line color, - basically saves GPU VRAM when the entire line has a single color. - If ``False``, you can set per-vertex colors. - 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 @@ -84,7 +78,6 @@ def __init__( super().__init__( data=data, colors=colors, - uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, size_space=size_space, @@ -101,7 +94,7 @@ def __init__( aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") - if uniform_color: + if isinstance(self._colors, UniformColor): geometry = pygfx.Geometry(positions=self._data.buffer) material = MaterialCls( aa=aa, diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 8ef1c4d8e..b766c8ea5 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -128,7 +128,6 @@ 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_color: bool = True, cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, name: str = None, @@ -319,7 +318,6 @@ def __init__( data=d, thickness=_s, colors=_c, - uniform_color=uniform_color, cmap=_cmap, name=_name, metadata=_m, diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 0464fd528..532b79177 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -40,7 +40,6 @@ def __init__( self, data: Any, colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w", - uniform_color: bool = True, cmap: str = None, cmap_transform: np.ndarray = None, mode: Literal["markers", "simple", "gaussian", "image"] = "markers", @@ -71,10 +70,6 @@ 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 ``True`` - 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. If ``False``, you can set per-vertex colors. - cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this overrides any argument passed to "colors". @@ -164,7 +159,6 @@ def __init__( super().__init__( data=data, colors=colors, - uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, size_space=size_space, @@ -271,7 +265,7 @@ def __init__( self._size_space = SizeSpace(size_space) - if uniform_color: + if isinstance(self._color, UniformColor): material_kwargs["color_mode"] = pygfx.ColorMode.uniform material_kwargs["color"] = self.colors else: diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index b6610f456..48e76b9b6 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -177,7 +177,6 @@ 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, name: str = None, @@ -245,7 +244,6 @@ def add_line_collection( data, thickness, colors, - uniform_colors, cmap, cmap_transform, name, @@ -261,7 +259,6 @@ def add_line( data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Sequence] = "w", - uniform_color: bool = True, cmap: str = None, cmap_transform: Union[numpy.ndarray, Sequence] = None, size_space: str = "screen", @@ -286,11 +283,6 @@ 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 ``True`` - if ``True``, uses a uniform buffer for the line color, - basically saves GPU VRAM when the entire line has a single color. - If ``False``, you can set per-vertex colors. - 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 @@ -312,7 +304,6 @@ def add_line( data, thickness, colors, - uniform_color, cmap, cmap_transform, size_space, @@ -546,7 +537,6 @@ def add_scatter( self, data: Any, colors: Union[str, numpy.ndarray, Sequence[float], Sequence[str]] = "w", - uniform_color: bool = True, cmap: str = None, cmap_transform: numpy.ndarray = None, mode: Literal["markers", "simple", "gaussian", "image"] = "markers", @@ -580,10 +570,6 @@ 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 ``True`` - 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. If ``False``, you can set per-vertex colors. - cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this overrides any argument passed to "colors". @@ -674,7 +660,6 @@ def add_scatter( ScatterGraphic, data, colors, - uniform_color, cmap, cmap_transform, mode, From 543d4e9c5fa32a6a6702a4671c5efe54f5a7c013 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 01:48:36 -0500 Subject: [PATCH 29/58] typo --- fastplotlib/graphics/scatter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 532b79177..6e0791c58 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -265,7 +265,7 @@ def __init__( self._size_space = SizeSpace(size_space) - if isinstance(self._color, UniformColor): + if isinstance(self._colors, UniformColor): material_kwargs["color_mode"] = pygfx.ColorMode.uniform material_kwargs["color"] = self.colors else: From 8a1c15aa9598c85301e2adf97647ae20d463cf2d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 01:51:48 -0500 Subject: [PATCH 30/58] balck --- fastplotlib/graphics/_positions_base.py | 20 +++++++++++--------- fastplotlib/graphics/features/_positions.py | 9 +++++++-- fastplotlib/graphics/line.py | 3 ++- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index b1ee55fc4..6c49afd8a 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -88,9 +88,7 @@ def color_mode(self, mode: Literal["uniform", "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 - ) + self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) self.world_object.material.color_mode = mode @@ -142,12 +140,16 @@ def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColo 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)): + 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]) + 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 @@ -204,7 +206,9 @@ def __init__( 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'") + raise ValueError( + "if a `cmap` is provided, `color_mode` must be 'vertex' or 'auto', not 'uniform'" + ) if isinstance(cmap, str): # make colors from cmap @@ -235,9 +239,7 @@ def __init__( # 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._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/_positions.py b/fastplotlib/graphics/features/_positions.py index 1187b4c88..991f539b8 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -70,11 +70,16 @@ def set_value( # 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)): + 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]): + if len(value) in (3, 4) and all( + [isinstance(v, (float, int)) for v in value] + ): # represents one color skip = True diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 120997796..f24ea2ebe 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -17,7 +17,8 @@ VertexColors, UniformColor, VertexCmap, - SizeSpace, UniformRotations, + SizeSpace, + UniformRotations, ) from ..utils import quick_min_max From fdeecb150c253366e5f36d079cde392be6075d4d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 02:02:11 -0500 Subject: [PATCH 31/58] update tests for color_mode --- tests/test_positions_graphics.py | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/test_positions_graphics.py b/tests/test_positions_graphics.py index 31c001888..c686c1313 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", ["auto", "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] @@ -179,7 +179,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 +187,7 @@ def test_positions_graphic_vertex_colors( def test_cmap( graphic_type, colors, - uniform_color, + color_mode, cmap, cmap_transform, ): @@ -195,7 +195,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] @@ -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,10 +301,10 @@ 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) From 6299cdd45ede0973594c67d5460bed24b6aa9190 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 02:13:49 -0500 Subject: [PATCH 32/58] update examples --- examples/misc/scatter_animation.py | 2 +- examples/misc/scatter_sizes_animation.py | 2 +- examples/scatter/scatter_cmap.py | 2 +- examples/scatter/scatter_iris.py | 1 + examples/scatter/scatter_size.py | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) 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/scatter/scatter_cmap.py b/examples/scatter/scatter_cmap.py index 24f5e00ab..3c7bd0e21 100644 --- a/examples/scatter/scatter_cmap.py +++ b/examples/scatter/scatter_cmap.py @@ -36,7 +36,7 @@ colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points # use an alpha value since this will be a lot of points -figure[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, uniform_color=False, alpha=0.6) +figure[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) figure.show() 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) From 8b217cc658519f10e7d43bc020725b95fd8e982b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 02:22:52 -0500 Subject: [PATCH 33/58] backend tests passing --- tests/test_colors_buffer_manager.py | 12 ++++++------ tests/test_markers_buffer_manager.py | 6 +++--- tests/test_positions_graphics.py | 9 ++++----- 3 files changed, 13 insertions(+), 14 deletions(-) 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..101b383db 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) + 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 @@ -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_positions_graphics.py b/tests/test_positions_graphics.py index c686c1313..00f4ccb46 100644 --- a/tests/test_positions_graphics.py +++ b/tests/test_positions_graphics.py @@ -130,7 +130,7 @@ 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", ["auto", "vertex"]) +@pytest.mark.parametrize("color_mode", ["vertex"]) def test_positions_graphic_vertex_colors( graphic_type, colors, @@ -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 From 5fd68e2d28932144cf3dcda687007d3bf0165d7b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 02:23:07 -0500 Subject: [PATCH 34/58] default for all uniforms is True --- fastplotlib/graphics/scatter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 6e0791c58..3e9496b17 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -53,7 +53,7 @@ def __init__( point_rotations: float | np.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: float | np.ndarray | Sequence[float] = 1, - uniform_size: bool = False, + uniform_size: bool = True, size_space: str = "screen", **kwargs, ): From e1d40ba359a48e604a468604132caf57c68ab0f4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 15:02:17 -0500 Subject: [PATCH 35/58] update examples --- examples/gridplot/multigraphic_gridplot.py | 2 +- examples/guis/imgui_basic.py | 4 +- examples/line/line.py | 4 +- examples/line/line_cmap.py | 2 - examples/line/line_cmap_more.py | 5 +- examples/line/line_colorslice.py | 6 +-- examples/line/line_dataslice.py | 4 +- .../line_collection_slicing.py | 2 +- examples/scatter/scatter.py | 2 +- fastplotlib/graphics/line.py | 1 + fastplotlib/graphics/line_collection.py | 2 + fastplotlib/graphics/scatter.py | 2 + fastplotlib/layouts/_graphic_methods_mixin.py | 48 +++++++++++-------- 13 files changed, 44 insertions(+), 40 deletions(-) diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py index 2ccb3a6e0..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", uniform_color=False) +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/line/line.py b/examples/line/line.py index e388adb21..f7839a1c4 100644 --- a/examples/line/line.py +++ b/examples/line/line.py @@ -30,11 +30,11 @@ sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta") # you can also use colormaps for lines! -cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn", uniform_color=False) +cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn") # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors, uniform_color=False) +sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors) figure[0, 0].axes.grids.xy.visible = True figure.show() diff --git a/examples/line/line_cmap.py b/examples/line/line_cmap.py index 9e07a5aeb..6dfc1fe23 100644 --- a/examples/line/line_cmap.py +++ b/examples/line/line_cmap.py @@ -28,7 +28,6 @@ thickness=10, cmap="plasma", cmap_transform=sine_data[:, 1], - uniform_color=False, ) # qualitative colormaps, useful for cluster labels or other types of categorical labels @@ -38,7 +37,6 @@ thickness=10, cmap="tab10", cmap_transform=labels, - uniform_color=False, ) figure.show() diff --git a/examples/line/line_cmap_more.py b/examples/line/line_cmap_more.py index cb3b22808..c6e811fb2 100644 --- a/examples/line/line_cmap_more.py +++ b/examples/line/line_cmap_more.py @@ -26,7 +26,7 @@ line0 = figure[0, 0].add_line(sine, thickness=10) # set colormap along line datapoints, use an offset to place it above the previous line -line1 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", uniform_color=False, offset=(0, 2, 0)) +line1 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", offset=(0, 2, 0)) # set colormap by mapping data using a transform # here we map the color using the y-values of the sine data @@ -36,7 +36,6 @@ thickness=10, cmap="jet", cmap_transform=sine[:, 1], - uniform_color=False, offset=(0, 4, 0), ) @@ -46,7 +45,6 @@ thickness=10, cmap="jet", cmap_transform=cosine[:, 1], - uniform_color=False, offset=(0, 6, 0) ) @@ -60,7 +58,6 @@ thickness=10, cmap="tab10", cmap_transform=labels, - uniform_color=False, offset=(0, 8, 0), ) diff --git a/examples/line/line_colorslice.py b/examples/line/line_colorslice.py index 2a74c73cf..264f944f3 100644 --- a/examples/line/line_colorslice.py +++ b/examples/line/line_colorslice.py @@ -31,7 +31,7 @@ data=sine_data, thickness=5, colors="magenta", - uniform_color=False, # initialize with same color across vertices, but we will change the per-vertex colors later + 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! @@ -39,7 +39,6 @@ data=cosine_data, thickness=12, cmap="autumn", - uniform_color=False, offset=(0, 3, 0) # places the graphic at a y-axis offset of 3, offsets don't affect data ) @@ -49,7 +48,6 @@ data=sinc_data, thickness=5, colors=colors, - uniform_color=False, offset=(0, 6, 0) ) @@ -59,7 +57,7 @@ data=zeros_data, thickness=8, colors="w", - uniform_color=False, # initialize with same color across vertices, but we will change the per-vertex colors later + 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/line_dataslice.py b/examples/line/line_dataslice.py index def3f678f..6ef9d0d90 100644 --- a/examples/line/line_dataslice.py +++ b/examples/line/line_dataslice.py @@ -30,11 +30,11 @@ sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta") # you can also use colormaps for lines! -cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn", uniform_color=False) +cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn") # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors, uniform_color=False) +sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors) figure.show() diff --git a/examples/line_collection/line_collection_slicing.py b/examples/line_collection/line_collection_slicing.py index 022ca094a..98ad97056 100644 --- a/examples/line_collection/line_collection_slicing.py +++ b/examples/line_collection/line_collection_slicing.py @@ -26,7 +26,7 @@ multi_data, thickness=[2, 10, 2, 5, 5, 5, 8, 8, 8, 9, 3, 3, 3, 4, 4], separation=4, - uniform_color=False, + 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/scatter/scatter.py b/examples/scatter/scatter.py index 973a55512..838199ecb 100644 --- a/examples/scatter/scatter.py +++ b/examples/scatter/scatter.py @@ -36,7 +36,7 @@ colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points # use an alpha value since this will be a lot of points -figure[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, uniform_color=False, alpha=0.6) +figure[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) figure.show() diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index f24ea2ebe..93ac3516f 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -39,6 +39,7 @@ def __init__( colors: str | np.ndarray | Sequence = "w", cmap: str = None, cmap_transform: np.ndarray | Sequence = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", **kwargs, ): diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index b766c8ea5..51c863870 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -130,6 +130,7 @@ def __init__( 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, @@ -319,6 +320,7 @@ def __init__( thickness=_s, colors=_c, cmap=_cmap, + color_mode=color_mode, name=_name, metadata=_m, **kwargs_lines, diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 3e9496b17..c76bdacef 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -42,6 +42,7 @@ def __init__( colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w", 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 = True, @@ -161,6 +162,7 @@ def __init__( colors=colors, cmap=cmap, cmap_transform=cmap_transform, + color_mode=color_mode, size_space=size_space, **kwargs, ) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 48e76b9b6..637dd3bba 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( @@ -179,12 +179,13 @@ def add_line_collection( 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, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -246,12 +247,13 @@ def add_line_collection( colors, cmap, cmap_transform, + color_mode, name, names, metadata, metadatas, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -261,8 +263,9 @@ def add_line( colors: Union[str, numpy.ndarray, Sequence] = "w", cmap: str = None, cmap_transform: Union[numpy.ndarray, Sequence] = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -306,8 +309,9 @@ def add_line( colors, cmap, cmap_transform, + color_mode, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -324,7 +328,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -400,7 +404,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_mesh( @@ -419,7 +423,7 @@ def add_mesh( | numpy.ndarray ) = None, clim: tuple[float, float] = None, - **kwargs, + **kwargs ) -> MeshGraphic: """ @@ -473,7 +477,7 @@ def add_mesh( mapcoords, cmap, clim, - **kwargs, + **kwargs ) def add_polygon( @@ -490,7 +494,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> PolygonGraphic: """ @@ -539,6 +543,7 @@ def add_scatter( 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", mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: Union[str, numpy.ndarray, Sequence[str]] = "o", uniform_marker: bool = True, @@ -552,9 +557,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]] = 1, - uniform_size: bool = False, + uniform_size: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -662,6 +667,7 @@ def add_scatter( colors, cmap, cmap_transform, + color_mode, mode, markers, uniform_marker, @@ -675,7 +681,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs, + **kwargs ) def add_surface( @@ -692,7 +698,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> SurfaceGraphic: """ @@ -746,7 +752,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -797,7 +803,7 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) def add_vectors( @@ -807,7 +813,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, + **kwargs ) -> VectorsGraphic: """ @@ -852,5 +858,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs, + **kwargs ) From 096a51cd942932202cf2be880cd2ac9a751f9fb6 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 15:53:49 -0500 Subject: [PATCH 36/58] forgot --- fastplotlib/graphics/line.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 93ac3516f..e71a554bc 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -82,6 +82,7 @@ def __init__( colors=colors, cmap=cmap, cmap_transform=cmap_transform, + color_mode=color_mode, size_space=size_space, **kwargs, ) From 274d449a5d5e8d7f2073bb7a7b3d93858e99769d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 16:38:10 -0500 Subject: [PATCH 37/58] update test --- tests/test_positions_graphics.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_positions_graphics.py b/tests/test_positions_graphics.py index 00f4ccb46..d0b974f6e 100644 --- a/tests/test_positions_graphics.py +++ b/tests/test_positions_graphics.py @@ -308,8 +308,7 @@ def test_incompatible_color_args(graphic_type, colors, color_mode): @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() @@ -321,7 +320,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) From 8d776d436e223562c7dfba8d558e6200d00aebb1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 2 Feb 2026 16:56:21 -0500 Subject: [PATCH 38/58] example tests passing --- examples/events/cmap_event.py | 2 +- examples/misc/lorenz_animation.py | 7 ++++++- examples/scatter/scatter_validate.py | 2 ++ examples/scatter/spinning_spiral.py | 9 ++++++++- 4 files changed, 17 insertions(+), 3 deletions(-) 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/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/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)) From fce70870aa19df1fbf4cbe69c47e85d91b3bd1b3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 00:08:00 -0500 Subject: [PATCH 39/58] dereferencing test and fixes --- fastplotlib/graphics/features/_base.py | 23 +++--- fastplotlib/graphics/features/_positions.py | 6 +- fastplotlib/graphics/features/_scatter.py | 4 +- fastplotlib/graphics/scatter.py | 25 ++---- tests/test_replace_buffer.py | 84 +++++++++++++++++++++ 5 files changed, 109 insertions(+), 33 deletions(-) create mode 100644 tests/test_replace_buffer.py diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 05b9da6d7..82c740b59 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -137,21 +137,24 @@ class BufferManager(GraphicFeature): def __init__( self, - data: NDArray | pygfx.Buffer, + data: NDArray | pygfx.Buffer | None, **kwargs, ): super().__init__(**kwargs) - if isinstance(data, pygfx.Resource): - # already a buffer, probably used for - # managing another BufferManager, example: VertexCmap manages VertexColors - self._buffer = data - else: - # create a buffer - bdata = np.empty(data.shape, dtype=data.dtype) - bdata[:] = data[:] + # 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._buffer = data + else: + # create a buffer + bdata = np.empty(data.shape, dtype=data.dtype) + bdata[:] = data[:] - self._buffer = pygfx.Buffer(bdata) + self._buffer = pygfx.Buffer(bdata) self._event_handlers: list[Callable] = list() diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 991f539b8..9dda94894 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -385,7 +385,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 @@ -410,6 +410,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 ce10fe4a8..b6e9ac449 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -215,7 +215,7 @@ def set_value(self, graphic, value): # set new buffer self._buffer = pygfx.Buffer(markers_int_array) - graphic.geometry.markers = self.buffer + graphic.world_object.geometry.markers = self.buffer self._emit_event(self._property_name, key=slice(None), value=value) @@ -584,7 +584,7 @@ def set_value(self, graphic, value): # set new buffer self._buffer = pygfx.Buffer(data) - graphic.geometry.sizes = self.buffer + graphic.world_object.geometry.sizes = self.buffer self._emit_event(self._property_name, key=slice(None), value=value) return diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index c76bdacef..ec231ca1f 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -331,10 +331,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: @@ -352,12 +350,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: @@ -399,11 +392,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: @@ -430,8 +419,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/tests/test_replace_buffer.py b/tests/test_replace_buffer.py new file mode 100644 index 000000000..1693927e9 --- /dev/null +++ b/tests/test_replace_buffer.py @@ -0,0 +1,84 @@ +import gc +import weakref + +import pytest +import numpy as np + +import fastplotlib as fpl +fpl.select_adapter(fpl.enumerate_adapters()[1]) + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("new_buffer_size", [50, 150]) +def test_replace_buffer(graphic_type, new_buffer_size): + fig = fpl.Figure() + + 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]) + + 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, + "point_rotations": zs * 180, + "point_rotation_mode": "vertex", + } + else: + kwargs = dict() + + graphic = adder( + data=data, + colors=np.random.rand(orig_datapoints, 4), + **kwargs + ) + + del data + del xs + del ys + del zs + del kwargs + + fig.show() + + orig_data_buffer = weakref.proxy(graphic.data.buffer) + orig_colors_buffer = weakref.proxy(graphic.colors.buffer) + + buffers = [orig_data_buffer, orig_colors_buffer] + + if graphic_type == "scatter": + for attr in ["markers", "sizes", "point_rotations"]: + buffers.append(weakref.proxy(getattr(graphic, attr).buffer)) + + # create some new data + 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 + + 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__)}" + ) From 44fdbb96c4d1ce1da4ca74e67fe0ed7f2717af30 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 18:55:27 -0500 Subject: [PATCH 40/58] simplify texture array tests a bit --- tests/test_texture_array.py | 134 +++++++++++++++--------------------- 1 file changed, 55 insertions(+), 79 deletions(-) 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: From d090dca870e768af9e4cce4dc25d1a381f809f99 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 18:57:47 -0500 Subject: [PATCH 41/58] image replace buffer tests pass yay --- tests/test_replace_buffer.py | 74 ++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/tests/test_replace_buffer.py b/tests/test_replace_buffer.py index 1693927e9..ce9f8474a 100644 --- a/tests/test_replace_buffer.py +++ b/tests/test_replace_buffer.py @@ -3,14 +3,23 @@ import pytest import numpy as np +from itertools import product import fastplotlib as fpl -fpl.select_adapter(fpl.enumerate_adapters()[1]) +from .test_texture_array import ( + MAX_TEXTURE_SIZE, +) +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_buffer(graphic_type, new_buffer_size): +def test_replace_positions_buffer(graphic_type, new_buffer_size): fig = fpl.Figure() orig_datapoints = 100 @@ -35,11 +44,7 @@ def test_replace_buffer(graphic_type, new_buffer_size): else: kwargs = dict() - graphic = adder( - data=data, - colors=np.random.rand(orig_datapoints, 4), - **kwargs - ) + graphic = adder(data=data, colors=np.random.rand(orig_datapoints, 4), **kwargs) del data del xs @@ -82,3 +87,58 @@ def test_replace_buffer(graphic_type, new_buffer_size): 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): + + # should + orig_size = (1_500, 1_500) + + data = np.random.rand(*orig_size) + + fig = fpl.Figure() + image = fig[0, 0].add_image(data) + + 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() + + new_dims = [v[0] for v in new_buffer_size] + new_shape = tuple(v[1] for v in new_buffer_size) + + new_data = np.random.rand(*new_dims) + image.data = new_data + + # test that old buffer is 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) + ] + ), + ) From d6754d01512aaf2927deec7534a889ba3d8a1d8b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 19:06:49 -0500 Subject: [PATCH 42/58] forgot a file --- tests/utils_textures.py | 64 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/utils_textures.py 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 83b924ee6037f654f184cecb991f8f0043df45c4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 19:14:03 -0500 Subject: [PATCH 43/58] comments, check image graphic --- tests/test_replace_buffer.py | 43 ++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/tests/test_replace_buffer.py b/tests/test_replace_buffer.py index ce9f8474a..7afd0ec8a 100644 --- a/tests/test_replace_buffer.py +++ b/tests/test_replace_buffer.py @@ -6,12 +6,8 @@ from itertools import product import fastplotlib as fpl -from .test_texture_array import ( - MAX_TEXTURE_SIZE, -) 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 @@ -22,6 +18,7 @@ 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) @@ -30,6 +27,7 @@ def test_replace_positions_buffer(graphic_type, new_buffer_size): 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": @@ -38,32 +36,32 @@ def test_replace_positions_buffer(graphic_type, new_buffer_size): "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) - del data - del xs - del ys - del zs - del 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.buffer) orig_colors_buffer = weakref.proxy(graphic.colors.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).buffer)) - # create some new data + # 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) @@ -80,6 +78,7 @@ def test_replace_positions_buffer(graphic_type, 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] @@ -90,10 +89,11 @@ def test_replace_positions_buffer(graphic_type, new_buffer_size): # 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))) +@pytest.mark.parametrize( + "new_buffer_size", list(product(*[[(500, 1), (1200, 2), (2200, 3)]] * 2)) +) def test_replace_image_buffer(new_buffer_size): - - # should + # make an image with some starting shape orig_size = (1_500, 1_500) data = np.random.rand(*orig_size) @@ -101,18 +101,26 @@ def test_replace_image_buffer(new_buffer_size): fig = fpl.Figure() image = fig[0, 0].add_image(data) - orig_buffers = [weakref.proxy(image.data.buffer.ravel()[i]) for i in range(image.data.buffer.size)] + # 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 buffer is de-referenced + # test that old Texture buffers are de-referenced for i in range(len(orig_buffers)): with pytest.raises(ReferenceError) as fail: orig_buffers[i] @@ -142,3 +150,6 @@ def test_replace_image_buffer(new_buffer_size): ] ), ) + + # check that new image tiles are arranged correctly + check_image_graphic(image.data, image) From 2237d51300a69846e67c6850fc7e6b5dd5857825 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 19:37:50 -0500 Subject: [PATCH 44/58] add image reshaping example --- examples/image/image_reshaping.py | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 examples/image/image_reshaping.py diff --git a/examples/image/image_reshaping.py b/examples/image/image_reshaping.py new file mode 100644 index 000000000..6f90742ff --- /dev/null +++ b/examples/image/image_reshaping.py @@ -0,0 +1,48 @@ +""" +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 4s' + + +import numpy as np +import fastplotlib as fpl + + +xs = np.linspace(0, 2300, 2300, dtype=np.float16) + +sine = np.sin(np.sqrt(xs)) + +full_data = np.vstack([sine * i for i in range(2_300)]) + + +fig = fpl.Figure() + +image = fig[0, 0].add_image(full_data) + +fig.show() + +i, j = 1, 1 +def update(): + global i, j + row = np.abs(np.sin(i)) * 2300 + col = np.abs(np.cos(i)) * 2300 + image.data = full_data[:int(row), :int(col)] + + i += 0.1 + j += 0.1 + + +fig.add_animations(update) + + +fpl.loop.run() From 630435e4d89e0e77cef6c83485f26a5cf241d274 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 19:38:40 -0500 Subject: [PATCH 45/58] add buffer replace imgui thing for manual testing --- examples/misc/buffer_replace_gc.py | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 examples/misc/buffer_replace_gc.py diff --git a/examples/misc/buffer_replace_gc.py b/examples/misc/buffer_replace_gc.py new file mode 100644 index 000000000..9b00d22ad --- /dev/null +++ b/examples/misc/buffer_replace_gc.py @@ -0,0 +1,92 @@ +""" +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, + 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) + + imgui.text(f"VRAM usage: {self.vram_usage} MB") + + 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() From a87dfdf8e54a713ca17e19279a9fe640128ac8a2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 19:38:49 -0500 Subject: [PATCH 46/58] black --- examples/image/image_reshaping.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/image/image_reshaping.py b/examples/image/image_reshaping.py index 6f90742ff..7f6d30e26 100644 --- a/examples/image/image_reshaping.py +++ b/examples/image/image_reshaping.py @@ -17,7 +17,6 @@ import numpy as np import fastplotlib as fpl - xs = np.linspace(0, 2300, 2300, dtype=np.float16) sine = np.sin(np.sqrt(xs)) @@ -32,11 +31,13 @@ fig.show() i, j = 1, 1 + + def update(): global i, j row = np.abs(np.sin(i)) * 2300 col = np.abs(np.cos(i)) * 2300 - image.data = full_data[:int(row), :int(col)] + image.data = full_data[: int(row), : int(col)] i += 0.1 j += 0.1 @@ -45,4 +46,8 @@ def update(): fig.add_animations(update) -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() From 30e87adebd40cdaddcd43fc9d84e194a9d9101c4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 21:25:17 -0500 Subject: [PATCH 47/58] dont call wgpu_obj.destroy(), seems to work and clear VRAM with normal dereferencing --- examples/misc/reshape_lines_scatters.py | 53 +++++++++++++++++++++ fastplotlib/graphics/_positions_base.py | 2 +- fastplotlib/graphics/features/_base.py | 6 ++- fastplotlib/graphics/features/_positions.py | 26 +++++----- fastplotlib/graphics/features/_scatter.py | 24 +++------- fastplotlib/graphics/line.py | 4 +- fastplotlib/graphics/scatter.py | 12 ++--- 7 files changed, 85 insertions(+), 42 deletions(-) create mode 100644 examples/misc/reshape_lines_scatters.py diff --git a/examples/misc/reshape_lines_scatters.py b/examples/misc/reshape_lines_scatters.py new file mode 100644 index 000000000..262d95652 --- /dev/null +++ b/examples/misc/reshape_lines_scatters.py @@ -0,0 +1,53 @@ +import numpy as np +import fastplotlib as fpl + +xs = np.linspace(0, 10 * np.pi, 100) +ys = np.sin(xs) + +data = np.column_stack([xs, ys]) + +fig = fpl.Figure(shape=(2, 2), size=(700, 700)) + +line = fig[0, 0].add_line(data) +text = fig[0, 0].add_text(f"n_points: {100}", offset=(0, 3, 0), anchor="middle-left") + +scatter = fig[0, 1].add_scatter( + np.random.rand(100, 3), + colors=np.random.rand(100, 4), + sizes=(np.random.rand(100) + 1) * 3, + edge_colors=np.random.rand(100, 4), + point_rotations=np.random.rand(100) * 180, + uniform_size=False, + uniform_edge_color=False, + point_rotation_mode="vertex", +) + +fig.show() + +i = 0 + + +def update(): + global i + + # update line + freq = (np.sin(i) + 1) * 5 + n_points = int((freq * 50_000) + 10) + + xs = np.linspace(0, 10 * np.pi, n_points) + ys = np.sin(xs * freq) + new_data = np.column_stack([xs, ys]) + + line.data = new_data + + # update scatter + scatter.data = np.random.rand(n_points, 3) + scatter.colors = np.random.rand(n_points, 4) + + i += 0.01 + text.text = f"n_points: {n_points}" + + +fig.add_animations(update) + +fpl.loop.run() diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 6c49afd8a..343d8dda2 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -59,7 +59,7 @@ def color_mode(self, mode: Literal["uniform", "vertex"]): 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.buffer + self.world_object.geometry.colors = new_colors._buffer elif mode == "uniform" and isinstance(self._colors, VertexColors): # vertex -> uniform diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 82c740b59..1dd070c98 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -1,3 +1,4 @@ +import weakref from warnings import warn from typing import Callable @@ -169,8 +170,9 @@ def set_value(self, graphic, value): @property def buffer(self) -> pygfx.Buffer: - """managed buffer""" - return self._buffer + """managed buffer, returns a weakref proxy""" + # the user should never create their own references to the buffer + return weakref.proxy(self._buffer) @property def __array_interface__(self): diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 9dda94894..e41404554 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -88,13 +88,9 @@ def set_value( # parse the new colors new_colors = parse_colors(value, len(value)) - # destroy old buffer - if self._buffer._wgpu_object is not None: - self._buffer._wgpu_object.destroy() - - # create new buffer + # create the new buffer, old buffer should get dereferenced self._buffer = pygfx.Buffer(new_colors) - graphic.world_object.geometry.colors = self.buffer + graphic.world_object.geometry.colors = self._buffer if len(self._event_handlers) < 1: return @@ -328,13 +324,10 @@ def set_value(self, graphic, value): bdata = np.empty(value.shape, dtype=np.float32) bdata[:] = value[:] - # destroy old buffer - if self._buffer._wgpu_object is not None: - self._buffer._wgpu_object.destroy() - - # create the new buffer + # create the new buffer, old buffer should get dereferenced self._buffer = pygfx.Buffer(bdata) - graphic.world_object.geometry.positions = self.buffer + graphic.world_object.geometry.positions = self._buffer + self._emit_event(self._property_name, key=slice(None), value=value) return @@ -347,7 +340,14 @@ def __setitem__( value: np.ndarray | float | list[float], ): # directly use the key to slice the buffer - self.buffer.data[key] = value + # if value is just 1D, assume these are y-values + if value.ndim == 1: + self.buffer.data[key, 1] = value + else: + # if value is [n, 1], this they just want to set y-values + # if value is [n, 2], this assumes they want to set xy values + # if value is [n, 3], it's xyz values + self.buffer.data[key, :value.shape[-1]] = value # _update_range handles parsing the key to # determine offset and size for GPU upload diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index b6e9ac449..8e57797d2 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -209,13 +209,9 @@ def set_value(self, graphic, value): value, len(value) ) - # destroy old buffer - if self._buffer._wgpu_object is not None: - self._buffer._wgpu_object.destroy() - - # set new buffer + # create the new buffer, old buffer should get dereferenced self._buffer = pygfx.Buffer(markers_int_array) - graphic.world_object.geometry.markers = self.buffer + graphic.world_object.geometry.markers = self._buffer self._emit_event(self._property_name, key=slice(None), value=value) @@ -482,13 +478,9 @@ def set_value(self, graphic, value): value = self._fix_rotations(value, len(value)) data = np.empty(shape=(len(value),), dtype=np.float32) - # destroy old buffer - if self._buffer._wgpu_object is not None: - self._buffer._wgpu_object.destroy() - - # set new buffer + # create the new buffer, old buffer should get dereferenced self._buffer = pygfx.Buffer(data) - graphic.world_object.geometry.rotations = self.buffer + graphic.world_object.geometry.rotations = self._buffer self._emit_event(self._property_name, key=slice(None), value=value) return @@ -578,13 +570,9 @@ def set_value(self, graphic, value): value = self._fix_sizes(value, len(value)) data = np.empty(shape=(len(value),), dtype=np.float32) - # destroy old buffer - if self._buffer._wgpu_object is not None: - self._buffer._wgpu_object.destroy() - - # set new buffer + # create the new buffer, old buffer should get dereferenced self._buffer = pygfx.Buffer(data) - graphic.world_object.geometry.sizes = self.buffer + graphic.world_object.geometry.sizes = self._buffer self._emit_event(self._property_name, key=slice(None), value=value) return diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index e71a554bc..90deb9c9d 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -98,7 +98,7 @@ def __init__( aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") if isinstance(self._colors, UniformColor): - geometry = pygfx.Geometry(positions=self._data.buffer) + geometry = pygfx.Geometry(positions=self._data._buffer) material = MaterialCls( aa=aa, thickness=self.thickness, @@ -118,7 +118,7 @@ def __init__( depth_compare="<=", ) geometry = pygfx.Geometry( - positions=self._data.buffer, colors=self._colors.buffer + positions=self._data._buffer, colors=self._colors._buffer ) world_object: pygfx.Line = pygfx.Line(geometry=geometry, material=material) diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index ec231ca1f..bbb155ba7 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -169,7 +169,7 @@ def __init__( n_datapoints = self.data.value.shape[0] - geo_kwargs = {"positions": self._data.buffer} + geo_kwargs = {"positions": self._data._buffer} aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") @@ -207,7 +207,7 @@ def __init__( self._markers = VertexMarkers(markers, n_datapoints) - geo_kwargs["markers"] = self._markers.buffer + geo_kwargs["markers"] = self._markers._buffer if edge_colors is None: # interpret as no edge color @@ -230,7 +230,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._buffer self._edge_width = EdgeWidth(edge_width) material_kwargs["edge_width"] = self._edge_width.value @@ -272,7 +272,7 @@ def __init__( material_kwargs["color"] = self.colors else: material_kwargs["color_mode"] = pygfx.ColorMode.vertex - geo_kwargs["colors"] = self.colors.buffer + geo_kwargs["colors"] = self.colors._buffer if uniform_size: material_kwargs["size_mode"] = pygfx.SizeMode.uniform @@ -281,14 +281,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._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._buffer case pygfx.enums.RotationMode.uniform: self._point_rotations = UniformRotations(point_rotations) From 6228d6c31c072068a4cce44eb1083a99b4d7c16b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 21:30:36 -0500 Subject: [PATCH 48/58] slower changes --- examples/image/image_reshaping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/image/image_reshaping.py b/examples/image/image_reshaping.py index 7f6d30e26..4b4726590 100644 --- a/examples/image/image_reshaping.py +++ b/examples/image/image_reshaping.py @@ -39,8 +39,8 @@ def update(): col = np.abs(np.cos(i)) * 2300 image.data = full_data[: int(row), : int(col)] - i += 0.1 - j += 0.1 + i += 0.01 + j += 0.01 fig.add_animations(update) From e207d5b8213db768fd14ed5253a39e6da2d627ac Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Feb 2026 21:30:46 -0500 Subject: [PATCH 49/58] update --- examples/misc/buffer_replace_gc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/misc/buffer_replace_gc.py b/examples/misc/buffer_replace_gc.py index 9b00d22ad..e3b0ac104 100644 --- a/examples/misc/buffer_replace_gc.py +++ b/examples/misc/buffer_replace_gc.py @@ -49,6 +49,7 @@ def __init__(self, figure): uniform_size=False, uniform_marker=False, uniform_edge_color=False, + point_rotation_mode="vertex", name="scatter", ) @@ -65,8 +66,6 @@ def update(self): if imgui.button(f"{size} - {graphic} - {fea}"): self._replace(graphic, fea, size) - imgui.text(f"VRAM usage: {self.vram_usage} MB") - def _replace( self, graphic: Literal["line", "scatter", "image"], From 5c894c39d6e1a1c0a6eefea9bd6c3d6099f30c50 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 00:09:26 -0500 Subject: [PATCH 50/58] update example --- examples/misc/reshape_lines_scatters.py | 61 +++++++++++++++++++++---- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/examples/misc/reshape_lines_scatters.py b/examples/misc/reshape_lines_scatters.py index 262d95652..9f0a5c27d 100644 --- a/examples/misc/reshape_lines_scatters.py +++ b/examples/misc/reshape_lines_scatters.py @@ -1,53 +1,94 @@ +""" +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 +fpl.select_adapter(fpl.enumerate_adapters()[1]) + +# create some data to start with xs = np.linspace(0, 10 * np.pi, 100) ys = np.sin(xs) data = np.column_stack([xs, ys]) -fig = fpl.Figure(shape=(2, 2), size=(700, 700)) +# create a figure, add a line, scatter and line_stack +fig = fpl.Figure(shape=(3, 1), size=(700, 700)) line = fig[0, 0].add_line(data) -text = fig[0, 0].add_text(f"n_points: {100}", offset=(0, 3, 0), anchor="middle-left") -scatter = fig[0, 1].add_scatter( +scatter = fig[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", ) -fig.show() +line_stack = fig[2, 0].add_line_stack(np.stack([data] * 10), cmap="viridis") + +text = fig[0, 0].add_text(f"n_points: {100}", offset=(0, 1.5, 0), anchor="middle-left") + +fig.show(maintain_aspect=False) i = 0 def update(): + # set a new larger or smaller data array on every render global i - # update line - freq = (np.sin(i) + 1) * 5 - n_points = int((freq * 50_000) + 10) + # 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 + # 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) - i += 0.01 text.text = f"n_points: {n_points}" + i += 0.01 + fig.add_animations(update) -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() From 7452fa882e194f363854f205e88eec631ff9af16 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 00:34:58 -0500 Subject: [PATCH 51/58] fixes and tweaks for test --- fastplotlib/graphics/_positions_base.py | 2 +- fastplotlib/graphics/features/_base.py | 8 ++++-- fastplotlib/graphics/features/_positions.py | 30 +++++++++++--------- fastplotlib/graphics/features/_scatter.py | 12 ++++---- fastplotlib/graphics/line.py | 4 +-- fastplotlib/graphics/mesh.py | 2 +- fastplotlib/graphics/scatter.py | 12 ++++---- tests/test_markers_buffer_manager.py | 2 +- tests/test_point_rotations_buffer_manager.py | 2 +- tests/test_positions_data_buffer_manager.py | 2 +- tests/test_positions_graphics.py | 3 +- tests/test_replace_buffer.py | 6 ++-- tests/test_scatter_graphic.py | 2 +- 13 files changed, 47 insertions(+), 40 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 343d8dda2..7856174ce 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -59,7 +59,7 @@ def color_mode(self, mode: Literal["uniform", "vertex"]): 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._buffer + self.world_object.geometry.colors = new_colors._fpl_buffer elif mode == "uniform" and isinstance(self._colors, VertexColors): # vertex -> uniform diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 1dd070c98..76352b4ef 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -149,13 +149,15 @@ def __init__( if isinstance(data, pygfx.Resource): # already a buffer, probably used for # managing another BufferManager, example: VertexCmap manages VertexColors - self._buffer = data + self._fpl_buffer = data else: # create a buffer bdata = np.empty(data.shape, dtype=data.dtype) bdata[:] = data[:] - self._buffer = pygfx.Buffer(bdata) + self._fpl_buffer = pygfx.Buffer(bdata) + else: + self._fpl_buffer = None self._event_handlers: list[Callable] = list() @@ -172,7 +174,7 @@ def set_value(self, graphic, value): 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._buffer) + return weakref.proxy(self._fpl_buffer) @property def __array_interface__(self): diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index e41404554..4909ebd9c 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -89,8 +89,8 @@ def set_value( new_colors = parse_colors(value, len(value)) # create the new buffer, old buffer should get dereferenced - self._buffer = pygfx.Buffer(new_colors) - graphic.world_object.geometry.colors = self._buffer + self._fpl_buffer = pygfx.Buffer(new_colors) + graphic.world_object.geometry.colors = self._fpl_buffer if len(self._event_handlers) < 1: return @@ -311,12 +311,11 @@ def set_value(self, graphic, value): # if data is not 3D if value.ndim == 1: - # this is already a newly allocated buffer # _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: - # this is already a newly allocated buffer + # _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: @@ -325,8 +324,8 @@ def set_value(self, graphic, value): bdata[:] = value[:] # create the new buffer, old buffer should get dereferenced - self._buffer = pygfx.Buffer(bdata) - graphic.world_object.geometry.positions = self._buffer + 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 @@ -340,14 +339,19 @@ def __setitem__( value: np.ndarray | float | list[float], ): # directly use the key to slice the buffer - # if value is just 1D, assume these are y-values - if value.ndim == 1: - self.buffer.data[key, 1] = value + # if value is an array and the key is not a tuple indicating a specific dimension to set + if not isinstance(key, tuple) and isinstance(value, np.ndarray): + if value.ndim == 1: + # assume these are y-values + self.buffer.data[key, 1] = value + else: + # if value is [n, 1], assume they just want to set y-values + # if value is [n, 2], assume they want to set xy values + # if value is [n, 3], it's xyz values + self.buffer.data[key, :value.shape[-1]] = value else: - # if value is [n, 1], this they just want to set y-values - # if value is [n, 2], this assumes they want to set xy values - # if value is [n, 3], it's xyz values - self.buffer.data[key, :value.shape[-1]] = value + # key is a tuple, user has explicitly specified the dimension of the buffer they want to change + self.buffer.data[key] = value # _update_range handles parsing the key to # determine offset and size for GPU upload diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index 8e57797d2..36c8527be 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -210,8 +210,8 @@ def set_value(self, graphic, value): ) # create the new buffer, old buffer should get dereferenced - self._buffer = pygfx.Buffer(markers_int_array) - graphic.world_object.geometry.markers = self._buffer + 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) @@ -479,8 +479,8 @@ def set_value(self, graphic, value): data = np.empty(shape=(len(value),), dtype=np.float32) # create the new buffer, old buffer should get dereferenced - self._buffer = pygfx.Buffer(data) - graphic.world_object.geometry.rotations = self._buffer + 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 @@ -571,8 +571,8 @@ def set_value(self, graphic, value): data = np.empty(shape=(len(value),), dtype=np.float32) # create the new buffer, old buffer should get dereferenced - self._buffer = pygfx.Buffer(data) - graphic.world_object.geometry.sizes = self._buffer + 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 diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 90deb9c9d..b55f2009f 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -98,7 +98,7 @@ def __init__( aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") if isinstance(self._colors, UniformColor): - geometry = pygfx.Geometry(positions=self._data._buffer) + geometry = pygfx.Geometry(positions=self._data._fpl_buffer) material = MaterialCls( aa=aa, thickness=self.thickness, @@ -118,7 +118,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/mesh.py b/fastplotlib/graphics/mesh.py index e71981b44..efe03c57b 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -128,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 bbb155ba7..06af8c7e3 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -169,7 +169,7 @@ def __init__( 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") @@ -207,7 +207,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 @@ -230,7 +230,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 @@ -272,7 +272,7 @@ def __init__( 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 @@ -281,14 +281,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) diff --git a/tests/test_markers_buffer_manager.py b/tests/test_markers_buffer_manager.py index 101b383db..488bed194 100644 --- a/tests/test_markers_buffer_manager.py +++ b/tests/test_markers_buffer_manager.py @@ -49,7 +49,7 @@ def test_create_buffer(test_graphic): 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)) 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 d0b974f6e..4bc93b626 100644 --- a/tests/test_positions_graphics.py +++ b/tests/test_positions_graphics.py @@ -219,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) diff --git a/tests/test_replace_buffer.py b/tests/test_replace_buffer.py index 7afd0ec8a..a9d0ffe41 100644 --- a/tests/test_replace_buffer.py +++ b/tests/test_replace_buffer.py @@ -51,15 +51,15 @@ def test_replace_positions_buffer(graphic_type, new_buffer_size): # 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.buffer) - orig_colors_buffer = weakref.proxy(graphic.colors.buffer) + 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).buffer)) + 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) 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 From 16c979d78a4a251c174d4c606360843a43132b81 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 00:39:46 -0500 Subject: [PATCH 52/58] remove unecessary stuff --- fastplotlib/graphics/features/_positions.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 4909ebd9c..7b67e6bd7 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -338,20 +338,8 @@ def __setitem__( key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value: np.ndarray | float | list[float], ): - # directly use the key to slice the buffer - # if value is an array and the key is not a tuple indicating a specific dimension to set - if not isinstance(key, tuple) and isinstance(value, np.ndarray): - if value.ndim == 1: - # assume these are y-values - self.buffer.data[key, 1] = value - else: - # if value is [n, 1], assume they just want to set y-values - # if value is [n, 2], assume they want to set xy values - # if value is [n, 3], it's xyz values - self.buffer.data[key, :value.shape[-1]] = value - else: - # key is a tuple, user has explicitly specified the dimension of the buffer they want to change - self.buffer.data[key] = value + # directly use the key to slice the buffer and set the values + self.buffer.data[key] = value # _update_range handles parsing the key to # determine offset and size for GPU upload From 68be2e0aadf272cc502873fee26e1847433e0506 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 00:39:57 -0500 Subject: [PATCH 53/58] update --- examples/misc/reshape_lines_scatters.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/misc/reshape_lines_scatters.py b/examples/misc/reshape_lines_scatters.py index 9f0a5c27d..fddab3eed 100644 --- a/examples/misc/reshape_lines_scatters.py +++ b/examples/misc/reshape_lines_scatters.py @@ -19,8 +19,6 @@ import numpy as np import fastplotlib as fpl -fpl.select_adapter(fpl.enumerate_adapters()[1]) - # create some data to start with xs = np.linspace(0, 10 * np.pi, 100) ys = np.sin(xs) From 500426df70fbb895e21b21f7ffdecfbd04867112 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 00:48:54 -0500 Subject: [PATCH 54/58] docstrings --- fastplotlib/graphics/_positions_base.py | 10 +++++++++- fastplotlib/graphics/image.py | 10 +++++++++- fastplotlib/graphics/line.py | 8 ++++++++ fastplotlib/graphics/line_collection.py | 3 +++ fastplotlib/graphics/scatter.py | 8 ++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 7856174ce..763f5e775 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -20,7 +20,15 @@ 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 diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 639a8f17c..760b856d2 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -217,7 +217,15 @@ def _create_tiles(self) -> list[_ImageTile]: @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 diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 397c74300..bba10b10f 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -66,6 +66,14 @@ def __init__( 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 diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 2b6acc64e..5ec56777e 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -169,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 diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 06af8c7e3..054691801 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -80,6 +80,14 @@ def __init__( 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. From dd7a829c4da08a5b029e57e121e4c2af467b3830 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 01:08:16 -0500 Subject: [PATCH 55/58] fix example --- examples/image/image_reshaping.py | 10 +++------- examples/machine_learning/kmeans.py | 1 + examples/notebooks/quickstart.ipynb | 4 ++-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/examples/image/image_reshaping.py b/examples/image/image_reshaping.py index 4b4726590..3401acb3b 100644 --- a/examples/image/image_reshaping.py +++ b/examples/image/image_reshaping.py @@ -11,18 +11,15 @@ """ # test_example = false -# sphinx_gallery_pygfx_docs = 'animate 4s' +# 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) - -sine = np.sin(np.sqrt(xs)) - -full_data = np.vstack([sine * i for i in range(2_300)]) - +full_data = np.vstack([np.cos(np.sqrt(xs + (np.pi / 2) * i)) * i for i in range(2_300)]) fig = fpl.Figure() @@ -45,7 +42,6 @@ def update(): fig.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__": 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/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", From da54a9c4cf6e43bfdc90bc12c3e971cc0f33b670 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 01:45:38 -0500 Subject: [PATCH 56/58] update example --- examples/image/image_reshaping.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/image/image_reshaping.py b/examples/image/image_reshaping.py index 3401acb3b..23264bda1 100644 --- a/examples/image/image_reshaping.py +++ b/examples/image/image_reshaping.py @@ -21,17 +21,18 @@ 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)]) -fig = fpl.Figure() +figure = fpl.Figure() -image = fig[0, 0].add_image(full_data) +image = figure[0, 0].add_image(full_data) -fig.show() +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)] @@ -40,7 +41,7 @@ def update(): j += 0.01 -fig.add_animations(update) +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 From eb7fbae078bd76cc609ba716822118985d3c1a1c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 02:08:35 -0500 Subject: [PATCH 57/58] update example --- examples/misc/reshape_lines_scatters.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/misc/reshape_lines_scatters.py b/examples/misc/reshape_lines_scatters.py index fddab3eed..db8adb29e 100644 --- a/examples/misc/reshape_lines_scatters.py +++ b/examples/misc/reshape_lines_scatters.py @@ -26,11 +26,11 @@ data = np.column_stack([xs, ys]) # create a figure, add a line, scatter and line_stack -fig = fpl.Figure(shape=(3, 1), size=(700, 700)) +figure = fpl.Figure(shape=(3, 1), size=(700, 700)) -line = fig[0, 0].add_line(data) +line = figure[0, 0].add_line(data) -scatter = fig[1, 0].add_scatter( +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), @@ -43,11 +43,11 @@ point_rotation_mode="vertex", ) -line_stack = fig[2, 0].add_line_stack(np.stack([data] * 10), cmap="viridis") +line_stack = figure[2, 0].add_line_stack(np.stack([data] * 10), cmap="viridis") -text = fig[0, 0].add_text(f"n_points: {100}", offset=(0, 1.5, 0), anchor="middle-left") +text = figure[0, 0].add_text(f"n_points: {100}", offset=(0, 1.5, 0), anchor="middle-left") -fig.show(maintain_aspect=False) +figure.show(maintain_aspect=False) i = 0 @@ -83,7 +83,7 @@ def update(): i += 0.01 -fig.add_animations(update) +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 From 2e8726fb9afa6040bf4f42fa90e580dc11277d64 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Feb 2026 02:10:41 -0500 Subject: [PATCH 58/58] update docs --- docs/source/api/graphics/LineGraphic.rst | 1 + docs/source/api/graphics/ScatterGraphic.rst | 1 + fastplotlib/layouts/_graphic_methods_mixin.py | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+) 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/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 637dd3bba..04d3ae5ed 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -219,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 @@ -291,6 +294,14 @@ def add_line( 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 @@ -584,6 +595,14 @@ def add_scatter( 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.