From 60e7f60d4f3aba38c7249a97f799cc74812372b9 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 3 Apr 2024 03:03:19 -0400 Subject: [PATCH 01/77] just a start --- fastplotlib/graphics/_features/_base.py | 160 ++++++++++++++++++------ 1 file changed, 120 insertions(+), 40 deletions(-) diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 99ebbf436..235da1410 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -1,10 +1,10 @@ -from abc import ABC, abstractmethod +from abc import abstractmethod from inspect import getfullargspec from warnings import warn -from typing import * -import weakref +from typing import Any, Literal import numpy as np +from numpy.typing import NDArray import pygfx @@ -76,29 +76,14 @@ def __repr__(self): ) -class GraphicFeature(ABC): - def __init__(self, parent, data: Any, collection_index: int = None): - # not shown as a docstring so it doesn't show up in the docs - # - # Parameters - # ---------- - # parent - # - # data: Any - # - # collection_index: int - # if part of a collection, index of this graphic within the collection - - self._parent = weakref.proxy(parent) - - self._data = to_gpu_supported_dtype(data) - - self._collection_index = collection_index +class GraphicFeature: + def __init__(self, **kwargs): self._event_handlers = list() self._block_events = False - def __call__(self, *args, **kwargs): - return self._data + @property + def data(self) -> Any: + raise NotImplemented def block_events(self, val: bool): """ @@ -112,21 +97,12 @@ def block_events(self, val: bool): """ self._block_events = val - @abstractmethod - def _set(self, value): - pass - - def _parse_set_value(self, value): - if isinstance(value, GraphicFeature): - return value() - - return value - def add_event_handler(self, handler: callable): """ Add an event handler. All added event handlers are called when this feature changes. + The ``handler`` can optionally accept a :class:`.FeatureEvent` as the first and only argument. - The ``FeatureEvent`` only has two attributes, ``type`` which denotes the type of event + The ``FeatureEvent`` only has 2 attributes, ``type`` which denotes the type of event as a ``str`` in the form of "", such as "color". And ``pick_info`` which contains information about the event and Graphic that triggered it. @@ -166,7 +142,7 @@ def clear_event_handlers(self): # TODO: maybe this can be implemented right here in the base class @abstractmethod - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + def _feature_changed(self,new_data: Any, key: int | slice | tuple[slice] | None = None): """Called whenever a feature changes, and it calls all funcs in self._event_handlers""" pass @@ -191,12 +167,116 @@ def _call_event_handlers(self, event_data: FeatureEvent): ) func() - @abstractmethod def __repr__(self) -> str: - pass + raise NotImplementedError + + +class BufferManager(GraphicFeature): + """Smaller wrapper for pygfx.Buffer""" + + def __init__( + self, + data: NDArray, + buffer_type: Literal["buffer", "texture"] = "buffer", + isolated_buffer: bool = True, + texture_dim: int = 2, + **kwargs + ): + super().__init__() + if isolated_buffer: + # useful if data is read-only, example: memmaps + bdata = np.zeros(data.shape) + bdata[:] = data[:] + else: + # user's input array is used as the buffer + bdata = data + + if buffer_type == "buffer": + self._buffer = pygfx.Buffer(bdata) + elif buffer_type == "texture": + self._buffer = pygfx.Texture(bdata, dim=texture_dim) + else: + raise ValueError("`buffer_type` must be one of: 'buffer' or 'texture'") + + self._event_handlers: list[callable] = list() + + @property + def data(self) -> NDArray: + return self.buffer.data + + @property + def buffer(self) -> pygfx.Buffer | pygfx.Texture: + return self._buffer + + def __getitem__(self, item): + return self.buffer.data[item] + + def __setitem__(self, key, value): + raise NotImplementedError + + def _update_range(self, offset, size): + self.buffer.update_range(offset=offset, size=size) + + def __repr__(self): + return f"{self.__class__.__name__} buffer data:\n" \ + f"{self.data.__repr__()}" + + +def parse_colors(value, n): + """parse colors using pygfx and return RGBA array for each vertex""" + if isinstance(value, str): + return np.array([pygfx.Color(value)] * n) + + return value + + +def parse_colors(key, value, n_colors, max_n_colors): + """ + + Parameters + ---------- + key: slice + + value + + n_colors + + max_n_colors: basically data.shape[0] + + Returns + ------- + + """ + pass + + +class ColorFeature(BufferManager): + """Manage color buffer for positions type objects""" + + def __init__(self, data: str | np.ndarray, n_colors: int, isolated_buffer: bool): + if not isinstance(data, np.ndarray): + # isolated buffer is only useful when data is a numpy array + isolated_buffer = False + + colors = parse_colors(data, n_colors) + + super().__init__(colors, isolated_buffer) + + def __setitem__(self, key, value): + if isinstance(value, BufferManager): + # trying to set feature from another feature instance + value = value.data + + key = self.cleanup_slice(key) + + colors = parse_colors(value, len(key)) + + self.buffer.data[key] = colors + + self._update_range(key.start, key.stop - key.start) -def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]: +def cleanup_slice(key: int | slice, upper_bound) -> slice | int: """ If the key in an `int`, it just returns it. Otherwise, @@ -257,7 +337,7 @@ def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]: return slice(start, stop, step) -def cleanup_array_slice(key: np.ndarray, upper_bound) -> Union[np.ndarray, None]: +def cleanup_array_slice(key: np.ndarray, upper_bound) -> np.darray | None: """ Cleanup numpy array used for fancy indexing, make sure key[-1] <= upper_bound. @@ -321,7 +401,7 @@ def _update_range(self, key): @property @abstractmethod - def buffer(self) -> Union[pygfx.Buffer, pygfx.Texture]: + def buffer(self) -> pygfx.Buffer | pygfx.Texture: """Underlying buffer for this feature""" pass From 6eec7d5440ffedd41a170ffee2447485bb08c9ce Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 16 May 2024 22:54:58 -0400 Subject: [PATCH 02/77] pushing to continue on my desktop --- fastplotlib/graphics/_features/_base.py | 27 ++++++++++++++++++----- fastplotlib/graphics/_features/_colors.py | 15 +++---------- fastplotlib/graphics/_features/utils.py | 7 ++++++ 3 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 fastplotlib/graphics/_features/utils.py diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 235da1410..1280829ed 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -80,9 +80,10 @@ class GraphicFeature: def __init__(self, **kwargs): self._event_handlers = list() self._block_events = False + self.collection_index: int = None @property - def data(self) -> Any: + def value(self) -> Any: raise NotImplemented def block_events(self, val: bool): @@ -99,7 +100,7 @@ def block_events(self, val: bool): def add_event_handler(self, handler: callable): """ - Add an event handler. All added event handlers are called when this feature changes. + Add an event handler. All added event handlers are calledcollection_ind when this feature changes. The ``handler`` can optionally accept a :class:`.FeatureEvent` as the first and only argument. The ``FeatureEvent`` only has 2 attributes, ``type`` which denotes the type of event @@ -201,7 +202,7 @@ def __init__( self._event_handlers: list[callable] = list() @property - def data(self) -> NDArray: + def value(self) -> NDArray: return self.buffer.data @property @@ -219,7 +220,23 @@ def _update_range(self, offset, size): def __repr__(self): return f"{self.__class__.__name__} buffer data:\n" \ - f"{self.data.__repr__()}" + f"{self.value.__repr__()}" + + +class GraphicProperty: + def __init__(self, name, collection_index: int = None): + self.name = name + + def _get_feature(self, instance): + feature: GraphicFeature = getattr(instance, f"_{self.name}") + return feature + + def __get__(self, instance, owner): + return self._get_feature(instance) + + def __set__(self, obj, value): + feature = self._get_feature(obj) + feature[:] = value def parse_colors(value, n): @@ -265,7 +282,7 @@ def __init__(self, data: str | np.ndarray, n_colors: int, isolated_buffer: bool) def __setitem__(self, key, value): if isinstance(value, BufferManager): # trying to set feature from another feature instance - value = value.data + value = value.value key = self.cleanup_slice(key) diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index 48405e74c..8a17225b7 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -10,14 +10,14 @@ ) from ._base import ( GraphicFeature, - GraphicFeatureIndexable, + BufferManager, cleanup_slice, FeatureEvent, cleanup_array_slice, ) -class ColorFeature(GraphicFeatureIndexable): +class ColorFeature(BufferManager): """ Manages the color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` @@ -34,20 +34,11 @@ class ColorFeature(GraphicFeatureIndexable): """ - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.colors - - def __getitem__(self, item): - return self.buffer.data[item] - def __init__( self, - parent, colors, n_colors: int, - alpha: float = 1.0, - collection_index: int = None, + alpha: float = None, ): """ ColorFeature diff --git a/fastplotlib/graphics/_features/utils.py b/fastplotlib/graphics/_features/utils.py new file mode 100644 index 000000000..0ffa08c13 --- /dev/null +++ b/fastplotlib/graphics/_features/utils.py @@ -0,0 +1,7 @@ +import pygfx +import numpy as np +from typing import Iterable + + +def parse_colors(colors: str | np.ndarray | Iterable[str]): + pass From 02491258306cf27a36d5e337ae7c289d3c1fd593 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 17 May 2024 00:51:24 -0400 Subject: [PATCH 03/77] progress on buffer manager cleanup_key --- fastplotlib/graphics/_features/_base.py | 269 +++++++--------------- fastplotlib/graphics/_features/_colors.py | 33 +-- fastplotlib/graphics/_features/utils.py | 21 +- 3 files changed, 107 insertions(+), 216 deletions(-) diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 1280829ed..3c434ec79 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -209,13 +209,88 @@ def value(self) -> NDArray: def buffer(self) -> pygfx.Buffer | pygfx.Texture: return self._buffer + def cleanup_key(self, key: int | np.ndarray[int, bool] | slice | tuple[slice, ...]) -> int | np.ndarray | range: + """ + Cleanup slice indices for setitem, returns positive indices. Converts negative indices to positive if necessary. + + Returns a cleaned up key corresponding to only the first dimension. + """ + upper_bound = self.value.shape[0] + + if isinstance(key, int): + if abs(key) > upper_bound: # absolute value in case negative index + raise IndexError(f"key value: {key} out of range for dimension with size: {upper_bound}") + return [key] + + elif isinstance(key, np.ndarray): + if key.ndim > 1: + raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") + + # if boolean array convert to integer array of indices + if key.dtype == bool: + key = np.nonzero(key)[0] + + if key.size < 1: + return None + + # make sure indices within bounds of feature buffer range + if key[-1] > upper_bound: + raise IndexError( + f"Index: `{key[-1]}` out of bounds for feature array of size: `{upper_bound}`" + ) + + # make sure indices are integers + if np.issubdtype(key.dtype, np.integer): + return key + + raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing graphic features") + + elif isinstance(key, tuple): + if isinstance(key[0], slice): + key = key[0] + else: + raise TypeError + + if not isinstance(key, (slice, range)): + raise TypeError("Must pass slice or int object") + + start = key.start if key.start is not None else 0 + stop = key.stop if key.stop is not None else self.value.shape[0] + # absolute value of the step in case it's negative + # since we convert start and stop to be positive below it is fine for step to be converted to positive + step = abs(key.step) if key.step is not None else 1 + + # modulus in case of negative indices + start %= upper_bound + stop %= upper_bound + + if start > stop: + raise ValueError("start index greater than stop index") + + return range(start, stop, step) + def __getitem__(self, item): return self.buffer.data[item] def __setitem__(self, key, value): raise NotImplementedError - def _update_range(self, offset, size): + def _update_range(self, key): + # assumes key is already cleaned up + if isinstance(key, range): + offset = key.start + size = key.stop - key.start + + elif isinstance(key, np.ndarray): + offset = key.min() + size = key.max() - offset + + elif isinstance(key, int): + offset = key + size = 1 + else: + raise TypeError + self.buffer.update_range(offset=offset, size=size) def __repr__(self): @@ -224,7 +299,7 @@ def __repr__(self): class GraphicProperty: - def __init__(self, name, collection_index: int = None): + def __init__(self, name): self.name = name def _get_feature(self, instance): @@ -239,33 +314,6 @@ def __set__(self, obj, value): feature[:] = value -def parse_colors(value, n): - """parse colors using pygfx and return RGBA array for each vertex""" - if isinstance(value, str): - return np.array([pygfx.Color(value)] * n) - - return value - - -def parse_colors(key, value, n_colors, max_n_colors): - """ - - Parameters - ---------- - key: slice - - value - - n_colors - - max_n_colors: basically data.shape[0] - - Returns - ------- - - """ - pass - class ColorFeature(BufferManager): """Manage color buffer for positions type objects""" @@ -291,166 +339,3 @@ def __setitem__(self, key, value): self.buffer.data[key] = colors self._update_range(key.start, key.stop - key.start) - - -def cleanup_slice(key: int | slice, upper_bound) -> slice | int: - """ - - If the key in an `int`, it just returns it. Otherwise, - it parses it and removes the `None` vals and replaces - them with corresponding values that can be used to - create a `range`, get `len` etc. - - Parameters - ---------- - key - upper_bound - - Returns - ------- - - """ - if isinstance(key, int): - return key - - if isinstance(key, np.ndarray): - return cleanup_array_slice(key, upper_bound) - - if isinstance(key, tuple): - # if tuple of slice we only need the first obj - # since the first obj is the datapoint indices - if isinstance(key[0], slice): - key = key[0] - else: - raise TypeError("Tuple slicing must have slice object in first position") - - if not isinstance(key, slice): - raise TypeError("Must pass slice or int object") - - start = key.start - stop = key.stop - step = key.step - for attr in [start, stop, step]: - if attr is None: - continue - if attr < 0: - raise IndexError("Negative indexing not supported.") - - if start is None: - start = 0 - - if stop is None: - stop = upper_bound - - elif stop > upper_bound: - raise IndexError( - f"Index: `{stop}` out of bounds for feature array of size: `{upper_bound}`" - ) - - step = key.step - if step is None: - step = 1 - - return slice(start, stop, step) - - -def cleanup_array_slice(key: np.ndarray, upper_bound) -> np.darray | None: - """ - Cleanup numpy array used for fancy indexing, make sure key[-1] <= upper_bound. - - Returns None if nothing to change. - - Parameters - ---------- - key: np.ndarray - integer or boolean array - - upper_bound - - Returns - ------- - np.ndarray - integer indexing array - - """ - - if key.ndim > 1: - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") - - # if boolean array convert to integer array of indices - if key.dtype == bool: - key = np.nonzero(key)[0] - - if key.size < 1: - return None - - # make sure indices within bounds of feature buffer range - if key[-1] > upper_bound: - raise IndexError( - f"Index: `{key[-1]}` out of bounds for feature array of size: `{upper_bound}`" - ) - - # make sure indices are integers - if np.issubdtype(key.dtype, np.integer): - return key - - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") - - -class GraphicFeatureIndexable(GraphicFeature): - """An indexable Graphic Feature, colors, data, sizes etc.""" - - def _set(self, value): - value = self._parse_set_value(value) - self[:] = value - - @abstractmethod - def __getitem__(self, item): - pass - - @abstractmethod - def __setitem__(self, key, value): - pass - - @abstractmethod - def _update_range(self, key): - pass - - @property - @abstractmethod - def buffer(self) -> pygfx.Buffer | pygfx.Texture: - """Underlying buffer for this feature""" - pass - - @property - def _upper_bound(self) -> int: - return self._data.shape[0] - - def _update_range_indices(self, key): - """Currently used by colors and positions data""" - if not isinstance(key, np.ndarray): - key = cleanup_slice(key, self._upper_bound) - - if isinstance(key, int): - self.buffer.update_range(key, size=1) - return - - # else if it's a slice obj - if isinstance(key, slice): - if key.step == 1: # we cleaned up the slice obj so step of None becomes 1 - # update range according to size using the offset - self.buffer.update_range(offset=key.start, size=key.stop - key.start) - - else: - step = key.step - # convert slice to indices - ixs = range(key.start, key.stop, step) - for ix in ixs: - self.buffer.update_range(ix, size=1) - - # TODO: See how efficient this is with large indexing - elif isinstance(key, np.ndarray): - self.buffer.update_range() - - else: - raise TypeError("must pass int or slice to update range") diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index 8a17225b7..aefd36a94 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -39,6 +39,7 @@ def __init__( colors, n_colors: int, alpha: float = None, + isolated_buffer: bool = True, ): """ ColorFeature @@ -118,17 +119,11 @@ def __init__( super().__init__(parent, data, collection_index=collection_index) def __setitem__(self, key, value): - # parse numerical slice indices - if isinstance(key, slice): - _key = cleanup_slice(key, self._upper_bound) - indices = range(_key.start, _key.stop, _key.step) - - # or single numerical index - elif isinstance(key, (int, np.integer)): - key = cleanup_slice(key, self._upper_bound) - indices = [key] + if isinstance(key, (int, np.ndarray, tuple, slice, range)): + key = self.cleanup_key(key) elif isinstance(key, tuple): + # directly setting RGBA values on every datapoint if not isinstance(value, (float, int, np.ndarray)): raise ValueError( "If using multiple-fancy indexing for color, you can only set numerical" @@ -144,26 +139,20 @@ def __setitem__(self, key, value): self.buffer.data[key] = value # update range - # first slice obj is going to be the indexing so use key[0] + # first slice obj is going to be the datapoints to modify so use key[0] # key[1] is going to be RGBA so get rid of it to pass to _update_range - # _key = cleanup_slice(key[0], self._upper_bound) + key = self.cleanup_key(key[0]) self._update_range(key) + self._feature_changed(key, value) return - elif isinstance(key, np.ndarray): - key = cleanup_array_slice(key, self._upper_bound) - if key is None: - return - - indices = key - else: raise TypeError( "Graphic features only support integer and numerical fancy indexing" ) - new_data_size = len(indices) + new_data_size = len(key) if not isinstance(value, np.ndarray): color = np.array(pygfx.Color(value)) # pygfx color parser @@ -199,14 +188,14 @@ def __setitem__(self, key, value): "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" ) + else: + raise TypeError + self.buffer.data[key] = new_colors self._update_range(key) self._feature_changed(key, new_colors) - def _update_range(self, key): - self._update_range_indices(key) - def _feature_changed(self, key, new_data): key = cleanup_slice(key, self._upper_bound) if isinstance(key, int): diff --git a/fastplotlib/graphics/_features/utils.py b/fastplotlib/graphics/_features/utils.py index 0ffa08c13..5b0ccdb54 100644 --- a/fastplotlib/graphics/_features/utils.py +++ b/fastplotlib/graphics/_features/utils.py @@ -3,5 +3,22 @@ from typing import Iterable -def parse_colors(colors: str | np.ndarray | Iterable[str]): - pass +def parse_colors( + colors: str | np.ndarray | Iterable[str], + n_colors: int | None, + alpha: float | None = None, + key: int | tuple | slice | None = None, +): + """ + + Parameters + ---------- + colors + n_colors + alpha + key + + Returns + ------- + + """ From 9d891ee75da19670bdfe943b25c0fd7c1a7ed483 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 17 May 2024 01:33:07 -0400 Subject: [PATCH 04/77] more progress, still lots to do --- fastplotlib/graphics/_features/_colors.py | 67 ++--------------------- fastplotlib/graphics/_features/_data.py | 29 +++------- fastplotlib/graphics/_features/utils.py | 64 ++++++++++++++++++++++ 3 files changed, 76 insertions(+), 84 deletions(-) diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index aefd36a94..81adeebbd 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -4,17 +4,15 @@ from ...utils import ( make_colors, get_cmap_texture, - make_pygfx_colors, parse_cmap_values, quick_min_max, ) from ._base import ( GraphicFeature, BufferManager, - cleanup_slice, FeatureEvent, - cleanup_array_slice, ) +from .utils import parse_colors class ColorFeature(BufferManager): @@ -59,64 +57,9 @@ def __init__( alpha value for the colors """ - # if provided as a numpy array of str - if isinstance(colors, np.ndarray): - if colors.dtype.kind in ["U", "S"]: - colors = colors.tolist() - # if the color is provided as a numpy array - if isinstance(colors, np.ndarray): - if colors.shape == (4,): # single RGBA array - data = np.repeat(np.array([colors]), n_colors, axis=0) - # else assume it's already a stack of RGBA arrays, keep this directly as the data - elif colors.ndim == 2: - if colors.shape[1] != 4 and colors.shape[0] != n_colors: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - data = colors - else: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - - # if the color is provided as an iterable - elif isinstance(colors, (list, tuple, np.ndarray)): - # if iterable of str - if all([isinstance(val, str) for val in colors]): - if not len(colors) == n_colors: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` of `str` " - f"where the length of the iterable is the same as the number of datapoints." - ) - - data = np.vstack([np.array(pygfx.Color(c)) for c in colors]) - - # if it's a single RGBA array as a tuple/list - elif len(colors) == 4: - c = pygfx.Color(colors) - data = np.repeat(np.array([c]), n_colors, axis=0) - - else: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " - f"an iterable of `str` with the same length as the number of datapoints." - ) - elif isinstance(colors, str): - if colors == "random": - data = np.random.rand(n_colors, 4) - data[:, -1] = alpha - else: - data = make_pygfx_colors(colors, n_colors) - else: - # assume it's a single color, use pygfx.Color to parse it - data = make_pygfx_colors(colors, n_colors) - - if alpha != 1.0: - data[:, -1] = alpha - - super().__init__(parent, data, collection_index=collection_index) + data = parse_colors(colors, n_colors, alpha) + + super().__init__(data=data, isolated_buffer=isolated_buffer) def __setitem__(self, key, value): if isinstance(key, (int, np.ndarray, tuple, slice, range)): @@ -194,7 +137,7 @@ def __setitem__(self, key, value): self.buffer.data[key] = new_colors self._update_range(key) - self._feature_changed(key, new_colors) + # self._feature_changed(key, new_colors) def _feature_changed(self, key, new_data): key = cleanup_slice(key, self._upper_bound) diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py index bcfe9446a..680218014 100644 --- a/fastplotlib/graphics/_features/_data.py +++ b/fastplotlib/graphics/_features/_data.py @@ -5,30 +5,20 @@ import pygfx from ._base import ( - GraphicFeatureIndexable, - cleanup_slice, + BufferManager, FeatureEvent, to_gpu_supported_dtype, - cleanup_array_slice, ) -class PointsDataFeature(GraphicFeatureIndexable): +class PointsDataFeature(BufferManager): """ Access to the vertex buffer data shown in the graphic. Supports fancy indexing if the data array also supports it. """ - def __init__(self, parent, data: Any, collection_index: int = None): - data = self._fix_data(data, parent) - super().__init__(parent, data, collection_index=collection_index) - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.positions - - def __getitem__(self, item): - return self.buffer.data[item] + def __init__(self, data: Any, isolated_buffer: bool = True): + super().__init__(data, isolated_buffer=isolated_buffer) def _fix_data(self, data, parent): graphic_type = parent.__class__.__name__ @@ -56,9 +46,7 @@ def _fix_data(self, data, parent): return data def __setitem__(self, key, value): - if isinstance(key, np.ndarray): - # make sure 1D array of int or boolean - key = cleanup_array_slice(key, self._upper_bound) + key = self.cleanup_key(key) # put data into right shape if they're only indexing datapoints if isinstance(key, (slice, int, np.ndarray, np.integer)): @@ -69,11 +57,8 @@ def __setitem__(self, key, value): self.buffer.data[key] = value self._update_range(key) # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self._update_range_indices(key) + # if len(self._event_handlers) > 0: + # self._feature_changed(key, value) def _feature_changed(self, key, new_data): if key is not None: diff --git a/fastplotlib/graphics/_features/utils.py b/fastplotlib/graphics/_features/utils.py index 5b0ccdb54..1b1bb56d9 100644 --- a/fastplotlib/graphics/_features/utils.py +++ b/fastplotlib/graphics/_features/utils.py @@ -2,6 +2,8 @@ import numpy as np from typing import Iterable +from ...utils import make_pygfx_colors + def parse_colors( colors: str | np.ndarray | Iterable[str], @@ -22,3 +24,65 @@ def parse_colors( ------- """ + + # if provided as a numpy array of str + if isinstance(colors, np.ndarray): + if colors.dtype.kind in ["U", "S"]: + colors = colors.tolist() + # if the color is provided as a numpy array + if isinstance(colors, np.ndarray): + if colors.shape == (4,): # single RGBA array + data = np.repeat(np.array([colors]), n_colors, axis=0) + # else assume it's already a stack of RGBA arrays, keep this directly as the data + elif colors.ndim == 2: + if colors.shape[1] != 4 and colors.shape[0] != n_colors: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + data = colors + else: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + + # if the color is provided as an iterable + elif isinstance(colors, (list, tuple, np.ndarray)): + # if iterable of str + if all([isinstance(val, str) for val in colors]): + if not len(colors) == n_colors: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` of `str` " + f"where the length of the iterable is the same as the number of datapoints." + ) + + data = np.vstack([np.array(pygfx.Color(c)) for c in colors]) + + # if it's a single RGBA array as a tuple/list + elif len(colors) == 4: + c = pygfx.Color(colors) + data = np.repeat(np.array([c]), n_colors, axis=0) + + else: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " + f"an iterable of `str` with the same length as the number of datapoints." + ) + elif isinstance(colors, str): + if colors == "random": + data = np.random.rand(n_colors, 4) + data[:, -1] = alpha + else: + data = make_pygfx_colors(colors, n_colors) + else: + # assume it's a single color, use pygfx.Color to parse it + data = make_pygfx_colors(colors, n_colors) + + if alpha is not None: + if isinstance(alpha, float): + data[:, -1] = alpha + else: + raise TypeError("if alpha is provided it must be of type `float`") + + return data From 9635bc010ab3d517ea24c1ad26aa91b376967718 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 17 May 2024 23:28:59 -0400 Subject: [PATCH 05/77] slicing working with PointsDataFeature, negative slices too, still major WIP --- fastplotlib/graphics/_features/__init__.py | 77 +++-- fastplotlib/graphics/_features/_base.py | 68 ++-- fastplotlib/graphics/_features/_colors.py | 378 ++++++++++----------- fastplotlib/graphics/_features/_data.py | 261 +++++++------- fastplotlib/graphics/_features/utils.py | 3 +- 5 files changed, 401 insertions(+), 386 deletions(-) diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index fb25db287..417472f72 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,33 +1,60 @@ -from ._colors import ColorFeature, CmapFeature, ImageCmapFeature, HeatmapCmapFeature -from ._data import PointsDataFeature, ImageDataFeature, HeatmapDataFeature -from ._sizes import PointsSizesFeature -from ._present import PresentFeature -from ._thickness import ThicknessFeature +from ._colors import ColorFeature#, CmapFeature, ImageCmapFeature, HeatmapCmapFeature +from ._data import PointsDataFeature#, ImageDataFeature, HeatmapDataFeature +# from ._sizes import PointsSizesFeature +# from ._present import PresentFeature +# from ._thickness import ThicknessFeature from ._base import ( GraphicFeature, - GraphicFeatureIndexable, + BufferManager, + GraphicFeatureDescriptor, FeatureEvent, to_gpu_supported_dtype, ) from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature from ._deleted import Deleted +# +# __all__ = [ +# "ColorFeature", +# "CmapFeature", +# "ImageCmapFeature", +# "HeatmapCmapFeature", +# "PointsDataFeature", +# "PointsSizesFeature", +# "ImageDataFeature", +# "HeatmapDataFeature", +# "PresentFeature", +# "ThicknessFeature", +# "GraphicFeature", +# "FeatureEvent", +# "to_gpu_supported_dtype", +# "LinearSelectionFeature", +# "LinearRegionSelectionFeature", +# "Deleted", +# ] -__all__ = [ - "ColorFeature", - "CmapFeature", - "ImageCmapFeature", - "HeatmapCmapFeature", - "PointsDataFeature", - "PointsSizesFeature", - "ImageDataFeature", - "HeatmapDataFeature", - "PresentFeature", - "ThicknessFeature", - "GraphicFeature", - "GraphicFeatureIndexable", - "FeatureEvent", - "to_gpu_supported_dtype", - "LinearSelectionFeature", - "LinearRegionSelectionFeature", - "Deleted", -] +class PresentFeature: + pass + +class Deleted: + pass + +class CmapFeature: + pass + +class PointsSizesFeature: + pass + +class ThicknessFeature: + pass + +class ImageCmapFeature: + pass + +class ImageDataFeature: + pass + +class HeatmapDataFeature: + pass + +class HeatmapCmapFeature: + pass \ No newline at end of file diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 3c434ec79..0a9e29e95 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -186,7 +186,7 @@ def __init__( super().__init__() if isolated_buffer: # useful if data is read-only, example: memmaps - bdata = np.zeros(data.shape) + bdata = np.zeros(data.shape, dtype=data.dtype) bdata[:] = data[:] else: # user's input array is used as the buffer @@ -209,7 +209,7 @@ def value(self) -> NDArray: def buffer(self) -> pygfx.Buffer | pygfx.Texture: return self._buffer - def cleanup_key(self, key: int | np.ndarray[int, bool] | slice | tuple[slice, ...]) -> int | np.ndarray | range: + def cleanup_key(self, key: int | np.ndarray[int, bool] | slice | tuple[slice, ...]) -> int | np.ndarray | range | tuple[range, ...]: """ Cleanup slice indices for setitem, returns positive indices. Converts negative indices to positive if necessary. @@ -220,7 +220,7 @@ def cleanup_key(self, key: int | np.ndarray[int, bool] | slice | tuple[slice, .. if isinstance(key, int): if abs(key) > upper_bound: # absolute value in case negative index raise IndexError(f"key value: {key} out of range for dimension with size: {upper_bound}") - return [key] + return key elif isinstance(key, np.ndarray): if key.ndim > 1: @@ -246,16 +246,22 @@ def cleanup_key(self, key: int | np.ndarray[int, bool] | slice | tuple[slice, .. raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing graphic features") elif isinstance(key, tuple): - if isinstance(key[0], slice): - key = key[0] - else: - raise TypeError + # multiple dimension slicing + if not all([isinstance(k, (int, slice, range, np.ndarray)) for k in key]): + raise TypeError(key) + + cleaned_tuple = list() + # cleanup the key for each dim + for k in key: + cleaned_tuple.append(self.cleanup_key(k)) + + return key if not isinstance(key, (slice, range)): raise TypeError("Must pass slice or int object") start = key.start if key.start is not None else 0 - stop = key.stop if key.stop is not None else self.value.shape[0] + stop = key.stop if key.stop is not None else self.value.shape[0] - 1 # absolute value of the step in case it's negative # since we convert start and stop to be positive below it is fine for step to be converted to positive step = abs(key.step) if key.step is not None else 1 @@ -265,7 +271,7 @@ def cleanup_key(self, key: int | np.ndarray[int, bool] | slice | tuple[slice, .. stop %= upper_bound if start > stop: - raise ValueError("start index greater than stop index") + raise ValueError(f"start index: {start} greater than stop index: {stop}") return range(start, stop, step) @@ -288,8 +294,21 @@ def _update_range(self, key): elif isinstance(key, int): offset = key size = 1 + elif isinstance(key, tuple): + key: range | slice = key[0] + upper_bound = self.value.shape[0] + + offset = key.start if key.start is not None else 0 + # size is number of points so do not subtract 1 from upper bound like in cleanup_key for indexing + stop = key.stop if key.stop is not None else upper_bound + + offset %= upper_bound + stop %= upper_bound + 1 + + size = stop - offset + else: - raise TypeError + raise TypeError(key) self.buffer.update_range(offset=offset, size=size) @@ -298,7 +317,7 @@ def __repr__(self): f"{self.value.__repr__()}" -class GraphicProperty: +class GraphicFeatureDescriptor: def __init__(self, name): self.name = name @@ -312,30 +331,3 @@ def __get__(self, instance, owner): def __set__(self, obj, value): feature = self._get_feature(obj) feature[:] = value - - - -class ColorFeature(BufferManager): - """Manage color buffer for positions type objects""" - - def __init__(self, data: str | np.ndarray, n_colors: int, isolated_buffer: bool): - if not isinstance(data, np.ndarray): - # isolated buffer is only useful when data is a numpy array - isolated_buffer = False - - colors = parse_colors(data, n_colors) - - super().__init__(colors, isolated_buffer) - - def __setitem__(self, key, value): - if isinstance(value, BufferManager): - # trying to set feature from another feature instance - value = value.value - - key = self.cleanup_slice(key) - - colors = parse_colors(value, len(key)) - - self.buffer.data[key] = colors - - self._update_range(key.start, key.stop - key.start) diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index 81adeebbd..bb98eb3b3 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -166,192 +166,192 @@ def __repr__(self) -> str: return s -class CmapFeature(ColorFeature): - """ - Indexable colormap feature, mostly wraps colors and just provides a way to set colormaps. - - Same event pick info as :class:`ColorFeature` - """ - - def __init__(self, parent, colors, cmap_name: str, cmap_values: np.ndarray): - # Skip the ColorFeature's __init__ - super(ColorFeature, self).__init__(parent, colors) - - self._cmap_name = cmap_name - self._cmap_values = cmap_values - - def __setitem__(self, key, cmap_name): - key = cleanup_slice(key, self._upper_bound) - if not isinstance(key, (slice, np.ndarray)): - raise TypeError( - "Cannot set cmap on single indices, must pass a slice object, " - "numpy.ndarray or set it on the entire data." - ) - - if isinstance(key, slice): - n_colors = len(range(key.start, key.stop, key.step)) - - else: - # numpy array - n_colors = key.size - - colors = parse_cmap_values( - n_colors=n_colors, cmap_name=cmap_name, cmap_values=self._cmap_values - ) - - self._cmap_name = cmap_name - super().__setitem__(key, colors) - - @property - def name(self) -> str: - return self._cmap_name - - @property - def values(self) -> np.ndarray: - return self._cmap_values - - @values.setter - def values(self, values: np.ndarray): - if not isinstance(values, np.ndarray): - values = np.array(values) - - colors = parse_cmap_values( - n_colors=self().shape[0], cmap_name=self._cmap_name, cmap_values=values - ) - - self._cmap_values = values - - super().__setitem__(slice(None), colors) - - def __repr__(self) -> str: - s = f"CmapFeature for {self._parent}, to get name or values: `.cmap.name`, `.cmap.values`" - return s - - -class ImageCmapFeature(GraphicFeature): - """ - Colormap for :class:`ImageGraphic`. - - .cmap() returns the Texture buffer for the cmap. - - .cmap.name returns the cmap name as a str. - - **event pick info:** - - ================ =================== =============== - key type description - ================ =================== =============== - "index" ``None`` not used - "name" ``str`` colormap name - "world_object" pygfx.WorldObject world object - "vmin" ``float`` minimum value - "vmax" ``float`` maximum value - ================ =================== =============== - - """ - - def __init__(self, parent, cmap: str): - cmap_texture_view = get_cmap_texture(cmap) - super().__init__(parent, cmap_texture_view) - self._name = cmap - - def _set(self, cmap_name: str): - if self._parent.data().ndim > 2: - return - - self._parent.world_object.material.map.data[:] = make_colors(256, cmap_name) - self._parent.world_object.material.map.update_range((0, 0, 0), size=(256, 1, 1)) - self._name = cmap_name - - self._feature_changed(key=None, new_data=self._name) - - @property - def name(self) -> str: - return self._name - - @property - def vmin(self) -> float: - """Minimum contrast limit.""" - return self._parent.world_object.material.clim[0] - - @vmin.setter - def vmin(self, value: float): - """Minimum contrast limit.""" - self._parent.world_object.material.clim = ( - value, - self._parent.world_object.material.clim[1], - ) - self._feature_changed(key=None, new_data=None) - - @property - def vmax(self) -> float: - """Maximum contrast limit.""" - return self._parent.world_object.material.clim[1] - - @vmax.setter - def vmax(self, value: float): - """Maximum contrast limit.""" - self._parent.world_object.material.clim = ( - self._parent.world_object.material.clim[0], - value, - ) - self._feature_changed(key=None, new_data=None) - - def reset_vmin_vmax(self): - """Reset vmin vmax values based on current data""" - self.vmin, self.vmax = quick_min_max(self._parent.data()) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "world_object": self._parent.world_object, - "name": self._name, - "vmin": self.vmin, - "vmax": self.vmax, - } - - event_data = FeatureEvent(type="cmap", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ImageCmapFeature for {self._parent}. Use `.cmap.name` to get str name of cmap." - return s - - -class HeatmapCmapFeature(ImageCmapFeature): - """ - Colormap for :class:`HeatmapGraphic` - - Same event pick info as :class:`ImageCmapFeature` - """ - - def _set(self, cmap_name: str): - # in heatmap we use one material for all ImageTiles - self._parent._material.map.data[:] = make_colors(256, cmap_name) - self._parent._material.map.update_range((0, 0, 0), size=(256, 1, 1)) - self._name = cmap_name - - self._feature_changed(key=None, new_data=self.name) - - @property - def vmin(self) -> float: - """Minimum contrast limit.""" - return self._parent._material.clim[0] - - @vmin.setter - def vmin(self, value: float): - """Minimum contrast limit.""" - self._parent._material.clim = (value, self._parent._material.clim[1]) - - @property - def vmax(self) -> float: - """Maximum contrast limit.""" - return self._parent._material.clim[1] - - @vmax.setter - def vmax(self, value: float): - """Maximum contrast limit.""" - self._parent._material.clim = (self._parent._material.clim[0], value) +# class CmapFeature(ColorFeature): +# """ +# Indexable colormap feature, mostly wraps colors and just provides a way to set colormaps. +# +# Same event pick info as :class:`ColorFeature` +# """ +# +# def __init__(self, parent, colors, cmap_name: str, cmap_values: np.ndarray): +# # Skip the ColorFeature's __init__ +# super(ColorFeature, self).__init__(parent, colors) +# +# self._cmap_name = cmap_name +# self._cmap_values = cmap_values +# +# def __setitem__(self, key, cmap_name): +# key = cleanup_slice(key, self._upper_bound) +# if not isinstance(key, (slice, np.ndarray)): +# raise TypeError( +# "Cannot set cmap on single indices, must pass a slice object, " +# "numpy.ndarray or set it on the entire data." +# ) +# +# if isinstance(key, slice): +# n_colors = len(range(key.start, key.stop, key.step)) +# +# else: +# # numpy array +# n_colors = key.size +# +# colors = parse_cmap_values( +# n_colors=n_colors, cmap_name=cmap_name, cmap_values=self._cmap_values +# ) +# +# self._cmap_name = cmap_name +# super().__setitem__(key, colors) +# +# @property +# def name(self) -> str: +# return self._cmap_name +# +# @property +# def values(self) -> np.ndarray: +# return self._cmap_values +# +# @values.setter +# def values(self, values: np.ndarray): +# if not isinstance(values, np.ndarray): +# values = np.array(values) +# +# colors = parse_cmap_values( +# n_colors=self().shape[0], cmap_name=self._cmap_name, cmap_values=values +# ) +# +# self._cmap_values = values +# +# super().__setitem__(slice(None), colors) +# +# def __repr__(self) -> str: +# s = f"CmapFeature for {self._parent}, to get name or values: `.cmap.name`, `.cmap.values`" +# return s +# +# +# class ImageCmapFeature(GraphicFeature): +# """ +# Colormap for :class:`ImageGraphic`. +# +# .cmap() returns the Texture buffer for the cmap. +# +# .cmap.name returns the cmap name as a str. +# +# **event pick info:** +# +# ================ =================== =============== +# key type description +# ================ =================== =============== +# "index" ``None`` not used +# "name" ``str`` colormap name +# "world_object" pygfx.WorldObject world object +# "vmin" ``float`` minimum value +# "vmax" ``float`` maximum value +# ================ =================== =============== +# +# """ +# +# def __init__(self, parent, cmap: str): +# cmap_texture_view = get_cmap_texture(cmap) +# super().__init__(parent, cmap_texture_view) +# self._name = cmap +# +# def _set(self, cmap_name: str): +# if self._parent.data().ndim > 2: +# return +# +# self._parent.world_object.material.map.data[:] = make_colors(256, cmap_name) +# self._parent.world_object.material.map.update_range((0, 0, 0), size=(256, 1, 1)) +# self._name = cmap_name +# +# self._feature_changed(key=None, new_data=self._name) +# +# @property +# def name(self) -> str: +# return self._name +# +# @property +# def vmin(self) -> float: +# """Minimum contrast limit.""" +# return self._parent.world_object.material.clim[0] +# +# @vmin.setter +# def vmin(self, value: float): +# """Minimum contrast limit.""" +# self._parent.world_object.material.clim = ( +# value, +# self._parent.world_object.material.clim[1], +# ) +# self._feature_changed(key=None, new_data=None) +# +# @property +# def vmax(self) -> float: +# """Maximum contrast limit.""" +# return self._parent.world_object.material.clim[1] +# +# @vmax.setter +# def vmax(self, value: float): +# """Maximum contrast limit.""" +# self._parent.world_object.material.clim = ( +# self._parent.world_object.material.clim[0], +# value, +# ) +# self._feature_changed(key=None, new_data=None) +# +# def reset_vmin_vmax(self): +# """Reset vmin vmax values based on current data""" +# self.vmin, self.vmax = quick_min_max(self._parent.data()) +# +# def _feature_changed(self, key, new_data): +# # this is a non-indexable feature so key=None +# +# pick_info = { +# "index": None, +# "world_object": self._parent.world_object, +# "name": self._name, +# "vmin": self.vmin, +# "vmax": self.vmax, +# } +# +# event_data = FeatureEvent(type="cmap", pick_info=pick_info) +# +# self._call_event_handlers(event_data) +# +# def __repr__(self) -> str: +# s = f"ImageCmapFeature for {self._parent}. Use `.cmap.name` to get str name of cmap." +# return s +# +# +# class HeatmapCmapFeature(ImageCmapFeature): +# """ +# Colormap for :class:`HeatmapGraphic` +# +# Same event pick info as :class:`ImageCmapFeature` +# """ +# +# def _set(self, cmap_name: str): +# # in heatmap we use one material for all ImageTiles +# self._parent._material.map.data[:] = make_colors(256, cmap_name) +# self._parent._material.map.update_range((0, 0, 0), size=(256, 1, 1)) +# self._name = cmap_name +# +# self._feature_changed(key=None, new_data=self.name) +# +# @property +# def vmin(self) -> float: +# """Minimum contrast limit.""" +# return self._parent._material.clim[0] +# +# @vmin.setter +# def vmin(self, value: float): +# """Minimum contrast limit.""" +# self._parent._material.clim = (value, self._parent._material.clim[1]) +# +# @property +# def vmax(self) -> float: +# """Maximum contrast limit.""" +# return self._parent._material.clim[1] +# +# @vmax.setter +# def vmax(self, value: float): +# """Maximum contrast limit.""" +# self._parent._material.clim = (self._parent._material.clim[0], value) diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py index 680218014..b912b2bb1 100644 --- a/fastplotlib/graphics/_features/_data.py +++ b/fastplotlib/graphics/_features/_data.py @@ -18,32 +18,27 @@ class PointsDataFeature(BufferManager): """ def __init__(self, data: Any, isolated_buffer: bool = True): + data = self._fix_data(data) super().__init__(data, isolated_buffer=isolated_buffer) - def _fix_data(self, data, parent): - graphic_type = parent.__class__.__name__ - - data = to_gpu_supported_dtype(data) + def _fix_data(self, data): + # data = to_gpu_supported_dtype(data) if data.ndim == 1: - # for scatter if we receive just 3 points in a 1d array, treat it as just a single datapoint - # this is different from fix_data for LineGraphic since there we assume that a 1d array - # is just y-values - if graphic_type == "ScatterGraphic": - data = np.array([data]) - elif graphic_type == "LineGraphic": - data = np.dstack([np.arange(data.size, dtype=data.dtype), data])[0] + # if user provides a 1D array, assume these are y-values + data = np.column_stack([np.arange(data.size, dtype=data.dtype), data]) if data.shape[1] != 3: if data.shape[1] != 2: - raise ValueError(f"Must pass 1D, 2D or 3D data to {graphic_type}") + raise ValueError(f"Must pass 1D, 2D or 3D data") # zeros for z zs = np.zeros(data.shape[0], dtype=data.dtype) - data = np.dstack([data[:, 0], data[:, 1], zs])[0] + # column stack [x, y, z] to make data of shape [n_points, 3] + data = np.column_stack([data[:, 0], data[:, 1], zs]) - return data + return to_gpu_supported_dtype(data) def __setitem__(self, key, value): key = self.cleanup_key(key) @@ -83,122 +78,122 @@ def _feature_changed(self, key, new_data): self._call_event_handlers(event_data) - def __repr__(self) -> str: - s = f"PointsDataFeature for {self._parent}, call `.data()` to get values" - return s - - -class ImageDataFeature(GraphicFeatureIndexable): - """ - Access to the Texture buffer shown in an ImageGraphic. - """ - - def __init__(self, parent, data: Any): - if data.ndim not in (2, 3): - raise ValueError( - "`data.ndim` must be 2 or 3, ImageGraphic data shape must be " - "``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]``" - ) - - super().__init__(parent, data) - - @property - def buffer(self) -> pygfx.Texture: - """Texture buffer for the image data""" - return self._parent.world_object.geometry.grid - - def update_gpu(self): - """Update the GPU with the buffer""" - self._update_range(None) - - def __call__(self, *args, **kwargs): - return self.buffer.data - - def __getitem__(self, item): - return self.buffer.data[item] - - def __setitem__(self, key, value): - # make sure float32 - value = to_gpu_supported_dtype(value) - - self.buffer.data[key] = value - self._update_range(key) - - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self.buffer.update_range((0, 0, 0), size=self.buffer.size) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif key is None: - indices = None - - pick_info = { - "index": indices, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ImageDataFeature for {self._parent}, call `.data()` to get values" - return s - - -class HeatmapDataFeature(ImageDataFeature): - @property - def buffer(self) -> List[pygfx.Texture]: - """list of Texture buffer for the image data""" - return [img.geometry.grid for img in self._parent.world_object.children] - - def __getitem__(self, item): - return self._data[item] - - def __call__(self, *args, **kwargs): - return self._data - - def __setitem__(self, key, value): - # make sure supported type, not float64 etc. - value = to_gpu_supported_dtype(value) - - self._data[key] = value - self._update_range(key) - - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - for buffer in self.buffer: - buffer.update_range((0, 0, 0), size=buffer.size) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif key is None: - indices = None - - pick_info = { - "index": indices, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) + # def __repr__(self) -> str: + # s = f"PointsDataFeature for {self._parent}, call `.data()` to get values" + # return s + +# +# class ImageDataFeature(GraphicFeatureIndexable): +# """ +# Access to the Texture buffer shown in an ImageGraphic. +# """ +# +# def __init__(self, parent, data: Any): +# if data.ndim not in (2, 3): +# raise ValueError( +# "`data.ndim` must be 2 or 3, ImageGraphic data shape must be " +# "``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]``" +# ) +# +# super().__init__(parent, data) +# +# @property +# def buffer(self) -> pygfx.Texture: +# """Texture buffer for the image data""" +# return self._parent.world_object.geometry.grid +# +# def update_gpu(self): +# """Update the GPU with the buffer""" +# self._update_range(None) +# +# def __call__(self, *args, **kwargs): +# return self.buffer.data +# +# def __getitem__(self, item): +# return self.buffer.data[item] +# +# def __setitem__(self, key, value): +# # make sure float32 +# value = to_gpu_supported_dtype(value) +# +# self.buffer.data[key] = value +# self._update_range(key) +# +# # avoid creating dicts constantly if there are no events to handle +# if len(self._event_handlers) > 0: +# self._feature_changed(key, value) +# +# def _update_range(self, key): +# self.buffer.update_range((0, 0, 0), size=self.buffer.size) +# +# def _feature_changed(self, key, new_data): +# if key is not None: +# key = cleanup_slice(key, self._upper_bound) +# if isinstance(key, int): +# indices = [key] +# elif isinstance(key, slice): +# indices = range(key.start, key.stop, key.step) +# elif key is None: +# indices = None +# +# pick_info = { +# "index": indices, +# "world_object": self._parent.world_object, +# "new_data": new_data, +# } +# +# event_data = FeatureEvent(type="data", pick_info=pick_info) +# +# self._call_event_handlers(event_data) +# +# def __repr__(self) -> str: +# s = f"ImageDataFeature for {self._parent}, call `.data()` to get values" +# return s +# +# +# class HeatmapDataFeature(ImageDataFeature): +# @property +# def buffer(self) -> List[pygfx.Texture]: +# """list of Texture buffer for the image data""" +# return [img.geometry.grid for img in self._parent.world_object.children] +# +# def __getitem__(self, item): +# return self._data[item] +# +# def __call__(self, *args, **kwargs): +# return self._data +# +# def __setitem__(self, key, value): +# # make sure supported type, not float64 etc. +# value = to_gpu_supported_dtype(value) +# +# self._data[key] = value +# self._update_range(key) +# +# # avoid creating dicts constantly if there are no events to handle +# if len(self._event_handlers) > 0: +# self._feature_changed(key, value) +# +# def _update_range(self, key): +# for buffer in self.buffer: +# buffer.update_range((0, 0, 0), size=buffer.size) +# +# def _feature_changed(self, key, new_data): +# if key is not None: +# key = cleanup_slice(key, self._upper_bound) +# if isinstance(key, int): +# indices = [key] +# elif isinstance(key, slice): +# indices = range(key.start, key.stop, key.step) +# elif key is None: +# indices = None +# +# pick_info = { +# "index": indices, +# "world_object": self._parent.world_object, +# "new_data": new_data, +# } +# +# event_data = FeatureEvent(type="data", pick_info=pick_info) +# +# self._call_event_handlers(event_data) diff --git a/fastplotlib/graphics/_features/utils.py b/fastplotlib/graphics/_features/utils.py index 1b1bb56d9..316014881 100644 --- a/fastplotlib/graphics/_features/utils.py +++ b/fastplotlib/graphics/_features/utils.py @@ -2,6 +2,7 @@ import numpy as np from typing import Iterable +from ._base import to_gpu_supported_dtype from ...utils import make_pygfx_colors @@ -85,4 +86,4 @@ def parse_colors( else: raise TypeError("if alpha is provided it must be of type `float`") - return data + return to_gpu_supported_dtype(data) From 9be017cbbe82dfd97d0df98be26e5db15a9f1de1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 17 May 2024 23:33:40 -0400 Subject: [PATCH 06/77] comitting stuff --- fastplotlib/graphics/_base.py | 13 ++++++++----- fastplotlib/graphics/line.py | 30 ++++++++++++++---------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 3a5b043f5..be1d932cd 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -9,7 +9,7 @@ from pygfx import WorldObject -from ._features import GraphicFeature, PresentFeature, GraphicFeatureIndexable, Deleted +from ._features import GraphicFeature, PresentFeature, BufferManager, GraphicFeatureDescriptor, Deleted HexStr: TypeAlias = str @@ -49,12 +49,15 @@ def __init_subclass__(cls, **kwargs): class Graphic(BaseGraphic): - feature_events = {} + features = {} def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) # all graphics give off a feature event when deleted - cls.feature_events = {*cls.feature_events, "deleted"} + cls.features = {*cls.features}#, "deleted"} + + for f in cls.features: + setattr(cls, f, GraphicFeatureDescriptor(f)) def __init__( self, @@ -80,12 +83,12 @@ def __init__( self.metadata = metadata self.collection_index = collection_index self.registered_callbacks = dict() - self.present = PresentFeature(parent=self) + # self.present = PresentFeature(parent=self) # store hex id str of Graphic instance mem location self._fpl_address: HexStr = hex(id(self)) - self.deleted = Deleted(self, False) + # self.deleted = Deleted(self, False) self._plot_area = None diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 0371fe59b..b4589f413 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -7,12 +7,12 @@ from ..utils import parse_cmap_values from ._base import Graphic, Interaction, PreviouslyModifiedData -from ._features import PointsDataFeature, ColorFeature, CmapFeature, ThicknessFeature +from ._features import GraphicFeatureDescriptor, PointsDataFeature, ColorFeature#, CmapFeature, ThicknessFeature from .selectors import LinearRegionSelector, LinearSelector class LineGraphic(Graphic, Interaction): - feature_events = {"data", "colors", "cmap", "thickness", "present"} + features = {"data", "colors"}#, "cmap", "thickness", "present"} def __init__( self, @@ -24,6 +24,7 @@ def __init__( cmap_values: np.ndarray | Iterable = None, z_position: float = None, collection_index: int = None, + isolated_buffer: bool = True, *args, **kwargs, ): @@ -64,6 +65,7 @@ def __init__( Features -------- + **data**: :class:`.ImageDataFeature` Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. @@ -81,26 +83,24 @@ def __init__( """ - self.data = PointsDataFeature(self, data, collection_index=collection_index) + self._data = PointsDataFeature(data, isolated_buffer=isolated_buffer) if cmap is not None: - n_datapoints = self.data().shape[0] + n_datapoints = self._data.value.shape[0] colors = parse_cmap_values( n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values ) - self.colors = ColorFeature( - self, + self._colors = ColorFeature( colors, - n_colors=self.data().shape[0], + n_colors=self._data.value.shape[0], alpha=alpha, - collection_index=collection_index, ) - self.cmap = CmapFeature( - self, self.colors(), cmap_name=cmap, cmap_values=cmap_values - ) + # self.cmap = CmapFeature( + # self, self.colors(), cmap_name=cmap, cmap_values=cmap_values + # ) super().__init__(*args, **kwargs) @@ -109,14 +109,12 @@ def __init__( else: material = pygfx.LineMaterial - self.thickness = ThicknessFeature(self, thickness) + # self.thickness = ThicknessFeature(self, thickness) world_object: pygfx.Line = pygfx.Line( # self.data.feature_data because data is a Buffer - geometry=pygfx.Geometry(positions=self.data(), colors=self.colors()), - material=material( - thickness=self.thickness(), color_mode="vertex", pick_write=True - ), + geometry=pygfx.Geometry(positions=self._data.buffer, colors=self._colors.buffer), + material=material(thickness=thickness, color_mode="vertex"), ) self._set_world_object(world_object) From 0491f8ca8e3bcbf54152f05a76f3f6fa195c5026 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 18 May 2024 00:50:20 -0400 Subject: [PATCH 07/77] _update_range() is pretty good now --- fastplotlib/graphics/_features/_base.py | 55 ++++++++++++++++++------- fastplotlib/graphics/_features/_data.py | 12 ++---- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 0a9e29e95..8bebadba8 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -231,7 +231,7 @@ def cleanup_key(self, key: int | np.ndarray[int, bool] | slice | tuple[slice, .. key = np.nonzero(key)[0] if key.size < 1: - return None + return np.array([], dtype=np.int64) # make sure indices within bounds of feature buffer range if key[-1] > upper_bound: @@ -281,32 +281,55 @@ def __getitem__(self, item): def __setitem__(self, key, value): raise NotImplementedError - def _update_range(self, key): - # assumes key is already cleaned up - if isinstance(key, range): - offset = key.start - size = key.stop - key.start + def _update_range(self, key: int | slice | range | np.ndarray[int | bool] | tuple[slice, ...] | tuple[range, ...]): + """ + Uses key from slicing to determine the offset and + size of the buffer to mark for upload to the GPU + """ + upper_bound = self.value.shape[0] - elif isinstance(key, np.ndarray): - offset = key.min() - size = key.max() - offset + if isinstance(key, tuple): + # if multiple dims are sliced, we only need the key for + # the first dimension corresponding to n_datapoints + key: int | np.ndarray[int | bool] | range | slice = key[0] - elif isinstance(key, int): + if isinstance(key, int): + # simplest case offset = key size = 1 - elif isinstance(key, tuple): - key: range | slice = key[0] - upper_bound = self.value.shape[0] + elif isinstance(key, (slice, range)): + # first dimension, corresponding to n_datapoints, sliced offset = key.start if key.start is not None else 0 - # size is number of points so do not subtract 1 from upper bound like in cleanup_key for indexing + + # size is number of points so do not subtract 1 from upper bound since this is not for indexing stop = key.stop if key.stop is not None else upper_bound - offset %= upper_bound - stop %= upper_bound + 1 + # add 1 to upper bound since we want size not index + offset %= (upper_bound + 1) + stop %= (upper_bound + 1) size = stop - offset + elif isinstance(key, np.ndarray): + if key.dtype == bool: + # convert bool mask to integer indices + key = np.nonzero(key)[0] + + if key.size < 1: + # nothing to update + return + + if not np.issubdtype(key.dtype, np.integer): + # fancy indexing doesn't make sense with non-integer types + raise TypeError(key) + + # convert any negative integer indices to positive indices + key %= (upper_bound + 1) + + offset = key.min() + size = key.max() - offset + 1 + else: raise TypeError(key) diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py index b912b2bb1..2143f06d3 100644 --- a/fastplotlib/graphics/_features/_data.py +++ b/fastplotlib/graphics/_features/_data.py @@ -41,16 +41,12 @@ def _fix_data(self, data): return to_gpu_supported_dtype(data) def __setitem__(self, key, value): - key = self.cleanup_key(key) - - # put data into right shape if they're only indexing datapoints - if isinstance(key, (slice, int, np.ndarray, np.integer)): - value = self._fix_data(value, self._parent) - # otherwise assume that they have the right shape - # numpy will throw errors if it can't broadcast - + # directly use the key to slice the buffer self.buffer.data[key] = value + # _update_range handles parsing the key to + # determine offset and size for GPU upload self._update_range(key) + # avoid creating dicts constantly if there are no events to handle # if len(self._event_handlers) > 0: # self._feature_changed(key, value) From 97b73038f0e6afc096cb4f807eb548f0aad1b6fa Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 18 May 2024 00:51:20 -0400 Subject: [PATCH 08/77] comment --- fastplotlib/graphics/_base.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index be1d932cd..aaa4f6f73 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -56,6 +56,7 @@ def __init_subclass__(cls, **kwargs): # all graphics give off a feature event when deleted cls.features = {*cls.features}#, "deleted"} + # graphic feature class attributes for f in cls.features: setattr(cls, f, GraphicFeatureDescriptor(f)) @@ -181,15 +182,6 @@ def children(self) -> list[WorldObject]: def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area - def __setattr__(self, key, value): - if hasattr(self, key): - attr = getattr(self, key) - if isinstance(attr, GraphicFeature): - attr._set(value) - return - - super().__setattr__(key, value) - def __repr__(self): rval = f"{self.__class__.__name__} @ {hex(id(self))}" if self.name is not None: From 4585be2562b3033b592c98d420aea5e7ba7c91a2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 18 May 2024 00:51:32 -0400 Subject: [PATCH 09/77] if statement --- fastplotlib/graphics/_features/_colors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index bb98eb3b3..293974ec4 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -65,7 +65,7 @@ def __setitem__(self, key, value): if isinstance(key, (int, np.ndarray, tuple, slice, range)): key = self.cleanup_key(key) - elif isinstance(key, tuple): + if isinstance(key, tuple): # directly setting RGBA values on every datapoint if not isinstance(value, (float, int, np.ndarray)): raise ValueError( From 261a5a9f8c10a247d33512c2b27d8b66deb68df3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 00:27:34 -0400 Subject: [PATCH 10/77] simply colors setting --- fastplotlib/graphics/_features/_colors.py | 95 +++++++++-------------- 1 file changed, 35 insertions(+), 60 deletions(-) diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index 293974ec4..01dabc027 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -61,80 +61,55 @@ def __init__( super().__init__(data=data, isolated_buffer=isolated_buffer) - def __setitem__(self, key, value): - if isinstance(key, (int, np.ndarray, tuple, slice, range)): - key = self.cleanup_key(key) + def __setitem__( + self, + key: int | slice | range | np.ndarray[int | bool] | tuple[slice, ...] | tuple[range, ...], + value: str | np.ndarray | tuple[float, float, float, float] | list[str] + ): + # if key is tuple assume they want to edit [n_points, RGBA] directly + # if key is slice | range | int | np.ndarray, they are slicing only n_points, get n_points and parse colors if isinstance(key, tuple): - # directly setting RGBA values on every datapoint - if not isinstance(value, (float, int, np.ndarray)): - raise ValueError( - "If using multiple-fancy indexing for color, you can only set numerical" - "values since this sets the RGBA array data directly." - ) - - if len(key) != 2: - raise ValueError( - "fancy indexing for colors must be 2-dimension, i.e. [n_datapoints, RGBA]" + # directly setting RGBA values, we do no parsing + if not isinstance(value, (int, float, np.ndarray)): + raise TypeError( + "Can only set from int, float, or array to set colors directly by slicing the entire array" ) - # set the user passed data directly - self.buffer.data[key] = value - - # update range - # first slice obj is going to be the datapoints to modify so use key[0] - # key[1] is going to be RGBA so get rid of it to pass to _update_range - key = self.cleanup_key(key[0]) - self._update_range(key) - - self._feature_changed(key, value) - return + elif isinstance(key, int): + # set color of one point + n_colors = 1 + value = parse_colors(value, n_colors) - else: - raise TypeError( - "Graphic features only support integer and numerical fancy indexing" - ) - - new_data_size = len(key) + elif isinstance(key, (slice, range)): + # find n_colors by converting slice to range and then parse colors + key = range(key.start, key.stop, key.step) + n_colors = len(key) + value = parse_colors(value, n_colors) - if not isinstance(value, np.ndarray): - color = np.array(pygfx.Color(value)) # pygfx color parser - # make it of shape [n_colors_modify, 4] - new_colors = np.repeat( - np.array([color]).astype(np.float32), new_data_size, axis=0 - ) + elif isinstance(key, np.ndarray): + # make sure it's 1D + if not key.ndim == 1: + raise TypeError("If slicing colors with an array, it must be a 1D array") - # if already a numpy array - elif isinstance(value, np.ndarray): - # if a single color provided as numpy array - if value.shape == (4,): - new_colors = value.astype(np.float32) - # if there are more than 1 datapoint color to modify - if new_data_size > 1: - new_colors = np.repeat( - np.array([new_colors]).astype(np.float32), new_data_size, axis=0 - ) + if key.dtype == bool: + # make sure len is same + if not key.size == self.buffer.data.shape[0]: + raise IndexError + n_colors = np.count_nonzero(key) - elif value.ndim == 2: - if value.shape[1] != 4 and value.shape[0] != new_data_size: - raise ValueError( - "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" - ) - # if there is a single datapoint to change color of but user has provided shape [1, 4] - if new_data_size == 1: - new_colors = value.ravel().astype(np.float32) - else: - new_colors = value.astype(np.float32) + elif np.issubdtype(key.dtype, np.integer): + n_colors = key.size else: - raise ValueError( - "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" - ) + raise TypeError + + value = parse_colors(value, n_colors) else: raise TypeError - self.buffer.data[key] = new_colors + self.buffer.data[key] = value self._update_range(key) # self._feature_changed(key, new_colors) From 5d40af863f242026dbfa8ca22dc0c0a17ed13b26 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 00:27:53 -0400 Subject: [PATCH 11/77] type annotation --- fastplotlib/graphics/_features/_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py index 2143f06d3..2be6170ac 100644 --- a/fastplotlib/graphics/_features/_data.py +++ b/fastplotlib/graphics/_features/_data.py @@ -40,7 +40,7 @@ def _fix_data(self, data): return to_gpu_supported_dtype(data) - def __setitem__(self, key, value): + def __setitem__(self, key: int | slice | range | np.ndarray[int | bool] | tuple[slice, ...] | tuple[range, ...], value): # directly use the key to slice the buffer self.buffer.data[key] = value # _update_range handles parsing the key to From 697d50e61885a87a73e925e76e342f941b9ca23b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 02:00:04 -0400 Subject: [PATCH 12/77] simpler slice parsing --- fastplotlib/graphics/_features/_base.py | 34 +++++++++++++---------- fastplotlib/graphics/_features/_colors.py | 14 ++++------ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 8bebadba8..4cd51d0cc 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -281,37 +281,38 @@ def __getitem__(self, item): def __setitem__(self, key, value): raise NotImplementedError - def _update_range(self, key: int | slice | range | np.ndarray[int | bool] | tuple[slice, ...] | tuple[range, ...]): + def _update_range(self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...]): """ Uses key from slicing to determine the offset and size of the buffer to mark for upload to the GPU """ + # number of elements in the buffer upper_bound = self.value.shape[0] if isinstance(key, tuple): # if multiple dims are sliced, we only need the key for # the first dimension corresponding to n_datapoints - key: int | np.ndarray[int | bool] | range | slice = key[0] + key: int | np.ndarray[int | bool] | slice = key[0] if isinstance(key, int): # simplest case offset = key size = 1 - elif isinstance(key, (slice, range)): - # first dimension, corresponding to n_datapoints, sliced - offset = key.start if key.start is not None else 0 + elif isinstance(key, slice): + # parse slice to get offset + offset, stop, step = key.indices(upper_bound) - # size is number of points so do not subtract 1 from upper bound since this is not for indexing - stop = key.stop if key.stop is not None else upper_bound + # make range from slice to get size + size = len(range(offset, stop, step)) - # add 1 to upper bound since we want size not index - offset %= (upper_bound + 1) - stop %= (upper_bound + 1) + elif isinstance(key, (np.ndarray, list)): + if isinstance(key, list): + # convert to 1D array + key = np.array(key) + if not key.ndim == 1: + raise TypeError(key) - size = stop - offset - - elif isinstance(key, np.ndarray): if key.dtype == bool: # convert bool mask to integer indices key = np.nonzero(key)[0] @@ -325,10 +326,13 @@ def _update_range(self, key: int | slice | range | np.ndarray[int | bool] | tupl raise TypeError(key) # convert any negative integer indices to positive indices - key %= (upper_bound + 1) + key %= upper_bound + # index of first element to upload offset = key.min() - size = key.max() - offset + 1 + + # number of elements to upload, max - min + 1 + size = np.ptp(key) + 1 else: raise TypeError(key) diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index 01dabc027..cace1d436 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -63,7 +63,7 @@ def __init__( def __setitem__( self, - key: int | slice | range | np.ndarray[int | bool] | tuple[slice, ...] | tuple[range, ...], + key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value: str | np.ndarray | tuple[float, float, float, float] | list[str] ): # if key is tuple assume they want to edit [n_points, RGBA] directly @@ -81,10 +81,12 @@ def __setitem__( n_colors = 1 value = parse_colors(value, n_colors) - elif isinstance(key, (slice, range)): + elif isinstance(key, slice): # find n_colors by converting slice to range and then parse colors - key = range(key.start, key.stop, key.step) - n_colors = len(key) + start, stop, step = key.indices(self.value.shape[0]) + + n_colors = len(range(start, stop, step)) + value = parse_colors(value, n_colors) elif isinstance(key, np.ndarray): @@ -136,10 +138,6 @@ def _feature_changed(self, key, new_data): self._call_event_handlers(event_data) - def __repr__(self) -> str: - s = f"ColorsFeature for {self._parent}. Call `.colors()` to get values." - return s - # class CmapFeature(ColorFeature): # """ From 08fd17dc81d28b01400501996fe39279e05b22ba Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 02:06:47 -0400 Subject: [PATCH 13/77] remove cleanup_key :D :D :D git status! --- fastplotlib/graphics/_features/_base.py | 83 +++---------------------- 1 file changed, 9 insertions(+), 74 deletions(-) diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 4cd51d0cc..6e147d342 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -209,79 +209,13 @@ def value(self) -> NDArray: def buffer(self) -> pygfx.Buffer | pygfx.Texture: return self._buffer - def cleanup_key(self, key: int | np.ndarray[int, bool] | slice | tuple[slice, ...]) -> int | np.ndarray | range | tuple[range, ...]: - """ - Cleanup slice indices for setitem, returns positive indices. Converts negative indices to positive if necessary. - - Returns a cleaned up key corresponding to only the first dimension. - """ - upper_bound = self.value.shape[0] - - if isinstance(key, int): - if abs(key) > upper_bound: # absolute value in case negative index - raise IndexError(f"key value: {key} out of range for dimension with size: {upper_bound}") - return key - - elif isinstance(key, np.ndarray): - if key.ndim > 1: - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") - - # if boolean array convert to integer array of indices - if key.dtype == bool: - key = np.nonzero(key)[0] - - if key.size < 1: - return np.array([], dtype=np.int64) - - # make sure indices within bounds of feature buffer range - if key[-1] > upper_bound: - raise IndexError( - f"Index: `{key[-1]}` out of bounds for feature array of size: `{upper_bound}`" - ) - - # make sure indices are integers - if np.issubdtype(key.dtype, np.integer): - return key - - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing graphic features") - - elif isinstance(key, tuple): - # multiple dimension slicing - if not all([isinstance(k, (int, slice, range, np.ndarray)) for k in key]): - raise TypeError(key) - - cleaned_tuple = list() - # cleanup the key for each dim - for k in key: - cleaned_tuple.append(self.cleanup_key(k)) - - return key - - if not isinstance(key, (slice, range)): - raise TypeError("Must pass slice or int object") - - start = key.start if key.start is not None else 0 - stop = key.stop if key.stop is not None else self.value.shape[0] - 1 - # absolute value of the step in case it's negative - # since we convert start and stop to be positive below it is fine for step to be converted to positive - step = abs(key.step) if key.step is not None else 1 - - # modulus in case of negative indices - start %= upper_bound - stop %= upper_bound - - if start > stop: - raise ValueError(f"start index: {start} greater than stop index: {stop}") - - return range(start, stop, step) - def __getitem__(self, item): return self.buffer.data[item] def __setitem__(self, key, value): raise NotImplementedError - def _update_range(self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...]): + def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...]): """ Uses key from slicing to determine the offset and size of the buffer to mark for upload to the GPU @@ -308,23 +242,24 @@ def _update_range(self, key: int | slice | np.ndarray[int | bool] | tuple[slice, elif isinstance(key, (np.ndarray, list)): if isinstance(key, list): - # convert to 1D array + # convert to array key = np.array(key) - if not key.ndim == 1: - raise TypeError(key) + + if not key.ndim == 1: + raise TypeError(key) if key.dtype == bool: # convert bool mask to integer indices key = np.nonzero(key)[0] - if key.size < 1: - # nothing to update - return - if not np.issubdtype(key.dtype, np.integer): # fancy indexing doesn't make sense with non-integer types raise TypeError(key) + if key.size < 1: + # nothing to update + return + # convert any negative integer indices to positive indices key %= upper_bound From a45651db915ead70e232e241bb96dfbf5c538282 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 02:33:15 -0400 Subject: [PATCH 14/77] all fancy and negative indexing working :D --- fastplotlib/graphics/_features/_base.py | 24 ++++++++++++++++++----- fastplotlib/graphics/_features/_colors.py | 6 +++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 6e147d342..c138b3c8e 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -234,11 +234,23 @@ def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | size = 1 elif isinstance(key, slice): - # parse slice to get offset - offset, stop, step = key.indices(upper_bound) + # parse slice + start, stop, step = key.indices(upper_bound) - # make range from slice to get size - size = len(range(offset, stop, step)) + # account for backwards indexing + if (start > stop) and step < 0: + offset = stop + else: + offset = start + + # slice.indices will give -1 if None is passed + # which just means 0 here since buffers do not + # use negative indexing + offset = max(0, offset) + + # number of elements to upload + # this is indexing so do not add 1 + size = abs(stop - start) elif isinstance(key, (np.ndarray, list)): if isinstance(key, list): @@ -266,7 +278,9 @@ def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | # index of first element to upload offset = key.min() - # number of elements to upload, max - min + 1 + # number of elements to upload + # add 1 because this is direct + # passing of indices, not a start:stop size = np.ptp(key) + 1 else: diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index cace1d436..0b6e094ff 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -89,7 +89,11 @@ def __setitem__( value = parse_colors(value, n_colors) - elif isinstance(key, np.ndarray): + elif isinstance(key, (np.ndarray, list)): + if isinstance(key, list): + # convert to array + key = np.array(key) + # make sure it's 1D if not key.ndim == 1: raise TypeError("If slicing colors with an array, it must be a 1D array") From 9612d60684f75097ca562f34a7ef08e40b34b3b6 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 03:07:51 -0400 Subject: [PATCH 15/77] start tests --- tests/test_buffer_manager.py | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/test_buffer_manager.py diff --git a/tests/test_buffer_manager.py b/tests/test_buffer_manager.py new file mode 100644 index 000000000..09041e5ab --- /dev/null +++ b/tests/test_buffer_manager.py @@ -0,0 +1,57 @@ +import numpy as np +from numpy import testing as npt + +from fastplotlib.graphics._features import ColorFeature, PointsDataFeature +from fastplotlib.graphics._features.utils import parse_colors + + +def make_colors_buffer(): + return ColorFeature(colors="w", n_colors=10) + + +def make_points_buffer(): + pass + + +def test_int(): + # setting single points + colors = make_colors_buffer() + colors[3] = "r" + npt.assert_almost_equal(colors[3], [1., 0., 0., 1.]) + + colors[6] = [0., 1., 1., 1.] + npt.assert_almost_equal(colors[6], [0., 1., 1., 1.]) + + colors[7] = (0., 1., 1., 1.) + npt.assert_almost_equal(colors[6], [0., 1., 1., 1.]) + + colors[8] = np.array([1, 0, 1, 1]) + npt.assert_almost_equal(colors[8], [1., 0., 1., 1.]) + + colors[2] = [1, 0, 1, 0.5] + npt.assert_almost_equal(colors[2], [1., 0., 1., 0.5]) + + +def test_tuple(): + # setting entire array manually + colors = make_colors_buffer() + colors[1, :] = 0.5 + print(colors[1]) + npt.assert_almost_equal(colors[1], [0.5, 0.5, 0.5, 0.5]) + + colors[1, 0] = 1 + npt.assert_almost_equal(colors[1], [1., 0.5, 0.5, 0.5]) + + colors[1, 2:] = 0.7 + npt.assert_almost_equal(colors[1], [1., 0.5, 0.7, 0.7]) + + colors[1, -1] = 0.2 + npt.assert_almost_equal(colors[1], [1., 0.5, 0.7, 0.2]) + + +def test_slice(): + pass + + +def test_array(): + pass From dda83967dba968d6dd1a6bbb1b06f1e68bf5ae98 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 03:08:11 -0400 Subject: [PATCH 16/77] exception message --- fastplotlib/graphics/_features/_colors.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index 0b6e094ff..eedb5563e 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -64,7 +64,7 @@ def __init__( def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], - value: str | np.ndarray | tuple[float, float, float, float] | list[str] + value: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float ): # if key is tuple assume they want to edit [n_points, RGBA] directly # if key is slice | range | int | np.ndarray, they are slicing only n_points, get n_points and parse colors @@ -96,7 +96,7 @@ def __setitem__( # make sure it's 1D if not key.ndim == 1: - raise TypeError("If slicing colors with an array, it must be a 1D array") + raise TypeError("If slicing colors with an array, it must be a 1D bool or int array") if key.dtype == bool: # make sure len is same @@ -108,7 +108,7 @@ def __setitem__( n_colors = key.size else: - raise TypeError + raise TypeError("If slicing colors with an array, it must be a 1D bool or int array") value = parse_colors(value, n_colors) From e22fe7c21b8a1aa805399a190fbd2711d4945694 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 03:30:33 -0400 Subject: [PATCH 17/77] more on tests --- tests/test_buffer_manager.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_buffer_manager.py b/tests/test_buffer_manager.py index 09041e5ab..7b111abcc 100644 --- a/tests/test_buffer_manager.py +++ b/tests/test_buffer_manager.py @@ -5,8 +5,9 @@ from fastplotlib.graphics._features.utils import parse_colors -def make_colors_buffer(): - return ColorFeature(colors="w", n_colors=10) +def make_colors_buffer() -> ColorFeature: + colors = ColorFeature(colors="w", n_colors=10) + return colors def make_points_buffer(): @@ -16,8 +17,12 @@ def make_points_buffer(): def test_int(): # setting single points colors = make_colors_buffer() + # TODO: placeholder until I make a testing figure where we draw frames only on call + colors.buffer._gfx_pending_uploads.clear() + colors[3] = "r" npt.assert_almost_equal(colors[3], [1., 0., 0., 1.]) + assert colors.buffer._gfx_pending_uploads[-1] == (3, 1) colors[6] = [0., 1., 1., 1.] npt.assert_almost_equal(colors[6], [0., 1., 1., 1.]) @@ -35,8 +40,8 @@ def test_int(): def test_tuple(): # setting entire array manually colors = make_colors_buffer() + colors[1, :] = 0.5 - print(colors[1]) npt.assert_almost_equal(colors[1], [0.5, 0.5, 0.5, 0.5]) colors[1, 0] = 1 @@ -50,7 +55,12 @@ def test_tuple(): def test_slice(): - pass + # slicing only first dim + colors = make_colors_buffer() + + colors[1:3] = "r" + npt.assert_almost_equal(colors[1:3], [0.5, 0.5, 0.5, 0.5]) + def test_array(): From 9b4082f8bd6a1de80f332446a06339d05ced7e58 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 19:12:25 -0400 Subject: [PATCH 18/77] refactor sizes, not tested yet --- fastplotlib/graphics/_features/_sizes.py | 85 ++++++++---------------- 1 file changed, 26 insertions(+), 59 deletions(-) diff --git a/fastplotlib/graphics/_features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py index 2ceeb7862..49702ec8f 100644 --- a/fastplotlib/graphics/_features/_sizes.py +++ b/fastplotlib/graphics/_features/_sizes.py @@ -1,97 +1,64 @@ -from typing import Any - import numpy as np -import pygfx - from ._base import ( - GraphicFeatureIndexable, - cleanup_slice, + BufferManager, FeatureEvent, to_gpu_supported_dtype, - cleanup_array_slice, ) -class PointsSizesFeature(GraphicFeatureIndexable): +class PointsSizesFeature(BufferManager): """ Access to the vertex buffer data shown in the graphic. Supports fancy indexing if the data array also supports it. """ - def __init__(self, parent, sizes: Any, collection_index: int = None): - sizes = self._fix_sizes(sizes, parent) - super().__init__(parent, sizes, collection_index=collection_index) - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.sizes - - def __getitem__(self, item): - return self.buffer.data[item] - - def _fix_sizes(self, sizes, parent): - graphic_type = parent.__class__.__name__ - - n_datapoints = parent.data().shape[0] - if not isinstance(sizes, (list, tuple, np.ndarray)): + def __init__( + self, + sizes: np.ndarray | list[int | float] | tuple[int | float], + n_datapoints: int, + isolated_buffer: bool = True + ): + sizes = self._fix_sizes(sizes, n_datapoints) + super().__init__(data=sizes, isolated_buffer=isolated_buffer) + + def _fix_sizes(self, sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], n_datapoints: int): + if np.issubdtype(type(sizes), np.integer): + # single value given sizes = np.full( n_datapoints, sizes, dtype=np.float32 ) # force it into a float to avoid weird gpu errors - elif not isinstance( - sizes, np.ndarray + + elif isinstance( + sizes, (np.ndarray, tuple, list) ): # if it's not a ndarray already, make it one - sizes = np.array(sizes, dtype=np.float32) # read it in as a numpy.float32 - if (sizes.ndim != 1) or (sizes.size != parent.data().shape[0]): + sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32 + if (sizes.ndim != 1) or (sizes.size != n_datapoints): raise ValueError( f"sequence of `sizes` must be 1 dimensional with " f"the same length as the number of datapoints" ) - sizes = to_gpu_supported_dtype(sizes) + else: + raise TypeError("sizes must be a single , , or a sequence (array, list, tuple) of int" + "or float with the length equal to the number of datapoints") - if any(s < 0 for s in sizes): + if np.count_nonzero(sizes < 0) > 1: raise ValueError( "All sizes must be positive numbers greater than or equal to 0.0." ) - if sizes.ndim == 1: - if graphic_type == "ScatterGraphic": - sizes = np.array(sizes) - else: - raise ValueError( - f"Sizes must be an array of shape (n,) where n == the number of data points provided.\ - Received shape={sizes.shape}." - ) - - return np.array(sizes) + return sizes def __setitem__(self, key, value): - if isinstance(key, np.ndarray): - # make sure 1D array of int or boolean - key = cleanup_array_slice(key, self._upper_bound) - - # put sizes into right shape if they're only indexing datapoints - if isinstance(key, (slice, int, np.ndarray, np.integer)): - value = self._fix_sizes(value, self._parent) - # otherwise assume that they have the right shape - # numpy will throw errors if it can't broadcast - - if value.size != self.buffer.data[key].size: - raise ValueError( - f"{value.size} is not equal to buffer size {self.buffer.data[key].size}.\ - If you want to set size to a non-scalar value, make sure it's the right length!" - ) - + # this is a very simple 1D buffer, no parsing required, directly set buffer self.buffer.data[key] = value self._update_range(key) + # avoid creating dicts constantly if there are no events to handle if len(self._event_handlers) > 0: self._feature_changed(key, value) - def _update_range(self, key): - self._update_range_indices(key) - def _feature_changed(self, key, new_data): if key is not None: key = cleanup_slice(key, self._upper_bound) From 2a1869ceae97c30b35fa53168d43ee3e035b7ced Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 20:05:13 -0400 Subject: [PATCH 19/77] start parameterizing buffer tests --- tests/test_buffer_manager.py | 74 ++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/tests/test_buffer_manager.py b/tests/test_buffer_manager.py index 7b111abcc..e0685235f 100644 --- a/tests/test_buffer_manager.py +++ b/tests/test_buffer_manager.py @@ -1,10 +1,26 @@ import numpy as np from numpy import testing as npt +import pytest + +import pygfx from fastplotlib.graphics._features import ColorFeature, PointsDataFeature from fastplotlib.graphics._features.utils import parse_colors +# TODO: parameterize every test where the color is given in as str, array, tuple, and list + + +def generate_color_inputs(name: str) -> list[str, np.ndarray, list, tuple]: + color = pygfx.Color(name) + + s = name + a = np.array(color) + l = list(color) + t = tuple(color) + + return [s, a, l, t] + def make_colors_buffer() -> ColorFeature: colors = ColorFeature(colors="w", n_colors=10) return colors @@ -54,13 +70,63 @@ def test_tuple(): npt.assert_almost_equal(colors[1], [1., 0.5, 0.7, 0.2]) -def test_slice(): +@pytest.mark.parametrize("color1", generate_color_inputs("red")) +@pytest.mark.parametrize("color2", generate_color_inputs("green")) +@pytest.mark.parametrize("color3", generate_color_inputs("blue")) +def test_slice(color1, color2, color3): # slicing only first dim colors = make_colors_buffer() - colors[1:3] = "r" - npt.assert_almost_equal(colors[1:3], [0.5, 0.5, 0.5, 0.5]) - + colors[1:3] = color1 + truth = np.repeat([pygfx.Color(color1)], repeats=2, axis=0) + npt.assert_almost_equal(colors[1:3], truth) + + colors[:] = "w" + npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) + + colors[2:8:2] = color2 # set index 2, 4, 6 to color2 + truth = np.repeat([pygfx.Color(color2)], repeats=3, axis=0) + npt.assert_almost_equal(colors[2:8:2], truth) + # make sure others are not changed + others = [0, 1, 3, 5, 7, 8, 9] + npt.assert_almost_equal(colors[others], np.repeat([[1., 1., 1., 1.]], repeats=7, axis=0)) + + # set the others to color3 + colors[others] = color3 + truth = np.repeat([pygfx.Color(color3)], repeats=len(others), axis=0) + npt.assert_almost_equal(colors[others], truth) + # make sure color2 items are not touched + npt.assert_almost_equal(colors[2:8:2], np.repeat([pygfx.Color(color2)], repeats=3, axis=0)) + + # reset + colors[:] = (1, 1, 1, 1) + + # negative slicing + colors[-5:] = color1 + truth = np.repeat([pygfx.Color(color1)], repeats=5, axis=0) + npt.assert_almost_equal(colors[-5:], truth) + + # set some to color2 + colors[-5:-1:2] = color2 + truth = np.repeat([pygfx.Color(color2)], 2, axis=0) + npt.assert_almost_equal(colors[[5, 7]], truth) + # make sure non-sliced not touched + npt.assert_almost_equal(colors[[6, 8, 9]], np.repeat([pygfx.Color(color1)], 3, axis=0)) + # make sure white non-sliced not touched + npt.assert_almost_equal(colors[:-5], np.repeat([[1., 1., 1., 1.]], 5, axis=0)) + + # negative slicing backwards, set points 5, 3 + colors[-5:1:-2] = color3 + truth = np.repeat([pygfx.Color(color3)], repeats=2, axis=0) + npt.assert_almost_equal(colors[[5, 3]], truth) + # make sure others are not touched + npt.assert_almost_equal(colors[[0, 1, 2]], np.repeat([[1., 1., 1., 1.]], repeats=3, axis=0)) + # this point should be color2 + npt.assert_almost_equal(colors[7], np.array(pygfx.Color(color2))) + # point 4 should be completely untouched + npt.assert_almost_equal(colors[4], [1, 1, 1, 1]) + # rest should be color1 + npt.assert_almost_equal(colors[[6, 8, 9]], np.repeat([pygfx.Color(color1)], 3, axis=0)) def test_array(): From 29a0a76378359b3a13f52b6b11e0c218c825dfff Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 20:35:46 -0400 Subject: [PATCH 20/77] better buffer tests --- tests/test_buffer_manager.py | 126 +++++++++++++++++++++-------------- 1 file changed, 76 insertions(+), 50 deletions(-) diff --git a/tests/test_buffer_manager.py b/tests/test_buffer_manager.py index e0685235f..badc570c1 100644 --- a/tests/test_buffer_manager.py +++ b/tests/test_buffer_manager.py @@ -21,6 +21,68 @@ def generate_color_inputs(name: str) -> list[str, np.ndarray, list, tuple]: return [s, a, l, t] + +def generate_slice_indices(kind: int): + n_elements = 10 + a = np.arange(n_elements) + + match kind: + case 1: + # everything + s = slice(None, None, None) + indices = list(range(10)) + + case 2: + # positive continuous range + s = slice(1, 5, None) + indices = list(range(1, 5)) + + case 3: + # positive stepped range + s = slice(2, 8, 2) + indices = [2, 4, 6] + + case 4: + # negative continuous range + s = slice(-5, None, None) + indices = [5, 6, 7, 8, 9] + + case 5: + # negative backwards + s = slice(-5, None, -1) + indices = [5, 4, 3, 2, 1, 0] + + case 5: + # negative backwards stepped + s = slice(-5, None, -2) + indices = [5, 3, 1] + + case 6: + # negative stepped forward + s = slice(-5, None, 2) + indices = [5, 7, 9] + + case 7: + # both negative + s = slice(-8, -2, None) + indices = [2, 3, 4, 5, 6, 7] + + case 8: + # both negative and stepped + s = slice(-8, -2, 2) + indices = [2, 4, 6] + + case 9: + # positive, negative, negative + s = slice(8, -9, -2) + indices = [8, 6, 4, 2] + + others = [i for i in a if i not in indices] + + return {"slice": s, "indices": indices, "others": others} + + + def make_colors_buffer() -> ColorFeature: colors = ColorFeature(colors="w", n_colors=10) return colors @@ -70,63 +132,27 @@ def test_tuple(): npt.assert_almost_equal(colors[1], [1., 0.5, 0.7, 0.2]) -@pytest.mark.parametrize("color1", generate_color_inputs("red")) -@pytest.mark.parametrize("color2", generate_color_inputs("green")) -@pytest.mark.parametrize("color3", generate_color_inputs("blue")) -def test_slice(color1, color2, color3): +@pytest.mark.parametrize("color_input", generate_color_inputs("red")) +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 10)]) +def test_slice(color_input, slice_method: dict): # slicing only first dim colors = make_colors_buffer() - colors[1:3] = color1 - truth = np.repeat([pygfx.Color(color1)], repeats=2, axis=0) - npt.assert_almost_equal(colors[1:3], truth) + s = slice_method["slice"] + indices = slice_method["indices"] + others = slice_method["others"] - colors[:] = "w" - npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) - - colors[2:8:2] = color2 # set index 2, 4, 6 to color2 - truth = np.repeat([pygfx.Color(color2)], repeats=3, axis=0) - npt.assert_almost_equal(colors[2:8:2], truth) - # make sure others are not changed - others = [0, 1, 3, 5, 7, 8, 9] - npt.assert_almost_equal(colors[others], np.repeat([[1., 1., 1., 1.]], repeats=7, axis=0)) - - # set the others to color3 - colors[others] = color3 - truth = np.repeat([pygfx.Color(color3)], repeats=len(others), axis=0) - npt.assert_almost_equal(colors[others], truth) - # make sure color2 items are not touched - npt.assert_almost_equal(colors[2:8:2], np.repeat([pygfx.Color(color2)], repeats=3, axis=0)) + colors[s] = color_input + truth = np.repeat([pygfx.Color(color_input)], repeats=len(indices), axis=0) + # check that correct indices are modified + npt.assert_almost_equal(colors[s], truth) + # check that others are not touched + others_truth = np.repeat([[1., 1., 1., 1.]], repeats=len(others), axis=0) + npt.assert_almost_equal(colors[others], others_truth) # reset colors[:] = (1, 1, 1, 1) - - # negative slicing - colors[-5:] = color1 - truth = np.repeat([pygfx.Color(color1)], repeats=5, axis=0) - npt.assert_almost_equal(colors[-5:], truth) - - # set some to color2 - colors[-5:-1:2] = color2 - truth = np.repeat([pygfx.Color(color2)], 2, axis=0) - npt.assert_almost_equal(colors[[5, 7]], truth) - # make sure non-sliced not touched - npt.assert_almost_equal(colors[[6, 8, 9]], np.repeat([pygfx.Color(color1)], 3, axis=0)) - # make sure white non-sliced not touched - npt.assert_almost_equal(colors[:-5], np.repeat([[1., 1., 1., 1.]], 5, axis=0)) - - # negative slicing backwards, set points 5, 3 - colors[-5:1:-2] = color3 - truth = np.repeat([pygfx.Color(color3)], repeats=2, axis=0) - npt.assert_almost_equal(colors[[5, 3]], truth) - # make sure others are not touched - npt.assert_almost_equal(colors[[0, 1, 2]], np.repeat([[1., 1., 1., 1.]], repeats=3, axis=0)) - # this point should be color2 - npt.assert_almost_equal(colors[7], np.array(pygfx.Color(color2))) - # point 4 should be completely untouched - npt.assert_almost_equal(colors[4], [1, 1, 1, 1]) - # rest should be color1 - npt.assert_almost_equal(colors[[6, 8, 9]], np.repeat([pygfx.Color(color1)], 3, axis=0)) + npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) def test_array(): From 054c836a46d61e684478d04dede5d360324ae9c9 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 20:41:40 -0400 Subject: [PATCH 21/77] more variants --- tests/test_buffer_manager.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_buffer_manager.py b/tests/test_buffer_manager.py index badc570c1..a289e9e3b 100644 --- a/tests/test_buffer_manager.py +++ b/tests/test_buffer_manager.py @@ -77,6 +77,16 @@ def generate_slice_indices(kind: int): s = slice(8, -9, -2) indices = [8, 6, 4, 2] + case 10: + # only stepped forward + s = slice(None, None, 2) + indices = [0, 2, 4, 6, 8] + + case 11: + # only stepped backward + s = slice(None, None, -3) + indices = [9, 6, 3, 0] + others = [i for i in a if i not in indices] return {"slice": s, "indices": indices, "others": others} @@ -133,7 +143,7 @@ def test_tuple(): @pytest.mark.parametrize("color_input", generate_color_inputs("red")) -@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 10)]) +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 12)]) def test_slice(color_input, slice_method: dict): # slicing only first dim colors = make_colors_buffer() From f50614fd7101916c0a6bc919ac5f2688412c245c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 20:51:27 -0400 Subject: [PATCH 22/77] add array fancy indexing to same parameterization --- tests/test_buffer_manager.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/test_buffer_manager.py b/tests/test_buffer_manager.py index a289e9e3b..91778bdfa 100644 --- a/tests/test_buffer_manager.py +++ b/tests/test_buffer_manager.py @@ -87,12 +87,31 @@ def generate_slice_indices(kind: int): s = slice(None, None, -3) indices = [9, 6, 3, 0] + case 12: + # list indices + s = [2, 5, 9] + indices = [2, 5, 9] + + case 13: + # bool indices + s = a > 5 + indices = [6, 7, 8, 9] + + case 14: + # list indices with negatives + s = [1, 4, -2] + indices = [1, 4, 8] + + case 15: + # array indices + s = np.array([1, 4, -7, 9]) + indices = [1, 4, 3, 9] + others = [i for i in a if i not in indices] return {"slice": s, "indices": indices, "others": others} - def make_colors_buffer() -> ColorFeature: colors = ColorFeature(colors="w", n_colors=10) return colors @@ -143,7 +162,7 @@ def test_tuple(): @pytest.mark.parametrize("color_input", generate_color_inputs("red")) -@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 12)]) +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 16)]) def test_slice(color_input, slice_method: dict): # slicing only first dim colors = make_colors_buffer() From 9ed2bf6c74a0754d6117b91a9f28654bdfcc577a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 21:03:27 -0400 Subject: [PATCH 23/77] parameterize tuple tests --- tests/test_buffer_manager.py | 60 ++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/tests/test_buffer_manager.py b/tests/test_buffer_manager.py index 91778bdfa..bf2760293 100644 --- a/tests/test_buffer_manager.py +++ b/tests/test_buffer_manager.py @@ -27,6 +27,11 @@ def generate_slice_indices(kind: int): a = np.arange(n_elements) match kind: + case 0: + # simplest, just int + s = 2 + indices = [2] + case 1: # everything s = slice(None, None, None) @@ -144,21 +149,54 @@ def test_int(): npt.assert_almost_equal(colors[2], [1., 0., 1., 0.5]) -def test_tuple(): +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(0, 16)]) +def test_tuple(slice_method): # setting entire array manually colors = make_colors_buffer() - colors[1, :] = 0.5 - npt.assert_almost_equal(colors[1], [0.5, 0.5, 0.5, 0.5]) + s = slice_method["slice"] + indices = slice_method["indices"] + others = slice_method["others"] + + # set all RGBA vals + colors[s, :] = 0.5 + truth = np.repeat([[0.5, 0.5, 0.5, 0.5]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + + # check others are not modified + others_truth = np.repeat([[1., 1., 1., 1.]], repeats=len(others), axis=0) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) + + # set just R values + colors[s, 0] = 0.5 + truth = np.repeat([[0.5, 1., 1., 1.]], repeats=len(indices), axis=0) + # check others not modified + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) - colors[1, 0] = 1 - npt.assert_almost_equal(colors[1], [1., 0.5, 0.5, 0.5]) + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) - colors[1, 2:] = 0.7 - npt.assert_almost_equal(colors[1], [1., 0.5, 0.7, 0.7]) + # set green and blue + colors[s, 1:-1] = 0.7 + truth = np.repeat([[1., 0.7, 0.7, 1.0]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) - colors[1, -1] = 0.2 - npt.assert_almost_equal(colors[1], [1., 0.5, 0.7, 0.2]) + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) + + # set only alpha + colors[s, -1] = 0.2 + truth = np.repeat([[1., 1., 1., 0.2]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) @pytest.mark.parametrize("color_input", generate_color_inputs("red")) @@ -182,7 +220,3 @@ def test_slice(color_input, slice_method: dict): # reset colors[:] = (1, 1, 1, 1) npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) - - -def test_array(): - pass From 5e1471ec847c72f5c3e7f039482ce4098c36699e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 21:06:14 -0400 Subject: [PATCH 24/77] remove repr --- fastplotlib/graphics/_features/_sizes.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/fastplotlib/graphics/_features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py index 49702ec8f..3c4139c08 100644 --- a/fastplotlib/graphics/_features/_sizes.py +++ b/fastplotlib/graphics/_features/_sizes.py @@ -81,7 +81,3 @@ def _feature_changed(self, key, new_data): event_data = FeatureEvent(type="sizes", pick_info=pick_info) self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"PointsSizesFeature for {self._parent}, call `.sizes()` to get values" - return s From f59fb199e3088937727b8c83e2e6a7ad505ed6ba Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 23:14:44 -0400 Subject: [PATCH 25/77] test offset and size --- tests/test_buffer_manager.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/test_buffer_manager.py b/tests/test_buffer_manager.py index bf2760293..2a5dd8bdc 100644 --- a/tests/test_buffer_manager.py +++ b/tests/test_buffer_manager.py @@ -40,7 +40,7 @@ def generate_slice_indices(kind: int): case 2: # positive continuous range s = slice(1, 5, None) - indices = list(range(1, 5)) + indices = [1, 2, 3, 4] case 3: # positive stepped range @@ -114,7 +114,9 @@ def generate_slice_indices(kind: int): others = [i for i in a if i not in indices] - return {"slice": s, "indices": indices, "others": others} + offset, size = (min(indices), np.ptp(indices) + 1) + + return {"slice": s, "indices": indices, "others": others, "offset": offset, "size": size} def make_colors_buffer() -> ColorFeature: @@ -205,14 +207,29 @@ def test_slice(color_input, slice_method: dict): # slicing only first dim colors = make_colors_buffer() + # TODO: placeholder until I make a testing figure where we draw frames only on call + colors.buffer._gfx_pending_uploads.clear() + s = slice_method["slice"] indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] others = slice_method["others"] colors[s] = color_input truth = np.repeat([pygfx.Color(color_input)], repeats=len(indices), axis=0) # check that correct indices are modified npt.assert_almost_equal(colors[s], truth) + + upload_offset, upload_size = colors.buffer._gfx_pending_uploads[-1] + # sometimes when slicing with step, it will over-estimate offset + # but it overestimates to upload 1 extra point so it's fine + assert (upload_offset == offset) or (upload_offset == offset - 1) + + # sometimes when slicing with step, it will over-estimate size + # but it overestimates to upload 1 extra point so it's fine + assert (upload_size == size) or (upload_size == size + 1) + # check that others are not touched others_truth = np.repeat([[1., 1., 1., 1.]], repeats=len(others), axis=0) npt.assert_almost_equal(colors[others], others_truth) From 4d25daa815b81c9d1ae0171811d1a617e5a1ea65 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 23:14:56 -0400 Subject: [PATCH 26/77] test offset and size --- tests/test_buffer_manager.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/test_buffer_manager.py b/tests/test_buffer_manager.py index 2a5dd8bdc..81c5b17fa 100644 --- a/tests/test_buffer_manager.py +++ b/tests/test_buffer_manager.py @@ -4,11 +4,7 @@ import pygfx -from fastplotlib.graphics._features import ColorFeature, PointsDataFeature -from fastplotlib.graphics._features.utils import parse_colors - - -# TODO: parameterize every test where the color is given in as str, array, tuple, and list +from fastplotlib.graphics._features import ColorFeature def generate_color_inputs(name: str) -> list[str, np.ndarray, list, tuple]: @@ -124,10 +120,6 @@ def make_colors_buffer() -> ColorFeature: return colors -def make_points_buffer(): - pass - - def test_int(): # setting single points colors = make_colors_buffer() From aa1949b89f41c988a39a310566c75e2116b3cd3e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 20 May 2024 23:36:43 -0400 Subject: [PATCH 27/77] test create colors --- ...nager.py => test_colors_buffer_manager.py} | 106 ++---------------- 1 file changed, 9 insertions(+), 97 deletions(-) rename tests/{test_buffer_manager.py => test_colors_buffer_manager.py} (65%) diff --git a/tests/test_buffer_manager.py b/tests/test_colors_buffer_manager.py similarity index 65% rename from tests/test_buffer_manager.py rename to tests/test_colors_buffer_manager.py index 81c5b17fa..cd39a0ad3 100644 --- a/tests/test_buffer_manager.py +++ b/tests/test_colors_buffer_manager.py @@ -5,6 +5,7 @@ import pygfx from fastplotlib.graphics._features import ColorFeature +from .utils import generate_slice_indices def generate_color_inputs(name: str) -> list[str, np.ndarray, list, tuple]: @@ -18,108 +19,18 @@ def generate_color_inputs(name: str) -> list[str, np.ndarray, list, tuple]: return [s, a, l, t] -def generate_slice_indices(kind: int): - n_elements = 10 - a = np.arange(n_elements) - - match kind: - case 0: - # simplest, just int - s = 2 - indices = [2] - - case 1: - # everything - s = slice(None, None, None) - indices = list(range(10)) - - case 2: - # positive continuous range - s = slice(1, 5, None) - indices = [1, 2, 3, 4] - - case 3: - # positive stepped range - s = slice(2, 8, 2) - indices = [2, 4, 6] - - case 4: - # negative continuous range - s = slice(-5, None, None) - indices = [5, 6, 7, 8, 9] - - case 5: - # negative backwards - s = slice(-5, None, -1) - indices = [5, 4, 3, 2, 1, 0] - - case 5: - # negative backwards stepped - s = slice(-5, None, -2) - indices = [5, 3, 1] - - case 6: - # negative stepped forward - s = slice(-5, None, 2) - indices = [5, 7, 9] - - case 7: - # both negative - s = slice(-8, -2, None) - indices = [2, 3, 4, 5, 6, 7] - - case 8: - # both negative and stepped - s = slice(-8, -2, 2) - indices = [2, 4, 6] - - case 9: - # positive, negative, negative - s = slice(8, -9, -2) - indices = [8, 6, 4, 2] - - case 10: - # only stepped forward - s = slice(None, None, 2) - indices = [0, 2, 4, 6, 8] - - case 11: - # only stepped backward - s = slice(None, None, -3) - indices = [9, 6, 3, 0] - - case 12: - # list indices - s = [2, 5, 9] - indices = [2, 5, 9] - - case 13: - # bool indices - s = a > 5 - indices = [6, 7, 8, 9] - - case 14: - # list indices with negatives - s = [1, 4, -2] - indices = [1, 4, 8] - - case 15: - # array indices - s = np.array([1, 4, -7, 9]) - indices = [1, 4, 3, 9] - - others = [i for i in a if i not in indices] - - offset, size = (min(indices), np.ptp(indices) + 1) - - return {"slice": s, "indices": indices, "others": others, "offset": offset, "size": size} - - def make_colors_buffer() -> ColorFeature: colors = ColorFeature(colors="w", n_colors=10) return colors +@pytest.mark.parametrize("color_input", [*generate_color_inputs("r"), *generate_color_inputs("g"), *generate_color_inputs("b")]) +def test_create_buffer(color_input): + colors = ColorFeature(colors=color_input, n_colors=10) + truth = np.repeat([pygfx.Color(color_input)], 10, axis=0) + npt.assert_almost_equal(colors[:], truth) + + def test_int(): # setting single points colors = make_colors_buffer() @@ -194,6 +105,7 @@ def test_tuple(slice_method): @pytest.mark.parametrize("color_input", generate_color_inputs("red")) +# skip testing with int since that results in shape [1, 4] with np.repeat, int tested in independent unit test @pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 16)]) def test_slice(color_input, slice_method: dict): # slicing only first dim From c74d7b9ddb5cb18458794fcbef08477ed2cc6a90 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 00:32:29 -0400 Subject: [PATCH 28/77] also test with direct truth indices in colors --- tests/test_colors_buffer_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py index cd39a0ad3..1a1908316 100644 --- a/tests/test_colors_buffer_manager.py +++ b/tests/test_colors_buffer_manager.py @@ -124,6 +124,7 @@ def test_slice(color_input, slice_method: dict): truth = np.repeat([pygfx.Color(color_input)], repeats=len(indices), axis=0) # check that correct indices are modified npt.assert_almost_equal(colors[s], truth) + npt.assert_almost_equal(colors[indices], truth) upload_offset, upload_size = colors.buffer._gfx_pending_uploads[-1] # sometimes when slicing with step, it will over-estimate offset From cecb438fc313a16a2910092f4b159505c66c3ae2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 00:32:43 -0400 Subject: [PATCH 29/77] points tests, works --- tests/test_points_data_buffer_manager.py | 117 +++++++++++++++++++++++ tests/utils.py | 98 +++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/test_points_data_buffer_manager.py create mode 100644 tests/utils.py diff --git a/tests/test_points_data_buffer_manager.py b/tests/test_points_data_buffer_manager.py new file mode 100644 index 000000000..a0f528b08 --- /dev/null +++ b/tests/test_points_data_buffer_manager.py @@ -0,0 +1,117 @@ +from typing import Literal + +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +from fastplotlib.graphics._features import PointsDataFeature +from .utils import generate_slice_indices + + +def generate_data(inputs: str) -> np.ndarray: + """ + Generates a spiral/spring + + Only 10 points so a very pointy spiral but easier to spot changes :D + """ + xs = np.linspace(0, 10 * np.pi, 10) + ys = np.sin(xs) + zs = np.cos(xs) + + match inputs: + case "y": + data = ys + + case "xy": + data = np.column_stack([xs, ys]) + + case "xyz": + data = np.column_stack([xs, ys, zs]) + + return data.astype(np.float32) + + +@pytest.mark.parametrize("data", [generate_data(v) for v in ["y", "xy", "xyz"]]) +def test_create_buffer(data): + points_data = PointsDataFeature(data) + + if data.ndim == 1: + # only y-vals specified + npt.assert_almost_equal(points_data[:, 1], generate_data("y")) + # x-vals are auto generated just using arange + npt.assert_almost_equal(points_data[:, 0], np.arange(data.size)) + + elif data.shape[1] == 2: + # test 2D + npt.assert_almost_equal(points_data[:, :-1], generate_data("xy")) + npt.assert_almost_equal(points_data[:, -1], 0.) + + + elif data.shape[1] == 3: + # test 3D spiral + npt.assert_almost_equal(points_data[:], generate_data("xyz")) + + +def test_int(): + data = generate_data("xyz") + # test setting single points + points = PointsDataFeature(data) + + # set all x, y, z points, create a kink in the spiral + points[2] = 1. + npt.assert_almost_equal(points[2], 1.) + # make sure other points are not affected + indices = list(range(10)) + indices.pop(2) + npt.assert_almost_equal(points[indices], data[indices]) + + +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 16)]) +@pytest.mark.parametrize("test_axis", ["y", "xy", "xyz"]) +def test_slice(slice_method: dict, test_axis: str): + data = generate_data("xyz") + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + points = PointsDataFeature(data) + # TODO: placeholder until I make a testing figure where we draw frames only on call + points.buffer._gfx_pending_uploads.clear() + + match test_axis: + case "y": + points[s, 1] = -data[s, 1] + npt.assert_almost_equal(points[s, 1], -data[s, 1]) + npt.assert_almost_equal(points[indices, 1], -data[indices, 1]) + # make sure other points are not modified + npt.assert_almost_equal(points[others, 1], data[others, 1]) # other points in same dimension + npt.assert_almost_equal(points[:, 2:], data[:, 2:]) # dimensions that are not sliced + + case "xy": + points[s, :-1] = -data[s, :-1] + npt.assert_almost_equal(points[s, :-1], -data[s, :-1]) + npt.assert_almost_equal(points[indices, :-1], -data[s, :-1]) + # make sure other points are not modified + npt.assert_almost_equal(points[others, :-1], data[others, :-1]) # other points in the same dimensions + npt.assert_almost_equal(points[:, -1], data[:, -1]) # dimensions that are not touched + + case "xyz": + points[s] = -data[s] + npt.assert_almost_equal(points[s], -data[s]) + npt.assert_almost_equal(points[indices], -data[s]) + # make sure other points are not modified + npt.assert_almost_equal(points[others], data[others]) + + upload_offset, upload_size = points.buffer._gfx_pending_uploads[-1] + # sometimes when slicing with step, it will over-estimate offset + # but it overestimates to upload 1 extra point so it's fine + assert (upload_offset == offset) or (upload_offset == offset - 1) + + # sometimes when slicing with step, it will over-estimate size + # but it overestimates to upload 1 extra point so it's fine + assert (upload_size == size) or (upload_size == size + 1) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..683a9ba46 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,98 @@ +import numpy as np + + +def generate_slice_indices(kind: int): + n_elements = 10 + a = np.arange(n_elements) + + match kind: + case 0: + # simplest, just int + s = 2 + indices = [2] + + case 1: + # everything + s = slice(None, None, None) + indices = list(range(10)) + + case 2: + # positive continuous range + s = slice(1, 5, None) + indices = [1, 2, 3, 4] + + case 3: + # positive stepped range + s = slice(2, 8, 2) + indices = [2, 4, 6] + + case 4: + # negative continuous range + s = slice(-5, None, None) + indices = [5, 6, 7, 8, 9] + + case 5: + # negative backwards + s = slice(-5, None, -1) + indices = [5, 4, 3, 2, 1, 0] + + case 5: + # negative backwards stepped + s = slice(-5, None, -2) + indices = [5, 3, 1] + + case 6: + # negative stepped forward + s = slice(-5, None, 2) + indices = [5, 7, 9] + + case 7: + # both negative + s = slice(-8, -2, None) + indices = [2, 3, 4, 5, 6, 7] + + case 8: + # both negative and stepped + s = slice(-8, -2, 2) + indices = [2, 4, 6] + + case 9: + # positive, negative, negative + s = slice(8, -9, -2) + indices = [8, 6, 4, 2] + + case 10: + # only stepped forward + s = slice(None, None, 2) + indices = [0, 2, 4, 6, 8] + + case 11: + # only stepped backward + s = slice(None, None, -3) + indices = [9, 6, 3, 0] + + case 12: + # list indices + s = [2, 5, 9] + indices = [2, 5, 9] + + case 13: + # bool indices + s = a > 5 + indices = [6, 7, 8, 9] + + case 14: + # list indices with negatives + s = [1, 4, -2] + indices = [1, 4, 8] + + case 15: + # array indices + s = np.array([1, 4, -7, 9]) + indices = [1, 4, 3, 9] + + others = [i for i in a if i not in indices] + + offset, size = (min(indices), np.ptp(indices) + 1) + + return {"slice": s, "indices": indices, "others": others, "offset": offset, "size": size} From 0a20008f6bc01e11bada5bdfde624210447c288c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 00:57:27 -0400 Subject: [PATCH 30/77] remove imports --- tests/test_points_data_buffer_manager.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_points_data_buffer_manager.py b/tests/test_points_data_buffer_manager.py index a0f528b08..12975162e 100644 --- a/tests/test_points_data_buffer_manager.py +++ b/tests/test_points_data_buffer_manager.py @@ -1,11 +1,7 @@ -from typing import Literal - import numpy as np from numpy import testing as npt import pytest -import pygfx - from fastplotlib.graphics._features import PointsDataFeature from .utils import generate_slice_indices @@ -68,7 +64,7 @@ def test_int(): npt.assert_almost_equal(points[indices], data[indices]) -@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 16)]) +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 16)]) # int tested separately @pytest.mark.parametrize("test_axis", ["y", "xy", "xyz"]) def test_slice(slice_method: dict, test_axis: str): data = generate_data("xyz") From a1c8c873996251020e89d3b1ae9b88e6cfa02bb0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 01:02:08 -0400 Subject: [PATCH 31/77] sizes test working, other cleanup --- tests/__init__.py | 0 tests/test_colors_buffer_manager.py | 12 +--- tests/test_points_data_buffer_manager.py | 12 +--- tests/test_sizes_buffer_manager.py | 74 ++++++++++++++++++++++++ tests/utils.py | 13 +++++ 5 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_sizes_buffer_manager.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py index 1a1908316..884a70eed 100644 --- a/tests/test_colors_buffer_manager.py +++ b/tests/test_colors_buffer_manager.py @@ -5,7 +5,7 @@ import pygfx from fastplotlib.graphics._features import ColorFeature -from .utils import generate_slice_indices +from .utils import generate_slice_indices, assert_pending_uploads def generate_color_inputs(name: str) -> list[str, np.ndarray, list, tuple]: @@ -126,14 +126,8 @@ def test_slice(color_input, slice_method: dict): npt.assert_almost_equal(colors[s], truth) npt.assert_almost_equal(colors[indices], truth) - upload_offset, upload_size = colors.buffer._gfx_pending_uploads[-1] - # sometimes when slicing with step, it will over-estimate offset - # but it overestimates to upload 1 extra point so it's fine - assert (upload_offset == offset) or (upload_offset == offset - 1) - - # sometimes when slicing with step, it will over-estimate size - # but it overestimates to upload 1 extra point so it's fine - assert (upload_size == size) or (upload_size == size + 1) + # make sure correct offset and size marked for pending upload + assert_pending_uploads(colors.buffer, offset, size) # check that others are not touched others_truth = np.repeat([[1., 1., 1., 1.]], repeats=len(others), axis=0) diff --git a/tests/test_points_data_buffer_manager.py b/tests/test_points_data_buffer_manager.py index 12975162e..ac0e7c784 100644 --- a/tests/test_points_data_buffer_manager.py +++ b/tests/test_points_data_buffer_manager.py @@ -3,7 +3,7 @@ import pytest from fastplotlib.graphics._features import PointsDataFeature -from .utils import generate_slice_indices +from .utils import generate_slice_indices, assert_pending_uploads def generate_data(inputs: str) -> np.ndarray: @@ -103,11 +103,5 @@ def test_slice(slice_method: dict, test_axis: str): # make sure other points are not modified npt.assert_almost_equal(points[others], data[others]) - upload_offset, upload_size = points.buffer._gfx_pending_uploads[-1] - # sometimes when slicing with step, it will over-estimate offset - # but it overestimates to upload 1 extra point so it's fine - assert (upload_offset == offset) or (upload_offset == offset - 1) - - # sometimes when slicing with step, it will over-estimate size - # but it overestimates to upload 1 extra point so it's fine - assert (upload_size == size) or (upload_size == size + 1) + # make sure correct offset and size marked for pending upload + assert_pending_uploads(points.buffer, offset, size) diff --git a/tests/test_sizes_buffer_manager.py b/tests/test_sizes_buffer_manager.py new file mode 100644 index 000000000..0f90353f4 --- /dev/null +++ b/tests/test_sizes_buffer_manager.py @@ -0,0 +1,74 @@ +import numpy as np +from numpy import testing as npt +import pytest + +from fastplotlib.graphics._features import PointsSizesFeature +from .utils import generate_slice_indices, assert_pending_uploads + + +def generate_data(input_type: str) -> np.ndarray | float: + """ + Point sizes varying with a sine wave + + Parameters + ---------- + input_type: str + one of "sine", "cosine", or "float" + """ + if input_type == "float": + return 10. + xs = np.linspace(0, 10 * np.pi, 10) + + if input_type == "sine": + return np.abs(np.sin(xs)).astype(np.float32) + + if input_type == "cosine": + return np.abs(np.cos(xs)).astype(np.float32) + + +@pytest.mark.parametrize("data", [generate_data(v) for v in ["float", "sine"]]) +def test_create_buffer(data): + sizes = PointsSizesFeature(data, n_datapoints=10) + + if isinstance(data, float): + npt.assert_almost_equal(sizes[:], generate_data("float")) + + elif isinstance(data, np.ndarray): + npt.assert_almost_equal(sizes[:], generate_data("sine")) + + +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(0, 16)]) +@pytest.mark.parametrize("user_input", ["float", "cosine"]) +def test_slice(slice_method: dict, user_input: str): + data = generate_data("sine") + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + sizes = PointsSizesFeature(data, n_datapoints=10) + + # TODO: placeholder until I make a testing figure where we draw frames only on call + sizes.buffer._gfx_pending_uploads.clear() + + match user_input: + case "float": + sizes[s] = 20. + truth = np.full(len(indices), 20.) + npt.assert_almost_equal(sizes[s], truth) + npt.assert_almost_equal(sizes[indices], truth) + # make sure other sizes not modified + npt.assert_almost_equal(sizes[others], data[others]) + + case "cosine": + cosine = generate_data("cosine") + sizes[s] = cosine[s] + npt.assert_almost_equal(sizes[s], cosine[s]) + npt.assert_almost_equal(sizes[indices], cosine[s]) + # make sure other sizes not modified + npt.assert_almost_equal(sizes[others], data[others]) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(sizes.buffer, offset, size) diff --git a/tests/utils.py b/tests/utils.py index 683a9ba46..df991095a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,7 @@ import numpy as np +import pygfx + def generate_slice_indices(kind: int): n_elements = 10 @@ -96,3 +98,14 @@ def generate_slice_indices(kind: int): offset, size = (min(indices), np.ptp(indices) + 1) return {"slice": s, "indices": indices, "others": others, "offset": offset, "size": size} + + +def assert_pending_uploads(buffer: pygfx.Buffer, offset: int, size: int): + upload_offset, upload_size = buffer._gfx_pending_uploads[-1] + # sometimes when slicing with step, it will over-estimate offset + # but it overestimates to upload 1 extra point so it's fine + assert (upload_offset == offset) or (upload_offset == offset - 1) + + # sometimes when slicing with step, it will over-estimate size + # but it overestimates to upload 1 extra point so it's fine + assert (upload_size == size) or (upload_size == size + 1) \ No newline at end of file From db93f7021594b29ca6680b84ca3508a86a3a65cf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 01:04:52 -0400 Subject: [PATCH 32/77] export sizes feature again --- fastplotlib/graphics/_features/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index 417472f72..535167ef6 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,6 +1,6 @@ from ._colors import ColorFeature#, CmapFeature, ImageCmapFeature, HeatmapCmapFeature from ._data import PointsDataFeature#, ImageDataFeature, HeatmapDataFeature -# from ._sizes import PointsSizesFeature +from ._sizes import PointsSizesFeature # from ._present import PresentFeature # from ._thickness import ThicknessFeature from ._base import ( @@ -41,9 +41,6 @@ class Deleted: class CmapFeature: pass -class PointsSizesFeature: - pass - class ThicknessFeature: pass From baca1fc8378498894f4db249d2918c9ea7e414b5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 01:05:22 -0400 Subject: [PATCH 33/77] ideas for sharing and unsharing buffers between graphics --- fastplotlib/graphics/_features/_base.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index c138b3c8e..40675cba7 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -201,6 +201,8 @@ def __init__( self._event_handlers: list[callable] = list() + self._shared = False + @property def value(self) -> NDArray: return self.buffer.data @@ -209,6 +211,11 @@ def value(self) -> NDArray: def buffer(self) -> pygfx.Buffer | pygfx.Texture: return self._buffer + @property + def shared(self) -> bool: + """If the buffer is shared between multiple graphics""" + return self._shared + def __getitem__(self, item): return self.buffer.data[item] From a177a7f63f6ba149657100f105186c3149225a17 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 01:05:37 -0400 Subject: [PATCH 34/77] ideas for sharing and unsharing buffers between graphics, nto tested --- fastplotlib/graphics/line.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index b4589f413..a21168e55 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -92,11 +92,15 @@ def __init__( n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values ) - self._colors = ColorFeature( - colors, - n_colors=self._data.value.shape[0], - alpha=alpha, - ) + if isinstance(colors, ColorFeature): + self._colors = colors + self._shared = True + else: + self._colors = ColorFeature( + colors, + n_colors=self._data.value.shape[0], + alpha=alpha, + ) # self.cmap = CmapFeature( # self, self.colors(), cmap_name=cmap, cmap_values=cmap_values @@ -122,6 +126,16 @@ def __init__( if z_position is not None: self.position_z = z_position + def unshare_buffer(self, feature: str): + f = getattr(self, feature) + if not f.shared: + raise BufferError + + if isinstance(f, ColorFeature): + self._colors._buffer = pygfx.Buffer(self._colors.value.copy()) + self.world_object.geometry.colors = self._colors.buffer + self._colors._shared = False + def add_linear_selector( self, selection: int = None, padding: float = 50, **kwargs ) -> LinearSelector: From c205b10bc733e2ae06ee70b94576c1e86c0921b0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 01:05:48 -0400 Subject: [PATCH 35/77] typing --- fastplotlib/graphics/_features/_sizes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/_features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py index 3c4139c08..b45474e5e 100644 --- a/fastplotlib/graphics/_features/_sizes.py +++ b/fastplotlib/graphics/_features/_sizes.py @@ -15,7 +15,7 @@ class PointsSizesFeature(BufferManager): def __init__( self, - sizes: np.ndarray | list[int | float] | tuple[int | float], + sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], n_datapoints: int, isolated_buffer: bool = True ): @@ -23,7 +23,7 @@ def __init__( super().__init__(data=sizes, isolated_buffer=isolated_buffer) def _fix_sizes(self, sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], n_datapoints: int): - if np.issubdtype(type(sizes), np.integer): + if np.issubdtype(type(sizes), np.number): # single value given sizes = np.full( n_datapoints, sizes, dtype=np.float32 From f380b26eeb273f538a15ab3596bf7abea6ce374f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 14:46:59 -0400 Subject: [PATCH 36/77] attach and detach buffers to a graphic, not tested --- fastplotlib/graphics/_base.py | 66 +++++++++++++++++++++++-- fastplotlib/graphics/_features/_base.py | 6 +-- fastplotlib/graphics/line.py | 16 ++---- fastplotlib/graphics/scatter.py | 4 +- 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index aaa4f6f73..88b4d3225 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -7,9 +7,9 @@ import numpy as np import pylinalg as la -from pygfx import WorldObject +import pygfx -from ._features import GraphicFeature, PresentFeature, BufferManager, GraphicFeatureDescriptor, Deleted +from ._features import GraphicFeature, PresentFeature, BufferManager, GraphicFeatureDescriptor, Deleted, PointsDataFeature, ColorFeature, PointsSizesFeature HexStr: TypeAlias = str @@ -112,14 +112,20 @@ def name(self, name: str): self._name = name @property - def world_object(self) -> WorldObject: + def world_object(self) -> pygfx.WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" # We use weakref to simplify garbage collection return weakref.proxy(WORLD_OBJECTS[self._fpl_address]) - def _set_world_object(self, wo: WorldObject): + def _set_world_object(self, wo: pygfx.WorldObject): WORLD_OBJECTS[self._fpl_address] = wo + def detach_feature(self, feature: str): + raise NotImplementedError + + def attach_feature(self, feature: BufferManager): + raise NotImplementedError + @property def position(self) -> np.ndarray: """position of the graphic, [x, y, z]""" @@ -175,7 +181,7 @@ def visible(self, v: bool): self.world_object.visible = v @property - def children(self) -> list[WorldObject]: + def children(self) -> list[pygfx.WorldObject]: """Return the children of the WorldObject.""" return self.world_object.children @@ -264,6 +270,56 @@ def rotate(self, alpha: float, axis: Literal["x", "y", "z"] = "y"): self.rotation = la.quat_mul(rot, self.rotation) +class PositionsGraphic(Graphic): + """Base class for LineGraphic and ScatterGraphic""" + + def detach_feature(self, feature: str): + if not isinstance(feature, str): + raise TypeError + + f = getattr(self, feature) + if f.shared == 0: + raise BufferError("Cannot detach an independent buffer") + + if feature == "colors": + self._colors._buffer = pygfx.Buffer(self._colors.value.copy()) + self.world_object.geometry.colors = self._colors.buffer + self._colors._shared -= 1 + + elif feature == "data": + self._data._buffer = pygfx.Buffer(self._data.value.copy()) + self.world_object.geometry.positions = self._data.buffer + self._data._shared -= 1 + + elif feature == "sizes": + self._sizes._buffer = pygfx.Buffer(self._sizes.value.copy()) + self.world_object.geometry.positions = self._sizes.buffer + self._sizes._shared -= 1 + + def attach_feature(self, feature: PointsDataFeature | ColorFeature | PointsSizesFeature): + if isinstance(feature, PointsDataFeature): + # TODO: check if this causes a memory leak + self._data._shared -= 1 + + self._data = feature + self._data._shared += 1 + self.world_object.geometry.positions = self._data.buffer + + elif isinstance(feature, ColorFeature): + self._colors._shared -= 1 + + self._colors = feature + self._colors._shared += 1 + self.world_object.geometry.colors = self._colors.buffer + + elif isinstance(feature, PointsSizesFeature): + self._sizes._shared -= 1 + + self._sizes = feature + self._sizes._shared += 1 + self.world_object.geometry.sizes = self._sizes.buffer + + class Interaction(ABC): """Mixin class that makes graphics interactive""" diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 40675cba7..49dc6f09d 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -201,7 +201,7 @@ def __init__( self._event_handlers: list[callable] = list() - self._shared = False + self._shared: int = 0 @property def value(self) -> NDArray: @@ -212,8 +212,8 @@ def buffer(self) -> pygfx.Buffer | pygfx.Texture: return self._buffer @property - def shared(self) -> bool: - """If the buffer is shared between multiple graphics""" + def shared(self) -> int: + """Number of graphics that share this buffer""" return self._shared def __getitem__(self, item): diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index a21168e55..b775dbd93 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -6,12 +6,12 @@ import pygfx from ..utils import parse_cmap_values -from ._base import Graphic, Interaction, PreviouslyModifiedData +from ._base import PositionsGraphic, Interaction, PreviouslyModifiedData from ._features import GraphicFeatureDescriptor, PointsDataFeature, ColorFeature#, CmapFeature, ThicknessFeature from .selectors import LinearRegionSelector, LinearSelector -class LineGraphic(Graphic, Interaction): +class LineGraphic(PositionsGraphic, Interaction): features = {"data", "colors"}#, "cmap", "thickness", "present"} def __init__( @@ -94,7 +94,7 @@ def __init__( if isinstance(colors, ColorFeature): self._colors = colors - self._shared = True + self._colors._shared += 1 else: self._colors = ColorFeature( colors, @@ -126,16 +126,6 @@ def __init__( if z_position is not None: self.position_z = z_position - def unshare_buffer(self, feature: str): - f = getattr(self, feature) - if not f.shared: - raise BufferError - - if isinstance(f, ColorFeature): - self._colors._buffer = pygfx.Buffer(self._colors.value.copy()) - self.world_object.geometry.colors = self._colors.buffer - self._colors._shared = False - def add_linear_selector( self, selection: int = None, padding: float = 50, **kwargs ) -> LinearSelector: diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 8682df3d5..eddf0fac3 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -4,11 +4,11 @@ import pygfx from ..utils import parse_cmap_values -from ._base import Graphic +from ._base import PositionsGraphic from ._features import PointsDataFeature, ColorFeature, CmapFeature, PointsSizesFeature -class ScatterGraphic(Graphic): +class ScatterGraphic(PositionsGraphic): feature_events = {"data", "sizes", "colors", "cmap", "present"} def __init__( From 3585c9ec4d37bc771b72a96806298cb2bfbdd263 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 14:47:15 -0400 Subject: [PATCH 37/77] import --- fastplotlib/graphics/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 88b4d3225..a93b1cace 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -16,7 +16,7 @@ # dict that holds all world objects for a given python kernel/session # Graphic objects only use proxies to WorldObjects -WORLD_OBJECTS: dict[HexStr, WorldObject] = dict() #: {hex id str: WorldObject} +WORLD_OBJECTS: dict[HexStr, pygfx.WorldObject] = dict() #: {hex id str: WorldObject} PYGFX_EVENTS = [ From 8d32134a17e5e40daebb4e1cb085fb5cf08134e0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 16:17:33 -0400 Subject: [PATCH 38/77] more int point tests --- tests/test_points_data_buffer_manager.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_points_data_buffer_manager.py b/tests/test_points_data_buffer_manager.py index ac0e7c784..eac03664a 100644 --- a/tests/test_points_data_buffer_manager.py +++ b/tests/test_points_data_buffer_manager.py @@ -63,6 +63,20 @@ def test_int(): indices.pop(2) npt.assert_almost_equal(points[indices], data[indices]) + # reset + points = data + npt.assert_almost_equal(points[:], data) + + # just set y value + points[3, 1] = 1. + npt.assert_almost_equal(points[3, 1], 1.) + # make sure others not modified + npt.assert_almost_equal(points[3, 0], data[3, 0]) + npt.assert_almost_equal(points[3, 2], data[3, 2]) + indices = list(range(10)) + indices.pop(3) + npt.assert_almost_equal(points[indices], data[indices]) + @pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 16)]) # int tested separately @pytest.mark.parametrize("test_axis", ["y", "xy", "xyz"]) From deceeeb22ce3ea0290bdfdb6cb3b7e317e0464b4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 19:02:11 -0400 Subject: [PATCH 39/77] Graphic.add_event_handler --- fastplotlib/graphics/_base.py | 70 +++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index a93b1cace..26f5ea57d 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,3 +1,5 @@ +from collections import defaultdict +from functools import partial from typing import Any, Literal, TypeAlias import weakref from warnings import warn @@ -6,6 +8,7 @@ import numpy as np import pylinalg as la +from wgpu.gui.base import log_exception import pygfx @@ -93,6 +96,8 @@ def __init__( self._plot_area = None + self._event_handlers = defaultdict(set) + @property def name(self) -> str | None: """str name reference for this item""" @@ -185,6 +190,71 @@ def children(self) -> list[pygfx.WorldObject]: """Return the children of the WorldObject.""" return self.world_object.children + def add_event_handler(self, *args): + """ + Register an event handler. + + Parameters + ---------- + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" + + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html + + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented + + Can also be used as a decorator. + + Example + ------- + + .. code-block:: py + + def my_handler(event): + print(event) + + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") + + Decorator usage example: + + .. code-block:: py + + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ + + decorating = not callable(args[0]) + callback = None if decorating else args[0] + types = args if decorating else args[1:] + + def decorator(_callback): + _callback_injector = partial(self._handle_event, _callback) # adds graphic instance as attribute + + for type in types: + if type in self.features: + # fpl feature event + feature = getattr(self, f"_{type}") + feature.add_event_handler(_callback_injector) + else: + # wrap pygfx event + self.world_object._event_handlers[type].add(_callback_injector) + return _callback + + if decorating: + return decorator + + return decorator(callback) + + def _handle_event(self, callback, event: pygfx.Event): + """Wrap pygfx event to add graphic to pick_info""" + event.graphic = self + callback(event) + def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area From 66b5e6dc4d7ecea75a2ce97408b69169af7d0e83 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 21:08:28 -0400 Subject: [PATCH 40/77] adding and removing data feature event works and tested --- fastplotlib/graphics/_base.py | 53 ++++++++++++- fastplotlib/graphics/_features/_base.py | 54 ++++++------- fastplotlib/graphics/_features/_colors.py | 52 ++----------- fastplotlib/graphics/_features/_data.py | 32 +------- fastplotlib/graphics/line.py | 12 +-- fastplotlib/graphics/scatter.py | 29 ++++--- tests/events.py | 92 +++++++++++++++++++++++ 7 files changed, 197 insertions(+), 127 deletions(-) create mode 100644 tests/events.py diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 26f5ea57d..2992cf875 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -96,8 +96,12 @@ def __init__( self._plot_area = None + # event handlers self._event_handlers = defaultdict(set) + # maps callbacks to their partials + self._event_handler_wrappers = defaultdict(set) + @property def name(self) -> str | None: """str name reference for this item""" @@ -190,6 +194,14 @@ def children(self) -> list[pygfx.WorldObject]: """Return the children of the WorldObject.""" return self.world_object.children + @property + def event_handlers(self) -> list[tuple[str, callable, ...]]: + """ + Registered event handlers. Read-only use ``add_event_handler()`` + and ``remove_event_handler()`` to manage callbacks + """ + return list(self._event_handlers.items()) + def add_event_handler(self, *args): """ Register an event handler. @@ -235,14 +247,20 @@ def my_handler(event): def decorator(_callback): _callback_injector = partial(self._handle_event, _callback) # adds graphic instance as attribute - for type in types: - if type in self.features: + for t in types: + # add to our record + self._event_handlers[t].add(_callback) + + if t in self.features: # fpl feature event - feature = getattr(self, f"_{type}") + feature = getattr(self, f"_{t}") feature.add_event_handler(_callback_injector) else: # wrap pygfx event - self.world_object._event_handlers[type].add(_callback_injector) + self.world_object._event_handlers[t].add(_callback_injector) + + # keep track of the partial too + self._event_handler_wrappers[t].add((_callback, _callback_injector)) return _callback if decorating: @@ -253,8 +271,35 @@ def decorator(_callback): def _handle_event(self, callback, event: pygfx.Event): """Wrap pygfx event to add graphic to pick_info""" event.graphic = self + + if event.type in self.features: + # for feature events + event._target = self.world_object + callback(event) + def remove_event_handler(self, callback, *types): + # remove from our record first + for t in types: + for wrapper_map in self._event_handler_wrappers[t]: + # TODO: not sure if we can handle this mapping in a better way + if wrapper_map[0] == callback: + wrapper = wrapper_map[1] + self._event_handler_wrappers[t].remove(wrapper_map) + break + else: + raise KeyError(f"event type: {t} with callback: {callback} is not registered") + + self._event_handlers[t].remove(callback) + # remove callback wrapper from world object if pygfx event + if t in PYGFX_EVENTS: + print("pygfx event") + print(wrapper) + self.world_object.remove_event_handler(wrapper, t) + else: + feature = getattr(self, f"_{t}") + feature.remove_event_handler(wrapper) + def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 49dc6f09d..dd2e060ba 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -6,6 +6,8 @@ import numpy as np from numpy.typing import NDArray +from wgpu.gui.base import log_exception + import pygfx @@ -41,7 +43,7 @@ def to_gpu_supported_dtype(array): return array -class FeatureEvent: +class FeatureEvent(pygfx.Event): """ Dataclass that holds feature event information. Has ``type`` and ``pick_info`` attributes. @@ -64,16 +66,9 @@ class FeatureEvent: """ - def __init__(self, type: str, pick_info: dict): - self.type = type - self.pick_info = pick_info - - def __repr__(self): - return ( - f"{self.__class__.__name__} @ {hex(id(self))}\n" - f"type: {self.type}\n" - f"pick_info: {self.pick_info}\n" - ) + def __init__(self, type: str, info: dict): + super().__init__(type=type) + self.info = info class GraphicFeature: @@ -100,7 +95,7 @@ def block_events(self, val: bool): def add_event_handler(self, handler: callable): """ - Add an event handler. All added event handlers are calledcollection_ind when this feature changes. + Add an event handler. All added event handlers are called when this feature changes. The ``handler`` can optionally accept a :class:`.FeatureEvent` as the first and only argument. The ``FeatureEvent`` only has 2 attributes, ``type`` which denotes the type of event @@ -141,32 +136,13 @@ def clear_event_handlers(self): """Clear all event handlers""" self._event_handlers.clear() - # TODO: maybe this can be implemented right here in the base class - @abstractmethod - def _feature_changed(self,new_data: Any, key: int | slice | tuple[slice] | None = None): - """Called whenever a feature changes, and it calls all funcs in self._event_handlers""" - pass - def _call_event_handlers(self, event_data: FeatureEvent): if self._block_events: return for func in self._event_handlers: - try: - args = getfullargspec(func).args - - if len(args) > 0: - if args[0] == "self" and not len(args) > 1: - func() - else: - func(event_data) - else: - func() - except TypeError: - warn( - f"Event handler {func} has an unresolvable argspec, calling it without arguments" - ) - func() + with log_exception(f"Error during handling {self.__class__.__name__} event"): + func(event_data) def __repr__(self) -> str: raise NotImplementedError @@ -295,6 +271,18 @@ def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | self.buffer.update_range(offset=offset, size=size) + def _emit_event(self, type: str, key, value): + if len(self._event_handlers) < 1: + return + + event_info = { + "key": key, + "value": value, + } + event = FeatureEvent(type, info=event_info) + + super()._call_event_handlers(event) + def __repr__(self): return f"{self.__class__.__name__} buffer data:\n" \ f"{self.value.__repr__()}" diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index eedb5563e..4fd40ac0e 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -18,23 +18,11 @@ class ColorFeature(BufferManager): """ Manages the color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` - - **event pick info:** - - ==================== =============================== ========================================================================= - key type description - ==================== =============================== ========================================================================= - "index" ``numpy.ndarray`` or ``None`` changed indices in the buffer - "new_data" ``numpy.ndarray`` or ``None`` new buffer data at the changed indices - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== =============================== ========================================================================= - """ def __init__( self, - colors, + colors: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float, n_colors: int, alpha: float = None, isolated_buffer: bool = True, @@ -44,16 +32,14 @@ def __init__( Parameters ---------- - parent: Graphic or GraphicCollection - - colors: str, array, or iterable - specify colors as a single human readable string, RGBA array, + colors: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float + specify colors as a single human-readable string, RGBA array, or an iterable of strings or RGBA arrays n_colors: int - number of colors to hold, if passing in a single str or single RGBA array + number of colors, if passing in a single str or single RGBA array - alpha: float + alpha: float, optional alpha value for the colors """ @@ -66,11 +52,8 @@ def __setitem__( key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float ): - # if key is tuple assume they want to edit [n_points, RGBA] directly - # if key is slice | range | int | np.ndarray, they are slicing only n_points, get n_points and parse colors - if isinstance(key, tuple): - # directly setting RGBA values, we do no parsing + # directly setting RGBA values for points, we do no parsing if not isinstance(value, (int, float, np.ndarray)): raise TypeError( "Can only set from int, float, or array to set colors directly by slicing the entire array" @@ -118,29 +101,8 @@ def __setitem__( self.buffer.data[key] = value self._update_range(key) - # self._feature_changed(key, new_colors) - - def _feature_changed(self, key, new_data): - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - else: - raise TypeError("feature changed key must be slice or int") - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="colors", pick_info=pick_info) - self._call_event_handlers(event_data) + self._emit_event("colors", key, value) # class CmapFeature(ColorFeature): diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py index 2be6170ac..e7a422d93 100644 --- a/fastplotlib/graphics/_features/_data.py +++ b/fastplotlib/graphics/_features/_data.py @@ -43,40 +43,12 @@ def _fix_data(self, data): def __setitem__(self, key: int | slice | range | np.ndarray[int | bool] | tuple[slice, ...] | tuple[range, ...], value): # directly use the key to slice the buffer self.buffer.data[key] = value + # _update_range handles parsing the key to # determine offset and size for GPU upload self._update_range(key) - # avoid creating dicts constantly if there are no events to handle - # if len(self._event_handlers) > 0: - # self._feature_changed(key, value) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, (int, np.integer)): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - elif key is None: - indices = None - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) - - # def __repr__(self) -> str: - # s = f"PointsDataFeature for {self._parent}, call `.data()` to get values" - # return s + self._emit_event("data", key, value) # # class ImageDataFeature(GraphicFeatureIndexable): diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index b775dbd93..6685e38a6 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -118,7 +118,7 @@ def __init__( world_object: pygfx.Line = pygfx.Line( # self.data.feature_data because data is a Buffer geometry=pygfx.Geometry(positions=self._data.buffer, colors=self._colors.buffer), - material=material(thickness=thickness, color_mode="vertex"), + material=material(thickness=thickness, color_mode="vertex", pick_write=True), ) self._set_world_object(world_object) @@ -231,7 +231,7 @@ def add_linear_region_selector( # TODO: this method is a bit of a mess, can refactor later def _get_linear_selector_init_args(self, padding: float, **kwargs): # computes initial bounds, limits, size and origin of linear selectors - data = self.data() + data = self.data.value if "axis" in kwargs.keys(): axis = kwargs["axis"] @@ -255,8 +255,8 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): # endpoints of the data range # used by linear selector but not linear region end_points = ( - self.data()[:, 1].min() - padding, - self.data()[:, 1].max() + padding, + self.data.value[:, 1].min() - padding, + self.data.value[:, 1].max() + padding, ) else: offset = self.position_y @@ -273,8 +273,8 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): origin = (position_x + self.position_x, limits[0] - offset) end_points = ( - self.data()[:, 0].min() - padding, - self.data()[:, 0].max() + padding, + self.data.value[:, 0].min() - padding, + self.data.value[:, 0].max() + padding, ) # initial bounds are 20% of the limits range diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index eddf0fac3..d3f83d5e6 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -9,7 +9,7 @@ class ScatterGraphic(PositionsGraphic): - feature_events = {"data", "sizes", "colors", "cmap", "present"} + features = {"data", "sizes", "colors"}#, "cmap", "present"} def __init__( self, @@ -20,6 +20,7 @@ def __init__( cmap: str = None, cmap_values: np.ndarray | List = None, z_position: float = 0.0, + isolated_buffer: bool = True, *args, **kwargs, ): @@ -73,25 +74,35 @@ def __init__( Control the presence of the Graphic in the scene, set to ``True`` or ``False`` """ - self.data = PointsDataFeature(self, data) - n_datapoints = self.data().shape[0] + self._data = PointsDataFeature(data, isolated_buffer=isolated_buffer) + + n_datapoints = self._data.value.shape[0] if cmap is not None: colors = parse_cmap_values( n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values ) - self.colors = ColorFeature(self, colors, n_colors=n_datapoints, alpha=alpha) - self.cmap = CmapFeature( - self, self.colors(), cmap_name=cmap, cmap_values=cmap_values - ) + if isinstance(colors, ColorFeature): + self._colors = colors + self._colors._shared += 1 + else: + self._colors = ColorFeature( + colors, + n_colors=self._data.value.shape[0], + alpha=alpha, + ) + + # self.cmap = CmapFeature( + # self, self.colors(), cmap_name=cmap, cmap_values=cmap_values + # ) - self.sizes = PointsSizesFeature(self, sizes) + self._sizes = PointsSizesFeature(sizes, n_datapoints=n_datapoints) super().__init__(*args, **kwargs) world_object = pygfx.Points( pygfx.Geometry( - positions=self.data(), sizes=self.sizes(), colors=self.colors() + positions=self._data.buffer, sizes=self._sizes.buffer, colors=self._colors.buffer ), material=pygfx.PointsMaterial( color_mode="vertex", size_mode="vertex", pick_write=True diff --git a/tests/events.py b/tests/events.py new file mode 100644 index 000000000..57fde6886 --- /dev/null +++ b/tests/events.py @@ -0,0 +1,92 @@ +from functools import partial +import pytest +import numpy as np +from numpy import testing as npt +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import FeatureEvent + + +def make_positions_data() -> np.ndarray: + xs = np.linspace(0, 10 * np.pi, 10) + ys = np.sin(xs) + return np.column_stack([xs, ys]) + + +def make_line_graphic() -> fpl.LineGraphic: + return fpl.LineGraphic(make_positions_data()) + + +def make_scatter_graphic() -> fpl.ScatterGraphic: + return fpl.ScatterGraphic(make_positions_data()) + + +event_instance: FeatureEvent = None + + +def event_handler(event): + global event_instance + event_instance = event + + +decorated_event_instance: FeatureEvent = None + + +@pytest.mark.parametrize("graphic", [make_line_graphic(), make_scatter_graphic()]) +def test_positions_data_event(graphic: fpl.LineGraphic | fpl.ScatterGraphic): + global decorated_event_instance + global event_instance + + value = np.cos(np.linspace(0, 10 * np.pi, 10))[3:8] + + info = { + "key": (slice(3, 8, None), 1), + "value": value + } + + expected = FeatureEvent(type="data", info=info) + + def validate(graphic, handler, expected_feature_event, event_to_test): + assert expected_feature_event.type == event_to_test.type + assert expected_feature_event.info["key"] == event_to_test.info["key"] + + npt.assert_almost_equal(expected_feature_event.info["value"], event_to_test.info["value"]) + + # should only have one event handler + assert graphic._event_handlers["data"] == {handler} + + # make sure wrappers are correct + wrapper_map = tuple(graphic._event_handler_wrappers["data"])[0] + assert wrapper_map[0] is handler + assert isinstance(wrapper_map[1], partial) + assert wrapper_map[1].func == graphic._handle_event + assert wrapper_map[1].args[0] is handler + + # test remove handler + graphic.remove_event_handler(handler, "data") + assert len(graphic._event_handlers["click"]) == 0 + assert len(graphic._event_handler_wrappers["click"]) == 0 + assert len(graphic.world_object._event_handlers["click"]) == 0 + + # reset data + graphic.data[:, :-1] = make_positions_data() + event_to_test = None + + # test decorated function + @graphic.add_event_handler("data") + def decorated_handler(event): + global decorated_event_instance + decorated_event_instance = event + + # test decorated + graphic.data[3:8, 1] = value + validate(graphic, decorated_handler, expected, decorated_event_instance) + + # test regular + graphic.add_event_handler(event_handler, "data") + graphic.data[3:8, 1] = value + + validate(graphic, event_handler, expected, event_instance) + + event_instance = None From 3a58feb207e59518f14f1722acc71990e39db6b5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 21 May 2024 21:44:41 -0400 Subject: [PATCH 41/77] common features, WIP --- fastplotlib/graphics/_base.py | 81 +-------------- fastplotlib/graphics/_features/__init__.py | 9 +- fastplotlib/graphics/_features/_base.py | 31 ++++-- fastplotlib/graphics/_features/_common.py | 103 +++++++++++++++++++ fastplotlib/graphics/_features/_deleted.py | 41 -------- fastplotlib/graphics/_features/_present.py | 72 ------------- fastplotlib/graphics/_features/_thickness.py | 45 ++------ 7 files changed, 140 insertions(+), 242 deletions(-) create mode 100644 fastplotlib/graphics/_features/_common.py delete mode 100644 fastplotlib/graphics/_features/_deleted.py delete mode 100644 fastplotlib/graphics/_features/_present.py diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 2992cf875..48ca37e12 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -12,7 +12,7 @@ import pygfx -from ._features import GraphicFeature, PresentFeature, BufferManager, GraphicFeatureDescriptor, Deleted, PointsDataFeature, ColorFeature, PointsSizesFeature +from ._features import GraphicFeature, BufferManager, GraphicFeatureDescriptor, Deleted, PointsDataFeature, ColorFeature, PointsSizesFeature, Name, Offset, Rotation, Visible HexStr: TypeAlias = str @@ -56,8 +56,7 @@ class Graphic(BaseGraphic): def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) - # all graphics give off a feature event when deleted - cls.features = {*cls.features}#, "deleted"} + cls.features = {*cls.features, "name", "offset", "rotation", "visible", "deleted"} # graphic feature class attributes for f in cls.features: @@ -83,7 +82,7 @@ def __init__( if (name is not None) and (not isinstance(name, str)): raise TypeError("Graphic `name` must be of type ") - self._name = name + self._name = Name(name) self.metadata = metadata self.collection_index = collection_index self.registered_callbacks = dict() @@ -92,7 +91,7 @@ def __init__( # store hex id str of Graphic instance mem location self._fpl_address: HexStr = hex(id(self)) - # self.deleted = Deleted(self, False) + self._deleted = Deleted(False) self._plot_area = None @@ -102,24 +101,6 @@ def __init__( # maps callbacks to their partials self._event_handler_wrappers = defaultdict(set) - @property - def name(self) -> str | None: - """str name reference for this item""" - return self._name - - @name.setter - def name(self, name: str): - if self.name == name: - return - - if not isinstance(name, str): - raise TypeError("`Graphic` name must be of type ") - - if self._plot_area is not None: - self._plot_area._check_graphic_name_exists(name) - - self._name = name - @property def world_object(self) -> pygfx.WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" @@ -135,60 +116,6 @@ def detach_feature(self, feature: str): def attach_feature(self, feature: BufferManager): raise NotImplementedError - @property - def position(self) -> np.ndarray: - """position of the graphic, [x, y, z]""" - return self.world_object.world.position - - @property - def position_x(self) -> float: - """x-axis position of the graphic""" - return self.world_object.world.x - - @property - def position_y(self) -> float: - """y-axis position of the graphic""" - return self.world_object.world.y - - @property - def position_z(self) -> float: - """z-axis position of the graphic""" - return self.world_object.world.z - - @position.setter - def position(self, val): - self.world_object.world.position = val - - @position_x.setter - def position_x(self, val): - self.world_object.world.x = val - - @position_y.setter - def position_y(self, val): - self.world_object.world.y = val - - @position_z.setter - def position_z(self, val): - self.world_object.world.z = val - - @property - def rotation(self): - return self.world_object.local.rotation - - @rotation.setter - def rotation(self, val): - self.world_object.local.rotation = val - - @property - def visible(self) -> bool: - """Access or change the visibility.""" - return self.world_object.visible - - @visible.setter - def visible(self, v: bool): - """Access or change the visibility.""" - self.world_object.visible = v - @property def children(self) -> list[pygfx.WorldObject]: """Return the children of the WorldObject.""" diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index 535167ef6..d81f8b432 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -11,8 +11,7 @@ to_gpu_supported_dtype, ) from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature -from ._deleted import Deleted -# +from ._common import Name, Offset, Rotation, Visible, Deleted # __all__ = [ # "ColorFeature", # "CmapFeature", @@ -32,12 +31,6 @@ # "Deleted", # ] -class PresentFeature: - pass - -class Deleted: - pass - class CmapFeature: pass diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index dd2e060ba..573bf7d83 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -75,12 +75,14 @@ class GraphicFeature: def __init__(self, **kwargs): self._event_handlers = list() self._block_events = False - self.collection_index: int = None @property def value(self) -> Any: raise NotImplemented + def set_value(self, graphic, value: float): + raise NotImplementedError + def block_events(self, val: bool): """ Block all events from this feature @@ -183,6 +185,10 @@ def __init__( def value(self) -> NDArray: return self.buffer.data + def set_value(self, graphic, value): + """Sets values on entire array""" + self[:] = value + @property def buffer(self) -> pygfx.Buffer | pygfx.Texture: return self._buffer @@ -289,16 +295,23 @@ def __repr__(self): class GraphicFeatureDescriptor: - def __init__(self, name): - self.name = name + def __init__(self, feature_name): + self.feature_name = feature_name def _get_feature(self, instance): - feature: GraphicFeature = getattr(instance, f"_{self.name}") + feature: GraphicFeature = getattr(instance, f"_{self.feature_name}") return feature - def __get__(self, instance, owner): - return self._get_feature(instance) + def __get__(self, graphic, owner): + f = self._get_feature(graphic) + if isinstance(f, BufferManager): + return f + else: + return f.value - def __set__(self, obj, value): - feature = self._get_feature(obj) - feature[:] = value + def __set__(self, graphic, value): + feature = self._get_feature(graphic) + if isinstance(feature, BufferManager): + feature[:] = value + else: + feature.set_value(graphic, value) diff --git a/fastplotlib/graphics/_features/_common.py b/fastplotlib/graphics/_features/_common.py new file mode 100644 index 000000000..cf8186966 --- /dev/null +++ b/fastplotlib/graphics/_features/_common.py @@ -0,0 +1,103 @@ +from ._base import GraphicFeature, FeatureEvent + + +class Name(GraphicFeature): + """Graphic name""" + def __init__(self, value: str): + self._value = value + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: bool): + if not isinstance(value, str): + raise TypeError("`Graphic` name must be of type ") + + if graphic._plot_area is not None: + graphic._plot_area._check_graphic_name_exists(value) + + self._value = value + + event = FeatureEvent(type="name", info={"value": value}) + self._call_event_handlers(event) + + +class Offset(GraphicFeature): + """Offset position of the graphic, [x, y, z]""" + def __init__(self, value: tuple[float, float, float]): + self._value = value + super().__init__() + + @property + def value(self) -> tuple[float, float, float]: + return self._value + + def set_value(self, graphic, value: tuple[float, float, float]): + if not len(value) == 3: + raise ValueError("offset must be a list, tuple, or array of 3 float values") + + graphic.position = value + self._value = value + + event = FeatureEvent(type="offset", info={"value": value}) + self._call_event_handlers(event) + + +class Rotation(GraphicFeature): + """Graphic rotation quaternion""" + def __init__(self, value: tuple[float, float, float, float]): + self._value = value + super().__init__() + + @property + def value(self) -> tuple[float, float, float, float]: + return self._value + + def set_value(self, graphic, value: tuple[float, float, float, float]): + if not len(value) == 4: + raise ValueError("rotation must be a list, tuple, or array of 4 float values" + "representing a quaternion") + + graphic.rotation = value + self._value = value + + event = FeatureEvent(type="rotation", info={"value": value}) + self._call_event_handlers(event) + + +class Visible(GraphicFeature): + """Access or change the visibility.""" + def __init__(self, value: bool): + self._value = value + super().__init__() + + @property + def value(self) -> bool: + return self._value + + def set_value(self, graphic, value: bool): + graphic.world_object.visible = value + self._value = value + + event = FeatureEvent(type="visible", info={"value": value}) + self._call_event_handlers(event) + + +class Deleted(GraphicFeature): + """ + Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted + """ + def __init__(self, value: bool): + self._value = value + super().__init__() + + @property + def value(self) -> bool: + return self._value + + def set_value(self, graphic, value: bool): + self._value = value + event = FeatureEvent(type="deleted", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_deleted.py b/fastplotlib/graphics/_features/_deleted.py deleted file mode 100644 index 7900385eb..000000000 --- a/fastplotlib/graphics/_features/_deleted.py +++ /dev/null @@ -1,41 +0,0 @@ -from ._base import GraphicFeature, FeatureEvent - - -class Deleted(GraphicFeature): - """ - Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== ======================== ========================================================================= - """ - - def __init__(self, parent, value: bool): - super().__init__(parent, value) - - def _set(self, value: bool): - value = self._parse_set_value(value) - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="deleted", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"DeletedFeature for {self._parent}" - return s diff --git a/fastplotlib/graphics/_features/_present.py b/fastplotlib/graphics/_features/_present.py deleted file mode 100644 index a73d66523..000000000 --- a/fastplotlib/graphics/_features/_present.py +++ /dev/null @@ -1,72 +0,0 @@ -from pygfx import Scene, Group - -from ._base import GraphicFeature, FeatureEvent - - -class PresentFeature(GraphicFeature): - """ - Toggles if the object is present in the scene, different from visible. - Useful for computing bounding boxes from the Scene to only include graphics - that are present. - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "index" ``None`` not used - "new_data" ``bool`` new data, ``True`` or ``False`` - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== ======================== ========================================================================= - """ - - def __init__(self, parent, present: bool = True, collection_index: int = False): - self._scene = None - super().__init__(parent, present, collection_index) - - def _set(self, present: bool): - present = self._parse_set_value(present) - - i = 0 - wo = self._parent.world_object - while not isinstance(self._scene, (Group, Scene)): - wo_parent = wo.parent - self._scene = wo_parent - wo = wo_parent - i += 1 - - if i > 100: - raise RecursionError( - "Exceeded scene graph depth threshold, cannot find Scene associated with" - "this graphic." - ) - - if present: - if self._parent.world_object not in self._scene.children: - self._scene.add(self._parent.world_object) - - else: - if self._parent.world_object in self._scene.children: - self._scene.remove(self._parent.world_object) - - self._data = present - self._feature_changed(key=None, new_data=present) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="present", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"PresentFeature for {self._parent}, call `.present()` to get values" - return s diff --git a/fastplotlib/graphics/_features/_thickness.py b/fastplotlib/graphics/_features/_thickness.py index fc90ef96f..d13c2b727 100644 --- a/fastplotlib/graphics/_features/_thickness.py +++ b/fastplotlib/graphics/_features/_thickness.py @@ -4,43 +4,18 @@ class ThicknessFeature(GraphicFeature): """ Used by Line graphics for line material thickness. - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "index" ``None`` not used - "new_data" ``float`` new thickness value - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== ======================== ========================================================================= """ - def __init__(self, parent, thickness: float): - self._scene = None - super().__init__(parent, thickness) - - def _set(self, value: float): - value = self._parse_set_value(value) - - self._parent.world_object.material.thickness = value - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } + def __init__(self, thickness: float): + self._value = thickness + super().__init__() - event_data = FeatureEvent(type="thickness", pick_info=pick_info) + @property + def value(self) -> float: + return self._value - self._call_event_handlers(event_data) + def set_value(self, parent, value: float): + parent.world_object.material.thickness = value - def __repr__(self) -> str: - s = f"ThicknessFeature for {self._parent}, call `.thickness()` to get value" - return s + event = FeatureEvent("thickness", {"value": value}) + self._call_event_handlers(event) From adcc13fb8bbfd9bcb28fcea1a3fb1a55cd1884b2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 22 May 2024 04:00:50 -0400 Subject: [PATCH 42/77] regular features and refactor line and scatter into positions graphic --- fastplotlib/graphics/_base.py | 164 +++++++++++++++--- fastplotlib/graphics/_features/__init__.py | 5 +- fastplotlib/graphics/_features/_base.py | 25 --- fastplotlib/graphics/_features/_common.py | 2 +- fastplotlib/graphics/_features/_data.py | 52 ------ .../{_colors.py => _positions_graphics.py} | 67 ++++++- fastplotlib/graphics/line.py | 50 +++--- tests/test_colors_buffer_manager.py | 8 +- tests/test_points_data_buffer_manager.py | 8 +- 9 files changed, 238 insertions(+), 143 deletions(-) rename fastplotlib/graphics/_features/{_colors.py => _positions_graphics.py} (80%) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 48ca37e12..dc6954160 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -12,8 +12,8 @@ import pygfx -from ._features import GraphicFeature, BufferManager, GraphicFeatureDescriptor, Deleted, PointsDataFeature, ColorFeature, PointsSizesFeature, Name, Offset, Rotation, Visible - +from ._features import GraphicFeature, BufferManager, Deleted, VertexPositions, VertexColors, PointsSizesFeature, Name, Offset, Rotation, Visible, UniformColor +from ..utils import parse_cmap_values HexStr: TypeAlias = str @@ -38,9 +38,56 @@ ] -class BaseGraphic: +class Graphic: + features = {} + + @property + def name(self) -> str | None: + """Graphic name""" + return self._name.value + + @name.setter + def name(self, value: str): + self._name.set_value(self, value) + + @property + def offset(self) -> tuple: + """Offset position of the graphic, [x, y, z]""" + return self._offset.value + + @offset.setter + def offset(self, value: tuple[float, float, float]): + self._offset.set_value(self, value) + + @property + def rotation(self) -> np.ndarray: + """Orientation of the graphic as a quaternion""" + return self._rotation.value + + @rotation.setter + def rotation(self, value: tuple[float, float, float, float]): + self._rotation.set_value(self, value) + + @property + def visible(self) -> bool: + """Whether the graphic is visible""" + return self._visible.value + + @visible.setter + def visible(self, value: bool): + self._visible.set_value(self, value) + + @property + def deleted(self) -> bool: + """used to emit an event after the graphic is deleted""" + return self._deleted.value + + @deleted.setter + def deleted(self, value: bool): + self._deleted.set_value(self, value) + def __init_subclass__(cls, **kwargs): - """set the type of the graphic in lower case like "image", "line_collection", etc.""" + # set the type of the graphic in lower case like "image", "line_collection", etc. cls.type = ( cls.__name__.lower() .replace("graphic", "") @@ -48,23 +95,14 @@ def __init_subclass__(cls, **kwargs): .replace("stack", "_stack") ) - super().__init_subclass__(**kwargs) - - -class Graphic(BaseGraphic): - features = {} - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) + # set of all features cls.features = {*cls.features, "name", "offset", "rotation", "visible", "deleted"} - - # graphic feature class attributes - for f in cls.features: - setattr(cls, f, GraphicFeatureDescriptor(f)) + super().__init_subclass__(**kwargs) def __init__( self, name: str = None, + offset: tuple[float] = (0., 0., 0.), metadata: Any = None, collection_index: int = None, ): @@ -82,7 +120,6 @@ def __init__( if (name is not None) and (not isinstance(name, str)): raise TypeError("Graphic `name` must be of type ") - self._name = Name(name) self.metadata = metadata self.collection_index = collection_index self.registered_callbacks = dict() @@ -91,8 +128,6 @@ def __init__( # store hex id str of Graphic instance mem location self._fpl_address: HexStr = hex(id(self)) - self._deleted = Deleted(False) - self._plot_area = None # event handlers @@ -101,6 +136,13 @@ def __init__( # maps callbacks to their partials self._event_handler_wrappers = defaultdict(set) + # all the common features + self._name = Name(name) + self._deleted = Deleted(False) + self._rotation = None # set later when world object is set + self._offset = Offset(offset) + self._visible = Visible(True) + @property def world_object(self) -> pygfx.WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" @@ -110,6 +152,8 @@ def world_object(self) -> pygfx.WorldObject: def _set_world_object(self, wo: pygfx.WorldObject): WORLD_OBJECTS[self._fpl_address] = wo + self._rotation = Rotation(self.world_object.world.rotation[:]) + def detach_feature(self, feature: str): raise NotImplementedError @@ -203,7 +247,8 @@ def _handle_event(self, callback, event: pygfx.Event): # for feature events event._target = self.world_object - callback(event) + with log_exception(f"Error during handling {event.type} event"): + callback(event) def remove_event_handler(self, callback, *types): # remove from our record first @@ -315,6 +360,77 @@ def rotate(self, alpha: float, axis: Literal["x", "y", "z"] = "y"): class PositionsGraphic(Graphic): """Base class for LineGraphic and ScatterGraphic""" + @property + def data(self) -> VertexPositions: + """Get or set the vertex positions data""" + return self._data + + @data.setter + def data(self, value): + self._data[:] = value + + @property + def colors(self) -> VertexColors | pygfx.Color: + """Get or set the colors data""" + if isinstance(self._colors, VertexColors): + return self._colors + + elif isinstance(self._colors, UniformColor): + return self._colors.value + + @colors.setter + def colors(self, value): + if isinstance(self._colors, VertexColors): + self._colors[:] = value + + elif isinstance(self._colors, UniformColor): + self._colors.set_value(self, value) + + def __init__( + self, + data: Any, + colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_colors: bool = False, + alpha: float = 1.0, + cmap: str = None, + cmap_values: np.ndarray = None, + isolated_buffer: bool = True, + *args, + **kwargs, + ): + self._data = VertexPositions(data, isolated_buffer=isolated_buffer) + + if cmap is not None: + if uniform_colors: + raise TypeError( + "Cannot use cmap if uniform_colors=True" + ) + + n_datapoints = self._data.value.shape[0] + + colors = parse_cmap_values( + n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values + ) + + if isinstance(colors, VertexColors): + if uniform_colors: + raise TypeError( + "Cannot use vertex colors from existing instance if uniform_colors=True" + ) + self._colors = colors + self._colors._shared += 1 + else: + if uniform_colors: + self._colors = UniformColor(colors) + else: + self._colors = VertexColors( + colors, + n_colors=self._data.value.shape[0], + alpha=alpha, + ) + + super().__init__(*args, **kwargs) + def detach_feature(self, feature: str): if not isinstance(feature, str): raise TypeError @@ -323,7 +439,7 @@ def detach_feature(self, feature: str): if f.shared == 0: raise BufferError("Cannot detach an independent buffer") - if feature == "colors": + if feature == "colors" and isinstance(feature, VertexColors): self._colors._buffer = pygfx.Buffer(self._colors.value.copy()) self.world_object.geometry.colors = self._colors.buffer self._colors._shared -= 1 @@ -338,8 +454,8 @@ def detach_feature(self, feature: str): self.world_object.geometry.positions = self._sizes.buffer self._sizes._shared -= 1 - def attach_feature(self, feature: PointsDataFeature | ColorFeature | PointsSizesFeature): - if isinstance(feature, PointsDataFeature): + def attach_feature(self, feature: VertexPositions | VertexColors | PointsSizesFeature): + if isinstance(feature, VertexPositions): # TODO: check if this causes a memory leak self._data._shared -= 1 @@ -347,7 +463,7 @@ def attach_feature(self, feature: PointsDataFeature | ColorFeature | PointsSizes self._data._shared += 1 self.world_object.geometry.positions = self._data.buffer - elif isinstance(feature, ColorFeature): + elif isinstance(feature, VertexColors): self._colors._shared -= 1 self._colors = feature diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index d81f8b432..63b438355 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,12 +1,11 @@ -from ._colors import ColorFeature#, CmapFeature, ImageCmapFeature, HeatmapCmapFeature -from ._data import PointsDataFeature#, ImageDataFeature, HeatmapDataFeature +from ._positions_graphics import VertexColors, UniformColor, \ + VertexPositions # , CmapFeature, ImageCmapFeature, HeatmapCmapFeature from ._sizes import PointsSizesFeature # from ._present import PresentFeature # from ._thickness import ThicknessFeature from ._base import ( GraphicFeature, BufferManager, - GraphicFeatureDescriptor, FeatureEvent, to_gpu_supported_dtype, ) diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 573bf7d83..06e53b163 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -1,5 +1,3 @@ -from abc import abstractmethod -from inspect import getfullargspec from warnings import warn from typing import Any, Literal @@ -292,26 +290,3 @@ def _emit_event(self, type: str, key, value): def __repr__(self): return f"{self.__class__.__name__} buffer data:\n" \ f"{self.value.__repr__()}" - - -class GraphicFeatureDescriptor: - def __init__(self, feature_name): - self.feature_name = feature_name - - def _get_feature(self, instance): - feature: GraphicFeature = getattr(instance, f"_{self.feature_name}") - return feature - - def __get__(self, graphic, owner): - f = self._get_feature(graphic) - if isinstance(f, BufferManager): - return f - else: - return f.value - - def __set__(self, graphic, value): - feature = self._get_feature(graphic) - if isinstance(feature, BufferManager): - feature[:] = value - else: - feature.set_value(graphic, value) diff --git a/fastplotlib/graphics/_features/_common.py b/fastplotlib/graphics/_features/_common.py index cf8186966..aa44bde5a 100644 --- a/fastplotlib/graphics/_features/_common.py +++ b/fastplotlib/graphics/_features/_common.py @@ -11,7 +11,7 @@ def __init__(self, value: str): def value(self) -> str: return self._value - def set_value(self, graphic, value: bool): + def set_value(self, graphic, value: str): if not isinstance(value, str): raise TypeError("`Graphic` name must be of type ") diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py index e7a422d93..d0f4bd4a4 100644 --- a/fastplotlib/graphics/_features/_data.py +++ b/fastplotlib/graphics/_features/_data.py @@ -1,55 +1,3 @@ -from typing import * - -import numpy as np - -import pygfx - -from ._base import ( - BufferManager, - FeatureEvent, - to_gpu_supported_dtype, -) - - -class PointsDataFeature(BufferManager): - """ - Access to the vertex buffer data shown in the graphic. - Supports fancy indexing if the data array also supports it. - """ - - def __init__(self, data: Any, isolated_buffer: bool = True): - data = self._fix_data(data) - super().__init__(data, isolated_buffer=isolated_buffer) - - def _fix_data(self, data): - # data = to_gpu_supported_dtype(data) - - if data.ndim == 1: - # if user provides a 1D array, assume these are y-values - data = np.column_stack([np.arange(data.size, dtype=data.dtype), data]) - - if data.shape[1] != 3: - if data.shape[1] != 2: - raise ValueError(f"Must pass 1D, 2D or 3D data") - - # zeros for z - zs = np.zeros(data.shape[0], dtype=data.dtype) - - # column stack [x, y, z] to make data of shape [n_points, 3] - data = np.column_stack([data[:, 0], data[:, 1], zs]) - - return to_gpu_supported_dtype(data) - - def __setitem__(self, key: int | slice | range | np.ndarray[int | bool] | tuple[slice, ...] | tuple[range, ...], value): - # directly use the key to slice the buffer - self.buffer.data[key] = value - - # _update_range handles parsing the key to - # determine offset and size for GPU upload - self._update_range(key) - - self._emit_event("data", key, value) - # # class ImageDataFeature(GraphicFeatureIndexable): # """ diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_positions_graphics.py similarity index 80% rename from fastplotlib/graphics/_features/_colors.py rename to fastplotlib/graphics/_features/_positions_graphics.py index 4fd40ac0e..2e73294a7 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -1,5 +1,8 @@ +from typing import Any + import numpy as np import pygfx +from ._base import BufferManager, to_gpu_supported_dtype from ...utils import ( make_colors, @@ -15,14 +18,14 @@ from .utils import parse_colors -class ColorFeature(BufferManager): +class VertexColors(BufferManager): """ Manages the color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` """ def __init__( self, - colors: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float, + colors: str | np.ndarray | tuple[float] | list[float] | list[str], n_colors: int, alpha: float = None, isolated_buffer: bool = True, @@ -50,7 +53,7 @@ def __init__( def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], - value: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float + value: str | np.ndarray | tuple[float] | list[float] | list[str] ): if isinstance(key, tuple): # directly setting RGBA values for points, we do no parsing @@ -105,6 +108,64 @@ def __setitem__( self._emit_event("colors", key, value) +class UniformColor(GraphicFeature): + def __init__(self, value: str | np.ndarray | tuple | list | pygfx.Color): + self._value = pygfx.Color(value) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): + value = pygfx.Color(value) + graphic.world_object.material.color = value + self._value = value + + event = FeatureEvent(type="colors", info={"value": value}) + self._call_event_handlers(event) + + +class VertexPositions(BufferManager): + """ + Manages the vertex positions buffer shown in the graphic. + Supports fancy indexing if the data array also supports it. + """ + + def __init__(self, data: Any, isolated_buffer: bool = True): + data = self._fix_data(data) + super().__init__(data, isolated_buffer=isolated_buffer) + + def _fix_data(self, data): + # data = to_gpu_supported_dtype(data) + + if data.ndim == 1: + # if user provides a 1D array, assume these are y-values + data = np.column_stack([np.arange(data.size, dtype=data.dtype), data]) + + if data.shape[1] != 3: + if data.shape[1] != 2: + raise ValueError(f"Must pass 1D, 2D or 3D data") + + # zeros for z + zs = np.zeros(data.shape[0], dtype=data.dtype) + + # column stack [x, y, z] to make data of shape [n_points, 3] + data = np.column_stack([data[:, 0], data[:, 1], zs]) + + return to_gpu_supported_dtype(data) + + def __setitem__(self, key: int | slice | range | np.ndarray[int | bool] | tuple[slice, ...] | tuple[range, ...], value): + # directly use the key to slice the buffer + self.buffer.data[key] = value + + # _update_range handles parsing the key to + # determine offset and size for GPU upload + self._update_range(key) + + self._emit_event("data", key, value) + + # class CmapFeature(ColorFeature): # """ # Indexable colormap feature, mostly wraps colors and just provides a way to set colormaps. diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 6685e38a6..524618767 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,9 +5,7 @@ import pygfx -from ..utils import parse_cmap_values from ._base import PositionsGraphic, Interaction, PreviouslyModifiedData -from ._features import GraphicFeatureDescriptor, PointsDataFeature, ColorFeature#, CmapFeature, ThicknessFeature from .selectors import LinearRegionSelector, LinearSelector @@ -19,6 +17,7 @@ def __init__( data: Any, thickness: float = 2.0, colors: str | np.ndarray | Iterable = "w", + uniform_colors: bool = False, alpha: float = 1.0, cmap: str = None, cmap_values: np.ndarray | Iterable = None, @@ -83,42 +82,39 @@ def __init__( """ - self._data = PointsDataFeature(data, isolated_buffer=isolated_buffer) - - if cmap is not None: - n_datapoints = self._data.value.shape[0] - - colors = parse_cmap_values( - n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values - ) - - if isinstance(colors, ColorFeature): - self._colors = colors - self._colors._shared += 1 - else: - self._colors = ColorFeature( - colors, - n_colors=self._data.value.shape[0], - alpha=alpha, - ) - # self.cmap = CmapFeature( # self, self.colors(), cmap_name=cmap, cmap_values=cmap_values # ) - super().__init__(*args, **kwargs) + super().__init__( + data=data, + colors=colors, + uniform_colors=uniform_colors, + alpha=alpha, + cmap=cmap, + cmap_values=cmap_values, + isolated_buffer=isolated_buffer, + *args, + **kwargs + ) if thickness < 1.1: - material = pygfx.LineThinMaterial + MaterialCls = pygfx.LineThinMaterial + else: + MaterialCls = pygfx.LineMaterial + + if uniform_colors: + geometry = pygfx.Geometry(positions=self._data.buffer) + material = MaterialCls(thickness=thickness, color_mode="uniform", pick_write=True) else: - material = pygfx.LineMaterial + material = MaterialCls(thickness=thickness, color_mode="vertex", pick_write=True) + geometry = pygfx.Geometry(positions=self._data.buffer, colors=self._colors.buffer) # self.thickness = ThicknessFeature(self, thickness) world_object: pygfx.Line = pygfx.Line( - # self.data.feature_data because data is a Buffer - geometry=pygfx.Geometry(positions=self._data.buffer, colors=self._colors.buffer), - material=material(thickness=thickness, color_mode="vertex", pick_write=True), + geometry=geometry, + material=material ) self._set_world_object(world_object) diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py index 884a70eed..3479fcd59 100644 --- a/tests/test_colors_buffer_manager.py +++ b/tests/test_colors_buffer_manager.py @@ -4,7 +4,7 @@ import pygfx -from fastplotlib.graphics._features import ColorFeature +from fastplotlib.graphics._features import VertexColors from .utils import generate_slice_indices, assert_pending_uploads @@ -19,14 +19,14 @@ def generate_color_inputs(name: str) -> list[str, np.ndarray, list, tuple]: return [s, a, l, t] -def make_colors_buffer() -> ColorFeature: - colors = ColorFeature(colors="w", n_colors=10) +def make_colors_buffer() -> VertexColors: + colors = VertexColors(colors="w", n_colors=10) return colors @pytest.mark.parametrize("color_input", [*generate_color_inputs("r"), *generate_color_inputs("g"), *generate_color_inputs("b")]) def test_create_buffer(color_input): - colors = ColorFeature(colors=color_input, n_colors=10) + colors = VertexColors(colors=color_input, n_colors=10) truth = np.repeat([pygfx.Color(color_input)], 10, axis=0) npt.assert_almost_equal(colors[:], truth) diff --git a/tests/test_points_data_buffer_manager.py b/tests/test_points_data_buffer_manager.py index eac03664a..86181adfa 100644 --- a/tests/test_points_data_buffer_manager.py +++ b/tests/test_points_data_buffer_manager.py @@ -2,7 +2,7 @@ from numpy import testing as npt import pytest -from fastplotlib.graphics._features import PointsDataFeature +from fastplotlib.graphics._features import VertexPositions from .utils import generate_slice_indices, assert_pending_uploads @@ -31,7 +31,7 @@ def generate_data(inputs: str) -> np.ndarray: @pytest.mark.parametrize("data", [generate_data(v) for v in ["y", "xy", "xyz"]]) def test_create_buffer(data): - points_data = PointsDataFeature(data) + points_data = VertexPositions(data) if data.ndim == 1: # only y-vals specified @@ -53,7 +53,7 @@ def test_create_buffer(data): def test_int(): data = generate_data("xyz") # test setting single points - points = PointsDataFeature(data) + points = VertexPositions(data) # set all x, y, z points, create a kink in the spiral points[2] = 1. @@ -89,7 +89,7 @@ def test_slice(slice_method: dict, test_axis: str): size = slice_method["size"] others = slice_method["others"] - points = PointsDataFeature(data) + points = VertexPositions(data) # TODO: placeholder until I make a testing figure where we draw frames only on call points.buffer._gfx_pending_uploads.clear() From 762fcbbe031d1dd6b5a9bc5590292a31cf80685f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 22 May 2024 04:23:47 -0400 Subject: [PATCH 43/77] uniform sizes --- .../graphics/_features/_positions_graphics.py | 18 +++++ fastplotlib/graphics/scatter.py | 71 ++++++++++--------- 2 files changed, 55 insertions(+), 34 deletions(-) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index 2e73294a7..d25eb64a8 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -126,6 +126,24 @@ def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Colo self._call_event_handlers(event) +class UniformSizes(GraphicFeature): + def __init__(self, value: int | float): + self._value = pygfx.Color(value) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): + value = pygfx.Color(value) + graphic.world_object.material.color = value + self._value = value + + event = FeatureEvent(type="colors", info={"value": value}) + self._call_event_handlers(event) + + class VertexPositions(BufferManager): """ Manages the vertex positions buffer shown in the graphic. diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index d3f83d5e6..9d4380faa 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -5,7 +5,7 @@ from ..utils import parse_cmap_values from ._base import PositionsGraphic -from ._features import PointsDataFeature, ColorFeature, CmapFeature, PointsSizesFeature +from ._features import CmapFeature, PointsSizesFeature class ScatterGraphic(PositionsGraphic): @@ -13,14 +13,15 @@ class ScatterGraphic(PositionsGraphic): def __init__( self, - data: np.ndarray, - sizes: float | np.ndarray | Iterable[float] = 1, - colors: str | np.ndarray | Iterable[str] = "w", + data: Any, + colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_colors: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: np.ndarray | List = None, - z_position: float = 0.0, + cmap_values: np.ndarray = None, isolated_buffer: bool = True, + sizes: float | np.ndarray | Iterable[float] = 1, + uniform_sizes: bool = False, *args, **kwargs, ): @@ -74,41 +75,43 @@ def __init__( Control the presence of the Graphic in the scene, set to ``True`` or ``False`` """ - self._data = PointsDataFeature(data, isolated_buffer=isolated_buffer) - - n_datapoints = self._data.value.shape[0] - - if cmap is not None: - colors = parse_cmap_values( - n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values - ) - - if isinstance(colors, ColorFeature): - self._colors = colors - self._colors._shared += 1 - else: - self._colors = ColorFeature( - colors, - n_colors=self._data.value.shape[0], - alpha=alpha, - ) - # self.cmap = CmapFeature( # self, self.colors(), cmap_name=cmap, cmap_values=cmap_values # ) + super().__init__( + data=data, + colors=colors, + uniform_colors=uniform_colors, + alpha=alpha, + cmap=cmap, + cmap_values=cmap_values, + isolated_buffer=isolated_buffer, + *args, + **kwargs + ) + + n_datapoints = self.data.value.shape[0] self._sizes = PointsSizesFeature(sizes, n_datapoints=n_datapoints) - super().__init__(*args, **kwargs) + + geo_kwargs = {"positions": self._data.buffer} + material_kwargs = {"pick_write": True} + + if uniform_colors: + material_kwargs["color_mode"] = "uniform" + else: + material_kwargs["color_mode"] = "vertex" + geo_kwargs["colors"] = self._colors.buffer + + if uniform_sizes: + material_kwargs["size_mode"] = "uniform" + else: + material_kwargs["size_mode"] = "vertex" + geo_kwargs["sizes"] = self._sizes.buffer world_object = pygfx.Points( - pygfx.Geometry( - positions=self._data.buffer, sizes=self._sizes.buffer, colors=self._colors.buffer - ), - material=pygfx.PointsMaterial( - color_mode="vertex", size_mode="vertex", pick_write=True - ), + pygfx.Geometry(**geo_kwargs), + material=pygfx.PointsMaterial(**material_kwargs), ) self._set_world_object(world_object) - - self.position_z = z_position From 3017b7e06942b4ab72235b1289e9a912a4dcbdf6 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 22 May 2024 04:37:01 -0400 Subject: [PATCH 44/77] implement sizes and uniform size for scatter --- fastplotlib/graphics/_features/__init__.py | 4 +- .../graphics/_features/_positions_graphics.py | 61 ++++++++++++-- fastplotlib/graphics/_features/_sizes.py | 80 ------------------- fastplotlib/graphics/scatter.py | 25 +++++- 4 files changed, 79 insertions(+), 91 deletions(-) diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index 63b438355..b2a32dd47 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,6 +1,4 @@ -from ._positions_graphics import VertexColors, UniformColor, \ - VertexPositions # , CmapFeature, ImageCmapFeature, HeatmapCmapFeature -from ._sizes import PointsSizesFeature +from ._positions_graphics import VertexColors, UniformColor, UniformSizes, VertexPositions, PointsSizesFeature # , CmapFeature, ImageCmapFeature, HeatmapCmapFeature # from ._present import PresentFeature # from ._thickness import ThicknessFeature from ._base import ( diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index d25eb64a8..f32968045 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -2,7 +2,6 @@ import numpy as np import pygfx -from ._base import BufferManager, to_gpu_supported_dtype from ...utils import ( make_colors, @@ -14,6 +13,7 @@ GraphicFeature, BufferManager, FeatureEvent, + to_gpu_supported_dtype, ) from .utils import parse_colors @@ -128,19 +128,19 @@ def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Colo class UniformSizes(GraphicFeature): def __init__(self, value: int | float): - self._value = pygfx.Color(value) + self._value = float(value) super().__init__() @property - def value(self) -> pygfx.Color: + def value(self) -> float: return self._value def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): value = pygfx.Color(value) - graphic.world_object.material.color = value + graphic.world_object.material.size = value self._value = value - event = FeatureEvent(type="colors", info={"value": value}) + event = FeatureEvent(type="sizes", info={"value": value}) self._call_event_handlers(event) @@ -184,6 +184,57 @@ def __setitem__(self, key: int | slice | range | np.ndarray[int | bool] | tuple[ self._emit_event("data", key, value) +class PointsSizesFeature(BufferManager): + """ + Access to the vertex buffer data shown in the graphic. + Supports fancy indexing if the data array also supports it. + """ + + def __init__( + self, + sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], + n_datapoints: int, + isolated_buffer: bool = True + ): + sizes = self._fix_sizes(sizes, n_datapoints) + super().__init__(data=sizes, isolated_buffer=isolated_buffer) + + def _fix_sizes(self, sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], n_datapoints: int): + if np.issubdtype(type(sizes), np.number): + # single value given + sizes = np.full( + n_datapoints, sizes, dtype=np.float32 + ) # force it into a float to avoid weird gpu errors + + elif isinstance( + sizes, (np.ndarray, tuple, list) + ): # if it's not a ndarray already, make it one + sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32 + if (sizes.ndim != 1) or (sizes.size != n_datapoints): + raise ValueError( + f"sequence of `sizes` must be 1 dimensional with " + f"the same length as the number of datapoints" + ) + + else: + raise TypeError("sizes must be a single , , or a sequence (array, list, tuple) of int" + "or float with the length equal to the number of datapoints") + + if np.count_nonzero(sizes < 0) > 1: + raise ValueError( + "All sizes must be positive numbers greater than or equal to 0.0." + ) + + return sizes + + def __setitem__(self, key, value): + # this is a very simple 1D buffer, no parsing required, directly set buffer + self.buffer.data[key] = value + self._update_range(key) + + self._emit_event("sizes", key, value) + + # class CmapFeature(ColorFeature): # """ # Indexable colormap feature, mostly wraps colors and just provides a way to set colormaps. diff --git a/fastplotlib/graphics/_features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py index b45474e5e..b28b04f64 100644 --- a/fastplotlib/graphics/_features/_sizes.py +++ b/fastplotlib/graphics/_features/_sizes.py @@ -1,83 +1,3 @@ -import numpy as np -from ._base import ( - BufferManager, - FeatureEvent, - to_gpu_supported_dtype, -) -class PointsSizesFeature(BufferManager): - """ - Access to the vertex buffer data shown in the graphic. - Supports fancy indexing if the data array also supports it. - """ - - def __init__( - self, - sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], - n_datapoints: int, - isolated_buffer: bool = True - ): - sizes = self._fix_sizes(sizes, n_datapoints) - super().__init__(data=sizes, isolated_buffer=isolated_buffer) - - def _fix_sizes(self, sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], n_datapoints: int): - if np.issubdtype(type(sizes), np.number): - # single value given - sizes = np.full( - n_datapoints, sizes, dtype=np.float32 - ) # force it into a float to avoid weird gpu errors - - elif isinstance( - sizes, (np.ndarray, tuple, list) - ): # if it's not a ndarray already, make it one - sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32 - if (sizes.ndim != 1) or (sizes.size != n_datapoints): - raise ValueError( - f"sequence of `sizes` must be 1 dimensional with " - f"the same length as the number of datapoints" - ) - - else: - raise TypeError("sizes must be a single , , or a sequence (array, list, tuple) of int" - "or float with the length equal to the number of datapoints") - - if np.count_nonzero(sizes < 0) > 1: - raise ValueError( - "All sizes must be positive numbers greater than or equal to 0.0." - ) - - return sizes - - def __setitem__(self, key, value): - # this is a very simple 1D buffer, no parsing required, directly set buffer - self.buffer.data[key] = value - self._update_range(key) - - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, (int, np.integer)): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - elif key is None: - indices = None - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="sizes", pick_info=pick_info) - - self._call_event_handlers(event_data) diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 9d4380faa..e98ba5452 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -5,12 +5,29 @@ from ..utils import parse_cmap_values from ._base import PositionsGraphic -from ._features import CmapFeature, PointsSizesFeature +from ._features import CmapFeature, PointsSizesFeature, UniformSizes class ScatterGraphic(PositionsGraphic): features = {"data", "sizes", "colors"}#, "cmap", "present"} + @property + def sizes(self) -> PointsSizesFeature | float: + """Get or set the scatter point size(s)""" + if isinstance(self._sizes, PointsSizesFeature): + return self._sizes + + elif isinstance(self._sizes, UniformSizes): + return self._sizes.value + + @sizes.setter + def sizes(self, value): + if isinstance(self._sizes, PointsSizesFeature): + self._sizes[:] = value + + elif isinstance(self._sizes, UniformSizes): + self._sizes.set_value(self, value) + def __init__( self, data: Any, @@ -99,15 +116,17 @@ def __init__( if uniform_colors: material_kwargs["color_mode"] = "uniform" + material_kwargs["color"] = self.colors.value else: material_kwargs["color_mode"] = "vertex" - geo_kwargs["colors"] = self._colors.buffer + geo_kwargs["colors"] = self.colors.buffer if uniform_sizes: material_kwargs["size_mode"] = "uniform" + material_kwargs["size"] = self.sizes.value else: material_kwargs["size_mode"] = "vertex" - geo_kwargs["sizes"] = self._sizes.buffer + geo_kwargs["sizes"] = self.sizes.buffer world_object = pygfx.Points( pygfx.Geometry(**geo_kwargs), From a45b3f9f378b2c77e8cd06dee9a6282903b05e29 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 22 May 2024 05:35:41 -0400 Subject: [PATCH 45/77] VertexCmap feature, not yet tested --- fastplotlib/graphics/_base.py | 16 ++- fastplotlib/graphics/_features/__init__.py | 6 +- fastplotlib/graphics/_features/_base.py | 36 ++++-- .../graphics/_features/_positions_graphics.py | 117 +++++++++--------- fastplotlib/graphics/_features/utils.py | 6 +- fastplotlib/graphics/line.py | 2 +- fastplotlib/graphics/scatter.py | 4 +- 7 files changed, 107 insertions(+), 80 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index dc6954160..737f80a20 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -12,7 +12,7 @@ import pygfx -from ._features import GraphicFeature, BufferManager, Deleted, VertexPositions, VertexColors, PointsSizesFeature, Name, Offset, Rotation, Visible, UniformColor +from ._features import GraphicFeature, BufferManager, Deleted, VertexPositions, VertexColors, VertexCmap, PointsSizesFeature, Name, Offset, Rotation, Visible, UniformColor from ..utils import parse_cmap_values HexStr: TypeAlias = str @@ -386,6 +386,18 @@ def colors(self, value): elif isinstance(self._colors, UniformColor): self._colors.set_value(self, value) + @property + def cmap(self) -> VertexCmap: + """Control cmap""" + return self._cmap + + @cmap.setter + def cmap(self, name: str): + if self._cmap is None: + raise BufferError("Cannot use cmap with uniform_colors=True") + + self._cmap[:] = name + def __init__( self, data: Any, @@ -422,12 +434,14 @@ def __init__( else: if uniform_colors: self._colors = UniformColor(colors) + self._cmap = None else: self._colors = VertexColors( colors, n_colors=self._data.value.shape[0], alpha=alpha, ) + self._cmap = VertexCmap(self._colors, cmap_name=cmap, cmap_values=cmap_values) super().__init__(*args, **kwargs) diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index b2a32dd47..1c908e703 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,4 +1,5 @@ -from ._positions_graphics import VertexColors, UniformColor, UniformSizes, VertexPositions, PointsSizesFeature # , CmapFeature, ImageCmapFeature, HeatmapCmapFeature +from ._positions_graphics import VertexColors, UniformColor, UniformSizes, VertexPositions, PointsSizesFeature, VertexCmap +# , CmapFeature, ImageCmapFeature, HeatmapCmapFeature # from ._present import PresentFeature # from ._thickness import ThicknessFeature from ._base import ( @@ -28,9 +29,6 @@ # "Deleted", # ] -class CmapFeature: - pass - class ThicknessFeature: pass diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 06e53b163..254852121 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -153,7 +153,7 @@ class BufferManager(GraphicFeature): def __init__( self, - data: NDArray, + data: NDArray | pygfx.Buffer, buffer_type: Literal["buffer", "texture"] = "buffer", isolated_buffer: bool = True, texture_dim: int = 2, @@ -168,12 +168,18 @@ def __init__( # user's input array is used as the buffer bdata = data - if buffer_type == "buffer": + if isinstance(data, pygfx.Buffer): + # 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) elif buffer_type == "texture": self._buffer = pygfx.Texture(bdata, dim=texture_dim) else: - raise ValueError("`buffer_type` must be one of: 'buffer' or 'texture'") + raise ValueError( + "`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'" + ) self._event_handlers: list[callable] = list() @@ -202,11 +208,7 @@ def __getitem__(self, item): def __setitem__(self, key, value): raise NotImplementedError - def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...]): - """ - Uses key from slicing to determine the offset and - size of the buffer to mark for upload to the GPU - """ + def _parse_offset_size(self, key: int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...]): # number of elements in the buffer upper_bound = self.value.shape[0] @@ -219,8 +221,12 @@ def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | # simplest case offset = key size = 1 + n_elements = 1 elif isinstance(key, slice): + # TODO: off-by-one sometimes when step is used + # the offset can be one to the left or the size + # is one extra so it's not really an issue for now # parse slice start, stop, step = key.indices(upper_bound) @@ -238,6 +244,7 @@ def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | # number of elements to upload # this is indexing so do not add 1 size = abs(stop - start) + n_elements = len(range(start, stop, step)) elif isinstance(key, (np.ndarray, list)): if isinstance(key, list): @@ -265,14 +272,25 @@ def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | # index of first element to upload offset = key.min() - # number of elements to upload + # size range to upload # add 1 because this is direct # passing of indices, not a start:stop size = np.ptp(key) + 1 + # number of elements indexed + n_elements = key.size + else: raise TypeError(key) + return offset, size, n_elements + + def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...]): + """ + Uses key from slicing to determine the offset and + size of the buffer to mark for upload to the GPU + """ + offset, size, n_elements = self._parse_offset_size(key) self.buffer.update_range(offset=offset, size=size) def _emit_event(self, type: str, key, value): diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index f32968045..83ef3869d 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -235,66 +235,63 @@ def __setitem__(self, key, value): self._emit_event("sizes", key, value) -# class CmapFeature(ColorFeature): -# """ -# Indexable colormap feature, mostly wraps colors and just provides a way to set colormaps. -# -# Same event pick info as :class:`ColorFeature` -# """ -# -# def __init__(self, parent, colors, cmap_name: str, cmap_values: np.ndarray): -# # Skip the ColorFeature's __init__ -# super(ColorFeature, self).__init__(parent, colors) -# -# self._cmap_name = cmap_name -# self._cmap_values = cmap_values -# -# def __setitem__(self, key, cmap_name): -# key = cleanup_slice(key, self._upper_bound) -# if not isinstance(key, (slice, np.ndarray)): -# raise TypeError( -# "Cannot set cmap on single indices, must pass a slice object, " -# "numpy.ndarray or set it on the entire data." -# ) -# -# if isinstance(key, slice): -# n_colors = len(range(key.start, key.stop, key.step)) -# -# else: -# # numpy array -# n_colors = key.size -# -# colors = parse_cmap_values( -# n_colors=n_colors, cmap_name=cmap_name, cmap_values=self._cmap_values -# ) -# -# self._cmap_name = cmap_name -# super().__setitem__(key, colors) -# -# @property -# def name(self) -> str: -# return self._cmap_name -# -# @property -# def values(self) -> np.ndarray: -# return self._cmap_values -# -# @values.setter -# def values(self, values: np.ndarray): -# if not isinstance(values, np.ndarray): -# values = np.array(values) -# -# colors = parse_cmap_values( -# n_colors=self().shape[0], cmap_name=self._cmap_name, cmap_values=values -# ) -# -# self._cmap_values = values -# -# super().__setitem__(slice(None), colors) -# -# def __repr__(self) -> str: -# s = f"CmapFeature for {self._parent}, to get name or values: `.cmap.name`, `.cmap.values`" -# return s +class VertexCmap(BufferManager): + """ + Sliceable colormap feature, manages a VertexColors instance and just provides a way to set colormaps. + """ + + def __init__(self, vertex_colors: VertexColors, cmap_name: str, cmap_values: np.ndarray): + super().__init__(data=vertex_colors) + + self._vertex_colors = vertex_colors + self._cmap_name = cmap_name + self._cmap_values = cmap_values + + def __setitem__(self, key, cmap_name): + if isinstance(key, slice): + if key.step is not None: + raise TypeError( + "step sized indexing not currently supported for setting VertexCmap, " + "continuous regions are recommended" + ) + + offset, size, n_elements = self._parse_offset_size(key) + + colors = parse_cmap_values( + n_colors=n_elements, cmap_name=cmap_name, cmap_values=self._cmap_values + ) + + self._cmap_name = cmap_name + self._vertex_colors[key] = colors + + @property + def name(self) -> str: + return self._cmap_name + + @property + def values(self) -> np.ndarray: + return self._cmap_values + + @values.setter + def values(self, values: np.ndarray | list[float | int], indices: slice | list | np.ndarray = None): + if self._cmap_name is None: + raise AttributeError( + "cmap is not set, set the cmap before setting the cmap_values" + ) + + values = np.asarray(values) + + colors = parse_cmap_values( + n_colors=self.value.shape[0], cmap_name=self._cmap_name, cmap_values=values + ) + + self._cmap_values = values + + if indices is None: + indices = slice(None) + + self._vertex_colors[indices] = colors + # # # class ImageCmapFeature(GraphicFeature): diff --git a/fastplotlib/graphics/_features/utils.py b/fastplotlib/graphics/_features/utils.py index 316014881..c3cd59ccc 100644 --- a/fastplotlib/graphics/_features/utils.py +++ b/fastplotlib/graphics/_features/utils.py @@ -7,7 +7,7 @@ def parse_colors( - colors: str | np.ndarray | Iterable[str], + colors: str | np.ndarray | list[str] | tuple[str], n_colors: int | None, alpha: float | None = None, key: int | tuple | slice | None = None, @@ -48,8 +48,8 @@ def parse_colors( "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" ) - # if the color is provided as an iterable - elif isinstance(colors, (list, tuple, np.ndarray)): + # if the color is provided as list or tuple + elif isinstance(colors, (list, tuple)): # if iterable of str if all([isinstance(val, str) for val in colors]): if not len(colors) == n_colors: diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 524618767..422f89013 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -10,7 +10,7 @@ class LineGraphic(PositionsGraphic, Interaction): - features = {"data", "colors"}#, "cmap", "thickness", "present"} + features = {"data", "colors", "cmap"}#, "thickness"} def __init__( self, diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index e98ba5452..60c237cea 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -5,11 +5,11 @@ from ..utils import parse_cmap_values from ._base import PositionsGraphic -from ._features import CmapFeature, PointsSizesFeature, UniformSizes +from ._features import PointsSizesFeature, UniformSizes class ScatterGraphic(PositionsGraphic): - features = {"data", "sizes", "colors"}#, "cmap", "present"} + features = {"data", "sizes", "colors", "cmap"} @property def sizes(self) -> PointsSizesFeature | float: From 09ce4f577d8df6e92d64e13931a0925047be0321 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 22 May 2024 06:05:22 -0400 Subject: [PATCH 46/77] start image features, not tested, add thickness, not tested --- fastplotlib/graphics/_features/__init__.py | 6 +- fastplotlib/graphics/_features/_image.py | 66 ++++++++++ .../graphics/_features/_positions_graphics.py | 119 ++++-------------- fastplotlib/graphics/image.py | 26 +++- fastplotlib/graphics/line.py | 24 ++-- fastplotlib/graphics/scatter.py | 3 - 6 files changed, 125 insertions(+), 119 deletions(-) create mode 100644 fastplotlib/graphics/_features/_image.py diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index 1c908e703..fd4ef7bf2 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,4 +1,5 @@ -from ._positions_graphics import VertexColors, UniformColor, UniformSizes, VertexPositions, PointsSizesFeature, VertexCmap +from ._positions_graphics import VertexColors, UniformColor, UniformSizes, Thickness, VertexPositions, PointsSizesFeature, VertexCmap +from ._image import Cmap, Vmin, Vmax # , CmapFeature, ImageCmapFeature, HeatmapCmapFeature # from ._present import PresentFeature # from ._thickness import ThicknessFeature @@ -29,9 +30,6 @@ # "Deleted", # ] -class ThicknessFeature: - pass - class ImageCmapFeature: pass diff --git a/fastplotlib/graphics/_features/_image.py b/fastplotlib/graphics/_features/_image.py new file mode 100644 index 000000000..b1fbcb7ff --- /dev/null +++ b/fastplotlib/graphics/_features/_image.py @@ -0,0 +1,66 @@ +from ._base import GraphicFeature, FeatureEvent + +from ...utils import ( + make_colors, + get_cmap_texture, + quick_min_max, +) + + +class Vmin(GraphicFeature): + """lower contrast limit""" + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + vmax = graphic.world_object.material.clim[1] + graphic.world_object.material.clim = (value, vmax) + self._value = value + + event = FeatureEvent(type="vmin", info={"value": value}) + self._call_event_handlers(event) + + +class Vmax(GraphicFeature): + """upper contrast limit""" + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + vmin = graphic.world_object.material.clim[0] + graphic.world_object.material.clim = (vmin, value) + self._value = value + + event = FeatureEvent(type="vmax", info={"value": value}) + self._call_event_handlers(event) + + +class Cmap(GraphicFeature): + """colormap for texture""" + def __init__(self, value: str): + self._value = value + self.texture = get_cmap_texture(value) + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + new_colors = make_colors(256, value) + graphic.world_object.material.map.data[:] = new_colors + graphic.world_object.material.map.data.update_range((0, 0, 0), size=(256, 1, 1)) + + self._value = value + event = FeatureEvent(type="cmap", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index 83ef3869d..3658059c6 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -4,10 +4,7 @@ import pygfx from ...utils import ( - make_colors, - get_cmap_texture, parse_cmap_values, - quick_min_max, ) from ._base import ( GraphicFeature, @@ -235,6 +232,24 @@ def __setitem__(self, key, value): self._emit_event("sizes", key, value) +class Thickness(GraphicFeature): + """line thickness""" + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + graphic.world_object.material.thickness = value + self._value = value + + event = FeatureEvent(type="thickness", info={"value": value}) + self._call_event_handlers(event) + + class VertexCmap(BufferManager): """ Sliceable colormap feature, manages a VertexColors instance and just provides a way to set colormaps. @@ -264,6 +279,8 @@ def __setitem__(self, key, cmap_name): self._cmap_name = cmap_name self._vertex_colors[key] = colors + self._emit_event("cmap", key, cmap_name) + @property def name(self) -> str: return self._cmap_name @@ -292,100 +309,8 @@ def values(self, values: np.ndarray | list[float | int], indices: slice | list | self._vertex_colors[indices] = colors -# -# -# class ImageCmapFeature(GraphicFeature): -# """ -# Colormap for :class:`ImageGraphic`. -# -# .cmap() returns the Texture buffer for the cmap. -# -# .cmap.name returns the cmap name as a str. -# -# **event pick info:** -# -# ================ =================== =============== -# key type description -# ================ =================== =============== -# "index" ``None`` not used -# "name" ``str`` colormap name -# "world_object" pygfx.WorldObject world object -# "vmin" ``float`` minimum value -# "vmax" ``float`` maximum value -# ================ =================== =============== -# -# """ -# -# def __init__(self, parent, cmap: str): -# cmap_texture_view = get_cmap_texture(cmap) -# super().__init__(parent, cmap_texture_view) -# self._name = cmap -# -# def _set(self, cmap_name: str): -# if self._parent.data().ndim > 2: -# return -# -# self._parent.world_object.material.map.data[:] = make_colors(256, cmap_name) -# self._parent.world_object.material.map.update_range((0, 0, 0), size=(256, 1, 1)) -# self._name = cmap_name -# -# self._feature_changed(key=None, new_data=self._name) -# -# @property -# def name(self) -> str: -# return self._name -# -# @property -# def vmin(self) -> float: -# """Minimum contrast limit.""" -# return self._parent.world_object.material.clim[0] -# -# @vmin.setter -# def vmin(self, value: float): -# """Minimum contrast limit.""" -# self._parent.world_object.material.clim = ( -# value, -# self._parent.world_object.material.clim[1], -# ) -# self._feature_changed(key=None, new_data=None) -# -# @property -# def vmax(self) -> float: -# """Maximum contrast limit.""" -# return self._parent.world_object.material.clim[1] -# -# @vmax.setter -# def vmax(self, value: float): -# """Maximum contrast limit.""" -# self._parent.world_object.material.clim = ( -# self._parent.world_object.material.clim[0], -# value, -# ) -# self._feature_changed(key=None, new_data=None) -# -# def reset_vmin_vmax(self): -# """Reset vmin vmax values based on current data""" -# self.vmin, self.vmax = quick_min_max(self._parent.data()) -# -# def _feature_changed(self, key, new_data): -# # this is a non-indexable feature so key=None -# -# pick_info = { -# "index": None, -# "world_object": self._parent.world_object, -# "name": self._name, -# "vmin": self.vmin, -# "vmax": self.vmax, -# } -# -# event_data = FeatureEvent(type="cmap", pick_info=pick_info) -# -# self._call_event_handlers(event_data) -# -# def __repr__(self) -> str: -# s = f"ImageCmapFeature for {self._parent}. Use `.cmap.name` to get str name of cmap." -# return s -# + self._emit_event("cmap.name", indices, values) + # # class HeatmapCmapFeature(ImageCmapFeature): # """ diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index ce736dab2..e87eaad0f 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -11,7 +11,9 @@ from ._base import Graphic, Interaction from .selectors import LinearSelector, LinearRegionSelector from ._features import ( - ImageCmapFeature, + Cmap, + Vmin, + Vmax, ImageDataFeature, HeatmapDataFeature, HeatmapCmapFeature, @@ -196,7 +198,16 @@ def _add_plot_area_hook(self, plot_area): class ImageGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = {"data", "cmap", "present"} + features = {"data", "cmap", "vmin", "vmax"} + + @property + def cmap(self) -> str: + """Graphic name""" + return self._cmap.value + + @cmap.setter + def cmap(self, name: str): + self._cmap.set_value(self, name) def __init__( self, @@ -277,7 +288,7 @@ def __init__( geometry = pygfx.Geometry(grid=texture) - self.cmap = ImageCmapFeature(self, cmap) + self._cmap = Cmap(cmap) # if data is RGB or RGBA if data.ndim > 2: @@ -288,7 +299,7 @@ def __init__( else: material = pygfx.ImageBasicMaterial( clim=(vmin, vmax), - map=self.cmap(), + map=self._cmap.texture, map_interpolation=filter, pick_write=True, ) @@ -297,8 +308,8 @@ def __init__( self._set_world_object(world_object) - self.cmap.vmin = vmin - self.cmap.vmax = vmax + self.vmin = Vmin(vmin) + self.vmax = Vmax(Vmax) self.data = ImageDataFeature(self, data) # TODO: we need to organize and do this better @@ -307,6 +318,9 @@ def __init__( # set it with the actual data self.data = data + # def reset_vmin_vmax(self): + # vmin, vmax = quick_min_max(data) + def set_feature(self, feature: str, new_data: Any, indices: Any): pass diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 422f89013..62160c164 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -7,10 +7,20 @@ from ._base import PositionsGraphic, Interaction, PreviouslyModifiedData from .selectors import LinearRegionSelector, LinearSelector +from ._features import Thickness class LineGraphic(PositionsGraphic, Interaction): - features = {"data", "colors", "cmap"}#, "thickness"} + features = {"data", "colors", "cmap", "thickness"} + + @property + def thickness(self) -> float: + """Graphic name""" + return self._thickness.value + + @thickness.setter + def thickness(self, value: float): + self._thickness.set_value(self, value) def __init__( self, @@ -82,10 +92,6 @@ def __init__( """ - # self.cmap = CmapFeature( - # self, self.colors(), cmap_name=cmap, cmap_values=cmap_values - # ) - super().__init__( data=data, colors=colors, @@ -98,6 +104,8 @@ def __init__( **kwargs ) + self._thickness = Thickness(thickness) + if thickness < 1.1: MaterialCls = pygfx.LineThinMaterial else: @@ -105,13 +113,11 @@ def __init__( if uniform_colors: geometry = pygfx.Geometry(positions=self._data.buffer) - material = MaterialCls(thickness=thickness, color_mode="uniform", pick_write=True) + material = MaterialCls(thickness=self.thickness, color_mode="uniform", pick_write=True) else: - material = MaterialCls(thickness=thickness, color_mode="vertex", pick_write=True) + material = MaterialCls(thickness=self.thickness, color_mode="vertex", pick_write=True) geometry = pygfx.Geometry(positions=self._data.buffer, colors=self._colors.buffer) - # self.thickness = ThicknessFeature(self, thickness) - world_object: pygfx.Line = pygfx.Line( geometry=geometry, material=material diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 60c237cea..f3afcd31a 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -92,9 +92,6 @@ def __init__( Control the presence of the Graphic in the scene, set to ``True`` or ``False`` """ - # self.cmap = CmapFeature( - # self, self.colors(), cmap_name=cmap, cmap_values=cmap_values - # ) super().__init__( data=data, From 93e7a9dc21e88cacbaa66a954a8b4145d2a92757 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 23 May 2024 03:16:20 -0400 Subject: [PATCH 47/77] better cmap parsing --- fastplotlib/graphics/_base.py | 72 ++++++++++++------- .../graphics/_features/_positions_graphics.py | 43 ++++++++--- 2 files changed, 81 insertions(+), 34 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 737f80a20..52d1465e2 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -404,44 +404,68 @@ def __init__( colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", uniform_colors: bool = False, alpha: float = 1.0, - cmap: str = None, + cmap: str | VertexCmap = None, cmap_values: np.ndarray = None, isolated_buffer: bool = True, *args, **kwargs, ): - self._data = VertexPositions(data, isolated_buffer=isolated_buffer) + if isinstance(data, VertexPositions): + self._data = data + else: + self._data = VertexPositions(data, isolated_buffer=isolated_buffer) if cmap is not None: + # if a cmap is specified it overrides colors argument if uniform_colors: raise TypeError( "Cannot use cmap if uniform_colors=True" ) - n_datapoints = self._data.value.shape[0] - - colors = parse_cmap_values( - n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values - ) - - if isinstance(colors, VertexColors): - if uniform_colors: - raise TypeError( - "Cannot use vertex colors from existing instance if uniform_colors=True" - ) - self._colors = colors - self._colors._shared += 1 - else: - if uniform_colors: - self._colors = UniformColor(colors) - self._cmap = None + 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]) + # make cmap using vertex colors buffer + self._cmap = VertexCmap( + self._colors, + cmap_name=cmap, + cmap_values=cmap_values + ) + elif isinstance(cmap, VertexCmap): + # use existing cmap instance + self._cmap = cmap + self._colors = cmap._vertex_colors else: - self._colors = VertexColors( - colors, - n_colors=self._data.value.shape[0], - alpha=alpha, + raise TypeError + 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, + cmap_values=None ) - self._cmap = VertexCmap(self._colors, cmap_name=cmap, cmap_values=cmap_values) + else: + if uniform_colors: + self._colors = UniformColor(colors) + self._cmap = None + else: + self._colors = VertexColors( + colors, + n_colors=self._data.value.shape[0], + alpha=alpha, + ) + self._cmap = VertexCmap(self._colors, cmap_name=None, cmap_values=None) super().__init__(*args, **kwargs) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index 3658059c6..37ac355a5 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -170,7 +170,7 @@ def _fix_data(self, data): return to_gpu_supported_dtype(data) - def __setitem__(self, key: int | slice | range | np.ndarray[int | bool] | tuple[slice, ...] | tuple[range, ...], value): + def __setitem__(self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value): # directly use the key to slice the buffer self.buffer.data[key] = value @@ -255,22 +255,42 @@ class VertexCmap(BufferManager): Sliceable colormap feature, manages a VertexColors instance and just provides a way to set colormaps. """ - def __init__(self, vertex_colors: VertexColors, cmap_name: str, cmap_values: np.ndarray): + def __init__(self, vertex_colors: VertexColors, cmap_name: str | None, cmap_values: np.ndarray | None): super().__init__(data=vertex_colors) self._vertex_colors = vertex_colors self._cmap_name = cmap_name self._cmap_values = cmap_values - def __setitem__(self, key, cmap_name): - if isinstance(key, slice): - if key.step is not None: - raise TypeError( - "step sized indexing not currently supported for setting VertexCmap, " - "continuous regions are recommended" - ) + if self._cmap_name is not None: + if not isinstance(self._cmap_name, str): + raise TypeError + if not isinstance(self._cmap_values, np.ndarray): + raise TypeError + + n_datapoints = vertex_colors.value.shape[0] + + colors = parse_cmap_values( + n_colors=n_datapoints, cmap_name=self._cmap_name, cmap_values=self._cmap_values + ) + # set vertex colors from cmap + self._vertex_colors[:] = colors + + def __setitem__(self, key: slice, cmap_name): + if not isinstance(key, slice): + raise TypeError( + "fancy indexing not supported for VertexCmap, only slices " + "of a continuous are supported for apply a cmap" + ) + if key.step is not None: + raise TypeError( + "step sized indexing not currently supported for setting VertexCmap, " + "slices must be a continuous region" + ) - offset, size, n_elements = self._parse_offset_size(key) + # parse slice + start, stop, step = key.indices(self.value.shape[0]) + n_elements = len(range(start, stop, step)) colors = parse_cmap_values( n_colors=n_elements, cmap_name=cmap_name, cmap_values=self._cmap_values @@ -279,6 +299,9 @@ def __setitem__(self, key, cmap_name): self._cmap_name = cmap_name self._vertex_colors[key] = colors + # TODO: should we block vertex_colors from emitting an event? + # Because currently this will result in 2 emitted events, one + # for cmap and another from the colors self._emit_event("cmap", key, cmap_name) @property From 2be22f63ac42a902afdd6ece44b1a0b37f3c10dc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 23 May 2024 03:23:03 -0400 Subject: [PATCH 48/77] cleanup --- fastplotlib/graphics/_base.py | 1 - fastplotlib/graphics/_features/__init__.py | 30 ++------------------ fastplotlib/graphics/_features/_base.py | 32 +++++++++++----------- fastplotlib/graphics/_features/utils.py | 2 -- 4 files changed, 18 insertions(+), 47 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 52d1465e2..443602b2c 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -13,7 +13,6 @@ import pygfx from ._features import GraphicFeature, BufferManager, Deleted, VertexPositions, VertexColors, VertexCmap, PointsSizesFeature, Name, Offset, Rotation, Visible, UniformColor -from ..utils import parse_cmap_values HexStr: TypeAlias = str diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index fd4ef7bf2..ace671805 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,8 +1,5 @@ from ._positions_graphics import VertexColors, UniformColor, UniformSizes, Thickness, VertexPositions, PointsSizesFeature, VertexCmap -from ._image import Cmap, Vmin, Vmax -# , CmapFeature, ImageCmapFeature, HeatmapCmapFeature -# from ._present import PresentFeature -# from ._thickness import ThicknessFeature +from ._image import ImageData, ImageCmap, ImageVmin, ImageVmax from ._base import ( GraphicFeature, BufferManager, @@ -11,33 +8,10 @@ ) from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature from ._common import Name, Offset, Rotation, Visible, Deleted -# __all__ = [ -# "ColorFeature", -# "CmapFeature", -# "ImageCmapFeature", -# "HeatmapCmapFeature", -# "PointsDataFeature", -# "PointsSizesFeature", -# "ImageDataFeature", -# "HeatmapDataFeature", -# "PresentFeature", -# "ThicknessFeature", -# "GraphicFeature", -# "FeatureEvent", -# "to_gpu_supported_dtype", -# "LinearSelectionFeature", -# "LinearRegionSelectionFeature", -# "Deleted", -# ] -class ImageCmapFeature: - pass - -class ImageDataFeature: - pass class HeatmapDataFeature: pass class HeatmapCmapFeature: - pass \ No newline at end of file + pass diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 254852121..d603e2a33 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -208,20 +208,14 @@ def __getitem__(self, item): def __setitem__(self, key, value): raise NotImplementedError - def _parse_offset_size(self, key: int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...]): - # number of elements in the buffer - upper_bound = self.value.shape[0] - - if isinstance(key, tuple): - # if multiple dims are sliced, we only need the key for - # the first dimension corresponding to n_datapoints - key: int | np.ndarray[int | bool] | slice = key[0] - + def _parse_offset_size(self, key: int | slice | np.ndarray[int | bool] | list[bool | int], upper_bound: int): + """ + parse offset and size for one dimension + """ if isinstance(key, int): # simplest case offset = key size = 1 - n_elements = 1 elif isinstance(key, slice): # TODO: off-by-one sometimes when step is used @@ -244,7 +238,6 @@ def _parse_offset_size(self, key: int | slice | np.ndarray[int | bool] | list[bo # number of elements to upload # this is indexing so do not add 1 size = abs(stop - start) - n_elements = len(range(start, stop, step)) elif isinstance(key, (np.ndarray, list)): if isinstance(key, list): @@ -277,20 +270,27 @@ def _parse_offset_size(self, key: int | slice | np.ndarray[int | bool] | list[bo # passing of indices, not a start:stop size = np.ptp(key) + 1 - # number of elements indexed - n_elements = key.size - else: raise TypeError(key) - return offset, size, n_elements + return offset, size def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...]): """ Uses key from slicing to determine the offset and size of the buffer to mark for upload to the GPU """ - offset, size, n_elements = self._parse_offset_size(key) + upper_bound = self.value.shape[0] + + if isinstance(key, tuple): + if any([k is Ellipsis for k in key]): + # let's worry about ellipsis later + raise TypeError("ellipses not supported for indexing buffers") + # if multiple dims are sliced, we only need the key for + # the first dimension corresponding to n_datapoints + key: int | np.ndarray[int | bool] | slice = key[0] + + offset, size = self._parse_offset_size(key, upper_bound) self.buffer.update_range(offset=offset, size=size) def _emit_event(self, type: str, key, value): diff --git a/fastplotlib/graphics/_features/utils.py b/fastplotlib/graphics/_features/utils.py index c3cd59ccc..e2f6e3428 100644 --- a/fastplotlib/graphics/_features/utils.py +++ b/fastplotlib/graphics/_features/utils.py @@ -1,6 +1,5 @@ import pygfx import numpy as np -from typing import Iterable from ._base import to_gpu_supported_dtype from ...utils import make_pygfx_colors @@ -10,7 +9,6 @@ def parse_colors( colors: str | np.ndarray | list[str] | tuple[str], n_colors: int | None, alpha: float | None = None, - key: int | tuple | slice | None = None, ): """ From eedee8ffdfbbf7b5980fcb8e8ec1b105c1ec514f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 23 May 2024 03:24:52 -0400 Subject: [PATCH 49/77] image features --- fastplotlib/graphics/_features/_image.py | 61 +++++++++++++++-- fastplotlib/graphics/image.py | 83 +++++++++++++----------- 2 files changed, 102 insertions(+), 42 deletions(-) diff --git a/fastplotlib/graphics/_features/_image.py b/fastplotlib/graphics/_features/_image.py index b1fbcb7ff..c32f14bd1 100644 --- a/fastplotlib/graphics/_features/_image.py +++ b/fastplotlib/graphics/_features/_image.py @@ -1,13 +1,64 @@ -from ._base import GraphicFeature, FeatureEvent +import numpy as np + +import pygfx +from ._base import GraphicFeature, BufferManager, FeatureEvent from ...utils import ( make_colors, get_cmap_texture, - quick_min_max, ) -class Vmin(GraphicFeature): +class ImageData(BufferManager): + def __init__(self, data, isolated_buffer: bool = True): + data = self._fix_data(data) + super().__init__(data, buffer_type="texture", isolated_buffer=isolated_buffer) + + @property + def buffer(self) -> pygfx.Texture: + return self._buffer + + def _fix_data(self, data): + if data.ndim not in (2, 3): + raise ValueError( + "image data must be 2D with or without an RGB(A) dimension, i.e. " + "it must be of shape [x, y], [x, y, 3] or [x, y, 4]" + ) + + # let's just cast to float32 always + return data.astype(np.float32) + + def __setitem__(self, key: int | slice | np.ndarray[int | bool] | tuple[slice | np.ndarray[int | bool]], value): + # offset and size should be (width, height, depth), i.e. (columns, rows, depth) + # offset and size for depth should always be 0, 1 for 2D images + if isinstance(key, tuple): + # multiple dims sliced + if any([k is Ellipsis for k in key]): + # let's worry about ellipsis later + raise TypeError("ellipses not supported for indexing buffers") + if len(key) in (2, 3): + dim_os = list() # hold offset and size for each dim + for dim, k in enumerate(key[:2]): # we only need width and height + dim_os.append(self._parse_offset_size(k, self.value.shape[dim])) + + # offset and size for each dim into individual offset and size tuple + # note that this is flipped since we need (width, height) from (rows, cols) + offset = (*tuple(os[1] for os in dim_os), 0) + size = (*tuple(os[1] for os in dim_os), 0) + else: + raise IndexError + + else: + # only first dim (rows) indexed + row_offset, row_size = self._parse_offset_size(key, self.value.shape[0]) + offset = (0, row_offset, 0) + size = (self.value.shape[1], row_size, 1) + + self.buffer.update_range(offset, size) + self._emit_event("data", key, value) + + +class ImageVmin(GraphicFeature): """lower contrast limit""" def __init__(self, value: float): self._value = value @@ -26,7 +77,7 @@ def set_value(self, graphic, value: float): self._call_event_handlers(event) -class Vmax(GraphicFeature): +class ImageVmax(GraphicFeature): """upper contrast limit""" def __init__(self, value: float): self._value = value @@ -45,7 +96,7 @@ def set_value(self, graphic, value: float): self._call_event_handlers(event) -class Cmap(GraphicFeature): +class ImageCmap(GraphicFeature): """colormap for texture""" def __init__(self, value: str): self._value = value diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index e87eaad0f..ef05631fc 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -11,10 +11,10 @@ from ._base import Graphic, Interaction from .selectors import LinearSelector, LinearRegionSelector from ._features import ( - Cmap, - Vmin, - Vmax, - ImageDataFeature, + ImageData, + ImageCmap, + ImageVmin, + ImageVmax, HeatmapDataFeature, HeatmapCmapFeature, to_gpu_supported_dtype, @@ -200,15 +200,42 @@ def _add_plot_area_hook(self, plot_area): class ImageGraphic(Graphic, Interaction, _AddSelectorsMixin): features = {"data", "cmap", "vmin", "vmax"} + @property + def data(self) -> ImageData: + """Get or set the image data""" + return self._data + + @data.setter + def data(self, data): + self._data[:] = data + @property def cmap(self) -> str: - """Graphic name""" + """colormap name""" return self._cmap.value @cmap.setter def cmap(self, name: str): self._cmap.set_value(self, name) + @property + def vmin(self) -> float: + """lower contrast limit""" + return self._vmin.value + + @vmin.setter + def vmin(self, value: float): + self._vmin.set_value(self, value) + + @property + def vmax(self) -> float: + """upper contrast limit""" + return self._vmax.value + + @vmax.setter + def vmax(self, value: float): + self._vmax.set_value(self, value) + def __init__( self, data: Any, @@ -268,37 +295,29 @@ def __init__( """ super().__init__(*args, **kwargs) - - data = to_gpu_supported_dtype(data) - - # TODO: we need to organize and do this better - if isolated_buffer: - # initialize a buffer with the same shape as the input data - # we do not directly use the input data array as the buffer - # because if the input array is a read-only type, such as - # numpy memmaps, we would not be able to change the image data - buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) - else: - buffer_init = data + self._data = ImageData(data, isolated_buffer=isolated_buffer) + self._cmap = ImageCmap(cmap) if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) - texture = pygfx.Texture(buffer_init, dim=2) + self._vmin = ImageVmin(vmin) + self._vmax = ImageVmax(vmax) - geometry = pygfx.Geometry(grid=texture) + clim = (self.vmin, self.vmax) - self._cmap = Cmap(cmap) + # make grid geometry from image data Texture + geometry = pygfx.Geometry(grid=self._data.buffer) - # if data is RGB or RGBA - if data.ndim > 2: + if self._data.value.ndim > 2: + # if data is RGB or RGBA material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), map_interpolation=filter, pick_write=True + clim=clim, map_interpolation=filter, pick_write=True ) - # if data is just 2D without color information, use colormap LUT else: + # if data is just 2D without color information, use colormap LUT material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), + clim=clim, map=self._cmap.texture, map_interpolation=filter, pick_write=True, @@ -308,18 +327,8 @@ def __init__( self._set_world_object(world_object) - self.vmin = Vmin(vmin) - self.vmax = Vmax(Vmax) - - self.data = ImageDataFeature(self, data) - # TODO: we need to organize and do this better - if isolated_buffer: - # if the buffer was initialized with zeros - # set it with the actual data - self.data = data - - # def reset_vmin_vmax(self): - # vmin, vmax = quick_min_max(data) + def reset_vmin_vmax(self): + self.vmin, self.vmax = quick_min_max(self._data.value) def set_feature(self, feature: str, new_data: Any, indices: Any): pass From 67ad32724143dc8f41ce88803d4c5e493123ef43 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 23 May 2024 03:27:16 -0400 Subject: [PATCH 50/77] cleanup --- fastplotlib/graphics/_features/_thickness.py | 21 -------------------- 1 file changed, 21 deletions(-) delete mode 100644 fastplotlib/graphics/_features/_thickness.py diff --git a/fastplotlib/graphics/_features/_thickness.py b/fastplotlib/graphics/_features/_thickness.py deleted file mode 100644 index d13c2b727..000000000 --- a/fastplotlib/graphics/_features/_thickness.py +++ /dev/null @@ -1,21 +0,0 @@ -from ._base import GraphicFeature, FeatureEvent - - -class ThicknessFeature(GraphicFeature): - """ - Used by Line graphics for line material thickness. - """ - - def __init__(self, thickness: float): - self._value = thickness - super().__init__() - - @property - def value(self) -> float: - return self._value - - def set_value(self, parent, value: float): - parent.world_object.material.thickness = value - - event = FeatureEvent("thickness", {"value": value}) - self._call_event_handlers(event) From b417190e382c127124e1da31f26188a86daffe5f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 23 May 2024 04:11:25 -0400 Subject: [PATCH 51/77] start selection feature refactor --- .../graphics/_features/_selection_features.py | 71 ++++++------------- 1 file changed, 21 insertions(+), 50 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 21e5d0a09..4c8cdaa99 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -10,69 +10,40 @@ class LinearSelectionFeature(GraphicFeature): # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system """ Manages the linear selection and callbacks - - **event pick info** - - =================== =============================== ================================================================================================= - key type selection - =================== =============================== ================================================================================================= - "selected_index" ``int`` the graphic data index that corresponds to the selector position - "world_object" ``pygfx.WorldObject`` pygfx WorldObject - "new_data" ``numpy.ndarray`` or ``None`` the new selector position in world coordinates, not necessarily the same as "selected_index" - "graphic" ``Graphic`` the selector graphic - "delta" ``numpy.ndarray`` the delta vector of the graphic in NDC - "pygfx_event" ``pygfx.Event`` pygfx Event - =================== =============================== ================================================================================================= - """ - def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]): - super().__init__(parent, data=value) + def __init__(self, axis: str, value: float, limits: Tuple[int, int]): + super().__init__() self._axis = axis self._limits = limits + self._value = value - def _set(self, value: float): + @property + def value(self) -> float: + """ + selection index w.r.t. the graphic.data + not world position, graphic.offset is subtracted + """ + return self._value + + def set_value(self, graphic, value: float): if not (self._limits[0] <= value <= self._limits[1]): return - if self._axis == "x": - self._parent.position_x = value - else: - self._parent.position_y = value - - self._data = value - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - if len(self._event_handlers) < 1: - return + offset = list(graphic.offset) - if self._parent.parent is not None: - g_ix = self._parent.get_selected_index() + if self._axis == "x": + offset[0] = value else: - g_ix = None - - # get pygfx event and reset it - pygfx_ev = self._parent._pygfx_event - self._parent._pygfx_event = None - - pick_info = { - "world_object": self._parent.world_object, - "new_data": new_data, - "selected_index": g_ix, - "graphic": self._parent, - "pygfx_event": pygfx_ev, - "delta": self._parent.delta, - } - - event_data = FeatureEvent(type="selection", pick_info=pick_info) + offset[1] = value - self._call_event_handlers(event_data) + graphic.offset = offset - def __repr__(self) -> str: - s = f"LinearSelectionFeature for {self._parent}" - return s + self._value = value + event = FeatureEvent("selection", {"value": value}) + self._call_event_handlers(event) + # TODO: selector event handlers can call event.get_selected_index() to get the data index class LinearRegionSelectionFeature(GraphicFeature): From eb4740842aaab50ebdf421d35ea8004136f36354 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 23 May 2024 04:34:15 -0400 Subject: [PATCH 52/77] more on selection features --- .../graphics/_features/_selection_features.py | 120 +++++++----------- 1 file changed, 47 insertions(+), 73 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 4c8cdaa99..9c27f3326 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -12,7 +12,21 @@ class LinearSelectionFeature(GraphicFeature): Manages the linear selection and callbacks """ - def __init__(self, axis: str, value: float, limits: Tuple[int, int]): + def __init__(self, axis: str, value: float, limits: Tuple[float, float]): + """ + + Parameters + ---------- + axis: "x" | "y" + axis the selector is restricted to + + value: float + position of the slider in world space, NOT data space + limits: (float, float) + min, max limits of the selector + + """ + super().__init__() self._axis = axis @@ -22,9 +36,10 @@ def __init__(self, axis: str, value: float, limits: Tuple[int, int]): @property def value(self) -> float: """ - selection index w.r.t. the graphic.data - not world position, graphic.offset is subtracted + selection in world space, NOT data space """ + # TODO: Not sure if we should make this public since it's in world space, not data space + # need to decide if we give a value based on the selector's parent graphic, if there is one return self._value def set_value(self, graphic, value: float): @@ -41,48 +56,37 @@ def set_value(self, graphic, value: float): graphic.offset = offset self._value = value - event = FeatureEvent("selection", {"value": value}) + event = FeatureEvent("selection", {"index": graphic.get_selected_index()}) self._call_event_handlers(event) - # TODO: selector event handlers can call event.get_selected_index() to get the data index class LinearRegionSelectionFeature(GraphicFeature): """ Feature for a linearly bounding region - - **event pick info** - - ===================== =============================== ======================================================================================= - key type description - ===================== =============================== ======================================================================================= - "selected_indices" ``numpy.ndarray`` or ``None`` selected graphic data indices - "world_object" ``pygfx.WorldObject`` pygfx World Object - "new_data" ``(float, float)`` current bounds in world coordinates, NOT necessarily the same as "selected_indices". - "graphic" ``Graphic`` the selection graphic - "delta" ``numpy.ndarray`` the delta vector of the graphic in NDC - "pygfx_event" ``pygfx.Event`` pygfx Event - "selected_data" ``numpy.ndarray`` or ``None`` selected graphic data - "move_info" ``MoveInfo`` last position and event source (pygfx.Mesh or pygfx.Line) - ===================== =============================== ======================================================================================= - """ def __init__( - self, parent, selection: Tuple[int, int], axis: str, limits: Tuple[int, int] + self, value: Tuple[int, int], axis: str, limits: Tuple[int, int] ): - super().__init__(parent, data=selection) + super().__init__() self._axis = axis self._limits = limits + self._value = value - self._set(selection) + @property + def value(self) -> float: + """ + selection in world space, NOT data space + """ + return self._value @property def axis(self) -> str: """one of "x" | "y" """ return self._axis - def _set(self, value: Tuple[float, float]): + def set_value(self, graphic, value: Tuple[float, float]): # sets new bounds if not isinstance(value, tuple): raise TypeError( @@ -103,71 +107,41 @@ def _set(self, value: Tuple[float, float]): if self.axis == "x": # change left x position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.x_left] = value[0] + graphic.fill.geometry.positions.data[mesh_masks.x_left] = value[0] # change right x position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.x_right] = value[1] + graphic.fill.geometry.positions.data[mesh_masks.x_right] = value[1] # change x position of the left edge line - self._parent.edges[0].geometry.positions.data[:, 0] = value[0] + graphic.edges[0].geometry.positions.data[:, 0] = value[0] # change x position of the right edge line - self._parent.edges[1].geometry.positions.data[:, 0] = value[1] + graphic.edges[1].geometry.positions.data[:, 0] = value[1] elif self.axis == "y": # change bottom y position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.y_bottom] = value[0] + graphic.fill.geometry.positions.data[mesh_masks.y_bottom] = value[0] # change top position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.y_top] = value[1] + graphic.fill.geometry.positions.data[mesh_masks.y_top] = value[1] # change y position of the bottom edge line - self._parent.edges[0].geometry.positions.data[:, 1] = value[0] + graphic.edges[0].geometry.positions.data[:, 1] = value[0] # change y position of the top edge line - self._parent.edges[1].geometry.positions.data[:, 1] = value[1] + graphic.edges[1].geometry.positions.data[:, 1] = value[1] - self._data = value # (value[0], value[1]) + self._value = value # (value[0], value[1]) # send changes to GPU - self._parent.fill.geometry.positions.update_range() - - self._parent.edges[0].geometry.positions.update_range() - self._parent.edges[1].geometry.positions.update_range() + graphic.fill.geometry.positions.update_range() - # calls any events - self._feature_changed(key=None, new_data=value) + graphic.edges[0].geometry.positions.update_range() + graphic.edges[1].geometry.positions.update_range() - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - if len(self._event_handlers) < 1: - return - - if self._parent.parent is not None: - selected_ixs = self._parent.get_selected_indices() - selected_data = self._parent.get_selected_data() - else: - selected_ixs = None - selected_data = None - - # get pygfx event and reset it - pygfx_ev = self._parent._pygfx_event - self._parent._pygfx_event = None - - pick_info = { - "world_object": self._parent.world_object, - "new_data": new_data, - "selected_indices": selected_ixs, - "selected_data": selected_data, - "graphic": self._parent, - "delta": self._parent.delta, - "pygfx_event": pygfx_ev, - "move_info": self._parent._move_info, - } - - event_data = FeatureEvent(type="selection", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"LinearRegionSelectionFeature for {self._parent}" - return s + # send event + event = FeatureEvent("selection", {"value": value}) + self._call_event_handlers(event) + # TODO: user's selector event handlers can call event.graphic.get_selected_indices() to get the data index, + # and event.graphic.get_selected_data() to get the data under the selection + # this is probably a good idea so that the data isn't sliced until it's actually necessary From ab80033c9e229b6817eee7ae65158cbd2b9b0eba Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 25 May 2024 05:14:51 -0400 Subject: [PATCH 53/77] offset and rotation for base graphic --- fastplotlib/graphics/_base.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 443602b2c..bbb2051f5 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -50,12 +50,12 @@ def name(self, value: str): self._name.set_value(self, value) @property - def offset(self) -> tuple: - """Offset position of the graphic, [x, y, z]""" + def offset(self) -> np.ndarray: + """Offset position of the graphic, array: [x, y, z]""" return self._offset.value @offset.setter - def offset(self, value: tuple[float, float, float]): + def offset(self, value: np.ndarray | list | tuple): self._offset.set_value(self, value) @property @@ -64,7 +64,7 @@ def rotation(self) -> np.ndarray: return self._rotation.value @rotation.setter - def rotation(self, value: tuple[float, float, float, float]): + def rotation(self, value: np.ndarray | list | tuple): self._rotation.set_value(self, value) @property @@ -101,7 +101,8 @@ def __init_subclass__(cls, **kwargs): def __init__( self, name: str = None, - offset: tuple[float] = (0., 0., 0.), + offset: np.ndarray | list | tuple = (0., 0., 0.), + rotation: np.ndarray | list | tuple = (0., 0., 0., 1.), metadata: Any = None, collection_index: int = None, ): @@ -122,7 +123,6 @@ def __init__( self.metadata = metadata self.collection_index = collection_index self.registered_callbacks = dict() - # self.present = PresentFeature(parent=self) # store hex id str of Graphic instance mem location self._fpl_address: HexStr = hex(id(self)) @@ -138,7 +138,7 @@ def __init__( # all the common features self._name = Name(name) self._deleted = Deleted(False) - self._rotation = None # set later when world object is set + self._rotation = Rotation(rotation) # set later when world object is set self._offset = Offset(offset) self._visible = Visible(True) @@ -151,7 +151,13 @@ def world_object(self) -> pygfx.WorldObject: def _set_world_object(self, wo: pygfx.WorldObject): WORLD_OBJECTS[self._fpl_address] = wo - self._rotation = Rotation(self.world_object.world.rotation[:]) + # set offset if it's not (0., 0., 0.) + if not all(self.world_object.world.position == self.offset): + self.offset = self.offset + + # set rotation if it's not (0., 0., 0., 1.) + if not all(self.world_object.world.rotation == self.rotation): + self.rotation = self.rotation def detach_feature(self, feature: str): raise NotImplementedError From da433ded5906d2e7daabb215f147c8a0e4f63426 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 25 May 2024 05:15:09 -0400 Subject: [PATCH 54/77] feature event table --- fastplotlib/graphics/_features/_base.py | 33 +++++++++++-------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index d603e2a33..2761bf994 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -43,24 +43,21 @@ def to_gpu_supported_dtype(array): class FeatureEvent(pygfx.Event): """ - Dataclass that holds feature event information. Has ``type`` and ``pick_info`` attributes. - - Attributes - ---------- - type: str, example "colors" - - pick_info: dict: - - ============== ============================================================================= - key value - ============== ============================================================================= - "index" indices where feature data was changed, ``range`` object or ``List[int]`` - "world_object" world object the feature belongs to - "new_data: the new data for this feature - ============== ============================================================================= - - .. note:: - pick info varies between features, this is just the general structure + **All event instances have the following attributes** + + +------------+-------------+-----------------------------------------------+ + | attribute | type | description | + +============+=============+===============================================+ + | type | str | "colors" - name of the event | + +------------+-------------+-----------------------------------------------+ + | graphic | Graphic | graphic instance that the event is from | + +------------+-------------+-----------------------------------------------+ + | info | dict | event info dictionary (see below) | + +------------+-------------+-----------------------------------------------+ + | target | WorldObject | pygfx rendering engine object for the graphic | + +------------+-------------+-----------------------------------------------+ + | time_stamp | float | time when the event occured, in ms | + +------------+-------------+-----------------------------------------------+ """ From d12d4faec367e4865e7aee123d775da2cf4ab6bc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 25 May 2024 05:15:34 -0400 Subject: [PATCH 55/77] position feature event tables --- .../graphics/_features/_positions_graphics.py | 68 +++++++++++++++---- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index 37ac355a5..a90e36806 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -17,7 +17,18 @@ class VertexColors(BufferManager): """ - Manages the color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` + + **info dict** + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | dict key | value type | value description | + +============+===========================================================+==================================================================================+ + | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which colors were indexed/sliced | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | value | np.ndarray | new color values for points that were changed, shape is [n_points_changed, RGBA] | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | user_value | str | np.ndarray | tuple[float] | list[float] | list[str] | user input value that was parsed into the RGBA array | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + """ def __init__( @@ -28,7 +39,7 @@ def __init__( isolated_buffer: bool = True, ): """ - ColorFeature + Manages the vertex color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` Parameters ---------- @@ -50,19 +61,20 @@ def __init__( def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], - value: str | np.ndarray | tuple[float] | list[float] | list[str] + user_value: str | np.ndarray | tuple[float] | list[float] | list[str] ): if isinstance(key, tuple): # directly setting RGBA values for points, we do no parsing - if not isinstance(value, (int, float, np.ndarray)): + if not isinstance(user_value, (int, float, np.ndarray)): raise TypeError( "Can only set from int, float, or array to set colors directly by slicing the entire array" ) + value = user_value elif isinstance(key, int): # set color of one point n_colors = 1 - value = parse_colors(value, n_colors) + value = parse_colors(user_value, n_colors) elif isinstance(key, slice): # find n_colors by converting slice to range and then parse colors @@ -70,7 +82,7 @@ def __setitem__( n_colors = len(range(start, stop, step)) - value = parse_colors(value, n_colors) + value = parse_colors(user_value, n_colors) elif isinstance(key, (np.ndarray, list)): if isinstance(key, list): @@ -93,7 +105,7 @@ def __setitem__( else: raise TypeError("If slicing colors with an array, it must be a 1D bool or int array") - value = parse_colors(value, n_colors) + value = parse_colors(user_value, n_colors) else: raise TypeError @@ -102,7 +114,16 @@ def __setitem__( self._update_range(key) - self._emit_event("colors", key, value) + if len(self._event_handlers) < 1: + return + + event_info = { + "key": key, + "value": value, + "user_value": user_value, + } + event = FeatureEvent("colors", info=event_info) + self._call_event_handlers(event) class UniformColor(GraphicFeature): @@ -143,11 +164,22 @@ def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Colo class VertexPositions(BufferManager): """ - Manages the vertex positions buffer shown in the graphic. - Supports fancy indexing if the data array also supports it. + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + | dict key | value type | value description | + +==========+==========================================================+==========================================================================================+ + | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which vertex positions data were indexed/sliced | + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + | value | np.ndarray | float | list[float] | new data values for points that were changed, shape depends on the indices that were set | + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + """ def __init__(self, data: Any, isolated_buffer: bool = True): + """ + Manages the vertex positions buffer shown in the graphic. + Supports fancy indexing if the data array also supports it. + """ + data = self._fix_data(data) super().__init__(data, isolated_buffer=isolated_buffer) @@ -170,7 +202,7 @@ def _fix_data(self, data): return to_gpu_supported_dtype(data) - def __setitem__(self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value): + def __setitem__(self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value: np.ndarray | float | list[float]): # directly use the key to slice the buffer self.buffer.data[key] = value @@ -183,8 +215,13 @@ def __setitem__(self, key: int | slice | np.ndarray[int | bool] | tuple[slice, . class PointsSizesFeature(BufferManager): """ - Access to the vertex buffer data shown in the graphic. - Supports fancy indexing if the data array also supports it. + +----------+-------------------------------------------------------------------+----------------------------------------------+ + | dict key | value type | value description | + +==========+===================================================================+==============================================+ + | key | int | slice | np.ndarray[int | bool] | list[int | bool] | key at which point sizes indexed/sliced | + +----------+-------------------------------------------------------------------+----------------------------------------------+ + | value | int | float | np.ndarray | list[int | float] | tuple[int | float] | new size values for points that were changed | + +----------+-------------------------------------------------------------------+----------------------------------------------+ """ def __init__( @@ -193,6 +230,9 @@ def __init__( n_datapoints: int, isolated_buffer: bool = True ): + """ + Manages sizes buffer of scatter points. + """ sizes = self._fix_sizes(sizes, n_datapoints) super().__init__(data=sizes, isolated_buffer=isolated_buffer) @@ -224,7 +264,7 @@ def _fix_sizes(self, sizes: int | float | np.ndarray | list[int | float] | tuple return sizes - def __setitem__(self, key, value): + def __setitem__(self, key: int | slice | np.ndarray[int | bool] | list[int | bool], value: int | float | np.ndarray | list[int | float] | tuple[int | float]): # this is a very simple 1D buffer, no parsing required, directly set buffer self.buffer.data[key] = value self._update_range(key) From 570683a84bf4c3dfa9f0fdf9334b725e2779e9cc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 25 May 2024 05:15:57 -0400 Subject: [PATCH 56/77] rotation and offset features --- fastplotlib/graphics/_features/_common.py | 47 +++++++++++++++-------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/fastplotlib/graphics/_features/_common.py b/fastplotlib/graphics/_features/_common.py index aa44bde5a..bd2604386 100644 --- a/fastplotlib/graphics/_features/_common.py +++ b/fastplotlib/graphics/_features/_common.py @@ -1,3 +1,5 @@ +import numpy as np + from ._base import GraphicFeature, FeatureEvent @@ -26,20 +28,26 @@ def set_value(self, graphic, value: str): class Offset(GraphicFeature): """Offset position of the graphic, [x, y, z]""" - def __init__(self, value: tuple[float, float, float]): - self._value = value + def __init__(self, value: np.ndarray | list | tuple): + self._validate(value) + self._value = np.array(value) + self._value.flags.writeable = False super().__init__() + def _validate(self, value): + if not len(value) == 3: + raise ValueError("offset must be a list, tuple, or array of 3 float values") + @property - def value(self) -> tuple[float, float, float]: + def value(self) -> np.ndarray: return self._value - def set_value(self, graphic, value: tuple[float, float, float]): - if not len(value) == 3: - raise ValueError("offset must be a list, tuple, or array of 3 float values") + def set_value(self, graphic, value: np.ndarray | list | tuple): + self._validate(value) - graphic.position = value - self._value = value + graphic.world_object.world.position = value + self._value = graphic.world_object.world.position.copy() + self._value.flags.writeable = False event = FeatureEvent(type="offset", info={"value": value}) self._call_event_handlers(event) @@ -47,21 +55,26 @@ def set_value(self, graphic, value: tuple[float, float, float]): class Rotation(GraphicFeature): """Graphic rotation quaternion""" - def __init__(self, value: tuple[float, float, float, float]): - self._value = value + def __init__(self, value: np.ndarray | list | tuple): + self._validate(value) + self._value = np.array(value) + self._value.flags.writeable = False super().__init__() + def _validate(self, value): + if not len(value) == 4: + raise ValueError("rotation quaternion must be a list, tuple, or array of 4 float values") + @property - def value(self) -> tuple[float, float, float, float]: + def value(self) -> np.ndarray: return self._value - def set_value(self, graphic, value: tuple[float, float, float, float]): - if not len(value) == 4: - raise ValueError("rotation must be a list, tuple, or array of 4 float values" - "representing a quaternion") + def set_value(self, graphic, value: np.ndarray | list | tuple): + self._validate(value) - graphic.rotation = value - self._value = value + graphic.world_object.world.rotation = value + self._value = graphic.world_object.world.rotation.copy() + self._value.flags.writeable = False event = FeatureEvent(type="rotation", info={"value": value}) self._call_event_handlers(event) From ec6b2f43455cbe84899e4562629c49a7528858a4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 25 May 2024 05:17:06 -0400 Subject: [PATCH 57/77] work on selectors, WIP, linear region selector inits properly and moves for x-axis --- .../graphics/_features/_selection_features.py | 65 ++-- fastplotlib/graphics/line.py | 62 +++- .../graphics/selectors/_base_selector.py | 11 +- fastplotlib/graphics/selectors/_linear.py | 83 +++-- .../graphics/selectors/_linear_region.py | 348 ++++++++++-------- 5 files changed, 342 insertions(+), 227 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 9c27f3326..9601468fd 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -1,5 +1,3 @@ -from typing import Tuple, Union, Any - import numpy as np from ...utils import mesh_masks @@ -12,7 +10,7 @@ class LinearSelectionFeature(GraphicFeature): Manages the linear selection and callbacks """ - def __init__(self, axis: str, value: float, limits: Tuple[float, float]): + def __init__(self, axis: str, value: float, limits: tuple[float, float]): """ Parameters @@ -42,21 +40,21 @@ def value(self) -> float: # need to decide if we give a value based on the selector's parent graphic, if there is one return self._value - def set_value(self, graphic, value: float): + def set_value(self, selector, value: float): if not (self._limits[0] <= value <= self._limits[1]): return - offset = list(graphic.offset) + offset = list(selector.offset) if self._axis == "x": offset[0] = value else: offset[1] = value - graphic.offset = offset + selector.offset = offset self._value = value - event = FeatureEvent("selection", {"index": graphic.get_selected_index()}) + event = FeatureEvent("selection", {"index": selector.get_selected_index()}) self._call_event_handlers(event) @@ -66,16 +64,16 @@ class LinearRegionSelectionFeature(GraphicFeature): """ def __init__( - self, value: Tuple[int, int], axis: str, limits: Tuple[int, int] + self, value: tuple[int, int], axis: str, limits: tuple[float, float] ): super().__init__() self._axis = axis self._limits = limits - self._value = value + self._value = tuple(int(v) for v in value) @property - def value(self) -> float: + def value(self) -> np.ndarray[int]: """ selection in world space, NOT data space """ @@ -86,14 +84,26 @@ def axis(self) -> str: """one of "x" | "y" """ return self._axis - def set_value(self, graphic, value: Tuple[float, float]): - # sets new bounds + def set_value(self, selector, value: tuple[float, float]): + """ + Set start, stop range of selector + + Parameters + ---------- + selector: LinearRegionSelector + + value: (float, float) + in world space, NOT data space + + """ if not isinstance(value, tuple): raise TypeError( "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " "where `min_bound` and `max_bound` are numeric values." ) + # value = tuple(int(v) for v in value) + # make sure bounds not exceeded for v in value: if not (self._limits[0] <= v <= self._limits[1]): @@ -107,41 +117,44 @@ def set_value(self, graphic, value: Tuple[float, float]): if self.axis == "x": # change left x position of the fill mesh - graphic.fill.geometry.positions.data[mesh_masks.x_left] = value[0] + selector.fill.geometry.positions.data[mesh_masks.x_left] = value[0] # change right x position of the fill mesh - graphic.fill.geometry.positions.data[mesh_masks.x_right] = value[1] + selector.fill.geometry.positions.data[mesh_masks.x_right] = value[1] # change x position of the left edge line - graphic.edges[0].geometry.positions.data[:, 0] = value[0] + selector.edges[0].geometry.positions.data[:, 0] = value[0] # change x position of the right edge line - graphic.edges[1].geometry.positions.data[:, 0] = value[1] + selector.edges[1].geometry.positions.data[:, 0] = value[1] elif self.axis == "y": # change bottom y position of the fill mesh - graphic.fill.geometry.positions.data[mesh_masks.y_bottom] = value[0] + selector.fill.geometry.positions.data[mesh_masks.y_bottom] = value[0] # change top position of the fill mesh - graphic.fill.geometry.positions.data[mesh_masks.y_top] = value[1] + selector.fill.geometry.positions.data[mesh_masks.y_top] = value[1] # change y position of the bottom edge line - graphic.edges[0].geometry.positions.data[:, 1] = value[0] + selector.edges[0].geometry.positions.data[:, 1] = value[0] # change y position of the top edge line - graphic.edges[1].geometry.positions.data[:, 1] = value[1] + selector.edges[1].geometry.positions.data[:, 1] = value[1] - self._value = value # (value[0], value[1]) + self._value = np.array(value) # (value[0], value[1]) # send changes to GPU - graphic.fill.geometry.positions.update_range() + selector.fill.geometry.positions.update_range() - graphic.edges[0].geometry.positions.update_range() - graphic.edges[1].geometry.positions.update_range() + selector.edges[0].geometry.positions.update_range() + selector.edges[1].geometry.positions.update_range() # send event - event = FeatureEvent("selection", {"value": value}) - self._call_event_handlers(event) + if len(self._event_handlers) > 0: + return + + # event = FeatureEvent("selection", {"indices": selector.get_selected_indices()}) + # self._call_event_handlers(event) # TODO: user's selector event handlers can call event.graphic.get_selected_indices() to get the data index, # and event.graphic.get_selected_data() to get the data under the selection # this is probably a good idea so that the data isn't sliced until it's actually necessary diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 62160c164..7a46f5195 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -177,12 +177,12 @@ def add_linear_selector( ) self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 + selector.offset = selector.offset + (0., 0., self.offset[-1] + 1) return weakref.proxy(selector) def add_linear_region_selector( - self, padding: float = 100.0, **kwargs + self, padding: float = 100.0, axis="x", **kwargs ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -203,28 +203,54 @@ def add_linear_region_selector( """ - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + # ( + # bounds_init, + # limits, + # size, + # origin, + # axis, + # end_points, + # ) = self._get_linear_selector_init_args(padding, **kwargs) + + n_datapoints = self.data.value.shape[0] + value_25p = int(n_datapoints / 4) + + # remove any nans + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] + + if axis == "x": + # xvals + axis_vals = data[:, 0] + + # yvals to get size and center + magn_vals = data[:, 1] + elif axis == "y": + axis_vals = data[:, 1] + magn_vals = data[:, 0] + + bounds_init = axis_vals[0], axis_vals[value_25p] + limits = axis_vals[0], axis_vals[-1] + + # width or height of selector + size = (magn_vals.min() - padding, magn_vals.max() + padding) + + # center of selector along the other axis + center = np.nanmean(magn_vals) # create selector selector = LinearRegionSelector( - bounds=bounds_init, + selection=bounds_init, limits=limits, size=size, - origin=origin, + center=center, parent=self, **kwargs, ) self._plot_area.add_graphic(selector, center=False) - # so that it is below this graphic - selector.position_z = self.position_z - 1 + + # place selector below this graphic + selector.offset = selector.offset + (0., 0., self.offset[-1] - 1) # PlotArea manages this for garbage collection etc. just like all other Graphics # so we should only work with a proxy on the user-end @@ -241,7 +267,7 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): axis = "x" if axis == "x": - offset = self.position_x + offset = self.offset[0] # x limits limits = (data[0, 0] + offset, data[-1, 0] + offset) @@ -252,7 +278,7 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): position_y = (data[:, 1].min() + data[:, 1].max()) / 2 # need y offset too for this - origin = (limits[0] - offset, position_y + self.position_y) + origin = (limits[0] - offset, position_y + self.offset[1]) # endpoints of the data range # used by linear selector but not linear region @@ -261,7 +287,7 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): self.data.value[:, 1].max() + padding, ) else: - offset = self.position_y + offset = self.offset[1] # y limits limits = (data[0, 1] + offset, data[-1, 1] + offset) @@ -272,7 +298,7 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): position_x = (data[:, 0].min() + data[:, 0].max()) / 2 # need x offset too for this - origin = (position_x + self.position_x, limits[0] - offset) + origin = (position_x + self.offset[0], limits[0] - offset) end_points = ( self.data.value[:, 0].min() - padding, diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index f20eba4a0..88082bced 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -35,7 +35,7 @@ class MoveInfo: # Selector base class class BaseSelector(Graphic): - feature_events = ("selection",) + features = {"selection"} def __init__( self, @@ -46,6 +46,7 @@ def __init__( arrow_keys_modifier: str = None, axis: str = None, name: str = None, + parent: Graphic = None, ): if edges is None: edges = tuple() @@ -95,6 +96,8 @@ def __init__( self._pygfx_event = None + self._parent = parent + Graphic.__init__(self, name=name) def get_selected_index(self): @@ -110,7 +113,7 @@ def get_selected_data(self): raise NotImplementedError def _get_source(self, graphic): - if self.parent is None and graphic is None: + if self._parent is None and graphic is None: raise AttributeError( "No Graphic to apply selector. " "You must either set a ``parent`` Graphic on the selector, or pass a graphic." @@ -120,7 +123,7 @@ def _get_source(self, graphic): if graphic is not None: source = graphic else: - source = self.parent + source = self._parent return source @@ -262,7 +265,7 @@ def _move_to_pointer(self, ev): """ Calculates delta just using current world object position and calls self._move_graphic(). """ - current_position: np.ndarray = self.position + current_position: np.ndarray = self.offset # middle mouse button clicks if ev.button != 3: diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 82e553f0a..9c7eb6f77 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -17,6 +17,42 @@ class LinearSelector(BaseSelector): + @property + def selection(self) -> float: + """ + The selected data index. Index of the data under the selector + not the x or y value of the data but the index of the x or y value + """ + if self._parent is not None: + return self.get_selected_index() + # TODO: if no parent graphic is set, this just returns world position + # but should we change it? + return self._selection.value + + @selection.setter + def selection(self, index: int): + graphic = self._parent + + if "Line" in graphic.__class__.__name__ or "Scatter" in graphic.__class__.__name__: + if self.axis == "x": + geo_positions = graphic.data.value[:, 0] + offset = graphic.offset[0] + elif self.axis == "y": + geo_positions = graphic.data.value[:, 1] + offset = graphic.offset[1] + + # we want to find the geometry position at the desired index + position = geo_positions[index] + + elif "Image" in graphic.__class__.__name__: + # 1:1 mapping between geometry position and index + position = index + + # new world position for the selector + # offset + new_index + world_pos = offset + position + self._selection.set_value(self, world_pos) + @property def limits(self) -> Tuple[float, float]: return self._limits @@ -79,17 +115,6 @@ def __init__( name: str, optional name of line slider - Features - -------- - - selection: :class:`.LinearSelectionFeature` - ``selection()`` returns the current selector position in world coordinates. - Use ``get_selected_index()`` to get the currently selected index in data - space. - Use ``selection.add_event_handler()`` to add callback functions that are - called when the LinearSelector selection changes. See feature class for - event pick_info table - """ if len(limits) != 2: raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)") @@ -144,8 +169,6 @@ def __init__( self._move_info: dict = None - self.parent = parent - self._block_ipywidget_call = False self._handled_widgets = list() @@ -158,19 +181,26 @@ def __init__( arrow_keys_modifier=arrow_keys_modifier, axis=axis, name=name, + parent=parent, ) self._set_world_object(world_object) - self.selection = LinearSelectionFeature( - self, axis=axis, value=selection, limits=self._limits + self._selection = LinearSelectionFeature( + axis=axis, value=selection, limits=self._limits ) - self.selection = selection + if self._parent is not None: + self.selection = selection + else: + self._selection.set_value(self, selection) + + # update any ipywidgets + self.add_event_handler("selection", self._update_ipywidgets) def _setup_ipywidget_slider(self, widget): # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection() + value = self.selection if isinstance(widget, ipywidgets.IntSlider): value = int(value) @@ -180,16 +210,13 @@ def _setup_ipywidget_slider(self, widget): # user changes widget -> linear selection changes widget.observe(self._ipywidget_callback, "value") - # user changes linear selection -> widget changes - self.selection.add_event_handler(self._update_ipywidgets) - self._handled_widgets.append(widget) def _update_ipywidgets(self, ev): # update the ipywidget sliders when LinearSelector value changes self._block_ipywidget_call = True # prevent infinite recursion - value = ev.pick_info["new_data"] + value = ev.info["index"] # update all the handled slider widgets for widget in self._handled_widgets: if isinstance(widget, ipywidgets.IntSlider): @@ -200,7 +227,7 @@ def _update_ipywidgets(self, ev): self._block_ipywidget_call = False def _ipywidget_callback(self, change): - # update the LinearSelector if the ipywidget value changes + # update the LinearSelector when the ipywidget value changes if self._block_ipywidget_call or self._moving: return @@ -249,9 +276,9 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): cls = getattr(ipywidgets, kind) - value = self.selection() + value = self.selection if "Int" in kind: - value = int(self.selection()) + value = int(self.selection) slider = cls( min=self.limits[0], @@ -335,7 +362,7 @@ def _get_selected_index(self, graphic): if "Line" in graphic.__class__.__name__: # we want to find the index of the geometry position that is closest to the slider's geometry position - find_value = self.selection() - offset + find_value = self._selection.value - offset # get closest data index to the world space position of the slider idx = np.searchsorted(geo_positions, find_value, side="left") @@ -354,7 +381,7 @@ def _get_selected_index(self, graphic): or "Image" in graphic.__class__.__name__ ): # indices map directly to grid geometry for image data buffer - index = self.selection() - offset + index = self._selection.value - offset return round(index) def _move_graphic(self, delta: np.ndarray): @@ -369,9 +396,9 @@ def _move_graphic(self, delta: np.ndarray): """ if self.axis == "x": - self.selection = self.selection() + delta[0] + self.selection = self._selection + delta[0] else: - self.selection = self.selection() + delta[1] + self.selection = self._selection + delta[1] def _fpl_cleanup(self): for widget in self._handled_widgets: diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 09c134800..a13d4adcd 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -16,6 +16,60 @@ class LinearRegionSelector(BaseSelector): + @property + def selection(self) -> tuple[int, int] | List[tuple[int, int]]: + """ + (min, max) of data value along selector's axis, in data space + """ + # TODO: This probably does not account for rotation since world.position + # does not account for rotation, we can do this later + if self._parent is not None: + # just subtract parent offset to map from world to data space + if self.axis == "x": + offset = self._parent.offset[0] + elif self.axis == "y": + offset = self._parent.offset[1] + + return self._selection.value.copy() - offset + + # + # indices = self.get_selected_indices() + # if isinstance(indices, np.ndarray): + # # this can be used directly to create a range object + # return indices[0], indices[-1] + 1 + # # if a collection is under the selector + # elif isinstance(indices, list): + # ranges = list() + # for ixs in indices: + # ranges.append((ixs[0], ixs[-1] + 1)) + # + # return ranges + + # TODO: if no parent graphic is set, this just returns world positions + # but should we change it? + return self._selection.value + + @selection.setter + def selection(self, selection: tuple[int, int]): + # set (xmin, xmax), or (ymin, ymax) of the selector in data space + graphic = self._parent + + start, stop = selection + + if isinstance(graphic, GraphicCollection): + pass + + if self.axis == "x": + offset = graphic.offset[0] + elif self.axis == "y": + offset = graphic.offset[1] + + # add the offset + start += offset + stop += offset + + self._selection.set_value(self, (start, stop)) + @property def limits(self) -> Tuple[float, float]: return self._limits @@ -32,19 +86,19 @@ def limits(self, values: Tuple[float, float]): self.selection._limits = self._limits def __init__( - self, - bounds: Tuple[int, int], - limits: Tuple[int, int], - size: int, - origin: Tuple[int, int], - axis: str = "x", - parent: Graphic = None, - resizable: bool = True, - fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.8, 0), - edge_thickness: int = 3, - arrow_keys_modifier: str = "Shift", - name: str = None, + self, + selection: Tuple[int, int], + limits: Tuple[int, int], + size: tuple[float, float], + center: float, + axis: str = "x", + parent: Graphic = None, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.8, 0), + edge_thickness: int = 3, + arrow_keys_modifier: str = "Shift", + name: str = None, ): """ Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. @@ -60,18 +114,15 @@ def __init__( Parameters ---------- - bounds: (int, int) - the initial bounds of the linear selector + selection: (int, int) + (min, max) values of the "axis" under the selector limits: (int, int) - (min limit, max limit) for the selector + (min limit, max limit) of values on the axis size: int height or width of the selector - origin: (int, int) - initial position of the selector - axis: str, default "x" "x" | "y", axis for the selector @@ -108,43 +159,32 @@ def __init__( """ # lots of very close to zero values etc. so round them, otherwise things get weird - bounds = tuple(map(round, bounds)) - self._limits = tuple(map(round, limits)) - origin = tuple(map(round, origin)) + if not len(selection) == 2: + raise ValueError + + selection = np.asarray(selection) + + if not len(limits) == 2: + raise ValueError + + self._limits = np.asarray(limits) # TODO: sanity checks, we recommend users to add LinearSelection using the add_linear_selector() methods # TODO: so we can worry about the sanity checks later - # if axis == "x": - # if limits[0] != origin[0] != bounds[0]: - # raise ValueError( - # f"limits[0] != position[0] != bounds[0]\n" - # f"{limits[0]} != {origin[0]} != {bounds[0]}" - # ) - # - # elif axis == "y": - # # initial y-position is position[1] - # if limits[0] != origin[1] != bounds[0]: - # raise ValueError( - # f"limits[0] != position[1] != bounds[0]\n" - # f"{limits[0]} != {origin[1]} != {bounds[0]}" - # ) - - self.parent = parent - - # world object for this will be a group - # basic mesh for the fill area of the selector - # line for each edge of the selector + group = pygfx.Group() + mesh_size = np.ptp(size) + if axis == "x": mesh = pygfx.Mesh( - pygfx.box_geometry(1, size, 1), + pygfx.box_geometry(1, mesh_size, 1), pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), ) elif axis == "y": mesh = pygfx.Mesh( - pygfx.box_geometry(size, 1, 1), + pygfx.box_geometry(mesh_size, 1, 1), pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), ) else: @@ -152,91 +192,77 @@ def __init__( # the fill of the selection self.fill = mesh - self.fill.world.position = (*origin, -2) + # no x, y offsets for linear region selector + # everything is done by setting the mesh data + # and line positions + self.fill.world.position = (0, 0, -2) group.add(self.fill) self._resizable = resizable if axis == "x": - # position data for the left edge line - left_line_data = np.array( + # just some data to initialize the edge lines + init_line_data = np.array( [ - [origin[0], (-size / 2) + origin[1], 0.5], - [origin[0], (size / 2) + origin[1], 0.5], + [0, size[0], 0], + [0, size[1], 0] ] ).astype(np.float32) - left_line = pygfx.Line( - pygfx.Geometry(positions=left_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - # position data for the right edge line - right_line_data = np.array( - [ - [bounds[1], (-size / 2) + origin[1], 0.5], - [bounds[1], (size / 2) + origin[1], 0.5], - ] - ).astype(np.float32) - - right_line = pygfx.Line( - pygfx.Geometry(positions=right_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) + if parent is not None: + parent_offset = parent.offset[0] + else: + parent_offset = 0 elif axis == "y": - # position data for the left edge line - bottom_line_data = np.array( + # just some line data to initialize y axis edge lines + init_line_data = np.array( [ - [(-size / 2) + origin[0], origin[1], 0.5], - [(size / 2) + origin[0], origin[1], 0.5], + [size[0], 0, 0], + [size[1], 0, 0], ] ).astype(np.float32) - bottom_line = pygfx.Line( - pygfx.Geometry(positions=bottom_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - # position data for the right edge line - top_line_data = np.array( - [ - [(-size / 2) + origin[0], bounds[1], 0.5], - [(size / 2) + origin[0], bounds[1], 0.5], - ] - ).astype(np.float32) - - top_line = pygfx.Line( - pygfx.Geometry(positions=top_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - self.edges: Tuple[pygfx.Line, pygfx.Line] = (bottom_line, top_line) + if parent is not None: + parent_offset = parent.offset[1] + else: + parent_offset = 0 else: raise ValueError("axis argument must be one of 'x' or 'y'") + line0 = pygfx.Line( + pygfx.Geometry(positions=init_line_data.copy()), # copy so the line buffer is isolated + pygfx.LineMaterial( + thickness=edge_thickness, color=edge_color, pick_write=True + ), + ) + line1 = pygfx.Line( + pygfx.Geometry(positions=init_line_data.copy()), # copy so the line buffer is isolated + pygfx.LineMaterial( + thickness=edge_thickness, color=edge_color, pick_write=True + ), + ) + + self.edges: Tuple[pygfx.Line, pygfx.Line] = (line0, line1) + # add the edge lines for edge in self.edges: - edge.world.z = -1 + edge.world.z = -0.5 group.add(edge) # set the initial bounds of the selector - self.selection = LinearRegionSelectionFeature( - self, bounds, axis=axis, limits=self._limits + # compensate for any offset from the parent graphic + # selection feature only works in world space, not data space + self._selection = LinearRegionSelectionFeature( + selection + parent_offset, + axis=axis, + limits=self._limits + parent_offset ) + print(f"sel value after construct: {selection}") + self._handled_widgets = list() self._block_ipywidget_call = False self._pygfx_event = None @@ -249,13 +275,25 @@ def __init__( arrow_keys_modifier=arrow_keys_modifier, axis=axis, name=name, + parent=parent ) self._set_world_object(group) + self.selection = selection + + print(f"sel value after set: {selection}") + + if self.axis == "x": + offset = (0, center, 0) + elif self.axis == "y": + offset = (center, 0, 0) + + self.offset = self.offset + offset + def get_selected_data( - self, graphic: Graphic = None - ) -> Union[np.ndarray, List[np.ndarray], None]: + self, graphic: Graphic = None + ) -> Union[np.ndarray, List[np.ndarray]]: """ Get the ``Graphic`` data bounded by the current selection. Returns a view of the full data array. @@ -289,33 +327,34 @@ def get_selected_data( data_selections: List[np.ndarray] = list() for i, g in enumerate(source.graphics): - if ixs[i].size == 0: - data_selections.append(None) - else: - s = slice(ixs[i][0], ixs[i][-1]) - data_selections.append(g.data.buffer.data[s]) + # if ixs[i].size == 0: + # data_selections.append(np.array([], dtype=np.float32)) + # else: + s = slice(ixs[i][0], ixs[i][-1]) + # slices n_datapoints dim + data_selections.append(g.data.buffer.data[s]) return source[:].data[s] - # just for one Line graphic else: - if ixs.size == 0: - return None + # just for one graphic + # if ixs.size == 0: + # return np.array([], dtype=np.float32) s = slice(ixs[0], ixs[-1]) + # slices n_datapoints dim return source.data.buffer.data[s] - if ( - "Heatmap" in source.__class__.__name__ - or "Image" in source.__class__.__name__ - ): + if "Image" in source.__class__.__name__: s = slice(ixs[0], ixs[-1]) + if self.axis == "x": - return source.data()[:, s] + return source.data.value[:, s] + elif self.axis == "y": - return source.data()[s] + return source.data.value[s] def get_selected_indices( - self, graphic: Graphic = None + self, graphic: Graphic = None ) -> Union[np.ndarray, List[np.ndarray]]: """ Returns the indices of the ``Graphic`` data bounded by the current selection. @@ -336,47 +375,52 @@ def get_selected_indices( data indices of the selection, list of np.ndarray if graphic is LineCollection """ + # we get the indices from the source graphic source = self._get_source(graphic) - # if the graphic position is not at (0, 0) then the bounds must be offset - offset = getattr(source, f"position_{self.selection.axis}") - offset_bounds = tuple(v - offset for v in self.selection()) - - # need them to be int to use as indices - offset_bounds = tuple(map(int, offset_bounds)) - - if self.selection.axis == "x": + # get the offset of the source graphic + if self.axis == "x": + source_offset = source.offset[0] dim = 0 - else: + elif self.axis == "y": + source_offset = source.offset[1] dim = 1 + # selector (min, max) in world space + bounds = self._selection.value + # subtract offset to get the (min, max) bounded region + # of the source graphic in world space + bounds = tuple(v - source_offset for v in bounds) + + # # need them to be int to use as indices + # offset_bounds = tuple(map(int, offset_bounds)) + if "Line" in source.__class__.__name__: - # now we need to map from graphic space to data space - # we can have more than 1 datapoint between two integer locations in the world space + # now we need to map from world space to data space + # gets indices corresponding to n_datapoints dim + # data space is [n_datapoints, xyz], so we return + # indices that can be used to slice `n_datapoints` if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: # map for each graphic in the collection g_ixs = np.where( - (g.data()[:, dim] >= offset_bounds[0]) - & (g.data()[:, dim] <= offset_bounds[1]) + (g.data.value[:, dim] >= bounds[0]) + & (g.data.value[:, dim] <= bounds[1]) )[0] ixs.append(g_ixs) else: # map this only this graphic ixs = np.where( - (source.data()[:, dim] >= offset_bounds[0]) - & (source.data()[:, dim] <= offset_bounds[1]) + (source.data.value[:, dim] >= bounds[0]) + & (source.data.value[:, dim] <= bounds[1]) )[0] return ixs - if ( - "Heatmap" in source.__class__.__name__ - or "Image" in source.__class__.__name__ - ): + if "Image" in source.__class__.__name__: # indices map directly to grid geometry for image data buffer - ixs = np.arange(*self.selection(), dtype=int) + ixs = np.arange(*bounds, dtype=int) return ixs def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): @@ -410,9 +454,9 @@ def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): cls = getattr(ipywidgets, kind) - value = self.selection() + value = self.selection if "Int" in kind: - value = tuple(map(int, self.selection())) + value = tuple(map(int, self.selection)) slider = cls( min=self.limits[0], @@ -438,7 +482,7 @@ def add_ipywidget_handler(self, widget, step: Union[int, float] = None): """ if not isinstance( - widget, (ipywidgets.IntRangeSlider, ipywidgets.FloatRangeSlider) + widget, (ipywidgets.IntRangeSlider, ipywidgets.FloatRangeSlider) ): raise TypeError( f"`widget` must be one of: ipywidgets.IntRangeSlider or ipywidgets.FloatRangeSlider\n" @@ -457,7 +501,7 @@ def add_ipywidget_handler(self, widget, step: Union[int, float] = None): def _setup_ipywidget_slider(self, widget): # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection() + value = self.selection if isinstance(widget, ipywidgets.IntSlider): value = tuple(map(int, value)) @@ -503,18 +547,19 @@ def _set_slider_layout(self, *args): def _move_graphic(self, delta: np.ndarray): # add delta to current bounds to get new positions - if self.selection.axis == "x": + # print(delta) + if self.axis == "x": # min and max of current bounds, i.e. the edges - xmin, xmax = self.selection() + xmin, xmax = self._selection.value # new left bound position bound0_new = xmin + delta[0] # new right bound position bound1_new = xmax + delta[0] - else: + elif self.axis == "y": # min and max of current bounds, i.e. the edges - ymin, ymax = self.selection() + ymin, ymax = self._selection.value # new bottom bound position bound0_new = ymin + delta[1] @@ -524,8 +569,9 @@ def _move_graphic(self, delta: np.ndarray): # move entire selector if source was fill if self._move_info.source == self.fill: - # set the new bounds - self.selection = (bound0_new, bound1_new) + # set the new bounds, in WORLD space + # don't set property because that is in data space! + self._selection.set_value(self, (bound0_new, bound1_new)) return # if selector is not resizable do nothing @@ -535,10 +581,10 @@ def _move_graphic(self, delta: np.ndarray): # if resizable, move edges if self._move_info.source == self.edges[0]: # change only left or bottom bound - self.selection = (bound0_new, self.selection()[1]) + self._selection.set_value(self, (bound0_new, self._selection.value[1])) elif self._move_info.source == self.edges[1]: # change only right or top bound - self.selection = (self.selection()[0], bound1_new) + self._selection.set_value(self, (self._selection.value[0], bound1_new)) else: return From db910d34cc98d07341d3ec387bbb4d89d8fd719c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 25 May 2024 05:28:07 -0400 Subject: [PATCH 58/77] proper centering --- fastplotlib/graphics/line.py | 3 ++- fastplotlib/graphics/selectors/_linear_region.py | 16 +++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 7a46f5195..3cd1be729 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -232,7 +232,7 @@ def add_linear_region_selector( limits = axis_vals[0], axis_vals[-1] # width or height of selector - size = (magn_vals.min() - padding, magn_vals.max() + padding) + size = int(np.ptp(magn_vals) + padding) # center of selector along the other axis center = np.nanmean(magn_vals) @@ -243,6 +243,7 @@ def add_linear_region_selector( limits=limits, size=size, center=center, + axis=axis, parent=self, **kwargs, ) diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index a13d4adcd..03a2de10b 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -89,7 +89,7 @@ def __init__( self, selection: Tuple[int, int], limits: Tuple[int, int], - size: tuple[float, float], + size: int, center: float, axis: str = "x", parent: Graphic = None, @@ -174,17 +174,15 @@ def __init__( group = pygfx.Group() - mesh_size = np.ptp(size) - if axis == "x": mesh = pygfx.Mesh( - pygfx.box_geometry(1, mesh_size, 1), + pygfx.box_geometry(1, size, 1), pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), ) elif axis == "y": mesh = pygfx.Mesh( - pygfx.box_geometry(mesh_size, 1, 1), + pygfx.box_geometry(size, 1, 1), pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), ) else: @@ -205,8 +203,8 @@ def __init__( # just some data to initialize the edge lines init_line_data = np.array( [ - [0, size[0], 0], - [0, size[1], 0] + [0, -size / 2, 0], + [0, size / 2, 0] ] ).astype(np.float32) @@ -219,8 +217,8 @@ def __init__( # just some line data to initialize y axis edge lines init_line_data = np.array( [ - [size[0], 0, 0], - [size[1], 0, 0], + [-size / 2, 0, 0], + [size / 2, 0, 0], ] ).astype(np.float32) From a09386bf6d87c6231515d572ca442abf0fa0efeb Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 25 May 2024 20:48:46 -0400 Subject: [PATCH 59/77] much simpliified and better linear region selector --- .../graphics/_features/_selection_features.py | 29 ++-- .../graphics/selectors/_base_selector.py | 4 +- .../graphics/selectors/_linear_region.py | 129 +++++++----------- 3 files changed, 66 insertions(+), 96 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 9601468fd..35538c28c 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -1,3 +1,5 @@ +from typing import Sequence + import numpy as np from ...utils import mesh_masks @@ -73,9 +75,9 @@ def __init__( self._value = tuple(int(v) for v in value) @property - def value(self) -> np.ndarray[int]: + def value(self) -> np.ndarray[float]: """ - selection in world space, NOT data space + (min, max) of the selection, in data space """ return self._value @@ -84,7 +86,7 @@ def axis(self) -> str: """one of "x" | "y" """ return self._axis - def set_value(self, selector, value: tuple[float, float]): + def set_value(self, selector, value: Sequence[float]): """ Set start, stop range of selector @@ -93,26 +95,21 @@ def set_value(self, selector, value: tuple[float, float]): selector: LinearRegionSelector value: (float, float) - in world space, NOT data space + (min, max) values in data space """ - if not isinstance(value, tuple): + if not len(value) == 2: raise TypeError( - "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " - "where `min_bound` and `max_bound` are numeric values." + "selection must be a array, tuple, list, or sequence in the form of `(min, max)`, " + "where `min` and `max` are numeric values." ) - # value = tuple(int(v) for v in value) - - # make sure bounds not exceeded - for v in value: - if not (self._limits[0] <= v <= self._limits[1]): - return + # convert to array, clip values if they are beyond the limits + value = np.asarray(value, dtype=np.float32).clip(*self._limits) # make sure `selector width >= 2`, left edge must not move past right edge! # or bottom edge must not move past top edge! - # has to be at least 2 otherwise can't join datapoints for lines - if not (value[1] - value[0]) >= 2: + if not (value[1] - value[0]) >= 0: return if self.axis == "x": @@ -141,7 +138,7 @@ def set_value(self, selector, value: tuple[float, float]): # change y position of the top edge line selector.edges[1].geometry.positions.data[:, 1] = value[1] - self._value = np.array(value) # (value[0], value[1]) + self._value = value # send changes to GPU selector.fill.geometry.positions.update_range() diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index 88082bced..8c0556ce5 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -45,8 +45,8 @@ def __init__( hover_responsive: Tuple[WorldObject, ...] = None, arrow_keys_modifier: str = None, axis: str = None, - name: str = None, parent: Graphic = None, + **kwargs, ): if edges is None: edges = tuple() @@ -98,7 +98,7 @@ def __init__( self._parent = parent - Graphic.__init__(self, name=name) + Graphic.__init__(self, **kwargs) def get_selected_index(self): """Not implemented for this selector""" diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 03a2de10b..ee538ab5f 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -17,20 +17,20 @@ class LinearRegionSelector(BaseSelector): @property - def selection(self) -> tuple[int, int] | List[tuple[int, int]]: + def selection(self) -> Sequence[float] | List[Sequence[float]]: """ (min, max) of data value along selector's axis, in data space """ # TODO: This probably does not account for rotation since world.position # does not account for rotation, we can do this later - if self._parent is not None: - # just subtract parent offset to map from world to data space - if self.axis == "x": - offset = self._parent.offset[0] - elif self.axis == "y": - offset = self._parent.offset[1] + # if self._parent is not None: + # # just subtract parent offset to map from world to data space + # if self.axis == "x": + # offset = self._parent.offset[0] + # elif self.axis == "y": + # offset = self._parent.offset[1] - return self._selection.value.copy() - offset + return self._selection.value.copy() # # indices = self.get_selected_indices() @@ -47,28 +47,28 @@ def selection(self) -> tuple[int, int] | List[tuple[int, int]]: # TODO: if no parent graphic is set, this just returns world positions # but should we change it? - return self._selection.value + # return self._selection.value @selection.setter - def selection(self, selection: tuple[int, int]): + def selection(self, selection: Sequence[float]): # set (xmin, xmax), or (ymin, ymax) of the selector in data space graphic = self._parent - start, stop = selection + # start, stop = selection if isinstance(graphic, GraphicCollection): pass - if self.axis == "x": - offset = graphic.offset[0] - elif self.axis == "y": - offset = graphic.offset[1] + # if self.axis == "x": + # offset = graphic.offset[0] + # elif self.axis == "y": + # offset = graphic.offset[1] + # + # # add the offset + # start += offset + # stop += offset - # add the offset - start += offset - stop += offset - - self._selection.set_value(self, (start, stop)) + self._selection.set_value(self, selection) @property def limits(self) -> Tuple[float, float]: @@ -104,13 +104,9 @@ def __init__( Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. Allows sub-selecting data from a ``Graphic`` or from multiple Graphics. - bounds[0], limits[0], and position[0] must be identical. - - Holding the right mouse button while dragging an edge will force the entire region selector to move. This is - a when using transparent fill areas due to ``pygfx`` picking limitations. - - **Note:** Events get very weird if the values of bounds, limits and origin are close to zero. If you need - a linear selector with small data, we recommend scaling the data and then using the selector. + Assumes that the data under the selector is a function of the axis on which the selector moves + along. Example: if the selector is along the x-axis, then there must be only one y-value for each + x-value, otherwise functions such as ``get_selected_indices()`` do not make sense. Parameters ---------- @@ -208,11 +204,6 @@ def __init__( ] ).astype(np.float32) - if parent is not None: - parent_offset = parent.offset[0] - else: - parent_offset = 0 - elif axis == "y": # just some line data to initialize y axis edge lines init_line_data = np.array( @@ -222,11 +213,6 @@ def __init__( ] ).astype(np.float32) - if parent is not None: - parent_offset = parent.offset[1] - else: - parent_offset = 0 - else: raise ValueError("axis argument must be one of 'x' or 'y'") @@ -250,17 +236,20 @@ def __init__( edge.world.z = -0.5 group.add(edge) + if axis == "x": + offset = (parent.offset[0], center, 0) + elif axis == "y": + offset = (center, parent.offset[1], 0) + # set the initial bounds of the selector # compensate for any offset from the parent graphic # selection feature only works in world space, not data space self._selection = LinearRegionSelectionFeature( - selection + parent_offset, + selection, axis=axis, - limits=self._limits + parent_offset + limits=self._limits ) - print(f"sel value after construct: {selection}") - self._handled_widgets = list() self._block_ipywidget_call = False self._pygfx_event = None @@ -272,23 +261,15 @@ def __init__( hover_responsive=self.edges, arrow_keys_modifier=arrow_keys_modifier, axis=axis, + parent=parent, name=name, - parent=parent + offset=offset, ) self._set_world_object(group) self.selection = selection - print(f"sel value after set: {selection}") - - if self.axis == "x": - offset = (0, center, 0) - elif self.axis == "y": - offset = (center, 0, 0) - - self.offset = self.offset + offset - def get_selected_data( self, graphic: Graphic = None ) -> Union[np.ndarray, List[np.ndarray]]: @@ -544,45 +525,37 @@ def _set_slider_layout(self, *args): widget.layout = ipywidgets.Layout(width=f"{w}px") def _move_graphic(self, delta: np.ndarray): - # add delta to current bounds to get new positions - # print(delta) + # add delta to current min, max to get new positions if self.axis == "x": - # min and max of current bounds, i.e. the edges - xmin, xmax = self._selection.value - - # new left bound position - bound0_new = xmin + delta[0] + # add x value + new_min, new_max = self.selection + delta[0] - # new right bound position - bound1_new = xmax + delta[0] elif self.axis == "y": - # min and max of current bounds, i.e. the edges - ymin, ymax = self._selection.value - - # new bottom bound position - bound0_new = ymin + delta[1] + # add y value + new_min, new_max = self.selection + delta[1] - # new top bound position - bound1_new = ymax + delta[1] - - # move entire selector if source was fill + # move entire selector if event source was fill if self._move_info.source == self.fill: - # set the new bounds, in WORLD space - # don't set property because that is in data space! - self._selection.set_value(self, (bound0_new, bound1_new)) + # prevent weird shrinkage of selector if one edge is already at the limit + if self.selection[0] == self.limits[0] and new_min < self.limits[0]: + return + if self.selection[1] == self.limits[1] and new_max > self.limits[1]: + return + + # move entire selector + self._selection.set_value(self, (new_min, new_max)) return - # if selector is not resizable do nothing + # if selector is not resizable return if not self._resizable: return - # if resizable, move edges + # if event source was an edge and selector is resizable, + # move the edge that caused the event if self._move_info.source == self.edges[0]: # change only left or bottom bound - self._selection.set_value(self, (bound0_new, self._selection.value[1])) + self._selection.set_value(self, (new_min, self._selection.value[1])) elif self._move_info.source == self.edges[1]: # change only right or top bound - self._selection.set_value(self, (self._selection.value[0], bound1_new)) - else: - return + self._selection.set_value(self, (self.selection[0], new_max)) From 49c2108c66c2b0d557bc7f6ccd94670c2af77f16 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 25 May 2024 22:08:49 -0400 Subject: [PATCH 60/77] linear region selector works well on x axis with events and data selection --- .../graphics/_features/_selection_features.py | 8 +- fastplotlib/graphics/line.py | 13 +- .../graphics/selectors/_base_selector.py | 6 +- .../graphics/selectors/_linear_region.py | 157 +++++++----------- 4 files changed, 71 insertions(+), 113 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 35538c28c..00f4e5aa7 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -147,11 +147,13 @@ def set_value(self, selector, value: Sequence[float]): selector.edges[1].geometry.positions.update_range() # send event - if len(self._event_handlers) > 0: + if len(self._event_handlers) < 1: return - # event = FeatureEvent("selection", {"indices": selector.get_selected_indices()}) - # self._call_event_handlers(event) + event = FeatureEvent("selection", {"value": self.value}) + event.get_selected_indices = selector.get_selected_indices + event.get_selected_data = selector.get_selected_data + self._call_event_handlers(event) # TODO: user's selector event handlers can call event.graphic.get_selected_indices() to get the data index, # and event.graphic.get_selected_data() to get the data under the selection # this is probably a good idea so that the data isn't sliced until it's actually necessary diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 3cd1be729..74ced44ef 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -182,7 +182,7 @@ def add_linear_selector( return weakref.proxy(selector) def add_linear_region_selector( - self, padding: float = 100.0, axis="x", **kwargs + self, padding: float = 0.0, axis="x", **kwargs ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -203,15 +203,6 @@ def add_linear_region_selector( """ - # ( - # bounds_init, - # limits, - # size, - # origin, - # axis, - # end_points, - # ) = self._get_linear_selector_init_args(padding, **kwargs) - n_datapoints = self.data.value.shape[0] value_25p = int(n_datapoints / 4) @@ -232,7 +223,7 @@ def add_linear_region_selector( limits = axis_vals[0], axis_vals[-1] # width or height of selector - size = int(np.ptp(magn_vals) + padding) + size = int(np.ptp(magn_vals) * 1.5 + padding) # center of selector along the other axis center = np.nanmean(magn_vals) diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index 8c0556ce5..408acf465 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -37,6 +37,10 @@ class MoveInfo: class BaseSelector(Graphic): features = {"selection"} + @property + def axis(self) -> str: + return self._axis + def __init__( self, edges: Tuple[Line, ...] = None, @@ -72,7 +76,7 @@ def __init__( for wo in self._hover_responsive: self._original_colors[wo] = wo.material.color - self.axis = axis + self._axis = axis # current delta in world coordinates self.delta: np.ndarray = None diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index ee538ab5f..e09023076 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -16,35 +16,21 @@ class LinearRegionSelector(BaseSelector): + @property + def parent(self) -> Graphic | None: + """graphic that the selector is associated with""" + return self._parent + @property def selection(self) -> Sequence[float] | List[Sequence[float]]: """ - (min, max) of data value along selector's axis, in data space + (min, max) of data value along selector's axis """ # TODO: This probably does not account for rotation since world.position # does not account for rotation, we can do this later - # if self._parent is not None: - # # just subtract parent offset to map from world to data space - # if self.axis == "x": - # offset = self._parent.offset[0] - # elif self.axis == "y": - # offset = self._parent.offset[1] return self._selection.value.copy() - # - # indices = self.get_selected_indices() - # if isinstance(indices, np.ndarray): - # # this can be used directly to create a range object - # return indices[0], indices[-1] + 1 - # # if a collection is under the selector - # elif isinstance(indices, list): - # ranges = list() - # for ixs in indices: - # ranges.append((ixs[0], ixs[-1] + 1)) - # - # return ranges - # TODO: if no parent graphic is set, this just returns world positions # but should we change it? # return self._selection.value @@ -54,20 +40,9 @@ def selection(self, selection: Sequence[float]): # set (xmin, xmax), or (ymin, ymax) of the selector in data space graphic = self._parent - # start, stop = selection - if isinstance(graphic, GraphicCollection): pass - # if self.axis == "x": - # offset = graphic.offset[0] - # elif self.axis == "y": - # offset = graphic.offset[1] - # - # # add the offset - # start += offset - # stop += offset - self._selection.set_value(self, selection) @property @@ -95,18 +70,18 @@ def __init__( parent: Graphic = None, resizable: bool = True, fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.8, 0), - edge_thickness: int = 3, + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 8, arrow_keys_modifier: str = "Shift", name: str = None, ): """ Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. - Allows sub-selecting data from a ``Graphic`` or from multiple Graphics. + Allows sub-selecting data from a parent ``Graphic`` or from multiple Graphics. Assumes that the data under the selector is a function of the axis on which the selector moves along. Example: if the selector is along the x-axis, then there must be only one y-value for each - x-value, otherwise functions such as ``get_selected_indices()`` do not make sense. + x-value, otherwise functions such as ``get_selected_data()`` do not make sense. Parameters ---------- @@ -114,16 +89,19 @@ def __init__( (min, max) values of the "axis" under the selector limits: (int, int) - (min limit, max limit) of values on the axis + (min limit, max limit) within which the selector can move size: int height or width of the selector + center: float + center offset of the selector, by default the data mean + axis: str, default "x" - "x" | "y", axis for the selector + "x" | "y", axis the selected can move on parent: Graphic, default ``None`` - associate this selector with a parent Graphic + associate this selector with a parent Graphic from which to fetch data or indices resizable: bool if ``True``, the edges can be dragged to resize the width of the linear selection @@ -134,6 +112,9 @@ def __init__( edge_color: str, array, or tuple edge color for the selector, passed to pygfx.Color + edge_thickness: float, default 8 + edge thickness + arrow_keys_modifier: str modifier key that must be pressed to initiate movement using arrow keys, must be one of: "Control", "Shift", "Alt" or ``None`` @@ -141,17 +122,6 @@ def __init__( name: str name for this selector graphic - Features - -------- - - selection: :class:`.LinearRegionSelectionFeature` - ``selection()`` returns the current selector bounds in world coordinates. - Use ``get_selected_indices()`` to return the selected indices in data - space, and ``get_selected_data()`` to return the selected data. - Use ``selection.add_event_handler()`` to add callback functions that are - called when the LinearSelector selection changes. See feature class for - event pick_info table. - """ # lots of very close to zero values etc. so round them, otherwise things get weird @@ -236,6 +206,7 @@ def __init__( edge.world.z = -0.5 group.add(edge) + # TODO: if parent offset changes, we should set the selector offset too if axis == "x": offset = (parent.offset[0], center, 0) elif axis == "y": @@ -275,7 +246,8 @@ def get_selected_data( ) -> Union[np.ndarray, List[np.ndarray]]: """ Get the ``Graphic`` data bounded by the current selection. - Returns a view of the full data array. + Returns a view of the data array. + If the ``Graphic`` is a collection, such as a ``LineStack``, it returns a list of views of the full array. Can be performed on the ``parent`` Graphic or on another graphic by passing to the ``graphic`` arg. @@ -286,15 +258,16 @@ def get_selected_data( Parameters ---------- - graphic: Graphic, optional + graphic: Graphic, optional, default ``None`` if provided, returns the data selection from this graphic instead of the graphic set as ``parent`` Returns ------- - np.ndarray, List[np.ndarray], or None + np.ndarray or List[np.ndarray] view or list of views of the full array, returns ``None`` if selection is empty """ + source = self._get_source(graphic) ixs = self.get_selected_indices(source) @@ -306,42 +279,42 @@ def get_selected_data( data_selections: List[np.ndarray] = list() for i, g in enumerate(source.graphics): - # if ixs[i].size == 0: - # data_selections.append(np.array([], dtype=np.float32)) - # else: - s = slice(ixs[i][0], ixs[i][-1]) - # slices n_datapoints dim - data_selections.append(g.data.buffer.data[s]) - - return source[:].data[s] + if ixs[i].size == 0: + data_selections.append(np.array([], dtype=np.float32).reshape(0, 3)) + else: + s = slice(ixs[i][0], ixs[i][-1] + 1) # add 1 because these are direct indices + # slices n_datapoints dim + data_selections.append(g.data[s]) + + # return source[:].data[s] else: - # just for one graphic - # if ixs.size == 0: - # return np.array([], dtype=np.float32) + if ixs.size == 0: + # empty selection + return np.array([], dtype=np.float32).reshape(0, 3) - s = slice(ixs[0], ixs[-1]) + s = slice(ixs[0], ixs[-1] + 1) # add 1 to end because these are direct indices # slices n_datapoints dim - return source.data.buffer.data[s] + # slice with min, max is faster than using all the indices + return source.data[s] if "Image" in source.__class__.__name__: - s = slice(ixs[0], ixs[-1]) + s = slice(ixs[0], ixs[-1] + 1) if self.axis == "x": - return source.data.value[:, s] + # slice columns + return source.data[:, s] elif self.axis == "y": - return source.data.value[s] + # slice rows + return source.data[s] def get_selected_indices( self, graphic: Graphic = None ) -> Union[np.ndarray, List[np.ndarray]]: """ Returns the indices of the ``Graphic`` data bounded by the current selection. - This is useful because the ``bounds`` min and max are not necessarily the same - as the Line Geometry positions x-vals or y-vals. For example, if if you used a - np.linspace(0, 100, 1000) for xvals in your line, then you will have 1,000 - x-positions. If the selection ``bounds`` are set to ``(0, 10)``, the returned - indices would be ``(0, 100)``. + + These are the data indices along the selector's "axis" which correspond to the data under the selector. Parameters ---------- @@ -351,7 +324,7 @@ def get_selected_indices( Returns ------- Union[np.ndarray, List[np.ndarray]] - data indices of the selection, list of np.ndarray if graphic is LineCollection + data indices of the selection, list of np.ndarray if graphic is a collection """ # we get the indices from the source graphic @@ -359,48 +332,34 @@ def get_selected_indices( # get the offset of the source graphic if self.axis == "x": - source_offset = source.offset[0] dim = 0 elif self.axis == "y": - source_offset = source.offset[1] dim = 1 - # selector (min, max) in world space - bounds = self._selection.value - # subtract offset to get the (min, max) bounded region - # of the source graphic in world space - bounds = tuple(v - source_offset for v in bounds) - - # # need them to be int to use as indices - # offset_bounds = tuple(map(int, offset_bounds)) + # selector (min, max) data values along axis + bounds = self.selection - if "Line" in source.__class__.__name__: - # now we need to map from world space to data space + if "Line" in source.__class__.__name__ or "Scatter" in source.__class__.__name__: # gets indices corresponding to n_datapoints dim - # data space is [n_datapoints, xyz], so we return + # data is [n_datapoints, xyz], so we return # indices that can be used to slice `n_datapoints` if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: - # map for each graphic in the collection - g_ixs = np.where( - (g.data.value[:, dim] >= bounds[0]) - & (g.data.value[:, dim] <= bounds[1]) - )[0] + # indices for each graphic in the collection + data = g.data.value[:, dim] + g_ixs = np.where((data >= bounds[0]) & (data <= bounds[1]))[0] ixs.append(g_ixs) else: # map this only this graphic - ixs = np.where( - (source.data.value[:, dim] >= bounds[0]) - & (source.data.value[:, dim] <= bounds[1]) - )[0] + data = source.data.value[:, dim] + ixs = np.where((data >= bounds[0]) & (data <= bounds[1]))[0] return ixs if "Image" in source.__class__.__name__: # indices map directly to grid geometry for image data buffer - ixs = np.arange(*bounds, dtype=int) - return ixs + return np.arange(*bounds, dtype=int) def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): """ @@ -538,8 +497,10 @@ def _move_graphic(self, delta: np.ndarray): if self._move_info.source == self.fill: # prevent weird shrinkage of selector if one edge is already at the limit if self.selection[0] == self.limits[0] and new_min < self.limits[0]: + # self._move_end(None) # TODO: cancel further movement to prevent weird asynchronization with pointer return if self.selection[1] == self.limits[1] and new_max > self.limits[1]: + # self._move_end(None) return # move entire selector From 1ec0f4002338ee78ac02026eae24094efbb398c3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 26 May 2024 00:15:56 -0400 Subject: [PATCH 61/77] vertex cmap fix, delete synchronizer --- fastplotlib/graphics/_features/_base.py | 4 +- .../graphics/_features/_positions_graphics.py | 2 +- fastplotlib/graphics/selectors/_sync.py | 90 ------------------- 3 files changed, 3 insertions(+), 93 deletions(-) delete mode 100644 fastplotlib/graphics/selectors/_sync.py diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 2761bf994..45254ad91 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -157,7 +157,7 @@ def __init__( **kwargs ): super().__init__() - if isolated_buffer: + 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[:] @@ -165,7 +165,7 @@ def __init__( # user's input array is used as the buffer bdata = data - if isinstance(data, pygfx.Buffer): + if isinstance(data, pygfx.Resource): # already a buffer, probably used for # managing another BufferManager, example: VertexCmap manages VertexColors self._buffer = data diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index a90e36806..c83fdd7e8 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -296,7 +296,7 @@ class VertexCmap(BufferManager): """ def __init__(self, vertex_colors: VertexColors, cmap_name: str | None, cmap_values: np.ndarray | None): - super().__init__(data=vertex_colors) + super().__init__(data=vertex_colors.buffer) self._vertex_colors = vertex_colors self._cmap_name = cmap_name diff --git a/fastplotlib/graphics/selectors/_sync.py b/fastplotlib/graphics/selectors/_sync.py deleted file mode 100644 index ce903aab8..000000000 --- a/fastplotlib/graphics/selectors/_sync.py +++ /dev/null @@ -1,90 +0,0 @@ -from . import LinearSelector -from typing import * - - -class Synchronizer: - def __init__( - self, *selectors: LinearSelector, key_bind: Union[str, None] = "Shift" - ): - """ - Synchronize the movement of `Selectors`. Selectors will move in sync only when the selected `"key_bind"` is - used during the mouse movement event. Valid key binds are: ``"Control"``, ``"Shift"`` and ``"Alt"``. - If ``key_bind`` is ``None`` then the selectors will always be synchronized. - - Parameters - ---------- - selectors - selectors to synchronize - - key_bind: str, default ``"Shift"`` - one of ``"Control"``, ``"Shift"`` and ``"Alt"`` or ``None`` - """ - self._selectors = list() - self.key_bind = key_bind - - for s in selectors: - self.add(s) - - self.block_event = False - - self.enabled: bool = True - - @property - def selectors(self): - """Selectors managed by the Synchronizer""" - return self._selectors - - def add(self, selector): - """add a selector""" - selector.selection.add_event_handler(self._handle_event) - self._selectors.append(selector) - - def remove(self, selector): - """remove a selector""" - selector.selection.remove_event_handler(self._handle_event) - self._selectors.remove(selector) - - def clear(self): - for i in range(len(self.selectors)): - self.remove(self.selectors[0]) - - def _handle_event(self, ev): - if self.block_event: - # because infinite recursion - return - - if not self.enabled: - return - - self.block_event = True - - source = ev.pick_info["graphic"] - delta = ev.pick_info["delta"] - pygfx_ev = ev.pick_info["pygfx_event"] - - # only moves when modifier is used - if pygfx_ev is None: - self.block_event = False - return - - if self.key_bind is not None: - if self.key_bind not in pygfx_ev.modifiers: - self.block_event = False - return - - if delta is not None: - self._move_selectors(source, delta) - - self.block_event = False - - def _move_selectors(self, source, delta): - for s in self.selectors: - # must use == and not is to compare Graphics because they are weakref proxies! - if s == source: - # if it's the source, since it has already moved - continue - - s._move_graphic(delta) - - def __del__(self): - self.clear() From 5022753361c0ce07f84861baa99abc61799f9726 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 26 May 2024 00:17:01 -0400 Subject: [PATCH 62/77] linear selector works --- .../graphics/_features/_selection_features.py | 65 +++++++++--- fastplotlib/graphics/line.py | 42 +++++--- .../graphics/selectors/_base_selector.py | 2 - fastplotlib/graphics/selectors/_linear.py | 98 ++++++++----------- .../graphics/selectors/_linear_region.py | 16 +-- 5 files changed, 128 insertions(+), 95 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 00f4e5aa7..0bf0d1d55 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -7,9 +7,23 @@ class LinearSelectionFeature(GraphicFeature): - # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system """ - Manages the linear selection and callbacks + **additional event attributes:** + + +--------------------+----------+------------------------------------+ + | attribute | type | description | + +====================+==========+====================================+ + | get_selected_index | callable | returns indices under the selector | + +--------------------+----------+------------------------------------+ + + **info dict:** + + +----------+------------+-------------------------------+ + | dict key | value type | value description | + +==========+============+===============================+ + | value | np.ndarray | new x or y value of selection | + +----------+------------+-------------------------------+ + """ def __init__(self, axis: str, value: float, limits: tuple[float, float]): @@ -36,33 +50,52 @@ def __init__(self, axis: str, value: float, limits: tuple[float, float]): @property def value(self) -> float: """ - selection in world space, NOT data space + selection, data x or y value """ - # TODO: Not sure if we should make this public since it's in world space, not data space - # need to decide if we give a value based on the selector's parent graphic, if there is one return self._value def set_value(self, selector, value: float): - if not (self._limits[0] <= value <= self._limits[1]): - return - - offset = list(selector.offset) + # clip value between limits + value = np.clip(value, self._limits[0], self._limits[1]) + # set position if self._axis == "x": - offset[0] = value - else: - offset[1] = value + dim = 0 + elif self._axis == "y": + dim = 1 - selector.offset = offset + for edge in selector._edges: + edge.geometry.positions.data[:, dim] = value + edge.geometry.positions.update_range() self._value = value - event = FeatureEvent("selection", {"index": selector.get_selected_index()}) + + event = FeatureEvent("selection", {"value": value}) + event.get_selected_index = selector.get_selected_index + self._call_event_handlers(event) class LinearRegionSelectionFeature(GraphicFeature): """ - Feature for a linearly bounding region + **additional event attributes:** + + +----------------------+----------+------------------------------------+ + | attribute | type | description | + +======================+==========+====================================+ + | get_selected_indices | callable | returns indices under the selector | + +----------------------+----------+------------------------------------+ + | get_selected_data | callable | returns data under the selector | + +----------------------+----------+------------------------------------+ + + **info dict:** + + +----------+------------+-----------------------------+ + | dict key | value type | value description | + +==========+============+=============================+ + | value | np.ndarray | new [min, max] of selection | + +----------+------------+-----------------------------+ + """ def __init__( @@ -151,8 +184,10 @@ def set_value(self, selector, value: Sequence[float]): return event = FeatureEvent("selection", {"value": self.value}) + event.get_selected_indices = selector.get_selected_indices event.get_selected_data = selector.get_selected_data + self._call_event_handlers(event) # TODO: user's selector event handlers can call event.graphic.get_selected_indices() to get the data index, # and event.graphic.get_selected_data() to get the data under the selection diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 74ced44ef..629b2cad6 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -129,14 +129,14 @@ def __init__( self.position_z = z_position def add_linear_selector( - self, selection: int = None, padding: float = 50, **kwargs + self, selection: float = None, padding: float = 0., axis: str = "x",**kwargs ) -> LinearSelector: """ Adds a linear selector. Parameters ---------- - selection: int + selection: float initial position of the selector padding: float @@ -151,38 +151,52 @@ def add_linear_selector( """ - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] + + if axis == "x": + # xvals + axis_vals = data[:, 0] + + # yvals to get size and center + magn_vals = data[:, 1] + elif axis == "y": + axis_vals = data[:, 1] + magn_vals = data[:, 0] if selection is None: - selection = limits[0] + selection = axis_vals[0] + limits = axis_vals[0], axis_vals[-1] - if selection < limits[0] or selection > limits[1]: + if not limits[0] <= selection <= limits[1]: raise ValueError( f"the passed selection: {selection} is beyond the limits: {limits}" ) + # width or height of selector + size = int(np.ptp(magn_vals) * 1.5 + padding) + + # center of selector along the other axis + center = np.nanmean(magn_vals) + selector = LinearSelector( selection=selection, limits=limits, - end_points=end_points, + size=size, + center=center, + axis=axis, parent=self, **kwargs, ) self._plot_area.add_graphic(selector, center=False) + + # place selector above this graphic selector.offset = selector.offset + (0., 0., self.offset[-1] + 1) return weakref.proxy(selector) def add_linear_region_selector( - self, padding: float = 0.0, axis="x", **kwargs + self, padding: float = 0., axis: str = "x", **kwargs ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index 408acf465..672b54cd1 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -355,8 +355,6 @@ def _key_down(self, ev): if ev.key not in key_bind_direction.keys(): return - # print(ev.key) - self._key_move_value = ev.key def _key_up(self, ev): diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 9c7eb6f77..b1082f5aa 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -17,41 +17,25 @@ class LinearSelector(BaseSelector): + @property + def parent(self) -> Graphic: + return self._parent + @property def selection(self) -> float: """ - The selected data index. Index of the data under the selector - not the x or y value of the data but the index of the x or y value + x or y value of selector's current position """ - if self._parent is not None: - return self.get_selected_index() - # TODO: if no parent graphic is set, this just returns world position - # but should we change it? return self._selection.value @selection.setter - def selection(self, index: int): + def selection(self, value: int): graphic = self._parent - if "Line" in graphic.__class__.__name__ or "Scatter" in graphic.__class__.__name__: - if self.axis == "x": - geo_positions = graphic.data.value[:, 0] - offset = graphic.offset[0] - elif self.axis == "y": - geo_positions = graphic.data.value[:, 1] - offset = graphic.offset[1] - - # we want to find the geometry position at the desired index - position = geo_positions[index] - - elif "Image" in graphic.__class__.__name__: - # 1:1 mapping between geometry position and index - position = index + if isinstance(graphic, GraphicCollection): + pass - # new world position for the selector - # offset + new_index - world_pos = offset + position - self._selection.set_value(self, world_pos) + self._selection.set_value(self, value) @property def limits(self) -> Tuple[float, float]: @@ -71,14 +55,15 @@ def limits(self, values: Tuple[float, float]): # TODO: make `selection` arg in graphics data space not world space def __init__( self, - selection: int, - limits: Tuple[int, int], + selection: float, + limits: Sequence[float], + size: float, + center: float, axis: str = "x", parent: Graphic = None, - end_points: Tuple[int, int] = None, - arrow_keys_modifier: str = "Shift", + color: str | tuple = "w", thickness: float = 2.5, - color: Any = "w", + arrow_keys_modifier: str = "Shift", name: str = None, ): """ @@ -119,19 +104,19 @@ def __init__( if len(limits) != 2: raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)") - self._limits = tuple(map(round, limits)) + self._limits = np.asarray(limits) - selection = round(selection) + end_points = [-size / 2, size / 2] if axis == "x": - xs = np.zeros(2) + xs = np.array([selection, selection]) ys = np.array(end_points) zs = np.zeros(2) line_data = np.column_stack([xs, ys, zs]) elif axis == "y": xs = np.array(end_points) - ys = np.zeros(2) + ys = np.array([selection, selection]) zs = np.zeros(2) line_data = np.column_stack([xs, ys, zs]) @@ -173,6 +158,11 @@ def __init__( self._handled_widgets = list() + if axis == "x": + offset = (parent.offset[0], center, 0) + elif axis == "y": + offset = (center, parent.offset[1], 0) + # init base selector BaseSelector.__init__( self, @@ -180,8 +170,9 @@ def __init__( hover_responsive=(line_inner, self.line_outer), arrow_keys_modifier=arrow_keys_modifier, axis=axis, - name=name, parent=parent, + name=name, + offset=offset ) self._set_world_object(world_object) @@ -196,7 +187,7 @@ def __init__( self._selection.set_value(self, selection) # update any ipywidgets - self.add_event_handler("selection", self._update_ipywidgets) + self.add_event_handler(self._update_ipywidgets, "selection") def _setup_ipywidget_slider(self, widget): # setup an ipywidget slider with bidirectional callbacks to this LinearSelector @@ -216,7 +207,7 @@ def _update_ipywidgets(self, ev): # update the ipywidget sliders when LinearSelector value changes self._block_ipywidget_call = True # prevent infinite recursion - value = ev.info["index"] + value = ev.info["value"] # update all the handled slider widgets for widget in self._handled_widgets: if isinstance(widget, ipywidgets.IntSlider): @@ -354,34 +345,29 @@ def get_selected_index(self, graphic: Graphic = None) -> Union[int, List[int]]: def _get_selected_index(self, graphic): # the array to search for the closest value along that axis if self.axis == "x": - geo_positions = graphic.data()[:, 0] - offset = getattr(graphic, f"position_{self.axis}") - else: - geo_positions = graphic.data()[:, 1] - offset = getattr(graphic, f"position_{self.axis}") + data = graphic.data[:, 0] + elif self.axis == "y": + data = graphic.data[:, 1] - if "Line" in graphic.__class__.__name__: - # we want to find the index of the geometry position that is closest to the slider's geometry position - find_value = self._selection.value - offset + if "Line" in graphic.__class__.__name__ or "Scatter" in graphic.__class__.__name__: + # we want to find the index of the data closest to the slider position + find_value = self.selection # get closest data index to the world space position of the slider - idx = np.searchsorted(geo_positions, find_value, side="left") + idx = np.searchsorted(data, find_value, side="left") if idx > 0 and ( - idx == len(geo_positions) - or math.fabs(find_value - geo_positions[idx - 1]) - < math.fabs(find_value - geo_positions[idx]) + idx == len(data) + or math.fabs(find_value - data[idx - 1]) + < math.fabs(find_value - data[idx]) ): return round(idx - 1) else: return round(idx) - if ( - "Heatmap" in graphic.__class__.__name__ - or "Image" in graphic.__class__.__name__ - ): + if "Image" in graphic.__class__.__name__: # indices map directly to grid geometry for image data buffer - index = self._selection.value - offset + index = self.selection return round(index) def _move_graphic(self, delta: np.ndarray): @@ -396,9 +382,9 @@ def _move_graphic(self, delta: np.ndarray): """ if self.axis == "x": - self.selection = self._selection + delta[0] + self.selection = self.selection + delta[0] else: - self.selection = self._selection + delta[1] + self.selection = self.selection + delta[1] def _fpl_cleanup(self): for widget in self._handled_widgets: diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index e09023076..c6c40fa88 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -62,8 +62,8 @@ def limits(self, values: Tuple[float, float]): def __init__( self, - selection: Tuple[int, int], - limits: Tuple[int, int], + selection: Sequence[float], + limits: Sequence[float], size: int, center: float, axis: str = "x", @@ -85,10 +85,10 @@ def __init__( Parameters ---------- - selection: (int, int) - (min, max) values of the "axis" under the selector + selection: (float, float) + initial (min, max) x or y values - limits: (int, int) + limits: (float, float) (min limit, max limit) within which the selector can move size: int @@ -347,12 +347,12 @@ def get_selected_indices( ixs = list() for g in source.graphics: # indices for each graphic in the collection - data = g.data.value[:, dim] + data = g.data[:, dim] g_ixs = np.where((data >= bounds[0]) & (data <= bounds[1]))[0] ixs.append(g_ixs) else: # map this only this graphic - data = source.data.value[:, dim] + data = source.data[:, dim] ixs = np.where((data >= bounds[0]) & (data <= bounds[1]))[0] return ixs @@ -450,7 +450,7 @@ def _setup_ipywidget_slider(self, widget): widget.observe(self._ipywidget_callback, "value") # user changes linear selection -> widget changes - self.selection.add_event_handler(self._update_ipywidgets) + self.selection.add_event_handler(self._update_ipywidgets, "selection") self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") From f295356676ad86a4120c9372dda0263baf19bbbe Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 26 May 2024 00:17:08 -0400 Subject: [PATCH 63/77] cleanup --- fastplotlib/graphics/selectors/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 1fb0c453e..113772173 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -2,11 +2,8 @@ from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector -from ._sync import Synchronizer - __all__ = [ "LinearSelector", "LinearRegionSelector", "PolygonSelector", - "Synchronizer", ] From 4d4d636885c22222100721ecee47f8dc37ea4528 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 26 May 2024 00:17:23 -0400 Subject: [PATCH 64/77] update graphic methods mixin --- fastplotlib/layouts/_graphic_methods_mixin.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 9f82cfed5..6765c33be 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -178,7 +178,7 @@ def add_image( def add_line_collection( self, data: List[numpy.ndarray], - z_offset: Union[Iterable[float], float] = None, + z_offset: Union[Iterable[float | int], float, int] = None, thickness: Union[float, Iterable[float]] = 2.0, colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w", alpha: float = 1.0, @@ -200,8 +200,8 @@ def add_line_collection( if elements are 2D, interpreted as [y_vals, n_lines] z_offset: Iterable of float or float, optional - | if ``float``, single offset will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines + | if ``float`` | ``int``, single offset will be used for all lines + | if ``list`` of ``float`` | ``int``, each value will apply to the individual lines thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines @@ -268,11 +268,13 @@ def add_line( data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Iterable] = "w", + uniform_colors: bool = False, alpha: float = 1.0, cmap: str = None, cmap_values: Union[numpy.ndarray, Iterable] = None, z_position: float = None, collection_index: int = None, + isolated_buffer: bool = True, *args, **kwargs ) -> LineGraphic: @@ -314,6 +316,7 @@ def add_line( Features -------- + **data**: :class:`.ImageDataFeature` Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. @@ -336,11 +339,13 @@ def add_line( data, thickness, colors, + uniform_colors, alpha, cmap, cmap_values, z_position, collection_index, + isolated_buffer, *args, **kwargs ) @@ -439,13 +444,15 @@ def add_line_stack( def add_scatter( self, - data: numpy.ndarray, - sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, - colors: Union[str, numpy.ndarray, Iterable[str]] = "w", + data: Any, + colors: str | numpy.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_colors: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: Union[numpy.ndarray, List] = None, - z_position: float = 0.0, + cmap_values: numpy.ndarray = None, + isolated_buffer: bool = True, + sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, + uniform_sizes: bool = False, *args, **kwargs ) -> ScatterGraphic: @@ -504,12 +511,14 @@ def add_scatter( return self._create_graphic( ScatterGraphic, data, - sizes, colors, + uniform_colors, alpha, cmap, cmap_values, - z_position, + isolated_buffer, + sizes, + uniform_sizes, *args, **kwargs ) From 1d6adc6413e861b4a4ddc1f22fbe4c754b584a30 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 26 May 2024 03:35:21 -0400 Subject: [PATCH 65/77] update selector example nbs, still WIP --- .../notebooks/linear_region_selector.ipynb | 57 ++++++++++--------- examples/notebooks/linear_selector.ipynb | 28 +++++---- 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/examples/notebooks/linear_region_selector.ipynb b/examples/notebooks/linear_region_selector.ipynb index 2ba40ed54..cbe845f71 100644 --- a/examples/notebooks/linear_region_selector.ipynb +++ b/examples/notebooks/linear_region_selector.ipynb @@ -17,7 +17,7 @@ "source": [ "import fastplotlib as fpl\n", "import numpy as np\n", - "from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n", + "# from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n", "\n", "fig = fpl.Figure((2, 2))\n", "\n", @@ -25,11 +25,12 @@ "zoomed_prealloc = 1_000\n", "\n", "# data to plot\n", - "xs = np.linspace(0, 100, 1_000)\n", - "sine = np.sin(xs) * 20\n", + "xs = np.linspace(0, 10* np.pi, 1_000)\n", + "sine = np.sin(xs)\n", + "sine += 100\n", "\n", "# make sine along x axis\n", - "sine_graphic_x = fig[0, 0].add_line(sine)\n", + "sine_graphic_x = fig[0, 0].add_line(np.column_stack([xs, sine]), offset=(10, 0, 0))\n", "\n", "# just something that looks different for line along y-axis\n", "sine_y = sine\n", @@ -47,7 +48,7 @@ "ls_y = sine_graphic_y.add_linear_region_selector(axis=\"y\")\n", "\n", "# preallocate array for storing zoomed in data\n", - "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.random.rand(zoomed_prealloc)])\n", + "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.zeros(zoomed_prealloc)])\n", "\n", "# make line graphics for displaying zoomed data\n", "zoomed_x = fig[1, 0].add_line(zoomed_init)\n", @@ -62,54 +63,54 @@ " # interpolate to preallocated size\n", " return np.interp(x, xp, fp=subdata[:, axis]) # use the y-values\n", "\n", - "\n", + "@ls_x.add_event_handler(\"selection\")\n", "def set_zoom_x(ev):\n", " \"\"\"sets zoomed x selector data\"\"\"\n", - " selected_data = ev.pick_info[\"selected_data\"]\n", - " zoomed_x.data = interpolate(selected_data, axis=1) # use the y-values\n", + " # get the selected data\n", + " selected_data = ev.get_selected_data()\n", + " if selected_data.size == 0:\n", + " # no data selected\n", + " zoomed_x.data[:, 1] = 0\n", + "\n", + " # set the y-values\n", + " zoomed_x.data[:, 1] = interpolate(selected_data, axis=1)\n", " fig[1, 0].auto_scale()\n", "\n", "\n", "def set_zoom_y(ev):\n", - " \"\"\"sets zoomed y selector data\"\"\"\n", - " selected_data = ev.pick_info[\"selected_data\"]\n", - " zoomed_y.data = -interpolate(selected_data, axis=0) # use the x-values\n", + " \"\"\"sets zoomed x selector data\"\"\"\n", + " # get the selected data\n", + " selected_data = ev.get_selected_data()\n", + " if selected_data.size == 0:\n", + " # no data selected\n", + " zoomed_y.data[:, 0] = 0\n", + "\n", + " # set the x-values\n", + " zoomed_y.data[:, 0] = -interpolate(selected_data, axis=1)\n", " fig[1, 1].auto_scale()\n", "\n", "\n", - "# update zoomed plots when bounds change\n", - "ls_x.selection.add_event_handler(set_zoom_x)\n", - "ls_y.selection.add_event_handler(set_zoom_y)\n", - "\n", - "fig.show()" - ] - }, - { - "cell_type": "markdown", - "id": "0bad4a35-f860-4f85-9061-920154ab682b", - "metadata": {}, - "source": [ - "### On the x-axis we have a 1-1 mapping from the data that we have passed and the line geometry positions. So the `bounds` min max corresponds directly to the data indices." + "fig.show(maintain_aspect=False)" ] }, { "cell_type": "code", "execution_count": null, - "id": "2c96a3ff-c2e7-4683-8097-8491e97dd6d3", + "id": "2f29e913-c4f8-44a6-8692-eb14436849a5", "metadata": {}, "outputs": [], "source": [ - "ls_x.selection()" + "sine_graphic_x.data[:, 1].ptp()" ] }, { "cell_type": "code", "execution_count": null, - "id": "3ec71e3f-291c-43c6-a954-0a082ba5981c", + "id": "1947a477-5dd2-4df9-aecd-6967c6ab45fe", "metadata": {}, "outputs": [], "source": [ - "ls_x.get_selected_indices()" + "np.clip(-0.1, 0, 10)" ] }, { diff --git a/examples/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb index e9c8e664a..ee590f20b 100644 --- a/examples/notebooks/linear_selector.ipynb +++ b/examples/notebooks/linear_selector.ipynb @@ -5,7 +5,7 @@ "id": "a06e1fd9-47df-42a3-a76c-19e23d7b89fd", "metadata": {}, "source": [ - "## `LinearSelector`, draggable selector that can optionally associated with an ipywidget." + "## `LinearSelector`, draggable selector that can also be linked to an ipywidget slider" ] }, { @@ -16,7 +16,6 @@ "outputs": [], "source": [ "import fastplotlib as fpl\n", - "from fastplotlib.graphics.selectors import Synchronizer\n", "\n", "import numpy as np\n", "from ipywidgets import VBox, IntSlider, FloatSlider\n", @@ -35,16 +34,14 @@ "selector2 = sine_graphic.add_linear_selector(20)\n", "selector3 = sine_graphic.add_linear_selector(40)\n", "\n", - "ss = Synchronizer(selector, selector2, selector3)\n", - "\n", + "# one of the selectors will change the line colors when it moves\n", + "@selector.add_event_handler(\"selection\")\n", "def set_color_at_index(ev):\n", " # changes the color at the index where the slider is\n", - " ix = ev.pick_info[\"selected_index\"]\n", - " g = ev.pick_info[\"graphic\"].parent\n", + " ix = ev.get_selected_index()\n", + " g = ev.graphic.parent\n", " g.colors[ix] = \"green\"\n", "\n", - "selector.selection.add_event_handler(set_color_at_index)\n", - "\n", "# fastplotlib LineSelector can make an ipywidget slider and return it :D \n", "ipywidget_slider = selector.make_ipywidget_slider()\n", "ipywidget_slider.description = \"slider1\"\n", @@ -57,7 +54,15 @@ "selector3.add_ipywidget_handler(ipywidget_slider3, step=0.1)\n", "\n", "fig[0, 0].auto_scale()\n", - "fig.show(add_widgets=[ipywidget_slider])" + "VBox([fig.show(), ipywidget_slider, ipywidget_slider2, ipywidget_slider3])" + ] + }, + { + "cell_type": "markdown", + "id": "d83caca6-e9b6-45df-b93c-0dfe0498d20e", + "metadata": {}, + "source": [ + "Double click the first selctor, and then use `Shift` + Right/Left Arrow Key to move it!" ] }, { @@ -67,13 +72,16 @@ "metadata": {}, "outputs": [], "source": [ + "# this controls the step-size of arrow key movements\n", "selector.step = 0.1" ] }, { "cell_type": "markdown", "id": "3b0f448f-bbe4-4b87-98e3-093f561c216c", - "metadata": {}, + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, "source": [ "### Drag linear selectors with the mouse, hold \"Shift\" to synchronize movement of all the selectors" ] From 284a1dbf2068eda49bf96c37b12340703f33c7a8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 28 May 2024 23:53:02 -0400 Subject: [PATCH 66/77] type annotation in setter --- fastplotlib/graphics/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index bbb2051f5..3ed02c4af 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -384,7 +384,7 @@ def colors(self) -> VertexColors | pygfx.Color: return self._colors.value @colors.setter - def colors(self, value): + def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str]): if isinstance(self._colors, VertexColors): self._colors[:] = value From b4fe9577785568e7637bd974d74d54b11fffb4bc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 31 May 2024 16:00:43 -0400 Subject: [PATCH 67/77] add notes to tests comments --- tests/utils.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index df991095a..8aa474b1f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -14,62 +14,62 @@ def generate_slice_indices(kind: int): indices = [2] case 1: - # everything + # everything, [:] s = slice(None, None, None) indices = list(range(10)) case 2: - # positive continuous range + # positive continuous range, [1:5] s = slice(1, 5, None) indices = [1, 2, 3, 4] case 3: - # positive stepped range + # positive stepped range, [2:8:2] s = slice(2, 8, 2) indices = [2, 4, 6] case 4: - # negative continuous range + # negative continuous range, [-5:] s = slice(-5, None, None) indices = [5, 6, 7, 8, 9] case 5: - # negative backwards + # negative backwards, [-5::-1] s = slice(-5, None, -1) indices = [5, 4, 3, 2, 1, 0] case 5: - # negative backwards stepped + # negative backwards stepped, [-5::-2] s = slice(-5, None, -2) indices = [5, 3, 1] case 6: - # negative stepped forward + # negative stepped forward[-5::2] s = slice(-5, None, 2) indices = [5, 7, 9] case 7: - # both negative + # both negative, [-8:-2] s = slice(-8, -2, None) indices = [2, 3, 4, 5, 6, 7] case 8: - # both negative and stepped + # both negative and stepped, [-8:2:2] s = slice(-8, -2, 2) indices = [2, 4, 6] case 9: - # positive, negative, negative + # positive, negative, negative, [8:-9:-2] s = slice(8, -9, -2) indices = [8, 6, 4, 2] case 10: - # only stepped forward + # only stepped forward, [::2] s = slice(None, None, 2) indices = [0, 2, 4, 6, 8] case 11: - # only stepped backward + # only stepped backward, [::-3] s = slice(None, None, -3) indices = [9, 6, 3, 0] From e6b91336c77bcf76c79c4dee9089d6d39aa91818 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Jun 2024 21:50:56 -0400 Subject: [PATCH 68/77] refactor image stuff --- fastplotlib/graphics/_features/__init__.py | 9 +- fastplotlib/graphics/_features/_base.py | 10 +- fastplotlib/graphics/_features/_data.py | 115 -------- fastplotlib/graphics/_features/_image.py | 151 ++++++++--- fastplotlib/graphics/image.py | 297 +++++++-------------- 5 files changed, 219 insertions(+), 363 deletions(-) delete mode 100644 fastplotlib/graphics/_features/_data.py diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index ace671805..b2b07fa04 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,5 +1,5 @@ from ._positions_graphics import VertexColors, UniformColor, UniformSizes, Thickness, VertexPositions, PointsSizesFeature, VertexCmap -from ._image import ImageData, ImageCmap, ImageVmin, ImageVmax +from ._image import TextureArray, ImageCmap, ImageVmin, ImageVmax, ImageInterpolation, ImageCmapInterpolation, WGPU_MAX_TEXTURE_SIZE from ._base import ( GraphicFeature, BufferManager, @@ -8,10 +8,3 @@ ) from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature from ._common import Name, Offset, Rotation, Visible, Deleted - - -class HeatmapDataFeature: - pass - -class HeatmapCmapFeature: - pass diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 45254ad91..ebf7dbf15 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -9,6 +9,9 @@ import pygfx +WGPU_MAX_TEXTURE_SIZE = 8192 + + supported_dtypes = [ np.uint8, np.uint16, @@ -141,9 +144,6 @@ def _call_event_handlers(self, event_data: FeatureEvent): with log_exception(f"Error during handling {self.__class__.__name__} event"): func(event_data) - def __repr__(self) -> str: - raise NotImplementedError - class BufferManager(GraphicFeature): """Smaller wrapper for pygfx.Buffer""" @@ -151,7 +151,7 @@ class BufferManager(GraphicFeature): def __init__( self, data: NDArray | pygfx.Buffer, - buffer_type: Literal["buffer", "texture"] = "buffer", + buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", isolated_buffer: bool = True, texture_dim: int = 2, **kwargs @@ -300,7 +300,7 @@ def _emit_event(self, type: str, key, value): } event = FeatureEvent(type, info=event_info) - super()._call_event_handlers(event) + self._call_event_handlers(event) def __repr__(self): return f"{self.__class__.__name__} buffer data:\n" \ diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py deleted file mode 100644 index d0f4bd4a4..000000000 --- a/fastplotlib/graphics/_features/_data.py +++ /dev/null @@ -1,115 +0,0 @@ -# -# class ImageDataFeature(GraphicFeatureIndexable): -# """ -# Access to the Texture buffer shown in an ImageGraphic. -# """ -# -# def __init__(self, parent, data: Any): -# if data.ndim not in (2, 3): -# raise ValueError( -# "`data.ndim` must be 2 or 3, ImageGraphic data shape must be " -# "``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]``" -# ) -# -# super().__init__(parent, data) -# -# @property -# def buffer(self) -> pygfx.Texture: -# """Texture buffer for the image data""" -# return self._parent.world_object.geometry.grid -# -# def update_gpu(self): -# """Update the GPU with the buffer""" -# self._update_range(None) -# -# def __call__(self, *args, **kwargs): -# return self.buffer.data -# -# def __getitem__(self, item): -# return self.buffer.data[item] -# -# def __setitem__(self, key, value): -# # make sure float32 -# value = to_gpu_supported_dtype(value) -# -# self.buffer.data[key] = value -# self._update_range(key) -# -# # avoid creating dicts constantly if there are no events to handle -# if len(self._event_handlers) > 0: -# self._feature_changed(key, value) -# -# def _update_range(self, key): -# self.buffer.update_range((0, 0, 0), size=self.buffer.size) -# -# def _feature_changed(self, key, new_data): -# if key is not None: -# key = cleanup_slice(key, self._upper_bound) -# if isinstance(key, int): -# indices = [key] -# elif isinstance(key, slice): -# indices = range(key.start, key.stop, key.step) -# elif key is None: -# indices = None -# -# pick_info = { -# "index": indices, -# "world_object": self._parent.world_object, -# "new_data": new_data, -# } -# -# event_data = FeatureEvent(type="data", pick_info=pick_info) -# -# self._call_event_handlers(event_data) -# -# def __repr__(self) -> str: -# s = f"ImageDataFeature for {self._parent}, call `.data()` to get values" -# return s -# -# -# class HeatmapDataFeature(ImageDataFeature): -# @property -# def buffer(self) -> List[pygfx.Texture]: -# """list of Texture buffer for the image data""" -# return [img.geometry.grid for img in self._parent.world_object.children] -# -# def __getitem__(self, item): -# return self._data[item] -# -# def __call__(self, *args, **kwargs): -# return self._data -# -# def __setitem__(self, key, value): -# # make sure supported type, not float64 etc. -# value = to_gpu_supported_dtype(value) -# -# self._data[key] = value -# self._update_range(key) -# -# # avoid creating dicts constantly if there are no events to handle -# if len(self._event_handlers) > 0: -# self._feature_changed(key, value) -# -# def _update_range(self, key): -# for buffer in self.buffer: -# buffer.update_range((0, 0, 0), size=buffer.size) -# -# def _feature_changed(self, key, new_data): -# if key is not None: -# key = cleanup_slice(key, self._upper_bound) -# if isinstance(key, int): -# indices = [key] -# elif isinstance(key, slice): -# indices = range(key.start, key.stop, key.step) -# elif key is None: -# indices = None -# -# pick_info = { -# "index": indices, -# "world_object": self._parent.world_object, -# "new_data": new_data, -# } -# -# event_data = FeatureEvent(type="data", pick_info=pick_info) -# -# self._call_event_handlers(event_data) diff --git a/fastplotlib/graphics/_features/_image.py b/fastplotlib/graphics/_features/_image.py index c32f14bd1..55bb5a745 100644 --- a/fastplotlib/graphics/_features/_image.py +++ b/fastplotlib/graphics/_features/_image.py @@ -1,23 +1,80 @@ +from math import ceil + import numpy as np +from numpy.typing import NDArray import pygfx -from ._base import GraphicFeature, BufferManager, FeatureEvent +from ._base import GraphicFeature, FeatureEvent, WGPU_MAX_TEXTURE_SIZE from ...utils import ( make_colors, get_cmap_texture, ) +# manages an array of 8192x8192 Textures representing chunks of an image +class TextureArray(GraphicFeature): -class ImageData(BufferManager): def __init__(self, data, isolated_buffer: bool = True): + super().__init__() + data = self._fix_data(data) - super().__init__(data, buffer_type="texture", isolated_buffer=isolated_buffer) + + 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 + + # indices for each Texture + self._row_indices = np.arange(0, ceil(self.value.shape[0] / WGPU_MAX_TEXTURE_SIZE) * WGPU_MAX_TEXTURE_SIZE, WGPU_MAX_TEXTURE_SIZE) + self._col_indices = np.arange(0, ceil(self.value.shape[1] / WGPU_MAX_TEXTURE_SIZE) * WGPU_MAX_TEXTURE_SIZE, WGPU_MAX_TEXTURE_SIZE) + + # buffer will be an array of textures + self._buffer: np.ndarray[pygfx.Texture] = np.empty(shape=(self.row_indices.size, self.col_indices.size), dtype=object) + + # max index + row_max = self.value.shape[0] - 1 + col_max = self.value.shape[1] - 1 + + for (buffer_row, row_ix), (buffer_col, col_ix) in zip(enumerate(self.row_indices), enumerate(self.col_indices)): + # stop index for this chunk + row_stop = min(row_max, row_ix + WGPU_MAX_TEXTURE_SIZE) + col_stop = min(col_max, col_ix + WGPU_MAX_TEXTURE_SIZE) + + # make texture from slice + texture = pygfx.Texture( + self.value[row_ix:row_stop, col_ix:col_stop], dim=2 + ) + + self.buffer[buffer_row, buffer_col] = texture + + self._shared: int = 0 @property - def buffer(self) -> pygfx.Texture: + def value(self) -> NDArray: + return self._data + + def set_value(self, graphic, value): + self[:] = value + + @property + def buffer(self) -> np.ndarray[pygfx.Texture]: return self._buffer + @property + def row_indices(self) -> np.ndarray: + return self._row_indices + + @property + def col_indices(self) -> np.ndarray: + return self._row_indices + + @property + def shared(self) -> int: + return self._shared + def _fix_data(self, data): if data.ndim not in (2, 3): raise ValueError( @@ -28,34 +85,17 @@ def _fix_data(self, data): # let's just cast to float32 always return data.astype(np.float32) - def __setitem__(self, key: int | slice | np.ndarray[int | bool] | tuple[slice | np.ndarray[int | bool]], value): - # offset and size should be (width, height, depth), i.e. (columns, rows, depth) - # offset and size for depth should always be 0, 1 for 2D images - if isinstance(key, tuple): - # multiple dims sliced - if any([k is Ellipsis for k in key]): - # let's worry about ellipsis later - raise TypeError("ellipses not supported for indexing buffers") - if len(key) in (2, 3): - dim_os = list() # hold offset and size for each dim - for dim, k in enumerate(key[:2]): # we only need width and height - dim_os.append(self._parse_offset_size(k, self.value.shape[dim])) - - # offset and size for each dim into individual offset and size tuple - # note that this is flipped since we need (width, height) from (rows, cols) - offset = (*tuple(os[1] for os in dim_os), 0) - size = (*tuple(os[1] for os in dim_os), 0) - else: - raise IndexError + def __getitem__(self, item): + return self.value[item] - else: - # only first dim (rows) indexed - row_offset, row_size = self._parse_offset_size(key, self.value.shape[0]) - offset = (0, row_offset, 0) - size = (self.value.shape[1], row_size, 1) + def __setitem__(self, key, value): + self.value[key] = value + + for texture in self.buffer.ravel(): + texture.update_range((0, 0, 0), texture.size) - self.buffer.update_range(offset, size) - self._emit_event("data", key, value) + event = FeatureEvent("data", info={"key": key, "value": value}) + self._call_event_handlers(event) class ImageVmin(GraphicFeature): @@ -115,3 +155,54 @@ def set_value(self, graphic, value: str): self._value = value event = FeatureEvent(type="cmap", info={"value": value}) self._call_event_handlers(event) + + +class ImageInterpolation(GraphicFeature): + """Image interpolation method""" + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__() + + def _validate(self, value): + if value not in ["nearest", "linear"]: + raise ValueError("`interpolation` must be one of 'nearest' or 'linear'") + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + self._validate(value) + + graphic.world_object.material.interpolation = value + + self._value = value + event = FeatureEvent(type="interpolation", info={"value": value}) + self._call_event_handlers(event) + + +class ImageCmapInterpolation(GraphicFeature): + """Image cmap interpolation method""" + + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__() + + def _validate(self, value): + if value not in ["nearest", "linear"]: + raise ValueError("`cmap_interpolation` must be one of 'nearest' or 'linear'") + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + self._validate(value) + + graphic.world_object.material.map_interpolation = value + + self._value = value + event = FeatureEvent(type="interpolation", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index ef05631fc..19b1677c8 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,9 +1,8 @@ from typing import * -from math import ceil -from itertools import product import weakref import numpy as np +from numpy.typing import NDArray import pygfx @@ -11,13 +10,13 @@ from ._base import Graphic, Interaction from .selectors import LinearSelector, LinearRegionSelector from ._features import ( - ImageData, + TextureArray, ImageCmap, ImageVmin, ImageVmax, - HeatmapDataFeature, - HeatmapCmapFeature, - to_gpu_supported_dtype, + ImageInterpolation, + ImageCmapInterpolation, + WGPU_MAX_TEXTURE_SIZE ) @@ -113,7 +112,7 @@ def add_linear_region_selector( # create selector selector = LinearRegionSelector( - bounds=bounds_init, + selection=bounds_init, limits=limits, size=size, origin=origin, @@ -197,11 +196,55 @@ def _add_plot_area_hook(self, plot_area): self._plot_area = plot_area +class _ImageTile(pygfx.Image): + """ + Similar to pygfx.Image, only difference is that it contains a few properties to keep track of + row chunk index, column chunk index + """ + def __init__(self, geometry, material, row_chunk_ix: int, col_chunk_ix: int, **kwargs): + super().__init__(geometry, material, **kwargs) + + self._row_chunk_index = row_chunk_ix + self._col_chunk_index = col_chunk_ix + + def _wgpu_get_pick_info(self, pick_value): + pick_info = super()._wgpu_get_pick_info(pick_value) + + row_start_ix = WGPU_MAX_TEXTURE_SIZE * self.row_chunk_index + col_start_ix = WGPU_MAX_TEXTURE_SIZE * self.col_chunk_index + + # adjust w.r.t. chunk + x, y = pick_info["index"] + x += col_start_ix + y += row_start_ix + pick_info["index"] = (x, y) + + xp, yp = pick_info["pixel_coord"] + xp += col_start_ix + yp += row_start_ix + pick_info["pixel_coord"] = (xp, yp) + + # add row chunk and col chunk index to pick_info dict + return { + **pick_info, + "row_chunk_index": self.row_chunk_index, + "col_chunk_index": self.col_chunk_index, + } + + @property + def row_chunk_index(self) -> int: + return self._row_chunk_index + + @property + def col_chunk_index(self) -> int: + return self._col_chunk_index + + class ImageGraphic(Graphic, Interaction, _AddSelectorsMixin): features = {"data", "cmap", "vmin", "vmax"} @property - def data(self) -> ImageData: + def data(self) -> NDArray: """Get or set the image data""" return self._data @@ -236,142 +279,23 @@ def vmax(self) -> float: def vmax(self, value: float): self._vmax.set_value(self, value) - def __init__( - self, - data: Any, - vmin: int = None, - vmax: int = None, - cmap: str = "plasma", - filter: str = "nearest", - isolated_buffer: bool = True, - *args, - **kwargs, - ): - """ - Create an Image Graphic - - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested - | shape must be ``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]`` - - vmin: int, optional - minimum value for color scaling, calculated from data if not provided - - vmax: int, optional - maximum value for color scaling, calculated from data if not provided - - cmap: str, optional, default "plasma" - colormap to use to display the image data, ignored if data is RGB - - filter: str, optional, default "nearest" - interpolation filter, 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. - - args: - additional arguments passed to Graphic - - kwargs: - additional keyword arguments passed to Graphic - - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the data buffer displayed in the ImageGraphic - - **cmap**: :class:`.ImageCmapFeature` - Manages the colormap - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene - - """ - - super().__init__(*args, **kwargs) - self._data = ImageData(data, isolated_buffer=isolated_buffer) - self._cmap = ImageCmap(cmap) - - if (vmin is None) or (vmax is None): - vmin, vmax = quick_min_max(data) - - self._vmin = ImageVmin(vmin) - self._vmax = ImageVmax(vmax) - - clim = (self.vmin, self.vmax) - - # make grid geometry from image data Texture - geometry = pygfx.Geometry(grid=self._data.buffer) - - if self._data.value.ndim > 2: - # if data is RGB or RGBA - material = pygfx.ImageBasicMaterial( - clim=clim, map_interpolation=filter, pick_write=True - ) - else: - # if data is just 2D without color information, use colormap LUT - material = pygfx.ImageBasicMaterial( - clim=clim, - map=self._cmap.texture, - map_interpolation=filter, - pick_write=True, - ) - - world_object = pygfx.Image(geometry, material) - - self._set_world_object(world_object) - - def reset_vmin_vmax(self): - self.vmin, self.vmax = quick_min_max(self._data.value) - - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass - - def reset_feature(self, feature: str): - pass - - -class _ImageTile(pygfx.Image): - """ - Similar to pygfx.Image, only difference is that it contains a few properties to keep track of - row chunk index, column chunk index - """ - - def _wgpu_get_pick_info(self, pick_value): - pick_info = super()._wgpu_get_pick_info(pick_value) - - # add row chunk and col chunk index to pick_info dict - return { - **pick_info, - "row_chunk_index": self.row_chunk_index, - "col_chunk_index": self.col_chunk_index, - } - @property - def row_chunk_index(self) -> int: - return self._row_chunk_index + def interpolation(self) -> str: + """image data interpolation method""" + return self._interpolation.value - @row_chunk_index.setter - def row_chunk_index(self, index: int): - self._row_chunk_index = index + @interpolation.setter + def interpolation(self, value: str): + self._interpolation.set_value(self, value) @property - def col_chunk_index(self) -> int: - return self._col_chunk_index + def cmap_interpolation(self) -> str: + """cmap interpolation method""" + return self._cmap_interpolation.value - @col_chunk_index.setter - def col_chunk_index(self, index: int): - self._col_chunk_index = index - - -class HeatmapGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = {"data", "cmap", "present"} + @cmap_interpolation.setter + def cmap_interpolation(self, value: str): + self._cmap_interpolation.set_value(self, value) def __init__( self, @@ -379,8 +303,8 @@ def __init__( vmin: int = None, vmax: int = None, cmap: str = "plasma", - filter: str = "nearest", - chunk_size: int = 8192, + interpolation: str = "nearest", + cmap_interpolation: str = "linear", isolated_buffer: bool = True, *args, **kwargs, @@ -392,7 +316,6 @@ def __init__( ---------- data: array-like array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested | shape must be ``[x_dim, y_dim]`` vmin: int, optional @@ -404,11 +327,11 @@ def __init__( cmap: str, optional, default "plasma" colormap to use to display the data - filter: str, optional, default "nearest" + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" - chunk_size: int, default 8192, max 8192 - chunk size for each tile used to make up the heatmap texture + 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 @@ -437,77 +360,41 @@ def __init__( super().__init__(*args, **kwargs) - if chunk_size > 8192: - raise ValueError("Maximum chunk size is 8192") - - data = to_gpu_supported_dtype(data) - - # TODO: we need to organize and do this better - if isolated_buffer: - # initialize a buffer with the same shape as the input data - # we do not directly use the input data array as the buffer - # because if the input array is a read-only type, such as - # numpy memmaps, we would not be able to change the image data - buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) - else: - buffer_init = data + world_object = pygfx.Group() - row_chunks = range(ceil(data.shape[0] / chunk_size)) - col_chunks = range(ceil(data.shape[1] / chunk_size)) + self._data = TextureArray(data, isolated_buffer=isolated_buffer) - chunks = list(product(row_chunks, col_chunks)) - # chunks is the index position of each chunk + if (vmin is None) or (vmax is None): + vmin, vmax = quick_min_max(data) - start_ixs = [list(map(lambda c: c * chunk_size, chunk)) for chunk in chunks] - stop_ixs = [list(map(lambda c: c + chunk_size, chunk)) for chunk in start_ixs] + self._vmin = ImageVmin(vmin) + self._vmax = ImageVmax(vmax) - world_object = pygfx.Group() - self._set_world_object(world_object) + self._cmap = ImageCmap(cmap) - if (vmin is None) or (vmax is None): - vmin, vmax = quick_min_max(data) + self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) - self.cmap = HeatmapCmapFeature(self, cmap) self._material = pygfx.ImageBasicMaterial( clim=(vmin, vmax), - map=self.cmap(), - map_interpolation=filter, + map=self._cmap.texture, + interpolatio=self._interpolation.value, + map_interpolation=self._cmap_interpolation.value, pick_write=True, ) - for start, stop, chunk in zip(start_ixs, stop_ixs, chunks): - row_start, col_start = start - row_stop, col_stop = stop - - # x and y positions of the Tile in world space coordinates - y_pos, x_pos = row_start, col_start - - texture = pygfx.Texture( - buffer_init[row_start:row_stop, col_start:col_stop], dim=2 - ) - geometry = pygfx.Geometry(grid=texture) - # material = pygfx.ImageBasicMaterial(clim=(0, 1), map=self.cmap()) - - img = _ImageTile(geometry, self._material) - - # row and column chunk index for this Tile - img.row_chunk_index = chunk[0] - img.col_chunk_index = chunk[1] - - img.world.x = x_pos - img.world.y = y_pos - - self.world_object.add(img) + for row_ix in range(self._data.row_indices.size): + for col_ix in range(self._data.col_indices.size): + img = _ImageTile( + geometry=pygfx.Geometry(grid=self._data.buffer[row_ix, col_ix]), + material=self._material, + row_chunk_ix=row_ix, + col_chunk_ix=col_ix + ) - self.data = HeatmapDataFeature(self, buffer_init) - # TODO: we need to organize and do this better - if isolated_buffer: - # if the buffer was initialized with zeros - # set it with the actual data - self.data = data + img.world.x = self._data.row_indices[row_ix] + img.world.y = self._data.row_indices[col_ix] - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass + world_object.add(img) - def reset_feature(self, feature: str): - pass + self._set_world_object(world_object) \ No newline at end of file From 820772d224f4129e3feb4c6077d5431c1092424c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Jun 2024 22:19:23 -0400 Subject: [PATCH 69/77] image selector tool --- fastplotlib/graphics/image.py | 323 +++++++++++++++------------------- 1 file changed, 143 insertions(+), 180 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 19b1677c8..0831123ed 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,13 +1,12 @@ from typing import * import weakref -import numpy as np from numpy.typing import NDArray import pygfx from ..utils import quick_min_max -from ._base import Graphic, Interaction +from ._base import Graphic from .selectors import LinearSelector, LinearRegionSelector from ._features import ( TextureArray, @@ -20,182 +19,6 @@ ) -class _AddSelectorsMixin: - def add_linear_selector( - self, selection: int = None, padding: float = None, **kwargs - ) -> LinearSelector: - """ - Adds a :class:`.LinearSelector`. - - Parameters - ---------- - selection: int, optional - initial position of the selector - - padding: float, optional - pad the length of the selector - - kwargs: - passed to :class:`.LinearSelector` - - Returns - ------- - LinearSelector - - """ - - # default padding is 15% the height or width of the image - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" - - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) - - if selection is None: - selection = limits[0] - - if selection < limits[0] or selection > limits[1]: - raise ValueError( - f"the passed selection: {selection} is beyond the limits: {limits}" - ) - - selector = LinearSelector( - selection=selection, - limits=limits, - end_points=end_points, - parent=weakref.proxy(self), - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 - - return weakref.proxy(selector) - - def add_linear_region_selector( - self, padding: float = None, **kwargs - ) -> LinearRegionSelector: - """ - Add a :class:`.LinearRegionSelector`. - - Parameters - ---------- - padding: float, optional - Extends the linear selector along the y-axis to make it easier to interact with. - - kwargs: optional - passed to ``LinearRegionSelector`` - - Returns - ------- - LinearRegionSelector - linear selection graphic - - """ - - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) - - # create selector - selector = LinearRegionSelector( - selection=bounds_init, - limits=limits, - size=size, - origin=origin, - parent=weakref.proxy(self), - fill_color=(0, 0, 0.35, 0.2), - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - # so that it is above this graphic - selector.position_z = self.position_z + 3 - - # 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 weakref.proxy(selector) - - # TODO: this method is a bit of a mess, can refactor later - def _get_linear_selector_init_args(self, padding: float, **kwargs): - # computes initial bounds, limits, size and origin of linear selectors - data = self.data() - - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" - - if padding is None: - if axis == "x": - # based on number of rows - padding = int(data.shape[0] * 0.15) - elif axis == "y": - # based on number of columns - padding = int(data.shape[1] * 0.15) - - if axis == "x": - offset = self.position_x - # x limits, number of columns - limits = (offset, data.shape[1] - 1) - - # size is number of rows + padding - # used by LinearRegionSelector but not LinearSelector - size = data.shape[0] + padding - - # initial position of the selector - # center row - position_y = data.shape[0] / 2 - - # need y offset too for this - origin = (limits[0] - offset, position_y + self.position_y) - - # endpoints of the data range - # used by linear selector but not linear region - # padding, n_rows + padding - end_points = (0 - padding, data.shape[0] + padding) - else: - offset = self.position_y - # y limits - limits = (offset, data.shape[0] - 1) - - # width + padding - # used by LinearRegionSelector but not LinearSelector - size = data.shape[1] + padding - - # initial position of the selector - position_x = data.shape[1] / 2 - - # need x offset too for this - origin = (position_x + self.position_x, limits[0] - offset) - - # endpoints of the data range - # used by linear selector but not linear region - end_points = (0 - padding, data.shape[1] + padding) - - # initial bounds are 20% of the limits range - # used by LinearRegionSelector but not LinearSelector - bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) - - return bounds_init, limits, size, origin, axis, end_points - - def _add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - - class _ImageTile(pygfx.Image): """ Similar to pygfx.Image, only difference is that it contains a few properties to keep track of @@ -240,7 +63,7 @@ def col_chunk_index(self) -> int: return self._col_chunk_index -class ImageGraphic(Graphic, Interaction, _AddSelectorsMixin): +class ImageGraphic(Graphic): features = {"data", "cmap", "vmin", "vmax"} @property @@ -397,4 +220,144 @@ def __init__( world_object.add(img) - self._set_world_object(world_object) \ No newline at end of file + self._set_world_object(world_object) + + def add_linear_selector( + self, selection: int = None, axis: str = "x", padding: float = None, **kwargs + ) -> LinearSelector: + """ + Adds a :class:`.LinearSelector`. + + Parameters + ---------- + selection: int, optional + initial position of the selector + + padding: float, optional + pad the length of the selector + + kwargs: + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + + if axis == "x": + size = self._data.value.shape[0] + center = size / 2 + limits = (0, self._data.value.shape[1]) + elif axis == "y": + size = self._data.value.shape[1] + center = size / 2 + limits = (0, self._data.value.shape[0]) + else: + raise ValueError( + "`axis` must be one of 'x' | 'y'" + ) + + # default padding is 25% the height or width of the image + if padding is None: + size *= 1.25 + else: + size += padding + + if selection is None: + selection = limits[0] + + if selection < limits[0] or selection > limits[1]: + raise ValueError( + f"the passed selection: {selection} is beyond the limits: {limits}" + ) + + selector = LinearSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + # place selector above this graphic + selector.offset = selector.offset + (0., 0., self.offset[-1] + 1) + + return weakref.proxy(selector) + + def add_linear_region_selector( + self, selection: tuple[float, float] = None, axis: str = "x", padding: float = 0., fill_color=(0, 0, 0.35, 0.2), **kwargs, + ) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float) + initial (min, max) of the selection + + axis: "x" | "y" + axis the selector can move along + + padding: float, default 100.0 + Extends the linear selector along the perpendicular axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + if axis == "x": + size = self._data.value.shape[0] + center = size / 2 + limits = (0, self._data.value.shape[1]) + elif axis == "y": + size = self._data.value.shape[1] + center = size / 2 + limits = (0, self._data.value.shape[0]) + else: + raise ValueError( + "`axis` must be one of 'x' | 'y'" + ) + + # default padding is 25% the height or width of the image + if padding is None: + size *= 1.25 + else: + size += padding + + if selection is None: + selection = limits[0], int(limits[1] * 0.25) + + if padding is None: + size *= 1.25 + + else: + size += padding + + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + # place above this graphic + selector.offset = selector.offset + (0., 0., self.offset[-1] + 1) + + return weakref.proxy(selector) From 254f0ba05096fe684030173ffdc07a88ef46bd66 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Jun 2024 22:21:31 -0400 Subject: [PATCH 70/77] return selectors as proxies --- fastplotlib/graphics/line.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 629b2cad6..f97f95b1f 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -184,7 +184,7 @@ def add_linear_selector( size=size, center=center, axis=axis, - parent=self, + parent=weakref.proxy(self), **kwargs, ) @@ -249,7 +249,7 @@ def add_linear_region_selector( size=size, center=center, axis=axis, - parent=self, + parent=weakref.proxy(self), **kwargs, ) @@ -316,9 +316,6 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): return bounds_init, limits, size, origin, axis, end_points - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - def set_feature(self, feature: str, new_data: Any, indices: Any = None): if not hasattr(self, "_previous_data"): self._previous_data = dict() From 2bf33fade95a5af84a1f7628d22bf7bc17bfbb6e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Jun 2024 22:41:07 -0400 Subject: [PATCH 71/77] image stuff works --- fastplotlib/graphics/__init__.py | 12 +-- fastplotlib/graphics/_features/_image.py | 21 ++--- fastplotlib/graphics/image.py | 11 ++- fastplotlib/graphics/selectors/__init__.py | 6 -- fastplotlib/layouts/_graphic_methods_mixin.py | 90 ++----------------- 5 files changed, 29 insertions(+), 111 deletions(-) diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 2a008015e..bb3cd8854 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,15 +1,5 @@ from .line import LineGraphic from .scatter import ScatterGraphic -from .image import ImageGraphic, HeatmapGraphic +from .image import ImageGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack - -__all__ = [ - "ImageGraphic", - "ScatterGraphic", - "LineGraphic", - "HeatmapGraphic", - "LineCollection", - "LineStack", - "TextGraphic", -] diff --git a/fastplotlib/graphics/_features/_image.py b/fastplotlib/graphics/_features/_image.py index 55bb5a745..1c71b8d4a 100644 --- a/fastplotlib/graphics/_features/_image.py +++ b/fastplotlib/graphics/_features/_image.py @@ -54,7 +54,7 @@ def __init__(self, data, isolated_buffer: bool = True): @property def value(self) -> NDArray: - return self._data + return self._value def set_value(self, graphic, value): self[:] = value @@ -109,8 +109,8 @@ def value(self) -> float: return self._value def set_value(self, graphic, value: float): - vmax = graphic.world_object.material.clim[1] - graphic.world_object.material.clim = (value, vmax) + vmax = graphic._material.clim[1] + graphic._material.clim = (value, vmax) self._value = value event = FeatureEvent(type="vmin", info={"value": value}) @@ -128,8 +128,8 @@ def value(self) -> float: return self._value def set_value(self, graphic, value: float): - vmin = graphic.world_object.material.clim[0] - graphic.world_object.material.clim = (vmin, value) + vmin = graphic._material.clim[0] + graphic._material.clim = (vmin, value) self._value = value event = FeatureEvent(type="vmax", info={"value": value}) @@ -149,8 +149,8 @@ def value(self) -> str: def set_value(self, graphic, value: str): new_colors = make_colors(256, value) - graphic.world_object.material.map.data[:] = new_colors - graphic.world_object.material.map.data.update_range((0, 0, 0), size=(256, 1, 1)) + graphic._material.map.data[:] = new_colors + graphic._material.map.update_range((0, 0, 0), size=(256, 1, 1)) self._value = value event = FeatureEvent(type="cmap", info={"value": value}) @@ -175,7 +175,7 @@ def value(self) -> str: def set_value(self, graphic, value: str): self._validate(value) - graphic.world_object.material.interpolation = value + graphic._material.interpolation = value self._value = value event = FeatureEvent(type="interpolation", info={"value": value}) @@ -201,8 +201,9 @@ def value(self) -> str: def set_value(self, graphic, value: str): self._validate(value) - graphic.world_object.material.map_interpolation = value + # common material for all image tiles + graphic._material.map_interpolation = value self._value = value - event = FeatureEvent(type="interpolation", info={"value": value}) + event = FeatureEvent(type="cmap_interpolation", info={"value": value}) self._call_event_handlers(event) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 0831123ed..f3603dee1 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -200,8 +200,8 @@ def __init__( self._material = pygfx.ImageBasicMaterial( clim=(vmin, vmax), - map=self._cmap.texture, - interpolatio=self._interpolation.value, + map=self._cmap.texture if self._data.value.ndim == 2 else None, # RGB vs. grayscale + interpolation=self._interpolation.value, map_interpolation=self._cmap_interpolation.value, pick_write=True, ) @@ -222,6 +222,11 @@ def __init__( self._set_world_object(world_object) + def reset_vmin_vmax(self): + vmin, vmax = quick_min_max(self._data.value) + self.vmin = vmin + self.vmax = vmax + def add_linear_selector( self, selection: int = None, axis: str = "x", padding: float = None, **kwargs ) -> LinearSelector: @@ -290,7 +295,7 @@ def add_linear_selector( return weakref.proxy(selector) def add_linear_region_selector( - self, selection: tuple[float, float] = None, axis: str = "x", padding: float = 0., fill_color=(0, 0, 0.35, 0.2), **kwargs, + self, selection: tuple[float, float] = None, axis: str = "x", padding: float = 0., fill_color = (0, 0, 0.35, 0.2), **kwargs, ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 113772173..6f081448e 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,9 +1,3 @@ from ._linear import LinearSelector from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector - -__all__ = [ - "LinearSelector", - "LinearRegionSelector", - "PolygonSelector", -] diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 6765c33be..00bdd5e85 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -28,18 +28,18 @@ def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: # only return a proxy to the real graphic return weakref.proxy(graphic) - def add_heatmap( + def add_image( self, data: Any, vmin: int = None, vmax: int = None, cmap: str = "plasma", - filter: str = "nearest", - chunk_size: int = 8192, + interpolation: str = "nearest", + cmap_interpolation: str = "linear", isolated_buffer: bool = True, *args, **kwargs - ) -> HeatmapGraphic: + ) -> ImageGraphic: """ Create an Image Graphic @@ -48,7 +48,6 @@ def add_heatmap( ---------- data: array-like array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested | shape must be ``[x_dim, y_dim]`` vmin: int, optional @@ -60,11 +59,11 @@ def add_heatmap( cmap: str, optional, default "plasma" colormap to use to display the data - filter: str, optional, default "nearest" + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" - chunk_size: int, default 8192, max 8192 - chunk size for each tile used to make up the heatmap texture + 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 @@ -90,78 +89,6 @@ def add_heatmap( Control the presence of the Graphic in the scene - """ - return self._create_graphic( - HeatmapGraphic, - data, - vmin, - vmax, - cmap, - filter, - chunk_size, - isolated_buffer, - *args, - **kwargs - ) - - def add_image( - self, - data: Any, - vmin: int = None, - vmax: int = None, - cmap: str = "plasma", - filter: str = "nearest", - isolated_buffer: bool = True, - *args, - **kwargs - ) -> ImageGraphic: - """ - - Create an Image Graphic - - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested - | shape must be ``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]`` - - vmin: int, optional - minimum value for color scaling, calculated from data if not provided - - vmax: int, optional - maximum value for color scaling, calculated from data if not provided - - cmap: str, optional, default "plasma" - colormap to use to display the image data, ignored if data is RGB - - filter: str, optional, default "nearest" - interpolation filter, 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. - - args: - additional arguments passed to Graphic - - kwargs: - additional keyword arguments passed to Graphic - - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the data buffer displayed in the ImageGraphic - - **cmap**: :class:`.ImageCmapFeature` - Manages the colormap - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene - - """ return self._create_graphic( ImageGraphic, @@ -169,7 +96,8 @@ def add_image( vmin, vmax, cmap, - filter, + interpolation, + cmap_interpolation, isolated_buffer, *args, **kwargs From 67404caef6b961f838e2b378cf065b55ce14440f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Jun 2024 23:00:15 -0400 Subject: [PATCH 72/77] fix offsets adding graphics, fix positions_graphic cmap bug, quickstart runs :D --- .../graphics/_features/_positions_graphics.py | 5 +++-- fastplotlib/layouts/_plot_area.py | 14 ++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index c83fdd7e8..4ccf639a6 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -305,8 +305,9 @@ def __init__(self, vertex_colors: VertexColors, cmap_name: str | None, cmap_valu if self._cmap_name is not None: if not isinstance(self._cmap_name, str): raise TypeError - if not isinstance(self._cmap_values, np.ndarray): - raise TypeError + if self._cmap_values is not None: + if not isinstance(self._cmap_values, np.ndarray): + raise TypeError n_datapoints = vertex_colors.value.shape[0] diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 6ff07a748..4d8900971 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -469,14 +469,14 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if self.camera.fov == 0: # for orthographic positions stack objects along the z-axis # for perspective projections we assume the user wants full 3D control - graphic.position_z = len(self) + graphic.offset = (*graphic.offset[:-1], len(self)) def insert_graphic( self, graphic: Graphic, center: bool = True, index: int = 0, - z_position: int = None, + auto_offset: int = None, ): """ Insert graphic into scene at given position ``index`` in stored graphics. @@ -493,8 +493,8 @@ def insert_graphic( index: int, default 0 Index to insert graphic. - z_position: int, default None - z axis position to place Graphic. If ``None``, uses value of `index` argument + auto_offset: bool, default True + If True and using an orthographic projection, sets z-axis offset of graphic to `index` """ if index > len(self._graphics): @@ -511,10 +511,8 @@ def insert_graphic( if self.camera.fov == 0: # for orthographic positions stack objects along the z-axis # for perspective projections we assume the user wants full 3D control - if z_position is None: - graphic.position_z = index - else: - graphic.position_z = z_position + if auto_offset: + graphic.offset = (*graphic.offset[:-1], index) def _add_or_insert_graphic( self, From b1b297d8c0ec523a7ba2718b73a2d37cabfd4859 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 4 Jun 2024 01:41:33 -0400 Subject: [PATCH 73/77] fix add_graphic args and mixin --- .../graphics/_features/_positions_graphics.py | 38 +------------ fastplotlib/graphics/image.py | 6 +-- fastplotlib/graphics/line.py | 10 ---- fastplotlib/graphics/scatter.py | 5 -- fastplotlib/graphics/text.py | 3 +- fastplotlib/layouts/_graphic_methods_mixin.py | 54 ++++--------------- scripts/generate_add_graphic_methods.py | 2 +- 7 files changed, 13 insertions(+), 105 deletions(-) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py index 4ccf639a6..c6a96b709 100644 --- a/fastplotlib/graphics/_features/_positions_graphics.py +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -373,40 +373,4 @@ def values(self, values: np.ndarray | list[float | int], indices: slice | list | self._vertex_colors[indices] = colors - self._emit_event("cmap.name", indices, values) - -# -# class HeatmapCmapFeature(ImageCmapFeature): -# """ -# Colormap for :class:`HeatmapGraphic` -# -# Same event pick info as :class:`ImageCmapFeature` -# """ -# -# def _set(self, cmap_name: str): -# # in heatmap we use one material for all ImageTiles -# self._parent._material.map.data[:] = make_colors(256, cmap_name) -# self._parent._material.map.update_range((0, 0, 0), size=(256, 1, 1)) -# self._name = cmap_name -# -# self._feature_changed(key=None, new_data=self.name) -# -# @property -# def vmin(self) -> float: -# """Minimum contrast limit.""" -# return self._parent._material.clim[0] -# -# @vmin.setter -# def vmin(self, value: float): -# """Minimum contrast limit.""" -# self._parent._material.clim = (value, self._parent._material.clim[1]) -# -# @property -# def vmax(self) -> float: -# """Maximum contrast limit.""" -# return self._parent._material.clim[1] -# -# @vmax.setter -# def vmax(self, value: float): -# """Maximum contrast limit.""" -# self._parent._material.clim = (self._parent._material.clim[0], value) + self._emit_event("cmap.values", indices, values) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index f3603dee1..ea43ee42f 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -129,7 +129,6 @@ def __init__( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - *args, **kwargs, ): """ @@ -161,9 +160,6 @@ def __init__( 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. - args: - additional arguments passed to Graphic - kwargs: additional keyword arguments passed to Graphic @@ -181,7 +177,7 @@ def __init__( """ - super().__init__(*args, **kwargs) + super().__init__(**kwargs) world_object = pygfx.Group() diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index f97f95b1f..b0df8f7ce 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -31,10 +31,7 @@ def __init__( alpha: float = 1.0, cmap: str = None, cmap_values: np.ndarray | Iterable = None, - z_position: float = None, - collection_index: int = None, isolated_buffer: bool = True, - *args, **kwargs, ): """ @@ -65,9 +62,6 @@ def __init__( z_position: float, optional z-axis position for placing the graphic - args - passed to Graphic - kwargs passed to Graphic @@ -100,7 +94,6 @@ def __init__( cmap=cmap, cmap_values=cmap_values, isolated_buffer=isolated_buffer, - *args, **kwargs ) @@ -125,9 +118,6 @@ def __init__( self._set_world_object(world_object) - if z_position is not None: - self.position_z = z_position - def add_linear_selector( self, selection: float = None, padding: float = 0., axis: str = "x",**kwargs ) -> LinearSelector: diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index f3afcd31a..a935b8092 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -39,7 +39,6 @@ def __init__( isolated_buffer: bool = True, sizes: float | np.ndarray | Iterable[float] = 1, uniform_sizes: bool = False, - *args, **kwargs, ): """ @@ -70,9 +69,6 @@ def __init__( z_position: float, optional z-axis position for placing the graphic - args - passed to Graphic - kwargs passed to Graphic @@ -101,7 +97,6 @@ def __init__( cmap=cmap, cmap_values=cmap_values, isolated_buffer=isolated_buffer, - *args, **kwargs ) diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index 49b4ac4be..27d49eece 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -16,7 +16,6 @@ def __init__( outline_thickness=0, screen_space: bool = True, anchor: str = "middle-center", - *args, **kwargs, ): """ @@ -55,7 +54,7 @@ def __init__( * Vertical values: "top", "middle", "baseline", "bottom" * Horizontal values: "left", "center", "right" """ - super().__init__(*args, **kwargs) + super().__init__(**kwargs) self._text = text diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 00bdd5e85..d523bc668 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -37,7 +37,6 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - *args, **kwargs ) -> ImageGraphic: """ @@ -70,9 +69,6 @@ def add_image( 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. - args: - additional arguments passed to Graphic - kwargs: additional keyword arguments passed to Graphic @@ -99,22 +95,21 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - *args, **kwargs ) def add_line_collection( self, data: List[numpy.ndarray], - z_offset: Union[Iterable[float | int], float, int] = None, - thickness: Union[float, Iterable[float]] = 2.0, - colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w", + thickness: Union[float, Sequence[float]] = 2.0, + colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", + uniform_colors: bool = False, alpha: float = 1.0, - cmap: Union[Iterable[str], str] = None, + cmap: Union[Sequence[str], str] = None, cmap_values: Union[numpy.ndarray, List] = None, name: str = None, - metadata: Union[Iterable[Any], numpy.ndarray] = None, - *args, + metadata: Union[Sequence[Any], numpy.ndarray] = None, + isolated_buffer: bool = True, **kwargs ) -> LineCollection: """ @@ -127,10 +122,6 @@ def add_line_collection( List of line data to plot, each element must be a 1D, 2D, or 3D numpy array if elements are 2D, interpreted as [y_vals, n_lines] - z_offset: Iterable of float or float, optional - | if ``float`` | ``int``, single offset will be used for all lines - | if ``list`` of ``float`` | ``int``, each value will apply to the individual lines - thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines | if ``list`` of ``float``, each value will apply to the individual lines @@ -161,11 +152,8 @@ def add_line_collection( metadata associated with this collection, this is for the user to manage. ``len(metadata)`` must be same as ``len(data)`` - args - passed to GraphicCollection - kwargs - passed to GraphicCollection + passed to Graphic Features -------- @@ -179,15 +167,15 @@ def add_line_collection( return self._create_graphic( LineCollection, data, - z_offset, thickness, colors, + uniform_colors, alpha, cmap, cmap_values, name, metadata, - *args, + isolated_buffer, **kwargs ) @@ -200,10 +188,7 @@ def add_line( alpha: float = 1.0, cmap: str = None, cmap_values: Union[numpy.ndarray, Iterable] = None, - z_position: float = None, - collection_index: int = None, isolated_buffer: bool = True, - *args, **kwargs ) -> LineGraphic: """ @@ -235,9 +220,6 @@ def add_line( z_position: float, optional z-axis position for placing the graphic - args - passed to Graphic - kwargs passed to Graphic @@ -271,17 +253,13 @@ def add_line( alpha, cmap, cmap_values, - z_position, - collection_index, isolated_buffer, - *args, **kwargs ) def add_line_stack( self, data: List[numpy.ndarray], - z_offset: Union[Iterable[float], float] = None, thickness: Union[float, Iterable[float]] = 2.0, colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w", alpha: float = 1.0, @@ -291,7 +269,6 @@ def add_line_stack( metadata: Union[Iterable[Any], numpy.ndarray] = None, separation: float = 10.0, separation_axis: str = "y", - *args, **kwargs ) -> LineStack: """ @@ -304,10 +281,6 @@ def add_line_stack( List of line data to plot, each element must be a 1D, 2D, or 3D numpy array if elements are 2D, interpreted as [y_vals, n_lines] - z_offset: Iterable of float or float, optional - | if ``float``, single offset will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines | if ``list`` of ``float``, each value will apply to the individual lines @@ -356,7 +329,6 @@ def add_line_stack( return self._create_graphic( LineStack, data, - z_offset, thickness, colors, alpha, @@ -366,7 +338,6 @@ def add_line_stack( metadata, separation, separation_axis, - *args, **kwargs ) @@ -381,7 +352,6 @@ def add_scatter( isolated_buffer: bool = True, sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, uniform_sizes: bool = False, - *args, **kwargs ) -> ScatterGraphic: """ @@ -413,9 +383,6 @@ def add_scatter( z_position: float, optional z-axis position for placing the graphic - args - passed to Graphic - kwargs passed to Graphic @@ -447,7 +414,6 @@ def add_scatter( isolated_buffer, sizes, uniform_sizes, - *args, **kwargs ) @@ -461,7 +427,6 @@ def add_text( outline_thickness=0, screen_space: bool = True, anchor: str = "middle-center", - *args, **kwargs ) -> TextGraphic: """ @@ -512,6 +477,5 @@ def add_text( outline_thickness, screen_space, anchor, - *args, **kwargs ) diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 2a480d884..3f45d9007 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -69,7 +69,7 @@ def generate_add_graphics_methods(): f.write(f" {class_name.__init__.__doc__}\n") f.write(' """\n') f.write( - f" return self._create_graphic({class_name.__name__}, {s}*args, **kwargs)\n\n" + f" return self._create_graphic({class_name.__name__}, {s} **kwargs)\n\n" ) f.close() From 74f842893f8b5c63fc905fa0456c3fc011bb0115 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 4 Jun 2024 01:43:14 -0400 Subject: [PATCH 74/77] simpler graphic collection stuff --- fastplotlib/graphics/_base.py | 194 ++++++++---------------- fastplotlib/graphics/line_collection.py | 121 +++++++++------ 2 files changed, 138 insertions(+), 177 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 3ed02c4af..c06f5681f 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -104,7 +104,6 @@ def __init__( offset: np.ndarray | list | tuple = (0., 0., 0.), rotation: np.ndarray | list | tuple = (0., 0., 0., 1.), metadata: Any = None, - collection_index: int = None, ): """ @@ -113,6 +112,12 @@ def __init__( name: str, optional name this graphic to use it as a key to access from the plot + offset: (float, float, float), default (0., 0., 0.) + (x, y, z) vector to offset this graphic from the origin + + rotation: (float, float, float, float), default (0, 0, 0, 1) + rotation quaternion + metadata: Any, optional metadata attached to this Graphic, this is for the user to manage @@ -121,7 +126,6 @@ def __init__( raise TypeError("Graphic `name` must be of type ") self.metadata = metadata - self.collection_index = collection_index self.registered_callbacks = dict() # store hex id str of Graphic instance mem location @@ -138,7 +142,7 @@ def __init__( # all the common features self._name = Name(name) self._deleted = Deleted(False) - self._rotation = Rotation(rotation) # set later when world object is set + self._rotation = Rotation(rotation) self._offset = Offset(offset) self._visible = Visible(True) @@ -165,11 +169,6 @@ def detach_feature(self, feature: str): def attach_feature(self, feature: BufferManager): raise NotImplementedError - @property - def children(self) -> list[pygfx.WorldObject]: - """Return the children of the WorldObject.""" - return self.world_object.children - @property def event_handlers(self) -> list[tuple[str, callable, ...]]: """ @@ -729,11 +728,52 @@ class PreviouslyModifiedData: COLLECTION_GRAPHICS: dict[HexStr, Graphic] = dict() +class CollectionIndexer: + """Collection Indexer""" + + def __init__( + self, + selection: np.ndarray[Graphic], + ): + """ + + Parameters + ---------- + + selection: np.ndarray of Graphics + array of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` + + """ + + self._selection = selection + + @property + def graphics(self) -> np.ndarray[Graphic]: + """Returns an array of the selected graphics. Always returns a proxy to the Graphic""" + return tuple(self._selection) + + def __getitem__(self, item): + return self.graphics[item] + + def __len__(self): + return len(self._selection) + + def __repr__(self): + return ( + f"{self.__class__.__name__} @ {hex(id(self))}\n" + f"Selection of <{len(self._selection)}> {self._selection[0].__class__.__name__}" + ) + + class GraphicCollection(Graphic): """Graphic Collection base class""" + child_type: type + _indexer: type def __init__(self, name: str = None): super().__init__(name) + + # list of mem locations of the graphics self._graphics: list[str] = list() self._graphics_changed: bool = True @@ -752,7 +792,7 @@ def graphics(self) -> np.ndarray[Graphic]: return self._graphics_array - def add_graphic(self, graphic: Graphic, reset_index: False): + def add_graphic(self, graphic: Graphic): """ Add a graphic to the collection. @@ -761,15 +801,12 @@ def add_graphic(self, graphic: Graphic, reset_index: False): graphic: Graphic graphic to add, must be a real ``Graphic`` not a proxy - reset_index: bool, default ``False`` - reset the collection index - """ - if not type(graphic).__name__ == self.child_type: + if not type(graphic) == self.child_type: raise TypeError( - f"Can only add graphics of the same type to a collection, " - f"You can only add {self.child_type} to a {self.__class__.__name__}, " + f"Can only add graphics of the same type to a collection.\n" + f"You can only add {self.child_type.__name__} to a {self.__class__.__name__}, " f"you are trying to add a {graphic.__class__.__name__}." ) @@ -778,41 +815,32 @@ def add_graphic(self, graphic: Graphic, reset_index: False): self._graphics.append(addr) - if reset_index: - self._reset_index() - elif graphic.collection_index is None: - graphic.collection_index = len(self) - self.world_object.add(graphic.world_object) self._graphics_changed = True - def remove_graphic(self, graphic: Graphic, reset_index: True): + def remove_graphic(self, graphic: Graphic): """ Remove a graphic from the collection. + Note: Only removes the graphic from the collection. Does not remove + the graphic from the scene, and does not delete the graphic. + Parameters ---------- graphic: Graphic graphic to remove - reset_index: bool, default ``False`` - reset the collection index - """ self._graphics.remove(graphic._fpl_address) - if reset_index: - self._reset_index() - self.world_object.remove(graphic.world_object) self._graphics_changed = True - def __getitem__(self, key): - return CollectionIndexer( - parent=self, + def __getitem__(self, key) -> CollectionIndexer: + return self._indexer( selection=self.graphics[key], ) @@ -824,10 +852,6 @@ def __del__(self): super().__del__() - def _reset_index(self): - for new_index, graphic in enumerate(self._graphics): - graphic.collection_index = new_index - def __len__(self): return len(self._graphics) @@ -836,70 +860,10 @@ def __repr__(self): return f"{rval}\nCollection of <{len(self._graphics)}> Graphics" -class CollectionIndexer: - """Collection Indexer""" - - def __init__( - self, - parent: GraphicCollection, - selection: list[Graphic], - ): - """ - - Parameters - ---------- - parent: GraphicCollection - the GraphicCollection object that is being indexed - - selection: list of Graphics - a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` - - """ - - self._parent = weakref.proxy(parent) - self._selection = selection - - # we use parent.graphics[0] instead of selection[0] - # because the selection can be empty - for attr_name in self._parent.graphics[0].__dict__.keys(): - attr = getattr(self._parent.graphics[0], attr_name) - if isinstance(attr, GraphicFeature): - collection_feature = CollectionFeature( - self._selection, feature=attr_name - ) - collection_feature.__doc__ = ( - f"indexable <{attr_name}> feature for collection" - ) - setattr(self, attr_name, collection_feature) - - @property - def graphics(self) -> np.ndarray[Graphic]: - """Returns an array of the selected graphics. Always returns a proxy to the Graphic""" - return tuple(self._selection) - - def __setattr__(self, key, value): - if hasattr(self, key): - attr = getattr(self, key) - if isinstance(attr, CollectionFeature): - attr._set(value) - return - - super().__setattr__(key, value) - - def __len__(self): - return len(self._selection) - - def __repr__(self): - return ( - f"{self.__class__.__name__} @ {hex(id(self))}\n" - f"Selection of <{len(self._selection)}> {self._selection[0].__class__.__name__}" - ) - - class CollectionFeature: """Collection Feature""" - def __init__(self, selection: list[Graphic], feature: str): + def __init__(self, selection: np.ndarray[Graphic], feature: str): """ selection: list of Graphics a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` @@ -912,50 +876,14 @@ def __init__(self, selection: list[Graphic], feature: str): self._selection = selection self._feature = feature - self._feature_instances: list[GraphicFeature] = list() - - if len(self._selection) > 0: - for graphic in self._selection: - fi = getattr(graphic, self._feature) - self._feature_instances.append(fi) - - if isinstance(fi, GraphicFeatureIndexable): - self._indexable = True - else: - self._indexable = False - else: # it's an empty selection so it doesn't really matter - self._indexable = False - - def _set(self, value): - self[:] = value + self._feature_instances = [getattr(g, feature) for g in self._selection] def __getitem__(self, item): - # only for indexable graphic features return [fi[item] for fi in self._feature_instances] def __setitem__(self, key, value): - if self._indexable: - for fi in self._feature_instances: - fi[key] = value - - else: - for fi in self._feature_instances: - fi._set(value) - - def add_event_handler(self, handler: callable): - """Adds an event handler to each of the selected Graphics from the parent GraphicCollection""" - for fi in self._feature_instances: - fi.add_event_handler(handler) - - def remove_event_handler(self, handler: callable): - """Removes an event handler from each of the selected Graphics of the parent GraphicCollection""" - for fi in self._feature_instances: - fi.remove_event_handler(handler) - - def block_events(self, b: bool): - """Blocks event handling from occurring.""" for fi in self._feature_instances: - fi.block_events(b) + fi[key] = value def __repr__(self): return f"Collection feature for: <{self._feature}>" diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index da74cc54e..5941e7a9a 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -7,27 +7,84 @@ import pygfx from ..utils import parse_cmap_values -from ._base import Interaction, PreviouslyModifiedData, GraphicCollection -from ._features import GraphicFeature +from ._base import Interaction, PreviouslyModifiedData, GraphicCollection, CollectionIndexer, CollectionFeature +from ._features import GraphicFeature, VertexColors, VertexPositions from .line import LineGraphic from .selectors import LinearRegionSelector, LinearSelector +class LineSelection(CollectionIndexer): + """A sub-selection of a line-collection""" + @property + def name(self) -> np.ndarray[str | None]: + return np.asarray([g.name for g in self.graphics]) + + @name.setter + def name(self, values: np.ndarray[str] | list[str]): + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.name = v + + @property + def colors(self) -> CollectionFeature: + return CollectionFeature(self.graphics, "colors") + + @colors.setter + def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(values, str): + # set colors of all lines to one str color + self.colors[:] = values + return + + elif all(isinstance(v, str) for v in values): + # individual str colors for each line + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.colors = v + + return + + elif len(values) == 4: + # assume RGBA + self.colors[:] = values + + else: + # assume individual colors for each + for g, v in zip(self.graphics, values): + g.colors = v + + @property + def data(self) -> CollectionFeature: + return CollectionFeature(self.graphics, "data") + + @data.setter + def data(self, values): + self.data[:] = values + + def add_event_handler(self): + pass + + class LineCollection(GraphicCollection, Interaction): - child_type = LineGraphic.__name__ + child_type = LineGraphic + _indexer = LineSelection def __init__( self, data: List[np.ndarray], - z_offset: Iterable[float | int] | float | int = None, - thickness: float | Iterable[float] = 2.0, - colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", + thickness: float | Sequence[float] = 2.0, + colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", + uniform_colors: bool = False, alpha: float = 1.0, - cmap: Iterable[str] | str = None, + cmap: Sequence[str] | str = None, cmap_values: np.ndarray | List = None, name: str = None, - metadata: Iterable[Any] | np.ndarray = None, - *args, + metadata: Sequence[Any] | np.ndarray = None, + isolated_buffer: bool = True, **kwargs, ): """ @@ -39,10 +96,6 @@ def __init__( List of line data to plot, each element must be a 1D, 2D, or 3D numpy array if elements are 2D, interpreted as [y_vals, n_lines] - z_offset: Iterable of float or float, optional - | if ``float`` | ``int``, single offset will be used for all lines - | if ``list`` of ``float`` | ``int``, each value will apply to the individual lines - thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines | if ``list`` of ``float``, each value will apply to the individual lines @@ -73,11 +126,8 @@ def __init__( metadata associated with this collection, this is for the user to manage. ``len(metadata)`` must be same as ``len(data)`` - args - passed to GraphicCollection - kwargs - passed to GraphicCollection + passed to Graphic Features -------- @@ -90,12 +140,6 @@ def __init__( super().__init__(name) - if not isinstance(z_offset, (float, int)) and z_offset is not None: - if len(data) != len(z_offset): - raise ValueError( - "z_position must be a single float or an iterable with same length as data" - ) - if not isinstance(thickness, (float, int)): if len(thickness) != len(data): raise ValueError( @@ -178,11 +222,6 @@ def __init__( self._set_world_object(pygfx.Group()) for i, d in enumerate(data): - if isinstance(z_offset, list): - _z = z_offset[i] - else: - _z = z_offset - if isinstance(thickness, list): _s = thickness[i] else: @@ -208,13 +247,14 @@ def __init__( data=d, thickness=_s, colors=_c, - z_position=_z, + uniform_colors=uniform_colors, cmap=_cmap, - collection_index=i, metadata=_m, + isolated_buffer=isolated_buffer, + **kwargs ) - self.add_graphic(lg, reset_index=False) + self.add_graphic(lg) @property def cmap(self) -> str: @@ -330,7 +370,7 @@ def add_linear_region_selector( ) = self._get_linear_selector_init_args(padding, **kwargs) selector = LinearRegionSelector( - bounds=bounds, + selection=bounds, limits=limits, size=size, origin=origin, @@ -478,7 +518,6 @@ class LineStack(LineCollection): def __init__( self, data: List[np.ndarray], - z_offset: Iterable[float] | float = None, thickness: float | Iterable[float] = 2.0, colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", alpha: float = 1.0, @@ -488,7 +527,6 @@ def __init__( metadata: Iterable[Any] | np.ndarray = None, separation: float = 10.0, separation_axis: str = "y", - *args, **kwargs, ): """ @@ -500,10 +538,6 @@ def __init__( List of line data to plot, each element must be a 1D, 2D, or 3D numpy array if elements are 2D, interpreted as [y_vals, n_lines] - z_offset: Iterable of float or float, optional - | if ``float``, single offset will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines | if ``list`` of ``float``, each value will apply to the individual lines @@ -550,27 +584,26 @@ def __init__( """ super().__init__( data=data, - z_offset=z_offset, thickness=thickness, colors=colors, alpha=alpha, cmap=cmap, cmap_values=cmap_values, - metadata=metadata, name=name, - *args, + metadata=metadata, **kwargs, ) axis_zero = 0 for i, line in enumerate(self.graphics): if separation_axis == "x": - line.position_x = axis_zero + line.offset = (axis_zero, *line.offset[1:]) + elif separation_axis == "y": - line.position_y = axis_zero + line.offset = (line.offset[0], axis_zero, line.offset[2]) axis_zero = ( - axis_zero + line.data()[:, axes[separation_axis]].max() + separation + axis_zero + line.data.value[:, axes[separation_axis]].max() + separation ) self.separation = separation From 6eb5a1a8b49b96c424361327e4709f3607136950 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 4 Jun 2024 03:13:24 -0400 Subject: [PATCH 75/77] more line collection --- fastplotlib/graphics/_base.py | 110 ++++++++++++++++++++++++ fastplotlib/graphics/line_collection.py | 47 ++++++---- 2 files changed, 141 insertions(+), 16 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index c06f5681f..f4ab6b7eb 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -730,10 +730,51 @@ class PreviouslyModifiedData: class CollectionIndexer: """Collection Indexer""" + @property + def name(self) -> np.ndarray[str | None]: + return np.asarray([g.name for g in self.graphics]) + + @name.setter + def name(self, values: np.ndarray[str] | list[str]): + self._set_feature("name", values) + + @property + def offset(self) -> np.ndarray: + return np.stack([g.offset for g in self.graphics]) + + @offset.setter + def offset(self, values: np.ndarray | list[np.ndarray]): + self._set_feature("offset", values) + + @property + def rotation(self) -> np.ndarray: + return np.stack([g.rotation for g in self.graphics]) + + @rotation.setter + def rotation(self, values: np.ndarray | list[np.ndarray]): + self._set_feature("rotation", values) + + @property + def visible(self) -> np.ndarray[bool]: + return np.asarray([g.visible for g in self.graphics]) + + @visible.setter + def visible(self, values: np.ndarray[bool] | list[bool]): + self._set_feature("visible", values) + + # TODO: how to work with deleted feature in a collection + + def _set_feature(self, feature, values): + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + setattr(g, feature, v) def __init__( self, selection: np.ndarray[Graphic], + features: set[str] ): """ @@ -746,12 +787,75 @@ def __init__( """ self._selection = selection + self._features = features @property def graphics(self) -> np.ndarray[Graphic]: """Returns an array of the selected graphics. Always returns a proxy to the Graphic""" return tuple(self._selection) + def add_event_handler(self, *args): + """ + Register an event handler. + + Parameters + ---------- + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" + + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html + + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented + + Can also be used as a decorator. + + Example + ------- + + .. code-block:: py + + def my_handler(event): + print(event) + + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") + + Decorator usage example: + + .. code-block:: py + + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ + + decorating = not callable(args[0]) + callback = None if decorating else args[0] + types = args if decorating else args[1:] + + if not all(t in set(PYGFX_EVENTS).union(self._features) for t in types): + raise KeyError( + f"event types must be strings for a valid event from the following:\n" + f"{PYGFX_EVENTS + list(self._features)}" + ) + + def decorator(_callback): + for g in self.graphics: + g.add_event_handler(_callback, types) + return _callback + + if decorating: + return decorator + + return decorator(callback) + + def remove_event_handler(self, callback, *types): + for g in self.graphics: + g.remove_event_handler(callback, *types) + def __getitem__(self, item): return self.graphics[item] @@ -839,6 +943,12 @@ def remove_graphic(self, graphic: Graphic): self._graphics_changed = True + def add_event_handler(self, *args): + raise NotImplementedError("Slice graphic collection to add event handlers") + + def remove_event_handler(self, callback, *types): + raise NotImplementedError("Slice graphic collection to remove event handlers") + def __getitem__(self, key) -> CollectionIndexer: return self._indexer( selection=self.graphics[key], diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 5941e7a9a..6ab416efc 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -8,25 +8,12 @@ from ..utils import parse_cmap_values from ._base import Interaction, PreviouslyModifiedData, GraphicCollection, CollectionIndexer, CollectionFeature -from ._features import GraphicFeature, VertexColors, VertexPositions +from ._features import GraphicFeature from .line import LineGraphic from .selectors import LinearRegionSelector, LinearSelector class LineSelection(CollectionIndexer): - """A sub-selection of a line-collection""" - @property - def name(self) -> np.ndarray[str | None]: - return np.asarray([g.name for g in self.graphics]) - - @name.setter - def name(self, values: np.ndarray[str] | list[str]): - if not len(values) == len(self): - raise IndexError - - for g, v in zip(self.graphics, values): - g.name = v - @property def colors(self) -> CollectionFeature: return CollectionFeature(self.graphics, "colors") @@ -48,6 +35,13 @@ def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[st return + if isinstance(values, np.ndarray): + if values.ndim == 2: + # assume individual colors for each + for g, v in zip(self.graphics, values): + g.colors = v + return + elif len(values) == 4: # assume RGBA self.colors[:] = values @@ -65,8 +59,28 @@ def data(self) -> CollectionFeature: def data(self, values): self.data[:] = values - def add_event_handler(self): - pass + @property + def cmap(self) -> CollectionFeature: + return CollectionFeature(self.graphics, "cmap") + + @cmap.setter + def cmap(self, name: str): + colors = parse_cmap_values( + n_colors=len(self), cmap_name=name + ) + self.colors = colors + + @property + def thickness(self) -> np.ndarray: + return np.asarray([g.thickness for g in self.graphics]) + + @thickness.setter + def thickness(self, values: np.ndarray | list[float]): + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.thickness = v class LineCollection(GraphicCollection, Interaction): @@ -308,6 +322,7 @@ def add_linear_selector( LinearSelector """ + # TODO: Use bbox to get size and center for selectors! ( bounds, From f543e4aca320f15d55cfcf808ff724a458917914 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 4 Jun 2024 09:31:37 -0400 Subject: [PATCH 76/77] remove old events system --- fastplotlib/graphics/_base.py | 163 ------------------------ fastplotlib/graphics/line.py | 4 +- fastplotlib/graphics/line_collection.py | 4 +- 3 files changed, 4 insertions(+), 167 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index f4ab6b7eb..aff4b90e5 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -519,129 +519,6 @@ def attach_feature(self, feature: VertexPositions | VertexColors | PointsSizesFe self._sizes._shared += 1 self.world_object.geometry.sizes = self._sizes.buffer - -class Interaction(ABC): - """Mixin class that makes graphics interactive""" - - @abstractmethod - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass - - @abstractmethod - def reset_feature(self, feature: str): - pass - - def link( - self, - event_type: str, - target: Any, - feature: str, - new_data: Any, - callback: callable = None, - bidirectional: bool = False, - ): - """ - Link this graphic to another graphic upon an ``event_type`` to change the ``feature`` - of a ``target`` graphic. - - Parameters - ---------- - event_type: str - can be a pygfx event ("key_down", "key_up","pointer_down", "pointer_move", "pointer_up", - "pointer_enter", "pointer_leave", "click", "double_click", "wheel", "close", "resize") - or appropriate feature event (ex. colors, data, etc.) associated with the graphic (can use - ``graphic_instance.feature_events`` to get a tuple of the valid feature events for the - graphic) - - target: Any - graphic to be linked to - - feature: str - feature (ex. colors, data, etc.) of the target graphic that will change following - the event - - new_data: Any - appropriate data that will be changed in the feature of the target graphic after - the event occurs - - callback: callable, optional - user-specified callable that will handle event, - the callable must take the following four arguments - | ''source'' - this graphic instance - | ''target'' - the graphic to be changed following the event - | ''event'' - the ''pygfx event'' or ''feature event'' that occurs - | ''new_data'' - the appropriate data of the ''target'' that will be changed - - bidirectional: bool, default False - if True, the target graphic is also linked back to this graphic instance using the - same arguments - - For example: - .. code-block::python - - Returns - ------- - None - - """ - if event_type in PYGFX_EVENTS: - self.world_object.add_event_handler(self._event_handler, event_type) - - # make sure event is valid - elif event_type in self.feature_events: - if isinstance(self, GraphicCollection): - feature_instance = getattr(self[:], event_type) - else: - feature_instance = getattr(self, event_type) - - feature_instance.add_event_handler(self._event_handler) - - else: - raise ValueError( - f"Invalid event, valid events are: {PYGFX_EVENTS + self.feature_events}" - ) - - # make sure target feature is valid - if feature is not None: - if feature not in target.feature_events: - raise ValueError( - f"Invalid feature for target, valid features are: {target.feature_events}" - ) - - if event_type not in self.registered_callbacks.keys(): - self.registered_callbacks[event_type] = list() - - callback_data = CallbackData( - target=target, - feature=feature, - new_data=new_data, - callback_function=callback, - ) - - for existing_callback_data in self.registered_callbacks[event_type]: - if existing_callback_data == callback_data: - warn( - "linkage already exists for given event, target, and data, skipping" - ) - return - - self.registered_callbacks[event_type].append(callback_data) - - if bidirectional: - if event_type in PYGFX_EVENTS: - warn("cannot use bidirectional link for pygfx events") - return - - target.link( - event_type=event_type, - target=self, - feature=feature, - new_data=new_data, - callback=callback, - bidirectional=False, # else infinite recursion, otherwise target will call - # this instance .link(), and then it will happen again etc. - ) - def _event_handler(self, event): """Handles the event after it occurs when two graphic have been linked together.""" if event.type in self.registered_callbacks.keys(): @@ -684,46 +561,6 @@ def _event_handler(self, event): ) -@dataclass -class CallbackData: - """Class for keeping track of the info necessary for interactivity after event occurs.""" - - target: Any - feature: str - new_data: Any - callback_function: callable = None - - def __eq__(self, other): - if not isinstance(other, CallbackData): - raise TypeError("Can only compare against other types") - - if other.target is not self.target: - return False - - if not other.feature == self.feature: - return False - - if not other.new_data == self.new_data: - return False - - if (self.callback_function is None) and (other.callback_function is None): - return True - - if other.callback_function is self.callback_function: - return True - - else: - return False - - -@dataclass -class PreviouslyModifiedData: - """Class for keeping track of previously modified data at indices""" - - data: Any - indices: Any - - # Dict that holds all collection graphics in one python instance COLLECTION_GRAPHICS: dict[HexStr, Graphic] = dict() diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index b0df8f7ce..640a880f2 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,12 +5,12 @@ import pygfx -from ._base import PositionsGraphic, Interaction, PreviouslyModifiedData +from ._base import PositionsGraphic from .selectors import LinearRegionSelector, LinearSelector from ._features import Thickness -class LineGraphic(PositionsGraphic, Interaction): +class LineGraphic(PositionsGraphic): features = {"data", "colors", "cmap", "thickness"} @property diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 6ab416efc..9403bcd08 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -7,7 +7,7 @@ import pygfx from ..utils import parse_cmap_values -from ._base import Interaction, PreviouslyModifiedData, GraphicCollection, CollectionIndexer, CollectionFeature +from ._base import GraphicCollection, CollectionIndexer, CollectionFeature from ._features import GraphicFeature from .line import LineGraphic from .selectors import LinearRegionSelector, LinearSelector @@ -83,7 +83,7 @@ def thickness(self, values: np.ndarray | list[float]): g.thickness = v -class LineCollection(GraphicCollection, Interaction): +class LineCollection(GraphicCollection): child_type = LineGraphic _indexer = LineSelection From aa1e974fa7eb41090fec3cf88a078c618c4fe158 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 4 Jun 2024 11:04:54 -0400 Subject: [PATCH 77/77] fix some examples --- examples/desktop/image/image_rgbvminvmax.py | 4 ++-- examples/desktop/image/image_vminvmax.py | 4 ++-- examples/desktop/image/image_widget.py | 2 +- examples/desktop/line/line_cmap.py | 2 +- examples/desktop/line/line_colorslice.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/desktop/image/image_rgbvminvmax.py b/examples/desktop/image/image_rgbvminvmax.py index 9725c038a..56114e1e3 100644 --- a/examples/desktop/image/image_rgbvminvmax.py +++ b/examples/desktop/image/image_rgbvminvmax.py @@ -23,8 +23,8 @@ fig[0, 0].auto_scale() -image_graphic.cmap.vmin = 0.5 -image_graphic.cmap.vmax = 0.75 +image_graphic.vmin = 0.5 +image_graphic.vmax = 0.75 if __name__ == "__main__": diff --git a/examples/desktop/image/image_vminvmax.py b/examples/desktop/image/image_vminvmax.py index 3c8607aef..d24d1f18c 100644 --- a/examples/desktop/image/image_vminvmax.py +++ b/examples/desktop/image/image_vminvmax.py @@ -23,8 +23,8 @@ fig[0, 0].auto_scale() -image_graphic.cmap.vmin = 0.5 -image_graphic.cmap.vmax = 0.75 +image_graphic.vmin = 0.5 +image_graphic.vmax = 0.75 if __name__ == "__main__": diff --git a/examples/desktop/image/image_widget.py b/examples/desktop/image/image_widget.py index 80aafe0b1..ddfc7c68d 100644 --- a/examples/desktop/image/image_widget.py +++ b/examples/desktop/image/image_widget.py @@ -10,7 +10,7 @@ a = iio.imread("imageio:camera.png") -iw = fpl.ImageWidget(data=a, cmap="viridis") +iw = fpl.ImageWidget(data=a, cmap="viridis", histogram_widget=False) iw.show() diff --git a/examples/desktop/line/line_cmap.py b/examples/desktop/line/line_cmap.py index 7d8e1e7d6..0bdc78aaf 100644 --- a/examples/desktop/line/line_cmap.py +++ b/examples/desktop/line/line_cmap.py @@ -35,7 +35,7 @@ data=cosine, thickness=10, cmap="tab10", - cmap_values=cmap_values + cmap_values=np.array(cmap_values) ) fig.show() diff --git a/examples/desktop/line/line_colorslice.py b/examples/desktop/line/line_colorslice.py index 4df666531..25a6329ae 100644 --- a/examples/desktop/line/line_colorslice.py +++ b/examples/desktop/line/line_colorslice.py @@ -53,8 +53,8 @@ key = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 67, 19]) sinc_graphic.colors[key] = "Red" -key2 = np.array([True, False, True, False, True, True, True, True]) -cosine_graphic.colors[key2] = "Green" +#key2 = np.array([True, False, True, False, True, True, True, True]) +#cosine_graphic.colors[key2] = "Green" fig.canvas.set_logical_size(800, 800)