Merge branch 'integrated-stitching' into 'v3'

Run `openflexure-stitch` on the server

See merge request openflexure/openflexure-microscope-server!169
This commit is contained in:
Richard Bowman 2024-01-11 14:01:02 +00:00
commit 13f39d5303
9 changed files with 495 additions and 176 deletions

View file

@ -11,6 +11,7 @@ from .things.system_control import SystemControlThing
from .things.settings_manager import SettingsManager from .things.settings_manager import SettingsManager
from .things.auto_recentre_stage import RecentringThing from .things.auto_recentre_stage import RecentringThing
from .things.smart_scan import SmartScanThing, BackgroundDetectThing from .things.smart_scan import SmartScanThing, BackgroundDetectThing
from .things.stitching import Stitcher
from .things.test import APITestThing from .things.test import APITestThing
from .serve_static_files import add_static_files from .serve_static_files import add_static_files
from .logging import configure_logging, retrieve_log from .logging import configure_logging, retrieve_log
@ -25,7 +26,7 @@ thing_server.add_thing(AutofocusThing(), "/autofocus/")
thing_server.add_thing(CameraStageMapper(), "/camera_stage_mapping/") thing_server.add_thing(CameraStageMapper(), "/camera_stage_mapping/")
thing_server.add_thing(SystemControlThing(), "/system_control/") thing_server.add_thing(SystemControlThing(), "/system_control/")
thing_server.add_thing(SettingsManager(), "/settings/") thing_server.add_thing(SettingsManager(), "/settings/")
thing_server.add_thing(SmartScanThing(), "/smart_scan/") thing_server.add_thing(SmartScanThing("application/openflexure-stitching/.venv/bin/openflexure-stitch"), "/smart_scan/")
thing_server.add_thing(BackgroundDetectThing(), "/background_detect/") thing_server.add_thing(BackgroundDetectThing(), "/background_detect/")
thing_server.add_thing(APITestThing(), "/api_test/") thing_server.add_thing(APITestThing(), "/api_test/")
try: try:

View file

