From 1f3211e3ee5c21a4861c872f173deafc5bf3f1d3 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 16 Apr 2025 09:40:14 +0100 Subject: [PATCH] Functional z stack Thing --- ofm_config_full.json | 3 +- .../things/smart_scan.py | 83 ++++++----- .../things/z_stack.py | 137 ++++++++++++++++++ 3 files changed, 182 insertions(+), 41 deletions(-) create mode 100644 src/openflexure_microscope_server/things/z_stack.py diff --git a/ofm_config_full.json b/ofm_config_full.json index a6076833..75ef4935 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -13,7 +13,8 @@ "path_to_openflexure_stitch": "application/openflexure-stitching/.venv/bin/openflexure-stitch" } }, - "/background_detect/": "openflexure_microscope_server.things.background_detect:BackgroundDetectThing" + "/background_detect/": "openflexure_microscope_server.things.background_detect:BackgroundDetectThing", + "/z_stack/": "openflexure_microscope_server.things.smart_scan:ZStackThing" }, "settings_folder": "/var/openflexure/settings/" } diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 9126b31b..aae9ddef 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -34,6 +34,7 @@ from openflexure_microscope_server.utilities import ErrorCapturingThread from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper from openflexure_microscope_server.things.background_detect import BackgroundDetectThing +from openflexure_microscope_server.things.z_stack import ZStackThing from openflexure_microscope_server import scan_planners CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") @@ -41,7 +42,7 @@ AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") BackgroundDep = direct_thing_client_dependency( BackgroundDetectThing, "/background_detect/" ) - +ZStackDep = direct_thing_client_dependency(ZStackThing, "/z_stack/") IMAGE_REGEX = re.compile(r"-?[0-9]+_-?[0-9]+\.jpeg$") @@ -159,6 +160,7 @@ class SmartScanThing(Thing): self._background_detect: Optional[BackgroundDep] = None self._ongoing_scan_name: Optional[str] = None self._starting_position: Optional[Mapping[str, int]] = None + self._z_stack: Optional[ZStackDep] = None self._capture_thread: Optional[ErrorCapturingThread] = None self._scan_images_taken: Optional[int] = None # TODO Scan data is a dict during refactoring, should become a dataclass @@ -175,6 +177,7 @@ class SmartScanThing(Thing): metadata_getter: GetThingStates, csm: CSMDep, background_detect: BackgroundDep, + z_stack: ZStackDep, scan_name: str = "", ): """Move the stage to cover an area, taking images that can be tiled together. @@ -198,6 +201,7 @@ class SmartScanThing(Thing): self._csm = csm self._background_detect = background_detect self._capture_thread = None + self._z_stack = z_stack self._scan_images_taken = 0 # Don't set self._scan_data dictionary. This is done at the start of _run_scan @@ -232,6 +236,7 @@ class SmartScanThing(Thing): self._ongoing_scan_name = None self._scan_images_taken = None self._scan_data = None + self._z_stack = None self._scan_lock.release() @_scan_running @@ -586,22 +591,24 @@ class SmartScanThing(Thing): ) # wait for the previous capture to be saved, i.e. don't leave more than one image saving in the background - if self._capture_thread: - self._wait_for_capture_thread() - # increment capure counter as thread has completed - self._scan_images_taken += 1 - # Add it to the incremental zip - self.create_zip_of_scan( - logger=self._scan_logger, - scan_name=self._ongoing_scan_name, - download_zip=False, - ) + # if self._capture_thread: + # self._wait_for_capture_thread() + # increment capure counter as thread has completed + self._scan_images_taken += 1 + # Add it to the incremental zip + self.create_zip_of_scan( + logger=self._scan_logger, + scan_name=self._ongoing_scan_name, + download_zip=False, + ) name = f"image_{new_pos_xyz[0]}_{new_pos_xyz[1]}.jpg" - jpeg_path = os.path.join(self._ongoing_scan_images_dir, name) - self._capture_thread, acquired = self._start_capture_thread(jpeg_path) + # jpeg_path = os.path.join(self._ongoing_scan_images_dir, name) + self._z_stack.smart_stack(images_dir = self._ongoing_scan_images_dir) + # self._capture_thread = self._start_capture_thread(jpeg_path) + # wait until the image is acquired - acquired.wait() + # acquired.wait() @_scan_running def _try_autofocus( @@ -659,31 +666,31 @@ class SmartScanThing(Thing): f"Waited {wait_time:.1f}s for the previous capture to finish saving." ) - @_scan_running - def _start_capture_thread( - self, jpeg_path: str - ) -> tuple[ErrorCapturingThread, Event]: - """ - Start the capture thread. + # @_scan_running + # def _start_capture_thread( + # self, jpeg_path: str + # ) -> tuple[ErrorCapturingThread, Event]: + # """ + # Start the capture thread. - Args: - jpeg_path, the path to save the image once aquired + # Args: + # jpeg_path, the path to save the image once aquired - Return the thread and an event that will be set when the image is aquired - """ - acquired = Event() - time.sleep(0.2) + # Return the thread and an event that will be set when the image is aquired + # """ + # time.sleep(0.2) - # Acquire the image in a thread, and continue once it's acquired - # (i.e. leave saving in the background) Use ErrorCapturingThread - # intead of Thread. This will raise errors in the calling thread - # only when join() is called, allowing us to handle this appropriately. - capture_thread = ErrorCapturingThread( - target=self._capture_and_save, - kwargs={"acquired": acquired, "jpeg_path": jpeg_path}, - ) - capture_thread.start() - return capture_thread, acquired + # # Acquire the image in a thread, and continue once it's acquired + # # (i.e. leave saving in the background) Use ErrorCapturingThread + # # intead of Thread. This will raise errors in the calling thread + # # only when join() is called, allowing us to handle this appropriately. + # capture_thread = ErrorCapturingThread( + # # target=self._capture_and_save, + # target=self._z_stack.smart_stack(), + # kwargs={"images_dir": self._ongoing_scan_images_dir}, + # ) + # capture_thread.start() + # return capture_thread @_scan_running def _return_to_starting_position(self): @@ -1241,7 +1248,3 @@ class SmartScanThing(Thing): """List the relative paths of all files and folders in the zip folder specified""" scan_zip = zipfile.ZipFile(zip_path) return [os.path.normpath(i) for i in scan_zip.namelist()] - - -class CaptureError(RuntimeError): - """An error trying to capture from Picamera""" diff --git a/src/openflexure_microscope_server/things/z_stack.py b/src/openflexure_microscope_server/things/z_stack.py new file mode 100644 index 00000000..97cd83a7 --- /dev/null +++ b/src/openflexure_microscope_server/things/z_stack.py @@ -0,0 +1,137 @@ +import os +from typing import Mapping, Optional +import cv2 +import numpy as np +from PIL import Image +from pydantic import BaseModel +from scipy.stats import norm +import time +import piexif +import json + +from labthings_fastapi.dependencies.metadata import GetThingStates +from labthings_fastapi.thing import Thing +from labthings_fastapi.decorators import thing_action, thing_property +from .camera import CameraDependency as CamDep +from .stage import StageDependency as StageDep +from labthings_fastapi.dependencies.invocation import ( + CancelHook, + InvocationLogger, + InvocationCancelledError, +) + + +class CaptureError(RuntimeError): + """An error trying to capture from Picamera""" + +class ZStackThing(Thing): + @thing_property + def images_to_capture(self) -> int: + """The number of images to capture and save in a stack + Defaults to 1 unless you need to see either side of focus""" + return self.thing_settings.get("stack_height", 9) + + @images_to_capture.setter + def images_to_capture(self, value: int) -> None: + self.thing_settings["images_to_capture"] = value + + @thing_property + def stack_dz(self) -> int: + """Space in steps between images in a z-stack + Suggested is 50 for 60-100x + 100 for 40x + 200 for 20x""" + return self.thing_settings.get("stack_dz", 50) + + @stack_dz.setter + def stack_dz(self, value: int) -> None: + self.thing_settings["stack_dz"] = value + + @thing_action + def smart_stack( + self, + cam: CamDep, + stage: StageDep, + images_dir: str, + logger: InvocationLogger, + metadata_getter: GetThingStates, + ) -> None: + stack_dz = self.stack_dz + images_to_capture = self.images_to_capture + + stack_z_range = stack_dz * (images_to_capture - 1) + stage.move_relative(z=-stack_z_range / 2) + + for capture_count in range(images_to_capture): + jpeg_path = os.path.join( + images_dir, + f"{capture_count}.jpeg", + ) + self._capture_and_save( + jpeg_path, cam, logger, metadata_getter, + ) + stage.move_relative(z=stack_dz) + + 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) + 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" + ) + + def _capture_image(self, cam, metadata_getter) -> tuple[np.ndarray, dict]: + """Capture an image in memory and return it with metadata + This will set the event `acquired` once the image has been acquired, so + that the stage may be moved while it's saved. + CaptureError raised if the capture fails for any reason + returns tuple with numpy array of image data, and dict of metadata + """ + try: + metadata = metadata_getter() + image = cam.capture_array()[..., :3] + except Exception as e: + raise CaptureError("An error occurred while capturing") from e + return image, metadata + + def _save_capture( + self, + jpeg_path: str, + image: np.ndarray, + metadata: dict, + ) -> None: + """Saving the captured image and metadata to disk + logger warning (via InvocationLogger) is raised if metadata is failed to be added + IOError is raised if the file cannot be saved + nothing is returned on success""" + try: + Image.fromarray(image.astype("uint8"), "RGB").save( + jpeg_path, quality=95, subsampling=0 + ) + try: + exif_dict = piexif.load(jpeg_path) + exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps( + metadata + ).encode("utf-8") + piexif.insert(piexif.dump(exif_dict), jpeg_path) + except: # noqa: E722 + # We need to capture any exception as there are many reasons metadata + # might not be added. We warn rather than log the error. + 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