Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
c221cff
refactor cmap and cmap_transform for positional graphics
kushalkolar Aug 8, 2026
125a128
color mode stuff
kushalkolar Aug 8, 2026
5e0bd95
WIP
kushalkolar Aug 13, 2026
b269f07
more WIP
kushalkolar Aug 14, 2026
3914db6
better add_graphic autogen
kushalkolar Aug 19, 2026
419069a
more progress
kushalkolar Aug 19, 2026
8fe4054
mostly done refactoring positions graphics cmap stuff
kushalkolar Aug 20, 2026
ad2e1d0
forgot to update mixin
kushalkolar Aug 20, 2026
782017f
cmap_transform tweaks
kushalkolar Aug 24, 2026
b102462
wip, JaggedArray
kushalkolar Aug 30, 2026
1f3def7
mostly works, writing tests
kushalkolar Sep 1, 2026
42aceb9
remove a print
kushalkolar Sep 1, 2026
0284cfa
image collection examples
kushalkolar Sep 2, 2026
c9fe59d
fix
kushalkolar Sep 2, 2026
21c9f68
feature inheritance in PositionsGraphic and cmap_range
kushalkolar Sep 2, 2026
b1f8d2e
cmap handling
kushalkolar Sep 2, 2026
ebfa98a
cmap tweaks
kushalkolar Sep 3, 2026
bff755b
remove parse_cmap_values, rely on cmap lib
kushalkolar Sep 3, 2026
f9c23f2
fix example, remove unused line of code
kushalkolar Sep 3, 2026
665952c
Image just uses cmap lib now too
kushalkolar Sep 3, 2026
f43d25f
delete_ndgraphic()
kushalkolar Sep 3, 2026
e3035c6
ndtimeseries cmap example
kushalkolar Sep 3, 2026
a997728
steps for stacks
kushalkolar Sep 3, 2026
565a689
ndtimeseries sets stack steps
kushalkolar Sep 3, 2026
467029f
docstrings
kushalkolar Sep 5, 2026
bc844c0
docstrings
kushalkolar Sep 5, 2026
72371e1
comments
kushalkolar Sep 5, 2026
7fb798b
renames
kushalkolar Sep 5, 2026
d51d19c
docstring
kushalkolar Sep 5, 2026
29c95f2
fixes
kushalkolar Sep 8, 2026
687e10a
update tests/test_colors_buffer_manager.py
kushalkolar Sep 9, 2026
a23cfb0
update test_markers_buffer_manager.py
kushalkolar Sep 9, 2026
a9d9444
update plot_helpers test
kushalkolar Sep 9, 2026
c52e829
update test_point_rotations_buffer_manager.py
kushalkolar Sep 9, 2026
7246c96
update test_positions_graphics.py
kushalkolar Sep 9, 2026
3225331
update another test
kushalkolar Sep 9, 2026
ebbae33
more tests
kushalkolar Sep 9, 2026
a568853
update yet more tests
kushalkolar Sep 9, 2026
aec89c9
remove unused
kushalkolar Sep 9, 2026
a32d3af
infline tests
kushalkolar Sep 9, 2026
650fd29
collections tests
kushalkolar Sep 9, 2026
c024321
updating examples, wip
kushalkolar Sep 9, 2026
45ea886
more examples updates
kushalkolar Sep 9, 2026
4a8f444
more examples updates
kushalkolar Sep 9, 2026
92e34d9
more example updates
kushalkolar Sep 9, 2026
f0ddde1
more example updates
kushalkolar Sep 9, 2026
7b04a0d
example and docstring
kushalkolar Sep 9, 2026
55864b0
docstring
kushalkolar Sep 9, 2026
2554a3d
remove complex line collection slicing example, stick with real usecases
kushalkolar Sep 9, 2026
4148e3c
better line stack example
kushalkolar Sep 9, 2026
b6b80ba
update example
kushalkolar Sep 9, 2026
d6a8fb8
update example
kushalkolar Sep 9, 2026
712d94b
update link
kushalkolar Sep 9, 2026
2949c81
update more examples
kushalkolar Sep 9, 2026
a5dd607
examples and fixes
kushalkolar Sep 9, 2026
2d59e6a
fix
kushalkolar Sep 9, 2026
b57d0a4
more fixes
kushalkolar Sep 9, 2026
df7c823
fix
kushalkolar Sep 9, 2026
cfdfdca
updates
kushalkolar Sep 9, 2026
cad9ee4
Fix
kushalkolar Sep 9, 2026
dee42ec
update docs
kushalkolar Sep 9, 2026
05acb46
docs
kushalkolar Sep 9, 2026
87f74b0
add image collection to docs conf.py
kushalkolar Sep 9, 2026
911570f
update docs
kushalkolar Sep 9, 2026
d5f1da9
Merge branch 'ndwidget' into flat-gfeatures2
kushalkolar Sep 9, 2026
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
Prev Previous commit
Next Next commit
mostly done refactoring positions graphics cmap stuff
  • Loading branch information
