From 5ad44146834d677465d4584e89feb504707bc0a2 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 7 May 2025 14:01:44 -0400 Subject: [PATCH 01/27] wip rois --- .../control/_stage_explorer/_rois.py | 256 ++++++++++++++++++ .../_stage_explorer/_stage_explorer.py | 199 +++++++++++++- 2 files changed, 447 insertions(+), 8 deletions(-) create mode 100644 src/pymmcore_widgets/control/_stage_explorer/_rois.py diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py new file mode 100644 index 000000000..10334066c --- /dev/null +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -0,0 +1,256 @@ +from collections.abc import Sequence +from enum import Enum +from typing import Any + +import numpy as np +import vispy.color +from qtpy.QtCore import Qt +from qtpy.QtGui import QCursor +from vispy import scene +from vispy.app.canvas import MouseEvent + + +class ROIMoveMode(Enum): + """ROI modes.""" + + NONE = "none" # No movement + DRAW = "draw" # Drawing a new ROI + HANDLE = "handle" # Moving a handle + TRANSLATE = "translate" # Translating the whole ROI + + +class ROIRectangle: + """A rectangle ROI.""" + + def __init__(self, parent: Any) -> None: + # flag to indicate if the ROI is selected + self._selected = False + # flag to indicate the move mode + self._move_mode: ROIMoveMode = ROIMoveMode.DRAW + # anchor point for the move mode + self._move_anchor: tuple[float, float] = (0, 0) + + self._rect = scene.Rectangle( + center=[0, 0], + width=1, + height=1, + color=None, + border_color=vispy.color.Color("yellow"), + border_width=2, + parent=parent, + ) + self._rect.set_gl_state(depth_test=False) + self._rect.interactive = True + + self._handle_data = np.zeros((4, 2)) + self._handle_size = 20 # px + self._handles = scene.Markers( + pos=self._handle_data, + size=self._handle_size, + scaling=False, # "fixed" + face_color=vispy.color.Color("white"), + parent=parent, + ) + self._handles.set_gl_state(depth_test=False) + self._handles.interactive = True + + # Add text at the center of the rectangle + self._text = scene.Text( + text="", + bold=True, + color="yellow", + font_size=12, + anchor_x="center", + anchor_y="center", + depth_test=False, + parent=parent, + ) + + self.set_visible(False) + + @property + def center(self) -> tuple[float, float]: + """Return the center of the ROI.""" + return tuple(self._rect.center) + + # ---------------------PUBLIC METHODS--------------------- + + def visible(self) -> bool: + """Return whether the ROI is visible.""" + return bool(self._rect.visible) + + def set_visible(self, visible: bool) -> None: + """Set the ROI as visible.""" + self._rect.visible = visible + self._handles.visible = visible and self.selected() + self._text.visible = visible + + def selected(self) -> bool: + """Return whether the ROI is selected.""" + return self._selected + + 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 + + def remove(self) -> None: + """Remove the ROI from the scene.""" + self._rect.parent = None + self._handles.parent = None + self._text.parent = None + + 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 + + def set_text(self, text: str) -> None: + """Set the text of the ROI.""" + self._text.text = text + + 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) + + def set_bounding_box( + self, mi: tuple[float, float], ma: tuple[float, float] + ) -> None: + """Set the bounding box of the ROI using two diagonal points.""" + x1 = float(min(mi[0], ma[0])) + y1 = float(min(mi[1], ma[1])) + x2 = float(max(mi[0], ma[0])) + y2 = float(max(mi[1], ma[1])) + # update rectangle + self._rect.center = [(x1 + x2) / 2, (y1 + y2) / 2] + self._rect.width = max(float(x2 - x1), 1e-30) + self._rect.height = max(float(y2 - y1), 1e-30) + # update handles + self._handle_data[0] = x1, y1 + self._handle_data[1] = x2, y1 + self._handle_data[2] = x2, y2 + self._handle_data[3] = x1, y2 + self._handles.set_data(pos=self._handle_data) + + self._text.pos = self._rect.center + + def get_cursor(self, event: MouseEvent) -> QCursor | None: + """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. + """ + canvas_pos = (event.pos[0], event.pos[1]) + pos = self._tform().map(canvas_pos)[:2] + if (idx := self._under_mouse_index(pos)) is not None: + # if the mouse is over the rectangle, return a SizeAllCursor cursor + # indicating that the whole ROI can be moved + if idx == -1: + return QCursor(Qt.CursorShape.SizeAllCursor) + # if the mouse is over a handle, return a cursor indicating that the handle + # can be dragged + elif idx >= 0: + return QCursor(Qt.CursorShape.DragMoveCursor) + # otherwise, return the default cursor + else: + return QCursor(Qt.CursorShape.ArrowCursor) + return QCursor(Qt.CursorShape.ArrowCursor) + + def connect(self, canvas: scene.SceneCanvas) -> None: + """Connect the ROI events to the canvas.""" + canvas.events.mouse_press.connect(self.on_mouse_press) + canvas.events.mouse_move.connect(self.on_mouse_move) + canvas.events.mouse_release.connect(self.on_mouse_release) + + def disconnect(self, canvas: scene.SceneCanvas) -> None: + """Disconnect the ROI events from the canvas.""" + canvas.events.mouse_press.disconnect(self.on_mouse_press) + canvas.events.mouse_move.disconnect(self.on_mouse_move) + canvas.events.mouse_release.disconnect(self.on_mouse_release) + + # ---------------------MOUSE EVENTS--------------------- + + # for canvas.events.mouse_press.connect + def on_mouse_press(self, event: MouseEvent) -> None: + """Handle the mouse press event.""" + canvas_pos = (event.pos[0], event.pos[1]) + world_pos = self._tform().map(canvas_pos)[:2] + + # check if the mouse is over a handle or the rectangle + idx = self._under_mouse_index(world_pos) + + # if the mouse is over a handle, set the move mode to HANDLE + if idx is not None and idx >= 0: + self.set_selected(True) + opposite_idx = (idx + 2) % 4 + self._move_mode = ROIMoveMode.HANDLE + self._move_anchor = tuple(self._handle_data[opposite_idx].copy()) + # if the mouse is over the rectangle, set the move mode to + elif idx == -1: + self.set_selected(True) + self._move_mode = ROIMoveMode.TRANSLATE + self._move_anchor = world_pos + # if the mouse is not over a handle or the rectangle, set the move mode to + else: + self.set_selected(False) + self._move_mode = ROIMoveMode.NONE + + # for canvas.events.mouse_move.connect + def on_mouse_move(self, event: MouseEvent) -> None: + """Handle the mouse drag event.""" + # convert canvas -> world + canvas_pos = (event.pos[0], event.pos[1]) + world_pos = self._tform().map(canvas_pos)[:2] + # drawing a new roi + if self._move_mode == ROIMoveMode.DRAW: + self.set_bounding_box(self._move_anchor, world_pos) + # moving a handle + elif self._move_mode == ROIMoveMode.HANDLE: + # The anchor is set to the opposite handle, which never moves. + self.set_bounding_box(self._move_anchor, world_pos) + # translating the whole roi + elif self._move_mode == ROIMoveMode.TRANSLATE: + # 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] + # 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) + + # for canvas.events.mouse_release.connect + def on_mouse_release(self, event: MouseEvent) -> None: + """Handle the mouse release event.""" + self._move_mode = ROIMoveMode.NONE + + # ---------------------PRIVATE METHODS--------------------- + + def _tform(self) -> scene.transforms.BaseTransform: + return self._rect.transforms.get_transform("canvas", "scene") + + def _under_mouse_index(self, pos: Sequence[float]) -> int | None: + """Returns an int in [0, 3], -1, or None. + + If an int i, means that the handle at self._positions[i] is at pos. + If -1, means that the mouse is within the rectangle. + If None, there is no handle at pos. + """ + # check if the mouse is over a handle + rad2 = (self._handle_size / 2) ** 2 + for i, p in enumerate(self._handle_data): + if (p[0] - pos[0]) ** 2 + (p[1] - pos[1]) ** 2 <= rad2: + return i + # check if the mouse is within the rectangle + left, bottom = self._handle_data[0] + right, top = self._handle_data[2] + return -1 if left <= pos[0] <= right and bottom <= pos[1] <= top else None diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 4ce1d7d1c..89719c3b1 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -1,10 +1,12 @@ from __future__ import annotations +import contextlib from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Callable, cast import numpy as np +import useq import vispy.scene from pymmcore_plus import CMMCorePlus, Keyword from qtpy.QtCore import QPoint, Qt @@ -19,17 +21,18 @@ QWidget, ) from superqt import QIconifyIcon +from vispy.util.keys import Key from pymmcore_widgets.control._q_stage_controller import QStageMoveAccumulator +from ._rois import ROIRectangle from ._stage_position_marker import StagePositionMarker from ._stage_viewer import StageViewer, get_vispy_scene_bounds if TYPE_CHECKING: - import useq from PyQt6.QtGui import QAction, QActionGroup from qtpy.QtCore import QTimerEvent - from vispy.app.canvas import MouseEvent + from vispy.app.canvas import KeyEvent, MouseEvent from vispy.scene.visuals import VisualNode else: from qtpy.QtWidgets import QAction, QActionGroup @@ -46,6 +49,8 @@ SNAP = "Snap on Double Click" POLL_STAGE = "Show FOV Position" SHOW_GRID = "Show Grid" +ROIS = "Activate/Deactivate ROIs Tool" +DELETE_ROIS = "Delete All ROIs" # this might belong in _stage_position_marker.py @@ -157,6 +162,8 @@ def __init__( self._auto_zoom_to_fit: bool = False self._snap_on_double_click: bool = False self._poll_stage_position: bool = True + # to store the rois + self._rois: list[ROIRectangle] = [] # stage position marker mode self._position_indicator: PositionIndicator = PositionIndicator.BOTH @@ -184,6 +191,8 @@ def __init__( SNAP: ("mdi:camera-outline", True, self._on_snap_action), POLL_STAGE: ("mdi:map-marker-outline", True, self._on_poll_stage_action), SHOW_GRID: ("mdi:grid", True, self._on_show_grid_action), + ROIS: ("mdi:vector-square", True, None), + DELETE_ROIS: ("mdi:vector-square-remove", False, self._remove_rois), } # fmt: on @@ -193,7 +202,8 @@ def __init__( icon = QIconifyIcon(icon, color=GRAY) self._actions[a_text] = action = QAction(icon, a_text, self) action.setCheckable(check) - action.triggered.connect(callback) + if callback is not None: + action.triggered.connect(callback) if a_text == POLL_STAGE: # create special toolbutton with a context menu on right-click @@ -231,6 +241,13 @@ def __init__( self._on_mouse_double_click ) + # connections vispy events for ROIs + self._stage_viewer.canvas.events.mouse_press.connect(self._on_mouse_press) + self._stage_viewer.canvas.events.mouse_move.connect(self._on_mouse_move) + self._stage_viewer.canvas.events.mouse_release.connect(self._on_mouse_release) + self._stage_viewer.canvas.events.key_press.connect(self._on_key_press) + self._stage_viewer.canvas.events.key_release.connect(self._on_key_release) + self._on_sys_config_loaded() # -----------------------------PUBLIC METHODS------------------------------------- @@ -282,6 +299,11 @@ def poll_stage_position(self, value: bool) -> None: self._actions[POLL_STAGE].setChecked(value) self._on_poll_stage_action(value) + @property + def rois(self) -> list[ROIRectangle]: + """List of ROIs in the scene.""" + return self._rois + def add_image( self, image: np.ndarray, stage_x_um: float, stage_y_um: float ) -> None: @@ -302,6 +324,27 @@ def zoom_to_fit(self, *, margin: float = 0.05) -> None: x_bounds, y_bounds, *_ = get_vispy_scene_bounds(visuals) self._stage_viewer.view.camera.set_range(x=x_bounds, y=y_bounds, margin=margin) + def value(self) -> list[useq.Position]: + """Return a list of `GridFromEdges` objects from the drawn rectangles.""" + # TODO: add a way to set overlap + positions = [] + px = self._mmc.getPixelSizeUm() + fov_w, fov_h = self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px + for rect in self._rois: + grid_plan = self._build_grid_plan(rect, fov_w, fov_h) + if isinstance(grid_plan, useq.AbsolutePosition): + positions.append(grid_plan) + else: + x, y = rect.center + pos = useq.AbsolutePosition( + x=x, + y=y, + z=self._mmc.getZPosition(), + sequence=useq.MDASequence(grid_plan=grid_plan), + ) + positions.append(pos) + return positions + # -----------------------------PRIVATE METHODS------------------------------------ # ACTIONS ---------------------------------------------------------------------- @@ -351,6 +394,16 @@ def _set_poll_mode(self) -> None: self._position_indicator.show_marker ) + def _on_show_grid_action(self, checked: bool) -> None: + """Set the show grid property based on the state of the action.""" + self._grid_lines.visible = checked + self._actions[SHOW_GRID].setChecked(checked) + + def _remove_rois(self) -> None: + """Delete all the ROIs.""" + while self._rois: + self._remove_last_roi() + # CORE ------------------------------------------------------------------------ def _on_sys_config_loaded(self) -> None: @@ -404,11 +457,6 @@ def _on_poll_stage_action(self, checked: bool) -> None: self._timer_id = None self._delete_stage_position_marker() - def _on_show_grid_action(self, checked: bool) -> None: - """Set the show grid property based on the state of the action.""" - self._grid_lines.visible = checked - self._actions[SHOW_GRID].setChecked(checked) - def _delete_stage_position_marker(self) -> None: """Delete the stage position marker.""" if self._stage_pos_marker is not None: @@ -610,6 +658,141 @@ def _t_half_width(self) -> np.ndarray: T_center[1, 3] = -self._mmc.getImageHeight() / 2 return T_center + # ROIs ------------------------------------------------------------------------ + + def _active_roi(self) -> ROIRectangle | None: + """Return the next active ROI.""" + return next((roi for roi in self._rois if roi.selected()), None) + + def _on_mouse_press(self, event: MouseEvent) -> None: + """Handle the mouse press event.""" + canvas_pos = (event.pos[0], event.pos[1]) + + if self._active_roi() is not None: + self._stage_viewer.view.camera.interactive = False + + elif self._actions[ROIS].isChecked() and event.button == 1: + self._stage_viewer.view.camera.interactive = False + # create the ROI rectangle for the first time + roi = self._create_roi(canvas_pos) + self._rois.append(roi) + + def _create_roi(self, canvas_pos: tuple[float, float]) -> ROIRectangle: + """Create a new ROI rectangle and connect its events.""" + roi = ROIRectangle(self._stage_viewer.view.scene) + roi.connect(self._stage_viewer.canvas) + world_pos = roi._tform().map(canvas_pos)[:2] + roi.set_selected(True) + roi.set_visible(True) + roi.set_anchor(world_pos) + roi.set_bounding_box(world_pos, world_pos) + return roi + + def _on_mouse_move(self, event: MouseEvent) -> None: + """Update the roi text when the roi changes size.""" + if (roi := self._active_roi()) is not None: + # set cursor + cursor = roi.get_cursor(event) + self._stage_viewer.canvas.native.setCursor(cursor) + # update roi text + px = self._mmc.getPixelSizeUm() + fov_w = self._mmc.getImageWidth() * px + fov_h = self._mmc.getImageHeight() * px + grid_plan = self._build_grid_plan(roi, fov_w, fov_h) + try: + pos = list(grid_plan) + rows = max(r.row for r in pos if r.row is not None) + 1 + cols = max(c.col for c in pos if c.col is not None) + 1 + roi.set_text(f"r{rows} x c{cols}") + except AttributeError: + roi.set_text("r1 x c1") + else: + # reset cursor to default + self._stage_viewer.canvas.native.setCursor(Qt.CursorShape.ArrowCursor) + + def _on_mouse_release(self, event: MouseEvent) -> None: + """Handle the mouse release event.""" + self._stage_viewer.view.camera.interactive = True + + def _on_key_press(self, event: KeyEvent) -> None: + """Delete the last ROI added to the scene when pressing Cmd/Ctrl + Z.""" + key: Key = event.key + modifiers: tuple[Key, ...] = event.modifiers + # if key is cmd/ctrl + z, remove the last roi + if ( + key == Key("Z") + and (Key("Meta") in modifiers or Key("Control") in modifiers) + and self._rois + ): + self._remove_last_roi() + # if key is alt, activate rois tool + elif key == Key("Alt") and not self._actions[ROIS].isChecked(): + self._actions[ROIS].setChecked(True) + # if key is del or cancel, remove the selected roi + # TODO: fix me!!! + elif key in (Key("Delete"), Key("Cancel")): + if self._active_roi() is not None: + self._remove_selected_roi() + + # TO REMOVE------------------ + elif key == Key("v"): + from rich import print + + print(self.value()) + # ----------------------------- + + def _on_key_release(self, event: KeyEvent) -> None: + """Deactivate the ROIs tool when releasing the Alt key.""" + key: Key = event.key + if key == Key("Alt") and self._actions[ROIS].isChecked(): + self._actions[ROIS].setChecked(False) + + def _remove_last_roi(self) -> None: + """Delete the last ROI added to the scene.""" + roi = self._rois.pop(-1) + roi.remove() + with contextlib.suppress(Exception): + roi.disconnect(self._stage_viewer.canvas) + + def _remove_selected_roi(self) -> None: + """Delete the selected ROI from the scene.""" + if (roi := self._active_roi()) is not None: + roi.remove() + self._rois.remove(roi) + with contextlib.suppress(Exception): + roi.disconnect(self._stage_viewer.canvas) + + # GRID PLAN ------------------------------------------------------------------- + + def _build_grid_plan( + self, roi: ROIRectangle, fov_w: float, fov_h: float + ) -> useq.GridFromEdges | useq.AbsolutePosition: + """Return a `GridFromEdges` plan from the roi and fov width and height.""" + top_left, bottom_right = roi.bounding_box() + + # if the width and the height of the roi are smaller than the fov width and + # height, return a single position at the center of the roi and not a grid plan. + w = bottom_right[0] - top_left[0] + h = bottom_right[1] - top_left[1] + if w < fov_w and h < fov_h: + return useq.AbsolutePosition( + x=top_left[0] + (w / 2), + y=top_left[1] + (h / 2), + z=self._mmc.getZPosition(), + ) + # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and + # bottom_right corners respectively because the grid plan is created + # considering the center of the fov and we want the roi to define the edges + # of the grid plan. + return useq.GridFromEdges( + top=top_left[1] - (fov_h / 2), + bottom=bottom_right[1] + (fov_h / 2), + left=top_left[0] + (fov_w / 2), + right=bottom_right[0] - (fov_w / 2), + fov_width=fov_w, + fov_height=fov_h, + ) + class _PollStageCtxMenu(QMenu): """Custom context menu for the poll stage position button. From c942ff93cc5ba9309827b6f1d0128dbeb7b25583 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 7 May 2025 15:15:21 -0400 Subject: [PATCH 02/27] better keypress --- .../_stage_explorer/_stage_explorer.py | 48 ++++++++----------- .../control/_stage_explorer/_stage_viewer.py | 25 +++++++++- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 89719c3b1..d31ee62f5 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -10,7 +10,7 @@ import vispy.scene from pymmcore_plus import CMMCorePlus, Keyword from qtpy.QtCore import QPoint, Qt -from qtpy.QtGui import QIcon +from qtpy.QtGui import QIcon, QKeyEvent from qtpy.QtWidgets import ( QLabel, QMenu, @@ -21,7 +21,6 @@ QWidget, ) from superqt import QIconifyIcon -from vispy.util.keys import Key from pymmcore_widgets.control._q_stage_controller import QStageMoveAccumulator @@ -32,7 +31,7 @@ if TYPE_CHECKING: from PyQt6.QtGui import QAction, QActionGroup from qtpy.QtCore import QTimerEvent - from vispy.app.canvas import KeyEvent, MouseEvent + from vispy.app.canvas import MouseEvent from vispy.scene.visuals import VisualNode else: from qtpy.QtWidgets import QAction, QActionGroup @@ -245,8 +244,6 @@ def __init__( self._stage_viewer.canvas.events.mouse_press.connect(self._on_mouse_press) self._stage_viewer.canvas.events.mouse_move.connect(self._on_mouse_move) self._stage_viewer.canvas.events.mouse_release.connect(self._on_mouse_release) - self._stage_viewer.canvas.events.key_press.connect(self._on_key_press) - self._stage_viewer.canvas.events.key_release.connect(self._on_key_release) self._on_sys_config_loaded() @@ -713,39 +710,36 @@ def _on_mouse_move(self, event: MouseEvent) -> None: def _on_mouse_release(self, event: MouseEvent) -> None: """Handle the mouse release event.""" self._stage_viewer.view.camera.interactive = True + self._actions[ROIS].setChecked(False) + + def keyPressEvent(self, a0: QKeyEvent | None) -> None: + if a0 is None: + return + from rich import print - def _on_key_press(self, event: KeyEvent) -> None: - """Delete the last ROI added to the scene when pressing Cmd/Ctrl + Z.""" - key: Key = event.key - modifiers: tuple[Key, ...] = event.modifiers # if key is cmd/ctrl + z, remove the last roi - if ( - key == Key("Z") - and (Key("Meta") in modifiers or Key("Control") in modifiers) - and self._rois - ): + if a0.key() == Qt.Key.Key_Z and (a0.modifiers() & Qt.Modifier.CTRL): self._remove_last_roi() # if key is alt, activate rois tool - elif key == Key("Alt") and not self._actions[ROIS].isChecked(): + elif a0.key() == Qt.Key.Key_Alt and not self._actions[ROIS].isChecked(): self._actions[ROIS].setChecked(True) # if key is del or cancel, remove the selected roi - # TODO: fix me!!! - elif key in (Key("Delete"), Key("Cancel")): + elif a0.key() == Qt.Key.Key_Backspace: if self._active_roi() is not None: self._remove_selected_roi() - - # TO REMOVE------------------ - elif key == Key("v"): - from rich import print - + elif a0.key() == Qt.Key.Key_V: print(self.value()) - # ----------------------------- + else: + super().keyPressEvent(a0) - def _on_key_release(self, event: KeyEvent) -> None: - """Deactivate the ROIs tool when releasing the Alt key.""" - key: Key = event.key - if key == Key("Alt") and self._actions[ROIS].isChecked(): + def keyReleaseEvent(self, a0: QKeyEvent | None) -> None: + if a0 is None: + return + + if a0.key() == Qt.Key.Key_Alt and self._actions[ROIS].isChecked(): self._actions[ROIS].setChecked(False) + else: + super().keyReleaseEvent(a0) def _remove_last_roi(self) -> None: """Delete the last ROI added to the scene.""" diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py index 344ec696d..3f0cd6ce9 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py @@ -1,10 +1,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast +from unittest.mock import patch import cmap import numpy as np import vispy +import vispy.app +import vispy.app.backends import vispy.scene import vispy.visuals from qtpy.QtCore import Qt @@ -22,6 +25,25 @@ class VisualNode(vispy.scene.Node, vispy.visuals.Visual): ... +class KeylessSceneCanvas(vispy.scene.SceneCanvas): + """Steal all key events from vispy.""" + + def create_native(self): + from vispy.app.backends._qt import CanvasBackendDesktop + + class CustomCanvasBackend(CanvasBackendDesktop): + def keyPressEvent(self, ev): + QWidget.keyPressEvent(self, ev) + + def keyReleaseEvent(self, ev): + QWidget.keyPressEvent(self, ev) + + with patch.object( + self._app.backend_module, "CanvasBackend", CustomCanvasBackend + ): + super().create_native() + + class StageViewer(QWidget): """A widget to add images with a transform to a vispy canves.""" @@ -33,7 +55,8 @@ def __init__(self, parent: QWidget | None = None) -> None: self._clims: tuple[float, float] | None = None self._cmap: cmap.Colormap = cmap.Colormap("gray") - self.canvas = scene.SceneCanvas(keys="interactive", show=True) + self.canvas = KeylessSceneCanvas(show=True) + self.view = cast("ViewBox", self.canvas.central_widget.add_view()) self.view.camera = scene.PanZoomCamera(aspect=1) From 6ddd698f015938757dff00bab07dd8e3a89ed8d4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 7 May 2025 17:30:46 -0400 Subject: [PATCH 03/27] wip --- .../control/_stage_explorer/_rois.py | 211 +++++++++--------- .../_stage_explorer/_stage_explorer.py | 114 +++++++--- 2 files changed, 192 insertions(+), 133 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index 10334066c..898fed023 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -1,33 +1,39 @@ -from collections.abc import Sequence -from enum import Enum -from typing import Any +from __future__ import annotations + +from enum import Enum, IntEnum +from typing import TYPE_CHECKING, Any import numpy as np import vispy.color from qtpy.QtCore import Qt from qtpy.QtGui import QCursor from vispy import scene -from vispy.app.canvas import MouseEvent +from vispy.scene import Compound + +if TYPE_CHECKING: + from collections.abc import Sequence + + from vispy.app.canvas import MouseEvent -class ROIMoveMode(Enum): +class ROIActionMode(Enum): """ROI modes.""" - NONE = "none" # No movement - DRAW = "draw" # Drawing a new ROI - HANDLE = "handle" # Moving a handle - TRANSLATE = "translate" # Translating the whole ROI + NONE = "none" + CREATE = "create" + RESIZE = "resize" + MOVE = "move" -class ROIRectangle: +class ROIRectangle(Compound): """A rectangle ROI.""" def __init__(self, parent: Any) -> None: # flag to indicate if the ROI is selected self._selected = False - # flag to indicate the move mode - self._move_mode: ROIMoveMode = ROIMoveMode.DRAW - # anchor point for the move mode + self._action_mode: ROIActionMode = ROIActionMode.CREATE + # 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) self._rect = scene.Rectangle( @@ -37,10 +43,7 @@ def __init__(self, parent: Any) -> None: color=None, border_color=vispy.color.Color("yellow"), border_width=2, - parent=parent, ) - self._rect.set_gl_state(depth_test=False) - self._rect.interactive = True self._handle_data = np.zeros((4, 2)) self._handle_size = 20 # px @@ -49,10 +52,7 @@ def __init__(self, parent: Any) -> None: size=self._handle_size, scaling=False, # "fixed" face_color=vispy.color.Color("white"), - parent=parent, ) - self._handles.set_gl_state(depth_test=False) - self._handles.interactive = True # Add text at the center of the rectangle self._text = scene.Text( @@ -63,10 +63,11 @@ def __init__(self, parent: Any) -> None: anchor_x="center", anchor_y="center", depth_test=False, - parent=parent, ) - self.set_visible(False) + super().__init__([self._rect, self._handles, self._text]) + self.parent = parent + self.set_gl_state(depth_test=False) @property def center(self) -> tuple[float, float]: @@ -75,16 +76,6 @@ def center(self) -> tuple[float, float]: # ---------------------PUBLIC METHODS--------------------- - def visible(self) -> bool: - """Return whether the ROI is visible.""" - return bool(self._rect.visible) - - def set_visible(self, visible: bool) -> None: - """Set the ROI as visible.""" - self._rect.visible = visible - self._handles.visible = visible and self.selected() - self._text.visible = visible - def selected(self) -> bool: """Return whether the ROI is selected.""" return self._selected @@ -92,15 +83,9 @@ def selected(self) -> bool: def set_selected(self, selected: bool) -> None: """Set the ROI as selected.""" self._selected = selected - self._handles.visible = selected and self.visible() + self._handles.visible = selected and self.visible self._text.visible = selected - def remove(self) -> None: - """Remove the ROI from the scene.""" - self._rect.parent = None - self._handles.parent = None - self._text.parent = None - def set_anchor(self, pos: tuple[float, float]) -> None: """Set the anchor of the ROI. @@ -121,22 +106,22 @@ def bounding_box(self) -> tuple[tuple[float, float], tuple[float, float]]: return (x1, y1), (x2, y2) def set_bounding_box( - self, mi: tuple[float, float], ma: tuple[float, float] + self, corner1: tuple[float, float], corner2: tuple[float, float] ) -> None: """Set the bounding box of the ROI using two diagonal points.""" - x1 = float(min(mi[0], ma[0])) - y1 = float(min(mi[1], ma[1])) - x2 = float(max(mi[0], ma[0])) - y2 = float(max(mi[1], ma[1])) + left = float(min(corner1[0], corner2[0])) + top = float(min(corner1[1], corner2[1])) + right = float(max(corner1[0], corner2[0])) + bot = float(max(corner1[1], corner2[1])) # update rectangle - self._rect.center = [(x1 + x2) / 2, (y1 + y2) / 2] - self._rect.width = max(float(x2 - x1), 1e-30) - self._rect.height = max(float(y2 - y1), 1e-30) + self._rect.center = [(left + right) / 2, (top + bot) / 2] + self._rect.width = max(float(right - left), 1e-30) + self._rect.height = max(float(bot - top), 1e-30) # update handles - self._handle_data[0] = x1, y1 - self._handle_data[1] = x2, y1 - self._handle_data[2] = x2, y2 - self._handle_data[3] = x1, y2 + self._handle_data[0] = left, top + self._handle_data[1] = right, top + self._handle_data[2] = right, bot + self._handle_data[3] = left, bot self._handles.set_data(pos=self._handle_data) self._text.pos = self._rect.center @@ -148,60 +133,43 @@ def get_cursor(self, event: MouseEvent) -> QCursor | None: dragged. If the mouse is over the rectangle, return a cursor indicating that th whole ROI can be moved. Otherwise, return the default cursor. """ - canvas_pos = (event.pos[0], event.pos[1]) - pos = self._tform().map(canvas_pos)[:2] - if (idx := self._under_mouse_index(pos)) is not None: - # if the mouse is over the rectangle, return a SizeAllCursor cursor - # indicating that the whole ROI can be moved - if idx == -1: - return QCursor(Qt.CursorShape.SizeAllCursor) - # if the mouse is over a handle, return a cursor indicating that the handle - # can be dragged - elif idx >= 0: - return QCursor(Qt.CursorShape.DragMoveCursor) - # otherwise, return the default cursor - else: - return QCursor(Qt.CursorShape.ArrowCursor) - return QCursor(Qt.CursorShape.ArrowCursor) + if (grb := self.obj_at_pos(event.pos)) is None: + # not grabbing anything return the default cursor + return QCursor(Qt.CursorShape.ArrowCursor) + + # if the mouse is over a handle, return a cursor indicating that the handle + # can be dragged + if grb in (Grab.TOP_RIGHT, Grab.BOT_LEFT): + return QCursor(Qt.CursorShape.SizeBDiagCursor) + elif grb in (Grab.TOP_LEFT, Grab.BOT_RIGHT): + return QCursor(Qt.CursorShape.SizeFDiagCursor) + + # if the mouse is over the rectangle, return a SizeAllCursor cursor + # indicating that the whole ROI can be moved + # grb == Grab.INSIDE + return QCursor(Qt.CursorShape.SizeAllCursor) def connect(self, canvas: scene.SceneCanvas) -> None: """Connect the ROI events to the canvas.""" - canvas.events.mouse_press.connect(self.on_mouse_press) canvas.events.mouse_move.connect(self.on_mouse_move) canvas.events.mouse_release.connect(self.on_mouse_release) def disconnect(self, canvas: scene.SceneCanvas) -> None: """Disconnect the ROI events from the canvas.""" - canvas.events.mouse_press.disconnect(self.on_mouse_press) canvas.events.mouse_move.disconnect(self.on_mouse_move) canvas.events.mouse_release.disconnect(self.on_mouse_release) # ---------------------MOUSE EVENTS--------------------- - # for canvas.events.mouse_press.connect - def on_mouse_press(self, event: MouseEvent) -> None: - """Handle the mouse press event.""" - canvas_pos = (event.pos[0], event.pos[1]) - world_pos = self._tform().map(canvas_pos)[:2] - - # check if the mouse is over a handle or the rectangle - idx = self._under_mouse_index(world_pos) - - # if the mouse is over a handle, set the move mode to HANDLE - if idx is not None and idx >= 0: - self.set_selected(True) - opposite_idx = (idx + 2) % 4 - self._move_mode = ROIMoveMode.HANDLE - self._move_anchor = tuple(self._handle_data[opposite_idx].copy()) + def anchor_at(self, grab: Grab, position: Sequence[float]) -> None: # if the mouse is over the rectangle, set the move mode to - elif idx == -1: - self.set_selected(True) - self._move_mode = ROIMoveMode.TRANSLATE - self._move_anchor = world_pos - # if the mouse is not over a handle or the rectangle, set the move mode to + if grab == Grab.INSIDE: + self._action_mode = ROIActionMode.MOVE + self._move_anchor = self._tform().map(position)[:2] else: - self.set_selected(False) - self._move_mode = ROIMoveMode.NONE + # 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()) # for canvas.events.mouse_move.connect def on_mouse_move(self, event: MouseEvent) -> None: @@ -210,14 +178,14 @@ def on_mouse_move(self, event: MouseEvent) -> None: canvas_pos = (event.pos[0], event.pos[1]) world_pos = self._tform().map(canvas_pos)[:2] # drawing a new roi - if self._move_mode == ROIMoveMode.DRAW: + if self._action_mode == ROIActionMode.CREATE: self.set_bounding_box(self._move_anchor, world_pos) # moving a handle - elif self._move_mode == ROIMoveMode.HANDLE: + elif self._action_mode == ROIActionMode.RESIZE: # The anchor is set to the opposite handle, which never moves. self.set_bounding_box(self._move_anchor, world_pos) # translating the whole roi - elif self._move_mode == ROIMoveMode.TRANSLATE: + elif self._action_mode == ROIActionMode.MOVE: # 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] @@ -231,26 +199,67 @@ def on_mouse_move(self, event: MouseEvent) -> None: # for canvas.events.mouse_release.connect def on_mouse_release(self, event: MouseEvent) -> None: """Handle the mouse release event.""" - self._move_mode = ROIMoveMode.NONE + self._action_mode = ROIActionMode.NONE # ---------------------PRIVATE METHODS--------------------- def _tform(self) -> scene.transforms.BaseTransform: return self._rect.transforms.get_transform("canvas", "scene") - def _under_mouse_index(self, pos: Sequence[float]) -> int | None: + def obj_at_pos(self, epos: Sequence[float]) -> Grab | None: """Returns an int in [0, 3], -1, or None. If an int i, means that the handle at self._positions[i] is at pos. If -1, means that the mouse is within the rectangle. If None, there is no handle at pos. """ - # check if the mouse is over a handle - rad2 = (self._handle_size / 2) ** 2 - for i, p in enumerate(self._handle_data): - if (p[0] - pos[0]) ** 2 + (p[1] - pos[1]) ** 2 <= rad2: - return i - # check if the mouse is within the rectangle + # Get the transform from canvas to scene (world) coordinates + transform = self._tform() + # Convert mouse position from canvas to world coordinates + world_pos = transform.map(epos)[:2] + world_x, world_y = world_pos + + # FIXME + # Get the pixel scale factor to adjust the handle hit detection based on zoom + # level This converts a fixed screen size to the equivalent in world coordinates + canvas_point1 = (epos[0], epos[1]) + canvas_point2 = (epos[0] + self._handle_size, epos[1]) + world_point1 = transform.map(canvas_point1)[:2] + world_point2 = transform.map(canvas_point2)[:2] + # distance in world units that corresponds to handle_size in canvas + pixel_scale = np.sqrt( + (world_point2[0] - world_point1[0]) ** 2 + + (world_point2[1] - world_point1[1]) ** 2 + ) + + # Adjust handle hit radius based on zoom level + handle_radius = pixel_scale / 2 + rad2 = handle_radius**2 + + # Check if the mouse is over a handle + for i, (handle_x, handle_y) in enumerate(self._handle_data): + dist_to_handle = (handle_x - world_x) ** 2 + (handle_y - world_y) ** 2 + if dist_to_handle <= rad2: + return Grab(i) + + # Check if the mouse is within the rectangle left, bottom = self._handle_data[0] right, top = self._handle_data[2] - return -1 if left <= pos[0] <= right and bottom <= pos[1] <= top else None + if left <= world_x <= right and bottom <= world_y <= top: + return Grab.INSIDE + return None + + +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) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index d31ee62f5..ecb44ac2d 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -10,8 +10,9 @@ import vispy.scene from pymmcore_plus import CMMCorePlus, Keyword from qtpy.QtCore import QPoint, Qt -from qtpy.QtGui import QIcon, QKeyEvent +from qtpy.QtGui import QIcon, QKeyEvent, QKeySequence, QUndoCommand, QUndoStack from qtpy.QtWidgets import ( + QApplication, QLabel, QMenu, QSizePolicy, @@ -99,6 +100,29 @@ def show_marker(self) -> bool: """ +class _RoiCommand(QUndoCommand): + def __init__(self, explorer: StageExplorer, roi: ROIRectangle) -> None: + super().__init__("Add ROI") + self._explorer = explorer + self._roi = roi + + +class InsertRoiCommand(_RoiCommand): + def undo(self) -> None: + self._explorer._remove_roi(self._roi) + + def redo(self) -> None: + self._explorer._add_roi(self._roi) + + +class DeleteRoiCommand(_RoiCommand): + def undo(self) -> None: + self._explorer._add_roi(self._roi) + + def redo(self) -> None: + self._explorer._remove_roi(self._roi) + + class StageExplorer(QWidget): """A stage positions explorer widget. @@ -140,6 +164,13 @@ def __init__( self.setWindowTitle("Stage Explorer") self._mmc = mmcore or CMMCorePlus.instance() + self._undo_stack = QUndoStack(self) + self._undo_stack.setUndoLimit(10) + self._undo_act = self._undo_stack.createUndoAction(self, "&Undo") + self._undo_act.triggered.connect(lambda: print("Undo triggered")) + self._undo_act.setShortcut(QKeySequence.StandardKey.Undo) + self._redo_act = self._undo_stack.createRedoAction(self, "&Redo") + self._redo_act.setShortcut(QKeySequence.StandardKey.Redo) device = self._mmc.getXYStageDevice() self._stage_controller = QStageMoveAccumulator.for_device(device, self._mmc) @@ -162,7 +193,7 @@ def __init__( self._snap_on_double_click: bool = False self._poll_stage_position: bool = True # to store the rois - self._rois: list[ROIRectangle] = [] + self._rois: set[ROIRectangle] = set() # stage position marker mode self._position_indicator: PositionIndicator = PositionIndicator.BOTH @@ -399,7 +430,8 @@ def _on_show_grid_action(self, checked: bool) -> None: def _remove_rois(self) -> None: """Delete all the ROIs.""" while self._rois: - self._remove_last_roi() + roi = self._rois.pop() + self._remove_roi(roi) # CORE ------------------------------------------------------------------------ @@ -665,24 +697,33 @@ def _on_mouse_press(self, event: MouseEvent) -> None: """Handle the mouse press event.""" canvas_pos = (event.pos[0], event.pos[1]) + picked = None + for roi in self._rois: + if not picked and (grb := roi.obj_at_pos(event.pos)) is not None: + roi.anchor_at(grb, event.pos) + roi.set_selected(True) + picked = roi + else: + roi.set_selected(False) + if self._active_roi() is not None: self._stage_viewer.view.camera.interactive = False + # (button = 1 is left mouse button) elif self._actions[ROIS].isChecked() and event.button == 1: self._stage_viewer.view.camera.interactive = False # create the ROI rectangle for the first time roi = self._create_roi(canvas_pos) - self._rois.append(roi) + self._undo_stack.push(InsertRoiCommand(self, roi)) def _create_roi(self, canvas_pos: tuple[float, float]) -> ROIRectangle: """Create a new ROI rectangle and connect its events.""" roi = ROIRectangle(self._stage_viewer.view.scene) - roi.connect(self._stage_viewer.canvas) world_pos = roi._tform().map(canvas_pos)[:2] + roi.visible = True roi.set_selected(True) - roi.set_visible(True) roi.set_anchor(world_pos) - roi.set_bounding_box(world_pos, world_pos) + # roi.set_bounding_box(world_pos, world_pos) return roi def _on_mouse_move(self, event: MouseEvent) -> None: @@ -710,52 +751,61 @@ def _on_mouse_move(self, event: MouseEvent) -> None: def _on_mouse_release(self, event: MouseEvent) -> None: """Handle the mouse release event.""" self._stage_viewer.view.camera.interactive = True - self._actions[ROIS].setChecked(False) + + # if alt key is not down... + if QApplication.keyboardModifiers() != Qt.KeyboardModifier.AltModifier: + # set the roi to not selected + self._actions[ROIS].setChecked(False) def keyPressEvent(self, a0: QKeyEvent | None) -> None: - if a0 is None: + if a0 is None: # pragma: no cover return - from rich import print - # if key is cmd/ctrl + z, remove the last roi - if a0.key() == Qt.Key.Key_Z and (a0.modifiers() & Qt.Modifier.CTRL): - self._remove_last_roi() # if key is alt, activate rois tool - elif a0.key() == Qt.Key.Key_Alt and not self._actions[ROIS].isChecked(): + if a0.key() == Qt.Key.Key_Alt and not self._actions[ROIS].isChecked(): self._actions[ROIS].setChecked(True) # if key is del or cancel, remove the selected roi elif a0.key() == Qt.Key.Key_Backspace: - if self._active_roi() is not None: - self._remove_selected_roi() + self._remove_selected_roi() elif a0.key() == Qt.Key.Key_V: print(self.value()) - else: + elif a0.key() == Qt.Key.Key_Z: + if a0.modifiers() == Qt.KeyboardModifier.ControlModifier: + self._undo_stack.undo() + elif ( + a0.modifiers() + == Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.ControlModifier + ): + self._undo_stack.redo() + else: # pragma: no cover super().keyPressEvent(a0) def keyReleaseEvent(self, a0: QKeyEvent | None) -> None: - if a0 is None: - return - - if a0.key() == Qt.Key.Key_Alt and self._actions[ROIS].isChecked(): - self._actions[ROIS].setChecked(False) - else: - super().keyReleaseEvent(a0) - - def _remove_last_roi(self) -> None: - """Delete the last ROI added to the scene.""" - roi = self._rois.pop(-1) - roi.remove() - with contextlib.suppress(Exception): - roi.disconnect(self._stage_viewer.canvas) + if a0 is not None: + if a0.key() == Qt.Key.Key_Alt and self._actions[ROIS].isChecked(): + self._actions[ROIS].setChecked(False) + else: + super().keyReleaseEvent(a0) def _remove_selected_roi(self) -> None: """Delete the selected ROI from the scene.""" if (roi := self._active_roi()) is not None: - roi.remove() + self._undo_stack.push(DeleteRoiCommand(self, roi)) + + def _remove_roi(self, roi: ROIRectangle) -> None: + """Delete the selected ROI from the scene.""" + if roi in self._rois: + roi.parent = None self._rois.remove(roi) with contextlib.suppress(Exception): roi.disconnect(self._stage_viewer.canvas) + def _add_roi(self, roi: ROIRectangle) -> None: + roi.parent = self._stage_viewer.view.scene + roi.connect(self._stage_viewer.canvas) + self._rois.add(roi) + # GRID PLAN ------------------------------------------------------------------- def _build_grid_plan( From e4a72b85e18ab069e6dfb5395dd6286b60261f40 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 8 May 2025 00:20:18 -0400 Subject: [PATCH 04/27] small fixes --- src/pymmcore_widgets/control/_stage_explorer/_rois.py | 2 +- .../control/_stage_explorer/_stage_explorer.py | 4 ++-- .../control/_stage_explorer/_stage_viewer.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index 898fed023..d75e7dc20 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -40,7 +40,7 @@ def __init__(self, parent: Any) -> None: center=[0, 0], width=1, height=1, - color=None, + color=vispy.color.Color("transparent"), border_color=vispy.color.Color("yellow"), border_width=2, ) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index ecb44ac2d..9ce5c7059 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -214,7 +214,7 @@ def __init__( # fmt: off # {text: (icon, checkable, on_triggered)} - ACTION_MAP: dict[str, tuple[str | QIcon, bool, Callable]] = { + ACTION_MAP: dict[str, tuple[str | QIcon, bool, Callable | None]] = { CLEAR: ("mdi:close", False, self._stage_viewer.clear), ZOOM_TO_FIT: ("mdi:fullscreen", False, self._on_zoom_to_fit_action), AUTO_ZOOM_TO_FIT: (AUTO_ZOOM_TO_FIT_ICON, True, self._on_auto_zoom_to_fit_action), # noqa: E501 @@ -328,7 +328,7 @@ def poll_stage_position(self, value: bool) -> None: self._on_poll_stage_action(value) @property - def rois(self) -> list[ROIRectangle]: + def rois(self) -> set[ROIRectangle]: """List of ROIs in the scene.""" return self._rois diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py index 3f0cd6ce9..0d506fb2e 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py @@ -28,14 +28,14 @@ class VisualNode(vispy.scene.Node, vispy.visuals.Visual): ... class KeylessSceneCanvas(vispy.scene.SceneCanvas): """Steal all key events from vispy.""" - def create_native(self): + def create_native(self) -> None: from vispy.app.backends._qt import CanvasBackendDesktop class CustomCanvasBackend(CanvasBackendDesktop): - def keyPressEvent(self, ev): + def keyPressEvent(self, ev) -> None: QWidget.keyPressEvent(self, ev) - def keyReleaseEvent(self, ev): + def keyReleaseEvent(self, ev) -> None: QWidget.keyPressEvent(self, ev) with patch.object( From 422879fdb8697c938efdcd04d7d824016f098783 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 08:50:46 -0400 Subject: [PATCH 05/27] cleanup --- .../control/_stage_explorer/_rois.py | 179 ++++++++---------- .../_stage_explorer/_stage_explorer.py | 4 +- 2 files changed, 80 insertions(+), 103 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index d75e7dc20..ab3eb608f 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -1,12 +1,12 @@ 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 qtpy.QtGui import QCursor from vispy import scene from vispy.scene import Compound @@ -25,6 +25,31 @@ class ROIActionMode(Enum): 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) + + +_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.""" @@ -45,6 +70,7 @@ def __init__(self, parent: Any) -> None: border_width=2, ) + # BL, BR, TR, TL self._handle_data = np.zeros((4, 2)) self._handle_size = 20 # px self._handles = scene.Markers( @@ -109,45 +135,36 @@ 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.""" - left = float(min(corner1[0], corner2[0])) - top = float(min(corner1[1], corner2[1])) - right = float(max(corner1[0], corner2[0])) - bot = float(max(corner1[1], corner2[1])) - # update rectangle - self._rect.center = [(left + right) / 2, (top + bot) / 2] - self._rect.width = max(float(right - left), 1e-30) - self._rect.height = max(float(bot - top), 1e-30) - # update handles - self._handle_data[0] = left, top - self._handle_data[1] = right, top - self._handle_data[2] = right, bot - self._handle_data[3] = left, bot + # Unpack and sort coordinates + left, right = sorted((corner1[0], corner2[0])) + bot, top = sorted((corner1[1], corner2[1])) + + # 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) + + # Update rectangle visual + self._rect.center = (center_x, center_y) + self._rect.width = width + self._rect.height = height + + self._handle_data[:] = [(left, bot), (right, bot), (right, top), (left, top)] self._handles.set_data(pos=self._handle_data) + # Keep text centered self._text.pos = self._rect.center - def get_cursor(self, event: MouseEvent) -> QCursor | None: + 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. """ - if (grb := self.obj_at_pos(event.pos)) is None: - # not grabbing anything return the default cursor - return QCursor(Qt.CursorShape.ArrowCursor) - - # if the mouse is over a handle, return a cursor indicating that the handle - # can be dragged - if grb in (Grab.TOP_RIGHT, Grab.BOT_LEFT): - return QCursor(Qt.CursorShape.SizeBDiagCursor) - elif grb in (Grab.TOP_LEFT, Grab.BOT_RIGHT): - return QCursor(Qt.CursorShape.SizeFDiagCursor) - - # if the mouse is over the rectangle, return a SizeAllCursor cursor - # indicating that the whole ROI can be moved - # grb == Grab.INSIDE - return QCursor(Qt.CursorShape.SizeAllCursor) + grab = self.obj_at_pos(event.pos) + return _CURSOR_MAP.get(grab, Qt.CursorShape.ArrowCursor) def connect(self, canvas: scene.SceneCanvas) -> None: """Connect the ROI events to the canvas.""" @@ -159,30 +176,48 @@ def disconnect(self, canvas: scene.SceneCanvas) -> None: canvas.events.mouse_move.disconnect(self.on_mouse_move) canvas.events.mouse_release.disconnect(self.on_mouse_release) + 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) + + # 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 + + # 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) + + # 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 + + return None + # ---------------------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._tform().map(position)[:2] + self._move_anchor = self._canvas_to_world(position) 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()) - # for canvas.events.mouse_move.connect def on_mouse_move(self, event: MouseEvent) -> None: """Handle the mouse drag event.""" # convert canvas -> world - canvas_pos = (event.pos[0], event.pos[1]) - world_pos = self._tform().map(canvas_pos)[:2] - # drawing a new roi - if self._action_mode == ROIActionMode.CREATE: - self.set_bounding_box(self._move_anchor, world_pos) - # moving a handle - elif self._action_mode == ROIActionMode.RESIZE: - # The anchor is set to the opposite handle, which never moves. + world_pos = self._canvas_to_world(event.pos) + # drawing or resizing the ROI + if self._action_mode in {ROIActionMode.CREATE, ROIActionMode.RESIZE}: self.set_bounding_box(self._move_anchor, world_pos) # translating the whole roi elif self._action_mode == ROIActionMode.MOVE: @@ -196,70 +231,14 @@ def on_mouse_move(self, event: MouseEvent) -> None: self._move_anchor = world_pos self.set_bounding_box(new_min, new_max) - # for canvas.events.mouse_release.connect def on_mouse_release(self, event: MouseEvent) -> None: """Handle the mouse release event.""" self._action_mode = ROIActionMode.NONE # ---------------------PRIVATE METHODS--------------------- - def _tform(self) -> scene.transforms.BaseTransform: + 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) return self._rect.transforms.get_transform("canvas", "scene") - - def obj_at_pos(self, epos: Sequence[float]) -> Grab | None: - """Returns an int in [0, 3], -1, or None. - - If an int i, means that the handle at self._positions[i] is at pos. - If -1, means that the mouse is within the rectangle. - If None, there is no handle at pos. - """ - # Get the transform from canvas to scene (world) coordinates - transform = self._tform() - # Convert mouse position from canvas to world coordinates - world_pos = transform.map(epos)[:2] - world_x, world_y = world_pos - - # FIXME - # Get the pixel scale factor to adjust the handle hit detection based on zoom - # level This converts a fixed screen size to the equivalent in world coordinates - canvas_point1 = (epos[0], epos[1]) - canvas_point2 = (epos[0] + self._handle_size, epos[1]) - world_point1 = transform.map(canvas_point1)[:2] - world_point2 = transform.map(canvas_point2)[:2] - # distance in world units that corresponds to handle_size in canvas - pixel_scale = np.sqrt( - (world_point2[0] - world_point1[0]) ** 2 - + (world_point2[1] - world_point1[1]) ** 2 - ) - - # Adjust handle hit radius based on zoom level - handle_radius = pixel_scale / 2 - rad2 = handle_radius**2 - - # Check if the mouse is over a handle - for i, (handle_x, handle_y) in enumerate(self._handle_data): - dist_to_handle = (handle_x - world_x) ** 2 + (handle_y - world_y) ** 2 - if dist_to_handle <= rad2: - return Grab(i) - - # Check if the mouse is within the rectangle - left, bottom = self._handle_data[0] - right, top = self._handle_data[2] - if left <= world_x <= right and bottom <= world_y <= top: - return Grab.INSIDE - return None - - -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) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 9ce5c7059..9f0cd1c6a 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -719,11 +719,9 @@ def _on_mouse_press(self, event: MouseEvent) -> None: def _create_roi(self, canvas_pos: tuple[float, float]) -> ROIRectangle: """Create a new ROI rectangle and connect its events.""" roi = ROIRectangle(self._stage_viewer.view.scene) - world_pos = roi._tform().map(canvas_pos)[:2] roi.visible = True roi.set_selected(True) - roi.set_anchor(world_pos) - # roi.set_bounding_box(world_pos, world_pos) + roi.set_anchor(roi._canvas_to_world(canvas_pos)) return roi def _on_mouse_move(self, event: MouseEvent) -> None: From e4d35778bd139791e2e42e544e4ec3812da69a8f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 08:56:25 -0400 Subject: [PATCH 06/27] add helpers --- .../control/_stage_explorer/_stage_viewer.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py index 0d506fb2e..addfb5840 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py @@ -172,6 +172,18 @@ def zoom_to_fit(self, *, margin: float = 0.05) -> None: x_bounds, y_bounds, *_ = get_vispy_scene_bounds(visuals) self.view.camera.set_range(x=x_bounds, y=y_bounds, margin=margin) + def canvas_to_world(self, canvas_pos: tuple[float, float]) -> tuple[float, float]: + """Convert canvas coordinates to world coordinates.""" + # map canvas position to world position + world_x, world_y, *_ = self.view.scene.transform.imap(canvas_pos) + return world_x, world_y + + def world_to_canvas(self, world_pos: tuple[float, float]) -> tuple[float, float]: + """Convert world coordinates to canvas coordinates.""" + # map world position to canvas position + canvas_x, canvas_y, *_ = self.view.scene.transform.map(world_pos) + return canvas_x, canvas_y + # --------------------PRIVATE METHODS-------------------- def _get_images(self) -> Iterator[Image]: @@ -185,7 +197,7 @@ def _on_mouse_move(self, event: MouseEvent) -> None: return # pragma: no cover # map canvas position to world position - world_x, world_y, *_ = self.view.scene.transform.imap(event.pos) + world_x, world_y = self.canvas_to_world(event.pos) self._hover_pos_label.setText(f"({world_x:.2f}, {world_y:.2f})") self._hover_pos_label.adjustSize() From b6ee508aa01b966d2678664407a7cd1da7e4a1fb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 09:03:49 -0400 Subject: [PATCH 07/27] make snap defatul --- .../control/_stage_explorer/_stage_explorer.py | 17 ++++++----------- .../control/_stage_explorer/_stage_viewer.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 9f0cd1c6a..90f36c2f0 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -7,7 +7,6 @@ import numpy as np import useq -import vispy.scene from pymmcore_plus import CMMCorePlus, Keyword from qtpy.QtCore import QPoint, Qt from qtpy.QtGui import QIcon, QKeyEvent, QKeySequence, QUndoCommand, QUndoStack @@ -178,19 +177,12 @@ def __init__( self._stage_viewer = StageViewer(self) self._stage_viewer.setCursor(Qt.CursorShape.CrossCursor) - self._grid_lines = vispy.scene.GridLines( - parent=self._stage_viewer.view.scene, - color="#888888", - border_width=1, - ) - self._grid_lines.visible = False - # to keep track of the current scale depending on the zoom level self._current_scale: int = 1 # properties self._auto_zoom_to_fit: bool = False - self._snap_on_double_click: bool = False + self._snap_on_double_click: bool = True self._poll_stage_position: bool = True # to store the rois self._rois: set[ROIRectangle] = set() @@ -241,10 +233,13 @@ def __init__( btn.setDefaultAction(action) self._toolbar.addWidget(btn) action.setChecked(self._poll_stage_position) - self._on_poll_stage_action(self._poll_stage_position) else: self._toolbar.addAction(action) + # update checked state of the actions + self._on_poll_stage_action(self._poll_stage_position) + self._on_snap_action(self._snap_on_double_click) + # add stage pos label to the toolbar self._stage_pos_label = QLabel() @@ -424,7 +419,7 @@ def _set_poll_mode(self) -> None: def _on_show_grid_action(self, checked: bool) -> None: """Set the show grid property based on the state of the action.""" - self._grid_lines.visible = checked + self._stage_viewer.set_grid_visible(checked) self._actions[SHOW_GRID].setChecked(checked) def _remove_rois(self) -> None: diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py index addfb5840..745acfbac 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py @@ -60,6 +60,13 @@ def __init__(self, parent: QWidget | None = None) -> None: self.view = cast("ViewBox", self.canvas.central_widget.add_view()) self.view.camera = scene.PanZoomCamera(aspect=1) + self._grid_lines = vispy.scene.GridLines( + parent=self.view.scene, + color="#888888", + border_width=1, + ) + self._grid_lines.visible = False + main_layout = QVBoxLayout(self) main_layout.setSpacing(0) main_layout.setContentsMargins(0, 0, 0, 0) @@ -91,6 +98,9 @@ def set_colormap(self, colormap: cmap.ColormapLike) -> None: for child in self._get_images(): child.cmap = self._cmap.to_vispy() + def set_grid_visible(self, visible: bool) -> None: + self._grid_lines.visible = visible + def global_autoscale(self, *, ignore_min: float = 0, ignore_max: float = 0) -> None: """Set the color limits of all images in the scene to the global min and max. From 95f136af82b01adad5ae94cb56bd9ee474429413 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 09:57:24 -0400 Subject: [PATCH 08/27] rename --- examples/stage_explorer_widget.py | 6 ++++-- .../control/_stage_explorer/_stage_explorer.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/stage_explorer_widget.py b/examples/stage_explorer_widget.py index bc583a770..4f2c08781 100644 --- a/examples/stage_explorer_widget.py +++ b/examples/stage_explorer_widget.py @@ -7,10 +7,12 @@ app = QApplication([]) mmc = CMMCorePlus.instance() -mmc.loadSystemConfiguration() +mmc.loadSystemConfiguration(r'D:\Christina\MyChristina.cfg') +mmc.setConfig("Channel", "BF") +mmc.setExposure(10) # set camera roi (rectangular helps confirm orientation) -mmc.setROI(0, 0, 400, 600) +# mmc.setROI(0, 0, 400, 600) xy = mmc.getXYStageDevice() if mmc.hasProperty(xy, "Velocity"): diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 90f36c2f0..a8a519b47 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -48,7 +48,7 @@ SNAP = "Snap on Double Click" POLL_STAGE = "Show FOV Position" SHOW_GRID = "Show Grid" -ROIS = "Activate/Deactivate ROIs Tool" +ROIS = "Create ROI" DELETE_ROIS = "Delete All ROIs" From 17f263a457cae81518640ee1d59e0f89797c95e5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 8 May 2025 13:57:47 +0000 Subject: [PATCH 09/27] style(pre-commit.ci): auto fixes [...] --- examples/stage_explorer_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/stage_explorer_widget.py b/examples/stage_explorer_widget.py index 4f2c08781..fc3885360 100644 --- a/examples/stage_explorer_widget.py +++ b/examples/stage_explorer_widget.py @@ -7,7 +7,7 @@ app = QApplication([]) mmc = CMMCorePlus.instance() -mmc.loadSystemConfiguration(r'D:\Christina\MyChristina.cfg') +mmc.loadSystemConfiguration(r"D:\Christina\MyChristina.cfg") mmc.setConfig("Channel", "BF") mmc.setExposure(10) From a1e1b0832b4723e2da91dd0145197fe212eb9ce5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 10:23:52 -0400 Subject: [PATCH 10/27] break out manager --- examples/stage_explorer_widget.py | 11 +- .../_stage_explorer/_stage_explorer.py | 373 ++++++++++-------- 2 files changed, 222 insertions(+), 162 deletions(-) diff --git a/examples/stage_explorer_widget.py b/examples/stage_explorer_widget.py index fc3885360..65c560903 100644 --- a/examples/stage_explorer_widget.py +++ b/examples/stage_explorer_widget.py @@ -7,16 +7,17 @@ app = QApplication([]) mmc = CMMCorePlus.instance() + mmc.loadSystemConfiguration(r"D:\Christina\MyChristina.cfg") mmc.setConfig("Channel", "BF") mmc.setExposure(10) +# mmc.loadSystemConfiguration() # set camera roi (rectangular helps confirm orientation) # mmc.setROI(0, 0, 400, 600) - -xy = mmc.getXYStageDevice() -if mmc.hasProperty(xy, "Velocity"): - mmc.setProperty(xy, "Velocity", 2) +# xy = mmc.getXYStageDevice() +# if mmc.hasProperty(xy, "Velocity"): +# mmc.setProperty(xy, "Velocity", 2) explorer = StageExplorer() @@ -43,4 +44,4 @@ splitter.addWidget(right) splitter.show() -app.exec() +# app.exec() diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index a8a519b47..0f63efb0c 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -3,7 +3,7 @@ import contextlib from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Callable, cast +from typing import TYPE_CHECKING, Callable import numpy as np import useq @@ -100,26 +100,209 @@ def show_marker(self) -> bool: class _RoiCommand(QUndoCommand): - def __init__(self, explorer: StageExplorer, roi: ROIRectangle) -> None: + def __init__(self, manager: ROIManager, roi: ROIRectangle) -> None: super().__init__("Add ROI") - self._explorer = explorer + self._manager = manager self._roi = roi class InsertRoiCommand(_RoiCommand): def undo(self) -> None: - self._explorer._remove_roi(self._roi) + self._manager._remove_roi(self._roi) def redo(self) -> None: - self._explorer._add_roi(self._roi) + self._manager._add_roi(self._roi) class DeleteRoiCommand(_RoiCommand): def undo(self) -> None: - self._explorer._add_roi(self._roi) + self._manager._add_roi(self._roi) def redo(self) -> None: - self._explorer._remove_roi(self._roi) + self._manager._remove_roi(self._roi) + + +class ROIManager: + """Manager for ROI rectangles in the StageViewer. + + This class is responsible for creating, adding, removing, and updating ROIs. + It also handles mouse events related to ROIs and maintains an undo stack + for ROI operations. + + Parameters + ---------- + stage_viewer : StageViewer + The stage viewer where ROIs will be displayed. + mmcore : CMMCorePlus + The micro-manager core instance. + undo_stack : QUndoStack + The undo stack for ROI operations. + """ + + def __init__( + self, stage_viewer: StageViewer, mmcore: CMMCorePlus, undo_stack: QUndoStack + ): + self._stage_viewer = stage_viewer + self._mmc = mmcore + self._undo_stack = undo_stack + self._rois: set[ROIRectangle] = set() + + @property + def rois(self) -> set[ROIRectangle]: + """List of ROIs in the scene.""" + return self._rois + + def value(self) -> list[useq.AbsolutePosition]: + """Return a list of `GridFromEdges` objects from the drawn rectangles.""" + # TODO: add a way to set overlap + positions = [] + px = self._mmc.getPixelSizeUm() + fov_w, fov_h = self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px + for rect in self._rois: + grid_plan = self._build_grid_plan(rect, fov_w, fov_h) + if isinstance(grid_plan, useq.AbsolutePosition): + positions.append(grid_plan) + else: + x, y = rect.center + pos = useq.AbsolutePosition( + x=x, + y=y, + z=self._mmc.getZPosition(), + sequence=useq.MDASequence(grid_plan=grid_plan), + ) + positions.append(pos) + return positions + + def remove_all_rois(self) -> None: + """Delete all the ROIs.""" + while self._rois: + roi = self._rois.pop() + self._remove_roi(roi) + + def _active_roi(self) -> ROIRectangle | None: + """Return the active ROI (the one that is currently selected).""" + return next((roi for roi in self._rois if roi.selected()), None) + + def remove_selected_roi(self) -> None: + """Delete the selected ROI from the scene.""" + if (roi := self._active_roi()) is not None: + self._undo_stack.push(DeleteRoiCommand(self, roi)) + + def create_roi(self, canvas_pos: tuple[float, float]) -> ROIRectangle: + """Create a new ROI rectangle and connect its events.""" + roi = ROIRectangle(self._stage_viewer.view.scene) + roi.visible = True + roi.set_selected(True) + roi.set_anchor(roi._canvas_to_world(canvas_pos)) + return roi + + def _add_roi(self, roi: ROIRectangle) -> None: + """Add a ROI to the scene.""" + roi.parent = self._stage_viewer.view.scene + roi.connect(self._stage_viewer.canvas) + self._rois.add(roi) + + def _remove_roi(self, roi: ROIRectangle) -> None: + """Remove a ROI from the scene.""" + if roi in self._rois: + roi.parent = None + self._rois.remove(roi) + with contextlib.suppress(Exception): + roi.disconnect(self._stage_viewer.canvas) + + def handle_mouse_press(self, event: MouseEvent) -> bool: + """Handle mouse press event for ROIs. + + Returns + ------- + bool + True if the event was handled, False otherwise. + """ + (event.pos[0], event.pos[1]) + + picked = None + for roi in self._rois: + if not picked and (grb := roi.obj_at_pos(event.pos)) is not None: + roi.anchor_at(grb, event.pos) + roi.set_selected(True) + picked = roi + else: + roi.set_selected(False) + + if self._active_roi() is not None: + self._stage_viewer.view.camera.interactive = False + return True + + return False + + def create_roi_at(self, event: MouseEvent, create_roi_mode: bool) -> bool: + """Create a new ROI at the given position if in create ROI mode. + + Returns + ------- + bool + True if a ROI was created, False otherwise. + """ + # (button = 1 is left mouse button) + if create_roi_mode and event.button == 1: + self._stage_viewer.view.camera.interactive = False + # create the ROI rectangle for the first time + canvas_pos = (event.pos[0], event.pos[1]) + roi = self.create_roi(canvas_pos) + self._undo_stack.push(InsertRoiCommand(self, roi)) + return True + return False + + def handle_mouse_move(self, event: MouseEvent) -> None: + """Update the roi text when the roi changes size.""" + if (roi := self._active_roi()) is not None: + # set cursor + cursor = roi.get_cursor(event) + self._stage_viewer.canvas.native.setCursor(cursor) + # update roi text + px = self._mmc.getPixelSizeUm() + fov_w = self._mmc.getImageWidth() * px + fov_h = self._mmc.getImageHeight() * px + grid_plan = self._build_grid_plan(roi, fov_w, fov_h) + try: + pos = list(grid_plan) + rows = max(r.row for r in pos if r.row is not None) + 1 + cols = max(c.col for c in pos if c.col is not None) + 1 + roi.set_text(f"r{rows} x c{cols}") + except AttributeError: + roi.set_text("r1 x c1") + else: + # reset cursor to default + self._stage_viewer.canvas.native.setCursor(Qt.CursorShape.ArrowCursor) + + def _build_grid_plan( + self, roi: ROIRectangle, fov_w: float, fov_h: float + ) -> useq.GridFromEdges | useq.AbsolutePosition: + """Return a `GridFromEdges` plan from the roi and fov width and height.""" + top_left, bottom_right = roi.bounding_box() + + # if the width and the height of the roi are smaller than the fov width and + # height, return a single position at the center of the roi and not a grid plan. + w = bottom_right[0] - top_left[0] + h = bottom_right[1] - top_left[1] + if w < fov_w and h < fov_h: + return useq.AbsolutePosition( + x=top_left[0] + (w / 2), + y=top_left[1] + (h / 2), + z=self._mmc.getZPosition(), + ) + # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and + # bottom_right corners respectively because the grid plan is created + # considering the center of the fov and we want the roi to define the edges + # of the grid plan. + return useq.GridFromEdges( + top=top_left[1] - (fov_h / 2), + bottom=bottom_right[1] + (fov_h / 2), + left=top_left[0] + (fov_w / 2), + right=bottom_right[0] - (fov_w / 2), + fov_width=fov_w, + fov_height=fov_h, + ) class StageExplorer(QWidget): @@ -184,8 +367,6 @@ def __init__( self._auto_zoom_to_fit: bool = False self._snap_on_double_click: bool = True self._poll_stage_position: bool = True - # to store the rois - self._rois: set[ROIRectangle] = set() # stage position marker mode self._position_indicator: PositionIndicator = PositionIndicator.BOTH @@ -273,6 +454,9 @@ def __init__( self._on_sys_config_loaded() + # ROI Manager + self._roi_manager = ROIManager(self._stage_viewer, self._mmc, self._undo_stack) + # -----------------------------PUBLIC METHODS------------------------------------- def toolBar(self) -> QToolBar: @@ -325,7 +509,7 @@ def poll_stage_position(self, value: bool) -> None: @property def rois(self) -> set[ROIRectangle]: """List of ROIs in the scene.""" - return self._rois + return self._roi_manager.rois def add_image( self, image: np.ndarray, stage_x_um: float, stage_y_um: float @@ -349,24 +533,7 @@ def zoom_to_fit(self, *, margin: float = 0.05) -> None: def value(self) -> list[useq.Position]: """Return a list of `GridFromEdges` objects from the drawn rectangles.""" - # TODO: add a way to set overlap - positions = [] - px = self._mmc.getPixelSizeUm() - fov_w, fov_h = self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px - for rect in self._rois: - grid_plan = self._build_grid_plan(rect, fov_w, fov_h) - if isinstance(grid_plan, useq.AbsolutePosition): - positions.append(grid_plan) - else: - x, y = rect.center - pos = useq.AbsolutePosition( - x=x, - y=y, - z=self._mmc.getZPosition(), - sequence=useq.MDASequence(grid_plan=grid_plan), - ) - positions.append(pos) - return positions + return self._roi_manager.value() # -----------------------------PRIVATE METHODS------------------------------------ @@ -424,9 +591,7 @@ def _on_show_grid_action(self, checked: bool) -> None: def _remove_rois(self) -> None: """Delete all the ROIs.""" - while self._rois: - roi = self._rois.pop() - self._remove_roi(roi) + self._roi_manager.remove_all_rois() # CORE ------------------------------------------------------------------------ @@ -684,62 +849,17 @@ def _t_half_width(self) -> np.ndarray: # ROIs ------------------------------------------------------------------------ - def _active_roi(self) -> ROIRectangle | None: - """Return the next active ROI.""" - return next((roi for roi in self._rois if roi.selected()), None) - def _on_mouse_press(self, event: MouseEvent) -> None: """Handle the mouse press event.""" - canvas_pos = (event.pos[0], event.pos[1]) - - picked = None - for roi in self._rois: - if not picked and (grb := roi.obj_at_pos(event.pos)) is not None: - roi.anchor_at(grb, event.pos) - roi.set_selected(True) - picked = roi - else: - roi.set_selected(False) - - if self._active_roi() is not None: - self._stage_viewer.view.camera.interactive = False - - # (button = 1 is left mouse button) - elif self._actions[ROIS].isChecked() and event.button == 1: - self._stage_viewer.view.camera.interactive = False - # create the ROI rectangle for the first time - roi = self._create_roi(canvas_pos) - self._undo_stack.push(InsertRoiCommand(self, roi)) + if self._roi_manager.handle_mouse_press(event): + return - def _create_roi(self, canvas_pos: tuple[float, float]) -> ROIRectangle: - """Create a new ROI rectangle and connect its events.""" - roi = ROIRectangle(self._stage_viewer.view.scene) - roi.visible = True - roi.set_selected(True) - roi.set_anchor(roi._canvas_to_world(canvas_pos)) - return roi + if self._roi_manager.create_roi_at(event, self._actions[ROIS].isChecked()): + return def _on_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" - if (roi := self._active_roi()) is not None: - # set cursor - cursor = roi.get_cursor(event) - self._stage_viewer.canvas.native.setCursor(cursor) - # update roi text - px = self._mmc.getPixelSizeUm() - fov_w = self._mmc.getImageWidth() * px - fov_h = self._mmc.getImageHeight() * px - grid_plan = self._build_grid_plan(roi, fov_w, fov_h) - try: - pos = list(grid_plan) - rows = max(r.row for r in pos if r.row is not None) + 1 - cols = max(c.col for c in pos if c.col is not None) + 1 - roi.set_text(f"r{rows} x c{cols}") - except AttributeError: - roi.set_text("r1 x c1") - else: - # reset cursor to default - self._stage_viewer.canvas.native.setCursor(Qt.CursorShape.ArrowCursor) + self._roi_manager.handle_mouse_move(event) def _on_mouse_release(self, event: MouseEvent) -> None: """Handle the mouse release event.""" @@ -759,7 +879,7 @@ def keyPressEvent(self, a0: QKeyEvent | None) -> None: self._actions[ROIS].setChecked(True) # if key is del or cancel, remove the selected roi elif a0.key() == Qt.Key.Key_Backspace: - self._remove_selected_roi() + self._roi_manager.remove_selected_roi() elif a0.key() == Qt.Key.Key_V: print(self.value()) elif a0.key() == Qt.Key.Key_Z: @@ -781,86 +901,25 @@ def keyReleaseEvent(self, a0: QKeyEvent | None) -> None: else: super().keyReleaseEvent(a0) - def _remove_selected_roi(self) -> None: - """Delete the selected ROI from the scene.""" - if (roi := self._active_roi()) is not None: - self._undo_stack.push(DeleteRoiCommand(self, roi)) - - def _remove_roi(self, roi: ROIRectangle) -> None: - """Delete the selected ROI from the scene.""" - if roi in self._rois: - roi.parent = None - self._rois.remove(roi) - with contextlib.suppress(Exception): - roi.disconnect(self._stage_viewer.canvas) - - def _add_roi(self, roi: ROIRectangle) -> None: - roi.parent = self._stage_viewer.view.scene - roi.connect(self._stage_viewer.canvas) - self._rois.add(roi) - - # GRID PLAN ------------------------------------------------------------------- - - def _build_grid_plan( - self, roi: ROIRectangle, fov_w: float, fov_h: float - ) -> useq.GridFromEdges | useq.AbsolutePosition: - """Return a `GridFromEdges` plan from the roi and fov width and height.""" - top_left, bottom_right = roi.bounding_box() - - # if the width and the height of the roi are smaller than the fov width and - # height, return a single position at the center of the roi and not a grid plan. - w = bottom_right[0] - top_left[0] - h = bottom_right[1] - top_left[1] - if w < fov_w and h < fov_h: - return useq.AbsolutePosition( - x=top_left[0] + (w / 2), - y=top_left[1] + (h / 2), - z=self._mmc.getZPosition(), - ) - # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and - # bottom_right corners respectively because the grid plan is created - # considering the center of the fov and we want the roi to define the edges - # of the grid plan. - return useq.GridFromEdges( - top=top_left[1] - (fov_h / 2), - bottom=bottom_right[1] + (fov_h / 2), - left=top_left[0] + (fov_w / 2), - right=bottom_right[0] - (fov_w / 2), - fov_width=fov_w, - fov_height=fov_h, - ) - class _PollStageCtxMenu(QMenu): - """Custom context menu for the poll stage position button. - - The menu contains options to select the type of marker to display (rectangle, - center, or both). - """ + """A context menu for the poll stage button.""" - def __init__(self, parent: QWidget | None = None) -> None: + def __init__(self, parent: QToolButton) -> None: super().__init__(parent) - - self.action_group = group = QActionGroup(self) - group.setExclusive(True) - - for mode in PositionIndicator: - action = cast("QAction", group.addAction(mode.value)) + self._parent = parent + self.action_group = QActionGroup(self) + self.action_group.setExclusive(True) + for indicator in PositionIndicator: + action = self.addAction(str(indicator)) action.setCheckable(True) + self.action_group.addAction(action) - self.addActions(group.actions()) - - def setIndicator(self, mode: PositionIndicator | str) -> None: - """Set the poll mode based on the selected action.""" - mode = PositionIndicator(mode) - action = next(action for action in self.actions() if action.text() == mode) - action.setChecked(True) + def setIndicator(self, indicator: PositionIndicator) -> None: + """Set the checked action from the current indicator.""" + for action in self.action_group.actions(): + action.setChecked(action.text() == str(indicator)) def show_at_position(self, pos: QPoint) -> None: - """Show the poll stage position context menu at the given global position. - - If a button is the sender, the position is mapped to global coordinates. - """ - if isinstance(sender := self.sender(), QWidget): - pos = sender.mapToGlobal(pos) - self.exec(pos) + """Use the action group to show the menu.""" + self.popup(self._parent.mapToGlobal(pos)) From 20ad25f94b5a6c3c5e98e861eb8794a4a22686c0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 10:24:43 -0400 Subject: [PATCH 11/27] fix demo --- examples/stage_explorer_widget.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/examples/stage_explorer_widget.py b/examples/stage_explorer_widget.py index 65c560903..0f5fe97a2 100644 --- a/examples/stage_explorer_widget.py +++ b/examples/stage_explorer_widget.py @@ -8,16 +8,15 @@ mmc = CMMCorePlus.instance() -mmc.loadSystemConfiguration(r"D:\Christina\MyChristina.cfg") -mmc.setConfig("Channel", "BF") -mmc.setExposure(10) - -# mmc.loadSystemConfiguration() -# set camera roi (rectangular helps confirm orientation) -# mmc.setROI(0, 0, 400, 600) -# xy = mmc.getXYStageDevice() -# if mmc.hasProperty(xy, "Velocity"): -# mmc.setProperty(xy, "Velocity", 2) +# mmc.loadSystemConfiguration(r"D:\Christina\MyChristina.cfg") +# mmc.setConfig("Channel", "BF") +# mmc.setExposure(10) + +mmc.loadSystemConfiguration() +mmc.setROI(0, 0, 400, 600) +xy = mmc.getXYStageDevice() +if mmc.hasProperty(xy, "Velocity"): + mmc.setProperty(xy, "Velocity", 2) explorer = StageExplorer() From a599c60a92263b4a3bfe0c6dcfea5a737844b557 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 10:25:12 -0400 Subject: [PATCH 12/27] fix again --- examples/stage_explorer_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/stage_explorer_widget.py b/examples/stage_explorer_widget.py index 0f5fe97a2..c8b8f022a 100644 --- a/examples/stage_explorer_widget.py +++ b/examples/stage_explorer_widget.py @@ -43,4 +43,4 @@ splitter.addWidget(right) splitter.show() -# app.exec() +app.exec() From 16f8dae18e34fe9b79297c2661c525a76102e3f4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 10:28:17 -0400 Subject: [PATCH 13/27] move roi manager --- .../control/_stage_explorer/_rois.py | 215 ++++++++++++++++++ .../_stage_explorer/_stage_explorer.py | 213 +---------------- 2 files changed, 220 insertions(+), 208 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index ab3eb608f..d971e4931 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -1,20 +1,29 @@ from __future__ import annotations +import contextlib import math from enum import Enum, IntEnum from typing import TYPE_CHECKING, Any import numpy as np +import useq import vispy.color +from pymmcore_plus import CMMCorePlus from qtpy.QtCore import Qt +from qtpy.QtGui import QUndoCommand, QUndoStack from vispy import scene +from vispy.app.canvas import MouseEvent from vispy.scene import Compound if TYPE_CHECKING: from collections.abc import Sequence + from pymmcore_plus import CMMCorePlus + from qtpy.QtGui import QUndoStack from vispy.app.canvas import MouseEvent + from ._stage_viewer import StageViewer + class ROIActionMode(Enum): """ROI modes.""" @@ -242,3 +251,209 @@ def _canvas_to_world(self, position: Sequence[float]) -> tuple[float, float]: cx, cy = tform.map(position)[:2] return float(cx), float(cy) return self._rect.transforms.get_transform("canvas", "scene") + + +class _RoiCommand(QUndoCommand): + def __init__(self, manager: ROIManager, roi: ROIRectangle) -> None: + super().__init__("Add ROI") + self._manager = manager + self._roi = roi + + +class InsertRoiCommand(_RoiCommand): + def undo(self) -> None: + self._manager._remove_roi(self._roi) + + def redo(self) -> None: + self._manager._add_roi(self._roi) + + +class DeleteRoiCommand(_RoiCommand): + def undo(self) -> None: + self._manager._add_roi(self._roi) + + def redo(self) -> None: + self._manager._remove_roi(self._roi) + + +class ROIManager: + """Manager for ROI rectangles in the StageViewer. + + This class is responsible for creating, adding, removing, and updating ROIs. + It also handles mouse events related to ROIs and maintains an undo stack + for ROI operations. + + Parameters + ---------- + stage_viewer : StageViewer + The stage viewer where ROIs will be displayed. + mmcore : CMMCorePlus + The micro-manager core instance. + undo_stack : QUndoStack + The undo stack for ROI operations. + """ + + def __init__( + self, stage_viewer: StageViewer, mmcore: CMMCorePlus, undo_stack: QUndoStack + ): + self._stage_viewer = stage_viewer + self._mmc = mmcore + self._undo_stack = undo_stack + self._rois: set[ROIRectangle] = set() + + @property + def rois(self) -> set[ROIRectangle]: + """List of ROIs in the scene.""" + return self._rois + + def value(self) -> list[useq.AbsolutePosition]: + """Return a list of `GridFromEdges` objects from the drawn rectangles.""" + # TODO: add a way to set overlap + positions = [] + px = self._mmc.getPixelSizeUm() + fov_w, fov_h = self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px + for rect in self._rois: + grid_plan = self._build_grid_plan(rect, fov_w, fov_h) + if isinstance(grid_plan, useq.AbsolutePosition): + positions.append(grid_plan) + else: + x, y = rect.center + pos = useq.AbsolutePosition( + x=x, + y=y, + z=self._mmc.getZPosition(), + sequence=useq.MDASequence(grid_plan=grid_plan), + ) + positions.append(pos) + return positions + + def remove_all_rois(self) -> None: + """Delete all the ROIs.""" + while self._rois: + roi = self._rois.pop() + self._remove_roi(roi) + + def _active_roi(self) -> ROIRectangle | None: + """Return the active ROI (the one that is currently selected).""" + return next((roi for roi in self._rois if roi.selected()), None) + + def remove_selected_roi(self) -> None: + """Delete the selected ROI from the scene.""" + if (roi := self._active_roi()) is not None: + self._undo_stack.push(DeleteRoiCommand(self, roi)) + + def create_roi(self, canvas_pos: tuple[float, float]) -> ROIRectangle: + """Create a new ROI rectangle and connect its events.""" + roi = ROIRectangle(self._stage_viewer.view.scene) + roi.visible = True + roi.set_selected(True) + roi.set_anchor(roi._canvas_to_world(canvas_pos)) + return roi + + def _add_roi(self, roi: ROIRectangle) -> None: + """Add a ROI to the scene.""" + roi.parent = self._stage_viewer.view.scene + roi.connect(self._stage_viewer.canvas) + self._rois.add(roi) + + def _remove_roi(self, roi: ROIRectangle) -> None: + """Remove a ROI from the scene.""" + if roi in self._rois: + roi.parent = None + self._rois.remove(roi) + with contextlib.suppress(Exception): + roi.disconnect(self._stage_viewer.canvas) + + def handle_mouse_press(self, event: MouseEvent) -> bool: + """Handle mouse press event for ROIs. + + Returns + ------- + bool + True if the event was handled, False otherwise. + """ + (event.pos[0], event.pos[1]) + + picked = None + for roi in self._rois: + if not picked and (grb := roi.obj_at_pos(event.pos)) is not None: + roi.anchor_at(grb, event.pos) + roi.set_selected(True) + picked = roi + else: + roi.set_selected(False) + + if self._active_roi() is not None: + self._stage_viewer.view.camera.interactive = False + return True + + return False + + def create_roi_at(self, event: MouseEvent, create_roi_mode: bool) -> bool: + """Create a new ROI at the given position if in create ROI mode. + + Returns + ------- + bool + True if a ROI was created, False otherwise. + """ + # (button = 1 is left mouse button) + if create_roi_mode and event.button == 1: + self._stage_viewer.view.camera.interactive = False + # create the ROI rectangle for the first time + canvas_pos = (event.pos[0], event.pos[1]) + roi = self.create_roi(canvas_pos) + self._undo_stack.push(InsertRoiCommand(self, roi)) + return True + return False + + def handle_mouse_move(self, event: MouseEvent) -> None: + """Update the roi text when the roi changes size.""" + if (roi := self._active_roi()) is not None: + # set cursor + cursor = roi.get_cursor(event) + self._stage_viewer.canvas.native.setCursor(cursor) + # update roi text + px = self._mmc.getPixelSizeUm() + fov_w = self._mmc.getImageWidth() * px + fov_h = self._mmc.getImageHeight() * px + grid_plan = self._build_grid_plan(roi, fov_w, fov_h) + try: + pos = list(grid_plan) + rows = max(r.row for r in pos if r.row is not None) + 1 + cols = max(c.col for c in pos if c.col is not None) + 1 + roi.set_text(f"r{rows} x c{cols}") + except AttributeError: + roi.set_text("r1 x c1") + else: + # reset cursor to default + self._stage_viewer.canvas.native.setCursor(Qt.CursorShape.ArrowCursor) + + def _build_grid_plan( + self, roi: ROIRectangle, fov_w: float, fov_h: float + ) -> useq.GridFromEdges | useq.AbsolutePosition: + """Return a `GridFromEdges` plan from the roi and fov width and height.""" + top_left, bottom_right = roi.bounding_box() + + # if the width and the height of the roi are smaller than the fov width and + # height, return a single position at the center of the roi and not a grid plan. + w = bottom_right[0] - top_left[0] + h = bottom_right[1] - top_left[1] + if w < fov_w and h < fov_h: + return useq.AbsolutePosition( + x=top_left[0] + (w / 2), + y=top_left[1] + (h / 2), + z=self._mmc.getZPosition(), + ) + # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and + # bottom_right corners respectively because the grid plan is created + # considering the center of the fov and we want the roi to define the edges + # of the grid plan. + return useq.GridFromEdges( + top=top_left[1] - (fov_h / 2), + bottom=bottom_right[1] + (fov_h / 2), + left=top_left[0] + (fov_w / 2), + right=bottom_right[0] - (fov_w / 2), + fov_width=fov_w, + fov_height=fov_h, + ) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 0f63efb0c..eda82c378 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -1,15 +1,13 @@ from __future__ import annotations -import contextlib from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Callable import numpy as np -import useq from pymmcore_plus import CMMCorePlus, Keyword from qtpy.QtCore import QPoint, Qt -from qtpy.QtGui import QIcon, QKeyEvent, QKeySequence, QUndoCommand, QUndoStack +from qtpy.QtGui import QIcon, QKeyEvent, QKeySequence, QUndoStack from qtpy.QtWidgets import ( QApplication, QLabel, @@ -24,15 +22,18 @@ from pymmcore_widgets.control._q_stage_controller import QStageMoveAccumulator -from ._rois import ROIRectangle +from ._rois import ROIManager from ._stage_position_marker import StagePositionMarker from ._stage_viewer import StageViewer, get_vispy_scene_bounds if TYPE_CHECKING: + import useq from PyQt6.QtGui import QAction, QActionGroup from qtpy.QtCore import QTimerEvent from vispy.app.canvas import MouseEvent from vispy.scene.visuals import VisualNode + + from ._rois import ROIRectangle else: from qtpy.QtWidgets import QAction, QActionGroup @@ -99,210 +100,6 @@ def show_marker(self) -> bool: """ -class _RoiCommand(QUndoCommand): - def __init__(self, manager: ROIManager, roi: ROIRectangle) -> None: - super().__init__("Add ROI") - self._manager = manager - self._roi = roi - - -class InsertRoiCommand(_RoiCommand): - def undo(self) -> None: - self._manager._remove_roi(self._roi) - - def redo(self) -> None: - self._manager._add_roi(self._roi) - - -class DeleteRoiCommand(_RoiCommand): - def undo(self) -> None: - self._manager._add_roi(self._roi) - - def redo(self) -> None: - self._manager._remove_roi(self._roi) - - -class ROIManager: - """Manager for ROI rectangles in the StageViewer. - - This class is responsible for creating, adding, removing, and updating ROIs. - It also handles mouse events related to ROIs and maintains an undo stack - for ROI operations. - - Parameters - ---------- - stage_viewer : StageViewer - The stage viewer where ROIs will be displayed. - mmcore : CMMCorePlus - The micro-manager core instance. - undo_stack : QUndoStack - The undo stack for ROI operations. - """ - - def __init__( - self, stage_viewer: StageViewer, mmcore: CMMCorePlus, undo_stack: QUndoStack - ): - self._stage_viewer = stage_viewer - self._mmc = mmcore - self._undo_stack = undo_stack - self._rois: set[ROIRectangle] = set() - - @property - def rois(self) -> set[ROIRectangle]: - """List of ROIs in the scene.""" - return self._rois - - def value(self) -> list[useq.AbsolutePosition]: - """Return a list of `GridFromEdges` objects from the drawn rectangles.""" - # TODO: add a way to set overlap - positions = [] - px = self._mmc.getPixelSizeUm() - fov_w, fov_h = self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px - for rect in self._rois: - grid_plan = self._build_grid_plan(rect, fov_w, fov_h) - if isinstance(grid_plan, useq.AbsolutePosition): - positions.append(grid_plan) - else: - x, y = rect.center - pos = useq.AbsolutePosition( - x=x, - y=y, - z=self._mmc.getZPosition(), - sequence=useq.MDASequence(grid_plan=grid_plan), - ) - positions.append(pos) - return positions - - def remove_all_rois(self) -> None: - """Delete all the ROIs.""" - while self._rois: - roi = self._rois.pop() - self._remove_roi(roi) - - def _active_roi(self) -> ROIRectangle | None: - """Return the active ROI (the one that is currently selected).""" - return next((roi for roi in self._rois if roi.selected()), None) - - def remove_selected_roi(self) -> None: - """Delete the selected ROI from the scene.""" - if (roi := self._active_roi()) is not None: - self._undo_stack.push(DeleteRoiCommand(self, roi)) - - def create_roi(self, canvas_pos: tuple[float, float]) -> ROIRectangle: - """Create a new ROI rectangle and connect its events.""" - roi = ROIRectangle(self._stage_viewer.view.scene) - roi.visible = True - roi.set_selected(True) - roi.set_anchor(roi._canvas_to_world(canvas_pos)) - return roi - - def _add_roi(self, roi: ROIRectangle) -> None: - """Add a ROI to the scene.""" - roi.parent = self._stage_viewer.view.scene - roi.connect(self._stage_viewer.canvas) - self._rois.add(roi) - - def _remove_roi(self, roi: ROIRectangle) -> None: - """Remove a ROI from the scene.""" - if roi in self._rois: - roi.parent = None - self._rois.remove(roi) - with contextlib.suppress(Exception): - roi.disconnect(self._stage_viewer.canvas) - - def handle_mouse_press(self, event: MouseEvent) -> bool: - """Handle mouse press event for ROIs. - - Returns - ------- - bool - True if the event was handled, False otherwise. - """ - (event.pos[0], event.pos[1]) - - picked = None - for roi in self._rois: - if not picked and (grb := roi.obj_at_pos(event.pos)) is not None: - roi.anchor_at(grb, event.pos) - roi.set_selected(True) - picked = roi - else: - roi.set_selected(False) - - if self._active_roi() is not None: - self._stage_viewer.view.camera.interactive = False - return True - - return False - - def create_roi_at(self, event: MouseEvent, create_roi_mode: bool) -> bool: - """Create a new ROI at the given position if in create ROI mode. - - Returns - ------- - bool - True if a ROI was created, False otherwise. - """ - # (button = 1 is left mouse button) - if create_roi_mode and event.button == 1: - self._stage_viewer.view.camera.interactive = False - # create the ROI rectangle for the first time - canvas_pos = (event.pos[0], event.pos[1]) - roi = self.create_roi(canvas_pos) - self._undo_stack.push(InsertRoiCommand(self, roi)) - return True - return False - - def handle_mouse_move(self, event: MouseEvent) -> None: - """Update the roi text when the roi changes size.""" - if (roi := self._active_roi()) is not None: - # set cursor - cursor = roi.get_cursor(event) - self._stage_viewer.canvas.native.setCursor(cursor) - # update roi text - px = self._mmc.getPixelSizeUm() - fov_w = self._mmc.getImageWidth() * px - fov_h = self._mmc.getImageHeight() * px - grid_plan = self._build_grid_plan(roi, fov_w, fov_h) - try: - pos = list(grid_plan) - rows = max(r.row for r in pos if r.row is not None) + 1 - cols = max(c.col for c in pos if c.col is not None) + 1 - roi.set_text(f"r{rows} x c{cols}") - except AttributeError: - roi.set_text("r1 x c1") - else: - # reset cursor to default - self._stage_viewer.canvas.native.setCursor(Qt.CursorShape.ArrowCursor) - - def _build_grid_plan( - self, roi: ROIRectangle, fov_w: float, fov_h: float - ) -> useq.GridFromEdges | useq.AbsolutePosition: - """Return a `GridFromEdges` plan from the roi and fov width and height.""" - top_left, bottom_right = roi.bounding_box() - - # if the width and the height of the roi are smaller than the fov width and - # height, return a single position at the center of the roi and not a grid plan. - w = bottom_right[0] - top_left[0] - h = bottom_right[1] - top_left[1] - if w < fov_w and h < fov_h: - return useq.AbsolutePosition( - x=top_left[0] + (w / 2), - y=top_left[1] + (h / 2), - z=self._mmc.getZPosition(), - ) - # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and - # bottom_right corners respectively because the grid plan is created - # considering the center of the fov and we want the roi to define the edges - # of the grid plan. - return useq.GridFromEdges( - top=top_left[1] - (fov_h / 2), - bottom=bottom_right[1] + (fov_h / 2), - left=top_left[0] + (fov_w / 2), - right=bottom_right[0] - (fov_w / 2), - fov_width=fov_w, - fov_height=fov_h, - ) class StageExplorer(QWidget): From 04d46485c383e23a7fcdcfe430b6d5c297d9c422 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 8 May 2025 14:28:39 +0000 Subject: [PATCH 14/27] style(pre-commit.ci): auto fixes [...] --- src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index eda82c378..c6ad5af21 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -100,8 +100,6 @@ def show_marker(self) -> bool: """ - - class StageExplorer(QWidget): """A stage positions explorer widget. From b5830fa34c433b2636cbf3defac8d25308294c23 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 10:32:46 -0400 Subject: [PATCH 15/27] move roi manager --- .../control/_stage_explorer/_rois.py | 61 +++++++++---------- .../_stage_explorer/_stage_explorer.py | 9 +-- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index d971e4931..f0cc0599c 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -8,7 +8,6 @@ import numpy as np import useq import vispy.color -from pymmcore_plus import CMMCorePlus from qtpy.QtCore import Qt from qtpy.QtGui import QUndoCommand, QUndoStack from vispy import scene @@ -18,7 +17,6 @@ if TYPE_CHECKING: from collections.abc import Sequence - from pymmcore_plus import CMMCorePlus from qtpy.QtGui import QUndoStack from vispy.app.canvas import MouseEvent @@ -287,17 +285,12 @@ class ROIManager: ---------- stage_viewer : StageViewer The stage viewer where ROIs will be displayed. - mmcore : CMMCorePlus - The micro-manager core instance. undo_stack : QUndoStack The undo stack for ROI operations. """ - def __init__( - self, stage_viewer: StageViewer, mmcore: CMMCorePlus, undo_stack: QUndoStack - ): + def __init__(self, stage_viewer: StageViewer, undo_stack: QUndoStack): self._stage_viewer = stage_viewer - self._mmc = mmcore self._undo_stack = undo_stack self._rois: set[ROIRectangle] = set() @@ -306,14 +299,14 @@ def rois(self) -> set[ROIRectangle]: """List of ROIs in the scene.""" return self._rois - def value(self) -> list[useq.AbsolutePosition]: + def value( + self, fov_w: float, fov_h: float, z_pos: float + ) -> list[useq.AbsolutePosition]: """Return a list of `GridFromEdges` objects from the drawn rectangles.""" # TODO: add a way to set overlap positions = [] - px = self._mmc.getPixelSizeUm() - fov_w, fov_h = self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px for rect in self._rois: - grid_plan = self._build_grid_plan(rect, fov_w, fov_h) + grid_plan = self._build_grid_plan(rect, fov_w, fov_h, z_pos) if isinstance(grid_plan, useq.AbsolutePosition): positions.append(grid_plan) else: @@ -321,7 +314,7 @@ def value(self) -> list[useq.AbsolutePosition]: pos = useq.AbsolutePosition( x=x, y=y, - z=self._mmc.getZPosition(), + z=z_pos, sequence=useq.MDASequence(grid_plan=grid_plan), ) positions.append(pos) @@ -409,28 +402,32 @@ def create_roi_at(self, event: MouseEvent, create_roi_mode: bool) -> bool: def handle_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" - if (roi := self._active_roi()) is not None: - # set cursor - cursor = roi.get_cursor(event) - self._stage_viewer.canvas.native.setCursor(cursor) - # update roi text - px = self._mmc.getPixelSizeUm() - fov_w = self._mmc.getImageWidth() * px - fov_h = self._mmc.getImageHeight() * px - grid_plan = self._build_grid_plan(roi, fov_w, fov_h) - try: - pos = list(grid_plan) - rows = max(r.row for r in pos if r.row is not None) + 1 - cols = max(c.col for c in pos if c.col is not None) + 1 - roi.set_text(f"r{rows} x c{cols}") - except AttributeError: - roi.set_text("r1 x c1") - else: + if (roi := self._active_roi()) is None: # reset cursor to default self._stage_viewer.canvas.native.setCursor(Qt.CursorShape.ArrowCursor) + return + + # set cursor + cursor = roi.get_cursor(event) + self._stage_viewer.canvas.native.setCursor(cursor) + + # update roi text + px = self._mmc.getPixelSizeUm() + fov_w = self._mmc.getImageWidth() * px + fov_h = self._mmc.getImageHeight() * px + z_pos = self._mmc.getFocusPosition() + grid_plan = self._build_grid_plan(roi, fov_w, fov_h, z_pos) + try: + pos = list(grid_plan) + rows = max(r.row for r in pos if r.row is not None) + 1 + cols = max(c.col for c in pos if c.col is not None) + 1 + roi.set_text(f"r{rows} x c{cols}") + except AttributeError: + breakpoint() + roi.set_text("r1 x c1") def _build_grid_plan( - self, roi: ROIRectangle, fov_w: float, fov_h: float + self, roi: ROIRectangle, fov_w: float, fov_h: float, z_pos: float ) -> useq.GridFromEdges | useq.AbsolutePosition: """Return a `GridFromEdges` plan from the roi and fov width and height.""" top_left, bottom_right = roi.bounding_box() @@ -443,7 +440,7 @@ def _build_grid_plan( return useq.AbsolutePosition( x=top_left[0] + (w / 2), y=top_left[1] + (h / 2), - z=self._mmc.getZPosition(), + z=z_pos, ) # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and # bottom_right corners respectively because the grid plan is created diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index eda82c378..15367fbf4 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -100,8 +100,6 @@ def show_marker(self) -> bool: """ - - class StageExplorer(QWidget): """A stage positions explorer widget. @@ -252,7 +250,7 @@ def __init__( self._on_sys_config_loaded() # ROI Manager - self._roi_manager = ROIManager(self._stage_viewer, self._mmc, self._undo_stack) + self._roi_manager = ROIManager(self._stage_viewer, self._undo_stack) # -----------------------------PUBLIC METHODS------------------------------------- @@ -330,7 +328,10 @@ def zoom_to_fit(self, *, margin: float = 0.05) -> None: def value(self) -> list[useq.Position]: """Return a list of `GridFromEdges` objects from the drawn rectangles.""" - return self._roi_manager.value() + px = self._mmc.getPixelSizeUm() + fov_w, fov_h = self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px + z_pos = self._mmc.getZPosition() + return self._roi_manager.value(fov_w=fov_w, fov_h=fov_h, z_pos=z_pos) # -----------------------------PRIVATE METHODS------------------------------------ From bc8d124c2620c2583016bc07266fca5918845fb1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 11:23:28 -0400 Subject: [PATCH 16/27] better handle mouse move --- .../control/_stage_explorer/_rois.py | 119 +++++++----------- .../_stage_explorer/_stage_explorer.py | 11 +- 2 files changed, 57 insertions(+), 73 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index f0cc0599c..6ed12025f 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -3,7 +3,7 @@ import contextlib import math from enum import Enum, IntEnum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np import useq @@ -207,6 +207,46 @@ def obj_at_pos(self, canvas_position: Sequence[float]) -> Grab | None: return None + def create_useq_position( + self, fov_w: float, fov_h: float, z_pos: float + ) -> useq.AbsolutePosition: + """Return a `GridFromEdges` plan from the roi and fov width and height.""" + (left, top), (right, bottom) = self.bounding_box() + x, y = self.center + + # if the width and the height of the roi are smaller than the fov width and + # a single position at the center of the roi is sufficient + if abs(right - left) < fov_w and abs(bottom - top) < fov_h: + return useq.AbsolutePosition(x=x, y=y, z=z_pos) + + # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and + # bottom_right corners respectively because the grid plan is created + # considering the center of the fov and we want the roi to define the edges + # of the grid plan. + grid_plan = useq.GridFromEdges( + top=top, + bottom=bottom, + left=left, + right=right, + fov_width=fov_w, + fov_height=fov_h, + ) + + return useq.AbsolutePosition( + x=x, y=y, z=z_pos, sequence=useq.MDASequence(grid_plan=grid_plan) + ) + + def update_rows_cols_text(self, fov_w: float, fov_h: float, z_pos: float) -> None: + """Update the text of the ROI with the number of rows and columns.""" + pos = self.create_useq_position(fov_w, fov_h, z_pos) + if pos.sequence: + grid = cast("useq.GridFromEdges", pos.sequence.grid_plan) + nc = math.ceil(abs(grid.right - grid.left) / fov_w) + nr = math.ceil(abs(grid.top - grid.bottom) / fov_h) + self.set_text(f"r{nr} x c{nc}") + else: + self.set_text("r1 x c1") + # ---------------------MOUSE EVENTS--------------------- def anchor_at(self, grab: Grab, position: Sequence[float]) -> None: @@ -305,19 +345,9 @@ def value( """Return a list of `GridFromEdges` objects from the drawn rectangles.""" # TODO: add a way to set overlap positions = [] - for rect in self._rois: - grid_plan = self._build_grid_plan(rect, fov_w, fov_h, z_pos) - if isinstance(grid_plan, useq.AbsolutePosition): - positions.append(grid_plan) - else: - x, y = rect.center - pos = useq.AbsolutePosition( - x=x, - y=y, - z=z_pos, - sequence=useq.MDASequence(grid_plan=grid_plan), - ) - positions.append(pos) + for roi in self._rois: + pos = self._generate_area_coverage(roi, fov_w, fov_h, z_pos) + positions.append(pos) return positions def remove_all_rois(self) -> None: @@ -326,13 +356,13 @@ def remove_all_rois(self) -> None: roi = self._rois.pop() self._remove_roi(roi) - def _active_roi(self) -> ROIRectangle | None: + def active_roi(self) -> ROIRectangle | None: """Return the active ROI (the one that is currently selected).""" return next((roi for roi in self._rois if roi.selected()), None) def remove_selected_roi(self) -> None: """Delete the selected ROI from the scene.""" - if (roi := self._active_roi()) is not None: + if (roi := self.active_roi()) is not None: self._undo_stack.push(DeleteRoiCommand(self, roi)) def create_roi(self, canvas_pos: tuple[float, float]) -> ROIRectangle: @@ -376,7 +406,7 @@ def handle_mouse_press(self, event: MouseEvent) -> bool: else: roi.set_selected(False) - if self._active_roi() is not None: + if self.active_roi() is not None: self._stage_viewer.view.camera.interactive = False return True @@ -399,58 +429,3 @@ def create_roi_at(self, event: MouseEvent, create_roi_mode: bool) -> bool: self._undo_stack.push(InsertRoiCommand(self, roi)) return True return False - - def handle_mouse_move(self, event: MouseEvent) -> None: - """Update the roi text when the roi changes size.""" - if (roi := self._active_roi()) is None: - # reset cursor to default - self._stage_viewer.canvas.native.setCursor(Qt.CursorShape.ArrowCursor) - return - - # set cursor - cursor = roi.get_cursor(event) - self._stage_viewer.canvas.native.setCursor(cursor) - - # update roi text - px = self._mmc.getPixelSizeUm() - fov_w = self._mmc.getImageWidth() * px - fov_h = self._mmc.getImageHeight() * px - z_pos = self._mmc.getFocusPosition() - grid_plan = self._build_grid_plan(roi, fov_w, fov_h, z_pos) - try: - pos = list(grid_plan) - rows = max(r.row for r in pos if r.row is not None) + 1 - cols = max(c.col for c in pos if c.col is not None) + 1 - roi.set_text(f"r{rows} x c{cols}") - except AttributeError: - breakpoint() - roi.set_text("r1 x c1") - - def _build_grid_plan( - self, roi: ROIRectangle, fov_w: float, fov_h: float, z_pos: float - ) -> useq.GridFromEdges | useq.AbsolutePosition: - """Return a `GridFromEdges` plan from the roi and fov width and height.""" - top_left, bottom_right = roi.bounding_box() - - # if the width and the height of the roi are smaller than the fov width and - # height, return a single position at the center of the roi and not a grid plan. - w = bottom_right[0] - top_left[0] - h = bottom_right[1] - top_left[1] - if w < fov_w and h < fov_h: - return useq.AbsolutePosition( - x=top_left[0] + (w / 2), - y=top_left[1] + (h / 2), - z=z_pos, - ) - # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and - # bottom_right corners respectively because the grid plan is created - # considering the center of the fov and we want the roi to define the edges - # of the grid plan. - return useq.GridFromEdges( - top=top_left[1] - (fov_h / 2), - bottom=bottom_right[1] + (fov_h / 2), - left=top_left[0] + (fov_w / 2), - right=bottom_right[0] - (fov_w / 2), - fov_width=fov_w, - fov_height=fov_h, - ) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 15367fbf4..39878f4ff 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -657,7 +657,16 @@ def _on_mouse_press(self, event: MouseEvent) -> None: def _on_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" - self._roi_manager.handle_mouse_move(event) + if roi := self._roi_manager.active_roi(): + self._stage_viewer.setCursor(roi.get_cursor(event)) + px = self._mmc.getPixelSizeUm() + fov_w = self._mmc.getImageWidth() * px + fov_h = self._mmc.getImageHeight() * px + z_pos = self._mmc.getZPosition() + roi.update_rows_cols_text(fov_w=fov_w, fov_h=fov_h, z_pos=z_pos) + else: + # reset cursor to default + self._stage_viewer.setCursor(Qt.CursorShape.ArrowCursor) def _on_mouse_release(self, event: MouseEvent) -> None: """Handle the mouse release event.""" From 7cdff119c2154d19214bcbf757362107701bfbe4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 11:26:00 -0400 Subject: [PATCH 17/27] better handle mouse move --- .../control/_stage_explorer/_rois.py | 15 +++++---------- .../control/_stage_explorer/_stage_explorer.py | 5 +++-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index 6ed12025f..c7a664f72 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -412,7 +412,7 @@ def handle_mouse_press(self, event: MouseEvent) -> bool: return False - def create_roi_at(self, event: MouseEvent, create_roi_mode: bool) -> bool: + def create_roi_at(self, position: Sequence[float]) -> None: """Create a new ROI at the given position if in create ROI mode. Returns @@ -420,12 +420,7 @@ def create_roi_at(self, event: MouseEvent, create_roi_mode: bool) -> bool: bool True if a ROI was created, False otherwise. """ - # (button = 1 is left mouse button) - if create_roi_mode and event.button == 1: - self._stage_viewer.view.camera.interactive = False - # create the ROI rectangle for the first time - canvas_pos = (event.pos[0], event.pos[1]) - roi = self.create_roi(canvas_pos) - self._undo_stack.push(InsertRoiCommand(self, roi)) - return True - return False + self._stage_viewer.view.camera.interactive = False + # create the ROI rectangle for the first time + roi = self.create_roi(position) + self._undo_stack.push(InsertRoiCommand(self, roi)) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 39878f4ff..a00982318 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -652,8 +652,9 @@ def _on_mouse_press(self, event: MouseEvent) -> None: if self._roi_manager.handle_mouse_press(event): return - if self._roi_manager.create_roi_at(event, self._actions[ROIS].isChecked()): - return + # (button = 1 is left mouse button) + if event.button == 1 and self._actions[ROIS].isChecked(): + self._roi_manager.create_roi_at(event) def _on_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" From 11a37685a62c6578684c639262aa9a9dda212704 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 11:26:35 -0400 Subject: [PATCH 18/27] fix --- src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index a00982318..b150c74aa 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -654,7 +654,7 @@ def _on_mouse_press(self, event: MouseEvent) -> None: # (button = 1 is left mouse button) if event.button == 1 and self._actions[ROIS].isChecked(): - self._roi_manager.create_roi_at(event) + self._roi_manager.create_roi_at(event.pos) def _on_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" From b978c65bf6b657bc6962c91baa5ff537ec0678df Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 11:39:20 -0400 Subject: [PATCH 19/27] more cleanup --- .../control/_stage_explorer/_rois.py | 65 ++++++------------- .../_stage_explorer/_stage_explorer.py | 12 +++- 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index c7a664f72..b7605afea 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -300,18 +300,18 @@ def __init__(self, manager: ROIManager, roi: ROIRectangle) -> None: class InsertRoiCommand(_RoiCommand): def undo(self) -> None: - self._manager._remove_roi(self._roi) + self._manager.remove(self._roi) def redo(self) -> None: - self._manager._add_roi(self._roi) + self._manager.add(self._roi) class DeleteRoiCommand(_RoiCommand): def undo(self) -> None: - self._manager._add_roi(self._roi) + self._manager.add(self._roi) def redo(self) -> None: - self._manager._remove_roi(self._roi) + self._manager.remove(self._roi) class ROIManager: @@ -354,32 +354,26 @@ def remove_all_rois(self) -> None: """Delete all the ROIs.""" while self._rois: roi = self._rois.pop() - self._remove_roi(roi) + self.remove(roi) - def active_roi(self) -> ROIRectangle | None: + def selected_roi(self) -> ROIRectangle | None: """Return the active ROI (the one that is currently selected).""" return next((roi for roi in self._rois if roi.selected()), None) def remove_selected_roi(self) -> None: """Delete the selected ROI from the scene.""" - if (roi := self.active_roi()) is not None: + if (roi := self.selected_roi()) is not None: self._undo_stack.push(DeleteRoiCommand(self, roi)) - def create_roi(self, canvas_pos: tuple[float, float]) -> ROIRectangle: - """Create a new ROI rectangle and connect its events.""" - roi = ROIRectangle(self._stage_viewer.view.scene) - roi.visible = True - roi.set_selected(True) - roi.set_anchor(roi._canvas_to_world(canvas_pos)) - return roi - - def _add_roi(self, roi: ROIRectangle) -> None: + def add(self, roi: ROIRectangle) -> None: """Add a ROI to the scene.""" + if roi in self._rois: + return roi.parent = self._stage_viewer.view.scene roi.connect(self._stage_viewer.canvas) self._rois.add(roi) - def _remove_roi(self, roi: ROIRectangle) -> None: + def remove(self, roi: ROIRectangle) -> None: """Remove a ROI from the scene.""" if roi in self._rois: roi.parent = None @@ -387,40 +381,21 @@ def _remove_roi(self, roi: ROIRectangle) -> None: with contextlib.suppress(Exception): roi.disconnect(self._stage_viewer.canvas) - def handle_mouse_press(self, event: MouseEvent) -> bool: - """Handle mouse press event for ROIs. - - Returns - ------- - bool - True if the event was handled, False otherwise. - """ - (event.pos[0], event.pos[1]) - + def select_roi_at(self, position: Sequence[float]) -> ROIRectangle | None: picked = None for roi in self._rois: - if not picked and (grb := roi.obj_at_pos(event.pos)) is not None: - roi.anchor_at(grb, event.pos) + if not picked and (grb := roi.obj_at_pos(position)) is not None: + roi.anchor_at(grb, position) roi.set_selected(True) picked = roi else: roi.set_selected(False) - - if self.active_roi() is not None: - self._stage_viewer.view.camera.interactive = False - return True - - return False + return picked def create_roi_at(self, position: Sequence[float]) -> None: - """Create a new ROI at the given position if in create ROI mode. - - Returns - ------- - bool - True if a ROI was created, False otherwise. - """ - self._stage_viewer.view.camera.interactive = False - # create the ROI rectangle for the first time - roi = self.create_roi(position) + """Create a new ROI at the given position.""" + roi = ROIRectangle(self._stage_viewer.view.scene) + roi.visible = True + roi.set_selected(True) + roi.set_anchor(roi._canvas_to_world(position)) self._undo_stack.push(InsertRoiCommand(self, roi)) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index b150c74aa..c91daf89d 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -649,16 +649,23 @@ def _t_half_width(self) -> np.ndarray: def _on_mouse_press(self, event: MouseEvent) -> None: """Handle the mouse press event.""" - if self._roi_manager.handle_mouse_press(event): + # 1. tell manager to update selection where the mouse was pressed + if self._roi_manager.select_roi_at(event.pos) is not None: + # if a roi was selected, disable the camera interaction + # because we are entering move mode... + self._stage_viewer.view.camera.interactive = False return + # 2. If there was no roi there, and we are in roi creation mode, + # create a new roi at the mouse position # (button = 1 is left mouse button) if event.button == 1 and self._actions[ROIS].isChecked(): + self._stage_viewer.view.camera.interactive = False self._roi_manager.create_roi_at(event.pos) def _on_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" - if roi := self._roi_manager.active_roi(): + if roi := self._roi_manager.selected_roi(): self._stage_viewer.setCursor(roi.get_cursor(event)) px = self._mmc.getPixelSizeUm() fov_w = self._mmc.getImageWidth() * px @@ -671,6 +678,7 @@ def _on_mouse_move(self, event: MouseEvent) -> None: def _on_mouse_release(self, event: MouseEvent) -> None: """Handle the mouse release event.""" + # restore the camera interaction, in case it was disabled by roi selection self._stage_viewer.view.camera.interactive = True # if alt key is not down... From f45ec7cecdedbf894e0dbc3602ae692ea70dd1d2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 11:53:07 -0400 Subject: [PATCH 20/27] remove another mouse event --- .../control/_stage_explorer/_rois.py | 82 +++++++++++-------- .../_stage_explorer/_stage_explorer.py | 7 +- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index b7605afea..ad021c6d2 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -176,12 +176,10 @@ def get_cursor(self, event: MouseEvent) -> Qt.CursorShape: 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) 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) def obj_at_pos(self, canvas_position: Sequence[float]) -> Grab | None: """Return the object at the given position.""" @@ -261,6 +259,7 @@ def anchor_at(self, grab: Grab, position: Sequence[float]) -> None: def on_mouse_move(self, event: MouseEvent) -> None: """Handle the mouse drag event.""" + print("roi mouse move", self, event.pos) # convert canvas -> world world_pos = self._canvas_to_world(event.pos) # drawing or resizing the ROI @@ -278,10 +277,6 @@ def on_mouse_move(self, event: MouseEvent) -> None: self._move_anchor = world_pos self.set_bounding_box(new_min, new_max) - def on_mouse_release(self, event: MouseEvent) -> None: - """Handle the mouse release event.""" - self._action_mode = ROIActionMode.NONE - # ---------------------PRIVATE METHODS--------------------- def _canvas_to_world(self, position: Sequence[float]) -> tuple[float, float]: @@ -300,18 +295,33 @@ def __init__(self, manager: ROIManager, roi: ROIRectangle) -> None: class InsertRoiCommand(_RoiCommand): def undo(self) -> None: - self._manager.remove(self._roi) + self._manager._remove(self._roi) def redo(self) -> None: - self._manager.add(self._roi) + self._manager._add(self._roi) class DeleteRoiCommand(_RoiCommand): def undo(self) -> None: - self._manager.add(self._roi) + self._manager._add(self._roi) def redo(self) -> None: - self._manager.remove(self._roi) + self._manager._remove(self._roi) + + +class ClearRoisCommand(QUndoCommand): + def __init__(self, manager: ROIManager) -> None: + super().__init__("Clear ROIs") + self._manager = manager + self._rois = set(self._manager.rois) + + def undo(self) -> None: + for roi in self._rois: + self._manager._add(roi) + + def redo(self) -> None: + for roi in self._rois: + self._manager._remove(roi) class ROIManager: @@ -339,33 +349,36 @@ def rois(self) -> set[ROIRectangle]: """List of ROIs in the scene.""" return self._rois - def value( - self, fov_w: float, fov_h: float, z_pos: float - ) -> list[useq.AbsolutePosition]: - """Return a list of `GridFromEdges` objects from the drawn rectangles.""" - # TODO: add a way to set overlap - positions = [] - for roi in self._rois: - pos = self._generate_area_coverage(roi, fov_w, fov_h, z_pos) - positions.append(pos) - return positions - - def remove_all_rois(self) -> None: - """Delete all the ROIs.""" - while self._rois: - roi = self._rois.pop() - self.remove(roi) + # undo/redoable operations --------------------------- def selected_roi(self) -> ROIRectangle | None: """Return the active ROI (the one that is currently selected).""" return next((roi for roi in self._rois if roi.selected()), None) + def clear(self) -> None: + """Delete all the ROIs.""" + self._undo_stack.push(ClearRoisCommand(self)) + + def reset_action_modes(self) -> None: + for roi in self._rois: + roi._action_mode = ROIActionMode.NONE + def remove_selected_roi(self) -> None: """Delete the selected ROI from the scene.""" if (roi := self.selected_roi()) is not None: self._undo_stack.push(DeleteRoiCommand(self, roi)) - def add(self, roi: ROIRectangle) -> None: + def create_roi_at(self, position: Sequence[float]) -> None: + """Create a new ROI at the given position.""" + roi = ROIRectangle(self._stage_viewer.view.scene) + roi.visible = True + roi.set_selected(True) + roi.set_anchor(roi._canvas_to_world(position)) + self._undo_stack.push(InsertRoiCommand(self, roi)) + + # direct manipulation of ROIs (NOT undoable) --------------------------- + + def _add(self, roi: ROIRectangle) -> None: """Add a ROI to the scene.""" if roi in self._rois: return @@ -373,7 +386,7 @@ def add(self, roi: ROIRectangle) -> None: roi.connect(self._stage_viewer.canvas) self._rois.add(roi) - def remove(self, roi: ROIRectangle) -> None: + def _remove(self, roi: ROIRectangle) -> None: """Remove a ROI from the scene.""" if roi in self._rois: roi.parent = None @@ -392,10 +405,9 @@ def select_roi_at(self, position: Sequence[float]) -> ROIRectangle | None: roi.set_selected(False) return picked - def create_roi_at(self, position: Sequence[float]) -> None: - """Create a new ROI at the given position.""" - roi = ROIRectangle(self._stage_viewer.view.scene) - roi.visible = True - roi.set_selected(True) - roi.set_anchor(roi._canvas_to_world(position)) - self._undo_stack.push(InsertRoiCommand(self, roi)) + def value( + self, fov_w: float, fov_h: float, z_pos: float + ) -> list[useq.AbsolutePosition]: + """Return a list of useq.Position objects from the drawn rectangles.""" + # TODO: add a way to set overlap + return [roi.create_useq_position(fov_w, fov_h, z_pos) for roi in self._rois] diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index c91daf89d..d535622e7 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -389,7 +389,7 @@ def _on_show_grid_action(self, checked: bool) -> None: def _remove_rois(self) -> None: """Delete all the ROIs.""" - self._roi_manager.remove_all_rois() + self._roi_manager.clear() # CORE ------------------------------------------------------------------------ @@ -665,6 +665,7 @@ def _on_mouse_press(self, event: MouseEvent) -> None: def _on_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" + print("explorer mouse move", event.pos) if roi := self._roi_manager.selected_roi(): self._stage_viewer.setCursor(roi.get_cursor(event)) px = self._mmc.getPixelSizeUm() @@ -678,12 +679,12 @@ def _on_mouse_move(self, event: MouseEvent) -> None: def _on_mouse_release(self, event: MouseEvent) -> None: """Handle the mouse release event.""" + self._roi_manager.reset_action_modes() # restore the camera interaction, in case it was disabled by roi selection self._stage_viewer.view.camera.interactive = True - # if alt key is not down... + # if alt key is not still pressed, disable the roi creation mode if QApplication.keyboardModifiers() != Qt.KeyboardModifier.AltModifier: - # set the roi to not selected self._actions[ROIS].setChecked(False) def keyPressEvent(self, a0: QKeyEvent | None) -> None: From e4f1e6b44ca5e52cce155bd395576e6e9d75f2f4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 12:36:04 -0400 Subject: [PATCH 21/27] cleanup --- .../control/_stage_explorer/_rois.py | 1 - .../_stage_explorer/_stage_explorer.py | 40 +++++++++---------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index ad021c6d2..767fa8277 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -259,7 +259,6 @@ def anchor_at(self, grab: Grab, position: Sequence[float]) -> None: def on_mouse_move(self, event: MouseEvent) -> None: """Handle the mouse drag event.""" - print("roi mouse move", self, event.pos) # convert canvas -> world world_pos = self._canvas_to_world(event.pos) # drawing or resizing the ROI diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index d535622e7..d05b00125 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -238,14 +238,12 @@ def __init__( self._mmc.events.pixelSizeChanged.connect(self._on_pixel_size_changed) # connections vispy events - self._stage_viewer.canvas.events.mouse_double_click.connect( - self._on_mouse_double_click - ) - - # connections vispy events for ROIs self._stage_viewer.canvas.events.mouse_press.connect(self._on_mouse_press) self._stage_viewer.canvas.events.mouse_move.connect(self._on_mouse_move) self._stage_viewer.canvas.events.mouse_release.connect(self._on_mouse_release) + self._stage_viewer.canvas.events.mouse_double_click.connect( + self._on_mouse_double_click + ) self._on_sys_config_loaded() @@ -401,20 +399,6 @@ def _on_pixel_size_changed(self, value: float) -> None: """Clear the scene when the pixel size changes.""" self._delete_stage_position_marker() - def _on_mouse_double_click(self, event: MouseEvent) -> None: - """Move the stage to the clicked position.""" - if not self._mmc.getXYStageDevice(): - return - - # map the clicked canvas position to the stage position - x, y, _, _ = self._stage_viewer.view.camera.transform.imap(event.pos) - self._stage_controller.move_absolute((x, y)) - self._stage_controller.snap_on_finish = self._snap_on_double_click - - # update the stage position label - self._stage_pos_label.setText(f"X: {x:.2f} µm Y: {y:.2f} µm") - # snap an image if the snap on double click property is set - def _on_image_snapped(self) -> None: """Add the snapped image to the scene.""" if self._mmc.mda.is_running(): @@ -645,7 +629,7 @@ def _t_half_width(self) -> np.ndarray: T_center[1, 3] = -self._mmc.getImageHeight() / 2 return T_center - # ROIs ------------------------------------------------------------------------ + # MOUSE/KEYBOARD EVENTS ----------------------------------------------------- def _on_mouse_press(self, event: MouseEvent) -> None: """Handle the mouse press event.""" @@ -665,7 +649,6 @@ def _on_mouse_press(self, event: MouseEvent) -> None: def _on_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" - print("explorer mouse move", event.pos) if roi := self._roi_manager.selected_roi(): self._stage_viewer.setCursor(roi.get_cursor(event)) px = self._mmc.getPixelSizeUm() @@ -687,6 +670,21 @@ def _on_mouse_release(self, event: MouseEvent) -> None: if QApplication.keyboardModifiers() != Qt.KeyboardModifier.AltModifier: self._actions[ROIS].setChecked(False) + def _on_mouse_double_click(self, event: MouseEvent) -> None: + """Move the stage to the clicked position.""" + # right click, or no stage device + if event.button != 1 or not self._mmc.getXYStageDevice(): + return # pragma: no cover + + # map the clicked canvas position to the stage position + x, y, _, _ = self._stage_viewer.view.camera.transform.imap(event.pos) + self._stage_controller.move_absolute((x, y)) + self._stage_controller.snap_on_finish = self._snap_on_double_click + + # update the stage position label + self._stage_pos_label.setText(f"X: {x:.2f} µm Y: {y:.2f} µm") + # snap an image if the snap on double click property is set + def keyPressEvent(self, a0: QKeyEvent | None) -> None: if a0 is None: # pragma: no cover return From 4a556d16e8883869bae88b55dfdc9f7936643a7b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 12:51:43 -0400 Subject: [PATCH 22/27] cleanup --- .../control/_stage_explorer/_rois.py | 56 ++++++++----------- .../_stage_explorer/_stage_explorer.py | 32 +++++++++-- 2 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index 767fa8277..3bef284f5 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -46,6 +46,10 @@ def opposite(self) -> Grab: """Return the opposite handle.""" return Grab((self + 2) % 4) + def qt_cursor(self) -> Qt.CursorShape: + """Return the Qt cursor for this handle.""" + _CURSOR_MAP.get(self, Qt.CursorShape.ArrowCursor) + _CURSOR_MAP: dict[Grab | None, Qt.CursorShape] = { None: Qt.CursorShape.ArrowCursor, @@ -163,16 +167,6 @@ def set_bounding_box( # Keep text centered self._text.pos = self._rect.center - 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) - def connect(self, canvas: scene.SceneCanvas) -> None: """Connect the ROI events to the canvas.""" canvas.events.mouse_move.connect(self.on_mouse_move) @@ -208,31 +202,29 @@ def obj_at_pos(self, canvas_position: Sequence[float]) -> Grab | None: def create_useq_position( self, fov_w: float, fov_h: float, z_pos: float ) -> useq.AbsolutePosition: - """Return a `GridFromEdges` plan from the roi and fov width and height.""" + """Return a useq.AbsolutePosition object that covers the ROI.""" (left, top), (right, bottom) = self.bounding_box() - x, y = self.center + pos = useq.AbsolutePosition(x=self.center[0], y=self.center[1], z=z_pos) # if the width and the height of the roi are smaller than the fov width and - # a single position at the center of the roi is sufficient - if abs(right - left) < fov_w and abs(bottom - top) < fov_h: - return useq.AbsolutePosition(x=x, y=y, z=z_pos) - - # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and - # bottom_right corners respectively because the grid plan is created - # considering the center of the fov and we want the roi to define the edges - # of the grid plan. - grid_plan = useq.GridFromEdges( - top=top, - bottom=bottom, - left=left, - right=right, - fov_width=fov_w, - fov_height=fov_h, - ) - - return useq.AbsolutePosition( - x=x, y=y, z=z_pos, sequence=useq.MDASequence(grid_plan=grid_plan) - ) + # a single position at the center of the roi is sufficient, otherwise create a + # grid plan that covers the roi + if abs(right - left) > fov_w or abs(bottom - top) > fov_h: + # NOTE: we need to add the fov_w/2 and fov_h/2 to the top_left and + # bottom_right corners respectively because the grid plan is created + # considering the center of the fov and we want the roi to define the edges + # of the grid plan. + pos.sequence = useq.MDASequence( + grid_plan=useq.GridFromEdges( + top=top, + bottom=bottom, + left=left, + right=right, + fov_width=fov_w, + fov_height=fov_h, + ) + ) + return pos def update_rows_cols_text(self, fov_w: float, fov_h: float, z_pos: float) -> None: """Update the text of the ROI with the number of rows and columns.""" diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index d05b00125..cc2451218 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -51,6 +51,7 @@ SHOW_GRID = "Show Grid" ROIS = "Create ROI" DELETE_ROIS = "Delete All ROIs" +SCAN = "Scan Selected ROIs" # this might belong in _stage_position_marker.py @@ -159,6 +160,7 @@ def __init__( self._current_scale: int = 1 # properties + self._show_mda_images: bool = False self._auto_zoom_to_fit: bool = False self._snap_on_double_click: bool = True self._poll_stage_position: bool = True @@ -191,6 +193,7 @@ def __init__( SHOW_GRID: ("mdi:grid", True, self._on_show_grid_action), ROIS: ("mdi:vector-square", True, None), DELETE_ROIS: ("mdi:vector-square-remove", False, self._remove_rois), + SCAN: ("iconoir:path-arrow-solid", False, self._on_scan_action), } # fmt: on @@ -351,6 +354,18 @@ def _on_auto_zoom_to_fit_action(self, checked: bool) -> None: if checked: self.zoom_to_fit() + def _on_scan_action(self) -> None: + """Scan the selected ROIs.""" + if not (active_roi := self._roi_manager.selected_roi()): + return + fov_w, fov_h, z_pos = self._fov_w_h_z_pos() + pos = active_roi.create_useq_position(fov_w=fov_w, fov_h=fov_h, z_pos=z_pos) + seq = useq.MDASequence(stage_positions=[pos]) + + self._show_mda_images = True + # fixme: turn back off + self._mmc.run_mda(seq) + def _create_poll_stage_button(self) -> QToolButton: btn = QToolButton() btn.setToolTip(f"{POLL_STAGE} (right-click for marker options)") @@ -401,7 +416,7 @@ def _on_pixel_size_changed(self, value: float) -> None: def _on_image_snapped(self) -> None: """Add the snapped image to the scene.""" - if self._mmc.mda.is_running(): + if self._mmc.mda.is_running() and not self._show_mda_images: return # get the snapped image img = self._mmc.getImage() @@ -650,16 +665,21 @@ def _on_mouse_press(self, event: MouseEvent) -> None: def _on_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" if roi := self._roi_manager.selected_roi(): - self._stage_viewer.setCursor(roi.get_cursor(event)) - px = self._mmc.getPixelSizeUm() - fov_w = self._mmc.getImageWidth() * px - fov_h = self._mmc.getImageHeight() * px - z_pos = self._mmc.getZPosition() + self._stage_viewer.setCursor(roi.obj_at_pos(event.pos).qt_cursor()) + fov_w, fov_h, z_pos = self._fov_w_h_z_pos() roi.update_rows_cols_text(fov_w=fov_w, fov_h=fov_h, z_pos=z_pos) else: # reset cursor to default self._stage_viewer.setCursor(Qt.CursorShape.ArrowCursor) + def _fov_w_h_z_pos(self) -> tuple[float, float, float]: + """Return the field of view width, height and z position.""" + px = self._mmc.getPixelSizeUm() + fov_w = self._mmc.getImageWidth() * px + fov_h = self._mmc.getImageHeight() * px + z_pos = self._mmc.getZPosition() + return fov_w, fov_h, z_pos + def _on_mouse_release(self, event: MouseEvent) -> None: """Handle the mouse release event.""" self._roi_manager.reset_action_modes() From 073bf3148e21a6b106b5aec3e9ec3c491617e704 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 13:19:43 -0400 Subject: [PATCH 23/27] scan area button --- examples/stage_explorer_widget.py | 2 +- .../control/_stage_explorer/_rois.py | 36 +++++++++++-------- .../_stage_explorer/_stage_explorer.py | 23 +++++++----- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/examples/stage_explorer_widget.py b/examples/stage_explorer_widget.py index c8b8f022a..eb0388cda 100644 --- a/examples/stage_explorer_widget.py +++ b/examples/stage_explorer_widget.py @@ -16,7 +16,7 @@ mmc.setROI(0, 0, 400, 600) xy = mmc.getXYStageDevice() if mmc.hasProperty(xy, "Velocity"): - mmc.setProperty(xy, "Velocity", 2) + mmc.setProperty(xy, "Velocity", 5) explorer = StageExplorer() diff --git a/src/pymmcore_widgets/control/_stage_explorer/_rois.py b/src/pymmcore_widgets/control/_stage_explorer/_rois.py index 3bef284f5..ceb6ec123 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_rois.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_rois.py @@ -46,10 +46,6 @@ def opposite(self) -> Grab: """Return the opposite handle.""" return Grab((self + 2) % 4) - def qt_cursor(self) -> Qt.CursorShape: - """Return the Qt cursor for this handle.""" - _CURSOR_MAP.get(self, Qt.CursorShape.ArrowCursor) - _CURSOR_MAP: dict[Grab | None, Qt.CursorShape] = { None: Qt.CursorShape.ArrowCursor, @@ -175,6 +171,10 @@ def disconnect(self, canvas: scene.SceneCanvas) -> None: """Disconnect the ROI events from the canvas.""" canvas.events.mouse_move.disconnect(self.on_mouse_move) + def get_cursor(self, position: Sequence[float]) -> Qt.CursorShape: + """Return the cursor shape for the given position.""" + return _CURSOR_MAP.get(self.obj_at_pos(position), Qt.CursorShape.ArrowCursor) + def obj_at_pos(self, canvas_position: Sequence[float]) -> Grab | None: """Return the object at the given position.""" # 1) Convert to world coords @@ -214,15 +214,19 @@ def create_useq_position( # bottom_right corners respectively because the grid plan is created # considering the center of the fov and we want the roi to define the edges # of the grid plan. - pos.sequence = useq.MDASequence( - grid_plan=useq.GridFromEdges( - top=top, - bottom=bottom, - left=left, - right=right, - fov_width=fov_w, - fov_height=fov_h, - ) + pos = pos.model_copy( + update={ + "sequence": useq.MDASequence( + grid_plan=useq.GridFromEdges( + top=top - fov_h / 2, + bottom=bottom + fov_h / 2, + left=left + fov_w / 2, + right=right - fov_w / 2, + fov_width=fov_w, + fov_height=fov_h, + ) + ) + } ) return pos @@ -231,8 +235,10 @@ def update_rows_cols_text(self, fov_w: float, fov_h: float, z_pos: float) -> Non pos = self.create_useq_position(fov_w, fov_h, z_pos) if pos.sequence: grid = cast("useq.GridFromEdges", pos.sequence.grid_plan) - nc = math.ceil(abs(grid.right - grid.left) / fov_w) - nr = math.ceil(abs(grid.top - grid.bottom) / fov_h) + # the added fov here is to account for the fact that the grid plan + # is created above with fov/2 added to the edges. + nc = math.ceil((fov_w + abs(grid.right - grid.left)) / fov_w) + nr = math.ceil((fov_h + abs(grid.top - grid.bottom)) / fov_h) self.set_text(f"r{nr} x c{nc}") else: self.set_text("r1 x c1") diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index cc2451218..356d4ca5d 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -5,9 +5,10 @@ from typing import TYPE_CHECKING, Callable import numpy as np +import useq from pymmcore_plus import CMMCorePlus, Keyword from qtpy.QtCore import QPoint, Qt -from qtpy.QtGui import QIcon, QKeyEvent, QKeySequence, QUndoStack +from qtpy.QtGui import QCloseEvent, QIcon, QKeyEvent, QKeySequence, QUndoStack from qtpy.QtWidgets import ( QApplication, QLabel, @@ -27,7 +28,6 @@ from ._stage_viewer import StageViewer, get_vispy_scene_bounds if TYPE_CHECKING: - import useq from PyQt6.QtGui import QAction, QActionGroup from qtpy.QtCore import QTimerEvent from vispy.app.canvas import MouseEvent @@ -158,9 +158,10 @@ def __init__( # to keep track of the current scale depending on the zoom level self._current_scale: int = 1 + # whether we started the MDA + self._our_mda_running: bool = False # properties - self._show_mda_images: bool = False self._auto_zoom_to_fit: bool = False self._snap_on_double_click: bool = True self._poll_stage_position: bool = True @@ -218,6 +219,7 @@ def __init__( # update checked state of the actions self._on_poll_stage_action(self._poll_stage_position) self._on_snap_action(self._snap_on_double_click) + self._actions[SCAN].setEnabled(False) # add stage pos label to the toolbar self._stage_pos_label = QLabel() @@ -238,6 +240,7 @@ def __init__( self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_config_loaded) self._mmc.events.imageSnapped.connect(self._on_image_snapped) self._mmc.mda.events.frameReady.connect(self._on_frame_ready) + self._mmc.mda.events.sequenceFinished.connect(self._on_sequence_finished) self._mmc.events.pixelSizeChanged.connect(self._on_pixel_size_changed) # connections vispy events @@ -362,8 +365,7 @@ def _on_scan_action(self) -> None: pos = active_roi.create_useq_position(fov_w=fov_w, fov_h=fov_h, z_pos=z_pos) seq = useq.MDASequence(stage_positions=[pos]) - self._show_mda_images = True - # fixme: turn back off + self._our_mda_running = True self._mmc.run_mda(seq) def _create_poll_stage_button(self) -> QToolButton: @@ -416,7 +418,7 @@ def _on_pixel_size_changed(self, value: float) -> None: def _on_image_snapped(self) -> None: """Add the snapped image to the scene.""" - if self._mmc.mda.is_running() and not self._show_mda_images: + if self._mmc.mda.is_running() and not self._our_mda_running: return # get the snapped image img = self._mmc.getImage() @@ -431,6 +433,10 @@ def _on_frame_ready(self, image: np.ndarray, event: useq.MDAEvent) -> None: y = event.y_pos if event.y_pos is not None else self._mmc.getYPosition() self._add_image_and_update_widget(image, x, y) + def _on_sequence_finished(self) -> None: + """Reset the MDA running flag when the sequence is finished.""" + self._our_mda_running = False + # STAGE POSITION MARKER ----------------------------------------------------- def _on_poll_stage_action(self, checked: bool) -> None: @@ -665,11 +671,10 @@ def _on_mouse_press(self, event: MouseEvent) -> None: def _on_mouse_move(self, event: MouseEvent) -> None: """Update the roi text when the roi changes size.""" if roi := self._roi_manager.selected_roi(): - self._stage_viewer.setCursor(roi.obj_at_pos(event.pos).qt_cursor()) + self._stage_viewer.setCursor(roi.get_cursor(event.pos)) fov_w, fov_h, z_pos = self._fov_w_h_z_pos() roi.update_rows_cols_text(fov_w=fov_w, fov_h=fov_h, z_pos=z_pos) else: - # reset cursor to default self._stage_viewer.setCursor(Qt.CursorShape.ArrowCursor) def _fov_w_h_z_pos(self) -> tuple[float, float, float]: @@ -690,6 +695,8 @@ def _on_mouse_release(self, event: MouseEvent) -> None: if QApplication.keyboardModifiers() != Qt.KeyboardModifier.AltModifier: self._actions[ROIS].setChecked(False) + self._actions[SCAN].setEnabled(bool(self._roi_manager.selected_roi())) + def _on_mouse_double_click(self, event: MouseEvent) -> None: """Move the stage to the clicked position.""" # right click, or no stage device From 1cf3c5f0af040b288dc0eadf57154d3a7ba1069c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 8 May 2025 17:20:05 +0000 Subject: [PATCH 24/27] style(pre-commit.ci): auto fixes [...] --- src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 356d4ca5d..8ac232f77 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -8,7 +8,7 @@ import useq from pymmcore_plus import CMMCorePlus, Keyword from qtpy.QtCore import QPoint, Qt -from qtpy.QtGui import QCloseEvent, QIcon, QKeyEvent, QKeySequence, QUndoStack +from qtpy.QtGui import QIcon, QKeyEvent, QKeySequence, QUndoStack from qtpy.QtWidgets import ( QApplication, QLabel, From d1ee3d45ff09ed135f669d213526f3cecc532020 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 8 May 2025 13:30:16 -0400 Subject: [PATCH 25/27] only if not running --- .../control/_stage_explorer/_stage_explorer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 356d4ca5d..ef87cb86a 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -365,8 +365,9 @@ def _on_scan_action(self) -> None: pos = active_roi.create_useq_position(fov_w=fov_w, fov_h=fov_h, z_pos=z_pos) seq = useq.MDASequence(stage_positions=[pos]) - self._our_mda_running = True - self._mmc.run_mda(seq) + if not self._mmc.mda.is_running(): + self._our_mda_running = True + self._mmc.run_mda(seq) def _create_poll_stage_button(self) -> QToolButton: btn = QToolButton() From d01974b75d9930f3e21434e221d67e711d3f71a1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 10 May 2025 09:28:57 -0400 Subject: [PATCH 26/27] fix tests --- tests/test_stage_explorer.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_stage_explorer.py b/tests/test_stage_explorer.py index 3d1cbd9ee..499fd25b5 100644 --- a/tests/test_stage_explorer.py +++ b/tests/test_stage_explorer.py @@ -71,7 +71,7 @@ def test_stage_explorer_initialization(qtbot: QtBot) -> None: explorer = StageExplorer() qtbot.addWidget(explorer) assert explorer.windowTitle() == "Stage Explorer" - assert explorer.snap_on_double_click is False + assert explorer.snap_on_double_click is True def test_stage_explorer_snap_on_double_click(qtbot: QtBot) -> None: @@ -101,9 +101,10 @@ def test_stage_explorer_actions(qtbot: QtBot) -> None: actions = explorer.toolBar().actions() snap_action = next(a for a in actions if a.text() == _stage_explorer.SNAP) + assert explorer.snap_on_double_click is True with qtbot.waitSignal(snap_action.triggered): snap_action.trigger() - assert explorer.snap_on_double_click is True + assert explorer.snap_on_double_click is False auto_action = explorer._actions[_stage_explorer.AUTO_ZOOM_TO_FIT] auto_action.trigger() @@ -112,10 +113,10 @@ def test_stage_explorer_actions(qtbot: QtBot) -> None: explorer._actions[_stage_explorer.ZOOM_TO_FIT].trigger() assert not explorer.auto_zoom_to_fit - assert not explorer._grid_lines.visible + assert not explorer._stage_viewer._grid_lines.visible grid_action = explorer._actions[_stage_explorer.SHOW_GRID] grid_action.trigger() - assert explorer._grid_lines.visible + assert explorer._stage_viewer._grid_lines.visible def test_stage_explorer_move_on_click(qtbot: QtBot) -> None: From 65bf77f16fdaebb001fb169101b8ca22370604c6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 10 May 2025 09:29:27 -0400 Subject: [PATCH 27/27] fix types --- .../control/_stage_explorer/_stage_viewer.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py index 745acfbac..1750fa3d3 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_viewer.py @@ -1,13 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from unittest.mock import patch import cmap import numpy as np import vispy -import vispy.app -import vispy.app.backends import vispy.scene import vispy.visuals from qtpy.QtCore import Qt @@ -32,10 +30,10 @@ def create_native(self) -> None: from vispy.app.backends._qt import CanvasBackendDesktop class CustomCanvasBackend(CanvasBackendDesktop): - def keyPressEvent(self, ev) -> None: + def keyPressEvent(self, ev: Any) -> None: QWidget.keyPressEvent(self, ev) - def keyReleaseEvent(self, ev) -> None: + def keyReleaseEvent(self, ev: Any) -> None: QWidget.keyPressEvent(self, ev) with patch.object(