Refactored new (and some old) code to tidy up scan function

I have:
* Moved the autofocus and focus-prediction code into a
new `FocusManager` class.  This hopefully keeps the logic relating to
axial motion in one place.
* Moved set-up code relating to autofocus and background detection
into separate functions.
* Changed the scan path from a ragged list-of-lists to a one dimensional
list.  I've left the old 2D function and added a new 1D function that
calls it and coverts, so it's not a breaking change.

The actual sequence of moves executed, and the accompanying logic, is
unchanged from the previous commit.  I've tested this a couple of times,
on my microscope with actual hardware.
I don't think it's necessary to test more widely as this is only a
refactoring change, not an algorithm change.

The shift from 2D to 1D scan path was one I initially decided against,
because I was trying to keep the new code as small as possible, and
avoid refactoring what's already there.  Since I'm doing that anyway,
I have taken the opportunity to eliminate the concept of scan
"lines".

The old behaviour was to always use the position of the last point
as the starting point for the next autofocus.  There was an exception
to this for raster scans, where the big jump used the first point
of the last line instead.  The new behaviour always uses the closest
point, which reproduces this behaviour without the need for a hard
coded exception.

If the "fast" scan axis (y) has a longer step size than the "slow"
scan axis (x), we may base the autofocus off the previous row, rather
than the previous point in the current row.  I don't see that this
should be any less reliable than the current behaviour, and is
arguably better.  It's also unlikely to be noticed, because our
default scan settings have longer spacing in X.  I think the
reduced complexity of the code is definitely worth the small
chance of changing some edge-case behaviour.
This commit is contained in:
Richard Bowman 2022-04-20 13:14:23 +01:00
parent 7f66235cac
commit 18145d4b67

View file

