Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
00b2ca1
feat: use useq GridFromPolygon
fdrgsp Aug 11, 2025
9e5b1ec
fix tolltip
fdrgsp Aug 11, 2025
5742e47
add all_rois method
fdrgsp Aug 11, 2025
9ceb59d
remove print
fdrgsp Aug 11, 2025
8b78bb5
fix Mode
fdrgsp Aug 11, 2025
3a5c2fa
revert
fdrgsp Aug 11, 2025
e8230ae
add GridFromPolygon visualization to MDA popup
fdrgsp Aug 12, 2025
b34b8d0
remove print
fdrgsp Aug 12, 2025
ed894a0
feat: add GridFromPolygon to GridPlanWidget + tests
fdrgsp Aug 12, 2025
2a70f1f
wip use QGraphicsScene
fdrgsp Aug 12, 2025
871fd08
update _PolygonWidget
fdrgsp Aug 13, 2025
b95f12d
update pen size
fdrgsp Aug 13, 2025
c18b785
fix test
fdrgsp Aug 13, 2025
f444da1
add acq order lines
fdrgsp Aug 13, 2025
b57d159
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Aug 13, 2025
ba627be
update
fdrgsp Aug 13, 2025
ab50d67
update
fdrgsp Aug 13, 2025
67d3c38
add rois_to_useq_positions
fdrgsp Aug 14, 2025
e321577
update
fdrgsp Aug 14, 2025
f74fef9
update example
fdrgsp Aug 14, 2025
34e5c9d
update example
fdrgsp Aug 14, 2025
832b589
limit max fov pixels rect size
fdrgsp Aug 14, 2025
1a18de8
remove print
fdrgsp Aug 14, 2025
d81ff55
remove unused
fdrgsp Aug 14, 2025
d705a66
add overlap and acq mode
fdrgsp Aug 14, 2025
51d9961
use useq GrodFromPolygon
fdrgsp Aug 15, 2025
35ea470
Merge branch 'polygon-from-useq' into add-overlap-to-explorer
fdrgsp Aug 15, 2025
e739deb
bump useq
tlambert03 Aug 15, 2025
163a34c
stuff
tlambert03 Aug 15, 2025
ee8149b
Merge branch 'main' into polygon-from-useq
tlambert03 Aug 15, 2025
f7da770
Merge branch 'polygon-from-useq' into add-overlap-to-explorer
tlambert03 Aug 15, 2025
626b50a
Merge branch 'main' into add-overlap-to-explorer
tlambert03 Aug 15, 2025
221d492
refactor: rename acq_mode to scan_order in ROI and update related ref…
tlambert03 Aug 15, 2025
4bb2b0f
feat: update ROI handling and improve grid plan creation in StageExpl…
tlambert03 Aug 15, 2025
34f21df
feat: enforce polygon mode selection validation in GridPlanWidget
tlambert03 Aug 15, 2025
a545c52
remove unused method
tlambert03 Aug 15, 2025
30554c3
remove try
tlambert03 Aug 15, 2025
63169c8
pragma
tlambert03 Aug 15, 2025
2b49153
remove unused resize event
fdrgsp Aug 18, 2025
1f85b74
feat: update polygon widget value on parameter changes in GridPlanWidget
fdrgsp Aug 18, 2025
d0fe851
Merge branch 'main' into add-overlap-to-explorer
tlambert03 Aug 19, 2025
9e8b5ba
resizeEvent
fdrgsp Aug 19, 2025
37e0cf6
Merge branch 'add-overlap-to-explorer' of https://github.com/fdrgsp/p…
fdrgsp Aug 19, 2025
64b8878
fix: update pymmcore-plus dependency version to 0.15.4
fdrgsp Aug 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions examples/stage_explorer_widget.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions src/pymmcore_widgets/control/_rois/_vispy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/pymmcore_widgets/control/_rois/q_roi_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 12 additions & 7 deletions src/pymmcore_widgets/control/_rois/roi_manager.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -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():
Expand All @@ -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(
Expand All @@ -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:
Expand Down
42 changes: 26 additions & 16 deletions src/pymmcore_widgets/control/_rois/roi_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -119,6 +129,8 @@ def create_grid_plan(
right=right,
fov_width=fov_w,
fov_height=fov_h,
mode=mode,
overlap=overlap,
)
return None

Expand All @@ -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)}
)


Expand Down
99 changes: 90 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 @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {}

Expand Down
5 changes: 5 additions & 0 deletions src/pymmcore_widgets/useq_widgets/_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Loading