Merge branch 'shorter-move-spiral-path' into 'v3'

Add a scan path planner that prioritises shorter moves

See merge request openflexure/openflexure-microscope-server!245
This commit is contained in:
Julian Stirling 2025-04-17 15:37:52 +00:00
commit 257d700333
7 changed files with 86 additions and 30 deletions

View file

@ -241,7 +241,9 @@ class ScanPlanner:
class SmartSpiral(ScanPlanner): class SmartSpiral(ScanPlanner):
""" """
This is a smart spiral scan that spirals out from the centre. This is a smart spiral scan that spirals out from the centre, but prioritises
short moves over rigidly sticking to minimising radius from the centre of the
scan.
Each time and image is taken the four neighbouring images are added Each time and image is taken the four neighbouring images are added
to the list of poisitions to image (unless they are already listed or to the list of poisitions to image (unless they are already listed or
@ -335,29 +337,33 @@ class SmartSpiral(ScanPlanner):
# Defined rather than use a lambda for readability # Defined rather than use a lambda for readability
def sort_key(pos): def sort_key(pos):
return self.moves_from_centre(pos), distance_between(current_pos, pos) return (
self.moves_between(current_pos, pos),
self.moves_between(self._initial_position, pos),
distance_between(current_pos, pos),
)
self._remaining_locations.sort(key=sort_key) self._remaining_locations.sort(key=sort_key)
def moves_from_centre( def moves_between(
self, self,
xy_pos: XYPos, starting_pos: XYPos | np.ndarray,
ending_pos: XYPos | np.ndarray,
) -> float: ) -> float:
""" """
Return the number of moves from the centre in the x or y direction Return the number of moves between two xy positions in the x or y direction
whichever is largest whichever is largest
Args: Args:
xy_pos: the position starting_pos: the position to measure from
ending_pos: the position to measure to
Note this has been renamed from `steps_from_centre` as that implied
stepper motor steps not number of moves in a scan
""" """
move_size = np.array([self._dx, self._dy]) move_size = np.array([self._dx, self._dy])
starting_pos = np.array(self._initial_position, dtype="float64")
current_pos = np.array(xy_pos, dtype="float64")
displacement_in_moves = (current_pos - starting_pos) / move_size 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)) return np.max(np.abs(displacement_in_moves))

View file

