From acab9d05034fa00586acc06f41485105226dd64b Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Mon, 21 Jul 2025 15:51:24 +0200 Subject: [PATCH 1/4] 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 968f0e09..a02587f3 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 4a4f5a79..14bb4c5e 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 4cdc7f41..81b210b0 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 2/4] 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 00000000..5b3a528b --- /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 00000000..0124c04e --- /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 14bb4c5e..0aa3d8ff 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 e93e2a14..5ae37310 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 db7691e0..ebc6ce5a 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 3/4] 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 0aa3d8ff..d4cbe324 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 4/4] 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 d4cbe324..60074f78 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