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

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