background detect and better focus handling

The primary change here is that there's now an option
to skip autofocus if the background detect plugin says
the current image is background.

I have also overhauled the way it picks the next Z position
based on Joe's code; instead of just using the last point, it will
pick the closest point where we have a successful autofocus
recorded.  This will usually be the last point, except for raster
scans where it neatly reproduces the behaviour of the old code
but without needing to treat it as a special case (when we jump
back to the start of a line, it will use the z position of the start
of the previous line, rather than the end of the previous line).

This does represent a minor change to previous behaviour, but
it should not break anything that isn't already broken, i.e. it might
cause slightly odd behaviour if autofocus gives random results -
but probably indistinguishable from the current behaviour.
This commit is contained in:
Richard Bowman 2022-04-06 23:15:25 +01:00
parent fe3af0fd29
commit 333b52aa9b

View file

@ -15,6 +15,7 @@ from labthings import (
)
from labthings.extensions import BaseExtension
from labthings.views import ActionView
import numpy as np
from typing_extensions import Literal
from openflexure_microscope.api.v2.views.actions.camera import FullCaptureArgs
@ -95,6 +96,30 @@ def construct_grid(
return arr
def closest_point_in_xy(current_position: XyCoordinate, points: List[XyzCoordinate]):
"""Find the closest point in a list
Given a 2D position, find the 3D position that's closest in XY and return it.
In the event of a tie, the most recent (i.e. latest in the list) is returned.
If the list is empty, we return None
"""
if len(points) < 1:
return None
points_2d = np.asarray(points)[:, :2]
squared_distances = np.sum((points_2d - current_position)**2, axis=1)
# We reverse the distances before searching, as argmin will return the first
# point in the event of there being multiple points with the same minimum,
# and we want to pick the last one.
reverse_min_index = np.argmin(squared_distances[::-1])
# of course, now we must convert the index to be the right way round
min_index = len(points) - 1 - reverse_min_index
if min_index != len(points) - 1:
logging.info(f"Using point {min_index} of {len(points)} for focus.")
return points[min_index]
### Capturing
@ -175,6 +200,7 @@ class ScanExtension(BaseExtension):
metadata: Optional[dict] = None,
annotations: Optional[Dict[str, str]] = None,
tags: Optional[List[str]] = None,
detect_empty_fields_and_skip_autofocus: bool = False
):
metadata = metadata or {}
annotations = annotations or {}
@ -182,16 +208,23 @@ class ScanExtension(BaseExtension):
start = time.time()
# Store initial position
initial_position = microscope.stage.position
# Construct an x-y grid (worry about z later)
x_y_grid = construct_grid(
initial_position[:2], stride_size[:2], grid[:2], style=style
) # This is a list of lists.
# Keep task progress
self._images_to_be_captured = reduce((lambda x, y: x * y), grid)
# 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_captured_so_far = 0
# Generate a basename if none given
if not basename:
basename = generate_basename()
# Store initial position
initial_position = microscope.stage.position
# Add dataset metadata
dataset_d = {
@ -217,32 +250,38 @@ class ScanExtension(BaseExtension):
else:
autofocus_enabled = False
# Construct an x-y grid (worry about z later)
x_y_grid = construct_grid(
initial_position[:2], stride_size[:2], grid[:2], style=style
)
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.")
# Keep the initial Z position the same as our current position
initial_z = initial_position[2]
next_z = initial_z # Save this value for use in raster scans
focused_positions: List[XyzCoordinate] = [] # Positions where we found sharp images
# Now step through each point in the x-y coordinate array
for line in x_y_grid:
# If rastering, rather than snake (or eventually spiral)
# Return focus to initial position
if style == "raster":
next_z = initial_z # Reset z position at start of each new row
logging.debug("Returning to initial z position")
microscope.stage.move_abs(
(line[0][0], line[0][1], next_z)
) # RWB: I think this line is redundant
for x_y in line:
# Move to new grid position without changing z
# 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]
# 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))
# Refocus
if autofocus_enabled:
# 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)
@ -254,6 +293,21 @@ class ScanExtension(BaseExtension):
)
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.warn(
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:
@ -373,6 +427,7 @@ class TileScanArgs(FullCaptureArgs):
stride_size = fields.List(
fields.Integer, missing=[2000, 1500, 100], example=[2000, 1500, 100]
)
detect_empty_fields_and_skip_autofocus = fields.Boolean(missing=False)
class TileScanAPI(ActionView):
@ -418,4 +473,5 @@ class TileScanAPI(ActionView):
fast_autofocus=args.get("fast_autofocus"),
annotations=args.get("annotations"),
tags=args.get("tags"),
detect_empty_fields_and_skip_autofocus=args.get("detect_empty_fields_and_skip_autofocus"),
)