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 0000000..bf9fa3d Binary files /dev/null and b/src/biaplotter/icons/picker.png differ diff --git a/src/biaplotter/icons/picker_checked.png b/src/biaplotter/icons/picker_checked.png new file mode 100644 index 0000000..19173ab Binary files /dev/null and b/src/biaplotter/icons/picker_checked.png differ 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()