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

@ -8,7 +8,8 @@
"smart_scan": { "smart_scan": {
"class": "openflexure_microscope_server.things.smart_scan:SmartScanThing", "class": "openflexure_microscope_server.things.smart_scan:SmartScanThing",
"kwargs": { "kwargs": {
"scans_folder": "./openflexure/scans/" "scans_folder": "./openflexure/scans/",
"default_workflow": "histo_scan_workflow"
} }
}, },
"histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow",

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 import BaseCamera
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper 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) SettingModelType = TypeVar("SettingModelType", bound=BaseModel)
@ -44,6 +45,11 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
scan planning, acquisition routine. 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] _settings_model: type[SettingModelType]
# All workflows must have a set class for scan planning # 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" "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): class HistoScanSettingsModel(BaseModel):
"""The settings for a scan with the HistoScanWorkflow. """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. 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 _settings_model = HistoScanSettingsModel
_planner_cls: type[ScanPlanner] = SmartSpiral _planner_cls: type[ScanPlanner] = SmartSpiral
# Thing Slots # Thing Slots
@ -428,3 +451,24 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
focus_height = None focus_height = None
return imaged, focus_height 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. """The core sample scanning functionality for the OpenFlexure Microscope.
SmartScan provides sample scanning functionality including automatic background SmartScan provides sample scanning functionality. This functionality can be customised
detection (via the ``CameraThing``) and automatic path planning via by different ``ScanWorkflow`` Things which control the path planning and acquisition
`scan_planners`. It manages the directories of past scans via `scan_directories`. routines. It manages the directories of past scans via `scan_directories`.
It also controls external processes for live stitching composite images, and It also controls external processes for live stitching composite images, and
the creation of the final stitched images. the creation of the final stitched images.
""" """
@ -20,6 +20,7 @@ from typing import (
Mapping, Mapping,
Optional, Optional,
ParamSpec, ParamSpec,
Self,
TypeVar, TypeVar,
) )
@ -30,10 +31,11 @@ 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
from openflexure_microscope_server.utilities import coerce_thing_selector
# Things # Things
from .camera import BaseCamera from .camera import BaseCamera
from .scan_workflows import HistoScanWorkflow, ScanWorkflow from .scan_workflows import ScanWorkflow
from .stage import BaseStage from .stage import BaseStage
T = TypeVar("T") T = TypeVar("T")
@ -127,10 +129,13 @@ class SmartScanThing(lt.Thing):
_cam: BaseCamera = lt.thing_slot() _cam: BaseCamera = lt.thing_slot()
_stage: BaseStage = lt.thing_slot() _stage: BaseStage = lt.thing_slot()
_workflow: HistoScanWorkflow = lt.thing_slot() _all_workflows: Mapping[str, ScanWorkflow] = lt.thing_slot()
def __init__( 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: ) -> None:
"""Initialise a SmartScanThing saving to and loading from the input directory. """Initialise a SmartScanThing saving to and loading from the input directory.
@ -141,6 +146,36 @@ class SmartScanThing(lt.Thing):
super().__init__(thing_server_interface) super().__init__(thing_server_interface)
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()
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 _ongoing_scan: Optional[scan_directories.ScanDirectory] = None

View file

@ -1,51 +1,32 @@
<template> <template>
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove"> <div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component uk-padding-small"> <div class="control-component uk-padding-small">
<div v-show="!scanning" class="uk-padding-small"> <div v-show="!scanning" v-observe-visibility="visibilityChanged" class="uk-padding-small">
<ul uk-accordion="multiple: true"> <ul uk-accordion="multiple: true">
<li> <li>
<a class="uk-accordion-title" href="#">Configure</a> <a class="uk-accordion-title" href="#">Scan Settings</a>
<div class="uk-accordion-content">
<h4 v-if="workflowDisplayName" class="workflow-name">
{{ workflowDisplayName }}
</h4>
<p class="workflow-blurb">{{ workflowBlurb }}</p>
<div
v-for="(setting, index) in workflowSettings"
:key="'detector_setting' + index"
class="uk-margin"
>
<server-specified-property-control :property-data="setting" />
</div>
</div>
</li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Stitching Settings</a>
<div class="uk-accordion-content"> <div class="uk-accordion-content">
<div class="uk-margin"> <div class="uk-margin">
<propertyControl <propertyControl
thing-name="smart_scan" thing-name="smart_scan"
property-name="max_range" property-name="stitch_automatically"
label="Maximum Distance (steps)" label="Automatically Stitch Images Together"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="autofocus_dz"
label="Autofocus Range (steps)"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="autofocus"
property-name="stack_dz"
label="Stack dz (steps)"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="autofocus"
property-name="stack_images_to_save"
label="Images in Stack to Save"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="autofocus"
property-name="stack_min_images_to_test"
label="Minimum number of images to test for focus"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="overlap"
label="Image Overlap (0-1)"
/> />
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
@ -57,25 +38,6 @@
</div> </div>
</div> </div>
</li> </li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Scan Settings</a>
<div class="uk-accordion-content">
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="skip_background"
label="Detect and Skip Empty Fields"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="stitch_automatically"
label="Automatically Stitch Images Together"
/>
</div>
</div>
</li>
</ul> </ul>
<label class="uk-form-label" for="form-stacked-text">Sample ID</label> <label class="uk-form-label" for="form-stacked-text">Sample ID</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
@ -149,6 +111,7 @@
<script> <script>
import streamDisplay from "./streamContent.vue"; import streamDisplay from "./streamContent.vue";
import propertyControl from "../labThingsComponents/propertyControl.vue"; import propertyControl from "../labThingsComponents/propertyControl.vue";
import ServerSpecifiedPropertyControl from "../labThingsComponents/serverSpecifiedPropertyControl.vue";
import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue"; import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue";
import actionProgressBar from "../labThingsComponents/actionProgressBar.vue"; import actionProgressBar from "../labThingsComponents/actionProgressBar.vue";
import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue"; import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
@ -160,6 +123,7 @@ export default {
components: { components: {
streamDisplay, streamDisplay,
propertyControl, propertyControl,
ServerSpecifiedPropertyControl,
actionLogDisplay, actionLogDisplay,
actionProgressBar, actionProgressBar,
MiniStreamDisplay, MiniStreamDisplay,
@ -177,6 +141,10 @@ export default {
log: [], log: [],
lastStitchedImage: null, lastStitchedImage: null,
scan_name: "", scan_name: "",
workflowName: undefined,
workflowSettings: [],
workflowDisplayName: undefined,
workflowBlurb: undefined,
}; };
}, },
@ -189,12 +157,49 @@ export default {
}, },
}, },
async created() {
this.readSettings();
},
methods: { methods: {
visibilityChanged(isVisible) {
if (isVisible) {
this.readSettings();
}
},
async readSettings() {
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name");
if (this.workflowName) {
this.ready = await this.readThingProperty(this.workflowName, "ready");
this.workflowSettings = await this.readThingProperty(this.workflowName, "settings_ui");
console.log(this.workflowSettings);
this.workflowDisplayName = await this.readThingProperty(this.workflowName, "display_name");
this.workflowBlurb = await this.readThingProperty(this.workflowName, "ui_blurb");
}
},
onScanError: function (error) { onScanError: function (error) {
this.scanRunning = false; this.scanRunning = false;
this.modalError(error); this.modalError(error);
}, },
correlateCurrentScan() {}, /**
* Transition the UI into "scanning" mode and begin polling for scan updates.
*
* IMPORTANT:
* This method does NOT start a scan on the server.
*
* The <ActionButton> component is responsible for:
* - initiating the scan action on the backend when the user clicks the button
* - detecting and resuming an already-running scan when the page loads
*
* As a result, this method may be invoked in two cases:
* 1. Immediately after the user clicks "Start Smart Scan"
* 2. Automatically on page load if <ActionButton> detects an ongoing scan
*
* This function only:
* - updates local UI state to reflect that scanning is in progress
* - clears any previous preview image
* - starts the polling loop that fetches scan progress and preview images
*/
startScanning() { startScanning() {
this.lastStitchedImage = null; this.lastStitchedImage = null;
this.scanning = true; this.scanning = true;
@ -233,4 +238,10 @@ export default {
.control-component { .control-component {
width: 33%; width: 33%;
} }
.workflow-name {
margin-bottom: 0.5rem;
}
.workflow-blurb {
color: #a2a2a2;
}
</style> </style>