Merge branch 'More-workflow-layers' into 'v3'

More workflow layers

Closes #628

See merge request openflexure/openflexure-microscope-server!469
This commit is contained in:
Joe Knapper 2026-02-12 13:49:51 +00:00
commit 52c592e35c
8 changed files with 688 additions and 331 deletions

View file

@ -19,6 +19,7 @@
}, },
"histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow",
"snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow", "snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow",
"raster_workflow": "openflexure_microscope_server.things.scan_workflows:RasterWorkflow",
"stage_measure": "openflexure_microscope_server.things.stage_measure:RangeofMotionThing", "stage_measure": "openflexure_microscope_server.things.stage_measure:RangeofMotionThing",
"bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV", "bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV",
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV" "bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"

View file

@ -14,6 +14,7 @@
}, },
"histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow",
"snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow", "snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow",
"raster_workflow": "openflexure_microscope_server.things.scan_workflows:RasterWorkflow",
"bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV", "bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV",
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV" "bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"
}, },

View file

@ -1,14 +1,20 @@
"""Functionality for planning scan routes. """Functionality for planning scan routes.
A scan route can be planned by a ScanPlanner class currently there A scan route can be planned by a ScanPlanner. There is a base class ``ScanPlanner``,
is only one type the SmartSpiral. More can be added using by and then a child class that is still generic called RectGridPlanner that helps planning
subclassing the ScanPlanner anything where the movements are on a regtangular grid. RectGridPlanner has two usable
child classes:
* SmartSpiral - For spiralling around a samples but adjusting when background is
detected
* RegularGridPlanner - For Raster and Snake scanning.
""" """
# Future annotations needed for typhinting same class in __eq__ method. Other option # Future annotations needed for typhinting same class in __eq__ method. Other option
# would be to import Union and use a string. # would be to import Union and use a string.
from __future__ import annotations from __future__ import annotations
import enum
import logging import logging
from copy import copy from copy import copy
from typing import Any, Literal, Optional, TypeAlias from typing import Any, Literal, Optional, TypeAlias
@ -23,6 +29,23 @@ XYPosList: TypeAlias = list[XYPos]
XYZPosList: TypeAlias = list[XYZPos] XYZPosList: TypeAlias = list[XYZPos]
class DistanceMetric(enum.Enum):
"""An enum for selecting distance metrics for grids.
Grid distance metrics are:
* Chebyshev (``CHEBYSHEV``) which is the larger of the number of x or y moves
in the grid.
* Manhattan (``MANHATTAN``) which is the number of moves between the two points
following the grid. Or
* Euclidean (``EUCLIDEAN``) which is the length of the direct route.
"""
CHEBYSHEV = enum.auto()
MANHATTAN = enum.auto()
EUCLIDEAN = enum.auto()
def enforce_xy_tuple(value: XYPos) -> XYPos: def enforce_xy_tuple(value: XYPos) -> XYPos:
"""Check input is a tuple and is of length 2. """Check input is a tuple and is of length 2.
@ -197,7 +220,12 @@ class ScanPlanner:
return [loc.xyz_tuple for loc in self._path_history if loc.imaged] return [loc.xyz_tuple for loc in self._path_history if loc.imaged]
@property @property
def focused_locations(self) -> XYZPosList: def focused_locations(self) -> list[VisitedScanLocation]:
"""Property to access a copy of the focused_locations."""
return [loc for loc in self._path_history if loc.focused]
@property
def focused_locations_xyz(self) -> XYZPosList:
"""Property to access a copy of the focused_locations.""" """Property to access a copy of the focused_locations."""
return [loc.xyz_tuple for loc in self._path_history if loc.focused] return [loc.xyz_tuple for loc in self._path_history if loc.focused]
@ -304,7 +332,97 @@ class ScanPlanner:
return [FutureScanLocation(location) for line in grid for location in line] return [FutureScanLocation(location) for line in grid for location in line]
class SmartSpiral(ScanPlanner): class RectGridPlanner(ScanPlanner):
"""Base class for planners that operate on a rectangular grid."""
_dx: int = 0
_dy: int = 0
def _parse(self, planner_settings: Optional[dict] = None) -> None:
expected_keys = ["dx", "dy"]
invalid_msg = "RectGridPlanner requires planner_settings with keys: "
if not planner_settings or not all(
k in planner_settings for k in expected_keys
):
raise KeyError(invalid_msg + ",".join(expected_keys))
self._dx = int(planner_settings["dx"])
self._dy = int(planner_settings["dy"])
def _adjacent_positions(self, xy_pos: XYPos) -> XYPosList:
return [
(xy_pos[0] - self._dx, xy_pos[1]),
(xy_pos[0] + self._dx, xy_pos[1]),
(xy_pos[0], xy_pos[1] - self._dy),
(xy_pos[0], xy_pos[1] + self._dy),
]
def moves_between(
self,
starting_pos: XYPos | np.ndarray | FutureScanLocation | VisitedScanLocation,
ending_pos: XYPos | np.ndarray | FutureScanLocation | VisitedScanLocation,
metric: DistanceMetric,
) -> float:
"""Return displacement in grid-move units as a numpy array [dx_moves, dy_moves].
:param starting_pos: the position to measure from
:param ending_pos: the position to measure to
:param metric: How the distance is calculated. See `DistanceMetric`
"""
if isinstance(starting_pos, (FutureScanLocation, VisitedScanLocation)):
starting_pos = starting_pos.xy_tuple
if isinstance(ending_pos, (FutureScanLocation, VisitedScanLocation)):
ending_pos = ending_pos.xy_tuple
move_size = np.array([self._dx, self._dy], dtype="float64")
starting_pos = np.array(starting_pos, dtype="float64")
ending_pos = np.array(ending_pos, dtype="float64")
displacement = (ending_pos - starting_pos) / move_size
if metric == DistanceMetric.CHEBYSHEV:
return float(np.max(np.abs(displacement)))
if metric == DistanceMetric.MANHATTAN:
return float(np.sum(np.abs(displacement)))
return float(np.linalg.norm(displacement))
def _intermediate_position(self, xy_pos1: XYPos, xy_pos2: XYPos) -> XYPos:
"""Return an (x,y) position halfway between two input positions."""
x = (xy_pos1[0] + xy_pos2[0]) // 2
y = (xy_pos1[1] + xy_pos2[1]) // 2
return (x, y)
def select_nearby_focus_site(self, next_position: XYPos) -> Optional[XYZPos]:
"""Return a focused site near the given position to estimate Z for the next move.
Looks for all previously focused locations that are within the scan
step size (self._dx, self._dy) of ``next_position``. Among these nearby focused
sites, it returns the most recently imaged one.
This is suitable for raster or snake scans, where the scan may move along a row
or column and then jump to a new row/column. If no nearby focused sites exist,
returns None.
:param next_position: The XY position where the next image will be taken.
:return: The XYZ tuple of the closest and most recent focused site, or None if
no focused locations exist.
"""
focused_locations = self.focused_locations
if not focused_locations:
return None
def sort_key(pos: VisitedScanLocation) -> float:
return self.moves_between(next_position, pos, DistanceMetric.MANHATTAN)
# Sort by the total number of dx and dy moves between sites, then by most
# recent. Using reverse=True puts the most recent, nearest at the end
nearby_focus_locations = sorted(focused_locations, key=sort_key, reverse=True)
# Pick the most recent nearby site
return nearby_focus_locations[-1].xyz_tuple
class SmartSpiral(RectGridPlanner):
"""A scan planner that spirals outward from the centre, prioritising short moves. """A scan planner that spirals outward from the centre, prioritising short moves.
This planner spirals out from the centre, but prioritises short moves over rigidly This planner spirals out from the centre, but prioritises short moves over rigidly
@ -321,20 +439,9 @@ class SmartSpiral(ScanPlanner):
dx and dy. dx and dy.
""" """
# The maximum distance for the scan to run in any direction.
# Any future moves which would move beyond this distance are not appended.
_max_dist: int = 0 _max_dist: int = 0
_dx: int = 0
_dy: int = 0
def __init__(
self, initial_position: XYPos, planner_settings: Optional[dict] = None
) -> None:
"""Set up the lists inherited from ScanPlanner, plus a distance cutoff.
Use the supplied _dx and _dy to set a distance cutoff for an image to be
considered neighbouring another
"""
super().__init__(initial_position, planner_settings)
self._distance_cutoff: float = max([self._dx, self._dy]) * 1.1
def _is_primary_location( def _is_primary_location(
self, location: FutureScanLocation | VisitedScanLocation self, location: FutureScanLocation | VisitedScanLocation
@ -352,21 +459,11 @@ class SmartSpiral(ScanPlanner):
] ]
def _parse(self, planner_settings: Optional[dict] = None) -> None: def _parse(self, planner_settings: Optional[dict] = None) -> None:
"""Parse SmartSpiral Settings dictionary. super()._parse(planner_settings)
* ``dx`` - the movement size in x if not planner_settings or "max_dist" not in planner_settings:
* ``dy`` - the movement size in y raise KeyError("SmartSpiral requires max_dist")
* ``max_dist`` - The maximum distance to a location can be from the centre.
"""
expected_keys = ["max_dist", "dx", "dy"]
invalid_msg = "SmartSpiral requires a planner_settings dictionary with keys: "
if not planner_settings:
raise ValueError(invalid_msg + ",".join(expected_keys))
if not all(keys in planner_settings for keys in expected_keys):
raise KeyError(invalid_msg + ",".join(expected_keys))
self._dx = int(planner_settings["dx"])
self._dy = int(planner_settings["dy"])
self._max_dist = int(planner_settings["max_dist"]) self._max_dist = int(planner_settings["max_dist"])
def _initial_location_list(self) -> list[FutureScanLocation]: def _initial_location_list(self) -> list[FutureScanLocation]:
@ -472,21 +569,6 @@ class SmartSpiral(ScanPlanner):
# imaged points. # imaged points.
self._remaining_locations.append(i_loc) self._remaining_locations.append(i_loc)
def _adjacent_positions(self, xy_pos: XYPos) -> XYPosList:
"""Return 4 points +/-dx and +/-dy from the input location."""
return [
(xy_pos[0] - self._dx, xy_pos[1]),
(xy_pos[0] + self._dx, xy_pos[1]),
(xy_pos[0], xy_pos[1] - self._dy),
(xy_pos[0], xy_pos[1] + self._dy),
]
def _intermediate_position(self, xy_pos1: XYPos, xy_pos2: XYPos) -> XYPos:
"""Return an (x,y) position halfway between two input positions."""
x = (xy_pos1[0] + xy_pos2[0]) // 2
y = (xy_pos1[1] + xy_pos2[1]) // 2
return (x, y)
def _re_sort_remaining_locations(self, current_pos: XYPos) -> None: def _re_sort_remaining_locations(self, current_pos: XYPos) -> None:
"""Sort the remaining positions based on the current location.""" """Sort the remaining positions based on the current location."""
@ -494,8 +576,10 @@ class SmartSpiral(ScanPlanner):
def sort_key(pos: FutureScanLocation) -> tuple[bool, float, float, float]: def sort_key(pos: FutureScanLocation) -> tuple[bool, float, float, float]:
return ( return (
self._is_primary_location(pos), # False sorts low self._is_primary_location(pos), # False sorts low
self.moves_between(current_pos, pos), self.moves_between(current_pos, pos, DistanceMetric.CHEBYSHEV),
self.moves_between(self._initial_position, pos), self.moves_between(
self._initial_position, pos, DistanceMetric.CHEBYSHEV
),
distance_between(current_pos, pos), distance_between(current_pos, pos),
) )
@ -514,110 +598,72 @@ class SmartSpiral(ScanPlanner):
Returns None if no focused locations are present Returns None if no focused locations are present
""" """
# save to variable rather than search for focussed sites each time. # save to variable rather than search for focussed sites each time.
focused_locations = self.focused_locations focused_locations = self.focused_locations_xyz
if not focused_locations: if not focused_locations:
return None return None
# must be float64 (double precision) to deal with the huge numbers involved! # must be float64 to deal with large coordinates
current_pos = np.array(xy_pos, dtype="float64") current_pos = np.array(xy_pos, dtype="float64")
path_pos = np.array(focused_locations, dtype="float64")[:, :2] focused_arr = np.array(focused_locations, dtype="float64")
# Use linalg.norm to calculate the direct distance between the points # Find focused sites within dx and dy
# Note linalg.norm always uses float64 dx_ok = np.abs(focused_arr[:, 0] - current_pos[0]) <= self._dx
dists = np.linalg.norm((path_pos - current_pos), axis=1) dy_ok = np.abs(focused_arr[:, 1] - current_pos[1]) <= self._dy
nearby_indices = np.where(dx_ok & dy_ok)[0]
# Get indices of all focused sites within distance_cutoff. # If no neighbouring sites were focused, choose the closest
# Note np.where always returns a tuple of arrays, hence the trailing [0] if len(nearby_indices) == 0:
indices = np.where(dists <= self._distance_cutoff)[0] deltas = focused_arr[:, :2] - current_pos
dists = np.linalg.norm(deltas, axis=1)
min_dist = np.min(dists)
nearby_indices = np.where(dists == min_dist)[0]
# Handle the case that no focused positions are within this range, and nearby_sites = focused_arr[nearby_indices]
# instead use the nearest focused position. This will always return a
# height, due to the check that self._focused_locations exists.
if len(indices) == 0:
distance_cutoff = min(dists)
indices = np.where(dists <= distance_cutoff)[0]
# Turning into an array allows slicing based on a list # Choose the lowest z
focused_locations_array = np.array(focused_locations) min_z = np.min(nearby_sites[:, 2])
# Choose the lowest (smallest z) of the neighbouring sites. Smart stack works best # Among those with min z, choose the most recent
# if started too low, so the lowest z will perform best chosen_site = nearby_sites[nearby_sites[:, 2] == min_z][-1]
candidates = focused_locations_array[indices]
min_z = np.min(candidates[:, -1])
# Find all with the minimum z, and select the latest return (
chosen_focused_site = candidates[candidates[:, -1] == min_z][-1] int(chosen_site[0]),
int(chosen_site[1]),
# Convert back into list so values are of type int instead of np.int32 int(chosen_site[2]),
return tuple(chosen_focused_site.tolist()) )
def moves_between(
self,
starting_pos: XYPos | np.ndarray | FutureScanLocation,
ending_pos: XYPos | np.ndarray | FutureScanLocation,
) -> float:
"""Return the larger of x moves or y moves between two xy positions.
:param starting_pos: the position to measure from
:param ending_pos: the position to measure to
"""
if isinstance(starting_pos, FutureScanLocation):
starting_pos = starting_pos.xy_tuple
if isinstance(ending_pos, FutureScanLocation):
ending_pos = ending_pos.xy_tuple
move_size = np.array([self._dx, self._dy])
starting_pos = np.array(starting_pos, dtype="float64")
ending_pos = np.array(ending_pos, dtype="float64")
displacement_in_moves = (ending_pos - starting_pos) / move_size
return np.max(np.abs(displacement_in_moves))
class SnakeScan(ScanPlanner): class RegularGridPlanner(RectGridPlanner):
"""A scan planner that performs a snake scan, right and down from a corner. """A scan planner that performs a snake or a raster scan.
Direction cannot yet be set it always scans, right and down from a corner.
This planner starts at the corner of the region to scan, snaking back and forth, This planner starts at the corner of the region to scan, snaking back and forth,
starting moving right and down (assuming positive dx and dy.) starting moving right and down (assuming positive dx and dy.)
""" """
_dx: int = 0
_dy: int = 0
_x_count: int = 0 _x_count: int = 0
_y_count: int = 0 _y_count: int = 0
_style: Literal["snake", "raster"]
def __init__(
self, initial_position: XYPos, planner_settings: Optional[dict] = None
) -> None:
"""Set up the lists inherited from ScanPlanner, plus a distance cutoff.
Use the supplied _dx and _dy to set a distance cutoff for an image to be
considered neighbouring another
"""
super().__init__(initial_position, planner_settings)
self._distance_cutoff: float = max([self._dx, self._dy]) * 1.1
def _parse(self, planner_settings: Optional[dict] = None) -> None: def _parse(self, planner_settings: Optional[dict] = None) -> None:
"""Parse SnakeScan Settings dictionary. super()._parse(planner_settings)
* ``dx`` - the movement size in x expected_keys = ["x_count", "y_count", "style"]
* ``dy`` - the movement size in y invalid_msg = "RegularGrid requires planner_settings with keys: "
* ``x_count`` - The number of columns in the scan. if not planner_settings or not all(
* ``y_count`` - The number of rows in the scan. k in planner_settings for k in expected_keys
""" ):
expected_keys = ["x_count", "y_count", "dx", "dy"]
invalid_msg = "SnakeScan requires a planner_settings dictionary with keys: "
if not planner_settings:
raise ValueError(invalid_msg + ",".join(expected_keys))
if not all(keys in planner_settings for keys in expected_keys):
raise KeyError(invalid_msg + ",".join(expected_keys)) raise KeyError(invalid_msg + ",".join(expected_keys))
self._dx = int(planner_settings["dx"])
self._dy = int(planner_settings["dy"])
self._x_count = int(planner_settings["x_count"]) self._x_count = int(planner_settings["x_count"])
self._y_count = int(planner_settings["y_count"]) self._y_count = int(planner_settings["y_count"])
style = planner_settings["style"]
if style not in ("snake", "raster"):
raise ValueError(
f"Unknown regular grid style {style}. Use snake or raster."
)
self._style = style
def _initial_location_list(self) -> list[FutureScanLocation]: def _initial_location_list(self) -> list[FutureScanLocation]:
"""Set the initial list of locations for this scan planner. """Set the initial list of locations for this scan planner.
@ -632,20 +678,11 @@ class SnakeScan(ScanPlanner):
y_count=self._y_count, y_count=self._y_count,
dx=self._dx, dx=self._dx,
dy=self._dy, dy=self._dy,
style="snake", style=self._style,
) )
return self._grid_to_future_locations(grid) return self._grid_to_future_locations(grid)
# The noqa statement is because next_position is unused but is needed for equivalence
# with other workflows that require the next pos to select a neighbour.
def select_nearby_focus_site(self, next_position: XYPos) -> Optional[XYZPos]: # noqa: ARG002
"""For a snake scan, use the most recent focused site to predict focus."""
focused_locations = self.focused_locations
if not focused_locations:
return None
return focused_locations[-1]
def distance_between( def distance_between(
current_pos: XYPos | np.ndarray | FutureScanLocation, current_pos: XYPos | np.ndarray | FutureScanLocation,

View file

@ -9,6 +9,7 @@ from __future__ import annotations
import os import os
from typing import ( from typing import (
Generic, Generic,
Literal,
Mapping, Mapping,
Optional, Optional,
TypeVar, TypeVar,
@ -19,9 +20,9 @@ from pydantic import BaseModel
import labthings_fastapi as lt import labthings_fastapi as lt
from openflexure_microscope_server.scan_planners import ( from openflexure_microscope_server.scan_planners import (
RegularGridPlanner,
ScanPlanner, ScanPlanner,
SmartSpiral, SmartSpiral,
SnakeScan,
) )
from openflexure_microscope_server.stitching import ( from openflexure_microscope_server.stitching import (
STITCHING_RESOLUTION, STITCHING_RESOLUTION,
@ -67,6 +68,10 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
# CSM may not be set, and isn't required for a workflow. Allow for it to exist or be None # CSM may not be set, and isn't required for a workflow. Allow for it to exist or be None
_csm: Optional[CameraStageMapper] = lt.thing_slot() _csm: Optional[CameraStageMapper] = lt.thing_slot()
# Camera, stage and autofocus are all required by any scan workflow
_cam: BaseCamera = lt.thing_slot()
_stage: BaseStage = lt.thing_slot()
_autofocus: AutofocusThing = lt.thing_slot()
def check_before_start(self, scan_name: str) -> None: def check_before_start(self, scan_name: str) -> None:
"""Check before the scan starts. Throw an error if the scan shouldn't start. """Check before the scan starts. Throw an error if the scan shouldn't start.
@ -116,11 +121,44 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
def acquisition_routine( def acquisition_routine(
self, settings: SettingModelType, xyz_pos: tuple[int, int, int] self, settings: SettingModelType, xyz_pos: tuple[int, int, int]
) -> tuple[bool, Optional[int]]: ) -> tuple[bool, Optional[int]]:
"""Overload to set the acquisition routine that happens at each scan site.""" """Overload to set the acquisition routine that happens at each scan site.
:param settings: The settings for this scan, which should be a SettingModelType
:param xyz_pos: The current position as a tuple or 3 ints.
:return: A tuple of whether an image was taken, and the z-position for focus.
If failed to find focus, returns for the focus z-position.
"""
raise NotImplementedError( raise NotImplementedError(
"Each specific ScanWorkflow must implement an acquisition routine" "Each specific ScanWorkflow must implement an acquisition routine"
) )
def _autofocus_and_capture(
self,
xyz_pos: tuple[int, int, int],
dz: int,
images_dir: str,
save_resolution: tuple[int, int],
) -> tuple[bool, Optional[int]]:
"""Autofocus and then capture, this can be used as an acquisition routine.
:param dz: The dz for autofocus.
:param images_dir: The path to the directory for saving images..
:param save_resolution: The resolution to save images at.
:return: A tuple ready to pass out of acquisition routine. In this method,
image is always taken, so first return is True.
"""
self._autofocus.fast_autofocus(dz=dz)
focus_height = self._stage.get_xyz_position()[2]
filename = f"img_{xyz_pos[0]}_{xyz_pos[1]}_{focus_height}.jpeg"
self._cam.capture_and_save(
jpeg_path=os.path.join(images_dir, filename),
save_resolution=save_resolution,
)
return True, focus_height
@lt.property @lt.property
def settings_ui(self) -> list[PropertyControl]: def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab.""" """A list of PropertyControl objects to create the settings in the scan tab."""
@ -128,13 +166,35 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
"Each scan workflow must implement a settings_ui method." "Each scan workflow must implement a settings_ui method."
) )
def _require_csm(self) -> CameraStageMapper:
"""Give each model the option to require CSM. Return it if present.""" class RectGridWorkflow(ScanWorkflow[SettingModelType], Generic[SettingModelType]):
if self._csm is None: """A generic workflow for any scan that captures images on a rectilinear grid."""
raise RuntimeError(
"CameraStageMapping not set, and is required for this workflow." # Redefine _csm Thing Slot, as CSM is required for any RectGridWorkflow
) _csm: CameraStageMapper = lt.thing_slot()
return self._csm
overlap: float = lt.setting(default=0.45, ge=0.1, le=0.7)
"""The fraction that adjacent images should overlap in x and y.
This must be between 0.1 and 0.7.
"""
autofocus_dz: int = lt.setting(default=1000, ge=200, le=2000)
"""The z distance to perform an autofocus in steps.
Must be greater than or equal to 200, and less than or equal to 2000.
"""
# The noqa statement is because scan_name is unused but is needed for equivalence
# with other workflows that may want to validate the scan name.
def check_before_start(self, scan_name: str) -> None: # noqa: ARG002
"""Before starting a scan, check that camera-stage-mapping is set.
Raise error if:
- camera stage mapping is not set
"""
if self._csm.calibration_required:
raise RuntimeError("Camera Stage Mapping is not calibrated.")
def _calc_displacement_from_overlap(self, overlap: float) -> tuple[int, int]: def _calc_displacement_from_overlap(self, overlap: float) -> tuple[int, int]:
"""Use camera stage mapping to calculate x and y displacement from given overlap. """Use camera stage mapping to calculate x and y displacement from given overlap.
@ -146,26 +206,41 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
:raises RuntimeError: If there is no camera stage mapper Thing available or if CMS isn't calibrated. :raises RuntimeError: If there is no camera stage mapper Thing available or if CMS isn't calibrated.
""" """
csm = self._require_csm() csm_image_res = self._csm.image_resolution
csm_image_res = csm.image_resolution
if csm_image_res is None: if csm_image_res is None:
raise RuntimeError("CSM not set. Scan shouldn't have progresses this far.") raise RuntimeError("CSM not set. Scan shouldn't have progressed this far.")
# Calculate displacements in image coordinates # Calculate displacements in image coordinates
dx_img = csm_image_res[1] * (1 - overlap) dx_img = csm_image_res[1] * (1 - overlap)
dy_img = csm_image_res[0] * (1 - overlap) dy_img = csm_image_res[0] * (1 - overlap)
x_move_stage = csm.convert_image_to_stage_coordinates(x=dx_img, y=0) x_move_stage = self._csm.convert_image_to_stage_coordinates(x=dx_img, y=0)
y_move_stage = csm.convert_image_to_stage_coordinates(x=0, y=dy_img) y_move_stage = self._csm.convert_image_to_stage_coordinates(x=0, y=dy_img)
# Assume no rotation or skew and take only the aligned axis of vector. # Assume no rotation or skew and take only the aligned axis of vector.
# Coerce to positive integer, but correct if x and y are flipped # Coerce to positive integer, but correct if x and y are flipped
if abs(x_move_stage["x"]) > abs(x_move_stage["y"]): if abs(x_move_stage["x"]) > abs(x_move_stage["y"]):
return x_move_stage["x"], y_move_stage["y"] return x_move_stage["x"], y_move_stage["y"]
# If not use the other stage axes. Note "dx" will be the movement in camera y. # If not use the other stage axes. Note "dx" will be the movement in camera y.
self.logger.info(
f"Based on an overlap of {self.overlap}, the stage will make steps of "
f"{y_move_stage['x']}, {x_move_stage['y']}"
)
return y_move_stage["x"], x_move_stage["y"] return y_move_stage["x"], x_move_stage["y"]
def _get_stitching_settings_model(self) -> StitchingSettings:
"""Return a stitching settings model based on current settings."""
return StitchingSettings(
overlap=self.overlap,
correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0],
)
@lt.property
def ready(self) -> bool:
"""Whether this scanworkflow is ready to start."""
return not self._csm.calibration_required
class HistoScanSettingsModel(BaseModel): class HistoScanSettingsModel(BaseModel):
"""The settings for a scan with the HistoScanWorkflow. """The settings for a scan with the HistoScanWorkflow.
@ -175,14 +250,14 @@ class HistoScanSettingsModel(BaseModel):
""" """
overlap: float overlap: float
max_dist: int
dx: int dx: int
dy: int dy: int
max_dist: int
skip_background: bool skip_background: bool
smart_stack_params: SmartStackParams smart_stack_params: SmartStackParams
class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
"""A workflow optimised for scanning Histopathology samples. """A workflow optimised for scanning Histopathology samples.
This workflow automatically plans its own path around a sample spiralling out from This workflow automatically plans its own path around a sample spiralling out from
@ -200,12 +275,9 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
) )
_settings_model = HistoScanSettingsModel _settings_model = HistoScanSettingsModel
_planner_cls: type[ScanPlanner] = SmartSpiral _planner_cls = SmartSpiral
# Thing Slots # Thing Slots
_background_detector: ChannelDeviationLUV = lt.thing_slot() _background_detector: ChannelDeviationLUV = lt.thing_slot()
_cam: BaseCamera = lt.thing_slot()
_csm: CameraStageMapper = lt.thing_slot()
_autofocus: AutofocusThing = lt.thing_slot()
# Scan settings # Scan settings
@ -215,21 +287,9 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
This uses the settings from the ``BackgroundDetectThing``. This uses the settings from the ``BackgroundDetectThing``.
""" """
autofocus_dz: int = lt.setting(default=1000, ge=200, le=2000)
"""The z distance to perform an autofocus in steps.
Must be greater than or equal to 200, and less than or equal to 2000.
"""
max_range: int = lt.setting(default=45000) max_range: int = lt.setting(default=45000)
"""The maximum distance in steps from the centre of the scan.""" """The maximum distance in steps from the centre of the scan."""
overlap: float = lt.setting(default=0.45, ge=0.1, le=0.7)
"""The fraction that adjacent images should overlap in x or y.
This must be between 0.1 and 0.7.
"""
# Stacking settings # Stacking settings
stack_images_to_save: int = lt.setting(default=1) stack_images_to_save: int = lt.setting(default=1)
@ -304,16 +364,8 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
:return: A tuple containing the settings model for this workflow and the :return: A tuple containing the settings model for this workflow and the
settings model for stitching. settings model for stitching.
""" """
stitching_settings = StitchingSettings( stitching_settings = self._get_stitching_settings_model()
overlap=self.overlap,
correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0],
)
dx, dy = self._calc_displacement_from_overlap(self.overlap) dx, dy = self._calc_displacement_from_overlap(self.overlap)
self.logger.info(
f"Based on an overlap of {self.overlap}, the stage will make steps of "
f"{dx}, {dy}"
)
smart_stack_params = self.create_smart_stack_params( smart_stack_params = self.create_smart_stack_params(
images_dir=images_dir, images_dir=images_dir,
@ -484,9 +536,6 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
"""A list of PropertyControl objects to create the settings in the scan tab.""" """A list of PropertyControl objects to create the settings in the scan tab."""
return [ return [
property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"), property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"),
property_control_for(
self, "skip_background", label="Detect and Skip Empty Fields "
),
property_control_for( property_control_for(
self, "stack_images_to_save", label="Images in Stack to Save" self, "stack_images_to_save", label="Images in Stack to Save"
), ),
@ -498,11 +547,14 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
property_control_for(self, "stack_dz", label="Stack dz (steps)"), property_control_for(self, "stack_dz", label="Stack dz (steps)"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"), property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
property_control_for(self, "max_range", label="Maximum Distance (steps)"), property_control_for(self, "max_range", label="Maximum Distance (steps)"),
property_control_for(
self, "skip_background", label="Detect and Skip Empty Fields "
),
] ]
class SnakeSettingsModel(BaseModel): class RegularGridSettingsModel(BaseModel):
"""The settings for a scan with the SnakeWorkflow. """The settings for a scan with a regular grid of dx and dy for x_count, y_count steps.
This includes settings calculated when starting. This will be held by smart scan This includes settings calculated when starting. This will be held by smart scan
during a scan and serialised to disk. during a scan and serialised to disk.
@ -513,12 +565,108 @@ class SnakeSettingsModel(BaseModel):
dy: int dy: int
x_count: int x_count: int
y_count: int y_count: int
style: Literal["snake", "raster"]
images_dir: str images_dir: str
autofocus_dz: int autofocus_dz: int
save_resolution: tuple[int, int] save_resolution: tuple[int, int]
class SnakeWorkflow(ScanWorkflow[SnakeSettingsModel]): class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
"""A base workflow for any workflow that uses a regular rectangular grid."""
x_count: int = lt.setting(default=3, ge=1)
"""The number of columns in the scan."""
y_count: int = lt.setting(default=2, ge=1)
"""The number of rows in the scan."""
_settings_model = RegularGridSettingsModel
_planner_cls = RegularGridPlanner
_grid_style: Literal["snake", "raster"]
def all_settings(
self, images_dir: str
) -> tuple[RegularGridSettingsModel, Optional[StitchingSettings]]:
"""Return the workflow and stitching settings.
:param images_dir: The directory that images are to be written to.
:return: A tuple containing the settings model for this workflow and the
settings model for stitching.
"""
stitching_settings = self._get_stitching_settings_model()
dx, dy = self._calc_displacement_from_overlap(self.overlap)
scan_settings = self._settings_model(
overlap=self.overlap,
dx=dx,
dy=dy,
x_count=self.x_count,
y_count=self.y_count,
style=self._grid_style,
images_dir=images_dir,
autofocus_dz=self.autofocus_dz,
save_resolution=self.save_resolution,
)
return scan_settings, stitching_settings
def pre_scan_routine(self, settings: RegularGridSettingsModel) -> None:
"""Perform these steps before starting the scan.
In this case, only autofocus.
:param settings: The settings for this scan as as the relevant SettingsModel type.
"""
self._autofocus.looping_autofocus(dz=settings.autofocus_dz, start="centre")
def new_scan_planner(
self, settings: RegularGridSettingsModel, position: Mapping[str, int]
) -> ScanPlanner:
"""Return a new scan planner object.
:param settings: The settings for this scan as a SnakeSettingsModel
:param position: The starting position as a mapping of axes names to int.
"""
planner_settings = {
"dx": settings.dx,
"dy": settings.dy,
"x_count": settings.x_count,
"y_count": settings.y_count,
"style": settings.style,
}
return self._planner_cls(
initial_position=(position["x"], position["y"]),
planner_settings=planner_settings,
)
def acquisition_routine(
self, settings: RegularGridSettingsModel, xyz_pos: tuple[int, int, int]
) -> tuple[bool, Optional[int]]:
"""Autofocus and capture.
:param settings: The settings for this scan as a RegularGridSettingsModel
:param xyz_pos: The current position as a tuple or 3 ints.
:return: A tuple of whether an image was taken, and the z-position for focus.
If failed to find focus, returns for the focus z-position.
"""
return self._autofocus_and_capture(
xyz_pos=xyz_pos,
dz=settings.autofocus_dz,
images_dir=settings.images_dir,
save_resolution=settings.save_resolution,
)
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
return [
property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"),
property_control_for(self, "x_count", label="Number of columns"),
property_control_for(self, "y_count", label="Number of rows"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
]
class SnakeWorkflow(RegularGridWorkflow):
"""A workflow optimised for snaking around samples. """A workflow optimised for snaking around samples.
This workflow generates a list of coordinates in a rectangle, and snakes This workflow generates a list of coordinates in a rectangle, and snakes
@ -533,139 +681,24 @@ class SnakeWorkflow(ScanWorkflow[SnakeSettingsModel]):
), ),
readonly=True, readonly=True,
) )
_grid_style = "snake"
_settings_model = SnakeSettingsModel
_planner_cls: type[ScanPlanner] = SnakeScan
# Thing Slots
_background_detector: ChannelDeviationLUV = lt.thing_slot()
_cam: BaseCamera = lt.thing_slot()
_csm: CameraStageMapper = lt.thing_slot()
_autofocus: AutofocusThing = lt.thing_slot()
_stage: BaseStage = lt.thing_slot()
# Scan settings class RasterWorkflow(RegularGridWorkflow):
"""A workflow optimised for snaking around samples.
autofocus_dz: int = lt.setting(default=1000, ge=200, le=2000) This workflow generates a list of coordinates in a rectangle, and always
"""The z distance to perform an autofocus in steps. moves right across a row, then moves down a row while moving to the starting
column (assuming positive dx and dy).
Must be greater than or equal to 200, and less than or equal to 2000.
""" """
overlap: float = lt.setting(default=0.45, ge=0.1, le=0.7) display_name: str = lt.property(default="Raster Scan", readonly=True)
"""The fraction that adjacent images should overlap in x or y. ui_blurb: str = lt.property(
default=(
"This scan workflow is optimised for performing a raster scan over a rectangle. It "
"always moves down and right from the starting point, over a defined grid."
),
readonly=True,
)
This must be between 0.1 and 0.7. _grid_style = "raster"
"""
x_count: int = lt.setting(default=3)
y_count: int = lt.setting(default=2)
# The noqa statement is because scan_name is unused but is needed for equivalence
# with other workflows that may want to validate the scan name.
def check_before_start(self, scan_name: str) -> None: # noqa: ARG002
"""Before starting a scan, check that camera-stage-mapping is set.
Raise error if:
- camera stage mapping is not set
"""
if self._csm.calibration_required:
raise RuntimeError("Camera Stage Mapping is not calibrated.")
@lt.property
def ready(self) -> bool:
"""Whether this scanworkflow is ready to start."""
return not self._csm.calibration_required
def all_settings(
self, images_dir: str
) -> tuple[SnakeSettingsModel, StitchingSettings]:
"""Return the workflow and stitching settings.
:param images_dir: The directory that images are to be written to.
:return: A tuple containing the settings model for this workflow and the
settings model for stitching.
"""
stitching_settings = StitchingSettings(
overlap=self.overlap,
correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0],
)
dx, dy = self._calc_displacement_from_overlap(self.overlap)
self.logger.info(
f"Based on an overlap of {self.overlap}, the stage will make steps of "
f"{dx}, {dy}"
)
scan_settings = SnakeSettingsModel(
overlap=self.overlap,
dx=dx,
dy=dy,
x_count=self.x_count,
y_count=self.y_count,
images_dir=images_dir,
autofocus_dz=self.autofocus_dz,
save_resolution=self.save_resolution,
)
return scan_settings, stitching_settings
def pre_scan_routine(self, settings: SnakeSettingsModel) -> None:
"""Autofocus before starting the scan.
:param settings: The settings for this scan as a SnakeSettingsModel
"""
self._autofocus.looping_autofocus(dz=settings.autofocus_dz, start="centre")
def new_scan_planner(
self, settings: SnakeSettingsModel, position: Mapping[str, int]
) -> ScanPlanner:
"""Return a new scan planner object.
:param settings: The settings for this scan as a SnakeSettingsModel
:param position: The starting position as a mapping of axes names to int.
"""
# The initial plan for the scan should be a single x,y position. All future
# moves will be planned around this point. In future, route planner could
# have multiple starting positions, each of which will be visited before the
# scan can end.
planner_settings = {
"dx": settings.dx,
"dy": settings.dy,
"x_count": settings.x_count,
"y_count": settings.y_count,
}
return self._planner_cls(
initial_position=(position["x"], position["y"]),
planner_settings=planner_settings,
)
def acquisition_routine(
self, settings: SnakeSettingsModel, xyz_pos: tuple[int, int, int]
) -> tuple[bool, Optional[int]]:
"""Perform acquisition routine. This is run at each scan location.
:param settings: The settings for this scan as a SnakeSettingsModel
:param xyz_pos: The current position as a tuple or 3 ints.
:return: A tuple of whether an image was taken, and the z-position for focus.
If failed to find focus, returns for the focus z-position.
"""
self._autofocus.fast_autofocus(dz=settings.autofocus_dz)
focus_height = self._stage.get_xyz_position()[2]
filename = f"img_{xyz_pos[0]}_{xyz_pos[1]}_{focus_height}.jpeg"
self._cam.capture_and_save(
jpeg_path=os.path.join(settings.images_dir, filename),
save_resolution=settings.save_resolution,
)
imaged = True
return imaged, focus_height
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
return [
property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"),
property_control_for(self, "x_count", label="Number of columns"),
property_control_for(self, "y_count", label="Number of rows"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
]

View file

@ -208,6 +208,16 @@ class SmartScanThing(lt.Thing):
raise ScanNotRunningError("Cannot get ongoing scan if scan is not running.") raise ScanNotRunningError("Cannot get ongoing scan if scan is not running.")
return self._ongoing_scan return self._ongoing_scan
@lt.property
def all_workflow_names(self) -> list[str]:
"""Return a list of all available Scan Workflows."""
return list(self._all_workflows.keys())
@lt.property
def workflow_display_names(self) -> dict[str, str]:
"""Return a list of the display names of all available Scan Workflows."""
return {name: wf.display_name for name, wf in self._all_workflows.items()}
_scan_data: Optional[ActiveScanData] = None _scan_data: Optional[ActiveScanData] = None
@property @property

View file

@ -89,8 +89,8 @@ def test_bad_smart_spiral_settings():
initial_position = (100, 50) initial_position = (100, 50)
# Class init should raise error if no planner_settings dictionary set # Class init should raise error if no planner_settings dictionary set
msg = "SmartSpiral requires a planner_settings dictionary with keys" msg = "RectGridPlanner requires planner_settings with keys"
with pytest.raises(ValueError, match=msg): with pytest.raises(KeyError, match=msg):
scan_planners.SmartSpiral(initial_position=initial_position) scan_planners.SmartSpiral(initial_position=initial_position)
planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000} planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000}
@ -105,7 +105,7 @@ def test_bad_smart_spiral_settings():
initial_position=initial_position, planner_settings=bad_planner_settings initial_position=initial_position, planner_settings=bad_planner_settings
) )
# Class init should raise error if planner_settings if any value can't be cast # Class init should raise error if any value in planner_settings can't be cast
# to int # to int
keys = ["dx", "dy", "max_dist"] keys = ["dx", "dy", "max_dist"]
for badkey in keys: for badkey in keys:
@ -317,12 +317,18 @@ def test_example_smart_spiral():
assert planner.imaged_locations == expected_planner.imaged_locations assert planner.imaged_locations == expected_planner.imaged_locations
def test_snake_scan_basic_grid(): def test_snake_planner_basic_grid():
"""Check that SnakeScan generates a single point for a 1x1 scan.""" """Check that snake scan planner generates a single point for a 1x1 scan."""
initial_position = (100, 50) initial_position = (100, 50)
planner_settings = {"dx": 100, "dy": 100, "x_count": 1, "y_count": 1} planner_settings = {
"dx": 100,
"dy": 100,
"x_count": 1,
"y_count": 1,
"style": "snake",
}
planner = scan_planners.SnakeScan( planner = scan_planners.RegularGridPlanner(
initial_position=initial_position, initial_position=initial_position,
planner_settings=planner_settings, planner_settings=planner_settings,
) )
@ -352,11 +358,17 @@ def test_snake_scan_basic_grid():
def test_snake_scan_basic_length(): def test_snake_scan_basic_length():
"""SnakeScan should generate the correct number of locations.""" """Snake scan planner should generate the correct number of locations."""
initial_position = (100, 50) initial_position = (100, 50)
planner_settings = {"dx": 100, "dy": 100, "x_count": 3, "y_count": 4} planner_settings = {
"dx": 100,
"dy": 100,
"x_count": 3,
"y_count": 4,
"style": "snake",
}
planner = scan_planners.SnakeScan( planner = scan_planners.RegularGridPlanner(
initial_position=initial_position, initial_position=initial_position,
planner_settings=planner_settings, planner_settings=planner_settings,
) )
@ -369,9 +381,15 @@ def test_snake_scan_basic_length():
def test_snake_scan_ordering(): def test_snake_scan_ordering():
"""Test that snake scan returns a path in the right order.""" """Test that snake scan returns a path in the right order."""
initial_position = (0, 0) initial_position = (0, 0)
planner_settings = {"dx": 10, "dy": 10, "x_count": 4, "y_count": 3} planner_settings = {
"dx": 10,
"dy": 10,
"x_count": 4,
"y_count": 3,
"style": "snake",
}
planner = scan_planners.SnakeScan( planner = scan_planners.RegularGridPlanner(
initial_position=initial_position, initial_position=initial_position,
planner_settings=planner_settings, planner_settings=planner_settings,
) )
@ -399,9 +417,9 @@ def test_snake_scan_ordering():
def test_snake_scan_single_row(): def test_snake_scan_single_row():
"""Test edge case of a single row scan.""" """Test edge case of a single row scan."""
initial_position = (0, 0) initial_position = (0, 0)
planner_settings = {"dx": 5, "dy": 5, "x_count": 4, "y_count": 1} planner_settings = {"dx": 5, "dy": 5, "x_count": 4, "y_count": 1, "style": "snake"}
planner = scan_planners.SnakeScan( planner = scan_planners.RegularGridPlanner(
initial_position=initial_position, initial_position=initial_position,
planner_settings=planner_settings, planner_settings=planner_settings,
) )
@ -412,9 +430,9 @@ def test_snake_scan_single_row():
def test_snake_scan_single_column(): def test_snake_scan_single_column():
"""Test edge case of a single column scan.""" """Test edge case of a single column scan."""
initial_position = (0, 0) initial_position = (0, 0)
planner_settings = {"dx": 5, "dy": 5, "x_count": 1, "y_count": 4} planner_settings = {"dx": 5, "dy": 5, "x_count": 1, "y_count": 4, "style": "snake"}
planner = scan_planners.SnakeScan( planner = scan_planners.RegularGridPlanner(
initial_position=initial_position, initial_position=initial_position,
planner_settings=planner_settings, planner_settings=planner_settings,
) )
@ -425,3 +443,229 @@ def test_snake_scan_single_column():
(0, 10), (0, 10),
(0, 15), (0, 15),
] ]
def test_snake_scan_z_propagation():
"""Test that snake planner selects correct previous focus height."""
initial_position = (0, 0)
planner_settings = {
"dx": 50,
"dy": 50,
"x_count": 5,
"y_count": 5,
"style": "snake",
}
planner = scan_planners.RegularGridPlanner(
initial_position=initial_position,
planner_settings=planner_settings,
)
expected_z = 1
while not planner.scan_complete:
xy_pos, z_est = planner.get_next_location_and_z_estimate()
print(xy_pos, z_est)
# First position should have no estimate
if xy_pos == (0, 0):
assert z_est is None
else:
# Should estimate from previous focused point
assert z_est == expected_z - 1
xyz_pos = (xy_pos[0], xy_pos[1], expected_z)
planner.mark_location_visited(
xyz_pos,
imaged=True,
focused=True,
)
expected_z += 1
def test_raster_planner_basic_grid():
"""Check that raster scan planner generates a single point for a 1x1 scan."""
initial_position = (100, 50)
planner_settings = {
"dx": 100,
"dy": 100,
"x_count": 1,
"y_count": 1,
"style": "raster",
}
planner = scan_planners.RegularGridPlanner(
initial_position=initial_position,
planner_settings=planner_settings,
)
assert not planner.scan_complete
# When we start it should want to stay in the initial pos and have
# no z_estimate
xy_pos, z_pos = planner.get_next_location_and_z_estimate()
assert xy_pos == initial_position
assert z_pos is None
# Try to mark location as imaged with only xy_position
with pytest.raises(ValueError, match="3 value tuple expected"):
planner.mark_location_visited(xy_pos, imaged=False, focused=False)
# scan still not complete
assert not planner.scan_complete
# if we mark this position as visited but not imaged
planner.mark_location_visited(
(xy_pos[0], xy_pos[1], 10), imaged=False, focused=False
)
# scan is now complete
assert planner.scan_complete
# if scan is complete, asking for the next location returns an error
with pytest.raises(RuntimeError):
planner.get_next_location_and_z_estimate()
def test_raster_scan_basic_length():
"""Raster scan planner should generate the correct number of locations."""
initial_position = (100, 50)
planner_settings = {
"dx": 100,
"dy": 100,
"x_count": 3,
"y_count": 4,
"style": "raster",
}
planner = scan_planners.RegularGridPlanner(
initial_position=initial_position,
planner_settings=planner_settings,
)
coords = planner.remaining_locations
assert len(coords) == 3 * 4
def test_raster_scan_ordering():
"""Test that raster scan returns a path in the right order."""
initial_position = (0, 0)
planner_settings = {
"dx": 10,
"dy": 10,
"x_count": 4,
"y_count": 3,
"style": "raster",
}
planner = scan_planners.RegularGridPlanner(
initial_position=initial_position,
planner_settings=planner_settings,
)
coords = planner.remaining_locations
expected = [
(0, 0),
(10, 0),
(20, 0),
(30, 0),
(0, 10),
(10, 10),
(20, 10),
(30, 10),
(0, 20),
(10, 20),
(20, 20),
(30, 20),
]
assert coords == expected
def test_raster_scan_single_row():
"""Test edge case of a single row scan."""
initial_position = (0, 0)
planner_settings = {"dx": 5, "dy": 5, "x_count": 4, "y_count": 1, "style": "raster"}
planner = scan_planners.RegularGridPlanner(
initial_position=initial_position,
planner_settings=planner_settings,
)
assert planner.remaining_locations == [(0, 0), (5, 0), (10, 0), (15, 0)]
def test_raster_scan_single_column():
"""Test edge case of a single column scan."""
initial_position = (0, 0)
planner_settings = {"dx": 5, "dy": 5, "x_count": 1, "y_count": 4, "style": "raster"}
planner = scan_planners.RegularGridPlanner(
initial_position=initial_position,
planner_settings=planner_settings,
)
assert planner.remaining_locations == [
(0, 0),
(0, 5),
(0, 10),
(0, 15),
]
def test_raster_z_propagation():
"""Test that snake planner selects correct previous focus height.
Constructs a 5x5 grid in a raster pattern, and test that for each movement,
the chosen next z position is either
- None, for the first point
- the start of the previous row, for the first point in a row
- the previous site otherwise
"""
initial_position = (0, 0)
x_count = 5
y_count = 5
dx = 50
dy = 50
planner = scan_planners.RegularGridPlanner(
initial_position=initial_position,
planner_settings={
"dx": dx,
"dy": dy,
"x_count": x_count,
"y_count": y_count,
"style": "raster",
},
)
visited_positions = []
current_z = 1
while not planner.scan_complete:
visited_count = len(visited_positions)
xy_pos, z_est = planner.get_next_location_and_z_estimate()
print(visited_count)
if visited_count == 0:
assert z_est is None
else:
# check whether this site is at the start of a new row
if visited_count % x_count == 0:
# if so, get the z position from the start of the previous row
expected_z = visited_positions[-x_count][2]
else:
expected_z = visited_positions[-1][2]
assert z_est == expected_z
xyz_pos = (xy_pos[0], xy_pos[1], current_z)
planner.mark_location_visited(
xyz_pos,
imaged=True,
focused=True,
)
visited_positions.append(xyz_pos)
current_z += 1

View file

@ -342,11 +342,11 @@ def test_histo_workflow_settings_ui(histo_workflow):
names = [el.property_name for el in ui] names = [el.property_name for el in ui]
expected_names = [ expected_names = [
"overlap", "overlap",
"skip_background",
"stack_images_to_save", "stack_images_to_save",
"stack_min_images_to_test", "stack_min_images_to_test",
"stack_dz", "stack_dz",
"autofocus_dz", "autofocus_dz",
"max_range", "max_range",
"skip_background",
] ]
assert names == expected_names assert names == expected_names

View file

@ -2,6 +2,20 @@
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove"> <div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component uk-padding-small"> <div class="control-component uk-padding-small">
<div v-show="!scanning" v-observe-visibility="visibilityChanged" class="uk-padding-small"> <div v-show="!scanning" v-observe-visibility="visibilityChanged" class="uk-padding-small">
<!-- Workflow Selection Dropdown -->
<div class="uk-margin">
<label class="uk-form-label">Workflow</label>
<select
class="uk-select uk-form-small"
:value="workflowName"
@change="setWorkflow($event.target.value)"
>
<option v-for="(label, name) in workflowOptions" :key="name" :value="name">
{{ label }}
</option>
</select>
</div>
<h4 v-if="workflowDisplayName" class="workflow-name"> <h4 v-if="workflowDisplayName" class="workflow-name">
{{ workflowDisplayName }} {{ workflowDisplayName }}
</h4> </h4>
@ -145,6 +159,7 @@ export default {
workflowSettings: [], workflowSettings: [],
workflowDisplayName: undefined, workflowDisplayName: undefined,
workflowBlurb: undefined, workflowBlurb: undefined,
workflowOptions: [],
}; };
}, },
@ -159,6 +174,7 @@ export default {
async created() { async created() {
this.readSettings(); this.readSettings();
this.workflowOptions = await this.readThingProperty("smart_scan", "workflow_display_names");
}, },
methods: { methods: {
@ -216,6 +232,21 @@ export default {
setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped
} }
}, },
async setWorkflow(name) {
try {
this.workflowName = name;
await this.writeThingProperty("smart_scan", "workflow_name", name);
// refresh UI
await this.readSettings();
} catch (err) {
this.modalError(err);
// revert if server rejected
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name");
}
},
async downloadZipFile(response) { async downloadZipFile(response) {
const scan_name = response.input.scan_name; const scan_name = response.input.scan_name;
const filename = `${scan_name}_images.zip`; const filename = `${scan_name}_images.zip`;