Skip to content

Commit bffeb24

Browse files
committed
Universal auto focus
1 parent 3d45d05 commit bffeb24

File tree

3 files changed

+149
-128
lines changed

3 files changed

+149
-128
lines changed

pylabrobot/plate_reading/biotek_backend.py

Lines changed: 3 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,8 @@
88
from dataclasses import dataclass
99
from typing import Any, Callable, Coroutine, Dict, List, Literal, Optional, Tuple, Union, cast
1010

11-
try:
12-
import cv2 # type: ignore
13-
14-
CV2_AVAILABLE = True
15-
except ImportError as e:
16-
cv2 = None # type: ignore
17-
CV2_AVAILABLE = False
18-
_CV2_IMPORT_ERROR = e
19-
2011
from pylabrobot.resources.plate import Plate
2112

22-
try:
23-
import numpy as np # type: ignore
24-
25-
USE_NUMPY = True
26-
except ImportError as e:
27-
USE_NUMPY = False
28-
_NUMPY_IMPORT_ERROR = e
29-
3013
try:
3114
import PySpin # type: ignore
3215

@@ -58,41 +41,6 @@
5841
SpinnakerException = PySpin.SpinnakerException if USE_PYSPIN else Exception
5942

6043

61-
async def _golden_ratio_search(
62-
func: Callable[..., Coroutine[Any, Any, float]], a: float, b: float, tol: float, timeout: float
63-
):
64-
"""Golden ratio search to maximize a unimodal function `func` over the interval [a, b]."""
65-
# thanks chat
66-
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
67-
68-
c = b - (b - a) / phi
69-
d = a + (b - a) / phi
70-
71-
cache: Dict[float, float] = {}
72-
73-
async def cached_func(x: float) -> float:
74-
x = round(x / tol) * tol # round x to units of tol
75-
if x not in cache:
76-
cache[x] = await func(x)
77-
return cache[x]
78-
79-
t0 = time.time()
80-
iteration = 0
81-
while abs(b - a) > tol:
82-
if (await cached_func(c)) > (await cached_func(d)):
83-
b = d
84-
else:
85-
a = c
86-
c = b - (b - a) / phi
87-
d = a + (b - a) / phi
88-
if time.time() - t0 > timeout:
89-
raise TimeoutError("Timeout while searching for optimal focus position")
90-
iteration += 1
91-
logger.debug("Golden ratio search (autofocus) iteration %d, a=%s, b=%s", iteration, a, b)
92-
93-
return (b + a) / 2
94-
95-
9644
@dataclass
9745
class Cytation5ImagingConfig:
9846
camera_serial_number: Optional[str] = None
@@ -156,7 +104,6 @@ def __init__(
156104
self._imaging_mode: Optional["ImagingMode"] = None
157105
self._row: Optional[int] = None
158106
self._column: Optional[int] = None
159-
self._auto_focus_search_range: Tuple[float, float] = (1.8, 2.5)
160107
self._shaking = False
161108
self._pos_x: Optional[float] = None
162109
self._pos_y: Optional[float] = None
@@ -912,8 +859,9 @@ async def set_focus(self, focal_position: FocalPosition):
912859
"""focus position in mm"""
913860

914861
if focal_position == "machine-auto":
915-
await self.auto_focus()
916-
return
862+
raise ValueError(
863+
"focal_position cannot be 'machine-auto'. Use the PLR Imager universal autofocus instead."
864+
)
917865

918866
if focal_position == self._focal_height:
919867
logger.debug("Focus position is already set to %s", focal_position)
@@ -977,77 +925,6 @@ async def set_position(self, x: float, y: float):
977925
self._pos_x, self._pos_y = x, y
978926
await asyncio.sleep(0.1)
979927

980-
def set_auto_focus_search_range(self, min_focal_height: float, max_focal_height: float):
981-
self._auto_focus_search_range = (min_focal_height, max_focal_height)
982-
983-
async def auto_focus(self, timeout: float = 30):
984-
"""Set auto focus search range with set_auto_focus_search_range()."""
985-
986-
plate = self._plate
987-
if plate is None:
988-
raise RuntimeError("Plate not set. Run set_plate() first.")
989-
imaging_mode = self._imaging_mode
990-
if imaging_mode is None:
991-
raise RuntimeError("Imaging mode not set. Run set_imaging_mode() first.")
992-
objective = self._objective
993-
if objective is None:
994-
raise RuntimeError("Objective not set. Run set_objective() first.")
995-
exposure = self._exposure
996-
if exposure is None:
997-
raise RuntimeError("Exposure time not set. Run set_exposure() first.")
998-
gain = self._gain
999-
if gain is None:
1000-
raise RuntimeError("Gain not set. Run set_gain() first.")
1001-
row, column = self._row, self._column
1002-
if row is None or column is None:
1003-
raise RuntimeError("Row and column not set. Run select() first.")
1004-
if not USE_NUMPY:
1005-
# This is strange, because Spinnaker requires numpy
1006-
raise RuntimeError(
1007-
"numpy is not installed. See Cytation5 installation instructions. "
1008-
f"Import error: {_NUMPY_IMPORT_ERROR}"
1009-
)
1010-
1011-
# objective function: variance of laplacian
1012-
async def evaluate_focus(focus_value):
1013-
await self.set_focus(focus_value)
1014-
image = await self._acquire_image()
1015-
1016-
if not CV2_AVAILABLE:
1017-
raise RuntimeError(
1018-
f"cv2 needs to be installed for auto focus. Import error: {_CV2_IMPORT_ERROR}"
1019-
)
1020-
1021-
# cut out 25% on each side
1022-
np_image = np.array(image, dtype=np.float64)
1023-
height, width = np_image.shape[:2]
1024-
crop_height = height // 4
1025-
crop_width = width // 4
1026-
np_image = np_image[crop_height : height - crop_height, crop_width : width - crop_width]
1027-
1028-
# NVMG: Normalized Variance of the Gradient Magnitude
1029-
# Chat invented this i think
1030-
sobel_x = cv2.Sobel(np_image, cv2.CV_64F, 1, 0, ksize=3)
1031-
sobel_y = cv2.Sobel(np_image, cv2.CV_64F, 0, 1, ksize=3)
1032-
gradient_magnitude = np.sqrt(sobel_x**2 + sobel_y**2)
1033-
1034-
mean_gm = np.mean(gradient_magnitude)
1035-
var_gm = np.var(gradient_magnitude)
1036-
sharpness = var_gm / (mean_gm + 1e-6)
1037-
return sharpness
1038-
1039-
# Use golden ratio search to find the best focus value
1040-
focus_min, focus_max = self._auto_focus_search_range
1041-
best_focal_height = await _golden_ratio_search(
1042-
func=evaluate_focus,
1043-
a=focus_min,
1044-
b=focus_max,
1045-
tol=0.001, # 1 micron
1046-
timeout=timeout,
1047-
)
1048-
self._focal_height = best_focal_height
1049-
return best_focal_height
1050-
1051928
async def set_auto_exposure(self, auto_exposure: Literal["off", "once", "continuous"]):
1052929
if self.cam is None:
1053930
raise ValueError("Camera not initialized. Run setup(use_cam=True) first.")

pylabrobot/plate_reading/imager.py

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import logging
12
import math
2-
from typing import Awaitable, Callable, Literal, Optional, Tuple, Union, cast
3+
import time
4+
from typing import Any, Awaitable, Callable, Coroutine, Dict, Literal, Optional, Tuple, Union, cast
35

46
from pylabrobot.machines import Machine
57
from pylabrobot.plate_reading.backend import ImagerBackend
68
from pylabrobot.plate_reading.standard import (
79
AutoExposure,
10+
AutoFocus,
811
Exposure,
912
FocalPosition,
1013
Gain,
@@ -16,12 +19,59 @@
1619
)
1720
from pylabrobot.resources import Plate, Resource, Well
1821

22+
try:
23+
import cv2 # type: ignore
24+
25+
CV2_AVAILABLE = True
26+
except ImportError as e:
27+
cv2 = None # type: ignore
28+
CV2_AVAILABLE = False
29+
_CV2_IMPORT_ERROR = e
30+
1931
try:
2032
import numpy as np
2133
except ImportError:
2234
np = None # type: ignore[assignment]
2335

2436

37+
logger = logging.getLogger(__name__)
38+
39+
40+
async def _golden_ratio_search(
41+
func: Callable[..., Coroutine[Any, Any, float]], a: float, b: float, tol: float, timeout: float
42+
):
43+
"""Golden ratio search to maximize a unimodal function `func` over the interval [a, b]."""
44+
# thanks chat
45+
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
46+
47+
c = b - (b - a) / phi
48+
d = a + (b - a) / phi
49+
50+
cache: Dict[float, float] = {}
51+
52+
async def cached_func(x: float) -> float:
53+
x = round(x / tol) * tol # round x to units of tol
54+
if x not in cache:
55+
cache[x] = await func(x)
56+
return cache[x]
57+
58+
t0 = time.time()
59+
iteration = 0
60+
while abs(b - a) > tol:
61+
if (await cached_func(c)) > (await cached_func(d)):
62+
b = d
63+
else:
64+
a = c
65+
c = b - (b - a) / phi
66+
d = a + (b - a) / phi
67+
if time.time() - t0 > timeout:
68+
raise TimeoutError("Timeout while searching for optimal focus position")
69+
iteration += 1
70+
logger.debug("Golden ratio search (autofocus) iteration %d, a=%s, b=%s", iteration, a, b)
71+
72+
return (b + a) / 2
73+
74+
2575
class Imager(Resource, Machine):
2676
"""Microscope"""
2777

@@ -124,6 +174,41 @@ def _rms_split(low: float, high: float) -> float:
124174

125175
raise RuntimeError("Failed to find a good exposure time.")
126176

177+
async def _capture_auto_focus(
178+
self,
179+
well: Union[Well, Tuple[int, int]],
180+
mode: ImagingMode,
181+
objective: Objective,
182+
exposure_time: float,
183+
auto_focus: AutoFocus,
184+
gain: float,
185+
**backend_kwargs,
186+
) -> ImagingResult:
187+
async def local_capture(focal_height: float) -> ImagingResult:
188+
return await self.capture(
189+
well=well,
190+
mode=mode,
191+
objective=objective,
192+
exposure_time=exposure_time,
193+
focal_height=focal_height,
194+
gain=gain,
195+
**backend_kwargs,
196+
)
197+
198+
async def capture_and_evaluate(focal_height: float) -> float:
199+
res = await local_capture(focal_height)
200+
return auto_focus.evaluate_focus(res.images[0])
201+
202+
# Use golden ratio search to find the best focus value
203+
best_focal_height = await _golden_ratio_search(
204+
func=capture_and_evaluate,
205+
a=auto_focus.low,
206+
b=auto_focus.high,
207+
tol=auto_focus.tolerance, # 1 micron
208+
timeout=auto_focus.timeout,
209+
)
210+
return await local_capture(best_focal_height)
211+
127212
async def capture(
128213
self,
129214
well: Union[Well, Tuple[int, int]],
@@ -136,7 +221,11 @@ async def capture(
136221
) -> ImagingResult:
137222
if not isinstance(exposure_time, (int, float, AutoExposure)):
138223
raise TypeError(f"Invalid exposure time: {exposure_time}")
139-
if not isinstance(focal_height, (int, float)) and focal_height != "machine-auto":
224+
if (
225+
not isinstance(focal_height, (int, float))
226+
and focal_height != "machine-auto"
227+
and not isinstance(focal_height, AutoFocus)
228+
):
140229
raise TypeError(f"Invalid focal height: {focal_height}")
141230

142231
if isinstance(well, tuple):
@@ -160,6 +249,21 @@ async def capture(
160249
**backend_kwargs,
161250
)
162251

252+
if isinstance(focal_height, AutoFocus):
253+
assert isinstance(
254+
exposure_time, (int, float)
255+
), "Exposure time must be specified for auto focus"
256+
assert gain != "machine-auto", "Gain must be specified for auto focus"
257+
return await self._capture_auto_focus(
258+
well=well,
259+
mode=mode,
260+
objective=objective,
261+
exposure_time=exposure_time,
262+
auto_focus=focal_height,
263+
gain=gain,
264+
**backend_kwargs,
265+
)
266+
163267
return await self.backend.capture(
164268
row=row,
165269
column=column,
@@ -225,3 +329,34 @@ async def evaluate_exposure(im) -> Literal["higher", "lower", "good"]:
225329
return "lower" if (actual_fraction - fraction) > 0 else "higher"
226330

227331
return evaluate_exposure
332+
333+
334+
def evaluate_focus_nvmg_sobel(image: Image) -> float:
335+
"""Evaluate the focus of an image using the Normalized Variance of the Gradient Magnitude (NVMG) method with Sobel filters.
336+
337+
I think Chat invented this method.
338+
339+
Only uses the center 50% of the image to avoid edge effects.
340+
"""
341+
if not CV2_AVAILABLE:
342+
raise RuntimeError(
343+
f"cv2 needs to be installed for auto focus. Import error: {_CV2_IMPORT_ERROR}"
344+
)
345+
346+
# cut out 25% on each side
347+
np_image = np.array(image, dtype=np.float64)
348+
height, width = np_image.shape[:2]
349+
crop_height = height // 4
350+
crop_width = width // 4
351+
np_image = np_image[crop_height : height - crop_height, crop_width : width - crop_width]
352+
353+
# NVMG: Normalized Variance of the Gradient Magnitude
354+
# Chat invented this i think
355+
sobel_x = cv2.Sobel(np_image, cv2.CV_64F, 1, 0, ksize=3)
356+
sobel_y = cv2.Sobel(np_image, cv2.CV_64F, 0, 1, ksize=3)
357+
gradient_magnitude = np.sqrt(sobel_x**2 + sobel_y**2)
358+
359+
mean_gm = np.mean(gradient_magnitude)
360+
var_gm = np.var(gradient_magnitude)
361+
sharpness = var_gm / (mean_gm + 1e-6)
362+
return cast(float, sharpness)

pylabrobot/plate_reading/standard.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ class AutoExposure:
103103
high: float
104104

105105

106+
@dataclass
107+
class AutoFocus:
108+
evaluate_focus: Callable[[Image], float]
109+
timeout: float
110+
low: float
111+
high: float
112+
tolerance: float = 0.001 # 1 micron
113+
114+
106115
Exposure = Union[float, Literal["machine-auto"]]
107116
FocalPosition = Union[float, Literal["machine-auto"]]
108117
Gain = Union[float, Literal["machine-auto"]]

0 commit comments

Comments
 (0)