Merge branch 'verify-focus' into 'v3'
Improvements to autofocus and stitching in `smart_scan`. See merge request openflexure/openflexure-microscope-server!175
This commit is contained in:
commit
612b88d58c
4 changed files with 134 additions and 41 deletions
|
|
@ -26,6 +26,7 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
Stage = direct_thing_client_dependency(SangaboardThing, "/stage/")
|
Stage = direct_thing_client_dependency(SangaboardThing, "/stage/")
|
||||||
Camera = raw_thing_dependency(StreamingPiCamera2)
|
Camera = raw_thing_dependency(StreamingPiCamera2)
|
||||||
|
WrappedCamera = direct_thing_client_dependency(StreamingPiCamera2, "/camera/")
|
||||||
|
|
||||||
### Autofocus utilities
|
### Autofocus utilities
|
||||||
|
|
||||||
|
|
@ -206,12 +207,14 @@ class AutofocusThing(Thing):
|
||||||
"""
|
"""
|
||||||
repeat = True
|
repeat = True
|
||||||
attempts = 0
|
attempts = 0
|
||||||
|
backlash = 200
|
||||||
|
|
||||||
with m.run():
|
with m.run():
|
||||||
while repeat and attempts < 10:
|
while repeat and attempts < 10:
|
||||||
|
|
||||||
if start == 'centre':
|
if start == 'centre':
|
||||||
stage.move_relative(x = 0, y = 0, z = -dz / 2)
|
stage.move_relative(x = 0, y = 0, z = -(backlash + dz / 2))
|
||||||
|
stage.move_relative(x = 0, y = 0, z = backlash)
|
||||||
|
|
||||||
i, z = m.focus_rel(dz, block_cancellation=True)
|
i, z = m.focus_rel(dz, block_cancellation=True)
|
||||||
_, heights, sizes = m.move_data(i)
|
_, heights, sizes = m.move_data(i)
|
||||||
|
|
@ -226,8 +229,24 @@ class AutofocusThing(Thing):
|
||||||
):
|
):
|
||||||
attempts += 1
|
attempts += 1
|
||||||
start = 'centre'
|
start = 'centre'
|
||||||
|
stage.move_absolute(z = peak_height-backlash)
|
||||||
stage.move_absolute(z = peak_height)
|
stage.move_absolute(z = peak_height)
|
||||||
else:
|
else:
|
||||||
repeat = False
|
repeat = False
|
||||||
stage.move_relative(x = 0, y = 0, z = -dz)
|
stage.move_relative(x = 0, y = 0, z = -(dz+backlash))
|
||||||
stage.move_absolute(z = peak_height)
|
stage.move_absolute(z = peak_height)
|
||||||
|
return heights.tolist(), sizes.tolist()
|
||||||
|
|
||||||
|
@thing_action
|
||||||
|
def verify_focus_sharpness(self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95):
|
||||||
|
'''Take the sharpness curve of the autofocus, and the size of the current frame
|
||||||
|
to see if the autofocus completed successfully. Returns True if current sharpness
|
||||||
|
is within "leniency" number of frames from the peak of the autofocus'''
|
||||||
|
|
||||||
|
current_sharpness = camera.grab_jpeg_size(stream_name='lores')
|
||||||
|
|
||||||
|
peak = np.max(sweep_sizes)
|
||||||
|
base = np.min(sweep_sizes)
|
||||||
|
cutoff = threshold * (peak - base)
|
||||||
|
|
||||||
|
return current_sharpness >= base + cutoff
|
||||||
|
|
@ -15,7 +15,7 @@ from scipy.ndimage import zoom
|
||||||
from scipy.interpolate import interp1d
|
from scipy.interpolate import interp1d
|
||||||
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, STDOUT
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
import glob
|
import glob
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
@ -584,6 +584,7 @@ class SmartScanThing(Thing):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
capture_start = time.time()
|
capture_start = time.time()
|
||||||
|
metadata = metadata_getter()
|
||||||
raw_image = cam.capture_array(stream_name="raw")
|
raw_image = cam.capture_array(stream_name="raw")
|
||||||
acquired.set()
|
acquired.set()
|
||||||
acquisition_time = time.time()
|
acquisition_time = time.time()
|
||||||
|
|
@ -601,7 +602,7 @@ class SmartScanThing(Thing):
|
||||||
)
|
)
|
||||||
exif_dict = piexif.load(os.path.join(images_folder, name))
|
exif_dict = piexif.load(os.path.join(images_folder, name))
|
||||||
exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps(
|
exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps(
|
||||||
metadata_getter()
|
metadata
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
piexif.insert(piexif.dump(exif_dict), os.path.join(images_folder, name))
|
piexif.insert(piexif.dump(exif_dict), os.path.join(images_folder, name))
|
||||||
save_time = time.time()
|
save_time = time.time()
|
||||||
|
|
@ -651,24 +652,30 @@ class SmartScanThing(Thing):
|
||||||
attempts = 0
|
attempts = 0
|
||||||
if self.autofocus_dz > 200:
|
if self.autofocus_dz > 200:
|
||||||
while True:
|
while True:
|
||||||
autofocus.looping_autofocus(dz=self.autofocus_dz, start = 'base')
|
jpeg_zs, jpeg_sizes = autofocus.looping_autofocus(dz=self.autofocus_dz, start = 'base')
|
||||||
current_height = stage.position["z"]
|
current_height = stage.position["z"]
|
||||||
|
time.sleep(0.2)
|
||||||
|
autofocus_success = autofocus.verify_focus_sharpness(sweep_sizes = jpeg_sizes, camera = CamDep, threshold = 0.92)
|
||||||
|
logger.info(f"We just tested the focus! Result was {autofocus_success}")
|
||||||
|
|
||||||
# if there have been successful autofocuses in this scan, find the closest one in x-y
|
if autofocus_success:
|
||||||
# test if the change in z between them exceeds a ratio (indicating a failed autofocus)
|
# if there have been successful autofocuses in this scan, find the closest one in x-y
|
||||||
if len(focused_path) > 0:
|
# test if the change in z between them exceeds a ratio (indicating a failed autofocus)
|
||||||
nearest_focused_site = focused_path[closest(loc, focused_path)]
|
if len(focused_path) > 0:
|
||||||
result = limit_focus_change(
|
nearest_focused_site = focused_path[closest(loc, focused_path)]
|
||||||
nearest_focused_site[0:2],
|
result = limit_focus_change(
|
||||||
nearest_focused_site[-1],
|
nearest_focused_site[0:2],
|
||||||
loc[0:2],
|
nearest_focused_site[-1],
|
||||||
current_height,
|
loc[0:2],
|
||||||
0.3,
|
current_height,
|
||||||
)
|
0.5,
|
||||||
|
)
|
||||||
|
|
||||||
# if there haven't been any previous autofocuses, we have to assume this one worked
|
# if there haven't been any previous autofocuses, we have to assume this one worked
|
||||||
|
else:
|
||||||
|
result = "accept"
|
||||||
else:
|
else:
|
||||||
result = "accept"
|
result = "reject"
|
||||||
|
|
||||||
# if the autofocus worked, add the current position to the list of successful locations
|
# if the autofocus worked, add the current position to the list of successful locations
|
||||||
if result == "accept":
|
if result == "accept":
|
||||||
|
|
@ -727,6 +734,7 @@ class SmartScanThing(Thing):
|
||||||
logger.info(f'Rejected moving to {i} as it is out of range')
|
logger.info(f'Rejected moving to {i} as it is out of range')
|
||||||
path = temp_path.copy()
|
path = temp_path.copy()
|
||||||
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)))
|
||||||
|
self.create_zip_of_scan(logger = logger, scan_name = scan_folder.split('scans/')[1], download_zip = False)
|
||||||
|
|
||||||
except InvocationCancelledError:
|
except InvocationCancelledError:
|
||||||
logger.error("Stopping scan because it was cancelled.")
|
logger.error("Stopping scan because it was cancelled.")
|
||||||
|
|
@ -767,12 +775,21 @@ class SmartScanThing(Thing):
|
||||||
@thing_property
|
@thing_property
|
||||||
def max_range(self) -> int:
|
def max_range(self) -> int:
|
||||||
"""The maximum distance from the centre of the scan before we break"""
|
"""The maximum distance from the centre of the scan before we break"""
|
||||||
return self.thing_settings.get("max_range", 70000)
|
return self.thing_settings.get("max_range", 45000)
|
||||||
|
|
||||||
@max_range.setter
|
@max_range.setter
|
||||||
def max_range(self, value: int) -> None:
|
def max_range(self, value: int) -> None:
|
||||||
self.thing_settings["max_range"] = value
|
self.thing_settings["max_range"] = value
|
||||||
|
|
||||||
|
@thing_property
|
||||||
|
def stitch_tiff(self) -> bool:
|
||||||
|
"""Whether or not to also produce a pyramidal tiff"""
|
||||||
|
return self.thing_settings.get("stitch_tiff", False)
|
||||||
|
|
||||||
|
@stitch_tiff.setter
|
||||||
|
def stitch_tiff(self, value: bool) -> None:
|
||||||
|
self.thing_settings["stitch_tiff"] = value
|
||||||
|
|
||||||
@thing_property
|
@thing_property
|
||||||
def skip_background(self) -> bool:
|
def skip_background(self) -> bool:
|
||||||
"""Whether to detect and skip empty fields of view
|
"""Whether to detect and skip empty fields of view
|
||||||
|
|
@ -999,18 +1016,40 @@ class SmartScanThing(Thing):
|
||||||
) -> CompletedProcess:
|
) -> CompletedProcess:
|
||||||
"""Run a subprocess and log any output"""
|
"""Run a subprocess and log any output"""
|
||||||
logger.info(f"Running command in subprocess: `{' '.join(cmd)}`")
|
logger.info(f"Running command in subprocess: `{' '.join(cmd)}`")
|
||||||
output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True)
|
|
||||||
for pipe in [output.stdout, output.stderr]:
|
|
||||||
if pipe:
|
p = Popen(cmd, stdout=PIPE, stderr = STDOUT, bufsize=1, universal_newlines=True)
|
||||||
logger.info(pipe)
|
os.set_blocking(p.stdout.fileno(), False)
|
||||||
output.check_returncode()
|
logger.info(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
|
||||||
return output
|
while p.poll() == None:
|
||||||
|
try:
|
||||||
|
output = p.stdout.readline()
|
||||||
|
if output != "" and output != None:
|
||||||
|
logger.info(output)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for line in p.stdout:
|
||||||
|
try:
|
||||||
|
output = p.stdout.readline()
|
||||||
|
if output != "" and output != None:
|
||||||
|
logger.info(output)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info('Stitching complete')
|
||||||
|
return p
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def stitch_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, overlap: float = 0.0) -> 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)
|
||||||
|
|
||||||
|
if self.stitch_tiff:
|
||||||
|
tiff_arg = '--stitch_tiff'
|
||||||
|
else:
|
||||||
|
tiff_arg = '--no-stitch_tiff'
|
||||||
|
|
||||||
if overlap == 0.0:
|
if overlap == 0.0:
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(images_folder, 'scan_inputs.json')) as data_file:
|
with open(os.path.join(images_folder, 'scan_inputs.json')) as data_file:
|
||||||
|
|
@ -1019,7 +1058,7 @@ class SmartScanThing(Thing):
|
||||||
overlap = data_loaded['overlap']
|
overlap = data_loaded['overlap']
|
||||||
except:
|
except:
|
||||||
overlap = 0.1
|
overlap = 0.1
|
||||||
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", "--minimum_overlap", f"{round(overlap*0.9,2)}", images_folder])
|
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", f"{tiff_arg}", "--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, download_zip = True) -> ZipBlob:
|
def create_zip_of_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, download_zip = True) -> ZipBlob:
|
||||||
|
|
@ -1033,7 +1072,7 @@ 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)...")
|
||||||
|
|
||||||
zip_fname = f'{os.path.join(scan_folder, "images")}.zip'
|
zip_fname = f'{os.path.join(scan_folder, "images")}.zip'
|
||||||
|
|
||||||
|
|
@ -1047,8 +1086,6 @@ class SmartScanThing(Thing):
|
||||||
# get a list of files in the existing zip
|
# get a list of files in the existing zip
|
||||||
current_zip = self.get_files_in_zip(zip_fname)
|
current_zip = self.get_files_in_zip(zip_fname)
|
||||||
|
|
||||||
logger.info(current_zip)
|
|
||||||
|
|
||||||
# get a list of files in the folder we're zipping
|
# get a list of files in the folder we're zipping
|
||||||
folder_path = self.scan_folder_path(scan_name)
|
folder_path = self.scan_folder_path(scan_name)
|
||||||
files = glob.glob(folder_path + '/**/*', recursive=True)
|
files = glob.glob(folder_path + '/**/*', recursive=True)
|
||||||
|
|
@ -1062,11 +1099,14 @@ class SmartScanThing(Thing):
|
||||||
with zipfile.ZipFile(zip_fname, mode="a") as zip:
|
with zipfile.ZipFile(zip_fname, mode="a") as zip:
|
||||||
for file in files:
|
for file in files:
|
||||||
if any(banned_name in file for banned_name in files_to_delay):
|
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')
|
# logger.info(f'we only add {file} into zip at the end of the scan')
|
||||||
|
pass
|
||||||
elif file in current_zip:
|
elif file in current_zip:
|
||||||
logger.info(f'{file} is already in zip')
|
# logger.info(f'{file} is already in zip')
|
||||||
elif ".zip" in file:
|
pass
|
||||||
logger.info('Not adding the .zip to itself')
|
elif ".zip" in file or 'raw' in file:
|
||||||
|
# logger.info('Not adding the .zip to itself')
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
logger.info(f'appending {file} to zip')
|
logger.info(f'appending {file} to zip')
|
||||||
zip.write(os.path.join(folder_path, file), arcname=file)
|
zip.write(os.path.join(folder_path, file), arcname=file)
|
||||||
|
|
@ -1087,7 +1127,7 @@ class SmartScanThing(Thing):
|
||||||
if any(banned_name in file for banned_name in files_to_delay):
|
if any(banned_name in file for banned_name in files_to_delay):
|
||||||
logger.info(f'we are finally adding {file} into zip')
|
logger.info(f'we are finally adding {file} into zip')
|
||||||
zip.write(os.path.join(folder_path, file), arcname=file)
|
zip.write(os.path.join(folder_path, file), arcname=file)
|
||||||
|
logger.info('about to download zip')
|
||||||
return ZipBlob.from_file(zip_fname)
|
return ZipBlob.from_file(zip_fname)
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="galleryDisplay uk-padding uk-padding-remove-top">
|
<div
|
||||||
|
v-observe-visibility="visibilityChanged"
|
||||||
|
class="galleryDisplay uk-padding uk-padding-remove-top"
|
||||||
|
>
|
||||||
<!-- Gallery nav bar -->
|
<!-- Gallery nav bar -->
|
||||||
<nav
|
<nav
|
||||||
class="gallery-navbar uk-navbar-container uk-navbar-transparent"
|
class="gallery-navbar uk-navbar-container uk-navbar-transparent"
|
||||||
|
|
@ -152,10 +155,15 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
visibilityChanged(isVisible) {
|
||||||
|
if (isVisible) {
|
||||||
|
this.updateScans();
|
||||||
|
}
|
||||||
|
},
|
||||||
async updateScans() {
|
async updateScans() {
|
||||||
let scans = await this.readThingProperty("smart_scan", "scans");
|
let scans = await this.readThingProperty("smart_scan", "scans");
|
||||||
if (!scans | scans.length == 0) {
|
if (!scans | scans.length == 0) {
|
||||||
return;
|
this.scans = scans;
|
||||||
}
|
}
|
||||||
scans.forEach(scan => {
|
scans.forEach(scan => {
|
||||||
scan.modified = Date.parse(scan.modified);
|
scan.modified = Date.parse(scan.modified);
|
||||||
|
|
|
||||||
|
|
@ -97,22 +97,33 @@
|
||||||
<button
|
<button
|
||||||
v-if="cancellable"
|
v-if="cancellable"
|
||||||
type="button"
|
type="button"
|
||||||
class="uk-button uk-button-danger uk-margin-remove uk-float-right uk-width-1-1"
|
class="uk-button uk-button-danger uk-width-1-1"
|
||||||
@click="$refs.smartScanTaskSubmitter.terminateTask()"
|
@click="$refs.smartScanTaskSubmitter.terminateTask()"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
<div class="uk-margin uk-grid-small uk-child-width-expand" v-if="!cancellable" uk-grid>
|
||||||
<button
|
<button
|
||||||
v-if="!cancellable"
|
|
||||||
type="button"
|
type="button"
|
||||||
class="uk-button uk-button-danger uk-margin-remove uk-float-right uk-width-1-1"
|
class="uk-button"
|
||||||
@click="scanning = false; lastStitchedImage=null;"
|
@click="scanning = false; lastStitchedImage=null;"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
<task-submitter
|
||||||
|
class="uk-button"
|
||||||
|
submit-label="Download ZIP"
|
||||||
|
:can-terminate="false"
|
||||||
|
:submit-data="{'scan_name': lastScanName}"
|
||||||
|
:button-primary="true"
|
||||||
|
:submit-url="createZipOfScanUri"
|
||||||
|
@response="downloadZipFile"
|
||||||
|
@error="modalError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 v-if="scanning">
|
<h3 v-if="scanning">
|
||||||
Scan ID: {{ scan_name }}
|
Scan ID: {{ lastScanName }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="view-image uk-width-expand uk-height-1-1">
|
<div class="view-image uk-width-expand uk-height-1-1">
|
||||||
|
|
@ -157,6 +168,9 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
createZipOfScanUri() {
|
||||||
|
return this.thingActionUrl("smart_scan", "create_zip_of_scan");
|
||||||
|
},
|
||||||
backendOK() {
|
backendOK() {
|
||||||
return this.thingAvailable("smart_scan");
|
return this.thingAvailable("smart_scan");
|
||||||
},
|
},
|
||||||
|
|
@ -190,8 +204,20 @@ export default {
|
||||||
if (mtime !== null) {
|
if (mtime !== null) {
|
||||||
this.lastStitchedImage = `${this.$store.getters.baseUri}/smart_scan/latest_preview_stitch.jpg?t=${mtime}`;
|
this.lastStitchedImage = `${this.$store.getters.baseUri}/smart_scan/latest_preview_stitch.jpg?t=${mtime}`;
|
||||||
}
|
}
|
||||||
|
this.lastScanName = await this.readThingProperty("smart_scan", "latest_scan_name");
|
||||||
setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped
|
setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async downloadZipFile(response) {
|
||||||
|
const scan_name = response.input.scan_name;
|
||||||
|
const filename = `${scan_name}_images.zip`
|
||||||
|
const url = response.output.href;
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute("download", filename);
|
||||||
|
console.log(link);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue