openflexure-microscope-server/src/openflexure_microscope_server/scan_directories.py

697 lines
25 KiB
Python

"""Functionality to manage file system operations for scan directories."""
import json
import logging
import os
import re
import shutil
import threading
import zipfile
from datetime import datetime, timedelta
from typing import Any, Mapping, Optional, Self
from pydantic import (
BaseModel,
ConfigDict,
ValidationError,
field_serializer,
field_validator,
model_validator,
)
import labthings_fastapi as lt
from labthings_fastapi.exceptions import InvocationError
from openflexure_microscope_server.stitching import StitchingSettings
from openflexure_microscope_server.utilities import (
make_name_safe,
requires_lock,
save_invocation_logs,
)
LOGGER = logging.getLogger(__name__)
IMG_DIR_NAME = "images"
SCAN_ZERO_PAD_DIGITS = 4
STITCH_REGEX = re.compile(r"stitched\.jpe?g$")
IMAGE_REGEX = re.compile(r"-?[0-9]+_-?[0-9]+\.jpe?g$")
SCAN_DATA_FILENAME = "scan_data.json"
SCAN_DATA_SCHEMA_VERSION = 2
class NotEnoughFreeSpaceError(InvocationError):
"""An exception raised if there is not enough free space on disk to scan."""
class ScanInfo(BaseModel):
"""Summary information for the UI about a scan folder."""
name: str
created: float
modified: float
duration: Optional[float]
number_of_images: int
stitch_available: bool
dzi: Optional[str]
class BaseScanData(BaseModel):
"""Data about a scan not including workflow specific data.
For including workflow specific data see also:
* ActiveScanData which subclasses this including the BaseModel used by the
ScanWorkflow
* HistoricScanData which has the workflow specific data loaded as a dictionary.
Separating historic and active data allows workflows to use any BaseModel for its
settings, but for the data to be reloaded even if that model has updated or is not
available. Historic scan data loaded from disk is used for stitching and for
creating a ScanInfo object for communicating with the UI. These uses are clearly
typed by this model.
This serialises into a human readable format where possible with
timestamps in %Y-%m-%d_%H:%M:%S format
timedeltas in %H:%M:%S format
Properties that are not known until the end have ``None`` serialised as "Unknown"
"""
model_config = ConfigDict(extra="forbid")
schema_version: int = SCAN_DATA_SCHEMA_VERSION
scan_name: str
"""The name of the scan i.e. scan_0001"""
starting_position: Mapping[str, int]
"""The starting position in dictionary format."""
start_time: datetime
"""The time the scan started."""
stitch_automatically: bool
"""Whether the scan is set to automatically stitch when complete."""
save_resolution: tuple[int, int]
"""The resolution that scan images are saved at."""
image_count: int = 0
"""The number of images taken."""
duration: Optional[timedelta] = None
"""The duration of the scan.
This is automatically set when ``set_final_data()`` is run.
"""
scan_result: Optional[str] = None
"""The result of the scan.
This should be set with ``set_final_data()`` to ensure duration is set.
"""
stitching_settings: Optional[StitchingSettings]
"""The data needed to stitch a scan.
Set to None for types of scan that cannot be stitched.
"""
workflow: str
"""The class name of the workflow Thing."""
@model_validator(mode="after")
def validate_schema_version(self) -> Self:
"""Validate the schema version is as the current one."""
if self.schema_version != SCAN_DATA_SCHEMA_VERSION:
raise ValueError(
f"Unsupported schema version {self.schema_version}, "
f"expected {SCAN_DATA_SCHEMA_VERSION}"
)
return self
@field_validator("start_time", mode="before")
@classmethod
def parse_timestamp(cls, value: str | datetime) -> datetime:
"""Validate a timestamp that may be a string in Year-Month-Day_Hrs:Min:Sec format."""
if isinstance(value, str):
return datetime.strptime(value, "%Y-%m-%d_%H:%M:%S")
return value
@field_serializer("start_time")
def serialize_timestamp(self, value: datetime) -> str:
"""Serialise timestamp to Year-Month-Day_Hrs:Min:Sec format."""
return value.strftime("%Y-%m-%d_%H:%M:%S")
@field_validator("duration", mode="before")
@classmethod
def parse_timedelta(cls, value: Optional[str | timedelta]) -> Optional[timedelta]:
"""Validate a timedelta that may be a string in Hrs:Min:Sec format or "Unknown"."""
if isinstance(value, str):
if value == "Unknown":
return None
hrs, mins, secs = map(int, value.split(":"))
return timedelta(hours=hrs, minutes=mins, seconds=secs)
return value
@field_serializer("duration")
def serialize_timedelta(self, value: Optional[timedelta]) -> str:
"""Serialise timedelta to Hrs:Min:Sec (or "Unknown" if None)."""
if value is None:
return "Unknown"
# Calculate the string manually rather than use str(), as this may convert to
# days for a very very long scan, and then it is much harder to parse.
total_secs = value.total_seconds()
hrs = int(total_secs // 3600)
mins = int((total_secs % 3600) // 60)
secs = int((total_secs % 60))
return f"{hrs}:{mins:02}:{secs:02}"
@field_validator("scan_result", mode="before")
@classmethod
def parse_unknown_as_none(cls, value: Optional[str | int]) -> Optional[str | int]:
"""Validate the string "Unknown" as None."""
return None if value == "Unknown" else value
@field_serializer("scan_result")
def serialize_none_as_unknown(self, value: Optional[str | int]) -> str | int:
"""Serialise None as "Unknown" for a more human readable result."""
return "Unknown" if value is None else value
class HistoricScanData(BaseScanData):
"""A Model for the scan data that has been loaded from disk.
Any workflow specific settings are loaded as an arbitrary dictionary. Other
settings such as those which are needed for the UI or stitching are loaded and
validated by the parent class ``BaseScanData``.
"""
workflow_settings: dict
"""A dictionary of the settings for the workflow that was used workflow."""
@model_validator(mode="before")
@classmethod
def coerce_legacy(cls, data: dict) -> dict:
"""Coerce any scan data from before version 2 into the version 2 format."""
# Before the current version no schema_version was set
if "schema_version" in data:
return data
if "correlation_resize" and "overlap" in data:
correlation_resize = data.pop("correlation_resize")
# Note we don't pop overlap, as it is a setting for the legacy workflow as well
# as a stitching setting.
# This is done because in future workflows the stitching overlap may be a
# directly set setting or something that is calculated from other settings.
overlap = data["overlap"]
data["stitching_settings"] = StitchingSettings(
correlation_resize=correlation_resize,
overlap=overlap,
)
else:
data["stitching_settings"] = None
# Add any legacy workflow settings that are found
legacy_keys = [
"overlap",
"max_dist",
"dx",
"dy",
"autofocus_dz",
"autofocus_on",
"skip_background",
]
workflow_settings = {}
for key in legacy_keys:
if key in data:
workflow_settings[key] = data.pop(key)
data["workflow"] = "Legacy"
data["workflow_settings"] = workflow_settings
return data
class ScanDirectoryManager:
"""A class for managing interactions with scan directories."""
_base_scan_dir: str
def __init__(self, base_scan_dir: str) -> None:
"""Initialise the scan directory manager.
:param base_scan_dir: Path of the directory that holds all scans.
"""
self._base_scan_dir = base_scan_dir
if not os.path.exists(self._base_scan_dir):
os.makedirs(self._base_scan_dir)
self._lock = threading.RLock()
@property
def base_dir(self) -> str:
"""The base directory scans are saved to."""
return self._base_scan_dir
def exists(self, scan_name: str) -> bool:
"""Return True if scan of this name exists on disk."""
return os.path.isdir(self.path_for(scan_name))
def path_for(self, scan_name: str) -> str:
"""Return the path for a given scan name.
Returns the path even if it doesn't exist
"""
return os.path.join(self._base_scan_dir, scan_name)
def img_dir_for(self, scan_name: str) -> str:
"""Return the path for the image dir for a given scan name.
Returns the path even if it doesn't exist
"""
return os.path.join(self._base_scan_dir, scan_name, IMG_DIR_NAME)
@requires_lock
def get_file_path_from(
self, scan_name: str, filename: str, check_exists: bool = False
) -> Optional[str]:
"""Return the full file path for the file within a scan directory.
If check_exists is True then None will be returned if the file does
not exist.
"""
file_path = os.path.join(self.path_for(scan_name), filename)
if check_exists and not os.path.exists(file_path):
return None
return file_path
@requires_lock
def get_file_path_from_img_dir(
self, scan_name: str, filename: str, check_exists: bool = False
) -> Optional[str]:
"""Return the full file path for the file within a scan directory.
If check_exists is True, None is returned if the file does not exist. If False
then the path is returned anyway
"""
file_path = os.path.join(self.img_dir_for(scan_name), filename)
if check_exists and not os.path.exists(file_path):
return None
return file_path
def get_final_stitch_path(self, scan_name: str) -> Optional[str]:
"""Return the file full path for the final stitch.
If no final stitch is found, return None
"""
stitch_fname = ScanDirectory(scan_name, self.base_dir).get_final_stitch_name()
if stitch_fname is None:
return None
return self.get_file_path_from_img_dir(scan_name, stitch_fname)
@requires_lock
def get_scan_data_path(self, scan_name: str) -> Optional[str]:
"""Return the file full path scan data JSON file.
If no scan data JSON file is found, return None
"""
scan_data_path = ScanDirectory(scan_name, self.base_dir).scan_data_path
if scan_data_path is None:
return None
if not os.path.isfile(scan_data_path):
return None
return scan_data_path
def get_scan_data(self, scan_name: str) -> Optional[HistoricScanData]:
"""Return the scan data read from a JSON file as a dict."""
return ScanDirectory(scan_name, self.base_dir).get_scan_data()
@property
@requires_lock
def all_scans(self) -> list[str]:
"""Return a list of the scan names in the base directory."""
return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()]
@requires_lock
def all_scans_info(
self, *, ongoing: Optional[str] = None, include_ongoing: bool = True
) -> list[ScanInfo]:
"""Return a lists of ScanInfo objects for each scan.
:param ongoing: The name of the ongoing scan (or None if no scan is ongoing).
:param include_ongoing: True (dfault) to include the scan info of the ongoing
scan in the return. False to exclude it.
:return: A list of ScanInfo objects for each scan.
"""
all_info: list[ScanInfo] = []
for scan_name in self.all_scans:
scan_is_ongoing = scan_name == ongoing
if scan_is_ongoing and not include_ongoing:
continue
scan_dir = ScanDirectory(scan_name, self.base_dir)
# If the scan is ongoing send flag to skip reading the json data
info = scan_dir.scan_info(skip_json=scan_is_ongoing)
all_info.append(info)
return all_info
@requires_lock
def _unique_scan_name(self, scan_name: str) -> str:
"""Get the next unique scan name starting with the given name.
For more explanation on the scan naming see `new_scan_dir`
"""
scan_name = make_name_safe(scan_name)
# Strip all trailing underscores from the base name
scan_name = scan_name.strip("_")
# A regex with the scan name and a group for the numbers
scan_regex = re.compile(
"^" + scan_name + "_([0-9]{" + str(SCAN_ZERO_PAD_DIGITS) + "})$"
)
matching_scans = [scan for scan in self.all_scans if scan_regex.match(scan)]
if not matching_scans:
scan_num = 1
else:
last_matching_scan = sorted(matching_scans)[-1]
# Get the first group from the regex, turn to int, and add 1
scan_match = scan_regex.match(last_matching_scan)
if scan_match is None: # pragma: no cover
# Type narrow, we know it is a match but mypy doesn't. This code is
# Not reachable hence the no cover.
raise RuntimeError("Internal error: regex No longer matches")
scan_num = int(scan_match[1]) + 1
# Set a sensible limit for the number of scans of one name
# based on our zero padding.
max_scan_no = 10**SCAN_ZERO_PAD_DIGITS - 1
if scan_num > max_scan_no:
raise FileExistsError(
"Could not create a new scan folder: all names in use!"
)
return f"{scan_name}_{scan_num:0{SCAN_ZERO_PAD_DIGITS}d}"
@requires_lock
def new_scan_dir(self, scan_name: str) -> "ScanDirectory":
"""Get a unique name for this scan and create a directory for it.
The scan will be named ``{scan_name}_0001`` where the number is
zero-padded to be SCAN_ZERO_PAD_DIGITS digits long (to allow correct sorting if the
scans are ordered alphanumerically).
Creates a new empty folder, into which scans are saved
Returns the a ScanDirectory object
"""
# if no scan name is set set to "scan". This done here as empty strings
# get passed in otherwise.
if not scan_name:
scan_name = "scan"
full_scan_name = self._unique_scan_name(scan_name)
os.makedirs(self.path_for(full_scan_name))
os.makedirs(self.img_dir_for(full_scan_name))
return ScanDirectory(full_scan_name, self.base_dir)
@requires_lock
def delete_scan(self, scan_name: str) -> None:
"""Delete a scan."""
shutil.rmtree(self.path_for(scan_name))
@requires_lock
def zip_scan(self, scan_name: str, final_version: bool = False) -> str:
"""Zips any images from the scan not yet zipped, return full path to zip.
``final_version`` Set true to stitch all files not just the scan images
this should only be done at the end as it is not possible to update a file
in a zip.
"""
return ScanDirectory(scan_name, self.base_dir).zip_files(final_version)
@requires_lock
def check_free_disk_space(self, min_space: int = 500000000) -> None:
"""Raise an exception if there is not enough free disk space to continue scanning.
:param min_space: the minimum space required in bytes.
Default = 500,000,000 (500MB)
:raises NotEnoughFreeSpaceError: if the remaining storage is below min_space
"""
disk_usage = shutil.disk_usage(self._base_scan_dir)
if disk_usage.free < min_space:
raise NotEnoughFreeSpaceError(
"There is not enough free disk space to continue. "
f"(Required: {min_space >> 20}MB. Free: {disk_usage.free >> 20}MB)."
)
class ScanDirectory:
"""A class for handling interactions with scan directories."""
_name: str
_base_scan_dir: str
def __init__(self, name: str, base_scan_dir: str) -> None:
"""Initialise the scan directory.
:param name: the name of the scan (the scan directory basename).
:param base_scan_dir: Path of the directory that holds all scans.
"""
self._log_saved_at = 0.0
self._name = name
self._base_scan_dir = base_scan_dir
if not os.path.isdir(self.dir_path):
raise FileNotFoundError(
f"The scan directory {self.dir_path} cannot be found"
)
@property
def name(self) -> str:
"""The name of the scan."""
return self._name
@property
def dir_path(self) -> str:
"""The full path to the scan directory."""
return os.path.join(self._base_scan_dir, self._name)
@property
def images_dir(self) -> Optional[str]:
"""The path to the images directory.
None is returned if no images directory was created.
"""
im_path = os.path.join(self.dir_path, IMG_DIR_NAME)
if os.path.isdir(im_path):
return im_path
return None
@property
def scan_data_path(self) -> Optional[str]:
"""The path to the scan data json file for this directory.
Returns None if there is no images dir to write to.
"""
if self.images_dir is not None:
return os.path.join(self.images_dir, SCAN_DATA_FILENAME)
return None
@property
def created_time(self) -> float:
"""The time the directory was created on disk."""
return os.path.getctime(self.dir_path)
def get_scan_files(self) -> list[str]:
"""Return a list of the files in the images dir."""
if self.images_dir is None:
return []
return os.listdir(self.images_dir)
def _extract_scan_images(self, file_list: list[str]) -> list[str]:
"""Extract files which match the naming convention for scan images.
:param file_list: The list of files to search. Normally this would be
``self.get_scan_files()``
:returns: The list of files that match the naming convention for scan images
"""
return [i for i in file_list if IMAGE_REGEX.search(i)]
def _extract_final_stitches(self, file_list: list[str]) -> list[str]:
"""Extract files which match the naming convention for final stitches.
:param file_list: The list of files to search.
:returns: The list of files that match the naming convention for final stitches
"""
return [i for i in file_list if STITCH_REGEX.search(i)]
def _extract_dzi_files(self, file_list: list[str]) -> list[str]:
"""Extract files which match the naming convention for dzi_files.
:param file_list: The list of files to search.
:returns: The list of files that match the naming convention for dzi_files
"""
return [i for i in file_list if i.endswith("dzi")]
def get_final_stitch_name(self) -> Optional[str]:
"""Return the filename for the final stitch (in the images dir).
If no final stitch is found, return None
"""
stitches = self._extract_final_stitches(self.get_scan_files())
if not stitches:
return None
return stitches[0]
def get_modified_time(self) -> float:
"""Return the modified time of the directory."""
return max(os.stat(root).st_mtime for root, _, _ in os.walk(self.dir_path))
def _get_scan_data_dict(self) -> Optional[dict[str, Any]]:
"""Return the scan data from the json file as a dictionary.
This is safer than get_scan_data for older scans before a defined model was
used.
:return: The data as a dictionary or None if it couldn't be loaded.
"""
if self.scan_data_path is None:
return None
try:
with open(self.scan_data_path, "r", encoding="utf-8") as data_file:
return json.load(data_file)
except (json.decoder.JSONDecodeError, IOError):
return None
def get_scan_data(self) -> Optional[HistoricScanData]:
"""Return the scan data from the json file as a HistoricScanData model.
:return: The data as a HistoricScanData model or None if it couldn't be loaded or
valdiated.
"""
data_dict = self._get_scan_data_dict()
if data_dict is None:
LOGGER.warning(f"Could not load scan data for {self.name}.")
return None
try:
return HistoricScanData(**data_dict)
except ValidationError:
LOGGER.warning(f"Could not validate scan data for {self.name}.")
return None
def scan_info(self, skip_json: bool = False) -> ScanInfo:
"""Return the information to be used in the UI for the scan."""
scan_files = self.get_scan_files()
scan_images = self._extract_scan_images(scan_files)
stitches = self._extract_final_stitches(scan_files)
dzi_files = self._extract_dzi_files(scan_files)
number_of_images = len(scan_images)
stitch_available = len(stitches) > 0
dzi = None if not dzi_files else str(dzi_files[0])
scan_data = None if skip_json else self.get_scan_data()
duration = (
None
if scan_data is None or scan_data.duration is None
else scan_data.duration.total_seconds()
)
return ScanInfo(
name=self.name,
created=self.created_time,
modified=self.get_modified_time(),
duration=duration,
number_of_images=number_of_images,
stitch_available=stitch_available,
dzi=dzi,
)
def all_files(self, skip_dirs: Optional[list[str]] = None) -> list[str]:
"""Return a list of all files in the scan dir relative to the dir.
:param skip_dirs: Skip any file in a directory that is on this list. The list
should be the basename of the directory. e.g. "scan_0001_files" not
"images/scan_0001_files"
"""
if skip_dirs is None:
skip_dirs = []
files = []
for file_root, dirs, filenames in os.walk(self.dir_path, topdown=True):
# Skip any skipped directories.
# Note: we must use slice assignment to edit in place.
dirs[:] = [d for d in dirs if d not in skip_dirs]
for filename in filenames:
full_path = os.path.join(file_root, filename)
files.append(os.path.relpath(full_path, self.dir_path))
return files
def save_scan_data(self, scan_data: BaseScanData) -> None:
"""Save the scan data for this scan to disk."""
if self.scan_data_path is None:
raise FileNotFoundError(
"There is no images directory to save scan data into."
)
with open(self.scan_data_path, "w", encoding="utf-8") as f:
f.write(scan_data.model_dump_json(indent=4))
def save_scan_log(self, thing: lt.Thing) -> None:
"""Save the scan log to file.
:param thing: The SmartScanThing. This is needed to exxtract the logs from the
LabThings server.
"""
self._log_saved_at = save_invocation_logs(
filename=os.path.join(self.dir_path, "log.txt"),
thing=thing,
last_saved=self._log_saved_at,
append=True,
)
def zip_files(self, final_version: bool = False) -> str:
"""Zips any images from the scan not yet zipped, return full path to zip.
``final_version`` Set true to stitch all files not just the scan images
this should only be done at the end as it is not possible to update a file
in a zip.
"""
zip_fname = os.path.join(self.dir_path, "images.zip")
# Use noqa as converting this into a 1 liner is not more readable.
if os.path.isfile(zip_fname): # noqa: SIM108
# get a list of files in the existing zip
zip_files = get_files_in_zip(zip_fname)
else:
zip_files = []
# For each `filename.dzi` we need to skip the `filename_files` directory
dzi_files = self._extract_dzi_files(self.get_scan_files())
dzi_dirs = [dzi[:-4] + "_files" for dzi in dzi_files]
with zipfile.ZipFile(zip_fname, mode="a") as scan_zip:
for file in self.all_files(skip_dirs=dzi_dirs):
# Don't zip zipfiles, dzi files, or files already in the zip
if file.endswith((".zip", ".dzi")) or file in zip_files:
continue
# If this is not the final version, then only zip image files.
if not final_version and not IMAGE_REGEX.search(file):
continue
scan_zip.write(os.path.join(self.dir_path, file), arcname=file)
return zip_fname
def get_files_in_zip(zip_path: str) -> list[str]:
"""List the relative paths of all files and folders in the zip folder specified."""
scan_zip = zipfile.ZipFile(zip_path)
return [os.path.normpath(i) for i in scan_zip.namelist()]