Source code for fastplotlib.graphics.scatter

from typing import *

import numpy as np
import pygfx

from ._positions_base import PositionsGraphic
from .features import (
    VertexPointSizes,
    UniformSize,
    SizeSpace,
    VertexPositions,
    VertexColors,
    UniformColor,
    VertexCmap,
    VertexMarkers,
    UniformMarker,
    UniformEdgeColor,
    EdgeWidth,
    UniformRotations,
    VertexRotations,
    TextureArray,
)


[docs] class ScatterGraphic(PositionsGraphic): _features = { "data": VertexPositions, "sizes": (VertexPointSizes, UniformSize), "colors": (VertexColors, UniformColor), "cmap": (VertexCmap, None), "markers": (VertexMarkers, UniformMarker, None), "edge_colors": (UniformEdgeColor, VertexColors, None), "edge_width": (EdgeWidth, None), "image": (TextureArray, None), "size_space": SizeSpace, "point_rotations": (UniformRotations, VertexRotations, None), } def __init__( self, data: Any, colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w", uniform_color: bool = False, cmap: str = None, cmap_transform: np.ndarray = None, mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: str | np.ndarray | Sequence[str] = "o", uniform_marker: bool = False, custom_sdf: str = None, edge_colors: str | np.ndarray | pygfx.Color | Sequence[float] = "black", uniform_edge_color: bool = True, edge_width: float = 1.0, image: np.ndarray = None, point_rotations: float | np.ndarray = 0, point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: float | np.ndarray | Sequence[float] = 1, uniform_size: bool = False, size_space: str = "screen", isolated_buffer: bool = True, **kwargs, ): """ Create a Scatter Graphic, 2d or 3d Parameters ---------- data: array-like Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3] colors: str, array, tuple, list, Sequence, 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 uniform_color: bool, default False if True, uses a uniform buffer for the scatter point colors. Useful if you need to save GPU VRAM when all points have the same color. cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this overrides any argument passed to "colors". For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap mode: one of: "markers", "simple", "gaussian", "image", default "markers" The scatter points mode, cannot be changed after the graphic has been created. * markers: represent points with various or custom markers, default * simple: all scatters points are simple circles * gaussian: each point is a gaussian blob * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites markers: None | str | np.ndarray | Sequence[str], default "o" The shape of the markers when `mode` is "markers" Supported values: * A string from pygfx.MarkerShape enum * Matplotlib compatible characters: "osD+x^v<>*". * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". * Emojis: "❤️♠️♣️♦️💎💍✳️📍". * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. uniform_marker: bool, default False Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use the same marker for all points and want to save GPU RAM. custom_sdf: str = None, The SDF code for the marker shape when the marker is set to custom. Can be used when `mode` is "markers". Negative values are inside the shape, positive values are outside the shape. The SDF's takes in two parameters `coords: vec2<f32>` and `size: f32`. The first is a WGSL coordinate and `size` is the overall size of the texture. The returned value should be the signed distance from any edge of the shape. Distances (positive and negative) that are less than half the `edge_width` in absolute terms will be colored with the `edge_color`. Other negative distances will be colored by `colors`. edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" edge color of the markers, used when `mode` is "markers" uniform_edge_color: bool, default True Set the same edge color for all markers. Useful for saving GPU RAM. edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". image: ArrayLike, optional renders an image at the scatter points, also known as sprites. The image color is multiplied with the point's "normal" color. point_rotations: float | ArrayLike = 0, The rotation of the scatter points in radians. Default 0. A single float rotation value can be set on all points, or an array of rotation values can be used to set per-point rotations point_rotation_mode: one of: "uniform" | "vertex" | "curve", default "uniform" * uniform: set the same rotation for every point, useful to save GPU RAM * vertex: set per-vertex rotations * curve: The rotation follows the curve of the line defined by the points (in screen space) sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points uniform_size: bool, default False if True, uses a uniform buffer for the scatter point sizes. Useful if you need to save GPU VRAM when all points have the same size. size_space: str, default "screen" coordinate space in which the size is expressed, one of ("screen", "world", "model") isolated_buffer: bool, default True whether the buffers should be isolated from the user input array. Generally always ``True``, ``False`` is for rare advanced use if you have large arrays. kwargs passed to :class:`.Graphic` """ super().__init__( data=data, colors=colors, uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, isolated_buffer=isolated_buffer, size_space=size_space, **kwargs, ) n_datapoints = self.data.value.shape[0] geo_kwargs = {"positions": self._data.buffer} aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") material_kwargs = dict( pick_write=True, aa=aa, depth_compare="<=", ) self._markers: VertexMarkers | UniformMarker | None = None self._edge_colors: UniformEdgeColor | VertexColors | None = None self._edge_width: EdgeWidth | None = None self._point_rotations: VertexRotations | UniformRotations | None = None self._image: TextureArray | None = None # material cannot be changed after the ScatterGraphic is created self._mode = mode match self.mode: case "markers": # default material = pygfx.PointsMarkerMaterial if uniform_marker: if not isinstance(markers, str): raise TypeError( "must pass a single <str> marker if uniform_marker is True" ) self._markers = UniformMarker(markers) material_kwargs["marker_mode"] = pygfx.MarkerMode.uniform material_kwargs["marker"] = self._markers.value else: material_kwargs["marker_mode"] = pygfx.MarkerMode.vertex self._markers = VertexMarkers(markers, n_datapoints) geo_kwargs["markers"] = self._markers.buffer if edge_colors is None: # interpret as no edge color edge_colors = (0, 0, 0, 0) if uniform_edge_color: if not isinstance(edge_colors, (str, pygfx.Color)): if len(edge_colors) not in [3, 4]: raise TypeError( f"if `uniform_edge_color` is True, then `edge_color` must be a str, pygfx.Color, " f"or an RGB(A) tuple, list, array representation of a single color. You have passed: " f"{edge_colors}" ) self._edge_colors = UniformEdgeColor(edge_colors) material_kwargs["edge_color"] = self._edge_colors.value material_kwargs["edge_color_mode"] = pygfx.ColorMode.uniform else: self._edge_colors = VertexColors( edge_colors, n_datapoints, property_name="edge_colors" ) material_kwargs["edge_color_mode"] = pygfx.ColorMode.vertex geo_kwargs["edge_colors"] = self._edge_colors.buffer self._edge_width = EdgeWidth(edge_width) material_kwargs["edge_width"] = self._edge_width.value material_kwargs["custom_sdf"] = custom_sdf case "simple": # basic points material material = pygfx.PointsMaterial case "gaussian": material = pygfx.PointsGaussianBlobMaterial case "image": material = pygfx.PointsSpriteMaterial # sprites should actually only be one texture, but we don't # want to create a new buffer manager just for sprites. # If someone is creating scatter plots with images of size # larger than the typical limit of 16384, I'm very curious # to know what they're trying to visualize shared = pygfx.renderers.wgpu.get_shared() limit = shared.device.limits["max-texture-dimension-2d"] if any([dim > limit for dim in image.shape]): raise BufferError( f"Scatter point image dimension is greater than the device texture limit." f"Your device limit is: {limit} but your image shape is: {image.shape}" ) # create texture array with normalized image self._image = TextureArray( image / np.nanmax(image), property_name="image" ) material_kwargs["sprite"] = self._image.buffer[0, 0] self._size_space = SizeSpace(size_space) if uniform_color: material_kwargs["color_mode"] = pygfx.ColorMode.uniform material_kwargs["color"] = self.colors else: material_kwargs["color_mode"] = pygfx.ColorMode.vertex geo_kwargs["colors"] = self.colors.buffer if uniform_size: material_kwargs["size_mode"] = pygfx.SizeMode.uniform self._sizes = UniformSize(sizes) material_kwargs["size"] = self.sizes else: material_kwargs["size_mode"] = pygfx.SizeMode.vertex self._sizes = VertexPointSizes(sizes, n_datapoints=n_datapoints) geo_kwargs["sizes"] = self.sizes.buffer match point_rotation_mode: case pygfx.enums.RotationMode.vertex: self._point_rotations = VertexRotations( point_rotations, n_datapoints=n_datapoints ) geo_kwargs["rotations"] = self._point_rotations.buffer case pygfx.enums.RotationMode.uniform: self._point_rotations = UniformRotations(point_rotations) case pygfx.enums.RotationMode.curve: pass # nothing special for curve rotation mode case _: raise ValueError( f"`point_rotation_mode` must be one of: {pygfx.enums.RotationMode}, " f"you have passed: {point_rotation_mode}" ) material_kwargs["rotation_mode"] = point_rotation_mode material_kwargs["size_space"] = self.size_space world_object = pygfx.Points( pygfx.Geometry(**geo_kwargs), material=material(**material_kwargs), ) self._set_world_object(world_object) @property def mode(self) -> str: """scatter point display mode""" return self._mode @property def markers(self) -> str | VertexMarkers | None: """markers if mode is 'marker'""" if isinstance(self._markers, VertexMarkers): return self._markers elif isinstance(self._markers, UniformMarker): return self._markers.value @markers.setter def markers(self, value: str | np.ndarray[str] | Sequence[str]): if self.mode != "markers": raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'markers' to set the markers" ) if isinstance(self._markers, VertexMarkers): self._markers[:] = value elif isinstance(self._markers, UniformMarker): self._markers.set_value(self, value) @property def edge_colors(self) -> str | pygfx.Color | VertexColors | None: """edge_colors if mode is 'marker'""" if isinstance(self._edge_colors, VertexColors): return self._edge_colors elif isinstance(self._edge_colors, UniformEdgeColor): return self._edge_colors.value @edge_colors.setter def edge_colors(self, value: str | np.ndarray | Sequence[str] | Sequence[float]): if self.mode != "markers": raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_colors" ) if isinstance(self._edge_colors, VertexColors): self._edge_colors[:] = value elif isinstance(self._edge_colors, UniformEdgeColor): self._edge_colors.set_value(self, value) @property def edge_width(self) -> float | None: """Get or set the edge_width if mode is 'markers'""" if self._edge_width is None: return None return self._edge_width.value @edge_width.setter def edge_width(self, value: float): if self.mode != "markers": raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_width" ) self._edge_width.set_value(self, value) @property def point_rotation_mode(self) -> str: """point rotation mode, read-only, one of 'uniform', 'vertex', or 'curve'""" return self.world_object.material.rotation_mode @property def point_rotations(self) -> VertexRotations | float | None: """rotation of each point, in radians, if `point_rotation_mode` is 'uniform' or 'vertex'""" if isinstance(self._point_rotations, VertexRotations): return self._point_rotations elif isinstance(self._point_rotations, UniformRotations): return self._point_rotations.value @point_rotations.setter def point_rotations(self, value: float | np.ndarray[float]): if self.point_rotation_mode not in ["uniform", "vertex"]: raise AttributeError( f"point_rotation_mode is: {self.point_rotation_mode}. " f"it be 'uniform' or 'vertex' to set the `point_rotations`" ) if isinstance(self._point_rotations, VertexRotations): self._point_rotations[:] = value elif isinstance(self._point_rotations, UniformRotations): self._point_rotations.set_value(self, value) @property def image(self) -> TextureArray | None: """Get or set the image data, returns None if scatter plot mode is not 'image'""" return self._image @image.setter def image(self, data): if self.mode != "image": raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'image' to set the image" ) self._image[:] = data @property def sizes(self) -> VertexPointSizes | float: """Get or set the scatter point size(s)""" if isinstance(self._sizes, VertexPointSizes): return self._sizes elif isinstance(self._sizes, UniformSize): return self._sizes.value @sizes.setter def sizes(self, value): if isinstance(self._sizes, VertexPointSizes): self._sizes[:] = value elif isinstance(self._sizes, UniformSize): self._sizes.set_value(self, value)