diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index cf8e1224d..3ca551195 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -55,6 +55,9 @@ Methods ScatterGraphic.add_axes ScatterGraphic.add_event_handler + ScatterGraphic.add_linear_region_selector + ScatterGraphic.add_linear_selector + ScatterGraphic.add_rectangle_selector ScatterGraphic.clear_event_handlers ScatterGraphic.format_pick_info ScatterGraphic.map_model_to_world diff --git a/examples/selection_tools/linear_region_scatter.py b/examples/selection_tools/linear_region_scatter.py new file mode 100644 index 000000000..c85eb8b57 --- /dev/null +++ b/examples/selection_tools/linear_region_scatter.py @@ -0,0 +1,65 @@ +""" +LinearRegionSelectors with ScatterGraphic +========================================= + +Example showing how to use a `LinearRegionSelector` with a scatter plot. We demonstrate two use cases, a horizontal +LinearRegionSelector which selects along the x-axis and a vertical selector which moves along the y-axis. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +# names for out subplots +names = [["scatter x", "scatter y"], ["zoomed x region", "zoomed y region"]] + +# 2 rows, 2 columns +figure = fpl.Figure( + (2, 2), + size=(700, 560), + names=names, +) + +scatter_x_data = (100 * np.random.random_sample(size=(500, 2))).astype(np.float32) +scatter_y_data = (100 * np.random.random_sample(size=(500, 2))).astype(np.float32) + +# plot scatter data +scatter_x = figure[0, 0].add_scatter(scatter_x_data, sizes=4) +scatter_y = figure[0, 1].add_scatter(scatter_y_data, sizes=4) + +# add linear selectors +selector_x = scatter_x.add_linear_region_selector((0, 100)) # default axis is "x" +selector_y = scatter_y.add_linear_region_selector(axis="y") + + +@selector_x.add_event_handler("selection") +def set_zoom_x(ev): + """sets zoomed x selector data""" + selected_data = ev.get_selected_data() + figure[1, 0].clear() + figure[1, 0].add_scatter(selected_data, sizes=10) + figure[1, 0].auto_scale() + + +@selector_y.add_event_handler("selection") +def set_zoom_y(ev): + """sets zoomed y selector data""" + selected_data = ev.get_selected_data() + figure[1, 1].clear() + figure[1, 1].add_scatter(selected_data, sizes=10) + figure[1, 1].auto_scale() + + +# set initial selection +selector_x.selection = (30, 60) +selector_y.selection = (30, 60) + +figure.show(maintain_aspect=False) + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/selection_tools/polygon_selector_scatter.py b/examples/selection_tools/polygon_selector_scatter.py new file mode 100644 index 000000000..54019abfa --- /dev/null +++ b/examples/selection_tools/polygon_selector_scatter.py @@ -0,0 +1,52 @@ +""" +Polygon Selectors with ScatterGraphic +===================================== + +Example showing how to use a `PolygonSelector` with a scatter plot. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +# create a figure +figure = fpl.Figure( + (1, 2), + size=(700, 560), + names=["scatter", "zoomed selection"], +) + +xys = (100 * np.random.random_sample(size=(2000, 2))).astype(np.float32) + +# add image +scatter = figure[0, 0].add_scatter(xys, cmap="jet", sizes=4) + +# add polygon selector to scatter graphic +polygon_selector = scatter.add_polygon_selector() + +# add event handler to highlight selected indices and display selected data in zoomed plot +@polygon_selector.add_event_handler("selection") +def color_indices(ev): + figure[0, 1].clear() + scatter.cmap = "jet" + scatter.sizes = 4 + ixs = ev.get_selected_indices() + if ixs.size == 0: + return + scatter.colors[ixs] = 'w' + scatter.sizes[ixs] = 8 + figure[0, 1].add_scatter(ev.get_selected_data(), sizes=16) + figure[0, 1].auto_scale() + +# set initial selection +polygon_selector.selection = [(50.0, 20.0,0.0), (80.0,80.0,0.0), (20.0, 50.0,0.0), (50.0, 50.0, 0.0), (50.0, 20.0,0.0)] + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/selection_tools/rectangle_selector_scatter.py b/examples/selection_tools/rectangle_selector_scatter.py new file mode 100644 index 000000000..037f1f85c --- /dev/null +++ b/examples/selection_tools/rectangle_selector_scatter.py @@ -0,0 +1,49 @@ +""" +Rectangle Selectors with ScatterGraphic +======================================= + +Example showing how to use a `RectangleSelector` with a scatter plot. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +# create a figure +figure = fpl.Figure( + size=(700, 560) +) + +xys = (100 * np.random.random_sample(size=(200, 2))).astype(np.float32) + +# add image +scatter = figure[0, 0].add_scatter(xys, cmap="jet", sizes=4) + +# add rectangle selector to image graphic +rectangle_selector = scatter.add_rectangle_selector() + +# add event handler to highlight selected indices +@rectangle_selector.add_event_handler("selection") +def color_indices(ev): + scatter.cmap = "jet" + scatter.sizes = 4 + ixs = ev.get_selected_indices() + if ixs.size == 0: + return + scatter.colors[ixs] = 'w' + scatter.sizes[ixs] = 8 + + +# manually move selector to make a nice gallery image :D +rectangle_selector.selection = (20, 40, 40, 60) + + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index af7d7badb..60e24aa80 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -4,6 +4,8 @@ import pygfx from ._base import Graphic +from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector +from ..utils import quick_min_max from .features import ( VertexPositions, VertexColors, @@ -147,6 +149,193 @@ def __init__( self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) + def add_linear_selector( + self, selection: float = None, axis: str = "x", **kwargs + ) -> LinearSelector: + """ + Adds a :class:`.LinearSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: float, optional + selected point on the linear selector, by default the first datapoint on the line. + + axis: str, default "x" + axis that the selector resides on + + kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding=0 + ) + + if selection is None: + selection = bounds_init[0] + + selector = LinearSelector( + selection=selection, + limits=limits, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_linear_region_selector( + 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, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float), optional + the starting bounds of the linear region selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init + + # create selector + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + # PlotArea manages this for garbage collection etc. just like all other Graphics + # so we should only work with a proxy on the user-end + return selector + + def add_rectangle_selector( + self, + selection: tuple[float, float, float, float] = None, + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + # computes args to create selectors + + # 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) + y25p = 0.25 * (ymax - ymin) + xmin = np.floor(x_axis_vals.min()).astype(int) + xmax = np.ceil(x_axis_vals.max()).astype(int) + x25p = 0.25 * (xmax - xmin) + + # default selection is 25% of the image + if selection is None: + selection = (xmin, xmin + x25p, ymin, ymax) + + # min/max limits include the data + 25% padding in the y-direction + limits = (xmin, xmax, ymin - y25p, ymax + y25p) + + selector = RectangleSelector( + selection=selection, + limits=limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + # TODO: this method is a bit of a mess, can refactor later + def _get_linear_selector_init_args( + self, axis: str, padding + ) -> tuple[tuple[float, float], tuple[float, float], float, float]: + # computes args to create selectors + + # remove any nans + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] + + if axis == "x": + # xvals + axis_vals = data[:, 0] + + # yvals to get size and center + magn_vals = data[:, 1] + elif axis == "y": + axis_vals = data[:, 1] + magn_vals = data[:, 0] + + axis_vals_min = np.floor(axis_vals.min()).astype(int) + axis_vals_max = np.ceil(axis_vals.max()).astype(int) + axis_vals_25p = axis_vals_min + 0.25 * (axis_vals_max - axis_vals_min) + + # default selection is 25% of the image + bounds_init = axis_vals_min, axis_vals_25p + limits = axis_vals_min, axis_vals_max + + # width or height of selector + size = int(np.ptp(magn_vals) * 1.5 + padding) + + # center of selector along the other axis + center = sum(quick_min_max(magn_vals)) / 2 + + return bounds_init, limits, size, center + def format_pick_info(self, pick_info: dict) -> str: index = pick_info["vertex_index"] info = "\n".join( diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index f2d862067..7cea88e9c 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,12 +5,7 @@ import pygfx from ._positions_base import PositionsGraphic -from .selectors import ( - LinearRegionSelector, - LinearSelector, - RectangleSelector, - PolygonSelector, -) +from .selectors import PolygonSelector from .features import ( Thickness, VertexPositions, @@ -19,7 +14,6 @@ VertexCmap, SizeSpace, ) -from ..utils import quick_min_max class LineGraphic(PositionsGraphic): @@ -139,156 +133,6 @@ def thickness(self) -> float: def thickness(self, value: float): self._thickness.set_value(self, value) - def add_linear_selector( - self, selection: float = None, axis: str = "x", **kwargs - ) -> LinearSelector: - """ - Adds a :class:`.LinearSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a - plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: float, optional - selected point on the linear selector, by default the first datapoint on the line. - - axis: str, default "x" - axis that the selector resides on - - kwargs - passed to :class:`.LinearSelector` - - Returns - ------- - LinearSelector - - """ - - bounds_init, limits, size, center = self._get_linear_selector_init_args( - axis, padding=0 - ) - - if selection is None: - selection = bounds_init[0] - - selector = LinearSelector( - selection=selection, - limits=limits, - axis=axis, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - def add_linear_region_selector( - 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, remove, or delete them from a - plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float), optional - the starting bounds of the linear region selector, computed from data if not provided - - axis: str, default "x" - axis that the selector resides on - - padding: float, default 0.0 - Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. - - kwargs - passed to ``LinearRegionSelector`` - - Returns - ------- - LinearRegionSelector - linear selection graphic - - """ - - bounds_init, limits, size, center = self._get_linear_selector_init_args( - axis, padding - ) - - if selection is None: - selection = bounds_init - - # create selector - selector = LinearRegionSelector( - selection=selection, - limits=limits, - size=size, - center=center, - axis=axis, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - # PlotArea manages this for garbage collection etc. just like all other Graphics - # so we should only work with a proxy on the user-end - return selector - - def add_rectangle_selector( - self, - selection: tuple[float, float, float, float] = None, - **kwargs, - ) -> RectangleSelector: - """ - Add a :class:`.RectangleSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a - plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float, float, float), optional - initial (xmin, xmax, ymin, ymax) of the selection - """ - # computes args to create selectors - n_datapoints = self.data.value.shape[0] - value_25p = int(n_datapoints / 4) - - # remove any nans - data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] - - x_axis_vals = data[:, 0] - y_axis_vals = data[:, 1] - - ymin = np.floor(y_axis_vals.min()).astype(int) - ymax = np.ceil(y_axis_vals.max()).astype(int) - - # default selection is 25% of the image - if selection is None: - selection = (x_axis_vals[0], x_axis_vals[value_25p], ymin, ymax) - - # min/max limits - limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) - - selector = RectangleSelector( - selection=selection, - limits=limits, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - def add_polygon_selector( self, selection: List[tuple[float, float]] = None, @@ -328,35 +172,3 @@ def add_polygon_selector( 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 - ) -> tuple[tuple[float, float], tuple[float, float], float, float]: - # 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)] - - if axis == "x": - # xvals - axis_vals = data[:, 0] - - # yvals to get size and center - magn_vals = data[:, 1] - elif axis == "y": - axis_vals = data[:, 1] - magn_vals = data[:, 0] - - bounds_init = axis_vals[0], axis_vals[value_25p] - limits = axis_vals[0], axis_vals[-1] - - # width or height of selector - size = int(np.ptp(magn_vals) * 1.5 + padding) - - # center of selector along the other axis - center = sum(quick_min_max(magn_vals)) / 2 - - return bounds_init, limits, size, center diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index a2e696a82..6c79da685 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -4,6 +4,7 @@ import pygfx from ._positions_base import PositionsGraphic +from .selectors import PolygonSelector from .features import ( VertexPointSizes, UniformSize, @@ -442,3 +443,47 @@ def sizes(self, value): elif isinstance(self._sizes, UniformSize): self._sizes.set_value(self, value) + + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: List of positions, optional + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). + """ + + # 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) + y25p = 0.25 * (ymax - ymin) + xmin = np.floor(x_axis_vals.min()).astype(int) + xmax = np.ceil(x_axis_vals.max()).astype(int) + x25p = 0.25 * (xmax - xmin) + + # min/max limits include the data + 25% padding in both directions + limits = (xmin - x25p, xmax + x25p, ymin - y25p, ymax + y25p) + + selector = PolygonSelector( + selection, + limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 70a8dffa8..d66a3859f 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -375,6 +375,9 @@ def get_selected_data( # slice with min, max is faster than using all the indices return source.data[s] + if "Scatter" in source.__class__.__name__: + return source.data[ixs] + if "Image" in source.__class__.__name__: s = slice(ixs[0], ixs[-1] + 1) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index e02c627ac..08d6fecda 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -19,7 +19,7 @@ class MoveInfo: """Movement info specific to the polygon selector.""" # The interaction mode: None, 'create', or 'drag' - mode: str + mode: str | None # The index of the point in the polygon that is currently being manipulated index: int @@ -57,6 +57,7 @@ def selection(self, selection: np.ndarray[float]): pass self._selection.set_value(self, selection) + self._move_info = MoveInfo(None, -1, -1, None, None) @property def limits(self) -> Tuple[float, float, float, float]: @@ -89,7 +90,7 @@ def __init__( self._resizable = bool(resizable) BaseSelector.__init__(self, name=name, parent=parent) - self._move_info = MoveInfo("none", -1, -1, None, None) + self._move_info = MoveInfo(None, -1, -1, None, None) # Initialize geometry with space for 8 points. The buffers are oversized, so we only need to create new buffers when the allocated space is full. # The points are 3D, even though the z-component is always 0. Indices represent the faces (i.e. the triangles). @@ -207,6 +208,9 @@ def get_selected_data( if "Image" in source.__class__.__name__: return source.data[ixs[:, 1], ixs[:, 0]] + if "Scatter" in source.__class__.__name__: + return source.data[ixs] + if mode not in ["full", "partial", "ignore"]: raise ValueError( f"`mode` must be one of 'full', 'partial', or 'ignore', you have passed {mode}" @@ -309,10 +313,11 @@ def get_selected_indices( Returns ------- Union[np.ndarray, List[np.ndarray]] - data indicies of the selection + data indices of the selection | array of (x, y) indices 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 + | array of indices if graphic is a scatter """ # get indices from source source = self._get_source(graphic) @@ -324,7 +329,7 @@ def get_selected_indices( if len(polygon) == 0: if "Image" in source.__class__.__name__: return np.zeros((0, 2), np.int32) - if "Line" in source.__class__.__name__: + if any([g in source.__class__.__name__ for g in ["Line", "Scatter"]]): if isinstance(source, GraphicCollection): return [np.zeros((0, 1), np.int32) for _ in source.graphics] else: @@ -348,7 +353,7 @@ def get_selected_indices( indices.append(p) return np.array(indices, np.int32).reshape(-1, 2) - if "Line" in source.__class__.__name__: + if any([g in source.__class__.__name__ for g in ["Line", "Scatter"]]): if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: @@ -366,7 +371,10 @@ def get_selected_indices( ixs.append(g_ixs) else: # map only this graphic - points = source.data.value[:2] + if "Scatter" in source.__class__.__name__: + points = source.data.value[:, :] + else: + points = source.data.value[:2] ixs = np.where( (points[:, 0] >= xmin) & (points[:, 0] <= xmax) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index e30165dae..e447252d2 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -513,7 +513,7 @@ def get_selected_indices( row_ixs = np.arange(ymin, ymax, dtype=int) return row_ixs, col_ixs - if "Line" in source.__class__.__name__: + if any([g in source.__class__.__name__ for g in ["Line", "Scatter"]]): if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: