First working interation of ScanWorkflows. Needs a tidy

This commit is contained in:
Julian Stirling 2026-01-15 18:25:05 +00:00
parent adab5bae24
commit 665622a802
6 changed files with 173 additions and 149 deletions

View file

@ -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"

View file

@ -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"
},

View file

@ -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(

View file

@ -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,

View file

@ -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

View file

@ -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,10 +309,15 @@ class SmartScanThing(lt.Thing):
raise RuntimeError(
"Couldn't run scan, images directory was not created."
)
# 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=self.scan_data.overlap,
correlation_resize=self.scan_data.correlation_resize,
overlap=stitching_settings.overlap,
correlation_resize=stitching_settings.correlation_resize,
)
# This is the main loop of the scan!
@ -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(