Merge branch 'parallel_acquisition_and_moves' into 'v3'
Parallel acquisition and moves See merge request openflexure/openflexure-microscope-server!174
This commit is contained in:
commit
08e74ac020
3 changed files with 177 additions and 50 deletions
|
|
@ -11,14 +11,19 @@ import time
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from scipy.stats import norm
|
from scipy.stats import norm
|
||||||
|
from scipy.ndimage import zoom
|
||||||
|
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
|
||||||
|
from threading import Event, Thread
|
||||||
import glob
|
import glob
|
||||||
import zipfile
|
import zipfile
|
||||||
import json
|
import json
|
||||||
|
import piexif
|
||||||
|
|
||||||
from labthings_fastapi.thing import Thing
|
from labthings_fastapi.thing import Thing
|
||||||
|
from labthings_fastapi.dependencies.metadata import GetThingStates
|
||||||
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
|
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
|
||||||
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError
|
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError
|
||||||
from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint
|
from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint
|
||||||
|
|
@ -162,6 +167,22 @@ def generate_config(folder_path: str, positions: list, names: list, camera_to_sa
|
||||||
loc = np.dot((positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix))
|
loc = np.dot((positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix))
|
||||||
fp.write(f'{names[i]}; ; {loc[1], loc[0]} \n')
|
fp.write(f'{names[i]}; ; {loc[1], loc[0]} \n')
|
||||||
|
|
||||||
|
|
||||||
|
def raw2rggb(raw):
|
||||||
|
"""Convert packed 10 bit raw to RGGB 8 bit"""
|
||||||
|
raw = np.asarray(raw) # ensure it's an array
|
||||||
|
rggb = np.empty((616, 820, 4), dtype=np.uint8)
|
||||||
|
raw_w = rggb.shape[1]//2*5
|
||||||
|
for plane, offset in enumerate([(1,1), (0,1), (1,0), (0,0)]):
|
||||||
|
rggb[:, ::2, plane] = raw[offset[0]::2, offset[1]:raw_w+offset[1]:5]
|
||||||
|
rggb[:, 1::2, plane] = raw[offset[0]::2, offset[1]+2:raw_w+offset[1]+2:5]
|
||||||
|
return rggb
|
||||||
|
|
||||||
|
|
||||||
|
def rggb2rgb(rggb):
|
||||||
|
return np.stack([rggb[..., 0], rggb[..., 1]//2 + rggb[..., 2]//2, rggb[...,3]], axis=2)
|
||||||
|
|
||||||
|
|
||||||
class ChannelDistributions(BaseModel):
|
class ChannelDistributions(BaseModel):
|
||||||
means: list[float]
|
means: list[float]
|
||||||
standard_deviations: list[float]
|
standard_deviations: list[float]
|
||||||
|
|
@ -372,6 +393,34 @@ class SmartScanThing(Thing):
|
||||||
return folder_path
|
return folder_path
|
||||||
raise FileExistsError("Could not create a new scan folder: all names in use!")
|
raise FileExistsError("Could not create a new scan folder: all names in use!")
|
||||||
|
|
||||||
|
|
||||||
|
def move_to_next_point(
|
||||||
|
self,
|
||||||
|
stage: StageDep,
|
||||||
|
logger: InvocationLogger,
|
||||||
|
path: list[list[int]],
|
||||||
|
focused_path: list[list[int]],
|
||||||
|
) -> list[int]:
|
||||||
|
"""Remove the first point from the path, and move there.
|
||||||
|
|
||||||
|
This will move to the next XY position in `path`, taking the `z` value
|
||||||
|
either from the current z value of the stage, or from `focused_path`.
|
||||||
|
|
||||||
|
Returns the point we have moved to.
|
||||||
|
"""
|
||||||
|
loc = [path[0][0], path[0][1]]
|
||||||
|
path.remove(path[0])
|
||||||
|
if len(focused_path) > 1:
|
||||||
|
z_index = closest(loc, focused_path)
|
||||||
|
z = int(focused_path[z_index][2])
|
||||||
|
else:
|
||||||
|
z = stage.position["z"]
|
||||||
|
logger.info(f"Moving to {loc}")
|
||||||
|
stage.move_absolute(
|
||||||
|
x=int(loc[0]), y=int(loc[1]), z = z - self.autofocus_dz / 2
|
||||||
|
)
|
||||||
|
return loc + [z]
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def sample_scan(
|
def sample_scan(
|
||||||
self,
|
self,
|
||||||
|
|
@ -380,6 +429,7 @@ class SmartScanThing(Thing):
|
||||||
autofocus: AutofocusDep,
|
autofocus: AutofocusDep,
|
||||||
stage: StageDep,
|
stage: StageDep,
|
||||||
cam: CamDep,
|
cam: CamDep,
|
||||||
|
metadata_getter: GetThingStates,
|
||||||
csm: CSMDep,
|
csm: CSMDep,
|
||||||
background_detect: BackgroundDep,
|
background_detect: BackgroundDep,
|
||||||
recentre: RecentreStage,
|
recentre: RecentreStage,
|
||||||
|
|
@ -401,6 +451,7 @@ class SmartScanThing(Thing):
|
||||||
scan_folder = None
|
scan_folder = None
|
||||||
images_folder = None
|
images_folder = None
|
||||||
starting_position = None
|
starting_position = None
|
||||||
|
capture_thread = None
|
||||||
self._scan_lock.acquire(timeout=0.1)
|
self._scan_lock.acquire(timeout=0.1)
|
||||||
try:
|
try:
|
||||||
# Before anything else, check that we've got a background set
|
# Before anything else, check that we've got a background set
|
||||||
|
|
@ -486,28 +537,89 @@ class SmartScanThing(Thing):
|
||||||
with open(os.path.join(images_folder, 'scan_inputs.json'), 'w', encoding='utf-8') as f:
|
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)
|
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
|
# We will capture images and process them with this function, defined once here.
|
||||||
|
# Most of the variables it needs will be "baked in" so the arguments are just the ones
|
||||||
|
# that change each iteration.
|
||||||
|
# We also pre-calculate a normalisation image based on the LST and white balance
|
||||||
|
raw_image = cam.capture_array(stream_name="raw")
|
||||||
|
#TODO: assert the image is 10-bit packed, or deal with other formats!
|
||||||
|
rgb = rggb2rgb(raw2rggb(raw_image))
|
||||||
|
lst = dict(cam.lens_shading_tables)
|
||||||
|
lum = np.array(lst["luminance"])
|
||||||
|
Cr = np.array(lst["Cr"])
|
||||||
|
Cb = np.array(lst["Cb"])
|
||||||
|
gr, gb = cam.colour_gains
|
||||||
|
G = 1/lum
|
||||||
|
R = G/Cr/gr*np.min(Cr) # The extra /np.max(Cr) emulates the quirky handling of Cr in
|
||||||
|
B = G/Cb/gb*np.min(Cb) # the picamera2 pipeline
|
||||||
|
white_norm_lores = np.stack([R, G, B], axis=2)
|
||||||
|
zoom_factors = [i/n for i, n in zip(rgb[...,:3].shape, white_norm_lores.shape)]
|
||||||
|
white_norm = zoom(white_norm_lores, zoom_factors, order=1)[:rgb.shape[0], :rgb.shape[1], :] # Could use some work
|
||||||
|
colour_correction_matrix = np.array(cam.colour_correction_matrix).reshape((3,3))
|
||||||
|
contrast_algorithm = cam.tuning["algorithms"][9]["rpi.contrast"]
|
||||||
|
gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1,2))
|
||||||
|
gamma_8bit = interp1d(gamma[:, 0]/255, gamma[:, 1]/255)
|
||||||
|
def process_raw_image(img):
|
||||||
|
normed = img/white_norm
|
||||||
|
corrected = np.dot(colour_correction_matrix, normed.reshape((-1, 3)).T).T.reshape(normed.shape)
|
||||||
|
return gamma_8bit(corrected)
|
||||||
|
logger.info(
|
||||||
|
f"Generated normalisation image with shape {white_norm.shape}, "
|
||||||
|
f"max {white_norm.max(axis=(0,1))}, min {white_norm.min(axis=(0,1))}"
|
||||||
|
)
|
||||||
|
norm_inputs = {
|
||||||
|
"luminance": lum,
|
||||||
|
"Cr": Cr,
|
||||||
|
"Cb": Cb,
|
||||||
|
"gain_red": gr,
|
||||||
|
"gain_blue": gb,
|
||||||
|
}
|
||||||
|
def capture_and_save(acquired: Event, name: str) -> None:
|
||||||
|
"""Capture an image and save it to disk
|
||||||
|
|
||||||
|
This will set the event `acquired` once the image has been acquired, so
|
||||||
|
that the stage may be moved while it's saved.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
capture_start = time.time()
|
||||||
|
raw_image = cam.capture_array(stream_name="raw")
|
||||||
|
acquired.set()
|
||||||
|
acquisition_time = time.time()
|
||||||
|
# Save the raw image
|
||||||
|
np.savez(os.path.join(raw_images_folder, name + ".npz"), raw_image=raw_image, **norm_inputs)
|
||||||
|
# Process it into 8 bit RGB
|
||||||
|
processed = process_raw_image(rggb2rgb(raw2rggb(raw_image)))
|
||||||
|
processed[processed > 255] = 255
|
||||||
|
processed[processed < 0] = 0
|
||||||
|
img = Image.fromarray(processed.astype(np.uint8), mode="RGB")
|
||||||
|
img.save(
|
||||||
|
os.path.join(images_folder, name),
|
||||||
|
quality=95,
|
||||||
|
subsampling=0
|
||||||
|
)
|
||||||
|
exif_dict = piexif.load(os.path.join(images_folder, name))
|
||||||
|
exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps(
|
||||||
|
metadata_getter()
|
||||||
|
).encode("utf-8")
|
||||||
|
piexif.insert(piexif.dump(exif_dict), os.path.join(images_folder, name))
|
||||||
|
save_time = time.time()
|
||||||
|
logger.info(f"Acquired {name} in {acquisition_time-capture_start:.1f}s then {save_time-acquisition_time:.1f}s saving to disk")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"An error occurred while saving {name}: {e}", exc_info=e)
|
||||||
|
|
||||||
|
# At the start of the loop, we simultaneously capture an image and move to the next scan point.
|
||||||
|
# We skip capturing on the first run, because we've not focused yet - and also we skip capturing if
|
||||||
|
# it looks like background.
|
||||||
while len(path) > 0:
|
while len(path) > 0:
|
||||||
|
loc = self.move_to_next_point(stage, logger, path=path, focused_path=focused_path)
|
||||||
|
if not self.preview_stitch_running():
|
||||||
|
self.preview_stitch_start(images_folder)
|
||||||
|
if self.stitch_automatically:
|
||||||
|
if not self.correlate_running():
|
||||||
|
self.correlate_start(images_folder, overlap=overlap)
|
||||||
|
|
||||||
ensure_free_disk_space(scan_folder)
|
ensure_free_disk_space(scan_folder)
|
||||||
|
|
||||||
loc = [path[0][0], path[0][1], stage.position["z"]]
|
|
||||||
|
|
||||||
path.remove(path[0])
|
|
||||||
|
|
||||||
# TODO: combine this with the move below for speed (I think this could just be "else")
|
|
||||||
logger.info(f"Moving to {loc}")
|
|
||||||
|
|
||||||
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 = 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:
|
||||||
image_is_sample = background_detect.image_is_sample()
|
image_is_sample = background_detect.image_is_sample()
|
||||||
|
|
@ -517,7 +629,9 @@ class SmartScanThing(Thing):
|
||||||
# if more than 92% of the image is background, treat it as background and continue
|
# if more than 92% of the image is background, treat it as background and continue
|
||||||
if not image_is_sample:
|
if not image_is_sample:
|
||||||
logger.info(f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background.")
|
logger.info(f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background.")
|
||||||
|
capture_image = False
|
||||||
else:
|
else:
|
||||||
|
capture_image = True
|
||||||
# if not, it's sample. run an autofocus and use the updated height
|
# if not, it's sample. run an autofocus and use the updated height
|
||||||
new_pos = [
|
new_pos = [
|
||||||
[stage.position["x"] - dx, stage.position["y"]],
|
[stage.position["x"] - dx, stage.position["y"]],
|
||||||
|
|
@ -566,42 +680,41 @@ class SmartScanThing(Thing):
|
||||||
logger.info(
|
logger.info(
|
||||||
"The focus has shifted further than we expect: retrying."
|
"The focus has shifted further than we expect: retrying."
|
||||||
)
|
)
|
||||||
stage.move_absolute(z=int(focused_path[z_index][2]))
|
stage.move_absolute(z=int(loc[2]))
|
||||||
attempts += 1
|
attempts += 1
|
||||||
|
|
||||||
|
|
||||||
|
# Acquire the image in a thread, and continue once it's acquired (i.e. leave saving in the background)
|
||||||
|
if capture_thread: # wait for the previous capture to be saved, i.e. don't leave more than one image saving in the background
|
||||||
|
if capture_thread.is_alive():
|
||||||
|
wait_start = time.time()
|
||||||
|
capture_thread.join()
|
||||||
|
wait_time = time.time() - wait_start
|
||||||
|
logger.info(f"Waited {wait_time:.1f}s for the previous capture to finish saving.")
|
||||||
|
acquired = Event()
|
||||||
name = f"image_{loc[0]}_{loc[1]}.jpg"
|
name = f"image_{loc[0]}_{loc[1]}.jpg"
|
||||||
# img = Image.open(cam.capture_jpeg(resolution="full").open())
|
time.sleep(0.2)
|
||||||
jpegblob = cam.capture_jpeg(resolution="full")
|
capture_thread = Thread(
|
||||||
jpegblob.save(os.path.join(raw_images_folder, name))
|
target=capture_and_save,
|
||||||
img = Image.open(jpegblob.open())
|
kwargs={
|
||||||
exif = img.info['exif']
|
# "cam": cam,
|
||||||
width, height = img.size
|
# "logger": logger,
|
||||||
img = img.resize((int(width*0.5), int(height*0.5)))
|
"acquired": acquired,
|
||||||
|
"name": name,
|
||||||
img_width, _ = img.size
|
# "images_folder": images_folder,
|
||||||
|
# "raw_images_folder": raw_images_folder,
|
||||||
logger.info(f"Saving {name}")
|
}
|
||||||
img.save(
|
|
||||||
os.path.join(images_folder, name),
|
|
||||||
exif=exif,
|
|
||||||
quality=95,
|
|
||||||
subsampling=0
|
|
||||||
)
|
)
|
||||||
|
capture_thread.start()
|
||||||
|
acquired.wait() # wait until the image is acquired
|
||||||
|
#time.sleep(0.5)
|
||||||
positions.append(loc[:2])
|
positions.append(loc[:2])
|
||||||
names.append(name)
|
names.append(name)
|
||||||
|
|
||||||
if not self.preview_stitch_running():
|
|
||||||
self.preview_stitch_start(images_folder)
|
|
||||||
if self.stitch_automatically:
|
|
||||||
if not self.correlate_running():
|
|
||||||
self.correlate_start(images_folder, overlap=overlap)
|
|
||||||
|
|
||||||
# add the current position to the list of all positions visited
|
# add the current position to the list of all positions visited
|
||||||
true_path.append(loc)
|
true_path.append(loc)
|
||||||
|
|
||||||
if len(names) > 1:
|
#if len(names) > 1:
|
||||||
generate_config(images_folder, positions, names, CSM, csm_calibration_width, img_width, logger)
|
# generate_config(images_folder, positions, names, CSM, csm_calibration_width, img_width, logger)
|
||||||
|
|
||||||
temp_path = []
|
temp_path = []
|
||||||
|
|
||||||
|
|
@ -610,9 +723,7 @@ class SmartScanThing(Thing):
|
||||||
temp_path.append(i)
|
temp_path.append(i)
|
||||||
else:
|
else:
|
||||||
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)))
|
||||||
|
|
||||||
except InvocationCancelledError:
|
except InvocationCancelledError:
|
||||||
|
|
@ -632,6 +743,8 @@ class SmartScanThing(Thing):
|
||||||
)
|
)
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
|
if capture_thread:
|
||||||
|
capture_thread.join()
|
||||||
try:
|
try:
|
||||||
logger.info("Returning to starting position.")
|
logger.info("Returning to starting position.")
|
||||||
if starting_position is not None:
|
if starting_position is not None:
|
||||||
|
|
@ -942,7 +1055,7 @@ class SmartScanThing(Thing):
|
||||||
# This is a list of file names that are updated as the scan goes,
|
# 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
|
# 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
|
# be appended on every loop as we can't overwrite files in the zip
|
||||||
files_to_delay = ['TileConfiguration', 'tiling_cache', 'stitched.jp', 'stitched_from']
|
files_to_delay = ['TileConfiguration', 'tiling_cache', 'stitched.jp', 'stitched_from', 'stitched.om']
|
||||||
|
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,13 @@ html {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-fit{
|
||||||
|
height: 80%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
overflow-y: clip;
|
||||||
|
}
|
||||||
|
|
||||||
.section-content {
|
.section-content {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@
|
||||||
label="Image overlap (0-1)"
|
label="Image overlap (0-1)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<propertyControl
|
||||||
|
thing-name="smart_scan"
|
||||||
|
property-name="stitch_tiff"
|
||||||
|
label="When stitching, produce a pyramidal tiff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="uk-open">
|
<li class="uk-open">
|
||||||
|
|
@ -108,8 +115,8 @@
|
||||||
Scan ID: {{ scan_name }}
|
Scan ID: {{ scan_name }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="view-component uk-width-expand">
|
<div class="view-image uk-width-expand uk-height-1-1">
|
||||||
<img v-if="displayImageOnRight" :src="lastStitchedImage" id="last-stitched-image"/>
|
<img v-if="displayImageOnRight" class=image-fit :src="lastStitchedImage" id="last-stitched-image">
|
||||||
<streamDisplay v-else />
|
<streamDisplay v-else />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue