Merge branch 'refactor-scanning' into 'v3'

Refactor scanning

See merge request openflexure/openflexure-microscope-server!240
This commit is contained in:
Julian Stirling 2025-04-10 22:05:40 +00:00
commit 5ab0a62964
4 changed files with 547 additions and 437 deletions

View file

@ -76,7 +76,7 @@ class OFMHandler(logging.Handler):
def append_record(self, record):
"""
Use the built in formatter to format the record, then save
it to an array. Pop any in excess of the mamimum number of logs
it to an array. Pop any in excess of the maximum number of logs
"""
self._log.append(self.format(record))
while len(self._log) > self._max_logs:

File diff suppressed because it is too large Load diff

View file

@ -1,117 +0,0 @@
from typing import Optional
import os
from subprocess import CompletedProcess, run, PIPE
from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency
from labthings_fastapi.dependencies.invocation import InvocationLogger
from labthings_fastapi.decorators import thing_action
from labthings_fastapi.outputs.blob import blob_type
from .smart_scan import SmartScanThing
SmartScanDep = raw_thing_dependency(SmartScanThing)
JPEGBlob = blob_type("image/jpeg")
class Stitcher(Thing):
def __init__(self, path_to_openflexure_stitch: str):
self._script = path_to_openflexure_stitch
def run_subprocess(
self,
logger: InvocationLogger,
cmd: list[str],
) -> CompletedProcess:
"""Run a subprocess and log any output"""
logger.info(f"Running command in subprocess: `{' '.join(cmd)}")
output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True)
for pipe in [output.stdout, output.stderr]:
if pipe:
logger.info(pipe)
output.check_returncode()
return output
def images_folder(
self, smart_scan: SmartScanDep, scan_name: Optional[str] = None
) -> str:
scan_folder = smart_scan.scan_folder_path(scan_name=scan_name)
return os.path.join(scan_folder, "images")
@staticmethod
def output_up_to_date(
folder: str,
output_filename: str,
image_prefix: str = "image",
image_suffix: str = ".jpg",
) -> bool:
"""Check if any of the images in a folder are newer than a file
If there are no images (files with the prefix and suffix) newer than the
`output_filename`, we return `True`, i.e. the output is up to date. If
any image in the folder is newer, we return `False`.
This is not flawless logic - if an update process is slow, images might be
saved between starting that process and saving the output. Consequently,
a `True` from this function does not guarantee the output is up to date.
If the output file is missing, we also return `False`.
"""
output_path = os.path.join(folder, output_filename)
if not os.path.exists(output_path):
return False
mtime = os.path.getmtime(output_path)
for fname in os.listdir(folder):
if fname.startswith(image_prefix) and fname.endswith(image_suffix):
if os.path.getmtime(os.path.join(folder, fname)) > mtime:
return False
return True
@thing_action
def stitch_scan_from_stage(
self,
logger: InvocationLogger,
smart_scan: SmartScanDep,
scan_name: Optional[str] = None,
downsample: float = 1.0,
) -> JPEGBlob:
"""Generate a stitched image based on stage position metadata"""
output_fname = "stitched_from_stage.jpg"
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
if self.output_up_to_date(images_folder, output_fname):
logger.info(f"No images are newer than {output_fname}, skipping.")
else:
self.run_subprocess(
logger,
[self._script, "--stitching_mode", "only_stage_stitch", images_folder],
)
return JPEGBlob.from_file(os.path.join(images_folder, output_fname))
@thing_action
def update_scan_correlations(
self,
logger: InvocationLogger,
smart_scan: SmartScanDep,
scan_name: Optional[str] = None,
) -> None:
"""Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
self.run_subprocess(
logger, [self._script, "--stitching_mode", "only_correlate", images_folder]
)
@thing_action
def stitch_scan(
self,
logger: InvocationLogger,
smart_scan: SmartScanDep,
scan_name: Optional[str] = None,
downsample: float = 1.0,
) -> None:
"""Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
self.run_subprocess(
logger, [self._script, "--stitching_mode", "all", images_folder]
)

View file

@ -0,0 +1,81 @@
"""
This module contains some utility functions and classes
"""
from threading import Thread
class ErrorCapturingThread(Thread):
"""
This is a a subclass or Thread. It wraps the target function in a
try-except block.
Execution will stop with an unhandled exception, but the exception will not be raised
until the join method is called. When the join method is called, the exception is
called in the calling thread.
This allows for error handling to be performed by the calling thread at the time that
join is run.
"""
def __init__(self, group=None, target=None, args=None, kwargs=None, daemon=None):
"""
Initialise with the same arguments as Thread
"""
# As all inputs are keywords we need to set the default values for args and kwargs:
if args is None:
args = ()
if kwargs is None:
kwargs = {}
# Make an empty list for any error.
# We are only ever going to have one or zero errors, this is an empty
# list as we need to pass the reference not the value to collect the
# error. If we could simply make a pointer we would have done that.
self._error_buffer = []
# Add the target function end error buffer to the thread to the start of
# the argument list so they are passed to _wrap_and_catch_errors()
args = (target, self._error_buffer, *args)
# Start the thread with _wrap_and_catch_errors() as the target
super().__init__(
group=group,
target=_wrap_and_catch_errors,
args=args,
kwargs=kwargs,
daemon=daemon,
)
def join(self, timeout=None):
"""
Join when the thread is complete. If the thread ended due to an unhandled exception,
the exception will be raised when this method is called.
"""
super().join(timeout)
# If there is an error in the error buffer clear the buffer and raise it
if self._error_buffer:
err = self._error_buffer[0]
self._error_buffer = []
raise err
def _wrap_and_catch_errors(target, error_buffer, *args, **kwargs):
"""
This function is designed only to be used by
ErrorCapturingThread
It will run a target function in a try-except block
any errors caught are added to an empty list that
should be supplied as a keyword argument
Arguments:
* target - The target function to call
* error_buffer - An empty list that is used for returning
the exception from the thread
*args: The arguments for the target function
**kwargs: The Keyword arguments for the target function
"""
try:
target(*args, **kwargs)
except BaseException as e:
error_buffer.append(e)