Skip to content

Commit a1ff6d4

Browse files
committed
Merge remote-tracking branch 'upstream/main'
2 parents 8699c1d + dbabfa5 commit a1ff6d4

11 files changed

Lines changed: 436 additions & 50 deletions

File tree

examples/stage_explorer_widget.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pymmcore_plus import CMMCorePlus
2+
from qtpy.QtCore import QModelIndex
23
from qtpy.QtWidgets import QApplication, QHBoxLayout, QSplitter, QVBoxLayout, QWidget
34

45
from pymmcore_widgets import (
@@ -34,6 +35,24 @@
3435
cam_roi = CameraRoiWidget()
3536

3637

38+
# As an example...
39+
# When ROIs are edited or destroyed, update the MDA widget's stage positions.
40+
def _on_data_changed(top_left: QModelIndex, bottom_right: QModelIndex) -> None:
41+
mgr = explorer.roi_manager
42+
positions = [
43+
roi.create_useq_position(overlap=mgr.scan_overlap, mode=mgr.scan_mode)
44+
for roi in mgr.all_rois()
45+
]
46+
# optional: skip positions that don't actually have a valid sequence (grid plan)
47+
positions = [pos for pos in positions if pos.sequence is not None]
48+
mda_widget.stage_positions.setValue(positions)
49+
50+
51+
model = explorer.roi_manager.roi_model
52+
model.dataChanged.connect(_on_data_changed)
53+
model.rowsRemoved.connect(_on_data_changed)
54+
55+
3756
# layout
3857

3958
splitter = QSplitter()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ dependencies = [
5151
"pymmcore-plus[cli]>=0.15.4",
5252
'qtpy >=2.0',
5353
'superqt[quantity,cmap,iconify] >=0.7.1',
54-
'useq-schema >=0.8.0',
54+
'useq-schema >=0.8.1',
5555
'vispy >=0.15.1',
5656
"pyopengl >=3.1.9; platform_system == 'Darwin'",
5757
"shapely>=2.1.2; python_version >= '3.14'",

src/pymmcore_widgets/control/_rois/_vispy.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import TYPE_CHECKING
44

55
import numpy as np
6+
import useq
67
from vispy.scene import Compound
78
from vispy.visuals import LineVisual, MarkersVisual, PolygonVisual
89

@@ -40,15 +41,20 @@ def __init__(self, roi: ROI) -> None:
4041
self.set_gl_state(depth_test=False)
4142
self.update_vertices(roi.vertices)
4243

43-
def update_vertices(self, vertices: np.ndarray) -> None:
44+
def update_vertices(
45+
self,
46+
vertices: np.ndarray,
47+
overlap: float | tuple[float, float] = 0.0,
48+
mode: useq.OrderMode = useq.OrderMode.row_wise_snake,
49+
) -> None:
4450
"""Update the vertices of the polygon."""
4551
self._polygon.pos = vertices
4652
self._handles.set_data(pos=vertices)
4753

48-
centers: list[tuple[float, float]] = []
49-
if (grid := self._roi.create_grid_plan()) is not None:
50-
for p in grid:
51-
centers.append((p.x, p.y))
54+
grid = self._roi.create_grid_plan(overlap=overlap, mode=mode)
55+
centers = [
56+
(p.x, p.y) for p in (grid or ()) if p.x is not None and p.y is not None
57+
]
5258

5359
if centers and (fov_size := self._roi.fov_size):
5460
edges = []
@@ -73,12 +79,17 @@ def update_vertices(self, vertices: np.ndarray) -> None:
7379
self._fov_centers.visible = False
7480
self._fov_lines.visible = False
7581

76-
def update_from_roi(self, roi: ROI) -> None:
82+
def update_from_roi(
83+
self,
84+
roi: ROI,
85+
overlap: float | tuple[float, float] = 0.0,
86+
mode: useq.OrderMode = useq.OrderMode.row_wise_snake,
87+
) -> None:
7788
self._polygon.color = roi.fill_color
7889
self._polygon.border_color = roi.border_color
7990
self._polygon._border_width = roi.border_width
8091

81-
self.update_vertices(roi.vertices)
92+
self.update_vertices(roi.vertices, overlap=overlap, mode=mode)
8293
self.set_selected(roi.selected)
8394

8495
def set_selected(self, selected: bool) -> None:

src/pymmcore_widgets/control/_rois/q_roi_model.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ def flags(self, index: QModelIndex) -> Qt.ItemFlag:
7575

7676
# editable stuff ------------------------------
7777

78+
def getRoi(self, index: int) -> ROI:
79+
if 0 <= index < len(self._rois):
80+
return self._rois[index]
81+
raise IndexError("Index out of bounds")
82+
7883
def setData(
7984
self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole
8085
) -> bool:

src/pymmcore_widgets/control/_rois/roi_manager.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Literal, cast
3+
from typing import TYPE_CHECKING, Literal
44

5+
import useq
56
from qtpy.QtCore import (
67
QEvent,
78
QItemSelection,
@@ -62,6 +63,8 @@ def __init__(self, canvas: SceneCanvas) -> None:
6263
self._roi_visuals: dict[ROI, RoiPolygon] = {}
6364

6465
self._fov_size: tuple[float, float] | None = None
66+
self._scan_overlap: float | tuple[float, float] = 0.0
67+
self._scan_mode: useq.OrderMode = useq.OrderMode.row_wise_snake
6568

6669
self.roi_model = QROIModel()
6770
self.selection_model = QItemSelectionModel(self.roi_model)
@@ -122,7 +125,7 @@ def update_fovs(self, fov: tuple[float, float]) -> None:
122125
"""Update the FOVs of all ROIs."""
123126
self._fov_size = fov
124127
for row in range(self.roi_model.rowCount()):
125-
roi = cast("ROI", self.roi_model.index(row).internalPointer())
128+
roi = self.roi_model.getRoi(row)
126129
roi.fov_size = fov
127130
self.roi_model.emitDataChange(roi)
128131

@@ -149,9 +152,35 @@ def canvas_to_world(self, point: QPointF) -> tuple[float, float]:
149152
def selected_rois(self) -> list[ROI]:
150153
"""Return a list of selected ROIs."""
151154
return [
152-
index.internalPointer() for index in self.selection_model.selectedIndexes()
155+
index.data(QROIModel.ROI_ROLE)
156+
for index in self.selection_model.selectedIndexes()
153157
]
154158

159+
def all_rois(self) -> list[ROI]:
160+
"""Return a list of all ROIs."""
161+
return [self.roi_model.getRoi(row) for row in range(self.roi_model.rowCount())]
162+
163+
@property
164+
def scan_overlap(self) -> float | tuple[float, float]:
165+
"""Return the current scan overlap."""
166+
return self._scan_overlap
167+
168+
@property
169+
def scan_mode(self) -> useq.OrderMode:
170+
"""Return the current scan order mode."""
171+
return self._scan_mode
172+
173+
def set_scan_options(
174+
self,
175+
overlap: float | tuple[float, float],
176+
mode: useq.OrderMode,
177+
) -> None:
178+
"""Set scan overlap and mode, then refresh all ROI visuals."""
179+
self._scan_overlap = overlap
180+
self._scan_mode = mode
181+
for roi in self.all_rois():
182+
self.roi_model.emitDataChange(roi)
183+
155184
def delete_selected_rois(self) -> None:
156185
"""Delete the selected ROIs from the model."""
157186
for roi in self.selected_rois():
@@ -168,7 +197,7 @@ def _on_rows_about_to_be_removed(
168197
) -> None:
169198
# Remove the ROIs from the canvas
170199
for row in range(first, last + 1):
171-
roi = self.roi_model.index(row).internalPointer()
200+
roi = self.roi_model.getRoi(row)
172201
self._remove_roi_from_canvas(roi)
173202

174203
def _on_data_changed(
@@ -181,24 +210,24 @@ def _on_data_changed(
181210

182211
# Update the ROI on the canvas
183212
for row in range(top_left.row(), bottom_right.row() + 1):
184-
roi = self.roi_model.index(row).internalPointer()
185-
do_update(roi)
213+
if roi := self.roi_model.index(row).data(QROIModel.ROI_ROLE):
214+
do_update(roi)
186215

187216
def _on_selection_changed(
188217
self, selected: QItemSelection, deselected: QItemSelection
189218
) -> None:
190219
for index in deselected.indexes():
191-
roi = cast("ROI", self.roi_model.index(index.row()).internalPointer())
220+
roi = self.roi_model.getRoi(index.row())
192221
if visual := self._roi_visuals.get(roi):
193222
visual.set_selected(False)
194223
for index in selected.indexes():
195-
roi = cast("ROI", self.roi_model.index(index.row()).internalPointer())
224+
roi = self.roi_model.getRoi(index.row())
196225
if visual := self._roi_visuals.get(roi):
197226
visual.set_selected(True)
198227

199228
def _on_rows_inserted(self, parent: QModelIndex, first: int, last: int) -> None:
200229
for row in range(first, last + 1):
201-
roi = self.roi_model.index(row).internalPointer()
230+
roi = self.roi_model.getRoi(row)
202231
self._add_roi_to_scene(roi)
203232

204233
def _add_roi_to_scene(self, roi: ROI) -> None:
@@ -214,12 +243,16 @@ def _remove_roi_from_canvas(self, roi: ROI) -> None:
214243
def _update_roi_visual(self, roi: ROI) -> None:
215244
# Update the the full ROI visual already on the canvas
216245
if visual := self._roi_visuals.get(roi):
217-
visual.update_from_roi(roi)
246+
visual.update_from_roi(
247+
roi, overlap=self._scan_overlap, mode=self._scan_mode
248+
)
218249

219250
def _update_roi_vertices(self, roi: ROI) -> None:
220251
# Update the only vertices of the ROI visual
221252
if visual := self._roi_visuals.get(roi):
222-
visual.update_vertices(roi.vertices)
253+
visual.update_vertices(
254+
roi.vertices, overlap=self._scan_overlap, mode=self._scan_mode
255+
)
223256

224257

225258
class ROIScene(QWidget):

src/pymmcore_widgets/control/_rois/roi_model.py

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
import numpy as np
88
import useq
9-
import useq._grid
109

1110

1211
@dataclass(eq=False)
@@ -23,7 +22,6 @@ class ROI:
2322
font_size: int = 12
2423

2524
fov_size: tuple[float, float] | None = None # (width, height)
26-
fov_overlap: tuple[float, float] | None = None # frac (width, height) 0..1
2725

2826
def translate(self, dx: float, dy: float) -> None:
2927
"""Translate the ROI in place by (dx, dy)."""
@@ -90,8 +88,10 @@ def create_grid_plan(
9088
self,
9189
fov_w: float | None = None,
9290
fov_h: float | None = None,
93-
) -> useq._grid._GridPlan | None:
94-
"""Return a useq.AbsolutePosition object that covers the ROI."""
91+
overlap: float | tuple[float, float] = 0.0,
92+
mode: useq.OrderMode = useq.OrderMode.row_wise_snake,
93+
) -> useq.GridFromPolygon | useq.GridFromEdges | None:
94+
"""Return a grid plan that covers this ROI, or None if it fits in one FOV."""
9595
if fov_w is None or fov_h is None:
9696
if self.fov_size is None:
9797
raise ValueError("fov_size must be set or fov_w and fov_h must be set")
@@ -103,14 +103,21 @@ def create_grid_plan(
103103
# a single position at the center of the roi is sufficient, otherwise create a
104104
# grid plan that covers the roi
105105
if abs(right - left) > fov_w or abs(bottom - top) > fov_h:
106+
overlap = overlap if isinstance(overlap, tuple) else (overlap, overlap)
106107
if type(self) is not RectangleROI:
107108
if len(self.vertices) < 3:
108109
return None
109-
return useq.GridFromPolygon(
110-
vertices=list(self.vertices),
111-
fov_width=fov_w,
112-
fov_height=fov_h,
113-
)
110+
try:
111+
return useq.GridFromPolygon(
112+
vertices=list(self.vertices),
113+
fov_width=fov_w,
114+
fov_height=fov_h,
115+
mode=mode,
116+
overlap=overlap,
117+
)
118+
except ValueError:
119+
# likely a self-intersecting polygon that cannot be scanned...
120+
return None
114121
else:
115122
return useq.GridFromEdges(
116123
top=top,
@@ -119,30 +126,30 @@ def create_grid_plan(
119126
right=right,
120127
fov_width=fov_w,
121128
fov_height=fov_h,
129+
mode=mode,
130+
overlap=overlap,
122131
)
123132
return None
124133

125134
def create_useq_position(
126135
self,
127136
fov_w: float | None = None,
128137
fov_h: float | None = None,
129-
z_pos: float = 0.0,
138+
z_pos: float | None = None,
139+
overlap: float | tuple[float, float] = 0.0,
140+
mode: useq.OrderMode = useq.OrderMode.row_wise_snake,
130141
) -> useq.AbsolutePosition:
131142
"""Return a useq.AbsolutePosition object that covers the ROI."""
132-
grid_plan = self.create_grid_plan(fov_w=fov_w, fov_h=fov_h)
133-
x, y = self.center()
134-
pos = useq.AbsolutePosition(x=x, y=y, z=z_pos)
143+
grid_plan = self.create_grid_plan(
144+
fov_w=fov_w, fov_h=fov_h, overlap=overlap, mode=mode
145+
)
146+
pos = useq.AbsolutePosition(z=z_pos, name=f"{self.text}_{self._uuid.hex[-4:]}")
147+
135148
if grid_plan is None:
136149
return pos
137150

138151
return pos.model_copy(
139-
update={
140-
"sequence": useq.MDASequence(
141-
grid_plan=grid_plan,
142-
fov_width=fov_w,
143-
fov_height=fov_h,
144-
)
145-
}
152+
update={"sequence": useq.MDASequence(grid_plan=grid_plan)}
146153
)
147154

148155

0 commit comments

Comments
 (0)