@ -2,7 +2,7 @@ import datetime
import logging
import time
import uuid
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Tuple, Callable
import marshmallow
import numpy as np
@ -29,6 +29,137 @@ XyzCoordinate = Tuple[int, int, int]
### Grid construction
class FocusManager:
"""Manage axial motion during a series of XY moves
This class keeps track of the focus position as we move around.
It currently uses the regular autofocus method, and has support
for background detection.
"""
initial_position: XyzCoordinate = None
focused_positions: List[XyzCoordinate] = None
current_image_is_background: Optional[Callable] = None
autofocus: Optional[Callable] = None
microscope: Microscope = None
axial_jump_threshold: Optional[float] = None
def __init__(
self,
microscope: Microscope,
initial_position: XyzCoordinate,
autofocus_function: Optional[Callable],
current_image_is_background_function: Optional[Callable] = None,
axial_jump_threshold: Optional[float] = 0.4
):
"""Set up management of axial motion.
The `FocusManager` keeps track of previous positions where the
microscope was in focus, and will estimate the best Z value for
future XY positions.
Arguments:
* microscope: The microscope object.
* initial_position: the XYZ position of the start of the scan
* autofocus: A function that performs an autofocus.
* current_image_is_background: a function that returns `True`
if the microscope is currently looking at an empty field.
* axial_jump_threshold: the maximum ratio between axial and
lateral moves. If this is not None, it will not count the
autofocus routine as successful if the focus moves more than
this ratio times the lateral move between two points. This can
help avoid focus drift due to accidentally focusing on the coverslip.
If either of the `autofocus` or `current_image_is_background`
functions are None, we will neither perform an autofocus, nor
use background estimation.
"""
self.initial_position = initial_position
self.microscope = microscope
self.autofocus_function = autofocus_function
if not autofocus_function:
logging.info("Setting up FocusManager with autofocus disabled.")
self.current_image_is_background_function = current_image_is_background_function
self.axial_jump_threshold = axial_jump_threshold
self.focused_positions = []
def record_focused_point(self, position: XyzCoordinate):
"""Add a position to the list of successfully-focused points"""
self.focused_positions.append(position)
def closest_focused_point(self, position: XyCoordinate) -> XyzCoordinate:
"""The closest point in our list of focused points to a given XY position."""
return closest_point_in_xy(position, self.focused_positions)
def estimate_z(self, position: XyCoordinate) -> int:
"""Estimate the z position most likely to be in focus at an XY point
The next z position is estimated based on the closest point that was in focus.
For a snake/spiral scan, this should always be the last point, unless
it's skipped for some reason. In a raster scan, this should be the last
point, except when we're at the start of a row when it will be the first
point of the preceding row.
It is possible that for some scan geometries, we won't be using the most
recent point (e.g. if X and Y spacing in a raster scan are very different).
This does not happen with the default settings used for raster scanning
clinical samples.
"""
closest_focused_point = self.closest_focused_point(position)
if closest_focused_point:
return closest_focused_point[2]
else:
return self.initial_position[2]
def check_for_axial_jumps(self, position: XyzCoordinate) -> bool:
"""Check if a position is inconsistent with previous positions.
This function returns `True` if the specified position is not consistent
with the list of previously-visited positions, i.e. it's made a big axial
move but a small lateral move.
The threshold is set by `self.axial_jump_threshold`, and if that is `None`
no check is performed. Sensible values are probably between 0.1 and 0.5.
"""
if not self.axial_jump_threshold:
return False
closest_focused_point = self.closest_focused_point(position)
move = np.array(position) - np.array(closest_focused_point)
lateral_move = np.sqrt(np.sum(move[:2]**2))
axial_move = np.abs(move[2])
return axial_move > lateral_move * self.axial_jump_threshold
def autofocus(self):
"""Perform an autofocus routine.
If autofocus is disabled, nothing happens here. If it is enabled, we optionally
check whether there's anything in the image to focus on, and then run the autofocus
routine.
"""
if not self.autofocus_function:
logging.debug("Autofocus is disabled, skipping autofocus.")
return
if self.current_image_is_background_function:
# If it's been set, call the background detect function and skip
# autofocus if appropriate
if self.current_image_is_background_function():
here = self.microscope.stage.position
logging.info(f"Detected an empty field at {here}, skipping autofocus.")
return
# Assuming it's not disabled, and we're not skipping it, actually run the autofocus now
self.autofocus_function()
# Now, check for big jumps and record the new position if we've not made a big jump
here = self.microscope.stage.position
if self.check_for_axial_jumps(here):
logging.warning(
f"During a scan, there was a large axial jump from "
f"{self.closest_focused_point(here[:2])}"
f" to {here}. This may mean autofocus has failed."
)
else:
# If there has not been a jump in focus, record the point as successful
self.record_focused_point(here)
def construct_grid(
initial: XyCoordinate,
@ -94,6 +225,23 @@ def construct_grid(
return arr
def construct_grid_1d(
initial: XyCoordinate,
step_sizes: XyCoordinate,
n_steps: XyCoordinate,
style: Literal["raster", "snake", "spiral"] = "raster",
) -> List[XyCoordinate]:
"""Construct coordinates for a scan, returning a 1D list.
This is the same set of coordinates returned by `construct_grid`
but the list-of-lists is flattened to a simple list.
"""
path = []
grid = construct_grid(initial=initial, step_sizes=step_sizes, n_steps=n_steps, style=style)
for line in grid:
path += line # concatenate the lists
return path
def closest_point_in_xy(
current_position: XyCoordinate, points: List[XyzCoordinate]
@ -181,6 +329,51 @@ class ScanExtension(BaseExtension):
logging.info(progress)
return progress
def get_autofocus_function(self, dz: int, use_fast_autofocus: bool) -> Callable:
"""Return a function that will perform an autofocus routine.
This should be called at the start of a scan. It will check that
the necessary hardware and software are present, and raise a helpful
error if they are not.
"""
microscope = find_component("org.openflexure.microscope")
# Locate the autofocus extension
autofocus_extension = find_extension("org.openflexure.autofocus")
if not autofocus_extension:
raise RuntimeError("The Autofocus extension is missing: select 'None' as your autofocus type to scan without autofocusing.")
if not (microscope.has_real_stage() and microscope.has_real_camera()):
raise RuntimeError("A real stage and camera are needed in order to autofocus. You can still run a scan without autofocus.")
if use_fast_autofocus:
def autofocus():
# Run fast autofocus. Client should provide dz ~ 2000
autofocus_extension.fast_autofocus(microscope, dz=dz)
time.sleep(0.5)
return autofocus
else:
def autofocus():
# Run slow autofocus. Client should provide dz ~ 50
autofocus_extension.autofocus(
microscope,
range(-3 * dz, 4 * dz, dz),
)
time.sleep(0.5)
return autofocus
def get_background_detect_function(self):
"""Return a function that returns true if we are looking at background"""
# Check for the background detect extension, raise an error now if it's missing.
background_detect_extension = find_extension("org.openflexure.background-detect")
if not background_detect_extension:
raise RuntimeError(
"Detecting background fields requires the background detect extension and it was not found."
)
def current_image_is_background():
verdict = background_detect_extension.grab_and_classify_image()
logging.debug(f"Background detection verdict: {verdict}")
return verdict["classification"] == "background"
return current_image_is_background
### Scanning
def tile(
self,
@ -209,15 +402,13 @@ class ScanExtension(BaseExtension):
# Store initial position
initial_position = microscope.stage.position
# Construct an x-y grid (worry about z later)
x_y_grid = construct_grid(
# Construct an x-y scan path (list of 2D coordinates)
path = construct_grid_1d(
initial_position[:2], stride_size[:2], grid[:2], style=style
) # This is a list of lists.
)
# Keep task progress
# NB the number of points is found from the number of elements in
# x_y_grid; this is not guaranteed to be the same in every row.
self._images_to_be_captured = sum([len(line) for line in x_y_grid])
self._images_to_be_captured = len(path)
self._images_captured_so_far = 0
# Generate a basename if none given
@ -236,134 +427,68 @@ class ScanExtension(BaseExtension):
"autofocusDz": autofocus_dz,
}
# Check if autofocus is enabled
autofocus_extension = find_extension("org.openflexure.autofocus")
if (
autofocus_dz
and autofocus_extension
and microscope.has_real_stage()
and microscope.has_real_camera()
):
autofocus_enabled = True
# Perform set-up to be able to autofocus, if needed
if autofocus_dz:
autofocus = self.get_autofocus_function(dz=autofocus_dz, use_fast_autofocus=fast_autofocus)
else:
autofocus_enabled = False
autofocus = None
if detect_empty_fields_and_skip_autofocus:
# Check for the background detect extension if we need it, raise an error now if it's missing.
background_detect_extension = find_extension(
"org.openflexure.background-detect"
)
if not background_detect_extension:
raise RuntimeError(
"Detecting background fields requires the background detect extension and it was not found."
)
focused_positions: List[
XyzCoordinate
] = [] # Positions where we found sharp images
current_image_is_background = self.get_background_detect_function()
else:
current_image_is_background = None
focus_manager = FocusManager(
microscope,
initial_position,
autofocus_function = autofocus,
current_image_is_background_function = current_image_is_background,
axial_jump_threshold = 0.4 if detect_empty_fields_and_skip_autofocus else None
)
# Now step through each point in the x-y coordinate array
for line in x_y_grid:
for x_y in line:
# Set the next z position based on the closest point that was in focus.
# For a snake/spiral scan, this should always be the last point, unless
# it's skipped for some reason. In a raster scan, this should be the last
# point, except when we're at the start of a row when it will be the first
# point of the preceding row.
closest_focused_point = closest_point_in_xy(x_y, focused_positions)
next_z = (
closest_focused_point[2]
if closest_focused_point
else initial_position[2]
for x_y in path:
next_z = focus_manager.estimate_z(x_y)
# Move to new grid position
logging.debug("Moving to step %s", ([x_y[0], x_y[1], next_z]))
microscope.stage.move_abs((x_y[0], x_y[1], next_z))
# Autofocus (if requested)
focus_manager.autofocus()
# If we're not doing a z-stack, just capture
if grid[2] <= 1:
self.capture(
microscope,
basename,
namemode=namemode,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
dataset=dataset_d,
annotations=annotations,
tags=tags,
)
# Move to new grid position
logging.debug("Moving to step %s", ([x_y[0], x_y[1], next_z]))
microscope.stage.move_abs((x_y[0], x_y[1], next_z))
# Check if the current field looks empty, and skip autofocus if it does
skip_autofocus = False
if detect_empty_fields_and_skip_autofocus:
verdict = background_detect_extension.grab_and_classify_image()
logging.debug(f"Background detection verdict: {verdict}")
if verdict["classification"] == "background":
skip_autofocus = True
logging.info(
f"Detected an empty field at {x_y}, skipping autofocus."
)
if autofocus_enabled and not skip_autofocus:
if fast_autofocus:
# Run fast autofocus. Client should provide dz ~ 2000
autofocus_extension.fast_autofocus(microscope, dz=autofocus_dz)
else:
# Run slow autofocus. Client should provide dz ~ 50
autofocus_extension.autofocus(
microscope,
range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz),
)
logging.debug("Finished autofocus")
time.sleep(1)
autofocus_accepted = True
here = microscope.stage.position
# Check if we've moved worryingly far in Z
if closest_focused_point:
lateral_move = np.sqrt(
np.sum(
(
np.array(here)[:2]
- np.array(closest_focused_point)[:2]
)
** 2
)
)
axial_move = np.abs(here[2] - closest_focused_point[2])
if axial_move > lateral_move * 0.4:
autofocus_accepted = False
logging.warning(
f"During a scan, there was a large axial jump from {closest_focused_point}"
f" to {here}. This may mean autofocus has failed."
)
# Append the (current, i.e. focused) position to the list
if autofocus_accepted:
focused_positions.append(here)
# If we're not doing a z-stack, just capture
if grid[2] <= 1:
self.capture(
microscope,
basename,
namemode=namemode,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
dataset=dataset_d,
annotations=annotations,
tags=tags,
)
# Update task progress
self._images_captured_so_far += 1
update_action_progress(self.progress())
else:
logging.debug("Entering z-stack")
self.stack(
microscope=microscope,
basename=basename,
namemode=namemode,
temporary=temporary,
step_size=stride_size[2],
steps=grid[2],
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
dataset=dataset_d,
annotations=annotations,
tags=tags,
)
if current_action() and current_action().stopped:
return
# Make sure we use our current best estimate of focus (i.e. the current position) next point
next_z = microscope.stage.position[2]
# Update task progress
self._images_captured_so_far += 1
update_action_progress(self.progress())
else:
logging.debug("Entering z-stack")
self.stack(
microscope=microscope,
basename=basename,
namemode=namemode,
temporary=temporary,
step_size=stride_size[2],
steps=grid[2],
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
dataset=dataset_d,
annotations=annotations,
tags=tags,
)
# Gracefully shut down if we have been requested to stop
if current_action() and current_action().stopped:
return
logging.debug("Returning to %s", (initial_position))
microscope.stage.move_abs(initial_position)