From c221cff0e10a96b2553142264e57949b47bdd5af Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Aug 2026 02:25:43 -0400 Subject: [PATCH 1/4] refactor cmap and cmap_transform for positional graphics --- fastplotlib/graphics/features/_positions.py | 163 ++++++++------------ 1 file changed, 62 insertions(+), 101 deletions(-) diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 2ede10b8b..c6e238026 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -2,6 +2,7 @@ import numpy as np import pygfx +import cmap as cmap_lib from ...utils import ( parse_cmap_values, @@ -339,138 +340,98 @@ def __len__(self): return len(self.buffer.data) -class VertexCmap(BufferManager): +class VertexCmap(GraphicFeature): event_info_spec = [ - { - "dict key": "key", - "type": "slice", - "description": "key at cmap colors were sliced", - }, { "dict key": "value", - "type": "str", - "description": "new cmap to set at given slice", + "type": "cmap.Colormap", + "description": "new colormap", }, ] def __init__( self, - vertex_colors: VertexColors, - cmap_name: str | None, - transform: np.ndarray | None, - property_name: str = "colors", + value: cmap_lib.ColormapLike, + property_name: str = "cmap", ): """ - Sliceable colormap feature, manages a VertexColors instance and - provides a way to set colormaps with arbitrary transforms + colormap feature, manages a VertexColors instance and provides a way to set colormaps. """ + self._value = cmap_lib.Colormap(value) - super().__init__(data=None, property_name=property_name) - - self._vertex_colors = vertex_colors - self._cmap_name = cmap_name - self._transform = transform - - if self._cmap_name is not None: - if not isinstance(self._cmap_name, str): - raise TypeError( - f"cmap name must be of type , you have passed: {self._cmap_name} of type: {type(self._cmap_name)}" - ) - - if self._transform is not None: - self._transform = np.asarray(self._transform) - - n_datapoints = vertex_colors.value.shape[0] - - colors = parse_cmap_values( - n_colors=n_datapoints, - cmap_name=self._cmap_name, - transform=self._transform, - ) - # set vertex colors from cmap - self._vertex_colors[:] = colors - - @property - def buffer(self) -> pygfx.Buffer: - return self._vertex_colors.buffer + super().__init__(property_name=property_name) @property - def value(self) -> np.ndarray: - # mirror the managed colors feature, whose length is the number of color entries - # (this is per-line, not per-vertex, for an InfLineColors) - return self._vertex_colors.value + def value(self) -> cmap_lib.Colormap: + return self._value @block_reentrance - def __setitem__(self, key: slice, cmap_name): - if not isinstance(key, slice): - raise TypeError( - "fancy indexing not supported for VertexCmap, only slices " - "of a continuous range are supported for applying a cmap" - ) - if key.step is not None: - raise TypeError( - "step sized indexing not currently supported for setting VertexCmap, " - "slices must be a continuous range" - ) + def set_value(self, graphic, value: cmap_lib.ColormapLike): + self._value = cmap_lib.Colormap(value) + pygfx.TextureMap + + # directly set the material map using the TextureMap + graphic.world_object.material.map = self._value.to_pygfx() + graphic.world_object.geometry.texcoords - # parse slice - start, stop, step = key.indices(self.value.shape[0]) - n_elements = len(range(start, stop, step)) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + self.value.__rich_repr__() - colors = parse_cmap_values( - n_colors=n_elements, cmap_name=cmap_name, transform=self._transform - ) + def __repr__(self): + return self.value.__repr__() - self._cmap_name = cmap_name - self._vertex_colors[key] = colors + def _repr_html_(self): + return self.value._repr_html_() - # TODO: should we block vertex_colors from emitting an event? - # Because currently this will result in 2 emitted events, one - # for cmap and another from the colors - self._emit_event(self._property_name, key, cmap_name) + def _repr_png(self): + return self.value._repr_png_() - @property - def name(self) -> str: - return self._cmap_name - @property - def transform(self) -> np.ndarray | None: - """Get or set the cmap transform. Maps values from the transform array to the cmap colors""" - return self._transform +class VertexCmapTransform(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "colormap transform", + }, + ] - @transform.setter - def transform( - self, - values: np.ndarray | list[float | int], - indices: slice | list | np.ndarray = None, + def __init__( + self, + value: np.ndarray, + property_name: str = "cmap_transform" ): - if self._cmap_name is None: - raise AttributeError( - "cmap name is not set, set the cmap name before setting the transform" - ) + """colormap transform""" - values = np.asarray(values) - - colors = parse_cmap_values( - n_colors=self.value.shape[0], cmap_name=self._cmap_name, transform=values - ) + self._value = np.asarray(value) + super().__init__(property_name=property_name) - self._transform = values + @property + def valeu(self) -> np.ndarray: + return self._value - if indices is None: - indices = slice(None) + @block_reentrance + def set_value(self, graphic, value: np.ndarray): + value = np.asarray(value).squeeze() - self._vertex_colors[indices] = colors + # make sure transform value is provided for every datapoint + n_datapoints = len(graphic.world_object.geometry.positions.data) + if value.size != n_datapoints: + raise ValueError( + f"`cmap_transform` must be a 1D array with a size that matches the number of datapoints\n" + f"you provided a `cmap_transform` with {value.size} elements but you have {n_datapoints} datapoints." + ) - self._emit_event("cmap.transform", indices, values) + if graphic.world_object.geometry.texcoords is not None: + graphic.world_object.geometry.texcoords[:] = value + else: + graphic.world_object.geometry.texcoords = pygfx.Buffer(self.value) - def __len__(self): - raise NotImplementedError( - "len not implemented for `cmap`, use len(colors) instead" - ) + self._value = graphic.world_object.geometry.texcoords.data - def __repr__(self): - return f"{self.__class__.__name__} | cmap: {self.name}\ntransform: {self.transform}" + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) class InfLineAxisData(VertexPositions): From 125a128f7d21165bcb201093e2cf3f3d353d3126 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 8 Aug 2026 02:31:32 -0400 Subject: [PATCH 2/4] color mode stuff --- fastplotlib/graphics/_positions_base.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 426079730..8673299a9 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -52,7 +52,7 @@ def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): self._colors.set_value(self, value) @property - def color_mode(self) -> Literal["uniform", "vertex"]: + def color_mode(self) -> pygfx.enums.ColorMode: """ Get or set the color mode. Note that after setting the color_mode, you will have to set the `colors` as well for switching between 'uniform' and 'vertex' modes. @@ -60,10 +60,10 @@ def color_mode(self) -> Literal["uniform", "vertex"]: return self.world_object.material.color_mode @color_mode.setter - def color_mode(self, mode: Literal["uniform", "vertex"]): - valid = ("uniform", "vertex") - if mode not in valid: - raise ValueError(f"`color_mode` must be one of : {valid}") + def color_mode(self, mode: pygfx.enums.ColorMode): + if mode not in pygfx.enums.ColorMode: + raise ValueError(f"`color_mode` must be one of : {pygfx.enums.ColorMode}, not {mode!r}") + if mode == "vertex" and isinstance(self._colors, UniformColor): # uniform -> vertex # need to make a new vertex buffer and get rid of uniform buffer @@ -87,6 +87,10 @@ def color_mode(self, mode: Literal["uniform", "vertex"]): self._cmap.clear_event_handlers() self._cmap = None + elif mode == "vertex_map": + # TODO: handle new cmap stuff + pass + else: # no change, return return From 5e0bd9557cd20bae7c93a402c0dc9812e35ba9ed Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Aug 2026 14:19:31 -0400 Subject: [PATCH 3/4] WIP --- fastplotlib/graphics/_positions_base.py | 85 ++++++++++----------- fastplotlib/graphics/_types.py | 14 ++++ fastplotlib/graphics/features/__init__.py | 1 + fastplotlib/graphics/features/_positions.py | 6 +- fastplotlib/graphics/inf_line.py | 2 +- fastplotlib/graphics/line.py | 27 ++++--- fastplotlib/utils/gui.py | 4 +- 7 files changed, 77 insertions(+), 62 deletions(-) create mode 100644 fastplotlib/graphics/_types.py diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 8673299a9..0f7f7bbe4 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -3,6 +3,7 @@ from warnings import warn import numpy as np +import cmap as cmap_lib import pygfx from ._base import Graphic @@ -11,8 +12,14 @@ VertexColors, UniformColor, VertexCmap, + VertexCmapTransform, SizeSpace, ) +from ._types import ColorLike, MultiColorLike + + +# we allow a subset of all pygfx.enum.ColorMode since some are not applicable to positional graphics +VALID_COLOR_MODES = ("auto", "uniform", "vertex", "vertex_map") class PositionsGraphic(Graphic): @@ -108,18 +115,24 @@ def color_mode(self, mode: pygfx.enums.ColorMode): self.world_object.material.color_mode = mode @property - def cmap(self) -> VertexCmap: + def cmap(self) -> cmap_lib.Colormap | None: """ - Control the cmap or cmap transform + Get or set the colormap For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ """ - return self._cmap + if self._cmap is not None: + return self._cmap.value + + return None @cmap.setter def cmap(self, name: str): - if self.color_mode == "uniform": - raise ValueError("cannot use `cmap` with `color_mode` = 'uniform'") + if self.color_mode not in ("auto", "vertex_map"): + raise ValueError( + f"`color_mode` must be 'auto' or 'vertex_map' to set the cmap, " + f"the current `color_mode` is: {self.color_mode}" + ) self._cmap[:] = name @@ -198,10 +211,10 @@ def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColo def __init__( self, data: Any, - colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", - cmap: str | VertexCmap = None, - cmap_transform: np.ndarray = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", + colors: str | ColorLike | MultiColorLike = "w", + cmap: str | cmap_lib.ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + color_mode: Literal["auto", "uniform", "vertex", "vertex_map"] = "auto", size_space: str = "screen", *args, **kwargs, @@ -214,54 +227,36 @@ def __init__( if cmap_transform is not None and cmap is None: raise ValueError("must pass `cmap` if passing `cmap_transform`") - valid = ("auto", "uniform", "vertex") - - # default _cmap is None + # defaults are None self._cmap = None + self._cmap_transform = None + self._colors = None - if color_mode not in valid: - raise ValueError(f"`color_mode` must be one of {valid}") + if color_mode not in VALID_COLOR_MODES: + raise ValueError(f"`color_mode` must be one of {VALID_COLOR_MODES}") if cmap is not None: # if a cmap is specified it overrides colors argument - if color_mode == "uniform": + if color_mode != "vertex_map": raise ValueError( - "if a `cmap` is provided, `color_mode` must be 'vertex' or 'auto', not 'uniform'" + f"if a `cmap` is provided, `color_mode` must be 'vertex_cmap' or 'auto', not {color_mode}" ) - if isinstance(cmap, str): - # make colors from cmap - if isinstance(colors, VertexColors): - # share buffer with existing colors instance for the cmap - self._colors = colors - else: - # create vertex colors buffer - self._colors = self._VertexColorsCls( - "w", n_colors=self._data.value.shape[0] - ) - # make cmap using vertex colors buffer - self._cmap = VertexCmap( - self._colors, - cmap_name=cmap, - transform=cmap_transform, - ) - elif isinstance(cmap, VertexCmap): - # use existing cmap instance - self._cmap = cmap - self._colors = cmap._vertex_colors + self._cmap = VertexCmap(cmap) + if cmap_transform is None: + # default transform is just a linspace along the datapoints + cmap_transform = np.linspace(0, 1, len(self)) else: - raise TypeError( - "`cmap` argument must be a cmap name or an existing `VertexCmap` instance" - ) + if len(cmap_transform) != len(self): + raise ValueError("`cmap_transform` must be a 1D array of the same size as the number of datapoints") + + self._cmap_transform = VertexCmapTransform(cmap_transform) + else: # no cmap given self._colors = self._create_colors_buffer(colors, color_mode) - # this is created so that cmap can be set later - if isinstance(self._colors, VertexColors): - self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) - self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) @@ -272,3 +267,7 @@ def format_pick_info(self, pick_info: dict) -> str: ) return info + + def __len__(self) -> int: + """number of datapoints""" + return len(self.data) diff --git a/fastplotlib/graphics/_types.py b/fastplotlib/graphics/_types.py new file mode 100644 index 000000000..27f8d11d6 --- /dev/null +++ b/fastplotlib/graphics/_types.py @@ -0,0 +1,14 @@ +import numpy as np +import pygfx + +RGB = tuple[float, float, float] | tuple[int, int, int] | list[int] | list[float] +RGBA = tuple[float, float, float, float] | tuple[int, int, int, int] | list[int] | list[float] | pygfx.Color + +ArrayRGBA = np.ndarray[tuple[int, int, int] | tuple[int, int, int, int], np.dtype[np.number]] + +ColorLike = RGB | RGBA | ArrayRGBA | pygfx.Color + +# [n, 3 | 4] RGBA array +MultiColorArray = np.ndarray[tuple[int, int], np.dtype[np.number]] + +MultiColorLike = tuple[ColorLike] | list[ColorLike] | MultiColorArray diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index cc1840a56..9e4625a85 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -4,6 +4,7 @@ SizeSpace, VertexPositions, VertexCmap, + VertexCmapTransform, InfLineAxisData, InfLineColors, ) diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index c6e238026..5a4eacb4d 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -397,11 +397,7 @@ class VertexCmapTransform(GraphicFeature): }, ] - def __init__( - self, - value: np.ndarray, - property_name: str = "cmap_transform" - ): + def __init__(self, value: np.ndarray, property_name: str = "cmap_transform"): """colormap transform""" self._value = np.asarray(value) diff --git a/fastplotlib/graphics/inf_line.py b/fastplotlib/graphics/inf_line.py index 6d92d4b3b..d5487043c 100644 --- a/fastplotlib/graphics/inf_line.py +++ b/fastplotlib/graphics/inf_line.py @@ -122,7 +122,7 @@ def _make_material(self) -> pygfx.LineInfiniteSegmentMaterial: return pygfx.LineInfiniteSegmentMaterial( start_is_infinite=self._start_is_infinite, end_is_infinite=self._end_is_infinite, - **self._material_kwargs(), + **self._get_material_kwargs(), ) @property diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 0b325df71..1dc8103f7 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -2,10 +2,10 @@ from warnings import warn import numpy as np +import cmap as cmap_lib import pygfx -from ._positions_base import PositionsGraphic from .selectors import ( LinearRegionSelector, LinearSelector, @@ -24,7 +24,8 @@ UniformRotations, ) from ..utils import quick_min_max - +from ._positions_base import PositionsGraphic, VALID_COLOR_MODES +from ._types import ColorLike, MultiColorLike class LineGraphic(PositionsGraphic): _features = { @@ -40,10 +41,10 @@ def __init__( self, data: Any, thickness: float = 2.0, - colors: str | np.ndarray | Sequence = "w", - cmap: str = None, - cmap_transform: np.ndarray | Sequence = None, - color_mode: Literal["auto", "uniform", "vertex"] = "auto", + colors: str | ColorLike | MultiColorLike = "w", + cmap: str | cmap_lib.ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + color_mode: Literal["auto", "uniform", "vertex", "vertex_map"] = "auto", size_space: str = "screen", dash_pattern: str | tuple | list = (), thin: bool = False, @@ -63,7 +64,7 @@ def __init__( thickness: float, optional, default 2.0 thickness of the line - colors: str, array, or iterable, default "w" + colors: str, ColorLike or MultiColorLike, default "w" specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays @@ -72,13 +73,14 @@ def __init__( overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - color_mode: one of "auto", "uniform", "vertex", default "auto" + color_mode: one of "auto", "uniform", "vertex", "vertex_map", default "auto" "uniform" restricts to a single color for all line datapoints. "vertex" allows independent colors per vertex. + "vertex_map" uses the ``cmap`` to set per-vertex colors. For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to - "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + "vertex_map". You can switch between color_modes after creating the graphic. cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap @@ -127,7 +129,7 @@ def __init__( self._set_world_object(world_object) - def _material_kwargs(self) -> dict: + def _get_material_kwargs(self) -> dict: # pygfx line material kwargs assembled from the current feature state kwargs = dict( thickness=self.thickness, @@ -141,6 +143,9 @@ def _material_kwargs(self) -> dict: if isinstance(self._colors, UniformColor): kwargs["color_mode"] = "uniform" kwargs["color"] = self.colors + elif self.cmap is not None: + kwargs["color_mode"] = "vertex_map" + kwargs["map"] = self.cmap.to_pygfx() else: kwargs["color_mode"] = "vertex" @@ -149,7 +154,7 @@ def _material_kwargs(self) -> dict: def _make_material(self) -> pygfx.LineMaterial: # create the pygfx material, subclasses override to use a different line material material_cls = pygfx.LineThinMaterial if self._thin else pygfx.LineMaterial - return material_cls(**self._material_kwargs()) + return material_cls(**self._get_material_kwargs()) def _create_geometry(self) -> pygfx.Geometry: if isinstance(self._colors, UniformColor): diff --git a/fastplotlib/utils/gui.py b/fastplotlib/utils/gui.py index 6a0d8dfdc..c17e11e12 100644 --- a/fastplotlib/utils/gui.py +++ b/fastplotlib/utils/gui.py @@ -38,7 +38,7 @@ # Get the name of the backend ('qt', 'glfw', 'jupyter') GUI_BACKEND = RenderCanvas.__module__.split(".")[-1] -IS_JUPYTER = GUI_BACKEND == "jupyter" +IS_JUPYTER = GUI_BACKEND == "anywidget" # --- Some backend-specific preparations @@ -120,7 +120,7 @@ def _notebook_print_banner(): display(HTML(table_str)) -if GUI_BACKEND == "jupyter": +if GUI_BACKEND == "anywidget": _notebook_print_banner() elif GUI_BACKEND == "qt": From b269f076c5c1aef371eaf69253d97cf1df28e0a5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Aug 2026 00:38:23 -0400 Subject: [PATCH 4/4] more WIP --- fastplotlib/graphics/_positions_base.py | 307 ++++++++---------- fastplotlib/graphics/features/_positions.py | 35 +- .../graphics/{_types.py => features/types.py} | 2 +- fastplotlib/graphics/features/utils.py | 19 +- fastplotlib/graphics/line.py | 44 ++- 5 files changed, 205 insertions(+), 202 deletions(-) rename fastplotlib/graphics/{_types.py => features/types.py} (89%) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 0f7f7bbe4..dc2ae34e3 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -1,6 +1,4 @@ -from numbers import Real -from typing import Any, Sequence, Literal -from warnings import warn +from typing import Any import numpy as np import cmap as cmap_lib @@ -15,11 +13,8 @@ VertexCmapTransform, SizeSpace, ) -from ._types import ColorLike, MultiColorLike - - -# we allow a subset of all pygfx.enum.ColorMode since some are not applicable to positional graphics -VALID_COLOR_MODES = ("auto", "uniform", "vertex", "vertex_map") +from .features.utils import is_single_color +from features.types import ColorLike, MultiColorLike class PositionsGraphic(Graphic): @@ -28,6 +23,40 @@ class PositionsGraphic(Graphic): # the feature used to manage a per-vertex color buffer, subclasses may override _VertexColorsCls = VertexColors + def __init__( + self, + data: Any, + colors: ColorLike | MultiColorLike = "w", + cmap: str | cmap_lib.ColormapLike | None = None, + cmap_transform: np.ndarray | None = None, + size_space: str = "screen", + *args, + **kwargs, + ): + if isinstance(data, VertexPositions): + self._data = data + else: + self._data = VertexPositions(data) + + if cmap_transform is not None and cmap is None: + raise ValueError("must pass `cmap` if passing `cmap_transform`") + + # defaults are None + self._cmap = None + self._cmap_transform = None + self._colors = None + + if cmap is not None: + # if a cmap is specified it overrides colors argument + self._cmap, self._cmap_transform = self._create_cmap_buffers(cmap, cmap_transform) + + else: + # no cmap given + self._colors = self._create_colors_buffer(colors) + + self._size_space = SizeSpace(size_space) + super().__init__(*args, **kwargs) + @property def data(self) -> VertexPositions: """ @@ -46,7 +75,7 @@ def data(self, value): self._data.set_value(self, value) @property - def colors(self) -> VertexColors | pygfx.Color: + def colors(self) -> VertexColors | pygfx.Color | None: """Get or set the colors""" if isinstance(self._colors, VertexColors): return self._colors @@ -55,64 +84,53 @@ def colors(self) -> VertexColors | pygfx.Color: return self._colors.value @colors.setter - def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): - self._colors.set_value(self, value) - - @property - def color_mode(self) -> pygfx.enums.ColorMode: - """ - Get or set the color mode. Note that after setting the color_mode, you will have to set the `colors` - as well for switching between 'uniform' and 'vertex' modes. - """ - return self.world_object.material.color_mode + def colors(self, value: ColorLike | MultiColorLike): + new_mode = "uniform" if is_single_color(value) else "vertex" + old_mode = self._color_mode + ColorsCls = { + "uniform": UniformColor, + "vertex": self._VertexColorsCls + }.get(new_mode) + + if isinstance(self._colors, ColorsCls): + # it's already the right instance type + self._colors.set_value(self, value) + return - @color_mode.setter - def color_mode(self, mode: pygfx.enums.ColorMode): - if mode not in pygfx.enums.ColorMode: - raise ValueError(f"`color_mode` must be one of : {pygfx.enums.ColorMode}, not {mode!r}") - - if mode == "vertex" and isinstance(self._colors, UniformColor): - # uniform -> vertex - # need to make a new vertex buffer and get rid of uniform buffer - new_colors = self._create_colors_buffer(self._colors.value, "vertex") - # we can't clear world_object.material.color so just set the colors buffer on the geometry - # this doesn't really matter anyways since the lingering uniform color takes up just a few bytes - self.world_object.geometry.colors = new_colors._fpl_buffer - - elif mode == "uniform" and isinstance(self._colors, VertexColors): - # vertex -> uniform - # use first vertex color and spit out a warning - warn( - "changing `color_mode` from vertex -> uniform, will use first vertex color " - "for the uniform and discard the remaining color values" - ) - new_colors = self._create_colors_buffer(self._colors.value[0], "uniform") - self.world_object.geometry.colors = None - self.world_object.material.color = new_colors.value + # clear any event handlers from old feature + if self._colors is not None: + self._colors.clear_event_handlers() - # clear out cmap + if self._cmap is not None: self._cmap.clear_event_handlers() + self._cmap_transform.clear_event_handlers() self._cmap = None + self._cmap_transform = None + + # create the new buffer and set + self._colors = self._create_colors_buffer(value) + + match new_mode: + case "uniform": + self.world_object.material.color = self._colors.value + self.world_object.material.color_mode = "uniform" + self.world_object.geometry.colors = None + case "vertex": + self.world_object.geometry.colors = self._colors._fpl_buffer + self.world_object.material.color_mode = "vertex" + self.world_object.material.color = None + + if old_mode == "vertex_map": + # clear cmap world object stuff: map and texcoords + self.world_object.material.map = None + self.world_object.geometry.texcoords = None - elif mode == "vertex_map": - # TODO: handle new cmap stuff - pass - - else: - # no change, return - return - - # restore event handlers onto the new colors feature - new_colors._event_handlers[:] = self._colors._event_handlers - self._colors.clear_event_handlers() - # this should trigger gc - self._colors = new_colors - - # this is created so that cmap can be set later - if isinstance(self._colors, VertexColors): - self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) - - self.world_object.material.color_mode = mode + @property + def _color_mode(self) -> pygfx.enums.ColorMode: + """ + Get the current color mode. + """ + return self.world_object.material.color_mode @property def cmap(self) -> cmap_lib.Colormap | None: @@ -124,17 +142,39 @@ def cmap(self) -> cmap_lib.Colormap | None: if self._cmap is not None: return self._cmap.value - return None - @cmap.setter - def cmap(self, name: str): - if self.color_mode not in ("auto", "vertex_map"): - raise ValueError( - f"`color_mode` must be 'auto' or 'vertex_map' to set the cmap, " - f"the current `color_mode` is: {self.color_mode}" - ) + def cmap(self, value: cmap_lib.ColormapLike): + if self._cmap is not None: + self._cmap.set_value(self, value) + return + + # need to create cmap features + self._cmap, self._cmap_transform = self._create_cmap_buffers(value, self.cmap_transform) + + # set stuff on wo + self.world_object.material.map = self._cmap.value.to_pygfx() + self.world_object.geometry.texcoords = pygfx.Buffer(self._cmap_transform.value) + self.world_object.material.color_mode = "vertex_map" + + # clear any other color info + if self._colors is not None: + self._colors.clear_event_handlers() + self.world_object.geometry.colors = None + self.world_object.material.color = None + self._colors = None - self._cmap[:] = name + @property + def cmap_transform(self) -> np.ndarray | None: + # TODO: if a usecase arises in the future we can make this a BufferManager instead of a simple GraphicFeature + if self._cmap_transform is not None: + return self._cmap_transform.value + + @cmap_transform.setter + def cmap_transform(self, value: np.ndarray): + if self._cmap is None: + raise AttributeError("Must set `cmap` before setting `cmap_transform`") + + self._cmap_transform.set_value(self, value) @property def size_space(self): @@ -149,116 +189,37 @@ def size_space(self): def size_space(self, value: str): self._size_space.set_value(self, value) - def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColors: - # creates either a UniformColor or VertexColors based on the given `colors` and `color_mode` - # if `color_mode` = "auto", returns {UniformColor | VertexColor} based on what the `colors` arg represents - # if `color_mode` = "uniform", it verifies that the user `colors` input represents just 1 color - # if `color_mode` = "vertex", always returns VertexColors regardless of whether `colors` represents >= 1 colors - - if isinstance(colors, VertexColors): - if color_mode == "uniform": - raise ValueError( - "if a `VertexColors` instance is provided for `colors`, " - "`color_mode` must be 'vertex' or 'auto', not 'uniform'" - ) + def _create_colors_buffer(self, colors) -> UniformColor | VertexColors: + # creates either a UniformColor or VertexColors based on the given `colors` + + if isinstance(colors, VertexColors, UniformColor): # share buffer with existing colors instance - new_colors = colors - # blank colormap instance - self._cmap = VertexCmap(new_colors, cmap_name=None, transform=None) + return colors - else: - # determine if a single or multiple colors were passed and decide color mode - if isinstance(colors, (pygfx.Color, str)) or ( - len(colors) in [3, 4] and all(isinstance(v, Real) for v in colors) - ): - # one color specified as a str or pygfx.Color, or one color specified with RGB(A) values - if color_mode in ("auto", "uniform"): - new_colors = UniformColor(colors) - else: - new_colors = self._VertexColorsCls( - colors, n_colors=self._data.value.shape[0] - ) - - elif all(isinstance(c, (str, pygfx.Color)) for c in colors): - # sequence of colors - if color_mode == "uniform": - raise ValueError( - "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " - "`color_mode` = 'auto' or 'vertex' for multiple colors." - ) - new_colors = self._VertexColorsCls( - colors, n_colors=self._data.value.shape[0] - ) - - elif len(colors) > 4: - # sequence of multiple colors, must again ensure color_mode is not uniform - if color_mode == "uniform": - raise ValueError( - "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " - "`color_mode` = 'auto' or 'vertex' for multiple colors." - ) - new_colors = self._VertexColorsCls( - colors, n_colors=self._data.value.shape[0] - ) - else: - raise ValueError( - "`colors` must be a str, pygfx.Color, array, list or tuple indicating an RGB(A) color, or a " - "sequence of str, pygfx.Color, or array of shape [n_datapoints, 3 | 4]" - ) - - return new_colors + # determine if a single or multiple colors were passed and decide color mode + if is_single_color(colors): + # one color specified as a str or pygfx.Color, or one color specified with RGB(A) values + return UniformColor(colors) - def __init__( - self, - data: Any, - colors: str | ColorLike | MultiColorLike = "w", - cmap: str | cmap_lib.ColormapLike | None = None, - cmap_transform: np.ndarray | None = None, - color_mode: Literal["auto", "uniform", "vertex", "vertex_map"] = "auto", - size_space: str = "screen", - *args, - **kwargs, - ): - if isinstance(data, VertexPositions): - self._data = data else: - self._data = VertexPositions(data) - - if cmap_transform is not None and cmap is None: - raise ValueError("must pass `cmap` if passing `cmap_transform`") - - # defaults are None - self._cmap = None - self._cmap_transform = None - self._colors = None - - if color_mode not in VALID_COLOR_MODES: - raise ValueError(f"`color_mode` must be one of {VALID_COLOR_MODES}") - - if cmap is not None: - # if a cmap is specified it overrides colors argument - if color_mode != "vertex_map": - raise ValueError( - f"if a `cmap` is provided, `color_mode` must be 'vertex_cmap' or 'auto', not {color_mode}" - ) - - self._cmap = VertexCmap(cmap) - - if cmap_transform is None: - # default transform is just a linspace along the datapoints - cmap_transform = np.linspace(0, 1, len(self)) - else: - if len(cmap_transform) != len(self): - raise ValueError("`cmap_transform` must be a 1D array of the same size as the number of datapoints") + # sequence of colors + return self._VertexColorsCls( + colors, n_colors=self._data.value.shape[0] + ) - self._cmap_transform = VertexCmapTransform(cmap_transform) + def _create_cmap_buffers(self, cmap, cmap_transform) -> tuple[VertexCmap, VertexCmapTransform]: + cmap = VertexCmap(cmap) + if cmap_transform is None: + # default transform is just a linspace along the datapoints + cmap_transform = np.linspace(0, 1, len(self)) else: - # no cmap given - self._colors = self._create_colors_buffer(colors, color_mode) + if len(cmap_transform) != len(self): + raise ValueError("`cmap_transform` must be a 1D array of the same size as the number of datapoints") - self._size_space = SizeSpace(size_space) - super().__init__(*args, **kwargs) + cmap_transform = VertexCmapTransform(cmap_transform) + + return cmap, cmap_transform def format_pick_info(self, pick_info: dict) -> str: index = pick_info["vertex_index"] diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 5a4eacb4d..feb90ec79 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -15,6 +15,24 @@ block_reentrance, ) from .utils import parse_colors, is_single_color +from .types import ColorLike, MultiColorLike + + +def _normalize_min_max(a, vmin: float = None, vmax: float = None, gamma: float = 1.0): + """ + normalize an array between 0 - 1, clipped to (vmin, vmax) + """ + + vmin = np.min(a) if vmin is None else vmin + vmax = np.max(a) if vmax is None else vmax + + if vmax <= vmin: + return np.zeros(a.size) + + transform = np.clip((a - vmin) / (vmax - vmin), 0, 1) + if gamma == 1.0: + return transform + return transform**gamma class VertexColors(BufferManager): @@ -38,16 +56,16 @@ class VertexColors(BufferManager): def __init__( self, - colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], + colors: ColorLike | MultiColorLike, n_colors: int, property_name: str = "colors", ): """ - Manages the vertex color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` + Manages the vertex color buffer for :class:`PositionsGraphic` Parameters ---------- - colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str] + colors: ColorLike | MultiColorLike specify colors as a single human-readable string, RGBA array, or an iterable of strings or RGBA arrays @@ -62,7 +80,7 @@ def __init__( def set_value( self, graphic, - value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], + value: ColorLike | MultiColorLike, ): """set the entire array, create new buffer if necessary""" # a sequence of colors whose length differs from the current buffer requires a new buffer @@ -100,7 +118,7 @@ def set_value( def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], - user_value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], + user_value: ColorLike | MultiColorLike, ): user_key = key @@ -192,7 +210,7 @@ class UniformColor(GraphicFeature): def __init__( self, - value: str | pygfx.Color | np.ndarray | Sequence[float], + value: ColorLike, property_name: str = "colors", ): """Manages uniform color for line or scatter material""" @@ -206,7 +224,7 @@ def value(self) -> pygfx.Color: @block_reentrance def set_value( - self, graphic, value: str | pygfx.Color | np.ndarray | Sequence[float] + self, graphic, value: ColorLike ): value = pygfx.Color(value) graphic.world_object.material.color = value @@ -368,15 +386,12 @@ def value(self) -> cmap_lib.Colormap: @block_reentrance def set_value(self, graphic, value: cmap_lib.ColormapLike): self._value = cmap_lib.Colormap(value) - pygfx.TextureMap # directly set the material map using the TextureMap graphic.world_object.material.map = self._value.to_pygfx() - graphic.world_object.geometry.texcoords event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) - self.value.__rich_repr__() def __repr__(self): return self.value.__repr__() diff --git a/fastplotlib/graphics/_types.py b/fastplotlib/graphics/features/types.py similarity index 89% rename from fastplotlib/graphics/_types.py rename to fastplotlib/graphics/features/types.py index 27f8d11d6..702450101 100644 --- a/fastplotlib/graphics/_types.py +++ b/fastplotlib/graphics/features/types.py @@ -6,7 +6,7 @@ ArrayRGBA = np.ndarray[tuple[int, int, int] | tuple[int, int, int, int], np.dtype[np.number]] -ColorLike = RGB | RGBA | ArrayRGBA | pygfx.Color +ColorLike = RGB | RGBA | ArrayRGBA | pygfx.Color | str # [n, 3 | 4] RGBA array MultiColorArray = np.ndarray[tuple[int, int], np.dtype[np.number]] diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py index 59c62f354..875c5ec7c 100644 --- a/fastplotlib/graphics/features/utils.py +++ b/fastplotlib/graphics/features/utils.py @@ -1,3 +1,5 @@ +import numbers + import pygfx import numpy as np @@ -12,13 +14,24 @@ def is_single_color(value) -> bool: A single color is a str, ``pygfx.Color``, or an RGB(A) array/list/tuple of 3-4 numbers. """ if isinstance(value, np.ndarray): + # returns True if a 1D RGB(A) array + # returns False if shape is [n, 3 | 4] return value.shape in ((3,), (4,)) and value.dtype.kind in "fiu" if isinstance(value, (list, tuple)): - return len(value) in (3, 4) and all(isinstance(v, (float, int)) for v in value) + # returns True if RGB(A) list or tuple of int/float + # returns False otherwise + return len(value) in (3, 4) and all(isinstance(v, numbers.Real) for v in value) # str, pygfx.Color, or any other scalar color specifier - return True + if isinstance(value, (pygfx.Color, str)): + return True + + raise ValueError( + "`colors` must be a str, pygfx.Color, array, list or tuple indicating an RGB(A) color, a " + "sequence of str, pygfx.Color, and array of shape [n_datapoints, 3 | 4], or an existing " + "`UniformColor` or `VertexColors` instance." + ) def parse_colors( @@ -115,4 +128,4 @@ def get_element_format_from_numpy_array(array): f"A dtype of {array.dtype.name} is not supported for buffers, use a 32-bit variant instead." ) - return array.dtype.str.lstrip("<>=|") \ No newline at end of file + return array.dtype.str.lstrip("<>=|") diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 1dc8103f7..7dc3736cd 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -21,11 +21,11 @@ UniformColor, VertexCmap, SizeSpace, - UniformRotations, ) +from features.types import ColorLike, MultiColorLike from ..utils import quick_min_max from ._positions_base import PositionsGraphic, VALID_COLOR_MODES -from ._types import ColorLike, MultiColorLike +from features.types import ColorLike, MultiColorLike class LineGraphic(PositionsGraphic): _features = { @@ -42,7 +42,7 @@ def __init__( data: Any, thickness: float = 2.0, colors: str | ColorLike | MultiColorLike = "w", - cmap: str | cmap_lib.ColormapLike | None = None, + cmap: cmap_lib.ColormapLike | None = None, cmap_transform: np.ndarray | None = None, color_mode: Literal["auto", "uniform", "vertex", "vertex_map"] = "auto", size_space: str = "screen", @@ -68,7 +68,7 @@ def __init__( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - cmap: str, optional + cmap: cmap_lib.ColormapLike, optional Apply a colormap to the line instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ @@ -123,7 +123,7 @@ def __init__( ) world_object = pygfx.Line( - geometry=self._create_geometry(), + geometry=self._make_geo(), material=self._make_material(), ) @@ -140,28 +140,42 @@ def _get_material_kwargs(self) -> dict: depth_compare="<=", ) - if isinstance(self._colors, UniformColor): - kwargs["color_mode"] = "uniform" - kwargs["color"] = self.colors - elif self.cmap is not None: + if self._cmap is not None: kwargs["color_mode"] = "vertex_map" kwargs["map"] = self.cmap.to_pygfx() + elif isinstance(self._colors, UniformColor): + kwargs["color_mode"] = "uniform" + kwargs["color"] = self.colors else: kwargs["color_mode"] = "vertex" return kwargs + def _get_geo_kwargs(self) -> dict: + kwargs = dict( + positions=self._data._fpl_buffer + ) + + if self._cmap is not None: + # cmap overrides all + kwargs["texcoords"] = pygfx.Buffer(self._cmap_transform.value) + + elif isinstance(self._colors, VertexColors): + # per-vertex colors + kwargs["colors"] = self._colors._fpl_buffer + + # no additional kwargs for uniform color + + return kwargs + def _make_material(self) -> pygfx.LineMaterial: # create the pygfx material, subclasses override to use a different line material material_cls = pygfx.LineThinMaterial if self._thin else pygfx.LineMaterial return material_cls(**self._get_material_kwargs()) - def _create_geometry(self) -> pygfx.Geometry: - if isinstance(self._colors, UniformColor): - return pygfx.Geometry(positions=self._data._fpl_buffer) - return pygfx.Geometry( - positions=self._data._fpl_buffer, colors=self._colors._fpl_buffer - ) + def _make_geo(self) -> pygfx.Geometry: + kwargs = self._get_geo_kwargs() + return pygfx.Geometry(**kwargs) @property def thickness(self) -> float: