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,
    VertexColors,
    VertexMarkers,
    UniformMarker,
    UniformEdgeColor,
    EdgeWidth,
    UniformRotations,
    VertexRotations,
    TextureArray,
)
from ..utils.types import ColorLike, MultiColorLike, ColormapLike
from .features.utils import is_single_color
from ..utils import global_config


[docs] @global_config.register class ScatterGraphic(PositionsGraphic): _features = { "sizes": (VertexPointSizes, UniformSize), "markers": (VertexMarkers, UniformMarker, None), "edge_colors": (UniformEdgeColor, VertexColors, None), "edge_width": (EdgeWidth, None), "image": (TextureArray, None), "point_rotations": (UniformRotations, VertexRotations, None), } @global_config.declare( "colors", "cmap", "mode", "markers", "custom_sdf", "edge_colors", "edge_width", "image", "point_rotations", "sizes", "size_space", ) def __init__( self, data: Any, colors: ColorLike | MultiColorLike = "w", cmap: ColormapLike | None = None, cmap_transform: np.ndarray | None = None, cmap_range: tuple[float, float] | None = None, mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: str | np.ndarray | Sequence[str] = "o", custom_sdf: str = None, edge_colors: ColorLike | MultiColorLike | None = "black", edge_width: float = 1.0, image: np.ndarray = None, point_rotations: float | np.ndarray | None = 0.0, sizes: float | np.ndarray | Sequence[float] = 5, size_space: str = "screen", **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: 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 cmap: ColormapLike, 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: np.ndarray, optional 1D array-like or list of numerical values, these values are used to map the colors from the cmap cmap_range: (float, float), optional the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range 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: str | np.ndarray | Sequence[str], default "o" The shape of the markers when `mode` is "markers". Specify a single marker to use the same marker for all points, or a Sequence of markers for per-vertex 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. 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: ColorLike, MultiColorLike, or None, default "black" edge color(s) of the markers, used when `mode` is "markers". Specify a single color to use the same edge color for all markers, or a Sequence of colors for per-vertex edge colors. Pass ``None`` for no edge color. edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". image: array-like, 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, array-like, or None, default 0.0 The rotation of the scatter points in radians. The rotation mode is determined automatically from the value: pass ``None`` (default) for "curve" mode, where each point's rotation follows the curve of the data (in screen space); a single float for the same rotation on every point ("uniform"); or an array of rotation values for per-point rotations ("vertex"). Units are in radians. sizes: float, np.ndarray, or Sequence[float], default 5 size(s) of the scatter points. Specify a single size to use the same size for all points, or a Sequence of sizes for per-point sizes. size_space: str, default "screen" coordinate space in which the size is expressed, one of ("screen", "world", "model") kwargs passed to :class:`.Graphic` """ super().__init__( data=data, colors=colors, cmap=cmap, cmap_transform=cmap_transform, cmap_range=cmap_range, size_space=size_space, **kwargs, ) n_datapoints = self.data.value.shape[0] 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 self._custom_sdf: str | None = None # material cannot be changed after the ScatterGraphic is created self._mode = mode match self._mode: case "markers": self._markers = self._create_markers_buffer(markers) self._edge_colors = self._create_edge_colors_buffer(edge_colors) self._edge_width = EdgeWidth(edge_width) self._custom_sdf = custom_sdf case "image": # 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" ) self._sizes = self._create_sizes_buffer(sizes) self._point_rotations = self._create_point_rotations_buffer(point_rotations) world_object = pygfx.Points( geometry=self._make_geo(), material=self._make_material(), ) self._set_world_object(world_object) def _make_material(self) -> pygfx.PointsMaterial: # create the pygfx material, the material class is determined by the scatter mode material_cls = { "markers": pygfx.PointsMarkerMaterial, "simple": pygfx.PointsMaterial, "gaussian": pygfx.PointsGaussianBlobMaterial, "image": pygfx.PointsSpriteMaterial, }[self._mode] return material_cls(**self._get_material_kwargs()) def _get_material_kwargs(self) -> dict: # pygfx points material kwargs assembled from the current feature state kwargs = super()._get_material_kwargs() kwargs["size_space"] = self.size_space if isinstance(self._sizes, UniformSize): kwargs["size_mode"] = pygfx.SizeMode.uniform kwargs["size"] = self.sizes else: kwargs["size_mode"] = pygfx.SizeMode.vertex if isinstance(self._point_rotations, VertexRotations): kwargs["rotation_mode"] = pygfx.enums.RotationMode.vertex elif isinstance(self._point_rotations, UniformRotations): kwargs["rotation_mode"] = pygfx.enums.RotationMode.uniform kwargs["rotation"] = self._point_rotations.value else: kwargs["rotation_mode"] = pygfx.enums.RotationMode.curve match self._mode: case "markers": if isinstance(self._markers, UniformMarker): kwargs["marker_mode"] = pygfx.MarkerMode.uniform kwargs["marker"] = self._markers.value else: kwargs["marker_mode"] = pygfx.MarkerMode.vertex if isinstance(self._edge_colors, UniformEdgeColor): kwargs["edge_color_mode"] = pygfx.ColorMode.uniform kwargs["edge_color"] = self._edge_colors.value else: kwargs["edge_color_mode"] = pygfx.ColorMode.vertex kwargs["edge_width"] = self._edge_width.value kwargs["custom_sdf"] = self._custom_sdf case "image": kwargs["sprite"] = self._image.buffer[0, 0] return kwargs def _get_geo_kwargs(self) -> dict: # pygfx points geometry kwargs assembled from the current feature state kwargs = super()._get_geo_kwargs() if isinstance(self._sizes, VertexPointSizes): kwargs["sizes"] = self._sizes._fpl_buffer if isinstance(self._point_rotations, VertexRotations): kwargs["rotations"] = self._point_rotations._fpl_buffer if self._mode == "markers": if isinstance(self._markers, VertexMarkers): kwargs["markers"] = self._markers._fpl_buffer if isinstance(self._edge_colors, VertexColors): kwargs["edge_colors"] = self._edge_colors._fpl_buffer return kwargs def _create_markers_buffer(self, markers) -> UniformMarker | VertexMarkers: # creates either a UniformMarker or VertexMarkers based on the given `markers` if isinstance(markers, (VertexMarkers, UniformMarker)): # share buffer with existing markers instance return markers # a single marker is a str, a sequence is one marker per datapoint if isinstance(markers, str): return UniformMarker(markers) else: return VertexMarkers(markers, n_datapoints=self._data.value.shape[0]) def _create_edge_colors_buffer( self, edge_colors ) -> UniformEdgeColor | VertexColors: # creates either a UniformEdgeColor or VertexColors based on the given `edge_colors` if edge_colors is None: # interpret as no edge color edge_colors = (0, 0, 0, 0) if isinstance(edge_colors, (VertexColors, UniformEdgeColor)): # share buffer with existing edge_colors instance return edge_colors # determine if a single or multiple colors were passed and decide edge_color_mode if is_single_color(edge_colors): # one color specified as a str or pygfx.Color, or one color specified with RGB(A) values return UniformEdgeColor(edge_colors) else: # sequence of colors, one edge color per datapoint return VertexColors( edge_colors, n_colors=self._data.value.shape[0], property_name="edge_colors", ) def _create_sizes_buffer(self, sizes) -> UniformSize | VertexPointSizes: # creates either a UniformSize or VertexPointSizes based on the given `sizes` if isinstance(sizes, (VertexPointSizes, UniformSize)): # share buffer with existing sizes instance return sizes # a single size is a scalar, a sequence is one size per datapoint if isinstance(sizes, (np.ndarray, list, tuple)): return VertexPointSizes(sizes, n_datapoints=self._data.value.shape[0]) else: return UniformSize(sizes) def _create_point_rotations_buffer( self, point_rotations ) -> UniformRotations | VertexRotations | None: # None -> curve mode (no feature, rotation follows the data curve), a single value -> # uniform, a sequence -> vertex if isinstance(point_rotations, (VertexRotations, UniformRotations)): # share buffer with existing point_rotations instance return point_rotations if point_rotations is None: return None if isinstance(point_rotations, (np.ndarray, list, tuple)): return VertexRotations( point_rotations, n_datapoints=self._data.value.shape[0] ) else: return UniformRotations(point_rotations) @property def mode(self) -> str: """scatter point display mode""" return self._mode @property def markers(self) -> str | VertexMarkers | None: """Get or set the markers, if mode is 'markers'""" 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" ) # currently per-vertex: stay per-vertex, broadcasting a single marker or setting a sequence if isinstance(self._markers, VertexMarkers): self._markers.set_value(self, value) return # currently uniform: a single marker stays uniform if isinstance(value, str): self._markers.set_value(self, value) return # currently uniform and a sequence was passed: switch uniform -> vertex self._markers.clear_event_handlers() self._markers = self._create_markers_buffer(value) self.world_object.geometry.markers = self._markers._fpl_buffer self.world_object.material.marker_mode = "vertex" @property def edge_colors(self) -> VertexColors | pygfx.Color | None: """Get or set the marker edge colors, if mode is 'markers'""" 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: ColorLike | MultiColorLike | None): if self.mode != "markers": raise AttributeError( f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_colors" ) if value is None: # interpret as no edge color value = (0, 0, 0, 0) # currently per-vertex: stay per-vertex, broadcasting a single color or setting a sequence if isinstance(self._edge_colors, VertexColors): self._edge_colors.set_value(self, value) return # currently uniform: a single color stays uniform if is_single_color(value): self._edge_colors.set_value(self, value) return # currently uniform and a sequence was passed: switch uniform -> vertex self._edge_colors.clear_event_handlers() self._edge_colors = self._create_edge_colors_buffer(value) self.world_object.geometry.edge_colors = self._edge_colors._fpl_buffer self.world_object.material.edge_color_mode = "vertex" @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: """Get or set the point rotations in radians; returns None in 'curve' mode""" 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[tuple[int], np.dtype[np.number]] | None ): # None selects curve mode, where the rotation follows the data curve if value is None: if self._point_rotations is not None: self._point_rotations.clear_event_handlers() self._point_rotations = None self.world_object.material.rotation_mode = "curve" self.world_object.geometry.rotations = None return # currently per-vertex: stay per-vertex, broadcasting a single value or setting a sequence if isinstance(self._point_rotations, VertexRotations): self._point_rotations.set_value(self, value) return # currently uniform: a single value stays uniform if isinstance(self._point_rotations, UniformRotations) and not isinstance( value, (np.ndarray, list, tuple) ): self._point_rotations.set_value(self, value) return # switch to the mode the value implies (from uniform, or from curve which has no feature) if self._point_rotations is not None: self._point_rotations.clear_event_handlers() self._point_rotations = self._create_point_rotations_buffer(value) if isinstance(self._point_rotations, VertexRotations): self.world_object.geometry.rotations = self._point_rotations._fpl_buffer self.world_object.material.rotation_mode = "vertex" else: self.world_object.material.rotation = self._point_rotations.value self.world_object.material.rotation_mode = "uniform" self.world_object.geometry.rotations = None @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: float | np.ndarray | Sequence[float]): # currently per-vertex: stay per-vertex, broadcasting a single value or setting a sequence if isinstance(self._sizes, VertexPointSizes): self._sizes.set_value(self, value) return # currently uniform: a single value stays uniform if not isinstance(value, (np.ndarray, list, tuple)): self._sizes.set_value(self, value) return # currently uniform and a sequence was passed: switch uniform -> vertex self._sizes.clear_event_handlers() self._sizes = self._create_sizes_buffer(value) self.world_object.geometry.sizes = self._sizes._fpl_buffer self.world_object.material.size_mode = "vertex"
[docs] def create_legend_item( self, label: str = None, markers_labels: dict[str, str] = None, sizes_labels: dict[float, str] = None, cmap_transform_labels: dict[int, str] = None, ): """ Create the :class:`.ScatterLegendItem` of this scatter, add it to a legend with ``Legend.add()``. The item follows the scatter: when its colors, colormap, markers, sizes or edges change the item changes with them. Per-vertex colors cannot be represented in a legend, and only a scatter with ``mode="markers"`` is supported. A per-point ``markers`` or ``sizes`` needs its labels, each one adds an element per labelled value. The other features of those elements are drawn with the scatter's uniform value, or with the value of its first point when that feature is per-point too. Parameters ---------- label: str, optional label of the scatter in the legend, its ``name`` is used if not provided markers_labels: dict, optional {marker: label}, the label of each marker sizes_labels: dict, optional {size: label}, the label of each point size cmap_transform_labels: dict, optional {cmap_transform value: label}, the label of each value of a qualitative colormap. A quantitative colormap is shown as a colorbar instead and needs no labels. Returns ------- ScatterLegendItem """ self._check_legend_item() from ..ui._legend import ScatterLegendItem self._legend_item = ScatterLegendItem( self, label=label, markers_labels=markers_labels, sizes_labels=sizes_labels, cmap_transform_labels=cmap_transform_labels, ) return self._legend_item