From acab9d05034fa00586acc06f41485105226dd64b Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Mon, 21 Jul 2025 15:51:24 +0200 Subject: [PATCH 01/15] Allow use of `add_*_selector` methods in `ScatterGraphic` - move these existing methods from `LineGraphic` to common base clase `PositionsGraphic` - regenerate docs --- docs/source/api/graphics/ScatterGraphic.rst | 3 + fastplotlib/graphics/_positions_base.py | 190 ++++++++++++++++++++ fastplotlib/graphics/line.py | 190 -------------------- 3 files changed, 193 insertions(+), 190 deletions(-) diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index 968f0e091..a02587f31 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -44,6 +44,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.remove_event_handler ScatterGraphic.rotate diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 4a4f5a797..14bb4c5ee 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, @@ -153,3 +155,191 @@ 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) + + # place selector above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) + + 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) + + # place selector below this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] - 1) + + # PlotArea manages this for garbage collection etc. just like all other Graphics + # so we should only work with a proxy on the user-end + return selector + + def add_rectangle_selector( + self, + selection: tuple[float, float, float, float] = None, + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + # computes args to create selectors + n_datapoints = self.data.value.shape[0] + value_25p = int(n_datapoints / 4) + + # remove any nans + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] + + x_axis_vals = data[:, 0] + y_axis_vals = data[:, 1] + + ymin = np.floor(y_axis_vals.min()).astype(int) + ymax = np.ceil(y_axis_vals.max()).astype(int) + + # default selection is 25% of the image + if selection is None: + selection = (x_axis_vals[0], x_axis_vals[value_25p], ymin, ymax) + + # min/max limits + limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) + + selector = RectangleSelector( + selection=selection, + limits=limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + # TODO: this method is a bit of a mess, can refactor later + def _get_linear_selector_init_args( + self, axis: str, padding + ) -> 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/line.py b/fastplotlib/graphics/line.py index 4cdc7f413..81b210b0b 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,7 +5,6 @@ import pygfx from ._positions_base import PositionsGraphic -from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector from .features import ( Thickness, VertexPositions, @@ -14,7 +13,6 @@ VertexCmap, SizeSpace, ) -from ..utils import quick_min_max class LineGraphic(PositionsGraphic): @@ -131,191 +129,3 @@ def thickness(self) -> float: @thickness.setter 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) - - # place selector above this graphic - selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - - 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) - - # place selector below this graphic - selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] - 1) - - # PlotArea manages this for garbage collection etc. just like all other Graphics - # so we should only work with a proxy on the user-end - return selector - - def add_rectangle_selector( - self, - selection: tuple[float, float, float, float] = None, - **kwargs, - ) -> RectangleSelector: - """ - Add a :class:`.RectangleSelector`. - - Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a - plot area just like any other ``Graphic``. - - Parameters - ---------- - selection: (float, float, float, float), optional - initial (xmin, xmax, ymin, ymax) of the selection - """ - # computes args to create selectors - n_datapoints = self.data.value.shape[0] - value_25p = int(n_datapoints / 4) - - # remove any nans - data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] - - x_axis_vals = data[:, 0] - y_axis_vals = data[:, 1] - - ymin = np.floor(y_axis_vals.min()).astype(int) - ymax = np.ceil(y_axis_vals.max()).astype(int) - - # default selection is 25% of the image - if selection is None: - selection = (x_axis_vals[0], x_axis_vals[value_25p], ymin, ymax) - - # min/max limits - limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) - - selector = RectangleSelector( - selection=selection, - limits=limits, - parent=self, - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - - return selector - - # TODO: this method is a bit of a mess, can refactor later - def _get_linear_selector_init_args( - self, axis: str, padding - ) -> 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 From 5ae771e598e5fe1aa08c8fa288300b8844fbdd65 Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Tue, 22 Jul 2025 09:57:56 +0200 Subject: [PATCH 02/15] Update linear/rectangle selectors to work with ScatterGraphics, add examples --- .../selection_tools/linear_region_scatter.py | 66 +++++++++++++++++++ .../rectangle_selector_scatter.py | 49 ++++++++++++++ fastplotlib/graphics/_positions_base.py | 17 +++-- .../graphics/selectors/_linear_region.py | 3 + fastplotlib/graphics/selectors/_rectangle.py | 5 +- 5 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 examples/selection_tools/linear_region_scatter.py create mode 100644 examples/selection_tools/rectangle_selector_scatter.py diff --git a/examples/selection_tools/linear_region_scatter.py b/examples/selection_tools/linear_region_scatter.py new file mode 100644 index 000000000..5b3a528bb --- /dev/null +++ b/examples/selection_tools/linear_region_scatter.py @@ -0,0 +1,66 @@ +""" +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 = false +# 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) +scatter_y = figure[0, 1].add_scatter(scatter_y_data) + +# 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/rectangle_selector_scatter.py b/examples/selection_tools/rectangle_selector_scatter.py new file mode 100644 index 000000000..0124c04e4 --- /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 = false +# 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 14bb4c5ee..0aa3d8ffc 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -283,7 +283,6 @@ def add_rectangle_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)] @@ -293,13 +292,15 @@ def add_rectangle_selector( ymin = np.floor(y_axis_vals.min()).astype(int) ymax = np.ceil(y_axis_vals.max()).astype(int) + xmin = np.floor(x_axis_vals.min()).astype(int) + xmax = np.ceil(x_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) + selection = (xmin, xmin + 0.25 * (xmax - xmin), ymin, ymax) # min/max limits - limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) + limits = (xmin, xmax, ymin, ymax) selector = RectangleSelector( selection=selection, @@ -318,7 +319,6 @@ def _get_linear_selector_init_args( ) -> 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)] @@ -333,8 +333,13 @@ def _get_linear_selector_init_args( axis_vals = data[:, 1] magn_vals = data[:, 0] - bounds_init = axis_vals[0], axis_vals[value_25p] - limits = axis_vals[0], axis_vals[-1] + axis_vals_min = np.floor(axis_vals.min()).astype(int) + axis_vals_max = np.floor(axis_vals.max()).astype(int) + + bounds_init = axis_vals_min, axis_vals_min + 0.25 * ( + axis_vals_max - axis_vals_min + ) + limits = axis_vals_min, axis_vals_max # width or height of selector size = int(np.ptp(magn_vals) * 1.5 + padding) diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index e93e2a147..5ae373102 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -301,6 +301,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/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index db7691e07..ebc6ce5aa 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -454,7 +454,10 @@ def get_selected_indices( row_ixs = np.arange(ymin, ymax, dtype=int) return row_ixs, col_ixs - if "Line" in source.__class__.__name__: + if ( + "Line" in source.__class__.__name__ + or "Scatter" in source.__class__.__name__ + ): if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: From c5ec08035fcdd35a7248b392a15f4fd36938c3d8 Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Wed, 23 Jul 2025 08:06:32 +0200 Subject: [PATCH 03/15] refactor bounds_init logic to make intent clearer, add comment, remove unused variables --- fastplotlib/graphics/_positions_base.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 0aa3d8ffc..d4cbe3248 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -282,7 +282,6 @@ def add_rectangle_selector( initial (xmin, xmax, ymin, ymax) of the selection """ # computes args to create selectors - n_datapoints = self.data.value.shape[0] # remove any nans data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] @@ -318,7 +317,6 @@ 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] # remove any nans data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] @@ -335,10 +333,10 @@ def _get_linear_selector_init_args( axis_vals_min = np.floor(axis_vals.min()).astype(int) axis_vals_max = np.floor(axis_vals.max()).astype(int) + axis_vals_25p = axis_vals_min + 0.25 * (axis_vals_max - axis_vals_min) - bounds_init = axis_vals_min, 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 From 75b7d73c7113c44482b23da9de26d9edddda75ed Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Wed, 23 Jul 2025 08:16:20 +0200 Subject: [PATCH 04/15] restore y-padding on rectangle selector limits, refactored to also work when ymin is positive --- fastplotlib/graphics/_positions_base.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index d4cbe3248..60074f786 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -291,15 +291,17 @@ def add_rectangle_selector( 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 + 0.25 * (xmax - xmin), ymin, ymax) + selection = (xmin, xmin + x25p, ymin, ymax) - # min/max limits - limits = (xmin, xmax, 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, @@ -332,7 +334,7 @@ def _get_linear_selector_init_args( magn_vals = data[:, 0] axis_vals_min = np.floor(axis_vals.min()).astype(int) - axis_vals_max = np.floor(axis_vals.max()).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 From e2d6b9f1536cda56e95ca6baf71a9e865c93e3e3 Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Tue, 30 Sep 2025 08:47:08 +0200 Subject: [PATCH 05/15] Add `add_polygon_selector` method to `ScatterGraphic` and create an example for it --- .../polygon_selector_scatter.py | 49 +++++++++++++++++++ fastplotlib/graphics/scatter.py | 44 +++++++++++++++++ fastplotlib/graphics/selectors/_polygon.py | 25 +++++++--- 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 examples/selection_tools/polygon_selector_scatter.py diff --git a/examples/selection_tools/polygon_selector_scatter.py b/examples/selection_tools/polygon_selector_scatter.py new file mode 100644 index 000000000..969e86d6e --- /dev/null +++ b/examples/selection_tools/polygon_selector_scatter.py @@ -0,0 +1,49 @@ +""" +Polygon Selectors with ScatterGraphic +===================================== + +Example showing how to use a `PolygonSelector` with a scatter plot. +""" + +# test_example = false +# 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() + +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/scatter.py b/fastplotlib/graphics/scatter.py index 73095714b..d1e4afa67 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 ( PointsSizesFeature, UniformSize, @@ -143,3 +144,46 @@ 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) + + # min/max limits include the data + 25% padding in the y-direction + limits = (xmin, xmax, 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/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index e02c627ac..22dfaf3b6 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 @@ -89,7 +89,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 +207,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 +312,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 +328,10 @@ 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 ( + "Line" in source.__class__.__name__ + or "Scatter" in graphic.__class__.__name__ + ): if isinstance(source, GraphicCollection): return [np.zeros((0, 1), np.int32) for _ in source.graphics] else: @@ -348,7 +355,10 @@ def get_selected_indices( indices.append(p) return np.array(indices, np.int32).reshape(-1, 2) - if "Line" in source.__class__.__name__: + if ( + "Line" in source.__class__.__name__ + or "Scatter" in source.__class__.__name__ + ): if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: @@ -366,7 +376,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) From 8776b2966b499007ecfc8a777b908c6b60cda1f9 Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Wed, 1 Oct 2025 16:00:19 +0200 Subject: [PATCH 06/15] allow selection to extend by 25% padding in all directions --- fastplotlib/graphics/scatter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index d1e4afa67..f88c2c6e9 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -173,9 +173,10 @@ def add_polygon_selector( 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 the y-direction - limits = (xmin, xmax, ymin - y25p, ymax + y25p) + # min/max limits include the data + 25% padding in both directions + limits = (xmin - x25p, xmax + x25p, ymin - y25p, ymax + y25p) selector = PolygonSelector( selection, From 7898ad28a17ed7ad2560169ace8a1135037d15d7 Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Wed, 1 Oct 2025 16:01:45 +0200 Subject: [PATCH 07/15] enable ground truth screenshots for scatter selector examples --- examples/selection_tools/linear_region_scatter.py | 2 +- examples/selection_tools/polygon_selector_scatter.py | 2 +- examples/selection_tools/rectangle_selector_scatter.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/selection_tools/linear_region_scatter.py b/examples/selection_tools/linear_region_scatter.py index 5b3a528bb..ed654457a 100644 --- a/examples/selection_tools/linear_region_scatter.py +++ b/examples/selection_tools/linear_region_scatter.py @@ -6,7 +6,7 @@ LinearRegionSelector which selects along the x-axis and a vertical selector which moves along the y-axis. """ -# test_example = false +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl diff --git a/examples/selection_tools/polygon_selector_scatter.py b/examples/selection_tools/polygon_selector_scatter.py index 969e86d6e..e49715762 100644 --- a/examples/selection_tools/polygon_selector_scatter.py +++ b/examples/selection_tools/polygon_selector_scatter.py @@ -5,7 +5,7 @@ Example showing how to use a `PolygonSelector` with a scatter plot. """ -# test_example = false +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np diff --git a/examples/selection_tools/rectangle_selector_scatter.py b/examples/selection_tools/rectangle_selector_scatter.py index 0124c04e4..037f1f85c 100644 --- a/examples/selection_tools/rectangle_selector_scatter.py +++ b/examples/selection_tools/rectangle_selector_scatter.py @@ -5,7 +5,7 @@ Example showing how to use a `RectangleSelector` with a scatter plot. """ -# test_example = false +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np From 82870b91e5eac1cff104d85679189015cc4f4b3e Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Wed, 1 Oct 2025 16:19:32 +0200 Subject: [PATCH 08/15] set initial polygon selection in example and reset _move_info after setting selection to complete selection --- examples/selection_tools/polygon_selector_scatter.py | 3 +++ fastplotlib/graphics/selectors/_polygon.py | 1 + 2 files changed, 4 insertions(+) diff --git a/examples/selection_tools/polygon_selector_scatter.py b/examples/selection_tools/polygon_selector_scatter.py index e49715762..54019abfa 100644 --- a/examples/selection_tools/polygon_selector_scatter.py +++ b/examples/selection_tools/polygon_selector_scatter.py @@ -40,6 +40,9 @@ def color_indices(ev): 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 diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 22dfaf3b6..4ec56644c 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -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]: From 2b607b5be483e6fae902e4220407a2014f52dfe1 Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Fri, 30 Jan 2026 09:43:56 +0100 Subject: [PATCH 09/15] Update fastplotlib/graphics/selectors/_polygon.py Co-authored-by: Kushal Kolar --- fastplotlib/graphics/selectors/_polygon.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 4ec56644c..98cd8c593 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -329,10 +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__ - or "Scatter" in graphic.__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: From 5313a055618126da1717f0b0b9cea5864fac99b1 Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Fri, 30 Jan 2026 09:44:55 +0100 Subject: [PATCH 10/15] Update fastplotlib/graphics/selectors/_rectangle.py Co-authored-by: Kushal Kolar --- fastplotlib/graphics/selectors/_rectangle.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index e923ed9fc..8a1266631 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -513,10 +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__ - or "Scatter" 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: From 481138b4cd29fc99fbf70ef4c679b9402197ec3a Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Fri, 30 Jan 2026 09:45:26 +0100 Subject: [PATCH 11/15] Update fastplotlib/graphics/selectors/_polygon.py Co-authored-by: Kushal Kolar --- fastplotlib/graphics/selectors/_polygon.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 98cd8c593..748e50d9c 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -353,10 +353,7 @@ def get_selected_indices( indices.append(p) return np.array(indices, np.int32).reshape(-1, 2) - if ( - "Line" in source.__class__.__name__ - or "Scatter" 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: From 41c3a0209c5a143c74774a8002d5a2ad72051124 Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Fri, 30 Jan 2026 09:44:30 +0100 Subject: [PATCH 12/15] run black on examples/selection_tools/linear_region_scatter.py --- examples/selection_tools/linear_region_scatter.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/examples/selection_tools/linear_region_scatter.py b/examples/selection_tools/linear_region_scatter.py index ed654457a..0fe10cbef 100644 --- a/examples/selection_tools/linear_region_scatter.py +++ b/examples/selection_tools/linear_region_scatter.py @@ -13,10 +13,7 @@ import numpy as np # names for out subplots -names = [ - ["scatter x", "scatter y"], - ["zoomed x region", "zoomed y region"] -] +names = [["scatter x", "scatter y"], ["zoomed x region", "zoomed y region"]] # 2 rows, 2 columns figure = fpl.Figure( @@ -25,8 +22,8 @@ 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) +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) @@ -36,6 +33,7 @@ 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""" @@ -53,6 +51,7 @@ def set_zoom_y(ev): 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) From 6e5b9230c610e4c68547ffe312cbd7863bd96a7d Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Fri, 30 Jan 2026 10:00:46 +0100 Subject: [PATCH 13/15] update add_*_selector to match main branch (keeping previous changes to support negative y-axis values) --- fastplotlib/graphics/_positions_base.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index c394e055c..60e24aa80 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -192,9 +192,6 @@ def add_linear_selector( self._plot_area.add_graphic(selector, center=False) - # place selector above this graphic - selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - return selector def add_linear_region_selector( @@ -251,9 +248,6 @@ def add_linear_region_selector( self._plot_area.add_graphic(selector, center=False) - # place selector below this graphic - selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] - 1) - # PlotArea manages this for garbage collection etc. just like all other Graphics # so we should only work with a proxy on the user-end return selector From 4598ae0912ba4094552e39bf00cf8da67c03d160 Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Fri, 30 Jan 2026 11:44:43 +0100 Subject: [PATCH 14/15] fix typos --- fastplotlib/graphics/selectors/_polygon.py | 4 ++-- fastplotlib/graphics/selectors/_rectangle.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 748e50d9c..08d6fecda 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -329,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 any([g in source.__class__.__name__ for g in ["Line", "Scatter"]]) + 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: @@ -353,7 +353,7 @@ def get_selected_indices( indices.append(p) return np.array(indices, np.int32).reshape(-1, 2) - if any([g in source.__class__.__name__ for g in ["Line", "Scatter"]]) + if any([g in source.__class__.__name__ for g in ["Line", "Scatter"]]): if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index 8a1266631..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 any([g in source.__class__.__name__ for g in ["Line", "Scatter"]]) + if any([g in source.__class__.__name__ for g in ["Line", "Scatter"]]): if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: From b0720b4159cd30e34893b8581b0becf530074f2e Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Fri, 30 Jan 2026 11:57:26 +0100 Subject: [PATCH 15/15] Add sizes to linear region scatter example --- examples/selection_tools/linear_region_scatter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/selection_tools/linear_region_scatter.py b/examples/selection_tools/linear_region_scatter.py index 0fe10cbef..c85eb8b57 100644 --- a/examples/selection_tools/linear_region_scatter.py +++ b/examples/selection_tools/linear_region_scatter.py @@ -26,8 +26,8 @@ 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) -scatter_y = figure[0, 1].add_scatter(scatter_y_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"