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 00000000..5d5727d8 Binary files /dev/null and b/tests/utilities/example_smart_spiral.pkl differ 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()