From 1aed597f9c95aa930dedc0b5d3274f23654ead43 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 2 Jul 2026 09:49:22 +0100 Subject: [PATCH 1/6] Smart scan return cancelled if cancelled before stitching starts. --- .../things/smart_scan.py | 15 ++++++++++++--- .../components/tabContentComponents/actionTab.vue | 1 + .../tabContentComponents/slideScanContent.vue | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index d8ce6cf8..d4ee2d59 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -43,6 +43,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[ @@ -409,7 +410,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 @@ -449,9 +453,14 @@ class SmartScanThing(OFMThing): 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 centre 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: @@ -760,6 +769,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/webapp/src/components/tabContentComponents/actionTab.vue b/webapp/src/components/tabContentComponents/actionTab.vue index 36570daf..dba0fd56 100644 --- a/webapp/src/components/tabContentComponents/actionTab.vue +++ b/webapp/src/components/tabContentComponents/actionTab.vue @@ -66,6 +66,7 @@ mode. submit-label="" :can-terminate="true" @completed="$emit('completed')" + @finished="$emit('finished')" @update:task-status="taskStatus = $event" @update:progress="progress = $event" @update:log="log = $event" diff --git a/webapp/src/components/tabContentComponents/slideScanContent.vue b/webapp/src/components/tabContentComponents/slideScanContent.vue index 6ab945f9..5ea250b8 100644 --- a/webapp/src/components/tabContentComponents/slideScanContent.vue +++ b/webapp/src/components/tabContentComponents/slideScanContent.vue @@ -6,7 +6,7 @@ :task-url="taskUrl" :task-info-title="infoPaneTitle" :task-info-stream="displayImageOnRight" - @completed="onScanCompleted" + @finished="onScanCompleted" @close-task="closeTask" @action-started-externally="startScanning" > From 18e3978134a766eab3bcc1b8395bedf4842e0a26 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 2 Jul 2026 14:40:34 +0100 Subject: [PATCH 2/6] Send more data back from smart scan to enable if a zip was created --- .../things/smart_scan.py | 52 ++++++++++++++++--- tests/unit_tests/test_smart_scan.py | 2 +- .../tabContentComponents/actionTab.vue | 2 +- .../tabContentComponents/slideScanContent.vue | 37 +++++++++++-- 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index d4ee2d59..7103e69f 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, @@ -52,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. @@ -277,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 san 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: @@ -306,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) @@ -328,6 +349,12 @@ 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 valued and unlocking just in case somehow pydantic errors. + if self._scan_data is not None: + final_image_count = self._scan_data.image_count + if self._preview_stitcher is not None: + last_timestamp = self.latest_preview_stitch_time # However the scan finishes, unset all variables and release lock self._ongoing_scan = None self._scan_data = None @@ -335,6 +362,11 @@ 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 + self._latest_scan_live_details.image_count = final_image_count + 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() @@ -449,6 +481,7 @@ class SmartScanThing(OFMThing): correlation_resize=stitching_settings.correlation_resize, ) + 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") @@ -553,6 +586,7 @@ class SmartScanThing(OFMThing): @_scan_running def _perform_final_stitch(self) -> None: """Update the scan zip and perform final stitch of the data.""" + 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 @@ -676,14 +710,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. diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py index 404165da..481091fb 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 diff --git a/webapp/src/components/tabContentComponents/actionTab.vue b/webapp/src/components/tabContentComponents/actionTab.vue index dba0fd56..bdb5b01e 100644 --- a/webapp/src/components/tabContentComponents/actionTab.vue +++ b/webapp/src/components/tabContentComponents/actionTab.vue @@ -134,7 +134,7 @@ export default { }, }, - 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 5ea250b8..7aa7079f 100644 --- a/webapp/src/components/tabContentComponents/slideScanContent.vue +++ b/webapp/src/components/tabContentComponents/slideScanContent.vue @@ -39,7 +39,7 @@
-

Scan ID: {{ lastScanName }}

+
+

Scan ID: {{ lastScanName }}

+

Images captured: {{ imageCount }}

