Intermediate level RectangleScan class for all three scanners

This commit is contained in:
Joe Knapper 2026-02-09 15:45:16 +00:00 committed by jaknapper
parent 3beb8e1147
commit 1a0dd71f30

View file

@ -304,7 +304,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 RectangleScan(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 = "RectangleScan requires planner_settings with keys: "
if not planner_settings:
raise ValueError(invalid_msg + ",".join(expected_keys))
if 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,
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))
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
next_pos_arr = np.array(next_position, dtype="float64")
path_arr = np.array(focused_locations, dtype="float64")[:, :2]
# Find all focused positions within dx and dy of next_position.
# Don't just use _adjacent_positions as some grids might have intermediate sites.
dx_ok = np.abs(path_arr[:, 0] - next_pos_arr[0]) <= self._dx
dy_ok = np.abs(path_arr[:, 1] - next_pos_arr[1]) <= self._dy
nearby_indices = np.where(dx_ok & dy_ok)[0]
if len(nearby_indices) == 0:
return None
# Pick the most recent nearby site
return focused_locations[nearby_indices[-1]]
class SmartSpiral(RectangleScan):
"""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 +411,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 +431,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 "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 +541,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."""
@ -518,104 +572,51 @@ class SmartSpiral(ScanPlanner):
if not focused_locations: if not focused_locations:
return None return None
# must be float64 (double precision) to deal with the huge numbers involved!
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 site(s) that are 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 best_sites = nearby_sites[nearby_sites[:, 2] == min_z]
candidates = focused_locations_array[indices] chosen_site = best_sites[-1]
min_z = np.min(candidates[:, -1])
# Find all with the minimum z, and select the latest return tuple(chosen_site.astype(int))
chosen_focused_site = candidates[candidates[:, -1] == min_z][-1]
# Convert back into list so values are of type int instead of np.int32
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 SnakeScan(RectangleScan):
"""A scan planner that performs a snake scan, right and down from a corner. """A scan planner that performs a snake scan, 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
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"]
* ``dy`` - the movement size in y invalid_msg = "SnakeScan requires planner_settings with keys: "
* ``x_count`` - The number of columns in the scan. if not all(k in planner_settings for k in expected_keys):
* ``y_count`` - The number of rows in the scan.
"""
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"])
@ -637,50 +638,28 @@ class SnakeScan(ScanPlanner):
return self._grid_to_future_locations(grid) return self._grid_to_future_locations(grid)
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 class RasterScan(RectangleScan):
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
next_pos_arr = np.array(next_position, dtype="float64")
path_arr = np.array(focused_locations, dtype="float64")[:, :2]
# Find all focused positions within dx and dy of next_position
dx_ok = np.abs(path_arr[:, 0] - next_pos_arr[0]) <= self._dx
dy_ok = np.abs(path_arr[:, 1] - next_pos_arr[1]) <= self._dy
nearby_indices = np.where(dx_ok & dy_ok)[0]
if len(nearby_indices) == 0:
return None
# Pick the most recent nearby site
return focused_locations[nearby_indices[-1]]
class RasterScan(SnakeScan):
"""A scan planner that performs a snake scan, always moving right and down. """A scan planner that performs a snake scan, always moving right and down.
This planner starts at the corner of the region to scan, and always moves right This planner starts at the corner of the region to scan, and always moves right
to the end of the row, then down to the next row (assuming positive dx, dy). to the end of the row, then down to the next row (assuming positive dx, dy).
This is subclassed from SnakeScan, as the only difference in behaviour is in
building the initial path.
""" """
_x_count: int = 0
_y_count: int = 0
def _parse(self, planner_settings: Optional[dict] = None) -> None:
super()._parse(planner_settings)
expected_keys = ["x_count", "y_count"]
invalid_msg = "SnakeScan requires planner_settings with keys: "
if not all(k in planner_settings for k in expected_keys):
raise KeyError(invalid_msg + ",".join(expected_keys))
self._x_count = int(planner_settings["x_count"])
self._y_count = int(planner_settings["y_count"])
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.