From fffc3085ca6f962ffdbf6c0233e071f7f76fb84e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 8 Jul 2025 10:50:12 +0200 Subject: [PATCH] Refactor handling of alpha --- .../api/graphic_features/VertexCmap.rst | 1 - examples/gridplot/multigraphic_gridplot.py | 13 ++----- fastplotlib/graphics/_positions_base.py | 20 +++++----- .../graphics/features/_positions_graphics.py | 38 +++++++------------ fastplotlib/graphics/features/utils.py | 31 ++++++--------- fastplotlib/graphics/line_collection.py | 28 +++++++------- 6 files changed, 51 insertions(+), 80 deletions(-) diff --git a/docs/source/api/graphic_features/VertexCmap.rst b/docs/source/api/graphic_features/VertexCmap.rst index 77d96aaf6..fd1013620 100644 --- a/docs/source/api/graphic_features/VertexCmap.rst +++ b/docs/source/api/graphic_features/VertexCmap.rst @@ -20,7 +20,6 @@ Properties .. autosummary:: :toctree: VertexCmap_api - VertexCmap.alpha VertexCmap.buffer VertexCmap.name VertexCmap.shared diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py index 8408f4f23..73a862a26 100644 --- a/examples/gridplot/multigraphic_gridplot.py +++ b/examples/gridplot/multigraphic_gridplot.py @@ -17,7 +17,7 @@ figure = fpl.Figure( shape=(2, 2), names=[["image-overlay", "circles"], ["line-stack", "scatter"]], - size=(700, 560) + size=(700, 560), ) img = iio.imread("imageio:coffee.png") @@ -36,6 +36,7 @@ # add overlay to image figure["image-overlay"].add_image(data=overlay) + # generate some circles def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: theta = np.linspace(0, 2 * np.pi, n_points) @@ -54,12 +55,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: circles.append(make_circle(center, 5, n_points=75)) # things like class labels, cluster labels, etc. -cmap_transform = [ - 0, 1, 1, 2, - 0, 0, 1, 1, - 2, 2, 8, 3, - 1, 9, 1, 5 -] +cmap_transform = [0, 1, 1, 2, 0, 0, 1, 1, 2, 2, 8, 3, 1, 9, 1, 5] # add an image to overlay the circles on img2 = iio.imread("imageio:coins.png")[10::5, 5::5] @@ -73,7 +69,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: cmap_transform=cmap_transform, thickness=3, alpha=0.5, - name="circles-graphic" + name="circles-graphic", ) # move the circles graphic so that it is centered over the image @@ -115,4 +111,3 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: if __name__ == "__main__": print(__doc__) fpl.loop.run() - diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 8b127aa19..dd1a4f9a5 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -46,7 +46,7 @@ def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str @property def cmap(self) -> VertexCmap: """ - Control the cmap, cmap transform, or cmap alpha + Control the cmap or cmap transform For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ """ @@ -112,7 +112,6 @@ def __init__( self._colors, cmap_name=cmap, transform=cmap_transform, - alpha=alpha, ) elif isinstance(cmap, VertexCmap): # use existing cmap instance @@ -129,9 +128,7 @@ def __init__( self._colors = colors self._colors._shared += 1 # blank colormap instance - self._cmap = VertexCmap( - self._colors, cmap_name=None, transform=None, alpha=alpha - ) + self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) else: if uniform_color: if not isinstance(colors, str): # not a single color @@ -139,21 +136,24 @@ def __init__( raise TypeError( "must pass a single color if using `uniform_colors=True`" ) - self._colors = UniformColor(colors, alpha=alpha) + self._colors = UniformColor(colors) self._cmap = None else: self._colors = VertexColors( - colors, - n_colors=self._data.value.shape[0], - alpha=alpha, + colors, n_colors=self._data.value.shape[0] ) self._cmap = VertexCmap( - self._colors, cmap_name=None, transform=None, alpha=alpha + self._colors, cmap_name=None, transform=None ) self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) + # Set the object's opacity. Note that setting this to < 1 will turn the object from opaque to transparent + self.world_object.material.opacity = alpha + # self.world_object.material.alpha_mode = "blend" if alpha < 1 else "opaque" # automatic + # TODO: should we add an alpha property on the graphic? + def unshare_property(self, property: str): """unshare a shared property. Experimental and untested!""" if not isinstance(property, str): diff --git a/fastplotlib/graphics/features/_positions_graphics.py b/fastplotlib/graphics/features/_positions_graphics.py index 868701079..f21e68de8 100644 --- a/fastplotlib/graphics/features/_positions_graphics.py +++ b/fastplotlib/graphics/features/_positions_graphics.py @@ -40,7 +40,6 @@ def __init__( self, colors: str | np.ndarray | tuple[float] | list[float] | list[str], n_colors: int, - alpha: float = None, isolated_buffer: bool = True, ): """ @@ -55,11 +54,8 @@ def __init__( n_colors: int number of colors, if passing in a single str or single RGBA array - alpha: float, optional - alpha value for the colors - """ - data = parse_colors(colors, n_colors, alpha) + data = parse_colors(colors, n_colors) super().__init__(data=data, isolated_buffer=isolated_buffer) @@ -158,13 +154,10 @@ class UniformColor(GraphicFeature): }, ] - def __init__( - self, value: str | np.ndarray | tuple | list | pygfx.Color, alpha: float = 1.0 - ): + def __init__(self, value: str | np.ndarray | tuple | list | pygfx.Color): """Manages uniform color for line or scatter material""" - v = (*tuple(pygfx.Color(value))[:-1], alpha) # apply alpha - self._value = pygfx.Color(v) + self._value = pygfx.Color(value) super().__init__() @property @@ -427,7 +420,6 @@ def __init__( vertex_colors: VertexColors, cmap_name: str | None, transform: np.ndarray | None, - alpha: float = 1.0, ): """ Sliceable colormap feature, manages a VertexColors instance and @@ -439,7 +431,6 @@ def __init__( self._vertex_colors = vertex_colors self._cmap_name = cmap_name self._transform = transform - self._alpha = alpha if self._cmap_name is not None: if not isinstance(self._cmap_name, str): @@ -457,7 +448,6 @@ def __init__( cmap_name=self._cmap_name, transform=self._transform, ) - colors[:, -1] = alpha # set vertex colors from cmap self._vertex_colors[:] = colors @@ -481,7 +471,6 @@ def __setitem__(self, key: slice, cmap_name): colors = parse_cmap_values( n_colors=n_elements, cmap_name=cmap_name, transform=self._transform ) - colors[:, -1] = self.alpha self._cmap_name = cmap_name self._vertex_colors[key] = colors @@ -517,8 +506,6 @@ def transform( n_colors=self.value.shape[0], cmap_name=self._cmap_name, transform=values ) - colors[:, -1] = self.alpha - self._transform = values if indices is None: @@ -528,17 +515,18 @@ def transform( self._emit_event("cmap.transform", indices, values) - @property - def alpha(self) -> float: - """Get or set the alpha level""" - return self._alpha + # TODO: is this property used a lot? Can we afford to remove it? Maybe raise a helpful exception? + # @property + # def alpha(self) -> float: + # """Get or set the alpha level""" + # return self._alpha - @alpha.setter - def alpha(self, value: float, indices: slice | list | np.ndarray = None): - self._vertex_colors[indices, -1] = value - self._alpha = value + # @alpha.setter + # def alpha(self, value: float, indices: slice | list | np.ndarray = None): + # self._vertex_colors[indices, -1] = value + # self._alpha = value - self._emit_event("cmap.alpha", indices, value) + # self._emit_event("cmap.alpha", indices, value) def __len__(self): raise NotImplementedError( diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py index e2f6e3428..408610e1e 100644 --- a/fastplotlib/graphics/features/utils.py +++ b/fastplotlib/graphics/features/utils.py @@ -6,9 +6,7 @@ def parse_colors( - colors: str | np.ndarray | list[str] | tuple[str], - n_colors: int | None, - alpha: float | None = None, + colors: str | np.ndarray | list[str] | tuple[str], n_colors: int | None ): """ @@ -16,8 +14,6 @@ def parse_colors( ---------- colors n_colors - alpha - key Returns ------- @@ -30,20 +26,22 @@ def parse_colors( colors = colors.tolist() # if the color is provided as a numpy array if isinstance(colors, np.ndarray): - if colors.shape == (4,): # single RGBA array + if colors.shape == (3,): # single RGB array + data = np.repeat(np.array([colors]), n_colors, axis=0) + elif 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: + if not (colors.shape[1] in (3, 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]" + "RGB or RGBA arrays for each datapoint in the shape [n_datapoints, 3] or [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]" + "Valid array color arguments must be a single RGB(A) array or a stack of " + "RGB(A) arrays for each datapoint in the shape [n_datapoints, 3] or [n_datapoints, 4]" ) # if the color is provided as list or tuple @@ -58,8 +56,8 @@ def parse_colors( 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: + # if it's a single RGB/RGBA array as a tuple/list + elif len(colors) in (3, 4): c = pygfx.Color(colors) data = np.repeat(np.array([c]), n_colors, axis=0) @@ -70,18 +68,11 @@ def parse_colors( ) elif isinstance(colors, str): if colors == "random": - data = np.random.rand(n_colors, 4) - data[:, -1] = alpha + data = np.random.rand(n_colors, 3) 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 to_gpu_supported_dtype(data) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index de4139679..02e5d728e 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -67,7 +67,7 @@ def cmap(self) -> CollectionFeature: """ Get or set a cmap along the line collection. - Optionally set using a tuple ("cmap", , ) to set the transform and/or alpha. + Optionally set using a tuple ("cmap", ) to set the transform.. Example: line_collection.cmap = ("jet", sine_transform_vals, 0.7) @@ -79,23 +79,20 @@ def cmap(self) -> CollectionFeature: def cmap(self, args): if isinstance(args, str): name = args - transform, alpha = None, 1.0 + transform = None elif len(args) == 1: name = args[0] - transform, alpha = None, None - + transform = None elif len(args) == 2: name, transform = args - alpha = None - - elif len(args) == 3: - name, transform, alpha = args + else: + raise ValueError( + "Too many values for cmap (note that alpha is deprecated, set alpha on the graphic instead)" + ) - colors = parse_cmap_values( + self.colors = parse_cmap_values( n_colors=len(self), cmap_name=name, transform=transform ) - colors[:, -1] = alpha - self.colors = colors @property def thickness(self) -> np.ndarray: @@ -160,7 +157,8 @@ def __init__( | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line alpha: float, optional - alpha value for colors, if colors is a ``str`` + The uniform opacity of the object. If a list of colors is given, these can be RGBA, and their alpha + component is multiplied with the uniform opacity. cmap: Iterable of str or str, optional | if ``str``, single cmap will be used for all lines @@ -248,7 +246,7 @@ def __init__( else: if isinstance(colors, np.ndarray): # single color for all lines in the collection as RGBA - if colors.shape == (4,): + if colors.shape in [(3,), (4,)]: single_color = True # colors specified for each line as array of shape [n_lines, RGBA] @@ -263,8 +261,7 @@ def __init__( elif isinstance(colors, str): if colors == "random": - colors = np.random.rand(len(data), 4) - colors[:, -1] = alpha + colors = np.random.rand(len(data), 3) single_color = False else: # parse string color @@ -325,6 +322,7 @@ def __init__( colors=_c, uniform_color=uniform_colors, cmap=_cmap, + alpha=alpha, name=_name, metadata=_m, isolated_buffer=isolated_buffer,