Merge branch 'better-rescue' into 'master'

Better rescue

See merge request openflexure/openflexure-microscope-server!81
This commit is contained in:
Joel Collins 2020-11-06 17:36:28 +00:00
commit 065825dfdd
7 changed files with 352 additions and 190 deletions

View file

@ -18,29 +18,6 @@ EXIF_FORMATS = ["JPG", "JPEG", "TIF", "TIFF"]
THUMBNAIL_SIZE = (200, 150) THUMBNAIL_SIZE = (200, 150)
def pull_usercomment_dict(filepath):
"""
Reads UserComment Exif data from a file, and returns the contained bytes as a dictionary.
Args:
filepath: Path to the Exif-containing file
"""
try:
exif_dict = piexif.load(filepath)
except InvalidImageDataError:
logging.warning("Invalid data at {}. Skipping.".format(filepath))
return None
if "Exif" in exif_dict and piexif.ExifIFD.UserComment in exif_dict["Exif"]:
try:
return json.loads(exif_dict["Exif"][piexif.ExifIFD.UserComment].decode())
except json.decoder.JSONDecodeError:
logging.error(
"Capture %s has old, corrupt, or missing OpenFlexure metadata. Unable to reload to server.",
filepath,
)
else:
return None
def make_file_list(directory, formats): def make_file_list(directory, formats):
files = [] files = []
for fmt in formats: for fmt in formats:
@ -63,8 +40,6 @@ def build_captures_from_exif(capture_path):
capture = capture_from_path(f) capture = capture_from_path(f)
if capture: if capture:
captures[capture.id] = capture captures[capture.id] = capture
else:
logging.error("Invalid data at {}. Skipping.".format(f))
logging.info("{} capture files successfully reloaded".format(len(captures))) logging.info("{} capture files successfully reloaded".format(len(captures)))
@ -72,37 +47,18 @@ def build_captures_from_exif(capture_path):
def capture_from_path(path): def capture_from_path(path):
exif_dict = pull_usercomment_dict(path) # Create a placeholder capture
if exif_dict: capture = CaptureObject(filepath=path)
# Create a placeholder capture
capture = CaptureObject(filepath=path)
# Build file path information # Build file path information
capture.split_file_path(capture.file) capture.split_file_path(capture.file)
# Image metadata
try:
image_metadata = exif_dict.pop("image")
except KeyError:
logging.error(
"Unable to obtain valid 2.0 OpenFlexure metadata from file %s", path
)
return None
# Populate capture parameters
capture.id = image_metadata.get("id")
capture.time = dateutil.parser.isoparse(
image_metadata.get("time") or image_metadata.get("acquisitionDate")
)
capture.format = image_metadata.get("format")
capture.tags = image_metadata.get("tags")
capture.annotations = image_metadata.get("annotations")
# Since we popped the "image" key, we dump whatever is left in _metadata
capture.set_metadata(exif_dict)
# Check and sync basic metadata
try:
capture.sync_basic_metadata()
return capture return capture
else: except (InvalidImageDataError, json.decoder.JSONDecodeError):
logging.error("Invalid metadata at {}.".format(path))
return None return None
@ -131,10 +87,8 @@ class CaptureObject(object):
if not os.path.exists(self.filefolder): if not os.path.exists(self.filefolder):
os.makedirs(self.filefolder) os.makedirs(self.filefolder)
# Dictionary for adding top-level metadata # Dataset information
# This can ONLY be modified by the server application self._dataset = None
# Top level metadata cannot be modified via the web API
self._metadata = {}
# Dictionary for storing custom annotations # Dictionary for storing custom annotations
# Can be modified via the web API # Can be modified via the web API
self.annotations = {} self.annotations = {}
@ -147,10 +101,12 @@ class CaptureObject(object):
self.stream.write(s) self.stream.write(s)
def flush(self): def flush(self):
logging.info("Writing to disk %s", self.file) logging.info("Writing image data to disk %s", self.file)
with open(self.file, "wb") as outfile: with open(self.file, "wb") as outfile:
outfile.write(self.stream.getbuffer()) outfile.write(self.stream.getbuffer())
self.stream.close() self.stream.close()
logging.info("Writing metadata to disk %s", self.file)
self._init_metadata()
logging.info("Finished writing to disk %s", self.file) logging.info("Finished writing to disk %s", self.file)
def open(self, mode): def open(self, mode):
@ -169,6 +125,65 @@ class CaptureObject(object):
self.basename = os.path.splitext(self.name)[0] self.basename = os.path.splitext(self.name)[0]
self.format = self.name.split(".")[-1] self.format = self.name.split(".")[-1]
def _read_exif(self):
return piexif.load(self.file)
def _decode_usercomment(self, exif_dict: dict):
if "Exif" not in exif_dict:
raise InvalidImageDataError
if piexif.ExifIFD.UserComment not in exif_dict["Exif"]:
return {}
return json.loads(exif_dict["Exif"][piexif.ExifIFD.UserComment].decode())
def _init_metadata(self):
self.put_and_save(
metadata={
"image": {
"id": self.id,
"name": self.name,
"time": self.time.isoformat(),
"format": self.format,
"tags": self.tags,
"annotations": self.annotations,
}
}
)
def sync_basic_metadata(self):
exif_dict = self._read_exif()
metadata_dict = self._decode_usercomment(exif_dict) or {}
image_metadata = metadata_dict.get("image")
if not image_metadata:
raise InvalidImageDataError("No capture metadata found")
self.id = image_metadata.get("id")
self.format = image_metadata.get("format")
self.time = dateutil.parser.isoparse(
image_metadata.get("time") or image_metadata.get("acquisitionDate")
)
self.tags = image_metadata.get("tags")
self.annotations = image_metadata.get("annotations")
dataset = metadata_dict.get("dataset")
if dataset:
self._dataset = {
"id": dataset.get("id"),
"name": dataset.get("name"),
"type": dataset.get("type"),
}
def read_full_metadata(self):
logging.info("Reading full capture metadata from %s...", self.file)
exif_dict = self._read_exif()
return self._decode_usercomment(exif_dict)
@property
def dataset(self):
"""Read-only dataset property"""
return self._dataset
@property @property
def exists(self) -> bool: def exists(self) -> bool:
"""Check if capture data file exists on disk.""" """Check if capture data file exists on disk."""
@ -177,21 +192,6 @@ class CaptureObject(object):
else: else:
return False return False
@property
def dataset(self) -> str:
"""
If capture is part of a dataset, return basic dataset info.
Otherwise return None
"""
dataset = self.metadata.get("dataset")
if not dataset:
return None
return {
"id": dataset.get("id"),
"name": dataset.get("name"),
"type": dataset.get("type"),
}
# HANDLE TAGS # HANDLE TAGS
def put_tags(self, tags: list): def put_tags(self, tags: list):
""" """
@ -200,11 +200,7 @@ class CaptureObject(object):
Args: Args:
tags (list): List of tags to be added tags (list): List of tags to be added
""" """
for tag in tags: self.put_and_save(tags=tags)
if tag not in self.tags:
self.tags.append(tag)
self.save_metadata()
def delete_tag(self, tag: str): def delete_tag(self, tag: str):
""" """
@ -213,10 +209,12 @@ class CaptureObject(object):
Args: Args:
tag (str): Tag to be removed tag (str): Tag to be removed
""" """
# Update in-memory tag list
if tag in self.tags: if tag in self.tags:
self.tags = [new_tag for new_tag in self.tags if new_tag != tag] self.tags = [new_tag for new_tag in self.tags if new_tag != tag]
self.save_metadata() # Write in-memory metadata to file
self.put_and_save()
# HANDLE ANNOTATIONS # HANDLE ANNOTATIONS
@ -227,13 +225,15 @@ class CaptureObject(object):
Args: Args:
data (dict): Dictionary of metadata to be added data (dict): Dictionary of metadata to be added
""" """
self.annotations.update(data) self.put_and_save(annotations=data)
self.save_metadata()
def delete_annotation(self, key: str) -> None: def delete_annotation(self, key: str) -> None:
# Update in-memory annotations list
if key in self.annotations: if key in self.annotations:
del self.annotations[key] del self.annotations[key]
self.save_metadata()
# Write in-memory metadata to file
self.put_and_save()
# HANDLE METADATA # HANDLE METADATA
@ -244,17 +244,7 @@ class CaptureObject(object):
Args: Args:
data (dict): Dictionary of metadata to be added data (dict): Dictionary of metadata to be added
""" """
self._metadata.update(data) self.put_and_save(metadata=data)
self.save_metadata()
def set_metadata(self, data: dict) -> None:
"""
Write root metadata from a passed dictionary into the capture metadata.
Args:
data (dict): Dictionary of metadata to be added
"""
self._metadata = data
# BULK OPERATIONS # BULK OPERATIONS
@ -271,35 +261,42 @@ class CaptureObject(object):
if not metadata: if not metadata:
metadata = {} metadata = {}
# Tags # Update in-memory tags array
for tag in tags: for tag in tags:
if tag not in self.tags: if tag not in self.tags:
self.tags.append(tag) self.tags.append(tag)
# Annotations
# Update in-memory annotations dictionary
self.annotations.update(annotations) self.annotations.update(annotations)
# Metadata
self._metadata.update(metadata)
self.save_metadata() # Write new data to file EXIF, if supported
def save_metadata(self) -> None:
"""
Save metadata to exif, if supported
"""
if self.format.upper() in EXIF_FORMATS and self.exists: if self.format.upper() in EXIF_FORMATS and self.exists:
logging.debug("Writing exif data to capture file") logging.info("Writing Exif data to %s", self.file)
# Extract current Exif data # Extract current Exif data
exif_dict = piexif.load(self.file) exif_dict = self._read_exif()
metadata_dict = self._decode_usercomment(exif_dict) or {}
# Add new tags to exif dictionary
metadata_dict.get("image", {})["tags"] = self.tags
# Add new annotations to exif dictionary
metadata_dict.get("image", {})["annotations"] = self.annotations
# Add new custom metadata to exif dictionary
metadata_dict.update(metadata)
# Serialize metadata # Serialize metadata
metadata_string = json.dumps(self.metadata, cls=JSONEncoder) metadata_string = json.dumps(metadata_dict, cls=JSONEncoder)
logging.debug("Saving metadata string to file: %s", metadata_string) logging.debug("Saving metadata string to file: %s", metadata_string)
# Insert metadata into exif_dict # Insert metadata into exif_dict
exif_dict["Exif"][piexif.ExifIFD.UserComment] = metadata_string.encode() exif_dict["Exif"][piexif.ExifIFD.UserComment] = metadata_string.encode()
# Convert new exif dict to exif bytes # Convert new exif dict to exif bytes
exif_bytes = piexif.dump(exif_dict) exif_bytes = piexif.dump(exif_dict)
# Insert exif into file # Insert exif into file
piexif.insert(exif_bytes, self.file) piexif.insert(exif_bytes, self.file)
logging.info("Finished saving metadata to %s", self.file) logging.info("Finished writing Exif data to %s", self.file)
# PROPERTIES # PROPERTIES
@ -309,42 +306,13 @@ class CaptureObject(object):
Create basic metadata dictionary from basic capture data, Create basic metadata dictionary from basic capture data,
and any added custom metadata and tags. and any added custom metadata and tags.
""" """
d = {
"image": {
"id": self.id,
"name": self.name,
"time": self.time.isoformat(),
"format": self.format,
"tags": self.tags,
"annotations": self.annotations,
},
**self._metadata,
}
# Add custom metadata to dictionary # Add custom metadata to dictionary
return d return self.read_full_metadata()
@property
def state(self) -> dict:
"""
Return a dictionary of objects full state, including metadata.
"""
# Create basic state dictionary
d = {"path": self.file, "name": self.name, "metadata": self.metadata}
# Combined availability of data
if self.exists:
d["available"] = True
else:
d["available"] = False
return d
@property @property
def data(self) -> io.BytesIO: def data(self) -> io.BytesIO:
""" """
Return a byte string of the capture data. Return a BytesIO object of the capture data.
""" """
if self.exists: # If data file exists if self.exists: # If data file exists
@ -390,7 +358,7 @@ class CaptureObject(object):
"""Write stream to file, and save/update metadata file""" """Write stream to file, and save/update metadata file"""
# If a stream OR file exists, save the metadata file # If a stream OR file exists, save the metadata file
if self.exists: if self.exists:
self.save_metadata() self.put_and_save()
def delete(self) -> bool: def delete(self) -> bool:
"""If the StreamObject has been saved, delete the file.""" """If the StreamObject has been saved, delete the file."""