kushalkolar committed Aug 20, 2026
commit 8fe405490120e118ea150e8d767abb12a86acd31
2 changes: 1 addition & 1 deletion examples/events/drag_points.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
line = figure[0, 0].add_line(data)

# add a scatter, share the line graphic buffer!
scatter = figure[0, 0].add_scatter(data=line.data, sizes=25, colors="r")
scatter = figure[0, 0].add_scatter(data=line.data, sizes=25, colors=["r"] * len(data))

is_moving = False
vertex_index = None
Expand Down
12 changes: 7 additions & 5 deletions examples/events/scatter_hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@
data, # the gaussian cloud
sizes=10, # some big points that are easy to click
cmap="viridis",
cmap_transform=np.linalg.norm(data, axis=1) # color points using distance from origin
cmap_transform=np.linalg.norm(data, axis=1), # color points using distance from origin
edge_colors=(1, 1, 1, 0),
uniform_edge_color=False,
uniform_size=False,
)

# simple dict to restore the original scatter color and size
# of the previously clicked point upon clicking a new point
old_props = {"index": None, "size": None, "color": None}
old_props = {"index": None, "size": None}


@scatter.add_event_handler("pointer_move")
Expand All @@ -46,16 +49,15 @@ def highlight_point(ev: pygfx.PointerEvent):
if new_index == old_index:
# same point, ignore
return
scatter.colors[old_index] = old_props["color"]
scatter.edge_colors[old_index] = (1, 1, 1, 0)
scatter.sizes[old_index] = old_props["size"]

# store the current property values of this new point
old_props["index"] = new_index
old_props["color"] = scatter.colors[new_index].copy() # if you do not copy you will just get a view of the array!
old_props["size"] = scatter.sizes[new_index]

# highlight this new point
scatter.colors[new_index] = "magenta"
scatter.edge_colors[new_index] = "magenta"
scatter.sizes[new_index] = 20


Expand Down
92 changes: 88 additions & 4 deletions fastplotlib/graphics/_positions_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
UniformColor,
VertexCmap,
VertexCmapTransform,
CmapTranformNormParam,
SizeSpace,
)
from .features.utils import is_single_color
from .features.utils import is_single_color, normalize_min_max
from .features.types import ColorLike, MultiColorLike, ColormapLike


Expand Down Expand Up @@ -46,6 +47,12 @@ def __init__(
self._cmap_transform = None
self._colors = None

# the cmap normalization params are created once and persist for the lifetime of the
# graphic; vmin, vmax default to None so the transform's own range is used
self._cmap_vmin = CmapTranformNormParam(None, "cmap_vmin")
self._cmap_vmax = CmapTranformNormParam(None, "cmap_vmax")
self._cmap_gamma = CmapTranformNormParam(1.0, "cmap_gamma")

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)
Expand Down Expand Up @@ -118,7 +125,7 @@ def colors(self, value: ColorLike | MultiColorLike):
case "vertex":
self.world_object.geometry.colors = self._colors._fpl_buffer
self.world_object.material.color_mode = "vertex"
self.world_object.material.color = None
self.world_object.material.color = (1, 1, 1, 1) # back to default, material.color cannot be None

