From b6343362b2b74c721a7cb37e73bf83b848ec5943 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 15 Jan 2026 12:57:16 +0000 Subject: [PATCH] Start splitting ScanData into workflow specific data. --- .../scan_directories.py | 108 ++++++++++--- .../things/scan_workflows.py | 144 ++++++++++++++++- .../things/smart_scan.py | 146 +++--------------- tests/unit_tests/test_scan_data.py | 12 +- 4 files changed, 249 insertions(+), 161 deletions(-) diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index 6770a9cd..c766b23e 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -8,7 +8,7 @@ import shutil import threading import zipfile from datetime import datetime, timedelta -from typing import Any, Mapping, Optional +from typing import Any, Mapping, Optional, Self from pydantic import ( BaseModel, @@ -16,6 +16,7 @@ from pydantic import ( ValidationError, field_serializer, field_validator, + model_validator, ) from openflexure_microscope_server.utilities import make_name_safe, requires_lock @@ -29,6 +30,7 @@ STITCH_REGEX = re.compile(r"stitched\.jpe?g$") IMAGE_REGEX = re.compile(r"-?[0-9]+_-?[0-9]+\.jpe?g$") SCAN_DATA_FILENAME = "scan_data.json" +SCAN_DATA_SCHEMA_VERSION = 2 class NotEnoughFreeSpaceError(IOError): @@ -47,6 +49,56 @@ class ScanInfo(BaseModel): dzi: Optional[str] +class StitchingData(BaseModel): + """The data needed to stitch a scan.""" + + correlation_resize: float + """The resize factor applied to images when the stitching program is correlating.""" + + overlap: float + """The overlap between adjacent images as a fraction of the image size.""" + + +def _coerce_lecacy_scan_data(data: dict) -> dict: + """Coerce any scan data from before version 2 into the version 2 format.""" + # Before the current version no schema_version was set + if "schema_version" in data: + return data + + if "correlation_resize" and "overlap" in data: + correlation_resize = data.pop("correlation_resize") + # Note we don't pop overlap is a setting for the legacy workflow as well + # as a stitching setting. + # This is done because in future workflows the stitching overlap may be a + # directly set setting or something that is calculated from other settings. + overlap = data["overlap"] + data["stitching_settings"] = StitchingData( + correlation_resize=correlation_resize, + overlap=overlap, + ) + else: + data["stitching_settings"] = None + + # Add any legacy workflow settings that are found + legacy_keys = [ + "overlap", + "max_dist", + "dx", + "dy", + "autofocus_dz", + "autofocus_on", + "skip_background", + ] + workflow_settings = {} + for key in legacy_keys: + if key in data: + workflow_settings[key] = data.pop(key) + + data["workflow"] = "Legacy" + data["workflow_settings"] = workflow_settings + return data + + class ScanData(BaseModel): """Data about a scan to be saved to a JSON file in the directory. @@ -60,42 +112,20 @@ class ScanData(BaseModel): model_config = ConfigDict(extra="forbid") + schema_version: int = SCAN_DATA_SCHEMA_VERSION + scan_name: str """The name of the scan i.e. scan_0001""" starting_position: Mapping[str, int] """The starting position in dictionary format.""" - overlap: float - """The overlap between adjacent images as a fraction of the image size.""" - - max_dist: int - """The maximum distance the scan could move (in steps) from the starting position.""" - - dx: int - """The number of steps between adjacent images in x.""" - - dy: int - """The number of steps between adjacent images in y.""" - - autofocus_dz: int - """The z range used for autofocus (in steps).""" - - autofocus_on: bool - """Whether autofocus is on.""" - start_time: datetime """The time the scan started.""" - skip_background: bool - """Whether automatic background detection is on, skipping locations with no sample.""" - stitch_automatically: bool """Whether the scan is set to automatically stitch when complete.""" - correlation_resize: float - """The resize factor applied to images when the stitching program is correlating.""" - save_resolution: tuple[int, int] """The resolution that scan images are saved at.""" @@ -114,6 +144,18 @@ class ScanData(BaseModel): This should be set with ``set_final_data()`` to ensure duration is set. """ + workflow: str + """The class name of the workflow Thing.""" + + workflow_settings: dict[str, Any] + """The settings for this workflow.""" + + stitching_settings: Optional[StitchingData] + """The data needed to stitch a scan. + + Set to None for types of scan that cannot be stitched. + """ + def set_final_data(self, result: str) -> None: """Set the final data for the scan, scan duration is automatically calculated. @@ -122,6 +164,22 @@ class ScanData(BaseModel): self.duration = datetime.now() - self.start_time self.scan_result = result + @model_validator(mode="before") + @classmethod + def coerce_legacy(cls, data: dict) -> dict: + """Coerce any legacy data.""" + return _coerce_lecacy_scan_data(data) + + @model_validator(mode="after") + def validate_schema_version(self) -> Self: + """Validate the schema version is as the current one.""" + if self.schema_version != SCAN_DATA_SCHEMA_VERSION: + raise ValueError( + f"Unsupported schema version {self.schema_version}, " + f"expected {SCAN_DATA_SCHEMA_VERSION}" + ) + return self + @field_validator("start_time", mode="before") @classmethod def parse_timestamp(cls, value: str | datetime) -> datetime: diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index 194c25b7..dea895b2 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -2,12 +2,15 @@ from typing import Mapping, Optional import labthings_fastapi as lt +from openflexure_microscope_server.scan_directories import StitchingData from openflexure_microscope_server.scan_planners import ScanPlanner, SmartSpiral +from openflexure_microscope_server.stitching import STITCHING_RESOLUTION from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.background_detect import ( BackgroundDetectAlgorithm, ChannelDeviationLUV, ) +from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper class ScanWorkflow(lt.Thing): @@ -17,9 +20,26 @@ class ScanWorkflow(lt.Thing): scan planning, aquisition routine. """ - _detector: Optional[BackgroundDetectAlgorithm | Mapping[str, BackgroundDetectAlgorithm]] = lt.thing_slot() + # All workdlows must have at least one background detector and a set class for scan + # planning + _background_detector: Optional[ + BackgroundDetectAlgorithm | Mapping[str, BackgroundDetectAlgorithm] + ] = lt.thing_slot() _planner_cls: type[ScanPlanner] + # All workdlows set a save resolution + save_resolution: tuple[int, int] = lt.setting(default=(1640, 1232)) + """A tuple of the image resolution to capture.""" + + def check_before_start(self, scan_name: str) -> None: + """Check before the scan starts. Throw an error if the scan shouldn't start. + + The scan_name is passed to this function to enable workflows to validate the + scan name if needed. + """ + raise NotImplementedError( + "Each specific ScanWorkflow must implement a check_before_start." + ) @lt.property def ready(self) -> bool: @@ -28,23 +48,141 @@ class ScanWorkflow(lt.Thing): "Each specific ScanWorkflow must implement a ready property." ) + def all_settings(self) -> tuple[dict, Optional[StitchingData]]: + """Return the scan settings and the stitching settings. + + - The specific settings for this scan workflow are returned as a dict. + - Stitiching settings are returned either as a StitchingData object or None + is returned if it is not possible to stitch the scan. + """ + raise NotImplementedError( + "Each specific ScanWorkflow must implement a `all_settings`. " + "method." + ) + def pre_scan_routine(self) -> None: raise NotImplementedError( "Each specific ScanWorkflow must implement a pre-scan routine." ) + class HistoScanWorkflow(ScanWorkflow): - _detector: ChannelDeviationLUV = lt.thing_slot() + # Thing Slots + _background_detector: ChannelDeviationLUV = lt.thing_slot() + _csm: CameraStageMapper = lt.thing_slot() _planner_cls: type[ScanPlanner] = SmartSpiral _autofocus: AutofocusThing = lt.thing_slot() + # Scan settings + + skip_background: bool = lt.setting(default=True) + """Whether to detect and skip empty fields of view. + + This uses the settings from the ``BackgroundDetectThing``. + """ + autofocus_dz: int = lt.setting(default=1000) """The z distance to perform an autofocus in steps.""" + max_range: int = lt.setting(default=45000) + """The maximum distance in steps from the centre of the scan.""" + + overlap: float = lt.setting(default=0.45) + """The fraction (0-1) that adjacent images should overlap in x or y.""" + + # noqa, scan_name is unused but is needed for equivalence with other workflows. + def check_before_start(self, scan_name: str) -> None: # noqa: ARG002 + """Before starting a scan, check that background and camera-stage-mapping are set. + + Raise error if: + - background is to be skipped but is not set + - camera stage mapping is not set + + Raise warning if not using background detect that scan will go on until max steps reached + """ + if self._csm.calibration_required: + raise RuntimeError("Camera Stage Mapping is not calibrated.") + + if self.skip_background: + if not self.background_detector.ready: + raise RuntimeError( + "Background is not set: you need to calibrate background detection." + ) + else: + self.logger.warning( + "This scan will run in a spiral from the starting point " + f"until you cancel it, or until it has moved by {self.max_range} steps " + "in every direction. Make sure you watch it run to stop it leaving " + "the area of interest, or (worse) leading the microscope's range " + "of motion." + ) + @lt.property def ready(self) -> bool: """Whether this scanworkflow is ready to start.""" - return self._detector.ready + if self._csm.calibration_required: + return False + if not self.skip_background: + return True + return self._background_detector.ready + + def all_settings(self) -> tuple[dict, StitchingData]: + stitching_settings = StitchingData( + overlap=self.overlap, + correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0], + ) + + dx, dy = self._calc_displacement_from_test_image(self.overlap) + self.logger.info( + f"Based on an overlap of {self.overlap}, the stage will make steps of " + f"{dx}, {dy}" + ) + + autofocus_dz = self.autofocus_dz + if autofocus_dz == 0: + self.logger.info("Running scan without autofocus") + elif autofocus_dz <= 200: + self.logger.warning( + f"Your autofocus range is {autofocus_dz} steps, which is too short to " + "attempt to focus. Running without autofocus" + ) + autofocus_dz = 0 + + scan_settings = { + "overlap": self.overlap, + "max_dist": self.max_range, + "dx": dx, + "dy": dy, + "autofocus_dz": autofocus_dz, + "autofocus_on": bool(autofocus_dz), + "skip_background": self.skip_background, + } + + return scan_settings, stitching_settings + + def _calc_displacement_from_overlap(self, overlap: float) -> tuple[int, int]: + """Take a test image and use camera stage mapping to calculate x and y displacement. + + :param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means + that each image should overlap its nearest neighbour by 50%. + + :returns: (dx, dy) - the x and y displacements in steps + """ + csm_image_res = [int(i) for i in self._csm.image_resolution] + + # Calculate displacements in image coordinates + dx_img = csm_image_res[1] * (1 - overlap) + dy_img = csm_image_res[0] * (1 - overlap) + + x_move_stage = self._csm.convert_image_to_stage_coordinates(x=dx_img, y=0) + y_move_stage = self._csm.convert_image_to_stage_coordinates(x=0, y=dy_img) + + # Assume no rotation or skew and take only the aligned axis of vector. + # Coerce to positive integer, but correct if x and y are flipped + if abs(x_move_stage["x"]) > abs(x_move_stage["y"]): + return x_move_stage["x"], y_move_stage["y"] + # If not use the other stage axes + return x_move_stage["y"], y_move_stage["x"] def pre_scan_routine(self) -> None: self._autofocus.looping_autofocus(dz=self.autofocus_dz, start="centre") diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index b7ff64b5..7278d4f3 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -179,11 +179,11 @@ class SmartScanThing(lt.Thing): @lt.action def sample_scan(self, scan_name: str = "") -> None: - """Move the stage to cover an area, taking images that can be tiled together. + """Move the stage to cover an area, taking images. - The stage will move in a pattern that grows outwards from the starting point, - stopping once it is surrounded by "background" (as detected by the - camera Thing) or reaches the "max_range" measured in steps. + Depending on the way the stage moves depends on the selected worflow. + If images overlap for a scan workdlow then the images can be stitched together + into a larger composite image. """ got_lock = self._scan_lock.acquire(timeout=0.1) if not got_lock: @@ -193,11 +193,13 @@ class SmartScanThing(lt.Thing): # the presence of `scan_data` is used during error handling to # determine whether the scan started. self._scan_data = None + # probably make workflow a context manager with a lock? + workflow = self._workflow try: - self._check_background_and_csm_set() + workflow.check_before_start(scan_name) self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name) self._latest_scan_name = self.ongoing_scan.name - self._run_scan() + self._run_scan(workflow) except Exception as e: # If _scan_data is set then scan started if self._scan_data is not None: @@ -222,35 +224,6 @@ class SmartScanThing(lt.Thing): # Remove any scan folders containing zero images. self.purge_empty_scans() - @_scan_running - def _check_background_and_csm_set(self) -> None: - """Before starting a scan, check that background and camera-stage-mapping are set. - - Raise error if: - - background is to be skipped but is not set - - camera stage mapping is not set - - Raise warning if not using background detect that scan will go on until max steps reached - """ - self._csm.assert_calibration() - - if self.skip_background: - if ( - self._cam.background_detector is None - or not self._cam.background_detector.ready - ): - raise RuntimeError( - "Background is not set: you need to calibrate background detection." - ) - else: - self.logger.warning( - "This scan will run in a spiral from the starting point " - f"until you cancel it, or until it has moved by {self.max_range} steps " - "in every direction. Make sure you watch it run to stop it leaving " - "the area of interest, or (worse) leading the microscope's range " - "of motion." - ) - @_scan_running def _move_to_next_point( self, next_point: tuple[int, int], z_estimate: Optional[int] = None @@ -276,90 +249,26 @@ class SmartScanThing(lt.Thing): return (next_point[0], next_point[1], z_estimate) @_scan_running - def _calc_displacement_from_test_image(self, overlap: float) -> tuple[int, int]: - """Take a test image and use camera stage mapping to calculate x and y displacement. - - :param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means - that each image should overlap its nearest neighbour by 50%. - - :returns: (dx, dy) - the x and y displacements in steps - """ - if ( - self._csm.image_resolution is None - or self._csm.image_to_stage_displacement_matrix is None - ): - raise CSMUncalibratedError("Camera stage mapping is not calibrated") - test_image = self._cam.grab_as_array() - - test_image_res = list(test_image.shape) - - csm_image_res = [int(i) for i in self._csm.image_resolution] - - # If current stream width is different to csm calibration width, - # perform the conversion here - res_ratio = csm_image_res[0] / test_image_res[0] - - # get displacement matrix. note it is for (y, x) not (x, y) coordinates - csm_disp_matrix = np.array(self._csm.image_to_stage_displacement_matrix) - csm_disp_matrix *= res_ratio - - # Calculate displacements in image coordinates - dx_img = test_image.shape[1] * (1 - overlap) - dy_img = test_image.shape[0] * (1 - overlap) - - # Calculate displacements in steps as vectors using a dot product with the matrix - dx_vec = np.dot(np.array([0, dx_img]), csm_disp_matrix) - dy_vec = np.dot(np.array([dy_img, 0]), csm_disp_matrix) - - # Assume no rotation or skew and take only the aligned axis of vector. - # Coerce to positive integer - dx = int(np.abs(dx_vec[0])) - dy = int(np.abs(dy_vec[1])) - - return dx, dy - - @_scan_running - def _collect_scan_data(self) -> scan_directories.ScanData: + def _collect_scan_data(self, workflow: ScanWorkflow) -> scan_directories.ScanData: """Collect and return the data for this scan so it cannot be changed mid-scan.""" # Record starting position so it can be returned to at end of scan. starting_position = self._stage.position - overlap = self.overlap - dx, dy = self._calc_displacement_from_test_image(overlap) - correlation_resize = stitching.STITCHING_RESOLUTION[0] / self.save_resolution[0] - self.logger.debug( - f"Resizing images when correlating by a factor of {correlation_resize}" - ) + workflow_settings, stitching_settings = workflow.all_settings() - self.logger.info( - f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}" - ) - - autofocus_dz = self.autofocus_dz - if autofocus_dz == 0: - self.logger.info("Running scan without autofocus") - elif autofocus_dz <= 200: - self.logger.warning( - f"Your autofocus range is {autofocus_dz} steps, which is too short to " - "attempt to focus. Running without autofocus" - ) - autofocus_dz = 0 + # If stitching settings is None then this workflow doesn't support stitching. + auto_stitch = self.stitch_automatically and stitching_settings is not None # Fix scan parameters in case UI is updated during scan. return scan_directories.ScanData( scan_name=self.ongoing_scan.name, starting_position=starting_position, - overlap=overlap, - max_dist=self.max_range, - dx=dx, - dy=dy, - autofocus_dz=autofocus_dz, - autofocus_on=bool(autofocus_dz), start_time=datetime.now(), - skip_background=self.skip_background, - stitch_automatically=self.stitch_automatically, - correlation_resize=correlation_resize, - save_resolution=self.save_resolution, + stitch_automatically=auto_stitch, + save_resolution=workflow.save_resolution, + workflow=type(workflow).__name__, + workflow_settings=workflow_settings, + stitching_settings=stitching_settings, ) @_scan_running @@ -381,7 +290,7 @@ class SmartScanThing(lt.Thing): self.preview_stitcher.start() @_scan_running - def _run_scan(self) -> None: + def _run_scan(self, workflow: ScanWorkflow) -> None: """Prepare and run the main scan, and perform final actions on completion. The result (or exception) from the main scan loop determines whether the @@ -389,10 +298,8 @@ class SmartScanThing(lt.Thing): starting x,y,z position. """ try: - # probably make workflow a context manager with a lock? - workflow = self._workflow self._cam.start_streaming(main_resolution=(3280, 2464)) - self._scan_data = self._collect_scan_data() + self._scan_data = self._collect_scan_data(workflow) workflow.pre_scan_routine(self._scan_data) self.ongoing_scan.save_scan_data(self._scan_data) images_dir = self.ongoing_scan.images_dir @@ -572,24 +479,9 @@ class SmartScanThing(lt.Thing): raise HTTPException(404, "File not found") return FileResponse(preview_path) - save_resolution: tuple[int, int] = lt.setting(default=(1640, 1232)) - """A tuple of the image resolution to capture.""" - - max_range: int = lt.setting(default=45000) - """The maximum distance in steps from the centre of the scan.""" - stitch_tiff: bool = lt.setting(default=False) """Whether or not to also produce a pyramidal tiff at the end of a scan.""" - skip_background: bool = lt.setting(default=True) - """Whether to detect and skip empty fields of view. - - This uses the settings from the ``BackgroundDetectThing``. - """ - - overlap: float = lt.setting(default=0.45) - """The fraction (0-1) that adjacent images should overlap in x or y.""" - stitch_automatically: bool = lt.setting(default=True) """Whether to run a final stitch at the end of a successful scan.""" diff --git a/tests/unit_tests/test_scan_data.py b/tests/unit_tests/test_scan_data.py index b14ba061..d09f1594 100644 --- a/tests/unit_tests/test_scan_data.py +++ b/tests/unit_tests/test_scan_data.py @@ -28,8 +28,8 @@ MOCK_END_TIME = datetime( ) -def _fake_scan_data(**kwargs) -> ScanData: - """Make fake scan data, the start time is now. Final properties are not added. +def _fake_legacy_scan_data(**kwargs) -> ScanData: + """Make fake legacy scan data, the start time is now. Final properties are not added. :param **kwargs: Key word arguments can be used to override other values. """ @@ -55,7 +55,7 @@ def _fake_scan_data(**kwargs) -> ScanData: def test_set_final_data(): """Check that adding final data to a ScanData object works as expected.""" - scan_data = _fake_scan_data() + scan_data = _fake_legacy_scan_data() assert scan_data.image_count == 0 assert scan_data.duration is None @@ -76,7 +76,7 @@ def test_set_final_data(): def test_custom_serialisation(): """Check that the custom serialisation in ScanData works as expected.""" - scan_data = _fake_scan_data() + scan_data = _fake_legacy_scan_data() # Serialise to string then load directly as json scan_data_dict = json.loads(scan_data.model_dump_json()) assert scan_data_dict["start_time"] == "2024-12-25_11:00:00" @@ -96,7 +96,7 @@ def test_custom_serialisation(): def test_round_trip_not_finalised(): """Check that ScanData without final data can be serialised and deserialised.""" - scan_data = _fake_scan_data() + scan_data = _fake_legacy_scan_data() scan_data_dict = json.loads(scan_data.model_dump_json()) scan_data_reloaded = ScanData(**scan_data_dict) @@ -108,7 +108,7 @@ def test_round_trip_not_finalised(): def test_round_trip_finalised(): """Check that finalised ScanData can be serialised and deserialised.""" - scan_data = _fake_scan_data() + scan_data = _fake_legacy_scan_data() # Finalise the data. scan_data.image_count += 123 scan_data.set_final_data(result="Success")