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/")
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):
@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
def recentre(
self,
@ -103,7 +54,7 @@ class RecentringThing(Thing):
# A list of all the positions we've focused
focused_pos = [[], []]
self.looping_autofocus(autofocus, stage)
autofocus.looping_autofocus()
for direction in [0, 1]:
# Start off with the current position, and moving in the positive direction
@ -133,7 +84,7 @@ class RecentringThing(Thing):
stage.move_absolute(
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())
focused_pos[direction].append(position)
@ -190,7 +141,7 @@ class RecentringThing(Thing):
direction
]
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")

View file

@ -143,7 +143,8 @@ class AutofocusThing(Thing):
def fast_autofocus(
self,
m: SharpnessMonitorDep,
dz: int=2000
dz: int=2000,
start: str='centre',
) -> SharpnessDataArrays:
"""Sweep the stage up and down, then move to the sharpest point
@ -153,7 +154,8 @@ class AutofocusThing(Thing):
"""
with m.run():
# Move to (-dz / 2)
m.focus_rel(-dz / 2)
if start == 'centre':
m.focus_rel(-dz / 2)
# Move to dz while monitoring sharpness
# i: Sharpness monitor index for this move
# z: Final z position after move
@ -192,3 +194,40 @@ class AutofocusThing(Thing):
time.sleep(wait)
m.focus_rel(current_dz)
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 datetime import datetime
from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, run
import glob
import zipfile
import json
from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
@ -470,6 +473,19 @@ class SmartScanThing(Thing):
os.mkdir(raw_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
while len(path) > 0:
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")
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:
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(focused_path)
stage.move_absolute(
x=int(loc[0]), y=int(loc[1]), z=int(focused_path[z_index][2])
)
stage.move_absolute(
x=int(loc[0]), y=int(loc[1]), z = z - self.autofocus_dz / 2
)
# Check if the image is background
if self.skip_background:
@ -517,7 +535,7 @@ class SmartScanThing(Thing):
attempts = 0
if self.autofocus_dz > 200:
while True:
recentre.looping_autofocus(dz=self.autofocus_dz)
autofocus.looping_autofocus(dz=self.autofocus_dz, start = 'base')
current_height = stage.position["z"]
# 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)
break
if attempts >= 3:
logger.warning("Could not autofocus after 5 attempts.")
logger.warning("Could not autofocus after 3 attempts.")
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
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)))
if len(true_path) > 750:
break
except InvocationCancelledError:
logger.error("Stopping scan because it was cancelled.")
except NotEnoughFreeSpaceError as e:
@ -622,6 +638,7 @@ class SmartScanThing(Thing):
stage.move_absolute(**starting_position, block_cancellation=True)
finally:
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...")
self.preview_stitch_wait()
self.correlate_wait()
@ -845,7 +862,7 @@ class SmartScanThing(Thing):
raise RuntimeError("Only one subprocess is allowed at a time")
with self._correlate_popen_lock:
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:
@ -875,13 +892,22 @@ class SmartScanThing(Thing):
return output
@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"""
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
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."""
images_folder = self.images_folder(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):
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)...")
shutil.make_archive(
os.path.join(scan_folder, "images"),
"zip",
scan_folder,
"images/",
logger=logger
)
zip_fname = os.path.join(scan_folder, "images.zip")
# Promote key files to the top level of the 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):
zip.write(fpath, arcname=fname)
return ZipBlob.from_file(zip_fname)
zip_fname = f'{os.path.join(scan_folder, "images")}.zip'
# Create an empty zip file - we don't want to autofill it with files,
# as some of them should only be added at the end (as we can't overwrite)
# them once they change
if not os.path.isfile(zip_fname):
with zipfile.ZipFile(zip_fname, mode="w") as zip:
pass
# get a list of files in the existing zip
current_zip = self.get_files_in_zip(zip_fname)
logger.info(current_zip)
# 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