diff --git a/docs/source/api/graphic_features/RectangleSelectionFeature.rst b/docs/source/api/graphic_features/RectangleSelectionFeature.rst new file mode 100644 index 000000000..d35752a24 --- /dev/null +++ b/docs/source/api/graphic_features/RectangleSelectionFeature.rst @@ -0,0 +1,35 @@ +.. _api.RectangleSelectionFeature: + +RectangleSelectionFeature +************************* + +========================= +RectangleSelectionFeature +========================= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectionFeature_api + + RectangleSelectionFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectionFeature_api + + RectangleSelectionFeature.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectionFeature_api + + RectangleSelectionFeature.add_event_handler + RectangleSelectionFeature.block_events + RectangleSelectionFeature.clear_event_handlers + RectangleSelectionFeature.remove_event_handler + RectangleSelectionFeature.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index 87504ea8a..ea3ce8903 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -24,6 +24,7 @@ Graphic Features TextOutlineThickness LinearSelectionFeature LinearRegionSelectionFeature + RectangleSelectionFeature Name Offset Rotation diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index 1f15c6963..d4fa2e7b9 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -46,6 +46,7 @@ Methods ImageGraphic.add_event_handler ImageGraphic.add_linear_region_selector ImageGraphic.add_linear_selector + ImageGraphic.add_rectangle_selector ImageGraphic.clear_event_handlers ImageGraphic.remove_event_handler ImageGraphic.reset_vmin_vmax diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index 23e0b512d..459884fdd 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -51,6 +51,7 @@ Methods LineCollection.add_graphic LineCollection.add_linear_region_selector LineCollection.add_linear_selector + LineCollection.add_rectangle_selector LineCollection.clear_event_handlers LineCollection.remove_event_handler LineCollection.remove_graphic diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 96c9ff62b..a3e1587f7 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -44,6 +44,7 @@ Methods LineGraphic.add_event_handler LineGraphic.add_linear_region_selector LineGraphic.add_linear_selector + LineGraphic.add_rectangle_selector LineGraphic.clear_event_handlers LineGraphic.remove_event_handler LineGraphic.rotate diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index 41cd3fbc8..3c14e708c 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -51,6 +51,7 @@ Methods LineStack.add_graphic LineStack.add_linear_region_selector LineStack.add_linear_selector + LineStack.add_rectangle_selector LineStack.clear_event_handlers LineStack.remove_event_handler LineStack.remove_graphic diff --git a/docs/source/api/selectors/RectangleSelector.rst b/docs/source/api/selectors/RectangleSelector.rst new file mode 100644 index 000000000..b2dc40d2e --- /dev/null +++ b/docs/source/api/selectors/RectangleSelector.rst @@ -0,0 +1,53 @@ +.. _api.RectangleSelector: + +RectangleSelector +***************** + +================= +RectangleSelector +================= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelector_api + + RectangleSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelector_api + + RectangleSelector.axes + RectangleSelector.axis + RectangleSelector.block_events + RectangleSelector.deleted + RectangleSelector.event_handlers + RectangleSelector.limits + RectangleSelector.name + RectangleSelector.offset + RectangleSelector.parent + RectangleSelector.rotation + RectangleSelector.selection + RectangleSelector.supported_events + RectangleSelector.visible + RectangleSelector.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: RectangleSelector_api + + RectangleSelector.add_axes + RectangleSelector.add_event_handler + RectangleSelector.clear_event_handlers + RectangleSelector.get_selected_data + RectangleSelector.get_selected_index + RectangleSelector.get_selected_indices + RectangleSelector.remove_event_handler + RectangleSelector.rotate + RectangleSelector.share_property + RectangleSelector.unshare_property + diff --git a/docs/source/api/selectors/index.rst b/docs/source/api/selectors/index.rst index ffa4054db..4a0caf8af 100644 --- a/docs/source/api/selectors/index.rst +++ b/docs/source/api/selectors/index.rst @@ -6,3 +6,4 @@ Selectors LinearSelector LinearRegionSelector + RectangleSelector diff --git a/docs/source/conf.py b/docs/source/conf.py index 64c05b82c..1b296f533 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -101,7 +101,7 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable/", None), - "pygfx": ("https://pygfx.org/stable", None), + "pygfx": ("https://docs.pygfx.org/stable/", None), "wgpu": ("https://wgpu-py.readthedocs.io/en/latest", None), "fastplotlib": ("https://fastplotlib.readthedocs.io/en/latest/", None), } diff --git a/examples/desktop/selectors/rectangle_selector.py b/examples/desktop/selectors/rectangle_selector.py new file mode 100644 index 000000000..48e8647ac --- /dev/null +++ b/examples/desktop/selectors/rectangle_selector.py @@ -0,0 +1,66 @@ +""" +Rectangle Selectors +=================== + +Example showing how to use a `RectangleSelector` with line collections +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from itertools import product + +# create a figure +figure = fpl.Figure( + size=(700, 560) +) + + +# generate some data +def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: + theta = np.linspace(0, 2 * np.pi, n_points) + xs = radius * np.sin(theta) + ys = radius * np.cos(theta) + + return np.column_stack([xs, ys]) + center + + +spatial_dims = (50, 50) + +circles = list() +for center in product(range(0, spatial_dims[0], 9), range(0, spatial_dims[1], 9)): + circles.append(make_circle(center, 3, n_points=75)) + +pos_xy = np.vstack(circles) + +# add image +line_collection = figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5) + +# add rectangle selector to image graphic +rectangle_selector = line_collection.add_rectangle_selector() + + +# add event handler to highlight selected indices +@rectangle_selector.add_event_handler("selection") +def color_indices(ev): + line_collection.cmap = "jet" + ixs = ev.get_selected_indices() + + # iterate through each of the selected indices, if the array size > 0 that mean it's under the selection + selected_line_ixs = [i for i in range(len(ixs)) if ixs[i].size > 0] + line_collection[selected_line_ixs].colors = "w" + + +# manually move selector to make a nice gallery image :D +rectangle_selector.selection = (15, 30, 15, 30) + + +figure.show() + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/selectors/rectangle_selector_zoom.py b/examples/desktop/selectors/rectangle_selector_zoom.py new file mode 100644 index 000000000..b5932d820 --- /dev/null +++ b/examples/desktop/selectors/rectangle_selector_zoom.py @@ -0,0 +1,52 @@ +""" +Rectangle Selectors +=================== +Example showing how to use a `RectangleSelector` with images +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import imageio.v3 as iio +import fastplotlib as fpl + +# create a figure +figure = fpl.Figure( + shape=(2, 1), + size=(700, 560) +) + +# add image +image_graphic = figure[0, 0].add_image(data=iio.imread("imageio:camera.png")) + +# add rectangle selector to image graphic +rectangle_selector = image_graphic.add_rectangle_selector() + +# add a zoomed plot of the selected data +zoom_ig = figure[1, 0].add_image(rectangle_selector.get_selected_data()) + + +# add event handler to update the data of the zoomed image as the selection changes +@rectangle_selector.add_event_handler("selection") +def update_data(ev): + # get the new data + new_data = ev.get_selected_data() + + # remove the old zoomed image graphic + global zoom_ig + + figure[1, 0].remove_graphic(zoom_ig) + + # add new zoomed image of new data + zoom_ig = figure[1, 0].add_image(data=new_data) + + # autoscale the plot + figure[1, 0].auto_scale() + +figure.show() + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index e36de089e..1d2f6ca44 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -31,7 +31,11 @@ TextOutlineThickness, ) -from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature +from ._selection_features import ( + LinearSelectionFeature, + LinearRegionSelectionFeature, + RectangleSelectionFeature, +) from ._common import Name, Offset, Rotation, Visible, Deleted @@ -56,6 +60,7 @@ "TextOutlineThickness", "LinearSelectionFeature", "LinearRegionSelectionFeature", + "RectangleSelectionFeature", "Name", "Offset", "Rotation", diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 71ba53425..e499e72c9 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -1,4 +1,4 @@ -from typing import Sequence +from typing import Sequence, Tuple import numpy as np @@ -190,3 +190,153 @@ def set_value(self, selector, value: Sequence[float]): # 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 + + +class RectangleSelectionFeature(GraphicFeature): + """ + **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 [xmin, xmax, ymin, ymax] of selection | + +----------+------------+-------------------------------------------+ + + """ + + def __init__( + self, + value: tuple[float, float, float, float], + limits: tuple[float, float, float, float], + ): + super().__init__() + + self._limits = limits + self._value = tuple(int(v) for v in value) + + @property + def value(self) -> np.ndarray[float]: + """ + (xmin, xmax, ymin, ymax) of the selection, in data space + """ + return self._value + + def set_value(self, selector, value: Sequence[float]): + """ + Set the selection of the rectangle selector. + + Parameters + ---------- + selector: RectangleSelector + + value: (float, float, float, float) + new values (xmin, xmax, ymin, ymax) of the selection + """ + if not len(value) == 4: + raise TypeError( + "Selection must be an array, tuple, list, or sequence in the form of `(xmin, xmax, ymin, ymax)`, " + "where `xmin`, `xmax`, `ymin`, `ymax` are numeric values." + ) + + # convert to array + value = np.asarray(value, dtype=np.float32) + + # clip values if they are beyond the limits + value[:2] = value[:2].clip(self._limits[0], self._limits[1]) + # clip y + value[2:] = value[2:].clip(self._limits[2], self._limits[3]) + + xmin, xmax, ymin, ymax = value + + # make sure `selector width >= 2` and selector height >=2 , left edge must not move past right edge! + # or bottom edge must not move past top edge! + if not (xmax - xmin) >= 0 or not (ymax - ymin) >= 0: + return + + # change fill mesh + # change left x position of the fill mesh + selector.fill.geometry.positions.data[mesh_masks.x_left] = xmin + + # change right x position of the fill mesh + selector.fill.geometry.positions.data[mesh_masks.x_right] = xmax + + # change bottom y position of the fill mesh + selector.fill.geometry.positions.data[mesh_masks.y_bottom] = ymin + + # change top position of the fill mesh + selector.fill.geometry.positions.data[mesh_masks.y_top] = ymax + + # change the edge lines + + # each edge line is defined by two end points which are stored in the + # geometry.positions + # [x0, y0, z0] + # [x1, y1, z0] + + # left line + z = selector.edges[0].geometry.positions.data[:, -1][0] + selector.edges[0].geometry.positions.data[:] = np.array( + [[xmin, ymin, z], [xmin, ymax, z]] + ) + + # right line + selector.edges[1].geometry.positions.data[:] = np.array( + [[xmax, ymin, z], [xmax, ymax, z]] + ) + + # bottom line + selector.edges[2].geometry.positions.data[:] = np.array( + [[xmin, ymin, z], [xmax, ymin, z]] + ) + + # top line + selector.edges[3].geometry.positions.data[:] = np.array( + [[xmin, ymax, z], [xmax, ymax, z]] + ) + + # change the vertex positions + + # bottom left + selector.vertices[0].geometry.positions.data[:] = np.array([[xmin, ymin, 1]]) + + # bottom right + selector.vertices[1].geometry.positions.data[:] = np.array([[xmax, ymin, 1]]) + + # top left + selector.vertices[2].geometry.positions.data[:] = np.array([[xmin, ymax, 1]]) + + # top right + selector.vertices[3].geometry.positions.data[:] = np.array([[xmax, ymax, 1]]) + + self._value = value + + # send changes to GPU + selector.fill.geometry.positions.update_range() + + for edge in selector.edges: + edge.geometry.positions.update_range() + + for vertex in selector.vertices: + vertex.geometry.positions.update_range() + + # send event + if len(self._event_handlers) < 1: + return + + event = FeatureEvent("selection", {"value": self.value}) + + event.get_selected_indices = selector.get_selected_indices + event.get_selected_data = selector.get_selected_data + + # calls any events + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 6730e86cb..299c682b3 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,10 +1,11 @@ +import math from typing import * import pygfx from ..utils import quick_min_max from ._base import Graphic -from .selectors import LinearSelector, LinearRegionSelector +from .selectors import LinearSelector, LinearRegionSelector, RectangleSelector from ._features import ( TextureArray, ImageCmap, @@ -393,3 +394,44 @@ def add_linear_region_selector( selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) return selector + + def add_rectangle_selector( + self, + selection: tuple[float, float, float, float] = None, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + # default selection is 25% of the diagonal + if selection is None: + diagonal = math.sqrt( + self._data.value.shape[0] ** 2 + self._data.value.shape[1] ** 2 + ) + + selection = (0, int(diagonal / 4), 0, int(diagonal / 4)) + + # min/max limits are image shape + limits = (0, self._data.value.shape[0], 0, self._data.value.shape[1]) + + selector = RectangleSelector( + selection=selection, + limits=limits, + fill_color=fill_color, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + # place above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) + + return selector diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index f352dfde5..1574587fe 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,7 +5,7 @@ import pygfx from ._positions_base import PositionsGraphic -from .selectors import LinearRegionSelector, LinearSelector +from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector from ._features import Thickness @@ -216,6 +216,51 @@ def add_linear_region_selector( # so we should only work with a proxy on the user-end return selector + def add_rectangle_selector( + self, + selection: tuple[float, float, float, float] = None, + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + # computes args to create selectors + 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)] + + x_axis_vals = data[:, 0] + y_axis_vals = data[:, 1] + + ymin = np.floor(y_axis_vals.min()).astype(int) + ymax = np.ceil(y_axis_vals.max()).astype(int) + + # default selection is 25% of the image + if selection is None: + selection = (x_axis_vals[0], x_axis_vals[value_25p], ymin, ymax) + + # min/max limits + limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) + + selector = RectangleSelector( + selection=selection, + limits=limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + # TODO: this method is a bit of a mess, can refactor later def _get_linear_selector_init_args( self, axis: str, padding diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 666d441e4..c4af5dddc 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -7,7 +7,7 @@ from ..utils import parse_cmap_values from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature from .line import LineGraphic -from .selectors import LinearRegionSelector, LinearSelector +from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector class _LineCollectionProperties: @@ -447,6 +447,47 @@ def add_linear_region_selector( # so we should only work with a proxy on the user-end return selector + def add_rectangle_selector( + self, + selection: tuple[float, float, float, float] = None, + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + bbox = self.world_object.get_world_bounding_box() + + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + value_25px = (xmax - xmin) / 4 + + ydata = np.array(self.data[:, 1]) + ymin = np.floor(ydata.min()).astype(int) + + ymax = np.ptp(bbox[:, 1]) + + if selection is None: + selection = (xmin, value_25px, ymin, ymax) + + limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) + + selector = RectangleSelector( + selection=selection, + limits=limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + def _get_linear_selector_init_args(self, axis, padding): # use bbox to get size and center bbox = self.world_object.get_world_bounding_box() diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 4f28f571c..9133192e9 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,6 +1,7 @@ from ._linear import LinearSelector from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector +from ._rectangle import RectangleSelector -__all__ = ["LinearSelector", "LinearRegionSelector"] +__all__ = ["LinearSelector", "LinearRegionSelector", "RectangleSelector"] diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index ab7bda049..1b5f75162 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -69,6 +69,9 @@ def __init__( self._edges + self._fill + self._vertices ) + for wo in self._world_objects: + wo.material.pick_write = True + self._hover_responsive: Tuple[WorldObject, ...] = hover_responsive if hover_responsive is not None: diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py new file mode 100644 index 000000000..aae99803e --- /dev/null +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -0,0 +1,517 @@ +import warnings +from numbers import Real +from typing import * +import numpy as np + +import pygfx +from .._collection_base import GraphicCollection + +from .._base import Graphic +from .._features import RectangleSelectionFeature +from ._base_selector import BaseSelector + + +class RectangleSelector(BaseSelector): + @property + def parent(self) -> Graphic | None: + """Graphic that selector is associated with.""" + return self._parent + + @property + def selection(self) -> Sequence[float] | List[Sequence[float]]: + """ + (xmin, xmax, ymin, ymax) of the rectangle selection + """ + return self._selection.value + + @selection.setter + def selection(self, selection: Sequence[float]): + # set (xmin, xmax, 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, float, float]: + """Return the limits of the selector.""" + return self._limits + + @limits.setter + def limits(self, values: Tuple[float, float, float, float]): + if len(values) != 4 or not all(map(lambda v: isinstance(v, Real), values)): + raise TypeError("limits must be an iterable of two numeric values") + self._limits = tuple( + map(round, values) + ) # if values are close to zero things get weird so round them + self._selection._limits = self._limits + + def __init__( + self, + selection: Sequence[float], + limits: Sequence[float], + parent: Graphic = None, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 8, + vertex_color=(0.7, 0.4, 0), + vertex_thickness: float = 8, + arrow_keys_modifier: str = "Shift", + name: str = None, + ): + """ + Create a RectangleSelector graphic which can be used to select a rectangular region of data. + Allows sub-selecting data from a ``Graphic`` or from multiple Graphics. + + Parameters + ---------- + selection: (float, float, float, float) + the initial selection of the rectangle, ``(x_min, x_max, y_min, y_max)`` + + limits: (float, float, float, float) + limits of the selector, ``(x_min, x_max, y_min, y_max)`` + + parent: Graphic, default ``None`` + associate this selector with a parent Graphic + + resizable: bool, default ``True`` + if ``True``, the edges can be dragged to resize the selection + + fill_color: str, array, or tuple + fill color for the selector, passed to pygfx.Color + + 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`` + + name: str + name for this selector graphic + """ + + if not len(selection) == 4 or not len(limits) == 4: + raise ValueError() + + # lots of very close to zero values etc. so round them + selection = tuple(map(round, selection)) + limits = tuple(map(round, limits)) + + self._parent = parent + self._limits = np.asarray(limits) + self._resizable = resizable + + selection = np.asarray(selection) + + # 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() + + xmin, xmax, ymin, ymax = selection + + width = xmax - xmin + height = ymax - ymin + + if width < 0 or height < 0: + raise ValueError() + + self.fill = pygfx.Mesh( + pygfx.box_geometry(width, height, 1), + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), + ) + + self.fill.world.position = (0, 0, -2) + + group.add(self.fill) + + # position data for the left edge line + left_line_data = np.array( + [ + [xmin, ymin, 0], + [xmin, ymax, 0], + ] + ).astype(np.float32) + + left_line = pygfx.Line( + pygfx.Geometry(positions=left_line_data.copy()), + pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), + ) + + # position data for the right edge line + right_line_data = np.array( + [ + [xmax, ymin, 0], + [xmax, ymax, 0], + ] + ).astype(np.float32) + + right_line = pygfx.Line( + pygfx.Geometry(positions=right_line_data.copy()), + pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), + ) + + # position data for the left edge line + bottom_line_data = np.array( + [ + [xmin, ymax, 0], + [xmax, ymax, 0], + ] + ).astype(np.float32) + + bottom_line = pygfx.Line( + pygfx.Geometry(positions=bottom_line_data.copy()), + pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), + ) + + # position data for the right edge line + top_line_data = np.array( + [ + [xmin, ymin, 0], + [xmax, ymin, 0], + ] + ).astype(np.float32) + + top_line = pygfx.Line( + pygfx.Geometry(positions=top_line_data.copy()), + pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), + ) + + self.edges: Tuple[pygfx.Line, pygfx.Line, pygfx.Line, pygfx.Line] = ( + left_line, + right_line, + bottom_line, + top_line, + ) # left line, right line, bottom line, top line + + # add the edge lines + for edge in self.edges: + edge.world.z = -0.5 + group.add(edge) + + # vertices + top_left_vertex_data = (xmin, ymax, 1) + top_right_vertex_data = (xmax, ymax, 1) + bottom_left_vertex_data = (xmin, ymin, 1) + bottom_right_vertex_data = (xmax, ymin, 1) + + top_left_vertex = pygfx.Points( + pygfx.Geometry(positions=[top_left_vertex_data], sizes=[vertex_thickness]), + pygfx.PointsMarkerMaterial( + marker="square", + size=vertex_thickness, + color=vertex_color, + size_mode="vertex", + edge_color=vertex_color, + ), + ) + + top_right_vertex = pygfx.Points( + pygfx.Geometry(positions=[top_right_vertex_data], sizes=[vertex_thickness]), + pygfx.PointsMarkerMaterial( + marker="square", + size=vertex_thickness, + color=vertex_color, + size_mode="vertex", + edge_color=vertex_color, + ), + ) + + bottom_left_vertex = pygfx.Points( + pygfx.Geometry( + positions=[bottom_left_vertex_data], sizes=[vertex_thickness] + ), + pygfx.PointsMarkerMaterial( + marker="square", + size=vertex_thickness, + color=vertex_color, + size_mode="vertex", + edge_color=vertex_color, + ), + ) + + bottom_right_vertex = pygfx.Points( + pygfx.Geometry( + positions=[bottom_right_vertex_data], sizes=[vertex_thickness] + ), + pygfx.PointsMarkerMaterial( + marker="square", + size=vertex_thickness, + color=vertex_color, + size_mode="vertex", + edge_color=vertex_color, + ), + ) + + self.vertices: Tuple[pygfx.Points, pygfx.Points, pygfx.Points, pygfx.Points] = ( + bottom_left_vertex, + bottom_right_vertex, + top_left_vertex, + top_right_vertex, + ) + + for vertex in self.vertices: + vertex.world.z = -0.25 + group.add(vertex) + + self._selection = RectangleSelectionFeature(selection, limits=self._limits) + + # include parent offset + if parent is not None: + offset = (parent.offset[0], parent.offset[1], 0) + else: + offset = (0, 0, 0) + + BaseSelector.__init__( + self, + edges=self.edges, + fill=(self.fill,), + vertices=self.vertices, + hover_responsive=(*self.edges, *self.vertices), + arrow_keys_modifier=arrow_keys_modifier, + parent=parent, + name=name, + offset=offset, + ) + + self._set_world_object(group) + + self.selection = selection + + def get_selected_data( + self, graphic: Graphic = None, mode: str = "full" + ) -> Union[np.ndarray, List[np.ndarray]]: + """ + Get the ``Graphic`` data bounded by the current selection. + 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. + + Parameters + ---------- + graphic: Graphic, optional, default ``None`` + if provided, returns the data selection from this graphic instead of the graphic set as ``parent`` + mode: str, default 'full' + One of 'full', 'partial', or 'ignore'. Indicates how selected data should be returned based on the + selectors position over the graphic. Only used for ``LineGraphic``, ``LineCollection``, and ``LineStack`` + | If 'full', will return all data bounded by the x and y limits of the selector even if partial indices + along one axis are not fully covered by the selector. + | If 'partial' will return only the data that is bounded by the selector, missing indices not bounded by the + selector will be set to NaNs + | If 'ignore', will only return data for graphics that have indices completely bounded by the selector + + Returns + ------- + np.ndarray or List[np.ndarray] + view or list of views of the full array, returns empty array if selection is empty + """ + source = self._get_source(graphic) + ixs = self.get_selected_indices(source) + + # do not need to check for mode for images, because the selector is bounded by the image shape + # will always be `full` + if "Image" in source.__class__.__name__: + col_ixs = slice(ixs[0][0], ixs[0][-1] + 1) + row_ixs = slice(ixs[1][0], ixs[1][-1] + 1) + + return source.data[row_ixs, col_ixs] + + if mode not in ["full", "partial", "ignore"]: + raise ValueError( + f"`mode` must be one of 'full', 'partial', or 'ignore', you have passed {mode}" + ) + if "Line" in source.__class__.__name__: + + if isinstance(source, GraphicCollection): + data_selections: List[np.ndarray] = list() + + for i, g in enumerate(source.graphics): + # want to keep same length as the original line collection + if ixs[i].size == 0: + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) + else: + # s gives entire slice of data along the x + s = slice( + ixs[i][0], ixs[i][-1] + 1 + ) # add 1 because these are direct indices + # slices n_datapoints dim + + # calculate missing ixs using set difference + # then calculate shift + missing_ixs = ( + np.setdiff1d(np.arange(ixs[i][0], ixs[i][-1] + 1), ixs[i]) + - ixs[i][0] + ) + + match mode: + # take all ixs, ignore missing + case "full": + data_selections.append(g.data[s]) + # set missing ixs data to NaNs + case "partial": + if len(missing_ixs) > 0: + data = g.data[s].copy() + data[missing_ixs] = np.nan + data_selections.append(data) + else: + data_selections.append(g.data[s]) + # ignore lines that do not have full ixs to start + case "ignore": + if len(missing_ixs) > 0: + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) + else: + data_selections.append(g.data[s]) + return data_selections + else: # for lines + if ixs.size == 0: + # empty selection + return np.array([], dtype=np.float32).reshape(0, 3) + + 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 + + # get missing ixs + missing_ixs = np.setdiff1d(np.arange(ixs[0], ixs[-1] + 1), ixs) - ixs[0] + + match mode: + # return all, do not care about missing + case "full": + return source.data[s] + # set missing to NaNs + case "partial": + if len(missing_ixs) > 0: + data = source.data[s].copy() + data[missing_ixs] = np.nan + return data + else: + return source.data[s] + # missing means nothing will be returned even if selector is partially over data + # warn the user and return empty + case "ignore": + if len(missing_ixs) > 0: + warnings.warn( + "You have selected 'ignore' mode. Selected graphic has incomplete indices. " + "Move the selector or change the mode to one of `partial` or `full`." + ) + return np.array([], dtype=np.float32) + else: + return source.data[s] + + def get_selected_indices( + self, graphic: Graphic = None + ) -> Union[np.ndarray, List[np.ndarray]]: + """ + Returns the indices of the ``Graphic`` data bounded by the current selection. + + These are the data indices which correspond to the data under the selector. + + Parameters + ---------- + graphic: Graphic, default ``None`` + If provided, returns the selection indices from this graphic instrad of the graphic set as ``parent`` + + Returns + ------- + Union[np.ndarray, List[np.ndarray]] + data indicies of the selection + | list of [x_indices_array, y_indices_array] if the graphic is an image + | list of indices along the x-dimension for each line if graphic is a line collection + | array of indices along the x-dimension if graphic is a line + """ + # get indices from source + source = self._get_source(graphic) + + # selector (xmin, xmax, ymin, ymax) values + bounds = self.selection + + # image data does not need to check for mode because the selector is always bounded + # to the image + if "Image" in source.__class__.__name__: + xs = np.arange(bounds[0], bounds[1], dtype=int) + ys = np.arange(bounds[2], bounds[3], dtype=int) + return [xs, ys] + + if "Line" in source.__class__.__name__: + if isinstance(source, GraphicCollection): + ixs = list() + for g in source.graphics: + data = g.data.value + g_ixs = np.where( + (data[:, 0] >= bounds[0] - g.offset[0]) + & (data[:, 0] <= bounds[1] - g.offset[0]) + & (data[:, 1] >= bounds[2] - g.offset[1]) + & (data[:, 1] <= bounds[3] - g.offset[1]) + )[0] + ixs.append(g_ixs) + else: + # map only this graphic + data = source.data.value + ixs = np.where( + (data[:, 0] >= bounds[0]) + & (data[:, 0] <= bounds[1]) + & (data[:, 1] >= bounds[2]) + & (data[:, 1] <= bounds[3]) + )[0] + + return ixs + + def _move_graphic(self, delta: np.ndarray): + + # new selection positions + xmin_new = self.selection[0] + delta[0] + xmax_new = self.selection[1] + delta[0] + ymin_new = self.selection[2] + delta[1] + ymax_new = self.selection[3] + delta[1] + + # move entire selector if source is fill + if self._move_info.source == self.fill: + if self.selection[0] == self.limits[0] and xmin_new < self.limits[0]: + return + if self.selection[1] == self.limits[1] and xmax_new > self.limits[1]: + return + if self.selection[2] == self.limits[2] and ymin_new < self.limits[2]: + return + if self.selection[3] == self.limits[3] and ymax_new > self.limits[3]: + return + # set thew new bounds + self._selection.set_value(self, (xmin_new, xmax_new, ymin_new, ymax_new)) + return + + # if selector not resizable return + if not self._resizable: + return + + xmin, xmax, ymin, ymax = self.selection + + if self._move_info.source == self.vertices[0]: # bottom left + self._selection.set_value(self, (xmin_new, xmax, ymin_new, ymax)) + if self._move_info.source == self.vertices[1]: # bottom right + self._selection.set_value(self, (xmin, xmax_new, ymin_new, ymax)) + if self._move_info.source == self.vertices[2]: # top left + self._selection.set_value(self, (xmin_new, xmax, ymin, ymax_new)) + if self._move_info.source == self.vertices[3]: # top right + self._selection.set_value(self, (xmin, xmax_new, ymin, ymax_new)) + # 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]: + self._selection.set_value(self, (xmin_new, xmax, ymin, ymax)) + if self._move_info.source == self.edges[1]: + self._selection.set_value(self, (xmin, xmax_new, ymin, ymax)) + if self._move_info.source == self.edges[2]: + self._selection.set_value(self, (xmin, xmax, ymin_new, ymax)) + if self._move_info.source == self.edges[3]: + self._selection.set_value(self, (xmin, xmax, ymin, ymax_new)) diff --git a/fastplotlib/graphics/selectors/_rectangle_region.py b/fastplotlib/graphics/selectors/_rectangle_region.py deleted file mode 100644 index bc2cad5b1..000000000 --- a/fastplotlib/graphics/selectors/_rectangle_region.py +++ /dev/null @@ -1,355 +0,0 @@ -from typing import * -import numpy as np - -import pygfx - -from ...utils import mesh_masks -from .._base import Graphic -from .._features import GraphicFeature -from ._base_selector import BaseSelector - - -class RectangleBoundsFeature(GraphicFeature): - """ - Feature for a linearly bounding region - - **event pick info** - - +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ - | key | type | description | - +====================+===============================+======================================================================================+ - | "selected_indices" | ``numpy.ndarray`` or ``None`` | selected graphic data indices | - | "selected_data" | ``numpy.ndarray`` or ``None`` | selected graphic data | - | "new_data" | ``(float, float)`` | current bounds in world coordinates, NOT necessarily the same as "selected_indices". | - +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ - - """ - - def __init__( - self, parent, bounds: Tuple[int, int], axis: str, limits: Tuple[int, int] - ): - super().__init__(parent, data=bounds) - - self._axis = axis - self.limits = limits - - self._set(bounds) - - @property - def axis(self) -> str: - """one of "x" | "y" """ - return self._axis - - def _set(self, value: Tuple[float, float, float, float]): - """ - - Parameters - ---------- - value: Tuple[float] - new values: (xmin, xmax, ymin, ymax) - - Returns - ------- - - """ - xmin, xmax, ymin, ymax = value - - # TODO: make sure new values do not exceed limits - - # change fill mesh - # change left x position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.x_left] = xmin - - # change right x position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.x_right] = xmax - - # change bottom y position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.y_bottom] = ymin - - # change top position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.y_top] = ymax - - # change the edge lines - - # each edge line is defined by two end points which are stored in the - # geometry.positions - # [x0, y0, z0] - # [x1, y1, z0] - - # left line - z = self._parent.edges[0].geometry.positions.data[:, -1][0] - self._parent.edges[0].geometry.positions.data[:] = np.array( - [[xmin, ymin, z], [xmin, ymax, z]] - ) - - # right line - self._parent.edges[1].geometry.positions.data[:] = np.array( - [[xmax, ymin, z], [xmax, ymax, z]] - ) - - # bottom line - self._parent.edges[2].geometry.positions.data[:] = np.array( - [[xmin, ymin, z], [xmax, ymin, z]] - ) - - # top line - self._parent.edges[3].geometry.positions.data[:] = np.array( - [[xmin, ymax, z], [xmax, ymax, z]] - ) - - self._data = value # (value[0], value[1]) - - # send changes to GPU - self._parent.fill.geometry.positions.update_range() - - for edge in self._parent.edges: - edge.geometry.positions.update_range() - - # calls any events - self._feature_changed(key=None, new_data=value) - - # TODO: feature_changed - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - return - # 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 - # - # pick_info = { - # "index": None, - # "collection-index": self._collection_index, - # "world_object": self._parent.world_object, - # "new_data": new_data, - # "selected_indices": selected_ixs, - # "selected_data": selected_data - # "graphic", - # "delta", - # "pygfx_event" - # } - # - # event_data = FeatureEvent(type="bounds", pick_info=pick_info) - # - # self._call_event_handlers(event_data) - - -class RectangleRegionSelector(Graphic, BaseSelector): - feature_events = "bounds" - - def __init__( - self, - bounds: Tuple[int, int, int, int], - limits: Tuple[int, int], - origin: Tuple[int, int], - axis: str = "x", - parent: Graphic = None, - resizable: bool = True, - fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.8, 0), - arrow_keys_modifier: str = "Shift", - name: str = None, - ): - """ - 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 - - Parameters - ---------- - bounds: (int, int, int, int) - the initial bounds of the rectangle, ``(x_min, x_max, y_min, y_max)`` - - limits: (int, int, int, int) - limits of the selector, ``(x_min, x_max, y_min, y_max)`` - - origin: (int, int) - initial position of the selector - - axis: str, default ``None`` - Restrict the selector to the "x" or "y" axis. - If the selector is restricted to an axis you cannot change the bounds along the other axis. For example, - if you set ``axis="x"``, then the ``y_min``, ``y_max`` values of the bounds will stay constant. - - parent: Graphic, default ``None`` - associate this selector with a parent Graphic - - resizable: bool - if ``True``, the edges can be dragged to resize the selection - - fill_color: str, array, or tuple - fill color for the selector, passed to pygfx.Color - - edge_color: str, array, or tuple - edge color for the selector, passed to pygfx.Color - - name: str - name for this selector graphic - """ - - # lots of very close to zero values etc. so round them - bounds = tuple(map(round, bounds)) - limits = tuple(map(round, limits)) - origin = tuple(map(round, origin)) - - Graphic.__init__(self, name=name) - - 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() - self._set_world_object(group) - - xmin, xmax, ymin, ymax = bounds - - width = xmax - xmin - height = ymax - ymin - - self.fill = pygfx.Mesh( - pygfx.box_geometry(width, height, 1), - pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), - ) - - self.fill.position.set(*origin, -2) - self.world_object.add(self.fill) - - # position data for the left edge line - left_line_data = np.array( - [ - [origin[0], (-height / 2) + origin[1], 0.5], - [origin[0], (height / 2) + origin[1], 0.5], - ] - ).astype(np.float32) - - left_line = pygfx.Line( - pygfx.Geometry(positions=left_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color), - ) - - # position data for the right edge line - right_line_data = np.array( - [ - [bounds[1], (-height / 2) + origin[1], 0.5], - [bounds[1], (height / 2) + origin[1], 0.5], - ] - ).astype(np.float32) - - right_line = pygfx.Line( - pygfx.Geometry(positions=right_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color), - ) - - # position data for the left edge line - bottom_line_data = np.array( - [ - [(-width / 2) + origin[0], origin[1], 0.5], - [(width / 2) + origin[0], origin[1], 0.5], - ] - ).astype(np.float32) - - bottom_line = pygfx.Line( - pygfx.Geometry(positions=bottom_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color), - ) - - # position data for the right edge line - top_line_data = np.array( - [ - [(-width / 2) + origin[0], bounds[1], 0.5], - [(width / 2) + origin[0], bounds[1], 0.5], - ] - ).astype(np.float32) - - top_line = pygfx.Line( - pygfx.Geometry(positions=top_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color), - ) - - self.edges: Tuple[pygfx.Line, ...] = ( - left_line, - right_line, - bottom_line, - top_line, - ) # left line, right line, bottom line, top line - - # add the edge lines - for edge in self.edges: - edge.position.set(*origin, -1) - self.world_object.add(edge) - - self._resizable = resizable - self._bounds = RectangleBoundsFeature(self, bounds, axis=axis, limits=limits) - - BaseSelector.__init__( - self, - edges=self.edges, - fill=(self.fill,), - hover_responsive=self.edges, - arrow_keys_modifier=arrow_keys_modifier, - axis=axis, - ) - - @property - def bounds(self) -> RectangleBoundsFeature: - """ - (xmin, xmax, ymin, ymax) The current bounds of the selection in world space. - - These bounds will NOT necessarily correspond to the indices of the data that are under the selection. - Use ``get_selected_indices()` which maps from world space to data indices. - """ - return self._bounds - - def _move_graphic(self, delta): - # new left bound position - xmin_new = Vector3(self.bounds()[0]).add(delta).x - - # new right bound position - xmax_new = Vector3(self.bounds()[1]).add(delta).x - - # new bottom bound position - ymin_new = Vector3(0, self.bounds()[2]).add(delta).y - - # new top bound position - ymax_new = Vector3(0, self.bounds()[3]).add(delta).y - - # move entire selector if source was fill - if self._move_info.source == self.fill: - # set the new bounds - self.bounds = (xmin_new, xmax_new, ymin_new, ymax_new) - return - - # if selector is not resizable do nothing - if not self._resizable: - return - - # if resizable, move edges - - xmin, xmax, ymin, ymax = self.bounds() - - # change only left bound - if self._move_info.source == self.edges[0]: - xmin = xmin_new - - # change only right bound - elif self._move_info.source == self.edges[1]: - xmax = xmax_new - - # change only bottom bound - elif self._move_info.source == self.edges[2]: - ymin = ymin_new - - # change only top bound - elif self._move_info.source == self.edges[3]: - ymax = ymax_new - else: - return - - # set the new bounds - self.bounds = (xmin, xmax, ymin, ymax)