From 8c5cf47f04bf65fb01cfa0ed9457c8a8fe179c6c Mon Sep 17 00:00:00 2001 From: Federico Gasparoli Date: Sun, 7 Jul 2024 11:26:37 -0400 Subject: [PATCH 01/22] feat: multi point plan useq widgets --- 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 | 125 +++++++++++++ .../points_plans/_random_points_widget.py | 166 ++++++++++++++++++ 6 files changed, 318 insertions(+), 29 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 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..1bbd9badc --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, +) +from useq import GridRowsColumns +from useq._grid import OrderMode + +AlignCenter = Qt.AlignmentFlag.AlignCenter +EXPANDING_W = (QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + +class GridRowColumnWidget(QGroupBox): + """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_size: tuple[float | None, float | None] = (None, None) + + # title + title = QLabel(text="Fields of Views in a Grid.") + title.setStyleSheet("font-weight: bold;") + title.setAlignment(AlignCenter) + # rows + self._rows = QSpinBox() + self._rows.setSizePolicy(*EXPANDING_W) + self._rows.setAlignment(AlignCenter) + self._rows.setMinimum(1) + # columns + self._cols = QSpinBox() + self._cols.setSizePolicy(*EXPANDING_W) + self._cols.setAlignment(AlignCenter) + self._cols.setMinimum(1) + # overlap along x + self._overlap_x = QDoubleSpinBox() + self._overlap_x.setSizePolicy(*EXPANDING_W) + self._overlap_x.setAlignment(AlignCenter) + self._overlap_x.setMinimum(-10000) + self._overlap_x.setMaximum(100) + self._overlap_x.setSingleStep(1.0) + self._overlap_x.setValue(0) + # overlap along y + self._overlap_y = QDoubleSpinBox() + self._overlap_y.setSizePolicy(*EXPANDING_W) + self._overlap_y.setAlignment(AlignCenter) + self._overlap_y.setMinimum(-10000) + self._overlap_y.setMaximum(100) + self._overlap_y.setSingleStep(1.0) + self._overlap_y.setValue(0) + # order combo + self._order_combo = QComboBox() + self._order_combo.setSizePolicy(*EXPANDING_W) + self._order_combo.addItems([mode.value for mode in OrderMode]) + self._order_combo.setCurrentText(OrderMode.row_wise_snake.value) + # form layout + form_layout = QFormLayout() + form_layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow + ) + form_layout.setSpacing(5) + form_layout.setContentsMargins(0, 0, 0, 0) + form_layout.addRow("Rows:", self._rows) + form_layout.addRow("Columns:", self._cols) + form_layout.addRow("Overlap x (%):", self._overlap_x) + form_layout.addRow("Overlap y (%):", self._overlap_y) + form_layout.addRow("Grid Order:", self._order_combo) + + # main + main_layout = QVBoxLayout(self) + main_layout.setSpacing(10) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addWidget(title) + main_layout.addLayout(form_layout) + + # connect + self._rows.valueChanged.connect(self._on_value_changed) + self._cols.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._order_combo.currentTextChanged.connect(self._on_value_changed) + + def _on_value_changed(self) -> None: + """Emit the valueChanged signal.""" + self.valueChanged.emit(self.value()) + + def value(self) -> GridRowsColumns: + """Return the values of the widgets.""" + fov_x, fov_y = self.fov_size + return GridRowsColumns( + rows=self._rows.value(), + columns=self._cols.value(), + overlap=(self._overlap_x.value(), self._overlap_y.value()), + mode=self._order_combo.currentText(), + fov_width=fov_x, + fov_height=fov_y, + ) + + def setValue(self, value: GridRowsColumns) -> None: + """Set the values of the widgets.""" + self._rows.setValue(value.rows) + self._cols.setValue(value.columns) + self._overlap_x.setValue(value.overlap[0]) + self._overlap_y.setValue(value.overlap[1]) + self._order_combo.setCurrentText(value.mode.value) + self.fov_size = (value.fov_width, value.fov_height) + + def reset(self) -> None: + """Reset the values of the widgets.""" + self._rows.setValue(1) + self._cols.setValue(1) + self._overlap_x.setValue(0) + self._overlap_y.setValue(0) + 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..63a3bf7de --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import numpy as np +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, +) +from useq import RandomPoints +from useq._grid import Shape + +AlignCenter = Qt.AlignmentFlag.AlignCenter +EXPANDING_W = (QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) +RECT = Shape.RECTANGLE +ELLIPSE = Shape.ELLIPSE + + +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) + + # setting a random seed for point generation reproducibility + self._random_seed: int | None = int( + np.random.randint(0, 2**32 - 1, dtype=np.uint32) + ) + self._is_circular: bool = False + self._fov_size: tuple[float | None, float | None] = (None, None) + + # title + title = QLabel(text="Random Fields of Views.") + title.setStyleSheet("font-weight: bold;") + title.setAlignment(AlignCenter) + # well area doublespinbox along x + self._area_x = QDoubleSpinBox() + self._area_x.setAlignment(AlignCenter) + self._area_x.setSizePolicy(*EXPANDING_W) + self._area_x.setMinimum(0.0) + self._area_x.setMaximum(1000000) + self._area_x.setSingleStep(100) + # well area doublespinbox along y + self._area_y = QDoubleSpinBox() + self._area_y.setAlignment(AlignCenter) + self._area_y.setSizePolicy(*EXPANDING_W) + self._area_y.setMinimum(0.0) + self._area_y.setMaximum(1000000) + self._area_y.setSingleStep(100) + # number of FOVs spinbox + self._number_of_points = QSpinBox() + self._number_of_points.setAlignment(AlignCenter) + self._number_of_points.setSizePolicy(*EXPANDING_W) + self._number_of_points.setMinimum(1) + self._number_of_points.setMaximum(1000) + # form layout + form_layout = QFormLayout() + form_layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow + ) + form_layout.setSpacing(5) + form_layout.setContentsMargins(0, 0, 0, 0) + form_layout.addRow(QLabel("Area x (µm):"), self._area_x) + form_layout.addRow(QLabel("Area y (µm):"), self._area_y) + form_layout.addRow(QLabel("Points:"), self._number_of_points) + # random button + self._random_button = QPushButton(text="Generate Random Points") + + # main + main_layout = QVBoxLayout(self) + main_layout.setSpacing(5) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addWidget(title) + main_layout.addLayout(form_layout) + main_layout.addWidget(self._random_button) + + # connect + self._area_x.valueChanged.connect(self._on_value_changed) + self._area_y.valueChanged.connect(self._on_value_changed) + self._number_of_points.valueChanged.connect(self._on_value_changed) + self._random_button.clicked.connect(self._on_random_clicked) + + @property + def is_circular(self) -> bool: + """Return True if the well is circular.""" + return self._is_circular + + @is_circular.setter + def is_circular(self, circular: bool) -> None: + """Set True if the well is circular.""" + self._is_circular = circular + + @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 + + @property + def random_seed(self) -> int | None: + """Return the random seed.""" + return self._random_seed + + @random_seed.setter + def random_seed(self, seed: int) -> None: + """Set the random seed.""" + self._random_seed = seed + + def _on_value_changed(self) -> None: + """Emit the valueChanged signal.""" + self.valueChanged.emit(self.value()) + + def _on_random_clicked(self) -> None: + """Emit the valueChanged signal.""" + # reset the random seed + self.random_seed = int(np.random.randint(0, 2**32 - 1, dtype=np.uint32)) + 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._number_of_points.value(), + shape=ELLIPSE if self._is_circular else RECT, + random_seed=self.random_seed, + max_width=self._area_x.value(), + max_height=self._area_y.value(), + allow_overlap=False, + fov_width=fov_x, + fov_height=fov_y, + ) + + def setValue(self, value: RandomPoints) -> None: + """Set the values of the widgets.""" + self.is_circular = value.shape == ELLIPSE + self.random_seed = ( + value.random_seed + if value.random_seed is not None + else int(np.random.randint(0, 2**32 - 1, dtype=np.uint32)) + ) + self._number_of_points.setValue(value.num_points) + self._area_x.setMaximum(value.max_width) + self._area_x.setValue(value.max_width) + self._area_y.setMaximum(value.max_height) + self._area_y.setValue(value.max_height) + self._fov_size = (value.fov_width, value.fov_height) + + def reset(self) -> None: + """Reset the values of the widgets.""" + self._number_of_points.setValue(1) + self._area_x.setValue(0) + self._area_y.setValue(0) + self._fov_size = (None, None) + self.is_circular = False From 8247b496d3f38b954fec9b10e1b7600a9ea1e4e6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 11:30:49 -0400 Subject: [PATCH 02/22] pull out pyproject --- pyproject.toml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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?)?"] + From a249d64b262abc3c95b77fae32e1eb3d4a83e336 Mon Sep 17 00:00:00 2001 From: Federico Gasparoli Date: Sun, 7 Jul 2024 11:31:06 -0400 Subject: [PATCH 03/22] pyproject --- .../useq_widgets/points_plans/_random_points_widget.py | 6 ++---- 1 file changed, 2 insertions(+), 4 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 63a3bf7de..1f054e38a 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 @@ -31,9 +31,7 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) # setting a random seed for point generation reproducibility - self._random_seed: int | None = int( - np.random.randint(0, 2**32 - 1, dtype=np.uint32) - ) + self._random_seed: int = int(np.random.randint(0, 2**32 - 1, dtype=np.uint32)) self._is_circular: bool = False self._fov_size: tuple[float | None, float | None] = (None, None) @@ -109,7 +107,7 @@ def fov_size(self, size: tuple[float | None, float | None]) -> None: self._fov_size = size @property - def random_seed(self) -> int | None: + def random_seed(self) -> int: """Return the random seed.""" return self._random_seed From 1cfd9c8cdf4f5ab04dfa44edc90f98aa1610a14a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 11:43:58 -0400 Subject: [PATCH 04/22] cleanup grid row --- .../points_plans/_grid_row_column_widget.py | 80 +++++++++---------- 1 file changed, 39 insertions(+), 41 deletions(-) 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 1bbd9badc..83c42a578 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 @@ -1,25 +1,21 @@ from __future__ import annotations +from typing import Mapping + from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QComboBox, QDoubleSpinBox, QFormLayout, - QGroupBox, QLabel, - QSizePolicy, QSpinBox, QVBoxLayout, QWidget, ) -from useq import GridRowsColumns -from useq._grid import OrderMode - -AlignCenter = Qt.AlignmentFlag.AlignCenter -EXPANDING_W = (QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) +from useq import GridRowsColumns, OrderMode -class GridRowColumnWidget(QGroupBox): +class GridRowColumnWidget(QWidget): """Widget to generate a grid of FOVs within a specified area.""" valueChanged = Signal(object) @@ -30,59 +26,48 @@ def __init__(self, parent: QWidget | None = None) -> None: self.fov_size: tuple[float | None, float | None] = (None, None) # title - title = QLabel(text="Fields of Views in a Grid.") + title = QLabel(text="Fields of View in a Grid.") title.setStyleSheet("font-weight: bold;") - title.setAlignment(AlignCenter) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + # rows self._rows = QSpinBox() - self._rows.setSizePolicy(*EXPANDING_W) - self._rows.setAlignment(AlignCenter) + self._rows.setAlignment(Qt.AlignmentFlag.AlignCenter) self._rows.setMinimum(1) # columns self._cols = QSpinBox() - self._cols.setSizePolicy(*EXPANDING_W) - self._cols.setAlignment(AlignCenter) + self._cols.setAlignment(Qt.AlignmentFlag.AlignCenter) self._cols.setMinimum(1) # overlap along x self._overlap_x = QDoubleSpinBox() - self._overlap_x.setSizePolicy(*EXPANDING_W) - self._overlap_x.setAlignment(AlignCenter) - self._overlap_x.setMinimum(-10000) - self._overlap_x.setMaximum(100) - self._overlap_x.setSingleStep(1.0) - self._overlap_x.setValue(0) + self._overlap_x.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._overlap_x.setRange(-10000, 100) # overlap along y self._overlap_y = QDoubleSpinBox() - self._overlap_y.setSizePolicy(*EXPANDING_W) - self._overlap_y.setAlignment(AlignCenter) - self._overlap_y.setMinimum(-10000) - self._overlap_y.setMaximum(100) - self._overlap_y.setSingleStep(1.0) - self._overlap_y.setValue(0) + self._overlap_y.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._overlap_x.setRange(-10000, 100) # order combo self._order_combo = QComboBox() - self._order_combo.setSizePolicy(*EXPANDING_W) self._order_combo.addItems([mode.value for mode in OrderMode]) self._order_combo.setCurrentText(OrderMode.row_wise_snake.value) + # form layout - form_layout = QFormLayout() - form_layout.setFieldGrowthPolicy( - QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow - ) - form_layout.setSpacing(5) - form_layout.setContentsMargins(0, 0, 0, 0) - form_layout.addRow("Rows:", self._rows) - form_layout.addRow("Columns:", self._cols) - form_layout.addRow("Overlap x (%):", self._overlap_x) - form_layout.addRow("Overlap y (%):", self._overlap_y) - form_layout.addRow("Grid Order:", self._order_combo) + 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._cols) + form.addRow("Overlap x (%):", self._overlap_x) + form.addRow("Overlap y (%):", self._overlap_y) + form.addRow("Grid Order:", self._order_combo) # main main_layout = QVBoxLayout(self) main_layout.setSpacing(10) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.addWidget(title) - main_layout.addLayout(form_layout) + main_layout.addLayout(form) # connect self._rows.valueChanged.connect(self._on_value_changed) @@ -107,8 +92,9 @@ def value(self) -> GridRowsColumns: fov_height=fov_y, ) - def setValue(self, value: GridRowsColumns) -> None: + def setValue(self, value: GridRowsColumns | Mapping) -> None: """Set the values of the widgets.""" + value = GridRowsColumns.model_validate(value) self._rows.setValue(value.rows) self._cols.setValue(value.columns) self._overlap_x.setValue(value.overlap[0]) @@ -117,9 +103,21 @@ def setValue(self, value: GridRowsColumns) -> None: self.fov_size = (value.fov_width, value.fov_height) def reset(self) -> None: - """Reset the values of the widgets.""" + """Reset value to 1x1, row-wise-snake, with 0 overlap.""" self._rows.setValue(1) self._cols.setValue(1) self._overlap_x.setValue(0) self._overlap_y.setValue(0) + self._order_combo.setCurrentText(OrderMode.row_wise_snake.value) self.fov_size = (None, None) + + +if __name__ == "__main__": + from qtpy.QtWidgets import QApplication + + app = QApplication([]) + + widget = GridRowColumnWidget() + widget.show() + + app.exec_() From 0256ccaadd65b25b14e21a8dd54fb1a0d26475fb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 12:05:25 -0400 Subject: [PATCH 05/22] cleanup --- .../points_plans/_grid_row_column_widget.py | 13 +- .../points_plans/_random_points_widget.py | 145 ++++++++---------- 2 files changed, 61 insertions(+), 97 deletions(-) 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 83c42a578..a657cdd99 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 @@ -64,7 +64,7 @@ def __init__(self, parent: QWidget | None = None) -> None: # main main_layout = QVBoxLayout(self) - main_layout.setSpacing(10) + main_layout.setSpacing(5) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.addWidget(title) main_layout.addLayout(form) @@ -110,14 +110,3 @@ def reset(self) -> None: self._overlap_y.setValue(0) self._order_combo.setCurrentText(OrderMode.row_wise_snake.value) self.fov_size = (None, None) - - -if __name__ == "__main__": - from qtpy.QtWidgets import QApplication - - app = QApplication([]) - - widget = GridRowColumnWidget() - widget.show() - - app.exec_() 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 1f054e38a..c55485034 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 @@ -1,25 +1,23 @@ from __future__ import annotations +import random +from re import S +from typing import Mapping + import numpy as np from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( + QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, QLabel, QPushButton, - QSizePolicy, QSpinBox, QVBoxLayout, QWidget, ) -from useq import RandomPoints -from useq._grid import Shape - -AlignCenter = Qt.AlignmentFlag.AlignCenter -EXPANDING_W = (QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) -RECT = Shape.RECTANGLE -ELLIPSE = Shape.ELLIPSE +from useq import RandomPoints, Shape class RandomPointWidget(QGroupBox): @@ -31,71 +29,59 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) # setting a random seed for point generation reproducibility - self._random_seed: int = int(np.random.randint(0, 2**32 - 1, dtype=np.uint32)) - self._is_circular: bool = False + self.random_seed: int = self._new_seed() self._fov_size: tuple[float | None, float | None] = (None, None) # title - title = QLabel(text="Random Fields of Views.") + title = QLabel(text="Random Points") title.setStyleSheet("font-weight: bold;") - title.setAlignment(AlignCenter) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) # well area doublespinbox along x - self._area_x = QDoubleSpinBox() - self._area_x.setAlignment(AlignCenter) - self._area_x.setSizePolicy(*EXPANDING_W) - self._area_x.setMinimum(0.0) - self._area_x.setMaximum(1000000) - self._area_x.setSingleStep(100) + 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._area_y = QDoubleSpinBox() - self._area_y.setAlignment(AlignCenter) - self._area_y.setSizePolicy(*EXPANDING_W) - self._area_y.setMinimum(0.0) - self._area_y.setMaximum(1000000) - self._area_y.setSingleStep(100) + 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._number_of_points = QSpinBox() - self._number_of_points.setAlignment(AlignCenter) - self._number_of_points.setSizePolicy(*EXPANDING_W) - self._number_of_points.setMinimum(1) - self._number_of_points.setMaximum(1000) - # form layout - form_layout = QFormLayout() - form_layout.setFieldGrowthPolicy( - QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow - ) - form_layout.setSpacing(5) - form_layout.setContentsMargins(0, 0, 0, 0) - form_layout.addRow(QLabel("Area x (µm):"), self._area_x) - form_layout.addRow(QLabel("Area y (µm):"), self._area_y) - form_layout.addRow(QLabel("Points:"), self._number_of_points) + self.num_points = QSpinBox() + self.num_points.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.num_points.setRange(1, 1000) # random button - self._random_button = QPushButton(text="Generate Random Points") + 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_layout) + main_layout.addLayout(form) main_layout.addWidget(self._random_button) # connect - self._area_x.valueChanged.connect(self._on_value_changed) - self._area_y.valueChanged.connect(self._on_value_changed) - self._number_of_points.valueChanged.connect(self._on_value_changed) + 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 is_circular(self) -> bool: - """Return True if the well is circular.""" - return self._is_circular - - @is_circular.setter - def is_circular(self, circular: bool) -> None: - """Set True if the well is circular.""" - self._is_circular = circular - @property def fov_size(self) -> tuple[float | None, float | None]: """Return the FOV size.""" @@ -106,59 +92,48 @@ def fov_size(self, size: tuple[float | None, float | None]) -> None: """Set the FOV size.""" self._fov_size = size - @property - def random_seed(self) -> int: - """Return the random seed.""" - return self._random_seed - - @random_seed.setter - def random_seed(self, seed: int) -> None: - """Set the random seed.""" - self._random_seed = seed - def _on_value_changed(self) -> None: """Emit the valueChanged signal.""" self.valueChanged.emit(self.value()) def _on_random_clicked(self) -> None: - """Emit the valueChanged signal.""" # reset the random seed - self.random_seed = int(np.random.randint(0, 2**32 - 1, dtype=np.uint32)) + 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._number_of_points.value(), - shape=ELLIPSE if self._is_circular else RECT, + num_points=self.num_points.value(), + shape=self.shape.currentText(), random_seed=self.random_seed, - max_width=self._area_x.value(), - max_height=self._area_y.value(), + max_width=self.max_width.value(), + max_height=self.max_height.value(), allow_overlap=False, fov_width=fov_x, fov_height=fov_y, ) - def setValue(self, value: RandomPoints) -> None: + def setValue(self, value: RandomPoints | Mapping) -> None: """Set the values of the widgets.""" - self.is_circular = value.shape == ELLIPSE + value = RandomPoints.model_validate(value) self.random_seed = ( - value.random_seed - if value.random_seed is not None - else int(np.random.randint(0, 2**32 - 1, dtype=np.uint32)) + self._new_seed() if value.random_seed is None else value.random_seed ) - self._number_of_points.setValue(value.num_points) - self._area_x.setMaximum(value.max_width) - self._area_x.setValue(value.max_width) - self._area_y.setMaximum(value.max_height) - self._area_y.setValue(value.max_height) + 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) def reset(self) -> None: - """Reset the values of the widgets.""" - self._number_of_points.setValue(1) - self._area_x.setValue(0) - self._area_y.setValue(0) + """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) - self.is_circular = False + + def _new_seed(self) -> int: + return random.randint(0, 2**32 - 1) From f6544a74a6e9cb6e9ba68123922284f1ad29f832 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 7 Jul 2024 16:05:41 +0000 Subject: [PATCH 06/22] style(pre-commit.ci): auto fixes [...] --- .../useq_widgets/points_plans/_random_points_widget.py | 2 -- 1 file changed, 2 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 c55485034..82648603b 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 @@ -1,10 +1,8 @@ from __future__ import annotations import random -from re import S from typing import Mapping -import numpy as np from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QComboBox, From 252cb2b163b1afd8a2d677f227184dc2fd4c75b1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 12:28:01 -0400 Subject: [PATCH 07/22] tests --- .../points_plans/_grid_row_column_widget.py | 107 +++++++++++------- .../points_plans/_random_points_widget.py | 4 +- tests/useq_widgets/test_useq_points_plans.py | 68 +++++++++++ tests/{ => useq_widgets}/test_useq_widgets.py | 0 4 files changed, 134 insertions(+), 45 deletions(-) create mode 100644 tests/useq_widgets/test_useq_points_plans.py rename tests/{ => useq_widgets}/test_useq_widgets.py (100%) 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 a657cdd99..4cc0cf782 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 @@ -23,7 +23,9 @@ class GridRowColumnWidget(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self.fov_size: tuple[float | None, float | None] = (None, None) + 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.") @@ -31,36 +33,36 @@ def __init__(self, parent: QWidget | None = None) -> None: title.setAlignment(Qt.AlignmentFlag.AlignCenter) # rows - self._rows = QSpinBox() - self._rows.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._rows.setMinimum(1) + self.rows = QSpinBox() + self.rows.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.rows.setMinimum(1) # columns - self._cols = QSpinBox() - self._cols.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._cols.setMinimum(1) + 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) + 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) + self.overlap_y = QDoubleSpinBox() + self.overlap_y.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.overlap_x.setRange(-10000, 100) # order combo - self._order_combo = QComboBox() - self._order_combo.addItems([mode.value for mode in OrderMode]) - self._order_combo.setCurrentText(OrderMode.row_wise_snake.value) + 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._cols) - form.addRow("Overlap x (%):", self._overlap_x) - form.addRow("Overlap y (%):", self._overlap_y) - form.addRow("Grid Order:", self._order_combo) + 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) @@ -70,43 +72,60 @@ def __init__(self, parent: QWidget | None = None) -> None: main_layout.addLayout(form) # connect - self._rows.valueChanged.connect(self._on_value_changed) - self._cols.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._order_combo.currentTextChanged.connect(self._on_value_changed) + 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.""" - fov_x, fov_y = self.fov_size return GridRowsColumns( - rows=self._rows.value(), - columns=self._cols.value(), - overlap=(self._overlap_x.value(), self._overlap_y.value()), - mode=self._order_combo.currentText(), - fov_width=fov_x, - fov_height=fov_y, + 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._cols.setValue(value.columns) - self._overlap_x.setValue(value.overlap[0]) - self._overlap_y.setValue(value.overlap[1]) - self._order_combo.setCurrentText(value.mode.value) - self.fov_size = (value.fov_width, value.fov_height) + 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 def reset(self) -> None: """Reset value to 1x1, row-wise-snake, with 0 overlap.""" - self._rows.setValue(1) - self._cols.setValue(1) - self._overlap_x.setValue(0) - self._overlap_y.setValue(0) - self._order_combo.setCurrentText(OrderMode.row_wise_snake.value) + 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 index 82648603b..8fe0f5c48 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,7 @@ class RandomPointWidget(QGroupBox): 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) @@ -108,7 +109,7 @@ def value(self) -> RandomPoints: random_seed=self.random_seed, max_width=self.max_width.value(), max_height=self.max_height.value(), - allow_overlap=False, + allow_overlap=self.allow_overlap, fov_width=fov_x, fov_height=fov_y, ) @@ -124,6 +125,7 @@ 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 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 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 f36d29d9b468405b249979386d50f943d6742194 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 12:28:37 -0400 Subject: [PATCH 08/22] lint --- .../useq_widgets/points_plans/_grid_row_column_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4cc0cf782..415bdaa45 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 @@ -119,7 +119,7 @@ def setValue(self, value: GridRowsColumns | Mapping) -> None: self.mode.setCurrentText(value.mode.value) self.fov_width = value.fov_width self.fov_height = value.fov_height - self._relative_to = value.relative_to + self._relative_to = value.relative_to.value def reset(self) -> None: """Reset value to 1x1, row-wise-snake, with 0 overlap.""" From 9ed073eb9dd4ae680928b2444544d7228b923f6b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 12:39:13 -0400 Subject: [PATCH 09/22] bump From 863e2f49afa00d350e4fc069df5dda90a0010964 Mon Sep 17 00:00:00 2001 From: Federico Gasparoli Date: Sun, 7 Jul 2024 14:59:20 -0400 Subject: [PATCH 10/22] fov-widget --- .../hcs/_fov_widget/_fov_sub_widgets.py | 472 ++++++++++++++++++ .../hcs/_fov_widget/_fov_widget.py | 307 ++++++++++++ src/pymmcore_widgets/hcs/_fov_widget/_util.py | 60 +++ 3 files changed, 839 insertions(+) create mode 100644 src/pymmcore_widgets/hcs/_fov_widget/_fov_sub_widgets.py create mode 100644 src/pymmcore_widgets/hcs/_fov_widget/_fov_widget.py create mode 100644 src/pymmcore_widgets/hcs/_fov_widget/_util.py diff --git a/src/pymmcore_widgets/hcs/_fov_widget/_fov_sub_widgets.py b/src/pymmcore_widgets/hcs/_fov_widget/_fov_sub_widgets.py new file mode 100644 index 000000000..87e74c62f --- /dev/null +++ b/src/pymmcore_widgets/hcs/_fov_widget/_fov_sub_widgets.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from typing import Any, Optional + +from qtpy.QtCore import QRectF, Qt, Signal +from qtpy.QtGui import QColor, QPen +from qtpy.QtWidgets import ( + QGraphicsEllipseItem, + QGraphicsLineItem, + QGraphicsRectItem, + QGraphicsScene, + QGroupBox, + QLabel, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from useq import GridRowsColumns, Position, RandomPoints, RelativePosition, Shape + +from pymmcore_widgets.hcs._graphics_items import ( + FOV, + GREEN, + _FOVGraphicsItem, + _WellAreaGraphicsItem, +) +from pymmcore_widgets.hcs._util import _ResizingGraphicsView + +from ._util import nearest_neighbor + +AlignCenter = Qt.AlignmentFlag.AlignCenter +FIXED_POLICY = (QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) +DEFAULT_VIEW_SIZE = (300, 300) # px +DEFAULT_WELL_SIZE = (10, 10) # mm +DEFAULT_FOV_SIZE = (DEFAULT_WELL_SIZE[0] / 10, DEFAULT_WELL_SIZE[1] / 10) # mm +PEN_WIDTH = 4 +RECT = Shape.RECTANGLE +ELLIPSE = Shape.ELLIPSE +PEN_AREA = QPen(QColor(GREEN)) +PEN_AREA.setWidth(PEN_WIDTH) + + +class Center(Position): + """A subclass of GridRowsColumns to store the center coordinates and FOV size. + + Attributes + ---------- + fov_width : float | None + The width of the FOV in µm. + fov_height : float | None + The height of the FOV in µm. + """ + + fov_width: Optional[float] = None # noqa: UP007 + fov_height: Optional[float] = None # noqa: UP007 + + +class _CenterFOVWidget(QGroupBox): + """Widget to select the center of a specifiied area.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self._x: float = 0.0 + self._y: float = 0.0 + + self._fov_size: tuple[float | None, float | None] = (None, None) + + lbl = QLabel(text="Center of the Well.") + lbl.setStyleSheet("font-weight: bold;") + lbl.setAlignment(AlignCenter) + + # main + main_layout = QVBoxLayout(self) + main_layout.setSpacing(0) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addWidget(lbl) + + @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 value(self) -> Center: + """Return the values of the widgets.""" + fov_x, fov_y = self._fov_size + return Center(x=self._x, y=self._y, fov_width=fov_x, fov_height=fov_y) + + def setValue(self, value: Center) -> None: + """Set the values of the widgets.""" + self._x = value.x or 0.0 + self._y = value.y or 0.0 + self.fov_size = (value.fov_width, value.fov_height) + + +@dataclass +class _WellViewData: + """A NamedTuple to store the well view data. + + Attributes + ---------- + well_size : tuple[float | None, float | None] + The size of the well in µm. By default, (None, None). + circular : bool + True if the well is circular. By default, False. + padding : int + The padding in pixel between the well and the view. By default, 0. + show_fovs_order : bool + If True, the FOVs will be connected by black lines to represent the order of + acquisition. In addition, the first FOV will be drawn with a black color, the + others with a white color. By default, True. + mode : Center | GridRowsColumns | RandomPoints | None + The mode to use to draw the FOVs. By default, None. + """ + + well_size: tuple[float | None, float | None] = (None, None) + circular: bool = False + padding: int = 0 + show_fovs_order: bool = True + mode: Center | GridRowsColumns | RandomPoints | None = None + + def replace(self, **kwargs: Any) -> _WellViewData: + """Replace the attributes of the dataclass.""" + return _WellViewData( + well_size=kwargs.get("well_size", self.well_size), + circular=kwargs.get("circular", self.circular), + padding=kwargs.get("padding", self.padding), + show_fovs_order=kwargs.get("show_fovs_order", self.show_fovs_order), + mode=kwargs.get("mode", self.mode), + ) + + +DEFAULT_WELL_DATA = _WellViewData() + + +class _WellView(_ResizingGraphicsView): + """Graphics view to draw a well and the FOVs. + + Parameters + ---------- + parent : QWidget | None + The parent widget. + view_size : tuple[int, int] + The minimum size of the QGraphicsView in pixel. By default (300, 300). + data : WellViewData + The data to use to initialize the view. By default: + WellViewData( + well_size=(None, None), + circular=False, + padding=0, + show_fovs_order=True, + mode=None, + ) + """ + + pointsWarning: Signal = Signal(int) + + def __init__( + self, + parent: QWidget | None = None, + view_size: tuple[int, int] = DEFAULT_VIEW_SIZE, + data: _WellViewData = DEFAULT_WELL_DATA, + ) -> None: + self._scene = QGraphicsScene() + super().__init__(self._scene, parent) + + self.setStyleSheet("background:grey; border-radius: 5px;") + + self._size_x, self._size_y = view_size + self.setMinimumSize(self._size_x, self._size_y) + + # set the scene rect so that the center is (0, 0) + self.setSceneRect( + -self._size_x / 2, -self._size_x / 2, self._size_x, self._size_y + ) + + self.setValue(data) + + # _________________________PUBLIC METHODS_________________________ # + + def setMode(self, mode: Center | GridRowsColumns | RandomPoints | None) -> None: + """Set the mode to use to draw the FOVs.""" + self._mode = mode + self._fov_width = mode.fov_width if mode else None + self._fov_height = mode.fov_height if mode else None + + # convert size in scene pixel + self._fov_width_px = ( + (self._size_x * self._fov_width) / self._well_width + if self._fov_width and self._well_width + else None + ) + self._fov_height_px = ( + (self._size_y * self._fov_height) / self._well_height + if self._fov_height and self._well_height + else None + ) + self._update_scene(self._mode) + + def mode(self) -> Center | GridRowsColumns | RandomPoints | None: + """Return the mode to use to draw the FOVs.""" + return self._mode + + def setWellSize(self, size: tuple[float | None, float | None]) -> None: + """Set the well size width and height in mm.""" + self._well_width, self._well_height = size + + def wellSize(self) -> tuple[float | None, float | None]: + """Return the well size width and height in mm.""" + return self._well_width, self._well_height + + def setCircular(self, is_circular: bool) -> None: + """Set True if the well is circular.""" + self._is_circular = is_circular + # update the mode fov size if a mode is set + if self._mode is not None and isinstance(self._mode, RandomPoints): + self._mode = self._mode.replace(shape=ELLIPSE if is_circular else RECT) + + def isCircular(self) -> bool: + """Return True if the well is circular.""" + return self._is_circular + + def setPadding(self, padding: int) -> None: + """Set the padding in pixel between the well and the view.""" + self._padding = padding + + def padding(self) -> int: + """Return the padding in pixel between the well and the view.""" + return self._padding + + def showFovOrder(self, show: bool) -> None: + """Show the FOVs order in the scene by drawing lines connecting the FOVs.""" + self._show_fovs_order = show + + def fovsOrder(self) -> bool: + """Return True if the FOVs order is shown.""" + return self._show_fovs_order + + def clear(self, *item_types: Any) -> None: + """Remove all items of `item_types` from the scene.""" + if not item_types: + self._scene.clear() + self._mode = None + for item in self._scene.items(): + if not item_types or isinstance(item, item_types): + self._scene.removeItem(item) + self._scene.update() + + def refresh(self) -> None: + """Refresh the scene.""" + self._scene.clear() + self._draw_well_area() + if self._mode is not None: + self._update_scene(self._mode) + + def value(self) -> _WellViewData: + """Return the value of the scene.""" + return _WellViewData( + well_size=(self._well_width, self._well_height), + circular=self._is_circular, + padding=self._padding, + show_fovs_order=self._show_fovs_order, + mode=self._mode, + ) + + def setValue(self, value: _WellViewData) -> None: + """Set the value of the scene.""" + self.clear() + + self.setWellSize(value.well_size) + self.setCircular(value.circular) + self.setPadding(value.padding) + self.showFovOrder(value.show_fovs_order) + self.setMode(value.mode) + + if self._well_width is None or self._well_height is None: + self.clear() + return + + self._draw_well_area() + self._update_scene(self._mode) + + # _________________________PRIVATE METHODS_________________________ # + + def _get_reference_well_area(self) -> QRectF | None: + """Return the well area in scene pixel as QRectF.""" + if self._well_width is None or self._well_height is None: + return None + + well_aspect = self._well_width / self._well_height + well_size_px = self._size_x - self._padding + size_x = size_y = well_size_px + # keep the ratio between well_size_x and well_size_y + if well_aspect > 1: + size_y = int(well_size_px * 1 / well_aspect) + elif well_aspect < 1: + size_x = int(well_size_px * well_aspect) + # set the position of the well plate in the scene using the center of the view + # QRectF as reference + x = self.sceneRect().center().x() - (size_x / 2) + y = self.sceneRect().center().y() - (size_y / 2) + w = size_x + h = size_y + + return QRectF(x, y, w, h) + + def _draw_well_area(self) -> None: + """Draw the well area in the scene.""" + if self._well_width is None or self._well_height is None: + self.clear() + return + + ref = self._get_reference_well_area() + if ref is None: + return + + if self._is_circular: + self._scene.addEllipse(ref, pen=PEN_AREA) + else: + self._scene.addRect(ref, pen=PEN_AREA) + + def _update_scene( + self, value: Center | GridRowsColumns | RandomPoints | None + ) -> None: + """Update the scene with the given mode.""" + if value is None: + self.clear(_WellAreaGraphicsItem, _FOVGraphicsItem) + return + + if isinstance(value, Center): + self._update_center_fov(value) + elif isinstance(value, RandomPoints): + self._update_random_fovs(value) + elif isinstance(value, (GridRowsColumns)): + self._update_grid_fovs(value) + else: + raise ValueError(f"Invalid value: {value}") + + def _update_center_fov(self, value: Center) -> None: + """Update the scene with the center point.""" + points = [FOV(value.x or 0.0, value.y or 0.0, self.sceneRect())] + self._draw_fovs(points) + + def _update_random_fovs(self, value: RandomPoints) -> None: + """Update the scene with the random points.""" + self.clear(_WellAreaGraphicsItem, QGraphicsEllipseItem, QGraphicsRectItem) + + if isinstance(value, RandomPoints): + self._is_circular = value.shape == ELLIPSE + + # get the well area in scene pixel + ref_area = self._get_reference_well_area() + + if ref_area is None or self._well_width is None or self._well_height is None: + return + + well_area_x_px = ref_area.width() * value.max_width / self._well_width + well_area_y_px = ref_area.height() * value.max_height / self._well_height + + # calculate the starting point of the well area + x = ref_area.center().x() - (well_area_x_px / 2) + y = ref_area.center().y() - (well_area_y_px / 2) + + rect = QRectF(x, y, well_area_x_px, well_area_y_px) + area = _WellAreaGraphicsItem(rect, self._is_circular, PEN_WIDTH) + + # draw well and well area + self._draw_well_area() + self._scene.addItem(area) + + val = value.replace( + max_width=area.boundingRect().width(), + max_height=area.boundingRect().height(), + fov_width=self._fov_width_px, + fov_height=self._fov_height_px, + ) + # get the random points list + + points = self._get_random_points(val, area.boundingRect()) + # draw the random points + self._draw_fovs(points) + + def _get_random_points(self, points: RandomPoints, area: QRectF) -> list[FOV]: + """Create the points for the random scene.""" + # catch the warning raised by the RandomPoints class if the max number of + # iterations is reached. + with warnings.catch_warnings(record=True) as w: + # note: inverting the y axis because in scene, y up is negative and y down + # is positive. + pos = [ + RelativePosition(x=point.x, y=point.y * (-1), name=point.name) # type: ignore + for point in points + ] + if len(pos) != points.num_points: + self.pointsWarning.emit(len(pos)) + + if len(w): + warnings.warn(w[0].message, w[0].category, stacklevel=2) + + top_x, top_y = area.topLeft().x(), area.topLeft().y() + return [FOV(p.x, p.y, area) for p in nearest_neighbor(pos, top_x, top_y)] + + def _update_grid_fovs(self, value: GridRowsColumns) -> None: + """Update the scene with the grid points.""" + val = value.replace(fov_width=self._fov_width_px, fov_height=self._fov_width_px) + + # x and y center coords of the scene in px + x, y = ( + self._scene.sceneRect().center().x(), + self._scene.sceneRect().center().y(), + ) + rect = self._get_reference_well_area() + + if rect is None: + return + # create a list of FOV points by shifting the grid by the center coords. + # note: inverting the y axis because in scene, y up is negative and y down is + # positive. + points = [FOV(g.x + x, (g.y - y) * (-1), rect) for g in val] + self._draw_fovs(points) + + def _draw_fovs(self, points: list[FOV]) -> None: + """Draw the fovs in the scene as `_FOVPoints. + + If 'showFOVsOrder' is True, the FOVs will be connected by black lines to + represent the order of acquisition. In addition, the first FOV will be drawn + with a black color, the others with a white color. + """ + if not self._fov_width_px or not self._fov_height_px: + return + + def _get_pen(index: int) -> QPen: + """Return the pen to use for the fovs.""" + pen = ( + QPen(Qt.GlobalColor.black) + if index == 0 and len(points) > 1 + else QPen(Qt.GlobalColor.white) + ) + pen.setWidth(3) + return pen + + self.clear(_FOVGraphicsItem, QGraphicsLineItem) + + line_pen = QPen(Qt.GlobalColor.black) + line_pen.setWidth(2) + + x = y = None + for index, fov in enumerate(points): + fovs = _FOVGraphicsItem( + fov.x, + fov.y, + self._fov_width_px, + self._fov_height_px, + fov.bounding_rect, + ( + _get_pen(index) + if self._show_fovs_order + else QPen(Qt.GlobalColor.white) + ), + ) + + self._scene.addItem(fovs) + # draw the lines connecting the fovs + if x is not None and y is not None and self._show_fovs_order: + self._scene.addLine(x, y, fov.x, fov.y, pen=line_pen) + x, y = (fov.x, fov.y) diff --git a/src/pymmcore_widgets/hcs/_fov_widget/_fov_widget.py b/src/pymmcore_widgets/hcs/_fov_widget/_fov_widget.py new file mode 100644 index 000000000..5943ec655 --- /dev/null +++ b/src/pymmcore_widgets/hcs/_fov_widget/_fov_widget.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, cast + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import ( + QButtonGroup, + QGraphicsLineItem, + QGridLayout, + QHBoxLayout, + QRadioButton, + QSizePolicy, + QWidget, +) +from superqt.utils import signals_blocked +from useq import ( + GridRowsColumns, + RandomPoints, +) + +from pymmcore_widgets.hcs._fov_widget._fov_sub_widgets import ( + Center, + _CenterFOVWidget, + _WellView, + _WellViewData, +) +from pymmcore_widgets.hcs._graphics_items import ( + _FOVGraphicsItem, + _WellAreaGraphicsItem, +) +from pymmcore_widgets.useq_widgets._grid import _SeparatorWidget +from pymmcore_widgets.useq_widgets._grid_row_column_widget import GridRowColumnWidget +from pymmcore_widgets.useq_widgets._random_points_widget import RandomPointWidget + +if TYPE_CHECKING: + from useq import WellPlate + +FIXED_POLICY = (QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) +CENTER = "Center" +RANDOM = "Random" +GRID = "Grid" +OFFSET = 20 +RECT = "rectangle" # Shape.RECTANGLE in useq +ELLIPSE = "ellipse" # Shape.ELLIPSE in useq + + +class _FOVSelectorWidget(QWidget): + """Widget to select the FOVVs per well of the plate.""" + + valueChanged = Signal(object) + + def __init__( + self, + plate: WellPlate | None = None, + mode: Center | RandomPoints | GridRowsColumns | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent=parent) + + self._plate: WellPlate | None = plate + + # graphics scene to draw the well and the fovs + self.well_view = _WellView() + + # centerwidget + self.center_wdg = _CenterFOVWidget() + self.center_radio_btn = QRadioButton() + self.center_radio_btn.setChecked(True) + self.center_radio_btn.setSizePolicy(*FIXED_POLICY) + self.center_radio_btn.setObjectName(CENTER) + center_wdg_layout = QHBoxLayout() + center_wdg_layout.setContentsMargins(0, 0, 0, 0) + center_wdg_layout.setSpacing(5) + center_wdg_layout.addWidget(self.center_radio_btn) + center_wdg_layout.addWidget(self.center_wdg) + # random widget + self.random_wdg = RandomPointWidget() + self.random_wdg.setEnabled(False) + self.random_radio_btn = QRadioButton() + self.random_radio_btn.setSizePolicy(*FIXED_POLICY) + self.random_radio_btn.setObjectName(RANDOM) + random_wdg_layout = QHBoxLayout() + random_wdg_layout.setContentsMargins(0, 0, 0, 0) + random_wdg_layout.setSpacing(5) + random_wdg_layout.addWidget(self.random_radio_btn) + random_wdg_layout.addWidget(self.random_wdg) + # grid widget + self.grid_wdg = GridRowColumnWidget() + self.grid_wdg.setEnabled(False) + self.grid_radio_btn = QRadioButton() + self.grid_radio_btn.setSizePolicy(*FIXED_POLICY) + self.grid_radio_btn.setObjectName(GRID) + grid_wdg_layout = QHBoxLayout() + grid_wdg_layout.setContentsMargins(0, 0, 0, 0) + grid_wdg_layout.setSpacing(5) + grid_wdg_layout.addWidget(self.grid_radio_btn) + grid_wdg_layout.addWidget(self.grid_wdg) + # radio buttons group for fov mode selection + self._mode_btn_group = QButtonGroup() + self._mode_btn_group.addButton(self.center_radio_btn) + self._mode_btn_group.addButton(self.random_radio_btn) + self._mode_btn_group.addButton(self.grid_radio_btn) + self.MODE: dict[ + str, _CenterFOVWidget | RandomPointWidget | GridRowColumnWidget + ] = { + CENTER: self.center_wdg, + RANDOM: self.random_wdg, + GRID: self.grid_wdg, + } + self._mode_btn_group.buttonToggled.connect(self._on_radiobutton_toggled) + + # main + main_layout = QGridLayout(self) + main_layout.setSpacing(10) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addWidget(_SeparatorWidget(), 0, 0) + main_layout.addLayout(center_wdg_layout, 1, 0) + main_layout.addWidget(_SeparatorWidget(), 2, 0) + main_layout.addLayout(random_wdg_layout, 3, 0) + main_layout.addWidget(_SeparatorWidget(), 4, 0) + main_layout.addLayout(grid_wdg_layout, 5, 0) + main_layout.addWidget(_SeparatorWidget(), 6, 0) + main_layout.addWidget(self.well_view, 0, 1, 7, 1) + + # connect + self.random_wdg.valueChanged.connect(self._on_value_changed) + self.grid_wdg.valueChanged.connect(self._on_value_changed) + self.well_view.pointsWarning.connect(self._on_points_warning) + + self.setValue(self._plate, mode) + + # _________________________PUBLIC METHODS_________________________ # + + def value( + self, + ) -> tuple[WellPlate | None, Center | RandomPoints | GridRowsColumns | None]: + return self._plate, self._get_mode_widget().value() + + def setValue( + self, + plate: WellPlate | None, + mode: Center | RandomPoints | GridRowsColumns | None, + ) -> None: + """Set the value of the widget. + + Parameters + ---------- + plate : WellPlate | None + The well plate object. + mode : Center | RandomPoints | GridRowsColumns + The mode to use to select the FOVs. + """ + self.well_view.clear() + + self._plate = plate + + if self._plate is None: + # reset view scene and mode widgets + self.well_view.setValue(_WellViewData()) + with signals_blocked(self.random_wdg): + self.random_wdg.reset() + with signals_blocked(self.grid_wdg): + self.grid_wdg.reset() + + # set the radio buttons + self._update_mode_widgets(mode) + return + + # update view data + well_size_x, well_size_y = self._plate.well_size + view_data = _WellViewData( + # plate well size in mm, convert to µm + well_size=(well_size_x * 1000, well_size_y * 1000), + circular=self._plate.circular_wells, + padding=OFFSET, + mode=mode, + ) + self.well_view.setValue(view_data) + + # update the fov size in each mode widget + self._update_mode_wdgs_fov_size( + (mode.fov_width, mode.fov_height) if mode else (None, None) + ) + + self._update_mode_widgets(mode) + + # _________________________PRIVATE METHODS_________________________ # + + def _get_mode_widget( + self, + ) -> _CenterFOVWidget | RandomPointWidget | GridRowColumnWidget: + """Return the current mode.""" + for btn in self._mode_btn_group.buttons(): + if btn.isChecked(): + mode_name = cast(str, btn.objectName()) + return self.MODE[mode_name] + raise ValueError("No mode selected.") + + def _update_mode_widgets( + self, mode: Center | RandomPoints | GridRowsColumns | None + ) -> None: + """Update the mode widgets.""" + if isinstance(mode, RandomPoints): + self._set_random_value(mode) + else: + # update the randon widget values depending on the plate + with signals_blocked(self.random_wdg): + self.random_wdg.setValue(self._plate_to_random(self._plate)) + # update center or grid widgets + if isinstance(mode, Center): + self._set_center_value(mode) + elif isinstance(mode, GridRowsColumns): + self._set_grid_value(mode) + + def _update_mode_wdgs_fov_size( + self, fov_size: tuple[float | None, float | None] + ) -> None: + """Update the fov size in each mode widget.""" + self.center_wdg.fov_size = fov_size + self.random_wdg.fov_size = fov_size + self.grid_wdg.fov_size = fov_size + + def _on_points_warning(self, num_points: int) -> None: + self.random_wdg._number_of_points.setValue(num_points) + + def _on_radiobutton_toggled(self, radio_btn: QRadioButton) -> None: + """Update the scene when the tab is changed.""" + self.well_view.clear(_WellAreaGraphicsItem, _FOVGraphicsItem, QGraphicsLineItem) + self._enable_radio_buttons_wdgs() + self._update_scene() + + if radio_btn.isChecked(): + self.valueChanged.emit(self.value()) + + def _enable_radio_buttons_wdgs(self) -> None: + """Enable any radio button that is checked.""" + for btn in self._mode_btn_group.buttons(): + self.MODE[btn.objectName()].setEnabled(btn.isChecked()) + + def _on_value_changed(self, value: RandomPoints | GridRowsColumns) -> None: + self.well_view.clear(_WellAreaGraphicsItem, _FOVGraphicsItem, QGraphicsLineItem) + view_data = self.well_view.value().replace(mode=value) + self.well_view.setValue(view_data) + self.valueChanged.emit(self.value()) + + def _update_scene(self) -> None: + """Update the scene depending on the selected tab.""" + mode = self._get_mode_widget().value() + view_data = self.well_view.value().replace(mode=mode) + self.well_view.setValue(view_data) + + def _set_center_value(self, mode: Center) -> None: + """Set the center widget values.""" + self.center_radio_btn.setChecked(True) + self.center_wdg.setValue(mode) + + def _set_random_value(self, mode: RandomPoints) -> None: + """Set the random widget values.""" + with signals_blocked(self._mode_btn_group): + self.random_radio_btn.setChecked(True) + self._enable_radio_buttons_wdgs() + + self._check_for_warnings(mode) + # here blocking random widget signals to not generate a new random seed + with signals_blocked(self.random_wdg): + self.random_wdg.setValue(mode) + + def _set_grid_value(self, mode: GridRowsColumns) -> None: + """Set the grid widget values.""" + self.grid_radio_btn.setChecked(True) + self.grid_wdg.setValue(mode) + + def _check_for_warnings(self, mode: RandomPoints) -> None: + """RandomPoints width and height warning. + + If max width and height are grater than the plate well size, set them to the + plate well size. + """ + if self._plate is None: + return + + # well_size is in mm, convert to µm + well_size_x, well_size_y = self._plate.well_size + if mode.max_width > well_size_x * 1000 or mode.max_height > well_size_y * 1000: + mode = mode.replace( + max_width=well_size_x * 1000, + max_height=well_size_y * 1000, + ) + warnings.warn( + "RandomPoints `max_width` and/or `max_height` are larger than " + "the well size. They will be set to the well size.", + stacklevel=2, + ) + + def _plate_to_random(self, plate: WellPlate | None) -> RandomPoints: + """Convert a WellPlate object to a RandomPoints object.""" + well_size_x, well_size_y = plate.well_size if plate is not None else (0.0, 0.0) + return RandomPoints( + num_points=self.random_wdg._number_of_points.value(), + max_width=well_size_x * 1000 if plate else 0.0, # convert to µm + max_height=well_size_y * 1000 if plate else 0.0, # convert to µm + shape=ELLIPSE if (plate and plate.circular_wells) else RECT, + random_seed=self.random_wdg.random_seed, + fov_width=self.random_wdg.fov_size[0], + fov_height=self.random_wdg.fov_size[1], + ) diff --git a/src/pymmcore_widgets/hcs/_fov_widget/_util.py b/src/pymmcore_widgets/hcs/_fov_widget/_util.py new file mode 100644 index 000000000..4bb520e5b --- /dev/null +++ b/src/pymmcore_widgets/hcs/_fov_widget/_util.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from useq import RelativePosition + + +def nearest_neighbor( + points: list[RelativePosition], top_x: float, top_y: float +) -> list[RelativePosition]: + """Find the nearest neighbor path for a list of points. + + The starting point is the closest to (top_x, top_y). + """ + first_point: RelativePosition | None = _top_left(points, top_x, top_y) + + if first_point is None: + return [] + + n = len(points) + visited: dict[int, bool] = {i: False for i in range(n)} + path_indices: list[int] = [points.index(first_point)] + visited[path_indices[0]] = True + + for _ in range(n - 1): + current_point = path_indices[-1] + nearest_point = None + min_distance = float("inf") + + for i in range(n): + if not visited[i]: + distance = _calculate_distance(points[current_point], points[i]) + if distance < min_distance: + min_distance = distance + nearest_point = i + + if nearest_point is not None: + path_indices.append(nearest_point) + visited[nearest_point] = True + + return [points[i] for i in path_indices] + + +def _calculate_distance(point1: RelativePosition, point2: RelativePosition) -> float: + """Calculate the Euclidean distance between two points.""" + return math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2) + + +def _top_left( + points: list[RelativePosition], top_x: float, top_y: float +) -> RelativePosition: + """Find the top left point in respect to (top_x, top_y).""" + return sorted( + points, + key=lambda coord: math.sqrt( + ((coord.x - top_x) ** 2) + ((coord.y - top_y) ** 2) + ), + )[0] From c0965e6ca23ee9f176ee5b1c2a7c20fcb7333d90 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 15:21:09 -0400 Subject: [PATCH 11/22] wip --- .../points_plans/_points_plan_selector.py | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py 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..9ff985bf5 --- /dev/null +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +import useq +from qtpy.QtCore import Signal +from qtpy.QtWidgets import ( + QButtonGroup, + QGraphicsLineItem, + QHBoxLayout, + QLabel, + QRadioButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from superqt.utils import signals_blocked + +from pymmcore_widgets._util import SeparatorWidget +from pymmcore_widgets.useq_widgets.points_plans._grid_row_column_widget import ( + GridRowColumnWidget, +) +from pymmcore_widgets.useq_widgets.points_plans._random_points_widget import ( + RandomPointWidget, +) + +if TYPE_CHECKING: + from useq import WellPlate + +FIXED_POLICY = (QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) +CENTER = "Center" +RANDOM = "Random" +GRID = "Grid" +OFFSET = 20 +RECT = "rectangle" # Shape.RECTANGLE in useq +ELLIPSE = "ellipse" # Shape.ELLIPSE in useq + +# excluding useq.GridWidthHeight even though it's also a valid relative multi point plan +RelativePointPlan = 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) + layout.addWidget(QLabel("Single FOV")) + + def value(self) -> useq.RelativePosition: + return useq.RelativePosition() + + +class _FOVSelectorWidget(QWidget): + """Widget to select the FOVVs per well of the plate.""" + + valueChanged = Signal(object) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent=parent) + + # centerwidget + self.center_wdg = RelativePositionWidget() + self.center_radio_btn = QRadioButton() + self.center_radio_btn.setChecked(True) + self.center_radio_btn.setSizePolicy(*FIXED_POLICY) + self.center_radio_btn.setObjectName(CENTER) + center_wdg_layout = QHBoxLayout() + center_wdg_layout.setContentsMargins(0, 0, 0, 0) + center_wdg_layout.setSpacing(5) + center_wdg_layout.addWidget(self.center_radio_btn) + center_wdg_layout.addWidget(self.center_wdg) + # random widget + self.random_wdg = RandomPointWidget() + self.random_wdg.setEnabled(False) + self.random_radio_btn = QRadioButton() + self.random_radio_btn.setSizePolicy(*FIXED_POLICY) + self.random_radio_btn.setObjectName(RANDOM) + random_wdg_layout = QHBoxLayout() + random_wdg_layout.setContentsMargins(0, 0, 0, 0) + random_wdg_layout.setSpacing(5) + random_wdg_layout.addWidget(self.random_radio_btn) + random_wdg_layout.addWidget(self.random_wdg) + # grid widget + self.grid_wdg = GridRowColumnWidget() + self.grid_wdg.setEnabled(False) + self.grid_radio_btn = QRadioButton() + self.grid_radio_btn.setSizePolicy(*FIXED_POLICY) + self.grid_radio_btn.setObjectName(GRID) + grid_wdg_layout = QHBoxLayout() + grid_wdg_layout.setContentsMargins(0, 0, 0, 0) + grid_wdg_layout.setSpacing(5) + grid_wdg_layout.addWidget(self.grid_radio_btn) + grid_wdg_layout.addWidget(self.grid_wdg) + + # radio buttons group for fov mode selection + self._mode_btn_group = QButtonGroup() + self._mode_btn_group.addButton(self.center_radio_btn) + self._mode_btn_group.addButton(self.random_radio_btn) + self._mode_btn_group.addButton(self.grid_radio_btn) + self._mode_btn_group.buttonToggled.connect(self._on_radiobutton_toggled) + + # main + main_layout = QVBoxLayout(self) + main_layout.setSpacing(10) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addWidget(SeparatorWidget()) + main_layout.addLayout(center_wdg_layout) + main_layout.addWidget(SeparatorWidget()) + main_layout.addLayout(random_wdg_layout) + main_layout.addWidget(SeparatorWidget()) + main_layout.addLayout(grid_wdg_layout) + main_layout.addWidget(SeparatorWidget()) + + # connect + self.random_wdg.valueChanged.connect(self._on_value_changed) + self.grid_wdg.valueChanged.connect(self._on_value_changed) + + # _________________________PUBLIC METHODS_________________________ # + + def value(self) -> useq.RelativeMultiPointPlan: + return self._plate, self._get_mode_widget().value() + + def setValue( + self, point_plane: useq.RelativeMultiPointPlan, plate: WellPlate | None = None + ) -> None: + """Set the value of the widget. + + Parameters + ---------- + point_plane : RelativeMultiPointPlan | None + The well plate object. + mode : Center | useq.RandomPoints | useq.GridRowsColumns + The mode to use to select the FOVs. + """ + self._plate = plate + + if self._plate is None: + # reset view scene and mode widgets + with signals_blocked(self.random_wdg): + self.random_wdg.reset() + with signals_blocked(self.grid_wdg): + self.grid_wdg.reset() + + # set the radio buttons + self._update_mode_widgets(mode) + return + + # update view data + well_size_x, well_size_y = self._plate.well_size + view_data = _WellViewData( + # plate well size in mm, convert to µm + well_size=(well_size_x * 1000, well_size_y * 1000), + circular=self._plate.circular_wells, + padding=OFFSET, + mode=mode, + ) + self.well_view.setValue(view_data) + + # update the fov size in each mode widget + self._update_mode_wdgs_fov_size( + (mode.fov_width, mode.fov_height) if mode else (None, None) + ) + + self._update_mode_widgets(mode) + + # _________________________PRIVATE METHODS_________________________ # + + def _get_mode_widget( + self, + ) -> RelativePositionWidget | RandomPointWidget | GridRowColumnWidget: + """Return the current mode.""" + if checked_btn := self._mode_btn_group.checkedButton(): + mode_name = checked_btn.objectName() + if mode_name == CENTER: + return self.center_wdg + elif mode_name == RANDOM: + return self.random_wdg + elif mode_name == GRID: + return self.grid_wdg + + def _update_mode_widgets(self, mode: RelativePointPlan | None) -> None: + """Update the mode widgets.""" + if isinstance(mode, useq.RandomPoints): + self._set_random_value(mode) + else: + # update the randon widget values depending on the plate + with signals_blocked(self.random_wdg): + self.random_wdg.setValue(self._plate_to_random(self._plate)) + # update center or grid widgets + if isinstance(mode, RandomPointWidget): + self._set_center_value(mode) + elif isinstance(mode, useq.GridRowsColumns): + self._set_grid_value(mode) + + def _update_mode_wdgs_fov_size( + self, fov_size: tuple[float | None, float | None] + ) -> None: + """Update the fov size in each mode widget.""" + self.center_wdg.fov_size = fov_size + self.random_wdg.fov_size = fov_size + self.grid_wdg.fov_size = fov_size + + def _on_points_warning(self, num_points: int) -> None: + self.random_wdg._number_of_points.setValue(num_points) + + def _on_radiobutton_toggled(self, radio_btn: QRadioButton) -> None: + """Update the scene when the tab is changed.""" + self.well_view.clear(_WellAreaGraphicsItem, _FOVGraphicsItem, QGraphicsLineItem) + self._enable_radio_buttons_wdgs() + self._update_scene() + + if radio_btn.isChecked(): + self.valueChanged.emit(self.value()) + + def _enable_radio_buttons_wdgs(self) -> None: + """Enable any radio button that is checked.""" + for btn in self._mode_btn_group.buttons(): + self.MODE[btn.objectName()].setEnabled(btn.isChecked()) + + def _on_value_changed( + self, value: useq.RandomPoints | useq.GridRowsColumns + ) -> None: + self.well_view.clear(_WellAreaGraphicsItem, _FOVGraphicsItem, QGraphicsLineItem) + view_data = self.well_view.value().replace(mode=value) + self.well_view.setValue(view_data) + self.valueChanged.emit(self.value()) + + def _update_scene(self) -> None: + """Update the scene depending on the selected tab.""" + mode = self._get_mode_widget().value() + view_data = self.well_view.value().replace(mode=mode) + self.well_view.setValue(view_data) + + def _set_center_value(self, mode: Center) -> None: + """Set the center widget values.""" + self.center_radio_btn.setChecked(True) + self.center_wdg.setValue(mode) + + def _set_random_value(self, mode: useq.RandomPoints) -> None: + """Set the random widget values.""" + with signals_blocked(self._mode_btn_group): + self.random_radio_btn.setChecked(True) + self._enable_radio_buttons_wdgs() + + self._check_for_warnings(mode) + # here blocking random widget signals to not generate a new random seed + with signals_blocked(self.random_wdg): + self.random_wdg.setValue(mode) + + def _set_grid_value(self, mode: useq.GridRowsColumns) -> None: + """Set the grid widget values.""" + self.grid_radio_btn.setChecked(True) + self.grid_wdg.setValue(mode) + + def _check_for_warnings(self, mode: useq.RandomPoints) -> None: + """useq.RandomPoints width and height warning. + + If max width and height are grater than the plate well size, set them to the + plate well size. + """ + if self._plate is None: + return + + # well_size is in mm, convert to µm + well_size_x, well_size_y = self._plate.well_size + if mode.max_width > well_size_x * 1000 or mode.max_height > well_size_y * 1000: + mode = mode.replace( + max_width=well_size_x * 1000, + max_height=well_size_y * 1000, + ) + warnings.warn( + "useq.RandomPoints `max_width` and/or `max_height` are larger than " + "the well size. They will be set to the well size.", + stacklevel=2, + ) + + def _plate_to_random(self, plate: WellPlate | None) -> useq.RandomPoints: + """Convert a WellPlate object to a useq.RandomPoints object.""" + well_size_x, well_size_y = plate.well_size if plate is not None else (0.0, 0.0) + return useq.RandomPoints( + num_points=self.random_wdg._number_of_points.value(), + max_width=well_size_x * 1000 if plate else 0.0, # convert to µm + max_height=well_size_y * 1000 if plate else 0.0, # convert to µm + shape=ELLIPSE if (plate and plate.circular_wells) else RECT, + random_seed=self.random_wdg.random_seed, + fov_width=self.random_wdg.fov_size[0], + fov_height=self.random_wdg.fov_size[1], + ) + + +if __name__ == "__main__": + from qtpy.QtWidgets import QApplication + + app = QApplication([]) + widget = _FOVSelectorWidget() + widget.show() + app.exec_() From 96d20d321404aa3f9b3c102d2c8c8eaaceadb5c0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:09:14 -0400 Subject: [PATCH 12/22] finish --- .../useq_widgets/points_plans/__init__.py | 3 +- .../points_plans/_points_plan_selector.py | 305 +++++------------- .../points_plans/_random_points_widget.py | 5 +- x.py | 11 + 4 files changed, 91 insertions(+), 233 deletions(-) create mode 100644 x.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/_points_plan_selector.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py index 9ff985bf5..dcc51a21c 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 @@ -1,40 +1,21 @@ from __future__ import annotations -import warnings -from typing import TYPE_CHECKING - import useq from qtpy.QtCore import Signal from qtpy.QtWidgets import ( QButtonGroup, - QGraphicsLineItem, QHBoxLayout, QLabel, QRadioButton, - QSizePolicy, QVBoxLayout, QWidget, ) from superqt.utils import signals_blocked from pymmcore_widgets._util import SeparatorWidget -from pymmcore_widgets.useq_widgets.points_plans._grid_row_column_widget import ( - GridRowColumnWidget, -) -from pymmcore_widgets.useq_widgets.points_plans._random_points_widget import ( - RandomPointWidget, -) -if TYPE_CHECKING: - from useq import WellPlate - -FIXED_POLICY = (QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) -CENTER = "Center" -RANDOM = "Random" -GRID = "Grid" -OFFSET = 20 -RECT = "rectangle" # Shape.RECTANGLE in useq -ELLIPSE = "ellipse" # Shape.ELLIPSE in useq +from ._grid_row_column_widget import GridRowColumnWidget +from ._random_points_widget import RandomPointWidget # excluding useq.GridWidthHeight even though it's also a valid relative multi point plan RelativePointPlan = useq.GridRowsColumns | useq.RandomPoints | useq.RelativePosition @@ -50,249 +31,115 @@ def __init__(self, parent: QWidget | None = None) -> None: 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. -class _FOVSelectorWidget(QWidget): - """Widget to select the FOVVs per well of the plate.""" + 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) - # centerwidget - self.center_wdg = RelativePositionWidget() + # WIDGET ---------------------- + + # plan widgets + self.relative_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.relative_pos_wdg + + # radio buttons selection + self.center_radio_btn = QRadioButton() self.center_radio_btn.setChecked(True) - self.center_radio_btn.setSizePolicy(*FIXED_POLICY) - self.center_radio_btn.setObjectName(CENTER) - center_wdg_layout = QHBoxLayout() - center_wdg_layout.setContentsMargins(0, 0, 0, 0) - center_wdg_layout.setSpacing(5) - center_wdg_layout.addWidget(self.center_radio_btn) - center_wdg_layout.addWidget(self.center_wdg) - # random widget - self.random_wdg = RandomPointWidget() - self.random_wdg.setEnabled(False) self.random_radio_btn = QRadioButton() - self.random_radio_btn.setSizePolicy(*FIXED_POLICY) - self.random_radio_btn.setObjectName(RANDOM) - random_wdg_layout = QHBoxLayout() - random_wdg_layout.setContentsMargins(0, 0, 0, 0) - random_wdg_layout.setSpacing(5) - random_wdg_layout.addWidget(self.random_radio_btn) - random_wdg_layout.addWidget(self.random_wdg) - # grid widget - self.grid_wdg = GridRowColumnWidget() - self.grid_wdg.setEnabled(False) self.grid_radio_btn = QRadioButton() - self.grid_radio_btn.setSizePolicy(*FIXED_POLICY) - self.grid_radio_btn.setObjectName(GRID) - grid_wdg_layout = QHBoxLayout() - grid_wdg_layout.setContentsMargins(0, 0, 0, 0) - grid_wdg_layout.setSpacing(5) - grid_wdg_layout.addWidget(self.grid_radio_btn) - grid_wdg_layout.addWidget(self.grid_wdg) - # radio buttons group for fov mode selection self._mode_btn_group = QButtonGroup() self._mode_btn_group.addButton(self.center_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 main_layout = QVBoxLayout(self) main_layout.setSpacing(10) main_layout.setContentsMargins(10, 10, 10, 10) - main_layout.addWidget(SeparatorWidget()) - main_layout.addLayout(center_wdg_layout) - main_layout.addWidget(SeparatorWidget()) - main_layout.addLayout(random_wdg_layout) - main_layout.addWidget(SeparatorWidget()) - main_layout.addLayout(grid_wdg_layout) - main_layout.addWidget(SeparatorWidget()) - - # connect - self.random_wdg.valueChanged.connect(self._on_value_changed) - self.grid_wdg.valueChanged.connect(self._on_value_changed) + for btn, wdg in ( + (self.center_radio_btn, self.relative_pos_wdg), + (self.random_radio_btn, self.random_points_wdg), + (self.grid_radio_btn, self.grid_wdg), + ): + wdg.setEnabled(btn.isChecked()) + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + layout.addWidget(btn, 0) + layout.addWidget(wdg, 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._plate, self._get_mode_widget().value() + return self._active_plan_widget.value() - def setValue( - self, point_plane: useq.RelativeMultiPointPlan, plate: WellPlate | None = None - ) -> None: + def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: """Set the value of the widget. Parameters ---------- - point_plane : RelativeMultiPointPlan | None - The well plate object. - mode : Center | useq.RandomPoints | useq.GridRowsColumns - The mode to use to select the FOVs. + plan : useq.RelativePosition | useq.RandomPoints | useq.GridRowsColumns + The point plan to set. """ - self._plate = plate - - if self._plate is None: - # reset view scene and mode widgets - with signals_blocked(self.random_wdg): - self.random_wdg.reset() + 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.reset() - - # set the radio buttons - self._update_mode_widgets(mode) - return - - # update view data - well_size_x, well_size_y = self._plate.well_size - view_data = _WellViewData( - # plate well size in mm, convert to µm - well_size=(well_size_x * 1000, well_size_y * 1000), - circular=self._plate.circular_wells, - padding=OFFSET, - mode=mode, - ) - self.well_view.setValue(view_data) - - # update the fov size in each mode widget - self._update_mode_wdgs_fov_size( - (mode.fov_width, mode.fov_height) if mode else (None, None) - ) - - self._update_mode_widgets(mode) + self.grid_wdg.setValue(plan) + self.grid_radio_btn.setChecked(True) + elif isinstance(plan, useq.RelativePosition): + with signals_blocked(self.relative_pos_wdg): + self.relative_pos_wdg.setValue(plan) + self.center_radio_btn.setChecked(True) + raise ValueError(f"Invalid plan type: {type(plan)}") # _________________________PRIVATE METHODS_________________________ # - def _get_mode_widget( - self, - ) -> RelativePositionWidget | RandomPointWidget | GridRowColumnWidget: - """Return the current mode.""" - if checked_btn := self._mode_btn_group.checkedButton(): - mode_name = checked_btn.objectName() - if mode_name == CENTER: - return self.center_wdg - elif mode_name == RANDOM: - return self.random_wdg - elif mode_name == GRID: - return self.grid_wdg - - def _update_mode_widgets(self, mode: RelativePointPlan | None) -> None: - """Update the mode widgets.""" - if isinstance(mode, useq.RandomPoints): - self._set_random_value(mode) - else: - # update the randon widget values depending on the plate - with signals_blocked(self.random_wdg): - self.random_wdg.setValue(self._plate_to_random(self._plate)) - # update center or grid widgets - if isinstance(mode, RandomPointWidget): - self._set_center_value(mode) - elif isinstance(mode, useq.GridRowsColumns): - self._set_grid_value(mode) - - def _update_mode_wdgs_fov_size( - self, fov_size: tuple[float | None, float | None] - ) -> None: - """Update the fov size in each mode widget.""" - self.center_wdg.fov_size = fov_size - self.random_wdg.fov_size = fov_size - self.grid_wdg.fov_size = fov_size - - def _on_points_warning(self, num_points: int) -> None: - self.random_wdg._number_of_points.setValue(num_points) - - def _on_radiobutton_toggled(self, radio_btn: QRadioButton) -> None: - """Update the scene when the tab is changed.""" - self.well_view.clear(_WellAreaGraphicsItem, _FOVGraphicsItem, QGraphicsLineItem) - self._enable_radio_buttons_wdgs() - self._update_scene() - - if radio_btn.isChecked(): + def _on_radiobutton_toggled(self, btn: QRadioButton, checked: bool) -> None: + btn2wdg: dict[QRadioButton, QWidget] = { + self.center_radio_btn: self.relative_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 _enable_radio_buttons_wdgs(self) -> None: - """Enable any radio button that is checked.""" - for btn in self._mode_btn_group.buttons(): - self.MODE[btn.objectName()].setEnabled(btn.isChecked()) - - def _on_value_changed( - self, value: useq.RandomPoints | useq.GridRowsColumns - ) -> None: - self.well_view.clear(_WellAreaGraphicsItem, _FOVGraphicsItem, QGraphicsLineItem) - view_data = self.well_view.value().replace(mode=value) - self.well_view.setValue(view_data) + def _on_value_changed(self) -> None: self.valueChanged.emit(self.value()) - - def _update_scene(self) -> None: - """Update the scene depending on the selected tab.""" - mode = self._get_mode_widget().value() - view_data = self.well_view.value().replace(mode=mode) - self.well_view.setValue(view_data) - - def _set_center_value(self, mode: Center) -> None: - """Set the center widget values.""" - self.center_radio_btn.setChecked(True) - self.center_wdg.setValue(mode) - - def _set_random_value(self, mode: useq.RandomPoints) -> None: - """Set the random widget values.""" - with signals_blocked(self._mode_btn_group): - self.random_radio_btn.setChecked(True) - self._enable_radio_buttons_wdgs() - - self._check_for_warnings(mode) - # here blocking random widget signals to not generate a new random seed - with signals_blocked(self.random_wdg): - self.random_wdg.setValue(mode) - - def _set_grid_value(self, mode: useq.GridRowsColumns) -> None: - """Set the grid widget values.""" - self.grid_radio_btn.setChecked(True) - self.grid_wdg.setValue(mode) - - def _check_for_warnings(self, mode: useq.RandomPoints) -> None: - """useq.RandomPoints width and height warning. - - If max width and height are grater than the plate well size, set them to the - plate well size. - """ - if self._plate is None: - return - - # well_size is in mm, convert to µm - well_size_x, well_size_y = self._plate.well_size - if mode.max_width > well_size_x * 1000 or mode.max_height > well_size_y * 1000: - mode = mode.replace( - max_width=well_size_x * 1000, - max_height=well_size_y * 1000, - ) - warnings.warn( - "useq.RandomPoints `max_width` and/or `max_height` are larger than " - "the well size. They will be set to the well size.", - stacklevel=2, - ) - - def _plate_to_random(self, plate: WellPlate | None) -> useq.RandomPoints: - """Convert a WellPlate object to a useq.RandomPoints object.""" - well_size_x, well_size_y = plate.well_size if plate is not None else (0.0, 0.0) - return useq.RandomPoints( - num_points=self.random_wdg._number_of_points.value(), - max_width=well_size_x * 1000 if plate else 0.0, # convert to µm - max_height=well_size_y * 1000 if plate else 0.0, # convert to µm - shape=ELLIPSE if (plate and plate.circular_wells) else RECT, - random_seed=self.random_wdg.random_seed, - fov_width=self.random_wdg.fov_size[0], - fov_height=self.random_wdg.fov_size[1], - ) - - -if __name__ == "__main__": - from qtpy.QtWidgets import QApplication - - app = QApplication([]) - widget = _FOVSelectorWidget() - widget.show() - app.exec_() 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/x.py b/x.py new file mode 100644 index 000000000..77ab935d8 --- /dev/null +++ b/x.py @@ -0,0 +1,11 @@ +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets.useq_widgets.points_plans import RelativePointPlanSelector + +app = QApplication([]) + +# Create a widget +wdg = RelativePointPlanSelector() +wdg.show() + +app.exec() From 2a04aa2b0400df15ec0b7c99c59304a422f0b75e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:09:22 -0400 Subject: [PATCH 13/22] rm x --- x.py | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 x.py diff --git a/x.py b/x.py deleted file mode 100644 index 77ab935d8..000000000 --- a/x.py +++ /dev/null @@ -1,11 +0,0 @@ -from qtpy.QtWidgets import QApplication - -from pymmcore_widgets.useq_widgets.points_plans import RelativePointPlanSelector - -app = QApplication([]) - -# Create a widget -wdg = RelativePointPlanSelector() -wdg.show() - -app.exec() From ed7b0f42b1b5456b46908c52b1fa543a8c6223ec Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:11:02 -0400 Subject: [PATCH 14/22] remove hcs --- .../hcs/_fov_widget/_fov_sub_widgets.py | 472 ------------------ .../hcs/_fov_widget/_fov_widget.py | 307 ------------ src/pymmcore_widgets/hcs/_fov_widget/_util.py | 60 --- 3 files changed, 839 deletions(-) delete mode 100644 src/pymmcore_widgets/hcs/_fov_widget/_fov_sub_widgets.py delete mode 100644 src/pymmcore_widgets/hcs/_fov_widget/_fov_widget.py delete mode 100644 src/pymmcore_widgets/hcs/_fov_widget/_util.py diff --git a/src/pymmcore_widgets/hcs/_fov_widget/_fov_sub_widgets.py b/src/pymmcore_widgets/hcs/_fov_widget/_fov_sub_widgets.py deleted file mode 100644 index 87e74c62f..000000000 --- a/src/pymmcore_widgets/hcs/_fov_widget/_fov_sub_widgets.py +++ /dev/null @@ -1,472 +0,0 @@ -from __future__ import annotations - -import warnings -from dataclasses import dataclass -from typing import Any, Optional - -from qtpy.QtCore import QRectF, Qt, Signal -from qtpy.QtGui import QColor, QPen -from qtpy.QtWidgets import ( - QGraphicsEllipseItem, - QGraphicsLineItem, - QGraphicsRectItem, - QGraphicsScene, - QGroupBox, - QLabel, - QSizePolicy, - QVBoxLayout, - QWidget, -) -from useq import GridRowsColumns, Position, RandomPoints, RelativePosition, Shape - -from pymmcore_widgets.hcs._graphics_items import ( - FOV, - GREEN, - _FOVGraphicsItem, - _WellAreaGraphicsItem, -) -from pymmcore_widgets.hcs._util import _ResizingGraphicsView - -from ._util import nearest_neighbor - -AlignCenter = Qt.AlignmentFlag.AlignCenter -FIXED_POLICY = (QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) -DEFAULT_VIEW_SIZE = (300, 300) # px -DEFAULT_WELL_SIZE = (10, 10) # mm -DEFAULT_FOV_SIZE = (DEFAULT_WELL_SIZE[0] / 10, DEFAULT_WELL_SIZE[1] / 10) # mm -PEN_WIDTH = 4 -RECT = Shape.RECTANGLE -ELLIPSE = Shape.ELLIPSE -PEN_AREA = QPen(QColor(GREEN)) -PEN_AREA.setWidth(PEN_WIDTH) - - -class Center(Position): - """A subclass of GridRowsColumns to store the center coordinates and FOV size. - - Attributes - ---------- - fov_width : float | None - The width of the FOV in µm. - fov_height : float | None - The height of the FOV in µm. - """ - - fov_width: Optional[float] = None # noqa: UP007 - fov_height: Optional[float] = None # noqa: UP007 - - -class _CenterFOVWidget(QGroupBox): - """Widget to select the center of a specifiied area.""" - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - - self._x: float = 0.0 - self._y: float = 0.0 - - self._fov_size: tuple[float | None, float | None] = (None, None) - - lbl = QLabel(text="Center of the Well.") - lbl.setStyleSheet("font-weight: bold;") - lbl.setAlignment(AlignCenter) - - # main - main_layout = QVBoxLayout(self) - main_layout.setSpacing(0) - main_layout.setContentsMargins(10, 10, 10, 10) - main_layout.addWidget(lbl) - - @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 value(self) -> Center: - """Return the values of the widgets.""" - fov_x, fov_y = self._fov_size - return Center(x=self._x, y=self._y, fov_width=fov_x, fov_height=fov_y) - - def setValue(self, value: Center) -> None: - """Set the values of the widgets.""" - self._x = value.x or 0.0 - self._y = value.y or 0.0 - self.fov_size = (value.fov_width, value.fov_height) - - -@dataclass -class _WellViewData: - """A NamedTuple to store the well view data. - - Attributes - ---------- - well_size : tuple[float | None, float | None] - The size of the well in µm. By default, (None, None). - circular : bool - True if the well is circular. By default, False. - padding : int - The padding in pixel between the well and the view. By default, 0. - show_fovs_order : bool - If True, the FOVs will be connected by black lines to represent the order of - acquisition. In addition, the first FOV will be drawn with a black color, the - others with a white color. By default, True. - mode : Center | GridRowsColumns | RandomPoints | None - The mode to use to draw the FOVs. By default, None. - """ - - well_size: tuple[float | None, float | None] = (None, None) - circular: bool = False - padding: int = 0 - show_fovs_order: bool = True - mode: Center | GridRowsColumns | RandomPoints | None = None - - def replace(self, **kwargs: Any) -> _WellViewData: - """Replace the attributes of the dataclass.""" - return _WellViewData( - well_size=kwargs.get("well_size", self.well_size), - circular=kwargs.get("circular", self.circular), - padding=kwargs.get("padding", self.padding), - show_fovs_order=kwargs.get("show_fovs_order", self.show_fovs_order), - mode=kwargs.get("mode", self.mode), - ) - - -DEFAULT_WELL_DATA = _WellViewData() - - -class _WellView(_ResizingGraphicsView): - """Graphics view to draw a well and the FOVs. - - Parameters - ---------- - parent : QWidget | None - The parent widget. - view_size : tuple[int, int] - The minimum size of the QGraphicsView in pixel. By default (300, 300). - data : WellViewData - The data to use to initialize the view. By default: - WellViewData( - well_size=(None, None), - circular=False, - padding=0, - show_fovs_order=True, - mode=None, - ) - """ - - pointsWarning: Signal = Signal(int) - - def __init__( - self, - parent: QWidget | None = None, - view_size: tuple[int, int] = DEFAULT_VIEW_SIZE, - data: _WellViewData = DEFAULT_WELL_DATA, - ) -> None: - self._scene = QGraphicsScene() - super().__init__(self._scene, parent) - - self.setStyleSheet("background:grey; border-radius: 5px;") - - self._size_x, self._size_y = view_size - self.setMinimumSize(self._size_x, self._size_y) - - # set the scene rect so that the center is (0, 0) - self.setSceneRect( - -self._size_x / 2, -self._size_x / 2, self._size_x, self._size_y - ) - - self.setValue(data) - - # _________________________PUBLIC METHODS_________________________ # - - def setMode(self, mode: Center | GridRowsColumns | RandomPoints | None) -> None: - """Set the mode to use to draw the FOVs.""" - self._mode = mode - self._fov_width = mode.fov_width if mode else None - self._fov_height = mode.fov_height if mode else None - - # convert size in scene pixel - self._fov_width_px = ( - (self._size_x * self._fov_width) / self._well_width - if self._fov_width and self._well_width - else None - ) - self._fov_height_px = ( - (self._size_y * self._fov_height) / self._well_height - if self._fov_height and self._well_height - else None - ) - self._update_scene(self._mode) - - def mode(self) -> Center | GridRowsColumns | RandomPoints | None: - """Return the mode to use to draw the FOVs.""" - return self._mode - - def setWellSize(self, size: tuple[float | None, float | None]) -> None: - """Set the well size width and height in mm.""" - self._well_width, self._well_height = size - - def wellSize(self) -> tuple[float | None, float | None]: - """Return the well size width and height in mm.""" - return self._well_width, self._well_height - - def setCircular(self, is_circular: bool) -> None: - """Set True if the well is circular.""" - self._is_circular = is_circular - # update the mode fov size if a mode is set - if self._mode is not None and isinstance(self._mode, RandomPoints): - self._mode = self._mode.replace(shape=ELLIPSE if is_circular else RECT) - - def isCircular(self) -> bool: - """Return True if the well is circular.""" - return self._is_circular - - def setPadding(self, padding: int) -> None: - """Set the padding in pixel between the well and the view.""" - self._padding = padding - - def padding(self) -> int: - """Return the padding in pixel between the well and the view.""" - return self._padding - - def showFovOrder(self, show: bool) -> None: - """Show the FOVs order in the scene by drawing lines connecting the FOVs.""" - self._show_fovs_order = show - - def fovsOrder(self) -> bool: - """Return True if the FOVs order is shown.""" - return self._show_fovs_order - - def clear(self, *item_types: Any) -> None: - """Remove all items of `item_types` from the scene.""" - if not item_types: - self._scene.clear() - self._mode = None - for item in self._scene.items(): - if not item_types or isinstance(item, item_types): - self._scene.removeItem(item) - self._scene.update() - - def refresh(self) -> None: - """Refresh the scene.""" - self._scene.clear() - self._draw_well_area() - if self._mode is not None: - self._update_scene(self._mode) - - def value(self) -> _WellViewData: - """Return the value of the scene.""" - return _WellViewData( - well_size=(self._well_width, self._well_height), - circular=self._is_circular, - padding=self._padding, - show_fovs_order=self._show_fovs_order, - mode=self._mode, - ) - - def setValue(self, value: _WellViewData) -> None: - """Set the value of the scene.""" - self.clear() - - self.setWellSize(value.well_size) - self.setCircular(value.circular) - self.setPadding(value.padding) - self.showFovOrder(value.show_fovs_order) - self.setMode(value.mode) - - if self._well_width is None or self._well_height is None: - self.clear() - return - - self._draw_well_area() - self._update_scene(self._mode) - - # _________________________PRIVATE METHODS_________________________ # - - def _get_reference_well_area(self) -> QRectF | None: - """Return the well area in scene pixel as QRectF.""" - if self._well_width is None or self._well_height is None: - return None - - well_aspect = self._well_width / self._well_height - well_size_px = self._size_x - self._padding - size_x = size_y = well_size_px - # keep the ratio between well_size_x and well_size_y - if well_aspect > 1: - size_y = int(well_size_px * 1 / well_aspect) - elif well_aspect < 1: - size_x = int(well_size_px * well_aspect) - # set the position of the well plate in the scene using the center of the view - # QRectF as reference - x = self.sceneRect().center().x() - (size_x / 2) - y = self.sceneRect().center().y() - (size_y / 2) - w = size_x - h = size_y - - return QRectF(x, y, w, h) - - def _draw_well_area(self) -> None: - """Draw the well area in the scene.""" - if self._well_width is None or self._well_height is None: - self.clear() - return - - ref = self._get_reference_well_area() - if ref is None: - return - - if self._is_circular: - self._scene.addEllipse(ref, pen=PEN_AREA) - else: - self._scene.addRect(ref, pen=PEN_AREA) - - def _update_scene( - self, value: Center | GridRowsColumns | RandomPoints | None - ) -> None: - """Update the scene with the given mode.""" - if value is None: - self.clear(_WellAreaGraphicsItem, _FOVGraphicsItem) - return - - if isinstance(value, Center): - self._update_center_fov(value) - elif isinstance(value, RandomPoints): - self._update_random_fovs(value) - elif isinstance(value, (GridRowsColumns)): - self._update_grid_fovs(value) - else: - raise ValueError(f"Invalid value: {value}") - - def _update_center_fov(self, value: Center) -> None: - """Update the scene with the center point.""" - points = [FOV(value.x or 0.0, value.y or 0.0, self.sceneRect())] - self._draw_fovs(points) - - def _update_random_fovs(self, value: RandomPoints) -> None: - """Update the scene with the random points.""" - self.clear(_WellAreaGraphicsItem, QGraphicsEllipseItem, QGraphicsRectItem) - - if isinstance(value, RandomPoints): - self._is_circular = value.shape == ELLIPSE - - # get the well area in scene pixel - ref_area = self._get_reference_well_area() - - if ref_area is None or self._well_width is None or self._well_height is None: - return - - well_area_x_px = ref_area.width() * value.max_width / self._well_width - well_area_y_px = ref_area.height() * value.max_height / self._well_height - - # calculate the starting point of the well area - x = ref_area.center().x() - (well_area_x_px / 2) - y = ref_area.center().y() - (well_area_y_px / 2) - - rect = QRectF(x, y, well_area_x_px, well_area_y_px) - area = _WellAreaGraphicsItem(rect, self._is_circular, PEN_WIDTH) - - # draw well and well area - self._draw_well_area() - self._scene.addItem(area) - - val = value.replace( - max_width=area.boundingRect().width(), - max_height=area.boundingRect().height(), - fov_width=self._fov_width_px, - fov_height=self._fov_height_px, - ) - # get the random points list - - points = self._get_random_points(val, area.boundingRect()) - # draw the random points - self._draw_fovs(points) - - def _get_random_points(self, points: RandomPoints, area: QRectF) -> list[FOV]: - """Create the points for the random scene.""" - # catch the warning raised by the RandomPoints class if the max number of - # iterations is reached. - with warnings.catch_warnings(record=True) as w: - # note: inverting the y axis because in scene, y up is negative and y down - # is positive. - pos = [ - RelativePosition(x=point.x, y=point.y * (-1), name=point.name) # type: ignore - for point in points - ] - if len(pos) != points.num_points: - self.pointsWarning.emit(len(pos)) - - if len(w): - warnings.warn(w[0].message, w[0].category, stacklevel=2) - - top_x, top_y = area.topLeft().x(), area.topLeft().y() - return [FOV(p.x, p.y, area) for p in nearest_neighbor(pos, top_x, top_y)] - - def _update_grid_fovs(self, value: GridRowsColumns) -> None: - """Update the scene with the grid points.""" - val = value.replace(fov_width=self._fov_width_px, fov_height=self._fov_width_px) - - # x and y center coords of the scene in px - x, y = ( - self._scene.sceneRect().center().x(), - self._scene.sceneRect().center().y(), - ) - rect = self._get_reference_well_area() - - if rect is None: - return - # create a list of FOV points by shifting the grid by the center coords. - # note: inverting the y axis because in scene, y up is negative and y down is - # positive. - points = [FOV(g.x + x, (g.y - y) * (-1), rect) for g in val] - self._draw_fovs(points) - - def _draw_fovs(self, points: list[FOV]) -> None: - """Draw the fovs in the scene as `_FOVPoints. - - If 'showFOVsOrder' is True, the FOVs will be connected by black lines to - represent the order of acquisition. In addition, the first FOV will be drawn - with a black color, the others with a white color. - """ - if not self._fov_width_px or not self._fov_height_px: - return - - def _get_pen(index: int) -> QPen: - """Return the pen to use for the fovs.""" - pen = ( - QPen(Qt.GlobalColor.black) - if index == 0 and len(points) > 1 - else QPen(Qt.GlobalColor.white) - ) - pen.setWidth(3) - return pen - - self.clear(_FOVGraphicsItem, QGraphicsLineItem) - - line_pen = QPen(Qt.GlobalColor.black) - line_pen.setWidth(2) - - x = y = None - for index, fov in enumerate(points): - fovs = _FOVGraphicsItem( - fov.x, - fov.y, - self._fov_width_px, - self._fov_height_px, - fov.bounding_rect, - ( - _get_pen(index) - if self._show_fovs_order - else QPen(Qt.GlobalColor.white) - ), - ) - - self._scene.addItem(fovs) - # draw the lines connecting the fovs - if x is not None and y is not None and self._show_fovs_order: - self._scene.addLine(x, y, fov.x, fov.y, pen=line_pen) - x, y = (fov.x, fov.y) diff --git a/src/pymmcore_widgets/hcs/_fov_widget/_fov_widget.py b/src/pymmcore_widgets/hcs/_fov_widget/_fov_widget.py deleted file mode 100644 index 5943ec655..000000000 --- a/src/pymmcore_widgets/hcs/_fov_widget/_fov_widget.py +++ /dev/null @@ -1,307 +0,0 @@ -from __future__ import annotations - -import warnings -from typing import TYPE_CHECKING, cast - -from qtpy.QtCore import Signal -from qtpy.QtWidgets import ( - QButtonGroup, - QGraphicsLineItem, - QGridLayout, - QHBoxLayout, - QRadioButton, - QSizePolicy, - QWidget, -) -from superqt.utils import signals_blocked -from useq import ( - GridRowsColumns, - RandomPoints, -) - -from pymmcore_widgets.hcs._fov_widget._fov_sub_widgets import ( - Center, - _CenterFOVWidget, - _WellView, - _WellViewData, -) -from pymmcore_widgets.hcs._graphics_items import ( - _FOVGraphicsItem, - _WellAreaGraphicsItem, -) -from pymmcore_widgets.useq_widgets._grid import _SeparatorWidget -from pymmcore_widgets.useq_widgets._grid_row_column_widget import GridRowColumnWidget -from pymmcore_widgets.useq_widgets._random_points_widget import RandomPointWidget - -if TYPE_CHECKING: - from useq import WellPlate - -FIXED_POLICY = (QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) -CENTER = "Center" -RANDOM = "Random" -GRID = "Grid" -OFFSET = 20 -RECT = "rectangle" # Shape.RECTANGLE in useq -ELLIPSE = "ellipse" # Shape.ELLIPSE in useq - - -class _FOVSelectorWidget(QWidget): - """Widget to select the FOVVs per well of the plate.""" - - valueChanged = Signal(object) - - def __init__( - self, - plate: WellPlate | None = None, - mode: Center | RandomPoints | GridRowsColumns | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent=parent) - - self._plate: WellPlate | None = plate - - # graphics scene to draw the well and the fovs - self.well_view = _WellView() - - # centerwidget - self.center_wdg = _CenterFOVWidget() - self.center_radio_btn = QRadioButton() - self.center_radio_btn.setChecked(True) - self.center_radio_btn.setSizePolicy(*FIXED_POLICY) - self.center_radio_btn.setObjectName(CENTER) - center_wdg_layout = QHBoxLayout() - center_wdg_layout.setContentsMargins(0, 0, 0, 0) - center_wdg_layout.setSpacing(5) - center_wdg_layout.addWidget(self.center_radio_btn) - center_wdg_layout.addWidget(self.center_wdg) - # random widget - self.random_wdg = RandomPointWidget() - self.random_wdg.setEnabled(False) - self.random_radio_btn = QRadioButton() - self.random_radio_btn.setSizePolicy(*FIXED_POLICY) - self.random_radio_btn.setObjectName(RANDOM) - random_wdg_layout = QHBoxLayout() - random_wdg_layout.setContentsMargins(0, 0, 0, 0) - random_wdg_layout.setSpacing(5) - random_wdg_layout.addWidget(self.random_radio_btn) - random_wdg_layout.addWidget(self.random_wdg) - # grid widget - self.grid_wdg = GridRowColumnWidget() - self.grid_wdg.setEnabled(False) - self.grid_radio_btn = QRadioButton() - self.grid_radio_btn.setSizePolicy(*FIXED_POLICY) - self.grid_radio_btn.setObjectName(GRID) - grid_wdg_layout = QHBoxLayout() - grid_wdg_layout.setContentsMargins(0, 0, 0, 0) - grid_wdg_layout.setSpacing(5) - grid_wdg_layout.addWidget(self.grid_radio_btn) - grid_wdg_layout.addWidget(self.grid_wdg) - # radio buttons group for fov mode selection - self._mode_btn_group = QButtonGroup() - self._mode_btn_group.addButton(self.center_radio_btn) - self._mode_btn_group.addButton(self.random_radio_btn) - self._mode_btn_group.addButton(self.grid_radio_btn) - self.MODE: dict[ - str, _CenterFOVWidget | RandomPointWidget | GridRowColumnWidget - ] = { - CENTER: self.center_wdg, - RANDOM: self.random_wdg, - GRID: self.grid_wdg, - } - self._mode_btn_group.buttonToggled.connect(self._on_radiobutton_toggled) - - # main - main_layout = QGridLayout(self) - main_layout.setSpacing(10) - main_layout.setContentsMargins(10, 10, 10, 10) - main_layout.addWidget(_SeparatorWidget(), 0, 0) - main_layout.addLayout(center_wdg_layout, 1, 0) - main_layout.addWidget(_SeparatorWidget(), 2, 0) - main_layout.addLayout(random_wdg_layout, 3, 0) - main_layout.addWidget(_SeparatorWidget(), 4, 0) - main_layout.addLayout(grid_wdg_layout, 5, 0) - main_layout.addWidget(_SeparatorWidget(), 6, 0) - main_layout.addWidget(self.well_view, 0, 1, 7, 1) - - # connect - self.random_wdg.valueChanged.connect(self._on_value_changed) - self.grid_wdg.valueChanged.connect(self._on_value_changed) - self.well_view.pointsWarning.connect(self._on_points_warning) - - self.setValue(self._plate, mode) - - # _________________________PUBLIC METHODS_________________________ # - - def value( - self, - ) -> tuple[WellPlate | None, Center | RandomPoints | GridRowsColumns | None]: - return self._plate, self._get_mode_widget().value() - - def setValue( - self, - plate: WellPlate | None, - mode: Center | RandomPoints | GridRowsColumns | None, - ) -> None: - """Set the value of the widget. - - Parameters - ---------- - plate : WellPlate | None - The well plate object. - mode : Center | RandomPoints | GridRowsColumns - The mode to use to select the FOVs. - """ - self.well_view.clear() - - self._plate = plate - - if self._plate is None: - # reset view scene and mode widgets - self.well_view.setValue(_WellViewData()) - with signals_blocked(self.random_wdg): - self.random_wdg.reset() - with signals_blocked(self.grid_wdg): - self.grid_wdg.reset() - - # set the radio buttons - self._update_mode_widgets(mode) - return - - # update view data - well_size_x, well_size_y = self._plate.well_size - view_data = _WellViewData( - # plate well size in mm, convert to µm - well_size=(well_size_x * 1000, well_size_y * 1000), - circular=self._plate.circular_wells, - padding=OFFSET, - mode=mode, - ) - self.well_view.setValue(view_data) - - # update the fov size in each mode widget - self._update_mode_wdgs_fov_size( - (mode.fov_width, mode.fov_height) if mode else (None, None) - ) - - self._update_mode_widgets(mode) - - # _________________________PRIVATE METHODS_________________________ # - - def _get_mode_widget( - self, - ) -> _CenterFOVWidget | RandomPointWidget | GridRowColumnWidget: - """Return the current mode.""" - for btn in self._mode_btn_group.buttons(): - if btn.isChecked(): - mode_name = cast(str, btn.objectName()) - return self.MODE[mode_name] - raise ValueError("No mode selected.") - - def _update_mode_widgets( - self, mode: Center | RandomPoints | GridRowsColumns | None - ) -> None: - """Update the mode widgets.""" - if isinstance(mode, RandomPoints): - self._set_random_value(mode) - else: - # update the randon widget values depending on the plate - with signals_blocked(self.random_wdg): - self.random_wdg.setValue(self._plate_to_random(self._plate)) - # update center or grid widgets - if isinstance(mode, Center): - self._set_center_value(mode) - elif isinstance(mode, GridRowsColumns): - self._set_grid_value(mode) - - def _update_mode_wdgs_fov_size( - self, fov_size: tuple[float | None, float | None] - ) -> None: - """Update the fov size in each mode widget.""" - self.center_wdg.fov_size = fov_size - self.random_wdg.fov_size = fov_size - self.grid_wdg.fov_size = fov_size - - def _on_points_warning(self, num_points: int) -> None: - self.random_wdg._number_of_points.setValue(num_points) - - def _on_radiobutton_toggled(self, radio_btn: QRadioButton) -> None: - """Update the scene when the tab is changed.""" - self.well_view.clear(_WellAreaGraphicsItem, _FOVGraphicsItem, QGraphicsLineItem) - self._enable_radio_buttons_wdgs() - self._update_scene() - - if radio_btn.isChecked(): - self.valueChanged.emit(self.value()) - - def _enable_radio_buttons_wdgs(self) -> None: - """Enable any radio button that is checked.""" - for btn in self._mode_btn_group.buttons(): - self.MODE[btn.objectName()].setEnabled(btn.isChecked()) - - def _on_value_changed(self, value: RandomPoints | GridRowsColumns) -> None: - self.well_view.clear(_WellAreaGraphicsItem, _FOVGraphicsItem, QGraphicsLineItem) - view_data = self.well_view.value().replace(mode=value) - self.well_view.setValue(view_data) - self.valueChanged.emit(self.value()) - - def _update_scene(self) -> None: - """Update the scene depending on the selected tab.""" - mode = self._get_mode_widget().value() - view_data = self.well_view.value().replace(mode=mode) - self.well_view.setValue(view_data) - - def _set_center_value(self, mode: Center) -> None: - """Set the center widget values.""" - self.center_radio_btn.setChecked(True) - self.center_wdg.setValue(mode) - - def _set_random_value(self, mode: RandomPoints) -> None: - """Set the random widget values.""" - with signals_blocked(self._mode_btn_group): - self.random_radio_btn.setChecked(True) - self._enable_radio_buttons_wdgs() - - self._check_for_warnings(mode) - # here blocking random widget signals to not generate a new random seed - with signals_blocked(self.random_wdg): - self.random_wdg.setValue(mode) - - def _set_grid_value(self, mode: GridRowsColumns) -> None: - """Set the grid widget values.""" - self.grid_radio_btn.setChecked(True) - self.grid_wdg.setValue(mode) - - def _check_for_warnings(self, mode: RandomPoints) -> None: - """RandomPoints width and height warning. - - If max width and height are grater than the plate well size, set them to the - plate well size. - """ - if self._plate is None: - return - - # well_size is in mm, convert to µm - well_size_x, well_size_y = self._plate.well_size - if mode.max_width > well_size_x * 1000 or mode.max_height > well_size_y * 1000: - mode = mode.replace( - max_width=well_size_x * 1000, - max_height=well_size_y * 1000, - ) - warnings.warn( - "RandomPoints `max_width` and/or `max_height` are larger than " - "the well size. They will be set to the well size.", - stacklevel=2, - ) - - def _plate_to_random(self, plate: WellPlate | None) -> RandomPoints: - """Convert a WellPlate object to a RandomPoints object.""" - well_size_x, well_size_y = plate.well_size if plate is not None else (0.0, 0.0) - return RandomPoints( - num_points=self.random_wdg._number_of_points.value(), - max_width=well_size_x * 1000 if plate else 0.0, # convert to µm - max_height=well_size_y * 1000 if plate else 0.0, # convert to µm - shape=ELLIPSE if (plate and plate.circular_wells) else RECT, - random_seed=self.random_wdg.random_seed, - fov_width=self.random_wdg.fov_size[0], - fov_height=self.random_wdg.fov_size[1], - ) diff --git a/src/pymmcore_widgets/hcs/_fov_widget/_util.py b/src/pymmcore_widgets/hcs/_fov_widget/_util.py deleted file mode 100644 index 4bb520e5b..000000000 --- a/src/pymmcore_widgets/hcs/_fov_widget/_util.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import math -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from useq import RelativePosition - - -def nearest_neighbor( - points: list[RelativePosition], top_x: float, top_y: float -) -> list[RelativePosition]: - """Find the nearest neighbor path for a list of points. - - The starting point is the closest to (top_x, top_y). - """ - first_point: RelativePosition | None = _top_left(points, top_x, top_y) - - if first_point is None: - return [] - - n = len(points) - visited: dict[int, bool] = {i: False for i in range(n)} - path_indices: list[int] = [points.index(first_point)] - visited[path_indices[0]] = True - - for _ in range(n - 1): - current_point = path_indices[-1] - nearest_point = None - min_distance = float("inf") - - for i in range(n): - if not visited[i]: - distance = _calculate_distance(points[current_point], points[i]) - if distance < min_distance: - min_distance = distance - nearest_point = i - - if nearest_point is not None: - path_indices.append(nearest_point) - visited[nearest_point] = True - - return [points[i] for i in path_indices] - - -def _calculate_distance(point1: RelativePosition, point2: RelativePosition) -> float: - """Calculate the Euclidean distance between two points.""" - return math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2) - - -def _top_left( - points: list[RelativePosition], top_x: float, top_y: float -) -> RelativePosition: - """Find the top left point in respect to (top_x, top_y).""" - return sorted( - points, - key=lambda coord: math.sqrt( - ((coord.x - top_x) ** 2) + ((coord.y - top_y) ** 2) - ), - )[0] From fc3f7f2d1e604df30e34bbe45cfccca30cd111ff Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:14:42 -0400 Subject: [PATCH 15/22] test --- .../points_plans/_points_plan_selector.py | 3 +- tests/useq_widgets/test_useq_points_plans.py | 83 +++++++++++-------- 2 files changed, 51 insertions(+), 35 deletions(-) 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 dcc51a21c..8e4bbbff5 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 @@ -125,7 +125,8 @@ def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: with signals_blocked(self.relative_pos_wdg): self.relative_pos_wdg.setValue(plan) self.center_radio_btn.setChecked(True) - raise ValueError(f"Invalid plan type: {type(plan)}") + else: # pragma: no cover + raise ValueError(f"Invalid plan type: {type(plan)}") # _________________________PRIVATE METHODS_________________________ # diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py index a859c3090..64901b155 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,25 @@ 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 + + wdg.setValue(GRID_ROWS_COLS) + assert wdg.value() == GRID_ROWS_COLS From cae38e893f51cd1c86cf0f81498164e9941c9efa Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:16:49 -0400 Subject: [PATCH 16/22] lint --- .../useq_widgets/points_plans/_points_plan_selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8e4bbbff5..7082e3002 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 @@ -89,7 +89,7 @@ def __init__(self, parent: QWidget | None = None) -> None: (self.random_radio_btn, self.random_points_wdg), (self.grid_radio_btn, self.grid_wdg), ): - wdg.setEnabled(btn.isChecked()) + wdg.setEnabled(btn.isChecked()) # type: ignore [attr-defined] layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(5) From a05ef2fe3c4d1ba1fbb8bc7aabc19228a6e222d5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:18:16 -0400 Subject: [PATCH 17/22] more test --- tests/useq_widgets/test_useq_points_plans.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py index 64901b155..39db700db 100644 --- a/tests/useq_widgets/test_useq_points_plans.py +++ b/tests/useq_widgets/test_useq_points_plans.py @@ -78,6 +78,11 @@ def test_point_plan_selector(qtbot: QtBot) -> None: 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 a3f7023689e618bdc22e173574e96af97440cee8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:26:24 -0400 Subject: [PATCH 18/22] style --- .../points_plans/_grid_row_column_widget.py | 2 +- .../points_plans/_points_plan_selector.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) 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 index 7082e3002..c6016e7be 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 @@ -1,9 +1,10 @@ from __future__ import annotations import useq -from qtpy.QtCore import Signal +from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QButtonGroup, + QGroupBox, QHBoxLayout, QLabel, QRadioButton, @@ -26,7 +27,10 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(QLabel("Single FOV")) + 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() @@ -56,6 +60,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.relative_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 @@ -90,11 +95,15 @@ def __init__(self, parent: QWidget | None = None) -> None: (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(wdg, 1) + layout.addWidget(grpbx, 1) main_layout.addLayout(layout) for i in range(0, 7, 2): From 60e288a8ceeae4509cd1157faa6d9133e5f4f2b8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:27:10 -0400 Subject: [PATCH 19/22] typing --- .../useq_widgets/points_plans/_points_plan_selector.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 c6016e7be..51b0a3c27 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 @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING, TypeAlias + import useq from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( @@ -18,8 +20,11 @@ from ._grid_row_column_widget import GridRowColumnWidget from ._random_points_widget import RandomPointWidget -# excluding useq.GridWidthHeight even though it's also a valid relative multi point plan -RelativePointPlan = useq.GridRowsColumns | useq.RandomPoints | useq.RelativePosition +if TYPE_CHECKING: + # excluding useq.GridWidthHeight even though it's also a relative multi point plan + RelativePointPlan: TypeAlias = ( + useq.GridRowsColumns | useq.RandomPoints | useq.RelativePosition + ) class RelativePositionWidget(QWidget): From 4937860c0216f5a4bd8b95b8fb31a0da1e9c404f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:29:43 -0400 Subject: [PATCH 20/22] rename --- .../points_plans/_points_plan_selector.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 51b0a3c27..2aa2e840e 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 @@ -62,7 +62,7 @@ def __init__(self, parent: QWidget | None = None) -> None: # WIDGET ---------------------- # plan widgets - self.relative_pos_wdg = RelativePositionWidget() + self.single_pos_wdg = RelativePositionWidget() self.random_points_wdg = RandomPointWidget() self.grid_wdg = GridRowColumnWidget() @@ -73,13 +73,13 @@ def __init__(self, parent: QWidget | None = None) -> None: # radio buttons selection - self.center_radio_btn = QRadioButton() - self.center_radio_btn.setChecked(True) + 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.center_radio_btn) + 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) @@ -95,7 +95,7 @@ def __init__(self, parent: QWidget | None = None) -> None: main_layout.setSpacing(10) main_layout.setContentsMargins(10, 10, 10, 10) for btn, wdg in ( - (self.center_radio_btn, self.relative_pos_wdg), + (self.single_radio_btn, self.relative_pos_wdg), (self.random_radio_btn, self.random_points_wdg), (self.grid_radio_btn, self.grid_wdg), ): @@ -138,7 +138,7 @@ def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: elif isinstance(plan, useq.RelativePosition): with signals_blocked(self.relative_pos_wdg): self.relative_pos_wdg.setValue(plan) - self.center_radio_btn.setChecked(True) + self.single_radio_btn.setChecked(True) else: # pragma: no cover raise ValueError(f"Invalid plan type: {type(plan)}") @@ -146,7 +146,7 @@ def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: def _on_radiobutton_toggled(self, btn: QRadioButton, checked: bool) -> None: btn2wdg: dict[QRadioButton, QWidget] = { - self.center_radio_btn: self.relative_pos_wdg, + self.single_radio_btn: self.relative_pos_wdg, self.random_radio_btn: self.random_points_wdg, self.grid_radio_btn: self.grid_wdg, } From 53ba0d82766cb97cda375eab367f7fc5c8f0cfd2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:30:15 -0400 Subject: [PATCH 21/22] lint --- .../useq_widgets/points_plans/_points_plan_selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2aa2e840e..43e796ae7 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 @@ -99,7 +99,7 @@ def __init__(self, parent: QWidget | None = None) -> None: (self.random_radio_btn, self.random_points_wdg), (self.grid_radio_btn, self.grid_wdg), ): - wdg.setEnabled(btn.isChecked()) # type: ignore [attr-defined] + wdg.setEnabled(btn.isChecked()) grpbx = QGroupBox() grpbx.setLayout(QVBoxLayout()) grpbx.layout().addWidget(wdg) From 2c33634bf30d20e177221746181884036e88020b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 7 Jul 2024 16:34:44 -0400 Subject: [PATCH 22/22] fix again you dummy --- .../points_plans/_points_plan_selector.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 43e796ae7..b0ecf4064 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 @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING import useq from qtpy.QtCore import Qt, Signal @@ -21,6 +21,8 @@ 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 @@ -69,7 +71,7 @@ def __init__(self, parent: QWidget | None = None) -> None: # this gets changed when the radio buttons are toggled self._active_plan_widget: ( RelativePositionWidget | RandomPointWidget | GridRowColumnWidget - ) = self.relative_pos_wdg + ) = self.single_pos_wdg # radio buttons selection @@ -95,11 +97,11 @@ def __init__(self, parent: QWidget | None = None) -> None: main_layout.setSpacing(10) main_layout.setContentsMargins(10, 10, 10, 10) for btn, wdg in ( - (self.single_radio_btn, self.relative_pos_wdg), + (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()) + wdg.setEnabled(btn.isChecked()) # type: ignore [attr-defined] grpbx = QGroupBox() grpbx.setLayout(QVBoxLayout()) grpbx.layout().addWidget(wdg) @@ -136,8 +138,8 @@ def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: self.grid_wdg.setValue(plan) self.grid_radio_btn.setChecked(True) elif isinstance(plan, useq.RelativePosition): - with signals_blocked(self.relative_pos_wdg): - self.relative_pos_wdg.setValue(plan) + 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)}") @@ -146,7 +148,7 @@ def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: def _on_radiobutton_toggled(self, btn: QRadioButton, checked: bool) -> None: btn2wdg: dict[QRadioButton, QWidget] = { - self.single_radio_btn: self.relative_pos_wdg, + self.single_radio_btn: self.single_pos_wdg, self.random_radio_btn: self.random_points_wdg, self.grid_radio_btn: self.grid_wdg, }