From d262570fa84c1dab273686bea78634c208758407 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 2 Aug 2024 15:07:26 -0400 Subject: [PATCH 01/24] start working on adding rectangle region selector --- fastplotlib/graphics/_features/__init__.py | 3 +- .../graphics/_features/_selection_features.py | 121 ++++++- fastplotlib/graphics/selectors/__init__.py | 3 +- .../graphics/selectors/_rectangle_region.py | 334 +++++++----------- 4 files changed, 243 insertions(+), 218 deletions(-) diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index e36de089e..aa777e047 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -31,7 +31,7 @@ TextOutlineThickness, ) -from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature +from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature, RectangleRegionSelectionFeature from ._common import Name, Offset, Rotation, Visible, Deleted @@ -56,6 +56,7 @@ "TextOutlineThickness", "LinearSelectionFeature", "LinearRegionSelectionFeature", + "RectangleRegionSelectionFeature" "Name", "Offset", "Rotation", diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 71ba53425..fd00c357b 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,122 @@ 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 RectangleRegionSelectionFeature(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 [min, max] of selection | + +----------+------------+-----------------------------+ + + """ + def __init__( + self, value: Tuple[float, float, float, float], axis: str | None, limits: Tuple[int, int] + ): + super().__init__() + + self._axis = axis + self._limits = limits + self._value = value + + @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_value(self, selector, selection: Sequence[float]): + """ + + Parameters + ---------- + value: Tuple[float] + new values: (xmin, xmax, ymin, ymax) + + Returns + ------- + + """ + # convert to array, clip values if they are beyond the limits + #selection = np.asarray(selection, dtype=np.float32).clip(*self._limits) + + xmin, xmax, ymin, ymax = selection + + # make sure `selector width >= 2`, left edge must not move past right edge! + # or bottom edge must not move past top edge! + # if not (xmax[1] - xmin[0]) >= 0 or not (ymax[1] - ymin[0]) >= 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]] + ) + # + # self._data = se # (value[0], value[1]) + # + # send changes to GPU + selector.fill.geometry.positions.update_range() + # + for edge in selector.edges: + edge.geometry.positions.update_range() + + event = FeatureEvent("selection", {"value": self.value}) + + # calls any events + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 4f28f571c..cf18abb0f 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_region import RectangleRegionSelector -__all__ = ["LinearSelector", "LinearRegionSelector"] +__all__ = ["LinearSelector", "LinearRegionSelector", "RectangleRegionSelector"] diff --git a/fastplotlib/graphics/selectors/_rectangle_region.py b/fastplotlib/graphics/selectors/_rectangle_region.py index bc2cad5b1..ff4b161ad 100644 --- a/fastplotlib/graphics/selectors/_rectangle_region.py +++ b/fastplotlib/graphics/selectors/_rectangle_region.py @@ -1,185 +1,91 @@ +from numbers import Real from typing import * import numpy as np import pygfx +from .._collection_base import GraphicCollection -from ...utils import mesh_masks from .._base import Graphic -from .._features import GraphicFeature +from .._features import RectangleRegionSelectionFeature 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) - +class RectangleRegionSelector(BaseSelector): @property - def axis(self) -> str: - """one of "x" | "y" """ - return self._axis + def parent(self) -> Graphic | None: + """Graphic that selector is associated with.""" + return self._parent - def _set(self, value: Tuple[float, float, float, float]): + @property + def selection(self) -> Sequence[float] | List[Sequence[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 + return self._selection.value - # change top position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.y_top] = ymax + @selection.setter + def selection(self, selection: Sequence[float]): + graphic = self._parent - # change the edge lines + if isinstance(graphic, GraphicCollection): + pass - # each edge line is defined by two end points which are stored in the - # geometry.positions - # [x0, y0, z0] - # [x1, y1, z0] + print(selection) - # 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]] - ) + # self._selection.set_value(self, selection) - # 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" + @property + def limits(self) -> Tuple[float, float, float, float]: + """Return the limits of the selector (xmin, xmax, ymin, ymax).""" + 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, - bounds: Tuple[int, int, int, int], - limits: Tuple[int, int], - origin: Tuple[int, int], - axis: str = "x", - parent: Graphic = None, - resizable: bool = True, - fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.8, 0), - arrow_keys_modifier: str = "Shift", - name: str = None, + self, + selection: Tuple[float, float, float, float], + limits: Tuple[float, float, float, float], + origin: Tuple[float, float], + axis: str = None, + parent: Graphic = None, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.8, 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. + Create a RectangleRegionSelector graphic which can be used to select a rectangular region of data. 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)`` + selection: (float, float, float, float) + the initial selection of the rectangle, ``(x_min, x_max, y_min, y_max)`` - limits: (int, int, int, int) + limits: (float, float, float, float) limits of the selector, ``(x_min, x_max, y_min, y_max)`` - origin: (int, int) + origin: (float, float) 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. + If the selector is restricted to an axis you cannot change the selection along the other axis. For example, + if you set ``axis="x"``, then the ``y_min``, ``y_max`` values of the selection will stay constant. parent: Graphic, default ``None`` associate this selector with a parent Graphic - resizable: bool + resizable: bool, default ``True`` if ``True``, the edges can be dragged to resize the selection fill_color: str, array, or tuple @@ -188,91 +94,105 @@ 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`` + name: str name for this selector graphic """ + if not len(selection) == 4 or not len(limits) == 4 or not len(origin) == 2: + raise ValueError() + # lots of very close to zero values etc. so round them - bounds = tuple(map(round, bounds)) + selection = tuple(map(round, selection)) limits = tuple(map(round, limits)) origin = tuple(map(round, origin)) - Graphic.__init__(self, name=name) + self._parent = parent + self._limits = np.asarray(limits) - self.parent = parent + 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() - self._set_world_object(group) - xmin, xmax, ymin, ymax = bounds + 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.position.set(*origin, -2) - self.world_object.add(self.fill) + self.fill.world.position = (origin[0] + (width / 2), origin[1] - (height / 2), -2) - # position data for the left edge line + group.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], + [origin[0], -height + origin[1], 0], + [origin[0], origin[1], 0], ] ).astype(np.float32) left_line = pygfx.Line( - pygfx.Geometry(positions=left_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color), + 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( [ - [bounds[1], (-height / 2) + origin[1], 0.5], - [bounds[1], (height / 2) + origin[1], 0.5], + [origin[0] + xmax, -height + origin[1], 0], + [origin[0] + xmax, origin[1], 0], ] ).astype(np.float32) right_line = pygfx.Line( - pygfx.Geometry(positions=right_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color), + 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( [ - [(-width / 2) + origin[0], origin[1], 0.5], - [(width / 2) + origin[0], origin[1], 0.5], + [origin[0], -height + origin[1], 0], + [origin[0] + xmax, -height + origin[1], 0], ] ).astype(np.float32) bottom_line = pygfx.Line( - pygfx.Geometry(positions=bottom_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color), + 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( [ - [(-width / 2) + origin[0], bounds[1], 0.5], - [(width / 2) + origin[0], bounds[1], 0.5], + [origin[0], origin[1], 0], + [origin[0] + xmax, origin[1], 0], ] ).astype(np.float32) top_line = pygfx.Line( - pygfx.Geometry(positions=top_line_data), - pygfx.LineMaterial(thickness=3, color=edge_color), + pygfx.Geometry(positions=top_line_data.copy()), + pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), ) - self.edges: Tuple[pygfx.Line, ...] = ( + self.edges: Tuple[pygfx.Line, pygfx.Line, pygfx.Line, pygfx.Line] = ( left_line, right_line, bottom_line, @@ -281,11 +201,11 @@ def __init__( # add the edge lines for edge in self.edges: - edge.position.set(*origin, -1) - self.world_object.add(edge) + edge.world.z = -0.5 + group.add(edge) self._resizable = resizable - self._bounds = RectangleBoundsFeature(self, bounds, axis=axis, limits=limits) + self._selection = RectangleRegionSelectionFeature(selection, axis=axis, limits=self._limits) BaseSelector.__init__( self, @@ -294,62 +214,46 @@ def __init__( hover_responsive=self.edges, arrow_keys_modifier=arrow_keys_modifier, axis=axis, + parent=parent, + name=name, ) - @property - def bounds(self) -> RectangleBoundsFeature: - """ - (xmin, xmax, ymin, ymax) The current bounds of the selection in world space. + self._set_world_object(group) - 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 + self.selection = selection - def _move_graphic(self, delta): - # new left bound position - xmin_new = Vector3(self.bounds()[0]).add(delta).x + def get_selected_data(self): + pass - # new right bound position - xmax_new = Vector3(self.bounds()[1]).add(delta).x + def get_selected_indices(self): + pass - # new bottom bound position - ymin_new = Vector3(0, self.bounds()[2]).add(delta).y + def _move_graphic(self, delta: np.ndarray): - # new top bound position - ymax_new = Vector3(0, self.bounds()[3]).add(delta).y + # 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 was fill + # move entire selector if source is fill if self._move_info.source == self.fill: - # set the new bounds - self.bounds = (xmin_new, xmax_new, ymin_new, ymax_new) + # set thew new bounds + self._selection.set_value(self, (xmin_new, xmax_new, ymin_new, ymax_new)) return - # if selector is not resizable do nothing + # if selector not resizable return if not self._resizable: return - # if resizable, move edges - - xmin, xmax, ymin, ymax = self.bounds() + xmin, xmax, ymin, ymax = self.selection - # change only left bound + # 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]: - 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) + 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)) From d7f3148d2c6e531cfa312cca8d23ca00ef055009 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 2 Aug 2024 15:11:32 -0400 Subject: [PATCH 02/24] rename selector tool --- fastplotlib/graphics/_features/__init__.py | 2 +- fastplotlib/graphics/_features/_selection_features.py | 2 +- fastplotlib/graphics/selectors/__init__.py | 4 ++-- fastplotlib/graphics/selectors/_rectangle_region.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index aa777e047..3670fb72e 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -31,7 +31,7 @@ TextOutlineThickness, ) -from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature, RectangleRegionSelectionFeature +from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature, RectangleSelectionFeature from ._common import Name, Offset, Rotation, Visible, Deleted diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index fd00c357b..6e4cf1784 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -192,7 +192,7 @@ def set_value(self, selector, value: Sequence[float]): # this is probably a good idea so that the data isn't sliced until it's actually necessary -class RectangleRegionSelectionFeature(GraphicFeature): +class RectangleSelectionFeature(GraphicFeature): """ **additional event attributes:** diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index cf18abb0f..73bd3045d 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,7 +1,7 @@ from ._linear import LinearSelector from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector -from ._rectangle_region import RectangleRegionSelector +from ._rectangle_region import RectangleSelector -__all__ = ["LinearSelector", "LinearRegionSelector", "RectangleRegionSelector"] +__all__ = ["LinearSelector", "LinearRegionSelector", "RectangleSelector"] diff --git a/fastplotlib/graphics/selectors/_rectangle_region.py b/fastplotlib/graphics/selectors/_rectangle_region.py index ff4b161ad..556fd4503 100644 --- a/fastplotlib/graphics/selectors/_rectangle_region.py +++ b/fastplotlib/graphics/selectors/_rectangle_region.py @@ -6,11 +6,11 @@ from .._collection_base import GraphicCollection from .._base import Graphic -from .._features import RectangleRegionSelectionFeature +from .._features import RectangleSelectionFeature from ._base_selector import BaseSelector -class RectangleRegionSelector(BaseSelector): +class RectangleSelector(BaseSelector): @property def parent(self) -> Graphic | None: """Graphic that selector is associated with.""" @@ -205,7 +205,7 @@ def __init__( group.add(edge) self._resizable = resizable - self._selection = RectangleRegionSelectionFeature(selection, axis=axis, limits=self._limits) + self._selection = RectangleSelectionFeature(selection, axis=axis, limits=self._limits) BaseSelector.__init__( self, From d0a04e6a37fc17d99ed5a2809bcf0dea7b8d1a69 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 2 Aug 2024 15:29:38 -0400 Subject: [PATCH 03/24] update line setting, remove origin --- .../graphics/selectors/_rectangle_region.py | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/fastplotlib/graphics/selectors/_rectangle_region.py b/fastplotlib/graphics/selectors/_rectangle_region.py index 556fd4503..cc80877be 100644 --- a/fastplotlib/graphics/selectors/_rectangle_region.py +++ b/fastplotlib/graphics/selectors/_rectangle_region.py @@ -19,24 +19,23 @@ def parent(self) -> Graphic | None: @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 - print(selection) - - # self._selection.set_value(self, selection) + self._selection.set_value(self, selection) @property def limits(self) -> Tuple[float, float, float, float]: - """Return the limits of the selector (xmin, xmax, ymin, ymax).""" + """Return the limits of the selector.""" return self._limits @limits.setter @@ -50,9 +49,8 @@ def limits(self, values: Tuple[float, float, float, float]): def __init__( self, - selection: Tuple[float, float, float, float], - limits: Tuple[float, float, float, float], - origin: Tuple[float, float], + selection: Sequence[float], + limits: Sequence[float], axis: str = None, parent: Graphic = None, resizable: bool = True, @@ -74,9 +72,6 @@ def __init__( limits: (float, float, float, float) limits of the selector, ``(x_min, x_max, y_min, y_max)`` - origin: (float, float) - 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 selection along the other axis. For example, @@ -105,16 +100,16 @@ def __init__( name for this selector graphic """ - if not len(selection) == 4 or not len(limits) == 4 or not len(origin) == 2: + 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)) - origin = tuple(map(round, origin)) self._parent = parent self._limits = np.asarray(limits) + self._resizable = resizable selection = np.asarray(selection) @@ -136,15 +131,15 @@ def __init__( pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), ) - self.fill.world.position = (origin[0] + (width / 2), origin[1] - (height / 2), -2) + self.fill.world.position = (0, 0, -2) group.add(self.fill) #position data for the left edge line left_line_data = np.array( [ - [origin[0], -height + origin[1], 0], - [origin[0], origin[1], 0], + [0, -width / 2, 0], + [0, width / 2, 0], ] ).astype(np.float32) @@ -156,8 +151,8 @@ def __init__( # position data for the right edge line right_line_data = np.array( [ - [origin[0] + xmax, -height + origin[1], 0], - [origin[0] + xmax, origin[1], 0], + [xmax, -width / 2, 0], + [xmax, width / 2, 0], ] ).astype(np.float32) @@ -169,8 +164,8 @@ def __init__( # position data for the left edge line bottom_line_data = np.array( [ - [origin[0], -height + origin[1], 0], - [origin[0] + xmax, -height + origin[1], 0], + [-height / 2, ymax, 0], + [height / 2, ymax, 0], ] ).astype(np.float32) @@ -182,8 +177,8 @@ def __init__( # position data for the right edge line top_line_data = np.array( [ - [origin[0], origin[1], 0], - [origin[0] + xmax, origin[1], 0], + [-height / 2, 0, 0], + [height / 2 + xmax, 0, 0], ] ).astype(np.float32) @@ -204,7 +199,6 @@ def __init__( edge.world.z = -0.5 group.add(edge) - self._resizable = resizable self._selection = RectangleSelectionFeature(selection, axis=axis, limits=self._limits) BaseSelector.__init__( From 8ddfc8a9de697ff2b4cc47b7c37c8f44889b1735 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 2 Aug 2024 15:49:09 -0400 Subject: [PATCH 04/24] fix selection feature --- .../graphics/_features/_selection_features.py | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 6e4cf1784..1d8bfce4d 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -206,26 +206,29 @@ class RectangleSelectionFeature(GraphicFeature): **info dict:** - +----------+------------+-----------------------------+ - | dict key | value type | value description | - +==========+============+=============================+ - | value | np.ndarray | new [min, max] of selection | - +----------+------------+-----------------------------+ + +----------+------------+-------------------------------------------+ + | 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], axis: str | None, limits: Tuple[int, int] + self, + value: tuple[float, float, float, float], + axis: str | None, + limits: tuple[float, float, float, float] ): super().__init__() self._axis = axis self._limits = limits - self._value = value + self._value = tuple(int(v) for v in value) @property def value(self) -> np.ndarray[float]: """ - (min, max) of the selection, in data space + (xmin, xmax, ymin, ymax) of the selection, in data space """ return self._value @@ -234,27 +237,36 @@ def axis(self) -> str: """one of "x" | "y" """ return self._axis - def set_value(self, selector, selection: Sequence[float]): + def set_value(self, selector, value: Sequence[float]): """ + Set the selection of the rectangle selector. Parameters ---------- - value: Tuple[float] - new values: (xmin, xmax, ymin, ymax) - - Returns - ------- + 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, clip values if they are beyond the limits - #selection = np.asarray(selection, dtype=np.float32).clip(*self._limits) + # clip x + value = np.asarray(value, dtype=np.float32) + 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 = selection + xmin, xmax, ymin, ymax = value - # make sure `selector width >= 2`, left edge must not move past right edge! + # 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[1] - xmin[0]) >= 0 or not (ymax[1] - ymin[0]) >= 0: - # return + if not (xmax - xmin) >= 0 or not (ymax - ymin) >= 0: + return # change fill mesh # change left x position of the fill mesh @@ -297,7 +309,7 @@ def set_value(self, selector, selection: Sequence[float]): [[xmin, ymax, z], [xmax, ymax, z]] ) # - # self._data = se # (value[0], value[1]) + self._value = value # # send changes to GPU selector.fill.geometry.positions.update_range() @@ -305,6 +317,10 @@ def set_value(self, selector, selection: Sequence[float]): for edge in selector.edges: edge.geometry.positions.update_range() + # send event + if len(self._event_handlers) < 1: + return + event = FeatureEvent("selection", {"value": self.value}) # calls any events From c3e2cefc0e5697e7be015b95275a863cf8072cec Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 2 Aug 2024 16:36:29 -0400 Subject: [PATCH 05/24] fix pick write issue --- fastplotlib/graphics/selectors/_base_selector.py | 3 +++ 1 file changed, 3 insertions(+) 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: From db37714de29664a1fcb5538b495d34b164b5a3de Mon Sep 17 00:00:00 2001 From: Caitlin Date: Sat, 3 Aug 2024 11:48:02 -0400 Subject: [PATCH 06/24] rename file, fix line initialization --- fastplotlib/graphics/selectors/__init__.py | 2 +- .../{_rectangle_region.py => _rectangle.py} | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) rename fastplotlib/graphics/selectors/{_rectangle_region.py => _rectangle.py} (95%) diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 73bd3045d..9133192e9 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,7 +1,7 @@ from ._linear import LinearSelector from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector -from ._rectangle_region import RectangleSelector +from ._rectangle import RectangleSelector __all__ = ["LinearSelector", "LinearRegionSelector", "RectangleSelector"] diff --git a/fastplotlib/graphics/selectors/_rectangle_region.py b/fastplotlib/graphics/selectors/_rectangle.py similarity index 95% rename from fastplotlib/graphics/selectors/_rectangle_region.py rename to fastplotlib/graphics/selectors/_rectangle.py index cc80877be..4feb7f5fb 100644 --- a/fastplotlib/graphics/selectors/_rectangle_region.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -55,7 +55,7 @@ def __init__( parent: Graphic = None, resizable: bool = True, fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.8, 0), + edge_color=(0.8, 0.6, 0), edge_thickness: float = 8, arrow_keys_modifier: str = "Shift", name: str = None, @@ -138,8 +138,8 @@ def __init__( #position data for the left edge line left_line_data = np.array( [ - [0, -width / 2, 0], - [0, width / 2, 0], + [xmin, ymin, 0], + [xmin, ymax, 0], ] ).astype(np.float32) @@ -151,8 +151,8 @@ def __init__( # position data for the right edge line right_line_data = np.array( [ - [xmax, -width / 2, 0], - [xmax, width / 2, 0], + [xmax, ymin, 0], + [xmax, ymax, 0], ] ).astype(np.float32) @@ -164,8 +164,8 @@ def __init__( # position data for the left edge line bottom_line_data = np.array( [ - [-height / 2, ymax, 0], - [height / 2, ymax, 0], + [xmin, ymax, 0], + [xmax, ymax, 0], ] ).astype(np.float32) @@ -177,8 +177,8 @@ def __init__( # position data for the right edge line top_line_data = np.array( [ - [-height / 2, 0, 0], - [height / 2 + xmax, 0, 0], + [xmin, ymin, 0], + [xmax, ymin, 0], ] ).astype(np.float32) From f8b7f0229823c8eb70ac1b5203c8bb3ceb5c4332 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Sat, 3 Aug 2024 12:23:01 -0400 Subject: [PATCH 07/24] add fixed axis if given, make sure bounds enforced --- .../graphics/_features/_selection_features.py | 13 +++++++++++-- fastplotlib/graphics/selectors/_rectangle.py | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 1d8bfce4d..29b722fd5 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -254,9 +254,18 @@ def set_value(self, selector, value: Sequence[float]): "where `xmin`, `xmax`, `ymin`, `ymax` are numeric values." ) - # convert to array, clip values if they are beyond the limits - # clip x + # convert to array value = np.asarray(value, dtype=np.float32) + + # check for fixed axis + if self.axis == "x": + value[2] = self.value[2] + value[3] = self.value[3] + elif self.axis == "y": + value[1] = self.value[1] + value[0] = self.value[0] + + # 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]) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index 4feb7f5fb..0cc64b6ba 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -61,7 +61,7 @@ def __init__( name: str = None, ): """ - Create a RectangleRegionSelector graphic which can be used to select a rectangular region of data. + 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 @@ -201,6 +201,12 @@ def __init__( self._selection = RectangleSelectionFeature(selection, axis=axis, 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, @@ -210,6 +216,7 @@ def __init__( axis=axis, parent=parent, name=name, + offset=offset, ) self._set_world_object(group) @@ -232,6 +239,14 @@ def _move_graphic(self, delta: np.ndarray): # 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 From 72defab9bf7cc22ae301c5230e88b20f8e67558d Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 5 Aug 2024 09:24:54 -0400 Subject: [PATCH 08/24] add methods for graphics --- .../graphics/_features/_selection_features.py | 3 ++ fastplotlib/graphics/image.py | 44 ++++++++++++++++++- fastplotlib/graphics/selectors/_rectangle.py | 21 ++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 29b722fd5..11b2e1902 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -332,5 +332,8 @@ def set_value(self, selector, value: Sequence[float]): 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..91bd19337 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -4,7 +4,7 @@ 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 +393,45 @@ 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, + axis: str = 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 + axis: str, default None + Optional string to restrict the movement of the selector along one axis. If passed, should + be one of "x" or "y". + fill_color: (float, float, float), optional + The fill color of the selector. + """ + + # default selection is 25% of the image + if selection is None: + selection = (0, int(self._data.value.shape[0] / 4), 0, self._data.value.shape[1] / 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, + axis=axis, + fill_color=fill_color, + parent=self, + **kwargs + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index 0cc64b6ba..bb6bc6f7c 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -226,7 +226,26 @@ def __init__( def get_selected_data(self): pass - def get_selected_indices(self): + 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 np.ndarray if the graphic is a collection + """ + # get indices from source + source = self._get_source(graphic) + + pass def _move_graphic(self, delta: np.ndarray): From 3f068cf9ef885a9651a5ee20d736fe3a59b9a71a Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 5 Aug 2024 10:14:09 -0400 Subject: [PATCH 09/24] get selected ixs and data for images --- fastplotlib/graphics/selectors/_rectangle.py | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index bb6bc6f7c..9056381a7 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -223,8 +223,18 @@ def __init__( self.selection = selection - def get_selected_data(self): - pass + def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: + """ + + """ + source = self._get_source(graphic) + ixs = self.get_selected_indices(source) + + if "Image" in source.__class__.__name__: + s_x = slice(ixs[0][0], ixs[0][-1] + 1) + s_y = slice(ixs[1][0], ixs[1][-1] + 1) + + return source.data[s_x, s_y] def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: """ @@ -245,8 +255,13 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis # get indices from source source = self._get_source(graphic) + # selector (xmin, xmax, ymin, ymax) values + bounds = self.selection - pass + if "Image" in source.__class__.__name__: + ys = np.arange(bounds[0], bounds[1], dtype=int) + xs = np.arange(bounds[2], bounds[3], dtype=int) + return [xs, ys] def _move_graphic(self, delta: np.ndarray): From dcc3774d32e9aeebdc7e46c0a5316327370e1ff7 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 5 Aug 2024 12:13:44 -0400 Subject: [PATCH 10/24] add rectangle methods --- fastplotlib/graphics/line.py | 90 ++++++++++++++++---- fastplotlib/graphics/line_collection.py | 52 ++++++++++- fastplotlib/graphics/selectors/_rectangle.py | 60 +++++++++++++ 3 files changed, 183 insertions(+), 19 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index f352dfde5..8a9e185d3 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 @@ -13,16 +13,16 @@ class LineGraphic(PositionsGraphic): _features = {"data", "colors", "cmap", "thickness"} def __init__( - self, - data: Any, - thickness: float = 2.0, - colors: str | np.ndarray | Iterable = "w", - uniform_color: bool = False, - alpha: float = 1.0, - cmap: str = None, - cmap_transform: np.ndarray | Iterable = None, - isolated_buffer: bool = True, - **kwargs, + self, + data: Any, + thickness: float = 2.0, + colors: str | np.ndarray | Iterable = "w", + uniform_color: bool = False, + alpha: float = 1.0, + cmap: str = None, + cmap_transform: np.ndarray | Iterable = None, + isolated_buffer: bool = True, + **kwargs, ): """ Create a line Graphic, 2d or 3d @@ -106,7 +106,7 @@ def thickness(self, value: float): self._thickness.set_value(self, value) def add_linear_selector( - self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs ) -> LinearSelector: """ Adds a linear selector. @@ -158,11 +158,11 @@ def add_linear_selector( return selector def add_linear_region_selector( - self, - selection: tuple[float, float] = None, - padding: float = 0.0, - axis: str = "x", - **kwargs, + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -216,9 +216,63 @@ 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, + axis: str = 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 + axis: str, default None + Optional string to restrict the movement of the selector along one axis. If passed, should + be one of "x" or "y". + fill_color: (float, float, float), optional + The fill color of the selector. + """ + # 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, + axis=axis, + fill_color=fill_color, + 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 + self, axis: str, padding ) -> tuple[tuple[float, float], tuple[float, float], float, float]: # computes args to create selectors n_datapoints = self.data.value.shape[0] diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 666d441e4..8a8cedbad 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,56 @@ 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, + axis: str = 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 + axis: str, default None + Optional string to restrict the movement of the selector along one axis. If passed, should + be one of "x" or "y". + fill_color: (float, float, float), optional + The fill color of the selector. + """ + 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, ymax = (np.nanmin(ydata), np.nanmax(ydata)) + + size = np.ptp(bbox[:, 1]) + + if selection is None: + selection = (xmin, value_25px, ymin, size) + + limits = (xmin, xmax, ymin - 15, size * 1.1) + + selector = RectangleSelector( + selection=selection, + limits=limits, + axis=axis, + fill_color=fill_color, + 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/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index 9056381a7..567d2e126 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -225,7 +225,21 @@ def __init__( def get_selected_data(self, graphic: Graphic = None) -> 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`` + + Returns + ------- + 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) @@ -236,6 +250,33 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n return source.data[s_x, s_y] + if "Line" in source.__class__.__name__: + if isinstance(source, GraphicCollection): + data_selections: List[np.ndarray] = list() + + for i, g in enumerate(source.graphics): + if ixs[i].size == 0: + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) + else: + 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]) + else: + 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 + 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. @@ -263,6 +304,25 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis xs = 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]) & + (data[:, 0] <= bounds[1]) & + (data[:, 1] >= bounds[2]) & + (data[:, 1] <= bounds[3]))[0] + ixs.append(g_ixs) + else: + # map this 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 From 4b7e00d16029db4e6829cc856da52ab1f5766922 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 5 Aug 2024 12:28:29 -0400 Subject: [PATCH 11/24] better lower bound limit for collections --- fastplotlib/graphics/line_collection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 8a8cedbad..66580b954 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -475,14 +475,14 @@ def add_rectangle_selector( value_25px = (xmax - xmin) / 4 ydata = np.array(self.data[:, 1]) - ymin, ymax = (np.nanmin(ydata), np.nanmax(ydata)) + ymin = np.floor(ydata.min()).astype(int) - size = np.ptp(bbox[:, 1]) + ymax = np.ptp(bbox[:, 1]) if selection is None: - selection = (xmin, value_25px, ymin, size) + selection = (xmin, value_25px, ymin, ymax) - limits = (xmin, xmax, ymin - 15, size * 1.1) + limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) selector = RectangleSelector( selection=selection, From 4dda74314c0cd16a3895a8c55b98cbbec914e12f Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 6 Aug 2024 14:23:01 -0400 Subject: [PATCH 12/24] add vertices --- .../graphics/_features/_selection_features.py | 20 +++++++- fastplotlib/graphics/image.py | 8 +-- fastplotlib/graphics/selectors/_rectangle.py | 50 ++++++++++++++++++- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 11b2e1902..b7b9fac4f 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -317,7 +317,22 @@ def set_value(self, selector, value: Sequence[float]): selector.edges[3].geometry.positions.data[:] = np.array( [[xmin, ymax, z], [xmax, ymax, z]] ) - # + + + # change the vertice 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 @@ -326,6 +341,9 @@ def set_value(self, selector, value: Sequence[float]): 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 diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 91bd19337..8d84abcaf 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,3 +1,4 @@ +import math from typing import * import pygfx @@ -415,10 +416,11 @@ def add_rectangle_selector( fill_color: (float, float, float), optional The fill color of the selector. """ - - # default selection is 25% of the image + # default selection is 25% of the diagonal if selection is None: - selection = (0, int(self._data.value.shape[0] / 4), 0, self._data.value.shape[1] / 4) + 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]) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index 567d2e126..b546122df 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -199,6 +199,43 @@ def __init__( 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=[edge_thickness]), + pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_space="world", size_mode="vertex"), + ) + + top_right_vertex = pygfx.Points( + pygfx.Geometry(positions=[top_right_vertex_data], sizes=[edge_thickness]), + pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_space="world", size_mode="vertex"), + ) + + bottom_left_vertex = pygfx.Points( + pygfx.Geometry(positions=[bottom_left_vertex_data], sizes=[edge_thickness]), + pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_space="world", size_mode="vertex"), + ) + + bottom_right_vertex = pygfx.Points( + pygfx.Geometry(positions=[bottom_right_vertex_data], sizes=[edge_thickness]), + pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_space="world", size_mode="vertex"), + ) + + 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, axis=axis, limits=self._limits) # include parent offset @@ -211,7 +248,8 @@ def __init__( self, edges=self.edges, fill=(self.fill,), - hover_responsive=self.edges, + vertices=self.vertices, + hover_responsive=(*self.edges, *self.vertices), arrow_keys_modifier=arrow_keys_modifier, axis=axis, parent=parent, @@ -315,7 +353,7 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis (data[:, 1] <= bounds[3]))[0] ixs.append(g_ixs) else: - # map this only this graphic + # map only this graphic data = source.data.value ixs = np.where((data[:, 0] >= bounds[0]) & (data[:, 0] <= bounds[1]) & @@ -351,6 +389,14 @@ def _move_graphic(self, delta: np.ndarray): 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)) From 4ab552f9d18b640833bb7b4afba223f32717e4cd Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 6 Aug 2024 14:37:11 -0400 Subject: [PATCH 13/24] screen space instead of world space for vertices --- fastplotlib/graphics/selectors/_rectangle.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index b546122df..b50e59e68 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -207,22 +207,22 @@ def __init__( top_left_vertex = pygfx.Points( pygfx.Geometry(positions=[top_left_vertex_data], sizes=[edge_thickness]), - pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_space="world", size_mode="vertex"), + pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_mode="vertex"), ) top_right_vertex = pygfx.Points( pygfx.Geometry(positions=[top_right_vertex_data], sizes=[edge_thickness]), - pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_space="world", size_mode="vertex"), + pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_mode="vertex"), ) bottom_left_vertex = pygfx.Points( pygfx.Geometry(positions=[bottom_left_vertex_data], sizes=[edge_thickness]), - pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_space="world", size_mode="vertex"), + pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_mode="vertex"), ) bottom_right_vertex = pygfx.Points( pygfx.Geometry(positions=[bottom_right_vertex_data], sizes=[edge_thickness]), - pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_space="world", size_mode="vertex"), + pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_mode="vertex"), ) self.vertices: Tuple[pygfx.Points, pygfx.Points, pygfx.Points, pygfx.Points] = ( From 0a63cac4d044bacd88376fb97a33648e560d7de2 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 7 Aug 2024 09:39:15 -0400 Subject: [PATCH 14/24] change vertices to square, change vertex color --- fastplotlib/graphics/selectors/_rectangle.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index b50e59e68..45da851b2 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -57,6 +57,8 @@ def __init__( fill_color=(0, 0, 0.35), edge_color=(0.8, 0.6, 0), edge_thickness: float = 8, + vertex_color=(0, 0, 0), + vertex_thickness: float = 8, arrow_keys_modifier: str = "Shift", name: str = None, ): @@ -206,23 +208,23 @@ def __init__( bottom_right_vertex_data = (xmax, ymin, 1) top_left_vertex = pygfx.Points( - pygfx.Geometry(positions=[top_left_vertex_data], sizes=[edge_thickness]), - pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_mode="vertex"), + 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=[edge_thickness]), - pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_mode="vertex"), + 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=[edge_thickness]), - pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_mode="vertex"), + 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=[edge_thickness]), - pygfx.PointsMaterial(size=edge_thickness, color=edge_color, size_mode="vertex"), + 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] = ( From 3e33aa532eecf1d3d2c3c8be3d96b6132b90d346 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 8 Aug 2024 10:06:08 -0400 Subject: [PATCH 15/24] fix selection for line collections --- fastplotlib/graphics/selectors/_rectangle.py | 71 +++++++++++++++++--- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index 45da851b2..7b41af96e 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -1,3 +1,4 @@ +import warnings from numbers import Real from typing import * import numpy as np @@ -57,7 +58,7 @@ def __init__( fill_color=(0, 0, 0.35), edge_color=(0.8, 0.6, 0), edge_thickness: float = 8, - vertex_color=(0, 0, 0), + vertex_color=(0.7, 0.4, 0), vertex_thickness: float = 8, arrow_keys_modifier: str = "Shift", name: str = None, @@ -263,7 +264,7 @@ def __init__( self.selection = selection - def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: + 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. @@ -275,6 +276,13 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n ---------- 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 + alone one axis are not fully covered by the selector. + If 'partial' will return only the data that is bounded within the limits. + If 'ignore', will not return data if selector is not fully covering the graphic along the axes bounds. Returns ------- @@ -284,13 +292,19 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n 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__: s_x = slice(ixs[0][0], ixs[0][-1] + 1) s_y = slice(ixs[1][0], ixs[1][-1] + 1) return source.data[s_x, s_y] + 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() @@ -304,7 +318,25 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n ixs[i][0], ixs[i][-1] + 1 ) # add 1 because these are direct indices # slices n_datapoints dim - data_selections.append(g.data[s]) + + missing_ixs = np.setdiff1d(np.arange(ixs[i][0], ixs[i][-1] + 1), ixs[i]) - ixs[i][0] + + match mode: + case "full": + data_selections.append(g.data[s]) + 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]) + 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: if ixs.size == 0: # empty selection @@ -315,7 +347,27 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n ) # 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] + + # get missing ixs + missing_ixs = np.setdiff1d(np.arange(ixs[0], ixs[-1] + 1), ixs) - ixs[0] + + match mode: + case "full": + return source.data[s] + case "partial": + if len(missing_ixs) > 0: + data = source.data[s].copy() + data[missing_ixs] = np.nan + return data + else: + return source.data[s] + 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]]: """ @@ -339,6 +391,8 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis # 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__: ys = np.arange(bounds[0], bounds[1], dtype=int) xs = np.arange(bounds[2], bounds[3], dtype=int) @@ -349,10 +403,10 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis ixs = list() for g in source.graphics: data = g.data.value - g_ixs = np.where((data[:, 0] >= bounds[0]) & - (data[:, 0] <= bounds[1]) & - (data[:, 1] >= bounds[2]) & - (data[:, 1] <= bounds[3]))[0] + 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 @@ -361,6 +415,7 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis (data[:, 0] <= bounds[1]) & (data[:, 1] >= bounds[2]) & (data[:, 1] <= bounds[3]))[0] + return ixs def _move_graphic(self, delta: np.ndarray): From 74f0925ca117d611bc7a876837107f51db7e416b Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 8 Aug 2024 10:24:46 -0400 Subject: [PATCH 16/24] add example --- .../desktop/selectors/rectangle_selector.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 examples/desktop/selectors/rectangle_selector.py diff --git a/examples/desktop/selectors/rectangle_selector.py b/examples/desktop/selectors/rectangle_selector.py new file mode 100644 index 000000000..ab46acd6f --- /dev/null +++ b/examples/desktop/selectors/rectangle_selector.py @@ -0,0 +1,53 @@ +""" +Rectangle Selectors +=================== + +Example showing how to use a `RectangleSelector` with lines, line collections, and images +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import imageio.v3 as iio +import numpy as np +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() + + +# 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() From c466dabbf619309d006759fb9e1b05092a6e077a Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 8 Aug 2024 10:54:49 -0400 Subject: [PATCH 17/24] update api docs, add example --- docs/source/api/graphic_features/index.rst | 1 + docs/source/api/graphics/ImageGraphic.rst | 1 + docs/source/api/graphics/LineCollection.rst | 1 + docs/source/api/graphics/LineGraphic.rst | 1 + docs/source/api/graphics/LineStack.rst | 1 + docs/source/api/selectors/index.rst | 1 + examples/desktop/selectors/rectangle_selector.py | 2 ++ fastplotlib/graphics/_features/__init__.py | 2 +- 8 files changed, 9 insertions(+), 1 deletion(-) 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/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/examples/desktop/selectors/rectangle_selector.py b/examples/desktop/selectors/rectangle_selector.py index ab46acd6f..4fea6c02b 100644 --- a/examples/desktop/selectors/rectangle_selector.py +++ b/examples/desktop/selectors/rectangle_selector.py @@ -46,6 +46,8 @@ def update_data(ev): 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__": diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index 3670fb72e..7c8c01fcb 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -56,7 +56,7 @@ "TextOutlineThickness", "LinearSelectionFeature", "LinearRegionSelectionFeature", - "RectangleRegionSelectionFeature" + "RectangleSelectionFeature", "Name", "Offset", "Rotation", From ad203b24e5b7d6de410efd28b307cae7f589ba72 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 8 Aug 2024 10:57:56 -0400 Subject: [PATCH 18/24] lint --- fastplotlib/graphics/_features/__init__.py | 6 +- .../graphics/_features/_selection_features.py | 10 +- fastplotlib/graphics/image.py | 16 ++- fastplotlib/graphics/line.py | 46 +++---- fastplotlib/graphics/line_collection.py | 12 +- fastplotlib/graphics/selectors/_rectangle.py | 128 ++++++++++++------ 6 files changed, 135 insertions(+), 83 deletions(-) diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index 7c8c01fcb..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, RectangleSelectionFeature +from ._selection_features import ( + LinearSelectionFeature, + LinearRegionSelectionFeature, + RectangleSelectionFeature, +) from ._common import Name, Offset, Rotation, Visible, Deleted diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index b7b9fac4f..014c75943 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -213,11 +213,12 @@ class RectangleSelectionFeature(GraphicFeature): +----------+------------+-------------------------------------------+ """ + def __init__( - self, - value: tuple[float, float, float, float], - axis: str | None, - limits: tuple[float, float, float, float] + self, + value: tuple[float, float, float, float], + axis: str | None, + limits: tuple[float, float, float, float], ): super().__init__() @@ -318,7 +319,6 @@ def set_value(self, selector, value: Sequence[float]): [[xmin, ymax, z], [xmax, ymax, z]] ) - # change the vertice positions # bottom left diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 8d84abcaf..de65257ef 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -396,11 +396,11 @@ def add_linear_region_selector( return selector def add_rectangle_selector( - self, - selection: tuple[float, float, float, float] = None, - axis: str = None, - fill_color=(0, 0, 0.35, 0.2), - **kwargs + self, + selection: tuple[float, float, float, float] = None, + axis: str = None, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, ) -> RectangleSelector: """ Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -418,7 +418,9 @@ def add_rectangle_selector( """ # 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) + diagonal = math.sqrt( + self._data.value.shape[0] ** 2 + self._data.value.shape[1] ** 2 + ) selection = (0, int(diagonal / 4), 0, int(diagonal / 4)) @@ -431,7 +433,7 @@ def add_rectangle_selector( axis=axis, fill_color=fill_color, parent=self, - **kwargs + **kwargs, ) self._plot_area.add_graphic(selector, center=False) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 8a9e185d3..55b3b5065 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -13,16 +13,16 @@ class LineGraphic(PositionsGraphic): _features = {"data", "colors", "cmap", "thickness"} def __init__( - self, - data: Any, - thickness: float = 2.0, - colors: str | np.ndarray | Iterable = "w", - uniform_color: bool = False, - alpha: float = 1.0, - cmap: str = None, - cmap_transform: np.ndarray | Iterable = None, - isolated_buffer: bool = True, - **kwargs, + self, + data: Any, + thickness: float = 2.0, + colors: str | np.ndarray | Iterable = "w", + uniform_color: bool = False, + alpha: float = 1.0, + cmap: str = None, + cmap_transform: np.ndarray | Iterable = None, + isolated_buffer: bool = True, + **kwargs, ): """ Create a line Graphic, 2d or 3d @@ -106,7 +106,7 @@ def thickness(self, value: float): self._thickness.set_value(self, value) def add_linear_selector( - self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs ) -> LinearSelector: """ Adds a linear selector. @@ -158,11 +158,11 @@ def add_linear_selector( return selector def add_linear_region_selector( - self, - selection: tuple[float, float] = None, - padding: float = 0.0, - axis: str = "x", - **kwargs, + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -217,11 +217,11 @@ def add_linear_region_selector( return selector def add_rectangle_selector( - self, - selection: tuple[float, float, float, float] = None, - axis: str = None, - fill_color=(0, 0, 0.35, 0.2), - **kwargs + self, + selection: tuple[float, float, float, float] = None, + axis: str = None, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, ) -> RectangleSelector: """ Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -263,7 +263,7 @@ def add_rectangle_selector( axis=axis, fill_color=fill_color, parent=self, - **kwargs + **kwargs, ) self._plot_area.add_graphic(selector, center=False) @@ -272,7 +272,7 @@ def add_rectangle_selector( # TODO: this method is a bit of a mess, can refactor later def _get_linear_selector_init_args( - self, axis: str, padding + self, axis: str, padding ) -> tuple[tuple[float, float], tuple[float, float], float, float]: # computes args to create selectors n_datapoints = self.data.value.shape[0] diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 66580b954..69e4d6450 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -448,11 +448,11 @@ def add_linear_region_selector( return selector def add_rectangle_selector( - self, - selection: tuple[float, float, float, float] = None, - axis: str = None, - fill_color=(0, 0, 0.35, 0.2), - **kwargs + self, + selection: tuple[float, float, float, float] = None, + axis: str = None, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, ) -> RectangleSelector: """ Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -490,7 +490,7 @@ def add_rectangle_selector( axis=axis, fill_color=fill_color, parent=self, - **kwargs + **kwargs, ) self._plot_area.add_graphic(selector, center=False) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index 7b41af96e..ee16f12a1 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -49,19 +49,19 @@ def limits(self, values: Tuple[float, float, float, float]): self._selection._limits = self._limits def __init__( - self, - selection: Sequence[float], - limits: Sequence[float], - axis: str = None, - 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, + self, + selection: Sequence[float], + limits: Sequence[float], + axis: str = None, + 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. @@ -138,7 +138,7 @@ def __init__( group.add(self.fill) - #position data for the left edge line + # position data for the left edge line left_line_data = np.array( [ [xmin, ymin, 0], @@ -210,22 +210,50 @@ def __init__( 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), + 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), + 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), + 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), + 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] = ( @@ -239,7 +267,9 @@ def __init__( vertex.world.z = -0.25 group.add(vertex) - self._selection = RectangleSelectionFeature(selection, axis=axis, limits=self._limits) + self._selection = RectangleSelectionFeature( + selection, axis=axis, limits=self._limits + ) # include parent offset if parent is not None: @@ -264,7 +294,9 @@ def __init__( self.selection = selection - def get_selected_data(self, graphic: Graphic = None, mode: str = "full") -> Union[np.ndarray, List[np.ndarray]]: + 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. @@ -301,10 +333,11 @@ def get_selected_data(self, graphic: Graphic = None, mode: str = "full") -> Unio return source.data[s_x, s_y] if mode not in ["full", "partial", "ignore"]: - raise ValueError(f"`mode` must be one of 'full', 'partial', or 'ignore', you have passed {mode}") + 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() @@ -319,7 +352,10 @@ def get_selected_data(self, graphic: Graphic = None, mode: str = "full") -> Unio ) # add 1 because these are direct indices # slices n_datapoints dim - missing_ixs = np.setdiff1d(np.arange(ixs[i][0], ixs[i][-1] + 1), ixs[i]) - ixs[i][0] + missing_ixs = ( + np.setdiff1d(np.arange(ixs[i][0], ixs[i][-1] + 1), ixs[i]) + - ixs[i][0] + ) match mode: case "full": @@ -333,7 +369,9 @@ def get_selected_data(self, graphic: Graphic = None, mode: str = "full") -> Unio data_selections.append(g.data[s]) case "ignore": if len(missing_ixs) > 0: - data_selections.append(np.array([], dtype=np.float32).reshape(0, 3)) + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) else: data_selections.append(g.data[s]) return data_selections @@ -363,13 +401,17 @@ def get_selected_data(self, graphic: Graphic = None, mode: str = "full") -> Unio return source.data[s] 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`.") + 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]]: + 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. @@ -403,18 +445,22 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis 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] + 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] + ixs = np.where( + (data[:, 0] >= bounds[0]) + & (data[:, 0] <= bounds[1]) + & (data[:, 1] >= bounds[2]) + & (data[:, 1] <= bounds[3]) + )[0] return ixs @@ -446,13 +492,13 @@ def _move_graphic(self, delta: np.ndarray): xmin, xmax, ymin, ymax = self.selection - if self._move_info.source == self.vertices[0]: # bottom left + 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 + 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 + 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 + 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]: From 86b0d5d817f55f2fc5d2a10c84160e0d1e1b3254 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 8 Aug 2024 13:55:19 -0400 Subject: [PATCH 19/24] update conf and api files --- docs/source/api/gpu.rst | 6 +++ .../RectangleSelectionFeature.rst | 36 +++++++++++++ .../api/selectors/RectangleSelector.rst | 53 +++++++++++++++++++ docs/source/conf.py | 2 +- 4 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 docs/source/api/gpu.rst create mode 100644 docs/source/api/graphic_features/RectangleSelectionFeature.rst create mode 100644 docs/source/api/selectors/RectangleSelector.rst diff --git a/docs/source/api/gpu.rst b/docs/source/api/gpu.rst new file mode 100644 index 000000000..6f94aff23 --- /dev/null +++ b/docs/source/api/gpu.rst @@ -0,0 +1,6 @@ +fastplotlib.utils.gpu +********************* + +.. currentmodule:: fastplotlib.utils.gpu +.. automodule:: fastplotlib + :members: diff --git a/docs/source/api/graphic_features/RectangleSelectionFeature.rst b/docs/source/api/graphic_features/RectangleSelectionFeature.rst new file mode 100644 index 000000000..476b7d32a --- /dev/null +++ b/docs/source/api/graphic_features/RectangleSelectionFeature.rst @@ -0,0 +1,36 @@ +.. _api.RectangleSelectionFeature: + +RectangleSelectionFeature +************************* + +========================= +RectangleSelectionFeature +========================= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectionFeature_api + + RectangleSelectionFeature + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectionFeature_api + + RectangleSelectionFeature.axis + 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/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/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), } From 4ffffa195f15cdc41d9f299108eb101046e9d711 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 8 Aug 2024 14:03:46 -0400 Subject: [PATCH 20/24] this file should be deleted --- docs/source/api/gpu.rst | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 docs/source/api/gpu.rst diff --git a/docs/source/api/gpu.rst b/docs/source/api/gpu.rst deleted file mode 100644 index 6f94aff23..000000000 --- a/docs/source/api/gpu.rst +++ /dev/null @@ -1,6 +0,0 @@ -fastplotlib.utils.gpu -********************* - -.. currentmodule:: fastplotlib.utils.gpu -.. automodule:: fastplotlib - :members: From 4962838df7f20b549017844034255d907dd6d27d Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 9 Aug 2024 10:22:15 -0400 Subject: [PATCH 21/24] requested changes, update with better example, more comments --- .../desktop/selectors/rectangle_selector.py | 49 +++++++++++------- .../graphics/_features/_selection_features.py | 21 ++------ fastplotlib/graphics/image.py | 9 ---- fastplotlib/graphics/line.py | 9 ---- fastplotlib/graphics/line_collection.py | 9 ---- fastplotlib/graphics/selectors/_rectangle.py | 50 +++++++++++-------- 6 files changed, 61 insertions(+), 86 deletions(-) diff --git a/examples/desktop/selectors/rectangle_selector.py b/examples/desktop/selectors/rectangle_selector.py index 4fea6c02b..a1ab950ba 100644 --- a/examples/desktop/selectors/rectangle_selector.py +++ b/examples/desktop/selectors/rectangle_selector.py @@ -8,42 +8,53 @@ # test_example = false # sphinx_gallery_pygfx_docs = 'screenshot' -import imageio.v3 as iio import numpy as np import fastplotlib as fpl +from itertools import product # create a figure figure = fpl.Figure( - shape=(2, 1), 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 -image_graphic = figure[0, 0].add_image(data=iio.imread("imageio:camera.png")) +line_collection = figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5) # add rectangle selector to image graphic -rectangle_selector = image_graphic.add_rectangle_selector() +rectangle_selector = line_collection.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 +# add event handler to highlight selected indices @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 +def color_indices(ev): + line_collection.cmap = "jet" + ixs = ev.get_selected_indices() - figure[1, 0].remove_graphic(zoom_ig) + # 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" - # 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() +# manually move selector to make a nice gallery image :D +rectangle_selector.selection = (15, 30, 15, 30) figure.show() diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 014c75943..e499e72c9 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -217,12 +217,10 @@ class RectangleSelectionFeature(GraphicFeature): def __init__( self, value: tuple[float, float, float, float], - axis: str | None, limits: tuple[float, float, float, float], ): super().__init__() - self._axis = axis self._limits = limits self._value = tuple(int(v) for v in value) @@ -233,11 +231,6 @@ def value(self) -> np.ndarray[float]: """ return self._value - @property - def axis(self) -> str: - """one of "x" | "y" """ - return self._axis - def set_value(self, selector, value: Sequence[float]): """ Set the selection of the rectangle selector. @@ -258,14 +251,6 @@ def set_value(self, selector, value: Sequence[float]): # convert to array value = np.asarray(value, dtype=np.float32) - # check for fixed axis - if self.axis == "x": - value[2] = self.value[2] - value[3] = self.value[3] - elif self.axis == "y": - value[1] = self.value[1] - value[0] = self.value[0] - # clip values if they are beyond the limits value[:2] = value[:2].clip(self._limits[0], self._limits[1]) # clip y @@ -319,7 +304,7 @@ def set_value(self, selector, value: Sequence[float]): [[xmin, ymax, z], [xmax, ymax, z]] ) - # change the vertice positions + # change the vertex positions # bottom left selector.vertices[0].geometry.positions.data[:] = np.array([[xmin, ymin, 1]]) @@ -334,10 +319,10 @@ def set_value(self, selector, value: Sequence[float]): 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() diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index de65257ef..0cb3f5568 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -398,8 +398,6 @@ def add_linear_region_selector( def add_rectangle_selector( self, selection: tuple[float, float, float, float] = None, - axis: str = None, - fill_color=(0, 0, 0.35, 0.2), **kwargs, ) -> RectangleSelector: """ @@ -410,11 +408,6 @@ def add_rectangle_selector( ---------- selection: (float, float, float, float), optional initial (xmin, xmax, ymin, ymax) of the selection - axis: str, default None - Optional string to restrict the movement of the selector along one axis. If passed, should - be one of "x" or "y". - fill_color: (float, float, float), optional - The fill color of the selector. """ # default selection is 25% of the diagonal if selection is None: @@ -430,8 +423,6 @@ def add_rectangle_selector( selector = RectangleSelector( selection=selection, limits=limits, - axis=axis, - fill_color=fill_color, parent=self, **kwargs, ) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 55b3b5065..1574587fe 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -219,8 +219,6 @@ def add_linear_region_selector( def add_rectangle_selector( self, selection: tuple[float, float, float, float] = None, - axis: str = None, - fill_color=(0, 0, 0.35, 0.2), **kwargs, ) -> RectangleSelector: """ @@ -231,11 +229,6 @@ def add_rectangle_selector( ---------- selection: (float, float, float, float), optional initial (xmin, xmax, ymin, ymax) of the selection - axis: str, default None - Optional string to restrict the movement of the selector along one axis. If passed, should - be one of "x" or "y". - fill_color: (float, float, float), optional - The fill color of the selector. """ # computes args to create selectors n_datapoints = self.data.value.shape[0] @@ -260,8 +253,6 @@ def add_rectangle_selector( selector = RectangleSelector( selection=selection, limits=limits, - axis=axis, - fill_color=fill_color, parent=self, **kwargs, ) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 69e4d6450..c4af5dddc 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -450,8 +450,6 @@ def add_linear_region_selector( def add_rectangle_selector( self, selection: tuple[float, float, float, float] = None, - axis: str = None, - fill_color=(0, 0, 0.35, 0.2), **kwargs, ) -> RectangleSelector: """ @@ -462,11 +460,6 @@ def add_rectangle_selector( ---------- selection: (float, float, float, float), optional initial (xmin, xmax, ymin, ymax) of the selection - axis: str, default None - Optional string to restrict the movement of the selector along one axis. If passed, should - be one of "x" or "y". - fill_color: (float, float, float), optional - The fill color of the selector. """ bbox = self.world_object.get_world_bounding_box() @@ -487,8 +480,6 @@ def add_rectangle_selector( selector = RectangleSelector( selection=selection, limits=limits, - axis=axis, - fill_color=fill_color, parent=self, **kwargs, ) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index ee16f12a1..8a23c4c81 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -52,7 +52,6 @@ def __init__( self, selection: Sequence[float], limits: Sequence[float], - axis: str = None, parent: Graphic = None, resizable: bool = True, fill_color=(0, 0, 0.35), @@ -75,11 +74,6 @@ def __init__( limits: (float, float, float, float) limits of the selector, ``(x_min, x_max, y_min, y_max)`` - 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 selection along the other axis. For example, - if you set ``axis="x"``, then the ``y_min``, ``y_max`` values of the selection will stay constant. - parent: Graphic, default ``None`` associate this selector with a parent Graphic @@ -267,9 +261,7 @@ def __init__( vertex.world.z = -0.25 group.add(vertex) - self._selection = RectangleSelectionFeature( - selection, axis=axis, limits=self._limits - ) + self._selection = RectangleSelectionFeature(selection, limits=self._limits) # include parent offset if parent is not None: @@ -284,7 +276,6 @@ def __init__( vertices=self.vertices, hover_responsive=(*self.edges, *self.vertices), arrow_keys_modifier=arrow_keys_modifier, - axis=axis, parent=parent, name=name, offset=offset, @@ -311,15 +302,16 @@ def get_selected_data( 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 - alone one axis are not fully covered by the selector. - If 'partial' will return only the data that is bounded within the limits. - If 'ignore', will not return data if selector is not fully covering the graphic along the axes bounds. + | 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 ``None`` if selection is empty + 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) @@ -327,10 +319,10 @@ def get_selected_data( # 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__: - s_x = slice(ixs[0][0], ixs[0][-1] + 1) - s_y = slice(ixs[1][0], ixs[1][-1] + 1) + row_ixs = slice(ixs[0][0], ixs[0][-1] + 1) + col_ixs = slice(ixs[1][0], ixs[1][-1] + 1) - return source.data[s_x, s_y] + return source.data[row_ixs, col_ixs] if mode not in ["full", "partial", "ignore"]: raise ValueError( @@ -342,24 +334,30 @@ def get_selected_data( 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() @@ -367,6 +365,7 @@ def get_selected_data( 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( @@ -375,7 +374,7 @@ def get_selected_data( else: data_selections.append(g.data[s]) return data_selections - else: + else: # for lines if ixs.size == 0: # empty selection return np.array([], dtype=np.float32).reshape(0, 3) @@ -390,8 +389,10 @@ def get_selected_data( 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() @@ -399,6 +400,8 @@ def get_selected_data( 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( @@ -425,7 +428,10 @@ def get_selected_indices( Returns ------- Union[np.ndarray, List[np.ndarray]] - data indicies of the selection, list of np.ndarray if the graphic is a collection + 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) @@ -436,8 +442,8 @@ def get_selected_indices( # image data does not need to check for mode because the selector is always bounded # to the image if "Image" in source.__class__.__name__: - ys = np.arange(bounds[0], bounds[1], dtype=int) - xs = np.arange(bounds[2], bounds[3], dtype=int) + 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__: From 28f247bdd87df042450de55580f666ffdcc2f398 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 9 Aug 2024 10:29:55 -0400 Subject: [PATCH 22/24] fix offset when adding rectangle selector so fill events happen --- fastplotlib/graphics/image.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 0cb3f5568..299c682b3 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -398,6 +398,7 @@ def add_linear_region_selector( def add_rectangle_selector( self, selection: tuple[float, float, float, float] = None, + fill_color=(0, 0, 0.35, 0.2), **kwargs, ) -> RectangleSelector: """ @@ -423,10 +424,14 @@ def add_rectangle_selector( 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 From 8e109b7fb1456e4f63299e9ad3ad2894c7c6f8d8 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 9 Aug 2024 10:32:41 -0400 Subject: [PATCH 23/24] update api docs --- docs/source/api/graphic_features/RectangleSelectionFeature.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/source/api/graphic_features/RectangleSelectionFeature.rst b/docs/source/api/graphic_features/RectangleSelectionFeature.rst index 476b7d32a..d35752a24 100644 --- a/docs/source/api/graphic_features/RectangleSelectionFeature.rst +++ b/docs/source/api/graphic_features/RectangleSelectionFeature.rst @@ -20,7 +20,6 @@ Properties .. autosummary:: :toctree: RectangleSelectionFeature_api - RectangleSelectionFeature.axis RectangleSelectionFeature.value Methods From da90444e407509fa2c749796a9b0d79d92b04d8a Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 20 Aug 2024 18:59:13 -0400 Subject: [PATCH 24/24] requested changes --- .../desktop/selectors/rectangle_selector.py | 2 +- .../selectors/rectangle_selector_zoom.py | 52 +++++++++++++++++++ fastplotlib/graphics/selectors/_rectangle.py | 4 +- 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 examples/desktop/selectors/rectangle_selector_zoom.py diff --git a/examples/desktop/selectors/rectangle_selector.py b/examples/desktop/selectors/rectangle_selector.py index a1ab950ba..48e8647ac 100644 --- a/examples/desktop/selectors/rectangle_selector.py +++ b/examples/desktop/selectors/rectangle_selector.py @@ -2,7 +2,7 @@ Rectangle Selectors =================== -Example showing how to use a `RectangleSelector` with lines, line collections, and images +Example showing how to use a `RectangleSelector` with line collections """ # test_example = false 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/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index 8a23c4c81..aae99803e 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -319,8 +319,8 @@ def get_selected_data( # 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__: - row_ixs = slice(ixs[0][0], ixs[0][-1] + 1) - col_ixs = slice(ixs[1][0], ixs[1][-1] + 1) + 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]