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/")
|
||||
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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue