Skip to content

Commit 9e9e7bf

Browse files
committed
feat: add picker selector
1 parent 5bbac0b commit 9e9e7bf

6 files changed

Lines changed: 247 additions & 9 deletions

File tree

src/biaplotter/_tests/test_selectors.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,39 @@
33
import pytest
44

55
from biaplotter.plotter import CanvasWidget
6-
from biaplotter.selectors import (BaseEllipseSelector, BaseLassoSelector,
7-
BaseRectangleSelector,
8-
InteractiveEllipseSelector,
9-
InteractiveLassoSelector,
10-
InteractiveRectangleSelector)
6+
from biaplotter.selectors import (
7+
BaseEllipseSelector, BaseLassoSelector, BaseRectangleSelector,
8+
InteractiveEllipseSelector, InteractiveLassoSelector, InteractiveRectangleSelector,
9+
InteractiveClickSelector, BaseClickSelector
10+
)
11+
12+
# --- Parametrized tests for BaseClickSelector threshold functionality ---
13+
@pytest.mark.parametrize(
14+
"data, click, threshold, expected_indices",
15+
[
16+
# Click near [1, 1], within threshold
17+
(np.array([[0, 0], [1, 1], [2, 2]]), (1.025, 1.025), 0.2, [1]),
18+
# Click far from all points, outside threshold
19+
(np.array([[0, 0], [1, 1], [2, 2]]), (10, 10), 0.5, []),
20+
# Click exactly on [2, 2], within threshold
21+
(np.array([[0, 0], [1, 1], [2, 2]]), (2, 2), 0.1, [2]),
22+
# Click between [0,0] and [1,1], closer to [0,0]
23+
(np.array([[0, 0], [1, 1], [2, 2]]), (0.1, 0.1), 0.5, [0]),
24+
# Click between [0,0] and [1,1], but threshold too small
25+
(np.array([[0, 0], [1, 1], [2, 2]]), (0.1, 0.1), 0.05, []),
26+
]
27+
)
28+
def test_base_click_selector_threshold(data, click, threshold, expected_indices):
29+
fig, ax = plt.subplots()
30+
ax.scatter(data[:, 0], data[:, 1])
31+
selector = BaseClickSelector(ax)
32+
selector.data = data
33+
event = type('Event', (), {'xdata': click[0], 'ydata': click[1]})()
34+
idx = selector.on_select(event, threshold=threshold)
35+
if len(expected_indices) == 0:
36+
assert len(idx) == 0, f"Expected empty array, got {idx}"
37+
else:
38+
assert np.array_equal(idx, expected_indices), f"Expected {expected_indices}, got {idx}"
1139

1240

1341
class MockMouseEvent:
@@ -102,13 +130,15 @@ def selector_class(request):
102130
(InteractiveEllipseSelector, [0, 0, 1, 1, 1, 0]),
103131
# Test case for InteractiveLassoSelector
104132
(InteractiveLassoSelector, [0, 0, 1, 1, 1, 0]),
133+
# Test case for InteractiveClickSelector
134+
(InteractiveClickSelector, [0, 0, 1, 1, 1, 0]),
105135
],
106136
indirect=["selector_class"],
107137
)
108138
def test_interactive_selectors(
109139
make_napari_viewer, selector_class, expected_color_indices
110140
):
111-
"""Test InteractiveRectangleSelector, InteractiveEllipseSelector, and InteractiveLassoSelector."""
141+
"""Test InteractiveRectangleSelector, InteractiveEllipseSelector, InteractiveLassoSelector, and InteractiveClickSelector."""
112142
viewer = make_napari_viewer()
113143
widget = CanvasWidget(viewer)
114144
selector = selector_class(widget.axes, widget)
@@ -131,3 +161,7 @@ def test_interactive_selectors(
131161
), "Color indices {} do not match expected values {}.".format(
132162
selector.color_indices, expected_color_indices
133163
)
164+
165+
166+
if __name__ == "__main__":
167+
pytest.main([__file__])

src/biaplotter/_version.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# file generated by setuptools-scm
2+
# don't change, don't track in version control
3+
4+
__all__ = [
5+
"__version__",
6+
"__version_tuple__",
7+
"version",
8+
"version_tuple",
9+
"__commit_id__",
10+
"commit_id",
11+
]
12+
13+
TYPE_CHECKING = False
14+
if TYPE_CHECKING:
15+
from typing import Tuple
16+
from typing import Union
17+
18+
VERSION_TUPLE = Tuple[Union[int, str], ...]
19+
COMMIT_ID = Union[str, None]
20+
else:
21+
VERSION_TUPLE = object
22+
COMMIT_ID = object
23+
24+
version: str
25+
__version__: str
26+
__version_tuple__: VERSION_TUPLE
27+
version_tuple: VERSION_TUPLE
28+
commit_id: COMMIT_ID
29+
__commit_id__: COMMIT_ID
30+
31+
__version__ = version = '0.4.3.dev0+g5bbac0ba8.d20260114'
32+
__version_tuple__ = version_tuple = (0, 4, 3, 'dev0', 'g5bbac0ba8.d20260114')
33+
34+
__commit_id__ = commit_id = 'g5bbac0ba8'

