From 984737eec93a9e3a60bced478b0a1eb80948c3c3 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Mon, 26 Jun 2023 04:15:26 -0400 Subject: [PATCH 01/21] Cleanup (#259) * cleanup * black * move plot to layouts * finished cleanup and flake8 config, need to figure out how to ignore long docstring tables * remove flake8 noqa * Delete setup.cfg don't want to use flake8 --- fastplotlib/__init__.py | 12 +- fastplotlib/graphics/__init__.py | 2 +- fastplotlib/graphics/_base.py | 147 ++++++----- fastplotlib/graphics/_features/__init__.py | 21 ++ .../graphics/{features => _features}/_base.py | 48 ++-- .../{features => _features}/_colors.py | 78 +++--- .../graphics/{features => _features}/_data.py | 20 +- .../{features => _features}/_present.py | 6 +- .../{features => _features}/_sizes.py | 0 .../{features => _features}/_thickness.py | 3 +- fastplotlib/graphics/features/__init__.py | 5 - fastplotlib/graphics/histogram.py | 48 ++-- fastplotlib/graphics/image.py | 152 ++++++----- fastplotlib/graphics/line.py | 100 +++++--- fastplotlib/graphics/line_collection.py | 175 +++++++------ fastplotlib/graphics/scatter.py | 45 ++-- fastplotlib/graphics/selectors/__init__.py | 8 +- .../graphics/selectors/_base_selector.py | 38 +-- fastplotlib/graphics/selectors/_linear.py | 58 +++-- .../graphics/selectors/_linear_region.py | 118 +++++---- .../graphics/selectors/_mesh_positions.py | 132 ++++++++-- .../graphics/selectors/_rectangle_region.py | 105 ++++---- fastplotlib/graphics/selectors/_sync.py | 2 - fastplotlib/graphics/text.py | 30 ++- fastplotlib/layouts/__init__.py | 5 +- fastplotlib/layouts/_base.py | 128 ++++++---- fastplotlib/layouts/_defaults.py | 12 +- fastplotlib/layouts/_gridplot.py | 169 +++++++----- fastplotlib/{plot.py => layouts/_plot.py} | 115 ++++++--- fastplotlib/layouts/_record_mixin.py | 39 +-- fastplotlib/layouts/_subplot.py | 240 ++++++++++-------- fastplotlib/layouts/_utils.py | 11 +- fastplotlib/utils/functions.py | 53 ++-- fastplotlib/utils/generate_colormaps.py | 119 +++++++-- fastplotlib/widgets/__init__.py | 2 + fastplotlib/widgets/image.py | 238 ++++++++--------- 36 files changed, 1505 insertions(+), 979 deletions(-) create mode 100644 fastplotlib/graphics/_features/__init__.py rename fastplotlib/graphics/{features => _features}/_base.py (88%) rename fastplotlib/graphics/{features => _features}/_colors.py (88%) rename fastplotlib/graphics/{features => _features}/_data.py (94%) rename fastplotlib/graphics/{features => _features}/_present.py (98%) rename fastplotlib/graphics/{features => _features}/_sizes.py (100%) rename fastplotlib/graphics/{features => _features}/_thickness.py (97%) delete mode 100644 fastplotlib/graphics/features/__init__.py rename fastplotlib/{plot.py => layouts/_plot.py} (68%) diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 7eef88276..999fbe46c 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -1,9 +1,8 @@ from pathlib import Path -from wgpu.gui.auto import run +from .layouts import Plot, GridPlot -from .plot import Plot -from .layouts import GridPlot +from wgpu.gui.auto import run try: import ipywidgets @@ -15,3 +14,10 @@ with open(Path(__file__).parent.joinpath("VERSION"), "r") as f: __version__ = f.read().split("\n")[0] + +__all__ = [ + "Plot", + "GridPlot", + "run", + "ImageWidget" +] diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 5a4786ca2..91ba4722e 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -13,5 +13,5 @@ "HeatmapGraphic", "LineCollection", "LineStack", - "TextGraphic" + "TextGraphic", ] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 5d186747b..dae31c61b 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,17 +1,14 @@ from typing import * import weakref from warnings import warn +from abc import ABC, abstractmethod +from dataclasses import dataclass import numpy as np -from .features._base import cleanup_slice - -from pygfx import WorldObject, Group -from .features import GraphicFeature, PresentFeature, GraphicFeatureIndexable - -from abc import ABC, abstractmethod -from dataclasses import dataclass +from pygfx import WorldObject +from ._features import GraphicFeature, PresentFeature, GraphicFeatureIndexable # dict that holds all world objects for a given python kernel/session # Graphic objects only use proxies to WorldObjects @@ -37,21 +34,22 @@ class BaseGraphic: def __init_subclass__(cls, **kwargs): """set the type of the graphic in lower case like "image", "line_collection", etc.""" - cls.type = cls.__name__\ - .lower()\ - .replace("graphic", "")\ - .replace("collection", "_collection")\ + cls.type = ( + cls.__name__.lower() + .replace("graphic", "") + .replace("collection", "_collection") .replace("stack", "_stack") + ) super().__init_subclass__(**kwargs) class Graphic(BaseGraphic): def __init__( - self, - name: str = None, - metadata: Any = None, - collection_index: int = None, + self, + name: str = None, + metadata: Any = None, + collection_index: int = None, ): """ @@ -163,6 +161,7 @@ def __del__(self): class Interaction(ABC): """Mixin class that makes graphics interactive""" + @abstractmethod def _set_feature(self, feature: str, new_data: Any, indices: Any): pass @@ -172,13 +171,13 @@ def _reset_feature(self, feature: str): pass def link( - self, - event_type: str, - target: Any, - feature: str, - new_data: Any, - callback: callable = None, - bidirectional: bool = False + self, + event_type: str, + target: Any, + feature: str, + new_data: Any, + callback: callable = None, + bidirectional: bool = False, ): """ Link this graphic to another graphic upon an ``event_type`` to change the ``feature`` @@ -192,14 +191,18 @@ def link( or appropriate feature event (ex. colors, data, etc.) associated with the graphic (can use ``graphic_instance.feature_events`` to get a tuple of the valid feature events for the graphic) + target: Any graphic to be linked to + feature: str feature (ex. colors, data, etc.) of the target graphic that will change following the event + new_data: Any appropriate data that will be changed in the feature of the target graphic after the event occurs + callback: callable, optional user-specified callable that will handle event, the callable must take the following four arguments @@ -207,9 +210,11 @@ def link( | ''target'' - the graphic to be changed following the event | ''event'' - the ''pygfx event'' or ''feature event'' that occurs | ''new_data'' - the appropriate data of the ''target'' that will be changed + bidirectional: bool, default False if True, the target graphic is also linked back to this graphic instance using the same arguments + For example: .. code-block::python @@ -231,21 +236,32 @@ def link( feature_instance.add_event_handler(self._event_handler) else: - raise ValueError(f"Invalid event, valid events are: {PYGFX_EVENTS + self.feature_events}") + raise ValueError( + f"Invalid event, valid events are: {PYGFX_EVENTS + self.feature_events}" + ) # make sure target feature is valid if feature is not None: if feature not in target.feature_events: - raise ValueError(f"Invalid feature for target, valid features are: {target.feature_events}") + raise ValueError( + f"Invalid feature for target, valid features are: {target.feature_events}" + ) if event_type not in self.registered_callbacks.keys(): self.registered_callbacks[event_type] = list() - callback_data = CallbackData(target=target, feature=feature, new_data=new_data, callback_function=callback) + callback_data = CallbackData( + target=target, + feature=feature, + new_data=new_data, + callback_function=callback, + ) for existing_callback_data in self.registered_callbacks[event_type]: if existing_callback_data == callback_data: - warn("linkage already exists for given event, target, and data, skipping") + warn( + "linkage already exists for given event, target, and data, skipping" + ) return self.registered_callbacks[event_type].append(callback_data) @@ -254,7 +270,7 @@ def link( if event_type in PYGFX_EVENTS: warn("cannot use bidirectional link for pygfx events") return - + target.link( event_type=event_type, target=self, @@ -262,7 +278,7 @@ def link( new_data=new_data, callback=callback, bidirectional=False # else infinite recursion, otherwise target will call - # this instance .link(), and then it will happen again etc. + # this instance .link(), and then it will happen again etc. ) def _event_handler(self, event): @@ -271,7 +287,12 @@ def _event_handler(self, event): for target_info in self.registered_callbacks[event.type]: if target_info.callback_function is not None: # if callback_function is not None, then callback function should handle the entire event - target_info.callback_function(source=self, target=target_info.target, event=event, new_data=target_info.new_data) + target_info.callback_function( + source=self, + target=target_info.target, + event=event, + new_data=target_info.new_data, + ) elif isinstance(self, GraphicCollection): # if target is a GraphicCollection, then indices will be stored in collection_index @@ -288,16 +309,24 @@ def _event_handler(self, event): # the real world object in the pick_info and not the proxy if wo is event.pick_info["world_object"]: indices = i - target_info.target._set_feature(feature=target_info.feature, new_data=target_info.new_data, indices=indices) + target_info.target._set_feature( + feature=target_info.feature, + new_data=target_info.new_data, + indices=indices, + ) else: # if target is a single graphic, then indices do not matter - target_info.target._set_feature(feature=target_info.feature, new_data=target_info.new_data, - indices=None) + target_info.target._set_feature( + feature=target_info.feature, + new_data=target_info.new_data, + indices=None, + ) @dataclass class CallbackData: """Class for keeping track of the info necessary for interactivity after event occurs.""" + target: Any feature: str new_data: Any @@ -329,6 +358,7 @@ def __eq__(self, other): @dataclass class PreviouslyModifiedData: """Class for keeping track of previously modified data at indices""" + data: Any indices: Any @@ -350,7 +380,9 @@ def __init__(self, name: str = None): def graphics(self) -> np.ndarray[Graphic]: """The Graphics within this collection. Always returns a proxy to the Graphics.""" if self._graphics_changed: - proxies = [weakref.proxy(COLLECTION_GRAPHICS[loc]) for loc in self._graphics] + proxies = [ + weakref.proxy(COLLECTION_GRAPHICS[loc]) for loc in self._graphics + ] self._graphics_array = np.array(proxies) self._graphics_array.flags["WRITEABLE"] = False self._graphics_changed = False @@ -395,15 +427,14 @@ def __getitem__(self, key): return CollectionIndexer( parent=self, selection=self.graphics[key], - # selection_indices=key ) - + def __del__(self): self.world_object.clear() for loc in self._graphics: del COLLECTION_GRAPHICS[loc] - + super().__del__() def _reset_index(self): @@ -420,11 +451,11 @@ def __repr__(self): class CollectionIndexer: """Collection Indexer""" + def __init__( - self, - parent: GraphicCollection, - selection: List[Graphic], - # selection_indices: Union[list, range], + self, + parent: GraphicCollection, + selection: List[Graphic], ): """ @@ -436,13 +467,10 @@ def __init__( selection: list of Graphics a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` - selection_indices: Union[list, range] - the corresponding indices from the parent GraphicCollection that were selected """ self._parent = weakref.proxy(parent) self._selection = selection - # self._selection_indices = selection_indices # we use parent.graphics[0] instead of selection[0] # because the selection can be empty @@ -450,12 +478,11 @@ def __init__( attr = getattr(self._parent.graphics[0], attr_name) if isinstance(attr, GraphicFeature): collection_feature = CollectionFeature( - parent, - self._selection, - # selection_indices=self._selection_indices, - feature=attr_name + self._selection, feature=attr_name + ) + collection_feature.__doc__ = ( + f"indexable <{attr_name}> feature for collection" ) - collection_feature.__doc__ = f"indexable <{attr_name}> feature for collection" setattr(self, attr_name, collection_feature) @property @@ -476,31 +503,26 @@ def __len__(self): return len(self._selection) def __repr__(self): - return f"{self.__class__.__name__} @ {hex(id(self))}\n" \ - f"Selection of <{len(self._selection)}> {self._selection[0].__class__.__name__}" + return ( + f"{self.__class__.__name__} @ {hex(id(self))}\n" + f"Selection of <{len(self._selection)}> {self._selection[0].__class__.__name__}" + ) class CollectionFeature: """Collection Feature""" - def __init__( - self, - parent: GraphicCollection, - selection: List[Graphic], - # selection_indices, - feature: str - ): + + def __init__(self, selection: List[Graphic], feature: str): """ - parent: GraphicCollection - GraphicCollection feature instance that is being indexed selection: list of Graphics a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` - selection_indices: Union[list, range] - the corresponding indices from the parent GraphicCollection that were selected + feature: str feature of Graphics in the GraphicCollection being indexed + """ + self._selection = selection - # self._selection_indices = selection_indices self._feature = feature self._feature_instances: List[GraphicFeature] = list() @@ -550,4 +572,3 @@ def block_events(self, b: bool): def __repr__(self): return f"Collection feature for: <{self._feature}>" - diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py new file mode 100644 index 000000000..e1cc5dd03 --- /dev/null +++ b/fastplotlib/graphics/_features/__init__.py @@ -0,0 +1,21 @@ +from ._colors import ColorFeature, CmapFeature, ImageCmapFeature, HeatmapCmapFeature +from ._data import PointsDataFeature, ImageDataFeature, HeatmapDataFeature +from ._present import PresentFeature +from ._thickness import ThicknessFeature +from ._base import GraphicFeature, GraphicFeatureIndexable, FeatureEvent, to_gpu_supported_dtype + +__all__ = [ + "ColorFeature", + "CmapFeature", + "ImageCmapFeature", + "HeatmapCmapFeature", + "PointsDataFeature", + "ImageDataFeature", + "HeatmapDataFeature", + "PresentFeature", + "ThicknessFeature", + "GraphicFeature", + "GraphicFeatureIndexable", + "FeatureEvent", + "to_gpu_supported_dtype" +] diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/_features/_base.py similarity index 88% rename from fastplotlib/graphics/features/_base.py rename to fastplotlib/graphics/_features/_base.py index bb8d35469..2860d6d4e 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -3,9 +3,9 @@ from warnings import warn from typing import * import weakref -from dataclasses import dataclass import numpy as np + from pygfx import Buffer, Texture @@ -17,7 +17,7 @@ np.int16, np.int32, np.float16, - np.float32 + np.float32, ] @@ -34,7 +34,9 @@ def to_gpu_supported_dtype(array): warn(f"converting {array.dtype} array to float32") return array.astype(np.float32, copy=False) else: - raise TypeError("Unsupported type, supported array types must be int or float dtypes") + raise TypeError( + "Unsupported type, supported array types must be int or float dtypes" + ) return array @@ -58,19 +60,23 @@ class FeatureEvent: ============== ============================================================================= """ + 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" + 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, collection_index: int = None): # not shown as a docstring so it doesn't show up in the docs + # # Parameters # ---------- # parent @@ -79,6 +85,7 @@ def __init__(self, parent, data: Any, collection_index: int = None): # # collection_index: int # if part of a collection, index of this graphic within the collection + self._parent = weakref.proxy(parent) self._data = to_gpu_supported_dtype(data) @@ -143,7 +150,7 @@ def remove_event_handler(self, handler: callable): def clear_event_handlers(self): self._event_handlers.clear() - #TODO: maybe this can be implemented right here in the base class + # 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""" @@ -165,7 +172,9 @@ def _call_event_handlers(self, event_data: FeatureEvent): else: func() except TypeError: - warn(f"Event handler {func} has an unresolvable argspec, calling it without arguments") + warn( + f"Event handler {func} has an unresolvable argspec, calling it without arguments" + ) func() @@ -192,9 +201,6 @@ def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]: if isinstance(key, np.ndarray): return cleanup_array_slice(key, upper_bound) - # if isinstance(key, np.integer): - # return int(key) - if isinstance(key, tuple): # if tuple of slice we only need the first obj # since the first obj is the datapoint indices @@ -222,14 +228,15 @@ def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]: stop = upper_bound elif stop > upper_bound: - raise IndexError(f"Index: `{stop}` out of bounds for feature array of size: `{upper_bound}`") + raise IndexError( + f"Index: `{stop}` out of bounds for feature array of size: `{upper_bound}`" + ) step = key.step if step is None: step = 1 return slice(start, stop, step) - # return slice(int(start), int(stop), int(step)) def cleanup_array_slice(key: np.ndarray, upper_bound) -> Union[np.ndarray, None]: @@ -253,9 +260,7 @@ def cleanup_array_slice(key: np.ndarray, upper_bound) -> Union[np.ndarray, None] """ if key.ndim > 1: - raise TypeError( - f"Can only use 1D boolean or integer arrays for fancy indexing" - ) + raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") # if boolean array convert to integer array of indices if key.dtype == bool: @@ -266,15 +271,15 @@ def cleanup_array_slice(key: np.ndarray, upper_bound) -> Union[np.ndarray, None] # make sure indices within bounds of feature buffer range if key[-1] > upper_bound: - raise IndexError(f"Index: `{key[-1]}` out of bounds for feature array of size: `{upper_bound}`") + raise IndexError( + f"Index: `{key[-1]}` out of bounds for feature array of size: `{upper_bound}`" + ) # make sure indices are integers if np.issubdtype(key.dtype, np.integer): return key - raise TypeError( - f"Can only use 1D boolean or integer arrays for fancy indexing" - ) + raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") class GraphicFeatureIndexable(GraphicFeature): @@ -331,9 +336,6 @@ def _update_range_indices(self, key): # TODO: See how efficient this is with large indexing elif isinstance(key, np.ndarray): self.buffer.update_range() - # for ix in key: - # self.buffer.update_range(int(ix), size=1) else: raise TypeError("must pass int or slice to update range") - diff --git a/fastplotlib/graphics/features/_colors.py b/fastplotlib/graphics/_features/_colors.py similarity index 88% rename from fastplotlib/graphics/features/_colors.py rename to fastplotlib/graphics/_features/_colors.py index 414bdeccc..55ab13f48 100644 --- a/fastplotlib/graphics/features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -1,8 +1,14 @@ import numpy as np +from pygfx import Color -from ._base import GraphicFeature, GraphicFeatureIndexable, cleanup_slice, FeatureEvent, cleanup_array_slice from ...utils import make_colors, get_cmap_texture, make_pygfx_colors, parse_cmap_values -from pygfx import Color +from ._base import ( + GraphicFeature, + GraphicFeatureIndexable, + cleanup_slice, + FeatureEvent, + cleanup_array_slice, +) class ColorFeature(GraphicFeatureIndexable): @@ -20,8 +26,8 @@ class ColorFeature(GraphicFeatureIndexable): "world_object" pygfx.WorldObject world object ==================== =============================== ========================================================================= - """ + @property def buffer(self): return self._parent.world_object.geometry.colors @@ -29,7 +35,14 @@ def buffer(self): def __getitem__(self, item): return self.buffer.data[item] - def __init__(self, parent, colors, n_colors: int, alpha: float = 1.0, collection_index: int = None): + def __init__( + self, + parent, + colors, + n_colors: int, + alpha: float = 1.0, + collection_index: int = None, + ): """ ColorFeature @@ -55,11 +68,7 @@ def __init__(self, parent, colors, n_colors: int, alpha: float = 1.0, collection # if the color is provided as a numpy array if isinstance(colors, np.ndarray): if colors.shape == (4,): # single RGBA array - data = np.repeat( - np.array([colors]), - n_colors, - axis=0 - ) + data = np.repeat(np.array([colors]), n_colors, axis=0) # else assume it's already a stack of RGBA arrays, keep this directly as the data elif colors.ndim == 2: if colors.shape[1] != 4 and colors.shape[0] != n_colors: @@ -109,7 +118,9 @@ def __init__(self, parent, colors, n_colors: int, alpha: float = 1.0, collection if alpha != 1.0: data[:, -1] = alpha - super(ColorFeature, self).__init__(parent, data, collection_index=collection_index) + super(ColorFeature, self).__init__( + parent, data, collection_index=collection_index + ) def __setitem__(self, key, value): # parse numerical slice indices @@ -130,7 +141,9 @@ def __setitem__(self, key, value): ) if len(key) != 2: - raise ValueError("fancy indexing for colors must be 2-dimension, i.e. [n_datapoints, RGBA]") + raise ValueError( + "fancy indexing for colors must be 2-dimension, i.e. [n_datapoints, RGBA]" + ) # set the user passed data directly self.buffer.data[key] = value @@ -151,7 +164,9 @@ def __setitem__(self, key, value): indices = key else: - raise TypeError("Graphic features only support integer and numerical fancy indexing") + raise TypeError( + "Graphic features only support integer and numerical fancy indexing" + ) new_data_size = len(indices) @@ -159,9 +174,7 @@ def __setitem__(self, key, value): color = np.array(Color(value)) # pygfx color parser # make it of shape [n_colors_modify, 4] new_colors = np.repeat( - np.array([color]).astype(np.float32), - new_data_size, - axis=0 + np.array([color]).astype(np.float32), new_data_size, axis=0 ) # if already a numpy array @@ -172,14 +185,14 @@ def __setitem__(self, key, value): # if there are more than 1 datapoint color to modify if new_data_size > 1: new_colors = np.repeat( - np.array([new_colors]).astype(np.float32), - new_data_size, - axis=0 + np.array([new_colors]).astype(np.float32), new_data_size, axis=0 ) elif value.ndim == 2: if value.shape[1] != 4 and value.shape[0] != new_data_size: - raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") + raise ValueError( + "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" + ) # if there is a single datapoint to change color of but user has provided shape [1, 4] if new_data_size == 1: new_colors = value.ravel().astype(np.float32) @@ -187,7 +200,9 @@ def __setitem__(self, key, value): new_colors = value.astype(np.float32) else: - raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") + raise ValueError( + "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" + ) self.buffer.data[key] = new_colors @@ -226,6 +241,7 @@ class CmapFeature(ColorFeature): Same event pick info as :class:`ColorFeature` """ + def __init__(self, parent, colors, cmap_name: str, cmap_values: np.ndarray): super(ColorFeature, self).__init__(parent, colors) @@ -235,8 +251,10 @@ def __init__(self, parent, colors, cmap_name: str, cmap_values: np.ndarray): def __setitem__(self, key, cmap_name): key = cleanup_slice(key, self._upper_bound) if not isinstance(key, (slice, np.ndarray)): - raise TypeError("Cannot set cmap on single indices, must pass a slice object, " - "numpy.ndarray or set it on the entire data.") + raise TypeError( + "Cannot set cmap on single indices, must pass a slice object, " + "numpy.ndarray or set it on the entire data." + ) if isinstance(key, slice): n_colors = len(range(key.start, key.stop, key.step)) @@ -246,9 +264,7 @@ def __setitem__(self, key, cmap_name): n_colors = key.size colors = parse_cmap_values( - n_colors=n_colors, - cmap_name=cmap_name, - cmap_values=self._cmap_values + n_colors=n_colors, cmap_name=cmap_name, cmap_values=self._cmap_values ) self._cmap_name = cmap_name @@ -264,9 +280,7 @@ def values(self, values: np.ndarray): values = np.array(values) colors = parse_cmap_values( - n_colors=self().shape[0], - cmap_name=self._cmap_name, - cmap_values=values + n_colors=self().shape[0], cmap_name=self._cmap_name, cmap_values=values ) self._cmap_values = values @@ -290,8 +304,8 @@ class ImageCmapFeature(GraphicFeature): "vmax" ``float`` maximum value ================ =================== =============== - """ + def __init__(self, parent, cmap: str): cmap_texture_view = get_cmap_texture(cmap) super(ImageCmapFeature, self).__init__(parent, cmap_texture_view) @@ -317,7 +331,7 @@ def vmin(self, value: float): """Minimum contrast limit.""" self._parent.world_object.material.clim = ( value, - self._parent.world_object.material.clim[1] + self._parent.world_object.material.clim[1], ) self._feature_changed(key=None, new_data=None) @@ -331,7 +345,7 @@ def vmax(self, value: float): """Maximum contrast limit.""" self._parent.world_object.material.clim = ( self._parent.world_object.material.clim[0], - value + value, ) self._feature_changed(key=None, new_data=None) @@ -343,7 +357,7 @@ def _feature_changed(self, key, new_data): "world_object": self._parent.world_object, "name": self.name, "vmin": self.vmin, - "vmax": self.vmax + "vmax": self.vmax, } event_data = FeatureEvent(type="cmap", pick_info=pick_info) diff --git a/fastplotlib/graphics/features/_data.py b/fastplotlib/graphics/_features/_data.py similarity index 94% rename from fastplotlib/graphics/features/_data.py rename to fastplotlib/graphics/_features/_data.py index 0228e2a15..2e8e38c12 100644 --- a/fastplotlib/graphics/features/_data.py +++ b/fastplotlib/graphics/_features/_data.py @@ -1,9 +1,16 @@ from typing import * import numpy as np + from pygfx import Buffer, Texture -from ._base import GraphicFeatureIndexable, cleanup_slice, FeatureEvent, to_gpu_supported_dtype, cleanup_array_slice +from ._base import ( + GraphicFeatureIndexable, + cleanup_slice, + FeatureEvent, + to_gpu_supported_dtype, + cleanup_array_slice, +) class PointsDataFeature(GraphicFeatureIndexable): @@ -11,9 +18,12 @@ class PointsDataFeature(GraphicFeatureIndexable): Access to the vertex buffer data shown in the graphic. Supports fancy indexing if the data array also supports it. """ + def __init__(self, parent, data: Any, collection_index: int = None): data = self._fix_data(data, parent) - super(PointsDataFeature, self).__init__(parent, data, collection_index=collection_index) + super(PointsDataFeature, self).__init__( + parent, data, collection_index=collection_index + ) @property def buffer(self) -> Buffer: @@ -83,7 +93,7 @@ def _feature_changed(self, key, new_data): "index": indices, "collection-index": self._collection_index, "world_object": self._parent.world_object, - "new_data": new_data + "new_data": new_data, } event_data = FeatureEvent(type="data", pick_info=pick_info) @@ -147,7 +157,7 @@ def _feature_changed(self, key, new_data): pick_info = { "index": indices, "world_object": self._parent.world_object, - "new_data": new_data + "new_data": new_data, } event_data = FeatureEvent(type="data", pick_info=pick_info) @@ -195,7 +205,7 @@ def _feature_changed(self, key, new_data): pick_info = { "index": indices, "world_object": self._parent.world_object, - "new_data": new_data + "new_data": new_data, } event_data = FeatureEvent(type="data", pick_info=pick_info) diff --git a/fastplotlib/graphics/features/_present.py b/fastplotlib/graphics/_features/_present.py similarity index 98% rename from fastplotlib/graphics/features/_present.py rename to fastplotlib/graphics/_features/_present.py index 820c1d123..22f42f357 100644 --- a/fastplotlib/graphics/features/_present.py +++ b/fastplotlib/graphics/_features/_present.py @@ -1,6 +1,7 @@ -from ._base import GraphicFeature, FeatureEvent from pygfx import Scene, Group +from ._base import GraphicFeature, FeatureEvent + class PresentFeature(GraphicFeature): """ @@ -19,6 +20,7 @@ class PresentFeature(GraphicFeature): "world_object" pygfx.WorldObject world object ==================== ======================== ======================================================================== """ + def __init__(self, parent, present: bool = True, collection_index: int = False): self._scene = None super(PresentFeature, self).__init__(parent, present, collection_index) @@ -58,7 +60,7 @@ def _feature_changed(self, key, new_data): "index": None, "collection-index": self._collection_index, "world_object": self._parent.world_object, - "new_data": new_data + "new_data": new_data, } event_data = FeatureEvent(type="present", pick_info=pick_info) diff --git a/fastplotlib/graphics/features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py similarity index 100% rename from fastplotlib/graphics/features/_sizes.py rename to fastplotlib/graphics/_features/_sizes.py diff --git a/fastplotlib/graphics/features/_thickness.py b/fastplotlib/graphics/_features/_thickness.py similarity index 97% rename from fastplotlib/graphics/features/_thickness.py rename to fastplotlib/graphics/_features/_thickness.py index ce9c3cbc4..b970d298e 100644 --- a/fastplotlib/graphics/features/_thickness.py +++ b/fastplotlib/graphics/_features/_thickness.py @@ -16,6 +16,7 @@ class ThicknessFeature(GraphicFeature): "world_object" pygfx.WorldObject world object ==================== ======================== ======================================================================== """ + def __init__(self, parent, thickness: float): self._scene = None super(ThicknessFeature, self).__init__(parent, thickness) @@ -33,7 +34,7 @@ def _feature_changed(self, key, new_data): "index": None, "collection-index": self._collection_index, "world_object": self._parent.world_object, - "new_data": new_data + "new_data": new_data, } event_data = FeatureEvent(type="thickness", pick_info=pick_info) diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py deleted file mode 100644 index 0e1e5f512..000000000 --- a/fastplotlib/graphics/features/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from ._colors import ColorFeature, CmapFeature, ImageCmapFeature, HeatmapCmapFeature -from ._data import PointsDataFeature, ImageDataFeature, HeatmapDataFeature -from ._present import PresentFeature -from ._thickness import ThicknessFeature -from ._base import GraphicFeature, GraphicFeatureIndexable diff --git a/fastplotlib/graphics/histogram.py b/fastplotlib/graphics/histogram.py index 749d1a441..6efd83a96 100644 --- a/fastplotlib/graphics/histogram.py +++ b/fastplotlib/graphics/histogram.py @@ -2,6 +2,7 @@ from typing import Union, Dict import numpy as np + import pygfx from ._base import Graphic @@ -16,14 +17,14 @@ def __int__(self, *args, **kwargs): class HistogramGraphic(Graphic): def __init__( - self, - data: np.ndarray = None, - bins: Union[int, str] = 'auto', - pre_computed: Dict[str, np.ndarray] = None, - colors: np.ndarray = "w", - draw_scale_factor: float = 100.0, - draw_bin_width_scale: float = 1.0, - **kwargs + self, + data: np.ndarray = None, + bins: Union[int, str] = "auto", + pre_computed: Dict[str, np.ndarray] = None, + colors: np.ndarray = "w", + draw_scale_factor: float = 100.0, + draw_bin_width_scale: float = 1.0, + **kwargs, ): """ Create a Histogram Graphic @@ -54,21 +55,29 @@ def __init__( if pre_computed is None: self.hist, self.bin_edges = np.histogram(data, bins) else: - if not set(pre_computed.keys()) == {'hist', 'bin_edges'}: - raise ValueError("argument to `pre_computed` must be a `dict` with keys 'hist' and 'bin_edges'") + if not set(pre_computed.keys()) == {"hist", "bin_edges"}: + raise ValueError( + "argument to `pre_computed` must be a `dict` with keys 'hist' and 'bin_edges'" + ) if not all(isinstance(v, np.ndarray) for v in pre_computed.values()): - raise ValueError("argument to `pre_computed` must be a `dict` where the values are numpy.ndarray") + raise ValueError( + "argument to `pre_computed` must be a `dict` where the values are numpy.ndarray" + ) self.hist, self.bin_edges = pre_computed["hist"], pre_computed["bin_edges"] self.bin_interval = (self.bin_edges[1] - self.bin_edges[0]) / 2 self.bin_centers = (self.bin_edges + self.bin_interval)[:-1] # scale between 0 - draw_scale_factor - scaled_bin_edges = ((self.bin_edges - self.bin_edges.min()) / (np.ptp(self.bin_edges))) * draw_scale_factor + scaled_bin_edges = ( + (self.bin_edges - self.bin_edges.min()) / (np.ptp(self.bin_edges)) + ) * draw_scale_factor bin_interval_scaled = scaled_bin_edges[1] / 2 # get the centers of the bins from the edges - x_positions_bins = (scaled_bin_edges + bin_interval_scaled)[:-1].astype(np.float32) + x_positions_bins = (scaled_bin_edges + bin_interval_scaled)[:-1].astype( + np.float32 + ) n_bins = x_positions_bins.shape[0] bin_width = (draw_scale_factor / n_bins) * draw_bin_width_scale @@ -77,16 +86,22 @@ def __init__( for bad_val in [np.nan, np.inf, -np.inf]: if bad_val in self.hist: - warn(f"Problematic value <{bad_val}> found in histogram, replacing with zero") + warn( + f"Problematic value <{bad_val}> found in histogram, replacing with zero" + ) self.hist[self.hist == bad_val] = 0 data = np.vstack([x_positions_bins, self.hist]) - super(HistogramGraphic, self).__init__(data=data, colors=colors, n_colors=n_bins, **kwargs) + super(HistogramGraphic, self).__init__( + data=data, colors=colors, n_colors=n_bins, **kwargs + ) self._world_object: pygfx.Group = pygfx.Group() - for x_val, y_val, bin_center in zip(x_positions_bins, self.hist, self.bin_centers): + for x_val, y_val, bin_center in zip( + x_positions_bins, self.hist, self.bin_centers + ): geometry = pygfx.plane_geometry( width=bin_width, height=y_val, @@ -99,4 +114,3 @@ def __init__( hist_bin_graphic.frequency = y_val self.world_object.add(hist_bin_graphic) - diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index c7bfb44de..2ddf5b4cf 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -4,17 +4,25 @@ import weakref import numpy as np + import pygfx -from ._base import Graphic, Interaction, PreviouslyModifiedData -from .selectors import LinearSelector, LinearRegionSelector -from .features import ImageCmapFeature, ImageDataFeature, HeatmapDataFeature, HeatmapCmapFeature, PresentFeature -from .features._base import to_gpu_supported_dtype from ..utils import quick_min_max +from ._base import Graphic, Interaction +from .selectors import LinearSelector, LinearRegionSelector +from ._features import ( + ImageCmapFeature, + ImageDataFeature, + HeatmapDataFeature, + HeatmapCmapFeature, + to_gpu_supported_dtype, +) class _ImageHeatmapSelectorsMixin: - def add_linear_selector(self, selection: int = None, padding: float = None, **kwargs) -> LinearSelector: + def add_linear_selector( + self, selection: int = None, padding: float = None, **kwargs + ) -> LinearSelector: """ Adds a linear selector. @@ -26,7 +34,7 @@ def add_linear_selector(self, selection: int = None, padding: float = None, **kw padding: float, optional pad the length of the selector - kwargs + kwargs: passed to :class:`.LinearSelector` Returns @@ -41,20 +49,29 @@ def add_linear_selector(self, selection: int = None, padding: float = None, **kw else: axis = "x" - bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + ( + bounds_init, + limits, + size, + origin, + axis, + end_points, + ) = self._get_linear_selector_init_args(padding, **kwargs) if selection is None: selection = limits[0] if selection < limits[0] or selection > limits[1]: - raise ValueError(f"the passed selection: {selection} is beyond the limits: {limits}") + raise ValueError( + f"the passed selection: {selection} is beyond the limits: {limits}" + ) selector = LinearSelector( selection=selection, limits=limits, end_points=end_points, parent=weakref.proxy(self), - **kwargs + **kwargs, ) self._plot_area.add_graphic(selector, center=False) @@ -62,7 +79,9 @@ def add_linear_selector(self, selection: int = None, padding: float = None, **kw return weakref.proxy(selector) - def add_linear_region_selector(self, padding: float = None, **kwargs) -> LinearRegionSelector: + def add_linear_region_selector( + self, padding: float = None, **kwargs + ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like any other ``Graphic``. @@ -72,7 +91,7 @@ def add_linear_region_selector(self, padding: float = None, **kwargs) -> LinearR padding: float, optional Extends the linear selector along the y-axis to make it easier to interact with. - kwargs, optional + kwargs: optional passed to ``LinearRegionSelector`` Returns @@ -82,7 +101,14 @@ def add_linear_region_selector(self, padding: float = None, **kwargs) -> LinearR """ - bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + ( + bounds_init, + limits, + size, + origin, + axis, + end_points, + ) = self._get_linear_selector_init_args(padding, **kwargs) # create selector selector = LinearRegionSelector( @@ -92,7 +118,7 @@ def add_linear_region_selector(self, padding: float = None, **kwargs) -> LinearR origin=origin, parent=weakref.proxy(self), fill_color=(0, 0, 0.35, 0.2), - **kwargs + **kwargs, ) self._plot_area.add_graphic(selector, center=False) @@ -171,22 +197,18 @@ def _add_plot_area_hook(self, plot_area): class ImageGraphic(Graphic, Interaction, _ImageHeatmapSelectorsMixin): - feature_events = ( - "data", - "cmap", - "present" - ) + feature_events = ("data", "cmap", "present") def __init__( - self, - data: Any, - vmin: int = None, - vmax: int = None, - cmap: str = 'plasma', - filter: str = "nearest", - isolated_buffer: bool = True, - *args, - **kwargs + self, + data: Any, + vmin: int = None, + vmax: int = None, + cmap: str = "plasma", + filter: str = "nearest", + isolated_buffer: bool = True, + *args, + **kwargs, ): """ Create an Image Graphic @@ -197,20 +219,27 @@ def __init__( array-like, usually numpy.ndarray, must support ``memoryview()`` Tensorflow Tensors also work **probably**, but not thoroughly tested | shape must be ``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]`` + vmin: int, optional minimum value for color scaling, calculated from data if not provided + vmax: int, optional maximum value for color scaling, calculated from data if not provided + cmap: str, optional, default "plasma" colormap to use to display the image data, ignored if data is RGB + filter: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" + isolated_buffer: bool, default True If True, initialize a buffer with the same shape as the input data and then set the data, useful if the data arrays are ready-only such as memmaps. If False, the input array is itself used as the buffer. + args: additional arguments passed to Graphic + kwargs: additional keyword arguments passed to Graphic @@ -226,8 +255,6 @@ def __init__( **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene - - Examples -------- .. code-block:: python @@ -241,6 +268,7 @@ def __init__( plot.add_image(data=data) # show the plot plot.show() + """ super().__init__(*args, **kwargs) @@ -268,16 +296,16 @@ def __init__( # if data is RGB or RGBA if data.ndim > 2: - - material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map_interpolation=filter) + material = pygfx.ImageBasicMaterial( + clim=(vmin, vmax), map_interpolation=filter + ) # if data is just 2D without color information, use colormap LUT else: - material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter) + material = pygfx.ImageBasicMaterial( + clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter + ) - world_object = pygfx.Image( - geometry, - material - ) + world_object = pygfx.Image(geometry, material) self._set_world_object(world_object) @@ -303,6 +331,7 @@ class _ImageTile(pygfx.Image): Similar to pygfx.Image, only difference is that it contains a few properties to keep track of row chunk index, column chunk index """ + def _wgpu_get_pick_info(self, pick_value): pick_info = super()._wgpu_get_pick_info(pick_value) @@ -310,7 +339,7 @@ def _wgpu_get_pick_info(self, pick_value): return { **pick_info, "row_chunk_index": self.row_chunk_index, - "col_chunk_index": self.col_chunk_index + "col_chunk_index": self.col_chunk_index, } @property @@ -337,16 +366,16 @@ class HeatmapGraphic(Graphic, Interaction, _ImageHeatmapSelectorsMixin): ) def __init__( - self, - data: Any, - vmin: int = None, - vmax: int = None, - cmap: str = 'plasma', - filter: str = "nearest", - chunk_size: int = 8192, - isolated_buffer: bool = True, - *args, - **kwargs + self, + data: Any, + vmin: int = None, + vmax: int = None, + cmap: str = "plasma", + filter: str = "nearest", + chunk_size: int = 8192, + isolated_buffer: bool = True, + *args, + **kwargs, ): """ Create an Image Graphic @@ -357,26 +386,33 @@ def __init__( array-like, usually numpy.ndarray, must support ``memoryview()`` Tensorflow Tensors also work **probably**, but not thoroughly tested | shape must be ``[x_dim, y_dim]`` + vmin: int, optional minimum value for color scaling, calculated from data if not provided + vmax: int, optional maximum value for color scaling, calculated from data if not provided + cmap: str, optional, default "plasma" colormap to use to display the data + filter: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" + chunk_size: int, default 8192, max 8192 chunk size for each tile used to make up the heatmap texture + isolated_buffer: bool, default True If True, initialize a buffer with the same shape as the input data and then set the data, useful if the data arrays are ready-only such as memmaps. If False, the input array is itself used as the buffer. + args: additional arguments passed to Graphic + kwargs: additional keyword arguments passed to Graphic - Features -------- @@ -389,7 +425,6 @@ def __init__( **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene - Examples -------- .. code-block:: python @@ -406,6 +441,7 @@ def __init__( # show the plot plot.show() + """ super().__init__(*args, **kwargs) @@ -441,7 +477,9 @@ def __init__( vmin, vmax = quick_min_max(data) self.cmap = HeatmapCmapFeature(self, cmap) - self._material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter) + self._material = pygfx.ImageBasicMaterial( + clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter + ) for start, stop, chunk in zip(start_ixs, stop_ixs, chunks): row_start, col_start = start @@ -450,7 +488,9 @@ def __init__( # x and y positions of the Tile in world space coordinates y_pos, x_pos = row_start, col_start - texture = pygfx.Texture(buffer_init[row_start:row_stop, col_start:col_stop], dim=2) + texture = pygfx.Texture( + buffer_init[row_start:row_stop, col_start:col_stop], dim=2 + ) geometry = pygfx.Geometry(grid=texture) # material = pygfx.ImageBasicMaterial(clim=(0, 1), map=self.cmap()) @@ -480,10 +520,7 @@ def vmin(self) -> float: @vmin.setter def vmin(self, value: float): """Minimum contrast limit.""" - self._material.clim = ( - value, - self._material.clim[1] - ) + self._material.clim = (value, self._material.clim[1]) @property def vmax(self) -> float: @@ -493,10 +530,7 @@ def vmax(self) -> float: @vmax.setter def vmax(self, value: float): """Maximum contrast limit.""" - self._material.clim = ( - self._material.clim[0], - value - ) + self._material.clim = (self._material.clim[0], value) def _set_feature(self, feature: str, new_data: Any, indices: Any): pass diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 6114fdd83..9793e8cc1 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -2,35 +2,30 @@ import weakref import numpy as np + import pygfx +from ..utils import parse_cmap_values from ._base import Graphic, Interaction, PreviouslyModifiedData -from .features import PointsDataFeature, ColorFeature, CmapFeature, ThicknessFeature +from ._features import PointsDataFeature, ColorFeature, CmapFeature, ThicknessFeature from .selectors import LinearRegionSelector, LinearSelector -from ..utils import parse_cmap_values class LineGraphic(Graphic, Interaction): - feature_events = ( - "data", - "colors", - "cmap", - "thickness", - "present" - ) + feature_events = ("data", "colors", "cmap", "thickness", "present") def __init__( - self, - data: Any, - thickness: float = 2.0, - colors: Union[str, np.ndarray, Iterable] = "w", - alpha: float = 1.0, - cmap: str = None, - cmap_values: Union[np.ndarray, List] = None, - z_position: float = None, - collection_index: int = None, - *args, - **kwargs + self, + data: Any, + thickness: float = 2.0, + colors: Union[str, np.ndarray, Iterable] = "w", + alpha: float = 1.0, + cmap: str = None, + cmap_values: Union[np.ndarray, List] = None, + z_position: float = None, + collection_index: int = None, + *args, + **kwargs, ): """ Create a line Graphic, 2d or 3d @@ -50,7 +45,7 @@ def __init__( cmap: str, optional apply a colormap to the line instead of assigning colors manually, this overrides any argument passed to "colors" - + cmap_values: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap @@ -88,9 +83,7 @@ def __init__( n_datapoints = self.data().shape[0] colors = parse_cmap_values( - n_colors=n_datapoints, - cmap_name=cmap, - cmap_values=cmap_values + n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values ) self.colors = ColorFeature( @@ -98,14 +91,11 @@ def __init__( colors, n_colors=self.data().shape[0], alpha=alpha, - collection_index=collection_index + collection_index=collection_index, ) self.cmap = CmapFeature( - self, - self.colors(), - cmap_name=cmap, - cmap_values=cmap_values + self, self.colors(), cmap_name=cmap, cmap_values=cmap_values ) super(LineGraphic, self).__init__(*args, **kwargs) @@ -120,7 +110,7 @@ def __init__( world_object: pygfx.Line = pygfx.Line( # self.data.feature_data because data is a Buffer geometry=pygfx.Geometry(positions=self.data(), colors=self.colors()), - material=material(thickness=self.thickness(), vertex_colors=True) + material=material(thickness=self.thickness(), vertex_colors=True), ) self._set_world_object(world_object) @@ -128,7 +118,9 @@ def __init__( if z_position is not None: self.position_z = z_position - def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs) -> LinearSelector: + def add_linear_selector( + self, selection: int = None, padding: float = 50, **kwargs + ) -> LinearSelector: """ Adds a linear selector. @@ -149,20 +141,29 @@ def add_linear_selector(self, selection: int = None, padding: float = 50, **kwar """ - bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + ( + bounds_init, + limits, + size, + origin, + axis, + end_points, + ) = self._get_linear_selector_init_args(padding, **kwargs) if selection is None: selection = limits[0] if selection < limits[0] or selection > limits[1]: - raise ValueError(f"the passed selection: {selection} is beyond the limits: {limits}") + raise ValueError( + f"the passed selection: {selection} is beyond the limits: {limits}" + ) selector = LinearSelector( selection=selection, limits=limits, end_points=end_points, parent=self, - **kwargs + **kwargs, ) self._plot_area.add_graphic(selector, center=False) @@ -170,7 +171,9 @@ def add_linear_selector(self, selection: int = None, padding: float = 50, **kwar return weakref.proxy(selector) - def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: + def add_linear_region_selector( + self, padding: float = 100.0, **kwargs + ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like any other ``Graphic``. @@ -190,7 +193,14 @@ def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> Linear """ - bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + ( + bounds_init, + limits, + size, + origin, + axis, + end_points, + ) = self._get_linear_selector_init_args(padding, **kwargs) # create selector selector = LinearRegionSelector( @@ -199,7 +209,7 @@ def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> Linear size=size, origin=origin, parent=self, - **kwargs + **kwargs, ) self._plot_area.add_graphic(selector, center=False) @@ -236,7 +246,10 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): # endpoints of the data range # used by linear selector but not linear region - end_points = (self.data()[:, 1].min() - padding, self.data()[:, 1].max() + padding) + end_points = ( + self.data()[:, 1].min() - padding, + self.data()[:, 1].max() + padding, + ) else: offset = self.position_y # y limits @@ -251,7 +264,10 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): # need x offset too for this origin = (position_x + self.position_x, limits[0] - offset) - end_points = (self.data()[:, 0].min() - padding, self.data()[:, 0].max() + padding) + end_points = ( + self.data()[:, 0].min() - padding, + self.data()[:, 0].max() + padding, + ) # initial bounds are 20% of the limits range bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) @@ -278,7 +294,9 @@ def _set_feature(self, feature: str, new_data: Any, indices: Any = None): self._previous_data[feature].data = previous self._previous_data[feature].indices = indices else: - self._previous_data[feature] = PreviouslyModifiedData(data=previous, indices=indices) + self._previous_data[feature] = PreviouslyModifiedData( + data=previous, indices=indices + ) def _reset_feature(self, feature: str): if feature not in self._previous_data.keys(): @@ -289,4 +307,4 @@ def _reset_feature(self, feature: str): if prev_ixs is not None: feature_instance[prev_ixs] = self._previous_data[feature].data else: - feature_instance._set(self._previous_data[feature].data) \ No newline at end of file + feature_instance._set(self._previous_data[feature].data) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 4756013d9..860a2e74a 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -1,41 +1,35 @@ from typing import * from copy import deepcopy import weakref -import traceback import numpy as np + import pygfx +from ..utils import parse_cmap_values from ._base import Interaction, PreviouslyModifiedData, GraphicCollection -from .features import GraphicFeature +from ._features import GraphicFeature from .line import LineGraphic from .selectors import LinearRegionSelector, LinearSelector -from ..utils import make_colors, get_cmap, QUALITATIVE_CMAPS, normalize_min_max, parse_cmap_values class LineCollection(GraphicCollection, Interaction): child_type = LineGraphic - feature_events = ( - "data", - "colors", - "cmap", - "thickness", - "present" - ) + feature_events = ("data", "colors", "cmap", "thickness", "present") def __init__( - self, - data: List[np.ndarray], - z_position: Union[List[float], float] = None, - thickness: Union[float, List[float]] = 2.0, - colors: Union[List[np.ndarray], np.ndarray] = "w", - alpha: float = 1.0, - cmap: Union[List[str], str] = None, - cmap_values: Union[np.ndarray, List] = None, - name: str = None, - metadata: Union[list, tuple, np.ndarray] = None, - *args, - **kwargs + self, + data: List[np.ndarray], + z_position: Union[List[float], float] = None, + thickness: Union[float, List[float]] = 2.0, + colors: Union[List[np.ndarray], np.ndarray] = "w", + alpha: float = 1.0, + cmap: Union[List[str], str] = None, + cmap_values: Union[np.ndarray, List] = None, + name: str = None, + metadata: Union[list, tuple, np.ndarray] = None, + *args, + **kwargs, ): """ Create a Line Collection @@ -82,7 +76,6 @@ def __init__( kwargs passed to GraphicCollection - Features -------- @@ -100,7 +93,6 @@ def __init__( See :class:`LineGraphic` details on the features. - Examples -------- .. code-block:: python @@ -156,17 +148,20 @@ def __init__( if not isinstance(z_position, float) and z_position is not None: if len(data) != len(z_position): - raise ValueError("z_position must be a single float or an iterable with same length as data") + raise ValueError( + "z_position must be a single float or an iterable with same length as data" + ) if not isinstance(thickness, (float, int)): if len(thickness) != len(data): - raise ValueError("args must be a single float or an iterable with same length as data") + raise ValueError( + "args must be a single float or an iterable with same length as data" + ) if metadata is not None: if len(metadata) != len(data): raise ValueError( - f"len(metadata) != len(data)\n" - f"{len(metadata)} != {len(data)}" + f"len(metadata) != len(data)\n" f"{len(metadata)} != {len(data)}" ) self._cmap_values = cmap_values @@ -177,21 +172,23 @@ def __init__( # cmap across lines if isinstance(cmap, str): colors = parse_cmap_values( - n_colors=len(data), - cmap_name=cmap, - cmap_values=cmap_values + n_colors=len(data), cmap_name=cmap, cmap_values=cmap_values ) single_color = False cmap = None elif isinstance(cmap, (tuple, list)): if len(cmap) != len(data): - raise ValueError("cmap argument must be a single cmap or a list of cmaps " - "with the same length as the data") + raise ValueError( + "cmap argument must be a single cmap or a list of cmaps " + "with the same length as the data" + ) single_color = False else: - raise ValueError("cmap argument must be a single cmap or a list of cmaps " - "with the same length as the data") + raise ValueError( + "cmap argument must be a single cmap or a list of cmaps " + "with the same length as the data" + ) else: if isinstance(colors, np.ndarray): # single color for all lines in the collection as RGBA @@ -270,7 +267,7 @@ def __init__( z_position=_z, cmap=_cmap, collection_index=i, - metadata=_m + metadata=_m, ) self.add_graphic(lg, reset_index=False) @@ -282,9 +279,7 @@ def cmap(self) -> str: @cmap.setter def cmap(self, cmap: str): colors = parse_cmap_values( - n_colors=len(self), - cmap_name=cmap, - cmap_values=self.cmap_values + n_colors=len(self), cmap_name=cmap, cmap_values=self.cmap_values ) for i, g in enumerate(self.graphics): @@ -297,16 +292,15 @@ def cmap_values(self) -> np.ndarray: @cmap_values.setter def cmap_values(self, values: Union[np.ndarray, list]): colors = parse_cmap_values( - n_colors=len(self), - cmap_name=self.cmap, - cmap_values=values - + n_colors=len(self), cmap_name=self.cmap, cmap_values=values ) for i, g in enumerate(self.graphics): g.colors = colors[i] - def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs) -> LinearSelector: + def add_linear_selector( + self, selection: int = None, padding: float = 50, **kwargs + ) -> LinearSelector: """ Adds a :class:`.LinearSelector` . Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like @@ -329,20 +323,29 @@ def add_linear_selector(self, selection: int = None, padding: float = 50, **kwar """ - bounds, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + ( + bounds, + limits, + size, + origin, + axis, + end_points, + ) = self._get_linear_selector_init_args(padding, **kwargs) if selection is None: selection = limits[0] if selection < limits[0] or selection > limits[1]: - raise ValueError(f"the passed selection: {selection} is beyond the limits: {limits}") + raise ValueError( + f"the passed selection: {selection} is beyond the limits: {limits}" + ) selector = LinearSelector( selection=selection, limits=limits, end_points=end_points, parent=self, - **kwargs + **kwargs, ) self._plot_area.add_graphic(selector, center=False) @@ -350,7 +353,9 @@ def add_linear_selector(self, selection: int = None, padding: float = 50, **kwar return weakref.proxy(selector) - def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: + def add_linear_region_selector( + self, padding: float = 100.0, **kwargs + ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector` Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like @@ -371,7 +376,14 @@ def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> Linear """ - bounds, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + ( + bounds, + limits, + size, + origin, + axis, + end_points, + ) = self._get_linear_selector_init_args(padding, **kwargs) selector = LinearRegionSelector( bounds=bounds, @@ -379,7 +391,7 @@ def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> Linear size=size, origin=origin, parent=self, - **kwargs + **kwargs, ) self._plot_area.add_graphic(selector, center=False) @@ -395,8 +407,14 @@ def _get_linear_selector_init_args(self, padding, **kwargs): end_points = list() for g in self.graphics: - _bounds_init, _limits, _size, _origin, axis, _end_points = \ - g._get_linear_selector_init_args(padding=0, **kwargs) + ( + _bounds_init, + _limits, + _size, + _origin, + axis, + _end_points, + ) = g._get_linear_selector_init_args(padding=0, **kwargs) bounds_init.append(_bounds_init) limits.append(_limits) @@ -409,13 +427,16 @@ def _get_linear_selector_init_args(self, padding, **kwargs): bounds = (b[:, 0].min(), b[:, 1].max()) # set the limits using the extents of the collection - l = np.vstack(limits) - limits = (l[:, 0].min(), l[:, 1].max()) + limits = np.vstack(limits) + limits = (limits[:, 0].min(), limits[:, 1].max()) # stack endpoints end_points = np.vstack(end_points) # use the min endpoint for index 0, highest endpoint for index 1 - end_points = [end_points[:, 0].min() - padding, end_points[:, 1].max() + padding] + end_points = [ + end_points[:, 0].min() - padding, + end_points[:, 1].max() + padding, + ] # TODO: refactor this to use `LineStack.graphics[-1].position.y` if isinstance(self, LineStack): @@ -427,7 +448,11 @@ def _get_linear_selector_init_args(self, padding, **kwargs): # a better way to get the max y value? # graphics y-position + data y-max + padding - end_points[1] = self.graphics[-1].position_y + self.graphics[-1].data()[:, 1].max() + padding + end_points[1] = ( + self.graphics[-1].position_y + + self.graphics[-1].data()[:, 1].max() + + padding + ) else: # just the biggest one if not stacked @@ -480,7 +505,9 @@ def _set_feature(self, feature: str, new_data: Any, indices: Any): self._previous_data[feature].data = previous_data self._previous_data[feature].indices = indices else: - self._previous_data[feature] = PreviouslyModifiedData(data=previous_data, indices=indices) + self._previous_data[feature] = PreviouslyModifiedData( + data=previous_data, indices=indices + ) # finally set the new data # this MUST occur after setting the previous data attribute to prevent recursion @@ -500,26 +527,22 @@ def _reset_feature(self, feature: str): coll_feature.block_events(False) -axes = { - "x": 0, - "y": 1, - "z": 2 -} +axes = {"x": 0, "y": 1, "z": 2} class LineStack(LineCollection): def __init__( - self, - data: List[np.ndarray], - z_position: Union[List[float], float] = None, - thickness: Union[float, List[float]] = 2.0, - colors: Union[List[np.ndarray], np.ndarray] = "w", - cmap: Union[List[str], str] = None, - separation: float = 10, - separation_axis: str = "y", - name: str = None, - *args, - **kwargs + self, + data: List[np.ndarray], + z_position: Union[List[float], float] = None, + thickness: Union[float, List[float]] = 2.0, + colors: Union[List[np.ndarray], np.ndarray] = "w", + cmap: Union[List[str], str] = None, + separation: float = 10, + separation_axis: str = "y", + name: str = None, + *args, + **kwargs, ): """ Create a line stack @@ -631,7 +654,7 @@ def __init__( colors=colors, cmap=cmap, name=name, - **kwargs + **kwargs, ) axis_zero = 0 @@ -641,6 +664,8 @@ def __init__( elif separation_axis == "y": line.position_y = axis_zero - axis_zero = axis_zero + line.data()[:, axes[separation_axis]].max() + separation + axis_zero = ( + axis_zero + line.data()[:, axes[separation_axis]].max() + separation + ) self.separation = separation diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index b2a92ea95..0d0eeada1 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -3,23 +3,23 @@ import numpy as np import pygfx +from ..utils import parse_cmap_values from ._base import Graphic -from .features import PointsDataFeature, ColorFeature, CmapFeature -from ..utils import make_colors, parse_cmap_values +from ._features import PointsDataFeature, ColorFeature, CmapFeature class ScatterGraphic(Graphic): def __init__( - self, - data: np.ndarray, - sizes: Union[int, np.ndarray, list] = 1, - colors: np.ndarray = "w", - alpha: float = 1.0, - cmap: str = None, - cmap_values: Union[np.ndarray, List] = None, - z_position: float = 0.0, - *args, - **kwargs + self, + data: np.ndarray, + sizes: Union[int, np.ndarray, list] = 1, + colors: np.ndarray = "w", + alpha: float = 1.0, + cmap: str = None, + cmap_values: Union[np.ndarray, List] = None, + z_position: float = 0.0, + *args, + **kwargs, ): """ Create a Scatter Graphic, 2d or 3d @@ -75,34 +75,33 @@ def __init__( if cmap is not None: colors = parse_cmap_values( - n_colors=n_datapoints, - cmap_name=cmap, - cmap_values=cmap_values + n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values ) self.colors = ColorFeature(self, colors, n_colors=n_datapoints, alpha=alpha) self.cmap = CmapFeature( - self, - self.colors(), - cmap_name=cmap, - cmap_values=cmap_values + self, self.colors(), cmap_name=cmap, cmap_values=cmap_values ) if isinstance(sizes, int): sizes = np.full(self.data().shape[0], sizes, dtype=np.float32) elif isinstance(sizes, np.ndarray): if (sizes.ndim != 1) or (sizes.size != self.data().shape[0]): - raise ValueError(f"numpy array of `sizes` must be 1 dimensional with " - f"the same length as the number of datapoints") + raise ValueError( + f"numpy array of `sizes` must be 1 dimensional with " + f"the same length as the number of datapoints" + ) elif isinstance(sizes, list): if len(sizes) != self.data().shape[0]: - raise ValueError("list of `sizes` must have the same length as the number of datapoints") + raise ValueError( + "list of `sizes` must have the same length as the number of datapoints" + ) super(ScatterGraphic, self).__init__(*args, **kwargs) world_object = pygfx.Points( pygfx.Geometry(positions=self.data(), sizes=sizes, colors=self.colors()), - material=pygfx.PointsMaterial(vertex_colors=True, vertex_sizes=True) + material=pygfx.PointsMaterial(vertex_colors=True, vertex_sizes=True), ) self._set_world_object(world_object) diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 8ebcaf053..83162644e 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,4 +1,10 @@ from ._linear import LinearSelector from ._linear_region import LinearRegionSelector -from ._rectangle_region import RectangleRegionSelector + from ._sync import Synchronizer + +__all__ = [ + "LinearSelector", + "LinearRegionSelector", + "Synchronizer", +] diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index 777eb09dc..8b9923b88 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -32,14 +32,16 @@ class MoveInfo: # Selector base class class BaseSelector: + feature_events = ("selection",) + def __init__( - self, - edges: Tuple[Line, ...] = None, - fill: Tuple[Mesh, ...] = None, - vertices: Tuple[Points, ...] = None, - hover_responsive: Tuple[WorldObject, ...] = None, - arrow_keys_modifier: str = None, - axis: str = None + self, + edges: Tuple[Line, ...] = None, + fill: Tuple[Mesh, ...] = None, + vertices: Tuple[Points, ...] = None, + hover_responsive: Tuple[WorldObject, ...] = None, + arrow_keys_modifier: str = None, + axis: str = None, ): if edges is None: edges = tuple() @@ -54,7 +56,9 @@ def __init__( self._fill: Tuple[Mesh, ...] = fill self._vertices: Tuple[Points, ...] = vertices - self._world_objects: Tuple[WorldObject, ...] = self._edges + self._fill + self._vertices + self._world_objects: Tuple[WorldObject, ...] = ( + self._edges + self._fill + self._vertices + ) self._hover_responsive: Tuple[WorldObject, ...] = hover_responsive @@ -181,10 +185,7 @@ def _move_start(self, event_source: WorldObject, ev): """ last_position = self._plot_area.map_screen_to_world(ev) - self._move_info = MoveInfo( - last_position=last_position, - source=event_source - ) + self._move_info = MoveInfo(last_position=last_position, source=event_source) def _move(self, ev): """ @@ -250,10 +251,14 @@ def _move_to_pointer(self, ev): # use fill by default as the source, such as in region selectors if len(self._fill) > 0: - self._move_info = MoveInfo(last_position=current_position, source=self._fill[0]) + self._move_info = MoveInfo( + last_position=current_position, source=self._fill[0] + ) # else use an edge, such as for linear selector else: - self._move_info = MoveInfo(last_position=current_position, source=self._edges[0]) + self._move_info = MoveInfo( + last_position=current_position, source=self._edges[0] + ) self._move_graphic(self.delta) self._move_info = None @@ -305,7 +310,10 @@ def _key_hold(self): def _key_down(self, ev): # key bind modifier must be set and must be used for the event # for example. if "Shift" is set as a modifier, then "Shift" must be used as a modifier during this event - if self.arrow_keys_modifier is not None and self.arrow_keys_modifier not in ev.modifiers: + if ( + self.arrow_keys_modifier is not None + and self.arrow_keys_modifier not in ev.modifiers + ): return # ignore if non-arrow key is pressed diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 639434aa3..88b0ac523 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -7,19 +7,20 @@ try: import ipywidgets + HAS_IPYWIDGETS = True -except: +except (ImportError, ModuleNotFoundError): HAS_IPYWIDGETS = False from .._base import Graphic, GraphicFeature, GraphicCollection -from ..features._base import FeatureEvent +from .._features import FeatureEvent from ._base_selector import BaseSelector class LinearSelectionFeature(GraphicFeature): # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system """ - Manages the slider selection and callbacks + Manages the linear selection and callbacks **event pick info** @@ -35,6 +36,7 @@ class LinearSelectionFeature(GraphicFeature): =================== =============================== ================================================================================================= """ + def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]): super(LinearSelectionFeature, self).__init__(parent, data=value) @@ -81,21 +83,19 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): class LinearSelector(Graphic, BaseSelector): - feature_events = ("selection",) - # TODO: make `selection` arg in graphics data space not world space def __init__( - self, - selection: int, - limits: Tuple[int, int], - axis: str = "x", - parent: Graphic = None, - end_points: Tuple[int, int] = None, - arrow_keys_modifier: str = "Shift", - ipywidget_slider = None, - thickness: float = 2.5, - color: Any = "w", - name: str = None, + self, + selection: int, + limits: Tuple[int, int], + axis: str = "x", + parent: Graphic = None, + end_points: Tuple[int, int] = None, + arrow_keys_modifier: str = "Shift", + ipywidget_slider=None, + thickness: float = 2.5, + color: Any = "w", + name: str = None, ): """ Create a horizontal or vertical line slider that is synced to an ipywidget IntSlider @@ -166,7 +166,6 @@ def __init__( line_data = line_data.astype(np.float32) - # super(LinearSelector, self).__init__(name=name) # init Graphic Graphic.__init__(self, name=name) @@ -180,12 +179,12 @@ def __init__( line_inner = pygfx.Line( # self.data.feature_data because data is a Buffer geometry=pygfx.Geometry(positions=line_data), - material=material(thickness=thickness, color=color) + material=material(thickness=thickness, color=color), ) self.line_outer = pygfx.Line( geometry=pygfx.Geometry(positions=line_data), - material=material(thickness=thickness + 6, color=self.colors_outer) + material=material(thickness=thickness + 6, color=self.colors_outer), ) line_inner.world.z = self.line_outer.world.z + 1 @@ -203,7 +202,9 @@ def __init__( else: self.position_y = selection - self.selection = LinearSelectionFeature(self, axis=axis, value=selection, limits=limits) + self.selection = LinearSelectionFeature( + self, axis=axis, value=selection, limits=limits + ) self.ipywidget_slider = ipywidget_slider @@ -272,7 +273,9 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): raise AttributeError("Already has ipywidget slider") if not HAS_IPYWIDGETS: - raise ImportError("Must installed `ipywidgets` to use `make_ipywidget_slider()`") + raise ImportError( + "Must installed `ipywidgets` to use `make_ipywidget_slider()`" + ) cls = getattr(ipywidgets, kind) @@ -281,7 +284,7 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): max=self.selection.limits[1], value=int(self.selection()), step=1, - **kwargs + **kwargs, ) self.ipywidget_slider = slider self._setup_ipywidget_slider(slider) @@ -332,12 +335,19 @@ def _get_selected_index(self, graphic): # get closest data index to the world space position of the slider idx = np.searchsorted(geo_positions, find_value, side="left") - if idx > 0 and (idx == len(geo_positions) or math.fabs(find_value - geo_positions[idx - 1]) < math.fabs(find_value - geo_positions[idx])): + if idx > 0 and ( + idx == len(geo_positions) + or math.fabs(find_value - geo_positions[idx - 1]) + < math.fabs(find_value - geo_positions[idx]) + ): return int(idx - 1) else: return int(idx) - if "Heatmap" in graphic.__class__.__name__ or "Image" in graphic.__class__.__name__: + if ( + "Heatmap" in graphic.__class__.__name__ + or "Image" in graphic.__class__.__name__ + ): # indices map directly to grid geometry for image data buffer index = self.selection() - offset return int(index) diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 49c914300..a9a0479b4 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -4,21 +4,16 @@ import pygfx from .._base import Graphic, GraphicCollection -from ..features._base import GraphicFeature, FeatureEvent +from .._features import GraphicFeature, FeatureEvent from ._base_selector import BaseSelector - from ._mesh_positions import x_right, x_left, y_top, y_bottom class LinearRegionSelectionFeature(GraphicFeature): - feature_events = ( - "data", - ) """ Feature for a linearly bounding region - Pick Info - --------- + **event pick info** +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ | key | type | description | @@ -28,13 +23,16 @@ class LinearRegionSelectionFeature(GraphicFeature): | "new_data" | ``(float, float)`` | current bounds in world coordinates, NOT necessarily the same as "selected_indices". | | "graphic" | ``Graphic`` | the selection graphic | | "delta" | ``numpy.ndarray`` | the delta vector of the graphic in NDC | - | "pygfx_event" | ``pygfx.Event`` | pygfx Event | + | "pygfx_event" | ``pygfx.Event`` | pygfx Event | | "selected_data" | ``numpy.ndarray`` or ``None`` | selected graphic data | - | "move_info" | ``MoveInfo`` | last position and event source (pygfx.Mesh or pygfx.Line) | + | "move_info" | ``MoveInfo`` | last position and event source (pygfx.Mesh or pygfx.Line) | +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ """ - def __init__(self, parent, selection: Tuple[int, int], axis: str, limits: Tuple[int, int]): + + def __init__( + self, parent, selection: Tuple[int, int], axis: str, limits: Tuple[int, int] + ): super(LinearRegionSelectionFeature, self).__init__(parent, data=selection) self._axis = axis @@ -92,7 +90,7 @@ def _set(self, value: Tuple[float, float]): # change y position of the top edge line self._parent.edges[1].geometry.positions.data[:, 1] = value[1] - self._data = value#(value[0], value[1]) + self._data = value # (value[0], value[1]) # send changes to GPU self._parent.fill.geometry.positions.update_range() @@ -126,7 +124,7 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): "graphic": self._parent, "delta": self._parent.delta, "pygfx_event": pygfx_ev, - "move_info": self._parent._move_info + "move_info": self._parent._move_info, } event_data = FeatureEvent(type="selection", pick_info=pick_info) @@ -136,18 +134,18 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): class LinearRegionSelector(Graphic, BaseSelector): def __init__( - self, - bounds: Tuple[int, int], - limits: Tuple[int, int], - size: int, - origin: Tuple[int, int], - axis: str = "x", - parent: Graphic = None, - resizable: bool = True, - fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.8, 0), - arrow_keys_modifier: str = "Shift", - name: str = None + self, + bounds: Tuple[int, int], + limits: Tuple[int, int], + size: int, + origin: Tuple[int, int], + axis: str = "x", + parent: Graphic = None, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.8, 0), + arrow_keys_modifier: str = "Shift", + name: str = None, ): """ Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. @@ -229,14 +227,14 @@ def __init__( if axis == "x": mesh = pygfx.Mesh( - pygfx.box_geometry(1, size, 1), - pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)) + pygfx.box_geometry(1, size, 1), + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)), ) elif axis == "y": mesh = pygfx.Mesh( pygfx.box_geometry(size, 1, 1), - pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)) + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)), ) else: raise ValueError("`axis` must be one of 'x' or 'y'") @@ -252,50 +250,57 @@ def __init__( if axis == "x": # position data for the left edge line left_line_data = np.array( - [[origin[0], (-size / 2) + origin[1], 0.5], - [origin[0], (size / 2) + origin[1], 0.5]] + [ + [origin[0], (-size / 2) + origin[1], 0.5], + [origin[0], (size / 2) + origin[1], 0.5], + ] ).astype(np.float32) left_line = pygfx.Line( pygfx.Geometry(positions=left_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color) + pygfx.LineMaterial(thickness=3, color=edge_color), ) # position data for the right edge line right_line_data = np.array( - [[bounds[1], (-size / 2) + origin[1], 0.5], - [bounds[1], (size / 2) + origin[1], 0.5]] + [ + [bounds[1], (-size / 2) + origin[1], 0.5], + [bounds[1], (size / 2) + origin[1], 0.5], + ] ).astype(np.float32) right_line = pygfx.Line( pygfx.Geometry(positions=right_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color) + pygfx.LineMaterial(thickness=3, color=edge_color), ) self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) elif axis == "y": # position data for the left edge line - bottom_line_data = \ - np.array( - [[(-size / 2) + origin[0], origin[1], 0.5], - [(size / 2) + origin[0], origin[1], 0.5]] - ).astype(np.float32) + bottom_line_data = np.array( + [ + [(-size / 2) + origin[0], origin[1], 0.5], + [(size / 2) + origin[0], origin[1], 0.5], + ] + ).astype(np.float32) bottom_line = pygfx.Line( pygfx.Geometry(positions=bottom_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color) + pygfx.LineMaterial(thickness=3, color=edge_color), ) # position data for the right edge line top_line_data = np.array( - [[(-size / 2) + origin[0], bounds[1], 0.5], - [(size / 2) + origin[0], bounds[1], 0.5]] + [ + [(-size / 2) + origin[0], bounds[1], 0.5], + [(size / 2) + origin[0], bounds[1], 0.5], + ] ).astype(np.float32) top_line = pygfx.Line( pygfx.Geometry(positions=top_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color) + pygfx.LineMaterial(thickness=3, color=edge_color), ) self.edges: Tuple[pygfx.Line, pygfx.Line] = (bottom_line, top_line) @@ -309,8 +314,9 @@ def __init__( self.world_object.add(edge) # set the initial bounds of the selector - self.selection = LinearRegionSelectionFeature(self, bounds, axis=axis, limits=limits) - # self._bounds: LinearBoundsFeature = bounds + self.selection = LinearRegionSelectionFeature( + self, bounds, axis=axis, limits=limits + ) BaseSelector.__init__( self, @@ -321,7 +327,9 @@ def __init__( axis=axis, ) - def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray], None]: + def get_selected_data( + self, graphic: Graphic = None + ) -> Union[np.ndarray, List[np.ndarray], None]: """ Get the ``Graphic`` data bounded by the current selection. Returns a view of the full data array. @@ -370,14 +378,19 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n s = slice(ixs[0], ixs[-1]) return source.data.buffer.data[s] - if "Heatmap" in source.__class__.__name__ or "Image" in source.__class__.__name__: + if ( + "Heatmap" in source.__class__.__name__ + or "Image" in source.__class__.__name__ + ): s = slice(ixs[0], ixs[-1]) if self.axis == "x": return source.data()[:, s] elif self.axis == "y": return source.data()[s] - def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: + def get_selected_indices( + self, graphic: Graphic = None + ) -> Union[np.ndarray, List[np.ndarray]]: """ Returns the indices of the ``Graphic`` data bounded by the current selection. This is useful because the ``bounds`` min and max are not necessarily the same @@ -419,18 +432,23 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis for g in source.graphics: # map for each graphic in the collection g_ixs = np.where( - (g.data()[:, dim] >= offset_bounds[0]) & (g.data()[:, dim] <= offset_bounds[1]) + (g.data()[:, dim] >= offset_bounds[0]) + & (g.data()[:, dim] <= offset_bounds[1]) )[0] ixs.append(g_ixs) else: # map this only this graphic ixs = np.where( - (source.data()[:, dim] >= offset_bounds[0]) & (source.data()[:, dim] <= offset_bounds[1]) + (source.data()[:, dim] >= offset_bounds[0]) + & (source.data()[:, dim] <= offset_bounds[1]) )[0] return ixs - if "Heatmap" in source.__class__.__name__ or "Image" in source.__class__.__name__: + if ( + "Heatmap" in source.__class__.__name__ + or "Image" in source.__class__.__name__ + ): # indices map directly to grid geometry for image data buffer ixs = np.arange(*self.selection(), dtype=int) return ixs diff --git a/fastplotlib/graphics/selectors/_mesh_positions.py b/fastplotlib/graphics/selectors/_mesh_positions.py index 9542aee58..e7cd5ae93 100644 --- a/fastplotlib/graphics/selectors/_mesh_positions.py +++ b/fastplotlib/graphics/selectors/_mesh_positions.py @@ -6,26 +6,118 @@ hacky, but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 """ -x_right = np.array([ - True, True, True, True, False, False, False, False, False, - True, False, True, True, False, True, False, False, True, - False, True, True, False, True, False -]) +x_right = np.array( + [ + True, + True, + True, + True, + False, + False, + False, + False, + False, + True, + False, + True, + True, + False, + True, + False, + False, + True, + False, + True, + True, + False, + True, + False, + ] +) -x_left = np.array([ - False, False, False, False, True, True, True, True, True, - False, True, False, False, True, False, True, True, False, - True, False, False, True, False, True -]) +x_left = np.array( + [ + False, + False, + False, + False, + True, + True, + True, + True, + True, + False, + True, + False, + False, + True, + False, + True, + True, + False, + True, + False, + False, + True, + False, + True, + ] +) -y_top = np.array([ - False, True, False, True, False, True, False, True, True, - True, True, True, False, False, False, False, False, False, - True, True, False, False, True, True -]) +y_top = np.array( + [ + False, + True, + False, + True, + False, + True, + False, + True, + True, + True, + True, + True, + False, + False, + False, + False, + False, + False, + True, + True, + False, + False, + True, + True, + ] +) -y_bottom = np.array([ - True, False, True, False, True, False, True, False, False, - False, False, False, True, True, True, True, True, True, - False, False, True, True, False, False -]) \ No newline at end of file +y_bottom = np.array( + [ + True, + False, + True, + False, + True, + False, + True, + False, + False, + False, + False, + False, + True, + True, + True, + True, + True, + True, + False, + False, + True, + True, + False, + False, + ] +) diff --git a/fastplotlib/graphics/selectors/_rectangle_region.py b/fastplotlib/graphics/selectors/_rectangle_region.py index 7065abe2d..a5a9a31cb 100644 --- a/fastplotlib/graphics/selectors/_rectangle_region.py +++ b/fastplotlib/graphics/selectors/_rectangle_region.py @@ -3,10 +3,9 @@ import pygfx -from .._base import Graphic, GraphicCollection -from ..features._base import GraphicFeature, FeatureEvent +from .._base import Graphic +from .._features import GraphicFeature from ._base_selector import BaseSelector - from ._mesh_positions import x_right, x_left, y_top, y_bottom @@ -14,8 +13,7 @@ class RectangleBoundsFeature(GraphicFeature): """ Feature for a linearly bounding region - Pick Info - --------- + **event pick info** +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ | key | type | description | @@ -26,7 +24,10 @@ class RectangleBoundsFeature(GraphicFeature): +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ """ - def __init__(self, parent, bounds: Tuple[int, int], axis: str, limits: Tuple[int, int]): + + def __init__( + self, parent, bounds: Tuple[int, int], axis: str, limits: Tuple[int, int] + ): super(RectangleBoundsFeature, self).__init__(parent, data=bounds) self._axis = axis @@ -78,37 +79,25 @@ def _set(self, value: Tuple[float, float, float, float]): # left line z = self._parent.edges[0].geometry.positions.data[:, -1][0] self._parent.edges[0].geometry.positions.data[:] = np.array( - [ - [xmin, ymin, z], - [xmin, ymax, z] - ] + [[xmin, ymin, z], [xmin, ymax, z]] ) # right line self._parent.edges[1].geometry.positions.data[:] = np.array( - [ - [xmax, ymin, z], - [xmax, ymax, z] - ] + [[xmax, ymin, z], [xmax, ymax, z]] ) # bottom line self._parent.edges[2].geometry.positions.data[:] = np.array( - [ - [xmin, ymin, z], - [xmax, ymin, z] - ] + [[xmin, ymin, z], [xmax, ymin, z]] ) # top line self._parent.edges[3].geometry.positions.data[:] = np.array( - [ - [xmin, ymax, z], - [xmax, ymax, z] - ] + [[xmin, ymax, z], [xmax, ymax, z]] ) - self._data = value#(value[0], value[1]) + self._data = value # (value[0], value[1]) # send changes to GPU self._parent.fill.geometry.positions.update_range() @@ -150,22 +139,20 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): class RectangleRegionSelector(Graphic, BaseSelector): - feature_events = ( - "bounds" - ) + feature_events = "bounds" def __init__( - self, - bounds: Tuple[int, int, int, int], - limits: Tuple[int, int], - origin: Tuple[int, int], - axis: str = "x", - parent: Graphic = None, - resizable: bool = True, - fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.8, 0), - arrow_keys_modifier: str = "Shift", - name: str = None + self, + bounds: Tuple[int, int, int, int], + limits: Tuple[int, int], + origin: Tuple[int, int], + axis: str = "x", + parent: Graphic = None, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.8, 0), + arrow_keys_modifier: str = "Shift", + name: str = None, ): """ Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. @@ -227,7 +214,7 @@ def __init__( self.fill = pygfx.Mesh( pygfx.box_geometry(width, height, 1), - pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)) + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)), ) self.fill.position.set(*origin, -2) @@ -235,51 +222,61 @@ def __init__( # position data for the left edge line left_line_data = np.array( - [[origin[0], (-height / 2) + origin[1], 0.5], - [origin[0], (height / 2) + origin[1], 0.5]] + [ + [origin[0], (-height / 2) + origin[1], 0.5], + [origin[0], (height / 2) + origin[1], 0.5], + ] ).astype(np.float32) left_line = pygfx.Line( pygfx.Geometry(positions=left_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color) + pygfx.LineMaterial(thickness=3, color=edge_color), ) # position data for the right edge line right_line_data = np.array( - [[bounds[1], (-height / 2) + origin[1], 0.5], - [bounds[1], (height / 2) + origin[1], 0.5]] + [ + [bounds[1], (-height / 2) + origin[1], 0.5], + [bounds[1], (height / 2) + origin[1], 0.5], + ] ).astype(np.float32) right_line = pygfx.Line( pygfx.Geometry(positions=right_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color) + pygfx.LineMaterial(thickness=3, color=edge_color), ) # position data for the left edge line - bottom_line_data = \ - np.array( - [[(-width / 2) + origin[0], origin[1], 0.5], - [(width / 2) + origin[0], origin[1], 0.5]] - ).astype(np.float32) + bottom_line_data = np.array( + [ + [(-width / 2) + origin[0], origin[1], 0.5], + [(width / 2) + origin[0], origin[1], 0.5], + ] + ).astype(np.float32) bottom_line = pygfx.Line( pygfx.Geometry(positions=bottom_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color) + pygfx.LineMaterial(thickness=3, color=edge_color), ) # position data for the right edge line top_line_data = np.array( - [[(-width / 2) + origin[0], bounds[1], 0.5], - [(width / 2) + origin[0], bounds[1], 0.5]] + [ + [(-width / 2) + origin[0], bounds[1], 0.5], + [(width / 2) + origin[0], bounds[1], 0.5], + ] ).astype(np.float32) top_line = pygfx.Line( pygfx.Geometry(positions=top_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color) + pygfx.LineMaterial(thickness=3, color=edge_color), ) self.edges: Tuple[pygfx.Line, ...] = ( - left_line, right_line, bottom_line, top_line + left_line, + right_line, + bottom_line, + top_line, ) # left line, right line, bottom line, top line # add the edge lines diff --git a/fastplotlib/graphics/selectors/_sync.py b/fastplotlib/graphics/selectors/_sync.py index 385f2cea1..b01823394 100644 --- a/fastplotlib/graphics/selectors/_sync.py +++ b/fastplotlib/graphics/selectors/_sync.py @@ -1,5 +1,3 @@ -from typing import * - from . import LinearSelector diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index 42bc3dba8..2648e2fa6 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -7,14 +7,14 @@ class TextGraphic(Graphic): def __init__( - self, - text: str, - position: Tuple[int] = (0, 0, 0), - size: int = 10, - face_color: Union[str, np.ndarray] = "w", - outline_color: Union[str, np.ndarray] = "w", - outline_thickness=0, - name: str = None, + self, + text: str, + position: Tuple[int] = (0, 0, 0), + size: int = 10, + face_color: Union[str, np.ndarray] = "w", + outline_color: Union[str, np.ndarray] = "w", + outline_thickness=0, + name: str = None, ): """ Create a text Graphic @@ -23,24 +23,36 @@ def __init__( ---------- text: str display text + position: int tuple, default (0, 0, 0) int tuple indicating location of text in scene + size: int, default 10 text size + face_color: str or array, default "w" str or RGBA array to set the color of the text + outline_color: str or array, default "w" str or RGBA array to set the outline color of the text + outline_thickness: int, default 0 text outline thickness + name: str, optional name of graphic, passed to Graphic + """ + super(TextGraphic, self).__init__(name=name) world_object = pygfx.Text( pygfx.TextGeometry(text=str(text), font_size=size, screen_space=False), - pygfx.TextMaterial(color=face_color, outline_color=outline_color, outline_thickness=outline_thickness) + pygfx.TextMaterial( + color=face_color, + outline_color=outline_color, + outline_thickness=outline_thickness, + ), ) self._set_world_object(world_object) diff --git a/fastplotlib/layouts/__init__.py b/fastplotlib/layouts/__init__.py index 682d6dfc9..aaed4c5a4 100644 --- a/fastplotlib/layouts/__init__.py +++ b/fastplotlib/layouts/__init__.py @@ -1,5 +1,4 @@ from ._gridplot import GridPlot +from ._plot import Plot -__all__ = [ - "GridPlot" -] +__all__ = ["Plot", "GridPlot"] diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index 0167fb1af..399acc65d 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -4,12 +4,19 @@ import numpy as np import pygfx -from pygfx import Scene, OrthographicCamera, PerspectiveCamera, PanZoomController, OrbitController, \ - Viewport, WgpuRenderer +from pygfx import ( + Scene, + OrthographicCamera, + PerspectiveCamera, + PanZoomController, + OrbitController, + Viewport, + WgpuRenderer, +) from pylinalg import vec_transform, vec_unproject from wgpu.gui.auto import WgpuCanvas -from ..graphics._base import Graphic, GraphicCollection +from ..graphics._base import Graphic from ..graphics.selectors._base_selector import BaseSelector # dict to store Graphic instances @@ -21,15 +28,15 @@ class PlotArea: def __init__( - self, - parent, - position: Any, - camera: Union[OrthographicCamera, PerspectiveCamera], - controller: Union[PanZoomController, OrbitController], - scene: Scene, - canvas: WgpuCanvas, - renderer: WgpuRenderer, - name: str = None, + self, + parent, + position: Any, + camera: Union[OrthographicCamera, PerspectiveCamera], + controller: Union[PanZoomController, OrbitController], + scene: Scene, + canvas: WgpuCanvas, + renderer: WgpuRenderer, + name: str = None, ): """ Base class for plot creation and management. ``PlotArea`` is not intended to be instantiated by users @@ -39,24 +46,33 @@ def __init__( ---------- parent: PlotArea parent class of subclasses will be a ``PlotArea`` instance + position: Any typical use will be for ``subplots`` in a ``gridplot``, position would correspond to the ``[row, column]`` location of the ``subplot`` in its ``gridplot`` + camera: pygfx OrthographicCamera or pygfx PerspectiveCamera ``OrthographicCamera`` type is used to visualize 2D content and ``PerspectiveCamera`` type is used to view 3D content, used to view the scene + controller: pygfx PanZoomController or pygfx OrbitController ``PanZoomController`` type is used for 2D pan-zoom camera control and ``OrbitController`` type is used for rotating the camera around a center position, used to control the camera + scene: pygfx Scene represents the root of a scene graph, will be viewed by the given ``camera`` + canvas: WgpuCanvas provides surface on which a scene will be rendered + renderer: WgpuRenderer object used to render scenes using wgpu + name: str, optional name of ``subplot`` or ``plot`` subclass being instantiated + """ + self._parent: PlotArea = parent self._position = position @@ -166,7 +182,9 @@ def get_rect(self) -> Tuple[float, float, float, float]: """allows setting the region occupied by the viewport w.r.t. the parent""" raise NotImplementedError("Must be implemented in subclass") - def map_screen_to_world(self, pos: Union[Tuple[float, float], pygfx.PointerEvent]) -> np.ndarray: + def map_screen_to_world( + self, pos: Union[Tuple[float, float], pygfx.PointerEvent] + ) -> np.ndarray: """ Map screen position to world position @@ -191,11 +209,7 @@ def map_screen_to_world(self, pos: Union[Tuple[float, float], pygfx.PointerEvent ) # convert screen position to NDC - pos_ndc = ( - pos_rel[0] / vs[0] * 2 - 1, - -(pos_rel[1] / vs[1] * 2 - 1), - 0 - ) + pos_ndc = (pos_rel[0] / vs[0] * 2 - 1, -(pos_rel[1] / vs[1] * 2 - 1), 0) # get world position pos_ndc += vec_transform(self.camera.world.position, self.camera.camera_matrix) @@ -220,7 +234,7 @@ def add_graphic(self, graphic: Graphic, center: bool = True): Parameters ---------- - graphic: Graphic or GraphicCollection + graphic: Graphic or `:ref:GraphicCollection` Add a Graphic or a GraphicCollection to the plot area. Note: this must be a real Graphic instance and not a proxy @@ -233,37 +247,41 @@ def add_graphic(self, graphic: Graphic, center: bool = True): graphic.position_z = len(self._graphics) def insert_graphic( - self, - graphic: Graphic, - center: bool = True, - index: int = 0, - z_position: int = None + self, + graphic: Graphic, + center: bool = True, + index: int = 0, + z_position: int = None, ): """ Insert graphic into scene at given position ``index`` in stored graphics. Parameters ---------- - graphic: Graphic or GraphicCollection - Add a Graphic or a GraphicCollection to the plot area at a given position. + graphic: Graphic + Add a Graphic to the plot area at a given position. Note: must be a real Graphic instance, not a weakref proxy to a Graphic center: bool, default True Center the camera on the newly added Graphic index: int, default 0 - Index to insert graphic. + Index to insert graphic. z_position: int, default None z axis position to place Graphic. If ``None``, uses value of `index` argument """ if index > len(self._graphics): - raise IndexError(f"Position {index} is out of bounds for number of graphics currently " - f"in the PlotArea: {len(self._graphics)}\n" - f"Call `add_graphic` method to insert graphic in the last position of the stored graphics") + raise IndexError( + f"Position {index} is out of bounds for number of graphics currently " + f"in the PlotArea: {len(self._graphics)}\n" + f"Call `add_graphic` method to insert graphic in the last position of the stored graphics" + ) - self._add_or_insert_graphic(graphic=graphic, center=center, action="insert", index=index) + self._add_or_insert_graphic( + graphic=graphic, center=center, action="insert", index=index + ) if z_position is None: graphic.position_z = index @@ -271,11 +289,11 @@ def insert_graphic( graphic.position_z = z_position def _add_or_insert_graphic( - self, - graphic: Graphic, - center: bool = True, - action: str = Union["insert", "add"], - index: int = 0 + self, + graphic: Graphic, + center: bool = True, + action: str = Union["insert", "add"], + index: int = 0, ): """Private method to handle inserting or adding a graphic to a PlotArea.""" if not isinstance(graphic, Graphic): @@ -289,7 +307,9 @@ def _add_or_insert_graphic( if isinstance(graphic, BaseSelector): # store in SELECTORS dict loc = graphic.loc - SELECTORS[loc] = graphic # add hex id string for referencing this graphic instance + SELECTORS[ + loc + ] = graphic # add hex id string for referencing this graphic instance # don't manage garbage collection of LineSliders for now if action == "insert": self._selectors.insert(index, loc) @@ -298,7 +318,9 @@ def _add_or_insert_graphic( else: # store in GRAPHICS dict loc = graphic.loc - GRAPHICS[loc] = graphic # add hex id string for referencing this graphic instance + GRAPHICS[ + loc + ] = graphic # add hex id string for referencing this graphic instance if action == "insert": self._graphics.insert(index, loc) @@ -324,7 +346,9 @@ def _check_graphic_name_exists(self, name): graphic_names.append(s.name) if name in graphic_names: - raise ValueError(f"graphics must have unique names, current graphic names are:\n {graphic_names}") + raise ValueError( + f"graphics must have unique names, current graphic names are:\n {graphic_names}" + ) def center_graphic(self, graphic: Graphic, zoom: float = 1.35): """ @@ -332,7 +356,7 @@ def center_graphic(self, graphic: Graphic, zoom: float = 1.35): Parameters ---------- - graphic: Graphic or GraphicCollection + graphic: Graphic The graphic instance to center on zoom: float, default 1.3 @@ -410,7 +434,7 @@ def remove_graphic(self, graphic: Graphic): Parameters ---------- - graphic: Graphic or GraphicCollection + graphic: Graphic The graphic to remove from the scene """ @@ -423,7 +447,7 @@ def delete_graphic(self, graphic: Graphic): Parameters ---------- - graphic: Graphic or GraphicCollection + graphic: Graphic The graphic to delete """ @@ -440,7 +464,9 @@ def delete_graphic(self, graphic: Graphic): kind = "selector" glist = self._selectors else: - raise KeyError(f"Graphic with following address not found in plot area: {loc}") + raise KeyError( + f"Graphic with following address not found in plot area: {loc}" + ) # remove from scene if necessary if graphic.world_object in self.scene.children: @@ -479,7 +505,9 @@ def __getitem__(self, name: str): graphic_names.append(g.name) for s in self.selectors: graphic_names.append(s.name) - raise IndexError(f"no graphic of given name, the current graphics are:\n {graphic_names}") + raise IndexError( + f"no graphic of given name, the current graphics are:\n {graphic_names}" + ) def __str__(self): if self.name is None: @@ -492,8 +520,10 @@ def __str__(self): def __repr__(self): newline = "\n\t" - return f"{self}\n" \ - f" parent: {self.parent}\n" \ - f" Graphics:\n" \ - f"\t{newline.join(graphic.__repr__() for graphic in self.graphics)}" \ - f"\n" + return ( + f"{self}\n" + f" parent: {self.parent}\n" + f" Graphics:\n" + f"\t{newline.join(graphic.__repr__() for graphic in self.graphics)}" + f"\n" + ) diff --git a/fastplotlib/layouts/_defaults.py b/fastplotlib/layouts/_defaults.py index 314774751..9a223855f 100644 --- a/fastplotlib/layouts/_defaults.py +++ b/fastplotlib/layouts/_defaults.py @@ -2,19 +2,21 @@ from typing import * camera_types = { - '2d': pygfx.OrthographicCamera, - '3d': pygfx.PerspectiveCamera, + "2d": pygfx.OrthographicCamera, + "3d": pygfx.PerspectiveCamera, } controller_types = { - '2d': pygfx.PanZoomController, - '3d': pygfx.OrbitController, + "2d": pygfx.PanZoomController, + "3d": pygfx.OrbitController, pygfx.OrthographicCamera: pygfx.PanZoomController, pygfx.PerspectiveCamera: pygfx.OrbitController, } -def create_camera(camera_type: str, big_camera: bool = False) -> Union[pygfx.OrthographicCamera, pygfx.PerspectiveCamera]: +def create_camera( + camera_type: str, big_camera: bool = False +) -> Union[pygfx.OrthographicCamera, pygfx.PerspectiveCamera]: camera_type = camera_type.split("-") # kinda messy but works for now diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py index 8f25f927b..3e9a16aac 100644 --- a/fastplotlib/layouts/_gridplot.py +++ b/fastplotlib/layouts/_gridplot.py @@ -34,14 +34,14 @@ def to_array(a) -> np.ndarray: class GridPlot(RecordMixin): def __init__( - self, - shape: Tuple[int, int], - cameras: Union[np.ndarray, str] = '2d', - controllers: Union[np.ndarray, str] = None, - canvas: Union[str, WgpuCanvas, pygfx.Texture] = None, - renderer: pygfx.Renderer = None, - size: Tuple[int, int] = (500, 300), - **kwargs + self, + shape: Tuple[int, int], + cameras: Union[np.ndarray, str] = "2d", + controllers: Union[np.ndarray, str] = None, + canvas: Union[str, WgpuCanvas, pygfx.Texture] = None, + renderer: pygfx.WgpuRenderer = None, + size: Tuple[int, int] = (500, 300), + **kwargs, ): """ A grid of subplots. @@ -84,13 +84,19 @@ def __init__( if isinstance(cameras, str): if cameras not in valid_cameras: - raise ValueError(f"If passing a str, `cameras` must be one of: {valid_cameras}") + raise ValueError( + f"If passing a str, `cameras` must be one of: {valid_cameras}" + ) # create the array representing the views for each subplot in the grid - cameras = np.array([cameras] * self.shape[0] * self.shape[1]).reshape(self.shape) + cameras = np.array([cameras] * self.shape[0] * self.shape[1]).reshape( + self.shape + ) if isinstance(controllers, str): if controllers == "sync": - controllers = np.zeros(self.shape[0] * self.shape[1], dtype=int).reshape(self.shape) + controllers = np.zeros( + self.shape[0] * self.shape[1], dtype=int + ).reshape(self.shape) if controllers is None: controllers = np.arange(self.shape[0] * self.shape[1]).reshape(self.shape) @@ -109,20 +115,26 @@ def __init__( # create controllers if the arguments were integers if np.issubdtype(controllers.dtype, np.integer): - if not np.all(np.sort(np.unique(controllers)) == np.arange(np.unique(controllers).size)): + if not np.all( + np.sort(np.unique(controllers)) + == np.arange(np.unique(controllers).size) + ): raise ValueError("controllers must be consecutive integers") for controller in np.unique(controllers): cam = np.unique(cameras[controllers == controller]) if cam.size > 1: raise ValueError( - f"Controller id: {controller} has been assigned to multiple different camera types") + f"Controller id: {controller} has been assigned to multiple different camera types" + ) self._controllers[controllers == controller] = create_controller(cam[0]) # else assume it's a single pygfx.Controller instance or a list of controllers else: if isinstance(controllers, pygfx.Controller): - self._controllers = np.array([controllers] * shape[0] * shape[1]).reshape(shape) + self._controllers = np.array( + [controllers] * shape[0] * shape[1] + ).reshape(shape) else: self._controllers = np.array(controllers).reshape(shape) @@ -144,12 +156,9 @@ def __init__( nrows, ncols = self.shape - self._subplots: np.ndarray[Subplot] = np.ndarray(shape=(nrows, ncols), dtype=object) - # self.viewports: np.ndarray[Subplot] = np.ndarray(shape=(nrows, ncols), dtype=object) - - # self._controllers: List[pygfx.PanZoomController] = [ - # pygfx.PanZoomController() for i in range(np.unique(controllers).size) - # ] + self._subplots: np.ndarray[Subplot] = np.ndarray( + shape=(nrows, ncols), dtype=object + ) for i, j in self._get_iterator(): position = (i, j) @@ -168,7 +177,7 @@ def __init__( controller=controller, canvas=canvas, renderer=renderer, - name=name + name=name, ) self._animate_funcs_pre: List[callable] = list() @@ -217,10 +226,10 @@ def _call_animate_functions(self, funcs: Iterable[callable]): fn() def add_animations( - self, - *funcs: Iterable[callable], - pre_render: bool = True, - post_render: bool = False + self, + *funcs: Iterable[callable], + pre_render: bool = True, + post_render: bool = False, ): """ Add function(s) that are called on every render cycle. @@ -272,10 +281,7 @@ def remove_animation(self, func): self._animate_funcs_post.remove(func) def show( - self, - autoscale: bool = True, - maintain_aspect: bool = None, - toolbar: bool = True + self, autoscale: bool = True, maintain_aspect: bool = None, toolbar: bool = True ): """ Begins the rendering event loop and returns the canvas @@ -295,7 +301,7 @@ def show( ------- WgpuCanvas the canvas - + """ self.canvas.request_draw(self.render) @@ -315,7 +321,9 @@ def show( if self.toolbar is None: self.toolbar = GridPlotToolBar(self) - self.toolbar.maintain_aspect_button.value = self[0, 0].camera.maintain_aspect + self.toolbar.maintain_aspect_button.value = self[ + 0, 0 + ].camera.maintain_aspect return VBox([self.canvas, self.toolbar.widget]) @@ -347,8 +355,7 @@ def __repr__(self): class GridPlotToolBar: - def __init__(self, - plot: GridPlot): + def __init__(self, plot: GridPlot): """ Basic toolbar for a GridPlot instance. @@ -358,20 +365,50 @@ def __init__(self, """ self.plot = plot - self.autoscale_button = Button(value=False, disabled=False, icon='expand-arrows-alt', - layout=Layout(width='auto'), tooltip='auto-scale scene') - self.center_scene_button = Button(value=False, disabled=False, icon='align-center', - layout=Layout(width='auto'), tooltip='auto-center scene') - self.panzoom_controller_button = ToggleButton(value=True, disabled=False, icon='hand-pointer', - layout=Layout(width='auto'), tooltip='panzoom controller') - self.maintain_aspect_button = ToggleButton(value=True, disabled=False, description="1:1", - layout=Layout(width='auto'), tooltip='maintain aspect') + self.autoscale_button = Button( + value=False, + disabled=False, + icon="expand-arrows-alt", + layout=Layout(width="auto"), + tooltip="auto-scale scene", + ) + self.center_scene_button = Button( + value=False, + disabled=False, + icon="align-center", + layout=Layout(width="auto"), + tooltip="auto-center scene", + ) + self.panzoom_controller_button = ToggleButton( + value=True, + disabled=False, + icon="hand-pointer", + layout=Layout(width="auto"), + tooltip="panzoom controller", + ) + self.maintain_aspect_button = ToggleButton( + value=True, + disabled=False, + description="1:1", + layout=Layout(width="auto"), + tooltip="maintain aspect", + ) self.maintain_aspect_button.style.font_weight = "bold" - self.flip_camera_button = Button(value=False, disabled=False, icon='arrows-v', - layout=Layout(width='auto'), tooltip='flip') - - self.record_button = ToggleButton(value=False, disabled=False, icon='video', - layout=Layout(width='auto'), tooltip='record') + self.flip_camera_button = Button( + value=False, + disabled=False, + icon="arrows-v", + layout=Layout(width="auto"), + tooltip="flip", + ) + + self.record_button = ToggleButton( + value=False, + disabled=False, + icon="video", + layout=Layout(width="auto"), + tooltip="record", + ) positions = list(product(range(self.plot.shape[0]), range(self.plot.shape[1]))) values = list() @@ -380,23 +417,31 @@ def __init__(self, values.append(self.plot[pos].name) else: values.append(str(pos)) - self.dropdown = Dropdown(options=values, disabled=False, description='Subplots:', - layout=Layout(width='200px')) - - self.widget = HBox([self.autoscale_button, - self.center_scene_button, - self.panzoom_controller_button, - self.maintain_aspect_button, - self.flip_camera_button, - self.record_button, - self.dropdown]) - - self.panzoom_controller_button.observe(self.panzoom_control, 'value') + self.dropdown = Dropdown( + options=values, + disabled=False, + description="Subplots:", + layout=Layout(width="200px"), + ) + + self.widget = HBox( + [ + self.autoscale_button, + self.center_scene_button, + self.panzoom_controller_button, + self.maintain_aspect_button, + self.flip_camera_button, + self.record_button, + self.dropdown, + ] + ) + + self.panzoom_controller_button.observe(self.panzoom_control, "value") self.autoscale_button.on_click(self.auto_scale) self.center_scene_button.on_click(self.center_scene) - self.maintain_aspect_button.observe(self.maintain_aspect, 'value') + self.maintain_aspect_button.observe(self.maintain_aspect, "value") self.flip_camera_button.on_click(self.flip_camera) - self.record_button.observe(self.record_plot, 'value') + self.record_button.observe(self.record_plot, "value") self.plot.renderer.add_event_handler(self.update_current_subplot, "click") @@ -444,7 +489,9 @@ def update_current_subplot(self, ev): def record_plot(self, obj): if self.record_button.value: try: - self.plot.record_start(f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4") + self.plot.record_start( + f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" + ) except Exception: traceback.print_exc() self.record_button.value = False diff --git a/fastplotlib/plot.py b/fastplotlib/layouts/_plot.py similarity index 68% rename from fastplotlib/plot.py rename to fastplotlib/layouts/_plot.py index e3c358bc2..52caf9cce 100644 --- a/fastplotlib/plot.py +++ b/fastplotlib/layouts/_plot.py @@ -8,19 +8,19 @@ if is_jupyter(): from ipywidgets import HBox, Layout, Button, ToggleButton, VBox -from .layouts._subplot import Subplot -from .layouts._record_mixin import RecordMixin +from ._subplot import Subplot +from ._record_mixin import RecordMixin class Plot(Subplot, RecordMixin): def __init__( - self, - canvas: WgpuCanvas = None, - renderer: pygfx.Renderer = None, - camera: str = '2d', - controller: Union[pygfx.PanZoomController, pygfx.OrbitController] = None, - size: Tuple[int, int] = (500, 300), - **kwargs + self, + canvas: WgpuCanvas = None, + renderer: pygfx.WgpuRenderer = None, + camera: str = "2d", + controller: Union[pygfx.PanZoomController, pygfx.OrbitController] = None, + size: Tuple[int, int] = (500, 300), + **kwargs, ): """ Simple Plot object. @@ -92,7 +92,7 @@ def __init__( renderer=renderer, camera=camera, controller=controller, - **kwargs + **kwargs, ) RecordMixin.__init__(self) @@ -107,10 +107,7 @@ def render(self): self.canvas.request_draw() def show( - self, - autoscale: bool = True, - maintain_aspect: bool = None, - toolbar: bool = True + self, autoscale: bool = True, maintain_aspect: bool = None, toolbar: bool = True ): """ Begins the rendering event loop and returns the canvas @@ -133,7 +130,7 @@ def show( """ self.canvas.request_draw(self.render) - + self.canvas.set_logical_size(*self._starting_size) if maintain_aspect is None: @@ -161,8 +158,7 @@ def close(self): class ToolBar: - def __init__(self, - plot: Plot): + def __init__(self, plot: Plot): """ Basic toolbar for a Plot instance. @@ -172,34 +168,67 @@ def __init__(self, """ self.plot = plot - self.autoscale_button = Button(value=False, disabled=False, icon='expand-arrows-alt', - layout=Layout(width='auto'), tooltip='auto-scale scene') - self.center_scene_button = Button(value=False, disabled=False, icon='align-center', - layout=Layout(width='auto'), tooltip='auto-center scene') - self.panzoom_controller_button = ToggleButton(value=True, disabled=False, icon='hand-pointer', - layout=Layout(width='auto'), tooltip='panzoom controller') - self.maintain_aspect_button = ToggleButton(value=True, disabled=False, description="1:1", - layout=Layout(width='auto'), - tooltip='maintain aspect') + self.autoscale_button = Button( + value=False, + disabled=False, + icon="expand-arrows-alt", + layout=Layout(width="auto"), + tooltip="auto-scale scene", + ) + self.center_scene_button = Button( + value=False, + disabled=False, + icon="align-center", + layout=Layout(width="auto"), + tooltip="auto-center scene", + ) + self.panzoom_controller_button = ToggleButton( + value=True, + disabled=False, + icon="hand-pointer", + layout=Layout(width="auto"), + tooltip="panzoom controller", + ) + self.maintain_aspect_button = ToggleButton( + value=True, + disabled=False, + description="1:1", + layout=Layout(width="auto"), + tooltip="maintain aspect", + ) self.maintain_aspect_button.style.font_weight = "bold" - self.flip_camera_button = Button(value=False, disabled=False, icon='arrows-v', - layout=Layout(width='auto'), tooltip='flip') - self.record_button = ToggleButton(value=False, disabled=False, icon='video', - layout=Layout(width='auto'), tooltip='record') - - self.widget = HBox([self.autoscale_button, - self.center_scene_button, - self.panzoom_controller_button, - self.maintain_aspect_button, - self.flip_camera_button, - self.record_button]) - - self.panzoom_controller_button.observe(self.panzoom_control, 'value') + self.flip_camera_button = Button( + value=False, + disabled=False, + icon="arrows-v", + layout=Layout(width="auto"), + tooltip="flip", + ) + self.record_button = ToggleButton( + value=False, + disabled=False, + icon="video", + layout=Layout(width="auto"), + tooltip="record", + ) + + self.widget = HBox( + [ + self.autoscale_button, + self.center_scene_button, + self.panzoom_controller_button, + self.maintain_aspect_button, + self.flip_camera_button, + self.record_button, + ] + ) + + self.panzoom_controller_button.observe(self.panzoom_control, "value") self.autoscale_button.on_click(self.auto_scale) self.center_scene_button.on_click(self.center_scene) - self.maintain_aspect_button.observe(self.maintain_aspect, 'value') + self.maintain_aspect_button.observe(self.maintain_aspect, "value") self.flip_camera_button.on_click(self.flip_camera) - self.record_button.observe(self.record_plot, 'value') + self.record_button.observe(self.record_plot, "value") def auto_scale(self, obj): self.plot.auto_scale(maintain_aspect=self.plot.camera.maintain_aspect) @@ -219,7 +248,9 @@ def flip_camera(self, obj): def record_plot(self, obj): if self.record_button.value: try: - self.plot.record_start(f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4") + self.plot.record_start( + f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" + ) except Exception: traceback.print_exc() self.record_button.value = False diff --git a/fastplotlib/layouts/_record_mixin.py b/fastplotlib/layouts/_record_mixin.py index 6af722624..e3a491915 100644 --- a/fastplotlib/layouts/_record_mixin.py +++ b/fastplotlib/layouts/_record_mixin.py @@ -13,16 +13,17 @@ class VideoWriterAV(Process): """Video writer, uses PyAV in an external process to write frames to disk""" + def __init__( - self, - path: Union[Path, str], - queue: Queue, - fps: int, - width: int, - height: int, - codec: str, - pixel_format: str, - options: dict = None + self, + path: Union[Path, str], + queue: Queue, + fps: int, + width: int, + height: int, + codec: str, + pixel_format: str, + options: dict = None, ): super().__init__() self.queue = queue @@ -56,8 +57,10 @@ def run(self): break frame = av.VideoFrame.from_ndarray( - frame[:self.stream.height, :self.stream.width], # trim if necessary because of x264 - format="rgb24" + frame[ + : self.stream.height, : self.stream.width + ], # trim if necessary because of x264 + format="rgb24", ) for packet in self.stream.encode(frame): @@ -104,12 +107,12 @@ def _record(self): self._video_writer_queue.put(ss.data[..., :-1]) def record_start( - self, - path: Union[str, Path], - fps: int = 25, - codec: str = "mpeg4", - pixel_format: str = "yuv420p", - options: dict = None + self, + path: Union[str, Path], + fps: int = 25, + codec: str = "mpeg4", + pixel_format: str = "yuv420p", + options: dict = None, ): """ Start a recording, experimental. Call ``record_end()`` to end a recording. @@ -198,7 +201,7 @@ def record_start( height=ss.height, codec=codec, pixel_format=pixel_format, - options=options + options=options, ) # start writer process diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index e2ae59d7e..e7d4a699b 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -1,59 +1,41 @@ from typing import * -import numpy as np -from math import copysign from functools import partial import weakref from inspect import signature, getfullargspec from warnings import warn -import traceback - -from pygfx import Scene, OrthographicCamera, PanZoomController, OrbitController, \ - AxesHelper, GridHelper, WgpuRenderer, Texture -from wgpu.gui.auto import WgpuCanvas - - -# TODO: this determination can be better -try: - from wgpu.gui.jupyter import JupyterWgpuCanvas -except ImportError: - JupyterWgpuCanvas = False - -try: - from wgpu.gui.qt import QWgpuCanvas -except ImportError: - QWgpuCanvas = False - -try: - from wgpu.gui.glfw import GlfwWgpuCanvas -except ImportError: - GlfwWgpuCanvas = False +import numpy as np -CANVAS_OPTIONS = ["jupyter", "glfw", "qt"] -CANVAS_OPTIONS_AVAILABLE = { - "jupyter": JupyterWgpuCanvas, - "glfw": GlfwWgpuCanvas, - "qt": QWgpuCanvas -} +from pygfx import ( + Scene, + OrthographicCamera, + PanZoomController, + OrbitController, + AxesHelper, + GridHelper, + WgpuRenderer, + Texture, +) +from wgpu.gui.auto import WgpuCanvas -from ._utils import make_canvas_and_renderer -from ._base import PlotArea from .. import graphics from ..graphics import TextGraphic +from ._utils import make_canvas_and_renderer +from ._base import PlotArea from ._defaults import create_camera, create_controller class Subplot(PlotArea): def __init__( - self, - position: Tuple[int, int] = None, - parent_dims: Tuple[int, int] = None, - camera: str = '2d', - controller: Union[PanZoomController, OrbitController] = None, - canvas: Union[str, WgpuCanvas, Texture] = None, - renderer: WgpuRenderer = None, - name: str = None, - **kwargs + self, + position: Tuple[int, int] = None, + parent_dims: Tuple[int, int] = None, + camera: str = "2d", + controller: Union[PanZoomController, OrbitController] = None, + canvas: Union[str, WgpuCanvas, Texture] = None, + renderer: WgpuRenderer = None, + name: str = None, + **kwargs, ): """ General plot object that composes a ``Gridplot``. Each ``Gridplot`` instance will have [n rows, n columns] @@ -63,22 +45,29 @@ def __init__( ---------- position: int tuple, optional corresponds to the [row, column] position of the subplot within a ``Gridplot`` + parent_dims: int tuple, optional dimensions of the parent ``GridPlot`` + camera: str, default '2d' indicates the kind of pygfx camera that will be instantiated, '2d' uses pygfx ``OrthographicCamera`` and '3d' uses pygfx ``PerspectiveCamera`` + controller: PanZoomController or OrbitOrthoController, optional ``PanZoomController`` type is used for 2D pan-zoom camera control and ``OrbitController`` type is used for rotating the camera around a center position, used to control the camera + canvas: WgpuCanvas, Texture, or one of "jupyter", "glfw", "qt", optional Provides surface on which a scene will be rendered. Can optionally provide a WgpuCanvas instance or a str to force the PlotArea to use a specific canvas from one of the following options: "jupyter", "glfw", "qt". Can also provide a pygfx Texture to render to. + renderer: WgpuRenderer, optional object used to render scenes using wgpu + name: str, optional name of the subplot, will appear as ``TextGraphic`` above the subplot + """ canvas, renderer = make_canvas_and_renderer(canvas, renderer) @@ -115,7 +104,7 @@ def __init__( scene=Scene(), canvas=canvas, renderer=renderer, - name=name + name=name, ) for pos in ["left", "top", "right", "bottom"]: @@ -194,17 +183,16 @@ def get_rect(self): row_ix, col_ix = self.position width_canvas, height_canvas = self.renderer.logical_size - x_pos = ((width_canvas / self.ncols) + ((col_ix - 1) * (width_canvas / self.ncols))) + self.spacing - y_pos = ((height_canvas / self.nrows) + ((row_ix - 1) * (height_canvas / self.nrows))) + self.spacing + x_pos = ( + (width_canvas / self.ncols) + ((col_ix - 1) * (width_canvas / self.ncols)) + ) + self.spacing + y_pos = ( + (height_canvas / self.nrows) + ((row_ix - 1) * (height_canvas / self.nrows)) + ) + self.spacing width_subplot = (width_canvas / self.ncols) - self.spacing height_subplot = (height_canvas / self.nrows) - self.spacing - rect = np.array([ - x_pos, - y_pos, - width_subplot, - height_subplot - ]) + rect = np.array([x_pos, y_pos, width_subplot, height_subplot]) for dv in self.docked_viewports.values(): rect = rect + dv.get_parent_rect_adjust() @@ -238,10 +226,10 @@ def _call_animate_functions(self, funcs: Iterable[callable]): fn() def add_animations( - self, - *funcs: Iterable[callable], - pre_render: bool = True, - post_render: bool = False + self, + *funcs: Iterable[callable], + pre_render: bool = True, + post_render: bool = False, ): """ Add function(s) that are called on every render cycle. @@ -308,21 +296,18 @@ def set_grid_visibility(self, visible: bool): class _DockedViewport(PlotArea): - _valid_positions = [ - "right", - "left", - "top", - "bottom" - ] + _valid_positions = ["right", "left", "top", "bottom"] def __init__( - self, - parent: Subplot, - position: str, - size: int, + self, + parent: Subplot, + position: str, + size: int, ): if position not in self._valid_positions: - raise ValueError(f"the `position` of an AnchoredViewport must be one of: {self._valid_positions}") + raise ValueError( + f"the `position` of an AnchoredViewport must be one of: {self._valid_positions}" + ) self._size = size @@ -333,13 +318,9 @@ def __init__( controller=PanZoomController(), scene=Scene(), canvas=parent.canvas, - renderer=parent.renderer + renderer=parent.renderer, ) - # self.scene.add( - # Background(None, BackgroundMaterial((0.2, 0.0, 0, 1), (0, 0.0, 0.2, 1))) - # ) - @property def size(self) -> int: return self._size @@ -361,28 +342,59 @@ def get_rect(self, *args): spacing = 2 # spacing in pixels if self.position == "right": - x_pos = (width_canvas / self.parent.ncols) + ((col_ix_parent - 1) * (width_canvas / self.parent.ncols)) + (width_canvas / self.parent.ncols) - self.size - y_pos = ((height_canvas / self.parent.nrows) + ((row_ix_parent - 1) * (height_canvas / self.parent.nrows))) + spacing - width_viewport = self.size - height_viewport = (height_canvas / self.parent.nrows) - spacing + x_pos = ( + (width_canvas / self.parent.ncols) + + ((col_ix_parent - 1) * (width_canvas / self.parent.ncols)) + + (width_canvas / self.parent.ncols) + - self.size + ) + y_pos = ( + (height_canvas / self.parent.nrows) + + ((row_ix_parent - 1) * (height_canvas / self.parent.nrows)) + ) + spacing + width_viewport = self.size + height_viewport = (height_canvas / self.parent.nrows) - spacing elif self.position == "left": - x_pos = (width_canvas / self.parent.ncols) + ((col_ix_parent - 1) * (width_canvas / self.parent.ncols)) - y_pos = ((height_canvas / self.parent.nrows) + ((row_ix_parent - 1) * (height_canvas / self.parent.nrows))) + spacing - width_viewport = self.size - height_viewport = (height_canvas / self.parent.nrows) - spacing + x_pos = (width_canvas / self.parent.ncols) + ( + (col_ix_parent - 1) * (width_canvas / self.parent.ncols) + ) + y_pos = ( + (height_canvas / self.parent.nrows) + + ((row_ix_parent - 1) * (height_canvas / self.parent.nrows)) + ) + spacing + width_viewport = self.size + height_viewport = (height_canvas / self.parent.nrows) - spacing elif self.position == "top": - x_pos = (width_canvas / self.parent.ncols) + ((col_ix_parent - 1) * (width_canvas / self.parent.ncols)) + spacing - y_pos = ((height_canvas / self.parent.nrows) + ((row_ix_parent - 1) * (height_canvas / self.parent.nrows))) + spacing - width_viewport = (width_canvas / self.parent.ncols) - spacing - height_viewport = self.size + x_pos = ( + (width_canvas / self.parent.ncols) + + ((col_ix_parent - 1) * (width_canvas / self.parent.ncols)) + + spacing + ) + y_pos = ( + (height_canvas / self.parent.nrows) + + ((row_ix_parent - 1) * (height_canvas / self.parent.nrows)) + ) + spacing + width_viewport = (width_canvas / self.parent.ncols) - spacing + height_viewport = self.size elif self.position == "bottom": - x_pos = (width_canvas / self.parent.ncols) + ((col_ix_parent - 1) * (width_canvas / self.parent.ncols)) + spacing - y_pos = ((height_canvas / self.parent.nrows) + ((row_ix_parent - 1) * (height_canvas / self.parent.nrows))) + (height_canvas / self.parent.nrows) - self.size - width_viewport = (width_canvas / self.parent.ncols) - spacing - height_viewport = self.size + x_pos = ( + (width_canvas / self.parent.ncols) + + ((col_ix_parent - 1) * (width_canvas / self.parent.ncols)) + + spacing + ) + y_pos = ( + ( + (height_canvas / self.parent.nrows) + + ((row_ix_parent - 1) * (height_canvas / self.parent.nrows)) + ) + + (height_canvas / self.parent.nrows) + - self.size + ) + width_viewport = (width_canvas / self.parent.ncols) - spacing + height_viewport = self.size else: raise ValueError("invalid position") @@ -390,36 +402,44 @@ def get_rect(self, *args): def get_parent_rect_adjust(self): if self.position == "right": - return np.array([ - 0, # parent subplot x-position is same - 0, - -self.size, # width of parent subplot is `self.size` smaller - 0 - ]) + return np.array( + [ + 0, # parent subplot x-position is same + 0, + -self.size, # width of parent subplot is `self.size` smaller + 0, + ] + ) elif self.position == "left": - return np.array([ - self.size, # `self.size` added to parent subplot x-position - 0, - -self.size, # width of parent subplot is `self.size` smaller - 0 - ]) + return np.array( + [ + self.size, # `self.size` added to parent subplot x-position + 0, + -self.size, # width of parent subplot is `self.size` smaller + 0, + ] + ) elif self.position == "top": - return np.array([ - 0, - self.size, # `self.size` added to parent subplot y-position - 0, - -self.size, # height of parent subplot is `self.size` smaller - ]) + return np.array( + [ + 0, + self.size, # `self.size` added to parent subplot y-position + 0, + -self.size, # height of parent subplot is `self.size` smaller + ] + ) elif self.position == "bottom": - return np.array([ - 0, - 0, # parent subplot y-position is same, - 0, - -self.size, # height of parent subplot is `self.size` smaller - ]) + return np.array( + [ + 0, + 0, # parent subplot y-position is same, + 0, + -self.size, # height of parent subplot is `self.size` smaller + ] + ) def render(self): if self.size == 0: diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py index 76f3e4cee..ebfe9e306 100644 --- a/fastplotlib/layouts/_utils.py +++ b/fastplotlib/layouts/_utils.py @@ -28,16 +28,15 @@ CANVAS_OPTIONS_AVAILABLE = { "jupyter": JupyterWgpuCanvas, "glfw": GlfwWgpuCanvas, - "qt": QWgpuCanvas + "qt": QWgpuCanvas, } def make_canvas_and_renderer( - canvas: Union[str, WgpuCanvas, Texture, None], - renderer: [WgpuRenderer, None] + canvas: Union[str, WgpuCanvas, Texture, None], renderer: [WgpuRenderer, None] ): """ - Parses arguments and returns the appropriate canvas and renderer instances + Parses arguments and returns the appropriate canvas and renderer instances as a tuple (canvas, renderer) """ @@ -46,9 +45,7 @@ def make_canvas_and_renderer( elif isinstance(canvas, str): if canvas not in CANVAS_OPTIONS: - raise ValueError( - f"str canvas argument must be one of: {CANVAS_OPTIONS}" - ) + raise ValueError(f"str canvas argument must be one of: {CANVAS_OPTIONS}") elif not CANVAS_OPTIONS_AVAILABLE[canvas]: raise ImportError( f"The {canvas} framework is not installed for using this canvas" diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index ce6740f71..f4d6ac4f1 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -1,30 +1,44 @@ -from typing import Union, List - -import numpy as np -from pygfx import Texture, Color from collections import OrderedDict from typing import * from pathlib import Path + +import numpy as np + +from pygfx import Texture, Color + # some funcs adapted from mesmerize -QUALITATIVE_CMAPS = ['Pastel1', 'Pastel2', 'Paired', 'Accent', 'Dark2', 'Set1', - 'Set2', 'Set3', 'tab10', 'tab20', 'tab20b', 'tab20c'] +QUALITATIVE_CMAPS = [ + "Pastel1", + "Pastel2", + "Paired", + "Accent", + "Dark2", + "Set1", + "Set2", + "Set3", + "tab10", + "tab20", + "tab20b", + "tab20c", +] def get_cmap(name: str, alpha: float = 1.0) -> np.ndarray: - cmap_path = Path(__file__).absolute().parent.joinpath('colormaps', name) + cmap_path = Path(__file__).absolute().parent.joinpath("colormaps", name) if cmap_path.is_file(): cmap = np.loadtxt(cmap_path) else: try: from .generate_colormaps import make_cmap + cmap = make_cmap(name, alpha) - except ModuleNotFoundError as e: + except (ImportError, ModuleNotFoundError): raise ModuleNotFoundError( "Couldn't find colormap files, matplotlib is required to generate them " - "if they aren't found. Please install `matplotlib`." + "if they aren't found. Please install `matplotlib`" ) cmap[:, -1] = alpha @@ -60,8 +74,10 @@ def make_colors(n_colors: int, cmap: str, alpha: float = 1.0) -> np.ndarray: if name in QUALITATIVE_CMAPS: max_colors = cmap.shape[0] if n_colors > cmap.shape[0]: - raise ValueError(f"You have requested <{n_colors}> but only <{max_colors} existing for the " - f"chosen cmap: <{cmap}>") + raise ValueError( + f"You have requested <{n_colors}> but only <{max_colors} existing for the " + f"chosen cmap: <{cmap}>" + ) return cmap[:n_colors] cm_ixs = np.linspace(0, 255, n_colors, dtype=int) @@ -139,7 +155,9 @@ def quick_min_max(data: np.ndarray) -> Tuple[float, float]: if hasattr(data, "min") and hasattr(data, "max"): # if value is pre-computed - if isinstance(data.min, (float, int, np.number)) and isinstance(data.max, (float, int, np.number)): + if isinstance(data.min, (float, int, np.number)) and isinstance( + data.max, (float, int, np.number) + ): return data.min, data.max while data.size > 1e6: @@ -164,10 +182,7 @@ def calculate_gridshape(n_subplots: int) -> Tuple[int, int]: """ sr = np.sqrt(n_subplots) - return ( - int(np.round(sr)), - int(np.ceil(sr)) - ) + return (int(np.round(sr)), int(np.ceil(sr))) def normalize_min_max(a): @@ -176,9 +191,9 @@ def normalize_min_max(a): def parse_cmap_values( - n_colors: int, - cmap_name: str, - cmap_values: Union[np.ndarray, List[Union[int, float]]] = None + n_colors: int, + cmap_name: str, + cmap_values: Union[np.ndarray, List[Union[int, float]]] = None, ) -> np.ndarray: """ diff --git a/fastplotlib/utils/generate_colormaps.py b/fastplotlib/utils/generate_colormaps.py index 029cce2b0..e56a9f226 100644 --- a/fastplotlib/utils/generate_colormaps.py +++ b/fastplotlib/utils/generate_colormaps.py @@ -3,31 +3,108 @@ class ColormapNames: - perceptually_uniform = ['viridis', 'plasma', 'inferno', 'magma', 'cividis'] - sequential = ['Greys', 'Purples', 'Blues', 'Greens', 'Oranges', 'Reds', - 'YlOrBr', 'YlOrRd', 'OrRd', 'PuRd', 'RdPu', 'BuPu', - 'GnBu', 'PuBu', 'YlGnBu', 'PuBuGn', 'BuGn', 'YlGn'] + perceptually_uniform = ["viridis", "plasma", "inferno", "magma", "cividis"] + sequential = [ + "Greys", + "Purples", + "Blues", + "Greens", + "Oranges", + "Reds", + "YlOrBr", + "YlOrRd", + "OrRd", + "PuRd", + "RdPu", + "BuPu", + "GnBu", + "PuBu", + "YlGnBu", + "PuBuGn", + "BuGn", + "YlGn", + ] - sequential2 = ['binary', 'gist_yarg', 'gist_gray', 'gray', 'bone', - 'pink', 'spring', 'summer', 'autumn', 'winter', 'cool', - 'Wistia', 'hot', 'afmhot', 'gist_heat', 'copper'] + sequential2 = [ + "binary", + "gist_yarg", + "gist_gray", + "gray", + "bone", + "pink", + "spring", + "summer", + "autumn", + "winter", + "cool", + "Wistia", + "hot", + "afmhot", + "gist_heat", + "copper", + ] - diverging = ['PiYG', 'PRGn', 'BrBG', 'PuOr', 'RdGy', 'RdBu', 'RdYlBu', - 'RdYlGn', 'Spectral', 'coolwarm', 'bwr', 'seismic'] + diverging = [ + "PiYG", + "PRGn", + "BrBG", + "PuOr", + "RdGy", + "RdBu", + "RdYlBu", + "RdYlGn", + "Spectral", + "coolwarm", + "bwr", + "seismic", + ] - cyclic = ['twilight', 'twilight_shifted', 'hsv'] + cyclic = ["twilight", "twilight_shifted", "hsv"] - qualitative = ['Pastel1', 'Pastel2', 'Paired', 'Accent', 'Dark2', - 'Set1', 'Set2', 'Set3', 'tab10', 'tab20', 'tab20b', - 'tab20c'] + qualitative = [ + "Pastel1", + "Pastel2", + "Paired", + "Accent", + "Dark2", + "Set1", + "Set2", + "Set3", + "tab10", + "tab20", + "tab20b", + "tab20c", + ] - miscellaneous = ['flag', 'prism', 'ocean', 'gist_earth', 'terrain', - 'gist_stern', 'gnuplot', 'gnuplot2', 'CMRmap', - 'cubehelix', 'brg', 'gist_rainbow', 'rainbow', 'jet', - 'turbo', 'nipy_spectral', 'gist_ncar'] + miscellaneous = [ + "flag", + "prism", + "ocean", + "gist_earth", + "terrain", + "gist_stern", + "gnuplot", + "gnuplot2", + "CMRmap", + "cubehelix", + "brg", + "gist_rainbow", + "rainbow", + "jet", + "turbo", + "nipy_spectral", + "gist_ncar", + ] - all = perceptually_uniform + sequential + sequential2 + \ - diverging + cyclic + qualitative + miscellaneous + all = ( + perceptually_uniform + + sequential + + sequential2 + + diverging + + cyclic + + qualitative + + miscellaneous + ) def make_cmap(name: str, alpha: float = 1.0) -> np.ndarray: @@ -44,6 +121,6 @@ def make_cmap(name: str, alpha: float = 1.0) -> np.ndarray: return cmap.astype(np.float32) -if __name__ == '__main__': +if __name__ == "__main__": for name in ColormapNames().all: - np.savetxt(f'./colormaps/{name}', make_cmap(name)) + np.savetxt(f"./colormaps/{name}", make_cmap(name)) diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index 553e990bf..30a68d672 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -1 +1,3 @@ from .image import ImageWidget + +__all__ = ["ImageWidget"] diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index e57bae216..80d4868ce 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -5,20 +5,29 @@ import numpy as np from wgpu.gui.auto import is_jupyter -from ipywidgets.widgets import IntSlider, VBox, HBox, Layout, FloatRangeSlider, Button, BoundedIntText, Play, jslink +from ipywidgets.widgets import ( + IntSlider, + VBox, + HBox, + Layout, + FloatRangeSlider, + Button, + BoundedIntText, + Play, + jslink, +) from ..layouts import GridPlot from ..graphics import ImageGraphic from ..utils import quick_min_max, calculate_gridshape -DEFAULT_DIMS_ORDER = \ - { - 2: "xy", - 3: "txy", - 4: "tzxy", - 5: "tzcxy", - } +DEFAULT_DIMS_ORDER = { + 2: "xy", + 3: "txy", + 4: "tzxy", + 5: "tzcxy", +} def _is_arraylike(obj) -> bool: @@ -26,11 +35,7 @@ def _is_arraylike(obj) -> bool: Checks if the object is array-like. For now just checks if obj has `__getitem__()` """ - for attr in [ - "__getitem__", - "shape", - "ndim" - ]: + for attr in ["__getitem__", "shape", "ndim"]: if not hasattr(obj, attr): return False @@ -133,7 +138,9 @@ def current_index(self, index: Dict[str, int]): | example: if you have sliders for dims "t" and "z", you can pass either ``{"t": 10}`` to index to position 10 on dimension "t" or ``{"t": 5, "z": 20}`` to index to position 5 on dimension "t" and position 20 on dimension "z" simultaneously. + """ + if not set(index.keys()).issubset(set(self._current_index.keys())): raise KeyError( f"All dimension keys for setting `current_index` must be present in the widget sliders. " @@ -146,8 +153,10 @@ def current_index(self, index: Dict[str, int]): if val < 0: raise IndexError("negative indexing is not supported for ImageWidget") if val > self._dims_max_bounds[k]: - raise IndexError(f"index {val} is out of bounds for dimension '{k}' " - f"which has a max bound of: {self._dims_max_bounds[k]}") + raise IndexError( + f"index {val} is out of bounds for dimension '{k}' " + f"which has a max bound of: {self._dims_max_bounds[k]}" + ) self._current_index.update(index) @@ -163,17 +172,17 @@ def current_index(self, index: Dict[str, int]): ig.data = frame def __init__( - self, - data: Union[np.ndarray, List[np.ndarray]], - dims_order: Union[str, Dict[int, str]] = None, - slider_dims: Union[str, int, List[Union[str, int]]] = None, - window_funcs: Union[int, Dict[str, int]] = None, - frame_apply: Union[callable, Dict[int, callable]] = None, - vmin_vmax_sliders: bool = False, - grid_shape: Tuple[int, int] = None, - names: List[str] = None, - grid_plot_kwargs: dict = None, - **kwargs + self, + data: Union[np.ndarray, List[np.ndarray]], + dims_order: Union[str, Dict[int, str]] = None, + slider_dims: Union[str, int, List[Union[str, int]]] = None, + window_funcs: Union[int, Dict[str, int]] = None, + frame_apply: Union[callable, Dict[int, callable]] = None, + vmin_vmax_sliders: bool = False, + grid_shape: Tuple[int, int] = None, + names: List[str] = None, + grid_plot_kwargs: dict = None, + **kwargs, ): """ A high level widget for displaying n-dimensional image data in conjunction with automatically generated @@ -238,7 +247,9 @@ def __init__( kwargs: Any passed to fastplotlib.graphics.Image + """ + if not is_jupyter(): raise EnvironmentError( "ImageWidget is currently not supported outside of jupyter" @@ -256,7 +267,9 @@ def __init__( # verify that user-specified grid shape is large enough for the number of image arrays passed elif grid_shape[0] * grid_shape[1] < len(data): grid_shape = calculate_gridshape(len(data)) - warn(f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}") + warn( + f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}" + ) _ndim = [d.ndim for d in data] @@ -272,7 +285,9 @@ def __init__( if names is not None: if not all([isinstance(n, str) for n in names]): - raise TypeError("optinal argument `names` must be a list of str") + raise TypeError( + "optinal argument `names` must be a list of str" + ) if len(names) != len(self.data): raise ValueError( @@ -314,7 +329,9 @@ def __init__( ) self._dims_order: List[str] = [dims_order] * len(self.data) elif isinstance(dims_order, dict): - self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) + self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len( + self.data + ) # dict of {array_ix: dims_order_str} for data_ix in list(dims_order.keys()): @@ -342,7 +359,8 @@ def __init__( ) else: raise TypeError( - f"`dims_order` must be a or , you have passed a: <{type(dims_order)}>") + f"`dims_order` must be a or , you have passed a: <{type(dims_order)}>" + ) if not len(self.dims_order[0]) == self.ndim: raise ValueError( @@ -431,7 +449,9 @@ def __init__( ) else: - raise TypeError(f"`slider_dims` must a , or , you have passed a: {type(slider_dims)}") + raise TypeError( + f"`slider_dims` must a , or , you have passed a: {type(slider_dims)}" + ) self.frame_apply: Dict[int, callable] = dict() @@ -440,7 +460,9 @@ def __init__( self.frame_apply = {0: frame_apply} elif isinstance(frame_apply, dict): - self.frame_apply: Dict[int, callable] = dict.fromkeys(list(range(len(self.data)))) + self.frame_apply: Dict[int, callable] = dict.fromkeys( + list(range(len(self.data))) + ) # dict of {array: dims_order_str} for data_ix in list(frame_apply.keys()): @@ -455,14 +477,13 @@ def __init__( else: raise TypeError( f"`frame_apply` must be a callable or , " - f"you have passed a: <{type(frame_apply)}>") + f"you have passed a: <{type(frame_apply)}>" + ) self._window_funcs = None self.window_funcs = window_funcs self._sliders: Dict[str, IntSlider] = dict() - self._vertical_sliders = list() - self._horizontal_sliders = list() # current_index stores {dimension_index: slice_index} for every dimension self._current_index: Dict[str, int] = {sax: 0 for sax in self.slider_dims} @@ -473,7 +494,9 @@ def __init__( self._dims_max_bounds: Dict[str, int] = {k: np.inf for k in self.slider_dims} for _dim in list(self._dims_max_bounds.keys()): for array, order in zip(self.data, self.dims_order): - self._dims_max_bounds[_dim] = min(self._dims_max_bounds[_dim], array.shape[order.index(_dim)]) + self._dims_max_bounds[_dim] = min( + self._dims_max_bounds[_dim], array.shape[order.index(_dim)] + ) if grid_plot_kwargs is None: grid_plot_kwargs = {"controllers": "sync"} @@ -501,12 +524,11 @@ def __init__( step=data_range / 150, description=f"mm: {name_slider}", readout=True, - readout_format='.3f', + readout_format=".3f", ) minmax_slider.observe( - partial(self._vmin_vmax_slider_changed, data_ix), - names="value" + partial(self._vmin_vmax_slider_changed, data_ix), names="value" ) self.vmin_vmax_sliders.append(minmax_slider) @@ -521,58 +543,27 @@ def __init__( self.gridplot.renderer.add_event_handler(self._set_slider_layout, "resize") for sdm in self.slider_dims: - if sdm == "z": - # TODO: once ipywidgets plays nicely with HBox and jupyter-rfb, use vertical - # orientation = "vertical" - orientation = "horizontal" - else: - orientation = "horizontal" - slider = IntSlider( min=0, max=self._dims_max_bounds[sdm] - 1, step=1, value=0, description=f"dimension: {sdm}", - orientation=orientation + orientation="horizontal", ) - slider.observe( - partial(self._slider_value_changed, sdm), - names="value" - ) + slider.observe(partial(self._slider_value_changed, sdm), names="value") self._sliders[sdm] = slider - if orientation == "horizontal": - self._horizontal_sliders.append(slider) - elif orientation == "vertical": - self._vertical_sliders.append(slider) # will change later # prevent the slider callback if value is self.current_index is changed programmatically self.block_sliders: bool = False # TODO: So just stack everything vertically for now - self._vbox_sliders = VBox([ - *list(self._sliders.values()), - *self.vmin_vmax_sliders - ]) - - # TODO: there is currently an issue with ipywidgets or jupyter-rfb and HBox doesn't work with RFB canvas - # self.widget = None - # hbox = None - # if len(self.vertical_sliders) > 0: - # hbox = HBox(self.vertical_sliders) - # - # if len(self.horizontal_sliders) > 0: - # if hbox is not None: - # self.widget = VBox([ - # HBox([self.plot.canvas, hbox]), - # *self.horizontal_sliders, - # ]) - # - # else: - # self.widget = VBox([self.plot.canvas, *self.horizontal_sliders]) + self._vbox_sliders = VBox( + [*list(self._sliders.values()), *self.vmin_vmax_sliders] + ) @property def window_funcs(self) -> Dict[str, _WindowFunctions]: @@ -601,7 +592,9 @@ def window_funcs(self, sa: Union[int, Dict[str, int]]): # for multiple dims elif isinstance(sa, dict): - if not all([isinstance(_sa, tuple) or (_sa is None) for _sa in sa.values()]): + if not all( + [isinstance(_sa, tuple) or (_sa is None) for _sa in sa.values()] + ): raise TypeError( "dict argument to `window_funcs` must be in the form of: " "`{dimension: (func, window_size)}`. " @@ -609,7 +602,9 @@ def window_funcs(self, sa: Union[int, Dict[str, int]]): ) for v in sa.values(): if v is not None: - if not callable(v[0]) or not (isinstance(v[1], int) or v[1] is None): + if not callable(v[0]) or not ( + isinstance(v[1], int) or v[1] is None + ): raise TypeError( "dict argument to `window_funcs` must be in the form of: " "`{dimension: (func, window_size)}`. " @@ -632,9 +627,7 @@ def window_funcs(self, sa: Union[int, Dict[str, int]]): ) def _process_indices( - self, - array: np.ndarray, - slice_indices: Dict[Union[int, str], int] + self, array: np.ndarray, slice_indices: Dict[Union[int, str], int] ) -> np.ndarray: """ Get the 2D array from the given slice indices. If not returning a 2D slice (such as due to window_funcs) @@ -668,9 +661,7 @@ def _process_indices( data_ix = i break if data_ix is None: - raise ValueError( - f"Given `array` not found in `self.data`" - ) + raise ValueError(f"Given `array` not found in `self.data`") # get axes order for that specific array numerical_dim = self.dims_order[data_ix].index(dim) else: @@ -735,7 +726,9 @@ def _get_window_indices(self, data_ix, dim, indices_dim): half_window = int((window_size - 1) / 2) # half-window size # get the max bound for that dimension max_bound = self._dims_max_bounds[dim_str] - indices_dim = range(max(0, ix - half_window), min(max_bound, ix + half_window)) + indices_dim = range( + max(0, ix - half_window), min(max_bound, ix + half_window) + ) return indices_dim def _process_frame_apply(self, array, data_ix) -> np.ndarray: @@ -750,31 +743,20 @@ def _process_frame_apply(self, array, data_ix) -> np.ndarray: return array - def _slider_value_changed( - self, - dimension: str, - change: dict - ): + def _slider_value_changed(self, dimension: str, change: dict): if self.block_sliders: return self.current_index = {dimension: change["new"]} - def _vmin_vmax_slider_changed( - self, - data_ix: int, - change: dict - ): + def _vmin_vmax_slider_changed(self, data_ix: int, change: dict): vmin, vmax = change["new"] self.managed_graphics[data_ix].cmap.vmin = vmin self.managed_graphics[data_ix].cmap.vmax = vmax def _set_slider_layout(self, *args): w, h = self.gridplot.renderer.logical_size - for hs in self._horizontal_sliders: - hs.layout = Layout(width=f"{w}px") - - for vs in self._vertical_sliders: - vs.layout = Layout(height=f"{h}px") + for k, v in self.sliders.items(): + v.layout = Layout(width=f"{w}px") for mm in self.vmin_vmax_sliders: mm.layout = Layout(width=f"{w}px") @@ -800,7 +782,7 @@ def _get_vmin_vmax_range(self, data: np.ndarray) -> tuple: minmax, data_range, minmax[0] - data_range_40p, - minmax[1] + data_range_40p + minmax[1] + data_range_40p, ) return _range @@ -817,7 +799,7 @@ def reset_vmin_vmax(self): "value": mm[0], "step": mm[1] / 150, "min": mm[2], - "max": mm[3] + "max": mm[3], } self.vmin_vmax_sliders[i].set_state(state) @@ -825,10 +807,10 @@ def reset_vmin_vmax(self): ig.cmap.vmin, ig.cmap.vmax = mm[0] def set_data( - self, - new_data: Union[np.ndarray, List[np.ndarray]], - reset_vmin_vmax: bool = True, - reset_indices: bool = True + self, + new_data: Union[np.ndarray, List[np.ndarray]], + reset_vmin_vmax: bool = True, + reset_indices: bool = True, ): """ Change data of widget. Note: sliders max currently update only for ``txy`` and ``tzxy`` data. @@ -872,7 +854,9 @@ def set_data( ) # if checks pass, update with new data - for i, (new_array, current_array, subplot) in enumerate(zip(new_data, self._data, self.gridplot)): + for i, (new_array, current_array, subplot) in enumerate( + zip(new_data, self._data, self.gridplot) + ): # check last two dims (x and y) to see if data shape is changing old_data_shape = self._data[i].shape[-2:] self._data[i] = new_array @@ -881,7 +865,9 @@ def set_data( # delete graphics at index zero subplot.delete_graphic(graphic=subplot["image_widget_managed"]) # insert new graphic at index zero - frame = self._process_indices(new_array, slice_indices=self._current_index) + frame = self._process_indices( + new_array, slice_indices=self._current_index + ) frame = self._process_frame_apply(frame, i) new_graphic = ImageGraphic(data=frame, name="image_widget_managed") subplot.insert_graphic(graphic=new_graphic) @@ -932,8 +918,7 @@ def show(self, toolbar: bool = True): class ImageWidgetToolbar: - def __init__(self, - iw: ImageWidget): + def __init__(self, iw: ImageWidget): """ Basic toolbar for a ImageWidget instance. @@ -944,25 +929,40 @@ def __init__(self, self.iw = iw self.plot = iw.gridplot - self.reset_vminvmax_button = Button(value=False, disabled=False, icon='adjust', - layout=Layout(width='auto'), tooltip='reset vmin/vmax') + self.reset_vminvmax_button = Button( + value=False, + disabled=False, + icon="adjust", + layout=Layout(width="auto"), + tooltip="reset vmin/vmax", + ) - self.step_size_setter = BoundedIntText(value=1, min=1, max=self.iw.sliders['t'].max, step=1, - description='Step Size:', disabled=False, - description_tooltip='set slider step', layout=Layout(width='150px')) + self.step_size_setter = BoundedIntText( + value=1, + min=1, + max=self.iw.sliders["t"].max, + step=1, + description="Step Size:", + disabled=False, + description_tooltip="set slider step", + layout=Layout(width="150px"), + ) self.play_button = Play( value=0, min=iw.sliders["t"].min, max=iw.sliders["t"].max, step=iw.sliders["t"].step, description="play/pause", - disabled=False) + disabled=False, + ) - self.widget = HBox([self.reset_vminvmax_button, self.play_button, self.step_size_setter]) + self.widget = HBox( + [self.reset_vminvmax_button, self.play_button, self.step_size_setter] + ) self.reset_vminvmax_button.on_click(self.reset_vminvmax) - self.step_size_setter.observe(self.change_stepsize, 'value') - jslink((self.play_button, 'value'), (self.iw.sliders["t"], 'value')) + self.step_size_setter.observe(self.change_stepsize, "value") + jslink((self.play_button, "value"), (self.iw.sliders["t"], "value")) jslink((self.play_button, "max"), (self.iw.sliders["t"], "max")) def reset_vminvmax(self, obj): @@ -970,4 +970,4 @@ def reset_vminvmax(self, obj): self.iw.reset_vmin_vmax() def change_stepsize(self, obj): - self.iw.sliders['t'].step = self.step_size_setter.value \ No newline at end of file + self.iw.sliders["t"].step = self.step_size_setter.value From f85a71ebc7e712ad9deefc22a305ae2a02800ed8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 26 Jun 2023 04:45:59 -0400 Subject: [PATCH 02/21] update examples --- examples/buttons.ipynb | 402 ------------------ examples/misc/garbage_collection.py | 60 +++ examples/misc/large_img.py | 35 ++ .../{ => misc}/selector_performance.ipynb | 0 4 files changed, 95 insertions(+), 402 deletions(-) delete mode 100644 examples/buttons.ipynb create mode 100644 examples/misc/garbage_collection.py create mode 100644 examples/misc/large_img.py rename examples/{ => misc}/selector_performance.ipynb (100%) diff --git a/examples/buttons.ipynb b/examples/buttons.ipynb deleted file mode 100644 index b46e09d5f..000000000 --- a/examples/buttons.ipynb +++ /dev/null @@ -1,402 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "6725ce7d-eea7-44f7-bedc-813e8ce5bf4f", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "from fastplotlib import Plot, GridPlot, ImageWidget\n", - "from ipywidgets import HBox, Checkbox, Image, VBox, Layout, ToggleButton, Button, Dropdown\n", - "import numpy as np" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "33bf59c4-14e5-43a8-8a16-69b6859864c5", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "5039947949ac4d5da76e561e082da8c2", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/clewis7/repos/fastplotlib/fastplotlib/graphics/features/_base.py:34: UserWarning: converting float64 array to float32\n", - " warn(f\"converting {array.dtype} array to float32\")\n" - ] - }, - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "plot = Plot()\n", - "xs = np.linspace(-10, 10, 100)\n", - "# sine wave\n", - "ys = np.sin(xs)\n", - "sine = np.dstack([xs, ys])[0]\n", - "plot.add_line(sine)" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "68ea8011-d6fd-448f-9bf6-34073164d271", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "5819995040a04300b5ccf10abac8b69e", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(JupyterWgpuCanvas(), HBox(children=(Button(icon='expand-arrows-alt', layout=Layout(width='auto'…" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "plot.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "91a31531-818b-46a2-9587-5d9ef5b59b93", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "2f318829d0a4419798a008c5fe2d6677", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "gp = GridPlot(\n", - " shape=(1,2),\n", - " names=[['plot1', 'plot2']])" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "e96bbda7-3693-42f2-bd52-f668f39134f6", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "img = np.random.rand(512,512)\n", - "for subplot in gp:\n", - " subplot.add_image(data=img)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "03b877ba-cf9c-47d9-a0e5-b3e694274a28", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "ff5064077bb944c6a5248f135f052668", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(JupyterWgpuCanvas(), HBox(children=(Button(icon='expand-arrows-alt', layout=Layout(width='auto'…" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "gp.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "36f5e040-cc58-4b0a-beb1-1f66ea02ccb9", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "f21de0c607b24fd281364a7bec8ad837", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "gp2 = GridPlot(shape=(1,2))" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "c6753d45-a0ae-4c96-8ed5-7638c4cf24e3", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "for subplot in gp2:\n", - " subplot.add_image(data=img)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "5a769c0f-6d95-4969-ad9d-24636fc74b18", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "fcb816a9aaab42b3a7a7e443607ad127", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(JupyterWgpuCanvas(), HBox(children=(Button(icon='expand-arrows-alt', layout=Layout(width='auto'…" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "gp2.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "3958829a-1a2b-4aa2-8c9d-408dce9ccf30", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "data = np.random.rand(500, 512, 512)" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "9f60c0b0-d0ee-4ea1-b961-708aff3b91ae", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "433485ec12b44aa68082a93c264e613c", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/clewis7/repos/fastplotlib/fastplotlib/graphics/features/_base.py:34: UserWarning: converting float64 array to float32\n", - " warn(f\"converting {array.dtype} array to float32\")\n" - ] - } - ], - "source": [ - "iw = ImageWidget(data=data, vmin_vmax_sliders=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "346f241c-4bd0-4f90-afd5-3fa617d96dad", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "a1bdd59532f240948d33ae440d61c4a5", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(VBox(children=(JupyterWgpuCanvas(), HBox(children=(Button(icon='expand-arrows-alt', layout=Layo…" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "iw.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "0563ddfb-8fd3-4a99-bcee-3a83ae5d0f32", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "79e924078e7f4cf69be71eaf12a94854", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "iw2 = ImageWidget(data=[data, data, data, data], grid_plot_kwargs={'controllers': np.array([[0, 1], [2, 3]])}, slider_dims=\"t\", vmin_vmax_sliders=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "d7fb94f8-825f-4447-b161-c9dafa1a068a", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "bb3b7dd92b4d4f74ace4adfbca35aaf3", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(VBox(children=(JupyterWgpuCanvas(), HBox(children=(Button(icon='expand-arrows-alt', layout=Layo…" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "iw2.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3743219d-6702-468a-bea6-0e4c4549e9e4", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "68bafb86-db5a-4681-8176-37ec72ce04a8", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/misc/garbage_collection.py b/examples/misc/garbage_collection.py new file mode 100644 index 000000000..baa92e848 --- /dev/null +++ b/examples/misc/garbage_collection.py @@ -0,0 +1,60 @@ +import numpy as np +from wgpu.gui.auto import WgpuCanvas, run +import pygfx as gfx +import subprocess + +canvas = WgpuCanvas() +renderer = gfx.WgpuRenderer(canvas) +scene = gfx.Scene() +camera = gfx.OrthographicCamera(5000, 5000) +camera.position.x = 2048 +camera.position.y = 2048 + + +def make_image(): + data = np.random.rand(4096, 4096).astype(np.float32) + + return gfx.Image( + gfx.Geometry(grid=gfx.Texture(data, dim=2)), + gfx.ImageBasicMaterial(clim=(0, 1)), + ) + + +def draw(): + renderer.render(scene, camera) + canvas.request_draw() + + +def print_nvidia(msg=""): + print(msg) + print( + subprocess.check_output(["nvidia-smi", "--format=csv", "--query-gpu=memory.used"]).decode().split("\n")[1] + ) + print() + + +def add_img(*args): + print_nvidia("Before creating image") + img = make_image() + print_nvidia("After creating image") + scene.add(img) + img.add_event_handler(remove_img, "click") + draw() + print_nvidia("After add image to scene") + + +def remove_img(*args): + img = scene.children[0] + scene.remove(img) + draw() + print_nvidia("After remove image from scene") + del img + draw() + print_nvidia("After del image") + renderer.add_event_handler(print_nvidia, "pointer_move") + + +renderer.add_event_handler(add_img, "double_click") + +draw() +run() diff --git a/examples/misc/large_img.py b/examples/misc/large_img.py new file mode 100644 index 000000000..021bbd6f6 --- /dev/null +++ b/examples/misc/large_img.py @@ -0,0 +1,35 @@ +from fastplotlib import Plot, run +import numpy as np + +temporal = np.load("./array_10-000x108-000.npy") + +from PIL import Image + +Image.MAX_IMAGE_PIXELS = None + +img = Image.open("/home/kushal/Downloads/gigahour_stitched_0042_bbs.png") + +a = np.array(img) + +r = np.random.randint(0, 50, a.size, dtype=np.uint8).reshape(a.shape) + +plot = Plot(renderer_kwargs={"show_fps": True}) +plot.add_heatmap(r) +# plot.camera.scale.y = 0.2 +plot.show() + +r = np.random.randint(0, 50, a.size, dtype=np.uint8).reshape(a.shape) +r2 = np.random.randint(0, 50, a.size, dtype=np.uint8).reshape(a.shape) +r3 = np.random.randint(0, 50, a.size, dtype=np.uint8).reshape(a.shape) + +rs = [r, r2, r3] +i = 0 + +def update_frame(p): + global i + p.graphics[0].data[:] = rs[i % 3] + i +=1 + +plot.add_animations(update_frame) + +run() diff --git a/examples/selector_performance.ipynb b/examples/misc/selector_performance.ipynb similarity index 100% rename from examples/selector_performance.ipynb rename to examples/misc/selector_performance.ipynb From bb2e71724a8b32b7df0715093511a20f504e018c Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Mon, 26 Jun 2023 05:47:08 -0400 Subject: [PATCH 03/21] Update examples (#261) * heatmap and lines cmap nb * update simple.ipynb * move nb examples * simplify examples * move test examples to desktop dir * update CI * update README * add tqdm to test requirements --- .github/workflows/ci.yml | 4 +- .github/workflows/screenshots.yml | 2 +- README.md | 30 +-- examples/README.md | 6 - examples/{ => desktop}/data/iris.npy | Bin examples/{ => desktop}/gridplot/__init__.py | 0 examples/{ => desktop}/gridplot/gridplot.py | 15 +- examples/{ => desktop}/image/__init__.py | 0 examples/{ => desktop}/image/image_cmap.py | 15 +- examples/{ => desktop}/image/image_rgb.py | 16 +- .../{ => desktop}/image/image_rgbvminvmax.py | 14 +- examples/{ => desktop}/image/image_simple.py | 15 +- .../{ => desktop}/image/image_vminvmax.py | 15 +- examples/{ => desktop}/line/__init__.py | 0 examples/{ => desktop}/line/line.py | 8 +- examples/{ => desktop}/line/line_cmap.py | 3 + .../{ => desktop}/line/line_colorslice.py | 7 +- examples/{ => desktop}/line/line_dataslice.py | 7 +- .../line/line_present_scaling.py | 8 +- .../{ => desktop}/line_collection/__init__.py | 0 .../line_collection/line_collection.py | 3 + .../line_collection_cmap_values.py | 3 + ...line_collection_cmap_values_qualitative.py | 3 + .../line_collection/line_collection_colors.py | 3 + .../line_collection/line_stack.py | 3 + examples/{ => desktop}/scatter/__init__.py | 0 examples/{ => desktop}/scatter/scatter.py | 13 +- .../{ => desktop}/scatter/scatter_cmap.py | 8 +- .../scatter/scatter_colorslice.py | 12 +- .../scatter/scatter_dataslice.py | 13 +- .../{ => desktop}/scatter/scatter_present.py | 10 +- .../{ => desktop}/screenshots/gridplot.png | 0 .../{ => desktop}/screenshots/image_cmap.png | 0 .../{ => desktop}/screenshots/image_rgb.png | 0 .../screenshots/image_rgbvminvmax.png | 0 .../screenshots/image_simple.png | 0 .../screenshots/image_vminvmax.png | 0 examples/{ => desktop}/screenshots/line.png | 0 .../{ => desktop}/screenshots/line_cmap.png | 0 .../screenshots/line_collection.png | 0 .../line_collection_cmap_values.png | 0 ...ine_collection_cmap_values_qualitative.png | 0 .../screenshots/line_collection_colors.png | 0 .../screenshots/line_colorslice.png | 0 .../screenshots/line_dataslice.png | 0 .../screenshots/line_present_scaling.png | 0 .../{ => desktop}/screenshots/line_stack.png | 0 .../{ => desktop}/screenshots/scatter.png | 0 .../screenshots/scatter_cmap.png | 0 .../screenshots/scatter_colorslice.png | 0 .../screenshots/scatter_dataslice.png | 0 .../screenshots/scatter_present.png | 0 .../notebooks}/gridplot.ipynb | 0 .../notebooks}/gridplot_simple.ipynb | 0 examples/notebooks/heatmap.ipynb | 135 ++++++++++++++ .../notebooks}/image_widget.ipynb | 0 .../notebooks}/linear_region_selector.ipynb | 0 .../notebooks}/linear_selector.ipynb | 0 .../notebooks}/lineplot.ipynb | 0 examples/notebooks/lines_cmap.ipynb | 174 ++++++++++++++++++ .../notebooks}/scatter.ipynb | 0 .../notebooks}/simple.ipynb | 92 ++++++--- examples/tests/testutils.py | 2 +- setup.py | 1 + 64 files changed, 490 insertions(+), 150 deletions(-) delete mode 100644 examples/README.md rename examples/{ => desktop}/data/iris.npy (100%) rename examples/{ => desktop}/gridplot/__init__.py (100%) rename examples/{ => desktop}/gridplot/gridplot.py (67%) rename examples/{ => desktop}/image/__init__.py (100%) rename examples/{ => desktop}/image/image_cmap.py (62%) rename examples/{ => desktop}/image/image_rgb.py (57%) rename examples/{ => desktop}/image/image_rgbvminvmax.py (64%) rename examples/{ => desktop}/image/image_simple.py (59%) rename examples/{ => desktop}/image/image_vminvmax.py (63%) rename examples/{ => desktop}/line/__init__.py (100%) rename examples/{ => desktop}/line/line.py (85%) rename examples/{ => desktop}/line/line_cmap.py (89%) rename examples/{ => desktop}/line/line_colorslice.py (91%) rename examples/{ => desktop}/line/line_dataslice.py (89%) rename examples/{ => desktop}/line/line_present_scaling.py (87%) rename examples/{ => desktop}/line_collection/__init__.py (100%) rename examples/{ => desktop}/line_collection/line_collection.py (89%) rename examples/{ => desktop}/line_collection/line_collection_cmap_values.py (92%) rename examples/{ => desktop}/line_collection/line_collection_cmap_values_qualitative.py (92%) rename examples/{ => desktop}/line_collection/line_collection_colors.py (90%) rename examples/{ => desktop}/line_collection/line_stack.py (83%) rename examples/{ => desktop}/scatter/__init__.py (100%) rename examples/{ => desktop}/scatter/scatter.py (73%) rename examples/{ => desktop}/scatter/scatter_cmap.py (83%) rename examples/{ => desktop}/scatter/scatter_colorslice.py (77%) rename examples/{ => desktop}/scatter/scatter_dataslice.py (81%) rename examples/{ => desktop}/scatter/scatter_present.py (79%) rename examples/{ => desktop}/screenshots/gridplot.png (100%) rename examples/{ => desktop}/screenshots/image_cmap.png (100%) rename examples/{ => desktop}/screenshots/image_rgb.png (100%) rename examples/{ => desktop}/screenshots/image_rgbvminvmax.png (100%) rename examples/{ => desktop}/screenshots/image_simple.png (100%) rename examples/{ => desktop}/screenshots/image_vminvmax.png (100%) rename examples/{ => desktop}/screenshots/line.png (100%) rename examples/{ => desktop}/screenshots/line_cmap.png (100%) rename examples/{ => desktop}/screenshots/line_collection.png (100%) rename examples/{ => desktop}/screenshots/line_collection_cmap_values.png (100%) rename examples/{ => desktop}/screenshots/line_collection_cmap_values_qualitative.png (100%) rename examples/{ => desktop}/screenshots/line_collection_colors.png (100%) rename examples/{ => desktop}/screenshots/line_colorslice.png (100%) rename examples/{ => desktop}/screenshots/line_dataslice.png (100%) rename examples/{ => desktop}/screenshots/line_present_scaling.png (100%) rename examples/{ => desktop}/screenshots/line_stack.png (100%) rename examples/{ => desktop}/screenshots/scatter.png (100%) rename examples/{ => desktop}/screenshots/scatter_cmap.png (100%) rename examples/{ => desktop}/screenshots/scatter_colorslice.png (100%) rename examples/{ => desktop}/screenshots/scatter_dataslice.png (100%) rename examples/{ => desktop}/screenshots/scatter_present.png (100%) rename {notebooks => examples/notebooks}/gridplot.ipynb (100%) rename {notebooks => examples/notebooks}/gridplot_simple.ipynb (100%) create mode 100644 examples/notebooks/heatmap.ipynb rename {notebooks => examples/notebooks}/image_widget.ipynb (100%) rename {notebooks => examples/notebooks}/linear_region_selector.ipynb (100%) rename {notebooks => examples/notebooks}/linear_selector.ipynb (100%) rename {notebooks => examples/notebooks}/lineplot.ipynb (100%) create mode 100644 examples/notebooks/lines_cmap.ipynb rename {notebooks => examples/notebooks}/scatter.ipynb (100%) rename {notebooks => examples/notebooks}/simple.ipynb (95%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0ddb4baf..582d02fe3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,9 +61,9 @@ jobs: PYGFX_EXPECT_LAVAPIPE: true run: | pytest -v examples - pytest --nbmake notebooks/ + pytest --nbmake examples/notebooks/ - uses: actions/upload-artifact@v3 if: ${{ failure() }} with: name: screenshot-diffs - path: examples/diffs + path: examples/desktop/diffs diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index 98d2ad86b..984d84aba 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -48,4 +48,4 @@ jobs: if: always() with: name: screenshots - path: examples/screenshots/ + path: examples/desktop/screenshots/ diff --git a/README.md b/README.md index 856df65d8..976ac1777 100644 --- a/README.md +++ b/README.md @@ -77,18 +77,26 @@ pip install -e ".[notebook,docs,tests]" > > `fastplotlib` and `pygfx` are fast evolving, you may require the latest `pygfx` and `fastplotlib` from github to use the examples in the master branch. -Clone or download the repo to try the examples +First clone or download the repo to try the examples ```bash -# clone the repo git clone https://github.com/kushalkolar/fastplotlib.git +``` + +### Desktop examples using `glfw` or `Qt` -# IMPORTANT: if you are using a specific version from pip, checkout that version to get the examples which work for that version -# example: -# git checkout git checkout v0.1.0.a9 # replace "v0.1.0.a9" with the version you have +```bash +# most dirs within examples contain example code +cd examples/desktop -# cd into notebooks and launch jupyter lab -cd fastplotlib/notebooks +# simplest example +python image/image_simple.py +``` + +### Notebook examples + +```bash +cd examples/notebooks jupyter lab ``` @@ -96,10 +104,10 @@ jupyter lab ### Simple image plot ```python -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np -plot = Plot() +plot = fpl.Plot() data = np.random.rand(512, 512) plot.add_image(data=data) @@ -110,10 +118,10 @@ plot.show() ### Fast animations ```python -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np -plot = Plot() +plot = fpl.Plot() data = np.random.rand(512, 512) image = plot.image(data=data) diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 1b79c879b..000000000 --- a/examples/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Examples - -**IMPORTANT NOTE: If you install `fastplotlib` and `pygfx` from `pypi`, i.e. `pip install pygfx`, you will need to use the examples from the following commit until `pygfx` publishes a new release to `pypi`: https://github.com/kushalkolar/fastplotlib/tree/f872155eb687b18e3cc9b3b720eb9e241a9f974c/examples . -The current examples will work if you installed `fastplotlib` and `pygfx` directly from github** - -Both `fastplotlib` and `pygfx` are rapidly evolving libraries, and we try to closely track `pygfx`. diff --git a/examples/data/iris.npy b/examples/desktop/data/iris.npy similarity index 100% rename from examples/data/iris.npy rename to examples/desktop/data/iris.npy diff --git a/examples/gridplot/__init__.py b/examples/desktop/gridplot/__init__.py similarity index 100% rename from examples/gridplot/__init__.py rename to examples/desktop/gridplot/__init__.py diff --git a/examples/gridplot/gridplot.py b/examples/desktop/gridplot/gridplot.py similarity index 67% rename from examples/gridplot/gridplot.py rename to examples/desktop/gridplot/gridplot.py index 211c671f7..3acf6a8ba 100644 --- a/examples/gridplot/gridplot.py +++ b/examples/desktop/gridplot/gridplot.py @@ -6,17 +6,13 @@ # test_example = true -from fastplotlib import GridPlot -import numpy as np +import fastplotlib as fpl import imageio.v3 as iio -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = GridPlot(shape=(2,2), canvas=canvas, renderer=renderer) +plot = fpl.GridPlot(shape=(2, 2)) +# to force a specific framework such as glfw: +# plot = fpl.GridPlot(canvas="glfw") im = iio.imread("imageio:clock.png") im2 = iio.imread("imageio:astronaut.png") @@ -35,7 +31,6 @@ for subplot in plot: subplot.auto_scale() -img = np.asarray(plot.renderer.target.draw()) - if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/image/__init__.py b/examples/desktop/image/__init__.py similarity index 100% rename from examples/image/__init__.py rename to examples/desktop/image/__init__.py diff --git a/examples/image/image_cmap.py b/examples/desktop/image/image_cmap.py similarity index 62% rename from examples/image/image_cmap.py rename to examples/desktop/image/image_cmap.py index 3f061c9d4..9a9f0d497 100644 --- a/examples/image/image_cmap.py +++ b/examples/desktop/image/image_cmap.py @@ -5,17 +5,13 @@ """ # test_example = true -from fastplotlib import Plot -import numpy as np +import fastplotlib as fpl import imageio.v3 as iio -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = Plot(canvas=canvas, renderer=renderer) +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") im = iio.imread("imageio:camera.png") @@ -30,7 +26,6 @@ image_graphic.cmap = "viridis" -img = np.asarray(plot.renderer.target.draw()) - if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/image/image_rgb.py b/examples/desktop/image/image_rgb.py similarity index 57% rename from examples/image/image_rgb.py rename to examples/desktop/image/image_rgb.py index fbd4cf24a..f73077acf 100644 --- a/examples/image/image_rgb.py +++ b/examples/desktop/image/image_rgb.py @@ -5,17 +5,13 @@ """ # test_example = true -from fastplotlib import Plot -import numpy as np +import fastplotlib as fpl import imageio.v3 as iio -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = Plot(canvas=canvas, renderer=renderer) +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") im = iio.imread("imageio:astronaut.png") @@ -28,7 +24,7 @@ plot.auto_scale() -img = np.asarray(plot.renderer.target.draw()) if __name__ == "__main__": - print(__doc__) \ No newline at end of file + print(__doc__) + fpl.run() diff --git a/examples/image/image_rgbvminvmax.py b/examples/desktop/image/image_rgbvminvmax.py similarity index 64% rename from examples/image/image_rgbvminvmax.py rename to examples/desktop/image/image_rgbvminvmax.py index f6b419b60..4891c5614 100644 --- a/examples/image/image_rgbvminvmax.py +++ b/examples/desktop/image/image_rgbvminvmax.py @@ -5,17 +5,13 @@ """ # test_example = true -from fastplotlib import Plot -import numpy as np +import fastplotlib as fpl import imageio.v3 as iio -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = Plot(canvas=canvas, renderer=renderer) +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") im = iio.imread("imageio:astronaut.png") @@ -31,7 +27,7 @@ image_graphic.cmap.vmin = 0.5 image_graphic.cmap.vmax = 0.75 -img = np.asarray(plot.renderer.target.draw()) if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/image/image_simple.py b/examples/desktop/image/image_simple.py similarity index 59% rename from examples/image/image_simple.py rename to examples/desktop/image/image_simple.py index afe5a608e..2d273ad68 100644 --- a/examples/image/image_simple.py +++ b/examples/desktop/image/image_simple.py @@ -6,17 +6,13 @@ # test_example = true -from fastplotlib import Plot -import numpy as np +import fastplotlib as fpl import imageio.v3 as iio -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = Plot(canvas=canvas, renderer=renderer) +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") data = iio.imread("imageio:camera.png") @@ -29,7 +25,6 @@ plot.auto_scale() -img = np.asarray(plot.renderer.target.draw()) - if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/image/image_vminvmax.py b/examples/desktop/image/image_vminvmax.py similarity index 63% rename from examples/image/image_vminvmax.py rename to examples/desktop/image/image_vminvmax.py index c2636bb17..ae5d102fa 100644 --- a/examples/image/image_vminvmax.py +++ b/examples/desktop/image/image_vminvmax.py @@ -5,18 +5,13 @@ """ # test_example = true -from fastplotlib import Plot -import numpy as np -from pathlib import Path +import fastplotlib as fpl import imageio.v3 as iio -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = Plot(canvas=canvas, renderer=renderer) +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") data = iio.imread("imageio:astronaut.png") @@ -32,7 +27,7 @@ image_graphic.cmap.vmin = 0.5 image_graphic.cmap.vmax = 0.75 -img = np.asarray(plot.renderer.target.draw()) if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/line/__init__.py b/examples/desktop/line/__init__.py similarity index 100% rename from examples/line/__init__.py rename to examples/desktop/line/__init__.py diff --git a/examples/line/line.py b/examples/desktop/line/line.py similarity index 85% rename from examples/line/line.py rename to examples/desktop/line/line.py index 45fc5eb5b..8cab1954f 100644 --- a/examples/line/line.py +++ b/examples/desktop/line/line.py @@ -6,11 +6,13 @@ # test_example = true -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np -plot = Plot() +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") xs = np.linspace(-10, 10, 100) # sine wave @@ -41,7 +43,7 @@ plot.auto_scale() -img = np.asarray(plot.renderer.target.draw()) if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/line/line_cmap.py b/examples/desktop/line/line_cmap.py similarity index 89% rename from examples/line/line_cmap.py rename to examples/desktop/line/line_cmap.py index f2fa29d79..b196132ed 100644 --- a/examples/line/line_cmap.py +++ b/examples/desktop/line/line_cmap.py @@ -11,6 +11,8 @@ plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") xs = np.linspace(-10, 10, 100) # sine wave @@ -43,4 +45,5 @@ plot.canvas.set_logical_size(800, 800) if __name__ == "__main__": + print(__doc__) fpl.run() diff --git a/examples/line/line_colorslice.py b/examples/desktop/line/line_colorslice.py similarity index 91% rename from examples/line/line_colorslice.py rename to examples/desktop/line/line_colorslice.py index a82f43aa6..f757a7efe 100644 --- a/examples/line/line_colorslice.py +++ b/examples/desktop/line/line_colorslice.py @@ -6,11 +6,13 @@ # test_example = true -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np -plot = Plot() +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") xs = np.linspace(-10, 10, 100) # sine wave @@ -64,3 +66,4 @@ if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/line/line_dataslice.py b/examples/desktop/line/line_dataslice.py similarity index 89% rename from examples/line/line_dataslice.py rename to examples/desktop/line/line_dataslice.py index ddc670cd2..ef3cccfe8 100644 --- a/examples/line/line_dataslice.py +++ b/examples/desktop/line/line_dataslice.py @@ -6,11 +6,13 @@ # test_example = true -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np -plot = Plot() +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") xs = np.linspace(-10, 10, 100) # sine wave @@ -53,3 +55,4 @@ if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/line/line_present_scaling.py b/examples/desktop/line/line_present_scaling.py similarity index 87% rename from examples/line/line_present_scaling.py rename to examples/desktop/line/line_present_scaling.py index 9cf2706e1..b8e9be63c 100644 --- a/examples/line/line_present_scaling.py +++ b/examples/desktop/line/line_present_scaling.py @@ -6,11 +6,13 @@ # test_example = true -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np -plot = Plot() +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") xs = np.linspace(-10, 10, 100) # sine wave @@ -47,4 +49,4 @@ if __name__ == "__main__": print(__doc__) - + fpl.run() diff --git a/examples/line_collection/__init__.py b/examples/desktop/line_collection/__init__.py similarity index 100% rename from examples/line_collection/__init__.py rename to examples/desktop/line_collection/__init__.py diff --git a/examples/line_collection/line_collection.py b/examples/desktop/line_collection/line_collection.py similarity index 89% rename from examples/line_collection/line_collection.py rename to examples/desktop/line_collection/line_collection.py index 508aca190..071da2e2e 100644 --- a/examples/line_collection/line_collection.py +++ b/examples/desktop/line_collection/line_collection.py @@ -28,6 +28,8 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: pos_xy = np.vstack(circles) plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") plot.add_line_collection(circles, cmap="jet", thickness=5) @@ -36,4 +38,5 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: plot.canvas.set_logical_size(800, 800) if __name__ == "__main__": + print(__doc__) fpl.run() diff --git a/examples/line_collection/line_collection_cmap_values.py b/examples/desktop/line_collection/line_collection_cmap_values.py similarity index 92% rename from examples/line_collection/line_collection_cmap_values.py rename to examples/desktop/line_collection/line_collection_cmap_values.py index 749d25b38..3623c20c3 100644 --- a/examples/line_collection/line_collection_cmap_values.py +++ b/examples/desktop/line_collection/line_collection_cmap_values.py @@ -34,6 +34,8 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: cmap_values = [10] * 4 + [0] * 4 + [7] * 4 + [5] * 4 plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") plot.add_line_collection( circles, @@ -47,4 +49,5 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: plot.canvas.set_logical_size(800, 800) if __name__ == "__main__": + print(__doc__) fpl.run() diff --git a/examples/line_collection/line_collection_cmap_values_qualitative.py b/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py similarity index 92% rename from examples/line_collection/line_collection_cmap_values_qualitative.py rename to examples/desktop/line_collection/line_collection_cmap_values_qualitative.py index f42c46ca3..f56d2ca02 100644 --- a/examples/line_collection/line_collection_cmap_values_qualitative.py +++ b/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py @@ -40,6 +40,8 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: ] plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") plot.add_line_collection( circles, @@ -53,4 +55,5 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: plot.canvas.set_logical_size(800, 800) if __name__ == "__main__": + print(__doc__) fpl.run() diff --git a/examples/line_collection/line_collection_colors.py b/examples/desktop/line_collection/line_collection_colors.py similarity index 90% rename from examples/line_collection/line_collection_colors.py rename to examples/desktop/line_collection/line_collection_colors.py index bb1a2c833..d74f65d82 100644 --- a/examples/line_collection/line_collection_colors.py +++ b/examples/desktop/line_collection/line_collection_colors.py @@ -32,6 +32,8 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: colors = ["blue"] * 4 + ["red"] * 4 + ["yellow"] * 4 + ["w"] * 4 plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") plot.add_line_collection(circles, colors=colors, thickness=10) @@ -40,4 +42,5 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: plot.canvas.set_logical_size(800, 800) if __name__ == "__main__": + print(__doc__) fpl.run() diff --git a/examples/line_collection/line_stack.py b/examples/desktop/line_collection/line_stack.py similarity index 83% rename from examples/line_collection/line_stack.py rename to examples/desktop/line_collection/line_stack.py index 282137c40..5a94caee7 100644 --- a/examples/line_collection/line_stack.py +++ b/examples/desktop/line_collection/line_stack.py @@ -18,6 +18,8 @@ data = np.vstack([ys] * 25) plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") # line stack takes all the same arguments as line collection and behaves similarly plot.add_line_stack(data, cmap="jet") @@ -27,4 +29,5 @@ plot.canvas.set_logical_size(900, 600) if __name__ == "__main__": + print(__doc__) fpl.run() diff --git a/examples/scatter/__init__.py b/examples/desktop/scatter/__init__.py similarity index 100% rename from examples/scatter/__init__.py rename to examples/desktop/scatter/__init__.py diff --git a/examples/scatter/scatter.py b/examples/desktop/scatter/scatter.py similarity index 73% rename from examples/scatter/scatter.py rename to examples/desktop/scatter/scatter.py index c866c4907..243924035 100644 --- a/examples/scatter/scatter.py +++ b/examples/desktop/scatter/scatter.py @@ -6,17 +6,13 @@ # test_example = true -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np from pathlib import Path -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer - -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = Plot(canvas=canvas, renderer=renderer) +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") data = np.load(data_path) @@ -36,3 +32,4 @@ if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/scatter/scatter_cmap.py b/examples/desktop/scatter/scatter_cmap.py similarity index 83% rename from examples/scatter/scatter_cmap.py rename to examples/desktop/scatter/scatter_cmap.py index b6ab5fb17..ae113537a 100644 --- a/examples/scatter/scatter_cmap.py +++ b/examples/desktop/scatter/scatter_cmap.py @@ -6,13 +6,15 @@ # test_example = true -from fastplotlib import Plot, run +import fastplotlib as fpl import numpy as np from pathlib import Path from sklearn.cluster import AgglomerativeClustering -plot = Plot() +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") data = np.load(data_path) @@ -43,4 +45,4 @@ if __name__ == "__main__": print(__doc__) - run() + fpl.run() diff --git a/examples/scatter/scatter_colorslice.py b/examples/desktop/scatter/scatter_colorslice.py similarity index 77% rename from examples/scatter/scatter_colorslice.py rename to examples/desktop/scatter/scatter_colorslice.py index d3dd681a2..f5f32f5be 100644 --- a/examples/scatter/scatter_colorslice.py +++ b/examples/desktop/scatter/scatter_colorslice.py @@ -6,17 +6,14 @@ # test_example = true -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np from pathlib import Path -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = Plot(canvas=canvas, renderer=renderer) +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") data = np.load(data_path) @@ -40,3 +37,4 @@ if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/scatter/scatter_dataslice.py b/examples/desktop/scatter/scatter_dataslice.py similarity index 81% rename from examples/scatter/scatter_dataslice.py rename to examples/desktop/scatter/scatter_dataslice.py index c522ca729..7b80d6c9e 100644 --- a/examples/scatter/scatter_dataslice.py +++ b/examples/desktop/scatter/scatter_dataslice.py @@ -6,17 +6,14 @@ # test_example = true -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np from pathlib import Path -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = Plot(canvas=canvas, renderer=renderer) +plot = fpl.Plot() +# to force a specific framework such as glfw: +# plot = fpl.Plot(canvas="glfw") data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") data = np.load(data_path) @@ -43,4 +40,4 @@ if __name__ == "__main__": print(__doc__) - \ No newline at end of file + fpl.run() diff --git a/examples/scatter/scatter_present.py b/examples/desktop/scatter/scatter_present.py similarity index 79% rename from examples/scatter/scatter_present.py rename to examples/desktop/scatter/scatter_present.py index 8770a1b92..fe0a3bf4f 100644 --- a/examples/scatter/scatter_present.py +++ b/examples/desktop/scatter/scatter_present.py @@ -6,17 +6,12 @@ # test_example = true -from fastplotlib import Plot +import fastplotlib as fpl import numpy as np from pathlib import Path -from wgpu.gui.offscreen import WgpuCanvas -from pygfx import WgpuRenderer -canvas = WgpuCanvas() -renderer = WgpuRenderer(canvas) - -plot = Plot(canvas=canvas, renderer=renderer) +plot = fpl.Plot() data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") data = np.load(data_path) @@ -41,3 +36,4 @@ if __name__ == "__main__": print(__doc__) + fpl.run() diff --git a/examples/screenshots/gridplot.png b/examples/desktop/screenshots/gridplot.png similarity index 100% rename from examples/screenshots/gridplot.png rename to examples/desktop/screenshots/gridplot.png diff --git a/examples/screenshots/image_cmap.png b/examples/desktop/screenshots/image_cmap.png similarity index 100% rename from examples/screenshots/image_cmap.png rename to examples/desktop/screenshots/image_cmap.png diff --git a/examples/screenshots/image_rgb.png b/examples/desktop/screenshots/image_rgb.png similarity index 100% rename from examples/screenshots/image_rgb.png rename to examples/desktop/screenshots/image_rgb.png diff --git a/examples/screenshots/image_rgbvminvmax.png b/examples/desktop/screenshots/image_rgbvminvmax.png similarity index 100% rename from examples/screenshots/image_rgbvminvmax.png rename to examples/desktop/screenshots/image_rgbvminvmax.png diff --git a/examples/screenshots/image_simple.png b/examples/desktop/screenshots/image_simple.png similarity index 100% rename from examples/screenshots/image_simple.png rename to examples/desktop/screenshots/image_simple.png diff --git a/examples/screenshots/image_vminvmax.png b/examples/desktop/screenshots/image_vminvmax.png similarity index 100% rename from examples/screenshots/image_vminvmax.png rename to examples/desktop/screenshots/image_vminvmax.png diff --git a/examples/screenshots/line.png b/examples/desktop/screenshots/line.png similarity index 100% rename from examples/screenshots/line.png rename to examples/desktop/screenshots/line.png diff --git a/examples/screenshots/line_cmap.png b/examples/desktop/screenshots/line_cmap.png similarity index 100% rename from examples/screenshots/line_cmap.png rename to examples/desktop/screenshots/line_cmap.png diff --git a/examples/screenshots/line_collection.png b/examples/desktop/screenshots/line_collection.png similarity index 100% rename from examples/screenshots/line_collection.png rename to examples/desktop/screenshots/line_collection.png diff --git a/examples/screenshots/line_collection_cmap_values.png b/examples/desktop/screenshots/line_collection_cmap_values.png similarity index 100% rename from examples/screenshots/line_collection_cmap_values.png rename to examples/desktop/screenshots/line_collection_cmap_values.png diff --git a/examples/screenshots/line_collection_cmap_values_qualitative.png b/examples/desktop/screenshots/line_collection_cmap_values_qualitative.png similarity index 100% rename from examples/screenshots/line_collection_cmap_values_qualitative.png rename to examples/desktop/screenshots/line_collection_cmap_values_qualitative.png diff --git a/examples/screenshots/line_collection_colors.png b/examples/desktop/screenshots/line_collection_colors.png similarity index 100% rename from examples/screenshots/line_collection_colors.png rename to examples/desktop/screenshots/line_collection_colors.png diff --git a/examples/screenshots/line_colorslice.png b/examples/desktop/screenshots/line_colorslice.png similarity index 100% rename from examples/screenshots/line_colorslice.png rename to examples/desktop/screenshots/line_colorslice.png diff --git a/examples/screenshots/line_dataslice.png b/examples/desktop/screenshots/line_dataslice.png similarity index 100% rename from examples/screenshots/line_dataslice.png rename to examples/desktop/screenshots/line_dataslice.png diff --git a/examples/screenshots/line_present_scaling.png b/examples/desktop/screenshots/line_present_scaling.png similarity index 100% rename from examples/screenshots/line_present_scaling.png rename to examples/desktop/screenshots/line_present_scaling.png diff --git a/examples/screenshots/line_stack.png b/examples/desktop/screenshots/line_stack.png similarity index 100% rename from examples/screenshots/line_stack.png rename to examples/desktop/screenshots/line_stack.png diff --git a/examples/screenshots/scatter.png b/examples/desktop/screenshots/scatter.png similarity index 100% rename from examples/screenshots/scatter.png rename to examples/desktop/screenshots/scatter.png diff --git a/examples/screenshots/scatter_cmap.png b/examples/desktop/screenshots/scatter_cmap.png similarity index 100% rename from examples/screenshots/scatter_cmap.png rename to examples/desktop/screenshots/scatter_cmap.png diff --git a/examples/screenshots/scatter_colorslice.png b/examples/desktop/screenshots/scatter_colorslice.png similarity index 100% rename from examples/screenshots/scatter_colorslice.png rename to examples/desktop/screenshots/scatter_colorslice.png diff --git a/examples/screenshots/scatter_dataslice.png b/examples/desktop/screenshots/scatter_dataslice.png similarity index 100% rename from examples/screenshots/scatter_dataslice.png rename to examples/desktop/screenshots/scatter_dataslice.png diff --git a/examples/screenshots/scatter_present.png b/examples/desktop/screenshots/scatter_present.png similarity index 100% rename from examples/screenshots/scatter_present.png rename to examples/desktop/screenshots/scatter_present.png diff --git a/notebooks/gridplot.ipynb b/examples/notebooks/gridplot.ipynb similarity index 100% rename from notebooks/gridplot.ipynb rename to examples/notebooks/gridplot.ipynb diff --git a/notebooks/gridplot_simple.ipynb b/examples/notebooks/gridplot_simple.ipynb similarity index 100% rename from notebooks/gridplot_simple.ipynb rename to examples/notebooks/gridplot_simple.ipynb diff --git a/examples/notebooks/heatmap.ipynb b/examples/notebooks/heatmap.ipynb new file mode 100644 index 000000000..d1c512661 --- /dev/null +++ b/examples/notebooks/heatmap.ipynb @@ -0,0 +1,135 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d8c90f4b-b635-4027-b7d5-080d77bd40a3", + "metadata": {}, + "source": [ + "# The `HeatmapGraphic` is useful for looking at very large arrays\n", + "\n", + "`ImageGraphic` is limited to a max size of `8192 x 8192`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49b2498d-56ae-4559-9282-c8484f3e6b6d", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import fastplotlib as fpl\n", + "\n", + "from tqdm import tqdm" + ] + }, + { + "cell_type": "markdown", + "id": "908f93f8-68c3-4a36-8f40-e0aab560955d", + "metadata": {}, + "source": [ + "## Generate some random neural-like data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40718465-abf6-4727-8bd7-4acdd59843d5", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "def generate_traces(n_components, n_frames):\n", + " n_frames = n_frames + 50\n", + " n_components = n_components\n", + " \n", + " out = np.zeros((n_components, n_frames), dtype=np.float16)\n", + " \n", + " xs = np.arange(0, 50, 1)\n", + " # exponential decay\n", + " _lambda = 0.1\n", + " ys = np.e**-(_lambda * xs)\n", + " \n", + " for component_num in tqdm(range(n_components)):\n", + " time_step = 0\n", + " while time_step < n_frames - 50:\n", + " firing_prop = np.random.randint(0, 20)\n", + " if np.random.poisson() > firing_prop:\n", + " out[component_num, time_step:min(time_step + 50, n_frames - 1)] = ys.astype(np.float16)\n", + " time_step += 100\n", + " else:\n", + " time_step += 2\n", + " \n", + " return out[:, :n_frames - 50]" + ] + }, + { + "cell_type": "markdown", + "id": "fc1070d9-f9e9-405f-939c-a130cc5c456a", + "metadata": {}, + "source": [ + "Generate an array that is `10,000 x 30,000`, this may take a few minutes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a1b83f6-c0d8-4237-abd6-b483e7d978ee", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "temporal = generate_traces(10_000, 30_000)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f89bd740-7397-43e7-9e66-d6cfb14de884", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot = fpl.Plot()\n", + "\n", + "plot.add_heatmap(temporal, cmap=\"viridis\")\n", + "\n", + "plot.show(maintain_aspect=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "84deb31b-5464-4cce-a938-694371011021", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/image_widget.ipynb b/examples/notebooks/image_widget.ipynb similarity index 100% rename from notebooks/image_widget.ipynb rename to examples/notebooks/image_widget.ipynb diff --git a/notebooks/linear_region_selector.ipynb b/examples/notebooks/linear_region_selector.ipynb similarity index 100% rename from notebooks/linear_region_selector.ipynb rename to examples/notebooks/linear_region_selector.ipynb diff --git a/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb similarity index 100% rename from notebooks/linear_selector.ipynb rename to examples/notebooks/linear_selector.ipynb diff --git a/notebooks/lineplot.ipynb b/examples/notebooks/lineplot.ipynb similarity index 100% rename from notebooks/lineplot.ipynb rename to examples/notebooks/lineplot.ipynb diff --git a/examples/notebooks/lines_cmap.ipynb b/examples/notebooks/lines_cmap.ipynb new file mode 100644 index 000000000..5eb783d77 --- /dev/null +++ b/examples/notebooks/lines_cmap.ipynb @@ -0,0 +1,174 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "b169210c-b148-4701-91d2-87f8be2c90da", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import fastplotlib as fpl" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6615d45-6a6e-4a1e-a998-18f7cc52f6b9", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# linspace, create 100 evenly spaced x values from -10 to 10\n", + "xs = np.linspace(-10, 10, 100)\n", + "# sine wave\n", + "ys = np.sin(xs)\n", + "sine = np.dstack([xs, ys])[0]\n", + "\n", + "# cosine wave\n", + "ys = np.cos(xs)\n", + "cosine = np.dstack([xs, ys])[0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52a91e8a-25b7-4121-a06f-623d7412b558", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot = fpl.Plot()\n", + "\n", + "plot.add_line(sine, thickness=10)\n", + "\n", + "plot.show()" + ] + }, + { + "cell_type": "markdown", + "id": "889b1858-ed64-4d6b-96ad-3883fbe4d38e", + "metadata": {}, + "source": [ + "# Fancy indexing of line colormaps" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13185547-07bc-4771-ac6d-83314622bf30", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.graphics[0].cmap = \"jet\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ee9ec4d7-d9a2-417c-92bd-b01a9a019801", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.graphics[0].cmap.values = sine[:, 1]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ebf9f494-782d-4529-9ef6-a2a4032f097d", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.graphics[0].cmap.values = cosine[:, 1]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5ddc95cf-b3be-4212-b525-1c628dc1e091", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.graphics[0].cmap = \"viridis\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7712d313-16cd-49e5-89ca-91364412f194", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "cmap_values = [0] * 25 + [1] * 5 + [2] * 50 + [3] * 20" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c8c13c03-56f0-48c3-b44e-65545a3bc3bc", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.graphics[0].cmap.values = cmap_values" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f64d036d-8a9e-4799-b77f-e78afa441fec", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.graphics[0].cmap = \"tab10\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c290c642-ba5f-4a46-9a17-c434cb39de26", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/scatter.ipynb b/examples/notebooks/scatter.ipynb similarity index 100% rename from notebooks/scatter.ipynb rename to examples/notebooks/scatter.ipynb diff --git a/notebooks/simple.ipynb b/examples/notebooks/simple.ipynb similarity index 95% rename from notebooks/simple.ipynb rename to examples/notebooks/simple.ipynb index 9ca764283..367a0126c 100644 --- a/notebooks/simple.ipynb +++ b/examples/notebooks/simple.ipynb @@ -16,7 +16,9 @@ "cell_type": "code", "execution_count": null, "id": "fb57c3d3-f20d-4d88-9e7a-04b9309bc637", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "from fastplotlib import Plot\n", @@ -36,7 +38,9 @@ "cell_type": "code", "execution_count": null, "id": "237823b7-e2c0-4e2f-9ee8-e3fc2b4453c4", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "# create a `Plot` instance\n", @@ -72,7 +76,9 @@ "cell_type": "code", "execution_count": null, "id": "de816c88-1c4a-4071-8a5e-c46c93671ef5", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "image_graphic.cmap = \"viridis\"" @@ -82,7 +88,9 @@ "cell_type": "code", "execution_count": null, "id": "09350854-5058-4574-a01d-84d00e276c57", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "image_graphic.data = 0" @@ -92,7 +100,9 @@ "cell_type": "code", "execution_count": null, "id": "83b2db1b-2783-4e89-bcf3-66bb6e09e18a", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "image_graphic.data[::15, :] = 1\n", @@ -103,7 +113,9 @@ "cell_type": "code", "execution_count": null, "id": "3e298c1c-7551-4401-ade0-b9af7d2bbe23", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "image_graphic.data = np.random.rand(512, 512)" @@ -121,7 +133,9 @@ "cell_type": "code", "execution_count": null, "id": "e6ba689c-ff4a-44ef-9663-f2c8755072c4", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "plot.graphics" @@ -131,7 +145,9 @@ "cell_type": "code", "execution_count": null, "id": "5b18f4e3-e13b-46d5-af1f-285c5a7fdc12", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "plot[\"random-image\"]" @@ -149,7 +165,9 @@ "cell_type": "code", "execution_count": null, "id": "2b5c1321-1fd4-44bc-9433-7439ad3e22cf", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "image_graphic" @@ -159,10 +177,12 @@ "cell_type": "code", "execution_count": null, "id": "b12bf75e-4e93-4930-9146-e96324fdf3f6", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "image_graphic is plot[\"random-image\"]" + "image_graphic == plot[\"random-image\"]" ] }, { @@ -181,7 +201,9 @@ "cell_type": "code", "execution_count": null, "id": "aadd757f-6379-4f52-a709-46aa57c56216", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "# create another `Plot` instance\n", @@ -268,15 +290,6 @@ "VBox([plot_v.show(), plot_sync.show()])" ] }, - { - "cell_type": "markdown", - "id": "be53ca2c-1d97-4cbd-b88b-a414bf0bcc4a", - "metadata": {}, - "source": [ - "# Please note that `HBox` can be buggy and crash the kernel, avoid using it\n", - "### This is an upstream issue in `jupyter-rfb`" - ] - }, { "cell_type": "code", "execution_count": null, @@ -377,7 +390,7 @@ "id": "9ac18409-56d8-46cc-86bf-32456fcece48", "metadata": {}, "source": [ - "### The point is, we have a movie of the following shape, an image sequence" + "### Now we have a movie of the following shape, an image sequence" ] }, { @@ -422,10 +435,7 @@ "slider = IntSlider(min=0, max=movie.shape[0] - 1, step=1, value=0)\n", "\n", "# function to update movie_graphic\n", - "def update_movie(change):\n", - " global movie\n", - " global movie_graphic\n", - " \n", + "def update_movie(change): \n", " index = change[\"new\"]\n", " movie_graphic.data = movie[index]\n", " \n", @@ -529,7 +539,9 @@ "source": [ "### \"stretching\" the camera, useful for large timeseries data\n", "\n", - "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!" + "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!\n", + "\n", + "You can also click the **`1:1`** button to toggle this." ] }, { @@ -721,6 +733,32 @@ "sinc_graphic.present = True" ] }, + { + "cell_type": "markdown", + "id": "05f93e93-283b-45d8-ab31-8d15a7671dd2", + "metadata": {}, + "source": [ + "### You can set the z-positions of graphics to have them appear under other graphics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6bb33406-5bef-455b-86ea-358a7d3ffa94", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "img = np.random.rand(20, 100)\n", + "\n", + "plot_l.add_image(img, name=\"image\", cmap=\"gray\")\n", + "\n", + "# z axix position -1 so it is below all the lines\n", + "plot_l[\"image\"].position_z = -1\n", + "plot_l[\"image\"].position_x = -50" + ] + }, { "cell_type": "markdown", "id": "2c90862e-2f2a-451f-a468-0cf6b857e87a", diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index 7bc271e02..6155f8763 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -9,7 +9,7 @@ ROOT = Path(__file__).parents[2] # repo root -examples_dir = ROOT / "examples" +examples_dir = ROOT / "examples" / "desktop" screenshots_dir = examples_dir / "screenshots" diffs_dir = examples_dir / "diffs" diff --git a/setup.py b/setup.py index 1f4e5cb3a..c9d5d6936 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ "jupyterlab", "jupyter-rfb", "scikit-learn", + "tqdm" ] } From 4a3a79edbc13ad2b9b81b0c66c2c357d38126955 Mon Sep 17 00:00:00 2001 From: Caitlin Lewis <69729525+clewis7@users.noreply.github.com> Date: Wed, 28 Jun 2023 21:08:28 -0400 Subject: [PATCH 04/21] image widget debug (#263) * handling empty subplots in image widget * fix toolbar to work with 2dim xy data * add code comments * update image widget notebook to have xy data * Update fastplotlib/widgets/image.py --------- Co-authored-by: Kushal Kolar --- examples/notebooks/image_widget.ipynb | 223 +++++++++++--------------- fastplotlib/widgets/image.py | 62 ++++--- 2 files changed, 128 insertions(+), 157 deletions(-) diff --git a/examples/notebooks/image_widget.ipynb b/examples/notebooks/image_widget.ipynb index f50d83d36..5b7de6145 100644 --- a/examples/notebooks/image_widget.ipynb +++ b/examples/notebooks/image_widget.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "04f453ca-d0bc-411f-b2a6-d38294dd0a26", "metadata": { "tags": [] @@ -13,6 +13,74 @@ "import numpy as np" ] }, + { + "cell_type": "markdown", + "id": "bd632552-dba1-4e48-b8b2-595da7757d0f", + "metadata": {}, + "source": [ + "# Single image" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c798a5e0-07a0-4468-8e22-9b53b8243ab5", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "a = np.random.rand(512, 512)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b0dbf913-c1c6-4c2a-8191-45a87b2ce310", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "iw = ImageWidget(\n", + " data=a,\n", + " vmin_vmax_sliders=True,\n", + " cmap=\"viridis\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8264fd19-661f-4c50-bdb4-d3998ffd5ff5", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "iw.show()" + ] + }, + { + "cell_type": "markdown", + "id": "b718162f-9aa6-4091-a7a4-c620676b48bd", + "metadata": {}, + "source": [ + "### can dynamically change features" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "393375df-327c-409a-9e3e-75121a0df6cb", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "iw.gridplot[0, 0].graphics[0].cmap = \"gnuplot2\"" + ] + }, { "cell_type": "markdown", "id": "e933771b-f172-4fa9-b2f8-129723efb808", @@ -23,7 +91,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "ea87f9a6-437f-41f6-8739-c957fb04bdbf", "metadata": { "tags": [] @@ -35,27 +103,12 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "id": "8b7a6066-ff69-4bee-bae6-160fb4038393", "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "5ccfeea584fb491b9431b9284ab45993", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "iw = ImageWidget(\n", " data=a, \n", @@ -67,28 +120,12 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "id": "3d4cb44e-2c71-4bff-aeed-b2129f34d724", "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "8624f2dba4d94e5881879d14464cd370", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(VBox(children=(JupyterWgpuCanvas(), HBox(children=(Button(icon='expand-arrows-alt', layout=Layo…" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "iw.show()" ] @@ -105,7 +142,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": null, "id": "f278b26a-1b71-4e76-9cc7-efaddbd7b122", "metadata": { "tags": [] @@ -118,7 +155,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "id": "cb4d4b7c-919f-41c0-b1cc-b4496473d760", "metadata": { "tags": [] @@ -131,7 +168,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "id": "2eea6432-4d38-4d42-ab75-f6aa1bab36f4", "metadata": { "tags": [] @@ -144,7 +181,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": null, "id": "afa2436f-2741-49d6-87f6-7a91a343fe0e", "metadata": { "tags": [] @@ -165,7 +202,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "id": "370d1d24-2944-491f-9da0-fa0c7ed073ef", "metadata": { "tags": [] @@ -186,7 +223,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "882162eb-c873-42df-a945-d5e05ad141c9", "metadata": { "tags": [] @@ -199,27 +236,12 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "bf9f92b6-38ad-4d78-b88c-a32d473b6462", "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "befbd479926a4195ad6ee395da0aaa89", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "iw = ImageWidget(\n", " data=data, \n", @@ -242,28 +264,12 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "403dde31-981a-46fb-b005-1bcef19c4f2c", "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "48534008501f483b865c8df4de77e204", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(VBox(children=(JupyterWgpuCanvas(), HBox(children=(Button(icon='expand-arrows-alt', layout=Layo…" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "iw.show()" ] @@ -278,26 +284,12 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "b59d95e2-9092-4915-beef-01661d164781", "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "text/plain": [ - "two: Subplot @ 0x7f362c02e350\n", - " parent: None\n", - " Graphics:\n", - "\t'image': ImageGraphic @ 0x7f362c0f69d0" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "iw.gridplot[\"two\"]" ] @@ -312,7 +304,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "a8f070db-da11-4062-95aa-f19b96351ee8", "metadata": { "tags": [] @@ -332,27 +324,12 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "b1587410-a08e-484c-8795-195a413d6374", "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "11be69e0a3c1411792cd25548c0ed273", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "dims = (256, 256, 5, 100)\n", "data = [np.random.rand(*dims) for i in range(4)]\n", @@ -370,28 +347,12 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "3ccea6c6-9580-4720-bce8-a5507cf867a3", "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "95b68c9c58754953a8a3e92980973bd6", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(VBox(children=(JupyterWgpuCanvas(), HBox(children=(Button(icon='expand-arrows-alt', layout=Layo…" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "iw.show()" ] @@ -406,7 +367,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "id": "fd4433a9-2add-417c-a618-5891371efae0", "metadata": { "tags": [] diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 80d4868ce..85ef9be15 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -1,3 +1,4 @@ +import weakref from typing import * from warnings import warn from functools import partial @@ -89,11 +90,13 @@ def gridplot(self) -> GridPlot: return self._gridplot @property - def managed_graphics(self): + def managed_graphics(self) -> List[ImageGraphic]: """List of ``ImageWidget`` managed graphics.""" iw_managed = list() for subplot in self.gridplot: - iw_managed.append(subplot["image_widget_managed"]) + # empty subplots will not have any image widget data + if len(subplot.graphics) > 0: + iw_managed.append(subplot["image_widget_managed"]) return iw_managed @property @@ -937,33 +940,40 @@ def __init__(self, iw: ImageWidget): tooltip="reset vmin/vmax", ) - self.step_size_setter = BoundedIntText( - value=1, - min=1, - max=self.iw.sliders["t"].max, - step=1, - description="Step Size:", - disabled=False, - description_tooltip="set slider step", - layout=Layout(width="150px"), - ) - self.play_button = Play( - value=0, - min=iw.sliders["t"].min, - max=iw.sliders["t"].max, - step=iw.sliders["t"].step, - description="play/pause", - disabled=False, - ) + # only for xy data, no time point slider needed + if self.iw.ndim == 2: + self.widget = HBox([self.reset_vminvmax_button]) + # for txy, tzxy, etc. data + else: - self.widget = HBox( - [self.reset_vminvmax_button, self.play_button, self.step_size_setter] - ) + self.step_size_setter = BoundedIntText( + value=1, + min=1, + max=self.iw.sliders["t"].max, + step=1, + description="Step Size:", + disabled=False, + description_tooltip="set slider step", + layout=Layout(width="150px"), + ) + self.play_button = Play( + value=0, + min=iw.sliders["t"].min, + max=iw.sliders["t"].max, + step=iw.sliders["t"].step, + description="play/pause", + disabled=False, + ) + + self.widget = HBox( + [self.reset_vminvmax_button, self.play_button, self.step_size_setter] + ) + + self.step_size_setter.observe(self.change_stepsize, "value") + jslink((self.play_button, "value"), (self.iw.sliders["t"], "value")) + jslink((self.play_button, "max"), (self.iw.sliders["t"], "max")) self.reset_vminvmax_button.on_click(self.reset_vminvmax) - self.step_size_setter.observe(self.change_stepsize, "value") - jslink((self.play_button, "value"), (self.iw.sliders["t"], "value")) - jslink((self.play_button, "max"), (self.iw.sliders["t"], "max")) def reset_vminvmax(self, obj): if len(self.iw.vmin_vmax_sliders) != 0: From 19adf82ff0dd3b4ca85cff9d1a85c6fb045fe29b Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 28 Jun 2023 22:16:31 -0400 Subject: [PATCH 05/21] add speed to imagewidget toolbar (#264) --- fastplotlib/widgets/image.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 85ef9be15..dcc5dafcc 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -945,7 +945,6 @@ def __init__(self, iw: ImageWidget): self.widget = HBox([self.reset_vminvmax_button]) # for txy, tzxy, etc. data else: - self.step_size_setter = BoundedIntText( value=1, min=1, @@ -956,6 +955,16 @@ def __init__(self, iw: ImageWidget): description_tooltip="set slider step", layout=Layout(width="150px"), ) + self.speed_text = BoundedIntText( + value=100, + min=1, + max=1_000, + step=50, + description="Speed", + disabled=False, + description_tooltip="Playback speed, this is NOT framerate.\nArbitrary units between 1 - 1,000", + layout=Layout(width="150px"), + ) self.play_button = Play( value=0, min=iw.sliders["t"].min, @@ -964,20 +973,26 @@ def __init__(self, iw: ImageWidget): description="play/pause", disabled=False, ) - self.widget = HBox( - [self.reset_vminvmax_button, self.play_button, self.step_size_setter] + [self.reset_vminvmax_button, self.play_button, self.step_size_setter, self.speed_text] ) - self.step_size_setter.observe(self.change_stepsize, "value") + self.play_button.interval = 10 + + self.step_size_setter.observe(self._change_stepsize, "value") + self.speed_text.observe(self._change_framerate, "value") jslink((self.play_button, "value"), (self.iw.sliders["t"], "value")) jslink((self.play_button, "max"), (self.iw.sliders["t"], "max")) - self.reset_vminvmax_button.on_click(self.reset_vminvmax) + self.reset_vminvmax_button.on_click(self._reset_vminvmax) - def reset_vminvmax(self, obj): + def _reset_vminvmax(self, obj): if len(self.iw.vmin_vmax_sliders) != 0: self.iw.reset_vmin_vmax() - def change_stepsize(self, obj): + def _change_stepsize(self, obj): self.iw.sliders["t"].step = self.step_size_setter.value + + def _change_framerate(self, change): + interval = int(1000 / change["new"]) + self.play_button.interval = interval From 31105cbc4c6beb7de4b15045572f1a4e2a6f54d3 Mon Sep 17 00:00:00 2001 From: Caitlin Lewis <69729525+clewis7@users.noreply.github.com> Date: Tue, 4 Jul 2023 22:33:40 -0400 Subject: [PATCH 06/21] auto mixin class for generating add_ methods (#248) * automixin class for generating add_ methods * generating the class and methods seems to work fine until I try to import the class elsewhere, graphic names not being defined * moving mixin class to layouts, adding mixin to subplot * requested changes, methods still not working * fix add graphic methods * revert to orig * trying to use locals() to circumvent issue * generated methods should work now * regenerate methods to align with most up-to-date fpl graphics * fix automixin generated methods * add disclaimer, change return type annotation * change return type --- fastplotlib/layouts/_subplot.py | 34 +- fastplotlib/layouts/graphic_methods_mixin.py | 561 +++++++++++++++++++ fastplotlib/utils/generate_add_methods.py | 70 +++ 3 files changed, 637 insertions(+), 28 deletions(-) create mode 100644 fastplotlib/layouts/graphic_methods_mixin.py create mode 100644 fastplotlib/utils/generate_add_methods.py diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index e7d4a699b..1ed52bc7c 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -18,14 +18,16 @@ ) from wgpu.gui.auto import WgpuCanvas -from .. import graphics +from ._utils import make_canvas_and_renderer +from ._base import PlotArea from ..graphics import TextGraphic from ._utils import make_canvas_and_renderer from ._base import PlotArea from ._defaults import create_camera, create_controller +from .graphic_methods_mixin import GraphicMethodsMixin -class Subplot(PlotArea): +class Subplot(PlotArea, GraphicMethodsMixin): def __init__( self, position: Tuple[int, int] = None, @@ -70,6 +72,8 @@ def __init__( """ + super(GraphicMethodsMixin, self).__init__() + canvas, renderer = make_canvas_and_renderer(canvas, renderer) if position is None: @@ -113,36 +117,10 @@ def __init__( self.docked_viewports[pos] = dv self.children.append(dv) - # attach all the add_ methods - for graphic_cls_name in graphics.__all__: - cls = getattr(graphics, graphic_cls_name) - - pfunc = partial(self._create_graphic, cls) - pfunc.__signature__ = signature(cls) - pfunc.__doc__ = cls.__init__.__doc__ - - # cls.type is defined in Graphic.__init_subclass__ - setattr(self, f"add_{cls.type}", pfunc) - self._title_graphic: TextGraphic = None if self.name is not None: self.set_title(self.name) - def _create_graphic(self, graphic_class, *args, **kwargs) -> weakref.proxy: - if "center" in kwargs.keys(): - center = kwargs.pop("center") - else: - center = False - - if "name" in kwargs.keys(): - self._check_graphic_name_exists(kwargs["name"]) - - graphic = graphic_class(*args, **kwargs) - self.add_graphic(graphic, center=center) - - # only return a proxy to the real graphic - return weakref.proxy(graphic) - @property def name(self) -> Any: return self._name diff --git a/fastplotlib/layouts/graphic_methods_mixin.py b/fastplotlib/layouts/graphic_methods_mixin.py new file mode 100644 index 000000000..ab697637b --- /dev/null +++ b/fastplotlib/layouts/graphic_methods_mixin.py @@ -0,0 +1,561 @@ +# This is an auto-generated file and should not be modified directly + +from typing import * + +import numpy +import weakref + +from ..graphics import * +from ..graphics._base import Graphic + + +class GraphicMethodsMixin: + def __init__(self): + pass + + def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: + if 'center' in kwargs.keys(): + center = kwargs.pop('center') + else: + center = False + + if 'name' in kwargs.keys(): + self._check_graphic_name_exists(kwargs['name']) + + graphic = graphic_class(*args, **kwargs) + self.add_graphic(graphic, center=center) + + # only return a proxy to the real graphic + return weakref.proxy(graphic) + + def add_heatmap(self, data: Any, vmin: int = None, vmax: int = None, cmap: str = 'plasma', filter: str = 'nearest', chunk_size: int = 8192, isolated_buffer: bool = True, *args, **kwargs) -> HeatmapGraphic: + """ + + Create an Image Graphic + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()`` + Tensorflow Tensors also work **probably**, but not thoroughly tested + | shape must be ``[x_dim, y_dim]`` + + vmin: int, optional + minimum value for color scaling, calculated from data if not provided + + vmax: int, optional + maximum value for color scaling, calculated from data if not provided + + cmap: str, optional, default "plasma" + colormap to use to display the data + + filter: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + chunk_size: int, default 8192, max 8192 + chunk size for each tile used to make up the heatmap texture + + isolated_buffer: bool, default True + If True, initialize a buffer with the same shape as the input data and then + set the data, useful if the data arrays are ready-only such as memmaps. + If False, the input array is itself used as the buffer. + + args: + additional arguments passed to Graphic + + kwargs: + additional keyword arguments passed to Graphic + + Features + -------- + + **data**: :class:`.HeatmapDataFeature` + Manages the data buffer displayed in the HeatmapGraphic + + **cmap**: :class:`.HeatmapCmapFeature` + Manages the colormap + + **present**: :class:`.PresentFeature` + Control the presence of the Graphic in the scene + + Examples + -------- + .. code-block:: python + + from fastplotlib import Plot + # create a `Plot` instance + plot = Plot() + + # make some random 2D heatmap data + data = np.random.rand(10_000, 8_000) + + # add a heatmap + plot.add_heatmap(data=data) + + # show the plot + plot.show() + + + """ + return self._create_graphic(HeatmapGraphic, data, vmin, vmax, cmap, filter, chunk_size, isolated_buffer, *args, **kwargs) + + def add_histogram(self, data: numpy.ndarray = None, bins: Union[int, str] = 'auto', pre_computed: Dict[str, numpy.ndarray] = None, colors: numpy.ndarray = 'w', draw_scale_factor: float = 100.0, draw_bin_width_scale: float = 1.0, **kwargs) -> HistogramGraphic: + """ + + Create a Histogram Graphic + + Parameters + ---------- + data: np.ndarray or None, optional + data to create a histogram from, can be ``None`` if pre-computed values are provided to ``pre_computed`` + + bins: int or str, default is "auto", optional + this is directly just passed to ``numpy.histogram`` + + pre_computed: dict in the form {"hist": vals, "bin_edges" : vals}, optional + pre-computed histogram values + + colors: np.ndarray, optional + + draw_scale_factor: float, default ``100.0``, optional + scale the drawing of the entire Graphic + + draw_bin_width_scale: float, default ``1.0`` + scale the drawing of the bin widths + + kwargs + passed to Graphic + + """ + return self._create_graphic(HistogramGraphic, data, bins, pre_computed, colors, draw_scale_factor, draw_bin_width_scale, *args, **kwargs) + + def add_image(self, data: Any, vmin: int = None, vmax: int = None, cmap: str = 'plasma', filter: str = 'nearest', isolated_buffer: bool = True, *args, **kwargs) -> ImageGraphic: + """ + + Create an Image Graphic + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()`` + Tensorflow Tensors also work **probably**, but not thoroughly tested + | shape must be ``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]`` + + vmin: int, optional + minimum value for color scaling, calculated from data if not provided + + vmax: int, optional + maximum value for color scaling, calculated from data if not provided + + cmap: str, optional, default "plasma" + colormap to use to display the image data, ignored if data is RGB + + filter: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + isolated_buffer: bool, default True + If True, initialize a buffer with the same shape as the input data and then + set the data, useful if the data arrays are ready-only such as memmaps. + If False, the input array is itself used as the buffer. + + args: + additional arguments passed to Graphic + + kwargs: + additional keyword arguments passed to Graphic + + Features + -------- + + **data**: :class:`.ImageDataFeature` + Manages the data buffer displayed in the ImageGraphic + + **cmap**: :class:`.ImageCmapFeature` + Manages the colormap + + **present**: :class:`.PresentFeature` + Control the presence of the Graphic in the scene + + Examples + -------- + .. code-block:: python + + from fastplotlib import Plot + # create a `Plot` instance + plot = Plot() + # make some random 2D image data + data = np.random.rand(512, 512) + # plot the image data + plot.add_image(data=data) + # show the plot + plot.show() + + + """ + return self._create_graphic(ImageGraphic, data, vmin, vmax, cmap, filter, isolated_buffer, *args, **kwargs) + + def add_line_collection(self, data: List[numpy.ndarray], z_position: Union[List[float], float] = None, thickness: Union[float, List[float]] = 2.0, colors: Union[List[numpy.ndarray], numpy.ndarray] = 'w', alpha: float = 1.0, cmap: Union[List[str], str] = None, cmap_values: Union[numpy.ndarray, List] = None, name: str = None, metadata: Union[list, tuple, numpy.ndarray] = None, *args, **kwargs) -> LineCollection: + """ + + Create a Line Collection + + Parameters + ---------- + + data: list of array-like or array + List of line data to plot, each element must be a 1D, 2D, or 3D numpy array + if elements are 2D, interpreted as [y_vals, n_lines] + + z_position: list of float or float, optional + | if ``float``, single position will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + thickness: float or list of float, default 2.0 + | if ``float``, single thickness will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + colors: str, RGBA array, list of RGBA array, or list of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: list of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + **Note:** ``cmap`` overrides any arguments passed to ``colors`` + + cmap_values: 1D array-like or list of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection + + metadata: list, tuple, or array + metadata associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + args + passed to GraphicCollection + + kwargs + passed to GraphicCollection + + Features + -------- + + Collections support the same features as the underlying graphic. You just have to slice the selection. + + .. code-block:: python + + # slice only the collection + line_collection[10:20].colors = "blue" + + # slice the collection and a feature + line_collection[20:30].colors[10:30] = "red" + + # the data feature also works like this + + See :class:`LineGraphic` details on the features. + + Examples + -------- + .. code-block:: python + + from fastplotlib import Plot + from fastplotlib.graphics import LineCollection + + # creating data for sine and cosine waves + xs = np.linspace(-10, 10, 100) + ys = np.sin(xs) + + sine = np.dstack([xs, ys])[0] + + ys = np.sin(xs) + 10 + sine2 = np.dstack([xs, ys])[0] + + ys = np.cos(xs) + 5 + cosine = np.dstack([xs, ys])[0] + + # creating plot + plot = Plot() + + # creating a line collection using the sine and cosine wave data + line_collection = LineCollection(data=[sine, cosine, sine2], cmap=["Oranges", "Blues", "Reds"], thickness=20.0) + + # add graphic to plot + plot.add_graphic(line_collection) + + # show plot + plot.show() + + # change the color of the sine wave to white + line_collection[0].colors = "w" + + # change certain color indexes of the cosine data to red + line_collection[1].colors[0:15] = "r" + + # toggle presence of sine2 and rescale graphics + line_collection[2].present = False + + plot.autoscale() + + line_collection[2].present = True + + plot.autoscale() + + # can also do slicing + line_collection[1:].colors[35:70] = "magenta" + + + """ + return self._create_graphic(LineCollection, data, z_position, thickness, colors, alpha, cmap, cmap_values, name, metadata, *args, **kwargs) + + def add_line(self, data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Iterable] = 'w', alpha: float = 1.0, cmap: str = None, cmap_values: Union[numpy.ndarray, List] = None, z_position: float = None, collection_index: int = None, *args, **kwargs) -> LineGraphic: + """ + + Create a line Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Line data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3] + + thickness: float, optional, default 2.0 + thickness of the line + + colors: str, array, or iterable, default "w" + specify colors as a single human-readable string, a single RGBA array, + or an iterable of strings or RGBA arrays + + cmap: str, optional + apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors" + + cmap_values: 1D array-like or list of numerical values, optional + if provided, these values are used to map the colors from the cmap + + alpha: float, optional, default 1.0 + alpha value for the colors + + z_position: float, optional + z-axis position for placing the graphic + + args + passed to Graphic + + kwargs + passed to Graphic + + Features + -------- + + **data**: :class:`.ImageDataFeature` + Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. + ex: ``scatter.data[:, 0] = 5```, ``scatter.data[xs > 5] = 3`` + + **colors**: :class:`.ColorFeature` + Manages the color buffer, allows regular and fancy indexing. + ex: ``scatter.data[:, 1] = 0.5``, ``scatter.colors[xs > 5] = "cyan"`` + + **present**: :class:`.PresentFeature` + Control the presence of the Graphic in the scene, set to ``True`` or ``False`` + + + """ + return self._create_graphic(LineGraphic, data, thickness, colors, alpha, cmap, cmap_values, z_position, collection_index, *args, **kwargs) + + def add_line_stack(self, data: List[numpy.ndarray], z_position: Union[List[float], float] = None, thickness: Union[float, List[float]] = 2.0, colors: Union[List[numpy.ndarray], numpy.ndarray] = 'w', cmap: Union[List[str], str] = None, separation: float = 10, separation_axis: str = 'y', name: str = None, *args, **kwargs) -> LineStack: + """ + + Create a line stack + + Parameters + ---------- + data: list of array-like + List of line data to plot, each element must be a 1D, 2D, or 3D numpy array + if elements are 2D, interpreted as [y_vals, n_lines] + + z_position: list of float or float, optional + | if ``float``, single position will be used for all lines + | if ``list`` of ``float``, each value will apply to individual lines + + thickness: float or list of float, default 2.0 + | if ``float``, single thickness will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + colors: str, RGBA array, list of RGBA array, or list of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | is ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``list`` of ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: list of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + **Note:** ``cmap`` overrides any arguments passed to ``colors`` + + name: str, optional + name of the line stack + + separation: float, default 10 + space in between each line graphic in the stack + + separation_axis: str, default "y" + axis in which the line graphics in the stack should be separated + + name: str, optional + name of the line stack + + args + passed to LineCollection + + kwargs + passed to LineCollection + + + Features + -------- + + Collections support the same features as the underlying graphic. You just have to slice the selection. + + .. code-block:: python + + # slice only the collection + line_collection[10:20].colors = "blue" + + # slice the collection and a feature + line_collection[20:30].colors[10:30] = "red" + + # the data feature also works like this + + See :class:`LineGraphic` details on the features. + + + Examples + -------- + .. code-block:: python + + from fastplotlib import Plot + from fastplotlib.graphics import LineStack + + # create plot + plot = Plot() + + # create line data + xs = np.linspace(-10, 10, 100) + ys = np.sin(xs) + + sine = np.dstack([xs, ys])[0] + + ys = np.sin(xs) + cosine = np.dstack([xs, ys])[0] + + # create line stack + line_stack = LineStack(data=[sine, cosine], cmap=["Oranges", "Blues"], thickness=20.0, separation=5.0) + + # add graphic to plot + plot.add_graphic(line_stack) + + # show plot + plot.show() + + # change the color of the sine wave to white + line_stack[0].colors = "w" + + # change certain color indexes of the cosine data to red + line_stack[1].colors[0:15] = "r" + + # more slicing + line_stack[0].colors[35:70] = "magenta" + + + """ + return self._create_graphic(LineStack, data, z_position, thickness, colors, cmap, separation, separation_axis, name, *args, **kwargs) + + def add_scatter(self, data: numpy.ndarray, sizes: Union[int, numpy.ndarray, list] = 1, colors: numpy.ndarray = 'w', alpha: float = 1.0, cmap: str = None, cmap_values: Union[numpy.ndarray, List] = None, z_position: float = 0.0, *args, **kwargs) -> ScatterGraphic: + """ + + Create a Scatter Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Scatter data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3] + + sizes: float or iterable of float, optional, default 1.0 + size of the scatter points + + colors: str, array, or iterable, default "w" + specify colors as a single human readable string, a single RGBA array, + or an iterable of strings or RGBA arrays + + cmap: str, optional + apply a colormap to the scatter instead of assigning colors manually, this + overrides any argument passed to "colors" + + cmap_values: 1D array-like or list of numerical values, optional + if provided, these values are used to map the colors from the cmap + + alpha: float, optional, default 1.0 + alpha value for the colors + + z_position: float, optional + z-axis position for placing the graphic + + args + passed to Graphic + + kwargs + passed to Graphic + + Features + -------- + + **data**: :class:`.ImageDataFeature` + Manages the scatter [x, y, z] positions data buffer, allows regular and fancy indexing. + ex: ``scatter.data[:, 0] = 5```, ``scatter.data[xs > 5] = 3`` + + **colors**: :class:`.ColorFeature` + Manages the color buffer, allows regular and fancy indexing. + ex: ``scatter.data[:, 1] = 0.5``, ``scatter.colors[xs > 5] = "cyan"`` + + **present**: :class:`.PresentFeature` + Control the presence of the Graphic in the scene, set to ``True`` or ``False`` + + + """ + return self._create_graphic(ScatterGraphic, data, sizes, colors, alpha, cmap, cmap_values, z_position, *args, **kwargs) + + def add_text(self, text: str, position: Tuple[int] = (0, 0, 0), size: int = 10, face_color: Union[str, numpy.ndarray] = 'w', outline_color: Union[str, numpy.ndarray] = 'w', outline_thickness=0, name: str = None) -> TextGraphic: + """ + + Create a text Graphic + + Parameters + ---------- + text: str + display text + + position: int tuple, default (0, 0, 0) + int tuple indicating location of text in scene + + size: int, default 10 + text size + + face_color: str or array, default "w" + str or RGBA array to set the color of the text + + outline_color: str or array, default "w" + str or RGBA array to set the outline color of the text + + outline_thickness: int, default 0 + text outline thickness + + name: str, optional + name of graphic, passed to Graphic + + + """ + return self._create_graphic(TextGraphic, text, position, size, face_color, outline_color, outline_thickness, name, *args, **kwargs) + diff --git a/fastplotlib/utils/generate_add_methods.py b/fastplotlib/utils/generate_add_methods.py new file mode 100644 index 000000000..e3993fff2 --- /dev/null +++ b/fastplotlib/utils/generate_add_methods.py @@ -0,0 +1,70 @@ +import inspect +import sys +import pathlib + +from fastplotlib.graphics import * + + +modules = list() + +for name, obj in inspect.getmembers(sys.modules[__name__]): + if inspect.isclass(obj): + modules.append(obj) + +def generate_add_graphics_methods(): + # clear file and regenerate from scratch + current_module = pathlib.Path(__file__).parent.parent.resolve() + + open(current_module.joinpath('layouts/graphic_methods_mixin.py'), 'w').close() + + f = open(current_module.joinpath('layouts/graphic_methods_mixin.py'), 'w') + + f.write('# This is an auto-generated file and should not be modified directly\n\n') + + f.write('from typing import *\n\n') + f.write('import numpy\n') + f.write('import weakref\n\n') + f.write('from ..graphics import *\n') + f.write('from ..graphics._base import Graphic\n\n') + + f.write("\nclass GraphicMethodsMixin:\n") + f.write(" def __init__(self):\n") + f.write(" pass\n\n") + + f.write(" def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic:\n") + f.write(" if 'center' in kwargs.keys():\n") + f.write(" center = kwargs.pop('center')\n") + f.write(" else:\n") + f.write(" center = False\n\n") + f.write(" if 'name' in kwargs.keys():\n") + f.write(" self._check_graphic_name_exists(kwargs['name'])\n\n") + f.write(" graphic = graphic_class(*args, **kwargs)\n") + f.write(" self.add_graphic(graphic, center=center)\n\n") + f.write(" # only return a proxy to the real graphic\n") + f.write(" return weakref.proxy(graphic)\n\n") + + + for m in modules: + class_name = m + method_name = class_name.type + + class_args = inspect.getfullargspec(class_name)[0][1:] + class_args = [arg + ', ' for arg in class_args] + s = "" + for a in class_args: + s += a + + f.write(f" def add_{method_name}{inspect.signature(class_name.__init__)} -> {class_name.__name__}:\n") + f.write(' """\n') + f.write(f' {class_name.__init__.__doc__}\n') + f.write(' """\n') + f.write(f" return self._create_graphic({class_name.__name__}, {s}*args, **kwargs)\n\n") + + f.close() + + return + + +if __name__ == '__main__': + generate_add_graphics_methods() + From 5e6438e50dfa2f6e5704d45af2f0259b31ecb2a3 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 4 Jul 2023 22:34:23 -0400 Subject: [PATCH 07/21] min version pins for numpy, jupyter-rfb, ipywidgets (#271) * min version pins for numpy, jupyter-rfb, ipywidgets numpy < 1.23 causes issues with type annotations in the form: -> np.ndarray[Graphic] * forgot a comma --- setup.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index c9d5d6936..f06183a60 100644 --- a/setup.py +++ b/setup.py @@ -3,8 +3,8 @@ install_requires = [ - 'numpy', - 'pygfx>=0.1.13', + "numpy>=1.23.0", + "pygfx>=0.1.13", ] @@ -13,13 +13,15 @@ "sphinx", "pydata-sphinx-theme<0.10.0", "glfw", - "jupyter_rfb" # required so ImageWidget docs show up + "jupyter-rfb>=0.4.1", # required so ImageWidget docs show up + "ipywidgets>=8.0.0,<9" ], "notebook": [ - 'jupyterlab', - 'jupyter-rfb', + "jupyterlab", + "jupyter-rfb>=0.4.1", + "ipywidgets>=8.0.0,<9" ], "tests": @@ -29,7 +31,8 @@ "scipy", "imageio", "jupyterlab", - "jupyter-rfb", + "jupyter-rfb>=0.4.1", + "ipywidgets>=8.0.0,<9", "scikit-learn", "tqdm" ] From 3a3c9db7f695cbb35b8d04cae6f00b3ba63a433e Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 5 Jul 2023 09:03:35 -0400 Subject: [PATCH 08/21] docs revamp (#273) * progress on auto generating docs * changes for docs, move selector features to features module * docs progress * fix table, remove unnecessary class attribute * add NB_SNAPSHOT env variable for docs * add new api docs * I can't spell nbsphinx * fix child_type issue for collections * move logo file * update logo path * cleanup --- .readthedocs.yaml | 1 + docs/source/_static/logo.png | 3 + docs/source/_static/style.css | 0 docs/source/_templates/autosummary/class.rst | 5 + docs/source/api/graphic_features.rst | 81 - .../api/graphic_features/CmapFeature.rst | 35 + .../api/graphic_features/ColorFeature.rst | 34 + .../api/graphic_features/FeatureEvent.rst | 29 + .../api/graphic_features/GraphicFeature.rst | 33 + .../GraphicFeatureIndexable.rst | 34 + .../graphic_features/HeatmapCmapFeature.rst | 35 + .../graphic_features/HeatmapDataFeature.rst | 35 + .../api/graphic_features/ImageCmapFeature.rst | 35 + .../api/graphic_features/ImageDataFeature.rst | 35 + .../LinearRegionSelectionFeature.rst | 34 + .../LinearSelectionFeature.rst | 33 + .../graphic_features/PointsDataFeature.rst | 34 + .../api/graphic_features/PresentFeature.rst | 33 + .../api/graphic_features/ThicknessFeature.rst | 33 + docs/source/api/graphic_features/index.rst | 21 + .../to_gpu_supported_dtype.rst | 29 + docs/source/api/graphics.rst | 61 - docs/source/api/graphics/HeatmapGraphic.rst | 41 + docs/source/api/graphics/HistogramGraphic.rst | 36 + docs/source/api/graphics/ImageGraphic.rst | 39 + docs/source/api/graphics/LineCollection.rst | 44 + docs/source/api/graphics/LineGraphic.rst | 39 + docs/source/api/graphics/LineStack.rst | 44 + docs/source/api/graphics/ScatterGraphic.rst | 36 + docs/source/api/graphics/TextGraphic.rst | 42 + docs/source/api/graphics/index.rst | 14 + docs/source/api/gridplot.rst | 8 - docs/source/api/layouts/gridplot.rst | 98 ++ docs/source/api/layouts/plot.rst | 70 + docs/source/api/plot.rst | 9 - docs/source/api/selectors.rst | 15 - .../api/selectors/LinearRegionSelector.rst | 39 + docs/source/api/selectors/LinearSelector.rst | 40 + docs/source/api/selectors/Synchronizer.rst | 32 + docs/source/api/selectors/index.rst | 9 + docs/source/api/subplot.rst | 10 - docs/source/api/widgets.rst | 12 - docs/source/api/widgets/ImageWidget.rst | 41 + docs/source/api/widgets/index.rst | 7 + docs/source/conf.py | 80 +- docs/source/fastplotlib_banner.xcf | Bin 0 -> 241410 bytes docs/source/fastplotlib_logo.xcf | Bin 0 -> 224234 bytes docs/source/generate_rst.py | 266 +++ docs/source/index.rst | 33 +- docs/source/quickstart.ipynb | 1431 +++++++++++++++++ fastplotlib/__init__.py | 2 + fastplotlib/graphics/_base.py | 2 +- fastplotlib/graphics/_features/__init__.py | 5 +- fastplotlib/graphics/_features/_base.py | 4 +- fastplotlib/graphics/_features/_colors.py | 10 +- fastplotlib/graphics/_features/_data.py | 8 +- fastplotlib/graphics/_features/_present.py | 2 +- .../graphics/_features/_selection_features.py | 316 ++++ fastplotlib/graphics/_features/_thickness.py | 4 +- fastplotlib/graphics/line_collection.py | 2 +- fastplotlib/graphics/selectors/_linear.py | 80 +- .../graphics/selectors/_linear_region.py | 138 +- .../graphics/selectors/_mesh_positions.py | 121 -- fastplotlib/layouts/_gridplot.py | 7 + fastplotlib/layouts/_plot.py | 6 + setup.py | 9 +- 66 files changed, 3329 insertions(+), 595 deletions(-) create mode 100644 docs/source/_static/logo.png create mode 100644 docs/source/_static/style.css create mode 100644 docs/source/_templates/autosummary/class.rst delete mode 100644 docs/source/api/graphic_features.rst create mode 100644 docs/source/api/graphic_features/CmapFeature.rst create mode 100644 docs/source/api/graphic_features/ColorFeature.rst create mode 100644 docs/source/api/graphic_features/FeatureEvent.rst create mode 100644 docs/source/api/graphic_features/GraphicFeature.rst create mode 100644 docs/source/api/graphic_features/GraphicFeatureIndexable.rst create mode 100644 docs/source/api/graphic_features/HeatmapCmapFeature.rst create mode 100644 docs/source/api/graphic_features/HeatmapDataFeature.rst create mode 100644 docs/source/api/graphic_features/ImageCmapFeature.rst create mode 100644 docs/source/api/graphic_features/ImageDataFeature.rst create mode 100644 docs/source/api/graphic_features/LinearRegionSelectionFeature.rst create mode 100644 docs/source/api/graphic_features/LinearSelectionFeature.rst create mode 100644 docs/source/api/graphic_features/PointsDataFeature.rst create mode 100644 docs/source/api/graphic_features/PresentFeature.rst create mode 100644 docs/source/api/graphic_features/ThicknessFeature.rst create mode 100644 docs/source/api/graphic_features/index.rst create mode 100644 docs/source/api/graphic_features/to_gpu_supported_dtype.rst delete mode 100644 docs/source/api/graphics.rst create mode 100644 docs/source/api/graphics/HeatmapGraphic.rst create mode 100644 docs/source/api/graphics/HistogramGraphic.rst create mode 100644 docs/source/api/graphics/ImageGraphic.rst create mode 100644 docs/source/api/graphics/LineCollection.rst create mode 100644 docs/source/api/graphics/LineGraphic.rst create mode 100644 docs/source/api/graphics/LineStack.rst create mode 100644 docs/source/api/graphics/ScatterGraphic.rst create mode 100644 docs/source/api/graphics/TextGraphic.rst create mode 100644 docs/source/api/graphics/index.rst delete mode 100644 docs/source/api/gridplot.rst create mode 100644 docs/source/api/layouts/gridplot.rst create mode 100644 docs/source/api/layouts/plot.rst delete mode 100644 docs/source/api/plot.rst delete mode 100644 docs/source/api/selectors.rst create mode 100644 docs/source/api/selectors/LinearRegionSelector.rst create mode 100644 docs/source/api/selectors/LinearSelector.rst create mode 100644 docs/source/api/selectors/Synchronizer.rst create mode 100644 docs/source/api/selectors/index.rst delete mode 100644 docs/source/api/subplot.rst delete mode 100644 docs/source/api/widgets.rst create mode 100644 docs/source/api/widgets/ImageWidget.rst create mode 100644 docs/source/api/widgets/index.rst create mode 100644 docs/source/fastplotlib_banner.xcf create mode 100644 docs/source/fastplotlib_logo.xcf create mode 100644 docs/source/generate_rst.py create mode 100644 docs/source/quickstart.ipynb create mode 100644 fastplotlib/graphics/_features/_selection_features.py diff --git a/.readthedocs.yaml b/.readthedocs.yaml index ae7598f41..fdbd87a65 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,6 +10,7 @@ build: - libxcb-xfixes0-dev - mesa-vulkan-drivers - libglfw3 + - pandoc jobs: pre_install: - pip install git+https://github.com/pygfx/pygfx.git@main diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png new file mode 100644 index 000000000..304fc88bc --- /dev/null +++ b/docs/source/_static/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7385f790683edc6c79fb6131e1649bd54d7fefd405cc8b6005ed86ea7dbb8fa6 +size 31759 diff --git a/docs/source/_static/style.css b/docs/source/_static/style.css new file mode 100644 index 000000000..e69de29bb diff --git a/docs/source/_templates/autosummary/class.rst b/docs/source/_templates/autosummary/class.rst new file mode 100644 index 000000000..d4fd5208b --- /dev/null +++ b/docs/source/_templates/autosummary/class.rst @@ -0,0 +1,5 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} diff --git a/docs/source/api/graphic_features.rst b/docs/source/api/graphic_features.rst deleted file mode 100644 index 2fe60ce24..000000000 --- a/docs/source/api/graphic_features.rst +++ /dev/null @@ -1,81 +0,0 @@ -.. _api_graphic_features: - -Graphic Features -**************** - -Image -##### - -.. autoclass:: fastplotlib.graphics.features.ImageDataFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: - -.. autoclass:: fastplotlib.graphics.features.ImageCmapFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: - -Heatmap -####### - -.. autoclass:: fastplotlib.graphics.features.HeatmapDataFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: - -.. autoclass:: fastplotlib.graphics.features.HeatmapCmapFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: - -Line -#### - -.. autoclass:: fastplotlib.graphics.features.PositionsDataFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: - -.. autoclass:: fastplotlib.graphics.features.ColorsFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: - -.. autoclass:: fastplotlib.graphics.features.ThicknessFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: - -Scatter -####### - -.. autoclass:: fastplotlib.graphics.features.PositionsDataFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: - -.. autoclass:: fastplotlib.graphics.features.ColorsFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: - -Common -###### - -Features common to all graphics - -.. autoclass:: fastplotlib.graphics.features.PresentFeature - :members: - :inherited-members: - :exclude-members: __init__ - :no-undoc-members: diff --git a/docs/source/api/graphic_features/CmapFeature.rst b/docs/source/api/graphic_features/CmapFeature.rst new file mode 100644 index 000000000..03e3330b7 --- /dev/null +++ b/docs/source/api/graphic_features/CmapFeature.rst @@ -0,0 +1,35 @@ +.. _api.CmapFeature: + +CmapFeature +*********** + +=========== +CmapFeature +=========== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: CmapFeature_api + + CmapFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: CmapFeature_api + + CmapFeature.buffer + CmapFeature.values + +Methods +~~~~~~~ +.. autosummary:: + :toctree: CmapFeature_api + + CmapFeature.add_event_handler + CmapFeature.block_events + CmapFeature.clear_event_handlers + CmapFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/ColorFeature.rst b/docs/source/api/graphic_features/ColorFeature.rst new file mode 100644 index 000000000..3ed84cd70 --- /dev/null +++ b/docs/source/api/graphic_features/ColorFeature.rst @@ -0,0 +1,34 @@ +.. _api.ColorFeature: + +ColorFeature +************ + +============ +ColorFeature +============ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ColorFeature_api + + ColorFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ColorFeature_api + + ColorFeature.buffer + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ColorFeature_api + + ColorFeature.add_event_handler + ColorFeature.block_events + ColorFeature.clear_event_handlers + ColorFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/FeatureEvent.rst b/docs/source/api/graphic_features/FeatureEvent.rst new file mode 100644 index 000000000..f22ee3ef4 --- /dev/null +++ b/docs/source/api/graphic_features/FeatureEvent.rst @@ -0,0 +1,29 @@ +.. _api.FeatureEvent: + +FeatureEvent +************ + +============ +FeatureEvent +============ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: FeatureEvent_api + + FeatureEvent + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: FeatureEvent_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: FeatureEvent_api + + diff --git a/docs/source/api/graphic_features/GraphicFeature.rst b/docs/source/api/graphic_features/GraphicFeature.rst new file mode 100644 index 000000000..7abc3e6b2 --- /dev/null +++ b/docs/source/api/graphic_features/GraphicFeature.rst @@ -0,0 +1,33 @@ +.. _api.GraphicFeature: + +GraphicFeature +************** + +============== +GraphicFeature +============== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicFeature_api + + GraphicFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicFeature_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: GraphicFeature_api + + GraphicFeature.add_event_handler + GraphicFeature.block_events + GraphicFeature.clear_event_handlers + GraphicFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/GraphicFeatureIndexable.rst b/docs/source/api/graphic_features/GraphicFeatureIndexable.rst new file mode 100644 index 000000000..7bd1383bc --- /dev/null +++ b/docs/source/api/graphic_features/GraphicFeatureIndexable.rst @@ -0,0 +1,34 @@ +.. _api.GraphicFeatureIndexable: + +GraphicFeatureIndexable +*********************** + +======================= +GraphicFeatureIndexable +======================= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicFeatureIndexable_api + + GraphicFeatureIndexable + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: GraphicFeatureIndexable_api + + GraphicFeatureIndexable.buffer + +Methods +~~~~~~~ +.. autosummary:: + :toctree: GraphicFeatureIndexable_api + + GraphicFeatureIndexable.add_event_handler + GraphicFeatureIndexable.block_events + GraphicFeatureIndexable.clear_event_handlers + GraphicFeatureIndexable.remove_event_handler + diff --git a/docs/source/api/graphic_features/HeatmapCmapFeature.rst b/docs/source/api/graphic_features/HeatmapCmapFeature.rst new file mode 100644 index 000000000..77df37ab0 --- /dev/null +++ b/docs/source/api/graphic_features/HeatmapCmapFeature.rst @@ -0,0 +1,35 @@ +.. _api.HeatmapCmapFeature: + +HeatmapCmapFeature +****************** + +================== +HeatmapCmapFeature +================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: HeatmapCmapFeature_api + + HeatmapCmapFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: HeatmapCmapFeature_api + + HeatmapCmapFeature.vmax + HeatmapCmapFeature.vmin + +Methods +~~~~~~~ +.. autosummary:: + :toctree: HeatmapCmapFeature_api + + HeatmapCmapFeature.add_event_handler + HeatmapCmapFeature.block_events + HeatmapCmapFeature.clear_event_handlers + HeatmapCmapFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/HeatmapDataFeature.rst b/docs/source/api/graphic_features/HeatmapDataFeature.rst new file mode 100644 index 000000000..029f0e199 --- /dev/null +++ b/docs/source/api/graphic_features/HeatmapDataFeature.rst @@ -0,0 +1,35 @@ +.. _api.HeatmapDataFeature: + +HeatmapDataFeature +****************** + +================== +HeatmapDataFeature +================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: HeatmapDataFeature_api + + HeatmapDataFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: HeatmapDataFeature_api + + HeatmapDataFeature.buffer + +Methods +~~~~~~~ +.. autosummary:: + :toctree: HeatmapDataFeature_api + + HeatmapDataFeature.add_event_handler + HeatmapDataFeature.block_events + HeatmapDataFeature.clear_event_handlers + HeatmapDataFeature.remove_event_handler + HeatmapDataFeature.update_gpu + diff --git a/docs/source/api/graphic_features/ImageCmapFeature.rst b/docs/source/api/graphic_features/ImageCmapFeature.rst new file mode 100644 index 000000000..d2174ff9a --- /dev/null +++ b/docs/source/api/graphic_features/ImageCmapFeature.rst @@ -0,0 +1,35 @@ +.. _api.ImageCmapFeature: + +ImageCmapFeature +**************** + +================ +ImageCmapFeature +================ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmapFeature_api + + ImageCmapFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmapFeature_api + + ImageCmapFeature.vmax + ImageCmapFeature.vmin + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageCmapFeature_api + + ImageCmapFeature.add_event_handler + ImageCmapFeature.block_events + ImageCmapFeature.clear_event_handlers + ImageCmapFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/ImageDataFeature.rst b/docs/source/api/graphic_features/ImageDataFeature.rst new file mode 100644 index 000000000..35fe74cf7 --- /dev/null +++ b/docs/source/api/graphic_features/ImageDataFeature.rst @@ -0,0 +1,35 @@ +.. _api.ImageDataFeature: + +ImageDataFeature +**************** + +================ +ImageDataFeature +================ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageDataFeature_api + + ImageDataFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageDataFeature_api + + ImageDataFeature.buffer + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageDataFeature_api + + ImageDataFeature.add_event_handler + ImageDataFeature.block_events + ImageDataFeature.clear_event_handlers + ImageDataFeature.remove_event_handler + ImageDataFeature.update_gpu + diff --git a/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst b/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst new file mode 100644 index 000000000..a15825530 --- /dev/null +++ b/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst @@ -0,0 +1,34 @@ +.. _api.LinearRegionSelectionFeature: + +LinearRegionSelectionFeature +**************************** + +============================ +LinearRegionSelectionFeature +============================ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelectionFeature_api + + LinearRegionSelectionFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelectionFeature_api + + LinearRegionSelectionFeature.axis + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelectionFeature_api + + LinearRegionSelectionFeature.add_event_handler + LinearRegionSelectionFeature.block_events + LinearRegionSelectionFeature.clear_event_handlers + LinearRegionSelectionFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/LinearSelectionFeature.rst b/docs/source/api/graphic_features/LinearSelectionFeature.rst new file mode 100644 index 000000000..aeb1ca66b --- /dev/null +++ b/docs/source/api/graphic_features/LinearSelectionFeature.rst @@ -0,0 +1,33 @@ +.. _api.LinearSelectionFeature: + +LinearSelectionFeature +********************** + +====================== +LinearSelectionFeature +====================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LinearSelectionFeature_api + + LinearSelectionFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LinearSelectionFeature_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LinearSelectionFeature_api + + LinearSelectionFeature.add_event_handler + LinearSelectionFeature.block_events + LinearSelectionFeature.clear_event_handlers + LinearSelectionFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/PointsDataFeature.rst b/docs/source/api/graphic_features/PointsDataFeature.rst new file mode 100644 index 000000000..078b1c535 --- /dev/null +++ b/docs/source/api/graphic_features/PointsDataFeature.rst @@ -0,0 +1,34 @@ +.. _api.PointsDataFeature: + +PointsDataFeature +***************** + +================= +PointsDataFeature +================= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: PointsDataFeature_api + + PointsDataFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: PointsDataFeature_api + + PointsDataFeature.buffer + +Methods +~~~~~~~ +.. autosummary:: + :toctree: PointsDataFeature_api + + PointsDataFeature.add_event_handler + PointsDataFeature.block_events + PointsDataFeature.clear_event_handlers + PointsDataFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/PresentFeature.rst b/docs/source/api/graphic_features/PresentFeature.rst new file mode 100644 index 000000000..1ddbf1ec4 --- /dev/null +++ b/docs/source/api/graphic_features/PresentFeature.rst @@ -0,0 +1,33 @@ +.. _api.PresentFeature: + +PresentFeature +************** + +============== +PresentFeature +============== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: PresentFeature_api + + PresentFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: PresentFeature_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: PresentFeature_api + + PresentFeature.add_event_handler + PresentFeature.block_events + PresentFeature.clear_event_handlers + PresentFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/ThicknessFeature.rst b/docs/source/api/graphic_features/ThicknessFeature.rst new file mode 100644 index 000000000..80219a2cd --- /dev/null +++ b/docs/source/api/graphic_features/ThicknessFeature.rst @@ -0,0 +1,33 @@ +.. _api.ThicknessFeature: + +ThicknessFeature +**************** + +================ +ThicknessFeature +================ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ThicknessFeature_api + + ThicknessFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ThicknessFeature_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ThicknessFeature_api + + ThicknessFeature.add_event_handler + ThicknessFeature.block_events + ThicknessFeature.clear_event_handlers + ThicknessFeature.remove_event_handler + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst new file mode 100644 index 000000000..aff2aabda --- /dev/null +++ b/docs/source/api/graphic_features/index.rst @@ -0,0 +1,21 @@ +Graphic Features +**************** + +.. toctree:: + :maxdepth: 1 + + ColorFeature + CmapFeature + ImageCmapFeature + HeatmapCmapFeature + PointsDataFeature + ImageDataFeature + HeatmapDataFeature + PresentFeature + ThicknessFeature + GraphicFeature + GraphicFeatureIndexable + FeatureEvent + to_gpu_supported_dtype + LinearSelectionFeature + LinearRegionSelectionFeature diff --git a/docs/source/api/graphic_features/to_gpu_supported_dtype.rst b/docs/source/api/graphic_features/to_gpu_supported_dtype.rst new file mode 100644 index 000000000..984a76157 --- /dev/null +++ b/docs/source/api/graphic_features/to_gpu_supported_dtype.rst @@ -0,0 +1,29 @@ +.. _api.to_gpu_supported_dtype: + +to_gpu_supported_dtype +********************** + +====================== +to_gpu_supported_dtype +====================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: to_gpu_supported_dtype_api + + to_gpu_supported_dtype + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: to_gpu_supported_dtype_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: to_gpu_supported_dtype_api + + diff --git a/docs/source/api/graphics.rst b/docs/source/api/graphics.rst deleted file mode 100644 index d38045dae..000000000 --- a/docs/source/api/graphics.rst +++ /dev/null @@ -1,61 +0,0 @@ -.. _api_graphics: - -Graphics -******** - -Image -##### - -.. autoclass:: fastplotlib.graphics.image.ImageGraphic - :members: - :inherited-members: - -Line -#### - -.. autoclass:: fastplotlib.graphics.line.LineGraphic - :members: - :inherited-members: - -Line Collection -############### - -.. autoclass:: fastplotlib.graphics.line_collection.LineCollection - :members: - :inherited-members: - -Line Stack -########## - -.. autoclass:: fastplotlib.graphics.line_collection.LineStack - :members: - :inherited-members: - -Heatmap -####### - -.. autoclass:: fastplotlib.graphics.image.HeatmapGraphic - :members: - :inherited-members: - -Histogram -######### - -.. autoclass:: fastplotlib.graphics.histogram.HistogramGraphic - :members: - :inherited-members: - -Scatter -####### - -.. autoclass:: fastplotlib.graphics.scatter.ScatterGraphic - :members: - :inherited-members: - -Text -#### - -.. autoclass:: fastplotlib.graphics.text.TextGraphic - :members: - :inherited-members: - diff --git a/docs/source/api/graphics/HeatmapGraphic.rst b/docs/source/api/graphics/HeatmapGraphic.rst new file mode 100644 index 000000000..57466698a --- /dev/null +++ b/docs/source/api/graphics/HeatmapGraphic.rst @@ -0,0 +1,41 @@ +.. _api.HeatmapGraphic: + +HeatmapGraphic +************** + +============== +HeatmapGraphic +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: HeatmapGraphic_api + + HeatmapGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: HeatmapGraphic_api + + HeatmapGraphic.children + HeatmapGraphic.position + HeatmapGraphic.position_x + HeatmapGraphic.position_y + HeatmapGraphic.position_z + HeatmapGraphic.visible + HeatmapGraphic.vmax + HeatmapGraphic.vmin + HeatmapGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: HeatmapGraphic_api + + HeatmapGraphic.add_linear_region_selector + HeatmapGraphic.add_linear_selector + HeatmapGraphic.link + diff --git a/docs/source/api/graphics/HistogramGraphic.rst b/docs/source/api/graphics/HistogramGraphic.rst new file mode 100644 index 000000000..9174092f5 --- /dev/null +++ b/docs/source/api/graphics/HistogramGraphic.rst @@ -0,0 +1,36 @@ +.. _api.HistogramGraphic: + +HistogramGraphic +**************** + +================ +HistogramGraphic +================ +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: HistogramGraphic_api + + HistogramGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: HistogramGraphic_api + + HistogramGraphic.children + HistogramGraphic.position + HistogramGraphic.position_x + HistogramGraphic.position_y + HistogramGraphic.position_z + HistogramGraphic.visible + HistogramGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: HistogramGraphic_api + + diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst new file mode 100644 index 000000000..083c72abb --- /dev/null +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -0,0 +1,39 @@ +.. _api.ImageGraphic: + +ImageGraphic +************ + +============ +ImageGraphic +============ +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGraphic_api + + ImageGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGraphic_api + + ImageGraphic.children + ImageGraphic.position + ImageGraphic.position_x + ImageGraphic.position_y + ImageGraphic.position_z + ImageGraphic.visible + ImageGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageGraphic_api + + ImageGraphic.add_linear_region_selector + ImageGraphic.add_linear_selector + ImageGraphic.link + diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst new file mode 100644 index 000000000..003ad2897 --- /dev/null +++ b/docs/source/api/graphics/LineCollection.rst @@ -0,0 +1,44 @@ +.. _api.LineCollection: + +LineCollection +************** + +============== +LineCollection +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LineCollection_api + + LineCollection + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LineCollection_api + + LineCollection.children + LineCollection.cmap + LineCollection.cmap_values + LineCollection.graphics + LineCollection.position + LineCollection.position_x + LineCollection.position_y + LineCollection.position_z + LineCollection.visible + LineCollection.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LineCollection_api + + LineCollection.add_graphic + LineCollection.add_linear_region_selector + LineCollection.add_linear_selector + LineCollection.link + LineCollection.remove_graphic + diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst new file mode 100644 index 000000000..75af2c4fe --- /dev/null +++ b/docs/source/api/graphics/LineGraphic.rst @@ -0,0 +1,39 @@ +.. _api.LineGraphic: + +LineGraphic +*********** + +=========== +LineGraphic +=========== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LineGraphic_api + + LineGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LineGraphic_api + + LineGraphic.children + LineGraphic.position + LineGraphic.position_x + LineGraphic.position_y + LineGraphic.position_z + LineGraphic.visible + LineGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LineGraphic_api + + LineGraphic.add_linear_region_selector + LineGraphic.add_linear_selector + LineGraphic.link + diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst new file mode 100644 index 000000000..6104d0f74 --- /dev/null +++ b/docs/source/api/graphics/LineStack.rst @@ -0,0 +1,44 @@ +.. _api.LineStack: + +LineStack +********* + +========= +LineStack +========= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LineStack_api + + LineStack + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LineStack_api + + LineStack.children + LineStack.cmap + LineStack.cmap_values + LineStack.graphics + LineStack.position + LineStack.position_x + LineStack.position_y + LineStack.position_z + LineStack.visible + LineStack.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LineStack_api + + LineStack.add_graphic + LineStack.add_linear_region_selector + LineStack.add_linear_selector + LineStack.link + LineStack.remove_graphic + diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst new file mode 100644 index 000000000..3c4bf3909 --- /dev/null +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -0,0 +1,36 @@ +.. _api.ScatterGraphic: + +ScatterGraphic +************** + +============== +ScatterGraphic +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterGraphic_api + + ScatterGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterGraphic_api + + ScatterGraphic.children + ScatterGraphic.position + ScatterGraphic.position_x + ScatterGraphic.position_y + ScatterGraphic.position_z + ScatterGraphic.visible + ScatterGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ScatterGraphic_api + + diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst new file mode 100644 index 000000000..c83c108f6 --- /dev/null +++ b/docs/source/api/graphics/TextGraphic.rst @@ -0,0 +1,42 @@ +.. _api.TextGraphic: + +TextGraphic +*********** + +=========== +TextGraphic +=========== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextGraphic_api + + TextGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextGraphic_api + + TextGraphic.children + TextGraphic.position + TextGraphic.position_x + TextGraphic.position_y + TextGraphic.position_z + TextGraphic.visible + TextGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextGraphic_api + + TextGraphic.update_face_color + TextGraphic.update_outline_color + TextGraphic.update_outline_size + TextGraphic.update_position + TextGraphic.update_size + TextGraphic.update_text + diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst new file mode 100644 index 000000000..fbfa5f6f3 --- /dev/null +++ b/docs/source/api/graphics/index.rst @@ -0,0 +1,14 @@ +Graphics +******** + +.. toctree:: + :maxdepth: 1 + + ImageGraphic + ScatterGraphic + LineGraphic + HistogramGraphic + HeatmapGraphic + LineCollection + LineStack + TextGraphic diff --git a/docs/source/api/gridplot.rst b/docs/source/api/gridplot.rst deleted file mode 100644 index 7e0f877c1..000000000 --- a/docs/source/api/gridplot.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _api_gridplot: - -GridPlot -######## - -.. autoclass:: fastplotlib.GridPlot - :members: - :inherited-members: diff --git a/docs/source/api/layouts/gridplot.rst b/docs/source/api/layouts/gridplot.rst new file mode 100644 index 000000000..f34d0b8d1 --- /dev/null +++ b/docs/source/api/layouts/gridplot.rst @@ -0,0 +1,98 @@ +.. _api.GridPlot: + +GridPlot +******** + +======== +GridPlot +======== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: GridPlot_api + + GridPlot + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: GridPlot_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: GridPlot_api + + GridPlot.add_animations + GridPlot.clear + GridPlot.close + GridPlot.record_start + GridPlot.record_stop + GridPlot.remove_animation + GridPlot.render + GridPlot.show + +======= +Subplot +======= +.. currentmodule:: fastplotlib.layouts._subplot + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Subplot_api + + Subplot + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Subplot_api + + Subplot.camera + Subplot.canvas + Subplot.controller + Subplot.graphics + Subplot.name + Subplot.parent + Subplot.position + Subplot.renderer + Subplot.scene + Subplot.selectors + Subplot.viewport + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Subplot_api + + Subplot.add_animations + Subplot.add_graphic + Subplot.add_heatmap + Subplot.add_histogram + Subplot.add_image + Subplot.add_line + Subplot.add_line_collection + Subplot.add_line_stack + Subplot.add_scatter + Subplot.add_text + Subplot.auto_scale + Subplot.center_graphic + Subplot.center_scene + Subplot.center_title + Subplot.clear + Subplot.delete_graphic + Subplot.get_rect + Subplot.insert_graphic + Subplot.map_screen_to_world + Subplot.remove_animation + Subplot.remove_graphic + Subplot.render + Subplot.set_axes_visibility + Subplot.set_grid_visibility + Subplot.set_title + Subplot.set_viewport_rect + diff --git a/docs/source/api/layouts/plot.rst b/docs/source/api/layouts/plot.rst new file mode 100644 index 000000000..d722bf3c3 --- /dev/null +++ b/docs/source/api/layouts/plot.rst @@ -0,0 +1,70 @@ +.. _api.Plot: + +Plot +**** + +==== +Plot +==== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Plot_api + + Plot + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Plot_api + + Plot.camera + Plot.canvas + Plot.controller + Plot.graphics + Plot.name + Plot.parent + Plot.position + Plot.renderer + Plot.scene + Plot.selectors + Plot.viewport + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Plot_api + + Plot.add_animations + Plot.add_graphic + Plot.add_heatmap + Plot.add_histogram + Plot.add_image + Plot.add_line + Plot.add_line_collection + Plot.add_line_stack + Plot.add_scatter + Plot.add_text + Plot.auto_scale + Plot.center_graphic + Plot.center_scene + Plot.center_title + Plot.clear + Plot.close + Plot.delete_graphic + Plot.get_rect + Plot.insert_graphic + Plot.map_screen_to_world + Plot.record_start + Plot.record_stop + Plot.remove_animation + Plot.remove_graphic + Plot.render + Plot.set_axes_visibility + Plot.set_grid_visibility + Plot.set_title + Plot.set_viewport_rect + Plot.show + diff --git a/docs/source/api/plot.rst b/docs/source/api/plot.rst deleted file mode 100644 index 6b3ecb188..000000000 --- a/docs/source/api/plot.rst +++ /dev/null @@ -1,9 +0,0 @@ -.. _api_plot: - -Plot -#### - -.. autoclass:: fastplotlib.Plot - :members: - :inherited-members: - diff --git a/docs/source/api/selectors.rst b/docs/source/api/selectors.rst deleted file mode 100644 index c43f936bd..000000000 --- a/docs/source/api/selectors.rst +++ /dev/null @@ -1,15 +0,0 @@ -.. _api_selectors: - -Selectors -********* - -Linear -###### - -.. autoclass:: fastplotlib.graphics.selectors.LinearSelector - :members: - :inherited-members: - -.. autoclass:: fastplotlib.graphics.selectors.LinearRegionSelector - :members: - :inherited-members: diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst new file mode 100644 index 000000000..e1824cfc8 --- /dev/null +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -0,0 +1,39 @@ +.. _api.LinearRegionSelector: + +LinearRegionSelector +******************** + +==================== +LinearRegionSelector +==================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelector_api + + LinearRegionSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelector_api + + LinearRegionSelector.children + LinearRegionSelector.position + LinearRegionSelector.position_x + LinearRegionSelector.position_y + LinearRegionSelector.position_z + LinearRegionSelector.visible + LinearRegionSelector.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelector_api + + LinearRegionSelector.get_selected_data + LinearRegionSelector.get_selected_index + LinearRegionSelector.get_selected_indices + diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst new file mode 100644 index 000000000..2c30579f1 --- /dev/null +++ b/docs/source/api/selectors/LinearSelector.rst @@ -0,0 +1,40 @@ +.. _api.LinearSelector: + +LinearSelector +************** + +============== +LinearSelector +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LinearSelector_api + + LinearSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LinearSelector_api + + LinearSelector.children + LinearSelector.position + LinearSelector.position_x + LinearSelector.position_y + LinearSelector.position_z + LinearSelector.visible + LinearSelector.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LinearSelector_api + + LinearSelector.get_selected_data + LinearSelector.get_selected_index + LinearSelector.get_selected_indices + LinearSelector.make_ipywidget_slider + diff --git a/docs/source/api/selectors/Synchronizer.rst b/docs/source/api/selectors/Synchronizer.rst new file mode 100644 index 000000000..d0fa0c2a8 --- /dev/null +++ b/docs/source/api/selectors/Synchronizer.rst @@ -0,0 +1,32 @@ +.. _api.Synchronizer: + +Synchronizer +************ + +============ +Synchronizer +============ +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Synchronizer_api + + Synchronizer + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Synchronizer_api + + Synchronizer.selectors + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Synchronizer_api + + Synchronizer.add + Synchronizer.remove + diff --git a/docs/source/api/selectors/index.rst b/docs/source/api/selectors/index.rst new file mode 100644 index 000000000..918944fd8 --- /dev/null +++ b/docs/source/api/selectors/index.rst @@ -0,0 +1,9 @@ +Selectors +********* + +.. toctree:: + :maxdepth: 1 + + LinearSelector + LinearRegionSelector + Synchronizer diff --git a/docs/source/api/subplot.rst b/docs/source/api/subplot.rst deleted file mode 100644 index b9f7a402b..000000000 --- a/docs/source/api/subplot.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. _api_subplot: - -Subplot -####### - -.. note:: ``Subplot`` is NOT meant to be instantiated directly, it only exists as part of a GridPlot. - -.. autoclass:: fastplotlib.layouts._subplot.Subplot - :members: - :inherited-members: diff --git a/docs/source/api/widgets.rst b/docs/source/api/widgets.rst deleted file mode 100644 index c7e621a37..000000000 --- a/docs/source/api/widgets.rst +++ /dev/null @@ -1,12 +0,0 @@ -.. _api_widgets: - -Widgets -******* - -ImageWidget -########### - -.. autoclass:: fastplotlib.widgets.image.ImageWidget - :members: - :inherited-members: - diff --git a/docs/source/api/widgets/ImageWidget.rst b/docs/source/api/widgets/ImageWidget.rst new file mode 100644 index 000000000..62ec176ce --- /dev/null +++ b/docs/source/api/widgets/ImageWidget.rst @@ -0,0 +1,41 @@ +.. _api.ImageWidget: + +ImageWidget +*********** + +=========== +ImageWidget +=========== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageWidget_api + + ImageWidget + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageWidget_api + + ImageWidget.current_index + ImageWidget.data + ImageWidget.dims_order + ImageWidget.gridplot + ImageWidget.managed_graphics + ImageWidget.ndim + ImageWidget.slider_dims + ImageWidget.sliders + ImageWidget.window_funcs + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageWidget_api + + ImageWidget.reset_vmin_vmax + ImageWidget.set_data + ImageWidget.show + diff --git a/docs/source/api/widgets/index.rst b/docs/source/api/widgets/index.rst new file mode 100644 index 000000000..5cb5299f6 --- /dev/null +++ b/docs/source/api/widgets/index.rst @@ -0,0 +1,7 @@ +Widgets +******* + +.. toctree:: + :maxdepth: 1 + + ImageWidget diff --git a/docs/source/conf.py b/docs/source/conf.py index 7be450060..d65ea7193 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -2,21 +2,31 @@ # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html -from bs4 import BeautifulSoup -from typing import * +import fastplotlib # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'fastplotlib' -copyright = '2022, Kushal Kolar, Caitlin Lewis' +copyright = '2023, Kushal Kolar, Caitlin Lewis' author = 'Kushal Kolar, Caitlin Lewis' -release = 'v0.1.0.a6' +release = fastplotlib.__version__ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["sphinx.ext.napoleon", "sphinx.ext.autodoc"] +extensions = [ + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", + "sphinx_copybutton", + "sphinx_design", + "nbsphinx", +] + +autosummary_generate = True templates_path = ['_templates'] exclude_patterns = [] @@ -26,10 +36,11 @@ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'pydata_sphinx_theme' -html_theme_options = {"page_sidebar_items": ["class_page_toc"]} +html_theme = "furo" html_static_path = ['_static'] +html_logo = "_static/logo.png" +html_title = f"v{release}" autodoc_member_order = 'groupwise' autoclass_content = "both" @@ -37,47 +48,14 @@ autodoc_typehints = "description" autodoc_typehints_description_target = "documented_params" -def _setup_navbar_side_toctree(app: Any): - - def add_class_toctree_function(app: Any, pagename: Any, templatename: Any, context: Any, doctree: Any): - def get_class_toc() -> Any: - soup = BeautifulSoup(context["body"], "html.parser") - - matches = soup.find_all('dl') - if matches is None or len(matches) == 0: - return "" - items = [] - deeper_depth = matches[0].find('dt').get('id').count(".") - for match in matches: - match_dt = match.find('dt') - if match_dt is not None and match_dt.get('id') is not None: - current_title = match_dt.get('id') - current_depth = match_dt.get('id').count(".") - current_link = match.find(class_="headerlink") - if current_link is not None: - if deeper_depth > current_depth: - deeper_depth = current_depth - if deeper_depth == current_depth: - items.append({ - "title": current_title.split('.')[-1], - "link": current_link["href"], - "attributes_and_methods": [] - }) - if deeper_depth < current_depth: - items[-1]["attributes_and_methods"].append( - { - "title": current_title.split('.')[-1], - "link": current_link["href"], - } - ) - return items - context["get_class_toc"] = get_class_toc - - app.connect("html-page-context", add_class_toctree_function) - - -def setup(app: Any): - for setup_function in [ - _setup_navbar_side_toctree, - ]: - setup_function(app) +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'numpy': ('https://numpy.org/doc/stable/', None), + 'pygfx': ('https://pygfx.readthedocs.io/en/latest', None) +} + +html_theme_options = { + "source_repository": "https://github.com/kushalkolar/fastplotlib", + "source_branch": "master", + "source_directory": "docs/", +} diff --git a/docs/source/fastplotlib_banner.xcf b/docs/source/fastplotlib_banner.xcf new file mode 100644 index 0000000000000000000000000000000000000000..e632ed0d711870363011c7a2fc14305f023683e1 GIT binary patch literal 241410 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

YB*Ssh&{hdNW>XT${K;lW!g>Z^hj(X&U5f9 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%0D-g!MC@(40M9bjRP~JRs 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

mvn?!b9i4^Z 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

Z=HQ%p$AvJvCuM)*-X)Ix#u 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 M=tqttM?boM13#NBSpWb4 literal 0 HcmV?d00001 diff --git a/docs/source/fastplotlib_logo.xcf b/docs/source/fastplotlib_logo.xcf new file mode 100644 index 0000000000000000000000000000000000000000..f80b3e1b5b612d434ea0fb1e1341359d5e264cb1 GIT binary patch literal 224234 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 literal 0 HcmV?d00001 diff --git a/docs/source/generate_rst.py b/docs/source/generate_rst.py new file mode 100644 index 000000000..3f9c7ac83 --- /dev/null +++ b/docs/source/generate_rst.py @@ -0,0 +1,266 @@ +from typing import * +import inspect +from pathlib import Path +import os + +import fastplotlib +from fastplotlib.layouts._subplot import Subplot +from fastplotlib import graphics +from fastplotlib.graphics import _features, selectors +from fastplotlib import widgets + +current_dir = Path(__file__).parent.resolve() + +API_DIR = current_dir.joinpath("api") +LAYOUTS_DIR = API_DIR.joinpath("layouts") +GRAPHICS_DIR = API_DIR.joinpath("graphics") +GRAPHIC_FEATURES_DIR = API_DIR.joinpath("graphic_features") +SELECTORS_DIR = API_DIR.joinpath("selectors") +WIDGETS_DIR = API_DIR.joinpath("widgets") + +doc_sources = [ + API_DIR, + LAYOUTS_DIR, + GRAPHICS_DIR, + GRAPHIC_FEATURES_DIR, + SELECTORS_DIR, + WIDGETS_DIR +] + +for source_dir in doc_sources: + os.makedirs(source_dir, exist_ok=True) + + +def get_public_members(cls) -> Tuple[List[str], List[str]]: + """ + Returns (public_methods, public_properties) + + Parameters + ---------- + cls + + Returns + ------- + + """ + methods = list() + properties = list() + for member in inspect.getmembers(cls): + # only document public methods + if member[0].startswith("_"): + continue + + if callable(member[1]): + methods.append(member[0]) + elif isinstance(member[1], property): + properties.append(member[0]) + + return methods, properties + + +def generate_class( + cls: type, + module: str, +): + name = cls.__name__ + methods, properties = get_public_members(cls) + methods = [ + f"{name}.{m}" for m in methods + ] + + properties = [ + f"{name}.{p}" for p in properties + ] + + underline = "=" * len(name) + + methods_str = "\n ".join([""] + methods) + properties_str = "\n ".join([""] + properties) + + out = ( + f"{underline}\n" + f"{name}\n" + f"{underline}\n" + f".. currentmodule:: {module}\n" + f"\n" + f"Constructor\n" + f"~~~~~~~~~~~\n" + f".. autosummary::\n" + f" :toctree: {name}_api\n" + f"\n" + f" {name}\n" + f"\n" + f"Properties\n" + f"~~~~~~~~~~\n" + f".. autosummary::\n" + f" :toctree: {name}_api\n" + f"{properties_str}\n" + f"\n" + f"Methods\n" + f"~~~~~~~\n" + f".. autosummary::\n" + f" :toctree: {name}_api\n" + f"{methods_str}\n" + f"\n" + ) + + return out + + +def generate_page( + page_name: str, + modules: List[str], + classes: List[type], + source_path: Path, +): + page_name_underline = "*" * len(page_name) + with open(source_path, "w") as f: + f.write( + f".. _api.{page_name}:\n" + f"\n" + f"{page_name}\n" + f"{page_name_underline}\n" + f"\n" + ) + + for cls, module in zip(classes, modules): + to_write = generate_class(cls, module) + f.write(to_write) + + +def main(): + generate_page( + page_name="Plot", + classes=[fastplotlib.Plot], + modules=["fastplotlib"], + source_path=LAYOUTS_DIR.joinpath("plot.rst") + ) + + generate_page( + page_name="GridPlot", + classes=[fastplotlib.GridPlot, Subplot], + modules=["fastplotlib", "fastplotlib.layouts._subplot"], + source_path=LAYOUTS_DIR.joinpath("gridplot.rst") + ) + + # the rest of this is a mess and can be refactored later + + graphic_classes = [ + getattr(graphics, g) for g in graphics.__all__ + ] + + graphic_class_names = [ + g.__name__ for g in graphic_classes + ] + + graphic_class_names_str = "\n ".join([""] + graphic_class_names) + + # graphic classes index file + with open(GRAPHICS_DIR.joinpath("index.rst"), "w") as f: + f.write( + f"Graphics\n" + f"********\n" + f"\n" + f".. toctree::\n" + f" :maxdepth: 1\n" + f"{graphic_class_names_str}\n" + ) + + for graphic_cls in graphic_classes: + generate_page( + page_name=graphic_cls.__name__, + classes=[graphic_cls], + modules=["fastplotlib"], + source_path=GRAPHICS_DIR.joinpath(f"{graphic_cls.__name__}.rst") + ) + ############################################################################## + + feature_classes = [ + getattr(_features, f) for f in _features.__all__ + ] + + feature_class_names = [ + f.__name__ for f in feature_classes + ] + + feature_class_names_str = "\n ".join([""] + feature_class_names) + + with open(GRAPHIC_FEATURES_DIR.joinpath("index.rst"), "w") as f: + f.write( + f"Graphic Features\n" + f"****************\n" + f"\n" + f".. toctree::\n" + f" :maxdepth: 1\n" + f"{feature_class_names_str}\n" + ) + + for feature_cls in feature_classes: + generate_page( + page_name=feature_cls.__name__, + classes=[feature_cls], + modules=["fastplotlib.graphics._features"], + source_path=GRAPHIC_FEATURES_DIR.joinpath(f"{feature_cls.__name__}.rst") + ) + ############################################################################## + + selector_classes = [ + getattr(selectors, s) for s in selectors.__all__ + ] + + selector_class_names = [ + s.__name__ for s in selector_classes + ] + + selector_class_names_str = "\n ".join([""] + selector_class_names) + + with open(SELECTORS_DIR.joinpath("index.rst"), "w") as f: + f.write( + f"Selectors\n" + f"*********\n" + f"\n" + f".. toctree::\n" + f" :maxdepth: 1\n" + f"{selector_class_names_str}\n" + ) + + for selector_cls in selector_classes: + generate_page( + page_name=selector_cls.__name__, + classes=[selector_cls], + modules=["fastplotlib"], + source_path=SELECTORS_DIR.joinpath(f"{selector_cls.__name__}.rst") + ) + ############################################################################## + + widget_classes = [ + getattr(widgets, w) for w in widgets.__all__ + ] + + widget_class_names = [ + w.__name__ for w in widget_classes + ] + + widget_class_names_str = "\n ".join([""] + widget_class_names) + + with open(WIDGETS_DIR.joinpath("index.rst"), "w") as f: + f.write( + f"Widgets\n" + f"*******\n" + f"\n" + f".. toctree::\n" + f" :maxdepth: 1\n" + f"{widget_class_names_str}\n" + ) + + for widget_cls in widget_classes: + generate_page( + page_name=widget_cls.__name__, + classes=[widget_cls], + modules=["fastplotlib"], + source_path=WIDGETS_DIR.joinpath(f"{widget_cls.__name__}.rst") + ) + + +if __name__ == "__main__": + main() diff --git a/docs/source/index.rst b/docs/source/index.rst index 9a0723af7..99f342fb9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,34 +6,43 @@ Welcome to fastplotlib's documentation! ======================================= +.. toctree:: + :caption: Quick Start + :maxdepth: 2 + + quickstart + .. toctree:: :maxdepth: 1 - :caption: Contents: + :caption: API - Plot - Subplot - Gridplot - Graphics - Graphic Features - Selectors - Widgets + Plot + Gridplot + Graphics + Graphic Features + Selectors + Widgets Summary ======= -``fastplotlib`` is a fast plotting library built using ``pygfx`` render engine utilizing `Vulkan `_ via WGPU. We are focused on fast interactive plotting in the notebook using an expressive API. It also works within desktop application using ``glfw`` or ``Qt``. +A fast plotting library built using the `pygfx `_ render engine utilizing `Vulkan `_, `DX12 `_, or `Metal `_ via `WGPU `_, so it is very fast! We also aim to be an expressive plotting library that enables rapid prototyping for large scale explorative scientific visualization. `fastplotlib` will run on any framework that ``pygfx`` runs on, this includes ``glfw``, ``Qt`` and ``jupyter lab`` + + Installation ============ -For installation please see the instruction on the README on GitHub: +For installation please see the instructions on GitHub: -https://github.com/kushalkolar/fastplotlib +https://github.com/kushalkolar/fastplotlib#installation Contributing ============ -We're open to contributions! If you think there is any useful functionality that can be added, post an issue on the repo with your idea. Also, take a look at the `Roadmap 2023 `_ for future plans or ways in which you could contribute. +Contributions are welcome! See the contributing guide on GitHub: https://github.com/kushalkolar/fastplotlib/blob/master/CONTRIBUTING.md. + +Also take a look at the `Roadmap 2023 `_ for future plans or ways in which you could contribute. Indices and tables ================== diff --git a/docs/source/quickstart.ipynb b/docs/source/quickstart.ipynb new file mode 100644 index 000000000..66543c063 --- /dev/null +++ b/docs/source/quickstart.ipynb @@ -0,0 +1,1431 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "93740a09-9111-4777-ad57-173e9b80a2f0", + "metadata": { + "tags": [] + }, + "source": [ + "# Quick Start Guide 🚀\n", + "\n", + "This notebook goes the basic components of the `fastplotlib` API, image, image updates, line plots, and scatter plots.\n", + "\n", + "**NOTE: This quick start guide in the docs is NOT interactive. Download the examples from the repo and try them on your own computer. You can run the desktop examples directly if you have `glfw` installed, or try the notebook demos:** https://github.com/kushalkolar/fastplotlib/tree/master/examples\n", + "\n", + "It will not be possible to have live demos on the docs until someone can figure out how to get [pygfx](https://github.com/pygfx/pygfx) to work with `wgpu` in the browser, perhaps through [pyodide](https://github.com/pyodide/pyodide) or something :D." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb57c3d3-f20d-4d88-9e7a-04b9309bc637", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import fastplotlib as fpl\n", + "from ipywidgets import VBox, HBox, IntSlider\n", + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2d663646-3d3b-4f5b-a083-a5daca65cb4f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import os" + ] + }, + { + "cell_type": "markdown", + "id": "a9b386ac-9218-4f8f-97b3-f29b4201ef55", + "metadata": {}, + "source": [ + "## Images" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "237823b7-e2c0-4e2f-9ee8-e3fc2b4453c4", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# create a `Plot` instance\n", + "plot = fpl.Plot()\n", + "\n", + "# make some random 2D image data\n", + "data = np.random.rand(512, 512)\n", + "\n", + "# plot the image data\n", + "image_graphic = plot.add_image(data=data, name=\"random-image\")\n", + "\n", + "# show the plot\n", + "plot.show()" + ] + }, + { + "cell_type": "markdown", + "id": "be5b408f-dd91-4e36-807a-8c22c8d7d216", + "metadata": {}, + "source": [ + "In live notebooks or desktop applications, you can use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!" + ] + }, + { + "cell_type": "markdown", + "id": "7c3b637c-a26b-416e-936c-705275852a8a", + "metadata": {}, + "source": [ + "Changing graphic \"features\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de816c88-1c4a-4071-8a5e-c46c93671ef5", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic.cmap = \"viridis\"" + ] + }, + { + "cell_type": "markdown", + "id": "ecc72f23-22ea-4bd1-b9a0-fd4e14baa79f", + "metadata": {}, + "source": [ + "This is how you can take a snapshot of the canvas. Snapshots are shown throughout this doc page for the purposes of documentation, they are NOT necessary for real interactive usage. Download the notebooks to run live demos." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ebc87904-f705-46f0-8f94-fc3b1c6c8e30", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "de0653cf-937e-4d0f-965d-296fccaac53e", + "metadata": {}, + "source": [ + "Setting image data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "09350854-5058-4574-a01d-84d00e276c57", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic.data = 0" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9bcc3943-ea40-4905-a2a2-29e2620f00c8", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "05034a44-f207-45a0-9b5e-3ba7cc118107", + "metadata": {}, + "source": [ + "Setting image data with slicing" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "83b2db1b-2783-4e89-bcf3-66bb6e09e18a", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic.data[::15, :] = 1\n", + "image_graphic.data[:, ::15] = 1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d400b00b-bdf0-4383-974f-9cccd4cd48b6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "4abfe97e-8aa6-42c0-8b23-797153a885e3", + "metadata": {}, + "source": [ + "Setting image data back to random" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e298c1c-7551-4401-ade0-b9af7d2bbe23", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic.data = np.random.rand(512, 512)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49d44536-b36c-47be-9c09-46a81a2c8607", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "67b92ffd-40cc-43fe-9df9-0e0d94763d8e", + "metadata": {}, + "source": [ + "Plots are indexable and give you their graphics by name" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e6ba689c-ff4a-44ef-9663-f2c8755072c4", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.graphics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5b18f4e3-e13b-46d5-af1f-285c5a7fdc12", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot[\"random-image\"]" + ] + }, + { + "cell_type": "markdown", + "id": "4316a8b5-5f33-427a-8f52-b101d1daab67", + "metadata": {}, + "source": [ + "The `Graphic` instance is also returned when you call `plot.add_`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b5c1321-1fd4-44bc-9433-7439ad3e22cf", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b12bf75e-4e93-4930-9146-e96324fdf3f6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic == plot[\"random-image\"]" + ] + }, + { + "cell_type": "markdown", + "id": "1cb03f42-1029-4b16-a16b-35447d9e2955", + "metadata": { + "tags": [] + }, + "source": [ + "## Image updates\n", + "\n", + "This examples show how you can define animation functions that run on every render cycle." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aadd757f-6379-4f52-a709-46aa57c56216", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# create another `Plot` instance\n", + "plot_v = fpl.Plot()\n", + "\n", + "plot.canvas.max_buffered_frames = 1\n", + "\n", + "# make some random data again\n", + "data = np.random.rand(512, 512)\n", + "\n", + "# plot the data\n", + "plot_v.add_image(data=data, name=\"random-image\")\n", + "\n", + "# a function to update the image_graphic\n", + "# a plot will pass its plot instance to the animation function as an arugment\n", + "def update_data(plot_instance):\n", + " new_data = np.random.rand(512, 512)\n", + " plot_instance[\"random-image\"].data = new_data\n", + "\n", + "#add this as an animation function\n", + "plot_v.add_animations(update_data)\n", + "\n", + "# show the plot\n", + "plot_v.show()" + ] + }, + { + "cell_type": "markdown", + "id": "b313eda1-6e6c-466f-9fd5-8b70c1d3c110", + "metadata": {}, + "source": [ + "**Share controllers across plots**\n", + "\n", + "This example creates a new plot, but it synchronizes the pan-zoom controller" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "86e70b1e-4328-4035-b992-70dff16d2a69", + "metadata": {}, + "outputs": [], + "source": [ + "plot_sync = fpl.Plot(controller=plot_v.controller)\n", + "\n", + "data = np.random.rand(512, 512)\n", + "\n", + "image_graphic_instance = plot_sync.add_image(data=data, cmap=\"viridis\")\n", + "\n", + "# you will need to define a new animation function for this graphic\n", + "def update_data_2():\n", + " new_data = np.random.rand(512, 512)\n", + " # alternatively, you can use the stored reference to the graphic as well instead of indexing the Plot\n", + " image_graphic_instance.data = new_data\n", + "\n", + "plot_sync.add_animations(update_data_2)\n", + "\n", + "plot_sync.show()" + ] + }, + { + "cell_type": "markdown", + "id": "f226c9c2-8d0e-41ab-9ab9-1ae31fd91de5", + "metadata": {}, + "source": [ + "Keeping a reference to the Graphic instance, as shown above `image_graphic_instance`, is useful if you're creating something where you need flexibility in the naming of the graphics" + ] + }, + { + "cell_type": "markdown", + "id": "d11fabb7-7c76-4e94-893d-80ed9ee3be3d", + "metadata": {}, + "source": [ + "You can also use `ipywidgets.VBox` and `HBox` to stack plots. See the `gridplot` notebooks for a proper gridplot interface for more automated subplotting\n", + "\n", + "Not shown in the docs, try the live demo for this feature" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d", + "metadata": {}, + "outputs": [], + "source": [ + "#VBox([plot_v.canvas, plot_sync.show()])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11839d95-8ff7-444c-ae13-6b072c3112c5", + "metadata": {}, + "outputs": [], + "source": [ + "#HBox([plot_v.show(), plot_sync.show()])" + ] + }, + { + "cell_type": "markdown", + "id": "e7859338-8162-408b-ac72-37e606057045", + "metadata": { + "tags": [] + }, + "source": [ + "## Line plots\n", + "\n", + "2D line plots\n", + "\n", + "This example plots a sine wave, cosine wave, and ricker wavelet and demonstrates how **Graphic Features** can be modified by slicing!" + ] + }, + { + "cell_type": "markdown", + "id": "a6fee1c2-4a24-4325-bca2-26e5a4bf6338", + "metadata": {}, + "source": [ + "Generate some data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e8280da-b421-43a5-a1a6-2a196a408e9a", + "metadata": {}, + "outputs": [], + "source": [ + "# linspace, create 100 evenly spaced x values from -10 to 10\n", + "xs = np.linspace(-10, 10, 100)\n", + "# sine wave\n", + "ys = np.sin(xs)\n", + "sine = np.dstack([xs, ys])[0]\n", + "\n", + "# cosine wave\n", + "ys = np.cos(xs) + 5\n", + "cosine = np.dstack([xs, ys])[0]\n", + "\n", + "# sinc function\n", + "a = 0.5\n", + "ys = np.sinc(xs) * 3 + 8\n", + "sinc = np.dstack([xs, ys])[0]" + ] + }, + { + "cell_type": "markdown", + "id": "fbb806e5-1565-4189-936c-b7cf147a59ee", + "metadata": {}, + "source": [ + "Plot all of it on the same plot. Each line plot will be an individual Graphic, you can have any combination of graphics on a plot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93a5d1e6-d019-4dd0-a0d1-25d1704ab7a7", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a plot instance\n", + "plot_l = fpl.Plot()\n", + "\n", + "# plot sine wave, use a single color\n", + "sine_graphic = plot_l.add_line(data=sine, thickness=5, colors=\"magenta\")\n", + "\n", + "# you can also use colormaps for lines!\n", + "cosine_graphic = plot_l.add_line(data=cosine, thickness=12, cmap=\"autumn\")\n", + "\n", + "# or a list of colors for each datapoint\n", + "colors = [\"r\"] * 25 + [\"purple\"] * 25 + [\"y\"] * 25 + [\"b\"] * 25\n", + "sinc_graphic = plot_l.add_line(data=sinc, thickness=5, colors = colors)\n", + "\n", + "plot_l.show()" + ] + }, + { + "cell_type": "markdown", + "id": "22dde600-0f56-4370-b017-c8f23a6c01aa", + "metadata": {}, + "source": [ + "\"stretching\" the camera, useful for large timeseries data\n", + "\n", + "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!\n", + "\n", + "You can also click the **`1:1`** button to toggle this." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2695f023-f6ce-4e26-8f96-4fbed5510d1d", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.camera.maintain_aspect = False" + ] + }, + { + "cell_type": "markdown", + "id": "1651e965-f750-47ac-bf53-c23dae84cc98", + "metadata": {}, + "source": [ + "reset the plot area" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba50a6ed-0f1b-4795-91dd-a7c3e40b8e3c", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.auto_scale(maintain_aspect=True)" + ] + }, + { + "cell_type": "markdown", + "id": "dcd68796-c190-4c3f-8519-d73b98ff6367", + "metadata": {}, + "source": [ + "Graphic features support slicing! :D " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb0d13ed-ef07-46ff-b19e-eeca4c831037", + "metadata": {}, + "outputs": [], + "source": [ + "# indexing of colors\n", + "cosine_graphic.colors[:15] = \"magenta\"\n", + "cosine_graphic.colors[90:] = \"red\"\n", + "cosine_graphic.colors[60] = \"w\"\n", + "\n", + "# indexing to assign colormaps to entire lines or segments\n", + "sinc_graphic.cmap[10:50] = \"gray\"\n", + "sine_graphic.cmap = \"seismic\"\n", + "\n", + "# more complex indexing, set the blue value directly from an array\n", + "cosine_graphic.colors[65:90, 0] = np.linspace(0, 1, 90-65)" + ] + }, + { + "cell_type": "markdown", + "id": "bfe14ed3-e81f-4058-96a7-e2720b6d2f45", + "metadata": {}, + "source": [ + "Make a snapshot of the canvas after slicing" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a061a888-d732-406e-a9c2-8cc632fbc368", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "c9689887-cdf3-4a4d-948f-7efdb09bde4e", + "metadata": {}, + "source": [ + "**You can capture changes to a graphic feature as events**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cfa001f6-c640-4f91-beb0-c19b030e503f", + "metadata": {}, + "outputs": [], + "source": [ + "def callback_func(event_data):\n", + " print(event_data)\n", + "\n", + "# Will print event data when the color changes\n", + "cosine_graphic.colors.add_event_handler(callback_func)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb8a0f95-0063-4cd4-a117-e3d62c6e120d", + "metadata": {}, + "outputs": [], + "source": [ + "# more complex indexing of colors\n", + "# from point 15 - 30, set every 3rd point as \"cyan\"\n", + "cosine_graphic.colors[15:50:3] = \"cyan\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3da9a43b-35bd-4b56-9cc7-967536aac967", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "c29f81f9-601b-49f4-b20c-575c56e58026", + "metadata": {}, + "source": [ + "Graphic `data` is also indexable" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60", + "metadata": {}, + "outputs": [], + "source": [ + "cosine_graphic.data[10:50:5, :2] = sine[10:50:5]\n", + "cosine_graphic.data[90:, 1] = 7" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "682db47b-8c7a-4934-9be4-2067e9fb12d5", + "metadata": {}, + "outputs": [], + "source": [ + "cosine_graphic.data[0] = np.array([[-10, 0, 0]])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f779cba0-7ee2-4795-8da8-9a9593d3893e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "3f6d264b-1b03-407e-9d83-cd6cfb02e706", + "metadata": {}, + "source": [ + "Toggle the presence of a graphic within the scene" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3", + "metadata": {}, + "outputs": [], + "source": [ + "sinc_graphic.present = False" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a5e22d0f-a244-47e2-9a2d-1eaf79eda1d9", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.canvas.snapshot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3", + "metadata": {}, + "outputs": [], + "source": [ + "sinc_graphic.present = True" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b22a8660-26b3-4c73-b87a-df9d7cb4353a", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "86f4e535-ce88-415a-b8d2-53612a2de7b9", + "metadata": {}, + "source": [ + "You can create callbacks to `present` too, for example to re-scale the plot w.r.t. graphics that are present in the scene" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64a20a16-75a5-4772-a849-630ade9be4ff", + "metadata": {}, + "outputs": [], + "source": [ + "sinc_graphic.present.add_event_handler(plot_l.auto_scale)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb093046-c94c-4085-86b4-8cd85cb638ff", + "metadata": {}, + "outputs": [], + "source": [ + "sinc_graphic.present = False" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f9dd6a54-3460-4fb7-bffb-82fd9288902f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.canvas.snapshot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f05981c3-c768-4631-ae62-6a8407b20c4c", + "metadata": {}, + "outputs": [], + "source": [ + "sinc_graphic.present = True" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb5bf73e-b015-4b4f-82a0-c3ae8cc39ef7", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "05f93e93-283b-45d8-ab31-8d15a7671dd2", + "metadata": {}, + "source": [ + "You can set the z-positions of graphics to have them appear under or over other graphics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6bb33406-5bef-455b-86ea-358a7d3ffa94", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "img = np.random.rand(20, 100)\n", + "\n", + "plot_l.add_image(img, name=\"image\", cmap=\"gray\")\n", + "\n", + "# z axix position -1 so it is below all the lines\n", + "plot_l[\"image\"].position_z = -1\n", + "plot_l[\"image\"].position_x = -50" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5b586a89-ca3e-4e88-a801-bdd665384f59", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "2c90862e-2f2a-451f-a468-0cf6b857e87a", + "metadata": {}, + "source": [ + "### 3D line plot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9c51229f-13a2-4653-bff3-15d43ddbca7b", + "metadata": {}, + "outputs": [], + "source": [ + "# just set the camera as \"3d\", the rest is basically the same :D \n", + "plot_l3d = fpl.Plot(camera='3d')\n", + "\n", + "# create a spiral\n", + "phi = np.linspace(0, 30, 200)\n", + "\n", + "xs = phi * np.cos(phi)\n", + "ys = phi * np.sin(phi)\n", + "zs = phi\n", + "\n", + "# use 3D data\n", + "# note: you usually mix 3D and 2D graphics on the same plot\n", + "spiral = np.dstack([xs, ys, zs])[0]\n", + "\n", + "plot_l3d.add_line(data=spiral, thickness=2, cmap='winter')\n", + "\n", + "plot_l3d.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28eb7014-4773-4a34-8bfc-bd3a46429012", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l3d.auto_scale(maintain_aspect=True)" + ] + }, + { + "cell_type": "markdown", + "id": "a202b3d0-2a0b-450a-93d4-76d0a1129d1d", + "metadata": {}, + "source": [ + "## Scatter plots\n", + "\n", + "Plot tens of thousands or millions of points\n", + "\n", + "There might be a small delay for a few seconds before the plot shows, this is due to shaders being compiled and a few other things. The plot should be very fast and responsive once it is displayed and future modifications should also be fast!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39252df5-9ae5-4132-b97b-2785c5fa92ea", + "metadata": {}, + "outputs": [], + "source": [ + "# create a random distribution\n", + "# only 1,000 points shown here in the docs, but it can be millions\n", + "n_points = 1_000\n", + "\n", + "# if you have a good GPU go for 1.5 million points :D \n", + "# this is multiplied by 3\n", + "#n_points = 500_000\n", + "\n", + "# dimensions always have to be [n_points, xyz]\n", + "dims = (n_points, 3)\n", + "\n", + "clouds_offset = 15\n", + "\n", + "# create some random clouds\n", + "normal = np.random.normal(size=dims, scale=5)\n", + "# stack the data into a single array\n", + "cloud = np.vstack(\n", + " [\n", + " normal - clouds_offset,\n", + " normal,\n", + " normal + clouds_offset,\n", + " ]\n", + ")\n", + "\n", + "# color each of them separately\n", + "colors = [\"yellow\"] * n_points + [\"cyan\"] * n_points + [\"magenta\"] * n_points\n", + "\n", + "# create plot\n", + "plot_s = fpl.Plot()\n", + "\n", + "# use an alpha value since this will be a lot of points\n", + "scatter_graphic = plot_s.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.7)\n", + "\n", + "plot_s.show()" + ] + }, + { + "cell_type": "markdown", + "id": "b6e4a704-ee6b-4316-956e-acb4dcc1c6f2", + "metadata": {}, + "source": [ + "**Scatter graphic features work similarly to line graphic**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8fa46ec0-8680-44f5-894c-559de3145932", + "metadata": {}, + "outputs": [], + "source": [ + "# half of the first cloud's points to red\n", + "scatter_graphic.colors[:n_points:2] = \"r\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "293a4793-44b9-4d18-ae6a-68e7c6f91acc", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_s.canvas.snapshot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e4dc71e4-5144-436f-a464-f2a29eee8f0b", + "metadata": {}, + "outputs": [], + "source": [ + "# set the green value directly\n", + "scatter_graphic.colors[n_points:n_points * 2, 1] = 0.3" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5ea7852d-fdae-401b-83b6-b6cfd975f64f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_s.canvas.snapshot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5b637a29-cd5e-4011-ab81-3f91490d9ecd", + "metadata": {}, + "outputs": [], + "source": [ + "# set color values directly using an array\n", + "scatter_graphic.colors[n_points * 2:] = np.repeat([[1, 1, 0, 0.5]], n_points, axis=0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02c19f51-6436-4601-976e-04326df0de81", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_s.canvas.snapshot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4084fce-78a2-48b3-9a0d-7b57c165c3c1", + "metadata": {}, + "outputs": [], + "source": [ + "# change the data, change y-values\n", + "scatter_graphic.data[n_points:n_points * 2, 1] += 15" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ec43f58-4710-4603-9358-682c4af3f701", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_s.canvas.snapshot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f486083e-7c58-4255-ae1a-3fe5d9bfaeed", + "metadata": {}, + "outputs": [], + "source": [ + "# set x values directly but using an array\n", + "scatter_graphic.data[n_points:n_points * 2, 0] = np.linspace(-40, 0, n_points)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6bcb3bc3-4b75-4bbc-b8ca-f8a3219ec3d7", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_s.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "d9e554de-c436-4684-a46a-ce8a33d409ac", + "metadata": {}, + "source": [ + "## ipywidget layouts\n", + "\n", + "This just plots everything from these examples in a single output cell" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "01a6f70b-c81b-4ee5-8a6b-d979b87227eb", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# row1 = HBox([plot.show(), plot_v.show(), plot_sync.show()])\n", + "# row2 = HBox([plot_l.show(), plot_l3d.show(), plot_s.show()])\n", + "\n", + "# VBox([row1, row2])" + ] + }, + { + "cell_type": "markdown", + "id": "a26c0063-b7e0-4f36-bb14-db06bafa31aa", + "metadata": {}, + "source": [ + "## Gridplot\n", + "\n", + "Subplots within a `GridPlot` behave the same as simple `Plot` instances! \n", + "\n", + "💡 `Plot` is actually a subclass of `Subplot`!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6b7e1129-ae8e-4a0f-82dc-bd8fb65871fc", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# GridPlot of shape 2 x 3 with all controllers synced\n", + "grid_plot = fpl.GridPlot(shape=(2, 3), controllers=\"sync\")\n", + "\n", + "# Make a random image graphic for each subplot\n", + "for subplot in grid_plot:\n", + " # create image data\n", + " data = np.random.rand(512, 512)\n", + " # add an image to the subplot\n", + " subplot.add_image(data, name=\"rand-img\")\n", + "\n", + "# Define a function to update the image graphics with new data\n", + "# add_animations will pass the gridplot to the animation function\n", + "def update_data(gp):\n", + " for sp in gp:\n", + " new_data = np.random.rand(512, 512)\n", + " # index the image graphic by name and set the data\n", + " sp[\"rand-img\"].data = new_data\n", + " \n", + "# add the animation function\n", + "grid_plot.add_animations(update_data)\n", + "\n", + "# show the gridplot \n", + "grid_plot.show()" + ] + }, + { + "cell_type": "markdown", + "id": "f4f71c34-3925-442f-bd76-60dd57d09f48", + "metadata": {}, + "source": [ + "### Slicing GridPlot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d8194c9e-9a99-4d4a-8984-a4cfcab0c42c", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# positional indexing\n", + "# row 0 and col 0\n", + "grid_plot[0, 0]" + ] + }, + { + "cell_type": "markdown", + "id": "d626640f-bc93-4883-9bf4-47b825bbc663", + "metadata": {}, + "source": [ + "You can get the graphics within a subplot, just like with simple `Plot`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bffec80c-e81b-4945-85a2-c2c5e8395677", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "grid_plot[0, 1].graphics" + ] + }, + { + "cell_type": "markdown", + "id": "a4e3184f-c86a-4a7e-b803-31632cc163b0", + "metadata": {}, + "source": [ + "and change their properties" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "04b616fb-6644-42ba-8683-0589ce7d165e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "grid_plot[0, 1].graphics[0].vmax = 0.5" + ] + }, + { + "cell_type": "markdown", + "id": "28f7362c-d1b9-43ef-85c5-4d68f70f459c", + "metadata": {}, + "source": [ + "more slicing with `GridPlot`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "920e6365-bb50-4882-9b0d-8367dc485360", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# you can give subplots human-readable string names\n", + "grid_plot[0, 2].name = \"top-right-plot\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73300d2c-3e70-43ad-b5a2-40341b701ac8", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "grid_plot[\"top-right-plot\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "834d9905-35e9-4711-9375-5b1828c80ee2", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# view its position\n", + "grid_plot[\"top-right-plot\"].position" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9aa61efa-c6a5-4611-a03b-1b8da66b19f0", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# these are really the same\n", + "grid_plot[\"top-right-plot\"] is grid_plot[0, 2]" + ] + }, + { + "cell_type": "markdown", + "id": "28c8b145-86cb-4445-92be-b7537a87f7ca", + "metadata": {}, + "source": [ + "Indexing with subplot name and graphic name" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b7b73a3-5335-4bd5-bbef-c7d3cfbb3ca7", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "grid_plot[\"top-right-plot\"][\"rand-img\"].vmin = 0.5" + ] + }, + { + "cell_type": "markdown", + "id": "6a5b4368-ae4d-442c-a11f-45c70267339b", + "metadata": {}, + "source": [ + "## GridPlot customization" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "175d45a6-3351-4b75-8ff3-08797fe0a389", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# grid with 2 rows and 3 columns\n", + "grid_shape = (2, 3)\n", + "\n", + "# pan-zoom controllers for each view\n", + "# views are synced if they have the \n", + "# same controller ID\n", + "controllers = [\n", + " [0, 3, 1], # id each controller with an integer\n", + " [2, 2, 3]\n", + "]\n", + "\n", + "\n", + "# you can give string names for each subplot within the gridplot\n", + "names = [\n", + " [\"subplot0\", \"subplot1\", \"subplot2\"],\n", + " [\"subplot3\", \"subplot4\", \"subplot5\"]\n", + "]\n", + "\n", + "# Create the grid plot\n", + "grid_plot = fpl.GridPlot(\n", + " shape=grid_shape,\n", + " controllers=controllers,\n", + " names=names,\n", + ")\n", + "\n", + "\n", + "# Make a random image graphic for each subplot\n", + "for subplot in grid_plot:\n", + " data = np.random.rand(512, 512)\n", + " # create and add an ImageGraphic\n", + " subplot.add_image(data=data, name=\"rand-image\")\n", + " \n", + "\n", + "# Define a function to update the image graphics \n", + "# with new randomly generated data\n", + "def set_random_frame(gp):\n", + " for subplot in gp:\n", + " new_data = np.random.rand(512, 512)\n", + " subplot[\"rand-image\"].data = new_data\n", + "\n", + "# add the animation\n", + "grid_plot.add_animations(set_random_frame)\n", + "grid_plot.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4224f1c2-5e61-4894-8d72-0519598a3cef", + "metadata": {}, + "source": [ + "Indexing the gridplot to access subplots" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d88dd9b2-9359-42e8-9dfb-96dcbbb34b95", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# can access subplot by name\n", + "grid_plot[\"subplot0\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a14df7ea-14c3-4a8a-84f2-2e2194236d9e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# can access subplot by index\n", + "grid_plot[0, 0]" + ] + }, + { + "cell_type": "markdown", + "id": "5f8a3427-7949-40a4-aec2-38d5d95ef156", + "metadata": {}, + "source": [ + "**subplots also support indexing!**\n", + "\n", + "this can be used to get graphics if they are named" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8c99fee0-ce46-4f18-8300-af025c9a967c", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# can access graphic directly via name\n", + "grid_plot[\"subplot0\"][\"rand-image\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed4eebb7-826d-4856-bbb8-db2de966a0c3", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "grid_plot[\"subplot0\"][\"rand-image\"].vmin = 0.6\n", + "grid_plot[\"subplot0\"][\"rand-image\"].vmax = 0.8" + ] + }, + { + "cell_type": "markdown", + "id": "ad322f6f-e7de-4eb3-a1d9-cf28701a2eae", + "metadata": {}, + "source": [ + "positional indexing also works event if subplots have string names" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "759d3966-d92b-460f-ba48-e57adabbf163", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "grid_plot[1, 0][\"rand-image\"].vim = 0.1\n", + "grid_plot[1, 0][\"rand-image\"].vmax = 0.3" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a5753b9-ee71-4ed1-bb0d-52bdb4ea365f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "grid_plot[1, 0][\"rand-image\"].type" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 999fbe46c..301412aff 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -1,6 +1,8 @@ from pathlib import Path from .layouts import Plot, GridPlot +from .graphics import * +from .graphics.selectors import * from wgpu.gui.auto import run diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index dae31c61b..850ef4f89 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -391,7 +391,7 @@ def graphics(self) -> np.ndarray[Graphic]: def add_graphic(self, graphic: Graphic, reset_index: False): """Add a graphic to the collection""" - if not isinstance(graphic, self.child_type): + if not type(graphic).__name__ == self.child_type: raise TypeError( f"Can only add graphics of the same type to a collection, " f"You can only add {self.child_type} to a {self.__class__.__name__}, " diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index e1cc5dd03..8e78a6260 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -3,6 +3,7 @@ from ._present import PresentFeature from ._thickness import ThicknessFeature from ._base import GraphicFeature, GraphicFeatureIndexable, FeatureEvent, to_gpu_supported_dtype +from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature __all__ = [ "ColorFeature", @@ -17,5 +18,7 @@ "GraphicFeature", "GraphicFeatureIndexable", "FeatureEvent", - "to_gpu_supported_dtype" + "to_gpu_supported_dtype", + "LinearSelectionFeature", + "LinearRegionSelectionFeature", ] diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 2860d6d4e..1d177c3f4 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -6,7 +6,7 @@ import numpy as np -from pygfx import Buffer, Texture +import pygfx supported_dtypes = [ @@ -303,7 +303,7 @@ def _update_range(self, key): @property @abstractmethod - def buffer(self) -> Union[Buffer, Texture]: + def buffer(self) -> Union[pygfx.Buffer, pygfx.Texture]: """Underlying buffer for this feature""" pass diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index 55ab13f48..feb349984 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -1,5 +1,5 @@ import numpy as np -from pygfx import Color +import pygfx from ...utils import make_colors, get_cmap_texture, make_pygfx_colors, parse_cmap_values from ._base import ( @@ -29,7 +29,7 @@ class ColorFeature(GraphicFeatureIndexable): """ @property - def buffer(self): + def buffer(self) -> pygfx.Buffer: return self._parent.world_object.geometry.colors def __getitem__(self, item): @@ -93,11 +93,11 @@ def __init__( f"where the length of the iterable is the same as the number of datapoints." ) - data = np.vstack([np.array(Color(c)) for c in colors]) + data = np.vstack([np.array(pygfx.Color(c)) for c in colors]) # if it's a single RGBA array as a tuple/list elif len(colors) == 4: - c = Color(colors) + c = pygfx.Color(colors) data = np.repeat(np.array([c]), n_colors, axis=0) else: @@ -171,7 +171,7 @@ def __setitem__(self, key, value): new_data_size = len(indices) if not isinstance(value, np.ndarray): - color = np.array(Color(value)) # pygfx color parser + color = np.array(pygfx.Color(value)) # pygfx color parser # make it of shape [n_colors_modify, 4] new_colors = np.repeat( np.array([color]).astype(np.float32), new_data_size, axis=0 diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py index 2e8e38c12..0d22299ed 100644 --- a/fastplotlib/graphics/_features/_data.py +++ b/fastplotlib/graphics/_features/_data.py @@ -2,7 +2,7 @@ import numpy as np -from pygfx import Buffer, Texture +import pygfx from ._base import ( GraphicFeatureIndexable, @@ -26,7 +26,7 @@ def __init__(self, parent, data: Any, collection_index: int = None): ) @property - def buffer(self) -> Buffer: + def buffer(self) -> pygfx.Buffer: return self._parent.world_object.geometry.positions def __getitem__(self, item): @@ -116,7 +116,7 @@ def __init__(self, parent, data: Any): super(ImageDataFeature, self).__init__(parent, data) @property - def buffer(self) -> Texture: + def buffer(self) -> pygfx.Texture: """Texture buffer for the image data""" return self._parent.world_object.geometry.grid @@ -167,7 +167,7 @@ def _feature_changed(self, key, new_data): class HeatmapDataFeature(ImageDataFeature): @property - def buffer(self) -> List[Texture]: + def buffer(self) -> List[pygfx.Texture]: """list of Texture buffer for the image data""" return [img.geometry.grid for img in self._parent.world_object.children] diff --git a/fastplotlib/graphics/_features/_present.py b/fastplotlib/graphics/_features/_present.py index 22f42f357..ba257e60b 100644 --- a/fastplotlib/graphics/_features/_present.py +++ b/fastplotlib/graphics/_features/_present.py @@ -18,7 +18,7 @@ class PresentFeature(GraphicFeature): "new_data" ``bool`` new data, ``True`` or ``False`` "collection-index" int the index of the graphic within the collection that triggered the event "world_object" pygfx.WorldObject world object - ==================== ======================== ======================================================================== + ==================== ======================== ========================================================================= """ def __init__(self, parent, present: bool = True, collection_index: int = False): diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py new file mode 100644 index 000000000..49dc78b75 --- /dev/null +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -0,0 +1,316 @@ +from typing import Tuple, Union, Any + +import numpy as np + +from ._base import GraphicFeature, FeatureEvent + + +""" +positions for indexing the BoxGeometry to set the "width" and "size" of the box +hacky, but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 +""" + +x_right = np.array( + [ + True, + True, + True, + True, + False, + False, + False, + False, + False, + True, + False, + True, + True, + False, + True, + False, + False, + True, + False, + True, + True, + False, + True, + False, + ] +) + +x_left = np.array( + [ + False, + False, + False, + False, + True, + True, + True, + True, + True, + False, + True, + False, + False, + True, + False, + True, + True, + False, + True, + False, + False, + True, + False, + True, + ] +) + +y_top = np.array( + [ + False, + True, + False, + True, + False, + True, + False, + True, + True, + True, + True, + True, + False, + False, + False, + False, + False, + False, + True, + True, + False, + False, + True, + True, + ] +) + +y_bottom = np.array( + [ + True, + False, + True, + False, + True, + False, + True, + False, + False, + False, + False, + False, + True, + True, + True, + True, + True, + True, + False, + False, + True, + True, + False, + False, + ] +) + + + +class LinearSelectionFeature(GraphicFeature): + # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system + """ + Manages the linear selection and callbacks + + **event pick info** + + =================== =============================== ================================================================================================= + key type selection + =================== =============================== ================================================================================================= + "selected_index" ``int`` the graphic data index that corresponds to the selector position + "world_object" ``pygfx.WorldObject`` pygfx WorldObject + "new_data" ``numpy.ndarray`` or ``None`` the new selector position in world coordinates, not necessarily the same as "selected_index" + "graphic" ``Graphic`` the selector graphic + "delta" ``numpy.ndarray`` the delta vector of the graphic in NDC + "pygfx_event" ``pygfx.Event`` pygfx Event + =================== =============================== ================================================================================================= + + """ + + def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]): + super(LinearSelectionFeature, self).__init__(parent, data=value) + + self.axis = axis + self.limits = limits + + def _set(self, value: float): + if not (self.limits[0] <= value <= self.limits[1]): + return + + if self.axis == "x": + self._parent.position_x = value + else: + self._parent.position_y = value + + self._data = value + self._feature_changed(key=None, new_data=value) + + def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + if len(self._event_handlers) < 1: + return + + if self._parent.parent is not None: + g_ix = self._parent.get_selected_index() + else: + g_ix = None + + # get pygfx event and reset it + pygfx_ev = self._parent._pygfx_event + self._parent._pygfx_event = None + + pick_info = { + "world_object": self._parent.world_object, + "new_data": new_data, + "selected_index": g_ix, + "graphic": self._parent, + "pygfx_event": pygfx_ev, + "delta": self._parent.delta, + } + + event_data = FeatureEvent(type="selection", pick_info=pick_info) + + self._call_event_handlers(event_data) + + +class LinearRegionSelectionFeature(GraphicFeature): + """ + Feature for a linearly bounding region + + **event pick info** + + ===================== =============================== ======================================================================================= + key type description + ===================== =============================== ======================================================================================= + "selected_indices" ``numpy.ndarray`` or ``None`` selected graphic data indices + "world_object" ``pygfx.WorldObject`` pygfx World Object + "new_data" ``(float, float)`` current bounds in world coordinates, NOT necessarily the same as "selected_indices". + "graphic" ``Graphic`` the selection graphic + "delta" ``numpy.ndarray`` the delta vector of the graphic in NDC + "pygfx_event" ``pygfx.Event`` pygfx Event + "selected_data" ``numpy.ndarray`` or ``None`` selected graphic data + "move_info" ``MoveInfo`` last position and event source (pygfx.Mesh or pygfx.Line) + ===================== =============================== ======================================================================================= + + """ + + def __init__( + self, parent, selection: Tuple[int, int], axis: str, limits: Tuple[int, int] + ): + super(LinearRegionSelectionFeature, self).__init__(parent, data=selection) + + self._axis = axis + self.limits = limits + + self._set(selection) + + @property + def axis(self) -> str: + """one of "x" | "y" """ + return self._axis + + def _set(self, value: Tuple[float, float]): + # sets new bounds + if not isinstance(value, tuple): + raise TypeError( + "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " + "where `min_bound` and `max_bound` are numeric values." + ) + + # make sure bounds not exceeded + for v in value: + if not (self.limits[0] <= v <= self.limits[1]): + return + + # make sure `selector width >= 2`, left edge must not move past right edge! + # or bottom edge must not move past top edge! + # has to be at least 2 otherwise can't join datapoints for lines + if not (value[1] - value[0]) >= 2: + return + + if self.axis == "x": + # change left x position of the fill mesh + self._parent.fill.geometry.positions.data[x_left, 0] = value[0] + + # change right x position of the fill mesh + self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + + # change x position of the left edge line + self._parent.edges[0].geometry.positions.data[:, 0] = value[0] + + # change x position of the right edge line + self._parent.edges[1].geometry.positions.data[:, 0] = value[1] + + elif self.axis == "y": + # change bottom y position of the fill mesh + self._parent.fill.geometry.positions.data[y_bottom, 1] = value[0] + + # change top position of the fill mesh + self._parent.fill.geometry.positions.data[y_top, 1] = value[1] + + # change y position of the bottom edge line + self._parent.edges[0].geometry.positions.data[:, 1] = value[0] + + # change y position of the top edge line + self._parent.edges[1].geometry.positions.data[:, 1] = value[1] + + self._data = value # (value[0], value[1]) + + # send changes to GPU + self._parent.fill.geometry.positions.update_range() + + self._parent.edges[0].geometry.positions.update_range() + self._parent.edges[1].geometry.positions.update_range() + + # calls any events + self._feature_changed(key=None, new_data=value) + + def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + if len(self._event_handlers) < 1: + return + + if self._parent.parent is not None: + selected_ixs = self._parent.get_selected_indices() + selected_data = self._parent.get_selected_data() + else: + selected_ixs = None + selected_data = None + + # get pygfx event and reset it + pygfx_ev = self._parent._pygfx_event + self._parent._pygfx_event = None + + pick_info = { + "world_object": self._parent.world_object, + "new_data": new_data, + "selected_indices": selected_ixs, + "selected_data": selected_data, + "graphic": self._parent, + "delta": self._parent.delta, + "pygfx_event": pygfx_ev, + "move_info": self._parent._move_info, + } + + event_data = FeatureEvent(type="selection", pick_info=pick_info) + + self._call_event_handlers(event_data) diff --git a/fastplotlib/graphics/_features/_thickness.py b/fastplotlib/graphics/_features/_thickness.py index b970d298e..cae3828b7 100644 --- a/fastplotlib/graphics/_features/_thickness.py +++ b/fastplotlib/graphics/_features/_thickness.py @@ -7,14 +7,14 @@ class ThicknessFeature(GraphicFeature): **event pick info:** - ===================== ======================== ========================================================================= + ==================== ======================== ========================================================================= key type description ==================== ======================== ========================================================================= "index" ``None`` not used "new_data" ``float`` new thickness value "collection-index" int the index of the graphic within the collection that triggered the event "world_object" pygfx.WorldObject world object - ==================== ======================== ======================================================================== + ==================== ======================== ========================================================================= """ def __init__(self, parent, thickness: float): diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 860a2e74a..ae2ec64d4 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -14,7 +14,7 @@ class LineCollection(GraphicCollection, Interaction): - child_type = LineGraphic + child_type = LineGraphic.__name__ feature_events = ("data", "colors", "cmap", "thickness", "present") def __init__( diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 88b0ac523..39710305d 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -12,76 +12,11 @@ except (ImportError, ModuleNotFoundError): HAS_IPYWIDGETS = False -from .._base import Graphic, GraphicFeature, GraphicCollection -from .._features import FeatureEvent +from .._base import Graphic, GraphicCollection +from .._features._selection_features import LinearSelectionFeature from ._base_selector import BaseSelector -class LinearSelectionFeature(GraphicFeature): - # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system - """ - Manages the linear selection and callbacks - - **event pick info** - - =================== =============================== ================================================================================================= - key type selection - =================== =============================== ================================================================================================= - "selected_index" ``int`` the graphic data index that corresponds to the selector position - "world_object" ``pygfx.WorldObject`` pygfx WorldObject - "new_data" ``numpy.ndarray`` or ``None`` the new selector position in world coordinates, not necessarily the same as "selected_index" - "graphic" ``Graphic`` the selector graphic - "delta" ``numpy.ndarray`` the delta vector of the graphic in NDC - "pygfx_event" ``pygfx.Event`` pygfx Event - =================== =============================== ================================================================================================= - - """ - - def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]): - super(LinearSelectionFeature, self).__init__(parent, data=value) - - self.axis = axis - self.limits = limits - - def _set(self, value: float): - if not (self.limits[0] <= value <= self.limits[1]): - return - - if self.axis == "x": - self._parent.position_x = value - else: - self._parent.position_y = value - - self._data = value - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - if len(self._event_handlers) < 1: - return - - if self._parent.parent is not None: - g_ix = self._parent.get_selected_index() - else: - g_ix = None - - # get pygfx event and reset it - pygfx_ev = self._parent._pygfx_event - self._parent._pygfx_event = None - - pick_info = { - "world_object": self._parent.world_object, - "new_data": new_data, - "selected_index": g_ix, - "graphic": self._parent, - "pygfx_event": pygfx_ev, - "delta": self._parent.delta, - } - - event_data = FeatureEvent(type="selection", pick_info=pick_info) - - self._call_event_handlers(event_data) - - class LinearSelector(Graphic, BaseSelector): # TODO: make `selection` arg in graphics data space not world space def __init__( @@ -137,10 +72,13 @@ def __init__( Features -------- - selection: :class:`LinearSelectionFeature` - ``selection()`` returns the current slider position in world coordinates - use ``selection.add_event_handler()`` to add callback functions that are - called when the LinearSelector selection changes. See feature class for event pick_info table + selection: :class:`.LinearSelectionFeature` + ``selection()`` returns the current selector position in world coordinates. + Use ``get_selected_index()`` to get the currently selected index in data + space. + Use ``selection.add_event_handler()`` to add callback functions that are + called when the LinearSelector selection changes. See feature class for + event pick_info table """ if len(limits) != 2: diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index a9a0479b4..0759cd4fc 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -4,132 +4,8 @@ import pygfx from .._base import Graphic, GraphicCollection -from .._features import GraphicFeature, FeatureEvent from ._base_selector import BaseSelector -from ._mesh_positions import x_right, x_left, y_top, y_bottom - - -class LinearRegionSelectionFeature(GraphicFeature): - """ - Feature for a linearly bounding region - - **event pick info** - - +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ - | key | type | description | - +====================+===============================+======================================================================================+ - | "selected_indices" | ``numpy.ndarray`` or ``None`` | selected graphic data indices | - | "world_object" | ``pygfx.WorldObject`` | pygfx World Object | - | "new_data" | ``(float, float)`` | current bounds in world coordinates, NOT necessarily the same as "selected_indices". | - | "graphic" | ``Graphic`` | the selection graphic | - | "delta" | ``numpy.ndarray`` | the delta vector of the graphic in NDC | - | "pygfx_event" | ``pygfx.Event`` | pygfx Event | - | "selected_data" | ``numpy.ndarray`` or ``None`` | selected graphic data | - | "move_info" | ``MoveInfo`` | last position and event source (pygfx.Mesh or pygfx.Line) | - +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ - - """ - - def __init__( - self, parent, selection: Tuple[int, int], axis: str, limits: Tuple[int, int] - ): - super(LinearRegionSelectionFeature, self).__init__(parent, data=selection) - - self._axis = axis - self.limits = limits - - self._set(selection) - - @property - def axis(self) -> str: - """one of "x" | "y" """ - return self._axis - - def _set(self, value: Tuple[float, float]): - # sets new bounds - if not isinstance(value, tuple): - raise TypeError( - "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " - "where `min_bound` and `max_bound` are numeric values." - ) - - # make sure bounds not exceeded - for v in value: - if not (self.limits[0] <= v <= self.limits[1]): - return - - # make sure `selector width >= 2`, left edge must not move past right edge! - # or bottom edge must not move past top edge! - # has to be at least 2 otherwise can't join datapoints for lines - if not (value[1] - value[0]) >= 2: - return - - if self.axis == "x": - # change left x position of the fill mesh - self._parent.fill.geometry.positions.data[x_left, 0] = value[0] - - # change right x position of the fill mesh - self._parent.fill.geometry.positions.data[x_right, 0] = value[1] - - # change x position of the left edge line - self._parent.edges[0].geometry.positions.data[:, 0] = value[0] - - # change x position of the right edge line - self._parent.edges[1].geometry.positions.data[:, 0] = value[1] - - elif self.axis == "y": - # change bottom y position of the fill mesh - self._parent.fill.geometry.positions.data[y_bottom, 1] = value[0] - - # change top position of the fill mesh - self._parent.fill.geometry.positions.data[y_top, 1] = value[1] - - # change y position of the bottom edge line - self._parent.edges[0].geometry.positions.data[:, 1] = value[0] - - # change y position of the top edge line - self._parent.edges[1].geometry.positions.data[:, 1] = value[1] - - self._data = value # (value[0], value[1]) - - # send changes to GPU - self._parent.fill.geometry.positions.update_range() - - self._parent.edges[0].geometry.positions.update_range() - self._parent.edges[1].geometry.positions.update_range() - - # calls any events - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - if len(self._event_handlers) < 1: - return - - if self._parent.parent is not None: - selected_ixs = self._parent.get_selected_indices() - selected_data = self._parent.get_selected_data() - else: - selected_ixs = None - selected_data = None - - # get pygfx event and reset it - pygfx_ev = self._parent._pygfx_event - self._parent._pygfx_event = None - - pick_info = { - "world_object": self._parent.world_object, - "new_data": new_data, - "selected_indices": selected_ixs, - "selected_data": selected_data, - "graphic": self._parent, - "delta": self._parent.delta, - "pygfx_event": pygfx_ev, - "move_info": self._parent._move_info, - } - - event_data = FeatureEvent(type="selection", pick_info=pick_info) - - self._call_event_handlers(event_data) +from .._features._selection_features import LinearRegionSelectionFeature class LinearRegionSelector(Graphic, BaseSelector): @@ -191,6 +67,18 @@ def __init__( name: str name for this selector graphic + + Features + -------- + + selection: :class:`.LinearRegionSelectionFeature` + ``selection()`` returns the current selector bounds in world coordinates. + Use ``get_selected_indices()`` to return the selected indices in data + space, and ``get_selected_data()`` to return the selected data. + Use ``selection.add_event_handler()`` to add callback functions that are + called when the LinearSelector selection changes. See feature class for + event pick_info table. + """ # lots of very close to zero values etc. so round them diff --git a/fastplotlib/graphics/selectors/_mesh_positions.py b/fastplotlib/graphics/selectors/_mesh_positions.py index e7cd5ae93..07ff60498 100644 --- a/fastplotlib/graphics/selectors/_mesh_positions.py +++ b/fastplotlib/graphics/selectors/_mesh_positions.py @@ -1,123 +1,2 @@ import numpy as np - -""" -positions for indexing the BoxGeometry to set the "width" and "size" of the box -hacky, but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 -""" - -x_right = np.array( - [ - True, - True, - True, - True, - False, - False, - False, - False, - False, - True, - False, - True, - True, - False, - True, - False, - False, - True, - False, - True, - True, - False, - True, - False, - ] -) - -x_left = np.array( - [ - False, - False, - False, - False, - True, - True, - True, - True, - True, - False, - True, - False, - False, - True, - False, - True, - True, - False, - True, - False, - False, - True, - False, - True, - ] -) - -y_top = np.array( - [ - False, - True, - False, - True, - False, - True, - False, - True, - True, - True, - True, - True, - False, - False, - False, - False, - False, - False, - True, - True, - False, - False, - True, - True, - ] -) - -y_bottom = np.array( - [ - True, - False, - True, - False, - True, - False, - True, - False, - False, - False, - False, - False, - True, - True, - True, - True, - True, - True, - False, - False, - True, - True, - False, - False, - ] -) diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py index 3e9a16aac..5983abe1b 100644 --- a/fastplotlib/layouts/_gridplot.py +++ b/fastplotlib/layouts/_gridplot.py @@ -5,6 +5,7 @@ from typing import * from inspect import getfullargspec from warnings import warn +import os import pygfx @@ -77,6 +78,7 @@ def __init__( starting size of canvas, default (500, 300) """ + self.shape = shape self.toolbar = None @@ -315,6 +317,11 @@ def show( _maintain_aspect = maintain_aspect subplot.auto_scale(maintain_aspect=_maintain_aspect, zoom=0.95) + if "NB_SNAPSHOT" in os.environ.keys(): + # used for docs + if os.environ["NB_SNAPSHOT"] == "1": + return self.canvas.snapshot() + # check if in jupyter notebook, or if toolbar is False if (self.canvas.__class__.__name__ != "JupyterWgpuCanvas") or (not toolbar): return self.canvas diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py index 52caf9cce..a8d61aa19 100644 --- a/fastplotlib/layouts/_plot.py +++ b/fastplotlib/layouts/_plot.py @@ -1,6 +1,7 @@ from typing import * from datetime import datetime import traceback +import os import pygfx from wgpu.gui.auto import WgpuCanvas, is_jupyter @@ -139,6 +140,11 @@ def show( if autoscale: self.auto_scale(maintain_aspect=maintain_aspect, zoom=0.95) + if "NB_SNAPSHOT" in os.environ.keys(): + # used for docs + if os.environ["NB_SNAPSHOT"] == "1": + return self.canvas.snapshot() + # check if in jupyter notebook, or if toolbar is False if (self.canvas.__class__.__name__ != "JupyterWgpuCanvas") or (not toolbar): return self.canvas diff --git a/setup.py b/setup.py index f06183a60..2616093fc 100644 --- a/setup.py +++ b/setup.py @@ -11,10 +11,15 @@ extras_require = { "docs": [ "sphinx", - "pydata-sphinx-theme<0.10.0", + "furo", "glfw", "jupyter-rfb>=0.4.1", # required so ImageWidget docs show up - "ipywidgets>=8.0.0,<9" + "ipywidgets>=8.0.0,<9", + "sphinx-copybutton", + "sphinx-design", + "nbsphinx", + "pandoc", + "jupyterlab" ], "notebook": From 9632759ce5c07545ddb2c0862f48e4a4ca9e1e84 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 5 Jul 2023 09:13:25 -0400 Subject: [PATCH 09/21] git lfs for readthedocs --- .readthedocs.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index fdbd87a65..594398b36 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -12,6 +12,21 @@ build: - libglfw3 - pandoc jobs: + post_checkout: + # Download and uncompress the binary + # https://git-lfs.github.com/ + - wget https://github.com/git-lfs/git-lfs/releases/download/v3.1.4/git-lfs-linux-amd64-v3.1.4.tar.gz + - tar xvfz git-lfs-linux-amd64-v3.1.4.tar.gz + # Modify LFS config paths to point where git-lfs binary was downloaded + - git config filter.lfs.process "`pwd`/git-lfs filter-process" + - git config filter.lfs.smudge "`pwd`/git-lfs smudge -- %f" + - git config filter.lfs.clean "`pwd`/git-lfs clean -- %f" + # Make LFS available in current repository + - ./git-lfs install + # Download content from remote + - ./git-lfs fetch + # Make local files to have the real content on them + - ./git-lfs checkout pre_install: - pip install git+https://github.com/pygfx/pygfx.git@main From ea77e46013729761419677b0b2f0c4699eeb025a Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 5 Jul 2023 09:44:19 -0400 Subject: [PATCH 10/21] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 976ac1777..dccd8196b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ Higher resolution demo: [https://github.com/kushalkolar/fastplotlib/assets/94033 http://fastplotlib.readthedocs.io/ -The docs are not entirely thorough, we recommend the example notebooks to get started. +The Quickstart guide is not interactive. We recommend cloning/downloading the repo and trying out the `desktop` or `notebook` examples: https://github.com/kushalkolar/fastplotlib/tree/master/examples + +If someone wants to integrate `pyodide` with `pygfx` we would be able to have live interactive examples! :smiley: Questions, ideas? Post an issue or [chat on gitter](https://gitter.im/fastplotlib/community?utm_source=share-link&utm_medium=link&utm_campaign=share-link). From 36bcc36ae8fdc3479886a94b06c3d492c4b76f6a Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 5 Jul 2023 20:13:01 -0400 Subject: [PATCH 11/21] fix heatmap tiling after linalg refactor (#266) * fix heatmap tiling after linalg refactor * I can't even do a simple fix properly * simpler nb example * change heatmap example nb --- examples/notebooks/heatmap.ipynb | 53 +++++++++----------------------- fastplotlib/graphics/image.py | 4 +-- 2 files changed, 16 insertions(+), 41 deletions(-) diff --git a/examples/notebooks/heatmap.ipynb b/examples/notebooks/heatmap.ipynb index d1c512661..82583b1df 100644 --- a/examples/notebooks/heatmap.ipynb +++ b/examples/notebooks/heatmap.ipynb @@ -20,9 +20,7 @@ "outputs": [], "source": [ "import numpy as np\n", - "import fastplotlib as fpl\n", - "\n", - "from tqdm import tqdm" + "import fastplotlib as fpl" ] }, { @@ -30,7 +28,7 @@ "id": "908f93f8-68c3-4a36-8f40-e0aab560955d", "metadata": {}, "source": [ - "## Generate some random neural-like data" + "## Generate some sine and cosine data" ] }, { @@ -42,54 +40,31 @@ }, "outputs": [], "source": [ - "def generate_traces(n_components, n_frames):\n", - " n_frames = n_frames + 50\n", - " n_components = n_components\n", - " \n", - " out = np.zeros((n_components, n_frames), dtype=np.float16)\n", - " \n", - " xs = np.arange(0, 50, 1)\n", - " # exponential decay\n", - " _lambda = 0.1\n", - " ys = np.e**-(_lambda * xs)\n", - " \n", - " for component_num in tqdm(range(n_components)):\n", - " time_step = 0\n", - " while time_step < n_frames - 50:\n", - " firing_prop = np.random.randint(0, 20)\n", - " if np.random.poisson() > firing_prop:\n", - " out[component_num, time_step:min(time_step + 50, n_frames - 1)] = ys.astype(np.float16)\n", - " time_step += 100\n", - " else:\n", - " time_step += 2\n", - " \n", - " return out[:, :n_frames - 50]" - ] - }, - { - "cell_type": "markdown", - "id": "fc1070d9-f9e9-405f-939c-a130cc5c456a", - "metadata": {}, - "source": [ - "Generate an array that is `10,000 x 30,000`, this may take a few minutes" + "xs = np.linspace(0, 50, 10_000)\n", + "\n", + "sine_data = np.sin(xs)\n", + "\n", + "cosine_data = np.cos(xs)\n", + "\n", + "data = np.vstack([(sine_data, cosine_data) for i in range(5)])" ] }, { "cell_type": "code", "execution_count": null, - "id": "8a1b83f6-c0d8-4237-abd6-b483e7d978ee", + "id": "02b072eb-2909-40c8-8739-950f07efbbc2", "metadata": { "tags": [] }, "outputs": [], "source": [ - "temporal = generate_traces(10_000, 30_000)" + "data.shape" ] }, { "cell_type": "code", "execution_count": null, - "id": "f89bd740-7397-43e7-9e66-d6cfb14de884", + "id": "84deb31b-5464-4cce-a938-694371011021", "metadata": { "tags": [] }, @@ -97,7 +72,7 @@ "source": [ "plot = fpl.Plot()\n", "\n", - "plot.add_heatmap(temporal, cmap=\"viridis\")\n", + "plot.add_heatmap(data, cmap=\"viridis\")\n", "\n", "plot.show(maintain_aspect=False)" ] @@ -105,7 +80,7 @@ { "cell_type": "code", "execution_count": null, - "id": "84deb31b-5464-4cce-a938-694371011021", + "id": "df3f8994-0f5b-4578-a36d-4cd9bf0733c0", "metadata": {}, "outputs": [], "source": [] diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 2ddf5b4cf..cb44e2ff2 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -500,8 +500,8 @@ def __init__( img.row_chunk_index = chunk[0] img.col_chunk_index = chunk[1] - img.position_x = x_pos - img.position_y = y_pos + img.world.x = x_pos + img.world.y = y_pos self.world_object.add(img) From aec67ac986c092a5b75cc4bc19dbbc7e340c76e8 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 5 Jul 2023 23:22:44 -0400 Subject: [PATCH 12/21] tweak docs, nicer now (#274) --- docs/doc_build_instructions.md | 11 ++++ docs/source/_templates/autosummary/method.rst | 5 ++ .../_templates/autosummary/property.rst | 5 ++ docs/source/api/layouts/gridplot.rst | 61 ----------------- docs/source/api/layouts/subplot.rst | 66 +++++++++++++++++++ docs/source/api/utils.rst | 6 ++ docs/source/conf.py | 1 + .../{generate_rst.py => generate_api.py} | 33 +++++++++- docs/source/index.rst | 2 + fastplotlib/layouts/_subplot.py | 3 + fastplotlib/utils/functions.py | 35 ++++++++-- 11 files changed, 161 insertions(+), 67 deletions(-) create mode 100644 docs/doc_build_instructions.md create mode 100644 docs/source/_templates/autosummary/method.rst create mode 100644 docs/source/_templates/autosummary/property.rst create mode 100644 docs/source/api/layouts/subplot.rst create mode 100644 docs/source/api/utils.rst rename docs/source/{generate_rst.py => generate_api.py} (89%) diff --git a/docs/doc_build_instructions.md b/docs/doc_build_instructions.md new file mode 100644 index 000000000..9df7587e2 --- /dev/null +++ b/docs/doc_build_instructions.md @@ -0,0 +1,11 @@ +1. The API doc files are autogenerated using `source/generate_api.py` + +``` +python source/generate_api.py +``` + +2. make the docs + +make html -j24 + + diff --git a/docs/source/_templates/autosummary/method.rst b/docs/source/_templates/autosummary/method.rst new file mode 100644 index 000000000..306d2aab5 --- /dev/null +++ b/docs/source/_templates/autosummary/method.rst @@ -0,0 +1,5 @@ +{{ name | escape | underline}} + +.. currentmodule:: {{ module }} + +.. automethod:: {{ objname }} diff --git a/docs/source/_templates/autosummary/property.rst b/docs/source/_templates/autosummary/property.rst new file mode 100644 index 000000000..c31bebe07 --- /dev/null +++ b/docs/source/_templates/autosummary/property.rst @@ -0,0 +1,5 @@ +{{ name | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoproperty:: {{ objname }} diff --git a/docs/source/api/layouts/gridplot.rst b/docs/source/api/layouts/gridplot.rst index f34d0b8d1..87a787b12 100644 --- a/docs/source/api/layouts/gridplot.rst +++ b/docs/source/api/layouts/gridplot.rst @@ -35,64 +35,3 @@ Methods GridPlot.render GridPlot.show -======= -Subplot -======= -.. currentmodule:: fastplotlib.layouts._subplot - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Subplot_api - - Subplot - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Subplot_api - - Subplot.camera - Subplot.canvas - Subplot.controller - Subplot.graphics - Subplot.name - Subplot.parent - Subplot.position - Subplot.renderer - Subplot.scene - Subplot.selectors - Subplot.viewport - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Subplot_api - - Subplot.add_animations - Subplot.add_graphic - Subplot.add_heatmap - Subplot.add_histogram - Subplot.add_image - Subplot.add_line - Subplot.add_line_collection - Subplot.add_line_stack - Subplot.add_scatter - Subplot.add_text - Subplot.auto_scale - Subplot.center_graphic - Subplot.center_scene - Subplot.center_title - Subplot.clear - Subplot.delete_graphic - Subplot.get_rect - Subplot.insert_graphic - Subplot.map_screen_to_world - Subplot.remove_animation - Subplot.remove_graphic - Subplot.render - Subplot.set_axes_visibility - Subplot.set_grid_visibility - Subplot.set_title - Subplot.set_viewport_rect - diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst new file mode 100644 index 000000000..adab809c7 --- /dev/null +++ b/docs/source/api/layouts/subplot.rst @@ -0,0 +1,66 @@ +.. _api.Subplot: + +Subplot +******* + +======= +Subplot +======= +.. currentmodule:: fastplotlib.layouts._subplot + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Subplot_api + + Subplot + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Subplot_api + + Subplot.camera + Subplot.canvas + Subplot.controller + Subplot.graphics + Subplot.name + Subplot.parent + Subplot.position + Subplot.renderer + Subplot.scene + Subplot.selectors + Subplot.viewport + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Subplot_api + + Subplot.add_animations + Subplot.add_graphic + Subplot.add_heatmap + Subplot.add_histogram + Subplot.add_image + Subplot.add_line + Subplot.add_line_collection + Subplot.add_line_stack + Subplot.add_scatter + Subplot.add_text + Subplot.auto_scale + Subplot.center_graphic + Subplot.center_scene + Subplot.center_title + Subplot.clear + Subplot.delete_graphic + Subplot.get_rect + Subplot.insert_graphic + Subplot.map_screen_to_world + Subplot.remove_animation + Subplot.remove_graphic + Subplot.render + Subplot.set_axes_visibility + Subplot.set_grid_visibility + Subplot.set_title + Subplot.set_viewport_rect + diff --git a/docs/source/api/utils.rst b/docs/source/api/utils.rst new file mode 100644 index 000000000..6222e22c6 --- /dev/null +++ b/docs/source/api/utils.rst @@ -0,0 +1,6 @@ +fastplotlib.utils +***************** + +.. currentmodule:: fastplotlib.utils +.. automodule:: fastplotlib.utils.functions + :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index d65ea7193..77bd6be62 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -44,6 +44,7 @@ autodoc_member_order = 'groupwise' autoclass_content = "both" +add_module_names = False autodoc_typehints = "description" autodoc_typehints_description_target = "documented_params" diff --git a/docs/source/generate_rst.py b/docs/source/generate_api.py similarity index 89% rename from docs/source/generate_rst.py rename to docs/source/generate_api.py index 3f9c7ac83..05e8b0f1c 100644 --- a/docs/source/generate_rst.py +++ b/docs/source/generate_api.py @@ -8,6 +8,8 @@ from fastplotlib import graphics from fastplotlib.graphics import _features, selectors from fastplotlib import widgets +from fastplotlib import utils + current_dir = Path(__file__).parent.resolve() @@ -107,6 +109,20 @@ def generate_class( return out +def generate_functions_module(module, name: str): + underline = "*" * len(name) + out = ( + f"{name}\n" + f"{underline}\n" + f"\n" + f".. currentmodule:: {name}\n" + f".. automodule:: {module.__name__}\n" + f" :members:\n" + ) + + return out + + def generate_page( page_name: str, modules: List[str], @@ -138,11 +154,18 @@ def main(): generate_page( page_name="GridPlot", - classes=[fastplotlib.GridPlot, Subplot], - modules=["fastplotlib", "fastplotlib.layouts._subplot"], + classes=[fastplotlib.GridPlot], + modules=["fastplotlib"], source_path=LAYOUTS_DIR.joinpath("gridplot.rst") ) + generate_page( + page_name="Subplot", + classes=[Subplot], + modules=["fastplotlib.layouts._subplot"], + source_path=LAYOUTS_DIR.joinpath("subplot.rst") + ) + # the rest of this is a mess and can be refactored later graphic_classes = [ @@ -260,6 +283,12 @@ def main(): modules=["fastplotlib"], source_path=WIDGETS_DIR.joinpath(f"{widget_cls.__name__}.rst") ) + ############################################################################## + + utils_str = generate_functions_module(utils.functions, "fastplotlib.utils") + + with open(API_DIR.joinpath("utils.rst"), "w") as f: + f.write(utils_str) if __name__ == "__main__": diff --git a/docs/source/index.rst b/docs/source/index.rst index 99f342fb9..7e1d3865a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -18,10 +18,12 @@ Welcome to fastplotlib's documentation! Plot Gridplot + Subplot Graphics Graphic Features Selectors Widgets + Utils Summary ======= diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 1ed52bc7c..c38510acf 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -43,6 +43,9 @@ def __init__( General plot object that composes a ``Gridplot``. Each ``Gridplot`` instance will have [n rows, n columns] of subplots. + .. important:: + ``Subplot`` is not meant to be constructed directly, it only exists as part of a ``GridPlot`` + Parameters ---------- position: int tuple, optional diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index f4d6ac4f1..3013559d5 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -149,9 +149,19 @@ def make_colors_dict(labels: iter, cmap: str, **kwargs) -> OrderedDict: def quick_min_max(data: np.ndarray) -> Tuple[float, float]: - # adapted from pyqtgraph.ImageView - # Estimate the min/max values of *data* by subsampling. - # Returns [(min, max), ...] with one item per channel + """ + Adapted from pyqtgraph.ImageView. + Estimate the min/max values of *data* by subsampling. + + Parameters + ---------- + data: np.ndarray or array-like with `min` and `max` attributes + + Returns + ------- + (float, float) + (min, max) + """ if hasattr(data, "min") and hasattr(data, "max"): # if value is pre-computed @@ -170,9 +180,26 @@ def quick_min_max(data: np.ndarray) -> Tuple[float, float]: def make_pygfx_colors(colors, n_colors): - """parse and make colors array using pyfx.Color""" + """ + Parse and make colors array using pyfx.Color + + Parameters + ---------- + colors: str, list, tuple, or np.ndarray + pygfx parseable color + + n_colors: int + number of repeats of the color + + Returns + ------- + np.ndarray + shape is [n_colors, 4], i.e. [n_colors, RGBA] + """ + c = Color(colors) colors_array = np.repeat(np.array([c]), n_colors, axis=0) + return colors_array From 1729a0d8d94a3a54029ad814d4c9774e263ce2e5 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Thu, 6 Jul 2023 01:50:59 -0400 Subject: [PATCH 13/21] clean, docs, docs CI test etc. (#275) * cleanup and docstrings for layouts * cleanup and docstrings for graphics, hide HistogramGraphic from imports * update add_graphic mixins * update feature docstrings * docstring for base selector * image widget docs and add func as property to WindowFunctions * more doc stuff, zero doc build warnings now :D * add test docs build to CI, update to latest pygfx commit * add pandoc to workflow CI * add newer pandoc to CI * newer pandocs in .readthedocs.yaml * fix ci yaml * more yaml fixes --- .github/workflows/ci.yml | 33 ++++++++++- .readthedocs.yaml | 4 +- docs/source/api/graphics/HistogramGraphic.rst | 36 ------------ docs/source/api/graphics/index.rst | 1 - docs/source/api/layouts/gridplot.rst | 2 + docs/source/api/layouts/plot.rst | 2 +- docs/source/api/layouts/subplot.rst | 2 +- fastplotlib/graphics/__init__.py | 2 - fastplotlib/graphics/_base.py | 39 +++++++++++-- fastplotlib/graphics/_features/_base.py | 26 +++++++-- .../graphics/_features/_selection_features.py | 1 - fastplotlib/graphics/image.py | 11 ++-- fastplotlib/graphics/line.py | 8 ++- fastplotlib/graphics/line_collection.py | 16 +++--- fastplotlib/graphics/scatter.py | 9 ++- .../graphics/selectors/_base_selector.py | 3 + fastplotlib/layouts/_base.py | 6 +- fastplotlib/layouts/_gridplot.py | 21 +++++-- fastplotlib/layouts/_plot.py | 1 + fastplotlib/layouts/_subplot.py | 47 +++++++++------ fastplotlib/layouts/graphic_methods_mixin.py | 57 ++++++------------- fastplotlib/utils/generate_add_methods.py | 23 ++++---- fastplotlib/widgets/image.py | 33 ++++++++--- 23 files changed, 228 insertions(+), 155 deletions(-) delete mode 100644 docs/source/api/graphics/HistogramGraphic.rst diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 582d02fe3..34284f8bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,37 @@ on: jobs: + docs-build: + name: Docs + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.9 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: Install llvmpipe and lavapipe for offscreen canvas, and git lfs + run: | + sudo apt-get update -y -qq + sudo apt-get install --no-install-recommends -y libegl1-mesa libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers git-lfs + - name: Install pandoc v3.14, nbsphinx complains about older pandoc versions + run: | + wget https://github.com/jgm/pandoc/releases/download/3.1.4/pandoc-3.1.4-1-amd64.deb + sudo apt-get install ./pandoc-3.1.4-1-amd64.deb + - name: Install dev dependencies + run: | + python -m pip install --upgrade pip + # remove pygfx from requirements, we install a specific commit of pygfx since both fpl and pygfx are fast evolving + sed -i "/pygfx/d" ./setup.py + pip install git+https://github.com/pygfx/pygfx.git@cf1da6c32223ba3cf7256982ce9b89c81b593076 + pip install -e ".[notebook,docs,tests]" + - name: Build docs + run: | + cd docs + make html SPHINXOPTS="-W --keep-going" + test-build: name: Test examples runs-on: ubuntu-latest @@ -47,7 +78,7 @@ jobs: python -m pip install --upgrade pip # remove pygfx from requirements, we install a specific commit of pygfx since both fpl and pygfx are fast evolving sed -i "/pygfx/d" ./setup.py - pip install git+https://github.com/pygfx/pygfx.git@b63f22a1aa61993c32cd96895316cb8248a81e4d + pip install git+https://github.com/pygfx/pygfx.git@cf1da6c32223ba3cf7256982ce9b89c81b593076 pip install -e ".["tests"]" - name: Show wgpu backend run: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 594398b36..93c816bd7 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,7 +10,6 @@ build: - libxcb-xfixes0-dev - mesa-vulkan-drivers - libglfw3 - - pandoc jobs: post_checkout: # Download and uncompress the binary @@ -27,6 +26,9 @@ build: - ./git-lfs fetch # Make local files to have the real content on them - ./git-lfs checkout + # install newer pandoc else nbsphinx complains + - wget https://github.com/jgm/pandoc/releases/download/3.1.4/pandoc-3.1.4-1-amd64.deb + - sudo apt-get install ./pandoc-3.1.4-1-amd64.deb pre_install: - pip install git+https://github.com/pygfx/pygfx.git@main diff --git a/docs/source/api/graphics/HistogramGraphic.rst b/docs/source/api/graphics/HistogramGraphic.rst deleted file mode 100644 index 9174092f5..000000000 --- a/docs/source/api/graphics/HistogramGraphic.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. _api.HistogramGraphic: - -HistogramGraphic -**************** - -================ -HistogramGraphic -================ -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HistogramGraphic_api - - HistogramGraphic - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HistogramGraphic_api - - HistogramGraphic.children - HistogramGraphic.position - HistogramGraphic.position_x - HistogramGraphic.position_y - HistogramGraphic.position_z - HistogramGraphic.visible - HistogramGraphic.world_object - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HistogramGraphic_api - - diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index fbfa5f6f3..611ee5833 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -7,7 +7,6 @@ Graphics ImageGraphic ScatterGraphic LineGraphic - HistogramGraphic HeatmapGraphic LineCollection LineStack diff --git a/docs/source/api/layouts/gridplot.rst b/docs/source/api/layouts/gridplot.rst index 87a787b12..63f1516cf 100644 --- a/docs/source/api/layouts/gridplot.rst +++ b/docs/source/api/layouts/gridplot.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: GridPlot_api + GridPlot.canvas + GridPlot.renderer Methods ~~~~~~~ diff --git a/docs/source/api/layouts/plot.rst b/docs/source/api/layouts/plot.rst index d722bf3c3..a0be9287b 100644 --- a/docs/source/api/layouts/plot.rst +++ b/docs/source/api/layouts/plot.rst @@ -23,6 +23,7 @@ Properties Plot.camera Plot.canvas Plot.controller + Plot.docks Plot.graphics Plot.name Plot.parent @@ -40,7 +41,6 @@ Methods Plot.add_animations Plot.add_graphic Plot.add_heatmap - Plot.add_histogram Plot.add_image Plot.add_line Plot.add_line_collection diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index adab809c7..c61c46e05 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -23,6 +23,7 @@ Properties Subplot.camera Subplot.canvas Subplot.controller + Subplot.docks Subplot.graphics Subplot.name Subplot.parent @@ -40,7 +41,6 @@ Methods Subplot.add_animations Subplot.add_graphic Subplot.add_heatmap - Subplot.add_histogram Subplot.add_image Subplot.add_line Subplot.add_line_collection diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 91ba4722e..2a008015e 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,4 +1,3 @@ -from .histogram import HistogramGraphic from .line import LineGraphic from .scatter import ScatterGraphic from .image import ImageGraphic, HeatmapGraphic @@ -9,7 +8,6 @@ "ImageGraphic", "ScatterGraphic", "LineGraphic", - "HistogramGraphic", "HeatmapGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 850ef4f89..d30f7175f 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -75,6 +75,7 @@ def __init__( @property def world_object(self) -> WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" + # We use weakref to simplify garbage collection return weakref.proxy(WORLD_OBJECTS[hex(id(self))]) def _set_world_object(self, wo: WorldObject): @@ -82,20 +83,22 @@ def _set_world_object(self, wo: WorldObject): @property def position(self) -> np.ndarray: - """The position of the graphic. You can access or change - using position.x, position.y, etc.""" + """position of the graphic, [x, y, z]""" return self.world_object.world.position @property def position_x(self) -> float: + """x-axis position of the graphic""" return self.world_object.world.x @property def position_y(self) -> float: + """y-axis position of the graphic""" return self.world_object.world.y @property def position_z(self) -> float: + """z-axis position of the graphic""" return self.world_object.world.z @position.setter @@ -390,7 +393,19 @@ def graphics(self) -> np.ndarray[Graphic]: return self._graphics_array def add_graphic(self, graphic: Graphic, reset_index: False): - """Add a graphic to the collection""" + """ + Add a graphic to the collection. + + Parameters + ---------- + graphic: Graphic + graphic to add, must be a real ``Graphic`` not a proxy + + reset_index: bool, default ``False`` + reset the collection index + + """ + if not type(graphic).__name__ == self.child_type: raise TypeError( f"Can only add graphics of the same type to a collection, " @@ -413,7 +428,19 @@ def add_graphic(self, graphic: Graphic, reset_index: False): self._graphics_changed = True def remove_graphic(self, graphic: Graphic, reset_index: True): - """Remove a graphic from the collection""" + """ + Remove a graphic from the collection. + + Parameters + ---------- + graphic: Graphic + graphic to remove + + reset_index: bool, default ``False`` + reset the collection index + + """ + self._graphics.remove(graphic.loc) if reset_index: @@ -486,8 +513,8 @@ def __init__( setattr(self, attr_name, collection_feature) @property - def graphics(self) -> Tuple[Graphic]: - """Returns a tuple of the selected graphics.""" + def graphics(self) -> np.ndarray[Graphic]: + """Returns an array of the selected graphics. Always returns a proxy to the Graphic""" return tuple(self._selection) def __setattr__(self, key, value): diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 1d177c3f4..5616eec19 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -49,7 +49,7 @@ class FeatureEvent: ---------- type: str, example "colors" - pick_info: dict in the form: + pick_info: dict: ============== ============================================================================= key value @@ -59,6 +59,9 @@ class FeatureEvent: "new_data: the new data for this feature ============== ============================================================================= + .. note:: + pick info varies between features, this is just the general structure + """ def __init__(self, type: str, pick_info: dict): @@ -97,8 +100,17 @@ def __init__(self, parent, data: Any, collection_index: int = None): def __call__(self, *args, **kwargs): return self._data - def block_events(self, b: bool): - self._block_events = b + def block_events(self, val: bool): + """ + Block all events from this feature + + Parameters + ---------- + val: bool + ``True`` or ``False`` + + """ + self._block_events = val @abstractmethod def _set(self, value): @@ -113,9 +125,10 @@ def _parse_set_value(self, value): 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 ``handler`` can optionally accept a :class:`.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 "", such as "color". + as a ``str`` in the form of "", such as "color". And ``pick_info`` which contains + information about the event and Graphic that triggered it. Parameters ---------- @@ -134,7 +147,7 @@ def add_event_handler(self, handler: callable): def remove_event_handler(self, handler: callable): """ - Remove a registered event handler + Remove a registered event ``handler``. Parameters ---------- @@ -148,6 +161,7 @@ def remove_event_handler(self, handler: callable): self._event_handlers.remove(handler) def clear_event_handlers(self): + """Clear all event handlers""" self._event_handlers.clear() # TODO: maybe this can be implemented right here in the base class diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 49dc78b75..ae486026e 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -127,7 +127,6 @@ ) - class LinearSelectionFeature(GraphicFeature): # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system """ diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index cb44e2ff2..cb2c4eaf7 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -19,12 +19,12 @@ ) -class _ImageHeatmapSelectorsMixin: +class _AddSelectorsMixin: def add_linear_selector( self, selection: int = None, padding: float = None, **kwargs ) -> LinearSelector: """ - Adds a linear selector. + Adds a :class:`.LinearSelector`. Parameters ---------- @@ -83,8 +83,7 @@ def add_linear_region_selector( self, padding: float = None, **kwargs ) -> LinearRegionSelector: """ - Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, - remove, or delete them from a plot area just like any other ``Graphic``. + Add a :class:`.LinearRegionSelector`. Parameters ---------- @@ -196,7 +195,7 @@ def _add_plot_area_hook(self, plot_area): self._plot_area = plot_area -class ImageGraphic(Graphic, Interaction, _ImageHeatmapSelectorsMixin): +class ImageGraphic(Graphic, Interaction, _AddSelectorsMixin): feature_events = ("data", "cmap", "present") def __init__( @@ -359,7 +358,7 @@ def col_chunk_index(self, index: int): self._col_chunk_index = index -class HeatmapGraphic(Graphic, Interaction, _ImageHeatmapSelectorsMixin): +class HeatmapGraphic(Graphic, Interaction, _AddSelectorsMixin): feature_events = ( "data", "cmap", diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 9793e8cc1..aeeeea3b0 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -66,11 +66,15 @@ def __init__( **data**: :class:`.ImageDataFeature` Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. - ex: ``scatter.data[:, 0] = 5```, ``scatter.data[xs > 5] = 3`` **colors**: :class:`.ColorFeature` Manages the color buffer, allows regular and fancy indexing. - ex: ``scatter.data[:, 1] = 0.5``, ``scatter.colors[xs > 5] = "cyan"`` + + **cmap**: :class:`.CmapFeature` + Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. + + **thickness**: :class:`.ThicknessFeature` + Manages the thickness feature of the lines. **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene, set to ``True`` or ``False`` diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index ae2ec64d4..a686a239e 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -32,7 +32,7 @@ def __init__( **kwargs, ): """ - Create a Line Collection + Create a collection of :class:`.LineGraphic` Parameters ---------- @@ -58,7 +58,9 @@ def __init__( cmap: list of str or str, optional | if ``str``, single cmap will be used for all lines | if ``list`` of ``str``, each cmap will apply to the individual lines - **Note:** ``cmap`` overrides any arguments passed to ``colors`` + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` cmap_values: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap @@ -303,8 +305,6 @@ def add_linear_selector( ) -> LinearSelector: """ Adds a :class:`.LinearSelector` . - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like - any other ``Graphic``. Parameters ---------- @@ -358,8 +358,6 @@ def add_linear_region_selector( ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector` - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like - any other ``Graphic``. Parameters ---------- @@ -545,7 +543,7 @@ def __init__( **kwargs, ): """ - Create a line stack + Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. Parameters ---------- @@ -570,7 +568,9 @@ def __init__( cmap: list of str or str, optional | if ``str``, single cmap will be used for all lines | if ``list`` of ``str``, each cmap will apply to the individual lines - **Note:** ``cmap`` overrides any arguments passed to ``colors`` + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` name: str, optional name of the line stack diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 0d0eeada1..9e162c57a 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -9,6 +9,8 @@ class ScatterGraphic(Graphic): + feature_events = ("data", "colors", "cmap", "present") + def __init__( self, data: np.ndarray, @@ -59,12 +61,13 @@ def __init__( -------- **data**: :class:`.ImageDataFeature` - Manages the scatter [x, y, z] positions data buffer, allows regular and fancy indexing. - ex: ``scatter.data[:, 0] = 5```, ``scatter.data[xs > 5] = 3`` + Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. **colors**: :class:`.ColorFeature` Manages the color buffer, allows regular and fancy indexing. - ex: ``scatter.data[:, 1] = 0.5``, ``scatter.colors[xs > 5] = "cyan"`` + + **cmap**: :class:`.CmapFeature` + Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene, set to ``True`` or ``False`` diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index 8b9923b88..a4159c194 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -85,12 +85,15 @@ def __init__( self._edge_hovered: bool = False def get_selected_index(self): + """Not implemented for this selector""" raise NotImplementedError def get_selected_indices(self): + """Not implemented for this selector""" raise NotImplementedError def get_selected_data(self): + """Not implemented for this selector""" raise NotImplementedError def _get_source(self, graphic): diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index 399acc65d..5d9d4a6e9 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -117,12 +117,12 @@ def parent(self): @property def position(self) -> Union[Tuple[int, int], Any]: - """Used by subclass to manage its own referencing system""" + """Position of this plot area within a larger layout (such as GridPlot) if relevant""" return self._position @property def scene(self) -> Scene: - """The Scene where Graphics live""" + """The Scene where Graphics lie in this plot area""" return self._scene @property @@ -137,6 +137,7 @@ def renderer(self) -> WgpuRenderer: @property def viewport(self) -> Viewport: + """The rectangular area of the renderer associated to this plot area""" return self._viewport @property @@ -172,6 +173,7 @@ def selectors(self) -> Tuple[BaseSelector, ...]: @property def name(self) -> Any: + """The name of this plot area""" return self._name @name.setter diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py index 5983abe1b..b339e8659 100644 --- a/fastplotlib/layouts/_gridplot.py +++ b/fastplotlib/layouts/_gridplot.py @@ -54,16 +54,16 @@ def __init__( cameras: np.ndarray or str, optional | One of ``"2d"`` or ``"3d"`` indicating 2D or 3D cameras for all subplots - | OR - - | Array of ``2d`` and/or ``3d`` that specifies the camera type for each subplot: + | Array of ``2d`` and/or ``3d`` that specifies the camera type for each subplot controllers: np.ndarray or str, optional | If `None` a unique controller is created for each subplot | If "sync" all the subplots use the same controller | If ``numpy.array``, its shape must be the same as ``grid_shape``. + This allows custom assignment of controllers + | Example: | unique controllers for a 2x2 gridplot: np.array([[0, 1], [2, 3]]) | same controllers for first 2 plots and last 2 plots: np.array([[0, 0, 1], [2, 3, 3]]) @@ -153,8 +153,8 @@ def __init__( else: self.names = None - self.canvas = canvas - self.renderer = renderer + self._canvas = canvas + self._renderer = renderer nrows, ncols = self.shape @@ -173,6 +173,7 @@ def __init__( name = None self._subplots[i, j] = Subplot( + parent=self, position=position, parent_dims=(nrows, ncols), camera=camera, @@ -191,6 +192,16 @@ def __init__( RecordMixin.__init__(self) + @property + def canvas(self) -> WgpuCanvas: + """The canvas associated to this GridPlot""" + return self._canvas + + @property + def renderer(self) -> pygfx.WgpuRenderer: + """The renderer associated to this GridPlot""" + return self._renderer + def __getitem__(self, index: Union[Tuple[int, int], str]) -> Subplot: if isinstance(index, str): for subplot in self._subplots.ravel(): diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py index a8d61aa19..2b5cc51b7 100644 --- a/fastplotlib/layouts/_plot.py +++ b/fastplotlib/layouts/_plot.py @@ -87,6 +87,7 @@ def __init__( """ super(Plot, self).__init__( + parent=None, position=(0, 0), parent_dims=(1, 1), canvas=canvas, diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index c38510acf..a8cd4852b 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -1,7 +1,5 @@ from typing import * -from functools import partial -import weakref -from inspect import signature, getfullargspec +from inspect import getfullargspec from warnings import warn import numpy as np @@ -18,8 +16,6 @@ ) from wgpu.gui.auto import WgpuCanvas -from ._utils import make_canvas_and_renderer -from ._base import PlotArea from ..graphics import TextGraphic from ._utils import make_canvas_and_renderer from ._base import PlotArea @@ -30,6 +26,7 @@ class Subplot(PlotArea, GraphicMethodsMixin): def __init__( self, + parent: Any = None, position: Tuple[int, int] = None, parent_dims: Tuple[int, int] = None, camera: str = "2d", @@ -90,7 +87,7 @@ def __init__( if controller is None: controller = create_controller(camera) - self.docked_viewports = dict() + self._docks = dict() self.spacing = 2 @@ -104,7 +101,7 @@ def __init__( self._animate_funcs_post = list() super(Subplot, self).__init__( - parent=None, + parent=parent, position=position, camera=create_camera(camera), controller=controller, @@ -115,9 +112,9 @@ def __init__( ) for pos in ["left", "top", "right", "bottom"]: - dv = _DockedViewport(self, pos, size=0) + dv = Dock(self, pos, size=0) dv.name = pos - self.docked_viewports[pos] = dv + self.docks[pos] = dv self.children.append(dv) self._title_graphic: TextGraphic = None @@ -133,8 +130,23 @@ def name(self, name: Any): self._name = name self.set_title(name) + @property + def docks(self) -> dict: + """ + The docks of this plot area. Each ``dock`` is basically just a PlotArea too. + + The docks are: ["left", "top", "right", "bottom"] + + Returns + ------- + Dict[str, Dock] + {dock_name: Dock} + + """ + return self._docks + def set_title(self, text: Any): - """Sets the name of a subplot to 'top' viewport if defined.""" + """Sets the plot title, stored as a ``TextGraphic`` in the "top" dock area""" if text is None: return @@ -145,8 +157,8 @@ def set_title(self, text: Any): tg = TextGraphic(text) self._title_graphic = tg - self.docked_viewports["top"].size = 35 - self.docked_viewports["top"].add_graphic(tg) + self.docks["top"].size = 35 + self.docks["top"].add_graphic(tg) self.center_title() @@ -156,7 +168,7 @@ def center_title(self): raise AttributeError("No title graphic is set") self._title_graphic.world_object.position = (0, 0, 0) - self.docked_viewports["top"].center_graphic(self._title_graphic, zoom=1.5) + self.docks["top"].center_graphic(self._title_graphic, zoom=1.5) self._title_graphic.world_object.position_y = -3.5 def get_rect(self): @@ -175,7 +187,7 @@ def get_rect(self): rect = np.array([x_pos, y_pos, width_subplot, height_subplot]) - for dv in self.docked_viewports.values(): + for dv in self.docks.values(): rect = rect + dv.get_parent_rect_adjust() return rect @@ -276,7 +288,7 @@ def set_grid_visibility(self, visible: bool): self.scene.remove(self._grid) -class _DockedViewport(PlotArea): +class Dock(PlotArea): _valid_positions = ["right", "left", "top", "bottom"] def __init__( @@ -292,7 +304,7 @@ def __init__( self._size = size - super(_DockedViewport, self).__init__( + super(Dock, self).__init__( parent=parent, position=position, camera=OrthographicCamera(), @@ -304,6 +316,7 @@ def __init__( @property def size(self) -> int: + """Get or set the size of this dock""" return self._size @size.setter @@ -426,4 +439,4 @@ def render(self): if self.size == 0: return - super(_DockedViewport, self).render() + super(Dock, self).render() diff --git a/fastplotlib/layouts/graphic_methods_mixin.py b/fastplotlib/layouts/graphic_methods_mixin.py index ab697637b..592329b4e 100644 --- a/fastplotlib/layouts/graphic_methods_mixin.py +++ b/fastplotlib/layouts/graphic_methods_mixin.py @@ -99,36 +99,6 @@ def add_heatmap(self, data: Any, vmin: int = None, vmax: int = None, cmap: str = """ return self._create_graphic(HeatmapGraphic, data, vmin, vmax, cmap, filter, chunk_size, isolated_buffer, *args, **kwargs) - def add_histogram(self, data: numpy.ndarray = None, bins: Union[int, str] = 'auto', pre_computed: Dict[str, numpy.ndarray] = None, colors: numpy.ndarray = 'w', draw_scale_factor: float = 100.0, draw_bin_width_scale: float = 1.0, **kwargs) -> HistogramGraphic: - """ - - Create a Histogram Graphic - - Parameters - ---------- - data: np.ndarray or None, optional - data to create a histogram from, can be ``None`` if pre-computed values are provided to ``pre_computed`` - - bins: int or str, default is "auto", optional - this is directly just passed to ``numpy.histogram`` - - pre_computed: dict in the form {"hist": vals, "bin_edges" : vals}, optional - pre-computed histogram values - - colors: np.ndarray, optional - - draw_scale_factor: float, default ``100.0``, optional - scale the drawing of the entire Graphic - - draw_bin_width_scale: float, default ``1.0`` - scale the drawing of the bin widths - - kwargs - passed to Graphic - - """ - return self._create_graphic(HistogramGraphic, data, bins, pre_computed, colors, draw_scale_factor, draw_bin_width_scale, *args, **kwargs) - def add_image(self, data: Any, vmin: int = None, vmax: int = None, cmap: str = 'plasma', filter: str = 'nearest', isolated_buffer: bool = True, *args, **kwargs) -> ImageGraphic: """ @@ -197,7 +167,7 @@ def add_image(self, data: Any, vmin: int = None, vmax: int = None, cmap: str = ' def add_line_collection(self, data: List[numpy.ndarray], z_position: Union[List[float], float] = None, thickness: Union[float, List[float]] = 2.0, colors: Union[List[numpy.ndarray], numpy.ndarray] = 'w', alpha: float = 1.0, cmap: Union[List[str], str] = None, cmap_values: Union[numpy.ndarray, List] = None, name: str = None, metadata: Union[list, tuple, numpy.ndarray] = None, *args, **kwargs) -> LineCollection: """ - Create a Line Collection + Create a collection of :class:`.LineGraphic` Parameters ---------- @@ -223,7 +193,9 @@ def add_line_collection(self, data: List[numpy.ndarray], z_position: Union[List[ cmap: list of str or str, optional | if ``str``, single cmap will be used for all lines | if ``list`` of ``str``, each cmap will apply to the individual lines - **Note:** ``cmap`` overrides any arguments passed to ``colors`` + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` cmap_values: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap @@ -352,11 +324,15 @@ def add_line(self, data: Any, thickness: float = 2.0, colors: Union[str, numpy.n **data**: :class:`.ImageDataFeature` Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. - ex: ``scatter.data[:, 0] = 5```, ``scatter.data[xs > 5] = 3`` **colors**: :class:`.ColorFeature` Manages the color buffer, allows regular and fancy indexing. - ex: ``scatter.data[:, 1] = 0.5``, ``scatter.colors[xs > 5] = "cyan"`` + + **cmap**: :class:`.CmapFeature` + Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. + + **thickness**: :class:`.ThicknessFeature` + Manages the thickness feature of the lines. **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene, set to ``True`` or ``False`` @@ -368,7 +344,7 @@ def add_line(self, data: Any, thickness: float = 2.0, colors: Union[str, numpy.n def add_line_stack(self, data: List[numpy.ndarray], z_position: Union[List[float], float] = None, thickness: Union[float, List[float]] = 2.0, colors: Union[List[numpy.ndarray], numpy.ndarray] = 'w', cmap: Union[List[str], str] = None, separation: float = 10, separation_axis: str = 'y', name: str = None, *args, **kwargs) -> LineStack: """ - Create a line stack + Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. Parameters ---------- @@ -393,7 +369,9 @@ def add_line_stack(self, data: List[numpy.ndarray], z_position: Union[List[float cmap: list of str or str, optional | if ``str``, single cmap will be used for all lines | if ``list`` of ``str``, each cmap will apply to the individual lines - **Note:** ``cmap`` overrides any arguments passed to ``colors`` + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` name: str, optional name of the line stack @@ -513,12 +491,13 @@ def add_scatter(self, data: numpy.ndarray, sizes: Union[int, numpy.ndarray, list -------- **data**: :class:`.ImageDataFeature` - Manages the scatter [x, y, z] positions data buffer, allows regular and fancy indexing. - ex: ``scatter.data[:, 0] = 5```, ``scatter.data[xs > 5] = 3`` + Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. **colors**: :class:`.ColorFeature` Manages the color buffer, allows regular and fancy indexing. - ex: ``scatter.data[:, 1] = 0.5``, ``scatter.colors[xs > 5] = "cyan"`` + + **cmap**: :class:`.CmapFeature` + Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene, set to ``True`` or ``False`` diff --git a/fastplotlib/utils/generate_add_methods.py b/fastplotlib/utils/generate_add_methods.py index e3993fff2..3fe16260c 100644 --- a/fastplotlib/utils/generate_add_methods.py +++ b/fastplotlib/utils/generate_add_methods.py @@ -1,21 +1,28 @@ import inspect -import sys import pathlib -from fastplotlib.graphics import * +# if there is an existing mixin class, replace it with an empty class +# so that fastplotlib will import +# hacky but it works +current_module = pathlib.Path(__file__).parent.parent.resolve() +with open(current_module.joinpath('layouts/graphic_methods_mixin.py'), 'w') as f: + f.write( + f"class GraphicMethodsMixin:\n" + f" pass" + ) + +from fastplotlib import graphics modules = list() -for name, obj in inspect.getmembers(sys.modules[__name__]): +for name, obj in inspect.getmembers(graphics): if inspect.isclass(obj): modules.append(obj) + def generate_add_graphics_methods(): # clear file and regenerate from scratch - current_module = pathlib.Path(__file__).parent.parent.resolve() - - open(current_module.joinpath('layouts/graphic_methods_mixin.py'), 'w').close() f = open(current_module.joinpath('layouts/graphic_methods_mixin.py'), 'w') @@ -43,7 +50,6 @@ def generate_add_graphics_methods(): f.write(" # only return a proxy to the real graphic\n") f.write(" return weakref.proxy(graphic)\n\n") - for m in modules: class_name = m method_name = class_name.type @@ -62,9 +68,6 @@ def generate_add_graphics_methods(): f.close() - return - if __name__ == '__main__': generate_add_graphics_methods() - diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index dcc5dafcc..962a94151 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -44,14 +44,26 @@ def _is_arraylike(obj) -> bool: class _WindowFunctions: + """Stores window function and window size""" def __init__(self, func: callable, window_size: int): + self._func = None self.func = func self._window_size = 0 self.window_size = window_size + @property + def func(self) -> callable: + """Get or set the function""" + return self._func + + @func.setter + def func(self, func: callable): + self._func = func + @property def window_size(self) -> int: + """Get or set window size""" return self._window_size @window_size.setter @@ -126,15 +138,11 @@ def slider_dims(self) -> List[str]: @property def current_index(self) -> Dict[str, int]: - return self._current_index - - @current_index.setter - def current_index(self, index: Dict[str, int]): """ - Set the current index + Get or set the current index - Parameters - ---------- + Returns + ------- index: Dict[str, int] | ``dict`` for indexing each dimension, provide a ``dict`` with indices for all dimensions used by sliders or only a subset of dimensions used by the sliders. @@ -143,7 +151,10 @@ def current_index(self, index: Dict[str, int]): dimension "z" simultaneously. """ + return self._current_index + @current_index.setter + def current_index(self, index: Dict[str, int]): if not set(index.keys()).issubset(set(self._current_index.keys())): raise KeyError( f"All dimension keys for setting `current_index` must be present in the widget sliders. " @@ -570,6 +581,14 @@ def __init__( @property def window_funcs(self) -> Dict[str, _WindowFunctions]: + """ + Get or set the window functions + + Returns + ------- + Dict[str, _WindowFunctions] + + """ return self._window_funcs @window_funcs.setter From c7265d352258bb0bb18df2ee0b08804c3216f5fd Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Thu, 6 Jul 2023 02:25:45 -0400 Subject: [PATCH 14/21] Fix pandoc rtd (#276) * try removing sudo * pandoc annoying issues --- .readthedocs.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 93c816bd7..ce6b214e4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,6 +10,7 @@ build: - libxcb-xfixes0-dev - mesa-vulkan-drivers - libglfw3 + - pandoc # installs older version of pandoc which nbsphinx complains about, but works for now jobs: post_checkout: # Download and uncompress the binary @@ -26,9 +27,6 @@ build: - ./git-lfs fetch # Make local files to have the real content on them - ./git-lfs checkout - # install newer pandoc else nbsphinx complains - - wget https://github.com/jgm/pandoc/releases/download/3.1.4/pandoc-3.1.4-1-amd64.deb - - sudo apt-get install ./pandoc-3.1.4-1-amd64.deb pre_install: - pip install git+https://github.com/pygfx/pygfx.git@main From 1f69a603687df029bc2f91d248e7b652c9c3710f Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Thu, 6 Jul 2023 05:55:57 -0400 Subject: [PATCH 15/21] notebook screenshot tests (#277) * started nb screenshot tests, add reset_vmin_vmax() on ImageCmapFeature * nb test screenshots to CI * add simple.ipynb test screenshots * imageio note in simple.ipynb * try to make failure detection work * faster scatter nb, less points * lines_cmap nb screenshot tests * simplify notebook_finished() * use real image for lines underlay * fix arg * fix test * update nb screenshots * type annotation, small change to try and get git lfs to pull in PR action --- .github/workflows/ci.yml | 4 +- .github/workflows/screenshots.yml | 5 +- examples/notebooks/lines_cmap.ipynb | 114 ++++ examples/notebooks/nb_test_utils.py | 87 +++ examples/notebooks/scatter.ipynb | 116 ++-- .../notebooks/screenshots/nb-astronaut.png | 3 + .../screenshots/nb-astronaut_RGB.png | 3 + examples/notebooks/screenshots/nb-camera.png | 3 + .../notebooks/screenshots/nb-lines-3d.png | 3 + .../nb-lines-cmap-jet-values-cosine.png | 3 + .../screenshots/nb-lines-cmap-jet-values.png | 3 + .../screenshots/nb-lines-cmap-jet.png | 3 + .../screenshots/nb-lines-cmap-tab-10.png | 3 + .../nb-lines-cmap-viridis-values.png | 3 + .../screenshots/nb-lines-cmap-viridis.png | 3 + .../screenshots/nb-lines-cmap-white.png | 3 + .../notebooks/screenshots/nb-lines-colors.png | 3 + .../notebooks/screenshots/nb-lines-data.png | 3 + .../screenshots/nb-lines-underlay.png | 3 + examples/notebooks/screenshots/nb-lines.png | 3 + examples/notebooks/simple.ipynb | 588 ++++++++++++------ fastplotlib/graphics/_features/_colors.py | 6 +- 22 files changed, 714 insertions(+), 251 deletions(-) create mode 100644 examples/notebooks/nb_test_utils.py create mode 100644 examples/notebooks/screenshots/nb-astronaut.png create mode 100644 examples/notebooks/screenshots/nb-astronaut_RGB.png create mode 100644 examples/notebooks/screenshots/nb-camera.png create mode 100644 examples/notebooks/screenshots/nb-lines-3d.png create mode 100644 examples/notebooks/screenshots/nb-lines-cmap-jet-values-cosine.png create mode 100644 examples/notebooks/screenshots/nb-lines-cmap-jet-values.png create mode 100644 examples/notebooks/screenshots/nb-lines-cmap-jet.png create mode 100644 examples/notebooks/screenshots/nb-lines-cmap-tab-10.png create mode 100644 examples/notebooks/screenshots/nb-lines-cmap-viridis-values.png create mode 100644 examples/notebooks/screenshots/nb-lines-cmap-viridis.png create mode 100644 examples/notebooks/screenshots/nb-lines-cmap-white.png create mode 100644 examples/notebooks/screenshots/nb-lines-colors.png create mode 100644 examples/notebooks/screenshots/nb-lines-data.png create mode 100644 examples/notebooks/screenshots/nb-lines-underlay.png create mode 100644 examples/notebooks/screenshots/nb-lines.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34284f8bc..3abcfaaf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,4 +97,6 @@ jobs: if: ${{ failure() }} with: name: screenshot-diffs - path: examples/desktop/diffs + path: | + examples/desktop/diffs + examples/notebooks/diffs diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index 984d84aba..488ad108f 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -44,8 +44,11 @@ jobs: run: | # regenerate screenshots REGENERATE_SCREENSHOTS=1 pytest -v examples + REGENERATE_SCREENSHOTS=1 pytest --nbmake examples/notebooks/ - uses: actions/upload-artifact@v3 if: always() with: name: screenshots - path: examples/desktop/screenshots/ + path: | + examples/desktop/screenshots/ + examples/notebooks/screenshots/ diff --git a/examples/notebooks/lines_cmap.ipynb b/examples/notebooks/lines_cmap.ipynb index 5eb783d77..c6dc604b4 100644 --- a/examples/notebooks/lines_cmap.ipynb +++ b/examples/notebooks/lines_cmap.ipynb @@ -13,6 +13,19 @@ "import fastplotlib as fpl" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "5d2ef4aa-0e4c-4694-ae2e-05da1153a413", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# this is only for testing, you do not need this to use fastplotlib\n", + "from nb_test_utils import plot_test, notebook_finished" + ] + }, { "cell_type": "code", "execution_count": null, @@ -49,6 +62,18 @@ "plot.show()" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "727282c3-aadf-420f-a88e-9dd4d4e91263", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_test(\"lines-cmap-white\", plot)" + ] + }, { "cell_type": "markdown", "id": "889b1858-ed64-4d6b-96ad-3883fbe4d38e", @@ -69,6 +94,19 @@ "plot.graphics[0].cmap = \"jet\"" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "3c9b0bc8-b176-425c-8036-63dc55ab7466", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# for testing, ignore\n", + "plot_test(\"lines-cmap-jet\", plot)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -81,6 +119,19 @@ "plot.graphics[0].cmap.values = sine[:, 1]" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "6b19d2d4-90e7-40ed-afb9-13abe5474ace", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# for testing, ignore\n", + "plot_test(\"lines-cmap-jet-values\", plot)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -93,6 +144,19 @@ "plot.graphics[0].cmap.values = cosine[:, 1]" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a6c4739-fa61-4532-865e-21107eab76f9", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# for testing, ignore\n", + "plot_test(\"lines-cmap-jet-values-cosine\", plot)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -105,6 +169,19 @@ "plot.graphics[0].cmap = \"viridis\"" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "45acfd2f-09f5-418c-bca5-3e574348b7d5", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# for testing, ignore\n", + "plot_test(\"lines-cmap-viridis\", plot)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -129,6 +206,19 @@ "plot.graphics[0].cmap.values = cmap_values" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "7548407f-05ed-4c47-93cc-131c61f8e242", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# for testing, ignore\n", + "plot_test(\"lines-cmap-viridis-values\", plot)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -147,6 +237,30 @@ "id": "c290c642-ba5f-4a46-9a17-c434cb39de26", "metadata": {}, "outputs": [], + "source": [ + "# for testing, ignore\n", + "plot_test(\"lines-cmap-tab-10\", plot)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4b9e735-72e9-4f0e-aa3e-43db57e65c99", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# for testing, ignore\n", + "notebook_finished()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f6735cc0-910c-4854-ac50-8ee553a6475e", + "metadata": {}, + "outputs": [], "source": [] } ], diff --git a/examples/notebooks/nb_test_utils.py b/examples/notebooks/nb_test_utils.py new file mode 100644 index 000000000..e16ed2eaf --- /dev/null +++ b/examples/notebooks/nb_test_utils.py @@ -0,0 +1,87 @@ +from typing import * +import os +from pathlib import Path + +import imageio.v3 as iio +import numpy as np + +from fastplotlib import Plot, GridPlot + +# make dirs for screenshots and diffs +current_dir = Path(__file__).parent + +SCREENSHOTS_DIR = current_dir.joinpath("screenshots") +DIFFS_DIR = current_dir.joinpath("diffs") + +os.makedirs(SCREENSHOTS_DIR, exist_ok=True) +os.makedirs(DIFFS_DIR, exist_ok=True) + + +# store all the failures to allow the nb to proceed to test other examples +FAILURES = list() + + +def plot_test(name, plot: Union[Plot, GridPlot]): + snapshot = plot.canvas.snapshot() + + if "REGENERATE_SCREENSHOTS" in os.environ.keys(): + if os.environ["REGENERATE_SCREENSHOTS"] == "1": + regenerate_screenshot(name, snapshot.data) + + try: + assert_screenshot_equal(name, snapshot.data) + except AssertionError: + FAILURES.append(name) + + +def regenerate_screenshot(name, data): + iio.imwrite(SCREENSHOTS_DIR.joinpath(f"nb-{name}.png"), data) + + +def assert_screenshot_equal(name, data): + ground_truth = iio.imread(SCREENSHOTS_DIR.joinpath(f"nb-{name}.png")) + + is_similar = np.allclose(data, ground_truth) + + update_diffs(name, is_similar, data, ground_truth) + + assert is_similar, ( + f"notebook snapshot for {name} has changed" + ) + + +def update_diffs(name, is_similar, img, ground_truth): + diffs_rgba = None + + def get_diffs_rgba(slicer): + # lazily get and cache the diff computation + nonlocal diffs_rgba + if diffs_rgba is None: + # cast to float32 to avoid overflow + # compute absolute per-pixel difference + diffs_rgba = np.abs(ground_truth.astype("f4") - img) + # magnify small values, making it easier to spot small errors + diffs_rgba = ((diffs_rgba / 255) ** 0.25) * 255 + # cast back to uint8 + diffs_rgba = diffs_rgba.astype("u1") + return diffs_rgba[..., slicer] + + # split into an rgb and an alpha diff + diffs = { + DIFFS_DIR.joinpath(f"nb-diff-{name}-rgb.png"): slice(0, 3), + DIFFS_DIR.joinpath(f"nb-diff-{name}-alpha.png"): 3, + } + + for path, slicer in diffs.items(): + if not is_similar: + diff = get_diffs_rgba(slicer) + iio.imwrite(path, diff) + elif path.exists(): + path.unlink() + + +def notebook_finished(): + if len(FAILURES) > 0: + raise AssertionError( + f"Failures for plots:\n{FAILURES}" + ) diff --git a/examples/notebooks/scatter.ipynb b/examples/notebooks/scatter.ipynb index 094204b63..948403f11 100644 --- a/examples/notebooks/scatter.ipynb +++ b/examples/notebooks/scatter.ipynb @@ -12,7 +12,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "9b3041ad-d94e-4b2a-af4d-63bcd19bf6c2", "metadata": { "tags": [] @@ -25,9 +25,11 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "51f1d76a-f815-460f-a884-097fe3ea81ac", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "# create a random distribution of 10,000 xyz coordinates\n", @@ -35,7 +37,7 @@ "\n", "# if you have a good GPU go for 1.2 million points :D \n", "# this is multiplied by 3\n", - "n_points = 400_000\n", + "#n_points = 400_000\n", "dims = (n_points, 3)\n", "\n", "offset = 15\n", @@ -54,60 +56,12 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "922990b6-24e9-4fa0-977b-6577f9752d84", - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "0d49371132174eb4a9501964b4584d67", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/kushalk/repos/fastplotlib/fastplotlib/layouts/_base.py:214: UserWarning: `center_scene()` not yet implemented for `PerspectiveCamera`\n", - " warn(\"`center_scene()` not yet implemented for `PerspectiveCamera`\")\n" - ] - }, - { - "data": { - "text/html": [ - "

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "a94246496c054599bc44a0a77ea7d58e", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "JupyterWgpuCanvas()" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "metadata": { + "tags": [] + }, + "outputs": [], "source": [ "# grid with 2 rows and 2 columns\n", "shape = (2, 2)\n", @@ -148,52 +102,62 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "7b912961-f72e-46ef-889f-c03234831059", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].colors[400_000:600_000] = \"r\"" + "grid_plot[0, 1].graphics[0].colors[n_points:int(n_points * 1.5)] = \"r\"" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "c6085806-c001-4632-ab79-420b4692693a", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].colors[:100_000:10] = \"blue\"" + "grid_plot[0, 1].graphics[0].colors[:n_points:10] = \"blue\"" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "6f416825-df31-4e5d-b66b-07f23b48e7db", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].colors[800_000:] = \"green\"" + "grid_plot[0, 1].graphics[0].colors[n_points:] = \"green\"" ] }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "c0fd611e-73e5-49e6-a25c-9d5b64afa5f4", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].colors[800_000:, -1] = 0" + "grid_plot[0, 1].graphics[0].colors[n_points:, -1] = 0" ] }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "cd390542-3a44-4973-8172-89e5583433bc", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].data[:400_000] = grid_plot[0, 1].graphics[0].data[800_000:]" + "grid_plot[0, 1].graphics[0].data[:n_points] = grid_plot[0, 1].graphics[0].data[n_points * 2:]" ] }, { @@ -203,6 +167,14 @@ "metadata": {}, "outputs": [], "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7d8aac54-4f36-41d4-8e5b-8d8da2f0d17d", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/examples/notebooks/screenshots/nb-astronaut.png b/examples/notebooks/screenshots/nb-astronaut.png new file mode 100644 index 000000000..e8345f7b2 --- /dev/null +++ b/examples/notebooks/screenshots/nb-astronaut.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36a11f5c0a80e1cfbdeb318b314886f4d8e02ba8a763bed0db9994ef451bfd42 +size 128068 diff --git a/examples/notebooks/screenshots/nb-astronaut_RGB.png b/examples/notebooks/screenshots/nb-astronaut_RGB.png new file mode 100644 index 000000000..0ff257ccf --- /dev/null +++ b/examples/notebooks/screenshots/nb-astronaut_RGB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc27fc081b464bb53afd98d3748b8bc75764537d76a8012b9f1b2c1d4c10613d +size 125492 diff --git a/examples/notebooks/screenshots/nb-camera.png b/examples/notebooks/screenshots/nb-camera.png new file mode 100644 index 000000000..cbf936192 --- /dev/null +++ b/examples/notebooks/screenshots/nb-camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbf213d944a16cf9f72542e7a2172330fefa97c8577905f07df12559eb4485c3 +size 89303 diff --git a/examples/notebooks/screenshots/nb-lines-3d.png b/examples/notebooks/screenshots/nb-lines-3d.png new file mode 100644 index 000000000..6bb05537a --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-3d.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7e61fb22db10e515a7d249649c5e220731c6ea5a83bb626f06dcf41167f117e +size 23052 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-jet-values-cosine.png b/examples/notebooks/screenshots/nb-lines-cmap-jet-values-cosine.png new file mode 100644 index 000000000..b1045cde6 --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-cmap-jet-values-cosine.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f55806e64a8ffde2f11eed1dc75a874371800046c062da21e71554abedda251 +size 17136 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-jet-values.png b/examples/notebooks/screenshots/nb-lines-cmap-jet-values.png new file mode 100644 index 000000000..53b3d4cbd --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-cmap-jet-values.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5e9bcb785fe5efee324bdde451d62158668dafa0c026179bd11d38298fb0002 +size 18526 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-jet.png b/examples/notebooks/screenshots/nb-lines-cmap-jet.png new file mode 100644 index 000000000..8bfd0d577 --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-cmap-jet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d6fd17a9a704b2d9c5341e85763f1ba9c5e3026da88858f004e66a781e02eaa +size 16310 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-tab-10.png b/examples/notebooks/screenshots/nb-lines-cmap-tab-10.png new file mode 100644 index 000000000..3e76883bf --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-cmap-tab-10.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:672da2cc5e500ce3bbdabb01eaf5a7d2b9fb6ea4e6e95cb3392b2a0573a970d9 +size 14882 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-viridis-values.png b/examples/notebooks/screenshots/nb-lines-cmap-viridis-values.png new file mode 100644 index 000000000..4b6212a6a --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-cmap-viridis-values.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e1224f75ce0286c4721b5f65af339fc922dcb2308f8d2fa3def10ead48cdce8 +size 15096 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-viridis.png b/examples/notebooks/screenshots/nb-lines-cmap-viridis.png new file mode 100644 index 000000000..35c38c881 --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-cmap-viridis.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6201cd8dc9273adca73329b0eae81faf6aed42c3bf8f7ee503b9251af499dcd +size 19203 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-white.png b/examples/notebooks/screenshots/nb-lines-cmap-white.png new file mode 100644 index 000000000..67c2fc116 --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-cmap-white.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ecb2d4d591b852bda8758efcf91d389442f916bbb4a06c5216d52dcf72172370 +size 12955 diff --git a/examples/notebooks/screenshots/nb-lines-colors.png b/examples/notebooks/screenshots/nb-lines-colors.png new file mode 100644 index 000000000..b9972c8f4 --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-colors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8eefa5106414bfb540b282d74372831ef3c4a9d941aaf50026ea64a3d3009f7 +size 40544 diff --git a/examples/notebooks/screenshots/nb-lines-data.png b/examples/notebooks/screenshots/nb-lines-data.png new file mode 100644 index 000000000..14d6f89f0 --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-data.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e89906d0d749f443e751eeb43b017622a46dfaa91545e9135d0a519e0aad0eb +size 54446 diff --git a/examples/notebooks/screenshots/nb-lines-underlay.png b/examples/notebooks/screenshots/nb-lines-underlay.png new file mode 100644 index 000000000..d8809f301 --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines-underlay.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61ed6bde5639d57694cb8752052dda08a5f2f7dcc32966ab62385bc866c299e3 +size 55936 diff --git a/examples/notebooks/screenshots/nb-lines.png b/examples/notebooks/screenshots/nb-lines.png new file mode 100644 index 000000000..3dcc1767e --- /dev/null +++ b/examples/notebooks/screenshots/nb-lines.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39478dbf9af2f74ae0e0240616d94480569d53dcbd5f046315eeff3855d4cb2e +size 37711 diff --git a/examples/notebooks/simple.ipynb b/examples/notebooks/simple.ipynb index 367a0126c..e994bfba8 100644 --- a/examples/notebooks/simple.ipynb +++ b/examples/notebooks/simple.ipynb @@ -9,7 +9,39 @@ "source": [ "# Introduction to `fastplotlib`\n", "\n", - "This notebook goes the basic components of the `fastplotlib` API, image, image updates, line plots, and scatter plots. " + "This notebook goes through the basic components of the `fastplotlib` API, image, image updates, line plots, and scatter plots. " + ] + }, + { + "cell_type": "markdown", + "id": "ae07272b-e94b-4262-b486-6b3ddac63038", + "metadata": {}, + "source": [ + "**The example images are from `imageio` so you will need to install it for this example notebook. But `imageio` is not required to use `fasptlotlib`**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6674c90b-bfe3-4a71-ab7d-21e9cc03c050", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "!pip install imageio" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c50e177-5800-4e19-a4f6-d0e0a082e4cd", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import imageio.v3 as iio" ] }, { @@ -26,12 +58,25 @@ "import numpy as np" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "1d374d5e-70e0-4946-937f-82d16a56009f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# this is only for testing, you do not need this to use fastplotlib\n", + "from nb_test_utils import plot_test, notebook_finished" + ] + }, { "cell_type": "markdown", "id": "a9b386ac-9218-4f8f-97b3-f29b4201ef55", "metadata": {}, "source": [ - "### Simple image" + "## Simple image" ] }, { @@ -46,11 +91,11 @@ "# create a `Plot` instance\n", "plot = Plot()\n", "\n", - "# make some random 2D image data\n", - "data = np.random.rand(512, 512)\n", + "# get a grayscale image\n", + "data = iio.imread(\"imageio:camera.png\")\n", "\n", "# plot the image data\n", - "image_graphic = plot.add_image(data=data, name=\"random-image\")\n", + "image_graphic = plot.add_image(data=data, name=\"sample-image\")\n", "\n", "# show the plot\n", "plot.show()" @@ -61,7 +106,21 @@ "id": "be5b408f-dd91-4e36-807a-8c22c8d7d216", "metadata": {}, "source": [ - "### Use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!" + "**Use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!**\n", + "\n", + "By default the origin is on the bottom left, you can click the flip button to flip the y-axis, or use `plot.camera.world.scale_y *= -1`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58c1dc0b-9bf0-4ad5-8579-7c10396fc6bc", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.camera.world.scale_y *= -1" ] }, { @@ -69,7 +128,7 @@ "id": "7c3b637c-a26b-416e-936c-705275852a8a", "metadata": {}, "source": [ - "Changing graphic \"features\"" + "Changing graphic **\"features\"**" ] }, { @@ -84,16 +143,28 @@ "image_graphic.cmap = \"viridis\"" ] }, + { + "cell_type": "markdown", + "id": "da1efe85-c5b8-42e8-ae81-6cbddccc30f7", + "metadata": {}, + "source": [ + "### Slicing data\n", + "\n", + "**Most features, such as `data` support slicing!**\n", + "\n", + "Out image data is of shape [n_rows, n_cols]" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "09350854-5058-4574-a01d-84d00e276c57", + "id": "a04afe48-5534-4ef6-a159-f6e6a4337d8d", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic.data = 0" + "image_graphic.data().shape" ] }, { @@ -109,351 +180,439 @@ "image_graphic.data[:, ::15] = 1" ] }, + { + "cell_type": "markdown", + "id": "135db5d2-53fb-4d50-8164-2c1f00560c25", + "metadata": {}, + "source": [ + "**Fancy indexing**" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "3e298c1c-7551-4401-ade0-b9af7d2bbe23", + "id": "a89120eb-108b-4df3-8d3f-8192c9315aa6", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic.data = np.random.rand(512, 512)" + "image_graphic.data[data > 175] = 255" ] }, { "cell_type": "markdown", - "id": "67b92ffd-40cc-43fe-9df9-0e0d94763d8e", + "id": "096ccb73-bf6d-4dba-8168-788a63450406", "metadata": {}, "source": [ - "### Plots are indexable and give you their graphics by name" + "Adjust vmin vmax" ] }, { "cell_type": "code", "execution_count": null, - "id": "e6ba689c-ff4a-44ef-9663-f2c8755072c4", + "id": "f8e69df8-7aaf-4d7c-92e3-861d9ebc8c5f", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot.graphics" + "image_graphic.cmap.vmin = 50\n", + "image_graphic.cmap.vmax = 150" ] }, { "cell_type": "code", "execution_count": null, - "id": "5b18f4e3-e13b-46d5-af1f-285c5a7fdc12", + "id": "aa67b34a-2694-4ec0-9ba2-e88c469f1a06", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot[\"random-image\"]" + "# testing cell, ignore\n", + "plot_test(\"camera\", plot)" ] }, { "cell_type": "markdown", - "id": "4316a8b5-5f33-427a-8f52-b101d1daab67", + "id": "da9c9b25-7c8b-49b2-9531-7c741debd71d", "metadata": {}, "source": [ - "#### The `Graphic` instance is also returned when you call `plot.add_`." + "**Set the entire data array again**\n", + "\n", + "Note: The shape of the new data array must match the current data shown in the Graphic." ] }, { "cell_type": "code", "execution_count": null, - "id": "2b5c1321-1fd4-44bc-9433-7439ad3e22cf", + "id": "089170fd-016e-4b2f-a090-c30beb85cc1b", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic" + "new_data = iio.imread(\"imageio:astronaut.png\")\n", + "new_data.shape" + ] + }, + { + "cell_type": "markdown", + "id": "d14cf14a-282f-40c6-b086-9bcf332ed0c8", + "metadata": {}, + "source": [ + "This is an RGB image, convert to grayscale to maintain the shape of (512, 512)" ] }, { "cell_type": "code", "execution_count": null, - "id": "b12bf75e-4e93-4930-9146-e96324fdf3f6", + "id": "ec9b2874-ce1a-49c6-9b84-ee8f14d55966", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic == plot[\"random-image\"]" + "gray = new_data.dot([0.3, 0.6, 0.1])\n", + "gray.shape" ] }, { - "cell_type": "markdown", - "id": "1cb03f42-1029-4b16-a16b-35447d9e2955", + "cell_type": "code", + "execution_count": null, + "id": "8a8fc1d3-19ba-42c0-b9ec-39f6ddd23314", "metadata": { "tags": [] }, + "outputs": [], + "source": [ + "image_graphic.data = gray" + ] + }, + { + "cell_type": "markdown", + "id": "bb568f89-ac92-4dde-9359-789049dc758a", + "metadata": {}, "source": [ - "### Image updates\n", "\n", - "This examples show how you can define animation functions that run on every render cycle." + "\n", + "reset vmin vmax" ] }, { "cell_type": "code", "execution_count": null, - "id": "aadd757f-6379-4f52-a709-46aa57c56216", + "id": "de09d977-88ea-472c-8d89-9e24abc845a9", "metadata": { "tags": [] }, "outputs": [], "source": [ - "# create another `Plot` instance\n", - "plot_v = Plot()\n", - "\n", - "plot.canvas.max_buffered_frames = 1\n", - "\n", - "# make some random data again\n", - "data = np.random.rand(512, 512)\n", - "\n", - "# plot the data\n", - "plot_v.add_image(data=data, name=\"random-image\")\n", - "\n", - "# a function to update the image_graphic\n", - "# a plot will pass its plot instance to the animation function as an arugment\n", - "def update_data(plot_instance):\n", - " new_data = np.random.rand(512, 512)\n", - " plot_instance[\"random-image\"].data = new_data\n", - "\n", - "#add this as an animation function\n", - "plot_v.add_animations(update_data)\n", - "\n", - "# show the plot\n", - "plot_v.show()" + "image_graphic.cmap.reset_vmin_vmax()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9cf84998-03e1-41b3-8e63-92d5b59426e6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# testing cell, ignore\n", + "plot_test(\"astronaut\", plot)" ] }, { "cell_type": "markdown", - "id": "b313eda1-6e6c-466f-9fd5-8b70c1d3c110", + "id": "b53bc11a-ddf1-4786-8dca-8f3d2eaf993d", "metadata": {}, "source": [ - "### We can share controllers across plots\n", - "\n", - "This example creates a new plot, but it synchronizes the pan-zoom controller" + "### Indexing plots" + ] + }, + { + "cell_type": "markdown", + "id": "67b92ffd-40cc-43fe-9df9-0e0d94763d8e", + "metadata": {}, + "source": [ + "**Plots are indexable and give you their graphics by name**" ] }, { "cell_type": "code", "execution_count": null, - "id": "86e70b1e-4328-4035-b992-70dff16d2a69", - "metadata": {}, + "id": "e6ba689c-ff4a-44ef-9663-f2c8755072c4", + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "plot_sync = Plot(controller=plot_v.controller)\n", - "\n", - "data = np.random.rand(512, 512)\n", - "\n", - "image_graphic_instance = plot_sync.add_image(data=data, cmap=\"viridis\")\n", - "\n", - "# you will need to define a new animation function for this graphic\n", - "def update_data_2():\n", - " new_data = np.random.rand(512, 512)\n", - " # alternatively, you can use the stored reference to the graphic as well instead of indexing the Plot\n", - " image_graphic_instance.data = new_data\n", - "\n", - "plot_sync.add_animations(update_data_2)\n", - "\n", - "plot_sync.show()" + "plot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5b18f4e3-e13b-46d5-af1f-285c5a7fdc12", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot[\"sample-image\"]" ] }, { "cell_type": "markdown", - "id": "f226c9c2-8d0e-41ab-9ab9-1ae31fd91de5", + "id": "a64314bf-a737-4858-803b-ea2adbd3578c", "metadata": {}, "source": [ - "#### Keeping a reference to the Graphic instance, as shown above `image_graphic_instance`, is useful if you're creating something where you need flexibility in the naming of the graphics" + "**You can also use numerical indexing on `plot.graphics`**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c09a1924-70f8-4d9e-9e92-510d700ac715", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.graphics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec9e6ba6-553f-4718-ba13-471c8c7c3c4e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.graphics[0]" ] }, { "cell_type": "markdown", - "id": "d11fabb7-7c76-4e94-893d-80ed9ee3be3d", + "id": "4316a8b5-5f33-427a-8f52-b101d1daab67", "metadata": {}, "source": [ - "### You can also use `ipywidgets.VBox` and `HBox` to stack plots. See the `gridplot` notebooks for a proper gridplot interface for more automated subplotting" + "The `Graphic` instance is also returned when you call `plot.add_`." ] }, { "cell_type": "code", "execution_count": null, - "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d", - "metadata": {}, + "id": "2b5c1321-1fd4-44bc-9433-7439ad3e22cf", + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "VBox([plot_v.show(), plot_sync.show()])" + "image_graphic" ] }, { "cell_type": "code", "execution_count": null, - "id": "11839d95-8ff7-444c-ae13-6b072c3112c5", - "metadata": {}, + "id": "b12bf75e-4e93-4930-9146-e96324fdf3f6", + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "HBox([plot_v.show(), plot_sync.show()])" + "image_graphic == plot[\"sample-image\"]" ] }, { "cell_type": "markdown", - "id": "eaaeac07-5046-4e17-ab17-b685645e65f4", + "id": "5694dca1-1041-4e09-a1da-85b293c5af47", "metadata": {}, "source": [ - "# Sliders to scroll through image data\n", + "### RGB images are also supported\n", + "\n", + "`cmap` arguments are ignored for rgb images, but vmin vmax still works" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d6b8ca51-073d-47aa-a464-44511fcaccbc", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_rgb = Plot()\n", + "\n", + "plot_rgb.add_image(new_data, name=\"rgb-image\")\n", "\n", - "We often already have large image arrays (whether in RAM or through lazy loading), and want to view 2D frames across one or more dimensions. There is an `ImageWidget` that should really be used for this, but this example just shows how you can use `ipywidgets` to change data or any **`GraphicFeature`**" + "plot_rgb.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "71eae361-3bbf-4d1f-a903-3615d35b557b", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_rgb.camera.world.scale_y *= -1" ] }, { "cell_type": "markdown", - "id": "6c9616e3-6ad0-4aa6-9f9f-f3282b05b0f1", + "id": "7fc66377-00e8-4f32-9671-9cf63f74529f", "metadata": {}, "source": [ - "### Some code to generate a bunch of time-varying Gaussians. This code is NOT important for understanding `fastplotlib`, it just generates some video-like data for us to visualize!" + "vmin and vmax are still applicable to rgb images" ] }, { "cell_type": "code", "execution_count": null, - "id": "0bcedf83-cbdd-4ec2-b8d5-172aa72a3e04", - "metadata": {}, + "id": "cafaa403-50a2-403c-b8e7-b0938d48cadd", + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "import numpy as np\n", - "from scipy.stats import multivariate_normal\n", - "\n", - "# set up gaussians centered at component_centers\n", - "n_frames = 1000\n", - "spatial_dims = 512\n", - "\n", - "frame_shape = [512, 512]\n", - "\n", - "n_components = 32\n", - "component_centers = (np.random.rand(n_components, 2) * spatial_dims).astype(int)\n", - "\n", - "# create component images: stack of images one for ech component\n", - "spatial_sigma = 50\n", - "x, y = np.meshgrid(\n", - " np.arange(0, spatial_dims),\n", - " np.arange(0, spatial_dims)\n", - ")\n", + "plot_rgb[\"rgb-image\"].cmap.vmin = 100" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b8d600c7-aa80-4c3f-8ec0-6641e9359c3a", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# testing cell, ignore\n", + "plot_test(\"astronaut_RGB\", plot_rgb)" + ] + }, + { + "cell_type": "markdown", + "id": "1cb03f42-1029-4b16-a16b-35447d9e2955", + "metadata": { + "tags": [] + }, + "source": [ + "### Image updates\n", "\n", - "pos = np.dstack((x, y))\n", - "component_sigma = np.array(\n", - " [[spatial_sigma, 0],\n", - " [0, spatial_sigma]]\n", - ")\n", + "This examples show how you can define animation functions that run on every render cycle." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aadd757f-6379-4f52-a709-46aa57c56216", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# create another `Plot` instance\n", + "plot_v = Plot()\n", "\n", - "component_images = []\n", - "for comp_ix in range(n_components):\n", - " comp_mean = component_centers[comp_ix]\n", - " gauss_rep = multivariate_normal(comp_mean, component_sigma)\n", - " gauss_img = gauss_rep.pdf(pos)\n", - " component_images.append(gauss_img)\n", + "plot.canvas.max_buffered_frames = 1\n", "\n", - "component_images = np.array(component_images)\n", + "# make some random data again\n", + "data = np.random.rand(512, 512)\n", "\n", + "# plot the data\n", + "plot_v.add_image(data=data, name=\"random-image\")\n", "\n", - "# generate traces\n", - "tau = 10\n", - "max_amp = 2000\n", - "amps_all = []\n", + "# a function to update the image_graphic\n", + "# a plot will pass its plot instance to the animation function as an arugment\n", + "def update_data(plot_instance):\n", + " new_data = np.random.rand(512, 512)\n", + " plot_instance[\"random-image\"].data = new_data\n", "\n", - "for component_num in range(n_components):\n", - " amps = []\n", - " amp = 0\n", - " for time_step in np.arange(n_frames):\n", - " if np.random.uniform(0,1) > 0.98:\n", - " amp = max_amp\n", - " else:\n", - " amp = np.max(np.array([amp - amp/tau, 0]));\n", - " amps.append(amp)\n", - " amps = np.array(amps)\n", - " amps_all.append(amps)\n", - "amps_all = np.array(amps_all)\n", + "#add this as an animation function\n", + "plot_v.add_animations(update_data)\n", "\n", - "# create movie\n", - "movie = np.zeros((n_frames, spatial_dims, spatial_dims))\n", - "for frame_ix in np.arange(n_frames):\n", - " for comp_ix in range(n_components):\n", - " movie[frame_ix] += amps_all[comp_ix][frame_ix] * component_images[comp_ix]" + "# show the plot\n", + "plot_v.show()" ] }, { "cell_type": "markdown", - "id": "9ac18409-56d8-46cc-86bf-32456fcece48", + "id": "b313eda1-6e6c-466f-9fd5-8b70c1d3c110", "metadata": {}, "source": [ - "### Now we have a movie of the following shape, an image sequence" + "### We can share controllers across plots\n", + "\n", + "This example creates a new plot, but it synchronizes the pan-zoom controller" ] }, { "cell_type": "code", "execution_count": null, - "id": "8b560151-c258-415c-a20d-3cccd421f44a", + "id": "86e70b1e-4328-4035-b992-70dff16d2a69", "metadata": {}, "outputs": [], "source": [ - "movie.shape" + "plot_sync = Plot(controller=plot_v.controller)\n", + "\n", + "data = np.random.rand(512, 512)\n", + "\n", + "image_graphic_instance = plot_sync.add_image(data=data, cmap=\"viridis\")\n", + "\n", + "# you will need to define a new animation function for this graphic\n", + "def update_data_2():\n", + " new_data = np.random.rand(512, 512)\n", + " # alternatively, you can use the stored reference to the graphic as well instead of indexing the Plot\n", + " image_graphic_instance.data = new_data\n", + "\n", + "plot_sync.add_animations(update_data_2)\n", + "\n", + "plot_sync.show()" ] }, { "cell_type": "markdown", - "id": "f70cf836-222a-40ef-835f-1d2a02331a12", + "id": "f226c9c2-8d0e-41ab-9ab9-1ae31fd91de5", "metadata": {}, "source": [ - "### This is usually [time, x, y]" + "#### Keeping a reference to the Graphic instance, as shown above `image_graphic_instance`, is useful if you're creating something where you need flexibility in the naming of the graphics" ] }, { "cell_type": "markdown", - "id": "836fef2e-5a27-44ec-9d7e-943d496b7864", + "id": "d11fabb7-7c76-4e94-893d-80ed9ee3be3d", "metadata": {}, "source": [ - "## Plot and scroll through the first dimension with a slider" + "### You can also use `ipywidgets.VBox` and `HBox` to stack plots. See the `gridplot` notebooks for a proper gridplot interface for more automated subplotting" ] }, { "cell_type": "code", "execution_count": null, - "id": "62166a9f-ab43-45cc-a6db-6d441387e9a5", + "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d", "metadata": {}, "outputs": [], "source": [ - "plot_movie = Plot()\n", - "\n", - "# plot the first frame to initialize\n", - "movie_graphic = plot_movie.add_image(movie[0], vmin=0, vmax=movie.max(), cmap=\"gnuplot2\")\n", - "\n", - "# make a slider\n", - "slider = IntSlider(min=0, max=movie.shape[0] - 1, step=1, value=0)\n", - "\n", - "# function to update movie_graphic\n", - "def update_movie(change): \n", - " index = change[\"new\"]\n", - " movie_graphic.data = movie[index]\n", - " \n", - "slider.observe(update_movie, \"value\")\n", - " \n", - "# Use an ipywidgets VBox to show the plot and slider\n", - "VBox([plot_movie.show(), slider])" + "VBox([plot_v.show(), plot_sync.show()])" ] }, { - "cell_type": "markdown", - "id": "876f1f89-c12e-44a5-9b00-9e6b4781b584", - "metadata": { - "jp-MarkdownHeadingCollapsed": true, - "tags": [] - }, + "cell_type": "code", + "execution_count": null, + "id": "11839d95-8ff7-444c-ae13-6b072c3112c5", + "metadata": {}, + "outputs": [], "source": [ - "#### Note that the use of globals in the `update_movie()` here can get messy, this is not recommended and you should create a class to properly handle combining widgets like this. _However_ if you want slider widgets for imaging data the recommended way to do this is by using the `ImageWidget`, see the `image_widget.ipynb` notebook for details." + "HBox([plot_v.show(), plot_sync.show()])" ] }, { @@ -532,6 +691,19 @@ "plot_l.show()" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4060576-2f29-4e4b-a86a-0410c766bd98", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# testing cell, ignore\n", + "plot_test(\"lines\", plot_l)" + ] + }, { "cell_type": "markdown", "id": "22dde600-0f56-4370-b017-c8f23a6c01aa", @@ -638,6 +810,19 @@ "cosine_graphic.colors[15:50:3] = \"cyan\"" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef8cab1b-8327-43e2-b021-176125b91ca9", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# testing cell, ignore\n", + "plot_test(\"lines-colors\", plot_l)" + ] + }, { "cell_type": "markdown", "id": "c29f81f9-601b-49f4-b20c-575c56e58026", @@ -667,6 +852,19 @@ "cosine_graphic.data[0] = np.array([[-10, 0, 0]])" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "96086bd4-cdaa-467d-a68b-1f57002ad6c5", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# testing cell, ignore\n", + "plot_test(\"lines-data\", plot_l)" + ] + }, { "cell_type": "markdown", "id": "3f6d264b-1b03-407e-9d83-cd6cfb02e706", @@ -750,13 +948,27 @@ }, "outputs": [], "source": [ - "img = np.random.rand(20, 100)\n", + "img = iio.imread(\"imageio:camera.png\")\n", "\n", - "plot_l.add_image(img, name=\"image\", cmap=\"gray\")\n", + "plot_l.add_image(img[::20, ::20], name=\"image\", cmap=\"gray\")\n", "\n", "# z axix position -1 so it is below all the lines\n", "plot_l[\"image\"].position_z = -1\n", - "plot_l[\"image\"].position_x = -50" + "plot_l[\"image\"].position_x = -8\n", + "plot_l[\"image\"].position_y = -8" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ae3e3dc9-e49b-430a-8471-5d0a0d659d20", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# testing cell, ignore\n", + "plot_test(\"lines-underlay\", plot_l)" ] }, { @@ -805,6 +1017,19 @@ "plot_l3d.auto_scale(maintain_aspect=True)" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "5135f3f1-a004-4451-86cd-ead6acea6e13", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# testing cell, ignore\n", + "plot_test(\"lines-3d\", plot_l3d)" + ] + }, { "cell_type": "markdown", "id": "a202b3d0-2a0b-450a-93d4-76d0a1129d1d", @@ -841,7 +1066,7 @@ "\n", "# if you have a good GPU go for 1.5 million points :D \n", "# this is multiplied by 3\n", - "n_points = 500_000\n", + "#n_points = 500_000\n", "\n", "# dimensions always have to be [n_points, xyz]\n", "dims = (n_points, 3)\n", @@ -963,6 +1188,17 @@ "id": "370d5837-aecf-4e52-9323-b899ac458bbf", "metadata": {}, "outputs": [], + "source": [ + "# for testing, ignore\n", + "notebook_finished()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52b6c281-ab27-4984-9a6e-f1e27f609e44", + "metadata": {}, + "outputs": [], "source": [] } ], diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py index feb349984..256a5d65f 100644 --- a/fastplotlib/graphics/_features/_colors.py +++ b/fastplotlib/graphics/_features/_colors.py @@ -1,7 +1,7 @@ import numpy as np import pygfx -from ...utils import make_colors, get_cmap_texture, make_pygfx_colors, parse_cmap_values +from ...utils import make_colors, get_cmap_texture, make_pygfx_colors, parse_cmap_values, quick_min_max from ._base import ( GraphicFeature, GraphicFeatureIndexable, @@ -349,6 +349,10 @@ def vmax(self, value: float): ) self._feature_changed(key=None, new_data=None) + def reset_vmin_vmax(self): + """Reset vmin vmax values based on current data""" + self.vmin, self.vmax = quick_min_max(self._parent.data()) + def _feature_changed(self, key, new_data): # this is a non-indexable feature so key=None From 821ecfcfd1f225eb0076f7c0e64f59d0bd572769 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Thu, 6 Jul 2023 05:56:13 -0400 Subject: [PATCH 16/21] real images in quickstart guide (#278) --- docs/source/quickstart.ipynb | 369 +++++++++++++++++++++++++++++++---- 1 file changed, 327 insertions(+), 42 deletions(-) diff --git a/docs/source/quickstart.ipynb b/docs/source/quickstart.ipynb index 66543c063..aebe04b25 100644 --- a/docs/source/quickstart.ipynb +++ b/docs/source/quickstart.ipynb @@ -9,37 +9,57 @@ "source": [ "# Quick Start Guide 🚀\n", "\n", - "This notebook goes the basic components of the `fastplotlib` API, image, image updates, line plots, and scatter plots.\n", + "This notebook goes through the basic components of the `fastplotlib` API, images, image updates, line plots, scatter plots, and grid plots.\n", "\n", "**NOTE: This quick start guide in the docs is NOT interactive. Download the examples from the repo and try them on your own computer. You can run the desktop examples directly if you have `glfw` installed, or try the notebook demos:** https://github.com/kushalkolar/fastplotlib/tree/master/examples\n", "\n", "It will not be possible to have live demos on the docs until someone can figure out how to get [pygfx](https://github.com/pygfx/pygfx) to work with `wgpu` in the browser, perhaps through [pyodide](https://github.com/pyodide/pyodide) or something :D." ] }, + { + "cell_type": "markdown", + "id": "5d21c330-89cd-49ab-9069-4e3652d4286b", + "metadata": {}, + "source": [ + "**The example images are from `imageio` so you will need to install it for this example notebook. But `imageio` is not required to use `fasptlotlib`**" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "fb57c3d3-f20d-4d88-9e7a-04b9309bc637", + "id": "07f064bb-025a-4794-9b05-243810edaf60", "metadata": { "tags": [] }, "outputs": [], "source": [ - "import fastplotlib as fpl\n", - "from ipywidgets import VBox, HBox, IntSlider\n", - "import numpy as np" + "!pip install imageio" ] }, { "cell_type": "code", "execution_count": null, - "id": "2d663646-3d3b-4f5b-a083-a5daca65cb4f", + "id": "5f842366-bd39-47de-ad00-723b2be707e4", "metadata": { "tags": [] }, "outputs": [], "source": [ - "import os" + "import imageio.v3 as iio" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb57c3d3-f20d-4d88-9e7a-04b9309bc637", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import fastplotlib as fpl\n", + "from ipywidgets import VBox, HBox, IntSlider\n", + "import numpy as np" ] }, { @@ -62,11 +82,11 @@ "# create a `Plot` instance\n", "plot = fpl.Plot()\n", "\n", - "# make some random 2D image data\n", - "data = np.random.rand(512, 512)\n", + "# get a grayscale image\n", + "data = iio.imread(\"imageio:camera.png\")\n", "\n", "# plot the image data\n", - "image_graphic = plot.add_image(data=data, name=\"random-image\")\n", + "image_graphic = plot.add_image(data=data, name=\"sample-image\")\n", "\n", "# show the plot\n", "plot.show()" @@ -77,21 +97,55 @@ "id": "be5b408f-dd91-4e36-807a-8c22c8d7d216", "metadata": {}, "source": [ - "In live notebooks or desktop applications, you can use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!" + "**In live notebooks or desktop applications, you can use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!**\n", + "\n", + "By default the origin is on the bottom left, you can click the flip button to flip the y-axis, or use `plot.camera.world.scale_y *= -1`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9b4e977e-2a7d-4e2b-aee4-cfc36767b3c6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.camera.world.scale_y *= -1" + ] + }, + { + "cell_type": "markdown", + "id": "9ba07ec1-a0cb-4461-87c6-c7b64d4a882b", + "metadata": {}, + "source": [ + "This is how you can take a snapshot of the canvas. Snapshots are shown throughout this doc page for the purposes of documentation, they are NOT necessary for real interactive usage. Download the notebooks to run live demos." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b64ba135-e753-43a9-ad1f-adcc7310792d", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.canvas.snapshot()" ] }, { "cell_type": "markdown", - "id": "7c3b637c-a26b-416e-936c-705275852a8a", + "id": "ac5f5e75-9aa4-441f-9a41-66c22cd53de8", "metadata": {}, "source": [ - "Changing graphic \"features\"" + "Changing graphic **\"features\"**" ] }, { "cell_type": "code", "execution_count": null, - "id": "de816c88-1c4a-4071-8a5e-c46c93671ef5", + "id": "d3541d1d-0819-450e-814c-588ffc8e7ed5", "metadata": { "tags": [] }, @@ -100,18 +154,59 @@ "image_graphic.cmap = \"viridis\"" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "ab544719-9187-45bd-8127-aac79eea30e4", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.canvas.snapshot()" + ] + }, { "cell_type": "markdown", - "id": "ecc72f23-22ea-4bd1-b9a0-fd4e14baa79f", + "id": "9693cf94-11e9-46a6-a5b7-b0fbed42ad81", "metadata": {}, "source": [ - "This is how you can take a snapshot of the canvas. Snapshots are shown throughout this doc page for the purposes of documentation, they are NOT necessary for real interactive usage. Download the notebooks to run live demos." + "### Slicing data\n", + "\n", + "**Most features, such as `data` support slicing!**\n", + "\n", + "Out image data is of shape [n_rows, n_cols]" ] }, { "cell_type": "code", "execution_count": null, - "id": "ebc87904-f705-46f0-8f94-fc3b1c6c8e30", + "id": "330a47b5-50b1-4e6a-b8ab-d55d92af2042", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic.data().shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "601f46d9-7f32-4a43-9090-4674218800ea", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic.data[::15, :] = 1\n", + "image_graphic.data[:, ::15] = 1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3443948f-9ac9-484a-a4bf-3a06c1ce5658", "metadata": { "tags": [] }, @@ -122,28 +217,28 @@ }, { "cell_type": "markdown", - "id": "de0653cf-937e-4d0f-965d-296fccaac53e", + "id": "53125b3b-3ce2-43c5-b2e3-7cd37cec7d7d", "metadata": {}, "source": [ - "Setting image data" + "**Fancy indexing**" ] }, { "cell_type": "code", "execution_count": null, - "id": "09350854-5058-4574-a01d-84d00e276c57", + "id": "7344cbbe-40c3-4d9e-ae75-7abe3ddaeeeb", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic.data = 0" + "image_graphic.data[data > 175] = 255" ] }, { "cell_type": "code", "execution_count": null, - "id": "9bcc3943-ea40-4905-a2a2-29e2620f00c8", + "id": "ef113d79-5d86-4be0-868e-30f82f8ab528", "metadata": { "tags": [] }, @@ -154,29 +249,29 @@ }, { "cell_type": "markdown", - "id": "05034a44-f207-45a0-9b5e-3ba7cc118107", + "id": "4df5296e-2a18-403f-82f1-acb8eaf280e3", "metadata": {}, "source": [ - "Setting image data with slicing" + "Adjust vmin vmax" ] }, { "cell_type": "code", "execution_count": null, - "id": "83b2db1b-2783-4e89-bcf3-66bb6e09e18a", + "id": "28af88d1-0518-47a4-ab73-431d6aaf9cb8", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic.data[::15, :] = 1\n", - "image_graphic.data[:, ::15] = 1" + "image_graphic.cmap.vmin = 50\n", + "image_graphic.cmap.vmax = 150" ] }, { "cell_type": "code", "execution_count": null, - "id": "d400b00b-bdf0-4383-974f-9cccd4cd48b6", + "id": "e3dfb827-c812-447d-b413-dc15653160b1", "metadata": { "tags": [] }, @@ -187,28 +282,64 @@ }, { "cell_type": "markdown", - "id": "4abfe97e-8aa6-42c0-8b23-797153a885e3", + "id": "19a1b56b-fdca-40c5-91c9-3c9486fd8a21", "metadata": {}, "source": [ - "Setting image data back to random" + "**Set the entire data array again**\n", + "\n", + "Note: The shape of the new data array must match the current data shown in the Graphic." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4dc3d0e4-b128-42cd-a53e-76846fc9b8a8", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "new_data = iio.imread(\"imageio:astronaut.png\")\n", + "new_data.shape" + ] + }, + { + "cell_type": "markdown", + "id": "3bd06068-fe3f-404d-ba4a-a72a2105904f", + "metadata": {}, + "source": [ + "This is an RGB image, convert to grayscale to maintain the shape of (512, 512)" ] }, { "cell_type": "code", "execution_count": null, - "id": "3e298c1c-7551-4401-ade0-b9af7d2bbe23", + "id": "150047a6-a6ac-442d-a468-3e0c224a2b7e", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic.data = np.random.rand(512, 512)" + "gray = new_data.dot([0.3, 0.6, 0.1])\n", + "gray.shape" ] }, { "cell_type": "code", "execution_count": null, - "id": "49d44536-b36c-47be-9c09-46a81a2c8607", + "id": "bf24576b-d336-4754-9992-9649ccaa4d1e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic.data = gray" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "67d810c2-4020-4769-a5ba-0d4a972ee243", "metadata": { "tags": [] }, @@ -219,16 +350,88 @@ }, { "cell_type": "markdown", - "id": "67b92ffd-40cc-43fe-9df9-0e0d94763d8e", + "id": "2fe82654-e554-4be6-92a0-ecdee0ef8519", "metadata": {}, "source": [ - "Plots are indexable and give you their graphics by name" + "reset vmin vmax" ] }, { "cell_type": "code", "execution_count": null, - "id": "e6ba689c-ff4a-44ef-9663-f2c8755072c4", + "id": "0be6e4bb-cf9a-4155-9f6a-8106e66e6132", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic.cmap.reset_vmin_vmax()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd51936c-ad80-4b33-b855-23565265a430", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "a6c1f3fb-a3a7-4175-bd8d-bb3203740771", + "metadata": {}, + "source": [ + "### Indexing plots" + ] + }, + { + "cell_type": "markdown", + "id": "3fc38694-aca6-4f56-97ac-3435059a6be7", + "metadata": {}, + "source": [ + "**Plots are indexable and give you their graphics by name**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a547138-0f7d-470b-9925-8df479c3979e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5551861f-9860-4515-8222-2f1c6d6a3220", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot[\"sample-image\"]" + ] + }, + { + "cell_type": "markdown", + "id": "0c29b36e-0eb4-4bb3-a8db-add58c303ee8", + "metadata": {}, + "source": [ + "**You can also use numerical indexing on `plot.graphics`**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce6adbb0-078a-4e74-b189-58f860ee5df5", "metadata": { "tags": [] }, @@ -240,18 +443,18 @@ { "cell_type": "code", "execution_count": null, - "id": "5b18f4e3-e13b-46d5-af1f-285c5a7fdc12", + "id": "119bd6af-c486-4378-bc23-79b1759aa3a4", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot[\"random-image\"]" + "plot.graphics[0]" ] }, { "cell_type": "markdown", - "id": "4316a8b5-5f33-427a-8f52-b101d1daab67", + "id": "6b8e3f0d-56f8-447f-bf26-b52629d06e95", "metadata": {}, "source": [ "The `Graphic` instance is also returned when you call `plot.add_`." @@ -260,7 +463,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2b5c1321-1fd4-44bc-9433-7439ad3e22cf", + "id": "967c0cbd-287c-4d99-9891-9baf18f7b56a", "metadata": { "tags": [] }, @@ -272,13 +475,95 @@ { "cell_type": "code", "execution_count": null, - "id": "b12bf75e-4e93-4930-9146-e96324fdf3f6", + "id": "5da72e26-3536-47b8-839c-53452dd94f7a", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "image_graphic == plot[\"sample-image\"]" + ] + }, + { + "cell_type": "markdown", + "id": "2b5ee18b-e61b-415d-902a-688b1c9c03b8", + "metadata": {}, + "source": [ + "### RGB images\n", + "\n", + "`cmap` arguments are ignored for rgb images, but vmin vmax still works" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1f7143ec-8ee1-47d2-b017-d0a8efc69fc6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_rgb = fpl.Plot()\n", + "\n", + "plot_rgb.add_image(new_data, name=\"rgb-image\")\n", + "\n", + "plot_rgb.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7e4b5a30-4293-4ae3-87dc-06a1355bb2c7", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_rgb.camera.world.scale_y *= -1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a47b1eaf-3638-470a-88a5-0026c81d7e2b", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_rgb.canvas.snapshot()" + ] + }, + { + "cell_type": "markdown", + "id": "4848a929-4f3b-46d7-921b-ebfe8de0ebb5", + "metadata": {}, + "source": [ + "vmin and vmax are still applicable to rgb images" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ffe50132-8dd0-433c-b9c6-9ead8c3d48de", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_rgb[\"rgb-image\"].cmap.vmin = 100" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "161468ba-b836-4021-8d11-8dfc140b94eb", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic == plot[\"random-image\"]" + "plot_rgb.canvas.snapshot()" ] }, { From 080d31f7304c3e9f7c030a77690298fc664db57b Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 11 Jul 2023 05:36:05 -0400 Subject: [PATCH 17/21] Polygon selector which can be draw (#282) * better PlotArea selector indexing error message, add __len__ to PlotArea * add polygon selector tool --- fastplotlib/graphics/selectors/__init__.py | 2 + fastplotlib/graphics/selectors/_polygon.py | 138 +++++++++++++++++++++ fastplotlib/layouts/_base.py | 14 ++- fastplotlib/layouts/_plot.py | 17 +++ 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 fastplotlib/graphics/selectors/_polygon.py diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 83162644e..1fb0c453e 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,10 +1,12 @@ from ._linear import LinearSelector from ._linear_region import LinearRegionSelector +from ._polygon import PolygonSelector from ._sync import Synchronizer __all__ = [ "LinearSelector", "LinearRegionSelector", + "PolygonSelector", "Synchronizer", ] diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py new file mode 100644 index 000000000..aee409542 --- /dev/null +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -0,0 +1,138 @@ +from typing import * + +import numpy as np + +import pygfx + +from ._base_selector import BaseSelector, MoveInfo +from .._base import Graphic + + +class PolygonSelector(Graphic, BaseSelector): + def __init__( + self, + edge_color="magenta", + edge_width: float = 3, + parent: Graphic = None, + name: str = None, + ): + Graphic.__init__(self, name=name) + + self.parent = parent + + group = pygfx.Group() + + self._set_world_object(group) + + self.edge_color = edge_color + self.edge_width = edge_width + + self._move_info: MoveInfo = None + + self._current_mode = None + + def get_vertices(self) -> np.ndarray: + """Get the vertices for the polygon""" + vertices = list() + for child in self.world_object.children: + vertices.append(child.geometry.positions.data[:, :2]) + + return np.vstack(vertices) + + def _add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + + # click to add new segment + self._plot_area.renderer.add_event_handler(self._add_segment, "click") + + # pointer move to change endpoint of segment + self._plot_area.renderer.add_event_handler(self._move_segment_endpoint, "pointer_move") + + # click to finish existing segment + self._plot_area.renderer.add_event_handler(self._finish_segment, "click") + + # double click to finish polygon + self._plot_area.renderer.add_event_handler(self._finish_polygon, "double_click") + + self.position_z = len(self._plot_area) + 10 + + def _add_segment(self, ev): + """After click event, adds a new line segment""" + self._current_mode = "add" + + last_position = self._plot_area.map_screen_to_world(ev) + self._move_info = MoveInfo(last_position=last_position, source=None) + + # line with same position for start and end until mouse moves + data = np.array([last_position, last_position]) + + new_line = pygfx.Line( + geometry=pygfx.Geometry(positions=data.astype(np.float32)), + material=pygfx.LineMaterial(thickness=self.edge_width, color=pygfx.Color(self.edge_color)) + ) + + self.world_object.add(new_line) + + def _move_segment_endpoint(self, ev): + """After mouse pointer move event, moves endpoint of current line segment""" + if self._move_info is None: + return + self._current_mode = "move" + + world_pos = self._plot_area.map_screen_to_world(ev) + + if world_pos is None: + return + + # change endpoint + self.world_object.children[-1].geometry.positions.data[1] = np.array([world_pos]).astype(np.float32) + self.world_object.children[-1].geometry.positions.update_range() + + def _finish_segment(self, ev): + """After click event, ends a line segment""" + # should start a new segment + if self._move_info is None: + return + + # since both _add_segment and _finish_segment use the "click" callback + # this is to block _finish_segment right after a _add_segment call + if self._current_mode == "add": + return + + # just make move info None so that _move_segment_endpoint is not called + # and _add_segment gets triggered for "click" + self._move_info = None + + self._current_mode = "finish-segment" + + def _finish_polygon(self, ev): + """finishes the polygon, disconnects events""" + world_pos = self._plot_area.map_screen_to_world(ev) + + if world_pos is None: + return + + # make new line to connect first and last vertices + data = np.vstack([ + world_pos, + self.world_object.children[0].geometry.positions.data[0] + ]) + + print(data) + + new_line = pygfx.Line( + geometry=pygfx.Geometry(positions=data.astype(np.float32)), + material=pygfx.LineMaterial(thickness=self.edge_width, color=pygfx.Color(self.edge_color)) + ) + + self.world_object.add(new_line) + + handlers = { + self._add_segment: "click", + self._move_segment_endpoint: "pointer_move", + self._finish_segment: "click", + self._finish_polygon: "double_click" + } + + for handler, event in handlers.items(): + self._plot_area.renderer.remove_event_handler(handler, event) diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index 5d9d4a6e9..69f50800e 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -246,7 +246,7 @@ def add_graphic(self, graphic: Graphic, center: bool = True): """ self._add_or_insert_graphic(graphic=graphic, center=center, action="add") - graphic.position_z = len(self._graphics) + graphic.position_z = len(self) def insert_graphic( self, @@ -505,10 +505,15 @@ def __getitem__(self, name: str): graphic_names = list() for g in self.graphics: graphic_names.append(g.name) + + selector_names = list() for s in self.selectors: - graphic_names.append(s.name) + selector_names.append(s.name) + raise IndexError( - f"no graphic of given name, the current graphics are:\n {graphic_names}" + f"No graphic or selector of given name.\n" + f"The current graphics are:\n {graphic_names}\n" + f"The current selectors are:\n {selector_names}" ) def __str__(self): @@ -529,3 +534,6 @@ def __repr__(self): f"\t{newline.join(graphic.__repr__() for graphic in self.graphics)}" f"\n" ) + + def __len__(self) -> int: + return len(self._graphics) + len(self.selectors) diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py index 2b5cc51b7..268109abb 100644 --- a/fastplotlib/layouts/_plot.py +++ b/fastplotlib/layouts/_plot.py @@ -11,6 +11,7 @@ from ._subplot import Subplot from ._record_mixin import RecordMixin +from ..graphics.selectors import PolygonSelector class Plot(Subplot, RecordMixin): @@ -211,6 +212,15 @@ def __init__(self, plot: Plot): layout=Layout(width="auto"), tooltip="flip", ) + + self.add_polygon_button = Button( + value=False, + disabled=False, + icon="draw-polygon", + layout=Layout(width="auto"), + tooltip="add PolygonSelector" + ) + self.record_button = ToggleButton( value=False, disabled=False, @@ -226,6 +236,7 @@ def __init__(self, plot: Plot): self.panzoom_controller_button, self.maintain_aspect_button, self.flip_camera_button, + self.add_polygon_button, self.record_button, ] ) @@ -235,6 +246,7 @@ def __init__(self, plot: Plot): self.center_scene_button.on_click(self.center_scene) self.maintain_aspect_button.observe(self.maintain_aspect, "value") self.flip_camera_button.on_click(self.flip_camera) + self.add_polygon_button.on_click(self.add_polygon) self.record_button.observe(self.record_plot, "value") def auto_scale(self, obj): @@ -252,6 +264,11 @@ def maintain_aspect(self, obj): def flip_camera(self, obj): self.plot.camera.world.scale_y *= -1 + def add_polygon(self, obj): + ps = PolygonSelector(edge_width=3, edge_color="magenta") + + self.plot.add_graphic(ps, center=False) + def record_plot(self, obj): if self.record_button.value: try: From 82aab8d34adba3fdb88338830162bb9e7e42606e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Jul 2023 08:36:06 -0400 Subject: [PATCH 18/21] cleanup print --- fastplotlib/graphics/selectors/_polygon.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index aee409542..244ad7b66 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -118,8 +118,6 @@ def _finish_polygon(self, ev): self.world_object.children[0].geometry.positions.data[0] ]) - print(data) - new_line = pygfx.Line( geometry=pygfx.Geometry(positions=data.astype(np.float32)), material=pygfx.LineMaterial(thickness=self.edge_width, color=pygfx.Color(self.edge_color)) From 699ed89f453cfaddc8008ad15a56b2e017e75547 Mon Sep 17 00:00:00 2001 From: Caitlin Lewis <69729525+clewis7@users.noreply.github.com> Date: Tue, 11 Jul 2023 08:50:40 -0400 Subject: [PATCH 19/21] removing outdated code block examples (#281) * remove old code block examples now that we have nbs * remove old docs examples and regenerate mixin class --- fastplotlib/graphics/image.py | 31 ----- fastplotlib/graphics/line_collection.py | 107 -------------- fastplotlib/layouts/_plot.py | 38 ----- fastplotlib/layouts/graphic_methods_mixin.py | 138 ------------------- 4 files changed, 314 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index cb2c4eaf7..d60fa36b2 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -254,20 +254,6 @@ def __init__( **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene - Examples - -------- - .. code-block:: python - - from fastplotlib import Plot - # create a `Plot` instance - plot = Plot() - # make some random 2D image data - data = np.random.rand(512, 512) - # plot the image data - plot.add_image(data=data) - # show the plot - plot.show() - """ super().__init__(*args, **kwargs) @@ -424,23 +410,6 @@ def __init__( **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene - Examples - -------- - .. code-block:: python - - from fastplotlib import Plot - # create a `Plot` instance - plot = Plot() - - # make some random 2D heatmap data - data = np.random.rand(10_000, 8_000) - - # add a heatmap - plot.add_heatmap(data=data) - - # show the plot - plot.show() - """ super().__init__(*args, **kwargs) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index a686a239e..d534fa8d0 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -83,67 +83,8 @@ def __init__( Collections support the same features as the underlying graphic. You just have to slice the selection. - .. code-block:: python - - # slice only the collection - line_collection[10:20].colors = "blue" - - # slice the collection and a feature - line_collection[20:30].colors[10:30] = "red" - - # the data feature also works like this - See :class:`LineGraphic` details on the features. - Examples - -------- - .. code-block:: python - - from fastplotlib import Plot - from fastplotlib.graphics import LineCollection - - # creating data for sine and cosine waves - xs = np.linspace(-10, 10, 100) - ys = np.sin(xs) - - sine = np.dstack([xs, ys])[0] - - ys = np.sin(xs) + 10 - sine2 = np.dstack([xs, ys])[0] - - ys = np.cos(xs) + 5 - cosine = np.dstack([xs, ys])[0] - - # creating plot - plot = Plot() - - # creating a line collection using the sine and cosine wave data - line_collection = LineCollection(data=[sine, cosine, sine2], cmap=["Oranges", "Blues", "Reds"], thickness=20.0) - - # add graphic to plot - plot.add_graphic(line_collection) - - # show plot - plot.show() - - # change the color of the sine wave to white - line_collection[0].colors = "w" - - # change certain color indexes of the cosine data to red - line_collection[1].colors[0:15] = "r" - - # toggle presence of sine2 and rescale graphics - line_collection[2].present = False - - plot.autoscale() - - line_collection[2].present = True - - plot.autoscale() - - # can also do slicing - line_collection[1:].colors[35:70] = "magenta" - """ super(LineCollection, self).__init__(name) @@ -596,56 +537,8 @@ def __init__( Collections support the same features as the underlying graphic. You just have to slice the selection. - .. code-block:: python - - # slice only the collection - line_collection[10:20].colors = "blue" - - # slice the collection and a feature - line_collection[20:30].colors[10:30] = "red" - - # the data feature also works like this - See :class:`LineGraphic` details on the features. - - Examples - -------- - .. code-block:: python - - from fastplotlib import Plot - from fastplotlib.graphics import LineStack - - # create plot - plot = Plot() - - # create line data - xs = np.linspace(-10, 10, 100) - ys = np.sin(xs) - - sine = np.dstack([xs, ys])[0] - - ys = np.sin(xs) - cosine = np.dstack([xs, ys])[0] - - # create line stack - line_stack = LineStack(data=[sine, cosine], cmap=["Oranges", "Blues"], thickness=20.0, separation=5.0) - - # add graphic to plot - plot.add_graphic(line_stack) - - # show plot - plot.show() - - # change the color of the sine wave to white - line_stack[0].colors = "w" - - # change certain color indexes of the cosine data to red - line_stack[1].colors[0:15] = "r" - - # more slicing - line_stack[0].colors[35:70] = "magenta" - """ super(LineStack, self).__init__( data=data, diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py index 268109abb..1f91bb303 100644 --- a/fastplotlib/layouts/_plot.py +++ b/fastplotlib/layouts/_plot.py @@ -48,44 +48,6 @@ def __init__( kwargs passed to Subplot, for example ``name`` - Examples - -------- - - Simple example - - .. code-block:: python - - from fastplotlib import Plot - - # create a `Plot` instance - plot1 = Plot() - - # make some random 2D image data - data = np.random.rand(512, 512) - - # plot the image data - plot1.add_image(data=data) - - # show the plot - plot1.show() - - Sharing controllers, start from the previous example and create a new jupyter cell - - .. code-block:: python - - # use the controller from the previous plot - # this will sync the pan & zoom controller - plot2 = Plot(controller=plot1.controller) - - # make some random 2D image data - data = np.random.rand(512, 512) - - # plot the image data - plot2.add_image(data=data) - - # show the plot - plot2.show() - """ super(Plot, self).__init__( parent=None, diff --git a/fastplotlib/layouts/graphic_methods_mixin.py b/fastplotlib/layouts/graphic_methods_mixin.py index 592329b4e..760083cb9 100644 --- a/fastplotlib/layouts/graphic_methods_mixin.py +++ b/fastplotlib/layouts/graphic_methods_mixin.py @@ -78,23 +78,6 @@ def add_heatmap(self, data: Any, vmin: int = None, vmax: int = None, cmap: str = **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene - Examples - -------- - .. code-block:: python - - from fastplotlib import Plot - # create a `Plot` instance - plot = Plot() - - # make some random 2D heatmap data - data = np.random.rand(10_000, 8_000) - - # add a heatmap - plot.add_heatmap(data=data) - - # show the plot - plot.show() - """ return self._create_graphic(HeatmapGraphic, data, vmin, vmax, cmap, filter, chunk_size, isolated_buffer, *args, **kwargs) @@ -146,20 +129,6 @@ def add_image(self, data: Any, vmin: int = None, vmax: int = None, cmap: str = ' **present**: :class:`.PresentFeature` Control the presence of the Graphic in the scene - Examples - -------- - .. code-block:: python - - from fastplotlib import Plot - # create a `Plot` instance - plot = Plot() - # make some random 2D image data - data = np.random.rand(512, 512) - # plot the image data - plot.add_image(data=data) - # show the plot - plot.show() - """ return self._create_graphic(ImageGraphic, data, vmin, vmax, cmap, filter, isolated_buffer, *args, **kwargs) @@ -218,67 +187,8 @@ def add_line_collection(self, data: List[numpy.ndarray], z_position: Union[List[ Collections support the same features as the underlying graphic. You just have to slice the selection. - .. code-block:: python - - # slice only the collection - line_collection[10:20].colors = "blue" - - # slice the collection and a feature - line_collection[20:30].colors[10:30] = "red" - - # the data feature also works like this - See :class:`LineGraphic` details on the features. - Examples - -------- - .. code-block:: python - - from fastplotlib import Plot - from fastplotlib.graphics import LineCollection - - # creating data for sine and cosine waves - xs = np.linspace(-10, 10, 100) - ys = np.sin(xs) - - sine = np.dstack([xs, ys])[0] - - ys = np.sin(xs) + 10 - sine2 = np.dstack([xs, ys])[0] - - ys = np.cos(xs) + 5 - cosine = np.dstack([xs, ys])[0] - - # creating plot - plot = Plot() - - # creating a line collection using the sine and cosine wave data - line_collection = LineCollection(data=[sine, cosine, sine2], cmap=["Oranges", "Blues", "Reds"], thickness=20.0) - - # add graphic to plot - plot.add_graphic(line_collection) - - # show plot - plot.show() - - # change the color of the sine wave to white - line_collection[0].colors = "w" - - # change certain color indexes of the cosine data to red - line_collection[1].colors[0:15] = "r" - - # toggle presence of sine2 and rescale graphics - line_collection[2].present = False - - plot.autoscale() - - line_collection[2].present = True - - plot.autoscale() - - # can also do slicing - line_collection[1:].colors[35:70] = "magenta" - """ return self._create_graphic(LineCollection, data, z_position, thickness, colors, alpha, cmap, cmap_values, name, metadata, *args, **kwargs) @@ -397,56 +307,8 @@ def add_line_stack(self, data: List[numpy.ndarray], z_position: Union[List[float Collections support the same features as the underlying graphic. You just have to slice the selection. - .. code-block:: python - - # slice only the collection - line_collection[10:20].colors = "blue" - - # slice the collection and a feature - line_collection[20:30].colors[10:30] = "red" - - # the data feature also works like this - See :class:`LineGraphic` details on the features. - - Examples - -------- - .. code-block:: python - - from fastplotlib import Plot - from fastplotlib.graphics import LineStack - - # create plot - plot = Plot() - - # create line data - xs = np.linspace(-10, 10, 100) - ys = np.sin(xs) - - sine = np.dstack([xs, ys])[0] - - ys = np.sin(xs) - cosine = np.dstack([xs, ys])[0] - - # create line stack - line_stack = LineStack(data=[sine, cosine], cmap=["Oranges", "Blues"], thickness=20.0, separation=5.0) - - # add graphic to plot - plot.add_graphic(line_stack) - - # show plot - plot.show() - - # change the color of the sine wave to white - line_stack[0].colors = "w" - - # change certain color indexes of the cosine data to red - line_stack[1].colors[0:15] = "r" - - # more slicing - line_stack[0].colors[35:70] = "magenta" - """ return self._create_graphic(LineStack, data, z_position, thickness, colors, cmap, separation, separation_axis, name, *args, **kwargs) From 28ab63297029f0b655db30640956ff8828c6605f Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 11 Jul 2023 22:23:00 -0400 Subject: [PATCH 20/21] bug fix cmap_str not being set in line collection (#285) --- fastplotlib/graphics/line_collection.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index d534fa8d0..06f260ee7 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -228,6 +228,8 @@ def cmap(self, cmap: str): for i, g in enumerate(self.graphics): g.colors = colors[i] + self._cmap_str = cmap + @property def cmap_values(self) -> np.ndarray: return self._cmap_values From 1aa83ab230da45d97ca4c354ee57cdbab2461a6f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Jul 2023 22:24:08 -0400 Subject: [PATCH 21/21] bump version --- fastplotlib/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/VERSION b/fastplotlib/VERSION index 31af98a76..99bed0205 100644 --- a/fastplotlib/VERSION +++ b/fastplotlib/VERSION @@ -1 +1 @@ -0.1.0.a11 +0.1.0.a12 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy

Alternative Proxy