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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/pymmcore_widgets/control/_rois/_vispy.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ def update_vertices(self, vertices: np.ndarray) -> None:

centers: list[tuple[float, float]] = []
try:
if (grid := self._roi.create_grid_plan()) is not None:
if (
grid := self._roi.create_grid_plan(
overlap=self._roi.fov_overlap, mode=self._roi.acq_mode
)
) is not None:
for p in grid:
centers.append((p.x, p.y))
except Exception as e:
Expand Down
7 changes: 7 additions & 0 deletions src/pymmcore_widgets/control/_rois/roi_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ def selected_rois(self) -> list[ROI]:
index.internalPointer() for index in self.selection_model.selectedIndexes()
]

def all_rois(self) -> list[ROI]:
"""Return a list of all ROIs."""
return [
self.roi_model.index(row).internalPointer()
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():
Expand Down
126 changes: 15 additions & 111 deletions src/pymmcore_widgets/control/_rois/roi_model.py
Original file line number Diff line number Diff line change
@@ -1,118 +1,12 @@
from __future__ import annotations

from dataclasses import dataclass, field
from functools import cached_property
from typing import TYPE_CHECKING, Annotated, Any
from typing import Any
from uuid import UUID, uuid4

import numpy as np
import useq
import useq._grid
from pydantic import Field, PrivateAttr
from shapely import Polygon, box, prepared

if TYPE_CHECKING:
from collections.abc import Iterator


class GridFromPolygon(useq._grid._GridPlan[useq.AbsolutePosition]):
vertices: Annotated[
list[tuple[float, float]],
Field(
min_length=3,
description="List of points that define the polygon",
frozen=True,
),
]

def num_positions(self) -> int:
"""Return the number of positions in the grid."""
if self.fov_width is None or self.fov_height is None:
raise ValueError("fov_width and fov_height must be set")
return len(
self._cached_tiles(
fov=(self.fov_width, self.fov_height), overlap=self.overlap
)
)

def iter_grid_positions(
self,
fov_width: float | None = None,
fov_height: float | None = None,
*,
order: useq.OrderMode | None = None,
) -> Iterator[useq.AbsolutePosition]:
"""Iterate over all grid positions, given a field of view size."""
try:
pos = self._cached_tiles(
fov=(
fov_width or self.fov_width or 1,
fov_height or self.fov_height or 1,
),
overlap=self.overlap,
order=order,
)
except ValueError:
pos = []
for x, y in pos:
yield useq.AbsolutePosition(x=x, y=y)

@cached_property
def poly(self) -> Polygon:
"""Return the polygon vertices as a list of (x, y) tuples."""
return Polygon(self.vertices)

@cached_property
def prepared_poly(self) -> prepared.PreparedGeometry:
"""Return the prepared polygon for faster intersection tests."""
return prepared.prep(self.poly)

_poly_cache: dict[tuple, list[tuple[float, float]]] = PrivateAttr(
default_factory=dict
)

def _cached_tiles(
self,
*,
fov: tuple[float, float],
overlap: tuple[float, float],
order: useq.OrderMode | None = None,
) -> list[tuple[float, float]]:
"""Compute an ordered list of (x, y) stage positions that cover the ROI."""
# Compute grid spacing and half-extents
mode = useq.OrderMode(order) if order is not None else self.mode
key = (fov, overlap, mode)

if key not in self._poly_cache:
w, h = fov
dx = w * (1 - overlap[0])
dy = h * (1 - overlap[1])
half_w, half_h = w / 2, h / 2

# Expand bounds to ensure full coverage
minx, miny, maxx, maxy = self.poly.bounds
minx -= half_w
miny -= half_h
maxx += half_w
maxy += half_h

# Determine grid dimensions
n_cols = int(np.ceil((maxx - minx) / dx))
n_rows = int(np.ceil((maxy - miny) / dy))

# Generate grid positions
positions: list[tuple[float, float]] = []
prepared_poly = self.prepared_poly

for r, c in mode.generate_indices(n_rows, n_cols):
x = c + minx + (c + 0.5) * dx + half_w
y = maxy - (r + 0.5) * dy - half_h
tile = box(x - half_w, y - half_h, x + half_w, y + half_h)
if prepared_poly.intersects(tile):
positions.append((x, y))

self._poly_cache[key] = positions
return self._poly_cache[key]


@dataclass(eq=False)
Expand All @@ -129,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)
acq_mode: useq.OrderMode = useq.OrderMode.row_wise_snake

def translate(self, dx: float, dy: float) -> None:
"""Translate the ROI in place by (dx, dy)."""
Expand Down Expand Up @@ -196,6 +91,8 @@ def create_grid_plan(
self,
fov_w: float | None = None,
fov_h: float | None = None,
overlap: float | tuple[float, float] = 0.0,
mode: useq.OrderMode = useq.OrderMode.row_wise_snake,
) -> useq._grid._GridPlan | None:
"""Return a useq.AbsolutePosition object that covers the ROI."""
if fov_w is None or fov_h is None:
Expand All @@ -209,13 +106,16 @@ 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 GridFromPolygon(
vertices=self.vertices,
return useq.GridFromPolygon( # type: ignore # until new useq-schema
vertices=list(self.vertices),
fov_width=fov_w,
fov_height=fov_h,
mode=mode,
overlap=overlap,
)
else:
return useq.GridFromEdges(
Expand All @@ -225,6 +125,8 @@ def create_grid_plan(
right=right,
fov_width=fov_w,
fov_height=fov_h,
mode=mode,
overlap=overlap,
)
return None

Expand All @@ -235,7 +137,9 @@ 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.acq_mode
)
x, y = self.center()
pos = useq.AbsolutePosition(x=x, y=y, z=z_pos)
if grid_plan is None:
Expand Down
122 changes: 113 additions & 9 deletions src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,22 @@
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,
QToolBar,
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -291,6 +300,29 @@ 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 rois_to_useq_positions(self) -> list[useq.AbsolutePosition] | None:
if not (rois := self.roi_manager.all_rois()):
return None

positions: list[useq.AbsolutePosition] = []
for idx, roi in enumerate(rois):
overlap, mode = self._toolbar.scan_menu.value()
if plan := roi.create_grid_plan(*self._fov_w_h(), overlap, mode):
p: useq.AbsolutePosition = next(iter(plan.iter_grid_positions()))
pos = useq.AbsolutePosition(
name=f"ROI_{idx}",
x=p.x,
y=p.y,
z=p.z,
sequence=useq.MDASequence(grid_plan=plan),
)
positions.append(pos)

if not positions:
return None

return positions

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

# ACTIONS ----------------------------------------------------------------------
Expand Down Expand Up @@ -339,18 +371,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.acq_mode = 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.index(row).internalPointer()
roi.fov_overlap = (overlap, overlap)
roi.acq_mode = mode
self.roi_manager.roi_model.emitDataChange(roi)

def keyPressEvent(self, a0: QKeyEvent | None) -> None:
if a0 is None:
return
Expand Down Expand Up @@ -431,7 +487,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)
Expand Down Expand Up @@ -583,8 +638,57 @@ 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 {}
Expand Down
Loading
Loading