Merge branch 'live-scan-details' into 'v3'

Return more live scan details for the UI

See merge request openflexure/openflexure-microscope-server!641
This commit is contained in:
Joe Knapper 2026-07-06 14:03:58 +00:00
commit dcf5751310
5 changed files with 125 additions and 23 deletions

View file

@ -17,6 +17,7 @@ from typing import (
Any, Any,
Callable, Callable,
Concatenate, Concatenate,
Literal,
Mapping, Mapping,
Optional, Optional,
ParamSpec, ParamSpec,
@ -43,6 +44,7 @@ from .stage import BacklashCompensation, BaseStage
T = TypeVar("T") T = TypeVar("T")
P = ParamSpec("P") P = ParamSpec("P")
MIN_IMAGES_TO_STITCH = 2
# This allows ActiveScanData to hold arbitrary workflow settings models during a scan. # This allows ActiveScanData to hold arbitrary workflow settings models during a scan.
AnyModel = Annotated[ AnyModel = Annotated[
@ -51,6 +53,15 @@ AnyModel = Annotated[
] ]
class LiveScanDetails(BaseModel):
"""Details of ongoing scan used for the UI."""
name: str
stitch_timestamp: Optional[float] = None
image_count: int = 0
scan_phase: Literal["Setup", "Scanning", "Stitching", "Complete"] = "Setup"
class ActiveScanData(scan_directories.BaseScanData): class ActiveScanData(scan_directories.BaseScanData):
"""A model for the scan data during an ongoing scan. """A model for the scan data during an ongoing scan.
@ -276,12 +287,21 @@ class SmartScanThing(OFMThing):
_preview_stitcher: Optional[stitching.PreviewStitcher] = None _preview_stitcher: Optional[stitching.PreviewStitcher] = None
_latest_scan_name: Optional[str] = None _latest_scan_live_details: Optional[LiveScanDetails] = None
@lt.property @lt.property
def latest_scan_name(self) -> Optional[str]: def latest_scan_live_details(self) -> Optional[LiveScanDetails]:
"""The name of the last scan to be started.""" """The details of any ongoing scan, or the previous scan if no scan ongoing."""
return self._latest_scan_name if self._latest_scan_live_details is None:
return None
# Update image count and stitch timestamp if scan is ongoing.
if self._scan_data is not None:
self._latest_scan_live_details.image_count = self._scan_data.image_count
# Use walrus operator to not need to read filesystem timestamp twice
if (stitch_time := self.latest_preview_stitch_time) is not None:
self._latest_scan_live_details.stitch_timestamp = stitch_time
return self._latest_scan_live_details
@lt.action @lt.action
def sample_scan(self, scan_name: str = "") -> None: def sample_scan(self, scan_name: str = "") -> None:
@ -305,7 +325,9 @@ class SmartScanThing(OFMThing):
workflow.check_before_start(scan_name) workflow.check_before_start(scan_name)
self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name) self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name)
self._latest_scan_name = self.ongoing_scan.name self._latest_scan_live_details = LiveScanDetails(
name=self.ongoing_scan.name
)
self._run_scan(workflow) self._run_scan(workflow)
# Save any final messages. # Save any final messages.
self._ongoing_scan.save_scan_log(self) self._ongoing_scan.save_scan_log(self)
@ -327,6 +349,18 @@ class SmartScanThing(OFMThing):
# Reraise so the action reports as cancelled if stitching is cancelled. # Reraise so the action reports as cancelled if stitching is cancelled.
raise e raise e
finally: finally:
# Save some final details but not set to the base model until after
# resetting values and unlocking in case of Pydantic errors.
final_image_count = (
self._scan_data.image_count if self._scan_data is not None else None
)
last_timestamp = (
self.latest_preview_stitch_time
if self._preview_stitcher is not None
else None
)
# However the scan finishes, unset all variables and release lock # However the scan finishes, unset all variables and release lock
self._ongoing_scan = None self._ongoing_scan = None
self._scan_data = None self._scan_data = None
@ -334,6 +368,14 @@ class SmartScanThing(OFMThing):
# Ensure any PreviewStitcher created cannot be reused. # Ensure any PreviewStitcher created cannot be reused.
self._preview_stitcher = None self._preview_stitcher = None
# Set the final details so they persist until the next scan
if self._latest_scan_live_details is not None:
if final_image_count is not None:
self._latest_scan_live_details.image_count = final_image_count
if last_timestamp is not None:
self._latest_scan_live_details.stitch_timestamp = last_timestamp
self._latest_scan_live_details.scan_phase = "Complete"
# Remove any scan folders containing zero images. # Remove any scan folders containing zero images.
self.purge_empty_scans() self.purge_empty_scans()
@ -409,7 +451,10 @@ class SmartScanThing(OFMThing):
# This scan can't stitch. # This scan can't stitch.
return return
# Begin stitching once two images are captured # Begin stitching once two images are captured
if self.scan_data.image_count >= 2 and not self._preview_stitcher.running: if (
self.scan_data.image_count >= MIN_IMAGES_TO_STITCH
and not self._preview_stitcher.running
):
self._preview_stitcher.start() self._preview_stitcher.start()
@_scan_running @_scan_running
@ -444,14 +489,20 @@ class SmartScanThing(OFMThing):
overlap=stitching_settings.overlap, overlap=stitching_settings.overlap,
correlation_resize=stitching_settings.correlation_resize, correlation_resize=stitching_settings.correlation_resize,
) )
if self._latest_scan_live_details is not None:
self._latest_scan_live_details.scan_phase = "Scanning"
# 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)
self._save_final_scan_data(scan_result="success") self._save_final_scan_data(scan_result="success")
except lt.exceptions.InvocationCancelledError: except lt.exceptions.InvocationCancelledError as e:
self.logger.info("Stopping scan because it was cancelled.") self.logger.info("Stopping scan because it was cancelled.")
self._save_final_scan_data(scan_result="cancelled by user") self._save_final_scan_data(scan_result="cancelled by user")
if self.scan_data.image_count < MIN_IMAGES_TO_STITCH:
# If too few images to stitch then, return to the start and report
# cancelled
self._return_to_starting_position()
raise e
except Exception as e: except Exception as e:
err_name = type(e).__name__ err_name = type(e).__name__
if self._scan_data is not None: if self._scan_data is not None:
@ -544,6 +595,8 @@ class SmartScanThing(OFMThing):
@_scan_running @_scan_running
def _perform_final_stitch(self) -> None: def _perform_final_stitch(self) -> None:
"""Update the scan zip and perform final stitch of the data.""" """Update the scan zip and perform final stitch of the data."""
if self._latest_scan_live_details is not None:
self._latest_scan_live_details.scan_phase = "Stitching"
if self.scan_data.image_count <= 1: if self.scan_data.image_count <= 1:
self.logger.info("Not performing a stitch as not enough images captured") self.logger.info("Not performing a stitch as not enough images captured")
return return
@ -667,14 +720,16 @@ class SmartScanThing(OFMThing):
@property @property
def latest_preview_stitch_path(self) -> Optional[str]: def latest_preview_stitch_path(self) -> Optional[str]:
"""The path of the latest preview stitched image, or None if not available.""" """The path of the latest preview stitched image, or None if not available."""
if self.latest_scan_name is None: if self._latest_scan_live_details is None:
return None return None
return self._scan_dir_manager.get_file_path_from_img_dir( return self._scan_dir_manager.get_file_path_from_img_dir(
scan_name=self.latest_scan_name, filename="preview.jpg", check_exists=True scan_name=self._latest_scan_live_details.name,
filename="preview.jpg",
check_exists=True,
) )
@lt.property @property
def latest_preview_stitch_time(self) -> Optional[float]: def latest_preview_stitch_time(self) -> Optional[float]:
"""The modification time of the latest preview image, to allow live updating. """The modification time of the latest preview image, to allow live updating.
@ -760,6 +815,6 @@ class SmartScanThing(OFMThing):
# Use the scan list (the data read by the gallery) to find any scans that # Use the scan list (the data read by the gallery) to find any scans that
# need stitching. # need stitching.
for scan in self.get_data_for_gallery(): for scan in self.get_data_for_gallery():
if scan.dzi is None and scan.number_of_images >= 2: if scan.dzi is None and scan.number_of_images >= MIN_IMAGES_TO_STITCH:
self.logger.info(f"Stitching {scan.name}") self.logger.info(f"Stitching {scan.name}")
self.stitch_scan(scan_name=scan.name) self.stitch_scan(scan_name=scan.name)