View file

@ -1,12 +1,23 @@
import logging import logging
import os import os
import sys
import platform
import pkg_resources
from .error_sources import bcolors
from openflexure_microscope.paths import ( from openflexure_microscope.paths import (
FALLBACK_OPENFLEXURE_VAR_PATH, FALLBACK_OPENFLEXURE_VAR_PATH,
PREFERRED_OPENFLEXURE_VAR_PATH, PREFERRED_OPENFLEXURE_VAR_PATH,
) )
from . import check_capture_reload, check_settings, check_picamera from . import (
check_capture_reload,
check_settings,
check_picamera,
check_sangaboard,
check_system,
)
# Paths for suggestions # Paths for suggestions
LOGS_PATHS = [ LOGS_PATHS = [
@ -26,55 +37,54 @@ DATA_PATHS = [
os.path.join(FALLBACK_OPENFLEXURE_VAR_PATH, "data"), os.path.join(FALLBACK_OPENFLEXURE_VAR_PATH, "data"),
] ]
# Look for debug flag
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(logging.DEBUG) if "-d" in sys.argv or "--debug" in sys.argv:
logging.debug("Testing debug logger. One two one two.") logger.setLevel(logging.DEBUG)
logging.debug("Testing debug logger. One two one two.")
else:
logger.setLevel(logging.WARNING)
class bcolors:
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
error_keys = {
"config_settings_import_error": "The configuration submodule could not be imported properly. This is usually because the default config or settings files are badly broken somehow. See further errors for details",
"default_config_empty": "The default configuration file is empty, and could not be automatically populated.",
"default_settings_empty": "The default settings file is empty, and could not be automatically populated.",
"default_config_error": f"The default configuration file could not be parsed. To fix, consider backing up and deleting your configuration files from {CONFIG_PATHS} to reset the configuration.",
"default_settings_error": f"The default settings file could not be parsed. To fix, consider backing up and deleting your configuration files from {SETTINGS_PATHS} to reset the configuration.",
"capture_rebuild_timeout": f"Capture database rebuilding took a long time. This may not cause catastrophic errors, but rather will cause the server to hang for a while. To fix, consider moving your captures from {DATA_PATHS} to another location.",
"picamera_import_error": "Picamera module could not be imported. Check physical connections to the camera as it may be damaged."
}
if __name__ == "__main__": if __name__ == "__main__":
spoof = False spoof = False
print()
print(bcolors.HEADER + "OpenFlexure Rescue" + bcolors.ENDC)
print()
print(
"This script attempts to identify common issues for a microscope not working properly."
)
print(
"It is not designed to identify bugs in the code, but rather configuration or setup issues."
)
print()
print("Any identified warnings [?] or errors [!] will be reported.")
print()
error_sources = [] error_sources = []
if spoof: error_sources.extend(check_system.main())
error_sources = list(error_keys.keys())
error_sources.extend(check_settings.main()) error_sources.extend(check_settings.main())
error_sources.extend(check_picamera.main()) error_sources.extend(check_picamera.main())
#error_sources.extend(check_capture_reload.main()) error_sources.extend(check_capture_reload.main())
error_sources.extend(check_sangaboard.main())
dist = pkg_resources.get_distribution("openflexure-microscope-server")
print()
print(f"Server Version: {dist.version}")
print(f"Platform: {platform.platform()}")
if not error_sources: if not error_sources:
print() print()
print(bcolors.OKGREEN + "No errors found!" + bcolors.ENDC) print(bcolors.OKGREEN + "No issues found!" + bcolors.ENDC)
print( print(
"That's not to say everything is fine, only that our automatic diagnostics couldn't find much." "That's not to say everything is fine, only that our automatic diagnostics couldn't find much."
) )
print(f"You can check through the server logs at {LOGS_PATHS}") print(f"You can check through the server logs at {LOGS_PATHS}")
else: else:
for err_code in error_sources: print()
logging.error(err_code) for err in error_sources:
if error_keys.get(err_code): print(err.message)
print(bcolors.FAIL + error_keys.get(err_code) + bcolors.ENDC)

View file

@ -1,23 +1,18 @@
import logging import logging
from openflexure_microscope.captures.capture import build_captures_from_exif from openflexure_microscope.captures.capture import (
build_captures_from_exif,
make_file_list,
EXIF_FORMATS,
)
from openflexure_microscope.captures.capture_manager import BASE_CAPTURE_PATH from openflexure_microscope.captures.capture_manager import BASE_CAPTURE_PATH
from openflexure_microscope.config import user_settings from openflexure_microscope.config import user_settings
from openflexure_microscope.rescue.monitor_timeout import launch_timeout_test_process from openflexure_microscope.rescue.monitor_timeout import launch_timeout_test_process
from .error_sources import ErrorSource, WarningSource
def check_capture_reload():
logging.info("Loading user settings...")
settings = user_settings.load()
cap_path = str(settings.get("captures", {}).get("paths", {}).get("default"))
logging.info("Capture path found: %s", cap_path)
if not cap_path:
logging.error(
"No capture path defined in settings. This is unusual for anything other than a first-run. \nFalling back to default path."
)
cap_path = BASE_CAPTURE_PATH
def check_capture_reload(cap_path):
logging.info("Starting capture reload with a 60 second timeout...") logging.info("Starting capture reload with a 60 second timeout...")
passed_timeout_test = launch_timeout_test_process( passed_timeout_test = launch_timeout_test_process(
build_captures_from_exif, args=(cap_path,), timeout=60 build_captures_from_exif, args=(cap_path,), timeout=60
@ -28,7 +23,40 @@ def check_capture_reload():
def main(): def main():
error_sources = [] error_sources = []
passed_timeout = check_capture_reload()
logging.info("Loading user settings...")
settings = user_settings.load()
cap_path = str(settings.get("captures", {}).get("paths", {}).get("default"))
logging.info("Capture path found: %s", cap_path)
if not cap_path:
logging.error(
"No capture path defined in settings. This is unusual for anything other than a first-run. \nFalling back to default path."
)
cap_path = BASE_CAPTURE_PATH
# Check number of captures being restored
files = make_file_list(cap_path, EXIF_FORMATS)
if len(files) >= 10000:
error_sources.append(
WarningSource(
(
"Over 10000 captures are being restored. This may slow down server startup.",
f"Consider moving your captures from {cap_path} to another location.",
)
)
)
# Check restore time of captures
passed_timeout = check_capture_reload(cap_path)
if not passed_timeout: if not passed_timeout:
error_sources.append("capture_rebuild_timeout") error_sources.append(
ErrorSource(
(
"Capture database rebuilding took a long time. This may not cause catastrophic errors, but rather will cause the server to hang for a while.",
f"To fix, consider moving your captures from {cap_path} to another location.",
)
)
)
return error_sources return error_sources

View file

@ -1,15 +1,25 @@
import logging import logging
from .error_sources import ErrorSource
picamera_import_error = ErrorSource(
(
"Picamera module could not be imported.",
"Check physical connections to the camera as it may be damaged or disconnected.",
)
)
def main(): def main():
error_sources = [] error_sources = []
logging.info("Attempting to import picamera...") logging.info("Attempting to import picamera...")
try: try:
from picamerax import PiCamera from picamerax import PiCamera as _
except Exception as e: # pylint: disable=W0703 except Exception as e: # pylint: disable=W0703
# TODO: Parse exception object for a few different issues, and append different error sources # TODO: Parse exception object for a few different issues, and append different error sources
# E.g. pi with camera already in use, disconnected, unsupported OS, etc # E.g. pi with camera already in use, disconnected, unsupported OS, etc
logging.error(e) logging.error(e)
error_sources.append("picamera_import_error") error_sources.append(picamera_import_error)
return error_sources return error_sources

View file

@ -0,0 +1,45 @@
from .error_sources import ErrorSource
from openflexure_microscope.config import user_configuration
def main():
error_sources = []
try:
from sangaboard import Sangaboard
except ImportError as e:
error_sources.append(ErrorSource(str(e)))
else:
configuration = user_configuration.load()
stage_type = configuration["stage"].get("type")
stage_port = configuration["stage"].get("port")
# If any Sangaboard-based stage is configured for use
if stage_type in ("SangaBoard", "SangaStage", "SangaDeltaStage"):
# Try connecting on the specified port
try:
stage = Sangaboard(stage_port)
except FileNotFoundError as e:
if stage_port:
error_sources.append(
ErrorSource(
f"No {stage_type} device was found on the configured port {stage_port}."
)
)
else:
error_sources.append(
ErrorSource(
f"No {stage_type} device was found during port scanning."
)
)
else:
stage.close()
else:
error_sources.append(
ErrorSource(
f"Invalid stage type {stage_type} specified in configuration file."
)
)
return error_sources

View file

@ -0,0 +1,68 @@
from urllib.request import urlopen
from urllib.error import URLError
import psutil
from .error_sources import WarningSource
def internet_on():
test_urls = (
"https://build.openflexure.org", # Bath server
"https://openflexure.org", # GitHub pages
)
for url in test_urls:
try:
urlopen(url, timeout=10)
# If any test passes, return True
return True
except URLError:
pass
# If no test passed, return False
return False
def main():
error_sources = []
# Check internet
if not internet_on():
error_sources.append(
WarningSource(
"No internet connection detected. Updates may not be available."
)
)
# Check memory
mem = psutil.virtual_memory()
total_gb = mem.total / 1e9
if total_gb <= 1:
error_sources.append(
WarningSource(
(
"Less than 1GB total memory available.",
"For small scans, or control from another device, this is usually fine.",
"\n More complex usage may require additional resources.",
)
)
)
# Check disks
data_partitions = [
disk
for disk in psutil.disk_partitions()
if "rw" in disk.opts and disk.mountpoint != "/boot"
]
for part in data_partitions:
usage = psutil.disk_usage(part.mountpoint)
if int(usage.percent) >= 90:
error_sources.append(
WarningSource(
(
f"Disk {part.device}, at {part.mountpoint} is {int(usage.percent)}% full.",
"Captures may fail to save soon.",
)
)
)
return error_sources

View file

@ -0,0 +1,33 @@
class bcolors:
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
class Source:
def __init__(self, message):
if isinstance(message, tuple):
self._message = " ".join(message)
else:
self._message = message
@property
def message(self):
return self._message
class WarningSource(Source):
@property
def message(self):
return "[?] " + bcolors.WARNING + self._message + bcolors.ENDC
class ErrorSource(Source):
@property
def message(self):
return "[!] " + bcolors.FAIL + self._message + bcolors.ENDC