Skip to content
109 changes: 101 additions & 8 deletions src/pymmcore_widgets/hcs/_plate_calibration_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ def setValue(
self._current_plate = plate
self._plate_view.drawPlate(plate)

# Set minimum wells required based on plate size
if plate:
self._min_wells_required = min(3, plate.rows * plate.columns)
else: # pragma: no cover
self._min_wells_required = 3

# clear existing calibration widgets
while self._calibration_widgets:
wdg = self._calibration_widgets.popitem()[1]
Expand Down Expand Up @@ -267,18 +273,34 @@ def _origin_spacing_rotation(
The center-to-center distance in mm (pitch) between wells in the x and y
directions.
rotation : float
a1_center_xy : tuple[float, float]
The rotation angle in degrees (anti-clockwise) of the plate.
"""
if not len(self._calibrated_wells) >= self._min_wells_required:
# not enough wells calibrated
if len(self._calibrated_wells) < self._min_wells_required:
return None

if self._current_plate is None:
return None

n = len(self._calibrated_wells)
if n == 1:
# Single well: use center, plate spacing, no rotation
center = next(iter(self._calibrated_wells.values()))
return center, self._current_plate.well_spacing, 0.0

# Check if all calibrated wells are collinear (single row or column)
indices = list(self._calibrated_wells.keys())
rows_set = {r for r, _ in indices}
cols_set = {c for _, c in indices}

if len(rows_set) == 1 or len(cols_set) == 1:
# Collinear: 2+ wells all in one row or one column
return self._fit_collinear(len(rows_set) == 1)

# Non-collinear: 3+ wells, use full affine transformation
try:
params = well_coords_affine(self._calibrated_wells)
except ValueError:
# collinear points
return None
return None # collinear points (e.g. diagonal)

a, b, ty, c, d, tx = params
unit_y = np.hypot(a, c) / 1000 # convert to mm
Expand All @@ -287,6 +309,76 @@ def _origin_spacing_rotation(

return (round(tx, 4), round(ty, 4)), (unit_x, unit_y), rotation

def _fit_collinear(
self, is_single_row: bool
) -> tuple[tuple[float, float], tuple[float, float], float] | None:
"""Fit calibration parameters when all calibrated wells are collinear.

Uses a reduced linear model (4 parameters) for the varying axis, and derives
the missing axis parameters from the plate spec assuming a rigid plate
(orthogonal axes).

Parameters
----------
is_single_row : bool
True if all calibrated wells share the same row (varying column),
False if they share the same column (varying row).
"""
plate = self._current_plate
if plate is None: # pragma: no cover
return None

indices = list(self._calibrated_wells.keys())
centers = np.array(list(self._calibrated_wells.values()))
xs, ys = centers[:, 0], centers[:, 1]

# Build the varying-axis indices and identify the fixed-axis index
if is_single_row: # varying column, fixed row
varying = np.array([c for _, c in indices], dtype=float)
fixed_idx = indices[0][0]
other_spacing = plate.well_spacing[1]
else: # varying row, fixed column
varying = np.array([-r for r, _ in indices], dtype=float)
fixed_idx = indices[0][1]
other_spacing = plate.well_spacing[0]

# Fit linear model: x = slope_x * v + tx, y = slope_y * v + ty
design = np.column_stack([varying, np.ones(len(varying))])
(slope_x, tx), *_ = np.linalg.lstsq(design, xs, rcond=None)
(slope_y, ty), *_ = np.linalg.lstsq(design, ys, rcond=None)

# Measured spacing along the varying axis
measured_spacing = np.hypot(slope_y, slope_x) / 1000 # mm
if measured_spacing < 1e-6:
return None # wells are coincident

# Compute rotation and assign spacings per axis
if is_single_row:
# Column axis direction is (slope_x, slope_y) in world (x, y)
rot_rad = np.arctan2(slope_y, slope_x)
unit_x, unit_y = measured_spacing, max(other_spacing, 0.0)

# Extrapolate A1 center to row=0
if fixed_idx != 0:
other_um = other_spacing * 1000
ty += other_um * np.cos(rot_rad) * fixed_idx
tx += other_um * np.sin(rot_rad) * fixed_idx
else:
# Row axis direction is (slope_x, slope_y) in world (x, y)
rot_rad = np.arctan2(slope_x, slope_y)
unit_x, unit_y = max(other_spacing, 0.0), measured_spacing

# Extrapolate A1 center to col=0
if fixed_idx != 0:
theta_col = rot_rad + np.pi / 2
other_um = other_spacing * 1000
ty -= other_um * np.cos(theta_col) * fixed_idx
tx -= other_um * np.sin(theta_col) * fixed_idx

rotation = round(float(np.rad2deg(rot_rad)), 2)

return (round(float(tx), 4), round(float(ty), 4)), (unit_x, unit_y), rotation

def _get_or_create_well_calibration_widget(
self, idx: tuple[int, int]
) -> WellCalibrationWidget:
Expand Down Expand Up @@ -360,12 +452,13 @@ def _update_info(self) -> None:
txt = "<strong>Plate calibrated.</strong>"
ico = QIconifyIcon(CALIBRATED_ICON, color=GREEN)
if self._current_plate is not None:
spacing_diff = abs(spacing[0] - self._current_plate.well_spacing[0])
plate_sp = self._current_plate.well_spacing[0]
spacing_diff = abs(spacing[0] - plate_sp)
# if spacing is more than 5% different from the plate spacing...
if spacing_diff > 0.05 * self._current_plate.well_spacing[0]:
if plate_sp > 0 and spacing_diff > 0.05 * plate_sp:
txt += (
"<font color='red'> Expected well spacing of "
f"{self._current_plate.well_spacing[0]:.2f} mm, "
f"{plate_sp:.2f} mm, "
f"calibrated at {spacing[0]:.2f}</font>"
)
ico = style.standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning)
Expand Down
4 changes: 3 additions & 1 deletion tests/hcs/test_well_calibration_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ def test_well_calibration_widget_modes(
combo = wdg._calibration_mode_wdg
modes = [Mode(*combo.itemData(i, COMBO_ROLE)) for i in range(combo.count())]
# make sure the modes are correct
assert modes == MODES[circular]
expected = [(mode.text, mode.points) for mode in MODES[circular]]
actual = [(m[0], m[1]) for m in modes]
assert actual == expected
# make sure that the correct number of rows are displayed when the mode is changed
for idx, mode in enumerate(modes):
# set the mode
Expand Down
209 changes: 209 additions & 0 deletions tests/hcs/test_well_plate_calibration_widget.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import math

import numpy as np
import pytest
import useq
from pymmcore_plus import CMMCorePlus

Expand Down Expand Up @@ -207,3 +208,211 @@ def test_plate_calibration_test_positions(global_mmcore: CMMCorePlus, qtbot) ->
data.append((hover_item_data.x, hover_item_data.y, hover_item_data.name))

assert data == expected_data


@pytest.mark.parametrize(
("rows", "cols", "calibrated_wells", "min_required", "expected"),
[
# 1x1 plate: single well calibration
(1, 1, {(0, 0): (100.0, 200.0)}, 1, ((100.0, 200.0), (10, 10), 0.0)),
# 1x2 plate: single row, two wells
(
1,
2,
{(0, 0): (100.0, 200.0), (0, 1): (10100.0, 200.0)},
2,
((100.0, 200.0), (10.0, 10), 0.0),
),
# 2x1 plate: single column, two wells
(
2,
1,
{(0, 0): (100.0, 200.0), (1, 0): (100.0, -9800.0)},
2,
((100.0, 200.0), (10, 10.0), 0.0),
),
],
ids=["1x1", "1x2-row", "2x1-col"],
)
def test_small_plate_calibration(
qtbot, rows, cols, calibrated_wells, min_required, expected
) -> None:
"""Test calibration for plates with fewer than 3 wells."""
wdg = PlateCalibrationWidget()
qtbot.addWidget(wdg)

plate = useq.WellPlate(
rows=rows, columns=cols, well_size=(5, 5), well_spacing=(10, 10)
)
wdg.setValue(plate)
assert wdg._min_wells_required == min_required

wdg._calibrated_wells = calibrated_wells
result = wdg._origin_spacing_rotation()
assert result is not None
origin, spacing, rotation = result
exp_origin, exp_spacing, exp_rotation = expected
assert math.isclose(origin[0], exp_origin[0], abs_tol=1e-3)
assert math.isclose(origin[1], exp_origin[1], abs_tol=1e-3)
assert math.isclose(spacing[0], exp_spacing[0], rel_tol=1e-6)
assert math.isclose(spacing[1], exp_spacing[1], rel_tol=1e-6)
assert rotation == exp_rotation


@pytest.mark.parametrize(
("rows", "cols", "calibrated_wells", "exp_measured_idx"),
[
# 1x3 plate: single row
(
1,
3,
{(0, 0): (0.0, 0.0), (0, 1): (3000.0, 0.0), (0, 2): (6000.0, 0.0)},
0, # spacing[0] is measured from data
),
# 3x1 plate: single column (row increases -> y decreases)
(
3,
1,
{(0, 0): (0.0, 0.0), (1, 0): (0.0, -3000.0), (2, 0): (0.0, -6000.0)},
1, # spacing[1] is measured from data
),
],
ids=["single-row", "single-column"],
)
def test_collinear_plate_calibration(
qtbot, rows, cols, calibrated_wells, exp_measured_idx
) -> None:
"""Test calibration for collinear plates (single row or column)."""
wdg = PlateCalibrationWidget()
qtbot.addWidget(wdg)

plate = useq.WellPlate(
rows=rows, columns=cols, well_size=(2, 2), well_spacing=(3, 3)
)
wdg.setValue(plate)

wdg._calibrated_wells = calibrated_wells
result = wdg._origin_spacing_rotation()
assert result is not None
origin, spacing, rotation = result
assert origin == (0.0, 0.0)
assert math.isclose(spacing[exp_measured_idx], 3.0, rel_tol=1e-6)
assert spacing[1 - exp_measured_idx] == 3 # from plate spec
assert rotation == 0.0


def test_collinear_plate_with_rotation(qtbot) -> None:
"""Test collinear calibration with a rotated plate."""
wdg = PlateCalibrationWidget()
qtbot.addWidget(wdg)

plate = useq.WellPlate(rows=1, columns=3, well_size=(2, 2), well_spacing=(3, 3))
wdg.setValue(plate)

# Calibrate 3 wells in a row with ~5 degree CCW rotation
# Column axis in world (x, y) = (sp*cos(θ), sp*sin(θ))
angle_rad = np.deg2rad(5)
sp = 3000 # 3mm spacing in µm
dx = sp * np.cos(angle_rad)
dy = sp * np.sin(angle_rad)
wdg._calibrated_wells = {
(0, 0): (0.0, 0.0),
(0, 1): (dx, dy),
(0, 2): (2 * dx, 2 * dy),
}
result = wdg._origin_spacing_rotation()
assert result is not None
origin, spacing, rotation = result
assert math.isclose(origin[0], 0.0, abs_tol=1e-3)
assert math.isclose(origin[1], 0.0, abs_tol=1e-3)
assert math.isclose(spacing[0], 3.0, rel_tol=1e-4)
assert math.isclose(rotation, 5.0, abs_tol=0.1)


def test_diagonal_collinear_returns_none(qtbot) -> None:
"""Diagonal wells spanning multiple rows and columns trigger the affine fallback.

When calibrated wells are not all in the same row or column, the code tries
a full affine fit. Geometrically collinear (diagonal) points make the
system rank-deficient, so ``_origin_spacing_rotation`` must return None.
"""
wdg = PlateCalibrationWidget()
qtbot.addWidget(wdg)

plate = useq.WellPlate(rows=4, columns=4, well_size=(5, 5), well_spacing=(9, 9))
wdg.setValue(plate)

# Three diagonal wells: different rows *and* different columns, but collinear
wdg._calibrated_wells = {
(0, 0): (0.0, 0.0),
(1, 1): (100.0, 100.0),
(2, 2): (200.0, 200.0),
}
assert wdg._origin_spacing_rotation() is None


def test_coincident_wells_collinear_returns_none(qtbot) -> None:
"""_fit_collinear returns None when calibrated wells are coincident (spacing ~0)."""
wdg = PlateCalibrationWidget()
qtbot.addWidget(wdg)

plate = useq.WellPlate(rows=1, columns=3, well_size=(2, 2), well_spacing=(3, 3))
wdg.setValue(plate)

# Two wells at the same world position → measured spacing will be ~0
wdg._calibrated_wells = {
(0, 0): (0.0, 0.0),
(0, 1): (0.0, 0.0),
(0, 2): (0.0, 0.0),
}
assert wdg._origin_spacing_rotation() is None


@pytest.mark.parametrize(
("rows", "cols", "calibrated_wells", "is_single_row", "expected_origin"),
[
# Row wells not starting at row 0: all in row 2 of a 4x3 plate
(
4,
3,
{(2, 0): (0.0, 0.0), (2, 1): (3000.0, 0.0), (2, 2): (6000.0, 0.0)},
True,
(0.0, 6000.0), # extrapolated 2 rows "up" (y += 3000 * 2)
),
# Column wells not starting at col 0: all in col 1 of a 3x4 plate
(
3,
4,
{(0, 1): (0.0, 0.0), (1, 1): (0.0, -3000.0), (2, 1): (0.0, -6000.0)},
False,
(-3000.0, 0.0), # extrapolated 1 column "left" (x -= 3000 * 1)
),
],
ids=["row-nonzero-fixed", "col-nonzero-fixed"],
)
def test_collinear_nonzero_fixed_index_extrapolation(
qtbot,
rows: int,
cols: int,
calibrated_wells: dict,
is_single_row: bool,
expected_origin: tuple[float, float],
) -> None:
"""_fit_collinear extrapolates A1 center when the fixed row/col index is not 0."""
wdg = PlateCalibrationWidget()
qtbot.addWidget(wdg)

plate = useq.WellPlate(
rows=rows, columns=cols, well_size=(2, 2), well_spacing=(3, 3)
)
wdg.setValue(plate)
wdg._calibrated_wells = calibrated_wells

result = wdg._origin_spacing_rotation()
assert result is not None
origin, spacing, rotation = result
assert math.isclose(origin[0], expected_origin[0], abs_tol=1.0)
assert math.isclose(origin[1], expected_origin[1], abs_tol=1.0)
assert math.isclose(spacing[0], 3.0, rel_tol=1e-4)
assert math.isclose(spacing[1], 3.0, rel_tol=1e-4)
assert rotation == 0.0
Loading