Dynamic UI for scan workflows

This commit is contained in:
Julian Stirling 2026-01-16 12:18:54 +00:00
parent 2a3a54b936
commit 21b779a4a5
4 changed files with 157 additions and 66 deletions

View file

@ -33,6 +33,7 @@ from openflexure_microscope_server.things.background_detect import (
)
from openflexure_microscope_server.things.camera import BaseCamera
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
from openflexure_microscope_server.ui import PropertyControl, property_control_for
SettingModelType = TypeVar("SettingModelType", bound=BaseModel)
@ -44,6 +45,11 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
scan planning, acquisition routine.
"""
display_name: str = lt.property(default="Base Workflow", readonly=True)
ui_blurb: str = lt.property(
default="If you see this message, something is wrong.", readonly=True
)
_settings_model: type[SettingModelType]
# All workflows must have a set class for scan planning
@ -106,6 +112,13 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
"Each specific ScanWorkflow must implement an acquisition routine"
)
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
raise NotImplementedError(
"Each scan workflow must implement a settings_ui method."
)
class HistoScanSettingsModel(BaseModel):
"""The settings for a scan with the HistoScanWorkflow.
@ -129,6 +142,16 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
the centre position, scanning only where it detects sample.
"""
display_name: str = lt.property(default="Histo Scan", readonly=True)
ui_blurb: str = lt.property(
default=(
"This scan workflow is optimised for scanning H&E stained biopsies. It"
"spirals out from the starting location, scanning only where it detects"
"sample. It also works well for many other flat, well-featured samples."
),
readonly=True,
)
_settings_model = HistoScanSettingsModel
_planner_cls: type[ScanPlanner] = SmartSpiral
# Thing Slots
@ -428,3 +451,24 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
focus_height = None
return imaged, focus_height
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
return [
property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"),
property_control_for(
self, "skip_background", label="Detect and Skip Empty Fields "
),
property_control_for(
self, "stack_images_to_save", label="Images in Stack to Save"
),
property_control_for(
self,
"stack_min_images_to_test",
label="Minimum number of images to test for focus",
),
property_control_for(self, "stack_dz", label="Stack dz (steps)"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
property_control_for(self, "max_range", label="Maximum Distance (steps)"),
]

View file

@ -1,8 +1,8 @@
"""The core sample scanning functionality for the OpenFlexure Microscope.
SmartScan provides sample scanning functionality including automatic background
detection (via the ``CameraThing``) and automatic path planning via
`scan_planners`. It manages the directories of past scans via `scan_directories`.
SmartScan provides sample scanning functionality. This functionality can be customised
by different ``ScanWorkflow`` Things which control the path planning and acquisition
routines. It manages the directories of past scans via `scan_directories`.
It also controls external processes for live stitching composite images, and
the creation of the final stitched images.
"""
@ -20,6 +20,7 @@ from typing import (
Mapping,
Optional,
ParamSpec,
Self,
TypeVar,
)
@ -30,10 +31,11 @@ from pydantic import BaseModel, PlainSerializer
import labthings_fastapi as lt
from openflexure_microscope_server import scan_directories, stitching
from openflexure_microscope_server.utilities import coerce_thing_selector
# Things
from .camera import BaseCamera
from .scan_workflows import HistoScanWorkflow, ScanWorkflow
from .scan_workflows import ScanWorkflow
from .stage import BaseStage
T = TypeVar("T")
@ -127,10 +129,13 @@ class SmartScanThing(lt.Thing):
_cam: BaseCamera = lt.thing_slot()
_stage: BaseStage = lt.thing_slot()
_workflow: HistoScanWorkflow = lt.thing_slot()
_all_workflows: Mapping[str, ScanWorkflow] = lt.thing_slot()
def __init__(
self, thing_server_interface: lt.ThingServerInterface, scans_folder: str
self,
thing_server_interface: lt.ThingServerInterface,
scans_folder: str,
default_workflow: Optional[str],
) -> None:
"""Initialise a SmartScanThing saving to and loading from the input directory.
@ -141,6 +146,36 @@ class SmartScanThing(lt.Thing):
super().__init__(thing_server_interface)
self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder)
self._scan_lock = threading.Lock()
self._default_workflow = default_workflow
self._workflow_name: Optional[str] = None
def __enter__(self) -> Self:
"""Open hardware connection when the Thing context manager is opened."""
self._workflow_name = coerce_thing_selector(
thing_mapping=self._all_workflows,
selected=self.workflow_name,
default=self._default_workflow,
)
return self
# Note that the default detector name is set at init. This is over written if
# setting is loaded from disk.
@lt.setting
def workflow_name(self) -> Optional[str]:
"""The name of the scan workflow selector."""
return self._workflow_name
@workflow_name.setter
def _set_workflow_name(self, name: str) -> None:
"""Validate and set workflow_name."""
if name not in self._all_workflows:
self.logger.warning(f"{name} is not a valid scan workflow name.")
self._workflow_name = name
@property
def _workflow(self) -> ScanWorkflow:
"""The active scan workflow object."""
return self._all_workflows[self.workflow_name]
_ongoing_scan: Optional[scan_directories.ScanDirectory] = None