-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #78 from kushalkolar/graphic-attrs
indexable graphic attributes
- Loading branch information
Showing
19 changed files
with
1,092 additions
and
229 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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))}" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
|
||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
|
||
|
Oops, something went wrong.