Merge branch 'autofocus_efficiency' into 'v3'

Autofocus efficiency

See merge request openflexure/openflexure-microscope-server!173
This commit is contained in:
Richard Bowman 2024-02-01 16:17:53 +00:00
commit f60e6f2a0b
3 changed files with 145 additions and 81 deletions

View file

@ -15,56 +15,7 @@ CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/")
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/")
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
def turningpoints(lst):
dx = np.diff(lst)
return dx[1:] * dx[:-1] < 0
def unpack_autofocus(scan_data):
"""Extract z, sharpness data from a move_and_measure call"""
scan_data = dict(scan_data)
jpeg_times = scan_data["jpeg_times"]
jpeg_sizes = scan_data["jpeg_sizes"]
jpeg_sizes_MB = [x / 10**3 for x in jpeg_sizes]
stage_times = scan_data["stage_times"]
stage_positions = scan_data["stage_positions"]
stage_height = [pos["z"] for pos in stage_positions]
jpeg_heights = np.interp(jpeg_times, stage_times, stage_height)
turning = np.where(turningpoints(jpeg_heights))[0] + 1
return jpeg_heights[turning[0] : turning[1]], jpeg_sizes_MB[turning[0] : turning[1]]
class RecentringThing(Thing): class RecentringThing(Thing):
@thing_action
def looping_autofocus(self, autofocus: AutofocusDep, stage: StageDep, dz=2000):
"""Repeatedly autofocus the stage until it looks focused.
This action will run the `fast_autofocus` action until it settles on a point
in the middle 3/5 of its range. Such logic can be helpful if the microscope
is close to focus, but not quite within `dz/2`. It will attempt to autofocus
up to 10 times.
"""
repeat = True
attempts = 0
while repeat and attempts < 10:
height_min = stage.position["z"] - dz / 2
height_max = stage.position["z"] + dz / 2
data = autofocus.fast_autofocus(dz=dz)
heights, _ = unpack_autofocus(data)
time.sleep(0.3)
# TODO: max heights seems badly wrong! Something about turning?
if (
stage.position["z"] - height_min < dz / 5
or height_max - stage.position["z"] < dz / 5
):
attempts += 1
else:
repeat = False
@thing_action @thing_action
def recentre( def recentre(
self, self,
@ -103,7 +54,7 @@ class RecentringThing(Thing):
# A list of all the positions we've focused # A list of all the positions we've focused
focused_pos = [[], []] focused_pos = [[], []]
self.looping_autofocus(autofocus, stage) autofocus.looping_autofocus()
for direction in [0, 1]: for direction in [0, 1]:
# Start off with the current position, and moving in the positive direction # Start off with the current position, and moving in the positive direction
@ -133,7 +84,7 @@ class RecentringThing(Thing):
stage.move_absolute( stage.move_absolute(
x=int(destination[0]), y=int(destination[1]), z=destination[2] x=int(destination[0]), y=int(destination[1]), z=destination[2]
) )
self.looping_autofocus(autofocus, stage) autofocus.looping_autofocus(autofocus, stage)
position = list(stage.position.values()) position = list(stage.position.values())
focused_pos[direction].append(position) focused_pos[direction].append(position)
@ -190,7 +141,7 @@ class RecentringThing(Thing):
direction direction
] ]
stage.move_absolute(x=centre[0], y=centre[1], z=centre[2]) stage.move_absolute(x=centre[0], y=centre[1], z=centre[2])
self.looping_autofocus(autofocus, stage) autofocus.looping_autofocus()
logging.info(f"Centre of ROM is at {centre, stage.position['z']} \n") logging.info(f"Centre of ROM is at {centre, stage.position['z']} \n")

View file

