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:
commit
13f39d5303
9 changed files with 495 additions and 176 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
||||||
85
src/openflexure_microscope_server/things/stitching.py
Normal file
85
src/openflexure_microscope_server/things/stitching.py
Normal 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])
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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"](
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue