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 5b7ba2de..b3b47db8 100644 Binary files a/tests/utilities/example_smart_spiral_core.pkl and b/tests/utilities/example_smart_spiral_core.pkl differ diff --git a/tests/utilities/example_smart_spiral_lobed.pkl b/tests/utilities/example_smart_spiral_lobed.pkl index a935e162..21bcf3e1 100644 Binary files a/tests/utilities/example_smart_spiral_lobed.pkl and b/tests/utilities/example_smart_spiral_lobed.pkl differ diff --git a/tests/utilities/example_smart_spiral_regular.pkl b/tests/utilities/example_smart_spiral_regular.pkl index ddf784c8..aa9b847b 100644 Binary files a/tests/utilities/example_smart_spiral_regular.pkl and b/tests/utilities/example_smart_spiral_regular.pkl differ 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