Skip to content

Commit 3dab0c4

Browse files
fdrgsptlambert03pre-commit-ci[bot]
authored
feat: add new calibration wdg (#360)
* PlateDatabaseWidget * delete some stuff * add example * running * wip * lots of cleanup * style(pre-commit.ci): auto fixes [...] * more tweaks * feat: wip PlateTestWidget * fix: wip * feat: click wip * feat: working stage movements + example * feat: preset movements * fix: _xy_label * fix: SELECTED_MOVE_TO_COLOR * fix: _MoveToItem size and color * fix: refactor * fix: adjust position if rotation * fix: invert rotated_y sign * fix: rename + fix _PresetPositionItem size * fix: update example with rotation * fix: remove print * test: wip * fix: use qtpy * test: update * test: update * fix: remove print * test: update * test: update * test: fix * fix: move test into hcs folder * fix: rename * fix: clear * fix: setPlate * fix: update layout * fix: margins * fix: spacing * feat: add well name to _xy_label * fix: spacing * unifying features * feat: calibration tab + _test_well_btn * fix: use _current_calibration_widget * fix: rename + random test points * fix: wip * changes * fix: update setPlate method * fix: refactor * fix: remove unused * fix: _add_preset_positions_items * fix: _get_random_edge_point * test: update tests * test: update tests * style(pre-commit.ci): auto fixes [...] * feat: add new calibration wdg * fix: rename methods * add _calibrated flag * rename to value and setValue * fix _on_plate_changed] * todo * add label * change click signal * minor * fix: add QGroupBox * fix: sizes + rotation * fix: accept * fix: HoverEllipse tooltip * fix: accept * fix: example * fix: example * fix: deselect wells with no explicit selection * undo last change * more changes in behavior * some docs * remove WellPlateWidgetNoRotation * supress runtime error * updates * fix: clear selection when plate changes * fix: emit signal when selecting a different plate * fix: update fov size when calling setValue * fix: test_well_btn no focus policy * fix: fix bug when setting selected_wells * test: fix * fix: systemConfigurationLoaded * test: add failing test * tests: fix tests * pragma --------- Co-authored-by: Talley Lambert <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent fb1e183 commit 3dab0c4

File tree

13 files changed

+428
-78
lines changed

13 files changed

+428
-78
lines changed

examples/hcs_wizard.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from contextlib import suppress
2+
3+
import useq
4+
from pymmcore_plus import CMMCorePlus
5+
from qtpy.QtWidgets import QApplication
6+
7+
from pymmcore_widgets import StageWidget
8+
9+
with suppress(ImportError):
10+
from rich import print
11+
12+
from pymmcore_widgets.hcs import HCSWizard
13+
14+
app = QApplication([])
15+
mmc = CMMCorePlus.instance()
16+
mmc.loadSystemConfiguration()
17+
w = HCSWizard()
18+
w.show()
19+
w.accepted.connect(lambda: print(w.value()))
20+
s = StageWidget("XY", mmcore=mmc)
21+
s.show()
22+
23+
24+
plan = useq.WellPlatePlan(
25+
plate=useq.WellPlate.from_str("96-well"),
26+
a1_center_xy=(1000, 1500),
27+
rotation=0.3,
28+
selected_wells=slice(0, 8, 2),
29+
)
30+
w.setValue(plan)
31+
32+
app.exec()

