Skip to content

Commit ec71935

Browse files
authored
auto exposure algorithm for Imager (#612)
1 parent d42e0bb commit ec71935

File tree

4 files changed

+210
-32
lines changed

4 files changed

+210
-32
lines changed

docs/user_guide/02_analytical/plate-reading/cytation5.ipynb

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,6 @@
2121
"%autoreload 2"
2222
]
2323
},
24-
{
25-
"cell_type": "code",
26-
"execution_count": null,
27-
"metadata": {},
28-
"outputs": [],
29-
"source": []
30-
},
3124
{
3225
"cell_type": "code",
3326
"execution_count": 2,
@@ -60,13 +53,6 @@
6053
"await pr.setup(use_cam=True)"
6154
]
6255
},
63-
{
64-
"cell_type": "code",
65-
"execution_count": null,
66-
"metadata": {},
67-
"outputs": [],
68-
"source": []
69-
},
7056
{
7157
"cell_type": "code",
7258
"execution_count": 5,
@@ -425,6 +411,51 @@
425411
"plt.imshow(ims[0], cmap=\"gray\", vmin=0, vmax=255)"
426412
]
427413
},
414+
{
415+
"cell_type": "markdown",
416+
"metadata": {},
417+
"source": [
418+
"### Autoexposure"
419+
]
420+
},
421+
{
422+
"cell_type": "markdown",
423+
"metadata": {},
424+
"source": [
425+
"Two autoexposure functions are available in the PLR library:\n",
426+
"- `max_pixel_at_fraction`: the value of the highest pixel in the image is a fraction of the maximum possible value (e.g. highest value is 50% of max, which would be 255/2 = 127.5 in the case of an 8 bit image)\n",
427+
"- `fraction_overexposed`: the fraction of pixels at the cap (eg 255 for an 8 bit image) should be a certain fraction of the total number of pixels (e.g. 0.5% of pixels should be at the cap, so 0.005 * total_pixels). This is useful for images that are not well illuminated, as it ensures that a certain fraction of pixels is overexposed, which can help with image quality.\n",
428+
"\n",
429+
"You can also define your own autoexposure function.\n",
430+
"\n",
431+
"The `AutoExposure` dataclass is used to configure the autoexposure settings, including the evaluation function, maximum number of rounds, and low and high exposure time limits."
432+
]
433+
},
434+
{
435+
"cell_type": "code",
436+
"execution_count": null,
437+
"metadata": {},
438+
"outputs": [],
439+
"source": [
440+
"from pylabrobot.plate_reading.imager import Imager, max_pixel_at_fraction, fraction_overexposed\n",
441+
"from pylabrobot.plate_reading.standard import AutoExposure\n",
442+
"\n",
443+
"ims = await pr.capture(\n",
444+
" exposure_time=AutoExposure(\n",
445+
" # evaluate_exposure=fraction_overexposed(fraction=0.005, margin=0.005/10),\n",
446+
" evaluate_exposure=max_pixel_at_fraction(fraction=0.90, margin=0.05),\n",
447+
" max_rounds=15,\n",
448+
" low=1,\n",
449+
" high=100\n",
450+
" ),\n",
451+
" well=(2, 2),\n",
452+
" mode=ImagingMode.PHASE_CONTRAST,\n",
453+
" objective=Objective.O_20X_PL_FL_Phase,\n",
454+
" focal_height=1.8, # focal height must be specified when using auto exposure\n",
455+
" gain=20 # gain must be specified when using auto exposure\n",
456+
")"
457+
]
458+
},
428459
{
429460
"cell_type": "markdown",
430461
"metadata": {},

pylabrobot/plate_reading/biotek_backend.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,7 @@ async def led_off(self):
788788
async def set_focus(self, focal_position: FocalPosition):
789789
"""focus position in mm"""
790790

791-
if focal_position == "auto":
791+
if focal_position == "machine-auto":
792792
await self.auto_focus()
793793
return
794794

@@ -938,7 +938,7 @@ async def set_auto_exposure(self, auto_exposure: Literal["off", "once", "continu
938938
)
939939

940940
async def set_exposure(self, exposure: Exposure):
941-
"""exposure (integration time) in ms, or "auto" """
941+
"""exposure (integration time) in ms, or "machine-auto" """
942942

943943
if exposure == self._exposure:
944944
logger.debug("Exposure time is already set to %s", exposure)
@@ -949,9 +949,9 @@ async def set_exposure(self, exposure: Exposure):
949949

950950
# either set auto exposure to continuous, or turn off
951951
if isinstance(exposure, str):
952-
if exposure == "auto":
952+
if exposure == "machine-auto":
953953
await self.set_auto_exposure("continuous")
954-
self._exposure = "auto"
954+
self._exposure = "machine-auto"
955955
return
956956
raise ValueError("exposure must be a number or 'auto'")
957957
self.cam.ExposureAuto.SetValue(PySpin.ExposureAuto_Off)
@@ -980,15 +980,15 @@ async def select(self, row: int, column: int):
980980
await self.set_position(0, 0)
981981

982982
async def set_gain(self, gain: Gain):
983-
"""gain of unknown units, or "auto" """
983+
"""gain of unknown units, or "machine-auto" """
984984
if self.cam is None:
985985
raise ValueError("Camera not initialized. Run setup(use_cam=True) first.")
986986

987987
if gain == self._gain:
988988
logger.debug("Gain is already set to %s", gain)
989989
return
990990

991-
if not (gain == "auto" or 0 <= gain <= 30):
991+
if not (gain == "machine-auto" or 0 <= gain <= 30):
992992
raise ValueError("gain must be between 0 and 30 (inclusive), or 'auto'")
993993

994994
nodemap = self.cam.GetNodeMap()
@@ -999,14 +999,14 @@ async def set_gain(self, gain: Gain):
999999
raise RuntimeError("unable to set automatic gain")
10001000
node = (
10011001
PySpin.CEnumEntryPtr(node_gain_auto.GetEntryByName("Continuous"))
1002-
if gain == "auto"
1002+
if gain == "machine-auto"
10031003
else PySpin.CEnumEntryPtr(node_gain_auto.GetEntryByName("Off"))
10041004
)
10051005
if not PySpin.IsReadable(node):
10061006
raise RuntimeError("unable to set automatic gain (enum entry retrieval)")
10071007
node_gain_auto.SetIntValue(node.GetValue())
10081008

1009-
if not gain == "auto":
1009+
if not gain == "machine-auto":
10101010
node_gain = PySpin.CFloatPtr(nodemap.GetNode("Gain"))
10111011
if (
10121012
not PySpin.IsReadable(node_gain)
@@ -1164,8 +1164,8 @@ async def capture(
11641164
speed: 211 ms ± 331 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
11651165
11661166
Args:
1167-
exposure_time: exposure time in ms, or `"auto"`
1168-
focal_height: focal height in mm, or `"auto"`
1167+
exposure_time: exposure time in ms, or `"machine-auto"`
1168+
focal_height: focal height in mm, or `"machine-auto"`
11691169
coverage: coverage of the well, either `"full"` or a tuple of `(num_rows, num_columns)`.
11701170
Around `center_position`.
11711171
center_position: center position of the well, in mm from the center of the selected well. If

pylabrobot/plate_reading/imager.py

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
from typing import List, Optional, Tuple, Union, cast
1+
import math
2+
from typing import Awaitable, Callable, List, Literal, Optional, Tuple, Union, cast
23

34
from pylabrobot.machines import Machine
45
from pylabrobot.plate_reading.backend import ImagerBackend
56
from pylabrobot.plate_reading.standard import (
7+
AutoExposure,
68
Exposure,
79
FocalPosition,
810
Gain,
@@ -13,6 +15,11 @@
1315
)
1416
from pylabrobot.resources import Plate, Resource, Well
1517

18+
try:
19+
import numpy as np
20+
except ImportError:
21+
np = None # type: ignore[assignment]
22+
1623

1724
class Imager(Resource, Machine):
1825
"""Microscope"""
@@ -52,14 +59,78 @@ def get_plate(self) -> Plate:
5259
raise NoPlateError("There is no plate in the plate reader.")
5360
return cast(Plate, self.children[0])
5461

62+
async def _capture_auto_exposure(
63+
self,
64+
well: Union[Well, Tuple[int, int]],
65+
mode: ImagingMode,
66+
objective: Objective,
67+
auto_exposure: AutoExposure,
68+
focal_height: float,
69+
gain: float,
70+
**backend_kwargs,
71+
) -> List[Image]:
72+
"""
73+
Capture an image with auto exposure.
74+
75+
This function will iteratively adjust the exposure time until a good exposure is found.
76+
It uses the provided `evaluate_exposure` function to determine if the exposure is good, too high, or too low.
77+
It uses a weighted binary search to find the optimal exposure time. The search is weighted by exposure time,
78+
meaning that instead of splitting the range in half, we split the range at the point that equalizes the integral
79+
of the exposure time on both sides (this works out to be equal to the root mean square of the endpoints).
80+
"""
81+
82+
if focal_height == "auto":
83+
raise ValueError("Focal height must be specified for auto exposure")
84+
if gain == "auto":
85+
raise ValueError("Gain must be specified for auto exposure")
86+
87+
def _rms_split(low: float, high: float) -> float:
88+
"""Split point that equalizes ∫t dt on both sides (RMS of endpoints)."""
89+
if low == high:
90+
return low
91+
return math.sqrt((low**2 + high**2) / 2)
92+
93+
low, high = auto_exposure.low, auto_exposure.high
94+
95+
rounds = 0
96+
while high - low > 1e-3:
97+
if auto_exposure.max_rounds is not None and rounds >= auto_exposure.max_rounds:
98+
raise ValueError("Exceeded maximum number of rounds")
99+
rounds += 1
100+
101+
p = _rms_split(low, high)
102+
ims = await self.capture(
103+
well=well,
104+
mode=mode,
105+
objective=objective,
106+
exposure_time=p,
107+
focal_height=focal_height,
108+
gain=gain,
109+
**backend_kwargs,
110+
)
111+
assert len(ims) == 1, "Expected exactly one image to be returned"
112+
im = ims[0]
113+
result = await auto_exposure.evaluate_exposure(im)
114+
115+
if result == "good":
116+
return ims
117+
if result == "lower":
118+
high = p
119+
elif result == "higher":
120+
low = p
121+
else:
122+
raise ValueError(f"Unexpected evaluation result: {result}")
123+
124+
raise RuntimeError("Failed to find a good exposure time.")
125+
55126
async def capture(
56127
self,
57128
well: Union[Well, Tuple[int, int]],
58129
mode: ImagingMode,
59130
objective: Objective,
60-
exposure_time: Exposure = "auto",
61-
focal_height: FocalPosition = "auto",
62-
gain: Gain = "auto",
131+
exposure_time: Union[Exposure, AutoExposure] = "machine-auto",
132+
focal_height: FocalPosition = "machine-auto",
133+
gain: Gain = "machine-auto",
63134
**backend_kwargs,
64135
) -> List[Image]:
65136
if isinstance(well, tuple):
@@ -70,6 +141,19 @@ async def capture(
70141
raise ValueError(f"Well {well} not in plate {well.parent}")
71142
row, column = divmod(idx, cast(Plate, well.parent).num_items_x)
72143

144+
if isinstance(exposure_time, AutoExposure):
145+
assert focal_height != "machine-auto", "Focal height must be specified for auto exposure"
146+
assert gain != "machine-auto", "Gain must be specified for auto exposure"
147+
return await self._capture_auto_exposure(
148+
well=well,
149+
mode=mode,
150+
objective=objective,
151+
auto_exposure=exposure_time,
152+
focal_height=focal_height,
153+
gain=gain,
154+
**backend_kwargs,
155+
)
156+
73157
return await self.backend.capture(
74158
row=row,
75159
column=column,
@@ -81,3 +165,57 @@ async def capture(
81165
plate=self.get_plate(),
82166
**backend_kwargs,
83167
)
168+
169+
170+
def max_pixel_at_fraction(
171+
fraction: float, margin: float
172+
) -> Callable[[Image], Awaitable[Literal["higher", "lower", "good"]]]:
173+
"""The maximum pixel value in a given image should be a fraction of the maximum possible pixel value (eg 255 for 8-bit images).
174+
175+
Args:
176+
fraction: the desired fraction of the actual maximum pixel value over the theoretically maximum pixel value (e.g. 0.8 for 80%). If it is an 8-bit image, the maximum value would be 0.8 * 255 = 204.
177+
margin: the margin of error that is accepted. A fraction of the theoretical maximum pixel value, e.g. 0.05 for 5%, so the maximum pixel value should be between 0.75 * 255 and 0.85 * 255.
178+
"""
179+
180+
if np is None:
181+
raise ImportError("numpy is required for max_pixel_at_fraction")
182+
183+
async def evaluate_exposure(im) -> Literal["higher", "lower", "good"]:
184+
array = np.array(im, dtype=np.float32)
185+
value = np.max(array) - (255.0 * fraction)
186+
margin_value = 255.0 * margin
187+
if abs(value) <= margin_value:
188+
return "good"
189+
# lower the exposure time if the max pixel value is too high
190+
return "lower" if value > 0 else "higher"
191+
192+
return evaluate_exposure
193+
194+
195+
def fraction_overexposed(
196+
fraction: float, margin: float, max_pixel_value: int = 255
197+
) -> Callable[[Image], Awaitable[Literal["higher", "lower", "good"]]]:
198+
"""A certain fraction of pixels in the image should be overexposed (e.g. 0.5%).
199+
200+
This is useful for images that are not well illuminated, as it ensures that a certain fraction of pixels is overexposed, which can help with image quality.
201+
202+
Args:
203+
fraction: the desired fraction of pixels that should be overexposed (e.g. 0.005 for 0.5%). Overexposed is defined as pixels with a value greater than the maximum pixel value (e.g. 255 for 8-bit images). You can customize this number if needed.
204+
margin: the margin of error for the fraction of pixels that should be overexposed (e.g. 0.001 for 0.1%, so the fraction of overexposed pixels should be between 0.004 and 0.006).
205+
max_pixel_value: the maximum pixel value for the image (e.g. 255 for 8-bit images). You can override it to change the definition of "overexposed" pixels.
206+
"""
207+
208+
if np is None:
209+
raise ImportError("numpy is required for fraction_overexposed")
210+
211+
async def evaluate_exposure(im) -> Literal["higher", "lower", "good"]:
212+
# count the number of pixels that are overexposed
213+
arr = np.asarray(im, dtype=np.uint8)
214+
actual_fraction = np.count_nonzero(arr > max_pixel_value) / arr.size
215+
lower_bound, upper_bound = fraction - margin, fraction + margin
216+
if lower_bound <= actual_fraction <= upper_bound:
217+
return "good"
218+
# too many saturated pixels -> shorten exposure
219+
return "lower" if (actual_fraction - fraction) > 0 else "higher"
220+
221+
return evaluate_exposure

pylabrobot/plate_reading/standard.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import enum
2-
from typing import List, Literal, Union
2+
from dataclasses import dataclass
3+
from typing import Awaitable, Callable, List, Literal, Union
34

45
Image = List[List[float]]
56

@@ -89,6 +90,14 @@ class NoPlateError(Exception):
8990
pass
9091

9192

92-
Exposure = Union[float, Literal["auto"]]
93-
FocalPosition = Union[float, Literal["auto"]]
94-
Gain = Union[float, Literal["auto"]]
93+
@dataclass
94+
class AutoExposure:
95+
evaluate_exposure: Callable[[Image], Awaitable[Literal["higher", "lower", "good"]]]
96+
max_rounds: int
97+
low: float
98+
high: float
99+
100+
101+
Exposure = Union[float, Literal["machine-auto"]]
102+
FocalPosition = Union[float, Literal["machine-auto"]]
103+
Gain = Union[float, Literal["machine-auto"]]

0 commit comments

Comments
 (0)