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(