@ -1,4 +1,5 @@
import shutil import shutil
import threading
from typing import Mapping, Optional from typing import Mapping, Optional
import cv2 import cv2
from fastapi import HTTPException from fastapi import HTTPException
@ -9,14 +10,15 @@ import time
from PIL import Image from PIL import Image
from pydantic import BaseModel from pydantic import BaseModel
from scipy.stats import norm from scipy.stats import norm
import logging
from copy import deepcopy from copy import deepcopy
from datetime import datetime from datetime import datetime
from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, run
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError
from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint
from labthings_fastapi.outputs.blob import BlobOutput
from labthings_sangaboard import SangaboardThing from labthings_sangaboard import SangaboardThing
from labthings_picamera2.thing import StreamingPiCamera2 from labthings_picamera2.thing import StreamingPiCamera2
from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.autofocus import AutofocusThing
@ -279,11 +281,18 @@ class BackgroundDetectThing(Thing):
BackgroundDep = direct_thing_client_dependency(BackgroundDetectThing, "/background_detect/") BackgroundDep = direct_thing_client_dependency(BackgroundDetectThing, "/background_detect/")
class NotEnoughFreeSpaceError(IOError):
pass
def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None: def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None:
"""Raise an exception if we are running out of disk space""" """Raise an exception if we are running out of disk space"""
du = shutil.disk_usage(path) du = shutil.disk_usage(path)
if du.free < min_space: if du.free < min_space:
raise IOError(f"There is not enough free disk space to continue {du}") raise NotEnoughFreeSpaceError(
"There is not enough free disk space to continue."
f"(Required: {min_space}, {du})."
)
class ScanInfo(BaseModel): class ScanInfo(BaseModel):
@ -298,24 +307,47 @@ DOWNLOADABLE_SCAN_FILES = (
"images.zip", "images.zip",
) )
class JPEGBlob(BlobOutput):
media_type = "image/jpeg"
class SmartScanThing(Thing): class SmartScanThing(Thing):
def __init__(self, path_to_openflexure_stitch: str):
self._script = path_to_openflexure_stitch
self._preview_stitch_popen_lock = threading.Lock()
self._correlate_popen_lock = threading.Lock()
self._scan_lock = threading.Lock()
@property @property
def scan_folder_path(self) -> str: def scans_folder_path(self) -> str:
"""This folder will hold all the scans we do.""" """This folder will hold all the scans we do."""
# TODO: This should be determined using sensible configuration. # TODO: This should be determined using sensible configuration.
# If the working directory is `/var/openflexure` this will result # If the working directory is `/var/openflexure` this will result
# in scans being saved at `/var/openflexure/scans/` # in scans being saved at `/var/openflexure/scans/`
return "scans" return "scans"
_latest_scan_name = None
@thing_property
def latest_scan_name(self) -> Optional[str]:
"""The name of the last scan to be started."""
return self._latest_scan_name
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: def new_scan_folder(self) -> str:
"""Create a new empty folder, into which we can save scan images""" """Create a new empty folder, into which we can save scan images"""
if not os.path.exists(self.scan_folder_path): if not os.path.exists(self.scans_folder_path):
os.makedirs(self.scan_folder_path) os.makedirs(self.scans_folder_path)
for j in range(999999): 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): if not os.path.exists(folder_path):
os.makedirs(folder_path) os.makedirs(folder_path)
self._latest_scan_name = os.path.basename(folder_path)
return folder_path return folder_path
raise FileExistsError("Could not create a new scan folder: all names in use!") raise FileExistsError("Could not create a new scan folder: all names in use!")
@ -330,7 +362,6 @@ class SmartScanThing(Thing):
csm: CSMDep, csm: CSMDep,
background_detect: BackgroundDep, background_detect: BackgroundDep,
recentre: RecentreStage, recentre: RecentreStage,
sample_check
): ):
"""Move the stage to cover an area, taking images that can be tiled together. """Move the stage to cover an area, taking images that can be tiled together.
@ -343,82 +374,84 @@ class SmartScanThing(Thing):
* `overlap` is the fraction by which images should overlap, i.e. * `overlap` is the fraction by which images should overlap, i.e.
`0.3` means we will move by 70% of the field of view each time. `0.3` means we will move by 70% of the field of view each time.
""" """
self._scan_lock.acquire(timeout=0.1)
# Before anything else, check that we've got a background set
# It's annoying to have to wait to find out!
max_dist = self.max_range
if sample_check:
d = background_detect.background_distributions
if not d:
raise RuntimeError("Background is not set: you need to calibrate background detection.")
else:
logger.warning(
"This scan will run in a spiral from the starting point "
f"until you cancel it, or until it has moved by {max_dist} 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."
)
names = []
positions = []
# Record the starting position so we can move back there afterwards
starting_position = stage.position
r = cam.grab_jpeg()
arr = np.array(Image.open(r.open()))
if list(arr.shape[:2]) != [int(i) for i in csm.image_resolution]:
logger.error(
f"Images are, by default, {arr.shape[:2]}, but the CSM was "
f"calibrated at {csm.image_resolution}."
)
# Here, we calculate the x and y step size based on the desired overlap
# TODO: Consider using CSM calibration size instead
# TODO: generalise to have 2D displacements for x and y (as the
# camera and stage may not be aligned).
CSM = csm.image_to_stage_displacement_matrix
csm_calibration_width = csm.last_calibration["image_resolution"][1]
# TODO: this downsampling by two is to deal with a camera issue
img_width = int(arr.shape[1] / 2)
overlap = self.overlap
dx = int(np.abs(np.dot(np.array([0, arr.shape[1] * (1 - overlap)]), CSM)[0]))
dy = int(np.abs(np.dot(np.array([arr.shape[0] * (1 - overlap), 0]), CSM)[1]))
logger.info(f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}")
# construct a 2D scan path
path = [[stage.position["x"], stage.position["y"]]]
focused_path = [] # This holds a list of all points where focus succeeded
true_path = [] # This holds a list of all points visited
i = 0
ids = []
start_time = time.strftime("%H_%M_%S-%d_%m_%Y")
scan_path = self.new_scan_folder()
images_folder = os.path.join(scan_path, "images")
os.mkdir(images_folder)
raw_images_folder = os.path.join(scan_path, "raw_images")
os.mkdir(raw_images_folder)
logger.info(f"Saving images to {images_folder}")
try: try:
# Before anything else, check that we've got a background set
# It's annoying to have to wait to find out!
max_dist = self.max_range
if self.skip_background:
d = background_detect.background_distributions
if not d:
raise RuntimeError("Background is not set: you need to calibrate background detection.")
else:
logger.warning(
"This scan will run in a spiral from the starting point "
f"until you cancel it, or until it has moved by {max_dist} 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."
)
names = []
positions = []
# Record the starting position so we can move back there afterwards
starting_position = stage.position
r = cam.grab_jpeg()
arr = np.array(Image.open(r.open()))
if list(arr.shape[:2]) != [int(i) for i in csm.image_resolution]:
logger.error(
f"Images are, by default, {arr.shape[:2]}, but the CSM was "
f"calibrated at {csm.image_resolution}."
)
# Here, we calculate the x and y step size based on the desired overlap
# TODO: Consider using CSM calibration size instead
# TODO: generalise to have 2D displacements for x and y (as the
# camera and stage may not be aligned).
CSM = csm.image_to_stage_displacement_matrix
csm_calibration_width = csm.last_calibration["image_resolution"][1]
# TODO: this downsampling by two is to deal with a camera issue
img_width = int(arr.shape[1] / 2)
overlap = self.overlap
dx = int(np.abs(np.dot(np.array([0, arr.shape[1] * (1 - overlap)]), CSM)[0]))
dy = int(np.abs(np.dot(np.array([arr.shape[0] * (1 - overlap), 0]), CSM)[1]))
logger.info(f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}")
# construct a 2D scan path
path = [[stage.position["x"], stage.position["y"]]]
focused_path = [] # This holds a list of all points where focus succeeded
true_path = [] # This holds a list of all points visited
i = 0
ids = []
start_time = time.strftime("%H_%M_%S-%d_%m_%Y")
scan_folder = self.new_scan_folder()
images_folder = os.path.join(scan_folder, "images")
os.mkdir(images_folder)
raw_images_folder = os.path.join(images_folder, "raw")
os.mkdir(raw_images_folder)
logger.info(f"Saving images to {images_folder}")
# TODO: I think this is unnecessary, we do a looping autofocus at the first point in the loop, unless # TODO: I think this is unnecessary, we do a looping autofocus at the first point in the loop, unless
# it's flagged as background (which is a wierd case anyway) # it's flagged as background (which is a wierd case anyway)
recentre.looping_autofocus() recentre.looping_autofocus()
# move to each x-y position. in z, move to the height of the closest x-y position that successfully focused # move to each x-y position. in z, move to the height of the closest x-y position that successfully focused
while len(path) > 0: while len(path) > 0:
ensure_free_disk_space(scan_folder)
loc = [path[0][0], path[0][1], stage.position["z"]] loc = [path[0][0], path[0][1], stage.position["z"]]
path.remove(path[0]) path.remove(path[0])
# TODO: combine this with the move below for speed (I think this could just be "else") # TODO: combine this with the move below for speed (I think this could just be "else")
logger.info(f"Moving to {loc}")
stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=int(loc[2])) stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=int(loc[2]))
if len(focused_path) > 1: if len(focused_path) > 1:
@ -430,7 +463,7 @@ class SmartScanThing(Thing):
) )
# Check if the image is background # Check if the image is background
if sample_check: if self.skip_background:
image_is_sample = background_detect.image_is_sample() image_is_sample = background_detect.image_is_sample()
else: else:
image_is_sample = True image_is_sample = True
@ -501,7 +534,7 @@ class SmartScanThing(Thing):
width, height = img.size width, height = img.size
img = img.resize((int(width*0.5), int(height*0.5))) img = img.resize((int(width*0.5), int(height*0.5)))
logger.info(f"Saving {name} at {stage.position}") logger.info(f"Saving {name}")
img.save( img.save(
os.path.join(images_folder, name), os.path.join(images_folder, name),
exif=exif, exif=exif,
@ -511,6 +544,11 @@ class SmartScanThing(Thing):
positions.append(loc[:2]) positions.append(loc[:2])
names.append(name) names.append(name)
if not self.preview_stitch_running():
self.preview_stitch_start(images_folder)
if not self.correlate_running():
self.correlate_start(images_folder)
# add the current position to the list of all positions visited # add the current position to the list of all positions visited
true_path.append(loc) true_path.append(loc)
@ -533,17 +571,34 @@ class SmartScanThing(Thing):
break break
except InvocationCancelledError: except InvocationCancelledError:
logger.error("Stopping scan because it was cancelled.") logger.error("Stopping scan because it was cancelled.")
except IOError as e: except NotEnoughFreeSpaceError as e:
logger.exception( logger.exception(
f"Stopping scan because of an IOError (most likely a full disk): {e}", f"Stopping scan to avoid filling up the disk: {e}",
) )
raise e
except Exception as e:
logger.exception(
f"The scan stopped because of an error: {e}",
"We will attempt to stitch and archive the images acquired "
"so far."
)
raise e
finally: finally:
logger.info("Waiting for background processes to finish...")
self.preview_stitch_wait()
self.correlate_wait()
logger.info("Stitching final image (may take some time)...")
try:
self.stitch_scan(logger, os.path.basename(scan_folder))
except SubprocessError as e:
logger.exception(f"Stitching failed: {e}")
logger.info("Creating zip archive of images (may take some time)...") logger.info("Creating zip archive of images (may take some time)...")
shutil.make_archive( shutil.make_archive(
os.path.join(scan_path, "images"), "zip", images_folder os.path.join(scan_folder, "images"), "zip", images_folder
) )
logger.info("Returning to starting position.") logger.info("Returning to starting position.")
stage.move_absolute(**starting_position, block_cancellation=True) stage.move_absolute(**starting_position, block_cancellation=True)
self._scan_lock.release()
@thing_property @thing_property
def max_range(self) -> int: def max_range(self) -> int:
@ -554,6 +609,18 @@ class SmartScanThing(Thing):
def max_range(self, value: int) -> None: def max_range(self, value: int) -> None:
self.thing_settings["max_range"] = value self.thing_settings["max_range"] = 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 @thing_property
def autofocus_dz(self) -> int: def autofocus_dz(self) -> int:
"""The z distance to perform an autofocus""" """The z distance to perform an autofocus"""
@ -583,8 +650,10 @@ class SmartScanThing(Thing):
in the `images` folder. in the `images` folder.
""" """
scans: list[ScanInfo] = [] scans: list[ScanInfo] = []
for f in os.listdir(self.scan_folder_path): if not os.path.isdir(self.scans_folder_path):
path = os.path.join(self.scan_folder_path, f) return scans
for f in os.listdir(self.scans_folder_path):
path = os.path.join(self.scans_folder_path, f)
if os.path.isdir(path): if os.path.isdir(path):
images_folder = os.path.join(path, "images") images_folder = os.path.join(path, "images")
if os.path.isdir(images_folder): if os.path.isdir(images_folder):
@ -624,7 +693,7 @@ class SmartScanThing(Thing):
raise HTTPException( raise HTTPException(
403, f"You may only download files named {DOWNLOADABLE_SCAN_FILES}" 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): if not os.path.isfile(path):
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
return FileResponse(path) return FileResponse(path)
@ -642,7 +711,7 @@ class SmartScanThing(Thing):
This endpoint allows scans to be deleted from disk. 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): if not os.path.isdir(path):
print(f"can't find {path}") print(f"can't find {path}")
raise HTTPException(404, "Scan not found") raise HTTPException(404, "Scan not found")
@ -660,4 +729,108 @@ class SmartScanThing(Thing):
Use with extreme caution. Use with extreme caution.
""" """
for scan in self.scans: for scan in self.scans:
self.delete_scan(scan.name) self.delete_scan(scan.name)
def images_folder(self, scan_name: Optional[str]=None) -> str:
scan_folder = self.scan_folder_path(scan_name=scan_name)
return os.path.join(scan_folder, "images")
@property
def latest_preview_stitch_path(self):
"""The path of the latest preview stitched image"""
return os.path.join(self.images_folder(), "stitched_from_stage.jpg")
@thing_property
def latest_preview_stitch_time(self) -> Optional[datetime]:
"""The modification time of the latest preview image"""
fpath = self.latest_preview_stitch_path
if not os.path.exists(fpath):
return None
else:
return os.path.getmtime(fpath)
@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.
"""
path = self.latest_preview_stitch_path
if not os.path.isfile(path):
raise HTTPException(404, "File not found")
return FileResponse(path)
_preview_stitch_popen = None
def preview_stitch_start(self, images_folder: str) -> None:
"""Start stitching a preview of the scan in a subprocess"""
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(
[self._script, "--stitching_mode", "only_stage_stitch", images_folder]
)
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
def preview_stitch_wait(self):
if self.preview_stitch_running():
with self._preview_stitch_popen_lock:
self._preview_stitch_popen.wait()
_correlate_popen = None
def correlate_start(self, images_folder: str) -> None:
"""Start stitching a preview of the scan in a subprocess"""
if self.correlate_running():
raise RuntimeError("Only one subprocess is allowed at a time")
with self._correlate_popen_lock:
self._correlate_popen = Popen(
[self._script, "--stitching_mode", "only_correlate", images_folder]
)
def correlate_running(self) -> bool:
"""Whether there is a preview stitch running in a subprocess"""
with self._correlate_popen_lock:
if self._correlate_popen is None:
return False
if self._correlate_popen.poll() is None:
return True
return False
def correlate_wait(self):
if self.correlate_running():
with self._correlate_popen_lock:
self._correlate_popen.wait()
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
@thing_action
def stitch_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, downsample: float=1.0) -> None:
"""Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(scan_name=scan_name)
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", images_folder])

View file

@ -0,0 +1,85 @@
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 BlobOutput
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")
@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

@ -1,5 +1,5 @@
<template> <template>
<div ref="logContainer" class="log-container"> <div ref="logContainer" class="log-container uk-margin-left uk-margin-right uk-margin">
<div v-if="log"> <div v-if="log">
<div v-for="(item, index) in log" :key="`log_entry_${index}`"> <div v-for="(item, index) in log" :key="`log_entry_${index}`">
{{ item.message }} {{ item.message }}
@ -57,5 +57,8 @@ export default {
position: relative; position: relative;
overflow-y: auto; overflow-y: auto;
overflow-x: auto; overflow-x: auto;
background-color: white;
padding: 0.5em;
border: 1px solid black;
} }
</style> </style>

View file

@ -235,18 +235,18 @@ export default {
); );
if (response.status == "completed") { if (response.status == "completed") {
this.$emit("response", response); this.$emit("response", response);
this.$emit("completed", response.output);
} else if (response.status == "cancelled") { } else if (response.status == "cancelled") {
this.$emit("cancelled", response); this.$emit("cancelled", response);
this.modalNotify(`The action '${this.submitLabel}' was cancelled.`); this.modalNotify(`The action '${this.submitLabel}' was cancelled.`);
} }
this.$emit("finished");
} catch (error) { } catch (error) {
this.$emit("error", error | Error("Unknown error")); this.$emit("error", error | Error("Unknown error"));
this.$emit("finished");
} finally { } finally {
// Reset taskRunning and taskId // Reset taskRunning and taskId
this.taskRunning = false; this.taskRunning = false;
this.taskStarted = false; this.taskStarted = false;
this.$emit("finished");
// Update the form data if we're self-updating // Update the form data if we're self-updating
if (this.selfUpdate) { if (this.selfUpdate) {
this.getFormData(); this.getFormData();
@ -300,19 +300,12 @@ export default {
return new Promise(checkCondition); return new Promise(checkCondition);
}, },
pollProgress: function() {
axios.get(this.taskUrl).then(response => {
this.progress = response.data.progress;
});
},
hideModal() { hideModal() {
UIkit.modal(this.$refs.statusModal).hide(); UIkit.modal(this.$refs.statusModal).hide();
}, },
terminateTask: function() { terminateTask: function() {
console.log(`deleting task at ${this.taskUrl}`); axios.delete(this.taskUrl, { baseURL: this.$store.getters.baseUri });
axios.delete(this.taskUrl);
} }
} }
}; };

