diff --git a/pyproject.toml b/pyproject.toml index 6e523ffd..18522243 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "ezdxf>=1.4.3", "ndv[vispy]>=0.5.0rc4", "ome-writers[tensorstore,tifffile]>=0.3.0", "pydantic-settings>=2.7.1", diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 91f3b2aa..ef93409a 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -340,6 +340,7 @@ def get_widget(self, key: str, create: bool = True) -> QWidget: return widget self._action_widgets[key] = widget + self._link_related_widgets(key, widget) action = self.get_action(key) dock = CDockWidget(self.dock_manager, info.text, self) @@ -411,6 +412,16 @@ def _on_system_config_loaded(self) -> None: settings.last_config = None settings.flush() + def _link_related_widgets(self, key: str, widget: QWidget) -> None: + if key == WidgetAction.MDA_WIDGET: + if explorer := self._action_widgets.get(WidgetAction.STAGE_EXPLORER): + if hasattr(explorer, "set_mda_widget"): + explorer.set_mda_widget(widget) + elif key == WidgetAction.STAGE_EXPLORER: + if mda_widget := self._action_widgets.get(WidgetAction.MDA_WIDGET): + if hasattr(widget, "set_mda_widget"): + widget.set_mda_widget(mda_widget) + def _add_toolbar(self, name: str, tb_entry: ToolDictValue) -> None: if callable(tb_entry): tb = tb_entry(self._mmc, self) diff --git a/src/pymmcore_gui/actions/widget_actions.py b/src/pymmcore_gui/actions/widget_actions.py index 634b5e6c..769890f4 100644 --- a/src/pymmcore_gui/actions/widget_actions.py +++ b/src/pymmcore_gui/actions/widget_actions.py @@ -192,9 +192,9 @@ def create_config_wizard(parent: QWidget) -> pmmw.ConfigWizard: def create_stage_explorer_widget(parent: QWidget) -> pmmw.StageExplorer: """Create the Stage Explorer widget.""" - from pymmcore_widgets import StageExplorer + from pymmcore_gui.widgets._mda_stage_explorer import MDALinkedStageExplorer - return StageExplorer(parent=parent, mmcore=_get_core(parent)) + return MDALinkedStageExplorer(parent=parent, mmcore=_get_core(parent)) # ######################## WidgetAction Enum ######################### @@ -317,5 +317,4 @@ def create_about_widget(parent: QWidget) -> QWidget: icon="mdi:map-search", create_widget=create_stage_explorer_widget, dock_area=DockWidgetArea.LeftDockWidgetArea, - floatable=False, ) diff --git a/src/pymmcore_gui/widgets/_chip_dxf.py b/src/pymmcore_gui/widgets/_chip_dxf.py new file mode 100644 index 00000000..2ab6d290 --- /dev/null +++ b/src/pymmcore_gui/widgets/_chip_dxf.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from math import hypot, pi +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from collections.abc import Iterable + + +@dataclass(slots=True) +class ChipCurve: + points: np.ndarray + closed: bool = False + + +@dataclass(slots=True) +class ChipOverlayData: + curves: list[ChipCurve] + reference_points: list[tuple[float, float]] + source: Path | None + + +def load_chip_overlay_data(path: str | Path) -> ChipOverlayData: + """Load a DXF file and extract a 2D XY projection for overlay display.""" + src = Path(path) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="'.*' deprecated - use '.*'", + category=DeprecationWarning, + module="pyparsing.*", + ) + import ezdxf + + doc = ezdxf.readfile(src) + curves: list[ChipCurve] = [] + ref_points: list[tuple[float, float]] = [] + + for entity in doc.modelspace(): + if entity.dxftype() == "3DSOLID": + sat_lines = getattr(entity, "sat", None) or getattr(entity, "acis_data", ()) + c, pts = _curves_from_acis_lines(sat_lines) + curves.extend(c) + ref_points.extend(pts) + + return ChipOverlayData( + curves=curves, reference_points=_unique_points(ref_points), source=src + ) + + +def _curves_from_acis_lines( + lines: Iterable[str], +) -> tuple[list[ChipCurve], list[tuple[float, float]]]: + curves: list[ChipCurve] = [] + points: list[tuple[float, float]] = [] + + for line in lines: + text = line.strip() + if text.startswith("straight-curve"): + segment = _parse_straight_curve(text) + if segment is not None: + curves.append( + ChipCurve(points=np.asarray(segment, dtype=float), closed=False) + ) + points.extend((tuple(segment[0]), tuple(segment[-1]))) + elif text.startswith("ellipse-curve"): + ellipse = _parse_ellipse_curve(text) + if ellipse is not None: + curves.append(ChipCurve(points=ellipse, closed=True)) + points.extend(_sample_reference_points(ellipse)) + elif text.startswith("point"): + pt = _parse_point(text) + if pt is not None: + points.append(pt) + + return curves, points + + +def _parse_straight_curve(line: str) -> np.ndarray | None: + values = _extract_numeric_tokens(line) + if len(values) < 8: + return None + + origin = np.array(values[0:3], dtype=float) + direction = np.array(values[3:6], dtype=float) + scalars = values[6:] + + if len(scalars) >= 2: + start = origin + direction * scalars[0] + end = origin + direction * scalars[1] + else: + start = origin + end = origin + direction + + return np.array([[start[0], start[1]], [end[0], end[1]]], dtype=float) + + +def _parse_ellipse_curve(line: str, samples: int = 64) -> np.ndarray | None: + values = _extract_numeric_tokens(line) + if len(values) < 9: + return None + + center = np.array(values[0:3], dtype=float) + major = np.array(values[6:9], dtype=float) + radius = hypot(float(major[0]), float(major[1])) + if radius <= 0: + return None + + angles = np.linspace(0, 2 * pi, samples, endpoint=False) + pts = np.column_stack( + ( + center[0] + radius * np.cos(angles), + center[1] + radius * np.sin(angles), + ) + ) + return pts.astype(float) + + +def _parse_point(line: str) -> tuple[float, float] | None: + values = _extract_numeric_tokens(line) + if len(values) < 2: + return None + return float(values[0]), float(values[1]) + + +def _extract_numeric_tokens(text: str) -> list[float]: + if "$-1" in text: + text = text.rsplit("$-1", maxsplit=1)[-1] + + out: list[float] = [] + for token in text.replace("#", " ").split(): + try: + out.append(float(token)) + except ValueError: + continue + return out + + +def _sample_reference_points(points: np.ndarray) -> list[tuple[float, float]]: + if len(points) < 4: + return [tuple(p) for p in points] + idxs = (0, len(points) // 4, len(points) // 2, (3 * len(points)) // 4) + return [tuple(points[i]) for i in idxs] + + +def _unique_points( + points: Iterable[tuple[float, float]], precision: int = 3 +) -> list[tuple[float, float]]: + seen: set[tuple[float, float]] = set() + out: list[tuple[float, float]] = [] + for x, y in points: + key = (round(float(x), precision), round(float(y), precision)) + if key in seen: + continue + seen.add(key) + out.append((float(x), float(y))) + return out diff --git a/src/pymmcore_gui/widgets/_mda_stage_explorer.py b/src/pymmcore_gui/widgets/_mda_stage_explorer.py new file mode 100644 index 00000000..8fff5767 --- /dev/null +++ b/src/pymmcore_gui/widgets/_mda_stage_explorer.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +from pymmcore_widgets import StageExplorer +from pymmcore_widgets.control._stage_explorer._stage_position_marker import ( + StagePositionMarker, +) +from pymmcore_widgets.control._stage_explorer._stage_viewer import ( + get_vispy_scene_bounds, +) +from vispy.color import Color +from vispy.scene.visuals import Line, Markers, Text + +from pymmcore_gui._qt.QtCore import Qt, QTimer +from pymmcore_gui._qt.QtWidgets import QFileDialog +from pymmcore_gui.widgets._chip_dxf import ( + ChipCurve, + ChipOverlayData, + load_chip_overlay_data, +) + +if TYPE_CHECKING: + import useq + from vispy.app.canvas import MouseEvent + + from pymmcore_gui.actions.widget_actions import MDAWidget + + +@dataclass(slots=True) +class MDAStagePosition: + row: int + x: float + y: float + name: str + enabled: bool + + +class MDALinkedStageExplorer(StageExplorer): + """StageExplorer extension that mirrors positions from an MDAWidget.""" + + _MDA_NORMAL = "#3FA7FF" + _MDA_SELECTED = "#FFB020" + _MDA_ACTIVE = "#22C55E" + _MDA_DISABLED = "#7F8C8D" + _CHIP_COLOR = "#E2E8F0" + _CHIP_REF_COLOR = "#F43F5E" + + def __init__(self, parent=None, mmcore=None): + super().__init__(parent=parent, mmcore=mmcore) + self._mda_widget: MDAWidget | None = None + self._mda_positions: list[MDAStagePosition] = [] + self._selected_row: int | None = None + self._active_row: int | None = None + self._mda_overlays: dict[int, tuple[StagePositionMarker, Text]] = {} + self._chip_overlay_data: ChipOverlayData | None = None + self._chip_curves_visuals: list[Line] = [] + self._chip_reference_marker: Markers | None = None + self._chip_reference_label: Text | None = None + self._chip_selected_reference: tuple[float, float] | None = None + self._chip_stage_offset_um = np.zeros(2, dtype=float) + self._calibration_pick_mode = False + self._refresh_pending = False + + self._show_mda_positions = self._toolbar.addAction("Show MDA Positions") + self._show_mda_positions.setCheckable(True) + self._show_mda_positions.setChecked(True) + self._show_mda_positions.toggled.connect(self._set_mda_overlays_visible) + + self._zoom_to_mda_positions = self._toolbar.addAction("Zoom to MDA Positions") + self._zoom_to_mda_positions.triggered.connect(self.zoom_to_mda_positions) + self._toolbar.addSeparator() + self._load_chip_overlay_action = self._toolbar.addAction("Load Chip DXF") + self._load_chip_overlay_action.triggered.connect(self._load_chip_overlay) + self._toggle_chip_overlay_action = self._toolbar.addAction("Show Chip Overlay") + self._toggle_chip_overlay_action.setCheckable(True) + self._toggle_chip_overlay_action.setChecked(True) + self._toggle_chip_overlay_action.toggled.connect(self._set_chip_overlay_visible) + self._pick_chip_ref_action = self._toolbar.addAction("Pick Chip Ref") + self._pick_chip_ref_action.setCheckable(True) + self._pick_chip_ref_action.toggled.connect(self._set_pick_chip_reference_mode) + self._set_chip_ref_action = self._toolbar.addAction("Set Ref To Stage") + self._set_chip_ref_action.triggered.connect( + self._set_chip_reference_to_current_stage + ) + self._clear_chip_ref_action = self._toolbar.addAction("Reset Chip Ref") + self._clear_chip_ref_action.triggered.connect(self._reset_chip_reference) + self._set_chip_controls_enabled(False) + + self._stage_viewer.canvas.events.mouse_press.connect(self._on_mouse_press) + self._mmc.events.roiSet.connect(self.refresh_mda_positions) + self._mmc.events.pixelSizeChanged.connect(self._on_mda_geometry_changed) + self._mmc.events.pixelSizeAffineChanged.connect(self.refresh_mda_positions) + self._mmc.mda.events.sequenceFinished.connect(self._on_mda_finished) + + def set_mda_widget(self, mda_widget: MDAWidget | None) -> None: + if mda_widget is self._mda_widget: + return + self._disconnect_mda_widget() + self._mda_widget = mda_widget + if mda_widget is None: + self._clear_mda_overlays() + return + + table = mda_widget.stage_positions.table() + mda_widget.stage_positions.valueChanged.connect(self.schedule_mda_refresh) + table.itemSelectionChanged.connect(self._sync_selection_from_table) + if model := table.model(): + model.dataChanged.connect(self.schedule_mda_refresh) + model.rowsInserted.connect(self.schedule_mda_refresh) + model.rowsRemoved.connect(self.schedule_mda_refresh) + model.modelReset.connect(self.schedule_mda_refresh) + self.schedule_mda_refresh() + self._sync_selection_from_table() + + def schedule_mda_refresh(self, *_args) -> None: + if self._refresh_pending: + return + self._refresh_pending = True + QTimer.singleShot(0, self.refresh_mda_positions) + + def refresh_mda_positions(self, *_args) -> None: + self._refresh_pending = False + self._clear_mda_overlays() + if self._mda_widget is None or not self._show_mda_positions.isChecked(): + return + + try: + table = self._mda_widget.stage_positions.table() + name_col = table.indexOf(self._mda_widget.stage_positions.NAME) + x_key = self._mda_widget.stage_positions.X.key + y_key = self._mda_widget.stage_positions.Y.key + except RuntimeError: + return + + positions: list[MDAStagePosition] = [] + for row in range(table.rowCount()): + data = table.rowData(row, exclude_hidden_cols=True) + x = data.get(x_key) + y = data.get(y_key) + if x is None or y is None: + continue + + name_item = table.item(row, name_col) + name = name_item.text() if name_item and name_item.text() else f"P{row + 1}" + enabled = True + if name_item is not None and bool( + name_item.flags() & Qt.ItemFlag.ItemIsUserCheckable + ): + enabled = name_item.checkState() != Qt.CheckState.Unchecked + + positions.append( + MDAStagePosition( + row=row, + x=float(x), + y=float(y), + name=name, + enabled=enabled, + ) + ) + + self._mda_positions = positions + if self._active_row not in {p.row for p in positions}: + self._active_row = None + self._rebuild_mda_overlays() + self._sync_selection_from_table() + + def zoom_to_mda_positions(self) -> None: + if not self._mda_overlays: + return + visuals = [marker for marker, _text in self._mda_overlays.values()] + x_bounds, y_bounds, *_ = get_vispy_scene_bounds(visuals) + self._stage_viewer.view.camera.set_range(x=x_bounds, y=y_bounds, margin=0.08) + + def _set_chip_controls_enabled(self, enabled: bool) -> None: + self._toggle_chip_overlay_action.setEnabled(enabled) + self._pick_chip_ref_action.setEnabled(enabled) + self._set_chip_ref_action.setEnabled(enabled) + self._clear_chip_ref_action.setEnabled(enabled) + + def _on_mda_geometry_changed(self, _value: float) -> None: + self.refresh_mda_positions() + + def _disconnect_mda_widget(self) -> None: + if self._mda_widget is None: + return + table = self._mda_widget.stage_positions.table() + with suppress(Exception): + self._mda_widget.stage_positions.valueChanged.disconnect( + self.schedule_mda_refresh + ) + with suppress(Exception): + table.itemSelectionChanged.disconnect(self._sync_selection_from_table) + if model := table.model(): + with suppress(Exception): + model.dataChanged.disconnect(self.schedule_mda_refresh) + with suppress(Exception): + model.rowsInserted.disconnect(self.schedule_mda_refresh) + with suppress(Exception): + model.rowsRemoved.disconnect(self.schedule_mda_refresh) + with suppress(Exception): + model.modelReset.disconnect(self.schedule_mda_refresh) + + def _clear_mda_overlays(self) -> None: + for marker, label in self._mda_overlays.values(): + marker.parent = None + label.parent = None + self._mda_overlays.clear() + + def _clear_chip_overlay(self) -> None: + for visual in self._chip_curves_visuals: + visual.parent = None + self._chip_curves_visuals.clear() + if self._chip_reference_marker is not None: + self._chip_reference_marker.parent = None + self._chip_reference_marker = None + if self._chip_reference_label is not None: + self._chip_reference_label.parent = None + self._chip_reference_label = None + + def _rebuild_mda_overlays(self) -> None: + if not self._mda_positions: + return + + img_w = self._mmc.getImageWidth() or 1 + img_h = self._mmc.getImageHeight() or 1 + _, fov_h = self._fov_w_h() + label_offset = max(fov_h * 0.55, 25.0) + + for pos in self._mda_positions: + color = self._MDA_NORMAL if pos.enabled else self._MDA_DISABLED + marker = StagePositionMarker( + parent=self._stage_viewer.view.scene, + rect_width=img_w, + rect_height=img_h, + rect_color=color, + marker_symbol_color=color, + marker_symbol_size=max(min(img_w, img_h) / 12, 6), + ) + marker.apply_transform( + self._affine_state.system_affine_translated(pos.x, pos.y).T + ) + marker.visible = True + + label = Text( + text=pos.name, + pos=(pos.x, pos.y + label_offset), + color=Color(color), + font_size=10, + anchor_x="center", + anchor_y="bottom", + parent=self._stage_viewer.view.scene, + ) + self._mda_overlays[pos.row] = (marker, label) + + def _set_mda_overlays_visible(self, visible: bool) -> None: + if not visible: + self._clear_mda_overlays() + return + self.refresh_mda_positions() + + def _load_chip_overlay(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + "Open chip DXF", + str(Path.home()), + "DXF Files (*.dxf)", + ) + if not path: + return + self.load_chip_overlay(path) + + def load_chip_overlay(self, path: str | Path) -> None: + self._chip_overlay_data = load_chip_overlay_data(path) + self._chip_selected_reference = None + self._chip_stage_offset_um = np.zeros(2, dtype=float) + self._set_chip_controls_enabled(True) + self._toggle_chip_overlay_action.setChecked(True) + self._rebuild_chip_overlay() + + def _chip_curves_in_stage_coordinates(self) -> list[ChipCurve]: + if self._chip_overlay_data is None: + return [] + offset = self._chip_stage_offset_um + return [ + ChipCurve(points=curve.points + offset, closed=curve.closed) + for curve in self._chip_overlay_data.curves + ] + + def _rebuild_chip_overlay(self) -> None: + self._clear_chip_overlay() + if ( + self._chip_overlay_data is None + or not self._toggle_chip_overlay_action.isChecked() + ): + return + + for curve in self._chip_curves_in_stage_coordinates(): + pts = curve.points + if curve.closed and len(pts) > 1: + pts = np.vstack([pts, pts[0]]) + visual = Line( + pos=np.column_stack([pts, np.zeros(len(pts))]), + color=Color(self._CHIP_COLOR), + width=1.0, + parent=self._stage_viewer.view.scene, + connect="strip", + ) + self._chip_curves_visuals.append(visual) + + self._rebuild_chip_reference_visual() + + def _rebuild_chip_reference_visual(self) -> None: + if self._chip_selected_reference is None: + return + point = np.array( + [ + [ + self._chip_selected_reference[0] + self._chip_stage_offset_um[0], + self._chip_selected_reference[1] + self._chip_stage_offset_um[1], + ] + ], + dtype=float, + ) + self._chip_reference_marker = Markers( + pos=np.column_stack([point, np.zeros(len(point))]), + symbol="x", + face_color=Color(self._CHIP_REF_COLOR), + edge_color=Color(self._CHIP_REF_COLOR), + size=14, + edge_width=2, + parent=self._stage_viewer.view.scene, + ) + self._chip_reference_label = Text( + text="Chip Ref", + pos=(point[0, 0], point[0, 1] + 40), + color=Color(self._CHIP_REF_COLOR), + font_size=11, + anchor_x="center", + anchor_y="bottom", + parent=self._stage_viewer.view.scene, + ) + + def _set_chip_overlay_visible(self, visible: bool) -> None: + if not visible: + self._clear_chip_overlay() + return + self._rebuild_chip_overlay() + + def _set_pick_chip_reference_mode(self, checked: bool) -> None: + self._calibration_pick_mode = checked + + def _nearest_chip_reference( + self, world_x: float, world_y: float + ) -> tuple[float, float] | None: + if ( + self._chip_overlay_data is None + or not self._chip_overlay_data.reference_points + ): + return None + + target = np.array([world_x, world_y], dtype=float) - self._chip_stage_offset_um + nearest: tuple[float, tuple[float, float]] | None = None + for point in self._chip_overlay_data.reference_points: + dx = target[0] - point[0] + dy = target[1] - point[1] + dist2 = dx * dx + dy * dy + if nearest is None or dist2 < nearest[0]: + nearest = (dist2, point) + return nearest[1] if nearest is not None else None + + def _set_chip_reference_to_current_stage(self) -> None: + if self._chip_selected_reference is None or not self._mmc.getXYStageDevice(): + return + x, y = self._mmc.getXYPosition() + self._chip_stage_offset_um = np.array( + [ + x - self._chip_selected_reference[0], + y - self._chip_selected_reference[1], + ], + dtype=float, + ) + self._rebuild_chip_overlay() + + def _reset_chip_reference(self) -> None: + self._chip_stage_offset_um = np.zeros(2, dtype=float) + self._chip_selected_reference = None + self._pick_chip_ref_action.setChecked(False) + self._rebuild_chip_overlay() + + def _sync_selection_from_table(self) -> None: + self._selected_row = None + if self._mda_widget is None: + return + + selected_rows = { + idx.row() + for idx in self._mda_widget.stage_positions.table().selectedIndexes() + } + self._selected_row = ( + next(iter(selected_rows), None) if len(selected_rows) == 1 else None + ) + self._update_overlay_styles() + + def _update_overlay_styles(self) -> None: + for pos in self._mda_positions: + marker, label = self._mda_overlays.get(pos.row, (None, None)) + if marker is None or label is None: + continue + + if pos.row == self._active_row: + color = self._MDA_ACTIVE + elif pos.row == self._selected_row: + color = self._MDA_SELECTED + elif pos.enabled: + color = self._MDA_NORMAL + else: + color = self._MDA_DISABLED + + marker._rect.border_color = Color(color) + marker._marker.set_data( + pos=np.array([[0.0, 0.0]], dtype=float), + symbol="++", + face_color=Color(color), + edge_color=Color(color), + size=max( + min(self._mmc.getImageWidth() or 1, self._mmc.getImageHeight() or 1) + / 12, + 6, + ), + edge_width=2, + ) + label.color = Color(color) + marker.update() + label.update() + + def _set_active_row(self, row: int | None) -> None: + if row == self._active_row: + return + self._active_row = row + self._update_overlay_styles() + + def _match_event_to_position(self, event: useq.MDAEvent) -> int | None: + if not self._mda_positions: + return None + + if event.pos_name: + for pos in self._mda_positions: + if pos.name == event.pos_name: + return pos.row + + if event.x_pos is None or event.y_pos is None: + return None + + nearest = self._nearest_position(float(event.x_pos), float(event.y_pos)) + return nearest.row if nearest is not None else None + + def _nearest_position( + self, world_x: float, world_y: float + ) -> MDAStagePosition | None: + if not self._mda_positions: + return None + fov_w, fov_h = self._fov_w_h() + hit_radius = max(fov_w, fov_h) * 0.6 + + nearest: tuple[float, MDAStagePosition] | None = None + for pos in self._mda_positions: + dx = world_x - pos.x + dy = world_y - pos.y + dist2 = dx * dx + dy * dy + if dist2 > hit_radius * hit_radius: + continue + if nearest is None or dist2 < nearest[0]: + nearest = (dist2, pos) + return nearest[1] if nearest is not None else None + + def _on_mouse_press(self, event: MouseEvent) -> None: + world_x, world_y = self._stage_viewer.canvas_to_world(event.pos) + + if self._calibration_pick_mode and self._chip_overlay_data is not None: + point = self._nearest_chip_reference(world_x, world_y) + if point is not None: + self._chip_selected_reference = point + self._rebuild_chip_overlay() + self._pick_chip_ref_action.setChecked(False) + return + + if self._mda_widget is None or not self._show_mda_positions.isChecked(): + return + if getattr(event, "button", None) not in (1,): + return + + pos = self._nearest_position(world_x, world_y) + if pos is None: + return + + table = self._mda_widget.stage_positions.table() + table.clearSelection() + table.selectRow(pos.row) + self._selected_row = pos.row + self._update_overlay_styles() + + def _on_mouse_double_click(self, event: MouseEvent) -> None: + if self._mda_widget is not None and self._show_mda_positions.isChecked(): + world_x, world_y = self._stage_viewer.view.camera.transform.imap(event.pos)[ + :2 + ] + pos = self._nearest_position(float(world_x), float(world_y)) + if pos is not None and self._stage_controller is not None: + self._stage_controller.move_absolute((pos.x, pos.y)) + self._stage_controller.snap_on_finish = self._snap_on_double_click + self._stage_pos_label.setText(f"X: {pos.x:.2f} um Y: {pos.y:.2f} um") + return + super()._on_mouse_double_click(event) + + def _on_frame_ready(self, image: np.ndarray, event: useq.MDAEvent) -> None: + super()._on_frame_ready(image, event) + self._set_active_row(self._match_event_to_position(event)) + + def _on_mda_finished(self, _sequence: useq.MDASequence) -> None: + self._set_active_row(None) + + def closeEvent(self, event) -> None: + self._disconnect_mda_widget() + self._clear_mda_overlays() + self._clear_chip_overlay() + super().closeEvent(event) diff --git a/tests/test_chip_dxf.py b/tests/test_chip_dxf.py new file mode 100644 index 00000000..fdbb9da0 --- /dev/null +++ b/tests/test_chip_dxf.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import numpy as np + +from pymmcore_gui.widgets._chip_dxf import ( + _curves_from_acis_lines, + _extract_numeric_tokens, + _parse_ellipse_curve, + _parse_point, + _parse_straight_curve, + _sample_reference_points, + _unique_points, +) + + +def test_parse_straight_curve_projects_to_xy() -> None: + line = "straight-curve $-1 -1 $-1 10 20 0 1 0 0 F 0 F 30 #" + segment = _parse_straight_curve(line) + + assert segment is not None + np.testing.assert_allclose(segment, np.array([[10.0, 20.0], [40.0, 20.0]])) + + +def test_parse_ellipse_curve_creates_xy_circle_samples() -> None: + line = "ellipse-curve $-1 -1 $-1 100 200 0 0 0 1 50 0 0 1 I I #" + curve = _parse_ellipse_curve(line, samples=8) + + assert curve is not None + assert curve.shape == (8, 2) + assert np.isclose(curve[:, 0].mean(), 100.0) + assert np.isclose(curve[:, 1].mean(), 200.0) + + +def test_curves_from_acis_lines_extract_geometry_and_points() -> None: + curves, points = _curves_from_acis_lines( + [ + "straight-curve $-1 -1 $-1 10 20 0 1 0 0 F 0 F 30 #", + "ellipse-curve $-1 -1 $-1 100 200 0 0 0 1 50 0 0 1 I I #", + "point $-1 -1 $-1 5 6 0 #", + ] + ) + + assert len(curves) == 2 + assert (10.0, 20.0) in points + assert (40.0, 20.0) in points + assert (5.0, 6.0) in points + + +def test_chip_dxf_helpers_ignore_invalid_geometry() -> None: + assert _parse_straight_curve("straight-curve 1 2 3") is None + assert _parse_ellipse_curve("ellipse-curve 1 2 3") is None + assert _parse_ellipse_curve("ellipse-curve $-1 0 0 0 0 0 1 0 0 0") is None + assert _parse_point("point bad") is None + + assert _extract_numeric_tokens("prefix $-1 bad 1 # 2.5 text") == [1.0, 2.5] + + +def test_chip_dxf_reference_point_helpers() -> None: + points = np.array( + [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0], [4.0, 0.0]] + ) + + assert _sample_reference_points(points) == [ + (0.0, 0.0), + (1.0, 0.0), + (2.0, 0.0), + (3.0, 0.0), + ] + assert _sample_reference_points(points[:2]) == [(0.0, 0.0), (1.0, 0.0)] + + assert _unique_points([(1.0001, 2.0), (1.0002, 2.0), (3.0, 4.0)]) == [ + (1.0001, 2.0), + (3.0, 4.0), + ] diff --git a/tests/test_mda_stage_explorer.py b/tests/test_mda_stage_explorer.py new file mode 100644 index 00000000..fca29003 --- /dev/null +++ b/tests/test_mda_stage_explorer.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import numpy as np +import useq + +from pymmcore_gui import MicroManagerGUI +from pymmcore_gui.actions import WidgetAction +from pymmcore_gui.widgets._chip_dxf import ChipCurve, ChipOverlayData +from pymmcore_gui.widgets._mda_stage_explorer import MDALinkedStageExplorer + + +def test_stage_explorer_binds_to_existing_mda_widget(qtbot) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + mda_widget = gui.get_widget(WidgetAction.MDA_WIDGET) + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + + assert isinstance(explorer, MDALinkedStageExplorer) + assert explorer._mda_widget is mda_widget + + +def test_stage_explorer_tracks_mda_positions(qtbot) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + mda_widget = gui.get_widget(WidgetAction.MDA_WIDGET) + + mda_widget.stage_positions.setValue( + [ + useq.Position(x=100.0, y=200.0, name="Inlet"), + useq.Position(x=350.0, y=500.0, name="Trap A"), + ] + ) + qtbot.wait(10) + + assert [p.row for p in explorer._mda_positions] == [0, 1] + assert [p.name for p in explorer._mda_positions] == ["Inlet", "Trap A"] + assert set(explorer._mda_overlays) == {0, 1} + + +def test_stage_explorer_tracks_selected_mda_row(qtbot) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + mda_widget = gui.get_widget(WidgetAction.MDA_WIDGET) + + mda_widget.stage_positions.setValue( + [ + useq.Position(x=100.0, y=200.0, name="Inlet"), + useq.Position(x=350.0, y=500.0, name="Trap A"), + ] + ) + qtbot.wait(10) + + table = mda_widget.stage_positions.table() + table.clearSelection() + table.selectRow(1) + qtbot.wait(10) + + assert explorer._selected_row == 1 + + +def test_stage_explorer_tracks_active_mda_position_from_frame_ready(qtbot) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + mda_widget = gui.get_widget(WidgetAction.MDA_WIDGET) + + mda_widget.stage_positions.setValue( + [ + useq.Position(x=100.0, y=200.0, name="Inlet"), + useq.Position(x=350.0, y=500.0, name="Trap A"), + ] + ) + qtbot.wait(10) + + event = useq.MDAEvent(x_pos=350.0, y_pos=500.0, pos_name="Trap A") + explorer._on_frame_ready(np.zeros((16, 16), dtype=np.uint16), event) + + assert explorer._active_row == 1 + + explorer._on_mda_finished(useq.MDASequence()) + assert explorer._active_row is None + + +def test_stage_explorer_matches_events_to_positions(qtbot) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + mda_widget = gui.get_widget(WidgetAction.MDA_WIDGET) + + mda_widget.stage_positions.setValue( + [ + useq.Position(x=100.0, y=200.0, name="Inlet"), + useq.Position(x=350.0, y=500.0, name="Trap A"), + ] + ) + qtbot.wait(10) + + assert explorer._match_event_to_position(useq.MDAEvent(pos_name="Trap A")) == 1 + assert explorer._match_event_to_position(useq.MDAEvent(x_pos=101, y_pos=201)) == 0 + assert ( + explorer._match_event_to_position(useq.MDAEvent(x_pos=9999, y_pos=9999)) is None + ) + assert explorer._match_event_to_position(useq.MDAEvent()) is None + + +def test_stage_explorer_toggles_mda_overlays(qtbot) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + mda_widget = gui.get_widget(WidgetAction.MDA_WIDGET) + mda_widget.stage_positions.setValue([useq.Position(x=100.0, y=200.0, name="P1")]) + qtbot.wait(10) + assert explorer._mda_overlays + + explorer._set_mda_overlays_visible(False) + assert explorer._mda_overlays == {} + + explorer._set_mda_overlays_visible(True) + assert explorer._mda_overlays + + explorer.set_mda_widget(None) + assert explorer._mda_widget is None + assert explorer._mda_overlays == {} + + +def test_stage_explorer_calibrates_chip_overlay_by_translation( + qtbot, monkeypatch +) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + assert isinstance(explorer, MDALinkedStageExplorer) + + explorer._chip_overlay_data = ChipOverlayData( + curves=[ + ChipCurve( + points=np.array([[0.0, 0.0], [100.0, 0.0], [100.0, 50.0]], dtype=float) + ) + ], + reference_points=[(0.0, 0.0), (100.0, 50.0)], + source=None, # type: ignore[arg-type] + ) + explorer._chip_selected_reference = (0.0, 0.0) + monkeypatch.setattr(explorer._mmc, "getXYStageDevice", lambda: "XY") + monkeypatch.setattr(explorer._mmc, "getXYPosition", lambda: (250.0, 400.0)) + explorer._set_chip_reference_to_current_stage() + + np.testing.assert_allclose(explorer._chip_stage_offset_um, np.array([250.0, 400.0])) + + +def test_stage_explorer_loads_and_toggles_chip_overlay(qtbot, monkeypatch) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + assert isinstance(explorer, MDALinkedStageExplorer) + + overlay = ChipOverlayData( + curves=[ + ChipCurve( + points=np.array([[0.0, 0.0], [100.0, 0.0], [100.0, 50.0]], dtype=float), + closed=True, + ) + ], + reference_points=[(0.0, 0.0)], + source=None, # type: ignore[arg-type] + ) + monkeypatch.setattr( + "pymmcore_gui.widgets._mda_stage_explorer.load_chip_overlay_data", + lambda path: overlay, + ) + + explorer.load_chip_overlay("chip.dxf") + + assert explorer._chip_overlay_data is overlay + assert explorer._chip_curves_visuals + assert explorer._toggle_chip_overlay_action.isEnabled() + + explorer._set_chip_overlay_visible(False) + assert explorer._chip_curves_visuals == [] + + explorer._set_chip_overlay_visible(True) + assert explorer._chip_curves_visuals + + +def test_stage_explorer_chip_reference_helpers(qtbot) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + assert isinstance(explorer, MDALinkedStageExplorer) + + explorer._chip_overlay_data = ChipOverlayData( + curves=[ + ChipCurve( + points=np.array([[0.0, 0.0], [100.0, 0.0], [100.0, 50.0]], dtype=float) + ) + ], + reference_points=[(0.0, 0.0), (100.0, 50.0)], + source=None, # type: ignore[arg-type] + ) + explorer._chip_stage_offset_um = np.array([10.0, 20.0], dtype=float) + + assert explorer._nearest_chip_reference(12.0, 21.0) == (0.0, 0.0) + assert explorer._nearest_chip_reference(111.0, 72.0) == (100.0, 50.0) + + explorer._chip_selected_reference = (100.0, 50.0) + curves = explorer._chip_curves_in_stage_coordinates() + np.testing.assert_allclose(curves[0].points[0], np.array([10.0, 20.0])) + + explorer._reset_chip_reference() + assert explorer._chip_selected_reference is None + np.testing.assert_allclose(explorer._chip_stage_offset_um, np.zeros(2)) + + +def test_stage_explorer_rebuilds_chip_reference_visual(qtbot) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + assert isinstance(explorer, MDALinkedStageExplorer) + + explorer._chip_overlay_data = ChipOverlayData( + curves=[], + reference_points=[(10.0, 20.0)], + source=None, # type: ignore[arg-type] + ) + explorer._chip_selected_reference = (10.0, 20.0) + explorer._chip_stage_offset_um = np.array([2.0, 3.0], dtype=float) + + explorer._rebuild_chip_overlay() + + assert explorer._chip_reference_marker is not None + assert explorer._chip_reference_label is not None + + +def test_stage_explorer_mouse_press_selects_position(qtbot, monkeypatch) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + mda_widget = gui.get_widget(WidgetAction.MDA_WIDGET) + mda_widget.stage_positions.setValue([useq.Position(x=100.0, y=200.0, name="P1")]) + qtbot.wait(10) + + monkeypatch.setattr( + explorer._stage_viewer, "canvas_to_world", lambda pos: (100.0, 200.0) + ) + + explorer._on_mouse_press(SimpleNamespace(pos=(0, 0), button=1)) + + assert explorer._selected_row == 0 + assert ( + mda_widget.stage_positions.table().selectionModel().selectedRows()[0].row() == 0 + ) + + +def test_stage_explorer_mouse_press_picks_chip_reference(qtbot, monkeypatch) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + explorer._chip_overlay_data = ChipOverlayData( + curves=[], + reference_points=[(10.0, 20.0)], + source=None, # type: ignore[arg-type] + ) + explorer._calibration_pick_mode = True + explorer._pick_chip_ref_action.setChecked(True) + monkeypatch.setattr( + explorer._stage_viewer, "canvas_to_world", lambda pos: (10.0, 20.0) + ) + + explorer._on_mouse_press(SimpleNamespace(pos=(0, 0), button=1)) + + assert explorer._chip_selected_reference == (10.0, 20.0) + assert not explorer._pick_chip_ref_action.isChecked() + + +def test_stage_explorer_double_click_moves_to_nearest_mda_position( + qtbot, monkeypatch +) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + + explorer = gui.get_widget(WidgetAction.STAGE_EXPLORER) + mda_widget = gui.get_widget(WidgetAction.MDA_WIDGET) + assert isinstance(explorer, MDALinkedStageExplorer) + assert mda_widget is not None + + controller = SimpleNamespace(move_absolute=Mock(), snap_on_finish=False) + explorer._stage_controller = controller + monkeypatch.setattr( + explorer._stage_viewer.view.camera.transform, + "imap", + lambda pos: (100.0, 200.0, 0.0), + ) + explorer._mda_positions = [ + SimpleNamespace(row=0, x=100.0, y=200.0, name="P1", enabled=True) + ] + + explorer._on_mouse_double_click(SimpleNamespace(pos=(0, 0))) + + controller.move_absolute.assert_called_once_with((100.0, 200.0)) + assert controller.snap_on_finish == explorer._snap_on_double_click