Skip to content

feat: Add ROI selection to stage explorer, and scan button to scan selected ROI #428

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5ad4414
wip rois
fdrgsp May 7, 2025
c942ff9
better keypress
tlambert03 May 7, 2025
6ddd698
wip
tlambert03 May 7, 2025
e4a72b8
small fixes
fdrgsp May 8, 2025
422879f
cleanup
tlambert03 May 8, 2025
e4d3577
add helpers
tlambert03 May 8, 2025
b6ee508
make snap defatul
tlambert03 May 8, 2025
95f136a
rename
tlambert03 May 8, 2025
17f263a
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] May 8, 2025
a1e1b08
break out manager
tlambert03 May 8, 2025
20ad25f
fix demo
tlambert03 May 8, 2025
a599c60
fix again
tlambert03 May 8, 2025
16f8dae
move roi manager
tlambert03 May 8, 2025
04d4648
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] May 8, 2025
b5830fa
move roi manager
tlambert03 May 8, 2025
318e158
Merge branch 'stage-explorer-rois' of https://github.com/fdrgsp/pymmc…
tlambert03 May 8, 2025
bc8d124
better handle mouse move
tlambert03 May 8, 2025
7cdff11
better handle mouse move
tlambert03 May 8, 2025
11a3768
fix
tlambert03 May 8, 2025
b978c65
more cleanup
tlambert03 May 8, 2025
f45ec7c
remove another mouse event
tlambert03 May 8, 2025
e4f1e6b
cleanup
tlambert03 May 8, 2025
4a556d1
cleanup
tlambert03 May 8, 2025
073bf31
scan area button
tlambert03 May 8, 2025
1cf3c5f
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] May 8, 2025
d1ee3d4
only if not running
tlambert03 May 8, 2025
c83377d
Merge branch 'stage-explorer-rois' of https://github.com/fdrgsp/pymmc…
tlambert03 May 8, 2025
d01974b
fix tests
tlambert03 May 10, 2025
65bf77f
fix types
tlambert03 May 10, 2025
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
244 changes: 244 additions & 0 deletions src/pymmcore_widgets/control/_stage_explorer/_rois.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
from __future__ import annotations

import math
from enum import Enum, IntEnum
from typing import TYPE_CHECKING, Any

import numpy as np
import vispy.color
from qtpy.QtCore import Qt
from vispy import scene
from vispy.scene import Compound

if TYPE_CHECKING:
from collections.abc import Sequence

from vispy.app.canvas import MouseEvent


class ROIActionMode(Enum):
"""ROI modes."""

NONE = "none"
CREATE = "create"
RESIZE = "resize"
MOVE = "move"


class Grab(IntEnum):
"""Enum for grabbable objects."""

INSIDE = -1
BOT_LEFT = 0
BOT_RIGHT = 1
TOP_RIGHT = 2
TOP_LEFT = 3

@property
def opposite(self) -> Grab:
"""Return the opposite handle."""
return Grab((self + 2) % 4)

Check warning on line 40 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L40

Added line #L40 was not covered by tests


_CURSOR_MAP: dict[Grab | None, Qt.CursorShape] = {
None: Qt.CursorShape.ArrowCursor,
Grab.TOP_RIGHT: Qt.CursorShape.SizeBDiagCursor,
Grab.BOT_LEFT: Qt.CursorShape.SizeBDiagCursor,
Grab.TOP_LEFT: Qt.CursorShape.SizeFDiagCursor,
Grab.BOT_RIGHT: Qt.CursorShape.SizeFDiagCursor,
Grab.INSIDE: Qt.CursorShape.SizeAllCursor,
}


class ROIRectangle(Compound):
"""A rectangle ROI."""

def __init__(self, parent: Any) -> None:
# flag to indicate if the ROI is selected
self._selected = False
self._action_mode: ROIActionMode = ROIActionMode.CREATE

Check warning on line 59 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L58-L59

Added lines #L58 - L59 were not covered by tests
# anchor point for the move mode, this is the "non-moving" point
# when moving or resizing the ROI
self._move_anchor: tuple[float, float] = (0, 0)

Check warning on line 62 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L62

Added line #L62 was not covered by tests

self._rect = scene.Rectangle(

Check warning on line 64 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L64

Added line #L64 was not covered by tests
center=[0, 0],
width=1,
height=1,
color=vispy.color.Color("transparent"),
border_color=vispy.color.Color("yellow"),
border_width=2,
)

