Skip to content

Commit

Permalink
Merge pull request #78 from kushalkolar/graphic-attrs
Browse files Browse the repository at this point in the history
indexable graphic attributes
  • Loading branch information
kushalkolar committed Dec 23, 2022
2 parents e671cab + 22f7283 commit 088f032
Show file tree
Hide file tree
Showing 19 changed files with 1,092 additions and 229 deletions.
20 changes: 10 additions & 10 deletions examples/gridplot.ipynb

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions examples/gridplot_simple.ipynb

Large diffs are not rendered by default.

19 changes: 8 additions & 11 deletions examples/lineplot.ipynb

Large diffs are not rendered by default.

86 changes: 66 additions & 20 deletions examples/scatter.ipynb

Large diffs are not rendered by default.

403 changes: 357 additions & 46 deletions examples/simple.ipynb

Large diffs are not rendered by default.

104 changes: 70 additions & 34 deletions fastplotlib/graphics/_base.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,106 @@
from typing import Any
from typing import *

import numpy as np
import pygfx

from fastplotlib.utils import get_colors, map_labels_to_colors
from ..utils import get_colors
from .features import GraphicFeature, DataFeature, ColorFeature, PresentFeature


class Graphic:
def __init__(
self,
data,
colors: np.ndarray = None,
colors_length: int = None,
colors: Any = False,
n_colors: int = None,
cmap: str = None,
alpha: float = 1.0,
name: str = None
):
self.data = data.astype(np.float32)
"""
Parameters
----------
data: array-like
data to show in the graphic, must be float32.
Automatically converted to float32 for numpy arrays.
Tensorflow Tensors also work but this is not fully
tested and might not be supported in the future.
colors: Any
if ``False``, no color generation is performed, cmap is also ignored.
n_colors
cmap: str
name of colormap to use
alpha: float, optional
alpha value for the colors
name: str, optional
name this graphic, makes it indexable within plots
"""
# self.data = data.astype(np.float32)
self.data = DataFeature(parent=self, data=data, graphic_name=self.__class__.__name__)
self.colors = None

self.name = name

# if colors_length is None:
# colors_length = self.data.shape[0]
if n_colors is None:
n_colors = self.data.feature_data.shape[0]

if cmap is not None and colors is not False:
colors = get_colors(n_colors=n_colors, cmap=cmap, alpha=alpha)

if colors is not False:
self._set_colors(colors, colors_length, cmap, alpha, )
self.colors = ColorFeature(parent=self, colors=colors, n_colors=n_colors, alpha=alpha)

def _set_colors(self, colors, colors_length, cmap, alpha):
if colors_length is None:
colors_length = self.data.shape[0]
# different from visible, toggles the Graphic presence in the Scene
# useful for bbox calculations to ignore these Graphics
self.present = PresentFeature(parent=self)

if colors is None and cmap is None: # just white
self.colors = np.vstack([[1., 1., 1., 1.]] * colors_length).astype(np.float32)
valid_features = ["visible"]
for attr_name in self.__dict__.keys():
attr = getattr(self, attr_name)
if isinstance(attr, GraphicFeature):
valid_features.append(attr_name)

elif (colors is None) and (cmap is not None):
self.colors = get_colors(n_colors=colors_length, cmap=cmap, alpha=alpha)
self._valid_features = tuple(valid_features)

elif (colors is not None) and (cmap is None):
# assume it's already an RGBA array
colors = np.array(colors)
if colors.shape == (1, 4) or colors.shape == (4,):
self.colors = np.vstack([colors] * colors_length).astype(np.float32)
elif colors.ndim == 2 and colors.shape[1] == 4 and colors.shape[0] == colors_length:
self.colors = colors.astype(np.float32)
else:
raise ValueError(f"Colors array must have ndim == 2 and shape of [<n_datapoints>, 4]")
@property
def world_object(self) -> pygfx.WorldObject:
return self._world_object

elif (colors is not None) and (cmap is not None):
if colors.ndim == 1 and np.issubdtype(colors.dtype, np.integer):
# assume it's a mapping of colors
self.colors = np.array(map_labels_to_colors(colors, cmap, alpha=alpha)).astype(np.float32)
@property
def interact_features(self) -> Tuple[str]:
"""The features for this ``Graphic`` that support interaction."""
return self._valid_features

else:
raise ValueError("Unknown color format")
@property
def visible(self) -> bool:
return self.world_object.visible

@visible.setter
def visible(self, v):
"""Toggle the visibility of this Graphic"""
self.world_object.visible = v

@property
def children(self) -> pygfx.WorldObject:
return self.world_object.children

def update_data(self, data: Any):
pass
def __setattr__(self, key, value):
if hasattr(self, key):
attr = getattr(self, key)
if isinstance(attr, GraphicFeature):
attr._set(value)
return

