diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 5cbc050c..61d1ff0a 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -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: diff --git a/tests/unit_tests/data/sharpness_test_cases.json b/tests/unit_tests/data/sharpness_test_cases.json new file mode 100644 index 00000000..27967eea --- /dev/null +++ b/tests/unit_tests/data/sharpness_test_cases.json @@ -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"} +] diff --git a/tests/unit_tests/test_stack_examples.py b/tests/unit_tests/test_stack_examples.py new file mode 100644 index 00000000..6c3ecbda --- /dev/null +++ b/tests/unit_tests/test_stack_examples.py @@ -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