1266 lines
47 KiB
Python
1266 lines
47 KiB
Python
import shutil
|
|
import zipfile
|
|
import threading
|
|
from typing import Optional, Mapping
|
|
from fastapi import HTTPException
|
|
from fastapi.responses import FileResponse
|
|
import numpy as np
|
|
import os
|
|
import time
|
|
from PIL import Image
|
|
from pydantic import BaseModel
|
|
from datetime import datetime
|
|
from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, STDOUT
|
|
import glob
|
|
import json
|
|
import re
|
|
|
|
from labthings_fastapi.thing import Thing
|
|
from labthings_fastapi.dependencies.metadata import GetThingStates
|
|
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
|
|
from labthings_fastapi.dependencies.invocation import (
|
|
CancelHook,
|
|
InvocationLogger,
|
|
InvocationCancelledError,
|
|
)
|
|
from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint
|
|
from labthings_fastapi.outputs.blob import blob_type
|
|
from .camera import CameraDependency as CamDep
|
|
from .stage import StageDependency as StageDep
|
|
|
|
from openflexure_microscope_server.utilities import ErrorCapturingThread
|
|
from openflexure_microscope_server.things.autofocus import AutofocusThing
|
|
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
|
|
from openflexure_microscope_server.things.background_detect import BackgroundDetectThing
|
|
from openflexure_microscope_server import scan_planners
|
|
|
|
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/")
|
|
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
|
|
BackgroundDep = direct_thing_client_dependency(
|
|
BackgroundDetectThing, "/background_detect/"
|
|
)
|
|
|
|
IMAGE_REGEX = re.compile(r"-?[0-9]+_-?[0-9]+\.jpe?g$")
|
|
|
|
|
|
class NotEnoughFreeSpaceError(IOError):
|
|
pass
|
|
|
|
|
|
def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None:
|
|
"""
|
|
Raise an exception if we are running out of disk space
|
|
|
|
Args:
|
|
path = path to a location on the disk you want to check
|
|
min_space [int] = the minimum space required in bytes
|
|
default = 500,000,000 (500MiB)
|
|
|
|
Raises:
|
|
NotEnoughFreeSpaceError if the remaining storage is below min_space
|
|
"""
|
|
du = shutil.disk_usage(path)
|
|
if du.free < min_space:
|
|
raise NotEnoughFreeSpaceError(
|
|
"There is not enough free disk space to continue. "
|
|
f"(Required: {min_space}, {du})."
|
|
)
|
|
|
|
|
|
class ScanInfo(BaseModel):
|
|
"""Summary information about a scan folder"""
|
|
|
|
"""Summary information about a scan folder"""
|
|
|
|
name: str
|
|
created: datetime
|
|
modified: datetime
|
|
number_of_images: int
|
|
stitch_available: bool
|
|
dzi: Optional[str]
|
|
|
|
|
|
DOWNLOADABLE_SCAN_FILES = (
|
|
"images.zip",
|
|
"stitched_thumbnail.jpg",
|
|
)
|
|
|
|
JPEGBlob = blob_type("image/jpeg")
|
|
ZipBlob = blob_type("application/zip")
|
|
IMG_DIR_NAME = "images"
|
|
SCAN_DATA_FILENAME = "scan_data.json"
|
|
STITCHING_CMD = "openflexure-stitch"
|
|
|
|
SCAN_ZERO_PAD_DIGITS = 4
|
|
|
|
|
|
def _scan_running(method):
|
|
"""
|
|
This decorator is used by all methods in SmartScanThing that are using
|
|
the variables set for the scan. It will throw a runtime error if
|
|
self._scan_logger is not set, as all scan variables are set at
|
|
the same time and released with the lock
|
|
"""
|
|
|
|
def scan_running_wrapper(self, *args, **kwargs):
|
|
# Only start the method is the scan logger is set
|
|
if self._scan_logger is not None:
|
|
return method(self, *args, **kwargs)
|
|
raise RuntimeError(
|
|
"Calling a @scan_running method can only be done while a scan is running!"
|
|
)
|
|
|
|
return scan_running_wrapper
|
|
|
|
|
|
class SmartScanThing(Thing):
|
|
def __init__(self, scans_folder):
|
|
self._scans_folder = scans_folder
|
|
self._preview_stitch_popen = None
|
|
self._preview_stitch_popen_lock = threading.Lock()
|
|
self._scan_lock = threading.Lock()
|
|
|
|
# Variables set by the scan
|
|
self._latest_scan_name: Optional[str] = None
|
|
|
|
# Scan logger is the invocation logger labthings-fastapi creates
|
|
# when the `sample_scan` thing_action is called. It is saved as
|
|
# private class variable along with many others here.
|
|
# Access to these variables requires a scan to be running,
|
|
# any method that calls these should be decorrected with
|
|
# @_scan_running
|
|
self._scan_logger: Optional[InvocationLogger] = None
|
|
self._cancel: Optional[CancelHook] = None
|
|
self._autofocus: Optional[AutofocusDep] = None
|
|
self._stage: Optional[StageDep] = None
|
|
self._cam: Optional[CamDep] = None
|
|
self._metadata_getter: Optional[GetThingStates] = None
|
|
self._csm: Optional[CSMDep] = None
|
|
self._background_detect: Optional[BackgroundDep] = None
|
|
self._ongoing_scan_name: Optional[str] = None
|
|
self._starting_position: Optional[Mapping[str, int]] = None
|
|
self._capture_thread: Optional[ErrorCapturingThread] = None
|
|
self._scan_images_taken: Optional[int] = None
|
|
# TODO Scan data is a dict during refactoring, should become a dataclass
|
|
self._scan_data: Optional[dict] = None
|
|
self._stitch_resize: Optional[float] = None
|
|
|
|
@thing_action
|
|
def sample_scan(
|
|
self,
|
|
cancel: CancelHook,
|
|
logger: InvocationLogger,
|
|
autofocus: AutofocusDep,
|
|
stage: StageDep,
|
|
cam: CamDep,
|
|
metadata_getter: GetThingStates,
|
|
csm: CSMDep,
|
|
background_detect: BackgroundDep,
|
|
scan_name: str = "",
|
|
):
|
|
"""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) or reaches the "max_range" measured in steps.
|
|
"""
|
|
|
|
got_lock = self._scan_lock.acquire(timeout=0.1)
|
|
if not got_lock:
|
|
raise RuntimeError("Trying to run scan while scan is already running!")
|
|
|
|
# Set private variables for this scan
|
|
self._cancel = cancel
|
|
self._scan_logger = logger
|
|
self._autofocus = autofocus
|
|
self._stage = stage
|
|
self._cam = cam
|
|
self._metadata_getter = metadata_getter
|
|
self._csm = csm
|
|
self._background_detect = background_detect
|
|
self._capture_thread = None
|
|
self._scan_images_taken = 0
|
|
self._stitch_resize = 1
|
|
|
|
self._cam.start_highres_streaming()
|
|
|
|
# Don't set self._scan_data dictionary. This is done at the start of _run_scan
|
|
|
|
try:
|
|
self._check_background_and_csm_set()
|
|
self._ongoing_scan_name = self._get_unique_scan_name_and_dir(scan_name)
|
|
self.create_zip_of_scan(scan_name=self._ongoing_scan_name)
|
|
self._autofocus.looping_autofocus(dz=self.autofocus_dz, start="centre")
|
|
# record starting position so we can return there
|
|
self._starting_position = self._stage.position
|
|
self._run_scan()
|
|
except Exception as e:
|
|
# If _scan_data is set then scan started
|
|
if self._scan_data is not None:
|
|
self._return_to_starting_position()
|
|
if not isinstance(e, NotEnoughFreeSpaceError):
|
|
# Don't stich if drive is full (already logged)
|
|
self._perform_final_stitch()
|
|
# Error must be raised so UI gives correct output
|
|
raise e
|
|
finally:
|
|
self._cam.start_streaming()
|
|
# However the scan finishes, unset all variables and release lock
|
|
self._cancel = None
|
|
self._scan_logger = None
|
|
self._autofocus = None
|
|
self._stage = None
|
|
self._cam = None
|
|
self._metadata_getter = None
|
|
self._csm = None
|
|
self._background_detect = None
|
|
self._capture_thread = None
|
|
self._ongoing_scan_name = None
|
|
self._scan_images_taken = None
|
|
self._scan_data = None
|
|
self._scan_lock.release()
|
|
self._stitch_resize = None
|
|
|
|
def promote_stitch_files(
|
|
self,
|
|
logger,
|
|
):
|
|
"""Copy the stitched image from the scan images folder to the top level"""
|
|
|
|
# Search the scan images dir for a file ending in '_stitched.jpg
|
|
stitched_image_path = glob.glob(
|
|
os.path.join(self._ongoing_scan_images_dir, "*_stitched.jpg")
|
|
)
|
|
|
|
if len(stitched_image_path) == 0:
|
|
logger.warning("Could't find a stitched image to copy")
|
|
else:
|
|
for stitched_image in stitched_image_path:
|
|
stitch_name = os.path.basename(stitched_image)
|
|
|
|
shutil.copy(
|
|
stitched_image,
|
|
os.path.join(
|
|
self.dir_for_scan(self._ongoing_scan_name), stitch_name
|
|
),
|
|
)
|
|
|
|
@_scan_running
|
|
def _check_background_and_csm_set(self):
|
|
"""Before starting a scan, check that background and camera-stage-mapping are set
|
|
|
|
Raise error if:
|
|
- background is to be skipped but is not set
|
|
- camera stage mapping is not set
|
|
|
|
Raise warning if not using background detect that scan will go on until max steps reached
|
|
"""
|
|
if self._csm.image_resolution is None:
|
|
raise RuntimeError(
|
|
"Camera-stage mapping is not calibrated. This is required before "
|
|
"scans can be carried out."
|
|
)
|
|
|
|
if self.skip_background:
|
|
if not self._background_detect.background_distributions:
|
|
raise RuntimeError(
|
|
"Background is not set: you need to calibrate background detection."
|
|
)
|
|
else:
|
|
self._scan_logger.warning(
|
|
"This scan will run in a spiral from the starting point "
|
|
f"until you cancel it, or until it has moved by {self.max_range} steps "
|
|
"in every direction. Make sure you watch it run to stop it leaving "
|
|
"the area of interest, or (worse) leading the microscope's range "
|
|
"of motion."
|
|
)
|
|
|
|
@property
|
|
def base_scan_dir(self) -> str:
|
|
"""The path of the directory where the scans are saved."""
|
|
return self._scans_folder
|
|
|
|
@thing_property
|
|
def latest_scan_name(self) -> Optional[str]:
|
|
"""The name of the last scan to be started."""
|
|
return self._latest_scan_name
|
|
|
|
def dir_for_scan(self, scan_name: str):
|
|
"""The path to the scan directory for scan with input name"""
|
|
return os.path.join(self.base_scan_dir, scan_name)
|
|
|
|
def images_dir_for_scan(self, scan_name: str) -> str:
|
|
"""The path to the images directory for scan with input name"""
|
|
scan_dir = self.dir_for_scan(scan_name=scan_name)
|
|
return os.path.join(scan_dir, IMG_DIR_NAME)
|
|
|
|
@property
|
|
@_scan_running
|
|
def _ongoing_scan_dir(self):
|
|
"""For the ongoing scan, this returns the scan directory"""
|
|
if not self._ongoing_scan_name:
|
|
raise RuntimeError("Cannot get ongoing scan name while no scan is running")
|
|
return self.dir_for_scan(self._ongoing_scan_name)
|
|
|
|
@property
|
|
@_scan_running
|
|
def _ongoing_scan_images_dir(self):
|
|
"""For the ongoing scan, this returns the image directory"""
|
|
if not self._ongoing_scan_name:
|
|
raise RuntimeError("Cannot get ongoing scan name while no scan is running")
|
|
return self.images_dir_for_scan(self._ongoing_scan_name)
|
|
|
|
@_scan_running
|
|
def _get_unique_scan_name_and_dir(self, scan_name: str) -> str:
|
|
"""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 4 digits long (to allow correct sorting if the
|
|
scans are ordered alphanumerically).
|
|
|
|
Note that if you have discontinuous numbering (e.g. you've got scans
|
|
numbered 1 through 10, but you deleted scan 5), then the gaps will
|
|
get filled in - so there's no guarantee, for now, that the numbers
|
|
will correspond to order of creation. This may change in the future.
|
|
|
|
Creates a new empty folder, into which scans are saved
|
|
|
|
Returns the scan name.
|
|
|
|
The directory can be accessed by self.dir_for_scan(unique_scan_name)
|
|
|
|
"""
|
|
if not os.path.exists(self.base_scan_dir):
|
|
os.makedirs(self.base_scan_dir)
|
|
|
|
# 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"
|
|
|
|
# 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
|
|
|
|
for j in range(max_scan_no):
|
|
trial_unique_scan_name = f"{scan_name}_{j:0{SCAN_ZERO_PAD_DIGITS}d}"
|
|
trial_dir = self.dir_for_scan(trial_unique_scan_name)
|
|
if not os.path.exists(trial_dir):
|
|
os.makedirs(trial_dir)
|
|
# If we made the directory this is the scan name
|
|
# Save the scan name as the latest scan (this persists as a
|
|
# property after the scan finishes)
|
|
self._latest_scan_name = trial_unique_scan_name
|
|
# Create images directory and
|
|
os.mkdir(self.images_dir_for_scan(trial_unique_scan_name))
|
|
# Return the scan name
|
|
return trial_unique_scan_name
|
|
raise FileExistsError("Could not create a new scan folder: all names in use!")
|
|
|
|
@_scan_running
|
|
def _move_to_next_point(
|
|
self, next_point: tuple[int, int], z_estimate: Optional[int] = None
|
|
) -> tuple[int, int, int]:
|
|
"""Moves the stage to the next poistion. If no z_estimate is given then
|
|
the current stage position is used. Must move to the estimated focused position
|
|
(although moving below would be marginally faster) because background detect is
|
|
most reliable at the focused position.
|
|
|
|
Returns the (x,y,z) with the chosen z_estimate
|
|
"""
|
|
|
|
if z_estimate is None:
|
|
z_estimate = self._stage.position["z"]
|
|
|
|
self._scan_logger.info(f"Moving to {next_point}")
|
|
self._stage.move_absolute(
|
|
x=next_point[0],
|
|
y=next_point[1],
|
|
z=z_estimate,
|
|
)
|
|
|
|
return (next_point[0], next_point[1], z_estimate)
|
|
|
|
# @_scan_running
|
|
# def _calc_resize_from_test_image(self):
|
|
# """
|
|
# Take a test image to set the amount to downsample images for stitching
|
|
|
|
# Return the decimal value to scale x and y by when stitching
|
|
# """
|
|
|
|
# #TODO: This needs to match how the capture in the z stack is done
|
|
# test_jpg = self._cam.capture_jpeg(resolution="full")
|
|
# test_jpg = test_jpg.content
|
|
|
|
# test_image = Image.open(io.BytesIO(test_jpg))
|
|
# test_image_res = list(test_image.size)
|
|
# return STITCH_IMAGE_WIDTH / test_image_res[0]
|
|
|
|
@_scan_running
|
|
def _calc_displacement_from_test_image(self, overlap):
|
|
"""
|
|
Take a test image and use camera stage mapping to calculate x and y displacement
|
|
|
|
Return (dx, dy) - the x and y displacments in steps
|
|
"""
|
|
test_jpg = self._cam.grab_jpeg()
|
|
test_image = np.array(Image.open(test_jpg.open()))
|
|
|
|
test_image_res = list(test_image.shape)
|
|
csm_image_res = [int(i) for i in self._csm.image_resolution]
|
|
|
|
|
|
# If current stream width is different to csm calibration width,
|
|
# perform the conversion here
|
|
res_ratio = csm_image_res[0] / test_image_res[0]
|
|
|
|
# get displacement matrix. note it is for (y, x) not (x, y) coordinates
|
|
csm_disp_matrix = np.array(self._csm.image_to_stage_displacement_matrix)
|
|
csm_disp_matrix *= res_ratio
|
|
|
|
# Calculate displacements in image coordinates
|
|
dx_img = test_image.shape[1] * (1 - overlap)
|
|
dy_img = test_image.shape[0] * (1 - overlap)
|
|
|
|
# Calculate displacements in steps as vectors using a dot product with the matrix
|
|
dx_vec = np.dot(np.array([0, dx_img]), csm_disp_matrix)
|
|
dy_vec = np.dot(np.array([dy_img, 0]), csm_disp_matrix)
|
|
|
|
# Assume no rotation or skew and take only the aligned axis of vector.
|
|
# Coerce to positive integer
|
|
dx = int(np.abs(dx_vec[0]))
|
|
dy = int(np.abs(dy_vec[1]))
|
|
|
|
return dx, dy
|
|
|
|
@_scan_running
|
|
def _set_scan_data(self):
|
|
"""
|
|
This sets the self._scan_data dictionary. This needs to become a
|
|
dataclass.
|
|
"""
|
|
overlap = self.overlap
|
|
dx, dy = self._calc_displacement_from_test_image(overlap)
|
|
# self._stitch_resize = self._calc_resize_from_test_image()
|
|
self._stitch_resize = 0.25
|
|
|
|
self._scan_logger.info(
|
|
f'Resizing images by {self._stitch_resize}'
|
|
)
|
|
|
|
self._scan_logger.info(
|
|
f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}"
|
|
)
|
|
|
|
autofocus_dz = self.autofocus_dz
|
|
if autofocus_dz == 0:
|
|
self._scan_logger.info("Running scan without autofocus")
|
|
elif autofocus_dz <= 200:
|
|
self._scan_logger.warning(
|
|
f"Your autofocus range is {autofocus_dz} steps, which is too short to "
|
|
"attempt to focus. Running without autofocus"
|
|
)
|
|
autofocus_dz = 0
|
|
|
|
# Fix scan parameters in case UI is updated during scan.
|
|
self._scan_data = {
|
|
"scan_name": self._ongoing_scan_name,
|
|
"overlap": overlap,
|
|
"max_dist": self.max_range,
|
|
"dx": dx,
|
|
"dy": dy,
|
|
"autofocus_dz": autofocus_dz,
|
|
"autofocus_on": bool(autofocus_dz),
|
|
"start_time": time.strftime("%H_%M_%S-%d_%m_%Y"),
|
|
"skip_background": self.skip_background,
|
|
"stitch_automatically": self.stitch_automatically,
|
|
}
|
|
|
|
@_scan_running
|
|
def _save_scan_inputs_json(self):
|
|
"""
|
|
Save scan inputs as a JSON file in the scan folder, to allow
|
|
the user to review the settings used in the scan
|
|
"""
|
|
# Should this be a method of the scan_data dataclass?
|
|
|
|
data = {
|
|
"scan_name": self._ongoing_scan_name,
|
|
"overlap": self._scan_data["overlap"],
|
|
"autofocus range": self._scan_data["autofocus_dz"],
|
|
"dx": self._scan_data["dx"],
|
|
"dy": self._scan_data["dy"],
|
|
"start time": self._scan_data["start_time"],
|
|
"skipping background": self._scan_data["skip_background"],
|
|
}
|
|
|
|
scan_inputs_fname = os.path.join(
|
|
self._ongoing_scan_images_dir, SCAN_DATA_FILENAME
|
|
)
|
|
with open(scan_inputs_fname, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=4)
|
|
|
|
@_scan_running
|
|
def _update_scan_data_json(self, scan_result: str):
|
|
"""
|
|
Update scan data as a JSON file in the scan folder, with
|
|
data only known at the end of the scan.
|
|
|
|
Takes scan_result, a string that is either "success",
|
|
"cancelled by user", or the error that ended the scan.
|
|
"""
|
|
# Should this be a method of the scan_data dataclass?
|
|
current_time = datetime.now().replace(microsecond=0)
|
|
start_time = datetime.strptime(
|
|
self._scan_data["start_time"], "%H_%M_%S-%d_%m_%Y"
|
|
).replace(microsecond=0)
|
|
|
|
duration = current_time - start_time
|
|
|
|
outputs = {
|
|
"image_count": self._scan_images_taken,
|
|
"duration": str(duration),
|
|
"scan_result": scan_result,
|
|
}
|
|
|
|
scan_data_fname = os.path.join(
|
|
self._ongoing_scan_images_dir, SCAN_DATA_FILENAME
|
|
)
|
|
|
|
with open(scan_data_fname, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
data.update(outputs)
|
|
|
|
with open(scan_data_fname, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=4)
|
|
|
|
@_scan_running
|
|
def _manage_stitching_threads(self):
|
|
"""
|
|
Manage the stitching threads, starting them if needed and not already running.
|
|
"""
|
|
|
|
# Assume 4 images means at least one offset in x and y, making the stitching
|
|
# well constrained.
|
|
if self._scan_images_taken > 3:
|
|
if not self._preview_stitch_running():
|
|
self._preview_stitch_start(overlap=self._scan_data["overlap"])
|
|
|
|
@_scan_running
|
|
def _run_scan(self):
|
|
"""
|
|
Prepare and run the main scan, handling the threads performing
|
|
stitching. Uses the result (or exception) from the scanning to
|
|
determine whether the scan should be stitched and the microscope
|
|
should return to the starting x,y,z position
|
|
"""
|
|
|
|
# Used to check if finally was reached via exeption (except
|
|
# cancel by user)
|
|
scan_successful = True
|
|
|
|
try:
|
|
self._set_scan_data()
|
|
self._save_scan_inputs_json()
|
|
if self._scan_images_taken != 0:
|
|
msg = "_scan_images_taken should be zero before starting scanning"
|
|
raise RuntimeError(msg)
|
|
|
|
# This is the main loop of the scan!
|
|
self._main_scan_loop()
|
|
self._update_scan_data_json(scan_result="success")
|
|
|
|
except InvocationCancelledError:
|
|
scan_successful = False
|
|
self._scan_logger.info("Stopping scan because it was cancelled.")
|
|
self._update_scan_data_json(scan_result="cancelled by user")
|
|
except NotEnoughFreeSpaceError as e:
|
|
scan_successful = False
|
|
self._update_scan_data_json(scan_result=str(e))
|
|
self._scan_logger.error(
|
|
f"Stopping scan to avoid filling up the disk: {e}",
|
|
exc_info=e,
|
|
)
|
|
raise e
|
|
except Exception as e:
|
|
scan_successful = False
|
|
self._scan_logger.error(
|
|
f"The scan stopped because of an error: {e} "
|
|
"Attempting to stitch and archive the images acquired so far.",
|
|
exc_info=e,
|
|
)
|
|
raise e
|
|
finally:
|
|
if self._capture_thread:
|
|
# If the capture thread had an error, we capture it here
|
|
try:
|
|
self._capture_thread.join()
|
|
except Exception as e:
|
|
# If the scan has already ended due to an exception,
|
|
# ignore any exceptions. If it appeared to be successful,
|
|
# log the error.
|
|
if scan_successful:
|
|
self._scan_logger.error(
|
|
"The scan appears to have started successfully, however "
|
|
f"the final capture raised the following error: {e}."
|
|
"Attempting to stitch and archive images.",
|
|
exc_info=e,
|
|
)
|
|
|
|
# This is what happens if the scan completes successfully or the
|
|
# user cancels it.
|
|
self._return_to_starting_position()
|
|
self._perform_final_stitch()
|
|
|
|
# Remove any scan folders containing zero images
|
|
self.purge_empty_scans(logger=self._scan_logger)
|
|
|
|
@_scan_running
|
|
def _main_scan_loop(self):
|
|
"""
|
|
The loop to run through during a scan, until no more scan x,y positions
|
|
are remaining.
|
|
"""
|
|
|
|
# The initial plan for the scan should be a single x,y position. All future
|
|
# moves will be planned around this point. In future, route planner could
|
|
# have multiple starting positions, each of which will be visited before the
|
|
# scan can end.
|
|
planner_settings = {
|
|
"dx": self._scan_data["dx"],
|
|
"dy": self._scan_data["dy"],
|
|
"max_dist": self._scan_data["max_dist"],
|
|
}
|
|
route_planner = scan_planners.SmartSpiral(
|
|
intial_position=(self._stage.position["x"], self._stage.position["y"]),
|
|
planner_settings=planner_settings,
|
|
)
|
|
|
|
# The loop tests if the scan should continue, moves to the next position,
|
|
# decides whether to capture an image, autofocuses if necessary,
|
|
# captures an image if necessary, updates the scan path and future path,
|
|
# and updates the zip file with new images.
|
|
while not route_planner.scan_complete:
|
|
ensure_free_disk_space(self._ongoing_scan_dir)
|
|
self._manage_stitching_threads()
|
|
|
|
next_pos_xy, z_est = route_planner.get_next_location_and_z_estimate()
|
|
new_pos_xyz = self._move_to_next_point(next_pos_xy, z_est)
|
|
current_pos_xyz = (
|
|
new_pos_xyz[0],
|
|
new_pos_xyz[1],
|
|
self._stage.position["z"],
|
|
)
|
|
|
|
capture_image = True
|
|
# If skipping background, take an image to check if current field of view is background
|
|
if self._scan_data["skip_background"]:
|
|
capture_image = self._background_detect.image_is_sample()
|
|
|
|
if not capture_image:
|
|
route_planner.mark_location_visited(
|
|
new_pos_xyz, imaged=False, focused=False
|
|
)
|
|
# Background fraction is actually a percentage
|
|
back_perc = round(self._background_detect.background_fraction(), 0)
|
|
msg = f"Skipping {new_pos_xyz} as it is {back_perc}% background."
|
|
self._scan_logger.info(msg)
|
|
continue
|
|
|
|
focused = False
|
|
if self._scan_data["autofocus_on"]:
|
|
self._autofocus.looping_autofocus(
|
|
dz=self._scan_data["autofocus_dz"], start="centre"
|
|
)
|
|
current_pos_xyz = (
|
|
new_pos_xyz[0],
|
|
new_pos_xyz[1],
|
|
self._stage.position["z"],
|
|
)
|
|
# Without a test for autofocus success, assume it worked
|
|
focused = True
|
|
|
|
route_planner.mark_location_visited(
|
|
current_pos_xyz, imaged=True, focused=focused
|
|
)
|
|
|
|
site_folder = os.path.join(
|
|
self._ongoing_scan_images_dir,
|
|
"stacks",
|
|
f"{new_pos_xyz[0]}_{new_pos_xyz[1]}",
|
|
)
|
|
os.makedirs(site_folder, exist_ok=True)
|
|
self._autofocus.run_z_stack(
|
|
images_dir=self._ongoing_scan_images_dir,
|
|
stack_dir=site_folder,
|
|
capture_method="blob",
|
|
)
|
|
|
|
# increment capure counter as thread has completed
|
|
self._scan_images_taken += 1
|
|
# Add it to the incremental zip
|
|
self.update_zip(
|
|
scan_name=self._ongoing_scan_name,
|
|
)
|
|
|
|
@_scan_running
|
|
def _return_to_starting_position(self):
|
|
"""Return to the initial scan position, if set"""
|
|
self._scan_logger.info("Returning to starting position.")
|
|
if self._starting_position is not None:
|
|
self._stage.move_absolute(
|
|
**self._starting_position, block_cancellation=True
|
|
)
|
|
|
|
@_scan_running
|
|
def _perform_final_stitch(self):
|
|
"""Update the scan zip and perform final stitch of the data"""
|
|
|
|
if self._scan_images_taken <= 3:
|
|
self._scan_logger.info("Not performing a stitch as 3 or fewer images taken")
|
|
return
|
|
|
|
self.update_zip(
|
|
scan_name=self._ongoing_scan_name,
|
|
final_version=False,
|
|
)
|
|
self._scan_logger.info("Waiting for background processes to finish...")
|
|
|
|
self._preview_stitch_wait()
|
|
try:
|
|
if self._scan_data["stitch_automatically"]:
|
|
self._scan_logger.info("Stitching final image (may take some time)...")
|
|
self.stitch_scan(
|
|
logger=self._scan_logger,
|
|
scan_name=self._ongoing_scan_name,
|
|
overlap=self._scan_data["overlap"],
|
|
)
|
|
self.promote_stitch_files(self._scan_logger)
|
|
except SubprocessError as e:
|
|
self._scan_logger.error(f"Stitching failed: {e}", exc_info=e)
|
|
|
|
@fastapi_endpoint(
|
|
"get",
|
|
"scans/stitched_thumbnail.jpg",
|
|
responses={
|
|
200: {
|
|
"description": "A thumbnail-quality stitched image",
|
|
"content": {"image/jpeg": {}},
|
|
},
|
|
404: {"description": "File not found"},
|
|
},
|
|
)
|
|
def get_scan_thumbnail(self, scan_name: str) -> FileResponse:
|
|
"""Retrieve a file from a scan.
|
|
|
|
This endpoint allows files to be downloaded from a scan.
|
|
"""
|
|
path = os.path.join(
|
|
self.images_dir_for_scan(scan_name), "stitched_thumbnail.jpg"
|
|
)
|
|
if not os.path.isfile(path):
|
|
raise HTTPException(404, "File not found")
|
|
return FileResponse(path)
|
|
|
|
@thing_property
|
|
def max_range(self) -> int:
|
|
"""The maximum distance from the centre of the scan before we break in steps"""
|
|
return self.thing_settings.get("max_range", 45000)
|
|
|
|
@max_range.setter
|
|
def max_range(self, value: int) -> None:
|
|
self.thing_settings["max_range"] = value
|
|
|
|
@thing_property
|
|
def stitch_tiff(self) -> bool:
|
|
"""Whether or not to also produce a pyramidal tiff"""
|
|
return self.thing_settings.get("stitch_tiff", False)
|
|
|
|
@stitch_tiff.setter
|
|
def stitch_tiff(self, value: bool) -> None:
|
|
self.thing_settings["stitch_tiff"] = value
|
|
|
|
@thing_property
|
|
def skip_background(self) -> bool:
|
|
"""Whether to detect and skip empty fields of view
|
|
|
|
This uses the settings from the `background_detect` Thing.
|
|
"""
|
|
return self.thing_settings.get("skip_background", True)
|
|
|
|
@skip_background.setter
|
|
def skip_background(self, value: bool) -> None:
|
|
self.thing_settings["skip_background"] = value
|
|
|
|
@thing_property
|
|
def autofocus_dz(self) -> int:
|
|
"""The z distance to perform an autofocus in steps"""
|
|
return self.thing_settings.get("autofocus_dz", 1000)
|
|
|
|
@autofocus_dz.setter
|
|
def autofocus_dz(self, value: int) -> None:
|
|
self.thing_settings["autofocus_dz"] = value
|
|
|
|
@thing_property
|
|
def overlap(self) -> float:
|
|
"""The fraction (0-1) that adjacent images should overlap in x or y"""
|
|
return self.thing_settings.get("overlap", 0.45)
|
|
|
|
@overlap.setter
|
|
def overlap(self, value: float) -> None:
|
|
self.thing_settings["overlap"] = value
|
|
|
|
@thing_property
|
|
def stitch_automatically(self) -> bool:
|
|
"""Whether to run a final stitch at the end of the scan (assuming scan success)"""
|
|
return self.thing_settings.get("stitch_automatically", True)
|
|
|
|
@stitch_automatically.setter
|
|
def stitch_automatically(self, value: bool) -> None:
|
|
self.thing_settings["stitch_automatically"] = value
|
|
|
|
@thing_property
|
|
def scans(self) -> list[ScanInfo]:
|
|
"""All the available scans
|
|
|
|
Each scan has a name (which can be used to access it), along with
|
|
its modified and created times (according to the filesystem) and
|
|
the number of items in the `images` folder. Note that image count
|
|
uses a regular expression, and changes to the naming scheme will
|
|
break it.
|
|
"""
|
|
scans: list[ScanInfo] = []
|
|
if not os.path.isdir(self.base_scan_dir):
|
|
return scans
|
|
for f in os.listdir(self.base_scan_dir):
|
|
path = os.path.join(self.base_scan_dir, f)
|
|
if os.path.isdir(path):
|
|
images_folder = os.path.join(path, IMG_DIR_NAME)
|
|
if os.path.isdir(images_folder):
|
|
folder_contents = os.listdir(images_folder)
|
|
scan_images = [i for i in folder_contents if IMAGE_REGEX.search(i)]
|
|
stitches = [
|
|
i for i in folder_contents if i.endswith("_stitched.jpg")
|
|
]
|
|
number_of_images = len(scan_images)
|
|
stitch_available = len(stitches) > 0
|
|
dzi = [i for i in folder_contents if i.endswith("dzi")]
|
|
if len(dzi) > 0:
|
|
dzi = str(dzi[0])
|
|
else:
|
|
dzi = None
|
|
else:
|
|
number_of_images = 0
|
|
stitch_available = False
|
|
modified = max(os.stat(root).st_mtime for root, _, _ in os.walk(path))
|
|
|
|
scans.append(
|
|
ScanInfo(
|
|
name=f,
|
|
created=os.path.getctime(path),
|
|
modified=modified,
|
|
number_of_images=number_of_images,
|
|
stitch_available=stitch_available,
|
|
dzi=dzi,
|
|
)
|
|
)
|
|
return scans
|
|
|
|
@fastapi_endpoint(
|
|
"get",
|
|
"scans/{scan_name}/{file}",
|
|
responses={
|
|
200: {
|
|
"description": "Successfully downloading file",
|
|
"content": {"*/*": {}},
|
|
},
|
|
403: {"description": "Filename not permitted"},
|
|
404: {"description": "File not found"},
|
|
},
|
|
)
|
|
def get_scan_file(self, scan_name: str, file: str) -> FileResponse:
|
|
"""Retrieve a file from a scan.
|
|
|
|
This endpoint allows files to be downloaded from a scan. For security
|
|
reasons, there is a list of allowable filenames, and paths with additional
|
|
slashes are not permitted.
|
|
"""
|
|
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.base_scan_dir, scan_name, file)
|
|
if not os.path.isfile(path):
|
|
raise HTTPException(404, "File not found")
|
|
return FileResponse(path)
|
|
|
|
@fastapi_endpoint(
|
|
"delete",
|
|
"scans/{scan_name}",
|
|
responses={
|
|
200: {"description": "Successfully deleted scan"},
|
|
404: {"description": "Scan not found"},
|
|
},
|
|
)
|
|
def delete_scan(self, scan_name: str, logger: InvocationLogger) -> None:
|
|
"""Delete the folder for the specified scan.
|
|
|
|
This endpoint allows scans to be deleted from disk.
|
|
|
|
Takes the scan name to delete, and the Invocation Logger
|
|
"""
|
|
path = os.path.join(self.base_scan_dir, scan_name)
|
|
if not os.path.isdir(path):
|
|
logger.info(f"can't find {path}")
|
|
raise HTTPException(400, "Scan not found")
|
|
deleted_scan_success = self._delete_scan(path, logger)
|
|
if not deleted_scan_success:
|
|
raise HTTPException(400, "Couldn't delete scan, check log for details")
|
|
|
|
@fastapi_endpoint(
|
|
"delete",
|
|
"scans",
|
|
)
|
|
def delete_all_scans(self, logger: InvocationLogger) -> None:
|
|
"""Delete all the scans on the microscope
|
|
|
|
**This will irreversibly remove all scanned data from the
|
|
microscope!**
|
|
Use with extreme caution.
|
|
"""
|
|
for scan in self.scans:
|
|
path = os.path.join(self.base_scan_dir, scan.name)
|
|
self._delete_scan(path, logger)
|
|
|
|
@thing_action
|
|
def _delete_scan(self, scan_path, logger: InvocationLogger) -> bool:
|
|
try:
|
|
shutil.rmtree(scan_path)
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Attempted to delete scan " + scan_path + ", which failed."
|
|
" Server sent response" + str(e)
|
|
)
|
|
return False
|
|
|
|
@property
|
|
def latest_preview_stitch_path(self):
|
|
"""Return the path of the latest preview stitched image
|
|
|
|
If the image cannot be found (because there is no latest
|
|
scan, or because the latest scan has no preview stitch) then
|
|
raise FileNotFoundError
|
|
"""
|
|
if not self.latest_scan_name:
|
|
raise FileNotFoundError("No latest scan found")
|
|
|
|
images_dir = self.images_dir_for_scan(self.latest_scan_name)
|
|
stitch_path = os.path.join(images_dir, "preview.jpg")
|
|
if not os.path.isfile(stitch_path):
|
|
raise FileNotFoundError("Latest scan has no preview stitch")
|
|
return stitch_path
|
|
|
|
@thing_property
|
|
def latest_preview_stitch_time(self) -> Optional[datetime]:
|
|
"""The modification time of the latest preview image
|
|
|
|
This will return `null` if there is no preview image to return.
|
|
"""
|
|
try:
|
|
return os.path.getmtime(self.latest_preview_stitch_path)
|
|
except FileNotFoundError:
|
|
return None
|
|
|
|
@fastapi_endpoint(
|
|
"get",
|
|
"latest_preview_stitch.jpg",
|
|
responses={
|
|
200: {
|
|
"description": "A preview-quality stitched image",
|
|
"content": {"image/jpeg": {}},
|
|
},
|
|
404: {"description": "File not found"},
|
|
},
|
|
)
|
|
def get_latest_preview(self) -> FileResponse:
|
|
"""Retrieve the latest preview image."""
|
|
try:
|
|
return FileResponse(self.latest_preview_stitch_path)
|
|
except FileNotFoundError:
|
|
raise HTTPException(404, "File not found")
|
|
|
|
@_scan_running
|
|
def _preview_stitch_start(self, overlap: float) -> None:
|
|
"""Start stitching a preview of the scan in a background subprocess
|
|
|
|
|
|
This uses popen and returns immediately
|
|
|
|
- self._preview_stitch_popen holds the popen for polling
|
|
- self._preview_stitch_popen_lock is a lock aquired while interacting
|
|
with Popen
|
|
"""
|
|
# Set minimum overlap to 90% of the scan overlap to catch only images directly adjacent,
|
|
# not images with overlapping corners.
|
|
min_overlap = round(overlap * 0.9, 2)
|
|
if self._preview_stitch_running():
|
|
raise RuntimeError("Only one subprocess is allowed at a time")
|
|
with self._preview_stitch_popen_lock:
|
|
self._preview_stitch_popen = Popen(
|
|
[
|
|
STITCHING_CMD,
|
|
"--stitching_mode",
|
|
"preview_stitch",
|
|
"--minimum_overlap",
|
|
f"{min_overlap}",
|
|
"--resize",
|
|
f"{self._stitch_resize}",
|
|
self._ongoing_scan_images_dir,
|
|
]
|
|
)
|
|
|
|
@_scan_running
|
|
def _preview_stitch_running(self) -> bool:
|
|
"""Whether there is a preview stitch running in a subprocess"""
|
|
with self._preview_stitch_popen_lock:
|
|
if self._preview_stitch_popen is None:
|
|
return False
|
|
if self._preview_stitch_popen.poll() is None:
|
|
return True
|
|
return False
|
|
|
|
@_scan_running
|
|
def _preview_stitch_wait(self):
|
|
"""
|
|
Wait for an ongoing preview stitch to return
|
|
"""
|
|
if self._preview_stitch_running():
|
|
with self._preview_stitch_popen_lock:
|
|
self._preview_stitch_popen.wait()
|
|
|
|
def run_subprocess(
|
|
self,
|
|
logger: InvocationLogger,
|
|
cmd: list[str],
|
|
) -> CompletedProcess:
|
|
"""
|
|
Run a subprocess and log any output
|
|
|
|
Raises:
|
|
ChildProcessError if exit code is not zero
|
|
"""
|
|
logger.info(f"Running command in subprocess: `{' '.join(cmd)}`")
|
|
|
|
def log_buffer(buffer):
|
|
"""A short internal function to read everything in the buffer to
|
|
the log"""
|
|
while line := buffer.readline():
|
|
logger.info(line)
|
|
|
|
# Run the command piping stdout into the process for reading and
|
|
# forwarding the stdrerr to stdout
|
|
process = Popen(
|
|
cmd, stdout=PIPE, stderr=STDOUT, bufsize=1, universal_newlines=True
|
|
)
|
|
# Stop opening pipe blocking writing to it
|
|
os.set_blocking(process.stdout.fileno(), False)
|
|
logger.info(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())))
|
|
|
|
# Poll returns None while running, will return the error code when finnished
|
|
while process.poll() is None:
|
|
log_buffer(process.stdout)
|
|
# Once buffer is clear sleep for 0.2s before trying again.
|
|
time.sleep(0.2)
|
|
|
|
# Print everything in the buffer when program finishes
|
|
log_buffer(process.stdout)
|
|
|
|
if process.poll() == 0:
|
|
logger.info("Stitching complete")
|
|
else:
|
|
raise ChildProcessError(f"Subprocess {cmd[0]} exited with an error.")
|
|
|
|
@thing_action
|
|
def stitch_scan(
|
|
self,
|
|
logger: InvocationLogger,
|
|
scan_name: str,
|
|
overlap: float = 0.0,
|
|
) -> None:
|
|
"""Generate a stitched image based on stage position metadata
|
|
|
|
Note that as this is a thing_action it needs the logger passed as
|
|
a variable if called from another thing action
|
|
"""
|
|
images_folder = self.images_dir_for_scan(scan_name=scan_name)
|
|
json_fpath = os.path.join(images_folder, SCAN_DATA_FILENAME)
|
|
|
|
if self.stitch_tiff:
|
|
tiff_arg = "--stitch_tiff"
|
|
else:
|
|
tiff_arg = "--no-stitch_tiff"
|
|
|
|
if overlap == 0.0:
|
|
try:
|
|
with open(json_fpath, "r", encoding="utf-8") as data_file:
|
|
data_loaded = json.load(data_file)
|
|
logger.info(data_loaded)
|
|
overlap = data_loaded["overlap"]
|
|
except (json.decoder.JSONDecodeError, FileNotFoundError, TypeError):
|
|
# As there is no schema or pydantic model this should handle
|
|
# the file not being there, it not being json in the file,
|
|
# or the imported data not being indexable
|
|
logger.warning(
|
|
f"Couldn't read scan data, is {SCAN_DATA_FILENAME} missing or corrupt? "
|
|
"Attempting stitch with overlap value of 0.1"
|
|
)
|
|
overlap = 0.1
|
|
except KeyError:
|
|
logger.warning(
|
|
"Value for overlap not found in scan data. "
|
|
"Attempting stitch with overlap value of 0.1"
|
|
)
|
|
overlap = 0.1
|
|
self.run_subprocess(
|
|
logger,
|
|
[
|
|
STITCHING_CMD,
|
|
"--stitching_mode",
|
|
"all",
|
|
f"{tiff_arg}",
|
|
"--stitch_dzi",
|
|
"--minimum_overlap",
|
|
f"{round(overlap * 0.9, 2)}",
|
|
"--resize",
|
|
f"{self._stitch_resize}",
|
|
images_folder,
|
|
],
|
|
)
|
|
|
|
@thing_action
|
|
def create_zip_of_scan(
|
|
self,
|
|
scan_name: str,
|
|
) -> None:
|
|
"""Generate an empty zip file for the current scan"""
|
|
images_folder = self.images_dir_for_scan(scan_name=scan_name)
|
|
scan_folder = self.dir_for_scan(scan_name=scan_name)
|
|
|
|
if not os.path.isdir(images_folder):
|
|
raise FileNotFoundError(
|
|
f"Tried to make a zip archive of {images_folder} but it does not exist."
|
|
)
|
|
|
|
zip_fname = os.path.join(scan_folder, "images.zip")
|
|
|
|
# Create an empty zip file
|
|
if not os.path.isfile(zip_fname):
|
|
with zipfile.ZipFile(zip_fname, mode="w"):
|
|
pass
|
|
|
|
def update_zip(
|
|
self,
|
|
scan_name: str,
|
|
final_version: bool = False,
|
|
) -> None:
|
|
"""Update the zip file with any images added to the folder since the last run,
|
|
except for files containing 'files_to_delay', which are files to only include
|
|
once the scan is finished or the user wants to download the zip
|
|
"""
|
|
scan_folder = self.dir_for_scan(scan_name=scan_name)
|
|
|
|
zip_fname = os.path.join(scan_folder, "images.zip")
|
|
|
|
# get a list of files in the existing zip
|
|
current_zip = self.get_files_in_zip(zip_fname)
|
|
|
|
# get a list of files in the folder we're zipping
|
|
|
|
files = glob.glob(scan_folder + "/**/*", recursive=True)
|
|
files = [os.path.relpath(file, scan_folder) for file in files]
|
|
|
|
# This is a list of file names that are updated as the scan goes,
|
|
# and should only be zipped at the end of the scan - otherwise they'll
|
|
# be appended on every loop as we can't overwrite files in the zip
|
|
|
|
# More elegant ways would be to only zip the images matching the global regex,
|
|
# which will all be images, then append other files at the end.
|
|
# Alternatively, use "output_dir" in stitching to separate stitching image files
|
|
files_to_delay = [
|
|
"TileConfiguration",
|
|
"tiling_cache",
|
|
"stitched.jp",
|
|
"stitched_from",
|
|
"stitched.om",
|
|
"stitching_correlations",
|
|
"preview.jp",
|
|
]
|
|
|
|
with zipfile.ZipFile(zip_fname, mode="a") as scan_zip:
|
|
for file in files:
|
|
if any(skipped_name in file for skipped_name in files_to_delay):
|
|
pass
|
|
elif file in current_zip:
|
|
pass
|
|
elif ".zip" in file:
|
|
pass
|
|
else:
|
|
scan_zip.write(os.path.join(scan_folder, file), arcname=file)
|
|
|
|
# Finally, zip the files skipped previously
|
|
if final_version:
|
|
with zipfile.ZipFile(zip_fname, mode="a") as scan_zip:
|
|
for file in files:
|
|
if any(delayed_name in file for delayed_name in files_to_delay):
|
|
scan_zip.write(os.path.join(scan_folder, file), arcname=file)
|
|
|
|
@thing_action
|
|
def download_zip(
|
|
self,
|
|
scan_name: str,
|
|
):
|
|
"""Update the zip to include the files left until the end, then return the
|
|
zip file as a Blob"""
|
|
self.update_zip(
|
|
scan_name=scan_name,
|
|
final_version=True,
|
|
)
|
|
|
|
scan_folder = self.dir_for_scan(scan_name=scan_name)
|
|
|
|
zip_fname = os.path.join(scan_folder, "images.zip")
|
|
return ZipBlob.from_file(zip_fname)
|
|
|
|
def get_files_in_zip(self, zip_path):
|
|
"""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()]
|
|
|
|
@thing_action
|
|
def purge_empty_scans(self, logger: InvocationLogger) -> None:
|
|
"""
|
|
Delete all scan folders containing no images at the top level
|
|
"""
|
|
scan_list = self.scans
|
|
|
|
# Filter out scans with no top level files, ignoring JSON files
|
|
# JSON is ignored as it's created before any images are captured
|
|
for scan in scan_list:
|
|
scan_folder = os.path.join(self.base_scan_dir, scan.name, IMG_DIR_NAME)
|
|
images_found = False
|
|
# Check the scan directory exists, and if it does loop through each file
|
|
# to check if they are scan captures.
|
|
if os.path.isdir(scan_folder):
|
|
for fname in os.listdir(scan_folder):
|
|
fpath = os.path.join(scan_folder, fname)
|
|
if os.path.isfile(fpath) and IMAGE_REGEX.search(fname):
|
|
images_found = True
|
|
# break as soon as an image is found.
|
|
break
|
|
|
|
if not images_found:
|
|
path = os.path.join(self.base_scan_dir, scan.name)
|
|
self._delete_scan(path, logger)
|