# BL, BR, TR, TL
self._handle_data = np.zeros((4, 2))
self._handle_size = 20 # px
self._handles = scene.Markers(

Check warning on line 76 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L74-L76

Added lines #L74 - L76 were not covered by tests
pos=self._handle_data,
size=self._handle_size,
scaling=False, # "fixed"
face_color=vispy.color.Color("white"),
)

# Add text at the center of the rectangle
self._text = scene.Text(

Check warning on line 84 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L84

Added line #L84 was not covered by tests
text="",
bold=True,
color="yellow",
font_size=12,
anchor_x="center",
anchor_y="center",
depth_test=False,
)

super().__init__([self._rect, self._handles, self._text])
self.parent = parent
self.set_gl_state(depth_test=False)

Check warning on line 96 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L94-L96

Added lines #L94 - L96 were not covered by tests

@property
def center(self) -> tuple[float, float]:
"""Return the center of the ROI."""
return tuple(self._rect.center)

Check warning on line 101 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L101

Added line #L101 was not covered by tests

# ---------------------PUBLIC METHODS---------------------

def selected(self) -> bool:
"""Return whether the ROI is selected."""
return self._selected

Check warning on line 107 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L107

Added line #L107 was not covered by tests

def set_selected(self, selected: bool) -> None:
"""Set the ROI as selected."""
self._selected = selected
self._handles.visible = selected and self.visible
self._text.visible = selected

Check warning on line 113 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L111-L113

Added lines #L111 - L113 were not covered by tests

def set_anchor(self, pos: tuple[float, float]) -> None:
"""Set the anchor of the ROI.

The anchor is the point where the ROI is created or moved from.
"""
self._move_anchor = pos

Check warning on line 120 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L120

Added line #L120 was not covered by tests

def set_text(self, text: str) -> None:
"""Set the text of the ROI."""
self._text.text = text

Check warning on line 124 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L124

Added line #L124 was not covered by tests

def bounding_box(self) -> tuple[tuple[float, float], tuple[float, float]]:
"""Return the bounding box of the ROI as top-left and bottom-right corners."""
x1 = self._rect.center[0] - self._rect.width / 2
y1 = self._rect.center[1] + self._rect.height / 2
x2 = self._rect.center[0] + self._rect.width / 2
y2 = self._rect.center[1] - self._rect.height / 2
return (x1, y1), (x2, y2)

Check warning on line 132 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L128-L132

Added lines #L128 - L132 were not covered by tests

def set_bounding_box(
self, corner1: tuple[float, float], corner2: tuple[float, float]
) -> None:
"""Set the bounding box of the ROI using two diagonal points."""
# Unpack and sort coordinates
left, right = sorted((corner1[0], corner2[0]))
bot, top = sorted((corner1[1], corner2[1]))

Check warning on line 140 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L139-L140

Added lines #L139 - L140 were not covered by tests

# Compute center, width, height
center_x = (left + right) / 2.0
center_y = (bot + top) / 2.0
width = max(right - left, 1e-30)
height = max(top - bot, 1e-30)

Check warning on line 146 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L143-L146

Added lines #L143 - L146 were not covered by tests

# Update rectangle visual
self._rect.center = (center_x, center_y)
self._rect.width = width
self._rect.height = height

Check warning on line 151 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L149-L151

Added lines #L149 - L151 were not covered by tests

self._handle_data[:] = [(left, bot), (right, bot), (right, top), (left, top)]
self._handles.set_data(pos=self._handle_data)

Check warning on line 154 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L153-L154

Added lines #L153 - L154 were not covered by tests

# Keep text centered
self._text.pos = self._rect.center

Check warning on line 157 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L157

Added line #L157 was not covered by tests

def get_cursor(self, event: MouseEvent) -> Qt.CursorShape:
"""Return the cursor shape depending on the mouse position.

If the mouse is over a handle, return a cursor indicating that the handle can be
dragged. If the mouse is over the rectangle, return a cursor indicating that th
whole ROI can be moved. Otherwise, return the default cursor.
"""
grab = self.obj_at_pos(event.pos)
return _CURSOR_MAP.get(grab, Qt.CursorShape.ArrowCursor)

Check warning on line 167 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L166-L167

Added lines #L166 - L167 were not covered by tests

def connect(self, canvas: scene.SceneCanvas) -> None:
"""Connect the ROI events to the canvas."""
canvas.events.mouse_move.connect(self.on_mouse_move)
canvas.events.mouse_release.connect(self.on_mouse_release)

Check warning on line 172 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L171-L172

Added lines #L171 - L172 were not covered by tests

