From 2ced8486959eaef44b1b8054a180f14114dcdd1c Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 13 Apr 2025 18:01:06 +0100 Subject: [PATCH] Add functionality for testing the scan planner on a given sample shape --- .gitignore | 4 + pyproject.toml | 3 + tests/__init__.py | 0 tests/test_scan_planners.py | 13 +- tests/utilities/__init__.py | 3 + tests/utilities/example_smart_spiral.pkl | Bin 0 -> 13513 bytes tests/utilities/scan_test_helpers.py | 196 +++++++++++++++++++++++ 7 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/utilities/__init__.py create mode 100644 tests/utilities/example_smart_spiral.pkl create mode 100644 tests/utilities/scan_test_helpers.py diff --git a/.gitignore b/.gitignore index 02c38a88..4f6925c9 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,7 @@ openflexure_microscope/cobertura.xml # web app build /src/openflexure_microscope_server/static/ + +# Files created by test utilities +/tests/utilities/*.pstats +/tests/utilities/*.png diff --git a/pyproject.toml b/pyproject.toml index 747efe26..53ec72fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ "mypy-gitlab-code-quality", # "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings "pytest", + "matplotlib~=3.10" ] pi = [ "labthings-picamera2 == 0.0.2-dev0", @@ -81,6 +82,8 @@ addopts = [ norecursedirs = [".git", "build", "node_modules"] +pythonpath = ["."] + [tool.ruff.format] # Use native line endings for all files line-ending = "native" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_scan_planners.py b/tests/test_scan_planners.py index 3cb3e566..e4699d91 100644 --- a/tests/test_scan_planners.py +++ b/tests/test_scan_planners.py @@ -1,7 +1,9 @@ import pytest -from openflexure_microscope_server import scan_planners from copy import copy +from openflexure_microscope_server import scan_planners +from .utilities import scan_test_helpers + def test_enforce_xy_tuple(): bad_len_vals = [[1], [], (1,), (2, 4, 4), [1, 4, 5, 7]] @@ -186,7 +188,7 @@ def test_smart_spiral_first_few_pos(): assert planner.closest_focus_site(xy_pos4) == xyz_pos3 -def test_scan_stops_on_max_dist(): +def test_smart_spiral_stops_on_max_dist(): intial_position = (0, 0) planner_settings = {"dx": 100, "dy": 100, "max_dist": 1000} # Create a planner @@ -248,3 +250,10 @@ def test_closest_focus_wth_large_numbers(): # Make the first point 1 step closer planner._focused_locations = [(1234566, 0, 0), (-1234567, 0, 0)] assert planner.closest_focus_site((0, 0)) == (1234566, 0, 0) + + +def test_example_smart_spiral(): + _, planner = scan_test_helpers.example_smart_spiral() + expected_planner = scan_test_helpers.get_expected_result_for_example_smart_spiral() + assert planner.path_history == expected_planner.path_history + assert planner.imaged_locations == expected_planner.imaged_locations diff --git a/tests/utilities/__init__.py b/tests/utilities/__init__.py new file mode 100644 index 00000000..275ec31c --- /dev/null +++ b/tests/utilities/__init__.py @@ -0,0 +1,3 @@ +""" +This directory contains utitlities that help with testing and debugging +""" diff --git a/tests/utilities/example_smart_spiral.pkl b/tests/utilities/example_smart_spiral.pkl new file mode 100644 index 0000000000000000000000000000000000000000..5d5727d87df59a640d40cb8366f60a0e22d91b1f GIT binary patch literal 13513 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 literal 0 HcmV?d00001 diff --git a/tests/utilities/scan_test_helpers.py b/tests/utilities/scan_test_helpers.py new file mode 100644 index 00000000..82fc84bd --- /dev/null +++ b/tests/utilities/scan_test_helpers.py @@ -0,0 +1,196 @@ +import os +import pickle + +import numpy as np +from scipy import interpolate +from matplotlib import pyplot as plt +from matplotlib.path import Path as MatPath +from matplotlib.patches import PathPatch +from matplotlib.figure import Figure + +from openflexure_microscope_server import scan_planners + +THIS_DIR = os.path.dirname(os.path.realpath(__file__)) + + +class FakeSample: + """ + A fake sample to test scan algorithms. The sample is able to return + whether a given position is sample, no image associated with the sample + """ + + def __init__(self, xy_points: list[tuple[int, int]]): + """ + Create the sample from a spline interpolation around + the given points. + """ + self._sample_perimeter = interp_closed_path(xy_points, 500) + + def is_sample(self, pos: tuple[int, int], im_size: tuple[int, int]) -> bool: + """ + Return whether an image at a given location with a given image size + is on the sample + + This doesn't check the entire image feild as this is designed to be used + where the fake sample is much larger than the image and has smooth edges + It just checks the 4 corners + """ + img_corners = [ + (pos[0] + im_size[0], pos[1] + im_size[1]), + (pos[0] + im_size[0], pos[1] - im_size[1]), + (pos[0] - im_size[0], pos[1] + im_size[1]), + (pos[0] - im_size[0], pos[1] - im_size[1]), + ] + return any( + self._sample_perimeter.contains_point(corner) for corner in img_corners + ) + + @property + def patch(self) -> PathPatch: + """ + The sample as a matplotlib patch fro plotting + """ + patch = PathPatch(self._sample_perimeter) + patch.set(color=(1.0, 0.8, 1.0, 1.0)) + return patch + + +def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Figure: + """ + 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) + xi, yi, _ = zip(*planner._imaged_locations) + + # convert history to numpy array so can calculate quiver arrows + xh = np.array(xh) + yh = np.array(yh) + + plt.quiver( + xh[:-1], + yh[:-1], + xh[1:] - xh[:-1], + yh[1:] - yh[:-1], + scale_units="xy", + angles="xy", + scale=1, + ) + plt.plot(xh, yh, "r.") + plt.plot(xi, yi, "g*") + ax.axis("equal") + return fig + + +def interp_closed_path(xy_points: list[tuple[int, int]], n_points: int) -> MatPath: + """ + Given a lists of xy_points interpolate an n_point closed curve. This can be used + to creat an arbitrary sample shape plan a scan. + + Modified from: + https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy + """ + + # Use zip to seperate x and y points into tuples + x, y = zip(*xy_points) + + # Append first point and convert to array + x = np.array(x + (x[0],)) + y = np.array(y + (y[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. + spline_data, _ = interpolate.splprep([x, y], s=0, per=True) + + # evaluate the spline + xi, yi = interpolate.splev(np.linspace(0, 1, n_points), spline_data) + + # Convert to a matplotlib closed path + path_points = [[xp, yp] for xp, yp in (zip(xi, yi))] + return MatPath(path_points, closed=True) + + +def example_smart_spiral() -> tuple[FakeSample, scan_planners.ScanPlanner]: + """ + Run an example scan and return the sample scanned and the planner object + after scan is complete + """ + xy_sample_points = [ + (-5000, -5000), + (-2000, 10000), + (1000, 2000), + (6000, 7000), + (9000, 2000), + ] + sample = FakeSample(xy_sample_points) + img_size = (1000, 1000) + intial_position = (0, 0) + planner_settings = {"dx": 700, "dy": 700, "max_dist": 100000} + planner = scan_planners.SmartSpiral( + intial_position=intial_position, planner_settings=planner_settings + ) + + while not planner.scan_complete: + xy_pos, _ = planner.get_next_location_and_z_estimate() + xyz_pos = (xy_pos[0], xy_pos[1], 0) + imaged = sample.is_sample(xy_pos, img_size) + planner.mark_location_visited(xyz_pos, imaged=imaged, focused=imaged) + return sample, planner + + +def profile_and_save_plot_for_example_smart_spiral(): + """ + Run the example scan and save a plot and the profile data + Also print the cumulative stats + + + This runs if you run this file directly + """ + import pstats + import cProfile + + profiler = cProfile.Profile() + sample, planner = profiler.runcall(example_smart_spiral) + + stats_fname = os.path.join(THIS_DIR, "scan_example_stats.pstats") + profiler.dump_stats(stats_fname) + + png_fname = os.path.join(THIS_DIR, "scan_example_plot.png") + fig = visualise_scan(sample, planner) + fig.savefig(png_fname, dpi=200) + + run_stats = pstats.Stats(stats_fname) + run_stats.strip_dirs() + run_stats.sort_stats("cumulative") + run_stats.print_stats("scan_planners.py") + + +def update_example_smart_spiral_pickle(): + """ + Pickle the ScanPlanner for the example_smart_spiral(), + this is done so the history can be compared by testing to check + the algorithm is unchanged. + + If the algorithm is purposefully changed then this will need to be + run to update the pickle for the test to pass. + """ + pkl_fname = os.path.join(THIS_DIR, "example_smart_spiral.pkl") + with open(pkl_fname, "wb") as pkl_file_obj: + _, planner = example_smart_spiral() + pickle.dump(planner, pkl_file_obj, pickle.HIGHEST_PROTOCOL) + + +def get_expected_result_for_example_smart_spiral(): + """ + Return the expected ScanPlanner object for the example_smart_spiral(), + this is pickled, so that it can be committed. + """ + pkl_fname = os.path.join(THIS_DIR, "example_smart_spiral.pkl") + with open(pkl_fname, "rb") as pkl_file_obj: + planner = pickle.load(pkl_file_obj) + return planner + + +if __name__ == "__main__": + profile_and_save_plot_for_example_smart_spiral()