From 3e164102f6de7e5789e2390d6526b60bb910caa9 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 21 May 2025 14:03:22 +0100 Subject: [PATCH 01/36] A stack that tests whether the sharpest image is towards the middle --- .../things/autofocus.py | 96 ++++++++++++++++--- .../things/capture.py | 7 +- .../things/smart_scan.py | 36 +++---- 3 files changed, 100 insertions(+), 39 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index dd4a388a..eb3e7305 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -266,6 +266,16 @@ class AutofocusThing(Thing): def stack_images_to_capture(self, value: int) -> None: self.thing_settings["stack_images_to_capture"] = value + @thing_property + def stack_images_to_test(self) -> int: + """The number of images to test for successful focusing in a stack + Defaults to 9, which balances reliability and speed""" + return self.thing_settings.get("stack_images_to_test", 9) + + @stack_images_to_test.setter + def stack_images_to_test(self, value: int) -> None: + self.thing_settings["stack_images_to_test"] = value + @thing_property def stack_dz(self) -> int: """Space in steps between images in a z-stack @@ -287,35 +297,78 @@ class AutofocusThing(Thing): metadata_getter: GetThingStates, capture: CaptureDep, images_dir: str, - stack_dir: str, ) -> None: """Run a z stack, saving all images to stack_dir and copying the central image to stack_dir""" stack_dz = self.stack_dz images_to_capture = self.stack_images_to_capture + images_to_test = self.stack_images_to_test - stack_z_range = stack_dz * (images_to_capture - 1) - stage.move_relative(z=-stack_z_range / 2) + if images_to_test < images_to_capture: + raise RuntimeError( + "Can't capture more images than are tested. Please increase number to test, or decrease number to capture" + ) + if images_to_test % 2 == 0: + raise RuntimeError("Images to test should be odd") - for capture_count in range(images_to_capture): + stack_z_range = stack_dz * (images_to_test - 1) + overshoot = stack_dz * 5 + backlash_correction = 250 + + stage.move_relative(z=-(overshoot + backlash_correction + stack_z_range / 2)) + stage.move_relative(z=backlash_correction) + + continue_stack = True + captures = [] + sharpnesses = [] + heights = [] + capture_count = 0 + starting_height = stage.position["z"] + + while continue_stack: time.sleep(SETTLING_TIME) + stage_location = stage.position + jpeg_path = os.path.join( - stack_dir, - f"{capture_count}.jpeg", + images_dir, + f"{stage_location['x']}_{stage_location['y']}_{stage_location['z']}.jpeg", ) - capture._capture_and_save( - jpeg_path=jpeg_path, + image, metadata = capture._capture_image( cam=cam, - logger=logger, metadata_getter=metadata_getter, ) + captures.append([jpeg_path, image, metadata]) + sharpnesses.append(cam.grab_jpeg_size(stream_name="lores")) + heights.append(stage.position["z"]) + capture_count += 1 # If the stack isn't complete yet, move - if capture_count + 1 < images_to_capture: - stage.move_relative(z=stack_dz) + if capture_count >= images_to_test: + logger.info(sharpnesses[-images_to_test:]) + stack_result = self.test_stack(sharpnesses[-images_to_test:]) - self.copy_central_image_from_stack(images_dir, stack_dir) + if stack_result == "success": + continue_stack = False + elif stack_result == "restart" or capture_count > 20: + captures = [] + sharpnesses = [] + heights = [] + capture_count = 0 + stage.move_absolute( + z=starting_height - stack_dz * (images_to_test - 1) + ) + stage.move_relative(z=stack_dz) + + sharpest_index = np.argmax(sharpnesses[-images_to_test:]) + capture._save_capture( + jpeg_path=captures[-images_to_test:][sharpest_index][0], + image=captures[-images_to_test:][sharpest_index][1], + metadata=captures[-images_to_test:][sharpest_index][2], + logger=logger, + ) + + return heights[-images_to_test:][sharpest_index] def copy_central_image_from_stack( self, @@ -331,3 +384,22 @@ class AutofocusThing(Thing): xy_location = os.path.basename(stack_dir) shutil.copy(central_image, os.path.join(images_dir, f"{xy_location}.jpeg")) + + def test_stack(self, sharpnesses: list): + """Test a list of sharpnesses, to decide whether the focal plane is within them""" + sharpest_index = np.argmax(sharpnesses) + sharpness_length = len(sharpnesses) + + if sharpness_length == 1: + return "success" + if sharpness_length == 3: + if sharpest_index == 1: + return "success" + + exclusion_range = 3 + + if sharpest_index < exclusion_range: + return "restart" + if sharpest_index >= len(sharpnesses) - exclusion_range: + return "continue" + return "success" diff --git a/src/openflexure_microscope_server/things/capture.py b/src/openflexure_microscope_server/things/capture.py index 214c05b9..43307f02 100644 --- a/src/openflexure_microscope_server/things/capture.py +++ b/src/openflexure_microscope_server/things/capture.py @@ -1,4 +1,3 @@ -import numpy as np from PIL import Image import time import piexif @@ -48,7 +47,8 @@ class CaptureThing(Thing): f"Acquired {jpeg_path} in {acquisition_duration}s then {saving_duration}s saving to disk" ) - def _capture_image(self, cam, metadata_getter) -> tuple[np.ndarray, dict]: + @thing_action + def _capture_image(self, cam, metadata_getter): """Capture an image in memory and return it with metadata CaptureError raised if the capture fails for any reason returns tuple with numpy array of image data, and dict of metadata @@ -60,10 +60,11 @@ class CaptureThing(Thing): raise CaptureError("An error occurred while capturing") from e return image, metadata + @thing_action def _save_capture( self, jpeg_path: str, - image: np.ndarray, + image, metadata: dict, logger: InvocationLogger, ) -> None: diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 50a8c442..92f5f5aa 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -620,6 +620,8 @@ class SmartScanThing(Thing): self._manage_stitching_threads() next_pos_xy, z_est = route_planner.get_next_location_and_z_estimate() + self._scan_logger.info(z_est) + self._scan_logger.info(self._stage.position["z"]) new_pos_xyz = self._move_to_next_point(next_pos_xy, z_est) current_pos_xyz = ( new_pos_xyz[0], @@ -642,32 +644,18 @@ class SmartScanThing(Thing): self._scan_logger.info(msg) continue - focused = False - if self._scan_data["autofocus_on"]: - self._autofocus.looping_autofocus( - dz=self._scan_data["autofocus_dz"], start="centre" - ) - current_pos_xyz = ( - new_pos_xyz[0], - new_pos_xyz[1], - self._stage.position["z"], - ) - # Without a test for autofocus success, assume it worked - focused = True + focused_height = self._autofocus.run_z_stack( + images_dir=self._ongoing_scan_images_dir, + ) + + current_pos_xyz = ( + new_pos_xyz[0], + new_pos_xyz[1], + focused_height, + ) route_planner.mark_location_visited( - current_pos_xyz, imaged=True, focused=focused - ) - - site_folder = os.path.join( - self._ongoing_scan_images_dir, - "stacks", - f"{new_pos_xyz[0]}_{new_pos_xyz[1]}", - ) - os.makedirs(site_folder, exist_ok=True) - self._autofocus.run_z_stack( - images_dir=self._ongoing_scan_images_dir, - stack_dir=site_folder, + current_pos_xyz, imaged=True, focused=True ) # increment capure counter as thread has completed From 712f5244149c50b42df5320ab45c991017dbcf63 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 10 Jun 2025 18:24:12 +0100 Subject: [PATCH 02/36] Looping autofocus on failed smart stack --- .../things/autofocus.py | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index eb3e7305..0bfd5c19 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -12,8 +12,6 @@ import logging import time from typing import Annotated, Mapping, Optional, Sequence import os -import shutil -import glob from fastapi import Depends import numpy as np @@ -297,6 +295,7 @@ class AutofocusThing(Thing): metadata_getter: GetThingStates, capture: CaptureDep, images_dir: str, + sharpness_monitor: SharpnessMonitorDep, ) -> None: """Run a z stack, saving all images to stack_dir and copying the central image to stack_dir""" @@ -355,9 +354,16 @@ class AutofocusThing(Thing): sharpnesses = [] heights = [] capture_count = 0 - stage.move_absolute( - z=starting_height - stack_dz * (images_to_test - 1) + stage.move_absolute(z=starting_height - stack_z_range) + self.looping_autofocus( + stage=stage, + sharpness_monitor=sharpness_monitor, + dz=2000, ) + stage.move_relative( + z=-(overshoot + backlash_correction + stack_z_range / 2) + ) + stage.move_relative(z=backlash_correction) stage.move_relative(z=stack_dz) sharpest_index = np.argmax(sharpnesses[-images_to_test:]) @@ -370,21 +376,6 @@ class AutofocusThing(Thing): return heights[-images_to_test:][sharpest_index] - def copy_central_image_from_stack( - self, - images_dir: str, - stack_dir: str, - ): - """Gets a list of images in a folder (stack_dir), sorts them, and copies the central image - to images dir.""" - image_list = glob.glob(os.path.join(stack_dir, "*")) - image_list.sort() - central_index = (len(image_list) - 1) // 2 - central_image = image_list[central_index] - xy_location = os.path.basename(stack_dir) - - shutil.copy(central_image, os.path.join(images_dir, f"{xy_location}.jpeg")) - def test_stack(self, sharpnesses: list): """Test a list of sharpnesses, to decide whether the focal plane is within them""" sharpest_index = np.argmax(sharpnesses) From 0671f2d1be5cc28f5dfe5d2208e4d51f06aacb6c Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 16 Jun 2025 17:03:50 +0100 Subject: [PATCH 03/36] Backlash correction as global, more comments --- .../things/autofocus.py | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 0bfd5c19..9e343ba8 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -33,6 +33,7 @@ from .capture import CaptureThing CaptureDep = direct_thing_client_dependency(CaptureThing, "/capture/") SETTLING_TIME = 0.3 +BACKLASH_CORRECTION = 250 class JPEGSharpnessMonitor: @@ -294,8 +295,8 @@ class AutofocusThing(Thing): logger: InvocationLogger, metadata_getter: GetThingStates, capture: CaptureDep, - images_dir: str, sharpness_monitor: SharpnessMonitorDep, + images_dir: str, ) -> None: """Run a z stack, saving all images to stack_dir and copying the central image to stack_dir""" @@ -312,10 +313,11 @@ class AutofocusThing(Thing): stack_z_range = stack_dz * (images_to_test - 1) overshoot = stack_dz * 5 - backlash_correction = 250 - stage.move_relative(z=-(overshoot + backlash_correction + stack_z_range / 2)) - stage.move_relative(z=backlash_correction) + # Move down by the height of the z stack, plus an overshoot + # Better to start too low and take too many images than too high and need to refocus + stage.move_relative(z=-(overshoot + BACKLASH_CORRECTION + stack_z_range / 2)) + stage.move_relative(z=BACKLASH_CORRECTION) continue_stack = True captures = [] @@ -326,7 +328,6 @@ class AutofocusThing(Thing): while continue_stack: time.sleep(SETTLING_TIME) - stage_location = stage.position jpeg_path = os.path.join( @@ -344,11 +345,12 @@ class AutofocusThing(Thing): # If the stack isn't complete yet, move if capture_count >= images_to_test: - logger.info(sharpnesses[-images_to_test:]) stack_result = self.test_stack(sharpnesses[-images_to_test:]) if stack_result == "success": continue_stack = False + + # These are signs of overshooting. Autofocus and retry elif stack_result == "restart" or capture_count > 20: captures = [] sharpnesses = [] @@ -361,12 +363,13 @@ class AutofocusThing(Thing): dz=2000, ) stage.move_relative( - z=-(overshoot + backlash_correction + stack_z_range / 2) + z=-(overshoot + BACKLASH_CORRECTION + stack_z_range / 2) ) - stage.move_relative(z=backlash_correction) + stage.move_relative(z=BACKLASH_CORRECTION) stage.move_relative(z=stack_dz) sharpest_index = np.argmax(sharpnesses[-images_to_test:]) + capture._save_capture( jpeg_path=captures[-images_to_test:][sharpest_index][0], image=captures[-images_to_test:][sharpest_index][1], @@ -377,20 +380,24 @@ class AutofocusThing(Thing): return heights[-images_to_test:][sharpest_index] def test_stack(self, sharpnesses: list): - """Test a list of sharpnesses, to decide whether the focal plane is within them""" + """Test a list of sharpnesses, to decide whether the sharpest image from a stack is within them""" sharpest_index = np.argmax(sharpnesses) sharpness_length = len(sharpnesses) + # If only testing one image, assume focus succeeded if sharpness_length == 1: return "success" + # If testing three images, test if the centre is the sharpest if sharpness_length == 3: if sharpest_index == 1: return "success" - exclusion_range = 3 + # For larger stacks, test if the best image is not within two of the edge of the stack + # ie for a stack of 7 images, best image must be between 3rd and and 5th + exclusion_range = 2 if sharpest_index < exclusion_range: return "restart" - if sharpest_index >= len(sharpnesses) - exclusion_range: + if sharpest_index >= sharpness_length - exclusion_range: return "continue" return "success" From cdd603d751632763939ed6834de6eb5cbaec9697 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 16 Jun 2025 17:27:59 +0100 Subject: [PATCH 04/36] Capture the full stack requested --- .../things/autofocus.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 9e343ba8..eadc26dc 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -368,14 +368,20 @@ class AutofocusThing(Thing): stage.move_relative(z=BACKLASH_CORRECTION) stage.move_relative(z=stack_dz) + # Find the range of images from the stack to capture sharpest_index = np.argmax(sharpnesses[-images_to_test:]) + stack_extent = int((images_to_capture - 1) / 2) + stack_range = range(sharpest_index - stack_extent, sharpest_index + stack_extent + 1) + logger.info(stack_range) - capture._save_capture( - jpeg_path=captures[-images_to_test:][sharpest_index][0], - image=captures[-images_to_test:][sharpest_index][1], - metadata=captures[-images_to_test:][sharpest_index][2], - logger=logger, - ) + # Loop through the range, saving each capture to disk + for capture_index in stack_range: + capture._save_capture( + jpeg_path=captures[-images_to_test:][capture_index][0], + image=captures[-images_to_test:][capture_index][1], + metadata=captures[-images_to_test:][capture_index][2], + logger=logger, + ) return heights[-images_to_test:][sharpest_index] From dbb65d55b5edb9587b8874f3175d7f8a9cd0c93b Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 17 Jun 2025 12:05:46 +0100 Subject: [PATCH 05/36] Use scan autofocus dz --- src/openflexure_microscope_server/things/autofocus.py | 3 ++- src/openflexure_microscope_server/things/smart_scan.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index eadc26dc..117e40b0 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -297,6 +297,7 @@ class AutofocusThing(Thing): capture: CaptureDep, sharpness_monitor: SharpnessMonitorDep, images_dir: str, + autofocus_dz: int, ) -> None: """Run a z stack, saving all images to stack_dir and copying the central image to stack_dir""" @@ -360,7 +361,7 @@ class AutofocusThing(Thing): self.looping_autofocus( stage=stage, sharpness_monitor=sharpness_monitor, - dz=2000, + dz=autofocus_dz, ) stage.move_relative( z=-(overshoot + BACKLASH_CORRECTION + stack_z_range / 2) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 92f5f5aa..2eb5d285 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -646,6 +646,7 @@ class SmartScanThing(Thing): focused_height = self._autofocus.run_z_stack( images_dir=self._ongoing_scan_images_dir, + autofocus_dz = self._scan_data['autofocus_dz'] ) current_pos_xyz = ( From f363ec0eff3286fb81b7c22c77d8a7597b98f895 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 17 Jun 2025 13:29:57 +0100 Subject: [PATCH 06/36] Docstring for test_stack --- src/openflexure_microscope_server/things/autofocus.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 117e40b0..8f9b5039 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -387,7 +387,14 @@ class AutofocusThing(Thing): return heights[-images_to_test:][sharpest_index] def test_stack(self, sharpnesses: list): - """Test a list of sharpnesses, to decide whether the sharpest image from a stack is within them""" + """Test a list of sharpnesses, to decide whether the sharpest image from a stack is within them + + Returns a string + 'success' if the sharpest image is towards the centre + 'continue' if the sharpest image is in the final two images of the list + 'restart' if the sharpest image is in the first two images of the list + """ + sharpest_index = np.argmax(sharpnesses) sharpness_length = len(sharpnesses) From e8c4103bb0af4d9a7430c4918e6434b8fb6d8595 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 17 Jun 2025 14:25:27 +0100 Subject: [PATCH 07/36] Remove unneeded logging --- src/openflexure_microscope_server/things/autofocus.py | 9 +++++---- src/openflexure_microscope_server/things/smart_scan.py | 4 +--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 8f9b5039..b923ee49 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -372,8 +372,9 @@ class AutofocusThing(Thing): # Find the range of images from the stack to capture sharpest_index = np.argmax(sharpnesses[-images_to_test:]) stack_extent = int((images_to_capture - 1) / 2) - stack_range = range(sharpest_index - stack_extent, sharpest_index + stack_extent + 1) - logger.info(stack_range) + stack_range = range( + sharpest_index - stack_extent, sharpest_index + stack_extent + 1 + ) # Loop through the range, saving each capture to disk for capture_index in stack_range: @@ -388,13 +389,13 @@ class AutofocusThing(Thing): def test_stack(self, sharpnesses: list): """Test a list of sharpnesses, to decide whether the sharpest image from a stack is within them - + Returns a string 'success' if the sharpest image is towards the centre 'continue' if the sharpest image is in the final two images of the list 'restart' if the sharpest image is in the first two images of the list """ - + sharpest_index = np.argmax(sharpnesses) sharpness_length = len(sharpnesses) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 2eb5d285..22824d77 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -620,8 +620,6 @@ class SmartScanThing(Thing): self._manage_stitching_threads() next_pos_xy, z_est = route_planner.get_next_location_and_z_estimate() - self._scan_logger.info(z_est) - self._scan_logger.info(self._stage.position["z"]) new_pos_xyz = self._move_to_next_point(next_pos_xy, z_est) current_pos_xyz = ( new_pos_xyz[0], @@ -646,7 +644,7 @@ class SmartScanThing(Thing): focused_height = self._autofocus.run_z_stack( images_dir=self._ongoing_scan_images_dir, - autofocus_dz = self._scan_data['autofocus_dz'] + autofocus_dz=self._scan_data["autofocus_dz"], ) current_pos_xyz = ( From 1d4bb7363f3b394a9295685bb919b2230b7978cb Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 17 Jun 2025 14:25:42 +0100 Subject: [PATCH 08/36] Images to test control in GUI --- .../components/tabContentComponents/slideScanContent.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/webapp/src/components/tabContentComponents/slideScanContent.vue b/webapp/src/components/tabContentComponents/slideScanContent.vue index fb031ddf..38f635df 100644 --- a/webapp/src/components/tabContentComponents/slideScanContent.vue +++ b/webapp/src/components/tabContentComponents/slideScanContent.vue @@ -38,6 +38,13 @@ label="Images in Stack to Capture" /> +
+ +
Date: Wed, 18 Jun 2025 14:36:50 +0100 Subject: [PATCH 09/36] Scan planner finds lowest neighbour, not most recent --- .../scan_planners.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 73d76fa7..8c261a63 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -174,8 +174,8 @@ class ScanPlanner: next_location = self._remaining_locations[0] - # If focussed locations exist return closest location, favouring most recent - closest_pos = self.closest_focus_site(next_location) + # If focussed locations exist, return the neighbour with the lowest z position + closest_pos = self.select_nearby_focus_site(next_location) if closest_pos is None: z = None else: @@ -183,7 +183,7 @@ class ScanPlanner: return next_location, z - def closest_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]: + def select_nearby_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]: """ Return the xyz position of the closest site where focus was achieved to the input xy_position, with the most recently taken image returned in @@ -202,12 +202,20 @@ class ScanPlanner: # Note linalg.norm always uses float64 dists = np.linalg.norm((path_pos - current_pos), axis=1) - # Get indices of all minima. + # Get indices of all focused sites within 1.6x the minimum distance. + # 1.6x chosen as it includes offsets in x and y on the Picamera2 aspect ratio # Note np.where always returns a tuple of arrays, hence the trailing [0] - indices = np.where(dists == np.min(dists))[0] + indices = np.where(dists <= 1.6 * np.min(dists))[0] - # The last index is most recent - return self._focused_locations[indices[-1]] + # Turning into an array allows slicing based on a list + focused_locations_array = np.array(self._focused_locations) + + # Choose the lowest (smallest z) of the neighbouring sites. Smart stack works best + # if started too low, so the lowest z will perform best + chosen_focused_site = min(focused_locations_array[indices], key=lambda x: x[-1]) + + # Convert back into list so values are of type int instead of np.int32 + return chosen_focused_site.tolist() def mark_location_visited( self, xyz_pos: XYZPos, imaged: bool, focused: bool From 6acfbaefd62cd93d3b09d2d4c9fd77161f76a109 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 18 Jun 2025 18:28:09 +0100 Subject: [PATCH 10/36] Split up z stack function --- .../things/autofocus.py | 198 +++++++++++++----- .../things/smart_scan.py | 2 +- 2 files changed, 143 insertions(+), 57 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index b923ee49..d6c4198a 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -288,7 +288,7 @@ class AutofocusThing(Thing): self.thing_settings["stack_dz"] = value @thing_action - def run_z_stack( + def run_smart_stack( self, cam: WrappedCamera, stage: Stage, @@ -305,72 +305,75 @@ class AutofocusThing(Thing): images_to_capture = self.stack_images_to_capture images_to_test = self.stack_images_to_test - if images_to_test < images_to_capture: - raise RuntimeError( - "Can't capture more images than are tested. Please increase number to test, or decrease number to capture" - ) - if images_to_test % 2 == 0: - raise RuntimeError("Images to test should be odd") - stack_z_range = stack_dz * (images_to_test - 1) overshoot = stack_dz * 5 - # Move down by the height of the z stack, plus an overshoot - # Better to start too low and take too many images than too high and need to refocus - stage.move_relative(z=-(overshoot + BACKLASH_CORRECTION + stack_z_range / 2)) - stage.move_relative(z=BACKLASH_CORRECTION) + self.validate_scan_inputs(images_to_test, images_to_capture) - continue_stack = True - captures = [] - sharpnesses = [] - heights = [] - capture_count = 0 - starting_height = stage.position["z"] - - while continue_stack: - time.sleep(SETTLING_TIME) - stage_location = stage.position - - jpeg_path = os.path.join( + success = False + while not success: + result, heights, captures, sharpest_index = self.z_stack( + stack_dz, + images_to_test, images_dir, - f"{stage_location['x']}_{stage_location['y']}_{stage_location['z']}.jpeg", + stack_z_range, + overshoot, + stage, + cam, + capture, + metadata_getter, + logger, ) - image, metadata = capture._capture_image( - cam=cam, - metadata_getter=metadata_getter, + + if result == "success": + success = True + break + + # If result remains as "restart", move to the start and autofocus + self.reset_stack( + heights, + stack_z_range, + overshoot, + autofocus_dz, + stage, + sharpness_monitor, ) - captures.append([jpeg_path, image, metadata]) - sharpnesses.append(cam.grab_jpeg_size(stream_name="lores")) - heights.append(stage.position["z"]) - capture_count += 1 - # If the stack isn't complete yet, move - if capture_count >= images_to_test: - stack_result = self.test_stack(sharpnesses[-images_to_test:]) + self.save_stack( + sharpest_index, + captures, + images_to_test, + images_to_capture, + logger, + capture, + ) - if stack_result == "success": - continue_stack = False + return heights[-images_to_test:][sharpest_index] - # These are signs of overshooting. Autofocus and retry - elif stack_result == "restart" or capture_count > 20: - captures = [] - sharpnesses = [] - heights = [] - capture_count = 0 - stage.move_absolute(z=starting_height - stack_z_range) - self.looping_autofocus( - stage=stage, - sharpness_monitor=sharpness_monitor, - dz=autofocus_dz, - ) - stage.move_relative( - z=-(overshoot + BACKLASH_CORRECTION + stack_z_range / 2) - ) - stage.move_relative(z=BACKLASH_CORRECTION) - stage.move_relative(z=stack_dz) + def reset_stack( + self, + heights, + autofocus_dz, + stage, + sharpness_monitor, + ): + stage.move_absolute(z=heights[0]) + self.looping_autofocus( + stage=stage, + sharpness_monitor=sharpness_monitor, + dz=autofocus_dz, + ) + def save_stack( + self, + sharpest_index, + captures, + images_to_test, + images_to_capture, + logger, + capture, + ): # Find the range of images from the stack to capture - sharpest_index = np.argmax(sharpnesses[-images_to_test:]) stack_extent = int((images_to_capture - 1) / 2) stack_range = range( sharpest_index - stack_extent, sharpest_index + stack_extent + 1 @@ -384,8 +387,91 @@ class AutofocusThing(Thing): metadata=captures[-images_to_test:][capture_index][2], logger=logger, ) + return sharpest_index - return heights[-images_to_test:][sharpest_index] + def z_stack( + self, + stack_dz, + images_to_test, + images_dir, + stack_z_range, + overshoot, + stage, + cam, + capture, + metadata_getter, + ): + # Move down by the height of the z stack, plus an overshoot + # Better to start too low and take too many images than too high and need to refocus + + stage.move_relative(z=-(overshoot + BACKLASH_CORRECTION + stack_z_range / 2)) + stage.move_relative(z=BACKLASH_CORRECTION) + + captures = [] + sharpnesses = [] + heights = [] + + while len(captures) <= 20: + time.sleep(SETTLING_TIME) + + captures, heights, sharpnesses = self.capture_stack_image( + captures, + heights, + sharpnesses, + images_dir, + cam, + stage, + capture, + metadata_getter, + ) + + # If the number of images is enough to test, test them + if len(captures) >= images_to_test: + stack_result = self.test_stack(sharpnesses[-images_to_test:]) + + if stack_result == "success": + sharpest_index = np.argmax(sharpnesses[-images_to_test:]) + return "success", heights, captures, sharpest_index + + if stack_result == "restart": + return "restart", heights, None, None + + stage.move_relative(z=stack_dz) + return "restart", heights, None, None + + def capture_stack_image( + self, + captures, + heights, + sharpnesses, + images_dir, + cam, + stage, + capture, + metadata_getter, + ): + stage_location = stage.position + jpeg_path = os.path.join( + images_dir, + f"{stage_location['x']}_{stage_location['y']}_{stage_location['z']}.jpeg", + ) + image, metadata = capture._capture_image( + cam=cam, + metadata_getter=metadata_getter, + ) + captures.append([jpeg_path, image, metadata]) + sharpnesses.append(cam.grab_jpeg_size(stream_name="lores")) + heights.append(stage_location["z"]) + + return captures, heights, sharpnesses + + def validate_scan_inputs(self, images_to_test, images_to_capture): + if images_to_test < images_to_capture: + raise RuntimeError( + "Can't capture more images than are tested. Please increase number to test, or decrease number to capture" + ) + if images_to_test % 2 == 0: + raise RuntimeError("Images to test should be odd") def test_stack(self, sharpnesses: list): """Test a list of sharpnesses, to decide whether the sharpest image from a stack is within them diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 22824d77..e62c7dd7 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -642,7 +642,7 @@ class SmartScanThing(Thing): self._scan_logger.info(msg) continue - focused_height = self._autofocus.run_z_stack( + focused_height = self._autofocus.run_smart_stack( images_dir=self._ongoing_scan_images_dir, autofocus_dz=self._scan_data["autofocus_dz"], ) From 00321d761d55eba7776e533d6d6ff8c8062a7626 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Thu, 19 Jun 2025 14:57:27 +0100 Subject: [PATCH 11/36] Update docstring for nearby focus site --- src/openflexure_microscope_server/scan_planners.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 8c261a63..780b1199 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -185,9 +185,8 @@ class ScanPlanner: def select_nearby_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]: """ - Return the xyz position of the closest site where focus was achieved - to the input xy_position, with the most recently taken image returned in - the case of a tie + Return the xyz position of the nearby site with the lowest z position. + Nearby is defined as within 1.6 times the distance to the closest neighbour. Returns None if there if no focussed locations are present """ From 6cf2f4d8c3a95e43d85be703c9b85fe7d77faeae Mon Sep 17 00:00:00 2001 From: jaknapper Date: Thu, 19 Jun 2025 14:58:16 +0100 Subject: [PATCH 12/36] Typehints and docstrings in z_stack --- .../things/autofocus.py | 157 +++++++++++++----- 1 file changed, 114 insertions(+), 43 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index d6c4198a..d4abc12a 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -299,8 +299,18 @@ class AutofocusThing(Thing): images_dir: str, autofocus_dz: int, ) -> None: - """Run a z stack, saving all images to stack_dir and copying the - central image to stack_dir""" + """Run a smart stack, which captures images offset in z, testing + whether the sharpest image is towards the centre of the stack. + The sharpest image, and optionally images around the sharpest, + will be saved using their coordinates to images_dir + + Arguments: + images_dir: the folder to save all images + autofocus_dz: should the stack fail, the range to refocus over before retrying + variables cam to sharpness_monitor are Thing dependencies injected automatically by LabThings FastAPI + """ + + # Set the variables to prevent changes from the GUI or other windows stack_dz = self.stack_dz images_to_capture = self.stack_images_to_capture images_to_test = self.stack_images_to_test @@ -308,9 +318,11 @@ class AutofocusThing(Thing): stack_z_range = stack_dz * (images_to_test - 1) overshoot = stack_dz * 5 - self.validate_scan_inputs(images_to_test, images_to_capture) + # Ensure the stack settings are appropriate + self.validate_stack_inputs(images_to_test, images_to_capture) success = False + # Loop until a stack is successful while not success: result, heights, captures, sharpest_index = self.z_stack( stack_dz, @@ -322,23 +334,21 @@ class AutofocusThing(Thing): cam, capture, metadata_getter, - logger, ) if result == "success": success = True break - # If result remains as "restart", move to the start and autofocus + # If "result" remains as "restart", move to the start and autofocus self.reset_stack( heights, - stack_z_range, - overshoot, autofocus_dz, stage, sharpness_monitor, ) + # Save the sharpest image, and images either side of focus self.save_stack( sharpest_index, captures, @@ -348,15 +358,24 @@ class AutofocusThing(Thing): capture, ) + # Return the z position of the sharpest image, for path planning and tracking return heights[-images_to_test:][sharpest_index] def reset_stack( self, - heights, - autofocus_dz, - stage, - sharpness_monitor, - ): + heights: list[int], + autofocus_dz: int, + stage: Stage, + sharpness_monitor: SharpnessMonitorDep, + ) -> None: + """Return to the initial height of the current stack, and run + a looping autofocus. Clears all previous captures, heights and sharpnesses. + + Arguments: + heights: a list of the z positions of previous captures + autofocus_dz: the range in steps to autofocus + variables stage and sharpness_monitor are Thing dependencies passed through from the calling action + """ stage.move_absolute(z=heights[0]) self.looping_autofocus( stage=stage, @@ -366,13 +385,23 @@ class AutofocusThing(Thing): def save_stack( self, - sharpest_index, - captures, - images_to_test, - images_to_capture, - logger, - capture, - ): + sharpest_index: int, + captures: list[list], + images_to_test: int, + images_to_capture: int, + logger: InvocationLogger, + capture: CaptureDep, + ) -> int: + """Save the required captures to disk. Will save the sharpest image, + and any images either side of focus. + + Arguments: + sharpest_index: the index of the sharpest image, within the "images_to_test" slice + captures: a list of captures, including file name, image data and metadata + images_to_test: the number of images in the stack tested for success + images_to_capture: the number of images to save to disk + variables logger and capture are Thing dependencies passed through from the calling action + """ # Find the range of images from the stack to capture stack_extent = int((images_to_capture - 1) / 2) stack_range = range( @@ -391,19 +420,32 @@ class AutofocusThing(Thing): def z_stack( self, - stack_dz, - images_to_test, - images_dir, - stack_z_range, - overshoot, - stage, - cam, - capture, - metadata_getter, - ): + stack_dz: int, + images_to_test: int, + images_dir: str, + stack_z_range: int, + overshoot: int, + stage: Stage, + cam: WrappedCamera, + capture: CaptureDep, + metadata_getter: GetThingStates, + ) -> list: + """Capture a series of images offset by stack_dz, and test whether + the sharpest image is towards the centre of the stack. + + Returns a test result string, a list of z positions, a list of captures and the + index of the sharpest image in a successful stack. + + Arguments: + stack_dz: the distance in steps between images in the stack + images_to_test: the number of images in the stack to test for focus + images_dir: a string of the path to write all images + stack_z_range: the height in steps of the stack to test + overshoot: how far below the estimated optimal starting position to begin stacking + variables stage to metadata_getter are Thing dependencies passed through from the calling action + """ # Move down by the height of the z stack, plus an overshoot # Better to start too low and take too many images than too high and need to refocus - stage.move_relative(z=-(overshoot + BACKLASH_CORRECTION + stack_z_range / 2)) stage.move_relative(z=BACKLASH_CORRECTION) @@ -411,9 +453,12 @@ class AutofocusThing(Thing): sharpnesses = [] heights = [] - while len(captures) <= 20: + # If the sharpest image isn't found within the 15 images above the estimated point, break + # the loop and return "restart" + while len(captures) <= images_to_test + 15: time.sleep(SETTLING_TIME) + # Append a new image to the stack captures, heights, sharpnesses = self.capture_stack_image( captures, heights, @@ -427,7 +472,7 @@ class AutofocusThing(Thing): # If the number of images is enough to test, test them if len(captures) >= images_to_test: - stack_result = self.test_stack(sharpnesses[-images_to_test:]) + stack_result = self.check_stack_result(sharpnesses[-images_to_test:]) if stack_result == "success": sharpest_index = np.argmax(sharpnesses[-images_to_test:]) @@ -441,15 +486,27 @@ class AutofocusThing(Thing): def capture_stack_image( self, - captures, - heights, - sharpnesses, - images_dir, - cam, - stage, - capture, - metadata_getter, - ): + captures: list[list], + heights: list[int], + sharpnesses: list[int], + images_dir: str, + cam: WrappedCamera, + stage: Stage, + capture: CaptureDep, + metadata_getter: GetThingStates, + ) -> list: + """Append a new capture to the ongoing stack. + Includes appending the height, image sharpness and image + data to the relevant lists. + TODO: clear previous captures, to limit images held in memory + + arguments: + captures: a list of captures, including file name, image data and metadata + heights: a list of the z positions of previous captures + sharpnesses: a list of the sharpnesses of previous captures + images_dir: a path to the folder to save images + variables stage to metadata_getter are Thing dependencies passed through from the calling action + """ stage_location = stage.position jpeg_path = os.path.join( images_dir, @@ -465,7 +522,15 @@ class AutofocusThing(Thing): return captures, heights, sharpnesses - def validate_scan_inputs(self, images_to_test, images_to_capture): + def validate_stack_inputs( + self, images_to_test: int, images_to_capture: int + ) -> None: + """Check the stack settings are appropriate, and raise an error if not + + Arguments: + images_to_test: number of images in the stack to test for focus + images_to_capture: number of images to be captured around the focused image + """ if images_to_test < images_to_capture: raise RuntimeError( "Can't capture more images than are tested. Please increase number to test, or decrease number to capture" @@ -473,13 +538,16 @@ class AutofocusThing(Thing): if images_to_test % 2 == 0: raise RuntimeError("Images to test should be odd") - def test_stack(self, sharpnesses: list): + def check_stack_result(self, sharpnesses: list[int]) -> str: """Test a list of sharpnesses, to decide whether the sharpest image from a stack is within them Returns a string 'success' if the sharpest image is towards the centre 'continue' if the sharpest image is in the final two images of the list 'restart' if the sharpest image is in the first two images of the list + + Arguments: + sharpnesses: a list of the sharpnesses to test for focus """ sharpest_index = np.argmax(sharpnesses) @@ -492,6 +560,9 @@ class AutofocusThing(Thing): if sharpness_length == 3: if sharpest_index == 1: return "success" + if sharpest_index == 0: + return "restart" + return "continue" # For larger stacks, test if the best image is not within two of the edge of the stack # ie for a stack of 7 images, best image must be between 3rd and and 5th From ce33b94aab257e5e671b981d9925ec690a86d692 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Fri, 20 Jun 2025 12:19:07 +0100 Subject: [PATCH 13/36] Capture as a Raw dependency to use methods that aren't actions --- .../things/autofocus.py | 5 +-- .../things/capture.py | 42 ++++--------------- 2 files changed, 10 insertions(+), 37 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index d4abc12a..d2d798a3 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -22,15 +22,12 @@ from labthings_fastapi.dependencies.blocking_portal import BlockingPortal from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.types.numpy import NDArray -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.invocation import InvocationLogger from .camera import RawCameraDependency as Camera from .camera import CameraDependency as WrappedCamera from .stage import StageDependency as Stage -from .capture import CaptureThing - -CaptureDep = direct_thing_client_dependency(CaptureThing, "/capture/") +from .capture import RawCaptureDependency as CaptureDep SETTLING_TIME = 0.3 BACKLASH_CORRECTION = 250 diff --git a/src/openflexure_microscope_server/things/capture.py b/src/openflexure_microscope_server/things/capture.py index 43307f02..896ce1bc 100644 --- a/src/openflexure_microscope_server/things/capture.py +++ b/src/openflexure_microscope_server/things/capture.py @@ -1,11 +1,11 @@ from PIL import Image -import time import piexif import json +import numpy as np from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action +from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency from .camera import CameraDependency as CamDep from labthings_fastapi.dependencies.invocation import ( InvocationLogger, @@ -20,35 +20,9 @@ class CaptureThing(Thing): """A temporary Thing to handle capturing to disk with associated metadata Will be moved to the camera Thing or dependency in due course""" - @thing_action - def _capture_and_save( - self, - jpeg_path: str, - cam: CamDep, - logger: InvocationLogger, - metadata_getter: GetThingStates, - ) -> None: - """Capture an image and save it to disk - - This will set the event `acquired` once the image has been acquired, so - that the stage may be moved while it's saved. - """ - capture_start = time.time() - image, metadata = self._capture_image( - cam, - metadata_getter, - ) - acquisition_time = time.time() - self._save_capture(jpeg_path, image, metadata, logger) - save_time = time.time() - acquisition_duration = round(acquisition_time - capture_start, 1) - saving_duration = round(save_time - acquisition_time, 1) - logger.info( - f"Acquired {jpeg_path} in {acquisition_duration}s then {saving_duration}s saving to disk" - ) - - @thing_action - def _capture_image(self, cam, metadata_getter): + def _capture_image( + self, cam: CamDep, metadata_getter: GetThingStates + ) -> tuple[np.ndarray, dict]: """Capture an image in memory and return it with metadata CaptureError raised if the capture fails for any reason returns tuple with numpy array of image data, and dict of metadata @@ -60,11 +34,10 @@ class CaptureThing(Thing): raise CaptureError("An error occurred while capturing") from e return image, metadata - @thing_action def _save_capture( self, jpeg_path: str, - image, + image: Image.Image, metadata: dict, logger: InvocationLogger, ) -> None: @@ -88,3 +61,6 @@ class CaptureThing(Thing): logger.warning(f"Failed to add metadata to {jpeg_path}") except Exception as e: raise IOError(f"An error occurred while saving {jpeg_path}") from e + + +RawCaptureDependency = raw_thing_dependency(CaptureThing) From 006d3b2a7ba8475bafa9cd81ff784d34d4c1cb8a Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Fri, 20 Jun 2025 11:23:14 +0000 Subject: [PATCH 14/36] Apply suggestions from code review of branch fast-stack Co-authored-by: Beth Probert --- src/openflexure_microscope_server/things/autofocus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index d2d798a3..cfd67cbf 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -337,7 +337,7 @@ class AutofocusThing(Thing): success = True break - # If "result" remains as "restart", move to the start and autofocus + # If a stack is not successful, move to the start and autofocus self.reset_stack( heights, autofocus_dz, From d1ed7ec79735fb36dcaae09ef30dab3a3300d0fb Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Fri, 20 Jun 2025 11:26:58 +0000 Subject: [PATCH 15/36] Apply suggestions from code review of branch fast-stack --- src/openflexure_microscope_server/things/autofocus.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index cfd67cbf..a408f595 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -495,7 +495,6 @@ class AutofocusThing(Thing): """Append a new capture to the ongoing stack. Includes appending the height, image sharpness and image data to the relevant lists. - TODO: clear previous captures, to limit images held in memory arguments: captures: a list of captures, including file name, image data and metadata From a001da0bcb954ff4e6ac5a8e2c666b18106e119c Mon Sep 17 00:00:00 2001 From: jaknapper Date: Fri, 20 Jun 2025 14:52:19 +0100 Subject: [PATCH 16/36] Restore closest_focus_site function --- .../scan_planners.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 780b1199..a2a93d82 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -183,6 +183,32 @@ class ScanPlanner: return next_location, z + def closest_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]: + """ + Return the xyz position of the closest site where focus was achieved + to the input xy_position, with the most recently taken image returned in + the case of a tie + + Returns None if there if no focussed locations are present + """ + if not self._focused_locations: + return None + + # must be float64 (double precision) to deal with the huge numbers involved! + current_pos = np.array(xy_pos, dtype="float64") + path_pos = np.array(self._focused_locations, dtype="float64")[:, :2] + + # Use linalg.norm to calculate the direct distance bweween the points + # Note linalg.norm always uses float64 + dists = np.linalg.norm((path_pos - current_pos), axis=1) + + # Get indices of all minima. + # Note np.where always returns a tuple of arrays, hence the trailing [0] + indices = np.where(dists == np.min(dists))[0] + + # The last index is most recent + return self._focused_locations[indices[-1]] + def select_nearby_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]: """ Return the xyz position of the nearby site with the lowest z position. @@ -214,7 +240,7 @@ class ScanPlanner: chosen_focused_site = min(focused_locations_array[indices], key=lambda x: x[-1]) # Convert back into list so values are of type int instead of np.int32 - return chosen_focused_site.tolist() + return tuple(chosen_focused_site.tolist()) def mark_location_visited( self, xyz_pos: XYZPos, imaged: bool, focused: bool From f6cc7403e316b7bd692a1a50aa3d75f6a493016f Mon Sep 17 00:00:00 2001 From: jaknapper Date: Fri, 20 Jun 2025 14:57:49 +0100 Subject: [PATCH 17/36] Update test_smart_spiral_first_few_pos() as smart stack chooses a different z_estimate --- tests/test_scan_planners.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_scan_planners.py b/tests/test_scan_planners.py index f9b56a32..9fe192ba 100644 --- a/tests/test_scan_planners.py +++ b/tests/test_scan_planners.py @@ -183,8 +183,8 @@ def test_smart_spiral_first_few_pos(): xy_pos4, z_pos4 = planner.get_next_location_and_z_estimate() # ...(100, 0)... assert xy_pos4 == (100, 0) - # ... and it should get its focus from the most recent point as this was focussed - assert z_pos4 is new_z_focus + # ... and it should get its focus from the lowest neighbouring point + assert z_pos4 is z_focus assert planner.closest_focus_site(xy_pos4) == xyz_pos3 From 7a7547542ca5e4172705be67b5e74d529e2d67c1 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Mon, 23 Jun 2025 10:36:26 +0000 Subject: [PATCH 18/36] Apply suggestions from code review of branch fast-stack Co-authored-by: Julian Stirling --- src/openflexure_microscope_server/things/autofocus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index a408f595..0680cebb 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -549,7 +549,7 @@ class AutofocusThing(Thing): sharpest_index = np.argmax(sharpnesses) sharpness_length = len(sharpnesses) - # If only testing one image, assume focus succeeded + # If only testing one image, then by definition the sharpest is central if sharpness_length == 1: return "success" # If testing three images, test if the centre is the sharpest From 333ea1b644eb7185a85f1dede54e158bf0ae30c6 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 23 Jun 2025 12:01:41 +0100 Subject: [PATCH 19/36] More input validation and comments --- src/openflexure_microscope_server/scan_planners.py | 2 ++ src/openflexure_microscope_server/things/autofocus.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index a2a93d82..6d430aa4 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -212,6 +212,8 @@ class ScanPlanner: def select_nearby_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]: """ Return the xyz position of the nearby site with the lowest z position. + Lowest position is best, as starting too high causes smart stacking to + autofocus and restart. Starting too low just requires extra movements in +z. Nearby is defined as within 1.6 times the distance to the closest neighbour. Returns None if there if no focussed locations are present diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 0680cebb..a2cd318d 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -313,6 +313,10 @@ class AutofocusThing(Thing): images_to_test = self.stack_images_to_test stack_z_range = stack_dz * (images_to_test - 1) + # Starting too low by "overshoot" makes smart stacking faster. + # Starting a stack too high requires it to move to the start, + # autofocus and then re-stack. Starting slightly too low only + # requires extra +z movements and captures. overshoot = stack_dz * 5 # Ensure the stack settings are appropriate @@ -533,6 +537,8 @@ class AutofocusThing(Thing): ) if images_to_test % 2 == 0: raise RuntimeError("Images to test should be odd") + if images_to_test <= 0 or images_to_capture <= 0: + raise RuntimeError("Stack parameters need to be at least 1") def check_stack_result(self, sharpnesses: list[int]) -> str: """Test a list of sharpnesses, to decide whether the sharpest image from a stack is within them From 8fea35a663d4e6ad6dd761b82eb915453b58a55b Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 23 Jun 2025 12:05:52 +0100 Subject: [PATCH 20/36] Remove duplicate docstring --- src/openflexure_microscope_server/things/smart_scan.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index e62c7dd7..b907004c 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -70,8 +70,6 @@ def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None: class ScanInfo(BaseModel): """Summary information about a scan folder""" - """Summary information about a scan folder""" - name: str created: datetime modified: datetime From 7cff0f8f44b60706f7d6897fd9f82551fa65a848 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 23 Jun 2025 12:56:31 +0100 Subject: [PATCH 21/36] StackInfo dataclass --- .../things/autofocus.py | 105 +++++++++++------- 1 file changed, 64 insertions(+), 41 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index a2cd318d..96c72154 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -29,9 +29,35 @@ from .camera import CameraDependency as WrappedCamera from .stage import StageDependency as Stage from .capture import RawCaptureDependency as CaptureDep -SETTLING_TIME = 0.3 -BACKLASH_CORRECTION = 250 +class StackInfo(BaseModel): + """Dataclass with default scan parameters""" + # These are generic settings that seem to apply well to a standard scan + # and are not altered by default when running a scan + + # time (in seconds) between moving and capturing an image + settling_time: float = 0.3 + # distance (in steps) to overshoot a move and then undo, to account for backlash + backlash_correction: int = 250 + + # how many times the minimum distance between images to include as a "nearby" image + # default 1.4 includes images offset in x or y, but not diagonally + neighbour_cutoff: float = 1.4 + + # how many images can be appended to the stack after the predicted peak to test for focus + # before assuming the focus was passed, and restarting the stack + stack_height_limit: int = 15 + + # how far below (in factors of stack_dz) the estimated optimal starting position to + # begin the stack. Better to start slightly too low and require many images, rather than + # too high and needing to autofocus and restart the stack + undershoot: int = 5 + + # These we expect to be overwritten by AutofocusThing properties + stack_dz: int = 50 + images_to_capture: int = 1 + images_to_test: int = 5 + autofocus_dz: int = 2000 class JPEGSharpnessMonitor: __globals__ = globals() # Required for FastAPI dependency @@ -308,29 +334,31 @@ class AutofocusThing(Thing): """ # Set the variables to prevent changes from the GUI or other windows - stack_dz = self.stack_dz - images_to_capture = self.stack_images_to_capture - images_to_test = self.stack_images_to_test + stack_parameters = StackInfo( + stack_dz = self.stack_dz, + images_to_capture = self.stack_images_to_capture, + images_to_test = self.stack_images_to_test, + autofocus_dz = autofocus_dz + ) - stack_z_range = stack_dz * (images_to_test - 1) - # Starting too low by "overshoot" makes smart stacking faster. + stack_z_range = stack_parameters.stack_dz * (stack_parameters.images_to_test - 1) + # Starting too low by "undershoot" makes smart stacking faster. # Starting a stack too high requires it to move to the start, # autofocus and then re-stack. Starting slightly too low only # requires extra +z movements and captures. - overshoot = stack_dz * 5 + undershoot = stack_parameters.stack_dz * stack_parameters.undershoot # Ensure the stack settings are appropriate - self.validate_stack_inputs(images_to_test, images_to_capture) + self.validate_stack_inputs(stack_parameters) success = False # Loop until a stack is successful while not success: result, heights, captures, sharpest_index = self.z_stack( - stack_dz, - images_to_test, images_dir, stack_z_range, - overshoot, + undershoot, + stack_parameters, stage, cam, capture, @@ -344,7 +372,7 @@ class AutofocusThing(Thing): # If a stack is not successful, move to the start and autofocus self.reset_stack( heights, - autofocus_dz, + stack_parameters.autofocus_dz, stage, sharpness_monitor, ) @@ -353,14 +381,13 @@ class AutofocusThing(Thing): self.save_stack( sharpest_index, captures, - images_to_test, - images_to_capture, + stack_parameters, logger, capture, ) # Return the z position of the sharpest image, for path planning and tracking - return heights[-images_to_test:][sharpest_index] + return heights[-stack_parameters.images_to_test:][sharpest_index] def reset_stack( self, @@ -388,8 +415,7 @@ class AutofocusThing(Thing): self, sharpest_index: int, captures: list[list], - images_to_test: int, - images_to_capture: int, + stack_parameters: StackInfo, logger: InvocationLogger, capture: CaptureDep, ) -> int: @@ -399,12 +425,11 @@ class AutofocusThing(Thing): Arguments: sharpest_index: the index of the sharpest image, within the "images_to_test" slice captures: a list of captures, including file name, image data and metadata - images_to_test: the number of images in the stack tested for success - images_to_capture: the number of images to save to disk + stack_parameters: a StackInfo dataclass with stack settings variables logger and capture are Thing dependencies passed through from the calling action """ # Find the range of images from the stack to capture - stack_extent = int((images_to_capture - 1) / 2) + stack_extent = int((stack_parameters.images_to_capture - 1) / 2) stack_range = range( sharpest_index - stack_extent, sharpest_index + stack_extent + 1 ) @@ -412,43 +437,41 @@ class AutofocusThing(Thing): # Loop through the range, saving each capture to disk for capture_index in stack_range: capture._save_capture( - jpeg_path=captures[-images_to_test:][capture_index][0], - image=captures[-images_to_test:][capture_index][1], - metadata=captures[-images_to_test:][capture_index][2], + jpeg_path=captures[-stack_parameters.images_to_test:][capture_index][0], + image=captures[-stack_parameters.images_to_test:][capture_index][1], + metadata=captures[-stack_parameters.images_to_test:][capture_index][2], logger=logger, ) return sharpest_index def z_stack( self, - stack_dz: int, - images_to_test: int, images_dir: str, stack_z_range: int, overshoot: int, + stack_parameters: StackInfo, stage: Stage, cam: WrappedCamera, capture: CaptureDep, metadata_getter: GetThingStates, ) -> list: - """Capture a series of images offset by stack_dz, and test whether + """Capture a series of images offset by stack_parameters.stack_dz, and test whether the sharpest image is towards the centre of the stack. Returns a test result string, a list of z positions, a list of captures and the index of the sharpest image in a successful stack. Arguments: - stack_dz: the distance in steps between images in the stack - images_to_test: the number of images in the stack to test for focus images_dir: a string of the path to write all images stack_z_range: the height in steps of the stack to test overshoot: how far below the estimated optimal starting position to begin stacking + stack_parameters: a StackInfo dataclass with stack settings variables stage to metadata_getter are Thing dependencies passed through from the calling action """ # Move down by the height of the z stack, plus an overshoot # Better to start too low and take too many images than too high and need to refocus - stage.move_relative(z=-(overshoot + BACKLASH_CORRECTION + stack_z_range / 2)) - stage.move_relative(z=BACKLASH_CORRECTION) + stage.move_relative(z=-(overshoot + stack_parameters.backlash_correction + stack_z_range / 2)) + stage.move_relative(z=stack_parameters.backlash_correction) captures = [] sharpnesses = [] @@ -456,8 +479,8 @@ class AutofocusThing(Thing): # If the sharpest image isn't found within the 15 images above the estimated point, break # the loop and return "restart" - while len(captures) <= images_to_test + 15: - time.sleep(SETTLING_TIME) + while len(captures) <= stack_parameters.images_to_test + 15: + time.sleep(stack_parameters.settling_time) # Append a new image to the stack captures, heights, sharpnesses = self.capture_stack_image( @@ -472,17 +495,17 @@ class AutofocusThing(Thing): ) # If the number of images is enough to test, test them - if len(captures) >= images_to_test: - stack_result = self.check_stack_result(sharpnesses[-images_to_test:]) + if len(captures) >= stack_parameters.images_to_test: + stack_result = self.check_stack_result(sharpnesses[-stack_parameters.images_to_test:]) if stack_result == "success": - sharpest_index = np.argmax(sharpnesses[-images_to_test:]) + sharpest_index = np.argmax(sharpnesses[-stack_parameters.images_to_test:]) return "success", heights, captures, sharpest_index if stack_result == "restart": return "restart", heights, None, None - stage.move_relative(z=stack_dz) + stage.move_relative(z=stack_parameters.stack_dz) return "restart", heights, None, None def capture_stack_image( @@ -523,7 +546,7 @@ class AutofocusThing(Thing): return captures, heights, sharpnesses def validate_stack_inputs( - self, images_to_test: int, images_to_capture: int + self, stack_parameters ) -> None: """Check the stack settings are appropriate, and raise an error if not @@ -531,13 +554,13 @@ class AutofocusThing(Thing): images_to_test: number of images in the stack to test for focus images_to_capture: number of images to be captured around the focused image """ - if images_to_test < images_to_capture: + if stack_parameters.images_to_test < stack_parameters.images_to_capture: raise RuntimeError( "Can't capture more images than are tested. Please increase number to test, or decrease number to capture" ) - if images_to_test % 2 == 0: + if stack_parameters.images_to_test % 2 == 0: raise RuntimeError("Images to test should be odd") - if images_to_test <= 0 or images_to_capture <= 0: + if stack_parameters.images_to_test <= 0 or stack_parameters.images_to_capture <= 0: raise RuntimeError("Stack parameters need to be at least 1") def check_stack_result(self, sharpnesses: list[int]) -> str: From ee128ad91fa6ed98fbd6e605a6fab306dc5b2f06 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 23 Jun 2025 13:45:26 +0100 Subject: [PATCH 22/36] Rename to StackParams, move undershoots into Params --- .../things/autofocus.py | 81 ++++++++++--------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 96c72154..3e66c6b9 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -29,8 +29,9 @@ from .camera import CameraDependency as WrappedCamera from .stage import StageDependency as Stage from .capture import RawCaptureDependency as CaptureDep -class StackInfo(BaseModel): - """Dataclass with default scan parameters""" + +class StackParams(BaseModel): + """Pydantic model for scan parameters""" # These are generic settings that seem to apply well to a standard scan # and are not altered by default when running a scan @@ -51,7 +52,7 @@ class StackInfo(BaseModel): # how far below (in factors of stack_dz) the estimated optimal starting position to # begin the stack. Better to start slightly too low and require many images, rather than # too high and needing to autofocus and restart the stack - undershoot: int = 5 + img_undershoot: int = 5 # These we expect to be overwritten by AutofocusThing properties stack_dz: int = 50 @@ -59,6 +60,14 @@ class StackInfo(BaseModel): images_to_test: int = 5 autofocus_dz: int = 2000 + stack_z_range: int = stack_dz * (images_to_test - 1) + # Starting too low by "steps_undershoot" makes smart stacking faster. + # Starting a stack too high requires it to move to the start, + # autofocus and then re-stack. Starting slightly too low only + # requires extra +z movements and captures. + steps_undershoot: int = stack_dz * img_undershoot + + class JPEGSharpnessMonitor: __globals__ = globals() # Required for FastAPI dependency @@ -334,20 +343,13 @@ class AutofocusThing(Thing): """ # Set the variables to prevent changes from the GUI or other windows - stack_parameters = StackInfo( - stack_dz = self.stack_dz, - images_to_capture = self.stack_images_to_capture, - images_to_test = self.stack_images_to_test, - autofocus_dz = autofocus_dz + stack_parameters = StackParams( + stack_dz=self.stack_dz, + images_to_capture=self.stack_images_to_capture, + images_to_test=self.stack_images_to_test, + autofocus_dz=autofocus_dz, ) - stack_z_range = stack_parameters.stack_dz * (stack_parameters.images_to_test - 1) - # Starting too low by "undershoot" makes smart stacking faster. - # Starting a stack too high requires it to move to the start, - # autofocus and then re-stack. Starting slightly too low only - # requires extra +z movements and captures. - undershoot = stack_parameters.stack_dz * stack_parameters.undershoot - # Ensure the stack settings are appropriate self.validate_stack_inputs(stack_parameters) @@ -356,8 +358,6 @@ class AutofocusThing(Thing): while not success: result, heights, captures, sharpest_index = self.z_stack( images_dir, - stack_z_range, - undershoot, stack_parameters, stage, cam, @@ -387,7 +387,7 @@ class AutofocusThing(Thing): ) # Return the z position of the sharpest image, for path planning and tracking - return heights[-stack_parameters.images_to_test:][sharpest_index] + return heights[-stack_parameters.images_to_test :][sharpest_index] def reset_stack( self, @@ -415,7 +415,7 @@ class AutofocusThing(Thing): self, sharpest_index: int, captures: list[list], - stack_parameters: StackInfo, + stack_parameters: StackParams, logger: InvocationLogger, capture: CaptureDep, ) -> int: @@ -425,7 +425,7 @@ class AutofocusThing(Thing): Arguments: sharpest_index: the index of the sharpest image, within the "images_to_test" slice captures: a list of captures, including file name, image data and metadata - stack_parameters: a StackInfo dataclass with stack settings + stack_parameters: a StackParams Pydantic model with stack settings variables logger and capture are Thing dependencies passed through from the calling action """ # Find the range of images from the stack to capture @@ -437,9 +437,11 @@ class AutofocusThing(Thing): # Loop through the range, saving each capture to disk for capture_index in stack_range: capture._save_capture( - jpeg_path=captures[-stack_parameters.images_to_test:][capture_index][0], - image=captures[-stack_parameters.images_to_test:][capture_index][1], - metadata=captures[-stack_parameters.images_to_test:][capture_index][2], + jpeg_path=captures[-stack_parameters.images_to_test :][capture_index][ + 0 + ], + image=captures[-stack_parameters.images_to_test :][capture_index][1], + metadata=captures[-stack_parameters.images_to_test :][capture_index][2], logger=logger, ) return sharpest_index @@ -447,9 +449,7 @@ class AutofocusThing(Thing): def z_stack( self, images_dir: str, - stack_z_range: int, - overshoot: int, - stack_parameters: StackInfo, + stack_parameters: StackParams, stage: Stage, cam: WrappedCamera, capture: CaptureDep, @@ -463,14 +463,18 @@ class AutofocusThing(Thing): Arguments: images_dir: a string of the path to write all images - stack_z_range: the height in steps of the stack to test - overshoot: how far below the estimated optimal starting position to begin stacking - stack_parameters: a StackInfo dataclass with stack settings + stack_parameters: a StackParams Pydantic model with stack settings variables stage to metadata_getter are Thing dependencies passed through from the calling action """ # Move down by the height of the z stack, plus an overshoot # Better to start too low and take too many images than too high and need to refocus - stage.move_relative(z=-(overshoot + stack_parameters.backlash_correction + stack_z_range / 2)) + stage.move_relative( + z=-( + stack_parameters.steps_undershoot + + stack_parameters.backlash_correction + + stack_parameters.stack_z_range / 2 + ) + ) stage.move_relative(z=stack_parameters.backlash_correction) captures = [] @@ -496,10 +500,14 @@ class AutofocusThing(Thing): # If the number of images is enough to test, test them if len(captures) >= stack_parameters.images_to_test: - stack_result = self.check_stack_result(sharpnesses[-stack_parameters.images_to_test:]) + stack_result = self.check_stack_result( + sharpnesses[-stack_parameters.images_to_test :] + ) if stack_result == "success": - sharpest_index = np.argmax(sharpnesses[-stack_parameters.images_to_test:]) + sharpest_index = np.argmax( + sharpnesses[-stack_parameters.images_to_test :] + ) return "success", heights, captures, sharpest_index if stack_result == "restart": @@ -545,9 +553,7 @@ class AutofocusThing(Thing): return captures, heights, sharpnesses - def validate_stack_inputs( - self, stack_parameters - ) -> None: + def validate_stack_inputs(self, stack_parameters) -> None: """Check the stack settings are appropriate, and raise an error if not Arguments: @@ -560,7 +566,10 @@ class AutofocusThing(Thing): ) if stack_parameters.images_to_test % 2 == 0: raise RuntimeError("Images to test should be odd") - if stack_parameters.images_to_test <= 0 or stack_parameters.images_to_capture <= 0: + if ( + stack_parameters.images_to_test <= 0 + or stack_parameters.images_to_capture <= 0 + ): raise RuntimeError("Stack parameters need to be at least 1") def check_stack_result(self, sharpnesses: list[int]) -> str: From 2a0de9122bb97ff297300d4eafe17e2829b471cd Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 23 Jun 2025 14:04:30 +0100 Subject: [PATCH 23/36] computed_field pydantic decorators --- .../things/autofocus.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 3e66c6b9..679b0e77 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -15,7 +15,7 @@ import os from fastapi import Depends import numpy as np -from pydantic import BaseModel +from pydantic import BaseModel, computed_field from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.blocking_portal import BlockingPortal @@ -60,12 +60,26 @@ class StackParams(BaseModel): images_to_test: int = 5 autofocus_dz: int = 2000 - stack_z_range: int = stack_dz * (images_to_test - 1) - # Starting too low by "steps_undershoot" makes smart stacking faster. - # Starting a stack too high requires it to move to the start, - # autofocus and then re-stack. Starting slightly too low only - # requires extra +z movements and captures. - steps_undershoot: int = stack_dz * img_undershoot + # Per pydantic docs, even with the @property applied before @computed_field, + # mypy may throw a Decorated property not supported error (mypy issue #1362) + # To avoid this error message, add # type: ignore[prop-decorator] to the @computed_field line. + + @computed_field + @property + def stack_z_range(self) -> int: + """The range of the entire z stack, in steps""" + return self.stack_dz * (self.images_to_test - 1) + + @computed_field + @property + def steps_undershoot(self) -> int: + """The distance to deliberately undershoot the estimated optimal starting point""" + + # Starting too low by "steps_undershoot" makes smart stacking faster. + # Starting a stack too high requires it to move to the start, + # autofocus and then re-stack. Starting slightly too low only + # requires extra +z movements and captures. + return self.stack_dz * self.img_undershoot class JPEGSharpnessMonitor: From 8ba40832e00e2a325e57245cb0eccec31a9d8c6e Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 23 Jun 2025 14:13:08 +0100 Subject: [PATCH 24/36] Neighbour cutoff moved from autofocus.py to scan_planners.py --- src/openflexure_microscope_server/scan_planners.py | 12 ++++++++---- .../things/autofocus.py | 4 ---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 6d430aa4..d9c26b9a 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -20,6 +20,11 @@ XYPosList: TypeAlias = list[XYPos] XYZPosList: TypeAlias = list[XYZPos] +# how many times the minimum distance between images to include as a "nearby" image +# default 1.4 includes images offset in x or y, but not diagonally +NEIGHBOUR_CUTOFF = 1.4 + + def enforce_xy_tuple(value: XYPos) -> XYPos: """ Used for enforcing that an input is a tuple and is the correct length @@ -214,7 +219,7 @@ class ScanPlanner: Return the xyz position of the nearby site with the lowest z position. Lowest position is best, as starting too high causes smart stacking to autofocus and restart. Starting too low just requires extra movements in +z. - Nearby is defined as within 1.6 times the distance to the closest neighbour. + Nearby is defined as within NEIGHBOUR_CUTOFF times the distance to the closest neighbour. Returns None if there if no focussed locations are present """ @@ -229,10 +234,9 @@ class ScanPlanner: # Note linalg.norm always uses float64 dists = np.linalg.norm((path_pos - current_pos), axis=1) - # Get indices of all focused sites within 1.6x the minimum distance. - # 1.6x chosen as it includes offsets in x and y on the Picamera2 aspect ratio + # Get indices of all focused sites within NEIGHBOUR_CUTOFF the minimum distance. # Note np.where always returns a tuple of arrays, hence the trailing [0] - indices = np.where(dists <= 1.6 * np.min(dists))[0] + indices = np.where(dists <= NEIGHBOUR_CUTOFF * np.min(dists))[0] # Turning into an array allows slicing based on a list focused_locations_array = np.array(self._focused_locations) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 679b0e77..c3359b9d 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -41,10 +41,6 @@ class StackParams(BaseModel): # distance (in steps) to overshoot a move and then undo, to account for backlash backlash_correction: int = 250 - # how many times the minimum distance between images to include as a "nearby" image - # default 1.4 includes images offset in x or y, but not diagonally - neighbour_cutoff: float = 1.4 - # how many images can be appended to the stack after the predicted peak to test for focus # before assuming the focus was passed, and restarting the stack stack_height_limit: int = 15 From 61dd5e49588b69e14b9f0f228c6074deda8c1d47 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 26 Jun 2025 17:43:11 +0100 Subject: [PATCH 25/36] Compete first (untested) pass of trying to make fast stack compatible with new memory captures --- .../things/autofocus.py | 272 +++++++++++------- .../things/camera/__init__.py | 125 ++++++-- .../things/smart_scan.py | 2 +- 3 files changed, 258 insertions(+), 141 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index e3f6cc10..1c0502ea 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -9,8 +9,9 @@ See repository root for licensing information. from contextlib import contextmanager import logging import time -from typing import Annotated, Mapping, Optional, Sequence +from typing import Annotated, Mapping, Optional, Sequence, Literal import os +from dataclasses import dataclass from fastapi import Depends import numpy as np @@ -26,7 +27,8 @@ from labthings_fastapi.dependencies.invocation import InvocationLogger from .camera import RawCameraDependency as Camera from .camera import CameraDependency as WrappedCamera from .stage import StageDependency as Stage -from .capture import RawCaptureDependency as CaptureDep + +# TODO Capture reolution should be save resolution for consistency class StackParams(BaseModel): @@ -39,7 +41,9 @@ class StackParams(BaseModel): is always evaluated with the same number of images. i.e. if min_images_to_test=9, then 9 images are captured, if the stack is not well focussed, a 10th image is captured and images 2 to 10 are evaluated for focus - :param autofocus_dz: The number of steps in a full autofocus (if required) + :param autofocus_dz: The number of steps in a full autofocus (when required) + :param images_dir: The directory to save images to disk + :param capture_resolution: The resolution to save the captures to disk with """ @@ -48,13 +52,15 @@ class StackParams(BaseModel): images_to_save: int min_images_to_test: int autofocus_dz: int + images_dir: str + capture_resolution: tuple[int, int] # The following parameters have values that apply well to a standard scan # and are not altered by default when running a scan # time (in seconds) between moving and capturing an image settling_time: float = 0.3 - + # distance (in steps) to overshoot a move and then undo, to account for backlash backlash_correction: int = 250 @@ -76,7 +82,7 @@ class StackParams(BaseModel): @property def stack_z_range(self) -> int: """The range of the z stack, in steps - + Note that this is the range of the minimum number of image captured, which is also the range of the images sored in memory that can be saved.""" @@ -97,13 +103,58 @@ class StackParams(BaseModel): @computed_field @property - def max_images_to_test(self) -> int + def max_images_to_test(self) -> int: """The maximum number of images that will be captured and tested in a stack This is 15 images more then the minimum number that are captured. """ return self.min_images_to_test - + + def slice_to_save(self, sharpest_index): + """Return the slice of images to save given the index of the sharpest image""" + images_each_side = (self.images_to_save - 1) // 2 + return slice( + sharpest_index - images_each_side, sharpest_index + images_each_side + 1 + ) + + +@dataclass +class CaptureInfo: + """ + The information from a capture in a z_stack + """ + + buffer_id: int + position: tuple[int, int, int] + sharpness: int + + @property + def filename(self) -> str: + """The filename for this image generated from the poistion""" + return f"{self.position[0]}_{self.position[1]}_{self.position[2]}.jpeg" + + +def _get_capture_by_id(captures: list[CaptureInfo], buffer_id: int) -> CaptureInfo: + """Return the capture from a list of CaptureInfo objects with the matching id. + + :param captures: A list of capture objects + :param buffer_id: The buffer id of the image to return + """ + return captures[_get_capture_index_by_id(captures, buffer_id)] + + +def _get_capture_index_by_id(captures: list[CaptureInfo], buffer_id: int) -> int: + """Return the index of the capture with the matching id from a list of CaptureInfo + objects + + :param captures: A list of capture objects + :param buffer_id: The buffer id of the image to return + """ + ids = [capture.buffer_id for capture in captures] + if buffer_id not in ids: + raise ValueError(f"No capture has a buffer id of {buffer_id}") + return ids.index(buffer_id) + class SharpnessDataArrays(BaseModel): jpeg_times: NDArray @@ -372,7 +423,11 @@ class AutofocusThing(Thing): Arguments: images_dir: the folder to save all images autofocus_dz: should the stack fail, the range to refocus over before retrying - variables cam to sharpness_monitor are Thing dependencies injected automatically by LabThings FastAPI + variables cam to sharpness_monitor are Thing dependencies injected automatically by + LabThings FastAPI + + Returns: + The z position of the sharpest image """ # Set the variables to prevent changes from the GUI or other windows @@ -381,6 +436,8 @@ class AutofocusThing(Thing): images_to_save=self.stack_images_to_save, min_images_to_test=self.stack_min_images_to_test, autofocus_dz=autofocus_dz, + images_dir=images_dir, + capture_resolution=capture_resolution, ) # Ensure the stack settings are appropriate @@ -388,22 +445,23 @@ class AutofocusThing(Thing): success = False # Loop until a stack is successful - while not success: - result, heights, captures, sharpest_index = self.z_stack( - images_dir, - stack_parameters, - stage, - cam, - metadata_getter, + while not success: # TODO add a maximum number? + success, captures, sharpest_id = self.z_stack( + stack_parameters=stack_parameters, + stage=stage, + cam=cam, + logger=logger, + metadata_getter=metadata_getter, ) - if result == "success": - success = True + if success: break + # The z position of the first images in the previous attempt. + initial_z_pos = captures[0].position[2] # If a stack is not successful, move to the start and autofocus self.reset_stack( - heights, + initial_z_pos, stack_parameters.autofocus_dz, stage, sharpness_monitor, @@ -411,31 +469,33 @@ class AutofocusThing(Thing): # Save the sharpest image, and images either side of focus self.save_stack( - sharpest_index, - captures, - stack_parameters, - logger, + sharpest_id=sharpest_id, + captures=captures, + stack_parameters=stack_parameters, + cam=cam, + logger=logger, ) # Return the z position of the sharpest image, for path planning and tracking - return heights[-stack_parameters.min_images_to_test :][sharpest_index] + return _get_capture_by_id(captures, sharpest_id).position[2] def reset_stack( self, - heights: list[int], + initial_z_pos: list[int], autofocus_dz: int, stage: Stage, sharpness_monitor: SharpnessMonitorDep, ) -> None: """Return to the initial height of the current stack, and run - a looping autofocus. Clears all previous captures, heights and sharpnesses. + a looping autofocus. Arguments: - heights: a list of the z positions of previous captures + initial_z_pos: The initial z positions of previous captures autofocus_dz: the range in steps to autofocus - variables stage and sharpness_monitor are Thing dependencies passed through from the calling action + variables stage and sharpness_monitor are Thing dependencies passed through from + the calling action """ - stage.move_absolute(z=heights[0]) + stage.move_absolute(z=initial_z_pos) self.looping_autofocus( stage=stage, sharpness_monitor=sharpness_monitor, @@ -444,48 +504,52 @@ class AutofocusThing(Thing): def save_stack( self, - sharpest_index: int, + sharpest_id: int, captures: list[list], stack_parameters: StackParams, + cam: WrappedCamera, logger: InvocationLogger, ) -> int: """Save the required captures to disk. Will save the sharpest image, and any images either side of focus. Arguments: - sharpest_index: the index of the sharpest image, within the "min_images_to_test" slice + sharpest_id: the buffer id index of the sharpest image captures: a list of captures, including file name, image data and metadata stack_parameters: a StackParams Pydantic model with stack settings - variables logger and capture are Thing dependencies passed through from the calling action + variables logger and capture are Thing dependencies passed through from the + calling action """ - + + sharpest_index = _get_capture_index_by_id(sharpest_id) + slice_to_save = stack_parameters.slice_to_save(sharpest_index) # Loop through the range, saving each capture to disk - for capture_index in stack_range: - capture._save_capture( - jpeg_path=captures[-stack_parameters.min_images_to_test :][capture_index][ - 0 - ], - image=captures[-stack_parameters.min_images_to_test :][capture_index][1], - metadata=captures[-stack_parameters.min_images_to_test :][capture_index][2], + for capture in captures[slice_to_save]: + cam.save_from_memory( + jpeg_path=os.path.join(stack_parameters.images_dir, capture.filename), logger=logger, + save_resolution=stack_parameters.capture_resolution, + buffer_id=capture.buffer_id, ) + cam.clear_buffers() return sharpest_index def z_stack( self, - images_dir: str, stack_parameters: StackParams, stage: Stage, cam: WrappedCamera, - capture: CaptureDep, + logger: InvocationLogger, metadata_getter: GetThingStates, - ) -> list: + ) -> tuple[bool, list[CaptureInfo], Optional[int]]: """Capture a series of images offset by stack_parameters.stack_dz, and test whether the sharpest image is towards the centre of the stack. - Returns a test result string, a list of z positions, a list of captures and the - index of the sharpest image in a successful stack. + Returns: + - the stack result (True for successful stack, False for failed stack), + - a list of CaptureInfo objects, + - the buffer_id of the shapest image (or None if the stack failed) Arguments: images_dir: a string of the path to write all images @@ -504,80 +568,60 @@ class AutofocusThing(Thing): stage.move_relative(z=stack_parameters.backlash_correction) captures = [] - sharpnesses = [] - heights = [] + # Always check for focus using the the last `min_images_to_test` in the + # stack so check is fair + ims_to_check = slice(-stack_parameters.min_images_to_test, None) - # If the sharpest image isn't found within the 15 images above the estimated point, break - # the loop and return "restart" - while len(captures) <= stack_parameters.min_images_to_test + 15: + # If the sharpest image isn't found within the maximum number of images + # end the loop and return "restart" + while len(captures) <= stack_parameters.max_images_to_test: time.sleep(stack_parameters.settling_time) # Append a new image to the stack - captures, heights, sharpnesses = self.capture_stack_image( - captures, - heights, - sharpnesses, - images_dir, - cam, - stage, - capture, - metadata_getter, + captures.append( + self.capture_stack_image( + cam, + stage, + logger, + metadata_getter, + buffer_max=stack_parameters.min_images_to_test, + ) ) # If the number of images is enough to test, test them if len(captures) >= stack_parameters.min_images_to_test: - stack_result = self.check_stack_result( - sharpnesses[-stack_parameters.min_images_to_test :] - ) + result, capture_id = self.check_stack_result(captures[ims_to_check]) - if stack_result == "success": - sharpest_index = np.argmax( - sharpnesses[-stack_parameters.min_images_to_test :] - ) - return "success", heights, captures, sharpest_index - - if stack_result == "restart": - return "restart", heights, None, None + if result == "success": + return True, captures, capture_id + if result == "restart": + return False, captures, None + # If reached here the result was "continue" stage.move_relative(z=stack_parameters.stack_dz) - return "restart", heights, None, None + return False, captures, None def capture_stack_image( self, - captures: list[list], - heights: list[int], - sharpnesses: list[int], - images_dir: str, cam: WrappedCamera, stage: Stage, - capture: CaptureDep, + logger: InvocationLogger, metadata_getter: GetThingStates, + buffer_max: int, ) -> list: - """Append a new capture to the ongoing stack. - Includes appending the height, image sharpness and image - data to the relevant lists. + """Capture another image and return the capture information. - arguments: - captures: a list of captures, including file name, image data and metadata - heights: a list of the z positions of previous captures - sharpnesses: a list of the sharpnesses of previous captures - images_dir: a path to the folder to save images - variables stage to metadata_getter are Thing dependencies passed through from the calling action + The capture is stored by the camera Thing, and can be saved by ID. """ stage_location = stage.position - jpeg_path = os.path.join( - images_dir, - f"{stage_location['x']}_{stage_location['y']}_{stage_location['z']}.jpeg", + buffer_id = cam.capture_to_memory( + logger, metadata_getter, buffer_max=buffer_max ) - image, metadata = capture._capture_image( - cam=cam, - metadata_getter=metadata_getter, + return CaptureInfo( + buffer_id=buffer_id, + position=stage_location, + sharpness=cam.grab_jpeg_size(stream_name="lores"), ) - captures.append([jpeg_path, image, metadata]) - sharpnesses.append(cam.grab_jpeg_size(stream_name="lores")) - heights.append(stage_location["z"]) - - return captures, heights, sharpnesses def validate_stack_inputs(self, stack_parameters) -> None: """Check the stack settings are appropriate, and raise an error if not @@ -598,38 +642,44 @@ class AutofocusThing(Thing): ): raise RuntimeError("Stack parameters need to be at least 1") - def check_stack_result(self, sharpnesses: list[int]) -> str: - """Test a list of sharpnesses, to decide whether the sharpest image from a stack is within them + def check_stack_result( + self, captures: list[CaptureInfo] + ) -> tuple[Literal["success", "continue", "restart"], int]: + """Test a list of captures, to decide whether the sharpest image from a + stack is within them - Returns a string - 'success' if the sharpest image is towards the centre - 'continue' if the sharpest image is in the final two images of the list - 'restart' if the sharpest image is in the first two images of the list + Returns two values, + result - which is one of three literal values: + 'success' if the sharpest image is towards the centre + 'continue' if the sharpest image is in the final two images of the list + 'restart' if the sharpest image is in the first two images of the list + capture_id - the buffer id of the sharpest image Arguments: sharpnesses: a list of the sharpnesses to test for focus """ - - sharpest_index = np.argmax(sharpnesses) - sharpness_length = len(sharpnesses) + sharpest_index = np.argmax([capture.sharpness for capture in captures]) + # The buffer id of the sharpest image + capture_id = captures[sharpest_index].buffer_id + sharpness_length = len(captures) # If only testing one image, then by definition the sharpest is central if sharpness_length == 1: - return "success" + return "success", capture_id # If testing three images, test if the centre is the sharpest if sharpness_length == 3: if sharpest_index == 1: - return "success" + return "success", capture_id if sharpest_index == 0: - return "restart" - return "continue" + return "restart", capture_id + return "continue", capture_id # For larger stacks, test if the best image is not within two of the edge of the stack # ie for a stack of 7 images, best image must be between 3rd and and 5th exclusion_range = 2 if sharpest_index < exclusion_range: - return "restart" + return "restart", capture_id if sharpest_index >= sharpness_length - exclusion_range: - return "continue" - return "success" + return "continue", capture_id + return "success", capture_id diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 14376e0f..e22a9b04 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -7,7 +7,7 @@ See repository root for licensing information. """ from __future__ import annotations -from typing import Literal, Optional, Tuple +from typing import Literal, Optional, Tuple, Any import json from pydantic import RootModel @@ -50,13 +50,78 @@ class NoImageInMemoryError(RuntimeError): """An error called if no image in in memory when an method is called to use that image""" +class CameraMemoryBuffer: + """ + A class that holds images in memory. The images are by default PIL images. + + However subclasses of BaseCamera can use this class to store other object types + """ + + _storage: dict[int, tuple[Any, Optional[dict]]] + + def __init__(self): + self._storage = {} + self._latest_id: int = 0 + + def add_image( + self, image: Any, metadata: Optional[dict], buffer_max: int = 1 + ) -> None: + self._latest_id += 1 + self._create_space(buffer_max) + self._storage[self._latest_id] = (image, metadata) + return self._latest_id + + def get_image( + self, buffer_id: Optional[int] = None, remove: bool = True + ) -> tuple[Any, Optional[dict]]: + """ + If the buffer ID is not set, always clear whole buffer + """ + + # No id given + if buffer_id is None: + # Get the latest image and metadata tuple from storage + try: + image_tuple = list(self._storage.value())[-1] + except IndexError as e: + raise NoImageInMemoryError("No image in memory to save.") from e + # Clear the storage so images don't get retrieved out of order + self._storage.clear() + return image_tuple + + if remove: + return self._storage.pop(buffer_id) + return self._storage[buffer_id] + + def clear(self): + self._storage.clear() + + def _create_space(self, buffer_max: int) -> None: + """ + Create space to add an image. + """ + # If only one image to be stored just clear the storage and return + if buffer_max <= 1: + self._storage.clear() + return + + # Number to remove to get the storage down to 1 less than the buffer length + to_remove = len(self._storage) - (buffer_max - 1) + # If if there is space. Nothing to do, just return + if to_remove < 1: + return + + keys_to_remove = list(self._storage.keys())[:to_remove] + for key in keys_to_remove: + del self._storage[key] + + class BaseCamera(Thing): """The base class for all cameras. All cameras must directly inherit from this class""" - _memory_image: Optional[Image] = None - _memory_metadata: Optional[dict] = None mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor() + _memory_buffer = CameraMemoryBuffer() def __enter__(self) -> None: raise NotImplementedError("CameraThings must define their own __enter__ method") @@ -64,17 +129,6 @@ class BaseCamera(Thing): def __exit__(self, _exc_type, _exc_value, _traceback) -> None: raise NotImplementedError("CameraThings must define their own __exit__ method") - @thing_property - def image_in_memory(self) -> bool: - """True if an image is in memory ready to be saved""" - return self._memory_image is not None and self._memory_metadata is not None - - @thing_action - def clear_image_memory(self) -> None: - """Clear any image in memory""" - self._memory_image = None - self._memory_metadata = None - @thing_action def start_streaming( self, main_resolution: tuple[int, int], buffer_count: int @@ -133,6 +187,18 @@ class BaseCamera(Thing): frame = portal.call(stream.grab_frame) return JPEGBlob.from_bytes(frame) + @thing_action + def grab_jpeg_size( + self, + portal: BlockingPortal, + stream_name: Literal["main", "lores"] = "main", + ) -> int: + """Acquire one image from the preview stream and return its size""" + stream = ( + self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream + ) + return portal.call(stream.next_frame_size) + @thing_action def capture_image( self, @@ -173,20 +239,18 @@ class BaseCamera(Thing): @thing_action def capture_to_memory( - self, - logger: InvocationLogger, - metadata_getter: GetThingStates, + self, logger: InvocationLogger, metadata_getter: GetThingStates, buffer_max ) -> None: """ Capture an image to memory. This can be saved later with `save_from_memory` Note that only one image is held in memory so this will overwrite any image in memory. + + Return the buffer id of the image captured """ - self._memory_image, self._memory_metadata = self._robust_image_capture( - metadata_getter, - logger=logger, - ) + image, metadata = self._robust_image_capture(metadata_getter, logger) + return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max) @thing_action def save_from_memory( @@ -194,21 +258,21 @@ class BaseCamera(Thing): jpeg_path: str, logger: InvocationLogger, save_resolution: Optional[Tuple[int, int]] = None, + buffer_id: Optional[int] = None, ) -> None: """ Save an image that has been captured to memory. """ - if not self.image_in_memory: - raise NoImageInMemoryError("No image in memory to save.") + image, metadata = self._memory_buffer.get_image(buffer_id) self._save_capture( jpeg_path=jpeg_path, - image=self._memory_image, - metadata=self._memory_metadata, + image=image, + metadata=metadata, logger=logger, save_resolution=save_resolution, ) - self.clear_image_memory() + self._memory_buffer.clear() def _robust_image_capture( self, @@ -248,10 +312,13 @@ class BaseCamera(Thing): if save_resolution is not None and image.size != save_resolution: image = image.resize(save_resolution, Image.BOX) try: - # Per PIL documentation, (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg) - # there are two factors when saving a JPEG. subsampling affects the colour, quality the pixels + # Per PIL documentation, + # (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg) + # there are two factors when saving a JPEG. Subsampling affects the colour, + # quality affects the pixels. # subsampling = 0 disables subsampling of colour - # quality = 95 is the maximum recommended - above this, JPEG compression is disabled, file size increases and quality is barely or not affected + # quality = 95 is the maximum recommended - above this, JPEG compression is + # disabled, file size increases and quality is barely or not affected image.save(jpeg_path, quality=95, subsampling=0) try: # Load EXIF metadata from image so it can be added to. diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index b29fc2f3..8204ade4 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -665,7 +665,7 @@ class SmartScanThing(Thing): ) route_planner.mark_location_visited( - current_pos_xyz, imaged=True, focused=focused + current_pos_xyz, imaged=True, focused=True ) site_folder = os.path.join( From 6a87805b476be29241065227fa7eb475d84416b7 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 26 Jun 2025 18:29:12 +0100 Subject: [PATCH 26/36] Fixing a number of issues from the merge and refactor. --- .../things/autofocus.py | 29 ++++--------------- .../things/camera/__init__.py | 9 ++++-- .../things/smart_scan.py | 13 +-------- 3 files changed, 13 insertions(+), 38 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 1c0502ea..5fc53701 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -20,9 +20,7 @@ from pydantic import BaseModel, computed_field from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.blocking_portal import BlockingPortal from labthings_fastapi.decorators import thing_action, thing_property -from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.types.numpy import NDArray -from labthings_fastapi.dependencies.invocation import InvocationLogger from .camera import RawCameraDependency as Camera from .camera import CameraDependency as WrappedCamera @@ -108,7 +106,7 @@ class StackParams(BaseModel): This is 15 images more then the minimum number that are captured. """ - return self.min_images_to_test + return self.min_images_to_test + 15 def slice_to_save(self, sharpest_index): """Return the slice of images to save given the index of the sharpest image""" @@ -131,7 +129,7 @@ class CaptureInfo: @property def filename(self) -> str: """The filename for this image generated from the poistion""" - return f"{self.position[0]}_{self.position[1]}_{self.position[2]}.jpeg" + return f"{self.position['x']}_{self.position['y']}_{self.position['z']}.jpeg" def _get_capture_by_id(captures: list[CaptureInfo], buffer_id: int) -> CaptureInfo: @@ -408,8 +406,6 @@ class AutofocusThing(Thing): self, cam: WrappedCamera, stage: Stage, - logger: InvocationLogger, - metadata_getter: GetThingStates, sharpness_monitor: SharpnessMonitorDep, images_dir: str, autofocus_dz: int, @@ -450,15 +446,13 @@ class AutofocusThing(Thing): stack_parameters=stack_parameters, stage=stage, cam=cam, - logger=logger, - metadata_getter=metadata_getter, ) if success: break # The z position of the first images in the previous attempt. - initial_z_pos = captures[0].position[2] + initial_z_pos = captures[0].position["z"] # If a stack is not successful, move to the start and autofocus self.reset_stack( initial_z_pos, @@ -473,11 +467,10 @@ class AutofocusThing(Thing): captures=captures, stack_parameters=stack_parameters, cam=cam, - logger=logger, ) # Return the z position of the sharpest image, for path planning and tracking - return _get_capture_by_id(captures, sharpest_id).position[2] + return _get_capture_by_id(captures, sharpest_id).position["z"] def reset_stack( self, @@ -508,7 +501,6 @@ class AutofocusThing(Thing): captures: list[list], stack_parameters: StackParams, cam: WrappedCamera, - logger: InvocationLogger, ) -> int: """Save the required captures to disk. Will save the sharpest image, and any images either side of focus. @@ -521,14 +513,13 @@ class AutofocusThing(Thing): calling action """ - sharpest_index = _get_capture_index_by_id(sharpest_id) + sharpest_index = _get_capture_index_by_id(captures, sharpest_id) slice_to_save = stack_parameters.slice_to_save(sharpest_index) # Loop through the range, saving each capture to disk for capture in captures[slice_to_save]: cam.save_from_memory( jpeg_path=os.path.join(stack_parameters.images_dir, capture.filename), - logger=logger, save_resolution=stack_parameters.capture_resolution, buffer_id=capture.buffer_id, ) @@ -540,8 +531,6 @@ class AutofocusThing(Thing): stack_parameters: StackParams, stage: Stage, cam: WrappedCamera, - logger: InvocationLogger, - metadata_getter: GetThingStates, ) -> tuple[bool, list[CaptureInfo], Optional[int]]: """Capture a series of images offset by stack_parameters.stack_dz, and test whether the sharpest image is towards the centre of the stack. @@ -582,8 +571,6 @@ class AutofocusThing(Thing): self.capture_stack_image( cam, stage, - logger, - metadata_getter, buffer_max=stack_parameters.min_images_to_test, ) ) @@ -605,8 +592,6 @@ class AutofocusThing(Thing): self, cam: WrappedCamera, stage: Stage, - logger: InvocationLogger, - metadata_getter: GetThingStates, buffer_max: int, ) -> list: """Capture another image and return the capture information. @@ -614,9 +599,7 @@ class AutofocusThing(Thing): The capture is stored by the camera Thing, and can be saved by ID. """ stage_location = stage.position - buffer_id = cam.capture_to_memory( - logger, metadata_getter, buffer_max=buffer_max - ) + buffer_id = cam.capture_to_memory(buffer_max=buffer_max) return CaptureInfo( buffer_id=buffer_id, position=stage_location, diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index e22a9b04..0e1b26d6 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -221,8 +221,8 @@ class BaseCamera(Thing): """Capture an image and save it to disk - save_resolution can be set to resize the image before saving. By default this is None - meaning that the image is saved at original resoltion. + save_resolution can be set to resize the image before saving. By default this + is None meaning that the image is saved at original resoltion. """ image, metadata = self._robust_image_capture( metadata_getter, @@ -239,7 +239,7 @@ class BaseCamera(Thing): @thing_action def capture_to_memory( - self, logger: InvocationLogger, metadata_getter: GetThingStates, buffer_max + self, logger: InvocationLogger, metadata_getter: GetThingStates, buffer_max: int ) -> None: """ Capture an image to memory. This can be saved later with `save_from_memory` @@ -272,6 +272,9 @@ class BaseCamera(Thing): logger=logger, save_resolution=save_resolution, ) + + @thing_action + def clear_buffers(self) -> None: self._memory_buffer.clear() def _robust_image_capture( diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 8204ade4..f94f715a 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -656,6 +656,7 @@ class SmartScanThing(Thing): focused_height = self._autofocus.run_smart_stack( images_dir=self._ongoing_scan_images_dir, autofocus_dz=self._scan_data["autofocus_dz"], + capture_resolution=self._scan_data["capture_resolution"], ) current_pos_xyz = ( @@ -668,18 +669,6 @@ class SmartScanThing(Thing): current_pos_xyz, imaged=True, focused=True ) - site_folder = os.path.join( - self._ongoing_scan_images_dir, - "stacks", - f"{new_pos_xyz[0]}_{new_pos_xyz[1]}", - ) - os.makedirs(site_folder, exist_ok=True) - self._autofocus.run_z_stack( - images_dir=self._ongoing_scan_images_dir, - stack_dir=site_folder, - capture_resolution=self._scan_data["capture_resolution"], - ) - # increment capure counter as thread has completed self._scan_images_taken += 1 # Add it to the incremental zip From 43d5f2fdfe511ea55d9d8caba0d392c4b50cdd87 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 26 Jun 2025 19:00:52 +0100 Subject: [PATCH 27/36] Change `capture_resolution` to save resolution, because it is used for saving not capture --- .../things/autofocus.py | 10 ++++----- .../things/smart_scan.py | 22 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 5fc53701..1c06c338 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -41,7 +41,7 @@ class StackParams(BaseModel): captured and images 2 to 10 are evaluated for focus :param autofocus_dz: The number of steps in a full autofocus (when required) :param images_dir: The directory to save images to disk - :param capture_resolution: The resolution to save the captures to disk with + :param save_resolution: The resolution to save the captures to disk with """ @@ -51,7 +51,7 @@ class StackParams(BaseModel): min_images_to_test: int autofocus_dz: int images_dir: str - capture_resolution: tuple[int, int] + save_resolution: tuple[int, int] # The following parameters have values that apply well to a standard scan # and are not altered by default when running a scan @@ -409,7 +409,7 @@ class AutofocusThing(Thing): sharpness_monitor: SharpnessMonitorDep, images_dir: str, autofocus_dz: int, - capture_resolution: tuple[int, int], + save_resolution: tuple[int, int], ) -> None: """Run a smart stack, which captures images offset in z, testing whether the sharpest image is towards the centre of the stack. @@ -433,7 +433,7 @@ class AutofocusThing(Thing): min_images_to_test=self.stack_min_images_to_test, autofocus_dz=autofocus_dz, images_dir=images_dir, - capture_resolution=capture_resolution, + save_resolution=save_resolution, ) # Ensure the stack settings are appropriate @@ -520,7 +520,7 @@ class AutofocusThing(Thing): for capture in captures[slice_to_save]: cam.save_from_memory( jpeg_path=os.path.join(stack_parameters.images_dir, capture.filename), - save_resolution=stack_parameters.capture_resolution, + save_resolution=stack_parameters.save_resolution, buffer_id=capture.buffer_id, ) cam.clear_buffers() diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index f94f715a..8ca4c1a1 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -421,7 +421,7 @@ class SmartScanThing(Thing): """ overlap = self.overlap dx, dy = self._calc_displacement_from_test_image(overlap) - stitch_resize = STITCHING_RESOLUTION[0] / self.capture_resolution[0] + stitch_resize = STITCHING_RESOLUTION[0] / self.save_resolution[0] self._scan_logger.debug( f"Resizing images when stitching by a factor of {stitch_resize}" @@ -454,7 +454,7 @@ class SmartScanThing(Thing): "skip_background": self.skip_background, "stitch_automatically": self.stitch_automatically, "stitch_resize": stitch_resize, - "capture_resolution": self.capture_resolution, + "save_resolution": self.save_resolution, } @_scan_running @@ -473,7 +473,7 @@ class SmartScanThing(Thing): "dy": self._scan_data["dy"], "start time": self._scan_data["start_time"], "skipping background": self._scan_data["skip_background"], - "capture resolution": self._scan_data["capture_resolution"], + "capture resolution": self._scan_data["save_resolution"], } scan_inputs_fname = os.path.join( @@ -656,7 +656,7 @@ class SmartScanThing(Thing): focused_height = self._autofocus.run_smart_stack( images_dir=self._ongoing_scan_images_dir, autofocus_dz=self._scan_data["autofocus_dz"], - capture_resolution=self._scan_data["capture_resolution"], + save_resolution=self._scan_data["save_resolution"], ) current_pos_xyz = ( @@ -737,14 +737,14 @@ class SmartScanThing(Thing): return FileResponse(path) @thing_property - def capture_resolution(self) -> tuple[int, int]: + def save_resolution(self) -> tuple[int, int]: """A tuple of the image resolution to capture. Should be in a 4:3 aspect ratio""" - return self.thing_settings.get("capture_resolution", ((1640, 1232))) + return self.thing_settings.get("save_resolution", ((1640, 1232))) - @capture_resolution.setter - def capture_resolution(self, value: tuple[int, int]) -> None: - self.thing_settings["capture_resolution"] = value + @save_resolution.setter + def save_resolution(self, value: tuple[int, int]) -> None: + self.thing_settings["save_resolution"] = value @thing_property def max_range(self) -> int: @@ -1111,8 +1111,8 @@ class SmartScanThing(Thing): try: with open(json_fpath, "r", encoding="utf-8") as data_file: data_loaded = json.load(data_file) - capture_resolution = data_loaded["capture resolution"] - stitch_resize = STITCHING_RESOLUTION[0] / capture_resolution[0] + save_resolution = data_loaded["capture resolution"] + stitch_resize = STITCHING_RESOLUTION[0] / save_resolution[0] except (json.decoder.JSONDecodeError, FileNotFoundError, TypeError): # As there is no schema or pydantic model this should handle # the file not being there, it not being json in the file, From 2aea6a256943a4174f9970dfd26fcdcf5deff954 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 26 Jun 2025 19:46:25 +0100 Subject: [PATCH 28/36] Docstrings and stop using Pydantic for ScanParameters --- .../scan_planners.py | 3 +- .../things/autofocus.py | 99 +++++++++---------- .../things/camera/__init__.py | 72 ++++++++++++-- 3 files changed, 113 insertions(+), 61 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index d9c26b9a..2ebfc54a 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -21,7 +21,8 @@ XYZPosList: TypeAlias = list[XYZPos] # how many times the minimum distance between images to include as a "nearby" image -# default 1.4 includes images offset in x or y, but not diagonally +# default 1.4 includes images offset in x or y, but not diagonally. +# This wis based of a 4:3 aspect ratio. So x moves are 1.33 times larger than y NEIGHBOUR_CUTOFF = 1.4 diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 1c06c338..9d1ab25f 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from fastapi import Depends import numpy as np -from pydantic import BaseModel, computed_field +from pydantic import BaseModel from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.blocking_portal import BlockingPortal @@ -29,8 +29,10 @@ from .stage import StageDependency as Stage # TODO Capture reolution should be save resolution for consistency -class StackParams(BaseModel): - """Pydantic model for scan parameters +class StackParams: + """A class for holding for scan parameters + + All arguments are keyword only :param stack_dz: The number of motor steps between images :param images_to_save: The number of images to save to disk @@ -42,41 +44,58 @@ class StackParams(BaseModel): :param autofocus_dz: The number of steps in a full autofocus (when required) :param images_dir: The directory to save images to disk :param save_resolution: The resolution to save the captures to disk with - """ - # These parameters must be set on initialisation - stack_dz: int - images_to_save: int - min_images_to_test: int - autofocus_dz: int - images_dir: str - save_resolution: tuple[int, int] + def __init__( + self, + *, + stack_dz: int, + images_to_save: int, + min_images_to_test: int, + autofocus_dz: int, + images_dir: str, + save_resolution: tuple[int, int], + ) -> None: + if min_images_to_test < images_to_save: + raise ValueError("Can't save more images than the minimum number tested") + if min_images_to_test % 2 == 0 or min_images_to_test <= 0: + raise ValueError( + "Minimum number of images to test should be positive and odd" + ) + if images_to_save % 2 == 0 or images_to_save <= 0: + raise ValueError("Images to svae must be positive and odd") - # The following parameters have values that apply well to a standard scan - # and are not altered by default when running a scan + self.stack_dz = stack_dz + self.images_to_save = images_to_save + self.min_images_to_test = min_images_to_test + self.autofocus_dz = autofocus_dz + self.images_dir = images_dir + self.save_resolution = save_resolution + + # Using docstrings under variables as this is how pdoc would expect + # attributed to be documented - # time (in seconds) between moving and capturing an image settling_time: float = 0.3 + """time (in seconds) between moving and capturing an image""" - # distance (in steps) to overshoot a move and then undo, to account for backlash backlash_correction: int = 250 + """ + distance (in steps) to overshoot a move and then undo, to account for backlash + """ - # how many images can be appended to the stack after the predicted peak to test - # for focus before assuming the focus was passed, and restarting the stack stack_height_limit: int = 15 + """ + how many images can be appended to the stack after the predicted peak to test + for focus before assuming the focus was passed, and restarting the stack + """ - # how far below (in factors of stack_dz) the estimated optimal starting position to - # begin the stack. Better to start slightly too low and require many images, rather - # than too high and needing to autofocus and restart the stack img_undershoot: int = 5 + """ + how far below (in factors of stack_dz) the estimated optimal starting position to + begin the stack. Better to start slightly too low and require many images, rather + than too high and needing to autofocus and restart the stack + """ - # Per pydantic docs, even with the @property applied before @computed_field, - # mypy may throw a Decorated property not supported error (mypy issue #1362) - # To avoid this error message, add # type: ignore[prop-decorator] to the - # @computed_field line. - - @computed_field @property def stack_z_range(self) -> int: """The range of the z stack, in steps @@ -86,7 +105,6 @@ class StackParams(BaseModel): saved.""" return self.stack_dz * (self.min_images_to_test - 1) - @computed_field @property def steps_undershoot(self) -> int: """ @@ -99,7 +117,6 @@ class StackParams(BaseModel): # requires extra +z movements and captures. return self.stack_dz * self.img_undershoot - @computed_field @property def max_images_to_test(self) -> int: """The maximum number of images that will be captured and tested in a stack @@ -436,9 +453,6 @@ class AutofocusThing(Thing): save_resolution=save_resolution, ) - # Ensure the stack settings are appropriate - self.validate_stack_inputs(stack_parameters) - success = False # Loop until a stack is successful while not success: # TODO add a maximum number? @@ -508,7 +522,7 @@ class AutofocusThing(Thing): Arguments: sharpest_id: the buffer id index of the sharpest image captures: a list of captures, including file name, image data and metadata - stack_parameters: a StackParams Pydantic model with stack settings + stack_parameters: a StackParams object holding stack parameters variables logger and capture are Thing dependencies passed through from the calling action """ @@ -542,7 +556,7 @@ class AutofocusThing(Thing): Arguments: images_dir: a string of the path to write all images - stack_parameters: a StackParams Pydantic model with stack settings + stack_parameters: a StackParams object holding stack parameters variables stage to metadata_getter are Thing dependencies passed through from the calling action """ # Move down by the height of the z stack, plus an overshoot @@ -606,25 +620,6 @@ class AutofocusThing(Thing): sharpness=cam.grab_jpeg_size(stream_name="lores"), ) - def validate_stack_inputs(self, stack_parameters) -> None: - """Check the stack settings are appropriate, and raise an error if not - - Arguments: - min_images_to_test: number of images in the stack to test for focus - images_to_save: number of images to be captured around the focused image - """ - if stack_parameters.min_images_to_test < stack_parameters.images_to_save: - raise RuntimeError( - "Can't capture more images than are tested. Please increase number to test, or decrease number to capture" - ) - if stack_parameters.min_images_to_test % 2 == 0: - raise RuntimeError("Images to test should be odd") - if ( - stack_parameters.min_images_to_test <= 0 - or stack_parameters.images_to_save <= 0 - ): - raise RuntimeError("Stack parameters need to be at least 1") - def check_stack_result( self, captures: list[CaptureInfo] ) -> tuple[Literal["success", "continue", "restart"], int]: diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 0e1b26d6..e541b11d 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -60,12 +60,31 @@ class CameraMemoryBuffer: _storage: dict[int, tuple[Any, Optional[dict]]] def __init__(self): + # This dictionary is the main store for data. Dictionaries are ordered since + # Python 3.6, so the order in the dictionary is the capture order self._storage = {} + # A simple id system where each capture id is just the number of captures since + # the server starts self._latest_id: int = 0 def add_image( - self, image: Any, metadata: Optional[dict], buffer_max: int = 1 + self, image: Any, metadata: Optional[dict] = None, buffer_max: int = 1 ) -> None: + """ + Add an image to the Memory buffer + + This will add an image to the memory buffer. By default the buffer will + be cleared. To allow saving multiple images the buffer_max must be set + every time an image is added. + + :param image: The image to add. A PIL image is recommended, but cameras + can choose to use other formats + :param metadata: Optional, a dictionary of the image metadata. + :param buffer_max: The maximum number of images that should be in the buffer + once this images is added. Default is 1. + + :return buffer_id: The id in the buffer for this image + """ self._latest_id += 1 self._create_space(buffer_max) self._storage[self._latest_id] = (image, metadata) @@ -75,14 +94,22 @@ class CameraMemoryBuffer: self, buffer_id: Optional[int] = None, remove: bool = True ) -> tuple[Any, Optional[dict]]: """ - If the buffer ID is not set, always clear whole buffer + Return the image with the given id. + + If no id is given the most recent image is returned. However, the + buffer is also cleared, otherwise it would be possible to accidentally + retrieve images out of order. + + :param buffer_id: The buffer id of the image to retrieve + :param remove: True (default) to remove this image from the buffer, False + to leave the image in the buffer. """ # No id given if buffer_id is None: # Get the latest image and metadata tuple from storage try: - image_tuple = list(self._storage.value())[-1] + image_tuple = list(self._storage.values())[-1] except IndexError as e: raise NoImageInMemoryError("No image in memory to save.") from e # Clear the storage so images don't get retrieved out of order @@ -94,11 +121,17 @@ class CameraMemoryBuffer: return self._storage[buffer_id] def clear(self): + """ + Clear all images from memory + """ self._storage.clear() def _create_space(self, buffer_max: int) -> None: """ Create space to add an image. + + :param buffer_max: The maximum number of images that should be in the buffer + once another images is added. """ # If only one image to be stored just clear the storage and return if buffer_max <= 1: @@ -220,9 +253,13 @@ class BaseCamera(Thing): ) -> None: """Capture an image and save it to disk - - save_resolution can be set to resize the image before saving. By default this - is None meaning that the image is saved at original resoltion. + :param jpeg_path: The path to save the file to + :param logger: This should be injected automatically by Labthings FastAPI + when calling the action + :param metadata_getter: This should be injected automatically by Labthings + FastAPI when calling the action + :param save_resolution: can be set to resize the image before saving. By + default this is None meaning that the image is saved at original resolution. """ image, metadata = self._robust_image_capture( metadata_getter, @@ -239,7 +276,10 @@ class BaseCamera(Thing): @thing_action def capture_to_memory( - self, logger: InvocationLogger, metadata_getter: GetThingStates, buffer_max: int + self, + logger: InvocationLogger, + metadata_getter: GetThingStates, + buffer_max: int = 1, ) -> None: """ Capture an image to memory. This can be saved later with `save_from_memory` @@ -247,7 +287,14 @@ class BaseCamera(Thing): Note that only one image is held in memory so this will overwrite any image in memory. - Return the buffer id of the image captured + :param logger: This should be injected automatically by Labthings FastAPI + when calling the action + :param metadata_getter: This should be injected automatically by Labthings + FastAPI when calling the action + :param buffer_max: The maximum number of images that should be in the buffer + once this images is added. Default is 1. + + :return: the buffer id of the image captured """ image, metadata = self._robust_image_capture(metadata_getter, logger) return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max) @@ -262,6 +309,14 @@ class BaseCamera(Thing): ) -> None: """ Save an image that has been captured to memory. + + :param jpeg_path: The path to save the file to + :param logger: This should be injected automatically by Labthings FastAPI + when calling the action + :param save_resolution: can be set to resize the image before saving. By + default this is None meaning that the image is saved at original resolution. + :param buffer_id: The buffer id of the image to save, this was returned by + `capture_to_memory` """ image, metadata = self._memory_buffer.get_image(buffer_id) @@ -275,6 +330,7 @@ class BaseCamera(Thing): @thing_action def clear_buffers(self) -> None: + """Clear all images in memory""" self._memory_buffer.clear() def _robust_image_capture( From 05af5e754884ad7292d74fd4371288d4c7548ae6 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 26 Jun 2025 22:49:09 +0100 Subject: [PATCH 29/36] Write unit teasts for CameraMemoryBuffer and fix minor issues found --- .../things/camera/__init__.py | 13 +- tests/test_camera_buffer.py | 208 ++++++++++++++++++ 2 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 tests/test_camera_buffer.py diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index e541b11d..753aa66f 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -111,14 +111,19 @@ class CameraMemoryBuffer: try: image_tuple = list(self._storage.values())[-1] except IndexError as e: - raise NoImageInMemoryError("No image in memory to save.") from e + raise NoImageInMemoryError("No image in memory to retrieve.") from e # Clear the storage so images don't get retrieved out of order self._storage.clear() return image_tuple - if remove: - return self._storage.pop(buffer_id) - return self._storage[buffer_id] + try: + if remove: + return self._storage.pop(buffer_id) + return self._storage[buffer_id] + except KeyError as e: + raise NoImageInMemoryError( + "No image with matching id in memory to retrieve." + ) from e def clear(self): """ diff --git a/tests/test_camera_buffer.py b/tests/test_camera_buffer.py new file mode 100644 index 00000000..35c226f7 --- /dev/null +++ b/tests/test_camera_buffer.py @@ -0,0 +1,208 @@ +""" +Tests for the built in buffer for the camera. +""" + +from random import randint + +import numpy as np +from PIL import Image +import pytest + + +from openflexure_microscope_server.things.camera import ( + CameraMemoryBuffer, + NoImageInMemoryError, +) + +RANDOM_GENERATOR = np.random.default_rng() + + +def random_image(): + """Create a random image""" + imarray = RANDOM_GENERATOR.integers( + low=0, high=255, size=(100, 100, 3), dtype="uint8" + ) + return Image.fromarray(imarray) + + +def random_metadata(): + """Create a misc dictionary to pretend to be metadata""" + # Not very metadata like, but we are just the same dict it is returned + return {"a": randint(1, 100), "b": randint(1, 100)} + + +def test_add_and_get_image(): + """Check images can be captured and retrieved""" + mem_buf = CameraMemoryBuffer() + misc_image = random_image() + buffer_id = mem_buf.add_image(misc_image) + returned_image, _ = mem_buf.get_image(buffer_id) + # It is the same image + assert misc_image is returned_image + # It is now removed from memory + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image(buffer_id) + + +def test_add_and_get_image_twice(): + """Check images can be retrieved twice if remove flag set false""" + mem_buf = CameraMemoryBuffer() + misc_image = random_image() + buffer_id = mem_buf.add_image(misc_image) + returned_image, _ = mem_buf.get_image(buffer_id, remove=False) + # It is the same image + assert misc_image is returned_image + # It is still in memory + returned_image, _ = mem_buf.get_image(buffer_id) + assert misc_image is returned_image + # It is now removed from memory + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image(buffer_id) + + +def test_get_without_id(): + """Check images can be captured and retrieved without ID""" + mem_buf = CameraMemoryBuffer() + misc_image = random_image() + mem_buf.add_image(misc_image) + returned_image, _ = mem_buf.get_image() + # It is the same image + assert misc_image is returned_image + # It is now removed from memory + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image() + + +def test_get_two_images(): + """Check two images can be retrieved.""" + mem_buf = CameraMemoryBuffer() + misc_image1 = random_image() + misc_image2 = random_image() + buffer_id1 = mem_buf.add_image(misc_image1, buffer_max=2) + buffer_id2 = mem_buf.add_image(misc_image2, buffer_max=2) + returned_image1, _ = mem_buf.get_image(buffer_id1) + returned_image2, _ = mem_buf.get_image(buffer_id2) + # It they the same images + assert misc_image1 is returned_image1 + assert misc_image2 is returned_image2 + # They are removed from memory + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image(buffer_id1) + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image(returned_image2) + + +def test_get_two_images_without_setting_buffer_size(): + """Check two images can't be retrieved if the buffer size isn't set""" + mem_buf = CameraMemoryBuffer() + misc_image1 = random_image() + misc_image2 = random_image() + buffer_id1 = mem_buf.add_image(misc_image1) + buffer_id2 = mem_buf.add_image(misc_image2) + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image(buffer_id1) + returned_image2, _ = mem_buf.get_image(buffer_id2) + # Image 2 the expected image + assert misc_image2 is returned_image2 + + +def test_buffer_size_changing(): + """Check buffer size resets back to 1 when if not set""" + mem_buf = CameraMemoryBuffer() + misc_image1 = random_image() + misc_image2 = random_image() + misc_image3 = random_image() + buffer_id1 = mem_buf.add_image(misc_image1, buffer_max=3) + buffer_id2 = mem_buf.add_image(misc_image2, buffer_max=3) + # Third capture doen't set buffer size, so it will be reset + buffer_id3 = mem_buf.add_image(misc_image3) + # As buffer size was reset, images 1 and 2 are deleted + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image(buffer_id1) + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image(buffer_id2) + returned_image3, _ = mem_buf.get_image(buffer_id3) + # Image 2 the expected image + assert misc_image3 is returned_image3 + + +def test_capture_two_images_get_without_id(): + """Check that all images are deleted when getting without id""" + mem_buf = CameraMemoryBuffer() + misc_image1 = random_image() + misc_image2 = random_image() + mem_buf.add_image(misc_image1, buffer_max=2) + mem_buf.add_image(misc_image2, buffer_max=2) + returned_image, _ = mem_buf.get_image() + # It is the same image + assert returned_image is misc_image2 + # All were wiped from memory + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image() + + +def test_buffer_size_respected(): + """Capture 10 images with a buffer size of 5. Check only last 5 exist""" + mem_buf = CameraMemoryBuffer() + + images = [] + buffer_ids = [] + for i in range(10): + image = random_image() + buffer_id = mem_buf.add_image(image, buffer_max=5) + images.append(image) + buffer_ids.append(buffer_id) + + for i, (image, buffer_id) in enumerate(zip(images, buffer_ids)): + if i < 5: + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image(buffer_id) + else: + returned_image, _ = mem_buf.get_image(buffer_id) + assert image is returned_image + + +def test_clear_buffer(): + """Capture 10 images clear the buffer and check they are gone""" + mem_buf = CameraMemoryBuffer() + + images = [] + buffer_ids = [] + for i in range(10): + image = random_image() + buffer_id = mem_buf.add_image(image, buffer_max=10) + images.append(image) + buffer_ids.append(buffer_id) + + # Clear the buffer + mem_buf.clear() + + # They are now gone + for i, (image, buffer_id) in enumerate(zip(images, buffer_ids)): + with pytest.raises(NoImageInMemoryError): + mem_buf.get_image(buffer_id) + + +def test_get_metadata_too(): + """Capture 10 images clear the buffer and check they are gone""" + mem_buf = CameraMemoryBuffer() + + images = [] + metadatas = [] + buffer_ids = [] + for i in range(10): + image = random_image() + metadata = random_metadata() + buffer_id = mem_buf.add_image(image, metadata, buffer_max=10) + images.append(image) + metadatas.append(metadata) + buffer_ids.append(buffer_id) + + # Preallocate zipped data, to avoid long confusing lines + zipped = zip(images, metadatas, buffer_ids) + + # Check both image and metdata + for i, (image, metadata, buffer_id) in enumerate(zipped): + returned_image, returned_metadata = mem_buf.get_image(buffer_id) + assert image is returned_image + assert metadata is returned_metadata From 2ce2f998fa908a06f5b6ee6bc95be3a9007e8d3e Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 27 Jun 2025 00:34:51 +0100 Subject: [PATCH 30/36] add tests for helper classes defined by fast stack --- .gitignore | 3 + pyproject.toml | 3 +- .../things/autofocus.py | 4 +- tests/test_stack.py | 218 ++++++++++++++++++ 4 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 tests/test_stack.py diff --git a/.gitignore b/.gitignore index 661ad967..c12227c5 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ openflexure/ # js version file /webapp/src/version.js + +# Hypothesis +.hypothesis diff --git a/pyproject.toml b/pyproject.toml index 55d346e5..5e9a985f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,8 @@ dev = [ "mypy-gitlab-code-quality", # "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings "pytest", - "matplotlib~=3.10" + "matplotlib~=3.10", + "hypothesis", ] pi = [ "picamera2~=0.3.27", diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 9d1ab25f..837d7b49 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -63,7 +63,7 @@ class StackParams: "Minimum number of images to test should be positive and odd" ) if images_to_save % 2 == 0 or images_to_save <= 0: - raise ValueError("Images to svae must be positive and odd") + raise ValueError("Images to save must be positive and odd") self.stack_dz = stack_dz self.images_to_save = images_to_save @@ -140,7 +140,7 @@ class CaptureInfo: """ buffer_id: int - position: tuple[int, int, int] + position: dict[str, int] sharpness: int @property diff --git a/tests/test_stack.py b/tests/test_stack.py new file mode 100644 index 00000000..c5d469bd --- /dev/null +++ b/tests/test_stack.py @@ -0,0 +1,218 @@ +""" +Tests for the smart/fast stacking. + +Currently these tests don't test the Thing itself, just surrounding functionality +""" + +from typing import Optional +from random import randint + +import pytest +import numpy as np +from hypothesis import given, strategies as st + +from openflexure_microscope_server.things.autofocus import ( + StackParams, + CaptureInfo, + _get_capture_by_id, + _get_capture_index_by_id, +) +from openflexure_microscope_server.things.smart_scan import IMAGE_REGEX + +RANDOM_GENERATOR = np.random.default_rng() + + +def odd_integers(min_value=0, max_value=1000): + """A hypothesis strategy for odd integers""" + min_base = (min_value) // 2 + max_base = (max_value - 1) // 2 + # Ensure the range allows at least one odd number + if min_base > max_base: + return st.nothing() + return st.integers(min_value=min_base, max_value=max_base).map(lambda x: 2 * x + 1) + + +def even_integers(min_value=0, max_value=1000): + """A hypothesis strategy for even integers""" + min_base = (min_value + 1) // 2 + max_base = (max_value) // 2 + # Ensure the range allows at least one even number + if min_base > max_base: + return st.nothing() + return st.integers(min_value=min_base, max_value=max_base).map(lambda x: 2 * x) + + +@given( + save_ims=odd_integers(min_value=0, max_value=10), + extra_ims=even_integers(min_value=0, max_value=10), +) +def test_stack_params_validation(save_ims, extra_ims): + """Tests specifically the validation on the image numbers + + save_ims is the number to save (must be odd and positive) + extra_ims is how many more images there are in min_images_to_test than + images_to_save. (even so that, min_images_to_test is odd and larger than + images_to_save + """ + + StackParams( + stack_dz=50, + images_to_save=save_ims, + min_images_to_test=save_ims + extra_ims, + autofocus_dz=2000, + images_dir="/this/is/fake", + save_resolution=(1640, 1232), + ) + + +@given( + save_ims=odd_integers(min_value=0, max_value=10), + extra_ims=even_integers(min_value=-10, max_value=-1), +) +def test_stack_params_not_enough_test_images(save_ims, extra_ims): + """Set the extra_ims negative so that min_images_to_test is smaller + than images_to_save. + + For arguments see test_stack_params_validation + """ + with pytest.raises(ValueError): + StackParams( + stack_dz=50, + images_to_save=save_ims, + min_images_to_test=save_ims + extra_ims, + autofocus_dz=2000, + images_dir="/this/is/fake", + save_resolution=(1640, 1232), + ) + + +@given( + save_ims=odd_integers(min_value=-10, max_value=-1), + extra_ims=even_integers(min_value=0, max_value=10), +) +def test_stack_params_negative_images_to_save(save_ims, extra_ims): + """save_ims is negative so images_to_save is negative, failing validation + + For arguments see test_stack_params_validation + """ + with pytest.raises(ValueError): + StackParams( + stack_dz=50, + images_to_save=save_ims, + min_images_to_test=save_ims + extra_ims, + autofocus_dz=2000, + images_dir="/this/is/fake", + save_resolution=(1640, 1232), + ) + + +@given( + save_ims=odd_integers(min_value=0, max_value=10), + extra_ims=odd_integers(min_value=0, max_value=10), +) +def test_even_min_images_to_test(save_ims, extra_ims): + """extra_ims is odd so min_images_to_test is even, failing validation + + For arguments see test_stack_params_validation + """ + with pytest.raises(ValueError): + StackParams( + stack_dz=50, + images_to_save=save_ims, + min_images_to_test=save_ims + extra_ims, + autofocus_dz=2000, + images_dir="/this/is/fake", + save_resolution=(1640, 1232), + ) + + +@given( + save_ims=even_integers(min_value=0, max_value=10), + extra_ims=odd_integers(min_value=0, max_value=10), +) +def test_even_images_to_save(save_ims, extra_ims): + """save_ims is even so images_to_save is even, failing validation + + For arguments see test_stack_params_validation + """ + with pytest.raises(ValueError): + StackParams( + stack_dz=50, + images_to_save=save_ims, + min_images_to_test=save_ims + extra_ims, + autofocus_dz=2000, + images_dir="/this/is/fake", + save_resolution=(1640, 1232), + ) + + +def test_computed_stack_params(): + """ + Test StackParams computed properties are as expected + + Not using hypothesis or we will just copy in the same formulas. + """ + stack_parameters = StackParams( + stack_dz=50, + images_to_save=5, + min_images_to_test=9, + autofocus_dz=2000, + images_dir="/this/is/fake", + save_resolution=(1640, 1232), + ) + + assert stack_parameters.stack_z_range == 8 * 50 + + assert stack_parameters.steps_undershoot == stack_parameters.img_undershoot * 50 + + assert stack_parameters.max_images_to_test == 9 + 15 + + sharpnesses = [50, 77, 234, 324, 390, 496, 569, 454, 333, 222, 178, 70] + max_ind = np.argmax(sharpnesses) + slice_to_save = stack_parameters.slice_to_save(max_ind) + # Check the slice corresponds to the inex for the 5 images centred on the sharpest + assert sharpnesses[slice_to_save] == [390, 496, 569, 454, 333] + + +def random_capture(set_id: Optional[int] = None): + """ + Create a capture with random values + + :param set_id: Optional, use to set a fixed id rather than a random one + """ + buffer_id = set_id if set_id is not None else randint(0, 1000) + return CaptureInfo( + buffer_id=buffer_id, + position={ + "x": randint(-100000, 100000), + "y": randint(-100000, 100000), + "z": randint(-100000, 100000), + }, + sharpness=randint(0, 100000), + ) + + +def test_capture_filename_matches_regex(): + """ + For 100 random captures check the image always matches the regex + """ + for _ in range(100): + assert IMAGE_REGEX.search(random_capture().filename) + + +@given(st.integers(min_value=0, max_value=5000)) +def test_retriaval_of_captures(start): + """ + For 20 random captures each chan be retried correctly + """ + captures = [random_capture(start + i) for i in range(20)] + + for i, capture in enumerate(captures): + buffer_id = capture.buffer_id + assert _get_capture_index_by_id(captures, buffer_id) == i + assert _get_capture_by_id(captures, buffer_id) is capture + + with pytest.raises(ValueError): + _get_capture_index_by_id(captures, start - 1) + with pytest.raises(ValueError): + _get_capture_index_by_id(captures, start + 21) From e90ff734994cb264263b5cead70dce5f7ae7e91e Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 27 Jun 2025 01:08:55 +0100 Subject: [PATCH 31/36] Move smart stack specific scan planning logic to SmartSpiral --- .../scan_planners.py | 95 ++++++++++++------- 1 file changed, 59 insertions(+), 36 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 2ebfc54a..63e48d87 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -180,8 +180,8 @@ class ScanPlanner: next_location = self._remaining_locations[0] - # If focussed locations exist, return the neighbour with the lowest z position - closest_pos = self.select_nearby_focus_site(next_location) + # If focussed locations exist return closest location, favouring most recent + closest_pos = self.closest_focus_site(next_location) if closest_pos is None: z = None else: @@ -215,40 +215,6 @@ class ScanPlanner: # The last index is most recent return self._focused_locations[indices[-1]] - def select_nearby_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]: - """ - Return the xyz position of the nearby site with the lowest z position. - Lowest position is best, as starting too high causes smart stacking to - autofocus and restart. Starting too low just requires extra movements in +z. - Nearby is defined as within NEIGHBOUR_CUTOFF times the distance to the closest neighbour. - - Returns None if there if no focussed locations are present - """ - if not self._focused_locations: - return None - - # must be float64 (double precision) to deal with the huge numbers involved! - current_pos = np.array(xy_pos, dtype="float64") - path_pos = np.array(self._focused_locations, dtype="float64")[:, :2] - - # Use linalg.norm to calculate the direct distance bweween the points - # Note linalg.norm always uses float64 - dists = np.linalg.norm((path_pos - current_pos), axis=1) - - # Get indices of all focused sites within NEIGHBOUR_CUTOFF the minimum distance. - # Note np.where always returns a tuple of arrays, hence the trailing [0] - indices = np.where(dists <= NEIGHBOUR_CUTOFF * np.min(dists))[0] - - # Turning into an array allows slicing based on a list - focused_locations_array = np.array(self._focused_locations) - - # Choose the lowest (smallest z) of the neighbouring sites. Smart stack works best - # if started too low, so the lowest z will perform best - chosen_focused_site = min(focused_locations_array[indices], key=lambda x: x[-1]) - - # Convert back into list so values are of type int instead of np.int32 - return tuple(chosen_focused_site.tolist()) - def mark_location_visited( self, xyz_pos: XYZPos, imaged: bool, focused: bool ) -> None: @@ -385,6 +351,63 @@ class SmartSpiral(ScanPlanner): self._remaining_locations.sort(key=sort_key) + def get_next_location_and_z_estimate(self) -> tuple[XYPos, Optional[int]]: + """ + Return the next location to scan, and the estimated z-position + for this location. This overrides the default behaviour of ScanPlanner + to take the lowest value of nearest neighbours as this works best for smart stack + + Note z-position may be None! This indicates that the current z, position + should be used. + """ + if self.scan_complete: + raise RuntimeError("Can't get next position, scan is complete") + + next_location = self._remaining_locations[0] + + # If focussed locations exist, return the neighbour with the lowest z position + closest_pos = self.select_nearby_focus_site(next_location) + if closest_pos is None: + z = None + else: + z = closest_pos[2] + + return next_location, z + + def select_nearby_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]: + """ + Return the xyz position of the nearby site with the lowest z position. + Lowest position is best, as starting too high causes smart stacking to + autofocus and restart. Starting too low just requires extra movements in +z. + Nearby is defined as within NEIGHBOUR_CUTOFF times the distance to the closest neighbour. + + Returns None if there if no focussed locations are present + """ + if not self._focused_locations: + return None + + # must be float64 (double precision) to deal with the huge numbers involved! + current_pos = np.array(xy_pos, dtype="float64") + path_pos = np.array(self._focused_locations, dtype="float64")[:, :2] + + # Use linalg.norm to calculate the direct distance bweween the points + # Note linalg.norm always uses float64 + dists = np.linalg.norm((path_pos - current_pos), axis=1) + + # Get indices of all focused sites within NEIGHBOUR_CUTOFF the minimum distance. + # Note np.where always returns a tuple of arrays, hence the trailing [0] + indices = np.where(dists <= NEIGHBOUR_CUTOFF * np.min(dists))[0] + + # Turning into an array allows slicing based on a list + focused_locations_array = np.array(self._focused_locations) + + # Choose the lowest (smallest z) of the neighbouring sites. Smart stack works best + # if started too low, so the lowest z will perform best + chosen_focused_site = min(focused_locations_array[indices], key=lambda x: x[-1]) + + # Convert back into list so values are of type int instead of np.int32 + return tuple(chosen_focused_site.tolist()) + def moves_between( self, starting_pos: XYPos | np.ndarray, From 73e54b802b62330fbce0fb88fd0650a4d054cfc6 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 27 Jun 2025 01:11:55 +0100 Subject: [PATCH 32/36] Stop smart stack infinietly looping --- .../things/autofocus.py | 29 +++++++++++-------- .../things/smart_scan.py | 10 ++----- tests/test_stack.py | 16 +++++++++- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 837d7b49..1d9bd2ea 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -26,8 +26,6 @@ from .camera import RawCameraDependency as Camera from .camera import CameraDependency as WrappedCamera from .stage import StageDependency as Stage -# TODO Capture reolution should be save resolution for consistency - class StackParams: """A class for holding for scan parameters @@ -76,26 +74,29 @@ class StackParams: # attributed to be documented settling_time: float = 0.3 - """time (in seconds) between moving and capturing an image""" + """Time (in seconds) between moving and capturing an image""" backlash_correction: int = 250 """ - distance (in steps) to overshoot a move and then undo, to account for backlash + Distance (in steps) to overshoot a move and then undo, to account for backlash """ stack_height_limit: int = 15 """ - how many images can be appended to the stack after the predicted peak to test + How many images can be appended to the stack after the predicted peak to test for focus before assuming the focus was passed, and restarting the stack """ img_undershoot: int = 5 """ - how far below (in factors of stack_dz) the estimated optimal starting position to + How far below (in factors of stack_dz) the estimated optimal starting position to begin the stack. Better to start slightly too low and require many images, rather than too high and needing to autofocus and restart the stack """ + max_attempts: int = 3 + """Maximum number of times to attempt fast stack""" + @property def stack_z_range(self) -> int: """The range of the z stack, in steps @@ -129,7 +130,8 @@ class StackParams: """Return the slice of images to save given the index of the sharpest image""" images_each_side = (self.images_to_save - 1) // 2 return slice( - sharpest_index - images_each_side, sharpest_index + images_each_side + 1 + max(sharpest_index - images_each_side, 0), + sharpest_index + images_each_side + 1, ) @@ -427,7 +429,7 @@ class AutofocusThing(Thing): images_dir: str, autofocus_dz: int, save_resolution: tuple[int, int], - ) -> None: + ) -> tuple[bool, int]: """Run a smart stack, which captures images offset in z, testing whether the sharpest image is towards the centre of the stack. The sharpest image, and optionally images around the sharpest, @@ -440,6 +442,7 @@ class AutofocusThing(Thing): LabThings FastAPI Returns: + A boolean, True if stack was successfully The z position of the sharpest image """ @@ -453,9 +456,9 @@ class AutofocusThing(Thing): save_resolution=save_resolution, ) - success = False + trys = 0 # Loop until a stack is successful - while not success: # TODO add a maximum number? + while trys < stack_parameters.max_attempts: success, captures, sharpest_id = self.z_stack( stack_parameters=stack_parameters, stage=stage, @@ -475,7 +478,9 @@ class AutofocusThing(Thing): sharpness_monitor, ) - # Save the sharpest image, and images either side of focus + # Save stack_parameters.image_to_save images centred on the sharpest capture. + # If the smart_stack failed the exact number of images saved may not be + # stack_parameters.image_to_save self.save_stack( sharpest_id=sharpest_id, captures=captures, @@ -484,7 +489,7 @@ class AutofocusThing(Thing): ) # Return the z position of the sharpest image, for path planning and tracking - return _get_capture_by_id(captures, sharpest_id).position["z"] + return success, _get_capture_by_id(captures, sharpest_id).position["z"] def reset_stack( self, diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 8ca4c1a1..b206058a 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -653,20 +653,16 @@ class SmartScanThing(Thing): self._scan_logger.info(msg) continue - focused_height = self._autofocus.run_smart_stack( + focused, focused_height = self._autofocus.run_smart_stack( images_dir=self._ongoing_scan_images_dir, autofocus_dz=self._scan_data["autofocus_dz"], save_resolution=self._scan_data["save_resolution"], ) - current_pos_xyz = ( - new_pos_xyz[0], - new_pos_xyz[1], - focused_height, - ) + current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focused_height) route_planner.mark_location_visited( - current_pos_xyz, imaged=True, focused=True + current_pos_xyz, imaged=True, focused=focused ) # increment capure counter as thread has completed diff --git a/tests/test_stack.py b/tests/test_stack.py index c5d469bd..04c67d8b 100644 --- a/tests/test_stack.py +++ b/tests/test_stack.py @@ -170,9 +170,23 @@ def test_computed_stack_params(): sharpnesses = [50, 77, 234, 324, 390, 496, 569, 454, 333, 222, 178, 70] max_ind = np.argmax(sharpnesses) slice_to_save = stack_parameters.slice_to_save(max_ind) - # Check the slice corresponds to the inex for the 5 images centred on the sharpest + # Check the slice corresponds to the index for the 5 images centred on the sharpest assert sharpnesses[slice_to_save] == [390, 496, 569, 454, 333] + # For a failed smart stack the slice may be truncated. Check it truncates correctly + sharpnesses = [496, 569, 454, 333, 222, 178, 70, 69, 66, 50, 45, 40] + max_ind = np.argmax(sharpnesses) + slice_to_save = stack_parameters.slice_to_save(max_ind) + # Check the slice corresponds to the index for the first 4 images + assert sharpnesses[slice_to_save] == [496, 569, 454, 333] + + # And again for sharpest at the end + sharpnesses = [13, 21, 26, 31, 39, 49, 50, 77, 234, 324, 390, 496, 569] + max_ind = np.argmax(sharpnesses) + slice_to_save = stack_parameters.slice_to_save(max_ind) + # Check the slice corresponds to the index for the final 3 images + assert sharpnesses[slice_to_save] == [390, 496, 569] + def random_capture(set_id: Optional[int] = None): """ From fc6c13f40ee021c89b80f68aa60a5051d98fb96f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 27 Jun 2025 10:41:53 +0000 Subject: [PATCH 33/36] Apply suggestions from code review of branch fast-stack Co-authored-by: Beth Probert --- src/openflexure_microscope_server/scan_planners.py | 8 ++++---- .../things/autofocus.py | 4 ++-- .../things/camera/__init__.py | 2 +- tests/test_camera_buffer.py | 12 +++++++----- tests/test_stack.py | 4 ++-- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 63e48d87..32dea090 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -357,7 +357,7 @@ class SmartSpiral(ScanPlanner): for this location. This overrides the default behaviour of ScanPlanner to take the lowest value of nearest neighbours as this works best for smart stack - Note z-position may be None! This indicates that the current z, position + Note z-position may be None! This indicates that the current z position should be used. """ if self.scan_complete: @@ -365,7 +365,7 @@ class SmartSpiral(ScanPlanner): next_location = self._remaining_locations[0] - # If focussed locations exist, return the neighbour with the lowest z position + # If focused locations exist, return the neighbour with the lowest z position closest_pos = self.select_nearby_focus_site(next_location) if closest_pos is None: z = None @@ -381,7 +381,7 @@ class SmartSpiral(ScanPlanner): autofocus and restart. Starting too low just requires extra movements in +z. Nearby is defined as within NEIGHBOUR_CUTOFF times the distance to the closest neighbour. - Returns None if there if no focussed locations are present + Returns None if there if no focused locations are present """ if not self._focused_locations: return None @@ -390,7 +390,7 @@ class SmartSpiral(ScanPlanner): current_pos = np.array(xy_pos, dtype="float64") path_pos = np.array(self._focused_locations, dtype="float64")[:, :2] - # Use linalg.norm to calculate the direct distance bweween the points + # Use linalg.norm to calculate the direct distance between the points # Note linalg.norm always uses float64 dists = np.linalg.norm((path_pos - current_pos), axis=1) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 1d9bd2ea..3fb7a699 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -37,7 +37,7 @@ class StackParams: :param min_images_to_test: The minimum number of images in the stack before, the stack is evaluated for focus. As more images are captured evaluation of the focus is always evaluated with the same number of images. i.e. if min_images_to_test=9, - then 9 images are captured, if the stack is not well focussed, a 10th image is + then 9 images are captured, if the stack is not well focused, a 10th image is captured and images 2 to 10 are evaluated for focus :param autofocus_dz: The number of steps in a full autofocus (when required) :param images_dir: The directory to save images to disk @@ -582,7 +582,7 @@ class AutofocusThing(Thing): # If the sharpest image isn't found within the maximum number of images # end the loop and return "restart" - while len(captures) <= stack_parameters.max_images_to_test: + while len(captures) < stack_parameters.max_images_to_test: time.sleep(stack_parameters.settling_time) # Append a new image to the stack diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 753aa66f..87f6a53a 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -69,7 +69,7 @@ class CameraMemoryBuffer: def add_image( self, image: Any, metadata: Optional[dict] = None, buffer_max: int = 1 - ) -> None: + ) -> int: """ Add an image to the Memory buffer diff --git a/tests/test_camera_buffer.py b/tests/test_camera_buffer.py index 35c226f7..b228dc07 100644 --- a/tests/test_camera_buffer.py +++ b/tests/test_camera_buffer.py @@ -27,7 +27,8 @@ def random_image(): def random_metadata(): """Create a misc dictionary to pretend to be metadata""" - # Not very metadata like, but we are just the same dict it is returned + # Not very metadata like, but we are just checking that the same dict it is + # returned return {"a": randint(1, 100), "b": randint(1, 100)} @@ -122,7 +123,7 @@ def test_buffer_size_changing(): with pytest.raises(NoImageInMemoryError): mem_buf.get_image(buffer_id2) returned_image3, _ = mem_buf.get_image(buffer_id3) - # Image 2 the expected image + # Image 3 the expected image assert misc_image3 is returned_image3 @@ -134,9 +135,10 @@ def test_capture_two_images_get_without_id(): mem_buf.add_image(misc_image1, buffer_max=2) mem_buf.add_image(misc_image2, buffer_max=2) returned_image, _ = mem_buf.get_image() - # It is the same image + # When buffer_id is not specified, the most recent image (image2) is expected to + # be retrieved assert returned_image is misc_image2 - # All were wiped from memory + # Check all images were wiped from memory, but trying get_image without an id with pytest.raises(NoImageInMemoryError): mem_buf.get_image() @@ -184,7 +186,7 @@ def test_clear_buffer(): def test_get_metadata_too(): - """Capture 10 images clear the buffer and check they are gone""" + """Capture 10 images with metadata and check metadata is returned as expected""" mem_buf = CameraMemoryBuffer() images = [] diff --git a/tests/test_stack.py b/tests/test_stack.py index 04c67d8b..2f335d0a 100644 --- a/tests/test_stack.py +++ b/tests/test_stack.py @@ -215,9 +215,9 @@ def test_capture_filename_matches_regex(): @given(st.integers(min_value=0, max_value=5000)) -def test_retriaval_of_captures(start): +def test_retrieval_of_captures(start): """ - For 20 random captures each chan be retried correctly + For 20 random captures, check each can be retrieved correctly by id """ captures = [random_capture(start + i) for i in range(20)] From 46f3d2163282f26d353edaad1cfbed6127523e18 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 27 Jun 2025 10:47:13 +0000 Subject: [PATCH 34/36] Apply suggestions from code review of branch fast-stack Co-authored-by: Beth Probert --- src/openflexure_microscope_server/things/autofocus.py | 9 +++++---- tests/test_camera_buffer.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 3fb7a699..c776c1bc 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -101,8 +101,8 @@ class StackParams: def stack_z_range(self) -> int: """The range of the z stack, in steps - Note that this is the range of the minimum number of image captured, - which is also the range of the images sored in memory that can be + Note that this is the range of the minimum number of images captured, + which is also the range of the images stored in memory that can be saved.""" return self.stack_dz * (self.min_images_to_test - 1) @@ -147,7 +147,7 @@ class CaptureInfo: @property def filename(self) -> str: - """The filename for this image generated from the poistion""" + """The filename for this image generated from the position""" return f"{self.position['x']}_{self.position['y']}_{self.position['z']}.jpeg" @@ -488,7 +488,8 @@ class AutofocusThing(Thing): cam=cam, ) - # Return the z position of the sharpest image, for path planning and tracking + # Return whether or not the smart stack was successful, and the z position of + # the sharpest image, for path planning and tracking return success, _get_capture_by_id(captures, sharpest_id).position["z"] def reset_stack( diff --git a/tests/test_camera_buffer.py b/tests/test_camera_buffer.py index b228dc07..f8e7f30a 100644 --- a/tests/test_camera_buffer.py +++ b/tests/test_camera_buffer.py @@ -27,8 +27,8 @@ def random_image(): def random_metadata(): """Create a misc dictionary to pretend to be metadata""" - # Not very metadata like, but we are just checking that the same dict it is - # returned + # Not very metadata like, but we are just checking that the same dict it + # is returned return {"a": randint(1, 100), "b": randint(1, 100)} From 9a92fd478a9c8865a5b7d81a6af427d20d3e7def Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 27 Jun 2025 16:54:28 +0100 Subject: [PATCH 35/36] Final docstring and testing updates based on code review --- .../things/autofocus.py | 67 ++++++++++++------- tests/test_stack.py | 5 ++ 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index c776c1bc..237122a8 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -156,6 +156,10 @@ def _get_capture_by_id(captures: list[CaptureInfo], buffer_id: int) -> CaptureIn :param captures: A list of capture objects :param buffer_id: The buffer id of the image to return + + :returns: the CaptureInfo object of the capture with matching id + + :raises: ValueError if buffer_id does not match the buffer_id of any captures """ return captures[_get_capture_index_by_id(captures, buffer_id)] @@ -166,6 +170,10 @@ def _get_capture_index_by_id(captures: list[CaptureInfo], buffer_id: int) -> int :param captures: A list of capture objects :param buffer_id: The buffer id of the image to return + + :returns: the list index of the capture with matching id + + :raises: ValueError if buffer_id does not match the buffer_id of any captures """ ids = [capture.buffer_id for capture in captures] if buffer_id not in ids: @@ -435,15 +443,19 @@ class AutofocusThing(Thing): The sharpest image, and optionally images around the sharpest, will be saved using their coordinates to images_dir - Arguments: - images_dir: the folder to save all images - autofocus_dz: should the stack fail, the range to refocus over before retrying - variables cam to sharpness_monitor are Thing dependencies injected automatically by - LabThings FastAPI - Returns: - A boolean, True if stack was successfully - The z position of the sharpest image + :param cam: Camera Dependency supplied by LabThings dependency injection + :param stage: Stage Dependency supplied by LabThings dependency injection + :param sharpness_monitor: Sharpness Monitor Dependency (for focus detection) + supplied by LabThings dependency injection + :param images_dir: the folder to save all images + :param autofocus_dz: the range to autofocus over if a stack fails + :param save_resolution: The resolution the images should be saved at, the + images will be resampled if this doesn't match the camera's capture resolution + + :returns: A tuple containing: + - A boolean, True if stack was successfully + - The z position of the sharpest image """ # Set the variables to prevent changes from the GUI or other windows @@ -461,8 +473,8 @@ class AutofocusThing(Thing): while trys < stack_parameters.max_attempts: success, captures, sharpest_id = self.z_stack( stack_parameters=stack_parameters, - stage=stage, cam=cam, + stage=stage, ) if success: @@ -549,21 +561,20 @@ class AutofocusThing(Thing): def z_stack( self, stack_parameters: StackParams, - stage: Stage, cam: WrappedCamera, + stage: Stage, ) -> tuple[bool, list[CaptureInfo], Optional[int]]: """Capture a series of images offset by stack_parameters.stack_dz, and test whether the sharpest image is towards the centre of the stack. - Returns: + :param stack_parameters: a StackParams object holding stack parameters + :param cam: Camera Dependency to be passed through from the calling action + :param stage: Stage Dependency to be passed through from the calling action + + :returns: A tuple of - the stack result (True for successful stack, False for failed stack), - a list of CaptureInfo objects, - the buffer_id of the shapest image (or None if the stack failed) - - Arguments: - images_dir: a string of the path to write all images - stack_parameters: a StackParams object holding stack parameters - variables stage to metadata_getter are Thing dependencies passed through from the calling action """ # Move down by the height of the z stack, plus an overshoot # Better to start too low and take too many images than too high and need to refocus @@ -613,10 +624,18 @@ class AutofocusThing(Thing): cam: WrappedCamera, stage: Stage, buffer_max: int, - ) -> list: + ) -> CaptureInfo: """Capture another image and return the capture information. The capture is stored by the camera Thing, and can be saved by ID. + + :param cam: Camera Dependency to be passed through from the calling action + :param stage: Stage Dependency to be passed through from the calling action + :buffer_max: The maximum number of images to tell the camera to keep in memory + for saving once the stack is complete + + :return: A CaatureInfo object containing the capture information including its + camera buffer_id needed for saving. """ stage_location = stage.position buffer_id = cam.capture_to_memory(buffer_max=buffer_max) @@ -630,17 +649,17 @@ class AutofocusThing(Thing): self, captures: list[CaptureInfo] ) -> tuple[Literal["success", "continue", "restart"], int]: """Test a list of captures, to decide whether the sharpest image from a - stack is within them + stack is centrally enough in the stack - Returns two values, - result - which is one of three literal values: + :param captures: a list of the capture objects to for testing if the + sharpeness has converged in the centre + + :return: A tuple with two values: + - result - which is one of three literal values: 'success' if the sharpest image is towards the centre 'continue' if the sharpest image is in the final two images of the list 'restart' if the sharpest image is in the first two images of the list - capture_id - the buffer id of the sharpest image - - Arguments: - sharpnesses: a list of the sharpnesses to test for focus + - capture_id - the buffer id of the sharpest image """ sharpest_index = np.argmax([capture.sharpness for capture in captures]) # The buffer id of the sharpest image diff --git a/tests/test_stack.py b/tests/test_stack.py index 2f335d0a..a8c81b7f 100644 --- a/tests/test_stack.py +++ b/tests/test_stack.py @@ -226,7 +226,12 @@ def test_retrieval_of_captures(start): assert _get_capture_index_by_id(captures, buffer_id) == i assert _get_capture_by_id(captures, buffer_id) is capture + # Check errors are raised when supplying ids that aren't int the list with pytest.raises(ValueError): _get_capture_index_by_id(captures, start - 1) with pytest.raises(ValueError): _get_capture_index_by_id(captures, start + 21) + with pytest.raises(ValueError): + _get_capture_by_id(captures, start - 1) + with pytest.raises(ValueError): + _get_capture_by_id(captures, start + 21) From 8002600fcf183ef09a0528233315830e6eb90cf6 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 27 Jun 2025 16:40:18 +0000 Subject: [PATCH 36/36] Apply suggestions from code review of branch fast-stack Co-authored-by: Joe Knapper --- src/openflexure_microscope_server/things/autofocus.py | 2 +- tests/test_stack.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 237122a8..bf507724 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -634,7 +634,7 @@ class AutofocusThing(Thing): :buffer_max: The maximum number of images to tell the camera to keep in memory for saving once the stack is complete - :return: A CaatureInfo object containing the capture information including its + :return: A CaptureInfo object containing the capture information including its camera buffer_id needed for saving. """ stage_location = stage.position diff --git a/tests/test_stack.py b/tests/test_stack.py index a8c81b7f..c9fad430 100644 --- a/tests/test_stack.py +++ b/tests/test_stack.py @@ -226,7 +226,7 @@ def test_retrieval_of_captures(start): assert _get_capture_index_by_id(captures, buffer_id) == i assert _get_capture_by_id(captures, buffer_id) is capture - # Check errors are raised when supplying ids that aren't int the list + # Check errors are raised when supplying ids that aren't in the list with pytest.raises(ValueError): _get_capture_index_by_id(captures, start - 1) with pytest.raises(ValueError):