@ -143,7 +143,8 @@ class AutofocusThing(Thing):
def fast_autofocus( def fast_autofocus(
self, self,
m: SharpnessMonitorDep, m: SharpnessMonitorDep,
dz: int=2000 dz: int=2000,
start: str='centre',
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
"""Sweep the stage up and down, then move to the sharpest point """Sweep the stage up and down, then move to the sharpest point
@ -153,7 +154,8 @@ class AutofocusThing(Thing):
""" """
with m.run(): with m.run():
# Move to (-dz / 2) # Move to (-dz / 2)
m.focus_rel(-dz / 2) if start == 'centre':
m.focus_rel(-dz / 2)
# Move to dz while monitoring sharpness # Move to dz while monitoring sharpness
# i: Sharpness monitor index for this move # i: Sharpness monitor index for this move
# z: Final z position after move # z: Final z position after move
@ -192,3 +194,40 @@ class AutofocusThing(Thing):
time.sleep(wait) time.sleep(wait)
m.focus_rel(current_dz) m.focus_rel(current_dz)
return m.data_dict() return m.data_dict()
@thing_action
def looping_autofocus(self, stage: Stage, m: SharpnessMonitorDep, dz=2000, start='centre'):
"""Repeatedly autofocus the stage until it looks focused.
This action will run the `fast_autofocus` action until it settles on a point
in the middle 3/5 of its range. Such logic can be helpful if the microscope
is close to focus, but not quite within `dz/2`. It will attempt to autofocus
up to 10 times.
"""
repeat = True
attempts = 0
with m.run():
while repeat and attempts < 10:
if start == 'centre':
stage.move_relative(x = 0, y = 0, z = -dz / 2)
i, z = m.focus_rel(dz, block_cancellation=True)
_, heights, sizes = m.move_data(i)
peak_height = heights[np.argmax(sizes)]
height_min = np.min(heights)
height_max = np.max(heights)
if (
peak_height - height_min < dz / 5
or height_max - peak_height < dz / 5
):
attempts += 1
start = 'centre'
stage.move_absolute(z = peak_height)
else:
repeat = False
stage.move_relative(x = 0, y = 0, z = -dz)
stage.move_absolute(z = peak_height)

View file

@ -14,6 +14,9 @@ from scipy.stats import norm
from copy import deepcopy from copy import deepcopy
from datetime import datetime from datetime import datetime
from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, run from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, run
import glob
import zipfile
import json
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
@ -470,6 +473,19 @@ class SmartScanThing(Thing):
os.mkdir(raw_images_folder) os.mkdir(raw_images_folder)
logger.info(f"Saving images to {images_folder}") logger.info(f"Saving images to {images_folder}")
data = {
'scan_name' : scan_name,
'overlap' : overlap,
'autofocus range' : self.autofocus_dz,
'dx' : dx,
'dy' : dy,
'start time' : start_time,
'skipping background' : self.skip_background
}
with open(os.path.join(images_folder, 'scan_inputs.json'), 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
# move to each x-y position. in z, move to the height of the closest x-y position that successfully focused # move to each x-y position. in z, move to the height of the closest x-y position that successfully focused
while len(path) > 0: while len(path) > 0:
ensure_free_disk_space(scan_folder) ensure_free_disk_space(scan_folder)
@ -480,15 +496,17 @@ class SmartScanThing(Thing):
# TODO: combine this with the move below for speed (I think this could just be "else") # TODO: combine this with the move below for speed (I think this could just be "else")
logger.info(f"Moving to {loc}") logger.info(f"Moving to {loc}")
stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=int(loc[2]))
if len(focused_path) > 1: if len(focused_path) > 1:
z_index = closest(loc, focused_path) z_index = closest(loc, focused_path)
z=int(focused_path[z_index][2])
else:
z = loc[2]
# print('Moving to {0}'.format([coords[0], coords[1], focused_path[z_index][2]])) # print('Moving to {0}'.format([coords[0], coords[1], focused_path[z_index][2]]))
# print(focused_path) # print(focused_path)
stage.move_absolute( stage.move_absolute(
x=int(loc[0]), y=int(loc[1]), z=int(focused_path[z_index][2]) x=int(loc[0]), y=int(loc[1]), z = z - self.autofocus_dz / 2
) )
# Check if the image is background # Check if the image is background
if self.skip_background: if self.skip_background:
@ -517,7 +535,7 @@ class SmartScanThing(Thing):
attempts = 0 attempts = 0
if self.autofocus_dz > 200: if self.autofocus_dz > 200:
while True: while True:
recentre.looping_autofocus(dz=self.autofocus_dz) autofocus.looping_autofocus(dz=self.autofocus_dz, start = 'base')
current_height = stage.position["z"] current_height = stage.position["z"]
# if there have been successful autofocuses in this scan, find the closest one in x-y # if there have been successful autofocuses in this scan, find the closest one in x-y
@ -542,7 +560,7 @@ class SmartScanThing(Thing):
focused_path.append(loc) focused_path.append(loc)
break break
if attempts >= 3: if attempts >= 3:
logger.warning("Could not autofocus after 5 attempts.") logger.warning("Could not autofocus after 3 attempts.")
break break
# if the autofocus was rejected, we return to the height of the closest successful autofocus. not perfect, but better than wandering out of focus # if the autofocus was rejected, we return to the height of the closest successful autofocus. not perfect, but better than wandering out of focus
logger.info( logger.info(
@ -597,8 +615,6 @@ class SmartScanThing(Thing):
path = sorted(path, key=lambda x: (steps_from_centre(x, true_path[0][:2], dx, dy), distance_to_site(loc[:2], x))) path = sorted(path, key=lambda x: (steps_from_centre(x, true_path[0][:2], dx, dy), distance_to_site(loc[:2], x)))
if len(true_path) > 750:
break
except InvocationCancelledError: except InvocationCancelledError:
logger.error("Stopping scan because it was cancelled.") logger.error("Stopping scan because it was cancelled.")
except NotEnoughFreeSpaceError as e: except NotEnoughFreeSpaceError as e:
@ -622,6 +638,7 @@ class SmartScanThing(Thing):
stage.move_absolute(**starting_position, block_cancellation=True) stage.move_absolute(**starting_position, block_cancellation=True)
finally: finally:
self._scan_lock.release() self._scan_lock.release()
self.create_zip_of_scan(logger = logger, scan_name = scan_folder.split('scans/')[1], download_zip = False)
logger.info("Waiting for background processes to finish...") logger.info("Waiting for background processes to finish...")
self.preview_stitch_wait() self.preview_stitch_wait()
self.correlate_wait() self.correlate_wait()
@ -845,7 +862,7 @@ class SmartScanThing(Thing):
raise RuntimeError("Only one subprocess is allowed at a time") raise RuntimeError("Only one subprocess is allowed at a time")
with self._correlate_popen_lock: with self._correlate_popen_lock:
self._correlate_popen = Popen( self._correlate_popen = Popen(
[self._script, "--stitching_mode", "only_correlate", "--minimum_overlap", f"{overlap*0.9}", images_folder] [self._script, "--stitching_mode", "only_correlate", "--minimum_overlap", f"{round(overlap*0.9, 2)}", images_folder]
) )
def correlate_running(self) -> bool: def correlate_running(self) -> bool:
@ -875,13 +892,22 @@ class SmartScanThing(Thing):
return output return output
@thing_action @thing_action
def stitch_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, overlap: float = 0.1) -> None: def stitch_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, overlap: float = 0.0) -> None:
"""Generate a stitched image based on stage position metadata""" """Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(scan_name=scan_name) images_folder = self.images_folder(scan_name=scan_name)
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", "--minimum_overlap", f"{overlap*0.9}", images_folder])
if overlap == 0.0:
try:
with open(os.path.join(images_folder, 'scan_inputs.json')) as data_file:
data_loaded = json.load(data_file)
logger.info(data_loaded)
overlap = data_loaded['overlap']
except:
overlap = 0.1
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", "--minimum_overlap", f"{round(overlap*0.9,2)}", images_folder])
@thing_action @thing_action
def create_zip_of_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None) -> ZipBlob: def create_zip_of_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, download_zip = True) -> ZipBlob:
"""Generate a zip file that can be downloaded, with all the scan files in it.""" """Generate a zip file that can be downloaded, with all the scan files in it."""
images_folder = self.images_folder(scan_name=scan_name) images_folder = self.images_folder(scan_name=scan_name)
scan_folder = self.scan_folder_path(scan_name=scan_name) scan_folder = self.scan_folder_path(scan_name=scan_name)
@ -893,19 +919,67 @@ class SmartScanThing(Thing):
if not os.path.isdir(images_folder): if not os.path.isdir(images_folder):
raise FileNotFoundError(f"Tried to make a zip archive of {images_folder} but it does not exist.") raise FileNotFoundError(f"Tried to make a zip archive of {images_folder} but it does not exist.")
logger.info("Creating zip archive of images (may take some time)...") logger.info("Creating zip archive of images (may take some time)...")
shutil.make_archive(
os.path.join(scan_folder, "images"), zip_fname = f'{os.path.join(scan_folder, "images")}.zip'
"zip",
scan_folder, # Create an empty zip file - we don't want to autofill it with files,
"images/", # as some of them should only be added at the end (as we can't overwrite)
logger=logger # them once they change
) if not os.path.isfile(zip_fname):
zip_fname = os.path.join(scan_folder, "images.zip") with zipfile.ZipFile(zip_fname, mode="w") as zip:
# Promote key files to the top level of the zip pass
with zipfile.ZipFile(zip_fname, mode="a") as zip:
for fname in ["stitched_from_stage.jpg", "stitched.jpg"]: # get a list of files in the existing zip
fpath = os.path.join(images_folder, fname) current_zip = self.get_files_in_zip(zip_fname)
if os.path.exists(fpath):
zip.write(fpath, arcname=fname) logger.info(current_zip)
return ZipBlob.from_file(zip_fname)
# get a list of files in the folder we're zipping
folder_path = self.scan_folder_path(scan_name)
files = glob.glob(folder_path + '/**/*', recursive=True)
files = [i.split(f'{folder_path}/')[1] for i in files]
# This is a list of file names that are updated as the scan goes,
# and should only be zipped at the end of the scan - otherwise they'll
# be appended on every loop as we can't overwrite files in the zip
files_to_delay = ['TileConfiguration', 'tiling_cache', 'stitched.jp', 'stitched_from']
with zipfile.ZipFile(zip_fname, mode="a") as zip:
for file in files:
if any(banned_name in file for banned_name in files_to_delay):
logger.info(f'we only add {file} into zip at the end of the scan')
elif file in current_zip:
logger.info(f'{file} is already in zip')
elif ".zip" in file:
logger.info('Not adding the .zip to itself')
else:
logger.info(f'appending {file} to zip')
zip.write(os.path.join(folder_path, file), arcname=file)
images_folder = os.path.join(folder_path, 'images')
# Promote key files to the top level of the zip only at the end of the scan (when downloading)
# and finally zip some of the final files
# TODO: if you download multiple times, you get duplicate files - is this a problem?
if download_zip:
with zipfile.ZipFile(zip_fname, mode="a") as zip:
for fname in ["stitched_from_stage.jpg", "stitched.jpg"]:
fpath = os.path.join(images_folder, fname)
if os.path.exists(fpath):
logger.info(f'copying {fpath} to upper level')
zip.write(fpath, arcname=fname)
for file in files:
if any(banned_name in file for banned_name in files_to_delay):
logger.info(f'we are finally adding {file} into zip')
zip.write(os.path.join(folder_path, file), arcname=file)
return ZipBlob.from_file(zip_fname)
@thing_action
def get_files_in_zip(self, zip_path):
"""List the relative paths of all files and folders in the zip folder specified"""
zip = zipfile.ZipFile(zip_path)
zip = [os.path.normpath(i) for i in zip.namelist()]
return zip