diff --git a/examples/desktop/image/image_rgbvminvmax.py b/examples/desktop/image/image_rgbvminvmax.py index 9725c038a..56114e1e3 100644 --- a/examples/desktop/image/image_rgbvminvmax.py +++ b/examples/desktop/image/image_rgbvminvmax.py @@ -23,8 +23,8 @@ fig[0, 0].auto_scale() -image_graphic.cmap.vmin = 0.5 -image_graphic.cmap.vmax = 0.75 +image_graphic.vmin = 0.5 +image_graphic.vmax = 0.75 if __name__ == "__main__": diff --git a/examples/desktop/image/image_vminvmax.py b/examples/desktop/image/image_vminvmax.py index 3c8607aef..d24d1f18c 100644 --- a/examples/desktop/image/image_vminvmax.py +++ b/examples/desktop/image/image_vminvmax.py @@ -23,8 +23,8 @@ fig[0, 0].auto_scale() -image_graphic.cmap.vmin = 0.5 -image_graphic.cmap.vmax = 0.75 +image_graphic.vmin = 0.5 +image_graphic.vmax = 0.75 if __name__ == "__main__": diff --git a/examples/desktop/image/image_widget.py b/examples/desktop/image/image_widget.py index 80aafe0b1..ddfc7c68d 100644 --- a/examples/desktop/image/image_widget.py +++ b/examples/desktop/image/image_widget.py @@ -10,7 +10,7 @@ a = iio.imread("imageio:camera.png") -iw = fpl.ImageWidget(data=a, cmap="viridis") +iw = fpl.ImageWidget(data=a, cmap="viridis", histogram_widget=False) iw.show() diff --git a/examples/desktop/line/line_cmap.py b/examples/desktop/line/line_cmap.py index 7d8e1e7d6..0bdc78aaf 100644 --- a/examples/desktop/line/line_cmap.py +++ b/examples/desktop/line/line_cmap.py @@ -35,7 +35,7 @@ data=cosine, thickness=10, cmap="tab10", - cmap_values=cmap_values + cmap_values=np.array(cmap_values) ) fig.show() diff --git a/examples/desktop/line/line_colorslice.py b/examples/desktop/line/line_colorslice.py index 4df666531..25a6329ae 100644 --- a/examples/desktop/line/line_colorslice.py +++ b/examples/desktop/line/line_colorslice.py @@ -53,8 +53,8 @@ key = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 67, 19]) sinc_graphic.colors[key] = "Red" -key2 = np.array([True, False, True, False, True, True, True, True]) -cosine_graphic.colors[key2] = "Green" +#key2 = np.array([True, False, True, False, True, True, True, True]) +#cosine_graphic.colors[key2] = "Green" fig.canvas.set_logical_size(800, 800) diff --git a/examples/notebooks/linear_region_selector.ipynb b/examples/notebooks/linear_region_selector.ipynb index 2ba40ed54..cbe845f71 100644 --- a/examples/notebooks/linear_region_selector.ipynb +++ b/examples/notebooks/linear_region_selector.ipynb @@ -17,7 +17,7 @@ "source": [ "import fastplotlib as fpl\n", "import numpy as np\n", - "from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n", + "# from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n", "\n", "fig = fpl.Figure((2, 2))\n", "\n", @@ -25,11 +25,12 @@ "zoomed_prealloc = 1_000\n", "\n", "# data to plot\n", - "xs = np.linspace(0, 100, 1_000)\n", - "sine = np.sin(xs) * 20\n", + "xs = np.linspace(0, 10* np.pi, 1_000)\n", + "sine = np.sin(xs)\n", + "sine += 100\n", "\n", "# make sine along x axis\n", - "sine_graphic_x = fig[0, 0].add_line(sine)\n", + "sine_graphic_x = fig[0, 0].add_line(np.column_stack([xs, sine]), offset=(10, 0, 0))\n", "\n", "# just something that looks different for line along y-axis\n", "sine_y = sine\n", @@ -47,7 +48,7 @@ "ls_y = sine_graphic_y.add_linear_region_selector(axis=\"y\")\n", "\n", "# preallocate array for storing zoomed in data\n", - "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.random.rand(zoomed_prealloc)])\n", + "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.zeros(zoomed_prealloc)])\n", "\n", "# make line graphics for displaying zoomed data\n", "zoomed_x = fig[1, 0].add_line(zoomed_init)\n", @@ -62,54 +63,54 @@ " # interpolate to preallocated size\n", " return np.interp(x, xp, fp=subdata[:, axis]) # use the y-values\n", "\n", - "\n", + "@ls_x.add_event_handler(\"selection\")\n", "def set_zoom_x(ev):\n", " \"\"\"sets zoomed x selector data\"\"\"\n", - " selected_data = ev.pick_info[\"selected_data\"]\n", - " zoomed_x.data = interpolate(selected_data, axis=1) # use the y-values\n", + " # get the selected data\n", + " selected_data = ev.get_selected_data()\n", + " if selected_data.size == 0:\n", + " # no data selected\n", + " zoomed_x.data[:, 1] = 0\n", + "\n", + " # set the y-values\n", + " zoomed_x.data[:, 1] = interpolate(selected_data, axis=1)\n", " fig[1, 0].auto_scale()\n", "\n", "\n", "def set_zoom_y(ev):\n", - " \"\"\"sets zoomed y selector data\"\"\"\n", - " selected_data = ev.pick_info[\"selected_data\"]\n", - " zoomed_y.data = -interpolate(selected_data, axis=0) # use the x-values\n", + " \"\"\"sets zoomed x selector data\"\"\"\n", + " # get the selected data\n", + " selected_data = ev.get_selected_data()\n", + " if selected_data.size == 0:\n", + " # no data selected\n", + " zoomed_y.data[:, 0] = 0\n", + "\n", + " # set the x-values\n", + " zoomed_y.data[:, 0] = -interpolate(selected_data, axis=1)\n", " fig[1, 1].auto_scale()\n", "\n", "\n", - "# update zoomed plots when bounds change\n", - "ls_x.selection.add_event_handler(set_zoom_x)\n", - "ls_y.selection.add_event_handler(set_zoom_y)\n", - "\n", - "fig.show()" - ] - }, - { - "cell_type": "markdown", - "id": "0bad4a35-f860-4f85-9061-920154ab682b", - "metadata": {}, - "source": [ - "### On the x-axis we have a 1-1 mapping from the data that we have passed and the line geometry positions. So the `bounds` min max corresponds directly to the data indices." + "fig.show(maintain_aspect=False)" ] }, { "cell_type": "code", "execution_count": null, - "id": "2c96a3ff-c2e7-4683-8097-8491e97dd6d3", + "id": "2f29e913-c4f8-44a6-8692-eb14436849a5", "metadata": {}, "outputs": [], "source": [ - "ls_x.selection()" + "sine_graphic_x.data[:, 1].ptp()" ] }, { "cell_type": "code", "execution_count": null, - "id": "3ec71e3f-291c-43c6-a954-0a082ba5981c", + "id": "1947a477-5dd2-4df9-aecd-6967c6ab45fe", "metadata": {}, "outputs": [], "source": [ - "ls_x.get_selected_indices()" + "np.clip(-0.1, 0, 10)" ] }, { diff --git a/examples/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb index e9c8e664a..ee590f20b 100644 --- a/examples/notebooks/linear_selector.ipynb +++ b/examples/notebooks/linear_selector.ipynb @@ -5,7 +5,7 @@ "id": "a06e1fd9-47df-42a3-a76c-19e23d7b89fd", "metadata": {}, "source": [ - "## `LinearSelector`, draggable selector that can optionally associated with an ipywidget." + "## `LinearSelector`, draggable selector that can also be linked to an ipywidget slider" ] }, { @@ -16,7 +16,6 @@ "outputs": [], "source": [ "import fastplotlib as fpl\n", - "from fastplotlib.graphics.selectors import Synchronizer\n", "\n", "import numpy as np\n", "from ipywidgets import VBox, IntSlider, FloatSlider\n", @@ -35,16 +34,14 @@ "selector2 = sine_graphic.add_linear_selector(20)\n", "selector3 = sine_graphic.add_linear_selector(40)\n", "\n", - "ss = Synchronizer(selector, selector2, selector3)\n", - "\n", + "# one of the selectors will change the line colors when it moves\n", + "@selector.add_event_handler(\"selection\")\n", "def set_color_at_index(ev):\n", " # changes the color at the index where the slider is\n", - " ix = ev.pick_info[\"selected_index\"]\n", - " g = ev.pick_info[\"graphic\"].parent\n", + " ix = ev.get_selected_index()\n", + " g = ev.graphic.parent\n", " g.colors[ix] = \"green\"\n", "\n", - "selector.selection.add_event_handler(set_color_at_index)\n", - "\n", "# fastplotlib LineSelector can make an ipywidget slider and return it :D \n", "ipywidget_slider = selector.make_ipywidget_slider()\n", "ipywidget_slider.description = \"slider1\"\n", @@ -57,7 +54,15 @@ "selector3.add_ipywidget_handler(ipywidget_slider3, step=0.1)\n", "\n", "fig[0, 0].auto_scale()\n", - "fig.show(add_widgets=[ipywidget_slider])" + "VBox([fig.show(), ipywidget_slider, ipywidget_slider2, ipywidget_slider3])" + ] + }, + { + "cell_type": "markdown", + "id": "d83caca6-e9b6-45df-b93c-0dfe0498d20e", + "metadata": {}, + "source": [ + "Double click the first selctor, and then use `Shift` + Right/Left Arrow Key to move it!" ] }, { @@ -67,13 +72,16 @@ "metadata": {}, "outputs": [], "source": [ + "# this controls the step-size of arrow key movements\n", "selector.step = 0.1" ] }, { "cell_type": "markdown", "id": "3b0f448f-bbe4-4b87-98e3-093f561c216c", - "metadata": {}, + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, "source": [ "### Drag linear selectors with the mouse, hold \"Shift\" to synchronize movement of all the selectors" ] diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 2a008015e..bb3cd8854 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,15 +1,5 @@ from .line import LineGraphic from .scatter import ScatterGraphic -from .image import ImageGraphic, HeatmapGraphic +from .image import ImageGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack - -__all__ = [ - "ImageGraphic", - "ScatterGraphic", - "LineGraphic", - "HeatmapGraphic", - "LineCollection", - "LineStack", - "TextGraphic", -] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 3a5b043f5..aff4b90e5 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,3 +1,5 @@ +from collections import defaultdict +from functools import partial from typing import Any, Literal, TypeAlias import weakref from warnings import warn @@ -6,17 +8,17 @@ import numpy as np import pylinalg as la +from wgpu.gui.base import log_exception -from pygfx import WorldObject - -from ._features import GraphicFeature, PresentFeature, GraphicFeatureIndexable, Deleted +import pygfx +from ._features import GraphicFeature, BufferManager, Deleted, VertexPositions, VertexColors, VertexCmap, PointsSizesFeature, Name, Offset, Rotation, Visible, UniformColor HexStr: TypeAlias = str # dict that holds all world objects for a given python kernel/session # Graphic objects only use proxies to WorldObjects -WORLD_OBJECTS: dict[HexStr, WorldObject] = dict() #: {hex id str: WorldObject} +WORLD_OBJECTS: dict[HexStr, pygfx.WorldObject] = dict() #: {hex id str: WorldObject} PYGFX_EVENTS = [ @@ -35,9 +37,56 @@ ] -class BaseGraphic: +class Graphic: + features = {} + + @property + def name(self) -> str | None: + """Graphic name""" + return self._name.value + + @name.setter + def name(self, value: str): + self._name.set_value(self, value) + + @property + def offset(self) -> np.ndarray: + """Offset position of the graphic, array: [x, y, z]""" + return self._offset.value + + @offset.setter + def offset(self, value: np.ndarray | list | tuple): + self._offset.set_value(self, value) + + @property + def rotation(self) -> np.ndarray: + """Orientation of the graphic as a quaternion""" + return self._rotation.value + + @rotation.setter + def rotation(self, value: np.ndarray | list | tuple): + self._rotation.set_value(self, value) + + @property + def visible(self) -> bool: + """Whether the graphic is visible""" + return self._visible.value + + @visible.setter + def visible(self, value: bool): + self._visible.set_value(self, value) + + @property + def deleted(self) -> bool: + """used to emit an event after the graphic is deleted""" + return self._deleted.value + + @deleted.setter + def deleted(self, value: bool): + self._deleted.set_value(self, value) + def __init_subclass__(cls, **kwargs): - """set the type of the graphic in lower case like "image", "line_collection", etc.""" + # set the type of the graphic in lower case like "image", "line_collection", etc. cls.type = ( cls.__name__.lower() .replace("graphic", "") @@ -45,22 +94,16 @@ def __init_subclass__(cls, **kwargs): .replace("stack", "_stack") ) + # set of all features + cls.features = {*cls.features, "name", "offset", "rotation", "visible", "deleted"} super().__init_subclass__(**kwargs) - -class Graphic(BaseGraphic): - feature_events = {} - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - # all graphics give off a feature event when deleted - cls.feature_events = {*cls.feature_events, "deleted"} - def __init__( self, name: str = None, + offset: np.ndarray | list | tuple = (0., 0., 0.), + rotation: np.ndarray | list | tuple = (0., 0., 0., 1.), metadata: Any = None, - collection_index: int = None, ): """ @@ -69,6 +112,12 @@ def __init__( name: str, optional name this graphic to use it as a key to access from the plot + offset: (float, float, float), default (0., 0., 0.) + (x, y, z) vector to offset this graphic from the origin + + rotation: (float, float, float, float), default (0, 0, 0, 1) + rotation quaternion + metadata: Any, optional metadata attached to this Graphic, this is for the user to manage @@ -76,116 +125,159 @@ def __init__( if (name is not None) and (not isinstance(name, str)): raise TypeError("Graphic `name` must be of type ") - self._name = name self.metadata = metadata - self.collection_index = collection_index self.registered_callbacks = dict() - self.present = PresentFeature(parent=self) # store hex id str of Graphic instance mem location self._fpl_address: HexStr = hex(id(self)) - self.deleted = Deleted(self, False) - self._plot_area = None - @property - def name(self) -> str | None: - """str name reference for this item""" - return self._name + # event handlers + self._event_handlers = defaultdict(set) - @name.setter - def name(self, name: str): - if self.name == name: - return + # maps callbacks to their partials + self._event_handler_wrappers = defaultdict(set) - if not isinstance(name, str): - raise TypeError("`Graphic` name must be of type ") - - if self._plot_area is not None: - self._plot_area._check_graphic_name_exists(name) - - self._name = name + # all the common features + self._name = Name(name) + self._deleted = Deleted(False) + self._rotation = Rotation(rotation) + self._offset = Offset(offset) + self._visible = Visible(True) @property - def world_object(self) -> WorldObject: + def world_object(self) -> pygfx.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[self._fpl_address]) - def _set_world_object(self, wo: WorldObject): + def _set_world_object(self, wo: pygfx.WorldObject): WORLD_OBJECTS[self._fpl_address] = wo - @property - def position(self) -> np.ndarray: - """position of the graphic, [x, y, z]""" - return self.world_object.world.position + # set offset if it's not (0., 0., 0.) + if not all(self.world_object.world.position == self.offset): + self.offset = self.offset - @property - def position_x(self) -> float: - """x-axis position of the graphic""" - return self.world_object.world.x + # set rotation if it's not (0., 0., 0., 1.) + if not all(self.world_object.world.rotation == self.rotation): + self.rotation = self.rotation - @property - def position_y(self) -> float: - """y-axis position of the graphic""" - return self.world_object.world.y + def detach_feature(self, feature: str): + raise NotImplementedError + + def attach_feature(self, feature: BufferManager): + raise NotImplementedError @property - def position_z(self) -> float: - """z-axis position of the graphic""" - return self.world_object.world.z + def event_handlers(self) -> list[tuple[str, callable, ...]]: + """ + Registered event handlers. Read-only use ``add_event_handler()`` + and ``remove_event_handler()`` to manage callbacks + """ + return list(self._event_handlers.items()) + + def add_event_handler(self, *args): + """ + Register an event handler. - @position.setter - def position(self, val): - self.world_object.world.position = val + Parameters + ---------- + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" - @position_x.setter - def position_x(self, val): - self.world_object.world.x = val + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html - @position_y.setter - def position_y(self, val): - self.world_object.world.y = val + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented - @position_z.setter - def position_z(self, val): - self.world_object.world.z = val + Can also be used as a decorator. - @property - def rotation(self): - return self.world_object.local.rotation + Example + ------- - @rotation.setter - def rotation(self, val): - self.world_object.local.rotation = val + .. code-block:: py - @property - def visible(self) -> bool: - """Access or change the visibility.""" - return self.world_object.visible + def my_handler(event): + print(event) - @visible.setter - def visible(self, v: bool): - """Access or change the visibility.""" - self.world_object.visible = v + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") - @property - def children(self) -> list[WorldObject]: - """Return the children of the WorldObject.""" - return self.world_object.children + Decorator usage example: - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area + .. code-block:: py + + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ + + decorating = not callable(args[0]) + callback = None if decorating else args[0] + types = args if decorating else args[1:] - def __setattr__(self, key, value): - if hasattr(self, key): - attr = getattr(self, key) - if isinstance(attr, GraphicFeature): - attr._set(value) - return + def decorator(_callback): + _callback_injector = partial(self._handle_event, _callback) # adds graphic instance as attribute - super().__setattr__(key, value) + for t in types: + # add to our record + self._event_handlers[t].add(_callback) + + if t in self.features: + # fpl feature event + feature = getattr(self, f"_{t}") + feature.add_event_handler(_callback_injector) + else: + # wrap pygfx event + self.world_object._event_handlers[t].add(_callback_injector) + + # keep track of the partial too + self._event_handler_wrappers[t].add((_callback, _callback_injector)) + return _callback + + if decorating: + return decorator + + return decorator(callback) + + def _handle_event(self, callback, event: pygfx.Event): + """Wrap pygfx event to add graphic to pick_info""" + event.graphic = self + + if event.type in self.features: + # for feature events + event._target = self.world_object + + with log_exception(f"Error during handling {event.type} event"): + callback(event) + + def remove_event_handler(self, callback, *types): + # remove from our record first + for t in types: + for wrapper_map in self._event_handler_wrappers[t]: + # TODO: not sure if we can handle this mapping in a better way + if wrapper_map[0] == callback: + wrapper = wrapper_map[1] + self._event_handler_wrappers[t].remove(wrapper_map) + break + else: + raise KeyError(f"event type: {t} with callback: {callback} is not registered") + + self._event_handlers[t].remove(callback) + # remove callback wrapper from world object if pygfx event + if t in PYGFX_EVENTS: + print("pygfx event") + print(wrapper) + self.world_object.remove_event_handler(wrapper, t) + else: + feature = getattr(self, f"_{t}") + feature.remove_event_handler(wrapper) + + def _fpl_add_plot_area_hook(self, plot_area): + self._plot_area = plot_area def __repr__(self): rval = f"{self.__class__.__name__} @ {hex(id(self))}" @@ -269,127 +361,163 @@ def rotate(self, alpha: float, axis: Literal["x", "y", "z"] = "y"): self.rotation = la.quat_mul(rot, self.rotation) -class Interaction(ABC): - """Mixin class that makes graphics interactive""" +class PositionsGraphic(Graphic): + """Base class for LineGraphic and ScatterGraphic""" - @abstractmethod - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass + @property + def data(self) -> VertexPositions: + """Get or set the vertex positions data""" + return self._data - @abstractmethod - def reset_feature(self, feature: str): - pass + @data.setter + def data(self, value): + self._data[:] = value - def link( - 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`` - of a ``target`` graphic. - - Parameters - ---------- - event_type: str - can be a pygfx event ("key_down", "key_up","pointer_down", "pointer_move", "pointer_up", - "pointer_enter", "pointer_leave", "click", "double_click", "wheel", "close", "resize") - 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) + @property + def colors(self) -> VertexColors | pygfx.Color: + """Get or set the colors data""" + if isinstance(self._colors, VertexColors): + return self._colors - target: Any - graphic to be linked to + elif isinstance(self._colors, UniformColor): + return self._colors.value - feature: str - feature (ex. colors, data, etc.) of the target graphic that will change following - the event + @colors.setter + def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(self._colors, VertexColors): + self._colors[:] = value - new_data: Any - appropriate data that will be changed in the feature of the target graphic after - the event occurs + elif isinstance(self._colors, UniformColor): + self._colors.set_value(self, value) - callback: callable, optional - user-specified callable that will handle event, - the callable must take the following four arguments - | ''source'' - this graphic instance - | ''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 + @property + def cmap(self) -> VertexCmap: + """Control cmap""" + return self._cmap - bidirectional: bool, default False - if True, the target graphic is also linked back to this graphic instance using the - same arguments + @cmap.setter + def cmap(self, name: str): + if self._cmap is None: + raise BufferError("Cannot use cmap with uniform_colors=True") - For example: - .. code-block::python + self._cmap[:] = name - Returns - ------- - None + def __init__( + self, + data: Any, + colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_colors: bool = False, + alpha: float = 1.0, + cmap: str | VertexCmap = None, + cmap_values: np.ndarray = None, + isolated_buffer: bool = True, + *args, + **kwargs, + ): + if isinstance(data, VertexPositions): + self._data = data + else: + self._data = VertexPositions(data, isolated_buffer=isolated_buffer) - """ - if event_type in PYGFX_EVENTS: - self.world_object.add_event_handler(self._event_handler, event_type) + if cmap is not None: + # if a cmap is specified it overrides colors argument + if uniform_colors: + raise TypeError( + "Cannot use cmap if uniform_colors=True" + ) - # make sure event is valid - elif event_type in self.feature_events: - if isinstance(self, GraphicCollection): - feature_instance = getattr(self[:], event_type) + if isinstance(cmap, str): + # make colors from cmap + if isinstance(colors, VertexColors): + # share buffer with existing colors instance for the cmap + self._colors = colors + self._colors._shared += 1 + else: + # create vertex colors buffer + self._colors = VertexColors("w", n_colors=self._data.value.shape[0]) + # make cmap using vertex colors buffer + self._cmap = VertexCmap( + self._colors, + cmap_name=cmap, + cmap_values=cmap_values + ) + elif isinstance(cmap, VertexCmap): + # use existing cmap instance + self._cmap = cmap + self._colors = cmap._vertex_colors + else: + raise TypeError + else: + # no cmap given + if isinstance(colors, VertexColors): + # share buffer with existing colors instance + self._colors = colors + self._colors._shared += 1 + # blank colormap instance + self._cmap = VertexCmap( + self._colors, + cmap_name=None, + cmap_values=None + ) else: - feature_instance = getattr(self, event_type) + if uniform_colors: + self._colors = UniformColor(colors) + self._cmap = None + else: + self._colors = VertexColors( + colors, + n_colors=self._data.value.shape[0], + alpha=alpha, + ) + self._cmap = VertexCmap(self._colors, cmap_name=None, cmap_values=None) + + super().__init__(*args, **kwargs) - feature_instance.add_event_handler(self._event_handler) + def detach_feature(self, feature: str): + if not isinstance(feature, str): + raise TypeError - else: - raise ValueError( - f"Invalid event, valid events are: {PYGFX_EVENTS + self.feature_events}" - ) + f = getattr(self, feature) + if f.shared == 0: + raise BufferError("Cannot detach an independent buffer") - # 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}" - ) + if feature == "colors" and isinstance(feature, VertexColors): + self._colors._buffer = pygfx.Buffer(self._colors.value.copy()) + self.world_object.geometry.colors = self._colors.buffer + self._colors._shared -= 1 - if event_type not in self.registered_callbacks.keys(): - self.registered_callbacks[event_type] = list() + elif feature == "data": + self._data._buffer = pygfx.Buffer(self._data.value.copy()) + self.world_object.geometry.positions = self._data.buffer + self._data._shared -= 1 - callback_data = CallbackData( - target=target, - feature=feature, - new_data=new_data, - callback_function=callback, - ) + elif feature == "sizes": + self._sizes._buffer = pygfx.Buffer(self._sizes.value.copy()) + self.world_object.geometry.positions = self._sizes.buffer + self._sizes._shared -= 1 - 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" - ) - return - - self.registered_callbacks[event_type].append(callback_data) - - if bidirectional: - if event_type in PYGFX_EVENTS: - warn("cannot use bidirectional link for pygfx events") - return - - target.link( - event_type=event_type, - target=self, - feature=feature, - 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. - ) + def attach_feature(self, feature: VertexPositions | VertexColors | PointsSizesFeature): + if isinstance(feature, VertexPositions): + # TODO: check if this causes a memory leak + self._data._shared -= 1 + + self._data = feature + self._data._shared += 1 + self.world_object.geometry.positions = self._data.buffer + + elif isinstance(feature, VertexColors): + self._colors._shared -= 1 + + self._colors = feature + self._colors._shared += 1 + self.world_object.geometry.colors = self._colors.buffer + + elif isinstance(feature, PointsSizesFeature): + self._sizes._shared -= 1 + + self._sizes = feature + self._sizes._shared += 1 + self.world_object.geometry.sizes = self._sizes.buffer def _event_handler(self, event): """Handles the event after it occurs when two graphic have been linked together.""" @@ -433,55 +561,160 @@ def _event_handler(self, event): ) -@dataclass -class CallbackData: - """Class for keeping track of the info necessary for interactivity after event occurs.""" +# Dict that holds all collection graphics in one python instance +COLLECTION_GRAPHICS: dict[HexStr, Graphic] = dict() - target: Any - feature: str - new_data: Any - callback_function: callable = None - def __eq__(self, other): - if not isinstance(other, CallbackData): - raise TypeError("Can only compare against other types") +class CollectionIndexer: + """Collection Indexer""" + @property + def name(self) -> np.ndarray[str | None]: + return np.asarray([g.name for g in self.graphics]) - if other.target is not self.target: - return False + @name.setter + def name(self, values: np.ndarray[str] | list[str]): + self._set_feature("name", values) - if not other.feature == self.feature: - return False + @property + def offset(self) -> np.ndarray: + return np.stack([g.offset for g in self.graphics]) - if not other.new_data == self.new_data: - return False + @offset.setter + def offset(self, values: np.ndarray | list[np.ndarray]): + self._set_feature("offset", values) - if (self.callback_function is None) and (other.callback_function is None): - return True + @property + def rotation(self) -> np.ndarray: + return np.stack([g.rotation for g in self.graphics]) - if other.callback_function is self.callback_function: - return True + @rotation.setter + def rotation(self, values: np.ndarray | list[np.ndarray]): + self._set_feature("rotation", values) - else: - return False + @property + def visible(self) -> np.ndarray[bool]: + return np.asarray([g.visible for g in self.graphics]) + @visible.setter + def visible(self, values: np.ndarray[bool] | list[bool]): + self._set_feature("visible", values) -@dataclass -class PreviouslyModifiedData: - """Class for keeping track of previously modified data at indices""" + # TODO: how to work with deleted feature in a collection - data: Any - indices: Any + def _set_feature(self, feature, values): + if not len(values) == len(self): + raise IndexError + for g, v in zip(self.graphics, values): + setattr(g, feature, v) -# Dict that holds all collection graphics in one python instance -COLLECTION_GRAPHICS: dict[HexStr, Graphic] = dict() + def __init__( + self, + selection: np.ndarray[Graphic], + features: set[str] + ): + """ + + Parameters + ---------- + + selection: np.ndarray of Graphics + array of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` + + """ + + self._selection = selection + self._features = features + + @property + 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 add_event_handler(self, *args): + """ + Register an event handler. + + Parameters + ---------- + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" + + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html + + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented + + Can also be used as a decorator. + + Example + ------- + + .. code-block:: py + + def my_handler(event): + print(event) + + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") + + Decorator usage example: + + .. code-block:: py + + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ + + decorating = not callable(args[0]) + callback = None if decorating else args[0] + types = args if decorating else args[1:] + + if not all(t in set(PYGFX_EVENTS).union(self._features) for t in types): + raise KeyError( + f"event types must be strings for a valid event from the following:\n" + f"{PYGFX_EVENTS + list(self._features)}" + ) + + def decorator(_callback): + for g in self.graphics: + g.add_event_handler(_callback, types) + return _callback + + if decorating: + return decorator + + return decorator(callback) + + def remove_event_handler(self, callback, *types): + for g in self.graphics: + g.remove_event_handler(callback, *types) + + def __getitem__(self, item): + return self.graphics[item] + + 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__}" + ) class GraphicCollection(Graphic): """Graphic Collection base class""" + child_type: type + _indexer: type def __init__(self, name: str = None): super().__init__(name) + + # list of mem locations of the graphics self._graphics: list[str] = list() self._graphics_changed: bool = True @@ -500,7 +733,7 @@ def graphics(self) -> np.ndarray[Graphic]: return self._graphics_array - def add_graphic(self, graphic: Graphic, reset_index: False): + def add_graphic(self, graphic: Graphic): """ Add a graphic to the collection. @@ -509,15 +742,12 @@ def add_graphic(self, graphic: Graphic, reset_index: False): 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: + if not type(graphic) == 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__}, " + f"Can only add graphics of the same type to a collection.\n" + f"You can only add {self.child_type.__name__} to a {self.__class__.__name__}, " f"you are trying to add a {graphic.__class__.__name__}." ) @@ -526,41 +756,38 @@ def add_graphic(self, graphic: Graphic, reset_index: False): self._graphics.append(addr) - if reset_index: - self._reset_index() - elif graphic.collection_index is None: - graphic.collection_index = len(self) - self.world_object.add(graphic.world_object) self._graphics_changed = True - def remove_graphic(self, graphic: Graphic, reset_index: True): + def remove_graphic(self, graphic: Graphic): """ Remove a graphic from the collection. + Note: Only removes the graphic from the collection. Does not remove + the graphic from the scene, and does not delete the graphic. + Parameters ---------- graphic: Graphic graphic to remove - reset_index: bool, default ``False`` - reset the collection index - """ self._graphics.remove(graphic._fpl_address) - if reset_index: - self._reset_index() - self.world_object.remove(graphic.world_object) self._graphics_changed = True - def __getitem__(self, key): - return CollectionIndexer( - parent=self, + def add_event_handler(self, *args): + raise NotImplementedError("Slice graphic collection to add event handlers") + + def remove_event_handler(self, callback, *types): + raise NotImplementedError("Slice graphic collection to remove event handlers") + + def __getitem__(self, key) -> CollectionIndexer: + return self._indexer( selection=self.graphics[key], ) @@ -572,10 +799,6 @@ def __del__(self): super().__del__() - def _reset_index(self): - for new_index, graphic in enumerate(self._graphics): - graphic.collection_index = new_index - def __len__(self): return len(self._graphics) @@ -584,70 +807,10 @@ def __repr__(self): return f"{rval}\nCollection of <{len(self._graphics)}> Graphics" -class CollectionIndexer: - """Collection Indexer""" - - def __init__( - self, - parent: GraphicCollection, - selection: list[Graphic], - ): - """ - - Parameters - ---------- - parent: GraphicCollection - the GraphicCollection object that is being indexed - - selection: list of Graphics - a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` - - """ - - self._parent = weakref.proxy(parent) - self._selection = selection - - # we use parent.graphics[0] instead of selection[0] - # because the selection can be empty - for attr_name in self._parent.graphics[0].__dict__.keys(): - attr = getattr(self._parent.graphics[0], attr_name) - if isinstance(attr, GraphicFeature): - collection_feature = CollectionFeature( - self._selection, feature=attr_name - ) - collection_feature.__doc__ = ( - f"indexable <{attr_name}> feature for collection" - ) - setattr(self, attr_name, collection_feature) - - @property - 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): - if hasattr(self, key): - attr = getattr(self, key) - if isinstance(attr, CollectionFeature): - attr._set(value) - return - - super().__setattr__(key, value) - - 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__}" - ) - - class CollectionFeature: """Collection Feature""" - def __init__(self, selection: list[Graphic], feature: str): + def __init__(self, selection: np.ndarray[Graphic], feature: str): """ selection: list of Graphics a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` @@ -660,50 +823,14 @@ def __init__(self, selection: list[Graphic], feature: str): self._selection = selection self._feature = feature - self._feature_instances: list[GraphicFeature] = list() - - if len(self._selection) > 0: - for graphic in self._selection: - fi = getattr(graphic, self._feature) - self._feature_instances.append(fi) - - if isinstance(fi, GraphicFeatureIndexable): - self._indexable = True - else: - self._indexable = False - else: # it's an empty selection so it doesn't really matter - self._indexable = False - - def _set(self, value): - self[:] = value + self._feature_instances = [getattr(g, feature) for g in self._selection] def __getitem__(self, item): - # only for indexable graphic features return [fi[item] for fi in self._feature_instances] def __setitem__(self, key, value): - if self._indexable: - for fi in self._feature_instances: - fi[key] = value - - else: - for fi in self._feature_instances: - fi._set(value) - - def add_event_handler(self, handler: callable): - """Adds an event handler to each of the selected Graphics from the parent GraphicCollection""" - for fi in self._feature_instances: - fi.add_event_handler(handler) - - def remove_event_handler(self, handler: callable): - """Removes an event handler from each of the selected Graphics of the parent GraphicCollection""" - for fi in self._feature_instances: - fi.remove_event_handler(handler) - - def block_events(self, b: bool): - """Blocks event handling from occurring.""" for fi in self._feature_instances: - fi.block_events(b) + fi[key] = value def __repr__(self): return f"Collection feature for: <{self._feature}>" diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index fb25db287..b2b07fa04 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,33 +1,10 @@ -from ._colors import ColorFeature, CmapFeature, ImageCmapFeature, HeatmapCmapFeature -from ._data import PointsDataFeature, ImageDataFeature, HeatmapDataFeature -from ._sizes import PointsSizesFeature -from ._present import PresentFeature -from ._thickness import ThicknessFeature +from ._positions_graphics import VertexColors, UniformColor, UniformSizes, Thickness, VertexPositions, PointsSizesFeature, VertexCmap +from ._image import TextureArray, ImageCmap, ImageVmin, ImageVmax, ImageInterpolation, ImageCmapInterpolation, WGPU_MAX_TEXTURE_SIZE from ._base import ( GraphicFeature, - GraphicFeatureIndexable, + BufferManager, FeatureEvent, to_gpu_supported_dtype, ) from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature -from ._deleted import Deleted - -__all__ = [ - "ColorFeature", - "CmapFeature", - "ImageCmapFeature", - "HeatmapCmapFeature", - "PointsDataFeature", - "PointsSizesFeature", - "ImageDataFeature", - "HeatmapDataFeature", - "PresentFeature", - "ThicknessFeature", - "GraphicFeature", - "GraphicFeatureIndexable", - "FeatureEvent", - "to_gpu_supported_dtype", - "LinearSelectionFeature", - "LinearRegionSelectionFeature", - "Deleted", -] +from ._common import Name, Offset, Rotation, Visible, Deleted diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 99ebbf436..ebf7dbf15 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -1,14 +1,17 @@ -from abc import ABC, abstractmethod -from inspect import getfullargspec from warnings import warn -from typing import * -import weakref +from typing import Any, Literal import numpy as np +from numpy.typing import NDArray + +from wgpu.gui.base import log_exception import pygfx +WGPU_MAX_TEXTURE_SIZE = 8192 + + supported_dtypes = [ np.uint8, np.uint16, @@ -41,64 +44,42 @@ def to_gpu_supported_dtype(array): return array -class FeatureEvent: +class FeatureEvent(pygfx.Event): """ - Dataclass that holds feature event information. Has ``type`` and ``pick_info`` attributes. - - Attributes - ---------- - type: str, example "colors" - - pick_info: dict: - - ============== ============================================================================= - key value - ============== ============================================================================= - "index" indices where feature data was changed, ``range`` object or ``List[int]`` - "world_object" world object the feature belongs to - "new_data: the new data for this feature - ============== ============================================================================= - - .. note:: - pick info varies between features, this is just the general structure + **All event instances have the following attributes** + + +------------+-------------+-----------------------------------------------+ + | attribute | type | description | + +============+=============+===============================================+ + | type | str | "colors" - name of the event | + +------------+-------------+-----------------------------------------------+ + | graphic | Graphic | graphic instance that the event is from | + +------------+-------------+-----------------------------------------------+ + | info | dict | event info dictionary (see below) | + +------------+-------------+-----------------------------------------------+ + | target | WorldObject | pygfx rendering engine object for the graphic | + +------------+-------------+-----------------------------------------------+ + | time_stamp | float | time when the event occured, in ms | + +------------+-------------+-----------------------------------------------+ """ - def __init__(self, type: str, pick_info: dict): - self.type = type - self.pick_info = pick_info + def __init__(self, type: str, info: dict): + super().__init__(type=type) + self.info = info - def __repr__(self): - return ( - f"{self.__class__.__name__} @ {hex(id(self))}\n" - f"type: {self.type}\n" - f"pick_info: {self.pick_info}\n" - ) - - -class GraphicFeature(ABC): - def __init__(self, parent, data: Any, collection_index: int = None): - # not shown as a docstring so it doesn't show up in the docs - # - # Parameters - # ---------- - # parent - # - # data: Any - # - # 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) - - self._collection_index = collection_index + +class GraphicFeature: + def __init__(self, **kwargs): self._event_handlers = list() self._block_events = False - def __call__(self, *args, **kwargs): - return self._data + @property + def value(self) -> Any: + raise NotImplemented + + def set_value(self, graphic, value: float): + raise NotImplementedError def block_events(self, val: bool): """ @@ -112,21 +93,12 @@ def block_events(self, val: bool): """ self._block_events = val - @abstractmethod - def _set(self, value): - pass - - def _parse_set_value(self, value): - if isinstance(value, GraphicFeature): - return value() - - return 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 a :class:`.FeatureEvent` as the first and only argument. - The ``FeatureEvent`` only has two attributes, ``type`` which denotes the type of event + The ``FeatureEvent`` only has 2 attributes, ``type`` which denotes the type of event as a ``str`` in the form of "", such as "color". And ``pick_info`` which contains information about the event and Graphic that triggered it. @@ -164,196 +136,172 @@ 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 - @abstractmethod - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - """Called whenever a feature changes, and it calls all funcs in self._event_handlers""" - pass - def _call_event_handlers(self, event_data: FeatureEvent): if self._block_events: return for func in self._event_handlers: - try: - args = getfullargspec(func).args - - if len(args) > 0: - if args[0] == "self" and not len(args) > 1: - func() - else: - func(event_data) - else: - func() - except TypeError: - warn( - f"Event handler {func} has an unresolvable argspec, calling it without arguments" - ) - func() - - @abstractmethod - def __repr__(self) -> str: - pass - - -def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]: - """ - - If the key in an `int`, it just returns it. Otherwise, - it parses it and removes the `None` vals and replaces - them with corresponding values that can be used to - create a `range`, get `len` etc. - - Parameters - ---------- - key - upper_bound - - Returns - ------- - - """ - if isinstance(key, int): - return key - - if isinstance(key, np.ndarray): - return cleanup_array_slice(key, upper_bound) - - if isinstance(key, tuple): - # if tuple of slice we only need the first obj - # since the first obj is the datapoint indices - if isinstance(key[0], slice): - key = key[0] + with log_exception(f"Error during handling {self.__class__.__name__} event"): + func(event_data) + + +class BufferManager(GraphicFeature): + """Smaller wrapper for pygfx.Buffer""" + + def __init__( + self, + data: NDArray | pygfx.Buffer, + buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", + isolated_buffer: bool = True, + texture_dim: int = 2, + **kwargs + ): + super().__init__() + if isolated_buffer and not isinstance(data, pygfx.Resource): + # useful if data is read-only, example: memmaps + bdata = np.zeros(data.shape, dtype=data.dtype) + bdata[:] = data[:] else: - raise TypeError("Tuple slicing must have slice object in first position") - - if not isinstance(key, slice): - raise TypeError("Must pass slice or int object") - - start = key.start - stop = key.stop - step = key.step - for attr in [start, stop, step]: - if attr is None: - continue - if attr < 0: - raise IndexError("Negative indexing not supported.") - - if start is None: - start = 0 - - if stop is None: - stop = upper_bound - - elif stop > upper_bound: - raise IndexError( - f"Index: `{stop}` out of bounds for feature array of size: `{upper_bound}`" - ) + # user's input array is used as the buffer + bdata = data + + if isinstance(data, pygfx.Resource): + # already a buffer, probably used for + # managing another BufferManager, example: VertexCmap manages VertexColors + self._buffer = data + elif buffer_type == "buffer": + self._buffer = pygfx.Buffer(bdata) + elif buffer_type == "texture": + self._buffer = pygfx.Texture(bdata, dim=texture_dim) + else: + raise ValueError( + "`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'" + ) - step = key.step - if step is None: - step = 1 + self._event_handlers: list[callable] = list() - return slice(start, stop, step) + self._shared: int = 0 + @property + def value(self) -> NDArray: + return self.buffer.data -def cleanup_array_slice(key: np.ndarray, upper_bound) -> Union[np.ndarray, None]: - """ - Cleanup numpy array used for fancy indexing, make sure key[-1] <= upper_bound. + def set_value(self, graphic, value): + """Sets values on entire array""" + self[:] = value - Returns None if nothing to change. + @property + def buffer(self) -> pygfx.Buffer | pygfx.Texture: + return self._buffer - Parameters - ---------- - key: np.ndarray - integer or boolean array + @property + def shared(self) -> int: + """Number of graphics that share this buffer""" + return self._shared - upper_bound + def __getitem__(self, item): + return self.buffer.data[item] - Returns - ------- - np.ndarray - integer indexing array + def __setitem__(self, key, value): + raise NotImplementedError - """ + def _parse_offset_size(self, key: int | slice | np.ndarray[int | bool] | list[bool | int], upper_bound: int): + """ + parse offset and size for one dimension + """ + if isinstance(key, int): + # simplest case + offset = key + size = 1 + + elif isinstance(key, slice): + # TODO: off-by-one sometimes when step is used + # the offset can be one to the left or the size + # is one extra so it's not really an issue for now + # parse slice + start, stop, step = key.indices(upper_bound) + + # account for backwards indexing + if (start > stop) and step < 0: + offset = stop + else: + offset = start - if key.ndim > 1: - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") + # slice.indices will give -1 if None is passed + # which just means 0 here since buffers do not + # use negative indexing + offset = max(0, offset) - # if boolean array convert to integer array of indices - if key.dtype == bool: - key = np.nonzero(key)[0] + # number of elements to upload + # this is indexing so do not add 1 + size = abs(stop - start) - if key.size < 1: - return None + elif isinstance(key, (np.ndarray, list)): + if isinstance(key, list): + # convert to array + key = np.array(key) - # 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}`" - ) + if not key.ndim == 1: + raise TypeError(key) - # make sure indices are integers - if np.issubdtype(key.dtype, np.integer): - return key + if key.dtype == bool: + # convert bool mask to integer indices + key = np.nonzero(key)[0] - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") + if not np.issubdtype(key.dtype, np.integer): + # fancy indexing doesn't make sense with non-integer types + raise TypeError(key) + if key.size < 1: + # nothing to update + return -class GraphicFeatureIndexable(GraphicFeature): - """An indexable Graphic Feature, colors, data, sizes etc.""" + # convert any negative integer indices to positive indices + key %= upper_bound - def _set(self, value): - value = self._parse_set_value(value) - self[:] = value + # index of first element to upload + offset = key.min() - @abstractmethod - def __getitem__(self, item): - pass + # size range to upload + # add 1 because this is direct + # passing of indices, not a start:stop + size = np.ptp(key) + 1 - @abstractmethod - def __setitem__(self, key, value): - pass + else: + raise TypeError(key) - @abstractmethod - def _update_range(self, key): - pass + return offset, size - @property - @abstractmethod - def buffer(self) -> Union[pygfx.Buffer, pygfx.Texture]: - """Underlying buffer for this feature""" - pass + def _update_range(self, key: int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...]): + """ + Uses key from slicing to determine the offset and + size of the buffer to mark for upload to the GPU + """ + upper_bound = self.value.shape[0] - @property - def _upper_bound(self) -> int: - return self._data.shape[0] + if isinstance(key, tuple): + if any([k is Ellipsis for k in key]): + # let's worry about ellipsis later + raise TypeError("ellipses not supported for indexing buffers") + # if multiple dims are sliced, we only need the key for + # the first dimension corresponding to n_datapoints + key: int | np.ndarray[int | bool] | slice = key[0] - def _update_range_indices(self, key): - """Currently used by colors and positions data""" - if not isinstance(key, np.ndarray): - key = cleanup_slice(key, self._upper_bound) + offset, size = self._parse_offset_size(key, upper_bound) + self.buffer.update_range(offset=offset, size=size) - if isinstance(key, int): - self.buffer.update_range(key, size=1) + def _emit_event(self, type: str, key, value): + if len(self._event_handlers) < 1: return - # else if it's a slice obj - if isinstance(key, slice): - if key.step == 1: # we cleaned up the slice obj so step of None becomes 1 - # update range according to size using the offset - self.buffer.update_range(offset=key.start, size=key.stop - key.start) + event_info = { + "key": key, + "value": value, + } + event = FeatureEvent(type, info=event_info) - else: - step = key.step - # convert slice to indices - ixs = range(key.start, key.stop, step) - for ix in ixs: - self.buffer.update_range(ix, size=1) + self._call_event_handlers(event) - # TODO: See how efficient this is with large indexing - elif isinstance(key, np.ndarray): - self.buffer.update_range() - - else: - raise TypeError("must pass int or slice to update range") + def __repr__(self): + return f"{self.__class__.__name__} buffer data:\n" \ + f"{self.value.__repr__()}" diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py deleted file mode 100644 index 48405e74c..000000000 --- a/fastplotlib/graphics/_features/_colors.py +++ /dev/null @@ -1,434 +0,0 @@ -import numpy as np -import pygfx - -from ...utils import ( - make_colors, - get_cmap_texture, - make_pygfx_colors, - parse_cmap_values, - quick_min_max, -) -from ._base import ( - GraphicFeature, - GraphicFeatureIndexable, - cleanup_slice, - FeatureEvent, - cleanup_array_slice, -) - - -class ColorFeature(GraphicFeatureIndexable): - """ - Manages the color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` - - **event pick info:** - - ==================== =============================== ========================================================================= - key type description - ==================== =============================== ========================================================================= - "index" ``numpy.ndarray`` or ``None`` changed indices in the buffer - "new_data" ``numpy.ndarray`` or ``None`` new buffer data at the changed indices - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== =============================== ========================================================================= - - """ - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.colors - - 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, - ): - """ - ColorFeature - - Parameters - ---------- - parent: Graphic or GraphicCollection - - colors: str, array, or iterable - specify colors as a single human readable string, RGBA array, - or an iterable of strings or RGBA arrays - - n_colors: int - number of colors to hold, if passing in a single str or single RGBA array - - alpha: float - alpha value for the colors - - """ - # if provided as a numpy array of str - if isinstance(colors, np.ndarray): - if colors.dtype.kind in ["U", "S"]: - colors = colors.tolist() - # 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) - # 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: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - data = colors - else: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - - # if the color is provided as an iterable - elif isinstance(colors, (list, tuple, np.ndarray)): - # if iterable of str - if all([isinstance(val, str) for val in colors]): - if not len(colors) == n_colors: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` of `str` " - f"where the length of the iterable is the same as the number of datapoints." - ) - - 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 = pygfx.Color(colors) - data = np.repeat(np.array([c]), n_colors, axis=0) - - else: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " - f"an iterable of `str` with the same length as the number of datapoints." - ) - elif isinstance(colors, str): - if colors == "random": - data = np.random.rand(n_colors, 4) - data[:, -1] = alpha - else: - data = make_pygfx_colors(colors, n_colors) - else: - # assume it's a single color, use pygfx.Color to parse it - data = make_pygfx_colors(colors, n_colors) - - if alpha != 1.0: - data[:, -1] = alpha - - super().__init__(parent, data, collection_index=collection_index) - - def __setitem__(self, key, value): - # parse numerical slice indices - if isinstance(key, slice): - _key = cleanup_slice(key, self._upper_bound) - indices = range(_key.start, _key.stop, _key.step) - - # or single numerical index - elif isinstance(key, (int, np.integer)): - key = cleanup_slice(key, self._upper_bound) - indices = [key] - - elif isinstance(key, tuple): - if not isinstance(value, (float, int, np.ndarray)): - raise ValueError( - "If using multiple-fancy indexing for color, you can only set numerical" - "values since this sets the RGBA array data directly." - ) - - if len(key) != 2: - 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 - - # update range - # first slice obj is going to be the indexing so use key[0] - # key[1] is going to be RGBA so get rid of it to pass to _update_range - # _key = cleanup_slice(key[0], self._upper_bound) - self._update_range(key) - self._feature_changed(key, value) - return - - elif isinstance(key, np.ndarray): - key = cleanup_array_slice(key, self._upper_bound) - if key is None: - return - - indices = key - - else: - raise TypeError( - "Graphic features only support integer and numerical fancy indexing" - ) - - new_data_size = len(indices) - - if not isinstance(value, np.ndarray): - 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 - ) - - # if already a numpy array - elif isinstance(value, np.ndarray): - # if a single color provided as numpy array - if value.shape == (4,): - new_colors = value.astype(np.float32) - # 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 - ) - - 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)" - ) - # 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) - else: - new_colors = value.astype(np.float32) - - else: - raise ValueError( - "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" - ) - - self.buffer.data[key] = new_colors - - self._update_range(key) - self._feature_changed(key, new_colors) - - def _update_range(self, key): - self._update_range_indices(key) - - def _feature_changed(self, key, new_data): - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - else: - raise TypeError("feature changed key must be slice or int") - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="colors", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ColorsFeature for {self._parent}. Call `.colors()` to get values." - return s - - -class CmapFeature(ColorFeature): - """ - Indexable colormap feature, mostly wraps colors and just provides a way to set colormaps. - - Same event pick info as :class:`ColorFeature` - """ - - def __init__(self, parent, colors, cmap_name: str, cmap_values: np.ndarray): - # Skip the ColorFeature's __init__ - super(ColorFeature, self).__init__(parent, colors) - - self._cmap_name = cmap_name - self._cmap_values = cmap_values - - 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." - ) - - if isinstance(key, slice): - n_colors = len(range(key.start, key.stop, key.step)) - - else: - # numpy array - n_colors = key.size - - colors = parse_cmap_values( - n_colors=n_colors, cmap_name=cmap_name, cmap_values=self._cmap_values - ) - - self._cmap_name = cmap_name - super().__setitem__(key, colors) - - @property - def name(self) -> str: - return self._cmap_name - - @property - def values(self) -> np.ndarray: - return self._cmap_values - - @values.setter - def values(self, values: np.ndarray): - if not isinstance(values, np.ndarray): - values = np.array(values) - - colors = parse_cmap_values( - n_colors=self().shape[0], cmap_name=self._cmap_name, cmap_values=values - ) - - self._cmap_values = values - - super().__setitem__(slice(None), colors) - - def __repr__(self) -> str: - s = f"CmapFeature for {self._parent}, to get name or values: `.cmap.name`, `.cmap.values`" - return s - - -class ImageCmapFeature(GraphicFeature): - """ - Colormap for :class:`ImageGraphic`. - - .cmap() returns the Texture buffer for the cmap. - - .cmap.name returns the cmap name as a str. - - **event pick info:** - - ================ =================== =============== - key type description - ================ =================== =============== - "index" ``None`` not used - "name" ``str`` colormap name - "world_object" pygfx.WorldObject world object - "vmin" ``float`` minimum value - "vmax" ``float`` maximum value - ================ =================== =============== - - """ - - def __init__(self, parent, cmap: str): - cmap_texture_view = get_cmap_texture(cmap) - super().__init__(parent, cmap_texture_view) - self._name = cmap - - def _set(self, cmap_name: str): - if self._parent.data().ndim > 2: - return - - self._parent.world_object.material.map.data[:] = make_colors(256, cmap_name) - self._parent.world_object.material.map.update_range((0, 0, 0), size=(256, 1, 1)) - self._name = cmap_name - - self._feature_changed(key=None, new_data=self._name) - - @property - def name(self) -> str: - return self._name - - @property - def vmin(self) -> float: - """Minimum contrast limit.""" - return self._parent.world_object.material.clim[0] - - @vmin.setter - def vmin(self, value: float): - """Minimum contrast limit.""" - self._parent.world_object.material.clim = ( - value, - self._parent.world_object.material.clim[1], - ) - self._feature_changed(key=None, new_data=None) - - @property - def vmax(self) -> float: - """Maximum contrast limit.""" - return self._parent.world_object.material.clim[1] - - @vmax.setter - def vmax(self, value: float): - """Maximum contrast limit.""" - self._parent.world_object.material.clim = ( - self._parent.world_object.material.clim[0], - value, - ) - 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 - - pick_info = { - "index": None, - "world_object": self._parent.world_object, - "name": self._name, - "vmin": self.vmin, - "vmax": self.vmax, - } - - event_data = FeatureEvent(type="cmap", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ImageCmapFeature for {self._parent}. Use `.cmap.name` to get str name of cmap." - return s - - -class HeatmapCmapFeature(ImageCmapFeature): - """ - Colormap for :class:`HeatmapGraphic` - - Same event pick info as :class:`ImageCmapFeature` - """ - - def _set(self, cmap_name: str): - # in heatmap we use one material for all ImageTiles - self._parent._material.map.data[:] = make_colors(256, cmap_name) - self._parent._material.map.update_range((0, 0, 0), size=(256, 1, 1)) - self._name = cmap_name - - self._feature_changed(key=None, new_data=self.name) - - @property - def vmin(self) -> float: - """Minimum contrast limit.""" - return self._parent._material.clim[0] - - @vmin.setter - def vmin(self, value: float): - """Minimum contrast limit.""" - self._parent._material.clim = (value, self._parent._material.clim[1]) - - @property - def vmax(self) -> float: - """Maximum contrast limit.""" - return self._parent._material.clim[1] - - @vmax.setter - def vmax(self, value: float): - """Maximum contrast limit.""" - self._parent._material.clim = (self._parent._material.clim[0], value) diff --git a/fastplotlib/graphics/_features/_common.py b/fastplotlib/graphics/_features/_common.py new file mode 100644 index 000000000..bd2604386 --- /dev/null +++ b/fastplotlib/graphics/_features/_common.py @@ -0,0 +1,116 @@ +import numpy as np + +from ._base import GraphicFeature, FeatureEvent + + +class Name(GraphicFeature): + """Graphic name""" + def __init__(self, value: str): + self._value = value + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + if not isinstance(value, str): + raise TypeError("`Graphic` name must be of type ") + + if graphic._plot_area is not None: + graphic._plot_area._check_graphic_name_exists(value) + + self._value = value + + event = FeatureEvent(type="name", info={"value": value}) + self._call_event_handlers(event) + + +class Offset(GraphicFeature): + """Offset position of the graphic, [x, y, z]""" + def __init__(self, value: np.ndarray | list | tuple): + self._validate(value) + self._value = np.array(value) + self._value.flags.writeable = False + super().__init__() + + def _validate(self, value): + if not len(value) == 3: + raise ValueError("offset must be a list, tuple, or array of 3 float values") + + @property + def value(self) -> np.ndarray: + return self._value + + def set_value(self, graphic, value: np.ndarray | list | tuple): + self._validate(value) + + graphic.world_object.world.position = value + self._value = graphic.world_object.world.position.copy() + self._value.flags.writeable = False + + event = FeatureEvent(type="offset", info={"value": value}) + self._call_event_handlers(event) + + +class Rotation(GraphicFeature): + """Graphic rotation quaternion""" + def __init__(self, value: np.ndarray | list | tuple): + self._validate(value) + self._value = np.array(value) + self._value.flags.writeable = False + super().__init__() + + def _validate(self, value): + if not len(value) == 4: + raise ValueError("rotation quaternion must be a list, tuple, or array of 4 float values") + + @property + def value(self) -> np.ndarray: + return self._value + + def set_value(self, graphic, value: np.ndarray | list | tuple): + self._validate(value) + + graphic.world_object.world.rotation = value + self._value = graphic.world_object.world.rotation.copy() + self._value.flags.writeable = False + + event = FeatureEvent(type="rotation", info={"value": value}) + self._call_event_handlers(event) + + +class Visible(GraphicFeature): + """Access or change the visibility.""" + def __init__(self, value: bool): + self._value = value + super().__init__() + + @property + def value(self) -> bool: + return self._value + + def set_value(self, graphic, value: bool): + graphic.world_object.visible = value + self._value = value + + event = FeatureEvent(type="visible", info={"value": value}) + self._call_event_handlers(event) + + +class Deleted(GraphicFeature): + """ + Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted + """ + def __init__(self, value: bool): + self._value = value + super().__init__() + + @property + def value(self) -> bool: + return self._value + + def set_value(self, graphic, value: bool): + self._value = value + event = FeatureEvent(type="deleted", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py deleted file mode 100644 index bcfe9446a..000000000 --- a/fastplotlib/graphics/_features/_data.py +++ /dev/null @@ -1,219 +0,0 @@ -from typing import * - -import numpy as np - -import pygfx - -from ._base import ( - GraphicFeatureIndexable, - cleanup_slice, - FeatureEvent, - to_gpu_supported_dtype, - cleanup_array_slice, -) - - -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().__init__(parent, data, collection_index=collection_index) - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.positions - - def __getitem__(self, item): - return self.buffer.data[item] - - def _fix_data(self, data, parent): - graphic_type = parent.__class__.__name__ - - data = to_gpu_supported_dtype(data) - - if data.ndim == 1: - # for scatter if we receive just 3 points in a 1d array, treat it as just a single datapoint - # this is different from fix_data for LineGraphic since there we assume that a 1d array - # is just y-values - if graphic_type == "ScatterGraphic": - data = np.array([data]) - elif graphic_type == "LineGraphic": - data = np.dstack([np.arange(data.size, dtype=data.dtype), data])[0] - - if data.shape[1] != 3: - if data.shape[1] != 2: - raise ValueError(f"Must pass 1D, 2D or 3D data to {graphic_type}") - - # zeros for z - zs = np.zeros(data.shape[0], dtype=data.dtype) - - data = np.dstack([data[:, 0], data[:, 1], zs])[0] - - return data - - def __setitem__(self, key, value): - if isinstance(key, np.ndarray): - # make sure 1D array of int or boolean - key = cleanup_array_slice(key, self._upper_bound) - - # put data into right shape if they're only indexing datapoints - if isinstance(key, (slice, int, np.ndarray, np.integer)): - value = self._fix_data(value, self._parent) - # otherwise assume that they have the right shape - # numpy will throw errors if it can't broadcast - - self.buffer.data[key] = value - self._update_range(key) - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self._update_range_indices(key) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, (int, np.integer)): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - elif key is None: - indices = None - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"PointsDataFeature for {self._parent}, call `.data()` to get values" - return s - - -class ImageDataFeature(GraphicFeatureIndexable): - """ - Access to the Texture buffer shown in an ImageGraphic. - """ - - def __init__(self, parent, data: Any): - if data.ndim not in (2, 3): - raise ValueError( - "`data.ndim` must be 2 or 3, ImageGraphic data shape must be " - "``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]``" - ) - - super().__init__(parent, data) - - @property - def buffer(self) -> pygfx.Texture: - """Texture buffer for the image data""" - return self._parent.world_object.geometry.grid - - def update_gpu(self): - """Update the GPU with the buffer""" - self._update_range(None) - - def __call__(self, *args, **kwargs): - return self.buffer.data - - def __getitem__(self, item): - return self.buffer.data[item] - - def __setitem__(self, key, value): - # make sure float32 - value = to_gpu_supported_dtype(value) - - self.buffer.data[key] = value - self._update_range(key) - - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self.buffer.update_range((0, 0, 0), size=self.buffer.size) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif key is None: - indices = None - - pick_info = { - "index": indices, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ImageDataFeature for {self._parent}, call `.data()` to get values" - return s - - -class HeatmapDataFeature(ImageDataFeature): - @property - 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] - - def __getitem__(self, item): - return self._data[item] - - def __call__(self, *args, **kwargs): - return self._data - - def __setitem__(self, key, value): - # make sure supported type, not float64 etc. - value = to_gpu_supported_dtype(value) - - self._data[key] = value - self._update_range(key) - - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - for buffer in self.buffer: - buffer.update_range((0, 0, 0), size=buffer.size) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif key is None: - indices = None - - pick_info = { - "index": indices, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) diff --git a/fastplotlib/graphics/_features/_deleted.py b/fastplotlib/graphics/_features/_deleted.py deleted file mode 100644 index 7900385eb..000000000 --- a/fastplotlib/graphics/_features/_deleted.py +++ /dev/null @@ -1,41 +0,0 @@ -from ._base import GraphicFeature, FeatureEvent - - -class Deleted(GraphicFeature): - """ - Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "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, value: bool): - super().__init__(parent, value) - - def _set(self, value: bool): - value = self._parse_set_value(value) - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="deleted", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"DeletedFeature for {self._parent}" - return s diff --git a/fastplotlib/graphics/_features/_image.py b/fastplotlib/graphics/_features/_image.py new file mode 100644 index 000000000..1c71b8d4a --- /dev/null +++ b/fastplotlib/graphics/_features/_image.py @@ -0,0 +1,209 @@ +from math import ceil + +import numpy as np +from numpy.typing import NDArray + +import pygfx +from ._base import GraphicFeature, FeatureEvent, WGPU_MAX_TEXTURE_SIZE + +from ...utils import ( + make_colors, + get_cmap_texture, +) + +# manages an array of 8192x8192 Textures representing chunks of an image +class TextureArray(GraphicFeature): + + def __init__(self, data, isolated_buffer: bool = True): + super().__init__() + + data = self._fix_data(data) + + if isolated_buffer: + # useful if data is read-only, example: memmaps + self._value = np.zeros(data.shape, dtype=data.dtype) + self.value[:] = data[:] + else: + # user's input array is used as the buffer + self._value = data + + # indices for each Texture + self._row_indices = np.arange(0, ceil(self.value.shape[0] / WGPU_MAX_TEXTURE_SIZE) * WGPU_MAX_TEXTURE_SIZE, WGPU_MAX_TEXTURE_SIZE) + self._col_indices = np.arange(0, ceil(self.value.shape[1] / WGPU_MAX_TEXTURE_SIZE) * WGPU_MAX_TEXTURE_SIZE, WGPU_MAX_TEXTURE_SIZE) + + # buffer will be an array of textures + self._buffer: np.ndarray[pygfx.Texture] = np.empty(shape=(self.row_indices.size, self.col_indices.size), dtype=object) + + # max index + row_max = self.value.shape[0] - 1 + col_max = self.value.shape[1] - 1 + + for (buffer_row, row_ix), (buffer_col, col_ix) in zip(enumerate(self.row_indices), enumerate(self.col_indices)): + # stop index for this chunk + row_stop = min(row_max, row_ix + WGPU_MAX_TEXTURE_SIZE) + col_stop = min(col_max, col_ix + WGPU_MAX_TEXTURE_SIZE) + + # make texture from slice + texture = pygfx.Texture( + self.value[row_ix:row_stop, col_ix:col_stop], dim=2 + ) + + self.buffer[buffer_row, buffer_col] = texture + + self._shared: int = 0 + + @property + def value(self) -> NDArray: + return self._value + + def set_value(self, graphic, value): + self[:] = value + + @property + def buffer(self) -> np.ndarray[pygfx.Texture]: + return self._buffer + + @property + def row_indices(self) -> np.ndarray: + return self._row_indices + + @property + def col_indices(self) -> np.ndarray: + return self._row_indices + + @property + def shared(self) -> int: + return self._shared + + def _fix_data(self, data): + if data.ndim not in (2, 3): + raise ValueError( + "image data must be 2D with or without an RGB(A) dimension, i.e. " + "it must be of shape [x, y], [x, y, 3] or [x, y, 4]" + ) + + # let's just cast to float32 always + return data.astype(np.float32) + + def __getitem__(self, item): + return self.value[item] + + def __setitem__(self, key, value): + self.value[key] = value + + for texture in self.buffer.ravel(): + texture.update_range((0, 0, 0), texture.size) + + event = FeatureEvent("data", info={"key": key, "value": value}) + self._call_event_handlers(event) + + +class ImageVmin(GraphicFeature): + """lower contrast limit""" + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + vmax = graphic._material.clim[1] + graphic._material.clim = (value, vmax) + self._value = value + + event = FeatureEvent(type="vmin", info={"value": value}) + self._call_event_handlers(event) + + +class ImageVmax(GraphicFeature): + """upper contrast limit""" + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + vmin = graphic._material.clim[0] + graphic._material.clim = (vmin, value) + self._value = value + + event = FeatureEvent(type="vmax", info={"value": value}) + self._call_event_handlers(event) + + +class ImageCmap(GraphicFeature): + """colormap for texture""" + def __init__(self, value: str): + self._value = value + self.texture = get_cmap_texture(value) + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + new_colors = make_colors(256, value) + graphic._material.map.data[:] = new_colors + graphic._material.map.update_range((0, 0, 0), size=(256, 1, 1)) + + self._value = value + event = FeatureEvent(type="cmap", info={"value": value}) + self._call_event_handlers(event) + + +class ImageInterpolation(GraphicFeature): + """Image interpolation method""" + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__() + + def _validate(self, value): + if value not in ["nearest", "linear"]: + raise ValueError("`interpolation` must be one of 'nearest' or 'linear'") + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + self._validate(value) + + graphic._material.interpolation = value + + self._value = value + event = FeatureEvent(type="interpolation", info={"value": value}) + self._call_event_handlers(event) + + +class ImageCmapInterpolation(GraphicFeature): + """Image cmap interpolation method""" + + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__() + + def _validate(self, value): + if value not in ["nearest", "linear"]: + raise ValueError("`cmap_interpolation` must be one of 'nearest' or 'linear'") + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + self._validate(value) + + # common material for all image tiles + graphic._material.map_interpolation = value + + self._value = value + event = FeatureEvent(type="cmap_interpolation", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py new file mode 100644 index 000000000..c6a96b709 --- /dev/null +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -0,0 +1,376 @@ +from typing import Any + +import numpy as np +import pygfx + +from ...utils import ( + parse_cmap_values, +) +from ._base import ( + GraphicFeature, + BufferManager, + FeatureEvent, + to_gpu_supported_dtype, +) +from .utils import parse_colors + + +class VertexColors(BufferManager): + """ + + **info dict** + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | dict key | value type | value description | + +============+===========================================================+==================================================================================+ + | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which colors were indexed/sliced | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | value | np.ndarray | new color values for points that were changed, shape is [n_points_changed, RGBA] | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | user_value | str | np.ndarray | tuple[float] | list[float] | list[str] | user input value that was parsed into the RGBA array | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + + """ + + def __init__( + self, + colors: str | np.ndarray | tuple[float] | list[float] | list[str], + n_colors: int, + alpha: float = None, + isolated_buffer: bool = True, + ): + """ + Manages the vertex color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` + + Parameters + ---------- + colors: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float + specify colors as a single human-readable string, RGBA array, + or an iterable of strings or RGBA arrays + + n_colors: int + number of colors, if passing in a single str or single RGBA array + + alpha: float, optional + alpha value for the colors + + """ + data = parse_colors(colors, n_colors, alpha) + + super().__init__(data=data, isolated_buffer=isolated_buffer) + + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], + user_value: str | np.ndarray | tuple[float] | list[float] | list[str] + ): + if isinstance(key, tuple): + # directly setting RGBA values for points, we do no parsing + if not isinstance(user_value, (int, float, np.ndarray)): + raise TypeError( + "Can only set from int, float, or array to set colors directly by slicing the entire array" + ) + value = user_value + + elif isinstance(key, int): + # set color of one point + n_colors = 1 + value = parse_colors(user_value, n_colors) + + elif isinstance(key, slice): + # find n_colors by converting slice to range and then parse colors + start, stop, step = key.indices(self.value.shape[0]) + + n_colors = len(range(start, stop, step)) + + value = parse_colors(user_value, n_colors) + + elif isinstance(key, (np.ndarray, list)): + if isinstance(key, list): + # convert to array + key = np.array(key) + + # make sure it's 1D + if not key.ndim == 1: + raise TypeError("If slicing colors with an array, it must be a 1D bool or int array") + + if key.dtype == bool: + # make sure len is same + if not key.size == self.buffer.data.shape[0]: + raise IndexError + n_colors = np.count_nonzero(key) + + elif np.issubdtype(key.dtype, np.integer): + n_colors = key.size + + else: + raise TypeError("If slicing colors with an array, it must be a 1D bool or int array") + + value = parse_colors(user_value, n_colors) + + else: + raise TypeError + + self.buffer.data[key] = value + + self._update_range(key) + + if len(self._event_handlers) < 1: + return + + event_info = { + "key": key, + "value": value, + "user_value": user_value, + } + event = FeatureEvent("colors", info=event_info) + self._call_event_handlers(event) + + +class UniformColor(GraphicFeature): + def __init__(self, value: str | np.ndarray | tuple | list | pygfx.Color): + self._value = pygfx.Color(value) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): + value = pygfx.Color(value) + graphic.world_object.material.color = value + self._value = value + + event = FeatureEvent(type="colors", info={"value": value}) + self._call_event_handlers(event) + + +class UniformSizes(GraphicFeature): + def __init__(self, value: int | float): + self._value = float(value) + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): + value = pygfx.Color(value) + graphic.world_object.material.size = value + self._value = value + + event = FeatureEvent(type="sizes", info={"value": value}) + self._call_event_handlers(event) + + +class VertexPositions(BufferManager): + """ + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + | dict key | value type | value description | + +==========+==========================================================+==========================================================================================+ + | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which vertex positions data were indexed/sliced | + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + | value | np.ndarray | float | list[float] | new data values for points that were changed, shape depends on the indices that were set | + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + + """ + + def __init__(self, data: Any, isolated_buffer: bool = True): + """ + Manages the vertex positions buffer shown in the graphic. + Supports fancy indexing if the data array also supports it. + """ + + data = self._fix_data(data) + super().__init__(data, isolated_buffer=isolated_buffer) + + def _fix_data(self, data): + # data = to_gpu_supported_dtype(data) + + if data.ndim == 1: + # if user provides a 1D array, assume these are y-values + data = np.column_stack([np.arange(data.size, dtype=data.dtype), data]) + + if data.shape[1] != 3: + if data.shape[1] != 2: + raise ValueError(f"Must pass 1D, 2D or 3D data") + + # zeros for z + zs = np.zeros(data.shape[0], dtype=data.dtype) + + # column stack [x, y, z] to make data of shape [n_points, 3] + data = np.column_stack([data[:, 0], data[:, 1], zs]) + + return to_gpu_supported_dtype(data) + + def __setitem__(self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value: np.ndarray | float | list[float]): + # directly use the key to slice the buffer + self.buffer.data[key] = value + + # _update_range handles parsing the key to + # determine offset and size for GPU upload + self._update_range(key) + + self._emit_event("data", key, value) + + +class PointsSizesFeature(BufferManager): + """ + +----------+-------------------------------------------------------------------+----------------------------------------------+ + | dict key | value type | value description | + +==========+===================================================================+==============================================+ + | key | int | slice | np.ndarray[int | bool] | list[int | bool] | key at which point sizes indexed/sliced | + +----------+-------------------------------------------------------------------+----------------------------------------------+ + | value | int | float | np.ndarray | list[int | float] | tuple[int | float] | new size values for points that were changed | + +----------+-------------------------------------------------------------------+----------------------------------------------+ + """ + + def __init__( + self, + sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], + n_datapoints: int, + isolated_buffer: bool = True + ): + """ + Manages sizes buffer of scatter points. + """ + sizes = self._fix_sizes(sizes, n_datapoints) + super().__init__(data=sizes, isolated_buffer=isolated_buffer) + + def _fix_sizes(self, sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], n_datapoints: int): + if np.issubdtype(type(sizes), np.number): + # single value given + sizes = np.full( + n_datapoints, sizes, dtype=np.float32 + ) # force it into a float to avoid weird gpu errors + + elif isinstance( + sizes, (np.ndarray, tuple, list) + ): # if it's not a ndarray already, make it one + sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32 + if (sizes.ndim != 1) or (sizes.size != n_datapoints): + raise ValueError( + f"sequence of `sizes` must be 1 dimensional with " + f"the same length as the number of datapoints" + ) + + else: + raise TypeError("sizes must be a single , , or a sequence (array, list, tuple) of int" + "or float with the length equal to the number of datapoints") + + if np.count_nonzero(sizes < 0) > 1: + raise ValueError( + "All sizes must be positive numbers greater than or equal to 0.0." + ) + + return sizes + + def __setitem__(self, key: int | slice | np.ndarray[int | bool] | list[int | bool], value: int | float | np.ndarray | list[int | float] | tuple[int | float]): + # this is a very simple 1D buffer, no parsing required, directly set buffer + self.buffer.data[key] = value + self._update_range(key) + + self._emit_event("sizes", key, value) + + +class Thickness(GraphicFeature): + """line thickness""" + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + graphic.world_object.material.thickness = value + self._value = value + + event = FeatureEvent(type="thickness", info={"value": value}) + self._call_event_handlers(event) + + +class VertexCmap(BufferManager): + """ + Sliceable colormap feature, manages a VertexColors instance and just provides a way to set colormaps. + """ + + def __init__(self, vertex_colors: VertexColors, cmap_name: str | None, cmap_values: np.ndarray | None): + super().__init__(data=vertex_colors.buffer) + + self._vertex_colors = vertex_colors + self._cmap_name = cmap_name + self._cmap_values = cmap_values + + if self._cmap_name is not None: + if not isinstance(self._cmap_name, str): + raise TypeError + if self._cmap_values is not None: + if not isinstance(self._cmap_values, np.ndarray): + raise TypeError + + n_datapoints = vertex_colors.value.shape[0] + + colors = parse_cmap_values( + n_colors=n_datapoints, cmap_name=self._cmap_name, cmap_values=self._cmap_values + ) + # set vertex colors from cmap + self._vertex_colors[:] = colors + + def __setitem__(self, key: slice, cmap_name): + if not isinstance(key, slice): + raise TypeError( + "fancy indexing not supported for VertexCmap, only slices " + "of a continuous are supported for apply a cmap" + ) + if key.step is not None: + raise TypeError( + "step sized indexing not currently supported for setting VertexCmap, " + "slices must be a continuous region" + ) + + # parse slice + start, stop, step = key.indices(self.value.shape[0]) + n_elements = len(range(start, stop, step)) + + colors = parse_cmap_values( + n_colors=n_elements, cmap_name=cmap_name, cmap_values=self._cmap_values + ) + + self._cmap_name = cmap_name + self._vertex_colors[key] = colors + + # TODO: should we block vertex_colors from emitting an event? + # Because currently this will result in 2 emitted events, one + # for cmap and another from the colors + self._emit_event("cmap", key, cmap_name) + + @property + def name(self) -> str: + return self._cmap_name + + @property + def values(self) -> np.ndarray: + return self._cmap_values + + @values.setter + def values(self, values: np.ndarray | list[float | int], indices: slice | list | np.ndarray = None): + if self._cmap_name is None: + raise AttributeError( + "cmap is not set, set the cmap before setting the cmap_values" + ) + + values = np.asarray(values) + + colors = parse_cmap_values( + n_colors=self.value.shape[0], cmap_name=self._cmap_name, cmap_values=values + ) + + self._cmap_values = values + + if indices is None: + indices = slice(None) + + self._vertex_colors[indices] = colors + + self._emit_event("cmap.values", indices, values) diff --git a/fastplotlib/graphics/_features/_present.py b/fastplotlib/graphics/_features/_present.py deleted file mode 100644 index a73d66523..000000000 --- a/fastplotlib/graphics/_features/_present.py +++ /dev/null @@ -1,72 +0,0 @@ -from pygfx import Scene, Group - -from ._base import GraphicFeature, FeatureEvent - - -class PresentFeature(GraphicFeature): - """ - Toggles if the object is present in the scene, different from visible. - Useful for computing bounding boxes from the Scene to only include graphics - that are present. - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "index" ``None`` not used - "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): - self._scene = None - super().__init__(parent, present, collection_index) - - def _set(self, present: bool): - present = self._parse_set_value(present) - - i = 0 - wo = self._parent.world_object - while not isinstance(self._scene, (Group, Scene)): - wo_parent = wo.parent - self._scene = wo_parent - wo = wo_parent - i += 1 - - if i > 100: - raise RecursionError( - "Exceeded scene graph depth threshold, cannot find Scene associated with" - "this graphic." - ) - - if present: - if self._parent.world_object not in self._scene.children: - self._scene.add(self._parent.world_object) - - else: - if self._parent.world_object in self._scene.children: - self._scene.remove(self._parent.world_object) - - self._data = present - self._feature_changed(key=None, new_data=present) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="present", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"PresentFeature for {self._parent}, call `.present()` to get values" - return s diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 21e5d0a09..0bf0d1d55 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -1,4 +1,4 @@ -from typing import Tuple, Union, Any +from typing import Sequence import numpy as np @@ -7,196 +7,188 @@ 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 + **additional event attributes:** - **event pick info** + +--------------------+----------+------------------------------------+ + | attribute | type | description | + +====================+==========+====================================+ + | get_selected_index | callable | returns indices under the selector | + +--------------------+----------+------------------------------------+ - =================== =============================== ================================================================================================= - 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 - =================== =============================== ================================================================================================= + **info dict:** + + +----------+------------+-------------------------------+ + | dict key | value type | value description | + +==========+============+===============================+ + | value | np.ndarray | new x or y value of selection | + +----------+------------+-------------------------------+ """ - def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]): - super().__init__(parent, data=value) + def __init__(self, axis: str, value: float, limits: tuple[float, float]): + """ - self._axis = axis - self._limits = limits + Parameters + ---------- + axis: "x" | "y" + axis the selector is restricted to - def _set(self, value: float): - if not (self._limits[0] <= value <= self._limits[1]): - return + value: float + position of the slider in world space, NOT data space + limits: (float, float) + min, max limits of the selector - 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) + super().__init__() - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - if len(self._event_handlers) < 1: - return + self._axis = axis + self._limits = limits + self._value = value + + @property + def value(self) -> float: + """ + selection, data x or y value + """ + return self._value - if self._parent.parent is not None: - g_ix = self._parent.get_selected_index() - else: - g_ix = None + def set_value(self, selector, value: float): + # clip value between limits + value = np.clip(value, self._limits[0], self._limits[1]) - # get pygfx event and reset it - pygfx_ev = self._parent._pygfx_event - self._parent._pygfx_event = None + # set position + if self._axis == "x": + dim = 0 + elif self._axis == "y": + dim = 1 - 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, - } + for edge in selector._edges: + edge.geometry.positions.data[:, dim] = value + edge.geometry.positions.update_range() - event_data = FeatureEvent(type="selection", pick_info=pick_info) + self._value = value - self._call_event_handlers(event_data) + event = FeatureEvent("selection", {"value": value}) + event.get_selected_index = selector.get_selected_index - def __repr__(self) -> str: - s = f"LinearSelectionFeature for {self._parent}" - return s + self._call_event_handlers(event) 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) - ===================== =============================== ======================================================================================= + **additional event attributes:** + + +----------------------+----------+------------------------------------+ + | attribute | type | description | + +======================+==========+====================================+ + | get_selected_indices | callable | returns indices under the selector | + +----------------------+----------+------------------------------------+ + | get_selected_data | callable | returns data under the selector | + +----------------------+----------+------------------------------------+ + + **info dict:** + + +----------+------------+-----------------------------+ + | dict key | value type | value description | + +==========+============+=============================+ + | value | np.ndarray | new [min, max] of selection | + +----------+------------+-----------------------------+ """ def __init__( - self, parent, selection: Tuple[int, int], axis: str, limits: Tuple[int, int] + self, value: tuple[int, int], axis: str, limits: tuple[float, float] ): - super().__init__(parent, data=selection) + super().__init__() self._axis = axis self._limits = limits + self._value = tuple(int(v) for v in value) - self._set(selection) + @property + def value(self) -> np.ndarray[float]: + """ + (min, max) of the selection, in data space + """ + return self._value @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): + def set_value(self, selector, value: Sequence[float]): + """ + Set start, stop range of selector + + Parameters + ---------- + selector: LinearRegionSelector + + value: (float, float) + (min, max) values in data space + + """ + if not len(value) == 2: raise TypeError( - "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " - "where `min_bound` and `max_bound` are numeric values." + "selection must be a array, tuple, list, or sequence in the form of `(min, max)`, " + "where `min` and `max` are numeric values." ) - # make sure bounds not exceeded - for v in value: - if not (self._limits[0] <= v <= self._limits[1]): - return + # convert to array, clip values if they are beyond the limits + value = np.asarray(value, dtype=np.float32).clip(*self._limits) # 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: + if not (value[1] - value[0]) >= 0: return if self.axis == "x": # change left x position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.x_left] = value[0] + selector.fill.geometry.positions.data[mesh_masks.x_left] = value[0] # change right x position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.x_right] = value[1] + selector.fill.geometry.positions.data[mesh_masks.x_right] = value[1] # change x position of the left edge line - self._parent.edges[0].geometry.positions.data[:, 0] = value[0] + selector.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] + selector.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[mesh_masks.y_bottom] = value[0] + selector.fill.geometry.positions.data[mesh_masks.y_bottom] = value[0] # change top position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.y_top] = value[1] + selector.fill.geometry.positions.data[mesh_masks.y_top] = value[1] # change y position of the bottom edge line - self._parent.edges[0].geometry.positions.data[:, 1] = value[0] + selector.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] + selector.edges[1].geometry.positions.data[:, 1] = value[1] - self._data = value # (value[0], value[1]) + self._value = value # 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() + selector.fill.geometry.positions.update_range() - # calls any events - self._feature_changed(key=None, new_data=value) + selector.edges[0].geometry.positions.update_range() + selector.edges[1].geometry.positions.update_range() - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + # send event 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) - - def __repr__(self) -> str: - s = f"LinearRegionSelectionFeature for {self._parent}" - return s + event = FeatureEvent("selection", {"value": self.value}) + + event.get_selected_indices = selector.get_selected_indices + event.get_selected_data = selector.get_selected_data + + self._call_event_handlers(event) + # TODO: user's selector event handlers can call event.graphic.get_selected_indices() to get the data index, + # and event.graphic.get_selected_data() to get the data under the selection + # this is probably a good idea so that the data isn't sliced until it's actually necessary diff --git a/fastplotlib/graphics/_features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py index 2ceeb7862..b28b04f64 100644 --- a/fastplotlib/graphics/_features/_sizes.py +++ b/fastplotlib/graphics/_features/_sizes.py @@ -1,120 +1,3 @@ -from typing import Any -import numpy as np -import pygfx -from ._base import ( - GraphicFeatureIndexable, - cleanup_slice, - FeatureEvent, - to_gpu_supported_dtype, - cleanup_array_slice, -) - - -class PointsSizesFeature(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, sizes: Any, collection_index: int = None): - sizes = self._fix_sizes(sizes, parent) - super().__init__(parent, sizes, collection_index=collection_index) - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.sizes - - def __getitem__(self, item): - return self.buffer.data[item] - - def _fix_sizes(self, sizes, parent): - graphic_type = parent.__class__.__name__ - - n_datapoints = parent.data().shape[0] - if not isinstance(sizes, (list, tuple, np.ndarray)): - sizes = np.full( - n_datapoints, sizes, dtype=np.float32 - ) # force it into a float to avoid weird gpu errors - elif not isinstance( - sizes, np.ndarray - ): # if it's not a ndarray already, make it one - sizes = np.array(sizes, dtype=np.float32) # read it in as a numpy.float32 - if (sizes.ndim != 1) or (sizes.size != parent.data().shape[0]): - raise ValueError( - f"sequence of `sizes` must be 1 dimensional with " - f"the same length as the number of datapoints" - ) - - sizes = to_gpu_supported_dtype(sizes) - - if any(s < 0 for s in sizes): - raise ValueError( - "All sizes must be positive numbers greater than or equal to 0.0." - ) - - if sizes.ndim == 1: - if graphic_type == "ScatterGraphic": - sizes = np.array(sizes) - else: - raise ValueError( - f"Sizes must be an array of shape (n,) where n == the number of data points provided.\ - Received shape={sizes.shape}." - ) - - return np.array(sizes) - - def __setitem__(self, key, value): - if isinstance(key, np.ndarray): - # make sure 1D array of int or boolean - key = cleanup_array_slice(key, self._upper_bound) - - # put sizes into right shape if they're only indexing datapoints - if isinstance(key, (slice, int, np.ndarray, np.integer)): - value = self._fix_sizes(value, self._parent) - # otherwise assume that they have the right shape - # numpy will throw errors if it can't broadcast - - if value.size != self.buffer.data[key].size: - raise ValueError( - f"{value.size} is not equal to buffer size {self.buffer.data[key].size}.\ - If you want to set size to a non-scalar value, make sure it's the right length!" - ) - - self.buffer.data[key] = value - self._update_range(key) - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self._update_range_indices(key) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, (int, np.integer)): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - elif key is None: - indices = None - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="sizes", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"PointsSizesFeature for {self._parent}, call `.sizes()` to get values" - return s diff --git a/fastplotlib/graphics/_features/_thickness.py b/fastplotlib/graphics/_features/_thickness.py deleted file mode 100644 index fc90ef96f..000000000 --- a/fastplotlib/graphics/_features/_thickness.py +++ /dev/null @@ -1,46 +0,0 @@ -from ._base import GraphicFeature, FeatureEvent - - -class ThicknessFeature(GraphicFeature): - """ - Used by Line graphics for line material thickness. - - **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): - self._scene = None - super().__init__(parent, thickness) - - def _set(self, value: float): - value = self._parse_set_value(value) - - self._parent.world_object.material.thickness = value - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="thickness", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ThicknessFeature for {self._parent}, call `.thickness()` to get value" - return s diff --git a/fastplotlib/graphics/_features/utils.py b/fastplotlib/graphics/_features/utils.py new file mode 100644 index 000000000..e2f6e3428 --- /dev/null +++ b/fastplotlib/graphics/_features/utils.py @@ -0,0 +1,87 @@ +import pygfx +import numpy as np + +from ._base import to_gpu_supported_dtype +from ...utils import make_pygfx_colors + + +def parse_colors( + colors: str | np.ndarray | list[str] | tuple[str], + n_colors: int | None, + alpha: float | None = None, +): + """ + + Parameters + ---------- + colors + n_colors + alpha + key + + Returns + ------- + + """ + + # if provided as a numpy array of str + if isinstance(colors, np.ndarray): + if colors.dtype.kind in ["U", "S"]: + colors = colors.tolist() + # 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) + # 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: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + data = colors + else: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + + # if the color is provided as list or tuple + elif isinstance(colors, (list, tuple)): + # if iterable of str + if all([isinstance(val, str) for val in colors]): + if not len(colors) == n_colors: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` of `str` " + f"where the length of the iterable is the same as the number of datapoints." + ) + + 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 = pygfx.Color(colors) + data = np.repeat(np.array([c]), n_colors, axis=0) + + else: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " + f"an iterable of `str` with the same length as the number of datapoints." + ) + elif isinstance(colors, str): + if colors == "random": + data = np.random.rand(n_colors, 4) + data[:, -1] = alpha + else: + data = make_pygfx_colors(colors, n_colors) + else: + # assume it's a single color, use pygfx.Color to parse it + data = make_pygfx_colors(colors, n_colors) + + if alpha is not None: + if isinstance(alpha, float): + data[:, -1] = alpha + else: + raise TypeError("if alpha is provided it must be of type `float`") + + return to_gpu_supported_dtype(data) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index ce736dab2..ea43ee42f 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,202 +1,124 @@ from typing import * -from math import ceil -from itertools import product import weakref -import numpy as np +from numpy.typing import NDArray import pygfx from ..utils import quick_min_max -from ._base import Graphic, Interaction +from ._base import Graphic from .selectors import LinearSelector, LinearRegionSelector from ._features import ( - ImageCmapFeature, - ImageDataFeature, - HeatmapDataFeature, - HeatmapCmapFeature, - to_gpu_supported_dtype, + TextureArray, + ImageCmap, + ImageVmin, + ImageVmax, + ImageInterpolation, + ImageCmapInterpolation, + WGPU_MAX_TEXTURE_SIZE ) -class _AddSelectorsMixin: - def add_linear_selector( - self, selection: int = None, padding: float = None, **kwargs - ) -> LinearSelector: - """ - Adds a :class:`.LinearSelector`. - - Parameters - ---------- - selection: int, optional - initial position of the selector - - padding: float, optional - pad the length of the selector - - kwargs: - passed to :class:`.LinearSelector` - - Returns - ------- - LinearSelector - - """ - - # default padding is 15% the height or width of the image - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" - - ( - 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}" - ) - - selector = LinearSelector( - selection=selection, - limits=limits, - end_points=end_points, - parent=weakref.proxy(self), - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 - - return weakref.proxy(selector) - - def add_linear_region_selector( - self, padding: float = None, **kwargs - ) -> LinearRegionSelector: - """ - Add a :class:`.LinearRegionSelector`. - - Parameters - ---------- - padding: float, optional - Extends the linear selector along the y-axis to make it easier to interact with. - - kwargs: optional - passed to ``LinearRegionSelector`` - - Returns - ------- - LinearRegionSelector - linear selection graphic +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 __init__(self, geometry, material, row_chunk_ix: int, col_chunk_ix: int, **kwargs): + super().__init__(geometry, material, **kwargs) - """ + self._row_chunk_index = row_chunk_ix + self._col_chunk_index = col_chunk_ix - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + def _wgpu_get_pick_info(self, pick_value): + pick_info = super()._wgpu_get_pick_info(pick_value) - # create selector - selector = LinearRegionSelector( - bounds=bounds_init, - limits=limits, - size=size, - origin=origin, - parent=weakref.proxy(self), - fill_color=(0, 0, 0.35, 0.2), - **kwargs, - ) + row_start_ix = WGPU_MAX_TEXTURE_SIZE * self.row_chunk_index + col_start_ix = WGPU_MAX_TEXTURE_SIZE * self.col_chunk_index - self._plot_area.add_graphic(selector, center=False) - # so that it is above this graphic - selector.position_z = self.position_z + 3 + # adjust w.r.t. chunk + x, y = pick_info["index"] + x += col_start_ix + y += row_start_ix + pick_info["index"] = (x, y) - # PlotArea manages this for garbage collection etc. just like all other Graphics - # so we should only work with a proxy on the user-end - return weakref.proxy(selector) + xp, yp = pick_info["pixel_coord"] + xp += col_start_ix + yp += row_start_ix + pick_info["pixel_coord"] = (xp, yp) - # TODO: this method is a bit of a mess, can refactor later - def _get_linear_selector_init_args(self, padding: float, **kwargs): - # computes initial bounds, limits, size and origin of linear selectors - data = self.data() + # add row chunk and col chunk index to pick_info dict + return { + **pick_info, + "row_chunk_index": self.row_chunk_index, + "col_chunk_index": self.col_chunk_index, + } - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" + @property + def row_chunk_index(self) -> int: + return self._row_chunk_index - if padding is None: - if axis == "x": - # based on number of rows - padding = int(data.shape[0] * 0.15) - elif axis == "y": - # based on number of columns - padding = int(data.shape[1] * 0.15) + @property + def col_chunk_index(self) -> int: + return self._col_chunk_index - if axis == "x": - offset = self.position_x - # x limits, number of columns - limits = (offset, data.shape[1] - 1) - # size is number of rows + padding - # used by LinearRegionSelector but not LinearSelector - size = data.shape[0] + padding +class ImageGraphic(Graphic): + features = {"data", "cmap", "vmin", "vmax"} - # initial position of the selector - # center row - position_y = data.shape[0] / 2 + @property + def data(self) -> NDArray: + """Get or set the image data""" + return self._data - # need y offset too for this - origin = (limits[0] - offset, position_y + self.position_y) + @data.setter + def data(self, data): + self._data[:] = data - # endpoints of the data range - # used by linear selector but not linear region - # padding, n_rows + padding - end_points = (0 - padding, data.shape[0] + padding) - else: - offset = self.position_y - # y limits - limits = (offset, data.shape[0] - 1) + @property + def cmap(self) -> str: + """colormap name""" + return self._cmap.value - # width + padding - # used by LinearRegionSelector but not LinearSelector - size = data.shape[1] + padding + @cmap.setter + def cmap(self, name: str): + self._cmap.set_value(self, name) - # initial position of the selector - position_x = data.shape[1] / 2 + @property + def vmin(self) -> float: + """lower contrast limit""" + return self._vmin.value - # need x offset too for this - origin = (position_x + self.position_x, limits[0] - offset) + @vmin.setter + def vmin(self, value: float): + self._vmin.set_value(self, value) - # endpoints of the data range - # used by linear selector but not linear region - end_points = (0 - padding, data.shape[1] + padding) + @property + def vmax(self) -> float: + """upper contrast limit""" + return self._vmax.value - # initial bounds are 20% of the limits range - # used by LinearRegionSelector but not LinearSelector - bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) + @vmax.setter + def vmax(self, value: float): + self._vmax.set_value(self, value) - return bounds_init, limits, size, origin, axis, end_points + @property + def interpolation(self) -> str: + """image data interpolation method""" + return self._interpolation.value - def _add_plot_area_hook(self, plot_area): - self._plot_area = plot_area + @interpolation.setter + def interpolation(self, value: str): + self._interpolation.set_value(self, value) + @property + def cmap_interpolation(self) -> str: + """cmap interpolation method""" + return self._cmap_interpolation.value -class ImageGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = {"data", "cmap", "present"} + @cmap_interpolation.setter + def cmap_interpolation(self, value: str): + self._cmap_interpolation.set_value(self, value) def __init__( self, @@ -204,9 +126,9 @@ def __init__( vmin: int = None, vmax: int = None, cmap: str = "plasma", - filter: str = "nearest", + interpolation: str = "nearest", + cmap_interpolation: str = "linear", isolated_buffer: bool = True, - *args, **kwargs, ): """ @@ -216,8 +138,7 @@ def __init__( ---------- 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]`` + | shape must be ``[x_dim, y_dim]`` vmin: int, optional minimum value for color scaling, calculated from data if not provided @@ -226,29 +147,29 @@ def __init__( 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 + colormap to use to display the data - filter: str, optional, default "nearest" + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, 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 + **data**: :class:`.HeatmapDataFeature` + Manages the data buffer displayed in the HeatmapGraphic - **cmap**: :class:`.ImageCmapFeature` + **cmap**: :class:`.HeatmapCmapFeature` Manages the colormap **present**: :class:`.PresentFeature` @@ -256,235 +177,188 @@ def __init__( """ - super().__init__(*args, **kwargs) + super().__init__(**kwargs) - data = to_gpu_supported_dtype(data) + world_object = pygfx.Group() - # TODO: we need to organize and do this better - if isolated_buffer: - # initialize a buffer with the same shape as the input data - # we do not directly use the input data array as the buffer - # because if the input array is a read-only type, such as - # numpy memmaps, we would not be able to change the image data - buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) - else: - buffer_init = data + self._data = TextureArray(data, isolated_buffer=isolated_buffer) if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) - texture = pygfx.Texture(buffer_init, dim=2) - - geometry = pygfx.Geometry(grid=texture) - - self.cmap = ImageCmapFeature(self, cmap) - - # if data is RGB or RGBA - if data.ndim > 2: - material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), map_interpolation=filter, pick_write=True - ) - # if data is just 2D without color information, use colormap LUT - else: - material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), - map=self.cmap(), - map_interpolation=filter, - pick_write=True, - ) - - world_object = pygfx.Image(geometry, material) + self._vmin = ImageVmin(vmin) + self._vmax = ImageVmax(vmax) - self._set_world_object(world_object) - - self.cmap.vmin = vmin - self.cmap.vmax = vmax - - self.data = ImageDataFeature(self, data) - # TODO: we need to organize and do this better - if isolated_buffer: - # if the buffer was initialized with zeros - # set it with the actual data - self.data = data - - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass - - def reset_feature(self, feature: str): - pass - - -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 - """ + self._cmap = ImageCmap(cmap) - def _wgpu_get_pick_info(self, pick_value): - pick_info = super()._wgpu_get_pick_info(pick_value) + self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) - # add row chunk and col chunk index to pick_info dict - return { - **pick_info, - "row_chunk_index": self.row_chunk_index, - "col_chunk_index": self.col_chunk_index, - } - - @property - def row_chunk_index(self) -> int: - return self._row_chunk_index + self._material = pygfx.ImageBasicMaterial( + clim=(vmin, vmax), + map=self._cmap.texture if self._data.value.ndim == 2 else None, # RGB vs. grayscale + interpolation=self._interpolation.value, + map_interpolation=self._cmap_interpolation.value, + pick_write=True, + ) - @row_chunk_index.setter - def row_chunk_index(self, index: int): - self._row_chunk_index = index + for row_ix in range(self._data.row_indices.size): + for col_ix in range(self._data.col_indices.size): + img = _ImageTile( + geometry=pygfx.Geometry(grid=self._data.buffer[row_ix, col_ix]), + material=self._material, + row_chunk_ix=row_ix, + col_chunk_ix=col_ix + ) - @property - def col_chunk_index(self) -> int: - return self._col_chunk_index + img.world.x = self._data.row_indices[row_ix] + img.world.y = self._data.row_indices[col_ix] - @col_chunk_index.setter - def col_chunk_index(self, index: int): - self._col_chunk_index = index + world_object.add(img) + self._set_world_object(world_object) -class HeatmapGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = {"data", "cmap", "present"} + def reset_vmin_vmax(self): + vmin, vmax = quick_min_max(self._data.value) + self.vmin = vmin + self.vmax = vmax - 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, - ): + def add_linear_selector( + self, selection: int = None, axis: str = "x", padding: float = None, **kwargs + ) -> LinearSelector: """ - Create an Image Graphic + Adds a :class:`.LinearSelector`. 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. + selection: int, optional + initial position of the selector - args: - additional arguments passed to Graphic + padding: float, optional + pad the length of the selector kwargs: - additional keyword arguments passed to Graphic + passed to :class:`.LinearSelector` - Features - -------- + Returns + ------- + LinearSelector - **data**: :class:`.HeatmapDataFeature` - Manages the data buffer displayed in the HeatmapGraphic + """ - **cmap**: :class:`.HeatmapCmapFeature` - Manages the colormap + if axis == "x": + size = self._data.value.shape[0] + center = size / 2 + limits = (0, self._data.value.shape[1]) + elif axis == "y": + size = self._data.value.shape[1] + center = size / 2 + limits = (0, self._data.value.shape[0]) + else: + raise ValueError( + "`axis` must be one of 'x' | 'y'" + ) - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene + # default padding is 25% the height or width of the image + if padding is None: + size *= 1.25 + else: + size += padding - """ + if selection is None: + selection = limits[0] - super().__init__(*args, **kwargs) + if selection < limits[0] or selection > limits[1]: + raise ValueError( + f"the passed selection: {selection} is beyond the limits: {limits}" + ) - if chunk_size > 8192: - raise ValueError("Maximum chunk size is 8192") + selector = LinearSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), + **kwargs, + ) - data = to_gpu_supported_dtype(data) + self._plot_area.add_graphic(selector, center=False) - # TODO: we need to organize and do this better - if isolated_buffer: - # initialize a buffer with the same shape as the input data - # we do not directly use the input data array as the buffer - # because if the input array is a read-only type, such as - # numpy memmaps, we would not be able to change the image data - buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) - else: - buffer_init = data + # place selector above this graphic + selector.offset = selector.offset + (0., 0., self.offset[-1] + 1) - row_chunks = range(ceil(data.shape[0] / chunk_size)) - col_chunks = range(ceil(data.shape[1] / chunk_size)) + return weakref.proxy(selector) - chunks = list(product(row_chunks, col_chunks)) - # chunks is the index position of each chunk + def add_linear_region_selector( + self, selection: tuple[float, float] = None, axis: str = "x", padding: float = 0., fill_color = (0, 0, 0.35, 0.2), **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``. - start_ixs = [list(map(lambda c: c * chunk_size, chunk)) for chunk in chunks] - stop_ixs = [list(map(lambda c: c + chunk_size, chunk)) for chunk in start_ixs] + Parameters + ---------- + selection: (float, float) + initial (min, max) of the selection - world_object = pygfx.Group() - self._set_world_object(world_object) + axis: "x" | "y" + axis the selector can move along - if (vmin is None) or (vmax is None): - vmin, vmax = quick_min_max(data) + padding: float, default 100.0 + Extends the linear selector along the perpendicular axis to make it easier to interact with. - self.cmap = HeatmapCmapFeature(self, cmap) - self._material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), - map=self.cmap(), - map_interpolation=filter, - pick_write=True, - ) + kwargs + passed to ``LinearRegionSelector`` - for start, stop, chunk in zip(start_ixs, stop_ixs, chunks): - row_start, col_start = start - row_stop, col_stop = stop + Returns + ------- + LinearRegionSelector + linear selection graphic - # 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 + if axis == "x": + size = self._data.value.shape[0] + center = size / 2 + limits = (0, self._data.value.shape[1]) + elif axis == "y": + size = self._data.value.shape[1] + center = size / 2 + limits = (0, self._data.value.shape[0]) + else: + raise ValueError( + "`axis` must be one of 'x' | 'y'" ) - geometry = pygfx.Geometry(grid=texture) - # material = pygfx.ImageBasicMaterial(clim=(0, 1), map=self.cmap()) - img = _ImageTile(geometry, self._material) + # default padding is 25% the height or width of the image + if padding is None: + size *= 1.25 + else: + size += padding - # row and column chunk index for this Tile - img.row_chunk_index = chunk[0] - img.col_chunk_index = chunk[1] + if selection is None: + selection = limits[0], int(limits[1] * 0.25) - img.world.x = x_pos - img.world.y = y_pos + if padding is None: + size *= 1.25 - self.world_object.add(img) + else: + size += padding - self.data = HeatmapDataFeature(self, buffer_init) - # TODO: we need to organize and do this better - if isolated_buffer: - # if the buffer was initialized with zeros - # set it with the actual data - self.data = data + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), + **kwargs, + ) - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass + self._plot_area.add_graphic(selector, center=False) + + # place above this graphic + selector.offset = selector.offset + (0., 0., self.offset[-1] + 1) - def reset_feature(self, feature: str): - pass + return weakref.proxy(selector) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 0371fe59b..640a880f2 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,26 +5,33 @@ import pygfx -from ..utils import parse_cmap_values -from ._base import Graphic, Interaction, PreviouslyModifiedData -from ._features import PointsDataFeature, ColorFeature, CmapFeature, ThicknessFeature +from ._base import PositionsGraphic from .selectors import LinearRegionSelector, LinearSelector +from ._features import Thickness -class LineGraphic(Graphic, Interaction): - feature_events = {"data", "colors", "cmap", "thickness", "present"} +class LineGraphic(PositionsGraphic): + features = {"data", "colors", "cmap", "thickness"} + + @property + def thickness(self) -> float: + """Graphic name""" + return self._thickness.value + + @thickness.setter + def thickness(self, value: float): + self._thickness.set_value(self, value) def __init__( self, data: Any, thickness: float = 2.0, colors: str | np.ndarray | Iterable = "w", + uniform_colors: bool = False, alpha: float = 1.0, cmap: str = None, cmap_values: np.ndarray | Iterable = None, - z_position: float = None, - collection_index: int = None, - *args, + isolated_buffer: bool = True, **kwargs, ): """ @@ -55,15 +62,13 @@ def __init__( 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. @@ -81,58 +86,47 @@ def __init__( """ - self.data = PointsDataFeature(self, data, collection_index=collection_index) - - if cmap is not None: - n_datapoints = self.data().shape[0] - - colors = parse_cmap_values( - n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values - ) - - self.colors = ColorFeature( - self, - colors, - n_colors=self.data().shape[0], + super().__init__( + data=data, + colors=colors, + uniform_colors=uniform_colors, alpha=alpha, - collection_index=collection_index, - ) - - self.cmap = CmapFeature( - self, self.colors(), cmap_name=cmap, cmap_values=cmap_values + cmap=cmap, + cmap_values=cmap_values, + isolated_buffer=isolated_buffer, + **kwargs ) - super().__init__(*args, **kwargs) + self._thickness = Thickness(thickness) if thickness < 1.1: - material = pygfx.LineThinMaterial + MaterialCls = pygfx.LineThinMaterial else: - material = pygfx.LineMaterial + MaterialCls = pygfx.LineMaterial - self.thickness = ThicknessFeature(self, thickness) + if uniform_colors: + geometry = pygfx.Geometry(positions=self._data.buffer) + material = MaterialCls(thickness=self.thickness, color_mode="uniform", pick_write=True) + else: + material = MaterialCls(thickness=self.thickness, color_mode="vertex", pick_write=True) + geometry = pygfx.Geometry(positions=self._data.buffer, colors=self._colors.buffer) 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(), color_mode="vertex", pick_write=True - ), + geometry=geometry, + material=material ) self._set_world_object(world_object) - if z_position is not None: - self.position_z = z_position - def add_linear_selector( - self, selection: int = None, padding: float = 50, **kwargs + self, selection: float = None, padding: float = 0., axis: str = "x",**kwargs ) -> LinearSelector: """ Adds a linear selector. Parameters ---------- - selection: int + selection: float initial position of the selector padding: float @@ -147,38 +141,52 @@ def add_linear_selector( """ - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] + + if axis == "x": + # xvals + axis_vals = data[:, 0] + + # yvals to get size and center + magn_vals = data[:, 1] + elif axis == "y": + axis_vals = data[:, 1] + magn_vals = data[:, 0] if selection is None: - selection = limits[0] + selection = axis_vals[0] + limits = axis_vals[0], axis_vals[-1] - if selection < limits[0] or selection > limits[1]: + if not limits[0] <= selection <= limits[1]: raise ValueError( f"the passed selection: {selection} is beyond the limits: {limits}" ) + # width or height of selector + size = int(np.ptp(magn_vals) * 1.5 + padding) + + # center of selector along the other axis + center = np.nanmean(magn_vals) + selector = LinearSelector( selection=selection, limits=limits, - end_points=end_points, - parent=self, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 + + # place selector above this graphic + selector.offset = selector.offset + (0., 0., self.offset[-1] + 1) return weakref.proxy(selector) def add_linear_region_selector( - self, padding: float = 100.0, **kwargs + self, padding: float = 0., axis: str = "x", **kwargs ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -199,28 +207,46 @@ def add_linear_region_selector( """ - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + n_datapoints = self.data.value.shape[0] + value_25p = int(n_datapoints / 4) + + # remove any nans + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] + + if axis == "x": + # xvals + axis_vals = data[:, 0] + + # yvals to get size and center + magn_vals = data[:, 1] + elif axis == "y": + axis_vals = data[:, 1] + magn_vals = data[:, 0] + + bounds_init = axis_vals[0], axis_vals[value_25p] + limits = axis_vals[0], axis_vals[-1] + + # width or height of selector + size = int(np.ptp(magn_vals) * 1.5 + padding) + + # center of selector along the other axis + center = np.nanmean(magn_vals) # create selector selector = LinearRegionSelector( - bounds=bounds_init, + selection=bounds_init, limits=limits, size=size, - origin=origin, - parent=self, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - # so that it is below this graphic - selector.position_z = self.position_z - 1 + + # place selector below this graphic + selector.offset = selector.offset + (0., 0., self.offset[-1] - 1) # PlotArea manages this for garbage collection etc. just like all other Graphics # so we should only work with a proxy on the user-end @@ -229,7 +255,7 @@ def add_linear_region_selector( # TODO: this method is a bit of a mess, can refactor later def _get_linear_selector_init_args(self, padding: float, **kwargs): # computes initial bounds, limits, size and origin of linear selectors - data = self.data() + data = self.data.value if "axis" in kwargs.keys(): axis = kwargs["axis"] @@ -237,7 +263,7 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): axis = "x" if axis == "x": - offset = self.position_x + offset = self.offset[0] # x limits limits = (data[0, 0] + offset, data[-1, 0] + offset) @@ -248,16 +274,16 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): position_y = (data[:, 1].min() + data[:, 1].max()) / 2 # need y offset too for this - origin = (limits[0] - offset, position_y + self.position_y) + origin = (limits[0] - offset, position_y + self.offset[1]) # 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, + self.data.value[:, 1].min() - padding, + self.data.value[:, 1].max() + padding, ) else: - offset = self.position_y + offset = self.offset[1] # y limits limits = (data[0, 1] + offset, data[-1, 1] + offset) @@ -268,11 +294,11 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): position_x = (data[:, 0].min() + data[:, 0].max()) / 2 # need x offset too for this - origin = (position_x + self.position_x, limits[0] - offset) + origin = (position_x + self.offset[0], limits[0] - offset) end_points = ( - self.data()[:, 0].min() - padding, - self.data()[:, 0].max() + padding, + self.data.value[:, 0].min() - padding, + self.data.value[:, 0].max() + padding, ) # initial bounds are 20% of the limits range @@ -280,9 +306,6 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): return bounds_init, limits, size, origin, axis, end_points - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - def set_feature(self, feature: str, new_data: Any, indices: Any = None): if not hasattr(self, "_previous_data"): self._previous_data = dict() diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index da74cc54e..9403bcd08 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -7,27 +7,98 @@ import pygfx from ..utils import parse_cmap_values -from ._base import Interaction, PreviouslyModifiedData, GraphicCollection +from ._base import GraphicCollection, CollectionIndexer, CollectionFeature from ._features import GraphicFeature from .line import LineGraphic from .selectors import LinearRegionSelector, LinearSelector -class LineCollection(GraphicCollection, Interaction): - child_type = LineGraphic.__name__ +class LineSelection(CollectionIndexer): + @property + def colors(self) -> CollectionFeature: + return CollectionFeature(self.graphics, "colors") + + @colors.setter + def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(values, str): + # set colors of all lines to one str color + self.colors[:] = values + return + + elif all(isinstance(v, str) for v in values): + # individual str colors for each line + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.colors = v + + return + + if isinstance(values, np.ndarray): + if values.ndim == 2: + # assume individual colors for each + for g, v in zip(self.graphics, values): + g.colors = v + return + + elif len(values) == 4: + # assume RGBA + self.colors[:] = values + + else: + # assume individual colors for each + for g, v in zip(self.graphics, values): + g.colors = v + + @property + def data(self) -> CollectionFeature: + return CollectionFeature(self.graphics, "data") + + @data.setter + def data(self, values): + self.data[:] = values + + @property + def cmap(self) -> CollectionFeature: + return CollectionFeature(self.graphics, "cmap") + + @cmap.setter + def cmap(self, name: str): + colors = parse_cmap_values( + n_colors=len(self), cmap_name=name + ) + self.colors = colors + + @property + def thickness(self) -> np.ndarray: + return np.asarray([g.thickness for g in self.graphics]) + + @thickness.setter + def thickness(self, values: np.ndarray | list[float]): + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.thickness = v + + +class LineCollection(GraphicCollection): + child_type = LineGraphic + _indexer = LineSelection def __init__( self, data: List[np.ndarray], - z_offset: Iterable[float | int] | float | int = None, - thickness: float | Iterable[float] = 2.0, - colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", + thickness: float | Sequence[float] = 2.0, + colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", + uniform_colors: bool = False, alpha: float = 1.0, - cmap: Iterable[str] | str = None, + cmap: Sequence[str] | str = None, cmap_values: np.ndarray | List = None, name: str = None, - metadata: Iterable[Any] | np.ndarray = None, - *args, + metadata: Sequence[Any] | np.ndarray = None, + isolated_buffer: bool = True, **kwargs, ): """ @@ -39,10 +110,6 @@ def __init__( 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_offset: Iterable of float or float, optional - | if ``float`` | ``int``, single offset will be used for all lines - | if ``list`` of ``float`` | ``int``, each value will apply to the individual lines - thickness: float or Iterable 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 @@ -73,11 +140,8 @@ def __init__( 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 + passed to Graphic Features -------- @@ -90,12 +154,6 @@ def __init__( super().__init__(name) - if not isinstance(z_offset, (float, int)) and z_offset is not None: - if len(data) != len(z_offset): - 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( @@ -178,11 +236,6 @@ def __init__( self._set_world_object(pygfx.Group()) for i, d in enumerate(data): - if isinstance(z_offset, list): - _z = z_offset[i] - else: - _z = z_offset - if isinstance(thickness, list): _s = thickness[i] else: @@ -208,13 +261,14 @@ def __init__( data=d, thickness=_s, colors=_c, - z_position=_z, + uniform_colors=uniform_colors, cmap=_cmap, - collection_index=i, metadata=_m, + isolated_buffer=isolated_buffer, + **kwargs ) - self.add_graphic(lg, reset_index=False) + self.add_graphic(lg) @property def cmap(self) -> str: @@ -268,6 +322,7 @@ def add_linear_selector( LinearSelector """ + # TODO: Use bbox to get size and center for selectors! ( bounds, @@ -330,7 +385,7 @@ def add_linear_region_selector( ) = self._get_linear_selector_init_args(padding, **kwargs) selector = LinearRegionSelector( - bounds=bounds, + selection=bounds, limits=limits, size=size, origin=origin, @@ -478,7 +533,6 @@ class LineStack(LineCollection): def __init__( self, data: List[np.ndarray], - z_offset: Iterable[float] | float = None, thickness: float | Iterable[float] = 2.0, colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", alpha: float = 1.0, @@ -488,7 +542,6 @@ def __init__( metadata: Iterable[Any] | np.ndarray = None, separation: float = 10.0, separation_axis: str = "y", - *args, **kwargs, ): """ @@ -500,10 +553,6 @@ def __init__( 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_offset: Iterable of float or float, optional - | if ``float``, single offset will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - thickness: float or Iterable 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 @@ -550,27 +599,26 @@ def __init__( """ super().__init__( data=data, - z_offset=z_offset, thickness=thickness, colors=colors, alpha=alpha, cmap=cmap, cmap_values=cmap_values, - metadata=metadata, name=name, - *args, + metadata=metadata, **kwargs, ) axis_zero = 0 for i, line in enumerate(self.graphics): if separation_axis == "x": - line.position_x = axis_zero + line.offset = (axis_zero, *line.offset[1:]) + elif separation_axis == "y": - line.position_y = axis_zero + line.offset = (line.offset[0], axis_zero, line.offset[2]) axis_zero = ( - axis_zero + line.data()[:, axes[separation_axis]].max() + separation + axis_zero + line.data.value[:, axes[separation_axis]].max() + separation ) self.separation = separation diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 8682df3d5..a935b8092 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -4,23 +4,41 @@ import pygfx from ..utils import parse_cmap_values -from ._base import Graphic -from ._features import PointsDataFeature, ColorFeature, CmapFeature, PointsSizesFeature +from ._base import PositionsGraphic +from ._features import PointsSizesFeature, UniformSizes -class ScatterGraphic(Graphic): - feature_events = {"data", "sizes", "colors", "cmap", "present"} +class ScatterGraphic(PositionsGraphic): + features = {"data", "sizes", "colors", "cmap"} + + @property + def sizes(self) -> PointsSizesFeature | float: + """Get or set the scatter point size(s)""" + if isinstance(self._sizes, PointsSizesFeature): + return self._sizes + + elif isinstance(self._sizes, UniformSizes): + return self._sizes.value + + @sizes.setter + def sizes(self, value): + if isinstance(self._sizes, PointsSizesFeature): + self._sizes[:] = value + + elif isinstance(self._sizes, UniformSizes): + self._sizes.set_value(self, value) def __init__( self, - data: np.ndarray, - sizes: float | np.ndarray | Iterable[float] = 1, - colors: str | np.ndarray | Iterable[str] = "w", + data: Any, + colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_colors: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: np.ndarray | List = None, - z_position: float = 0.0, - *args, + cmap_values: np.ndarray = None, + isolated_buffer: bool = True, + sizes: float | np.ndarray | Iterable[float] = 1, + uniform_sizes: bool = False, **kwargs, ): """ @@ -51,9 +69,6 @@ def __init__( z_position: float, optional z-axis position for placing the graphic - args - passed to Graphic - kwargs passed to Graphic @@ -73,31 +88,41 @@ def __init__( Control the presence of the Graphic in the scene, set to ``True`` or ``False`` """ - self.data = PointsDataFeature(self, data) - n_datapoints = self.data().shape[0] - - if cmap is not None: - colors = parse_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 + super().__init__( + data=data, + colors=colors, + uniform_colors=uniform_colors, + alpha=alpha, + cmap=cmap, + cmap_values=cmap_values, + isolated_buffer=isolated_buffer, + **kwargs ) - self.sizes = PointsSizesFeature(self, sizes) - super().__init__(*args, **kwargs) + n_datapoints = self.data.value.shape[0] + self._sizes = PointsSizesFeature(sizes, n_datapoints=n_datapoints) + + geo_kwargs = {"positions": self._data.buffer} + material_kwargs = {"pick_write": True} + + if uniform_colors: + material_kwargs["color_mode"] = "uniform" + material_kwargs["color"] = self.colors.value + else: + material_kwargs["color_mode"] = "vertex" + geo_kwargs["colors"] = self.colors.buffer + + if uniform_sizes: + material_kwargs["size_mode"] = "uniform" + material_kwargs["size"] = self.sizes.value + else: + material_kwargs["size_mode"] = "vertex" + geo_kwargs["sizes"] = self.sizes.buffer world_object = pygfx.Points( - pygfx.Geometry( - positions=self.data(), sizes=self.sizes(), colors=self.colors() - ), - material=pygfx.PointsMaterial( - color_mode="vertex", size_mode="vertex", pick_write=True - ), + pygfx.Geometry(**geo_kwargs), + material=pygfx.PointsMaterial(**material_kwargs), ) self._set_world_object(world_object) - - self.position_z = z_position diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 1fb0c453e..6f081448e 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,12 +1,3 @@ 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/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index f20eba4a0..672b54cd1 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -35,7 +35,11 @@ class MoveInfo: # Selector base class class BaseSelector(Graphic): - feature_events = ("selection",) + features = {"selection"} + + @property + def axis(self) -> str: + return self._axis def __init__( self, @@ -45,7 +49,8 @@ def __init__( hover_responsive: Tuple[WorldObject, ...] = None, arrow_keys_modifier: str = None, axis: str = None, - name: str = None, + parent: Graphic = None, + **kwargs, ): if edges is None: edges = tuple() @@ -71,7 +76,7 @@ def __init__( for wo in self._hover_responsive: self._original_colors[wo] = wo.material.color - self.axis = axis + self._axis = axis # current delta in world coordinates self.delta: np.ndarray = None @@ -95,7 +100,9 @@ def __init__( self._pygfx_event = None - Graphic.__init__(self, name=name) + self._parent = parent + + Graphic.__init__(self, **kwargs) def get_selected_index(self): """Not implemented for this selector""" @@ -110,7 +117,7 @@ def get_selected_data(self): raise NotImplementedError def _get_source(self, graphic): - if self.parent is None and graphic is None: + if self._parent is None and graphic is None: raise AttributeError( "No Graphic to apply selector. " "You must either set a ``parent`` Graphic on the selector, or pass a graphic." @@ -120,7 +127,7 @@ def _get_source(self, graphic): if graphic is not None: source = graphic else: - source = self.parent + source = self._parent return source @@ -262,7 +269,7 @@ def _move_to_pointer(self, ev): """ Calculates delta just using current world object position and calls self._move_graphic(). """ - current_position: np.ndarray = self.position + current_position: np.ndarray = self.offset # middle mouse button clicks if ev.button != 3: @@ -348,8 +355,6 @@ def _key_down(self, ev): if ev.key not in key_bind_direction.keys(): return - # print(ev.key) - self._key_move_value = ev.key def _key_up(self, ev): diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 82e553f0a..b1082f5aa 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -17,6 +17,26 @@ class LinearSelector(BaseSelector): + @property + def parent(self) -> Graphic: + return self._parent + + @property + def selection(self) -> float: + """ + x or y value of selector's current position + """ + return self._selection.value + + @selection.setter + def selection(self, value: int): + graphic = self._parent + + if isinstance(graphic, GraphicCollection): + pass + + self._selection.set_value(self, value) + @property def limits(self) -> Tuple[float, float]: return self._limits @@ -35,14 +55,15 @@ def limits(self, values: Tuple[float, float]): # TODO: make `selection` arg in graphics data space not world space def __init__( self, - selection: int, - limits: Tuple[int, int], + selection: float, + limits: Sequence[float], + size: float, + center: float, axis: str = "x", parent: Graphic = None, - end_points: Tuple[int, int] = None, - arrow_keys_modifier: str = "Shift", + color: str | tuple = "w", thickness: float = 2.5, - color: Any = "w", + arrow_keys_modifier: str = "Shift", name: str = None, ): """ @@ -79,34 +100,23 @@ def __init__( name: str, optional name of line slider - Features - -------- - - 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: raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)") - self._limits = tuple(map(round, limits)) + self._limits = np.asarray(limits) - selection = round(selection) + end_points = [-size / 2, size / 2] if axis == "x": - xs = np.zeros(2) + xs = np.array([selection, selection]) ys = np.array(end_points) zs = np.zeros(2) line_data = np.column_stack([xs, ys, zs]) elif axis == "y": xs = np.array(end_points) - ys = np.zeros(2) + ys = np.array([selection, selection]) zs = np.zeros(2) line_data = np.column_stack([xs, ys, zs]) @@ -144,12 +154,15 @@ def __init__( self._move_info: dict = None - self.parent = parent - self._block_ipywidget_call = False self._handled_widgets = list() + if axis == "x": + offset = (parent.offset[0], center, 0) + elif axis == "y": + offset = (center, parent.offset[1], 0) + # init base selector BaseSelector.__init__( self, @@ -157,20 +170,28 @@ def __init__( hover_responsive=(line_inner, self.line_outer), arrow_keys_modifier=arrow_keys_modifier, axis=axis, + parent=parent, name=name, + offset=offset ) self._set_world_object(world_object) - self.selection = LinearSelectionFeature( - self, axis=axis, value=selection, limits=self._limits + self._selection = LinearSelectionFeature( + axis=axis, value=selection, limits=self._limits ) - self.selection = selection + if self._parent is not None: + self.selection = selection + else: + self._selection.set_value(self, selection) + + # update any ipywidgets + self.add_event_handler(self._update_ipywidgets, "selection") def _setup_ipywidget_slider(self, widget): # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection() + value = self.selection if isinstance(widget, ipywidgets.IntSlider): value = int(value) @@ -180,16 +201,13 @@ def _setup_ipywidget_slider(self, widget): # user changes widget -> linear selection changes widget.observe(self._ipywidget_callback, "value") - # user changes linear selection -> widget changes - self.selection.add_event_handler(self._update_ipywidgets) - self._handled_widgets.append(widget) def _update_ipywidgets(self, ev): # update the ipywidget sliders when LinearSelector value changes self._block_ipywidget_call = True # prevent infinite recursion - value = ev.pick_info["new_data"] + value = ev.info["value"] # update all the handled slider widgets for widget in self._handled_widgets: if isinstance(widget, ipywidgets.IntSlider): @@ -200,7 +218,7 @@ def _update_ipywidgets(self, ev): self._block_ipywidget_call = False def _ipywidget_callback(self, change): - # update the LinearSelector if the ipywidget value changes + # update the LinearSelector when the ipywidget value changes if self._block_ipywidget_call or self._moving: return @@ -249,9 +267,9 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): cls = getattr(ipywidgets, kind) - value = self.selection() + value = self.selection if "Int" in kind: - value = int(self.selection()) + value = int(self.selection) slider = cls( min=self.limits[0], @@ -327,34 +345,29 @@ def get_selected_index(self, graphic: Graphic = None) -> Union[int, List[int]]: def _get_selected_index(self, graphic): # the array to search for the closest value along that axis if self.axis == "x": - geo_positions = graphic.data()[:, 0] - offset = getattr(graphic, f"position_{self.axis}") - else: - geo_positions = graphic.data()[:, 1] - offset = getattr(graphic, f"position_{self.axis}") + data = graphic.data[:, 0] + elif self.axis == "y": + data = graphic.data[:, 1] - if "Line" in graphic.__class__.__name__: - # we want to find the index of the geometry position that is closest to the slider's geometry position - find_value = self.selection() - offset + if "Line" in graphic.__class__.__name__ or "Scatter" in graphic.__class__.__name__: + # we want to find the index of the data closest to the slider position + find_value = self.selection # get closest data index to the world space position of the slider - idx = np.searchsorted(geo_positions, find_value, side="left") + idx = np.searchsorted(data, 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]) + idx == len(data) + or math.fabs(find_value - data[idx - 1]) + < math.fabs(find_value - data[idx]) ): return round(idx - 1) else: return round(idx) - if ( - "Heatmap" in graphic.__class__.__name__ - or "Image" in graphic.__class__.__name__ - ): + if "Image" in graphic.__class__.__name__: # indices map directly to grid geometry for image data buffer - index = self.selection() - offset + index = self.selection return round(index) def _move_graphic(self, delta: np.ndarray): @@ -369,9 +382,9 @@ def _move_graphic(self, delta: np.ndarray): """ if self.axis == "x": - self.selection = self.selection() + delta[0] + self.selection = self.selection + delta[0] else: - self.selection = self.selection() + delta[1] + self.selection = self.selection + delta[1] def _fpl_cleanup(self): for widget in self._handled_widgets: diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 09c134800..c6c40fa88 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -16,6 +16,35 @@ class LinearRegionSelector(BaseSelector): + @property + def parent(self) -> Graphic | None: + """graphic that the selector is associated with""" + return self._parent + + @property + def selection(self) -> Sequence[float] | List[Sequence[float]]: + """ + (min, max) of data value along selector's axis + """ + # TODO: This probably does not account for rotation since world.position + # does not account for rotation, we can do this later + + return self._selection.value.copy() + + # TODO: if no parent graphic is set, this just returns world positions + # but should we change it? + # return self._selection.value + + @selection.setter + def selection(self, selection: Sequence[float]): + # set (xmin, xmax), or (ymin, ymax) of the selector in data space + graphic = self._parent + + if isinstance(graphic, GraphicCollection): + pass + + self._selection.set_value(self, selection) + @property def limits(self) -> Tuple[float, float]: return self._limits @@ -32,51 +61,47 @@ def limits(self, values: Tuple[float, float]): self.selection._limits = self._limits 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), - edge_thickness: int = 3, - arrow_keys_modifier: str = "Shift", - name: str = None, + self, + selection: Sequence[float], + limits: Sequence[float], + size: int, + center: float, + axis: str = "x", + parent: Graphic = None, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 8, + 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. - Allows sub-selecting data from a ``Graphic`` or from multiple Graphics. - - bounds[0], limits[0], and position[0] must be identical. - - Holding the right mouse button while dragging an edge will force the entire region selector to move. This is - a when using transparent fill areas due to ``pygfx`` picking limitations. + Allows sub-selecting data from a parent ``Graphic`` or from multiple Graphics. - **Note:** Events get very weird if the values of bounds, limits and origin are close to zero. If you need - a linear selector with small data, we recommend scaling the data and then using the selector. + Assumes that the data under the selector is a function of the axis on which the selector moves + along. Example: if the selector is along the x-axis, then there must be only one y-value for each + x-value, otherwise functions such as ``get_selected_data()`` do not make sense. Parameters ---------- - bounds: (int, int) - the initial bounds of the linear selector + selection: (float, float) + initial (min, max) x or y values - limits: (int, int) - (min limit, max limit) for the selector + limits: (float, float) + (min limit, max limit) within which the selector can move size: int height or width of the selector - origin: (int, int) - initial position of the selector + center: float + center offset of the selector, by default the data mean axis: str, default "x" - "x" | "y", axis for the selector + "x" | "y", axis the selected can move on parent: Graphic, default ``None`` - associate this selector with a parent Graphic + associate this selector with a parent Graphic from which to fetch data or indices resizable: bool if ``True``, the edges can be dragged to resize the width of the linear selection @@ -87,6 +112,9 @@ def __init__( edge_color: str, array, or tuple edge color for the selector, passed to pygfx.Color + edge_thickness: float, default 8 + edge thickness + arrow_keys_modifier: str modifier key that must be pressed to initiate movement using arrow keys, must be one of: "Control", "Shift", "Alt" or ``None`` @@ -94,46 +122,22 @@ 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, otherwise things get weird - bounds = tuple(map(round, bounds)) - self._limits = tuple(map(round, limits)) - origin = tuple(map(round, origin)) + if not len(selection) == 2: + raise ValueError + + selection = np.asarray(selection) + + if not len(limits) == 2: + raise ValueError + + self._limits = np.asarray(limits) # TODO: sanity checks, we recommend users to add LinearSelection using the add_linear_selector() methods # TODO: so we can worry about the sanity checks later - # if axis == "x": - # if limits[0] != origin[0] != bounds[0]: - # raise ValueError( - # f"limits[0] != position[0] != bounds[0]\n" - # f"{limits[0]} != {origin[0]} != {bounds[0]}" - # ) - # - # elif axis == "y": - # # initial y-position is position[1] - # if limits[0] != origin[1] != bounds[0]: - # raise ValueError( - # f"limits[0] != position[1] != bounds[0]\n" - # f"{limits[0]} != {origin[1]} != {bounds[0]}" - # ) - - self.parent = parent - - # world object for this will be a group - # basic mesh for the fill area of the selector - # line for each edge of the selector + group = pygfx.Group() if axis == "x": @@ -152,89 +156,69 @@ def __init__( # the fill of the selection self.fill = mesh - self.fill.world.position = (*origin, -2) + # no x, y offsets for linear region selector + # everything is done by setting the mesh data + # and line positions + self.fill.world.position = (0, 0, -2) group.add(self.fill) self._resizable = resizable 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], - ] - ).astype(np.float32) - - left_line = pygfx.Line( - pygfx.Geometry(positions=left_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - # position data for the right edge line - right_line_data = np.array( + # just some data to initialize the edge lines + init_line_data = np.array( [ - [bounds[1], (-size / 2) + origin[1], 0.5], - [bounds[1], (size / 2) + origin[1], 0.5], + [0, -size / 2, 0], + [0, size / 2, 0] ] ).astype(np.float32) - right_line = pygfx.Line( - pygfx.Geometry(positions=right_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - 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( + # just some line data to initialize y axis edge lines + init_line_data = np.array( [ - [(-size / 2) + origin[0], origin[1], 0.5], - [(size / 2) + origin[0], origin[1], 0.5], + [-size / 2, 0, 0], + [size / 2, 0, 0], ] ).astype(np.float32) - bottom_line = pygfx.Line( - pygfx.Geometry(positions=bottom_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - # 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], - ] - ).astype(np.float32) - - top_line = pygfx.Line( - pygfx.Geometry(positions=top_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - self.edges: Tuple[pygfx.Line, pygfx.Line] = (bottom_line, top_line) - else: raise ValueError("axis argument must be one of 'x' or 'y'") + line0 = pygfx.Line( + pygfx.Geometry(positions=init_line_data.copy()), # copy so the line buffer is isolated + pygfx.LineMaterial( + thickness=edge_thickness, color=edge_color, pick_write=True + ), + ) + line1 = pygfx.Line( + pygfx.Geometry(positions=init_line_data.copy()), # copy so the line buffer is isolated + pygfx.LineMaterial( + thickness=edge_thickness, color=edge_color, pick_write=True + ), + ) + + self.edges: Tuple[pygfx.Line, pygfx.Line] = (line0, line1) + # add the edge lines for edge in self.edges: - edge.world.z = -1 + edge.world.z = -0.5 group.add(edge) + # TODO: if parent offset changes, we should set the selector offset too + if axis == "x": + offset = (parent.offset[0], center, 0) + elif axis == "y": + offset = (center, parent.offset[1], 0) + # set the initial bounds of the selector - self.selection = LinearRegionSelectionFeature( - self, bounds, axis=axis, limits=self._limits + # compensate for any offset from the parent graphic + # selection feature only works in world space, not data space + self._selection = LinearRegionSelectionFeature( + selection, + axis=axis, + limits=self._limits ) self._handled_widgets = list() @@ -248,17 +232,22 @@ def __init__( hover_responsive=self.edges, arrow_keys_modifier=arrow_keys_modifier, axis=axis, + parent=parent, name=name, + offset=offset, ) self._set_world_object(group) + self.selection = selection + def get_selected_data( - self, graphic: Graphic = None - ) -> Union[np.ndarray, List[np.ndarray], None]: + self, graphic: Graphic = None + ) -> Union[np.ndarray, List[np.ndarray]]: """ Get the ``Graphic`` data bounded by the current selection. - Returns a view of the full data array. + Returns a view of the data array. + If the ``Graphic`` is a collection, such as a ``LineStack``, it returns a list of views of the full array. Can be performed on the ``parent`` Graphic or on another graphic by passing to the ``graphic`` arg. @@ -269,15 +258,16 @@ def get_selected_data( Parameters ---------- - graphic: Graphic, optional + graphic: Graphic, optional, default ``None`` if provided, returns the data selection from this graphic instead of the graphic set as ``parent`` Returns ------- - np.ndarray, List[np.ndarray], or None + np.ndarray or List[np.ndarray] view or list of views of the full array, returns ``None`` if selection is empty """ + source = self._get_source(graphic) ixs = self.get_selected_indices(source) @@ -290,40 +280,41 @@ def get_selected_data( for i, g in enumerate(source.graphics): if ixs[i].size == 0: - data_selections.append(None) + data_selections.append(np.array([], dtype=np.float32).reshape(0, 3)) else: - s = slice(ixs[i][0], ixs[i][-1]) - data_selections.append(g.data.buffer.data[s]) + s = slice(ixs[i][0], ixs[i][-1] + 1) # add 1 because these are direct indices + # slices n_datapoints dim + data_selections.append(g.data[s]) - return source[:].data[s] - # just for one Line graphic + # return source[:].data[s] else: if ixs.size == 0: - return None + # empty selection + return np.array([], dtype=np.float32).reshape(0, 3) - s = slice(ixs[0], ixs[-1]) - return source.data.buffer.data[s] + s = slice(ixs[0], ixs[-1] + 1) # add 1 to end because these are direct indices + # slices n_datapoints dim + # slice with min, max is faster than using all the indices + return source.data[s] + + if "Image" in source.__class__.__name__: + s = slice(ixs[0], ixs[-1] + 1) - 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] + # slice columns + return source.data[:, s] + elif self.axis == "y": - return source.data()[s] + # slice rows + return source.data[s] def get_selected_indices( - self, graphic: Graphic = None + 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 - as the Line Geometry positions x-vals or y-vals. For example, if if you used a - np.linspace(0, 100, 1000) for xvals in your line, then you will have 1,000 - x-positions. If the selection ``bounds`` are set to ``(0, 10)``, the returned - indices would be ``(0, 100)``. + + These are the data indices along the selector's "axis" which correspond to the data under the selector. Parameters ---------- @@ -333,51 +324,42 @@ def get_selected_indices( Returns ------- Union[np.ndarray, List[np.ndarray]] - data indices of the selection, list of np.ndarray if graphic is LineCollection + data indices of the selection, list of np.ndarray if graphic is a collection """ + # we get the indices from the source graphic source = self._get_source(graphic) - # if the graphic position is not at (0, 0) then the bounds must be offset - offset = getattr(source, f"position_{self.selection.axis}") - offset_bounds = tuple(v - offset for v in self.selection()) - - # need them to be int to use as indices - offset_bounds = tuple(map(int, offset_bounds)) - - if self.selection.axis == "x": + # get the offset of the source graphic + if self.axis == "x": dim = 0 - else: + elif self.axis == "y": dim = 1 - if "Line" in source.__class__.__name__: - # now we need to map from graphic space to data space - # we can have more than 1 datapoint between two integer locations in the world space + # selector (min, max) data values along axis + bounds = self.selection + + if "Line" in source.__class__.__name__ or "Scatter" in source.__class__.__name__: + # gets indices corresponding to n_datapoints dim + # data is [n_datapoints, xyz], so we return + # indices that can be used to slice `n_datapoints` if isinstance(source, GraphicCollection): ixs = list() 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]) - )[0] + # indices for each graphic in the collection + data = g.data[:, dim] + g_ixs = np.where((data >= bounds[0]) & (data <= 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]) - )[0] + data = source.data[:, dim] + ixs = np.where((data >= bounds[0]) & (data <= bounds[1]))[0] return ixs - if ( - "Heatmap" in source.__class__.__name__ - or "Image" in source.__class__.__name__ - ): + if "Image" in source.__class__.__name__: # indices map directly to grid geometry for image data buffer - ixs = np.arange(*self.selection(), dtype=int) - return ixs + return np.arange(*bounds, dtype=int) def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): """ @@ -410,9 +392,9 @@ def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): cls = getattr(ipywidgets, kind) - value = self.selection() + value = self.selection if "Int" in kind: - value = tuple(map(int, self.selection())) + value = tuple(map(int, self.selection)) slider = cls( min=self.limits[0], @@ -438,7 +420,7 @@ def add_ipywidget_handler(self, widget, step: Union[int, float] = None): """ if not isinstance( - widget, (ipywidgets.IntRangeSlider, ipywidgets.FloatRangeSlider) + widget, (ipywidgets.IntRangeSlider, ipywidgets.FloatRangeSlider) ): raise TypeError( f"`widget` must be one of: ipywidgets.IntRangeSlider or ipywidgets.FloatRangeSlider\n" @@ -457,7 +439,7 @@ def add_ipywidget_handler(self, widget, step: Union[int, float] = None): def _setup_ipywidget_slider(self, widget): # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection() + value = self.selection if isinstance(widget, ipywidgets.IntSlider): value = tuple(map(int, value)) @@ -468,7 +450,7 @@ def _setup_ipywidget_slider(self, widget): widget.observe(self._ipywidget_callback, "value") # user changes linear selection -> widget changes - self.selection.add_event_handler(self._update_ipywidgets) + self.selection.add_event_handler(self._update_ipywidgets, "selection") self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") @@ -502,43 +484,39 @@ def _set_slider_layout(self, *args): widget.layout = ipywidgets.Layout(width=f"{w}px") def _move_graphic(self, delta: np.ndarray): - # add delta to current bounds to get new positions - if self.selection.axis == "x": - # min and max of current bounds, i.e. the edges - xmin, xmax = self.selection() - - # new left bound position - bound0_new = xmin + delta[0] - - # new right bound position - bound1_new = xmax + delta[0] - else: - # min and max of current bounds, i.e. the edges - ymin, ymax = self.selection() - - # new bottom bound position - bound0_new = ymin + delta[1] + # add delta to current min, max to get new positions + if self.axis == "x": + # add x value + new_min, new_max = self.selection + delta[0] - # new top bound position - bound1_new = ymax + delta[1] + elif self.axis == "y": + # add y value + new_min, new_max = self.selection + delta[1] - # move entire selector if source was fill + # move entire selector if event source was fill if self._move_info.source == self.fill: - # set the new bounds - self.selection = (bound0_new, bound1_new) + # prevent weird shrinkage of selector if one edge is already at the limit + if self.selection[0] == self.limits[0] and new_min < self.limits[0]: + # self._move_end(None) # TODO: cancel further movement to prevent weird asynchronization with pointer + return + if self.selection[1] == self.limits[1] and new_max > self.limits[1]: + # self._move_end(None) + return + + # move entire selector + self._selection.set_value(self, (new_min, new_max)) return - # if selector is not resizable do nothing + # if selector is not resizable return if not self._resizable: return - # if resizable, move edges + # if event source was an edge and selector is resizable, + # move the edge that caused the event if self._move_info.source == self.edges[0]: # change only left or bottom bound - self.selection = (bound0_new, self.selection()[1]) + self._selection.set_value(self, (new_min, self._selection.value[1])) elif self._move_info.source == self.edges[1]: # change only right or top bound - self.selection = (self.selection()[0], bound1_new) - else: - return + self._selection.set_value(self, (self.selection[0], new_max)) diff --git a/fastplotlib/graphics/selectors/_sync.py b/fastplotlib/graphics/selectors/_sync.py deleted file mode 100644 index ce903aab8..000000000 --- a/fastplotlib/graphics/selectors/_sync.py +++ /dev/null @@ -1,90 +0,0 @@ -from . import LinearSelector -from typing import * - - -class Synchronizer: - def __init__( - self, *selectors: LinearSelector, key_bind: Union[str, None] = "Shift" - ): - """ - Synchronize the movement of `Selectors`. Selectors will move in sync only when the selected `"key_bind"` is - used during the mouse movement event. Valid key binds are: ``"Control"``, ``"Shift"`` and ``"Alt"``. - If ``key_bind`` is ``None`` then the selectors will always be synchronized. - - Parameters - ---------- - selectors - selectors to synchronize - - key_bind: str, default ``"Shift"`` - one of ``"Control"``, ``"Shift"`` and ``"Alt"`` or ``None`` - """ - self._selectors = list() - self.key_bind = key_bind - - for s in selectors: - self.add(s) - - self.block_event = False - - self.enabled: bool = True - - @property - def selectors(self): - """Selectors managed by the Synchronizer""" - return self._selectors - - def add(self, selector): - """add a selector""" - selector.selection.add_event_handler(self._handle_event) - self._selectors.append(selector) - - def remove(self, selector): - """remove a selector""" - selector.selection.remove_event_handler(self._handle_event) - self._selectors.remove(selector) - - def clear(self): - for i in range(len(self.selectors)): - self.remove(self.selectors[0]) - - def _handle_event(self, ev): - if self.block_event: - # because infinite recursion - return - - if not self.enabled: - return - - self.block_event = True - - source = ev.pick_info["graphic"] - delta = ev.pick_info["delta"] - pygfx_ev = ev.pick_info["pygfx_event"] - - # only moves when modifier is used - if pygfx_ev is None: - self.block_event = False - return - - if self.key_bind is not None: - if self.key_bind not in pygfx_ev.modifiers: - self.block_event = False - return - - if delta is not None: - self._move_selectors(source, delta) - - self.block_event = False - - def _move_selectors(self, source, delta): - for s in self.selectors: - # must use == and not is to compare Graphics because they are weakref proxies! - if s == source: - # if it's the source, since it has already moved - continue - - s._move_graphic(delta) - - def __del__(self): - self.clear() diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index 49b4ac4be..27d49eece 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -16,7 +16,6 @@ def __init__( outline_thickness=0, screen_space: bool = True, anchor: str = "middle-center", - *args, **kwargs, ): """ @@ -55,7 +54,7 @@ def __init__( * Vertical values: "top", "middle", "baseline", "bottom" * Horizontal values: "left", "center", "right" """ - super().__init__(*args, **kwargs) + super().__init__(**kwargs) self._text = text diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 9f82cfed5..d523bc668 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -28,18 +28,17 @@ def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: # only return a proxy to the real graphic return weakref.proxy(graphic) - def add_heatmap( + def add_image( self, data: Any, vmin: int = None, vmax: int = None, cmap: str = "plasma", - filter: str = "nearest", - chunk_size: int = 8192, + interpolation: str = "nearest", + cmap_interpolation: str = "linear", isolated_buffer: bool = True, - *args, **kwargs - ) -> HeatmapGraphic: + ) -> ImageGraphic: """ Create an Image Graphic @@ -48,7 +47,6 @@ def add_heatmap( ---------- 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 @@ -60,20 +58,17 @@ def add_heatmap( cmap: str, optional, default "plasma" colormap to use to display the data - filter: str, optional, default "nearest" + interpolation: 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 + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, 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 @@ -90,78 +85,6 @@ def add_heatmap( Control the presence of the Graphic in the scene - """ - return self._create_graphic( - HeatmapGraphic, - data, - vmin, - vmax, - cmap, - filter, - chunk_size, - isolated_buffer, - *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 - - """ return self._create_graphic( ImageGraphic, @@ -169,24 +92,24 @@ def add_image( vmin, vmax, cmap, - filter, + interpolation, + cmap_interpolation, isolated_buffer, - *args, **kwargs ) def add_line_collection( self, data: List[numpy.ndarray], - z_offset: Union[Iterable[float], float] = None, - thickness: Union[float, Iterable[float]] = 2.0, - colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w", + thickness: Union[float, Sequence[float]] = 2.0, + colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", + uniform_colors: bool = False, alpha: float = 1.0, - cmap: Union[Iterable[str], str] = None, + cmap: Union[Sequence[str], str] = None, cmap_values: Union[numpy.ndarray, List] = None, name: str = None, - metadata: Union[Iterable[Any], numpy.ndarray] = None, - *args, + metadata: Union[Sequence[Any], numpy.ndarray] = None, + isolated_buffer: bool = True, **kwargs ) -> LineCollection: """ @@ -199,10 +122,6 @@ def add_line_collection( 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_offset: Iterable of float or float, optional - | if ``float``, single offset will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - thickness: float or Iterable 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 @@ -233,11 +152,8 @@ def add_line_collection( 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 + passed to Graphic Features -------- @@ -251,15 +167,15 @@ def add_line_collection( return self._create_graphic( LineCollection, data, - z_offset, thickness, colors, + uniform_colors, alpha, cmap, cmap_values, name, metadata, - *args, + isolated_buffer, **kwargs ) @@ -268,12 +184,11 @@ def add_line( data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Iterable] = "w", + uniform_colors: bool = False, alpha: float = 1.0, cmap: str = None, cmap_values: Union[numpy.ndarray, Iterable] = None, - z_position: float = None, - collection_index: int = None, - *args, + isolated_buffer: bool = True, **kwargs ) -> LineGraphic: """ @@ -305,15 +220,13 @@ def add_line( 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. @@ -336,19 +249,17 @@ def add_line( data, thickness, colors, + uniform_colors, alpha, cmap, cmap_values, - z_position, - collection_index, - *args, + isolated_buffer, **kwargs ) def add_line_stack( self, data: List[numpy.ndarray], - z_offset: Union[Iterable[float], float] = None, thickness: Union[float, Iterable[float]] = 2.0, colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w", alpha: float = 1.0, @@ -358,7 +269,6 @@ def add_line_stack( metadata: Union[Iterable[Any], numpy.ndarray] = None, separation: float = 10.0, separation_axis: str = "y", - *args, **kwargs ) -> LineStack: """ @@ -371,10 +281,6 @@ def add_line_stack( 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_offset: Iterable of float or float, optional - | if ``float``, single offset will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - thickness: float or Iterable 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 @@ -423,7 +329,6 @@ def add_line_stack( return self._create_graphic( LineStack, data, - z_offset, thickness, colors, alpha, @@ -433,20 +338,20 @@ def add_line_stack( metadata, separation, separation_axis, - *args, **kwargs ) def add_scatter( self, - data: numpy.ndarray, - sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, - colors: Union[str, numpy.ndarray, Iterable[str]] = "w", + data: Any, + colors: str | numpy.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_colors: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: Union[numpy.ndarray, List] = None, - z_position: float = 0.0, - *args, + cmap_values: numpy.ndarray = None, + isolated_buffer: bool = True, + sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, + uniform_sizes: bool = False, **kwargs ) -> ScatterGraphic: """ @@ -478,9 +383,6 @@ def add_scatter( z_position: float, optional z-axis position for placing the graphic - args - passed to Graphic - kwargs passed to Graphic @@ -504,13 +406,14 @@ def add_scatter( return self._create_graphic( ScatterGraphic, data, - sizes, colors, + uniform_colors, alpha, cmap, cmap_values, - z_position, - *args, + isolated_buffer, + sizes, + uniform_sizes, **kwargs ) @@ -524,7 +427,6 @@ def add_text( outline_thickness=0, screen_space: bool = True, anchor: str = "middle-center", - *args, **kwargs ) -> TextGraphic: """ @@ -575,6 +477,5 @@ def add_text( outline_thickness, screen_space, anchor, - *args, **kwargs ) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 6ff07a748..4d8900971 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -469,14 +469,14 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if self.camera.fov == 0: # for orthographic positions stack objects along the z-axis # for perspective projections we assume the user wants full 3D control - graphic.position_z = len(self) + graphic.offset = (*graphic.offset[:-1], len(self)) def insert_graphic( self, graphic: Graphic, center: bool = True, index: int = 0, - z_position: int = None, + auto_offset: int = None, ): """ Insert graphic into scene at given position ``index`` in stored graphics. @@ -493,8 +493,8 @@ def insert_graphic( index: int, default 0 Index to insert graphic. - z_position: int, default None - z axis position to place Graphic. If ``None``, uses value of `index` argument + auto_offset: bool, default True + If True and using an orthographic projection, sets z-axis offset of graphic to `index` """ if index > len(self._graphics): @@ -511,10 +511,8 @@ def insert_graphic( if self.camera.fov == 0: # for orthographic positions stack objects along the z-axis # for perspective projections we assume the user wants full 3D control - if z_position is None: - graphic.position_z = index - else: - graphic.position_z = z_position + if auto_offset: + graphic.offset = (*graphic.offset[:-1], index) def _add_or_insert_graphic( self, diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 2a480d884..3f45d9007 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -69,7 +69,7 @@ def generate_add_graphics_methods(): 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" return self._create_graphic({class_name.__name__}, {s} **kwargs)\n\n" ) f.close() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/events.py b/tests/events.py new file mode 100644 index 000000000..57fde6886 --- /dev/null +++ b/tests/events.py @@ -0,0 +1,92 @@ +from functools import partial +import pytest +import numpy as np +from numpy import testing as npt +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import FeatureEvent + + +def make_positions_data() -> np.ndarray: + xs = np.linspace(0, 10 * np.pi, 10) + ys = np.sin(xs) + return np.column_stack([xs, ys]) + + +def make_line_graphic() -> fpl.LineGraphic: + return fpl.LineGraphic(make_positions_data()) + + +def make_scatter_graphic() -> fpl.ScatterGraphic: + return fpl.ScatterGraphic(make_positions_data()) + + +event_instance: FeatureEvent = None + + +def event_handler(event): + global event_instance + event_instance = event + + +decorated_event_instance: FeatureEvent = None + + +@pytest.mark.parametrize("graphic", [make_line_graphic(), make_scatter_graphic()]) +def test_positions_data_event(graphic: fpl.LineGraphic | fpl.ScatterGraphic): + global decorated_event_instance + global event_instance + + value = np.cos(np.linspace(0, 10 * np.pi, 10))[3:8] + + info = { + "key": (slice(3, 8, None), 1), + "value": value + } + + expected = FeatureEvent(type="data", info=info) + + def validate(graphic, handler, expected_feature_event, event_to_test): + assert expected_feature_event.type == event_to_test.type + assert expected_feature_event.info["key"] == event_to_test.info["key"] + + npt.assert_almost_equal(expected_feature_event.info["value"], event_to_test.info["value"]) + + # should only have one event handler + assert graphic._event_handlers["data"] == {handler} + + # make sure wrappers are correct + wrapper_map = tuple(graphic._event_handler_wrappers["data"])[0] + assert wrapper_map[0] is handler + assert isinstance(wrapper_map[1], partial) + assert wrapper_map[1].func == graphic._handle_event + assert wrapper_map[1].args[0] is handler + + # test remove handler + graphic.remove_event_handler(handler, "data") + assert len(graphic._event_handlers["click"]) == 0 + assert len(graphic._event_handler_wrappers["click"]) == 0 + assert len(graphic.world_object._event_handlers["click"]) == 0 + + # reset data + graphic.data[:, :-1] = make_positions_data() + event_to_test = None + + # test decorated function + @graphic.add_event_handler("data") + def decorated_handler(event): + global decorated_event_instance + decorated_event_instance = event + + # test decorated + graphic.data[3:8, 1] = value + validate(graphic, decorated_handler, expected, decorated_event_instance) + + # test regular + graphic.add_event_handler(event_handler, "data") + graphic.data[3:8, 1] = value + + validate(graphic, event_handler, expected, event_instance) + + event_instance = None diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py new file mode 100644 index 000000000..3479fcd59 --- /dev/null +++ b/tests/test_colors_buffer_manager.py @@ -0,0 +1,138 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +from fastplotlib.graphics._features import VertexColors +from .utils import generate_slice_indices, assert_pending_uploads + + +def generate_color_inputs(name: str) -> list[str, np.ndarray, list, tuple]: + color = pygfx.Color(name) + + s = name + a = np.array(color) + l = list(color) + t = tuple(color) + + return [s, a, l, t] + + +def make_colors_buffer() -> VertexColors: + colors = VertexColors(colors="w", n_colors=10) + return colors + + +@pytest.mark.parametrize("color_input", [*generate_color_inputs("r"), *generate_color_inputs("g"), *generate_color_inputs("b")]) +def test_create_buffer(color_input): + colors = VertexColors(colors=color_input, n_colors=10) + truth = np.repeat([pygfx.Color(color_input)], 10, axis=0) + npt.assert_almost_equal(colors[:], truth) + + +def test_int(): + # setting single points + colors = make_colors_buffer() + # TODO: placeholder until I make a testing figure where we draw frames only on call + colors.buffer._gfx_pending_uploads.clear() + + colors[3] = "r" + npt.assert_almost_equal(colors[3], [1., 0., 0., 1.]) + assert colors.buffer._gfx_pending_uploads[-1] == (3, 1) + + colors[6] = [0., 1., 1., 1.] + npt.assert_almost_equal(colors[6], [0., 1., 1., 1.]) + + colors[7] = (0., 1., 1., 1.) + npt.assert_almost_equal(colors[6], [0., 1., 1., 1.]) + + colors[8] = np.array([1, 0, 1, 1]) + npt.assert_almost_equal(colors[8], [1., 0., 1., 1.]) + + colors[2] = [1, 0, 1, 0.5] + npt.assert_almost_equal(colors[2], [1., 0., 1., 0.5]) + + +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(0, 16)]) +def test_tuple(slice_method): + # setting entire array manually + colors = make_colors_buffer() + + s = slice_method["slice"] + indices = slice_method["indices"] + others = slice_method["others"] + + # set all RGBA vals + colors[s, :] = 0.5 + truth = np.repeat([[0.5, 0.5, 0.5, 0.5]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + + # check others are not modified + others_truth = np.repeat([[1., 1., 1., 1.]], repeats=len(others), axis=0) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) + + # set just R values + colors[s, 0] = 0.5 + truth = np.repeat([[0.5, 1., 1., 1.]], repeats=len(indices), axis=0) + # check others not modified + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) + + # set green and blue + colors[s, 1:-1] = 0.7 + truth = np.repeat([[1., 0.7, 0.7, 1.0]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) + + # set only alpha + colors[s, -1] = 0.2 + truth = np.repeat([[1., 1., 1., 0.2]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) + + +@pytest.mark.parametrize("color_input", generate_color_inputs("red")) +# skip testing with int since that results in shape [1, 4] with np.repeat, int tested in independent unit test +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 16)]) +def test_slice(color_input, slice_method: dict): + # slicing only first dim + colors = make_colors_buffer() + + # TODO: placeholder until I make a testing figure where we draw frames only on call + colors.buffer._gfx_pending_uploads.clear() + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + colors[s] = color_input + truth = np.repeat([pygfx.Color(color_input)], repeats=len(indices), axis=0) + # check that correct indices are modified + npt.assert_almost_equal(colors[s], truth) + npt.assert_almost_equal(colors[indices], truth) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(colors.buffer, offset, size) + + # check that others are not touched + others_truth = np.repeat([[1., 1., 1., 1.]], repeats=len(others), axis=0) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1., 1., 1., 1.]], 10, axis=0)) diff --git a/tests/test_points_data_buffer_manager.py b/tests/test_points_data_buffer_manager.py new file mode 100644 index 000000000..86181adfa --- /dev/null +++ b/tests/test_points_data_buffer_manager.py @@ -0,0 +1,121 @@ +import numpy as np +from numpy import testing as npt +import pytest + +from fastplotlib.graphics._features import VertexPositions +from .utils import generate_slice_indices, assert_pending_uploads + + +def generate_data(inputs: str) -> np.ndarray: + """ + Generates a spiral/spring + + Only 10 points so a very pointy spiral but easier to spot changes :D + """ + xs = np.linspace(0, 10 * np.pi, 10) + ys = np.sin(xs) + zs = np.cos(xs) + + match inputs: + case "y": + data = ys + + case "xy": + data = np.column_stack([xs, ys]) + + case "xyz": + data = np.column_stack([xs, ys, zs]) + + return data.astype(np.float32) + + +@pytest.mark.parametrize("data", [generate_data(v) for v in ["y", "xy", "xyz"]]) +def test_create_buffer(data): + points_data = VertexPositions(data) + + if data.ndim == 1: + # only y-vals specified + npt.assert_almost_equal(points_data[:, 1], generate_data("y")) + # x-vals are auto generated just using arange + npt.assert_almost_equal(points_data[:, 0], np.arange(data.size)) + + elif data.shape[1] == 2: + # test 2D + npt.assert_almost_equal(points_data[:, :-1], generate_data("xy")) + npt.assert_almost_equal(points_data[:, -1], 0.) + + + elif data.shape[1] == 3: + # test 3D spiral + npt.assert_almost_equal(points_data[:], generate_data("xyz")) + + +def test_int(): + data = generate_data("xyz") + # test setting single points + points = VertexPositions(data) + + # set all x, y, z points, create a kink in the spiral + points[2] = 1. + npt.assert_almost_equal(points[2], 1.) + # make sure other points are not affected + indices = list(range(10)) + indices.pop(2) + npt.assert_almost_equal(points[indices], data[indices]) + + # reset + points = data + npt.assert_almost_equal(points[:], data) + + # just set y value + points[3, 1] = 1. + npt.assert_almost_equal(points[3, 1], 1.) + # make sure others not modified + npt.assert_almost_equal(points[3, 0], data[3, 0]) + npt.assert_almost_equal(points[3, 2], data[3, 2]) + indices = list(range(10)) + indices.pop(3) + npt.assert_almost_equal(points[indices], data[indices]) + + +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(1, 16)]) # int tested separately +@pytest.mark.parametrize("test_axis", ["y", "xy", "xyz"]) +def test_slice(slice_method: dict, test_axis: str): + data = generate_data("xyz") + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + points = VertexPositions(data) + # TODO: placeholder until I make a testing figure where we draw frames only on call + points.buffer._gfx_pending_uploads.clear() + + match test_axis: + case "y": + points[s, 1] = -data[s, 1] + npt.assert_almost_equal(points[s, 1], -data[s, 1]) + npt.assert_almost_equal(points[indices, 1], -data[indices, 1]) + # make sure other points are not modified + npt.assert_almost_equal(points[others, 1], data[others, 1]) # other points in same dimension + npt.assert_almost_equal(points[:, 2:], data[:, 2:]) # dimensions that are not sliced + + case "xy": + points[s, :-1] = -data[s, :-1] + npt.assert_almost_equal(points[s, :-1], -data[s, :-1]) + npt.assert_almost_equal(points[indices, :-1], -data[s, :-1]) + # make sure other points are not modified + npt.assert_almost_equal(points[others, :-1], data[others, :-1]) # other points in the same dimensions + npt.assert_almost_equal(points[:, -1], data[:, -1]) # dimensions that are not touched + + case "xyz": + points[s] = -data[s] + npt.assert_almost_equal(points[s], -data[s]) + npt.assert_almost_equal(points[indices], -data[s]) + # make sure other points are not modified + npt.assert_almost_equal(points[others], data[others]) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(points.buffer, offset, size) diff --git a/tests/test_sizes_buffer_manager.py b/tests/test_sizes_buffer_manager.py new file mode 100644 index 000000000..0f90353f4 --- /dev/null +++ b/tests/test_sizes_buffer_manager.py @@ -0,0 +1,74 @@ +import numpy as np +from numpy import testing as npt +import pytest + +from fastplotlib.graphics._features import PointsSizesFeature +from .utils import generate_slice_indices, assert_pending_uploads + + +def generate_data(input_type: str) -> np.ndarray | float: + """ + Point sizes varying with a sine wave + + Parameters + ---------- + input_type: str + one of "sine", "cosine", or "float" + """ + if input_type == "float": + return 10. + xs = np.linspace(0, 10 * np.pi, 10) + + if input_type == "sine": + return np.abs(np.sin(xs)).astype(np.float32) + + if input_type == "cosine": + return np.abs(np.cos(xs)).astype(np.float32) + + +@pytest.mark.parametrize("data", [generate_data(v) for v in ["float", "sine"]]) +def test_create_buffer(data): + sizes = PointsSizesFeature(data, n_datapoints=10) + + if isinstance(data, float): + npt.assert_almost_equal(sizes[:], generate_data("float")) + + elif isinstance(data, np.ndarray): + npt.assert_almost_equal(sizes[:], generate_data("sine")) + + +@pytest.mark.parametrize("slice_method", [generate_slice_indices(i) for i in range(0, 16)]) +@pytest.mark.parametrize("user_input", ["float", "cosine"]) +def test_slice(slice_method: dict, user_input: str): + data = generate_data("sine") + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + sizes = PointsSizesFeature(data, n_datapoints=10) + + # TODO: placeholder until I make a testing figure where we draw frames only on call + sizes.buffer._gfx_pending_uploads.clear() + + match user_input: + case "float": + sizes[s] = 20. + truth = np.full(len(indices), 20.) + npt.assert_almost_equal(sizes[s], truth) + npt.assert_almost_equal(sizes[indices], truth) + # make sure other sizes not modified + npt.assert_almost_equal(sizes[others], data[others]) + + case "cosine": + cosine = generate_data("cosine") + sizes[s] = cosine[s] + npt.assert_almost_equal(sizes[s], cosine[s]) + npt.assert_almost_equal(sizes[indices], cosine[s]) + # make sure other sizes not modified + npt.assert_almost_equal(sizes[others], data[others]) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(sizes.buffer, offset, size) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..8aa474b1f --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,111 @@ +import numpy as np + +import pygfx + + +def generate_slice_indices(kind: int): + n_elements = 10 + a = np.arange(n_elements) + + match kind: + case 0: + # simplest, just int + s = 2 + indices = [2] + + case 1: + # everything, [:] + s = slice(None, None, None) + indices = list(range(10)) + + case 2: + # positive continuous range, [1:5] + s = slice(1, 5, None) + indices = [1, 2, 3, 4] + + case 3: + # positive stepped range, [2:8:2] + s = slice(2, 8, 2) + indices = [2, 4, 6] + + case 4: + # negative continuous range, [-5:] + s = slice(-5, None, None) + indices = [5, 6, 7, 8, 9] + + case 5: + # negative backwards, [-5::-1] + s = slice(-5, None, -1) + indices = [5, 4, 3, 2, 1, 0] + + case 5: + # negative backwards stepped, [-5::-2] + s = slice(-5, None, -2) + indices = [5, 3, 1] + + case 6: + # negative stepped forward[-5::2] + s = slice(-5, None, 2) + indices = [5, 7, 9] + + case 7: + # both negative, [-8:-2] + s = slice(-8, -2, None) + indices = [2, 3, 4, 5, 6, 7] + + case 8: + # both negative and stepped, [-8:2:2] + s = slice(-8, -2, 2) + indices = [2, 4, 6] + + case 9: + # positive, negative, negative, [8:-9:-2] + s = slice(8, -9, -2) + indices = [8, 6, 4, 2] + + case 10: + # only stepped forward, [::2] + s = slice(None, None, 2) + indices = [0, 2, 4, 6, 8] + + case 11: + # only stepped backward, [::-3] + s = slice(None, None, -3) + indices = [9, 6, 3, 0] + + case 12: + # list indices + s = [2, 5, 9] + indices = [2, 5, 9] + + case 13: + # bool indices + s = a > 5 + indices = [6, 7, 8, 9] + + case 14: + # list indices with negatives + s = [1, 4, -2] + indices = [1, 4, 8] + + case 15: + # array indices + s = np.array([1, 4, -7, 9]) + indices = [1, 4, 3, 9] + + others = [i for i in a if i not in indices] + + offset, size = (min(indices), np.ptp(indices) + 1) + + return {"slice": s, "indices": indices, "others": others, "offset": offset, "size": size} + + +def assert_pending_uploads(buffer: pygfx.Buffer, offset: int, size: int): + upload_offset, upload_size = buffer._gfx_pending_uploads[-1] + # sometimes when slicing with step, it will over-estimate offset + # but it overestimates to upload 1 extra point so it's fine + assert (upload_offset == offset) or (upload_offset == offset - 1) + + # sometimes when slicing with step, it will over-estimate size + # but it overestimates to upload 1 extra point so it's fine + assert (upload_size == size) or (upload_size == size + 1) \ No newline at end of file