def disconnect(self, canvas: scene.SceneCanvas) -> None:
"""Disconnect the ROI events from the canvas."""
canvas.events.mouse_move.disconnect(self.on_mouse_move)
canvas.events.mouse_release.disconnect(self.on_mouse_release)

Check warning on line 177 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L176-L177

Added lines #L176 - L177 were not covered by tests

def obj_at_pos(self, canvas_position: Sequence[float]) -> Grab | None:
"""Return the object at the given position."""
# 1) Convert to world coords
world_x, world_y = self._canvas_to_world(canvas_position)

Check warning on line 182 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L182

Added line #L182 was not covered by tests

# 2) Compute world-space length of one handle_size in canvas
shifted = (canvas_position[0] + self._handle_size, canvas_position[1])
shift_x, shift_y = self._canvas_to_world(shifted)
pix_scale = math.hypot(shift_x - world_x, shift_y - world_y)
handle_radius2 = (pix_scale / 2) ** 2

Check warning on line 188 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L185-L188

Added lines #L185 - L188 were not covered by tests

# 3) hit-test against all handles
for i, (hx, hy) in enumerate(self._handle_data):
dx, dy = hx - world_x, hy - world_y
if dx * dx + dy * dy <= handle_radius2:
return Grab(i)

Check warning on line 194 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L191-L194

Added lines #L191 - L194 were not covered by tests

# 4) Check “inside” the rectangle
(left, bottom), _, (right, top), _ = self._handle_data
if left <= world_x <= right and bottom <= world_y <= top:
return Grab.INSIDE

Check warning on line 199 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L197-L199

Added lines #L197 - L199 were not covered by tests

return None

Check warning on line 201 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L201

Added line #L201 was not covered by tests

# ---------------------MOUSE EVENTS---------------------

def anchor_at(self, grab: Grab, position: Sequence[float]) -> None:
# if the mouse is over the rectangle, set the move mode to
if grab == Grab.INSIDE:
self._action_mode = ROIActionMode.MOVE
self._move_anchor = self._canvas_to_world(position)

Check warning on line 209 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L207-L209

Added lines #L207 - L209 were not covered by tests
else:
# if the mouse is over a handle, set the move mode to HANDLE
self._action_mode = ROIActionMode.RESIZE
self._move_anchor = tuple(self._handle_data[grab.opposite].copy())

Check warning on line 213 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L212-L213

Added lines #L212 - L213 were not covered by tests

def on_mouse_move(self, event: MouseEvent) -> None:
"""Handle the mouse drag event."""
# convert canvas -> world
world_pos = self._canvas_to_world(event.pos)

Check warning on line 218 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L218

Added line #L218 was not covered by tests
# drawing or resizing the ROI
if self._action_mode in {ROIActionMode.CREATE, ROIActionMode.RESIZE}:
self.set_bounding_box(self._move_anchor, world_pos)

Check warning on line 221 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L220-L221

Added lines #L220 - L221 were not covered by tests
# translating the whole roi
elif self._action_mode == ROIActionMode.MOVE:

Check warning on line 223 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L223

Added line #L223 was not covered by tests
# The anchor is the mouse position reported in the previous mouse event.
dx = world_pos[0] - self._move_anchor[0]
dy = world_pos[1] - self._move_anchor[1]

Check warning on line 226 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L225-L226

Added lines #L225 - L226 were not covered by tests
# If the mouse moved (dx, dy) between events, the whole ROI needs to be
# translated that amount.
new_min = (self._handle_data[0, 0] + dx, self._handle_data[0, 1] + dy)
new_max = (self._handle_data[2, 0] + dx, self._handle_data[2, 1] + dy)
self._move_anchor = world_pos
self.set_bounding_box(new_min, new_max)

Check warning on line 232 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L229-L232

Added lines #L229 - L232 were not covered by tests

def on_mouse_release(self, event: MouseEvent) -> None:
"""Handle the mouse release event."""
self._action_mode = ROIActionMode.NONE

Check warning on line 236 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L236

Added line #L236 was not covered by tests

# ---------------------PRIVATE METHODS---------------------

def _canvas_to_world(self, position: Sequence[float]) -> tuple[float, float]:
tform = self._rect.transforms.get_transform("canvas", "scene")
cx, cy = tform.map(position)[:2]
return float(cx), float(cy)

Check warning on line 243 in src/pymmcore_widgets/control/_stage_explorer/_rois.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/control/_stage_explorer/_rois.py#L241-L243

Added lines #L241 - L243 were not covered by tests
return self._rect.transforms.get_transform("canvas", "scene")
Loading
Loading