WIP: Exception handling and file management

It's now possible to see (via the HTTP API) scans and download
them as zip files. Zip files appear to be malformed at present.
This commit is contained in:
Richard Bowman 2024-01-04 18:59:51 +00:00
parent 1ca2c9ca0d
commit 2ce143ff01

View file

@ -1,5 +1,8 @@
import shutil
from typing import Mapping, Optional from typing import Mapping, Optional
import cv2 import cv2
from fastapi import HTTPException
from fastapi.responses import FileResponse
import numpy as np import numpy as np
import os import os
import time import time
@ -8,11 +11,12 @@ from pydantic import BaseModel
from scipy.stats import norm from scipy.stats import norm
import logging import logging
from copy import deepcopy from copy import deepcopy
from datetime import datetime
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
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError
from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint
from labthings_sangaboard import SangaboardThing from labthings_sangaboard import SangaboardThing
from labthings_picamera2.thing import StreamingPiCamera2 from labthings_picamera2.thing import StreamingPiCamera2
from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.autofocus import AutofocusThing
@ -248,7 +252,46 @@ class BackgroundDetectThing(Thing):
BackgroundDep = direct_thing_client_dependency(BackgroundDetectThing, "/background_detect/") BackgroundDep = direct_thing_client_dependency(BackgroundDetectThing, "/background_detect/")
def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None:
"""Raise an exception if we are running out of disk space"""
du = shutil.disk_usage(path)
if du.free < min_space:
raise IOError(f"There is not enough free disk space to continue {du}")
class ScanInfo(BaseModel):
""""Summary information about a scan folder"""
name: str
created: datetime
modified: datetime
number_of_images: int
DOWNLOADABLE_SCAN_FILES = (
"images.zip",
)
class SmartScanThing(Thing): class SmartScanThing(Thing):
@property
def scan_folder_path(self) -> str:
"""This folder will hold all the scans we do."""
# TODO: This should be determined using sensible configuration.
# If the working directory is `/var/openflexure` this will result
# in scans being saved at `/var/openflexure/scans/`
return "scans"
def new_scan_folder(self) -> str:
"""Create a new empty folder, into which we can save scan images"""
if not os.path.exists(self.scan_folder_path):
os.makedirs(self.scan_folder_path)
for j in range(999999):
folder_path = os.path.join(self.scan_folder_path, f"scan_{j:06}")
if not os.path.exists(folder_path):
os.makedirs(folder_path)
return folder_path
raise FileExistsError("Could not create a new scan folder: all names in use!")
@thing_action @thing_action
def sample_scan( def sample_scan(
@ -261,187 +304,218 @@ class SmartScanThing(Thing):
csm: CSMDep, csm: CSMDep,
background_detect: BackgroundDep, background_detect: BackgroundDep,
recentre: RecentreStage, recentre: RecentreStage,
overlap: float = 0.4,
): ):
"""Move the stage to cover an area, taking images that can be tiled together.
The stage will move in a pattern that grows outwards from the starting point,
stopping once it is surrounded by "background" (as detected by the
background_detect Thing).
Input:
* `overlap` is the fraction by which images should overlap, i.e.
`0.3` means we will move by 70% of the field of view each time.
"""
names = [] names = []
positions = [] positions = []
previous_dir = 0 # Record the starting position so we can move back there afterwards
starting_position = stage.position
# if necessary, move to the starting point for your scan
starting_loc = list(stage.position.values())
recentre.looping_autofocus()
dx = 60
dy = 60
r = cam.grab_jpeg() r = cam.grab_jpeg()
arr = np.array(Image.open(r.open())) arr = np.array(Image.open(r.open()))
if arr.shape[:2] != csm.image_resolution:
logger.error(
f"Images are, by default, {arr.shape[:2]}, but the CSM was "
f"calibrated at {csm.image_resolution}."
)
camera_stage_mapping_matrix = csm.image_to_stage_displacement_matrix # Here, we calculate the x and y step size based on the desired overlap
# TODO: Consider using CSM calibration size instead
# TODO: generalise to have 2D displacements for x and y (as the
# camera and stage may not be aligned).
CSM = csm.image_to_stage_displacement_matrix
dx = int(np.dot(np.array([0, arr.shape[0] * (1 - overlap)]), CSM)[0])
dy = int(np.dot(np.array([arr.shape[1] * (1 - overlap), 0]), CSM)[1])
logger.info(f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}")
# TODO: CHECK HOW TO GET CALIBRATION IMAGE SIZE dz = 3000 # This is used for autofocus - make configurable?
sample_coverage = 7 # TODO: make this configurable
dx = dx * arr.shape[1] / 100
dy = dy * arr.shape[0] / 100
dx = np.array(
np.dot(np.array([0, dx]), camera_stage_mapping_matrix), dtype=int
)[0]
dy = np.array(
np.dot(np.array([dy, 0]), camera_stage_mapping_matrix), dtype=int
)[1]
dz = 3000
sample_coverage = 7
print(dx, dy)
# construct a 2D scan path # construct a 2D scan path
path = [[stage.position["x"], stage.position["y"]]] path = [[stage.position["x"], stage.position["y"]]]
# a list of the sites images have been taken at, and sites with a successful autofocus focused_path = [] # This holds a list of all points where focus succeeded
focused_path = [] true_path = [] # This holds a list of all points visited
true_path = []
i = 0 i = 0
ids = [] ids = []
start_time = time.strftime("%H_%M_%S-%d_%m_%Y") start_time = time.strftime("%H_%M_%S-%d_%m_%Y")
folder_path = r"scans"
scan_path = self.new_scan_folder()
images_folder = os.path.join(scan_path, "images")
os.mkdir(images_folder)
logger.info(f"Saving images to {images_folder}")
if not os.path.exists(folder_path): try:
os.makedirs(folder_path) # TODO: I think this is unnecessary, we do a looping autofocus at the first point in the loop, unless
j = 0 # it's flagged as background (which is a wierd case anyway)
while os.path.exists(os.path.join(folder_path, str(j))): recentre.looping_autofocus()
j += 1 # move to each x-y position. in z, move to the height of the closest x-y position that successfully focused
folder_path = os.path.join(folder_path, str(j)) while len(path) > 0:
os.makedirs(folder_path) loc = [path[0][0], path[0][1], stage.position["z"]]
logger.info(f"Saving scan to local folder {folder_path}") path.remove(loc[:2])
# move to each x-y position. in z, move to the height of the closest x-y position that successfully focused # TODO: combine this with the move below for speed (I think this could just be "else")
while len(path) > 0: stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=int(loc[2]))
if cancel.is_set():
logging.info("Scan terminated.")
break
loc = [path[0][0], path[0][1], stage.position["z"]]
path.remove(loc[:2]) if len(focused_path) > 1:
z_index = closest(loc, focused_path)
# 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=int(loc[2])) # Check if the image is background
background_fraction = background_detect.background_fraction()
if len(focused_path) > 1: background_coverage = round(
z_index = closest(loc, focused_path) 100 * background_fraction, 1
# 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])
) )
# Check if the image is background # if more than 92% of the image is background, treat it as background and continue
background_fraction = background_detect.background_fraction() if 100 - background_coverage < sample_coverage:
background_coverage = round( category = "background"
100 * background_fraction, 1 logger.info(f"Skipping {stage.position} as it is {background_coverage}% background.")
) else:
# if not, it's sample. run an autofocus and use the updated height
new_pos = [
[stage.position["x"] - dx, stage.position["y"]],
[stage.position["x"] + dx, stage.position["y"]],
[stage.position["x"], stage.position["y"] - dy],
[stage.position["x"], stage.position["y"] + dy],
]
for pos in new_pos:
if (
pos not in [sublist[:2] for sublist in true_path]
and pos not in path
):
path.append(pos)
# if more than 92% of the image is background, treat it as background and continue category = "sample"
if 100 - background_coverage < sample_coverage: attempts = 0
category = "background" while True:
logger.info(f"Skipping {stage.position} as it is {background_coverage}% background.") recentre.looping_autofocus(dz=3000)
# fig.set_facecolor("gray") current_height = stage.position["z"]
else:
# if not, it's sample. run an autofocus and use the updated height
new_pos = [
[stage.position["x"] - dx, stage.position["y"]],
[stage.position["x"] + dx, stage.position["y"]],
[stage.position["x"], stage.position["y"] - dy],
[stage.position["x"], stage.position["y"] + dy],
]
for pos in new_pos:
if (
pos not in [sublist[:2] for sublist in true_path]
and pos not in path
):
path.append(pos)
category = "sample" # if there have been successful autofocuses in this scan, find the closest one in x-y
# fig.set_facecolor("pink") # test if the change in z between them exceeds a ratio (indicating a failed autofocus)
if len(focused_path) > 0:
nearest_focused_site = focused_path[closest(loc, focused_path)]
result = limit_focus_change(
nearest_focused_site[0:2],
nearest_focused_site[-1],
loc[0:2],
current_height,
0.3,
)
attempts = 0 # if there haven't been any previous autofocuses, we have to assume this one worked
else:
result = "accept"
while True: # if the autofocus worked, add the current position to the list of successful locations
recentre.looping_autofocus(dz=3000) if result == "accept":
current_height = stage.position["z"] loc = list(stage.position.values())
focused_path.append(loc)
# if there have been successful autofocuses in this scan, find the closest one in x-y break
# test if the change in z between them exceeds a ratio (indicating a failed autofocus)
if len(focused_path) > 0:
nearest_focused_site = focused_path[closest(loc, focused_path)]
result = limit_focus_change(
nearest_focused_site[0:2],
nearest_focused_site[-1],
loc[0:2],
current_height,
0.3,
)
# if there haven't been any previous autofocuses, we have to assume this one worked
else:
result = "accept"
# if the autofocus worked, take a new, more focused image. add the current position to the list of successful locations
if result == "accept":
loc = list(stage.position.values())
# img = microscope.grab_image_array()
focused_path.append(loc)
break
else:
if attempts >= 5: if attempts >= 5:
logger.warning("Could not autofocus after 5 attempts.") logger.warning("Could not autofocus after 5 attempts.")
break break
else: # 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( "The focus seems to have moved farther than we expect. "
"The focus seems to have moved farther than we expect. " "We will now rest the Z position and try again. "
"We will now rest the Z position and try again. " f"Options are {focused_path}, we chose "
f"Options are {focused_path}, we chose " f"{nearest_focused_site}"
f"{nearest_focused_site}" )
) stage.move_absolute(z=int(focused_path[z_index][2]))
stage.move_absolute(z=int(focused_path[z_index][2])) attempts += 1
attempts += 1
img = Image.open(cam.capture_jpeg().open()) img = Image.open(cam.capture_jpeg(resolution="full").open())
exif = img.info['exif'] exif = img.info['exif']
width, height = img.size width, height = img.size
img = img.resize((int(width*0.5), int(height*0.5))) img = img.resize((int(width*0.5), int(height*0.5)))
name = f"rabbit_{loc[0]}_{loc[1]}.jpg" name = f"image_{loc[0]}_{loc[1]}.jpg"
logger.info(f"Saving {name} at {stage.position}") logger.info(f"Saving {name} at {stage.position}")
img.save(os.path.join(folder_path, name), exif = exif) img.save(
positions.append(loc[:2]) os.path.join(images_folder, name),
names.append(name) exif=exif,
quality=95,
subsampling=0
)
positions.append(loc[:2])
names.append(name)
# add the current position to the list of all positions visited
true_path.append(loc)
img_preview = cam.grab_jpeg() # if len(names) > 1:
img_preview = np.array(Image.open(img_preview.open())) generate_config(images_folder, positions, names)
# add the current position to the list of all positions visited
true_path.append(loc)
# if len(names) > 1: path = sorted(path, key=lambda x: distance_to_site(loc[:2], x))
generate_config(folder_path, positions, names)
if len(true_path) > 750:
path = sorted(path, key=lambda x: distance_to_site(loc[:2], x)) break
except InvocationCancelledError:
if len(true_path) > 750: logger.error("Stopping scan because it was cancelled.", exc_info=1)
break except IOError as e:
logger.error(
logger.info("Returning to starting position.") f"Stopping scan because of an IOError (most likely a full disk): {e}",
stage.move_absolute(x=starting_loc[0], y=starting_loc[1], z=starting_loc[2]) exc_info=1,
)
finally:
logger.info("Creating zip archive of images (may take some time)...")
shutil.make_archive(
os.path.join(scan_path, "images"), "zip", images_folder
)
logger.info("Returning to starting position.")
stage.move_absolute(**starting_position)
@thing_property
def scans(self) -> list[ScanInfo]:
"""All available scans"""
scans: list[ScanInfo] = []
for f in os.listdir(self.scan_folder_path):
path = os.path.join(self.scan_folder_path, f)
if os.path.isdir(path):
images_folder = os.path.join(path, "images")
if os.path.isdir(images_folder):
number_of_images = len(os.listdir(images_folder))
else:
number_of_images = 0
scans.append(
ScanInfo(
name = f,
created = os.path.getctime(path),
modified = os.path.getmtime(path),
number_of_images = number_of_images,
)
)
return scans
@fastapi_endpoint("get", "{scan_name}/{file}")
def get_images_zip(self, scan_name: str, file: str) -> FileResponse:
if file not in DOWNLOADABLE_SCAN_FILES:
raise HTTPException(
403, f"You may only download files named {DOWNLOADABLE_SCAN_FILES}"
)
path = os.path.join(self.scan_folder_path, scan_name, file)
if not os.path.isfile(path):
raise HTTPException(404, "File not found")
return FileResponse(path)