src/biaplotter/icons/picker.png

869 Bytes
Loading
1.21 KB
Loading

src/biaplotter/plotter.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
from qtpy.QtGui import QCursor
1515

1616
from biaplotter.artists import Histogram2D, Scatter
17-
from biaplotter.selectors import (InteractiveEllipseSelector,
18-
InteractiveLassoSelector,
19-
InteractiveRectangleSelector)
17+
from biaplotter.selectors import (
18+
InteractiveEllipseSelector,
19+
InteractiveLassoSelector,
20+
InteractiveRectangleSelector,
21+
InteractiveClickSelector,
22+
)
2023

2124
if TYPE_CHECKING:
2225
import napari
@@ -53,6 +56,12 @@ class CanvasWidget(BaseNapariMPLWidget):
5356
The widget includes a selection toolbar with buttons to enable/disable selection tools.
5457
The selection toolbar includes a color class spinbox to select the class to assign to selections.
5558
The widget includes artists and selectors to plot data and select points.
59+
60+
Available selectors:
61+
* Lasso
62+
* Ellipse
63+
* Rectangle
64+
* Click (single-point picker)
5665
5766
Parameters
5867
----------
@@ -165,6 +174,7 @@ def _initialize_selector_toolbar(self, label_text: str):
165174
self.class_spinbox: QtColorSpinBox = class_spinbox
166175
self.show_overlay_button: CustomToolButton = show_overlay_button
167176

177+
168178
# Add buttons to the toolbar
169179
self.selection_toolbar.add_custom_button(
170180
name="LASSO",
@@ -190,6 +200,14 @@ def _initialize_selector_toolbar(self, label_text: str):
190200
checked_icon_path=icon_folder_path / "rectangle_checked.png",
191201
callback=self._on_toggle_button,
192202
)
203+
self.selection_toolbar.add_custom_button(
204+
name="CLICK",
205+
tooltip="Click to enable/disable single-point selection",
206+
default_icon_path=icon_folder_path / "picker.png",
207+
checkable=True,
208+
checked_icon_path=icon_folder_path / "picker_checked.png",
209+
callback=self._on_toggle_button,
210+
)
193211

194212
# Add selection tools layout to the main layout
195213
self.layout().insertLayout(1, self.selection_tools_layout)
@@ -212,6 +230,7 @@ def _initialize_selectors(self):
212230
InteractiveRectangleSelector
213231
| InteractiveEllipseSelector
214232
| InteractiveLassoSelector
233+
| InteractiveClickSelector
215234
) = None
216235
self.selectors: dict = {}
217236
self.add_selector(
@@ -226,6 +245,10 @@ def _initialize_selectors(self):
226245
"RECTANGLE",
227246
InteractiveRectangleSelector(self.axes, self),
228247
)
248+
self.add_selector(
249+
"CLICK",
250+
InteractiveClickSelector(ax=self.axes, canvas_widget=self),
251+
)
229252

