From 9e9e7bf9935cc503f106a8aa04e02144ef02347d Mon Sep 17 00:00:00 2001 From: Johannes Soltwedel <38459088+jo-mueller@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:50:05 +0100 Subject: [PATCH] feat: add picker selector --- src/biaplotter/_tests/test_selectors.py | 46 +++++++- src/biaplotter/_version.py | 34 ++++++ src/biaplotter/icons/picker.png | Bin 0 -> 869 bytes src/biaplotter/icons/picker_checked.png | Bin 0 -> 1234 bytes src/biaplotter/plotter.py | 29 ++++- src/biaplotter/selectors.py | 147 ++++++++++++++++++++++++ 6 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 src/biaplotter/_version.py create mode 100644 src/biaplotter/icons/picker.png create mode 100644 src/biaplotter/icons/picker_checked.png diff --git a/src/biaplotter/_tests/test_selectors.py b/src/biaplotter/_tests/test_selectors.py index 7deef16..46b5e04 100644 --- a/src/biaplotter/_tests/test_selectors.py +++ b/src/biaplotter/_tests/test_selectors.py @@ -3,11 +3,39 @@ import pytest from biaplotter.plotter import CanvasWidget -from biaplotter.selectors import (BaseEllipseSelector, BaseLassoSelector, - BaseRectangleSelector, - InteractiveEllipseSelector, - InteractiveLassoSelector, - InteractiveRectangleSelector) +from biaplotter.selectors import ( + BaseEllipseSelector, BaseLassoSelector, BaseRectangleSelector, + InteractiveEllipseSelector, InteractiveLassoSelector, InteractiveRectangleSelector, + InteractiveClickSelector, BaseClickSelector +) + +# --- Parametrized tests for BaseClickSelector threshold functionality --- +@pytest.mark.parametrize( + "data, click, threshold, expected_indices", + [ + # Click near [1, 1], within threshold + (np.array([[0, 0], [1, 1], [2, 2]]), (1.025, 1.025), 0.2, [1]), + # Click far from all points, outside threshold + (np.array([[0, 0], [1, 1], [2, 2]]), (10, 10), 0.5, []), + # Click exactly on [2, 2], within threshold + (np.array([[0, 0], [1, 1], [2, 2]]), (2, 2), 0.1, [2]), + # Click between [0,0] and [1,1], closer to [0,0] + (np.array([[0, 0], [1, 1], [2, 2]]), (0.1, 0.1), 0.5, [0]), + # Click between [0,0] and [1,1], but threshold too small + (np.array([[0, 0], [1, 1], [2, 2]]), (0.1, 0.1), 0.05, []), + ] +) +def test_base_click_selector_threshold(data, click, threshold, expected_indices): + fig, ax = plt.subplots() + ax.scatter(data[:, 0], data[:, 1]) + selector = BaseClickSelector(ax) + selector.data = data + event = type('Event', (), {'xdata': click[0], 'ydata': click[1]})() + idx = selector.on_select(event, threshold=threshold) + if len(expected_indices) == 0: + assert len(idx) == 0, f"Expected empty array, got {idx}" + else: + assert np.array_equal(idx, expected_indices), f"Expected {expected_indices}, got {idx}" class MockMouseEvent: @@ -102,13 +130,15 @@ def selector_class(request): (InteractiveEllipseSelector, [0, 0, 1, 1, 1, 0]), # Test case for InteractiveLassoSelector (InteractiveLassoSelector, [0, 0, 1, 1, 1, 0]), + # Test case for InteractiveClickSelector + (InteractiveClickSelector, [0, 0, 1, 1, 1, 0]), ], indirect=["selector_class"], ) def test_interactive_selectors( make_napari_viewer, selector_class, expected_color_indices ): - """Test InteractiveRectangleSelector, InteractiveEllipseSelector, and InteractiveLassoSelector.""" + """Test InteractiveRectangleSelector, InteractiveEllipseSelector, InteractiveLassoSelector, and InteractiveClickSelector.""" viewer = make_napari_viewer() widget = CanvasWidget(viewer) selector = selector_class(widget.axes, widget) @@ -131,3 +161,7 @@ def test_interactive_selectors( ), "Color indices {} do not match expected values {}.".format( selector.color_indices, expected_color_indices ) + + +if __name__ == "__main__": + pytest.main([__file__]) \ No newline at end of file diff --git a/src/biaplotter/_version.py b/src/biaplotter/_version.py new file mode 100644 index 0000000..5708c52 --- /dev/null +++ b/src/biaplotter/_version.py @@ -0,0 +1,34 @@ +# file generated by setuptools-scm +# don't change, don't track in version control + +__all__ = [ + "__version__", + "__version_tuple__", + "version", + "version_tuple", + "__commit_id__", + "commit_id", +] + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Tuple + from typing import Union + + VERSION_TUPLE = Tuple[Union[int, str], ...] + COMMIT_ID = Union[str, None] +else: + VERSION_TUPLE = object + COMMIT_ID = object + +version: str +__version__: str +__version_tuple__: VERSION_TUPLE +version_tuple: VERSION_TUPLE +commit_id: COMMIT_ID +__commit_id__: COMMIT_ID + +__version__ = version = '0.4.3.dev0+g5bbac0ba8.d20260114' +__version_tuple__ = version_tuple = (0, 4, 3, 'dev0', 'g5bbac0ba8.d20260114') + +__commit_id__ = commit_id = 'g5bbac0ba8' diff --git a/src/biaplotter/icons/picker.png b/src/biaplotter/icons/picker.png new file mode 100644 index 0000000000000000000000000000000000000000..bf9fa3d9d78ef9bc72a42a8cb582ed691c4f0d4d GIT binary patch literal 869 zcmeAS@N?(olHy`uVBq!ia0vp^V9db=WF6OOo(-fp3p^r=85sBugD~Uq{1qucLCF%= zh?3y^w370~qEv?R@^Zb*yzJuS#DY}4{G#;P?`)(P7??hJx;TbZFut8@n0-4yr1k9# zv+QkrOS_kE@m`pH;#K^GmC2k-Cft9pa=+pP#gaEq;vbl%@RoQQm1@fFnwjUR`Oean z%c(c7$6|QMKdvJ7 zDd#5YM{U{nu!4VQ)>i(y9i~4x-pL zjmo@1E)FWm?ovxS8a$5mnRo~?PVA8-tnuWl6Za4PSp>ClJ;HMT8Q0tBerWtDvn0shbf1EOvd6vR zd*8k+?EfhdcWC_t-|wcsFD{*aBK_>K#_!^L+^@ag$YV2Ycfb0AUq$<}bKY#2#;Me9 zcKX+b%!1$cWt^WD{tmov{BY@6cJJ^F*J@^!&%K`hdy>L)k9!8jEb?aVC)WK|K7K;# z{KK7+O>dt4`?dM!{zvoI+t2o%`u1e|1H(;q8$J{*6W)Bc%V5dH6=hHEH@nowCF!J} z`1ai;?%eMa+9|6}{JK)d@0HuZa@_3NCD*VCmm5MtxOYv}O=Y#@Tqb+9DKFJK(sA0{ zQpVC|5l2(%#0%C4^$Bw?GR+Bh*}Bf|=_0!+iRHxnJzN6S!%x9O!S!2g>#m_5u z_u*f03>z8)m2G?0+Ha)AS{? ze>eZ(P3Pka?{}MOuG#o;PI8Z-yIoai`I+7EVh3N=J~~o-V~WssQ}cPnJ%%2B#t%+* zhAXg&ReqIsQoG2t@n9EFz51El7A5;CR-uaNOfkFI9o751H@q^OzV5l(x%H6^=?b$; z{}!vW1-(=^p1>B=*)XZc@{)s=V8A3pcPS55CNHCB9?UCU96XQ7WGYPM&^S3mnRkg0 zqpD=_lEx6F1uDsXCKJ3^rlg#iNQ~x+rylM57R7}B)6QDGO|X4q&5YG8iGPja8hP~7 ze=%}BH45AGL?U^T`QCrt*Fz(+w3nqY$+s`H{WxXLvl~&%f1B=KI&J!zml91U%{BPv z9muH>nSFWYhNJ5h{_a#ajGhwFX&Ll>#}ob&^Zx3VUR}~qe$M-0$?m4N!c*d2DdvC4 z*)08SgGtdhg>6~&i?VEWli~wPlTS>)zbI(Z!{3|wa~yTH%Ew+>$nBH3@fWY@v12Lr zyO=w#aBMmmcf@?pHOtB`HZBW4ty#LqpC~Kmu{Wm>b~{kHBoiFd#(!u zTpvX+EoJ6AsCP~Hz}|HVUsd`}?R|VApY(t7ND3oHaN$=Uarx{79CYCllQqj=BAGyJ}il;1=lfy1Z!9L!tJ~ z#_H2l>|&;;o)aktrK)EoQR&;Va*tTfoG2@K>(g`nj`Em)1!+&7U2fXLc4vaBU-@#@ zojSc@Pt2^Mo24gh+8a6H^yOpg%YUV48t%JX`!ljgJKO@~oqbo|K3-#6z%nys`BAgu z65Ml7NS~a2R(k^Dy^r~S-dr>N=fazO)8M*8m9xW2f%P&gOMEAuknm7_!$!O0I79ir z|Ag90rZOVWuVv(}JO1O4K=I$3dy1B9*|X_&>9dJ@ctY~)WBDMtD0ZH}L-q9z*DQBL z*DUV(dvmQpmdKI;Vst0Iml!6#xJL literal 0 HcmV?d00001 diff --git a/src/biaplotter/plotter.py b/src/biaplotter/plotter.py index c63709b..0acfd7b 100644 --- a/src/biaplotter/plotter.py +++ b/src/biaplotter/plotter.py @@ -14,9 +14,12 @@ from qtpy.QtGui import QCursor from biaplotter.artists import Histogram2D, Scatter -from biaplotter.selectors import (InteractiveEllipseSelector, - InteractiveLassoSelector, - InteractiveRectangleSelector) +from biaplotter.selectors import ( + InteractiveEllipseSelector, + InteractiveLassoSelector, + InteractiveRectangleSelector, + InteractiveClickSelector, +) if TYPE_CHECKING: import napari @@ -53,6 +56,12 @@ class CanvasWidget(BaseNapariMPLWidget): The widget includes a selection toolbar with buttons to enable/disable selection tools. The selection toolbar includes a color class spinbox to select the class to assign to selections. The widget includes artists and selectors to plot data and select points. + + Available selectors: + * Lasso + * Ellipse + * Rectangle + * Click (single-point picker) Parameters ---------- @@ -165,6 +174,7 @@ def _initialize_selector_toolbar(self, label_text: str): self.class_spinbox: QtColorSpinBox = class_spinbox self.show_overlay_button: CustomToolButton = show_overlay_button + # Add buttons to the toolbar self.selection_toolbar.add_custom_button( name="LASSO", @@ -190,6 +200,14 @@ def _initialize_selector_toolbar(self, label_text: str): checked_icon_path=icon_folder_path / "rectangle_checked.png", callback=self._on_toggle_button, ) + self.selection_toolbar.add_custom_button( + name="CLICK", + tooltip="Click to enable/disable single-point selection", + default_icon_path=icon_folder_path / "picker.png", + checkable=True, + checked_icon_path=icon_folder_path / "picker_checked.png", + callback=self._on_toggle_button, + ) # Add selection tools layout to the main layout self.layout().insertLayout(1, self.selection_tools_layout) @@ -212,6 +230,7 @@ def _initialize_selectors(self): InteractiveRectangleSelector | InteractiveEllipseSelector | InteractiveLassoSelector + | InteractiveClickSelector ) = None self.selectors: dict = {} self.add_selector( @@ -226,6 +245,10 @@ def _initialize_selectors(self): "RECTANGLE", InteractiveRectangleSelector(self.axes, self), ) + self.add_selector( + "CLICK", + InteractiveClickSelector(ax=self.axes, canvas_widget=self), + ) def _connect_signals(self): """ diff --git a/src/biaplotter/selectors.py b/src/biaplotter/selectors.py index 42df6cb..ebc11ce 100644 --- a/src/biaplotter/selectors.py +++ b/src/biaplotter/selectors.py @@ -440,6 +440,95 @@ def create_selector(self): ) +# --- Single Click Selector --- +class BaseClickSelector(Selector): + """Base class for creating a single-point (click) selector. + + Inherits all parameters and attributes from Selector. + For parameter and attribute details, see the Selector class documentation. + + Parameters + ---------- + ax : plt.Axes + axes to which the selector will be applied. + data : (N, 2) np.ndarray + data to be selected. + """ + + def __init__(self, ax: plt.Axes, data: np.ndarray = None): + super().__init__(ax, data) + self.name: str = "Click Selector" + self.data = data + self._cid = None # Matplotlib connection id + + def on_select(self, event, threshold: float = 0.025) -> np.ndarray: + """Selects the closest point to the click and returns its index, if within a threshold distance. + + Parameters + ---------- + event : MouseEvent + The mouse click event. + threshold : float, optional + Maximum allowed distance from the click to a data point. If None, always select the closest. + + Returns + ------- + np.ndarray or None + The index of the selected point (as a 1-element array), or None if no data or too far. + """ + if self._data is None or len(self._data) == 0: + return np.array([]) + if event.xdata is None or event.ydata is None: + return np.array([]) + click_point = np.array([event.xdata, event.ydata]) + dists = np.linalg.norm(self._data - click_point, axis=1) + idx = np.argmin(dists) + min_dist = dists[idx] + + # Default threshold: 2.5% of the max plot range + xlim = self.ax.get_xlim() + ylim = self.ax.get_ylim() + max_range = max(xlim[1] - xlim[0], ylim[1] - ylim[0]) + + if min_dist > threshold * max_range: + return np.array([])# Too far, do not select + return np.array([idx]) + + @property + def data(self) -> np.ndarray: + """Gets or sets the data from which points will be selected. + + Returns + ------- + np.ndarray + The data from which points will be selected. + """ + return self._data + + @data.setter + def data(self, value: np.ndarray): + self._data = value + + def create_selector(self): + """Creates a click selector by connecting a pick event to the axes canvas.""" + if self._cid is not None: + self.ax.figure.canvas.mpl_disconnect(self._cid) + self._cid = self.ax.figure.canvas.mpl_connect("button_press_event", self.on_select) + # Assign a dummy selector object for API consistency + # There is no actual selector widget for click selection in matplotlib, + # it's just the raw event that's used. + class DummySelector: + def clear(self): pass + def disconnect_events(self): pass + self._selector = DummySelector() + + def remove(self): + """Removes the click selector from the canvas.""" + if self._cid is not None: + self.ax.figure.canvas.mpl_disconnect(self._cid) + self._cid = None + + class Interactive(Selector): """Interactive selector class. @@ -771,3 +860,61 @@ def on_select(self, vertices: np.ndarray): """ self.selected_indices = super().on_select(vertices) self.apply_selection() + + +# --- Interactive Click Selector --- +class InteractiveClickSelector(Interactive, BaseClickSelector): + """Interactive click selector class. + + Inherits all parameters and attributes from Interactive and BaseClickSelector. + To be used as an interactive single-point selector for Scatter, or bin selector for Histogram2D. + + Parameters + ---------- + ax : plt.Axes + The axes to which the selector will be applied. + canvas_widget : biaplotter.plotter.CanvasWidget + The canvas widget to which the selector will be applied. + data : (N, 2) np.ndarray, optional + The data to be selected. + + Other Parameters + ---------------- + name : str + The name of the selector, set to 'Interactive Click Selector' by default. + """ + + def __init__( + self, + ax: plt.Axes, + canvas_widget: "CanvasWidget", + data: np.ndarray = None, + ): + super().__init__(ax, canvas_widget, data) + self.name: str = "Interactive Click Selector" + + def on_select(self, event): + """Selects the closest point to the click for Scatter, or all points in the clicked bin for Histogram2D.""" + artist = self.active_artist + if isinstance(artist, Histogram2D): + # Find the bin for the click + x_edges, y_edges = artist.histogram[1], artist.histogram[2] + if event.xdata is None or event.ydata is None: + return + bin_x = np.digitize(event.xdata, x_edges) - 1 + bin_y = np.digitize(event.ydata, y_edges) - 1 + if 0 <= bin_x < len(x_edges) - 1 and 0 <= bin_y < len(y_edges) - 1: + mask = ( + (artist.data[:, 0] >= x_edges[bin_x]) + & (artist.data[:, 0] < x_edges[bin_x + 1]) + & (artist.data[:, 1] >= y_edges[bin_y]) + & (artist.data[:, 1] < y_edges[bin_y + 1]) + ) + indices = np.where(mask)[0] + if len(indices) > 0: + self.selected_indices = indices + self.apply_selection() + else: + indices = super().on_select(event) + self.selected_indices = indices + self.apply_selection()