Merge branch 'improve-scan-planning' into 'v3'
Improve smart spiral scan planner to better capture the edge of scamples Closes #559 See merge request openflexure/openflexure-microscope-server!411
This commit is contained in:
commit
e51fbb4fa6
6 changed files with 276 additions and 85 deletions
|
|
@ -5,7 +5,11 @@ is only one type the SmartSpiral. More can be added using by
|
||||||
subclassing the ScanPlanner
|
subclassing the ScanPlanner
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import TypeAlias, Optional
|
# Future annotations needed for typhinting same class in __eq__ method. Other option
|
||||||
|
# would be to import Union and use a string.
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TypeAlias, Optional, Any
|
||||||
import logging
|
import logging
|
||||||
from copy import copy
|
from copy import copy
|
||||||
|
|
||||||
|
|
@ -51,6 +55,99 @@ def enforce_xyz_tuple(value: XYZPos) -> XYZPos:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Disable PLW1641, this warns against hash not being set when equals is. But the
|
||||||
|
# class shouldn't be hashable.
|
||||||
|
class FutureScanLocation: # noqa PLW1641
|
||||||
|
"""Data for information on future locations to scan.
|
||||||
|
|
||||||
|
This object is only used for internal calculation and data storage it shouldn't
|
||||||
|
be an input or output of public methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, xy_pos: XYPos, **kwargs: Any) -> None:
|
||||||
|
"""Initialise FutureScanLocation with an xy position.
|
||||||
|
|
||||||
|
:param xy_pos: The (x, y) position to scan.
|
||||||
|
:param kwargs: Any other information about the location. This will be passed
|
||||||
|
through to the VisitedScanLocation object.
|
||||||
|
"""
|
||||||
|
self._xy_pos = enforce_xy_tuple(xy_pos)
|
||||||
|
self.planner_data = copy(kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def xy_tuple(self) -> XYPos:
|
||||||
|
"""The xy position tuple."""
|
||||||
|
return self._xy_pos
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
"""Check for equality, only checks the xy position.
|
||||||
|
|
||||||
|
Will check against tuple or other FutureScanLocation object.
|
||||||
|
"""
|
||||||
|
if isinstance(other, FutureScanLocation):
|
||||||
|
return self._xy_pos == other.xy_tuple
|
||||||
|
if isinstance(other, tuple) and len(other) == 2:
|
||||||
|
return self._xy_pos[:2] == other
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
|
||||||
|
# Disable PLW1641, this warns against hash not being set when equals is. But the
|
||||||
|
# class shouldn't be hashable.
|
||||||
|
class VisitedScanLocation: # noqa PLW1641
|
||||||
|
"""Data for information on locations already visited during a scan.
|
||||||
|
|
||||||
|
This object is only used for internal calculation and data storage it shouldn't
|
||||||
|
be an input or output of public methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, xyz_pos: XYZPos, imaged: bool, focused: bool, **kwargs: Any
|
||||||
|
) -> None:
|
||||||
|
"""Initialise VisitedScanLocation with an xyz-position, and whether imaged/focused.
|
||||||
|
|
||||||
|
:param xyz_pos: The (x, y, z) position visited.
|
||||||
|
:param imaged: True if an image was taken, False if not (due to background
|
||||||
|
detect)
|
||||||
|
:param focused: True if autofocus completed successfully
|
||||||
|
:param kwargs: Any other information about the location. This should be passed
|
||||||
|
through to from the FutureScanLocation object that requested the location
|
||||||
|
to be scanned.
|
||||||
|
"""
|
||||||
|
self._xyz_pos = enforce_xyz_tuple(xyz_pos)
|
||||||
|
self.imaged = imaged
|
||||||
|
self.focused = focused
|
||||||
|
self.planner_data = copy(kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def xyz_tuple(self) -> XYZPos:
|
||||||
|
"""The xyz position tuple."""
|
||||||
|
return self._xyz_pos
|
||||||
|
|
||||||
|
@property
|
||||||
|
def xy_tuple(self) -> XYPos:
|
||||||
|
"""The xy position tuple."""
|
||||||
|
return self._xyz_pos[:2]
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
"""Check for equality, only checks the xyz-position or xy-position if z isn't available.
|
||||||
|
|
||||||
|
Will check xyz-position against 3-value tuples and other VisitedScanLocation
|
||||||
|
objects.
|
||||||
|
|
||||||
|
Will check xy-position against 2-value tuples and FutureScanLocation objects.
|
||||||
|
"""
|
||||||
|
if isinstance(other, VisitedScanLocation):
|
||||||
|
return self._xyz_pos == other.xyz_tuple
|
||||||
|
if isinstance(other, FutureScanLocation):
|
||||||
|
return self._xyz_pos[:2] == other.xy_tuple
|
||||||
|
if isinstance(other, tuple):
|
||||||
|
if len(other) == 2:
|
||||||
|
return self._xyz_pos[:2] == other
|
||||||
|
if len(other) == 3:
|
||||||
|
return self._xyz_pos == other
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
|
||||||
class ScanPlanner:
|
class ScanPlanner:
|
||||||
"""A base class for a scan planner.
|
"""A base class for a scan planner.
|
||||||
|
|
||||||
|
|
@ -79,25 +176,10 @@ class ScanPlanner:
|
||||||
self._initial_position = enforce_xy_tuple(initial_position)
|
self._initial_position = enforce_xy_tuple(initial_position)
|
||||||
self._parse(planner_settings)
|
self._parse(planner_settings)
|
||||||
|
|
||||||
# The remaining (x,y) locations to scan
|
self._remaining_locations: list[FutureScanLocation] = (
|
||||||
# (This was `path` before refactoring from the long `sample_scan` code)
|
self._initial_location_list()
|
||||||
self._remaining_locations: XYPosList = self._initial_location_list()
|
)
|
||||||
|
self._path_history: list[VisitedScanLocation] = []
|
||||||
# This holds a list of all (x,y,z) locations where images were taken
|
|
||||||
# this may not be equivalent to the x,y positions ins self._path_history
|
|
||||||
# if background detect is used
|
|
||||||
# (This was not used in the `sample_scan` code)
|
|
||||||
self._imaged_locations: XYZPosList = []
|
|
||||||
|
|
||||||
# This holds a list of all (x,y,z) locations where autofocus was successful
|
|
||||||
# (This was `focused_path` before refactoring from the long `sample_scan` code)
|
|
||||||
self._focused_locations: XYZPosList = []
|
|
||||||
|
|
||||||
# This holds a list of all x,y locations visited in order since the start
|
|
||||||
# (This was `true_path` before refactoring from the long `sample_scan` code
|
|
||||||
# previously it had z set, but if we don't take an image, not z is needed and
|
|
||||||
# it slows other checks)
|
|
||||||
self._path_history: XYPosList = []
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scan_complete(self) -> bool:
|
def scan_complete(self) -> bool:
|
||||||
|
|
@ -107,48 +189,57 @@ class ScanPlanner:
|
||||||
@property
|
@property
|
||||||
def remaining_locations(self) -> XYPosList:
|
def remaining_locations(self) -> XYPosList:
|
||||||
"""Property to access a copy of the remaining_locations."""
|
"""Property to access a copy of the remaining_locations."""
|
||||||
return copy(self._remaining_locations)
|
return [loc.xy_tuple for loc in self._remaining_locations]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def imaged_locations(self) -> XYZPosList:
|
def imaged_locations(self) -> XYZPosList:
|
||||||
"""Property to access a copy of the imaged_locations."""
|
"""Property to access a copy of the imaged_locations."""
|
||||||
return copy(self._imaged_locations)
|
return [loc.xyz_tuple for loc in self._path_history if loc.imaged]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def focused_locations(self) -> XYZPosList:
|
def focused_locations(self) -> XYZPosList:
|
||||||
"""Property to access a copy of the focused_locations."""
|
"""Property to access a copy of the focused_locations."""
|
||||||
return copy(self._focused_locations)
|
return [loc.xyz_tuple for loc in self._path_history if loc.focused]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path_history(self) -> XYPosList:
|
def path_history(self) -> XYPosList:
|
||||||
"""Property to access a copy of the path_history."""
|
"""Property to access a copy of the path_history."""
|
||||||
return copy(self._path_history)
|
return [loc.xy_tuple for loc in self._path_history]
|
||||||
|
|
||||||
def _parse(self, planner_settings: Optional[dict] = None) -> None:
|
def _parse(self, planner_settings: Optional[dict] = None) -> None:
|
||||||
"""Parse any settings sent to this planner and store them if needed."""
|
"""Parse any settings sent to this planner and store them if needed."""
|
||||||
raise NotImplementedError("Did you call the ScanPlanner base class?")
|
raise NotImplementedError("Did you call the ScanPlanner base class?")
|
||||||
|
|
||||||
def _initial_location_list(self) -> XYPosList:
|
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.
|
||||||
|
|
||||||
This is called on initialisation.
|
This is called on initialisation.
|
||||||
|
|
||||||
For a simple grid scan/snake scan this would be all locations to move to.
|
For a simple grid scan/snake scan this would be all locations to move to.
|
||||||
|
|
||||||
Note for implementation that this _must_ contain (x,y) tuples, not [x, y]
|
:return: A list of FutureScanLocation objects with all planned locations.
|
||||||
lists or matching errors could occur.
|
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError("Did you call the ScanPlanner base class?")
|
raise NotImplementedError("Did you call the ScanPlanner base class?")
|
||||||
|
|
||||||
def position_visited(self, position: XYPos) -> bool:
|
def position_visited(self, position: XYPos | FutureScanLocation) -> bool:
|
||||||
"""Return True if input xy position has been visited before."""
|
"""Return True if input scan position has been visited before."""
|
||||||
# Ensure tuple for correct matching!
|
# Ensure tuple for correct matching!
|
||||||
return tuple(position) in self._path_history
|
return position in self._path_history
|
||||||
|
|
||||||
def position_planned(self, position: XYPos) -> bool:
|
def get_visited_location(
|
||||||
"""Return True if input xy position is planned."""
|
self, position: XYPos | XYZPos | FutureScanLocation
|
||||||
|
) -> VisitedScanLocation:
|
||||||
|
"""Return the scan location from the history that matches the input position."""
|
||||||
|
# Ignoring type as self._path_history has type List[VisitedScanLocation], and
|
||||||
|
# VisitedScanLocation implements __eq__ for XYPos & XYZPos & FutureScanLocation
|
||||||
|
# however this is not statically detectable by MyPy
|
||||||
|
index = self._path_history.index(position) # type: ignore[arg-type]
|
||||||
|
return self._path_history[index]
|
||||||
|
|
||||||
|
def position_planned(self, position: XYPos | FutureScanLocation) -> bool:
|
||||||
|
"""Return True if input scan position position is planned."""
|
||||||
# Ensure tuple for correct matching!
|
# Ensure tuple for correct matching!
|
||||||
return tuple(position) in self._remaining_locations
|
return position in self._remaining_locations
|
||||||
|
|
||||||
def get_next_location_and_z_estimate(self) -> tuple[XYPos, Optional[int]]:
|
def get_next_location_and_z_estimate(self) -> tuple[XYPos, Optional[int]]:
|
||||||
"""Return the next location to scan and its estimated z-position.
|
"""Return the next location to scan and its estimated z-position.
|
||||||
|
|
@ -159,7 +250,7 @@ class ScanPlanner:
|
||||||
if self.scan_complete:
|
if self.scan_complete:
|
||||||
raise RuntimeError("Can't get next position, scan is complete")
|
raise RuntimeError("Can't get next position, scan is complete")
|
||||||
|
|
||||||
next_location = self._remaining_locations[0]
|
next_location = self._remaining_locations[0].xy_tuple
|
||||||
|
|
||||||
# If focussed locations exist return closest location, favouring most recent
|
# If focussed locations exist return closest location, favouring most recent
|
||||||
closest_pos = self.closest_focus_site(next_location)
|
closest_pos = self.closest_focus_site(next_location)
|
||||||
|
|
@ -177,12 +268,14 @@ class ScanPlanner:
|
||||||
|
|
||||||
Returns None if there if no focussed locations are present
|
Returns None if there if no focussed locations are present
|
||||||
"""
|
"""
|
||||||
if not self._focused_locations:
|
# save to variable rather than search for focussed sites each time.
|
||||||
|
focused_locations = self.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 (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(self._focused_locations, dtype="float64")[:, :2]
|
path_pos = np.array(focused_locations, dtype="float64")[:, :2]
|
||||||
|
|
||||||
# Use linalg.norm to calculate the direct distance bweween the points
|
# Use linalg.norm to calculate the direct distance bweween the points
|
||||||
# Note linalg.norm always uses float64
|
# Note linalg.norm always uses float64
|
||||||
|
|
@ -193,36 +286,35 @@ class ScanPlanner:
|
||||||
indices = np.where(dists == np.min(dists))[0]
|
indices = np.where(dists == np.min(dists))[0]
|
||||||
|
|
||||||
# The last index is most recent
|
# The last index is most recent
|
||||||
return self._focused_locations[indices[-1]]
|
return focused_locations[indices[-1]]
|
||||||
|
|
||||||
def mark_location_visited(
|
def mark_location_visited(
|
||||||
self, xyz_pos: XYZPos, imaged: bool, focused: bool
|
self, xyz_pos: XYZPos, imaged: bool, focused: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark the location as visited.
|
"""Mark the location as visited.
|
||||||
|
|
||||||
Args:
|
:param xyz_pos: the x_y_z position
|
||||||
xyz_pos: the x_y_z position
|
:param imaged: true if an image was taken, false if not (due to background detect)
|
||||||
imaged: true if an image was taken, false if not (due to background detect)
|
:param focused: true if autofocus completed successfully
|
||||||
focused: true if autofocus completed successfully
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# ensure is tuple!
|
# ensure is tuple!
|
||||||
xyz_pos = enforce_xyz_tuple(xyz_pos)
|
xyz_pos = enforce_xyz_tuple(xyz_pos)
|
||||||
xy_pos = xyz_pos[:2]
|
|
||||||
|
|
||||||
# Remove the expected position from the remaining locations list
|
# Remove the expected position from the remaining locations list
|
||||||
# and check it's correct
|
# and check it's correct
|
||||||
expected_pos = tuple(self._remaining_locations.pop(0))
|
expected_pos = self._remaining_locations.pop(0)
|
||||||
if xy_pos != expected_pos:
|
if xyz_pos[:2] != expected_pos.xy_tuple:
|
||||||
raise RuntimeError("Wrong scan location visited!")
|
raise RuntimeError("Wrong scan location visited!")
|
||||||
|
|
||||||
# Append xy position for path_history
|
# Append xy position for path_history
|
||||||
self._path_history.append(xy_pos)
|
self._path_history.append(
|
||||||
# And full x,y,z for imaged and foucsed if appropriate
|
VisitedScanLocation(
|
||||||
if imaged:
|
xyz_pos=xyz_pos,
|
||||||
self._imaged_locations.append(xyz_pos)
|
imaged=imaged,
|
||||||
if focused:
|
focused=focused,
|
||||||
self._focused_locations.append(xyz_pos)
|
**expected_pos.planner_data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SmartSpiral(ScanPlanner):
|
class SmartSpiral(ScanPlanner):
|
||||||
|
|
@ -257,6 +349,21 @@ class SmartSpiral(ScanPlanner):
|
||||||
super().__init__(initial_position, planner_settings)
|
super().__init__(initial_position, planner_settings)
|
||||||
self._distance_cutoff: float = max([self._dx, self._dy]) * 1.1
|
self._distance_cutoff: float = max([self._dx, self._dy]) * 1.1
|
||||||
|
|
||||||
|
def _is_primary_location(
|
||||||
|
self, location: FutureScanLocation | VisitedScanLocation
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if input is a primary location not a secondary (intermediate) location."""
|
||||||
|
return location.planner_data["primary"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secondary_locations(self) -> XYZPosList:
|
||||||
|
"""A list of all secondary (intermediate) locations."""
|
||||||
|
return [
|
||||||
|
loc.xyz_tuple
|
||||||
|
for loc in self._path_history
|
||||||
|
if not self._is_primary_location(loc)
|
||||||
|
]
|
||||||
|
|
||||||
def _parse(self, planner_settings: Optional[dict] = None) -> None:
|
def _parse(self, planner_settings: Optional[dict] = None) -> None:
|
||||||
"""Parse SmartSpiral Settings dictionary.
|
"""Parse SmartSpiral Settings dictionary.
|
||||||
|
|
||||||
|
|
@ -275,32 +382,33 @@ class SmartSpiral(ScanPlanner):
|
||||||
self._dy = int(planner_settings["dy"])
|
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) -> XYPosList:
|
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.
|
||||||
|
|
||||||
This is salled on initialisation.
|
This is salled on initialisation.
|
||||||
|
|
||||||
For smart spiral this is just the first point
|
For smart spiral this is just the first point
|
||||||
"""
|
"""
|
||||||
return [self._initial_position]
|
return [FutureScanLocation(self._initial_position, primary=True)]
|
||||||
|
|
||||||
def mark_location_visited(
|
def mark_location_visited(
|
||||||
self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True
|
self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark the location as visited. Adjust extra positions accordingly.
|
"""Mark the location as visited.
|
||||||
|
|
||||||
Args:
|
|
||||||
xyz_pos: the x_y_z position
|
|
||||||
imaged: true if an image was taken, false if not (due to background detect)
|
|
||||||
focused: true if autofocus completed successfully
|
|
||||||
|
|
||||||
|
:param xyz_pos: the x_y_z position
|
||||||
|
:param imaged: true if an image was taken, false if not (due to background detect)
|
||||||
|
:param focused: true if autofocus completed successfully
|
||||||
"""
|
"""
|
||||||
# First call the base class to update the positions
|
# First call the base class to update the positions
|
||||||
super().mark_location_visited(xyz_pos, imaged, focused)
|
super().mark_location_visited(xyz_pos, imaged, focused)
|
||||||
|
|
||||||
xy_pos = enforce_xy_tuple(xyz_pos[:2])
|
xy_pos = enforce_xy_tuple(xyz_pos[:2])
|
||||||
if imaged:
|
if self._is_primary_location(self._path_history[-1]):
|
||||||
self._add_surrounding_positions(xy_pos)
|
if imaged:
|
||||||
|
self._add_surrounding_positions(xy_pos)
|
||||||
|
else:
|
||||||
|
self._add_intermediate_positions(xy_pos)
|
||||||
self._re_sort_remaining_locations(xy_pos)
|
self._re_sort_remaining_locations(xy_pos)
|
||||||
|
|
||||||
def _add_surrounding_positions(self, xy_pos: XYPos) -> None:
|
def _add_surrounding_positions(self, xy_pos: XYPos) -> None:
|
||||||
|
|
@ -312,30 +420,89 @@ class SmartSpiral(ScanPlanner):
|
||||||
* too far away
|
* too far away
|
||||||
* already planned
|
* already planned
|
||||||
* already visited
|
* already visited
|
||||||
|
|
||||||
|
If the already visited position was not imaged then an intermediate location is
|
||||||
|
added. See also self._add_intermediate_positions() for adding intermediate
|
||||||
|
locations after visiting a location that was not imaged.
|
||||||
"""
|
"""
|
||||||
new_positions = [
|
new_positions = self._adjacent_positions(xy_pos)
|
||||||
(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),
|
|
||||||
]
|
|
||||||
|
|
||||||
for new_pos in new_positions:
|
for new_pos in new_positions:
|
||||||
# Skip position if already planned or visited
|
# Skip position if already planned
|
||||||
if self.position_planned(new_pos) or self.position_visited(new_pos):
|
if self.position_planned(new_pos):
|
||||||
|
continue
|
||||||
|
if self.position_visited(new_pos):
|
||||||
|
# Get the VisitedScanLocation object if already visited
|
||||||
|
visited = self.get_visited_location(new_pos)
|
||||||
|
if visited.imaged:
|
||||||
|
# If this adjacent position was imaged successfully already then skip.
|
||||||
|
continue
|
||||||
|
# If it wasn't imaged add an intermediate location between the
|
||||||
|
# last imaged position and this one.
|
||||||
|
i_pos = self._intermediate_position(xy_pos, new_pos)
|
||||||
|
# Set primary=False surrounding images are not added once imaged.
|
||||||
|
i_loc = FutureScanLocation(i_pos, primary=False)
|
||||||
|
# Append instantly without checking max_distance as this is between
|
||||||
|
# imaged points.
|
||||||
|
self._remaining_locations.append(i_loc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dist = distance_between(new_pos, self._initial_position)
|
dist = distance_between(new_pos, self._initial_position)
|
||||||
if dist > self._max_dist:
|
if dist > self._max_dist:
|
||||||
LOGGER.debug("Rejected moving to %s as it is out of range", new_pos)
|
LOGGER.debug("Rejected moving to %s as it is out of range", new_pos)
|
||||||
continue
|
continue
|
||||||
self._remaining_locations.append(new_pos)
|
new_loc = FutureScanLocation(new_pos, primary=True)
|
||||||
|
self._remaining_locations.append(new_loc)
|
||||||
|
|
||||||
|
def _add_intermediate_positions(self, xy_pos: XYPos) -> None:
|
||||||
|
"""Add intermediate points after locating a background location.
|
||||||
|
|
||||||
|
This is called after an image is recorded that was background. Intermediate
|
||||||
|
locations are added between any adjacent locations that were successfully
|
||||||
|
imaged due to being labelled as containing sample.
|
||||||
|
|
||||||
|
Note that in the case that an imaged location has an adjacent background image
|
||||||
|
then adding the intermediate image will be handled by
|
||||||
|
_add_surrounding_positions().
|
||||||
|
"""
|
||||||
|
surrounding_positions = self._adjacent_positions(xy_pos)
|
||||||
|
|
||||||
|
for surr_pos in surrounding_positions:
|
||||||
|
if self.position_visited(surr_pos):
|
||||||
|
# Get the VisitedScanLocation object if already visited
|
||||||
|
visited = self.get_visited_location(surr_pos)
|
||||||
|
if not visited.imaged:
|
||||||
|
# If it wasn't imaged then skip this position
|
||||||
|
continue
|
||||||
|
# If surrounding location was imaged add an intermediate location
|
||||||
|
# between the most recent position and this imaged position.
|
||||||
|
i_pos = self._intermediate_position(xy_pos, surr_pos)
|
||||||
|
# Set primary=False surrounding images are not added once imaged.
|
||||||
|
i_loc = FutureScanLocation(i_pos, primary=False)
|
||||||
|
# Append instantly without checking max_distance as this is between
|
||||||
|
# imaged points.
|
||||||
|
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."""
|
||||||
|
|
||||||
# Defined rather than use a lambda for readability
|
# Defined rather than use a lambda for readability
|
||||||
def sort_key(pos: XYPos) -> tuple[float, float, float]:
|
def sort_key(pos: FutureScanLocation) -> tuple[float, float, float]:
|
||||||
return (
|
return (
|
||||||
self.moves_between(current_pos, pos),
|
self.moves_between(current_pos, pos),
|
||||||
self.moves_between(self._initial_position, pos),
|
self.moves_between(self._initial_position, pos),
|
||||||
|
|
@ -356,7 +523,7 @@ class SmartSpiral(ScanPlanner):
|
||||||
if self.scan_complete:
|
if self.scan_complete:
|
||||||
raise RuntimeError("Can't get next position, scan is complete")
|
raise RuntimeError("Can't get next position, scan is complete")
|
||||||
|
|
||||||
next_location = self._remaining_locations[0]
|
next_location = self._remaining_locations[0].xy_tuple
|
||||||
|
|
||||||
# If focused locations exist, return the neighbour with the lowest z position
|
# If focused locations exist, return the neighbour with the lowest z position
|
||||||
closest_pos = self.select_nearby_focus_site(next_location)
|
closest_pos = self.select_nearby_focus_site(next_location)
|
||||||
|
|
@ -376,12 +543,14 @@ class SmartSpiral(ScanPlanner):
|
||||||
|
|
||||||
Returns None if no focused locations are present
|
Returns None if no focused locations are present
|
||||||
"""
|
"""
|
||||||
if not self._focused_locations:
|
# save to variable rather than search for focussed sites each time.
|
||||||
|
focused_locations = self.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 (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(self._focused_locations, dtype="float64")[:, :2]
|
path_pos = np.array(focused_locations, dtype="float64")[:, :2]
|
||||||
|
|
||||||
# Use linalg.norm to calculate the direct distance between the points
|
# Use linalg.norm to calculate the direct distance between the points
|
||||||
# Note linalg.norm always uses float64
|
# Note linalg.norm always uses float64
|
||||||
|
|
@ -399,7 +568,7 @@ class SmartSpiral(ScanPlanner):
|
||||||
indices = np.where(dists <= distance_cutoff)[0]
|
indices = np.where(dists <= distance_cutoff)[0]
|
||||||
|
|
||||||
# Turning into an array allows slicing based on a list
|
# Turning into an array allows slicing based on a list
|
||||||
focused_locations_array = np.array(self._focused_locations)
|
focused_locations_array = np.array(focused_locations)
|
||||||
|
|
||||||
# Choose the lowest (smallest z) of the neighbouring sites. Smart stack works best
|
# Choose the lowest (smallest z) of the neighbouring sites. Smart stack works best
|
||||||
# if started too low, so the lowest z will perform best
|
# if started too low, so the lowest z will perform best
|
||||||
|
|
@ -410,8 +579,8 @@ class SmartSpiral(ScanPlanner):
|
||||||
|
|
||||||
def moves_between(
|
def moves_between(
|
||||||
self,
|
self,
|
||||||
starting_pos: XYPos | np.ndarray,
|
starting_pos: XYPos | np.ndarray | FutureScanLocation,
|
||||||
ending_pos: XYPos | np.ndarray,
|
ending_pos: XYPos | np.ndarray | FutureScanLocation,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Return the larger of x moves or y moves between two xy positions.
|
"""Return the larger of x moves or y moves between two xy positions.
|
||||||
|
|
||||||
|
|
@ -419,6 +588,10 @@ class SmartSpiral(ScanPlanner):
|
||||||
:param ending_pos: the position to measure to
|
: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])
|
move_size = np.array([self._dx, self._dy])
|
||||||
|
|
||||||
starting_pos = np.array(starting_pos, dtype="float64")
|
starting_pos = np.array(starting_pos, dtype="float64")
|
||||||
|
|
@ -430,12 +603,17 @@ class SmartSpiral(ScanPlanner):
|
||||||
|
|
||||||
|
|
||||||
def distance_between(
|
def distance_between(
|
||||||
current_pos: XYPos | np.ndarray, next_pos: XYPos | np.ndarray
|
current_pos: XYPos | np.ndarray | FutureScanLocation,
|
||||||
|
next_pos: XYPos | np.ndarray | FutureScanLocation,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Calculate the distance between the two xy positions.
|
"""Calculate the distance between the two xy positions.
|
||||||
|
|
||||||
This was previously called ``distance_to_site``
|
This was previously called ``distance_to_site``
|
||||||
"""
|
"""
|
||||||
|
if isinstance(current_pos, FutureScanLocation):
|
||||||
|
current_pos = current_pos.xy_tuple
|
||||||
|
if isinstance(next_pos, FutureScanLocation):
|
||||||
|
next_pos = next_pos.xy_tuple
|
||||||
next_pos = np.array(next_pos, dtype="float64")
|
next_pos = np.array(next_pos, dtype="float64")
|
||||||
current_pos = np.array(current_pos, dtype="float64")
|
current_pos = np.array(current_pos, dtype="float64")
|
||||||
return float(np.linalg.norm(next_pos - current_pos))
|
return float(np.linalg.norm(next_pos - current_pos))
|
||||||
|
|
|
||||||
|
|
@ -254,17 +254,27 @@ def test_closest_focus_with_large_numbers():
|
||||||
planner = scan_planners.SmartSpiral(
|
planner = scan_planners.SmartSpiral(
|
||||||
initial_position=initial_position, planner_settings=planner_settings
|
initial_position=initial_position, planner_settings=planner_settings
|
||||||
)
|
)
|
||||||
# Directly overwrite the private focussed locations list for test
|
# Directly overwrite the private path history locations list for test
|
||||||
|
|
||||||
# For two points 1m points away it should choose the last as they are equal
|
# For two points 1m points away it should choose the last as they are equal
|
||||||
planner._focused_locations = [(1000000, 0, 0), (0, 1000000, 0)]
|
|
||||||
|
planner._path_history = [
|
||||||
|
scan_planners.VisitedScanLocation((1000000, 0, 0), imaged=True, focused=True),
|
||||||
|
scan_planners.VisitedScanLocation((0, 1000000, 0), imaged=True, focused=True),
|
||||||
|
]
|
||||||
assert planner.closest_focus_site((0, 0)) == (0, 1000000, 0)
|
assert planner.closest_focus_site((0, 0)) == (0, 1000000, 0)
|
||||||
# Try similar
|
# Try similar
|
||||||
planner._focused_locations = [(1234567, 0, 0), (-1234567, 0, 0)]
|
planner._path_history = [
|
||||||
|
scan_planners.VisitedScanLocation((1234567, 0, 0), imaged=True, focused=True),
|
||||||
|
scan_planners.VisitedScanLocation((-1234567, 0, 0), imaged=True, focused=True),
|
||||||
|
]
|
||||||
assert planner.closest_focus_site((0, 0)) == (-1234567, 0, 0)
|
assert planner.closest_focus_site((0, 0)) == (-1234567, 0, 0)
|
||||||
|
|
||||||
# Make the first point 1 step closer
|
# Make the first point 1 step closer
|
||||||
planner._focused_locations = [(1234566, 0, 0), (-1234567, 0, 0)]
|
planner._path_history = [
|
||||||
|
scan_planners.VisitedScanLocation((1234566, 0, 0), imaged=True, focused=True),
|
||||||
|
scan_planners.VisitedScanLocation((-1234567, 0, 0), imaged=True, focused=True),
|
||||||
|
]
|
||||||
assert planner.closest_focus_site((0, 0)) == (1234566, 0, 0)
|
assert planner.closest_focus_site((0, 0)) == (1234566, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -292,5 +302,6 @@ def test_example_smart_spiral():
|
||||||
expected_planner = (
|
expected_planner = (
|
||||||
scan_test_helpers.get_expected_result_for_example_smart_spiral(sample_name)
|
scan_test_helpers.get_expected_result_for_example_smart_spiral(sample_name)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert planner.path_history == expected_planner.path_history
|
assert planner.path_history == expected_planner.path_history
|
||||||
assert planner.imaged_locations == expected_planner.imaged_locations
|
assert planner.imaged_locations == expected_planner.imaged_locations
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -60,8 +60,9 @@ def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Fi
|
||||||
"""For a given sample and scanner object return a matplotlib figure of the scan."""
|
"""For a given sample and scanner object return a matplotlib figure of the scan."""
|
||||||
fig, ax = plt.subplots(figsize=(8, 8))
|
fig, ax = plt.subplots(figsize=(8, 8))
|
||||||
ax.add_artist(sample.patch)
|
ax.add_artist(sample.patch)
|
||||||
xh, yh = zip(*planner._path_history, strict=True)
|
xh, yh = zip(*planner.path_history, strict=True)
|
||||||
xi, yi, _ = zip(*planner._imaged_locations, strict=True)
|
xi, yi, _zi = zip(*planner.imaged_locations, strict=True)
|
||||||
|
xs, ys, _zs = zip(*planner.secondary_locations, strict=True)
|
||||||
|
|
||||||
# convert history to numpy array so can calculate quiver arrows
|
# convert history to numpy array so can calculate quiver arrows
|
||||||
xh = np.array(xh)
|
xh = np.array(xh)
|
||||||
|
|
@ -78,6 +79,7 @@ def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Fi
|
||||||
)
|
)
|
||||||
plt.plot(xh, yh, "r.")
|
plt.plot(xh, yh, "r.")
|
||||||
plt.plot(xi, yi, "g*")
|
plt.plot(xi, yi, "g*")
|
||||||
|
plt.plot(xs, ys, "o", mfc="none", mec="blue")
|
||||||
ax.axis("equal")
|
ax.axis("equal")
|
||||||
return fig
|
return fig
|
||||||
|
|
||||||
|
|
@ -99,7 +101,7 @@ def interp_closed_path(xy_points: list[tuple[int, int]], n_points: int) -> MatPa
|
||||||
|
|
||||||
# fit splines to x=f(u) and y=g(u), treating both as periodic. also note that s=0
|
# fit splines to x=f(u) and y=g(u), treating both as periodic. also note that s=0
|
||||||
# is needed in order to force the spline fit to pass through all the input points.
|
# is needed in order to force the spline fit to pass through all the input points.
|
||||||
spline_data, _ = interpolate.splprep([x, y], s=0, per=True)
|
spline_data, *_unused = interpolate.splprep([x, y], s=0, per=True)
|
||||||
|
|
||||||
# evaluate the spline
|
# evaluate the spline
|
||||||
xi, yi = interpolate.splev(np.linspace(0, 1, n_points), spline_data)
|
xi, yi = interpolate.splev(np.linspace(0, 1, n_points), spline_data)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue