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

View file

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

View file

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

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

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

View file

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