Add functionality for testing the scan planner on a given sample shape
This commit is contained in:
parent
a0b6dbdfce
commit
2ced848695
7 changed files with 217 additions and 2 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
0
tests/__init__.py
Normal 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
|
||||||
|
|
|
||||||
3
tests/utilities/__init__.py
Normal file
3
tests/utilities/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
This directory contains utitlities that help with testing and debugging
|
||||||
|
"""
|
||||||
BIN
tests/utilities/example_smart_spiral.pkl
Normal file
BIN
tests/utilities/example_smart_spiral.pkl
Normal file
Binary file not shown.
196
tests/utilities/scan_test_helpers.py
Normal file
196
tests/utilities/scan_test_helpers.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue