First working interation of ScanWorkflows. Needs a tidy
This commit is contained in:
parent
adab5bae24
commit
665622a802
6 changed files with 173 additions and 149 deletions
|
|
@ -16,6 +16,7 @@
|
||||||
"scans_folder": "/var/openflexure/scans/"
|
"scans_folder": "/var/openflexure/scans/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow",
|
||||||
"stage_measure": "openflexure_microscope_server.things.stage_measure:RangeofMotionThing",
|
"stage_measure": "openflexure_microscope_server.things.stage_measure:RangeofMotionThing",
|
||||||
"bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV",
|
"bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV",
|
||||||
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"
|
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"scans_folder": "./openflexure/scans/"
|
"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_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV",
|
||||||
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"
|
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@ import shutil
|
||||||
import threading
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Annotated, Any, Mapping, Optional, Self
|
from typing import Any, Mapping, Optional, Self
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
ConfigDict,
|
ConfigDict,
|
||||||
PlainSerializer,
|
|
||||||
ValidationError,
|
ValidationError,
|
||||||
field_serializer,
|
field_serializer,
|
||||||
field_validator,
|
field_validator,
|
||||||
|
|
@ -60,56 +59,20 @@ class StitchingData(BaseModel):
|
||||||
"""The overlap between adjacent images as a fraction of the image size."""
|
"""The overlap between adjacent images as a fraction of the image size."""
|
||||||
|
|
||||||
|
|
||||||
def _coerce_lecacy_scan_data(data: dict) -> dict:
|
class BaseScanData(BaseModel):
|
||||||
"""Coerce any scan data from before version 2 into the version 2 format."""
|
"""Data about a scan not including workflow specific data.
|
||||||
# Before the current version no schema_version was set
|
|
||||||
if "schema_version" in data:
|
|
||||||
return data
|
|
||||||
|
|
||||||
if "correlation_resize" and "overlap" in data:
|
For including workflow specific data see also:
|
||||||
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
|
* ActiveScanData which subclasses this including the BaseModel used by the
|
||||||
legacy_keys = [
|
ScanWorkflow
|
||||||
"overlap",
|
* HistoricScanData which has the workflow specific data loaded as a dictionary.
|
||||||
"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"
|
Sepearating historic and active data allows workflows to use any BaseModel for its
|
||||||
data["workflow_settings"] = workflow_settings
|
settings, but for the data to be reloaded even if that model has updated or is not
|
||||||
return data
|
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 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.
|
|
||||||
|
|
||||||
This serialises into a human readable format where possible with
|
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.
|
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]
|
stitching_settings: Optional[StitchingData]
|
||||||
"""The data needed to stitch a scan.
|
"""The data needed to stitch a scan.
|
||||||
|
|
||||||
Set to None for types of scan that cannot be stitched.
|
Set to None for types of scan that cannot be stitched.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def set_final_data(self, result: str) -> None:
|
workflow: str
|
||||||
"""Set the final data for the scan, scan duration is automatically calculated.
|
"""The class name of the workflow Thing."""
|
||||||
|
|
||||||
: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)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_schema_version(self) -> Self:
|
def validate_schema_version(self) -> Self:
|
||||||
|
|
@ -238,6 +184,53 @@ class ScanData(BaseModel):
|
||||||
return "Unknown" if value is None else value
|
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:
|
class ScanDirectoryManager:
|
||||||
"""A class for managing interactions with scan directories."""
|
"""A class for managing interactions with scan directories."""
|
||||||
|
|
||||||
|
|
@ -565,10 +558,10 @@ class ScanDirectory:
|
||||||
except (json.decoder.JSONDecodeError, IOError):
|
except (json.decoder.JSONDecodeError, IOError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_scan_data(self) -> Optional[ScanData]:
|
def get_scan_data(self) -> Optional[HistoricScanData]:
|
||||||
"""Return the scan data from the json file as a ScanData model.
|
"""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.
|
valdiated.
|
||||||
"""
|
"""
|
||||||
data_dict = self.get_scan_data_dict()
|
data_dict = self.get_scan_data_dict()
|
||||||
|
|
@ -576,7 +569,7 @@ class ScanDirectory:
|
||||||
LOGGER.warning(f"Could not load scan data for {self.name}.")
|
LOGGER.warning(f"Could not load scan data for {self.name}.")
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return ScanData(**data_dict)
|
return HistoricScanData(**data_dict)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
LOGGER.warning(f"Could not validate scan data for {self.name}.")
|
LOGGER.warning(f"Could not validate scan data for {self.name}.")
|
||||||
return None
|
return None
|
||||||
|
|
@ -627,7 +620,7 @@ class ScanDirectory:
|
||||||
files.append(os.path.relpath(full_path, self.dir_path))
|
files.append(os.path.relpath(full_path, self.dir_path))
|
||||||
return files
|
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."""
|
"""Save the scan data for this scan to disk."""
|
||||||
if self.scan_data_path is None:
|
if self.scan_data_path is None:
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
|
|
|
||||||
|
|
@ -218,9 +218,11 @@ class FinalStitcher(BaseStitcher):
|
||||||
if not known enter None. A value will be chosen from the scan_data_dict, or
|
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.
|
set to a default value if no value is available in the scan_data.
|
||||||
:param stitch_tiff: Whether to stitch a pyramidal TIFF.
|
:param stitch_tiff: Whether to stitch a pyramidal TIFF.
|
||||||
:param scan_data_dict: The ScanData for this scan as a dictionary. This is used
|
:param scan_data_dict: The HistoricScanData for this scan as a dictionary. This
|
||||||
to read/calculate overlap and correlation_resize if they are not provided.
|
is used to read/calculate overlap and correlation_resize if they are not
|
||||||
|
provided.
|
||||||
"""
|
"""
|
||||||
|
# TODO ^fix the above historic data
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
overlap, correlation_resize = self._process_inputs(
|
overlap, correlation_resize = self._process_inputs(
|
||||||
overlap=overlap,
|
overlap=overlap,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
from typing import Mapping, Optional
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import (
|
||||||
|
Generic,
|
||||||
|
Mapping,
|
||||||
|
Optional,
|
||||||
|
TypeVar,
|
||||||
|
)
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -16,16 +23,21 @@ from openflexure_microscope_server.things.autofocus import (
|
||||||
from openflexure_microscope_server.things.background_detect import (
|
from openflexure_microscope_server.things.background_detect import (
|
||||||
ChannelDeviationLUV,
|
ChannelDeviationLUV,
|
||||||
)
|
)
|
||||||
|
from openflexure_microscope_server.things.camera import BaseCamera
|
||||||
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
|
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.
|
"""A base class for all Scanworkflows.
|
||||||
|
|
||||||
Scan workflows set the behaviour of a scan, inclduing the background detection,
|
Scan workflows set the behaviour of a scan, inclduing the background detection,
|
||||||
scan planning, aquisition routine.
|
scan planning, aquisition routine.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_settings_model: type[SettingModelType]
|
||||||
|
|
||||||
# All workdlows must have a set class for scan planning
|
# All workdlows must have a set class for scan planning
|
||||||
_planner_cls: type[ScanPlanner]
|
_planner_cls: type[ScanPlanner]
|
||||||
|
|
||||||
|
|
@ -50,11 +62,13 @@ class ScanWorkflow(lt.Thing):
|
||||||
"Each specific ScanWorkflow must implement a ready property."
|
"Each specific ScanWorkflow must implement a ready property."
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO should be a model
|
def all_settings(
|
||||||
def all_settings(self, images_dir: str) -> tuple[dict, Optional[StitchingData]]:
|
self, images_dir: str
|
||||||
|
) -> tuple[SettingModelType, Optional[StitchingData]]:
|
||||||
"""Return the scan settings and the stitching settings.
|
"""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
|
- Stitiching settings are returned either as a StitchingData object or None
|
||||||
is returned if it is not possible to stitch the scan.
|
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."
|
"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(
|
raise NotImplementedError(
|
||||||
"Each specific ScanWorkflow must implement a pre-scan routine."
|
"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(
|
raise NotImplementedError(
|
||||||
"Each specific ScanWorkflow must implement a ``new_scan_planner`` method."
|
"Each specific ScanWorkflow must implement a ``new_scan_planner`` method."
|
||||||
)
|
)
|
||||||
|
|
||||||
def aquisition_routine(
|
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]]:
|
) -> tuple[bool, Optional[int]]:
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"Each specific ScanWorkflow must implement an aquisition routine"
|
"Each specific ScanWorkflow must implement an aquisition routine"
|
||||||
|
|
@ -83,8 +99,8 @@ class ScanWorkflow(lt.Thing):
|
||||||
class HistoScanSettingsModel(BaseModel):
|
class HistoScanSettingsModel(BaseModel):
|
||||||
"""The settings including needed for running a HistoScan.
|
"""The settings including needed for running a HistoScan.
|
||||||
|
|
||||||
This includes settings caluclated when starting. This will be serialised in
|
This includes settings caluclated when starting. This will be held by smart scan
|
||||||
ScanData.
|
during a scan and serialised to disk.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
overlap: float
|
overlap: float
|
||||||
|
|
@ -92,14 +108,16 @@ class HistoScanSettingsModel(BaseModel):
|
||||||
dx: int
|
dx: int
|
||||||
dy: int
|
dy: int
|
||||||
skip_background: bool
|
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
|
# Thing Slots
|
||||||
_background_detector: ChannelDeviationLUV = lt.thing_slot()
|
_background_detector: ChannelDeviationLUV = lt.thing_slot()
|
||||||
|
_cam: BaseCamera = lt.thing_slot()
|
||||||
_csm: CameraStageMapper = lt.thing_slot()
|
_csm: CameraStageMapper = lt.thing_slot()
|
||||||
_planner_cls: type[ScanPlanner] = SmartSpiral
|
|
||||||
_autofocus: AutofocusThing = lt.thing_slot()
|
_autofocus: AutofocusThing = lt.thing_slot()
|
||||||
|
|
||||||
# Scan settings
|
# Scan settings
|
||||||
|
|
@ -147,7 +165,8 @@ class HistoScanWorkflow(ScanWorkflow):
|
||||||
* 200 for 20x
|
* 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
|
def check_before_start(self, scan_name: str) -> None: # noqa: ARG002
|
||||||
"""Before starting a scan, check that background and camera-stage-mapping are set.
|
"""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],
|
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(
|
self.logger.info(
|
||||||
f"Based on an overlap of {self.overlap}, the stage will make steps of "
|
f"Based on an overlap of {self.overlap}, the stage will make steps of "
|
||||||
f"{dx}, {dy}"
|
f"{dx}, {dy}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# TODO: set a min on the property
|
||||||
autofocus_dz = self.autofocus_dz
|
autofocus_dz = self.autofocus_dz
|
||||||
if autofocus_dz == 0:
|
if autofocus_dz == 0:
|
||||||
self.logger.info("Running scan without autofocus")
|
self.logger.info("Running scan without autofocus")
|
||||||
|
|
@ -207,7 +227,7 @@ class HistoScanWorkflow(ScanWorkflow):
|
||||||
)
|
)
|
||||||
autofocus_dz = 0
|
autofocus_dz = 0
|
||||||
|
|
||||||
smart_stack_prarams = self.create_smart_stack_params(
|
smart_stack_params = self.create_smart_stack_params(
|
||||||
images_dir=images_dir,
|
images_dir=images_dir,
|
||||||
autofocus_dz=autofocus_dz,
|
autofocus_dz=autofocus_dz,
|
||||||
save_resolution=self.save_resolution,
|
save_resolution=self.save_resolution,
|
||||||
|
|
@ -219,9 +239,8 @@ class HistoScanWorkflow(ScanWorkflow):
|
||||||
dx=dx,
|
dx=dx,
|
||||||
dy=dy,
|
dy=dy,
|
||||||
skip_background=self.skip_background,
|
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
|
return scan_settings, stitching_settings
|
||||||
|
|
||||||
|
|
@ -233,7 +252,9 @@ class HistoScanWorkflow(ScanWorkflow):
|
||||||
|
|
||||||
:returns: (dx, dy) - the x and y displacements in steps
|
: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
|
# Calculate displacements in image coordinates
|
||||||
dx_img = csm_image_res[1] * (1 - overlap)
|
dx_img = csm_image_res[1] * (1 - overlap)
|
||||||
|
|
@ -320,11 +341,13 @@ class HistoScanWorkflow(ScanWorkflow):
|
||||||
)
|
)
|
||||||
|
|
||||||
def pre_scan_routine(self, settings: HistoScanSettingsModel) -> None:
|
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(
|
def new_scan_planner(
|
||||||
self, settings: HistoScanSettingsModel, position: Mapping[str, int]
|
self, settings: HistoScanSettingsModel, position: Mapping[str, int]
|
||||||
) -> None:
|
) -> ScanPlanner:
|
||||||
# The initial plan for the scan should be a single x,y position. All future
|
# 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
|
# moves will be planned around this point. In future, route planner could
|
||||||
# have multiple starting positions, each of which will be visited before the
|
# 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 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()
|
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:
|
if not capture_image:
|
||||||
msg = f"Skipping {xyz_pos} as it is {bg_message}."
|
msg = f"Skipping {xyz_pos} as it is {bg_message}."
|
||||||
|
|
@ -358,7 +385,8 @@ class HistoScanWorkflow(ScanWorkflow):
|
||||||
|
|
||||||
save_on_failure = settings.skip_background
|
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,
|
stack_parameters=settings.smart_stack_params,
|
||||||
save_on_failure=save_on_failure,
|
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
|
# 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 it failed to find a good focus. Set to None if not focussed.
|
||||||
if not focused:
|
if not focused:
|
||||||
focused_height = None
|
focus_height = None
|
||||||
|
|
||||||
return imaged, focused_height
|
return imaged, focus_height
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from subprocess import SubprocessError
|
from subprocess import SubprocessError
|
||||||
from typing import (
|
from typing import (
|
||||||
|
Annotated,
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
Concatenate,
|
Concatenate,
|
||||||
|
|
@ -24,16 +25,14 @@ from typing import (
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, PlainSerializer
|
||||||
|
|
||||||
import labthings_fastapi as lt
|
import labthings_fastapi as lt
|
||||||
|
|
||||||
from openflexure_microscope_server import scan_directories, stitching
|
from openflexure_microscope_server import scan_directories, stitching
|
||||||
|
|
||||||
# Things
|
# Things
|
||||||
from .autofocus import AutofocusThing, StackParams
|
|
||||||
from .camera import BaseCamera
|
from .camera import BaseCamera
|
||||||
from .camera_stage_mapping import CameraStageMapper
|
|
||||||
from .scan_workflows import HistoScanWorkflow, ScanWorkflow
|
from .scan_workflows import HistoScanWorkflow, ScanWorkflow
|
||||||
from .stage import BaseStage
|
from .stage import BaseStage
|
||||||
|
|
||||||
|
|
@ -41,6 +40,26 @@ T = TypeVar("T")
|
||||||
P = ParamSpec("P")
|
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):
|
class ScanListInfo(BaseModel):
|
||||||
"""The information to be sent to the Scan List tab."""
|
"""The information to be sent to the Scan List tab."""
|
||||||
|
|
||||||
|
|
@ -99,9 +118,7 @@ class SmartScanThing(lt.Thing):
|
||||||
past scans.
|
past scans.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_autofocus: AutofocusThing = lt.thing_slot()
|
|
||||||
_cam: BaseCamera = lt.thing_slot()
|
_cam: BaseCamera = lt.thing_slot()
|
||||||
_csm: CameraStageMapper = lt.thing_slot()
|
|
||||||
_stage: BaseStage = lt.thing_slot()
|
_stage: BaseStage = lt.thing_slot()
|
||||||
_workflow: HistoScanWorkflow = 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_dir_manager = scan_directories.ScanDirectoryManager(scans_folder)
|
||||||
self._scan_lock = threading.Lock()
|
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
|
_ongoing_scan: Optional[scan_directories.ScanDirectory] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -143,11 +147,11 @@ class SmartScanThing(lt.Thing):
|
||||||
raise ScanNotRunningError("Cannot get ongoing scan if scan is not running.")
|
raise ScanNotRunningError("Cannot get ongoing scan if scan is not running.")
|
||||||
return self._ongoing_scan
|
return self._ongoing_scan
|
||||||
|
|
||||||
_scan_data: Optional[scan_directories.ScanData] = None
|
_scan_data: Optional[ActiveScanData] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scan_data(self) -> scan_directories.ScanData:
|
def scan_data(self) -> ActiveScanData:
|
||||||
"""The ScanData object jolding information about the of the ongoing scan.
|
"""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.
|
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
|
_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
|
_latest_scan_name: Optional[str] = None
|
||||||
|
|
||||||
@lt.property
|
@lt.property
|
||||||
|
|
@ -218,7 +210,6 @@ class SmartScanThing(lt.Thing):
|
||||||
self._scan_lock.release()
|
self._scan_lock.release()
|
||||||
# Ensure any PreviewStitcher created cannot be reused.
|
# Ensure any PreviewStitcher created cannot be reused.
|
||||||
self._preview_stitcher = None
|
self._preview_stitcher = None
|
||||||
self._stack_params = None
|
|
||||||
|
|
||||||
# Remove any scan folders containing zero images.
|
# Remove any scan folders containing zero images.
|
||||||
self.purge_empty_scans()
|
self.purge_empty_scans()
|
||||||
|
|
@ -248,7 +239,7 @@ class SmartScanThing(lt.Thing):
|
||||||
return (next_point[0], next_point[1], z_estimate)
|
return (next_point[0], next_point[1], z_estimate)
|
||||||
|
|
||||||
@_scan_running
|
@_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."""
|
"""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.
|
# Record starting position so it can be returned to at end of scan.
|
||||||
starting_position = self._stage.position
|
starting_position = self._stage.position
|
||||||
|
|
@ -266,7 +257,7 @@ class SmartScanThing(lt.Thing):
|
||||||
auto_stitch = self.stitch_automatically and stitching_settings is not None
|
auto_stitch = self.stitch_automatically and stitching_settings is not None
|
||||||
|
|
||||||
# Fix scan parameters in case UI is updated during scan.
|
# Fix scan parameters in case UI is updated during scan.
|
||||||
return scan_directories.ScanData(
|
return ActiveScanData(
|
||||||
scan_name=self.ongoing_scan.name,
|
scan_name=self.ongoing_scan.name,
|
||||||
starting_position=starting_position,
|
starting_position=starting_position,
|
||||||
start_time=datetime.now(),
|
start_time=datetime.now(),
|
||||||
|
|
@ -290,13 +281,13 @@ class SmartScanThing(lt.Thing):
|
||||||
@_scan_running
|
@_scan_running
|
||||||
def _manage_stitching_threads(self) -> None:
|
def _manage_stitching_threads(self) -> None:
|
||||||
"""Manage the stitching threads, starting them if needed and not already running."""
|
"""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.
|
# This scan can't stitch.
|
||||||
return
|
return
|
||||||
# Assume 4 images means at least one offset in x and y, making the stitching
|
# Assume 4 images means at least one offset in x and y, making the stitching
|
||||||
# well constrained.
|
# well constrained.
|
||||||
if self.scan_data.image_count > 3 and not self.preview_stitcher.running:
|
if self.scan_data.image_count > 3 and not self._preview_stitcher.running:
|
||||||
self.preview_stitcher.start()
|
self._preview_stitcher.start()
|
||||||
|
|
||||||
@_scan_running
|
@_scan_running
|
||||||
def _run_scan(self, workflow: ScanWorkflow) -> None:
|
def _run_scan(self, workflow: ScanWorkflow) -> None:
|
||||||
|
|
@ -309,7 +300,8 @@ class SmartScanThing(lt.Thing):
|
||||||
try:
|
try:
|
||||||
self._cam.start_streaming(main_resolution=(3280, 2464))
|
self._cam.start_streaming(main_resolution=(3280, 2464))
|
||||||
self._scan_data = self._collect_scan_data(workflow)
|
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)
|
self.ongoing_scan.save_scan_data(self._scan_data)
|
||||||
images_dir = self.ongoing_scan.images_dir
|
images_dir = self.ongoing_scan.images_dir
|
||||||
# Type narrowing
|
# Type narrowing
|
||||||
|
|
@ -317,11 +309,16 @@ class SmartScanThing(lt.Thing):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Couldn't run scan, images directory was not created."
|
"Couldn't run scan, images directory was not created."
|
||||||
)
|
)
|
||||||
self._preview_stitcher = stitching.PreviewStitcher(
|
|
||||||
images_dir,
|
# If stitching settings are None then this type of scan can't be stitched
|
||||||
overlap=self.scan_data.overlap,
|
if self.scan_data.stitching_settings is not None:
|
||||||
correlation_resize=self.scan_data.correlation_resize,
|
# 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!
|
# This is the main loop of the scan!
|
||||||
self._main_scan_loop(workflow)
|
self._main_scan_loop(workflow)
|
||||||
|
|
@ -386,6 +383,7 @@ class SmartScanThing(lt.Thing):
|
||||||
if focus_height is None:
|
if focus_height is None:
|
||||||
focused = False
|
focused = False
|
||||||
else:
|
else:
|
||||||
|
focused = True
|
||||||
current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focus_height)
|
current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focus_height)
|
||||||
|
|
||||||
route_planner.mark_location_visited(
|
route_planner.mark_location_visited(
|
||||||
|
|
@ -426,17 +424,18 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
self.logger.info("Waiting for background processes to finish...")
|
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.
|
# this method can be called during exception handling.
|
||||||
if self._preview_stitcher is not None:
|
if self._preview_stitcher is not None:
|
||||||
self._preview_stitcher.wait()
|
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.logger.info("Stitching final image (may take some time)...")
|
||||||
self.stitch_scan(
|
self.stitch_scan(
|
||||||
scan_name=self.ongoing_scan.name,
|
scan_name=self.ongoing_scan.name,
|
||||||
correlation_resize=self.scan_data.correlation_resize,
|
correlation_resize=stitching_settings.correlation_resize,
|
||||||
overlap=self.scan_data.overlap,
|
overlap=stitching_settings.overlap,
|
||||||
)
|
)
|
||||||
|
|
||||||
@lt.endpoint(
|
@lt.endpoint(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue