openflexure-microscope-server/openflexure_microscope/api/default_extensions/scan.py
Richard Bowman 333b52aa9b 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.
2022-04-06 23:15:25 +01:00

477 lines
18 KiB
Python

import datetime
import logging
import time
import uuid
from functools import reduce
from typing import Dict, List, Optional, Tuple
import marshmallow
from labthings import (
current_action,
fields,
find_component,
find_extension,
update_action_progress,
)
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
from openflexure_microscope.captures.capture_manager import generate_basename
from openflexure_microscope.devel import abort
from openflexure_microscope.microscope import Microscope
# Type alias for convenience
XyCoordinate = Tuple[int, int]
XyzCoordinate = Tuple[int, int, int]
### Grid construction
def construct_grid(
initial: XyCoordinate,
step_sizes: XyCoordinate,
n_steps: XyCoordinate,
style: Literal["raster", "snake", "spiral"] = "raster",
):
"""
Given an initial position, step sizes, and number of steps,
construct a 2-dimensional list of scan x-y positions.
"""
arr: List[List[XyCoordinate]] = [] # 2D array of coordinates
if style == "spiral":
# deal with the centre image immediately
coord = initial
arr.append([initial])
# for spiral, n_steps is the number of shells, and so only requires n_steps[0]
for i in range(2, n_steps[0] + 1):
arr.append([]) # Append new shell holder
side_length = (2 * i) - 1
# Iteratively generate the next location to append
# Start coordinate of the shell
# We create a copy of coord so that the new value of coord doesn't depend on itself
# Otherwise we create a generator, not a tuple, which makes type checking angry
last_coordinate: XyCoordinate = coord
coord = (
last_coordinate[0] + [-1, 1][0] * step_sizes[0],
last_coordinate[1] + [-1, 1][1] * step_sizes[1],
)
for direction in ([1, 0], [0, -1], [-1, 0], [0, 1]):
for _ in range(side_length - 1):
last_coordinate = coord
coord = (
last_coordinate[0] + direction[0] * step_sizes[0],
last_coordinate[1] + direction[1] * step_sizes[1],
)
arr[i - 1].append(coord)
# If raster or snake
else:
for i in range(n_steps[0]): # x axis
arr.append([])
for j in range(n_steps[1]): # y axis
# Create a coordinate tuple
coord = (
initial[0] + [i, j][0] * step_sizes[0],
initial[1] + [i, j][1] * step_sizes[1],
)
# Append coordinate array to position grid
arr[i].append(coord)
# Style modifiers
if style == "snake":
# For each line (row) in the coordinate array
for i, line in enumerate(arr):
# If it's an odd row
if i % 2 != 0:
# Reverse the list of coordinates
line.reverse()
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
class ScanExtension(BaseExtension):
def __init__(self):
BaseExtension.__init__(self, "org.openflexure.scan", version="2.0.0")
self._images_to_be_captured: int = 1
self._images_captured_so_far: int = 0
self.add_view(TileScanAPI, "/tile", endpoint="tile")
def capture(
self,
microscope: Microscope,
basename: Optional[str],
namemode: str = "coordinates",
temporary: bool = False,
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = False,
metadata: Optional[dict] = None,
annotations: Optional[Dict[str, str]] = None,
tags: Optional[List[str]] = None,
dataset: Optional[Dict[str, str]] = None,
):
metadata = metadata or {}
annotations = annotations or {}
tags = tags or []
# Construct a tile filename
if namemode == "coordinates":
filename = "{}_{}_{}_{}".format(basename, *microscope.stage.position)
else:
filename = "{}_{}".format(
basename,
str(self._images_captured_so_far).zfill(
len(str(self._images_to_be_captured))
),
)
folder = "SCAN_{}".format(basename)
# Do capture
return microscope.capture(
filename=filename,
folder=folder,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
annotations=annotations,
tags=tags,
dataset=dataset,
metadata=metadata,
cache_key=folder,
)
def progress(self):
progress = (self._images_captured_so_far / self._images_to_be_captured) * 100
logging.info(progress)
return progress
### Scanning
def tile(
self,
microscope: Microscope,
basename: Optional[str] = None,
namemode: str = "coordinates",
temporary: bool = False,
stride_size: XyzCoordinate = (2000, 1500, 100),
grid: XyzCoordinate = (3, 3, 5),
style="raster",
autofocus_dz: int = 50,
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = False,
fast_autofocus: bool = False,
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 {}
tags = tags or []
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
# 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()
# Add dataset metadata
dataset_d = {
"id": uuid.uuid4(),
"type": "xyzScan",
"name": basename,
"acquisitionDate": datetime.datetime.now().isoformat(),
"strideSize": stride_size,
"grid": grid,
"style": style,
"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
else:
autofocus_enabled = False
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
# 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]
# 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.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:
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]
logging.debug("Returning to %s", (initial_position))
microscope.stage.move_abs(initial_position)
end = time.time()
logging.info("Scan took %s seconds", end - start)
def stack(
self,
microscope: Microscope,
basename: Optional[str] = None,
namemode: str = "coordinates",
temporary: bool = False,
step_size: int = 100,
steps: int = 5,
return_to_start: bool = True,
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = False,
metadata: Optional[dict] = None,
annotations: Optional[Dict[str, str]] = None,
dataset: Optional[Dict[str, str]] = None,
tags: Optional[List[str]] = None,
):
metadata = metadata or {}
annotations = annotations or {}
tags = tags or []
# Store initial position
initial_position = microscope.stage.position
logging.debug("Starting z-stack from position %s", microscope.stage.position)
with microscope.lock:
# Move to center scan
logging.debug("Moving to z-stack starting position")
microscope.stage.move_rel((0, 0, int((-step_size * steps) / 2)))
logging.debug("Starting scan from position %s", microscope.stage.position)
for i in range(steps):
time.sleep(0.1)
logging.debug("Capturing from position %s", microscope.stage.position)
self.capture(
microscope,
basename,
namemode=namemode,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
metadata=metadata,
annotations=annotations,
dataset=dataset,
tags=tags,
)
# Update task progress
self._images_captured_so_far += 1
update_action_progress(self.progress())
if current_action() and current_action().stopped:
return
if i != steps - 1:
logging.debug("Moving z by %s", (step_size))
microscope.stage.move_rel((0, 0, step_size))
if return_to_start:
logging.debug("Returning to %s", (initial_position))
microscope.stage.move_abs(initial_position)
class TileScanArgs(FullCaptureArgs):
namemode = fields.String(missing="coordinates", example="coordinates")
grid = fields.List(
fields.Integer(validate=marshmallow.validate.Range(min=1)),
missing=[3, 3, 3],
example=[3, 3, 3],
)
style = fields.String(missing="raster")
autofocus_dz = fields.Integer(missing=50)
fast_autofocus = fields.Boolean(missing=False)
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):
args = TileScanArgs()
# Allow 10 seconds to stop upon DELETE request
# Gives fast-autofocus time to finish if it's running
default_stop_timeout = 10
def post(self, args):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
resize = args.get("resize", None)
if resize:
if ("width" in resize) and ("height" in resize):
resize = (
int(resize["width"]),
int(resize["height"]),
) # Convert dict to tuple
else:
abort(404)
logging.info("Running tile scan...")
# Acquire microscope lock with 1s timeout
with microscope.lock(timeout=1):
# Run scan_extension_v2
return self.extension.tile(
microscope,
basename=args.get("filename"),
namemode=args.get("namemode"),
temporary=args.get("temporary"),
stride_size=args.get("stride_size"),
grid=args.get("grid"),
style=args.get("style"),
autofocus_dz=args.get("autofocus_dz"),
use_video_port=args.get("use_video_port"),
resize=resize,
bayer=args.get("bayer"),
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"),
)