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_manager.py b/src/pymmcore_widgets/control/_rois/roi_manager.py index a5ab1b95f..ee7ab608f 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(): diff --git a/src/pymmcore_widgets/control/_rois/roi_model.py b/src/pymmcore_widgets/control/_rois/roi_model.py index 2167f6a2d..3dbbe00f3 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) @@ -129,7 +23,8 @@ class ROI: font_size: int = 12 fov_size: tuple[float, float] | None = None # (width, height) - fov_overlap: tuple[float, float] | None = None # frac (width, height) 0..1 + fov_overlap: tuple[float, float] = (0.0, 0.0) # (width, height) + acq_mode: useq.OrderMode = useq.OrderMode.row_wise_snake def translate(self, dx: float, dy: float) -> None: """Translate the ROI in place by (dx, dy).""" @@ -196,6 +91,8 @@ def create_grid_plan( self, fov_w: float | None = None, fov_h: float | None = None, + overlap: float | tuple[float, float] = 0.0, + mode: useq.OrderMode = useq.OrderMode.row_wise_snake, ) -> useq._grid._GridPlan | None: """Return a useq.AbsolutePosition object that covers the ROI.""" if fov_w is None or fov_h is None: @@ -209,13 +106,16 @@ def create_grid_plan( # a single position at the center of the roi is sufficient, otherwise create a # grid plan that covers the roi if abs(right - left) > fov_w or abs(bottom - top) > fov_h: + overlap = overlap if isinstance(overlap, tuple) else (overlap, overlap) if type(self) is not RectangleROI: if len(self.vertices) < 3: return None - return GridFromPolygon( - vertices=self.vertices, + return useq.GridFromPolygon( # type: ignore # until new useq-schema + vertices=list(self.vertices), fov_width=fov_w, fov_height=fov_h, + mode=mode, + overlap=overlap, ) else: return useq.GridFromEdges( @@ -225,6 +125,8 @@ def create_grid_plan( right=right, fov_width=fov_w, fov_height=fov_h, + mode=mode, + overlap=overlap, ) return None @@ -235,7 +137,9 @@ def create_useq_position( z_pos: float = 0.0, ) -> useq.AbsolutePosition: """Return a useq.AbsolutePosition object that covers the ROI.""" - grid_plan = self.create_grid_plan(fov_w=fov_w, fov_h=fov_h) + grid_plan = self.create_grid_plan( + fov_w=fov_w, fov_h=fov_h, overlap=self.fov_overlap, mode=self.acq_mode + ) x, y = self.center() pos = useq.AbsolutePosition(x=x, y=y, z=z_pos) if grid_plan is None: diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 451c00a8c..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) @@ -291,6 +300,29 @@ def zoom_to_fit(self, *, margin: float = 0.05) -> None: x_bounds, y_bounds, *_ = get_vispy_scene_bounds(visuals) self._stage_viewer.view.camera.set_range(x=x_bounds, y=y_bounds, margin=margin) + def rois_to_useq_positions(self) -> list[useq.AbsolutePosition] | None: + if not (rois := self.roi_manager.all_rois()): + return None + + positions: list[useq.AbsolutePosition] = [] + for idx, roi in enumerate(rois): + overlap, mode = self._toolbar.scan_menu.value() + if plan := roi.create_grid_plan(*self._fov_w_h(), overlap, mode): + p: useq.AbsolutePosition = next(iter(plan.iter_grid_positions())) + pos = useq.AbsolutePosition( + name=f"ROI_{idx}", + x=p.x, + y=p.y, + z=p.z, + sequence=useq.MDASequence(grid_plan=plan), + ) + positions.append(pos) + + if not positions: + return None + + return positions + # -----------------------------PRIVATE METHODS------------------------------------ # ACTIONS ---------------------------------------------------------------------- @@ -339,18 +371,42 @@ def _update_marker_mode(self) -> None: self._stage_pos_marker.set_marker_visible(pi.show_marker) def _on_scan_action(self) -> None: - """Scan the selected ROIs.""" + """Scan the selected ROI.""" if not (active_rois := self.roi_manager.selected_rois()): return active_roi = active_rois[0] - if plan := active_roi.create_grid_plan(*self._fov_w_h()): - # for now, we expand the grid plan to a list of positions because - # useq grid_plan= doesn't yet support our custom polygon ROIs - seq = useq.MDASequence(stage_positions=list(plan)) + + overlap, mode = self._toolbar.scan_menu.value() + if plan := active_roi.create_grid_plan(*self._fov_w_h(), overlap, mode): + seq = useq.MDASequence(grid_plan=plan) if not self._mmc.mda.is_running(): self._our_mda_running = True self._mmc.run_mda(seq) + def _on_scan_options_changed(self, value: tuple[float, OrderMode]) -> None: + """Update all ROIs with the new overlap so the vispy visuals refresh.""" + # store locally in case callers want to use it + self._grid_overlap, self._grid_mode = value + + # update ROIs and emit model dataChanged so visuals update + for roi in self.roi_manager.all_rois(): + roi.fov_overlap = (self._grid_overlap, self._grid_overlap) + roi.acq_mode = self._grid_mode + self.roi_manager.roi_model.emitDataChange(roi) + + def _on_roi_rows_inserted(self, parent: QModelIndex, first: int, last: int) -> None: + """Initialize newly-inserted ROIs with the current scan menu values. + + This ensures ROIs created after adjusting the scan options start with the + chosen overlap and acquisition order. + """ + overlap, mode = self._toolbar.scan_menu.value() + for row in range(first, last + 1): + roi = self.roi_manager.roi_model.index(row).internalPointer() + roi.fov_overlap = (overlap, overlap) + roi.acq_mode = mode + self.roi_manager.roi_model.emitDataChange(roi) + def keyPressEvent(self, a0: QKeyEvent | None) -> None: if a0 is None: return @@ -431,7 +487,6 @@ def _on_frame_ready(self, image: np.ndarray, event: useq.MDAEvent) -> None: def _on_poll_stage_action(self, checked: bool) -> None: """Set the poll stage position property based on the state of the action.""" self._stage_pos_marker.visible = checked - print("Stage position marker visible:", self._stage_pos_marker.visible) self._poll_stage_position = checked if checked: self._timer_id = self.startTimer(20) @@ -583,8 +638,57 @@ def __init__(self, parent: QWidget | None = None): self.addSeparator() self.scan_action = self.addAction( QIconifyIcon("ph:path-duotone", color=GRAY), - "Scan Selected ROIs", + "Scan Selected ROI", ) + scan_btn = cast("QToolButton", self.widgetForAction(self.scan_action)) + self.scan_menu = ScanMenu(self) + scan_btn.setMenu(self.scan_menu) + scan_btn.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) + + +class ScanMenu(QMenu): + """Menu widget that exposes scan grid options.""" + + valueChanged = Signal(object) + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setTitle("Scan Selected ROI") + + # container widget for form layout + opts_widget = QWidget(self) + form = QFormLayout(opts_widget) + form.setContentsMargins(8, 8, 8, 8) + form.setSpacing(6) + + # overlap spinbox + self._overlap_spin = QDoubleSpinBox(opts_widget) + self._overlap_spin.setDecimals(2) + self._overlap_spin.setRange(-100, 100) + self._overlap_spin.setSingleStep(1) + form.addRow("Overlap", self._overlap_spin) + + # acquisition mode combo + self._mode_cbox = QEnumComboBox(self, OrderMode) + self._mode_cbox.setCurrentEnum(OrderMode.row_wise_snake) + form.addRow("Order", self._mode_cbox) + + # wrap in a QWidgetAction so it shows as a menu panel + self.opts_action = QWidgetAction(self) + self.opts_action.setDefaultWidget(opts_widget) + self.addAction(self.opts_action) + + self._overlap_spin.valueChanged.connect(self._on_value_changed) + self._mode_cbox.currentTextChanged.connect(self._on_value_changed) + + def value(self) -> tuple[float, useq.OrderMode]: + """Return the current grid overlap and order mode.""" + return self._overlap_spin.value(), cast( + "OrderMode", self._mode_cbox.currentEnum() + ) + + def _on_value_changed(self) -> None: + self.valueChanged.emit(self.value()) SLOTS = {"slots": True} if sys.version_info >= (3, 10) else {} 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..874c3b08f 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 @@ -418,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" + + 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 == 3 + 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 +570,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() diff --git a/x.py b/x.py new file mode 100644 index 000000000..219db991d --- /dev/null +++ b/x.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()