src/pymmcore_widgets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from ._shutter_widget import ShuttersWidget
2929
from ._snap_button_widget import SnapButton
3030
from ._stage_widget import StageWidget
31+
from .hcs import HCSWizard
3132
from .hcwizard import ConfigWizard
3233
from .mda import MDAWidget
3334
from .useq_widgets import (
@@ -78,6 +79,7 @@ def __getattr__(name: str) -> object:
7879
"ExposureWidget",
7980
"GridPlanWidget",
8081
"GroupPresetTableWidget",
82+
"HCSWizard",
8183
"ImagePreview",
8284
"InstallWidget",
8385
"LiveButton",
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
"""Calibration widget."""
1+
"""HCS Wizard."""
2+
3+
from ._hcs_wizard import HCSWizard
4+
5+
__all__ = ["HCSWizard"]
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
from __future__ import annotations
2+
3+
import warnings
4+
from contextlib import suppress
5+
from pathlib import Path
6+
7+
import useq
8+
from pymmcore_plus import CMMCorePlus
9+
from qtpy.QtCore import QSize
10+
from qtpy.QtWidgets import QFileDialog, QVBoxLayout, QWidget, QWizard, QWizardPage
11+
from useq import WellPlatePlan
12+
13+
from pymmcore_widgets.useq_widgets import PointsPlanWidget, WellPlateWidget
14+
15+
from ._plate_calibration_widget import PlateCalibrationWidget
16+
17+
18+
class HCSWizard(QWizard):
19+
"""A wizard to setup an High Content Screening (HCS) experiment.
20+
21+
This widget can be used to select a plate, calibrate it, and then select the number
22+
of images (and their arrangement) to acquire per well. The output is a
23+
[useq.WellPlatePlan][] object, which can be retrieved with the `value()` method.
24+
25+
Parameters
26+
----------
27+
parent : QWidget | None
28+
The parent widget. By default, None.
29+
mmcore : CMMCorePlus | None
30+
The CMMCorePlus instance. By default, None.
31+
"""
32+
33+
def __init__(
34+
self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None
35+
) -> None:
36+
super().__init__(parent)
37+
self._mmc = mmcore or CMMCorePlus.instance()
38+
self._calibrated: bool = False
39+
40+
self.setWizardStyle(QWizard.WizardStyle.ModernStyle)
41+
self.setWindowTitle("HCS Wizard")
42+
43+
# WIZARD PAGES ----------------------
44+
45+
self.plate_page = _PlatePage(self)
46+
self.calibration_page = _PlateCalibrationPage(self._mmc, self)
47+
self.points_plan_page = _PointsPlanPage(self._mmc, self)
48+
49+
self.addPage(self.plate_page)
50+
self.addPage(self.calibration_page)
51+
self.addPage(self.points_plan_page)
52+
53+
# SAVE/LOAD BUTTONS ----------------------
54+
55+
# add custom button to save
56+
self.setOption(QWizard.WizardOption.HaveCustomButton1, True)
57+
if save_btn := self.button(QWizard.WizardButton.CustomButton1):
58+
save_btn.setText("Save")
59+
save_btn.clicked.connect(self.save)
60+
save_btn.setEnabled(False)
61+
# add custom button to load
62+
self.setOption(QWizard.WizardOption.HaveCustomButton2, True)
63+
if load_btn := self.button(QWizard.WizardButton.CustomButton2):
64+
load_btn.setText("Load")
65+
load_btn.clicked.connect(self.load)
66+
67+
# CONNECTIONS ---------------------------
68+
69+
self.plate_page.widget.valueChanged.connect(self._on_plate_changed)
70+
self._on_plate_changed(self.plate_page.widget.value())
71+
self.calibration_page.widget.calibrationChanged.connect(
72+
self._on_calibration_changed
73+
)
74+
75+
def sizeHint(self) -> QSize:
76+
return QSize(880, 690)
77+
78+
def value(self) -> useq.WellPlatePlan | None:
79+
"""Return the current well plate plan, or None if the plan is uncalibrated."""
80+
calib_plan = self.calibration_page.widget.value()
81+
if not self._calibrated or not calib_plan: # pragma: no cover
82+
return None
83+
84+
plate_plan = self.plate_page.widget.value()
85+
if plate_plan.plate != calib_plan.plate: # pragma: no cover
86+
warnings.warn("Plate Plan and Calibration Plan do not match.", stacklevel=2)
87+
return None
88+
89+
return useq.WellPlatePlan(
90+
plate=plate_plan.plate,
91+
selected_wells=plate_plan.selected_wells,
92+
rotation=calib_plan.rotation,
93+
a1_center_xy=calib_plan.a1_center_xy,
94+
well_points_plan=self.points_plan_page.widget.value(),
95+
)
96+
97+
def setValue(self, value: useq.WellPlatePlan) -> None:
98+
"""Set the state of the wizard to a WellPlatePlan."""
99+
self.plate_page.widget.setValue(value)
100+
self.calibration_page.widget.setValue(value)
101+
# update the points plan fov size if it's not set
102+
point_plan = value.well_points_plan
103+
if point_plan.fov_width is None or point_plan.fov_height is None:
104+
point_plan.fov_width, point_plan.fov_height = (
105+
self.points_plan_page._get_fov_size()
106+
)
107+
self.points_plan_page.widget.setValue(point_plan)
108+
109+
def save(self, path: str | None = None) -> None:
110+
"""Save the current well plate plan to disk."""
111+
if not isinstance(path, str):
112+
path, _ = QFileDialog.getSaveFileName(
113+
self, "Save Well Plate Plan", "", "JSON (*.json)"
114+
)
115+
elif not path.endswith(".json"): # pragma: no cover
116+
raise ValueError("Path must end with '.json'")
117+
if path and (value := self.value()):
118+
txt = value.model_dump_json(exclude_unset=True, indent=2)
119+
Path(path).write_text(txt)
120+
121+
def load(self, path: str | None = None) -> None:
122+
"""Load a well plate plan from disk."""
123+
if not isinstance(path, str):
124+
path, _ = QFileDialog.getOpenFileName(
125+
self, "Load Well Plate Plan", "", "JSON (*.json)"
126+
)
127+
if path:
128+
self.setValue(WellPlatePlan.from_file(path))
129+
130+
def _on_plate_changed(self, plate_plan: useq.WellPlatePlan) -> None:
131+
"""Synchronize the points plan with the well size/shape."""
132+
# update the calibration widget with the new plate if it's different
133+
current_calib_plan = self.calibration_page.widget.value()
134+
if current_calib_plan is None or current_calib_plan.plate != plate_plan.plate:
135+
self.calibration_page.widget.setValue(plate_plan.plate)
136+
137+
pp_widget = self.points_plan_page.widget
138+
139+
# set the well size on the points plan widget to the current plate well size
140+
well_width, well_height = plate_plan.plate.well_size
141+
pp_widget.setWellSize(well_width, well_height)
142+
143+
# additionally, restrict the max width and height of the random points widget
144+
# to the plate size minus the fov size.
145+
fovw = pp_widget._selector.fov_w.value()
146+
fovh = pp_widget._selector.fov_h.value()
147+
148+
# if the random points shape is a rectangle, but the wells are circular,
149+
# reduce the max width and height by 1.4 to keep the points inside the wells
150+
random_wdg = pp_widget.random_points_wdg
151+
if random_wdg.shape.currentText() == useq.Shape.RECTANGLE.value:
152+
if plate_plan.plate.circular_wells:
153+
well_width /= 1.4
154+
well_height /= 1.4
155+
156+
random_wdg.max_width.setMaximum(well_width * 1000)
157+
random_wdg.max_width.setValue(well_width * 1000 - fovw / 1.4)
158+
random_wdg.max_height.setMaximum(well_height * 1000)
159+
random_wdg.max_height.setValue(well_height * 1000 - fovh / 1.4)
160+
161+
def _on_calibration_changed(self, calibrated: bool) -> None:
162+
self._calibrated = calibrated
163+
self.button(QWizard.WizardButton.CustomButton1).setEnabled(calibrated)
164+
165+
166+
# ---------------------------------- PAGES ---------------------------------------
167+
168+
169+
class _PlatePage(QWizardPage):
170+
def __init__(self, parent: QWidget | None = None) -> None:
171+
super().__init__(parent)
172+
173+
self.setTitle("Plate and Well Selection")
174+
175+
self.widget = WellPlateWidget()
176+
self.widget.setShowRotation(False)
177+
layout = QVBoxLayout(self)
178+
layout.setContentsMargins(0, 0, 0, 0)
179+
layout.addWidget(self.widget)
180+
181+
182+
class _PlateCalibrationPage(QWizardPage):
183+
def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None:
184+
super().__init__(parent)
185+
self.setTitle("Plate Calibration")
186+
187+
self._is_complete = False
188+
self.widget = PlateCalibrationWidget(mmcore=mmcore)
189+
self.widget.calibrationChanged.connect(self._on_calibration_changed)
190+
layout = QVBoxLayout(self)
191+
layout.setContentsMargins(0, 0, 0, 0)
192+
layout.addWidget(self.widget)
193+
194+
def isComplete(self) -> bool:
195+
return self._is_complete
196+
197+
def _on_calibration_changed(self, calibrated: bool) -> None:
198+
self._is_complete = calibrated
199+
self.completeChanged.emit()
200+
201+
202+
class _PointsPlanPage(QWizardPage):
203+
def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None:
204+
super().__init__(parent)
205+
self._mmc = mmcore
206+
self.setTitle("Field of View Selection")
207+
208+
self.widget = PointsPlanWidget()
209+
self.widget._selector.fov_widgets.setEnabled(False)
210+
211+
layout = QVBoxLayout(self)
212+
layout.setContentsMargins(0, 0, 0, 0)
213+
layout.addWidget(self.widget)
214+
215+
self._mmc.events.pixelSizeChanged.connect(self._on_px_size_changed)
216+
self._mmc.events.systemConfigurationLoaded.connect(self._on_px_size_changed)
217+
self._on_px_size_changed()
218+
219+
def _on_px_size_changed(self) -> None:
220+
val = self.widget.value()
221+
val.fov_width, val.fov_height = self._get_fov_size()
222+
self.widget.setValue(val)
223+
224+
def _get_fov_size(self) -> tuple[float, float] | tuple[None, None]:
225+
with suppress(RuntimeError):
226+
if self._mmc.getCameraDevice() and (px := self._mmc.getPixelSizeUm()):
227+
return self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px
228+
return (None, None)

0 commit comments

Comments
 (0)