Merge branch 'stack-trends' into 'v3'

Stack trends

Closes #752

See merge request openflexure/openflexure-microscope-server!564
This commit is contained in:
Joe Knapper 2026-04-16 12:20:01 +00:00
commit 924676ff5d
3 changed files with 158 additions and 5 deletions

View file

@ -676,10 +676,11 @@ class AutofocusThing(lt.Thing):
sharpness=self._cam.grab_jpeg_size(stream_name="lores"),
)
# Silence too many returns in this situation as refactoring to reduce returns is
# unlikely to improve readability. This function is basically a complex switch
# statement, having an explicit return after each option is clear.
def check_stack_result( # noqa: PLR0911
# Silence too many returns and complexity in this situation as refactoring to
# reduce returns is unlikely to improve readability.
# This function is basically a complex switch statement, having an explicit
# return after each option is clear.
def check_stack_result( # noqa: PLR0911 C901
self, captures: list[CaptureInfo], check_turning_points: bool
) -> tuple[Literal["success", "continue", "restart"], int]:
"""Check if the sharpest image in a list of captures is central enough.
@ -688,7 +689,7 @@ class AutofocusThing(lt.Thing):
sharpness has converged in the centre
:param check_turning_points: Whether to check the number of turning points in
the sharpnesses of the images in the stack is exactly 1. (May fail with
thick samples)
thick samples and stacks with many images)
:returns: A tuple with two values:
@ -719,6 +720,13 @@ class AutofocusThing(lt.Thing):
return "restart", capture_id
return "continue", capture_id
# Manually test for monotomically increasing or decreasing sharpnesses, as
# fitting can struggle with these and their behaviour is simpler to hardcode
if np.all(sharpnesses[:-1] <= sharpnesses[1:]):
return "continue", capture_id
if np.all(sharpnesses[:-1] >= sharpnesses[1:]):
return "restart", capture_id
try:
turning = _get_peak_turning_point(sharpnesses)
except NotAPeakError:

View file

@ -0,0 +1,43 @@
[
{"sharpnesses":[100000,300000,500000,300000,100000],"label":"success"},
{"sharpnesses":[100000,200000,400000,600000,400000,200000,100000],"label":"success"},
{"sharpnesses":[100000,200000,300000,400000,500000],"label":"continue"},
{"sharpnesses":[500000,400000,300000,200000,100000],"label":"restart"},
{"sharpnesses":[100000,200000,300000,200000,100000,50000],"label":["success"]},
{"sharpnesses":[0,100000,200000,300000,400000,500000],"label":"continue"},
{"sharpnesses":[500000,500000,500000,500000,500000],"label":"continue"},
{"sharpnesses":[100000,200000,300000,300000,200000,100000],"label":["success"]},
{"sharpnesses":[100000,200000,300000,400000,400000,300000,200000],"label":["success","continue"]},
{"sharpnesses":[100000,300000,500000,500000,500000,300000,100000],"label":["success","continue"]},
{"sharpnesses":[100000,300000,500000,400000,500000,300000,100000],"label":["continue","success"]},
{"sharpnesses":[100000,400000,600000,500000,300000,200000,100000],"label":"success"},
{"sharpnesses":[100000,200000,500000,700000,600000,300000,100000],"label":"success"},
{"sharpnesses":[100000,200000,300000,500000,700000,600000,400000],"label":["success","continue"]},
{"sharpnesses":[700000,600000,500000,400000,300000,200000,100000,0],"label":"restart"},
{"sharpnesses":[100000,300000,600000,900000,600000,300000,200000,100000],"label":"success"},
{"sharpnesses":[100000,200000,400000,800000,900000,800000,400000,200000],"label":"success"},
{"sharpnesses":[100000,200000,300000,400000,500000,600000,700000,800000],"label":"continue"},
{"sharpnesses":[800000,700000,600000,500000,400000,300000,200000,100000],"label":"restart"},
{"sharpnesses":[100000,200000,300000,400000,400000,400000,300000,200000],"label":["success","continue"]},
{"sharpnesses":[100000,200000,300000,500000,500000,500000,500000,500000],"label":"continue"},
{"sharpnesses":[500000,500000,500000,500000,500000,400000,300000,200000],"label":"restart"},
{"sharpnesses":[100000,200000,300000,600000,900000,600000,300000,200000,100000],"label":"success"},
{"sharpnesses":[100000,200000,400000,700000,1000000,900000,700000,400000,200000],"label":"success"},
{"sharpnesses":[100000,200000,300000,400000,500000,600000,700000,800000,900000],"label":"continue"},
{"sharpnesses":[900000,800000,700000,600000,500000,400000,300000,200000,100000],"label":"restart"},
{"sharpnesses":[100000,200000,1000000,200000,1000000,200000,100000],"label":"continue"},
{"sharpnesses":[100000,500000,100000,500000,100000,500000,100000],"label":["restart","continue"]},
{"sharpnesses":[300000,300000,300000,300000,300000,300000,300000],"label":"continue"},
{"sharpnesses":[500000,600000,800000,1100000,1500000,1100000,800000,600000,500000],"label":"success"},
{"sharpnesses":[1000000,1100000,1300000,1600000,2000000,1600000,1300000,1100000,1000000],"label":"success"},
{"sharpnesses":[2000000,1800000,1500000,1200000,1000000,900000,800000,700000,600000],"label":"restart"},
{"sharpnesses":[600000,700000,800000,900000,1100000,1300000,1500000,1700000,1800000],"label":"continue"},
{"sharpnesses":[500000,700000,1000000,1400000,1800000,1700000,1400000,1000000,700000],"label":"success"},
{"sharpnesses":[500000,600000,900000,1300000,1800000,1900000,1700000,1300000,900000],"label":"success"},
{"sharpnesses":[500000,500000,500000,500000,600000,900000,600000,500000,500000],"label":["success"],"allow_failure":true},
{"sharpnesses":[500000,500000,500000,500000,500000,600000,900000,600000,500000],"label":["success","continue"]},
{"sharpnesses":[500000,500000,500000,500000,500000,500000,600000,900000,600000],"label":"continue"},
{"sharpnesses":[100000,100000,100000,100000,200000,500000,200000,100000,100000],"label":["success"],"allow_failure":true},
{"sharpnesses":[100000,100000,100000,100000,100000,200000,500000,200000,100000],"label":["success","continue"]},
{"sharpnesses":[100000,100000,100000,100000,100000,100000,200000,500000,200000],"label":"continue"}
]

View file

@ -0,0 +1,102 @@
"""Unit tests for validating stack classification logic in AutofocusThing.
This module tests whether the `check_stack_result` method correctly classifies
z-stack sharpness profiles into "success", "continue", or "restart" categories
based on predefined test cases.
Sharpness profiles are loaded from a JSON file and converted into mock capture
objects to simulate real camera captures.
"""
import json
import os
import pytest
from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things.autofocus import AutofocusThing
THIS_DIR = os.path.dirname(__file__)
DATA_PATH = os.path.join(THIS_DIR, "data", "sharpness_test_cases.json")
class MockCapture:
"""Simple mock object representing a captured image.
This class mimics the minimal interface required by
`check_stack_result` by adding the `sharpness` and `buffer_id`
attributes.
:param sharpness: The sharpness value associated with the image.
:param buffer_id: A unique identifier for the image buffer.
"""
def __init__(self, sharpness, buffer_id):
"""Give each capture a sharpness and buffer_id."""
self.sharpness = sharpness
self.buffer_id = buffer_id
def make_captures(sharpness_list):
"""Convert a list of sharpness values into mock capture objects.
:param sharpness_list: A list of numeric sharpness values.
:returns: A list of MockCapture instances with sequential buffer IDs.
"""
return [MockCapture(s, i) for i, s in enumerate(sharpness_list)]
def load_cases():
"""Load the sharpness data from the json.
Includes the sharpnesses to test, manually written labels
on the required result, and whether the test is allowed to
fail. This ensures future tests will flag any regression on
tests while allowing improvements.
"""
with open(DATA_PATH) as f:
data = json.load(f)
params = []
for i, case in enumerate(data):
expected = case["label"]
# Allow multiple acceptable labels
if not isinstance(expected, list):
expected = [expected]
marks = []
if case.get("allow_failure", False):
marks.append(pytest.mark.xfail(reason="Known failing case"))
params.append(
pytest.param(
case["sharpnesses"],
expected,
marks=marks,
id=f"case_{i}",
)
)
return params
@pytest.mark.parametrize(("sharpnesses", "expected"), load_cases())
def test_stack_labelling(sharpnesses, expected):
"""Test stack classification accuracy against labelled sharpness cases.
This test loads the test from load_cases and evaluates
the classification returned by `check_stack_result`.
The test ensures that only cases marked with "allow_failure: true" can fail.
Expected labels may be a single value or a list of acceptable values.
"""
autofocus_thing = create_thing_without_server(AutofocusThing, mock_all_slots=True)
captures = make_captures(sharpnesses)
result, _ = autofocus_thing.check_stack_result(captures, check_turning_points=False)
assert result in expected