Functional z stack Thing
This commit is contained in:
parent
327a87e344
commit
1f3211e3ee
3 changed files with 182 additions and 41 deletions
|
|
@ -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/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
137
src/openflexure_microscope_server/things/z_stack.py
Normal file
137
src/openflexure_microscope_server/things/z_stack.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue