Skip to content

Commit

Permalink
feat: add new calibration wdg (#360)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Sep 18, 2024
1 parent fb1e183 commit 3dab0c4
Show file tree
Hide file tree
Showing 13 changed files with 428 additions and 78 deletions.
32 changes: 32 additions & 0 deletions examples/hcs_wizard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from contextlib import suppress

import useq
from pymmcore_plus import CMMCorePlus
from qtpy.QtWidgets import QApplication

from pymmcore_widgets import StageWidget

with suppress(ImportError):
from rich import print

from pymmcore_widgets.hcs import HCSWizard

app = QApplication([])
mmc = CMMCorePlus.instance()
mmc.loadSystemConfiguration()
w = HCSWizard()
w.show()
w.accepted.connect(lambda: print(w.value()))
s = StageWidget("XY", mmcore=mmc)
s.show()


plan = useq.WellPlatePlan(
plate=useq.WellPlate.from_str("96-well"),
a1_center_xy=(1000, 1500),
rotation=0.3,
selected_wells=slice(0, 8, 2),
)
w.setValue(plan)

app.exec()
2 changes: 2 additions & 0 deletions src/pymmcore_widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from ._shutter_widget import ShuttersWidget
from ._snap_button_widget import SnapButton
from ._stage_widget import StageWidget
from .hcs import HCSWizard
from .hcwizard import ConfigWizard
from .mda import MDAWidget
from .useq_widgets import (
Expand Down Expand Up @@ -78,6 +79,7 @@ def __getattr__(name: str) -> object:
"ExposureWidget",
"GridPlanWidget",
"GroupPresetTableWidget",
"HCSWizard",
"ImagePreview",
"InstallWidget",
"LiveButton",
Expand Down
6 changes: 5 additions & 1 deletion src/pymmcore_widgets/hcs/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""Calibration widget."""
"""HCS Wizard."""

from ._hcs_wizard import HCSWizard

__all__ = ["HCSWizard"]
228 changes: 228 additions & 0 deletions src/pymmcore_widgets/hcs/_hcs_wizard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
from __future__ import annotations

import warnings
from contextlib import suppress
from pathlib import Path

import useq
from pymmcore_plus import CMMCorePlus
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QFileDialog, QVBoxLayout, QWidget, QWizard, QWizardPage
from useq import WellPlatePlan

from pymmcore_widgets.useq_widgets import PointsPlanWidget, WellPlateWidget

from ._plate_calibration_widget import PlateCalibrationWidget


class HCSWizard(QWizard):
"""A wizard to setup an High Content Screening (HCS) experiment.
This widget can be used to select a plate, calibrate it, and then select the number
of images (and their arrangement) to acquire per well. The output is a
[useq.WellPlatePlan][] object, which can be retrieved with the `value()` method.
Parameters
----------
parent : QWidget | None
The parent widget. By default, None.
mmcore : CMMCorePlus | None
The CMMCorePlus instance. By default, None.
"""

def __init__(
self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None
) -> None:
super().__init__(parent)
self._mmc = mmcore or CMMCorePlus.instance()
self._calibrated: bool = False

self.setWizardStyle(QWizard.WizardStyle.ModernStyle)
self.setWindowTitle("HCS Wizard")

# WIZARD PAGES ----------------------

self.plate_page = _PlatePage(self)
self.calibration_page = _PlateCalibrationPage(self._mmc, self)
self.points_plan_page = _PointsPlanPage(self._mmc, self)

self.addPage(self.plate_page)
self.addPage(self.calibration_page)
self.addPage(self.points_plan_page)

# SAVE/LOAD BUTTONS ----------------------

# add custom button to save
self.setOption(QWizard.WizardOption.HaveCustomButton1, True)
if save_btn := self.button(QWizard.WizardButton.CustomButton1):
save_btn.setText("Save")
save_btn.clicked.connect(self.save)
save_btn.setEnabled(False)
# add custom button to load
self.setOption(QWizard.WizardOption.HaveCustomButton2, True)
if load_btn := self.button(QWizard.WizardButton.CustomButton2):
load_btn.setText("Load")
load_btn.clicked.connect(self.load)

# CONNECTIONS ---------------------------

self.plate_page.widget.valueChanged.connect(self._on_plate_changed)
self._on_plate_changed(self.plate_page.widget.value())
self.calibration_page.widget.calibrationChanged.connect(
self._on_calibration_changed
)

def sizeHint(self) -> QSize:
return QSize(880, 690)

def value(self) -> useq.WellPlatePlan | None:
"""Return the current well plate plan, or None if the plan is uncalibrated."""
calib_plan = self.calibration_page.widget.value()
if not self._calibrated or not calib_plan: # pragma: no cover
return None

plate_plan = self.plate_page.widget.value()
if plate_plan.plate != calib_plan.plate: # pragma: no cover
warnings.warn("Plate Plan and Calibration Plan do not match.", stacklevel=2)
return None

return useq.WellPlatePlan(
plate=plate_plan.plate,
selected_wells=plate_plan.selected_wells,
rotation=calib_plan.rotation,
a1_center_xy=calib_plan.a1_center_xy,
well_points_plan=self.points_plan_page.widget.value(),
)

def setValue(self, value: useq.WellPlatePlan) -> None:
"""Set the state of the wizard to a WellPlatePlan."""
self.plate_page.widget.setValue(value)
self.calibration_page.widget.setValue(value)
# update the points plan fov size if it's not set
point_plan = value.well_points_plan
if point_plan.fov_width is None or point_plan.fov_height is None:
point_plan.fov_width, point_plan.fov_height = (
self.points_plan_page._get_fov_size()
)
self.points_plan_page.widget.setValue(point_plan)

def save(self, path: str | None = None) -> None:
"""Save the current well plate plan to disk."""
if not isinstance(path, str):
path, _ = QFileDialog.getSaveFileName(
self, "Save Well Plate Plan", "", "JSON (*.json)"
)
elif not path.endswith(".json"): # pragma: no cover
raise ValueError("Path must end with '.json'")
if path and (value := self.value()):
txt = value.model_dump_json(exclude_unset=True, indent=2)
Path(path).write_text(txt)

def load(self, path: str | None = None) -> None:
"""Load a well plate plan from disk."""
if not isinstance(path, str):
path, _ = QFileDialog.getOpenFileName(
self, "Load Well Plate Plan", "", "JSON (*.json)"
)
if path:
self.setValue(WellPlatePlan.from_file(path))

def _on_plate_changed(self, plate_plan: useq.WellPlatePlan) -> None:
"""Synchronize the points plan with the well size/shape."""
# update the calibration widget with the new plate if it's different
current_calib_plan = self.calibration_page.widget.value()
if current_calib_plan is None or current_calib_plan.plate != plate_plan.plate:
self.calibration_page.widget.setValue(plate_plan.plate)

pp_widget = self.points_plan_page.widget

# set the well size on the points plan widget to the current plate well size
well_width, well_height = plate_plan.plate.well_size
pp_widget.setWellSize(well_width, well_height)

# additionally, restrict the max width and height of the random points widget
# to the plate size minus the fov size.
fovw = pp_widget._selector.fov_w.value()
fovh = pp_widget._selector.fov_h.value()

# if the random points shape is a rectangle, but the wells are circular,
# reduce the max width and height by 1.4 to keep the points inside the wells
random_wdg = pp_widget.random_points_wdg
if random_wdg.shape.currentText() == useq.Shape.RECTANGLE.value:
if plate_plan.plate.circular_wells:
well_width /= 1.4
well_height /= 1.4

random_wdg.max_width.setMaximum(well_width * 1000)
random_wdg.max_width.setValue(well_width * 1000 - fovw / 1.4)
random_wdg.max_height.setMaximum(well_height * 1000)
random_wdg.max_height.setValue(well_height * 1000 - fovh / 1.4)

def _on_calibration_changed(self, calibrated: bool) -> None:
self._calibrated = calibrated
self.button(QWizard.WizardButton.CustomButton1).setEnabled(calibrated)


# ---------------------------------- PAGES ---------------------------------------


class _PlatePage(QWizardPage):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)

self.setTitle("Plate and Well Selection")

self.widget = WellPlateWidget()
self.widget.setShowRotation(False)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.widget)


class _PlateCalibrationPage(QWizardPage):
def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setTitle("Plate Calibration")

self._is_complete = False
self.widget = PlateCalibrationWidget(mmcore=mmcore)
self.widget.calibrationChanged.connect(self._on_calibration_changed)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.widget)

def isComplete(self) -> bool:
return self._is_complete

def _on_calibration_changed(self, calibrated: bool) -> None:
self._is_complete = calibrated
self.completeChanged.emit()


class _PointsPlanPage(QWizardPage):
def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._mmc = mmcore
self.setTitle("Field of View Selection")

self.widget = PointsPlanWidget()
self.widget._selector.fov_widgets.setEnabled(False)

layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.widget)

self._mmc.events.pixelSizeChanged.connect(self._on_px_size_changed)
self._mmc.events.systemConfigurationLoaded.connect(self._on_px_size_changed)
self._on_px_size_changed()

def _on_px_size_changed(self) -> None:
val = self.widget.value()
val.fov_width, val.fov_height = self._get_fov_size()
self.widget.setValue(val)

def _get_fov_size(self) -> tuple[float, float] | tuple[None, None]:
with suppress(RuntimeError):
if self._mmc.getCameraDevice() and (px := self._mmc.getPixelSizeUm()):
return self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px
return (None, None)
Loading

0 comments on commit 3dab0c4

Please sign in to comment.