diff --git a/src/openflexure_microscope_server/server.py b/src/openflexure_microscope_server/server.py index ae30e963..04dcf7db 100644 --- a/src/openflexure_microscope_server/server.py +++ b/src/openflexure_microscope_server/server.py @@ -11,6 +11,7 @@ from .things.system_control import SystemControlThing from .things.settings_manager import SettingsManager from .things.auto_recentre_stage import RecentringThing from .things.smart_scan import SmartScanThing, BackgroundDetectThing +from .things.stitching import Stitcher from .things.test import APITestThing from .serve_static_files import add_static_files from .logging import configure_logging, retrieve_log @@ -26,6 +27,7 @@ thing_server.add_thing(CameraStageMapper(), "/camera_stage_mapping/") thing_server.add_thing(SystemControlThing(), "/system_control/") thing_server.add_thing(SettingsManager(), "/settings/") thing_server.add_thing(SmartScanThing(), "/smart_scan/") +thing_server.add_thing(Stitcher("application/openflexure-stitching/.venv/bin/openflexure-stitch"), "/stitching/") thing_server.add_thing(BackgroundDetectThing(), "/background_detect/") thing_server.add_thing(APITestThing(), "/api_test/") try: diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index e97529aa..b68cfe66 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -301,21 +301,30 @@ DOWNLOADABLE_SCAN_FILES = ( class SmartScanThing(Thing): @property - def scan_folder_path(self) -> str: + def scans_folder_path(self) -> str: """This folder will hold all the scans we do.""" # TODO: This should be determined using sensible configuration. # If the working directory is `/var/openflexure` this will result # in scans being saved at `/var/openflexure/scans/` return "scans" + + def scan_folder_path(self, scan_name: Optional[str]=None): + """The path to the scan folder with a given name""" + if not scan_name: + if not self.latest_scan_name: + raise IOError("There is no latest scan to return") + scan_name = self.latest_scan_name + return os.path.join(self.scans_folder_path, scan_name) def new_scan_folder(self) -> str: """Create a new empty folder, into which we can save scan images""" - if not os.path.exists(self.scan_folder_path): - os.makedirs(self.scan_folder_path) + if not os.path.exists(self.scans_folder_path): + os.makedirs(self.scans_folder_path) for j in range(999999): - folder_path = os.path.join(self.scan_folder_path, f"scan_{j:06}") + folder_path = os.path.join(self.scans_folder_path, f"scan_{j:06}") if not os.path.exists(folder_path): os.makedirs(folder_path) + self.latest_scan_name = os.path.basename(folder_path) return folder_path raise FileExistsError("Could not create a new scan folder: all names in use!") @@ -583,8 +592,8 @@ class SmartScanThing(Thing): in the `images` folder. """ scans: list[ScanInfo] = [] - for f in os.listdir(self.scan_folder_path): - path = os.path.join(self.scan_folder_path, f) + for f in os.listdir(self.scans_folder_path): + path = os.path.join(self.scans_folder_path, f) if os.path.isdir(path): images_folder = os.path.join(path, "images") if os.path.isdir(images_folder): @@ -624,7 +633,7 @@ class SmartScanThing(Thing): raise HTTPException( 403, f"You may only download files named {DOWNLOADABLE_SCAN_FILES}" ) - path = os.path.join(self.scan_folder_path, scan_name, file) + path = os.path.join(self.scans_folder_path, scan_name, file) if not os.path.isfile(path): raise HTTPException(404, "File not found") return FileResponse(path) @@ -642,7 +651,7 @@ class SmartScanThing(Thing): This endpoint allows scans to be deleted from disk. """ - path = os.path.join(self.scan_folder_path, scan_name) + path = os.path.join(self.scans_folder_path, scan_name) if not os.path.isdir(path): print(f"can't find {path}") raise HTTPException(404, "Scan not found") diff --git a/src/openflexure_microscope_server/things/stitching.py b/src/openflexure_microscope_server/things/stitching.py new file mode 100644 index 00000000..9cb4d82c --- /dev/null +++ b/src/openflexure_microscope_server/things/stitching.py @@ -0,0 +1,72 @@ +import shutil +from typing import Mapping, Optional +import cv2 +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 scipy.stats import norm +import logging +from copy import deepcopy +from datetime import datetime +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 CancelHook, InvocationLogger, InvocationCancelledError +from labthings_fastapi.decorators import thing_action +from labthings_fastapi.outputs.blob import BlobOutput +from openflexure_microscope_server.things.autofocus import AutofocusThing +from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper +from openflexure_microscope_server.things.auto_recentre_stage import RecentringThing + +from .smart_scan import SmartScanThing + +SmartScanDep = raw_thing_dependency(SmartScanThing) + + +class JPEGBlob(BlobOutput): + media_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") + + @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""" + images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) + self.run_subprocess(logger, [self._script, "--stitching_mode", "only_stage_stitch", images_folder]) + return JPEGBlob.from_file(os.path.join(images_folder, "stitched_from_stage.jpg")) + + @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])