super().__setattr__(key, value)

def __repr__(self):
if self.name is not None:
return f"'{self.name}' fastplotlib.{self.__class__.__name__} @ {hex(id(self))}"
else:
return f"fastplotlib.{self.__class__.__name__} @ {hex(id(self))}"

3 changes: 3 additions & 0 deletions fastplotlib/graphics/_graphic_attribute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@



4 changes: 4 additions & 0 deletions fastplotlib/graphics/features/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from ._colors import ColorFeature
from ._data import DataFeature
from ._present import PresentFeature
from ._base import GraphicFeature
182 changes: 182 additions & 0 deletions fastplotlib/graphics/features/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
from abc import ABC, abstractmethod
from inspect import getfullargspec
from warnings import warn
from typing import *

import numpy as np
from pygfx import Buffer


class FeatureEvent:
"""
type: <feature_name>-<changed>, example: "color-changed"
pick_info: dict in the form:
{
"index": indices where feature data was changed, ``range`` object or List[int],
"world_object": world object the feature belongs to,
"new_values": the new values
}
"""
def __init__(self, type: str, pick_info: dict):
self.type = type
self.pick_info = pick_info

def __repr__(self):
return f"{self.__class__.__name__} @ {hex(id(self))}\n" \
f"type: {self.type}\n" \
f"pick_info: {self.pick_info}\n"


class GraphicFeature(ABC):
def __init__(self, parent, data: Any):
self._parent = parent
if isinstance(data, np.ndarray):
data = data.astype(np.float32)

self._data = data
self._event_handlers = list()

@property
def feature_data(self):
"""graphic feature data managed by fastplotlib, do not modify directly"""
return self._data

@abstractmethod
def _set(self, value):
pass

@abstractmethod
def __repr__(self):
pass

def add_event_handler(self, handler: callable):
"""
Add an event handler. All added event handlers are called when this feature changes.
The `handler` can optionally accept ``FeatureEvent`` as the first and only argument.
The ``FeatureEvent`` only has two attributes, `type` which denotes the type of event
as a str in the form of "<feature_name>-changed", such as "color-changed".
Parameters
----------
handler: callable
a function to call when this feature changes
"""
if not callable(handler):
raise TypeError("event handler must be callable")

if handler in self._event_handlers:
warn(f"Event handler {handler} is already registered.")
return

self._event_handlers.append(handler)

#TODO: maybe this can be implemented right here in the base class
@abstractmethod
def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any):
"""Called whenever a feature changes, and it calls all funcs in self._event_handlers"""
pass

def _call_event_handlers(self, event_data: FeatureEvent):
for func in self._event_handlers:
try:
if len(getfullargspec(func).args) > 0:
func(event_data)
except:
warn(f"Event handler {func} has an unresolvable argspec, trying it anyways.")
func(event_data)
else:
func()


def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]:
if isinstance(key, int):
return key

if isinstance(key, tuple):
# if tuple of slice we only need the first obj
# since the first obj is the datapoint indices
if isinstance(key[0], slice):
key = key[0]
else:
raise TypeError("Tuple slicing must have slice object in first position")

if not isinstance(key, slice):
raise TypeError("Must pass slice or int object")

start = key.start
stop = key.stop
step = key.step
for attr in [start, stop, step]:
if attr is None:
continue
if attr < 0:
raise IndexError("Negative indexing not supported.")

if start is None:
start = 0

if stop is None:
stop = upper_bound

elif stop > upper_bound:
raise IndexError("Index out of bounds")

step = key.step
if step is None:
step = 1

return slice(start, stop, step)


class GraphicFeatureIndexable(GraphicFeature):
"""And indexable Graphic Feature, colors, data, sizes etc."""

def _set(self, value):
self[:] = value

@abstractmethod
def __getitem__(self, item):
pass

@abstractmethod
def __setitem__(self, key, value):
pass

@abstractmethod
def _update_range(self, key):
pass

@property
@abstractmethod
def _buffer(self) -> Buffer:
pass

@property
def _upper_bound(self) -> int:
return self.feature_data.shape[0]

def _update_range_indices(self, key):
"""Currently used by colors and data"""
key = cleanup_slice(key, self._upper_bound)

if isinstance(key, int):
self._buffer.update_range(key, size=1)
return

# else if it's a slice obj
if isinstance(key, slice):
if key.step == 1: # we cleaned up the slice obj so step of None becomes 1
# update range according to size using the offset
self._buffer.update_range(offset=key.start, size=key.stop - key.start)

else:
step = key.step
# convert slice to indices
ixs = range(key.start, key.stop, step)
for ix in ixs:
self._buffer.update_range(ix, size=1)
else:
raise TypeError("must pass int or slice to update range")


0 comments on commit 088f032

Please sign in to comment.