if old_mode == "vertex_map":
# clear cmap world object stuff: map and texcoords
Expand Down Expand Up @@ -160,7 +167,7 @@ def cmap(self, value: cmap_lib.ColormapLike):
if self._colors is not None:
self._colors.clear_event_handlers()
self.world_object.geometry.colors = None
self.world_object.material.color = None
self.world_object.material.color = (1, 1, 1, 1) # back to default, material.color cannot be None
self._colors = None

@property
Expand All @@ -176,6 +183,42 @@ def cmap_transform(self, value: np.ndarray):

self._cmap_transform.set_value(self, value)

@property
def cmap_vmin(self) -> float | None:
"""Get or set the lower bound used to normalize the cmap_transform"""
if self._cmap_vmin is not None:
return self._cmap_vmin.value

@cmap_vmin.setter
def cmap_vmin(self, value: float):
if self._cmap is None:
raise AttributeError("Must set `cmap` before setting `cmap_vmin`")
self._cmap_vmin.set_value(self, value)

@property
def cmap_vmax(self) -> float | None:
"""Get or set the upper bound used to normalize the cmap_transform"""
if self._cmap_vmax is not None:
return self._cmap_vmax.value

@cmap_vmax.setter
def cmap_vmax(self, value: float):
if self._cmap is None:
raise AttributeError("Must set `cmap` before setting `cmap_vmax`")
self._cmap_vmax.set_value(self, value)

@property
def cmap_gamma(self) -> float | None:
"""Get or set the gamma applied when normalizing the cmap_transform"""
if self._cmap_gamma is not None:
return self._cmap_gamma.value

@cmap_gamma.setter
def cmap_gamma(self, value: float):
if self._cmap is None:
raise AttributeError("Must set `cmap` before setting `cmap_gamma`")
self._cmap_gamma.set_value(self, value)

