From c1915759b4f407f705ca10a16498f6a6fba065ba Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 2 Jun 2026 17:22:38 +0100 Subject: [PATCH] Update unit tests for capture mode. --- .../things/__init__.py | 1 + tests/unit_tests/test_camera_buffer.py | 83 +++++++++++++------ tests/unit_tests/test_scan_workflows.py | 37 ++++++--- tests/unit_tests/test_smart_scan.py | 3 +- tests/unit_tests/test_stack.py | 47 ++++------- 5 files changed, 102 insertions(+), 69 deletions(-) diff --git a/src/openflexure_microscope_server/things/__init__.py b/src/openflexure_microscope_server/things/__init__.py index 975be827..85bf8dd5 100644 --- a/src/openflexure_microscope_server/things/__init__.py +++ b/src/openflexure_microscope_server/things/__init__.py @@ -82,6 +82,7 @@ class OFMThing(lt.Thing): return rel_data_path +# TODO Write tests for this class RelativeDataPath(RootModel[str]): """A relative path that is validated, and can have a Thing assigned to it. diff --git a/tests/unit_tests/test_camera_buffer.py b/tests/unit_tests/test_camera_buffer.py index 1ea6620d..aa373299 100644 --- a/tests/unit_tests/test_camera_buffer.py +++ b/tests/unit_tests/test_camera_buffer.py @@ -33,8 +33,8 @@ def test_add_and_get_image(): """Check images can be captured and retrieved.""" mem_buf = CameraMemoryBuffer() misc_image = random_image() - buffer_id = mem_buf.add_image(misc_image, random_metadata()) - returned_image, _ = mem_buf.get_image(buffer_id) + buffer_id = mem_buf.add_image(misc_image, random_metadata(), "standard") + returned_image, _, _ = mem_buf.get_image(buffer_id) # It is the same image assert misc_image is returned_image # It is now removed from memory @@ -46,12 +46,12 @@ def test_add_and_get_image_twice(): """Check images can be retrieved twice if remove flag set false.""" mem_buf = CameraMemoryBuffer() misc_image = random_image() - buffer_id = mem_buf.add_image(misc_image, random_metadata()) - returned_image, _ = mem_buf.get_image(buffer_id, remove=False) + buffer_id = mem_buf.add_image(misc_image, random_metadata(), "standard") + returned_image, _, _ = mem_buf.get_image(buffer_id, remove=False) # It is the same image assert misc_image is returned_image # It is still in memory - returned_image, _ = mem_buf.get_image(buffer_id) + returned_image, _, _ = mem_buf.get_image(buffer_id) assert misc_image is returned_image # It is now removed from memory with pytest.raises(NoImageInMemoryError): @@ -62,8 +62,8 @@ def test_get_without_id(): """Check images can be captured and retrieved without ID.""" mem_buf = CameraMemoryBuffer() misc_image = random_image() - mem_buf.add_image(misc_image, random_metadata()) - returned_image, _ = mem_buf.get_image() + mem_buf.add_image(misc_image, random_metadata(), "standard") + returned_image, _, _ = mem_buf.get_image() # It is the same image assert misc_image is returned_image # It is now removed from memory @@ -76,10 +76,20 @@ def test_get_two_images(): mem_buf = CameraMemoryBuffer() misc_image1 = random_image() misc_image2 = random_image() - buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), buffer_max=2) - buffer_id2 = mem_buf.add_image(misc_image2, random_metadata(), buffer_max=2) - returned_image1, _ = mem_buf.get_image(buffer_id1) - returned_image2, _ = mem_buf.get_image(buffer_id2) + buffer_id1 = mem_buf.add_image( + misc_image1, random_metadata(), "standard", buffer_max=2 + ) + buffer_id1 = mem_buf.add_image( + misc_image1, random_metadata(), "standard", buffer_max=2 + ) + buffer_id1 = mem_buf.add_image( + misc_image1, random_metadata(), "standard", buffer_max=2 + ) + buffer_id2 = mem_buf.add_image( + misc_image2, random_metadata(), "standard", buffer_max=2 + ) + returned_image1, _, _ = mem_buf.get_image(buffer_id1) + returned_image2, _, _ = mem_buf.get_image(buffer_id2) # It they the same images assert misc_image1 is returned_image1 assert misc_image2 is returned_image2 @@ -95,11 +105,11 @@ def test_get_two_images_without_setting_buffer_size(): mem_buf = CameraMemoryBuffer() misc_image1 = random_image() misc_image2 = random_image() - buffer_id1 = mem_buf.add_image(misc_image1, random_metadata()) - buffer_id2 = mem_buf.add_image(misc_image2, random_metadata()) + buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), "standard") + buffer_id2 = mem_buf.add_image(misc_image2, random_metadata(), "standard") with pytest.raises(NoImageInMemoryError): mem_buf.get_image(buffer_id1) - returned_image2, _ = mem_buf.get_image(buffer_id2) + returned_image2, _, _ = mem_buf.get_image(buffer_id2) # Image 2 the expected image assert misc_image2 is returned_image2 @@ -110,16 +120,20 @@ def test_buffer_size_changing(): misc_image1 = random_image() misc_image2 = random_image() misc_image3 = random_image() - buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), buffer_max=3) - buffer_id2 = mem_buf.add_image(misc_image2, random_metadata(), buffer_max=3) + buffer_id1 = mem_buf.add_image( + misc_image1, random_metadata(), "standard", buffer_max=3 + ) + buffer_id2 = mem_buf.add_image( + misc_image2, random_metadata(), "standard", buffer_max=3 + ) # Third capture doesn't set buffer size, so it will be reset - buffer_id3 = mem_buf.add_image(misc_image3, random_metadata()) + buffer_id3 = mem_buf.add_image(misc_image3, random_metadata(), "standard") # As buffer size was reset, images 1 and 2 are deleted with pytest.raises(NoImageInMemoryError): mem_buf.get_image(buffer_id1) with pytest.raises(NoImageInMemoryError): mem_buf.get_image(buffer_id2) - returned_image3, _ = mem_buf.get_image(buffer_id3) + returned_image3, _, _ = mem_buf.get_image(buffer_id3) # Image 3 the expected image assert misc_image3 is returned_image3 @@ -129,9 +143,9 @@ def test_capture_two_images_get_without_id(): mem_buf = CameraMemoryBuffer() misc_image1 = random_image() misc_image2 = random_image() - mem_buf.add_image(misc_image1, random_metadata(), buffer_max=2) - mem_buf.add_image(misc_image2, random_metadata(), buffer_max=2) - returned_image, _ = mem_buf.get_image() + mem_buf.add_image(misc_image1, random_metadata(), "standard", buffer_max=2) + mem_buf.add_image(misc_image2, random_metadata(), "standard", buffer_max=2) + returned_image, _, _ = mem_buf.get_image() # When buffer_id is not specified, the most recent image (image2) is expected to # be retrieved assert returned_image is misc_image2 @@ -148,7 +162,9 @@ def test_buffer_size_respected(): buffer_ids = [] for _i in range(10): image = random_image() - buffer_id = mem_buf.add_image(image, random_metadata(), buffer_max=5) + buffer_id = mem_buf.add_image( + image, random_metadata(), "standard", buffer_max=5 + ) images.append(image) buffer_ids.append(buffer_id) @@ -157,7 +173,7 @@ def test_buffer_size_respected(): with pytest.raises(NoImageInMemoryError): mem_buf.get_image(buffer_id) else: - returned_image, _ = mem_buf.get_image(buffer_id) + returned_image, _, _ = mem_buf.get_image(buffer_id) assert image is returned_image @@ -169,7 +185,9 @@ def test_clear_buffer(): buffer_ids = [] for _i in range(10): image = random_image() - buffer_id = mem_buf.add_image(image, random_metadata(), buffer_max=10) + buffer_id = mem_buf.add_image( + image, random_metadata(), "standard", buffer_max=10 + ) images.append(image) buffer_ids.append(buffer_id) @@ -192,7 +210,7 @@ def test_get_metadata_too(): for _i in range(10): image = random_image() metadata = random_metadata() - buffer_id = mem_buf.add_image(image, metadata, buffer_max=10) + buffer_id = mem_buf.add_image(image, metadata, "standard", buffer_max=10) images.append(image) metadatas.append(metadata) buffer_ids.append(buffer_id) @@ -202,6 +220,19 @@ def test_get_metadata_too(): # Check both image and metadata for image, metadata, buffer_id in zipped: - returned_image, returned_metadata = mem_buf.get_image(buffer_id) + returned_image, returned_metadata, _ = mem_buf.get_image(buffer_id) assert image is returned_image assert metadata is returned_metadata + + +def test_mode_is_returned(): + """Capture 10 images with metadata and check metadata is returned as expected.""" + mem_buf = CameraMemoryBuffer() + misc_image = random_image() + buffer_id = mem_buf.add_image(misc_image, random_metadata(), "standard") + _, _, mode = mem_buf.get_image(buffer_id) + assert mode == "standard" + + buffer_id = mem_buf.add_image(misc_image, random_metadata(), "foobar") + _, _, mode = mem_buf.get_image(buffer_id) + assert mode == "foobar" diff --git a/tests/unit_tests/test_scan_workflows.py b/tests/unit_tests/test_scan_workflows.py index 0bb7b13c..a4ba6e0c 100644 --- a/tests/unit_tests/test_scan_workflows.py +++ b/tests/unit_tests/test_scan_workflows.py @@ -4,6 +4,7 @@ import itertools import logging import pytest +from PIL import Image from pydantic import BaseModel import labthings_fastapi as lt @@ -11,6 +12,7 @@ from labthings_fastapi.testing import create_thing_without_server from openflexure_microscope_server.scan_planners import SmartSpiral from openflexure_microscope_server.stitching import StitchingSettings +from openflexure_microscope_server.things import RelativeDataPath from openflexure_microscope_server.things.autofocus import SmartStackParams from openflexure_microscope_server.things.camera_stage_mapping import csm_img_to_stage from openflexure_microscope_server.things.scan_workflows import ( @@ -44,8 +46,6 @@ def test_partial_base_classes(): bad_workflow = create_thing_without_server(BadWorkflow) settings = MinimalSettings() - with pytest.raises(NotImplementedError): - bad_workflow.check_before_start(settings) with pytest.raises(NotImplementedError): bad_workflow.ready @@ -69,7 +69,17 @@ def test_partial_base_classes(): @pytest.fixture def histo_workflow(): """Return a HistoScanWorkflow thing with slots mocked.""" - return create_thing_without_server(HistoScanWorkflow, mock_all_slots=True) + workflow = create_thing_without_server(HistoScanWorkflow, mock_all_slots=True) + workflow._cam.capture_modes = {"standard": "Mock"} + workflow._cam._capture_image.return_value = Image.new("RGB", (1111, 1222)) + return workflow + + +def test_histo_workflow_save_resolution(histo_workflow): + """Check that the camera is used to get the save resolution.""" + width, height = histo_workflow._get_save_resolution() + assert width == 1111 + assert height == 1222 # Use itertools to iterate over every true/false permutation @@ -117,7 +127,12 @@ def test_histo_workflow_settings_generation(histo_workflow, mocker): mocker.patch.object( histo_workflow, "_calc_displacement_from_overlap", return_value=(123, 456) ) - workflow_settings, stitching_settings = histo_workflow.all_settings("/this/img_dir") + + img_dir = RelativeDataPath("this/img_dir") + workflow_settings, stitching_settings, save_res = histo_workflow.all_settings( + img_dir + ) + assert save_res == (1111, 1222) ## Check type assert isinstance(workflow_settings, HistoScanSettingsModel) assert isinstance(stitching_settings, StitchingSettings) @@ -137,7 +152,7 @@ def test_histo_workflow_settings_generation(histo_workflow, mocker): assert workflow_settings.dx == 123 assert workflow_settings.dy == 456 # And that the input image dir is passed to stack the stack parameter for saving - assert workflow_settings.capture_params.images_dir == "/this/img_dir" + assert workflow_settings.capture_params.images_dir.root == "this/img_dir" def test_histo_workflow_settings_generation_equal_overlap(histo_workflow, mocker): @@ -148,8 +163,9 @@ def test_histo_workflow_settings_generation_equal_overlap(histo_workflow, mocker # Different when False histo_workflow.equal_distances = False - workflow_settings, _stitching_settings = histo_workflow.all_settings( - "/this/img_dir" + img_dir = RelativeDataPath("this/img_dir") + workflow_settings, _stitching_settings, _save_res = histo_workflow.all_settings( + img_dir ) assert workflow_settings.dx == 123 @@ -157,8 +173,8 @@ def test_histo_workflow_settings_generation_equal_overlap(histo_workflow, mocker # Same when set True histo_workflow.equal_distances = True - workflow_settings, _stitching_settings = histo_workflow.all_settings( - "/this/img_dir" + workflow_settings, _stitching_settings, _save_res = histo_workflow.all_settings( + img_dir ) assert workflow_settings.dx == 123 @@ -455,9 +471,8 @@ def test_correlation_resize(histo_workflow, save_res, expected_resize): target area, taking the square root, and rounding to the nearest integer N. Then correlation_resize = 1 / N. """ - histo_workflow.save_resolution = save_res histo_workflow.overlap = 0.1 - settings = histo_workflow._get_stitching_settings_model() + settings = histo_workflow._get_stitching_settings_model(save_res) assert isinstance(settings, StitchingSettings) assert settings.correlation_resize == expected_resize diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py index a56773b6..d8fe3038 100644 --- a/tests/unit_tests/test_smart_scan.py +++ b/tests/unit_tests/test_smart_scan.py @@ -413,13 +413,14 @@ def scan_thing_mocked_for_scan_data(smart_scan_thing, mocker): smart_scan_thing._workflow.all_settings.return_value = ( MockWorkflowSettingModel(), StitchingSettings(correlation_resize=0.5, overlap=0.45), + (1640, 1232), ) - smart_scan_thing._workflow.save_resolution = (1640, 1232) mock_ongoing_scan = mocker.Mock() mock_ongoing_scan.name = MOCK_SCAN_NAME mock_ongoing_scan.images_dir = MOCK_SCAN_DIR smart_scan_thing._ongoing_scan = mock_ongoing_scan + smart_scan_thing._data_dir = "scans" yield smart_scan_thing diff --git a/tests/unit_tests/test_stack.py b/tests/unit_tests/test_stack.py index 1c965d2e..40b48e87 100644 --- a/tests/unit_tests/test_stack.py +++ b/tests/unit_tests/test_stack.py @@ -8,10 +8,13 @@ import numpy as np import pytest from hypothesis import given from hypothesis import strategies as st +from PIL import Image +from pydantic import ValidationError from labthings_fastapi.testing import create_thing_without_server from openflexure_microscope_server.scan_directories import IMAGE_REGEX +from openflexure_microscope_server.things import RelativeDataPath from openflexure_microscope_server.things.autofocus import ( EXTRA_STACK_CAPTURES, AutofocusThing, @@ -262,6 +265,10 @@ def histo_scan_workflow(): workflow._csm.calibration_required = False workflow._csm.convert_image_to_stage_coordinates = lambda x, y: {"x": x, "y": y} + # And set up camera + workflow._cam.capture_modes = {"standard": "Mock"} + workflow._cam._capture_image.return_value = Image.new("RGB", (1000, 1000)) + return workflow @@ -341,7 +348,7 @@ def test_coercing_stack_save_ims( @pytest.mark.parametrize("pass_on", [1, 2, 3, 4]) def test_run_smart_stack(pass_on, histo_scan_workflow, autofocus_thing, mocker): """Test Running smart stack with the stack passing on different attempts.""" - scan_settings, _ = histo_scan_workflow.all_settings(images_dir="dummy") + scan_settings, _, _ = histo_scan_workflow.all_settings(RelativeDataPath("dummy")) assert scan_settings.smart_stack_params.max_attempts == 3 # Set up returns from z-stack @@ -878,37 +885,15 @@ def test_invalid_stack_settling_raises(): @pytest.mark.parametrize( - ("bad_path", "match_err"), + ("bad_path", "error_type"), [ - ("", "String should have at least 1 character"), - (None, "Input should be a valid string"), - (67, "Input should be a valid string"), + (None, TypeError), + (67, TypeError), + ("../dangerous", ValidationError), + ("/usr/bin/bash", ValidationError), ], ) -def test_invalid_capture_dir_raises(bad_path, match_err): +def test_invalid_capture_dir_raises(bad_path, error_type): """Test basic stack raises expected error for bad image dir paths.""" - with pytest.raises(ValueError, match=match_err): - # TODO set images dir correctly as a RelDataPath - CaptureParams(images_dir=bad_path, save_resolution=(20, 20)) - - -@pytest.mark.parametrize( - ("bad_res", "match_err"), - [ - ((-100, 50), "Input should be greater than or equal to 1"), - ((20, 0), "Input should be greater than or equal to 1"), - ("", "Input should be a valid tuple"), - (None, "Input should be a valid tuple"), - (67, "Input should be a valid tuple"), - ( - ["path"], - "Input should be a valid integer, unable to parse string as an integer", - ), - ((20, 20, 20), "Tuple should have at most 2 items"), - ], -) -def test_invalid_capture_res_raises(bad_res, match_err): - """Test basic stack raises expected error for invalid save resolutions.""" - with pytest.raises(ValueError, match=match_err): - # TODO set images dir correctly as a RelDataPath - CaptureParams(images_dir="dummy", save_resolution=bad_res) + with pytest.raises(error_type): + CaptureParams(images_dir=bad_path, capture_mode="foo")