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
import cv2
from fastapi import HTTPException
from fastapi.responses import FileResponse
import numpy as np
import os
import time
@ -8,11 +11,12 @@ from pydantic import BaseModel
from scipy.stats import norm
import logging
from copy import deepcopy
from datetime import datetime
from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger
from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError
from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint
from labthings_sangaboard import SangaboardThing
from labthings_picamera2.thing import StreamingPiCamera2
from openflexure_microscope_server.things.autofocus import AutofocusThing
@ -248,7 +252,46 @@ class BackgroundDetectThing(Thing):
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):
@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
def sample_scan(
@ -261,187 +304,218 @@ class SmartScanThing(Thing):
csm: CSMDep,
background_detect: BackgroundDep,
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 = []
positions = []
previous_dir = 0
# if necessary, move to the starting point for your scan
starting_loc = list(stage.position.values())
recentre.looping_autofocus()
dx = 60
dy = 60
# Record the starting position so we can move back there afterwards
starting_position = stage.position
r = cam.grab_jpeg()
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
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)
dz = 3000 # This is used for autofocus - make configurable?
sample_coverage = 7 # TODO: make this configurable
# construct a 2D scan path
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 = []
true_path = []
focused_path = [] # This holds a list of all points where focus succeeded
true_path = [] # This holds a list of all points visited
i = 0
ids = []
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):
os.makedirs(folder_path)
j = 0
while os.path.exists(os.path.join(folder_path, str(j))):
j += 1
folder_path = os.path.join(folder_path, str(j))
os.makedirs(folder_path)
try:
# TODO: I think this is unnecessary, we do a looping autofocus at the first point in the loop, unless
# it's flagged as background (which is a wierd case anyway)
recentre.looping_autofocus()
# 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:
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
while len(path) > 0:
if cancel.is_set():
logging.info("Scan terminated.")
break
loc = [path[0][0], path[0][1], stage.position["z"]]
# TODO: combine this with the move below for speed (I think this could just be "else")
stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=int(loc[2]))
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]))
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])
# Check if the image is background
background_fraction = background_detect.background_fraction()
background_coverage = round(
100 * background_fraction, 1
)
# Check if the image is background
background_fraction = background_detect.background_fraction()
background_coverage = round(
100 * background_fraction, 1
)
# if more than 92% of the image is background, treat it as background and continue
if 100 - background_coverage < sample_coverage:
category = "background"
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:
category = "background"
logger.info(f"Skipping {stage.position} as it is {background_coverage}% background.")
# fig.set_facecolor("gray")
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)
attempts = 0
while True:
recentre.looping_autofocus(dz=3000)
current_height = stage.position["z"]
category = "sample"
# fig.set_facecolor("pink")
# if there have been successful autofocuses in this scan, find the closest one in x-y
# 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:
recentre.looping_autofocus(dz=3000)
current_height = stage.position["z"]
# if there have been successful autofocuses in this scan, find the closest one in x-y
# 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 the autofocus worked, add the current position to the list of successful locations
if result == "accept":
loc = list(stage.position.values())
focused_path.append(loc)
break
if attempts >= 5:
logger.warning("Could not autofocus after 5 attempts.")
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
logger.info(
"The focus seems to have moved farther than we expect. "
"We will now rest the Z position and try again. "
f"Options are {focused_path}, we chose "
f"{nearest_focused_site}"
)
stage.move_absolute(z=int(focused_path[z_index][2]))
attempts += 1
# 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(
"The focus seems to have moved farther than we expect. "
"We will now rest the Z position and try again. "
f"Options are {focused_path}, we chose "
f"{nearest_focused_site}"
)
stage.move_absolute(z=int(focused_path[z_index][2]))
attempts += 1
img = Image.open(cam.capture_jpeg().open())
exif = img.info['exif']
width, height = img.size
img = img.resize((int(width*0.5), int(height*0.5)))
name = f"rabbit_{loc[0]}_{loc[1]}.jpg"
img = Image.open(cam.capture_jpeg(resolution="full").open())
exif = img.info['exif']
width, height = img.size
img = img.resize((int(width*0.5), int(height*0.5)))
name = f"image_{loc[0]}_{loc[1]}.jpg"
logger.info(f"Saving {name} at {stage.position}")
img.save(os.path.join(folder_path, name), exif = exif)
positions.append(loc[:2])
names.append(name)
logger.info(f"Saving {name} at {stage.position}")
img.save(
os.path.join(images_folder, 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()
img_preview = np.array(Image.open(img_preview.open()))
# add the current position to the list of all positions visited
true_path.append(loc)
# if len(names) > 1:
generate_config(images_folder, positions, names)
# if len(names) > 1:
generate_config(folder_path, positions, names)
path = sorted(path, key=lambda x: distance_to_site(loc[:2], x))
path = sorted(path, key=lambda x: distance_to_site(loc[:2], x))
if len(true_path) > 750:
break
logger.info("Returning to starting position.")
stage.move_absolute(x=starting_loc[0], y=starting_loc[1], z=starting_loc[2])
if len(true_path) > 750:
break
except InvocationCancelledError:
logger.error("Stopping scan because it was cancelled.", exc_info=1)
except IOError as e:
logger.error(
f"Stopping scan because of an IOError (most likely a full disk): {e}",
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)