Add functionality for testing the scan planner on a given sample shape

This commit is contained in:
Julian Stirling 2025-04-13 18:01:06 +01:00
parent a0b6dbdfce
commit 2ced848695
7 changed files with 217 additions and 2 deletions

4
.gitignore vendored
View file

@ -84,3 +84,7 @@ openflexure_microscope/cobertura.xml
# web app build # web app build
/src/openflexure_microscope_server/static/ /src/openflexure_microscope_server/static/
# Files created by test utilities
/tests/utilities/*.pstats
/tests/utilities/*.png

View file

@ -35,6 +35,7 @@ dev = [
"mypy-gitlab-code-quality", "mypy-gitlab-code-quality",
# "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings # "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings
"pytest", "pytest",
"matplotlib~=3.10"
] ]
pi = [ pi = [
"labthings-picamera2 == 0.0.2-dev0", "labthings-picamera2 == 0.0.2-dev0",
@ -81,6 +82,8 @@ addopts = [
norecursedirs = [".git", "build", "node_modules"] norecursedirs = [".git", "build", "node_modules"]
pythonpath = ["."]
[tool.ruff.format] [tool.ruff.format]
# Use native line endings for all files # Use native line endings for all files
line-ending = "native" line-ending = "native"

0
tests/__init__.py Normal file
View file

View file

@ -1,7 +1,9 @@
import pytest import pytest
from openflexure_microscope_server import scan_planners
from copy import copy from copy import copy
from openflexure_microscope_server import scan_planners
from .utilities import scan_test_helpers
def test_enforce_xy_tuple(): def test_enforce_xy_tuple():
bad_len_vals = [[1], [], (1,), (2, 4, 4), [1, 4, 5, 7]] 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 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) intial_position = (0, 0)
planner_settings = {"dx": 100, "dy": 100, "max_dist": 1000} planner_settings = {"dx": 100, "dy": 100, "max_dist": 1000}
# Create a planner # Create a planner
@ -248,3 +250,10 @@ def test_closest_focus_wth_large_numbers():
# Make the first point 1 step closer # Make the first point 1 step closer
planner._focused_locations = [(1234566, 0, 0), (-1234567, 0, 0)] planner._focused_locations = [(1234566, 0, 0), (-1234567, 0, 0)]
assert planner.closest_focus_site((0, 0)) == (1234566, 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

View file

@ -0,0 +1,3 @@
"""
This directory contains utitlities that help with testing and debugging
"""

Binary file not shown.

View file

@ -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()