@property
def size_space(self):
"""
Expand Down Expand Up @@ -217,10 +260,51 @@ def _create_cmap_buffers(self, cmap, cmap_transform) -> tuple[VertexCmap, Vertex
if len(cmap_transform) != len(self):
raise ValueError("`cmap_transform` must be a 1D array of the same size as the number of datapoints")

cmap_transform = VertexCmapTransform(cmap_transform)
# normalize the transform; the texcoords index into the colormap
cmap_transform = VertexCmapTransform(
normalize_min_max(
cmap_transform, self.cmap_vmin, self.cmap_vmax, self.cmap_gamma
)
)

return cmap, cmap_transform

def _get_material_kwargs(self) -> dict:
# material kwargs shared by all positions graphics; the color mode is
# determined by the current color/cmap state, subclasses add their own kwargs
kwargs = dict(
pick_write=True,
aa=self.alpha_mode in ("blend", "weighted_blend"),
depth_compare="<=",
)

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:
# geometry kwargs shared by all positions graphics, subclasses add their own kwargs
kwargs = dict(positions=self._data._fpl_buffer)

if self._cmap is not None:
# cmap overrides colors, uses per-vertex texcoords into the colormap
kwargs["texcoords"] = pygfx.Buffer(self._cmap_transform.value)
elif isinstance(self._colors, VertexColors):
kwargs["colors"] = self._colors._fpl_buffer
# uniform color needs no geometry buffer

return kwargs

def _make_geo(self) -> pygfx.Geometry:
return pygfx.Geometry(**self._get_geo_kwargs())

def format_pick_info(self, pick_info: dict) -> str:
index = pick_info["vertex_index"]
info = "\n".join(
Expand Down
2 changes: 2 additions & 0 deletions fastplotlib/graphics/features/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
VertexPositions,
VertexCmap,
VertexCmapTransform,
CmapTranformNormParam,
InfLineAxisData,
InfLineColors,
)
Expand Down Expand Up @@ -86,6 +87,7 @@
"SizeSpace",
"VertexPositions",
"VertexCmap",
"CmapTranformNormParam",
"InfLineAxisData",
"InfLineColors",
"MeshIndices",
Expand Down
6 changes: 3 additions & 3 deletions fastplotlib/graphics/features/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ def __setitem__(self, key, value):

def _parse_offset_size(
self,
key: int | slice | np.ndarray[int | bool] | list[bool | int],
key: int | slice | np.ndarray[tuple[int, ...], np.dtype[np.integer | np.bool]] | list[bool | int],
upper_bound: int,
):
"""
Expand Down Expand Up @@ -270,7 +270,7 @@ def _parse_offset_size(
def _update_range(
self,
key: (
int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...]
int | slice | np.ndarray[tuple[int, ...], np.dtype[np.integer | np.bool]] | list[bool | int] | tuple[slice, ...]
),
):
"""
Expand All @@ -285,7 +285,7 @@ def _update_range(
raise TypeError("ellipses not supported for indexing buffers")
# if multiple dims are sliced, we only need the key for
# the first dimension corresponding to n_datapoints
key: int | np.ndarray[int | bool] | slice = key[0]
key: int | np.ndarray[tuple[int, ...], np.dtype[np.integer | np.bool]] | slice = key[0]

if isinstance(key, slice):
if key == slice(None):
Expand Down
73 changes: 52 additions & 21 deletions fastplotlib/graphics/features/_positions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Sequence
from typing import Any, Literal, Sequence

import numpy as np
import pygfx
Expand All @@ -14,27 +14,10 @@
to_gpu_supported_dtype,
block_reentrance,
)
from .utils import parse_colors, is_single_color
from .utils import parse_colors, is_single_color, normalize_min_max
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):
event_info_spec = [
{
Expand Down Expand Up @@ -342,7 +325,7 @@ def set_value(self, graphic, value):
@block_reentrance
def __setitem__(
self,
key: int | slice | np.ndarray[int | bool] | tuple[slice, ...],
key: int | slice | np.ndarray[tuple[int, ...], np.dtype[np.integer | np.bool]] | tuple[slice, ...],
value: np.ndarray | float | list[float],
):
# directly use the key to slice the buffer and set the values
Expand Down Expand Up @@ -435,7 +418,8 @@ def set_value(self, graphic, value: np.ndarray):
)

if graphic.world_object.geometry.texcoords is not None:
graphic.world_object.geometry.texcoords[:] = value
graphic.world_object.geometry.texcoords.data[:] = value
graphic.world_object.geometry.texcoords.update_full()
else:
graphic.world_object.geometry.texcoords = pygfx.Buffer(self.value)

Expand All @@ -445,6 +429,53 @@ def set_value(self, graphic, value: np.ndarray):
self._call_event_handlers(event)


class CmapTranformNormParam(GraphicFeature):
"""
A scalar controlling how the cmap_transform is normalized into the colormap.

One instance is used for each of the ``cmap_vmin``, ``cmap_vmax``, and ``cmap_gamma``
of a positions graphic; the instance's ``property_name`` identifies which one it manages.
"""

event_info_spec = [
{
"dict key": "value",
"type": "float",
"description": "new value",
},
]

def __init__(
self, value: float, property_name: Literal["cmap_vmin", "cmap_vmax", "cmap_gamma"]
):
self._value = value
super().__init__(property_name=property_name)

@property
def value(self) -> float:
return self._value

@block_reentrance
def set_value(self, graphic, value: float):
self._value = value

# this instance manages one of vmin/vmax/gamma, identified by its property_name;
# combine it with the other two to normalize the cmap_transform into the texcoords
params = {
"vmin": graphic.cmap_vmin,
"vmax": graphic.cmap_vmax,
"gamma": graphic.cmap_gamma,
}
params[self._property_name.removeprefix("cmap_")] = value

texcoords = graphic.world_object.geometry.texcoords
texcoords.data[:] = normalize_min_max(graphic.cmap_transform, **params)
texcoords.update_range()

event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)


class InfLineAxisData(VertexPositions):
"""
Manages the positions buffer for :class:`InfLineGraphic`.
Expand Down
17 changes: 17 additions & 0 deletions fastplotlib/graphics/features/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,20 @@ def get_element_format_from_numpy_array(array):
)

return array.dtype.str.lstrip("<>=|")


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
Loading