From 5ce74cad8a30337df9f58aba1b0c1c1b61ef766c Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 14 Jan 2026 13:35:23 +0000 Subject: [PATCH 01/27] A start to implementing ScanWorkflow --- .../things/scan_workflows.py | 50 +++++++++++++++++++ .../things/smart_scan.py | 18 ++++--- 2 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 src/openflexure_microscope_server/things/scan_workflows.py diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py new file mode 100644 index 00000000..194c25b7 --- /dev/null +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -0,0 +1,50 @@ +from typing import Mapping, Optional + +import labthings_fastapi as lt + +from openflexure_microscope_server.scan_planners import ScanPlanner, SmartSpiral +from openflexure_microscope_server.things.autofocus import AutofocusThing +from openflexure_microscope_server.things.background_detect import ( + BackgroundDetectAlgorithm, + ChannelDeviationLUV, +) + + +class ScanWorkflow(lt.Thing): + """A base class for all Scanworkflows. + + Scan workflows set the behaviour of a scan, inclduing the background detection, + scan planning, aquisition routine. + """ + + _detector: Optional[BackgroundDetectAlgorithm | Mapping[str, BackgroundDetectAlgorithm]] = lt.thing_slot() + _planner_cls: type[ScanPlanner] + + + @lt.property + def ready(self) -> bool: + """Whether this scanworkflow is ready to start.""" + raise NotImplementedError( + "Each specific ScanWorkflow must implement a ready property." + ) + + 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() + _planner_cls: type[ScanPlanner] = SmartSpiral + _autofocus: AutofocusThing = lt.thing_slot() + + autofocus_dz: int = lt.setting(default=1000) + """The z distance to perform an autofocus in steps.""" + + @lt.property + def ready(self) -> bool: + """Whether this scanworkflow is ready to start.""" + return self._detector.ready + + 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 ebbe37f2..b7ff64b5 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -35,6 +35,7 @@ from openflexure_microscope_server import scan_directories, scan_planners, stitc from .autofocus import AutofocusThing, StackParams from .camera import BaseCamera from .camera_stage_mapping import CameraStageMapper, CSMUncalibratedError +from .scan_workflows import HistoScanWorkflow, ScanWorkflow from .stage import BaseStage T = TypeVar("T") @@ -103,6 +104,7 @@ class SmartScanThing(lt.Thing): _cam: BaseCamera = lt.thing_slot() _csm: CameraStageMapper = lt.thing_slot() _stage: BaseStage = lt.thing_slot() + _workflow: HistoScanWorkflow = lt.thing_slot() def __init__( self, thing_server_interface: lt.ThingServerInterface, scans_folder: str @@ -195,7 +197,6 @@ class SmartScanThing(lt.Thing): self._check_background_and_csm_set() self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name) self._latest_scan_name = self.ongoing_scan.name - self._autofocus.looping_autofocus(dz=self.autofocus_dz, start="centre") self._run_scan() except Exception as e: # If _scan_data is set then scan started @@ -388,8 +389,11 @@ 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() + workflow.pre_scan_routine(self._scan_data) self.ongoing_scan.save_scan_data(self._scan_data) images_dir = self.ongoing_scan.images_dir if images_dir is None: @@ -398,7 +402,7 @@ class SmartScanThing(lt.Thing): ) self._stack_params = self._autofocus.create_stack_params( images_dir=images_dir, - autofocus_dz=self.autofocus_dz, + autofocus_dz=self.scan_data.autofocus_dz, save_resolution=self.scan_data.save_resolution, ) self._preview_stitcher = stitching.PreviewStitcher( @@ -408,7 +412,7 @@ class SmartScanThing(lt.Thing): ) # This is the main loop of the scan! - self._main_scan_loop() + self._main_scan_loop(workflow) self._save_final_scan_data(scan_result="success") except lt.exceptions.InvocationCancelledError: @@ -437,7 +441,7 @@ class SmartScanThing(lt.Thing): self._perform_final_stitch() @_scan_running - def _main_scan_loop(self) -> None: + def _main_scan_loop(self, workflow: ScanWorkflow) -> None: """Run the main loop of the scan. This loop runs during a scan, until no more scan x,y positions @@ -580,10 +584,8 @@ class SmartScanThing(lt.Thing): 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.""" + 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.""" From b6343362b2b74c721a7cb37e73bf83b848ec5943 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 15 Jan 2026 12:57:16 +0000 Subject: [PATCH 02/27] 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") From 54ed78c832ec529b722394a0ce4264c670aba81d Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 15 Jan 2026 14:53:47 +0000 Subject: [PATCH 03/27] Create aquisition routines for scan workflows, remove background detector from base ScanWorkflow class --- .../things/scan_workflows.py | 78 ++++++++++++++++--- .../things/smart_scan.py | 60 +++++--------- 2 files changed, 86 insertions(+), 52 deletions(-) diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index dea895b2..161caef9 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -7,7 +7,6 @@ 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 @@ -20,14 +19,10 @@ class ScanWorkflow(lt.Thing): scan planning, aquisition routine. """ - # 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() + # All workdlows must have a set class for scan planning _planner_cls: type[ScanPlanner] - # All workdlows set a save resolution + # All workflows set a save resolution save_resolution: tuple[int, int] = lt.setting(default=(1640, 1232)) """A tuple of the image resolution to capture.""" @@ -56,15 +51,26 @@ class ScanWorkflow(lt.Thing): is returned if it is not possible to stitch the scan. """ raise NotImplementedError( - "Each specific ScanWorkflow must implement a `all_settings`. " - "method." + "Each specific ScanWorkflow must implement a `all_settings`. method." ) - def pre_scan_routine(self) -> None: + def pre_scan_routine(self, settings: dict) -> None: raise NotImplementedError( "Each specific ScanWorkflow must implement a pre-scan routine." ) + def new_scan_planner(self, settings: dict, position: Mapping[str, int]) -> None: + raise NotImplementedError( + "Each specific ScanWorkflow must implement a ``new_scan_planner`` method." + ) + + def aquisition_routine( + self, settings: dict, xyz_pos: tuple[int, int, int] + ) -> tuple[bool, Optional[int]]: + raise NotImplementedError( + "Each specific ScanWorkflow must implement an aquisition routine" + ) + class HistoScanWorkflow(ScanWorkflow): # Thing Slots @@ -184,5 +190,53 @@ class HistoScanWorkflow(ScanWorkflow): # 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") + def pre_scan_routine(self, settings: dict) -> None: + self._autofocus.looping_autofocus(dz=settings["autofocus_dz"], start="centre") + + def new_scan_planner(self, settings: dict, position: Mapping[str, int]) -> None: + # The initial plan for the scan should be a single x,y position. All future + # moves will be planned around this point. In future, route planner could + # have multiple starting positions, each of which will be visited before the + # scan can end. + planner_settings = { + "dx": settings["dx"], + "dy": settings["dy"], + "max_dist": settings["max_dist"], + } + return self._planner_cls( + initial_position=(position["x"], position["y"]), + planner_settings=planner_settings, + ) + + def aquisition_routine( + self, settings: dict, xyz_pos: tuple[int, int, int] + ) -> tuple[bool, Optional[int]]: + """Perform aquisition routine. This is run at each scan location. + + :return: A tuple of whether an image was taken, and the z-position for focus. + If failed to find focus, returns for the focus z-position. + """ + # If skipping background, take an image to check if current field of view is background + if settings["skip_background"]: + capture_image, bg_message = self._cam.image_is_sample() + + if not capture_image: + msg = f"Skipping {xyz_pos} as it is {bg_message}." + self.logger.info(msg) + return False, None + + save_on_failure = settings["skip_background"] + + focused, focused_height = self._autofocus.run_smart_stack( + stack_parameters=self.stack_params, + save_on_failure=save_on_failure, + ) + # An image was captured if we are focussed or we are not skipping background. + imaged = focused or save_on_failure + + # run_smart_stage always returns a focus height for the sharpest image even + # if it failed to find a good focus. Set to None if not focussed. + if not focused: + focused_height = None + + return imaged, focused_height diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 7278d4f3..4752f8e4 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -22,19 +22,18 @@ from typing import ( TypeVar, ) -import numpy as np from fastapi import HTTPException from fastapi.responses import FileResponse from pydantic import BaseModel import labthings_fastapi as lt -from openflexure_microscope_server import scan_directories, scan_planners, stitching +from openflexure_microscope_server import scan_directories, stitching # Things from .autofocus import AutofocusThing, StackParams from .camera import BaseCamera -from .camera_stage_mapping import CameraStageMapper, CSMUncalibratedError +from .camera_stage_mapping import CameraStageMapper from .scan_workflows import HistoScanWorkflow, ScanWorkflow from .stage import BaseStage @@ -284,6 +283,9 @@ class SmartScanThing(lt.Thing): @_scan_running def _manage_stitching_threads(self) -> None: """Manage the stitching threads, starting them if needed and not already running.""" + if self.scan_data.stitching_settings is None: + # This scan can't stitch. + return # Assume 4 images means at least one offset in x and y, making the stitching # well constrained. if self.scan_data.image_count > 3 and not self.preview_stitcher.running: @@ -354,18 +356,9 @@ class SmartScanThing(lt.Thing): This loop runs during a scan, until no more scan x,y positions are remaining. """ - # The initial plan for the scan should be a single x,y position. All future - # moves will be planned around this point. In future, route planner could - # have multiple starting positions, each of which will be visited before the - # scan can end. - planner_settings = { - "dx": self.scan_data.dx, - "dy": self.scan_data.dy, - "max_dist": self.scan_data.max_dist, - } - route_planner = scan_planners.SmartSpiral( - initial_position=(self._stage.position["x"], self._stage.position["y"]), - planner_settings=planner_settings, + workflow_settings = self.scan_data.workflow_settings + route_planner = workflow.new_scan_planner( + workflow_settings, self._stage.position ) # The loop tests if the scan should continue, moves to the next position, @@ -383,43 +376,30 @@ class SmartScanThing(lt.Thing): new_pos_xyz[1], self._stage.position["z"], ) - - capture_image = True - # If skipping background, take an image to check if current field of view is background - if self.scan_data.skip_background: - capture_image, bg_message = self._cam.image_is_sample() - - if not capture_image: - route_planner.mark_location_visited( - new_pos_xyz, imaged=False, focused=False - ) - msg = f"Skipping {new_pos_xyz} as it is {bg_message}." - self.logger.info(msg) - continue - - focused, focused_height = self._autofocus.run_smart_stack( - stack_parameters=self.stack_params, - save_on_failure=not self.scan_data.skip_background, + imaged, focus_height = workflow.aquisition_routine( + workflow_settings, current_pos_xyz ) - current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focused_height) - - # An image was captured if we are focussed or we are not skipping background. - imaged = focused or not self.scan_data.skip_background + if focus_height is None: + focused = False + else: + current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focus_height) route_planner.mark_location_visited( current_pos_xyz, imaged=imaged, focused=focused ) - # increment capture counter as thread has completed - self.scan_data.image_count += 1 - # Add it to the incremental zip - self.ongoing_scan.zip_files() + if imaged: + # increment capture counter as thread has completed + self.scan_data.image_count += 1 + # Add it to the incremental zip + self.ongoing_scan.zip_files() @_scan_running def _return_to_starting_position(self) -> None: """Return to the initial scan position, if set.""" self.logger.info("Returning to starting position.") + if self._scan_data is not None: self._stage.move_absolute( **self.scan_data.starting_position, block_cancellation=True From adab5bae24178e6242101f3c5939580883b7d1d8 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 15 Jan 2026 16:04:24 +0000 Subject: [PATCH 04/27] Start smart stack parameters in scan workflow --- .../scan_directories.py | 13 +- .../things/autofocus.py | 126 ++----------- .../things/scan_workflows.py | 177 +++++++++++++++--- .../things/smart_scan.py | 15 +- 4 files changed, 191 insertions(+), 140 deletions(-) diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index c766b23e..54a52dcc 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -8,11 +8,12 @@ import shutil import threading import zipfile from datetime import datetime, timedelta -from typing import Any, Mapping, Optional, Self +from typing import Annotated, Any, Mapping, Optional, Self from pydantic import ( BaseModel, ConfigDict, + PlainSerializer, ValidationError, field_serializer, field_validator, @@ -99,6 +100,14 @@ def _coerce_lecacy_scan_data(data: dict) -> dict: return data +# This is a dictionary of a PyDantic model. It allows ScanData to hold arbitrary +# workflow settings models during a scan. +AnyModelOrDict = Annotated[ + dict | BaseModel, + PlainSerializer(lambda v: v.model_dump(), return_type=dict), +] + + class ScanData(BaseModel): """Data about a scan to be saved to a JSON file in the directory. @@ -147,7 +156,7 @@ class ScanData(BaseModel): workflow: str """The class name of the workflow Thing.""" - workflow_settings: dict[str, Any] + workflow_settings: AnyModelOrDict """The settings for this workflow.""" stitching_settings: Optional[StitchingData] diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index b1165888..5a95b3de 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -33,8 +33,8 @@ class NotStreamingError(RuntimeError): """No images captured from stream. The camera is almost certainly not streaming.""" -class StackParams(BaseModel): - """A class for holding for stack parameters, and returning computed ones.""" +class SmartStackParams(BaseModel): + """A class for holding for smart stack parameters, and returning computed ones.""" stack_dz: int images_to_save: int @@ -80,7 +80,8 @@ class StackParams(BaseModel): ) if min_images_to_test > MAX_TEST_IMAGE_COUNT: raise ValueError( - f"Testing with more than {MAX_TEST_IMAGE_COUNT} images is likely to focus on the cover slip, or strike the sample." + f"Testing with more than {MAX_TEST_IMAGE_COUNT} images is likely to " + "focus on the cover slip, or strike the sample." ) if min_images_to_test % 2 == 0 or min_images_to_test <= 0: raise ValueError( @@ -97,7 +98,7 @@ class StackParams(BaseModel): return images_to_save @model_validator(mode="after") - def check_image_limits(self) -> "StackParams": + def check_image_limits(self) -> "SmartStackParams": """Ensure the number of images to save isn't more than the minimum tested.""" if self.images_to_save > self.min_images_to_test: raise ValueError("Can't save more images than the minimum number tested.") @@ -452,107 +453,10 @@ class AutofocusThing(lt.Thing): "Looping autofocus couldn't converge on a focus location." ) - stack_images_to_save: int = lt.setting(default=1) - """The number of images to save in a stack. - - Defaults to 1 unless you need to see either side of focus - """ - - stack_min_images_to_test: int = lt.setting(default=9) - """The minimum number of images to capture in a stack. - - This many images are captures and tested for focus, if the focus is not central - enough more images may be captured. After new images are captured the number sets - the number of images used for checking if focus is central. - - Defaults to 9 which balances reliability and speed. - """ - - stack_dz: int = lt.setting(default=50) - """Distance in steps between images in a z-stack. - - Suggested values: - - * 50 for 60-100x - * 100 for 40x - * 200 for 20x - """ - - @lt.action - def create_stack_params( - self, - images_dir: str, - autofocus_dz: int, - save_resolution: tuple[int, int], - ) -> StackParams: - """Set up the parameters used for all stacks in a scan. - - :param images_dir: the folder to save all images - :param autofocus_dz: the range to autofocus over if a stack fails - :param save_resolution: The resolution to save the captures to disk with - - :returns: A StackParams object with the required parameters. - """ - # Coerce min_images_to_test parameter - min_images_to_test = self.stack_min_images_to_test - if min_images_to_test < MIN_TEST_IMAGE_COUNT: - self.logger.warning( - f"Cannot test only {min_images_to_test} image(s) as this will fail. " - "Setting min images to test to lowest possible value of" - f"{MIN_TEST_IMAGE_COUNT}." - ) - min_images_to_test = MIN_TEST_IMAGE_COUNT - elif min_images_to_test > MAX_TEST_IMAGE_COUNT: - self.logger.warning( - f"Testing {min_images_to_test} images will cause defocus. " - "Setting min images to test to highest possible value of " - f"{MAX_TEST_IMAGE_COUNT}." - ) - min_images_to_test = MAX_TEST_IMAGE_COUNT - elif min_images_to_test % 2 == 0: - min_images_to_test += 1 - self.logger.warning( - "Minimum number of images to test should be odd, setting to " - f"{min_images_to_test}." - ) - # Set the Thing property to the coerced value - self.stack_min_images_to_test = min_images_to_test - - # Coerce the images to save parameter to be positive, odd, and less than - # min_images_to_save - images_to_save = self.stack_images_to_save - if images_to_save <= 0: - self.logger.warning( - "At least 1 images must be saved. Setting images to save to 1." - ) - images_to_save = 1 - elif images_to_save > min_images_to_test: - self.logger.warning( - f"Cannot save {images_to_save} images as this above the minimum " - f"number to test. Setting images to save to {MAX_TEST_IMAGE_COUNT}." - ) - images_to_save = min_images_to_test - elif images_to_save % 2 == 0: - images_to_save += 1 - self.logger.warning( - f"Images to save should be odd, setting to {images_to_save}." - ) - # Set the Thing property to the coerced value - self.stack_images_to_save = images_to_save - - return StackParams( - stack_dz=self.stack_dz, - images_to_save=self.stack_images_to_save, - min_images_to_test=self.stack_min_images_to_test, - autofocus_dz=autofocus_dz, - images_dir=images_dir, - save_resolution=save_resolution, - ) - @lt.action def run_smart_stack( self, - stack_parameters: StackParams, + stack_parameters: SmartStackParams, save_on_failure: bool = False, check_turning_points: bool = True, ) -> tuple[bool, int]: @@ -564,7 +468,7 @@ class AutofocusThing(lt.Thing): The sharpest image, and optionally images around the sharpest, will be saved to the images_dir with their coordinates in the filename. - :param stack_parameters: A StackParams object containing the required + :param stack_parameters: A SmartStackParams object containing the required parameters to run a stack. :param save_on_failure: Whether to save an image even if no focus was found. :param check_turning_points: Whether to check the number of turning points in @@ -579,7 +483,7 @@ class AutofocusThing(lt.Thing): attempt = 0 while True: attempt += 1 - success, captures, sharpest_id = self.z_stack( + success, captures, sharpest_id = self.smart_z_stack( stack_parameters=stack_parameters, check_turning_points=check_turning_points, ) @@ -630,7 +534,7 @@ class AutofocusThing(lt.Thing): self, sharpest_id: int, captures: list[CaptureInfo], - stack_parameters: StackParams, + stack_parameters: SmartStackParams, ) -> int: """Save the required captures to disk. @@ -640,7 +544,7 @@ class AutofocusThing(lt.Thing): :param sharpest_id: the buffer id index of the sharpest image :param captures: a list of captures, including file name, image data and metadata - :param stack_parameters: a StackParams object holding stack parameters + :param stack_parameters: a SmartStackParams object holding stack parameters """ sharpest_index = _get_capture_index_by_id(captures, sharpest_id) slice_to_save = stack_parameters.slice_to_save(sharpest_index) @@ -655,19 +559,23 @@ class AutofocusThing(lt.Thing): self._cam.clear_buffers() return sharpest_index - def z_stack( + def smart_z_stack( self, - stack_parameters: StackParams, + stack_parameters: SmartStackParams, check_turning_points: bool, ) -> tuple[bool, list[CaptureInfo], int]: """Capture a series of images checking that sharpest image central. + This is part of run_smart_stack. This is the actual z_stackng stacking method + called by the action run_smart_stack. The action also handles reseting, + autofocussing, and retrying. + The images are separated in z offset by stack_parameters.stack_dz, as they are captured the last stack_parameters.min_images_to_test images are checked to see if the sharpest image is central enough in the stack. If it is the stack completes. - :param stack_parameters: a StackParams object holding stack parameters + :param stack_parameters: a SmartStackParams object holding stack parameters :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) diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index 161caef9..d06ba985 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -1,11 +1,18 @@ from typing import Mapping, Optional +from pydantic import BaseModel + 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.autofocus import ( + MAX_TEST_IMAGE_COUNT, + MIN_TEST_IMAGE_COUNT, + AutofocusThing, + SmartStackParams, +) from openflexure_microscope_server.things.background_detect import ( ChannelDeviationLUV, ) @@ -43,7 +50,8 @@ class ScanWorkflow(lt.Thing): "Each specific ScanWorkflow must implement a ready property." ) - def all_settings(self) -> tuple[dict, Optional[StitchingData]]: + # TODO should be a model + def all_settings(self, images_dir: str) -> tuple[dict, Optional[StitchingData]]: """Return the scan settings and the stitching settings. - The specific settings for this scan workflow are returned as a dict. @@ -72,6 +80,21 @@ class ScanWorkflow(lt.Thing): ) +class HistoScanSettingsModel(BaseModel): + """The settings including needed for running a HistoScan. + + This includes settings caluclated when starting. This will be serialised in + ScanData. + """ + + overlap: float + max_dist: int + dx: int + dy: int + skip_background: bool + smart_stack_prarams: SmartStackParams + + class HistoScanWorkflow(ScanWorkflow): # Thing Slots _background_detector: ChannelDeviationLUV = lt.thing_slot() @@ -96,6 +119,34 @@ class HistoScanWorkflow(ScanWorkflow): overlap: float = lt.setting(default=0.45) """The fraction (0-1) that adjacent images should overlap in x or y.""" + # Stacking settings + + stack_images_to_save: int = lt.setting(default=1) + """The number of images to save in a stack. + + Defaults to 1 unless you need to see either side of focus + """ + + stack_min_images_to_test: int = lt.setting(default=9) + """The minimum number of images to capture in a stack. + + This many images are captures and tested for focus, if the focus is not central + enough more images may be captured. After new images are captured the number sets + the number of images used for checking if focus is central. + + Defaults to 9 which balances reliability and speed. + """ + + stack_dz: int = lt.setting(default=50) + """Distance in steps between images in a z-stack. + + Suggested values: + + * 50 for 60-100x + * 100 for 40x + * 200 for 20x + """ + # 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. @@ -110,7 +161,7 @@ class HistoScanWorkflow(ScanWorkflow): raise RuntimeError("Camera Stage Mapping is not calibrated.") if self.skip_background: - if not self.background_detector.ready: + if not self._background_detector.ready: raise RuntimeError( "Background is not set: you need to calibrate background detection." ) @@ -132,7 +183,9 @@ class HistoScanWorkflow(ScanWorkflow): return True return self._background_detector.ready - def all_settings(self) -> tuple[dict, StitchingData]: + def all_settings( + self, images_dir: str + ) -> tuple[HistoScanSettingsModel, StitchingData]: stitching_settings = StitchingData( overlap=self.overlap, correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0], @@ -154,15 +207,21 @@ class HistoScanWorkflow(ScanWorkflow): ) 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, - } + smart_stack_prarams = self.create_smart_stack_params( + images_dir=images_dir, + autofocus_dz=autofocus_dz, + save_resolution=self.save_resolution, + ) + + scan_settings = HistoScanSettingsModel( + overlap=self.overlap, + max_dist=self.max_range, + dx=dx, + dy=dy, + skip_background=self.skip_background, + smart_stack_prarams=smart_stack_prarams, + ) + autofocus_dz = (autofocus_dz,) return scan_settings, stitching_settings @@ -190,18 +249,90 @@ class HistoScanWorkflow(ScanWorkflow): # If not use the other stage axes return x_move_stage["y"], y_move_stage["x"] - def pre_scan_routine(self, settings: dict) -> None: - self._autofocus.looping_autofocus(dz=settings["autofocus_dz"], start="centre") + def create_smart_stack_params( + self, + images_dir: str, + autofocus_dz: int, + save_resolution: tuple[int, int], + ) -> SmartStackParams: + """Set up the parameters used for all stacks in a scan. - def new_scan_planner(self, settings: dict, position: Mapping[str, int]) -> None: + :param images_dir: the folder to save all images + :param autofocus_dz: the range to autofocus over if a stack fails + :param save_resolution: The resolution to save the captures to disk with + + :returns: A StackSmartParams object with the required parameters. + """ + # Coerce min_images_to_test parameter + min_images_to_test = self.stack_min_images_to_test + if min_images_to_test < MIN_TEST_IMAGE_COUNT: + self.logger.warning( + f"Cannot test only {min_images_to_test} image(s) as this will fail. " + "Setting min images to test to lowest possible value of" + f"{MIN_TEST_IMAGE_COUNT}." + ) + min_images_to_test = MIN_TEST_IMAGE_COUNT + elif min_images_to_test > MAX_TEST_IMAGE_COUNT: + self.logger.warning( + f"Testing {min_images_to_test} images will cause defocus. " + "Setting min images to test to highest possible value of " + f"{MAX_TEST_IMAGE_COUNT}." + ) + min_images_to_test = MAX_TEST_IMAGE_COUNT + elif min_images_to_test % 2 == 0: + min_images_to_test += 1 + self.logger.warning( + "Minimum number of images to test should be odd, setting to " + f"{min_images_to_test}." + ) + # Set the Thing property to the coerced value + self.stack_min_images_to_test = min_images_to_test + + # Coerce the images to save parameter to be positive, odd, and less than + # min_images_to_save + images_to_save = self.stack_images_to_save + if images_to_save <= 0: + self.logger.warning( + "At least 1 images must be saved. Setting images to save to 1." + ) + images_to_save = 1 + elif images_to_save > min_images_to_test: + self.logger.warning( + f"Cannot save {images_to_save} images as this above the minimum " + f"number to test. Setting images to save to {MAX_TEST_IMAGE_COUNT}." + ) + images_to_save = min_images_to_test + elif images_to_save % 2 == 0: + images_to_save += 1 + self.logger.warning( + f"Images to save should be odd, setting to {images_to_save}." + ) + # Set the Thing property to the coerced value + self.stack_images_to_save = images_to_save + + return SmartStackParams( + stack_dz=self.stack_dz, + images_to_save=self.stack_images_to_save, + min_images_to_test=self.stack_min_images_to_test, + autofocus_dz=autofocus_dz, + images_dir=images_dir, + save_resolution=save_resolution, + ) + + def pre_scan_routine(self, settings: HistoScanSettingsModel) -> None: + self._autofocus.looping_autofocus(dz=settings.autofocus_dz, start="centre") + + def new_scan_planner( + self, settings: HistoScanSettingsModel, position: Mapping[str, int] + ) -> None: # The initial plan for the scan should be a single x,y position. All future # moves will be planned around this point. In future, route planner could # have multiple starting positions, each of which will be visited before the # scan can end. planner_settings = { - "dx": settings["dx"], - "dy": settings["dy"], - "max_dist": settings["max_dist"], + "dx": settings.dx, + "dy": settings.dy, + "max_dist": settings.max_dist, } return self._planner_cls( initial_position=(position["x"], position["y"]), @@ -209,7 +340,7 @@ class HistoScanWorkflow(ScanWorkflow): ) def aquisition_routine( - self, settings: dict, xyz_pos: tuple[int, int, int] + self, settings: HistoScanSettingsModel, xyz_pos: tuple[int, int, int] ) -> tuple[bool, Optional[int]]: """Perform aquisition routine. This is run at each scan location. @@ -217,7 +348,7 @@ class HistoScanWorkflow(ScanWorkflow): If failed to find focus, returns for the focus z-position. """ # If skipping background, take an image to check if current field of view is background - if settings["skip_background"]: + if settings.skip_background: capture_image, bg_message = self._cam.image_is_sample() if not capture_image: @@ -225,10 +356,10 @@ class HistoScanWorkflow(ScanWorkflow): self.logger.info(msg) return False, None - save_on_failure = settings["skip_background"] + save_on_failure = settings.skip_background focused, focused_height = self._autofocus.run_smart_stack( - stack_parameters=self.stack_params, + stack_parameters=settings.smart_stack_params, save_on_failure=save_on_failure, ) # An image was captured if we are focussed or we are not skipping background. diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 4752f8e4..4207783a 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -253,7 +253,14 @@ class SmartScanThing(lt.Thing): # Record starting position so it can be returned to at end of scan. starting_position = self._stage.position - workflow_settings, stitching_settings = workflow.all_settings() + images_dir = self.ongoing_scan.images_dir + # Type narrowing + if images_dir is None: + raise RuntimeError("Couldn't run scan, images directory was not created.") + + workflow_settings, stitching_settings = workflow.all_settings( + images_dir=images_dir + ) # If stitching settings is None then this workflow doesn't support stitching. auto_stitch = self.stitch_automatically and stitching_settings is not None @@ -305,15 +312,11 @@ class SmartScanThing(lt.Thing): workflow.pre_scan_routine(self._scan_data) self.ongoing_scan.save_scan_data(self._scan_data) images_dir = self.ongoing_scan.images_dir + # Type narrowing if images_dir is None: raise RuntimeError( "Couldn't run scan, images directory was not created." ) - self._stack_params = self._autofocus.create_stack_params( - images_dir=images_dir, - autofocus_dz=self.scan_data.autofocus_dz, - save_resolution=self.scan_data.save_resolution, - ) self._preview_stitcher = stitching.PreviewStitcher( images_dir, overlap=self.scan_data.overlap, From 665622a8020d221ccd2cb722ac7b4b38a16da1da Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 15 Jan 2026 18:25:05 +0000 Subject: [PATCH 05/27] First working interation of ScanWorkflows. Needs a tidy --- ofm_config_full.json | 1 + ofm_config_simulation.json | 1 + .../scan_directories.py | 139 +++++++++--------- .../stitching.py | 6 +- .../things/scan_workflows.py | 78 ++++++---- .../things/smart_scan.py | 97 ++++++------ 6 files changed, 173 insertions(+), 149 deletions(-) diff --git a/ofm_config_full.json b/ofm_config_full.json index 6d4686ec..19b9e89a 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -16,6 +16,7 @@ "scans_folder": "/var/openflexure/scans/" } }, + "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", "stage_measure": "openflexure_microscope_server.things.stage_measure:RangeofMotionThing", "bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV", "bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV" diff --git a/ofm_config_simulation.json b/ofm_config_simulation.json index ef886fb4..8cc7602f 100644 --- a/ofm_config_simulation.json +++ b/ofm_config_simulation.json @@ -11,6 +11,7 @@ "scans_folder": "./openflexure/scans/" } }, + "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", "bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV", "bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV" }, diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index 54a52dcc..86ab0455 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -8,12 +8,11 @@ import shutil import threading import zipfile from datetime import datetime, timedelta -from typing import Annotated, Any, Mapping, Optional, Self +from typing import Any, Mapping, Optional, Self from pydantic import ( BaseModel, ConfigDict, - PlainSerializer, ValidationError, field_serializer, field_validator, @@ -60,56 +59,20 @@ class StitchingData(BaseModel): """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 +class BaseScanData(BaseModel): + """Data about a scan not including workflow specific 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 + For including workflow specific data see also: - # 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) + * ActiveScanData which subclasses this including the BaseModel used by the + ScanWorkflow + * HistoricScanData which has the workflow specific data loaded as a dictionary. - data["workflow"] = "Legacy" - data["workflow_settings"] = workflow_settings - return data - - -# This is a dictionary of a PyDantic model. It allows ScanData to hold arbitrary -# workflow settings models during a scan. -AnyModelOrDict = Annotated[ - dict | BaseModel, - PlainSerializer(lambda v: v.model_dump(), return_type=dict), -] - - -class ScanData(BaseModel): - """Data about a scan to be saved to a JSON file in the directory. + Sepearating historic and active data allows workflows to use any BaseModel for its + settings, but for the data to be reloaded even if that model has updated or is not + available. Historic scan data loaded from disk is used for stitching and for + creating a ScanInfo object for communicating with the UI. These uses are clearly + typed by this model. This serialises into a human readable format where possible with @@ -153,31 +116,14 @@ 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: AnyModelOrDict - """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. - - :param result: A string describing the result. - """ - 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) + workflow: str + """The class name of the workflow Thing.""" @model_validator(mode="after") def validate_schema_version(self) -> Self: @@ -238,6 +184,53 @@ class ScanData(BaseModel): return "Unknown" if value is None else value +class HistoricScanData(BaseScanData): + workflow_settings: dict + """A dictionary of the settings for the workflow that was used workflow.""" + + @model_validator(mode="before") + @classmethod + def coerce_legacy(cls, data: dict) -> dict: + """Coerce any legacy data.""" + r"""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 ScanDirectoryManager: """A class for managing interactions with scan directories.""" @@ -565,10 +558,10 @@ class ScanDirectory: except (json.decoder.JSONDecodeError, IOError): return None - def get_scan_data(self) -> Optional[ScanData]: - """Return the scan data from the json file as a ScanData model. + def get_scan_data(self) -> Optional[HistoricScanData]: + """Return the scan data from the json file as a HistoricScanData model. - :return: The data as a ScanData model or None if it couldn't be loaded or + :return: The data as a HistoricScanData model or None if it couldn't be loaded or valdiated. """ data_dict = self.get_scan_data_dict() @@ -576,7 +569,7 @@ class ScanDirectory: LOGGER.warning(f"Could not load scan data for {self.name}.") return None try: - return ScanData(**data_dict) + return HistoricScanData(**data_dict) except ValidationError: LOGGER.warning(f"Could not validate scan data for {self.name}.") return None @@ -627,7 +620,7 @@ class ScanDirectory: files.append(os.path.relpath(full_path, self.dir_path)) return files - def save_scan_data(self, scan_data: ScanData) -> None: + def save_scan_data(self, scan_data: BaseScanData) -> None: """Save the scan data for this scan to disk.""" if self.scan_data_path is None: raise FileNotFoundError( diff --git a/src/openflexure_microscope_server/stitching.py b/src/openflexure_microscope_server/stitching.py index e505971f..88e06df6 100644 --- a/src/openflexure_microscope_server/stitching.py +++ b/src/openflexure_microscope_server/stitching.py @@ -218,9 +218,11 @@ class FinalStitcher(BaseStitcher): if not known enter None. A value will be chosen from the scan_data_dict, or set to a default value if no value is available in the scan_data. :param stitch_tiff: Whether to stitch a pyramidal TIFF. - :param scan_data_dict: The ScanData for this scan as a dictionary. This is used - to read/calculate overlap and correlation_resize if they are not provided. + :param scan_data_dict: The HistoricScanData for this scan as a dictionary. This + is used to read/calculate overlap and correlation_resize if they are not + provided. """ + # TODO ^fix the above historic data self.logger = logger overlap, correlation_resize = self._process_inputs( overlap=overlap, diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index d06ba985..feb81653 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -1,4 +1,11 @@ -from typing import Mapping, Optional +from __future__ import annotations + +from typing import ( + Generic, + Mapping, + Optional, + TypeVar, +) from pydantic import BaseModel @@ -16,16 +23,21 @@ from openflexure_microscope_server.things.autofocus import ( from openflexure_microscope_server.things.background_detect import ( ChannelDeviationLUV, ) +from openflexure_microscope_server.things.camera import BaseCamera from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper +SettingModelType = TypeVar("SettingModelType", bound=BaseModel) -class ScanWorkflow(lt.Thing): + +class ScanWorkflow(Generic[SettingModelType], lt.Thing): """A base class for all Scanworkflows. Scan workflows set the behaviour of a scan, inclduing the background detection, scan planning, aquisition routine. """ + _settings_model: type[SettingModelType] + # All workdlows must have a set class for scan planning _planner_cls: type[ScanPlanner] @@ -50,11 +62,13 @@ class ScanWorkflow(lt.Thing): "Each specific ScanWorkflow must implement a ready property." ) - # TODO should be a model - def all_settings(self, images_dir: str) -> tuple[dict, Optional[StitchingData]]: + def all_settings( + self, images_dir: str + ) -> tuple[SettingModelType, Optional[StitchingData]]: """Return the scan settings and the stitching settings. - - The specific settings for this scan workflow are returned as a dict. + - The specific settings for this scan workflow are returned as a Base Model of + the type set when defining the class. - Stitiching settings are returned either as a StitchingData object or None is returned if it is not possible to stitch the scan. """ @@ -62,18 +76,20 @@ class ScanWorkflow(lt.Thing): "Each specific ScanWorkflow must implement a `all_settings`. method." ) - def pre_scan_routine(self, settings: dict) -> None: + def pre_scan_routine(self, settings: SettingModelType) -> None: raise NotImplementedError( "Each specific ScanWorkflow must implement a pre-scan routine." ) - def new_scan_planner(self, settings: dict, position: Mapping[str, int]) -> None: + def new_scan_planner( + self, settings: SettingModelType, position: Mapping[str, int] + ) -> ScanPlanner: raise NotImplementedError( "Each specific ScanWorkflow must implement a ``new_scan_planner`` method." ) def aquisition_routine( - self, settings: dict, xyz_pos: tuple[int, int, int] + self, settings: SettingModelType, xyz_pos: tuple[int, int, int] ) -> tuple[bool, Optional[int]]: raise NotImplementedError( "Each specific ScanWorkflow must implement an aquisition routine" @@ -83,8 +99,8 @@ class ScanWorkflow(lt.Thing): class HistoScanSettingsModel(BaseModel): """The settings including needed for running a HistoScan. - This includes settings caluclated when starting. This will be serialised in - ScanData. + This includes settings caluclated when starting. This will be held by smart scan + during a scan and serialised to disk. """ overlap: float @@ -92,14 +108,16 @@ class HistoScanSettingsModel(BaseModel): dx: int dy: int skip_background: bool - smart_stack_prarams: SmartStackParams + smart_stack_params: SmartStackParams -class HistoScanWorkflow(ScanWorkflow): +class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): + _settings_model = HistoScanSettingsModel + _planner_cls: type[ScanPlanner] = SmartSpiral # Thing Slots _background_detector: ChannelDeviationLUV = lt.thing_slot() + _cam: BaseCamera = lt.thing_slot() _csm: CameraStageMapper = lt.thing_slot() - _planner_cls: type[ScanPlanner] = SmartSpiral _autofocus: AutofocusThing = lt.thing_slot() # Scan settings @@ -147,7 +165,8 @@ class HistoScanWorkflow(ScanWorkflow): * 200 for 20x """ - # noqa, scan_name is unused but is needed for equivalence with other workflows. + # The noqa statment is because scan_name is unused but is needed for equivalence + # with other workflows that may want to validate the scan name. def check_before_start(self, scan_name: str) -> None: # noqa: ARG002 """Before starting a scan, check that background and camera-stage-mapping are set. @@ -191,12 +210,13 @@ class HistoScanWorkflow(ScanWorkflow): correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0], ) - dx, dy = self._calc_displacement_from_test_image(self.overlap) + dx, dy = self._calc_displacement_from_overlap(self.overlap) self.logger.info( f"Based on an overlap of {self.overlap}, the stage will make steps of " f"{dx}, {dy}" ) + # TODO: set a min on the property autofocus_dz = self.autofocus_dz if autofocus_dz == 0: self.logger.info("Running scan without autofocus") @@ -207,7 +227,7 @@ class HistoScanWorkflow(ScanWorkflow): ) autofocus_dz = 0 - smart_stack_prarams = self.create_smart_stack_params( + smart_stack_params = self.create_smart_stack_params( images_dir=images_dir, autofocus_dz=autofocus_dz, save_resolution=self.save_resolution, @@ -219,9 +239,8 @@ class HistoScanWorkflow(ScanWorkflow): dx=dx, dy=dy, skip_background=self.skip_background, - smart_stack_prarams=smart_stack_prarams, + smart_stack_params=smart_stack_params, ) - autofocus_dz = (autofocus_dz,) return scan_settings, stitching_settings @@ -233,7 +252,9 @@ class HistoScanWorkflow(ScanWorkflow): :returns: (dx, dy) - the x and y displacements in steps """ - csm_image_res = [int(i) for i in self._csm.image_resolution] + csm_image_res = self._csm.image_resolution + if csm_image_res is None: + raise RuntimeError("CSM not set. Scan shouldn't have progresses this far.") # Calculate displacements in image coordinates dx_img = csm_image_res[1] * (1 - overlap) @@ -320,11 +341,13 @@ class HistoScanWorkflow(ScanWorkflow): ) def pre_scan_routine(self, settings: HistoScanSettingsModel) -> None: - self._autofocus.looping_autofocus(dz=settings.autofocus_dz, start="centre") + self._autofocus.looping_autofocus( + dz=settings.smart_stack_params.autofocus_dz, start="centre" + ) def new_scan_planner( self, settings: HistoScanSettingsModel, position: Mapping[str, int] - ) -> None: + ) -> ScanPlanner: # The initial plan for the scan should be a single x,y position. All future # moves will be planned around this point. In future, route planner could # have multiple starting positions, each of which will be visited before the @@ -349,7 +372,11 @@ class HistoScanWorkflow(ScanWorkflow): """ # If skipping background, take an image to check if current field of view is background if settings.skip_background: - capture_image, bg_message = self._cam.image_is_sample() + image_array = self._cam.grab_as_array(stream_name="lores") + capture_image, bg_message = self._background_detector.image_is_sample( + image_array + ) + del image_array if not capture_image: msg = f"Skipping {xyz_pos} as it is {bg_message}." @@ -358,7 +385,8 @@ class HistoScanWorkflow(ScanWorkflow): save_on_failure = settings.skip_background - focused, focused_height = self._autofocus.run_smart_stack( + focus_height: Optional[int] + focused, focus_height = self._autofocus.run_smart_stack( stack_parameters=settings.smart_stack_params, save_on_failure=save_on_failure, ) @@ -368,6 +396,6 @@ class HistoScanWorkflow(ScanWorkflow): # run_smart_stage always returns a focus height for the sharpest image even # if it failed to find a good focus. Set to None if not focussed. if not focused: - focused_height = None + focus_height = None - return imaged, focused_height + return imaged, focus_height diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 4207783a..97a56e36 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -13,6 +13,7 @@ import time from datetime import datetime from subprocess import SubprocessError from typing import ( + Annotated, Any, Callable, Concatenate, @@ -24,16 +25,14 @@ from typing import ( from fastapi import HTTPException from fastapi.responses import FileResponse -from pydantic import BaseModel +from pydantic import BaseModel, PlainSerializer import labthings_fastapi as lt from openflexure_microscope_server import scan_directories, stitching # Things -from .autofocus import AutofocusThing, StackParams from .camera import BaseCamera -from .camera_stage_mapping import CameraStageMapper from .scan_workflows import HistoScanWorkflow, ScanWorkflow from .stage import BaseStage @@ -41,6 +40,26 @@ T = TypeVar("T") P = ParamSpec("P") +# This allows ActiveScanData to hold arbitrary workflow settings models during a scan. +AnyModel = Annotated[ + BaseModel, + PlainSerializer(lambda value: value.model_dump(), return_type=dict), +] + + +class ActiveScanData(scan_directories.BaseScanData): + workflow_settings: AnyModel + """The settings for the ongoing workflow.""" + + def set_final_data(self, result: str) -> None: + """Set the final data for the scan, scan duration is automatically calculated. + + :param result: A string describing the result. + """ + self.duration = datetime.now() - self.start_time + self.scan_result = result + + class ScanListInfo(BaseModel): """The information to be sent to the Scan List tab.""" @@ -99,9 +118,7 @@ class SmartScanThing(lt.Thing): past scans. """ - _autofocus: AutofocusThing = lt.thing_slot() _cam: BaseCamera = lt.thing_slot() - _csm: CameraStageMapper = lt.thing_slot() _stage: BaseStage = lt.thing_slot() _workflow: HistoScanWorkflow = lt.thing_slot() @@ -118,19 +135,6 @@ class SmartScanThing(lt.Thing): self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder) self._scan_lock = threading.Lock() - # Variables set by the scan - _stack_params: Optional[StackParams] = None - - @property - def stack_params(self) -> StackParams: - """The parameters for z-stacking during the onging scan. - - Only read this property is a scan is ongoing or it will raise an error. - """ - if self._stack_params is None: - raise RuntimeError("Cannot get stack parameters as they are not set.") - return self._stack_params - _ongoing_scan: Optional[scan_directories.ScanDirectory] = None @property @@ -143,11 +147,11 @@ class SmartScanThing(lt.Thing): raise ScanNotRunningError("Cannot get ongoing scan if scan is not running.") return self._ongoing_scan - _scan_data: Optional[scan_directories.ScanData] = None + _scan_data: Optional[ActiveScanData] = None @property - def scan_data(self) -> scan_directories.ScanData: - """The ScanData object jolding information about the of the ongoing scan. + def scan_data(self) -> ActiveScanData: + """The ActiveScanData object holding information about the of the ongoing scan. Only read this property is a scan is ongoing or it will raise an error. """ @@ -157,18 +161,6 @@ class SmartScanThing(lt.Thing): _preview_stitcher: Optional[stitching.PreviewStitcher] = None - @property - def preview_stitcher(self) -> stitching.PreviewStitcher: - """The PreviewStitcher object for stitching live previews. - - Only read this property is a scan is ongoing or it will raise an error. - """ - if self._preview_stitcher is None: - raise ScanNotRunningError( - "No preview stitcher agailable as scan is not running." - ) - return self._preview_stitcher - _latest_scan_name: Optional[str] = None @lt.property @@ -218,7 +210,6 @@ class SmartScanThing(lt.Thing): self._scan_lock.release() # Ensure any PreviewStitcher created cannot be reused. self._preview_stitcher = None - self._stack_params = None # Remove any scan folders containing zero images. self.purge_empty_scans() @@ -248,7 +239,7 @@ class SmartScanThing(lt.Thing): return (next_point[0], next_point[1], z_estimate) @_scan_running - def _collect_scan_data(self, workflow: ScanWorkflow) -> scan_directories.ScanData: + def _collect_scan_data(self, workflow: ScanWorkflow) -> ActiveScanData: """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 @@ -266,7 +257,7 @@ class SmartScanThing(lt.Thing): 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( + return ActiveScanData( scan_name=self.ongoing_scan.name, starting_position=starting_position, start_time=datetime.now(), @@ -290,13 +281,13 @@ class SmartScanThing(lt.Thing): @_scan_running def _manage_stitching_threads(self) -> None: """Manage the stitching threads, starting them if needed and not already running.""" - if self.scan_data.stitching_settings is None: + if self._preview_stitcher is None: # This scan can't stitch. return # Assume 4 images means at least one offset in x and y, making the stitching # well constrained. - if self.scan_data.image_count > 3 and not self.preview_stitcher.running: - self.preview_stitcher.start() + if self.scan_data.image_count > 3 and not self._preview_stitcher.running: + self._preview_stitcher.start() @_scan_running def _run_scan(self, workflow: ScanWorkflow) -> None: @@ -309,7 +300,8 @@ class SmartScanThing(lt.Thing): try: self._cam.start_streaming(main_resolution=(3280, 2464)) self._scan_data = self._collect_scan_data(workflow) - workflow.pre_scan_routine(self._scan_data) + + workflow.pre_scan_routine(self._scan_data.workflow_settings) self.ongoing_scan.save_scan_data(self._scan_data) images_dir = self.ongoing_scan.images_dir # Type narrowing @@ -317,11 +309,16 @@ class SmartScanThing(lt.Thing): raise RuntimeError( "Couldn't run scan, images directory was not created." ) - self._preview_stitcher = stitching.PreviewStitcher( - images_dir, - overlap=self.scan_data.overlap, - correlation_resize=self.scan_data.correlation_resize, - ) + + # If stitching settings are None then this type of scan can't be stitched + if self.scan_data.stitching_settings is not None: + # Settings exist, so create preview stitcher + stitching_settings = self.scan_data.stitching_settings + self._preview_stitcher = stitching.PreviewStitcher( + images_dir, + overlap=stitching_settings.overlap, + correlation_resize=stitching_settings.correlation_resize, + ) # This is the main loop of the scan! self._main_scan_loop(workflow) @@ -386,6 +383,7 @@ class SmartScanThing(lt.Thing): if focus_height is None: focused = False else: + focused = True current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focus_height) route_planner.mark_location_visited( @@ -426,17 +424,18 @@ class SmartScanThing(lt.Thing): self.logger.info("Waiting for background processes to finish...") - # Actually check the sticher exists rather than using self.preview_sticher as + # Check the sticher exists rather than using self.preview_sticher as # this method can be called during exception handling. if self._preview_stitcher is not None: self._preview_stitcher.wait() - if self.scan_data.stitch_automatically: + stitching_settings = self.scan_data.stitching_settings + if self.scan_data.stitch_automatically and stitching_settings is not None: self.logger.info("Stitching final image (may take some time)...") self.stitch_scan( scan_name=self.ongoing_scan.name, - correlation_resize=self.scan_data.correlation_resize, - overlap=self.scan_data.overlap, + correlation_resize=stitching_settings.correlation_resize, + overlap=stitching_settings.overlap, ) @lt.endpoint( From c938560f00dd053e6d1c5b5b4648d45ee60a654f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 15 Jan 2026 21:59:53 +0000 Subject: [PATCH 06/27] Docstrings and final tweaks of ScanWorflow refactor --- .../scan_directories.py | 36 ++++---- .../stitching.py | 88 ++++--------------- .../things/scan_workflows.py | 75 +++++++++++----- .../things/smart_scan.py | 34 +++---- 4 files changed, 103 insertions(+), 130 deletions(-) diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index 86ab0455..1a2d25ff 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -19,6 +19,7 @@ from pydantic import ( model_validator, ) +from openflexure_microscope_server.stitching import StitchingSettings from openflexure_microscope_server.utilities import make_name_safe, requires_lock LOGGER = logging.getLogger(__name__) @@ -49,16 +50,6 @@ 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.""" - - class BaseScanData(BaseModel): """Data about a scan not including workflow specific data. @@ -116,7 +107,7 @@ class BaseScanData(BaseModel): This should be set with ``set_final_data()`` to ensure duration is set. """ - stitching_settings: Optional[StitchingData] + stitching_settings: Optional[StitchingSettings] """The data needed to stitch a scan. Set to None for types of scan that cannot be stitched. @@ -185,6 +176,13 @@ class BaseScanData(BaseModel): class HistoricScanData(BaseScanData): + """A Model for the ScanData that has been loaded from disk. + + Any workflow specific settings are loaded as an arbitrary dictionary. Other + settings such as those which are needed for the UI or stitching are loaded and + validated by the parent class ``BaseScanData``. + """ + workflow_settings: dict """A dictionary of the settings for the workflow that was used workflow.""" @@ -204,7 +202,7 @@ class HistoricScanData(BaseScanData): # 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( + data["stitching_settings"] = StitchingSettings( correlation_resize=correlation_resize, overlap=overlap, ) @@ -320,13 +318,9 @@ class ScanDirectoryManager: return None return scan_data_path - def get_scan_data_dict(self, scan_name: str) -> Optional[dict[str, Any]]: - """Return the scan data read from a JSON file as a dict. - - This is a dictionary not a base model as the data format has changed - somewhat over time. - """ - return ScanDirectory(scan_name, self.base_dir).get_scan_data_dict() + def get_scan_data(self, scan_name: str) -> Optional[HistoricScanData]: + """Return the scan data read from a JSON file as a dict.""" + return ScanDirectory(scan_name, self.base_dir).get_scan_data() @property @requires_lock @@ -542,7 +536,7 @@ class ScanDirectory: """Return the modified time of the directory.""" return max(os.stat(root).st_mtime for root, _, _ in os.walk(self.dir_path)) - def get_scan_data_dict(self) -> Optional[dict[str, Any]]: + def _get_scan_data_dict(self) -> Optional[dict[str, Any]]: """Return the scan data from the json file as a dictionary. This is safer than get_scan_data for older scans before a defined model was @@ -564,7 +558,7 @@ class ScanDirectory: :return: The data as a HistoricScanData model or None if it couldn't be loaded or valdiated. """ - data_dict = self.get_scan_data_dict() + data_dict = self._get_scan_data_dict() if data_dict is None: LOGGER.warning(f"Could not load scan data for {self.name}.") return None diff --git a/src/openflexure_microscope_server/stitching.py b/src/openflexure_microscope_server/stitching.py index 88e06df6..2d68736b 100644 --- a/src/openflexure_microscope_server/stitching.py +++ b/src/openflexure_microscope_server/stitching.py @@ -12,7 +12,9 @@ import shlex import signal import subprocess import threading -from typing import IO, Any, Optional +from typing import IO, Optional + +from pydantic import BaseModel import labthings_fastapi as lt @@ -28,6 +30,16 @@ DEFAULT_OVERLAP = 0.1 DEFAULT_RESIZE = 0.5 +class StitchingSettings(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.""" + + class ExternalSigkillError(ChildProcessError): """Exception called when stitch is killed by an external process calling Sigkill.""" @@ -200,10 +212,8 @@ class FinalStitcher(BaseStitcher): images_dir: str, *, logger: logging.Logger, - overlap: Optional[float] = None, - correlation_resize: Optional[float] = None, + stitching_settings: StitchingSettings, stitch_tiff: bool = False, - scan_data_dict: Optional[dict[str, Any]] = None, ) -> None: """Initialise a final stitcher, this has more args than the base class. @@ -211,24 +221,13 @@ class FinalStitcher(BaseStitcher): :param images_dir: The images directory of the scan to stitch. :param logger: The logger from the Thing that created this stitcher. - :param overlap: The scan overlap, if not known enter None. A value will be - chosen from the scan_data_dict, or set to a default value if no value is - available in the scan_data. - :param correlation_resize: The fraction to resize images by when correlating, - if not known enter None. A value will be chosen from the scan_data_dict, or - set to a default value if no value is available in the scan_data. + :param stitching_settings: A StitchingSettings model this can be loaded from a + HistoricScanData for this scan as a dictionary. :param stitch_tiff: Whether to stitch a pyramidal TIFF. - :param scan_data_dict: The HistoricScanData for this scan as a dictionary. This - is used to read/calculate overlap and correlation_resize if they are not - provided. """ - # TODO ^fix the above historic data self.logger = logger - overlap, correlation_resize = self._process_inputs( - overlap=overlap, - correlation_resize=correlation_resize, - scan_data_dict=scan_data_dict, - ) + overlap = stitching_settings.overlap + correlation_resize = stitching_settings.correlation_resize super().__init__( images_dir, overlap=overlap, correlation_resize=correlation_resize ) @@ -242,57 +241,6 @@ class FinalStitcher(BaseStitcher): str(STITCH_TILE_SIZE), ] - def _process_inputs( - self, - overlap: Optional[float], - correlation_resize: Optional[float], - scan_data_dict: Optional[dict[str, Any]], - ) -> tuple[float, float]: - """Process inputs to ensure ``overlap`` and ``correlation_resize`` have values. - - First the scan_data_dict is inspected for values to allow ``overlap`` and - ``correlation_resize`` to be set correctly, if these values are not available - then default values are used, and a warning is logged to the thing logger. - - :param overlap: overlap as input to __init__ - :param correlation_resize: correlation_resize as input to __init__ - :param scan_data_dict: scan_data_dict as input to __init__ - - :returns: overlap and correlation_resize as floats. - """ - if overlap is None: - if scan_data_dict is not None and "overlap" in scan_data_dict: - overlap = scan_data_dict["overlap"] - - # Warn if still None and set to default. - if overlap is None: - overlap = DEFAULT_OVERLAP - self.logger.warning( - "No value set for overlap. Attempting stitch with overlap " - f"value of {DEFAULT_OVERLAP}" - ) - - if correlation_resize is None: - if scan_data_dict is not None: - # Handle "capture resolution" being used to store the save resolution - # in old scans. - key = ( - "capture resolution" - if "capture resolution" in scan_data_dict - else "save_resolution" - ) - if key in scan_data_dict: - save_resolution = scan_data_dict[key] - correlation_resize = STITCHING_RESOLUTION[0] / save_resolution[0] - # Warn if still None and set to default. - if correlation_resize is None: - correlation_resize = DEFAULT_RESIZE - self.logger.warning( - "No information available to calculate stitch resize. Attempting " - f"stitch with resize value of {DEFAULT_RESIZE}" - ) - return overlap, correlation_resize - def run(self) -> None: """Run the final stitch logging any output. diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index feb81653..17e03147 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -1,3 +1,9 @@ +"""Scan workflows set different ways that smart scan can behave. + +This module contains the base ``ScanWorkflow`` class that all workflows should subclass, +as well as specific workflows. +""" + from __future__ import annotations from typing import ( @@ -11,9 +17,11 @@ from pydantic import BaseModel 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.stitching import ( + STITCHING_RESOLUTION, + StitchingSettings, +) from openflexure_microscope_server.things.autofocus import ( MAX_TEST_IMAGE_COUNT, MIN_TEST_IMAGE_COUNT, @@ -64,12 +72,12 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing): def all_settings( self, images_dir: str - ) -> tuple[SettingModelType, Optional[StitchingData]]: + ) -> tuple[SettingModelType, Optional[StitchingSettings]]: """Return the scan settings and the stitching settings. - The specific settings for this scan workflow are returned as a Base Model of the type set when defining the class. - - Stitiching settings are returned either as a StitchingData object or None + - Stitiching settings are returned either as a StitchingSettings object or None is returned if it is not possible to stitch the scan. """ raise NotImplementedError( @@ -77,6 +85,7 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing): ) def pre_scan_routine(self, settings: SettingModelType) -> None: + """Overload to set the routine that happens before each scan.""" raise NotImplementedError( "Each specific ScanWorkflow must implement a pre-scan routine." ) @@ -84,6 +93,7 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing): def new_scan_planner( self, settings: SettingModelType, position: Mapping[str, int] ) -> ScanPlanner: + """Return the a new scan planner object for a scan.""" raise NotImplementedError( "Each specific ScanWorkflow must implement a ``new_scan_planner`` method." ) @@ -91,13 +101,14 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing): def aquisition_routine( self, settings: SettingModelType, xyz_pos: tuple[int, int, int] ) -> tuple[bool, Optional[int]]: + """Overload to set the aquisition routine that happens at each scan site.""" raise NotImplementedError( "Each specific ScanWorkflow must implement an aquisition routine" ) class HistoScanSettingsModel(BaseModel): - """The settings including needed for running a HistoScan. + """The settings for a scan with the HistoScanWorkflow. This includes settings caluclated when starting. This will be held by smart scan during a scan and serialised to disk. @@ -112,6 +123,12 @@ class HistoScanSettingsModel(BaseModel): class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): + """A workflow optimised for scanning Histopathology samples. + + This workflow automatically plans its own path around a sample spiralling out from + the centre position, scanning only where it detects sample. + """ + _settings_model = HistoScanSettingsModel _planner_cls: type[ScanPlanner] = SmartSpiral # Thing Slots @@ -128,14 +145,20 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): This uses the settings from the ``BackgroundDetectThing``. """ - autofocus_dz: int = lt.setting(default=1000) - """The z distance to perform an autofocus in steps.""" + autofocus_dz: int = lt.setting(default=1000, ge=200, le=2000) + """The z distance to perform an autofocus in steps. + + Must be greater than or equal to 200, and less than or equal to 2000. + """ 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.""" + overlap: float = lt.setting(default=0.45, ge=0.1, le=0.7) + """The fraction that adjacent images should overlap in x or y. + + This must be between 0.1 and 0.7. + """ # Stacking settings @@ -204,8 +227,14 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): def all_settings( self, images_dir: str - ) -> tuple[HistoScanSettingsModel, StitchingData]: - stitching_settings = StitchingData( + ) -> tuple[HistoScanSettingsModel, StitchingSettings]: + """Return the workflow and stitching settings. + + :param images_dir: The directory that images are to be written to. + :return: A tuple containing the settings model for this workflow and the + settings model for stitching. + """ + stitching_settings = StitchingSettings( overlap=self.overlap, correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0], ) @@ -216,20 +245,9 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): f"{dx}, {dy}" ) - # TODO: set a min on the property - 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 - smart_stack_params = self.create_smart_stack_params( images_dir=images_dir, - autofocus_dz=autofocus_dz, + autofocus_dz=self.autofocus_dz, save_resolution=self.save_resolution, ) @@ -341,6 +359,10 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): ) def pre_scan_routine(self, settings: HistoScanSettingsModel) -> None: + """Autofocus before starting the scan. + + :param settings: The settings for this scan as a HistoScanSettingsModel + """ self._autofocus.looping_autofocus( dz=settings.smart_stack_params.autofocus_dz, start="centre" ) @@ -348,6 +370,11 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): def new_scan_planner( self, settings: HistoScanSettingsModel, position: Mapping[str, int] ) -> ScanPlanner: + """Return a new scan planner object. + + :param settings: The settings for this scan as a HistoScanSettingsModel + :param position: The starting position as a mapping of axes names to int. + """ # The initial plan for the scan should be a single x,y position. All future # moves will be planned around this point. In future, route planner could # have multiple starting positions, each of which will be visited before the @@ -367,6 +394,8 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): ) -> tuple[bool, Optional[int]]: """Perform aquisition routine. This is run at each scan location. + :param settings: The settings for this scan as a HistoScanSettingsModel + :param xyz_position: The current position as a tuple or 3 ints. :return: A tuple of whether an image was taken, and the z-position for focus. If failed to find focus, returns for the focus z-position. """ diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 97a56e36..da23f422 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -48,6 +48,13 @@ AnyModel = Annotated[ class ActiveScanData(scan_directories.BaseScanData): + """A Model for the ScanData during an ongoing scan. + + This differs from HistoricScanData as in this model ``workflow_settings`` are the + model specified for the current ScanWorkflow. HistoricScanData loads + ``workflow_settings`` into a dictionary. + """ + workflow_settings: AnyModel """The settings for the ongoing workflow.""" @@ -432,11 +439,7 @@ class SmartScanThing(lt.Thing): stitching_settings = self.scan_data.stitching_settings if self.scan_data.stitch_automatically and stitching_settings is not None: self.logger.info("Stitching final image (may take some time)...") - self.stitch_scan( - scan_name=self.ongoing_scan.name, - correlation_resize=stitching_settings.correlation_resize, - overlap=stitching_settings.overlap, - ) + self.stitch_scan(scan_name=self.ongoing_scan.name) @lt.endpoint( "get", @@ -624,25 +627,24 @@ class SmartScanThing(lt.Thing): return FileResponse(preview_path) @lt.action - def stitch_scan( - self, - scan_name: str, - correlation_resize: Optional[float] = None, - overlap: Optional[float] = None, - ) -> None: + def stitch_scan(self, scan_name: str) -> None: """Generate a stitched image based on stage position metadata.""" - scan_data_dict = self._scan_dir_manager.get_scan_data_dict(scan_name) - if scan_data_dict is None: + scan_data = self._scan_dir_manager.get_scan_data(scan_name) + if scan_data is None: self.logger.warning( "Couldn't read scan data - it may be missing or corrupt." ) + return + if scan_data.stitching_settings is None: + # If the stitching settings are none then this type of scan cannot be + # stitiched. + return + final_stitcher = stitching.FinalStitcher( self._scan_dir_manager.img_dir_for(scan_name), logger=self.logger, - overlap=overlap, - correlation_resize=correlation_resize, stitch_tiff=self.stitch_tiff, - scan_data_dict=scan_data_dict, + stitching_settings=scan_data.stitching_settings, ) try: final_stitcher.run() From 11ef1217e027f2eeac7b8ca839afe7f232756d2f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 15 Jan 2026 22:00:15 +0000 Subject: [PATCH 07/27] Docstrings and tweaks of ScanWorflow refactor --- .../scan_directories.py | 2 +- .../things/autofocus.py | 2 +- .../things/scan_workflows.py | 22 +++++++++---------- .../things/smart_scan.py | 6 ++--- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index 1a2d25ff..50acfe2f 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -59,7 +59,7 @@ class BaseScanData(BaseModel): ScanWorkflow * HistoricScanData which has the workflow specific data loaded as a dictionary. - Sepearating historic and active data allows workflows to use any BaseModel for its + Separating historic and active data allows workflows to use any BaseModel for its settings, but for the data to be reloaded even if that model has updated or is not available. Historic scan data loaded from disk is used for stitching and for creating a ScanInfo object for communicating with the UI. These uses are clearly diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 5a95b3de..76779aa9 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -567,7 +567,7 @@ class AutofocusThing(lt.Thing): """Capture a series of images checking that sharpest image central. This is part of run_smart_stack. This is the actual z_stackng stacking method - called by the action run_smart_stack. The action also handles reseting, + called by the action run_smart_stack. The action also handles resetting, autofocussing, and retrying. The images are separated in z offset by stack_parameters.stack_dz, as they diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index 17e03147..39548663 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -40,13 +40,13 @@ SettingModelType = TypeVar("SettingModelType", bound=BaseModel) class ScanWorkflow(Generic[SettingModelType], lt.Thing): """A base class for all Scanworkflows. - Scan workflows set the behaviour of a scan, inclduing the background detection, - scan planning, aquisition routine. + Scan workflows set the behaviour of a scan, including the background detection, + scan planning, acquisition routine. """ _settings_model: type[SettingModelType] - # All workdlows must have a set class for scan planning + # All workflows must have a set class for scan planning _planner_cls: type[ScanPlanner] # All workflows set a save resolution @@ -98,19 +98,19 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing): "Each specific ScanWorkflow must implement a ``new_scan_planner`` method." ) - def aquisition_routine( + def acquisition_routine( self, settings: SettingModelType, xyz_pos: tuple[int, int, int] ) -> tuple[bool, Optional[int]]: - """Overload to set the aquisition routine that happens at each scan site.""" + """Overload to set the acquisition routine that happens at each scan site.""" raise NotImplementedError( - "Each specific ScanWorkflow must implement an aquisition routine" + "Each specific ScanWorkflow must implement an acquisition routine" ) class HistoScanSettingsModel(BaseModel): """The settings for a scan with the HistoScanWorkflow. - This includes settings caluclated when starting. This will be held by smart scan + This includes settings calculated when starting. This will be held by smart scan during a scan and serialised to disk. """ @@ -188,7 +188,7 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): * 200 for 20x """ - # The noqa statment is because scan_name is unused but is needed for equivalence + # The noqa statement is because scan_name is unused but is needed for equivalence # with other workflows that may want to validate the scan name. def check_before_start(self, scan_name: str) -> None: # noqa: ARG002 """Before starting a scan, check that background and camera-stage-mapping are set. @@ -389,13 +389,13 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): planner_settings=planner_settings, ) - def aquisition_routine( + def acquisition_routine( self, settings: HistoScanSettingsModel, xyz_pos: tuple[int, int, int] ) -> tuple[bool, Optional[int]]: - """Perform aquisition routine. This is run at each scan location. + """Perform acquisition routine. This is run at each scan location. :param settings: The settings for this scan as a HistoScanSettingsModel - :param xyz_position: The current position as a tuple or 3 ints. + :param xyz_pos: The current position as a tuple or 3 ints. :return: A tuple of whether an image was taken, and the z-position for focus. If failed to find focus, returns for the focus z-position. """ diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index da23f422..3df18ad3 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -179,8 +179,8 @@ class SmartScanThing(lt.Thing): def sample_scan(self, scan_name: str = "") -> None: """Move the stage to cover an area, taking images. - 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 + Depending on the way the stage moves depends on the selected workflow. + If images overlap for a scan workflow then the images can be stitched together into a larger composite image. """ got_lock = self._scan_lock.acquire(timeout=0.1) @@ -383,7 +383,7 @@ class SmartScanThing(lt.Thing): new_pos_xyz[1], self._stage.position["z"], ) - imaged, focus_height = workflow.aquisition_routine( + imaged, focus_height = workflow.acquisition_routine( workflow_settings, current_pos_xyz ) From c82523dd5b2ba3cad6c92f5b6fc45645e5bab5fd Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 15 Jan 2026 22:59:58 +0000 Subject: [PATCH 08/27] Start updating unit tests after refactoring into ScanWorkflows --- .../scan_directories.py | 2 +- .../things/smart_scan.py | 2 +- tests/unit_tests/test_scan_data.py | 100 ++++++++++++++---- tests/unit_tests/test_scan_directories.py | 53 ++++------ tests/unit_tests/test_smart_scan.py | 18 ++-- tests/unit_tests/test_stack.py | 16 +-- 6 files changed, 119 insertions(+), 72 deletions(-) diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index 50acfe2f..a31d24ce 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -176,7 +176,7 @@ class BaseScanData(BaseModel): class HistoricScanData(BaseScanData): - """A Model for the ScanData that has been loaded from disk. + """A Model for the scan data that has been loaded from disk. Any workflow specific settings are loaded as an arbitrary dictionary. Other settings such as those which are needed for the UI or stitching are loaded and diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 3df18ad3..9768ef29 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -48,7 +48,7 @@ AnyModel = Annotated[ class ActiveScanData(scan_directories.BaseScanData): - """A Model for the ScanData during an ongoing scan. + """A model for the scan data during an ongoing scan. This differs from HistoricScanData as in this model ``workflow_settings`` are the model specified for the current ScanWorkflow. HistoricScanData loads diff --git a/tests/unit_tests/test_scan_data.py b/tests/unit_tests/test_scan_data.py index d09f1594..cc29935b 100644 --- a/tests/unit_tests/test_scan_data.py +++ b/tests/unit_tests/test_scan_data.py @@ -5,7 +5,11 @@ from copy import copy from datetime import datetime, timedelta from math import floor -from openflexure_microscope_server.scan_directories import ScanData +from pydantic import BaseModel + +from openflexure_microscope_server.scan_directories import HistoricScanData +from openflexure_microscope_server.stitching import StitchingSettings +from openflexure_microscope_server.things.smart_scan import ActiveScanData MOCK_START_TIME = datetime( year=2024, @@ -28,8 +32,8 @@ MOCK_END_TIME = datetime( ) -def _fake_legacy_scan_data(**kwargs) -> ScanData: - """Make fake legacy scan data, the start time is now. Final properties are not added. +def _fake_legacy_scan_data(**kwargs) -> HistoricScanData: + """Make fake legacy scan data. :param **kwargs: Key word arguments can be used to override other values. """ @@ -50,12 +54,73 @@ def _fake_legacy_scan_data(**kwargs) -> ScanData: } for key, value in kwargs.items(): data_dict[key] = value - return ScanData(**data_dict) + return HistoricScanData(**data_dict) + + +class MockWorkflowSettingModel(BaseModel): + """A mock model to check that ActiveScanData can hold arbitrary models.""" + + setting_1: int + setting_2: int + setting_3: str + + +def fake_active_scan_data(): + """Fake scan data for and active scan. + + The start time is now. Final properties are not added. + """ + return ActiveScanData( + schema_version=2, + scan_name="fake_scan_0001", + starting_position={"x": 123, "y": 456, "z": 789}, + start_time=copy(MOCK_START_TIME), + stitch_automatically=True, + save_resolution=(1000, 1000), + stitching_settings=StitchingSettings(correlation_resize=0.25, overlap=0.1), + workflow="MockWorkflow", + workflow_settings=MockWorkflowSettingModel( + setting_1=1, + setting_2=2, + setting_3="three", + ), + ) + + +def assert_active_and_historic_data_equivalent(active_data, historic_data): + """Raise and error if active and historic scan data is not equivalent.""" + assert isinstance(active_data, ActiveScanData) + assert isinstance(historic_data, HistoricScanData) + # For the round trip to be equal we must remove microseconds from the start + # time as they are not saved + active_data.start_time = active_data.start_time.replace(microsecond=0) + for key in active_data.model_fields: + if key == "workflow_settings": + # For workflow_settings check the base model serialises to the historic + # data. + active_wf_setting_dict = active_data.workflow_settings.model_dump() + assert historic_data.workflow_settings == active_wf_setting_dict + continue + + assert getattr(active_data, key) == getattr(historic_data, key) + + +def test_legacy_data_validates(): + """Check that legacy scan data validates.""" + scan_data = _fake_legacy_scan_data() + assert isinstance(scan_data, HistoricScanData) + assert scan_data.image_count == 0 + assert scan_data.duration is None + assert scan_data.scan_result is None + # Most importantly legacy stitching data should now be in the StitchingSettings + # model + assert scan_data.stitching_settings.correlation_resize == 0.25 + assert scan_data.stitching_settings.overlap == 0.1 def test_set_final_data(): - """Check that adding final data to a ScanData object works as expected.""" - scan_data = _fake_legacy_scan_data() + """Check that adding final data to a ActiveScanData object works as expected.""" + scan_data = fake_active_scan_data() assert scan_data.image_count == 0 assert scan_data.duration is None @@ -75,8 +140,8 @@ def test_set_final_data(): def test_custom_serialisation(): - """Check that the custom serialisation in ScanData works as expected.""" - scan_data = _fake_legacy_scan_data() + """Check that the custom serialisation in ActiveScanData works as expected.""" + scan_data = fake_active_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" @@ -95,26 +160,23 @@ def test_custom_serialisation(): def test_round_trip_not_finalised(): - """Check that ScanData without final data can be serialised and deserialised.""" - scan_data = _fake_legacy_scan_data() + """Check that ActiveScanData without final data can be serialised and deserialised.""" + scan_data = fake_active_scan_data() scan_data_dict = json.loads(scan_data.model_dump_json()) - scan_data_reloaded = ScanData(**scan_data_dict) + scan_data_reloaded = HistoricScanData(**scan_data_dict) - # For the round trip to be equal we must remove microseconds from the start - # time as they are not saved - scan_data.start_time = scan_data.start_time.replace(microsecond=0) - assert scan_data == scan_data_reloaded + assert_active_and_historic_data_equivalent(scan_data, scan_data_reloaded) def test_round_trip_finalised(): - """Check that finalised ScanData can be serialised and deserialised.""" - scan_data = _fake_legacy_scan_data() + """Check that finalised HistoricScanData can be serialised and deserialised.""" + scan_data = fake_active_scan_data() # Finalise the data. scan_data.image_count += 123 scan_data.set_final_data(result="Success") scan_data_dict = json.loads(scan_data.model_dump_json()) - scan_data_reloaded = ScanData(**scan_data_dict) + scan_data_reloaded = HistoricScanData(**scan_data_dict) # For the round trip to be equal we must remove microseconds from the start # time and duration as they are not saved @@ -122,4 +184,4 @@ def test_round_trip_finalised(): scan_data.start_time = scan_data.start_time.replace(microsecond=0) scan_data.duration = timedelta(seconds=floor(scan_data.duration.total_seconds())) - assert scan_data == scan_data_reloaded + assert_active_and_historic_data_equivalent(scan_data, scan_data_reloaded) diff --git a/tests/unit_tests/test_scan_directories.py b/tests/unit_tests/test_scan_directories.py index ff7af3eb..603587a3 100644 --- a/tests/unit_tests/test_scan_directories.py +++ b/tests/unit_tests/test_scan_directories.py @@ -21,7 +21,10 @@ from openflexure_microscope_server.scan_directories import ( get_files_in_zip, ) -from .test_scan_data import _fake_scan_data +from .test_scan_data import ( + assert_active_and_historic_data_equivalent, + fake_active_scan_data, +) from .utilities import assert_unique_of_length # Use our own dir in the root temp dir not a dynamically generated one so we @@ -173,7 +176,7 @@ def test_scan_sequence_and_listing(caplog): scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) # Create some scan data and mark it as successful to get an end date. - scan_data = _fake_scan_data() + scan_data = fake_active_scan_data() scan_data.set_final_data(result="Success") # Make 4 scans scan_dir = scan_dir_manager.new_scan_dir("fake_scan") @@ -362,26 +365,33 @@ def test_get_scan_data_path(): assert scan_dir_manager.get_scan_data_path(scan_name) is None -def test_get_scan_data_dict(): - """Check that the dictionary for the scan data is returned, or None if doesn't exist.""" +def test_get_scan_data(): + """Check that the scan data is returned, or None if doesn't exist.""" _clear_scan_dir() scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) scan_dir = scan_dir_manager.new_scan_dir("fake_scan") scan_name = scan_dir.name # Doesn't yet exist - assert scan_dir_manager.get_scan_data_dict(scan_name) is None + assert scan_dir_manager.get_scan_data(scan_name) is None - fake_data = {"foo": 1, "bar": "foobar"} + fake_active_data = fake_active_scan_data() with open(scan_dir.scan_data_path, "w", encoding="utf-8") as json_file: - json.dump(fake_data, json_file) + json.dump(fake_active_data.model_dump(), json_file) # Should now be able to load this fake data from disk - assert scan_dir_manager.get_scan_data_dict(scan_name) == fake_data + fake_historic_data = scan_dir_manager.get_scan_data(scan_name) + assert_active_and_historic_data_equivalent(fake_active_data, fake_historic_data) # Check None is returned if the data cannot be read. with open(scan_dir.scan_data_path, "w", encoding="utf-8") as json_file: json_file.write("this is not json") - assert scan_dir_manager.get_scan_data_dict(scan_name) is None + assert scan_dir_manager.get_scan_data(scan_name) is None + + # Check None is returned if the data cannot or is json but cannot be serialised to + # the data model + with open(scan_dir.scan_data_path, "w", encoding="utf-8") as json_file: + json_file.write(json.dumps({"foo": "bar"})) + assert scan_dir_manager.get_scan_data(scan_name) is None def test_empty_scan_info(): @@ -442,29 +452,6 @@ def test_zipping_scan_data(): assert not file.endswith(".dzi") -def test_saving_and_loading_scan_data(): - """Test that scan data is saved and loaded as expected.""" - _clear_scan_dir() - scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) - scan_dir = scan_dir_manager.new_scan_dir("fake_scan") - scan_name = scan_dir.name - - # Should start without a scan data file. - assert not os.path.isfile(scan_dir.scan_data_path) - # Create - scan_data_obj = _fake_scan_data() - scan_dir.save_scan_data(scan_data_obj) - # File should now exist - assert os.path.isfile(scan_dir.scan_data_path) - - # Dump the scan json to a string an reload it - # Note that more detailed checking of the dumping and loading of ScanData is in - # tests/test_scan_data.py - scan_data_dict = json.loads(scan_data_obj.model_dump_json()) - # What is loaded from file should be the same as from dumping and loading. - assert scan_dir_manager.get_scan_data_dict(scan_name) == scan_data_dict - - def test_saving_scan_data_error(): """Test that saving scan data if there is no images directory raises FileNotFoundError.""" _clear_scan_dir() @@ -475,7 +462,7 @@ def test_saving_scan_data_error(): shutil.rmtree(scan_dir.images_dir) # Should raise FileNotFoundError. with pytest.raises(FileNotFoundError): - scan_dir.save_scan_data(_fake_scan_data()) + scan_dir.save_scan_data(fake_active_scan_data()) def test_all_files(): diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py index 4a59e47e..c35ef6f7 100644 --- a/tests/unit_tests/test_smart_scan.py +++ b/tests/unit_tests/test_smart_scan.py @@ -26,11 +26,9 @@ from fastapi import HTTPException from labthings_fastapi.exceptions import InvocationCancelledError from labthings_fastapi.testing import create_thing_without_server -from openflexure_microscope_server.scan_directories import ( - NotEnoughFreeSpaceError, - ScanData, -) +from openflexure_microscope_server.scan_directories import NotEnoughFreeSpaceError from openflexure_microscope_server.things.smart_scan import ( + ActiveScanData, ScanNotRunningError, SmartScanThing, ) @@ -224,7 +222,7 @@ MOCK_START_POS = {"x": 123, "y": 456, "z": 789} def _expected_scan_data(): - """Return the expected ScanData object for a SmartScan with default properties.""" + """Return the expected ActiveScanData object for a SmartScan with default properties.""" expected_dict = { "scan_name": MOCK_SCAN_NAME, "starting_position": MOCK_START_POS, @@ -239,7 +237,7 @@ def _expected_scan_data(): "correlation_resize": 0.5, "save_resolution": (1640, 1232), } - return ScanData(start_time=datetime.now(), **expected_dict) + return ActiveScanData(start_time=datetime.now(), **expected_dict) @pytest.fixture @@ -264,7 +262,7 @@ def scan_thing_mocked_for_scan_data(smart_scan_thing, mocker): def test_collect_scan_data(scan_thing_mocked_for_scan_data): - """Run _collect_scan_data, and check the ScanData object has the expected values.""" + """Run _collect_scan_data, and check the ActiveScanData object has the expected values.""" scan_thing = scan_thing_mocked_for_scan_data data = scan_thing._collect_scan_data() @@ -277,7 +275,7 @@ def test_collect_scan_data(scan_thing_mocked_for_scan_data): def test_save_final_scan_data(scan_thing_mocked_for_scan_data): - """Run _save_final_scan_data, check save is called with final results in ScanData.""" + """Run _save_final_scan_data, check save is called with final results in ActiveScanData.""" scan_thing = scan_thing_mocked_for_scan_data scan_thing._scan_data = scan_thing._collect_scan_data() @@ -287,7 +285,7 @@ def test_save_final_scan_data(scan_thing_mocked_for_scan_data): # the value scan_thing._ongoing_scan.save_scan_data.assert_called() final_data = scan_thing._ongoing_scan.save_scan_data.call_args[0][0] - assert isinstance(final_data, ScanData) + assert isinstance(final_data, ActiveScanData) assert final_data.scan_result == "Mocked!" assert final_data.image_count == 44 assert final_data.duration.total_seconds() < 1 @@ -341,7 +339,7 @@ def check_run_scan(scan_thing, caplog, expected_exception=None): def test_run_scan(scan_thing_mocked_for_run_scan, caplog): - """Run _save_final_scan_data, check save is called with final results in ScanData.""" + """Run _save_final_scan_data, check save is called with final results in ActiveScanData.""" result, logs, calls = check_run_scan(scan_thing_mocked_for_run_scan, caplog) assert result == "success" diff --git a/tests/unit_tests/test_stack.py b/tests/unit_tests/test_stack.py index 6683c719..bcc048b0 100644 --- a/tests/unit_tests/test_stack.py +++ b/tests/unit_tests/test_stack.py @@ -19,7 +19,7 @@ from openflexure_microscope_server.things.autofocus import ( AutofocusThing, CaptureInfo, NotAPeakError, - StackParams, + SmartStackParams, _count_turning_points, _get_capture_by_id, _get_capture_index_by_id, @@ -64,7 +64,7 @@ def test_stack_params_validation(save_ims, extra_ims): # Coerce min_images_to_test as the max extra ims depends on save_ims so is hard # to do automatically in hypothesis. This clamps the number between 3 and 9. min_images_to_test = max(min(save_ims + extra_ims, 9), 3) - StackParams( + SmartStackParams( stack_dz=50, images_to_save=save_ims, min_images_to_test=min_images_to_test, @@ -91,7 +91,7 @@ def test_stack_params_not_enough_test_images(save_ims, extra_ims): "Can't save more images than the minimum number tested)" ) with pytest.raises(ValueError, match=match): - StackParams( + SmartStackParams( stack_dz=50, images_to_save=save_ims, min_images_to_test=save_ims + extra_ims, @@ -116,7 +116,7 @@ def test_stack_params_negative_images_to_save(save_ims, extra_ims): "Images to save must be positive and odd)" ) with pytest.raises(ValueError, match=match): - StackParams( + SmartStackParams( stack_dz=50, images_to_save=save_ims, min_images_to_test=save_ims + extra_ims, @@ -142,7 +142,7 @@ def test_even_min_images_to_test(save_ims, extra_ims): "Minimum number of images to test should be positive and odd)" ) with pytest.raises(ValueError, match=match): - StackParams( + SmartStackParams( stack_dz=50, images_to_save=save_ims, min_images_to_test=save_ims + extra_ims, @@ -166,7 +166,7 @@ def test_even_images_to_save(save_ims, extra_ims): "Images to save must be positive and odd)" ) with pytest.raises(ValueError, match=match): - StackParams( + SmartStackParams( stack_dz=50, images_to_save=save_ims, min_images_to_test=save_ims + extra_ims, @@ -177,11 +177,11 @@ def test_even_images_to_save(save_ims, extra_ims): def test_computed_stack_params(): - """Test StackParams computed properties are as expected. + """Test SmartStackParams computed properties are as expected. Not using hypothesis or we will just copy in the same formulas. """ - stack_parameters = StackParams( + stack_parameters = SmartStackParams( stack_dz=50, images_to_save=5, min_images_to_test=9, From 2a3a54b936e26c7784e05b0860fbc86bab56103e Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 16 Jan 2026 00:08:33 +0000 Subject: [PATCH 09/27] Get unit tests passing with Scan Workflows --- .../stitching.py | 5 + .../things/autofocus.py | 2 +- tests/unit_tests/test_smart_scan.py | 47 ++++--- tests/unit_tests/test_stack.py | 104 +++++++++------ tests/unit_tests/test_stitching.py | 126 +++++++----------- 5 files changed, 149 insertions(+), 135 deletions(-) diff --git a/src/openflexure_microscope_server/stitching.py b/src/openflexure_microscope_server/stitching.py index 2d68736b..9a69f7c1 100644 --- a/src/openflexure_microscope_server/stitching.py +++ b/src/openflexure_microscope_server/stitching.py @@ -225,6 +225,11 @@ class FinalStitcher(BaseStitcher): HistoricScanData for this scan as a dictionary. :param stitch_tiff: Whether to stitch a pyramidal TIFF. """ + if not isinstance(stitching_settings, StitchingSettings): + raise StitcherValidationError( + "Final stitcher requires settings to be set as a StitchingSettings " + "model" + ) self.logger = logger overlap = stitching_settings.overlap correlation_resize = stitching_settings.correlation_resize diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 76779aa9..266d4bd1 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -146,7 +146,7 @@ class SmartStackParams(BaseModel): @dataclass class CaptureInfo: - """The information from a capture in a z_stack.""" + """The information from a capture in a smart_z_stack.""" buffer_id: int position: Mapping[str, int] diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py index c35ef6f7..21901f7c 100644 --- a/tests/unit_tests/test_smart_scan.py +++ b/tests/unit_tests/test_smart_scan.py @@ -22,11 +22,13 @@ from typing import Callable, Optional import pytest from fastapi import HTTPException +from pydantic import BaseModel from labthings_fastapi.exceptions import InvocationCancelledError from labthings_fastapi.testing import create_thing_without_server from openflexure_microscope_server.scan_directories import NotEnoughFreeSpaceError +from openflexure_microscope_server.stitching import StitchingSettings from openflexure_microscope_server.things.smart_scan import ( ActiveScanData, ScanNotRunningError, @@ -221,21 +223,28 @@ MOCK_SCAN_DIR = "scans/test_name_0001/images/" MOCK_START_POS = {"x": 123, "y": 456, "z": 789} +class MockWorkflowSettingModel(BaseModel): + """A mock model to check that ActiveScanData can hold arbitrary models.""" + + foo: str = "bar" + bar: str = "foo" + dx: int = 123 + dy: int = 456 + + def _expected_scan_data(): """Return the expected ActiveScanData object for a SmartScan with default properties.""" expected_dict = { "scan_name": MOCK_SCAN_NAME, "starting_position": MOCK_START_POS, - "overlap": 0.45, - "max_dist": 45000, - "dx": 100, - "dy": 100, - "autofocus_dz": 1000, - "autofocus_on": True, - "skip_background": True, - "stitch_automatically": True, - "correlation_resize": 0.5, "save_resolution": (1640, 1232), + "stitch_automatically": True, + "stitching_settings": { + "overlap": 0.45, + "correlation_resize": 0.5, + }, + "workflow": "Mock", + "workflow_settings": MockWorkflowSettingModel(), } return ActiveScanData(start_time=datetime.now(), **expected_dict) @@ -245,14 +254,14 @@ def scan_thing_mocked_for_scan_data(smart_scan_thing, mocker): """Return a scan thing that is mocked so that _collect_scan_data will run.""" # Set the lock so it thinks the scan is running with smart_scan_thing._scan_lock: - mocker.patch.object( - smart_scan_thing, - "_calc_displacement_from_test_image", - return_value=[100, 100], - ) - smart_scan_thing._stage.position = MOCK_START_POS + smart_scan_thing._workflow.all_settings.return_value = ( + MockWorkflowSettingModel(), + StitchingSettings(correlation_resize=0.5, overlap=0.45), + ) + 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 @@ -265,7 +274,7 @@ def test_collect_scan_data(scan_thing_mocked_for_scan_data): """Run _collect_scan_data, and check the ActiveScanData object has the expected values.""" scan_thing = scan_thing_mocked_for_scan_data - data = scan_thing._collect_scan_data() + data = scan_thing._collect_scan_data(scan_thing._workflow) expected_data = _expected_scan_data() time_diff = expected_data.start_time - data.start_time assert abs(time_diff.total_seconds()) < 1 @@ -278,7 +287,7 @@ def test_save_final_scan_data(scan_thing_mocked_for_scan_data): """Run _save_final_scan_data, check save is called with final results in ActiveScanData.""" scan_thing = scan_thing_mocked_for_scan_data - scan_thing._scan_data = scan_thing._collect_scan_data() + scan_thing._scan_data = scan_thing._collect_scan_data(scan_thing._workflow) scan_thing._scan_data.image_count = 44 scan_thing._save_final_scan_data("Mocked!") # _ongoing_scan is a mock so we can check that save_scan data was called and get @@ -320,10 +329,10 @@ def check_run_scan(scan_thing, caplog, expected_exception=None): """ if expected_exception is None: with caplog.at_level(logging.WARNING): - scan_thing._scan_data = scan_thing._run_scan() + scan_thing._scan_data = scan_thing._run_scan(scan_thing._workflow) else: with pytest.raises(expected_exception), caplog.at_level(logging.WARNING): - scan_thing._scan_data = scan_thing._run_scan() + scan_thing._scan_data = scan_thing._run_scan(scan_thing._workflow) # The preview stitcher object should still exist. And images dir should be set. assert scan_thing._preview_stitcher.images_dir == MOCK_SCAN_DIR diff --git a/tests/unit_tests/test_stack.py b/tests/unit_tests/test_stack.py index bcc048b0..7cc4fe7e 100644 --- a/tests/unit_tests/test_stack.py +++ b/tests/unit_tests/test_stack.py @@ -25,6 +25,7 @@ from openflexure_microscope_server.things.autofocus import ( _get_capture_index_by_id, _get_peak_turning_point, ) +from openflexure_microscope_server.things.scan_workflows import HistoScanWorkflow RANDOM_GENERATOR = np.random.default_rng() @@ -267,20 +268,28 @@ def autofocus_thing(): return create_thing_without_server(AutofocusThing, mock_all_slots=True) -def test_create_stack(autofocus_thing, caplog): +@pytest.fixture +def histo_scan_workflow(): + """Return an autofocus thing connected to a server.""" + return create_thing_without_server(HistoScanWorkflow, mock_all_slots=True) + + +def test_create_stack(histo_scan_workflow, caplog): """Run create stack with default values and check there is no coercion or logging.""" - initial_min_images_to_test = autofocus_thing.stack_min_images_to_test - initial_images_to_save = autofocus_thing.stack_images_to_save + initial_min_images_to_test = histo_scan_workflow.stack_min_images_to_test + initial_images_to_save = histo_scan_workflow.stack_images_to_save with caplog.at_level(logging.INFO): - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) assert len(caplog.records) == 0 - assert autofocus_thing.stack_min_images_to_test == initial_min_images_to_test - assert autofocus_thing.stack_images_to_save == initial_images_to_save - assert stack_params.min_images_to_test == autofocus_thing.stack_min_images_to_test - assert stack_params.images_to_save == autofocus_thing.stack_images_to_save + assert histo_scan_workflow.stack_min_images_to_test == initial_min_images_to_test + assert histo_scan_workflow.stack_images_to_save == initial_images_to_save + assert ( + stack_params.min_images_to_test == histo_scan_workflow.stack_min_images_to_test + ) + assert stack_params.images_to_save == histo_scan_workflow.stack_images_to_save @pytest.mark.parametrize( @@ -293,13 +302,13 @@ def test_create_stack(autofocus_thing, caplog): ], ) def test_coercing_stack_test_ims( - initial_test_ims, coerced_test_ims, expected_log_start, autofocus_thing, caplog + initial_test_ims, coerced_test_ims, expected_log_start, histo_scan_workflow, caplog ): """Run create stack with images to test set to values requiring coercion, and check result.""" - autofocus_thing.stack_min_images_to_test = initial_test_ims + histo_scan_workflow.stack_min_images_to_test = initial_test_ims with caplog.at_level(logging.WARNING): - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) @@ -308,7 +317,9 @@ def test_coercing_stack_test_ims( # Check the value is coerced in the stack_params assert stack_params.min_images_to_test == coerced_test_ims # Check that the setting in the Thing was updated to the coerced value - assert stack_params.min_images_to_test == autofocus_thing.stack_min_images_to_test + assert ( + stack_params.min_images_to_test == histo_scan_workflow.stack_min_images_to_test + ) @pytest.mark.parametrize( @@ -321,13 +332,13 @@ def test_coercing_stack_test_ims( ], ) def test_coercing_stack_save_ims( - initial_save_ims, coerced_save_ims, expected_log_start, autofocus_thing, caplog + initial_save_ims, coerced_save_ims, expected_log_start, histo_scan_workflow, caplog ): """Run create stack with images to save set to values requiring coercion, and check result.""" - autofocus_thing.stack_images_to_save = initial_save_ims + histo_scan_workflow.stack_images_to_save = initial_save_ims with caplog.at_level(logging.WARNING): - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) @@ -336,13 +347,13 @@ def test_coercing_stack_save_ims( # Check the value is coerced in the stack_params assert stack_params.images_to_save == coerced_save_ims # Check that the setting in the Thing was updated to the coerced value - assert stack_params.images_to_save == autofocus_thing.stack_images_to_save + assert stack_params.images_to_save == histo_scan_workflow.stack_images_to_save @pytest.mark.parametrize("pass_on", [1, 2, 3, 4]) -def test_run_smart_stack(pass_on, autofocus_thing, mocker): +def test_run_smart_stack(pass_on, histo_scan_workflow, autofocus_thing, mocker): """Test Running smart stack with the stack passing on different attempts.""" - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) assert stack_params.max_attempts == 3 @@ -364,8 +375,8 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker): failed_return = (False, fake_captures, "pick_me") return_list = [failed_return] * (pass_on - 1) + [successful_return] - # Mock z_stack and looping_autofocus - autofocus_thing.z_stack = mocker.Mock(side_effect=return_list) + # Mock smart_z_stack and looping_autofocus + autofocus_thing.smart_z_stack = mocker.Mock(side_effect=return_list) autofocus_thing.looping_autofocus = mocker.Mock() # Run it @@ -380,9 +391,10 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker): # Final z is the one from the id returned by the stack "pick_me" assert final_z == 555 - # z_stack should run up until the time it passes. Running no more than max_attempts + # smart_z_stack should run up until the time it passes. Running no more than + # max_attempts n_stacks = min(pass_on, stack_params.max_attempts) - assert autofocus_thing.z_stack.call_count == n_stacks + assert autofocus_thing.smart_z_stack.call_count == n_stacks # Move absolute should be 1 less time that the number of times z_stack_run assert autofocus_thing._stage.move_absolute.call_count == n_stacks - 1 # As should looping autofocus @@ -396,14 +408,16 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker): assert autofocus_thing._cam.save_from_memory.call_count == (1 if success else 0) -def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing, mocker): - """Set up a z_stack, run it, and return the result. +def setup_and_run_smart_z_stack( + check_returns, check_turning_points, histo_scan_workflow, autofocus_thing, mocker +): + """Set up a smart_z_stack, run it, and return the result. :param check_returns: The return values from check_stack_result. Note that if this is a list, it will be set as a side effect (and should be a list of tuples of results). If it a tuple (or anything else), it is set as a return value. """ - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) stack_params.settling_time = 0 # Don't settle or tests take forever. @@ -413,58 +427,70 @@ def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing, autofocus_thing.check_stack_result = mocker.Mock(side_effect=check_returns) else: autofocus_thing.check_stack_result = mocker.Mock(return_value=check_returns) - return autofocus_thing.z_stack( + return autofocus_thing.smart_z_stack( stack_parameters=stack_params, check_turning_points=check_turning_points, ) -def test_z_stack_turning_toggle_passed(autofocus_thing, mocker): +def test_z_stack_turning_toggle_passed(histo_scan_workflow, autofocus_thing, mocker): """Check that the toggling of turning points is passed to the check.""" check_returns = ("success", "mock_id") for check_turning in [True, False]: - setup_and_run_z_stack(check_returns, check_turning, autofocus_thing, mocker) + setup_and_run_smart_z_stack( + check_returns, check_turning, histo_scan_workflow, autofocus_thing, mocker + ) check_kwargs = autofocus_thing.check_stack_result.call_args.kwargs assert check_kwargs["check_turning_points"] == check_turning -def test_z_stack_returns_on_success_and_restart(autofocus_thing, mocker): +def test_z_stack_returns_on_success_and_restart( + histo_scan_workflow, autofocus_thing, mocker +): """Check that if the check returns success or restart then the stack exits with correct return value.""" for result in ["success", "restart"]: check_returns = (result, "mock_id") - ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker) + ret = setup_and_run_smart_z_stack( + check_returns, True, histo_scan_workflow, autofocus_thing, mocker + ) assert autofocus_thing.check_stack_result.call_count == 1 # Check the number of images taken is exactly the call count. ims_taken = autofocus_thing.capture_stack_image.call_count - assert ims_taken == autofocus_thing.stack_min_images_to_test + assert ims_taken == histo_scan_workflow.stack_min_images_to_test # And the result is as expected. assert ret[0] == (result == "success") -def test_z_stack_exits_if_focus_never_found(autofocus_thing, mocker): +def test_z_stack_exits_if_focus_never_found( + histo_scan_workflow, autofocus_thing, mocker +): """Check that if the check returns continue the stack exits eventually with a failure.""" check_returns = ("continue", "mock_id") - ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker) + ret = setup_and_run_smart_z_stack( + check_returns, True, histo_scan_workflow, autofocus_thing, mocker + ) assert autofocus_thing.check_stack_result.call_count == EXTRA_STACK_CAPTURES + 1 # Check the number of images taken is the maximum possible, set by the min images to # test and the number of extra images that can be taken ims_taken = autofocus_thing.capture_stack_image.call_count - max_ims = autofocus_thing.stack_min_images_to_test + EXTRA_STACK_CAPTURES + max_ims = histo_scan_workflow.stack_min_images_to_test + EXTRA_STACK_CAPTURES assert ims_taken == max_ims # And the result is as expected. assert not ret[0] -def test_z_stack_return(autofocus_thing, mocker): +def test_z_stack_return(histo_scan_workflow, autofocus_thing, mocker): """Check z-stack returns as expected for more complex cases the fixed results above.""" for i in range(2, EXTRA_STACK_CAPTURES): check_returns = [ ("restart" if j == i - 1 else "continue", f"id_{j}") for j in range(i) ] - ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker) + ret = setup_and_run_smart_z_stack( + check_returns, True, histo_scan_workflow, autofocus_thing, mocker + ) # Calculate images taken - images_taken = autofocus_thing.stack_min_images_to_test + i - 1 + images_taken = histo_scan_workflow.stack_min_images_to_test + i - 1 assert autofocus_thing.capture_stack_image.call_count == images_taken # Check it reports a failure assert not ret[0] @@ -473,7 +499,9 @@ def test_z_stack_return(autofocus_thing, mocker): check_returns = [ ("success" if j == i - 1 else "continue", f"id_{j}") for j in range(i) ] - ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker) + ret = setup_and_run_smart_z_stack( + check_returns, True, histo_scan_workflow, autofocus_thing, mocker + ) # Calculate images taken assert autofocus_thing.capture_stack_image.call_count == images_taken # Check it reports a success diff --git a/tests/unit_tests/test_stitching.py b/tests/unit_tests/test_stitching.py index 12bcfbfb..349d975c 100644 --- a/tests/unit_tests/test_stitching.py +++ b/tests/unit_tests/test_stitching.py @@ -11,15 +11,16 @@ import time from copy import copy import pytest +from pydantic import BaseModel import labthings_fastapi as lt from openflexure_microscope_server.stitching import ( - STITCHING_RESOLUTION, BaseStitcher, FinalStitcher, PreviewStitcher, StitcherValidationError, + StitchingSettings, ) from ..shared_utils.lt_test_utils import LabThingsTestEnv @@ -88,87 +89,41 @@ FINAL_EXPECTED_COMMAND = [ FAKE_DIR, ] - -def test_final_stitcher_command_defaults(caplog): - """Check the FinalStitcher stitches with expected default values. - - It should warn when default values are used as they are a fallback. - """ - n_logs = 0 - # Test with no dictionary data and with irrelevant dictionary data. - with caplog.at_level(logging.WARNING): - for data_dict in [None, {"irrelevant": "data"}]: - stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER, scan_data_dict=data_dict) - # Should log for overlap being None and correlation_resize being None - n_logs += 2 - assert len(caplog.records) == n_logs - assert stitcher.command == FINAL_EXPECTED_COMMAND +DEFAULT_SETTINGS = StitchingSettings(correlation_resize=0.5, overlap=0.1) -def test_final_stitcher_command_tiff(caplog): +def test_final_stitcher_command_tiff(): """Check that the tiff can be requested.""" # Modify defaults expected_command = copy(FINAL_EXPECTED_COMMAND) expected_command[4] = "--stitch_tiff" - stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER, stitch_tiff=True) - # Should log for overlap being None and correlation_resize being None - assert len(caplog.records) == 2 + stitcher = FinalStitcher( + FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS, stitch_tiff=True + ) assert stitcher.command == expected_command -def test_final_stitcher_command_set_val_directly(): - """Check that values are set as expected when directly input.""" - # Modify defaults - expected_command = copy(FINAL_EXPECTED_COMMAND) - expected_command[8] = "0.36" - expected_command[10] = "0.25" - # Test with no data dictionary, irrelevant data, and also the wrong data - # When wrong data is submitted, it should take the directly input data. - dict_vals = [ - None, - {"irrelevant": "data"}, - {"overlap": 0.2, "save_resolution": [5, 5]}, - ] - for data_dict in dict_vals: - stitcher = FinalStitcher( - FAKE_DIR, - logger=LOGGER, - overlap=0.4, - correlation_resize=0.25, - scan_data_dict=data_dict, - ) - assert stitcher.command == expected_command - - -def test_final_stitcher_command_set_with_dict(): +def test_final_stitcher_command_with_settings(): """Check that values are set as expected when set from a ScanData dictionary.""" # Modify defaults expected_command = copy(FINAL_EXPECTED_COMMAND) expected_command[8] = "0.36" expected_command[10] = "0.25" - # Check same thing works with a dictionary, resize is calculated from the saved image - # resolution. Make 4x bigger than STITCHING_RESOLUTION to get 0.25 - resolution = [dim * 4 for dim in STITCHING_RESOLUTION] - # Check legacy key as well as current one: - for resolution_key in ["save_resolution", "capture resolution"]: - stitcher = FinalStitcher( - FAKE_DIR, - logger=LOGGER, - scan_data_dict={"overlap": 0.4, resolution_key: resolution}, - ) - assert stitcher.command == expected_command + + stitcher = FinalStitcher( + FAKE_DIR, + logger=LOGGER, + stitching_settings=StitchingSettings(correlation_resize=0.25, overlap=0.4), + ) + assert stitcher.command == expected_command def _validation_error_tester(scan_path, **kwargs): - """Check each type of stitcher throws a validation error for the given init args.""" - # If scan_data_dict is in the kwargs only test the scan_data_dict - if "scan_data_dict" not in kwargs: - with pytest.raises(StitcherValidationError): - BaseStitcher(scan_path, **kwargs).command - with pytest.raises(StitcherValidationError): - PreviewStitcher(scan_path, **kwargs).command + """Check stitcher throws a validation error for the given init args.""" with pytest.raises(StitcherValidationError): - FinalStitcher(scan_path, logger=LOGGER, **kwargs).command + BaseStitcher(scan_path, **kwargs).command + with pytest.raises(StitcherValidationError): + PreviewStitcher(scan_path, **kwargs).command def test_validation_error(): @@ -176,10 +131,23 @@ def test_validation_error(): The stitcher should throw a validation error each attempt. """ + # Tests for preview (and base) stitcher _validation_error_tester("/dir;rm -rf /;", overlap=".2", correlation_resize=".25") _validation_error_tester(FAKE_DIR, overlap=".2", correlation_resize=".25;rm -rf /;") _validation_error_tester(FAKE_DIR, overlap=".2;rm -rf /;", correlation_resize=".25") - _validation_error_tester(FAKE_DIR, scan_data_dict={"overlap": ".2;rm -rf /;"}) + + class EvilModel(BaseModel): + overlap: str + correlation_resize: str + + with pytest.raises(StitcherValidationError): + FinalStitcher( + FAKE_DIR, + logger=LOGGER, + stitching_settings=EvilModel( + overlap=".2;rm -rf /;", correlation_resize=".25" + ), + ) def test_extra_arg_validation(): @@ -188,7 +156,9 @@ def test_extra_arg_validation(): Currently extra args do not come from user input. But this makes checks more future-proof. """ - stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER) + stitcher = FinalStitcher( + FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS + ) stitcher._extra_args = ["&&rm -rf /&&"] with pytest.raises(StitcherValidationError): stitcher.command @@ -233,7 +203,9 @@ class StitchingTestThing(lt.Thing): @lt.action def run_final(self): """Run the final stitcher.""" - stitcher = FinalStitcher(FAKE_DIR, logger=self.logger) + stitcher = FinalStitcher( + FAKE_DIR, logger=self.logger, stitching_settings=DEFAULT_SETTINGS + ) # Send in the argument HANG to mock-stitch and it just hang for 10s stitcher._extra_args = ["HANG"] stitcher.run() @@ -277,9 +249,8 @@ def test_final_stitching_command(caplog, mocker): mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd) with caplog.at_level(logging.INFO): - # Input values to prevent logging stitcher = FinalStitcher( - FAKE_DIR, logger=LOGGER, overlap=0.1, correlation_resize=0.5 + FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS ) # For the final stitcher it will always complete before returning. stitcher.run() @@ -315,16 +286,17 @@ def test_final_stitching_command_cancelled(stitching_test_env, mocker): assert re.match(r"^Invocation [0-9a-f-]+ was cancelled", logs[-1]["message"]) -def test_final_stitching_command_error(caplog, mocker): +def test_final_stitching_command_error(mocker): """Check that ChildProcessError is raised if the final stitch errors.""" mock_cmd = f"python {MOCK_STITCHER}" mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd) - with caplog.at_level(logging.INFO): - stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER) - # Send in the argument ERROR to mock-stitch and it will raise an error rather - # than echo. - stitcher._extra_args = ["ERROR"] - with pytest.raises(ChildProcessError): - stitcher.run() + stitcher = FinalStitcher( + FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS + ) + # Send in the argument ERROR to mock-stitch and it will raise an error rather + # than echo. + stitcher._extra_args = ["ERROR"] + with pytest.raises(ChildProcessError): + stitcher.run() From 21b779a4a534a59eb450953acf223fa6dd695a0f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 16 Jan 2026 12:18:54 +0000 Subject: [PATCH 10/27] Dynamic UI for scan workflows --- ofm_config_simulation.json | 3 +- .../things/scan_workflows.py | 44 ++++++ .../things/smart_scan.py | 47 ++++++- .../tabContentComponents/slideScanContent.vue | 129 ++++++++++-------- 4 files changed, 157 insertions(+), 66 deletions(-) diff --git a/ofm_config_simulation.json b/ofm_config_simulation.json index 8cc7602f..367088de 100644 --- a/ofm_config_simulation.json +++ b/ofm_config_simulation.json @@ -8,7 +8,8 @@ "smart_scan": { "class": "openflexure_microscope_server.things.smart_scan:SmartScanThing", "kwargs": { - "scans_folder": "./openflexure/scans/" + "scans_folder": "./openflexure/scans/", + "default_workflow": "histo_scan_workflow" } }, "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index 39548663..58563457 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -33,6 +33,7 @@ from openflexure_microscope_server.things.background_detect import ( ) from openflexure_microscope_server.things.camera import BaseCamera from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper +from openflexure_microscope_server.ui import PropertyControl, property_control_for SettingModelType = TypeVar("SettingModelType", bound=BaseModel) @@ -44,6 +45,11 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing): scan planning, acquisition routine. """ + display_name: str = lt.property(default="Base Workflow", readonly=True) + ui_blurb: str = lt.property( + default="If you see this message, something is wrong.", readonly=True + ) + _settings_model: type[SettingModelType] # All workflows must have a set class for scan planning @@ -106,6 +112,13 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing): "Each specific ScanWorkflow must implement an acquisition routine" ) + @lt.property + def settings_ui(self) -> list[PropertyControl]: + """A list of PropertyControl objects to create the settings in the scan tab.""" + raise NotImplementedError( + "Each scan workflow must implement a settings_ui method." + ) + class HistoScanSettingsModel(BaseModel): """The settings for a scan with the HistoScanWorkflow. @@ -129,6 +142,16 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): the centre position, scanning only where it detects sample. """ + display_name: str = lt.property(default="Histo Scan", readonly=True) + ui_blurb: str = lt.property( + default=( + "This scan workflow is optimised for scanning H&E stained biopsies. It" + "spirals out from the starting location, scanning only where it detects" + "sample. It also works well for many other flat, well-featured samples." + ), + readonly=True, + ) + _settings_model = HistoScanSettingsModel _planner_cls: type[ScanPlanner] = SmartSpiral # Thing Slots @@ -428,3 +451,24 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): focus_height = None return imaged, focus_height + + @lt.property + def settings_ui(self) -> list[PropertyControl]: + """A list of PropertyControl objects to create the settings in the scan tab.""" + return [ + property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"), + property_control_for( + self, "skip_background", label="Detect and Skip Empty Fields " + ), + property_control_for( + self, "stack_images_to_save", label="Images in Stack to Save" + ), + property_control_for( + self, + "stack_min_images_to_test", + label="Minimum number of images to test for focus", + ), + property_control_for(self, "stack_dz", label="Stack dz (steps)"), + property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"), + property_control_for(self, "max_range", label="Maximum Distance (steps)"), + ] diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 9768ef29..2a44474a 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -1,8 +1,8 @@ """The core sample scanning functionality for the OpenFlexure Microscope. -SmartScan provides sample scanning functionality including automatic background -detection (via the ``CameraThing``) and automatic path planning via -`scan_planners`. It manages the directories of past scans via `scan_directories`. +SmartScan provides sample scanning functionality. This functionality can be customised +by different ``ScanWorkflow`` Things which control the path planning and acquisition +routines. It manages the directories of past scans via `scan_directories`. It also controls external processes for live stitching composite images, and the creation of the final stitched images. """ @@ -20,6 +20,7 @@ from typing import ( Mapping, Optional, ParamSpec, + Self, TypeVar, ) @@ -30,10 +31,11 @@ from pydantic import BaseModel, PlainSerializer import labthings_fastapi as lt from openflexure_microscope_server import scan_directories, stitching +from openflexure_microscope_server.utilities import coerce_thing_selector # Things from .camera import BaseCamera -from .scan_workflows import HistoScanWorkflow, ScanWorkflow +from .scan_workflows import ScanWorkflow from .stage import BaseStage T = TypeVar("T") @@ -127,10 +129,13 @@ class SmartScanThing(lt.Thing): _cam: BaseCamera = lt.thing_slot() _stage: BaseStage = lt.thing_slot() - _workflow: HistoScanWorkflow = lt.thing_slot() + _all_workflows: Mapping[str, ScanWorkflow] = lt.thing_slot() def __init__( - self, thing_server_interface: lt.ThingServerInterface, scans_folder: str + self, + thing_server_interface: lt.ThingServerInterface, + scans_folder: str, + default_workflow: Optional[str], ) -> None: """Initialise a SmartScanThing saving to and loading from the input directory. @@ -141,6 +146,36 @@ class SmartScanThing(lt.Thing): super().__init__(thing_server_interface) self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder) self._scan_lock = threading.Lock() + self._default_workflow = default_workflow + self._workflow_name: Optional[str] = None + + def __enter__(self) -> Self: + """Open hardware connection when the Thing context manager is opened.""" + self._workflow_name = coerce_thing_selector( + thing_mapping=self._all_workflows, + selected=self.workflow_name, + default=self._default_workflow, + ) + return self + + # Note that the default detector name is set at init. This is over written if + # setting is loaded from disk. + @lt.setting + def workflow_name(self) -> Optional[str]: + """The name of the scan workflow selector.""" + return self._workflow_name + + @workflow_name.setter + def _set_workflow_name(self, name: str) -> None: + """Validate and set workflow_name.""" + if name not in self._all_workflows: + self.logger.warning(f"{name} is not a valid scan workflow name.") + self._workflow_name = name + + @property + def _workflow(self) -> ScanWorkflow: + """The active scan workflow object.""" + return self._all_workflows[self.workflow_name] _ongoing_scan: Optional[scan_directories.ScanDirectory] = None diff --git a/webapp/src/components/tabContentComponents/slideScanContent.vue b/webapp/src/components/tabContentComponents/slideScanContent.vue index 0fdf6e1f..0290a07f 100644 --- a/webapp/src/components/tabContentComponents/slideScanContent.vue +++ b/webapp/src/components/tabContentComponents/slideScanContent.vue @@ -1,51 +1,32 @@