1+ import logging
12import 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
46from pylabrobot .machines import Machine
57from pylabrobot .plate_reading .backend import ImagerBackend
68from pylabrobot .plate_reading .standard import (
79 AutoExposure ,
10+ AutoFocus ,
811 Exposure ,
912 FocalPosition ,
1013 Gain ,
1619)
1720from 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+
1931try :
2032 import numpy as np
2133except 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+
2575class 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 )
0 commit comments