+
@@ -79,6 +82,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 @@ -123,6 +128,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 +142,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 +180,14 @@ export default { }; - + From cae5a03922c3095fc6190c21e88553c6ea96425e Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 2 Jul 2026 15:04:07 +0100 Subject: [PATCH 3/6] Add "scanning" or "stitching" to the cancel button in smart scan --- webapp/src/components/labThingsComponents/actionButton.vue | 7 ++++++- webapp/src/components/tabContentComponents/actionTab.vue | 6 ++++++ .../components/tabContentComponents/slideScanContent.vue | 6 ++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/labThingsComponents/actionButton.vue b/webapp/src/components/labThingsComponents/actionButton.vue index a33319fb..ba320e4d 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,7 @@ export default { return this.isDisabled || this.isBroken || (this.taskRunning && !this.canTerminate); }, buttonLabel() { - return this.taskRunning && this.canTerminate ? "Cancel" : this.submitLabel; + 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 bdb5b01e..27389976 100644 --- a/webapp/src/components/tabContentComponents/actionTab.vue +++ b/webapp/src/components/tabContentComponents/actionTab.vue @@ -64,6 +64,7 @@ mode. :force-id="taskId" :force-url="taskUrl" submit-label="" + :cancel-label="cancelLabel" :can-terminate="true" @completed="$emit('completed')" @finished="$emit('finished')" @@ -132,6 +133,11 @@ export default { required: false, default: false, }, + cancelLabel: { + type: String, + required: false, + default: "Cancel", + }, }, emits: ["closeTask", "completed", "finished", "actionStartedExternally"], diff --git a/webapp/src/components/tabContentComponents/slideScanContent.vue b/webapp/src/components/tabContentComponents/slideScanContent.vue index 7aa7079f..d14c5fab 100644 --- a/webapp/src/components/tabContentComponents/slideScanContent.vue +++ b/webapp/src/components/tabContentComponents/slideScanContent.vue @@ -6,6 +6,7 @@ :task-url="taskUrl" :task-info-title="infoPaneTitle" :task-info-stream="displayImageOnRight" + :cancel-label="cancelLabel" @finished="onScanCompleted" @close-task="closeTask" @action-started-externally="startScanning" @@ -103,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: { From 26a2e1a5825191eb377a072c71f4fd3a4ac6613c Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 2 Jul 2026 15:36:38 +0100 Subject: [PATCH 4/6] Type narrowing for mypy --- .../things/smart_scan.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 7103e69f..a6da0b51 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -363,9 +363,10 @@ class SmartScanThing(OFMThing): self._preview_stitcher = None # Set the final details so they persist until the next scan - self._latest_scan_live_details.image_count = final_image_count - self._latest_scan_live_details.stitch_timestamp = last_timestamp - self._latest_scan_live_details.scan_phase = "Complete" + if self._latest_scan_live_details is not None: + self._latest_scan_live_details.image_count = final_image_count + 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() @@ -480,8 +481,8 @@ class SmartScanThing(OFMThing): overlap=stitching_settings.overlap, correlation_resize=stitching_settings.correlation_resize, ) - - self._latest_scan_live_details.scan_phase = "Scanning" + 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") @@ -586,7 +587,8 @@ class SmartScanThing(OFMThing): @_scan_running def _perform_final_stitch(self) -> None: """Update the scan zip and perform final stitch of the data.""" - self._latest_scan_live_details.scan_phase = "Stitching" + 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 From d25ebabc773f2a30e698c5250463fecea785abbe Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 2 Jul 2026 16:27:51 +0100 Subject: [PATCH 5/6] Update unit tests for new cancel behaviour --- .../things/smart_scan.py | 20 +++++++++++++------ tests/unit_tests/test_smart_scan.py | 4 ++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index a6da0b51..5e065b32 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -351,10 +351,16 @@ class SmartScanThing(OFMThing): finally: # Save some final details but not set to the base model until after # resetting valued and unlocking just in case somehow pydantic errors. - if self._scan_data is not None: - final_image_count = self._scan_data.image_count - if self._preview_stitcher is not None: - last_timestamp = self.latest_preview_stitch_time + + 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 @@ -364,8 +370,10 @@ class SmartScanThing(OFMThing): # Set the final details so they persist until the next scan if self._latest_scan_live_details is not None: - self._latest_scan_live_details.image_count = final_image_count - self._latest_scan_live_details.stitch_timestamp = last_timestamp + 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. diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py index 481091fb..545d23d1 100644 --- a/tests/unit_tests/test_smart_scan.py +++ b/tests/unit_tests/test_smart_scan.py @@ -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 From 543433f31e31fd32eb6852ffc05f436b16160676 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sat, 4 Jul 2026 21:52:31 +0000 Subject: [PATCH 6/6] Apply suggestions from code review of branch live-scan-details Co-authored-by: Joe Knapper --- src/openflexure_microscope_server/things/smart_scan.py | 6 +++--- webapp/src/components/labThingsComponents/actionButton.vue | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 5e065b32..ea069de4 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -291,7 +291,7 @@ class SmartScanThing(OFMThing): @lt.property def latest_scan_live_details(self) -> Optional[LiveScanDetails]: - """The details of any ongoing scan, or the previous scan if no san ongoing.""" + """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. @@ -350,7 +350,7 @@ class SmartScanThing(OFMThing): raise e finally: # Save some final details but not set to the base model until after - # resetting valued and unlocking just in case somehow pydantic errors. + # 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 @@ -499,7 +499,7 @@ class SmartScanThing(OFMThing): 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 centre and report + # If too few images to stitch then, return to the start and report # cancelled self._return_to_starting_position() raise e diff --git a/webapp/src/components/labThingsComponents/actionButton.vue b/webapp/src/components/labThingsComponents/actionButton.vue index ba320e4d..2f1e9b16 100644 --- a/webapp/src/components/labThingsComponents/actionButton.vue +++ b/webapp/src/components/labThingsComponents/actionButton.vue @@ -163,6 +163,8 @@ export default { return this.isDisabled || this.isBroken || (this.taskRunning && !this.canTerminate); }, buttonLabel() { + // 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() {