View file

@ -92,7 +92,7 @@ def test_initial_properties(entered_smart_scan_thing):
""" """
smart_scan_thing = entered_smart_scan_thing smart_scan_thing = entered_smart_scan_thing
assert smart_scan_thing._scan_dir_manager.base_dir == SCAN_DIR assert smart_scan_thing._scan_dir_manager.base_dir == SCAN_DIR
assert smart_scan_thing.latest_scan_name is None assert smart_scan_thing.latest_scan_live_details is None
@dataclass @dataclass
@ -559,7 +559,7 @@ def test_run_scan_cancelled(scan_thing_mocked_for_run_scan, caplog, mocker):
scan_thing, "_main_scan_loop", side_effect=InvocationCancelledError() scan_thing, "_main_scan_loop", side_effect=InvocationCancelledError()
) )
result, logs, calls = check_run_scan(scan_thing, caplog) result, logs, calls = check_run_scan(scan_thing, caplog, InvocationCancelledError)
assert result == "cancelled by user" assert result == "cancelled by user"
# No logs at warning level. # No logs at warning level.
@ -571,7 +571,7 @@ def test_run_scan_cancelled(scan_thing_mocked_for_run_scan, caplog, mocker):
"cam_change_streaming_mode_calls": 2, "cam_change_streaming_mode_calls": 2,
"main_scan_loop_calls": 1, "main_scan_loop_calls": 1,
"return_to_start_calls": 1, "return_to_start_calls": 1,
"perform_final_stitch_calls": 1, "perform_final_stitch_calls": 0,
"save_scan_data_calls": 2, "save_scan_data_calls": 2,
} }
assert calls == expected_calls_numbers assert calls == expected_calls_numbers

View file

@ -79,6 +79,11 @@ export default {
required: false, required: false,
default: "Submit", default: "Submit",
}, },
cancelLabel: {
type: String,
required: false,
default: "Cancel",
},
canTerminate: { canTerminate: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -158,7 +163,9 @@ export default {
return this.isDisabled || this.isBroken || (this.taskRunning && !this.canTerminate); return this.isDisabled || this.isBroken || (this.taskRunning && !this.canTerminate);
}, },
buttonLabel() { buttonLabel() {
return this.taskRunning && this.canTerminate ? "Cancel" : this.submitLabel; // If the action is running (and can terminate) then show the cancel label, otherwise
// show the submit label.
return this.taskRunning && this.canTerminate ? this.cancelLabel : this.submitLabel;
}, },
buttonClasses() { buttonClasses() {
const classes = []; const classes = [];

View file

@ -64,8 +64,10 @@ mode.
:force-id="taskId" :force-id="taskId"
:force-url="taskUrl" :force-url="taskUrl"
submit-label="" submit-label=""
:cancel-label="cancelLabel"
:can-terminate="true" :can-terminate="true"
@completed="$emit('completed')" @completed="$emit('completed')"
@finished="$emit('finished')"
@update:task-status="taskStatus = $event" @update:task-status="taskStatus = $event"
@update:progress="progress = $event" @update:progress="progress = $event"
@update:log="log = $event" @update:log="log = $event"
@ -131,9 +133,14 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
cancelLabel: {
type: String,
required: false,
default: "Cancel",
},
}, },
emits: ["closeTask", "completed", "actionStartedExternally"], emits: ["closeTask", "completed", "finished", "actionStartedExternally"],
data() { data() {
return { return {

View file

@ -6,7 +6,8 @@
:task-url="taskUrl" :task-url="taskUrl"
:task-info-title="infoPaneTitle" :task-info-title="infoPaneTitle"
:task-info-stream="displayImageOnRight" :task-info-stream="displayImageOnRight"
@completed="onScanCompleted" :cancel-label="cancelLabel"
@finished="onScanCompleted"
@close-task="closeTask" @close-task="closeTask"
@action-started-externally="startScanning" @action-started-externally="startScanning"
> >
@ -39,7 +40,7 @@
<div class="uk-width-1-1 uk-flex uk-flex-center"> <div class="uk-width-1-1 uk-flex uk-flex-center">
<div class="uk-width-2-3"> <div class="uk-width-2-3">
<action-button <action-button
v-if="scanComplete" v-if="scanComplete && imageCount >= 1"
thing="smart_scan" thing="smart_scan"
action="download_zip" action="download_zip"
submit-label="Download ZIP" submit-label="Download ZIP"
@ -51,7 +52,10 @@
/> />
</div> </div>
</div> </div>
<h3 v-if="scanning" class="uk-margin-left">Scan ID: {{ lastScanName }}</h3> <div class="uk-margin-left info-headings">
<h3 v-if="scanning" class="uk-margin-left">Scan ID: {{ lastScanName }}</h3>
<h3 v-if="scanning" class="uk-margin-left">Images captured: {{ imageCount }}</h3>
</div>
</template> </template>
</actionTab> </actionTab>
</template> </template>
@ -79,6 +83,8 @@ export default {
taskId: null, taskId: null,
taskUrl: null, taskUrl: null,
lastStitchedImage: null, lastStitchedImage: null,
imageCount: null,
scanPhase: null,
scanComplete: false, scanComplete: false,
scan_name: "", scan_name: "",
// This adds the parent name as value for prop streamId // This adds the parent name as value for prop streamId
@ -98,6 +104,11 @@ export default {
if (!this.displayImageOnRight) return null; if (!this.displayImageOnRight) return null;
return "Live stitching preview"; return "Live stitching preview";
}, },
cancelLabel() {
if (!this.scanPhase) return "Cancel";
if (this.scanPhase === "Complete") return "Cancel";
return `Cancel ${this.scanPhase}`;
},
}, },
methods: { methods: {
@ -123,6 +134,8 @@ export default {
startScanning(id, url) { startScanning(id, url) {
this.lastStitchedImage = null; this.lastStitchedImage = null;
this.lastScanName = null; this.lastScanName = null;
this.imageCount = 0;
this.scanPhase = null;
this.taskId = id; this.taskId = id;
this.taskUrl = url; this.taskUrl = url;
this.scanComplete = false; this.scanComplete = false;
@ -135,17 +148,27 @@ export default {
this.taskId = null; this.taskId = null;
this.taskUrl = null; this.taskUrl = null;
this.lastStitchedImage = null; this.lastStitchedImage = null;
this.imageCount = null;
this.scanPhase = null;
this.scanComplete = false; this.scanComplete = false;
}, },
async pollScan() { async pollScan() {
if (!this.scanComplete) { if (!this.scanComplete) {
// while the scan is running // while the scan is running
let mtime = await this.readThingProperty("smart_scan", "latest_preview_stitch_time", true); let scanDetails = await this.readThingProperty(
"smart_scan",
"latest_scan_live_details",
true,
);
this.lastScanName = scanDetails.name;
let mtime = scanDetails.stitch_timestamp;
this.imageCount = scanDetails.image_count;
this.scanPhase = scanDetails.scan_phase;
if (mtime !== null) { if (mtime !== null) {
this.lastStitchedImage = `${this.baseUri}/smart_scan/latest_preview_stitch.jpg?t=${mtime}`; this.lastStitchedImage = `${this.baseUri}/smart_scan/latest_preview_stitch.jpg?t=${mtime}`;
} }
this.lastScanName = await this.readThingProperty("smart_scan", "latest_scan_name", true);
setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped
} }
}, },
@ -163,4 +186,14 @@ export default {
}; };
</script> </script>
<style scoped></style> <style scoped>
.info-headings h3 {
margin-top: 20px;
margin-bottom: 5px;
}
/* If one heading follows another don't have a large top margin. */
.info-headings h3 + h3 {
margin-top: 5px;
}
</style>