diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index d77ac823..a07f7da8 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -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) diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py index 404165da..545d23d1 100644 --- a/tests/unit_tests/test_smart_scan.py +++ b/tests/unit_tests/test_smart_scan.py @@ -92,7 +92,7 @@ def test_initial_properties(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.latest_scan_name is None + assert smart_scan_thing.latest_scan_live_details is None @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() ) - result, logs, calls = check_run_scan(scan_thing, caplog) + result, logs, calls = check_run_scan(scan_thing, caplog, InvocationCancelledError) assert result == "cancelled by user" # 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, "main_scan_loop_calls": 1, "return_to_start_calls": 1, - "perform_final_stitch_calls": 1, + "perform_final_stitch_calls": 0, "save_scan_data_calls": 2, } assert calls == expected_calls_numbers diff --git a/webapp/src/components/labThingsComponents/actionButton.vue b/webapp/src/components/labThingsComponents/actionButton.vue index a33319fb..2f1e9b16 100644 --- a/webapp/src/components/labThingsComponents/actionButton.vue +++ b/webapp/src/components/labThingsComponents/actionButton.vue @@ -79,6 +79,11 @@ export default { required: false, default: "Submit", }, + cancelLabel: { + type: String, + required: false, + default: "Cancel", + }, canTerminate: { type: Boolean, required: false, @@ -158,7 +163,9 @@ export default { return this.isDisabled || this.isBroken || (this.taskRunning && !this.canTerminate); }, 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() { const classes = []; diff --git a/webapp/src/components/tabContentComponents/actionTab.vue b/webapp/src/components/tabContentComponents/actionTab.vue index 36570daf..27389976 100644 --- a/webapp/src/components/tabContentComponents/actionTab.vue +++ b/webapp/src/components/tabContentComponents/actionTab.vue @@ -64,8 +64,10 @@ mode. :force-id="taskId" :force-url="taskUrl" submit-label="" + :cancel-label="cancelLabel" :can-terminate="true" @completed="$emit('completed')" + @finished="$emit('finished')" @update:task-status="taskStatus = $event" @update:progress="progress = $event" @update:log="log = $event" @@ -131,9 +133,14 @@ export default { required: false, default: false, }, + cancelLabel: { + type: String, + required: false, + default: "Cancel", + }, }, - emits: ["closeTask", "completed", "actionStartedExternally"], + emits: ["closeTask", "completed", "finished", "actionStartedExternally"], data() { return { diff --git a/webapp/src/components/tabContentComponents/slideScanContent.vue b/webapp/src/components/tabContentComponents/slideScanContent.vue index 6ab945f9..d14c5fab 100644 --- a/webapp/src/components/tabContentComponents/slideScanContent.vue +++ b/webapp/src/components/tabContentComponents/slideScanContent.vue @@ -6,7 +6,8 @@ :task-url="taskUrl" :task-info-title="infoPaneTitle" :task-info-stream="displayImageOnRight" - @completed="onScanCompleted" + :cancel-label="cancelLabel" + @finished="onScanCompleted" @close-task="closeTask" @action-started-externally="startScanning" > @@ -39,7 +40,7 @@
-

Scan ID: {{ lastScanName }}

+
+

Scan ID: {{ lastScanName }}

+

Images captured: {{ imageCount }}

+
@@ -79,6 +83,8 @@ export default { taskId: null, taskUrl: null, lastStitchedImage: null, + imageCount: null, + scanPhase: null, scanComplete: false, scan_name: "", // This adds the parent name as value for prop streamId @@ -98,6 +104,11 @@ export default { if (!this.displayImageOnRight) return null; return "Live stitching preview"; }, + cancelLabel() { + if (!this.scanPhase) return "Cancel"; + if (this.scanPhase === "Complete") return "Cancel"; + return `Cancel ${this.scanPhase}`; + }, }, methods: { @@ -123,6 +134,8 @@ export default { startScanning(id, url) { this.lastStitchedImage = null; this.lastScanName = null; + this.imageCount = 0; + this.scanPhase = null; this.taskId = id; this.taskUrl = url; this.scanComplete = false; @@ -135,17 +148,27 @@ export default { this.taskId = null; this.taskUrl = null; this.lastStitchedImage = null; + this.imageCount = null; + this.scanPhase = null; this.scanComplete = false; }, async pollScan() { if (!this.scanComplete) { // 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) { 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 } }, @@ -163,4 +186,14 @@ export default { }; - +