Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions src/biaplotter/_tests/test_selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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__])
34 changes: 34 additions & 0 deletions src/biaplotter/_version.py
Original file line number Diff line number Diff line change
@@ -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'
Binary file added src/biaplotter/icons/picker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/biaplotter/icons/picker_checked.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 26 additions & 3 deletions src/biaplotter/plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
----------
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -212,6 +230,7 @@ def _initialize_selectors(self):
InteractiveRectangleSelector
| InteractiveEllipseSelector
| InteractiveLassoSelector
| InteractiveClickSelector
) = None
self.selectors: dict = {}
self.add_selector(
Expand All @@ -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):
"""
Expand Down
147 changes: 147 additions & 0 deletions src/biaplotter/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()