@ -253,7 +253,20 @@ def test_closest_focus_wth_large_numbers():
def test_example_smart_spiral(): def test_example_smart_spiral():
_, planner = scan_test_helpers.example_smart_spiral() """Test the smart spiral scan algorithm on the sample types listed
expected_planner = scan_test_helpers.get_expected_result_for_example_smart_spiral() below and defined in scan_test_helpers.load_sample_points
assert planner.path_history == expected_planner.path_history
assert planner.imaged_locations == expected_planner.imaged_locations Will fail if the locations or path between locations visited has changed
for any of the samples listed"""
example_samples = [
"regular",
"lobed",
"core",
]
for sample_name in example_samples:
_, planner = scan_test_helpers.example_smart_spiral(sample_name)
expected_planner = (
scan_test_helpers.get_expected_result_for_example_smart_spiral(sample_name)
)
assert planner.path_history == expected_planner.path_history
assert planner.imaged_locations == expected_planner.imaged_locations

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -111,22 +111,18 @@ def interp_closed_path(xy_points: list[tuple[int, int]], n_points: int) -> MatPa
return MatPath(path_points, closed=True) return MatPath(path_points, closed=True)
def example_smart_spiral() -> tuple[FakeSample, scan_planners.ScanPlanner]: def example_smart_spiral(
sample_name: str = "lobed",
) -> tuple[FakeSample, scan_planners.ScanPlanner]:
""" """
Run an example scan and return the sample scanned and the planner object Run an example scan and return the sample scanned and the planner object
after scan is complete after scan is complete
""" """
xy_sample_points = [ xy_sample_points = load_sample_points(sample_name)
(-5000, -5000),
(-2000, 10000),
(1000, 2000),
(6000, 7000),
(9000, 2000),
]
sample = FakeSample(xy_sample_points) sample = FakeSample(xy_sample_points)
img_size = (1000, 1000) img_size = (1000, 1000)
intial_position = (0, 0) intial_position = (0, 0)
planner_settings = {"dx": 700, "dy": 700, "max_dist": 100000} planner_settings = {"dx": 1200, "dy": 800, "max_dist": 100000}
planner = scan_planners.SmartSpiral( planner = scan_planners.SmartSpiral(
intial_position=intial_position, planner_settings=planner_settings intial_position=intial_position, planner_settings=planner_settings
) )
@ -166,7 +162,7 @@ def profile_and_save_plot_for_example_smart_spiral():
run_stats.print_stats("scan_planners.py") run_stats.print_stats("scan_planners.py")
def update_example_smart_spiral_pickle(): def update_example_smart_spiral_pickle(sample_name: str):
""" """
Pickle the ScanPlanner for the example_smart_spiral(), Pickle the ScanPlanner for the example_smart_spiral(),
this is done so the history can be compared by testing to check this is done so the history can be compared by testing to check
@ -174,23 +170,64 @@ def update_example_smart_spiral_pickle():
If the algorithm is purposefully changed then this will need to be If the algorithm is purposefully changed then this will need to be
run to update the pickle for the test to pass. run to update the pickle for the test to pass.
Takes sample, the sample type we have generated, so we can make a
pickle for each sample type
""" """
pkl_fname = os.path.join(THIS_DIR, "example_smart_spiral.pkl") pkl_fname = os.path.join(THIS_DIR, f"example_smart_spiral_{sample_name}.pkl")
_, planner = example_smart_spiral(sample_name)
with open(pkl_fname, "wb") as pkl_file_obj: with open(pkl_fname, "wb") as pkl_file_obj:
_, planner = example_smart_spiral()
pickle.dump(planner, pkl_file_obj, pickle.HIGHEST_PROTOCOL) pickle.dump(planner, pkl_file_obj, pickle.HIGHEST_PROTOCOL)
def get_expected_result_for_example_smart_spiral(): def get_expected_result_for_example_smart_spiral(
sample_name: str,
) -> scan_planners.ScanPlanner:
""" """
Return the expected ScanPlanner object for the example_smart_spiral(), Return the expected ScanPlanner object for the example_smart_spiral(),
this is pickled, so that it can be committed. this is pickled, so that it can be committed.
""" """
pkl_fname = os.path.join(THIS_DIR, "example_smart_spiral.pkl") pkl_fname = os.path.join(THIS_DIR, f"example_smart_spiral_{sample_name}.pkl")
with open(pkl_fname, "rb") as pkl_file_obj: with open(pkl_fname, "rb") as pkl_file_obj:
planner = pickle.load(pkl_file_obj) planner = pickle.load(pkl_file_obj)
return planner return planner
def load_sample_points(sample_name: str):
"""Return the points to generate the FakeSample corresponding to the given input name
Options are "lobed", "regular", and "core".
"""
sample_options = {
"lobed": [
(-5000, -5000),
(-2000, 16000),
(1000, 2000),
(6000, 7000),
(9000, 2000),
],
"regular": [
(-5000, -5000),
(-5000, 5000),
(5000, 5000),
(5000, -5000),
],
"core": [
(-12000, 2000),
(-12000, 1000),
(0, -2000),
(10000, -2000),
(10000, -1000),
(1000, 0),
],
}
if sample_name not in sample_options:
all_samples = ", ".join(sample_options.keys())
raise ValueError(
f"{sample_name} is not a valid sample name. Valid names : {all_samples}"
)
return sample_options[sample_name]
if __name__ == "__main__": if __name__ == "__main__":
profile_and_save_plot_for_example_smart_spiral() profile_and_save_plot_for_example_smart_spiral()