From 00b2ca135a4f855dad0c2171299e9c9c8e04e600 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 11 Aug 2025 11:00:47 -0400 Subject: [PATCH 01/38] feat: use useq GridFromPolygon --- .../control/_rois/roi_model.py | 112 +----------------- .../_stage_explorer/_stage_explorer.py | 4 +- 2 files changed, 4 insertions(+), 112 deletions(-) diff --git a/src/pymmcore_widgets/control/_rois/roi_model.py b/src/pymmcore_widgets/control/_rois/roi_model.py index 2167f6a2d..bf1ed29ba 100644 --- a/src/pymmcore_widgets/control/_rois/roi_model.py +++ b/src/pymmcore_widgets/control/_rois/roi_model.py @@ -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) @@ -212,8 +106,8 @@ def create_grid_plan( if type(self) is not RectangleROI: if len(self.vertices) < 3: return None - return GridFromPolygon( - vertices=self.vertices, + return useq.GridFromPolygon( + vertices=list(self.vertices), 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 451c00a8c..b83197391 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -344,9 +344,7 @@ def _on_scan_action(self) -> None: 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)) + seq = useq.MDASequence(grid_plan=plan) if not self._mmc.mda.is_running(): self._our_mda_running = True self._mmc.run_mda(seq) From 9e5b1ec0ae73ddf7103f1b2e56a627f314ee02a1 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 11 Aug 2025 17:02:46 -0400 Subject: [PATCH 02/38] fix tolltip --- .../control/_stage_explorer/_stage_explorer.py | 4 ++-- 1 file changed, 2 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 b83197391..103a8d6a1 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -339,7 +339,7 @@ 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] @@ -581,7 +581,7 @@ 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", ) From 5742e4757c3f97219afbf4bb55f4949533646b08 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 11 Aug 2025 17:02:57 -0400 Subject: [PATCH 03/38] add all_rois method --- src/pymmcore_widgets/control/_rois/roi_manager.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pymmcore_widgets/control/_rois/roi_manager.py b/src/pymmcore_widgets/control/_rois/roi_manager.py index a5ab1b95f..ea4c81f3f 100644 --- a/src/pymmcore_widgets/control/_rois/roi_manager.py +++ b/src/pymmcore_widgets/control/_rois/roi_manager.py @@ -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(): From 9ceb59d2e40f24fb506ce361cac20a49873690d7 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 11 Aug 2025 17:18:57 -0400 Subject: [PATCH 04/38] remove print --- src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py | 1 - 1 file changed, 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 103a8d6a1..e1541254c 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -429,7 +429,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) From 8b78bb5fed3c94d631dd66aa5f4aa7af8eec5774 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 11 Aug 2025 17:25:02 -0400 Subject: [PATCH 05/38] fix Mode --- src/pymmcore_widgets/useq_widgets/_grid.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index cb6aacd91..8ceb64c53 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -29,7 +29,10 @@ from typing import Literal, TypeAlias GridPlan: TypeAlias = ( - useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight + useq.GridFromEdges + | useq.GridRowsColumns + | useq.GridWidthHeight + | useq.GridFromPolygon ) class ValueWidget(Protocol, QWidget): # pyright: ignore @@ -55,6 +58,7 @@ class Mode(Enum): NUMBER = "number" AREA = "area" BOUNDS = "bounds" + POLYGON = "polygon" def __str__(self) -> str: return self.value @@ -70,6 +74,8 @@ def for_grid_plan(cls, plan: GridPlan) -> Mode: return cls.BOUNDS elif isinstance(plan, useq.GridWidthHeight): return cls.AREA + elif isinstance(plan, useq.GridFromPolygon): + return cls.POLYGON raise TypeError(f"Unknown grid plan type: {type(plan)}") # pragma: no cover @@ -77,6 +83,7 @@ def for_grid_plan(cls, plan: GridPlan) -> Mode: Mode.NUMBER: useq.GridRowsColumns, Mode.BOUNDS: useq.GridFromEdges, Mode.AREA: useq.GridWidthHeight, + Mode.POLYGON: useq.GridFromPolygon, } From 3a5c2fa0f9d0555a786b664025ce773c1b2495f7 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 11 Aug 2025 17:32:57 -0400 Subject: [PATCH 06/38] revert --- src/pymmcore_widgets/useq_widgets/_grid.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 8ceb64c53..cb6aacd91 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -29,10 +29,7 @@ from typing import Literal, TypeAlias GridPlan: TypeAlias = ( - useq.GridFromEdges - | useq.GridRowsColumns - | useq.GridWidthHeight - | useq.GridFromPolygon + useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight ) class ValueWidget(Protocol, QWidget): # pyright: ignore @@ -58,7 +55,6 @@ class Mode(Enum): NUMBER = "number" AREA = "area" BOUNDS = "bounds" - POLYGON = "polygon" def __str__(self) -> str: return self.value @@ -74,8 +70,6 @@ def for_grid_plan(cls, plan: GridPlan) -> Mode: return cls.BOUNDS elif isinstance(plan, useq.GridWidthHeight): return cls.AREA - elif isinstance(plan, useq.GridFromPolygon): - return cls.POLYGON raise TypeError(f"Unknown grid plan type: {type(plan)}") # pragma: no cover @@ -83,7 +77,6 @@ def for_grid_plan(cls, plan: GridPlan) -> Mode: Mode.NUMBER: useq.GridRowsColumns, Mode.BOUNDS: useq.GridFromEdges, Mode.AREA: useq.GridWidthHeight, - Mode.POLYGON: useq.GridFromPolygon, } From e8230ae62e02e9ceac5bb631cd9fad2c7124c289 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 12 Aug 2025 10:59:03 -0400 Subject: [PATCH 07/38] add GridFromPolygon visualization to MDA popup --- .../useq_widgets/_positions.py | 43 +++++++++++++++++++ tests/useq_widgets/test_useq_widgets.py | 26 ++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/useq_widgets/_positions.py b/src/pymmcore_widgets/useq_widgets/_positions.py index e22102eab..3507813d5 100644 --- a/src/pymmcore_widgets/useq_widgets/_positions.py +++ b/src/pymmcore_widgets/useq_widgets/_positions.py @@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, cast import useq +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure from qtpy.QtCore import Signal from qtpy.QtWidgets import ( QCheckBox, @@ -70,6 +72,20 @@ def __init__( break par = par.parent() + # if the grid plan is a GridFromPolygon, replace the grid plan tab with an + # empty tab where we show the result of GridFromPolygon.plot() + if value and (gp := value.grid_plan) and isinstance(gp, useq.GridFromPolygon): + self.mda_tabs.removeTab(self.mda_tabs.indexOf(self.mda_tabs.grid_plan)) + self.mda_tabs.addTab( + _PolygonViewer(parent=self, polygon=gp), # Pass the correct parameter + "Polygon Viewer", + checked=True, + ) + # hide the checkbox + self.mda_tabs._cboxes[-1].setVisible(False) + # remove grid plan from value + value = value.replace(grid_plan=None) + # set the value if provided if value: self.mda_tabs.setValue(value) @@ -85,6 +101,29 @@ def __init__( layout.addWidget(self._btns) +class _PolygonViewer(QWidget): + """A Simple widget to display the useq.GridFromPolygon.""" + + def __init__( + self, + parent: QWidget | None, + polygon: useq.GridFromPolygon, + ) -> None: + super().__init__(parent) + + # create a matplotlib figure and canvas + self.plot_widget = FigureCanvas(Figure()) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.plot_widget) + + # plot the polygon using the provided plot method + ax = self.plot_widget.figure.subplots() + ax.clear() + polygon.plot(axes=ax, hide_axes=True) + self.plot_widget.draw() + + class MDAButton(QWidget): valueChanged = Signal() _value: useq.MDASequence | None @@ -113,6 +152,10 @@ def __init__(self) -> None: self.setValue(None) def _on_click(self) -> None: + + from rich import print + print(self._value) + dialog = _MDAPopup(self._value, self) if dialog.exec(): self.setValue(dialog.mda_tabs.value()) diff --git a/tests/useq_widgets/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py index fd704ff9c..7c076b0bb 100644 --- a/tests/useq_widgets/test_useq_widgets.py +++ b/tests/useq_widgets/test_useq_widgets.py @@ -30,7 +30,12 @@ TextColumn, parse_timedelta, ) -from pymmcore_widgets.useq_widgets._positions import MDAButton, QFileDialog, _MDAPopup +from pymmcore_widgets.useq_widgets._positions import ( + MDAButton, + QFileDialog, + _MDAPopup, + _PolygonViewer, +) if TYPE_CHECKING: from pathlib import Path @@ -552,3 +557,22 @@ def _qmsgbox(*args, **kwargs): assert wdg.af_axis.isEnabled() assert wdg.stage_positions.af_per_position.isEnabled() + + +def test_mda_popup_with_polygon(qtbot: QtBot) -> None: + polygon = useq.GridFromPolygon( + vertices=[(-10, 0), (12, -5), (10, 20), (0, 10)], + fov_height=1, + fov_width=1, + ) + seq = useq.MDASequence(channels=["DAPI", "GFP"], grid_plan=polygon) + pop = _MDAPopup(seq) + qtbot.addWidget(pop) + # assert grid plan tab is hidden + assert pop.mda_tabs.grid_plan.isHidden() + # get the _polygon viewer + polygon = pop.mda_tabs.children()[0].widget(3) + # make sure it is a _PolygonViewer + assert isinstance(polygon, _PolygonViewer) + # make sure the plot is visible + assert polygon.plot_widget.figure is not None From b34b8d004498ece50db98da13578f4c762e173b6 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 12 Aug 2025 11:10:40 -0400 Subject: [PATCH 08/38] remove print --- src/pymmcore_widgets/useq_widgets/_positions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_positions.py b/src/pymmcore_widgets/useq_widgets/_positions.py index 3507813d5..0ef5f58a6 100644 --- a/src/pymmcore_widgets/useq_widgets/_positions.py +++ b/src/pymmcore_widgets/useq_widgets/_positions.py @@ -152,10 +152,6 @@ def __init__(self) -> None: self.setValue(None) def _on_click(self) -> None: - - from rich import print - print(self._value) - dialog = _MDAPopup(self._value, self) if dialog.exec(): self.setValue(dialog.mda_tabs.value()) From ed894a0c37e2f06f132b1adfdfdb8cd8dfd9a700 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 12 Aug 2025 15:16:31 -0400 Subject: [PATCH 09/38] feat: add GridFromPolygon to GridPlanWidget + tests --- .../control/_rois/roi_model.py | 2 +- src/pymmcore_widgets/useq_widgets/_grid.py | 75 +++++++++++++++++-- .../useq_widgets/_positions.py | 39 ---------- tests/useq_widgets/test_useq_widgets.py | 34 ++++++--- 4 files changed, 93 insertions(+), 57 deletions(-) diff --git a/src/pymmcore_widgets/control/_rois/roi_model.py b/src/pymmcore_widgets/control/_rois/roi_model.py index bf1ed29ba..89a5abc5a 100644 --- a/src/pymmcore_widgets/control/_rois/roi_model.py +++ b/src/pymmcore_widgets/control/_rois/roi_model.py @@ -106,7 +106,7 @@ def create_grid_plan( if type(self) is not RectangleROI: if len(self.vertices) < 3: return None - return useq.GridFromPolygon( + return useq.GridFromPolygon( # type: ignore # until new useq-schema vertices=list(self.vertices), fov_width=fov_w, fov_height=fov_h, diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index cb6aacd91..ba882bcc3 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Any, Protocol import useq +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QAbstractButton, @@ -29,7 +31,10 @@ from typing import Literal, TypeAlias GridPlan: TypeAlias = ( - useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight + useq.GridFromEdges + | useq.GridRowsColumns + | useq.GridWidthHeight + | useq.GridFromPolygon ) class ValueWidget(Protocol, QWidget): # pyright: ignore @@ -55,6 +60,7 @@ class Mode(Enum): NUMBER = "number" AREA = "area" BOUNDS = "bounds" + POLYGON = "polygon" def __str__(self) -> str: return self.value @@ -70,6 +76,8 @@ def for_grid_plan(cls, plan: GridPlan) -> Mode: return cls.BOUNDS elif isinstance(plan, useq.GridWidthHeight): return cls.AREA + elif isinstance(plan, useq.GridFromPolygon): + return cls.POLYGON raise TypeError(f"Unknown grid plan type: {type(plan)}") # pragma: no cover @@ -77,6 +85,7 @@ def for_grid_plan(cls, plan: GridPlan) -> Mode: Mode.NUMBER: useq.GridRowsColumns, Mode.BOUNDS: useq.GridFromEdges, Mode.AREA: useq.GridWidthHeight, + Mode.POLYGON: useq.GridFromPolygon, } @@ -97,21 +106,28 @@ def __init__(self, parent: QWidget | None = None): self._mode_number_radio = QRadioButton("Fields of View") self._mode_area_radio = QRadioButton("Width && Height") self._mode_bounds_radio = QRadioButton("Absolute Bounds") + self._mode_polygon_radio = QRadioButton("Polygon") + # by default, hide the polygon mode. Will be visible only if required using + # the setMode method. + self._mode_polygon_radio.hide() # group the radio buttons together self._mode_btn_group = QButtonGroup() self._mode_btn_group.addButton(self._mode_number_radio) self._mode_btn_group.addButton(self._mode_area_radio) self._mode_btn_group.addButton(self._mode_bounds_radio) + self._mode_btn_group.addButton(self._mode_polygon_radio) self._mode_btn_group.buttonToggled.connect(self.setMode) self.row_col_wdg = _RowsColsWidget() self.width_height_wdg = _WidthHeightWidget() self.bounds_wdg = _BoundsWidget() + self.polygon_wdg = _PolygonWidget() # ease of lookup self._mode_to_widget: dict[Mode, ValueWidget] = { Mode.NUMBER: self.row_col_wdg, Mode.AREA: self.width_height_wdg, Mode.BOUNDS: self.bounds_wdg, + Mode.POLYGON: self.polygon_wdg, } self._bottom_stuff = _BottomStuff() @@ -128,12 +144,14 @@ def __init__(self, parent: QWidget | None = None): btns_row.addWidget(self._mode_number_radio) btns_row.addWidget(self._mode_area_radio) btns_row.addWidget(self._mode_bounds_radio) + btns_row.addWidget(self._mode_polygon_radio) # stack the different mode widgets on top of each other self._stack = _ResizableStackedWidget(self) self._stack.addWidget(self.row_col_wdg) self._stack.addWidget(self.width_height_wdg) self._stack.addWidget(self.bounds_wdg) + self._stack.addWidget(self.polygon_wdg) # wrap the whole thing in an inner widget so we can put it in this ScrollArea inner_widget = QWidget(self) @@ -170,12 +188,14 @@ def mode(self) -> Mode: """Return the current mode, one of "number", "area", or "bounds".""" return self._mode - def setMode(self, mode: Mode | Literal["number", "area", "bounds"]) -> None: - """Set the current mode, one of "number", "area", or "bounds". + def setMode( + self, mode: Mode | Literal["number", "area", "bounds", "polygon"] + ) -> None: + """Set the current mode, one of "number", "area", "bounds", or "polygon". Parameters ---------- - mode : Mode | Literal["number", "area", "bounds"] + mode : Mode | Literal["number", "area", "bounds", "polygon"] The mode to set. """ if isinstance(mode, QRadioButton): @@ -183,6 +203,7 @@ def setMode(self, mode: Mode | Literal["number", "area", "bounds"]) -> None: self._mode_number_radio: Mode.NUMBER, self._mode_area_radio: Mode.AREA, self._mode_bounds_radio: Mode.BOUNDS, + self._mode_polygon_radio: Mode.POLYGON, } mode = btn_map[mode] elif isinstance(mode, str): @@ -212,6 +233,7 @@ def value(self) -> GridPlan: } if self._mode not in {Mode.NUMBER, Mode.AREA}: kwargs.pop("relative_to", None) + return self._mode.to_useq_cls()(**kwargs) def setValue(self, value: GridPlan) -> None: @@ -219,10 +241,10 @@ def setValue(self, value: GridPlan) -> None: Parameters ---------- - value : useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight + value : useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight | useq.GridFromPolygon The [`useq-schema` GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans) to set. - """ + """ # noqa: E501 mode = Mode.for_grid_plan(value) with signals_blocked(self): @@ -237,6 +259,17 @@ def setValue(self, value: GridPlan) -> None: self._bottom_stuff.setValue(value) self.setMode(mode) + # ensure the correct QRadioButton is checked + if mode == Mode.NUMBER: + self._mode_number_radio.setChecked(True) + elif mode == Mode.AREA: + self._mode_area_radio.setChecked(True) + elif mode == Mode.BOUNDS: + self._mode_bounds_radio.setChecked(True) + elif mode == Mode.POLYGON: + self._mode_polygon_radio.show() + self._mode_polygon_radio.setChecked(True) + self._on_change() def setFovWidth(self, value: float) -> None: @@ -387,6 +420,36 @@ def setValue(self, plan: useq.GridFromEdges) -> None: self.bottom.setValue(plan.bottom) +class _PolygonWidget(QWidget): + """A Simple widget to display the useq.GridFromPolygon.""" + + def __init__(self) -> None: + super().__init__() + + self._polygon: useq.GridFromPolygon | None = None + + # create a matplotlib figure and canvas + self.plot_widget = FigureCanvas(Figure()) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.plot_widget) + + def value(self) -> dict[str, list[tuple[float, float]]]: + vertices = self._polygon.vertices if self._polygon else [] + if not vertices: + return {"vertices": [(0, 0), (0, 0), (0, 0)]} + return {"vertices": vertices} + + def setValue(self, plan: useq.GridFromPolygon) -> None: + """Set the polygon to display.""" + self._polygon = plan + self.plot_widget.figure.clear() + ax = self.plot_widget.figure.subplots() + ax.clear() + self._polygon.plot(axes=ax, hide_axes=True) + self.plot_widget.draw() + + class _ResizableStackedWidget(QStackedWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent=parent) diff --git a/src/pymmcore_widgets/useq_widgets/_positions.py b/src/pymmcore_widgets/useq_widgets/_positions.py index 0ef5f58a6..e22102eab 100644 --- a/src/pymmcore_widgets/useq_widgets/_positions.py +++ b/src/pymmcore_widgets/useq_widgets/_positions.py @@ -7,8 +7,6 @@ from typing import TYPE_CHECKING, cast import useq -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure from qtpy.QtCore import Signal from qtpy.QtWidgets import ( QCheckBox, @@ -72,20 +70,6 @@ def __init__( break par = par.parent() - # if the grid plan is a GridFromPolygon, replace the grid plan tab with an - # empty tab where we show the result of GridFromPolygon.plot() - if value and (gp := value.grid_plan) and isinstance(gp, useq.GridFromPolygon): - self.mda_tabs.removeTab(self.mda_tabs.indexOf(self.mda_tabs.grid_plan)) - self.mda_tabs.addTab( - _PolygonViewer(parent=self, polygon=gp), # Pass the correct parameter - "Polygon Viewer", - checked=True, - ) - # hide the checkbox - self.mda_tabs._cboxes[-1].setVisible(False) - # remove grid plan from value - value = value.replace(grid_plan=None) - # set the value if provided if value: self.mda_tabs.setValue(value) @@ -101,29 +85,6 @@ def __init__( layout.addWidget(self._btns) -class _PolygonViewer(QWidget): - """A Simple widget to display the useq.GridFromPolygon.""" - - def __init__( - self, - parent: QWidget | None, - polygon: useq.GridFromPolygon, - ) -> None: - super().__init__(parent) - - # create a matplotlib figure and canvas - self.plot_widget = FigureCanvas(Figure()) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.plot_widget) - - # plot the polygon using the provided plot method - ax = self.plot_widget.figure.subplots() - ax.clear() - polygon.plot(axes=ax, hide_axes=True) - self.plot_widget.draw() - - class MDAButton(QWidget): valueChanged = Signal() _value: useq.MDASequence | None diff --git a/tests/useq_widgets/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py index 7c076b0bb..f6486bf05 100644 --- a/tests/useq_widgets/test_useq_widgets.py +++ b/tests/useq_widgets/test_useq_widgets.py @@ -34,7 +34,6 @@ MDAButton, QFileDialog, _MDAPopup, - _PolygonViewer, ) if TYPE_CHECKING: @@ -423,30 +422,44 @@ def test_grid_plan_widget(qtbot: QtBot) -> None: assert isinstance(wdg.value(), useq.GridRowsColumns) wdg.setMode("area") assert isinstance(wdg.value(), useq.GridWidthHeight) + wdg.setMode("polygon") + assert isinstance(wdg.value(), useq.GridFromPolygon) plan = useq.GridRowsColumns(rows=3, columns=3, mode="spiral", overlap=10) with qtbot.waitSignal(wdg.valueChanged): wdg.setValue(plan) assert wdg.mode() == _grid.Mode.NUMBER assert wdg.value() == plan + assert wdg._mode_btn_group.checkedButton().text() == "Fields of View" plan = useq.GridFromEdges(left=1, right=2, top=3, bottom=4, overlap=10) with qtbot.waitSignal(wdg.valueChanged): wdg.setValue(plan) assert wdg.mode() == _grid.Mode.BOUNDS assert wdg.value() == plan + assert wdg._mode_btn_group.checkedButton().text() == "Absolute Bounds" plan = useq.GridWidthHeight(width=1000, height=2000, fov_height=3, fov_width=4) with qtbot.waitSignal(wdg.valueChanged): wdg.setValue(plan) assert wdg.mode() == _grid.Mode.AREA assert wdg.value() == plan + assert wdg._mode_btn_group.checkedButton().text() == "Width && Height" - assert wdg._fov_height == 3 + plan = useq.GridFromPolygon( + vertices=[(-4, 0), (5, -5), (5, 9), (0, 10)], fov_height=1, fov_width=1 + ) + with qtbot.waitSignal(wdg.valueChanged): + wdg.setValue(plan) + assert wdg.mode() == _grid.Mode.POLYGON + assert wdg.value().model_dump() == plan.model_dump() + assert wdg._mode_btn_group.checkedButton().text() == "Polygon" + + assert wdg._fov_height == 1 wdg.setFovHeight(5) assert wdg.fovHeight() == 5 - assert wdg._fov_width == 4 + assert wdg._fov_width == 1 wdg.setFovWidth(6) assert wdg.fovWidth() == 6 @@ -568,11 +581,10 @@ def test_mda_popup_with_polygon(qtbot: QtBot) -> None: seq = useq.MDASequence(channels=["DAPI", "GFP"], grid_plan=polygon) pop = _MDAPopup(seq) qtbot.addWidget(pop) - # assert grid plan tab is hidden - assert pop.mda_tabs.grid_plan.isHidden() - # get the _polygon viewer - polygon = pop.mda_tabs.children()[0].widget(3) - # make sure it is a _PolygonViewer - assert isinstance(polygon, _PolygonViewer) - # make sure the plot is visible - assert polygon.plot_widget.figure is not None + + assert pop.mda_tabs.isChecked(pop.mda_tabs.channels) + + gp = pop.mda_tabs.grid_plan + assert pop.mda_tabs.isChecked(gp) + assert gp._mode_btn_group.checkedButton().text() == "Polygon" + assert gp.polygon_wdg.plot_widget.figure is not None From 2a70f1fb82422f73c4d168e1572b5522ed3adc10 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 12 Aug 2025 18:36:28 -0400 Subject: [PATCH 10/38] wip use QGraphicsScene --- src/pymmcore_widgets/useq_widgets/_grid.py | 250 ++++++++++++++++++--- x.py | 23 ++ 2 files changed, 248 insertions(+), 25 deletions(-) create mode 100644 x.py diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index ba882bcc3..2d427a31c 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -4,14 +4,18 @@ from typing import TYPE_CHECKING, Any, Protocol import useq -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure -from qtpy.QtCore import Qt, Signal +from qtpy.QtCore import QPointF, QRectF, Qt, Signal +from qtpy.QtGui import QBrush, QPainter, QPainterPath, QPen, QPolygonF, QTransform from qtpy.QtWidgets import ( QAbstractButton, QButtonGroup, QDoubleSpinBox, QFormLayout, + QGraphicsEllipseItem, + QGraphicsPathItem, + QGraphicsRectItem, + QGraphicsScene, + QGraphicsView, QHBoxLayout, QLabel, QRadioButton, @@ -22,6 +26,7 @@ QVBoxLayout, QWidget, ) +from shapely.geometry import Point from superqt import QEnumComboBox from superqt.utils import signals_blocked @@ -260,15 +265,16 @@ def setValue(self, value: GridPlan) -> None: self.setMode(mode) # ensure the correct QRadioButton is checked - if mode == Mode.NUMBER: - self._mode_number_radio.setChecked(True) - elif mode == Mode.AREA: - self._mode_area_radio.setChecked(True) - elif mode == Mode.BOUNDS: - self._mode_bounds_radio.setChecked(True) - elif mode == Mode.POLYGON: - self._mode_polygon_radio.show() - self._mode_polygon_radio.setChecked(True) + with signals_blocked(self._mode_btn_group): + if mode == Mode.NUMBER: + self._mode_number_radio.setChecked(True) + elif mode == Mode.AREA: + self._mode_area_radio.setChecked(True) + elif mode == Mode.BOUNDS: + self._mode_bounds_radio.setChecked(True) + elif mode == Mode.POLYGON: + self._mode_polygon_radio.show() + self._mode_polygon_radio.setChecked(True) self._on_change() @@ -421,18 +427,39 @@ def setValue(self, plan: useq.GridFromEdges) -> None: class _PolygonWidget(QWidget): - """A Simple widget to display the useq.GridFromPolygon.""" - - def __init__(self) -> None: + """Qt widget that draws a useq.GridFromPolygon similar to its matplotlib `plot()`.""" + + # tune these to match your matplotlib aesthetics + _VERTEX_RADIUS = 0.12 + _CENTER_RADIUS = 0.08 + _POLY_PEN = QPen(Qt.GlobalColor.black, 0.1) + _POLY_BRUSH = QBrush(Qt.BrushStyle.NoBrush) + _BB_PEN = QPen(Qt.GlobalColor.black, 0.1, Qt.PenStyle.DashLine) + _VERTEX_PEN = QPen(Qt.GlobalColor.red, 0) + _VERTEX_BRUSH = QBrush(Qt.GlobalColor.red) + _CENTER_PEN = QPen(Qt.GlobalColor.darkBlue, 0) + _CENTER_BRUSH = QBrush(Qt.GlobalColor.darkBlue) + _FOV_PEN = QPen(Qt.GlobalColor.blue, 0.1) + _FOV_BRUSH = QBrush(Qt.BrushStyle.NoBrush) # or a translucent brush if you like + + def __init__(self, show_fovs: bool = True, show_centers: bool = True) -> None: super().__init__() - self._polygon: useq.GridFromPolygon | None = None + self._show_fovs = show_fovs + self._show_centers = show_centers + + self.scene = QGraphicsScene() + self.view = QGraphicsView(self.scene) + self.view.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + # y-up like matplotlib + self.view.setTransform(QTransform.fromScale(1, -1)) - # create a matplotlib figure and canvas - self.plot_widget = FigureCanvas(Figure()) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.plot_widget) + layout.addWidget(self.view) + + # --- public API --------------------------------------------------------- def value(self) -> dict[str, list[tuple[float, float]]]: vertices = self._polygon.vertices if self._polygon else [] @@ -441,13 +468,186 @@ def value(self) -> dict[str, list[tuple[float, float]]]: return {"vertices": vertices} def setValue(self, plan: useq.GridFromPolygon) -> None: - """Set the polygon to display.""" + """Set and render the polygon/grid plan.""" self._polygon = plan - self.plot_widget.figure.clear() - ax = self.plot_widget.figure.subplots() - ax.clear() - self._polygon.plot(axes=ax, hide_axes=True) - self.plot_widget.draw() + self._redraw() + + # --- internals ---------------------------------------------------------- + + def _redraw(self) -> None: + self.scene.clear() + if not self._polygon: + return + + poly = self._polygon.poly # shapely Polygon (already in your snippet) + verts: list[tuple[float, float]] = list(self._polygon.vertices or []) + + # 1) Draw polygon outline (support holes) + poly_item = self._make_polygon_item(poly) + poly_item.setPen(self._POLY_PEN) + poly_item.setBrush(self._POLY_BRUSH) + self.scene.addItem(poly_item) + + # 2) Draw vertices as red dots + for x, y in verts: + self._add_dot( + x, y, self._VERTEX_RADIUS, self._VERTEX_PEN, self._VERTEX_BRUSH + ) + + # 3) Draw dashed bounding box + min_x, min_y, max_x, max_y = poly.bounds + bb = QGraphicsRectItem(min_x, min_y, max_x - min_x, max_y - min_y) + bb.setPen(self._BB_PEN) + self.scene.addItem(bb) + + # 4) Draw grid centers and FOV rectangles (computed like the mpl plot) + centers = self._compute_centers(self._polygon) + if self._show_fovs: + fw, fh = self._get_fov(self._polygon) + if fw > 0 and fh > 0: + hw, hh = fw / 2.0, fh / 2.0 + for cx, cy in centers: + rect = QGraphicsRectItem(cx - hw, cy - hh, fw, fh) + rect.setPen(self._FOV_PEN) + rect.setBrush(self._FOV_BRUSH) + self.scene.addItem(rect) + + if self._show_centers: + for cx, cy in centers: + self._add_dot( + cx, cy, self._CENTER_RADIUS, self._CENTER_PEN, self._CENTER_BRUSH + ) + + # 5) Fit everything nicely + self._fit_view_to_items(pad=0.1) + + def _make_polygon_item(self, shapely_poly) -> QGraphicsPathItem: + """Create a QGraphicsPathItem for a shapely Polygon with holes.""" + path = QPainterPath() + # exterior + ext = [QPointF(x, y) for (x, y) in shapely_poly.exterior.coords] + if ext: + path.addPolygon(QPolygonF(ext)) + # holes + for interior in shapely_poly.interiors: + pts = [QPointF(x, y) for (x, y) in interior.coords] + if pts: + path.addPolygon(QPolygonF(pts)) + item = QGraphicsPathItem(path) + return item + + def _add_dot( + self, x: float, y: float, r: float, pen: QPen, brush: QBrush + ) -> QGraphicsEllipseItem: + d = 2 * r + ell = self.scene.addEllipse(x - r, y - r, d, d, pen, brush) + ell.setZValue(10) # keep dots above outlines + return ell + + def _fit_view_to_items(self, pad: float = 0.05) -> None: + rect = self.scene.itemsBoundingRect() + if rect.isNull(): + return + # small padding (in scene units) + padded = QRectF( + rect.x() - rect.width() * pad, + rect.y() - rect.height() * pad, + rect.width() * (1 + 2 * pad), + rect.height() * (1 + 2 * pad), + ) + self.scene.setSceneRect(padded) + # keep transform (y-up) while fitting + self.view.transform() + self.view.resetTransform() + self.view.setTransform(QTransform()) # reset + self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + self.view.setTransform(QTransform.fromScale(1, -1) * self.view.transform()) + + def resizeEvent(self, ev) -> None: # keep fitted on resize + super().resizeEvent(ev) + self._fit_view_to_items(pad=0.1) + + # --- grid center computation (parallels what mpl plot visualizes) ------- + + def _get_fov(self, plan) -> Tuple[float, float]: + """Return FOV width/height with safe fallbacks.""" + fw = float(getattr(plan, "fov_width", 0.0) or 0.0) + fh = float(getattr(plan, "fov_height", fw) or fw) # fallback: square FOV + return fw, fh + + def _get_step(self, plan, fw: float, fh: float) -> Tuple[float, float]: + """Compute step from overlap, mimicking useq logic: step = fov * (1 - overlap).""" + ov = getattr(plan, "overlap", (0.0, 0.0)) or (0.0, 0.0) + try: + ox, oy = float(ov[0]), float(ov[1]) + except Exception: + ox, oy = 0.0, 0.0 + sx = fw * (1.0 - ox) + sy = fh * (1.0 - oy) + # keep sensible defaults if something is missing + if sx <= 0: + sx = fw if fw > 0 else 1.0 + if sy <= 0: + sy = fh if fh > 0 else sx + return sx, sy + + def _compute_centers(self, plan) -> list[tuple[float, float]]: + """ + Try to use any available attribute from the plan first (if it exposes centers). + Otherwise, generate a rectilinear lattice over the polygon bounds and keep + points whose centers fall inside the polygon. + """ + # 1) Use exposed centers if the plan provides them (future-proof) + for attr_name in ("centers", "sites", "tile_centers", "points", "grid_points"): + pts = getattr(plan, attr_name, None) + if callable(pts): + try: + got = list(pts()) + if got: + return [(float(x), float(y)) for x, y in got] + except Exception: + pass + elif isinstance(pts, (list, tuple)) and pts: + try: + return [(float(x), float(y)) for x, y in pts] # type: ignore[arg-type] + except Exception: + pass + + # 2) Fallback: lattice inside polygon + poly = plan.poly + fw, fh = self._get_fov(plan) + if fw <= 0 or fh <= 0: # no FOV info -> just return vertices as a hint + return [(float(x), float(y)) for x, y in getattr(plan, "vertices", [])] + + sx, sy = self._get_step(plan, fw, fh) + min_x, min_y, max_x, max_y = poly.bounds + + # center grid roughly on polygon centroid to match mpl plot "feel" + cx, cy = poly.centroid.x, poly.centroid.y + # anchor the first node on the nearest lattice intersection to the centroid + import math + + start_x = cx - math.floor((cx - (min_x - fw)) / sx) * sx + start_y = cy - math.floor((cy - (min_y - fh)) / sy) * sy + + pts: list[tuple[float, float]] = [] + y = start_y + # extend slightly beyond bounds to ensure edge coverage + while y <= max_y + fh: + x = start_x + while x <= max_x + fw: + if Point is None: + # coarse check without shapely Point.contains + if poly.buffer(1e-12).contains( + poly.__class__([(x, y)]) + ): # very defensive + pts.append((x, y)) + else: + if poly.contains(Point(x, y)): + pts.append((x, y)) + x += sx + y += sy + return pts class _ResizableStackedWidget(QStackedWidget): diff --git a/x.py b/x.py new file mode 100644 index 000000000..9dd44c2be --- /dev/null +++ b/x.py @@ -0,0 +1,23 @@ +import useq +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets import MDAWidget + +app = QApplication([]) + +poly = useq.GridFromPolygon( + # vertices=[(0, 0), (10, -5), (12, 15), (0, 8)], + vertices=[(-4, 0), (5, -5), (5, 9), (0, 10)], + fov_height=1, + fov_width=1, + overlap=(0.1, 0.1) +) +pos = useq.AbsolutePosition( + x=1, y=2, z=3, sequence=useq.MDASequence(grid_plan=poly)) +seq = useq.MDASequence(grid_plan=poly, stage_positions=[pos]) + +m = MDAWidget() +m.setValue(seq) +m.show() + +app.exec() From 871fd08be0c91e175a1e51aa2c4d6c47472802ec Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 13 Aug 2025 00:36:10 -0400 Subject: [PATCH 11/38] update _PolygonWidget --- src/pymmcore_widgets/useq_widgets/_grid.py | 228 ++++++++------------- 1 file changed, 81 insertions(+), 147 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 2d427a31c..741aa6120 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -1,11 +1,19 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, cast import useq from qtpy.QtCore import QPointF, QRectF, Qt, Signal -from qtpy.QtGui import QBrush, QPainter, QPainterPath, QPen, QPolygonF, QTransform +from qtpy.QtGui import ( + QBrush, + QPainter, + QPainterPath, + QPen, + QPolygonF, + QResizeEvent, + QTransform, +) from qtpy.QtWidgets import ( QAbstractButton, QButtonGroup, @@ -26,7 +34,6 @@ QVBoxLayout, QWidget, ) -from shapely.geometry import Point from superqt import QEnumComboBox from superqt.utils import signals_blocked @@ -35,6 +42,8 @@ if TYPE_CHECKING: from typing import Literal, TypeAlias + from shapely import Polygon + GridPlan: TypeAlias = ( useq.GridFromEdges | useq.GridRowsColumns @@ -301,6 +310,8 @@ def fovHeight(self) -> float | None: def _on_change(self) -> None: if (val := self.value()) is None: return # pragma: no cover + if isinstance(val, useq.GridFromPolygon): + self.polygon_wdg._redraw(val) self.valueChanged.emit(val) @@ -427,101 +438,98 @@ def setValue(self, plan: useq.GridFromEdges) -> None: class _PolygonWidget(QWidget): - """Qt widget that draws a useq.GridFromPolygon similar to its matplotlib `plot()`.""" - - # tune these to match your matplotlib aesthetics - _VERTEX_RADIUS = 0.12 - _CENTER_RADIUS = 0.08 - _POLY_PEN = QPen(Qt.GlobalColor.black, 0.1) - _POLY_BRUSH = QBrush(Qt.BrushStyle.NoBrush) - _BB_PEN = QPen(Qt.GlobalColor.black, 0.1, Qt.PenStyle.DashLine) - _VERTEX_PEN = QPen(Qt.GlobalColor.red, 0) - _VERTEX_BRUSH = QBrush(Qt.GlobalColor.red) - _CENTER_PEN = QPen(Qt.GlobalColor.darkBlue, 0) - _CENTER_BRUSH = QBrush(Qt.GlobalColor.darkBlue) - _FOV_PEN = QPen(Qt.GlobalColor.blue, 0.1) - _FOV_BRUSH = QBrush(Qt.BrushStyle.NoBrush) # or a translucent brush if you like + """QWidget that draws a useq.GridFromPolygon similar to its matplotlib `plot()`.""" + + VERTEX_RADIUS = 0.12 + CENTER_RADIUS = 0.12 + POLY_PEN = QPen(Qt.GlobalColor.darkMagenta, 0.08) + POLY_BRUSH = QBrush(Qt.BrushStyle.NoBrush) + BB_PEN = QPen(Qt.GlobalColor.darkGray, 0.08, Qt.PenStyle.DashLine) + VERTEX_PEN = QPen(Qt.GlobalColor.magenta, 0) + VERTEX_BRUSH = QBrush(Qt.GlobalColor.magenta) + CENTER_PEN = QPen(Qt.GlobalColor.darkGreen, 0) + CENTER_BRUSH = QBrush(Qt.GlobalColor.darkGreen) + FOV_PEN = QPen(Qt.GlobalColor.darkGray, 0.08) + FOV_BRUSH = QBrush(Qt.BrushStyle.NoBrush) def __init__(self, show_fovs: bool = True, show_centers: bool = True) -> None: super().__init__() self._polygon: useq.GridFromPolygon | None = None - self._show_fovs = show_fovs - self._show_centers = show_centers self.scene = QGraphicsScene() self.view = QGraphicsView(self.scene) self.view.setRenderHint(QPainter.RenderHint.Antialiasing, True) - # y-up like matplotlib - self.view.setTransform(QTransform.fromScale(1, -1)) + self.view.setTransform(QTransform.fromScale(1, -1)) # y-up layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.view) - # --- public API --------------------------------------------------------- + # ----------------------------PUBLIC METHODS---------------------------- - def value(self) -> dict[str, list[tuple[float, float]]]: + def value(self) -> dict[str, Any]: vertices = self._polygon.vertices if self._polygon else [] + convex_hull = self._polygon.convex_hull if self._polygon else False + offset = self._polygon.offset if self._polygon else 0 if not vertices: - return {"vertices": [(0, 0), (0, 0), (0, 0)]} - return {"vertices": vertices} + return { + "vertices": [(0, 0), (0, 0), (0, 0)], + "convex_hull": False, + "offset": 0, + } + return {"vertices": vertices, "convex_hull": convex_hull, "offset": offset} def setValue(self, plan: useq.GridFromPolygon) -> None: """Set and render the polygon/grid plan.""" - self._polygon = plan - self._redraw() + self._redraw(plan) - # --- internals ---------------------------------------------------------- + # ----------------------------PRIVATE METHODS---------------------------- - def _redraw(self) -> None: + def _redraw(self, plan: useq.GridFromPolygon) -> None: self.scene.clear() - if not self._polygon: + if not self._polygon and plan is None: return - poly = self._polygon.poly # shapely Polygon (already in your snippet) + self._polygon = plan + poly = self._polygon.poly verts: list[tuple[float, float]] = list(self._polygon.vertices or []) - # 1) Draw polygon outline (support holes) + # draw polygon outline poly_item = self._make_polygon_item(poly) - poly_item.setPen(self._POLY_PEN) - poly_item.setBrush(self._POLY_BRUSH) + poly_item.setPen(self.POLY_PEN) + poly_item.setBrush(self.POLY_BRUSH) self.scene.addItem(poly_item) - # 2) Draw vertices as red dots + # draw vertices for x, y in verts: - self._add_dot( - x, y, self._VERTEX_RADIUS, self._VERTEX_PEN, self._VERTEX_BRUSH - ) + self._add_dot(x, y, self.VERTEX_RADIUS, self.VERTEX_PEN, self.VERTEX_BRUSH) - # 3) Draw dashed bounding box + # draw dashed bounding box min_x, min_y, max_x, max_y = poly.bounds bb = QGraphicsRectItem(min_x, min_y, max_x - min_x, max_y - min_y) - bb.setPen(self._BB_PEN) + bb.setPen(self.BB_PEN) self.scene.addItem(bb) - # 4) Draw grid centers and FOV rectangles (computed like the mpl plot) + # draw grid centers and FOV rectangles centers = self._compute_centers(self._polygon) - if self._show_fovs: - fw, fh = self._get_fov(self._polygon) - if fw > 0 and fh > 0: - hw, hh = fw / 2.0, fh / 2.0 - for cx, cy in centers: - rect = QGraphicsRectItem(cx - hw, cy - hh, fw, fh) - rect.setPen(self._FOV_PEN) - rect.setBrush(self._FOV_BRUSH) - self.scene.addItem(rect) - - if self._show_centers: + fw, fh = plan.fov_width or 0, plan.fov_height or 0 + if fw > 0 and fh > 0: + hw, hh = fw / 2.0, fh / 2.0 for cx, cy in centers: - self._add_dot( - cx, cy, self._CENTER_RADIUS, self._CENTER_PEN, self._CENTER_BRUSH - ) + rect = QGraphicsRectItem(cx - hw, cy - hh, fw, fh) + rect.setPen(self.FOV_PEN) + rect.setBrush(self.FOV_BRUSH) + self.scene.addItem(rect) - # 5) Fit everything nicely - self._fit_view_to_items(pad=0.1) + for cx, cy in centers: + self._add_dot( + cx, cy, self.CENTER_RADIUS, self.CENTER_PEN, self.CENTER_BRUSH + ) - def _make_polygon_item(self, shapely_poly) -> QGraphicsPathItem: + self._fit_view_to_items() + + def _make_polygon_item(self, shapely_poly: Polygon) -> QGraphicsPathItem: """Create a QGraphicsPathItem for a shapely Polygon with holes.""" path = QPainterPath() # exterior @@ -541,14 +549,14 @@ def _add_dot( ) -> QGraphicsEllipseItem: d = 2 * r ell = self.scene.addEllipse(x - r, y - r, d, d, pen, brush) - ell.setZValue(10) # keep dots above outlines + ell = cast("QGraphicsEllipseItem", ell) return ell - def _fit_view_to_items(self, pad: float = 0.05) -> None: + def _fit_view_to_items(self, pad: float = 0.01) -> None: rect = self.scene.itemsBoundingRect() if rect.isNull(): return - # small padding (in scene units) + # add padding padded = QRectF( rect.x() - rect.width() * pad, rect.y() - rect.height() * pad, @@ -557,97 +565,23 @@ def _fit_view_to_items(self, pad: float = 0.05) -> None: ) self.scene.setSceneRect(padded) # keep transform (y-up) while fitting - self.view.transform() self.view.resetTransform() - self.view.setTransform(QTransform()) # reset self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) self.view.setTransform(QTransform.fromScale(1, -1) * self.view.transform()) - def resizeEvent(self, ev) -> None: # keep fitted on resize + def _compute_centers(self, plan: useq.GridFromPolygon) -> list[tuple[float, float]]: + """Compute grid center points within the polygon.""" + centers: list[tuple[float, float]] = [] + for item in plan: + x, y = item.x, item.y + if x is None or y is None: + continue + centers.append((float(x), float(y))) + return centers + + def resizeEvent(self, ev: QResizeEvent) -> None: super().resizeEvent(ev) - self._fit_view_to_items(pad=0.1) - - # --- grid center computation (parallels what mpl plot visualizes) ------- - - def _get_fov(self, plan) -> Tuple[float, float]: - """Return FOV width/height with safe fallbacks.""" - fw = float(getattr(plan, "fov_width", 0.0) or 0.0) - fh = float(getattr(plan, "fov_height", fw) or fw) # fallback: square FOV - return fw, fh - - def _get_step(self, plan, fw: float, fh: float) -> Tuple[float, float]: - """Compute step from overlap, mimicking useq logic: step = fov * (1 - overlap).""" - ov = getattr(plan, "overlap", (0.0, 0.0)) or (0.0, 0.0) - try: - ox, oy = float(ov[0]), float(ov[1]) - except Exception: - ox, oy = 0.0, 0.0 - sx = fw * (1.0 - ox) - sy = fh * (1.0 - oy) - # keep sensible defaults if something is missing - if sx <= 0: - sx = fw if fw > 0 else 1.0 - if sy <= 0: - sy = fh if fh > 0 else sx - return sx, sy - - def _compute_centers(self, plan) -> list[tuple[float, float]]: - """ - Try to use any available attribute from the plan first (if it exposes centers). - Otherwise, generate a rectilinear lattice over the polygon bounds and keep - points whose centers fall inside the polygon. - """ - # 1) Use exposed centers if the plan provides them (future-proof) - for attr_name in ("centers", "sites", "tile_centers", "points", "grid_points"): - pts = getattr(plan, attr_name, None) - if callable(pts): - try: - got = list(pts()) - if got: - return [(float(x), float(y)) for x, y in got] - except Exception: - pass - elif isinstance(pts, (list, tuple)) and pts: - try: - return [(float(x), float(y)) for x, y in pts] # type: ignore[arg-type] - except Exception: - pass - - # 2) Fallback: lattice inside polygon - poly = plan.poly - fw, fh = self._get_fov(plan) - if fw <= 0 or fh <= 0: # no FOV info -> just return vertices as a hint - return [(float(x), float(y)) for x, y in getattr(plan, "vertices", [])] - - sx, sy = self._get_step(plan, fw, fh) - min_x, min_y, max_x, max_y = poly.bounds - - # center grid roughly on polygon centroid to match mpl plot "feel" - cx, cy = poly.centroid.x, poly.centroid.y - # anchor the first node on the nearest lattice intersection to the centroid - import math - - start_x = cx - math.floor((cx - (min_x - fw)) / sx) * sx - start_y = cy - math.floor((cy - (min_y - fh)) / sy) * sy - - pts: list[tuple[float, float]] = [] - y = start_y - # extend slightly beyond bounds to ensure edge coverage - while y <= max_y + fh: - x = start_x - while x <= max_x + fw: - if Point is None: - # coarse check without shapely Point.contains - if poly.buffer(1e-12).contains( - poly.__class__([(x, y)]) - ): # very defensive - pts.append((x, y)) - else: - if poly.contains(Point(x, y)): - pts.append((x, y)) - x += sx - y += sy - return pts + self._fit_view_to_items() class _ResizableStackedWidget(QStackedWidget): From b95f12d82d639b0ba797c6fc04b3250546ce392c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 13 Aug 2025 00:53:14 -0400 Subject: [PATCH 12/38] update pen size --- src/pymmcore_widgets/useq_widgets/_grid.py | 18 ++++++++++++------ x.py | 17 +++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 741aa6120..55b5905bf 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -440,16 +440,16 @@ def setValue(self, plan: useq.GridFromEdges) -> None: class _PolygonWidget(QWidget): """QWidget that draws a useq.GridFromPolygon similar to its matplotlib `plot()`.""" - VERTEX_RADIUS = 0.12 - CENTER_RADIUS = 0.12 - POLY_PEN = QPen(Qt.GlobalColor.darkMagenta, 0.08) + VERTEX_RADIUS = 0 + CENTER_RADIUS = 0 + POLY_PEN = QPen(Qt.GlobalColor.darkMagenta) POLY_BRUSH = QBrush(Qt.BrushStyle.NoBrush) - BB_PEN = QPen(Qt.GlobalColor.darkGray, 0.08, Qt.PenStyle.DashLine) + BB_PEN = QPen(Qt.GlobalColor.darkGray, 0, Qt.PenStyle.DashLine) VERTEX_PEN = QPen(Qt.GlobalColor.magenta, 0) VERTEX_BRUSH = QBrush(Qt.GlobalColor.magenta) CENTER_PEN = QPen(Qt.GlobalColor.darkGreen, 0) CENTER_BRUSH = QBrush(Qt.GlobalColor.darkGreen) - FOV_PEN = QPen(Qt.GlobalColor.darkGray, 0.08) + FOV_PEN = QPen(Qt.GlobalColor.darkGray) FOV_BRUSH = QBrush(Qt.BrushStyle.NoBrush) def __init__(self, show_fovs: bool = True, show_centers: bool = True) -> None: @@ -491,12 +491,17 @@ def _redraw(self, plan: useq.GridFromPolygon) -> None: if not self._polygon and plan is None: return + fw, fh = plan.fov_width or 0, plan.fov_height or 0 + pen_size = int(fw * 0.04) if fw > 0 else 1 + self.VERTEX_RADIUS = self.CENTER_RADIUS = pen_size + self._polygon = plan poly = self._polygon.poly verts: list[tuple[float, float]] = list(self._polygon.vertices or []) # draw polygon outline poly_item = self._make_polygon_item(poly) + self.POLY_PEN.setWidth(pen_size) poly_item.setPen(self.POLY_PEN) poly_item.setBrush(self.POLY_BRUSH) self.scene.addItem(poly_item) @@ -508,16 +513,17 @@ def _redraw(self, plan: useq.GridFromPolygon) -> None: # draw dashed bounding box min_x, min_y, max_x, max_y = poly.bounds bb = QGraphicsRectItem(min_x, min_y, max_x - min_x, max_y - min_y) + self.BB_PEN.setWidth(pen_size) bb.setPen(self.BB_PEN) self.scene.addItem(bb) # draw grid centers and FOV rectangles centers = self._compute_centers(self._polygon) - fw, fh = plan.fov_width or 0, plan.fov_height or 0 if fw > 0 and fh > 0: hw, hh = fw / 2.0, fh / 2.0 for cx, cy in centers: rect = QGraphicsRectItem(cx - hw, cy - hh, fw, fh) + self.FOV_PEN.setWidth(pen_size) rect.setPen(self.FOV_PEN) rect.setBrush(self.FOV_BRUSH) self.scene.addItem(rect) diff --git a/x.py b/x.py index 9dd44c2be..0c2ca9e93 100644 --- a/x.py +++ b/x.py @@ -6,15 +6,16 @@ app = QApplication([]) poly = useq.GridFromPolygon( - # vertices=[(0, 0), (10, -5), (12, 15), (0, 8)], - vertices=[(-4, 0), (5, -5), (5, 9), (0, 10)], - fov_height=1, - fov_width=1, - overlap=(0.1, 0.1) + vertices=[(-400, 0), (1500, -500), (500, 1900), (0, 100)], + # vertices=[(0, 0), (300, 0), (300, 100), (100, 100), (100, 300), (0, 300)], + fov_height=100, + fov_width=100, + overlap=(10, 10), + # convex_hull=True ) -pos = useq.AbsolutePosition( - x=1, y=2, z=3, sequence=useq.MDASequence(grid_plan=poly)) -seq = useq.MDASequence(grid_plan=poly, stage_positions=[pos]) +pos = useq.AbsolutePosition(x=1, y=2, z=3, sequence=useq.MDASequence(grid_plan=poly)) +# seq = useq.MDASequence(grid_plan=poly, stage_positions=[pos]) +seq = useq.MDASequence(grid_plan=poly) m = MDAWidget() m.setValue(seq) From c18b7855d0ba9453df2ef95614e4439f97b44897 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 13 Aug 2025 10:08:54 -0400 Subject: [PATCH 13/38] fix test --- src/pymmcore_widgets/useq_widgets/_grid.py | 12 ++++++------ tests/useq_widgets/test_useq_widgets.py | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 55b5905bf..b06c4c275 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -585,8 +585,8 @@ def _compute_centers(self, plan: useq.GridFromPolygon) -> list[tuple[float, floa centers.append((float(x), float(y))) return centers - def resizeEvent(self, ev: QResizeEvent) -> None: - super().resizeEvent(ev) + def resizeEvent(self, a0: QResizeEvent | None) -> None: + super().resizeEvent(a0) self._fit_view_to_items() @@ -595,10 +595,10 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent=parent) self.currentChanged.connect(self.onCurrentChanged) - def addWidget(self, wdg: QWidget | None) -> int: - if wdg is not None: - wdg.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) - return super().addWidget(wdg) # type: ignore [no-any-return] + def addWidget(self, w: QWidget | None) -> int: + if w is not None: + w.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + return super().addWidget(w) # type: ignore [no-any-return] def onCurrentChanged(self, idx: int) -> None: for i in range(self.count()): diff --git a/tests/useq_widgets/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py index f6486bf05..874c3b08f 100644 --- a/tests/useq_widgets/test_useq_widgets.py +++ b/tests/useq_widgets/test_useq_widgets.py @@ -587,4 +587,5 @@ def test_mda_popup_with_polygon(qtbot: QtBot) -> None: gp = pop.mda_tabs.grid_plan assert pop.mda_tabs.isChecked(gp) assert gp._mode_btn_group.checkedButton().text() == "Polygon" - assert gp.polygon_wdg.plot_widget.figure is not None + assert gp.polygon_wdg.scene is not None + assert gp.polygon_wdg.scene.items() From f444da1aa2c74c5973709688b7832edc8f6fd41b Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 13 Aug 2025 10:23:06 -0400 Subject: [PATCH 14/38] add acq order lines --- src/pymmcore_widgets/useq_widgets/_grid.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index b06c4c275..c928a3568 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -438,13 +438,13 @@ def setValue(self, plan: useq.GridFromEdges) -> None: class _PolygonWidget(QWidget): - """QWidget that draws a useq.GridFromPolygon similar to its matplotlib `plot()`.""" + """QWidget that draws a useq.GridFromPolygon.""" VERTEX_RADIUS = 0 CENTER_RADIUS = 0 POLY_PEN = QPen(Qt.GlobalColor.darkMagenta) POLY_BRUSH = QBrush(Qt.BrushStyle.NoBrush) - BB_PEN = QPen(Qt.GlobalColor.darkGray, 0, Qt.PenStyle.DashLine) + BB_PEN = QPen(Qt.GlobalColor.darkGray, 0, Qt.PenStyle.DotLine) VERTEX_PEN = QPen(Qt.GlobalColor.magenta, 0) VERTEX_BRUSH = QBrush(Qt.GlobalColor.magenta) CENTER_PEN = QPen(Qt.GlobalColor.darkGreen, 0) @@ -519,6 +519,20 @@ def _redraw(self, plan: useq.GridFromPolygon) -> None: # draw grid centers and FOV rectangles centers = self._compute_centers(self._polygon) + + # connect centers + if len(centers) >= 2: + path = QPainterPath(QPointF(*centers[0])) + for x, y in centers[1:]: + path.lineTo(x, y) + path_item = QGraphicsPathItem(path) + path_pen = QPen(self.CENTER_PEN) + path_pen.setWidth(pen_size) + path_pen.setStyle(Qt.PenStyle.DashLine) + path_item.setPen(path_pen) + path_item.setZValue(0.5) + self.scene.addItem(path_item) + if fw > 0 and fh > 0: hw, hh = fw / 2.0, fh / 2.0 for cx, cy in centers: From b57d15977f545a33bca480d0bc01b72072131bfa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:35:30 +0000 Subject: [PATCH 15/38] style(pre-commit.ci): auto fixes [...] --- src/pymmcore_widgets/control/_rois/roi_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/control/_rois/roi_manager.py b/src/pymmcore_widgets/control/_rois/roi_manager.py index ea4c81f3f..ee7ab608f 100644 --- a/src/pymmcore_widgets/control/_rois/roi_manager.py +++ b/src/pymmcore_widgets/control/_rois/roi_manager.py @@ -157,7 +157,7 @@ def all_rois(self) -> list[ROI]: 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.""" From ba627be40aa94bd7bc84fd3c0949bb828139c00e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 13 Aug 2025 12:01:22 -0400 Subject: [PATCH 16/38] update --- src/pymmcore_widgets/useq_widgets/_grid.py | 39 +++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index c928a3568..4cc6c658a 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -311,7 +311,7 @@ def _on_change(self) -> None: if (val := self.value()) is None: return # pragma: no cover if isinstance(val, useq.GridFromPolygon): - self.polygon_wdg._redraw(val) + self.polygon_wdg.setValue(val) self.valueChanged.emit(val) @@ -437,6 +437,43 @@ def setValue(self, plan: useq.GridFromEdges) -> None: self.bottom.setValue(plan.bottom) +# class _PolygonWidget(QWidget): +# def __init__(self, parent=None): +# from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as Canvas +# from matplotlib.figure import Figure + +# super().__init__(parent) + +# self._fig = Figure(constrained_layout=True) +# self._ax = self._fig.add_subplot(111) +# self._canvas = Canvas(self._fig) + +# self._polygon: useq.GridFromPolygon | None = None + +# lay = QVBoxLayout(self) +# lay.setContentsMargins(0, 0, 0, 0) +# lay.addWidget(self._canvas) + +# def value(self) -> dict[str, Any]: +# vertices = self._polygon.vertices if self._polygon else [] +# convex_hull = self._polygon.convex_hull if self._polygon else False +# offset = self._polygon.offset if self._polygon else 0 +# if not vertices: +# return { +# "vertices": [(0, 0), (0, 0), (0, 0)], +# "convex_hull": False, +# "offset": 0, +# } +# return {"vertices": vertices, "convex_hull": convex_hull, "offset": offset} + +# def setValue(self, plan: useq.GridFromPolygon) -> None: +# self._polygon = plan +# self._ax.clear() +# plan.plot(axes=self._ax) # <— direct reuse +# self._ax.set_aspect("equal") # usually desirable for XY +# self._canvas.draw_idle() + + class _PolygonWidget(QWidget): """QWidget that draws a useq.GridFromPolygon.""" From ab50d6705436a3434bfdec9e30c9919f185ff9a7 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 13 Aug 2025 12:02:14 -0400 Subject: [PATCH 17/38] update --- x.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/x.py b/x.py index 0c2ca9e93..105de35d7 100644 --- a/x.py +++ b/x.py @@ -6,16 +6,15 @@ app = QApplication([]) poly = useq.GridFromPolygon( - vertices=[(-400, 0), (1500, -500), (500, 1900), (0, 100)], - # vertices=[(0, 0), (300, 0), (300, 100), (100, 100), (100, 300), (0, 300)], + # vertices=[(-400, 0), (1000, -500), (500, 1200), (0, 100)], + vertices=[(0, 0), (300, 0), (300, 100), (100, 100), (100, 300), (0, 300)], fov_height=100, fov_width=100, overlap=(10, 10), # convex_hull=True ) pos = useq.AbsolutePosition(x=1, y=2, z=3, sequence=useq.MDASequence(grid_plan=poly)) -# seq = useq.MDASequence(grid_plan=poly, stage_positions=[pos]) -seq = useq.MDASequence(grid_plan=poly) +seq = useq.MDASequence(stage_positions=[pos]) m = MDAWidget() m.setValue(seq) From 67d3c38528b2707ce571067281e69cc137835d29 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 14 Aug 2025 09:20:12 -0400 Subject: [PATCH 18/38] add rois_to_useq_positions --- .../_stage_explorer/_stage_explorer.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index e1541254c..728d094b9 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -291,6 +291,28 @@ 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): + if plan := roi.create_grid_plan(*self._fov_w_h()): + 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 ---------------------------------------------------------------------- From e321577e403f6653beccc9f40be2dcaafbad3a0c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 14 Aug 2025 09:55:19 -0400 Subject: [PATCH 19/38] update --- src/pymmcore_widgets/useq_widgets/_grid.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 4cc6c658a..62639c0e5 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -469,8 +469,7 @@ def setValue(self, plan: useq.GridFromEdges) -> None: # def setValue(self, plan: useq.GridFromPolygon) -> None: # self._polygon = plan # self._ax.clear() -# plan.plot(axes=self._ax) # <— direct reuse -# self._ax.set_aspect("equal") # usually desirable for XY +# plan.plot(axes=self._ax) # self._canvas.draw_idle() From f74fef95e2e1bf2a52a0a06ebc23009e728d40e9 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 14 Aug 2025 09:59:48 -0400 Subject: [PATCH 20/38] update example --- x.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/x.py b/x.py index 105de35d7..2682b5071 100644 --- a/x.py +++ b/x.py @@ -5,16 +5,26 @@ app = QApplication([]) -poly = useq.GridFromPolygon( - # vertices=[(-400, 0), (1000, -500), (500, 1200), (0, 100)], +poly1 = useq.GridFromPolygon( + vertices=[(-400, 0), (1000, -500), (500, 1200), (0, 100)], + fov_height=100, + fov_width=100, + overlap=(10, 10), +) +poly2 = useq.GridFromPolygon( vertices=[(0, 0), (300, 0), (300, 100), (100, 100), (100, 300), (0, 300)], fov_height=100, fov_width=100, overlap=(10, 10), - # convex_hull=True ) -pos = useq.AbsolutePosition(x=1, y=2, z=3, sequence=useq.MDASequence(grid_plan=poly)) -seq = useq.MDASequence(stage_positions=[pos]) +pos1 = useq.AbsolutePosition( + x=1, y=2, z=3, name="pos1", sequence=useq.MDASequence(grid_plan=poly1) +) +pos2 = useq.AbsolutePosition( + x=4, y=5, z=6, name="pos2", sequence=useq.MDASequence(grid_plan=poly2) +) + +seq = useq.MDASequence(stage_positions=[pos1, pos2]) m = MDAWidget() m.setValue(seq) From 34e5c9dbfe26f3b3ad579012fc70ff1951c340e7 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 14 Aug 2025 10:08:18 -0400 Subject: [PATCH 21/38] update example --- src/pymmcore_widgets/useq_widgets/_grid.py | 1 + x.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 62639c0e5..9a48ea436 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -606,6 +606,7 @@ def _add_dot( d = 2 * r ell = self.scene.addEllipse(x - r, y - r, d, d, pen, brush) ell = cast("QGraphicsEllipseItem", ell) + ell.setZValue(1.0) return ell def _fit_view_to_items(self, pad: float = 0.01) -> None: diff --git a/x.py b/x.py index 2682b5071..219db991d 100644 --- a/x.py +++ b/x.py @@ -1,8 +1,12 @@ import useq +from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QApplication from pymmcore_widgets import MDAWidget +mmc = CMMCorePlus.instance() +mmc.loadSystemConfiguration() + app = QApplication([]) poly1 = useq.GridFromPolygon( From 832b58976fd3084e7dd31849fb46e14ab8766e3f Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 14 Aug 2025 11:24:15 -0400 Subject: [PATCH 22/38] limit max fov pixels rect size --- src/pymmcore_widgets/useq_widgets/_grid.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 9a48ea436..87b2a7cea 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -437,6 +437,7 @@ def setValue(self, plan: useq.GridFromEdges) -> None: self.bottom.setValue(plan.bottom) +# TODO: remove, this is to test using the GridFromPolygon.plot() method # class _PolygonWidget(QWidget): # def __init__(self, parent=None): # from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as Canvas @@ -487,6 +488,10 @@ class _PolygonWidget(QWidget): CENTER_BRUSH = QBrush(Qt.GlobalColor.darkGreen) FOV_PEN = QPen(Qt.GlobalColor.darkGray) FOV_BRUSH = QBrush(Qt.BrushStyle.NoBrush) + # maximum allowed FOV rectangle size in pixels; if an FOV would be larger + # than this when rendered, the view will be zoomed out to keep it at or + # below this size. + MAX_FOV_PIXELS = 50 def __init__(self, show_fovs: bool = True, show_centers: bool = True) -> None: super().__init__() @@ -564,7 +569,7 @@ def _redraw(self, plan: useq.GridFromPolygon) -> None: path_item = QGraphicsPathItem(path) path_pen = QPen(self.CENTER_PEN) path_pen.setWidth(pen_size) - path_pen.setStyle(Qt.PenStyle.DashLine) + path_pen.setStyle(Qt.PenStyle.DotLine) path_item.setPen(path_pen) path_item.setZValue(0.5) self.scene.addItem(path_item) @@ -624,6 +629,20 @@ def _fit_view_to_items(self, pad: float = 0.01) -> None: # keep transform (y-up) while fitting self.view.resetTransform() self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + # after fitting, ensure that individual FOV rectangles are not rendered + # larger than MAX_FOV_PIXELS. If they are, scale the view down. + try: + current_scale = float(self.view.transform().m11()) + print(current_scale) + except Exception: + current_scale = 1.0 + if self._polygon is not None: + if (fw := self._polygon.fov_width) and fw > 0: + fov_pixel = current_scale * fw + if fov_pixel > self.MAX_FOV_PIXELS: + max_allowed = self.MAX_FOV_PIXELS / fw + factor = max_allowed / current_scale + self.view.scale(factor, factor) self.view.setTransform(QTransform.fromScale(1, -1) * self.view.transform()) def _compute_centers(self, plan: useq.GridFromPolygon) -> list[tuple[float, float]]: From 1a18de8f762010b8df59bb3692dd5894cd0bcf0f Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 14 Aug 2025 11:25:08 -0400 Subject: [PATCH 23/38] remove print --- src/pymmcore_widgets/useq_widgets/_grid.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 87b2a7cea..5eff95669 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -633,7 +633,6 @@ def _fit_view_to_items(self, pad: float = 0.01) -> None: # larger than MAX_FOV_PIXELS. If they are, scale the view down. try: current_scale = float(self.view.transform().m11()) - print(current_scale) except Exception: current_scale = 1.0 if self._polygon is not None: From d81ff55924ee27dcf1b5fb45eff1cc68ffe114f6 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 14 Aug 2025 11:35:29 -0400 Subject: [PATCH 24/38] remove unused --- src/pymmcore_widgets/useq_widgets/_grid.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 5eff95669..8804792a2 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -493,8 +493,9 @@ class _PolygonWidget(QWidget): # below this size. MAX_FOV_PIXELS = 50 - def __init__(self, show_fovs: bool = True, show_centers: bool = True) -> None: + def __init__(self) -> None: super().__init__() + self._polygon: useq.GridFromPolygon | None = None self.scene = QGraphicsScene() From d705a66f954345d452f729f4a953d53885f67fda Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 14 Aug 2025 15:52:23 -0400 Subject: [PATCH 25/38] add overlap and acq mode --- src/pymmcore_widgets/control/_rois/_vispy.py | 6 +- .../control/_rois/roi_model.py | 14 ++- .../_stage_explorer/_stage_explorer.py | 93 ++++++++++++++++++- 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/pymmcore_widgets/control/_rois/_vispy.py b/src/pymmcore_widgets/control/_rois/_vispy.py index 4e3fe3533..c5cf656ae 100644 --- a/src/pymmcore_widgets/control/_rois/_vispy.py +++ b/src/pymmcore_widgets/control/_rois/_vispy.py @@ -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: diff --git a/src/pymmcore_widgets/control/_rois/roi_model.py b/src/pymmcore_widgets/control/_rois/roi_model.py index 89a5abc5a..3dbbe00f3 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) + 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).""" @@ -90,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: @@ -103,6 +106,7 @@ 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 @@ -110,6 +114,8 @@ def create_grid_plan( vertices=list(self.vertices), fov_width=fov_w, fov_height=fov_h, + mode=mode, + overlap=overlap, ) else: return useq.GridFromEdges( @@ -119,6 +125,8 @@ def create_grid_plan( right=right, fov_width=fov_w, fov_height=fov_h, + mode=mode, + overlap=overlap, ) return None @@ -129,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: diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 728d094b9..0426229f7 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) @@ -297,7 +306,8 @@ def rois_to_useq_positions(self) -> list[useq.AbsolutePosition] | None: positions: list[useq.AbsolutePosition] = [] for idx, roi in enumerate(rois): - if plan := roi.create_grid_plan(*self._fov_w_h()): + 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}", @@ -365,12 +375,38 @@ def _on_scan_action(self) -> None: 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()): + + 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 @@ -604,6 +640,55 @@ def __init__(self, parent: QWidget | None = None): QIconifyIcon("ph:path-duotone", color=GRAY), "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 {} From 51d9961be09db0abaa8c3027683bcd18c1c4bbf0 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 15 Aug 2025 13:31:48 -0400 Subject: [PATCH 26/38] use useq GrodFromPolygon --- examples/temp/grid_from_polygon.py | 37 +++ .../control/_rois/roi_model.py | 112 +------ src/pymmcore_widgets/useq_widgets/_grid.py | 297 +++++++++++++++++- tests/useq_widgets/test_useq_widgets.py | 44 ++- 4 files changed, 366 insertions(+), 124 deletions(-) create mode 100644 examples/temp/grid_from_polygon.py diff --git a/examples/temp/grid_from_polygon.py b/examples/temp/grid_from_polygon.py new file mode 100644 index 000000000..219db991d --- /dev/null +++ b/examples/temp/grid_from_polygon.py @@ -0,0 +1,37 @@ +import useq +from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets import MDAWidget + +mmc = CMMCorePlus.instance() +mmc.loadSystemConfiguration() + +app = QApplication([]) + +poly1 = useq.GridFromPolygon( + vertices=[(-400, 0), (1000, -500), (500, 1200), (0, 100)], + fov_height=100, + fov_width=100, + overlap=(10, 10), +) +poly2 = useq.GridFromPolygon( + vertices=[(0, 0), (300, 0), (300, 100), (100, 100), (100, 300), (0, 300)], + fov_height=100, + fov_width=100, + overlap=(10, 10), +) +pos1 = useq.AbsolutePosition( + x=1, y=2, z=3, name="pos1", sequence=useq.MDASequence(grid_plan=poly1) +) +pos2 = useq.AbsolutePosition( + x=4, y=5, z=6, name="pos2", sequence=useq.MDASequence(grid_plan=poly2) +) + +seq = useq.MDASequence(stage_positions=[pos1, pos2]) + +m = MDAWidget() +m.setValue(seq) +m.show() + +app.exec() diff --git a/src/pymmcore_widgets/control/_rois/roi_model.py b/src/pymmcore_widgets/control/_rois/roi_model.py index 2167f6a2d..958881480 100644 --- a/src/pymmcore_widgets/control/_rois/roi_model.py +++ b/src/pymmcore_widgets/control/_rois/roi_model.py @@ -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) @@ -212,8 +106,8 @@ def create_grid_plan( if type(self) is not RectangleROI: if len(self.vertices) < 3: return None - return GridFromPolygon( - vertices=self.vertices, + return useq.GridFromPolygon( # type: ignore[no-any-return] + vertices=list(self.vertices), fov_width=fov_w, fov_height=fov_h, ) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index cb6aacd91..8804792a2 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -1,15 +1,29 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, cast import useq -from qtpy.QtCore import Qt, Signal +from qtpy.QtCore import QPointF, QRectF, Qt, Signal +from qtpy.QtGui import ( + QBrush, + QPainter, + QPainterPath, + QPen, + QPolygonF, + QResizeEvent, + QTransform, +) from qtpy.QtWidgets import ( QAbstractButton, QButtonGroup, QDoubleSpinBox, QFormLayout, + QGraphicsEllipseItem, + QGraphicsPathItem, + QGraphicsRectItem, + QGraphicsScene, + QGraphicsView, QHBoxLayout, QLabel, QRadioButton, @@ -28,8 +42,13 @@ if TYPE_CHECKING: from typing import Literal, TypeAlias + from shapely import Polygon + GridPlan: TypeAlias = ( - useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight + useq.GridFromEdges + | useq.GridRowsColumns + | useq.GridWidthHeight + | useq.GridFromPolygon ) class ValueWidget(Protocol, QWidget): # pyright: ignore @@ -55,6 +74,7 @@ class Mode(Enum): NUMBER = "number" AREA = "area" BOUNDS = "bounds" + POLYGON = "polygon" def __str__(self) -> str: return self.value @@ -70,6 +90,8 @@ def for_grid_plan(cls, plan: GridPlan) -> Mode: return cls.BOUNDS elif isinstance(plan, useq.GridWidthHeight): return cls.AREA + elif isinstance(plan, useq.GridFromPolygon): + return cls.POLYGON raise TypeError(f"Unknown grid plan type: {type(plan)}") # pragma: no cover @@ -77,6 +99,7 @@ def for_grid_plan(cls, plan: GridPlan) -> Mode: Mode.NUMBER: useq.GridRowsColumns, Mode.BOUNDS: useq.GridFromEdges, Mode.AREA: useq.GridWidthHeight, + Mode.POLYGON: useq.GridFromPolygon, } @@ -97,21 +120,28 @@ def __init__(self, parent: QWidget | None = None): self._mode_number_radio = QRadioButton("Fields of View") self._mode_area_radio = QRadioButton("Width && Height") self._mode_bounds_radio = QRadioButton("Absolute Bounds") + self._mode_polygon_radio = QRadioButton("Polygon") + # by default, hide the polygon mode. Will be visible only if required using + # the setMode method. + self._mode_polygon_radio.hide() # group the radio buttons together self._mode_btn_group = QButtonGroup() self._mode_btn_group.addButton(self._mode_number_radio) self._mode_btn_group.addButton(self._mode_area_radio) self._mode_btn_group.addButton(self._mode_bounds_radio) + self._mode_btn_group.addButton(self._mode_polygon_radio) self._mode_btn_group.buttonToggled.connect(self.setMode) self.row_col_wdg = _RowsColsWidget() self.width_height_wdg = _WidthHeightWidget() self.bounds_wdg = _BoundsWidget() + self.polygon_wdg = _PolygonWidget() # ease of lookup self._mode_to_widget: dict[Mode, ValueWidget] = { Mode.NUMBER: self.row_col_wdg, Mode.AREA: self.width_height_wdg, Mode.BOUNDS: self.bounds_wdg, + Mode.POLYGON: self.polygon_wdg, } self._bottom_stuff = _BottomStuff() @@ -128,12 +158,14 @@ def __init__(self, parent: QWidget | None = None): btns_row.addWidget(self._mode_number_radio) btns_row.addWidget(self._mode_area_radio) btns_row.addWidget(self._mode_bounds_radio) + btns_row.addWidget(self._mode_polygon_radio) # stack the different mode widgets on top of each other self._stack = _ResizableStackedWidget(self) self._stack.addWidget(self.row_col_wdg) self._stack.addWidget(self.width_height_wdg) self._stack.addWidget(self.bounds_wdg) + self._stack.addWidget(self.polygon_wdg) # wrap the whole thing in an inner widget so we can put it in this ScrollArea inner_widget = QWidget(self) @@ -170,12 +202,14 @@ def mode(self) -> Mode: """Return the current mode, one of "number", "area", or "bounds".""" return self._mode - def setMode(self, mode: Mode | Literal["number", "area", "bounds"]) -> None: - """Set the current mode, one of "number", "area", or "bounds". + def setMode( + self, mode: Mode | Literal["number", "area", "bounds", "polygon"] + ) -> None: + """Set the current mode, one of "number", "area", "bounds", or "polygon". Parameters ---------- - mode : Mode | Literal["number", "area", "bounds"] + mode : Mode | Literal["number", "area", "bounds", "polygon"] The mode to set. """ if isinstance(mode, QRadioButton): @@ -183,6 +217,7 @@ def setMode(self, mode: Mode | Literal["number", "area", "bounds"]) -> None: self._mode_number_radio: Mode.NUMBER, self._mode_area_radio: Mode.AREA, self._mode_bounds_radio: Mode.BOUNDS, + self._mode_polygon_radio: Mode.POLYGON, } mode = btn_map[mode] elif isinstance(mode, str): @@ -212,6 +247,7 @@ def value(self) -> GridPlan: } if self._mode not in {Mode.NUMBER, Mode.AREA}: kwargs.pop("relative_to", None) + return self._mode.to_useq_cls()(**kwargs) def setValue(self, value: GridPlan) -> None: @@ -219,10 +255,10 @@ def setValue(self, value: GridPlan) -> None: Parameters ---------- - value : useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight + value : useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight | useq.GridFromPolygon The [`useq-schema` GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans) to set. - """ + """ # noqa: E501 mode = Mode.for_grid_plan(value) with signals_blocked(self): @@ -237,6 +273,18 @@ def setValue(self, value: GridPlan) -> None: self._bottom_stuff.setValue(value) self.setMode(mode) + # ensure the correct QRadioButton is checked + with signals_blocked(self._mode_btn_group): + if mode == Mode.NUMBER: + self._mode_number_radio.setChecked(True) + elif mode == Mode.AREA: + self._mode_area_radio.setChecked(True) + elif mode == Mode.BOUNDS: + self._mode_bounds_radio.setChecked(True) + elif mode == Mode.POLYGON: + self._mode_polygon_radio.show() + self._mode_polygon_radio.setChecked(True) + self._on_change() def setFovWidth(self, value: float) -> None: @@ -262,6 +310,8 @@ def fovHeight(self) -> float | None: def _on_change(self) -> None: if (val := self.value()) is None: return # pragma: no cover + if isinstance(val, useq.GridFromPolygon): + self.polygon_wdg.setValue(val) self.valueChanged.emit(val) @@ -387,15 +437,238 @@ def setValue(self, plan: useq.GridFromEdges) -> None: self.bottom.setValue(plan.bottom) +# TODO: remove, this is to test using the GridFromPolygon.plot() method +# class _PolygonWidget(QWidget): +# def __init__(self, parent=None): +# from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as Canvas +# from matplotlib.figure import Figure + +# super().__init__(parent) + +# self._fig = Figure(constrained_layout=True) +# self._ax = self._fig.add_subplot(111) +# self._canvas = Canvas(self._fig) + +# self._polygon: useq.GridFromPolygon | None = None + +# lay = QVBoxLayout(self) +# lay.setContentsMargins(0, 0, 0, 0) +# lay.addWidget(self._canvas) + +# def value(self) -> dict[str, Any]: +# vertices = self._polygon.vertices if self._polygon else [] +# convex_hull = self._polygon.convex_hull if self._polygon else False +# offset = self._polygon.offset if self._polygon else 0 +# if not vertices: +# return { +# "vertices": [(0, 0), (0, 0), (0, 0)], +# "convex_hull": False, +# "offset": 0, +# } +# return {"vertices": vertices, "convex_hull": convex_hull, "offset": offset} + +# def setValue(self, plan: useq.GridFromPolygon) -> None: +# self._polygon = plan +# self._ax.clear() +# plan.plot(axes=self._ax) +# self._canvas.draw_idle() + + +class _PolygonWidget(QWidget): + """QWidget that draws a useq.GridFromPolygon.""" + + VERTEX_RADIUS = 0 + CENTER_RADIUS = 0 + POLY_PEN = QPen(Qt.GlobalColor.darkMagenta) + POLY_BRUSH = QBrush(Qt.BrushStyle.NoBrush) + BB_PEN = QPen(Qt.GlobalColor.darkGray, 0, Qt.PenStyle.DotLine) + VERTEX_PEN = QPen(Qt.GlobalColor.magenta, 0) + VERTEX_BRUSH = QBrush(Qt.GlobalColor.magenta) + CENTER_PEN = QPen(Qt.GlobalColor.darkGreen, 0) + CENTER_BRUSH = QBrush(Qt.GlobalColor.darkGreen) + FOV_PEN = QPen(Qt.GlobalColor.darkGray) + FOV_BRUSH = QBrush(Qt.BrushStyle.NoBrush) + # maximum allowed FOV rectangle size in pixels; if an FOV would be larger + # than this when rendered, the view will be zoomed out to keep it at or + # below this size. + MAX_FOV_PIXELS = 50 + + def __init__(self) -> None: + super().__init__() + + self._polygon: useq.GridFromPolygon | None = None + + self.scene = QGraphicsScene() + self.view = QGraphicsView(self.scene) + self.view.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + self.view.setTransform(QTransform.fromScale(1, -1)) # y-up + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.view) + + # ----------------------------PUBLIC METHODS---------------------------- + + def value(self) -> dict[str, Any]: + vertices = self._polygon.vertices if self._polygon else [] + convex_hull = self._polygon.convex_hull if self._polygon else False + offset = self._polygon.offset if self._polygon else 0 + if not vertices: + return { + "vertices": [(0, 0), (0, 0), (0, 0)], + "convex_hull": False, + "offset": 0, + } + return {"vertices": vertices, "convex_hull": convex_hull, "offset": offset} + + def setValue(self, plan: useq.GridFromPolygon) -> None: + """Set and render the polygon/grid plan.""" + self._redraw(plan) + + # ----------------------------PRIVATE METHODS---------------------------- + + def _redraw(self, plan: useq.GridFromPolygon) -> None: + self.scene.clear() + if not self._polygon and plan is None: + return + + fw, fh = plan.fov_width or 0, plan.fov_height or 0 + pen_size = int(fw * 0.04) if fw > 0 else 1 + self.VERTEX_RADIUS = self.CENTER_RADIUS = pen_size + + self._polygon = plan + poly = self._polygon.poly + verts: list[tuple[float, float]] = list(self._polygon.vertices or []) + + # draw polygon outline + poly_item = self._make_polygon_item(poly) + self.POLY_PEN.setWidth(pen_size) + poly_item.setPen(self.POLY_PEN) + poly_item.setBrush(self.POLY_BRUSH) + self.scene.addItem(poly_item) + + # draw vertices + for x, y in verts: + self._add_dot(x, y, self.VERTEX_RADIUS, self.VERTEX_PEN, self.VERTEX_BRUSH) + + # draw dashed bounding box + min_x, min_y, max_x, max_y = poly.bounds + bb = QGraphicsRectItem(min_x, min_y, max_x - min_x, max_y - min_y) + self.BB_PEN.setWidth(pen_size) + bb.setPen(self.BB_PEN) + self.scene.addItem(bb) + + # draw grid centers and FOV rectangles + centers = self._compute_centers(self._polygon) + + # connect centers + if len(centers) >= 2: + path = QPainterPath(QPointF(*centers[0])) + for x, y in centers[1:]: + path.lineTo(x, y) + path_item = QGraphicsPathItem(path) + path_pen = QPen(self.CENTER_PEN) + path_pen.setWidth(pen_size) + path_pen.setStyle(Qt.PenStyle.DotLine) + path_item.setPen(path_pen) + path_item.setZValue(0.5) + self.scene.addItem(path_item) + + if fw > 0 and fh > 0: + hw, hh = fw / 2.0, fh / 2.0 + for cx, cy in centers: + rect = QGraphicsRectItem(cx - hw, cy - hh, fw, fh) + self.FOV_PEN.setWidth(pen_size) + rect.setPen(self.FOV_PEN) + rect.setBrush(self.FOV_BRUSH) + self.scene.addItem(rect) + + for cx, cy in centers: + self._add_dot( + cx, cy, self.CENTER_RADIUS, self.CENTER_PEN, self.CENTER_BRUSH + ) + + self._fit_view_to_items() + + def _make_polygon_item(self, shapely_poly: Polygon) -> QGraphicsPathItem: + """Create a QGraphicsPathItem for a shapely Polygon with holes.""" + path = QPainterPath() + # exterior + ext = [QPointF(x, y) for (x, y) in shapely_poly.exterior.coords] + if ext: + path.addPolygon(QPolygonF(ext)) + # holes + for interior in shapely_poly.interiors: + pts = [QPointF(x, y) for (x, y) in interior.coords] + if pts: + path.addPolygon(QPolygonF(pts)) + item = QGraphicsPathItem(path) + return item + + def _add_dot( + self, x: float, y: float, r: float, pen: QPen, brush: QBrush + ) -> QGraphicsEllipseItem: + d = 2 * r + ell = self.scene.addEllipse(x - r, y - r, d, d, pen, brush) + ell = cast("QGraphicsEllipseItem", ell) + ell.setZValue(1.0) + return ell + + def _fit_view_to_items(self, pad: float = 0.01) -> None: + rect = self.scene.itemsBoundingRect() + if rect.isNull(): + return + # add padding + padded = QRectF( + rect.x() - rect.width() * pad, + rect.y() - rect.height() * pad, + rect.width() * (1 + 2 * pad), + rect.height() * (1 + 2 * pad), + ) + self.scene.setSceneRect(padded) + # keep transform (y-up) while fitting + self.view.resetTransform() + self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + # after fitting, ensure that individual FOV rectangles are not rendered + # larger than MAX_FOV_PIXELS. If they are, scale the view down. + try: + current_scale = float(self.view.transform().m11()) + except Exception: + current_scale = 1.0 + if self._polygon is not None: + if (fw := self._polygon.fov_width) and fw > 0: + fov_pixel = current_scale * fw + if fov_pixel > self.MAX_FOV_PIXELS: + max_allowed = self.MAX_FOV_PIXELS / fw + factor = max_allowed / current_scale + self.view.scale(factor, factor) + self.view.setTransform(QTransform.fromScale(1, -1) * self.view.transform()) + + def _compute_centers(self, plan: useq.GridFromPolygon) -> list[tuple[float, float]]: + """Compute grid center points within the polygon.""" + centers: list[tuple[float, float]] = [] + for item in plan: + x, y = item.x, item.y + if x is None or y is None: + continue + centers.append((float(x), float(y))) + return centers + + def resizeEvent(self, a0: QResizeEvent | None) -> None: + super().resizeEvent(a0) + self._fit_view_to_items() + + class _ResizableStackedWidget(QStackedWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent=parent) self.currentChanged.connect(self.onCurrentChanged) - def addWidget(self, wdg: QWidget | None) -> int: - if wdg is not None: - wdg.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) - return super().addWidget(wdg) # type: ignore [no-any-return] + def addWidget(self, w: QWidget | None) -> int: + if w is not None: + w.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + return super().addWidget(w) # type: ignore [no-any-return] def onCurrentChanged(self, idx: int) -> None: for i in range(self.count()): diff --git a/tests/useq_widgets/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py index fd704ff9c..46cc01c59 100644 --- a/tests/useq_widgets/test_useq_widgets.py +++ b/tests/useq_widgets/test_useq_widgets.py @@ -30,7 +30,11 @@ TextColumn, parse_timedelta, ) -from pymmcore_widgets.useq_widgets._positions import MDAButton, QFileDialog, _MDAPopup +from pymmcore_widgets.useq_widgets._positions import ( + MDAButton, + QFileDialog, + _MDAPopup, +) if TYPE_CHECKING: from pathlib import Path @@ -412,6 +416,9 @@ def test_grid_plan_widget(qtbot: QtBot) -> None: qtbot.addWidget(wdg) wdg.show() + with pytest.raises(ValueError): + wdg.setMode("polygon") + assert isinstance(wdg.value(), useq.GridFromPolygon) wdg.setMode("bounds") assert isinstance(wdg.value(), useq.GridFromEdges) wdg.setMode("number") @@ -424,24 +431,36 @@ def test_grid_plan_widget(qtbot: QtBot) -> None: wdg.setValue(plan) assert wdg.mode() == _grid.Mode.NUMBER assert wdg.value() == plan + assert wdg._mode_btn_group.checkedButton().text() == "Fields of View" plan = useq.GridFromEdges(left=1, right=2, top=3, bottom=4, overlap=10) with qtbot.waitSignal(wdg.valueChanged): wdg.setValue(plan) assert wdg.mode() == _grid.Mode.BOUNDS assert wdg.value() == plan + assert wdg._mode_btn_group.checkedButton().text() == "Absolute Bounds" plan = useq.GridWidthHeight(width=1000, height=2000, fov_height=3, fov_width=4) with qtbot.waitSignal(wdg.valueChanged): wdg.setValue(plan) assert wdg.mode() == _grid.Mode.AREA assert wdg.value() == plan + assert wdg._mode_btn_group.checkedButton().text() == "Width && Height" - assert wdg._fov_height == 3 + plan = useq.GridFromPolygon( + vertices=[(-4, 0), (5, -5), (5, 9), (0, 10)], fov_height=1, fov_width=1 + ) + with qtbot.waitSignal(wdg.valueChanged): + wdg.setValue(plan) + assert wdg.mode() == _grid.Mode.POLYGON + assert wdg.value().model_dump() == plan.model_dump() + assert wdg._mode_btn_group.checkedButton().text() == "Polygon" + + assert wdg._fov_height == 1 wdg.setFovHeight(5) assert wdg.fovHeight() == 5 - assert wdg._fov_width == 4 + assert wdg._fov_width == 1 wdg.setFovWidth(6) assert wdg.fovWidth() == 6 @@ -552,3 +571,22 @@ def _qmsgbox(*args, **kwargs): assert wdg.af_axis.isEnabled() assert wdg.stage_positions.af_per_position.isEnabled() + + +def test_mda_popup_with_polygon(qtbot: QtBot) -> None: + polygon = useq.GridFromPolygon( + vertices=[(-10, 0), (12, -5), (10, 20), (0, 10)], + fov_height=1, + fov_width=1, + ) + seq = useq.MDASequence(channels=["DAPI", "GFP"], grid_plan=polygon) + pop = _MDAPopup(seq) + qtbot.addWidget(pop) + + assert pop.mda_tabs.isChecked(pop.mda_tabs.channels) + + gp = pop.mda_tabs.grid_plan + assert pop.mda_tabs.isChecked(gp) + assert gp._mode_btn_group.checkedButton().text() == "Polygon" + assert gp.polygon_wdg.scene is not None + assert gp.polygon_wdg.scene.items() From e739deb06ed53f1d5d12a56374c096cf15ddb92e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 15 Aug 2025 13:47:30 -0400 Subject: [PATCH 27/38] bump useq --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2e7a5bea9..75b07fa65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ 'pymmcore-plus[cli] >=0.14.0', 'qtpy >=2.0', 'superqt[quantity,cmap,iconify] >=0.7.1', - 'useq-schema >=0.7.3', + 'useq-schema >=0.8.0', 'vispy >=0.15.0', "pyopengl >=3.1.9; platform_system == 'Darwin'", "shapely>=2.0.7", From 163a34c906d4b9d32e55f3c429df63d68d24a49d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 15 Aug 2025 14:39:25 -0400 Subject: [PATCH 28/38] stuff --- .pre-commit-config.yaml | 4 +- src/pymmcore_widgets/_util.py | 15 --- .../control/_rois/roi_model.py | 2 +- src/pymmcore_widgets/useq_widgets/_grid.py | 112 +++++------------- tests/useq_widgets/test_useq_widgets.py | 11 +- 5 files changed, 35 insertions(+), 109 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 284167b3e..287a3f93e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1 + rev: v1.35.4 hooks: - id: typos args: [--force-exclude] # omitting --write-changes @@ -29,4 +29,4 @@ repos: files: "^src/" additional_dependencies: - pymmcore-plus >=0.15.0 - - useq-schema >=0.5.0 + - useq-schema >=0.8.0 diff --git a/src/pymmcore_widgets/_util.py b/src/pymmcore_widgets/_util.py index 83bc31430..6c2c0b290 100644 --- a/src/pymmcore_widgets/_util.py +++ b/src/pymmcore_widgets/_util.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import TYPE_CHECKING -import useq from psygnal import SignalInstance from pymmcore_plus import CMMCorePlus from qtpy.QtCore import QEvent, QMarginsF, QObject, Qt @@ -144,20 +143,6 @@ def block_core(obj: Any) -> AbstractContextManager: raise TypeError(f"Cannot block signals for {obj}") -def cast_grid_plan( - grid: dict | useq.GridRowsColumns | useq.GridWidthHeight | useq.GridFromEdges, -) -> useq.GridRowsColumns | useq.GridWidthHeight | useq.GridFromEdges | None: - """Get the grid type from the grid_plan.""" - if not grid or isinstance(grid, useq.RandomPoints): - return None - if isinstance(grid, dict): - _grid = useq.MDASequence(grid_plan=grid).grid_plan - if isinstance(_grid, useq.RelativePosition): # pragma: no cover - raise ValueError("Grid plan cannot be a single Relative position.") - return None if isinstance(_grid, useq.RandomPoints) else _grid - return grid - - def fov_kwargs(core: CMMCorePlus) -> dict: """Return image width and height in micron to be used for the grid plan.""" if px := core.getPixelSizeUm(): diff --git a/src/pymmcore_widgets/control/_rois/roi_model.py b/src/pymmcore_widgets/control/_rois/roi_model.py index 958881480..bf1ed29ba 100644 --- a/src/pymmcore_widgets/control/_rois/roi_model.py +++ b/src/pymmcore_widgets/control/_rois/roi_model.py @@ -106,7 +106,7 @@ def create_grid_plan( if type(self) is not RectangleROI: if len(self.vertices) < 3: return None - return useq.GridFromPolygon( # type: ignore[no-any-return] + return useq.GridFromPolygon( vertices=list(self.vertices), fov_width=fov_w, fov_height=fov_h, diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 8804792a2..cc1cd3dbf 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -1,10 +1,10 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Any, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol import useq -from qtpy.QtCore import QPointF, QRectF, Qt, Signal +from qtpy.QtCore import QPointF, Qt, Signal from qtpy.QtGui import ( QBrush, QPainter, @@ -19,7 +19,6 @@ QButtonGroup, QDoubleSpinBox, QFormLayout, - QGraphicsEllipseItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, @@ -154,7 +153,7 @@ def __init__(self, parent: QWidget | None = None): # radio buttons on the top row btns_row = QHBoxLayout() - btns_row.addWidget(QLabel("Create Grid Using:")) + btns_row.addWidget(QLabel("Mode:")) btns_row.addWidget(self._mode_number_radio) btns_row.addWidget(self._mode_area_radio) btns_row.addWidget(self._mode_bounds_radio) @@ -310,8 +309,6 @@ def fovHeight(self) -> float | None: def _on_change(self) -> None: if (val := self.value()) is None: return # pragma: no cover - if isinstance(val, useq.GridFromPolygon): - self.polygon_wdg.setValue(val) self.valueChanged.emit(val) @@ -437,48 +434,9 @@ def setValue(self, plan: useq.GridFromEdges) -> None: self.bottom.setValue(plan.bottom) -# TODO: remove, this is to test using the GridFromPolygon.plot() method -# class _PolygonWidget(QWidget): -# def __init__(self, parent=None): -# from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as Canvas -# from matplotlib.figure import Figure - -# super().__init__(parent) - -# self._fig = Figure(constrained_layout=True) -# self._ax = self._fig.add_subplot(111) -# self._canvas = Canvas(self._fig) - -# self._polygon: useq.GridFromPolygon | None = None - -# lay = QVBoxLayout(self) -# lay.setContentsMargins(0, 0, 0, 0) -# lay.addWidget(self._canvas) - -# def value(self) -> dict[str, Any]: -# vertices = self._polygon.vertices if self._polygon else [] -# convex_hull = self._polygon.convex_hull if self._polygon else False -# offset = self._polygon.offset if self._polygon else 0 -# if not vertices: -# return { -# "vertices": [(0, 0), (0, 0), (0, 0)], -# "convex_hull": False, -# "offset": 0, -# } -# return {"vertices": vertices, "convex_hull": convex_hull, "offset": offset} - -# def setValue(self, plan: useq.GridFromPolygon) -> None: -# self._polygon = plan -# self._ax.clear() -# plan.plot(axes=self._ax) -# self._canvas.draw_idle() - - class _PolygonWidget(QWidget): """QWidget that draws a useq.GridFromPolygon.""" - VERTEX_RADIUS = 0 - CENTER_RADIUS = 0 POLY_PEN = QPen(Qt.GlobalColor.darkMagenta) POLY_BRUSH = QBrush(Qt.BrushStyle.NoBrush) BB_PEN = QPen(Qt.GlobalColor.darkGray, 0, Qt.PenStyle.DotLine) @@ -524,22 +482,22 @@ def value(self) -> dict[str, Any]: def setValue(self, plan: useq.GridFromPolygon) -> None: """Set and render the polygon/grid plan.""" - self._redraw(plan) + self._polygon = plan + self._redraw() # ----------------------------PRIVATE METHODS---------------------------- - def _redraw(self, plan: useq.GridFromPolygon) -> None: + def _redraw(self) -> None: self.scene.clear() - if not self._polygon and plan is None: + if (plan := self._polygon) is None: return fw, fh = plan.fov_width or 0, plan.fov_height or 0 pen_size = int(fw * 0.04) if fw > 0 else 1 - self.VERTEX_RADIUS = self.CENTER_RADIUS = pen_size + vertex_radius = center_radius = pen_size - self._polygon = plan - poly = self._polygon.poly - verts: list[tuple[float, float]] = list(self._polygon.vertices or []) + poly = plan.poly + verts: list[tuple[float, float]] = list(plan.vertices or []) # draw polygon outline poly_item = self._make_polygon_item(poly) @@ -550,7 +508,7 @@ def _redraw(self, plan: useq.GridFromPolygon) -> None: # draw vertices for x, y in verts: - self._add_dot(x, y, self.VERTEX_RADIUS, self.VERTEX_PEN, self.VERTEX_BRUSH) + self._add_dot(x, y, vertex_radius, self.VERTEX_PEN, self.VERTEX_BRUSH) # draw dashed bounding box min_x, min_y, max_x, max_y = poly.bounds @@ -560,7 +518,7 @@ def _redraw(self, plan: useq.GridFromPolygon) -> None: self.scene.addItem(bb) # draw grid centers and FOV rectangles - centers = self._compute_centers(self._polygon) + centers = self._compute_centers(plan) # connect centers if len(centers) >= 2: @@ -585,9 +543,7 @@ def _redraw(self, plan: useq.GridFromPolygon) -> None: self.scene.addItem(rect) for cx, cy in centers: - self._add_dot( - cx, cy, self.CENTER_RADIUS, self.CENTER_PEN, self.CENTER_BRUSH - ) + self._add_dot(cx, cy, center_radius, self.CENTER_PEN, self.CENTER_BRUSH) self._fit_view_to_items() @@ -606,43 +562,33 @@ def _make_polygon_item(self, shapely_poly: Polygon) -> QGraphicsPathItem: item = QGraphicsPathItem(path) return item - def _add_dot( - self, x: float, y: float, r: float, pen: QPen, brush: QBrush - ) -> QGraphicsEllipseItem: + def _add_dot(self, x: float, y: float, r: float, pen: QPen, brush: QBrush) -> None: d = 2 * r - ell = self.scene.addEllipse(x - r, y - r, d, d, pen, brush) - ell = cast("QGraphicsEllipseItem", ell) - ell.setZValue(1.0) - return ell + if ell := self.scene.addEllipse(x - r, y - r, d, d, pen, brush): + ell.setZValue(1.0) - def _fit_view_to_items(self, pad: float = 0.01) -> None: + def _fit_view_to_items(self, pad: int = 10) -> None: rect = self.scene.itemsBoundingRect() if rect.isNull(): return # add padding - padded = QRectF( - rect.x() - rect.width() * pad, - rect.y() - rect.height() * pad, - rect.width() * (1 + 2 * pad), - rect.height() * (1 + 2 * pad), - ) + padded = rect.adjusted(-pad, -pad, pad, pad) self.scene.setSceneRect(padded) # keep transform (y-up) while fitting self.view.resetTransform() - self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + self.view.fitInView(padded, Qt.AspectRatioMode.KeepAspectRatio) # after fitting, ensure that individual FOV rectangles are not rendered # larger than MAX_FOV_PIXELS. If they are, scale the view down. - try: - current_scale = float(self.view.transform().m11()) - except Exception: - current_scale = 1.0 - if self._polygon is not None: - if (fw := self._polygon.fov_width) and fw > 0: - fov_pixel = current_scale * fw - if fov_pixel > self.MAX_FOV_PIXELS: - max_allowed = self.MAX_FOV_PIXELS / fw - factor = max_allowed / current_scale - self.view.scale(factor, factor) + current_scale = self.view.transform().m11() + if ( + self._polygon is not None + and (fw := self._polygon.fov_width) + and fw > 0 + and (current_scale * fw) > self.MAX_FOV_PIXELS + ): + max_allowed = self.MAX_FOV_PIXELS / fw + factor = max_allowed / current_scale + self.view.scale(factor, factor) self.view.setTransform(QTransform.fromScale(1, -1) * self.view.transform()) def _compute_centers(self, plan: useq.GridFromPolygon) -> list[tuple[float, float]]: diff --git a/tests/useq_widgets/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py index 46cc01c59..7cb6711c7 100644 --- a/tests/useq_widgets/test_useq_widgets.py +++ b/tests/useq_widgets/test_useq_widgets.py @@ -30,11 +30,7 @@ TextColumn, parse_timedelta, ) -from pymmcore_widgets.useq_widgets._positions import ( - MDAButton, - QFileDialog, - _MDAPopup, -) +from pymmcore_widgets.useq_widgets._positions import MDAButton, QFileDialog, _MDAPopup if TYPE_CHECKING: from pathlib import Path @@ -416,15 +412,14 @@ def test_grid_plan_widget(qtbot: QtBot) -> None: qtbot.addWidget(wdg) wdg.show() - with pytest.raises(ValueError): - wdg.setMode("polygon") - assert isinstance(wdg.value(), useq.GridFromPolygon) wdg.setMode("bounds") assert isinstance(wdg.value(), useq.GridFromEdges) wdg.setMode("number") assert isinstance(wdg.value(), useq.GridRowsColumns) wdg.setMode("area") assert isinstance(wdg.value(), useq.GridWidthHeight) + wdg.setMode("polygon") + assert isinstance(wdg.value(), useq.GridFromPolygon) plan = useq.GridRowsColumns(rows=3, columns=3, mode="spiral", overlap=10) with qtbot.waitSignal(wdg.valueChanged): From 221d492d212a9c5d757f8e2a7fee2912620b1fb0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 15 Aug 2025 15:13:22 -0400 Subject: [PATCH 29/38] refactor: rename acq_mode to scan_order in ROI and update related references --- src/pymmcore_widgets/control/_rois/_vispy.py | 2 +- .../control/_rois/q_roi_model.py | 5 +++++ .../control/_rois/roi_manager.py | 22 +++++++++---------- .../control/_rois/roi_model.py | 4 ++-- .../_stage_explorer/_stage_explorer.py | 2 +- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/pymmcore_widgets/control/_rois/_vispy.py b/src/pymmcore_widgets/control/_rois/_vispy.py index c5cf656ae..3dba95f9f 100644 --- a/src/pymmcore_widgets/control/_rois/_vispy.py +++ b/src/pymmcore_widgets/control/_rois/_vispy.py @@ -49,7 +49,7 @@ def update_vertices(self, vertices: np.ndarray) -> None: try: if ( grid := self._roi.create_grid_plan( - overlap=self._roi.fov_overlap, mode=self._roi.acq_mode + overlap=self._roi.fov_overlap, mode=self._roi.scan_order ) ) is not None: for p in grid: 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 ee7ab608f..5915a71c8 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,15 +149,13 @@ 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.index(row).internalPointer() - for row in range(self.roi_model.rowCount()) - ] + 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.""" @@ -175,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,24 +186,24 @@ def _on_data_changed( # Update the ROI on the canvas for row in range(top_left.row(), bottom_right.row() + 1): - roi = self.roi_model.index(row).internalPointer() + roi = self.roi_model.getRoi(row) do_update(roi) 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 c38171001..cb655d54a 100644 --- a/src/pymmcore_widgets/control/_rois/roi_model.py +++ b/src/pymmcore_widgets/control/_rois/roi_model.py @@ -24,7 +24,7 @@ class ROI: fov_size: tuple[float, float] | None = None # (width, height) fov_overlap: tuple[float, float] = (0.0, 0.0) # (width, height) - acq_mode: useq.OrderMode = useq.OrderMode.row_wise_snake + 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).""" @@ -138,7 +138,7 @@ def create_useq_position( ) -> 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, overlap=self.fov_overlap, mode=self.acq_mode + 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) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 0426229f7..99819f9ab 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -391,7 +391,7 @@ def _on_scan_options_changed(self, value: tuple[float, OrderMode]) -> None: # 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 + 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: From 4bb2b0f6ed69566fff1445f489e6aa6e61940178 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 15 Aug 2025 16:15:52 -0400 Subject: [PATCH 30/38] feat: update ROI handling and improve grid plan creation in StageExplorer --- examples/stage_explorer_widget.py | 15 +++++++++ pyproject.toml | 2 +- src/pymmcore_widgets/control/_rois/_vispy.py | 12 +++---- .../control/_rois/roi_manager.py | 2 +- .../control/_rois/roi_model.py | 32 +++++++++---------- .../_stage_explorer/_stage_explorer.py | 27 +++------------- src/pymmcore_widgets/useq_widgets/_grid.py | 6 +++- 7 files changed, 49 insertions(+), 47 deletions(-) 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..f75c2da3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ 'pymmcore-plus[cli] >=0.14.0', '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 3dba95f9f..6bad4efb9 100644 --- a/src/pymmcore_widgets/control/_rois/_vispy.py +++ b/src/pymmcore_widgets/control/_rois/_vispy.py @@ -47,13 +47,13 @@ def update_vertices(self, vertices: np.ndarray) -> None: centers: list[tuple[float, float]] = [] try: - if ( - grid := self._roi.create_grid_plan( - overlap=self._roi.fov_overlap, mode=self._roi.scan_order - ) - ) 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/roi_manager.py b/src/pymmcore_widgets/control/_rois/roi_manager.py index 5915a71c8..63adec102 100644 --- a/src/pymmcore_widgets/control/_rois/roi_manager.py +++ b/src/pymmcore_widgets/control/_rois/roi_manager.py @@ -186,7 +186,7 @@ def _on_data_changed( # Update the ROI on the canvas for row in range(top_left.row(), bottom_right.row() + 1): - roi = self.roi_model.getRoi(row) + roi = self.roi_model.index(row).internalPointer() do_update(roi) def _on_selection_changed( diff --git a/src/pymmcore_widgets/control/_rois/roi_model.py b/src/pymmcore_widgets/control/_rois/roi_model.py index cb655d54a..4c9d85d5c 100644 --- a/src/pymmcore_widgets/control/_rois/roi_model.py +++ b/src/pymmcore_widgets/control/_rois/roi_model.py @@ -93,7 +93,7 @@ def create_grid_plan( fov_h: float | None = None, overlap: float | tuple[float, float] = 0.0, mode: useq.OrderMode = useq.OrderMode.row_wise_snake, - ) -> useq._grid._GridPlan | None: + ) -> 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: @@ -110,13 +110,17 @@ def create_grid_plan( 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, - mode=mode, - overlap=overlap, - ) + 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, @@ -141,18 +145,14 @@ def create_useq_position( 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 99819f9ab..8bf38c63d 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -300,28 +300,11 @@ 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 + def rois_to_useq_positions(self) -> list[useq.AbsolutePosition]: + return [ + roi.create_useq_position(*self._fov_w_h()) + for roi in self.roi_manager.all_rois() + ] # -----------------------------PRIVATE METHODS------------------------------------ diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index cc1cd3dbf..924a4ce19 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -491,12 +491,16 @@ def _redraw(self) -> None: self.scene.clear() if (plan := self._polygon) is None: return + try: + poly = plan.poly + except ValueError: + # likely a self-intersecting polygon that cannot be scanned... + return fw, fh = plan.fov_width or 0, plan.fov_height or 0 pen_size = int(fw * 0.04) if fw > 0 else 1 vertex_radius = center_radius = pen_size - poly = plan.poly verts: list[tuple[float, float]] = list(plan.vertices or []) # draw polygon outline From 34f21df4a108f714d7dea6a22e0afff4601bf5e5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 15 Aug 2025 16:24:44 -0400 Subject: [PATCH 31/38] feat: enforce polygon mode selection validation in GridPlanWidget --- src/pymmcore_widgets/useq_widgets/_grid.py | 24 ++++++++++++---------- tests/useq_widgets/test_useq_widgets.py | 4 ++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 924a4ce19..0d657bb67 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -222,6 +222,12 @@ def setMode( elif isinstance(mode, str): mode = Mode(mode) + if mode is Mode.POLYGON and not self.polygon_wdg._polygon: + raise ValueError( + "Polygon mode may not be selected programmatically without a " + " pre-assigned GridFromPolygon Plan." + ) + previous, self._mode = getattr(self, "_mode", None), mode if previous != self._mode: current_wdg = self._mode_to_widget[self._mode] @@ -459,7 +465,6 @@ def __init__(self) -> None: self.scene = QGraphicsScene() self.view = QGraphicsView(self.scene) self.view.setRenderHint(QPainter.RenderHint.Antialiasing, True) - self.view.setTransform(QTransform.fromScale(1, -1)) # y-up layout = QVBoxLayout(self) @@ -469,16 +474,13 @@ def __init__(self) -> None: # ----------------------------PUBLIC METHODS---------------------------- def value(self) -> dict[str, Any]: - vertices = self._polygon.vertices if self._polygon else [] - convex_hull = self._polygon.convex_hull if self._polygon else False - offset = self._polygon.offset if self._polygon else 0 - if not vertices: - return { - "vertices": [(0, 0), (0, 0), (0, 0)], - "convex_hull": False, - "offset": 0, - } - return {"vertices": vertices, "convex_hull": convex_hull, "offset": offset} + if not self._polygon: + return {} + return { + "vertices": self._polygon.vertices, + "convex_hull": self._polygon.convex_hull, + "offset": self._polygon.offset, + } def setValue(self, plan: useq.GridFromPolygon) -> None: """Set and render the polygon/grid plan.""" diff --git a/tests/useq_widgets/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py index 7cb6711c7..4eff71533 100644 --- a/tests/useq_widgets/test_useq_widgets.py +++ b/tests/useq_widgets/test_useq_widgets.py @@ -418,8 +418,8 @@ def test_grid_plan_widget(qtbot: QtBot) -> None: assert isinstance(wdg.value(), useq.GridRowsColumns) wdg.setMode("area") assert isinstance(wdg.value(), useq.GridWidthHeight) - wdg.setMode("polygon") - assert isinstance(wdg.value(), useq.GridFromPolygon) + with pytest.raises(ValueError, match="Polygon mode may not be selected"): + wdg.setMode("polygon") plan = useq.GridRowsColumns(rows=3, columns=3, mode="spiral", overlap=10) with qtbot.waitSignal(wdg.valueChanged): From a545c528cf7d9b8f6ea0033f82086ea3669b03e7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 15 Aug 2025 16:34:11 -0400 Subject: [PATCH 32/38] remove unused method --- .../control/_stage_explorer/_stage_explorer.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 8bf38c63d..a8e2dd689 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -300,12 +300,6 @@ 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]: - return [ - roi.create_useq_position(*self._fov_w_h()) - for roi in self.roi_manager.all_rois() - ] - # -----------------------------PRIVATE METHODS------------------------------------ # ACTIONS ---------------------------------------------------------------------- From 30554c31a7f21df59c96f36ab5d9fce6f4dd979c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 15 Aug 2025 16:40:55 -0400 Subject: [PATCH 33/38] remove try --- .../control/_stage_explorer/_stage_explorer.py | 4 ++-- src/pymmcore_widgets/useq_widgets/_grid.py | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index a8e2dd689..69ca3651a 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -379,9 +379,9 @@ def _on_roi_rows_inserted(self, parent: QModelIndex, first: int, last: int) -> N """ overlap, mode = self._toolbar.scan_menu.value() for row in range(first, last + 1): - roi = self.roi_manager.roi_model.index(row).internalPointer() + roi = self.roi_manager.roi_model.getRoi(row) roi.fov_overlap = (overlap, overlap) - roi.acq_mode = mode + roi.scan_order = mode self.roi_manager.roi_model.emitDataChange(roi) def keyPressEvent(self, a0: QKeyEvent | None) -> None: diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 0d657bb67..076e029db 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -493,11 +493,6 @@ def _redraw(self) -> None: self.scene.clear() if (plan := self._polygon) is None: return - try: - poly = plan.poly - except ValueError: - # likely a self-intersecting polygon that cannot be scanned... - return fw, fh = plan.fov_width or 0, plan.fov_height or 0 pen_size = int(fw * 0.04) if fw > 0 else 1 @@ -506,6 +501,7 @@ def _redraw(self) -> None: verts: list[tuple[float, float]] = list(plan.vertices or []) # draw polygon outline + poly = plan.poly poly_item = self._make_polygon_item(poly) self.POLY_PEN.setWidth(pen_size) poly_item.setPen(self.POLY_PEN) From 63169c89ec47688c978b8a93bac3c36f2242f4bc Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 15 Aug 2025 16:42:16 -0400 Subject: [PATCH 34/38] pragma --- src/pymmcore_widgets/useq_widgets/_grid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 076e029db..4fab620b7 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -474,7 +474,7 @@ def __init__(self) -> None: # ----------------------------PUBLIC METHODS---------------------------- def value(self) -> dict[str, Any]: - if not self._polygon: + if not self._polygon: # pragma: no cover return {} return { "vertices": self._polygon.vertices, From 2b49153b6a4c4487109af2c4a2f8436502e2be83 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 18 Aug 2025 16:55:08 -0500 Subject: [PATCH 35/38] remove unused resize event --- src/pymmcore_widgets/useq_widgets/_grid.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 4fab620b7..6263165a2 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -11,7 +11,6 @@ QPainterPath, QPen, QPolygonF, - QResizeEvent, QTransform, ) from qtpy.QtWidgets import ( @@ -603,10 +602,6 @@ def _compute_centers(self, plan: useq.GridFromPolygon) -> list[tuple[float, floa centers.append((float(x), float(y))) return centers - def resizeEvent(self, a0: QResizeEvent | None) -> None: - super().resizeEvent(a0) - self._fit_view_to_items() - class _ResizableStackedWidget(QStackedWidget): def __init__(self, parent: QWidget | None = None) -> None: From 1f85b74dd811302eaf2bed96bec88056e561fccf Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 18 Aug 2025 17:00:36 -0500 Subject: [PATCH 36/38] feat: update polygon widget value on parameter changes in GridPlanWidget --- src/pymmcore_widgets/useq_widgets/_grid.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 6263165a2..fc1e5018d 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -314,6 +314,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) From 9e8b5baee22affd7e0334292f91a65c1b78997de Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Aug 2025 11:51:06 -0500 Subject: [PATCH 37/38] resizeEvent --- src/pymmcore_widgets/useq_widgets/_grid.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index fc1e5018d..071db773b 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -11,6 +11,7 @@ QPainterPath, QPen, QPolygonF, + QResizeEvent, QTransform, ) from qtpy.QtWidgets import ( @@ -607,6 +608,10 @@ def _compute_centers(self, plan: useq.GridFromPolygon) -> list[tuple[float, floa centers.append((float(x), float(y))) return centers + def resizeEvent(self, a0: QResizeEvent | None) -> None: + super().resizeEvent(a0) + self._fit_view_to_items() + class _ResizableStackedWidget(QStackedWidget): def __init__(self, parent: QWidget | None = None) -> None: From 64b88781b009423dfaadd86f53d530f0275fdbf0 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Aug 2025 15:22:00 -0500 Subject: [PATCH 38/38] fix: update pymmcore-plus dependency version to 0.15.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f75c2da3c..29903faca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ 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.1',