From 563bfcf7bef8793b2c0e24660afbbcc1ee2a34b8 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 13 May 2026 12:18:36 +0100 Subject: [PATCH] Update move_and_measure, add unit tests --- .../things/autofocus.py | 18 +- tests/unit_tests/test_autofocus.py | 156 ++++++++++++++++++ 2 files changed, 170 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 84d1acad..a2b5f0c7 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -536,20 +536,30 @@ class AutofocusThing(lt.Thing): self, dz: Sequence[int], wait: float = 0, + sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, + record: Optional[int] = None, ) -> SharpnessDataArrays: """Make a move (or a series of moves) and monitor sharpness. This method will will make a series of relative moves in z, and - return the sharpness (JPEG size and FOM) vs time, along with timestamps + return the sharpness (JPEG size and/or FOM) vs time, along with timestamps for the moves. This can be used to calibrate autofocus. Each move is relative to the last one, i.e. we will finish at ``sum(dz)`` relative to the starting position. - If ``wait`` is specified, we will wait for that many seconds - between moves. + :param dz: A list of moves to make in z (in steps). + :param wait: The time to pause between movements, in seconds. + :param sharpness_metric: Sharpness metric used for evaluation. Either + JPEG file size (1) or inbuilt figure of metric (2). + :param record: Optional bitmask of sharpness metrics to record during + acquisition. Sharpness metrics can be selected as the sum of their int + value (see sharpness_metric). + If None, defaults to recording only the selected sharpness_metric. """ - with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor: + with JPEGSharpnessMonitor( + self._stage, self._cam, sharpness_metric, record + ) as sharpness_monitor: for move_index, current_dz in enumerate(dz): if move_index > 0 and wait > 0: time.sleep(wait) diff --git a/tests/unit_tests/test_autofocus.py b/tests/unit_tests/test_autofocus.py index 3661cbc6..3401f2d4 100644 --- a/tests/unit_tests/test_autofocus.py +++ b/tests/unit_tests/test_autofocus.py @@ -10,7 +10,9 @@ from labthings_fastapi.testing import create_thing_without_server from openflexure_microscope_server.things.autofocus import ( AutofocusThing, + JPEGSharpnessMonitor, NoFocusFoundError, + SharpnessMethod, ) @@ -98,3 +100,157 @@ def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes, ) assert sharpness_monitor.focus_rel.call_count == attempts_expected assert abs(max_loc - autofocus_thing._stage.position["z"]) < dz / 40 + + +@pytest.fixture +def mock_camera(mocker): + """Mock a camera to return fom properties.""" + camera = mocker.MagicMock() + camera.supports_focus_fom = True + camera.focus_fom = 123 + return camera + + +@pytest.fixture +def mock_stage(mocker): + """Mock a stage to return position.""" + stage = mocker.MagicMock() + stage.position = {"x": 0, "y": 0, "z": 0} + return stage + + +def test_record_defaults_to_method(mock_stage, mock_camera): + """If record=None, only the selected method is recorded.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + ) + + assert monitor.record == SharpnessMethod.FOCUS_FOM + + +def test_record_must_include_selected_method(mock_stage, mock_camera): + """The selected autofocus metric must also be recorded.""" + with pytest.raises(ValueError, match="not being recorded"): + JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + record=SharpnessMethod.JPEG, + ) + + +def test_focus_fom_requires_camera_support(mock_stage, mock_camera): + """FocusFoM recording requires camera support.""" + mock_camera.supports_focus_fom = False + + with pytest.raises(ValueError, match="doesn't support"): + JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + ) + + +def test_jpeg_sizes_property_requires_recording(mock_stage, mock_camera): + """JPEG sizes should error if JPEG recording disabled.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + ) + + with pytest.raises(ValueError, match="JPEG sizes are not being recorded"): + _ = monitor.jpeg_sizes + + +def test_focus_fom_property_requires_recording(mock_stage, mock_camera): + """FocusFoM values should error if FOM recording disabled.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.JPEG, + ) + + with pytest.raises(ValueError, match="FocusFoM values are not being recorded"): + _ = monitor.focus_foms + + +@pytest.mark.parametrize( + ("method", "expected"), + [ + (SharpnessMethod.JPEG, [100, 200, 300]), + (SharpnessMethod.FOCUS_FOM, [1, 5, 2]), + ], +) +def test_move_data_uses_selected_method( + method, + expected, + mock_stage, + mock_camera, +): + """move_data should select the correct sharpness metric.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=method, + record=SharpnessMethod.JPEG + SharpnessMethod.FOCUS_FOM, + ) + + monitor._jpeg_times = [1.0, 2.0, 3.0] + monitor._jpeg_sizes = [100, 200, 300] + monitor._focus_foms = [1, 5, 2] + + monitor._stage_times = [0.5, 3.5] + monitor._stage_positions = [ + {"z": 0}, + {"z": 100}, + ] + + _, _, sharpnesses = monitor.move_data(0) + + assert sharpnesses.tolist() == expected + + +def test_monitor_records_both_metrics(mock_stage, mock_camera): + """Both JPEG and FocusFoM metrics can be recorded together.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.JPEG, + record=SharpnessMethod.JPEG + SharpnessMethod.FOCUS_FOM, + ) + + monitor._jpeg_sizes.append(999) + monitor._focus_foms.append(321) + + assert monitor.jpeg_sizes == [999] + assert monitor.focus_foms == [321] + + +def test_move_data_uses_focus_fom(mock_stage, mock_camera): + """Test that move_data returns the chosen metric.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + record=SharpnessMethod.JPEG + SharpnessMethod.FOCUS_FOM, + ) + + # Record both sharpness metrics + monitor._jpeg_times = [1.0, 2.0, 3.0] + monitor._jpeg_sizes = [100, 200, 300] + monitor._focus_foms = [10, 99, 20] + + monitor._stage_times = [0.5, 3.5] + monitor._stage_positions = [ + {"z": 0}, + {"z": 100}, + ] + + # sharpnesses chosen by the chosen method + _, _, sharpnesses = monitor.move_data(0) + + # ensure sharpnesses matches focus_foms + assert sharpnesses.tolist() == [10, 99, 20]