Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 141 additions & 177 deletions fastplotlib/graphics/_positions_base.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions fastplotlib/graphics/features/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
SizeSpace,
VertexPositions,
VertexCmap,
VertexCmapTransform,
InfLineAxisData,
InfLineColors,
)
Expand Down
188 changes: 80 additions & 108 deletions fastplotlib/graphics/features/_positions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import numpy as np
import pygfx
import cmap as cmap_lib

from ...utils import (
parse_cmap_values,
Expand All @@ -14,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):
Expand All @@ -37,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

Expand All @@ -61,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
Expand Down Expand Up @@ -99,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

Expand Down Expand Up @@ -191,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"""
Expand All @@ -205,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
Expand Down Expand Up @@ -339,138 +358,91 @@ 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 <str>, 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)

# parse slice
start, stop, step = key.indices(self.value.shape[0])
n_elements = len(range(start, stop, step))
# directly set the material map using the TextureMap
graphic.world_object.material.map = self._value.to_pygfx()

colors = parse_cmap_values(
n_colors=n_elements, cmap_name=cmap_name, transform=self._transform
)
event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)

self._cmap_name = cmap_name
self._vertex_colors[key] = colors
def __repr__(self):
return self.value.__repr__()

# 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_html_(self):
return self.value._repr_html_()

@property
def name(self) -> str:
return self._cmap_name
def _repr_png(self):
return self.value._repr_png_()

@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

@transform.setter
def transform(
self,
values: np.ndarray | list[float | int],
indices: slice | list | np.ndarray = None,
):
if self._cmap_name is None:
raise AttributeError(
"cmap name is not set, set the cmap name before setting the transform"
)
class VertexCmapTransform(GraphicFeature):
event_info_spec = [
{
"dict key": "value",
"type": "np.ndarray",
"description": "colormap transform",
},
]

values = np.asarray(values)
def __init__(self, value: np.ndarray, property_name: str = "cmap_transform"):
"""colormap transform"""

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):
Expand Down
14 changes: 14 additions & 0 deletions fastplotlib/graphics/features/types.py
Original file line number Diff line number Diff line change
@@ -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 | str

# [n, 3 | 4] RGBA array
MultiColorArray = np.ndarray[tuple[int, int], np.dtype[np.number]]

MultiColorLike = tuple[ColorLike] | list[ColorLike] | MultiColorArray
19 changes: 16 additions & 3 deletions fastplotlib/graphics/features/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import numbers

import pygfx
import numpy as np

Expand All @@ -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(
Expand Down Expand Up @@ -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("<>=|")
return array.dtype.str.lstrip("<>=|")
2 changes: 1 addition & 1 deletion fastplotlib/graphics/inf_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading