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,
Callable,
Concatenate,
Literal,
Mapping,
Optional,
ParamSpec,
@ -43,6 +44,7 @@ from .stage import BacklashCompensation, BaseStage
T = TypeVar("T")
P = ParamSpec("P")
MIN_IMAGES_TO_STITCH = 2
# This allows ActiveScanData to hold arbitrary workflow settings models during a scan.
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):
"""A model for the scan data during an ongoing scan.
@ -276,12 +287,21 @@ class SmartScanThing(OFMThing):
_preview_stitcher: Optional[stitching.PreviewStitcher] = None
_latest_scan_name: Optional[str] = None
_latest_scan_live_details: Optional[LiveScanDetails] = None
@lt.property
def latest_scan_name(self) -> Optional[str]:
"""The name of the last scan to be started."""
return self._latest_scan_name
def latest_scan_live_details(self) -> Optional[LiveScanDetails]:
"""The details of any ongoing scan, or the previous scan if no scan ongoing."""
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
def sample_scan(self, scan_name: str = "") -> None:
@ -305,7 +325,9 @@ class SmartScanThing(OFMThing):
workflow.check_before_start(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)
# Save any final messages.
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.
raise e
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
self._ongoing_scan = None
self._scan_data = None
@ -334,6 +368,14 @@ class SmartScanThing(OFMThing):
# Ensure any PreviewStitcher created cannot be reused.
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.
self.purge_empty_scans()
@ -409,7 +451,10 @@ class SmartScanThing(OFMThing):
# This scan can't stitch.
return
# 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()
@_scan_running
@ -444,14 +489,20 @@ class SmartScanThing(OFMThing):
overlap=stitching_settings.overlap,
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!
self._main_scan_loop(workflow)
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._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:
err_name = type(e).__name__
if self._scan_data is not None:
@ -544,6 +595,8 @@ class SmartScanThing(OFMThing):
@_scan_running
def _perform_final_stitch(self) -> None:
"""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:
self.logger.info("Not performing a stitch as not enough images captured")
return
@ -667,14 +720,16 @@ class SmartScanThing(OFMThing):
@property
def latest_preview_stitch_path(self) -> Optional[str]:
"""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 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]:
"""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
# need stitching.
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.stitch_scan(scan_name=scan.name)