diff --git a/examples/stage_explorer_widget.py b/examples/stage_explorer_widget.py index 2be744435..a69e629da 100644 --- a/examples/stage_explorer_widget.py +++ b/examples/stage_explorer_widget.py @@ -1,4 +1,5 @@ from pymmcore_plus import CMMCorePlus +from qtpy.QtCore import QModelIndex from qtpy.QtWidgets import QApplication, QHBoxLayout, QSplitter, QVBoxLayout, QWidget from pymmcore_widgets import ( @@ -34,6 +35,20 @@ cam_roi = CameraRoiWidget() +# As an example... +# When ROIs are edited or destroyed, update the MDA widget's stage positions. +def _on_data_changed(top_left: QModelIndex, bottom_right: QModelIndex) -> None: + positions = [roi.create_useq_position() for roi in explorer.roi_manager.all_rois()] + # optional: skip positions that don't actually have a valid sequence (grid plan) + positions = [pos for pos in positions if pos.sequence is not None] + mda_widget.stage_positions.setValue(positions) + + +model = explorer.roi_manager.roi_model +model.dataChanged.connect(_on_data_changed) +model.rowsRemoved.connect(_on_data_changed) + + # layout splitter = QSplitter() diff --git a/pyproject.toml b/pyproject.toml index 75b07fa65..29903faca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,10 +48,10 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - 'pymmcore-plus[cli] >=0.14.0', + 'pymmcore-plus[cli] >=0.15.4', 'qtpy >=2.0', 'superqt[quantity,cmap,iconify] >=0.7.1', - 'useq-schema >=0.8.0', + 'useq-schema >=0.8.1', 'vispy >=0.15.0', "pyopengl >=3.1.9; platform_system == 'Darwin'", "shapely>=2.0.7", diff --git a/src/pymmcore_widgets/control/_rois/_vispy.py b/src/pymmcore_widgets/control/_rois/_vispy.py index 4e3fe3533..6bad4efb9 100644 --- a/src/pymmcore_widgets/control/_rois/_vispy.py +++ b/src/pymmcore_widgets/control/_rois/_vispy.py @@ -47,9 +47,13 @@ def update_vertices(self, vertices: np.ndarray) -> None: centers: list[tuple[float, float]] = [] try: - if (grid := self._roi.create_grid_plan()) is not None: + grid = self._roi.create_grid_plan( + overlap=self._roi.fov_overlap, mode=self._roi.scan_order + ) + if grid is not None: for p in grid: - centers.append((p.x, p.y)) + if p.x is not None and p.y is not None: + centers.append((p.x, p.y)) except Exception as e: raise print(e) diff --git a/src/pymmcore_widgets/control/_rois/q_roi_model.py b/src/pymmcore_widgets/control/_rois/q_roi_model.py index 25088ddcd..3fcf468fb 100644 --- a/src/pymmcore_widgets/control/_rois/q_roi_model.py +++ b/src/pymmcore_widgets/control/_rois/q_roi_model.py @@ -75,6 +75,11 @@ def flags(self, index: QModelIndex) -> Qt.ItemFlag: # editable stuff ------------------------------ + def getRoi(self, index: int) -> ROI: + if 0 <= index < len(self._rois): + return self._rois[index] + raise IndexError("Index out of bounds") + def setData( self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole ) -> bool: diff --git a/src/pymmcore_widgets/control/_rois/roi_manager.py b/src/pymmcore_widgets/control/_rois/roi_manager.py index a5ab1b95f..63adec102 100644 --- a/src/pymmcore_widgets/control/_rois/roi_manager.py +++ b/src/pymmcore_widgets/control/_rois/roi_manager.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, Literal from qtpy.QtCore import ( QEvent, @@ -122,7 +122,7 @@ def update_fovs(self, fov: tuple[float, float]) -> None: """Update the FOVs of all ROIs.""" self._fov_size = fov for row in range(self.roi_model.rowCount()): - roi = cast("ROI", self.roi_model.index(row).internalPointer()) + roi = self.roi_model.getRoi(row) roi.fov_size = fov self.roi_model.emitDataChange(roi) @@ -149,9 +149,14 @@ def canvas_to_world(self, point: QPointF) -> tuple[float, float]: def selected_rois(self) -> list[ROI]: """Return a list of selected ROIs.""" return [ - index.internalPointer() for index in self.selection_model.selectedIndexes() + index.data(QROIModel.ROI_ROLE) + for index in self.selection_model.selectedIndexes() ] + def all_rois(self) -> list[ROI]: + """Return a list of all ROIs.""" + return [self.roi_model.getRoi(row) for row in range(self.roi_model.rowCount())] + def delete_selected_rois(self) -> None: """Delete the selected ROIs from the model.""" for roi in self.selected_rois(): @@ -168,7 +173,7 @@ def _on_rows_about_to_be_removed( ) -> None: # Remove the ROIs from the canvas for row in range(first, last + 1): - roi = self.roi_model.index(row).internalPointer() + roi = self.roi_model.getRoi(row) self._remove_roi_from_canvas(roi) def _on_data_changed( @@ -188,17 +193,17 @@ def _on_selection_changed( self, selected: QItemSelection, deselected: QItemSelection ) -> None: for index in deselected.indexes(): - roi = cast("ROI", self.roi_model.index(index.row()).internalPointer()) + roi = self.roi_model.getRoi(index.row()) if visual := self._roi_visuals.get(roi): visual.set_selected(False) for index in selected.indexes(): - roi = cast("ROI", self.roi_model.index(index.row()).internalPointer()) + roi = self.roi_model.getRoi(index.row()) if visual := self._roi_visuals.get(roi): visual.set_selected(True) def _on_rows_inserted(self, parent: QModelIndex, first: int, last: int) -> None: for row in range(first, last + 1): - roi = self.roi_model.index(row).internalPointer() + roi = self.roi_model.getRoi(row) self._add_roi_to_scene(roi) def _add_roi_to_scene(self, roi: ROI) -> None: diff --git a/src/pymmcore_widgets/control/_rois/roi_model.py b/src/pymmcore_widgets/control/_rois/roi_model.py index bf1ed29ba..4c9d85d5c 100644 --- a/src/pymmcore_widgets/control/_rois/roi_model.py +++ b/src/pymmcore_widgets/control/_rois/roi_model.py @@ -23,7 +23,8 @@ class ROI: font_size: int = 12 fov_size: tuple[float, float] | None = None # (width, height) - fov_overlap: tuple[float, float] | None = None # frac (width, height) 0..1 + fov_overlap: tuple[float, float] = (0.0, 0.0) # (width, height) + scan_order: useq.OrderMode = useq.OrderMode.row_wise_snake def translate(self, dx: float, dy: float) -> None: """Translate the ROI in place by (dx, dy).""" @@ -90,7 +91,9 @@ def create_grid_plan( self, fov_w: float | None = None, fov_h: float | None = None, - ) -> useq._grid._GridPlan | None: + overlap: float | tuple[float, float] = 0.0, + mode: useq.OrderMode = useq.OrderMode.row_wise_snake, + ) -> useq.GridFromPolygon | useq.GridFromEdges | None: """Return a useq.AbsolutePosition object that covers the ROI.""" if fov_w is None or fov_h is None: if self.fov_size is None: @@ -103,14 +106,21 @@ def create_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: + overlap = overlap if isinstance(overlap, tuple) else (overlap, overlap) if type(self) is not RectangleROI: if len(self.vertices) < 3: return None - return useq.GridFromPolygon( - vertices=list(self.vertices), - fov_width=fov_w, - fov_height=fov_h, - ) + try: + return useq.GridFromPolygon( + vertices=list(self.vertices), + fov_width=fov_w, + fov_height=fov_h, + mode=mode, + overlap=overlap, + ) + except ValueError: + # likely a self-intersecting polygon that cannot be scanned... + return None else: return useq.GridFromEdges( top=top, @@ -119,6 +129,8 @@ def create_grid_plan( right=right, fov_width=fov_w, fov_height=fov_h, + mode=mode, + overlap=overlap, ) return None @@ -129,20 +141,18 @@ def create_useq_position( z_pos: float = 0.0, ) -> useq.AbsolutePosition: """Return a useq.AbsolutePosition object that covers the ROI.""" - grid_plan = self.create_grid_plan(fov_w=fov_w, fov_h=fov_h) + grid_plan = self.create_grid_plan( + fov_w=fov_w, fov_h=fov_h, overlap=self.fov_overlap, mode=self.scan_order + ) x, y = self.center() - pos = useq.AbsolutePosition(x=x, y=y, z=z_pos) + pos = useq.AbsolutePosition( + x=x, y=y, z=z_pos, name=f"{self.text} {str(id(self))[-4:]}" + ) if grid_plan is None: return pos return pos.model_copy( - update={ - "sequence": useq.MDASequence( - grid_plan=grid_plan, - fov_width=fov_w, - fov_height=fov_h, - ) - } + update={"sequence": useq.MDASequence(grid_plan=grid_plan)} ) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 451c00a8c..69ca3651a 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -9,9 +9,11 @@ import numpy as np import useq from pymmcore_plus import CMMCorePlus, Keyword -from qtpy.QtCore import QSize, Qt +from qtpy.QtCore import QModelIndex, QSize, Qt, Signal from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( + QDoubleSpinBox, + QFormLayout, QLabel, QMenu, QSizePolicy, @@ -19,8 +21,10 @@ QToolButton, QVBoxLayout, QWidget, + QWidgetAction, ) -from superqt import QIconifyIcon +from superqt import QEnumComboBox, QIconifyIcon +from useq import OrderMode from pymmcore_widgets.control._q_stage_controller import QStageMoveAccumulator from pymmcore_widgets.control._rois.roi_manager import GRAY, SceneROIManager @@ -150,6 +154,8 @@ def __init__( self._snap_on_double_click: bool = True self._poll_stage_position: bool = True self._our_mda_running: bool = False + self._grid_overlap: float = 0.0 + self._grid_mode: OrderMode = OrderMode.row_wise_snake # timer for polling stage position self._timer_id: int | None = None @@ -190,6 +196,9 @@ def __init__( tb.delete_rois_action.triggered.connect(self.roi_manager.clear) tb.scan_action.triggered.connect(self._on_scan_action) tb.marker_mode_action_group.triggered.connect(self._update_marker_mode) + tb.scan_menu.valueChanged.connect(self._on_scan_options_changed) + # ensure newly-created ROIs inherit the current scan menu settings + self.roi_manager.roi_model.rowsInserted.connect(self._on_roi_rows_inserted) # main layout main_layout = QVBoxLayout(self) @@ -339,18 +348,42 @@ def _update_marker_mode(self) -> None: self._stage_pos_marker.set_marker_visible(pi.show_marker) def _on_scan_action(self) -> None: - """Scan the selected ROIs.""" + """Scan the selected ROI.""" if not (active_rois := self.roi_manager.selected_rois()): return active_roi = active_rois[0] - if plan := active_roi.create_grid_plan(*self._fov_w_h()): - # for now, we expand the grid plan to a list of positions because - # useq grid_plan= doesn't yet support our custom polygon ROIs - seq = useq.MDASequence(stage_positions=list(plan)) + + overlap, mode = self._toolbar.scan_menu.value() + if plan := active_roi.create_grid_plan(*self._fov_w_h(), overlap, mode): + seq = useq.MDASequence(grid_plan=plan) if not self._mmc.mda.is_running(): self._our_mda_running = True self._mmc.run_mda(seq) + def _on_scan_options_changed(self, value: tuple[float, OrderMode]) -> None: + """Update all ROIs with the new overlap so the vispy visuals refresh.""" + # store locally in case callers want to use it + self._grid_overlap, self._grid_mode = value + + # update ROIs and emit model dataChanged so visuals update + for roi in self.roi_manager.all_rois(): + roi.fov_overlap = (self._grid_overlap, self._grid_overlap) + roi.scan_order = self._grid_mode + self.roi_manager.roi_model.emitDataChange(roi) + + def _on_roi_rows_inserted(self, parent: QModelIndex, first: int, last: int) -> None: + """Initialize newly-inserted ROIs with the current scan menu values. + + This ensures ROIs created after adjusting the scan options start with the + chosen overlap and acquisition order. + """ + overlap, mode = self._toolbar.scan_menu.value() + for row in range(first, last + 1): + roi = self.roi_manager.roi_model.getRoi(row) + roi.fov_overlap = (overlap, overlap) + roi.scan_order = mode + self.roi_manager.roi_model.emitDataChange(roi) + def keyPressEvent(self, a0: QKeyEvent | None) -> None: if a0 is None: return @@ -431,7 +464,6 @@ def _on_frame_ready(self, image: np.ndarray, event: useq.MDAEvent) -> None: def _on_poll_stage_action(self, checked: bool) -> None: """Set the poll stage position property based on the state of the action.""" self._stage_pos_marker.visible = checked - print("Stage position marker visible:", self._stage_pos_marker.visible) self._poll_stage_position = checked if checked: self._timer_id = self.startTimer(20) @@ -583,9 +615,58 @@ def __init__(self, parent: QWidget | None = None): self.addSeparator() self.scan_action = self.addAction( QIconifyIcon("ph:path-duotone", color=GRAY), - "Scan Selected ROIs", + "Scan Selected ROI", + ) + scan_btn = cast("QToolButton", self.widgetForAction(self.scan_action)) + self.scan_menu = ScanMenu(self) + scan_btn.setMenu(self.scan_menu) + scan_btn.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) + + +class ScanMenu(QMenu): + """Menu widget that exposes scan grid options.""" + + valueChanged = Signal(object) + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setTitle("Scan Selected ROI") + + # container widget for form layout + opts_widget = QWidget(self) + form = QFormLayout(opts_widget) + form.setContentsMargins(8, 8, 8, 8) + form.setSpacing(6) + + # overlap spinbox + self._overlap_spin = QDoubleSpinBox(opts_widget) + self._overlap_spin.setDecimals(2) + self._overlap_spin.setRange(-100, 100) + self._overlap_spin.setSingleStep(1) + form.addRow("Overlap", self._overlap_spin) + + # acquisition mode combo + self._mode_cbox = QEnumComboBox(self, OrderMode) + self._mode_cbox.setCurrentEnum(OrderMode.row_wise_snake) + form.addRow("Order", self._mode_cbox) + + # wrap in a QWidgetAction so it shows as a menu panel + self.opts_action = QWidgetAction(self) + self.opts_action.setDefaultWidget(opts_widget) + self.addAction(self.opts_action) + + self._overlap_spin.valueChanged.connect(self._on_value_changed) + self._mode_cbox.currentTextChanged.connect(self._on_value_changed) + + def value(self) -> tuple[float, useq.OrderMode]: + """Return the current grid overlap and order mode.""" + return self._overlap_spin.value(), cast( + "OrderMode", self._mode_cbox.currentEnum() ) + def _on_value_changed(self) -> None: + self.valueChanged.emit(self.value()) + SLOTS = {"slots": True} if sys.version_info >= (3, 10) else {} diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 4fab620b7..071db773b 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -315,6 +315,11 @@ def fovHeight(self) -> float | None: def _on_change(self) -> None: if (val := self.value()) is None: return # pragma: no cover + # here we are calling setValue on the polygon widget only because + # we want to be able to change parameters such as the overlap or acquisition + # order from the gui. + if isinstance(val, useq.GridFromPolygon): + self.polygon_wdg.setValue(val) self.valueChanged.emit(val)