From f04ac7e0f4d9757db93653cdc75e9ef43a9afb05 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 15:20:24 -0400 Subject: [PATCH 1/8] feat: multi point plan useq widgets (#314) * feat: multi point plan useq widgets * pull out pyproject * pyproject * cleanup grid row * cleanup * style(pre-commit.ci): auto fixes [...] * tests * lint * bump --------- Co-authored-by: Federico Gasparoli Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- pyproject.toml | 8 +- src/pymmcore_widgets/_util.py | 14 +- src/pymmcore_widgets/useq_widgets/_grid.py | 20 +-- src/pymmcore_widgets/useq_widgets/_z.py | 16 +- .../useq_widgets/points_plans/__init__.py | 6 + .../points_plans/_grid_row_column_widget.py | 131 +++++++++++++++++ .../points_plans/_random_points_widget.py | 139 ++++++++++++++++++ tests/useq_widgets/test_useq_points_plans.py | 68 +++++++++ tests/{ => useq_widgets}/test_useq_widgets.py | 0 9 files changed, 371 insertions(+), 31 deletions(-) create mode 100644 src/pymmcore_widgets/useq_widgets/points_plans/__init__.py create mode 100644 src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py create mode 100644 src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py create mode 100644 tests/useq_widgets/test_useq_points_plans.py rename tests/{ => useq_widgets}/test_useq_widgets.py (100%) diff --git a/pyproject.toml b/pyproject.toml index 3c9f43dd1..c6473c0c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,9 +51,12 @@ dependencies = [ 'pymmcore-plus[cli] >=0.11.0', 'qtpy >=2.0', 'superqt[quantity] >=0.5.3', - 'useq-schema >=0.4.7', + 'useq-schema @ git+https://github.com/pymmcore-plus/useq-schema.git', # temporary until new useq release ] +[tool.hatch.metadata] +allow-direct-references = true + # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] @@ -174,4 +177,5 @@ ignore = [ ] [tool.typos.default] -extend-ignore-identifiers-re = ["(?i)ome"] +extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome", "FO(Vs?)?"] + diff --git a/src/pymmcore_widgets/_util.py b/src/pymmcore_widgets/_util.py index 8f6061ff6..468b39b09 100644 --- a/src/pymmcore_widgets/_util.py +++ b/src/pymmcore_widgets/_util.py @@ -7,7 +7,8 @@ import useq from psygnal import SignalInstance from pymmcore_plus import CMMCorePlus -from qtpy.QtCore import QObject +from qtpy.QtCore import QObject, Qt +from qtpy.QtGui import QPainter, QPaintEvent, QPen from qtpy.QtWidgets import ( QComboBox, QDialog, @@ -176,3 +177,14 @@ def get_next_available_path(requested_path: Path | str, min_digits: int = 3) -> # use it current_max = max(int(num), current_max) return directory / f"{stem}_{current_max:0{min_digits}d}{extension}" + + +class SeparatorWidget(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setFixedHeight(1) + + def paintEvent(self, a0: QPaintEvent | None) -> None: + painter = QPainter(self) + painter.setPen(QPen(Qt.GlobalColor.gray, 1, Qt.PenStyle.SolidLine)) + painter.drawLine(self.rect().topLeft(), self.rect().topRight()) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 6d1af41bf..44664faf1 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -5,7 +5,6 @@ import useq from qtpy.QtCore import QSize, Qt, Signal -from qtpy.QtGui import QPainter, QPaintEvent, QPen from qtpy.QtWidgets import ( QAbstractButton, QButtonGroup, @@ -23,6 +22,8 @@ from superqt import QEnumComboBox from superqt.utils import signals_blocked +from pymmcore_widgets._util import SeparatorWidget + class RelativeTo(Enum): center = "center" @@ -166,11 +167,11 @@ def __init__(self, parent: QWidget | None = None): inner_widget = QWidget(self) layout = QVBoxLayout(inner_widget) layout.addLayout(row_col_layout) - layout.addWidget(_SeparatorWidget()) + layout.addWidget(SeparatorWidget()) layout.addLayout(width_height_layout) # hiding until useq supports it - layout.addWidget(_SeparatorWidget()) + layout.addWidget(SeparatorWidget()) layout.addLayout(self.bounds_layout) - layout.addWidget(_SeparatorWidget()) + layout.addWidget(SeparatorWidget()) layout.addLayout(bottom_stuff) layout.addStretch() @@ -361,14 +362,3 @@ def _on_change(self) -> None: if (val := self.value()) is None: return # pragma: no cover self.valueChanged.emit(val) - - -class _SeparatorWidget(QWidget): - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setFixedHeight(1) - - def paintEvent(self, a0: QPaintEvent | None) -> None: - painter = QPainter(self) - painter.setPen(QPen(Qt.GlobalColor.gray, 1, Qt.PenStyle.SolidLine)) - painter.drawLine(self.rect().topLeft(), self.rect().topRight()) diff --git a/src/pymmcore_widgets/useq_widgets/_z.py b/src/pymmcore_widgets/useq_widgets/_z.py index cf42a5504..ba516bfdc 100644 --- a/src/pymmcore_widgets/useq_widgets/_z.py +++ b/src/pymmcore_widgets/useq_widgets/_z.py @@ -6,7 +6,6 @@ import useq from fonticon_mdi6 import MDI6 from qtpy.QtCore import Qt, Signal -from qtpy.QtGui import QPainter, QPaintEvent, QPen from qtpy.QtWidgets import ( QButtonGroup, QDoubleSpinBox, @@ -23,6 +22,8 @@ from superqt.fonticon import icon from superqt.utils import signals_blocked +from pymmcore_widgets._util import SeparatorWidget + if TYPE_CHECKING: from PyQt6.QtGui import QAction, QActionGroup else: @@ -261,7 +262,7 @@ def __init__(self, parent: QWidget | None = None) -> None: layout = QVBoxLayout(self) layout.addLayout(btn_layout) - layout.addWidget(_SeparatorWidget()) + layout.addWidget(SeparatorWidget()) layout.addLayout(self._grid_layout) layout.addStretch() layout.addLayout(below_grid) @@ -449,14 +450,3 @@ def _set_row_visible(self, idx: int, visible: bool) -> None: for col in range(grid.columnCount()): if (item := grid.itemAtPosition(idx, col)) and (wdg := item.widget()): wdg.setVisible(visible) - - -class _SeparatorWidget(QWidget): - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setFixedHeight(1) - - def paintEvent(self, a0: QPaintEvent | None) -> None: - painter = QPainter(self) - painter.setPen(QPen(Qt.GlobalColor.gray, 1, Qt.PenStyle.SolidLine)) - painter.drawLine(self.rect().topLeft(), self.rect().topRight()) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py b/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py new file mode 100644 index 000000000..4e9b313be --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py @@ -0,0 +1,6 @@ +"""Widgets that create MultiPoint plans.""" + +from ._grid_row_column_widget import GridRowColumnWidget +from ._random_points_widget import RandomPointWidget + +__all__ = ["GridRowColumnWidget", "RandomPointWidget"] diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py new file mode 100644 index 000000000..415bdaa45 --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import Mapping + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QFormLayout, + QLabel, + QSpinBox, + QVBoxLayout, + QWidget, +) +from useq import GridRowsColumns, OrderMode + + +class GridRowColumnWidget(QWidget): + """Widget to generate a grid of FOVs within a specified area.""" + + valueChanged = Signal(object) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self.fov_width: float | None = None + self.fov_height: float | None = None + self._relative_to: str = "center" + + # title + title = QLabel(text="Fields of View in a Grid.") + title.setStyleSheet("font-weight: bold;") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # rows + self.rows = QSpinBox() + self.rows.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.rows.setMinimum(1) + # columns + self.columns = QSpinBox() + self.columns.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.columns.setMinimum(1) + # overlap along x + self.overlap_x = QDoubleSpinBox() + self.overlap_x.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.overlap_x.setRange(-10000, 100) + # overlap along y + self.overlap_y = QDoubleSpinBox() + self.overlap_y.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.overlap_x.setRange(-10000, 100) + # order combo + self.mode = QComboBox() + self.mode.addItems([mode.value for mode in OrderMode]) + self.mode.setCurrentText(OrderMode.row_wise_snake.value) + + # form layout + form = QFormLayout() + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + form.setSpacing(5) + form.setContentsMargins(0, 0, 0, 0) + form.addRow("Rows:", self.rows) + form.addRow("Columns:", self.columns) + form.addRow("Overlap x (%):", self.overlap_x) + form.addRow("Overlap y (%):", self.overlap_y) + form.addRow("Grid Order:", self.mode) + + # main + main_layout = QVBoxLayout(self) + main_layout.setSpacing(5) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addWidget(title) + main_layout.addLayout(form) + + # connect + self.rows.valueChanged.connect(self._on_value_changed) + self.columns.valueChanged.connect(self._on_value_changed) + self.overlap_x.valueChanged.connect(self._on_value_changed) + self.overlap_y.valueChanged.connect(self._on_value_changed) + self.mode.currentTextChanged.connect(self._on_value_changed) + + def _on_value_changed(self) -> None: + """Emit the valueChanged signal.""" + self.valueChanged.emit(self.value()) + + @property + def overlap(self) -> tuple[float, float]: + """Return the overlap along x and y.""" + return self.overlap_x.value(), self.overlap_y.value() + + @property + def fov_size(self) -> tuple[float | None, float | None]: + """Return the FOV size in (width, height).""" + return self.fov_width, self.fov_height + + @fov_size.setter + def fov_size(self, size: tuple[float | None, float | None]) -> None: + """Set the FOV size.""" + self.fov_width, self.fov_height = size + + def value(self) -> GridRowsColumns: + """Return the values of the widgets.""" + return GridRowsColumns( + rows=self.rows.value(), + columns=self.columns.value(), + overlap=self.overlap, + mode=self.mode.currentText(), + fov_width=self.fov_width, + fov_height=self.fov_height, + relative_to=self._relative_to, + ) + + def setValue(self, value: GridRowsColumns | Mapping) -> None: + """Set the values of the widgets.""" + value = GridRowsColumns.model_validate(value) + self.rows.setValue(value.rows) + self.columns.setValue(value.columns) + self.overlap_x.setValue(value.overlap[0]) + self.overlap_y.setValue(value.overlap[1]) + self.mode.setCurrentText(value.mode.value) + self.fov_width = value.fov_width + self.fov_height = value.fov_height + self._relative_to = value.relative_to.value + + def reset(self) -> None: + """Reset value to 1x1, row-wise-snake, with 0 overlap.""" + self.rows.setValue(1) + self.columns.setValue(1) + self.overlap_x.setValue(0) + self.overlap_y.setValue(0) + self.mode.setCurrentText(OrderMode.row_wise_snake.value) + self.fov_size = (None, None) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py new file mode 100644 index 000000000..8fe0f5c48 --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import random +from typing import Mapping + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) +from useq import RandomPoints, Shape + + +class RandomPointWidget(QGroupBox): + """Widget to generate random points within a specified area.""" + + valueChanged = Signal(object) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self.allow_overlap: bool = False + # setting a random seed for point generation reproducibility + self.random_seed: int = self._new_seed() + self._fov_size: tuple[float | None, float | None] = (None, None) + + # title + title = QLabel(text="Random Points") + title.setStyleSheet("font-weight: bold;") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + # well area doublespinbox along x + self.max_width = QDoubleSpinBox() + self.max_width.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.max_width.setRange(0, 1000000) + self.max_width.setSingleStep(100) + # well area doublespinbox along y + self.max_height = QDoubleSpinBox() + self.max_height.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.max_height.setRange(0, 1000000) + self.max_height.setSingleStep(100) + # number of FOVs spinbox + self.num_points = QSpinBox() + self.num_points.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.num_points.setRange(1, 1000) + # random button + self._random_button = QPushButton(text="Randomize") + + self.shape = QComboBox() + self.shape.addItems([mode.value for mode in Shape]) + self.shape.setCurrentText(Shape.ELLIPSE.value) + + # form layout + form = QFormLayout() + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + form.setSpacing(5) + form.setContentsMargins(0, 0, 0, 0) + form.addRow("Width (µm):", self.max_width) + form.addRow("Height (µm):", self.max_height) + form.addRow("Num Points:", self.num_points) + form.addRow("Shape:", self.shape) + + # main + main_layout = QVBoxLayout(self) + main_layout.setSpacing(5) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addWidget(title) + main_layout.addLayout(form) + main_layout.addWidget(self._random_button) + + # connect + self.max_width.valueChanged.connect(self._on_value_changed) + self.max_height.valueChanged.connect(self._on_value_changed) + self.num_points.valueChanged.connect(self._on_value_changed) + self.shape.currentTextChanged.connect(self._on_value_changed) + self._random_button.clicked.connect(self._on_random_clicked) + + @property + def fov_size(self) -> tuple[float | None, float | None]: + """Return the FOV size.""" + return self._fov_size + + @fov_size.setter + def fov_size(self, size: tuple[float | None, float | None]) -> None: + """Set the FOV size.""" + self._fov_size = size + + def _on_value_changed(self) -> None: + """Emit the valueChanged signal.""" + self.valueChanged.emit(self.value()) + + def _on_random_clicked(self) -> None: + # reset the random seed + self.random_seed = self._new_seed() + self.valueChanged.emit(self.value()) + + def value(self) -> RandomPoints: + """Return the values of the widgets.""" + fov_x, fov_y = self._fov_size + return RandomPoints( + num_points=self.num_points.value(), + shape=self.shape.currentText(), + random_seed=self.random_seed, + max_width=self.max_width.value(), + max_height=self.max_height.value(), + allow_overlap=self.allow_overlap, + fov_width=fov_x, + fov_height=fov_y, + ) + + def setValue(self, value: RandomPoints | Mapping) -> None: + """Set the values of the widgets.""" + value = RandomPoints.model_validate(value) + self.random_seed = ( + self._new_seed() if value.random_seed is None else value.random_seed + ) + self.num_points.setValue(value.num_points) + self.max_width.setValue(value.max_width) + self.max_height.setValue(value.max_height) + self.shape.setCurrentText(value.shape.value) + self._fov_size = (value.fov_width, value.fov_height) + self.allow_overlap = value.allow_overlap + + def reset(self) -> None: + """Reset value to 1 point and 0 area.""" + self.num_points.setValue(1) + self.max_width.setValue(0) + self.max_height.setValue(0) + self.shape.setCurrentText(Shape.ELLIPSE.value) + self._fov_size = (None, None) + + def _new_seed(self) -> int: + return random.randint(0, 2**32 - 1) diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py new file mode 100644 index 000000000..a859c3090 --- /dev/null +++ b/tests/useq_widgets/test_useq_points_plans.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from useq import GridRowsColumns, OrderMode, RandomPoints + +from pymmcore_widgets.useq_widgets import points_plans as pp + +if TYPE_CHECKING: + from pytestqt.qtbot import QtBot + + +def test_random_points_widget(qtbot: QtBot) -> None: + wdg = pp.RandomPointWidget() + qtbot.addWidget(wdg) + assert wdg.num_points.value() == 1 + assert wdg.max_width.value() == 0 + assert wdg.max_height.value() == 0 + assert wdg.shape.currentText() == "ellipse" + assert wdg.random_seed is not None + + points = RandomPoints( + num_points=5, + shape="rectangle", + max_width=100, + max_height=100, + fov_height=10, + fov_width=10, + random_seed=123, + ) + with qtbot.waitSignal(wdg.valueChanged): + wdg.setValue(points) + assert wdg.value() == points + + assert wdg.num_points.value() == points.num_points + assert wdg.max_width.value() == points.max_width + assert wdg.max_height.value() == points.max_height + assert wdg.shape.currentText() == points.shape.value + assert wdg.random_seed == points.random_seed + + +def test_grid_plan_widget(qtbot: QtBot) -> None: + wdg = pp.GridRowColumnWidget() + qtbot.addWidget(wdg) + assert wdg.rows.value() == 1 + assert wdg.columns.value() == 1 + assert wdg.overlap_x.value() == 0 + assert wdg.overlap_y.value() == 0 + assert wdg.mode.currentText() == "row_wise_snake" + + grid = GridRowsColumns( + rows=5, + columns=5, + overlap=(10, 12), + mode=OrderMode.column_wise_snake, + fov_height=10, + fov_width=12, + relative_to="top_left", + ) + with qtbot.waitSignal(wdg.valueChanged): + wdg.setValue(grid) + assert wdg.value() == grid + + assert wdg.rows.value() == grid.rows + assert wdg.columns.value() == grid.columns + assert wdg.overlap_x.value() == grid.overlap[0] + assert wdg.overlap_y.value() == grid.overlap[1] + assert wdg.mode.currentText() == grid.mode.value diff --git a/tests/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py similarity index 100% rename from tests/test_useq_widgets.py rename to tests/useq_widgets/test_useq_widgets.py From 4c515835af5ddca78205540040eacd84f13137fb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:46:23 -0400 Subject: [PATCH 2/8] feat: Points plan selector (#315) * feat: multi point plan useq widgets * pull out pyproject * pyproject * cleanup grid row * cleanup * style(pre-commit.ci): auto fixes [...] * tests * lint * bump * fov-widget * wip * finish * rm x * remove hcs * test * lint * more test * style * typing * rename * lint * fix again you dummy --------- Co-authored-by: Federico Gasparoli Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../useq_widgets/points_plans/__init__.py | 3 +- .../points_plans/_grid_row_column_widget.py | 2 +- .../points_plans/_points_plan_selector.py | 162 ++++++++++++++++++ .../points_plans/_random_points_widget.py | 5 +- tests/useq_widgets/test_useq_points_plans.py | 88 ++++++---- 5 files changed, 221 insertions(+), 39 deletions(-) create mode 100644 src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py b/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py index 4e9b313be..70762aa99 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py @@ -1,6 +1,7 @@ """Widgets that create MultiPoint plans.""" from ._grid_row_column_widget import GridRowColumnWidget +from ._points_plan_selector import RelativePointPlanSelector from ._random_points_widget import RandomPointWidget -__all__ = ["GridRowColumnWidget", "RandomPointWidget"] +__all__ = ["GridRowColumnWidget", "RandomPointWidget", "RelativePointPlanSelector"] diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py index 415bdaa45..3e3657fe7 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py @@ -28,7 +28,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._relative_to: str = "center" # title - title = QLabel(text="Fields of View in a Grid.") + title = QLabel(text="Fields of View in a Grid") title.setStyleSheet("font-weight: bold;") title.setAlignment(Qt.AlignmentFlag.AlignCenter) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py new file mode 100644 index 000000000..b0ecf4064 --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import useq +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QButtonGroup, + QGroupBox, + QHBoxLayout, + QLabel, + QRadioButton, + QVBoxLayout, + QWidget, +) +from superqt.utils import signals_blocked + +from pymmcore_widgets._util import SeparatorWidget + +from ._grid_row_column_widget import GridRowColumnWidget +from ._random_points_widget import RandomPointWidget + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + # excluding useq.GridWidthHeight even though it's also a relative multi point plan + RelativePointPlan: TypeAlias = ( + useq.GridRowsColumns | useq.RandomPoints | useq.RelativePosition + ) + + +class RelativePositionWidget(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + title = QLabel("Single FOV") + title.setStyleSheet("font-weight: bold;") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title) + + def value(self) -> useq.RelativePosition: + return useq.RelativePosition() + + def setValue(self, plan: useq.RelativePosition) -> None: + pass + + +class RelativePointPlanSelector(QWidget): + """Widget to select a relative multi-position point plan. + + In useq, a RelativeMultiPointPlan is one of: + - useq.RelativePosition + - useq.RandomPoints + - useq.GridRowsColumns + - useq.GridWidthHeight # not included in this widget + """ + + valueChanged = Signal(object) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent=parent) + + # WIDGET ---------------------- + + # plan widgets + self.single_pos_wdg = RelativePositionWidget() + self.random_points_wdg = RandomPointWidget() + self.grid_wdg = GridRowColumnWidget() + + # this gets changed when the radio buttons are toggled + self._active_plan_widget: ( + RelativePositionWidget | RandomPointWidget | GridRowColumnWidget + ) = self.single_pos_wdg + + # radio buttons selection + + self.single_radio_btn = QRadioButton() + self.single_radio_btn.setChecked(True) + self.random_radio_btn = QRadioButton() + self.grid_radio_btn = QRadioButton() + + self._mode_btn_group = QButtonGroup() + self._mode_btn_group.addButton(self.single_radio_btn) + self._mode_btn_group.addButton(self.random_radio_btn) + self._mode_btn_group.addButton(self.grid_radio_btn) + + # CONNECTIONS ---------------------- + + self._mode_btn_group.buttonToggled.connect(self._on_radiobutton_toggled) + self.random_points_wdg.valueChanged.connect(self._on_value_changed) + self.grid_wdg.valueChanged.connect(self._on_value_changed) + + # LAYOUT ---------------------- + + main_layout = QVBoxLayout(self) + main_layout.setSpacing(10) + main_layout.setContentsMargins(10, 10, 10, 10) + for btn, wdg in ( + (self.single_radio_btn, self.single_pos_wdg), + (self.random_radio_btn, self.random_points_wdg), + (self.grid_radio_btn, self.grid_wdg), + ): + wdg.setEnabled(btn.isChecked()) # type: ignore [attr-defined] + grpbx = QGroupBox() + grpbx.setLayout(QVBoxLayout()) + grpbx.layout().addWidget(wdg) + + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + layout.addWidget(btn, 0) + layout.addWidget(grpbx, 1) + main_layout.addLayout(layout) + + for i in range(0, 7, 2): + main_layout.insertWidget(i, SeparatorWidget()) + + # _________________________PUBLIC METHODS_________________________ # + + def value(self) -> useq.RelativeMultiPointPlan: + return self._active_plan_widget.value() + + def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: + """Set the value of the widget. + + Parameters + ---------- + plan : useq.RelativePosition | useq.RandomPoints | useq.GridRowsColumns + The point plan to set. + """ + if isinstance(plan, useq.RandomPoints): + with signals_blocked(self.random_points_wdg): + self.random_points_wdg.setValue(plan) + self.random_radio_btn.setChecked(True) + elif isinstance(plan, useq.GridRowsColumns): + with signals_blocked(self.grid_wdg): + self.grid_wdg.setValue(plan) + self.grid_radio_btn.setChecked(True) + elif isinstance(plan, useq.RelativePosition): + with signals_blocked(self.single_pos_wdg): + self.single_pos_wdg.setValue(plan) + self.single_radio_btn.setChecked(True) + else: # pragma: no cover + raise ValueError(f"Invalid plan type: {type(plan)}") + + # _________________________PRIVATE METHODS_________________________ # + + def _on_radiobutton_toggled(self, btn: QRadioButton, checked: bool) -> None: + btn2wdg: dict[QRadioButton, QWidget] = { + self.single_radio_btn: self.single_pos_wdg, + self.random_radio_btn: self.random_points_wdg, + self.grid_radio_btn: self.grid_wdg, + } + wdg = btn2wdg[btn] + wdg.setEnabled(checked) + if checked: + self._active_plan_widget = wdg + self.valueChanged.emit(self.value()) + + def _on_value_changed(self) -> None: + self.valueChanged.emit(self.value()) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py index 8fe0f5c48..b009e6cee 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -8,7 +8,6 @@ QComboBox, QDoubleSpinBox, QFormLayout, - QGroupBox, QLabel, QPushButton, QSpinBox, @@ -18,7 +17,7 @@ from useq import RandomPoints, Shape -class RandomPointWidget(QGroupBox): +class RandomPointWidget(QWidget): """Widget to generate random points within a specified area.""" valueChanged = Signal(object) @@ -57,7 +56,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.shape.setCurrentText(Shape.ELLIPSE.value) # form layout - form = QFormLayout() + self.form = form = QFormLayout() form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) form.setSpacing(5) form.setContentsMargins(0, 0, 0, 0) diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py index a859c3090..39db700db 100644 --- a/tests/useq_widgets/test_useq_points_plans.py +++ b/tests/useq_widgets/test_useq_points_plans.py @@ -2,13 +2,33 @@ from typing import TYPE_CHECKING -from useq import GridRowsColumns, OrderMode, RandomPoints +from useq import GridRowsColumns, OrderMode, RandomPoints, RelativePosition from pymmcore_widgets.useq_widgets import points_plans as pp if TYPE_CHECKING: from pytestqt.qtbot import QtBot +RANDOM_POINTS = RandomPoints( + num_points=5, + shape="rectangle", + max_width=100, + max_height=100, + fov_height=10, + fov_width=10, + random_seed=123, +) + +GRID_ROWS_COLS = GridRowsColumns( + rows=5, + columns=5, + overlap=(10, 12), + mode=OrderMode.column_wise_snake, + fov_height=10, + fov_width=12, + relative_to="top_left", +) + def test_random_points_widget(qtbot: QtBot) -> None: wdg = pp.RandomPointWidget() @@ -19,24 +39,15 @@ def test_random_points_widget(qtbot: QtBot) -> None: assert wdg.shape.currentText() == "ellipse" assert wdg.random_seed is not None - points = RandomPoints( - num_points=5, - shape="rectangle", - max_width=100, - max_height=100, - fov_height=10, - fov_width=10, - random_seed=123, - ) with qtbot.waitSignal(wdg.valueChanged): - wdg.setValue(points) - assert wdg.value() == points + wdg.setValue(RANDOM_POINTS) + assert wdg.value() == RANDOM_POINTS - assert wdg.num_points.value() == points.num_points - assert wdg.max_width.value() == points.max_width - assert wdg.max_height.value() == points.max_height - assert wdg.shape.currentText() == points.shape.value - assert wdg.random_seed == points.random_seed + assert wdg.num_points.value() == RANDOM_POINTS.num_points + assert wdg.max_width.value() == RANDOM_POINTS.max_width + assert wdg.max_height.value() == RANDOM_POINTS.max_height + assert wdg.shape.currentText() == RANDOM_POINTS.shape.value + assert wdg.random_seed == RANDOM_POINTS.random_seed def test_grid_plan_widget(qtbot: QtBot) -> None: @@ -48,21 +59,30 @@ def test_grid_plan_widget(qtbot: QtBot) -> None: assert wdg.overlap_y.value() == 0 assert wdg.mode.currentText() == "row_wise_snake" - grid = GridRowsColumns( - rows=5, - columns=5, - overlap=(10, 12), - mode=OrderMode.column_wise_snake, - fov_height=10, - fov_width=12, - relative_to="top_left", - ) with qtbot.waitSignal(wdg.valueChanged): - wdg.setValue(grid) - assert wdg.value() == grid - - assert wdg.rows.value() == grid.rows - assert wdg.columns.value() == grid.columns - assert wdg.overlap_x.value() == grid.overlap[0] - assert wdg.overlap_y.value() == grid.overlap[1] - assert wdg.mode.currentText() == grid.mode.value + wdg.setValue(GRID_ROWS_COLS) + assert wdg.value() == GRID_ROWS_COLS + + assert wdg.rows.value() == GRID_ROWS_COLS.rows + assert wdg.columns.value() == GRID_ROWS_COLS.columns + assert wdg.overlap_x.value() == GRID_ROWS_COLS.overlap[0] + assert wdg.overlap_y.value() == GRID_ROWS_COLS.overlap[1] + assert wdg.mode.currentText() == GRID_ROWS_COLS.mode.value + + +def test_point_plan_selector(qtbot: QtBot) -> None: + wdg = pp.RelativePointPlanSelector() + qtbot.addWidget(wdg) + + assert isinstance(wdg.value(), RelativePosition) + + wdg.setValue(RANDOM_POINTS) + assert wdg.value() == RANDOM_POINTS + assert wdg.random_radio_btn.isChecked() + + wdg.setValue(GRID_ROWS_COLS) + assert wdg.value() == GRID_ROWS_COLS + assert wdg.grid_radio_btn.isChecked() + + wdg.random_radio_btn.setChecked(True) + assert wdg.value() == RANDOM_POINTS From a2b18b8e6e12b2ec73dcf208895c40422ce7674c Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Mon, 8 Jul 2024 18:31:55 -0400 Subject: [PATCH 3/8] feat: add overlap checkbox (#317) --- .../points_plans/_random_points_widget.py | 31 ++++++++++++++----- tests/useq_widgets/test_useq_points_plans.py | 10 ++++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py index b009e6cee..fd340eb2c 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -5,6 +5,7 @@ from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( + QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, @@ -14,7 +15,7 @@ QVBoxLayout, QWidget, ) -from useq import RandomPoints, Shape +from useq import RandomPoints, Shape, TraversalOrder class RandomPointWidget(QWidget): @@ -25,7 +26,6 @@ class RandomPointWidget(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self.allow_overlap: bool = False # setting a random seed for point generation reproducibility self.random_seed: int = self._new_seed() self._fov_size: tuple[float | None, float | None] = (None, None) @@ -37,17 +37,25 @@ def __init__(self, parent: QWidget | None = None) -> None: # well area doublespinbox along x self.max_width = QDoubleSpinBox() self.max_width.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.max_width.setRange(0, 1000000) - self.max_width.setSingleStep(100) + self.max_width.setRange(1, 1000000) + self.max_width.setValue(1000) + self.max_width.setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType) # well area doublespinbox along y self.max_height = QDoubleSpinBox() self.max_height.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.max_height.setRange(0, 1000000) - self.max_height.setSingleStep(100) + self.max_height.setRange(1, 1000000) + self.max_height.setValue(1000) + self.max_height.setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType) # number of FOVs spinbox self.num_points = QSpinBox() self.num_points.setAlignment(Qt.AlignmentFlag.AlignCenter) self.num_points.setRange(1, 1000) + # order combobox + self.order = QComboBox() + self.order.addItems([mode.value for mode in TraversalOrder]) + self.order.setCurrentText(TraversalOrder.TWO_OPT.value) + # allow overlap checkbox + self.allow_overlap = QCheckBox() # random button self._random_button = QPushButton(text="Randomize") @@ -63,7 +71,9 @@ def __init__(self, parent: QWidget | None = None) -> None: form.addRow("Width (µm):", self.max_width) form.addRow("Height (µm):", self.max_height) form.addRow("Num Points:", self.num_points) + form.addRow("Order:", self.order) form.addRow("Shape:", self.shape) + form.addRow("Allow Overlap:", self.allow_overlap) # main main_layout = QVBoxLayout(self) @@ -77,7 +87,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self.max_width.valueChanged.connect(self._on_value_changed) self.max_height.valueChanged.connect(self._on_value_changed) self.num_points.valueChanged.connect(self._on_value_changed) + self.order.currentTextChanged.connect(self._on_value_changed) self.shape.currentTextChanged.connect(self._on_value_changed) + self.allow_overlap.stateChanged.connect(self._on_value_changed) self._random_button.clicked.connect(self._on_random_clicked) @property @@ -108,7 +120,8 @@ def value(self) -> RandomPoints: random_seed=self.random_seed, max_width=self.max_width.value(), max_height=self.max_height.value(), - allow_overlap=self.allow_overlap, + allow_overlap=self.allow_overlap.isChecked(), + order=self.order.currentText(), fov_width=fov_x, fov_height=fov_y, ) @@ -124,7 +137,9 @@ def setValue(self, value: RandomPoints | Mapping) -> None: self.max_height.setValue(value.max_height) self.shape.setCurrentText(value.shape.value) self._fov_size = (value.fov_width, value.fov_height) - self.allow_overlap = value.allow_overlap + self.allow_overlap.setChecked(value.allow_overlap) + if value.order is not None: # type: ignore # until useq is released + self.order.setCurrentText(value.order.value) # type: ignore # until useq is released def reset(self) -> None: """Reset value to 1 point and 0 area.""" diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py index 39db700db..dc5e10fac 100644 --- a/tests/useq_widgets/test_useq_points_plans.py +++ b/tests/useq_widgets/test_useq_points_plans.py @@ -17,6 +17,8 @@ fov_height=10, fov_width=10, random_seed=123, + order="random", + allow_overlap=True, ) GRID_ROWS_COLS = GridRowsColumns( @@ -34,9 +36,11 @@ def test_random_points_widget(qtbot: QtBot) -> None: wdg = pp.RandomPointWidget() qtbot.addWidget(wdg) assert wdg.num_points.value() == 1 - assert wdg.max_width.value() == 0 - assert wdg.max_height.value() == 0 + assert wdg.max_width.value() == 1000 + assert wdg.max_height.value() == 1000 assert wdg.shape.currentText() == "ellipse" + assert not wdg.allow_overlap.isChecked() + assert wdg.order.currentText() == "two_opt" assert wdg.random_seed is not None with qtbot.waitSignal(wdg.valueChanged): @@ -48,6 +52,8 @@ def test_random_points_widget(qtbot: QtBot) -> None: assert wdg.max_height.value() == RANDOM_POINTS.max_height assert wdg.shape.currentText() == RANDOM_POINTS.shape.value assert wdg.random_seed == RANDOM_POINTS.random_seed + assert wdg.order.currentText() == RANDOM_POINTS.order.value + assert wdg.allow_overlap.isChecked() == RANDOM_POINTS.allow_overlap def test_grid_plan_widget(qtbot: QtBot) -> None: From 370dfcd5b73de95640fb0cc8aea79ec7f03adfd0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 10 Jul 2024 14:05:06 -0400 Subject: [PATCH 4/8] feat: add minimal Points plan view (#316) * feat: multi point plan useq widgets * pull out pyproject * pyproject * cleanup grid row * cleanup * style(pre-commit.ci): auto fixes [...] * tests * lint * bump * fov-widget * wip * finish * rm x * remove hcs * test * lint * more test * style * typing * rename * lint * fix again you dummy * fov-widget-selector * wip * demo * init files * fix import * chore: Update FOVSelectorWidget to disallow overlap in random points * minor * add connecting line * click on point * style(pre-commit.ci): auto fixes [...] * lint * fixup * deal with None * cleanup * fix test * more fov fixes * move to useq * add tests * tighten * fix test * fix deprecation * docs --------- Co-authored-by: Federico Gasparoli Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- examples/points_plan_widget.py | 20 ++ src/pymmcore_widgets/_util.py | 24 ++- src/pymmcore_widgets/useq_widgets/__init__.py | 22 +- .../useq_widgets/points_plans/__init__.py | 8 +- .../points_plans/_grid_row_column_widget.py | 2 +- .../points_plans/_points_plan_selector.py | 92 ++++++-- .../points_plans/_points_plan_widget.py | 74 +++++++ .../points_plans/_random_points_widget.py | 26 ++- .../points_plans/_well_graphics_view.py | 197 ++++++++++++++++++ tests/useq_widgets/test_useq_points_plans.py | 111 +++++++++- 10 files changed, 539 insertions(+), 37 deletions(-) create mode 100644 examples/points_plan_widget.py create mode 100644 src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py create mode 100644 src/pymmcore_widgets/useq_widgets/points_plans/_well_graphics_view.py diff --git a/examples/points_plan_widget.py b/examples/points_plan_widget.py new file mode 100644 index 000000000..bf60e0b05 --- /dev/null +++ b/examples/points_plan_widget.py @@ -0,0 +1,20 @@ +from qtpy.QtWidgets import QApplication +from useq import RandomPoints + +from pymmcore_widgets.useq_widgets import PointsPlanWidget + +app = QApplication([]) + +points = RandomPoints( + num_points=60, + allow_overlap=False, + fov_width=400, + fov_height=300, + max_width=4000, + max_height=4000, +) + +fs = PointsPlanWidget(points) +fs.show() + +app.exec() diff --git a/src/pymmcore_widgets/_util.py b/src/pymmcore_widgets/_util.py index 468b39b09..54c2adac3 100644 --- a/src/pymmcore_widgets/_util.py +++ b/src/pymmcore_widgets/_util.py @@ -7,12 +7,14 @@ import useq from psygnal import SignalInstance from pymmcore_plus import CMMCorePlus -from qtpy.QtCore import QObject, Qt -from qtpy.QtGui import QPainter, QPaintEvent, QPen +from qtpy.QtCore import QMarginsF, QObject, Qt +from qtpy.QtGui import QPainter, QPaintEvent, QPen, QResizeEvent from qtpy.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, + QGraphicsScene, + QGraphicsView, QLabel, QVBoxLayout, QWidget, @@ -188,3 +190,21 @@ def paintEvent(self, a0: QPaintEvent | None) -> None: painter = QPainter(self) painter.setPen(QPen(Qt.GlobalColor.gray, 1, Qt.PenStyle.SolidLine)) painter.drawLine(self.rect().topLeft(), self.rect().topRight()) + + +class ResizingGraphicsView(QGraphicsView): + """A QGraphicsView that resizes the scene to fit the view.""" + + def __init__(self, scene: QGraphicsScene, parent: QWidget | None = None) -> None: + super().__init__(scene, parent) + self.padding = 0.05 # fraction of the bounding rect + + def resizeEvent(self, event: QResizeEvent) -> None: + if not (scene := self.scene()): + return + rect = scene.itemsBoundingRect() + xmargin = rect.width() * self.padding + ymargin = rect.height() * self.padding + margins = QMarginsF(xmargin, ymargin, xmargin, ymargin) + self.fitInView(rect.marginsAdded(margins), Qt.AspectRatioMode.KeepAspectRatio) + super().resizeEvent(event) diff --git a/src/pymmcore_widgets/useq_widgets/__init__.py b/src/pymmcore_widgets/useq_widgets/__init__.py index 337173f24..f1daa92bf 100644 --- a/src/pymmcore_widgets/useq_widgets/__init__.py +++ b/src/pymmcore_widgets/useq_widgets/__init__.py @@ -15,21 +15,23 @@ from ._positions import PositionTable from ._time import TimePlanWidget from ._z import ZPlanWidget +from .points_plans import PointsPlanWidget __all__ = [ - "PositionTable", + "BoolColumn", "ChannelTable", - "DataTableWidget", - "TimePlanWidget", - "DataTable", - "MDASequenceWidget", "ChoiceColumn", - "GridPlanWidget", - "TextColumn", + "DataTable", + "DataTableWidget", "FloatColumn", + "GridPlanWidget", "IntColumn", - "ZPlanWidget", - "BoolColumn", - "TimeDeltaColumn", + "MDASequenceWidget", + "PointsPlanWidget", + "PositionTable", "PYMMCW_METADATA_KEY", + "TextColumn", + "TimeDeltaColumn", + "TimePlanWidget", + "ZPlanWidget", ] diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py b/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py index 70762aa99..0a7344f9a 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/__init__.py @@ -2,6 +2,12 @@ from ._grid_row_column_widget import GridRowColumnWidget from ._points_plan_selector import RelativePointPlanSelector +from ._points_plan_widget import PointsPlanWidget from ._random_points_widget import RandomPointWidget -__all__ = ["GridRowColumnWidget", "RandomPointWidget", "RelativePointPlanSelector"] +__all__ = [ + "GridRowColumnWidget", + "RandomPointWidget", + "RelativePointPlanSelector", + "PointsPlanWidget", +] diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py index 3e3657fe7..4a398e42a 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py @@ -47,7 +47,7 @@ def __init__(self, parent: QWidget | None = None) -> None: # overlap along y self.overlap_y = QDoubleSpinBox() self.overlap_y.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.overlap_x.setRange(-10000, 100) + self.overlap_y.setRange(-10000, 100) # order combo self.mode = QComboBox() self.mode.addItems([mode.value for mode in OrderMode]) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py index b0ecf4064..c935b0815 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py @@ -6,6 +6,7 @@ from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QButtonGroup, + QDoubleSpinBox, QGroupBox, QHBoxLayout, QLabel, @@ -15,8 +16,6 @@ ) from superqt.utils import signals_blocked -from pymmcore_widgets._util import SeparatorWidget - from ._grid_row_column_widget import GridRowColumnWidget from ._random_points_widget import RandomPointWidget @@ -46,9 +45,22 @@ def setValue(self, plan: useq.RelativePosition) -> None: pass +class _FovWidget(QDoubleSpinBox): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setSpecialValueText("--") + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setRange(0, 100000) + self.setValue(200) + self.setSingleStep(10) + + class RelativePointPlanSelector(QWidget): """Widget to select a relative multi-position point plan. + See also: [PointsPlanWidget][pymmcore_widgets.useq_widgets.PointsPlanWidget] + which combines this widget with a graphical representation of the points. + In useq, a RelativeMultiPointPlan is one of: - useq.RelativePosition - useq.RandomPoints @@ -72,6 +84,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._active_plan_widget: ( RelativePositionWidget | RandomPointWidget | GridRowColumnWidget ) = self.single_pos_wdg + self._active_plan_type: type[RelativePointPlan] = useq.RelativePosition # radio buttons selection @@ -85,17 +98,22 @@ def __init__(self, parent: QWidget | None = None) -> None: self._mode_btn_group.addButton(self.random_radio_btn) self._mode_btn_group.addButton(self.grid_radio_btn) + self.fov_w = _FovWidget() + self.fov_h = _FovWidget() + # CONNECTIONS ---------------------- self._mode_btn_group.buttonToggled.connect(self._on_radiobutton_toggled) self.random_points_wdg.valueChanged.connect(self._on_value_changed) self.grid_wdg.valueChanged.connect(self._on_value_changed) + self.fov_h.valueChanged.connect(self._on_value_changed) + self.fov_w.valueChanged.connect(self._on_value_changed) # LAYOUT ---------------------- main_layout = QVBoxLayout(self) main_layout.setSpacing(10) - main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setContentsMargins(10, 0, 10, 0) for btn, wdg in ( (self.single_radio_btn, self.single_pos_wdg), (self.random_radio_btn, self.random_points_wdg), @@ -103,8 +121,11 @@ def __init__(self, parent: QWidget | None = None) -> None: ): wdg.setEnabled(btn.isChecked()) # type: ignore [attr-defined] grpbx = QGroupBox() - grpbx.setLayout(QVBoxLayout()) - grpbx.layout().addWidget(wdg) + # make a click on the groupbox act as a click on the button + grpbx.mousePressEvent = lambda _, b=btn: b.setChecked(True) + grpbx_layout = QVBoxLayout(grpbx) + grpbx_layout.setContentsMargins(4, 6, 4, 6) + grpbx_layout.addWidget(wdg) layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) @@ -113,13 +134,35 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.addWidget(grpbx, 1) main_layout.addLayout(layout) - for i in range(0, 7, 2): - main_layout.insertWidget(i, SeparatorWidget()) + # FOV widgets go at the bottom, and are combined into a single widget + # for ease of showing/hiding the whole thing at once + self.fov_widgets = QWidget() + fov_layout = QHBoxLayout(self.fov_widgets) + fov_layout.setContentsMargins(0, 0, 0, 0) + fov_layout.setSpacing(2) + fov_layout.addSpacing(24) + fov_layout.addWidget(QLabel("FOV (w, h; µm):")) + fov_layout.addWidget(self.fov_w) + fov_layout.addWidget(self.fov_h) + main_layout.addWidget(self.fov_widgets) + + # for i in range(1, 5, 2): + # main_layout.insertWidget(i, SeparatorWidget()) # _________________________PUBLIC METHODS_________________________ # + @property + def active_plan_type(self) -> type[RelativePointPlan]: + return self._active_plan_type + def value(self) -> useq.RelativeMultiPointPlan: - return self._active_plan_widget.value() + # the fov_w/h values are global to all plans + return self._active_plan_widget.value().model_copy( + update={ + "fov_width": self.fov_w.value() or None, + "fov_height": self.fov_h.value() or None, + } + ) def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: """Set the value of the widget. @@ -129,20 +172,24 @@ def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: plan : useq.RelativePosition | useq.RandomPoints | useq.GridRowsColumns The point plan to set. """ - if isinstance(plan, useq.RandomPoints): - with signals_blocked(self.random_points_wdg): + if plan == self.value(): + return + + with signals_blocked(self): + if isinstance(plan, useq.RandomPoints): self.random_points_wdg.setValue(plan) - self.random_radio_btn.setChecked(True) - elif isinstance(plan, useq.GridRowsColumns): - with signals_blocked(self.grid_wdg): + self.random_radio_btn.setChecked(True) + elif isinstance(plan, useq.GridRowsColumns): self.grid_wdg.setValue(plan) - self.grid_radio_btn.setChecked(True) - elif isinstance(plan, useq.RelativePosition): - with signals_blocked(self.single_pos_wdg): + self.grid_radio_btn.setChecked(True) + elif isinstance(plan, useq.RelativePosition): self.single_pos_wdg.setValue(plan) - self.single_radio_btn.setChecked(True) - else: # pragma: no cover - raise ValueError(f"Invalid plan type: {type(plan)}") + self.single_radio_btn.setChecked(True) + else: # pragma: no cover + raise ValueError(f"Invalid plan type: {type(plan)}") + self.fov_h.setValue(plan.fov_height or 0) + self.fov_w.setValue(plan.fov_width or 0) + self._on_value_changed() # _________________________PRIVATE METHODS_________________________ # @@ -156,7 +203,12 @@ def _on_radiobutton_toggled(self, btn: QRadioButton, checked: bool) -> None: wdg.setEnabled(checked) if checked: self._active_plan_widget = wdg - self.valueChanged.emit(self.value()) + self._active_plan_type = { + self.single_radio_btn: useq.RelativePosition, + self.random_radio_btn: useq.RandomPoints, + self.grid_radio_btn: useq.GridRowsColumns, + }[btn] + self._on_value_changed() def _on_value_changed(self) -> None: self.valueChanged.emit(self.value()) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py new file mode 100644 index 000000000..0fc07b32b --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import useq +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QHBoxLayout, QWidget + +from pymmcore_widgets.useq_widgets.points_plans import RelativePointPlanSelector + +from ._well_graphics_view import WellView + + +class PointsPlanWidget(QWidget): + """Widget to select the FOVVs per well of the plate. + + This widget allows the user to select the number of FOVs per well, (or to generally + show a multi-point plan, such as a grid or random points plan, even if not within + the context of a well plate.) + + The value() method returns the selected plan, one of: + - [useq.GridRowsColumns][] + - [useq.RandomPoints][] + - [useq.RelativePosition][] + + Parameters + ---------- + plan : useq.RelativeMultiPointPlan | None + The useq MultiPoint plan to display and edit. + parent : QWidget | None + The parent widget. + """ + + valueChanged = Signal(object) + + def __init__( + self, + plan: useq.RelativeMultiPointPlan | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent=parent) + + self._selector = RelativePointPlanSelector() + # graphics scene to draw the well and the fovs + self._well_view = WellView() + + # main + layout = QHBoxLayout(self) + layout.addWidget(self._selector, 1) + layout.addWidget(self._well_view, 2) + + # connect + self._selector.valueChanged.connect(self._on_selector_value_changed) + self._well_view.maxPointsDetected.connect(self._on_view_max_points_detected) + self._well_view.positionClicked.connect(self._on_view_position_clicked) + + if plan is not None: + self.setValue(plan) + + def value(self) -> useq.RelativeMultiPointPlan: + return self._selector.value() + + def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: + self._selector.setValue(plan) + + def _on_selector_value_changed(self, value: useq.RelativeMultiPointPlan) -> None: + self._well_view.setPointsPlan(value) + self.valueChanged.emit(value) + + def _on_view_max_points_detected(self, value: int) -> None: + self._selector.random_points_wdg.num_points.setValue(value) + + def _on_view_position_clicked(self, position: useq.RelativePosition) -> None: + if self._selector.active_plan_type is useq.RandomPoints: + pos_no_name = position.model_copy(update={"name": ""}) + self._selector.random_points_wdg.start_at = pos_no_name diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py index fd340eb2c..d5874c54f 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -26,6 +26,10 @@ class RandomPointWidget(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + # NON-GUI attributes + + self._start_at: int = 0 + # setting a random seed for point generation reproducibility self.random_seed: int = self._new_seed() self._fov_size: tuple[float | None, float | None] = (None, None) @@ -63,6 +67,10 @@ def __init__(self, parent: QWidget | None = None) -> None: self.shape.addItems([mode.value for mode in Shape]) self.shape.setCurrentText(Shape.ELLIPSE.value) + self.order = QComboBox() + self.order.addItems([mode.value for mode in TraversalOrder]) + self.order.setCurrentText(TraversalOrder.TWO_OPT.value) + # form layout self.form = form = QFormLayout() form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) @@ -102,12 +110,26 @@ def fov_size(self, size: tuple[float | None, float | None]) -> None: """Set the FOV size.""" self._fov_size = size + # not in the gui for now... + + @property + def start_at(self) -> int: + """Return the start_at value.""" + return self._start_at + + @start_at.setter + def start_at(self, value: int) -> None: + """Set the start_at value.""" + self._start_at = value + self._on_value_changed() + def _on_value_changed(self) -> None: """Emit the valueChanged signal.""" self.valueChanged.emit(self.value()) def _on_random_clicked(self) -> None: # reset the random seed + self._start_at = 0 self.random_seed = self._new_seed() self.valueChanged.emit(self.value()) @@ -121,9 +143,10 @@ def value(self) -> RandomPoints: max_width=self.max_width.value(), max_height=self.max_height.value(), allow_overlap=self.allow_overlap.isChecked(), - order=self.order.currentText(), fov_width=fov_x, fov_height=fov_y, + order=self.order.currentText(), + start_at=self.start_at, ) def setValue(self, value: RandomPoints | Mapping) -> None: @@ -138,6 +161,7 @@ def setValue(self, value: RandomPoints | Mapping) -> None: self.shape.setCurrentText(value.shape.value) self._fov_size = (value.fov_width, value.fov_height) self.allow_overlap.setChecked(value.allow_overlap) + self.start_at = value.start_at # type: ignore # until useq is released if value.order is not None: # type: ignore # until useq is released self.order.setCurrentText(value.order.value) # type: ignore # until useq is released diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_well_graphics_view.py b/src/pymmcore_widgets/useq_widgets/points_plans/_well_graphics_view.py new file mode 100644 index 000000000..f3e4f6249 --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_well_graphics_view.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +import useq +from qtpy.QtCore import QRectF, QSize, Qt, Signal +from qtpy.QtGui import QColor, QPainter, QPen +from qtpy.QtWidgets import QGraphicsItem, QGraphicsScene, QWidget +from useq import Shape + +from pymmcore_widgets._util import ResizingGraphicsView + +if TYPE_CHECKING: + from qtpy.QtGui import QMouseEvent + +DATA_POSITION = 1 + + +class WellView(ResizingGraphicsView): + """Graphics view to draw a well and the FOVs. + + This GraphicsView shows one or more points in a useq mult-point plan (such as a + RandomPoints or GridRowsColumns plan) in a well area. The well area is drawn as a + rectangle or ellipse, and the points are drawn as rectangles or spots (if the fov + size is unknowns) + """ + + # emitted when iterating over the plan doesn't yield the expected number + maxPointsDetected = Signal(int) + # emitted when a position is clicked, the value is a useq.RelativePosition + positionClicked = Signal(object) + + def __init__(self, parent: QWidget | None = None) -> None: + self._scene = QGraphicsScene() + + super().__init__(self._scene, parent) + self.setStyleSheet("background:grey; border-radius: 5px;") + self.setRenderHints( + QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform + ) + + # the scene coordinates are all real-world coordinates, in µm + # with the origin at the center of the view (0, 0) + self._well_width_um: float | None = 6000 + self._well_height_um: float | None = 6000 + self._fov_width_um: float | None = 400 + self._fov_height_um: float | None = 340 + self._is_circular: bool = False + + # the item that draws the outline of the entire well area + self._outline_item: QGraphicsItem | None = None + # all of the rectangles representing the FOVs + self._fov_items: list[QGraphicsItem] = [] + + self.setMinimumSize(250, 250) + + def sizeHint(self) -> QSize: + return QSize(500, 500) + + def setWellSize(self, width_mm: float | None, height_mm: float | None) -> None: + """Set the well size width and height in mm.""" + self._well_width_um = (width_mm * 1000) if width_mm else None + self._well_height_um = (height_mm * 1000) if height_mm else None + + def setPointsPlan(self, plan: useq.RelativeMultiPointPlan) -> None: + """Set the plan to use to draw the FOVs.""" + self._fov_width_um = plan.fov_width + self._fov_height_um = plan.fov_height + if hasattr(plan, "shape") and isinstance(plan.shape, Shape): + self._is_circular = plan.shape == Shape.ELLIPSE + + self._draw_outline() + self._draw_fovs(plan) + + def _draw_outline(self) -> None: + """Draw the outline of the well area.""" + if self._outline_item: + self._scene.removeItem(self._outline_item) + if (rect := self._well_rect()).isNull(): + return + + pen = QPen(QColor(Qt.GlobalColor.green)) + pen.setWidth(self._scaled_pen_size()) + if self._is_circular: + self._outline_item = self._scene.addEllipse(rect, pen=pen) + else: + self._outline_item = self._scene.addRect(rect, pen=pen) + self._resize_to_fit() + + def _draw_fovs(self, plan: useq.RelativeMultiPointPlan) -> None: + """Draw the fovs in the scene as rectangles.""" + # delete existing FOVs + while self._fov_items: + self._scene.removeItem(self._fov_items.pop()) + + # constrain random points to our own well size, regardless of the plan settings + # XXX: this may not always be what we want... + if isinstance(plan, useq.RandomPoints): + updates = {} + if fovw := self._fov_width_um: + ww = self._well_width_um or (fovw * 25) + updates["max_width"] = ww - fovw * 0.5 * 1.4 + if fovh := self._fov_height_um: + wh = self._well_height_um or (fovh * 25) + updates["max_height"] = wh - fovh * 0.5 * 1.4 + plan = plan.model_copy(update=updates) + + pen = QPen(Qt.GlobalColor.white) + pen.setWidth(self._scaled_pen_size()) + line_pen = QPen(QColor(0, 0, 0, 100)) + line_pen.setWidth(int(self._scaled_pen_size() // 1.5)) + + # iterate over the plan greedily, catching any warnings + # and then alert the model if we didn't get all the points + # TODO: I think this logic should somehow be on the RandomPointsWidget itself + # however, because we add additional information above about max_width, etc + # the RandomPointsWidget doesn't have all the information it needs ... + # so we need to refactor this a bit + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + points = list(plan) + if len(points) < getattr(plan, "num_points", 0): + self.maxPointsDetected.emit(len(points)) + + # decide what type of FOV to draw. + # fov_rect will be null if no FOV is set, in which case just draw a point + if (fov_rect := self._fov_rect()).isNull(): + w = pen.width() + fov_rect = QRectF(-w / 2, -w / 2, w, w) + add_item = self._scene.addEllipse + else: + # otherwise, draw a rectangle + add_item = self._scene.addRect + + # draw the FOVs, and a connecting line + last_p: useq.RelativePosition | None = None + item: QGraphicsItem + for i, pos in enumerate(points): + # first point is black, the rest are white + first_point = i == 0 + color = Qt.GlobalColor.black if first_point else Qt.GlobalColor.white + pen.setColor(QColor(color)) + + # invert y for screen coordinates + px, py = pos.x, -pos.y + + # add the item to the scene + # (not sure when addRect would return None, but it's possible...) + if item := add_item(fov_rect.translated(px, py), pen): + item.setData(DATA_POSITION, pos) + item.setZValue(100 if first_point else 0) + self._fov_items.append(item) + + # draw a line from the last point to this one + if i > 0 and last_p: + line = self._scene.addLine(last_p.x, -last_p.y, px, py, line_pen) + line.setZValue(2) + self._fov_items.append(line) + last_p = pos + + self._resize_to_fit() + + def _well_rect(self) -> QRectF: + """Return the QRectF of the well area.""" + if not (ww := self._well_width_um) or not (wh := self._well_height_um): + return QRectF() + return QRectF(-ww / 2, -wh / 2, ww, wh) + + def _fov_rect(self) -> QRectF: + """Return the QRectF of the FOV area.""" + fov_w = self._fov_width_um or 0 + fov_h = self._fov_height_um or 0 + if not fov_w and not fov_h: + return QRectF() + return QRectF(-fov_w / 2, -fov_h / 2, fov_w, fov_h) + + def _scaled_pen_size(self) -> int: + # pick a pen size appropriate for the scene scale + # we might also want to scale this based on the sceneRect... + # and it's possible this needs to be rescaled on resize + if self._well_width_um: + return int(self._well_width_um / 150) + return max(61, int(self.sceneRect().width() / 150)) + + def _resize_to_fit(self) -> None: + self.setSceneRect(self._scene.itemsBoundingRect()) + self.resizeEvent(None) + + def mousePressEvent(self, event: QMouseEvent | None) -> None: + if event is not None: + scene_pos = self.mapToScene(event.pos()) + items = self.scene().items(scene_pos) + for item in items: + if pos := item.data(DATA_POSITION): + self.positionClicked.emit(pos) + break diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py index dc5e10fac..6f48ffd7c 100644 --- a/tests/useq_widgets/test_useq_points_plans.py +++ b/tests/useq_widgets/test_useq_points_plans.py @@ -2,7 +2,12 @@ from typing import TYPE_CHECKING -from useq import GridRowsColumns, OrderMode, RandomPoints, RelativePosition +import pytest +import qtpy +import useq +from qtpy.QtCore import Qt +from qtpy.QtGui import QMouseEvent +from useq import GridRowsColumns, OrderMode, RandomPoints, RelativePosition, Shape from pymmcore_widgets.useq_widgets import points_plans as pp @@ -31,6 +36,8 @@ relative_to="top_left", ) +RELATIVE_POSITION = RelativePosition() + def test_random_points_widget(qtbot: QtBot) -> None: wdg = pp.RandomPointWidget() @@ -86,9 +93,109 @@ def test_point_plan_selector(qtbot: QtBot) -> None: assert wdg.value() == RANDOM_POINTS assert wdg.random_radio_btn.isChecked() + wdg.setValue(RELATIVE_POSITION) + assert wdg.value() == RELATIVE_POSITION + assert wdg.single_radio_btn.isChecked() + wdg.setValue(GRID_ROWS_COLS) assert wdg.value() == GRID_ROWS_COLS assert wdg.grid_radio_btn.isChecked() wdg.random_radio_btn.setChecked(True) - assert wdg.value() == RANDOM_POINTS + # fov_width and fov_height are global to the RelativePointPlanSelector + # so setting the value to GRID_ROWS_COLS will update the fov_width and fov_height + assert wdg.value() == RANDOM_POINTS.model_copy( + update={ + "fov_width": GRID_ROWS_COLS.fov_width, + "fov_height": GRID_ROWS_COLS.fov_height, + } + ) + + +def test_points_plan_widget(qtbot: QtBot) -> None: + """PointsPlanWidget is a RelativePointPlanSelector combined with a graphics view.""" + wdg = pp.PointsPlanWidget() + wdg.show() + qtbot.addWidget(wdg) + + for plan in (RANDOM_POINTS, RELATIVE_POSITION, GRID_ROWS_COLS): + with qtbot.waitSignal(wdg.valueChanged): + wdg.setValue(plan) + assert wdg.value() == plan + + +@pytest.mark.parametrize( + "plan", + [ + RELATIVE_POSITION, + GRID_ROWS_COLS, + RANDOM_POINTS, + RANDOM_POINTS.replace(shape=Shape.ELLIPSE), + RANDOM_POINTS.replace(fov_width=None), + RANDOM_POINTS.replace(fov_width=None, fov_height=None), + ], +) +def test_points_plan_variants(plan: useq.RelativeMultiPointPlan, qtbot: QtBot) -> None: + """Test PointsPlanWidget with different plan types.""" + wdg = pp.PointsPlanWidget(plan) + wdg.show() + qtbot.addWidget(wdg) + # make sure the view can also render without a well size + wdg._well_view.setWellSize(None, None) + wdg._well_view.setPointsPlan(plan) + assert wdg.value() == plan + + +@pytest.mark.skipif(qtpy.QT5, reason="QMouseEvent API changed") +def test_clicking_point_changes_first_position(qtbot: QtBot) -> None: + plan = RandomPoints( + num_points=20, + random_seed=0, + fov_width=500, + fov_height=500, + max_width=1000, + max_height=1000, + ) + wdg = pp.PointsPlanWidget(plan) + wdg.show() + qtbot.addWidget(wdg) + + assert isinstance(wdg.value().start_at, int) + + # clicking on a point should change the start_at position + event = QMouseEvent( + QMouseEvent.Type.MouseButtonPress, + wdg._well_view.mapFromScene(0, 0).toPointF(), + wdg._well_view.mapFromScene(0, 0).toPointF(), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + wdg._well_view.mousePressEvent(event) + + new_val = wdg.value() + assert isinstance(new_val.start_at, useq.RelativePosition) + rounded = round(new_val.start_at) + # feel free to relax this if it ever fails tests + assert rounded.x == 122 + assert rounded.y == -50 + + +def test_max_points_detected(qtbot: QtBot) -> None: + plan = RandomPoints( + num_points=20, + random_seed=0, + fov_width=500, + fov_height=500, + max_width=1000, + max_height=1000, + allow_overlap=False, + ) + wdg = pp.PointsPlanWidget(plan) + wdg.show() + qtbot.addWidget(wdg) + + with qtbot.waitSignal(wdg._well_view.maxPointsDetected): + wdg._selector.random_points_wdg.num_points.setValue(100) + + assert wdg.value().num_points < 60 From db0217a8dfbe1ba5b300ffe701c0dbe8905be58b Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Thu, 11 Jul 2024 18:15:40 -0400 Subject: [PATCH 5/8] feat: add restrict well area (#319) * feat: multi point plan useq widgets * pull out pyproject * pyproject * cleanup grid row * cleanup * style(pre-commit.ci): auto fixes [...] * tests * lint * bump * fov-widget * wip * finish * rm x * remove hcs * test * lint * more test * style * typing * rename * lint * fix again you dummy * fov-widget-selector * wip * demo * init files * fix import * chore: Update FOVSelectorWidget to disallow overlap in random points * minor * add connecting line * click on point * style(pre-commit.ci): auto fixes [...] * lint * fixup * deal with None * cleanup * fix test * more fov fixes * feat: restrict well area * move to useq * fix: comment * add tests * fix: order * tighten * fix test * fix deprecation * docs * test: add * test: add * fix: _draw_fov_constrain_area * fix: well_size * fix: temp init * fix: temp init * fix: points_bounding_area * fix: circularWell * changes * feat: Update well and FOV dimensions in PointsPlanWidget - Update the default width and height of the field of view (FOV) in the PointsPlanWidget to 300x200. - Remove the explicit setting of well size in the PointsPlanWidget initialization. Resolves: #123 * fix: example * test: add * test: fix comment * test: update --------- Co-authored-by: Talley Lambert Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- examples/points_plan_widget.py | 5 +- .../points_plans/_points_plan_widget.py | 22 ++++ .../points_plans/_random_points_widget.py | 4 +- .../points_plans/_well_graphics_view.py | 106 +++++++++++----- tests/useq_widgets/test_useq_points_plans.py | 120 +++++++++++++++++- 5 files changed, 213 insertions(+), 44 deletions(-) diff --git a/examples/points_plan_widget.py b/examples/points_plan_widget.py index bf60e0b05..5a03a5454 100644 --- a/examples/points_plan_widget.py +++ b/examples/points_plan_widget.py @@ -8,13 +8,14 @@ points = RandomPoints( num_points=60, allow_overlap=False, - fov_width=400, - fov_height=300, + fov_width=300, + fov_height=200, max_width=4000, max_height=4000, ) fs = PointsPlanWidget(points) +fs.setWellSize(6, 6) fs.show() app.exec() diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py index 0fc07b32b..7e8d5f64e 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py @@ -56,11 +56,33 @@ def __init__( self.setValue(plan) def value(self) -> useq.RelativeMultiPointPlan: + """Return the selected plan.""" return self._selector.value() def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: + """Set the current plan.""" self._selector.setValue(plan) + def setWellSize( + self, width: float | None = None, height: float | None = None + ) -> None: + """Set the well size width and/or height in mm.""" + self._well_view.setWellSize(width, height) + + def setWellShape(self, shape: useq.Shape | str) -> None: + """Set the shape of the well. + + Can be a `useq.Shape` enum or the strings "circle", "ellipse", + "square", or "rectangle". + """ + if isinstance(shape, str): + if shape.lower() == "circle": + shape = useq.Shape.ELLIPSE + elif shape.lower() == "square": + shape = useq.Shape.RECTANGLE + shape = useq.Shape(shape) + self._well_view.setWellCircular(shape == useq.Shape.ELLIPSE) + def _on_selector_value_changed(self, value: useq.RelativeMultiPointPlan) -> None: self._well_view.setPointsPlan(value) self.valueChanged.emit(value) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py index d5874c54f..8c91cee44 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -42,13 +42,13 @@ def __init__(self, parent: QWidget | None = None) -> None: self.max_width = QDoubleSpinBox() self.max_width.setAlignment(Qt.AlignmentFlag.AlignCenter) self.max_width.setRange(1, 1000000) - self.max_width.setValue(1000) + self.max_width.setValue(6000) self.max_width.setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType) # well area doublespinbox along y self.max_height = QDoubleSpinBox() self.max_height.setAlignment(Qt.AlignmentFlag.AlignCenter) self.max_height.setRange(1, 1000000) - self.max_height.setValue(1000) + self.max_height.setValue(6000) self.max_height.setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType) # number of FOVs spinbox self.num_points = QSpinBox() diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_well_graphics_view.py b/src/pymmcore_widgets/useq_widgets/points_plans/_well_graphics_view.py index f3e4f6249..85f020b84 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_well_graphics_view.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_well_graphics_view.py @@ -42,14 +42,21 @@ def __init__(self, parent: QWidget | None = None) -> None: # the scene coordinates are all real-world coordinates, in µm # with the origin at the center of the view (0, 0) - self._well_width_um: float | None = 6000 - self._well_height_um: float | None = 6000 - self._fov_width_um: float | None = 400 - self._fov_height_um: float | None = 340 - self._is_circular: bool = False - + self._well_width_um: float | None = None + self._well_height_um: float | None = None + self._well_is_circular: bool = True # the item that draws the outline of the entire well area - self._outline_item: QGraphicsItem | None = None + self._well_outline_item: QGraphicsItem | None = None + + # an area (independent of the well size) that the points are constrained to + self._bounding_area_width_um: float | None = None + self._bounding_area_height_um: float | None = None + self._bounding_area_is_circular: bool = False + self._bounding_area_item: QGraphicsItem | None = None + + self._fov_width_um: float | None = None + self._fov_height_um: float | None = None + # all of the rectangles representing the FOVs self._fov_items: list[QGraphicsItem] = [] @@ -62,31 +69,68 @@ def setWellSize(self, width_mm: float | None, height_mm: float | None) -> None: """Set the well size width and height in mm.""" self._well_width_um = (width_mm * 1000) if width_mm else None self._well_height_um = (height_mm * 1000) if height_mm else None + self._draw_well_outline() + self._resize_to_fit() + + def setWellCircular(self, is_circular: bool) -> None: + """Set the shape of the well.""" + self._well_is_circular = is_circular + self._draw_well_outline() + self._resize_to_fit() def setPointsPlan(self, plan: useq.RelativeMultiPointPlan) -> None: """Set the plan to use to draw the FOVs.""" self._fov_width_um = plan.fov_width self._fov_height_um = plan.fov_height - if hasattr(plan, "shape") and isinstance(plan.shape, Shape): - self._is_circular = plan.shape == Shape.ELLIPSE + if isinstance(plan, useq.RandomPoints): + self._bounding_area_is_circular = plan.shape == Shape.ELLIPSE + self._bounding_area_width_um = plan.max_width + self._bounding_area_height_um = plan.max_height + else: + self._bounding_area_width_um = None + self._bounding_area_height_um = None - self._draw_outline() + self._draw_well_outline() + self._draw_points_bounding_area() self._draw_fovs(plan) + self._resize_to_fit() - def _draw_outline(self) -> None: + def _draw_well_outline(self) -> None: """Draw the outline of the well area.""" - if self._outline_item: - self._scene.removeItem(self._outline_item) + if self._well_outline_item: + self._scene.removeItem(self._well_outline_item) + self._well_outline_item = None + if (rect := self._well_rect()).isNull(): return pen = QPen(QColor(Qt.GlobalColor.green)) pen.setWidth(self._scaled_pen_size()) - if self._is_circular: - self._outline_item = self._scene.addEllipse(rect, pen=pen) + pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin) + + if self._well_is_circular: + self._well_outline_item = self._scene.addEllipse(rect, pen=pen) else: - self._outline_item = self._scene.addRect(rect, pen=pen) - self._resize_to_fit() + self._well_outline_item = self._scene.addRect(rect, pen=pen) + + def _draw_points_bounding_area(self) -> None: + """Draw the points bounding area.""" + if self._bounding_area_item: + self._scene.removeItem(self._bounding_area_item) + self._bounding_area_item = None + + if (rect := self._points_bounding_area_rect()).isNull(): + return + + pen = QPen(QColor(Qt.GlobalColor.magenta)) + pen.setWidth(self._scaled_pen_size()) + pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin) + pen.setStyle(Qt.PenStyle.DotLine) + + if self._bounding_area_is_circular: + self._bounding_area_item = self._scene.addEllipse(rect, pen=pen) + else: + self._bounding_area_item = self._scene.addRect(rect, pen=pen) def _draw_fovs(self, plan: useq.RelativeMultiPointPlan) -> None: """Draw the fovs in the scene as rectangles.""" @@ -94,22 +138,12 @@ def _draw_fovs(self, plan: useq.RelativeMultiPointPlan) -> None: while self._fov_items: self._scene.removeItem(self._fov_items.pop()) - # constrain random points to our own well size, regardless of the plan settings - # XXX: this may not always be what we want... - if isinstance(plan, useq.RandomPoints): - updates = {} - if fovw := self._fov_width_um: - ww = self._well_width_um or (fovw * 25) - updates["max_width"] = ww - fovw * 0.5 * 1.4 - if fovh := self._fov_height_um: - wh = self._well_height_um or (fovh * 25) - updates["max_height"] = wh - fovh * 0.5 * 1.4 - plan = plan.model_copy(update=updates) - pen = QPen(Qt.GlobalColor.white) - pen.setWidth(self._scaled_pen_size()) + pen.setWidth(int(self._scaled_pen_size() / 1.6)) + pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin) line_pen = QPen(QColor(0, 0, 0, 100)) - line_pen.setWidth(int(self._scaled_pen_size() // 1.5)) + line_pen.setWidth(int(self._scaled_pen_size() / 1.75)) + line_pen.setCapStyle(Qt.PenCapStyle.RoundCap) # iterate over the plan greedily, catching any warnings # and then alert the model if we didn't get all the points @@ -159,14 +193,20 @@ def _draw_fovs(self, plan: useq.RelativeMultiPointPlan) -> None: self._fov_items.append(line) last_p = pos - self._resize_to_fit() - def _well_rect(self) -> QRectF: """Return the QRectF of the well area.""" if not (ww := self._well_width_um) or not (wh := self._well_height_um): return QRectF() return QRectF(-ww / 2, -wh / 2, ww, wh) + def _points_bounding_area_rect(self) -> QRectF: + """Return the QRectF for the FOVs bounding area.""" + if (baw := self._bounding_area_width_um) is None or ( + bah := self._bounding_area_height_um + ) is None: + return QRectF() + return QRectF(-baw / 2, -bah / 2, baw, bah) + def _fov_rect(self) -> QRectF: """Return the QRectF of the FOV area.""" fov_w = self._fov_width_um or 0 diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py index 6f48ffd7c..0ff69b042 100644 --- a/tests/useq_widgets/test_useq_points_plans.py +++ b/tests/useq_widgets/test_useq_points_plans.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple import pytest import qtpy import useq from qtpy.QtCore import Qt from qtpy.QtGui import QMouseEvent +from qtpy.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsRectItem from useq import GridRowsColumns, OrderMode, RandomPoints, RelativePosition, Shape from pymmcore_widgets.useq_widgets import points_plans as pp @@ -14,6 +15,8 @@ if TYPE_CHECKING: from pytestqt.qtbot import QtBot + from pymmcore_widgets.useq_widgets.points_plans._well_graphics_view import WellView + RANDOM_POINTS = RandomPoints( num_points=5, shape="rectangle", @@ -43,8 +46,8 @@ def test_random_points_widget(qtbot: QtBot) -> None: wdg = pp.RandomPointWidget() qtbot.addWidget(wdg) assert wdg.num_points.value() == 1 - assert wdg.max_width.value() == 1000 - assert wdg.max_height.value() == 1000 + assert wdg.max_width.value() == 6000 + assert wdg.max_height.value() == 6000 assert wdg.shape.currentText() == "ellipse" assert not wdg.allow_overlap.isChecked() assert wdg.order.currentText() == "two_opt" @@ -153,8 +156,8 @@ def test_clicking_point_changes_first_position(qtbot: QtBot) -> None: random_seed=0, fov_width=500, fov_height=500, - max_width=1000, - max_height=1000, + max_width=5000, + max_height=5000, ) wdg = pp.PointsPlanWidget(plan) wdg.show() @@ -177,8 +180,8 @@ def test_clicking_point_changes_first_position(qtbot: QtBot) -> None: assert isinstance(new_val.start_at, useq.RelativePosition) rounded = round(new_val.start_at) # feel free to relax this if it ever fails tests - assert rounded.x == 122 - assert rounded.y == -50 + assert rounded.x == 108 + assert rounded.y == -44 def test_max_points_detected(qtbot: QtBot) -> None: @@ -199,3 +202,106 @@ def test_max_points_detected(qtbot: QtBot) -> None: wdg._selector.random_points_wdg.num_points.setValue(100) assert wdg.value().num_points < 60 + + +def test_points_plan_set_well_info(qtbot: QtBot) -> None: + wdg = pp.PointsPlanWidget() + wdg.show() + + assert wdg._well_view._well_outline_item is None + assert wdg._well_view._bounding_area_item is None + assert wdg._well_view._well_is_circular + + wdg.setWellSize(3, 3) + + assert wdg._well_view._well_outline_item + assert wdg._well_view._well_outline_item.isVisible() + assert isinstance(wdg._well_view._well_outline_item, QGraphicsEllipseItem) + + wdg.setWellShape("square") + assert isinstance(wdg._well_view._well_outline_item, QGraphicsRectItem) + + wdg.setWellShape("circle") + assert isinstance(wdg._well_view._well_outline_item, QGraphicsEllipseItem) + + plan = RandomPoints( + num_points=3, fov_width=500, fov_height=0, max_width=1000, max_height=1500 + ) + wdg.setValue(plan) + + assert wdg._well_view._bounding_area_item + assert wdg._well_view._bounding_area_item.isVisible() + assert isinstance(wdg._well_view._bounding_area_item, QGraphicsEllipseItem) + + well = wdg._well_view._well_outline_item.sceneBoundingRect() + bound = wdg._well_view._bounding_area_item.sceneBoundingRect() + assert well.center() == bound.center() + offset = 20 # ofset automatically added when drawing + # bounding rect should be 1/3 the size of the well rect in width + assert well.width() - offset == (bound.width() - offset) * 3 + # bounding rect should be 1/2 the size of the well rect in height + assert well.height() - offset == (bound.height() - offset) * 2 + + wdg.setWellSize(None, None) + assert wdg._well_view._well_outline_item is None + + +class SceneItems(NamedTuple): + rect: int # QGraphicsRectItem (fovs/well area/bounding area) + lines: int # QGraphicsLineItem (fovs lines) + circles: int # QGraphicsEllipseItem (well area/bounding area) + + +def get_items_number(view: WellView) -> SceneItems: + """Return the number of items in the scene as a SceneItems namedtuple.""" + items = view.scene().items() + lines = len([ln for ln in items if isinstance(ln, QGraphicsLineItem)]) + circles = len([c for c in items if isinstance(c, QGraphicsEllipseItem)]) + rect = len([r for r in items if isinstance(r, QGraphicsRectItem)]) + return SceneItems(rect, lines, circles) + + +# fmt: off +rp = RandomPoints(num_points=3, max_width=1000, max_height=1000,fov_width=410, fov_height=300, random_seed=0) # noqa E501 +plans = [ + # plan, well_shape, expected number of QGraphicsItems + (useq.RelativePosition(), "square", SceneItems(rect=0, lines=0, circles=2)), + (useq.RelativePosition(fov_width=100, fov_height=50), "circle", SceneItems(rect=1, lines=0, circles=1)), # noqa E501 + (rp, "ellipse", SceneItems(rect=3, lines=2, circles=2)), + (rp.replace(shape="rectangle"),"rectangle", SceneItems(rect=4, lines=2, circles=1)), + (GridRowsColumns(rows=2, columns=3, fov_width=400, fov_height=500), "circle", SceneItems(rect=6, lines=5, circles=1)) # noqa E501 +] +# fmt: on + + +# make sure that the correct QGraphicsItems are drawn in the scene +@pytest.mark.parametrize(["plan", "shape", "expedted_items"], plans) +def test_points_plan_plans( + qtbot: QtBot, + plan: useq.RelativeMultiPointPlan, + shape: str, + expedted_items: SceneItems, +): + wdg = pp.PointsPlanWidget(plan=plan) + wdg.setWellSize(3, 3) # fix well size + wdg.setWellShape("circle") # fix well shape + wdg.show() + assert get_items_number(wdg._well_view) == expedted_items + + +@pytest.mark.parametrize(["plan", "shape", "expedted_items"], plans) +def test_points_plan_set_get_value( + qtbot: QtBot, + plan: useq.RelativeMultiPointPlan, + shape: str, + expedted_items: SceneItems, +): + wdg = pp.PointsPlanWidget() + wdg.setWellSize(3, 3) # fix well size + wdg.setWellShape("circle") # fix well shape + wdg.show() + + wdg.setValue(plan) + + assert get_items_number(wdg._well_view) == expedted_items + assert wdg.value() == plan From 426baf293485a214d7f0c1deed5132da44c99fab Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 11 Jul 2024 20:03:55 -0400 Subject: [PATCH 6/8] feat: add useq.WellPlanPlan widget with well selection (#318) * plate-selector * plate-selector 2 * drawing plate * minimal working example * add init * add names * add mouse events * wip * wip * nice selection * good state * move stuff * better selection and rotation drawing * test: add tests * docs --------- Co-authored-by: Federico Gasparoli --- examples/well_plate_widget.py | 22 + src/pymmcore_widgets/_util.py | 2 +- src/pymmcore_widgets/useq_widgets/__init__.py | 2 + .../useq_widgets/_well_plate_widget.py | 404 ++++++++++++++++++ tests/useq_widgets/test_plate_widget.py | 111 +++++ 5 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 examples/well_plate_widget.py create mode 100644 src/pymmcore_widgets/useq_widgets/_well_plate_widget.py create mode 100644 tests/useq_widgets/test_plate_widget.py diff --git a/examples/well_plate_widget.py b/examples/well_plate_widget.py new file mode 100644 index 000000000..36a9e0ba7 --- /dev/null +++ b/examples/well_plate_widget.py @@ -0,0 +1,22 @@ +from contextlib import suppress + +import useq +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets.useq_widgets import WellPlateWidget + +with suppress(ImportError): + from rich import print + + +app = QApplication([]) + +plan = useq.WellPlatePlan( + plate="24-well", a1_center_xy=(0, 0), selected_wells=slice(0, 8, 2) +) + +ps = WellPlateWidget(plan) +ps.valueChanged.connect(print) +ps.show() + +app.exec() diff --git a/src/pymmcore_widgets/_util.py b/src/pymmcore_widgets/_util.py index 54c2adac3..251251d23 100644 --- a/src/pymmcore_widgets/_util.py +++ b/src/pymmcore_widgets/_util.py @@ -199,7 +199,7 @@ def __init__(self, scene: QGraphicsScene, parent: QWidget | None = None) -> None super().__init__(scene, parent) self.padding = 0.05 # fraction of the bounding rect - def resizeEvent(self, event: QResizeEvent) -> None: + def resizeEvent(self, event: QResizeEvent | None) -> None: if not (scene := self.scene()): return rect = scene.itemsBoundingRect() diff --git a/src/pymmcore_widgets/useq_widgets/__init__.py b/src/pymmcore_widgets/useq_widgets/__init__.py index f1daa92bf..97cc16542 100644 --- a/src/pymmcore_widgets/useq_widgets/__init__.py +++ b/src/pymmcore_widgets/useq_widgets/__init__.py @@ -14,6 +14,7 @@ from ._mda_sequence import PYMMCW_METADATA_KEY, MDASequenceWidget from ._positions import PositionTable from ._time import TimePlanWidget +from ._well_plate_widget import WellPlateWidget from ._z import ZPlanWidget from .points_plans import PointsPlanWidget @@ -33,5 +34,6 @@ "TextColumn", "TimeDeltaColumn", "TimePlanWidget", + "WellPlateWidget", "ZPlanWidget", ] diff --git a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py new file mode 100644 index 000000000..029032896 --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable, Mapping + +import useq +from qtpy.QtCore import QRect, QRectF, QSize, Qt, Signal +from qtpy.QtGui import QFont, QMouseEvent, QPainter, QPen +from qtpy.QtWidgets import ( + QAbstractGraphicsShapeItem, + QCheckBox, + QComboBox, + QGraphicsItem, + QGraphicsScene, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) +from superqt.utils import signals_blocked + +from pymmcore_widgets._util import ResizingGraphicsView + +if TYPE_CHECKING: + from qtpy.QtGui import QMouseEvent + + Index = int | list[int] | tuple[int] | slice + IndexExpression = tuple[Index, ...] | Index + + +def _sort_plate(item: str) -> tuple[int, int | str]: + """Sort well plate keys by number first, then by string.""" + parts = item.split("-") + if parts[0].isdigit(): + return (0, int(parts[0])) + return (1, item) + + +DATA_POSITION = 1 +DATA_INDEX = 2 + +# in the WellPlateView, any item that merely posses a brush color of SELECTED_COLOR +# IS a selected object. +SELECTED_COLOR = Qt.GlobalColor.green +UNSELECTED_COLOR = Qt.GlobalColor.transparent + + +class WellPlateWidget(QWidget): + """Widget for selecting a well plate and a subset of wells. + + The value returned/received by this widget is a [useq.WellPlatePlan][] (or simply + a [useq.WellPlate][] if no selection is made). This widget draws the well plate + and allows the user to select wells by clicking/dragging on them. + + Parameters + ---------- + plan: useq.WellPlatePlan | useq.WellPlate | None, optional + The initial well plate plan. Accepts both a useq.WellPlate (which lacks a + selection definition), or a full WellPlatePlan. By default None. + parent : QWidget, optional + The parent widget, by default None + """ + + valueChanged = Signal(object) + + def __init__( + self, + plan: useq.WellPlatePlan | useq.WellPlate | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + + self._plate: useq.WellPlate | None = None + self._a1_center_xy: tuple[float, float] = (0.0, 0.0) + self._rotation: float | None = None + + # WIDGETS --------------------------------------- + + # well plate combobox + self.plate_name = QComboBox() + plate_names = sorted(useq.registered_well_plate_keys(), key=_sort_plate) + self.plate_name.addItems(plate_names) + + # clear selection button + self._clear_button = QPushButton(text="Clear Selection") + self._clear_button.setAutoDefault(False) + + # plate view + self._view = WellPlateView(self) + + self._show_rotation = QCheckBox("Show Rotation", self._view) + self._show_rotation.move(6, 6) + self._show_rotation.hide() + + # LAYOUT --------------------------------------- + + top_layout = QHBoxLayout() + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget(QLabel("WellPlate:"), 0) + top_layout.addWidget(self.plate_name, 1) + top_layout.addWidget(self._clear_button, 0) + + main_layout = QVBoxLayout(self) + main_layout.setSpacing(15) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addLayout(top_layout) + main_layout.addWidget(self._view) + + # connect + self._view.selectionChanged.connect(self._on_value_changed) + self._clear_button.clicked.connect(self._view.clearSelection) + self.plate_name.currentTextChanged.connect(self._on_plate_name_changed) + self._show_rotation.toggled.connect(self._on_show_rotation_toggled) + + if plan: + self.setValue(plan) + + # _________________________PUBLIC METHODS_________________________ # + + def value(self) -> useq.WellPlatePlan: + """Return the current plate and the selected wells as a `useq.WellPlatePlan`.""" + return useq.WellPlatePlan( + plate=self._plate or useq.WellPlate.from_str(self.plate_name.currentText()), + a1_center_xy=self._a1_center_xy, + rotation=self._rotation, + selected_wells=tuple(zip(*self.currentSelection())), + ) + + def setValue(self, value: useq.WellPlatePlan | useq.WellPlate | Mapping) -> None: + """Set the current plate and the selected wells. + + Parameters + ---------- + value : PlateInfo + The plate information to set containing the plate and the selected wells + as a list of (name, row, column). + """ + if isinstance(value, useq.WellPlate): + plan = useq.WellPlatePlan(plate=value, a1_center_xy=(0, 0)) + else: + plan = useq.WellPlatePlan.model_validate(value) + + self._plate = plan.plate + self._rotation = plan.rotation + self._a1_center_xy = plan.a1_center_xy + with signals_blocked(self): + self.plate_name.setCurrentText(plan.plate.name) + self._view.drawPlate(plan) + + if plan.rotation: + self._show_rotation.show() + self._show_rotation.setChecked(True) + else: + self._show_rotation.hide() + self._show_rotation.setChecked(False) + + def currentSelection(self) -> tuple[tuple[int, int], ...]: + """Return the indices of the selected wells as `((row, col), ...)`.""" + return self._view.selectedIndices() + + def setCurrentSelection(self, selection: IndexExpression) -> None: + """Select the wells with the given indices. + + `selection` can be any 2-d numpy indexing expression, e.g.: + - (0, 0) + - [(0, 0), (1, 1), (2, 2)] + - slice(0, 2) + - (0, slice(0, 2)) + """ + self.setValue( + useq.WellPlatePlan( + plate=self.plate_name.currentText(), + a1_center_xy=(0, 0), + selected_wells=selection, + ) + ) + + # _________________________PRIVATE METHODS________________________ # + + def _on_value_changed(self) -> None: + """Emit the valueChanged signal when the value changes.""" + self.valueChanged.emit(self.value()) + + def _on_plate_name_changed(self, plate_name: str) -> None: + plate = useq.WellPlate.from_str(plate_name) + val = self.value().model_copy(update={"plate": plate, "selected_wells": None}) + self.setValue(val) + + def _on_show_rotation_toggled(self, checked: bool) -> None: + rot = self._rotation if checked else None + val = self.value().model_copy(update={"rotation": rot}) + self._view.drawPlate(val) + + +class WellPlateView(ResizingGraphicsView): + """QGraphicsView for displaying a well plate.""" + + selectionChanged = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + self._scene = QGraphicsScene() + super().__init__(self._scene, parent) + self.setStyleSheet("background:grey; border-radius: 5px;") + self.setRenderHints( + QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform + ) + # RubberBandDrag enables rubber band selection with mouse + self.setDragMode(self.DragMode.RubberBandDrag) + self.rubberBandChanged.connect(self._on_rubber_band_changed) + + # all the graphics items that outline wells + self._well_items: list[QAbstractGraphicsShapeItem] = [] + # all the graphics items that label wells + self._well_labels: list[QGraphicsItem] = [] + + # we manually manage the selection state of items + self._selected_items: set[QAbstractGraphicsShapeItem] = set() + # the set of selected items at the time of the mouse press + self._selection_on_press: set[QAbstractGraphicsShapeItem] = set() + + # item at the point where the mouse was pressed + self._pressed_item: QAbstractGraphicsShapeItem | None = None + # whether option/alt is pressed at the time of the mouse press + self._is_removing = False + + def _on_rubber_band_changed(self, rect: QRect) -> None: + """When the rubber band changes, select the items within the rectangle.""" + if rect.isNull(): # pragma: no cover + # this is the last signal emitted when releasing the mouse + return + + # all scene items within the rubber band + bounded_items = set(self._scene.items(self.mapToScene(rect).boundingRect())) + + # loop through all wells and recolor them based on their selection state + select = set() + deselect = set() + for item in self._well_items: + if item in bounded_items: + if self._is_removing: + deselect.add(item) + else: + select.add(item) + # if the item is not in the rubber band, keep its previous state + elif item in self._selection_on_press: + select.add(item) + else: + deselect.add(item) + with signals_blocked(self): + self._select_items(select) + self._deselect_items(deselect) + + def mousePressEvent(self, event: QMouseEvent | None) -> None: + if event and event.button() == Qt.MouseButton.LeftButton: + # store the state of selected items at the time of the mouse press + self._selection_on_press = self._selected_items.copy() + + # when the cmd/control key is pressed, add to the selection + if event.modifiers() & Qt.KeyboardModifier.AltModifier: + self._is_removing = True + + # store the item at the point where the mouse was pressed + for item in self.items(event.pos()): + if isinstance(item, QAbstractGraphicsShapeItem): + self._pressed_item = item + break + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent | None) -> None: + if event and event.button() == Qt.MouseButton.LeftButton: + # if we are on the same item that we pressed, + # toggle selection of that item + for item in self.items(event.pos()): + if item == self._pressed_item: + if self._pressed_item.brush().color() == SELECTED_COLOR: + self._deselect_items((self._pressed_item,)) + else: + self._select_items((self._pressed_item,)) + break + + self._pressed_item = None + self._is_removing = False + self._selection_on_press.clear() + super().mouseReleaseEvent(event) + + def selectedIndices(self) -> tuple[tuple[int, int], ...]: + """Return the indices of the selected wells.""" + return tuple(sorted(item.data(DATA_INDEX) for item in self._selected_items)) + + def setSelectedIndices(self, indices: Iterable[tuple[int, int]]) -> None: + """Select the wells with the given indices. + + Parameters + ---------- + indices : Iterable[tuple[int, int]] + The indices of the wells to select. Each index is a tuple of row and column. + e.g. [(0, 0), (1, 1), (2, 2)] + """ + _indices = {tuple(idx) for idx in indices} + select = set() + deselect = set() + for item in self._well_items: + if item.data(DATA_INDEX) in _indices: + select.add(item) + else: + deselect.add(item) + with signals_blocked(self): + self._select_items(select) + self._deselect_items(deselect) + + def clearSelection(self) -> None: + """Clear the current selection.""" + self._deselect_items(self._selected_items) + + def clear(self) -> None: + """Clear all the wells from the view.""" + while self._well_items: + self._scene.removeItem(self._well_items.pop()) + while self._well_labels: + self._scene.removeItem(self._well_labels.pop()) + self.clearSelection() + + def drawPlate(self, plan: useq.WellPlate | useq.WellPlatePlan) -> None: + """Draw the well plate on the view. + + Parameters + ---------- + plan : useq.WellPlate | useq.WellPlatePlan + The WellPlatePlan to draw. If a WellPlate is provided, the plate is drawn + with no selected wells. + """ + if isinstance(plan, useq.WellPlate): # pragma: no cover + plan = useq.WellPlatePlan(a1_center_xy=(0, 0), plate=plan) + + well_width = plan.plate.well_size[0] * 1000 + well_height = plan.plate.well_size[1] * 1000 + well_rect = QRectF(-well_width / 2, -well_height / 2, well_width, well_height) + add_item = ( + self._scene.addEllipse if plan.plate.circular_wells else self._scene.addRect + ) + + # font for well labels + font = QFont() + font.setPixelSize(int(min(6000, well_rect.width() / 2.5))) + + # Since most plates have the same extent, a constant pen width seems to work + pen = QPen(Qt.GlobalColor.black) + pen.setWidth(200) + + self.clear() + indices = plan.all_well_indices.reshape(-1, 2) + for idx, pos in zip(indices, plan.all_well_positions): + # invert y-axis for screen coordinates + screen_x, screen_y = pos.x, -pos.y + rect = well_rect.translated(screen_x, screen_y) + if item := add_item(rect, pen): + item.setData(DATA_POSITION, pos) + item.setData(DATA_INDEX, tuple(idx.tolist())) + if plan.rotation: + item.setTransformOriginPoint(rect.center()) + item.setRotation(-plan.rotation) + self._well_items.append(item) + + # NOTE, we are *not* using the Qt selection model here due to + # customizations that we want to make. So we don't use... + # item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) + + # add text + if text_item := self._scene.addText(pos.name): + text_item.setFont(font) + br = text_item.boundingRect() + text_item.setPos( + screen_x - br.width() // 2, + screen_y - br.height() // 2, + ) + self._well_labels.append(text_item) + + if plan.selected_wells: + self.setSelectedIndices(plan.selected_well_indices) + + self._resize_to_fit() + + def _resize_to_fit(self) -> None: + self.setSceneRect(self._scene.itemsBoundingRect()) + self.resizeEvent(None) + + def _select_items(self, items: Iterable[QAbstractGraphicsShapeItem]) -> None: + for item in items: + item.setBrush(SELECTED_COLOR) + self._selected_items.update(items) + self.selectionChanged.emit() + + def _deselect_items(self, items: Iterable[QAbstractGraphicsShapeItem]) -> None: + for item in items: + item.setBrush(UNSELECTED_COLOR) + self._selected_items.difference_update(items) + self.selectionChanged.emit() + + def sizeHint(self) -> QSize: + """Provide a reasonable size hint with aspect ratio of a well plate.""" + aspect = 1.5 + width = 600 + height = int(width // aspect) + return QSize(width, height) diff --git a/tests/useq_widgets/test_plate_widget.py b/tests/useq_widgets/test_plate_widget.py new file mode 100644 index 000000000..0f125762d --- /dev/null +++ b/tests/useq_widgets/test_plate_widget.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +import qtpy +import useq +from qtpy.QtCore import Qt +from qtpy.QtGui import QMouseEvent + +from pymmcore_widgets.useq_widgets import WellPlateWidget + +if TYPE_CHECKING: + from pytestqt.qtbot import QtBot + +WELL_96 = useq.WellPlate.from_str("96-well") +CUSTOM_PLATE = useq.WellPlate( + name="custom", + rows=8, + columns=12, + circular_wells=False, + well_size=(13, 10), + well_spacing=(18, 18), +) + +BASIC_PLAN = useq.WellPlatePlan( + plate="96-well", + a1_center_xy=(0, 0), + selected_wells=slice(0, 8, 2), +) + +ROTATED_PLAN = BASIC_PLAN.model_copy(update={"rotation": 10}) + + +@pytest.mark.parametrize("plan", [BASIC_PLAN, ROTATED_PLAN, CUSTOM_PLATE]) +def test_plate_widget(qtbot: QtBot, plan: Any) -> None: + wdg = WellPlateWidget(plan) + qtbot.addWidget(wdg) + wdg.show() + val = wdg.value() + if isinstance(plan, useq.WellPlate): + val = val.plate # type: ignore + assert val == plan + + +def test_plate_widget_selection(qtbot: QtBot) -> None: + wdg = WellPlateWidget() + qtbot.addWidget(wdg) + wdg.show() + wdg.plate_name.setCurrentText("96-well") + wdg.setCurrentSelection((slice(0, 4, 2), (1, 2))) + selection = wdg.currentSelection() + assert selection == ((0, 1), (0, 2), (2, 1), (2, 2)) + + +@pytest.mark.skipif(qtpy.QT5, reason="QMouseEvent API changed") +def test_plate_mouse_press(qtbot: QtBot) -> None: + wdg = WellPlateWidget() + qtbot.addWidget(wdg) + wdg.show() + wdg.plate_name.setCurrentText("96-well") + + # press + assert wdg._view._pressed_item is None + assert not wdg._view._selected_items + event = QMouseEvent( + QMouseEvent.Type.MouseButtonPress, + wdg.rect().translated(10, 0).center().toPointF(), + wdg.rect().translated(10, 0).center().toPointF(), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + wdg._view.mousePressEvent(event) + + assert wdg._view._pressed_item is not None + + # release + event = QMouseEvent( + QMouseEvent.Type.MouseButtonRelease, + wdg.rect().translated(10, 0).center().toPointF(), + wdg.rect().translated(10, 0).center().toPointF(), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + wdg._view.mouseReleaseEvent(event) + + assert wdg._view._pressed_item is None + assert len(wdg._view._selected_items) == 1 + + # simulate rubber band on full widget, should select all + with qtbot.waitSignal(wdg._view.selectionChanged): + wdg._view._on_rubber_band_changed(wdg.rect()) + assert len(wdg._view._selected_items) == 96 + + # simulate opt-click rubber band on full widget, should clear selection + event = QMouseEvent( + QMouseEvent.Type.MouseButtonPress, + wdg.rect().translated(10, 0).center().toPointF(), + wdg.rect().translated(10, 0).center().toPointF(), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.AltModifier, + ) + wdg._view.mousePressEvent(event) + + # simulate rubber band on full widget + with qtbot.waitSignal(wdg._view.selectionChanged): + wdg._view._on_rubber_band_changed(wdg.rect()) + assert len(wdg._view._selected_items) == 0 From 581906e681f49084428fea176dfaa34cc36a0704 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Sat, 13 Jul 2024 10:13:59 -0400 Subject: [PATCH 7/8] fix: WellPlateWidget initial drawing (#327) * fix: wip * test: update * fix: plate init --- src/pymmcore_widgets/useq_widgets/_well_plate_widget.py | 3 +-- tests/useq_widgets/test_plate_widget.py | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py index 029032896..bccc66555 100644 --- a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py +++ b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py @@ -112,8 +112,7 @@ def __init__( self.plate_name.currentTextChanged.connect(self._on_plate_name_changed) self._show_rotation.toggled.connect(self._on_show_rotation_toggled) - if plan: - self.setValue(plan) + self.setValue(plan or self.value()) # _________________________PUBLIC METHODS_________________________ # diff --git a/tests/useq_widgets/test_plate_widget.py b/tests/useq_widgets/test_plate_widget.py index 0f125762d..0d8f5613c 100644 --- a/tests/useq_widgets/test_plate_widget.py +++ b/tests/useq_widgets/test_plate_widget.py @@ -47,6 +47,11 @@ def test_plate_widget_selection(qtbot: QtBot) -> None: wdg = WellPlateWidget() qtbot.addWidget(wdg) wdg.show() + + # Ensure that if no plate is provided when instantiating the widget, the currently + # selected plate in the combobox is used. + assert wdg._view.scene().items() + wdg.plate_name.setCurrentText("96-well") wdg.setCurrentSelection((slice(0, 4, 2), (1, 2))) selection = wdg.currentSelection() From 5957860901237424a297096b4817e80f30927605 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:18:00 -0400 Subject: [PATCH 8/8] fix: make name editable EditGroupWidget (#328) fix: edit group name --- .../_group_preset_widget/_edit_group_widget.py | 10 ++++++++-- tests/test_group_preset_widget.py | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py b/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py index 1ba16c199..7100f90a0 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py @@ -35,7 +35,6 @@ def __init__(self, group: str, *, parent: QWidget | None = None) -> None: self._create_gui() self.group_lineedit.setText(self._group) - self.group_lineedit.setEnabled(False) self.destroyed.connect(self._disconnect) @@ -144,6 +143,13 @@ def _update_filter(self) -> None: ) def _add_group(self) -> None: + # rename group if it has been changed + renamed: bool = False + if self._group != self.group_lineedit.text(): + self._mmc.renameConfigGroup(self._group, self.group_lineedit.text()) + self._group = self.group_lineedit.text() + renamed = True + # [(device, property, value), ...], need to remove the value new_dev_prop = [x[:2] for x in self._prop_table.getCheckedProperties()] @@ -152,7 +158,7 @@ def _add_group(self) -> None: (k[0], k[1]) for k in self._mmc.getConfigData(self._group, presets[0]) ] - if preset_dev_prop == new_dev_prop: + if preset_dev_prop == new_dev_prop and not renamed: return # get any new dev prop to add to each preset diff --git a/tests/test_group_preset_widget.py b/tests/test_group_preset_widget.py index feb45b254..3d6b625cb 100644 --- a/tests/test_group_preset_widget.py +++ b/tests/test_group_preset_widget.py @@ -165,10 +165,15 @@ def test_edit_group(global_mmcore: CMMCorePlus, qtbot: QtBot): item.setCheckState(Qt.CheckState.Checked) assert table.item(t_row, 0).text() == "Camera-CCDTemperature" + edit_gp.group_lineedit.setText("Camera_New") + edit_gp.modify_group_btn.click() - assert edit_gp.info_lbl.text() == "'Camera' Group Modified." + assert edit_gp.info_lbl.text() == "'Camera_New' Group Modified." + + assert "Camera" not in mmc.getAvailableConfigGroups() + assert "Camera_New" in mmc.getAvailableConfigGroups() - dp = [k[:2] for k in mmc.getConfigData("Camera", "LowRes")] + dp = [k[:2] for k in mmc.getConfigData("Camera_New", "LowRes")] assert ("Camera", "CCDTemperature") in dp