From b35db02f343975eb7efff98df4397e547cf97d8f Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 17 Jan 2024 13:14:43 +0000 Subject: [PATCH] Improved error handling and zip download I've swapped out various `logging.exception` calls for `logging.error` with `exc_info` set to the exception. My reading of the docs is that the two shoulld be equivalent, but the former fails with an error related to message formatting, as if there was an extra (or missing) '%'. I've changed the format of the downloaded zip file, so that it now puts the stitched images at the top level (if they exist). I've also modified the time of the last stitched image so it returns None if it doesn't exist - this avoids HTTP errors in the client. --- .../things/smart_scan.py | 72 ++++++++++++++----- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 7bf05a5e..51a44b44 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -1,4 +1,5 @@ import shutil +import zipfile import threading from typing import Mapping, Optional import cv2 @@ -312,6 +313,9 @@ DOWNLOADABLE_SCAN_FILES = ( class JPEGBlob(BlobOutput): media_type = "image/jpeg" +class ZipBlob(BlobOutput): + media_type = "application/zip" + class SmartScanThing(Thing): def __init__(self, path_to_openflexure_stitch: str): self._script = path_to_openflexure_stitch @@ -425,6 +429,11 @@ class SmartScanThing(Thing): r = cam.grab_jpeg() arr = np.array(Image.open(r.open())) + if csm.image_resolution is None: + raise RuntimeError( + "Camera-stage mapping is not calibrated. This is required before " + "scans can be carried out." + ) if list(arr.shape[:2]) != [int(i) for i in csm.image_resolution]: logger.error( f"Images are, by default, {arr.shape[:2]}, but the CSM was " @@ -593,15 +602,17 @@ class SmartScanThing(Thing): except InvocationCancelledError: logger.error("Stopping scan because it was cancelled.") except NotEnoughFreeSpaceError as e: - logger.exception( + logger.error( f"Stopping scan to avoid filling up the disk: {e}", + exc_info=e, ) raise e except Exception as e: - logger.exception( + logger.error( f"The scan stopped because of an error: {e}", "We will attempt to stitch and archive the images acquired " - "so far." + "so far.", + exc_info=e, ) raise e finally: @@ -614,18 +625,13 @@ class SmartScanThing(Thing): logger.info("Waiting for background processes to finish...") self.preview_stitch_wait() self.correlate_wait() - logger.info("Stitching final image (may take some time)...") try: if scan_folder and self.stitch_automatically: + logger.info("Stitching final image (may take some time)...") self.stitch_scan(logger, os.path.basename(scan_folder), overlap=overlap) except SubprocessError as e: - logger.exception(f"Stitching failed: {e}") - logger.info("Creating zip archive of images (may take some time)...") - if images_folder and os.path.isdir(images_folder): - shutil.make_archive( - os.path.join(scan_folder, "images"), "zip", images_folder - ) - + logger.error(f"Stitching failed: {e}", exc_info=e) + @thing_property def max_range(self) -> int: """The maximum distance from the centre of the scan before we break""" @@ -777,12 +783,17 @@ class SmartScanThing(Thing): @thing_property def latest_preview_stitch_time(self) -> Optional[datetime]: - """The modification time of the latest preview image""" - fpath = self.latest_preview_stitch_path - if not os.path.exists(fpath): + """The modification time of the latest preview image + + This will return `null` if there is no preview image to return. + """ + try: + fpath = self.latest_preview_stitch_path + if os.path.exists(fpath): + return os.path.getmtime(fpath) + except IOError: return None - else: - return os.path.getmtime(fpath) + return None @fastapi_endpoint( "get", @@ -868,4 +879,33 @@ class SmartScanThing(Thing): """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]) + + @thing_action + def create_zip_of_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None) -> 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) + if scan_folder != os.path.dirname(images_folder) or os.path.basename(images_folder) != "images": + logger.error( + "There is a problem with filenames, the archive may be incorrect." + f"scan_folder: {scan_folder}, images_folder: {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.") + 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)