Merge branch 'autofocus_efficiency' into 'v3'
Autofocus efficiency See merge request openflexure/openflexure-microscope-server!173
This commit is contained in:
commit
f60e6f2a0b
3 changed files with 145 additions and 81 deletions
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue