From 39a04df5bef1820205ceee6f42d4676863da85f0 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 19 Oct 2025 19:55:36 +0100 Subject: [PATCH 1/4] Objects for scan locations allowing more sophisitcated tracking. This commit updates the scan planners to allow more sophisticated tracking of past positions. Currently it still checks that the algorithm is unchanged from past behaviour. --- .../scan_planners.py | 233 ++++++++++++------ tests/test_scan_planners.py | 31 ++- tests/utilities/scan_test_helpers.py | 6 +- 3 files changed, 191 insertions(+), 79 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 7387e70d..f3ace6f1 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -5,7 +5,11 @@ is only one type the SmartSpiral. More can be added using by 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 from copy import copy @@ -51,6 +55,101 @@ def enforce_xyz_tuple(value: XYZPos) -> XYZPos: 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: XYPos | FutureScanLocation) -> 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: XYPos | XYZPos | FutureScanLocation | VisitedScanLocation + ) -> 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: """A base class for a scan planner. @@ -79,25 +178,10 @@ class ScanPlanner: self._initial_position = enforce_xy_tuple(initial_position) self._parse(planner_settings) - # The remaining (x,y) locations to scan - # (This was `path` before refactoring from the long `sample_scan` code) - self._remaining_locations: XYPosList = self._initial_location_list() - - # 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 = [] + self._remaining_locations: list[FutureScanLocation] = ( + self._initial_location_list() + ) + self._path_history: list[VisitedScanLocation] = [] @property def scan_complete(self) -> bool: @@ -107,48 +191,47 @@ class ScanPlanner: @property def remaining_locations(self) -> XYPosList: """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 def imaged_locations(self) -> XYZPosList: """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 def focused_locations(self) -> XYZPosList: """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 def path_history(self) -> XYPosList: """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: """Parse any settings sent to this planner and store them if needed.""" 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. This is called on initialisation. 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] - lists or matching errors could occur. + :return: A list of FutureScanLocation objects with all planned locations. """ raise NotImplementedError("Did you call the ScanPlanner base class?") - def position_visited(self, position: XYPos) -> bool: - """Return True if input xy position has been visited before.""" + def position_visited(self, position: FutureScanLocation) -> bool: + """Return True if input scan position has been visited before.""" # 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: - """Return True if input xy position is planned.""" + def position_planned(self, position: FutureScanLocation) -> bool: + """Return True if input scan position position is planned.""" # 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]]: """Return the next location to scan and its estimated z-position. @@ -159,7 +242,7 @@ class ScanPlanner: if self.scan_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 closest_pos = self.closest_focus_site(next_location) @@ -177,12 +260,14 @@ class ScanPlanner: 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 # must be float64 (double precision) to deal with the huge numbers involved! 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 # Note linalg.norm always uses float64 @@ -193,36 +278,35 @@ class ScanPlanner: indices = np.where(dists == np.min(dists))[0] # The last index is most recent - return self._focused_locations[indices[-1]] + return focused_locations[indices[-1]] def mark_location_visited( self, xyz_pos: XYZPos, imaged: bool, focused: bool ) -> None: """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 """ # ensure is tuple! xyz_pos = enforce_xyz_tuple(xyz_pos) - xy_pos = xyz_pos[:2] # Remove the expected position from the remaining locations list # and check it's correct - expected_pos = tuple(self._remaining_locations.pop(0)) - if xy_pos != expected_pos: + expected_pos = self._remaining_locations.pop(0) + if xyz_pos[:2] != expected_pos.xy_tuple: raise RuntimeError("Wrong scan location visited!") # Append xy position for path_history - self._path_history.append(xy_pos) - # And full x,y,z for imaged and foucsed if appropriate - if imaged: - self._imaged_locations.append(xyz_pos) - if focused: - self._focused_locations.append(xyz_pos) + self._path_history.append( + VisitedScanLocation( + xyz_pos=xyz_pos, + imaged=imaged, + focused=focused, + **expected_pos.planner_data, + ) + ) class SmartSpiral(ScanPlanner): @@ -275,25 +359,23 @@ class SmartSpiral(ScanPlanner): self._dy = int(planner_settings["dy"]) 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. This is salled on initialisation. For smart spiral this is just the first point """ - return [self._initial_position] + return [FutureScanLocation(self._initial_position)] def mark_location_visited( self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True ) -> None: - """Mark the location as visited. Adjust extra positions accordingly. - - 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 + """Mark the location as visited. + :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 super().mark_location_visited(xyz_pos, imaged, focused) @@ -314,10 +396,10 @@ class SmartSpiral(ScanPlanner): * already visited """ new_positions = [ - (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), + FutureScanLocation((xy_pos[0] - self._dx, xy_pos[1])), + FutureScanLocation((xy_pos[0] + self._dx, xy_pos[1])), + FutureScanLocation((xy_pos[0], xy_pos[1] - self._dy)), + FutureScanLocation((xy_pos[0], xy_pos[1] + self._dy)), ] for new_pos in new_positions: @@ -356,7 +438,7 @@ class SmartSpiral(ScanPlanner): if self.scan_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 closest_pos = self.select_nearby_focus_site(next_location) @@ -376,12 +458,14 @@ class SmartSpiral(ScanPlanner): 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 # must be float64 (double precision) to deal with the huge numbers involved! 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 # Note linalg.norm always uses float64 @@ -399,7 +483,7 @@ class SmartSpiral(ScanPlanner): indices = np.where(dists <= distance_cutoff)[0] # 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 # if started too low, so the lowest z will perform best @@ -410,8 +494,8 @@ class SmartSpiral(ScanPlanner): def moves_between( self, - starting_pos: XYPos | np.ndarray, - ending_pos: XYPos | np.ndarray, + 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. @@ -419,6 +503,10 @@ class SmartSpiral(ScanPlanner): :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") @@ -430,12 +518,17 @@ class SmartSpiral(ScanPlanner): 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: """Calculate the distance between the two xy positions. 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") current_pos = np.array(current_pos, dtype="float64") return float(np.linalg.norm(next_pos - current_pos)) diff --git a/tests/test_scan_planners.py b/tests/test_scan_planners.py index 89996945..940257d0 100644 --- a/tests/test_scan_planners.py +++ b/tests/test_scan_planners.py @@ -254,17 +254,27 @@ def test_closest_focus_with_large_numbers(): planner = scan_planners.SmartSpiral( 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 - 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) # 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) # 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) @@ -292,5 +302,14 @@ def test_example_smart_spiral(): 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 + # Use hasattr to check if the saved test data is the old object where imaged + # locations were saved directly as a list of tuples rather than being generated + # from a history of VisitedScanLocation objects. + # This "if" section of this if-else block can be deleted once we are confident + # of the new format and the pickles are updated + if hasattr(expected_planner, "_imaged_locations"): + assert planner.path_history == expected_planner._path_history + assert planner.imaged_locations == expected_planner._imaged_locations + else: + assert planner.path_history == expected_planner.path_history + assert planner.imaged_locations == expected_planner.imaged_locations diff --git a/tests/utilities/scan_test_helpers.py b/tests/utilities/scan_test_helpers.py index 288996ac..cec99249 100644 --- a/tests/utilities/scan_test_helpers.py +++ b/tests/utilities/scan_test_helpers.py @@ -60,8 +60,8 @@ def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Fi """For a given sample and scanner object return a matplotlib figure of the scan.""" fig, ax = plt.subplots(figsize=(8, 8)) ax.add_artist(sample.patch) - xh, yh = zip(*planner._path_history, strict=True) - xi, yi, _ = zip(*planner._imaged_locations, strict=True) + xh, yh = zip(*planner.path_history, strict=True) + xi, yi, _zi = zip(*planner.imaged_locations, strict=True) # convert history to numpy array so can calculate quiver arrows xh = np.array(xh) @@ -99,7 +99,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 # 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, *_extra = interpolate.splprep([x, y], s=0, per=True) # evaluate the spline xi, yi = interpolate.splev(np.linspace(0, 1, n_points), spline_data) From f781e6b674b7f569ac092f0d015b98d14709af7a Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 19 Oct 2025 21:29:11 +0100 Subject: [PATCH 2/4] Image intermediate locations between sample and background --- .../scan_planners.py | 97 +++++++++++++++--- tests/test_scan_planners.py | 14 +-- tests/utilities/example_smart_spiral_core.pkl | Bin 4139 -> 10004 bytes .../utilities/example_smart_spiral_lobed.pkl | Bin 8295 -> 18596 bytes .../example_smart_spiral_regular.pkl | Bin 5877 -> 12935 bytes tests/utilities/scan_test_helpers.py | 2 + 6 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index f3ace6f1..6f04fa95 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -341,6 +341,19 @@ class SmartSpiral(ScanPlanner): super().__init__(initial_position, planner_settings) self._distance_cutoff: float = max([self._dx, self._dy]) * 1.1 + def _is_primary_location(self, location: FutureScanLocation) -> 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: """Parse SmartSpiral Settings dictionary. @@ -366,7 +379,7 @@ class SmartSpiral(ScanPlanner): For smart spiral this is just the first point """ - return [FutureScanLocation(self._initial_position)] + return [FutureScanLocation(self._initial_position, primary=True)] def mark_location_visited( self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True @@ -381,8 +394,11 @@ class SmartSpiral(ScanPlanner): super().mark_location_visited(xyz_pos, imaged, focused) xy_pos = enforce_xy_tuple(xyz_pos[:2]) - if imaged: - self._add_surrounding_positions(xy_pos) + if self._is_primary_location(self._path_history[-1]): + if imaged: + self._add_surrounding_positions(xy_pos) + else: + self._add_intermediate_positions(xy_pos) self._re_sort_remaining_locations(xy_pos) def _add_surrounding_positions(self, xy_pos: XYPos) -> None: @@ -394,24 +410,81 @@ class SmartSpiral(ScanPlanner): * too far away * already planned * 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 = [ - FutureScanLocation((xy_pos[0] - self._dx, xy_pos[1])), - FutureScanLocation((xy_pos[0] + self._dx, xy_pos[1])), - FutureScanLocation((xy_pos[0], xy_pos[1] - self._dy)), - FutureScanLocation((xy_pos[0], xy_pos[1] + self._dy)), - ] + new_positions = self._adjacent_positions(xy_pos) for new_pos in new_positions: - # Skip position if already planned or visited - if self.position_planned(new_pos) or self.position_visited(new_pos): + # Skip position if already planned + if self.position_planned(new_pos): + continue + if self.position_visited(new_pos): + # Get the VisitedScanLocation object if already visited + visited = self._path_history[self._path_history.index(new_pos)] + if visited.imaged: + # If this adjacent position was imaged succsfully already then skip. + continue + # If it wasn't imaged add an intimediate 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 dist = distance_between(new_pos, self._initial_position) if dist > self._max_dist: LOGGER.debug("Rejected moving to %s as it is out of range", new_pos) 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. + + 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._path_history[self._path_history.index(surr_pos)] + if not visited.imaged: + # If it wasn't imaged then skip this position + continue + # If surrpounding location was imaged add an intimediate 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.""" + return tuple((i + j) // 2 for i, j in zip(xy_pos1, xy_pos2, strict=True)) def _re_sort_remaining_locations(self, current_pos: XYPos) -> None: """Sort the remaining positions based on the current location.""" diff --git a/tests/test_scan_planners.py b/tests/test_scan_planners.py index 940257d0..f18d6c81 100644 --- a/tests/test_scan_planners.py +++ b/tests/test_scan_planners.py @@ -302,14 +302,6 @@ def test_example_smart_spiral(): expected_planner = ( scan_test_helpers.get_expected_result_for_example_smart_spiral(sample_name) ) - # Use hasattr to check if the saved test data is the old object where imaged - # locations were saved directly as a list of tuples rather than being generated - # from a history of VisitedScanLocation objects. - # This "if" section of this if-else block can be deleted once we are confident - # of the new format and the pickles are updated - if hasattr(expected_planner, "_imaged_locations"): - assert planner.path_history == expected_planner._path_history - assert planner.imaged_locations == expected_planner._imaged_locations - else: - assert planner.path_history == expected_planner.path_history - assert planner.imaged_locations == expected_planner.imaged_locations + + assert planner.path_history == expected_planner.path_history + assert planner.imaged_locations == expected_planner.imaged_locations diff --git a/tests/utilities/example_smart_spiral_core.pkl b/tests/utilities/example_smart_spiral_core.pkl index 5b7ba2de52d49d0611a2eae688f1d446428e50e6..b3b47db8adccf808706d79aa6eee7f0849f6b4a7 100644 GIT binary patch literal 10004 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 D4qSL} diff --git a/tests/utilities/example_smart_spiral_lobed.pkl b/tests/utilities/example_smart_spiral_lobed.pkl index a935e1620650c6113a53c3ddd9bd23f840ef4784..21bcf3e1bbab6bcc69da41d7198655632dbeebb7 100644 GIT binary patch literal 18596 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 literal 8295 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 diff --git a/tests/utilities/example_smart_spiral_regular.pkl b/tests/utilities/example_smart_spiral_regular.pkl index ddf784c8d469a1f5cb2d4bddb6d5d65e58aab204..aa9b847b85d4bdca9608b2339298485e0039b300 100644 GIT binary patch literal 12935 zcmZo*omykW00uqU`30$YX*sDCrA4XnxtYmD`Nhfk1*!4HsYPX}MS8`_iFxq_If;3B zsYS(8dbopg6N^fM3o?rmbEZt5qS-j5c8W%iV0>m?W=UpZPJBUrab`(oe%=&s25*M8 zDLu^bDHT(EH?V*hl~a5bn0q+ma}z7#Q!{LA{`l`9T{S^Q!*q_6?p~x{{Np?b)ax*pF+SeUj=6TR(dlK zRN}jVg%~x2+~d1}g$S>KR2}$9D4@IWGL%kUB!4;RRAe zFjyg~9N6&3A=sn7gj9Jm5L5z+X+p;MCU6oqCxMfo*L)K=35Ahw0w*ykCV`WP$OD-L z3Ud4*2}*s0Re3Xj;|0G_;7B1x9mqan)P4B)|3A^{h)DHd^N2{zAa#Ud$_u23V3b2t z5lUbXRiM;?w{!yq8#oQ&RRted+M?~@j+1T)zQ27qZtwgBWz~V(%l{W(siXgd_uwh^y5@Q}H zLlC3R;qCwbM5_a(c><*l*gPU~KUf_RxgVsCP%82QDI%C0A*u-Gey~}DvJu3r1|9-| z0g41)f~r8FN>~-RBq3y$Hv>U~KqUzwV?ZSdK{Wz=gzY*YM3fpr!R>oMh;WH?K!{)x z@I4?zC@uLO5F(~TIv_+$iF7~+oL}*$5|E9AGd@UF!UuxomH?j@5vme6iBYwIg$PyN z3`8h`lx~C_1`aJ^%mZaCV$^{$GcoEWy!ij0Xmx}m%L{BCC|~1`dWbqAN=vYLM3j~w zb%atl*iwR71)_>zX$i8BV1oi;7NG(KqG|y@fgl4#sV_lQpaeo#6)1)Xs{)t2glzO? zAZQS%WKcD;ch zB=`}k(4rHR%)u>UkXeXCiIzA(i5H71Y-T+Wh1iJGEKml-ViuZ>pzKUo6)1fXRt2uR zVc~_#UET}?4Fc8Ogp2{z-2~ML@DaA_fDmCd1>%I&D9FIoAQAy;VEdksgsVXW4{GH4 zD##FSCn(7H5=wBs7Zl*;;B-%g92Pa0?je+&KyD_KF+pkwwn-FZ2o@H;3NnOB9bW|* zV%j7MGQ_k=6lB0PBmTSwvXO964N^t8!U3rQWnGAkIHOKM#*1*JQx%$7p!rU?9}za9xeGLpPEZwS zy#YZ*kTQ^PP(aE+BGiHN5)tZVya%mwz~VmC00B=0dJzs@h&sXn4DkmM=7IA%!aPJ& zpt%p6*NHF>td6jGpk)mN(~>s>Qc}X@GEl)m*j*r1gk1$HUWhO&fs+__Dac^0gV2)r z2}!J~P($DMf&$W*G0q~PLJq4cOtTQpHGF1aQ-x_3C;>qV4t!=|Q-x_3qKA&pEQBhY zY2tzcQX?3jS=jpe8190#x$zhTQ-a4u@YX#ZW8iA=nFH%d;4=rN2A?^wUJE{RFx6m$ z4t(AXpElfX$>%?1{NYP=Z;DgjqJgjIoZ2Vqs9 zDu=KtP$QH`RSc4ljEhJlsD%@_vhgLT3ha7oS literal 5877 zcmZo*o%%|Q0StPy^9xe*(sEKON{dqCb2F2R@{5!63sU2YQ;W({i}Z?<6Z7H=auW0M zQj3bG^l%5~CKi?oF{JbgN4BiZF zQ+k-=Q!1wTZeRg1DyR4=F!ylA=O$Lfr(_nFOz~RK#>mhk5?_>>o0yrGnU@})lb@Uj z(q23zb_&SU+{EE?acsXfZPQU@=f4`i6wACwfiP;Ld^6{;6%7JffMcskT}RXkoh1#z}VglV4Fe0 zU~3UPkO~Cv!^i*s(RgsbgXQ4<1Mwhk@d7cS4ur5F9)PexE^eO!HWTc6SbRXly&0f_ zAb&%IK>miZ1o&XG0(`Kr5a5Gapv+ zHZ*=fN?`E=WGe8AF@dgnB#T%3*zz35( zAOvF-h$FEe4)84yhdK+yf;bk$f)s(i1>z8Y`4)&H;<7*-5tjwx;LrxSAEX8rcpx?` zIzViY*T8BH2!WF)m@UAEU?*@Q*c({jY;OiQ6B5TTgTS^T;g;$V55S%0sJdipB59E3T59EG0&zk|x1l8*><3M#8oGrkIP;)>C z!7dO-un!1%F+7Jj1ym=(*$JEo@eM3+wl@Qu3HB?32X-q=J2(yya-i5j@W6fqyD%Mj0y$dJsC%8<^G$&k&E%aG4d$WY8s%23Ww$xzKu%TUkI$k5Et%Fxcx z$$}r9_$uP|@%P`Nd$gs??%COF`$*|3^%dpRI$Z*VX%5ctb$#BhZ z%W%)|$nebY%J9zc$?(na%ka+#$Oy~`$_UN~$q3B|%Lvbi$cW5{%81U0$%xH}%ZSfN z$Vkjc%1F*g$wh$;i#f%gE0t$SBMx$|%k#$tcYz%P7yN$f(Sy z%Baq$$*9e!%c#$2$Y{)H%4p7L$!N`J%V^K&$mq=I%IMDM$>`1K%jnOTkTEf1QpV(r zDH&5Ure#den2|9vV^+rOj5!%|Gv;N?&sdPLFk?~1;*2F3OEZ>bEYDbxu`**-#_EhU z8EZ4vWvtKGkg+jiQ^w|uEg4%gwqCFP9M3qBaWdmn#_5bR8D}%jWt`8rka02NQpV+sD;ZZau4P=$xRG%)<5tG)j5`^3 zGwx;F&v=mWFym3ig31k$Fo*`b22^ddO#x+CsB56jc8D0r z4zOmh(O?%sgdi@2vOyG70VKzGG8zYWHN*W4jqsfxOoi;9S|23I$&A=)Ua-w;tOt8LuhcQft%D2 zJ~*^MH9D9J3oU3<8X^X=5W<3Z(D#54xIGIAUr^Nx2|*|e+I$9wAVdfff>1WdG^loP zl*8B{@59*ODhjF|%!XJDZp*;Z8(16?r4Vsw$b!VdAq!$aLKe&dhb)){3IebeNGO8E z7VtwO%NNW7r%Esj@U=5%qE?B%k9BdJY53(9W zgB$~*!J!Xo0Jlv6HDMuM0I?t*1F=93fXG4u0VDdF8{!;rEegvIU^Nh@L&PB-g0Lap1hc_0 z1yT%&DKHBZIABRg9srAhk~2gM6r>Q=0im`jkaz^MK+g3Avp}AJus|+=u)whgk_9(v z!GQvn0L30i7*ukA=>wp~EI7a+@(MC=8e}!7!3uH=hz7?dxGCE<1;hu(CaCoV=ECw4 zv`~UxgL)r_>0;LxS3ltj=7ASroEKsTe zvp`V@WnN3dF(9Y;Y{WSOR=77PL(du>;gp2gM3h5*#lu zHrS021>ksqiGyMX#s>Qpss@zWpiTg%MsV=}tutY4u$dr*u)+_*hPLq`Zh{Db{0(J6 tTlWzEAjA$l0JUa7>Y%I*EM5>6XlxcN3Goj^5FBk#1rYy0#8XR?^Z>L3qk#Yb diff --git a/tests/utilities/scan_test_helpers.py b/tests/utilities/scan_test_helpers.py index cec99249..675e36dd 100644 --- a/tests/utilities/scan_test_helpers.py +++ b/tests/utilities/scan_test_helpers.py @@ -62,6 +62,7 @@ def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Fi ax.add_artist(sample.patch) xh, yh = zip(*planner.path_history, 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 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(xi, yi, "g*") + plt.plot(xs, ys, "o", mfc="none", mec="blue") ax.axis("equal") return fig From dbb0a4ac16c87dd5c770d0a9bf1137d298608ed2 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 19 Oct 2025 23:04:24 +0100 Subject: [PATCH 3/4] Fix issues found by MyPy --- .../scan_planners.py | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 6f04fa95..f7c64367 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -79,7 +79,7 @@ class FutureScanLocation: # noqa PLW1641 """The xy position tuple.""" return self._xy_pos - def __eq__(self, other: XYPos | FutureScanLocation) -> bool: + def __eq__(self, other: Any) -> bool: """Check for equality, only checks the xy position. Will check against tuple or other FutureScanLocation object. @@ -128,9 +128,7 @@ class VisitedScanLocation: # noqa PLW1641 """The xy position tuple.""" return self._xyz_pos[:2] - def __eq__( - self, other: XYPos | XYZPos | FutureScanLocation | VisitedScanLocation - ) -> bool: + 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 @@ -223,12 +221,22 @@ class ScanPlanner: """ raise NotImplementedError("Did you call the ScanPlanner base class?") - def position_visited(self, position: FutureScanLocation) -> bool: + def position_visited(self, position: XYPos | FutureScanLocation) -> bool: """Return True if input scan position has been visited before.""" # Ensure tuple for correct matching! return position in self._path_history - def position_planned(self, position: FutureScanLocation) -> bool: + def get_visited_location( + 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! return position in self._remaining_locations @@ -341,7 +349,9 @@ class SmartSpiral(ScanPlanner): super().__init__(initial_position, planner_settings) self._distance_cutoff: float = max([self._dx, self._dy]) * 1.1 - def _is_primary_location(self, location: FutureScanLocation) -> bool: + 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"] @@ -423,7 +433,7 @@ class SmartSpiral(ScanPlanner): continue if self.position_visited(new_pos): # Get the VisitedScanLocation object if already visited - visited = self._path_history[self._path_history.index(new_pos)] + visited = self.get_visited_location(new_pos) if visited.imaged: # If this adjacent position was imaged succsfully already then skip. continue @@ -460,7 +470,7 @@ class SmartSpiral(ScanPlanner): for surr_pos in surrounding_positions: if self.position_visited(surr_pos): # Get the VisitedScanLocation object if already visited - visited = self._path_history[self._path_history.index(surr_pos)] + visited = self.get_visited_location(surr_pos) if not visited.imaged: # If it wasn't imaged then skip this position continue @@ -484,13 +494,15 @@ class SmartSpiral(ScanPlanner): def _intermediate_position(self, xy_pos1: XYPos, xy_pos2: XYPos) -> XYPos: """Return an (x,y) position halfway between two input positions.""" - return tuple((i + j) // 2 for i, j in zip(xy_pos1, xy_pos2, strict=True)) + 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: """Sort the remaining positions based on the current location.""" # 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 ( self.moves_between(current_pos, pos), self.moves_between(self._initial_position, pos), From ff606ff10d2ce2b9cbdfc3c4c9ee3dcbc64cfaf9 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 20 Oct 2025 17:13:22 +0000 Subject: [PATCH 4/4] Apply spelling corrections and variable renaming from code review of branch improve-scan-planning Co-authored-by: Joe Knapper --- src/openflexure_microscope_server/scan_planners.py | 8 ++++---- tests/utilities/scan_test_helpers.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index f7c64367..2248a55c 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -435,9 +435,9 @@ class SmartSpiral(ScanPlanner): # Get the VisitedScanLocation object if already visited visited = self.get_visited_location(new_pos) if visited.imaged: - # If this adjacent position was imaged succsfully already then skip. + # If this adjacent position was imaged successfully already then skip. continue - # If it wasn't imaged add an intimediate location between the + # 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. @@ -459,7 +459,7 @@ class SmartSpiral(ScanPlanner): This is called after an image is recorded that was background. Intermediate locations are added between any adjacent locations that were successfully - imaged. + 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 @@ -474,7 +474,7 @@ class SmartSpiral(ScanPlanner): if not visited.imaged: # If it wasn't imaged then skip this position continue - # If surrpounding location was imaged add an intimediate location + # 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. diff --git a/tests/utilities/scan_test_helpers.py b/tests/utilities/scan_test_helpers.py index 675e36dd..4d32a2fb 100644 --- a/tests/utilities/scan_test_helpers.py +++ b/tests/utilities/scan_test_helpers.py @@ -101,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 # is needed in order to force the spline fit to pass through all the input points. - spline_data, *_extra = interpolate.splprep([x, y], s=0, per=True) + spline_data, *_unused = interpolate.splprep([x, y], s=0, per=True) # evaluate the spline xi, yi = interpolate.splev(np.linspace(0, 1, n_points), spline_data)