Skip to content

Commit db9de89

Browse files
authored
Autofocus cytation5 (#289)
1 parent 15dce32 commit db9de89

File tree

9 files changed

+186
-26
lines changed

9 files changed

+186
-26
lines changed

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
7070
- `get_absolute_size_x`, `get_absolute_size_y`, `get_absolute_size_z` for `Resource` (https://github.com/PyLabRobot/pylabrobot/pull/235)
7171
- `Cytation5Backend` for plate reading on BioTek Cytation 5 (https://github.com/PyLabRobot/pylabrobot/pull/238)
7272
- imaging (https://github.com/PyLabRobot/pylabrobot/pull/277)
73+
- autofocus (https://github.com/PyLabRobot/pylabrobot/pull/289)
7374
- More chatterboxes (https://github.com/PyLabRobot/pylabrobot/pull/242)
7475
- `FanChatterboxBackend`
7576
- `PlateReaderChatterboxBackend`

Diff for: docs/resources/index.md

+2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ This standard is similar to the [Opentrons API labware naming standard](https://
6363
```{toctree}
6464
:caption: Library
6565
66+
library/agenbio
6667
library/alpaqua
6768
library/azenta
6869
library/boekel
@@ -76,4 +77,5 @@ library/opentrons
7677
library/porvair
7778
library/revvity
7879
library/thermo_fisher
80+
library/vwr
7981
```

Diff for: docs/resources/library/falcon.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
|--------------------|--------------------|--------------------|
77
| Falcon_96_wellplate_Fl [manufacturer website](https://www.fishersci.com/shop/products/falcon-96-well-cell-culture-treated-flat-bottom-microplate/087722C) | ![](img/falcon/Falcon_96_wellplate_Fl.webp) | `Falcon_96_wellplate_Fl`
88
| Falcon_96_wellplate_Rb [manufacturer website](https://ecatalog.corning.com/life-sciences/b2c/US/en/Microplates/Assay-Microplates/96-Well-Microplates/Falcon®-96-well-Polystyrene-Microplates/p/353077) | ![](img/falcon/Falcon_96_wellplate_Rb.jpg) | `Falcon_96_wellplate_Rb`
9-
| Falcon_96_wellplate_Fl_Black [manufacturer website](https://www.fishersci.com/shop/products/falcon-96-well-imaging-plate-lid/08772225) | ![](img/falcon/Falcon_96_wellplate_Fl_Black.jpg) | `Falcon_96_wellplate_Fl_Black`
9+
| Falcon_96_wellplate_Fl_Black [manufacturer website](https://www.fishersci.com/shop/products/falcon-96-well-imaging-plate-lid/08772225) | ![](img/falcon/Falcon_96_wellplate_Fl_Black.jpg.webp) | `Falcon_96_wellplate_Fl_Black`
1010

1111
## Tubes
1212

16.1 KB
Binary file not shown.
Loading

Diff for: docs/resources/library/vwr.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ Company page: [Wikipedia](https://en.wikipedia.org/wiki/VWR_International)
66

77
| Description | Image | PLR definition |
88
|--------------------|--------------------|--------------------|
9-
| 'VWRReagentReservoirs25mL'<br>Part no.: 89094<br>[manufacturer website](https://us.vwr.com/store/product/4694822/vwr-disposable-pipetting-reservoirs)<br>Polystyrene Reservoirs | ![](img/vwr/VWRReagentReservoirs25mL.jpg.avif) | `VWRReagentReservoirs25mL` |
9+
| 'VWRReagentReservoirs25mL'<br>Part no.: 89094<br>[manufacturer website](https://us.vwr.com/store/product/4694822/vwr-disposable-pipetting-reservoirs)<br>Polystyrene Reservoirs | ![](img/vwr/VWRReagentReservoirs25mL.jpg) | `VWRReagentReservoirs25mL` |

Diff for: docs/user_guide/cytation5.ipynb

+53-21
Large diffs are not rendered by default.

Diff for: docs/user_guide/installation.md

+10
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,13 @@ If you ever wish to switch back from firmware command to use `pyhamilton` or pla
164164
If you get a `usb.core.NoBackendError: No backend available` error: [this](https://github.com/pyusb/pyusb/blob/master/docs/faq.rst#how-do-i-fix-no-backend-available-errors) may be helpful.
165165

166166
If you are still having trouble, please reach out on [discuss.pylabrobot.org](https://discuss.pylabrobot.org).
167+
168+
## Cytation5 imager
169+
170+
In order to use imaging on the Cytation5, you need to:
171+
172+
1. Install python 3.10
173+
2. Download Spinnaker SDK and install (including Python) [https://www.teledynevisionsolutions.com/products/spinnaker-sdk/](https://www.teledynevisionsolutions.com/products/spinnaker-sdk/)
174+
3. Install numpy==1.26 (this is an older version)
175+
176+
If you just want to do plate reading, heating, shaknig, etc. you don't need to follow these specific steps.

Diff for: pylabrobot/plate_reading/biotek_backend.py

+118-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111
except ImportError:
1212
USE_FTDI = False
1313

14+
try:
15+
import numpy as np # type: ignore
16+
17+
USE_NUMPY = True
18+
except ImportError:
19+
USE_NUMPY = False
20+
1421
try:
1522
import PySpin # type: ignore
1623

@@ -33,6 +40,72 @@
3340
SpinnakerException = PySpin.SpinnakerException if USE_PYSPIN else Exception
3441

3542

43+
def _laplacian_2d(u):
44+
# thanks chat (one shotted this)
45+
# verified to be the same as scipy.ndimage.laplace
46+
# 6.09 ms ± 40.5 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
47+
48+
# Assumes u is a 2D numpy array and that dx = dy = 1
49+
laplacian = np.zeros_like(u)
50+
51+
# Applying the finite difference approximation for interior points
52+
laplacian[1:-1, 1:-1] = (u[2:, 1:-1] - 2 * u[1:-1, 1:-1] + u[:-2, 1:-1]) + (
53+
u[1:-1, 2:] - 2 * u[1:-1, 1:-1] + u[1:-1, :-2]
54+
)
55+
56+
# Handle the edges using reflection
57+
laplacian[0, 1:-1] = (u[1, 1:-1] - 2 * u[0, 1:-1] + u[0, 1:-1]) + (
58+
u[0, 2:] - 2 * u[0, 1:-1] + u[0, :-2]
59+
)
60+
61+
laplacian[-1, 1:-1] = (u[-2, 1:-1] - 2 * u[-1, 1:-1] + u[-1, 1:-1]) + (
62+
u[-1, 2:] - 2 * u[-1, 1:-1] + u[-1, :-2]
63+
)
64+
65+
laplacian[1:-1, 0] = (u[2:, 0] - 2 * u[1:-1, 0] + u[:-2, 0]) + (
66+
u[1:-1, 1] - 2 * u[1:-1, 0] + u[1:-1, 0]
67+
)
68+
69+
laplacian[1:-1, -1] = (u[2:, -1] - 2 * u[1:-1, -1] + u[:-2, -1]) + (
70+
u[1:-1, -2] - 2 * u[1:-1, -1] + u[1:-1, -1]
71+
)
72+
73+
# Handle the corners (reflection)
74+
laplacian[0, 0] = (u[1, 0] - 2 * u[0, 0] + u[0, 0]) + (u[0, 1] - 2 * u[0, 0] + u[0, 0])
75+
laplacian[0, -1] = (u[1, -1] - 2 * u[0, -1] + u[0, -1]) + (u[0, -2] - 2 * u[0, -1] + u[0, -1])
76+
laplacian[-1, 0] = (u[-2, 0] - 2 * u[-1, 0] + u[-1, 0]) + (u[-1, 1] - 2 * u[-1, 0] + u[-1, 0])
77+
laplacian[-1, -1] = (u[-2, -1] - 2 * u[-1, -1] + u[-1, -1]) + (
78+
u[-1, -2] - 2 * u[-1, -1] + u[-1, -1]
79+
)
80+
81+
return laplacian
82+
83+
84+
async def _golden_ratio_search(func, a, b, tol, timeout):
85+
"""Golden ratio search to maximize a unimodal function `func` over the interval [a, b]."""
86+
# thanks chat
87+
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
88+
89+
c = b - (b - a) / phi
90+
d = a + (b - a) / phi
91+
92+
t0 = time.time()
93+
iteration = 0
94+
while abs(b - a) > tol:
95+
if (await func(c)) > (await func(d)):
96+
b = d
97+
else:
98+
a = c
99+
c = b - (b - a) / phi
100+
d = a + (b - a) / phi
101+
if time.time() - t0 > timeout:
102+
raise TimeoutError("Timeout while searching for optimal focus position")
103+
iteration += 1
104+
logger.debug("Golden ratio search (autofocus) iteration %d, a=%s, b=%s", iteration, a, b)
105+
106+
return (b + a) / 2
107+
108+
36109
class Cytation5Backend(ImageReaderBackend):
37110
"""Backend for biotek cytation 5 image reader.
38111
@@ -461,8 +534,50 @@ async def set_focus(self, focal_position: FocalPosition):
461534

462535
self._focal_height = focal_position
463536

464-
async def auto_focus(self):
465-
raise NotImplementedError("auto_focus not implemented yet")
537+
async def auto_focus(self, timeout: float = 30):
538+
imaging_mode = self._imaging_mode
539+
if imaging_mode is None:
540+
raise RuntimeError("Imaging mode not set. Run set_imaging_mode() first.")
541+
exposure = self._exposure
542+
if exposure is None:
543+
raise RuntimeError("Exposure time not set. Run set_exposure() first.")
544+
gain = self._gain
545+
if gain is None:
546+
raise RuntimeError("Gain not set. Run set_gain() first.")
547+
row, column = self._row, self._column
548+
if row is None or column is None:
549+
raise RuntimeError("Row and column not set. Run select() first.")
550+
if not USE_NUMPY:
551+
# This is strange, because Spinnaker requires numpy
552+
raise RuntimeError("numpy is not installed. See Cytation5 installation instructions.")
553+
554+
# these seem reasonable, but might need to be adjusted in the future
555+
focus_min = 2
556+
focus_max = 5
557+
558+
# objective function: variance of laplacian
559+
async def evaluate_focus(focus_value):
560+
image = await self.capture(
561+
row=row,
562+
column=column,
563+
mode=imaging_mode,
564+
focal_height=focus_value,
565+
exposure_time=exposure,
566+
gain=gain,
567+
)
568+
laplacian = _laplacian_2d(np.asarray(image))
569+
return np.var(laplacian)
570+
571+
# Use golden ratio search to find the best focus value
572+
best_focus_value = await _golden_ratio_search(
573+
func=evaluate_focus,
574+
a=focus_min,
575+
b=focus_max,
576+
tol=0.01,
577+
timeout=timeout,
578+
)
579+
580+
return best_focus_value
466581

467582
async def set_auto_exposure(self, auto_exposure: Literal["off", "once", "continuous"]):
468583
if self.cam is None:
@@ -677,8 +792,8 @@ async def capture(
677792
await self.select(row, column)
678793
await self.set_imaging_mode(mode)
679794
await self.set_exposure(exposure_time)
680-
await self.set_focus(focal_height)
681795
await self.set_gain(gain)
796+
await self.set_focus(focal_height)
682797
return await self._acquire_image(
683798
color_processing_algorithm=color_processing_algorithm, pixel_format=pixel_format
684799
)

0 commit comments

Comments
 (0)