230253
def _connect_signals(self):
231254
"""

src/biaplotter/selectors.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,95 @@ def create_selector(self):
440440
)
441441

442442

443+
# --- Single Click Selector ---
444+
class BaseClickSelector(Selector):
445+
"""Base class for creating a single-point (click) selector.
446+
447+
Inherits all parameters and attributes from Selector.
448+
For parameter and attribute details, see the Selector class documentation.
449+
450+
Parameters
451+
----------
452+
ax : plt.Axes
453+
axes to which the selector will be applied.
454+
data : (N, 2) np.ndarray
455+
data to be selected.
456+
"""
457+
458+
def __init__(self, ax: plt.Axes, data: np.ndarray = None):
459+
super().__init__(ax, data)
460+
self.name: str = "Click Selector"
461+
self.data = data
462+
self._cid = None # Matplotlib connection id
463+
464+
def on_select(self, event, threshold: float = 0.025) -> np.ndarray:
465+
"""Selects the closest point to the click and returns its index, if within a threshold distance.
466+
467+
Parameters
468+
----------
469+
event : MouseEvent
470+
The mouse click event.
471+
threshold : float, optional
472+
Maximum allowed distance from the click to a data point. If None, always select the closest.
473+
474+
Returns
475+
-------
476+
np.ndarray or None
477+
The index of the selected point (as a 1-element array), or None if no data or too far.
478+
"""
479+
if self._data is None or len(self._data) == 0:
480+
return np.array([])
481+
if event.xdata is None or event.ydata is None:
482+
return np.array([])
483+
click_point = np.array([event.xdata, event.ydata])
484+
dists = np.linalg.norm(self._data - click_point, axis=1)
485+
idx = np.argmin(dists)
486+
min_dist = dists[idx]
487+
488+
# Default threshold: 2.5% of the max plot range
489+
xlim = self.ax.get_xlim()
490+
ylim = self.ax.get_ylim()
491+
max_range = max(xlim[1] - xlim[0], ylim[1] - ylim[0])
492+
493+
if min_dist > threshold * max_range:
494+
return np.array([])# Too far, do not select
495+
return np.array([idx])
496+
497+
@property
498+
def data(self) -> np.ndarray:
499+
"""Gets or sets the data from which points will be selected.
500+
501+
Returns
502+
-------
503+
np.ndarray
504+
The data from which points will be selected.
505+
"""
506+
return self._data
507+
508+
@data.setter
509+
def data(self, value: np.ndarray):
510+
self._data = value
511+
512+
def create_selector(self):
513+
"""Creates a click selector by connecting a pick event to the axes canvas."""
514+
if self._cid is not None:
515+
self.ax.figure.canvas.mpl_disconnect(self._cid)
516+
self._cid = self.ax.figure.canvas.mpl_connect("button_press_event", self.on_select)
517+
# Assign a dummy selector object for API consistency
518+
# There is no actual selector widget for click selection in matplotlib,
519+
# it's just the raw event that's used.
520+
class DummySelector:
521+
def clear(self): pass
522+
def disconnect_events(self): pass
523+
self._selector = DummySelector()
524+
525+
def remove(self):
526+
"""Removes the click selector from the canvas."""
527+
if self._cid is not None:
528+
self.ax.figure.canvas.mpl_disconnect(self._cid)
529+
self._cid = None
530+
531+
443532
class Interactive(Selector):
444533
"""Interactive selector class.
445534
@@ -771,3 +860,61 @@ def on_select(self, vertices: np.ndarray):
771860
"""
772861
self.selected_indices = super().on_select(vertices)
773862
self.apply_selection()
863+
864+
865+
# --- Interactive Click Selector ---
866+
class InteractiveClickSelector(Interactive, BaseClickSelector):
867+
"""Interactive click selector class.
868+
869+
Inherits all parameters and attributes from Interactive and BaseClickSelector.
870+
To be used as an interactive single-point selector for Scatter, or bin selector for Histogram2D.
871+
872+
Parameters
873+
----------
874+
ax : plt.Axes
875+
The axes to which the selector will be applied.
876+
canvas_widget : biaplotter.plotter.CanvasWidget
877+
The canvas widget to which the selector will be applied.
878+
data : (N, 2) np.ndarray, optional
879+
The data to be selected.
880+
881+
Other Parameters
882+
----------------
883+
name : str
884+
The name of the selector, set to 'Interactive Click Selector' by default.
885+
"""
886+
887+
def __init__(
888+
self,
889+
ax: plt.Axes,
890+
canvas_widget: "CanvasWidget",
891+
data: np.ndarray = None,
892+
):
893+
super().__init__(ax, canvas_widget, data)
894+
self.name: str = "Interactive Click Selector"
895+
896+
def on_select(self, event):
897+
"""Selects the closest point to the click for Scatter, or all points in the clicked bin for Histogram2D."""
898+
artist = self.active_artist
899+
if isinstance(artist, Histogram2D):
900+
# Find the bin for the click
901+
x_edges, y_edges = artist.histogram[1], artist.histogram[2]
902+
if event.xdata is None or event.ydata is None:
903+
return
904+
bin_x = np.digitize(event.xdata, x_edges) - 1
905+
bin_y = np.digitize(event.ydata, y_edges) - 1
906+
if 0 <= bin_x < len(x_edges) - 1 and 0 <= bin_y < len(y_edges) - 1:
907+
mask = (
908+
(artist.data[:, 0] >= x_edges[bin_x])
909+
& (artist.data[:, 0] < x_edges[bin_x + 1])
910+
& (artist.data[:, 1] >= y_edges[bin_y])
911+
& (artist.data[:, 1] < y_edges[bin_y + 1])
912+
)
913+
indices = np.where(mask)[0]
914+
if len(indices) > 0:
915+
self.selected_indices = indices
916+
self.apply_selection()
917+
else:
918+
indices = super().on_select(event)
919+
self.selected_indices = indices
920+
self.apply_selection()

0 commit comments

Comments
 (0)