|
11 | 11 | except ImportError:
|
12 | 12 | USE_FTDI = False
|
13 | 13 |
|
| 14 | +try: |
| 15 | + import numpy as np # type: ignore |
| 16 | + |
| 17 | + USE_NUMPY = True |
| 18 | +except ImportError: |
| 19 | + USE_NUMPY = False |
| 20 | + |
14 | 21 | try:
|
15 | 22 | import PySpin # type: ignore
|
16 | 23 |
|
|
33 | 40 | SpinnakerException = PySpin.SpinnakerException if USE_PYSPIN else Exception
|
34 | 41 |
|
35 | 42 |
|
| 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 | + |
36 | 109 | class Cytation5Backend(ImageReaderBackend):
|
37 | 110 | """Backend for biotek cytation 5 image reader.
|
38 | 111 |
|
@@ -461,8 +534,50 @@ async def set_focus(self, focal_position: FocalPosition):
|
461 | 534 |
|
462 | 535 | self._focal_height = focal_position
|
463 | 536 |
|
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 |
466 | 581 |
|
467 | 582 | async def set_auto_exposure(self, auto_exposure: Literal["off", "once", "continuous"]):
|
468 | 583 | if self.cam is None:
|
@@ -677,8 +792,8 @@ async def capture(
|
677 | 792 | await self.select(row, column)
|
678 | 793 | await self.set_imaging_mode(mode)
|
679 | 794 | await self.set_exposure(exposure_time)
|
680 |
| - await self.set_focus(focal_height) |
681 | 795 | await self.set_gain(gain)
|
| 796 | + await self.set_focus(focal_height) |
682 | 797 | return await self._acquire_image(
|
683 | 798 | color_processing_algorithm=color_processing_algorithm, pixel_format=pixel_format
|
684 | 799 | )
|
0 commit comments