View file

@ -1,57 +1,79 @@
<template> <template>
<div> <div>
<label class="uk-form-label">{{ label }}</label> <label v-if="dataType == 'number'" class="uk-form-label">{{ label }}
<div v-if="dataType == 'number'" class="input-and-buttons-container"> <div class="input-and-buttons-container">
<input <input
v-model="value" v-model="value"
class="uk-form-small numeric-setting-line-input" class="uk-form-small numeric-setting-line-input"
type="number" type="number"
@focusin="focusIn" @focusin="focusIn"
@focusout="focusOut" @focusout="focusOut"
@keydown="keyDown" @keydown="keyDown"
/> />
<a class="button-next-to-input" @click="readProperty">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</label>
<div v-if="dataType == 'boolean'" class="input-and-buttons-container">
<label class="uk-form-label numeric-setting-line-input">
<input
ref="checkbox"
v-model="value"
class="uk-checkbox"
type="checkbox"
@change="writeProperty"
/>
{{ label }}
</label>
<a class="button-next-to-input" @click="readProperty"> <a class="button-next-to-input" @click="readProperty">
<span class="material-symbols-outlined">refresh</span> <span class="material-symbols-outlined">refresh</span>
</a> </a>
</div> </div>
<div v-if="dataType == 'number_array'" class="input-and-buttons-container"> <label v-if="dataType == 'number_array'" class="uk-form-label">{{ label }}
<input <div class="input-and-buttons-container">
v-for="i in valueLength" <input
:key="i" v-for="i in valueLength"
v-model="value[i - 1]" :key="i"
class="uk-form-small numeric-setting-line-input" v-model="value[i - 1]"
type="number" class="uk-form-small numeric-setting-line-input"
@focusin="focusIn" type="number"
@focusout="focusOut" @focusin="focusIn"
@keydown="keyDown" @focusout="focusOut"
/> @keydown="keyDown"
<a class="button-next-to-input" @click="readProperty"> />
<span class="material-symbols-outlined">refresh</span> <a class="button-next-to-input" @click="readProperty">
</a> <span class="material-symbols-outlined">refresh</span>
</div> </a>
<div v-if="dataType == 'number_object'" class="input-and-buttons-container"> </div>
<input </label>
v-for="(_v, key) in value" <label v-if="dataType == 'number_object'" class="uk-form-label">{{ label }}
:key="key" <div class="input-and-buttons-container">
v-model="value[key]" <input
class="uk-form-small numeric-setting-line-input" v-for="(_v, key) in value"
type="number" :key="key"
@focusin="focusIn" v-model="value[key]"
@focusout="focusOut" class="uk-form-small numeric-setting-line-input"
@keydown="keyDown" type="number"
/> @focusin="focusIn"
<a class="button-next-to-input" @click="readProperty"> @focusout="focusOut"
<span class="material-symbols-outlined">refresh</span> @keydown="keyDown"
</a> />
</div> <a class="button-next-to-input" @click="readProperty">
<div v-if="dataType == 'other'" class="input-and-buttons-container"> <span class="material-symbols-outlined">refresh</span>
<input </a>
:value="value" </div>
class="uk-form-small numeric-setting-line-input" </label>
type="text" <label v-if="dataType == 'other'" class="uk-form-label">{{ label }}
disabled="true" <div class="input-and-buttons-container">
/> <input
</div> :value="value"
class="uk-form-small numeric-setting-line-input"
type="text"
disabled="true"
/>
</div>
</label>
</div> </div>
</template> </template>
@ -129,6 +151,9 @@ export default {
} }
} }
} }
if (prop.type == "boolean") {
return "boolean";
}
if (prop.type == "object") { if (prop.type == "object") {
let numeric = true; let numeric = true;
for (let key in prop.properties) { for (let key in prop.properties) {
@ -173,6 +198,7 @@ export default {
writeProperty: async function() { writeProperty: async function() {
try { try {
let requestedValue = this.value; let requestedValue = this.value;
console.log("writing", requestedValue);
await this.writeThingProperty( await this.writeThingProperty(
this.thingName, this.thingName,
this.propertyName, this.propertyName,
@ -195,6 +221,12 @@ export default {
this.modalError(error); // Let mixin handle error this.modalError(error); // Let mixin handle error
} }
}, },
checkboxUpdated: function() {
if (this.value != this.$refs.checkbox.checked) {
this.value = this.$refs.checkbox.checked;
this.writeProperty();
}
},
focusIn: function(event) { focusIn: function(event) {
this.valueOnEnter = event.target.value; this.valueOnEnter = event.target.value;
}, },

View file

@ -135,6 +135,9 @@ export default {
methods: { methods: {
async updateScans() { async updateScans() {
let scans = await this.readThingProperty("smart_scan", "scans"); let scans = await this.readThingProperty("smart_scan", "scans");
if (!scans | scans.length == 0) {
return;
}
scans.forEach(scan => { scans.forEach(scan => {
scan.modified = Date.parse(scan.modified); scan.modified = Date.parse(scan.modified);
scan.created = Date.parse(scan.created); scan.created = Date.parse(scan.created);

View file

@ -16,20 +16,32 @@
property-name="max_range" property-name="max_range"
label="Maximum Distance (steps)" label="Maximum Distance (steps)"
/> />
<div class="uk-margin"> </div>
<propertyControl <div class="uk-margin">
thing-name="smart_scan" <propertyControl
property-name="autofocus_dz" thing-name="smart_scan"
label="Autofocus range (steps)" property-name="autofocus_dz"
/> label="Autofocus range (steps)"
</div> />
<div class="uk-margin"> </div>
<propertyControl <div class="uk-margin">
thing-name="smart_scan" <propertyControl
property-name="overlap" thing-name="smart_scan"
label="Image overlap (0-1)" property-name="overlap"
/> label="Image overlap (0-1)"
</div> />
</div>
</div>
</li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Scan Settings</a>
<div class="uk-accordion-content">
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="skip_background"
label="Detect and skip empty fields"
/>
</div> </div>
</div> </div>
</li> </li>
@ -40,19 +52,7 @@
:submit-url="smartScanUri" :submit-url="smartScanUri"
submit-label="Start smart scan" submit-label="Start smart scan"
:can-terminate="true" :can-terminate="true"
:submit-data="{ sample_check: true }" @taskStarted="startScanning"
@taskStarted="scanning = true"
@update:taskStatus="taskStatus = $event"
@update:progress="progress = $event"
@update:log="log = $event"
/>
</div>
<div class="uk-margin">
<taskSubmitter
:submit-url="smartScanUri"
submit-label="Start manual scan"
:can-terminate="true"
:submit-data="{ sample_check: false }"
@update:taskStatus="taskStatus = $event" @update:taskStatus="taskStatus = $event"
@update:progress="progress = $event" @update:progress="progress = $event"
@update:log="log = $event" @update:log="log = $event"
@ -60,6 +60,7 @@
</div> </div>
</div> </div>
<div v-show="scanning"> <div v-show="scanning">
<mini-stream-display v-if="displayImageOnRight" />
<action-log-display <action-log-display
id="log-display" id="log-display"
:log="log" :log="log"
@ -78,14 +79,15 @@
v-if="!cancellable" v-if="!cancellable"
type="button" type="button"
class="uk-button uk-button-danger uk-margin-remove uk-float-right uk-width-1-1" class="uk-button uk-button-danger uk-margin-remove uk-float-right uk-width-1-1"
@click="scanning = false" @click="scanning = false; lastStitchedImage=null;"
> >
Close Close
</button> </button>
</div> </div>
</div> </div>
<div class="view-component uk-width-expand"> <div class="view-component uk-width-expand">
<streamDisplay /> <img v-if="displayImageOnRight" :src="lastStitchedImage" id="last-stitched-image"/>
<streamDisplay v-else />
</div> </div>
</div> </div>
</template> </template>
@ -96,6 +98,7 @@ import taskSubmitter from "../genericComponents/taskSubmitter";
import propertyControl from "../labThingsComponents/propertyControl.vue"; import propertyControl from "../labThingsComponents/propertyControl.vue";
import actionLogDisplay from "../genericComponents/actionLogDisplay.vue"; import actionLogDisplay from "../genericComponents/actionLogDisplay.vue";
import actionProgressBar from "../genericComponents/actionProgressBar.vue"; import actionProgressBar from "../genericComponents/actionProgressBar.vue";
import MiniStreamDisplay from '../genericComponents/miniStreamDisplay.vue';
export default { export default {
name: "SlideScanContent", name: "SlideScanContent",
@ -105,7 +108,8 @@ export default {
taskSubmitter, taskSubmitter,
propertyControl, propertyControl,
actionLogDisplay, actionLogDisplay,
actionProgressBar actionProgressBar,
MiniStreamDisplay
}, },
data() { data() {
@ -113,8 +117,11 @@ export default {
lastScanName: null, lastScanName: null,
scanning: false, scanning: false,
taskStatus: "pending", taskStatus: "pending",
correlateStatus: "",
stitchFromStageStatus: "",
progress: null, progress: null,
log: [] log: [],
lastStitchedImage: null,
}; };
}, },
@ -127,6 +134,9 @@ export default {
}, },
cancellable() { cancellable() {
return (this.taskStatus == "running") | (this.taskStatus == "pending"); return (this.taskStatus == "running") | (this.taskStatus == "pending");
},
displayImageOnRight() {
return this.scanning & this.lastStitchedImage !== null;
} }
}, },
@ -134,6 +144,23 @@ export default {
onScanError: function(error) { onScanError: function(error) {
this.scanRunning = false; this.scanRunning = false;
this.modalError(error); this.modalError(error);
},
correlateCurrentScan() {
},
startScanning() {
this.lastStitchedImage = null;
this.scanning = true;
setTimeout(this.pollScan, 1000);
},
async pollScan() {
if (this.cancellable) { // while the scan is running
let mtime = await this.readThingProperty("smart_scan", "latest_preview_stitch_time");
if (mtime !== null) {
this.lastStitchedImage = `${this.$store.getters.baseUri}/smart_scan/latest_preview_stitch.jpg?t=${mtime}`;
}
setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped
}
} }
} }
}; };

View file

@ -63,11 +63,13 @@ Vue.mixin({
"writeproperty", "writeproperty",
false false
); );
try { // `false` fails because axios somehow eats it!
await axios.put(url, value); // Other values should not be stringified or pydantic
} catch (error) { // can't parse them.
this.modalError(error); if (value===false | value===true) {
value=JSON.stringify(value);
} }
await axios.put(url, value);
}, },
thingActionUrl(thing, action) { thingActionUrl(thing, action) {
let url = this.$store.getters["wot/thingActionUrl"]( let url = this.$store.getters["wot/thingActionUrl"](